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.
294 lines
10 KiB
Rust
294 lines
10 KiB
Rust
//! 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>/
|
|
//! 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/ state, writable only by the actor
|
|
//! stderr.log stderr of the actor and notes of the broker
|
|
//! versions/<n>/ every version that was installed: actor.wasm, source/, meta.json
|
|
//! ```
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::path::{Component, Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::fs::TMP_PREFIX;
|
|
|
|
/// IDs of the actors that the host implements natively.
|
|
pub const NATIVE: &[&str] = &["broker", "console", "cargo"];
|
|
|
|
#[derive(Clone)]
|
|
pub struct World {
|
|
pub root: PathBuf,
|
|
}
|
|
|
|
/// `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,
|
|
/// Path of the `.wasm` file, as the requester gave it.
|
|
pub wasm: String,
|
|
/// Path of the source folder, as the requester gave it.
|
|
pub source: Option<String>,
|
|
/// The actor that built the `.wasm` file.
|
|
pub built_by: Option<String>,
|
|
/// The actor that requested the version ("scan" for a file found on disk).
|
|
pub requested_by: String,
|
|
pub created_at: u64,
|
|
}
|
|
|
|
pub fn valid_id(id: &str) -> bool {
|
|
!id.is_empty()
|
|
&& id.len() <= 64
|
|
&& id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
|
}
|
|
|
|
pub fn now() -> u64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
impl World {
|
|
pub fn new(root: impl Into<PathBuf>) -> Self {
|
|
Self { root: root.into() }
|
|
}
|
|
|
|
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")
|
|
}
|
|
pub fn source_dir(&self, id: &str) -> PathBuf {
|
|
self.actor_dir(id).join("source")
|
|
}
|
|
pub fn data_dir(&self, id: &str) -> PathBuf {
|
|
self.actor_dir(id).join("data")
|
|
}
|
|
pub fn versions_dir(&self, id: &str) -> PathBuf {
|
|
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.data_dir("broker").join("scratch")
|
|
}
|
|
|
|
pub fn exists(&self) -> bool {
|
|
self.actors_root().is_dir()
|
|
}
|
|
|
|
/// Create an empty world with the folders of the native actors.
|
|
pub fn create(root: &Path) -> Result<World> {
|
|
let world = World::new(std::path::absolute(root)?);
|
|
if world.exists() {
|
|
bail!("{} is already a Sausage world", root.display());
|
|
}
|
|
for id in NATIVE {
|
|
world.ensure_actor_dirs(id)?;
|
|
}
|
|
Ok(world)
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
/// 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 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())
|
|
}
|
|
|
|
/// Make `actor.wasm` and `source/` a copy of `versions/<version>/`,
|
|
/// unless `actor.wasm` already is that version (it came from there).
|
|
pub fn install_files(&self, id: &str, version: u64) -> Result<()> {
|
|
let dir = self.version_dir(id, version);
|
|
self.ensure_actor_dirs(id)?;
|
|
let wasm = std::fs::read(dir.join("actor.wasm"))?;
|
|
if std::fs::read(self.wasm_path(id)).ok().as_ref() == Some(&wasm) {
|
|
return Ok(());
|
|
}
|
|
write_atomic(&self.wasm_path(id), &wasm)?;
|
|
replace_tree(&dir.join("source"), &self.source_dir(id))
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// - `/actor/<id>/...` is the global filesystem.
|
|
/// - `/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 under = |base: PathBuf, rest: &str| -> Result<PathBuf> {
|
|
let mut out = base;
|
|
for c in Path::new(rest).components() {
|
|
match c {
|
|
Component::Normal(n) => out.push(n),
|
|
Component::CurDir | Component::RootDir => {}
|
|
_ => bail!("path must not contain '..': {path}"),
|
|
}
|
|
}
|
|
Ok(out)
|
|
};
|
|
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) = prefixed("/public") {
|
|
return under(self.public_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));
|
|
}
|
|
bail!("cannot resolve path {path} for {sender}: use /actor/<id>/..., /public/... or /data/...")
|
|
}
|
|
|
|
/// The path of a host file in the global filesystem, if it has one.
|
|
pub fn global_path(&self, host: &Path) -> Option<String> {
|
|
let rel = host.strip_prefix(self.actors_root()).ok()?;
|
|
Some(format!("/actor/{}", rel.display()))
|
|
}
|
|
}
|
|
|
|
/// Write a file so that readers never see a partial version.
|
|
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 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(())
|
|
}
|
|
|
|
/// Folders that a copy of a source folder leaves out.
|
|
const BUILD_DIRS: &[&str] = &["target", ".git"];
|
|
|
|
/// Copy a directory tree, except uncommitted files.
|
|
pub fn copy_tree(from: &Path, to: &Path) -> Result<()> {
|
|
copy_filtered(from, to, &[])
|
|
}
|
|
|
|
fn copy_filtered(from: &Path, to: &Path, skip: &[&str]) -> Result<()> {
|
|
std::fs::create_dir_all(to)?;
|
|
for entry in std::fs::read_dir(from).with_context(|| format!("read {}", from.display()))? {
|
|
let entry = entry?;
|
|
let name = entry.file_name();
|
|
let name = name.to_string_lossy();
|
|
if name.starts_with(TMP_PREFIX) {
|
|
continue;
|
|
}
|
|
let dst = to.join(&*name);
|
|
let ty = entry.file_type()?;
|
|
if ty.is_dir() && !skip.contains(&&*name) {
|
|
copy_filtered(&entry.path(), &dst, skip)?;
|
|
} else if ty.is_file() {
|
|
std::fs::copy(entry.path(), &dst)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 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<()> {
|
|
replace_filtered(src, dst, &[])
|
|
}
|
|
|
|
/// Like `replace_tree`, for a source folder: leaves out `target/` and `.git/`.
|
|
pub fn replace_source(src: &Path, dst: &Path) -> Result<()> {
|
|
replace_filtered(src, dst, BUILD_DIRS)
|
|
}
|
|
|
|
fn replace_filtered(src: &Path, dst: &Path, skip: &[&str]) -> Result<()> {
|
|
let parent = dst.parent().context("path has no parent")?;
|
|
let name = dst.file_name().and_then(|n| n.to_str()).unwrap_or("dir");
|
|
let fresh = parent.join(format!("{TMP_PREFIX}new-{name}"));
|
|
let old = parent.join(format!("{TMP_PREFIX}old-{name}"));
|
|
let _ = std::fs::remove_dir_all(&fresh);
|
|
let _ = std::fs::remove_dir_all(&old);
|
|
if src.is_dir() {
|
|
copy_filtered(src, &fresh, skip)?;
|
|
} else {
|
|
std::fs::create_dir_all(&fresh)?;
|
|
}
|
|
if dst.exists() {
|
|
std::fs::rename(dst, &old)?;
|
|
}
|
|
std::fs::rename(&fresh, dst)?;
|
|
let _ = std::fs::remove_dir_all(&old);
|
|
Ok(())
|
|
}
|