Files
crank/crates/crank-core/src/ext/protocol.rs
T
github-ops 41e2a0d57b
Deploy / deploy (push) Successful in 2m36s
CI / Rust Checks (push) Successful in 5m34s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Successful in 4m25s
chore: publish clean community baseline
2026-06-19 15:55:36 +00:00

323 lines
9.4 KiB
Rust

use std::{collections::BTreeMap, sync::Arc};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
AgentId, ExecutionMode, InvocationSource, Protocol, StreamSession, Target, WorkspaceId,
};
#[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>,
pub metering_context: Option<MeteringContext>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MeteringContext {
pub workspace_id: WorkspaceId,
pub agent_id: Option<AgentId>,
pub source: InvocationSource,
}
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,
metering_context: 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()
}
pub fn with_metering_context(
mut self,
workspace_id: WorkspaceId,
agent_id: Option<AgentId>,
source: InvocationSource,
) -> Self {
self.metering_context = Some(MeteringContext {
workspace_id,
agent_id,
source,
});
self
}
pub fn metering_context(&self) -> Option<&MeteringContext> {
self.metering_context.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>,
#[serde(default)]
pub timeout_ms: u64,
}
#[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,
target: &Target,
prepared: &PreparedRequest,
context: &RuntimeRequestContext,
) -> Result<AdapterResponse, ProtocolAdapterError>;
async fn invoke_window(
&self,
_target: &Target,
_prepared: &PreparedRequest,
_window_duration_ms: u64,
_max_items: Option<u32>,
_context: &RuntimeRequestContext,
) -> Result<WindowExecutionResult, ProtocolAdapterError> {
Err(ProtocolAdapterError::UnsupportedMode {
protocol: self.protocol(),
mode: ExecutionMode::Window,
})
}
async fn open_session(
&self,
_target: &Target,
_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, HttpMethod, InvocationSource, Protocol, RestTarget, Target, WorkspaceId,
};
#[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,
_target: &Target,
_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");
}
#[test]
fn request_context_attaches_metering_context() {
let context = RuntimeRequestContext::from_request_id("req_123").with_metering_context(
WorkspaceId::new("ws_01"),
None,
InvocationSource::AgentToolCall,
);
let metering = context.metering_context().unwrap();
assert_eq!(metering.workspace_id, WorkspaceId::new("ws_01"));
assert_eq!(metering.agent_id, None);
assert_eq!(metering.source, InvocationSource::AgentToolCall);
}
#[tokio::test]
async fn registry_returns_registered_adapter() {
let registry = AdapterRegistry::empty().register(Arc::new(StubAdapter(Protocol::Rest)));
let adapter = registry.get(Protocol::Rest).unwrap();
let response = adapter
.invoke_unary(
&Target::Rest(RestTarget {
base_url: "https://example.test".to_owned(),
method: HttpMethod::Get,
path_template: "/health".to_owned(),
static_headers: BTreeMap::new(),
}),
&PreparedRequest::default(),
&RuntimeRequestContext::from_request_id("req_1"),
)
.await
.unwrap();
assert_eq!(response.status_code, 200);
}
}