наблюдаемость: завершить базовый контур Community
Добавить структурированные журналы, метрики, трассировку и безопасный канал критических ошибок. Усилить границы рантайма, тесты, проверку зависимостей и сценарии развёртывания.
This commit is contained in:
@@ -3,6 +3,7 @@ name = "crank-registry"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -74,6 +74,8 @@ pub enum RegistryError {
|
||||
YamlImportJobNotFound { job_id: String },
|
||||
#[error("import job {job_id} was not found")]
|
||||
ImportJobNotFound { job_id: String },
|
||||
#[error("import job {job_id} was already applied with different parameters")]
|
||||
ImportJobAlreadyApplied { job_id: String },
|
||||
#[error("unsupported enum representation for field {field}")]
|
||||
InvalidEnumRepresentation { field: &'static str },
|
||||
#[error("invalid numeric value for field {field}: {value}")]
|
||||
|
||||
@@ -9,12 +9,14 @@ pub use ext::{ExtensionMigration, RegistryExtension, apply_extension_migrations}
|
||||
|
||||
pub mod records {
|
||||
pub use crate::model::{
|
||||
AgentSummary, AgentVersionRecord, ApprovalRequestRecord, AuthUserRecord, DescriptorKind,
|
||||
DescriptorMetadata, ImportJob, ImportJobId, ImportJobKind, ImportJobStatus,
|
||||
InvitationRecord, InvocationLogRecord, MembershipRecord, OperationAgentRef,
|
||||
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
|
||||
Page, PlatformApiKeyRecord, PublishedAgentCatalog, PublishedAgentTool, RegistryOperation,
|
||||
SampleKind, SecretRecord, SecretVersionRecord, SessionRecord, UsageAgentBreakdown,
|
||||
AgentSummary, AgentVersionRecord, AppliedImportOperation, ApprovalRequestRecord,
|
||||
AuthUserRecord, DescriptorKind, DescriptorMetadata, ImportJob, ImportJobApplyResult,
|
||||
ImportJobId, ImportJobKind, ImportJobStatus, InvitationRecord, InvocationHistoryLoss,
|
||||
InvocationHistoryLossCategory, InvocationHistoryWriteOutcome, InvocationLogRecord,
|
||||
MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
|
||||
OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord,
|
||||
PublishedAgentCatalog, PublishedAgentTool, RegistryOperation, SampleKind, SecretRecord,
|
||||
SecretVersionRecord, SessionRecord, SkippedImportOperation, UsageAgentBreakdown,
|
||||
UsageBucket, UsageOperationBreakdown, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
||||
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
|
||||
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
@@ -23,11 +25,12 @@ pub mod records {
|
||||
|
||||
pub mod requests {
|
||||
pub use crate::model::{
|
||||
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
|
||||
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
|
||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
|
||||
ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest,
|
||||
ApplyImportJobRequest, CreateAgentDraftVersionRequest, CreateAgentRequest,
|
||||
CreateApprovalRequest, CreateImportJobRequest, CreateInvitationRequest,
|
||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
|
||||
DecideApprovalRequest, ExpireApprovalRequest, FinishApprovalRequest,
|
||||
FinishImportJobRequest, ImportConflictMode, ImportOperationDraft,
|
||||
ListApprovalRequestsQuery, ListInvocationLogsQuery, PublishAgentRequest, PublishRequest,
|
||||
RotateSecretRequest, SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
@@ -41,20 +44,23 @@ pub mod infrastructure {
|
||||
}
|
||||
|
||||
pub use model::{
|
||||
AgentSummary, AgentVersionRecord, ApprovalRequestRecord, AuthUserRecord,
|
||||
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
|
||||
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest,
|
||||
CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata,
|
||||
ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest, ImportJob, ImportJobId,
|
||||
ImportJobKind, ImportJobStatus, InvitationRecord, InvocationLogRecord,
|
||||
ListApprovalRequestsQuery, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
|
||||
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, Page,
|
||||
PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentCatalog,
|
||||
PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind,
|
||||
SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest, SaveAuthProfileRequest,
|
||||
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest,
|
||||
SecretRecord, SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown,
|
||||
AgentSummary, AgentVersionRecord, AppliedImportOperation, ApplyImportJobRequest,
|
||||
ApprovalRequestRecord, AuthUserRecord, CreateAgentDraftVersionRequest, CreateAgentRequest,
|
||||
CreateApprovalRequest, CreateImportJobRequest, CreateInvitationRequest,
|
||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
|
||||
DecideApprovalRequest, DescriptorKind, DescriptorMetadata, ExpireApprovalRequest,
|
||||
FinishApprovalRequest, FinishImportJobRequest, ImportConflictMode, ImportJob,
|
||||
ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobStatus, ImportOperationDraft,
|
||||
InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
|
||||
InvocationHistoryWriteOutcome, InvocationLogRecord, ListApprovalRequestsQuery,
|
||||
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
|
||||
OperationSummary, OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord,
|
||||
PublishAgentRequest, PublishRequest, PublishedAgentCatalog, PublishedAgentTool,
|
||||
RegistryOperation, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest,
|
||||
SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord,
|
||||
SessionRecord, SkippedImportOperation, UpdateWorkspaceRequest, UsageAgentBreakdown,
|
||||
UsageBucket, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary,
|
||||
UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream,
|
||||
WorkspaceUpstreamId, YamlImportJob, YamlImportJobCompletion, YamlImportJobId,
|
||||
|
||||
@@ -1,6 +1,61 @@
|
||||
use sqlx::{PgPool, query};
|
||||
use sqlx::{PgPool, Postgres, Row, Transaction, query};
|
||||
|
||||
const CORE_MIGRATION_LOCK_ID: i64 = 0x43_52_41_4E_4B;
|
||||
const BASELINE_VERSION: i32 = 1;
|
||||
const BASELINE_CHECKSUM: &str = "crank-community-baseline-v1";
|
||||
|
||||
pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
let mut transaction = pool.begin().await?;
|
||||
query("select pg_advisory_xact_lock($1)")
|
||||
.bind(CORE_MIGRATION_LOCK_ID)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create table if not exists __crank_core_migrations (
|
||||
version integer primary key,
|
||||
description text not null,
|
||||
checksum text not null,
|
||||
applied_at timestamptz not null default now()
|
||||
)",
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let applied = query(
|
||||
"select version, checksum
|
||||
from __crank_core_migrations
|
||||
order by version",
|
||||
)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?;
|
||||
for row in &applied {
|
||||
let version = row.try_get::<i32, _>("version")?;
|
||||
let checksum = row.try_get::<String, _>("checksum")?;
|
||||
if version != BASELINE_VERSION || checksum != BASELINE_CHECKSUM {
|
||||
return Err(sqlx::Error::Protocol(format!(
|
||||
"unsupported or modified core migration: version={version}, checksum={checksum}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if applied.is_empty() {
|
||||
apply_baseline(&mut transaction).await?;
|
||||
query(
|
||||
"insert into __crank_core_migrations (version, description, checksum)
|
||||
values ($1, $2, $3)",
|
||||
)
|
||||
.bind(BASELINE_VERSION)
|
||||
.bind("community baseline")
|
||||
.bind(BASELINE_CHECKSUM)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_baseline(transaction: &mut Transaction<'_, Postgres>) -> Result<(), sqlx::Error> {
|
||||
query(
|
||||
"create table if not exists workspaces (
|
||||
id text primary key,
|
||||
@@ -12,7 +67,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -25,11 +80,11 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
created_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query("alter table users add column if not exists password_hash text null")
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -48,7 +103,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
)
|
||||
on conflict (id) do nothing",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -60,7 +115,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
primary key (workspace_id, user_id)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -75,14 +130,14 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
created_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"alter table user_sessions
|
||||
add column if not exists current_workspace_id text null references workspaces(id) on delete set null",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -105,7 +160,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
)
|
||||
on conflict (id) do nothing",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -122,7 +177,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
)
|
||||
on conflict (workspace_id, user_id) do nothing",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -137,7 +192,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
created_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -158,29 +213,29 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
allowed_origins_json jsonb not null default '[]'::jsonb
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create unique index if not exists platform_api_keys_workspace_name_idx on platform_api_keys(workspace_id, name)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table platform_api_keys add column if not exists agent_id text null")
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"alter table platform_api_keys add column if not exists key_kind text not null default 'mcp_client'",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table platform_api_keys add column if not exists expires_at timestamptz null")
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"alter table platform_api_keys add column if not exists allowed_origins_json jsonb not null default '[]'::jsonb",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -203,7 +258,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
)
|
||||
on conflict (id) do nothing",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -223,35 +278,35 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
published_at timestamptz null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query("alter table operations add column if not exists workspace_id text null references workspaces(id) on delete cascade")
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"alter table operations add column if not exists category text not null default 'general'",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"alter table operations add column if not exists security_level text not null default 'standard'",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("update operations set workspace_id = 'ws_default' where workspace_id is null")
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table operations alter column workspace_id set not null")
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table operations drop constraint if exists operations_name_key")
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create unique index if not exists operations_workspace_name_idx on operations(workspace_id, name)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -276,11 +331,11 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
primary key (operation_id, version)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query("alter table operation_versions add column if not exists wizard_state_json jsonb null")
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -292,7 +347,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -308,7 +363,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -324,7 +379,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -338,25 +393,25 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query("alter table auth_profiles add column if not exists workspace_id text null references workspaces(id) on delete cascade")
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("update auth_profiles set workspace_id = 'ws_default' where workspace_id is null")
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table auth_profiles alter column workspace_id set not null")
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table auth_profiles drop constraint if exists auth_profiles_name_key")
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create unique index if not exists auth_profiles_workspace_name_idx on auth_profiles(workspace_id, name)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -371,17 +426,17 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create unique index if not exists workspace_upstreams_workspace_name_idx on workspace_upstreams(workspace_id, name)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create unique index if not exists workspace_upstreams_workspace_base_auth_idx on workspace_upstreams(workspace_id, base_url, coalesce(auth_profile_id, ''))",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"insert into workspace_upstreams (
|
||||
@@ -411,7 +466,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
and wu.name = 'Frankfurter'
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -427,13 +482,13 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create unique index if not exists secrets_workspace_name_idx on secrets(workspace_id, name)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -447,7 +502,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
primary key (secret_id, version)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -464,7 +519,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
finished_at timestamptz null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -483,7 +538,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
finished_at timestamptz null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -501,13 +556,13 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
published_at timestamptz null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create unique index if not exists agents_workspace_slug_idx on agents(workspace_id, slug)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -521,7 +576,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
primary key (agent_id, version)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -538,13 +593,13 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
foreign key (operation_id, operation_version) references operation_versions(operation_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create unique index if not exists agent_bindings_tool_name_idx on agent_operation_bindings(agent_id, agent_version, tool_name)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -556,7 +611,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
foreign key (agent_id, version) references agent_versions(agent_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -577,36 +632,30 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
decision_note text null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query("alter table approval_requests drop column if exists confirmation_title")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
query("alter table approval_requests drop column if exists confirmation_body")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
query("alter table approval_requests add column if not exists execution_started_at timestamptz null")
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table approval_requests add column if not exists execution_attempts integer not null default 0")
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table approval_requests add column if not exists request_fingerprint text null")
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create unique index if not exists approval_requests_pending_fingerprint_idx
|
||||
on approval_requests(agent_id, operation_id, operation_version, request_fingerprint)
|
||||
where status = 'pending' and request_fingerprint is not null",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create index if not exists approval_requests_agent_status_idx
|
||||
on approval_requests(workspace_id, agent_id, status, expires_at)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -629,25 +678,25 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
created_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create index if not exists invocation_logs_workspace_created_idx on invocation_logs(workspace_id, created_at desc)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create index if not exists invocation_logs_workspace_operation_created_idx on invocation_logs(workspace_id, operation_id, created_at desc)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create index if not exists invocation_logs_workspace_agent_created_idx on invocation_logs(workspace_id, agent_id, created_at desc)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
@@ -665,7 +714,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -395,11 +395,92 @@ pub struct FinishImportJobRequest<'a> {
|
||||
pub finished_at: &'a OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ImportConflictMode {
|
||||
Skip,
|
||||
Rename,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ImportOperationDraft {
|
||||
pub operation_key: String,
|
||||
pub operation: RegistryOperation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AppliedImportOperation {
|
||||
pub operation_key: String,
|
||||
pub operation_id: OperationId,
|
||||
pub name: String,
|
||||
pub version: u32,
|
||||
pub renamed_from: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SkippedImportOperation {
|
||||
pub operation_key: String,
|
||||
pub name: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportJobApplyResult {
|
||||
pub application_key: String,
|
||||
pub created: Vec<AppliedImportOperation>,
|
||||
pub skipped: Vec<SkippedImportOperation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ApplyImportJobRequest<'a> {
|
||||
pub id: &'a ImportJobId,
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub application_key: &'a str,
|
||||
pub conflict_mode: ImportConflictMode,
|
||||
pub operations: &'a [ImportOperationDraft],
|
||||
pub finished_at: &'a OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CreateInvocationLogRequest<'a> {
|
||||
pub log: &'a InvocationLog,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum InvocationHistoryLossCategory {
|
||||
Unavailable,
|
||||
InvalidRecord,
|
||||
}
|
||||
|
||||
impl InvocationHistoryLossCategory {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unavailable => "unavailable",
|
||||
Self::InvalidRecord => "invalid_record",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct InvocationHistoryLoss {
|
||||
pub category: InvocationHistoryLossCategory,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum InvocationHistoryWriteOutcome {
|
||||
Recorded,
|
||||
Lost(InvocationHistoryLoss),
|
||||
}
|
||||
|
||||
impl InvocationHistoryWriteOutcome {
|
||||
pub fn loss(self) -> Option<InvocationHistoryLoss> {
|
||||
match self {
|
||||
Self::Recorded => None,
|
||||
Self::Lost(loss) => Some(loss),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ListInvocationLogsQuery<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
|
||||
@@ -397,18 +397,31 @@ impl PostgresRegistry {
|
||||
key_id: &PlatformApiKeyId,
|
||||
used_at: &time::OffsetDateTime,
|
||||
) -> Result<(), RegistryError> {
|
||||
let result = sqlx::query(
|
||||
"update platform_api_keys
|
||||
set last_used_at = $3::timestamptz
|
||||
where workspace_id = $1 and id = $2",
|
||||
let exists = sqlx::query_scalar::<_, bool>(
|
||||
"with target as (
|
||||
select id
|
||||
from platform_api_keys
|
||||
where workspace_id = $1 and id = $2
|
||||
), updated as (
|
||||
update platform_api_keys
|
||||
set last_used_at = $3::timestamptz
|
||||
where workspace_id = $1
|
||||
and id = $2
|
||||
and (
|
||||
last_used_at is null
|
||||
or last_used_at < $3::timestamptz - interval '1 minute'
|
||||
)
|
||||
returning id
|
||||
)
|
||||
select exists(select 1 from target)",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(key_id.as_str())
|
||||
.bind(used_at)
|
||||
.execute(&self.pool)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
if !exists {
|
||||
return Err(RegistryError::PlatformApiKeyNotFound {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
|
||||
@@ -357,21 +357,20 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
started_at: OffsetDateTime,
|
||||
approved_before: OffsetDateTime,
|
||||
stale_before: OffsetDateTime,
|
||||
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"with candidate as (
|
||||
select id
|
||||
from approval_requests
|
||||
where (status = 'approved' and decided_at <= $1)
|
||||
or (status = 'executing' and execution_started_at < $2)
|
||||
where status = 'approved'
|
||||
and decided_at <= $1
|
||||
order by decided_at asc nulls last, created_at asc
|
||||
for update skip locked
|
||||
limit 1
|
||||
)
|
||||
update approval_requests as approval
|
||||
set status = 'executing',
|
||||
execution_started_at = $3,
|
||||
execution_started_at = $2,
|
||||
execution_attempts = approval.execution_attempts + 1
|
||||
from candidate
|
||||
where approval.id = candidate.id
|
||||
@@ -383,7 +382,6 @@ impl PostgresRegistry {
|
||||
approval.decided_at, approval.decided_by_key_id, approval.decision_note",
|
||||
)
|
||||
.bind(approved_before)
|
||||
.bind(stale_before)
|
||||
.bind(started_at)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
@@ -391,6 +389,50 @@ impl PostgresRegistry {
|
||||
row.map(map_approval_request_row).transpose()
|
||||
}
|
||||
|
||||
pub async fn fail_next_interrupted_approval_request(
|
||||
&self,
|
||||
stale_before: OffsetDateTime,
|
||||
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
|
||||
let response_payload = serde_json::json!({
|
||||
"error": {
|
||||
"code": "approval_execution_outcome_unknown",
|
||||
"message": "execution was interrupted; the operation was not retried automatically"
|
||||
}
|
||||
});
|
||||
let row = sqlx::query(
|
||||
"with candidate as (
|
||||
select id
|
||||
from approval_requests
|
||||
where status = 'executing'
|
||||
and execution_started_at < $1
|
||||
order by execution_started_at asc, created_at asc
|
||||
for update skip locked
|
||||
limit 1
|
||||
)
|
||||
update approval_requests as approval
|
||||
set status = 'failed',
|
||||
response_payload_json = $2,
|
||||
decision_note = coalesce(
|
||||
approval.decision_note,
|
||||
'execution interrupted; outcome unknown'
|
||||
)
|
||||
from candidate
|
||||
where approval.id = candidate.id
|
||||
returning
|
||||
approval.id, approval.workspace_id, approval.agent_id,
|
||||
approval.operation_id, approval.operation_version, approval.status,
|
||||
approval.risk_level, approval.request_payload_json,
|
||||
approval.response_payload_json, approval.created_at, approval.expires_at,
|
||||
approval.decided_at, approval.decided_by_key_id, approval.decision_note",
|
||||
)
|
||||
.bind(stale_before)
|
||||
.bind(Json(response_payload))
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.map(map_approval_request_row).transpose()
|
||||
}
|
||||
|
||||
pub async fn expire_approval_request(
|
||||
&self,
|
||||
request: ExpireApprovalRequest<'_>,
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
use super::*;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
fn map_auth_user_row(row: &PgRow) -> Result<AuthUserRecord, RegistryError> {
|
||||
let status = row.try_get::<String, _>("status")?;
|
||||
Ok(AuthUserRecord {
|
||||
user: User {
|
||||
id: UserId::new(row.try_get::<String, _>("id")?),
|
||||
email: row.try_get("email")?,
|
||||
display_name: row.try_get("display_name")?,
|
||||
status: deserialize_enum_text(&status, "status")?,
|
||||
created_at: row.try_get::<OffsetDateTime, _>("created_at")?,
|
||||
},
|
||||
password_hash: row
|
||||
.try_get::<Option<String>, _>("password_hash")?
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn upsert_bootstrap_user(
|
||||
pub async fn ensure_bootstrap_user(
|
||||
&self,
|
||||
email: &str,
|
||||
display_name: &str,
|
||||
password_hash: &str,
|
||||
) -> Result<UserId, RegistryError> {
|
||||
let user_id = format!("user_{}", uuid::Uuid::now_v7().simple());
|
||||
let row = sqlx::query!(
|
||||
if let Some(id) = sqlx::query_scalar::<_, String>(
|
||||
"insert into users (
|
||||
id,
|
||||
email,
|
||||
@@ -24,16 +40,40 @@ impl PostgresRegistry {
|
||||
set display_name = excluded.display_name,
|
||||
password_hash = excluded.password_hash,
|
||||
status = 'active'
|
||||
where users.password_hash is null
|
||||
returning id",
|
||||
user_id,
|
||||
email,
|
||||
display_name,
|
||||
password_hash,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(email)
|
||||
.bind(display_name)
|
||||
.bind(password_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
{
|
||||
return Ok(UserId::new(id));
|
||||
}
|
||||
|
||||
let existing = sqlx::query_scalar::<_, String>(
|
||||
"select id
|
||||
from users
|
||||
where email = $1
|
||||
limit 1",
|
||||
)
|
||||
.bind(email)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(UserId::new(row.id))
|
||||
Ok(UserId::new(existing))
|
||||
}
|
||||
|
||||
pub async fn upsert_bootstrap_user(
|
||||
&self,
|
||||
email: &str,
|
||||
display_name: &str,
|
||||
password_hash: &str,
|
||||
) -> Result<UserId, RegistryError> {
|
||||
self.ensure_bootstrap_user(email, display_name, password_hash)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn ensure_membership(
|
||||
@@ -67,70 +107,46 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
email: &str,
|
||||
) -> Result<Option<AuthUserRecord>, RegistryError> {
|
||||
let row = sqlx::query!(
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
email,
|
||||
display_name,
|
||||
password_hash as \"password_hash!\",
|
||||
password_hash,
|
||||
status,
|
||||
created_at as \"created_at!: OffsetDateTime\"
|
||||
created_at
|
||||
from users
|
||||
where email = $1
|
||||
limit 1",
|
||||
email,
|
||||
)
|
||||
.bind(email)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.map(|row| {
|
||||
Ok(AuthUserRecord {
|
||||
user: User {
|
||||
id: UserId::new(row.id),
|
||||
email: row.email,
|
||||
display_name: row.display_name,
|
||||
status: deserialize_enum_text(&row.status, "status")?,
|
||||
created_at: row.created_at,
|
||||
},
|
||||
password_hash: row.password_hash,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
row.as_ref().map(map_auth_user_row).transpose()
|
||||
}
|
||||
|
||||
pub async fn get_auth_user_by_id(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Option<AuthUserRecord>, RegistryError> {
|
||||
let row = sqlx::query!(
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
email,
|
||||
display_name,
|
||||
password_hash as \"password_hash!\",
|
||||
password_hash,
|
||||
status,
|
||||
created_at as \"created_at!: OffsetDateTime\"
|
||||
created_at
|
||||
from users
|
||||
where id = $1
|
||||
limit 1",
|
||||
user_id.as_str(),
|
||||
)
|
||||
.bind(user_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.map(|row| {
|
||||
Ok(AuthUserRecord {
|
||||
user: User {
|
||||
id: UserId::new(row.id),
|
||||
email: row.email,
|
||||
display_name: row.display_name,
|
||||
status: deserialize_enum_text(&row.status, "status")?,
|
||||
created_at: row.created_at,
|
||||
},
|
||||
password_hash: row.password_hash,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
row.as_ref().map(map_auth_user_row).transpose()
|
||||
}
|
||||
|
||||
pub async fn update_user_profile(
|
||||
@@ -191,6 +207,45 @@ impl PostgresRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_user_password_and_revoke_other_sessions(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
current_session_id: &UserSessionId,
|
||||
password_hash: &str,
|
||||
) -> Result<(), RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let result = sqlx::query(
|
||||
"update users
|
||||
set password_hash = $2
|
||||
where id = $1",
|
||||
)
|
||||
.bind(user_id.as_str())
|
||||
.bind(password_hash)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(RegistryError::UserNotFound {
|
||||
user_id: user_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"update user_sessions
|
||||
set status = 'revoked'
|
||||
where user_id = $1
|
||||
and id <> $2
|
||||
and status = 'active'",
|
||||
)
|
||||
.bind(user_id.as_str())
|
||||
.bind(current_session_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_user_session(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
@@ -305,7 +360,11 @@ impl PostgresRegistry {
|
||||
sqlx::query(
|
||||
"update user_sessions
|
||||
set last_seen_at = now()
|
||||
where id = $1",
|
||||
where id = $1
|
||||
and (
|
||||
last_seen_at is null
|
||||
or last_seen_at < now() - interval '1 minute'
|
||||
)",
|
||||
)
|
||||
.bind(session_id.as_str())
|
||||
.execute(&self.pool)
|
||||
|
||||
@@ -41,6 +41,11 @@ impl PostgresRegistry {
|
||||
&self.pool
|
||||
}
|
||||
|
||||
pub async fn ping(&self) -> Result<(), RegistryError> {
|
||||
sqlx::query("select 1").execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn migrate(&self) -> Result<(), RegistryError> {
|
||||
migrations::apply_postgres(&self.pool).await?;
|
||||
Ok(())
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
const APPLICATION_RESULT_KEY: &str = "_crank_application_result";
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn create_import_job(
|
||||
&self,
|
||||
@@ -97,6 +99,43 @@ impl PostgresRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn apply_import_job(
|
||||
&self,
|
||||
request: ApplyImportJobRequest<'_>,
|
||||
) -> Result<ImportJobApplyResult, RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let applied = apply_import_job_transaction(&mut tx, &request).await;
|
||||
|
||||
match applied {
|
||||
Ok(result) => {
|
||||
tx.commit().await?;
|
||||
Ok(result)
|
||||
}
|
||||
Err(error) => {
|
||||
tx.rollback().await?;
|
||||
let error_text = error.to_string();
|
||||
let _ = sqlx::query(
|
||||
"update import_jobs
|
||||
set status = $3,
|
||||
error_text = $4,
|
||||
finished_at = $5::timestamptz
|
||||
where id = $1
|
||||
and workspace_id = $2
|
||||
and status <> $6",
|
||||
)
|
||||
.bind(request.id.as_str())
|
||||
.bind(request.workspace_id.as_str())
|
||||
.bind(serialize_enum_text(&ImportJobStatus::Failed, "status")?)
|
||||
.bind(error_text)
|
||||
.bind(request.finished_at)
|
||||
.bind(serialize_enum_text(&ImportJobStatus::Completed, "status")?)
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_expired_import_jobs(&self) -> Result<u64, RegistryError> {
|
||||
let result = sqlx::query("delete from import_jobs where expires_at < now()")
|
||||
.execute(&self.pool)
|
||||
@@ -105,3 +144,160 @@ impl PostgresRegistry {
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_import_job_transaction(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
request: &ApplyImportJobRequest<'_>,
|
||||
) -> Result<ImportJobApplyResult, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select status, preview_payload
|
||||
from import_jobs
|
||||
where id = $1 and workspace_id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(request.id.as_str())
|
||||
.bind(request.workspace_id.as_str())
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.ok_or_else(|| RegistryError::ImportJobNotFound {
|
||||
job_id: request.id.as_str().to_owned(),
|
||||
})?;
|
||||
let status = deserialize_enum_text::<ImportJobStatus>(row.try_get("status")?, "status")?;
|
||||
let mut preview_payload = row.try_get::<Value, _>("preview_payload")?;
|
||||
|
||||
if status == ImportJobStatus::Completed
|
||||
&& let Some(result) = stored_application_result(&preview_payload)?
|
||||
{
|
||||
if result.application_key != request.application_key {
|
||||
return Err(RegistryError::ImportJobAlreadyApplied {
|
||||
job_id: request.id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
sqlx::query("select id from workspaces where id = $1 for update")
|
||||
.bind(request.workspace_id.as_str())
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let mut result = ImportJobApplyResult {
|
||||
application_key: request.application_key.to_owned(),
|
||||
..ImportJobApplyResult::default()
|
||||
};
|
||||
for draft in request.operations {
|
||||
if draft.operation.version != 1 {
|
||||
return Err(RegistryError::InvalidInitialVersion {
|
||||
operation_id: draft.operation.id.as_str().to_owned(),
|
||||
version: draft.operation.version,
|
||||
});
|
||||
}
|
||||
|
||||
let mut operation = draft.operation.clone();
|
||||
let original_name = operation.name.clone();
|
||||
if operation_name_exists(tx, request.workspace_id, &operation.name).await? {
|
||||
match request.conflict_mode {
|
||||
ImportConflictMode::Skip => {
|
||||
result.skipped.push(SkippedImportOperation {
|
||||
operation_key: draft.operation_key.clone(),
|
||||
name: operation.name,
|
||||
reason: "operation_name_conflict".to_owned(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
ImportConflictMode::Rename => {
|
||||
operation.name =
|
||||
next_available_operation_name(tx, request.workspace_id, &operation.name)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
insert_operation_rows(tx, request.workspace_id, &operation, None).await?;
|
||||
result.created.push(AppliedImportOperation {
|
||||
operation_key: draft.operation_key.clone(),
|
||||
operation_id: operation.id,
|
||||
name: operation.name.clone(),
|
||||
version: operation.version,
|
||||
renamed_from: (operation.name != original_name).then_some(original_name),
|
||||
});
|
||||
}
|
||||
|
||||
let stored_result = serde_json::to_value(&result)?;
|
||||
if let Some(object) = preview_payload.as_object_mut() {
|
||||
object.insert(APPLICATION_RESULT_KEY.to_owned(), stored_result);
|
||||
} else {
|
||||
preview_payload = serde_json::json!({
|
||||
"preview": preview_payload,
|
||||
"_crank_application_result": stored_result,
|
||||
});
|
||||
}
|
||||
let created_operation_ids = serde_json::to_value(
|
||||
result
|
||||
.created
|
||||
.iter()
|
||||
.map(|operation| operation.operation_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
)?;
|
||||
sqlx::query(
|
||||
"update import_jobs
|
||||
set status = $3,
|
||||
preview_payload = $4,
|
||||
created_operation_ids = $5,
|
||||
error_text = null,
|
||||
finished_at = $6::timestamptz
|
||||
where id = $1 and workspace_id = $2",
|
||||
)
|
||||
.bind(request.id.as_str())
|
||||
.bind(request.workspace_id.as_str())
|
||||
.bind(serialize_enum_text(&ImportJobStatus::Completed, "status")?)
|
||||
.bind(preview_payload)
|
||||
.bind(created_operation_ids)
|
||||
.bind(request.finished_at)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn stored_application_result(
|
||||
preview_payload: &Value,
|
||||
) -> Result<Option<ImportJobApplyResult>, RegistryError> {
|
||||
preview_payload
|
||||
.get(APPLICATION_RESULT_KEY)
|
||||
.cloned()
|
||||
.map(serde_json::from_value)
|
||||
.transpose()
|
||||
.map_err(RegistryError::from)
|
||||
}
|
||||
|
||||
async fn operation_name_exists(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
name: &str,
|
||||
) -> Result<bool, RegistryError> {
|
||||
Ok(sqlx::query_scalar::<_, bool>(
|
||||
"select exists(
|
||||
select 1 from operations where workspace_id = $1 and name = $2
|
||||
)",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(name)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn next_available_operation_name(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
base_name: &str,
|
||||
) -> Result<String, RegistryError> {
|
||||
for index in 2.. {
|
||||
let candidate = format!("{base_name}_{index}");
|
||||
if !operation_name_exists(tx, workspace_id, &candidate).await? {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
@@ -30,23 +30,25 @@ pub use pool_config::{PostgresPoolConfig, PostgresPoolConfigError};
|
||||
use crate::{
|
||||
error::RegistryError,
|
||||
model::{
|
||||
AgentSummary, AgentVersionRecord, ApprovalRequestRecord, AuthUserRecord,
|
||||
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
|
||||
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
|
||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
|
||||
DescriptorMetadata, ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest,
|
||||
ImportJob, ImportJobId, InvitationRecord, InvocationLogRecord, ListApprovalRequestsQuery,
|
||||
AgentSummary, AgentVersionRecord, AppliedImportOperation, ApplyImportJobRequest,
|
||||
ApprovalRequestRecord, AuthUserRecord, CreateAgentDraftVersionRequest, CreateAgentRequest,
|
||||
CreateApprovalRequest, CreateImportJobRequest, CreateInvitationRequest,
|
||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
|
||||
DecideApprovalRequest, DescriptorMetadata, ExpireApprovalRequest, FinishApprovalRequest,
|
||||
FinishImportJobRequest, ImportConflictMode, ImportJob, ImportJobApplyResult, ImportJobId,
|
||||
ImportJobStatus, InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
|
||||
InvocationHistoryWriteOutcome, InvocationLogRecord, ListApprovalRequestsQuery,
|
||||
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
|
||||
OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord,
|
||||
PublishAgentRequest, PublishRequest, PublishedAgentCatalog, PublishedAgentTool,
|
||||
RegistryOperation, RotateSecretRequest, SaveAgentBindingsRequest,
|
||||
SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord,
|
||||
SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageOperationBreakdown,
|
||||
UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord,
|
||||
WorkspaceRecord, WorkspaceUpstream, YamlImportJob, YamlImportJobCompletion,
|
||||
YamlImportJobId, YamlImportJobStatus,
|
||||
SessionRecord, SkippedImportOperation, UpdateWorkspaceRequest, UsageAgentBreakdown,
|
||||
UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
||||
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, YamlImportJob,
|
||||
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -135,6 +137,61 @@ async fn insert_version_row(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_operation_rows(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
snapshot: &RegistryOperation,
|
||||
created_by: Option<&str>,
|
||||
) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
"insert into operations (
|
||||
id,
|
||||
workspace_id,
|
||||
name,
|
||||
display_name,
|
||||
category,
|
||||
protocol,
|
||||
security_level,
|
||||
status,
|
||||
current_draft_version,
|
||||
latest_published_version,
|
||||
created_at,
|
||||
updated_at,
|
||||
published_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||
$11::timestamptz,
|
||||
$12::timestamptz,
|
||||
$13::timestamptz
|
||||
)",
|
||||
)
|
||||
.bind(snapshot.id.as_str())
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(&snapshot.name)
|
||||
.bind(&snapshot.display_name)
|
||||
.bind(&snapshot.category)
|
||||
.bind(serialize_enum_text(&snapshot.protocol, "protocol")?)
|
||||
.bind(serialize_enum_text(
|
||||
&snapshot.security_level,
|
||||
"security_level",
|
||||
)?)
|
||||
.bind(serialize_enum_text(&snapshot.status, "status")?)
|
||||
.bind(to_db_version(snapshot.version))
|
||||
.bind(
|
||||
snapshot
|
||||
.published_at
|
||||
.as_ref()
|
||||
.map(|_| to_db_version(snapshot.version)),
|
||||
)
|
||||
.bind(snapshot.created_at)
|
||||
.bind(snapshot.updated_at)
|
||||
.bind(snapshot.published_at)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
insert_version_row(tx, snapshot, None, created_by).await
|
||||
}
|
||||
|
||||
async fn insert_agent_version_row(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
version: &AgentVersion,
|
||||
|
||||
@@ -1,9 +1,47 @@
|
||||
use super::*;
|
||||
|
||||
fn invocation_history_loss_category(error: &RegistryError) -> InvocationHistoryLossCategory {
|
||||
match error {
|
||||
RegistryError::Storage(error)
|
||||
if error
|
||||
.as_database_error()
|
||||
.and_then(|database_error| database_error.code())
|
||||
.is_some_and(|code| code.starts_with("22") || code.starts_with("23")) =>
|
||||
{
|
||||
InvocationHistoryLossCategory::InvalidRecord
|
||||
}
|
||||
RegistryError::Storage(_) => InvocationHistoryLossCategory::Unavailable,
|
||||
_ => InvocationHistoryLossCategory::InvalidRecord,
|
||||
}
|
||||
}
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn delete_invocation_logs_before(
|
||||
&self,
|
||||
cutoff: OffsetDateTime,
|
||||
) -> Result<u64, RegistryError> {
|
||||
let result = sqlx::query("delete from invocation_logs where created_at < $1::timestamptz")
|
||||
.bind(cutoff)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn create_invocation_log(
|
||||
&self,
|
||||
request: CreateInvocationLogRequest<'_>,
|
||||
) -> InvocationHistoryWriteOutcome {
|
||||
match self.try_create_invocation_log(request).await {
|
||||
Ok(()) => InvocationHistoryWriteOutcome::Recorded,
|
||||
Err(error) => InvocationHistoryWriteOutcome::Lost(InvocationHistoryLoss {
|
||||
category: invocation_history_loss_category(&error),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_create_invocation_log(
|
||||
&self,
|
||||
request: CreateInvocationLogRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let request_preview = crank_core::sanitize_invocation_preview(&request.log.request_preview);
|
||||
let response_preview =
|
||||
|
||||
@@ -26,53 +26,7 @@ impl PostgresRegistry {
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
sqlx::query(
|
||||
"insert into operations (
|
||||
id,
|
||||
workspace_id,
|
||||
name,
|
||||
display_name,
|
||||
category,
|
||||
protocol,
|
||||
security_level,
|
||||
status,
|
||||
current_draft_version,
|
||||
latest_published_version,
|
||||
created_at,
|
||||
updated_at,
|
||||
published_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||
$11::timestamptz,
|
||||
$12::timestamptz,
|
||||
$13::timestamptz
|
||||
)",
|
||||
)
|
||||
.bind(snapshot.id.as_str())
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(&snapshot.name)
|
||||
.bind(&snapshot.display_name)
|
||||
.bind(&snapshot.category)
|
||||
.bind(serialize_enum_text(&snapshot.protocol, "protocol")?)
|
||||
.bind(serialize_enum_text(
|
||||
&snapshot.security_level,
|
||||
"security_level",
|
||||
)?)
|
||||
.bind(serialize_enum_text(&snapshot.status, "status")?)
|
||||
.bind(to_db_version(snapshot.version))
|
||||
.bind(
|
||||
snapshot
|
||||
.published_at
|
||||
.as_ref()
|
||||
.map(|_| to_db_version(snapshot.version)),
|
||||
)
|
||||
.bind(snapshot.created_at)
|
||||
.bind(snapshot.updated_at)
|
||||
.bind(snapshot.published_at)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
insert_version_row(&mut tx, snapshot, None, created_by).await?;
|
||||
insert_operation_rows(&mut tx, workspace_id, snapshot, created_by).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
|
||||
@@ -282,18 +282,31 @@ impl PostgresRegistry {
|
||||
secret_id: &SecretId,
|
||||
used_at: &OffsetDateTime,
|
||||
) -> Result<(), RegistryError> {
|
||||
let result = sqlx::query(
|
||||
"update secrets
|
||||
set last_used_at = $3::timestamptz
|
||||
where workspace_id = $1 and id = $2",
|
||||
let exists = sqlx::query_scalar::<_, bool>(
|
||||
"with target as (
|
||||
select id
|
||||
from secrets
|
||||
where workspace_id = $1 and id = $2
|
||||
), updated as (
|
||||
update secrets
|
||||
set last_used_at = $3::timestamptz
|
||||
where workspace_id = $1
|
||||
and id = $2
|
||||
and (
|
||||
last_used_at is null
|
||||
or last_used_at < $3::timestamptz - interval '1 minute'
|
||||
)
|
||||
returning id
|
||||
)
|
||||
select exists(select 1 from target)",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(secret_id.as_str())
|
||||
.bind(used_at)
|
||||
.execute(&self.pool)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
if !exists {
|
||||
return Err(RegistryError::SecretNotFound {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
mod integration {
|
||||
mod agents_usage;
|
||||
mod common;
|
||||
mod migrations;
|
||||
mod observability;
|
||||
mod operations_artifacts;
|
||||
mod workspace_access;
|
||||
}
|
||||
|
||||
@@ -228,32 +228,36 @@ async fn manages_operation_usage_and_agent_ref_reads() {
|
||||
})
|
||||
.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();
|
||||
assert_eq!(
|
||||
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,
|
||||
crank_registry::InvocationHistoryWriteOutcome::Recorded
|
||||
);
|
||||
assert_eq!(
|
||||
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,
|
||||
crank_registry::InvocationHistoryWriteOutcome::Recorded
|
||||
);
|
||||
|
||||
let has_bindings = registry
|
||||
.has_published_agent_bindings_for_operation(&test_workspace_id(), &operation.id)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
use crank_registry::PostgresRegistry;
|
||||
use sqlx::Row;
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_migration_is_versioned_and_safe_under_concurrent_startup() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_core_migration").await;
|
||||
|
||||
let (first, second) = tokio::join!(
|
||||
PostgresRegistry::connect(&database_url),
|
||||
PostgresRegistry::connect(&database_url),
|
||||
);
|
||||
let first = first.expect("first service startup must apply the migration");
|
||||
second.expect("second service startup must observe the applied migration");
|
||||
|
||||
let rows = sqlx::query(
|
||||
"select version, description, checksum from __crank_core_migrations order by version",
|
||||
)
|
||||
.fetch_all(first.pool())
|
||||
.await
|
||||
.expect("migration ledger must be readable");
|
||||
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].get::<i32, _>("version"), 1);
|
||||
assert_eq!(
|
||||
rows[0].get::<String, _>("description"),
|
||||
"community baseline"
|
||||
);
|
||||
assert_eq!(
|
||||
rows[0].get::<String, _>("checksum"),
|
||||
"crank-community-baseline-v1"
|
||||
);
|
||||
|
||||
let approval_columns = sqlx::query(
|
||||
"select column_name
|
||||
from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'approval_requests'
|
||||
and column_name in ('execution_started_at', 'execution_attempts', 'request_fingerprint')",
|
||||
)
|
||||
.fetch_all(first.pool())
|
||||
.await
|
||||
.expect("approval schema must be readable");
|
||||
assert_eq!(approval_columns.len(), 3);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use crank_registry::{
|
||||
CreateInvocationLogRequest, InvocationHistoryLossCategory, InvocationHistoryWriteOutcome,
|
||||
};
|
||||
|
||||
use super::common::{TestDatabase, test_invocation_log};
|
||||
|
||||
#[tokio::test]
|
||||
async fn invocation_history_write_returns_typed_loss_without_error_details() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let log = test_invocation_log(
|
||||
"log_missing_owner",
|
||||
&crank_core::OperationId::new("op_missing"),
|
||||
None,
|
||||
crank_core::InvocationStatus::Ok,
|
||||
10,
|
||||
"2026-03-25T12:20:00Z",
|
||||
);
|
||||
|
||||
let outcome = registry
|
||||
.create_invocation_log(CreateInvocationLogRequest { log: &log })
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
outcome,
|
||||
InvocationHistoryWriteOutcome::Lost(crank_registry::InvocationHistoryLoss {
|
||||
category: InvocationHistoryLossCategory::InvalidRecord,
|
||||
})
|
||||
);
|
||||
database.cleanup().await;
|
||||
}
|
||||
@@ -38,6 +38,38 @@ fn timestamp(value: &str) -> OffsetDateTime {
|
||||
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bootstrap_user_does_not_overwrite_an_existing_password() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let email = "bootstrap-owner@example.com";
|
||||
|
||||
let user_id = registry
|
||||
.upsert_bootstrap_user(email, "Bootstrap Owner", "initial-bootstrap-hash")
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.update_user_password(&user_id, "user-selected-hash")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let repeated_user_id = registry
|
||||
.upsert_bootstrap_user(email, "Changed Bootstrap Name", "changed-bootstrap-hash")
|
||||
.await
|
||||
.unwrap();
|
||||
let stored = registry
|
||||
.get_auth_user_by_email(email)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(repeated_user_id, user_id);
|
||||
assert_eq!(stored.password_hash, "user-selected-hash");
|
||||
assert_eq!(stored.user.display_name, "Bootstrap Owner");
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stores_and_finishes_yaml_import_jobs() {
|
||||
let database = TestDatabase::new().await;
|
||||
@@ -352,6 +384,24 @@ async fn creates_and_loads_user_sessions_with_typed_expiration() {
|
||||
assert_eq!(session.user.id, user_id);
|
||||
assert!(session.user.created_at.unix_timestamp() > 0);
|
||||
|
||||
registry.touch_user_session(&session_id).await.unwrap();
|
||||
let first_seen = sqlx::query_scalar::<_, OffsetDateTime>(
|
||||
"select last_seen_at from user_sessions where id = $1",
|
||||
)
|
||||
.bind(session_id.as_str())
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
registry.touch_user_session(&session_id).await.unwrap();
|
||||
let second_seen = sqlx::query_scalar::<_, OffsetDateTime>(
|
||||
"select last_seen_at from user_sessions where id = $1",
|
||||
)
|
||||
.bind(session_id.as_str())
|
||||
.fetch_one(registry.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second_seen, first_seen);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
@@ -454,6 +504,40 @@ async fn manages_platform_api_key_read_paths() {
|
||||
Some(timestamp("2026-03-25T12:05:00Z"))
|
||||
);
|
||||
|
||||
registry
|
||||
.touch_platform_api_key(
|
||||
&workspace.id,
|
||||
&PlatformApiKeyId::new("key_01"),
|
||||
×tamp("2026-03-25T12:05:30Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let throttled = registry
|
||||
.list_platform_api_keys(&workspace.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
throttled[0].api_key.last_used_at,
|
||||
Some(timestamp("2026-03-25T12:05:00Z"))
|
||||
);
|
||||
|
||||
registry
|
||||
.touch_platform_api_key(
|
||||
&workspace.id,
|
||||
&PlatformApiKeyId::new("key_01"),
|
||||
×tamp("2026-03-25T12:06:01Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let refreshed = registry
|
||||
.list_platform_api_keys(&workspace.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
refreshed[0].api_key.last_used_at,
|
||||
Some(timestamp("2026-03-25T12:06:01Z"))
|
||||
);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
@@ -587,7 +671,6 @@ async fn manages_approval_request_lifecycle() {
|
||||
.claim_next_recoverable_approval_request(
|
||||
timestamp("2026-03-25T12:02:01Z"),
|
||||
timestamp("2026-03-25T12:01:59Z"),
|
||||
timestamp("2026-03-25T11:55:00Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -608,23 +691,10 @@ async fn manages_approval_request_lifecycle() {
|
||||
.claim_next_recoverable_approval_request(
|
||||
timestamp("2026-03-25T12:02:10Z"),
|
||||
timestamp("2026-03-25T12:02:09Z"),
|
||||
timestamp("2026-03-25T12:01:59Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(fresh_claim.is_none());
|
||||
let recovered = registry
|
||||
.claim_next_recoverable_approval_request(
|
||||
timestamp("2026-03-25T12:03:00Z"),
|
||||
timestamp("2026-03-25T12:02:59Z"),
|
||||
timestamp("2026-03-25T12:02:30Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(recovered.approval.id, approval.id);
|
||||
assert_eq!(recovered.approval.status, ApprovalRequestStatus::Executing);
|
||||
|
||||
let completed = registry
|
||||
.finish_approval_request(FinishApprovalRequest {
|
||||
workspace_id: &workspace_id,
|
||||
@@ -637,7 +707,6 @@ async fn manages_approval_request_lifecycle() {
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(completed.approval.status, ApprovalRequestStatus::Completed);
|
||||
assert_eq!(
|
||||
completed.approval.response_payload,
|
||||
@@ -653,9 +722,81 @@ async fn manages_approval_request_lifecycle() {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(completed_by_status.len(), 1);
|
||||
|
||||
let mut interrupted_approval = approval.clone();
|
||||
interrupted_approval.id = ApprovalRequestId::new("approval_interrupted_01");
|
||||
interrupted_approval.request_payload = json!({"amount": 150});
|
||||
interrupted_approval.created_at = timestamp("2026-03-25T12:02:10Z");
|
||||
registry
|
||||
.create_approval_request(CreateApprovalRequest {
|
||||
approval: &interrupted_approval,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.decide_approval_request(DecideApprovalRequest {
|
||||
workspace_id: &workspace_id,
|
||||
agent_id: &agent.id,
|
||||
approval_id: &interrupted_approval.id,
|
||||
status: ApprovalRequestStatus::Approved,
|
||||
decided_at: timestamp("2026-03-25T12:02:20Z"),
|
||||
decided_by_key_id: &approval_key.id,
|
||||
response_payload: Some(json!({"approve": "yes"})),
|
||||
decision_note: None,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
registry
|
||||
.claim_approval_request(
|
||||
&workspace_id,
|
||||
&agent.id,
|
||||
&interrupted_approval.id,
|
||||
timestamp("2026-03-25T12:02:21Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let recovered = registry
|
||||
.claim_next_recoverable_approval_request(
|
||||
timestamp("2026-03-25T12:03:00Z"),
|
||||
timestamp("2026-03-25T12:02:59Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(recovered.is_none());
|
||||
|
||||
let interrupted = registry
|
||||
.fail_next_interrupted_approval_request(timestamp("2026-03-25T12:02:30Z"))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(interrupted.approval.id, interrupted_approval.id);
|
||||
assert_eq!(interrupted.approval.status, ApprovalRequestStatus::Failed);
|
||||
assert_eq!(
|
||||
completed_by_status[0].approval.status,
|
||||
ApprovalRequestStatus::Completed
|
||||
interrupted.approval.response_payload,
|
||||
Some(json!({
|
||||
"error": {
|
||||
"code": "approval_execution_outcome_unknown",
|
||||
"message": "execution was interrupted; the operation was not retried automatically"
|
||||
}
|
||||
}))
|
||||
);
|
||||
|
||||
let failed_by_status = registry
|
||||
.list_approval_requests(ListApprovalRequestsQuery {
|
||||
workspace_id: &workspace_id,
|
||||
status: Some(ApprovalRequestStatus::Failed),
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(failed_by_status.len(), 1);
|
||||
assert_eq!(
|
||||
failed_by_status[0].approval.status,
|
||||
ApprovalRequestStatus::Failed
|
||||
);
|
||||
|
||||
let pending_after_decision = registry
|
||||
|
||||
Reference in New Issue
Block a user