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::from_vars(env::vars()) } fn from_vars(vars: I) -> Result where I: IntoIterator, K: AsRef, V: AsRef, { let vars = vars .into_iter() .map(|(name, value)| (name.as_ref().to_owned(), value.as_ref().to_owned())) .collect::>(); 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 { 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::connect_in_schema(database_url, None).await } pub async fn connect_with_options( connect_options: PgConnectOptions, ) -> Result { 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 { 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 { 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, 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, name: &'static str, default: u32, ) -> Result { vars.get(name) .map(|value| { value .parse::() .map_err(|_| PostgresPoolConfigError::InvalidValue { name, value: value.clone(), }) }) .transpose() .map(|value| value.unwrap_or(default)) } fn parse_u64_setting( vars: &BTreeMap, name: &'static str, default: u64, ) -> Result { vars.get(name) .map(|value| { value .parse::() .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 { 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 { Ok(InvocationLogRecord { log: InvocationLog { id: InvocationLogId::new(row.try_get::("id")?), workspace_id: WorkspaceId::new(row.try_get::("workspace_id")?), agent_id: row .try_get::, _>("agent_id")? .map(AgentId::new), operation_id: OperationId::new(row.try_get::("operation_id")?), source: deserialize_enum_text(&row.try_get::("source")?, "source")?, level: deserialize_enum_text(&row.try_get::("level")?, "level")?, status: deserialize_enum_text(&row.try_get::("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::, _>("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::("duration_ms")?, "duration_ms")?, error_kind: row.try_get("error_kind")?, request_preview: row.try_get::, _>("request_preview_json")?.0, response_preview: row.try_get::, _>("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 { 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 { Ok(AgentOperationBinding { operation_id: OperationId::new(row.try_get::("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, created_at: OffsetDateTime, updated_at: OffsetDateTime, published_at: Option, ) -> Result { 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, created_at: OffsetDateTime, updated_at: OffsetDateTime, published_at: Option, ) -> Result { 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 { 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 { 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 { 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, ) -> Result { 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, 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, generated_draft_json: Option, config_export_json: Option, wizard_state_json: Option, change_note: Option, created_at: OffsetDateTime, created_by: Option, ) -> Result { 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 { 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, created_at: OffsetDateTime, ) -> Result { 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, version: Option, descriptor_kind: String, storage_ref: String, source_name: Option, package_index: Option, created_at: OffsetDateTime, ) -> Result { 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 { Ok(YamlImportJob { id: YamlImportJobId::new(row.try_get::("id")?), source_sample_id: row .try_get::, _>("source_sample_id")? .map(crank_core::SampleId::new), status: deserialize_enum_text(&row.try_get::("status")?, "status")?, format_version: row.try_get("format_version")?, mode: deserialize_enum_text(&row.try_get::("mode")?, "mode")?, result_operation_id: row .try_get::, _>("result_operation_id")? .map(OperationId::new), result_version: row .try_get::, _>("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 { Ok(UsageOperationBreakdown { operation_id: OperationId::new(row.try_get::("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::("protocol")?, "protocol")?, calls_total: to_u64(row.try_get::("calls_total")?, "calls_total")?, calls_error: to_u64(row.try_get::("calls_error")?, "calls_error")?, p50_ms: to_u64(row.try_get::("p50_ms")?, "p50_ms")?, p95_ms: to_u64(row.try_get::("p95_ms")?, "p95_ms")?, p99_ms: to_u64(row.try_get::("p99_ms")?, "p99_ms")?, }) } fn map_usage_agent_breakdown(row: &PgRow) -> Result { Ok(UsageAgentBreakdown { agent_id: AgentId::new(row.try_get::("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::("calls_total")?, "calls_total")?, calls_error: to_u64(row.try_get::("calls_error")?, "calls_error")?, p50_ms: to_u64(row.try_get::("p50_ms")?, "p50_ms")?, p95_ms: to_u64(row.try_get::("p95_ms")?, "p95_ms")?, p99_ms: to_u64(row.try_get::("p99_ms")?, "p99_ms")?, }) } fn serialize_json_value(value: &T) -> Result { Ok(serde_json::to_value(value)?) } fn serialize_option_json_value( value: &Option, ) -> Result, RegistryError> { value.as_ref().map(serialize_json_value).transpose() } fn deserialize_json_value(value: Value) -> Result { Ok(serde_json::from_value(value)?) } fn serialize_enum_text( value: &T, field: &'static str, ) -> Result { serde_json::to_value(value)? .as_str() .map(ToOwned::to_owned) .ok_or(RegistryError::InvalidEnumRepresentation { field }) } fn deserialize_enum_text( value: &str, field: &'static str, ) -> Result { 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::try_from(value).map_err(|_| RegistryError::InvalidNumericValue { field, value: i64::from(value), }) } fn to_u64(value: i64, field: &'static str) -> Result { 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, 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), } } }