World folder is the source of truth

- actor/<id>/ holds actor.wasm, actor.json, source/, public/, data/, versions/
- the broker boots by scanning actor/; a changed actor.wasm is an upgrade,
  a rejected one stays in versions/ with a reason and the files come back
- /actor hides every data/ folder
- subscriptions live in the subscriber's actor.json
- cargo installs the SDK and points sausage-actor at it; no build limits
- PROTOCOL.md
This commit is contained in:
2026-10-15 11:14:11 +01:00
parent c9d9864743
commit 0a8e8c2e3c
13 changed files with 786 additions and 335 deletions
+98 -66
View File
@@ -1,19 +1,22 @@
//! On-disk layout of a Sausage workspace ("world").
//! On-disk layout of a Sausage world. The folder is the source of truth:
//! `sausage run` boots every actor that it finds in `actor/`.
//!
//! ```text
//! <world>/
//! sausage.json registry: actors, versions, subscriptions
//! actor/<id>/public/ written by the actor, read-only for others
//! actor/<id>/source/ source of the running version (broker)
//! actor/<id>/status.json state of the actor (broker)
//! actor/<id>/stderr.log stderr of the actor (broker)
//! private/<id>/data/ private state of the actor
//! broker/versions/<id>/<n>/ actor.wasm, source/, meta.json, snapshot/
//! archive/ removed actors
//! actor/<id>/
//! actor.wasm the current version
//! actor.json version, running, subscriptions (broker)
//! source/ source of the current version
//! public/ written by the actor, read-only for others
//! data/ private state; hidden from other actors
//! status.json runtime state (broker)
//! stderr.log stderr of the actor and notes of the broker
//! versions/<n>/ actor.wasm, source/, meta.json, snapshot/{data,public}
//! archive/ removed actors
//! ```
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
@@ -28,20 +31,25 @@ pub struct World {
pub root: PathBuf,
}
#[derive(Default, Serialize, Deserialize, Clone)]
pub struct Registry {
pub actors: BTreeMap<String, ActorRecord>,
/// Publisher ID → subscriber IDs.
pub subscriptions: BTreeMap<String, BTreeSet<String>>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ActorRecord {
pub version: u64,
pub next_version: u64,
/// `actor/<id>/actor.json`.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct ActorMeta {
/// The installed version. `None` for native actors and for a new
/// `actor.wasm` that the broker has not installed yet.
#[serde(default)]
pub version: Option<u64>,
#[serde(default = "yes")]
pub running: bool,
/// The actors whose published messages this actor receives.
#[serde(default)]
pub subscriptions: BTreeSet<String>,
}
fn yes() -> bool {
true
}
/// `versions/<n>/meta.json`.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct VersionMeta {
pub version: u64,
@@ -51,9 +59,12 @@ pub struct VersionMeta {
pub source: Option<String>,
/// The actor that built the `.wasm` file.
pub built_by: Option<String>,
/// The actor that requested the spawn or upgrade.
/// The actor that requested the version ("scan" for a file found on disk).
pub requested_by: String,
pub created_at: u64,
/// Why the broker did not install this version, if it did not.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rejected: Option<String>,
}
pub fn valid_id(id: &str) -> bool {
@@ -74,15 +85,15 @@ impl World {
Self { root: root.into() }
}
pub fn registry_path(&self) -> PathBuf {
self.root.join("sausage.json")
}
pub fn actor_dir(&self, id: &str) -> PathBuf {
self.root.join("actor").join(id)
}
pub fn actors_root(&self) -> PathBuf {
self.root.join("actor")
}
pub fn actor_dir(&self, id: &str) -> PathBuf {
self.actors_root().join(id)
}
pub fn wasm_path(&self, id: &str) -> PathBuf {
self.actor_dir(id).join("actor.wasm")
}
pub fn public_dir(&self, id: &str) -> PathBuf {
self.actor_dir(id).join("public")
}
@@ -90,20 +101,21 @@ impl World {
self.actor_dir(id).join("source")
}
pub fn data_dir(&self, id: &str) -> PathBuf {
self.root.join("private").join(id).join("data")
self.actor_dir(id).join("data")
}
pub fn versions_dir(&self, id: &str) -> PathBuf {
self.root.join("broker").join("versions").join(id)
self.actor_dir(id).join("versions")
}
pub fn version_dir(&self, id: &str, v: u64) -> PathBuf {
self.versions_dir(id).join(v.to_string())
}
/// Private scratch space of the broker (candidate copies, seeds).
pub fn scratch_dir(&self) -> PathBuf {
self.root.join("broker").join("scratch")
self.data_dir("broker").join("scratch")
}
pub fn exists(&self) -> bool {
self.registry_path().is_file()
self.actors_root().is_dir()
}
/// Create an empty world with the folders of the native actors.
@@ -112,8 +124,6 @@ impl World {
if world.exists() {
bail!("{} is already a Sausage world", root.display());
}
std::fs::create_dir_all(&world.root)?;
world.save_registry(&Registry::default())?;
for id in NATIVE {
world.ensure_actor_dirs(id)?;
let status = serde_json::json!({"id": id, "kind": "native", "state": "built-in"});
@@ -122,21 +132,46 @@ impl World {
Ok(world)
}
/// Create the directories of one actor.
pub fn ensure_actor_dirs(&self, id: &str) -> Result<()> {
std::fs::create_dir_all(self.public_dir(id))?;
std::fs::create_dir_all(self.data_dir(id))?;
Ok(())
}
pub fn load_registry(&self) -> Result<Registry> {
let text = std::fs::read_to_string(self.registry_path())
.with_context(|| format!("no Sausage world at {}", self.root.display()))?;
Ok(serde_json::from_str(&text)?)
/// IDs of every folder in `actor/`, sorted.
pub fn actor_ids(&self) -> Result<Vec<String>> {
let mut ids: Vec<String> = std::fs::read_dir(self.actors_root())?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().into_string().ok())
.filter(|id| valid_id(id))
.collect();
ids.sort();
Ok(ids)
}
pub fn save_registry(&self, reg: &Registry) -> Result<()> {
write_atomic(&self.registry_path(), serde_json::to_string_pretty(reg)?.as_bytes())
pub fn read_meta(&self, id: &str) -> ActorMeta {
std::fs::read_to_string(self.actor_dir(id).join("actor.json"))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(ActorMeta { running: true, ..Default::default() })
}
pub fn write_meta(&self, id: &str, meta: &ActorMeta) -> Result<()> {
write_atomic(&self.actor_dir(id).join("actor.json"), serde_json::to_string_pretty(meta)?.as_bytes())
}
/// Version numbers in `versions/`, ascending.
pub fn versions(&self, id: &str) -> Vec<u64> {
let mut v: Vec<u64> = std::fs::read_dir(self.versions_dir(id))
.map(|rd| rd.filter_map(|e| e.ok()?.file_name().to_str()?.parse().ok()).collect())
.unwrap_or_default();
v.sort();
v
}
pub fn next_version(&self, id: &str) -> u64 {
self.versions(id).last().map_or(1, |v| v + 1)
}
/// Map a path in an actor's namespace to a host path.
@@ -145,25 +180,28 @@ impl World {
/// - `/public/...` and `/data/...` belong to `sender`.
/// - Native senders (the console) can also give absolute host paths.
pub fn resolve(&self, sender: &str, path: &str) -> Result<PathBuf> {
let clean = |base: PathBuf, rest: &str| -> Result<PathBuf> {
let mut out = base.clone();
let under = |base: PathBuf, rest: &str| -> Result<PathBuf> {
let mut out = base;
for c in Path::new(rest).components() {
match c {
std::path::Component::Normal(n) => out.push(n),
std::path::Component::CurDir | std::path::Component::RootDir => {}
Component::Normal(n) => out.push(n),
Component::CurDir | Component::RootDir => {}
_ => bail!("path must not contain '..': {path}"),
}
}
Ok(out)
};
if let Some(rest) = path.strip_prefix("/actor/") {
return clean(self.actors_root(), rest);
let prefixed = |prefix: &str| {
path.strip_prefix(prefix).filter(|rest| rest.is_empty() || rest.starts_with('/'))
};
if let Some(rest) = prefixed("/actor") {
return under(self.actors_root(), rest);
}
if let Some(rest) = path.strip_prefix("/public/").or(path.strip_prefix("/public").filter(|r| r.is_empty())) {
return clean(self.public_dir(sender), rest);
if let Some(rest) = prefixed("/public") {
return under(self.public_dir(sender), rest);
}
if let Some(rest) = path.strip_prefix("/data/").or(path.strip_prefix("/data").filter(|r| r.is_empty())) {
return clean(self.data_dir(sender), rest);
if let Some(rest) = prefixed("/data") {
return under(self.data_dir(sender), rest);
}
if NATIVE.contains(&sender) && Path::new(path).is_absolute() {
return Ok(PathBuf::from(path));
@@ -182,11 +220,8 @@ impl World {
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
let dir = path.parent().context("path has no parent")?;
std::fs::create_dir_all(dir)?;
let tmp = dir.join(format!(
"{TMP_PREFIX}host-{}-{}",
std::process::id(),
path.file_name().and_then(|n| n.to_str()).unwrap_or("file")
));
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
let tmp = dir.join(format!("{TMP_PREFIX}host-{}-{name}", std::process::id()));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, path)?;
Ok(())
@@ -198,16 +233,13 @@ pub fn copy_tree(from: &Path, to: &Path) -> Result<()> {
for entry in std::fs::read_dir(from).with_context(|| format!("read {}", from.display()))? {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with(TMP_PREFIX) {
let name = name.to_string_lossy();
if name.starts_with(TMP_PREFIX) {
continue;
}
let dst = to.join(&*name);
let ty = entry.file_type()?;
let dst = to.join(&name);
if ty.is_dir() {
if name_str == "target" || name_str == ".git" {
continue;
}
if ty.is_dir() && name != "target" && name != ".git" {
copy_tree(&entry.path(), &dst)?;
} else if ty.is_file() {
std::fs::copy(entry.path(), &dst)?;
@@ -216,8 +248,8 @@ pub fn copy_tree(from: &Path, to: &Path) -> Result<()> {
Ok(())
}
/// Replace the directory `dst` with a copy of `src`, as close to atomic as a
/// directory can be: copy to a sibling, then swap with two renames.
/// Replace the directory `dst` with a copy of `src` (an empty folder if `src`
/// does not exist): copy to a sibling, then swap with two renames.
pub fn replace_tree(src: &Path, dst: &Path) -> Result<()> {
let parent = dst.parent().context("path has no parent")?;
let name = dst.file_name().and_then(|n| n.to_str()).unwrap_or("dir");