Trim the harness to what it needs to keep going

Host:
- test first: a new version is tested where it lies; only an installed
  version goes to versions/, and instances run from there
- data/ and public/ are shared by all versions; no snapshots; rollback
  restores code only
- no re-delivery, status.json, list, remove/archive, smoke test, data/
  hiding, or stdin timeout; rollback needs a version
- supervisor.rs split out; one place for installing a version
- init only writes the agent folder; run installs it like any actor
- console: @actor, text to the agent, /help, /quit

Agent:
- every text goes out as a broadcast; 5-line prompt; 5 tools that describe
  themselves; turn.json only marks an active turn

Docs: PROTOCOL.md, README.md, DECISIONS.md, AGENTS.md rewritten.
Examples: only notes and notes-v2.
This commit is contained in:
2026-10-15 13:35:45 +01:00
parent cee862275c
commit 11144b947e
35 changed files with 909 additions and 2038 deletions
+38 -178
View File
@@ -3,205 +3,65 @@
//! - `@<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`.
//! - `/help`, `/quit`. EOF on stdin also quits.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::sync::Arc;
use anyhow::{Result, anyhow, bail};
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::{mpsc, oneshot};
use tokio::sync::mpsc;
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,
}
@<actor> <json> send a JSON body to an actor
@<actor> <text> send {\"type\":\"text\",\"text\":...}
<text> send text to the agent
/quit stop all actors and exit";
/// Print a message: text bodies as text, everything else as JSON.
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"));
match env.body["text"].as_str() {
Some(text) if env.body["type"] == "text" => println!("[{}{reply}] {text}", env.from),
_ => println!("[{}{reply}] {}", env.from, env.body),
}
}
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(),
})
/// Print every message that arrives for the console.
pub async fn run_mailbox(mut mailbox: mpsc::Receiver<Envelope>) {
while let Some(env) = mailbox.recv().await {
show(&env);
}
}
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.meta(actor).is_some_and(|m| m.version.is_some());
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('#') {
/// Read lines until EOF or `/quit`, and send them as messages.
pub async fn run_input(broker: Arc<Broker>) {
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();
let (to, body) = match line {
"" => continue,
"/quit" => break,
"/help" => {
println!("{HELP}");
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>");
_ => match line.strip_prefix('@') {
Some(rest) => {
let (to, text) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
let body = match serde_json::from_str::<Value>(text.trim()) {
Ok(v @ Value::Object(_)) => v,
_ => json!({"type": "text", "text": text.trim()}),
};
self.deploy(actor, dir).await?;
(to, body)
}
_ => bail!("unknown command. Type /help."),
}
return Ok(());
}
self.send("agent", json!({"type": "text", "text": line}));
Ok(())
None => ("agent", json!({"type": "text", "text": line})),
},
};
broker.send(ID, Some(to), None, None, body);
}
}