| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | | use futures::future::BoxFuture; |
| 7 | | use serde_json::{json, Value as Json}; |
| 8 | | use spectra_core::authn::Caller; |
| 9 | | use spectra_core::daemon::Daemon; |
| 10 | | use spectra_core::principals::Principal; |
| 11 | | use std::sync::Arc; |
| 12 | |
|
| 13 | |
|
| 14 | | pub struct Ctx { |
| 15 | | pub daemon: Arc<Daemon>, |
| 16 | | pub caller: Caller, |
| 17 | | pub session: Option<String>, |
| 18 | | } |
| 19 | |
|
| 20 | |
|
| 21 | |
|
| 22 | | #[derive(Clone, Debug, Default)] |
| 23 | | pub struct Output { |
| 24 | | pub text: String, |
| 25 | | pub structured: Option<Json>, |
| 26 | | pub is_error: bool, |
| 27 | | } |
| 28 | |
|
| 29 | | impl Output { |
| 30 | 1× | pub fn ok(text: impl Into<String>, structured: Json) -> Output { |
| 31 | 1× | Output { text: text.into(), structured: Some(structured), is_error: false } |
| 32 | 1× | } |
| 33 | 0× | pub fn err(text: impl Into<String>) -> Output { |
| 34 | 0× | Output { text: text.into(), structured: None, is_error: true } |
| 35 | 0× | } |
| 36 | | } |
| 37 | |
|
| 38 | | pub type Run = for<'a> fn(&'a Ctx, Json) -> BoxFuture<'a, Output>; |
| 39 | |
|
| 40 | |
|
| 41 | | pub struct Tool { |
| 42 | | pub name: &'static str, |
| 43 | | pub description: &'static str, |
| 44 | | pub schema: fn() -> Json, |
| 45 | | pub read_only: bool, |
| 46 | | pub destructive: bool, |
| 47 | | |
| 48 | | pub long: bool, |
| 49 | | pub run: Run, |
| 50 | | } |
| 51 | |
|
| 52 | | impl Tool { |
| 53 | 2× | pub fn describe(&self) -> Json { |
| 54 | 2× | json!({ |
| 55 | 2× | "name": self.name, |
| 56 | 2× | "description": self.description, |
| 57 | 2× | "inputSchema": (self.schema)(), |
| 58 | 2× | "annotations": { "readOnlyHint": self.read_only, "destructiveHint": self.destructive }, |
| 59 | | }) |
| 60 | 2× | } |
| 61 | | } |
| 62 | |
|
| 63 | |
|
| 64 | 2× | pub fn all() -> &'static [Tool] { |
| 65 | 2× | TOOLS |
| 66 | 2× | } |
| 67 | |
|
| 68 | 1× | pub fn find(name: &str) -> Option<&'static Tool> { |
| 69 | 1× | TOOLS.iter().find(|t| t.name == name) |
| 70 | 1× | } |
| 71 | |
|
| 72 | | static TOOLS: &[Tool] = &[Tool { |
| 73 | | name: "agents", |
| 74 | | description: "Every agent with its owner, kind, project, presence and status.", |
| 75 | 2× | schema: || json!({ "type": "object", "properties": { "project": { "type": "string", "description": "Only this project's agents" } }, "additionalProperties": false }), |
| 76 | | read_only: true, |
| 77 | | destructive: false, |
| 78 | | long: false, |
| 79 | | run: agents, |
| 80 | | }]; |
| 81 | |
|
| 82 | |
|
| 83 | 0× | pub fn who(p: &Principal) -> String { |
| 84 | 0× | p.display() |
| 85 | 0× | } |
| 86 | |
|
| 87 | 1× | fn agents(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> { |
| 88 | 1× | Box::pin(async move { |
| 89 | 1× | let project = args.get("project").and_then(Json::as_str); |
| 90 | 1× | let rows = sqlx_rows(&ctx.daemon, project).await; |
| 91 | 1× | let rows = match rows { |
| 92 | 1× | Ok(r) => r, |
| 93 | 0× | Err(e) => return Output::err(format!("could not list agents: {e}")), |
| 94 | | }; |
| 95 | 1× | let list: Vec<Json> = rows |
| 96 | 1× | .iter() |
| 97 | 1× | .map(|(name, owner, kind, project, presence, status)| { |
| 98 | 1× | json!({ "name": name, "owner": owner, "kind": kind, "project": project, "presence": presence.clone().unwrap_or_else(|| "offline".into()), "status": status }) |
| 99 | 1× | }) |
| 100 | 1× | .collect(); |
| 101 | 1× | let text = if rows.is_empty() { |
| 102 | 0× | "No agents.".to_string() |
| 103 | | } else { |
| 104 | 1× | rows.iter() |
| 105 | 1× | .map(|(name, owner, kind, project, presence, status)| { |
| 106 | 1× | let st = status.as_deref().map(|s| format!(" — \"{s}\"")).unwrap_or_default(); |
| 107 | 1× | format!("- {name} · {owner}'s agent ({kind}, #{project}): {}{st}", presence.as_deref().unwrap_or("offline")) |
| 108 | 1× | }) |
| 109 | 1× | .collect::<Vec<_>>() |
| 110 | 1× | .join("\n") |
| 111 | | }; |
| 112 | 1× | Output::ok(text, json!({ "agents": list })) |
| 113 | 1× | }) |
| 114 | 1× | } |
| 115 | |
|
| 116 | | #[allow(clippy::type_complexity)] |
| 117 | 1× | async fn sqlx_rows( |
| 118 | 1× | d: &Daemon, |
| 119 | 1× | project: Option<&str>, |
| 120 | 1× | ) -> Result<Vec<(String, String, String, String, Option<String>, Option<String>)>, sqlx::Error> { |
| 121 | 1× | sqlx::query_as( |
| 122 | 1× | "SELECT a.name, a.owner_name, a.kind, a.project, p.state, p.status_text FROM agents a LEFT JOIN presence p ON p.agent = a.name \ |
| 123 | 1× | WHERE a.disabled_ms IS NULL AND (? IS NULL OR a.project = ?) ORDER BY a.name", |
| 124 | 1× | ) |
| 125 | 1× | .bind(project) |
| 126 | 1× | .bind(project) |
| 127 | 1× | .fetch_all(d.ledger.pool()) |
| 128 | 1× | .await |
| 129 | 1× | } |