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
+74 -34
View File
@@ -4,8 +4,8 @@ use crate::{Host, RpcResult};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, HashMap},
path::Path,
collections::BTreeMap,
path::{Path, PathBuf},
};
#[derive(Clone)]
@@ -16,7 +16,6 @@ pub(crate) struct Interface {
}
pub(crate) struct Graph {
pub(crate) plugins: BTreeMap<String, Interface>,
pub(crate) versions: HashMap<String, Value>,
json: Vec<u8>,
dot: Vec<u8>,
}
@@ -26,7 +25,6 @@ impl Default for Graph {
fn default() -> Self {
Self {
plugins: BTreeMap::new(),
versions: HashMap::new(),
json: b"{\"plugins\":{}}".to_vec(),
dot: b"digraph plugins {}\n".to_vec(),
}
@@ -38,6 +36,17 @@ pub(crate) fn hash(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
/// Reads and hashes one immutable snapshot without blocking an async worker.
pub(crate) async fn read_hashed(path: PathBuf) -> std::io::Result<(Vec<u8>, String)> {
tokio::task::spawn_blocking(move || -> std::io::Result<_> {
let bytes = std::fs::read(path)?;
let hash = hash(&bytes);
Ok((bytes, hash))
})
.await
.map_err(std::io::Error::other)?
}
/// Lists broken imports, optionally restricted to changed consumers or providers.
pub(crate) fn problems(
plugins: &BTreeMap<String, Interface>,
@@ -105,32 +114,39 @@ impl Host {
&self,
plugin: &str,
path: &Path,
bytes: &[u8],
hash: String,
) -> Result<Interface, Value> {
let hash = hash(bytes);
let cache_key = format!("{plugin}/{hash}");
let document = self
.route_from(
"core".into(),
Some(json!(uuid::Uuid::now_v7().to_string())),
format!("{plugin}.rpc.discover"),
json!({}),
true,
Some(path.to_path_buf()),
)
.await?;
let request = json!(uuid::Uuid::now_v7().to_string());
let discovery = self.route_from(
"core".into(),
Some(request.clone()),
format!("{plugin}.rpc.discover"),
json!({}),
true,
Some(path.to_path_buf()),
);
let result = tokio::time::timeout(std::time::Duration::from_secs(10), discovery).await;
let processes: Vec<_> = self
.0
.state
.lock()
.unwrap()
.processes
.iter()
.filter(|(_, process)| process.id.as_ref() == Some(&request))
.map(|(pid, _)| pid.clone())
.collect();
for pid in processes {
self.begin_stop(&pid);
}
let document = result
.map_err(|_| crate::error(-32003, "plugin discovery timed out after 10 seconds"))??;
if !document["methods"].is_array() {
return Err(crate::error(
-32003,
"rpc.discover did not return an OpenRPC document",
));
}
self.0
.graph
.lock()
.unwrap()
.versions
.insert(cache_key, document.clone());
Ok(Interface {
hash,
document,
@@ -139,21 +155,44 @@ impl Host {
}
/// Refreshes every installed plugin while the caller holds the activation mutex.
pub(crate) async fn refresh_graph(&self) -> RpcResult {
self.refresh_graph_except(&[]).await
}
pub(crate) async fn refresh_graph_except(&self, candidates: &[String]) -> RpcResult {
let mut directory = tokio::fs::read_dir(self.0.world.root.join("plugins"))
.await
.map_err(io_error)?;
let mut plugins = BTreeMap::new();
while let Some(entry) = directory.next_entry().await.map_err(io_error)? {
if !entry.file_type().await.map_err(io_error)?.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let source = entry.path().join("plugin.wasm");
let bytes = match tokio::fs::read(&source).await {
Ok(bytes) => bytes,
let (bytes, hash) = match read_hashed(source).await {
Ok(snapshot) => snapshot,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(io_error(error)),
Err(error) => {
plugins.insert(
name,
Interface {
hash: String::new(),
document: Value::Null,
error: Some(io_error(error)),
},
);
continue;
}
};
let hash = hash(&bytes);
let previous = self.0.graph.lock().unwrap().plugins.get(&name).cloned();
let interface = if let Some(previous) =
let interface = if candidates.contains(&name) {
previous
.filter(|entry| entry.hash == hash)
.unwrap_or(Interface {
hash,
document: Value::Null,
error: None,
})
} else if let Some(previous) =
previous.filter(|entry| entry.hash == hash && entry.error.is_none())
{
previous
@@ -161,8 +200,10 @@ impl Host {
let copy = entry
.path()
.join(format!("discover-{}.wasm", uuid::Uuid::now_v7()));
tokio::fs::write(&copy, &bytes).await.map_err(io_error)?;
let result = self.discover_copy(&name, &copy, &bytes).await;
let result = match tokio::fs::write(&copy, &bytes).await {
Ok(()) => self.discover_copy(&name, &copy, hash.clone()).await,
Err(error) => Err(io_error(error)),
};
let _ = tokio::fs::remove_file(&copy).await;
match result {
Ok(interface) => interface,
@@ -179,10 +220,9 @@ impl Host {
let old_plugins = self.0.graph.lock().unwrap().plugins.clone();
let mut upgrades = Vec::new();
for (plugin, current) in &plugins {
if let Some(old) = old_plugins
.get(plugin)
.filter(|old| old.hash != current.hash && current.error.is_none())
{
if let Some(old) = old_plugins.get(plugin).filter(|old| {
!old.hash.is_empty() && old.hash != current.hash && current.error.is_none()
}) {
let grace = old.document["x-sausage-grace"]
.as_f64()
.filter(|value| value.is_finite() && *value >= 0.0)