Files
crank/crates/crank-community-mcp/src/approval_execution.rs
T

387 lines
13 KiB
Rust

use std::{sync::Arc, time::Instant};
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use crank_core::{ApprovalRequestStatus, InvocationLevel, InvocationSource, InvocationStatus};
use crank_core::{CorrelationContext, RequestId, TraceContext};
use crank_registry::{ApprovalRequestRecord, FinishApprovalRequest};
use crank_runtime::{RuntimeExecutionRequest, RuntimeRequestContext};
use crank_trace::{DbOperation, ErrorCategory, Stage, StageOutcome, observe_db_query};
use serde_json::{Value, json};
use time::OffsetDateTime;
use tracing::{Instrument, warn};
use crate::app::{
AgentRoutePath, AppState, InvocationRecord, persist_invocation, resolve_operation_auth,
runtime_operation,
};
const RECOVERY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
const RECOVERY_GRACE: time::Duration = time::Duration::seconds(5);
const EXECUTION_LEASE: time::Duration = time::Duration::minutes(6);
pub(super) fn spawn_approval_recovery(state: Arc<AppState>) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(RECOVERY_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
interval.tick().await;
recover_approved_requests(&state).await;
}
});
}
async fn recover_approved_requests(state: &Arc<AppState>) {
fail_interrupted_requests(state).await;
for _ in 0..32 {
let now = OffsetDateTime::now_utc();
let approval = match observe_db_query(
DbOperation::ApprovalWrite,
state
.registry
.claim_next_recoverable_approval_request(now, now - RECOVERY_GRACE),
)
.await
{
Ok(Some(approval)) => approval,
Ok(None) => break,
Err(_) => {
warn!(
name: "mcp.approval_recovery.query_failed",
error_category = "registry",
"approval recovery query failed"
);
break;
}
};
let Some(path) = approval_agent_path(state, &approval).await else {
continue;
};
let recovery_span = Stage::ApprovalRecovery.span();
let trace_context = crank_trace::trace_context_for_span(&recovery_span)
.unwrap_or_else(TraceContext::generate);
let correlation = CorrelationContext::new(RequestId::generate(), trace_context);
let result = execute_approved_request(state, &path, approval, Some(&correlation))
.instrument(recovery_span.clone())
.await;
match &result {
Ok(_) => StageOutcome::Success.record(&recovery_span),
Err(_) => {
StageOutcome::Error.record(&recovery_span);
ErrorCategory::Approval.record(&recovery_span);
}
}
drop(recovery_span);
if result.is_err() {
warn!(
name: "mcp.approval_recovery.execution_failed",
error_category = "runtime",
"recovered approval execution did not finish"
);
}
}
}
async fn fail_interrupted_requests(state: &Arc<AppState>) {
for _ in 0..32 {
let stale_before = OffsetDateTime::now_utc() - EXECUTION_LEASE;
match observe_db_query(
DbOperation::ApprovalWrite,
state
.registry
.fail_next_interrupted_approval_request(stale_before),
)
.await
{
Ok(Some(approval)) => {
warn!(
name: "mcp.approval_recovery.interrupted",
approval_id = approval.approval.id.as_str(),
"interrupted approval execution was not retried because its outcome is unknown"
);
}
Ok(None) => break,
Err(_) => {
warn!(
name: "mcp.approval_recovery.interrupted_query_failed",
error_category = "registry",
"interrupted approval recovery query failed"
);
break;
}
}
}
}
async fn approval_agent_path(
state: &Arc<AppState>,
approval: &ApprovalRequestRecord,
) -> Option<AgentRoutePath> {
let workspace = match state
.registry
.get_workspace(&approval.approval.workspace_id)
.await
{
Ok(Some(workspace)) => workspace,
Ok(None) => return None,
Err(_) => {
warn!(
name: "mcp.approval_recovery.workspace_lookup_failed",
error_category = "registry",
"approval workspace lookup failed"
);
return None;
}
};
let agent = match state
.registry
.get_agent_summary(&approval.approval.workspace_id, &approval.approval.agent_id)
.await
{
Ok(Some(agent)) => agent,
Ok(None) => return None,
Err(_) => {
warn!(
name: "mcp.approval_recovery.agent_lookup_failed",
error_category = "registry",
"approval agent lookup failed"
);
return None;
}
};
Some(AgentRoutePath {
workspace_slug: workspace.workspace.slug,
agent_slug: agent.slug,
})
}
pub(super) async fn execute_approved_request(
state: &Arc<AppState>,
path: &AgentRoutePath,
approval: ApprovalRequestRecord,
correlation: Option<&CorrelationContext>,
) -> Result<ApprovalRequestRecord, Response> {
let correlation = correlation
.cloned()
.unwrap_or_else(CorrelationContext::generate);
let request_id = correlation.request_id().as_str();
let tools = state
.catalog
.list_tools(&path.workspace_slug, &path.agent_slug)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?;
let Some(tool) = tools.into_iter().find(|tool| {
tool.operation.id == approval.approval.operation_id
&& tool.operation.version == approval.approval.operation_version
}) else {
return finish_unavailable_approval(state, &approval).await;
};
let operation = runtime_operation(&tool);
let started_at = Instant::now();
let runtime_request_context = RuntimeRequestContext::from_correlation(&correlation)
.with_response_cache_scope(
tool.workspace_id.as_str().to_owned(),
tool.agent_id.as_str().to_owned(),
)
.with_metering_context(
tool.workspace_id.clone(),
Some(tool.agent_id.clone()),
InvocationSource::AgentToolCall,
)
.with_approval_granted();
let resolved_auth =
resolve_operation_auth(state, &tool.workspace_id, &operation.execution_config).await;
let result = match resolved_auth {
Ok(resolved_auth) => {
match RuntimeExecutionRequest::try_new(
&tool.workspace_id,
crank_core::ExecutionOrigin::AgentSnapshot,
Some(&tool.agent_id),
&operation,
&approval.approval.request_payload,
crank_runtime::ExecutionAuthorization::Authorized,
resolved_auth.as_ref(),
&runtime_request_context,
Instant::now()
+ std::time::Duration::from_millis(
operation.execution_config.timeout_ms.max(1),
),
) {
Ok(request) => state.runtime.execute_outcome(request).await,
Err(_) => Err(crank_core::ExecutionFailure::new(
crank_core::ExecutionErrorCode::RuntimeInternal,
correlation.clone(),
)),
}
}
Err(error) => Err(crank_runtime::normalize_runtime_error(&error, &correlation)),
};
let (
status,
response_payload,
invocation_status,
invocation_level,
message,
error_kind,
execution_stage,
execution_error_code,
retryability,
outcome_certainty,
upstream_status,
request_preview,
) = match result {
Ok(success) => {
let request_preview = success.request_preview;
(
ApprovalRequestStatus::Completed,
success.output,
InvocationStatus::Ok,
InvocationLevel::Info,
"approved tool call completed",
None,
Some(crank_core::ExecutionStage::Runtime),
None,
Some(crank_core::Retryability::Never),
Some(crank_core::OutcomeCertainty::Certain),
None,
request_preview,
)
}
Err(failure) => (
ApprovalRequestStatus::Failed,
json!({
"error": {
"code": failure.error_code().as_str(),
"message": failure.error_code().message(crank_core::ExecutionLocale::Ru),
"stage": failure.stage().as_str(),
"retryability": failure.retryability().as_str(),
"outcome_certainty": failure.outcome_certainty().as_str(),
"request_id": request_id,
"trace_id": correlation.trace_id().as_str(),
}
}),
InvocationStatus::Error,
InvocationLevel::Error,
"approved tool call failed",
Some(failure.error_code().as_str()),
Some(failure.stage()),
Some(failure.error_code()),
Some(failure.retryability()),
Some(failure.outcome_certainty()),
failure.upstream_status(),
Value::Null,
),
};
persist_invocation(
state,
&tool,
InvocationRecord {
request_id: Some(request_id),
trace_id: Some(correlation.trace_id().as_str()),
tool_name: &tool.tool_name,
status: invocation_status,
level: invocation_level,
message,
status_code: upstream_status,
error_kind,
execution_stage,
execution_error_code,
retryability,
outcome_certainty,
duration: started_at.elapsed(),
request_preview,
response_preview: response_payload.clone(),
},
)
.await;
observe_db_query(
DbOperation::ApprovalWrite,
state
.registry
.finish_approval_request(FinishApprovalRequest {
workspace_id: &approval.approval.workspace_id,
agent_id: &approval.approval.agent_id,
approval_id: &approval.approval.id,
status,
response_payload: Some(response_payload),
decision_note: None,
}),
)
.await
.map_err(|_| approved_completion_persistence_error(&correlation))?
.ok_or_else(|| approved_completion_conflict_error(&correlation))
}
fn approved_completion_conflict_error(correlation: &CorrelationContext) -> Response {
(
StatusCode::CONFLICT,
axum::Json(json!({
"error": {
"code": "approval_state_conflict",
"stage": "mandatory_persistence",
"retryability": "manual_reconcile",
"outcome_certainty": "outcome_unknown",
"request_id": correlation.request_id().as_str(),
"trace_id": correlation.trace_id().as_str(),
}
})),
)
.into_response()
}
fn approved_completion_persistence_error(correlation: &CorrelationContext) -> Response {
let failure = crank_core::ExecutionFailure::new(
crank_core::ExecutionErrorCode::PersistenceUnavailable,
correlation.clone(),
)
.with_dispatch_uncertainty();
(
StatusCode::SERVICE_UNAVAILABLE,
axum::Json(json!({
"error": {
"code": failure.error_code().as_str(),
"stage": failure.stage().as_str(),
"retryability": failure.retryability().as_str(),
"outcome_certainty": failure.outcome_certainty().as_str(),
"request_id": failure.correlation().request_id().as_str(),
"trace_id": failure.correlation().trace_id().as_str(),
}
})),
)
.into_response()
}
async fn finish_unavailable_approval(
state: &Arc<AppState>,
approval: &ApprovalRequestRecord,
) -> Result<ApprovalRequestRecord, Response> {
observe_db_query(
DbOperation::ApprovalWrite,
state
.registry
.finish_approval_request(FinishApprovalRequest {
workspace_id: &approval.approval.workspace_id,
agent_id: &approval.approval.agent_id,
approval_id: &approval.approval.id,
status: ApprovalRequestStatus::Failed,
response_payload: Some(json!({
"error": {
"code": "approved_operation_unavailable",
"message": "the approved operation version is no longer published"
}
})),
decision_note: None,
}),
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
.ok_or_else(|| StatusCode::CONFLICT.into_response())
}