2155 lines
73 KiB
Rust
2155 lines
73 KiB
Rust
mod agent;
|
|
mod api_key;
|
|
mod auth;
|
|
mod observability;
|
|
mod operation;
|
|
mod secret;
|
|
mod upstream;
|
|
mod workspace;
|
|
mod yaml_import;
|
|
|
|
use crank_core::{
|
|
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, HttpMethod,
|
|
InvitationId, InvitationToken, InvocationLog, InvocationLogId, MembershipRole, OperationId,
|
|
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Secret, SecretId,
|
|
SecretVersion, Target, UsageRollup, User, UserId, UserSessionId, Workspace, WorkspaceId,
|
|
};
|
|
use serde::{Serialize, de::DeserializeOwned};
|
|
use serde_json::Value;
|
|
use sqlx::{
|
|
PgPool, Postgres, Row, Transaction,
|
|
postgres::{PgConnectOptions, PgPoolOptions, PgRow},
|
|
types::Json,
|
|
};
|
|
use std::{collections::BTreeMap, env, time::Duration};
|
|
use thiserror::Error;
|
|
use time::OffsetDateTime;
|
|
|
|
use crate::{
|
|
error::RegistryError,
|
|
migrations,
|
|
model::{
|
|
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentDraftVersionRequest,
|
|
CreateAgentRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
|
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
|
|
CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorMetadata, InvitationRecord,
|
|
InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
|
|
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
|
|
PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool,
|
|
RegistryOperation, RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest,
|
|
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest,
|
|
SecretRecord, SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest,
|
|
UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary,
|
|
UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream,
|
|
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
|
},
|
|
};
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct PostgresRegistry {
|
|
pool: PgPool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct PostgresPoolConfig {
|
|
pub max_connections: u32,
|
|
pub min_connections: u32,
|
|
pub acquire_timeout_ms: u64,
|
|
pub idle_timeout_ms: u64,
|
|
pub max_lifetime_ms: u64,
|
|
}
|
|
|
|
#[derive(Debug, Error, PartialEq, Eq)]
|
|
pub enum PostgresPoolConfigError {
|
|
#[error("invalid postgres pool setting {name}={value}")]
|
|
InvalidValue { name: &'static str, value: String },
|
|
#[error("POSTGRES_MAX_CONNECTIONS must be greater than zero")]
|
|
ZeroMaxConnections,
|
|
#[error("POSTGRES_MIN_CONNECTIONS must not exceed POSTGRES_MAX_CONNECTIONS")]
|
|
MinConnectionsExceedMax,
|
|
}
|
|
|
|
impl Default for PostgresPoolConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
max_connections: 20,
|
|
min_connections: 2,
|
|
acquire_timeout_ms: 5_000,
|
|
idle_timeout_ms: 600_000,
|
|
max_lifetime_ms: 1_800_000,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PostgresPoolConfig {
|
|
pub fn from_env() -> Result<Self, PostgresPoolConfigError> {
|
|
Self::from_vars(env::vars())
|
|
}
|
|
|
|
fn from_vars<I, K, V>(vars: I) -> Result<Self, PostgresPoolConfigError>
|
|
where
|
|
I: IntoIterator<Item = (K, V)>,
|
|
K: AsRef<str>,
|
|
V: AsRef<str>,
|
|
{
|
|
let vars = vars
|
|
.into_iter()
|
|
.map(|(name, value)| (name.as_ref().to_owned(), value.as_ref().to_owned()))
|
|
.collect::<BTreeMap<_, _>>();
|
|
let defaults = Self::default();
|
|
let config = Self {
|
|
max_connections: parse_u32_setting(
|
|
&vars,
|
|
"POSTGRES_MAX_CONNECTIONS",
|
|
defaults.max_connections,
|
|
)?,
|
|
min_connections: parse_u32_setting(
|
|
&vars,
|
|
"POSTGRES_MIN_CONNECTIONS",
|
|
defaults.min_connections,
|
|
)?,
|
|
acquire_timeout_ms: parse_u64_setting(
|
|
&vars,
|
|
"POSTGRES_ACQUIRE_TIMEOUT_MS",
|
|
defaults.acquire_timeout_ms,
|
|
)?,
|
|
idle_timeout_ms: parse_u64_setting(
|
|
&vars,
|
|
"POSTGRES_IDLE_TIMEOUT_MS",
|
|
defaults.idle_timeout_ms,
|
|
)?,
|
|
max_lifetime_ms: parse_u64_setting(
|
|
&vars,
|
|
"POSTGRES_MAX_LIFETIME_MS",
|
|
defaults.max_lifetime_ms,
|
|
)?,
|
|
};
|
|
|
|
config.validate()?;
|
|
Ok(config)
|
|
}
|
|
|
|
fn validate(self) -> Result<Self, PostgresPoolConfigError> {
|
|
if self.max_connections == 0 {
|
|
return Err(PostgresPoolConfigError::ZeroMaxConnections);
|
|
}
|
|
if self.min_connections > self.max_connections {
|
|
return Err(PostgresPoolConfigError::MinConnectionsExceedMax);
|
|
}
|
|
|
|
Ok(self)
|
|
}
|
|
}
|
|
|
|
impl PostgresRegistry {
|
|
pub async fn connect(database_url: &str) -> Result<Self, RegistryError> {
|
|
Self::connect_in_schema(database_url, None).await
|
|
}
|
|
|
|
pub async fn connect_with_options(
|
|
connect_options: PgConnectOptions,
|
|
) -> Result<Self, RegistryError> {
|
|
Self::connect_with_options_and_pool_config(connect_options, PostgresPoolConfig::default())
|
|
.await
|
|
}
|
|
|
|
pub async fn connect_with_options_and_pool_config(
|
|
connect_options: PgConnectOptions,
|
|
pool_config: PostgresPoolConfig,
|
|
) -> Result<Self, RegistryError> {
|
|
let pool = PgPoolOptions::new()
|
|
.max_connections(pool_config.max_connections)
|
|
.min_connections(pool_config.min_connections)
|
|
.acquire_timeout(Duration::from_millis(pool_config.acquire_timeout_ms))
|
|
.idle_timeout(Duration::from_millis(pool_config.idle_timeout_ms))
|
|
.max_lifetime(Duration::from_millis(pool_config.max_lifetime_ms))
|
|
.connect_with(connect_options)
|
|
.await?;
|
|
migrations::apply_postgres(&pool).await?;
|
|
Ok(Self { pool })
|
|
}
|
|
|
|
pub fn pool(&self) -> &PgPool {
|
|
&self.pool
|
|
}
|
|
|
|
pub async fn migrate(&self) -> Result<(), RegistryError> {
|
|
migrations::apply_postgres(&self.pool).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn connect_in_schema(
|
|
database_url: &str,
|
|
schema: Option<&str>,
|
|
) -> Result<Self, RegistryError> {
|
|
let pool = PgPoolOptions::new()
|
|
.min_connections(0)
|
|
.max_connections(1)
|
|
.idle_timeout(std::time::Duration::from_secs(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)
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
fn parse_u32_setting(
|
|
vars: &BTreeMap<String, String>,
|
|
name: &'static str,
|
|
default: u32,
|
|
) -> Result<u32, PostgresPoolConfigError> {
|
|
vars.get(name)
|
|
.map(|value| {
|
|
value
|
|
.parse::<u32>()
|
|
.map_err(|_| PostgresPoolConfigError::InvalidValue {
|
|
name,
|
|
value: value.clone(),
|
|
})
|
|
})
|
|
.transpose()
|
|
.map(|value| value.unwrap_or(default))
|
|
}
|
|
|
|
fn parse_u64_setting(
|
|
vars: &BTreeMap<String, String>,
|
|
name: &'static str,
|
|
default: u64,
|
|
) -> Result<u64, PostgresPoolConfigError> {
|
|
vars.get(name)
|
|
.map(|value| {
|
|
value
|
|
.parse::<u64>()
|
|
.map_err(|_| PostgresPoolConfigError::InvalidValue {
|
|
name,
|
|
value: value.clone(),
|
|
})
|
|
})
|
|
.transpose()
|
|
.map(|value| value.unwrap_or(default))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod pool_config_tests {
|
|
use super::{PostgresPoolConfig, PostgresPoolConfigError};
|
|
|
|
#[test]
|
|
fn pool_config_uses_explicit_defaults() {
|
|
let config = PostgresPoolConfig::from_vars(std::iter::empty::<(&str, &str)>()).unwrap();
|
|
|
|
assert_eq!(
|
|
config,
|
|
PostgresPoolConfig {
|
|
max_connections: 20,
|
|
min_connections: 2,
|
|
acquire_timeout_ms: 5_000,
|
|
idle_timeout_ms: 600_000,
|
|
max_lifetime_ms: 1_800_000,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_config_parses_overrides() {
|
|
let config = PostgresPoolConfig::from_vars([
|
|
("POSTGRES_MAX_CONNECTIONS", "32"),
|
|
("POSTGRES_MIN_CONNECTIONS", "4"),
|
|
("POSTGRES_ACQUIRE_TIMEOUT_MS", "7000"),
|
|
("POSTGRES_IDLE_TIMEOUT_MS", "900000"),
|
|
("POSTGRES_MAX_LIFETIME_MS", "3600000"),
|
|
])
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
config,
|
|
PostgresPoolConfig {
|
|
max_connections: 32,
|
|
min_connections: 4,
|
|
acquire_timeout_ms: 7_000,
|
|
idle_timeout_ms: 900_000,
|
|
max_lifetime_ms: 3_600_000,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_config_rejects_invalid_numeric_value() {
|
|
let error =
|
|
PostgresPoolConfig::from_vars([("POSTGRES_MAX_CONNECTIONS", "abc")]).unwrap_err();
|
|
|
|
assert_eq!(
|
|
error,
|
|
PostgresPoolConfigError::InvalidValue {
|
|
name: "POSTGRES_MAX_CONNECTIONS",
|
|
value: "abc".to_owned(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_config_rejects_zero_max_connections() {
|
|
let error = PostgresPoolConfig::from_vars([("POSTGRES_MAX_CONNECTIONS", "0")]).unwrap_err();
|
|
|
|
assert_eq!(error, PostgresPoolConfigError::ZeroMaxConnections);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_config_rejects_min_connections_above_max() {
|
|
let error = PostgresPoolConfig::from_vars([
|
|
("POSTGRES_MAX_CONNECTIONS", "2"),
|
|
("POSTGRES_MIN_CONNECTIONS", "3"),
|
|
])
|
|
.unwrap_err();
|
|
|
|
assert_eq!(error, PostgresPoolConfigError::MinConnectionsExceedMax);
|
|
}
|
|
}
|
|
|
|
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,
|
|
wizard_state_json,
|
|
change_note,
|
|
created_at,
|
|
created_by
|
|
) values (
|
|
$1, $2, $3, $4, $5, $6, $7, $8,
|
|
$9, $10, $11, $12, $13, $14, $15, $16::timestamptz, $17
|
|
)",
|
|
)
|
|
.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(serialize_option_json_value(&snapshot.wizard_state)?.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.protocol != snapshot.protocol {
|
|
return Err(RegistryError::ImmutableOperationFieldChanged {
|
|
operation_id: snapshot.id.as_str().to_owned(),
|
|
field: "protocol",
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn build_user(
|
|
id: String,
|
|
email: String,
|
|
display_name: String,
|
|
status: String,
|
|
created_at: OffsetDateTime,
|
|
) -> Result<User, RegistryError> {
|
|
Ok(User {
|
|
id: UserId::new(id),
|
|
email,
|
|
display_name,
|
|
status: deserialize_enum_text(&status, "status")?,
|
|
created_at,
|
|
})
|
|
}
|
|
|
|
fn map_user_update_error(error: sqlx::Error, user_id: &UserId, email: &str) -> RegistryError {
|
|
match error {
|
|
sqlx::Error::RowNotFound => RegistryError::UserNotFound {
|
|
user_id: user_id.as_str().to_owned(),
|
|
},
|
|
sqlx::Error::Database(database_error)
|
|
if database_error.code().as_deref() == Some("23505") =>
|
|
{
|
|
RegistryError::UserEmailAlreadyExists {
|
|
email: email.to_owned(),
|
|
}
|
|
}
|
|
other => RegistryError::Storage(other),
|
|
}
|
|
}
|
|
|
|
fn map_invocation_log_record(row: &PgRow) -> Result<InvocationLogRecord, RegistryError> {
|
|
Ok(InvocationLogRecord {
|
|
log: InvocationLog {
|
|
id: InvocationLogId::new(row.try_get::<String, _>("id")?),
|
|
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
|
|
agent_id: row
|
|
.try_get::<Option<String>, _>("agent_id")?
|
|
.map(AgentId::new),
|
|
operation_id: OperationId::new(row.try_get::<String, _>("operation_id")?),
|
|
source: deserialize_enum_text(&row.try_get::<String, _>("source")?, "source")?,
|
|
level: deserialize_enum_text(&row.try_get::<String, _>("level")?, "level")?,
|
|
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
|
|
tool_name: row.try_get("tool_name")?,
|
|
message: row.try_get("message")?,
|
|
request_id: row.try_get("request_id")?,
|
|
status_code: match row.try_get::<Option<i32>, _>("status_code")? {
|
|
Some(value) => {
|
|
Some(
|
|
u16::try_from(value).map_err(|_| RegistryError::InvalidNumericValue {
|
|
field: "status_code",
|
|
value: i64::from(value),
|
|
})?,
|
|
)
|
|
}
|
|
None => None,
|
|
},
|
|
duration_ms: to_u64(row.try_get::<i64, _>("duration_ms")?, "duration_ms")?,
|
|
error_kind: row.try_get("error_kind")?,
|
|
request_preview: row.try_get::<Json<Value>, _>("request_preview_json")?.0,
|
|
response_preview: row.try_get::<Json<Value>, _>("response_preview_json")?.0,
|
|
created_at: row.try_get("created_at")?,
|
|
},
|
|
operation_name: row.try_get("operation_name")?,
|
|
operation_display_name: row.try_get("operation_display_name")?,
|
|
agent_slug: row.try_get("agent_slug")?,
|
|
agent_display_name: row.try_get("agent_display_name")?,
|
|
})
|
|
}
|
|
|
|
fn map_operation_usage_summary(row: &PgRow) -> Result<OperationUsageSummary, RegistryError> {
|
|
build_operation_usage_summary(
|
|
row.try_get("operation_id")?,
|
|
row.try_get("calls_today")?,
|
|
row.try_get("error_rate_pct")?,
|
|
row.try_get("avg_latency_ms")?,
|
|
)
|
|
}
|
|
|
|
fn target_summary(target: &Target) -> (String, String) {
|
|
match target {
|
|
Target::Rest(rest) => (
|
|
join_target_url(&rest.base_url, &rest.path_template),
|
|
match rest.method {
|
|
HttpMethod::Get => "GET",
|
|
HttpMethod::Post => "POST",
|
|
HttpMethod::Put => "PUT",
|
|
HttpMethod::Patch => "PATCH",
|
|
HttpMethod::Delete => "DELETE",
|
|
}
|
|
.to_owned(),
|
|
),
|
|
}
|
|
}
|
|
|
|
fn join_target_url(base: &str, path: &str) -> String {
|
|
if path.is_empty() {
|
|
return base.to_owned();
|
|
}
|
|
|
|
if path.starts_with("http://") || path.starts_with("https://") || path.starts_with("grpc://") {
|
|
return path.to_owned();
|
|
}
|
|
|
|
let trimmed_base = base.trim_end_matches('/');
|
|
let trimmed_path = path.trim_start_matches('/');
|
|
|
|
if trimmed_base.is_empty() {
|
|
format!("/{trimmed_path}")
|
|
} else if trimmed_path.is_empty() {
|
|
trimmed_base.to_owned()
|
|
} else {
|
|
format!("{trimmed_base}/{trimmed_path}")
|
|
}
|
|
}
|
|
|
|
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")?,
|
|
})
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn build_agent_summary(
|
|
id: String,
|
|
workspace_id: String,
|
|
slug: String,
|
|
display_name: String,
|
|
description: String,
|
|
status: String,
|
|
current_draft_version: i32,
|
|
latest_published_version: Option<i32>,
|
|
created_at: OffsetDateTime,
|
|
updated_at: OffsetDateTime,
|
|
published_at: Option<OffsetDateTime>,
|
|
) -> Result<AgentSummary, RegistryError> {
|
|
Ok(AgentSummary {
|
|
id: AgentId::new(id),
|
|
workspace_id: WorkspaceId::new(workspace_id),
|
|
slug,
|
|
display_name,
|
|
description,
|
|
status: deserialize_enum_text(&status, "status")?,
|
|
current_draft_version: from_db_version(current_draft_version, "current_draft_version")?,
|
|
latest_published_version: latest_published_version
|
|
.map(|value| from_db_version(value, "latest_published_version"))
|
|
.transpose()?,
|
|
created_at,
|
|
updated_at,
|
|
published_at,
|
|
})
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn build_operation_summary(
|
|
id: String,
|
|
workspace_id: String,
|
|
name: String,
|
|
display_name: String,
|
|
category: String,
|
|
protocol: String,
|
|
security_level: String,
|
|
target_json: Value,
|
|
status: String,
|
|
current_draft_version: i32,
|
|
latest_published_version: Option<i32>,
|
|
created_at: OffsetDateTime,
|
|
updated_at: OffsetDateTime,
|
|
published_at: Option<OffsetDateTime>,
|
|
) -> Result<OperationSummary, RegistryError> {
|
|
let target: Target = deserialize_json_value(target_json)?;
|
|
let (target_url, target_action) = target_summary(&target);
|
|
|
|
Ok(OperationSummary {
|
|
id: OperationId::new(id),
|
|
workspace_id: WorkspaceId::new(workspace_id),
|
|
name,
|
|
display_name,
|
|
category,
|
|
protocol: deserialize_enum_text(&protocol, "protocol")?,
|
|
security_level: deserialize_enum_text(&security_level, "security_level")?,
|
|
target_url,
|
|
target_action,
|
|
status: deserialize_enum_text(&status, "status")?,
|
|
current_draft_version: from_db_version(current_draft_version, "current_draft_version")?,
|
|
latest_published_version: latest_published_version
|
|
.map(|value| from_db_version(value, "latest_published_version"))
|
|
.transpose()?,
|
|
created_at,
|
|
updated_at,
|
|
published_at,
|
|
})
|
|
}
|
|
|
|
fn build_auth_profile(
|
|
id: String,
|
|
workspace_id: String,
|
|
name: String,
|
|
kind: String,
|
|
config_json: Value,
|
|
created_at: OffsetDateTime,
|
|
updated_at: OffsetDateTime,
|
|
) -> Result<AuthProfile, RegistryError> {
|
|
Ok(AuthProfile {
|
|
id: crank_core::AuthProfileId::new(id),
|
|
workspace_id: WorkspaceId::new(workspace_id),
|
|
name,
|
|
kind: deserialize_enum_text(&kind, "kind")?,
|
|
config: deserialize_json_value(config_json)?,
|
|
created_at,
|
|
updated_at,
|
|
})
|
|
}
|
|
|
|
fn build_operation_usage_summary(
|
|
operation_id: String,
|
|
calls_today: i64,
|
|
error_rate_pct: f64,
|
|
avg_latency_ms: i64,
|
|
) -> Result<OperationUsageSummary, RegistryError> {
|
|
Ok(OperationUsageSummary {
|
|
operation_id: OperationId::new(operation_id),
|
|
calls_today: to_u64(calls_today, "calls_today")?,
|
|
error_rate_pct,
|
|
avg_latency_ms: to_u64(avg_latency_ms, "avg_latency_ms")?,
|
|
})
|
|
}
|
|
|
|
fn build_operation_agent_ref(
|
|
operation_id: String,
|
|
agent_id: String,
|
|
agent_slug: String,
|
|
display_name: String,
|
|
) -> Result<OperationAgentRef, RegistryError> {
|
|
Ok(OperationAgentRef {
|
|
operation_id: OperationId::new(operation_id),
|
|
agent_id: AgentId::new(agent_id),
|
|
agent_slug,
|
|
display_name,
|
|
})
|
|
}
|
|
|
|
fn build_agent_version_record(
|
|
summary: &AgentSummary,
|
|
version: i32,
|
|
status: String,
|
|
instructions_json: Value,
|
|
tool_selection_policy_json: Value,
|
|
created_at: OffsetDateTime,
|
|
bindings: Vec<AgentOperationBinding>,
|
|
) -> Result<AgentVersionRecord, RegistryError> {
|
|
let version = from_db_version(version, "version")?;
|
|
let status = deserialize_enum_text(&status, "status")?;
|
|
|
|
Ok(AgentVersionRecord {
|
|
agent_id: summary.id.clone(),
|
|
workspace_id: summary.workspace_id.clone(),
|
|
version,
|
|
status,
|
|
created_at,
|
|
bindings,
|
|
snapshot: AgentVersion {
|
|
agent_id: summary.id.clone(),
|
|
version,
|
|
status,
|
|
instructions: instructions_json,
|
|
tool_selection_policy: tool_selection_policy_json,
|
|
created_at,
|
|
},
|
|
})
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn build_operation_version_record(
|
|
id: String,
|
|
workspace_id: String,
|
|
name: String,
|
|
display_name: String,
|
|
category: String,
|
|
protocol: String,
|
|
security_level: String,
|
|
operation_created_at: OffsetDateTime,
|
|
operation_updated_at: OffsetDateTime,
|
|
operation_published_at: Option<OffsetDateTime>,
|
|
version: i32,
|
|
status: String,
|
|
target_json: Value,
|
|
input_schema_json: Value,
|
|
output_schema_json: Value,
|
|
input_mapping_json: Value,
|
|
output_mapping_json: Value,
|
|
execution_config_json: Value,
|
|
tool_description_json: Value,
|
|
samples_json: Option<Value>,
|
|
generated_draft_json: Option<Value>,
|
|
config_export_json: Option<Value>,
|
|
wizard_state_json: Option<Value>,
|
|
change_note: Option<String>,
|
|
created_at: OffsetDateTime,
|
|
created_by: Option<String>,
|
|
) -> Result<OperationVersionRecord, RegistryError> {
|
|
let operation_id = OperationId::new(id);
|
|
let version = from_db_version(version, "version")?;
|
|
let status = deserialize_enum_text(&status, "status")?;
|
|
|
|
Ok(OperationVersionRecord {
|
|
operation_id: operation_id.clone(),
|
|
workspace_id: WorkspaceId::new(workspace_id),
|
|
version,
|
|
status,
|
|
change_note,
|
|
created_at,
|
|
created_by,
|
|
snapshot: RegistryOperation {
|
|
id: operation_id,
|
|
name,
|
|
display_name,
|
|
category,
|
|
protocol: deserialize_enum_text(&protocol, "protocol")?,
|
|
security_level: deserialize_enum_text(&security_level, "security_level")?,
|
|
status,
|
|
version,
|
|
target: deserialize_json_value(target_json)?,
|
|
input_schema: deserialize_json_value(input_schema_json)?,
|
|
output_schema: deserialize_json_value(output_schema_json)?,
|
|
input_mapping: deserialize_json_value(input_mapping_json)?,
|
|
output_mapping: deserialize_json_value(output_mapping_json)?,
|
|
execution_config: deserialize_json_value(execution_config_json)?,
|
|
tool_description: deserialize_json_value(tool_description_json)?,
|
|
samples: samples_json.map(deserialize_json_value).transpose()?,
|
|
generated_draft: generated_draft_json
|
|
.map(deserialize_json_value)
|
|
.transpose()?,
|
|
config_export: config_export_json.map(deserialize_json_value).transpose()?,
|
|
wizard_state: wizard_state_json.map(deserialize_json_value).transpose()?,
|
|
created_at: operation_created_at,
|
|
updated_at: operation_updated_at,
|
|
published_at: operation_published_at,
|
|
},
|
|
})
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn build_published_agent_tool(
|
|
workspace_id: String,
|
|
workspace_slug: String,
|
|
agent_id: String,
|
|
agent_slug: String,
|
|
tool_name: String,
|
|
tool_title: String,
|
|
tool_description: String,
|
|
operation: RegistryOperation,
|
|
) -> Result<PublishedAgentTool, RegistryError> {
|
|
Ok(PublishedAgentTool {
|
|
workspace_id: WorkspaceId::new(workspace_id),
|
|
workspace_slug,
|
|
agent_id: AgentId::new(agent_id),
|
|
agent_slug,
|
|
tool_name,
|
|
tool_title,
|
|
tool_description,
|
|
operation,
|
|
})
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn build_sample_metadata(
|
|
id: String,
|
|
operation_id: String,
|
|
version: i32,
|
|
sample_kind: String,
|
|
storage_ref: String,
|
|
content_type: String,
|
|
file_name: Option<String>,
|
|
created_at: OffsetDateTime,
|
|
) -> Result<OperationSampleMetadata, RegistryError> {
|
|
Ok(OperationSampleMetadata {
|
|
id: crank_core::SampleId::new(id),
|
|
operation_id: OperationId::new(operation_id),
|
|
version: from_db_version(version, "version")?,
|
|
sample_kind: deserialize_enum_text(&sample_kind, "sample_kind")?,
|
|
storage_ref,
|
|
content_type,
|
|
file_name,
|
|
created_at,
|
|
})
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn build_descriptor_metadata(
|
|
id: String,
|
|
operation_id: Option<String>,
|
|
version: Option<i32>,
|
|
descriptor_kind: String,
|
|
storage_ref: String,
|
|
source_name: Option<String>,
|
|
package_index: Option<Value>,
|
|
created_at: OffsetDateTime,
|
|
) -> Result<DescriptorMetadata, RegistryError> {
|
|
Ok(DescriptorMetadata {
|
|
id: crank_core::DescriptorId::new(id),
|
|
operation_id: operation_id.map(OperationId::new),
|
|
version: version
|
|
.map(|value| from_db_version(value, "version"))
|
|
.transpose()?,
|
|
descriptor_kind: deserialize_enum_text(&descriptor_kind, "descriptor_kind")?,
|
|
storage_ref,
|
|
source_name,
|
|
package_index,
|
|
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 map_usage_operation_breakdown(row: &PgRow) -> Result<UsageOperationBreakdown, RegistryError> {
|
|
Ok(UsageOperationBreakdown {
|
|
operation_id: OperationId::new(row.try_get::<String, _>("operation_id")?),
|
|
operation_name: row.try_get("operation_name")?,
|
|
operation_display_name: row.try_get("operation_display_name")?,
|
|
protocol: deserialize_enum_text(&row.try_get::<String, _>("protocol")?, "protocol")?,
|
|
calls_total: to_u64(row.try_get::<i64, _>("calls_total")?, "calls_total")?,
|
|
calls_error: to_u64(row.try_get::<i64, _>("calls_error")?, "calls_error")?,
|
|
p50_ms: to_u64(row.try_get::<i64, _>("p50_ms")?, "p50_ms")?,
|
|
p95_ms: to_u64(row.try_get::<i64, _>("p95_ms")?, "p95_ms")?,
|
|
p99_ms: to_u64(row.try_get::<i64, _>("p99_ms")?, "p99_ms")?,
|
|
})
|
|
}
|
|
|
|
fn map_usage_agent_breakdown(row: &PgRow) -> Result<UsageAgentBreakdown, RegistryError> {
|
|
Ok(UsageAgentBreakdown {
|
|
agent_id: AgentId::new(row.try_get::<String, _>("agent_id")?),
|
|
agent_slug: row.try_get("agent_slug")?,
|
|
agent_display_name: row.try_get("agent_display_name")?,
|
|
calls_total: to_u64(row.try_get::<i64, _>("calls_total")?, "calls_total")?,
|
|
calls_error: to_u64(row.try_get::<i64, _>("calls_error")?, "calls_error")?,
|
|
p50_ms: to_u64(row.try_get::<i64, _>("p50_ms")?, "p50_ms")?,
|
|
p95_ms: to_u64(row.try_get::<i64, _>("p95_ms")?, "p95_ms")?,
|
|
p99_ms: to_u64(row.try_get::<i64, _>("p99_ms")?, "p99_ms")?,
|
|
})
|
|
}
|
|
|
|
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),
|
|
})
|
|
}
|
|
|
|
fn to_u64(value: i64, field: &'static str) -> Result<u64, RegistryError> {
|
|
u64::try_from(value).map_err(|_| RegistryError::InvalidNumericValue { field, value })
|
|
}
|
|
|
|
fn usage_bucket_sql(bucket: crate::model::UsageBucket) -> &'static str {
|
|
match bucket {
|
|
crate::model::UsageBucket::Hour => "hour",
|
|
crate::model::UsageBucket::Day => "day",
|
|
crate::model::UsageBucket::Week => "week",
|
|
crate::model::UsageBucket::Month => "month",
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::{
|
|
collections::BTreeMap,
|
|
env,
|
|
time::{SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use crank_core::{
|
|
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApiKeyHeaderAuthConfig,
|
|
AuthConfig, AuthKind, AuthProfile, ConfigExport, ExecutionConfig, ExportMode,
|
|
GeneratedDraft, GeneratedDraftStatus, HttpMethod, InvocationLog, MembershipRole,
|
|
OperationId, OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId,
|
|
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples,
|
|
SecretId, Target, ToolDescription, ToolExample, User, UserId, UserSessionId, WizardState,
|
|
Workspace, WorkspaceId,
|
|
};
|
|
use crank_mapping::{MappingRule, MappingSet};
|
|
use crank_schema::{Schema, SchemaKind};
|
|
use serde_json::json;
|
|
use sqlx::{Executor, PgPool, postgres::PgPoolOptions};
|
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
|
|
|
use crate::{
|
|
PostgresRegistry, RegistryError,
|
|
model::{
|
|
CreateAgentRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
|
|
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
|
|
DescriptorKind, DescriptorMetadata, OperationSampleMetadata, PlatformApiKeyRecord,
|
|
PublishAgentRequest, PublishRequest, RegistryOperation, SampleKind,
|
|
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
|
WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
|
},
|
|
};
|
|
|
|
fn test_workspace_id() -> WorkspaceId {
|
|
WorkspaceId::new("ws_default")
|
|
}
|
|
|
|
fn timestamp(value: &str) -> OffsetDateTime {
|
|
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
|
}
|
|
|
|
#[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: ×tamp("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();
|
|
let published_list = registry.list_published_operations().await.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!(
|
|
versions[1]
|
|
.snapshot
|
|
.wizard_state
|
|
.as_ref()
|
|
.unwrap()
|
|
.test_input,
|
|
Some(json!({ "email": "test-v2@example.com" }))
|
|
);
|
|
assert_eq!(published.version, 2);
|
|
assert_eq!(
|
|
published.wizard_state.as_ref().unwrap().output_sample,
|
|
Some(json!({ "id": "lead_2" }))
|
|
);
|
|
assert!(published.is_published());
|
|
assert_eq!(published_list, vec![published.clone()]);
|
|
|
|
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 update_operation_draft_persists_optional_json_columns_as_sql_null() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = database.registry().await;
|
|
let mut operation = test_operation("op_rest_02b", 1, OperationStatus::Draft);
|
|
|
|
registry
|
|
.create_operation(&test_workspace_id(), &operation, None)
|
|
.await
|
|
.unwrap();
|
|
|
|
operation.generated_draft = None;
|
|
operation.samples = None;
|
|
operation.config_export = None;
|
|
operation.wizard_state = None;
|
|
operation.updated_at = timestamp("2026-03-25T12:34:00Z");
|
|
|
|
registry
|
|
.update_operation_draft(&test_workspace_id(), &operation)
|
|
.await
|
|
.unwrap();
|
|
|
|
let stored = registry
|
|
.get_operation_version(&test_workspace_id(), &operation.id, operation.version)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert_eq!(stored.snapshot.generated_draft, None);
|
|
assert_eq!(stored.snapshot.samples, None);
|
|
assert_eq!(stored.snapshot.config_export, None);
|
|
assert_eq!(stored.snapshot.wizard_state, None);
|
|
|
|
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_id: SecretId::new("secret_crank_api_key"),
|
|
}),
|
|
created_at: timestamp("2026-03-25T12:00:00Z"),
|
|
updated_at: timestamp("2026-03-25T12:00:00Z"),
|
|
};
|
|
|
|
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: timestamp("2026-03-25T12:01:00Z"),
|
|
};
|
|
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: timestamp("2026-03-25T12:02:00Z"),
|
|
};
|
|
|
|
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 lists_auth_profiles_referencing_secret() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = database.registry().await;
|
|
let primary_secret_id = SecretId::new("secret_primary");
|
|
let secondary_secret_id = SecretId::new("secret_secondary");
|
|
let profile = AuthProfile {
|
|
id: "auth_crank".into(),
|
|
workspace_id: test_workspace_id(),
|
|
name: "Crank basic auth".to_owned(),
|
|
kind: AuthKind::Basic,
|
|
config: AuthConfig::Basic(crank_core::BasicAuthConfig {
|
|
username_secret_id: primary_secret_id.clone(),
|
|
password_secret_id: secondary_secret_id.clone(),
|
|
}),
|
|
created_at: timestamp("2026-03-25T12:00:00Z"),
|
|
updated_at: timestamp("2026-03-25T12:00:00Z"),
|
|
};
|
|
|
|
registry
|
|
.save_auth_profile(SaveAuthProfileRequest {
|
|
workspace_id: &test_workspace_id(),
|
|
profile: &profile,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let profiles = registry
|
|
.list_auth_profiles_referencing_secret(&test_workspace_id(), &primary_secret_id)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(profiles, vec![profile]);
|
|
|
|
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: ×tamp("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: timestamp("2026-03-25T12:05:00Z"),
|
|
},
|
|
)
|
|
.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;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn manages_workspace_read_paths() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = database.registry().await;
|
|
let workspace = Workspace {
|
|
id: WorkspaceId::new("ws_extra_01"),
|
|
slug: "extra".to_owned(),
|
|
display_name: "Extra Workspace".to_owned(),
|
|
status: crank_core::WorkspaceStatus::Active,
|
|
settings: json!({"region":"eu"}),
|
|
created_at: timestamp("2026-03-25T12:00:00Z"),
|
|
updated_at: timestamp("2026-03-25T12:00:00Z"),
|
|
};
|
|
let mut user = User {
|
|
id: UserId::new("user_extra_01"),
|
|
email: "owner@example.com".to_owned(),
|
|
display_name: "Owner".to_owned(),
|
|
status: crank_core::UserStatus::Active,
|
|
created_at: timestamp("2026-03-25T11:00:00Z"),
|
|
};
|
|
|
|
registry
|
|
.create_workspace(CreateWorkspaceRequest {
|
|
workspace: &workspace,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let user_id = registry
|
|
.upsert_bootstrap_user(&user.email, &user.display_name, "hashed-password")
|
|
.await
|
|
.unwrap();
|
|
user.id = user_id;
|
|
registry
|
|
.ensure_membership(&workspace.id, &user.id, MembershipRole::Owner)
|
|
.await
|
|
.unwrap();
|
|
|
|
let all_workspaces = registry.list_workspaces().await.unwrap();
|
|
let user_workspaces = registry.list_workspaces_for_user(&user.id).await.unwrap();
|
|
let memberships = registry.list_memberships(&workspace.id).await.unwrap();
|
|
let loaded = registry
|
|
.get_workspace(&workspace.id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert!(
|
|
all_workspaces
|
|
.iter()
|
|
.any(|record| record.workspace == workspace)
|
|
);
|
|
assert_eq!(user_workspaces.len(), 1);
|
|
assert_eq!(user_workspaces[0].workspace, workspace);
|
|
assert_eq!(user_workspaces[0].role, MembershipRole::Owner);
|
|
assert_eq!(memberships.len(), 1);
|
|
assert_eq!(memberships[0].workspace_id, workspace.id);
|
|
assert_eq!(memberships[0].user.id, user.id);
|
|
assert_eq!(memberships[0].user.email, user.email);
|
|
assert_eq!(memberships[0].user.display_name, user.display_name);
|
|
assert_eq!(memberships[0].user.status, user.status);
|
|
assert_eq!(memberships[0].role, MembershipRole::Owner);
|
|
assert_eq!(loaded, WorkspaceRecord { workspace });
|
|
|
|
database.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn manages_user_profile_and_workspace_access_reads() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = database.registry().await;
|
|
let workspace = Workspace {
|
|
id: WorkspaceId::new("ws_profile_01"),
|
|
slug: "profile".to_owned(),
|
|
display_name: "Profile Workspace".to_owned(),
|
|
status: crank_core::WorkspaceStatus::Active,
|
|
settings: json!({}),
|
|
created_at: timestamp("2026-03-25T12:00:00Z"),
|
|
updated_at: timestamp("2026-03-25T12:00:00Z"),
|
|
};
|
|
let other_workspace = Workspace {
|
|
id: WorkspaceId::new("ws_profile_02"),
|
|
slug: "profile-other".to_owned(),
|
|
display_name: "Other Workspace".to_owned(),
|
|
status: crank_core::WorkspaceStatus::Active,
|
|
settings: json!({}),
|
|
created_at: timestamp("2026-03-25T12:01:00Z"),
|
|
updated_at: timestamp("2026-03-25T12:01:00Z"),
|
|
};
|
|
let user_id = registry
|
|
.upsert_bootstrap_user("profile@example.com", "Owner", "hashed-password")
|
|
.await
|
|
.unwrap();
|
|
|
|
registry
|
|
.create_workspace(CreateWorkspaceRequest {
|
|
workspace: &workspace,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.create_workspace(CreateWorkspaceRequest {
|
|
workspace: &other_workspace,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.ensure_membership(&workspace.id, &user_id, MembershipRole::Owner)
|
|
.await
|
|
.unwrap();
|
|
|
|
let updated = registry
|
|
.update_user_profile(&user_id, "updated@example.com", "Updated Owner")
|
|
.await
|
|
.unwrap();
|
|
let has_access = registry
|
|
.user_has_workspace_access(&user_id, &workspace.id)
|
|
.await
|
|
.unwrap();
|
|
let lacks_access = registry
|
|
.user_has_workspace_access(&user_id, &other_workspace.id)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(updated.id, user_id);
|
|
assert_eq!(updated.email, "updated@example.com");
|
|
assert_eq!(updated.display_name, "Updated Owner");
|
|
assert!(has_access);
|
|
assert!(!lacks_access);
|
|
assert!(updated.created_at.unix_timestamp() > 0);
|
|
|
|
database.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn creates_and_loads_user_sessions_with_typed_expiration() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = database.registry().await;
|
|
let workspace = Workspace {
|
|
id: WorkspaceId::new("ws_session_01"),
|
|
slug: "session".to_owned(),
|
|
display_name: "Session Workspace".to_owned(),
|
|
status: crank_core::WorkspaceStatus::Active,
|
|
settings: json!({}),
|
|
created_at: timestamp("2026-03-25T12:00:00Z"),
|
|
updated_at: timestamp("2026-03-25T12:00:00Z"),
|
|
};
|
|
let user_id = registry
|
|
.upsert_bootstrap_user("session@example.com", "Owner", "hashed-password")
|
|
.await
|
|
.unwrap();
|
|
let session_id = UserSessionId::new("sess_01");
|
|
let expires_at = timestamp("2030-04-06T12:05:00Z");
|
|
|
|
registry
|
|
.create_workspace(CreateWorkspaceRequest {
|
|
workspace: &workspace,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.ensure_membership(&workspace.id, &user_id, MembershipRole::Owner)
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.create_user_session(
|
|
&session_id,
|
|
&user_id,
|
|
Some(&workspace.id),
|
|
"secret-hash-01",
|
|
&expires_at,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let session = registry
|
|
.get_user_session(&session_id, "secret-hash-01")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert_eq!(session.session_id, session_id);
|
|
assert_eq!(session.current_workspace_id, Some(workspace.id.clone()));
|
|
assert_eq!(session.user.id, user_id);
|
|
assert!(session.user.created_at.unix_timestamp() > 0);
|
|
|
|
database.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn manages_platform_api_key_read_paths() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = database.registry().await;
|
|
let workspace = Workspace {
|
|
id: WorkspaceId::new("ws_keys_01"),
|
|
slug: "keys".to_owned(),
|
|
display_name: "Keys Workspace".to_owned(),
|
|
status: crank_core::WorkspaceStatus::Active,
|
|
settings: json!({}),
|
|
created_at: timestamp("2026-03-25T12:00:00Z"),
|
|
updated_at: timestamp("2026-03-25T12:00:00Z"),
|
|
};
|
|
let agent = test_agent("agent_keys_01", AgentStatus::Draft);
|
|
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
|
|
let api_key = PlatformApiKey {
|
|
id: PlatformApiKeyId::new("key_01"),
|
|
workspace_id: workspace.id.clone(),
|
|
agent_id: Some(agent.id.clone()),
|
|
name: "Primary".to_owned(),
|
|
prefix: "crk_live".to_owned(),
|
|
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
|
status: PlatformApiKeyStatus::Active,
|
|
created_at: timestamp("2026-03-25T12:01:00Z"),
|
|
last_used_at: None,
|
|
};
|
|
let secret_hash = "secret_hash_01";
|
|
|
|
registry
|
|
.create_workspace(CreateWorkspaceRequest {
|
|
workspace: &workspace,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.create_agent(CreateAgentRequest {
|
|
agent: &agent,
|
|
version: &version,
|
|
bindings: &[],
|
|
})
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.create_platform_api_key(CreatePlatformApiKeyRequest {
|
|
api_key: &api_key,
|
|
secret_hash,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let listed = registry
|
|
.list_platform_api_keys(&workspace.id)
|
|
.await
|
|
.unwrap();
|
|
let listed_for_agent = registry
|
|
.list_platform_api_keys_for_agent(&workspace.id, &agent.id)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
listed,
|
|
vec![PlatformApiKeyRecord {
|
|
api_key: api_key.clone()
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
listed_for_agent,
|
|
vec![PlatformApiKeyRecord {
|
|
api_key: api_key.clone()
|
|
}]
|
|
);
|
|
|
|
let resolved = registry
|
|
.get_platform_api_key_by_secret_for_agent_slug(
|
|
&workspace.slug,
|
|
&agent.slug,
|
|
secret_hash,
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(resolved.api_key, api_key);
|
|
|
|
registry
|
|
.touch_platform_api_key(
|
|
&workspace.id,
|
|
&PlatformApiKeyId::new("key_01"),
|
|
×tamp("2026-03-25T12:05:00Z"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let touched = registry
|
|
.list_platform_api_keys(&workspace.id)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
touched[0].api_key.last_used_at,
|
|
Some(timestamp("2026-03-25T12:05:00Z"))
|
|
);
|
|
|
|
database.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn manages_agent_read_paths() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = database.registry().await;
|
|
let operation = test_operation("op_agent_01", 1, OperationStatus::Draft);
|
|
let agent = test_agent("agent_01", AgentStatus::Draft);
|
|
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
|
|
let bindings = vec![AgentOperationBinding {
|
|
operation_id: operation.id.clone(),
|
|
operation_version: operation.version,
|
|
tool_name: "create_lead".to_owned(),
|
|
tool_title: "Create lead".to_owned(),
|
|
tool_description_override: Some("Creates CRM lead".to_owned()),
|
|
enabled: true,
|
|
}];
|
|
|
|
registry
|
|
.create_operation(&test_workspace_id(), &operation, None)
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.create_agent(CreateAgentRequest {
|
|
agent: &agent,
|
|
version: &version,
|
|
bindings: &bindings,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let listed = registry.list_agents(&test_workspace_id()).await.unwrap();
|
|
let summary = registry
|
|
.get_agent_summary(&test_workspace_id(), &agent.id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let loaded_version = registry
|
|
.get_agent_version(&test_workspace_id(), &agent.id, version.version)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert_eq!(listed, vec![summary.clone()]);
|
|
assert_eq!(summary.id, agent.id);
|
|
assert_eq!(summary.slug, agent.slug);
|
|
assert_eq!(summary.display_name, agent.display_name);
|
|
assert_eq!(summary.status, agent.status);
|
|
assert_eq!(loaded_version.agent_id, agent.id);
|
|
assert_eq!(loaded_version.version, version.version);
|
|
assert_eq!(loaded_version.status, version.status);
|
|
assert_eq!(loaded_version.snapshot, version);
|
|
assert_eq!(loaded_version.bindings, bindings);
|
|
|
|
database.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn manages_published_agent_tool_reads() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = database.registry().await;
|
|
let operation = test_operation("op_agent_pub_01", 1, OperationStatus::Draft);
|
|
let operation_v2 = test_operation("op_agent_pub_01", 2, OperationStatus::Draft);
|
|
let agent = test_agent("agent_pub_01", AgentStatus::Draft);
|
|
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
|
|
let bindings = vec![AgentOperationBinding {
|
|
operation_id: operation.id.clone(),
|
|
operation_version: operation_v2.version,
|
|
tool_name: "create_lead".to_owned(),
|
|
tool_title: "Create lead".to_owned(),
|
|
tool_description_override: Some("Creates CRM lead".to_owned()),
|
|
enabled: true,
|
|
}];
|
|
|
|
registry
|
|
.create_operation(&test_workspace_id(), &operation, None)
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.create_version(CreateVersionRequest {
|
|
workspace_id: &test_workspace_id(),
|
|
snapshot: &operation_v2,
|
|
change_note: Some("publishable"),
|
|
created_by: Some("alice"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.publish_operation(PublishRequest {
|
|
workspace_id: &test_workspace_id(),
|
|
operation_id: &operation.id,
|
|
version: operation_v2.version,
|
|
published_at: ×tamp("2026-03-25T12:10:00Z"),
|
|
published_by: Some("alice"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.create_agent(CreateAgentRequest {
|
|
agent: &agent,
|
|
version: &version,
|
|
bindings: &bindings,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.publish_agent(PublishAgentRequest {
|
|
workspace_id: &test_workspace_id(),
|
|
agent_id: &agent.id,
|
|
version: version.version,
|
|
published_at: ×tamp("2026-03-25T12:11:00Z"),
|
|
published_by: Some("alice"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let tools = registry
|
|
.get_published_agent_tools_by_slug("default", &agent.slug)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(tools.len(), 1);
|
|
assert_eq!(tools[0].workspace_id, test_workspace_id());
|
|
assert_eq!(tools[0].workspace_slug, "default");
|
|
assert_eq!(tools[0].agent_id, agent.id);
|
|
assert_eq!(tools[0].agent_slug, agent.slug);
|
|
assert_eq!(tools[0].tool_name, bindings[0].tool_name);
|
|
assert_eq!(tools[0].tool_title, bindings[0].tool_title);
|
|
assert_eq!(tools[0].tool_description, "Creates CRM lead");
|
|
assert_eq!(tools[0].operation.id, operation_v2.id);
|
|
assert_eq!(tools[0].operation.version, operation_v2.version);
|
|
assert_eq!(tools[0].operation.protocol, operation_v2.protocol);
|
|
assert!(tools[0].operation.is_published());
|
|
|
|
database.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn manages_operation_usage_and_agent_ref_reads() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = database.registry().await;
|
|
let operation = test_operation("op_usage_01", 1, OperationStatus::Draft);
|
|
let operation_v2 = test_operation("op_usage_01", 2, OperationStatus::Draft);
|
|
let agent = test_agent("agent_usage_01", AgentStatus::Draft);
|
|
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
|
|
let bindings = vec![AgentOperationBinding {
|
|
operation_id: operation.id.clone(),
|
|
operation_version: operation_v2.version,
|
|
tool_name: "create_lead".to_owned(),
|
|
tool_title: "Create lead".to_owned(),
|
|
tool_description_override: None,
|
|
enabled: true,
|
|
}];
|
|
|
|
registry
|
|
.create_operation(&test_workspace_id(), &operation, None)
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.create_version(CreateVersionRequest {
|
|
workspace_id: &test_workspace_id(),
|
|
snapshot: &operation_v2,
|
|
change_note: Some("publishable"),
|
|
created_by: Some("alice"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.publish_operation(PublishRequest {
|
|
workspace_id: &test_workspace_id(),
|
|
operation_id: &operation.id,
|
|
version: operation_v2.version,
|
|
published_at: ×tamp("2026-03-25T12:10:00Z"),
|
|
published_by: Some("alice"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.create_agent(CreateAgentRequest {
|
|
agent: &agent,
|
|
version: &version,
|
|
bindings: &bindings,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.publish_agent(PublishAgentRequest {
|
|
workspace_id: &test_workspace_id(),
|
|
agent_id: &agent.id,
|
|
version: version.version,
|
|
published_at: ×tamp("2026-03-25T12:11:00Z"),
|
|
published_by: Some("alice"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.create_invocation_log(CreateInvocationLogRequest {
|
|
log: &test_invocation_log(
|
|
"log_usage_ok",
|
|
&operation.id,
|
|
Some(agent.id.clone()),
|
|
crank_core::InvocationStatus::Ok,
|
|
120,
|
|
"2026-03-25T12:20:00Z",
|
|
),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.create_invocation_log(CreateInvocationLogRequest {
|
|
log: &test_invocation_log(
|
|
"log_usage_err",
|
|
&operation.id,
|
|
Some(agent.id.clone()),
|
|
crank_core::InvocationStatus::Error,
|
|
240,
|
|
"2026-03-25T12:21:00Z",
|
|
),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let has_bindings = registry
|
|
.has_published_agent_bindings_for_operation(&test_workspace_id(), &operation.id)
|
|
.await
|
|
.unwrap();
|
|
let agent_refs = registry
|
|
.list_operation_agent_refs(&test_workspace_id())
|
|
.await
|
|
.unwrap();
|
|
let usage = registry
|
|
.list_operation_usage_summaries(&test_workspace_id(), "2026-03-25T12:00:00Z")
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(has_bindings);
|
|
assert_eq!(agent_refs.len(), 1);
|
|
assert_eq!(agent_refs[0].operation_id, operation.id);
|
|
assert_eq!(agent_refs[0].agent_id, agent.id);
|
|
assert_eq!(agent_refs[0].agent_slug, agent.slug);
|
|
assert_eq!(agent_refs[0].display_name, agent.display_name);
|
|
assert_eq!(usage.len(), 1);
|
|
assert_eq!(usage[0].operation_id, operation.id);
|
|
assert_eq!(usage[0].calls_today, 2);
|
|
assert_eq!(usage[0].error_rate_pct, 50.0);
|
|
assert_eq!(usage[0].avg_latency_ms, 180);
|
|
|
|
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}"),
|
|
category: "general".to_owned(),
|
|
protocol: Protocol::Rest,
|
|
security_level: OperationSecurityLevel::Standard,
|
|
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())]),
|
|
response_cache: 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()),
|
|
}),
|
|
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,
|
|
}),
|
|
wizard_state: Some(WizardState {
|
|
input_sample: Some(json!({ "email": format!("lead-{version}@example.com") })),
|
|
output_sample: Some(json!({ "id": format!("lead_{version}") })),
|
|
test_input: Some(json!({ "email": format!("test-v{version}@example.com") })),
|
|
}),
|
|
created_at: timestamp("2026-03-25T11:58:00Z"),
|
|
updated_at: timestamp(&format!("2026-03-25T12:{version:02}:00Z")),
|
|
published_at: None,
|
|
}
|
|
}
|
|
fn test_agent(id: &str, status: AgentStatus) -> crank_core::Agent {
|
|
crank_core::Agent {
|
|
id: AgentId::new(id),
|
|
workspace_id: test_workspace_id(),
|
|
slug: format!("{id}_slug"),
|
|
display_name: format!("Display {id}"),
|
|
description: format!("Description {id}"),
|
|
status,
|
|
current_draft_version: 1,
|
|
latest_published_version: None,
|
|
created_at: timestamp("2026-03-25T11:58:00Z"),
|
|
updated_at: timestamp("2026-03-25T12:00:00Z"),
|
|
published_at: None,
|
|
}
|
|
}
|
|
|
|
fn test_agent_version(agent_id: &AgentId, version: u32, status: AgentStatus) -> AgentVersion {
|
|
AgentVersion {
|
|
agent_id: agent_id.clone(),
|
|
version,
|
|
status,
|
|
instructions: json!({
|
|
"system": "triage tickets",
|
|
"guardrails": ["don't mutate state"]
|
|
}),
|
|
tool_selection_policy: json!({
|
|
"mode": "allow_list",
|
|
"max_tools": 8
|
|
}),
|
|
created_at: timestamp("2026-03-25T12:00:00Z"),
|
|
}
|
|
}
|
|
|
|
fn test_invocation_log(
|
|
id: &str,
|
|
operation_id: &OperationId,
|
|
agent_id: Option<AgentId>,
|
|
status: crank_core::InvocationStatus,
|
|
duration_ms: u64,
|
|
created_at: &str,
|
|
) -> InvocationLog {
|
|
InvocationLog {
|
|
id: crank_core::InvocationLogId::new(id),
|
|
workspace_id: test_workspace_id(),
|
|
agent_id,
|
|
operation_id: operation_id.clone(),
|
|
source: crank_core::InvocationSource::AgentToolCall,
|
|
level: crank_core::InvocationLevel::Info,
|
|
status,
|
|
tool_name: "create_lead".to_owned(),
|
|
message: "invocation".to_owned(),
|
|
request_id: Some(format!("req_{id}")),
|
|
status_code: Some(200),
|
|
duration_ms,
|
|
error_kind: None,
|
|
request_preview: json!({"input":"value"}),
|
|
response_preview: json!({"ok":true}),
|
|
created_at: timestamp(created_at),
|
|
}
|
|
}
|
|
}
|