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 World { event: serde_json::Value },
132}
133
134impl ServerEvent {
135 pub fn run_id(&self) -> &str {
139 match self {
140 ServerEvent::AgentStatus { run_id, .. }
141 | ServerEvent::ContextUpdate { run_id, .. }
142 | ServerEvent::Log { run_id, .. }
143 | ServerEvent::InteractionNeeded { run_id, .. }
144 | ServerEvent::AgentSpawned { run_id, .. }
145 | ServerEvent::AgentCompleted { run_id, .. }
146 | ServerEvent::Tokens { run_id, .. } => run_id,
147 ServerEvent::World { event } => event
148 .get("run_id")
149 .and_then(|v| v.as_str())
150 .unwrap_or_default(),
151 }
152 }
153}
154
155#[derive(Clone)]
156pub struct AppState {
157 pub(super) config: Arc<Config>,
158 pub(super) event_tx: broadcast::Sender<ServerEvent>,
159 pub(super) control: leviath_runtime::control_socket::ControlClient,
163 pub(super) mcp: super::mcp::McpAdmin,
165 pub(super) limits: Arc<ServeLimits>,
168}
169
170#[derive(Debug, Clone, Default)]
182pub(super) struct ServeLimits {
183 pub(super) workdir_root: Option<PathBuf>,
185 pub(super) no_remote_yolo: bool,
187 pub(super) allow_local_network: bool,
190}
191
192impl ServeLimits {
193 pub(super) fn check_callback_url(&self, url: &str) -> Result<(), String> {
211 let parsed = url
212 .parse::<url::Url>()
213 .map_err(|e| format!("callback_url is not a URL: {e}"))?;
214 leviath_core::check_url(&parsed, self.allow_local_network)
215 .map_err(|e| format!("callback_url is not allowed: {e}"))
216 }
217
218 pub(super) fn check_workdir(&self, workdir: &std::path::Path) -> Result<(), String> {
219 let Some(root) = &self.workdir_root else {
220 return Ok(());
221 };
222 match leviath_core::resolves_within(workdir, root) {
223 true => Ok(()),
224 false => Err(format!(
225 "workdir '{}' is outside the configured --workdir-root '{}'",
226 workdir.display(),
227 root.display()
228 )),
229 }
230 }
231}
232
233#[derive(Debug, Serialize)]
236pub(super) struct ErrorResponse {
237 pub(super) error: String,
238}
239
240pub(super) fn err(
242 code: axum::http::StatusCode,
243 message: String,
244) -> (axum::http::StatusCode, axum::response::Json<ErrorResponse>) {
245 (code, axum::response::Json(ErrorResponse { error: message }))
246}
247
248#[derive(Debug, Serialize)]
251pub(super) struct BlueprintInfo {
252 pub(super) name: String,
253 pub(super) version: String,
254 pub(super) description: String,
255 pub(super) path: String,
256 pub(super) stages: Vec<String>,
257}
258
259#[derive(Deserialize)]
260pub(super) struct CreateBlueprintReq {
261 pub(super) name: String,
262 pub(super) manifest: String,
263}
264
265#[derive(Deserialize)]
266pub(super) struct UpdateBlueprintReq {
267 pub(super) manifest: String,
268}
269
270#[derive(Deserialize)]
271pub(super) struct ValidateBlueprintReq {
272 pub(super) manifest: String,
273}
274
275#[derive(Serialize, Deserialize)]
276pub(super) struct ValidateResponse {
277 pub(super) valid: bool,
278 #[serde(skip_serializing_if = "Option::is_none")]
279 pub(super) errors: Option<Vec<String>>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub(super) warnings: Option<Vec<String>>,
285}
286
287impl ValidateResponse {
288 pub(super) fn invalid(errors: Vec<String>) -> Self {
290 Self {
291 valid: false,
292 errors: Some(errors),
293 warnings: None,
294 }
295 }
296}
297
298#[derive(Default, Deserialize)]
301pub(super) struct SpawnAgentReq {
302 pub(super) blueprint: String,
303 pub(super) task: String,
304 pub(super) model: Option<String>,
305 pub(super) max_depth: Option<usize>,
307 #[serde(default)]
309 pub(super) yolo: bool,
310 #[serde(default)]
312 pub(super) allow: Vec<String>,
313 #[serde(default)]
316 pub(super) no_seed_commands: bool,
317 pub(super) workdir: Option<String>,
318 #[serde(default)]
320 pub(super) regions: HashMap<String, String>,
321 #[serde(default)]
322 pub(super) metadata: HashMap<String, String>,
323 pub(super) callback_url: Option<String>,
324 pub(super) callback_secret: Option<String>,
327}
328
329#[derive(Serialize, Debug)]
330pub(super) struct SpawnAgentResp {
331 pub(super) agent_id: String,
332 pub(super) run_id: String,
333}
334
335#[derive(Deserialize)]
336pub(super) struct ListAgentsQuery {
337 pub(super) status: Option<String>,
338}
339
340#[derive(Serialize)]
341pub(super) struct AgentResultResp {
342 pub(super) run_id: String,
343 pub(super) status: String,
344 pub(super) output: String,
345 pub(super) error: Option<String>,
346 pub(super) prompt_tokens: usize,
347 pub(super) completion_tokens: usize,
348}
349
350#[derive(Deserialize)]
351pub(super) struct LogsQuery {
352 pub(super) tail: Option<u64>,
353}
354
355#[derive(Serialize)]
358pub(super) struct AgentTreeNode {
359 pub(super) run_id: String,
360 pub(super) agent_name: String,
361 pub(super) status: String,
362 pub(super) stage: String,
363 pub(super) iteration: usize,
364 pub(super) prompt_tokens: usize,
365 pub(super) completion_tokens: usize,
366 pub(super) children: Vec<AgentTreeNode>,
367}
368
369#[derive(Debug, Serialize)]
370pub(super) struct TreeStatusNode {
371 pub(super) run_id: String,
372 pub(super) agent_name: String,
373 pub(super) status: String,
374 pub(super) stage: String,
375 pub(super) prompt_tokens: usize,
376 pub(super) completion_tokens: usize,
377 pub(super) subtree_prompt_tokens: usize,
378 pub(super) subtree_completion_tokens: usize,
379 pub(super) children: Vec<TreeStatusNode>,
380}
381
382#[derive(Deserialize)]
385pub(super) struct SubmitInteractionReq {
386 pub(super) request_id: String,
387 pub(super) value: Option<String>,
388 pub(super) choice_index: Option<usize>,
389 pub(super) approved: Option<bool>,
390 pub(super) scope: Option<String>,
391}
392
393#[derive(Deserialize)]
394pub(super) struct SendMessageReq {
395 pub(super) message: String,
396 #[serde(default)]
397 pub(super) target_region: Option<String>,
398}
399
400#[derive(Serialize, Deserialize)]
403pub(super) struct RedactedConfig {
404 pub(super) default_provider: String,
405 pub(super) has_anthropic_key: bool,
406 pub(super) has_openai_key: bool,
407 pub(super) has_google_key: bool,
408 pub(super) has_openrouter_key: bool,
409 pub(super) ollama_base_url: Option<String>,
410 pub(super) agent_paths: Vec<PathBuf>,
411 pub(super) mcp_server_count: usize,
412}
413
414#[derive(Debug, Default, Deserialize)]
418pub(super) struct WriteConfigReq {
419 pub(super) default_provider: Option<String>,
420 pub(super) default_model: Option<String>,
421 pub(super) anthropic_key: Option<String>,
422 pub(super) openai_key: Option<String>,
423 pub(super) google_key: Option<String>,
424 pub(super) openrouter_key: Option<String>,
425 pub(super) ollama_base_url: Option<String>,
426}
427
428#[derive(Debug, Deserialize)]
431pub(super) struct ValidateKeyReq {
432 pub(super) provider: String,
433 pub(super) key: String,
434}
435
436#[derive(Debug, Serialize, Deserialize)]
437pub(super) struct ValidateKeyResp {
438 pub(super) valid: bool,
439 #[serde(skip_serializing_if = "Option::is_none")]
440 pub(super) message: Option<String>,
441}
442
443#[derive(Serialize)]
444pub(super) struct ModelEntry {
445 pub(super) id: String,
446 pub(super) provider: String,
447 pub(super) display_name: Option<String>,
448 pub(super) max_context_tokens: usize,
449 pub(super) max_output_tokens: usize,
450 pub(super) supports_tools: bool,
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 #[test]
458 fn server_event_agent_status_serialization() {
459 let event = ServerEvent::AgentStatus {
460 agent_id: "coder".to_string(),
461 run_id: "run-123".to_string(),
462 status: "running".to_string(),
463 stage: "implement".to_string(),
464 iteration: 5,
465 tool_calls: 12,
466 accepts_messages: true,
467 };
468 let json = serde_json::to_string(&event).unwrap();
469 assert!(json.contains("\"type\":\"agent_status\""));
470 assert!(json.contains("\"agent_id\":\"coder\""));
471 assert!(json.contains("\"iteration\":5"));
472 assert!(json.contains("\"tool_calls\":12"));
473 }
474
475 #[test]
476 fn server_event_tokens_serialization() {
477 let event = ServerEvent::Tokens {
478 agent_id: "coder".to_string(),
479 run_id: "run-123".to_string(),
480 prompt_tokens: 5000,
481 completion_tokens: 1200,
482 cached_tokens: 200,
483 cache_write_tokens: 100,
484 };
485 let json = serde_json::to_string(&event).unwrap();
486 assert!(json.contains("\"type\":\"tokens\""));
487 assert!(json.contains("\"prompt_tokens\":5000"));
488 assert!(json.contains("\"cached_tokens\":200"));
489 assert!(json.contains("\"cache_write_tokens\":100"));
490 }
491
492 #[test]
493 fn server_event_agent_spawned_serialization() {
494 let event = ServerEvent::AgentSpawned {
495 agent_id: "coder".to_string(),
496 run_id: "run-456".to_string(),
497 parent_id: Some("run-123".to_string()),
498 blueprint: "coder".to_string(),
499 };
500 let json = serde_json::to_string(&event).unwrap();
501 assert!(json.contains("\"type\":\"agent_spawned\""));
502 assert!(json.contains("\"parent_id\":\"run-123\""));
503 }
504
505 #[test]
506 fn server_event_agent_completed_serialization() {
507 let event = ServerEvent::AgentCompleted {
508 agent_id: "coder".to_string(),
509 run_id: "run-123".to_string(),
510 status: "complete".to_string(),
511 result: Some("success".to_string()),
512 };
513 let json = serde_json::to_string(&event).unwrap();
514 assert!(json.contains("\"type\":\"agent_completed\""));
515 }
516
517 #[test]
518 fn server_event_context_update_serialization() {
519 let event = ServerEvent::ContextUpdate {
520 agent_id: "coder".to_string(),
521 run_id: "run-123".to_string(),
522 total_tokens: 10000,
523 max_tokens: 200000,
524 };
525 let json = serde_json::to_string(&event).unwrap();
526 assert!(json.contains("\"type\":\"context_update\""));
527 assert!(json.contains("\"total_tokens\":10000"));
528 }
529
530 #[test]
531 fn server_event_interaction_needed_serialization() {
532 let event = ServerEvent::InteractionNeeded {
533 agent_id: "coder".to_string(),
534 run_id: "run-123".to_string(),
535 request: serde_json::json!({"prompt": "approve?"}),
536 };
537 let json = serde_json::to_string(&event).unwrap();
538 assert!(json.contains("\"type\":\"interaction_needed\""));
539 }
540
541 #[test]
542 fn server_event_log_serialization() {
543 let event = ServerEvent::Log {
544 agent_id: "coder".to_string(),
545 run_id: "run-123".to_string(),
546 line: "doing work".to_string(),
547 };
548 let json = serde_json::to_string(&event).unwrap();
549 assert!(json.contains("\"type\":\"log\""));
550 assert!(json.contains("\"line\":\"doing work\""));
551 }
552
553 #[test]
554 fn validate_response_serde_roundtrip() {
555 let resp = ValidateResponse {
556 valid: true,
557 errors: None,
558 warnings: None,
559 };
560 let json = serde_json::to_string(&resp).unwrap();
561 assert_eq!(json, r#"{"valid":true}"#);
563 let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
564 assert!(parsed.valid);
565 assert!(parsed.errors.is_none());
566 assert!(parsed.warnings.is_none());
567 }
568
569 #[test]
570 fn validate_response_with_errors_roundtrip() {
571 let resp = ValidateResponse::invalid(vec!["bad field".to_string()]);
572 let json = serde_json::to_string(&resp).unwrap();
573 let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
574 assert!(!parsed.valid);
575 assert_eq!(parsed.errors.unwrap().len(), 1);
576 assert!(parsed.warnings.is_none());
577 }
578
579 #[test]
581 fn validate_response_with_warnings_roundtrip() {
582 let resp = ValidateResponse {
583 valid: true,
584 errors: None,
585 warnings: Some(vec!["stage 'main': no max_iterations".to_string()]),
586 };
587 let json = serde_json::to_string(&resp).unwrap();
588 let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
589 assert!(parsed.valid);
590 assert_eq!(parsed.warnings.unwrap().len(), 1);
591 }
592
593 #[test]
594 fn redacted_config_serde_roundtrip() {
595 let config = RedactedConfig {
596 default_provider: "anthropic".to_string(),
597 has_anthropic_key: true,
598 has_openai_key: false,
599 has_google_key: false,
600 has_openrouter_key: false,
601 ollama_base_url: None,
602 agent_paths: vec![],
603 mcp_server_count: 0,
604 };
605 let json = serde_json::to_string(&config).unwrap();
606 let parsed: RedactedConfig = serde_json::from_str(&json).unwrap();
607 assert_eq!(parsed.default_provider, "anthropic");
608 assert!(parsed.has_anthropic_key);
609 assert!(!parsed.has_openai_key);
610 }
611
612 #[test]
613 fn error_response_serialization() {
614 let err = ErrorResponse {
615 error: "not found".to_string(),
616 };
617 let json = serde_json::to_string(&err).unwrap();
618 assert!(json.contains("\"error\":\"not found\""));
619 }
620
621 #[test]
622 fn server_event_run_id_covers_every_variant() {
623 let cases: Vec<(ServerEvent, &str)> = vec![
624 (
625 ServerEvent::AgentStatus {
626 agent_id: "a".to_string(),
627 run_id: "r1".to_string(),
628 status: "active".to_string(),
629 stage: "s".to_string(),
630 iteration: 0,
631 tool_calls: 0,
632 accepts_messages: false,
633 },
634 "r1",
635 ),
636 (
637 ServerEvent::ContextUpdate {
638 agent_id: "a".to_string(),
639 run_id: "r2".to_string(),
640 total_tokens: 1,
641 max_tokens: 2,
642 },
643 "r2",
644 ),
645 (
646 ServerEvent::Log {
647 agent_id: "a".to_string(),
648 run_id: "r3".to_string(),
649 line: "l".to_string(),
650 },
651 "r3",
652 ),
653 (
654 ServerEvent::InteractionNeeded {
655 agent_id: "a".to_string(),
656 run_id: "r4".to_string(),
657 request: serde_json::Value::Null,
658 },
659 "r4",
660 ),
661 (
662 ServerEvent::AgentSpawned {
663 agent_id: "a".to_string(),
664 run_id: "r5".to_string(),
665 parent_id: None,
666 blueprint: "b".to_string(),
667 },
668 "r5",
669 ),
670 (
671 ServerEvent::AgentCompleted {
672 agent_id: "a".to_string(),
673 run_id: "r6".to_string(),
674 status: "complete".to_string(),
675 result: None,
676 },
677 "r6",
678 ),
679 (
680 ServerEvent::Tokens {
681 agent_id: "a".to_string(),
682 run_id: "r7".to_string(),
683 prompt_tokens: 0,
684 completion_tokens: 0,
685 cached_tokens: 0,
686 cache_write_tokens: 0,
687 },
688 "r7",
689 ),
690 (
691 ServerEvent::World {
692 event: serde_json::json!({"event": "stage_transition", "run_id": "r8"}),
693 },
694 "r8",
695 ),
696 (
699 ServerEvent::World {
700 event: serde_json::Value::Null,
701 },
702 "",
703 ),
704 ];
705 for (ev, want) in cases {
706 assert_eq!(ev.run_id(), want);
707 }
708 }
709}