Compare commits

...
Author SHA1 Message Date
surma a7eb96bbbb PROPOSAL.md: results of the review, no mention of the old model
ci / check (push) Successful in 2m33s
Upgrades retire old processes after a grace period, then call
rpc.retired. Services live in boot.json. One preopen / with a mount
table in the sausage-vfs crate, with guards for in-flight file
operations. Draft 7 and our own compatibility check on a fixed subset.
Imports register with inventory. Rules for liveness, stop states,
routing, locks, activation, endpoints (rpc.endpoint handshake), boot,
builds, and a 10 ms scheduling tick.
2026-10-22 23:27:08 +01:00
surma 2415bb361f PROPOSAL.md: plugins, processes, and JSON-RPC
ci / check (push) Successful in 2m17s
The design from the redesign discussion: a process per call, JSON-RPC on
stdin and stdout, a virtual /proc, /self, and /run, TCP endpoints, locks,
a central journal, generated imports with interface checks at activation,
and the SDK with examples.
2026-10-22 20:58:30 +01:00
surma a536d873e6 cargo: compile C for wasm32-wasip2 with a WASI clang
ci / check (push) Successful in 18s
The Nix package, the dev shell, and the test check set CC_wasm32_wasip2
and AR_wasm32_wasip2 to the clang of pkgsCross.wasi32 (wasi-libc), so
build scripts with the cc crate work, for example rusqlite with bundled
SQLite. The runtime-toolchain check links C into a probe with the
packaged host and an empty PATH.
2026-10-22 20:16:38 +01:00
Sausage Agent 34e2f35272 Add writable /self alias alongside /public and /data
ci / check (push) Successful in 15s
2026-10-22 13:35:13 +00:00
Sausage Agent f932ab9719 Mount the writable world root for every actor
ci / check (push) Successful in 3m9s
2026-10-22 13:29:38 +00:00
surma 3d3f6f35fb Merge pull request 'broker: upgrade and rollback keep a stopped actor stopped' (#3) from upgrade-keeps-stopped into main
ci / check (push) Successful in 13s
Reviewed-on: #3
2026-10-22 10:50:15 +01:00
surma f1af07506f broker: upgrade and rollback keep a stopped actor stopped
ci / check (push) Successful in 8m42s
ci / check (pull_request) Successful in 4m38s
A stopped actor gets the new version installed, keeps its mailbox and does
not run main until the next start. Running actors still restart on the new
version. The upgraded and rolled_back answers say whether the actor runs.
2026-10-22 10:11:36 +01:00
Sausage Agent a72e42a8c9 headless: register signal handlers synchronously, split tests, drop deployment examples and wording
ci / check (pull_request) Successful in 1m36s
ci / check (push) Successful in 11s
2026-10-18 15:31:41 +00:00
Sausage Agent 01a821ab72 Add --headless mode (no stdin read, runs until SIGTERM/exit) + systemd/NixOS examples
ci / check (push) Successful in 2m44s
2026-10-18 15:24:20 +00:00
Sausage Agent c204e1ef0c docs: clarify actor data is world-readable
ci / check (pull_request) Successful in 12s
ci / check (push) Successful in 6s
2026-10-17 17:22:50 +00:00
Sausage Agent 6923ca3e60 preserve the packaged runtime Rust toolchain
ci / check (push) Successful in 6s
2026-10-17 16:36:08 +00:00
Sausage Agent c504532513 drive packaged host runtime check over pipes
ci / check (push) Failing after 1m16s
2026-10-17 16:33:31 +00:00
Sausage Agent eb35a79698 trace the packaged host runtime check
ci / check (push) Failing after 1m47s
2026-10-17 16:29:02 +00:00
Sausage Agent bfcd71e2f4 test packaged host cargo runtime with an empty PATH
ci / check (push) Failing after 1m33s
2026-10-17 16:25:14 +00:00
agent-pool-12 172a776eec cargo: annotate test summary count parsing
ci / check (push) Successful in 5s
2026-10-17 16:00:54 +00:00
agent-pool-12 6a9d35ceb8 cargo: preserve build failures in result wrapper
ci / check (push) Failing after 6m15s
2026-10-17 15:52:37 +00:00
agent-pool-12 22590c608d cargo: run host unit tests before builds
ci / check (push) Failing after 8m51s
2026-10-17 15:50:09 +00:00
Sausage Agent 3bc462ffd2 agent seed: thinking levels low|medium|high|xhigh|max|off on both APIs (card #128)
ci / check (push) Successful in 5s
New config field `thinking` (OpenAI: reasoning_effort still wins): Anthropic adaptive thinking +
output_config.effort (thinking blocks kept and sent back), OpenAI reasoning.effort. A 400 that rejects
the effort is retried with the nearest accepted level (from the error's list, lower on a tie), logged,
never dropped to off.
2026-10-17 15:28:44 +00:00
Sausage Agent f008d0cc60 nix: lock crane; don't store the test run's target folder
ci / check (push) Successful in 39s
2026-10-17 08:02:08 +00:00
Sausage Agent 81daddfd70 nix: build with crane, dependencies cached separately
ci / check (push) Successful in 10m50s
buildDepsOnly builds the dependencies of the host and the tests once
(keyed only on Cargo.toml/Cargo.lock); packages.default and checks.tests
reuse them, so a change to our crates no longer recompiles every crate.
checks.package builds the binary the host loop runs.
2026-10-17 07:49:54 +00:00
agent-pool-9 e8df22fe06 host tests: reuse the existing drain helper
ci / check (push) Successful in 8s
2026-10-17 07:23:30 +00:00
agent-pool-9 0995824c01 Watch the world folder: auto spawn/upgrade, broker watch/unwatch, retire scan
ci / check (push) Failing after 7m36s
The host watches the whole world folder recursively with the notify crate
(any writer counts, .sausage-tmp-* skipped, ~100 ms coalescing per path).
A new or changed actor/<id>/actor.wasm (or new folder actor/<id>) is
booted as at start once quiet: new = spawn (tested first), changed =
upgrade. The broker `scan` request is gone; the boot pass stays, plus a
full pass when the OS watcher overflows.

New broker requests watch/unwatch {path} (saved in actor.json `watches`)
deliver fs_changed {path, op: write|create|remove|rename, to?} from `fs`,
not routed, dropped when the mailbox is full; overflow sends every watcher
one fs_changed {path:"/actor", op:"rescan"}.

Card #91 (multi-host step 1).
2026-10-17 07:12:04 +00:00
agent-pool-8 6a136c1b17 Borrow host descriptors while tracking VFS paths
ci / check (push) Successful in 19s
2026-10-16 21:47:17 +00:00
agent-pool-8 3e90cd73d3 Keep discovery test file at served root 2026-10-16 21:47:17 +00:00
agent-pool-8 845c961f4f Expose advertised actor-served files in directory listings 2026-10-16 21:47:17 +00:00
Sausage agent d6d3c9a2f2 Bare sausage creates ./world if needed and runs it (default app)
ci / check (push) Successful in 5s
- cli: the subcommand is optional; without one, sausage runs ./world and
  runs init first when ./world is not a world yet (test: cli/tests/cli.rs).
  So `nix run git+https://gitea.surma.technology/surma/sausage` works.
- flake: .#loop without a folder runs bare sausage.
- README: mention the bare command and the bare loop.
2026-10-16 21:09:06 +00:00
Sausage Agent 0951ba18e1 README: run straight from the public URL (nix run git+https://...), clone optional
ci / check (push) Successful in 8s
2026-10-16 20:55:39 +00:00
Sausage Agent d0876626df Public repo: .#loop fetches over public HTTPS, README for outsiders
ci / check (push) Successful in 8s
- flake.nix: .#loop runs git+https://gitea.surma.technology/surma/sausage
  (SAUSAGE_FLAKE overrides it); drop loop-internal / --internal (LAN SSH).
- README: clone step, LLM token requirement, loop URL and SAUSAGE_FLAKE.
- ci.yml: comment without internal host name.
2026-10-16 20:40:26 +00:00
28 changed files with 2670 additions and 216 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Runs on the Nix runner on nexus (label `nixos`, host executor). # Runs on a self-hosted Nix runner (label `nixos`, host executor).
name: ci name: ci
on: [push, pull_request] on: [push, pull_request]
Generated
+97
View File
@@ -678,6 +678,15 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.34" version = "0.3.34"
@@ -1066,6 +1075,26 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "inotify"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592"
dependencies = [
"bitflags",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "instant" name = "instant"
version = "0.1.13" version = "0.1.13"
@@ -1165,6 +1194,26 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "kqueue"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags",
"libc",
]
[[package]] [[package]]
name = "leb128" name = "leb128"
version = "0.2.7" version = "0.2.7"
@@ -1259,6 +1308,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [ dependencies = [
"libc", "libc",
"log",
"wasi", "wasi",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -1269,6 +1319,33 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "notify"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.60.2",
]
[[package]]
name = "notify-types"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [
"bitflags",
]
[[package]] [[package]]
name = "object" name = "object"
version = "0.40.0" version = "0.40.0"
@@ -1580,6 +1657,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "sausage" name = "sausage"
version = "0.1.0" version = "0.1.0"
@@ -1590,6 +1676,7 @@ dependencies = [
"http", "http",
"http-body-util", "http-body-util",
"include_dir", "include_dir",
"notify",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@@ -2075,6 +2162,16 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.2" version = "0.3.2"
+915
View File
@@ -0,0 +1,915 @@
# Proposal: plugins, processes, and JSON-RPC
Status: a proposal on the branch `plugins`. When we accept it, its content
goes into `PROTOCOL.md` (the contract) and `DECISIONS.md` (the reasons).
Then this file goes away.
## 1. The idea
Sausage is a world of small WASI programs that an agent grows by talking to
it. The design uses three ideas that models already know:
1. **JSON-RPC calls.** A call has a result, like an LLM tool call. LSP and
MCP use the same protocol.
2. **Processes.** Each call to a plugin starts a new WASI process. WASI has no
threads, so processes are the unit of parallelism. Calls are asynchronous,
so one process can make many calls at the same time.
3. **Files to inspect, calls to act.** The core shows its state as virtual
files in `/proc`, like Plan 9 and Linux. Each change of state is a call.
## 2. Terms
| Term | Meaning |
| --- | --- |
| world | The folder that a `sausage` host runs. It is `/` for every process. |
| core | The host. It runs processes, routes calls, serves the virtual files, and keeps the journal. |
| plugin | A folder `/plugins/<name>/` with a `plugin.wasm`. A plugin does not run by itself. |
| process | One run of a plugin version. Its ID is `<plugin>-<uuidv7>`, for example `notes-0197f3a2-7c1e-7d2b-9a41-5f0c2e8b1d34`. |
| call | A JSON-RPC request. `notes.add` calls the method `add` of the plugin `notes`. |
| interface | A set of methods. A plugin has one main interface. It can also declare endpoint interfaces. |
| endpoint | An interface that a running process serves on a TCP port. An endpoint file tells others where it is. |
| service | A call in `/config/boot.json`. The core starts it at boot and again after an upgrade of its plugin. |
| import | An interface of another plugin that a plugin uses. The SDK generates it from that plugin's description. |
| provider, consumer | The plugin that has an interface, and a plugin that imports it. |
| journal | The central log of the world. |
## 3. The world folder
```text
<world>/
plugins/<name>/
plugin.wasm the current version
plugin.prev.wasm the version before, for `core.revert`
source/ the source. `cargo.build` builds it from here.
imports/<plugin>.json descriptions of the imported plugins
state/<plugin>/ persistent state of a plugin, by convention
state/core/journal.sqlite the journal
config/ global configuration
boot.json the services
agent.json API, base URL, model, and token of the agent
```
- Every process can read and write all of it. There are no permissions.
- The world folder must not contain `proc`, `run`, or `self`. These names are
mount points (section 5). The host does not start a world that has them.
## 4. Calls and processes
### 4.1 The protocol
- JSON-RPC 2.0 on stdin and stdout, one message per line, as in MCP's stdio
transport.
- A line is a message only if it is a JSON object with `"jsonrpc": "2.0"`.
- The core discards every other line on stdout. For the first discarded line
of a process, it writes one warning to the journal, without the content of
the line. Logs go to stderr.
- Each line on stderr goes to the journal.
- `params` is always an object. Batches are not supported.
- The SDK writes each message with one blocking write through std's stdout,
and it writes a newline before each message. So a `print!` without a
newline cannot damage a message.
### 4.2 Method names
- A method name has the form `<target>.<method>`. The core splits it at the
first `.`.
- Plugin names use `a-z`, `0-9`, `-`, and `_`. The name `core` is reserved.
- `notes.add` starts a new process of `notes` and sends it the request `add`.
`notes.rpc.discover` sends it `rpc.discover`.
- `core.<method>` is a method of the core (section 10).
- Method names that start with `rpc.` are reserved: `rpc.discover`,
`rpc.retired`, and `rpc.endpoint`.
### 4.3 What a call does
1. The core gives the call a UUIDv7 and starts the process `<plugin>-<uuid>`
with the current version of the plugin.
2. The core writes the request to the stdin of the process. The request ID is
the process ID.
3. The process writes the response to stdout. The core sends the response to
the caller, with the caller's request ID.
4. If the process ends before it answers, the caller gets the error -32002.
Its `data` has the exit status and the last journal lines of the process.
Before the core decides this, it reads the stdout of the process to the
end.
- A process can write calls to stdout at any time, to the core and to
plugins. The responses come on stdin. A process can have many calls in
flight.
- The core keys each pending request by the process and the exact JSON ID.
The core drops a response with an unknown or late ID. The SDK writes a note
about such a response to stderr.
- A notification to `plugin.method` (without an ID) starts an attached
process. That process gets the notification without an ID, and nobody gets
a result.
### 4.4 Detached calls
`core.call {"method": "www.start", "params": {...}, "detach": true}` makes the
call and returns its result to the caller. The new process has no parent, so
it does not end with its caller.
Sent as a notification (without an ID), the same `core.call` starts the
process and forgets it.
### 4.5 Notifications to the caller
A process can tell its caller about progress. It sends the notification
`core.notify {"name": "text", "params": {...}}`. The core gives it to the
caller as `core.notify {"call": <ID of the caller's request>, "name": "text",
"params": {...}}`. After the call has ended, the core drops such
notifications.
So a process never needs to know who called it. Endpoint calls send their
notifications over the endpoint connection (section 6).
### 4.6 Lifetime
A process lives while one of these runs:
- a handler of a call,
- a background task,
- an endpoint that listens.
The SDK's own reader of stdin does not count. When nothing of this runs, the
SDK returns from `main`, and the process ends.
A process has three states:
1. **running**
2. **stopping**: the core has closed stdin. EOF on stdin means "stop now".
- New calls of the process fail at once, and its pending calls fail.
- A stop hook can still write files and send notifications. It cannot
make calls, because no response can arrive.
- After 5 seconds, the core kills the process.
3. **ended**: the core releases the locks of the process (section 7) and
removes its folder in `/proc`.
Rules:
- `core.stop {pid}` starts the stopping state. `core.kill {pid}` kills the
process at once.
- When a process ends, the core stops each of its attached children. All
children stop in parallel, so a whole tree takes 5 seconds at most.
- A detached process has no parent. Only `core.stop`, `core.kill`, an
upgrade (section 4.7), and the end of the host stop it.
- At host shutdown, the core stops every process in parallel.
Note: on Unix, children outlive their parent. Here, an attached child ends
with its parent. Only `detach` makes a process independent.
### 4.7 Upgrades
A process is an instance of one plugin version. When a new version becomes
active (section 11.3):
1. New calls get the new version at once.
2. Each process of an older version gets a grace period to finish its work.
The default is 60 seconds. A plugin can declare its own value in
`rpc.discover` (`x-sausage-grace`). Then the core stops the process.
3. When the old process of a service has ended, the core starts the service
again with the new version.
4. When the last process of an older version has ended, the core starts a
process of the new version and calls `rpc.retired {"previous":
"<sha256 of the older file>"}`. A plugin can use this hook to migrate its
state, or to write a marker that its other new processes wait for. The
result goes to the journal.
During the grace period, an old process can call a method that a new
provider no longer has. The time is limited, so this risk is acceptable.
### 4.8 Environment and network
- `SAUSAGE_PID`: the process ID.
- `SAUSAGE_PLUGIN`: the plugin name.
- `SAUSAGE_PARENT`: the parent: `console`, `core`, or a process ID.
Every process gets the network: outgoing HTTP and HTTPS through `wasi:http`,
and TCP, UDP, and DNS through `wasi:sockets`.
### 4.9 Scheduling
- Each process is a task on the core's multi-thread tokio runtime, with work
stealing.
- A scheduler thread advances Wasmtime's epoch every 10 milliseconds. At
each tick, a process that computes yields to the runtime. So a busy process
cannot block the core or the other processes.
- `core.kill` aborts the task. It takes effect at the next tick at the latest.
- Most processes wait for IO most of the time. A better scheduler can come
later.
### 4.10 Errors
The standard JSON-RPC codes apply. The core adds these codes:
| Code | Meaning |
| --- | --- |
| -32001 | There is no such plugin. |
| -32002 | The process ended without an answer. `data` has the exit status and the last journal lines. |
| -32003 | The activation was rejected. `data` has the reasons. |
## 5. The filesystem
Each process gets one preopen, `/`. The VFS resolves each path through a
mount table:
| Path | Layer | Access |
| --- | --- | --- |
| `/` | host: the world folder | read-write |
| `/proc` | generated by the core | read-only |
| `/self` | memory: the folder `/proc/<own pid>/` | read-write |
| `/run` | memory, shared, empty at each host start | read-write |
A listing of `/` shows `proc`, `run`, and `self`. A path from any folder
descriptor goes through the same mount table.
```text
/proc/
core/
PROTOCOL.md the contract
graph.json plugins, imports, and the result of each check
graph.dot the same graph for Graphviz
locks.json held locks and waiting processes
plugins/<name>/
discover.json the rpc.discover document of the current plugin.wasm
client.rs the Rust code that import!("<name>") generates
<pid>/
info.json plugin, version, method, params, parent, detached, start time, state
log the journal lines of this process
<files> the files that the process writes to /self
```
Rules:
- A process writes its own folder only through `/self`. Writes through
`/proc` always fail.
- In a process folder, the names `info.json`, `log`, and `fs` belong to the
core. A write to `/self/info.json` fails.
- A read of a generated file returns a snapshot that ends. For example,
`/proc/<pid>/log` returns the lines up to the time of the open.
- The folder of a process goes away when the process ends. Its files are in
memory. Large or persistent data belongs in `/state`.
- Do not send a `/self` path to another process. There it means a different
folder, like `localhost` on another machine.
Principle: **inspect through files, act through calls.** A file read is
cheaper than a call, and every language and tool can read a file.
### 5.1 The VFS crate
The VFS is a crate of its own, `sausage-vfs`, so that it can become public
later. It knows nothing about Sausage.
- One trait with the operations of a filesystem: stat, list, open, read and
write at an offset, set size, create a folder, remove, and rename.
- The layers:
- **host:** a real folder, sandboxed with `cap-std`. Each file operation
runs in a blocking task. That task holds a guard of its process until
the system call has returned, even if the process was killed.
- **memory:** files and folders in memory.
- **generated:** files that a function makes at each open.
- **mounts:** sends each path to the right layer. Each process has its own
mount table, which shares most layers with the other processes.
- An adapter implements `wasi:filesystem` for Wasmtime on top of the trait.
It replaces the filesystem of `wasmtime-wasi` completely.
- Not now: overlays (a union of two layers). Deletes and renames across
layers need whiteouts and copy-up. The trait leaves room for overlays.
- Tests:
1. Unit tests for each layer.
2. Differential tests: `proptest` generates sequences of operations. They
run on the host layer and on `std::fs` in a temporary folder, and the
results must be equal.
3. A model test for the memory layer.
4. WASI tests: Rust guests use `std::fs` through the adapter. `rusqlite`
and `tempfile` must work.
## 6. Endpoints
- `listen(path, interface)` binds a free port on 127.0.0.1. When the listener
is ready, it writes an endpoint file at `path`, in one atomic step. The
file has one line: the address, the owner, and the interface. Example:
`tcp:127.0.0.1:41234 www-0197… control`.
- Usually the path is in `/self`. A service that others must find uses a
fixed path in `/run`, for example `/run/secrets`.
- `connect(path)` reads the file and connects. Its first request is the
built-in `rpc.endpoint`, which returns the owner, the plugin, and the
interface. If they do not match the file, `connect` fails. So a stale file
or a reused port cannot connect a client to the wrong process.
- If the file does not exist yet, `connect` waits a short time, for example
while the services start.
- JSON-RPC runs on the connection, one message per line. In an endpoint
handler, `ctx.notify` sends the notification over the connection.
- `listen` accepts only interfaces that the plugin declares (section 11.1).
## 7. Locks
- `core.lock {"path": "/state/notes/notes.json"}` returns when the caller
holds the lock.
- Each acquisition gets its own place in a FIFO queue. The same process
cannot take one lock twice: its second acquisition waits behind its first.
- With `"wait": false`, the call returns at once with `{"held": true}` or
`{"held": false}`.
- `core.unlock {path}` releases the lock. The SDK sends it as a notification
when a guard drops. If the future of an acquisition was dropped, the SDK
releases the lock when the grant arrives.
- When a process ends, the core releases all of its locks, also after a
crash or a kill. It waits until the last file operation of the process has
returned (section 5.1). So a write of a killed process cannot land after
the next process got the lock.
- Locks are advisory. The core does not block writes to the file: the path
is only a name.
- SQLite has no file locks in WASI. Hold the lock while the database is open.
## 8. The journal
- An SQLite file at `/state/core/journal.sqlite`. Only the core writes it.
- Each entry has a UUIDv7 (it sorts by time), the process ID, the plugin, the
stream (`stderr` or `core`), and the line.
- The core writes its own events too: start (with method and params), exit
status, stop, kill, upgrades, and the warning about discarded stdout.
- `core.log {plugin?, pid?, since?, until?, grep?, limit?}` queries the
journal. `/proc/<pid>/log` shows the lines of one running process.
- Later: structured logging, like `tracing`.
## 9. Boot and services
At start, the core starts each service in `/config/boot.json`, in order:
```json
[
{"method": "secrets.serve", "params": {}},
{"method": "www.start", "params": {"port": 8080}}
]
```
- A service is a detached call. The core waits for its result before it
starts the next one. The result shows that the service is ready.
- If a service fails, the core writes the error to the journal and continues.
- After an upgrade of the plugin of a service, the core starts the service
again with the new version (section 4.7).
- The core does not restart other detached processes.
- A singleton is a service with a fixed endpoint in `/run`. Only the core
starts it, so two instances cannot start at the same time.
## 10. The core API
| Method | Params | Result |
| --- | --- | --- |
| `core.call` | `method`, `params`, `detach`? | the result of the method |
| `core.notify` | `name`, `params` | none (a notification) |
| `core.stop` | `pid` | `{}` when the process has ended |
| `core.kill` | `pid` | `{}` |
| `core.lock` | `path`, `wait`? | `{"held": bool}` |
| `core.unlock` | `path` | none (a notification) |
| `core.activate` | `plugin` and `wasm`, or `plugins: [{plugin, wasm}]`. `force`? | the new `rpc.discover` documents |
| `core.revert` | `plugin`, `force`? | like `core.activate` |
| `core.log` | filters (section 8) | journal entries |
| `core.exit` | none | `{}`. Then the host stops with exit code 75. |
Everything else is a file.
## 11. Interfaces
### 11.1 `rpc.discover`
Each plugin answers `rpc.discover` with an OpenRPC document. The SDK
generates it from the code, with all doc comments. The schemas are JSON
Schema Draft 7, as OpenRPC requires. The document has these extension
fields:
- `x-sausage-params` for each method: a `$ref` to the schema of the params
struct. So the name and the docs of the struct are not lost.
- `x-sausage-endpoints`: the endpoint interfaces by name, each with its
methods.
- `x-sausage-imports`: the methods that the plugin uses from each provider,
with their schemas.
- `x-sausage-grace`: the grace period in seconds, if the plugin declares one.
The schema of params describes what the provider can deserialize. The schema
of a result describes what the provider serializes. (Schemars generates both
kinds. They differ, for example for fields with `#[serde(default)]`.)
```json
{
"openrpc": "1.2.6",
"info": {"title": "www", "version": "0.1.0"},
"methods": [{
"name": "start",
"summary": "Start the web server and return the ID of its process.",
"paramStructure": "by-name",
"params": [{"name": "port", "required": true,
"description": "The TCP port to listen on, on all interfaces.",
"schema": {"type": "integer", "minimum": 0, "maximum": 65535}}],
"result": {"name": "pid", "schema": {"type": "string"}},
"x-sausage-params": {"$ref": "#/components/schemas/StartParams"}
}],
"components": {"schemas": {"StartParams": {"…": "…"}}},
"x-sausage-endpoints": {"control": {"methods": [{"name": "stats", "…": "…"}]}},
"x-sausage-imports": {"notes": {"methods": [{"name": "add", "…": "…"}]}}
}
```
### 11.2 Compatibility
A provider can add methods, optional params, and result fields. For each
imported method:
1. The provider has the method.
2. Each params value that the consumer can send is valid for the provider.
3. Each result that the provider can return is valid for the consumer.
- The same rules apply to endpoint interfaces.
- There are no version numbers. This check replaces them.
- The check is our own. It covers a fixed subset of JSON Schema: objects with
`properties` and `required`, the primitive types, arrays, optional and
nullable values, string enums, and `$ref`. `#[schema]` gives a compile
error for a type outside this subset. So the check can decide every case.
- The check covers calls, not state. Old and new processes share the files in
`/state`, and `core.revert` restores only code. A plugin must keep its
state format compatible, or migrate it in `rpc.retired`.
### 11.3 Activation
`core.activate {"plugin": "notes", "wasm": "/state/cargo/builds/7/notes.wasm"}`:
1. The core copies the file. All further steps use this copy, even if the
file changes.
2. It starts the copy and calls `rpc.discover`.
3. It waits until it has the documents of all plugins (section 11.4). A
missing document never counts as "no consumers".
4. It checks the imports of the new version against the current providers.
5. It checks each current consumer of the plugin against the new version.
6. If all checks pass, `plugin.wasm` becomes `plugin.prev.wasm`, and the copy
becomes `plugin.wasm`. Then the upgrade starts (section 4.7).
If a check fails, nothing changes. The error lists each problem, with a
counterexample where possible:
```text
notes was not activated: 1 consumer breaks.
- agent imports notes.add(text: string) -> Added.
The new notes.add has no param "text". It requires "body": string.
Counterexample: {"text": "milk"} is not valid for the new params.
Update agent first, or activate both together.
```
- The core runs one activation at a time.
- `plugins: [...]` activates several plugins together. The checks then use
the new set. The core writes all files under temporary names first, and
then renames them.
- `force: true` skips steps 4 and 5.
- `core.revert {plugin}` activates `plugin.prev.wasm` in the same way, and
the current version becomes `plugin.prev.wasm`.
- A person can also copy a file to `plugin.wasm` by hand. That skips the
checks (section 11.4).
### 11.4 The graph
- The core keeps the `rpc.discover` documents of all plugins in memory, by
file hash. At start, it gets them in the background.
- A file watcher on `/plugins/*/plugin.wasm` updates them, for example after
a copy by hand. Such a copy skips the checks. `graph.json` then shows each
broken import, and the journal records it.
- An import of a plugin that does not exist is "missing".
- Untyped calls (`call("x.y", ...)`) are not in the graph.
## 12. Building and tests
`cargo.build {"plugin": "notes"}` is a native method of the host. A call
still gets a process ID, a folder in `/proc`, and journal entries, like other
calls. The method does these steps:
1. It copies `/plugins/notes/source/` once. The tests and the build both use
this copy.
2. It runs the tests under WASI.
3. It builds the release `.wasm` for `wasm32-wasip2`.
4. It returns `{"wasm": "/state/cargo/builds/<n>/notes.wasm", "tests":
{"passed", "failed", "output"}, "warnings"}`. If a step fails, it returns
an error with the compiler or test output.
- The dependency `sausage-plugin = "0.1"` points to the SDK copy at
`/plugins/cargo/sdk/`.
- Build scripts can compile C: `CC_wasm32_wasip2` and `AR_wasm32_wasip2`
point to a WASI clang.
- The warnings include unused imported methods. Remove them from
`imports/<plugin>.json`, so that fewer changes of the provider break the
plugin.
- A kill of the call stops the whole process group of cargo, with rustc, the
build scripts, and the tests.
Tests:
- `sausage` is the test runner (`CARGO_TARGET_WASM32_WASIP2_RUNNER`). It runs
each test in its own instance, in parallel. A panic ends only its own test.
Without this, the first panic would end the whole test run, because WASI
has `panic=abort`.
- The normal Rust test setup works: `#[test]`, `tests/*.rs`, name filters,
and `--ignored`. Async tests use `#[sausage_plugin::test]`, like
`#[tokio::test]`. `#[should_panic]` cannot work, because WASI cannot catch
a panic.
- Each test gets its own mount table: an empty temporary world as `/`, the
package folder (so that fixture files work), the variables that cargo sets,
and its own `/proc`, `/self`, and `/run`.
- The runner answers `core.lock`, `core.unlock`, `core.notify`, and
`core.log`. A call to a plugin fails with a clear error: a test has no
other plugins.
## 13. The SDK
Two crates: `sausage-plugin`, and `sausage-plugin-macros`, which
`sausage-plugin` re-exports. A plugin depends only on `sausage-plugin`. The
SDK also re-exports `serde` and `schemars` for the macros.
| Item | Purpose |
| --- | --- |
| `#[rpc]` | Makes an `impl` an interface. On the main interface: `#[rpc(endpoints(name = Type), grace_seconds = N)]`. |
| `#[on_retired]` | Marks the method that answers `rpc.retired`. |
| `#[schema]` | For each type that a method takes or returns: adds the serde and schemars derives, and checks the subset of section 11.2. |
| `run(T)` | In `main`: serves the main interface on stdin and stdout. |
| `call(method, params)` | An untyped call. Returns a JSON value. |
| `.detach()`, `.on_notify(f)` | Options of a call. |
| `Ctx` | An optional second argument of a method: `ctx.notify(name, params)`, `ctx.exit_after_response()`. |
| `lock(path)` | A lock guard. `.try_now()` does not wait. `.keep()` holds the lock until the process ends. |
| `listen(path, value)`, `connect(path)` | Endpoints. |
| `background(future)` | Runs a task in this process. The process lives while a task runs. |
| `import!("name")` | Generates `mod name` from `imports/name.json`. |
| `state_dir()`, `write_atomic(path, bytes)`, `pid()` | Helpers. |
| `#[sausage_plugin::test]` | An async test. A plain `#[test]` also works. |
Rules that the compiler enforces:
- A method takes `&self`, an optional `ctx: &Ctx`, and at most one params
argument.
- The params argument is a `#[schema]` struct with named fields. Its fields
are the params of the method.
- Each method, each `#[schema]` type, and each field has a doc comment.
Other agents read these docs to use the interface. Write them like good
Rust documentation.
Other rules:
- Method docs become `summary` (the first paragraph) and `description` in
OpenRPC. Type and field docs become `description` in the JSON Schema. The
generated imports contain them again as doc comments.
- Each `import!` registers its document in a static list with the
`inventory` crate. `run` reads the list for `rpc.discover`. `import!` uses
`include_str!`, so cargo rebuilds the plugin when the file changes.
- The value that `run` receives must be cheap to create, because the core
also starts a plugin only for `rpc.discover`. Open files and databases in
the methods.
- Log with `eprintln!`. stdout belongs to the protocol.
### 13.1 A plugin
```toml
[package]
name = "notes"
version = "0.1.0"
edition = "2024"
[dependencies]
sausage-plugin = "0.1"
serde_json = "1"
```
```rust
//! Notes: a list of short texts, stored in `/state/notes/notes.json`.
//!
//! Each call runs in its own process, so two calls can run at the same time.
//! `add` takes a lock on the file, so that no note gets lost.
use std::path::PathBuf;
use sausage_plugin::{Result, lock, rpc, schema, state_dir, write_atomic};
/// The notes plugin. It keeps no state in memory: each call reads the file.
struct Notes;
/// The params of `add`.
#[schema]
struct AddParams {
/// The text of the new note. It can contain newlines.
text: String,
}
/// The result of `add`.
#[schema]
struct Added {
/// The number of notes after the call, with the new note.
count: usize,
}
#[rpc]
impl Notes {
/// Add a note at the end of the list.
///
/// Returns the number of notes after the call.
async fn add(&self, AddParams { text }: AddParams) -> Result<Added> {
let _lock = lock(file()).await?; // released on drop, or when the process ends
let mut notes = load()?;
notes.push(text);
write_atomic(file(), serde_json::to_vec(&notes)?)?;
Ok(Added { count: notes.len() })
}
/// List all notes, the oldest first.
fn list(&self) -> Result<Vec<String>> {
load() // no lock: write_atomic replaces the file in one step
}
}
/// The file with the notes: a JSON list of strings.
fn file() -> PathBuf {
state_dir().join("notes.json")
}
/// Read all notes. A missing file means no notes.
fn load() -> Result<Vec<String>> {
match std::fs::read(file()) {
Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]),
Err(e) => Err(e.into()),
}
}
fn main() {
sausage_plugin::run(Notes);
}
#[cfg(test)]
mod tests {
use super::*;
#[sausage_plugin::test]
async fn add_appends_and_counts() {
assert_eq!(Notes.add(AddParams { text: "milk".into() }).await.unwrap().count, 1);
assert_eq!(Notes.add(AddParams { text: "eggs".into() }).await.unwrap().count, 2);
assert_eq!(Notes.list().unwrap(), ["milk", "eggs"]);
}
#[test]
fn list_is_empty_without_a_file() {
assert!(Notes.list().unwrap().is_empty());
}
}
```
### 13.2 A server with an endpoint
```rust
//! A web server for the files in `/state/www/`.
//!
//! `start` starts the server in its own process and returns at once. Run it
//! as a service, or call it detached, or the server ends with its caller.
//! The running server serves the endpoint `control` at
//! `/proc/<pid>/control`.
use std::{cell::Cell, rc::Rc};
use sausage_plugin::{Ctx, Result, background, listen, pid, rpc, schema};
use wstd::net::TcpListener;
/// The www plugin.
struct Www;
/// The params of `start`.
#[schema]
struct StartParams {
/// The TCP port to listen on, on all interfaces.
port: u16,
}
#[rpc(endpoints(control = Control), grace_seconds = 5)]
impl Www {
/// Start the web server and return the ID of its process.
///
/// The server runs until it gets `stop` on its endpoint, until
/// `core.stop`, or until its caller ends. Fails if the port is in use.
async fn start(&self, StartParams { port }: StartParams) -> Result<String> {
let tcp = TcpListener::bind(&format!("0.0.0.0:{port}")).await?;
let control = Rc::new(Control { hits: Cell::new(0) });
listen("/self/control", control.clone()).await?;
background(serve(tcp, control)); // the process lives while this task runs
Ok(pid())
}
}
/// The control endpoint of a running server.
struct Control {
/// The number of HTTP requests that the server answered.
hits: Cell<u64>,
}
#[rpc]
impl Control {
/// The number of HTTP requests that the server answered since its start.
fn stats(&self) -> Result<u64> {
Ok(self.hits.get())
}
/// Stop the server. The process ends after this response.
fn stop(&self, ctx: &Ctx) -> Result<()> {
ctx.exit_after_response();
Ok(())
}
}
/// Accept HTTP connections, and answer each request from `/state/www/`.
async fn serve(tcp: TcpListener, control: Rc<Control>) {
// …
}
fn main() {
sausage_plugin::run(Www);
}
```
The same type can serve stdin and an endpoint. The example uses two types
for a reason: `www.stats` through the core would start a fresh process, and
that process would answer 0.
### 13.3 A service with a fixed endpoint
```rust
//! Secrets: keeps secrets in memory, for other plugins.
//!
//! Run it as a service: `{"method": "secrets.serve", "params": {}}` in
//! `/config/boot.json`. Clients connect to the endpoint `vault` at
//! `/run/secrets`.
use std::rc::Rc;
use sausage_plugin::{Result, listen, rpc};
/// The secrets plugin.
struct Secrets;
#[rpc(endpoints(vault = Vault))]
impl Secrets {
/// Open the vault and serve it at `/run/secrets`.
///
/// Returns when the endpoint is ready. The process then lives while the
/// endpoint listens.
async fn serve(&self) -> Result<()> {
listen("/run/secrets", Rc::new(Vault::open()?)).await
}
}
```
A client imports `secrets` and calls
`secrets::vault::connect("/run/secrets").await?`.
### 13.4 Imports
To use another plugin, an agent does these steps:
1. Copy `/proc/core/plugins/notes/discover.json` to
`source/imports/notes.json`.
2. Read `/proc/core/plugins/notes/client.rs`: the exact code that the import
generates.
3. Write `import!("notes")` and the code that uses it.
4. Call `cargo.build`, and remove the imported methods that the warnings call
unused.
5. Call `core.activate`.
```rust
sausage_plugin::import!("notes"); // imports/notes.json
sausage_plugin::import!("www"); // imports/www.json, with its endpoint "control"
/// Add a note, then start a web server and return its number of answered requests.
async fn example() -> Result<u64> {
let added = notes::add(notes::AddParams { text: "milk".into() }).await?;
eprintln!("{} notes", added.count); // stderr goes to the journal
let pid = www::start(www::StartParams { port: 8080 }).detach().await?;
let control = www::control::connect(&format!("/proc/{pid}/control")).await?;
control.stats().await
}
```
- The params of each method are one struct. The fields have the same names as
the JSON params.
- Types take their names from the schemas. When the provider uses
`#[schema]`, those are the names of its Rust types: `AddParams` stays
`AddParams`.
- The generated items are private to the crate, so rustc warns about each
unused method.
## 14. The agent
- `agent.chat {"text": "..."}` answers a message from a person. The result is
the final text. The interim texts come as `core.notify` with the name
`text`.
- The agent takes a lock on `/state/agent/history.jsonl`. So a second message
waits until the first turn has ended.
- The agent declares a long grace period (for example 10 minutes), so that a
turn can finish after an upgrade. A new version applies at the next
`chat`.
- The tools: `call` (method, params, detach), `read_file`, `write_file`,
`list_dir`, and `copy_dir`.
- The configuration is in `/config/agent.json`.
- The prompt stays short. It says:
- where the contract is (`/proc/core/PROTOCOL.md`),
- where the SDK is (`/plugins/cargo/sdk/`),
- how to build and activate a plugin,
- where the agent's own source is (`/plugins/agent/source/`),
- that logs go to stderr,
- that the interface check does not cover state.
## 15. The console
- `rustyline` gives line editing and history. Notifications appear while you
type.
- A short prompt. Results are indented. Agent notifications are dimmed.
| Input | Effect |
| --- | --- |
| text | `agent.chat` |
| `<plugin>.<method> {json}` | a call. The console prints the result. |
| `ls [path]`, `cat <path>` | the filesystem as a process sees it, with `/proc` and `/run` |
| `methods <plugin>` | the methods from `rpc.discover`, with params and docs |
| `ps` | the running processes |
| `log [plugin or pid] [-n N]` | the journal |
| `activate <plugin> <wasm>`, `revert <plugin>` | `core.activate`, `core.revert` |
| `stop <pid>`, `kill <pid>` | `core.stop`, `core.kill` |
| `help`, `quit` | |
`--headless` stays: no console input, and the host runs until SIGTERM or
`core.exit`.
## 16. Caches
- Wasmtime's own compile cache stores the compiled code of each `.wasm` file.
It supports components.
- The folder is `$XDG_CACHE_HOME/sausage/`, or `~/.cache/sausage/` without
that variable. `--cache-dir` changes the folder.
## 17. Decisions
- **JSON-RPC, not component interfaces.** Every language can read and write
lines of JSON, and models know JSON-RPC from LSP and MCP.
- **A process per call.** We get parallelism without threads in WASI. A new
version applies at the next call, and old processes retire after a grace
period.
- **Attached by default.** A process does not outlive its caller by
accident.
- **Services in one file.** Only the core starts and restarts them, so
upgrades and singletons need no logic in plugins.
- **Files to inspect, calls to act.** This keeps the core API small, and the
agent's file tools are enough to explore the world.
- **Generated imports and interface checks, without version numbers.** A model
that translates a description by hand makes errors. A generator does not.
The check catches the most common error in a world that changes itself: a
change to one plugin breaks another one.
- **A small schema subset.** With Draft 7 and a fixed subset, our own check
can decide every case.
- **Docs are part of the interface.** Other agents work from them, so the
compiler requires them.
- **No permissions.** The checks protect against mistakes, not against
plugins. Locks are advisory.
- **Our own VFS, in its own crate.** It works on Linux and macOS, it makes
`/proc` live, and plugin-served files need it later. Mounts now, overlays
later.
- **TCP endpoints for now.** They need no host code. VFS pipes can replace
them later behind the same SDK API.
- **Simple scheduling for now.** An epoch tick every 10 milliseconds keeps
the core responsive and makes kills reliable.
## 18. Later
- Plugin-served files at `/proc/<pid>/fs/`, for example for an FTP plugin. A
request gets a timeout of 10 seconds, because a process that reads its own
served files would wait for itself forever.
- Overlays in the VFS.
- VFS pipes for endpoints, instead of TCP ports.
- Subscriptions to file changes.
- Structured logging.
- A secrets service.
- Cancellation of calls.
- A better scheduler.
## 19. Plan
The work happens on the branch `plugins`. Each step includes its tests.
Already confirmed:
- `inventory` works on `wasm32-wasip2`.
- Wasmtime's compile cache supports components.
- C code builds for `wasm32-wasip2`.
Checks of risks first:
1. An end-to-end pair: a provider and a consumer with a generated import,
through the real transport. This includes `typify` with Draft 7 from
schemars, and our compatibility check on real documents.
2. Fixture paths in tests under the runner.
Then the steps:
1. `PROTOCOL.md` and `DECISIONS.md` from this proposal.
2. `sausage-vfs`: the trait, the layers, the Wasmtime adapter, and its tests.
3. The core: a process per call, JSON-RPC routing, the process tree, upgrades,
services, locks, the journal, scheduling, and the compile cache.
4. The SDK and its macros.
5. `cargo.build`, the test runner, activation, and the graph.
6. The agent and the console.
+93 -21
View File
@@ -59,16 +59,30 @@ it exits. After 5 seconds, it kills the instance.
## Filesystem ## Filesystem
Each instance sees three folders: The world folder is the foundation of every instance's filesystem:
| Path | Access | Contents | | Path | Access | Contents |
| --- | --- | --- | | --- | --- | --- |
| `/data` | read-write | the actor's state | | `/` | read-write by every actor | the whole world folder |
| `/public` | read-write | the files that the actor publishes | | `/self` | read-write | alias for the actor's own folder (`public/`, `data/`, `source/`, etc.) |
| `/actor` | read-only | the `actor/` folder of the world | | `/data` | read-write | alias for the actor's state folder |
| `/public` | read-write | alias for the actor's published files |
| `/actor` | read-write by every actor | the `actor/` folder of the world |
Every actor can read and write every file in the world, including other actors'
`/actor/<id>/data/` and `/actor/<id>/public/`. Nothing there is private.
Creating a folder in the world makes it visible to running actors immediately:
`/config`, `/docs`, `/www`, and `/skills` need no new mounts or host restart.
New worlds include an empty `/config` for global configuration by convention;
existing worlds can simply create it. Keep secrets out of global configuration.
Candidate instances see the same writable world root. Their `/self` alias
points to the scratch actor folder; `/public` and `/data` still point to its
corresponding children, and network access remains off;
absolute world paths are not isolated from the live world.
`/actor/<id>/` holds everything about an actor: `actor.wasm`, `actor.json` `/actor/<id>/` holds everything about an actor: `actor.wasm`, `actor.json`
(installed version, running, subscriptions, tapped), `source/`, `public/`, `data/`, (installed version, running, subscriptions, tapped, watches), `source/`, `public/`, `data/`,
`stderr.log`, and `versions/<n>/`. `ls /actor` lists all actors. `stderr.log`, and `versions/<n>/`. `ls /actor` lists all actors.
Files are plain POSIX files: a write is visible at once. To publish a file Files are plain POSIX files: a write is visible at once. To publish a file
@@ -85,6 +99,13 @@ Every actor can read and write them. The host turns each file operation into a
message from `fs` to `<id>` and waits up to 10 seconds for the reply. `path` message from `fs` to `<id>` and waits up to 10 seconds for the reply. `path`
is relative to `fs/` and starts with `/` (the folder itself is `/`). is relative to `fs/` and starts with `/` (the folder itself is `/`).
To make this virtual directory discoverable, an actor that serves it declares
`Actor-served files` and `fs_read` in its leading `//!` documentation in
`source/src/main.rs`. The host uses that declaration when listing
`/actor/<id>/`; it does not probe every actor (which could stall on actors that
do not handle filesystem messages). This is discovery metadata only: opening
and reading the path still requires a valid `fs_read` reply.
| Request | Reply | | Request | Reply |
| --- | --- | | --- | --- |
| `{"type": "fs_read", "path"}` | `{"type": "fs_file", "text"}` (or `"base64"` instead of `"text"`), or `{"type": "fs_dir", "entries": [{"name", "type": "file" \| "dir"}]}` | | `{"type": "fs_read", "path"}` | `{"type": "fs_file", "text"}` (or `"base64"` instead of `"text"`), or `{"type": "fs_dir", "entries": [{"name", "type": "file" \| "dir"}]}` |
@@ -103,28 +124,34 @@ Any request can be answered with `{"type": "fs_error", "errno": "no-entry"}`.
- No reply within 10 seconds, or an actor that does not exist, makes the - No reply within 10 seconds, or an actor that does not exist, makes the
operation fail (`io`, or `no-entry`). An actor that opens its own `fs/` operation fail (`io`, or `no-entry`). An actor that opens its own `fs/`
waits for itself and fails after the timeout. waits for itself and fails after the timeout.
- `fs` is reserved: no actor can have that ID. - `fs` is reserved: no actor can have that ID. It also sends `fs_changed`
to watchers (see Watches).
## The broker ## The broker
Requests to `broker`. The answer is a message whose `reply_to` is the ID of Requests to `broker`. The answer is a message whose `reply_to` is the ID of
the request: the request's `type` in past tense (`spawned`, `upgraded`, the request: the request's `type` in past tense (`spawned`, `upgraded`,
`rolled_back`, `stopped`, `started`, `removed`, `subscribed`, `unsubscribed`, `rolled_back`, `stopped`, `started`, `removed`, `subscribed`, `unsubscribed`,
`tapping`, `untapped`, `scanned`, `exiting`), or `{"type": "error", "reason": "..."}`. `tapping`, `untapped`, `watching`, `unwatched`, `exiting`), or `{"type": "error", "reason": "..."}`.
| `type` | Fields | Effect | | `type` | Fields | Effect |
| --- | --- | --- | | --- | --- | --- |
| `spawn` | `actor`, `wasm`, `source`?, `built_by`? | test, then start a new actor | | `spawn` | `actor`, `wasm`, `source`?, `built_by`? | test, then start a new actor |
| `upgrade` | `actor`, `wasm`, `source`?, `built_by`? | test, then swap the version | | `upgrade` | `actor`, `wasm`, `source`?, `built_by`? | test, then swap the version: a running actor is stopped (normal shutdown) and starts the new version; a stopped actor gets the new version and stays stopped, with its mailbox untouched, until `start`. The answer has `running` |
| `rollback` | `actor`, `version` | go back to an older version | | `rollback` | `actor`, `version` | go back to an older version; like `upgrade`, a stopped actor stays stopped. The answer has `running` |
| `stop`, `start` | `actor` | stop or start an actor | | `stop`, `start` | `actor` | stop or start an actor |
| `remove` | `actor` | stop the actor (normal shutdown), then delete its whole folder `/actor/<id>/` (all versions, `data/`, `public/`, logs); answer `{"type": "removed", "actor"}`. Messages to it are then undeliverable, and the ID can be spawned again. An unknown actor or a native one (`broker`, `console`, `cargo`, `fs`) is an `error` | | `remove` | `actor` | stop the actor (normal shutdown), then delete its whole folder `/actor/<id>/` (all versions, `data/`, `public/`, logs); answer `{"type": "removed", "actor"}`. Messages to it are then undeliverable, and the ID can be spawned again. An unknown actor or a native one (`broker`, `console`, `cargo`, `fs`) is an `error` |
| `subscribe`, `unsubscribe` | `actor` | receive (or not) what `actor` publishes | | `subscribe`, `unsubscribe` | `actor` | receive (or not) what `actor` publishes |
| `tap`, `untap` | | receive (or not) a copy of every message the broker routes | | `tap`, `untap` | | receive (or not) a copy of every message the broker routes |
| `scan` | | boot new or changed `actor.wasm` files | | `watch`, `unwatch` | `path` | receive (or not) `fs_changed` for every change of `path` or anything under it; answer `{"type": "watching" \| "unwatched", "path"}` with the path as an absolute world path |
| `exit` | | answer `exiting`, stop every actor, and end the `sausage` process (a wrapper like `nix run .#loop` starts it again, updated) | | `exit` | | answer `exiting`, stop every actor, and end the `sausage` process with exit code 75 (a wrapper like `nix run .#loop` starts it again, updated) |
Paths are `/actor/...`, or the sender's `/public/...` or `/data/...`. **Headless.** `sausage run --headless [dir]` (or `sausage --headless`, or `SAUSAGE_HEADLESS=1`) turns the console input off: the host does not read stdin, so end of input (`/dev/null`) does not stop the world. `nix run .#loop -- --headless <world>` passes it through the loop. It runs until SIGTERM or SIGINT (stops every actor, exit code 0) or a broker `exit` request (exit code 75, as above). Without the flag, EOF on stdin or `/quit` ends the host.
Paths are absolute world paths, or the sender's `/self/...`, `/public/...` or
`/data/...` aliases. Native senders (such as the console) can also supply
absolute host paths; apart from `/actor` and these aliases,
these retain their host meaning.
**Taps.** After `tap`, the sender gets a copy of every message that the **Taps.** After `tap`, the sender gets a copy of every message that the
broker routes (direct messages, replies, and publishes, between any actors, broker routes (direct messages, replies, and publishes, between any actors,
@@ -144,6 +171,31 @@ including its own), for debugging, logging, or a live view of the world:
- Like subscriptions, a tap is saved in `actor.json` (`"tapped": true`) and - Like subscriptions, a tap is saved in `actor.json` (`"tapped": true`) and
survives restarts and upgrades. `untap` ends it. survives restarts and upgrades. `untap` ends it.
**Watches.** The host watches the whole world folder with the OS file
watcher: every change inside it counts, whoever makes it (actors, a 9P mount,
a shell or `git` on the host). Actor-served files (`/actor/<id>/fs/`) are not
on disk and do not count. After `watch {path}`, the sender gets for each
change of `path` or a path under it (`/actor/a` covers `/actor/a/x`, not
`/actor/ab`):
```json
{"type": "fs_changed", "path": "/actor/notes/public/list.md", "op": "write"}
```
- `op` is `write`, `create`, `remove`, or `rename` (then `to` is the new
path; a watcher of either path gets it).
- Changes of one path within about 100 ms become one event (a `write` after
a `create` stays a `create`).
- The host's temporary files (names starting with `.sausage-tmp-`) are
skipped; renaming one onto a file is a `write` of that file.
- Events come from `fs`. Like tap copies, they are not routed, a full
mailbox drops them, and they never block the writer. Never answer them.
- If the OS drops events, every watcher gets one
`{"type": "fs_changed", "path": "/", "op": "rescan"}`: anything may
have changed.
- A watch is saved in `actor.json` (`"watches": [...]`) and survives
restarts and upgrades. `unwatch` with the same path ends it.
**Versions.** A new version (from `spawn`, `upgrade`, or a changed **Versions.** A new version (from `spawn`, `upgrade`, or a changed
`actor.wasm` on disk) is tested first, while the old version keeps running. `actor.wasm` on disk) is tested first, while the old version keeps running.
Only when its tests pass does the broker copy it to `versions/<n>/` Only when its tests pass does the broker copy it to `versions/<n>/`
@@ -175,18 +227,23 @@ instance. A version without test files passes.
**Swap.** The broker holds the mailbox, stops the old instance, makes the **Swap.** The broker holds the mailbox, stops the old instance, makes the
top-level `actor.wasm` and `source/` a copy of the new version, and starts top-level `actor.wasm` and `source/` a copy of the new version, and starts
it. A rollback does the same with the older version. it. A stopped actor is not started: it keeps its mailbox and runs the new
version at the next `start`. A rollback does the same with the older version.
**Crashes.** The broker restarts a crashed actor after 0.5 s, then doubles **Crashes.** The broker restarts a crashed actor after 0.5 s, then doubles
the delay up to 30 s. A new version that crashes 3 times within 30 seconds the delay up to 30 s. A new version that crashes 3 times within 30 seconds
after an upgrade goes back to the previous version. The actor that asked for after an upgrade goes back to the previous version. The actor that asked for
the upgrade gets `{"type": "rolled_back", "from", "to", "reason", "last_exit"}`. the upgrade gets `{"type": "rolled_back", "from", "to", "reason", "last_exit"}`.
**The folder is the truth.** At start, and on `scan`, the broker boots every **The folder is the truth.** At start, the broker boots every
`actor/<id>/actor.wasm`. If the file differs from the installed version, it `actor/<id>/actor.wasm`. Afterwards, the file watcher reports a new or
is a new version, tested with the tests in `actor/<id>/source/`. If its changed `actor/<id>/actor.wasm` (or a new folder `actor/<id>/`) once it is
tests fail, the file stays where it is, the reason goes to `stderr.log`, and quiet for about 100 ms: a new file becomes a new actor, a changed one an
the next scan tries again. upgrade, tested first with the tests in `actor/<id>/source/`. A file equal
to the installed version changes nothing. If its tests fail, the file stays
where it is, the reason goes to `stderr.log`, and the next change of the
file tries again. When the OS drops events, the broker checks every actor
folder again, as at start.
## The `cargo` actor ## The `cargo` actor
@@ -194,9 +251,24 @@ the next scan tries again.
{"type": "build", "source": "/actor/agent/public/drafts/notes"} {"type": "build", "source": "/actor/agent/public/drafts/notes"}
``` ```
The reply is `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", "built_by": "cargo", ...}` The build request may include `"skip_tests": true` to skip Rust unit tests.
or `{"type": "build_failed", "errors": "..."}`. Pass `wasm`, `source`, and By default, `cargo` first runs `cargo test` for the host target (not WASI),
`built_by` on to `spawn` or `upgrade`. so pure-logic unit tests in a `wasm32-wasip2` actor crate can run without a
WASI test runner. If tests fail or cannot compile, the build fails before the
WASI build. The reply carries `tests: {"passed": <count>, "failed": <count>,
"output": "<cargo test stdout and stderr>"}`; counts sum Cargo's test-result
summaries, and a test command/compilation failure without a summary counts as
one failure. With `skip_tests: true`, counts are zero and output says tests
were skipped.
The reply is `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", "built_by": "cargo", "tests": {...}, ...}`
or `{"type": "build_failed", "errors": "...", "tests": {...}}`. Pass
`wasm`, `source`, and `built_by` on to `spawn` or `upgrade`.
Build scripts can compile C for `wasm32-wasip2`: the host sets
`CC_wasm32_wasip2` and `AR_wasm32_wasip2` to a WASI clang with wasi-libc, so
the `cc` crate (for example `rusqlite` with `bundled`) works. Its headers are
WASI 0.1: C code that needs sockets does not build.
A Rust actor depends on the SDK with `sausage-actor = "0.1"`. `cargo` points A Rust actor depends on the SDK with `sausage-actor = "0.1"`. `cargo` points
that dependency at the SDK of the host, which it publishes at that dependency at the SDK of the host, which it publishes at
+38 -10
View File
@@ -10,28 +10,55 @@ change its own code.
## Start ## Start
You need [Nix](https://nixos.org/download) with flakes. You need [Nix](https://nixos.org/download) with flakes, and an API token for
an LLM (OpenAI Responses or Anthropic Messages API). No clone needed:
```sh ```sh
nix run -- init # creates ./world and builds the agent (a few minutes) nix run git+https://gitea.surma.technology/surma/sausage
nix run -- run # starts the world and a console ```
This creates a world in `./world` the first time (it builds the agent, which
takes a few minutes), then starts it with a console. Later runs start the
existing world. The two steps on their own:
```sh
nix run git+https://gitea.surma.technology/surma/sausage -- init # creates ./world and builds the agent
nix run git+https://gitea.surma.technology/surma/sausage -- run # starts the world and a console
``` ```
`init` asks for the API shape (`openai-responses` or `anthropic-messages`), `init` asks for the API shape (`openai-responses` or `anthropic-messages`),
the base URL, the model, and the token. It reads `SAUSAGE_API`, the base URL, the model, and the token. It reads `SAUSAGE_API`,
`SAUSAGE_BASE_URL`, `SAUSAGE_MODEL`, and `SAUSAGE_API_TOKEN` first. The `SAUSAGE_BASE_URL`, `SAUSAGE_MODEL`, and `SAUSAGE_API_TOKEN` first. The
values go to `world/actor/agent/data/config.json`. Both commands take another values go to `world/actor/agent/data/config.json`. Both commands take another
folder as an argument, for example `nix run -- run my-world`. folder as an argument, for example `... -- run my-world`.
To keep a world on the newest version, run it in a loop: To keep a world on the newest version, run it in a loop:
```sh ```sh
nix run .#loop -- my-world # or .#loop-internal (Gitea on the LAN) nix run git+https://gitea.surma.technology/surma/sausage#loop # ./world, created if needed
nix run git+https://gitea.surma.technology/surma/sausage#loop -- my-world # an existing world
``` ```
The loop runs `nix run --refresh git+ssh://…/surma/sausage -- run my-world`. The loop runs `nix run --refresh git+https://gitea.surma.technology/surma/sausage -- run my-world`.
When the host crashes or an actor sends `@broker {"type":"exit"}`, the loop When the host crashes or an actor sends `@broker {"type":"exit"}`, the loop
fetches the newest commit and starts it again. `/quit` ends the loop. fetches the newest commit and starts it again. `/quit` ends the loop. Set
`SAUSAGE_FLAKE` to run another flake URL instead, for example your fork.
From a clone (`git clone https://gitea.surma.technology/surma/sausage`), the
same commands are `nix run`, `nix run -- init`, `nix run -- run` and
`nix run .#loop`.
### Headless
`--headless` (or `SAUSAGE_HEADLESS=1`) turns the console input off: the host
never reads stdin, so end of input does not stop the world. It runs until
SIGTERM/SIGINT (clean stop, exit code 0) or a broker `exit` request (exit
code 75, so the loop starts the newest version). Create the world first
(`init`), then pass the flag through the loop:
```sh
nix run git+https://gitea.surma.technology/surma/sausage#loop -- --headless my-world
```
In the console: In the console:
@@ -51,14 +78,15 @@ actor.wasm the current version
actor.json installed version, running, subscriptions actor.json installed version, running, subscriptions
source/ the source of the current version source/ the source of the current version
public/ files that the actor publishes public/ files that the actor publishes
data/ the actor's state data/ actor state; owner-writable, world-readable via /actor/<id>/data/ (not private)
stderr.log what the actor and the broker write about it stderr.log what the actor and the broker write about it
versions/<n>/ every installed version: actor.wasm, source/, meta.json versions/<n>/ every installed version: actor.wasm, source/, meta.json
``` ```
To add or replace an actor by hand, put an `actor.wasm` (and its `source/`, To add or replace an actor by hand, put an `actor.wasm` (and its `source/`,
with the tests in `source/tests/`) into its folder. Then restart the world or with the tests in `source/tests/`) into its folder; the host watches the
send `@broker {"type":"scan"}`. The broker tests the file before it uses it. world folder and picks the file up once it stops changing. The broker tests
the file before it uses it.
If the tests fail, the old version keeps running, and `stderr.log` says why. If the tests fail, the old version keeps running, and `stderr.log` says why.
## More ## More
+84 -10
View File
@@ -14,11 +14,49 @@ pub struct Config {
pub base_url: String, pub base_url: String,
pub model: String, pub model: String,
pub token: String, pub token: String,
/// Optional reasoning effort for OpenAI models ("low", "medium", ...). /// Optional reasoning effort for OpenAI models only (wins over `thinking` there).
#[serde(default)] #[serde(default)]
pub reasoning_effort: Option<String>, pub reasoning_effort: Option<String>,
#[serde(default)] #[serde(default)]
pub max_tokens: Option<u32>, pub max_tokens: Option<u32>,
/// Thinking effort for both APIs: "low", "medium", "high", "xhigh", "max" (`LEVELS`), or "off"/unset for none.
/// Anthropic: adaptive thinking + output_config.effort; OpenAI: reasoning.effort. A level the model rejects is
/// replaced by the nearest accepted one (logged), never by "off".
#[serde(default)]
pub thinking: Option<String>,
}
/// Thinking levels, lowest first.
pub const LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
/// The level nearest to `want` (lower on a tie) among those a 400 error lists in quotes (else all levels), not
/// yet `tried`. None: nothing left, so the request goes out without an effort field (model default).
fn fallback_effort(want: &str, error: &str, tried: &[String]) -> Option<String> {
let listed: Vec<&str> = error.split('\'').skip(1).step_by(2).filter(|w| LEVELS.contains(w)).collect();
let pool = if listed.iter().any(|l| *l != want) { listed } else { LEVELS.to_vec() };
let at = LEVELS.iter().position(|l| *l == want).unwrap_or(1) as i32;
pool.into_iter()
.filter(|l| *l != want && !tried.iter().any(|t| t == l))
.min_by_key(|l| {
let i = LEVELS.iter().position(|x| x == l).unwrap_or(1) as i32;
((i - at).abs(), i > at)
})
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::fallback_effort;
#[test]
fn nearest_accepted_level() {
let anthropic = "HTTP 400: output_config.effort: Input should be 'low', 'medium', 'high' or 'max'";
assert_eq!(fallback_effort("xhigh", anthropic, &["xhigh".into()]).as_deref(), Some("high"));
let openai = "HTTP 400: Invalid value: 'ultra'. Supported values are: 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', and 'max'.";
assert_eq!(fallback_effort("ultra", openai, &["ultra".into()]).as_deref(), Some("medium"));
// No list in the error: step to the neighbour, never to off.
assert_eq!(fallback_effort("max", "HTTP 400: effort max not supported", &["max".into()]).as_deref(), Some("xhigh"));
}
} }
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
@@ -32,7 +70,7 @@ pub enum Entry {
}, },
ToolCall { id: String, name: String, arguments: String }, ToolCall { id: String, name: String, arguments: String },
ToolResult { id: String, output: String }, ToolResult { id: String, output: String },
/// A provider item that only this API shape understands (OpenAI reasoning). /// A provider item that only this API shape understands (OpenAI reasoning, Anthropic thinking).
Opaque { api: String, item: Value }, Opaque { api: String, item: Value },
} }
@@ -43,10 +81,28 @@ pub struct Tool {
} }
pub async fn complete(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> { pub async fn complete(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
match cfg.api.as_str() { let want = match cfg.api.as_str() {
"openai-responses" => openai(cfg, system, tools, history).await, "openai-responses" => cfg.reasoning_effort.clone().or_else(|| cfg.thinking.clone()),
"anthropic-messages" => anthropic(cfg, system, tools, history).await, _ => cfg.thinking.clone(),
other => Err(format!("unknown API shape {other:?}")), }
.filter(|t| t != "off");
let mut effort = want.clone();
let mut tried = Vec::new();
loop {
let result = match cfg.api.as_str() {
"openai-responses" => openai(cfg, system, tools, history, effort.as_deref()).await,
"anthropic-messages" => anthropic(cfg, system, tools, history, want.is_some(), effort.as_deref()).await,
other => return Err(format!("unknown API shape {other:?}")),
};
match (result, effort.clone()) {
// The model rejected the effort level: retry with the nearest accepted one (thinking stays on).
(Err(e), Some(current)) if e.starts_with("HTTP 400") && e.contains("effort") => {
tried.push(current.clone());
effort = fallback_effort(want.as_deref().unwrap_or(""), &e, &tried);
eprintln!("agent: {} rejected thinking effort {current:?}; retrying with {effort:?}: {e}", cfg.model);
}
(result, _) => return result,
}
} }
} }
@@ -67,7 +123,7 @@ async fn post(url: &str, headers: &[(&str, String)], body: &Value) -> Result<Val
serde_json::from_str(&text).map_err(|e| format!("invalid JSON in response: {e}")) 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> { async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry], effort: Option<&str>) -> Result<Vec<Entry>, String> {
let mut input = Vec::new(); let mut input = Vec::new();
for e in history { for e in history {
match e { match e {
@@ -104,7 +160,7 @@ async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -
if let Some(max_tokens) = cfg.max_tokens { if let Some(max_tokens) = cfg.max_tokens {
body["max_output_tokens"] = json!(max_tokens); body["max_output_tokens"] = json!(max_tokens);
} }
if let Some(effort) = &cfg.reasoning_effort { if let Some(effort) = effort {
body["reasoning"] = json!({"effort": effort}); body["reasoning"] = json!({"effort": effort});
} }
let url = format!("{}/v1/responses", cfg.base_url); let url = format!("{}/v1/responses", cfg.base_url);
@@ -143,7 +199,14 @@ async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -
Ok(out) Ok(out)
} }
async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> { async fn anthropic(
cfg: &Config,
system: &str,
tools: &[Tool],
history: &[Entry],
thinking: bool,
effort: Option<&str>,
) -> Result<Vec<Entry>, String> {
// Merge consecutive blocks of the same role into one message. // Merge consecutive blocks of the same role into one message.
let mut messages: Vec<(String, Vec<Value>)> = Vec::new(); let mut messages: Vec<(String, Vec<Value>)> = Vec::new();
let mut push = |role: &str, block: Value| match messages.last_mut() { let mut push = |role: &str, block: Value| match messages.last_mut() {
@@ -163,6 +226,8 @@ async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]
Entry::ToolResult { id, output } => { Entry::ToolResult { id, output } => {
push("user", json!({"type": "tool_result", "tool_use_id": id, "content": output})) push("user", json!({"type": "tool_result", "tool_use_id": id, "content": output}))
} }
// Thinking blocks go back unchanged (with their signature): required next to tool use.
Entry::Opaque { api, item } if api == "anthropic-messages" && thinking => push("assistant", item.clone()),
Entry::Opaque { .. } => {} Entry::Opaque { .. } => {}
} }
} }
@@ -171,13 +236,19 @@ async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]
.iter() .iter()
.map(|t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters})) .map(|t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters}))
.collect(); .collect();
let body = json!({ let mut body = json!({
"model": cfg.model, "model": cfg.model,
"system": system, "system": system,
"messages": messages, "messages": messages,
"tools": tools, "tools": tools,
"max_tokens": cfg.max_tokens.unwrap_or(32000), "max_tokens": cfg.max_tokens.unwrap_or(32000),
}); });
if thinking {
body["thinking"] = json!({"type": "adaptive"});
if let Some(effort) = effort {
body["output_config"] = json!({"effort": effort});
}
}
let url = format!("{}/v1/messages", cfg.base_url); let url = format!("{}/v1/messages", cfg.base_url);
let resp = post( let resp = post(
&url, &url,
@@ -199,6 +270,9 @@ async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]
name: block["name"].as_str().unwrap_or_default().to_string(), name: block["name"].as_str().unwrap_or_default().to_string(),
arguments: block["input"].to_string(), arguments: block["input"].to_string(),
}), }),
Some("thinking") | Some("redacted_thinking") => {
out.push(Entry::Opaque { api: "anthropic-messages".into(), item: block })
}
_ => {} _ => {}
} }
} }
+2
View File
@@ -3,3 +3,5 @@ People talk to you through other actors. Your text goes to your subscribers.
Before writing an actor, read `/actor/broker/public/PROTOCOL.md` for messages, the broker, `cargo`, and tests. Before writing an actor, read `/actor/broker/public/PROTOCOL.md` for messages, the broker, `cargo`, and tests.
Read the SDK at `/actor/cargo/public/sdk/` before you write an actor. Read the SDK at `/actor/cargo/public/sdk/` before you write an actor.
You can change your own source at `/actor/agent/source`. You can change your own source at `/actor/agent/source`.
The global filesystem is readable by every actor, including every actor's `/data` at `/actor/<id>/data/`. An actor can write only its own `/data` and `/public`. `/data` is not private: any unprotected secret file there is readable by every actor.
+1
View File
@@ -15,6 +15,7 @@ clap = { version = "4", features = ["derive"] }
http = "1" http = "1"
http-body-util = "0.1" http-body-util = "0.1"
include_dir = "0.7" include_dir = "0.7"
notify = "8"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
+119 -28
View File
@@ -1,11 +1,12 @@
//! The broker: message routing, mailboxes, subscriptions, taps, the version //! The broker: message routing, mailboxes, subscriptions, taps, watches, the
//! store, and the requests to `broker` (spawn, upgrade, rollback, stop, start, //! version store, and the requests to `broker` (spawn, upgrade, rollback,
//! remove, subscribe, unsubscribe, tap, untap, scan, exit). `supervisor.rs` //! stop, start, remove, subscribe, unsubscribe, tap, untap, watch, unwatch,
//! runs each WASM actor. //! exit). `supervisor.rs` runs each WASM actor.
//! //!
//! The broker accepts only `.wasm` files. It does not know any source language. //! The broker accepts only `.wasm` files. It does not know any source language.
//! The world folder is the source of truth: `scan` boots every //! The world folder is the source of truth: at start, the broker boots every
//! `actor/<id>/actor.wasm` and treats a changed file as an upgrade. //! `actor/<id>/actor.wasm`; afterwards the file watcher (`watch.rs`) reports
//! changes: a new `actor.wasm` becomes an actor, a changed one an upgrade.
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use std::path::Path; use std::path::Path;
@@ -167,14 +168,19 @@ impl Broker {
/// they are never tapped themselves; a full mailbox drops the copy. /// they are never tapped themselves; a full mailbox drops the copy.
fn tap(&self, tappers: &[String], message: Value) { fn tap(&self, tappers: &[String], message: Value) {
let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64); let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64);
let body = json!({"type": "tapped", "message": message, "ts": ts}); self.inject(tappers, "broker", json!({"type": "tapped", "message": message, "ts": ts}));
}
/// Put a message of the host straight into the mailboxes of `to`: not
/// routed (so not tapped), never blocking, dropped if a mailbox is full.
fn inject(&self, to: &[String], from: &str, body: Value) {
let mailboxes = self.mailboxes.lock().unwrap(); let mailboxes = self.mailboxes.lock().unwrap();
for tapper in tappers { for id in to {
if let Some(tx) = mailboxes.get(tapper) { if let Some(tx) = mailboxes.get(id) {
let _ = tx.try_send(Envelope { let _ = tx.try_send(Envelope {
id: format!("m-{}", self.seq.fetch_add(1, Ordering::Relaxed)), id: format!("m-{}", self.seq.fetch_add(1, Ordering::Relaxed)),
from: "broker".into(), from: from.into(),
to: tapper.clone(), to: id.clone(),
reply_to: None, reply_to: None,
body: body.clone(), body: body.clone(),
}); });
@@ -249,7 +255,9 @@ impl Broker {
} }
InstanceConfig { InstanceConfig {
mounts: vec![ mounts: vec![
Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: false }, Mount { guest: "/".into(), host: self.world.root.clone(), writable: true },
Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: true },
Mount { guest: "/self".into(), host: own.clone(), writable: true },
Mount { guest: "/public".into(), host: own.join("public"), writable: true }, Mount { guest: "/public".into(), host: own.join("public"), writable: true },
Mount { guest: "/data".into(), host: own.join("data"), writable: true }, Mount { guest: "/data".into(), host: own.join("data"), writable: true },
], ],
@@ -325,12 +333,13 @@ impl Broker {
Ok((component, tests)) Ok((component, tests))
} }
/// Make `version` the current `actor.wasm`, `source/`, and `actor.json`. /// Make `version` the current `actor.wasm`, `source/`, and `actor.json`,
pub fn install(&self, id: &str, version: u64) -> Result<()> { /// and record whether the actor runs.
pub fn install(&self, id: &str, version: u64, running: bool) -> Result<()> {
self.world.install_files(id, version)?; self.world.install_files(id, version)?;
self.update_meta(id, |m| { self.update_meta(id, |m| {
m.version = Some(version); m.version = Some(version);
m.running = true; m.running = running;
}) })
} }
@@ -354,7 +363,7 @@ impl Broker {
async fn create_version(self: &Arc<Self>, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result<Value> { async fn create_version(self: &Arc<Self>, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result<Value> {
let (_, tests) = self.try_version(id, meta, wasm, source).await?; let (_, tests) = self.try_version(id, meta, wasm, source).await?;
self.install(id, meta.version)?; self.install(id, meta.version, true)?;
self.start_supervisor(id, meta.version, true); self.start_supervisor(id, meta.version, true);
Ok(json!({"type": "spawned", "actor": id, "version": meta.version, "tests": tests})) Ok(json!({"type": "spawned", "actor": id, "version": meta.version, "tests": tests}))
} }
@@ -406,31 +415,90 @@ impl Broker {
Ok(json!({"type": "removed", "actor": id})) Ok(json!({"type": "removed", "actor": id}))
} }
/// Boot the actors in `actor/`. Called at start and on `scan`. /// Boot the actors in `actor/`: at start, and as a full pass when the
/// file watcher dropped events (`overflowed`).
/// ///
/// - An `actor.wasm` equal to the installed version starts as is. /// - An `actor.wasm` equal to the installed version starts as is.
/// - A new or changed `actor.wasm` is tested where it lies. A new actor /// - A new or changed `actor.wasm` is tested where it lies. A new actor
/// is installed, a changed one is an upgrade. If its tests fail, /// is installed, a changed one is an upgrade. If its tests fail,
/// nothing changes, and the file stays for the next scan. /// nothing changes, and the file stays until it changes again.
pub async fn scan(self: &Arc<Self>) -> Vec<Value> { pub async fn scan(self: &Arc<Self>) -> Vec<Value> {
let mut report = Vec::new(); let mut report = Vec::new();
for id in self.world.actor_ids().unwrap_or_default() { for id in self.world.actor_ids().unwrap_or_default() {
if NATIVE.contains(&id.as_str()) || !self.world.wasm_path(&id).is_file() { if let Some(r) = self.boot_actor(&id).await {
continue;
}
let lock = self.op_lock(&id);
let _guard = lock.lock().await;
if let Some(r) = self.scan_actor(&id).await {
report.push(r); report.push(r);
} }
} }
report report
} }
/// The file watcher saw `actor/<id>/actor.wasm` (or the folder) appear or
/// change: start, install or upgrade `id` as at boot, and log the result.
pub async fn check_actor(self: Arc<Self>, id: String) {
if let Some(result) = self.boot_actor(&id).await {
self.log(&id, &format!("actor.wasm changed: {result}"));
eprintln!("sausage: {result}");
}
}
/// The OS file watcher dropped events: tell every watcher with one
/// `fs_changed {path: "/actor", op: "rescan"}`, then run a full pass.
pub async fn overflowed(self: Arc<Self>) -> Vec<Value> {
self.host_error("the file watcher dropped events; watchers get a rescan, actors a full pass");
let watchers: Vec<String> =
self.metas.lock().unwrap().iter().filter(|(_, m)| !m.watches.is_empty()).map(|(id, _)| id.clone()).collect();
self.inject(&watchers, vfs::ID, json!({"type": "fs_changed", "path": "/", "op": "rescan"}));
let report = self.scan().await;
for change in &report {
eprintln!("sausage: {change}");
}
report
}
/// Send `fs_changed` for one coalesced change (from `watch.rs`) to every
/// actor that watches `path` or `to`. Like taps: straight into the
/// mailbox, dropped if it is full.
pub fn notify_watchers(&self, path: &str, op: &str, to: Option<&str>) {
let watchers: Vec<String> = {
let metas = self.metas.lock().unwrap();
metas
.iter()
.filter(|(_, m)| m.watches.iter().any(|w| under(path, w) || to.is_some_and(|t| under(t, w))))
.map(|(id, _)| id.clone())
.collect()
};
if watchers.is_empty() {
return;
}
let mut body = json!({"type": "fs_changed", "path": path, "op": op});
if let Some(to) = to {
body["to"] = json!(to);
}
self.inject(&watchers, vfs::ID, body);
}
/// A problem of the host itself: the broker's `stderr.log` and the terminal.
pub fn host_error(&self, text: &str) {
self.log("broker", text);
eprintln!("sausage: {text}");
}
async fn boot_actor(self: &Arc<Self>, id: &str) -> Option<Value> {
if NATIVE.contains(&id) || !world::valid_id(id) {
return None;
}
let lock = self.op_lock(id);
let _guard = lock.lock().await;
if !self.world.wasm_path(id).is_file() {
return None;
}
self.scan_actor(id).await
}
async fn scan_actor(self: &Arc<Self>, id: &str) -> Option<Value> { async fn scan_actor(self: &Arc<Self>, id: &str) -> Option<Value> {
let meta = self.world.read_meta(id); // The broker's copy wins: actors change theirs (subscribe, ...) without the op lock.
let meta = self.metas.lock().unwrap().entry(id.to_string()).or_insert_with(|| self.world.read_meta(id)).clone();
let installed = meta.version.filter(|v| self.world.version_dir(id, *v).join("actor.wasm").is_file()); let installed = meta.version.filter(|v| self.world.version_dir(id, *v).join("actor.wasm").is_file());
self.metas.lock().unwrap().insert(id.to_string(), meta.clone());
if let Some(v) = installed if let Some(v) = installed
&& !self.is_supervised(id) && !self.is_supervised(id)
{ {
@@ -529,14 +597,32 @@ impl Broker {
let answer = if on { "tapping" } else { "untapped" }; let answer = if on { "tapping" } else { "untapped" };
Ok(json!({"type": answer})) Ok(json!({"type": answer}))
} }
"scan" => Ok(json!({"type": "scanned", "changes": self.scan().await})), "watch" | "unwatch" => {
let on = ty == "watch";
let path_s = b["path"].as_str().ok_or_else(|| anyhow!("missing \"path\""))?;
let global = self.world.global_path(&self.world.resolve(&env.from, path_s)?);
let path = global.ok_or_else(|| anyhow!("{path_s} is not under the world root"))?;
let path = match path.trim_end_matches('/') {
"" => "/".to_string(),
p => p.to_string(),
};
self.update_meta(&env.from, |m| {
if on {
m.watches.insert(path.clone());
} else {
m.watches.remove(&path);
}
})?;
let answer = if on { "watching" } else { "unwatched" };
Ok(json!({"type": answer, "path": path}))
}
"exit" => { "exit" => {
self.log("broker", &format!("exit requested by {}", env.from)); self.log("broker", &format!("exit requested by {}", env.from));
// notify_one keeps the permit if nobody waits yet. // notify_one keeps the permit if nobody waits yet.
self.exit.notify_one(); self.exit.notify_one();
Ok(json!({"type": "exiting"})) Ok(json!({"type": "exiting"}))
} }
other => bail!("unknown broker request {other:?}. Known: spawn, upgrade, rollback, stop, start, remove, subscribe, unsubscribe, tap, untap, scan, exit"), other => bail!("unknown broker request {other:?}. Known: spawn, upgrade, rollback, stop, start, remove, subscribe, unsubscribe, tap, untap, watch, unwatch, exit"),
} }
} }
@@ -563,6 +649,11 @@ fn same_file(a: &Path, b: &Path) -> bool {
matches!((std::fs::read(a), std::fs::read(b)), (Ok(x), Ok(y)) if x == y) matches!((std::fs::read(a), std::fs::read(b)), (Ok(x), Ok(y)) if x == y)
} }
/// Whether the namespace path `path` is `prefix` or inside it.
fn under(path: &str, prefix: &str) -> bool {
prefix == "/" || path.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}
pub fn truncate(s: &str, max: usize) -> String { pub fn truncate(s: &str, max: usize) -> String {
if s.len() <= max { if s.len() <= max {
return s.to_string(); return s.to_string();
+140 -11
View File
@@ -1,12 +1,16 @@
//! The native `cargo` actor: builds Rust source folders into `.wasm` files. //! The native `cargo` actor: builds Rust source folders into `.wasm` files.
//! //!
//! Request: `{"type": "build", "source": "/actor/<id>/public/<folder>"}` //! Request: `{"type": "build", "source": "/actor/<id>/public/<folder>"}`
//! Replies: `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", ...}` //! optional `"skip_tests": true` skips native `cargo test` (tests run by default).
//! `{"type": "build_failed", "errors": "..."}` //! Replies: `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", "tests": {"passed": 1, "failed": 0, "output": "..."}, ...}`
//! `{"type": "build_failed", "errors": "...", "tests": {"passed": 0, "failed": 1, "output": "..."}}`
//! //!
//! The broker knows nothing about Rust. This actor is one way to get a //! The broker knows nothing about Rust. This actor is one way to get a
//! `.wasm` file. Other actors can add other languages. //! `.wasm` file. Other actors can add other languages.
//! //!
//! Build scripts compile C for WASI with the clang in `CC_wasm32_wasip2`
//! and `AR_wasm32_wasip2`. The Nix package and the dev shell set both.
//!
//! Actors depend on the SDK with `sausage-actor = "0.1"`. The SDK is not //! Actors depend on the SDK with `sausage-actor = "0.1"`. The SDK is not
//! published: this actor copies the SDK of this host to `public/sdk/` (so //! published: this actor copies the SDK of this host to `public/sdk/` (so
//! that every actor can read it), and points the dependency there in the //! that every actor can read it), and points the dependency there in the
@@ -32,11 +36,49 @@ pub const TARGET: &str = "wasm32-wasip2";
/// The source of the `sausage-actor` SDK. /// The source of the `sausage-actor` SDK.
static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk"); static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk");
#[derive(Clone, Default, Debug)]
pub struct TestResults {
pub passed: u64,
pub failed: u64,
pub output: String,
}
impl TestResults {
fn value(&self) -> Value {
json!({"passed": self.passed, "failed": self.failed, "output": self.output})
}
}
pub struct BuildOutput { pub struct BuildOutput {
/// Host path of the published `.wasm` file. /// Host path of the published `.wasm` file.
pub wasm: PathBuf, pub wasm: PathBuf,
pub seconds: f64, pub seconds: f64,
pub warnings: String, pub warnings: String,
pub tests: TestResults,
}
#[derive(Debug)]
pub struct BuildFailure {
pub reason: String,
pub tests: TestResults,
}
impl std::fmt::Display for BuildFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.tests.failed > 0 && !self.tests.output.is_empty() {
write!(f, "{}:\n{}", self.reason, self.tests.output)
} else {
f.write_str(&self.reason)
}
}
}
impl std::error::Error for BuildFailure {}
impl From<anyhow::Error> for BuildFailure {
fn from(error: anyhow::Error) -> Self {
Self { reason: format!("{error:#}"), tests: TestResults::default() }
}
} }
/// All builds share one target folder, so they run one at a time. /// All builds share one target folder, so they run one at a time.
@@ -51,19 +93,44 @@ impl Cargo {
Ok(Self { world }) Ok(Self { world })
} }
/// Build the crate in `source` (a host path) and publish the `.wasm` file /// Run native `cargo test` unless opted out, then build and publish the
/// under `/actor/cargo/public/builds/<n>/`. /// crate's `wasm32-wasip2` binary under `/actor/cargo/public/builds/<n>/`.
pub async fn build(&self, source: &Path) -> Result<BuildOutput> { pub async fn build(&self, source: &Path, skip_tests: bool) -> std::result::Result<BuildOutput, BuildFailure> {
if !source.join("Cargo.toml").is_file() { if !source.join("Cargo.toml").is_file() {
bail!("{} has no Cargo.toml", source.display()); return Err(BuildFailure {
reason: format!("{} has no Cargo.toml", source.display()),
tests: TestResults::default(),
});
} }
let started = Instant::now(); let started = Instant::now();
let data = self.world.data_dir(ID); let data = self.world.data_dir(ID);
let build_dir = data.join("work"); let build_dir = data.join("work");
world::replace_source(source, &build_dir)?; world::replace_source(source, &build_dir)?;
let manifest = build_dir.join("Cargo.toml"); let manifest = build_dir.join("Cargo.toml");
std::fs::write(&manifest, prepare_manifest(&std::fs::read_to_string(&manifest)?, Some(&self.world.public_dir(ID).join("sdk")))?)?; let original = std::fs::read_to_string(&manifest).context("read build manifest")?;
let prepared = prepare_manifest(&original, Some(&self.world.public_dir(ID).join("sdk")))?;
std::fs::write(&manifest, prepared).context("write prepared manifest")?;
let target_dir = data.join("target");
let tests = if skip_tests {
TestResults { output: "skipped (skip_tests=true)".into(), ..TestResults::default() }
} else {
match run_tests(&build_dir, &target_dir).await {
Ok(tests) => tests,
Err(error) => {
let output = format!("cannot run cargo tests: {error:#}");
return Err(BuildFailure {
reason: output.clone(),
tests: TestResults { failed: 1, output, ..TestResults::default() },
});
}
}
};
if tests.failed > 0 {
return Err(BuildFailure { reason: "unit tests failed".into(), tests });
}
let build_result: Result<BuildOutput> = async {
let mut cmd = Command::new("cargo"); let mut cmd = Command::new("cargo");
cmd.arg("build") cmd.arg("build")
.arg("--release") .arg("--release")
@@ -71,7 +138,7 @@ impl Cargo {
.arg(TARGET) .arg(TARGET)
.arg("--message-format=json-render-diagnostics") .arg("--message-format=json-render-diagnostics")
.arg("--target-dir") .arg("--target-dir")
.arg(data.join("target")) .arg(&target_dir)
.current_dir(&build_dir) .current_dir(&build_dir)
.kill_on_drop(true); .kill_on_drop(true);
let out = cmd.output().await.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?; let out = cmd.output().await.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?;
@@ -95,7 +162,15 @@ impl Cargo {
let name = artifact.file_name().context("artifact has no file name")?; let name = artifact.file_name().context("artifact has no file name")?;
let dest = dest_dir.join(name); let dest = dest_dir.join(name);
world::write_atomic(&dest, &std::fs::read(&artifact)?)?; world::write_atomic(&dest, &std::fs::read(&artifact)?)?;
Ok(BuildOutput { wasm: dest, seconds: started.elapsed().as_secs_f64(), warnings: strip_progress(&stderr) }) Ok(BuildOutput {
wasm: dest,
seconds: started.elapsed().as_secs_f64(),
warnings: strip_progress(&stderr),
tests: tests.clone(),
})
}
.await;
build_result.map_err(|error| BuildFailure { reason: format!("{error:#}"), tests })
} }
/// The native `cargo` actor. Builds run one at a time, in order. /// The native `cargo` actor. Builds run one at a time, in order.
@@ -106,7 +181,7 @@ impl Cargo {
continue; continue;
} }
let body = self.handle(&broker, &env).await.unwrap_or_else(|e| { let body = self.handle(&broker, &env).await.unwrap_or_else(|e| {
json!({"type": "build_failed", "source": env.body.get("source"), "errors": format!("{e:#}")}) json!({"type": "build_failed", "source": env.body.get("source"), "errors": format!("{e:#}"), "tests": TestResults::default().value()})
}); });
broker.send(ID, Some(&env.from), None, Some(&env.id), body); broker.send(ID, Some(&env.from), None, Some(&env.id), body);
} }
@@ -117,7 +192,9 @@ impl Cargo {
Some("build") => { Some("build") => {
let source_s = env.body.get("source").and_then(Value::as_str).ok_or_else(|| anyhow!("missing \"source\""))?; 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 source = broker.world.resolve(&env.from, source_s)?;
let out = self.build(&source).await?; let skip_tests = env.body.get("skip_tests").and_then(Value::as_bool).unwrap_or(false);
match self.build(&source, skip_tests).await {
Ok(out) => {
let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string()); let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string());
Ok(json!({ Ok(json!({
"type": "built", "type": "built",
@@ -126,13 +203,65 @@ impl Cargo {
"built_by": ID, "built_by": ID,
"seconds": (out.seconds * 10.0).round() / 10.0, "seconds": (out.seconds * 10.0).round() / 10.0,
"warnings": out.warnings, "warnings": out.warnings,
"tests": out.tests.value(),
})) }))
} }
Err(failure) => Ok(json!({
"type": "build_failed",
"source": source_s,
"errors": failure.reason,
"tests": failure.tests.value(),
})),
}
}
other => bail!("unknown request {other:?}. Known: build"), other => bail!("unknown request {other:?}. Known: build"),
} }
} }
} }
/// Run tests for the host triple: WASI crates' pure-logic tests can use the
/// normal Rust test harness without needing a WASI test runner.
async fn run_tests(build_dir: &Path, target_dir: &Path) -> Result<TestResults> {
let mut cmd = Command::new("cargo");
cmd.arg("test")
.arg("--target-dir")
.arg(target_dir)
.current_dir(build_dir)
.kill_on_drop(true);
let out = cmd.output().await.context("cannot run cargo test")?;
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
let combined = match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => String::new(),
(false, true) => stdout,
(true, false) => stderr,
(false, false) => format!("{stdout}\n{stderr}"),
};
let output = strip_progress(&combined);
let passed = test_count(&output, "passed");
let mut failed = test_count(&output, "failed");
// Compilation failures and runner errors have no `test result` summary,
// but still make the default test phase fail.
if !out.status.success() && failed == 0 {
failed = 1;
}
Ok(TestResults { passed, failed, output })
}
/// Count the `passed` or `failed` totals in cargo's per-target test summaries.
fn test_count(output: &str, field: &str) -> u64 {
output
.lines()
.filter_map(|line| line.split_once("test result:").map(|(_, rest)| rest))
.flat_map(|summary| summary.split(';'))
.filter_map(|part| {
let words: Vec<_> = part.split_whitespace().collect();
let index = words.iter().position(|word| *word == field)?;
words.get(index.checked_sub(1)?)?.parse::<u64>().ok()
})
.sum()
}
/// Point a `sausage-actor` dependency at `sdk` (keeping its features), and /// Point a `sausage-actor` dependency at `sdk` (keeping its features), and
/// add an empty `[workspace]`, so the crate never joins a parent workspace. /// add an empty `[workspace]`, so the crate never joins a parent workspace.
fn prepare_manifest(text: &str, sdk: Option<&Path>) -> Result<String> { fn prepare_manifest(text: &str, sdk: Option<&Path>) -> Result<String> {
+2 -1
View File
@@ -3,7 +3,8 @@
//! - `@<actor> <json>` sends a JSON body. `@<actor> <text>` sends //! - `@<actor> <json>` sends a JSON body. `@<actor> <text>` sends
//! `{"type": "text", "text": ...}`. //! `{"type": "text", "text": ...}`.
//! - A line without `@` goes to `agent` as text. //! - A line without `@` goes to `agent` as text.
//! - `/help`, `/quit`. EOF on stdin also quits. //! - `/help`, `/quit`. EOF on stdin also quits (not with `--headless`, which
//! never reads stdin).
use std::sync::Arc; use std::sync::Arc;
+15 -7
View File
@@ -1,7 +1,8 @@
//! Sausage: grow a system of WASI actors by talking to it. //! Sausage: grow a system of WASI actors by talking to it.
//! //!
//! The host runs the broker, the native actors (`broker`, `cargo`, and the //! The host runs the broker, the native actors (`broker`, `cargo`, and the
//! console), and one supervisor per WASM actor. See `PROTOCOL.md`. //! console), one supervisor per WASM actor, and the file watcher of the
//! world folder (`watch.rs`). See `PROTOCOL.md`.
pub mod broker; pub mod broker;
pub mod candidate; pub mod candidate;
@@ -10,6 +11,7 @@ pub mod console;
pub mod runtime; pub mod runtime;
pub mod supervisor; pub mod supervisor;
pub mod vfs; pub mod vfs;
pub mod watch;
pub mod world; pub mod world;
use std::path::Path; use std::path::Path;
@@ -30,12 +32,13 @@ pub struct Host {
pub world: World, pub world: World,
pub broker: Arc<Broker>, pub broker: Arc<Broker>,
native: Vec<tokio::task::JoinHandle<()>>, native: Vec<tokio::task::JoinHandle<()>>,
watcher: Option<watch::Watcher>,
_lock: std::fs::File, _lock: std::fs::File,
} }
impl Host { impl Host {
/// Start the broker, `cargo`, and every registered actor of the world at /// Start the broker, `cargo`, the file watcher, and every registered
/// `root`. Only one host can run a world at a time. /// actor of the world at `root`. Only one host can run a world at a time.
pub async fn start(root: &Path) -> Result<Host> { pub async fn start(root: &Path) -> Result<Host> {
let world = World::new(std::path::absolute(root)?); let world = World::new(std::path::absolute(root)?);
if !world.exists() { if !world.exists() {
@@ -53,10 +56,14 @@ impl Host {
tokio::spawn(broker.clone().run_actor(broker.open_mailbox("broker"))), tokio::spawn(broker.clone().run_actor(broker.open_mailbox("broker"))),
tokio::spawn(cargo.run_actor(broker.clone(), broker.open_mailbox(cargo_actor::ID))), tokio::spawn(cargo.run_actor(broker.clone(), broker.open_mailbox(cargo_actor::ID))),
]; ];
// Watch first, so that no change during the boot pass is lost.
let watcher = watch::start(broker.clone())
.inspect_err(|e| broker.host_error(&format!("no file watching: {e}")))
.ok();
for change in broker.scan().await { for change in broker.scan().await {
eprintln!("sausage: {change}"); eprintln!("sausage: {change}");
} }
Ok(Host { world, broker, native, _lock: lock }) Ok(Host { world, broker, native, watcher, _lock: lock })
} }
/// Register a native actor with the ID `id` and return its mailbox. /// Register a native actor with the ID `id` and return its mailbox.
@@ -64,9 +71,10 @@ impl Host {
self.broker.open_mailbox(id) self.broker.open_mailbox(id)
} }
/// Stop the broker and `cargo` (with the requests they still run), then /// Stop the file watcher, the broker and `cargo` (with the requests they
/// every actor, gracefully. /// still run), then every actor, gracefully.
pub async fn stop(self) { pub async fn stop(mut self) {
drop(self.watcher.take());
for task in &self.native { for task in &self.native {
task.abort(); task.abort();
} }
+53 -7
View File
@@ -5,6 +5,7 @@ use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use include_dir::{Dir, include_dir}; use include_dir::{Dir, include_dir};
use serde_json::json; use serde_json::json;
use tokio::signal::unix::Signal;
use sausage::world::{ActorMeta, World}; use sausage::world::{ActorMeta, World};
use sausage::{Host, cargo_actor, console}; use sausage::{Host, cargo_actor, console};
@@ -12,11 +13,18 @@ use sausage::{Host, cargo_actor, console};
/// The source of the agent actor. `init` builds it like any other actor. /// The source of the agent actor. `init` builds it like any other actor.
static AGENT_SOURCE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent"); static AGENT_SOURCE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent");
// Without a command, `sausage` runs the world in `./world` and creates it
// first (like `init`) if it does not exist yet.
#[derive(Parser)] #[derive(Parser)]
#[command(version, about)] #[command(version, about)]
struct Cli { struct Cli {
/// Do not read stdin, so end of input does not stop the world. It runs
/// until SIGTERM (or SIGINT), or until an actor sends
/// `@broker {"type":"exit"}`. Also set by SAUSAGE_HEADLESS=1.
#[arg(long, global = true)]
headless: bool,
#[command(subcommand)] #[command(subcommand)]
command: Cmd, command: Option<Cmd>,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -83,19 +91,47 @@ async fn init(dir: PathBuf) -> Result<()> {
std::fs::create_dir_all(&source)?; std::fs::create_dir_all(&source)?;
AGENT_SOURCE.extract(&source).context("extract agent source")?; AGENT_SOURCE.extract(&source).context("extract agent source")?;
println!("Building the agent actor (the first build takes a few minutes) …"); 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")?; let out = cargo_actor::Cargo::new(world.clone())?.build(&source, false).await.context("build agent")?;
std::fs::copy(&out.wasm, world.wasm_path("agent"))?; std::fs::copy(&out.wasm, world.wasm_path("agent"))?;
println!("Built in {:.1} s. Start the world with: sausage run {}", out.seconds, dir.display()); println!("Built in {:.1} s. Start the world with: sausage run {}", out.seconds, dir.display());
Ok(()) Ok(())
} }
async fn run(dir: PathBuf) -> Result<()> { /// Install the SIGTERM and SIGINT handlers now (not when first polled).
fn termination_signals() -> Result<(Signal, Signal)> {
use tokio::signal::unix::{SignalKind, signal};
let term = signal(SignalKind::terminate()).context("install SIGTERM handler")?;
let int = signal(SignalKind::interrupt()).context("install SIGINT handler")?;
Ok((term, int))
}
/// Resolves on SIGTERM or SIGINT.
async fn termination((mut term, mut int): (Signal, Signal)) {
tokio::select! {
_ = term.recv() => {}
_ = int.recv() => {}
}
}
async fn run(dir: PathBuf, headless: bool) -> Result<()> {
// Register the signal handlers before the host starts, so a signal that
// arrives early is never the default (kill) action.
let signals = if headless { Some(termination_signals()?) } else { None };
let host = Host::start(&dir).await?; let host = Host::start(&dir).await?;
tokio::spawn(console::run_mailbox(host.mailbox(console::ID))); tokio::spawn(console::run_mailbox(host.mailbox(console::ID)));
let broker = host.broker.clone(); let broker = host.broker.clone();
let exit = tokio::select! { let exit = match signals {
Some(signals) => {
println!("Sausage headless: running until SIGTERM or an exit request.");
tokio::select! {
_ = termination(signals) => false,
_ = broker.exit_requested() => true,
}
}
None => tokio::select! {
_ = console::run_input(host.broker.clone()) => false, _ = console::run_input(host.broker.clone()) => false,
_ = broker.exit_requested() => true, _ = broker.exit_requested() => true,
},
}; };
println!("Stopping actors …"); println!("Stopping actors …");
host.stop().await; host.stop().await;
@@ -111,8 +147,18 @@ async fn run(dir: PathBuf) -> Result<()> {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
match Cli::parse().command { let cli = Cli::parse();
Cmd::Init { dir } => init(dir).await, let headless = cli.headless || std::env::var("SAUSAGE_HEADLESS").is_ok_and(|v| !v.is_empty() && v != "0");
Cmd::Run { dir } => run(dir).await, match cli.command {
Some(Cmd::Init { dir }) => init(dir).await,
Some(Cmd::Run { dir }) => run(dir, headless).await,
None => {
let dir = PathBuf::from("world");
if !dir.join("actor").is_dir() {
println!("No world in ./world yet, creating it (same as `sausage init`).");
init(dir.clone()).await?;
}
run(dir, headless).await
}
} }
} }
+10 -5
View File
@@ -220,17 +220,16 @@ impl Supervisor {
/// Make `version` current. It is compiled first (unless `component` is /// Make `version` current. It is compiled first (unless `component` is
/// given): if that fails, nothing changes. `data/` and `public/` stay as /// given): if that fails, nothing changes. `data/` and `public/` stay as
/// they are. /// they are. A stopped actor stays stopped.
async fn install(&mut self, version: u64, component: Option<Component>) -> Result<()> { async fn install(&mut self, version: u64, component: Option<Component>) -> Result<()> {
let component = match component { let component = match component {
Some(c) => c, Some(c) => c,
None => self.broker.load_version(&self.id, version)?, None => self.broker.load_version(&self.id, version)?,
}; };
self.stop().await; self.stop().await;
self.broker.install(&self.id, version)?; self.broker.install(&self.id, version, self.running)?;
self.version = version; self.version = version;
self.component = Some(component); self.component = Some(component);
self.running = true;
self.restarts = 0; self.restarts = 0;
self.restart_at = None; self.restart_at = None;
Ok(()) Ok(())
@@ -239,20 +238,26 @@ impl Supervisor {
async fn upgrade(&mut self, version: u64, component: Component, requester: String) -> Result<Value> { async fn upgrade(&mut self, version: u64, component: Component, requester: String) -> Result<Value> {
let previous = self.version; let previous = self.version;
self.install(version, Some(component)).await?; self.install(version, Some(component)).await?;
if !self.running {
// Installed, not started: main runs and mail is read at the next `start`.
return Ok(json!({"type": "upgraded", "actor": self.id, "version": version, "previous": previous, "running": false}));
}
self.probation = Some(Probation { previous, since: Instant::now(), crashes: 0, requester }); self.probation = Some(Probation { previous, since: Instant::now(), crashes: 0, requester });
if let Err(e) = self.start().await { if let Err(e) = self.start().await {
self.rollback(previous).await?; self.rollback(previous).await?;
bail!("version {version} did not start ({e:#}); rolled back to {previous}"); bail!("version {version} did not start ({e:#}); rolled back to {previous}");
} }
Ok(json!({"type": "upgraded", "actor": self.id, "version": version, "previous": previous})) Ok(json!({"type": "upgraded", "actor": self.id, "version": version, "previous": previous, "running": true}))
} }
async fn rollback(&mut self, version: u64) -> Result<Value> { async fn rollback(&mut self, version: u64) -> Result<Value> {
let from = self.version; let from = self.version;
self.install(version, None).await?; self.install(version, None).await?;
self.probation = None; self.probation = None;
if self.running {
self.start().await?; self.start().await?;
Ok(json!({"type": "rolled_back", "actor": self.id, "from": from, "to": version})) }
Ok(json!({"type": "rolled_back", "actor": self.id, "from": from, "to": version, "running": self.running}))
} }
/// Handle a control request. Returns `true` when the supervisor ends. /// Handle a control request. Returns `true` when the supervisor ends.
+96 -16
View File
@@ -4,7 +4,8 @@
//! This module wraps the `wasi:filesystem` implementation of `wasmtime-wasi`. //! This module wraps the `wasi:filesystem` implementation of `wasmtime-wasi`.
//! A path under `/actor/<id>/fs/` does not reach the disk: the host asks the //! A path under `/actor/<id>/fs/` does not reach the disk: the host asks the
//! actor `<id>` with a message from `fs` and waits up to `ASK_TIMEOUT` for //! actor `<id>` with a message from `fs` and waits up to `ASK_TIMEOUT` for
//! the reply. Every other path goes to `wasmtime-wasi` unchanged. //! the reply. Other paths go to `wasmtime-wasi` unchanged except that an
//! actor directory listing advertises `fs/` when its source docs declare it.
//! //!
//! - `fs_read {path}` -> `fs_file {text | base64}`, `fs_dir {entries: [{name, type}]}` //! - `fs_read {path}` -> `fs_file {text | base64}`, `fs_dir {entries: [{name, type}]}`
//! - `fs_write {path, text | base64}`, `fs_mkdir {path}`, `fs_remove {path}`, //! - `fs_write {path, text | base64}`, `fs_mkdir {path}`, `fs_remove {path}`,
@@ -14,7 +15,7 @@
//! A file is read once when it is opened. After a change, it is written back //! A file is read once when it is opened. After a change, it is written back
//! in one `fs_write` when it is synced or closed. //! in one `fs_write` when it is synced or closed.
use std::collections::HashSet; use std::collections::HashMap;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, Weak}; use std::sync::{Arc, Mutex, Weak};
@@ -41,13 +42,14 @@ pub struct VfsState {
broker: Weak<Broker>, broker: Weak<Broker>,
/// The actor of this instance, for notes in its `stderr.log`. /// The actor of this instance, for notes in its `stderr.log`.
me: String, me: String,
/// Descriptors of the `/actor` preopen. /// Native directory descriptors and their absolute paths. Keeping every
roots: HashSet<u32>, /// `/actor` child (not only the preopen) lets `fs/` work after traversal.
dirs: HashMap<u32, String>,
} }
impl VfsState { impl VfsState {
pub fn new(broker: Weak<Broker>, me: String) -> Self { pub fn new(broker: Weak<Broker>, me: String) -> Self {
Self { broker, me, roots: HashSet::new() } Self { broker, me, dirs: HashMap::new() }
} }
} }
@@ -199,6 +201,33 @@ fn join(base: &str, rel: &str) -> String {
format!("/{}", parts.join("/")) format!("/{}", parts.join("/"))
} }
/// Split a global path under an actor's virtual `fs/` into its owner and
/// actor-relative path.
fn actor_fs_path(full: &str) -> Option<(String, String)> {
let rest = full.strip_prefix("/actor/")?;
let (actor, rest) = rest.split_once('/')?;
let (fs, path) = rest.split_once('/').unwrap_or((rest, ""));
(fs == "fs" && !actor.is_empty()).then(|| (actor.to_string(), format!("/{path}")))
}
fn actor_root(path: &str) -> Option<&str> {
let actor = path.strip_prefix("/actor/")?;
(!actor.is_empty() && !actor.contains('/')).then_some(actor)
}
/// An actor advertises its virtual filesystem in its leading `//!` docs.
/// This is discovery metadata; actual reads still go through `fs_read`.
fn declares_actor_fs(source: &str) -> bool {
let docs = source
.lines()
.take_while(|line| line.trim().starts_with("//!"))
.map(|line| line.trim().trim_start_matches("//!").trim())
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase();
docs.contains("actor-served files") && docs.contains("fs_read")
}
fn stat(type_: DescriptorType, size: u64) -> types::DescriptorStat { fn stat(type_: DescriptorType, size: u64) -> types::DescriptorStat {
types::DescriptorStat { types::DescriptorStat {
type_, type_,
@@ -266,6 +295,14 @@ impl VfsView<'_> {
Asker { broker: self.state.broker.upgrade(), me: self.state.me.clone() } Asker { broker: self.state.broker.upgrade(), me: self.state.me.clone() }
} }
fn actor_serves_fs(&self, actor: &str) -> bool {
self.state
.broker
.upgrade()
.and_then(|broker| std::fs::read_to_string(broker.world.source_dir(actor).join("src/main.rs")).ok())
.is_some_and(|source| declares_actor_fs(&source))
}
fn virt(&mut self, fd: &Resource<types::Descriptor>) -> Option<Virt> { fn virt(&mut self, fd: &Resource<types::Descriptor>) -> Option<Virt> {
self.inner.table.get_any_mut(fd.rep()).ok()?.downcast_ref::<Virt>().cloned() self.inner.table.get_any_mut(fd.rep()).ok()?.downcast_ref::<Virt>().cloned()
} }
@@ -282,16 +319,27 @@ impl VfsView<'_> {
match self.virt(fd) { match self.virt(fd) {
Some(Virt::Dir { actor, path: base }) => Some((actor, join(&base, path))), Some(Virt::Dir { actor, path: base }) => Some((actor, join(&base, path))),
Some(Virt::File(f)) => Some((f.actor, join(&f.path, path))), Some(Virt::File(f)) => Some((f.actor, join(&f.path, path))),
None if self.state.roots.contains(&fd.rep()) => { None => {
let full = join("/", path); let base = self.state.dirs.get(&fd.rep())?;
let mut parts = full.splitn(4, '/').skip(1); actor_fs_path(&join(base, path))
let (actor, fs, rest) = (parts.next()?, parts.next()?, parts.next().unwrap_or(""));
(fs == "fs" && !actor.is_empty()).then(|| (actor.to_string(), format!("/{rest}")))
} }
None => None,
} }
} }
async fn native_directory_entries(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Vec<DirectoryEntry>> {
let stream = HostDescriptor::read_directory(&mut self.inner, fd).await?;
let stream_rep = stream.rep();
let mut entries = Vec::new();
loop {
match HostDirectoryEntryStream::read_directory_entry(&mut self.inner, Resource::new_borrow(stream_rep)).await? {
Some(entry) => entries.push(entry),
None => break,
}
}
HostDirectoryEntryStream::drop(&mut self.inner, stream).map_err(|_| ErrorCode::Io)?;
Ok(entries)
}
fn push(&mut self, v: Virt) -> FsResult<Resource<types::Descriptor>> { fn push(&mut self, v: Virt) -> FsResult<Resource<types::Descriptor>> {
let r = self.inner.table.push(v)?; let r = self.inner.table.push(v)?;
Ok(Resource::new_own(r.rep())) Ok(Resource::new_own(r.rep()))
@@ -342,8 +390,8 @@ impl preopens::Host for VfsView<'_> {
fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<types::Descriptor>, String)>> { fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<types::Descriptor>, String)>> {
let dirs = preopens::Host::get_directories(&mut self.inner)?; let dirs = preopens::Host::get_directories(&mut self.inner)?;
for (fd, name) in &dirs { for (fd, name) in &dirs {
if name == "/actor" { if name == "/actor" || name == "/" {
self.state.roots.insert(fd.rep()); self.state.dirs.insert(fd.rep(), name.clone());
} }
} }
Ok(dirs) Ok(dirs)
@@ -453,7 +501,19 @@ impl HostDescriptor for VfsView<'_> {
Node::File(_) => Err(ErrorCode::NotDirectory.into()), Node::File(_) => Err(ErrorCode::NotDirectory.into()),
}, },
Some(Virt::File(_)) => Err(ErrorCode::NotDirectory.into()), Some(Virt::File(_)) => Err(ErrorCode::NotDirectory.into()),
None => HostDescriptor::read_directory(&mut self.inner, fd).await, None => {
let actor = self.state.dirs.get(&fd.rep()).and_then(|path| actor_root(path)).map(str::to_string);
if actor.as_deref().is_some_and(|id| self.actor_serves_fs(id)) {
let mut entries = self.native_directory_entries(fd).await?;
if !entries.iter().any(|entry| entry.name == "fs") {
entries.push(DirectoryEntry { type_: DescriptorType::Directory, name: "fs".to_string() });
}
let r = self.inner.table.push(VDir(entries.into_iter()))?;
Ok(Resource::new_own(r.rep()))
} else {
HostDescriptor::read_directory(&mut self.inner, fd).await
}
}
} }
} }
@@ -525,7 +585,16 @@ impl HostDescriptor for VfsView<'_> {
) -> FsResult<Resource<types::Descriptor>> { ) -> FsResult<Resource<types::Descriptor>> {
match self.target(&fd, &path) { match self.target(&fd, &path) {
Some((actor, path)) => self.open(actor, path, oflags, flags).await, Some((actor, path)) => self.open(actor, path, oflags, flags).await,
None => HostDescriptor::open_at(&mut self.inner, fd, path_flags, path, oflags, flags).await, None => {
let child_path = self.state.dirs.get(&fd.rep()).map(|base| join(base, &path));
let opened = HostDescriptor::open_at(&mut self.inner, fd, path_flags, path, oflags, flags).await?;
if let Some(path) = child_path {
if HostDescriptor::get_type(&mut self.inner, Resource::new_borrow(opened.rep())).await? == DescriptorType::Directory {
self.state.dirs.insert(opened.rep(), path);
}
}
Ok(opened)
}
} }
} }
@@ -538,7 +607,7 @@ impl HostDescriptor for VfsView<'_> {
} }
return Ok(()); return Ok(());
} }
self.state.roots.remove(&fd.rep()); self.state.dirs.remove(&fd.rep());
HostDescriptor::drop(&mut self.inner, fd) HostDescriptor::drop(&mut self.inner, fd)
} }
@@ -698,6 +767,17 @@ fn base64_decode(text: &str) -> Option<Vec<u8>> {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn actor_fs_paths_and_discovery_docs() {
assert_eq!(actor_fs_path("/actor/brain/fs"), Some(("brain".to_string(), "/".to_string())));
assert_eq!(actor_fs_path("/actor/brain/fs/docs/readme.md"), Some(("brain".to_string(), "/docs/readme.md".to_string())));
assert_eq!(actor_fs_path("/actor/brain/public/fs/file"), None);
assert_eq!(actor_root("/actor/brain"), Some("brain"));
assert_eq!(actor_root("/actor/brain/fs"), None);
assert!(declares_actor_fs("//! brain\n//! Actor-served files include fs_read.\nfn main() {}"));
assert!(!declares_actor_fs("//! actor without fs support\nfn main() {}"));
}
#[test] #[test]
fn base64_round_trip() { fn base64_round_trip() {
for s in ["", "a", "ab", "abc", "abcd", "\u{0}\u{ff}binary"] { for s in ["", "a", "ab", "abc", "abcd", "\u{0}\u{ff}binary"] {
+258
View File
@@ -0,0 +1,258 @@
//! File watching: the host watches the whole world folder with the OS file
//! watcher (`notify`, inotify on Linux), whoever writes: actors, a 9P mount,
//! a shell or `git` on the host. Nothing outside the folder counts.
//!
//! Events are coalesced per path: once a path is quiet for `QUIET` (or has
//! kept changing for `MAX_DELAY`), it becomes one change
//! `{path, op: write | create | remove | rename, to?}` with a namespace path
//! (relative to the world root, such as `/actor/...` or `/config/...`). Then the broker
//!
//! - sends `fs_changed` to the actors that watch the path (`watch` request),
//! - starts or upgrades an actor whose `actor/<id>/actor.wasm` (or folder
//! `actor/<id>`) changed, tested first, as at boot (`Broker::check_actor`).
//!
//! The host's temporary files (`.sausage-tmp-*`) are skipped: a rename of one
//! onto a file is a `write` of that file. If the OS drops events, the broker
//! runs a full pass and tells every watcher (`Broker::overflowed`).
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use notify::event::{EventKind, ModifyKind, RenameMode};
use notify::{RecursiveMode, Watcher as _};
use tokio::sync::mpsc;
use tokio::time::Instant;
use crate::broker::Broker;
use crate::world::TMP_PREFIX;
/// A path that is quiet this long becomes one change.
pub const QUIET: Duration = Duration::from_millis(100);
/// A path that keeps changing becomes a change at least this often.
pub const MAX_DELAY: Duration = Duration::from_secs(1);
/// The running watcher. Dropping it stops watching.
pub struct Watcher {
_os: notify::RecommendedWatcher,
task: tokio::task::JoinHandle<()>,
}
impl Drop for Watcher {
fn drop(&mut self) {
self.task.abort();
}
}
/// Watch the world folder of `broker` recursively. A failure to watch part
/// of it (for example too few inotify watches) is logged, and the rest is
/// watched.
pub fn start(broker: Arc<Broker>) -> notify::Result<Watcher> {
let (tx, rx) = mpsc::unbounded_channel();
let mut os = notify::recommended_watcher(move |event| {
let _ = tx.send(event);
})?;
if let Err(e) = os.watch(&broker.world.root, RecursiveMode::Recursive) {
broker.host_error(&format!("cannot watch all of {}: {e}", broker.world.root.display()));
}
let task = tokio::spawn(run(broker, rx));
Ok(Watcher { _os: os, task })
}
/// A change waiting for its path to be quiet.
struct Pending {
op: &'static str,
to: Option<String>,
first: Instant,
last: Instant,
}
impl Pending {
fn due(&self) -> Instant {
(self.last + QUIET).min(self.first + MAX_DELAY)
}
}
async fn run(broker: Arc<Broker>, mut events: mpsc::UnboundedReceiver<notify::Result<notify::Event>>) {
let root = broker.world.root.clone();
let mut pending: HashMap<String, Pending> = HashMap::new();
// Actor checks and full passes. Dropping the set (when the host stops) aborts them.
let mut tasks = tokio::task::JoinSet::new();
loop {
let due = pending.values().map(Pending::due).min();
tokio::select! {
event = events.recv() => match event {
Some(Ok(event)) => {
if event.need_rescan() {
let broker = broker.clone();
tasks.spawn(async move {
broker.overflowed().await;
});
}
let now = Instant::now();
for (path, op, to) in changes(&root, &event) {
add(&mut pending, path, op, to, now);
}
}
Some(Err(e)) => broker.host_error(&format!("file watcher: {e}")),
None => break,
},
_ = sleep_until(due) => {
let now = Instant::now();
let ready: Vec<String> = pending.iter().filter(|(_, p)| p.due() <= now).map(|(k, _)| k.clone()).collect();
for path in ready {
let p = pending.remove(&path).unwrap();
let op = if p.op == "moved-in" { "create" } else { p.op };
broker.notify_watchers(&path, op, p.to.as_deref());
if let Some(id) = new_wasm(&path, op, p.to.as_deref()) {
tasks.spawn(broker.clone().check_actor(id));
}
}
}
Some(_) = tasks.join_next() => {}
}
}
}
async fn sleep_until(t: Option<Instant>) {
match t {
Some(t) => tokio::time::sleep_until(t).await,
None => std::future::pending().await,
}
}
/// The world namespace path of a host path, unless it is outside the
/// world or a temporary file of the host.
fn namespace(root: &Path, path: &Path) -> Option<String> {
let rel = path.strip_prefix(root).ok()?;
let mut out = String::new();
for part in rel.components() {
let name = part.as_os_str().to_str()?;
if name.starts_with(TMP_PREFIX) {
return None;
}
out.push('/');
out.push_str(name);
}
Some(if out.is_empty() { "/".to_string() } else { out })
}
/// One change: namespace path, op, and the new path of a rename.
type Change = (String, &'static str, Option<String>);
/// The changes of one OS event. inotify reports a rename three times:
/// `From` (the old name), `To` (the new name) and `Both`. `To` becomes the
/// internal op `moved-in`, which `Both` replaces.
fn changes(root: &Path, event: &notify::Event) -> Vec<Change> {
let path = |i: usize| event.paths.get(i).and_then(|p| namespace(root, p));
let one = |op: &'static str| -> Vec<Change> { path(0).map(|p| (p, op, None)).into_iter().collect() };
match event.kind {
EventKind::Create(_) => one("create"),
EventKind::Remove(_) => one("remove"),
EventKind::Modify(ModifyKind::Metadata(_)) => vec![],
EventKind::Modify(ModifyKind::Name(RenameMode::From)) => one("remove"),
EventKind::Modify(ModifyKind::Name(RenameMode::To)) => one("moved-in"),
EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => match (path(0), path(1)) {
(Some(from), Some(to)) => vec![(from, "rename", Some(to))],
// Renamed to a temporary name: gone.
(Some(from), None) => vec![(from, "remove", None)],
// A temporary file renamed onto `to`: an atomic write.
(None, Some(to)) => vec![(to, "write", None)],
(None, None) => vec![],
},
EventKind::Modify(_) => one("write"),
_ => vec![],
}
}
/// Coalesce a change into `pending`. The last op wins, except that a write
/// after a create stays a create. A rename replaces the `moved-in` of its
/// new name.
fn add(pending: &mut HashMap<String, Pending>, path: String, op: &'static str, to: Option<String>, now: Instant) {
if let Some(to) = &to
&& pending.get(to).is_some_and(|p| p.op == "moved-in")
{
pending.remove(to);
}
match pending.get_mut(&path) {
Some(p) => {
if !(p.op == "create" && op == "write") {
p.op = op;
p.to = to;
}
p.last = now;
}
None => {
pending.insert(path, Pending { op, to, first: now, last: now });
}
}
}
/// The actor to check when `actor/<id>/actor.wasm` or the folder `actor/<id>`
/// appeared or changed.
fn new_wasm(path: &str, op: &str, to: Option<&str>) -> Option<String> {
let target = match op {
"remove" => return None,
"rename" => to?,
_ => path,
};
let rest = target.strip_prefix("/actor/")?;
let id = rest.strip_suffix("/actor.wasm").unwrap_or(rest);
(!id.is_empty() && !id.contains('/')).then(|| id.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wasm_and_folder_changes_name_the_actor() {
assert_eq!(new_wasm("/actor/a/actor.wasm", "write", None).as_deref(), Some("a"));
assert_eq!(new_wasm("/actor/a", "create", None).as_deref(), Some("a"));
assert_eq!(new_wasm("/actor/x/y", "rename", Some("/actor/b/actor.wasm")).as_deref(), Some("b"));
assert_eq!(new_wasm("/actor/a/actor.wasm", "remove", None), None);
assert_eq!(new_wasm("/actor/a/versions/1/actor.wasm", "write", None), None);
assert_eq!(new_wasm("/actor/a/public/x", "write", None), None);
assert_eq!(new_wasm("/actor", "write", None), None);
}
#[test]
fn a_rename_is_one_change_and_temp_files_are_skipped() {
let root = Path::new("/w");
let now = Instant::now();
let mut pending = HashMap::new();
let ev = |kind, paths: &[&str]| {
let mut e = notify::Event::new(kind);
for p in paths {
e = e.add_path(std::path::PathBuf::from(*p));
}
e
};
let name = |mode| EventKind::Modify(ModifyKind::Name(mode));
for e in [
ev(name(RenameMode::From), &["/w/actor/a/public/x"]),
ev(name(RenameMode::To), &["/w/actor/a/public/y"]),
ev(name(RenameMode::Both), &["/w/actor/a/public/x", "/w/actor/a/public/y"]),
ev(EventKind::Create(notify::event::CreateKind::File), &["/w/actor/a/.sausage-tmp-host-1-actor.json"]),
ev(name(RenameMode::Both), &["/w/actor/a/.sausage-tmp-host-1-actor.json", "/w/actor/a/actor.json"]),
ev(EventKind::Create(notify::event::CreateKind::File), &["/outside/.lock"]),
ev(EventKind::Create(notify::event::CreateKind::File), &["/w/config/x"]),
] {
for (path, op, to) in changes(root, &e) {
add(&mut pending, path, op, to, now);
}
}
let mut got: Vec<(String, &str, Option<String>)> =
pending.into_iter().map(|(k, p)| (k, p.op, p.to)).collect();
got.sort();
assert_eq!(
got,
vec![
("/actor/a/actor.json".to_string(), "write", None),
("/actor/a/public/x".to_string(), "rename", Some("/actor/a/public/y".to_string())),
("/config/x".to_string(), "create", None),
]
);
}
}
+24 -10
View File
@@ -1,14 +1,17 @@
//! On-disk layout of a Sausage world. The folder is the source of truth: //! On-disk layout of a Sausage world. The folder is the source of truth:
//! `sausage run` boots every actor that it finds in `actor/`. //! `sausage run` boots every actor that it finds in `actor/`, and then
//! watches the folder (`watch.rs`). `/self` aliases the running actor's folder;
//! `/public` and `/data` remain aliases for its `public/` and `data/`.
//! //!
//! ```text //! ```text
//! <world>/ //! <world>/ mounted at /, readable and writable by every actor
//! config/ global configuration by convention (no secrets)
//! actor/<id>/ //! actor/<id>/
//! actor.wasm the current version //! actor.wasm the current version
//! actor.json version, running, subscriptions, tapped (broker) //! actor.json version, running, subscriptions, tapped, watches (broker)
//! source/ source of the current version //! source/ source of the current version
//! public/ written by the actor, read-only for others //! public/ published files, readable and writable by everyone
//! data/ state, writable only by the actor //! data/ state, readable and writable by everyone
//! stderr.log stderr of the actor and notes of the broker //! stderr.log stderr of the actor and notes of the broker
//! versions/<n>/ every version that was installed: actor.wasm, source/, meta.json //! versions/<n>/ every version that was installed: actor.wasm, source/, meta.json
//! ``` //! ```
@@ -46,6 +49,9 @@ pub struct ActorMeta {
/// Whether this actor gets a copy of every routed message (`tap`). /// Whether this actor gets a copy of every routed message (`tap`).
#[serde(default)] #[serde(default)]
pub tapped: bool, pub tapped: bool,
/// Namespace paths whose changes this actor gets as `fs_changed` (`watch`).
#[serde(default)]
pub watches: BTreeSet<String>,
} }
fn yes() -> bool { fn yes() -> bool {
@@ -127,6 +133,7 @@ impl World {
for id in NATIVE { for id in NATIVE {
world.ensure_actor_dirs(id)?; world.ensure_actor_dirs(id)?;
} }
std::fs::create_dir_all(world.root.join("config"))?;
Ok(world) Ok(world)
} }
@@ -187,8 +194,9 @@ impl World {
/// Map a path in an actor's namespace to a host path. /// Map a path in an actor's namespace to a host path.
/// ///
/// - `/actor/<id>/...` is the global filesystem. /// - Absolute paths are relative to the world root.
/// - `/public/...` and `/data/...` belong to `sender`. /// - `/self/...` aliases `sender`'s folder; `/public/...` and `/data/...`
/// alias its corresponding children.
/// - Native senders (the console) can also give absolute host paths. /// - Native senders (the console) can also give absolute host paths.
pub fn resolve(&self, sender: &str, path: &str) -> Result<PathBuf> { pub fn resolve(&self, sender: &str, path: &str) -> Result<PathBuf> {
let under = |base: PathBuf, rest: &str| -> Result<PathBuf> { let under = |base: PathBuf, rest: &str| -> Result<PathBuf> {
@@ -208,6 +216,9 @@ impl World {
if let Some(rest) = prefixed("/actor") { if let Some(rest) = prefixed("/actor") {
return under(self.actors_root(), rest); return under(self.actors_root(), rest);
} }
if let Some(rest) = prefixed("/self") {
return under(self.actor_dir(sender), rest);
}
if let Some(rest) = prefixed("/public") { if let Some(rest) = prefixed("/public") {
return under(self.public_dir(sender), rest); return under(self.public_dir(sender), rest);
} }
@@ -217,13 +228,16 @@ impl World {
if NATIVE.contains(&sender) && Path::new(path).is_absolute() { if NATIVE.contains(&sender) && Path::new(path).is_absolute() {
return Ok(PathBuf::from(path)); return Ok(PathBuf::from(path));
} }
bail!("cannot resolve path {path} for {sender}: use /actor/<id>/..., /public/... or /data/...") if Path::new(path).is_absolute() {
return under(self.root.clone(), path);
}
bail!("cannot resolve path {path} for {sender}: use an absolute world path")
} }
/// The path of a host file in the global filesystem, if it has one. /// The path of a host file in the global filesystem, if it has one.
pub fn global_path(&self, host: &Path) -> Option<String> { pub fn global_path(&self, host: &Path) -> Option<String> {
let rel = host.strip_prefix(self.actors_root()).ok()?; let rel = host.strip_prefix(&self.root).ok()?;
Some(format!("/actor/{}", rel.display())) Some(format!("/{}", rel.display()))
} }
} }
+85
View File
@@ -0,0 +1,85 @@
//! Tests of the `sausage` command line.
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
fn empty_dir(label: &str) -> PathBuf {
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("target/test-cli")
.join(format!("{label}-{}-{nonce}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Bare `sausage` (as in `nix run <flake>`) without a world starts `init`
/// for ./world instead of printing a usage error.
#[test]
fn bare_command_without_a_world_starts_init() {
let dir = empty_dir("bare");
let output = Command::new(env!("CARGO_BIN_EXE_sausage"))
.current_dir(&dir)
.env_remove("SAUSAGE_API")
.env_remove("SAUSAGE_BASE_URL")
.env_remove("SAUSAGE_MODEL")
.env_remove("SAUSAGE_API_TOKEN")
.stdin(Stdio::null())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
// With no answers on stdin, init stops at the first required value.
assert!(!output.status.success(), "stdout:\n{stdout}\nstderr:\n{stderr}");
assert!(stdout.contains("No world in ./world yet"), "stdout:\n{stdout}\nstderr:\n{stderr}");
assert!(stderr.contains("SAUSAGE_BASE_URL"), "stdout:\n{stdout}\nstderr:\n{stderr}");
assert!(!stderr.contains("Usage:"), "stderr:\n{stderr}");
// It asks before it creates anything.
assert!(!dir.join("world").exists());
}
/// Start a headless host on a fresh world with stdin at EOF; returns once it
/// printed its ready line (printed after the signal handlers are installed).
fn start_headless(label: &str) -> (std::process::Child, std::io::Lines<std::io::BufReader<std::process::ChildStdout>>) {
use std::io::{BufRead, BufReader};
let dir = empty_dir(label);
let world = dir.join("world");
sausage::World::create(&world).unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_sausage"))
.args(["run", "--headless"])
.arg(&world)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
let line = lines.next().expect("host exited before it started").unwrap();
assert!(line.contains("headless"), "unexpected first line: {line}");
(child, lines)
}
/// With stdin at EOF (as soon as the host starts), a headless host stays up.
#[test]
fn headless_ignores_stdin_eof() {
let (mut child, _lines) = start_headless("headless-eof");
std::thread::sleep(std::time::Duration::from_millis(500));
let alive = child.try_wait().unwrap().is_none();
let _ = child.kill();
let _ = child.wait();
assert!(alive, "headless host exited on stdin EOF");
}
/// SIGTERM right after the ready line stops the actors and exits with 0.
#[test]
fn headless_stops_cleanly_on_sigterm_right_after_ready() {
let (mut child, lines) = start_headless("headless-term");
let pid = child.id().to_string();
assert!(Command::new("kill").args(["-TERM", &pid]).status().unwrap().success());
let rest: Vec<String> = lines.map(|l| l.unwrap()).collect();
let status = child.wait().unwrap();
assert!(status.success(), "exit status {status:?}, output {rest:?}");
assert!(rest.iter().any(|l| l.contains("Stopping actors")), "output {rest:?}");
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "cargo-c"
version = "0.1.0"
edition = "2024"
[dependencies]
sausage-actor = "0.1"
[build-dependencies]
cc = "1"
[workspace]
+5
View File
@@ -0,0 +1,5 @@
// Compile C with the `cc` crate: natively for the unit test, and with the
// WASI clang (`CC_wasm32_wasip2`) for the actor.
fn main() {
cc::Build::new().file("src/add.c").compile("add");
}
+1
View File
@@ -0,0 +1 @@
int c_add(int a, int b) { return a + b; }
+25
View File
@@ -0,0 +1,25 @@
//! Adds two numbers with a C function: `{"type": "add", "a", "b"}` -> `{"type": "sum", "value"}`.
use sausage_actor::{json, messages, reply};
unsafe extern "C" {
fn c_add(a: i32, b: i32) -> i32;
}
fn add(a: i32, b: i32) -> i32 {
unsafe { c_add(a, b) }
}
fn main() {
for message in messages() {
let (a, b) = (message.body["a"].as_i64().unwrap_or(0), message.body["b"].as_i64().unwrap_or(0));
let _ = reply(&message, json!({"type": "sum", "value": add(a as i32, b as i32)}));
}
}
#[cfg(test)]
mod tests {
#[test]
fn c_code_runs_on_the_host_target() {
assert_eq!(super::add(2, 3), 5);
}
}
@@ -0,0 +1,6 @@
[package]
name = "cargo-test-fail"
version = "0.1.0"
edition = "2024"
[workspace]
@@ -0,0 +1,9 @@
fn main() {}
#[cfg(test)]
mod tests {
#[test]
fn deliberate_unit_test_failure() {
assert_eq!(2 + 2, 5);
}
}
+8
View File
@@ -1 +1,9 @@
fn main() {} fn main() {}
#[cfg(test)]
mod tests {
#[test]
fn unit_tests_run_on_the_host_target() {
assert_eq!(2 + 2, 4);
}
}
+366 -25
View File
@@ -108,15 +108,15 @@ async fn routes_replies_publishes_and_reports_undeliverable() {
async fn broker_does_not_answer_messages_with_reply_to() { async fn broker_does_not_answer_messages_with_reply_to() {
let world = create_world("broker-reply-to"); let world = create_world("broker-reply-to");
let (host, mut mailbox) = start(&world).await; let (host, mut mailbox) = start(&world).await;
let scanned = request(&host, &mut mailbox, json!({"type": "scan"})).await; let untapped = request(&host, &mut mailbox, json!({"type": "untap"})).await;
assert_eq!(scanned.body["type"], "scanned"); assert_eq!(untapped.body["type"], "untapped");
host.broker.send( host.broker.send(
"console", "console",
Some("broker"), Some("broker"),
None, None,
Some("upstream-message"), Some("upstream-message"),
json!({"type": "scan"}), json!({"type": "untap"}),
); );
assert!( assert!(
tokio::time::timeout(Duration::from_secs(1), mailbox.recv()) tokio::time::timeout(Duration::from_secs(1), mailbox.recv())
@@ -140,20 +140,20 @@ async fn exit_request_is_answered_and_signals_the_host() {
} }
#[tokio::test] #[tokio::test]
async fn filesystem_permissions_and_public_sharing() { async fn filesystem_is_writable_and_public_aliases_share_files() {
let (_, host, mut mailbox) = filesystem_world("filesystem-permissions").await; let (_, host, mut mailbox) = filesystem_world("filesystem-permissions").await;
for path in [ for path in [
"/actor/reader/public/denied.txt", "/actor/reader/public/shared.txt",
"/actor/writer/public/denied.txt", "/actor/writer/public/shared.txt",
] { ] {
let denied = ask( let written = ask(
&host, &host,
&mut mailbox, &mut mailbox,
"writer", "writer",
json!({"type": "write", "path": path, "text": "denied"}), json!({"type": "write", "path": path, "text": "shared"}),
) )
.await; .await;
assert_eq!(denied.body["type"], "error", "write to {path} succeeded"); assert_eq!(written.body["type"], "ok", "write to {path} failed");
} }
let written = ask( let written = ask(
@@ -176,7 +176,7 @@ async fn filesystem_permissions_and_public_sharing() {
} }
#[tokio::test] #[tokio::test]
async fn actors_can_read_but_not_write_other_actor_data() { async fn actors_can_read_and_write_other_actor_data() {
let (_, host, mut mailbox) = filesystem_world("actor-data-sharing").await; let (_, host, mut mailbox) = filesystem_world("actor-data-sharing").await;
ask_type( ask_type(
&host, &host,
@@ -201,10 +201,10 @@ async fn actors_can_read_but_not_write_other_actor_data() {
&mut mailbox, &mut mailbox,
"reader", "reader",
json!({"type": "write", "path": "/actor/writer/data/x", "text": "changed"}), json!({"type": "write", "path": "/actor/writer/data/x", "text": "changed"}),
"error", "ok",
) )
.await; .await;
let unchanged = ask_type( let changed = ask_type(
&host, &host,
&mut mailbox, &mut mailbox,
"writer", "writer",
@@ -212,7 +212,49 @@ async fn actors_can_read_but_not_write_other_actor_data() {
"content", "content",
) )
.await; .await;
assert_eq!(unchanged.body["text"], "shared"); assert_eq!(changed.body["text"], "changed");
host.stop().await;
}
#[tokio::test]
async fn world_root_is_shared_writable_and_survives_restart() {
let (world, host, mut mailbox) = filesystem_world("world-root").await;
assert!(world.root.join("config").is_dir());
ask_type(&host, &mut mailbox, "writer", json!({"type": "write", "path": "/self/data/x", "text": "self-alias"}), "ok").await;
assert_eq!(ask_type(&host, &mut mailbox, "writer", json!({"type": "read", "path": "/data/x"}), "content").await.body["text"], "self-alias");
let file = "/config/x";
ask_type(&host, &mut mailbox, "writer", json!({"type": "write", "path": file, "text": "first"}), "ok").await;
let read = json!({"type": "read", "path": file});
assert_eq!(ask_type(&host, &mut mailbox, "reader", read.clone(), "content").await.body["text"], "first");
ask_type(&host, &mut mailbox, "reader", json!({"type": "write", "path": file, "text": "second"}), "ok").await;
assert_eq!(std::fs::read_to_string(world.root.join("config/x")).unwrap(), "second");
for actor in ["writer", "reader"] {
assert_eq!(world.resolve(actor, file).unwrap(), world.root.join("config/x"));
}
assert_eq!(world.global_path(&world.root.join("config/x")).as_deref(), Some(file));
// Watches use the same world namespace, not only /actor paths.
let watching = request(&host, &mut mailbox, json!({"type": "watch", "path": world.root.join("config").display().to_string()})).await;
assert_eq!(watching.body, json!({"type": "watching", "path": "/config"}));
drain(&mut mailbox, Duration::from_millis(300)).await;
std::fs::write(world.root.join("config/event"), "watched").unwrap();
assert_eq!(fs_events(drain(&mut mailbox, Duration::from_millis(600)).await),
vec![json!({"type": "fs_changed", "path": "/config/event", "op": "create"})]);
request(&host, &mut mailbox, json!({"type": "unwatch", "path": world.root.join("config").display().to_string()})).await;
// A new top-level folder appears in already running instances, without a mount change.
std::fs::create_dir(world.root.join("docs")).unwrap();
std::fs::write(world.root.join("docs/new"), "host-created").unwrap();
let docs = json!({"type": "read", "path": "/docs/new"});
assert_eq!(ask_type(&host, &mut mailbox, "reader", docs, "content").await.body["text"], "host-created");
ask_type(&host, &mut mailbox, "reader", json!({"type": "write", "path": "/docs/new", "text": "actor-updated"}), "ok").await;
assert_eq!(std::fs::read_to_string(world.root.join("docs/new")).unwrap(), "actor-updated");
host.stop().await;
let (host, mut mailbox) = start(&world).await;
for actor in ["writer", "reader"] {
assert_eq!(ask_type(&host, &mut mailbox, actor, read.clone(), "content").await.body["text"], "second");
}
host.stop().await; host.stop().await;
} }
@@ -246,6 +288,71 @@ async fn actors_serve_files_under_fs() {
host.stop().await; host.stop().await;
} }
#[tokio::test]
async fn actor_fs_is_discoverable_only_when_its_source_docs_advertise_it() {
let world = create_world("actor-fs-discovery");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let advertised = passing_source(&world, "advertised-fs");
std::fs::create_dir_all(advertised.join("src")).unwrap();
std::fs::write(
advertised.join("src/main.rs"),
"//! test actor\n//! Actor-served files: implements fs_read and fs_dir.\n",
)
.unwrap();
spawn_type(&host, &mut mailbox, "served", &wasm, &advertised).await;
let undocumented = passing_source(&world, "undocumented-fs");
std::fs::create_dir_all(undocumented.join("src")).unwrap();
std::fs::write(undocumented.join("src/main.rs"), "//! test actor without a virtual filesystem\n").unwrap();
spawn_type(&host, &mut mailbox, "plain", &wasm, &undocumented).await;
let reader_source = passing_source(&world, "fs-reader");
spawn_type(&host, &mut mailbox, "reader", &wasm, &reader_source).await;
let served_entries = ask_type(
&host,
&mut mailbox,
"reader",
json!({"type": "list", "path": "/actor/served"}),
"entries",
)
.await;
assert!(served_entries.body["names"].as_array().unwrap().iter().any(|name| name.as_str() == Some("fs")));
let plain_entries = ask_type(
&host,
&mut mailbox,
"reader",
json!({"type": "list", "path": "/actor/plain"}),
"entries",
)
.await;
assert!(!plain_entries.body["names"].as_array().unwrap().iter().any(|name| name.as_str() == Some("fs")));
let path = "/actor/served/fs/readme.txt";
ask_type(
&host,
&mut mailbox,
"reader",
json!({"type": "write", "path": path, "text": "virtual document"}),
"ok",
)
.await;
let document = ask_type(
&host,
&mut mailbox,
"reader",
json!({"type": "read", "path": path}),
"content",
)
.await;
assert_eq!(document.body["text"], "virtual document");
host.stop().await;
}
#[tokio::test] #[tokio::test]
async fn files_are_plain_posix_files() { async fn files_are_plain_posix_files() {
let (_, host, mut mailbox) = filesystem_world("posix").await; let (_, host, mut mailbox) = filesystem_world("posix").await;
@@ -441,6 +548,50 @@ async fn rolls_back_a_crash_loop_to_the_previous_version_without_restoring_data(
host.stop().await; host.stop().await;
} }
#[tokio::test]
async fn upgrade_and_rollback_keep_a_stopped_actor_stopped() {
let world = create_world("upgrade-stopped");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let source = passing_source(&world, "source");
spawn_type(&host, &mut mailbox, "sleeper", &wasm, &source).await;
let stopped = request(&host, &mut mailbox, json!({"type": "stop", "actor": "sleeper"})).await;
assert_eq!(stopped.body["type"], "stopped");
let upgraded = upgrade_actor(&host, &mut mailbox, "sleeper", &wasm, &source).await;
assert_eq!(upgraded.body["type"], "upgraded", "{upgraded:?}");
assert_eq!(upgraded.body["version"], 2);
assert_eq!(upgraded.body["running"], false);
let meta = world.read_meta("sleeper");
assert_eq!((meta.version, meta.running), (Some(2), false));
// Mail waits in the mailbox: the stopped actor reads nothing.
let waiting = host.broker.send("console", Some("sleeper"), None, None, json!({"type": "version"}));
assert!(
tokio::time::timeout(Duration::from_millis(500), receive_reply(&mut mailbox, &waiting, Duration::from_secs(10)))
.await
.is_err(),
"a stopped actor must not answer"
);
let rolled_back = request(&host, &mut mailbox, json!({"type": "rollback", "actor": "sleeper", "version": 1})).await;
assert_eq!(rolled_back.body["type"], "rolled_back", "{rolled_back:?}");
assert_eq!(rolled_back.body["running"], false);
assert!(!world.read_meta("sleeper").running);
// `start` runs the installed version and delivers the waiting message.
let started = request(&host, &mut mailbox, json!({"type": "start", "actor": "sleeper"})).await;
assert_eq!(started.body["type"], "started");
let answer = receive_reply(&mut mailbox, &waiting, Duration::from_secs(10)).await;
assert_eq!(answer.body["version"], "1");
// A running actor still restarts on the new version.
let running = upgrade_actor(&host, &mut mailbox, "sleeper", &wasm, &source).await;
assert_eq!(running.body["running"], true);
assert_eq!(ask(&host, &mut mailbox, "sleeper", json!({"type": "version"})).await.body["version"], "3");
host.stop().await;
}
#[tokio::test] #[tokio::test]
async fn explicit_rollback_keeps_current_data() { async fn explicit_rollback_keeps_current_data() {
let world = create_world("explicit-rollback"); let world = create_world("explicit-rollback");
@@ -546,14 +697,18 @@ async fn unknown_broker_request_lists_the_known_ones() {
let unknown = request(&host, &mut mailbox, json!({"type": "frobnicate"})).await; let unknown = request(&host, &mut mailbox, json!({"type": "frobnicate"})).await;
assert_eq!(unknown.body["type"], "error"); assert_eq!(unknown.body["type"], "error");
let reason = unknown.body["reason"].as_str().unwrap(); let reason = unknown.body["reason"].as_str().unwrap();
assert!(reason.contains("remove") && reason.contains("tap"), "{reason}"); assert!(reason.contains("remove") && reason.contains("tap") && reason.contains("watch"), "{reason}");
// `scan` is retired: the host watches the world folder instead.
let scan = request(&host, &mut mailbox, json!({"type": "scan"})).await;
assert_eq!(scan.body["type"], "error");
host.stop().await; host.stop().await;
} }
#[tokio::test] #[tokio::test]
async fn cargo_actor_builds_valid_crates_and_reports_compiler_errors() { async fn cargo_actor_runs_unit_tests_by_default_and_can_skip_them() {
let world = create_world("cargo-actor"); let world = create_world("cargo-actor");
let (host, mut mailbox) = start(&world).await; let (host, mut mailbox) = start(&world).await;
let valid_source = cargo_source("cargo-valid"); let valid_source = cargo_source("cargo-valid");
let valid_id = host.broker.send( let valid_id = host.broker.send(
"console", "console",
@@ -562,12 +717,42 @@ async fn cargo_actor_builds_valid_crates_and_reports_compiler_errors() {
None, None,
json!({"type": "build", "source": valid_source.display().to_string()}), json!({"type": "build", "source": valid_source.display().to_string()}),
); );
let built = receive_reply(&mut mailbox, &valid_id, Duration::from_secs(120)).await; let built = receive_reply(&mut mailbox, &valid_id, Duration::from_secs(180)).await;
assert_eq!(built.body["type"], "built"); assert_eq!(built.body["type"], "built", "{built:?}");
assert_eq!(built.body["tests"]["passed"], 1);
assert_eq!(built.body["tests"]["failed"], 0);
assert!(built.body["tests"]["output"].as_str().unwrap().contains("unit_tests_run_on_the_host_target"));
let wasm = built.body["wasm"].as_str().unwrap(); let wasm = built.body["wasm"].as_str().unwrap();
assert!(wasm.ends_with(".wasm")); assert!(wasm.ends_with(".wasm"));
assert!(host.world.resolve("console", wasm).unwrap().is_file()); assert!(host.world.resolve("console", wasm).unwrap().is_file());
let failing_test_source = cargo_source("cargo-test-fail");
let failed_test_id = host.broker.send(
"console",
Some("cargo"),
None,
None,
json!({"type": "build", "source": failing_test_source.display().to_string()}),
);
let failed_test = receive_reply(&mut mailbox, &failed_test_id, Duration::from_secs(120)).await;
assert_eq!(failed_test.body["type"], "build_failed", "{failed_test:?}");
assert_eq!(failed_test.body["tests"]["passed"], 0);
assert_eq!(failed_test.body["tests"]["failed"], 1);
assert!(failed_test.body["tests"]["output"].as_str().unwrap().contains("deliberate_unit_test_failure"));
let skipped_id = host.broker.send(
"console",
Some("cargo"),
None,
None,
json!({"type": "build", "source": failing_test_source.display().to_string(), "skip_tests": true}),
);
let skipped = receive_reply(&mut mailbox, &skipped_id, Duration::from_secs(120)).await;
assert_eq!(skipped.body["type"], "built", "{skipped:?}");
assert_eq!(skipped.body["tests"]["passed"], 0);
assert_eq!(skipped.body["tests"]["failed"], 0);
assert_eq!(skipped.body["tests"]["output"], "skipped (skip_tests=true)");
let invalid_source = cargo_source("cargo-fail"); let invalid_source = cargo_source("cargo-fail");
let invalid_id = host.broker.send( let invalid_id = host.broker.send(
"console", "console",
@@ -576,18 +761,30 @@ async fn cargo_actor_builds_valid_crates_and_reports_compiler_errors() {
None, None,
json!({"type": "build", "source": invalid_source.display().to_string()}), json!({"type": "build", "source": invalid_source.display().to_string()}),
); );
let failed = receive_reply(&mut mailbox, &invalid_id, Duration::from_secs(120)).await; let invalid = receive_reply(&mut mailbox, &invalid_id, Duration::from_secs(120)).await;
assert_eq!(failed.body["type"], "build_failed"); assert_eq!(invalid.body["type"], "build_failed", "{invalid:?}");
assert!( assert_eq!(invalid.body["tests"]["failed"], 1);
failed.body["errors"] assert!(invalid.body["tests"]["output"].as_str().unwrap().contains("missing_function"));
.as_str()
.unwrap()
.contains("missing_function")
);
host.stop().await; host.stop().await;
} }
#[tokio::test]
async fn cargo_actor_compiles_c_code_for_wasi() {
let world = create_world("cargo-c");
let (host, mut mailbox) = start(&world).await;
let source = cargo_source("cargo-c");
let id = host.broker.send("console", Some("cargo"), None, None, json!({"type": "build", "source": source.display().to_string()}));
let built = receive_reply(&mut mailbox, &id, Duration::from_secs(300)).await;
assert_eq!(built.body["type"], "built", "{built:?}");
assert_eq!(built.body["tests"]["passed"], 1, "{built:?}");
let wasm = host.world.resolve("console", built.body["wasm"].as_str().unwrap()).unwrap();
spawn_type(&host, &mut mailbox, "c-add", &wasm, &source).await;
let sum = ask_type(&host, &mut mailbox, "c-add", json!({"type": "add", "a": 2, "b": 3}), "sum").await;
assert_eq!(sum.body["value"], 5);
host.stop().await;
}
#[tokio::test] #[tokio::test]
async fn rejects_invalid_actor_ids() { async fn rejects_invalid_actor_ids() {
let world = create_world("invalid-id"); let world = create_world("invalid-id");
@@ -909,3 +1106,147 @@ async fn a_full_tapper_mailbox_drops_copies_without_blocking() {
assert_eq!(ghost.try_recv().unwrap().body["type"], "tapped"); assert_eq!(ghost.try_recv().unwrap().body["type"], "tapped");
host.stop().await; host.stop().await;
} }
/// Ask `actor` again and again (it may not exist yet) until `want` holds for
/// the answer.
async fn ask_until(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
body: Value,
want: impl Fn(&Value) -> bool,
) -> Envelope {
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let reply = ask(host, mailbox, actor, body.clone()).await;
if want(&reply.body) {
return reply;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
})
.await
.unwrap_or_else(|_| panic!("{actor} never gave the wanted answer"))
}
/// The `fs_changed` bodies among `messages` (all from `fs`).
fn fs_events(messages: Vec<Envelope>) -> Vec<Value> {
messages
.into_iter()
.filter(|m| m.body["type"] == "fs_changed")
.map(|m| {
assert_eq!(m.from, "fs");
m.body
})
.collect()
}
#[tokio::test]
async fn a_new_actor_wasm_on_disk_becomes_an_actor() {
let world = create_world("watch-spawn");
let (host, mut mailbox) = start(&world).await;
// Any writer counts: here the test itself, like a shell on the host.
let tests = world.source_dir("dropped").join("tests");
std::fs::create_dir_all(&tests).unwrap();
let test = json!({"steps": [{"send": {"type": "echo", "value": "candidate"}, "expect": {"type": "echo", "value": "candidate"}}]});
std::fs::write(tests.join("candidate.json"), test.to_string()).unwrap();
std::fs::copy(toolbox_wasm(), world.wasm_path("dropped")).unwrap();
let echo = json!({"type": "echo", "value": "hi"});
let reply = ask_until(&host, &mut mailbox, "dropped", echo, |b| b["type"] == "echo").await;
assert_eq!(reply.from, "dropped");
assert_eq!(world.read_meta("dropped").version, Some(1));
let meta = std::fs::read_to_string(world.version_dir("dropped", 1).join("meta.json")).unwrap();
assert_eq!(serde_json::from_str::<Value>(&meta).unwrap()["requested_by"], "scan");
host.stop().await;
}
#[tokio::test]
async fn a_changed_actor_wasm_on_disk_is_an_upgrade() {
let world = create_world("watch-upgrade");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let source = passing_source(&world, "source");
spawn_type(&host, &mut mailbox, "changing", &wasm, &source).await;
// The same program plus a custom section: a different file.
let mut changed = std::fs::read(&wasm).unwrap();
changed.extend_from_slice(&[0, 4, 1, b'x', b'v', b'2']);
std::fs::write(world.wasm_path("changing"), changed).unwrap();
let version = json!({"type": "version"});
ask_until(&host, &mut mailbox, "changing", version, |b| b["version"] == "2").await;
assert_eq!(world.read_meta("changing").version, Some(2));
assert_eq!(world.versions("changing"), vec![1, 2]);
host.stop().await;
}
#[tokio::test]
async fn watchers_get_one_event_per_change_from_any_writer() {
let (world, host, mut mailbox) = filesystem_world("watch-events").await;
let watch = json!({"type": "watch", "path": "/actor/writer/public/"});
let watching = request(&host, &mut mailbox, watch).await;
assert_eq!(watching.body, json!({"type": "watching", "path": "/actor/writer/public"}));
assert!(world.read_meta("console").watches.contains("/actor/writer/public"));
drain(&mut mailbox, Duration::from_millis(300)).await;
let quiet = Duration::from_millis(600);
let event = |path: &str, op: &str| json!({"type": "fs_changed", "path": format!("/actor/writer/public/{path}"), "op": op});
// An actor creates a file: one create, not create + write.
let write = json!({"type": "write", "path": "/public/a.txt", "text": "a"});
ask_type(&host, &mut mailbox, "writer", write, "ok").await;
assert_eq!(fs_events(drain(&mut mailbox, quiet).await), vec![event("a.txt", "create")]);
// The host (a shell, git, ...) changes it.
std::fs::write(world.public_dir("writer").join("a.txt"), "b").unwrap();
assert_eq!(fs_events(drain(&mut mailbox, quiet).await), vec![event("a.txt", "write")]);
// A rename is one event with `to`.
let rename = json!({"type": "rename", "path": "/public/a.txt", "to": "/public/b.txt"});
ask_type(&host, &mut mailbox, "writer", rename, "ok").await;
let mut renamed = event("a.txt", "rename");
renamed["to"] = json!("/actor/writer/public/b.txt");
assert_eq!(fs_events(drain(&mut mailbox, quiet).await), vec![renamed]);
std::fs::remove_file(world.public_dir("writer").join("b.txt")).unwrap();
assert_eq!(fs_events(drain(&mut mailbox, quiet).await), vec![event("b.txt", "remove")]);
// Nothing outside the watched path.
let outside = json!({"type": "write", "path": "/data/x", "text": "x"});
ask_type(&host, &mut mailbox, "writer", outside, "ok").await;
assert_eq!(fs_events(drain(&mut mailbox, quiet).await), Vec::<Value>::new());
let unwatch = json!({"type": "unwatch", "path": "/actor/writer/public"});
let unwatched = request(&host, &mut mailbox, unwatch).await;
assert_eq!(unwatched.body, json!({"type": "unwatched", "path": "/actor/writer/public"}));
assert!(world.read_meta("console").watches.is_empty());
let write = json!({"type": "write", "path": "/public/c.txt", "text": "c"});
ask_type(&host, &mut mailbox, "writer", write, "ok").await;
assert_eq!(fs_events(drain(&mut mailbox, quiet).await), Vec::<Value>::new());
host.stop().await;
}
#[tokio::test]
async fn a_watcher_overflow_sends_one_rescan_and_runs_a_full_pass() {
let world = create_world("watch-overflow");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let mut bystander = host.mailbox("bystander");
let watch = json!({"type": "watch", "path": "/actor/elsewhere"});
assert_eq!(request(&host, &mut mailbox, watch).await.body["type"], "watching");
// A dropped actor that the full pass finds (before the watcher's quiet time).
let tests = world.source_dir("planted").join("tests");
std::fs::create_dir_all(&tests).unwrap();
let test = json!({"steps": [{"send": {"type": "echo", "value": "candidate"}, "expect": {"type": "echo", "value": "candidate"}}]});
std::fs::write(tests.join("candidate.json"), test.to_string()).unwrap();
std::fs::copy(&wasm, world.wasm_path("planted")).unwrap();
let report = host.broker.clone().overflowed().await;
assert!(report.iter().any(|r| r["type"] == "spawned" && r["actor"] == "planted"), "{report:?}");
let rescan = json!({"type": "fs_changed", "path": "/", "op": "rescan"});
assert_eq!(fs_events(drain(&mut mailbox, Duration::from_millis(600)).await), vec![rescan]);
assert!(bystander.try_recv().is_err(), "an actor without watches got the rescan");
host.stop().await;
}
Generated
+16
View File
@@ -1,5 +1,20 @@
{ {
"nodes": { "nodes": {
"crane": {
"locked": {
"lastModified": 1791754810,
"narHash": "sha256-cN0Mgk+sQtCJcGWs9H4M0CnLRAuC4I8aCwiBTcBJs6E=",
"owner": "ipetkov",
"repo": "crane",
"rev": "bd63bf1d0a0f6dcacd7549c714a671ee63b0db6a",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-utils": { "flake-utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": "systems"
@@ -36,6 +51,7 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"crane": "crane",
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay" "rust-overlay": "rust-overlay"
+150 -25
View File
@@ -6,9 +6,10 @@
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
rust-overlay.url = "github:oxalica/rust-overlay"; rust-overlay.url = "github:oxalica/rust-overlay";
rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
crane.url = "github:ipetkov/crane";
}; };
outputs = { nixpkgs, flake-utils, rust-overlay, ... }: outputs = { nixpkgs, flake-utils, rust-overlay, crane, ... }:
flake-utils.lib.eachSystem flake-utils.lib.allSystems (system: flake-utils.lib.eachSystem flake-utils.lib.allSystems (system:
let let
pkgs = import nixpkgs { pkgs = import nixpkgs {
@@ -20,34 +21,64 @@
targets = [ "wasm32-wasip2" ]; targets = [ "wasm32-wasip2" ];
extensions = [ "rust-src" ]; extensions = [ "rust-src" ];
}; };
rustPlatform = pkgs.makeRustPlatform { cargo = rust; rustc = rust; }; # The runtime cargo actor needs Cargo, rustc (with wasm32-wasip2), and
sausage = rustPlatform.buildRustPackage { # the native linker, even when the host itself is started with a bare
# PATH. Keep this same runtime path in the packaged host and exercise
# the installed binary below with PATH deliberately empty.
runtimePath = pkgs.lib.makeBinPath [ rust pkgs.stdenv.cc ];
# A WASI clang with wasi-libc, so that build scripts (the `cc` crate)
# can compile C for wasm32-wasip2. Its headers are WASI 0.1: C code
# that needs sockets does not build.
wasiCc = pkgs.pkgsCross.wasi32.stdenv.cc;
wasiCcEnv = {
CC_wasm32_wasip2 = "${wasiCc}/bin/${wasiCc.targetPrefix}cc";
AR_wasm32_wasip2 = "${wasiCc}/bin/${wasiCc.targetPrefix}ar";
};
craneLib = (crane.mkLib pkgs).overrideToolchain rust;
common = {
pname = "sausage"; pname = "sausage";
version = "0.1.0"; version = "0.1.0";
src = ./.; src = ./.;
cargoLock.lockFile = ./Cargo.lock; };
cargoBuildFlags = [ "-p" "sausage" ]; # The test packages for `checks.tests`.
testPackages = "-p sausage -p sausage-actor -p agent";
cargoArtifacts = craneLib.buildDepsOnly (common // {
doCheck = false;
# The dependencies of the host binary and of the tests.
buildPhaseCargoCommand = ''
cargoWithProfile build --locked -p sausage
cargoWithProfile test --locked --no-run ${testPackages}
'';
});
sausage = craneLib.buildPackage (common // {
inherit cargoArtifacts;
doNotRemoveReferencesToRustToolchain = true;
cargoExtraArgs = "--locked -p sausage";
doCheck = false; doCheck = false;
nativeBuildInputs = [ pkgs.makeWrapper ]; nativeBuildInputs = [ pkgs.makeWrapper ];
# The `cargo` actor runs cargo at runtime. Build scripts need a C linker. # The `cargo` actor runs cargo at runtime. Build scripts need a C linker,
# and C code for WASI needs the WASI clang.
postInstall = '' postInstall = ''
wrapProgram $out/bin/sausage --prefix PATH : ${pkgs.lib.makeBinPath [ rust pkgs.stdenv.cc ]} wrapProgram $out/bin/sausage --prefix PATH : ${runtimePath} \
--set-default CC_wasm32_wasip2 ${wasiCcEnv.CC_wasm32_wasip2} \
--set-default AR_wasm32_wasip2 ${wasiCcEnv.AR_wasm32_wasip2}
''; '';
meta.mainProgram = "sausage"; meta.mainProgram = "sausage";
}; });
# `nix run .#loop -- <world>` runs the newest `sausage run <world>` # `nix run .#loop -- <world>` runs the newest `sausage run <world>`
# from the Gitea repo, again and again: after a crash or a broker # (without a folder: bare `sausage`, i.e. ./world, created if needed)
# from the public repo, again and again: after a crash or a broker
# `exit` request it refreshes the flake and starts the host again, # `exit` request it refreshes the flake and starts the host again,
# so the world updates itself. `/quit` (exit code 0) ends the loop. # so the world updates itself. `/quit` (exit code 0) ends the loop.
# `--internal` (or `.#loop-internal`) uses Gitea's SSH port on the LAN. # SAUSAGE_FLAKE overrides where it fetches from (any flake URL,
publicUrl = "git+ssh://containeruser@gitea.surma.technology:2222/surma/sausage"; # e.g. a fork or a local checkout).
internalUrl = "git+ssh://containeruser@10.0.0.2:2222/surma/sausage"; repoUrl = "git+https://gitea.surma.technology/surma/sausage";
loop = name: url: pkgs.writeShellScriptBin name '' loop = pkgs.writeShellScriptBin "sausage-loop" ''
url="${url}" url="''${SAUSAGE_FLAKE:-${repoUrl}}"
if [ "''${1:-}" = "--internal" ]; then url="${internalUrl}"; shift; fi # No folder: bare `sausage` (creates ./world first if needed).
if [ $# -eq 0 ]; then set -- world; fi if [ $# -gt 0 ]; then set -- run "$@"; fi
while true; do while true; do
nix run --refresh "$url#default" -- run "$@" nix run --refresh "$url#default" -- "$@"
code=$? code=$?
if [ "$code" -eq 0 ]; then exit 0; fi if [ "$code" -eq 0 ]; then exit 0; fi
echo "sausage-loop: sausage exited with $code, starting the newest version in 2 s …" >&2 echo "sausage-loop: sausage exited with $code, starting the newest version in 2 s …" >&2
@@ -61,25 +92,119 @@
# `nix flake check` runs every test of the host, the SDK, and the # `nix flake check` runs every test of the host, the SDK, and the
# agent. The tests build WASM actors with cargo, offline, from the # agent. The tests build WASM actors with cargo, offline, from the
# vendored crates of Cargo.lock. # vendored crates of Cargo.lock.
checks.tests = sausage.overrideAttrs (old: { # The binary the host loop runs (`nix run`), built from the same
pname = "sausage-tests"; # cached dependencies as the tests.
doCheck = true; checks.package = sausage;
cargoTestFlags = [ "-p" "sausage" "-p" "sausage-actor" "-p" "agent" ]; checks.tests = craneLib.cargoTest (common // wasiCcEnv // {
inherit cargoArtifacts;
cargoExtraArgs = "--locked ${testPackages}";
# Nothing builds on the test run's target folder: don't store it.
doInstallCargoArtifacts = false;
preCheck = '' preCheck = ''
# Its own lock file pins crates that the vendored set may lack. # Its own lock file pins crates that the vendored set may lack.
rm -f cli/tests/actors/toolbox/Cargo.lock rm -f cli/tests/actors/toolbox/Cargo.lock
''; '';
}); });
# Exercise the installed host exactly as the loop launches it: the
# caller has no Cargo on PATH, so only the packaged runtime toolchain
# can run the native tests and the wasm32-wasip2 build.
checks.runtime-toolchain = pkgs.runCommand "sausage-runtime-toolchain" { } ''
set -eu
root="$TMPDIR/runtime-toolchain"
world="$root/world"
emptyPath="$root/empty-bin"
mkdir -p "$emptyPath" "$root/home" "$root/cargo-home"
for actor in broker console cargo fs; do
mkdir -p "$world/actor/$actor/public" "$world/actor/$actor/data"
done
source="$world/actor/console/public/probe"
mkdir -p "$source/src" "$source/tests"
cat > "$source/Cargo.toml" <<'TOML'
[package]
name = "runtime-probe"
version = "0.1.0"
edition = "2024"
TOML
# C code for WASI, compiled with the packaged WASI clang. The native
# test build has no C, so it needs no C compiler for the host.
printf 'int probe_add(int a, int b) { return a + b; }\n' > "$source/src/add.c"
cat > "$source/build.rs" <<'RUST'
use std::process::Command;
fn main() {
if std::env::var("TARGET").unwrap() != "wasm32-wasip2" {
return;
}
let out = std::env::var("OUT_DIR").unwrap();
let cc = std::env::var("CC_wasm32_wasip2").expect("CC_wasm32_wasip2");
let ar = std::env::var("AR_wasm32_wasip2").expect("AR_wasm32_wasip2");
let object = format!("{out}/add.o");
assert!(Command::new(cc).args(["-c", "src/add.c", "-o", &object]).status().unwrap().success());
assert!(Command::new(ar).args(["crs", &format!("{out}/libadd.a"), &object]).status().unwrap().success());
println!("cargo:rustc-link-search=native={out}");
println!("cargo:rustc-link-lib=static=add");
}
RUST
cat > "$source/src/main.rs" <<'RUST'
#[cfg(target_os = "wasi")]
unsafe extern "C" {
fn probe_add(a: i32, b: i32) -> i32;
}
fn main() {
#[cfg(target_os = "wasi")]
assert_eq!(unsafe { probe_add(2, 3) }, 5);
}
RUST
cat > "$source/tests/probe.rs" <<'RUST'
#[test]
fn native_test_harness_runs() {
assert_eq!(2 + 2, 4);
}
RUST
echo "runtime-check: run packaged host with an empty PATH"
coproc host {
${pkgs.coreutils}/bin/env -i PATH="$emptyPath" HOME="$root/home" TMPDIR="$root" \
CARGO_HOME="$root/cargo-home" ${sausage}/bin/sausage run "$world"
}
hostInput=''${host[1]}
hostOutput=''${host[0]}
hostPid=$host_PID
trap 'kill "$hostPid" 2>/dev/null || true' EXIT
printf '%s\n' '@cargo {"type":"build","source":"/actor/console/public/probe"}' >&"$hostInput"
built=0
while IFS= read -r -t 300 line <&"$hostOutput"; do
echo "$line"
case "$line" in
*'"type":"build_failed"'*)
echo "packaged cargo actor build failed with an empty caller PATH" >&2
exit 1
;;
*'"type":"built"'*)
case "$line" in
*'"passed":1'*) built=1 ;;
*) echo "native test did not pass" >&2; exit 1 ;;
esac
break
;;
esac
done
if [ "$built" -ne 1 ]; then
echo "packaged host did not build the probe" >&2
exit 1
fi
printf '%s\n' '/quit' >&"$hostInput"
wait "$hostPid"
mkdir -p "$out"
touch "$out/passed"
'';
apps.default = { type = "app"; program = "${sausage}/bin/sausage"; }; apps.default = { type = "app"; program = "${sausage}/bin/sausage"; };
apps.loop = { type = "app"; program = "${loop "sausage-loop" publicUrl}/bin/sausage-loop"; }; apps.loop = { type = "app"; program = "${loop}/bin/sausage-loop"; };
apps.loop-internal = { type = "app"; program = "${loop "sausage-loop-internal" internalUrl}/bin/sausage-loop-internal"; };
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell (wasiCcEnv // {
packages = [ rust pkgs.wasmtime pkgs.wasm-tools ]; packages = [ rust pkgs.wasmtime pkgs.wasm-tools ];
shellHook = '' shellHook = ''
export CARGO_HOME="$PWD/.cargo-home" export CARGO_HOME="$PWD/.cargo-home"
''; '';
}; });
}); });
} }