chore: publish clean community baseline
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,89 @@
|
||||
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("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 },
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
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};
|
||||
@@ -0,0 +1,599 @@
|
||||
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,
|
||||
change_note text null,
|
||||
created_at timestamptz not null,
|
||||
created_by text null,
|
||||
primary key (operation_id, version)
|
||||
)",
|
||||
)
|
||||
.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(())
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
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 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,
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
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 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.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.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(())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user