Write new journal entries under logs/core and merge read-only legacy history without moving SQLite/WAL files. Atomically spool failed batches, retaining unspoolable entries for event-driven retries. Journal full trap diagnostics before UTF-8-safe status display. Port session overflow archives and followable line/byte excerpts to the still-shipped embedded agent. Document retention/recovery and cover the contracts with regressions. Include one rustfmt-only normalization in the existing SDK WASI test. Card: SAU-143
451 lines
15 KiB
Rust
451 lines
15 KiB
Rust
//! Binary contracts for atomic init, environment flags, signals, and the piped console.
|
|
use sausage::{Host, world::World};
|
|
use serde_json::{Value, json};
|
|
use std::{
|
|
path::{Path, PathBuf},
|
|
process::Stdio,
|
|
time::Duration,
|
|
};
|
|
use tokio::{
|
|
io::AsyncWriteExt,
|
|
process::{Child, Command},
|
|
};
|
|
|
|
use super::common::{CACHE, probe};
|
|
|
|
fn binary() -> Command {
|
|
let mut command = Command::new(env!("CARGO_BIN_EXE_sausage"));
|
|
command
|
|
.arg("--cache-dir")
|
|
.arg(CACHE)
|
|
.env_remove("SAUSAGE_HEADLESS")
|
|
.env_remove("SAUSAGE_LOOP")
|
|
.kill_on_drop(true);
|
|
command
|
|
}
|
|
fn world() -> (tempfile::TempDir, PathBuf) {
|
|
let folder = tempfile::tempdir().unwrap();
|
|
let world = World::create(&folder.path().join("world")).unwrap();
|
|
(folder, world.root)
|
|
}
|
|
async fn status(child: &mut Child) -> std::process::ExitStatus {
|
|
tokio::time::timeout(Duration::from_secs(15), child.wait())
|
|
.await
|
|
.expect("CLI did not exit")
|
|
.unwrap()
|
|
}
|
|
async fn journal_exists(world: &Path) {
|
|
tokio::time::timeout(Duration::from_secs(15), async {
|
|
while !world.join("logs/core/journal.sqlite").exists() {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
}
|
|
fn signal(child: &Child, signal: nix::sys::signal::Signal) {
|
|
nix::sys::signal::kill(
|
|
nix::unistd::Pid::from_raw(child.id().unwrap() as i32),
|
|
signal,
|
|
)
|
|
.unwrap();
|
|
}
|
|
fn install_probe(world: &Path) {
|
|
std::fs::create_dir_all(world.join("plugins/probe")).unwrap();
|
|
std::fs::copy(probe(), world.join("plugins/probe/plugin.wasm")).unwrap();
|
|
}
|
|
|
|
/// Failed init leaves no world; successful init seeds the restart policy and a working agent.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn init_builds_agent() {
|
|
tokio::time::timeout(Duration::from_secs(300), init_builds_agent_inner())
|
|
.await
|
|
.expect("init and seeded-agent checks exceeded 300 seconds");
|
|
}
|
|
|
|
async fn init_builds_agent_inner() {
|
|
let temporary = tempfile::tempdir().unwrap();
|
|
let world = temporary.path().join("world");
|
|
// Legacy configuration variables must not affect the templates.
|
|
let configured = || {
|
|
let mut command = binary();
|
|
command
|
|
.current_dir(temporary.path())
|
|
.env("SAUSAGE_API", "not-an-api")
|
|
.env("SAUSAGE_BASE_URL", "http://127.0.0.1:1/")
|
|
.env("SAUSAGE_MODEL", "init-test")
|
|
.env("SAUSAGE_API_TOKEN", "fake-token")
|
|
.stdin(Stdio::null());
|
|
command
|
|
};
|
|
let failed = configured()
|
|
.arg("init")
|
|
.env("SAUSAGE_HEADLESS", "not-a-boolean")
|
|
.env("RUSTC", "/nonexistent-sausage-rustc")
|
|
.output()
|
|
.await
|
|
.unwrap();
|
|
assert!(!failed.status.success());
|
|
assert!(String::from_utf8_lossy(&failed.stderr).contains("nonexistent-sausage-rustc"));
|
|
assert!(!world.exists());
|
|
let output = configured().arg("init").output().await.unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"stdout: {}\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&output.stdout).contains("first agent build takes a few minutes")
|
|
);
|
|
// Bare sausage reopens the completed world instead of trying to recreate it.
|
|
assert!(configured().output().await.unwrap().status.success());
|
|
let prompt = std::fs::read_to_string(world.join("plugins/agent/source/src/prompt.md")).unwrap();
|
|
assert!(prompt.contains("HARD RULE: the mediator and all agents NEVER call `core.restart`"));
|
|
assert!(prompt.contains("without the person's explicit approval each time"));
|
|
assert!(prompt.contains("alias `core.exit`"));
|
|
for text in [
|
|
"`#loop`",
|
|
"SAUSAGE_LOOP=1",
|
|
"answers before",
|
|
"exit code 75",
|
|
] {
|
|
assert!(prompt.contains(text), "seeded prompt missing {text}");
|
|
}
|
|
let boot = std::fs::read_to_string(world.join("config/boot.yaml")).unwrap();
|
|
assert!(boot.contains("SAUSAGE_LOOP=1"));
|
|
let mut config: Value =
|
|
serde_yaml_ng::from_slice(&std::fs::read(world.join("config/agent.yaml")).unwrap())
|
|
.unwrap();
|
|
assert_eq!(config["token"], "CHANGE_ME");
|
|
assert_eq!(config["api"], "openai-responses");
|
|
assert!(String::from_utf8_lossy(&output.stdout).contains("config/agent.yaml"));
|
|
assert_eq!(
|
|
serde_yaml_ng::from_slice::<Value>(&std::fs::read(world.join("config/boot.yaml")).unwrap())
|
|
.unwrap(),
|
|
json!([])
|
|
);
|
|
for file in [
|
|
"plugins/cargo/sdk/Cargo.toml",
|
|
"plugins/cargo/sdk/src/lib.rs",
|
|
"plugins/agent/source/Cargo.toml",
|
|
"plugins/agent/source/src/main.rs",
|
|
"plugins/agent/plugin.wasm",
|
|
] {
|
|
assert!(world.join(file).is_file(), "missing {file}");
|
|
}
|
|
assert_eq!(
|
|
std::fs::read(world.join("plugins/agent/source/Cargo.lock")).unwrap(),
|
|
include_bytes!("../../../Cargo.lock")
|
|
);
|
|
assert!(!world.join("state/cargo/target").exists());
|
|
assert!(
|
|
Path::new(CACHE)
|
|
.join("cargo-target/wasm32-wasip2/release")
|
|
.is_dir()
|
|
);
|
|
let host = Host::start_with_cache(&world, Path::new(CACHE))
|
|
.await
|
|
.unwrap();
|
|
let doc = host.call("agent.rpc.discover", json!({})).await.unwrap();
|
|
assert_eq!(doc["x-sausage-grace"], 600);
|
|
assert!(
|
|
doc["methods"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|method| method["name"] == "chat")
|
|
);
|
|
let error = host
|
|
.call("agent.chat", json!({"text":"hello"}))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(
|
|
error["message"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("/config/agent.yaml")
|
|
);
|
|
assert!(error["message"].as_str().unwrap().contains("token"));
|
|
let mut server = super::agent::Llm::new("anthropic-messages").await;
|
|
config["api"] = json!("anthropic-messages");
|
|
config["base_url"] = json!(server.url);
|
|
config["model"] = json!("scripted-test");
|
|
config["token"] = json!("fake-token");
|
|
std::fs::write(
|
|
world.join("config/agent.yaml"),
|
|
serde_yaml_ng::to_string(&config).unwrap(),
|
|
)
|
|
.unwrap();
|
|
let caller = host.clone();
|
|
let turn =
|
|
tokio::spawn(async move { caller.call("agent.chat", json!({"text":"hello"})).await });
|
|
let (_, reply) = server.next().await;
|
|
reply
|
|
.send((200, server.answer("configured agent", &[])))
|
|
.unwrap();
|
|
assert_eq!(turn.await.unwrap().unwrap(), "configured agent");
|
|
host.stop().await;
|
|
}
|
|
|
|
/// Both headless forms ignore EOF. False and empty values preserve the plain console.
|
|
#[tokio::test]
|
|
async fn headless_flag_and_environment() {
|
|
for value in [None, Some("1"), Some("true")] {
|
|
let (_folder, world) = world();
|
|
let mut command = binary();
|
|
command.arg("run").arg(&world).stdin(Stdio::null());
|
|
if let Some(value) = value {
|
|
command.env("SAUSAGE_HEADLESS", value);
|
|
} else {
|
|
command.arg("--headless");
|
|
}
|
|
let mut child = command.spawn().unwrap();
|
|
journal_exists(&world).await;
|
|
assert!(
|
|
tokio::time::timeout(Duration::from_millis(300), child.wait())
|
|
.await
|
|
.is_err()
|
|
);
|
|
signal(&child, nix::sys::signal::Signal::SIGTERM);
|
|
assert_eq!(status(&mut child).await.code(), Some(0));
|
|
}
|
|
for value in ["0", ""] {
|
|
let (_folder, world) = world();
|
|
let mut child = binary()
|
|
.arg("run")
|
|
.arg(world)
|
|
.env("SAUSAGE_HEADLESS", value)
|
|
.stdin(Stdio::null())
|
|
.spawn()
|
|
.unwrap();
|
|
assert_eq!(status(&mut child).await.code(), Some(0));
|
|
}
|
|
}
|
|
|
|
/// Signals interrupt an unanswered boot service instead of waiting forever.
|
|
#[tokio::test]
|
|
async fn signals_work_during_boot() {
|
|
for sig in [
|
|
nix::sys::signal::Signal::SIGTERM,
|
|
nix::sys::signal::Signal::SIGINT,
|
|
] {
|
|
let (_folder, world) = world();
|
|
std::fs::write(
|
|
world.join("config/boot.yaml"),
|
|
json!([
|
|
{"method":"core.lock","params":{"path":"boot"}},
|
|
{"method":"core.lock","params":{"path":"boot"}}
|
|
])
|
|
.to_string(),
|
|
)
|
|
.unwrap();
|
|
let mut child = binary()
|
|
.arg("run")
|
|
.arg(&world)
|
|
.arg("--headless")
|
|
.stdin(Stdio::null())
|
|
.spawn()
|
|
.unwrap();
|
|
journal_exists(&world).await;
|
|
tokio::time::timeout(Duration::from_secs(15), async {
|
|
loop {
|
|
let db =
|
|
rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
|
|
if let Ok(2) = db.query_row(
|
|
"SELECT count(*) FROM journal WHERE line='service start core.lock'",
|
|
[],
|
|
|row| row.get::<_, i64>(0),
|
|
) {
|
|
break;
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
signal(&child, sig);
|
|
assert_eq!(status(&mut child).await.code(), Some(0));
|
|
}
|
|
}
|
|
|
|
/// Piped input supports commands, quit, and the restart exit code.
|
|
#[tokio::test]
|
|
async fn piped_console_and_exit_codes() {
|
|
for (line, supervised, expected) in [
|
|
("help\n", false, 0),
|
|
("quit\n", false, 0),
|
|
("core.restart\n", false, 0),
|
|
("core.exit\n", false, 0),
|
|
("core.restart\n", true, 75),
|
|
("core.exit\n", true, 75),
|
|
] {
|
|
let (_folder, world) = world();
|
|
let mut command = binary();
|
|
command.arg("run").arg(world);
|
|
if supervised {
|
|
command.env("SAUSAGE_LOOP", "1");
|
|
}
|
|
let mut child = command
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.spawn()
|
|
.unwrap();
|
|
// Supervised calls must finish via restart, not a racing console EOF.
|
|
let mut input = child.stdin.take().unwrap();
|
|
input.write_all(line.as_bytes()).await.unwrap();
|
|
if !supervised {
|
|
drop(input);
|
|
}
|
|
let output = tokio::time::timeout(Duration::from_secs(15), child.wait_with_output())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(output.status.code(), Some(expected));
|
|
if !supervised && line.starts_with("core.") {
|
|
assert!(String::from_utf8_lossy(&output.stdout).contains("SAUSAGE_LOOP not set"));
|
|
}
|
|
if expected == 0 {
|
|
assert!(!output.stdout.is_empty());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A console input error still stops live actors and flushes the journal.
|
|
#[tokio::test]
|
|
async fn console_error_stops_host() {
|
|
let (_folder, world) = world();
|
|
install_probe(&world);
|
|
std::fs::write(
|
|
world.join("config/boot.yaml"),
|
|
json!([{"method":"probe.serve","params":{}}]).to_string(),
|
|
)
|
|
.unwrap();
|
|
let mut child = binary()
|
|
.arg("run")
|
|
.arg(&world)
|
|
.stdin(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap();
|
|
child
|
|
.stdin
|
|
.take()
|
|
.unwrap()
|
|
.write_all(&[255, 10])
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(status(&mut child).await.code(), Some(1));
|
|
let db = rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
|
|
let stopped: i64 = db
|
|
.query_row(
|
|
"SELECT count(*) FROM journal WHERE plugin='probe' AND line='stop'",
|
|
[],
|
|
|row| row.get(0),
|
|
)
|
|
.unwrap();
|
|
assert!(stopped > 0);
|
|
}
|
|
|
|
/// SIGINT stops only the wait, so ps can still find the live call.
|
|
#[tokio::test]
|
|
async fn interrupt_leaves_call_running() {
|
|
let (_folder, world) = world();
|
|
install_probe(&world);
|
|
let mut child = binary()
|
|
.arg("run")
|
|
.arg(&world)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.spawn()
|
|
.unwrap();
|
|
let mut input = child.stdin.take().unwrap();
|
|
input.write_all(b"probe.busy\n").await.unwrap();
|
|
let pid = tokio::time::timeout(Duration::from_secs(20), async {
|
|
loop {
|
|
if world.join("logs/core/journal.sqlite").exists() {
|
|
let db =
|
|
rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
|
|
if let Ok(pid) = db.query_row(
|
|
"SELECT pid FROM journal WHERE plugin='probe' AND line='start busy {}'",
|
|
[],
|
|
|r| r.get::<_, String>(0),
|
|
) {
|
|
break pid;
|
|
}
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
signal(&child, nix::sys::signal::Signal::SIGINT);
|
|
input
|
|
.write_all(format!("ps\nkill {pid}\nquit\n").as_bytes())
|
|
.await
|
|
.unwrap();
|
|
drop(input);
|
|
let output = tokio::time::timeout(Duration::from_secs(20), child.wait_with_output())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let text = String::from_utf8(output.stdout).unwrap();
|
|
assert_eq!(output.status.code(), Some(0), "{text}");
|
|
assert!(
|
|
text.contains("stopped waiting; the call continues, see ps"),
|
|
"{text}"
|
|
);
|
|
assert!(
|
|
text.contains(&format!("{pid} probe busy console running")),
|
|
"{text}"
|
|
);
|
|
}
|
|
|
|
/// Piped calls wait for activation, and EOF or quit preserves the last result.
|
|
#[tokio::test]
|
|
async fn piped_console_runs_dependent_commands_in_order() {
|
|
for ending in ["", "quit\n"] {
|
|
let (_folder, world) = world();
|
|
std::fs::copy(probe(), world.join("candidate.wasm")).unwrap();
|
|
std::fs::create_dir_all(world.join("state/probe")).unwrap();
|
|
std::fs::write(world.join("state/probe/discover.json"), r#"{"methods":[]}"#).unwrap();
|
|
let activate = r#"core.activate {"plugin":"notes","wasm":"candidate.wasm","force":true}"#;
|
|
let call = r#"notes.echo {"ordered":true}"#;
|
|
let mut child = binary()
|
|
.arg("run")
|
|
.arg(&world)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap();
|
|
child
|
|
.stdin
|
|
.take()
|
|
.unwrap()
|
|
.write_all(format!("{activate}\n{call}\n{ending}").as_bytes())
|
|
.await
|
|
.unwrap();
|
|
let output = tokio::time::timeout(Duration::from_secs(20), child.wait_with_output())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
assert_eq!(
|
|
output.status.code(),
|
|
Some(0),
|
|
"{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
assert!(!stdout.contains("error:"), "{stdout}");
|
|
assert!(stdout.contains(r#""ordered": true"#), "{stdout}");
|
|
assert!(!stdout.contains("> core.activate"), "{stdout}");
|
|
if !ending.is_empty() {
|
|
assert!(
|
|
stdout.find(r#""ordered": true"#).unwrap() < stdout.find("Goodbye.").unwrap(),
|
|
"{stdout}"
|
|
);
|
|
}
|
|
}
|
|
}
|