Sausage: host, broker, commit-on-close filesystem, cargo actor, console, agent
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
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
//! 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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user