Imported from the spike branch, with: - a library crate (Host API) for the CLI and the tests - JSON test scenarios for candidates - commit on close only for new or truncated files - a Nix flake: nix run -- init, nix run -- run
208 lines
7.8 KiB
Rust
208 lines
7.8 KiB
Rust
//! The native `console` actor: your terminal.
|
|
//!
|
|
//! - `@<actor> <json>` sends a JSON body. `@<actor> <text>` sends
|
|
//! `{"type": "text", "text": ...}`.
|
|
//! - A line without `@` goes to `agent` as text.
|
|
//! - `/deploy <actor> <folder>` builds a Rust folder with `cargo`, then spawns
|
|
//! or upgrades the actor.
|
|
//! - `/list`, `/sleep <seconds>`, `/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 = "\
|
|
@<actor> <json> send a JSON body to an actor
|
|
@<actor> <text> send {\"type\":\"text\",\"text\":...}
|
|
<text> send text to the agent
|
|
/deploy <actor> <dir> build a Rust folder with cargo, then spawn or upgrade
|
|
/list list actors
|
|
/sleep <seconds> wait (for scripts)
|
|
/await <actor> <secs> wait for the next message from an actor (for scripts)
|
|
/quit stop all actors and exit";
|
|
|
|
pub struct Console {
|
|
broker: Arc<Broker>,
|
|
waiters: Mutex<HashMap<String, oneshot::Sender<Envelope>>>,
|
|
seq: AtomicU64,
|
|
/// Number of messages received per sender, for `/await`.
|
|
seen: Mutex<HashMap<String, u64>>,
|
|
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::<Vec<_>>().join("\n"));
|
|
}
|
|
}
|
|
|
|
impl Console {
|
|
pub fn new(broker: Arc<Broker>) -> Arc<Self> {
|
|
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<Self>, mut mailbox: mpsc::Receiver<Envelope>) {
|
|
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<Envelope> {
|
|
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<Self>) {
|
|
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::<Value>(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 <actor> <dir>");
|
|
};
|
|
self.deploy(actor, dir).await?;
|
|
}
|
|
_ => bail!("unknown command. Type /help."),
|
|
}
|
|
return Ok(());
|
|
}
|
|
self.send("agent", json!({"type": "text", "text": line}));
|
|
Ok(())
|
|
}
|
|
}
|