Sausage: host, broker, commit-on-close filesystem, cargo actor, console, agent
Imported from the spike branch, with: - a library crate (Host API) for the CLI and the tests - JSON test scenarios for candidates - commit on close only for new or truncated files - a Nix flake: nix run -- init, nix run -- run
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
/target
|
||||
/.cargo-home
|
||||
/world
|
||||
/result
|
||||
Generated
+3021
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["cli", "agent", "sdk"]
|
||||
default-members = ["cli"]
|
||||
exclude = ["examples"]
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
# Decisions
|
||||
|
||||
This file lists the design decisions of the spike. **(you)** marks a decision
|
||||
that Surma made. **(spike)** marks a decision that Scout made alone during the
|
||||
night and that needs review.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **(you)** Actors are WASI commands (`wasm32-wasip2`). They use stdin, stdout,
|
||||
files, and HTTP. No WIT interfaces, no component composition.
|
||||
- **(you)** Actors exchange newline-delimited JSON through the host, which is
|
||||
the broker. No built-in request/response. A reply is a message with
|
||||
`reply_to`. Delivery is at most once.
|
||||
- **(you)** The broker accepts only `.wasm` files. It knows no source language.
|
||||
- **(you)** `cargo` is a built-in Rust compiler actor. **(spike)** It is native
|
||||
(host code), because cargo cannot run inside WASM.
|
||||
- **(you)** The agent is a normal WASM actor with the fixed ID `agent`. It can
|
||||
read and upgrade its own source.
|
||||
- **(spike)** Three native actors: `broker`, `console`, `cargo`.
|
||||
- **(you)** One Cargo workspace with two crates: `cli` (binary `sausage`) and
|
||||
`agent`. **(spike)** Example actors are in `examples/`, outside the workspace.
|
||||
- **(you)** `sausage init` builds the agent and asks for the API shape, the
|
||||
endpoint, the model, and the token. **(spike)** The token can also come from
|
||||
`SAUSAGE_API_TOKEN`. The source contains no endpoint and no token.
|
||||
|
||||
## Messages
|
||||
|
||||
- **(spike)** Stdin line: `{"id", "from", "reply_to"?, "body"}`.
|
||||
Stdout line: `{"to"?, "id"?, "reply_to"?, "body"}`.
|
||||
- **(spike)** The broker sets `from`. An actor can choose its own `id`. The
|
||||
broker then uses `"<sender>/<id>"`, so IDs cannot collide or be faked.
|
||||
Without an `id`, the broker assigns `m-<n>`.
|
||||
- **(you)** Without `to`, a message goes to the subscribers of the sender.
|
||||
**(spike)** `subscribe` / `unsubscribe` messages to the broker. The registry
|
||||
stores subscriptions, so they survive restarts and upgrades. `init`
|
||||
subscribes `console` to `agent`.
|
||||
- **(spike)** Errors from routing come back as
|
||||
`{"type": "undeliverable", "reason", "to"}` from `broker`.
|
||||
- **(spike)** Lifecycle: `{"type": "shutdown"}` from `broker`, then EOF on stdin.
|
||||
After 5 seconds the broker kills the instance.
|
||||
- **(spike)** Lines on stdout that are not JSON objects with `body` go to the
|
||||
actor's `stderr.log` with a note.
|
||||
|
||||
## Filesystem
|
||||
|
||||
- **(you)** `/data` private, `/public` own public files,
|
||||
`/actor/<id>/public` and `/actor/<id>/source` read-only for everyone.
|
||||
- **(spike)** The broker also writes `/actor/<id>/status.json` and
|
||||
`/actor/<id>/stderr.log`. `ls /actor` is the registry.
|
||||
- **(you)** Commit on close: a write becomes visible only when the writer
|
||||
closes the file. **(spike)** This also applies to `/data`. Implementation: a
|
||||
wrapper around the `wasmtime-wasi` filesystem. An open for write goes to a
|
||||
hidden `.sausage-tmp-*` file next to the target. The descriptor drop renames
|
||||
it into place. Details:
|
||||
- A normal exit commits every open file (process exit closes files).
|
||||
- A trap or kill discards uncommitted writes.
|
||||
- An append or an edit without truncate copies the old content first.
|
||||
- Other descriptors see the old content until the close.
|
||||
- Directory operations take effect immediately.
|
||||
- The layer hides `.sausage-tmp-*` names from listings, `stat`, `open`,
|
||||
`rename`, and `unlink`.
|
||||
- Symlinks are disabled (they would break the path tracking).
|
||||
- No `fsync`. A power loss can lose committed data.
|
||||
- **(spike)** Raw sockets are not available (no network permission), but the
|
||||
`wasi:sockets` interfaces are linked, so programs that import them still
|
||||
start.
|
||||
|
||||
## Broker requests
|
||||
|
||||
- **(spike)** `spawn`, `upgrade`, `rollback`, `stop`, `start`, `remove`,
|
||||
`subscribe`, `unsubscribe`, `list`.
|
||||
- **(you)** `spawn` and `upgrade` take `wasm`, plus optional `source` and
|
||||
`built_by` metadata. **(spike)** The broker copies the `.wasm` file and the
|
||||
source folder into `broker/versions/<id>/<n>/`, with `meta.json` (paths,
|
||||
builder, requester, time).
|
||||
- **(spike)** Path resolution: `/actor/...` is global, and `/public/...` and
|
||||
`/data/...` belong to the sender. `..` is rejected. Native senders (the
|
||||
console) can give absolute host paths.
|
||||
- **(you)** Anyone can upgrade anyone. No permissions.
|
||||
- **(spike)** Actor IDs: `a-z`, `0-9`, `-`, `_`, at most 64 characters.
|
||||
- **(spike)** `remove` archives every folder of the actor to `archive/`. Nothing
|
||||
is deleted.
|
||||
|
||||
## Upgrades, tests, rollback
|
||||
|
||||
- **(spike)** The tests are message transcripts in `source/tests/*.jsonl`:
|
||||
`{"send": body}` and `{"expect": subset-of-body, "to"?}`. Each file runs in
|
||||
a fresh candidate on a copy of the actor's `/data` and `/public`. The
|
||||
candidate has no HTTP access. An `expect` waits up to 10 s. Without tests,
|
||||
the candidate must stay up for 2 s (smoke test).
|
||||
- **(spike)** Swap: hold the mailbox, stop the old instance gracefully,
|
||||
snapshot `/data` + `/public` into the old version's folder, install the new
|
||||
source in `/actor/<id>/source`, start the new instance, release the mailbox.
|
||||
- **(spike)** Restart after a crash: backoff 0.5 s × 2ⁿ, at most 30 s. The
|
||||
counter resets after 60 s of uptime.
|
||||
- **(spike)** Parked message: when an instance traps, the broker keeps the
|
||||
last delivered message. It delivers that message again after the next
|
||||
upgrade, not after a plain restart (that would loop).
|
||||
- **(spike)** Crash loop: 3 crashes within 30 s after an upgrade → automatic
|
||||
rollback to the previous version, with its data snapshot. The broker
|
||||
informs the actor that requested the upgrade.
|
||||
- **(spike)** `rollback` without `version` goes to the highest older version.
|
||||
It restores that version's snapshot, and it first snapshots the current
|
||||
state, so a roll-forward is possible.
|
||||
- **(spike)** Versions are per actor, not global.
|
||||
|
||||
## Limits
|
||||
|
||||
- **(spike)** Mailbox: 1,000 messages. When it is full, the sender gets
|
||||
`undeliverable`.
|
||||
- **(spike)** The broker waits 30 s when it writes to an actor's stdin. Then it
|
||||
drops the message and logs it.
|
||||
- **(spike)** Time slices: the epoch advances every 10 ms, and each guest
|
||||
yields at each epoch.
|
||||
- **(spike)** One `sausage run` per world (file lock on `world/.lock`).
|
||||
- **(spike)** HTTP for all running actors, with no allow-list yet.
|
||||
|
||||
## Agent
|
||||
|
||||
- **(you)** API shapes: OpenAI Responses and Anthropic Messages, chosen by
|
||||
`"api"` in `/data/config.json`.
|
||||
- **(spike)** Config fields: `api`, `base_url`, `model`, `token`, optional
|
||||
`reasoning_effort` and `max_tokens` (16,000). OpenAI: `POST
|
||||
{base_url}/v1/responses` with `store: false` and encrypted reasoning passed
|
||||
back. Anthropic: `POST {base_url}/v1/messages` with `x-api-key`.
|
||||
- **(spike)** One shared conversation in `/data/history.json`, in a neutral
|
||||
format, so the API shape can change. The history is limited to 600 entries,
|
||||
with the cut at a user message. There is no compaction yet.
|
||||
- **(spike)** A turn starts only for `{"type": "text"}` messages *without*
|
||||
`reply_to`. Replies never start turns, so two actors cannot keep the agent
|
||||
in an endless loop. Other messages are collected and shown at the next
|
||||
turn.
|
||||
- **(spike)** Tools: `send_message` (with optional wait for the reply),
|
||||
`wait_for_messages`, `read_file`, `list_dir`, `write_file`, `copy_dir`,
|
||||
`remove_path`. At most 40 steps per turn. Tool output is cut at 30 KB.
|
||||
- **(spike)** The final text of a turn goes to the sender, as a reply to the
|
||||
message that started the turn.
|
||||
- **(spike)** A shutdown during a turn saves the turn to `/data/turn.json`. The
|
||||
next start (for example after a self-upgrade) resumes the turn and answers
|
||||
the original message.
|
||||
|
||||
## Console
|
||||
|
||||
- **(spike)** `@<actor> <json|text>`, plain text to `agent`, `/deploy`,
|
||||
`/list`, `/sleep`, `/await`, `/help`, `/quit`. `/sleep` and `/await` exist
|
||||
for scripted tests.
|
||||
|
||||
## Known risks (accepted for now)
|
||||
|
||||
- Build scripts and proc macros run as native code in the host environment.
|
||||
- Any actor can upgrade the agent with code that reads the token.
|
||||
- Any actor can message, stop, upgrade, or remove any actor.
|
||||
- A full copy of `/data` and `/public` at every upgrade is slow for large data.
|
||||
- Mailboxes are in memory. A host crash loses queued messages.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Sausage
|
||||
|
||||
You can see how the sausage is made.
|
||||
|
||||
Sausage is a system of actors that you grow by talking to it. Every actor is a
|
||||
WASI program (a `.wasm` file) with stdin and stdout. Actors send each other
|
||||
newline-delimited JSON messages through a broker. The agent is an actor too:
|
||||
it does inference over HTTP, writes new actors, and can change its own code.
|
||||
|
||||
Inspired by [Jiti](https://ghuntley.com/lisp/), but with WASM instead of a
|
||||
Lisp image.
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
cargo build --release
|
||||
export SAUSAGE_API_TOKEN=... # or type it at the prompt
|
||||
./target/release/sausage init world --api openai-responses \
|
||||
--base-url https://api.example.com --model some-model
|
||||
./target/release/sausage run world
|
||||
```
|
||||
|
||||
`init` builds the agent (the first build takes a few minutes) and writes its
|
||||
configuration to `world/private/agent/data/config.json`. The source contains
|
||||
no endpoint and no token.
|
||||
|
||||
In the console:
|
||||
|
||||
```text
|
||||
hello → text to the agent
|
||||
@notes {"type":"add","text":"milk"} → a JSON message to an actor
|
||||
@agent what actors exist? → text to an actor
|
||||
/deploy notes examples/notes → build with cargo, then spawn or upgrade
|
||||
/list → list actors
|
||||
/await agent 300 → wait for the next message (scripts)
|
||||
/quit
|
||||
```
|
||||
|
||||
## Concepts
|
||||
|
||||
- **Actor**: a `wasi:cli/command` program. It reads messages on stdin and
|
||||
writes messages on stdout. Logs go to stderr.
|
||||
- **Message**: `{"id", "from", "reply_to"?, "body"}` on stdin;
|
||||
`{"to"?, "id"?, "reply_to"?, "body"}` on stdout. No `to` = publish to
|
||||
subscribers. There is no request/response: a reply is a new message with
|
||||
`reply_to`. Delivery is at most once.
|
||||
- **Broker**: routes messages, owns mailboxes, and runs actors. It accepts
|
||||
only `.wasm` files: `spawn`, `upgrade`, `rollback`, `stop`, `start`,
|
||||
`remove`, `subscribe`, `unsubscribe`, `list`.
|
||||
- **cargo**: the built-in Rust compiler actor. `build` a source folder, get a
|
||||
`.wasm` path back. Other actors can add other languages.
|
||||
- **Filesystem** of each actor:
|
||||
- `/data`: private state
|
||||
- `/public`: its public files
|
||||
- `/actor/<id>/{public,source,status.json,stderr.log}`: every actor, read-only
|
||||
|
||||
A write becomes visible when the writer closes the file (commit on close).
|
||||
- **Upgrade**: the broker tests the candidate on a copy of `/data` and
|
||||
`/public` (transcripts in `source/tests/*.jsonl`). Then it holds new
|
||||
messages, stops the old version, starts the new one, and delivers the held
|
||||
messages. A new version that crashes 3 times in 30 seconds is rolled back.
|
||||
|
||||
See [DECISIONS.md](DECISIONS.md) for every design decision and
|
||||
[SPIKE.md](SPIKE.md) for the spike results.
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "agent"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "The Sausage agent actor."
|
||||
|
||||
[dependencies]
|
||||
async-channel = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
wstd = "0.6"
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Inference through two API shapes: OpenAI Responses and Anthropic Messages.
|
||||
//!
|
||||
//! The conversation is stored in a neutral format, so the agent can switch
|
||||
//! between the two shapes.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use wstd::http::{Body, Client, Request};
|
||||
use wstd::time::Duration;
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct Config {
|
||||
/// `openai-responses` or `anthropic-messages`.
|
||||
pub api: String,
|
||||
pub base_url: String,
|
||||
pub model: String,
|
||||
pub token: String,
|
||||
/// Optional reasoning effort for OpenAI models ("low", "medium", ...).
|
||||
#[serde(default)]
|
||||
pub reasoning_effort: Option<String>,
|
||||
#[serde(default = "default_max_tokens")]
|
||||
pub max_tokens: u32,
|
||||
}
|
||||
|
||||
fn default_max_tokens() -> u32 {
|
||||
16000
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Entry {
|
||||
User { text: String },
|
||||
Assistant { text: String },
|
||||
ToolCall { id: String, name: String, arguments: String },
|
||||
ToolResult { id: String, output: String },
|
||||
/// A provider item that only this API shape understands (OpenAI reasoning).
|
||||
Opaque { api: String, item: Value },
|
||||
}
|
||||
|
||||
pub struct Tool {
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub parameters: Value,
|
||||
}
|
||||
|
||||
pub async fn complete(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
|
||||
match cfg.api.as_str() {
|
||||
"openai-responses" => openai(cfg, system, tools, history).await,
|
||||
"anthropic-messages" => anthropic(cfg, system, tools, history).await,
|
||||
other => Err(format!("unknown API shape {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn post(url: &str, headers: &[(&str, String)], body: &Value) -> Result<Value, String> {
|
||||
let mut req = Request::post(url).header("content-type", "application/json");
|
||||
for (k, v) in headers {
|
||||
req = req.header(*k, v.as_str());
|
||||
}
|
||||
let req = req.body(Body::from(body.to_string())).map_err(|e| format!("request: {e}"))?;
|
||||
let mut client = Client::new();
|
||||
client.set_first_byte_timeout(Duration::from_secs(600));
|
||||
client.set_between_bytes_timeout(Duration::from_secs(600));
|
||||
let resp = client.send(req).await.map_err(|e| format!("HTTP request failed: {e:?}"))?;
|
||||
let status = resp.status();
|
||||
let mut body = resp.into_body();
|
||||
let text = body.str_contents().await.map_err(|e| format!("reading the response failed: {e:?}"))?.to_string();
|
||||
if !status.is_success() {
|
||||
let mut t = text;
|
||||
t.truncate(2000);
|
||||
return Err(format!("HTTP {status}: {t}"));
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|e| format!("invalid JSON in response: {e}"))
|
||||
}
|
||||
|
||||
async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
|
||||
let mut input = Vec::new();
|
||||
for e in history {
|
||||
match e {
|
||||
Entry::User { text } => input.push(json!({"role": "user", "content": text})),
|
||||
Entry::Assistant { text } => input.push(json!({"role": "assistant", "content": text})),
|
||||
Entry::ToolCall { id, name, arguments } => {
|
||||
input.push(json!({"type": "function_call", "call_id": id, "name": name, "arguments": arguments}))
|
||||
}
|
||||
Entry::ToolResult { id, output } => {
|
||||
input.push(json!({"type": "function_call_output", "call_id": id, "output": output}))
|
||||
}
|
||||
Entry::Opaque { api, item } if api == "openai-responses" => input.push(item.clone()),
|
||||
Entry::Opaque { .. } => {}
|
||||
}
|
||||
}
|
||||
let tools: Vec<Value> = tools
|
||||
.iter()
|
||||
.map(|t| json!({"type": "function", "name": t.name, "description": t.description, "parameters": t.parameters}))
|
||||
.collect();
|
||||
let mut body = json!({
|
||||
"model": cfg.model,
|
||||
"instructions": system,
|
||||
"input": input,
|
||||
"tools": tools,
|
||||
"store": false,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"max_output_tokens": cfg.max_tokens,
|
||||
});
|
||||
if let Some(effort) = &cfg.reasoning_effort {
|
||||
body["reasoning"] = json!({"effort": effort});
|
||||
}
|
||||
let url = format!("{}/v1/responses", cfg.base_url);
|
||||
let resp = post(&url, &[("authorization", format!("Bearer {}", cfg.token))], &body).await?;
|
||||
let mut out = Vec::new();
|
||||
for item in resp["output"].as_array().cloned().unwrap_or_default() {
|
||||
match item["type"].as_str() {
|
||||
Some("message") => {
|
||||
let text: String = item["content"]
|
||||
.as_array()
|
||||
.map(|parts| {
|
||||
parts
|
||||
.iter()
|
||||
.filter(|p| p["type"] == "output_text")
|
||||
.filter_map(|p| p["text"].as_str())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if !text.is_empty() {
|
||||
out.push(Entry::Assistant { text });
|
||||
}
|
||||
}
|
||||
Some("function_call") => out.push(Entry::ToolCall {
|
||||
id: item["call_id"].as_str().unwrap_or_default().to_string(),
|
||||
name: item["name"].as_str().unwrap_or_default().to_string(),
|
||||
arguments: item["arguments"].as_str().unwrap_or("{}").to_string(),
|
||||
}),
|
||||
Some("reasoning") => out.push(Entry::Opaque { api: "openai-responses".into(), item }),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if resp["status"] == "incomplete" && out.is_empty() {
|
||||
return Err(format!("incomplete response: {}", resp["incomplete_details"]));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
|
||||
// Merge consecutive blocks of the same role into one message.
|
||||
let mut messages: Vec<(String, Vec<Value>)> = Vec::new();
|
||||
let mut push = |role: &str, block: Value| match messages.last_mut() {
|
||||
Some((r, blocks)) if r == role => blocks.push(block),
|
||||
_ => messages.push((role.to_string(), vec![block])),
|
||||
};
|
||||
for e in history {
|
||||
match e {
|
||||
Entry::User { text } => push("user", json!({"type": "text", "text": text})),
|
||||
Entry::Assistant { text } => push("assistant", json!({"type": "text", "text": text})),
|
||||
Entry::ToolCall { id, name, arguments } => {
|
||||
let input: Value = serde_json::from_str(arguments).unwrap_or(json!({}));
|
||||
push("assistant", json!({"type": "tool_use", "id": id, "name": name, "input": input}))
|
||||
}
|
||||
Entry::ToolResult { id, output } => {
|
||||
push("user", json!({"type": "tool_result", "tool_use_id": id, "content": output}))
|
||||
}
|
||||
Entry::Opaque { .. } => {}
|
||||
}
|
||||
}
|
||||
let messages: Vec<Value> = messages.into_iter().map(|(role, content)| json!({"role": role, "content": content})).collect();
|
||||
let tools: Vec<Value> = tools
|
||||
.iter()
|
||||
.map(|t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters}))
|
||||
.collect();
|
||||
let body = json!({
|
||||
"model": cfg.model,
|
||||
"system": system,
|
||||
"messages": messages,
|
||||
"tools": tools,
|
||||
"max_tokens": cfg.max_tokens,
|
||||
});
|
||||
let url = format!("{}/v1/messages", cfg.base_url);
|
||||
let resp = post(
|
||||
&url,
|
||||
&[("x-api-key", cfg.token.clone()), ("anthropic-version", "2023-06-01".to_string())],
|
||||
&body,
|
||||
)
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
for block in resp["content"].as_array().cloned().unwrap_or_default() {
|
||||
match block["type"].as_str() {
|
||||
Some("text") => {
|
||||
let text = block["text"].as_str().unwrap_or_default().to_string();
|
||||
if !text.is_empty() {
|
||||
out.push(Entry::Assistant { text });
|
||||
}
|
||||
}
|
||||
Some("tool_use") => out.push(Entry::ToolCall {
|
||||
id: block["id"].as_str().unwrap_or_default().to_string(),
|
||||
name: block["name"].as_str().unwrap_or_default().to_string(),
|
||||
arguments: block["input"].to_string(),
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
//! The Sausage agent: a normal WASM actor that does inference over HTTP and
|
||||
//! grows the world through messages to `cargo` and `broker`.
|
||||
//!
|
||||
//! Configuration: `/data/config.json` (see `llm::Config`).
|
||||
//! Conversation: `/data/history.json` (one shared conversation).
|
||||
|
||||
mod llm;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use async_channel::{Receiver, Sender};
|
||||
use llm::{Config, Entry, Tool};
|
||||
use serde_json::{Value, json};
|
||||
use wstd::future::FutureExt;
|
||||
use wstd::io::{AsyncRead, AsyncWrite};
|
||||
use wstd::time::{Duration, Instant};
|
||||
|
||||
const SYSTEM: &str = include_str!("prompt.md");
|
||||
const CONFIG: &str = "/data/config.json";
|
||||
const HISTORY: &str = "/data/history.json";
|
||||
/// An interrupted turn (sender and message ID), resumed at the next start.
|
||||
const TURN: &str = "/data/turn.json";
|
||||
const MAX_STEPS: usize = 40;
|
||||
const MAX_HISTORY: usize = 600;
|
||||
const MAX_TOOL_OUTPUT: usize = 30_000;
|
||||
const MAX_NOTES: usize = 50;
|
||||
|
||||
fn main() {
|
||||
wstd::runtime::block_on(async {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
wstd::runtime::spawn(read_stdin(tx)).detach();
|
||||
Agent::new(rx).run().await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Read NDJSON messages from stdin into the channel.
|
||||
async fn read_stdin(tx: Sender<Value>) {
|
||||
let mut stdin = wstd::io::stdin();
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = vec![0u8; 16 * 1024];
|
||||
loop {
|
||||
match stdin.read(&mut chunk).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
while let Some(pos) = buf.iter().position(|b| *b == b'\n') {
|
||||
let line: Vec<u8> = buf.drain(..=pos).collect();
|
||||
if let Ok(v) = serde_json::from_slice::<Value>(&line)
|
||||
&& tx.send(v).await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.close();
|
||||
}
|
||||
|
||||
fn truncate(mut s: String, max: usize) -> String {
|
||||
if s.len() > max {
|
||||
let mut end = max;
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
let total = s.len();
|
||||
s.truncate(end);
|
||||
s.push_str(&format!("\n… (truncated, {total} bytes in total)"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn tools() -> Vec<Tool> {
|
||||
vec![
|
||||
Tool {
|
||||
name: "send_message",
|
||||
description: "Send a message to an actor. With wait_seconds > 0, wait for the reply (a message whose reply_to is the ID of this message). Returns the message ID, the reply (or null), and other messages that arrived meanwhile.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"to": {"type": "string", "description": "Actor ID"},
|
||||
"body": {"type": "object", "description": "Message body. Must have a \"type\" field."},
|
||||
"wait_seconds": {"type": "number", "description": "Seconds to wait for the reply. 0 = do not wait."}
|
||||
},
|
||||
"required": ["to", "body"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "wait_for_messages",
|
||||
description: "Wait up to `seconds` for messages to arrive and return them.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {"seconds": {"type": "number"}},
|
||||
"required": ["seconds"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "read_file",
|
||||
description: "Read a text file. Paths: /data/..., /public/..., /actor/<id>/...",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"offset": {"type": "integer", "description": "Byte offset (default 0)"},
|
||||
"max_bytes": {"type": "integer", "description": "Default 30000"}
|
||||
},
|
||||
"required": ["path"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "list_dir",
|
||||
description: "List a folder.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string"}},
|
||||
"required": ["path"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "write_file",
|
||||
description: "Write a text file (only under /data or /public). Creates parent folders.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
|
||||
"required": ["path", "content"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "copy_dir",
|
||||
description: "Copy a folder tree, for example /actor/<id>/source to /public/drafts/<id>. Skips target/ folders.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {"from": {"type": "string"}, "to": {"type": "string"}},
|
||||
"required": ["from", "to"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "remove_path",
|
||||
description: "Remove a file or folder under /data or /public.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string"}},
|
||||
"required": ["path"]
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn copy_tree(from: &str, to: &str) -> std::io::Result<u64> {
|
||||
std::fs::create_dir_all(to)?;
|
||||
let mut n = 0;
|
||||
for entry in std::fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let src = format!("{from}/{name}");
|
||||
let dst = format!("{to}/{name}");
|
||||
if entry.file_type()?.is_dir() {
|
||||
if name != "target" {
|
||||
n += copy_tree(&src, &dst)?;
|
||||
}
|
||||
} else {
|
||||
std::fs::copy(&src, &dst)?;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
struct Agent {
|
||||
rx: Receiver<Value>,
|
||||
stdout: wstd::io::Stdout,
|
||||
history: Vec<Entry>,
|
||||
/// Text messages that start a new turn later.
|
||||
queue: VecDeque<Value>,
|
||||
/// Other messages that arrived while the agent was idle.
|
||||
notes: Vec<Value>,
|
||||
seq: u64,
|
||||
shutdown: bool,
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
fn new(rx: Receiver<Value>) -> Self {
|
||||
let history = std::fs::read_to_string(HISTORY)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
rx,
|
||||
stdout: wstd::io::stdout(),
|
||||
history,
|
||||
queue: VecDeque::new(),
|
||||
notes: Vec::new(),
|
||||
seq: 0,
|
||||
shutdown: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(mut self) {
|
||||
eprintln!("agent: started with {} history entries", self.history.len());
|
||||
self.resume().await;
|
||||
while !self.shutdown {
|
||||
let msg = match self.queue.pop_front() {
|
||||
Some(m) => m,
|
||||
None => match self.rx.recv().await {
|
||||
Ok(m) => m,
|
||||
Err(_) => break,
|
||||
},
|
||||
};
|
||||
if msg["body"]["type"] == "shutdown" {
|
||||
break;
|
||||
}
|
||||
if starts_turn(&msg) {
|
||||
self.turn(msg).await;
|
||||
} else {
|
||||
self.notes.push(msg);
|
||||
if self.notes.len() > MAX_NOTES {
|
||||
self.notes.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.save();
|
||||
eprintln!("agent: stopped");
|
||||
}
|
||||
|
||||
/// Continue a turn that a restart (usually an upgrade of the agent itself)
|
||||
/// interrupted.
|
||||
async fn resume(&mut self) {
|
||||
let Some(turn) = std::fs::read_to_string(TURN).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()) else {
|
||||
return;
|
||||
};
|
||||
let _ = std::fs::remove_file(TURN);
|
||||
let Ok(cfg) = std::fs::read_to_string(CONFIG).map_err(|_| ()).and_then(|s| serde_json::from_str::<Config>(&s).map_err(|_| ())) else {
|
||||
return;
|
||||
};
|
||||
// Every tool call needs a result, or the APIs reject the history.
|
||||
let answered: std::collections::HashSet<String> = self
|
||||
.history
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Entry::ToolResult { id, .. } => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let dangling: Vec<String> = self
|
||||
.history
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Entry::ToolCall { id, .. } if !answered.contains(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for id in dangling {
|
||||
self.history.push(Entry::ToolResult {
|
||||
id,
|
||||
output: json!({"error": "interrupted: the agent restarted before this tool finished"}).to_string(),
|
||||
});
|
||||
}
|
||||
let version = std::env::var("SAUSAGE_VERSION").unwrap_or_default();
|
||||
self.history.push(Entry::User {
|
||||
text: format!(
|
||||
"[note from your runtime] You restarted and now run version {version}. The restart interrupted your last turn (probably your own upgrade). Check the state you need, then finish the turn and answer the original message."
|
||||
),
|
||||
});
|
||||
eprintln!("agent: resuming an interrupted turn");
|
||||
self.steps(&cfg, turn["from"].clone(), turn["id"].clone()).await;
|
||||
}
|
||||
|
||||
fn save(&mut self) {
|
||||
if self.history.len() > MAX_HISTORY {
|
||||
let start = self.history.len() - MAX_HISTORY;
|
||||
let cut = (start..self.history.len())
|
||||
.find(|i| matches!(self.history[*i], Entry::User { .. }))
|
||||
.unwrap_or(start);
|
||||
self.history.drain(..cut);
|
||||
}
|
||||
if let Ok(s) = serde_json::to_string(&self.history)
|
||||
&& let Err(e) = std::fs::write(HISTORY, s)
|
||||
{
|
||||
eprintln!("agent: cannot save history: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn emit(&mut self, msg: Value) {
|
||||
let mut line = msg.to_string();
|
||||
line.push('\n');
|
||||
if self.stdout.write_all(line.as_bytes()).await.is_err() || self.stdout.flush().await.is_err() {
|
||||
eprintln!("agent: cannot write to stdout");
|
||||
}
|
||||
}
|
||||
|
||||
async fn reply(&mut self, to: &Value, reply_to: &Value, text: String) {
|
||||
self.emit(json!({"to": to, "reply_to": reply_to, "body": {"type": "text", "text": text}})).await;
|
||||
}
|
||||
|
||||
/// Sort a message that arrived during a turn.
|
||||
fn stash(&mut self, m: Value, others: &mut Vec<Value>) {
|
||||
if m["body"]["type"] == "shutdown" {
|
||||
self.shutdown = true;
|
||||
} else if starts_turn(&m) {
|
||||
others.push(json!({"queued_for_a_later_turn": true, "message": m}));
|
||||
self.queue.push_back(m);
|
||||
} else {
|
||||
others.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
async fn turn(&mut self, msg: Value) {
|
||||
let from = msg["from"].clone();
|
||||
let id = msg["id"].clone();
|
||||
let cfg: Config = match std::fs::read_to_string(CONFIG).map_err(|e| e.to_string()).and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string())) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
self.reply(&from, &id, format!("I am not configured: cannot read {CONFIG}: {e}")).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut text = format!(
|
||||
"Message {} from {}:\n{}",
|
||||
id.as_str().unwrap_or("?"),
|
||||
from.as_str().unwrap_or("?"),
|
||||
msg["body"]["text"].as_str().unwrap_or("")
|
||||
);
|
||||
if !self.notes.is_empty() {
|
||||
let notes: Vec<String> = self.notes.drain(..).map(|n| n.to_string()).collect();
|
||||
text = format!("Messages that arrived while you were idle:\n{}\n\n{text}", notes.join("\n"));
|
||||
}
|
||||
self.history.push(Entry::User { text });
|
||||
self.steps(&cfg, from, id).await;
|
||||
}
|
||||
|
||||
/// Run inference and tools until the model answers. If a shutdown
|
||||
/// interrupts the turn, remember it in TURN so the next start resumes it.
|
||||
async fn steps(&mut self, cfg: &Config, from: Value, id: Value) {
|
||||
let tools = tools();
|
||||
let cfg = cfg.clone();
|
||||
for _ in 0..MAX_STEPS {
|
||||
let started = Instant::now();
|
||||
let result = llm::complete(&cfg, SYSTEM, &tools, &self.history).await;
|
||||
let entries = match result {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("agent: inference failed: {e}");
|
||||
self.history.push(Entry::Assistant { text: format!("(inference failed: {e})") });
|
||||
self.save();
|
||||
self.reply(&from, &id, format!("Inference failed: {e}")).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
eprintln!("agent: inference took {:?}", started.elapsed());
|
||||
let calls: Vec<(String, String, String)> = entries
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Entry::ToolCall { id, name, arguments } => Some((id.clone(), name.clone(), arguments.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let answer: Vec<String> = entries
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Entry::Assistant { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
self.history.extend(entries);
|
||||
if calls.is_empty() {
|
||||
self.save();
|
||||
let answer = if answer.is_empty() { "(no answer)".to_string() } else { answer.join("\n\n") };
|
||||
self.reply(&from, &id, answer).await;
|
||||
return;
|
||||
}
|
||||
for (call_id, name, args) in calls {
|
||||
let output = self.tool(&name, &args).await;
|
||||
self.history.push(Entry::ToolResult { id: call_id, output: truncate(output.to_string(), MAX_TOOL_OUTPUT) });
|
||||
}
|
||||
self.save();
|
||||
if self.shutdown {
|
||||
let _ = std::fs::write(TURN, json!({"from": from, "id": id}).to_string());
|
||||
eprintln!("agent: turn interrupted by shutdown; it resumes after the restart");
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.reply(&from, &id, format!("I stopped after {MAX_STEPS} steps. Tell me to continue if you want.")).await;
|
||||
}
|
||||
|
||||
async fn tool(&mut self, name: &str, args: &str) -> Value {
|
||||
let a: Value = match serde_json::from_str(args) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json!({"error": format!("arguments are not valid JSON: {e}")}),
|
||||
};
|
||||
let path = |k: &str| a[k].as_str().unwrap_or_default().to_string();
|
||||
let err = |e: std::io::Error| json!({"error": e.to_string()});
|
||||
match name {
|
||||
"send_message" => self.send_message(&a).await,
|
||||
"wait_for_messages" => {
|
||||
let secs = a["seconds"].as_f64().unwrap_or(10.0).clamp(0.0, 900.0);
|
||||
let mut others = Vec::new();
|
||||
match self.rx.recv().timeout(Duration::from_secs_f64(secs)).await {
|
||||
Ok(Ok(m)) => {
|
||||
self.stash(m, &mut others);
|
||||
while let Ok(m) = self.rx.try_recv() {
|
||||
self.stash(m, &mut others);
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => self.shutdown = true,
|
||||
Err(_) => {}
|
||||
}
|
||||
json!({"messages": others})
|
||||
}
|
||||
"read_file" => {
|
||||
let p = path("path");
|
||||
match std::fs::read(&p) {
|
||||
Ok(bytes) => {
|
||||
let offset = (a["offset"].as_u64().unwrap_or(0) as usize).min(bytes.len());
|
||||
let max = a["max_bytes"].as_u64().unwrap_or(30_000) as usize;
|
||||
let end = (offset + max).min(bytes.len());
|
||||
json!({
|
||||
"path": p, "size": bytes.len(), "offset": offset,
|
||||
"content": String::from_utf8_lossy(&bytes[offset..end]),
|
||||
"truncated": end < bytes.len(),
|
||||
})
|
||||
}
|
||||
Err(e) => err(e),
|
||||
}
|
||||
}
|
||||
"list_dir" => match std::fs::read_dir(path("path")) {
|
||||
Ok(rd) => {
|
||||
let mut entries: Vec<Value> = rd
|
||||
.filter_map(|e| e.ok())
|
||||
.take(500)
|
||||
.map(|e| {
|
||||
let meta = e.metadata().ok();
|
||||
json!({
|
||||
"name": e.file_name().to_string_lossy(),
|
||||
"type": if meta.as_ref().is_some_and(|m| m.is_dir()) { "dir" } else { "file" },
|
||||
"size": meta.map(|m| m.len()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by(|x, y| x["name"].as_str().cmp(&y["name"].as_str()));
|
||||
json!({"entries": entries})
|
||||
}
|
||||
Err(e) => err(e),
|
||||
},
|
||||
"write_file" => {
|
||||
let p = path("path");
|
||||
if let Some(parent) = std::path::Path::new(&p).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
match std::fs::write(&p, a["content"].as_str().unwrap_or_default()) {
|
||||
Ok(()) => json!({"written": p}),
|
||||
Err(e) => err(e),
|
||||
}
|
||||
}
|
||||
"copy_dir" => match copy_tree(&path("from"), &path("to")) {
|
||||
Ok(n) => json!({"copied_files": n}),
|
||||
Err(e) => err(e),
|
||||
},
|
||||
"remove_path" => {
|
||||
let p = path("path");
|
||||
let r = if std::path::Path::new(&p).is_dir() { std::fs::remove_dir_all(&p) } else { std::fs::remove_file(&p) };
|
||||
match r {
|
||||
Ok(()) => json!({"removed": p}),
|
||||
Err(e) => err(e),
|
||||
}
|
||||
}
|
||||
other => json!({"error": format!("unknown tool {other}")}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_message(&mut self, a: &Value) -> Value {
|
||||
let Some(to) = a["to"].as_str() else { return json!({"error": "missing \"to\""}) };
|
||||
if !a["body"].is_object() {
|
||||
return json!({"error": "\"body\" must be a JSON object"});
|
||||
}
|
||||
self.seq += 1;
|
||||
let own = format!("a{}-{}", wstd_now_ms(), self.seq);
|
||||
let full = format!("agent/{own}");
|
||||
self.emit(json!({"to": to, "id": own, "body": a["body"]})).await;
|
||||
let wait = a["wait_seconds"].as_f64().unwrap_or(0.0).clamp(0.0, 900.0);
|
||||
let mut reply = Value::Null;
|
||||
let mut others = Vec::new();
|
||||
if wait > 0.0 {
|
||||
let deadline = Instant::now() + Duration::from_secs_f64(wait);
|
||||
loop {
|
||||
match self.rx.recv().timeout(deadline).await {
|
||||
Ok(Ok(m)) => {
|
||||
if m["reply_to"] == full.as_str() {
|
||||
reply = m;
|
||||
break;
|
||||
}
|
||||
self.stash(m, &mut others);
|
||||
if self.shutdown {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
// stdin closed: the broker stops us.
|
||||
self.shutdown = true;
|
||||
break;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({"id": full, "reply": reply, "waited": wait > 0.0, "other_messages": others})
|
||||
}
|
||||
}
|
||||
|
||||
/// A text message that is not a reply starts a turn. Replies never do, so
|
||||
/// two actors cannot keep the agent in an endless conversation.
|
||||
fn starts_turn(m: &Value) -> bool {
|
||||
m["body"]["type"] == "text" && m.get("reply_to").is_none_or(Value::is_null)
|
||||
}
|
||||
|
||||
fn wstd_now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
You are the agent of a Sausage world. Your actor ID is `agent`.
|
||||
|
||||
Sausage is a system of actors. Each actor is a WASI program (a `.wasm` file). Actors talk only through messages. People talk to you through other actors, for example `console` (a terminal) or later `matrix`. You help them by using actors, and by writing, testing, and upgrading actors. You can also change your own code.
|
||||
|
||||
# Messages
|
||||
|
||||
Every message has a sender (`from`), an ID, an optional `reply_to` (the ID of the message it answers), and a JSON `body`. A body is an object with a `"type"` field. There is no request/response: a reply is a new message with `reply_to` set. Delivery is at most once.
|
||||
|
||||
A WASM actor reads one JSON object per line on stdin:
|
||||
`{"id":"m-17","from":"console","body":{...}}`
|
||||
and writes one JSON object per line on stdout:
|
||||
`{"to":"console","reply_to":"m-17","body":{...}}`
|
||||
Without `"to"`, the broker publishes the message to the subscribers of the sender. An actor may set its own `"id"`. The broker then uses `"<sender>/<id>"`. stdout is only for messages. Logs go to stderr. The broker sends `{"type":"shutdown"}` before it stops an actor. EOF on stdin means "stop now".
|
||||
|
||||
# Filesystem
|
||||
|
||||
Each actor sees:
|
||||
- `/data`: private state, read-write. Only the actor itself sees it.
|
||||
- `/public`: its public files, read-write.
|
||||
- `/actor/<id>/public`: the public files of every actor, read-only.
|
||||
- `/actor/<id>/source`: the source of the running version of every actor, read-only.
|
||||
- `/actor/<id>/status.json` and `/actor/<id>/stderr.log`: state and stderr log of every actor.
|
||||
|
||||
`ls /actor` lists all actors. A write becomes visible to other actors only when the writer closes the file. Send large or binary data as a file path, not in a message.
|
||||
|
||||
# Built-in actors
|
||||
|
||||
`broker` (only accepts `.wasm` files, knows no source language):
|
||||
- `{"type":"spawn","actor":"<new-id>","wasm":"<path>","source":"<folder>","built_by":"cargo"}`
|
||||
- `{"type":"upgrade","actor":"<id>","wasm":"<path>","source":"<folder>","built_by":"cargo"}`
|
||||
- `{"type":"rollback","actor":"<id>"}` (optional `"version"`)
|
||||
- `{"type":"stop","actor":"<id>"}`, `{"type":"start","actor":"<id>"}`, `{"type":"remove","actor":"<id>"}` (archives, never deletes)
|
||||
- `{"type":"subscribe","actor":"<id>"}`, `{"type":"unsubscribe","actor":"<id>"}`, `{"type":"list"}`
|
||||
|
||||
Before an upgrade or spawn, the broker tests the candidate on a copy of the actor's `/data` and `/public`, with no HTTP access. The tests are message transcripts in `<source>/tests/*.jsonl`. Each line is `{"send": <body>}` or `{"expect": <subset of the reply body>}` (optional `"to"`). Without test files, the candidate must start and stay up for 2 seconds. If a new version crashes 3 times within 30 seconds, the broker rolls it back automatically. The broker keeps the message that crashed an actor and delivers it again after the next upgrade.
|
||||
|
||||
`cargo` (built-in Rust compiler):
|
||||
- `{"type":"build","source":"<folder with Cargo.toml>"}` → `{"type":"built","wasm":"/actor/cargo/public/builds/<n>/<name>.wasm",...}` or `{"type":"build_failed","errors":"..."}`. A build can take a minute. Wait up to 600 seconds for the reply.
|
||||
|
||||
# Writing an actor in Rust
|
||||
|
||||
1. Write the crate to `/public/drafts/<name>/`: `Cargo.toml`, `src/main.rs`, and `tests/*.jsonl`. Use `edition = "2024"`. Use `serde_json` for messages.
|
||||
2. Send `build` to `cargo` with the folder path `/actor/agent/public/drafts/<name>` and wait for the reply. Fix compiler errors and build again.
|
||||
3. Send `spawn` (new actor) or `upgrade` (existing actor) to `broker` with the `wasm` path from the build reply and the same `source` folder. Wait for the reply.
|
||||
4. Send a message to the new actor to check it.
|
||||
|
||||
A simple actor that only reacts to messages can block on stdin:
|
||||
|
||||
```rust
|
||||
use serde_json::{json, Value};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
fn main() {
|
||||
let mut out = std::io::stdout().lock();
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
let body = &msg["body"];
|
||||
let reply = match body["type"].as_str() {
|
||||
Some("shutdown") => break,
|
||||
Some("ping") => json!({"type": "pong"}),
|
||||
_ => json!({"type": "error", "reason": "unknown message"}),
|
||||
};
|
||||
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
|
||||
out.flush().unwrap();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
An actor that needs HTTP or timers and must also read messages uses the async `wstd` crate (`wstd = "0.6"`): `#[wstd::main] async fn main()`, `wstd::io::stdin()` (`AsyncRead`), `wstd::io::stdout()` (`AsyncWrite`, `write_all`, `flush`), `wstd::http::{Client, Request, Body}` (`Client::new().send(Request::get(url).body(Body::empty())?).await?`, then `response.into_body().contents().await?`), `wstd::task::sleep(wstd::time::Duration::from_secs(n)).await`, and `wstd::runtime::spawn(future)`. Actors have outgoing HTTP and HTTPS only, no raw sockets. Threads are not available. Store state in `/data` so it survives restarts and upgrades. A new version must read the data format of the old version.
|
||||
|
||||
# Changing yourself
|
||||
|
||||
Your own source is at `/actor/agent/source`. To change yourself, copy it to `/public/drafts/agent`, edit the copy, build it, and upgrade `agent`. The upgrade restarts you, and the conversation continues from `/data`. Be careful: if the new version cannot answer, the person must roll you back through the console. Change yourself only when the person asks for it.
|
||||
|
||||
# How to work
|
||||
|
||||
- Use tools to look at the real state before you claim anything. Read files instead of guessing.
|
||||
- Keep answers short and concrete. Say what you changed and whether it worked.
|
||||
- Your final text in a turn goes back to the person who wrote to you.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"description": "the agent answers text with text, even without config or network",
|
||||
"steps": [
|
||||
{"send": {"type": "text", "text": "Hello"}, "expect": {"type": "text"}}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "sausage"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Grow a system of WASI actors by talking to it."
|
||||
|
||||
[[bin]]
|
||||
name = "sausage"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
bytes = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
http = "1"
|
||||
http-body-util = "0.1"
|
||||
include_dir = "0.7"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
wasmtime = "49"
|
||||
wasmtime-wasi = "49"
|
||||
wasmtime-wasi-http = "49"
|
||||
@@ -0,0 +1,856 @@
|
||||
//! The broker: message routing, mailboxes, subscriptions, and the lifecycle of
|
||||
//! WASM actors (spawn, upgrade, rollback, stop, start, remove).
|
||||
//!
|
||||
//! The broker accepts only `.wasm` files. It does not know any source language.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use wasmtime::component::Component;
|
||||
|
||||
use crate::candidate;
|
||||
use crate::fs::Mount;
|
||||
use crate::runtime::{Exit, Instance, InstanceConfig, Runtime};
|
||||
use crate::world::{self, ActorRecord, NATIVE, Registry, VersionMeta, World};
|
||||
|
||||
pub const MAILBOX_SIZE: usize = 1000;
|
||||
pub const SHUTDOWN_DEADLINE: Duration = Duration::from_secs(5);
|
||||
/// A new version that crashes this often within `CRASH_WINDOW` after a swap
|
||||
/// is rolled back automatically.
|
||||
const CRASH_LIMIT: usize = 3;
|
||||
const CRASH_WINDOW: Duration = Duration::from_secs(30);
|
||||
const STDIN_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Envelope {
|
||||
pub id: String,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub reply_to: Option<String>,
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
impl Envelope {
|
||||
/// The NDJSON line that the recipient reads on stdin.
|
||||
pub fn to_line(&self) -> String {
|
||||
let mut v = json!({"id": self.id, "from": self.from, "body": self.body});
|
||||
if let Some(r) = &self.reply_to {
|
||||
v["reply_to"] = json!(r);
|
||||
}
|
||||
v.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shutdown_line() -> String {
|
||||
json!({"id": "broker/shutdown", "from": "broker", "body": {"type": "shutdown"}}).to_string()
|
||||
}
|
||||
|
||||
enum Control {
|
||||
Upgrade { version: u64, requester: String, reply: oneshot::Sender<Result<Value>> },
|
||||
Rollback { target: Option<u64>, reply: oneshot::Sender<Result<Value>> },
|
||||
Stop { reply: oneshot::Sender<Result<Value>> },
|
||||
Start { reply: oneshot::Sender<Result<Value>> },
|
||||
Remove { reply: oneshot::Sender<Result<Value>> },
|
||||
Shutdown { reply: oneshot::Sender<Result<Value>> },
|
||||
}
|
||||
|
||||
pub struct Broker {
|
||||
pub world: World,
|
||||
pub runtime: Arc<Runtime>,
|
||||
mailboxes: Mutex<HashMap<String, mpsc::Sender<Envelope>>>,
|
||||
controls: Mutex<HashMap<String, mpsc::Sender<Control>>>,
|
||||
registry: Mutex<Registry>,
|
||||
op_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||
seq: AtomicU64,
|
||||
}
|
||||
|
||||
impl Broker {
|
||||
pub fn new(world: World, runtime: Arc<Runtime>) -> Result<Arc<Self>> {
|
||||
let registry = world.load_registry()?;
|
||||
Ok(Arc::new(Self {
|
||||
world,
|
||||
runtime,
|
||||
mailboxes: Mutex::new(HashMap::new()),
|
||||
controls: Mutex::new(HashMap::new()),
|
||||
registry: Mutex::new(registry),
|
||||
op_locks: Mutex::new(HashMap::new()),
|
||||
seq: AtomicU64::new(1),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn registry(&self) -> Registry {
|
||||
self.registry.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn update_registry(&self, f: impl FnOnce(&mut Registry)) -> Result<()> {
|
||||
let mut reg = self.registry.lock().unwrap();
|
||||
f(&mut reg);
|
||||
self.world.save_registry(®)
|
||||
}
|
||||
|
||||
/// Create the mailbox of an actor. Messages wait here while the actor is
|
||||
/// down, during a swap, or while it restarts.
|
||||
pub fn open_mailbox(&self, id: &str) -> mpsc::Receiver<Envelope> {
|
||||
let (tx, rx) = mpsc::channel(MAILBOX_SIZE);
|
||||
self.mailboxes.lock().unwrap().insert(id.to_string(), tx);
|
||||
rx
|
||||
}
|
||||
|
||||
fn next_id(&self) -> String {
|
||||
format!("m-{}", self.seq.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Send a message from a native actor. `to: None` publishes to the
|
||||
/// subscribers of `from`. Returns the message ID.
|
||||
pub fn send(&self, from: &str, to: Option<&str>, id: Option<&str>, reply_to: Option<&str>, body: Value) -> String {
|
||||
let id = match id {
|
||||
Some(own) => format!("{from}/{own}"),
|
||||
None => self.next_id(),
|
||||
};
|
||||
let recipients: Vec<String> = match to {
|
||||
Some(to) => vec![to.to_string()],
|
||||
None => self
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.subscriptions
|
||||
.get(from)
|
||||
.map(|s| s.iter().cloned().collect())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
for to in recipients {
|
||||
self.deliver(Envelope {
|
||||
id: id.clone(),
|
||||
from: from.to_string(),
|
||||
to,
|
||||
reply_to: reply_to.map(str::to_string),
|
||||
body: body.clone(),
|
||||
});
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// Route one stdout line of a WASM actor.
|
||||
pub fn route_line(&self, from: &str, line: &str) {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return;
|
||||
}
|
||||
let parsed: Option<Value> = serde_json::from_str(line).ok();
|
||||
let Some(Value::Object(obj)) = parsed else {
|
||||
self.log(from, &format!("stdout line is not a JSON object, ignored: {}", truncate(line, 500)));
|
||||
return;
|
||||
};
|
||||
let Some(body) = obj.get("body").cloned() else {
|
||||
self.log(from, &format!("message without \"body\", ignored: {}", truncate(line, 500)));
|
||||
return;
|
||||
};
|
||||
let to = obj.get("to").and_then(Value::as_str);
|
||||
let own_id = obj.get("id").and_then(|v| match v {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
});
|
||||
let reply_to = obj.get("reply_to").and_then(Value::as_str);
|
||||
self.send(from, to, own_id.as_deref(), reply_to, body);
|
||||
}
|
||||
|
||||
fn deliver(&self, env: Envelope) {
|
||||
let tx = self.mailboxes.lock().unwrap().get(&env.to).cloned();
|
||||
let error = match tx {
|
||||
None => Some(format!("no actor with ID {}", env.to)),
|
||||
Some(tx) => match tx.try_send(env.clone()) {
|
||||
Ok(()) => None,
|
||||
Err(mpsc::error::TrySendError::Full(_)) => Some(format!("mailbox of {} is full", env.to)),
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => Some(format!("actor {} is gone", env.to)),
|
||||
},
|
||||
};
|
||||
if let Some(reason) = error
|
||||
&& env.from != "broker"
|
||||
&& env.from != "test"
|
||||
{
|
||||
self.send(
|
||||
"broker",
|
||||
Some(&env.from),
|
||||
None,
|
||||
Some(&env.id),
|
||||
json!({"type": "undeliverable", "reason": reason, "to": env.to}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a host note to the stderr log of an actor.
|
||||
pub fn log(&self, id: &str, text: &str) {
|
||||
use std::io::Write;
|
||||
let path = self.world.actor_dir(id).join("stderr.log");
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(f, "[sausage {}] {text}", world::now());
|
||||
}
|
||||
}
|
||||
|
||||
fn op_lock(&self, id: &str) -> Arc<tokio::sync::Mutex<()>> {
|
||||
self.op_locks.lock().unwrap().entry(id.to_string()).or_default().clone()
|
||||
}
|
||||
|
||||
/// Mounts of a running actor.
|
||||
pub fn mounts(&self, id: &str) -> Vec<Mount> {
|
||||
vec![
|
||||
Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: false },
|
||||
Mount { guest: "/public".into(), host: self.world.public_dir(id), writable: true },
|
||||
Mount { guest: "/data".into(), host: self.world.data_dir(id), writable: true },
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// Start the supervisors of all registered WASM actors.
|
||||
pub fn start_all(self: &Arc<Self>) {
|
||||
let reg = self.registry();
|
||||
for (id, rec) in reg.actors {
|
||||
self.start_supervisor(&id, rec.version, rec.running);
|
||||
}
|
||||
}
|
||||
|
||||
fn start_supervisor(self: &Arc<Self>, id: &str, version: u64, running: bool) {
|
||||
let mailbox = self.open_mailbox(id);
|
||||
let (ctl_tx, ctl_rx) = mpsc::channel(16);
|
||||
self.controls.lock().unwrap().insert(id.to_string(), ctl_tx);
|
||||
let sv = Supervisor::new(self.clone(), id.to_string(), version, running, mailbox, ctl_rx);
|
||||
tokio::spawn(sv.run());
|
||||
}
|
||||
|
||||
async fn control(&self, id: &str, make: impl FnOnce(oneshot::Sender<Result<Value>>) -> Control) -> Result<Value> {
|
||||
let tx = self.controls.lock().unwrap().get(id).cloned().ok_or_else(|| anyhow!("no WASM actor with ID {id}"))?;
|
||||
let (reply, rx) = oneshot::channel();
|
||||
tx.send(make(reply)).await.map_err(|_| anyhow!("supervisor of {id} is gone"))?;
|
||||
rx.await.map_err(|_| anyhow!("supervisor of {id} did not answer"))?
|
||||
}
|
||||
|
||||
/// Stop every actor gracefully (host shutdown). The registry keeps their
|
||||
/// `running` flags, so the next `sausage run` starts them again.
|
||||
pub async fn shutdown_all(&self) {
|
||||
let ids: Vec<String> = self.controls.lock().unwrap().keys().cloned().collect();
|
||||
let mut waits = Vec::new();
|
||||
for id in ids {
|
||||
waits.push(async move {
|
||||
let _ = self.control(&id, |reply| Control::Shutdown { reply }).await;
|
||||
});
|
||||
}
|
||||
futures_join_all(waits).await;
|
||||
}
|
||||
|
||||
/// Store a new version: copy the `.wasm` file and the source folder into
|
||||
/// the version store. Returns the version directory and the component.
|
||||
fn store_version(&self, id: &str, version: u64, wasm: &Path, source: Option<&Path>, meta: &VersionMeta) -> Result<(PathBuf, Component)> {
|
||||
let dir = self.world.version_dir(id, version);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
std::fs::copy(wasm, dir.join("actor.wasm")).with_context(|| format!("copy {}", wasm.display()))?;
|
||||
match source {
|
||||
Some(src) => world::copy_tree(src, &dir.join("source"))?,
|
||||
None => std::fs::create_dir_all(dir.join("source"))?,
|
||||
}
|
||||
world::write_atomic(&dir.join("meta.json"), serde_json::to_string_pretty(meta)?.as_bytes())?;
|
||||
let component = self.runtime.load(&dir.join("actor.wasm"))?;
|
||||
Ok((dir, component))
|
||||
}
|
||||
|
||||
/// Create a new actor from a `.wasm` file. With `start`, also start its
|
||||
/// supervisor (in a running host). Without `start` (during `init`), only
|
||||
/// register it.
|
||||
pub async fn create_actor(
|
||||
self: &Arc<Self>,
|
||||
id: &str,
|
||||
wasm: &Path,
|
||||
source: Option<&Path>,
|
||||
meta: VersionMeta,
|
||||
start: bool,
|
||||
) -> Result<Value> {
|
||||
if !world::valid_id(id) {
|
||||
bail!("invalid actor ID {id:?}: use a-z, 0-9, '-' and '_'");
|
||||
}
|
||||
if NATIVE.contains(&id) || self.registry().actors.contains_key(id) {
|
||||
bail!("an actor with ID {id} already exists");
|
||||
}
|
||||
let lock = self.op_lock(id);
|
||||
let _guard = lock.lock().await;
|
||||
let (dir, component) = self.store_version(id, 1, wasm, source, &meta)?;
|
||||
let report = candidate::test(self, id, &component, &dir.join("source"), None, None).await;
|
||||
let report = match report {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_dir_all(self.world.versions_dir(id));
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
self.world.ensure_actor_dirs(id)?;
|
||||
world::replace_tree(&dir.join("source"), &self.world.source_dir(id))?;
|
||||
self.update_registry(|r| {
|
||||
r.actors.insert(id.to_string(), ActorRecord { version: 1, next_version: 2, running: true });
|
||||
})?;
|
||||
write_status(&self.world, id, &json!({"id": id, "version": 1, "state": "created"}));
|
||||
if start {
|
||||
self.start_supervisor(id, 1, true);
|
||||
}
|
||||
Ok(json!({"type": "spawned", "actor": id, "version": 1, "tests": report}))
|
||||
}
|
||||
|
||||
pub async fn upgrade_actor(self: &Arc<Self>, id: &str, wasm: &Path, source: Option<&Path>, mut meta: VersionMeta) -> Result<Value> {
|
||||
let lock = self.op_lock(id);
|
||||
let _guard = lock.lock().await;
|
||||
let rec = self.registry().actors.get(id).cloned().ok_or_else(|| anyhow!("no WASM actor with ID {id}"))?;
|
||||
let version = rec.next_version;
|
||||
self.update_registry(|r| {
|
||||
if let Some(a) = r.actors.get_mut(id) {
|
||||
a.next_version = version + 1;
|
||||
}
|
||||
})?;
|
||||
meta.version = version;
|
||||
let (dir, component) = self.store_version(id, version, wasm, source, &meta)?;
|
||||
let report = candidate::test(
|
||||
self,
|
||||
id,
|
||||
&component,
|
||||
&dir.join("source"),
|
||||
Some(&self.world.data_dir(id)),
|
||||
Some(&self.world.public_dir(id)),
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = report {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
return Err(e);
|
||||
}
|
||||
let mut result = self
|
||||
.control(id, |reply| Control::Upgrade { version, requester: meta.requested_by.clone(), reply })
|
||||
.await?;
|
||||
result["tests"] = report.unwrap();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Handle one message to `broker`.
|
||||
async fn handle(self: Arc<Self>, env: Envelope) {
|
||||
let result = self.clone().command(&env).await;
|
||||
let body = match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => json!({"type": "error", "request": env.body.get("type"), "reason": format!("{e:#}")}),
|
||||
};
|
||||
self.send("broker", Some(&env.from), None, Some(&env.id), body);
|
||||
}
|
||||
|
||||
async fn command(self: Arc<Self>, env: &Envelope) -> Result<Value> {
|
||||
let b = &env.body;
|
||||
let ty = b.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
let actor = || -> Result<String> {
|
||||
b.get("actor").and_then(Value::as_str).map(str::to_string).ok_or_else(|| anyhow!("missing \"actor\""))
|
||||
};
|
||||
let meta = |version: u64| -> Result<(PathBuf, Option<PathBuf>, VersionMeta)> {
|
||||
let wasm_s = b.get("wasm").and_then(Value::as_str).ok_or_else(|| anyhow!("missing \"wasm\""))?;
|
||||
let wasm = self.world.resolve(&env.from, wasm_s)?;
|
||||
if !wasm.is_file() {
|
||||
bail!("{wasm_s} is not a file");
|
||||
}
|
||||
let source_s = b.get("source").and_then(Value::as_str);
|
||||
let source = source_s.map(|s| self.world.resolve(&env.from, s)).transpose()?;
|
||||
if let Some(s) = &source
|
||||
&& !s.is_dir()
|
||||
{
|
||||
bail!("{} is not a folder", source_s.unwrap());
|
||||
}
|
||||
let m = VersionMeta {
|
||||
version,
|
||||
wasm: wasm_s.to_string(),
|
||||
source: source_s.map(str::to_string),
|
||||
built_by: b.get("built_by").and_then(Value::as_str).map(str::to_string),
|
||||
requested_by: env.from.clone(),
|
||||
created_at: world::now(),
|
||||
};
|
||||
Ok((wasm, source, m))
|
||||
};
|
||||
match ty {
|
||||
"spawn" => {
|
||||
let id = actor()?;
|
||||
let (wasm, source, m) = meta(1)?;
|
||||
self.create_actor(&id, &wasm, source.as_deref(), m, true).await
|
||||
}
|
||||
"upgrade" => {
|
||||
let id = actor()?;
|
||||
let (wasm, source, m) = meta(0)?;
|
||||
self.upgrade_actor(&id, &wasm, source.as_deref(), m).await
|
||||
}
|
||||
"rollback" => {
|
||||
let id = actor()?;
|
||||
let target = b.get("version").and_then(Value::as_u64);
|
||||
let lock = self.op_lock(&id);
|
||||
let _g = lock.lock().await;
|
||||
self.control(&id, |reply| Control::Rollback { target, reply }).await
|
||||
}
|
||||
"stop" => self.control(&actor()?, |reply| Control::Stop { reply }).await,
|
||||
"start" => self.control(&actor()?, |reply| Control::Start { reply }).await,
|
||||
"remove" => {
|
||||
let id = actor()?;
|
||||
let lock = self.op_lock(&id);
|
||||
let _g = lock.lock().await;
|
||||
let r = self.control(&id, |reply| Control::Remove { reply }).await?;
|
||||
self.mailboxes.lock().unwrap().remove(&id);
|
||||
self.controls.lock().unwrap().remove(&id);
|
||||
self.update_registry(|reg| {
|
||||
reg.actors.remove(&id);
|
||||
reg.subscriptions.remove(&id);
|
||||
for subs in reg.subscriptions.values_mut() {
|
||||
subs.remove(&id);
|
||||
}
|
||||
})?;
|
||||
Ok(r)
|
||||
}
|
||||
"subscribe" | "unsubscribe" => {
|
||||
let publisher = actor()?;
|
||||
let sub = env.from.clone();
|
||||
let on = ty == "subscribe";
|
||||
self.update_registry(|reg| {
|
||||
let set = reg.subscriptions.entry(publisher.clone()).or_insert_with(BTreeSet::new);
|
||||
if on {
|
||||
set.insert(sub.clone());
|
||||
} else {
|
||||
set.remove(&sub);
|
||||
}
|
||||
})?;
|
||||
Ok(json!({"type": format!("{ty}d"), "actor": publisher}))
|
||||
}
|
||||
"list" => {
|
||||
let reg = self.registry();
|
||||
let mut actors: Vec<Value> = NATIVE.iter().map(|id| json!({"id": id, "kind": "native"})).collect();
|
||||
for (id, rec) in reg.actors {
|
||||
let status = std::fs::read_to_string(self.world.actor_dir(&id).join("status.json"))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<Value>(&s).ok());
|
||||
actors.push(json!({
|
||||
"id": id, "kind": "wasm", "version": rec.version, "running": rec.running,
|
||||
"state": status.as_ref().and_then(|s| s.get("state")).cloned(),
|
||||
}));
|
||||
}
|
||||
Ok(json!({"type": "actors", "actors": actors, "subscriptions": reg.subscriptions}))
|
||||
}
|
||||
"shutdown" => Ok(json!({"type": "ignored"})),
|
||||
other => bail!("unknown broker request {other:?}. Known: spawn, upgrade, rollback, stop, start, remove, subscribe, unsubscribe, list"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The native `broker` actor.
|
||||
pub async fn run_actor(self: Arc<Self>, mut mailbox: mpsc::Receiver<Envelope>) {
|
||||
while let Some(env) = mailbox.recv().await {
|
||||
tokio::spawn(self.clone().handle(env));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn futures_join_all<F: Future<Output = ()>>(futs: Vec<F>) {
|
||||
let mut set = Vec::new();
|
||||
for f in futs {
|
||||
set.push(Box::pin(f));
|
||||
}
|
||||
for f in set {
|
||||
f.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut end = max;
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}… ({} bytes)", &s[..end], s.len())
|
||||
}
|
||||
|
||||
fn write_status(world: &World, id: &str, status: &Value) {
|
||||
let mut s = status.clone();
|
||||
s["updated_at"] = json!(world::now());
|
||||
let _ = world::write_atomic(
|
||||
&world.actor_dir(id).join("status.json"),
|
||||
serde_json::to_string_pretty(&s).unwrap_or_default().as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Supervisor: owns one WASM actor and its instance.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct Supervisor {
|
||||
broker: Arc<Broker>,
|
||||
id: String,
|
||||
version: u64,
|
||||
desired_running: bool,
|
||||
mailbox: mpsc::Receiver<Envelope>,
|
||||
control: mpsc::Receiver<Control>,
|
||||
component: Option<Component>,
|
||||
instance: Option<Instance>,
|
||||
state: &'static str,
|
||||
started_at: Instant,
|
||||
restarts: u32,
|
||||
restart_at: Option<Instant>,
|
||||
last_exit: Option<String>,
|
||||
last_delivered: Option<Envelope>,
|
||||
/// The message that the actor got just before it crashed. The broker
|
||||
/// delivers it again after the next upgrade.
|
||||
parked: Option<Envelope>,
|
||||
redeliver: Option<Envelope>,
|
||||
previous: Option<u64>,
|
||||
swapped_at: Option<Instant>,
|
||||
crashes_since_swap: usize,
|
||||
upgrade_requester: Option<String>,
|
||||
}
|
||||
|
||||
async fn recv_line(instance: &mut Option<Instance>) -> Option<String> {
|
||||
match instance {
|
||||
Some(i) => i.stdout.recv().await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep_until_opt(t: Option<Instant>) {
|
||||
match t {
|
||||
Some(t) => tokio::time::sleep_until(t.into()).await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
}
|
||||
|
||||
impl Supervisor {
|
||||
fn new(
|
||||
broker: Arc<Broker>,
|
||||
id: String,
|
||||
version: u64,
|
||||
running: bool,
|
||||
mailbox: mpsc::Receiver<Envelope>,
|
||||
control: mpsc::Receiver<Control>,
|
||||
) -> Self {
|
||||
Self {
|
||||
broker,
|
||||
id,
|
||||
version,
|
||||
desired_running: running,
|
||||
mailbox,
|
||||
control,
|
||||
component: None,
|
||||
instance: None,
|
||||
state: if running { "starting" } else { "stopped" },
|
||||
started_at: Instant::now(),
|
||||
restarts: 0,
|
||||
restart_at: None,
|
||||
last_exit: None,
|
||||
last_delivered: None,
|
||||
parked: None,
|
||||
redeliver: None,
|
||||
previous: None,
|
||||
swapped_at: None,
|
||||
crashes_since_swap: 0,
|
||||
upgrade_requester: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn world(&self) -> &World {
|
||||
&self.broker.world
|
||||
}
|
||||
|
||||
fn status(&self) {
|
||||
write_status(
|
||||
self.world(),
|
||||
&self.id,
|
||||
&json!({
|
||||
"id": self.id,
|
||||
"version": self.version,
|
||||
"state": self.state,
|
||||
"restarts": self.restarts,
|
||||
"last_exit": self.last_exit,
|
||||
"parked_message": self.parked.as_ref().map(|m| json!({"id": m.id, "from": m.from, "body": m.body})),
|
||||
"previous_version": self.previous,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async fn run(mut self) {
|
||||
self.status();
|
||||
loop {
|
||||
if self.desired_running && self.instance.is_none() && self.restart_at.is_none_or(|t| Instant::now() >= t) {
|
||||
self.restart_at = None;
|
||||
self.start_instance().await;
|
||||
}
|
||||
let has_instance = self.instance.is_some();
|
||||
let waiting = !has_instance && self.desired_running;
|
||||
tokio::select! {
|
||||
ctl = self.control.recv() => {
|
||||
let Some(ctl) = ctl else { break };
|
||||
if self.handle_control(ctl).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
msg = self.mailbox.recv(), if has_instance => {
|
||||
let Some(msg) = msg else { break };
|
||||
self.deliver(msg).await;
|
||||
}
|
||||
line = recv_line(&mut self.instance) => {
|
||||
match line {
|
||||
Some(line) => self.broker.route_line(&self.id, &line),
|
||||
None => {
|
||||
let exit = self.instance.as_mut().unwrap().wait().await;
|
||||
self.instance = None;
|
||||
self.on_exit(exit).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = sleep_until_opt(self.restart_at), if waiting => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn deliver(&mut self, msg: Envelope) {
|
||||
let Some(inst) = self.instance.as_mut() else { return };
|
||||
let line = msg.to_line();
|
||||
match tokio::time::timeout(STDIN_WRITE_TIMEOUT, inst.send_line(&line)).await {
|
||||
Ok(Ok(())) => self.last_delivered = Some(msg),
|
||||
Ok(Err(_)) => {} // The instance is ending. The exit path handles it.
|
||||
Err(_) => {
|
||||
let note = format!("actor did not read stdin for {STDIN_WRITE_TIMEOUT:?}; message {} dropped", msg.id);
|
||||
self.broker.log(&self.id, ¬e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_instance(&mut self) {
|
||||
if self.component.is_none() {
|
||||
let path = self.world().version_dir(&self.id, self.version).join("actor.wasm");
|
||||
let t = Instant::now();
|
||||
match self.broker.runtime.load(&path) {
|
||||
Ok(c) => {
|
||||
self.broker.log(&self.id, &format!("compiled version {} in {} ms", self.version, t.elapsed().as_millis()));
|
||||
self.component = Some(c);
|
||||
}
|
||||
Err(e) => {
|
||||
self.broker.log(&self.id, &format!("cannot load version {}: {e:#}", self.version));
|
||||
self.state = "broken";
|
||||
self.desired_running = false;
|
||||
self.status();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let cfg = InstanceConfig {
|
||||
mounts: self.broker.mounts(&self.id),
|
||||
allow_http: true,
|
||||
stderr_log: self.world().actor_dir(&self.id).join("stderr.log"),
|
||||
env: vec![
|
||||
("SAUSAGE_ACTOR".into(), self.id.clone()),
|
||||
("SAUSAGE_VERSION".into(), self.version.to_string()),
|
||||
],
|
||||
};
|
||||
match self.broker.runtime.start(self.component.as_ref().unwrap(), cfg) {
|
||||
Ok(inst) => {
|
||||
self.instance = Some(inst);
|
||||
self.state = "running";
|
||||
self.started_at = Instant::now();
|
||||
self.status();
|
||||
if let Some(msg) = self.redeliver.take() {
|
||||
self.broker.log(&self.id, &format!("delivering parked message {} again", msg.id));
|
||||
self.deliver(msg).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.broker.log(&self.id, &format!("cannot start: {e:#}"));
|
||||
self.state = "broken";
|
||||
self.desired_running = false;
|
||||
self.status();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_instance(&mut self) -> Option<Exit> {
|
||||
let inst = self.instance.take()?;
|
||||
let exit = inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE).await;
|
||||
self.broker.log(&self.id, &format!("stopped: {exit}"));
|
||||
self.last_exit = Some(exit.to_string());
|
||||
Some(exit)
|
||||
}
|
||||
|
||||
async fn on_exit(&mut self, exit: Exit) {
|
||||
self.broker.log(&self.id, &format!("instance ended unexpectedly: {exit}"));
|
||||
self.last_exit = Some(exit.to_string());
|
||||
if !exit.is_clean()
|
||||
&& let Some(m) = self.last_delivered.take()
|
||||
{
|
||||
self.parked = Some(m);
|
||||
}
|
||||
if self.started_at.elapsed() > Duration::from_secs(60) {
|
||||
self.restarts = 0;
|
||||
}
|
||||
if let (Some(prev), Some(at)) = (self.previous, self.swapped_at)
|
||||
&& at.elapsed() < CRASH_WINDOW
|
||||
{
|
||||
self.crashes_since_swap += 1;
|
||||
if self.crashes_since_swap >= CRASH_LIMIT {
|
||||
let from = self.version;
|
||||
self.broker.log(&self.id, &format!("version {from} crashed {CRASH_LIMIT} times after the upgrade; rolling back to {prev}"));
|
||||
let result = self.rollback(Some(prev)).await;
|
||||
let body = match result {
|
||||
Ok(_) => json!({"type": "rolled_back", "actor": self.id, "from": from, "to": prev, "reason": "crash loop after upgrade", "last_exit": exit.to_string()}),
|
||||
Err(e) => json!({"type": "error", "actor": self.id, "reason": format!("automatic rollback failed: {e:#}")}),
|
||||
};
|
||||
if let Some(r) = self.upgrade_requester.clone() {
|
||||
self.broker.send("broker", Some(&r), None, None, body);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
let delay = Duration::from_millis(500 * 2u64.pow(self.restarts.min(6))).min(Duration::from_secs(30));
|
||||
self.restarts += 1;
|
||||
self.restart_at = Some(Instant::now() + delay);
|
||||
self.state = "restarting";
|
||||
self.status();
|
||||
}
|
||||
|
||||
fn snapshot_current(&self) -> Result<()> {
|
||||
let snap = self.world().version_dir(&self.id, self.version).join("snapshot");
|
||||
world::replace_tree(&self.world().data_dir(&self.id), &snap.join("data"))?;
|
||||
world::replace_tree(&self.world().public_dir(&self.id), &snap.join("public"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install(&mut self, version: u64) -> Result<()> {
|
||||
let src = self.world().version_dir(&self.id, version).join("source");
|
||||
world::replace_tree(&src, &self.world().source_dir(&self.id))?;
|
||||
self.version = version;
|
||||
self.component = None;
|
||||
let id = self.id.clone();
|
||||
self.broker.update_registry(|r| {
|
||||
if let Some(a) = r.actors.get_mut(&id) {
|
||||
a.version = version;
|
||||
a.running = true;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn upgrade(&mut self, version: u64, requester: String) -> Result<Value> {
|
||||
self.stop_instance().await;
|
||||
self.snapshot_current()?;
|
||||
let previous = self.version;
|
||||
self.install(version)?;
|
||||
self.previous = Some(previous);
|
||||
self.swapped_at = Some(Instant::now());
|
||||
self.crashes_since_swap = 0;
|
||||
self.restarts = 0;
|
||||
self.restart_at = None;
|
||||
self.upgrade_requester = Some(requester);
|
||||
self.redeliver = self.parked.take();
|
||||
self.desired_running = true;
|
||||
self.start_instance().await;
|
||||
Ok(json!({"type": "upgraded", "actor": self.id, "version": version, "previous": previous}))
|
||||
}
|
||||
|
||||
async fn rollback(&mut self, target: Option<u64>) -> Result<Value> {
|
||||
let target = match target {
|
||||
Some(t) => t,
|
||||
None => (1..self.version)
|
||||
.rev()
|
||||
.find(|v| self.world().version_dir(&self.id, *v).join("actor.wasm").is_file())
|
||||
.ok_or_else(|| anyhow!("{} has no version before {}", self.id, self.version))?,
|
||||
};
|
||||
let dir = self.world().version_dir(&self.id, target);
|
||||
if !dir.join("actor.wasm").is_file() {
|
||||
bail!("{} has no version {target}", self.id);
|
||||
}
|
||||
self.stop_instance().await;
|
||||
self.snapshot_current()?;
|
||||
let from = self.version;
|
||||
let snap = dir.join("snapshot");
|
||||
if snap.is_dir() {
|
||||
world::replace_tree(&snap.join("data"), &self.world().data_dir(&self.id))?;
|
||||
world::replace_tree(&snap.join("public"), &self.world().public_dir(&self.id))?;
|
||||
}
|
||||
self.install(target)?;
|
||||
self.previous = None;
|
||||
self.swapped_at = None;
|
||||
self.restarts = 0;
|
||||
self.restart_at = None;
|
||||
self.desired_running = true;
|
||||
self.start_instance().await;
|
||||
Ok(json!({"type": "rolled_back", "actor": self.id, "from": from, "to": target, "restored_state": snap.is_dir()}))
|
||||
}
|
||||
|
||||
/// Returns `true` when the supervisor must end.
|
||||
async fn handle_control(&mut self, ctl: Control) -> bool {
|
||||
match ctl {
|
||||
Control::Upgrade { version, requester, reply } => {
|
||||
let r = self.upgrade(version, requester).await;
|
||||
let _ = reply.send(r);
|
||||
}
|
||||
Control::Rollback { target, reply } => {
|
||||
let r = self.rollback(target).await;
|
||||
let _ = reply.send(r);
|
||||
}
|
||||
Control::Stop { reply } => {
|
||||
self.desired_running = false;
|
||||
self.restart_at = None;
|
||||
self.stop_instance().await;
|
||||
self.state = "stopped";
|
||||
let id = self.id.clone();
|
||||
let r = self.broker.update_registry(|r| {
|
||||
if let Some(a) = r.actors.get_mut(&id) {
|
||||
a.running = false;
|
||||
}
|
||||
});
|
||||
let _ = reply.send(r.map(|_| json!({"type": "stopped", "actor": self.id})));
|
||||
}
|
||||
Control::Start { reply } => {
|
||||
self.desired_running = true;
|
||||
self.restart_at = None;
|
||||
let id = self.id.clone();
|
||||
let r = self.broker.update_registry(|r| {
|
||||
if let Some(a) = r.actors.get_mut(&id) {
|
||||
a.running = true;
|
||||
}
|
||||
});
|
||||
let _ = reply.send(r.map(|_| json!({"type": "started", "actor": self.id})));
|
||||
}
|
||||
Control::Remove { reply } => {
|
||||
self.desired_running = false;
|
||||
self.stop_instance().await;
|
||||
let r = self.archive();
|
||||
let _ = reply.send(r);
|
||||
return true;
|
||||
}
|
||||
Control::Shutdown { reply } => {
|
||||
self.stop_instance().await;
|
||||
self.state = "host stopped";
|
||||
self.status();
|
||||
let _ = reply.send(Ok(json!({"type": "shut_down"})));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.status();
|
||||
false
|
||||
}
|
||||
|
||||
/// Move every folder of the actor into `archive/`. Nothing is deleted.
|
||||
fn archive(&self) -> Result<Value> {
|
||||
let dest = self.world().root.join("archive").join(format!("{}-{}", self.id, world::now()));
|
||||
std::fs::create_dir_all(&dest)?;
|
||||
for (from, name) in [
|
||||
(self.world().actor_dir(&self.id), "actor"),
|
||||
(self.world().root.join("private").join(&self.id), "private"),
|
||||
(self.world().versions_dir(&self.id), "versions"),
|
||||
] {
|
||||
if from.exists() {
|
||||
std::fs::rename(&from, dest.join(name))?;
|
||||
}
|
||||
}
|
||||
Ok(json!({"type": "removed", "actor": self.id, "archive": dest.display().to_string()}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Tests for a candidate version, before the broker installs it.
|
||||
//!
|
||||
//! A test is a JSON file in `source/tests/*.json`, one scenario per file:
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "description": "add, then list",
|
||||
//! "steps": [
|
||||
//! {"send": {"type": "add", "text": "milk"}, "expect": {"type": "added"}},
|
||||
//! {"send": {"type": "list"}, "from": "someone", "expect": [{"type": "notes"}], "to": "someone"}
|
||||
//! ]
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Each step sends `send` (optional, from `from` or "test"), then reads one
|
||||
//! message for each entry of `expect` (an object or a list). An expected body
|
||||
//! matches if it is a subset of the actual body. With `to`, each message must
|
||||
//! also go to that recipient.
|
||||
//!
|
||||
//! Each file runs in a fresh instance on a copy of the actor's `/data` and
|
||||
//! `/public`. The candidate has no HTTP access. Without test files, the
|
||||
//! candidate must start and stay up for `SMOKE_TIME`.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use serde_json::{Value, json};
|
||||
use wasmtime::component::Component;
|
||||
|
||||
use crate::broker::{Broker, SHUTDOWN_DEADLINE, shutdown_line, truncate};
|
||||
use crate::fs::Mount;
|
||||
use crate::runtime::{Instance, InstanceConfig};
|
||||
use crate::world;
|
||||
|
||||
const SMOKE_TIME: Duration = Duration::from_secs(2);
|
||||
const EXPECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// `expected` matches `actual` if every key of an expected object exists in
|
||||
/// the actual object with a matching value. Other values must be equal.
|
||||
pub fn subset_match(expected: &Value, actual: &Value) -> bool {
|
||||
match (expected, actual) {
|
||||
(Value::Object(e), Value::Object(a)) => e.iter().all(|(k, v)| a.get(k).is_some_and(|av| subset_match(v, av))),
|
||||
(Value::Array(e), Value::Array(a)) => e.len() == a.len() && e.iter().zip(a).all(|(x, y)| subset_match(x, y)),
|
||||
_ => expected == actual,
|
||||
}
|
||||
}
|
||||
|
||||
struct Scratch {
|
||||
dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn stderr_tail(path: &Path) -> String {
|
||||
let text = std::fs::read_to_string(path).unwrap_or_default();
|
||||
let start = text.len().saturating_sub(2000);
|
||||
let mut s = start;
|
||||
while !text.is_char_boundary(s) {
|
||||
s += 1;
|
||||
}
|
||||
text[s..].to_string()
|
||||
}
|
||||
|
||||
pub async fn test(
|
||||
broker: &Broker,
|
||||
id: &str,
|
||||
component: &Component,
|
||||
source: &Path,
|
||||
data: Option<&Path>,
|
||||
public: Option<&Path>,
|
||||
) -> Result<Value> {
|
||||
let mut files: Vec<_> = std::fs::read_dir(source.join("tests"))
|
||||
.map(|rd| {
|
||||
rd.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "json"))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
files.sort();
|
||||
|
||||
if files.is_empty() {
|
||||
let (mut inst, scratch) = start(broker, id, component, data, public)?;
|
||||
tokio::time::sleep(SMOKE_TIME).await;
|
||||
if inst.is_finished() {
|
||||
let exit = inst.wait().await;
|
||||
bail!(
|
||||
"candidate ended during the smoke test ({exit}). stderr:\n{}",
|
||||
stderr_tail(&scratch.dir.join("stderr.log"))
|
||||
);
|
||||
}
|
||||
inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE).await;
|
||||
return Ok(json!({"smoke_test": "passed", "files": 0}));
|
||||
}
|
||||
|
||||
let mut passed = Vec::new();
|
||||
for file in &files {
|
||||
let name = file.file_name().unwrap().to_string_lossy().to_string();
|
||||
run_file(broker, id, component, data, public, file)
|
||||
.await
|
||||
.map_err(|e| anyhow!("test {name} failed: {e:#}"))?;
|
||||
passed.push(name);
|
||||
}
|
||||
Ok(json!({"passed": passed}))
|
||||
}
|
||||
|
||||
fn start(broker: &Broker, id: &str, component: &Component, data: Option<&Path>, public: Option<&Path>) -> Result<(Instance, Scratch)> {
|
||||
let dir = broker.world.scratch_dir().join(format!("{id}-{}-{}", world::now(), rand_suffix()));
|
||||
let scratch = Scratch { dir: dir.clone() };
|
||||
std::fs::create_dir_all(dir.join("data"))?;
|
||||
std::fs::create_dir_all(dir.join("public"))?;
|
||||
if let Some(d) = data.filter(|d| d.is_dir()) {
|
||||
world::copy_tree(d, &dir.join("data"))?;
|
||||
}
|
||||
if let Some(p) = public.filter(|p| p.is_dir()) {
|
||||
world::copy_tree(p, &dir.join("public"))?;
|
||||
}
|
||||
let cfg = InstanceConfig {
|
||||
mounts: vec![
|
||||
Mount { guest: "/actor".into(), host: broker.world.actors_root(), writable: false },
|
||||
Mount { guest: "/public".into(), host: dir.join("public"), writable: true },
|
||||
Mount { guest: "/data".into(), host: dir.join("data"), writable: true },
|
||||
],
|
||||
allow_http: false,
|
||||
stderr_log: dir.join("stderr.log"),
|
||||
env: vec![("SAUSAGE_ACTOR".into(), id.to_string()), ("SAUSAGE_CANDIDATE".into(), "1".into())],
|
||||
};
|
||||
Ok((broker.runtime.start(component, cfg)?, scratch))
|
||||
}
|
||||
|
||||
fn rand_suffix() -> u32 {
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
|
||||
h.write_u64(std::process::id() as u64);
|
||||
h.finish() as u32
|
||||
}
|
||||
|
||||
async fn run_file(
|
||||
broker: &Broker,
|
||||
id: &str,
|
||||
component: &Component,
|
||||
data: Option<&Path>,
|
||||
public: Option<&Path>,
|
||||
file: &Path,
|
||||
) -> Result<()> {
|
||||
let test: Value = serde_json::from_str(&std::fs::read_to_string(file)?).map_err(|e| anyhow!("invalid JSON: {e}"))?;
|
||||
let steps = test["steps"].as_array().ok_or_else(|| anyhow!("missing \"steps\" list"))?;
|
||||
let (mut inst, scratch) = start(broker, id, component, data, public)?;
|
||||
let stderr = scratch.dir.join("stderr.log");
|
||||
for (n, step) in steps.iter().enumerate() {
|
||||
let n = n + 1;
|
||||
if let Some(body) = step.get("send") {
|
||||
let from = step["from"].as_str().unwrap_or("test");
|
||||
let env = json!({"id": format!("test/{n}"), "from": from, "body": body});
|
||||
inst.send_line(&env.to_string())
|
||||
.await
|
||||
.map_err(|e| anyhow!("step {n}: candidate does not read stdin: {e}"))?;
|
||||
}
|
||||
let expected = match step.get("expect") {
|
||||
None => vec![],
|
||||
Some(Value::Array(list)) => list.clone(),
|
||||
Some(one) => vec![one.clone()],
|
||||
};
|
||||
for expected in expected {
|
||||
let line = match tokio::time::timeout(EXPECT_TIMEOUT, inst.stdout.recv()).await {
|
||||
Err(_) => bail!("step {n}: no message within {EXPECT_TIMEOUT:?}; expected {expected}"),
|
||||
Ok(None) => {
|
||||
let exit = inst.wait().await;
|
||||
bail!("step {n}: candidate ended ({exit}); expected {expected}. stderr:\n{}", stderr_tail(&stderr))
|
||||
}
|
||||
Ok(Some(l)) => l,
|
||||
};
|
||||
let msg: Value = serde_json::from_str(&line)
|
||||
.map_err(|_| anyhow!("step {n}: candidate wrote a non-JSON line: {}", truncate(&line, 300)))?;
|
||||
if !subset_match(&expected, &msg["body"]) {
|
||||
bail!("step {n}: expected body {expected}, got {}", truncate(&msg["body"].to_string(), 1000));
|
||||
}
|
||||
if let Some(to) = step.get("to")
|
||||
&& msg.get("to") != Some(to)
|
||||
{
|
||||
bail!("step {n}: expected recipient {to}, got {}", msg.get("to").unwrap_or(&Value::Null));
|
||||
}
|
||||
}
|
||||
}
|
||||
inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! The native `cargo` actor: builds Rust source folders into `.wasm` files.
|
||||
//!
|
||||
//! Request: `{"type": "build", "source": "/actor/<id>/public/<folder>"}`
|
||||
//! Replies: `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", ...}`
|
||||
//! `{"type": "build_failed", "errors": "..."}`
|
||||
//!
|
||||
//! The broker knows nothing about Rust. This actor is one way to get a
|
||||
//! `.wasm` file. Other actors can add other languages.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, 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 crate::world::{self, World};
|
||||
|
||||
pub const ID: &str = "cargo";
|
||||
pub const TARGET: &str = "wasm32-wasip2";
|
||||
const BUILD_TIMEOUT: Duration = Duration::from_secs(900);
|
||||
|
||||
pub struct BuildOutput {
|
||||
/// Host path of the published `.wasm` file.
|
||||
pub wasm: PathBuf,
|
||||
pub seconds: f64,
|
||||
pub warnings: String,
|
||||
}
|
||||
|
||||
pub struct Cargo {
|
||||
world: World,
|
||||
/// One build at a time: all builds share one target folder.
|
||||
lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl Cargo {
|
||||
pub fn new(world: World) -> Self {
|
||||
Self { world, lock: Mutex::new(()) }
|
||||
}
|
||||
|
||||
/// Build the crate in `source` (a host path) and publish the `.wasm` file
|
||||
/// under `/actor/cargo/public/builds/<n>/`.
|
||||
pub async fn build(&self, source: &Path) -> Result<BuildOutput> {
|
||||
if !source.join("Cargo.toml").is_file() {
|
||||
bail!("{} has no Cargo.toml", source.display());
|
||||
}
|
||||
let _guard = self.lock.lock().await;
|
||||
let started = Instant::now();
|
||||
let data = self.world.data_dir(ID);
|
||||
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"))?;
|
||||
}
|
||||
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.arg("build")
|
||||
.arg("--release")
|
||||
.arg("--target")
|
||||
.arg(TARGET)
|
||||
.arg("--message-format=json-render-diagnostics")
|
||||
.arg("--target-dir")
|
||||
.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 stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
if !out.status.success() {
|
||||
bail!("{}", truncate(&strip_progress(&stderr), 20_000));
|
||||
}
|
||||
let mut artifact = None;
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
if let Ok(v) = serde_json::from_str::<Value>(line)
|
||||
&& v["reason"] == "compiler-artifact"
|
||||
&& let Some(exe) = v["executable"].as_str()
|
||||
{
|
||||
artifact = Some(PathBuf::from(exe));
|
||||
}
|
||||
}
|
||||
let artifact = artifact.ok_or_else(|| anyhow!("cargo built no binary. Does the crate have a src/main.rs?"))?;
|
||||
|
||||
let n = next_build_number(&self.world.public_dir(ID).join("builds"))?;
|
||||
let dest_dir = self.world.public_dir(ID).join("builds").join(n.to_string());
|
||||
let name = artifact.file_name().context("artifact has no file name")?;
|
||||
let dest = dest_dir.join(name);
|
||||
world::write_atomic(&dest, &std::fs::read(&artifact)?)?;
|
||||
Ok(BuildOutput { wasm: dest, seconds: started.elapsed().as_secs_f64(), warnings: strip_progress(&stderr) })
|
||||
}
|
||||
|
||||
pub async fn run_actor(self: Arc<Self>, broker: Arc<Broker>, mut mailbox: mpsc::Receiver<Envelope>) {
|
||||
while let Some(env) = mailbox.recv().await {
|
||||
let this = self.clone();
|
||||
let broker = broker.clone();
|
||||
tokio::spawn(async move {
|
||||
let body = this.handle(&broker, &env).await.unwrap_or_else(|e| {
|
||||
json!({"type": "build_failed", "source": env.body.get("source"), "errors": format!("{e:#}")})
|
||||
});
|
||||
broker.send(ID, Some(&env.from), None, Some(&env.id), body);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(&self, broker: &Broker, env: &Envelope) -> Result<Value> {
|
||||
match env.body.get("type").and_then(Value::as_str) {
|
||||
Some("build") => {
|
||||
let source_s = env.body.get("source").and_then(Value::as_str).ok_or_else(|| anyhow!("missing \"source\""))?;
|
||||
let source = broker.world.resolve(&env.from, source_s)?;
|
||||
let out = self.build(&source).await?;
|
||||
let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string());
|
||||
Ok(json!({
|
||||
"type": "built",
|
||||
"wasm": wasm,
|
||||
"source": source_s,
|
||||
"built_by": ID,
|
||||
"seconds": (out.seconds * 10.0).round() / 10.0,
|
||||
"warnings": truncate(&out.warnings, 4000),
|
||||
}))
|
||||
}
|
||||
Some("shutdown") => Ok(json!({"type": "ignored"})),
|
||||
other => bail!("unknown request {other:?}. Known: build"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn next_build_number(dir: &Path) -> Result<u64> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let max = std::fs::read_dir(dir)?
|
||||
.filter_map(|e| e.ok()?.file_name().to_str()?.parse::<u64>().ok())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
Ok(max + 1)
|
||||
}
|
||||
|
||||
/// Remove cargo's "Compiling ..." progress lines. Keep diagnostics.
|
||||
fn strip_progress(stderr: &str) -> String {
|
||||
stderr
|
||||
.lines()
|
||||
.filter(|l| {
|
||||
let t = l.trim_start();
|
||||
!(t.starts_with("Compiling ")
|
||||
|| t.starts_with("Downloaded ")
|
||||
|| t.starts_with("Downloading ")
|
||||
|| t.starts_with("Updating ")
|
||||
|| t.starts_with("Locking ")
|
||||
|| t.starts_with("Adding ")
|
||||
|| t.starts_with("Blocking "))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn fxhash(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
for b in bytes {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! The native `console` actor: your terminal.
|
||||
//!
|
||||
//! - `@<actor> <json>` sends a JSON body. `@<actor> <text>` sends
|
||||
//! `{"type": "text", "text": ...}`.
|
||||
//! - A line without `@` goes to `agent` as text.
|
||||
//! - `/deploy <actor> <folder>` builds a Rust folder with `cargo`, then spawns
|
||||
//! or upgrades the actor.
|
||||
//! - `/list`, `/sleep <seconds>`, `/help`, `/quit`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use crate::broker::{Broker, Envelope};
|
||||
|
||||
pub const ID: &str = "console";
|
||||
|
||||
const HELP: &str = "\
|
||||
@<actor> <json> send a JSON body to an actor
|
||||
@<actor> <text> send {\"type\":\"text\",\"text\":...}
|
||||
<text> send text to the agent
|
||||
/deploy <actor> <dir> build a Rust folder with cargo, then spawn or upgrade
|
||||
/list list actors
|
||||
/sleep <seconds> wait (for scripts)
|
||||
/await <actor> <secs> wait for the next message from an actor (for scripts)
|
||||
/quit stop all actors and exit";
|
||||
|
||||
pub struct Console {
|
||||
broker: Arc<Broker>,
|
||||
waiters: Mutex<HashMap<String, oneshot::Sender<Envelope>>>,
|
||||
seq: AtomicU64,
|
||||
/// Number of messages received per sender, for `/await`.
|
||||
seen: Mutex<HashMap<String, u64>>,
|
||||
arrived: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
fn show(env: &Envelope) {
|
||||
let reply = if env.reply_to.is_some() { " ↩" } else { "" };
|
||||
let b = &env.body;
|
||||
if b["type"] == "text"
|
||||
&& let Some(t) = b["text"].as_str()
|
||||
{
|
||||
println!("[{}{reply}] {t}", env.from);
|
||||
return;
|
||||
}
|
||||
let mut compact = b.clone();
|
||||
let mut extra = Vec::new();
|
||||
if let Some(obj) = compact.as_object_mut() {
|
||||
for key in ["errors", "warnings", "reason"] {
|
||||
if let Some(Value::String(s)) = obj.get(key)
|
||||
&& s.contains('\n')
|
||||
{
|
||||
extra.push((key, s.clone()));
|
||||
obj.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("[{}{reply}] {compact}", env.from);
|
||||
for (k, s) in extra {
|
||||
println!(" {k}:\n{}", s.lines().map(|l| format!(" {l}")).collect::<Vec<_>>().join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
impl Console {
|
||||
pub fn new(broker: Arc<Broker>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
broker,
|
||||
waiters: Mutex::new(HashMap::new()),
|
||||
seq: AtomicU64::new(1),
|
||||
seen: Mutex::new(HashMap::new()),
|
||||
arrived: tokio::sync::Notify::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn run_mailbox(self: Arc<Self>, mut mailbox: mpsc::Receiver<Envelope>) {
|
||||
while let Some(env) = mailbox.recv().await {
|
||||
show(&env);
|
||||
*self.seen.lock().unwrap().entry(env.from.clone()).or_default() += 1;
|
||||
self.arrived.notify_waiters();
|
||||
if let Some(r) = &env.reply_to
|
||||
&& let Some(tx) = self.waiters.lock().unwrap().remove(r)
|
||||
{
|
||||
let _ = tx.send(env);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send(&self, to: &str, body: Value) -> String {
|
||||
let own = format!("c{}", self.seq.fetch_add(1, Ordering::Relaxed));
|
||||
self.broker.send(ID, Some(to), Some(&own), None, body)
|
||||
}
|
||||
|
||||
async fn request(&self, to: &str, body: Value, timeout: Duration) -> Result<Envelope> {
|
||||
let own = format!("c{}", self.seq.fetch_add(1, Ordering::Relaxed));
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.waiters.lock().unwrap().insert(format!("{ID}/{own}"), tx);
|
||||
self.broker.send(ID, Some(to), Some(&own), None, body);
|
||||
tokio::time::timeout(timeout, rx)
|
||||
.await
|
||||
.map_err(|_| anyhow!("no reply from {to} within {timeout:?}"))?
|
||||
.map_err(|_| anyhow!("reply channel closed"))
|
||||
}
|
||||
|
||||
async fn deploy(&self, actor: &str, dir: &str) -> Result<()> {
|
||||
let path = std::path::absolute(dir)?;
|
||||
let source = path.display().to_string();
|
||||
println!("· building {source} …");
|
||||
let built = self.request(crate::cargo_actor::ID, json!({"type": "build", "source": source}), Duration::from_secs(900)).await?;
|
||||
if built.body["type"] != "built" {
|
||||
bail!("build failed");
|
||||
}
|
||||
let exists = self.broker.registry().actors.contains_key(actor);
|
||||
let ty = if exists { "upgrade" } else { "spawn" };
|
||||
println!("· {ty} {actor} …");
|
||||
let body = json!({
|
||||
"type": ty,
|
||||
"actor": actor,
|
||||
"wasm": built.body["wasm"],
|
||||
"source": source,
|
||||
"built_by": built.body["built_by"],
|
||||
});
|
||||
let r = self.request("broker", body, Duration::from_secs(300)).await?;
|
||||
if r.body["type"] == "error" {
|
||||
bail!("{ty} failed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read commands until EOF or `/quit`.
|
||||
pub async fn run_input(self: Arc<Self>) {
|
||||
println!("Sausage console. Type /help for commands.");
|
||||
let mut lines = BufReader::new(tokio::io::stdin()).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = self.command(line).await {
|
||||
if e.to_string() == "quit" {
|
||||
break;
|
||||
}
|
||||
println!("! {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn command(&self, line: &str) -> Result<()> {
|
||||
if let Some(rest) = line.strip_prefix('@') {
|
||||
let (to, text) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
|
||||
let text = text.trim();
|
||||
let body = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v @ Value::Object(_)) => v,
|
||||
_ => json!({"type": "text", "text": text}),
|
||||
};
|
||||
self.send(to, body);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(cmd) = line.strip_prefix('/') {
|
||||
let mut parts = cmd.split_whitespace();
|
||||
match parts.next() {
|
||||
Some("help") => println!("{HELP}"),
|
||||
Some("quit") => bail!("quit"),
|
||||
Some("list") => {
|
||||
self.send("broker", json!({"type": "list"}));
|
||||
}
|
||||
Some("sleep") => {
|
||||
let secs: f64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(1.0);
|
||||
tokio::time::sleep(Duration::from_secs_f64(secs)).await;
|
||||
}
|
||||
Some("await") => {
|
||||
let from = parts.next().unwrap_or("agent").to_string();
|
||||
let secs: f64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(60.0);
|
||||
let count = |c: &Self| c.seen.lock().unwrap().get(&from).copied().unwrap_or(0);
|
||||
let before = count(self);
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs_f64(secs);
|
||||
loop {
|
||||
let notified = self.arrived.notified();
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
if count(self) != before {
|
||||
break;
|
||||
}
|
||||
if tokio::time::timeout_at(deadline, notified).await.is_err() {
|
||||
bail!("no message from {from} within {secs} s");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("deploy") => {
|
||||
let (Some(actor), Some(dir)) = (parts.next(), parts.next()) else {
|
||||
bail!("usage: /deploy <actor> <dir>");
|
||||
};
|
||||
self.deploy(actor, dir).await?;
|
||||
}
|
||||
_ => bail!("unknown command. Type /help."),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
self.send("agent", json!({"type": "text", "text": line}));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
//! Filesystem layer with commit-on-close semantics.
|
||||
//!
|
||||
//! The layer wraps the wasmtime-wasi `wasi:filesystem` implementation. In a
|
||||
//! writable mount, an open that creates or truncates a file goes to a hidden
|
||||
//! temporary file next to the target. When the guest drops the descriptor
|
||||
//! (close), the layer renames the temporary file into place. Until then, no
|
||||
//! other reader sees the change. If the instance traps or is killed, the
|
||||
//! changes are lost.
|
||||
//!
|
||||
//! An open of an existing file without truncate (append, in-place edit)
|
||||
//! writes in place, so that appending to a log does not copy the log.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use wasmtime::component::{HasData, Resource};
|
||||
use wasmtime_wasi::filesystem::{Descriptor, WasiFilesystemCtxView};
|
||||
use wasmtime_wasi::p2::bindings::filesystem::types::{
|
||||
self, ErrorCode, HostDescriptor, HostDirectoryEntryStream,
|
||||
};
|
||||
use wasmtime_wasi::p2::bindings::filesystem::preopens;
|
||||
use wasmtime_wasi::p2::{DynInputStream, DynOutputStream, FsError, FsResult};
|
||||
|
||||
/// Name prefix of uncommitted files. The layer hides these names from guests.
|
||||
pub const TMP_PREFIX: &str = ".sausage-tmp-";
|
||||
|
||||
static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Mount {
|
||||
pub guest: String,
|
||||
pub host: PathBuf,
|
||||
pub writable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DirInfo {
|
||||
host: PathBuf,
|
||||
root: PathBuf,
|
||||
commit: bool,
|
||||
}
|
||||
|
||||
struct Pending {
|
||||
tmp: PathBuf,
|
||||
target: PathBuf,
|
||||
}
|
||||
|
||||
/// Per-instance state of the layer.
|
||||
pub struct FsExt {
|
||||
mounts: Vec<Mount>,
|
||||
dirs: HashMap<u32, DirInfo>,
|
||||
pending: HashMap<u32, Pending>,
|
||||
}
|
||||
|
||||
impl FsExt {
|
||||
pub fn new(mounts: Vec<Mount>) -> Self {
|
||||
Self { mounts, dirs: HashMap::new(), pending: HashMap::new() }
|
||||
}
|
||||
|
||||
/// Commit every open write. Use this when the guest exits normally,
|
||||
/// because process exit closes all files.
|
||||
pub fn commit_all(&mut self) {
|
||||
for (_, p) in self.pending.drain() {
|
||||
commit(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FsExt {
|
||||
/// Discard writes that were not committed (trap, kill).
|
||||
fn drop(&mut self) {
|
||||
for (_, p) in self.pending.drain() {
|
||||
let _ = std::fs::remove_file(&p.tmp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn commit(p: Pending) {
|
||||
if let Err(e) = std::fs::rename(&p.tmp, &p.target) {
|
||||
eprintln!("sausage: commit of {} failed: {e}", p.target.display());
|
||||
let _ = std::fs::remove_file(&p.tmp);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_tmp_name(path: &str) -> bool {
|
||||
path.rsplit('/').next().is_some_and(|n| n.starts_with(TMP_PREFIX))
|
||||
}
|
||||
|
||||
/// Join `rel` to the directory `dir` lexically. Return `None` if the result
|
||||
/// leaves the mount root.
|
||||
fn resolve(dir: &DirInfo, rel: &str) -> Option<PathBuf> {
|
||||
let mut out = dir.host.clone();
|
||||
for c in Path::new(rel).components() {
|
||||
match c {
|
||||
Component::Normal(n) => out.push(n),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if !out.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Component::RootDir | Component::Prefix(_) => return None,
|
||||
}
|
||||
}
|
||||
out.starts_with(&dir.root).then_some(out)
|
||||
}
|
||||
|
||||
pub struct ActorFs<'a> {
|
||||
pub inner: WasiFilesystemCtxView<'a>,
|
||||
pub ext: &'a mut FsExt,
|
||||
}
|
||||
|
||||
pub struct ActorFsData;
|
||||
|
||||
impl HasData for ActorFsData {
|
||||
type Data<'a> = ActorFs<'a>;
|
||||
}
|
||||
|
||||
impl preopens::Host for ActorFs<'_> {
|
||||
fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<Descriptor>, String)>> {
|
||||
let dirs = preopens::Host::get_directories(&mut self.inner)?;
|
||||
for (r, name) in &dirs {
|
||||
if let Some(m) = self.ext.mounts.iter().find(|m| &m.guest == name) {
|
||||
self.ext.dirs.insert(
|
||||
r.rep(),
|
||||
DirInfo { host: m.host.clone(), root: m.host.clone(), commit: m.writable },
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(dirs)
|
||||
}
|
||||
}
|
||||
|
||||
impl types::Host for ActorFs<'_> {
|
||||
fn convert_error_code(&mut self, err: FsError) -> wasmtime::Result<ErrorCode> {
|
||||
types::Host::convert_error_code(&mut self.inner, err)
|
||||
}
|
||||
|
||||
fn filesystem_error_code(
|
||||
&mut self,
|
||||
err: Resource<wasmtime::Error>,
|
||||
) -> wasmtime::Result<Option<ErrorCode>> {
|
||||
types::Host::filesystem_error_code(&mut self.inner, err)
|
||||
}
|
||||
}
|
||||
|
||||
impl HostDescriptor for ActorFs<'_> {
|
||||
async fn advise(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
offset: types::Filesize,
|
||||
len: types::Filesize,
|
||||
advice: types::Advice,
|
||||
) -> FsResult<()> {
|
||||
HostDescriptor::advise(&mut self.inner, fd, offset, len, advice).await
|
||||
}
|
||||
|
||||
async fn sync_data(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
|
||||
HostDescriptor::sync_data(&mut self.inner, fd).await
|
||||
}
|
||||
|
||||
async fn get_flags(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorFlags> {
|
||||
HostDescriptor::get_flags(&mut self.inner, fd).await
|
||||
}
|
||||
|
||||
async fn get_type(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorType> {
|
||||
HostDescriptor::get_type(&mut self.inner, fd).await
|
||||
}
|
||||
|
||||
async fn set_size(&mut self, fd: Resource<types::Descriptor>, size: types::Filesize) -> FsResult<()> {
|
||||
HostDescriptor::set_size(&mut self.inner, fd, size).await
|
||||
}
|
||||
|
||||
async fn set_times(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
atim: types::NewTimestamp,
|
||||
mtim: types::NewTimestamp,
|
||||
) -> FsResult<()> {
|
||||
HostDescriptor::set_times(&mut self.inner, fd, atim, mtim).await
|
||||
}
|
||||
|
||||
async fn read(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
len: types::Filesize,
|
||||
offset: types::Filesize,
|
||||
) -> FsResult<(Vec<u8>, bool)> {
|
||||
HostDescriptor::read(&mut self.inner, fd, len, offset).await
|
||||
}
|
||||
|
||||
async fn write(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
buf: Vec<u8>,
|
||||
offset: types::Filesize,
|
||||
) -> FsResult<types::Filesize> {
|
||||
HostDescriptor::write(&mut self.inner, fd, buf, offset).await
|
||||
}
|
||||
|
||||
async fn read_directory(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
) -> FsResult<Resource<types::DirectoryEntryStream>> {
|
||||
HostDescriptor::read_directory(&mut self.inner, fd).await
|
||||
}
|
||||
|
||||
async fn sync(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
|
||||
HostDescriptor::sync(&mut self.inner, fd).await
|
||||
}
|
||||
|
||||
async fn create_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
||||
HostDescriptor::create_directory_at(&mut self.inner, fd, path).await
|
||||
}
|
||||
|
||||
async fn stat(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorStat> {
|
||||
HostDescriptor::stat(&mut self.inner, fd).await
|
||||
}
|
||||
|
||||
async fn stat_at(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
path_flags: types::PathFlags,
|
||||
path: String,
|
||||
) -> FsResult<types::DescriptorStat> {
|
||||
if is_tmp_name(&path) {
|
||||
return Err(ErrorCode::NoEntry.into());
|
||||
}
|
||||
HostDescriptor::stat_at(&mut self.inner, fd, path_flags, path).await
|
||||
}
|
||||
|
||||
async fn set_times_at(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
path_flags: types::PathFlags,
|
||||
path: String,
|
||||
atim: types::NewTimestamp,
|
||||
mtim: types::NewTimestamp,
|
||||
) -> FsResult<()> {
|
||||
HostDescriptor::set_times_at(&mut self.inner, fd, path_flags, path, atim, mtim).await
|
||||
}
|
||||
|
||||
async fn link_at(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
old_path_flags: types::PathFlags,
|
||||
old_path: String,
|
||||
new_descriptor: Resource<types::Descriptor>,
|
||||
new_path: String,
|
||||
) -> FsResult<()> {
|
||||
HostDescriptor::link_at(&mut self.inner, fd, old_path_flags, old_path, new_descriptor, new_path).await
|
||||
}
|
||||
|
||||
async fn open_at(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
path_flags: types::PathFlags,
|
||||
path: String,
|
||||
oflags: types::OpenFlags,
|
||||
flags: types::DescriptorFlags,
|
||||
) -> FsResult<Resource<types::Descriptor>> {
|
||||
if is_tmp_name(&path) {
|
||||
return Err(ErrorCode::NoEntry.into());
|
||||
}
|
||||
let parent = self.ext.dirs.get(&fd.rep()).cloned();
|
||||
|
||||
// Commit-on-close: a new or truncated file goes to a hidden temporary file.
|
||||
if let Some(dir) = parent.as_ref().filter(|d| d.commit)
|
||||
&& flags.contains(types::DescriptorFlags::WRITE)
|
||||
&& !oflags.contains(types::OpenFlags::DIRECTORY)
|
||||
&& let Some(target) = resolve(dir, &path)
|
||||
{
|
||||
let meta = std::fs::metadata(&target).ok();
|
||||
let new_file = meta.is_none() && oflags.contains(types::OpenFlags::CREATE);
|
||||
let truncated = meta.as_ref().is_some_and(|m| m.is_file())
|
||||
&& oflags.contains(types::OpenFlags::TRUNCATE)
|
||||
&& !oflags.contains(types::OpenFlags::EXCLUSIVE);
|
||||
if new_file || truncated {
|
||||
let n = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let tmp_name = format!("{TMP_PREFIX}{}-{n}", std::process::id());
|
||||
let tmp_rel = match path.rsplit_once('/') {
|
||||
Some((d, _)) => format!("{d}/{tmp_name}"),
|
||||
None => tmp_name.clone(),
|
||||
};
|
||||
let tmp = target.with_file_name(&tmp_name);
|
||||
let r = HostDescriptor::open_at(&mut self.inner, fd, path_flags, tmp_rel, oflags, flags).await?;
|
||||
self.ext.pending.insert(r.rep(), Pending { tmp, target });
|
||||
return Ok(r);
|
||||
}
|
||||
}
|
||||
|
||||
let r = HostDescriptor::open_at(&mut self.inner, fd, path_flags, path.clone(), oflags, flags).await?;
|
||||
if let Some(dir) = parent
|
||||
&& matches!(self.inner.table.get(&r)?, Descriptor::Dir(_))
|
||||
&& let Some(host) = resolve(&dir, &path)
|
||||
{
|
||||
self.ext.dirs.insert(r.rep(), DirInfo { host, root: dir.root, commit: dir.commit });
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn drop(&mut self, fd: Resource<types::Descriptor>) -> wasmtime::Result<()> {
|
||||
let rep = fd.rep();
|
||||
HostDescriptor::drop(&mut self.inner, fd)?;
|
||||
self.ext.dirs.remove(&rep);
|
||||
if let Some(p) = self.ext.pending.remove(&rep) {
|
||||
commit(p);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn readlink_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<String> {
|
||||
HostDescriptor::readlink_at(&mut self.inner, fd, path).await
|
||||
}
|
||||
|
||||
async fn remove_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
||||
HostDescriptor::remove_directory_at(&mut self.inner, fd, path).await
|
||||
}
|
||||
|
||||
async fn rename_at(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
old_path: String,
|
||||
new_fd: Resource<types::Descriptor>,
|
||||
new_path: String,
|
||||
) -> FsResult<()> {
|
||||
if is_tmp_name(&old_path) || is_tmp_name(&new_path) {
|
||||
return Err(ErrorCode::NoEntry.into());
|
||||
}
|
||||
HostDescriptor::rename_at(&mut self.inner, fd, old_path, new_fd, new_path).await
|
||||
}
|
||||
|
||||
async fn symlink_at(
|
||||
&mut self,
|
||||
_fd: Resource<types::Descriptor>,
|
||||
_src_path: String,
|
||||
_dest_path: String,
|
||||
) -> FsResult<()> {
|
||||
// Symlinks would break the lexical path tracking of commit-on-close.
|
||||
Err(ErrorCode::NotPermitted.into())
|
||||
}
|
||||
|
||||
async fn unlink_file_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
||||
if is_tmp_name(&path) {
|
||||
return Err(ErrorCode::NoEntry.into());
|
||||
}
|
||||
HostDescriptor::unlink_file_at(&mut self.inner, fd, path).await
|
||||
}
|
||||
|
||||
fn read_via_stream(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
offset: types::Filesize,
|
||||
) -> FsResult<Resource<DynInputStream>> {
|
||||
HostDescriptor::read_via_stream(&mut self.inner, fd, offset)
|
||||
}
|
||||
|
||||
fn write_via_stream(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
offset: types::Filesize,
|
||||
) -> FsResult<Resource<DynOutputStream>> {
|
||||
HostDescriptor::write_via_stream(&mut self.inner, fd, offset)
|
||||
}
|
||||
|
||||
fn append_via_stream(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Resource<DynOutputStream>> {
|
||||
HostDescriptor::append_via_stream(&mut self.inner, fd)
|
||||
}
|
||||
|
||||
async fn is_same_object(
|
||||
&mut self,
|
||||
a: Resource<types::Descriptor>,
|
||||
b: Resource<types::Descriptor>,
|
||||
) -> wasmtime::Result<bool> {
|
||||
HostDescriptor::is_same_object(&mut self.inner, a, b).await
|
||||
}
|
||||
|
||||
async fn metadata_hash(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::MetadataHashValue> {
|
||||
HostDescriptor::metadata_hash(&mut self.inner, fd).await
|
||||
}
|
||||
|
||||
async fn metadata_hash_at(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
path_flags: types::PathFlags,
|
||||
path: String,
|
||||
) -> FsResult<types::MetadataHashValue> {
|
||||
HostDescriptor::metadata_hash_at(&mut self.inner, fd, path_flags, path).await
|
||||
}
|
||||
}
|
||||
|
||||
impl HostDirectoryEntryStream for ActorFs<'_> {
|
||||
async fn read_directory_entry(
|
||||
&mut self,
|
||||
stream: Resource<types::DirectoryEntryStream>,
|
||||
) -> FsResult<Option<types::DirectoryEntry>> {
|
||||
let rep = stream.rep();
|
||||
loop {
|
||||
let entry = HostDirectoryEntryStream::read_directory_entry(
|
||||
&mut self.inner,
|
||||
Resource::new_borrow(rep),
|
||||
)
|
||||
.await?;
|
||||
match entry {
|
||||
Some(e) if e.name.starts_with(TMP_PREFIX) => continue,
|
||||
other => return Ok(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drop(&mut self, stream: Resource<types::DirectoryEntryStream>) -> wasmtime::Result<()> {
|
||||
HostDirectoryEntryStream::drop(&mut self.inner, stream)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Sausage: grow a system of WASI actors by talking to it.
|
||||
//!
|
||||
//! The host runs the broker, the native actors (`broker`, `cargo`, and the
|
||||
//! console), and one supervisor per WASM actor. See `PROTOCOL.md`.
|
||||
|
||||
pub mod broker;
|
||||
pub mod candidate;
|
||||
pub mod cargo_actor;
|
||||
pub mod console;
|
||||
pub mod fs;
|
||||
pub mod runtime;
|
||||
pub mod world;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub use broker::{Broker, Envelope};
|
||||
pub use world::World;
|
||||
|
||||
/// A running world.
|
||||
pub struct Host {
|
||||
pub world: World,
|
||||
pub broker: Arc<Broker>,
|
||||
_lock: std::fs::File,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Start the broker, `cargo`, and every registered actor of the world at
|
||||
/// `root`. Only one host can run a world at a time.
|
||||
pub async fn start(root: &Path) -> Result<Host> {
|
||||
let world = World::new(std::path::absolute(root)?);
|
||||
if !world.exists() {
|
||||
bail!("{} is not a Sausage world. Create one with: sausage init", root.display());
|
||||
}
|
||||
let lock = std::fs::File::create(world.root.join(".lock"))?;
|
||||
if lock.try_lock().is_err() {
|
||||
bail!("another sausage process runs {}", root.display());
|
||||
}
|
||||
let broker = Broker::new(world.clone(), runtime::Runtime::new()?)?;
|
||||
|
||||
let rx = broker.open_mailbox("broker");
|
||||
tokio::spawn(broker.clone().run_actor(rx));
|
||||
let rx = broker.open_mailbox(cargo_actor::ID);
|
||||
tokio::spawn(Arc::new(cargo_actor::Cargo::new(world.clone())).run_actor(broker.clone(), rx));
|
||||
|
||||
broker.start_all();
|
||||
Ok(Host { world, broker, _lock: lock })
|
||||
}
|
||||
|
||||
/// Register a native actor with the ID `id` and return its mailbox.
|
||||
pub fn mailbox(&self, id: &str) -> mpsc::Receiver<Envelope> {
|
||||
self.broker.open_mailbox(id)
|
||||
}
|
||||
|
||||
/// Stop every actor gracefully.
|
||||
pub async fn stop(self) {
|
||||
self.broker.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::{Parser, Subcommand};
|
||||
use include_dir::{Dir, include_dir};
|
||||
use serde_json::json;
|
||||
|
||||
use sausage::world::{self, VersionMeta, World};
|
||||
use sausage::{Broker, Host, cargo_actor, console, runtime};
|
||||
|
||||
/// The source of the agent actor. `init` builds it like any other actor.
|
||||
static AGENT_SOURCE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent");
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Create a new world and build the agent actor.
|
||||
///
|
||||
/// Reads SAUSAGE_API, SAUSAGE_BASE_URL, SAUSAGE_MODEL and
|
||||
/// SAUSAGE_API_TOKEN, and asks for every value that is missing.
|
||||
Init {
|
||||
/// Folder of the new world.
|
||||
#[arg(default_value = "world")]
|
||||
dir: PathBuf,
|
||||
},
|
||||
/// Start the broker, all actors, and the console.
|
||||
Run {
|
||||
#[arg(default_value = "world")]
|
||||
dir: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
/// The value of `var`, or the answer to `question`.
|
||||
fn setting(var: &str, question: &str, default: Option<&str>) -> Result<String> {
|
||||
if let Ok(v) = std::env::var(var)
|
||||
&& !v.is_empty()
|
||||
{
|
||||
return Ok(v);
|
||||
}
|
||||
match default {
|
||||
Some(d) => print!("{question} [{d}]: "),
|
||||
None => print!("{question}: "),
|
||||
}
|
||||
std::io::stdout().flush()?;
|
||||
let mut line = String::new();
|
||||
std::io::stdin().read_line(&mut line)?;
|
||||
match (line.trim(), default) {
|
||||
("", Some(d)) => Ok(d.to_string()),
|
||||
("", None) => bail!("{question} is required (or set {var})"),
|
||||
(v, _) => Ok(v.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn init(dir: PathBuf) -> Result<()> {
|
||||
let api = setting("SAUSAGE_API", "API shape (openai-responses or anthropic-messages)", Some("openai-responses"))?;
|
||||
if api != "openai-responses" && api != "anthropic-messages" {
|
||||
bail!("unknown API shape {api:?}");
|
||||
}
|
||||
let base_url = setting("SAUSAGE_BASE_URL", "API base URL", None)?;
|
||||
let model = setting("SAUSAGE_MODEL", "Model", None)?;
|
||||
let token = setting("SAUSAGE_API_TOKEN", "API token", None)?;
|
||||
|
||||
let world = World::create(&dir)?;
|
||||
let mut reg = world.load_registry()?;
|
||||
reg.subscriptions.entry("agent".into()).or_default().insert("console".into());
|
||||
world.save_registry(®)?;
|
||||
|
||||
// The agent's private configuration. Only the agent can read /data.
|
||||
world.ensure_actor_dirs("agent")?;
|
||||
let config = json!({"api": api, "base_url": base_url.trim_end_matches('/'), "model": model, "token": token});
|
||||
let config_path = world.data_dir("agent").join("config.json");
|
||||
std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
|
||||
let source = world.root.join("broker").join("seed").join("agent");
|
||||
std::fs::create_dir_all(&source)?;
|
||||
AGENT_SOURCE.extract(&source).context("extract agent source")?;
|
||||
println!("Building the agent actor (the first build takes a few minutes) …");
|
||||
let out = cargo_actor::Cargo::new(world.clone()).build(&source).await.context("build agent")?;
|
||||
println!("Built in {:.1} s.", out.seconds);
|
||||
|
||||
let broker = Broker::new(world.clone(), runtime::Runtime::new()?)?;
|
||||
let meta = VersionMeta {
|
||||
version: 1,
|
||||
wasm: world.global_path(&out.wasm).unwrap_or_default(),
|
||||
source: Some(source.display().to_string()),
|
||||
built_by: Some(cargo_actor::ID.into()),
|
||||
requested_by: "init".into(),
|
||||
created_at: world::now(),
|
||||
};
|
||||
broker.create_actor("agent", &out.wasm, Some(&source), meta, false).await?;
|
||||
println!("World ready. Start it with: sausage run {}", dir.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run(dir: PathBuf) -> Result<()> {
|
||||
let host = Host::start(&dir).await?;
|
||||
let rx = host.mailbox(console::ID);
|
||||
let console = console::Console::new(host.broker.clone());
|
||||
tokio::spawn(Arc::clone(&console).run_mailbox(rx));
|
||||
console.run_input().await;
|
||||
println!("Stopping actors …");
|
||||
host.stop().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
match Cli::parse().command {
|
||||
Cmd::Init { dir } => init(dir).await,
|
||||
Cmd::Run { dir } => run(dir).await,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Wasmtime engine and actor instances.
|
||||
//!
|
||||
//! An instance is one run of a `wasi:cli/command` component. The host writes
|
||||
//! NDJSON lines to its stdin and reads NDJSON lines from its stdout.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use wasmtime::component::{Component, Linker, ResourceTable};
|
||||
use wasmtime::{Config, Engine, Store};
|
||||
use wasmtime_wasi::cli::{AsyncStdinStream, AsyncStdoutStream};
|
||||
use wasmtime_wasi::p2::bindings::Command;
|
||||
use wasmtime_wasi::p2::bindings::filesystem::{preopens, types};
|
||||
use wasmtime_wasi::{FsPerms, I32Exit, WasiCtx, WasiCtxView, WasiView};
|
||||
use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView};
|
||||
|
||||
use crate::fs::{ActorFs, ActorFsData, FsExt, Mount};
|
||||
|
||||
pub struct WasiPart {
|
||||
ctx: WasiCtx,
|
||||
table: ResourceTable,
|
||||
}
|
||||
|
||||
impl WasiView for WasiPart {
|
||||
fn ctx(&mut self) -> WasiCtxView<'_> {
|
||||
WasiCtxView { ctx: &mut self.ctx, table: &mut self.table }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HttpHooks {
|
||||
allow: bool,
|
||||
}
|
||||
|
||||
impl WasiHttpHooks for HttpHooks {
|
||||
fn send_request(
|
||||
&mut self,
|
||||
request: http::Request<wasmtime_wasi_http::WasiBody>,
|
||||
options: Option<wasmtime_wasi_http::RequestOptions>,
|
||||
fut: Box<dyn Future<Output = Result<(), wasmtime_wasi_http::Error>> + Send>,
|
||||
) -> Box<
|
||||
dyn Future<
|
||||
Output = wasmtime_wasi_http::Result<(
|
||||
http::Response<wasmtime_wasi_http::WasiBody>,
|
||||
Box<dyn Future<Output = Result<(), wasmtime_wasi_http::Error>> + Send>,
|
||||
)>,
|
||||
> + Send,
|
||||
> {
|
||||
_ = fut;
|
||||
let allow = self.allow;
|
||||
Box::new(async move {
|
||||
use http_body_util::BodyExt;
|
||||
if !allow {
|
||||
return Err(wasmtime_wasi_http::Error::HttpRequestDenied);
|
||||
}
|
||||
let (res, io) = wasmtime_wasi_http::default_send_request(request, options).await?;
|
||||
Ok((
|
||||
res.map(BodyExt::boxed_unsync),
|
||||
Box::new(io) as Box<dyn Future<Output = _> + Send>,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ActorState {
|
||||
wasi: WasiPart,
|
||||
fsx: FsExt,
|
||||
http: WasiHttpCtx,
|
||||
hooks: HttpHooks,
|
||||
}
|
||||
|
||||
impl WasiView for ActorState {
|
||||
fn ctx(&mut self) -> WasiCtxView<'_> {
|
||||
self.wasi.ctx()
|
||||
}
|
||||
}
|
||||
|
||||
impl WasiHttpView for ActorState {
|
||||
fn http(&mut self) -> WasiHttpCtxView<'_> {
|
||||
WasiHttpCtxView { ctx: &mut self.http, table: &mut self.wasi.table, hooks: &mut self.hooks }
|
||||
}
|
||||
}
|
||||
|
||||
fn actor_fs(s: &mut ActorState) -> ActorFs<'_> {
|
||||
ActorFs {
|
||||
inner: wasmtime_wasi::filesystem::WasiFilesystemView::filesystem(&mut s.wasi),
|
||||
ext: &mut s.fsx,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Runtime {
|
||||
engine: Engine,
|
||||
linker: Linker<ActorState>,
|
||||
}
|
||||
|
||||
/// How an instance ended.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Exit {
|
||||
/// `main` returned or the guest called `exit(0)`.
|
||||
Clean,
|
||||
/// The guest exited with a nonzero code.
|
||||
Code(i32),
|
||||
/// The guest trapped (panic, invalid memory access, ...).
|
||||
Trap(String),
|
||||
}
|
||||
|
||||
impl Exit {
|
||||
pub fn is_clean(&self) -> bool {
|
||||
matches!(self, Exit::Clean)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Exit {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Exit::Clean => write!(f, "exit 0"),
|
||||
Exit::Code(c) => write!(f, "exit {c}"),
|
||||
Exit::Trap(t) => write!(f, "trap: {t}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InstanceConfig {
|
||||
pub mounts: Vec<Mount>,
|
||||
pub allow_http: bool,
|
||||
pub stderr_log: PathBuf,
|
||||
pub env: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
pub struct Instance {
|
||||
stdin: DuplexStream,
|
||||
/// Lines that the guest writes to stdout. Closed when the guest ends.
|
||||
pub stdout: mpsc::Receiver<String>,
|
||||
task: JoinHandle<Exit>,
|
||||
}
|
||||
|
||||
impl Instance {
|
||||
/// Write one line to the guest's stdin.
|
||||
pub async fn send_line(&mut self, line: &str) -> std::io::Result<()> {
|
||||
self.stdin.write_all(line.as_bytes()).await?;
|
||||
self.stdin.write_all(b"\n").await?;
|
||||
self.stdin.flush().await
|
||||
}
|
||||
|
||||
/// Wait until the guest ends.
|
||||
pub async fn wait(&mut self) -> Exit {
|
||||
match (&mut self.task).await {
|
||||
Ok(exit) => exit,
|
||||
Err(e) if e.is_cancelled() => Exit::Trap("killed".into()),
|
||||
Err(e) => Exit::Trap(format!("host task failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.task.is_finished()
|
||||
}
|
||||
|
||||
/// Send `shutdown`, close stdin, and wait. Kill the guest after `deadline`.
|
||||
pub async fn stop(mut self, shutdown_line: &str, deadline: Duration) -> Exit {
|
||||
let _ = self.send_line(shutdown_line).await;
|
||||
let _ = self.stdin.shutdown().await;
|
||||
drop(self.stdin);
|
||||
match tokio::time::timeout(deadline, &mut self.task).await {
|
||||
Ok(Ok(exit)) => exit,
|
||||
Ok(Err(e)) => Exit::Trap(format!("host task failed: {e}")),
|
||||
Err(_) => {
|
||||
self.task.abort();
|
||||
let _ = (&mut self.task).await;
|
||||
Exit::Trap("killed after shutdown deadline".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn log_note(path: &Path, text: &str) {
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(f, "[sausage] {text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The most useful line of a trap: the `wasm trap: ...` reason.
|
||||
fn short_trap(full: &str) -> String {
|
||||
let line = full
|
||||
.lines()
|
||||
.find_map(|l| l.find("wasm trap:").map(|i| l[i..].to_string()))
|
||||
.unwrap_or_else(|| full.lines().next().unwrap_or("trap").to_string());
|
||||
let mut line = line.trim().to_string();
|
||||
line.truncate(300);
|
||||
line
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
pub fn new() -> Result<Arc<Self>> {
|
||||
let mut config = Config::new();
|
||||
config.epoch_interruption(true);
|
||||
let engine = Engine::new(&config)?;
|
||||
|
||||
let mut linker: Linker<ActorState> = Linker::new(&engine);
|
||||
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
|
||||
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
|
||||
// Replace the filesystem with the commit-on-close layer.
|
||||
linker.allow_shadowing(true);
|
||||
types::add_to_linker::<ActorState, ActorFsData>(&mut linker, actor_fs)?;
|
||||
preopens::add_to_linker::<ActorState, ActorFsData>(&mut linker, actor_fs)?;
|
||||
linker.allow_shadowing(false);
|
||||
|
||||
// Time slices: every 10 ms each running guest yields to the scheduler.
|
||||
let ticker = engine.clone();
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
ticker.increment_epoch();
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Arc::new(Self { engine, linker }))
|
||||
}
|
||||
|
||||
pub fn load(&self, wasm: &Path) -> Result<Component> {
|
||||
Component::from_file(&self.engine, wasm)
|
||||
.map_err(anyhow::Error::from)
|
||||
.with_context(|| format!("load {}", wasm.display()))
|
||||
}
|
||||
|
||||
pub fn start(self: &Arc<Self>, component: &Component, cfg: InstanceConfig) -> Result<Instance> {
|
||||
let (host_in, guest_in) = tokio::io::duplex(64 * 1024);
|
||||
let (guest_out, host_out) = tokio::io::duplex(64 * 1024);
|
||||
|
||||
let stderr = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&cfg.stderr_log)
|
||||
.with_context(|| format!("open {}", cfg.stderr_log.display()))?;
|
||||
|
||||
let mut b = WasiCtx::builder();
|
||||
b.stdin(AsyncStdinStream::new(guest_in));
|
||||
b.stdout(AsyncStdoutStream::new(8192, guest_out));
|
||||
b.stderr(AsyncStdoutStream::new(8192, tokio::fs::File::from_std(stderr)));
|
||||
for (k, v) in &cfg.env {
|
||||
b.env(k, v);
|
||||
}
|
||||
for m in &cfg.mounts {
|
||||
let perms = if m.writable { FsPerms::ReadWrite } else { FsPerms::ReadOnly };
|
||||
b.preopened_dir(&m.host, &m.guest, perms)
|
||||
.map_err(anyhow::Error::from)
|
||||
.with_context(|| format!("mount {} at {}", m.host.display(), m.guest))?;
|
||||
}
|
||||
let state = ActorState {
|
||||
wasi: WasiPart { ctx: b.build(), table: ResourceTable::new() },
|
||||
fsx: FsExt::new(cfg.mounts),
|
||||
http: WasiHttpCtx::new(),
|
||||
hooks: HttpHooks { allow: cfg.allow_http },
|
||||
};
|
||||
|
||||
let (line_tx, line_rx) = mpsc::channel(1024);
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(host_out).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if line_tx.send(line).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let rt = self.clone();
|
||||
let component = component.clone();
|
||||
let stderr_log = cfg.stderr_log.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut store = Store::new(&rt.engine, state);
|
||||
store.epoch_deadline_async_yield_and_update(1);
|
||||
let result = async {
|
||||
let cmd = Command::instantiate_async(&mut store, &component, &rt.linker).await?;
|
||||
cmd.wasi_cli_run().call_run(&mut store).await
|
||||
}
|
||||
.await;
|
||||
let exit = match result {
|
||||
Ok(Ok(())) => Exit::Clean,
|
||||
Ok(Err(())) => Exit::Code(1),
|
||||
Err(e) => match e.downcast_ref::<I32Exit>() {
|
||||
Some(I32Exit(0)) => Exit::Clean,
|
||||
Some(I32Exit(c)) => Exit::Code(*c),
|
||||
None => {
|
||||
// Keep the full backtrace in the log, and a short reason in the status.
|
||||
let full = format!("{e:?}");
|
||||
log_note(&stderr_log, &format!("trap details:\n{full}"));
|
||||
Exit::Trap(short_trap(&full))
|
||||
}
|
||||
},
|
||||
};
|
||||
// Process exit closes every file, so a clean exit commits.
|
||||
if exit.is_clean() {
|
||||
store.data_mut().fsx.commit_all();
|
||||
}
|
||||
exit
|
||||
});
|
||||
|
||||
Ok(Instance { stdin: host_in, stdout: line_rx, task })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! On-disk layout of a Sausage workspace ("world").
|
||||
//!
|
||||
//! ```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
|
||||
//! ```
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{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,
|
||||
}
|
||||
|
||||
#[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,
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
#[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 spawn or upgrade.
|
||||
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 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 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.root.join("private").join(id).join("data")
|
||||
}
|
||||
pub fn versions_dir(&self, id: &str) -> PathBuf {
|
||||
self.root.join("broker").join("versions").join(id)
|
||||
}
|
||||
pub fn version_dir(&self, id: &str, v: u64) -> PathBuf {
|
||||
self.versions_dir(id).join(v.to_string())
|
||||
}
|
||||
pub fn scratch_dir(&self) -> PathBuf {
|
||||
self.root.join("broker").join("scratch")
|
||||
}
|
||||
|
||||
pub fn exists(&self) -> bool {
|
||||
self.registry_path().is_file()
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
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"});
|
||||
write_atomic(&world.actor_dir(id).join("status.json"), serde_json::to_string_pretty(&status)?.as_bytes())?;
|
||||
}
|
||||
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)?)
|
||||
}
|
||||
|
||||
pub fn save_registry(&self, reg: &Registry) -> Result<()> {
|
||||
write_atomic(&self.registry_path(), serde_json::to_string_pretty(reg)?.as_bytes())
|
||||
}
|
||||
|
||||
/// 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 clean = |base: PathBuf, rest: &str| -> Result<PathBuf> {
|
||||
let mut out = base.clone();
|
||||
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 => {}
|
||||
_ => bail!("path must not contain '..': {path}"),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
if let Some(rest) = path.strip_prefix("/actor/") {
|
||||
return clean(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) = path.strip_prefix("/data/").or(path.strip_prefix("/data").filter(|r| r.is_empty())) {
|
||||
return clean(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 tmp = dir.join(format!(
|
||||
"{TMP_PREFIX}host-{}-{}",
|
||||
std::process::id(),
|
||||
path.file_name().and_then(|n| n.to_str()).unwrap_or("file")
|
||||
));
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy a directory tree. Skips `target/` folders and uncommitted files.
|
||||
pub fn copy_tree(from: &Path, to: &Path) -> 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_str = name.to_string_lossy();
|
||||
if name_str.starts_with(TMP_PREFIX) {
|
||||
continue;
|
||||
}
|
||||
let ty = entry.file_type()?;
|
||||
let dst = to.join(&name);
|
||||
if ty.is_dir() {
|
||||
if name_str == "target" || name_str == ".git" {
|
||||
continue;
|
||||
}
|
||||
copy_tree(&entry.path(), &dst)?;
|
||||
} else if ty.is_file() {
|
||||
std::fs::copy(entry.path(), &dst)?;
|
||||
}
|
||||
}
|
||||
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.
|
||||
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");
|
||||
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_tree(src, &fresh)?;
|
||||
} 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(())
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "bench"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,45 @@
|
||||
//! {"type":"bench","target":"echo","n":1000}: ping-pong with the target, one
|
||||
//! message at a time, and report the round-trip times.
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, Write};
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
let mut out = std::io::stdout().lock();
|
||||
let mut lines = std::io::stdin().lock().lines();
|
||||
while let Some(Ok(line)) = lines.next() {
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
match msg["body"]["type"].as_str() {
|
||||
Some("shutdown") => break,
|
||||
Some("bench") => {
|
||||
let target = msg["body"]["target"].as_str().unwrap_or("echo").to_string();
|
||||
let n = msg["body"]["n"].as_u64().unwrap_or(1000);
|
||||
let mut times = Vec::new();
|
||||
let started = Instant::now();
|
||||
for i in 0..n {
|
||||
let t = Instant::now();
|
||||
writeln!(out, "{}", json!({"to": target, "id": i, "body": {"type": "ping", "i": i}})).unwrap();
|
||||
out.flush().unwrap();
|
||||
// Wait for the reply.
|
||||
loop {
|
||||
let Some(Ok(l)) = lines.next() else { return };
|
||||
let Ok(r) = serde_json::from_str::<Value>(&l) else { continue };
|
||||
if r["reply_to"].as_str().is_some_and(|s| s.ends_with(&format!("/{i}"))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
times.push(t.elapsed().as_micros() as u64);
|
||||
}
|
||||
let total = started.elapsed().as_secs_f64();
|
||||
times.sort();
|
||||
let p = |q: f64| times[((times.len() as f64 - 1.0) * q) as usize];
|
||||
let reply = json!({"type": "bench_result", "n": n, "total_s": total,
|
||||
"msgs_per_s": (n as f64 / total).round(),
|
||||
"p50_us": p(0.5), "p90_us": p(0.9), "p99_us": p(0.99), "max_us": p(1.0)});
|
||||
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
|
||||
out.flush().unwrap();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "crasher"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,6 @@
|
||||
//! A bad version: it starts, waits 3 seconds (long enough for the smoke
|
||||
//! test), then panics. Used to test the automatic rollback.
|
||||
fn main() {
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
panic!("this version is broken");
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "echo"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
fn main() {
|
||||
let mut out = std::io::stdout().lock();
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line.unwrap()) else { continue };
|
||||
if msg["body"]["type"] == "shutdown" {
|
||||
break;
|
||||
}
|
||||
let env = json!({"to": msg["from"], "reply_to": msg["id"], "body": {"type": "echo", "got": msg["body"]}});
|
||||
writeln!(out, "{env}").unwrap();
|
||||
out.flush().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "fetch"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
wstd = "0.6"
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Fetches URLs over HTTP(S) and writes the body to /public/fetch/<n>.
|
||||
//! Reads stdin and runs requests at the same time: while a fetch runs,
|
||||
//! `ping` still answers at once.
|
||||
use serde_json::{Value, json};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use wstd::http::{Body, Client, Request};
|
||||
use wstd::io::{AsyncRead, AsyncWrite};
|
||||
|
||||
type Out = Rc<RefCell<Vec<String>>>;
|
||||
|
||||
async fn flush(out: &Out) {
|
||||
let lines: Vec<String> = out.borrow_mut().drain(..).collect();
|
||||
let mut stdout = wstd::io::stdout();
|
||||
for l in lines {
|
||||
let _ = stdout.write_all(format!("{l}\n").as_bytes()).await;
|
||||
}
|
||||
let _ = stdout.flush().await;
|
||||
}
|
||||
|
||||
async fn fetch(n: u64, msg: Value, out: Out) {
|
||||
let url = msg["body"]["url"].as_str().unwrap_or_default().to_string();
|
||||
let started = wstd::time::Instant::now();
|
||||
let result = async {
|
||||
let req = Request::get(&url).body(Body::empty()).map_err(|e| e.to_string())?;
|
||||
let resp = Client::new().send(req).await.map_err(|e| format!("{e:?}"))?;
|
||||
let status = resp.status().as_u16();
|
||||
let bytes = resp.into_body().contents().await.map_err(|e| format!("{e:?}"))?.to_vec();
|
||||
std::fs::create_dir_all("/public/fetch").map_err(|e| e.to_string())?;
|
||||
let path = format!("/public/fetch/{n}");
|
||||
std::fs::write(&path, &bytes).map_err(|e| e.to_string())?;
|
||||
Ok::<_, String>((status, bytes.len(), path))
|
||||
}
|
||||
.await;
|
||||
let ms = started.elapsed().as_millis();
|
||||
let body = match result {
|
||||
Ok((status, size, path)) => {
|
||||
let id = std::env::var("SAUSAGE_ACTOR").unwrap_or("fetch".into());
|
||||
json!({"type": "fetched", "status": status, "bytes": size, "path": path.replacen("/public", &format!("/actor/{id}/public"), 1), "ms": ms})
|
||||
}
|
||||
Err(e) => json!({"type": "error", "reason": e, "ms": ms}),
|
||||
};
|
||||
out.borrow_mut().push(json!({"to": msg["from"], "reply_to": msg["id"], "body": body}).to_string());
|
||||
flush(&out).await;
|
||||
}
|
||||
|
||||
fn main() {
|
||||
wstd::runtime::block_on(async {
|
||||
let out: Out = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut stdin = wstd::io::stdin();
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = vec![0u8; 8192];
|
||||
let mut n = 0u64;
|
||||
loop {
|
||||
let Ok(len) = stdin.read(&mut chunk).await else { break };
|
||||
if len == 0 {
|
||||
break;
|
||||
}
|
||||
buf.extend_from_slice(&chunk[..len]);
|
||||
while let Some(pos) = buf.iter().position(|b| *b == b'\n') {
|
||||
let line: Vec<u8> = buf.drain(..=pos).collect();
|
||||
let Ok(msg) = serde_json::from_slice::<Value>(&line) else { continue };
|
||||
match msg["body"]["type"].as_str() {
|
||||
Some("shutdown") => return,
|
||||
Some("fetch") => {
|
||||
n += 1;
|
||||
wstd::runtime::spawn(fetch(n, msg, out.clone())).detach();
|
||||
}
|
||||
Some("ping") => {
|
||||
out.borrow_mut().push(json!({"to": msg["from"], "reply_to": msg["id"], "body": {"type": "pong"}}).to_string());
|
||||
flush(&out).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "fstest"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Filesystem probe for the spike. Keeps files open across messages.
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
fn main() {
|
||||
let mut open: HashMap<String, std::fs::File> = HashMap::new();
|
||||
let mut out = std::io::stdout().lock();
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
let b = &msg["body"];
|
||||
let path = b["path"].as_str().unwrap_or_default().to_string();
|
||||
let r = |res: std::io::Result<Value>| match res {
|
||||
Ok(v) => v,
|
||||
Err(e) => json!({"type": "error", "error": e.to_string()}),
|
||||
};
|
||||
let reply = match b["type"].as_str() {
|
||||
Some("shutdown") => break,
|
||||
Some("write") => r(std::fs::write(&path, b["text"].as_str().unwrap_or_default()).map(|_| json!({"type": "ok"}))),
|
||||
Some("open") => r((|| {
|
||||
let mut f = std::fs::File::create(&path)?;
|
||||
f.write_all(b["text"].as_str().unwrap_or_default().as_bytes())?;
|
||||
open.insert(path.clone(), f);
|
||||
Ok(json!({"type": "ok", "open": true}))
|
||||
})()),
|
||||
Some("append") => r((|| {
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
|
||||
f.write_all(b["text"].as_str().unwrap_or_default().as_bytes())?;
|
||||
open.insert(path.clone(), f);
|
||||
Ok(json!({"type": "ok", "open": true}))
|
||||
})()),
|
||||
Some("close") => json!({"type": "ok", "closed": open.remove(&path).is_some()}),
|
||||
Some("read") => r(std::fs::read_to_string(&path).map(|t| json!({"type": "content", "text": t}))),
|
||||
Some("list") => r(std::fs::read_dir(&path).map(|rd| {
|
||||
let mut names: Vec<String> = rd.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
names.sort();
|
||||
json!({"type": "entries", "names": names})
|
||||
})),
|
||||
Some("remove") => r(std::fs::remove_file(&path).map(|_| json!({"type": "ok"}))),
|
||||
Some("crash") => panic!("asked to crash"),
|
||||
_ => json!({"type": "error", "error": "unknown message"}),
|
||||
};
|
||||
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
|
||||
out.flush().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "notes"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Keeps a list of notes in /data/notes.json and publishes them as
|
||||
//! /public/notes.txt. Version 2: adds `count` and no longer crashes.
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
const DATA: &str = "/data/notes.json";
|
||||
|
||||
fn save(notes: &[String]) {
|
||||
std::fs::write(DATA, serde_json::to_string(notes).unwrap()).unwrap();
|
||||
std::fs::write("/public/notes.txt", notes.join("\n") + "\n").unwrap();
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut notes: Vec<String> = std::fs::read_to_string(DATA)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
let mut out = std::io::stdout().lock();
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
let body = &msg["body"];
|
||||
let reply = match body["type"].as_str() {
|
||||
Some("shutdown") => break,
|
||||
Some("add") => {
|
||||
notes.push(body["text"].as_str().unwrap_or_default().to_string());
|
||||
save(¬es);
|
||||
json!({"type": "added", "count": notes.len()})
|
||||
}
|
||||
Some("list") => json!({"type": "notes", "items": notes}),
|
||||
Some("count") => json!({"type": "count", "count": notes.len(), "version": 2}),
|
||||
// Version 2 fixes the crash.
|
||||
Some("crash") => json!({"type": "not_crashing", "version": 2}),
|
||||
_ => json!({"type": "error", "reason": "unknown message"}),
|
||||
};
|
||||
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
|
||||
out.flush().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"description": "version 2 counts notes and no longer crashes",
|
||||
"steps": [
|
||||
{"send": {"type": "add", "text": "test note"}, "expect": {"type": "added"}},
|
||||
{"send": {"type": "list"}, "expect": {"type": "notes"}},
|
||||
{"send": {"type": "count"}, "expect": {"type": "count", "version": 2}},
|
||||
{"send": {"type": "crash"}, "expect": {"type": "not_crashing"}}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "notes"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Keeps a list of notes in /data/notes.json and publishes them as
|
||||
//! /public/notes.txt.
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
const DATA: &str = "/data/notes.json";
|
||||
|
||||
fn save(notes: &[String]) {
|
||||
std::fs::write(DATA, serde_json::to_string(notes).unwrap()).unwrap();
|
||||
std::fs::write("/public/notes.txt", notes.join("\n") + "\n").unwrap();
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut notes: Vec<String> = std::fs::read_to_string(DATA)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
let mut out = std::io::stdout().lock();
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
let body = &msg["body"];
|
||||
let reply = match body["type"].as_str() {
|
||||
Some("shutdown") => break,
|
||||
Some("add") => {
|
||||
notes.push(body["text"].as_str().unwrap_or_default().to_string());
|
||||
save(¬es);
|
||||
json!({"type": "added", "count": notes.len()})
|
||||
}
|
||||
Some("list") => json!({"type": "notes", "items": notes}),
|
||||
Some("crash") => panic!("asked to crash"),
|
||||
_ => json!({"type": "error", "reason": "unknown message"}),
|
||||
};
|
||||
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
|
||||
out.flush().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"description": "add a note, then list the notes",
|
||||
"steps": [
|
||||
{"send": {"type": "add", "text": "test note"}, "expect": {"type": "added"}},
|
||||
{"send": {"type": "list"}, "expect": {"type": "notes"}}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "ticker"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Sends {"type":"add","text":"tick N"} to `notes` every 50 ms, N = 1..=200.
|
||||
//! Does not read stdin, so the broker kills it at shutdown; that is fine.
|
||||
use std::io::Write;
|
||||
fn main() {
|
||||
let mut out = std::io::stdout().lock();
|
||||
for n in 1..=200 {
|
||||
let msg = serde_json::json!({"to": "notes", "body": {"type": "add", "text": format!("tick {n}")}});
|
||||
writeln!(out, "{msg}").unwrap();
|
||||
out.flush().unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
// Stay alive without doing anything.
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(3600));
|
||||
}
|
||||
}
|
||||
Generated
+48
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1791637130,
|
||||
"narHash": "sha256-TuvwvSjJrpqyIYdjhQ7Wu+ADVfeOpO2Um21/V7TOubw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ca1659429c950a11a565b5764e37a1b704e17ec8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1791962566,
|
||||
"narHash": "sha256-rEB0dvSZw1niBVJvt0AiWygNS/OMdA81lquTzLIWuvo=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "8111d2b45adcbaeaff037f996643f1ac43f944e6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
description = "Sausage: grow a system of WASI actors by talking to it";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs = { nixpkgs, rust-overlay, ... }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
||||
forAll = f: nixpkgs.lib.genAttrs systems (system: f (import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
}));
|
||||
# The toolchain that builds the host and, at runtime, the actors.
|
||||
rustFor = pkgs: pkgs.rust-bin.stable.latest.default.override {
|
||||
targets = [ "wasm32-wasip2" ];
|
||||
extensions = [ "rust-src" ];
|
||||
};
|
||||
sausageFor = pkgs:
|
||||
let
|
||||
rust = rustFor pkgs;
|
||||
rustPlatform = pkgs.makeRustPlatform { cargo = rust; rustc = rust; };
|
||||
in
|
||||
rustPlatform.buildRustPackage {
|
||||
pname = "sausage";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
cargoBuildFlags = [ "-p" "sausage" ];
|
||||
doCheck = false;
|
||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||
# The `cargo` actor runs cargo at runtime. Build scripts need a C linker.
|
||||
postInstall = ''
|
||||
wrapProgram $out/bin/sausage --prefix PATH : ${pkgs.lib.makeBinPath [ rust pkgs.stdenv.cc ]}
|
||||
'';
|
||||
meta.mainProgram = "sausage";
|
||||
};
|
||||
in
|
||||
{
|
||||
packages = forAll (pkgs: { default = sausageFor pkgs; });
|
||||
|
||||
apps = forAll (pkgs: {
|
||||
default = { type = "app"; program = "${sausageFor pkgs}/bin/sausage"; };
|
||||
});
|
||||
|
||||
devShells = forAll (pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
packages = [ (rustFor pkgs) pkgs.wasmtime pkgs.wasm-tools ];
|
||||
shellHook = ''
|
||||
export CARGO_HOME="$PWD/.cargo-home"
|
||||
'';
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "sausage-actor"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "SDK for Sausage actors."
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1 @@
|
||||
//! SDK for Sausage actors (placeholder).
|
||||
Reference in New Issue
Block a user