From 8b8f2fc6c512db1636683b1cfb7d276fa0b68e17 Mon Sep 17 00:00:00 2001 From: github-ops Date: Wed, 24 Jun 2026 13:51:54 +0000 Subject: [PATCH] Expose approval requests in admin logs --- apps/admin-api/src/app.rs | 7 +- apps/admin-api/src/dto.rs | 14 +- apps/admin-api/src/routes/observability.rs | 34 +++- apps/admin-api/src/service/observability.rs | 86 +++++++- apps/ui/css/logs.css | 185 ++++++++++++++++++ apps/ui/html/logs.html | 14 ++ apps/ui/js/api.js | 6 + apps/ui/js/i18n.js | 42 ++++ apps/ui/js/logs.js | 178 ++++++++++++++++- crates/crank-registry/src/lib.rs | 29 +-- crates/crank-registry/src/model.rs | 7 + .../crank-registry/src/postgres/approval.rs | 77 ++++++++ crates/crank-registry/src/postgres/mod.rs | 20 +- .../tests/integration/workspace_access.rs | 42 +++- 14 files changed, 699 insertions(+), 42 deletions(-) diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 34944a5..c655859 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -19,7 +19,10 @@ use crate::{ auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles}, capabilities::get_capabilities, imports::{create_openapi_import, preview_openapi_import}, - observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs}, + observability::{ + get_agent_usage, get_approval, get_log, get_operation_usage, get_usage, list_approvals, + list_logs, + }, operations::{ analyze_operation_quality, archive_operation, create_operation, create_version, delete_operation, export_operation, generate_draft, get_operation, @@ -123,6 +126,8 @@ pub fn build_app(state: AppState) -> Router { .route("/export", get(export_workspace)) .route("/logs", get(list_logs)) .route("/logs/{log_id}", get(get_log)) + .route("/approvals", get(list_approvals)) + .route("/approvals/{approval_id}", get(get_approval)) .route("/usage", get(get_usage)) .route("/usage/operations/{operation_id}", get(get_operation_usage)) .route("/usage/agents/{agent_id}", get(get_agent_usage)); diff --git a/apps/admin-api/src/dto.rs b/apps/admin-api/src/dto.rs index 14cb8ff..da46c81 100644 --- a/apps/admin-api/src/dto.rs +++ b/apps/admin-api/src/dto.rs @@ -1,8 +1,8 @@ use crank_core::{ - AgentId, AgentStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode, GeneratedDraft, - InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel, OperationStatus, - PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target, UsagePeriod, - WizardState, WorkspaceId, WorkspaceStatus, + AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode, + GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel, + OperationStatus, PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target, + UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus, }; use crank_mapping::MappingSet; use crank_registry::{ @@ -250,6 +250,12 @@ pub struct LogsQuery { pub limit: Option, } +#[derive(Clone, Debug, Deserialize)] +pub struct ApprovalsQuery { + pub status: Option, + pub limit: Option, +} + #[derive(Clone, Debug, Deserialize)] pub struct UsageRequestQuery { pub period: Option, diff --git a/apps/admin-api/src/routes/observability.rs b/apps/admin-api/src/routes/observability.rs index dfa6329..84c56c4 100644 --- a/apps/admin-api/src/routes/observability.rs +++ b/apps/admin-api/src/routes/observability.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use crate::{ error::ApiError, routes::access::WorkspacePath, - service::{LogsQuery, UsageRequestQuery}, + service::{ApprovalsQuery, LogsQuery, UsageRequestQuery}, state::AppState, }; @@ -17,6 +17,12 @@ pub struct WorkspaceLogPath { pub log_id: String, } +#[derive(serde::Deserialize)] +pub struct WorkspaceApprovalPath { + pub workspace_id: String, + pub approval_id: String, +} + #[derive(serde::Deserialize)] pub struct WorkspaceOperationUsagePath { pub workspace_id: String, @@ -55,6 +61,32 @@ pub async fn get_log( Ok(Json(json!(item))) } +pub async fn list_approvals( + Path(path): Path, + Query(query): Query, + State(state): State, +) -> Result, ApiError> { + let items = state + .service + .list_approvals(&path.workspace_id.as_str().into(), query) + .await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn get_approval( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let item = state + .service + .get_approval( + &path.workspace_id.as_str().into(), + &path.approval_id.as_str().into(), + ) + .await?; + Ok(Json(json!(item))) +} + pub async fn get_usage( Path(path): Path, Query(query): Query, diff --git a/apps/admin-api/src/service/observability.rs b/apps/admin-api/src/service/observability.rs index 8cdf010..6d72501 100644 --- a/apps/admin-api/src/service/observability.rs +++ b/apps/admin-api/src/service/observability.rs @@ -1,11 +1,21 @@ -use crank_core::{AgentId, InvocationLogId, OperationId, UsagePeriod, WorkspaceId}; -use crank_registry::{InvocationLogRecord, ListInvocationLogsQuery, UsageQuery, UsageRollupRecord}; +use crank_core::{ + AgentId, ApprovalRequestId, ApprovalRequestStatus, InvocationLogId, OperationId, UsagePeriod, + WorkspaceId, +}; +use crank_registry::{ + ApprovalRequestRecord, ExpireApprovalRequest, InvocationLogRecord, ListApprovalRequestsQuery, + ListInvocationLogsQuery, UsageQuery, UsageRollupRecord, +}; use serde_json::json; +use time::OffsetDateTime; use tracing::instrument; use crate::{ error::ApiError, - service::{AdminService, LogsQuery, UsageOverviewResponse, UsageRequestQuery, usage_window}, + service::{ + AdminService, ApprovalsQuery, LogsQuery, UsageOverviewResponse, UsageRequestQuery, + usage_window, + }, }; impl AdminService { @@ -52,6 +62,76 @@ impl AdminService { }) } + #[instrument(skip(self))] + pub async fn list_approvals( + &self, + workspace_id: &WorkspaceId, + query: ApprovalsQuery, + ) -> Result, ApiError> { + self.ensure_workspace_exists(workspace_id).await?; + let records = self + .registry + .list_approval_requests(ListApprovalRequestsQuery { + workspace_id, + status: query.status, + limit: query.limit.unwrap_or(50).clamp(1, 200), + }) + .await?; + + let mut normalized = Vec::with_capacity(records.len()); + for record in records { + let record = self.normalize_approval_record(record).await?; + if query.status.is_none() || record.approval.status == query.status.unwrap() { + normalized.push(record); + } + } + + Ok(normalized) + } + + #[instrument(skip(self))] + pub async fn get_approval( + &self, + workspace_id: &WorkspaceId, + approval_id: &ApprovalRequestId, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + let record = self + .registry + .get_approval_request(workspace_id, approval_id) + .await? + .ok_or_else(|| { + ApiError::not_found_with_context( + format!("approval request {} was not found", approval_id.as_str()), + json!({ "approval_id": approval_id.as_str() }), + ) + })?; + + self.normalize_approval_record(record).await + } + + async fn normalize_approval_record( + &self, + record: ApprovalRequestRecord, + ) -> Result { + if record.approval.status != ApprovalRequestStatus::Pending + || record.approval.expires_at > OffsetDateTime::now_utc() + { + return Ok(record); + } + + Ok(self + .registry + .expire_approval_request(ExpireApprovalRequest { + workspace_id: &record.approval.workspace_id, + agent_id: &record.approval.agent_id, + approval_id: &record.approval.id, + expired_at: OffsetDateTime::now_utc(), + }) + .await? + .unwrap_or(record)) + } + #[instrument(skip(self))] pub async fn get_usage_overview( &self, diff --git a/apps/ui/css/logs.css b/apps/ui/css/logs.css index a449de3..87e0127 100644 --- a/apps/ui/css/logs.css +++ b/apps/ui/css/logs.css @@ -134,3 +134,188 @@ } .refresh-btn:hover { color: var(--text-secondary); background: var(--bg-muted); } + +.approval-panel { + margin-bottom: 18px; +} + +.approval-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 20px; + border-bottom: 1px solid var(--border-subtle); +} + +.approval-panel-title { + font-size: 15px; + font-weight: 650; + color: var(--text-primary); +} + +.approval-panel-subtitle { + margin-top: 4px; + font-size: 12.5px; + line-height: 1.45; + color: var(--text-muted); +} + +.approval-refresh-btn { + margin-left: 0; +} + +.approval-list { + display: grid; + gap: 12px; + padding: 16px 20px 20px; +} + +.approval-empty { + padding: 20px; + border: 1px dashed var(--border); + border-radius: 10px; + background: var(--bg-canvas); + color: var(--text-muted); + font-size: 13px; +} + +.approval-empty-error { + border-color: rgba(248, 81, 73, 0.35); + color: var(--red); +} + +.approval-item { + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-canvas); + padding: 14px; +} + +.approval-pending { + border-color: rgba(210, 153, 34, 0.45); + background: linear-gradient(180deg, rgba(210, 153, 34, 0.08), var(--bg-canvas) 46%); +} + +.approval-completed { + border-color: rgba(63, 185, 80, 0.28); +} + +.approval-failed, +.approval-denied, +.approval-expired { + border-color: rgba(248, 81, 73, 0.26); +} + +.approval-item-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.approval-item-title { + font-size: 14px; + font-weight: 650; + color: var(--text-primary); +} + +.approval-item-meta, +.approval-timing, +.approval-note { + margin-top: 5px; + font-size: 11.5px; + color: var(--text-muted); +} + +.approval-item-body { + margin: 10px 0 0; + font-size: 13px; + line-height: 1.55; + color: var(--text-secondary); +} + +.approval-status { + flex-shrink: 0; + border: 1px solid var(--border); + border-radius: 999px; + padding: 3px 9px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.35px; + color: var(--text-muted); + background: var(--bg-overlay); +} + +.approval-status-pending { + color: var(--amber); + border-color: rgba(210, 153, 34, 0.45); + background: rgba(210, 153, 34, 0.1); +} + +.approval-status-completed { + color: var(--green); + border-color: rgba(63, 185, 80, 0.35); + background: rgba(63, 185, 80, 0.1); +} + +.approval-status-denied, +.approval-status-expired, +.approval-status-failed { + color: var(--red); + border-color: rgba(248, 81, 73, 0.35); + background: rgba(248, 81, 73, 0.09); +} + +.approval-payload-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + margin-top: 12px; +} + +.approval-payload-label { + margin-bottom: 5px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.45px; + text-transform: uppercase; + color: var(--text-muted); +} + +.approval-payload-code { + margin: 0; + max-height: 180px; + overflow: auto; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background: #161b22; + padding: 10px; + color: #c9d1d9; + font-family: 'JetBrains Mono', monospace; + font-size: 11.5px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} + +@media (max-width: 720px) { + .approval-panel-header { + align-items: stretch; + flex-direction: column; + } + + .approval-refresh-btn { + justify-content: center; + } + + .approval-item-header { + align-items: stretch; + flex-direction: column; + } + + .approval-status { + align-self: flex-start; + } +} diff --git a/apps/ui/html/logs.html b/apps/ui/html/logs.html index 33a184d..aa59b8f 100644 --- a/apps/ui/html/logs.html +++ b/apps/ui/html/logs.html @@ -78,6 +78,20 @@ +
+
+
+
Human confirmations
+
Requests waiting for an external user decision and recent results.
+
+ +
+
+
+
diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index d9466cc..b905658 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -327,6 +327,12 @@ getLog: function(workspaceId, logId) { return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs/' + encodeURIComponent(logId)); }, + listApprovals: function(workspaceId, params) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/approvals' + query(params)); + }, + getApproval: function(workspaceId, approvalId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/approvals/' + encodeURIComponent(approvalId)); + }, getUsageOverview: function(workspaceId, params) { return get('/workspaces/' + encodeURIComponent(workspaceId) + '/usage' + query(params)); }, diff --git a/apps/ui/js/i18n.js b/apps/ui/js/i18n.js index c1b3920..02b07aa 100644 --- a/apps/ui/js/i18n.js +++ b/apps/ui/js/i18n.js @@ -281,6 +281,27 @@ var TRANSLATIONS = { 'logs.live.off.body': 'Automatic polling is paused.', 'logs.refresh.title': 'Logs refreshed', 'logs.refresh.body': 'The latest invocation records were loaded for the current workspace.', + 'approvals.title': 'Human confirmations', + 'approvals.subtitle': 'Requests waiting for an external user decision and recent results.', + 'approvals.refresh': 'Refresh', + 'approvals.refresh.title': 'Confirmations refreshed', + 'approvals.refresh.body': 'The latest confirmation requests were loaded.', + 'approvals.loading': 'Loading confirmation requests…', + 'approvals.empty': 'There are no confirmation requests yet.', + 'approvals.error.load': 'Failed to load confirmation requests', + 'approvals.untitled': 'Confirmation request', + 'approvals.operation': 'Operation', + 'approvals.agent': 'Agent', + 'approvals.expires_at': 'Expires', + 'approvals.updated_at': 'Updated', + 'approvals.request': 'Request', + 'approvals.response': 'Result', + 'approvals.status.pending': 'Pending', + 'approvals.status.approved': 'Approved', + 'approvals.status.denied': 'Denied', + 'approvals.status.expired': 'Expired', + 'approvals.status.completed': 'Completed', + 'approvals.status.failed': 'Failed', // Usage page 'usage.title': 'Usage', @@ -1127,6 +1148,27 @@ var TRANSLATIONS = { 'logs.live.off.body': 'Автоматический опрос остановлен.', 'logs.refresh.title': 'Логи обновлены', 'logs.refresh.body': 'Получены последние записи вызовов для текущего воркспейса.', + 'approvals.title': 'Подтверждения человеком', + 'approvals.subtitle': 'Заявки, которые ожидают решения пользователя, и последние результаты.', + 'approvals.refresh': 'Обновить', + 'approvals.refresh.title': 'Подтверждения обновлены', + 'approvals.refresh.body': 'Получены последние заявки на подтверждение.', + 'approvals.loading': 'Загрузка заявок на подтверждение…', + 'approvals.empty': 'Заявок на подтверждение пока нет.', + 'approvals.error.load': 'Не удалось загрузить заявки на подтверждение', + 'approvals.untitled': 'Заявка на подтверждение', + 'approvals.operation': 'Операция', + 'approvals.agent': 'Агент', + 'approvals.expires_at': 'Истекает', + 'approvals.updated_at': 'Обновлено', + 'approvals.request': 'Запрос', + 'approvals.response': 'Результат', + 'approvals.status.pending': 'Ожидает', + 'approvals.status.approved': 'Подтверждено', + 'approvals.status.denied': 'Отклонено', + 'approvals.status.expired': 'Истекло', + 'approvals.status.completed': 'Выполнено', + 'approvals.status.failed': 'Ошибка', // Usage page 'usage.title': 'Использование', diff --git a/apps/ui/js/logs.js b/apps/ui/js/logs.js index b51f776..534d05a 100644 --- a/apps/ui/js/logs.js +++ b/apps/ui/js/logs.js @@ -11,9 +11,14 @@ document.addEventListener('DOMContentLoaded', function () { workspaceId: null, loading: false, loadError: '', + approvals: [], + approvalsLoading: false, + approvalsError: '', }; var logList = document.getElementById('log-list'); + var approvalList = document.getElementById('approval-list'); + var approvalRefreshBtn = document.getElementById('approval-refresh-btn'); var logSearch = document.getElementById('log-search'); var refreshBtn = document.getElementById('refresh-btn'); var timeRangeSel = document.getElementById('time-range'); @@ -54,6 +59,19 @@ document.addEventListener('DOMContentLoaded', function () { return date.toISOString().slice(11, 23); } + function formatDateTime(timestamp) { + if (!timestamp) { + return ''; + } + var date = new Date(timestamp); + return date.toLocaleString(window.CrankLocale || undefined, { + day: '2-digit', + month: 'short', + hour: '2-digit', + minute: '2-digit', + }); + } + function element(tag, className, text) { var node = document.createElement(tag); if (className) node.className = className; @@ -111,6 +129,115 @@ document.addEventListener('DOMContentLoaded', function () { }; } + function normalizeApproval(record) { + var approval = record.approval || record; + return { + id: approval.id, + agentId: approval.agent_id, + operationId: approval.operation_id, + operationVersion: approval.operation_version, + status: approval.status, + riskLevel: approval.risk_level, + title: approval.confirmation_title, + body: approval.confirmation_body, + requestPayload: approval.request_payload, + responsePayload: approval.response_payload, + createdAt: approval.created_at, + expiresAt: approval.expires_at, + decidedAt: approval.decided_at, + note: approval.decision_note, + }; + } + + function approvalStatusLabel(status) { + var key = 'approvals.status.' + status; + var translated = tKey(key); + return translated === key ? status : translated; + } + + function renderApprovals() { + if (!approvalList) { + return; + } + + approvalList.innerHTML = ''; + + if (state.approvalsLoading && state.approvals.length === 0) { + var loading = element('div', 'approval-empty', tKey('approvals.loading')); + approvalList.appendChild(loading); + return; + } + + if (state.approvalsError) { + var error = element('div', 'approval-empty approval-empty-error', state.approvalsError); + approvalList.appendChild(error); + return; + } + + if (!state.approvals.length) { + var empty = element('div', 'approval-empty', tKey('approvals.empty')); + approvalList.appendChild(empty); + return; + } + + var fragment = document.createDocumentFragment(); + state.approvals.forEach(function (item) { + var card = element('article', 'approval-item approval-' + item.status); + + var header = element('div', 'approval-item-header'); + var titleWrap = element('div', 'approval-item-title-wrap'); + titleWrap.appendChild(element('div', 'approval-item-title', item.title || tKey('approvals.untitled'))); + + var meta = element('div', 'approval-item-meta'); + meta.textContent = [ + tKey('approvals.operation') + ': ' + item.operationId + ' v' + item.operationVersion, + tKey('approvals.agent') + ': ' + item.agentId, + ].join(' · '); + titleWrap.appendChild(meta); + header.appendChild(titleWrap); + + var badge = element('span', 'approval-status approval-status-' + item.status, approvalStatusLabel(item.status)); + header.appendChild(badge); + card.appendChild(header); + + if (item.body) { + card.appendChild(element('p', 'approval-item-body', item.body)); + } + + var timing = element('div', 'approval-timing'); + timing.textContent = item.status === 'pending' + ? tKey('approvals.expires_at') + ': ' + formatDateTime(item.expiresAt) + : tKey('approvals.updated_at') + ': ' + formatDateTime(item.decidedAt || item.createdAt); + card.appendChild(timing); + + var payloadGrid = element('div', 'approval-payload-grid'); + var requestBlock = element('div', 'approval-payload'); + requestBlock.appendChild(element('div', 'approval-payload-label', tKey('approvals.request'))); + var requestPre = element('pre', 'approval-payload-code'); + requestPre.textContent = formatJson(item.requestPayload); + requestBlock.appendChild(requestPre); + payloadGrid.appendChild(requestBlock); + + if (item.responsePayload !== null && item.responsePayload !== undefined) { + var responseBlock = element('div', 'approval-payload'); + responseBlock.appendChild(element('div', 'approval-payload-label', tKey('approvals.response'))); + var responsePre = element('pre', 'approval-payload-code'); + responsePre.textContent = formatJson(item.responsePayload); + responseBlock.appendChild(responsePre); + payloadGrid.appendChild(responseBlock); + } + card.appendChild(payloadGrid); + + if (item.note) { + card.appendChild(element('div', 'approval-note', item.note)); + } + + fragment.appendChild(card); + }); + + approvalList.appendChild(fragment); + } + function renderEmpty(title, message) { logList.innerHTML = ''; var empty = element('div', 'empty-state'); @@ -306,6 +433,39 @@ document.addEventListener('DOMContentLoaded', function () { } } + async function loadApprovals() { + if (!window.CrankApi) { + state.approvalsError = tKey('logs.error.api'); + renderApprovals(); + return; + } + + state.workspaceId = currentWorkspaceId(); + if (!state.workspaceId) { + state.approvalsError = tKey('logs.error.workspace'); + renderApprovals(); + return; + } + + state.approvalsLoading = true; + state.approvalsError = ''; + renderApprovals(); + + try { + var response = await window.CrankApi.listApprovals(state.workspaceId, { limit: 20 }); + state.approvals = (response && response.items ? response.items : []).map(normalizeApproval); + } catch (error) { + state.approvalsError = error.message || tKey('approvals.error.load'); + } finally { + state.approvalsLoading = false; + renderApprovals(); + } + } + + async function refreshOperationalData() { + await Promise.all([loadLogs(), loadApprovals()]); + } + async function loadLogDetail(logId) { if (!window.CrankApi || !state.workspaceId || state.details[logId]) { return; @@ -343,7 +503,7 @@ document.addEventListener('DOMContentLoaded', function () { if (!state.liveMode) { return; } - state.timer = setInterval(loadLogs, 4000); + state.timer = setInterval(refreshOperationalData, 4000); } function toggleLive() { @@ -386,6 +546,16 @@ document.addEventListener('DOMContentLoaded', function () { }); } + if (approvalRefreshBtn) { + approvalRefreshBtn.addEventListener('click', function () { + loadApprovals().then(function () { + if (!state.approvalsError && window.CrankUi) { + window.CrankUi.info(tKey('approvals.refresh.body'), tKey('approvals.refresh.title')); + } + }); + }); + } + if (timeRangeSel) { timeRangeSel.value = state.period; timeRangeSel.addEventListener('change', function () { @@ -405,15 +575,15 @@ document.addEventListener('DOMContentLoaded', function () { window.addEventListener('crank:workspacechange', function () { state.details = {}; state.openId = null; - loadLogs(); + refreshOperationalData(); }); setLiveState(); startPolling(); if (window.whenWorkspacesReady) { - window.whenWorkspacesReady().finally(loadLogs); + window.whenWorkspacesReady().finally(refreshOperationalData); } else { - loadLogs(); + refreshOperationalData(); } }); diff --git a/crates/crank-registry/src/lib.rs b/crates/crank-registry/src/lib.rs index c1a8ce4..04190d8 100644 --- a/crates/crank-registry/src/lib.rs +++ b/crates/crank-registry/src/lib.rs @@ -28,10 +28,10 @@ pub mod requests { CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest, ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest, - ListInvocationLogsQuery, PublishAgentRequest, PublishRequest, RotateSecretRequest, - SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, - SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, UpdateWorkspaceRequest, - UsageQuery, + ListApprovalRequestsQuery, ListInvocationLogsQuery, PublishAgentRequest, PublishRequest, + RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest, + SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, + UpdateWorkspaceRequest, UsageQuery, }; } @@ -47,15 +47,16 @@ pub use model::{ CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata, ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest, ImportJob, ImportJobId, - ImportJobKind, ImportJobStatus, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, - MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary, - OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord, PublishAgentRequest, - PublishRequest, PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind, - SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, - SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord, - SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, - UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, - WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId, - YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, + ImportJobKind, ImportJobStatus, InvitationRecord, InvocationLogRecord, + ListApprovalRequestsQuery, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, + OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, Page, + PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool, + RegistryOperation, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest, + SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, + SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord, SessionRecord, + UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery, + UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, + WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId, YamlImportJob, + YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, }; pub use postgres::{PostgresPoolConfig, PostgresPoolConfigError, PostgresRegistry}; diff --git a/crates/crank-registry/src/model.rs b/crates/crank-registry/src/model.rs index 250aa46..6cfe2a4 100644 --- a/crates/crank-registry/src/model.rs +++ b/crates/crank-registry/src/model.rs @@ -524,6 +524,13 @@ pub struct CreateApprovalRequest<'a> { pub approval: &'a ApprovalRequest, } +#[derive(Clone, Debug)] +pub struct ListApprovalRequestsQuery<'a> { + pub workspace_id: &'a WorkspaceId, + pub status: Option, + pub limit: u32, +} + #[derive(Clone, Debug)] pub struct DecideApprovalRequest<'a> { pub workspace_id: &'a WorkspaceId, diff --git a/crates/crank-registry/src/postgres/approval.rs b/crates/crank-registry/src/postgres/approval.rs index 446fbfa..2662ef3 100644 --- a/crates/crank-registry/src/postgres/approval.rs +++ b/crates/crank-registry/src/postgres/approval.rs @@ -140,6 +140,83 @@ impl PostgresRegistry { row.map(map_approval_request_row).transpose() } + pub async fn list_approval_requests( + &self, + query: ListApprovalRequestsQuery<'_>, + ) -> Result, RegistryError> { + let status = query + .status + .map(|status| serialize_enum_text(&status, "approval_status")) + .transpose()?; + let rows = sqlx::query( + "select + id, + workspace_id, + agent_id, + operation_id, + operation_version, + status, + risk_level, + confirmation_title, + confirmation_body, + request_payload_json, + response_payload_json, + created_at, + expires_at, + decided_at, + decided_by_key_id, + decision_note + from approval_requests + where workspace_id = $1 + and ($2::text is null or status = $2) + order by created_at desc + limit $3", + ) + .bind(query.workspace_id.as_str()) + .bind(status.as_deref()) + .bind(i64::from(query.limit)) + .fetch_all(&self.pool) + .await?; + + rows.into_iter().map(map_approval_request_row).collect() + } + + pub async fn get_approval_request( + &self, + workspace_id: &WorkspaceId, + approval_id: &ApprovalRequestId, + ) -> Result, RegistryError> { + let row = sqlx::query( + "select + id, + workspace_id, + agent_id, + operation_id, + operation_version, + status, + risk_level, + confirmation_title, + confirmation_body, + request_payload_json, + response_payload_json, + created_at, + expires_at, + decided_at, + decided_by_key_id, + decision_note + from approval_requests + where workspace_id = $1 + and id = $2 + limit 1", + ) + .bind(workspace_id.as_str()) + .bind(approval_id.as_str()) + .fetch_optional(&self.pool) + .await?; + + row.map(map_approval_request_row).transpose() + } + pub async fn decide_approval_request( &self, request: DecideApprovalRequest<'_>, diff --git a/crates/crank-registry/src/postgres/mod.rs b/crates/crank-registry/src/postgres/mod.rs index ede6424..b79f413 100644 --- a/crates/crank-registry/src/postgres/mod.rs +++ b/crates/crank-registry/src/postgres/mod.rs @@ -36,16 +36,16 @@ use crate::{ CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorMetadata, ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest, - ImportJob, ImportJobId, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, - MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary, - OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, - PublishRequest, PublishedAgentTool, RegistryOperation, RotateSecretRequest, - SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, - SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord, - SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageOperationBreakdown, - UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, - WorkspaceRecord, WorkspaceUpstream, YamlImportJob, YamlImportJobCompletion, - YamlImportJobId, YamlImportJobStatus, + ImportJob, ImportJobId, InvitationRecord, InvocationLogRecord, ListApprovalRequestsQuery, + ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata, + OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, + PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation, + RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest, + SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, + SecretRecord, SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest, + UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, + UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, + YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, }, }; diff --git a/crates/crank-registry/tests/integration/workspace_access.rs b/crates/crank-registry/tests/integration/workspace_access.rs index b881389..0558843 100644 --- a/crates/crank-registry/tests/integration/workspace_access.rs +++ b/crates/crank-registry/tests/integration/workspace_access.rs @@ -23,11 +23,11 @@ use crank_registry::{ CreateAgentRequest, CreateApprovalRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata, - FinishApprovalRequest, OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry, - PublishAgentRequest, PublishRequest, RegistryError, RegistryOperation, SampleKind, - SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, - UpdateWorkspaceRequest, WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId, - YamlImportJobStatus, + FinishApprovalRequest, ListApprovalRequestsQuery, OperationSampleMetadata, + PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryError, + RegistryOperation, SampleKind, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, + SaveSampleMetadataRequest, UpdateWorkspaceRequest, WorkspaceRecord, YamlImportJobCompletion, + YamlImportJobId, YamlImportJobStatus, }; fn test_workspace_id() -> WorkspaceId { @@ -531,6 +531,24 @@ async fn manages_approval_request_lifecycle() { assert_eq!(pending.len(), 1); assert_eq!(pending[0].approval, approval); + let workspace_approvals = registry + .list_approval_requests(ListApprovalRequestsQuery { + workspace_id: &workspace_id, + status: None, + limit: 10, + }) + .await + .unwrap(); + assert_eq!(workspace_approvals.len(), 1); + assert_eq!(workspace_approvals[0].approval.id, approval.id); + + let fetched = registry + .get_approval_request(&workspace_id, &approval.id) + .await + .unwrap() + .unwrap(); + assert_eq!(fetched.approval, approval); + let decided = registry .decide_approval_request(DecideApprovalRequest { workspace_id: &workspace_id, @@ -575,6 +593,20 @@ async fn manages_approval_request_lifecycle() { Some(json!({"id": "lead_123"})) ); + let completed_by_status = registry + .list_approval_requests(ListApprovalRequestsQuery { + workspace_id: &workspace_id, + status: Some(ApprovalRequestStatus::Completed), + limit: 10, + }) + .await + .unwrap(); + assert_eq!(completed_by_status.len(), 1); + assert_eq!( + completed_by_status[0].approval.status, + ApprovalRequestStatus::Completed + ); + let pending_after_decision = registry .list_pending_approval_requests_for_agent(&workspace_id, &agent.id) .await