use argentor_core::{ArgentorError, ArgentorResult};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{info, warn};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VersionStatus {
Active,
Inactive,
Testing,
Deprecated,
}
impl std::fmt::Display for VersionStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Active => write!(f, "active"),
Self::Inactive => write!(f, "inactive"),
Self::Testing => write!(f, "testing"),
Self::Deprecated => write!(f, "deprecated"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentVersionConfig {
pub agent_id: String,
pub version: u32,
pub model_id: String,
pub system_prompt: String,
pub temperature: f32,
pub max_tokens: u32,
pub tools: Vec<String>,
pub guardrails: Vec<String>,
pub metadata: HashMap<String, String>,
pub created_at: DateTime<Utc>,
pub created_by: String,
pub status: VersionStatus,
pub change_log: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrafficSplit {
pub primary_version: u32,
pub primary_weight: f32,
pub canary_version: Option<u32>,
pub canary_weight: f32,
}
impl TrafficSplit {
pub fn resolve(&self) -> u32 {
match self.canary_version {
None => self.primary_version,
Some(canary) => {
let nanos = Utc::now().timestamp_subsec_nanos();
let rand_val = (nanos % 1000) as f32 / 1000.0;
if rand_val < self.primary_weight {
self.primary_version
} else {
canary
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldChange {
pub field: String,
pub old_value: String,
pub new_value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionDiff {
pub from_version: u32,
pub to_version: u32,
pub changes: Vec<FieldChange>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeploymentAction {
Deploy,
Rollback,
TrafficSplit,
Deprecate,
}
impl std::fmt::Display for DeploymentAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Deploy => write!(f, "deploy"),
Self::Rollback => write!(f, "rollback"),
Self::TrafficSplit => write!(f, "traffic_split"),
Self::Deprecate => write!(f, "deprecate"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeploymentEvent {
pub agent_id: String,
pub version: u32,
pub action: DeploymentAction,
pub actor: String,
pub timestamp: DateTime<Utc>,
pub details: String,
}
#[derive(Debug, Clone)]
pub struct DeploymentHistory {
events: Vec<DeploymentEvent>,
}
impl DeploymentHistory {
fn new() -> Self {
Self { events: Vec::new() }
}
pub fn record_deployment(
&mut self,
agent_id: &str,
version: u32,
action: DeploymentAction,
actor: &str,
details: &str,
) {
self.events.push(DeploymentEvent {
agent_id: agent_id.to_string(),
version,
action,
actor: actor.to_string(),
timestamp: Utc::now(),
details: details.to_string(),
});
}
pub fn get_history(&self, agent_id: &str) -> Vec<DeploymentEvent> {
self.events
.iter()
.filter(|e| e.agent_id == agent_id)
.cloned()
.collect()
}
pub fn get_all(&self) -> &[DeploymentEvent] {
&self.events
}
}
impl Default for DeploymentHistory {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
struct AgentVersionState {
versions: Vec<AgentVersionConfig>,
active_version: Option<u32>,
traffic_split: Option<TrafficSplit>,
}
impl AgentVersionState {
fn new() -> Self {
Self {
versions: Vec::new(),
active_version: None,
traffic_split: None,
}
}
fn next_version(&self) -> u32 {
self.versions.last().map_or(1, |v| v.version + 1)
}
fn get_version(&self, version: u32) -> Option<&AgentVersionConfig> {
self.versions.iter().find(|v| v.version == version)
}
fn get_version_mut(&mut self, version: u32) -> Option<&mut AgentVersionConfig> {
self.versions.iter_mut().find(|v| v.version == version)
}
}
#[derive(Clone)]
pub struct AgentVersionManager {
state: Arc<RwLock<HashMap<String, AgentVersionState>>>,
history: Arc<RwLock<DeploymentHistory>>,
}
impl AgentVersionManager {
pub fn new() -> Self {
Self {
state: Arc::new(RwLock::new(HashMap::new())),
history: Arc::new(RwLock::new(DeploymentHistory::new())),
}
}
pub async fn deploy(
&self,
agent_id: &str,
mut config: AgentVersionConfig,
) -> ArgentorResult<u32> {
let mut state = self.state.write().await;
let agent_state = state
.entry(agent_id.to_string())
.or_insert_with(AgentVersionState::new);
let version = agent_state.next_version();
config.version = version;
config.agent_id = agent_id.to_string();
config.status = VersionStatus::Active;
config.created_at = Utc::now();
if let Some(prev_version) = agent_state.active_version {
if let Some(prev) = agent_state.get_version_mut(prev_version) {
prev.status = VersionStatus::Inactive;
}
}
info!(
agent_id = agent_id,
version = version,
model_id = %config.model_id,
"Deploying agent version"
);
agent_state.versions.push(config);
agent_state.active_version = Some(version);
let mut history = self.history.write().await;
history.record_deployment(
agent_id,
version,
DeploymentAction::Deploy,
"system",
&format!("Deployed version {version}"),
);
Ok(version)
}
pub async fn rollback(&self, agent_id: &str) -> ArgentorResult<u32> {
let mut state = self.state.write().await;
let agent_state = state
.get_mut(agent_id)
.ok_or_else(|| ArgentorError::Orchestrator(format!("Agent '{agent_id}' not found")))?;
let active = agent_state.active_version.ok_or_else(|| {
ArgentorError::Orchestrator(format!("Agent '{agent_id}' has no active version"))
})?;
if active <= 1 {
return Err(ArgentorError::Orchestrator(format!(
"Agent '{agent_id}' has no previous version to roll back to"
)));
}
let previous = active - 1;
self.do_rollback(agent_state, agent_id, previous).await?;
let mut history = self.history.write().await;
history.record_deployment(
agent_id,
previous,
DeploymentAction::Rollback,
"system",
&format!("Rolled back from version {active} to {previous}"),
);
Ok(previous)
}
pub async fn rollback_to(&self, agent_id: &str, version: u32) -> ArgentorResult<u32> {
let mut state = self.state.write().await;
let agent_state = state
.get_mut(agent_id)
.ok_or_else(|| ArgentorError::Orchestrator(format!("Agent '{agent_id}' not found")))?;
let active = agent_state.active_version;
if agent_state.get_version(version).is_none() {
return Err(ArgentorError::Orchestrator(format!(
"Version {version} not found for agent '{agent_id}'"
)));
}
self.do_rollback(agent_state, agent_id, version).await?;
let mut history = self.history.write().await;
history.record_deployment(
agent_id,
version,
DeploymentAction::Rollback,
"system",
&format!(
"Rolled back from version {} to {version}",
active.map_or("none".to_string(), |v| v.to_string())
),
);
Ok(version)
}
async fn do_rollback(
&self,
agent_state: &mut AgentVersionState,
agent_id: &str,
target: u32,
) -> ArgentorResult<()> {
if let Some(current_active) = agent_state.active_version {
if let Some(current) = agent_state.get_version_mut(current_active) {
current.status = VersionStatus::Inactive;
}
}
let target_config = agent_state.get_version_mut(target).ok_or_else(|| {
ArgentorError::Orchestrator(format!(
"Version {target} not found for agent '{agent_id}'"
))
})?;
target_config.status = VersionStatus::Active;
agent_state.active_version = Some(target);
warn!(
agent_id = agent_id,
target_version = target,
"Rolled back agent version"
);
Ok(())
}
pub async fn get_active(&self, agent_id: &str) -> Option<AgentVersionConfig> {
let state = self.state.read().await;
let agent_state = state.get(agent_id)?;
let active = agent_state.active_version?;
agent_state.get_version(active).cloned()
}
pub async fn get_version(&self, agent_id: &str, version: u32) -> Option<AgentVersionConfig> {
let state = self.state.read().await;
let agent_state = state.get(agent_id)?;
agent_state.get_version(version).cloned()
}
pub async fn list_versions(&self, agent_id: &str) -> Vec<AgentVersionConfig> {
let state = self.state.read().await;
state
.get(agent_id)
.map(|s| s.versions.clone())
.unwrap_or_default()
}
pub async fn set_traffic_split(
&self,
agent_id: &str,
split: TrafficSplit,
) -> ArgentorResult<()> {
let total = split.primary_weight + split.canary_weight;
if (total - 1.0).abs() > 0.01 {
return Err(ArgentorError::Orchestrator(format!(
"Traffic split weights must sum to 1.0, got {total:.2}"
)));
}
if split.primary_weight < 0.0 || split.canary_weight < 0.0 {
return Err(ArgentorError::Orchestrator(
"Traffic split weights must be non-negative".to_string(),
));
}
let mut state = self.state.write().await;
let agent_state = state
.get_mut(agent_id)
.ok_or_else(|| ArgentorError::Orchestrator(format!("Agent '{agent_id}' not found")))?;
if agent_state.get_version(split.primary_version).is_none() {
return Err(ArgentorError::Orchestrator(format!(
"Primary version {} not found for agent '{agent_id}'",
split.primary_version
)));
}
if let Some(canary) = split.canary_version {
if agent_state.get_version(canary).is_none() {
return Err(ArgentorError::Orchestrator(format!(
"Canary version {canary} not found for agent '{agent_id}'"
)));
}
}
info!(
agent_id = agent_id,
primary_version = split.primary_version,
primary_weight = split.primary_weight,
canary_version = ?split.canary_version,
canary_weight = split.canary_weight,
"Setting traffic split"
);
agent_state.traffic_split = Some(split.clone());
let mut history = self.history.write().await;
history.record_deployment(
agent_id,
split.primary_version,
DeploymentAction::TrafficSplit,
"system",
&format!(
"Traffic split: v{} ({:.0}%) / v{} ({:.0}%)",
split.primary_version,
split.primary_weight * 100.0,
split
.canary_version
.map_or("none".to_string(), |v| v.to_string()),
split.canary_weight * 100.0,
),
);
Ok(())
}
pub async fn get_traffic_split(&self, agent_id: &str) -> Option<TrafficSplit> {
let state = self.state.read().await;
state.get(agent_id)?.traffic_split.clone()
}
pub async fn diff_versions(
&self,
agent_id: &str,
from_version: u32,
to_version: u32,
) -> ArgentorResult<VersionDiff> {
let state = self.state.read().await;
let agent_state = state
.get(agent_id)
.ok_or_else(|| ArgentorError::Orchestrator(format!("Agent '{agent_id}' not found")))?;
let from = agent_state.get_version(from_version).ok_or_else(|| {
ArgentorError::Orchestrator(format!(
"Version {from_version} not found for agent '{agent_id}'"
))
})?;
let to = agent_state.get_version(to_version).ok_or_else(|| {
ArgentorError::Orchestrator(format!(
"Version {to_version} not found for agent '{agent_id}'"
))
})?;
let mut changes = Vec::new();
if from.model_id != to.model_id {
changes.push(FieldChange {
field: "model_id".to_string(),
old_value: from.model_id.clone(),
new_value: to.model_id.clone(),
});
}
if from.system_prompt != to.system_prompt {
changes.push(FieldChange {
field: "system_prompt".to_string(),
old_value: from.system_prompt.clone(),
new_value: to.system_prompt.clone(),
});
}
if (from.temperature - to.temperature).abs() > f32::EPSILON {
changes.push(FieldChange {
field: "temperature".to_string(),
old_value: from.temperature.to_string(),
new_value: to.temperature.to_string(),
});
}
if from.max_tokens != to.max_tokens {
changes.push(FieldChange {
field: "max_tokens".to_string(),
old_value: from.max_tokens.to_string(),
new_value: to.max_tokens.to_string(),
});
}
if from.tools != to.tools {
changes.push(FieldChange {
field: "tools".to_string(),
old_value: format!("{:?}", from.tools),
new_value: format!("{:?}", to.tools),
});
}
if from.guardrails != to.guardrails {
changes.push(FieldChange {
field: "guardrails".to_string(),
old_value: format!("{:?}", from.guardrails),
new_value: format!("{:?}", to.guardrails),
});
}
if from.created_by != to.created_by {
changes.push(FieldChange {
field: "created_by".to_string(),
old_value: from.created_by.clone(),
new_value: to.created_by.clone(),
});
}
Ok(VersionDiff {
from_version,
to_version,
changes,
})
}
pub async fn deprecate_version(&self, agent_id: &str, version: u32) -> ArgentorResult<()> {
let mut state = self.state.write().await;
let agent_state = state
.get_mut(agent_id)
.ok_or_else(|| ArgentorError::Orchestrator(format!("Agent '{agent_id}' not found")))?;
let config = agent_state.get_version_mut(version).ok_or_else(|| {
ArgentorError::Orchestrator(format!(
"Version {version} not found for agent '{agent_id}'"
))
})?;
if config.status == VersionStatus::Active {
return Err(ArgentorError::Orchestrator(format!(
"Cannot deprecate active version {version} of agent '{agent_id}'"
)));
}
config.status = VersionStatus::Deprecated;
info!(
agent_id = agent_id,
version = version,
"Deprecated agent version"
);
let mut history = self.history.write().await;
history.record_deployment(
agent_id,
version,
DeploymentAction::Deprecate,
"system",
&format!("Deprecated version {version}"),
);
Ok(())
}
pub async fn get_deployment_history(&self, agent_id: &str) -> Vec<DeploymentEvent> {
let history = self.history.read().await;
history.get_history(agent_id)
}
pub async fn get_all_history(&self) -> Vec<DeploymentEvent> {
let history = self.history.read().await;
history.get_all().to_vec()
}
pub async fn agent_count(&self) -> usize {
let state = self.state.read().await;
state.len()
}
pub async fn total_version_count(&self) -> usize {
let state = self.state.read().await;
state.values().map(|s| s.versions.len()).sum()
}
}
impl Default for AgentVersionManager {
fn default() -> Self {
Self::new()
}
}
pub fn test_version_config(model_id: &str, change_log: &str) -> AgentVersionConfig {
AgentVersionConfig {
agent_id: String::new(),
version: 0,
model_id: model_id.to_string(),
system_prompt: "You are a helpful assistant.".to_string(),
temperature: 0.7,
max_tokens: 4096,
tools: vec!["memory_search".to_string(), "file_read".to_string()],
guardrails: vec!["no-pii".to_string()],
metadata: HashMap::new(),
created_at: Utc::now(),
created_by: "test-user".to_string(),
status: VersionStatus::Inactive,
change_log: change_log.to_string(),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn make_config(model_id: &str, change_log: &str) -> AgentVersionConfig {
test_version_config(model_id, change_log)
}
#[allow(clippy::too_many_arguments)]
fn make_config_with_details(
model_id: &str,
prompt: &str,
temp: f32,
max_tokens: u32,
tools: Vec<&str>,
guardrails: Vec<&str>,
created_by: &str,
change_log: &str,
) -> AgentVersionConfig {
AgentVersionConfig {
agent_id: String::new(),
version: 0,
model_id: model_id.to_string(),
system_prompt: prompt.to_string(),
temperature: temp,
max_tokens,
tools: tools.into_iter().map(String::from).collect(),
guardrails: guardrails.into_iter().map(String::from).collect(),
metadata: HashMap::new(),
created_at: Utc::now(),
created_by: created_by.to_string(),
status: VersionStatus::Inactive,
change_log: change_log.to_string(),
}
}
#[tokio::test]
async fn test_deploy_creates_version_1() {
let mgr = AgentVersionManager::new();
let v = mgr
.deploy("agent-a", make_config("claude-sonnet", "Initial"))
.await
.unwrap();
assert_eq!(v, 1);
}
#[tokio::test]
async fn test_deploy_increments_version_numbers() {
let mgr = AgentVersionManager::new();
let v1 = mgr
.deploy("agent-a", make_config("model-1", "v1"))
.await
.unwrap();
let v2 = mgr
.deploy("agent-a", make_config("model-2", "v2"))
.await
.unwrap();
let v3 = mgr
.deploy("agent-a", make_config("model-3", "v3"))
.await
.unwrap();
assert_eq!(v1, 1);
assert_eq!(v2, 2);
assert_eq!(v3, 3);
}
#[tokio::test]
async fn test_deploy_activates_new_deactivates_old() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("model-1", "v1"))
.await
.unwrap();
mgr.deploy("agent-a", make_config("model-2", "v2"))
.await
.unwrap();
let v1 = mgr.get_version("agent-a", 1).await.unwrap();
assert_eq!(v1.status, VersionStatus::Inactive);
let v2 = mgr.get_version("agent-a", 2).await.unwrap();
assert_eq!(v2.status, VersionStatus::Active);
}
#[tokio::test]
async fn test_get_active_returns_current() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("model-1", "v1"))
.await
.unwrap();
mgr.deploy("agent-a", make_config("model-2", "v2"))
.await
.unwrap();
let active = mgr.get_active("agent-a").await.unwrap();
assert_eq!(active.version, 2);
assert_eq!(active.model_id, "model-2");
assert_eq!(active.status, VersionStatus::Active);
}
#[tokio::test]
async fn test_get_active_returns_none_for_unknown() {
let mgr = AgentVersionManager::new();
assert!(mgr.get_active("nonexistent").await.is_none());
}
#[tokio::test]
async fn test_get_version_returns_specific() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("model-1", "first"))
.await
.unwrap();
mgr.deploy("agent-a", make_config("model-2", "second"))
.await
.unwrap();
let v1 = mgr.get_version("agent-a", 1).await.unwrap();
assert_eq!(v1.model_id, "model-1");
assert_eq!(v1.change_log, "first");
let v2 = mgr.get_version("agent-a", 2).await.unwrap();
assert_eq!(v2.model_id, "model-2");
assert_eq!(v2.change_log, "second");
}
#[tokio::test]
async fn test_get_version_returns_none_for_missing() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("model-1", "v1"))
.await
.unwrap();
assert!(mgr.get_version("agent-a", 99).await.is_none());
}
#[tokio::test]
async fn test_list_versions_returns_all() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("m1", "v1"))
.await
.unwrap();
mgr.deploy("agent-a", make_config("m2", "v2"))
.await
.unwrap();
mgr.deploy("agent-a", make_config("m3", "v3"))
.await
.unwrap();
let versions = mgr.list_versions("agent-a").await;
assert_eq!(versions.len(), 3);
assert_eq!(versions[0].version, 1);
assert_eq!(versions[1].version, 2);
assert_eq!(versions[2].version, 3);
}
#[tokio::test]
async fn test_list_versions_empty_for_unknown() {
let mgr = AgentVersionManager::new();
let versions = mgr.list_versions("nonexistent").await;
assert!(versions.is_empty());
}
#[tokio::test]
async fn test_rollback_reverts_to_previous() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("m1", "v1"))
.await
.unwrap();
mgr.deploy("agent-a", make_config("m2", "v2"))
.await
.unwrap();
let rolled = mgr.rollback("agent-a").await.unwrap();
assert_eq!(rolled, 1);
let active = mgr.get_active("agent-a").await.unwrap();
assert_eq!(active.version, 1);
assert_eq!(active.status, VersionStatus::Active);
let v2 = mgr.get_version("agent-a", 2).await.unwrap();
assert_eq!(v2.status, VersionStatus::Inactive);
}
#[tokio::test]
async fn test_rollback_fails_at_version_1() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("m1", "v1"))
.await
.unwrap();
let result = mgr.rollback("agent-a").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_rollback_fails_for_unknown_agent() {
let mgr = AgentVersionManager::new();
let result = mgr.rollback("nonexistent").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_rollback_to_specific_version() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("m1", "v1"))
.await
.unwrap();
mgr.deploy("agent-a", make_config("m2", "v2"))
.await
.unwrap();
mgr.deploy("agent-a", make_config("m3", "v3"))
.await
.unwrap();
let rolled = mgr.rollback_to("agent-a", 1).await.unwrap();
assert_eq!(rolled, 1);
let active = mgr.get_active("agent-a").await.unwrap();
assert_eq!(active.version, 1);
let v3 = mgr.get_version("agent-a", 3).await.unwrap();
assert_eq!(v3.status, VersionStatus::Inactive);
}
#[tokio::test]
async fn test_rollback_to_nonexistent_version_fails() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("m1", "v1"))
.await
.unwrap();
let result = mgr.rollback_to("agent-a", 99).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_set_and_get_traffic_split() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("m1", "v1"))
.await
.unwrap();
mgr.deploy("agent-a", make_config("m2", "v2"))
.await
.unwrap();
let split = TrafficSplit {
primary_version: 1,
primary_weight: 0.8,
canary_version: Some(2),
canary_weight: 0.2,
};
mgr.set_traffic_split("agent-a", split).await.unwrap();
let fetched = mgr.get_traffic_split("agent-a").await.unwrap();
assert_eq!(fetched.primary_version, 1);
assert!((fetched.primary_weight - 0.8).abs() < 0.001);
assert_eq!(fetched.canary_version, Some(2));
assert!((fetched.canary_weight - 0.2).abs() < 0.001);
}
#[tokio::test]
async fn test_traffic_split_invalid_weights_rejected() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("m1", "v1"))
.await
.unwrap();
mgr.deploy("agent-a", make_config("m2", "v2"))
.await
.unwrap();
let split = TrafficSplit {
primary_version: 1,
primary_weight: 0.5,
canary_version: Some(2),
canary_weight: 0.3, };
let result = mgr.set_traffic_split("agent-a", split).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_traffic_split_negative_weights_rejected() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("m1", "v1"))
.await
.unwrap();
let split = TrafficSplit {
primary_version: 1,
primary_weight: 1.5,
canary_version: None,
canary_weight: -0.5,
};
let result = mgr.set_traffic_split("agent-a", split).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_traffic_split_nonexistent_version_rejected() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("m1", "v1"))
.await
.unwrap();
let split = TrafficSplit {
primary_version: 1,
primary_weight: 0.8,
canary_version: Some(99),
canary_weight: 0.2,
};
let result = mgr.set_traffic_split("agent-a", split).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_traffic_split_resolve_no_canary() {
let split = TrafficSplit {
primary_version: 1,
primary_weight: 1.0,
canary_version: None,
canary_weight: 0.0,
};
for _ in 0..100 {
assert_eq!(split.resolve(), 1);
}
}
#[tokio::test]
async fn test_traffic_split_resolve_with_canary() {
let split = TrafficSplit {
primary_version: 1,
primary_weight: 0.5,
canary_version: Some(2),
canary_weight: 0.5,
};
let result = split.resolve();
assert!(result == 1 || result == 2);
}
#[tokio::test]
async fn test_get_traffic_split_returns_none() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("m1", "v1"))
.await
.unwrap();
assert!(mgr.get_traffic_split("agent-a").await.is_none());
}
#[tokio::test]
async fn test_diff_detects_model_change() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("model-old", "v1"))
.await
.unwrap();
mgr.deploy("agent-a", make_config("model-new", "v2"))
.await
.unwrap();
let diff = mgr.diff_versions("agent-a", 1, 2).await.unwrap();
assert_eq!(diff.from_version, 1);
assert_eq!(diff.to_version, 2);
let model_change = diff
.changes
.iter()
.find(|c| c.field == "model_id")
.expect("should detect model_id change");
assert_eq!(model_change.old_value, "model-old");
assert_eq!(model_change.new_value, "model-new");
}
#[tokio::test]
async fn test_diff_detects_multiple_changes() {
let mgr = AgentVersionManager::new();
let c1 = make_config_with_details(
"m1",
"prompt-a",
0.7,
4096,
vec!["tool-a"],
vec!["guard-a"],
"alice",
"v1",
);
let c2 = make_config_with_details(
"m2",
"prompt-b",
0.9,
8192,
vec!["tool-b"],
vec!["guard-b"],
"bob",
"v2",
);
mgr.deploy("agent-a", c1).await.unwrap();
mgr.deploy("agent-a", c2).await.unwrap();
let diff = mgr.diff_versions("agent-a", 1, 2).await.unwrap();
let fields: Vec<&str> = diff.changes.iter().map(|c| c.field.as_str()).collect();
assert!(fields.contains(&"model_id"));
assert!(fields.contains(&"system_prompt"));
assert!(fields.contains(&"temperature"));
assert!(fields.contains(&"max_tokens"));
assert!(fields.contains(&"tools"));
assert!(fields.contains(&"guardrails"));
assert!(fields.contains(&"created_by"));
}
#[tokio::test]
async fn test_diff_identical_versions_no_changes() {
let mgr = AgentVersionManager::new();
let c = make_config("same-model", "same-log");
mgr.deploy("agent-a", c.clone()).await.unwrap();
mgr.deploy("agent-a", c).await.unwrap();
let diff = mgr.diff_versions("agent-a", 1, 2).await.unwrap();
assert!(
diff.changes.is_empty(),
"identical configs should produce no diff"
);
}
#[tokio::test]
async fn test_deployment_history_records_events() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("m1", "v1"))
.await
.unwrap();
mgr.deploy("agent-a", make_config("m2", "v2"))
.await
.unwrap();
mgr.rollback("agent-a").await.unwrap();
let history = mgr.get_deployment_history("agent-a").await;
assert_eq!(history.len(), 3);
assert_eq!(history[0].action, DeploymentAction::Deploy);
assert_eq!(history[1].action, DeploymentAction::Deploy);
assert_eq!(history[2].action, DeploymentAction::Rollback);
}
#[tokio::test]
async fn test_deployment_history_per_agent() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("m1", "v1"))
.await
.unwrap();
mgr.deploy("agent-b", make_config("m2", "v1"))
.await
.unwrap();
let history_a = mgr.get_deployment_history("agent-a").await;
let history_b = mgr.get_deployment_history("agent-b").await;
assert_eq!(history_a.len(), 1);
assert_eq!(history_b.len(), 1);
assert_eq!(history_a[0].agent_id, "agent-a");
assert_eq!(history_b[0].agent_id, "agent-b");
}
#[tokio::test]
async fn test_deprecate_version() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("m1", "v1"))
.await
.unwrap();
mgr.deploy("agent-a", make_config("m2", "v2"))
.await
.unwrap();
mgr.deprecate_version("agent-a", 1).await.unwrap();
let v1 = mgr.get_version("agent-a", 1).await.unwrap();
assert_eq!(v1.status, VersionStatus::Deprecated);
}
#[tokio::test]
async fn test_cannot_deprecate_active_version() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("m1", "v1"))
.await
.unwrap();
let result = mgr.deprecate_version("agent-a", 1).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_multiple_agents_independent() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("m-a", "a-v1"))
.await
.unwrap();
mgr.deploy("agent-b", make_config("m-b", "b-v1"))
.await
.unwrap();
mgr.deploy("agent-a", make_config("m-a2", "a-v2"))
.await
.unwrap();
let active_a = mgr.get_active("agent-a").await.unwrap();
assert_eq!(active_a.version, 2);
assert_eq!(active_a.model_id, "m-a2");
let active_b = mgr.get_active("agent-b").await.unwrap();
assert_eq!(active_b.version, 1);
assert_eq!(active_b.model_id, "m-b");
assert_eq!(mgr.agent_count().await, 2);
}
#[tokio::test]
async fn test_agent_count_and_total_versions() {
let mgr = AgentVersionManager::new();
mgr.deploy("a", make_config("m1", "v1")).await.unwrap();
mgr.deploy("a", make_config("m2", "v2")).await.unwrap();
mgr.deploy("b", make_config("m3", "v1")).await.unwrap();
assert_eq!(mgr.agent_count().await, 2);
assert_eq!(mgr.total_version_count().await, 3);
}
#[tokio::test]
async fn test_deploy_sets_agent_id_on_config() {
let mgr = AgentVersionManager::new();
mgr.deploy("my-agent", make_config("m1", "v1"))
.await
.unwrap();
let config = mgr.get_version("my-agent", 1).await.unwrap();
assert_eq!(config.agent_id, "my-agent");
}
#[tokio::test]
async fn test_version_status_serde_roundtrip() {
let statuses = vec![
VersionStatus::Active,
VersionStatus::Inactive,
VersionStatus::Testing,
VersionStatus::Deprecated,
];
for status in statuses {
let json = serde_json::to_string(&status).unwrap();
let parsed: VersionStatus = serde_json::from_str(&json).unwrap();
assert_eq!(status, parsed);
}
}
#[tokio::test]
async fn test_agent_version_config_serde_roundtrip() {
let config = make_config("test-model", "serde test");
let json = serde_json::to_string(&config).unwrap();
let parsed: AgentVersionConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.model_id, config.model_id);
assert_eq!(parsed.change_log, config.change_log);
assert_eq!(parsed.status, config.status);
}
#[tokio::test]
async fn test_traffic_split_records_history() {
let mgr = AgentVersionManager::new();
mgr.deploy("agent-a", make_config("m1", "v1"))
.await
.unwrap();
mgr.deploy("agent-a", make_config("m2", "v2"))
.await
.unwrap();
let split = TrafficSplit {
primary_version: 1,
primary_weight: 0.9,
canary_version: Some(2),
canary_weight: 0.1,
};
mgr.set_traffic_split("agent-a", split).await.unwrap();
let history = mgr.get_deployment_history("agent-a").await;
let split_events: Vec<_> = history
.iter()
.filter(|e| e.action == DeploymentAction::TrafficSplit)
.collect();
assert_eq!(split_events.len(), 1);
}
#[tokio::test]
async fn test_default_manager_is_empty() {
let mgr = AgentVersionManager::default();
assert_eq!(mgr.agent_count().await, 0);
assert_eq!(mgr.total_version_count().await, 0);
}
}