chore: publish clean community baseline
Deploy / deploy (push) Successful in 2m32s
CI / Rust Checks (push) Successful in 5m33s
CI / UI Checks (push) Successful in 6s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Successful in 4m36s

This commit is contained in:
github-ops
2026-06-17 06:15:46 +00:00
commit ae09eeba30
318 changed files with 73473 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "crank-registry"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
[dependencies]
crank-core = { path = "../crank-core" }
crank-mapping = { path = "../crank-mapping" }
crank-schema = { path = "../crank-schema" }
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
thiserror.workspace = true
time.workspace = true
uuid.workspace = true
[dev-dependencies]
tokio.workspace = true
+95
View File
@@ -0,0 +1,95 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum RegistryError {
#[error(transparent)]
Storage(#[from] sqlx::Error),
#[error(transparent)]
Serialization(#[from] serde_json::Error),
#[error("workspace {workspace_id} was not found")]
WorkspaceNotFound { workspace_id: String },
#[error("workspace with slug {slug} already exists")]
WorkspaceSlugAlreadyExists { slug: String },
#[error("user {user_id} was not found")]
UserNotFound { user_id: String },
#[error("user with email {email} already exists")]
UserEmailAlreadyExists { email: String },
#[error("membership for user {user_id} in workspace {workspace_id} was not found")]
MembershipNotFound {
workspace_id: String,
user_id: String,
},
#[error("invitation {invitation_id} was not found")]
InvitationNotFound { invitation_id: String },
#[error("platform api key {key_id} was not found")]
PlatformApiKeyNotFound { key_id: String },
#[error("secret {secret_id} was not found")]
SecretNotFound { secret_id: String },
#[error("stream session {session_id} was not found")]
StreamSessionNotFound { session_id: String },
#[error("async job {job_id} was not found")]
AsyncJobNotFound { job_id: String },
#[error("invalid stream session transition for {session_id}: {from} -> {to}")]
InvalidStreamSessionTransition {
session_id: String,
from: String,
to: String,
},
#[error("invalid async job transition for {job_id}: {from} -> {to}")]
InvalidAsyncJobTransition {
job_id: String,
from: String,
to: String,
},
#[error("secret with name {name} already exists in workspace {workspace_id}")]
SecretNameAlreadyExists { workspace_id: String, name: String },
#[error("secret {secret_id} is referenced by auth profile {auth_profile_id}")]
SecretReferencedByAuthProfile {
secret_id: String,
auth_profile_id: String,
},
#[error("invocation log {log_id} was not found")]
InvocationLogNotFound { log_id: String },
#[error("agent {agent_id} was not found")]
AgentNotFound { agent_id: String },
#[error("published agent {workspace_slug}/{agent_slug} was not found")]
PublishedAgentNotFound {
workspace_slug: String,
agent_slug: String,
},
#[error("operation {operation_id} already exists")]
OperationAlreadyExists { operation_id: String },
#[error("operation {operation_id} was not found")]
OperationNotFound { operation_id: String },
#[error("operation version {version} for {operation_id} was not found")]
OperationVersionNotFound { operation_id: String, version: u32 },
#[error("operation {operation_id} cannot be deleted while it is bound to a published agent")]
OperationHasPublishedAgentBindings { operation_id: String },
#[error("operation {operation_id} must start with version 1, got {version}")]
InvalidInitialVersion { operation_id: String, version: u32 },
#[error("operation {operation_id} expected next version {expected}, got {actual}")]
InvalidVersionSequence {
operation_id: String,
expected: u32,
actual: u32,
},
#[error("agent {agent_id} expected next version {expected}, got {actual}")]
InvalidAgentVersionSequence {
agent_id: String,
expected: u32,
actual: u32,
},
#[error("operation {operation_id} changed immutable field {field}")]
ImmutableOperationFieldChanged {
operation_id: String,
field: &'static str,
},
#[error("auth profile {auth_profile_id} was not found")]
AuthProfileNotFound { auth_profile_id: String },
#[error("yaml import job {job_id} was not found")]
YamlImportJobNotFound { job_id: String },
#[error("unsupported enum representation for field {field}")]
InvalidEnumRepresentation { field: &'static str },
#[error("invalid numeric value for field {field}: {value}")]
InvalidNumericValue { field: &'static str, value: i64 },
}
+77
View File
@@ -0,0 +1,77 @@
use std::sync::Arc;
use sqlx::{PgPool, query};
use crate::RegistryError;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ExtensionMigration {
pub version: u32,
pub sql: &'static str,
}
pub trait RegistryExtension: Send + Sync {
fn name(&self) -> &str;
fn migrations(&self) -> &[ExtensionMigration];
}
pub async fn apply_extension_migrations(
pool: &PgPool,
extensions: &[Arc<dyn RegistryExtension>],
) -> Result<(), RegistryError> {
query(
"create table if not exists __crank_ext_migrations (
extension_name text not null,
version integer not null,
applied_at timestamptz not null default now(),
primary key (extension_name, version)
)",
)
.execute(pool)
.await?;
for extension in extensions {
for migration in extension.migrations() {
let already_applied = query(
"select 1
from __crank_ext_migrations
where extension_name = $1 and version = $2",
)
.bind(extension.name())
.bind(i32::try_from(migration.version).map_err(|_| {
RegistryError::InvalidNumericValue {
field: "extension_migration.version",
value: migration.version as i64,
}
})?)
.fetch_optional(pool)
.await?
.is_some();
if already_applied {
continue;
}
let mut tx = pool.begin().await?;
query(migration.sql).execute(&mut *tx).await?;
query(
"insert into __crank_ext_migrations (extension_name, version)
values ($1, $2)",
)
.bind(extension.name())
.bind(i32::try_from(migration.version).map_err(|_| {
RegistryError::InvalidNumericValue {
field: "extension_migration.version",
value: migration.version as i64,
}
})?)
.execute(&mut *tx)
.await?;
tx.commit().await?;
}
}
Ok(())
}
+26
View File
@@ -0,0 +1,26 @@
mod error;
mod ext;
mod migrations;
mod model;
mod postgres;
pub use error::RegistryError;
pub use ext::{ExtensionMigration, RegistryExtension, apply_extension_migrations};
pub use model::{
AgentSummary, AgentVersionRecord, AsyncJobFilter, AsyncJobRecord, AuthUserRecord,
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateAsyncJobRequest,
CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
CreateSecretRequest, CreateStreamSessionRequest, CreateVersionRequest, CreateWorkspaceRequest,
CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata, InvitationRecord,
InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, Page,
PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool,
RegistryOperation, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SecretRecord,
SecretVersionRecord, SessionRecord, StreamSessionFilter, StreamSessionRecord,
UpdateAsyncJobStatusRequest, UpdateStreamSessionStateRequest, UpdateWorkspaceRequest,
UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery, UsageRollupRecord,
UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, YamlImportJob,
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
};
pub use postgres::{PostgresPoolConfig, PostgresPoolConfigError, PostgresRegistry};
+604
View File
@@ -0,0 +1,604 @@
use sqlx::{PgPool, query};
pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
query(
"create table if not exists workspaces (
id text primary key,
slug text not null unique,
display_name text not null,
status text not null,
settings_json jsonb not null default '{}'::jsonb,
created_at timestamptz not null,
updated_at timestamptz not null
)",
)
.execute(pool)
.await?;
query(
"create table if not exists users (
id text primary key,
email text not null unique,
display_name text not null,
password_hash text null,
status text not null,
created_at timestamptz not null
)",
)
.execute(pool)
.await?;
query("alter table users add column if not exists password_hash text null")
.execute(pool)
.await?;
query(
"insert into users (
id,
email,
display_name,
status,
created_at
) values (
'user_default_owner',
'owner@crank.local',
'Workspace Owner',
'active',
now()
)
on conflict (id) do nothing",
)
.execute(pool)
.await?;
query(
"create table if not exists memberships (
workspace_id text not null references workspaces(id) on delete cascade,
user_id text not null references users(id) on delete cascade,
role text not null,
created_at timestamptz not null,
primary key (workspace_id, user_id)
)",
)
.execute(pool)
.await?;
query(
"create table if not exists user_sessions (
id text primary key,
user_id text not null references users(id) on delete cascade,
current_workspace_id text null references workspaces(id) on delete set null,
secret_hash text not null,
status text not null,
expires_at timestamptz not null,
last_seen_at timestamptz null,
created_at timestamptz not null
)",
)
.execute(pool)
.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)
.await?;
query(
"insert into workspaces (
id,
slug,
display_name,
status,
settings_json,
created_at,
updated_at
) values (
'ws_default',
'default',
'Default Workspace',
'active',
'{}'::jsonb,
now(),
now()
)
on conflict (id) do nothing",
)
.execute(pool)
.await?;
query(
"insert into memberships (
workspace_id,
user_id,
role,
created_at
) values (
'ws_default',
'user_default_owner',
'owner',
now()
)
on conflict (workspace_id, user_id) do nothing",
)
.execute(pool)
.await?;
query(
"create table if not exists invitation_tokens (
id text primary key,
workspace_id text not null references workspaces(id) on delete cascade,
email text not null,
role text not null,
status text not null,
token_hash text not null,
expires_at timestamptz not null,
created_at timestamptz not null
)",
)
.execute(pool)
.await?;
query(
"create table if not exists platform_api_keys (
id text primary key,
workspace_id text not null references workspaces(id) on delete cascade,
agent_id text null,
name text not null,
prefix text not null,
secret_hash text not null,
scopes_json jsonb not null,
status text not null,
created_at timestamptz not null,
last_used_at timestamptz null,
revoked_at timestamptz null
)",
)
.execute(pool)
.await?;
query(
"create unique index if not exists platform_api_keys_workspace_name_idx on platform_api_keys(workspace_id, name)",
)
.execute(pool)
.await?;
query("alter table platform_api_keys add column if not exists agent_id text null")
.execute(pool)
.await?;
query(
"insert into workspaces (
id,
slug,
display_name,
status,
settings_json,
created_at,
updated_at
) values (
'ws_default',
'default',
'Default Workspace',
'active',
'{}'::jsonb,
now(),
now()
)
on conflict (id) do nothing",
)
.execute(pool)
.await?;
query(
"create table if not exists operations (
id text primary key,
workspace_id text null references workspaces(id) on delete cascade,
name text not null,
display_name text not null,
category text not null default 'general',
protocol text not null,
security_level text not null default 'standard',
status text not null,
current_draft_version integer not null default 1,
latest_published_version integer null,
created_at timestamptz not null,
updated_at timestamptz not null,
published_at timestamptz null
)",
)
.execute(pool)
.await?;
query("alter table operations add column if not exists workspace_id text null references workspaces(id) on delete cascade")
.execute(pool)
.await?;
query(
"alter table operations add column if not exists category text not null default 'general'",
)
.execute(pool)
.await?;
query(
"alter table operations add column if not exists security_level text not null default 'standard'",
)
.execute(pool)
.await?;
query("update operations set workspace_id = 'ws_default' where workspace_id is null")
.execute(pool)
.await?;
query("alter table operations alter column workspace_id set not null")
.execute(pool)
.await?;
query("alter table operations drop constraint if exists operations_name_key")
.execute(pool)
.await?;
query(
"create unique index if not exists operations_workspace_name_idx on operations(workspace_id, name)",
)
.execute(pool)
.await?;
query(
"create table if not exists operation_versions (
operation_id text not null references operations(id) on delete cascade,
version integer not null,
status text not null,
target_json jsonb not null,
input_schema_json jsonb not null,
output_schema_json jsonb not null,
input_mapping_json jsonb not null,
output_mapping_json jsonb not null,
execution_config_json jsonb not null,
tool_description_json jsonb not null,
samples_json jsonb null,
generated_draft_json jsonb null,
config_export_json jsonb null,
wizard_state_json jsonb null,
change_note text null,
created_at timestamptz not null,
created_by text null,
primary key (operation_id, version)
)",
)
.execute(pool)
.await?;
query("alter table operation_versions add column if not exists wizard_state_json jsonb null")
.execute(pool)
.await?;
query(
"create table if not exists published_operations (
operation_id text primary key references operations(id) on delete cascade,
version integer not null,
published_at timestamptz not null,
published_by text null,
foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade
)",
)
.execute(pool)
.await?;
query(
"create table if not exists operation_samples (
id text primary key,
operation_id text not null references operations(id) on delete cascade,
version integer not null,
sample_kind text not null,
storage_ref text not null,
content_type text not null,
file_name text null,
created_at timestamptz not null,
foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade
)",
)
.execute(pool)
.await?;
query(
"create table if not exists descriptors (
id text primary key,
operation_id text null references operations(id) on delete cascade,
version integer null,
descriptor_kind text not null,
storage_ref text not null,
source_name text null,
package_index_json jsonb null,
created_at timestamptz not null,
foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade
)",
)
.execute(pool)
.await?;
query(
"create table if not exists auth_profiles (
id text primary key,
workspace_id text null references workspaces(id) on delete cascade,
name text not null,
kind text not null,
config_json jsonb not null,
created_at timestamptz not null,
updated_at timestamptz not null
)",
)
.execute(pool)
.await?;
query("alter table auth_profiles add column if not exists workspace_id text null references workspaces(id) on delete cascade")
.execute(pool)
.await?;
query("update auth_profiles set workspace_id = 'ws_default' where workspace_id is null")
.execute(pool)
.await?;
query("alter table auth_profiles alter column workspace_id set not null")
.execute(pool)
.await?;
query("alter table auth_profiles drop constraint if exists auth_profiles_name_key")
.execute(pool)
.await?;
query(
"create unique index if not exists auth_profiles_workspace_name_idx on auth_profiles(workspace_id, name)",
)
.execute(pool)
.await?;
query(
"create table if not exists secrets (
id text primary key,
workspace_id text not null references workspaces(id) on delete cascade,
name text not null,
kind text not null,
status text not null,
current_version integer not null,
last_used_at timestamptz null,
created_at timestamptz not null,
updated_at timestamptz not null
)",
)
.execute(pool)
.await?;
query(
"create unique index if not exists secrets_workspace_name_idx on secrets(workspace_id, name)",
)
.execute(pool)
.await?;
query(
"create table if not exists secret_versions (
secret_id text not null references secrets(id) on delete cascade,
version integer not null,
ciphertext text not null,
key_version text not null,
created_at timestamptz not null,
created_by text null references users(id) on delete set null,
primary key (secret_id, version)
)",
)
.execute(pool)
.await?;
query(
"create table if not exists yaml_import_jobs (
id text primary key,
source_sample_id text null references operation_samples(id) on delete set null,
status text not null,
format_version text not null,
mode text not null,
result_operation_id text null references operations(id) on delete set null,
result_version integer null,
error_text text null,
created_at timestamptz not null,
finished_at timestamptz null
)",
)
.execute(pool)
.await?;
query(
"create table if not exists agents (
id text primary key,
workspace_id text not null references workspaces(id) on delete cascade,
slug text not null,
display_name text not null,
description text not null,
status text not null,
current_draft_version integer not null default 1,
latest_published_version integer null,
created_at timestamptz not null,
updated_at timestamptz not null,
published_at timestamptz null
)",
)
.execute(pool)
.await?;
query(
"create unique index if not exists agents_workspace_slug_idx on agents(workspace_id, slug)",
)
.execute(pool)
.await?;
query(
"create table if not exists agent_versions (
agent_id text not null references agents(id) on delete cascade,
version integer not null,
status text not null,
instructions_json jsonb not null,
tool_selection_policy_json jsonb not null,
created_at timestamptz not null,
primary key (agent_id, version)
)",
)
.execute(pool)
.await?;
query(
"create table if not exists agent_operation_bindings (
agent_id text not null references agents(id) on delete cascade,
agent_version integer not null,
operation_id text not null references operations(id) on delete cascade,
operation_version integer not null,
tool_name text not null,
tool_title text not null,
tool_description_override text null,
enabled boolean not null default true,
foreign key (agent_id, agent_version) references agent_versions(agent_id, version) on delete cascade,
foreign key (operation_id, operation_version) references operation_versions(operation_id, version) on delete cascade
)",
)
.execute(pool)
.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)
.await?;
query(
"create table if not exists published_agents (
agent_id text primary key references agents(id) on delete cascade,
version integer not null,
published_at timestamptz not null,
published_by text null,
foreign key (agent_id, version) references agent_versions(agent_id, version) on delete cascade
)",
)
.execute(pool)
.await?;
query(
"create table if not exists invocation_logs (
id text primary key,
workspace_id text not null references workspaces(id) on delete cascade,
agent_id text null references agents(id) on delete set null,
operation_id text not null references operations(id) on delete cascade,
source text not null,
level text not null,
status text not null,
tool_name text not null,
message text not null,
request_id text null,
status_code integer null,
duration_ms bigint not null,
error_kind text null,
request_preview_json jsonb not null,
response_preview_json jsonb not null,
created_at timestamptz not null
)",
)
.execute(pool)
.await?;
query(
"create index if not exists invocation_logs_workspace_created_idx on invocation_logs(workspace_id, created_at desc)",
)
.execute(pool)
.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)
.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)
.await?;
query(
"create table if not exists usage_rollups (
workspace_id text not null references workspaces(id) on delete cascade,
agent_id text null references agents(id) on delete cascade,
operation_id text null references operations(id) on delete cascade,
period text not null,
calls_total bigint not null,
calls_ok bigint not null,
calls_error bigint not null,
p50_ms bigint not null,
p95_ms bigint not null,
p99_ms bigint not null,
updated_at timestamptz not null
)",
)
.execute(pool)
.await?;
query(
"create table if not exists stream_sessions (
id text primary key,
workspace_id text not null references workspaces(id) on delete cascade,
agent_id text null references agents(id) on delete set null,
operation_id text not null references operations(id) on delete cascade,
protocol text not null,
mode text not null,
status text not null,
cursor_json jsonb null,
state_json jsonb not null,
expires_at timestamptz not null,
last_poll_at timestamptz null,
created_at timestamptz not null,
closed_at timestamptz null
)",
)
.execute(pool)
.await?;
query(
"create index if not exists stream_sessions_workspace_status_idx
on stream_sessions(workspace_id, status, created_at desc)",
)
.execute(pool)
.await?;
query(
"create index if not exists stream_sessions_expires_at_idx
on stream_sessions(expires_at)",
)
.execute(pool)
.await?;
query(
"create table if not exists async_jobs (
id text primary key,
workspace_id text not null references workspaces(id) on delete cascade,
agent_id text null references agents(id) on delete set null,
operation_id text not null references operations(id) on delete cascade,
status text not null,
progress_json jsonb not null,
result_json jsonb null,
error_json jsonb null,
expires_at timestamptz null,
last_poll_at timestamptz null,
created_at timestamptz not null,
updated_at timestamptz not null,
finished_at timestamptz null
)",
)
.execute(pool)
.await?;
query("alter table async_jobs add column if not exists last_poll_at timestamptz null")
.execute(pool)
.await?;
query(
"create index if not exists async_jobs_workspace_status_idx
on async_jobs(workspace_id, status, updated_at desc)",
)
.execute(pool)
.await?;
query(
"create index if not exists async_jobs_expires_at_idx
on async_jobs(expires_at)",
)
.execute(pool)
.await?;
Ok(())
}
+534
View File
@@ -0,0 +1,534 @@
use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AsyncJobHandle, AsyncJobId,
AuthProfile, DescriptorId, ExecutionMode, ExportMode, InvitationToken, InvocationLevel,
InvocationLog, InvocationSource, JobStatus, MembershipRole, Operation, OperationId,
OperationSecurityLevel, OperationStatus, PlatformApiKey, Protocol, SampleId, Secret, SecretId,
SecretVersion, StreamSession, StreamSessionId, StreamStatus, UsagePeriod, UsageRollup, User,
UserSessionId, Workspace, WorkspaceId,
};
use crank_mapping::MappingSet;
use crank_schema::Schema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use time::OffsetDateTime;
macro_rules! define_registry_id {
($name:ident) => {
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct $name(String);
impl $name {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<String> for $name {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<&str> for $name {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}
};
}
define_registry_id!(YamlImportJobId);
pub type RegistryOperation = Operation<Schema, MappingSet>;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Page<T> {
pub items: Vec<T>,
pub total: u64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct WorkspaceRecord {
pub workspace: Workspace,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MembershipRecord {
pub workspace_id: WorkspaceId,
pub user: User,
pub role: MembershipRole,
pub created_at: OffsetDateTime,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct WorkspaceMembershipRecord {
pub workspace: Workspace,
pub role: MembershipRole,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AuthUserRecord {
pub user: User,
pub password_hash: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SessionRecord {
pub session_id: UserSessionId,
pub user: User,
pub memberships: Vec<WorkspaceMembershipRecord>,
pub current_workspace_id: Option<WorkspaceId>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct InvitationRecord {
pub invitation: InvitationToken,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PlatformApiKeyRecord {
pub api_key: PlatformApiKey,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SecretRecord {
pub secret: Secret,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SecretVersionRecord {
pub secret_version: SecretVersion,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StreamSessionRecord {
pub session: StreamSession,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AsyncJobRecord {
pub job: AsyncJobHandle,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct InvocationLogRecord {
pub log: InvocationLog,
pub operation_name: String,
pub operation_display_name: String,
pub agent_slug: Option<String>,
pub agent_display_name: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UsageRollupRecord {
pub rollup: UsageRollup,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UsageTimelinePoint {
#[serde(with = "time::serde::rfc3339")]
pub bucket_start: OffsetDateTime,
pub calls_ok: u64,
pub calls_error: u64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UsageOperationBreakdown {
pub operation_id: OperationId,
pub operation_name: String,
pub operation_display_name: String,
pub protocol: Protocol,
pub calls_total: u64,
pub calls_error: u64,
pub p50_ms: u64,
pub p95_ms: u64,
pub p99_ms: u64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UsageAgentBreakdown {
pub agent_id: AgentId,
pub agent_slug: String,
pub agent_display_name: String,
pub calls_total: u64,
pub calls_error: u64,
pub p50_ms: u64,
pub p95_ms: u64,
pub p99_ms: u64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AgentSummary {
pub id: AgentId,
pub workspace_id: WorkspaceId,
pub slug: String,
pub display_name: String,
pub description: String,
pub status: AgentStatus,
pub current_draft_version: u32,
pub latest_published_version: Option<u32>,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
pub published_at: Option<OffsetDateTime>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AgentVersionRecord {
pub agent_id: AgentId,
pub workspace_id: WorkspaceId,
pub version: u32,
pub status: AgentStatus,
pub created_at: OffsetDateTime,
pub snapshot: AgentVersion,
pub bindings: Vec<AgentOperationBinding>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PublishedAgentTool {
pub workspace_id: WorkspaceId,
pub workspace_slug: String,
pub agent_id: AgentId,
pub agent_slug: String,
pub operation: RegistryOperation,
pub tool_name: String,
pub tool_title: String,
pub tool_description: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OperationSummary {
pub id: OperationId,
pub workspace_id: WorkspaceId,
pub name: String,
pub display_name: String,
pub category: String,
pub protocol: Protocol,
pub security_level: OperationSecurityLevel,
pub target_url: String,
pub target_action: String,
pub status: OperationStatus,
pub current_draft_version: u32,
pub latest_published_version: Option<u32>,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
pub published_at: Option<OffsetDateTime>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OperationUsageSummary {
pub operation_id: OperationId,
pub calls_today: u64,
pub error_rate_pct: f64,
pub avg_latency_ms: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OperationAgentRef {
pub operation_id: OperationId,
pub agent_id: AgentId,
pub agent_slug: String,
pub display_name: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OperationVersionRecord {
pub operation_id: OperationId,
pub workspace_id: WorkspaceId,
pub version: u32,
pub status: OperationStatus,
pub change_note: Option<String>,
pub created_at: OffsetDateTime,
pub created_by: Option<String>,
pub snapshot: RegistryOperation,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SampleKind {
InputJson,
OutputJson,
YamlImportSource,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OperationSampleMetadata {
pub id: SampleId,
pub operation_id: OperationId,
pub version: u32,
pub sample_kind: SampleKind,
pub storage_ref: String,
pub content_type: String,
pub file_name: Option<String>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DescriptorKind {
ProtoUpload,
DescriptorSet,
ReflectionSnapshot,
WsdlUpload,
XsdUpload,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DescriptorMetadata {
pub id: DescriptorId,
pub operation_id: Option<OperationId>,
pub version: Option<u32>,
pub descriptor_kind: DescriptorKind,
pub storage_ref: String,
pub source_name: Option<String>,
pub package_index: Option<Value>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum YamlImportJobStatus {
Pending,
Completed,
Failed,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct YamlImportJob {
pub id: YamlImportJobId,
pub source_sample_id: Option<SampleId>,
pub status: YamlImportJobStatus,
pub format_version: String,
pub mode: ExportMode,
pub result_operation_id: Option<OperationId>,
pub result_version: Option<u32>,
pub error_text: Option<String>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
pub finished_at: Option<OffsetDateTime>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct YamlImportJobCompletion {
pub status: YamlImportJobStatus,
pub result_operation_id: Option<OperationId>,
pub result_version: Option<u32>,
pub error_text: Option<String>,
#[serde(with = "time::serde::rfc3339")]
pub finished_at: OffsetDateTime,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CreateInvocationLogRequest<'a> {
pub log: &'a InvocationLog,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ListInvocationLogsQuery<'a> {
pub workspace_id: &'a WorkspaceId,
pub level: Option<InvocationLevel>,
pub search_text: Option<&'a str>,
pub source: Option<InvocationSource>,
pub operation_id: Option<&'a OperationId>,
pub agent_id: Option<&'a AgentId>,
pub created_after: Option<&'a str>,
pub limit: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UsageBucket {
Hour,
Day,
Week,
Month,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UsageQuery<'a> {
pub workspace_id: &'a WorkspaceId,
pub period: UsagePeriod,
pub source: Option<InvocationSource>,
pub created_after: &'a str,
pub bucket: UsageBucket,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UsageSummary {
pub rollup: UsageRollup,
pub success_rate: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CreateVersionRequest<'a> {
pub workspace_id: &'a WorkspaceId,
pub snapshot: &'a RegistryOperation,
pub change_note: Option<&'a str>,
pub created_by: Option<&'a str>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublishRequest<'a> {
pub workspace_id: &'a WorkspaceId,
pub operation_id: &'a OperationId,
pub version: u32,
pub published_at: &'a OffsetDateTime,
pub published_by: Option<&'a str>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateYamlImportJobRequest<'a> {
pub id: &'a YamlImportJobId,
pub source_sample_id: Option<&'a SampleId>,
pub format_version: &'a str,
pub mode: ExportMode,
pub created_at: &'a OffsetDateTime,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SaveAuthProfileRequest<'a> {
pub workspace_id: &'a WorkspaceId,
pub profile: &'a AuthProfile,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CreateWorkspaceRequest<'a> {
pub workspace: &'a Workspace,
}
#[derive(Clone, Debug, PartialEq)]
pub struct UpdateWorkspaceRequest<'a> {
pub workspace: &'a Workspace,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CreateAgentRequest<'a> {
pub agent: &'a Agent,
pub version: &'a AgentVersion,
pub bindings: &'a [AgentOperationBinding],
}
#[derive(Clone, Debug, PartialEq)]
pub struct CreateAgentDraftVersionRequest<'a> {
pub workspace_id: &'a WorkspaceId,
pub agent_id: &'a AgentId,
pub version: &'a AgentVersion,
pub bindings: &'a [AgentOperationBinding],
pub updated_at: &'a OffsetDateTime,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SaveAgentBindingsRequest<'a> {
pub workspace_id: &'a WorkspaceId,
pub agent_id: &'a AgentId,
pub agent_version: u32,
pub bindings: &'a [AgentOperationBinding],
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublishAgentRequest<'a> {
pub workspace_id: &'a WorkspaceId,
pub agent_id: &'a AgentId,
pub version: u32,
pub published_at: &'a OffsetDateTime,
pub published_by: Option<&'a str>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateInvitationRequest<'a> {
pub invitation: &'a InvitationToken,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CreatePlatformApiKeyRequest<'a> {
pub api_key: &'a PlatformApiKey,
pub secret_hash: &'a str,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CreateSecretRequest<'a> {
pub secret: &'a Secret,
pub ciphertext: &'a str,
pub key_version: &'a str,
pub created_by: Option<&'a crank_core::UserId>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RotateSecretRequest<'a> {
pub workspace_id: &'a WorkspaceId,
pub secret_id: &'a SecretId,
pub ciphertext: &'a str,
pub key_version: &'a str,
pub created_at: &'a OffsetDateTime,
pub updated_at: &'a OffsetDateTime,
pub created_by: Option<&'a crank_core::UserId>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CreateStreamSessionRequest<'a> {
pub session: &'a StreamSession,
}
#[derive(Clone, Debug, PartialEq)]
pub struct UpdateStreamSessionStateRequest<'a> {
pub session_id: &'a StreamSessionId,
pub current_status: StreamStatus,
pub next_status: StreamStatus,
pub cursor: Option<&'a Value>,
pub state: &'a Value,
pub expires_at: Option<&'a OffsetDateTime>,
pub last_poll_at: Option<&'a OffsetDateTime>,
pub closed_at: Option<&'a OffsetDateTime>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StreamSessionFilter<'a> {
pub workspace_id: &'a WorkspaceId,
pub agent_id: Option<&'a AgentId>,
pub operation_id: Option<&'a OperationId>,
pub status: Option<StreamStatus>,
pub mode: Option<ExecutionMode>,
pub limit: u32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CreateAsyncJobRequest<'a> {
pub job: &'a AsyncJobHandle,
}
#[derive(Clone, Debug, PartialEq)]
pub struct UpdateAsyncJobStatusRequest<'a> {
pub job_id: &'a AsyncJobId,
pub current_status: JobStatus,
pub next_status: JobStatus,
pub progress: &'a Value,
pub result: Option<&'a Value>,
pub error: Option<&'a Value>,
pub expires_at: Option<&'a OffsetDateTime>,
pub updated_at: &'a OffsetDateTime,
pub finished_at: Option<&'a OffsetDateTime>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AsyncJobFilter<'a> {
pub workspace_id: &'a WorkspaceId,
pub agent_id: Option<&'a AgentId>,
pub operation_id: Option<&'a OperationId>,
pub status: Option<JobStatus>,
pub limit: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SaveSampleMetadataRequest<'a> {
pub sample: &'a OperationSampleMetadata,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SaveDescriptorMetadataRequest<'a> {
pub descriptor: &'a DescriptorMetadata,
}
+603
View File
@@ -0,0 +1,603 @@
use super::*;
impl PostgresRegistry {
pub async fn list_agents(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<AgentSummary>, RegistryError> {
let rows = sqlx::query!(
"select
id,
workspace_id,
slug,
display_name,
description,
status,
current_draft_version,
latest_published_version,
created_at as \"created_at!: time::OffsetDateTime\",
updated_at as \"updated_at!: time::OffsetDateTime\",
published_at as \"published_at: time::OffsetDateTime\"
from agents
where workspace_id = $1
order by slug asc",
workspace_id.as_str(),
)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
build_agent_summary(
row.id,
row.workspace_id,
row.slug,
row.display_name,
row.description,
row.status,
row.current_draft_version,
row.latest_published_version,
row.created_at,
row.updated_at,
row.published_at,
)
})
.collect()
}
pub async fn get_agent_summary(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
) -> Result<Option<AgentSummary>, RegistryError> {
let row = sqlx::query!(
"select
id,
workspace_id,
slug,
display_name,
description,
status,
current_draft_version,
latest_published_version,
created_at as \"created_at!: time::OffsetDateTime\",
updated_at as \"updated_at!: time::OffsetDateTime\",
published_at as \"published_at: time::OffsetDateTime\"
from agents
where workspace_id = $1 and id = $2",
workspace_id.as_str(),
agent_id.as_str(),
)
.fetch_optional(&self.pool)
.await?;
row.map(|row| {
build_agent_summary(
row.id,
row.workspace_id,
row.slug,
row.display_name,
row.description,
row.status,
row.current_draft_version,
row.latest_published_version,
row.created_at,
row.updated_at,
row.published_at,
)
})
.transpose()
}
pub async fn create_agent(&self, request: CreateAgentRequest<'_>) -> Result<(), RegistryError> {
let mut tx = self.pool.begin().await?;
sqlx::query(
"insert into agents (
id,
workspace_id,
slug,
display_name,
description,
status,
current_draft_version,
latest_published_version,
created_at,
updated_at,
published_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9::timestamptz, $10::timestamptz, $11::timestamptz
)",
)
.bind(request.agent.id.as_str())
.bind(request.agent.workspace_id.as_str())
.bind(&request.agent.slug)
.bind(&request.agent.display_name)
.bind(&request.agent.description)
.bind(serialize_enum_text(&request.agent.status, "status")?)
.bind(to_db_version(request.agent.current_draft_version))
.bind(request.agent.latest_published_version.map(to_db_version))
.bind(request.agent.created_at)
.bind(request.agent.updated_at)
.bind(request.agent.published_at)
.execute(&mut *tx)
.await?;
insert_agent_version_row(&mut tx, request.version).await?;
replace_agent_bindings_rows(
&mut tx,
&request.agent.id,
request.version.version,
request.bindings,
)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn create_agent_draft_version(
&self,
request: CreateAgentDraftVersionRequest<'_>,
) -> Result<(), RegistryError> {
let Some(summary) = self
.get_agent_summary(request.workspace_id, request.agent_id)
.await?
else {
return Err(RegistryError::AgentNotFound {
agent_id: request.agent_id.as_str().to_owned(),
});
};
let expected = summary.current_draft_version + 1;
if request.version.version != expected {
return Err(RegistryError::InvalidAgentVersionSequence {
agent_id: request.agent_id.as_str().to_owned(),
expected,
actual: request.version.version,
});
}
let mut tx = self.pool.begin().await?;
insert_agent_version_row(&mut tx, request.version).await?;
replace_agent_bindings_rows(
&mut tx,
request.agent_id,
request.version.version,
request.bindings,
)
.await?;
sqlx::query(
"update agents
set current_draft_version = $3,
updated_at = $4::timestamptz
where workspace_id = $1 and id = $2",
)
.bind(request.workspace_id.as_str())
.bind(request.agent_id.as_str())
.bind(to_db_version(request.version.version))
.bind(request.updated_at)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn get_agent_version(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
version: u32,
) -> Result<Option<AgentVersionRecord>, RegistryError> {
let summary = match self.get_agent_summary(workspace_id, agent_id).await? {
Some(value) => value,
None => return Ok(None),
};
let row = sqlx::query!(
"select
agent_id,
version,
status,
instructions_json,
tool_selection_policy_json,
created_at as \"created_at!: time::OffsetDateTime\"
from agent_versions
where agent_id = $1 and version = $2",
agent_id.as_str(),
to_db_version(version),
)
.fetch_optional(&self.pool)
.await?;
let Some(row) = row else {
return Ok(None);
};
let bindings = self.list_agent_bindings(agent_id, version).await?;
Ok(Some(build_agent_version_record(
&summary,
row.version,
row.status,
row.instructions_json,
row.tool_selection_policy_json,
row.created_at,
bindings,
)?))
}
pub async fn save_agent_bindings(
&self,
request: SaveAgentBindingsRequest<'_>,
) -> Result<(), RegistryError> {
if self
.get_agent_summary(request.workspace_id, request.agent_id)
.await?
.is_none()
{
return Err(RegistryError::AgentNotFound {
agent_id: request.agent_id.as_str().to_owned(),
});
}
let mut tx = self.pool.begin().await?;
replace_agent_bindings_rows(
&mut tx,
request.agent_id,
request.agent_version,
request.bindings,
)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn update_agent_summary(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
slug: &str,
display_name: &str,
description: &str,
updated_at: &time::OffsetDateTime,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"update agents
set slug = $3,
display_name = $4,
description = $5,
updated_at = $6::timestamptz
where workspace_id = $1 and id = $2",
)
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.bind(slug)
.bind(display_name)
.bind(description)
.bind(updated_at)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::AgentNotFound {
agent_id: agent_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn delete_agent(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
) -> Result<(), RegistryError> {
let result = sqlx::query("delete from agents where workspace_id = $1 and id = $2")
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::AgentNotFound {
agent_id: agent_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn publish_agent(
&self,
request: PublishAgentRequest<'_>,
) -> Result<(), RegistryError> {
if self
.get_agent_version(request.workspace_id, request.agent_id, request.version)
.await?
.is_none()
{
return Err(RegistryError::AgentNotFound {
agent_id: request.agent_id.as_str().to_owned(),
});
}
let mut tx = self.pool.begin().await?;
sqlx::query(
"insert into published_agents (
agent_id,
version,
published_at,
published_by
) values ($1, $2, $3::timestamptz, $4)
on conflict(agent_id) do update set
version = excluded.version,
published_at = excluded.published_at,
published_by = excluded.published_by",
)
.bind(request.agent_id.as_str())
.bind(to_db_version(request.version))
.bind(request.published_at)
.bind(request.published_by)
.execute(&mut *tx)
.await?;
sqlx::query(
"update agent_versions
set status = $1
where agent_id = $2 and version = $3",
)
.bind(serialize_enum_text(&AgentStatus::Published, "status")?)
.bind(request.agent_id.as_str())
.bind(to_db_version(request.version))
.execute(&mut *tx)
.await?;
sqlx::query(
"update agents
set status = $1,
latest_published_version = $2,
published_at = $3::timestamptz,
updated_at = $4::timestamptz
where id = $5 and workspace_id = $6",
)
.bind(serialize_enum_text(&AgentStatus::Published, "status")?)
.bind(to_db_version(request.version))
.bind(request.published_at)
.bind(request.published_at)
.bind(request.agent_id.as_str())
.bind(request.workspace_id.as_str())
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn unpublish_agent(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
updated_at: &time::OffsetDateTime,
) -> Result<(), RegistryError> {
let mut tx = self.pool.begin().await?;
sqlx::query("delete from published_agents where agent_id = $1")
.bind(agent_id.as_str())
.execute(&mut *tx)
.await?;
sqlx::query(
"update agent_versions
set status = $1
where agent_id = $2
and version = (
select current_draft_version
from agents
where id = $2 and workspace_id = $3
)",
)
.bind(serialize_enum_text(&AgentStatus::Draft, "status")?)
.bind(agent_id.as_str())
.bind(workspace_id.as_str())
.execute(&mut *tx)
.await?;
let result = sqlx::query(
"update agents
set status = $1,
published_at = null,
updated_at = $2::timestamptz
where id = $3 and workspace_id = $4",
)
.bind(serialize_enum_text(&AgentStatus::Draft, "status")?)
.bind(updated_at)
.bind(agent_id.as_str())
.bind(workspace_id.as_str())
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::AgentNotFound {
agent_id: agent_id.as_str().to_owned(),
});
}
tx.commit().await?;
Ok(())
}
pub async fn archive_agent(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
updated_at: &time::OffsetDateTime,
) -> Result<(), RegistryError> {
let mut tx = self.pool.begin().await?;
sqlx::query("delete from published_agents where agent_id = $1")
.bind(agent_id.as_str())
.execute(&mut *tx)
.await?;
sqlx::query(
"update agent_versions
set status = $1
where agent_id = $2
and version = (
select current_draft_version
from agents
where id = $2 and workspace_id = $3
)",
)
.bind(serialize_enum_text(&AgentStatus::Archived, "status")?)
.bind(agent_id.as_str())
.bind(workspace_id.as_str())
.execute(&mut *tx)
.await?;
let result = sqlx::query(
"update agents
set status = $1,
published_at = null,
updated_at = $2::timestamptz
where id = $3 and workspace_id = $4",
)
.bind(serialize_enum_text(&AgentStatus::Archived, "status")?)
.bind(updated_at)
.bind(agent_id.as_str())
.bind(workspace_id.as_str())
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::AgentNotFound {
agent_id: agent_id.as_str().to_owned(),
});
}
tx.commit().await?;
Ok(())
}
pub async fn get_published_agent_tools_by_slug(
&self,
workspace_slug: &str,
agent_slug: &str,
) -> Result<Vec<PublishedAgentTool>, RegistryError> {
let rows = sqlx::query!(
"select
w.id as workspace_id,
w.slug as workspace_slug,
a.id as agent_id,
a.slug as agent_slug,
b.tool_name,
b.tool_title,
coalesce(b.tool_description_override, ov.tool_description_json->>'description') as \"tool_description!\",
o.id,
o.name,
o.display_name,
o.category,
o.protocol,
o.security_level,
o.created_at as \"operation_created_at!: time::OffsetDateTime\",
o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",
o.published_at as \"operation_published_at: time::OffsetDateTime\",
ov.version,
ov.status,
ov.target_json,
ov.input_schema_json,
ov.output_schema_json,
ov.input_mapping_json,
ov.output_mapping_json,
ov.execution_config_json,
ov.tool_description_json,
ov.samples_json,
ov.generated_draft_json,
ov.config_export_json,
ov.wizard_state_json,
ov.change_note,
ov.created_at as \"created_at!: time::OffsetDateTime\",
ov.created_by
from workspaces w
join agents a on a.workspace_id = w.id
join published_agents pa on pa.agent_id = a.id
join agent_operation_bindings b on b.agent_id = a.id and b.agent_version = pa.version
join operation_versions ov on ov.operation_id = b.operation_id and ov.version = b.operation_version
join operations o on o.id = ov.operation_id and o.workspace_id = w.id
where w.slug = $1 and a.slug = $2 and b.enabled = true
order by b.tool_name asc",
workspace_slug,
agent_slug,
)
.fetch_all(&self.pool)
.await?;
if rows.is_empty() {
let exists = sqlx::query!(
"select 1 as \"present!\"
from workspaces w
join agents a on a.workspace_id = w.id
join published_agents pa on pa.agent_id = a.id
where w.slug = $1 and a.slug = $2",
workspace_slug,
agent_slug,
)
.fetch_optional(&self.pool)
.await?;
if exists.is_none() {
return Err(RegistryError::PublishedAgentNotFound {
workspace_slug: workspace_slug.to_owned(),
agent_slug: agent_slug.to_owned(),
});
}
}
rows.into_iter()
.map(|row| {
let workspace_id = row.workspace_id.clone();
build_published_agent_tool(
row.workspace_id,
row.workspace_slug,
row.agent_id,
row.agent_slug,
row.tool_name,
row.tool_title,
row.tool_description,
build_operation_version_record(
row.id,
workspace_id,
row.name,
row.display_name,
row.category,
row.protocol,
row.security_level,
row.operation_created_at,
row.operation_updated_at,
row.operation_published_at,
row.version,
row.status,
row.target_json,
row.input_schema_json,
row.output_schema_json,
row.input_mapping_json,
row.output_mapping_json,
row.execution_config_json,
row.tool_description_json,
row.samples_json,
row.generated_draft_json,
row.config_export_json,
row.wizard_state_json,
row.change_note,
row.created_at,
row.created_by,
)?
.snapshot,
)
})
.collect()
}
}
@@ -0,0 +1,321 @@
use super::*;
impl PostgresRegistry {
pub async fn list_platform_api_keys_for_agent(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
) -> Result<Vec<PlatformApiKeyRecord>, RegistryError> {
let rows = sqlx::query(
"select
id,
workspace_id,
agent_id,
name,
prefix,
scopes_json,
status,
created_at,
last_used_at
from platform_api_keys
where workspace_id = $1
and agent_id = $2
order by created_at desc",
)
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(PlatformApiKeyRecord {
api_key: PlatformApiKey {
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
name: row.get("name"),
prefix: row.get("prefix"),
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
created_at: row.get("created_at"),
last_used_at: row.get("last_used_at"),
},
})
})
.collect()
}
pub async fn list_platform_api_keys(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<PlatformApiKeyRecord>, RegistryError> {
let rows = sqlx::query(
"select
id,
workspace_id,
agent_id,
name,
prefix,
scopes_json,
status,
created_at,
last_used_at
from platform_api_keys
where workspace_id = $1
order by created_at desc",
)
.bind(workspace_id.as_str())
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(PlatformApiKeyRecord {
api_key: PlatformApiKey {
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
name: row.get("name"),
prefix: row.get("prefix"),
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
created_at: row.get("created_at"),
last_used_at: row.get("last_used_at"),
},
})
})
.collect()
}
pub async fn get_platform_api_key_by_secret_for_agent_slug(
&self,
workspace_slug: &str,
agent_slug: &str,
secret_hash: &str,
) -> Result<Option<PlatformApiKeyRecord>, RegistryError> {
let row = sqlx::query(
"select
k.id,
k.workspace_id,
k.agent_id,
k.name,
k.prefix,
k.scopes_json,
k.status,
k.created_at,
k.last_used_at
from platform_api_keys k
join workspaces w on w.id = k.workspace_id
join agents a on a.id = k.agent_id
where w.slug = $1
and a.slug = $2
and k.secret_hash = $3
and k.status = 'active'
limit 1",
)
.bind(workspace_slug)
.bind(agent_slug)
.bind(secret_hash)
.fetch_optional(&self.pool)
.await?;
row.map(|row| {
Ok(PlatformApiKeyRecord {
api_key: PlatformApiKey {
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
name: row.get("name"),
prefix: row.get("prefix"),
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
created_at: row.get("created_at"),
last_used_at: row.get("last_used_at"),
},
})
})
.transpose()
}
pub async fn create_platform_api_key(
&self,
request: CreatePlatformApiKeyRequest<'_>,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"insert into platform_api_keys (
id,
workspace_id,
agent_id,
name,
prefix,
secret_hash,
scopes_json,
status,
created_at,
last_used_at,
revoked_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9::timestamptz, $10::timestamptz, $11::timestamptz
)",
)
.bind(request.api_key.id.as_str())
.bind(request.api_key.workspace_id.as_str())
.bind(request.api_key.agent_id.as_ref().map(AgentId::as_str))
.bind(&request.api_key.name)
.bind(&request.api_key.prefix)
.bind(request.secret_hash)
.bind(Json(serialize_json_value(&request.api_key.scopes)?))
.bind(serialize_enum_text(&request.api_key.status, "status")?)
.bind(request.api_key.created_at)
.bind(request.api_key.last_used_at)
.bind(Option::<&str>::None)
.execute(&self.pool)
.await;
match result {
Ok(_) => Ok(()),
Err(sqlx::Error::Database(error))
if error.constraint() == Some("platform_api_keys_workspace_name_idx") =>
{
Err(RegistryError::Storage(sqlx::Error::Database(error)))
}
Err(error) => Err(RegistryError::Storage(error)),
}
}
pub async fn revoke_platform_api_key(
&self,
workspace_id: &WorkspaceId,
key_id: &PlatformApiKeyId,
revoked_at: &time::OffsetDateTime,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"update platform_api_keys
set status = $1,
revoked_at = $2::timestamptz
where workspace_id = $3 and id = $4",
)
.bind(serialize_enum_text(
&PlatformApiKeyStatus::Revoked,
"status",
)?)
.bind(revoked_at)
.bind(workspace_id.as_str())
.bind(key_id.as_str())
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::PlatformApiKeyNotFound {
key_id: key_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn revoke_platform_api_key_for_agent(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
key_id: &PlatformApiKeyId,
revoked_at: &time::OffsetDateTime,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"update platform_api_keys
set status = $1,
revoked_at = $2::timestamptz
where workspace_id = $3 and agent_id = $4 and id = $5",
)
.bind(serialize_enum_text(
&PlatformApiKeyStatus::Revoked,
"status",
)?)
.bind(revoked_at)
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.bind(key_id.as_str())
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::PlatformApiKeyNotFound {
key_id: key_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn delete_platform_api_key(
&self,
workspace_id: &WorkspaceId,
key_id: &PlatformApiKeyId,
) -> Result<(), RegistryError> {
let result =
sqlx::query("delete from platform_api_keys where workspace_id = $1 and id = $2")
.bind(workspace_id.as_str())
.bind(key_id.as_str())
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::PlatformApiKeyNotFound {
key_id: key_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn delete_platform_api_key_for_agent(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
key_id: &PlatformApiKeyId,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"delete from platform_api_keys where workspace_id = $1 and agent_id = $2 and id = $3",
)
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.bind(key_id.as_str())
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::PlatformApiKeyNotFound {
key_id: key_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn touch_platform_api_key(
&self,
workspace_id: &WorkspaceId,
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",
)
.bind(workspace_id.as_str())
.bind(key_id.as_str())
.bind(used_at)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::PlatformApiKeyNotFound {
key_id: key_id.as_str().to_owned(),
});
}
Ok(())
}
}
+490
View File
@@ -0,0 +1,490 @@
use super::*;
use time::OffsetDateTime;
impl PostgresRegistry {
pub async fn upsert_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!(
"insert into users (
id,
email,
display_name,
password_hash,
status,
created_at
) values (
$1, $2, $3, $4, 'active', now()
)
on conflict (email) do update
set display_name = excluded.display_name,
password_hash = excluded.password_hash,
status = 'active'
returning id",
user_id,
email,
display_name,
password_hash,
)
.fetch_one(&self.pool)
.await?;
Ok(UserId::new(row.id))
}
pub async fn ensure_membership(
&self,
workspace_id: &WorkspaceId,
user_id: &UserId,
role: MembershipRole,
) -> Result<(), RegistryError> {
sqlx::query(
"insert into memberships (
workspace_id,
user_id,
role,
created_at
) values (
$1, $2, $3, now()
)
on conflict (workspace_id, user_id) do update
set role = excluded.role",
)
.bind(workspace_id.as_str())
.bind(user_id.as_str())
.bind(serialize_enum_text(&role, "role")?)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_auth_user_by_email(
&self,
email: &str,
) -> Result<Option<AuthUserRecord>, RegistryError> {
let row = sqlx::query!(
"select
id,
email,
display_name,
password_hash as \"password_hash!\",
status,
created_at as \"created_at!: OffsetDateTime\"
from users
where email = $1
limit 1",
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()
}
pub async fn get_auth_user_by_id(
&self,
user_id: &UserId,
) -> Result<Option<AuthUserRecord>, RegistryError> {
let row = sqlx::query!(
"select
id,
email,
display_name,
password_hash as \"password_hash!\",
status,
created_at as \"created_at!: OffsetDateTime\"
from users
where id = $1
limit 1",
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()
}
pub async fn update_user_profile(
&self,
user_id: &UserId,
email: &str,
display_name: &str,
) -> Result<User, RegistryError> {
let row = sqlx::query!(
"update users
set email = $2,
display_name = $3
where id = $1
returning
id,
email,
display_name,
status,
created_at as \"created_at!: OffsetDateTime\"",
user_id.as_str(),
email,
display_name,
)
.fetch_one(&self.pool)
.await
.map_err(|error| map_user_update_error(error, user_id, email))?;
build_user(
row.id,
row.email,
row.display_name,
row.status,
row.created_at,
)
}
pub async fn update_user_password(
&self,
user_id: &UserId,
password_hash: &str,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"update users
set password_hash = $2
where id = $1",
)
.bind(user_id.as_str())
.bind(password_hash)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::UserNotFound {
user_id: user_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn create_user_session(
&self,
session_id: &UserSessionId,
user_id: &UserId,
current_workspace_id: Option<&WorkspaceId>,
secret_hash: &str,
expires_at: &OffsetDateTime,
) -> Result<(), RegistryError> {
sqlx::query(
"insert into user_sessions (
id,
user_id,
current_workspace_id,
secret_hash,
status,
expires_at,
last_seen_at,
created_at
) values (
$1,
$2,
$3,
$4,
'active',
$5::timestamptz,
now(),
now()
)",
)
.bind(session_id.as_str())
.bind(user_id.as_str())
.bind(current_workspace_id.map(|id| id.as_str()))
.bind(secret_hash)
.bind(*expires_at)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_user_session(
&self,
session_id: &UserSessionId,
secret_hash: &str,
) -> Result<Option<SessionRecord>, RegistryError> {
let row = sqlx::query!(
"select
s.id,
s.user_id,
s.current_workspace_id,
u.email,
u.display_name,
u.status,
u.created_at as \"created_at!: OffsetDateTime\"
from user_sessions s
join users u on u.id = s.user_id
where s.id = $1
and s.secret_hash = $2
and s.status = 'active'
and s.expires_at > now()
limit 1",
session_id.as_str(),
secret_hash,
)
.fetch_optional(&self.pool)
.await?;
let Some(row) = row else {
return Ok(None);
};
let user_id = UserId::new(row.user_id);
let user = User {
id: user_id.clone(),
email: row.email,
display_name: row.display_name,
status: deserialize_enum_text(&row.status, "status")?,
created_at: row.created_at,
};
let memberships = self.list_workspaces_for_user(&user_id).await?;
let stored_workspace_id = row.current_workspace_id.map(WorkspaceId::new);
let current_workspace_id = stored_workspace_id
.filter(|workspace_id| {
memberships
.iter()
.any(|membership| membership.workspace.id == *workspace_id)
})
.or_else(|| {
memberships
.first()
.map(|membership| membership.workspace.id.clone())
});
Ok(Some(SessionRecord {
session_id: UserSessionId::new(row.id),
user,
memberships,
current_workspace_id,
}))
}
pub async fn touch_user_session(
&self,
session_id: &UserSessionId,
) -> Result<(), RegistryError> {
sqlx::query(
"update user_sessions
set last_seen_at = now()
where id = $1",
)
.bind(session_id.as_str())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn revoke_user_session(
&self,
session_id: &UserSessionId,
) -> Result<(), RegistryError> {
sqlx::query(
"update user_sessions
set status = 'revoked'
where id = $1",
)
.bind(session_id.as_str())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn set_user_session_current_workspace(
&self,
session_id: &UserSessionId,
workspace_id: &WorkspaceId,
) -> Result<(), RegistryError> {
sqlx::query(
"update user_sessions
set current_workspace_id = $2
where id = $1",
)
.bind(session_id.as_str())
.bind(workspace_id.as_str())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn user_has_workspace_access(
&self,
user_id: &UserId,
workspace_id: &WorkspaceId,
) -> Result<bool, RegistryError> {
let row = sqlx::query!(
"select exists(
select 1
from memberships
where user_id = $1
and workspace_id = $2
) as \"allowed!\"",
user_id.as_str(),
workspace_id.as_str(),
)
.fetch_one(&self.pool)
.await?;
Ok(row.allowed)
}
pub async fn save_auth_profile(
&self,
request: SaveAuthProfileRequest<'_>,
) -> Result<(), RegistryError> {
sqlx::query(
"insert into auth_profiles (
id,
workspace_id,
name,
kind,
config_json,
created_at,
updated_at
) values ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz)
on conflict(id) do update set
workspace_id = excluded.workspace_id,
name = excluded.name,
kind = excluded.kind,
config_json = excluded.config_json,
updated_at = excluded.updated_at",
)
.bind(request.profile.id.as_str())
.bind(request.workspace_id.as_str())
.bind(&request.profile.name)
.bind(serialize_enum_text(&request.profile.kind, "kind")?)
.bind(Json(serialize_json_value(&request.profile.config)?))
.bind(request.profile.created_at)
.bind(request.profile.updated_at)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_auth_profile(
&self,
workspace_id: &WorkspaceId,
auth_profile_id: &crank_core::AuthProfileId,
) -> Result<Option<AuthProfile>, RegistryError> {
let row = sqlx::query!(
"select
id,
workspace_id,
name,
kind,
config_json,
created_at as \"created_at!: OffsetDateTime\",
updated_at as \"updated_at!: OffsetDateTime\"
from auth_profiles
where workspace_id = $1 and id = $2",
workspace_id.as_str(),
auth_profile_id.as_str(),
)
.fetch_optional(&self.pool)
.await?;
row.map(|row| {
build_auth_profile(
row.id,
row.workspace_id,
row.name,
row.kind,
row.config_json,
row.created_at,
row.updated_at,
)
})
.transpose()
}
pub async fn list_auth_profiles(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<AuthProfile>, RegistryError> {
let rows = sqlx::query!(
"select
id,
workspace_id,
name,
kind,
config_json,
created_at as \"created_at!: OffsetDateTime\",
updated_at as \"updated_at!: OffsetDateTime\"
from auth_profiles
where workspace_id = $1
order by name asc",
workspace_id.as_str(),
)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
build_auth_profile(
row.id,
row.workspace_id,
row.name,
row.kind,
row.config_json,
row.created_at,
row.updated_at,
)
})
.collect()
}
pub async fn list_auth_profiles_referencing_secret(
&self,
workspace_id: &WorkspaceId,
secret_id: &SecretId,
) -> Result<Vec<AuthProfile>, RegistryError> {
let profiles = self.list_auth_profiles(workspace_id).await?;
Ok(profiles
.into_iter()
.filter(|profile| {
profile
.config
.secret_ids()
.into_iter()
.any(|candidate| candidate == secret_id)
})
.collect())
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,446 @@
use super::*;
impl PostgresRegistry {
pub async fn create_invocation_log(
&self,
request: CreateInvocationLogRequest<'_>,
) -> Result<(), RegistryError> {
sqlx::query(
"insert into invocation_logs (
id,
workspace_id,
agent_id,
operation_id,
source,
level,
status,
tool_name,
message,
request_id,
status_code,
duration_ms,
error_kind,
request_preview_json,
response_preview_json,
created_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::timestamptz
)",
)
.bind(request.log.id.as_str())
.bind(request.log.workspace_id.as_str())
.bind(request.log.agent_id.as_ref().map(|value| value.as_str()))
.bind(request.log.operation_id.as_str())
.bind(serialize_enum_text(&request.log.source, "source")?)
.bind(serialize_enum_text(&request.log.level, "level")?)
.bind(serialize_enum_text(&request.log.status, "status")?)
.bind(&request.log.tool_name)
.bind(&request.log.message)
.bind(&request.log.request_id)
.bind(request.log.status_code.map(i32::from))
.bind(i64::try_from(request.log.duration_ms).map_err(|_| {
RegistryError::InvalidNumericValue {
field: "duration_ms",
value: i64::MAX,
}
})?)
.bind(&request.log.error_kind)
.bind(Json(request.log.request_preview.clone()))
.bind(Json(request.log.response_preview.clone()))
.bind(request.log.created_at)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn list_invocation_logs(
&self,
query: ListInvocationLogsQuery<'_>,
) -> Result<Vec<InvocationLogRecord>, RegistryError> {
let rows = sqlx::query(
"select
l.id,
l.workspace_id,
l.agent_id,
l.operation_id,
l.source,
l.level,
l.status,
l.tool_name,
l.message,
l.request_id,
l.status_code,
l.duration_ms,
l.error_kind,
l.request_preview_json,
l.response_preview_json,
l.created_at as created_at,
o.name as operation_name,
o.display_name as operation_display_name,
a.slug as agent_slug,
a.display_name as agent_display_name
from invocation_logs l
join operations o on o.id = l.operation_id
left join agents a on a.id = l.agent_id
where l.workspace_id = $1
and ($2::text is null or l.level = $2)
and ($3::text is null or l.source = $3)
and ($4::text is null or l.operation_id = $4)
and ($5::text is null or l.agent_id = $5)
and ($6::timestamptz is null or l.created_at >= $6::timestamptz)
and (
$7::text is null
or l.tool_name ilike '%' || $7 || '%'
or l.message ilike '%' || $7 || '%'
or o.name ilike '%' || $7 || '%'
or o.display_name ilike '%' || $7 || '%'
)
order by l.created_at desc
limit $8",
)
.bind(query.workspace_id.as_str())
.bind(
query
.level
.as_ref()
.map(|value| serialize_enum_text(value, "level"))
.transpose()?,
)
.bind(
query
.source
.as_ref()
.map(|value| serialize_enum_text(value, "source"))
.transpose()?,
)
.bind(query.operation_id.map(|value| value.as_str()))
.bind(query.agent_id.map(|value| value.as_str()))
.bind(query.created_after)
.bind(query.search_text)
.bind(i64::from(query.limit))
.fetch_all(&self.pool)
.await?;
rows.iter().map(map_invocation_log_record).collect()
}
pub async fn get_invocation_log(
&self,
workspace_id: &WorkspaceId,
log_id: &InvocationLogId,
) -> Result<Option<InvocationLogRecord>, RegistryError> {
let row = sqlx::query(
"select
l.id,
l.workspace_id,
l.agent_id,
l.operation_id,
l.source,
l.level,
l.status,
l.tool_name,
l.message,
l.request_id,
l.status_code,
l.duration_ms,
l.error_kind,
l.request_preview_json,
l.response_preview_json,
l.created_at as created_at,
o.name as operation_name,
o.display_name as operation_display_name,
a.slug as agent_slug,
a.display_name as agent_display_name
from invocation_logs l
join operations o on o.id = l.operation_id
left join agents a on a.id = l.agent_id
where l.workspace_id = $1 and l.id = $2",
)
.bind(workspace_id.as_str())
.bind(log_id.as_str())
.fetch_optional(&self.pool)
.await?;
row.as_ref().map(map_invocation_log_record).transpose()
}
pub async fn summarize_usage(
&self,
query: UsageQuery<'_>,
) -> Result<UsageSummary, RegistryError> {
let row = sqlx::query(
"select
count(*)::bigint as calls_total,
count(*) filter (where status = 'ok')::bigint as calls_ok,
count(*) filter (where status = 'error')::bigint as calls_error,
coalesce(percentile_cont(0.5) within group (order by duration_ms), 0)::bigint as p50_ms,
coalesce(percentile_cont(0.95) within group (order by duration_ms), 0)::bigint as p95_ms,
coalesce(percentile_cont(0.99) within group (order by duration_ms), 0)::bigint as p99_ms
from invocation_logs
where workspace_id = $1
and created_at >= $2::timestamptz
and ($3::text is null or source = $3)",
)
.bind(query.workspace_id.as_str())
.bind(query.created_after)
.bind(
query
.source
.as_ref()
.map(|value| serialize_enum_text(value, "source"))
.transpose()?,
)
.fetch_one(&self.pool)
.await?;
let calls_total = row.try_get::<i64, _>("calls_total")?;
let calls_ok = row.try_get::<i64, _>("calls_ok")?;
let calls_error = row.try_get::<i64, _>("calls_error")?;
let rollup = UsageRollup {
workspace_id: query.workspace_id.clone(),
agent_id: None,
operation_id: None,
period: query.period,
calls_total: to_u64(calls_total, "calls_total")?,
calls_ok: to_u64(calls_ok, "calls_ok")?,
calls_error: to_u64(calls_error, "calls_error")?,
p50_ms: to_u64(row.try_get::<i64, _>("p50_ms")?, "p50_ms")?,
p95_ms: to_u64(row.try_get::<i64, _>("p95_ms")?, "p95_ms")?,
p99_ms: to_u64(row.try_get::<i64, _>("p99_ms")?, "p99_ms")?,
};
let success_rate = if rollup.calls_total == 0 {
0.0
} else {
(rollup.calls_ok as f64 / rollup.calls_total as f64) * 100.0
};
Ok(UsageSummary {
rollup,
success_rate,
})
}
pub async fn list_usage_timeline(
&self,
query: UsageQuery<'_>,
) -> Result<Vec<UsageTimelinePoint>, RegistryError> {
let bucket = usage_bucket_sql(query.bucket);
let sql = format!(
"select
(date_trunc('{bucket}', created_at at time zone 'UTC') at time zone 'UTC') as bucket_start,
count(*) filter (where status = 'ok')::bigint as calls_ok,
count(*) filter (where status = 'error')::bigint as calls_error
from invocation_logs
where workspace_id = $1
and created_at >= $2::timestamptz
and ($3::text is null or source = $3)
group by 1
order by 1 asc"
);
let rows = sqlx::query(&sql)
.bind(query.workspace_id.as_str())
.bind(query.created_after)
.bind(
query
.source
.as_ref()
.map(|value| serialize_enum_text(value, "source"))
.transpose()?,
)
.fetch_all(&self.pool)
.await?;
rows.iter()
.map(|row| {
Ok(UsageTimelinePoint {
bucket_start: row.try_get("bucket_start")?,
calls_ok: to_u64(row.try_get::<i64, _>("calls_ok")?, "calls_ok")?,
calls_error: to_u64(row.try_get::<i64, _>("calls_error")?, "calls_error")?,
})
})
.collect()
}
pub async fn list_usage_by_operation(
&self,
query: UsageQuery<'_>,
) -> Result<Vec<UsageOperationBreakdown>, RegistryError> {
let rows = sqlx::query(
"select
o.id as operation_id,
o.name as operation_name,
o.display_name as operation_display_name,
o.protocol,
count(*)::bigint as calls_total,
count(*) filter (where l.status = 'error')::bigint as calls_error,
coalesce(percentile_cont(0.5) within group (order by l.duration_ms), 0)::bigint as p50_ms,
coalesce(percentile_cont(0.95) within group (order by l.duration_ms), 0)::bigint as p95_ms,
coalesce(percentile_cont(0.99) within group (order by l.duration_ms), 0)::bigint as p99_ms
from invocation_logs l
join operations o on o.id = l.operation_id
where l.workspace_id = $1
and l.created_at >= $2::timestamptz
and ($3::text is null or l.source = $3)
group by o.id, o.name, o.display_name, o.protocol
order by calls_total desc, o.name asc",
)
.bind(query.workspace_id.as_str())
.bind(query.created_after)
.bind(
query
.source
.as_ref()
.map(|value| serialize_enum_text(value, "source"))
.transpose()?,
)
.fetch_all(&self.pool)
.await?;
rows.iter().map(map_usage_operation_breakdown).collect()
}
pub async fn get_usage_for_operation(
&self,
query: UsageQuery<'_>,
operation_id: &OperationId,
) -> Result<Option<UsageRollupRecord>, RegistryError> {
let row = sqlx::query(
"select
count(*)::bigint as calls_total,
count(*) filter (where status = 'ok')::bigint as calls_ok,
count(*) filter (where status = 'error')::bigint as calls_error,
coalesce(percentile_cont(0.5) within group (order by duration_ms), 0)::bigint as p50_ms,
coalesce(percentile_cont(0.95) within group (order by duration_ms), 0)::bigint as p95_ms,
coalesce(percentile_cont(0.99) within group (order by duration_ms), 0)::bigint as p99_ms
from invocation_logs
where workspace_id = $1
and operation_id = $2
and created_at >= $3::timestamptz
and ($4::text is null or source = $4)",
)
.bind(query.workspace_id.as_str())
.bind(operation_id.as_str())
.bind(query.created_after)
.bind(
query
.source
.as_ref()
.map(|value| serialize_enum_text(value, "source"))
.transpose()?,
)
.fetch_one(&self.pool)
.await?;
let calls_total = to_u64(row.try_get::<i64, _>("calls_total")?, "calls_total")?;
if calls_total == 0 {
return Ok(None);
}
Ok(Some(UsageRollupRecord {
rollup: UsageRollup {
workspace_id: query.workspace_id.clone(),
agent_id: None,
operation_id: Some(operation_id.clone()),
period: query.period,
calls_total,
calls_ok: to_u64(row.try_get::<i64, _>("calls_ok")?, "calls_ok")?,
calls_error: to_u64(row.try_get::<i64, _>("calls_error")?, "calls_error")?,
p50_ms: to_u64(row.try_get::<i64, _>("p50_ms")?, "p50_ms")?,
p95_ms: to_u64(row.try_get::<i64, _>("p95_ms")?, "p95_ms")?,
p99_ms: to_u64(row.try_get::<i64, _>("p99_ms")?, "p99_ms")?,
},
}))
}
pub async fn list_usage_by_agent(
&self,
query: UsageQuery<'_>,
) -> Result<Vec<UsageAgentBreakdown>, RegistryError> {
let rows = sqlx::query(
"select
a.id as agent_id,
a.slug as agent_slug,
a.display_name as agent_display_name,
count(*)::bigint as calls_total,
count(*) filter (where l.status = 'error')::bigint as calls_error,
coalesce(percentile_cont(0.5) within group (order by l.duration_ms), 0)::bigint as p50_ms,
coalesce(percentile_cont(0.95) within group (order by l.duration_ms), 0)::bigint as p95_ms,
coalesce(percentile_cont(0.99) within group (order by l.duration_ms), 0)::bigint as p99_ms
from invocation_logs l
join agents a on a.id = l.agent_id
where l.workspace_id = $1
and l.created_at >= $2::timestamptz
and ($3::text is null or l.source = $3)
group by a.id, a.slug, a.display_name
order by calls_total desc, a.slug asc",
)
.bind(query.workspace_id.as_str())
.bind(query.created_after)
.bind(
query
.source
.as_ref()
.map(|value| serialize_enum_text(value, "source"))
.transpose()?,
)
.fetch_all(&self.pool)
.await?;
rows.iter().map(map_usage_agent_breakdown).collect()
}
pub async fn get_usage_for_agent(
&self,
query: UsageQuery<'_>,
agent_id: &AgentId,
) -> Result<Option<UsageRollupRecord>, RegistryError> {
let row = sqlx::query(
"select
count(*)::bigint as calls_total,
count(*) filter (where status = 'ok')::bigint as calls_ok,
count(*) filter (where status = 'error')::bigint as calls_error,
coalesce(percentile_cont(0.5) within group (order by duration_ms), 0)::bigint as p50_ms,
coalesce(percentile_cont(0.95) within group (order by duration_ms), 0)::bigint as p95_ms,
coalesce(percentile_cont(0.99) within group (order by duration_ms), 0)::bigint as p99_ms
from invocation_logs
where workspace_id = $1
and agent_id = $2
and created_at >= $3::timestamptz
and ($4::text is null or source = $4)",
)
.bind(query.workspace_id.as_str())
.bind(agent_id.as_str())
.bind(query.created_after)
.bind(
query
.source
.as_ref()
.map(|value| serialize_enum_text(value, "source"))
.transpose()?,
)
.fetch_one(&self.pool)
.await?;
let calls_total = to_u64(row.try_get::<i64, _>("calls_total")?, "calls_total")?;
if calls_total == 0 {
return Ok(None);
}
Ok(Some(UsageRollupRecord {
rollup: UsageRollup {
workspace_id: query.workspace_id.clone(),
agent_id: Some(agent_id.clone()),
operation_id: None,
period: query.period,
calls_total,
calls_ok: to_u64(row.try_get::<i64, _>("calls_ok")?, "calls_ok")?,
calls_error: to_u64(row.try_get::<i64, _>("calls_error")?, "calls_error")?,
p50_ms: to_u64(row.try_get::<i64, _>("p50_ms")?, "p50_ms")?,
p95_ms: to_u64(row.try_get::<i64, _>("p95_ms")?, "p95_ms")?,
p99_ms: to_u64(row.try_get::<i64, _>("p99_ms")?, "p99_ms")?,
},
}))
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,304 @@
use super::*;
impl PostgresRegistry {
pub async fn list_secrets(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<SecretRecord>, RegistryError> {
let rows = sqlx::query!(
"select
id,
workspace_id,
name,
kind,
status,
current_version,
created_at as \"created_at!: time::OffsetDateTime\",
updated_at as \"updated_at!: time::OffsetDateTime\",
last_used_at as \"last_used_at: time::OffsetDateTime\"
from secrets
where workspace_id = $1
order by name asc",
workspace_id.as_str(),
)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(SecretRecord {
secret: Secret {
id: SecretId::new(row.id),
workspace_id: WorkspaceId::new(row.workspace_id),
name: row.name,
kind: deserialize_enum_text(&row.kind, "kind")?,
status: deserialize_enum_text(&row.status, "status")?,
current_version: from_db_version(row.current_version, "current_version")?,
created_at: row.created_at,
updated_at: row.updated_at,
last_used_at: row.last_used_at,
},
})
})
.collect()
}
pub async fn get_secret(
&self,
workspace_id: &WorkspaceId,
secret_id: &SecretId,
) -> Result<Option<SecretRecord>, RegistryError> {
let row = sqlx::query!(
"select
id,
workspace_id,
name,
kind,
status,
current_version,
created_at as \"created_at!: time::OffsetDateTime\",
updated_at as \"updated_at!: time::OffsetDateTime\",
last_used_at as \"last_used_at: time::OffsetDateTime\"
from secrets
where workspace_id = $1 and id = $2",
workspace_id.as_str(),
secret_id.as_str(),
)
.fetch_optional(&self.pool)
.await?;
row.map(|row| {
Ok(SecretRecord {
secret: Secret {
id: SecretId::new(row.id),
workspace_id: WorkspaceId::new(row.workspace_id),
name: row.name,
kind: deserialize_enum_text(&row.kind, "kind")?,
status: deserialize_enum_text(&row.status, "status")?,
current_version: from_db_version(row.current_version, "current_version")?,
created_at: row.created_at,
updated_at: row.updated_at,
last_used_at: row.last_used_at,
},
})
})
.transpose()
}
pub async fn get_current_secret_version(
&self,
workspace_id: &WorkspaceId,
secret_id: &SecretId,
) -> Result<Option<SecretVersionRecord>, RegistryError> {
let row = sqlx::query!(
"select
sv.secret_id,
sv.version,
sv.ciphertext,
sv.key_version,
sv.created_at as \"created_at!: time::OffsetDateTime\",
sv.created_by
from secrets s
join secret_versions sv
on sv.secret_id = s.id and sv.version = s.current_version
where s.workspace_id = $1 and s.id = $2",
workspace_id.as_str(),
secret_id.as_str(),
)
.fetch_optional(&self.pool)
.await?;
row.map(|row| {
Ok(SecretVersionRecord {
secret_version: SecretVersion {
secret_id: SecretId::new(row.secret_id),
version: from_db_version(row.version, "version")?,
ciphertext: row.ciphertext,
key_version: row.key_version,
created_at: row.created_at,
created_by: row.created_by.map(UserId::new),
},
})
})
.transpose()
}
pub async fn create_secret(
&self,
request: CreateSecretRequest<'_>,
) -> Result<(), RegistryError> {
let mut tx = self.pool.begin().await?;
let result = sqlx::query(
"insert into secrets (
id,
workspace_id,
name,
kind,
status,
current_version,
last_used_at,
created_at,
updated_at
) values (
$1, $2, $3, $4, $5, $6, $7::timestamptz, $8::timestamptz, $9::timestamptz
)",
)
.bind(request.secret.id.as_str())
.bind(request.secret.workspace_id.as_str())
.bind(&request.secret.name)
.bind(serialize_enum_text(&request.secret.kind, "kind")?)
.bind(serialize_enum_text(&request.secret.status, "status")?)
.bind(to_db_version(request.secret.current_version))
.bind(request.secret.last_used_at)
.bind(request.secret.created_at)
.bind(request.secret.updated_at)
.execute(&mut *tx)
.await;
match result {
Ok(_) => {
sqlx::query(
"insert into secret_versions (
secret_id,
version,
ciphertext,
key_version,
created_at,
created_by
) values (
$1, $2, $3, $4, $5::timestamptz, $6
)",
)
.bind(request.secret.id.as_str())
.bind(to_db_version(request.secret.current_version))
.bind(request.ciphertext)
.bind(request.key_version)
.bind(request.secret.created_at)
.bind(request.created_by.map(|value| value.as_str()))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
Err(sqlx::Error::Database(error))
if error.constraint() == Some("secrets_workspace_name_idx") =>
{
Err(RegistryError::SecretNameAlreadyExists {
workspace_id: request.secret.workspace_id.as_str().to_owned(),
name: request.secret.name.clone(),
})
}
Err(error) => Err(RegistryError::Storage(error)),
}
}
pub async fn rotate_secret(
&self,
request: RotateSecretRequest<'_>,
) -> Result<SecretVersionRecord, RegistryError> {
let existing = self
.get_secret(request.workspace_id, request.secret_id)
.await?
.ok_or_else(|| RegistryError::SecretNotFound {
secret_id: request.secret_id.as_str().to_owned(),
})?;
let next_version = existing.secret.current_version + 1;
let mut tx = self.pool.begin().await?;
sqlx::query(
"insert into secret_versions (
secret_id,
version,
ciphertext,
key_version,
created_at,
created_by
) values (
$1, $2, $3, $4, $5::timestamptz, $6
)",
)
.bind(request.secret_id.as_str())
.bind(to_db_version(next_version))
.bind(request.ciphertext)
.bind(request.key_version)
.bind(request.created_at)
.bind(request.created_by.map(|value| value.as_str()))
.execute(&mut *tx)
.await?;
sqlx::query(
"update secrets
set current_version = $3,
updated_at = $4::timestamptz
where workspace_id = $1 and id = $2",
)
.bind(request.workspace_id.as_str())
.bind(request.secret_id.as_str())
.bind(to_db_version(next_version))
.bind(request.updated_at)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(SecretVersionRecord {
secret_version: SecretVersion {
secret_id: request.secret_id.clone(),
version: next_version,
ciphertext: request.ciphertext.to_owned(),
key_version: request.key_version.to_owned(),
created_at: *request.created_at,
created_by: request.created_by.cloned(),
},
})
}
pub async fn delete_secret(
&self,
workspace_id: &WorkspaceId,
secret_id: &SecretId,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"delete from secrets
where workspace_id = $1 and id = $2",
)
.bind(workspace_id.as_str())
.bind(secret_id.as_str())
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::SecretNotFound {
secret_id: secret_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn touch_secret(
&self,
workspace_id: &WorkspaceId,
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",
)
.bind(workspace_id.as_str())
.bind(secret_id.as_str())
.bind(used_at)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::SecretNotFound {
secret_id: secret_id.as_str().to_owned(),
});
}
Ok(())
}
}
@@ -0,0 +1,634 @@
use super::*;
use time::OffsetDateTime;
impl PostgresRegistry {
pub async fn create_stream_session(
&self,
request: CreateStreamSessionRequest<'_>,
) -> Result<StreamSession, RegistryError> {
sqlx::query(
"insert into stream_sessions (
id,
workspace_id,
agent_id,
operation_id,
protocol,
mode,
status,
cursor_json,
state_json,
expires_at,
last_poll_at,
created_at,
closed_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10::timestamptz,
$11::timestamptz, $12::timestamptz, $13::timestamptz
)",
)
.bind(request.session.id.as_str())
.bind(request.session.workspace_id.as_str())
.bind(
request
.session
.agent_id
.as_ref()
.map(|value| value.as_str()),
)
.bind(request.session.operation_id.as_str())
.bind(serialize_enum_text(&request.session.protocol, "protocol")?)
.bind(serialize_enum_text(&request.session.mode, "mode")?)
.bind(serialize_enum_text(&request.session.status, "status")?)
.bind(request.session.cursor.clone().map(Json))
.bind(Json(request.session.state.clone()))
.bind(request.session.expires_at)
.bind(request.session.last_poll_at)
.bind(request.session.created_at)
.bind(request.session.closed_at)
.execute(&self.pool)
.await?;
Ok(request.session.clone())
}
pub async fn get_stream_session(
&self,
id: &StreamSessionId,
) -> Result<Option<StreamSession>, RegistryError> {
let row = sqlx::query!(
"select
id,
workspace_id,
agent_id,
operation_id,
protocol,
mode,
status,
cursor_json,
state_json,
expires_at as \"expires_at!: OffsetDateTime\",
last_poll_at as \"last_poll_at: OffsetDateTime\",
created_at as \"created_at!: OffsetDateTime\",
closed_at as \"closed_at: OffsetDateTime\"
from stream_sessions
where id = $1",
id.as_str(),
)
.fetch_optional(&self.pool)
.await?;
row.map(|row| {
Ok(StreamSession {
id: StreamSessionId::new(row.id),
workspace_id: WorkspaceId::new(row.workspace_id),
agent_id: row.agent_id.map(AgentId::new),
operation_id: OperationId::new(row.operation_id),
protocol: deserialize_enum_text(&row.protocol, "protocol")?,
mode: deserialize_enum_text(&row.mode, "mode")?,
status: deserialize_enum_text(&row.status, "status")?,
cursor: row.cursor_json,
state: row.state_json,
expires_at: row.expires_at,
last_poll_at: row.last_poll_at,
created_at: row.created_at,
closed_at: row.closed_at,
})
})
.transpose()
}
pub async fn update_stream_session_state(
&self,
request: UpdateStreamSessionStateRequest<'_>,
) -> Result<StreamSession, RegistryError> {
validate_stream_session_transition(
request.session_id,
request.current_status,
request.next_status,
)?;
let row = sqlx::query(
"update stream_sessions
set status = $3,
cursor_json = $4::jsonb,
state_json = $5::jsonb,
expires_at = coalesce($6::timestamptz, expires_at),
last_poll_at = coalesce($7::timestamptz, last_poll_at),
closed_at = case
when $8::timestamptz is null then closed_at
else $8::timestamptz
end
where id = $1
and status = $2
returning
id,
workspace_id,
agent_id,
operation_id,
protocol,
mode,
status,
cursor_json,
state_json,
expires_at,
last_poll_at,
created_at,
closed_at",
)
.bind(request.session_id.as_str())
.bind(serialize_enum_text(&request.current_status, "status")?)
.bind(serialize_enum_text(&request.next_status, "status")?)
.bind(request.cursor.cloned().map(Json))
.bind(Json(request.state.clone()))
.bind(request.expires_at)
.bind(request.last_poll_at)
.bind(request.closed_at)
.fetch_optional(&self.pool)
.await?;
match row.as_ref() {
Some(row) => map_stream_session(row),
None => {
if self.get_stream_session(request.session_id).await?.is_some() {
Err(RegistryError::InvalidStreamSessionTransition {
session_id: request.session_id.as_str().to_owned(),
from: serialize_enum_text(&request.current_status, "status")?,
to: serialize_enum_text(&request.next_status, "status")?,
})
} else {
Err(RegistryError::StreamSessionNotFound {
session_id: request.session_id.as_str().to_owned(),
})
}
}
}
}
pub async fn close_stream_session(
&self,
id: &StreamSessionId,
now: &OffsetDateTime,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"update stream_sessions
set status = 'stopped',
last_poll_at = $2::timestamptz,
closed_at = $2::timestamptz
where id = $1
and status in ('created', 'running')",
)
.bind(id.as_str())
.bind(now)
.execute(&self.pool)
.await?;
if result.rows_affected() > 0 {
return Ok(());
}
match self.get_stream_session(id).await? {
Some(existing) => Err(RegistryError::InvalidStreamSessionTransition {
session_id: id.as_str().to_owned(),
from: serialize_enum_text(&existing.status, "status")?,
to: "stopped".to_owned(),
}),
None => Err(RegistryError::StreamSessionNotFound {
session_id: id.as_str().to_owned(),
}),
}
}
pub async fn touch_stream_session_poll(
&self,
id: &StreamSessionId,
now: &OffsetDateTime,
) -> Result<StreamSession, RegistryError> {
let row = sqlx::query(
"update stream_sessions
set last_poll_at = $2::timestamptz
where id = $1
returning
id,
workspace_id,
agent_id,
operation_id,
protocol,
mode,
status,
cursor_json,
state_json,
expires_at,
last_poll_at,
created_at,
closed_at",
)
.bind(id.as_str())
.bind(now)
.fetch_optional(&self.pool)
.await?;
match row.as_ref() {
Some(row) => map_stream_session(row),
None => Err(RegistryError::StreamSessionNotFound {
session_id: id.as_str().to_owned(),
}),
}
}
pub async fn list_stream_sessions(
&self,
filter: StreamSessionFilter<'_>,
) -> Result<Page<StreamSession>, RegistryError> {
let status = filter
.status
.as_ref()
.map(|value| serialize_enum_text(value, "status"))
.transpose()?;
let mode = filter
.mode
.as_ref()
.map(|value| serialize_enum_text(value, "mode"))
.transpose()?;
let limit = i64::from(filter.limit);
let rows = sqlx::query!(
"select
id,
workspace_id,
agent_id,
operation_id,
protocol,
mode,
status,
cursor_json,
state_json,
expires_at as \"expires_at!: OffsetDateTime\",
last_poll_at as \"last_poll_at: OffsetDateTime\",
created_at as \"created_at!: OffsetDateTime\",
closed_at as \"closed_at: OffsetDateTime\"
from stream_sessions
where workspace_id = $1
and ($2::text is null or agent_id = $2)
and ($3::text is null or operation_id = $3)
and ($4::text is null or status = $4)
and ($5::text is null or mode = $5)
order by created_at desc
limit $6",
filter.workspace_id.as_str(),
filter.agent_id.map(|value| value.as_str()),
filter.operation_id.map(|value| value.as_str()),
status,
mode,
limit,
)
.fetch_all(&self.pool)
.await?;
let items = rows
.into_iter()
.map(|row| {
Ok(StreamSession {
id: StreamSessionId::new(row.id),
workspace_id: WorkspaceId::new(row.workspace_id),
agent_id: row.agent_id.map(AgentId::new),
operation_id: OperationId::new(row.operation_id),
protocol: deserialize_enum_text(&row.protocol, "protocol")?,
mode: deserialize_enum_text(&row.mode, "mode")?,
status: deserialize_enum_text(&row.status, "status")?,
cursor: row.cursor_json,
state: row.state_json,
expires_at: row.expires_at,
last_poll_at: row.last_poll_at,
created_at: row.created_at,
closed_at: row.closed_at,
})
})
.collect::<Result<Vec<_>, RegistryError>>()?;
let total = items.len() as u64;
Ok(Page { items, total })
}
pub async fn delete_expired_stream_sessions(
&self,
now: &OffsetDateTime,
) -> Result<u64, RegistryError> {
let result = sqlx::query(
"delete from stream_sessions
where expires_at <= $1::timestamptz",
)
.bind(now)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
pub async fn create_async_job(
&self,
request: CreateAsyncJobRequest<'_>,
) -> Result<AsyncJobHandle, RegistryError> {
sqlx::query(
"insert into async_jobs (
id,
workspace_id,
agent_id,
operation_id,
status,
progress_json,
result_json,
error_json,
expires_at,
last_poll_at,
created_at,
updated_at,
finished_at
) values (
$1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8::jsonb, $9::timestamptz,
$10::timestamptz, $11::timestamptz, $12::timestamptz, $13::timestamptz
)",
)
.bind(request.job.id.as_str())
.bind(request.job.workspace_id.as_str())
.bind(request.job.agent_id.as_ref().map(|value| value.as_str()))
.bind(request.job.operation_id.as_str())
.bind(serialize_enum_text(&request.job.status, "status")?)
.bind(Json(request.job.progress.clone()))
.bind(request.job.result.clone().map(Json))
.bind(request.job.error.clone().map(Json))
.bind(request.job.expires_at)
.bind(request.job.last_poll_at)
.bind(request.job.created_at)
.bind(request.job.updated_at)
.bind(request.job.finished_at)
.execute(&self.pool)
.await?;
Ok(request.job.clone())
}
pub async fn get_async_job(
&self,
id: &AsyncJobId,
) -> Result<Option<AsyncJobHandle>, RegistryError> {
let row = sqlx::query!(
"select
id,
workspace_id,
agent_id,
operation_id,
status,
progress_json,
result_json,
error_json,
expires_at as \"expires_at: OffsetDateTime\",
last_poll_at as \"last_poll_at: OffsetDateTime\",
created_at as \"created_at!: OffsetDateTime\",
updated_at as \"updated_at!: OffsetDateTime\",
finished_at as \"finished_at: OffsetDateTime\"
from async_jobs
where id = $1",
id.as_str(),
)
.fetch_optional(&self.pool)
.await?;
row.map(|row| {
Ok(AsyncJobHandle {
id: AsyncJobId::new(row.id),
workspace_id: WorkspaceId::new(row.workspace_id),
agent_id: row.agent_id.map(AgentId::new),
operation_id: OperationId::new(row.operation_id),
status: deserialize_enum_text(&row.status, "status")?,
progress: row.progress_json,
result: row.result_json,
error: row.error_json,
expires_at: row.expires_at,
last_poll_at: row.last_poll_at,
created_at: row.created_at,
updated_at: row.updated_at,
finished_at: row.finished_at,
})
})
.transpose()
}
pub async fn update_async_job_status(
&self,
request: UpdateAsyncJobStatusRequest<'_>,
) -> Result<AsyncJobHandle, RegistryError> {
validate_async_job_transition(request.job_id, request.current_status, request.next_status)?;
let row = sqlx::query(
"update async_jobs
set status = $3,
progress_json = $4::jsonb,
result_json = $5::jsonb,
error_json = $6::jsonb,
expires_at = coalesce($7::timestamptz, expires_at),
updated_at = $8::timestamptz,
finished_at = case
when $9::timestamptz is null then finished_at
else $9::timestamptz
end
where id = $1
and status = $2
returning
id,
workspace_id,
agent_id,
operation_id,
status,
progress_json,
result_json,
error_json,
expires_at,
last_poll_at,
created_at,
updated_at,
finished_at",
)
.bind(request.job_id.as_str())
.bind(serialize_enum_text(&request.current_status, "status")?)
.bind(serialize_enum_text(&request.next_status, "status")?)
.bind(Json(request.progress.clone()))
.bind(request.result.cloned().map(Json))
.bind(request.error.cloned().map(Json))
.bind(request.expires_at)
.bind(request.updated_at)
.bind(request.finished_at)
.fetch_optional(&self.pool)
.await?;
match row.as_ref() {
Some(row) => map_async_job(row),
None => {
if self.get_async_job(request.job_id).await?.is_some() {
Err(RegistryError::InvalidAsyncJobTransition {
job_id: request.job_id.as_str().to_owned(),
from: serialize_enum_text(&request.current_status, "status")?,
to: serialize_enum_text(&request.next_status, "status")?,
})
} else {
Err(RegistryError::AsyncJobNotFound {
job_id: request.job_id.as_str().to_owned(),
})
}
}
}
}
pub async fn touch_async_job_poll(
&self,
id: &AsyncJobId,
now: &OffsetDateTime,
) -> Result<AsyncJobHandle, RegistryError> {
let row = sqlx::query(
"update async_jobs
set last_poll_at = $2::timestamptz
where id = $1
returning
id,
workspace_id,
agent_id,
operation_id,
status,
progress_json,
result_json,
error_json,
expires_at,
last_poll_at,
created_at,
updated_at,
finished_at",
)
.bind(id.as_str())
.bind(now)
.fetch_optional(&self.pool)
.await?;
match row.as_ref() {
Some(row) => map_async_job(row),
None => Err(RegistryError::AsyncJobNotFound {
job_id: id.as_str().to_owned(),
}),
}
}
pub async fn cancel_async_job(
&self,
id: &AsyncJobId,
now: &OffsetDateTime,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"update async_jobs
set status = 'cancelled',
updated_at = $2::timestamptz,
finished_at = $2::timestamptz
where id = $1
and status in ('created', 'running')",
)
.bind(id.as_str())
.bind(now)
.execute(&self.pool)
.await?;
if result.rows_affected() > 0 {
return Ok(());
}
match self.get_async_job(id).await? {
Some(existing) => Err(RegistryError::InvalidAsyncJobTransition {
job_id: id.as_str().to_owned(),
from: serialize_enum_text(&existing.status, "status")?,
to: "cancelled".to_owned(),
}),
None => Err(RegistryError::AsyncJobNotFound {
job_id: id.as_str().to_owned(),
}),
}
}
pub async fn list_async_jobs(
&self,
filter: AsyncJobFilter<'_>,
) -> Result<Page<AsyncJobHandle>, RegistryError> {
let status = filter
.status
.as_ref()
.map(|value| serialize_enum_text(value, "status"))
.transpose()?;
let limit = i64::from(filter.limit);
let rows = sqlx::query!(
"select
id,
workspace_id,
agent_id,
operation_id,
status,
progress_json,
result_json,
error_json,
expires_at as \"expires_at: OffsetDateTime\",
last_poll_at as \"last_poll_at: OffsetDateTime\",
created_at as \"created_at!: OffsetDateTime\",
updated_at as \"updated_at!: OffsetDateTime\",
finished_at as \"finished_at: OffsetDateTime\"
from async_jobs
where workspace_id = $1
and ($2::text is null or agent_id = $2)
and ($3::text is null or operation_id = $3)
and ($4::text is null or status = $4)
order by updated_at desc
limit $5",
filter.workspace_id.as_str(),
filter.agent_id.map(|value| value.as_str()),
filter.operation_id.map(|value| value.as_str()),
status,
limit,
)
.fetch_all(&self.pool)
.await?;
let items = rows
.into_iter()
.map(|row| {
Ok(AsyncJobHandle {
id: AsyncJobId::new(row.id),
workspace_id: WorkspaceId::new(row.workspace_id),
agent_id: row.agent_id.map(AgentId::new),
operation_id: OperationId::new(row.operation_id),
status: deserialize_enum_text(&row.status, "status")?,
progress: row.progress_json,
result: row.result_json,
error: row.error_json,
expires_at: row.expires_at,
last_poll_at: row.last_poll_at,
created_at: row.created_at,
updated_at: row.updated_at,
finished_at: row.finished_at,
})
})
.collect::<Result<Vec<_>, RegistryError>>()?;
let total = items.len() as u64;
Ok(Page { items, total })
}
pub async fn delete_expired_async_jobs(
&self,
now: &OffsetDateTime,
) -> Result<u64, RegistryError> {
let result = sqlx::query(
"delete from async_jobs
where expires_at is not null
and expires_at <= $1::timestamptz",
)
.bind(now)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
}
@@ -0,0 +1,391 @@
use super::*;
impl PostgresRegistry {
pub async fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>, RegistryError> {
let rows = sqlx::query!(
"select
id,
slug,
display_name,
status,
settings_json,
created_at as \"created_at!: time::OffsetDateTime\",
updated_at as \"updated_at!: time::OffsetDateTime\"
from workspaces
order by slug asc",
)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(WorkspaceRecord {
workspace: Workspace {
id: WorkspaceId::new(row.id),
slug: row.slug,
display_name: row.display_name,
status: deserialize_enum_text(&row.status, "status")?,
settings: row.settings_json,
created_at: row.created_at,
updated_at: row.updated_at,
},
})
})
.collect()
}
pub async fn list_workspaces_for_user(
&self,
user_id: &UserId,
) -> Result<Vec<WorkspaceMembershipRecord>, RegistryError> {
let rows = sqlx::query!(
"select
w.id,
w.slug,
w.display_name,
w.status,
w.settings_json,
w.created_at as \"created_at!: time::OffsetDateTime\",
w.updated_at as \"updated_at!: time::OffsetDateTime\",
m.role
from memberships m
join workspaces w on w.id = m.workspace_id
where m.user_id = $1
order by w.slug asc",
user_id.as_str(),
)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(WorkspaceMembershipRecord {
workspace: Workspace {
id: WorkspaceId::new(row.id),
slug: row.slug,
display_name: row.display_name,
status: deserialize_enum_text(&row.status, "status")?,
settings: row.settings_json,
created_at: row.created_at,
updated_at: row.updated_at,
},
role: deserialize_enum_text(&row.role, "role")?,
})
})
.collect()
}
pub async fn list_memberships(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<MembershipRecord>, RegistryError> {
let rows = sqlx::query!(
"select
m.workspace_id,
m.user_id,
m.role,
m.created_at as \"created_at!: time::OffsetDateTime\",
u.email,
u.display_name,
u.status,
u.created_at as \"user_created_at!: time::OffsetDateTime\"
from memberships m
join users u on u.id = m.user_id
where m.workspace_id = $1
order by u.email asc",
workspace_id.as_str(),
)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(MembershipRecord {
workspace_id: WorkspaceId::new(row.workspace_id),
user: User {
id: UserId::new(row.user_id),
email: row.email,
display_name: row.display_name,
status: deserialize_enum_text(&row.status, "status")?,
created_at: row.user_created_at,
},
role: deserialize_enum_text(&row.role, "role")?,
created_at: row.created_at,
})
})
.collect()
}
pub async fn update_membership_role(
&self,
workspace_id: &WorkspaceId,
user_id: &UserId,
role: MembershipRole,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"update memberships
set role = $3
where workspace_id = $1 and user_id = $2",
)
.bind(workspace_id.as_str())
.bind(user_id.as_str())
.bind(serialize_enum_text(&role, "role")?)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::MembershipNotFound {
workspace_id: workspace_id.as_str().to_owned(),
user_id: user_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn delete_membership(
&self,
workspace_id: &WorkspaceId,
user_id: &UserId,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"delete from memberships
where workspace_id = $1 and user_id = $2",
)
.bind(workspace_id.as_str())
.bind(user_id.as_str())
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::MembershipNotFound {
workspace_id: workspace_id.as_str().to_owned(),
user_id: user_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn list_invitations(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<InvitationRecord>, RegistryError> {
let rows = sqlx::query!(
"select
id,
workspace_id,
email,
role,
status,
token_hash,
expires_at as \"expires_at!: time::OffsetDateTime\",
created_at as \"created_at!: time::OffsetDateTime\"
from invitation_tokens
where workspace_id = $1
order by created_at desc",
workspace_id.as_str(),
)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(InvitationRecord {
invitation: InvitationToken {
id: InvitationId::new(row.id),
workspace_id: WorkspaceId::new(row.workspace_id),
email: row.email,
role: deserialize_enum_text(&row.role, "role")?,
status: deserialize_enum_text(&row.status, "status")?,
token_hash: row.token_hash,
expires_at: row.expires_at,
created_at: row.created_at,
},
})
})
.collect()
}
pub async fn create_invitation(
&self,
request: CreateInvitationRequest<'_>,
) -> Result<(), RegistryError> {
sqlx::query(
"insert into invitation_tokens (
id,
workspace_id,
email,
role,
status,
token_hash,
expires_at,
created_at
) values (
$1, $2, $3, $4, $5, $6, $7::timestamptz, $8::timestamptz
)",
)
.bind(request.invitation.id.as_str())
.bind(request.invitation.workspace_id.as_str())
.bind(&request.invitation.email)
.bind(serialize_enum_text(&request.invitation.role, "role")?)
.bind(serialize_enum_text(&request.invitation.status, "status")?)
.bind(&request.invitation.token_hash)
.bind(request.invitation.expires_at)
.bind(request.invitation.created_at)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn delete_invitation(
&self,
workspace_id: &WorkspaceId,
invitation_id: &InvitationId,
) -> Result<(), RegistryError> {
let result =
sqlx::query("delete from invitation_tokens where workspace_id = $1 and id = $2")
.bind(workspace_id.as_str())
.bind(invitation_id.as_str())
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::InvitationNotFound {
invitation_id: invitation_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn delete_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), RegistryError> {
let result = sqlx::query("delete from workspaces where id = $1")
.bind(workspace_id.as_str())
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::WorkspaceNotFound {
workspace_id: workspace_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn get_workspace(
&self,
workspace_id: &WorkspaceId,
) -> Result<Option<WorkspaceRecord>, RegistryError> {
let row = sqlx::query!(
"select
id,
slug,
display_name,
status,
settings_json,
created_at as \"created_at!: time::OffsetDateTime\",
updated_at as \"updated_at!: time::OffsetDateTime\"
from workspaces
where id = $1",
workspace_id.as_str(),
)
.fetch_optional(&self.pool)
.await?;
row.map(|row| {
Ok(WorkspaceRecord {
workspace: Workspace {
id: WorkspaceId::new(row.id),
slug: row.slug,
display_name: row.display_name,
status: deserialize_enum_text(&row.status, "status")?,
settings: row.settings_json,
created_at: row.created_at,
updated_at: row.updated_at,
},
})
})
.transpose()
}
pub async fn create_workspace(
&self,
request: CreateWorkspaceRequest<'_>,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"insert into workspaces (
id,
slug,
display_name,
status,
settings_json,
created_at,
updated_at
) values (
$1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz
)",
)
.bind(request.workspace.id.as_str())
.bind(&request.workspace.slug)
.bind(&request.workspace.display_name)
.bind(serialize_enum_text(&request.workspace.status, "status")?)
.bind(Json(request.workspace.settings.clone()))
.bind(request.workspace.created_at)
.bind(request.workspace.updated_at)
.execute(&self.pool)
.await;
match result {
Ok(_) => Ok(()),
Err(sqlx::Error::Database(error))
if error.constraint() == Some("workspaces_slug_key") =>
{
Err(RegistryError::WorkspaceSlugAlreadyExists {
slug: request.workspace.slug.clone(),
})
}
Err(error) => Err(RegistryError::Storage(error)),
}
}
pub async fn update_workspace(
&self,
request: UpdateWorkspaceRequest<'_>,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"update workspaces
set slug = $1,
display_name = $2,
status = $3,
settings_json = $4,
updated_at = $5::timestamptz
where id = $6",
)
.bind(&request.workspace.slug)
.bind(&request.workspace.display_name)
.bind(serialize_enum_text(&request.workspace.status, "status")?)
.bind(Json(request.workspace.settings.clone()))
.bind(request.workspace.updated_at)
.bind(request.workspace.id.as_str())
.execute(&self.pool)
.await;
match result {
Ok(result) if result.rows_affected() == 0 => Err(RegistryError::WorkspaceNotFound {
workspace_id: request.workspace.id.as_str().to_owned(),
}),
Ok(_) => Ok(()),
Err(sqlx::Error::Database(error))
if error.constraint() == Some("workspaces_slug_key") =>
{
Err(RegistryError::WorkspaceSlugAlreadyExists {
slug: request.workspace.slug.clone(),
})
}
Err(error) => Err(RegistryError::Storage(error)),
}
}
}
@@ -0,0 +1,101 @@
use super::*;
impl PostgresRegistry {
pub async fn create_yaml_import_job(
&self,
request: CreateYamlImportJobRequest<'_>,
) -> Result<(), RegistryError> {
sqlx::query(
"insert into yaml_import_jobs (
id,
source_sample_id,
status,
format_version,
mode,
result_operation_id,
result_version,
error_text,
created_at,
finished_at
) values (
$1, $2, $3, $4, $5, null, null, null, $6::timestamptz, null
)",
)
.bind(request.id.as_str())
.bind(request.source_sample_id.map(|value| value.as_str()))
.bind(serialize_enum_text(
&YamlImportJobStatus::Pending,
"status",
)?)
.bind(request.format_version)
.bind(serialize_enum_text(&request.mode, "mode")?)
.bind(request.created_at)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn finish_yaml_import_job(
&self,
job_id: &YamlImportJobId,
completion: &YamlImportJobCompletion,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"update yaml_import_jobs
set status = $2,
result_operation_id = $3,
result_version = $4,
error_text = $5,
finished_at = $6::timestamptz
where id = $1",
)
.bind(job_id.as_str())
.bind(serialize_enum_text(&completion.status, "status")?)
.bind(
completion
.result_operation_id
.as_ref()
.map(|value| value.as_str()),
)
.bind(completion.result_version.map(to_db_version))
.bind(completion.error_text.as_deref())
.bind(completion.finished_at)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::YamlImportJobNotFound {
job_id: job_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn get_yaml_import_job(
&self,
job_id: &YamlImportJobId,
) -> Result<Option<YamlImportJob>, RegistryError> {
let row = sqlx::query(
"select
id,
source_sample_id,
status,
format_version,
mode,
result_operation_id,
result_version,
error_text,
created_at,
finished_at
from yaml_import_jobs
where id = $1",
)
.bind(job_id.as_str())
.fetch_optional(&self.pool)
.await?;
row.as_ref().map(map_yaml_import_job).transpose()
}
}