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
+60 -16
View File
@@ -6,22 +6,30 @@
//!
//! The broker knows nothing about Rust. This actor is one way to get a
//! `.wasm` file. Other actors can add other languages.
//!
//! Actors depend on the SDK with `sausage-actor = "0.1"`. The SDK is not
//! published: this actor copies the SDK of this host to `data/sdk/`, and
//! points the dependency there in the build copy of `Cargo.toml`.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::time::Instant;
use anyhow::{Context, Result, anyhow, bail};
use serde_json::{Value, json};
use tokio::process::Command;
use tokio::sync::{Mutex, mpsc};
use crate::broker::{Broker, Envelope, truncate};
use include_dir::{Dir, include_dir};
use crate::broker::{Broker, Envelope};
use crate::world::{self, World};
pub const ID: &str = "cargo";
pub const TARGET: &str = "wasm32-wasip2";
const BUILD_TIMEOUT: Duration = Duration::from_secs(900);
/// The source of the `sausage-actor` SDK.
static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk");
pub struct BuildOutput {
/// Host path of the published `.wasm` file.
@@ -37,8 +45,10 @@ pub struct Cargo {
}
impl Cargo {
pub fn new(world: World) -> Self {
Self { world, lock: Mutex::new(()) }
/// Create the actor and install the SDK of this host into `data/sdk/`.
pub fn new(world: World) -> Result<Self> {
write_dir(&SDK, &world.data_dir(ID).join("sdk"))?;
Ok(Self { world, lock: Mutex::new(()) })
}
/// Build the crate in `source` (a host path) and publish the `.wasm` file
@@ -53,12 +63,8 @@ impl Cargo {
let key = format!("{:016x}", fxhash(source.to_string_lossy().as_bytes()));
let build_dir = data.join("work").join(&key);
world::replace_tree(source, &build_dir)?;
// The crate must not join a parent workspace by accident.
let manifest = build_dir.join("Cargo.toml");
let text = std::fs::read_to_string(&manifest)?;
if !text.contains("[workspace]") {
std::fs::write(&manifest, format!("{text}\n[workspace]\n"))?;
}
std::fs::write(&manifest, prepare_manifest(&std::fs::read_to_string(&manifest)?, Some(&data.join("sdk")))?)?;
let mut cmd = Command::new("cargo");
cmd.arg("build")
@@ -70,13 +76,10 @@ impl Cargo {
.arg(data.join("target"))
.current_dir(&build_dir)
.kill_on_drop(true);
let out = tokio::time::timeout(BUILD_TIMEOUT, cmd.output())
.await
.map_err(|_| anyhow!("cargo build took longer than {BUILD_TIMEOUT:?}"))?
.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?;
let out = cmd.output().await.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?;
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
if !out.status.success() {
bail!("{}", truncate(&strip_progress(&stderr), 20_000));
bail!("{}", strip_progress(&stderr));
}
let mut artifact = None;
for line in String::from_utf8_lossy(&out.stdout).lines() {
@@ -123,7 +126,7 @@ impl Cargo {
"source": source_s,
"built_by": ID,
"seconds": (out.seconds * 10.0).round() / 10.0,
"warnings": truncate(&out.warnings, 4000),
"warnings": out.warnings,
}))
}
Some("shutdown") => Ok(json!({"type": "ignored"})),
@@ -132,6 +135,47 @@ impl Cargo {
}
}
/// Point a `sausage-actor` dependency at `sdk` (keeping its features), and
/// add an empty `[workspace]`, so the crate never joins a parent workspace.
fn prepare_manifest(text: &str, sdk: Option<&Path>) -> Result<String> {
let mut doc: toml_edit::DocumentMut = text.parse()?;
if let Some(sdk) = sdk {
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
let Some(dep) = doc.get_mut(section).and_then(|t| t.get_mut("sausage-actor")) else { continue };
let mut table = dep.as_inline_table().cloned().unwrap_or_default();
for key in ["version", "path", "git", "branch", "tag", "rev", "registry"] {
table.remove(key);
}
table.insert("path", sdk.display().to_string().into());
*dep = toml_edit::value(table);
}
}
if doc.get("workspace").is_none() {
doc["workspace"] = toml_edit::table();
}
Ok(doc.to_string())
}
/// Write the files of `dir` below `to`. Skip files that did not change, so
/// that cargo does not rebuild the SDK at every start.
fn write_dir(dir: &Dir<'_>, to: &Path) -> Result<()> {
for file in dir.files() {
let dest = to.join(file.path());
let contents = match file.path().file_name() {
Some(n) if n == "Cargo.toml" => prepare_manifest(file.contents_utf8().context("Cargo.toml")?, None)?.into_bytes(),
_ => file.contents().to_vec(),
};
if std::fs::read(&dest).ok() != Some(contents.clone()) {
std::fs::create_dir_all(dest.parent().context("no parent")?)?;
std::fs::write(&dest, contents)?;
}
}
for sub in dir.dirs() {
write_dir(sub, to)?;
}
Ok(())
}
fn next_build_number(dir: &Path) -> Result<u64> {
std::fs::create_dir_all(dir)?;
let max = std::fs::read_dir(dir)?