use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use clap::Args;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use crate::config::Config;
#[derive(Args)]
pub struct ServeArgs {
#[arg(short, long, default_value = "3000")]
pub port: u16,
#[arg(short = 'H', long, default_value = "127.0.0.1")]
pub host: String,
#[arg(long)]
pub cors: Option<String>,
#[arg(long)]
pub token: Option<String>,
#[arg(long)]
pub allow_admin: bool,
#[arg(long)]
pub workdir_root: Option<PathBuf>,
#[arg(long)]
pub no_remote_yolo: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerEvent {
AgentStatus {
agent_id: String,
run_id: String,
status: String,
stage: String,
iteration: usize,
#[serde(default)]
tool_calls: usize,
accepts_messages: bool,
},
ContextUpdate {
agent_id: String,
run_id: String,
total_tokens: usize,
max_tokens: usize,
},
Log {
agent_id: String,
run_id: String,
line: String,
},
InteractionNeeded {
agent_id: String,
run_id: String,
request: serde_json::Value,
},
AgentSpawned {
agent_id: String,
run_id: String,
parent_id: Option<String>,
blueprint: String,
},
AgentCompleted {
agent_id: String,
run_id: String,
status: String,
result: Option<String>,
},
Tokens {
agent_id: String,
run_id: String,
prompt_tokens: usize,
completion_tokens: usize,
#[serde(default)]
cached_tokens: usize,
#[serde(default)]
cache_write_tokens: usize,
},
World { event: serde_json::Value },
}
impl ServerEvent {
pub fn run_id(&self) -> &str {
match self {
ServerEvent::AgentStatus { run_id, .. }
| ServerEvent::ContextUpdate { run_id, .. }
| ServerEvent::Log { run_id, .. }
| ServerEvent::InteractionNeeded { run_id, .. }
| ServerEvent::AgentSpawned { run_id, .. }
| ServerEvent::AgentCompleted { run_id, .. }
| ServerEvent::Tokens { run_id, .. } => run_id,
ServerEvent::World { event } => event
.get("run_id")
.and_then(|v| v.as_str())
.unwrap_or_default(),
}
}
}
#[derive(Clone)]
pub struct AppState {
pub(super) config: Arc<Config>,
pub(super) event_tx: broadcast::Sender<ServerEvent>,
pub(super) control: leviath_runtime::control_socket::ControlClient,
pub(super) mcp: super::mcp::McpAdmin,
pub(super) limits: Arc<ServeLimits>,
}
#[derive(Debug, Clone, Default)]
pub(super) struct ServeLimits {
pub(super) workdir_root: Option<PathBuf>,
pub(super) no_remote_yolo: bool,
pub(super) allow_local_network: bool,
}
impl ServeLimits {
pub(super) fn check_callback_url(&self, url: &str) -> Result<(), String> {
let parsed = url
.parse::<url::Url>()
.map_err(|e| format!("callback_url is not a URL: {e}"))?;
leviath_core::check_url(&parsed, self.allow_local_network)
.map_err(|e| format!("callback_url is not allowed: {e}"))
}
pub(super) fn check_workdir(&self, workdir: &std::path::Path) -> Result<(), String> {
let Some(root) = &self.workdir_root else {
return Ok(());
};
match leviath_core::resolves_within(workdir, root) {
true => Ok(()),
false => Err(format!(
"workdir '{}' is outside the configured --workdir-root '{}'",
workdir.display(),
root.display()
)),
}
}
}
#[derive(Debug, Serialize)]
pub(super) struct ErrorResponse {
pub(super) error: String,
}
pub(super) fn err(
code: axum::http::StatusCode,
message: String,
) -> (axum::http::StatusCode, axum::response::Json<ErrorResponse>) {
(code, axum::response::Json(ErrorResponse { error: message }))
}
#[derive(Debug, Serialize)]
pub(super) struct BlueprintInfo {
pub(super) name: String,
pub(super) version: String,
pub(super) description: String,
pub(super) path: String,
pub(super) stages: Vec<String>,
}
#[derive(Deserialize)]
pub(super) struct CreateBlueprintReq {
pub(super) name: String,
pub(super) manifest: String,
}
#[derive(Deserialize)]
pub(super) struct UpdateBlueprintReq {
pub(super) manifest: String,
}
#[derive(Deserialize)]
pub(super) struct ValidateBlueprintReq {
pub(super) manifest: String,
}
#[derive(Serialize, Deserialize)]
pub(super) struct ValidateResponse {
pub(super) valid: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) errors: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) warnings: Option<Vec<String>>,
}
impl ValidateResponse {
pub(super) fn invalid(errors: Vec<String>) -> Self {
Self {
valid: false,
errors: Some(errors),
warnings: None,
}
}
}
#[derive(Default, Deserialize)]
pub(super) struct SpawnAgentReq {
pub(super) blueprint: String,
pub(super) task: String,
pub(super) model: Option<String>,
pub(super) max_depth: Option<usize>,
#[serde(default)]
pub(super) yolo: bool,
#[serde(default)]
pub(super) allow: Vec<String>,
#[serde(default)]
pub(super) no_seed_commands: bool,
pub(super) workdir: Option<String>,
#[serde(default)]
pub(super) regions: HashMap<String, String>,
#[serde(default)]
pub(super) metadata: HashMap<String, String>,
pub(super) callback_url: Option<String>,
pub(super) callback_secret: Option<String>,
}
#[derive(Serialize, Debug)]
pub(super) struct SpawnAgentResp {
pub(super) agent_id: String,
pub(super) run_id: String,
}
#[derive(Deserialize)]
pub(super) struct ListAgentsQuery {
pub(super) status: Option<String>,
}
#[derive(Serialize)]
pub(super) struct AgentResultResp {
pub(super) run_id: String,
pub(super) status: String,
pub(super) output: String,
pub(super) error: Option<String>,
pub(super) prompt_tokens: usize,
pub(super) completion_tokens: usize,
}
#[derive(Deserialize)]
pub(super) struct LogsQuery {
pub(super) tail: Option<u64>,
}
#[derive(Deserialize)]
pub(super) struct FileQuery {
pub(super) path: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct FileContentResp {
pub(super) path: String,
pub(super) size: u64,
pub(super) content: String,
pub(super) truncated: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct DoctorResp {
pub(super) checks: Vec<DoctorCheck>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct DoctorCheck {
pub(super) name: String,
pub(super) ok: bool,
pub(super) detail: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) elapsed_ms: Option<u64>,
}
#[derive(Deserialize)]
pub(super) struct DirsQuery {
pub(super) path: Option<String>,
#[serde(default)]
pub(super) hidden: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct DirsResp {
pub(super) path: String,
pub(super) parent: Option<String>,
pub(super) home: String,
pub(super) cwd: String,
pub(super) root: Option<String>,
pub(super) dirs: Vec<DirEntry>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct DirEntry {
pub(super) name: String,
pub(super) path: String,
}
#[derive(Serialize)]
pub(super) struct AgentTreeNode {
pub(super) run_id: String,
pub(super) agent_name: String,
pub(super) status: String,
pub(super) stage: String,
pub(super) iteration: usize,
pub(super) prompt_tokens: usize,
pub(super) completion_tokens: usize,
pub(super) children: Vec<AgentTreeNode>,
}
#[derive(Debug, Serialize)]
pub(super) struct TreeStatusNode {
pub(super) run_id: String,
pub(super) agent_name: String,
pub(super) status: String,
pub(super) stage: String,
pub(super) prompt_tokens: usize,
pub(super) completion_tokens: usize,
pub(super) subtree_prompt_tokens: usize,
pub(super) subtree_completion_tokens: usize,
pub(super) children: Vec<TreeStatusNode>,
}
#[derive(Deserialize)]
pub(super) struct SubmitInteractionReq {
pub(super) request_id: String,
pub(super) value: Option<String>,
pub(super) choice_index: Option<usize>,
pub(super) approved: Option<bool>,
pub(super) scope: Option<String>,
}
#[derive(Deserialize)]
pub(super) struct SendMessageReq {
pub(super) message: String,
#[serde(default)]
pub(super) target_region: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub(super) struct RedactedConfig {
pub(super) default_provider: String,
pub(super) has_anthropic_key: bool,
pub(super) has_openai_key: bool,
pub(super) has_google_key: bool,
pub(super) has_openrouter_key: bool,
pub(super) ollama_base_url: Option<String>,
pub(super) agent_paths: Vec<PathBuf>,
pub(super) mcp_server_count: usize,
}
#[derive(Debug, Default, Deserialize)]
pub(super) struct WriteConfigReq {
pub(super) default_provider: Option<String>,
pub(super) default_model: Option<String>,
pub(super) anthropic_key: Option<String>,
pub(super) openai_key: Option<String>,
pub(super) google_key: Option<String>,
pub(super) openrouter_key: Option<String>,
pub(super) ollama_base_url: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(super) struct ValidateKeyReq {
pub(super) provider: String,
pub(super) key: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct ValidateKeyResp {
pub(super) valid: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) message: Option<String>,
}
#[derive(Serialize)]
pub(super) struct ModelEntry {
pub(super) id: String,
pub(super) provider: String,
pub(super) display_name: Option<String>,
pub(super) max_context_tokens: usize,
pub(super) max_output_tokens: usize,
pub(super) supports_tools: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn server_event_agent_status_serialization() {
let event = ServerEvent::AgentStatus {
agent_id: "coder".to_string(),
run_id: "run-123".to_string(),
status: "running".to_string(),
stage: "implement".to_string(),
iteration: 5,
tool_calls: 12,
accepts_messages: true,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"agent_status\""));
assert!(json.contains("\"agent_id\":\"coder\""));
assert!(json.contains("\"iteration\":5"));
assert!(json.contains("\"tool_calls\":12"));
}
#[test]
fn server_event_tokens_serialization() {
let event = ServerEvent::Tokens {
agent_id: "coder".to_string(),
run_id: "run-123".to_string(),
prompt_tokens: 5000,
completion_tokens: 1200,
cached_tokens: 200,
cache_write_tokens: 100,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"tokens\""));
assert!(json.contains("\"prompt_tokens\":5000"));
assert!(json.contains("\"cached_tokens\":200"));
assert!(json.contains("\"cache_write_tokens\":100"));
}
#[test]
fn server_event_agent_spawned_serialization() {
let event = ServerEvent::AgentSpawned {
agent_id: "coder".to_string(),
run_id: "run-456".to_string(),
parent_id: Some("run-123".to_string()),
blueprint: "coder".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"agent_spawned\""));
assert!(json.contains("\"parent_id\":\"run-123\""));
}
#[test]
fn server_event_agent_completed_serialization() {
let event = ServerEvent::AgentCompleted {
agent_id: "coder".to_string(),
run_id: "run-123".to_string(),
status: "complete".to_string(),
result: Some("success".to_string()),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"agent_completed\""));
}
#[test]
fn server_event_context_update_serialization() {
let event = ServerEvent::ContextUpdate {
agent_id: "coder".to_string(),
run_id: "run-123".to_string(),
total_tokens: 10000,
max_tokens: 200000,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"context_update\""));
assert!(json.contains("\"total_tokens\":10000"));
}
#[test]
fn server_event_interaction_needed_serialization() {
let event = ServerEvent::InteractionNeeded {
agent_id: "coder".to_string(),
run_id: "run-123".to_string(),
request: serde_json::json!({"prompt": "approve?"}),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"interaction_needed\""));
}
#[test]
fn server_event_log_serialization() {
let event = ServerEvent::Log {
agent_id: "coder".to_string(),
run_id: "run-123".to_string(),
line: "doing work".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"log\""));
assert!(json.contains("\"line\":\"doing work\""));
}
#[test]
fn validate_response_serde_roundtrip() {
let resp = ValidateResponse {
valid: true,
errors: None,
warnings: None,
};
let json = serde_json::to_string(&resp).unwrap();
assert_eq!(json, r#"{"valid":true}"#);
let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
assert!(parsed.valid);
assert!(parsed.errors.is_none());
assert!(parsed.warnings.is_none());
}
#[test]
fn validate_response_with_errors_roundtrip() {
let resp = ValidateResponse::invalid(vec!["bad field".to_string()]);
let json = serde_json::to_string(&resp).unwrap();
let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
assert!(!parsed.valid);
assert_eq!(parsed.errors.unwrap().len(), 1);
assert!(parsed.warnings.is_none());
}
#[test]
fn validate_response_with_warnings_roundtrip() {
let resp = ValidateResponse {
valid: true,
errors: None,
warnings: Some(vec!["stage 'main': no max_iterations".to_string()]),
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
assert!(parsed.valid);
assert_eq!(parsed.warnings.unwrap().len(), 1);
}
#[test]
fn redacted_config_serde_roundtrip() {
let config = RedactedConfig {
default_provider: "anthropic".to_string(),
has_anthropic_key: true,
has_openai_key: false,
has_google_key: false,
has_openrouter_key: false,
ollama_base_url: None,
agent_paths: vec![],
mcp_server_count: 0,
};
let json = serde_json::to_string(&config).unwrap();
let parsed: RedactedConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.default_provider, "anthropic");
assert!(parsed.has_anthropic_key);
assert!(!parsed.has_openai_key);
}
#[test]
fn error_response_serialization() {
let err = ErrorResponse {
error: "not found".to_string(),
};
let json = serde_json::to_string(&err).unwrap();
assert!(json.contains("\"error\":\"not found\""));
}
#[test]
fn file_content_resp_serde_roundtrip() {
let resp = FileContentResp {
path: "/work/report.md".to_string(),
size: 9,
content: "# Report\n".to_string(),
truncated: false,
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: FileContentResp = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.path, "/work/report.md");
assert_eq!(parsed.size, 9);
assert_eq!(parsed.content, "# Report\n");
assert!(!parsed.truncated);
}
#[test]
fn dirs_resp_serde_roundtrip() {
let resp = DirsResp {
path: "/work".to_string(),
parent: None,
home: "/Users/someone".to_string(),
cwd: "/work/project".to_string(),
root: Some("/work".to_string()),
dirs: vec![DirEntry {
name: "src".to_string(),
path: "/work/src".to_string(),
}],
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"parent\":null"));
assert!(json.contains(r#"{"name":"src","path":"/work/src"}"#));
let parsed: DirsResp = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.path, "/work");
assert!(parsed.parent.is_none());
assert_eq!(parsed.root.as_deref(), Some("/work"));
assert_eq!(parsed.dirs.len(), 1);
assert_eq!(parsed.dirs[0].name, "src");
}
#[test]
fn doctor_resp_serde_roundtrip() {
let resp = DoctorResp {
checks: vec![
DoctorCheck {
name: "config".to_string(),
ok: true,
detail: "default_provider=anthropic".to_string(),
elapsed_ms: None,
},
DoctorCheck {
name: "inference".to_string(),
ok: false,
detail: "HTTP 401: bad key".to_string(),
elapsed_ms: Some(1200),
},
],
};
let json = serde_json::to_string(&resp).unwrap();
assert!(
json.contains(r#"{"name":"config","ok":true,"detail":"default_provider=anthropic"}"#)
);
assert!(json.contains("\"elapsed_ms\":1200"));
let parsed: DoctorResp = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.checks.len(), 2);
assert!(parsed.checks[0].ok);
assert!(parsed.checks[0].elapsed_ms.is_none());
assert!(!parsed.checks[1].ok);
assert_eq!(parsed.checks[1].elapsed_ms, Some(1200));
}
#[test]
fn server_event_run_id_covers_every_variant() {
let cases: Vec<(ServerEvent, &str)> = vec![
(
ServerEvent::AgentStatus {
agent_id: "a".to_string(),
run_id: "r1".to_string(),
status: "active".to_string(),
stage: "s".to_string(),
iteration: 0,
tool_calls: 0,
accepts_messages: false,
},
"r1",
),
(
ServerEvent::ContextUpdate {
agent_id: "a".to_string(),
run_id: "r2".to_string(),
total_tokens: 1,
max_tokens: 2,
},
"r2",
),
(
ServerEvent::Log {
agent_id: "a".to_string(),
run_id: "r3".to_string(),
line: "l".to_string(),
},
"r3",
),
(
ServerEvent::InteractionNeeded {
agent_id: "a".to_string(),
run_id: "r4".to_string(),
request: serde_json::Value::Null,
},
"r4",
),
(
ServerEvent::AgentSpawned {
agent_id: "a".to_string(),
run_id: "r5".to_string(),
parent_id: None,
blueprint: "b".to_string(),
},
"r5",
),
(
ServerEvent::AgentCompleted {
agent_id: "a".to_string(),
run_id: "r6".to_string(),
status: "complete".to_string(),
result: None,
},
"r6",
),
(
ServerEvent::Tokens {
agent_id: "a".to_string(),
run_id: "r7".to_string(),
prompt_tokens: 0,
completion_tokens: 0,
cached_tokens: 0,
cache_write_tokens: 0,
},
"r7",
),
(
ServerEvent::World {
event: serde_json::json!({"event": "stage_transition", "run_id": "r8"}),
},
"r8",
),
(
ServerEvent::World {
event: serde_json::Value::Null,
},
"",
),
];
for (ev, want) in cases {
assert_eq!(ev.run_id(), want);
}
}
}