chore: publish clean community baseline
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::ids::{AgentId, InvitationId, PlatformApiKeyId, UserId, WorkspaceId};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UserStatus {
|
||||
Active,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MembershipRole {
|
||||
Owner,
|
||||
Admin,
|
||||
Operator,
|
||||
Viewer,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InvitationStatus {
|
||||
Pending,
|
||||
Accepted,
|
||||
Revoked,
|
||||
Expired,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PlatformApiKeyStatus {
|
||||
Active,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PlatformApiKeyScope {
|
||||
Read,
|
||||
Write,
|
||||
Deploy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub id: UserId,
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
pub status: UserStatus,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Membership {
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub user_id: UserId,
|
||||
pub role: MembershipRole,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InvitationToken {
|
||||
pub id: InvitationId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub email: String,
|
||||
pub role: MembershipRole,
|
||||
pub status: InvitationStatus,
|
||||
pub token_hash: String,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub expires_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PlatformApiKey {
|
||||
pub id: PlatformApiKeyId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_id: Option<AgentId>,
|
||||
pub name: String,
|
||||
pub prefix: String,
|
||||
pub scopes: Vec<PlatformApiKeyScope>,
|
||||
pub status: PlatformApiKeyStatus,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub last_used_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use super::{PlatformApiKey, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus};
|
||||
use crate::ids::{AgentId, PlatformApiKeyId, UserId, WorkspaceId};
|
||||
|
||||
#[test]
|
||||
fn user_serializes_created_at_as_rfc3339() {
|
||||
let user = User {
|
||||
id: UserId::new("user_01"),
|
||||
email: "owner@example.com".to_owned(),
|
||||
display_name: "Owner".to_owned(),
|
||||
status: UserStatus::Active,
|
||||
created_at: OffsetDateTime::parse("2026-03-25T12:00:00Z", &Rfc3339).unwrap(),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&user).unwrap();
|
||||
|
||||
assert_eq!(value["created_at"], json!("2026-03-25T12:00:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_deserializes_created_at_from_rfc3339() {
|
||||
let user: User = serde_json::from_value(json!({
|
||||
"id": "user_01",
|
||||
"email": "owner@example.com",
|
||||
"display_name": "Owner",
|
||||
"status": "active",
|
||||
"created_at": "2026-03-25T12:00:00Z"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
user.created_at,
|
||||
OffsetDateTime::parse("2026-03-25T12:00:00Z", &Rfc3339).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_api_key_serializes_timestamps_as_rfc3339() {
|
||||
let api_key = PlatformApiKey {
|
||||
id: PlatformApiKeyId::new("pk_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
agent_id: Some(AgentId::new("agent_01")),
|
||||
name: "Primary".to_owned(),
|
||||
prefix: "crk_live".to_owned(),
|
||||
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
status: PlatformApiKeyStatus::Active,
|
||||
created_at: OffsetDateTime::parse("2026-03-25T12:01:00Z", &Rfc3339).unwrap(),
|
||||
last_used_at: Some(OffsetDateTime::parse("2026-03-25T12:05:00Z", &Rfc3339).unwrap()),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&api_key).unwrap();
|
||||
|
||||
assert_eq!(value["created_at"], json!("2026-03-25T12:01:00Z"));
|
||||
assert_eq!(value["last_used_at"], json!("2026-03-25T12:05:00Z"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::ids::{AgentId, OperationId, WorkspaceId};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentStatus {
|
||||
Draft,
|
||||
Published,
|
||||
Archived,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Agent {
|
||||
pub id: AgentId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub slug: String,
|
||||
pub display_name: String,
|
||||
pub description: String,
|
||||
pub status: AgentStatus,
|
||||
pub current_draft_version: u32,
|
||||
pub latest_published_version: Option<u32>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub updated_at: OffsetDateTime,
|
||||
#[serde(default, with = "time::serde::rfc3339::option")]
|
||||
pub published_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AgentVersion {
|
||||
pub agent_id: AgentId,
|
||||
pub version: u32,
|
||||
pub status: AgentStatus,
|
||||
pub instructions: Value,
|
||||
pub tool_selection_policy: Value,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AgentOperationBinding {
|
||||
pub operation_id: OperationId,
|
||||
pub operation_version: u32,
|
||||
pub tool_name: String,
|
||||
pub tool_title: String,
|
||||
pub tool_description_override: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use super::{Agent, AgentStatus, AgentVersion};
|
||||
use crate::ids::{AgentId, WorkspaceId};
|
||||
|
||||
#[test]
|
||||
fn agent_serializes_timestamps_as_rfc3339() {
|
||||
let agent = Agent {
|
||||
id: AgentId::new("agent_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
slug: "triage".to_owned(),
|
||||
display_name: "Triage".to_owned(),
|
||||
description: "Triage agent".to_owned(),
|
||||
status: AgentStatus::Published,
|
||||
current_draft_version: 2,
|
||||
latest_published_version: Some(2),
|
||||
created_at: OffsetDateTime::parse("2026-03-25T12:00:00Z", &Rfc3339).unwrap(),
|
||||
updated_at: OffsetDateTime::parse("2026-03-25T12:05:00Z", &Rfc3339).unwrap(),
|
||||
published_at: Some(OffsetDateTime::parse("2026-03-25T12:10:00Z", &Rfc3339).unwrap()),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&agent).unwrap();
|
||||
|
||||
assert_eq!(value["created_at"], json!("2026-03-25T12:00:00Z"));
|
||||
assert_eq!(value["updated_at"], json!("2026-03-25T12:05:00Z"));
|
||||
assert_eq!(value["published_at"], json!("2026-03-25T12:10:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_version_deserializes_created_at_from_rfc3339() {
|
||||
let version: AgentVersion = serde_json::from_value(json!({
|
||||
"agent_id": "agent_01",
|
||||
"version": 1,
|
||||
"status": "draft",
|
||||
"instructions": {"system": "triage"},
|
||||
"tool_selection_policy": {"mode": "allow_list"},
|
||||
"created_at": "2026-03-25T12:00:00Z"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
version.created_at,
|
||||
OffsetDateTime::parse("2026-03-25T12:00:00Z", &Rfc3339).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
edition::{MachineAccessMode, OperationSecurityLevel},
|
||||
ids::{AgentId, AuthProfileId, OperationId, SecretId, WorkspaceId},
|
||||
protocol::AuthKind,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BearerAuthConfig {
|
||||
pub header_name: String,
|
||||
pub secret_id: SecretId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BasicAuthConfig {
|
||||
pub username_secret_id: SecretId,
|
||||
pub password_secret_id: SecretId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ApiKeyHeaderAuthConfig {
|
||||
pub header_name: String,
|
||||
pub secret_id: SecretId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ApiKeyQueryAuthConfig {
|
||||
pub param_name: String,
|
||||
pub secret_id: SecretId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuthConfig {
|
||||
Bearer(BearerAuthConfig),
|
||||
Basic(BasicAuthConfig),
|
||||
ApiKeyHeader(ApiKeyHeaderAuthConfig),
|
||||
ApiKeyQuery(ApiKeyQueryAuthConfig),
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
pub fn secret_ids(&self) -> Vec<&SecretId> {
|
||||
match self {
|
||||
Self::Bearer(config) => vec![&config.secret_id],
|
||||
Self::Basic(config) => vec![&config.username_secret_id, &config.password_secret_id],
|
||||
Self::ApiKeyHeader(config) => vec![&config.secret_id],
|
||||
Self::ApiKeyQuery(config) => vec![&config.secret_id],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuthProfile {
|
||||
pub id: AuthProfileId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub name: String,
|
||||
pub kind: AuthKind,
|
||||
pub config: AuthConfig,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentTokenGrantType {
|
||||
AgentKey,
|
||||
RefreshToken,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct IssueAgentTokenRequest {
|
||||
pub grant_type: AgentTokenGrantType,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub refresh_token: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub scope: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct IssueOneTimeAgentTokenRequest {
|
||||
pub agent_key: String,
|
||||
pub operation_id: OperationId,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub scope: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct IssuedAgentTokenResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub refresh_token: Option<String>,
|
||||
pub machine_access_mode: MachineAccessMode,
|
||||
pub security_level: OperationSecurityLevel,
|
||||
pub agent_id: AgentId,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use super::{
|
||||
AgentTokenGrantType, ApiKeyHeaderAuthConfig, AuthConfig, AuthProfile,
|
||||
IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, IssuedAgentTokenResponse,
|
||||
};
|
||||
use crate::{
|
||||
edition::{MachineAccessMode, OperationSecurityLevel},
|
||||
ids::{AuthProfileId, SecretId, WorkspaceId},
|
||||
protocol::AuthKind,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn auth_profile_serializes_timestamps_as_rfc3339() {
|
||||
let profile = AuthProfile {
|
||||
id: AuthProfileId::new("auth_01"),
|
||||
workspace_id: WorkspaceId::new("ws_default"),
|
||||
name: "header".to_owned(),
|
||||
kind: AuthKind::ApiKeyHeader,
|
||||
config: AuthConfig::ApiKeyHeader(ApiKeyHeaderAuthConfig {
|
||||
header_name: "X-Api-Key".to_owned(),
|
||||
secret_id: SecretId::new("secret_01"),
|
||||
}),
|
||||
created_at: OffsetDateTime::parse("2026-03-25T12:00:00Z", &Rfc3339).unwrap(),
|
||||
updated_at: OffsetDateTime::parse("2026-03-25T12:05:00Z", &Rfc3339).unwrap(),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&profile).unwrap();
|
||||
|
||||
assert_eq!(value["created_at"], json!("2026-03-25T12:00:00Z"));
|
||||
assert_eq!(value["updated_at"], json!("2026-03-25T12:05:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_agent_token_request_serializes_stable_contract() {
|
||||
let request = IssueAgentTokenRequest {
|
||||
grant_type: AgentTokenGrantType::AgentKey,
|
||||
agent_key: Some("crk_agent_secret".to_owned()),
|
||||
refresh_token: None,
|
||||
scope: vec!["tools:call".to_owned()],
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&request).unwrap();
|
||||
|
||||
assert_eq!(value["grant_type"], json!("agent_key"));
|
||||
assert_eq!(value["agent_key"], json!("crk_agent_secret"));
|
||||
assert_eq!(value["scope"], json!(["tools:call"]));
|
||||
assert_eq!(value.get("refresh_token"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_one_time_agent_token_request_serializes_stable_contract() {
|
||||
let request = IssueOneTimeAgentTokenRequest {
|
||||
agent_key: "crk_agent_secret".to_owned(),
|
||||
operation_id: "op_01".into(),
|
||||
scope: vec!["tools:call".to_owned()],
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&request).unwrap();
|
||||
|
||||
assert_eq!(value["agent_key"], json!("crk_agent_secret"));
|
||||
assert_eq!(value["operation_id"], json!("op_01"));
|
||||
assert_eq!(value["scope"], json!(["tools:call"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issued_agent_token_response_serializes_machine_access_metadata() {
|
||||
let response = IssuedAgentTokenResponse {
|
||||
access_token: "tok_01".to_owned(),
|
||||
token_type: "Bearer".to_owned(),
|
||||
expires_in: 300,
|
||||
refresh_token: Some("rfr_01".to_owned()),
|
||||
machine_access_mode: MachineAccessMode::ShortLivedToken,
|
||||
security_level: OperationSecurityLevel::Elevated,
|
||||
agent_id: "agt_01".into(),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&response).unwrap();
|
||||
|
||||
assert_eq!(value["access_token"], json!("tok_01"));
|
||||
assert_eq!(value["token_type"], json!("Bearer"));
|
||||
assert_eq!(value["expires_in"], json!(300));
|
||||
assert_eq!(value["refresh_token"], json!("rfr_01"));
|
||||
assert_eq!(value["machine_access_mode"], json!("short_lived_token"));
|
||||
assert_eq!(value["security_level"], json!("elevated"));
|
||||
assert_eq!(value["agent_id"], json!("agt_01"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use std::{fmt, str::FromStr, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CacheBackend {
|
||||
Memory,
|
||||
Valkey,
|
||||
Redis,
|
||||
}
|
||||
|
||||
impl CacheBackend {
|
||||
pub fn is_external(self) -> bool {
|
||||
matches!(self, Self::Valkey | Self::Redis)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CacheBackend {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let value = match self {
|
||||
Self::Memory => "memory",
|
||||
Self::Valkey => "valkey",
|
||||
Self::Redis => "redis",
|
||||
};
|
||||
f.write_str(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for CacheBackend {
|
||||
type Err = ParseCacheBackendError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"memory" => Ok(Self::Memory),
|
||||
"valkey" => Ok(Self::Valkey),
|
||||
"redis" => Ok(Self::Redis),
|
||||
_ => Err(ParseCacheBackendError {
|
||||
value: value.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CacheScope {
|
||||
Response,
|
||||
RateLimit,
|
||||
ReplayGuard,
|
||||
Coordination,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CachedHeader {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CachedResponse {
|
||||
pub status: u16,
|
||||
pub headers: Vec<CachedHeader>,
|
||||
pub body: Vec<u8>,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RateLimitBucketState {
|
||||
pub tokens_micros: u64,
|
||||
pub last_refill_unix_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReplayGuardStatus {
|
||||
Fresh,
|
||||
AlreadySeen,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CoordinationStateValue {
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ResponseCacheStore: Send + Sync {
|
||||
async fn get(&self, key: &str) -> Result<Option<CachedResponse>, CacheStoreError>;
|
||||
async fn put(
|
||||
&self,
|
||||
key: &str,
|
||||
value: CachedResponse,
|
||||
ttl: Duration,
|
||||
) -> Result<(), CacheStoreError>;
|
||||
async fn delete(&self, key: &str) -> Result<(), CacheStoreError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait RateLimitStateStore: Send + Sync {
|
||||
async fn get_bucket(&self, key: &str) -> Result<Option<RateLimitBucketState>, CacheStoreError>;
|
||||
async fn put_bucket(
|
||||
&self,
|
||||
key: &str,
|
||||
value: RateLimitBucketState,
|
||||
ttl: Duration,
|
||||
) -> Result<(), CacheStoreError>;
|
||||
async fn delete_bucket(&self, key: &str) -> Result<(), CacheStoreError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ReplayGuardStore: Send + Sync {
|
||||
async fn mark_seen(
|
||||
&self,
|
||||
key: &str,
|
||||
ttl: Duration,
|
||||
) -> Result<ReplayGuardStatus, CacheStoreError>;
|
||||
async fn clear(&self, key: &str) -> Result<(), CacheStoreError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait CoordinationStateStore: Send + Sync {
|
||||
async fn get_value(
|
||||
&self,
|
||||
scope: CacheScope,
|
||||
key: &str,
|
||||
) -> Result<Option<CoordinationStateValue>, CacheStoreError>;
|
||||
async fn put_value(
|
||||
&self,
|
||||
scope: CacheScope,
|
||||
key: &str,
|
||||
value: CoordinationStateValue,
|
||||
ttl: Duration,
|
||||
) -> Result<(), CacheStoreError>;
|
||||
async fn delete_value(&self, scope: CacheScope, key: &str) -> Result<(), CacheStoreError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum CacheStoreError {
|
||||
#[error("cache backend is unavailable: {message}")]
|
||||
Unavailable { message: String },
|
||||
#[error("cache key is invalid: {message}")]
|
||||
InvalidKey { message: String },
|
||||
#[error("cache value serialization failed: {message}")]
|
||||
Serialization { message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
#[error("unsupported cache backend {value}")]
|
||||
pub struct ParseCacheBackendError {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CacheBackend, CacheScope, ParseCacheBackendError, ReplayGuardStatus};
|
||||
|
||||
#[test]
|
||||
fn cache_backend_roundtrip_is_stable() {
|
||||
for backend in [
|
||||
CacheBackend::Memory,
|
||||
CacheBackend::Valkey,
|
||||
CacheBackend::Redis,
|
||||
] {
|
||||
let encoded = backend.to_string();
|
||||
let decoded = encoded.parse::<CacheBackend>().unwrap();
|
||||
assert_eq!(decoded, backend);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_cache_backend() {
|
||||
assert_eq!(
|
||||
"memcached".parse::<CacheBackend>(),
|
||||
Err(ParseCacheBackendError {
|
||||
value: "memcached".to_owned()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinguishes_external_cache_backends() {
|
||||
assert!(!CacheBackend::Memory.is_external());
|
||||
assert!(CacheBackend::Valkey.is_external());
|
||||
assert!(CacheBackend::Redis.is_external());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_contracts_use_snake_case_values() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&CacheScope::ReplayGuard).unwrap(),
|
||||
"\"replay_guard\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ReplayGuardStatus::AlreadySeen).unwrap(),
|
||||
"\"already_seen\""
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Protocol;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProductEdition {
|
||||
Community,
|
||||
Enterprise,
|
||||
Cloud,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MachineAccessMode {
|
||||
StaticAgentKey,
|
||||
ShortLivedToken,
|
||||
OneTimeToken,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OperationSecurityLevel {
|
||||
Standard,
|
||||
Elevated,
|
||||
Strict,
|
||||
}
|
||||
|
||||
impl Default for OperationSecurityLevel {
|
||||
fn default() -> Self {
|
||||
Self::Standard
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct EditionLimits {
|
||||
pub max_workspaces: Option<u32>,
|
||||
pub max_users_per_workspace: Option<u32>,
|
||||
pub max_agents_per_workspace: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct EditionCapabilities {
|
||||
pub edition: ProductEdition,
|
||||
pub supported_protocols: Vec<Protocol>,
|
||||
pub supported_security_levels: Vec<OperationSecurityLevel>,
|
||||
pub machine_access_modes: Vec<MachineAccessMode>,
|
||||
pub limits: EditionLimits,
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use crate::{MembershipRole, UserId, WorkspaceId};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SessionActor {
|
||||
pub user_id: UserId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub role: MembershipRole,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
pub enum PolicyAction {
|
||||
ReadWorkspace,
|
||||
WriteWorkspace,
|
||||
ReadWorkspaceAccess,
|
||||
WriteWorkspaceAccess,
|
||||
ReadOperation,
|
||||
WriteOperation,
|
||||
ReadAgent,
|
||||
WriteAgent,
|
||||
ReadPlatformApiKey,
|
||||
WritePlatformApiKey,
|
||||
ReadSecret,
|
||||
WriteSecret,
|
||||
ReadAuthProfile,
|
||||
WriteAuthProfile,
|
||||
ReadObservability,
|
||||
ReadCapability,
|
||||
}
|
||||
|
||||
impl PolicyAction {
|
||||
pub fn is_read(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::ReadWorkspace
|
||||
| Self::ReadWorkspaceAccess
|
||||
| Self::ReadOperation
|
||||
| Self::ReadAgent
|
||||
| Self::ReadPlatformApiKey
|
||||
| Self::ReadSecret
|
||||
| Self::ReadAuthProfile
|
||||
| Self::ReadObservability
|
||||
| Self::ReadCapability
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PolicyScope {
|
||||
Workspace(WorkspaceId),
|
||||
Global,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PolicyDecision {
|
||||
Allow,
|
||||
Deny { reason: &'static str },
|
||||
}
|
||||
|
||||
pub trait PolicyEngine: Send + Sync {
|
||||
fn check(
|
||||
&self,
|
||||
actor: &SessionActor,
|
||||
action: PolicyAction,
|
||||
scope: PolicyScope,
|
||||
) -> PolicyDecision;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct OwnerOnlyPolicyEngine;
|
||||
|
||||
impl PolicyEngine for OwnerOnlyPolicyEngine {
|
||||
fn check(
|
||||
&self,
|
||||
actor: &SessionActor,
|
||||
action: PolicyAction,
|
||||
_scope: PolicyScope,
|
||||
) -> PolicyDecision {
|
||||
if matches!(actor.role, MembershipRole::Owner) || action.is_read() {
|
||||
PolicyDecision::Allow
|
||||
} else {
|
||||
PolicyDecision::Deny {
|
||||
reason: "write actions require owner role in community",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
OwnerOnlyPolicyEngine, PolicyAction, PolicyDecision, PolicyEngine, PolicyScope,
|
||||
SessionActor,
|
||||
};
|
||||
use crate::{MembershipRole, UserId, WorkspaceId};
|
||||
|
||||
fn actor(role: MembershipRole) -> SessionActor {
|
||||
SessionActor {
|
||||
user_id: UserId::new("user_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
role,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_is_allowed_for_write_actions() {
|
||||
let decision = OwnerOnlyPolicyEngine.check(
|
||||
&actor(MembershipRole::Owner),
|
||||
PolicyAction::WriteWorkspace,
|
||||
PolicyScope::Workspace(WorkspaceId::new("ws_01")),
|
||||
);
|
||||
|
||||
assert_eq!(decision, PolicyDecision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_owner_is_allowed_for_read_actions() {
|
||||
let decision = OwnerOnlyPolicyEngine.check(
|
||||
&actor(MembershipRole::Viewer),
|
||||
PolicyAction::ReadWorkspace,
|
||||
PolicyScope::Workspace(WorkspaceId::new("ws_01")),
|
||||
);
|
||||
|
||||
assert_eq!(decision, PolicyDecision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_owner_is_denied_for_write_actions() {
|
||||
let decision = OwnerOnlyPolicyEngine.check(
|
||||
&actor(MembershipRole::Admin),
|
||||
PolicyAction::WriteWorkspace,
|
||||
PolicyScope::Workspace(WorkspaceId::new("ws_01")),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
decision,
|
||||
PolicyDecision::Deny {
|
||||
reason: "write actions require owner role in community",
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{AuditEventId, UserId, UserSessionId, WorkspaceId};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuditActor {
|
||||
pub user_id: UserId,
|
||||
pub email: String,
|
||||
pub session_id: Option<UserSessionId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuditTargetKind {
|
||||
Workspace,
|
||||
Membership,
|
||||
Invitation,
|
||||
Operation,
|
||||
Agent,
|
||||
PlatformApiKey,
|
||||
Secret,
|
||||
AuthProfile,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuditTarget {
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub kind: AuditTargetKind,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AuditEvent {
|
||||
pub id: AuditEventId,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub occurred_at: OffsetDateTime,
|
||||
pub actor: AuditActor,
|
||||
pub action: String,
|
||||
pub target: AuditTarget,
|
||||
pub payload: Value,
|
||||
pub source_ip: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuditQuery {
|
||||
pub workspace_id: Option<WorkspaceId>,
|
||||
pub action: Option<String>,
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AuditEventPage {
|
||||
pub items: Vec<AuditEvent>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuditError {
|
||||
#[error("audit sink failed: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AuditSink: Send + Sync {
|
||||
async fn record(&self, event: AuditEvent) -> Result<(), AuditError>;
|
||||
|
||||
async fn list(&self, _query: AuditQuery) -> Result<AuditEventPage, AuditError> {
|
||||
Ok(AuditEventPage::default())
|
||||
}
|
||||
|
||||
async fn get(&self, _id: &AuditEventId) -> Result<Option<AuditEvent>, AuditError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedAuditSink = Arc<dyn AuditSink>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct NoopAuditSink;
|
||||
|
||||
#[async_trait]
|
||||
impl AuditSink for NoopAuditSink {
|
||||
async fn record(&self, _event: AuditEvent) -> Result<(), AuditError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, IssuedAgentTokenResponse,
|
||||
MachineAccessMode, Membership, MembershipRole, OperationSecurityLevel, PlatformApiKeyScope,
|
||||
User, UserId, WorkspaceId,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct VerifiedMachineCredential {
|
||||
pub machine_access_mode: MachineAccessMode,
|
||||
pub max_security_level: OperationSecurityLevel,
|
||||
pub scopes: Vec<PlatformApiKeyScope>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("machine credential verifier failed")]
|
||||
pub struct MachineCredentialVerifierError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait MachineCredentialVerifier: Send + Sync {
|
||||
async fn verify_bearer_token(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
token: &str,
|
||||
) -> Result<Option<VerifiedMachineCredential>, MachineCredentialVerifierError>;
|
||||
}
|
||||
|
||||
pub type SharedMachineCredentialVerifier = Arc<dyn MachineCredentialVerifier>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TokenIssuerActor {
|
||||
pub user_id: UserId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub role: MembershipRole,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum TokenIssuerError {
|
||||
#[error("machine tokens are not available in this edition")]
|
||||
NotSupportedInEdition,
|
||||
#[error("invalid grant: {0}")]
|
||||
InvalidGrant(String),
|
||||
#[error("agent key is unknown")]
|
||||
AgentKeyUnknown,
|
||||
#[error("operation is not strict")]
|
||||
OperationNotStrict,
|
||||
#[error("operation is not published for agent")]
|
||||
OperationNotPublishedForAgent,
|
||||
#[error("registry failure: {0}")]
|
||||
RegistryFailure(String),
|
||||
#[error("replay guard failure: {0}")]
|
||||
ReplayGuardFailure(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait MachineTokenIssuer: Send + Sync {
|
||||
async fn issue_short_lived(
|
||||
&self,
|
||||
request: IssueAgentTokenRequest,
|
||||
actor: &TokenIssuerActor,
|
||||
) -> Result<IssuedAgentTokenResponse, TokenIssuerError>;
|
||||
|
||||
async fn issue_one_time(
|
||||
&self,
|
||||
request: IssueOneTimeAgentTokenRequest,
|
||||
actor: &TokenIssuerActor,
|
||||
) -> Result<IssuedAgentTokenResponse, TokenIssuerError>;
|
||||
}
|
||||
|
||||
pub type SharedMachineTokenIssuer = Arc<dyn MachineTokenIssuer>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct NoMachineTokenIssuer;
|
||||
|
||||
#[async_trait]
|
||||
impl MachineTokenIssuer for NoMachineTokenIssuer {
|
||||
async fn issue_short_lived(
|
||||
&self,
|
||||
_request: IssueAgentTokenRequest,
|
||||
_actor: &TokenIssuerActor,
|
||||
) -> Result<IssuedAgentTokenResponse, TokenIssuerError> {
|
||||
Err(TokenIssuerError::NotSupportedInEdition)
|
||||
}
|
||||
|
||||
async fn issue_one_time(
|
||||
&self,
|
||||
_request: IssueOneTimeAgentTokenRequest,
|
||||
_actor: &TokenIssuerActor,
|
||||
) -> Result<IssuedAgentTokenResponse, TokenIssuerError> {
|
||||
Err(TokenIssuerError::NotSupportedInEdition)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum IdentityProviderKind {
|
||||
Password,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AuthenticatedIdentity {
|
||||
pub user: User,
|
||||
pub memberships: Vec<Membership>,
|
||||
pub current_workspace_id: Option<WorkspaceId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum IdentityError {
|
||||
#[error("not supported for this provider")]
|
||||
NotSupportedForProvider,
|
||||
#[error("bad credentials")]
|
||||
BadCredentials,
|
||||
#[error("account disabled")]
|
||||
AccountDisabled,
|
||||
#[error("internal: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LoginPayload {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SsoAuthorizeRequest {
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub provider_id: String,
|
||||
pub base_origin: String,
|
||||
pub return_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SsoAuthorizeRedirect {
|
||||
pub authorization_url: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SsoCallbackRequest {
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub provider_id: String,
|
||||
pub code: String,
|
||||
pub base_origin: String,
|
||||
pub return_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TwoFactorPendingIdentity {
|
||||
pub user_id: UserId,
|
||||
pub workspace_id: Option<WorkspaceId>,
|
||||
pub return_to: Option<String>,
|
||||
pub expires_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TwoFactorStatus {
|
||||
pub enabled: bool,
|
||||
pub pending_setup: bool,
|
||||
pub recovery_codes_remaining: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TwoFactorSetup {
|
||||
pub secret: String,
|
||||
pub otpauth_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TwoFactorActivation {
|
||||
pub recovery_codes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub struct TwoFactorChallenge {
|
||||
pub code: Option<String>,
|
||||
pub recovery_code: Option<String>,
|
||||
}
|
||||
|
||||
pub enum LoginOutcome {
|
||||
Authenticated(AuthenticatedIdentity),
|
||||
TwoFactorRequired(TwoFactorPendingIdentity),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait IdentityProvider: Send + Sync {
|
||||
fn id(&self) -> &str;
|
||||
|
||||
fn kind(&self) -> IdentityProviderKind;
|
||||
|
||||
async fn login_password(&self, payload: LoginPayload) -> Result<LoginOutcome, IdentityError>;
|
||||
|
||||
async fn begin_sso(
|
||||
&self,
|
||||
_request: SsoAuthorizeRequest,
|
||||
) -> Result<SsoAuthorizeRedirect, IdentityError> {
|
||||
Err(IdentityError::NotSupportedForProvider)
|
||||
}
|
||||
|
||||
async fn complete_sso(
|
||||
&self,
|
||||
_request: SsoCallbackRequest,
|
||||
) -> Result<LoginOutcome, IdentityError> {
|
||||
Err(IdentityError::NotSupportedForProvider)
|
||||
}
|
||||
|
||||
async fn get_two_factor_status(
|
||||
&self,
|
||||
_user_id: &UserId,
|
||||
) -> Result<TwoFactorStatus, IdentityError> {
|
||||
Err(IdentityError::NotSupportedForProvider)
|
||||
}
|
||||
|
||||
async fn begin_two_factor_setup(
|
||||
&self,
|
||||
_user_id: &UserId,
|
||||
_email: &str,
|
||||
) -> Result<TwoFactorSetup, IdentityError> {
|
||||
Err(IdentityError::NotSupportedForProvider)
|
||||
}
|
||||
|
||||
async fn activate_two_factor(
|
||||
&self,
|
||||
_user_id: &UserId,
|
||||
_code: &str,
|
||||
) -> Result<TwoFactorActivation, IdentityError> {
|
||||
Err(IdentityError::NotSupportedForProvider)
|
||||
}
|
||||
|
||||
async fn disable_two_factor(
|
||||
&self,
|
||||
_user_id: &UserId,
|
||||
_challenge: TwoFactorChallenge,
|
||||
) -> Result<(), IdentityError> {
|
||||
Err(IdentityError::NotSupportedForProvider)
|
||||
}
|
||||
|
||||
async fn verify_two_factor_login(
|
||||
&self,
|
||||
_pending: &TwoFactorPendingIdentity,
|
||||
_challenge: TwoFactorChallenge,
|
||||
) -> Result<AuthenticatedIdentity, IdentityError> {
|
||||
Err(IdentityError::NotSupportedForProvider)
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedIdentityProvider = Arc<dyn IdentityProvider>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MachineTokenIssuer, NoMachineTokenIssuer, TokenIssuerActor, TokenIssuerError};
|
||||
use crate::{
|
||||
AgentTokenGrantType, IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, MembershipRole,
|
||||
UserId, WorkspaceId,
|
||||
};
|
||||
|
||||
fn actor() -> TokenIssuerActor {
|
||||
TokenIssuerActor {
|
||||
user_id: UserId::new("user_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
role: MembershipRole::Owner,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_machine_token_issuer_rejects_short_lived_issue() {
|
||||
let result = NoMachineTokenIssuer
|
||||
.issue_short_lived(
|
||||
IssueAgentTokenRequest {
|
||||
grant_type: AgentTokenGrantType::AgentKey,
|
||||
agent_key: Some("crk_agent_secret".to_owned()),
|
||||
refresh_token: None,
|
||||
scope: vec![],
|
||||
},
|
||||
&actor(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result, Err(TokenIssuerError::NotSupportedInEdition));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_machine_token_issuer_rejects_one_time_issue() {
|
||||
let result = NoMachineTokenIssuer
|
||||
.issue_one_time(
|
||||
IssueOneTimeAgentTokenRequest {
|
||||
agent_key: "crk_agent_secret".to_owned(),
|
||||
operation_id: "op_01".into(),
|
||||
scope: vec![],
|
||||
},
|
||||
&actor(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result, Err(TokenIssuerError::NotSupportedInEdition));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{TenantId, UsageRollup};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum BillingGate {
|
||||
Allow,
|
||||
Deny { reason: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum BillingError {
|
||||
#[error("billing integration is not available in this edition")]
|
||||
NotSupportedInEdition,
|
||||
#[error("billing provider failure: {0}")]
|
||||
Provider(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait BillingHook: Send + Sync {
|
||||
async fn on_rollup(&self, rollup: UsageRollup) -> Result<(), BillingError>;
|
||||
|
||||
async fn check_billing_gate(&self, tenant: &TenantId) -> Result<BillingGate, BillingError>;
|
||||
}
|
||||
|
||||
pub type SharedBillingHook = Arc<dyn BillingHook>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct NoopBillingHook;
|
||||
|
||||
#[async_trait]
|
||||
impl BillingHook for NoopBillingHook {
|
||||
async fn on_rollup(&self, _rollup: UsageRollup) -> Result<(), BillingError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_billing_gate(&self, _tenant: &TenantId) -> Result<BillingGate, BillingError> {
|
||||
Ok(BillingGate::Allow)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BillingGate, BillingHook, NoopBillingHook};
|
||||
use crate::{TenantId, UsagePeriod, UsageRollup, WorkspaceId};
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_billing_hook_allows_default_gate_and_rollups() {
|
||||
let hook = NoopBillingHook;
|
||||
|
||||
hook.on_rollup(UsageRollup {
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
agent_id: None,
|
||||
operation_id: None,
|
||||
period: UsagePeriod::Last24Hours,
|
||||
calls_total: 10,
|
||||
calls_ok: 9,
|
||||
calls_error: 1,
|
||||
p50_ms: 12,
|
||||
p95_ms: 42,
|
||||
p99_ms: 77,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let gate = hook
|
||||
.check_billing_gate(&TenantId::new("tenant_01"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(gate, BillingGate::Allow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use crate::{
|
||||
EditionCapabilities, EditionLimits, MachineAccessMode, OperationSecurityLevel, ProductEdition,
|
||||
Protocol,
|
||||
};
|
||||
|
||||
pub trait CapabilityProfile: Send + Sync {
|
||||
fn capabilities(&self) -> EditionCapabilities;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct CommunityCapabilityProfile;
|
||||
|
||||
impl CapabilityProfile for CommunityCapabilityProfile {
|
||||
fn capabilities(&self) -> EditionCapabilities {
|
||||
EditionCapabilities {
|
||||
edition: ProductEdition::Community,
|
||||
supported_protocols: vec![Protocol::Rest],
|
||||
supported_security_levels: vec![OperationSecurityLevel::Standard],
|
||||
machine_access_modes: vec![MachineAccessMode::StaticAgentKey],
|
||||
limits: EditionLimits {
|
||||
max_workspaces: Some(1),
|
||||
max_users_per_workspace: Some(1),
|
||||
max_agents_per_workspace: Some(1),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{AgentId, InvocationSource, InvocationStatus, OperationId, WorkspaceId};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MeteringEvent {
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub agent_id: Option<AgentId>,
|
||||
pub operation_id: OperationId,
|
||||
pub source: InvocationSource,
|
||||
pub status: InvocationStatus,
|
||||
pub duration_ms: u64,
|
||||
pub created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait MeteringSink: Send + Sync {
|
||||
async fn record(&self, event: MeteringEvent);
|
||||
}
|
||||
|
||||
pub type SharedMeteringSink = Arc<dyn MeteringSink>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct NoopMeteringSink;
|
||||
|
||||
#[async_trait]
|
||||
impl MeteringSink for NoopMeteringSink {
|
||||
async fn record(&self, _event: MeteringEvent) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use super::{MeteringEvent, MeteringSink, NoopMeteringSink};
|
||||
use crate::{InvocationSource, InvocationStatus, OperationId, WorkspaceId};
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_metering_sink_accepts_event() {
|
||||
NoopMeteringSink
|
||||
.record(MeteringEvent {
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
agent_id: None,
|
||||
operation_id: OperationId::new("op_01"),
|
||||
source: InvocationSource::AgentToolCall,
|
||||
status: InvocationStatus::Ok,
|
||||
duration_ms: 42,
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod access;
|
||||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod billing;
|
||||
pub mod capability;
|
||||
pub mod metering;
|
||||
pub mod protocol;
|
||||
pub mod tenancy;
|
||||
@@ -0,0 +1,322 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{TenantId, WorkspaceId};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct TenantResolutionContext {
|
||||
pub workspace_id: Option<WorkspaceId>,
|
||||
pub workspace_slug: Option<String>,
|
||||
pub host: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum TenancyError {
|
||||
#[error("tenant lookup is not supported in this edition")]
|
||||
UnsupportedLookup,
|
||||
#[error("internal tenancy failure: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait TenantController: Send + Sync {
|
||||
fn resolve_tenant(&self, request: &TenantResolutionContext) -> Result<TenantId, TenancyError>;
|
||||
|
||||
async fn list_workspaces_for_tenant(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
) -> Result<Vec<WorkspaceId>, TenancyError>;
|
||||
}
|
||||
|
||||
pub type SharedTenantController = Arc<dyn TenantController>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SingleTenantController {
|
||||
tenant_id: TenantId,
|
||||
}
|
||||
|
||||
impl Default for SingleTenantController {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tenant_id: TenantId::new("default"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TenantController for SingleTenantController {
|
||||
fn resolve_tenant(&self, _request: &TenantResolutionContext) -> Result<TenantId, TenancyError> {
|
||||
Ok(self.tenant_id.clone())
|
||||
}
|
||||
|
||||
async fn list_workspaces_for_tenant(
|
||||
&self,
|
||||
_tenant: &TenantId,
|
||||
) -> Result<Vec<WorkspaceId>, TenancyError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SingleTenantController, TenantController, TenantResolutionContext};
|
||||
use crate::TenantId;
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_tenant_controller_returns_default_tenant() {
|
||||
let controller = SingleTenantController::default();
|
||||
|
||||
let tenant = controller
|
||||
.resolve_tenant(&TenantResolutionContext::default())
|
||||
.unwrap();
|
||||
let workspaces = controller
|
||||
.list_workspaces_for_tenant(&tenant)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(tenant, TenantId::new("default"));
|
||||
assert!(workspaces.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
macro_rules! define_id {
|
||||
($name:ident) => {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct $name(String);
|
||||
|
||||
impl $name {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for $name {
|
||||
fn from(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for $name {
|
||||
fn from(value: &str) -> Self {
|
||||
Self(value.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for $name {
|
||||
fn as_ref(&self) -> &str {
|
||||
self.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for $name {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
define_id!(OperationId);
|
||||
define_id!(DescriptorId);
|
||||
define_id!(ToolId);
|
||||
define_id!(SampleId);
|
||||
define_id!(AuthProfileId);
|
||||
define_id!(WorkspaceId);
|
||||
define_id!(TenantId);
|
||||
define_id!(UserId);
|
||||
define_id!(UserSessionId);
|
||||
define_id!(AgentId);
|
||||
define_id!(InvitationId);
|
||||
define_id!(PlatformApiKeyId);
|
||||
define_id!(InvocationLogId);
|
||||
define_id!(AuditEventId);
|
||||
define_id!(SecretId);
|
||||
define_id!(StreamSessionId);
|
||||
define_id!(AsyncJobId);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ids_implement_display() {
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
|
||||
assert_eq!(workspace_id.to_string(), "ws_default");
|
||||
assert_eq!(format!("{workspace_id}"), "ws_default");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
pub mod access;
|
||||
pub mod agent;
|
||||
pub mod auth;
|
||||
pub mod cache;
|
||||
pub mod edition;
|
||||
pub mod ext;
|
||||
pub mod ids;
|
||||
pub mod observability;
|
||||
pub mod operation;
|
||||
pub mod protocol;
|
||||
pub mod secret;
|
||||
pub mod soap;
|
||||
pub mod stream_session;
|
||||
pub mod streaming;
|
||||
pub mod workspace;
|
||||
|
||||
pub use access::{
|
||||
InvitationStatus, InvitationToken, Membership, MembershipRole, PlatformApiKey,
|
||||
PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
|
||||
};
|
||||
pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
|
||||
pub use auth::{
|
||||
AgentTokenGrantType, ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile,
|
||||
BasicAuthConfig, BearerAuthConfig, IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest,
|
||||
IssuedAgentTokenResponse,
|
||||
};
|
||||
pub use cache::{
|
||||
CacheBackend, CacheScope, CacheStoreError, CachedHeader, CachedResponse,
|
||||
CoordinationStateStore, CoordinationStateValue, ParseCacheBackendError, RateLimitBucketState,
|
||||
RateLimitStateStore, ReplayGuardStatus, ReplayGuardStore, ResponseCacheStore,
|
||||
};
|
||||
pub use edition::{
|
||||
EditionCapabilities, EditionLimits, MachineAccessMode, OperationSecurityLevel, ProductEdition,
|
||||
};
|
||||
pub use ext::access::{
|
||||
OwnerOnlyPolicyEngine, PolicyAction, PolicyDecision, PolicyEngine, PolicyScope, SessionActor,
|
||||
};
|
||||
pub use ext::audit::{
|
||||
AuditActor, AuditError, AuditEvent, AuditEventPage, AuditQuery, AuditSink, AuditTarget,
|
||||
AuditTargetKind, NoopAuditSink, SharedAuditSink,
|
||||
};
|
||||
pub use ext::auth::{
|
||||
AuthenticatedIdentity, IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome,
|
||||
LoginPayload, MachineCredentialVerifier, MachineCredentialVerifierError, MachineTokenIssuer,
|
||||
NoMachineTokenIssuer, SharedIdentityProvider, SharedMachineCredentialVerifier,
|
||||
SharedMachineTokenIssuer, SsoAuthorizeRedirect, SsoAuthorizeRequest, SsoCallbackRequest,
|
||||
TokenIssuerActor, TokenIssuerError, TwoFactorActivation, TwoFactorChallenge,
|
||||
TwoFactorPendingIdentity, TwoFactorSetup, TwoFactorStatus, VerifiedMachineCredential,
|
||||
};
|
||||
pub use ext::billing::{
|
||||
BillingError, BillingGate, BillingHook, NoopBillingHook, SharedBillingHook,
|
||||
};
|
||||
pub use ext::capability::{CapabilityProfile, CommunityCapabilityProfile};
|
||||
pub use ext::metering::{MeteringEvent, MeteringSink, NoopMeteringSink, SharedMeteringSink};
|
||||
pub use ext::protocol::{
|
||||
AdapterRegistry, AdapterResponse, MeteringContext, PreparedRequest, ProtocolAdapter,
|
||||
ProtocolAdapterError, ResponseCacheScope, RuntimeRequestContext, SharedProtocolAdapter,
|
||||
WindowExecutionResult,
|
||||
};
|
||||
pub use ext::tenancy::{
|
||||
SharedTenantController, SingleTenantController, TenancyError, TenantController,
|
||||
TenantResolutionContext,
|
||||
};
|
||||
pub use ids::{
|
||||
AgentId, AsyncJobId, AuditEventId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId,
|
||||
OperationId, PlatformApiKeyId, SampleId, SecretId, StreamSessionId, TenantId, ToolId, UserId,
|
||||
UserSessionId, WorkspaceId,
|
||||
};
|
||||
pub use observability::{
|
||||
InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod, UsageRollup,
|
||||
};
|
||||
pub use operation::{
|
||||
ConfigExport, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, GraphqlTarget,
|
||||
GrpcProtocolOptions, GrpcTarget, Operation, OperationStatus, ProtocolOptions,
|
||||
ResponseCachePolicy, RestTarget, RetryPolicy, Samples, SoapProtocolOptions, SoapTarget, Target,
|
||||
ToolDescription, ToolExample, WebsocketProtocolOptions, WebsocketTarget,
|
||||
};
|
||||
pub use protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol};
|
||||
pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion};
|
||||
pub use soap::{
|
||||
SoapBindingStyle, SoapFaultContract, SoapHeaderConfig, SoapOperationMetadata, SoapVersion,
|
||||
};
|
||||
pub use stream_session::{AsyncJobHandle, JobStatus, StreamSession, StreamStatus};
|
||||
pub use streaming::{
|
||||
AggregationMode, ExecutionMode, StreamingConfig, StreamingConfigError, ToolFamilyConfig,
|
||||
TransportBehavior,
|
||||
};
|
||||
pub use workspace::{Workspace, WorkspaceStatus};
|
||||
@@ -0,0 +1,132 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{AgentId, OperationId, WorkspaceId};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InvocationSource {
|
||||
AdminTestRun,
|
||||
AgentToolCall,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InvocationLevel {
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InvocationStatus {
|
||||
Ok,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum UsagePeriod {
|
||||
#[serde(rename = "30m")]
|
||||
Last30Minutes,
|
||||
#[serde(rename = "1h")]
|
||||
LastHour,
|
||||
#[serde(rename = "6h")]
|
||||
Last6Hours,
|
||||
#[serde(rename = "24h")]
|
||||
Last24Hours,
|
||||
#[serde(rename = "7d")]
|
||||
Last7Days,
|
||||
#[serde(rename = "30d")]
|
||||
Last30Days,
|
||||
#[serde(rename = "90d")]
|
||||
Last90Days,
|
||||
#[serde(rename = "this_month")]
|
||||
ThisMonth,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InvocationLog {
|
||||
pub id: crate::ids::InvocationLogId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub agent_id: Option<AgentId>,
|
||||
pub operation_id: OperationId,
|
||||
pub source: InvocationSource,
|
||||
pub level: InvocationLevel,
|
||||
pub status: InvocationStatus,
|
||||
pub tool_name: String,
|
||||
pub message: String,
|
||||
pub request_id: Option<String>,
|
||||
pub status_code: Option<u16>,
|
||||
pub duration_ms: u64,
|
||||
pub error_kind: Option<String>,
|
||||
pub request_preview: Value,
|
||||
pub response_preview: Value,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UsageRollup {
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub agent_id: Option<AgentId>,
|
||||
pub operation_id: Option<OperationId>,
|
||||
pub period: UsagePeriod,
|
||||
pub calls_total: u64,
|
||||
pub calls_ok: u64,
|
||||
pub calls_error: u64,
|
||||
pub p50_ms: u64,
|
||||
pub p95_ms: u64,
|
||||
pub p99_ms: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use super::{InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod};
|
||||
use crate::{AgentId, OperationId, WorkspaceId, ids::InvocationLogId};
|
||||
|
||||
fn timestamp(value: &str) -> OffsetDateTime {
|
||||
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invocation_log_serializes_timestamps_as_rfc3339() {
|
||||
let log = InvocationLog {
|
||||
id: InvocationLogId::new("log_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
agent_id: Some(AgentId::new("agent_01")),
|
||||
operation_id: OperationId::new("op_01"),
|
||||
source: InvocationSource::AdminTestRun,
|
||||
level: InvocationLevel::Info,
|
||||
status: InvocationStatus::Ok,
|
||||
tool_name: "create_lead".to_owned(),
|
||||
message: "ok".to_owned(),
|
||||
request_id: Some("req_01".to_owned()),
|
||||
status_code: Some(200),
|
||||
duration_ms: 123,
|
||||
error_kind: None,
|
||||
request_preview: json!({"input": "value"}),
|
||||
response_preview: json!({"ok": true}),
|
||||
created_at: timestamp("2026-04-19T12:34:56Z"),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&log).unwrap();
|
||||
|
||||
assert_eq!(value["created_at"], "2026-04-19T12:34:56Z");
|
||||
assert_eq!(value["source"], "admin_test_run");
|
||||
assert_eq!(value["level"], "info");
|
||||
assert_eq!(value["status"], "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_period_serializes_compact_labels() {
|
||||
let value = serde_json::to_value(UsagePeriod::Last7Days).unwrap();
|
||||
|
||||
assert_eq!(value, "7d");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
edition::OperationSecurityLevel,
|
||||
ids::{AuthProfileId, DescriptorId, OperationId, SampleId},
|
||||
protocol::{ExportMode, GraphqlOperationType, HttpMethod, Protocol},
|
||||
soap::{
|
||||
SoapBindingStyle, SoapFaultContract, SoapHeaderConfig, SoapOperationMetadata, SoapVersion,
|
||||
},
|
||||
streaming::StreamingConfig,
|
||||
};
|
||||
|
||||
fn default_operation_category() -> String {
|
||||
"general".to_owned()
|
||||
}
|
||||
|
||||
fn default_operation_security_level() -> OperationSecurityLevel {
|
||||
OperationSecurityLevel::Standard
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OperationStatus {
|
||||
Draft,
|
||||
Testing,
|
||||
Published,
|
||||
Archived,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RestTarget {
|
||||
pub base_url: String,
|
||||
pub method: HttpMethod,
|
||||
pub path_template: String,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub static_headers: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GraphqlTarget {
|
||||
pub endpoint: String,
|
||||
pub operation_type: GraphqlOperationType,
|
||||
pub operation_name: String,
|
||||
pub query_template: String,
|
||||
pub response_path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GrpcTarget {
|
||||
pub server_addr: String,
|
||||
pub package: String,
|
||||
pub service: String,
|
||||
pub method: String,
|
||||
#[serde(default)]
|
||||
pub read_only: bool,
|
||||
pub descriptor_ref: DescriptorId,
|
||||
pub descriptor_set_b64: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WebsocketTarget {
|
||||
pub url: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub subprotocols: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subscribe_message_template: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unsubscribe_message_template: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub static_headers: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SoapTarget {
|
||||
pub wsdl_ref: SampleId,
|
||||
pub service_name: String,
|
||||
pub port_name: String,
|
||||
pub operation_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint_override: Option<String>,
|
||||
pub soap_version: SoapVersion,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub soap_action: Option<String>,
|
||||
pub binding_style: SoapBindingStyle,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub headers: Vec<SoapHeaderConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fault_contract: Option<SoapFaultContract>,
|
||||
#[serde(default)]
|
||||
pub metadata: SoapOperationMetadata,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Target {
|
||||
Rest(RestTarget),
|
||||
Graphql(GraphqlTarget),
|
||||
Grpc(GrpcTarget),
|
||||
Websocket(WebsocketTarget),
|
||||
Soap(SoapTarget),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct RetryPolicy {
|
||||
pub max_attempts: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResponseCachePolicy {
|
||||
pub ttl_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct GrpcProtocolOptions {
|
||||
pub use_tls: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct WebsocketProtocolOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub heartbeat_interval_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reconnect_max_attempts: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reconnect_backoff_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SoapProtocolOptions {
|
||||
#[serde(default)]
|
||||
pub use_tls: bool,
|
||||
#[serde(default)]
|
||||
pub validate_certificate: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ws_security_profile: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct ProtocolOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub grpc: Option<GrpcProtocolOptions>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub websocket: Option<WebsocketProtocolOptions>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub soap: Option<SoapProtocolOptions>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ExecutionConfig {
|
||||
pub timeout_ms: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub retry_policy: Option<RetryPolicy>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub response_cache: Option<ResponseCachePolicy>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth_profile_ref: Option<AuthProfileId>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub headers: BTreeMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub protocol_options: Option<ProtocolOptions>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub streaming: Option<StreamingConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ToolExample {
|
||||
pub input: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ToolDescription {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub examples: Vec<ToolExample>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct Samples {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_json_sample_ref: Option<SampleId>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_json_sample_ref: Option<SampleId>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub proto_file_ref: Option<SampleId>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub descriptor_ref: Option<DescriptorId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GeneratedDraftStatus {
|
||||
None,
|
||||
Available,
|
||||
Stale,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GeneratedDraft {
|
||||
pub status: GeneratedDraftStatus,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub source_types: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generated_at: Option<String>,
|
||||
pub input_schema_generated: bool,
|
||||
pub output_schema_generated: bool,
|
||||
pub input_mapping_generated: bool,
|
||||
pub output_mapping_generated: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConfigExport {
|
||||
pub format_version: String,
|
||||
pub export_mode: ExportMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Operation<TSchema, TMapping> {
|
||||
pub id: OperationId,
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
#[serde(default = "default_operation_category")]
|
||||
pub category: String,
|
||||
pub protocol: Protocol,
|
||||
#[serde(default = "default_operation_security_level")]
|
||||
pub security_level: OperationSecurityLevel,
|
||||
pub status: OperationStatus,
|
||||
pub version: u32,
|
||||
pub target: Target,
|
||||
pub input_schema: TSchema,
|
||||
pub output_schema: TSchema,
|
||||
pub input_mapping: TMapping,
|
||||
pub output_mapping: TMapping,
|
||||
pub execution_config: ExecutionConfig,
|
||||
pub tool_description: ToolDescription,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub samples: Option<Samples>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generated_draft: Option<GeneratedDraft>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub config_export: Option<ConfigExport>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub updated_at: OffsetDateTime,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub published_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
impl<TSchema, TMapping> Operation<TSchema, TMapping> {
|
||||
pub fn tool_name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn is_draft(&self) -> bool {
|
||||
self.status == OperationStatus::Draft
|
||||
}
|
||||
|
||||
pub fn is_published(&self) -> bool {
|
||||
self.status == OperationStatus::Published
|
||||
}
|
||||
|
||||
pub fn protocol(&self) -> Protocol {
|
||||
self.protocol
|
||||
}
|
||||
|
||||
pub fn auth_profile_ref(&self) -> Option<&AuthProfileId> {
|
||||
self.execution_config.auth_profile_ref.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::json;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use crate::{
|
||||
auth::{AuthConfig, AuthProfile, BearerAuthConfig},
|
||||
edition::OperationSecurityLevel,
|
||||
ids::{AuthProfileId, OperationId, SampleId},
|
||||
operation::{
|
||||
ConfigExport, ExecutionConfig, GraphqlTarget, Operation, OperationStatus,
|
||||
ProtocolOptions, RestTarget, Samples, SoapProtocolOptions, SoapTarget, Target,
|
||||
ToolDescription, ToolExample, WebsocketProtocolOptions, WebsocketTarget,
|
||||
},
|
||||
protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol},
|
||||
soap::{
|
||||
SoapBindingStyle, SoapFaultContract, SoapHeaderConfig, SoapOperationMetadata,
|
||||
SoapVersion,
|
||||
},
|
||||
};
|
||||
|
||||
fn timestamp(value: &str) -> OffsetDateTime {
|
||||
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_target_serializes_with_kind_tag() {
|
||||
let target = Target::Rest(RestTarget {
|
||||
base_url: "https://api.example.com".to_owned(),
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/v1/leads".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
});
|
||||
|
||||
let value = serde_json::to_value(target).unwrap();
|
||||
|
||||
assert_eq!(value["kind"], "rest");
|
||||
assert_eq!(value["method"], "POST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphql_target_serializes_response_path() {
|
||||
let target = Target::Graphql(GraphqlTarget {
|
||||
endpoint: "https://api.example.com/graphql".to_owned(),
|
||||
operation_type: GraphqlOperationType::Mutation,
|
||||
operation_name: "CreateLead".to_owned(),
|
||||
query_template: "mutation {}".to_owned(),
|
||||
response_path: "$.response.body.data.createLead".to_owned(),
|
||||
});
|
||||
|
||||
let value = serde_json::to_value(target).unwrap();
|
||||
|
||||
assert_eq!(value["kind"], "graphql");
|
||||
assert_eq!(value["operation_type"], "mutation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_target_serializes_templates() {
|
||||
let target = Target::Websocket(WebsocketTarget {
|
||||
url: "wss://events.example.com/stream".to_owned(),
|
||||
subprotocols: vec!["graphql-transport-ws".to_owned()],
|
||||
subscribe_message_template: Some(json!({ "type": "subscribe" })),
|
||||
unsubscribe_message_template: Some(json!({ "type": "unsubscribe" })),
|
||||
static_headers: BTreeMap::from([("x-env".to_owned(), "test".to_owned())]),
|
||||
});
|
||||
|
||||
let value = serde_json::to_value(target).unwrap();
|
||||
|
||||
assert_eq!(value["kind"], "websocket");
|
||||
assert_eq!(value["subprotocols"][0], "graphql-transport-ws");
|
||||
assert_eq!(value["subscribe_message_template"]["type"], "subscribe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soap_target_serializes_binding_metadata() {
|
||||
let target = Target::Soap(SoapTarget {
|
||||
wsdl_ref: SampleId::new("sample_wsdl_01"),
|
||||
service_name: "LeadService".to_owned(),
|
||||
port_name: "LeadPort".to_owned(),
|
||||
operation_name: "CreateLead".to_owned(),
|
||||
endpoint_override: Some("https://soap.example.com/lead".to_owned()),
|
||||
soap_version: SoapVersion::Soap12,
|
||||
soap_action: Some("urn:createLead".to_owned()),
|
||||
binding_style: SoapBindingStyle::DocumentLiteral,
|
||||
headers: vec![SoapHeaderConfig {
|
||||
name: "CorrelationId".to_owned(),
|
||||
namespace_uri: Some("urn:crm".to_owned()),
|
||||
required: true,
|
||||
value_path: Some("$.mcp.correlation_id".to_owned()),
|
||||
}],
|
||||
fault_contract: Some(SoapFaultContract {
|
||||
code_path: Some("$.Envelope.Body.Fault.Code.Value".to_owned()),
|
||||
message_path: Some("$.Envelope.Body.Fault.Reason.Text".to_owned()),
|
||||
detail_path: Some("$.Envelope.Body.Fault.Detail".to_owned()),
|
||||
}),
|
||||
metadata: SoapOperationMetadata {
|
||||
input_part_names: vec!["LeadRequest".to_owned()],
|
||||
output_part_names: vec!["LeadResponse".to_owned()],
|
||||
namespaces: vec!["urn:crm".to_owned()],
|
||||
},
|
||||
});
|
||||
|
||||
let value = serde_json::to_value(target).unwrap();
|
||||
|
||||
assert_eq!(value["kind"], "soap");
|
||||
assert_eq!(value["soap_version"], "soap_12");
|
||||
assert_eq!(value["binding_style"], "document_literal");
|
||||
assert_eq!(value["metadata"]["input_part_names"][0], "LeadRequest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_exposes_local_domain_helpers() {
|
||||
let operation = Operation {
|
||||
id: OperationId::new("op_01"),
|
||||
name: "crm_create_lead".to_owned(),
|
||||
display_name: "Create Lead".to_owned(),
|
||||
category: "sales".to_owned(),
|
||||
protocol: Protocol::Rest,
|
||||
security_level: OperationSecurityLevel::Standard,
|
||||
status: OperationStatus::Draft,
|
||||
version: 1,
|
||||
target: Target::Rest(RestTarget {
|
||||
base_url: "https://api.example.com".to_owned(),
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/v1/leads".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
}),
|
||||
input_schema: json!({"type":"object"}),
|
||||
output_schema: json!({"type":"object"}),
|
||||
input_mapping: json!({"rules":[]}),
|
||||
output_mapping: json!({"rules":[]}),
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 10_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
auth_profile_ref: Some(AuthProfileId::new("auth_01")),
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: Some(ProtocolOptions {
|
||||
grpc: None,
|
||||
websocket: Some(WebsocketProtocolOptions {
|
||||
heartbeat_interval_ms: Some(5_000),
|
||||
reconnect_max_attempts: Some(3),
|
||||
reconnect_backoff_ms: Some(250),
|
||||
}),
|
||||
soap: Some(SoapProtocolOptions {
|
||||
use_tls: true,
|
||||
validate_certificate: true,
|
||||
ws_security_profile: Some("username_token".to_owned()),
|
||||
}),
|
||||
}),
|
||||
streaming: None,
|
||||
},
|
||||
tool_description: ToolDescription {
|
||||
title: "Create CRM lead".to_owned(),
|
||||
description: "Creates a new lead.".to_owned(),
|
||||
tags: Vec::new(),
|
||||
examples: Vec::new(),
|
||||
},
|
||||
samples: Some(Samples::default()),
|
||||
generated_draft: None,
|
||||
config_export: None,
|
||||
created_at: timestamp("2026-03-25T08:00:00Z"),
|
||||
updated_at: timestamp("2026-03-25T08:00:00Z"),
|
||||
published_at: None,
|
||||
};
|
||||
|
||||
assert_eq!(operation.tool_name(), "crm_create_lead");
|
||||
assert!(operation.is_draft());
|
||||
assert!(!operation.is_published());
|
||||
assert_eq!(operation.protocol(), Protocol::Rest);
|
||||
assert_eq!(
|
||||
operation.auth_profile_ref().map(|value| value.as_str()),
|
||||
Some("auth_01")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_profile_serializes_secret_ids_without_secret_values() {
|
||||
let profile = AuthProfile {
|
||||
id: AuthProfileId::new("auth_01"),
|
||||
workspace_id: crate::ids::WorkspaceId::new("ws_01"),
|
||||
name: "crm-prod-bearer".to_owned(),
|
||||
kind: AuthKind::Bearer,
|
||||
config: AuthConfig::Bearer(BearerAuthConfig {
|
||||
header_name: "Authorization".to_owned(),
|
||||
secret_id: crate::ids::SecretId::new("secret_crm_prod_token"),
|
||||
}),
|
||||
created_at: timestamp("2026-03-25T08:00:00Z"),
|
||||
updated_at: timestamp("2026-03-25T08:00:00Z"),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(profile).unwrap();
|
||||
|
||||
assert_eq!(value["kind"], "bearer");
|
||||
assert_eq!(
|
||||
value["config"]["bearer"]["secret_id"],
|
||||
"secret_crm_prod_token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_roundtrips_through_yaml() {
|
||||
let operation = Operation {
|
||||
id: OperationId::new("op_01"),
|
||||
name: "crm_create_lead".to_owned(),
|
||||
display_name: "Create Lead".to_owned(),
|
||||
category: "sales".to_owned(),
|
||||
protocol: Protocol::Rest,
|
||||
security_level: OperationSecurityLevel::Standard,
|
||||
status: OperationStatus::Published,
|
||||
version: 3,
|
||||
target: Target::Rest(RestTarget {
|
||||
base_url: "https://api.example.com".to_owned(),
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/v1/leads".to_owned(),
|
||||
static_headers: BTreeMap::from([("X-App-Source".to_owned(), "crank".to_owned())]),
|
||||
}),
|
||||
input_schema: json!({"type":"object","fields":{"email":{"type":"string","required":true}}}),
|
||||
output_schema: json!({"type":"object","fields":{"id":{"type":"string","required":true}}}),
|
||||
input_mapping: json!({"rules":[{"source":"$.mcp.email","target":"$.request.body.email"}]}),
|
||||
output_mapping: json!({"rules":[{"source":"$.response.body.id","target":"$.output.id"}]}),
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 10_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
auth_profile_ref: Some(AuthProfileId::new("auth_01")),
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: Some(ProtocolOptions::default()),
|
||||
streaming: None,
|
||||
},
|
||||
tool_description: ToolDescription {
|
||||
title: "Create CRM lead".to_owned(),
|
||||
description: "Creates a new lead.".to_owned(),
|
||||
tags: vec!["crm".to_owned()],
|
||||
examples: vec![ToolExample {
|
||||
input: json!({"email":"user@example.com"}),
|
||||
}],
|
||||
},
|
||||
samples: Some(Samples::default()),
|
||||
generated_draft: None,
|
||||
config_export: Some(ConfigExport {
|
||||
format_version: "1".to_owned(),
|
||||
export_mode: ExportMode::Portable,
|
||||
}),
|
||||
created_at: timestamp("2026-03-25T08:00:00Z"),
|
||||
updated_at: timestamp("2026-03-25T08:10:00Z"),
|
||||
published_at: Some(timestamp("2026-03-25T08:15:00Z")),
|
||||
};
|
||||
|
||||
let yaml = serde_yaml::to_string(&operation).unwrap();
|
||||
let restored: Operation<serde_json::Value, serde_json::Value> =
|
||||
serde_yaml::from_str(&yaml).unwrap();
|
||||
|
||||
assert!(yaml.contains("protocol: rest"));
|
||||
assert!(yaml.contains("security_level: standard"));
|
||||
assert!(yaml.contains("export_mode: portable"));
|
||||
assert_eq!(restored, operation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_yaml_deserializes_without_optional_published_at() {
|
||||
let yaml = r#"
|
||||
id: op_01
|
||||
name: crm_create_lead
|
||||
display_name: Create Lead
|
||||
category: sales
|
||||
protocol: rest
|
||||
security_level: standard
|
||||
status: draft
|
||||
version: 1
|
||||
target:
|
||||
kind: rest
|
||||
base_url: https://api.example.com
|
||||
method: POST
|
||||
path_template: /v1/leads
|
||||
static_headers: {}
|
||||
input_schema:
|
||||
type: object
|
||||
output_schema:
|
||||
type: object
|
||||
input_mapping:
|
||||
rules: []
|
||||
output_mapping:
|
||||
rules: []
|
||||
execution_config:
|
||||
timeout_ms: 10000
|
||||
headers: {}
|
||||
tool_description:
|
||||
title: Create CRM lead
|
||||
description: Creates a new lead.
|
||||
tags: []
|
||||
examples: []
|
||||
created_at: 2026-03-25T08:00:00Z
|
||||
updated_at: 2026-03-25T08:10:00Z
|
||||
"#;
|
||||
|
||||
let restored: Operation<serde_json::Value, serde_json::Value> =
|
||||
serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
assert_eq!(restored.security_level, OperationSecurityLevel::Standard);
|
||||
assert_eq!(restored.published_at, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::streaming::{ExecutionMode, TransportBehavior};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Protocol {
|
||||
Rest,
|
||||
Graphql,
|
||||
Grpc,
|
||||
Websocket,
|
||||
Soap,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "UPPERCASE")]
|
||||
pub enum HttpMethod {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Patch,
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GraphqlOperationType {
|
||||
Query,
|
||||
Mutation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuthKind {
|
||||
Bearer,
|
||||
Basic,
|
||||
ApiKeyHeader,
|
||||
ApiKeyQuery,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExportMode {
|
||||
Portable,
|
||||
Bundle,
|
||||
}
|
||||
|
||||
impl Protocol {
|
||||
pub fn supports_execution_mode(self, mode: ExecutionMode) -> bool {
|
||||
match self {
|
||||
Self::Rest => true,
|
||||
Self::Graphql => matches!(mode, ExecutionMode::Unary),
|
||||
Self::Grpc => true,
|
||||
Self::Websocket => !matches!(mode, ExecutionMode::Unary),
|
||||
Self::Soap => matches!(mode, ExecutionMode::Unary | ExecutionMode::AsyncJob),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn supports_transport_behavior(self, behavior: TransportBehavior) -> bool {
|
||||
match self {
|
||||
Self::Rest => true,
|
||||
Self::Graphql => matches!(behavior, TransportBehavior::RequestResponse),
|
||||
Self::Grpc => !matches!(behavior, TransportBehavior::DeferredResult),
|
||||
Self::Websocket => !matches!(behavior, TransportBehavior::RequestResponse),
|
||||
Self::Soap => !matches!(behavior, TransportBehavior::StatefulSession),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Protocol;
|
||||
use crate::streaming::{ExecutionMode, TransportBehavior};
|
||||
|
||||
#[test]
|
||||
fn graphql_support_matrix_is_restricted() {
|
||||
assert!(Protocol::Graphql.supports_execution_mode(ExecutionMode::Unary));
|
||||
assert!(!Protocol::Graphql.supports_execution_mode(ExecutionMode::Window));
|
||||
assert!(!Protocol::Graphql.supports_execution_mode(ExecutionMode::Session));
|
||||
assert!(!Protocol::Graphql.supports_transport_behavior(TransportBehavior::ServerStream));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_supports_session_but_not_deferred_result() {
|
||||
assert!(Protocol::Grpc.supports_execution_mode(ExecutionMode::Session));
|
||||
assert!(!Protocol::Grpc.supports_transport_behavior(TransportBehavior::DeferredResult));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_requires_streaming_modes() {
|
||||
assert!(!Protocol::Websocket.supports_execution_mode(ExecutionMode::Unary));
|
||||
assert!(Protocol::Websocket.supports_execution_mode(ExecutionMode::Window));
|
||||
assert!(Protocol::Websocket.supports_execution_mode(ExecutionMode::Session));
|
||||
assert!(Protocol::Websocket.supports_execution_mode(ExecutionMode::AsyncJob));
|
||||
assert!(
|
||||
!Protocol::Websocket.supports_transport_behavior(TransportBehavior::RequestResponse)
|
||||
);
|
||||
assert!(Protocol::Websocket.supports_transport_behavior(TransportBehavior::ServerStream));
|
||||
assert!(
|
||||
Protocol::Websocket.supports_transport_behavior(TransportBehavior::StatefulSession)
|
||||
);
|
||||
assert!(Protocol::Websocket.supports_transport_behavior(TransportBehavior::DeferredResult));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soap_is_request_response_first_with_async_job_support() {
|
||||
assert!(Protocol::Soap.supports_execution_mode(ExecutionMode::Unary));
|
||||
assert!(!Protocol::Soap.supports_execution_mode(ExecutionMode::Window));
|
||||
assert!(!Protocol::Soap.supports_execution_mode(ExecutionMode::Session));
|
||||
assert!(Protocol::Soap.supports_execution_mode(ExecutionMode::AsyncJob));
|
||||
assert!(Protocol::Soap.supports_transport_behavior(TransportBehavior::RequestResponse));
|
||||
assert!(Protocol::Soap.supports_transport_behavior(TransportBehavior::ServerStream));
|
||||
assert!(!Protocol::Soap.supports_transport_behavior(TransportBehavior::StatefulSession));
|
||||
assert!(Protocol::Soap.supports_transport_behavior(TransportBehavior::DeferredResult));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::ids::{SecretId, UserId, WorkspaceId};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SecretKind {
|
||||
Token,
|
||||
UsernamePassword,
|
||||
Header,
|
||||
Generic,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SecretStatus {
|
||||
Active,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Secret {
|
||||
pub id: SecretId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub name: String,
|
||||
pub kind: SecretKind,
|
||||
pub status: SecretStatus,
|
||||
pub current_version: u32,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub updated_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub last_used_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SecretVersion {
|
||||
pub secret_id: SecretId,
|
||||
pub version: u32,
|
||||
pub ciphertext: String,
|
||||
pub key_version: String,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
pub created_by: Option<UserId>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use super::{Secret, SecretKind, SecretStatus, SecretVersion};
|
||||
use crate::ids::{SecretId, UserId, WorkspaceId};
|
||||
|
||||
#[test]
|
||||
fn secret_serializes_timestamps_as_rfc3339() {
|
||||
let secret = Secret {
|
||||
id: SecretId::new("secret_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
name: "primary".to_owned(),
|
||||
kind: SecretKind::Token,
|
||||
status: SecretStatus::Active,
|
||||
current_version: 2,
|
||||
created_at: OffsetDateTime::parse("2026-03-25T12:00:00Z", &Rfc3339).unwrap(),
|
||||
updated_at: OffsetDateTime::parse("2026-03-25T12:05:00Z", &Rfc3339).unwrap(),
|
||||
last_used_at: Some(OffsetDateTime::parse("2026-03-25T12:07:00Z", &Rfc3339).unwrap()),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&secret).unwrap();
|
||||
|
||||
assert_eq!(value["created_at"], json!("2026-03-25T12:00:00Z"));
|
||||
assert_eq!(value["updated_at"], json!("2026-03-25T12:05:00Z"));
|
||||
assert_eq!(value["last_used_at"], json!("2026-03-25T12:07:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_version_deserializes_created_at_from_rfc3339() {
|
||||
let version: SecretVersion = serde_json::from_value(json!({
|
||||
"secret_id": "secret_01",
|
||||
"version": 2,
|
||||
"ciphertext": "opaque",
|
||||
"key_version": "v2",
|
||||
"created_at": "2026-03-25T12:07:00Z",
|
||||
"created_by": "user_01"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
version.created_at,
|
||||
OffsetDateTime::parse("2026-03-25T12:07:00Z", &Rfc3339).unwrap()
|
||||
);
|
||||
assert_eq!(version.created_by, Some(UserId::new("user_01")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SoapVersion {
|
||||
#[serde(rename = "soap_11")]
|
||||
Soap11,
|
||||
#[serde(rename = "soap_12")]
|
||||
Soap12,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SoapBindingStyle {
|
||||
DocumentLiteral,
|
||||
RpcLiteral,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SoapHeaderConfig {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub namespace_uri: Option<String>,
|
||||
pub required: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SoapFaultContract {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SoapOperationMetadata {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub input_part_names: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub output_part_names: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub namespaces: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
SoapBindingStyle, SoapFaultContract, SoapHeaderConfig, SoapOperationMetadata, SoapVersion,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn soap_metadata_serializes_compactly() {
|
||||
let value = serde_json::to_value(SoapOperationMetadata {
|
||||
input_part_names: vec!["LeadRequest".to_owned()],
|
||||
output_part_names: vec!["LeadResponse".to_owned()],
|
||||
namespaces: vec!["urn:crm".to_owned()],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(value["input_part_names"][0], "LeadRequest");
|
||||
assert_eq!(value["output_part_names"][0], "LeadResponse");
|
||||
assert_eq!(value["namespaces"][0], "urn:crm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soap_supporting_types_roundtrip() {
|
||||
let value = json!({
|
||||
"version": "soap_12",
|
||||
"style": "document_literal",
|
||||
"header": {
|
||||
"name": "CorrelationId",
|
||||
"namespace_uri": "urn:crm",
|
||||
"required": true,
|
||||
"value_path": "$.mcp.correlation_id"
|
||||
},
|
||||
"fault": {
|
||||
"code_path": "$.Envelope.Body.Fault.Code.Value",
|
||||
"message_path": "$.Envelope.Body.Fault.Reason.Text",
|
||||
"detail_path": "$.Envelope.Body.Fault.Detail"
|
||||
}
|
||||
});
|
||||
|
||||
let version: SoapVersion = serde_json::from_value(value["version"].clone()).unwrap();
|
||||
let style: SoapBindingStyle = serde_json::from_value(value["style"].clone()).unwrap();
|
||||
let header: SoapHeaderConfig = serde_json::from_value(value["header"].clone()).unwrap();
|
||||
let fault: SoapFaultContract = serde_json::from_value(value["fault"].clone()).unwrap();
|
||||
|
||||
assert_eq!(version, SoapVersion::Soap12);
|
||||
assert_eq!(style, SoapBindingStyle::DocumentLiteral);
|
||||
assert_eq!(header.name, "CorrelationId");
|
||||
assert_eq!(
|
||||
fault.detail_path.as_deref(),
|
||||
Some("$.Envelope.Body.Fault.Detail")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
ids::{AgentId, AsyncJobId, OperationId, StreamSessionId, WorkspaceId},
|
||||
protocol::Protocol,
|
||||
streaming::ExecutionMode,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StreamStatus {
|
||||
Created,
|
||||
Running,
|
||||
Stopped,
|
||||
Failed,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl StreamStatus {
|
||||
pub fn is_terminal(self) -> bool {
|
||||
matches!(self, Self::Stopped | Self::Failed | Self::Expired)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StreamSession {
|
||||
pub id: StreamSessionId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_id: Option<AgentId>,
|
||||
pub operation_id: OperationId,
|
||||
pub protocol: Protocol,
|
||||
pub mode: ExecutionMode,
|
||||
pub status: StreamStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor: Option<Value>,
|
||||
pub state: Value,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub expires_at: OffsetDateTime,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub last_poll_at: Option<OffsetDateTime>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub closed_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
impl StreamSession {
|
||||
pub fn is_expired(&self, now: OffsetDateTime) -> bool {
|
||||
self.expires_at <= now
|
||||
}
|
||||
|
||||
pub fn can_poll(&self, now: OffsetDateTime) -> bool {
|
||||
!self.status.is_terminal() && !self.is_expired(now)
|
||||
}
|
||||
|
||||
pub fn remaining_poll_delay_ms(&self, now: OffsetDateTime, poll_interval_ms: u64) -> u64 {
|
||||
let Some(last_poll_at) = self.last_poll_at else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let elapsed_ms = (now - last_poll_at).whole_milliseconds().max(0) as u64;
|
||||
poll_interval_ms.saturating_sub(elapsed_ms)
|
||||
}
|
||||
|
||||
pub fn mark_polled(&mut self, now: OffsetDateTime) {
|
||||
self.last_poll_at = Some(now);
|
||||
}
|
||||
|
||||
pub fn mark_closed(&mut self, now: OffsetDateTime) {
|
||||
self.status = StreamStatus::Stopped;
|
||||
self.last_poll_at = Some(now);
|
||||
self.closed_at = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum JobStatus {
|
||||
Created,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl JobStatus {
|
||||
pub fn is_terminal(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Completed | Self::Failed | Self::Cancelled | Self::Expired
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AsyncJobHandle {
|
||||
pub id: AsyncJobId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_id: Option<AgentId>,
|
||||
pub operation_id: OperationId,
|
||||
pub status: JobStatus,
|
||||
pub progress: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub expires_at: Option<OffsetDateTime>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub last_poll_at: Option<OffsetDateTime>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub updated_at: OffsetDateTime,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub finished_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
impl AsyncJobHandle {
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.status.is_terminal()
|
||||
}
|
||||
|
||||
pub fn can_cancel(&self) -> bool {
|
||||
matches!(self.status, JobStatus::Created | JobStatus::Running)
|
||||
}
|
||||
|
||||
pub fn remaining_poll_delay_ms(&self, now: OffsetDateTime, poll_interval_ms: u64) -> u64 {
|
||||
let Some(last_poll_at) = self.last_poll_at else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let elapsed_ms = (now - last_poll_at).whole_milliseconds().max(0) as u64;
|
||||
poll_interval_ms.saturating_sub(elapsed_ms)
|
||||
}
|
||||
|
||||
pub fn mark_finished(&mut self, now: OffsetDateTime, result: Value) {
|
||||
self.status = JobStatus::Completed;
|
||||
self.result = Some(result);
|
||||
self.error = None;
|
||||
self.updated_at = now;
|
||||
self.finished_at = Some(now);
|
||||
}
|
||||
|
||||
pub fn mark_failed(&mut self, now: OffsetDateTime, error: Value) {
|
||||
self.status = JobStatus::Failed;
|
||||
self.error = Some(error);
|
||||
self.updated_at = now;
|
||||
self.finished_at = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use super::{AsyncJobHandle, JobStatus, StreamSession, StreamStatus};
|
||||
use crate::{
|
||||
ids::{AsyncJobId, OperationId, StreamSessionId, WorkspaceId},
|
||||
protocol::Protocol,
|
||||
streaming::ExecutionMode,
|
||||
};
|
||||
|
||||
fn timestamp(value: &str) -> OffsetDateTime {
|
||||
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_session_tracks_poll_and_close_transitions() {
|
||||
let mut session = StreamSession {
|
||||
id: StreamSessionId::new("stream_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
agent_id: None,
|
||||
operation_id: OperationId::new("op_01"),
|
||||
protocol: Protocol::Rest,
|
||||
mode: ExecutionMode::Session,
|
||||
status: StreamStatus::Running,
|
||||
cursor: None,
|
||||
state: json!({"cursor":"abc"}),
|
||||
expires_at: timestamp("2026-04-06T12:05:00Z"),
|
||||
last_poll_at: None,
|
||||
created_at: timestamp("2026-04-06T12:00:00Z"),
|
||||
closed_at: None,
|
||||
};
|
||||
|
||||
assert!(session.can_poll(timestamp("2026-04-06T12:01:00Z")));
|
||||
|
||||
session.mark_polled(timestamp("2026-04-06T12:01:00Z"));
|
||||
session.mark_closed(timestamp("2026-04-06T12:02:00Z"));
|
||||
|
||||
assert_eq!(session.status, StreamStatus::Stopped);
|
||||
assert_eq!(session.closed_at, Some(timestamp("2026-04-06T12:02:00Z")));
|
||||
assert!(!session.can_poll(timestamp("2026-04-06T12:03:00Z")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_poll_has_no_required_delay() {
|
||||
let session = StreamSession {
|
||||
id: StreamSessionId::new("stream_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
agent_id: None,
|
||||
operation_id: OperationId::new("op_01"),
|
||||
protocol: Protocol::Rest,
|
||||
mode: ExecutionMode::Session,
|
||||
status: StreamStatus::Running,
|
||||
cursor: None,
|
||||
state: json!({"cursor":"abc"}),
|
||||
expires_at: timestamp("2026-04-06T12:05:00Z"),
|
||||
last_poll_at: None,
|
||||
created_at: timestamp("2026-04-06T12:00:00Z"),
|
||||
closed_at: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
session.remaining_poll_delay_ms(timestamp("2026-04-06T12:00:00.100Z"), 250),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rapid_repeat_poll_reports_remaining_delay() {
|
||||
let session = StreamSession {
|
||||
id: StreamSessionId::new("stream_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
agent_id: None,
|
||||
operation_id: OperationId::new("op_01"),
|
||||
protocol: Protocol::Rest,
|
||||
mode: ExecutionMode::Session,
|
||||
status: StreamStatus::Running,
|
||||
cursor: None,
|
||||
state: json!({"cursor":"abc"}),
|
||||
expires_at: timestamp("2026-04-06T12:05:00Z"),
|
||||
last_poll_at: Some(timestamp("2026-04-06T12:01:00Z")),
|
||||
created_at: timestamp("2026-04-06T12:00:00Z"),
|
||||
closed_at: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
session.remaining_poll_delay_ms(timestamp("2026-04-06T12:01:00.100Z"), 250),
|
||||
150
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_job_tracks_finish_and_failure() {
|
||||
let mut job = AsyncJobHandle {
|
||||
id: AsyncJobId::new("job_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
agent_id: None,
|
||||
operation_id: OperationId::new("op_01"),
|
||||
status: JobStatus::Running,
|
||||
progress: json!({"percent": 60}),
|
||||
result: None,
|
||||
error: None,
|
||||
expires_at: Some(timestamp("2026-04-06T12:05:00Z")),
|
||||
last_poll_at: None,
|
||||
created_at: timestamp("2026-04-06T12:00:00Z"),
|
||||
updated_at: timestamp("2026-04-06T12:00:00Z"),
|
||||
finished_at: None,
|
||||
};
|
||||
|
||||
assert!(job.can_cancel());
|
||||
|
||||
job.mark_finished(timestamp("2026-04-06T12:01:00Z"), json!({"ok": true}));
|
||||
assert!(job.is_finished());
|
||||
assert_eq!(job.status, JobStatus::Completed);
|
||||
|
||||
let mut failed_job = job.clone();
|
||||
failed_job.status = JobStatus::Running;
|
||||
failed_job.result = None;
|
||||
failed_job.finished_at = None;
|
||||
|
||||
failed_job.mark_failed(
|
||||
timestamp("2026-04-06T12:02:00Z"),
|
||||
json!({"message": "boom"}),
|
||||
);
|
||||
assert_eq!(failed_job.status, JobStatus::Failed);
|
||||
assert_eq!(
|
||||
failed_job.finished_at,
|
||||
Some(timestamp("2026-04-06T12:02:00Z"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_job_reports_remaining_poll_delay() {
|
||||
let job = AsyncJobHandle {
|
||||
id: AsyncJobId::new("job_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
agent_id: None,
|
||||
operation_id: OperationId::new("op_01"),
|
||||
status: JobStatus::Running,
|
||||
progress: json!({"percent": 60}),
|
||||
result: None,
|
||||
error: None,
|
||||
expires_at: Some(timestamp("2026-04-06T12:05:00Z")),
|
||||
last_poll_at: Some(timestamp("2026-04-06T12:01:00Z")),
|
||||
created_at: timestamp("2026-04-06T12:00:00Z"),
|
||||
updated_at: timestamp("2026-04-06T12:00:00Z"),
|
||||
finished_at: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
job.remaining_poll_delay_ms(timestamp("2026-04-06T12:01:00.100Z"), 250),
|
||||
150
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::protocol::Protocol;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExecutionMode {
|
||||
Unary,
|
||||
Window,
|
||||
Session,
|
||||
AsyncJob,
|
||||
}
|
||||
|
||||
impl ExecutionMode {
|
||||
pub fn is_stateful(self) -> bool {
|
||||
matches!(self, Self::Session | Self::AsyncJob)
|
||||
}
|
||||
|
||||
pub fn requires_tool_family(self) -> bool {
|
||||
self.is_stateful()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AggregationMode {
|
||||
RawItems,
|
||||
SummaryOnly,
|
||||
SummaryPlusSamples,
|
||||
Stats,
|
||||
LatestState,
|
||||
}
|
||||
|
||||
impl AggregationMode {
|
||||
pub fn needs_items(self) -> bool {
|
||||
matches!(self, Self::RawItems | Self::SummaryPlusSamples)
|
||||
}
|
||||
|
||||
pub fn needs_summary(self) -> bool {
|
||||
!matches!(self, Self::RawItems)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TransportBehavior {
|
||||
RequestResponse,
|
||||
ServerStream,
|
||||
StatefulSession,
|
||||
DeferredResult,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct ToolFamilyConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub start_tool_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub poll_tool_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop_tool_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status_tool_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result_tool_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cancel_tool_name: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolFamilyConfig {
|
||||
pub fn validate_for_mode(&self, mode: ExecutionMode) -> Result<(), StreamingConfigError> {
|
||||
match mode {
|
||||
ExecutionMode::Unary | ExecutionMode::Window => {
|
||||
if self.start_tool_name.is_some()
|
||||
|| self.poll_tool_name.is_some()
|
||||
|| self.stop_tool_name.is_some()
|
||||
|| self.status_tool_name.is_some()
|
||||
|| self.result_tool_name.is_some()
|
||||
|| self.cancel_tool_name.is_some()
|
||||
{
|
||||
return Err(StreamingConfigError::UnexpectedToolFamily(mode));
|
||||
}
|
||||
}
|
||||
ExecutionMode::Session => {
|
||||
if self.start_tool_name.is_none()
|
||||
|| self.poll_tool_name.is_none()
|
||||
|| self.stop_tool_name.is_none()
|
||||
{
|
||||
return Err(StreamingConfigError::MissingSessionToolNames);
|
||||
}
|
||||
}
|
||||
ExecutionMode::AsyncJob => {
|
||||
if self.start_tool_name.is_none()
|
||||
|| self.status_tool_name.is_none()
|
||||
|| self.result_tool_name.is_none()
|
||||
|| self.cancel_tool_name.is_none()
|
||||
{
|
||||
return Err(StreamingConfigError::MissingAsyncJobToolNames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StreamingConfig {
|
||||
pub mode: ExecutionMode,
|
||||
pub transport_behavior: TransportBehavior,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub window_duration_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub poll_interval_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_timeout_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub idle_timeout_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_session_lifetime_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_items: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_bytes: Option<u32>,
|
||||
pub aggregation_mode: AggregationMode,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub items_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub done_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub redacted_paths: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub truncate_item_fields: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_field_length: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub drop_duplicates: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sampling_rate: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub tool_family: ToolFamilyConfig,
|
||||
}
|
||||
|
||||
impl StreamingConfig {
|
||||
pub fn validate_common(&self) -> Result<(), StreamingConfigError> {
|
||||
self.tool_family.validate_for_mode(self.mode)?;
|
||||
|
||||
if self.max_items.is_some_and(|value| value == 0) {
|
||||
return Err(StreamingConfigError::InvalidMaxItems);
|
||||
}
|
||||
|
||||
if self.max_bytes.is_some_and(|value| value == 0) {
|
||||
return Err(StreamingConfigError::InvalidMaxBytes);
|
||||
}
|
||||
|
||||
if self.max_field_length.is_some_and(|value| value == 0) {
|
||||
return Err(StreamingConfigError::InvalidMaxFieldLength);
|
||||
}
|
||||
|
||||
if self
|
||||
.sampling_rate
|
||||
.is_some_and(|value| value <= 0.0 || value > 1.0)
|
||||
{
|
||||
return Err(StreamingConfigError::InvalidSamplingRate);
|
||||
}
|
||||
|
||||
match self.mode {
|
||||
ExecutionMode::Unary => {
|
||||
if self.window_duration_ms.is_some()
|
||||
|| self.poll_interval_ms.is_some()
|
||||
|| self.idle_timeout_ms.is_some()
|
||||
|| self.max_session_lifetime_ms.is_some()
|
||||
{
|
||||
return Err(StreamingConfigError::UnexpectedStatefulLimits(
|
||||
ExecutionMode::Unary,
|
||||
));
|
||||
}
|
||||
}
|
||||
ExecutionMode::Window => {
|
||||
if self.window_duration_ms.is_none() {
|
||||
return Err(StreamingConfigError::MissingWindowDuration);
|
||||
}
|
||||
}
|
||||
ExecutionMode::Session => {
|
||||
if self.poll_interval_ms.is_none() || self.max_session_lifetime_ms.is_none() {
|
||||
return Err(StreamingConfigError::MissingSessionLimits);
|
||||
}
|
||||
}
|
||||
ExecutionMode::AsyncJob => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_for_protocol(&self, protocol: Protocol) -> Result<(), StreamingConfigError> {
|
||||
self.validate_common()?;
|
||||
|
||||
if !protocol.supports_execution_mode(self.mode) {
|
||||
return Err(StreamingConfigError::UnsupportedExecutionMode {
|
||||
protocol,
|
||||
mode: self.mode,
|
||||
});
|
||||
}
|
||||
|
||||
if !protocol.supports_transport_behavior(self.transport_behavior) {
|
||||
return Err(StreamingConfigError::UnsupportedTransportBehavior {
|
||||
protocol,
|
||||
behavior: self.transport_behavior,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq)]
|
||||
pub enum StreamingConfigError {
|
||||
#[error("window mode requires window_duration_ms")]
|
||||
MissingWindowDuration,
|
||||
#[error("session mode requires poll_interval_ms and max_session_lifetime_ms")]
|
||||
MissingSessionLimits,
|
||||
#[error("max_items must be greater than zero")]
|
||||
InvalidMaxItems,
|
||||
#[error("max_bytes must be greater than zero")]
|
||||
InvalidMaxBytes,
|
||||
#[error("max_field_length must be greater than zero")]
|
||||
InvalidMaxFieldLength,
|
||||
#[error("sampling_rate must be in range (0, 1]")]
|
||||
InvalidSamplingRate,
|
||||
#[error("{0:?} mode cannot use session/window-only limits")]
|
||||
UnexpectedStatefulLimits(ExecutionMode),
|
||||
#[error("{mode:?} is not supported for protocol {protocol:?}")]
|
||||
UnsupportedExecutionMode {
|
||||
protocol: Protocol,
|
||||
mode: ExecutionMode,
|
||||
},
|
||||
#[error("{behavior:?} is not supported for protocol {protocol:?}")]
|
||||
UnsupportedTransportBehavior {
|
||||
protocol: Protocol,
|
||||
behavior: TransportBehavior,
|
||||
},
|
||||
#[error("session mode requires start, poll and stop tool names")]
|
||||
MissingSessionToolNames,
|
||||
#[error("async_job mode requires start, status, result and cancel tool names")]
|
||||
MissingAsyncJobToolNames,
|
||||
#[error("{0:?} mode cannot define tool-family names")]
|
||||
UnexpectedToolFamily(ExecutionMode),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
AggregationMode, ExecutionMode, StreamingConfig, StreamingConfigError, ToolFamilyConfig,
|
||||
TransportBehavior,
|
||||
};
|
||||
use crate::protocol::Protocol;
|
||||
|
||||
fn session_config() -> StreamingConfig {
|
||||
StreamingConfig {
|
||||
mode: ExecutionMode::Session,
|
||||
transport_behavior: TransportBehavior::StatefulSession,
|
||||
window_duration_ms: None,
|
||||
poll_interval_ms: Some(1_000),
|
||||
upstream_timeout_ms: Some(5_000),
|
||||
idle_timeout_ms: Some(15_000),
|
||||
max_session_lifetime_ms: Some(60_000),
|
||||
max_items: Some(100),
|
||||
max_bytes: Some(65_536),
|
||||
aggregation_mode: AggregationMode::SummaryPlusSamples,
|
||||
summary_path: Some("$.summary".to_owned()),
|
||||
items_path: Some("$.items".to_owned()),
|
||||
cursor_path: Some("$.cursor".to_owned()),
|
||||
status_path: Some("$.status".to_owned()),
|
||||
done_path: Some("$.done".to_owned()),
|
||||
redacted_paths: vec!["$.items[*].token".to_owned()],
|
||||
truncate_item_fields: true,
|
||||
max_field_length: Some(256),
|
||||
drop_duplicates: true,
|
||||
sampling_rate: Some(0.5),
|
||||
tool_family: ToolFamilyConfig {
|
||||
start_tool_name: Some("logs_start".to_owned()),
|
||||
poll_tool_name: Some("logs_poll".to_owned()),
|
||||
stop_tool_name: Some("logs_stop".to_owned()),
|
||||
status_tool_name: None,
|
||||
result_tool_name: None,
|
||||
cancel_tool_name: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_config_roundtrips_through_json() {
|
||||
let config = session_config();
|
||||
|
||||
let value = serde_json::to_value(&config).unwrap();
|
||||
let decoded: StreamingConfig = serde_json::from_value(value.clone()).unwrap();
|
||||
|
||||
assert_eq!(decoded, config);
|
||||
assert_eq!(value["mode"], json!("session"));
|
||||
assert_eq!(value["tool_family"]["start_tool_name"], json!("logs_start"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unary_mode_rejects_session_specific_fields() {
|
||||
let config = StreamingConfig {
|
||||
mode: ExecutionMode::Unary,
|
||||
transport_behavior: TransportBehavior::RequestResponse,
|
||||
window_duration_ms: None,
|
||||
poll_interval_ms: Some(1_000),
|
||||
upstream_timeout_ms: None,
|
||||
idle_timeout_ms: None,
|
||||
max_session_lifetime_ms: None,
|
||||
max_items: None,
|
||||
max_bytes: None,
|
||||
aggregation_mode: AggregationMode::SummaryOnly,
|
||||
summary_path: None,
|
||||
items_path: None,
|
||||
cursor_path: None,
|
||||
status_path: None,
|
||||
done_path: None,
|
||||
redacted_paths: Vec::new(),
|
||||
truncate_item_fields: false,
|
||||
max_field_length: None,
|
||||
drop_duplicates: false,
|
||||
sampling_rate: None,
|
||||
tool_family: ToolFamilyConfig::default(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
config.validate_common(),
|
||||
Err(StreamingConfigError::UnexpectedStatefulLimits(
|
||||
ExecutionMode::Unary
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_validation_rejects_graphql_session() {
|
||||
let config = session_config();
|
||||
|
||||
assert_eq!(
|
||||
config.validate_for_protocol(Protocol::Graphql),
|
||||
Err(StreamingConfigError::UnsupportedExecutionMode {
|
||||
protocol: Protocol::Graphql,
|
||||
mode: ExecutionMode::Session,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_validation_accepts_rest_session() {
|
||||
let config = session_config();
|
||||
|
||||
assert!(config.validate_for_protocol(Protocol::Rest).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::ids::WorkspaceId;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceStatus {
|
||||
Active,
|
||||
Archived,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Workspace {
|
||||
pub id: WorkspaceId,
|
||||
pub slug: String,
|
||||
pub display_name: String,
|
||||
pub status: WorkspaceStatus,
|
||||
pub settings: Value,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use super::{Workspace, WorkspaceStatus};
|
||||
use crate::ids::WorkspaceId;
|
||||
|
||||
#[test]
|
||||
fn workspace_serializes_timestamps_as_rfc3339() {
|
||||
let workspace = Workspace {
|
||||
id: WorkspaceId::new("ws_01"),
|
||||
slug: "primary".to_owned(),
|
||||
display_name: "Primary".to_owned(),
|
||||
status: WorkspaceStatus::Active,
|
||||
settings: json!({"region":"eu"}),
|
||||
created_at: OffsetDateTime::parse("2026-03-25T12:00:00Z", &Rfc3339).unwrap(),
|
||||
updated_at: OffsetDateTime::parse("2026-03-25T12:05:00Z", &Rfc3339).unwrap(),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&workspace).unwrap();
|
||||
|
||||
assert_eq!(value["created_at"], json!("2026-03-25T12:00:00Z"));
|
||||
assert_eq!(value["updated_at"], json!("2026-03-25T12:05:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_deserializes_timestamps_from_rfc3339() {
|
||||
let workspace: Workspace = serde_json::from_value(json!({
|
||||
"id": "ws_01",
|
||||
"slug": "primary",
|
||||
"display_name": "Primary",
|
||||
"status": "active",
|
||||
"settings": {"region":"eu"},
|
||||
"created_at": "2026-03-25T12:00:00Z",
|
||||
"updated_at": "2026-03-25T12:05:00Z"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
workspace.created_at,
|
||||
OffsetDateTime::parse("2026-03-25T12:00:00Z", &Rfc3339).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
workspace.updated_at,
|
||||
OffsetDateTime::parse("2026-03-25T12:05:00Z", &Rfc3339).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user