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(Deserialize)]
359pub(super) struct FileQuery {
360 pub(super) path: String,
361}
362
363#[derive(Debug, Serialize, Deserialize)]
365pub(super) struct FileContentResp {
366 pub(super) path: String,
368 pub(super) size: u64,
370 pub(super) content: String,
373 pub(super) truncated: bool,
376}
377
378#[derive(Debug, Serialize, Deserialize)]
382pub(super) struct DoctorResp {
383 pub(super) checks: Vec<DoctorCheck>,
384}
385
386#[derive(Debug, Serialize, Deserialize)]
389pub(super) struct DoctorCheck {
390 pub(super) name: String,
392 pub(super) ok: bool,
395 pub(super) detail: String,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub(super) elapsed_ms: Option<u64>,
399}
400
401#[derive(Deserialize)]
407pub(super) struct DirsQuery {
408 pub(super) path: Option<String>,
409 #[serde(default)]
412 pub(super) hidden: bool,
413}
414
415#[derive(Debug, Serialize, Deserialize)]
418pub(super) struct DirsResp {
419 pub(super) path: String,
421 pub(super) parent: Option<String>,
424 pub(super) home: String,
426 pub(super) cwd: String,
428 pub(super) root: Option<String>,
430 pub(super) dirs: Vec<DirEntry>,
433}
434
435#[derive(Debug, Serialize, Deserialize)]
437pub(super) struct DirEntry {
438 pub(super) name: String,
439 pub(super) path: String,
440}
441
442#[derive(Serialize)]
445pub(super) struct AgentTreeNode {
446 pub(super) run_id: String,
447 pub(super) agent_name: String,
448 pub(super) status: String,
449 pub(super) stage: String,
450 pub(super) iteration: usize,
451 pub(super) prompt_tokens: usize,
452 pub(super) completion_tokens: usize,
453 pub(super) children: Vec<AgentTreeNode>,
454}
455
456#[derive(Debug, Serialize)]
457pub(super) struct TreeStatusNode {
458 pub(super) run_id: String,
459 pub(super) agent_name: String,
460 pub(super) status: String,
461 pub(super) stage: String,
462 pub(super) prompt_tokens: usize,
463 pub(super) completion_tokens: usize,
464 pub(super) subtree_prompt_tokens: usize,
465 pub(super) subtree_completion_tokens: usize,
466 pub(super) children: Vec<TreeStatusNode>,
467}
468
469#[derive(Deserialize)]
472pub(super) struct SubmitInteractionReq {
473 pub(super) request_id: String,
474 pub(super) value: Option<String>,
475 pub(super) choice_index: Option<usize>,
476 pub(super) approved: Option<bool>,
477 pub(super) scope: Option<String>,
478}
479
480#[derive(Deserialize)]
481pub(super) struct SendMessageReq {
482 pub(super) message: String,
483 #[serde(default)]
484 pub(super) target_region: Option<String>,
485}
486
487#[derive(Serialize, Deserialize)]
490pub(super) struct RedactedConfig {
491 pub(super) default_provider: String,
492 pub(super) has_anthropic_key: bool,
493 pub(super) has_openai_key: bool,
494 pub(super) has_google_key: bool,
495 pub(super) has_openrouter_key: bool,
496 pub(super) ollama_base_url: Option<String>,
497 pub(super) agent_paths: Vec<PathBuf>,
498 pub(super) mcp_server_count: usize,
499}
500
501#[derive(Debug, Default, Deserialize)]
505pub(super) struct WriteConfigReq {
506 pub(super) default_provider: Option<String>,
507 pub(super) default_model: Option<String>,
508 pub(super) anthropic_key: Option<String>,
509 pub(super) openai_key: Option<String>,
510 pub(super) google_key: Option<String>,
511 pub(super) openrouter_key: Option<String>,
512 pub(super) ollama_base_url: Option<String>,
513}
514
515#[derive(Debug, Deserialize)]
518pub(super) struct ValidateKeyReq {
519 pub(super) provider: String,
520 pub(super) key: String,
521}
522
523#[derive(Debug, Serialize, Deserialize)]
524pub(super) struct ValidateKeyResp {
525 pub(super) valid: bool,
526 #[serde(skip_serializing_if = "Option::is_none")]
527 pub(super) message: Option<String>,
528}
529
530#[derive(Serialize)]
531pub(super) struct ModelEntry {
532 pub(super) id: String,
533 pub(super) provider: String,
534 pub(super) display_name: Option<String>,
535 pub(super) max_context_tokens: usize,
536 pub(super) max_output_tokens: usize,
537 pub(super) supports_tools: bool,
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 #[test]
545 fn server_event_agent_status_serialization() {
546 let event = ServerEvent::AgentStatus {
547 agent_id: "coder".to_string(),
548 run_id: "run-123".to_string(),
549 status: "running".to_string(),
550 stage: "implement".to_string(),
551 iteration: 5,
552 tool_calls: 12,
553 accepts_messages: true,
554 };
555 let json = serde_json::to_string(&event).unwrap();
556 assert!(json.contains("\"type\":\"agent_status\""));
557 assert!(json.contains("\"agent_id\":\"coder\""));
558 assert!(json.contains("\"iteration\":5"));
559 assert!(json.contains("\"tool_calls\":12"));
560 }
561
562 #[test]
563 fn server_event_tokens_serialization() {
564 let event = ServerEvent::Tokens {
565 agent_id: "coder".to_string(),
566 run_id: "run-123".to_string(),
567 prompt_tokens: 5000,
568 completion_tokens: 1200,
569 cached_tokens: 200,
570 cache_write_tokens: 100,
571 };
572 let json = serde_json::to_string(&event).unwrap();
573 assert!(json.contains("\"type\":\"tokens\""));
574 assert!(json.contains("\"prompt_tokens\":5000"));
575 assert!(json.contains("\"cached_tokens\":200"));
576 assert!(json.contains("\"cache_write_tokens\":100"));
577 }
578
579 #[test]
580 fn server_event_agent_spawned_serialization() {
581 let event = ServerEvent::AgentSpawned {
582 agent_id: "coder".to_string(),
583 run_id: "run-456".to_string(),
584 parent_id: Some("run-123".to_string()),
585 blueprint: "coder".to_string(),
586 };
587 let json = serde_json::to_string(&event).unwrap();
588 assert!(json.contains("\"type\":\"agent_spawned\""));
589 assert!(json.contains("\"parent_id\":\"run-123\""));
590 }
591
592 #[test]
593 fn server_event_agent_completed_serialization() {
594 let event = ServerEvent::AgentCompleted {
595 agent_id: "coder".to_string(),
596 run_id: "run-123".to_string(),
597 status: "complete".to_string(),
598 result: Some("success".to_string()),
599 };
600 let json = serde_json::to_string(&event).unwrap();
601 assert!(json.contains("\"type\":\"agent_completed\""));
602 }
603
604 #[test]
605 fn server_event_context_update_serialization() {
606 let event = ServerEvent::ContextUpdate {
607 agent_id: "coder".to_string(),
608 run_id: "run-123".to_string(),
609 total_tokens: 10000,
610 max_tokens: 200000,
611 };
612 let json = serde_json::to_string(&event).unwrap();
613 assert!(json.contains("\"type\":\"context_update\""));
614 assert!(json.contains("\"total_tokens\":10000"));
615 }
616
617 #[test]
618 fn server_event_interaction_needed_serialization() {
619 let event = ServerEvent::InteractionNeeded {
620 agent_id: "coder".to_string(),
621 run_id: "run-123".to_string(),
622 request: serde_json::json!({"prompt": "approve?"}),
623 };
624 let json = serde_json::to_string(&event).unwrap();
625 assert!(json.contains("\"type\":\"interaction_needed\""));
626 }
627
628 #[test]
629 fn server_event_log_serialization() {
630 let event = ServerEvent::Log {
631 agent_id: "coder".to_string(),
632 run_id: "run-123".to_string(),
633 line: "doing work".to_string(),
634 };
635 let json = serde_json::to_string(&event).unwrap();
636 assert!(json.contains("\"type\":\"log\""));
637 assert!(json.contains("\"line\":\"doing work\""));
638 }
639
640 #[test]
641 fn validate_response_serde_roundtrip() {
642 let resp = ValidateResponse {
643 valid: true,
644 errors: None,
645 warnings: None,
646 };
647 let json = serde_json::to_string(&resp).unwrap();
648 assert_eq!(json, r#"{"valid":true}"#);
650 let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
651 assert!(parsed.valid);
652 assert!(parsed.errors.is_none());
653 assert!(parsed.warnings.is_none());
654 }
655
656 #[test]
657 fn validate_response_with_errors_roundtrip() {
658 let resp = ValidateResponse::invalid(vec!["bad field".to_string()]);
659 let json = serde_json::to_string(&resp).unwrap();
660 let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
661 assert!(!parsed.valid);
662 assert_eq!(parsed.errors.unwrap().len(), 1);
663 assert!(parsed.warnings.is_none());
664 }
665
666 #[test]
668 fn validate_response_with_warnings_roundtrip() {
669 let resp = ValidateResponse {
670 valid: true,
671 errors: None,
672 warnings: Some(vec!["stage 'main': no max_iterations".to_string()]),
673 };
674 let json = serde_json::to_string(&resp).unwrap();
675 let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
676 assert!(parsed.valid);
677 assert_eq!(parsed.warnings.unwrap().len(), 1);
678 }
679
680 #[test]
681 fn redacted_config_serde_roundtrip() {
682 let config = RedactedConfig {
683 default_provider: "anthropic".to_string(),
684 has_anthropic_key: true,
685 has_openai_key: false,
686 has_google_key: false,
687 has_openrouter_key: false,
688 ollama_base_url: None,
689 agent_paths: vec![],
690 mcp_server_count: 0,
691 };
692 let json = serde_json::to_string(&config).unwrap();
693 let parsed: RedactedConfig = serde_json::from_str(&json).unwrap();
694 assert_eq!(parsed.default_provider, "anthropic");
695 assert!(parsed.has_anthropic_key);
696 assert!(!parsed.has_openai_key);
697 }
698
699 #[test]
700 fn error_response_serialization() {
701 let err = ErrorResponse {
702 error: "not found".to_string(),
703 };
704 let json = serde_json::to_string(&err).unwrap();
705 assert!(json.contains("\"error\":\"not found\""));
706 }
707
708 #[test]
709 fn file_content_resp_serde_roundtrip() {
710 let resp = FileContentResp {
711 path: "/work/report.md".to_string(),
712 size: 9,
713 content: "# Report\n".to_string(),
714 truncated: false,
715 };
716 let json = serde_json::to_string(&resp).unwrap();
717 let parsed: FileContentResp = serde_json::from_str(&json).unwrap();
718 assert_eq!(parsed.path, "/work/report.md");
719 assert_eq!(parsed.size, 9);
720 assert_eq!(parsed.content, "# Report\n");
721 assert!(!parsed.truncated);
722 }
723
724 #[test]
725 fn dirs_resp_serde_roundtrip() {
726 let resp = DirsResp {
727 path: "/work".to_string(),
728 parent: None,
729 home: "/Users/someone".to_string(),
730 cwd: "/work/project".to_string(),
731 root: Some("/work".to_string()),
732 dirs: vec![DirEntry {
733 name: "src".to_string(),
734 path: "/work/src".to_string(),
735 }],
736 };
737 let json = serde_json::to_string(&resp).unwrap();
738 assert!(json.contains("\"parent\":null"));
741 assert!(json.contains(r#"{"name":"src","path":"/work/src"}"#));
742 let parsed: DirsResp = serde_json::from_str(&json).unwrap();
743 assert_eq!(parsed.path, "/work");
744 assert!(parsed.parent.is_none());
745 assert_eq!(parsed.root.as_deref(), Some("/work"));
746 assert_eq!(parsed.dirs.len(), 1);
747 assert_eq!(parsed.dirs[0].name, "src");
748 }
749
750 #[test]
751 fn doctor_resp_serde_roundtrip() {
752 let resp = DoctorResp {
753 checks: vec![
754 DoctorCheck {
755 name: "config".to_string(),
756 ok: true,
757 detail: "default_provider=anthropic".to_string(),
758 elapsed_ms: None,
759 },
760 DoctorCheck {
761 name: "inference".to_string(),
762 ok: false,
763 detail: "HTTP 401: bad key".to_string(),
764 elapsed_ms: Some(1200),
765 },
766 ],
767 };
768 let json = serde_json::to_string(&resp).unwrap();
769 assert!(
771 json.contains(r#"{"name":"config","ok":true,"detail":"default_provider=anthropic"}"#)
772 );
773 assert!(json.contains("\"elapsed_ms\":1200"));
774 let parsed: DoctorResp = serde_json::from_str(&json).unwrap();
775 assert_eq!(parsed.checks.len(), 2);
776 assert!(parsed.checks[0].ok);
777 assert!(parsed.checks[0].elapsed_ms.is_none());
778 assert!(!parsed.checks[1].ok);
779 assert_eq!(parsed.checks[1].elapsed_ms, Some(1200));
780 }
781
782 #[test]
783 fn server_event_run_id_covers_every_variant() {
784 let cases: Vec<(ServerEvent, &str)> = vec![
785 (
786 ServerEvent::AgentStatus {
787 agent_id: "a".to_string(),
788 run_id: "r1".to_string(),
789 status: "active".to_string(),
790 stage: "s".to_string(),
791 iteration: 0,
792 tool_calls: 0,
793 accepts_messages: false,
794 },
795 "r1",
796 ),
797 (
798 ServerEvent::ContextUpdate {
799 agent_id: "a".to_string(),
800 run_id: "r2".to_string(),
801 total_tokens: 1,
802 max_tokens: 2,
803 },
804 "r2",
805 ),
806 (
807 ServerEvent::Log {
808 agent_id: "a".to_string(),
809 run_id: "r3".to_string(),
810 line: "l".to_string(),
811 },
812 "r3",
813 ),
814 (
815 ServerEvent::InteractionNeeded {
816 agent_id: "a".to_string(),
817 run_id: "r4".to_string(),
818 request: serde_json::Value::Null,
819 },
820 "r4",
821 ),
822 (
823 ServerEvent::AgentSpawned {
824 agent_id: "a".to_string(),
825 run_id: "r5".to_string(),
826 parent_id: None,
827 blueprint: "b".to_string(),
828 },
829 "r5",
830 ),
831 (
832 ServerEvent::AgentCompleted {
833 agent_id: "a".to_string(),
834 run_id: "r6".to_string(),
835 status: "complete".to_string(),
836 result: None,
837 },
838 "r6",
839 ),
840 (
841 ServerEvent::Tokens {
842 agent_id: "a".to_string(),
843 run_id: "r7".to_string(),
844 prompt_tokens: 0,
845 completion_tokens: 0,
846 cached_tokens: 0,
847 cache_write_tokens: 0,
848 },
849 "r7",
850 ),
851 (
852 ServerEvent::World {
853 event: serde_json::json!({"event": "stage_transition", "run_id": "r8"}),
854 },
855 "r8",
856 ),
857 (
860 ServerEvent::World {
861 event: serde_json::Value::Null,
862 },
863 "",
864 ),
865 ];
866 for (ev, want) in cases {
867 assert_eq!(ev.run_id(), want);
868 }
869 }
870}