1use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use clap::Args;
8use serde::{Deserialize, Serialize};
9use tokio::sync::broadcast;
10
11use crate::config::Config;
12
13#[derive(Args)]
16pub struct ServeArgs {
17 #[arg(short, long, default_value = "3000")]
19 pub port: u16,
20
21 #[arg(short = 'H', long, default_value = "127.0.0.1")]
23 pub host: String,
24
25 #[arg(long)]
35 pub cors: Option<String>,
36
37 #[arg(long)]
44 pub token: Option<String>,
45
46 #[arg(long)]
56 pub allow_admin: bool,
57
58 #[arg(long)]
64 pub workdir_root: Option<PathBuf>,
65
66 #[arg(long)]
69 pub no_remote_yolo: bool,
70}
71
72#[derive(Debug, Clone, Serialize)]
76#[serde(tag = "type", rename_all = "snake_case")]
77pub enum ServerEvent {
78 AgentStatus {
79 agent_id: String,
80 run_id: String,
81 status: String,
82 stage: String,
83 iteration: usize,
84 #[serde(default)]
85 tool_calls: usize,
86 accepts_messages: bool,
87 },
88 ContextUpdate {
89 agent_id: String,
90 run_id: String,
91 total_tokens: usize,
92 max_tokens: usize,
93 },
94 Log {
95 agent_id: String,
96 run_id: String,
97 line: String,
98 },
99 InteractionNeeded {
100 agent_id: String,
101 run_id: String,
102 request: serde_json::Value,
103 },
104 AgentSpawned {
105 agent_id: String,
106 run_id: String,
107 parent_id: Option<String>,
108 blueprint: String,
109 },
110 AgentCompleted {
111 agent_id: String,
112 run_id: String,
113 status: String,
114 result: Option<String>,
115 },
116 Tokens {
117 agent_id: String,
118 run_id: String,
119 prompt_tokens: usize,
120 completion_tokens: usize,
121 #[serde(default)]
122 cached_tokens: usize,
123 #[serde(default)]
124 cache_write_tokens: usize,
125 },
126}
127
128#[derive(Clone)]
129pub struct AppState {
130 pub(super) config: Arc<Config>,
131 pub(super) event_tx: broadcast::Sender<ServerEvent>,
132 pub(super) control: leviath_runtime::control_socket::ControlClient,
136 pub(super) mcp: super::mcp::McpAdmin,
138 pub(super) limits: Arc<ServeLimits>,
141}
142
143#[derive(Debug, Clone, Default)]
155pub(super) struct ServeLimits {
156 pub(super) workdir_root: Option<PathBuf>,
158 pub(super) no_remote_yolo: bool,
160 pub(super) allow_local_network: bool,
163}
164
165impl ServeLimits {
166 pub(super) fn check_callback_url(&self, url: &str) -> Result<(), String> {
184 let parsed = url
185 .parse::<url::Url>()
186 .map_err(|e| format!("callback_url is not a URL: {e}"))?;
187 leviath_core::check_url(&parsed, self.allow_local_network)
188 .map_err(|e| format!("callback_url is not allowed: {e}"))
189 }
190
191 pub(super) fn check_workdir(&self, workdir: &std::path::Path) -> Result<(), String> {
192 let Some(root) = &self.workdir_root else {
193 return Ok(());
194 };
195 match leviath_core::resolves_within(workdir, root) {
196 true => Ok(()),
197 false => Err(format!(
198 "workdir '{}' is outside the configured --workdir-root '{}'",
199 workdir.display(),
200 root.display()
201 )),
202 }
203 }
204}
205
206#[derive(Debug, Serialize)]
209pub(super) struct ErrorResponse {
210 pub(super) error: String,
211}
212
213pub(super) fn err(
215 code: axum::http::StatusCode,
216 message: String,
217) -> (axum::http::StatusCode, axum::response::Json<ErrorResponse>) {
218 (code, axum::response::Json(ErrorResponse { error: message }))
219}
220
221#[derive(Debug, Serialize)]
224pub(super) struct BlueprintInfo {
225 pub(super) name: String,
226 pub(super) version: String,
227 pub(super) description: String,
228 pub(super) path: String,
229 pub(super) stages: Vec<String>,
230}
231
232#[derive(Deserialize)]
233pub(super) struct CreateBlueprintReq {
234 pub(super) name: String,
235 pub(super) manifest: String,
236}
237
238#[derive(Deserialize)]
239pub(super) struct UpdateBlueprintReq {
240 pub(super) manifest: String,
241}
242
243#[derive(Deserialize)]
244pub(super) struct ValidateBlueprintReq {
245 pub(super) manifest: String,
246}
247
248#[derive(Serialize, Deserialize)]
249pub(super) struct ValidateResponse {
250 pub(super) valid: bool,
251 #[serde(skip_serializing_if = "Option::is_none")]
252 pub(super) errors: Option<Vec<String>>,
253}
254
255#[derive(Default, Deserialize)]
258pub(super) struct SpawnAgentReq {
259 pub(super) blueprint: String,
260 pub(super) task: String,
261 pub(super) model: Option<String>,
262 pub(super) max_depth: Option<usize>,
264 #[serde(default)]
266 pub(super) yolo: bool,
267 #[serde(default)]
269 pub(super) allow: Vec<String>,
270 #[serde(default)]
273 pub(super) no_seed_commands: bool,
274 pub(super) workdir: Option<String>,
275 #[serde(default)]
277 pub(super) regions: HashMap<String, String>,
278 #[serde(default)]
279 pub(super) metadata: HashMap<String, String>,
280 pub(super) callback_url: Option<String>,
281 pub(super) callback_secret: Option<String>,
284}
285
286#[derive(Serialize, Debug)]
287pub(super) struct SpawnAgentResp {
288 pub(super) agent_id: String,
289 pub(super) run_id: String,
290}
291
292#[derive(Deserialize)]
293pub(super) struct ListAgentsQuery {
294 pub(super) status: Option<String>,
295}
296
297#[derive(Serialize)]
298pub(super) struct AgentResultResp {
299 pub(super) run_id: String,
300 pub(super) status: String,
301 pub(super) output: String,
302 pub(super) error: Option<String>,
303 pub(super) prompt_tokens: usize,
304 pub(super) completion_tokens: usize,
305}
306
307#[derive(Deserialize)]
308pub(super) struct LogsQuery {
309 pub(super) tail: Option<u64>,
310}
311
312#[derive(Serialize)]
315pub(super) struct AgentTreeNode {
316 pub(super) run_id: String,
317 pub(super) agent_name: String,
318 pub(super) status: String,
319 pub(super) stage: String,
320 pub(super) iteration: usize,
321 pub(super) prompt_tokens: usize,
322 pub(super) completion_tokens: usize,
323 pub(super) children: Vec<AgentTreeNode>,
324}
325
326#[derive(Debug, Serialize)]
327pub(super) struct TreeStatusNode {
328 pub(super) run_id: String,
329 pub(super) agent_name: String,
330 pub(super) status: String,
331 pub(super) stage: String,
332 pub(super) prompt_tokens: usize,
333 pub(super) completion_tokens: usize,
334 pub(super) subtree_prompt_tokens: usize,
335 pub(super) subtree_completion_tokens: usize,
336 pub(super) children: Vec<TreeStatusNode>,
337}
338
339#[derive(Deserialize)]
342pub(super) struct SubmitInteractionReq {
343 pub(super) request_id: String,
344 pub(super) value: Option<String>,
345 pub(super) choice_index: Option<usize>,
346 pub(super) approved: Option<bool>,
347 pub(super) scope: Option<String>,
348}
349
350#[derive(Deserialize)]
351pub(super) struct SendMessageReq {
352 pub(super) message: String,
353 #[serde(default)]
354 pub(super) target_region: Option<String>,
355}
356
357#[derive(Serialize, Deserialize)]
360pub(super) struct RedactedConfig {
361 pub(super) default_provider: String,
362 pub(super) has_anthropic_key: bool,
363 pub(super) has_openai_key: bool,
364 pub(super) has_google_key: bool,
365 pub(super) has_openrouter_key: bool,
366 pub(super) ollama_base_url: Option<String>,
367 pub(super) agent_paths: Vec<PathBuf>,
368 pub(super) mcp_server_count: usize,
369}
370
371#[derive(Debug, Default, Deserialize)]
375pub(super) struct WriteConfigReq {
376 pub(super) default_provider: Option<String>,
377 pub(super) default_model: Option<String>,
378 pub(super) anthropic_key: Option<String>,
379 pub(super) openai_key: Option<String>,
380 pub(super) google_key: Option<String>,
381 pub(super) openrouter_key: Option<String>,
382 pub(super) ollama_base_url: Option<String>,
383}
384
385#[derive(Debug, Deserialize)]
388pub(super) struct ValidateKeyReq {
389 pub(super) provider: String,
390 pub(super) key: String,
391}
392
393#[derive(Debug, Serialize, Deserialize)]
394pub(super) struct ValidateKeyResp {
395 pub(super) valid: bool,
396 #[serde(skip_serializing_if = "Option::is_none")]
397 pub(super) message: Option<String>,
398}
399
400#[derive(Serialize)]
401pub(super) struct ModelEntry {
402 pub(super) id: String,
403 pub(super) provider: String,
404 pub(super) display_name: Option<String>,
405 pub(super) max_context_tokens: usize,
406 pub(super) max_output_tokens: usize,
407 pub(super) supports_tools: bool,
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn server_event_agent_status_serialization() {
416 let event = ServerEvent::AgentStatus {
417 agent_id: "coder".to_string(),
418 run_id: "run-123".to_string(),
419 status: "running".to_string(),
420 stage: "implement".to_string(),
421 iteration: 5,
422 tool_calls: 12,
423 accepts_messages: true,
424 };
425 let json = serde_json::to_string(&event).unwrap();
426 assert!(json.contains("\"type\":\"agent_status\""));
427 assert!(json.contains("\"agent_id\":\"coder\""));
428 assert!(json.contains("\"iteration\":5"));
429 assert!(json.contains("\"tool_calls\":12"));
430 }
431
432 #[test]
433 fn server_event_tokens_serialization() {
434 let event = ServerEvent::Tokens {
435 agent_id: "coder".to_string(),
436 run_id: "run-123".to_string(),
437 prompt_tokens: 5000,
438 completion_tokens: 1200,
439 cached_tokens: 200,
440 cache_write_tokens: 100,
441 };
442 let json = serde_json::to_string(&event).unwrap();
443 assert!(json.contains("\"type\":\"tokens\""));
444 assert!(json.contains("\"prompt_tokens\":5000"));
445 assert!(json.contains("\"cached_tokens\":200"));
446 assert!(json.contains("\"cache_write_tokens\":100"));
447 }
448
449 #[test]
450 fn server_event_agent_spawned_serialization() {
451 let event = ServerEvent::AgentSpawned {
452 agent_id: "coder".to_string(),
453 run_id: "run-456".to_string(),
454 parent_id: Some("run-123".to_string()),
455 blueprint: "coder".to_string(),
456 };
457 let json = serde_json::to_string(&event).unwrap();
458 assert!(json.contains("\"type\":\"agent_spawned\""));
459 assert!(json.contains("\"parent_id\":\"run-123\""));
460 }
461
462 #[test]
463 fn server_event_agent_completed_serialization() {
464 let event = ServerEvent::AgentCompleted {
465 agent_id: "coder".to_string(),
466 run_id: "run-123".to_string(),
467 status: "complete".to_string(),
468 result: Some("success".to_string()),
469 };
470 let json = serde_json::to_string(&event).unwrap();
471 assert!(json.contains("\"type\":\"agent_completed\""));
472 }
473
474 #[test]
475 fn server_event_context_update_serialization() {
476 let event = ServerEvent::ContextUpdate {
477 agent_id: "coder".to_string(),
478 run_id: "run-123".to_string(),
479 total_tokens: 10000,
480 max_tokens: 200000,
481 };
482 let json = serde_json::to_string(&event).unwrap();
483 assert!(json.contains("\"type\":\"context_update\""));
484 assert!(json.contains("\"total_tokens\":10000"));
485 }
486
487 #[test]
488 fn server_event_interaction_needed_serialization() {
489 let event = ServerEvent::InteractionNeeded {
490 agent_id: "coder".to_string(),
491 run_id: "run-123".to_string(),
492 request: serde_json::json!({"prompt": "approve?"}),
493 };
494 let json = serde_json::to_string(&event).unwrap();
495 assert!(json.contains("\"type\":\"interaction_needed\""));
496 }
497
498 #[test]
499 fn server_event_log_serialization() {
500 let event = ServerEvent::Log {
501 agent_id: "coder".to_string(),
502 run_id: "run-123".to_string(),
503 line: "doing work".to_string(),
504 };
505 let json = serde_json::to_string(&event).unwrap();
506 assert!(json.contains("\"type\":\"log\""));
507 assert!(json.contains("\"line\":\"doing work\""));
508 }
509
510 #[test]
511 fn validate_response_serde_roundtrip() {
512 let resp = ValidateResponse {
513 valid: true,
514 errors: None,
515 };
516 let json = serde_json::to_string(&resp).unwrap();
517 let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
518 assert!(parsed.valid);
519 assert!(parsed.errors.is_none());
520 }
521
522 #[test]
523 fn validate_response_with_errors_roundtrip() {
524 let resp = ValidateResponse {
525 valid: false,
526 errors: Some(vec!["bad field".to_string()]),
527 };
528 let json = serde_json::to_string(&resp).unwrap();
529 let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
530 assert!(!parsed.valid);
531 assert_eq!(parsed.errors.unwrap().len(), 1);
532 }
533
534 #[test]
535 fn redacted_config_serde_roundtrip() {
536 let config = RedactedConfig {
537 default_provider: "anthropic".to_string(),
538 has_anthropic_key: true,
539 has_openai_key: false,
540 has_google_key: false,
541 has_openrouter_key: false,
542 ollama_base_url: None,
543 agent_paths: vec![],
544 mcp_server_count: 0,
545 };
546 let json = serde_json::to_string(&config).unwrap();
547 let parsed: RedactedConfig = serde_json::from_str(&json).unwrap();
548 assert_eq!(parsed.default_provider, "anthropic");
549 assert!(parsed.has_anthropic_key);
550 assert!(!parsed.has_openai_key);
551 }
552
553 #[test]
554 fn error_response_serialization() {
555 let err = ErrorResponse {
556 error: "not found".to_string(),
557 };
558 let json = serde_json::to_string(&err).unwrap();
559 assert!(json.contains("\"error\":\"not found\""));
560 }
561}