//! The native `console` actor: your terminal. //! //! - `@ ` sends a JSON body. `@ ` sends //! `{"type": "text", "text": ...}`. //! - A line without `@` goes to `agent` as text. //! - `/deploy ` builds a Rust folder with `cargo`, then spawns //! or upgrades the actor. //! - `/list`, `/sleep `, `/help`, `/quit`. use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use anyhow::{Result, anyhow, bail}; use serde_json::{Value, json}; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::sync::{mpsc, oneshot}; use crate::broker::{Broker, Envelope}; pub const ID: &str = "console"; const HELP: &str = "\ @ send a JSON body to an actor @ send {\"type\":\"text\",\"text\":...} send text to the agent /deploy build a Rust folder with cargo, then spawn or upgrade /list list actors /sleep wait (for scripts) /await wait for the next message from an actor (for scripts) /quit stop all actors and exit"; pub struct Console { broker: Arc, waiters: Mutex>>, seq: AtomicU64, /// Number of messages received per sender, for `/await`. seen: Mutex>, arrived: tokio::sync::Notify, } fn show(env: &Envelope) { let reply = if env.reply_to.is_some() { " ↩" } else { "" }; let b = &env.body; if b["type"] == "text" && let Some(t) = b["text"].as_str() { println!("[{}{reply}] {t}", env.from); return; } let mut compact = b.clone(); let mut extra = Vec::new(); if let Some(obj) = compact.as_object_mut() { for key in ["errors", "warnings", "reason"] { if let Some(Value::String(s)) = obj.get(key) && s.contains('\n') { extra.push((key, s.clone())); obj.remove(key); } } } println!("[{}{reply}] {compact}", env.from); for (k, s) in extra { println!(" {k}:\n{}", s.lines().map(|l| format!(" {l}")).collect::>().join("\n")); } } impl Console { pub fn new(broker: Arc) -> Arc { Arc::new(Self { broker, waiters: Mutex::new(HashMap::new()), seq: AtomicU64::new(1), seen: Mutex::new(HashMap::new()), arrived: tokio::sync::Notify::new(), }) } pub async fn run_mailbox(self: Arc, mut mailbox: mpsc::Receiver) { while let Some(env) = mailbox.recv().await { show(&env); *self.seen.lock().unwrap().entry(env.from.clone()).or_default() += 1; self.arrived.notify_waiters(); if let Some(r) = &env.reply_to && let Some(tx) = self.waiters.lock().unwrap().remove(r) { let _ = tx.send(env); } } } fn send(&self, to: &str, body: Value) -> String { let own = format!("c{}", self.seq.fetch_add(1, Ordering::Relaxed)); self.broker.send(ID, Some(to), Some(&own), None, body) } async fn request(&self, to: &str, body: Value, timeout: Duration) -> Result { let own = format!("c{}", self.seq.fetch_add(1, Ordering::Relaxed)); let (tx, rx) = oneshot::channel(); self.waiters.lock().unwrap().insert(format!("{ID}/{own}"), tx); self.broker.send(ID, Some(to), Some(&own), None, body); tokio::time::timeout(timeout, rx) .await .map_err(|_| anyhow!("no reply from {to} within {timeout:?}"))? .map_err(|_| anyhow!("reply channel closed")) } async fn deploy(&self, actor: &str, dir: &str) -> Result<()> { let path = std::path::absolute(dir)?; let source = path.display().to_string(); println!("· building {source} …"); let built = self.request(crate::cargo_actor::ID, json!({"type": "build", "source": source}), Duration::from_secs(900)).await?; if built.body["type"] != "built" { bail!("build failed"); } let exists = self.broker.registry().actors.contains_key(actor); let ty = if exists { "upgrade" } else { "spawn" }; println!("· {ty} {actor} …"); let body = json!({ "type": ty, "actor": actor, "wasm": built.body["wasm"], "source": source, "built_by": built.body["built_by"], }); let r = self.request("broker", body, Duration::from_secs(300)).await?; if r.body["type"] == "error" { bail!("{ty} failed"); } Ok(()) } /// Read commands until EOF or `/quit`. pub async fn run_input(self: Arc) { println!("Sausage console. Type /help for commands."); let mut lines = BufReader::new(tokio::io::stdin()).lines(); while let Ok(Some(line)) = lines.next_line().await { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } if let Err(e) = self.command(line).await { if e.to_string() == "quit" { break; } println!("! {e:#}"); } } } async fn command(&self, line: &str) -> Result<()> { if let Some(rest) = line.strip_prefix('@') { let (to, text) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); let text = text.trim(); let body = match serde_json::from_str::(text) { Ok(v @ Value::Object(_)) => v, _ => json!({"type": "text", "text": text}), }; self.send(to, body); return Ok(()); } if let Some(cmd) = line.strip_prefix('/') { let mut parts = cmd.split_whitespace(); match parts.next() { Some("help") => println!("{HELP}"), Some("quit") => bail!("quit"), Some("list") => { self.send("broker", json!({"type": "list"})); } Some("sleep") => { let secs: f64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(1.0); tokio::time::sleep(Duration::from_secs_f64(secs)).await; } Some("await") => { let from = parts.next().unwrap_or("agent").to_string(); let secs: f64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(60.0); let count = |c: &Self| c.seen.lock().unwrap().get(&from).copied().unwrap_or(0); let before = count(self); let deadline = tokio::time::Instant::now() + Duration::from_secs_f64(secs); loop { let notified = self.arrived.notified(); tokio::pin!(notified); notified.as_mut().enable(); if count(self) != before { break; } if tokio::time::timeout_at(deadline, notified).await.is_err() { bail!("no message from {from} within {secs} s"); } } } Some("deploy") => { let (Some(actor), Some(dir)) = (parts.next(), parts.next()) else { bail!("usage: /deploy "); }; self.deploy(actor, dir).await?; } _ => bail!("unknown command. Type /help."), } return Ok(()); } self.send("agent", json!({"type": "text", "text": line})); Ok(()) } }