core: add protocol adapter registry seam

This commit is contained in:
github-ops
2026-05-14 17:53:35 +00:00
parent f8930fc9b2
commit c8c7ee5472
3 changed files with 251 additions and 0 deletions
+1
View File
@@ -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 не изменено
+246
View File
@@ -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<ResponseCacheScope>,
}
impl RuntimeRequestContext {
pub fn new(request_id: impl Into<String>, correlation_id: impl Into<String>) -> 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<String>) -> Self {
let request_id = request_id.into();
Self::new(request_id.clone(), request_id)
}
pub fn outbound_headers(&self) -> BTreeMap<String, String> {
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<String>,
agent_key: impl Into<String>,
) -> 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<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub query_params: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub grpc: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub variables: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<Value>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AdapterResponse {
pub status_code: u16,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
pub body: Value,
pub data: Value,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct WindowExecutionResult {
pub summary: Value,
pub items: Vec<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<Value>,
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<AdapterResponse, ProtocolAdapterError>;
async fn invoke_window(
&self,
_prepared: &PreparedRequest,
_context: &RuntimeRequestContext,
) -> Result<WindowExecutionResult, ProtocolAdapterError> {
Err(ProtocolAdapterError::UnsupportedMode {
protocol: self.protocol(),
mode: ExecutionMode::Window,
})
}
async fn open_session(
&self,
_prepared: &PreparedRequest,
_context: &RuntimeRequestContext,
) -> Result<StreamSession, ProtocolAdapterError> {
Err(ProtocolAdapterError::UnsupportedMode {
protocol: self.protocol(),
mode: ExecutionMode::Session,
})
}
}
pub type SharedProtocolAdapter = Arc<dyn ProtocolAdapter>;
#[derive(Clone, Default)]
pub struct AdapterRegistry {
adapters: Vec<SharedProtocolAdapter>,
}
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<SharedProtocolAdapter> {
self.adapters
.iter()
.find(|adapter| adapter.protocol() == protocol)
.cloned()
}
pub fn protocols(&self) -> Vec<Protocol> {
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<AdapterResponse, ProtocolAdapterError> {
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");
}
}
+4
View File
@@ -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,