From c8c7ee5472c696c616fd74d0ca29e817942d0b50 Mon Sep 17 00:00:00 2001 From: github-ops Date: Thu, 14 May 2026 17:53:35 +0000 Subject: [PATCH] core: add protocol adapter registry seam --- TASKS.md | 1 + crates/crank-core/src/ext/protocol.rs | 246 ++++++++++++++++++++++++++ crates/crank-core/src/lib.rs | 4 + 3 files changed, 251 insertions(+) diff --git a/TASKS.md b/TASKS.md index f0e0cde..7d31b0c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -149,5 +149,6 @@ Progress: - Phase 0 / task `0.5`: введены `PolicyEngine`, `SessionActor`, `PolicyAction`, `PolicyScope`, `PolicyDecision` и default `OwnerOnlyPolicyEngine` в public seam `crank_core::ext::access` - Phase 0 / task `0.6`: введены `AuditSink`, `NoopAuditSink`, `AuditEventId` и базовые audit-типы в public seam `crank_core::ext::audit` - Phase 0 / task `0.7`: введены `CapabilityProfile` и `CommunityCapabilityProfile`, а `admin-api` capability payload теперь собирается через public seam вместо локального literal + - Phase 0 / task `0.8`: введены `ProtocolAdapter`, `ProtocolAdapterError`, `AdapterRegistry` и базовые transport-типы (`PreparedRequest`, `AdapterResponse`, `WindowExecutionResult`, `RuntimeRequestContext`) в `crank_core::ext::protocol` - Phase 0 / task `0.13`: введены `RegistryExtension`, `ExtensionMigration` и `apply_extension_migrations(...)` в `crank-registry` как отдельный public seam для additive private migrations - backward-compatible alias `CommunityMachineCredentialVerifier` сохранен, поведение Community не изменено diff --git a/crates/crank-core/src/ext/protocol.rs b/crates/crank-core/src/ext/protocol.rs index 8b13789..a0ea280 100644 --- a/crates/crank-core/src/ext/protocol.rs +++ b/crates/crank-core/src/ext/protocol.rs @@ -1 +1,247 @@ +use std::{collections::BTreeMap, sync::Arc}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{ExecutionMode, Protocol, StreamSession}; + +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct ResponseCacheScope { + pub workspace_key: String, + pub agent_key: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeRequestContext { + pub request_id: String, + pub correlation_id: String, + pub response_cache_scope: Option, +} + +impl RuntimeRequestContext { + pub fn new(request_id: impl Into, correlation_id: impl Into) -> Self { + Self { + request_id: request_id.into(), + correlation_id: correlation_id.into(), + response_cache_scope: None, + } + } + + pub fn from_request_id(request_id: impl Into) -> Self { + let request_id = request_id.into(); + Self::new(request_id.clone(), request_id) + } + + pub fn outbound_headers(&self) -> BTreeMap { + BTreeMap::from([ + ("x-request-id".to_owned(), self.request_id.clone()), + ("x-correlation-id".to_owned(), self.correlation_id.clone()), + ]) + } + + pub fn with_response_cache_scope( + mut self, + workspace_key: impl Into, + agent_key: impl Into, + ) -> Self { + self.response_cache_scope = Some(ResponseCacheScope { + workspace_key: workspace_key.into(), + agent_key: agent_key.into(), + }); + self + } + + pub fn response_cache_scope(&self) -> Option<&ResponseCacheScope> { + self.response_cache_scope.as_ref() + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +pub struct PreparedRequest { + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub path_params: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub query_params: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub grpc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub variables: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AdapterResponse { + pub status_code: u16, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + pub body: Value, + pub data: Value, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct WindowExecutionResult { + pub summary: Value, + pub items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, + pub window_complete: bool, + pub truncated: bool, + pub has_more: bool, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ProtocolAdapterError { + #[error("protocol {protocol:?} does not support execution mode {mode:?}")] + UnsupportedMode { + protocol: Protocol, + mode: ExecutionMode, + }, + #[error("{0}")] + Message(String), +} + +#[async_trait] +pub trait ProtocolAdapter: Send + Sync { + fn protocol(&self) -> Protocol; + + fn supports_mode(&self, mode: ExecutionMode) -> bool; + + async fn invoke_unary( + &self, + prepared: &PreparedRequest, + context: &RuntimeRequestContext, + ) -> Result; + + async fn invoke_window( + &self, + _prepared: &PreparedRequest, + _context: &RuntimeRequestContext, + ) -> Result { + Err(ProtocolAdapterError::UnsupportedMode { + protocol: self.protocol(), + mode: ExecutionMode::Window, + }) + } + + async fn open_session( + &self, + _prepared: &PreparedRequest, + _context: &RuntimeRequestContext, + ) -> Result { + Err(ProtocolAdapterError::UnsupportedMode { + protocol: self.protocol(), + mode: ExecutionMode::Session, + }) + } +} + +pub type SharedProtocolAdapter = Arc; + +#[derive(Clone, Default)] +pub struct AdapterRegistry { + adapters: Vec, +} + +impl AdapterRegistry { + pub fn empty() -> Self { + Self::default() + } + + pub fn register(mut self, adapter: SharedProtocolAdapter) -> Self { + self.adapters + .retain(|existing| existing.protocol() != adapter.protocol()); + self.adapters.push(adapter); + self + } + + pub fn get(&self, protocol: Protocol) -> Option { + self.adapters + .iter() + .find(|adapter| adapter.protocol() == protocol) + .cloned() + } + + pub fn protocols(&self) -> Vec { + self.adapters + .iter() + .map(|adapter| adapter.protocol()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use std::{collections::BTreeMap, sync::Arc}; + + use async_trait::async_trait; + use serde_json::json; + + use super::{ + AdapterRegistry, AdapterResponse, PreparedRequest, ProtocolAdapter, ProtocolAdapterError, + RuntimeRequestContext, + }; + use crate::{ExecutionMode, Protocol}; + + #[derive(Clone)] + struct StubAdapter(Protocol); + + #[async_trait] + impl ProtocolAdapter for StubAdapter { + fn protocol(&self) -> Protocol { + self.0 + } + + fn supports_mode(&self, mode: ExecutionMode) -> bool { + matches!(mode, ExecutionMode::Unary) + } + + async fn invoke_unary( + &self, + _prepared: &PreparedRequest, + _context: &RuntimeRequestContext, + ) -> Result { + Ok(AdapterResponse { + status_code: 200, + headers: BTreeMap::new(), + body: json!({"ok": true}), + data: json!({"ok": true}), + }) + } + } + + #[test] + fn registry_returns_registered_protocols() { + let registry = AdapterRegistry::empty() + .register(Arc::new(StubAdapter(Protocol::Rest))) + .register(Arc::new(StubAdapter(Protocol::Graphql))); + + assert_eq!( + registry.protocols(), + vec![Protocol::Rest, Protocol::Graphql] + ); + } + + #[test] + fn registry_replaces_adapter_for_same_protocol() { + let registry = AdapterRegistry::empty() + .register(Arc::new(StubAdapter(Protocol::Rest))) + .register(Arc::new(StubAdapter(Protocol::Rest))); + + assert_eq!(registry.protocols(), vec![Protocol::Rest]); + assert!(registry.get(Protocol::Rest).is_some()); + } + + #[test] + fn request_context_attaches_response_cache_scope() { + let context = RuntimeRequestContext::from_request_id("req_123") + .with_response_cache_scope("ws_01", "agent_01"); + + let scope = context.response_cache_scope().unwrap(); + assert_eq!(scope.workspace_key, "ws_01"); + assert_eq!(scope.agent_key, "agent_01"); + } +} diff --git a/crates/crank-core/src/lib.rs b/crates/crank-core/src/lib.rs index 5c57c25..cd11542 100644 --- a/crates/crank-core/src/lib.rs +++ b/crates/crank-core/src/lib.rs @@ -46,6 +46,10 @@ pub use ext::auth::{ SharedMachineTokenIssuer, TokenIssuerActor, TokenIssuerError, VerifiedMachineCredential, }; pub use ext::capability::{CapabilityProfile, CommunityCapabilityProfile}; +pub use ext::protocol::{ + AdapterRegistry, AdapterResponse, PreparedRequest, ProtocolAdapter, ProtocolAdapterError, + ResponseCacheScope, RuntimeRequestContext, SharedProtocolAdapter, WindowExecutionResult, +}; pub use ids::{ AgentId, AsyncJobId, AuditEventId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId, PlatformApiKeyId, SampleId, SecretId, StreamSessionId, ToolId, UserId,