core: review fixes, init builds the agent, console without a terminal

Activation path (review T6R): a broken plugin or a stray file in
/plugins blocks no activation; candidates replace their own entries;
discovery has a 10 s deadline and its process is stopped after the
answer; an unchanged hash keeps plugin.prev.wasm; activations run in
their own task; the watcher is not recursive; no duplicate services;
rpc.retired waits for live processes of its version; reads and hashes
on blocking threads.

Guest I/O and per-call paths: a stdin write error is EOF, stdout and
stderr are drained before the status; stderr is lossy UTF-8 and goes
to the journal from its own reader; compiled components are freed;
shutdown starts no new processes; lock queues drop stopping processes;
compile before the admission gate; CommandPre per component;
core.unlock in order; null errors are successes; the journal thread
survives SQLite errors.

sausage init builds the agent with cargo.build; sausage without a
command creates ./world. The console reads plain lines without a
terminal. PROTOCOL.md and DECISIONS.md describe the decided rules. The
flake tests the whole workspace and checks the packaged cargo.build.
This commit is contained in:
2026-10-23 02:25:03 +01:00
parent 53b90a8ecf
commit d7b78ed0fd
27 changed files with 1800 additions and 352 deletions
+87 -82
View File
@@ -1,4 +1,4 @@
//! A dedicated SQLite writer batches entries and serves ordered journal queries.
//! A dedicated SQLite writer batches entries and serves ordered queries without propagating disk failures into the host.
use rusqlite::{Connection, params};
use serde_json::{Value, json};
use std::{
@@ -7,7 +7,6 @@ use std::{
sync::{Mutex, mpsc},
};
use tokio::sync::oneshot;
struct Entry {
id: String,
pid: String,
@@ -19,31 +18,27 @@ enum Command {
Write(Entry),
Query(Value, oneshot::Sender<Value>),
}
pub(crate) struct Journal {
sender: mpsc::Sender<Command>,
warned: Mutex<HashSet<String>>,
}
impl Journal {
/// Open SQLite on its owning thread and wait for its schema to become ready.
pub(crate) async fn open(path: PathBuf) -> anyhow::Result<Self> {
let (sender, receiver) = mpsc::channel();
let (ready, initialized) = oneshot::channel();
std::thread::spawn(move || {
let opened = open_database(path);
match opened {
Ok(mut db) => {
let _ = ready.send(Ok(()));
while let Ok(first) = receiver.recv() {
let mut batch = vec![first];
batch.extend(receiver.try_iter().take(255));
write_batch(&mut db, batch);
}
}
Err(error) => {
let _ = ready.send(Err(error));
std::thread::spawn(move || match open_database(path) {
Ok(mut db) => {
let _ = ready.send(Ok(()));
while let Ok(first) = receiver.recv() {
let mut batch = vec![first];
batch.extend(receiver.try_iter().take(255));
write_batch(&mut db, batch);
}
}
Err(error) => {
eprintln!("journal open failed: {error}");
let _ = ready.send(Err(error));
}
});
initialized.await??;
Ok(Self {
@@ -51,7 +46,6 @@ impl Journal {
warned: Mutex::new(HashSet::new()),
})
}
/// Queue an entry without file operations or SQLite work on the caller's thread.
pub(crate) fn write(&self, pid: &str, plugin: &str, stream: &str, line: &str) {
let entry = Entry {
id: uuid::Uuid::now_v7().to_string(),
@@ -60,99 +54,110 @@ impl Journal {
stream: stream.into(),
line: line.into(),
};
self.sender
.send(Command::Write(entry))
.expect("journal writer stopped");
if self.sender.send(Command::Write(entry)).is_err() {
eprintln!("journal writer stopped");
}
}
/// Queue one warning for the first non-blank discarded line of a process.
pub(crate) fn warning(&self, pid: &str, plugin: &str) {
if self.warned.lock().unwrap().insert(pid.into()) {
self.write(pid, plugin, "core", "warning: discarded stdout");
}
}
/// Queue a query after all entries that the caller already submitted.
pub(crate) fn forget(&self, pid: &str) {
self.warned.lock().unwrap().remove(pid);
}
pub(crate) fn query_later(&self, filters: Value) -> oneshot::Receiver<Value> {
let (tx, rx) = oneshot::channel();
self.sender
.send(Command::Query(filters, tx))
.expect("journal writer stopped");
if self.sender.send(Command::Query(filters, tx)).is_err() {
eprintln!("journal writer stopped");
}
rx
}
/// Wait asynchronously for the journal thread to execute a query.
pub(crate) async fn query(&self, filters: Value) -> Value {
self.query_later(filters)
.await
.expect("journal writer stopped")
.unwrap_or_else(|_| json!([]))
}
}
fn open_database(path: PathBuf) -> anyhow::Result<Connection> {
std::fs::create_dir_all(path.parent().unwrap())?;
let db = Connection::open(path)?;
db.execute_batch(
"PRAGMA journal_mode=WAL;
db.execute_batch("PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
CREATE TABLE IF NOT EXISTS journal (
id TEXT PRIMARY KEY, time TEXT, pid TEXT, plugin TEXT, stream TEXT, line TEXT
);
CREATE TABLE IF NOT EXISTS journal (id TEXT PRIMARY KEY, time TEXT, pid TEXT, plugin TEXT, stream TEXT, line TEXT);
CREATE INDEX IF NOT EXISTS journal_pid ON journal(pid);
CREATE INDEX IF NOT EXISTS journal_plugin ON journal(plugin);",
)?;
CREATE INDEX IF NOT EXISTS journal_plugin ON journal(plugin);")?;
Ok(db)
}
/// Commit queued writes together before answering the batch's queries.
fn write_batch(db: &mut Connection, batch: Vec<Command>) {
let transaction = db.transaction().expect("journal transaction");
for command in &batch {
if let Command::Write(entry) = command {
transaction
.execute(
"INSERT INTO journal (id,time,pid,plugin,stream,line)
VALUES (?1,strftime('%Y-%m-%dT%H:%M:%fZ','now'),?2,?3,?4,?5)",
params![entry.id, entry.pid, entry.plugin, entry.stream, entry.line],
)
.expect("write journal");
let write = (|| -> rusqlite::Result<()> {
let transaction = db.transaction()?;
for command in &batch {
if let Command::Write(e) = command {
transaction.execute("INSERT INTO journal (id,time,pid,plugin,stream,line) VALUES (?1,strftime('%Y-%m-%dT%H:%M:%fZ','now'),?2,?3,?4,?5)",params![e.id,e.pid,e.plugin,e.stream,e.line])?;
}
}
transaction.commit()
})();
if let Err(error) = write {
eprintln!("journal write failed: {error}");
}
transaction.commit().expect("commit journal");
for command in batch {
if let Command::Query(filters, response) = command {
let _ = response.send(query_database(db, &filters));
let value = query_database(db, &filters).unwrap_or_else(|error| {
eprintln!("journal query failed: {error}");
json!([])
});
let _ = response.send(value);
}
}
}
fn query_database(db: &Connection, filters: &Value) -> Value {
let mut statement = db
.prepare(
"SELECT id,time,pid,plugin,stream,line FROM journal
WHERE (?1 IS NULL OR plugin=?1) AND (?2 IS NULL OR pid=?2)
AND (?3 IS NULL OR id>=?3) AND (?4 IS NULL OR id<=?4)
AND (?5 IS NULL OR instr(line,?5)>0)
ORDER BY id DESC LIMIT ?6",
)
.expect("prepare journal query");
let rows = statement
.query_map(
params![
filters["plugin"].as_str(),
filters["pid"].as_str(),
filters["since"].as_str(),
filters["until"].as_str(),
filters["grep"].as_str(),
filters["limit"].as_i64().unwrap_or(100)
],
|row| {
Ok(json!({
"id":row.get::<_,String>(0)?,
"time":row.get::<_,String>(1)?,
"pid":row.get::<_,String>(2)?,
"plugin":row.get::<_,String>(3)?,
"stream":row.get::<_,String>(4)?,
"line":row.get::<_,String>(5)?,
}))
},
)
.expect("query journal");
let mut entries: Vec<_> = rows.map(Result::unwrap).collect();
fn query_database(db: &Connection, filters: &Value) -> rusqlite::Result<Value> {
let mut statement = db.prepare(
"SELECT id,time,pid,plugin,stream,line FROM journal
WHERE (?1 IS NULL OR plugin=?1) AND (?2 IS NULL OR pid=?2)
AND (?3 IS NULL OR id>=?3) AND (?4 IS NULL OR id<=?4)
AND (?5 IS NULL OR instr(line,?5)>0) ORDER BY id DESC LIMIT ?6",
)?;
let rows=statement.query_map(params![filters["plugin"].as_str(),filters["pid"].as_str(),filters["since"].as_str(),filters["until"].as_str(),filters["grep"].as_str(),filters["limit"].as_i64().unwrap_or(100)],|row| Ok(json!({
"id":row.get::<_,String>(0)?,"time":row.get::<_,String>(1)?,"pid":row.get::<_,String>(2)?,"plugin":row.get::<_,String>(3)?,"stream":row.get::<_,String>(4)?,"line":row.get::<_,String>(5)?,
})))?;
let mut entries: Vec<_> = rows.collect::<rusqlite::Result<_>>()?;
entries.reverse();
json!(entries)
Ok(json!(entries))
}
#[cfg(test)]
mod tests {
use super::*;
/// PROTOCOL 7: completed processes do not leave warning identities in memory.
#[tokio::test]
async fn completion_forgets_warning_identity() {
let folder = std::env::temp_dir().join(format!("sausage-warning-{}", uuid::Uuid::now_v7()));
crate::world::World::create(&folder).unwrap();
let host = crate::Host::start_with_cache(&folder, &folder.join("cache"))
.await
.unwrap();
host.register_native(
"warn",
std::sync::Arc::new(|ctx, _, _| {
Box::pin(async move {
ctx.host.output(&ctx.pid, "not JSON".into());
Ok(json!(ctx.pid))
})
}),
);
let pid = host.call("warn.once", json!({})).await.unwrap();
host.call("core.stop", json!({"pid":pid})).await.unwrap();
let retained = host
.0
.journal
.warned
.lock()
.unwrap()
.contains(pid.as_str().unwrap());
host.stop().await;
std::fs::remove_dir_all(folder).unwrap();
assert!(!retained);
}
}