Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e701ab846 |
-915
@@ -1,915 +0,0 @@
|
||||
# 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(¬es)?)?;
|
||||
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.
|
||||
+32
-28
@@ -59,27 +59,17 @@ it exits. After 5 seconds, it kills the instance.
|
||||
|
||||
## Filesystem
|
||||
|
||||
The world folder is the foundation of every instance's filesystem:
|
||||
Each instance sees these mounts:
|
||||
|
||||
| Path | Access | Contents |
|
||||
| --- | --- | --- |
|
||||
| `/` | read-write by every actor | the whole world folder |
|
||||
| `/self` | read-write | alias for the actor's own folder (`public/`, `data/`, `source/`, etc.) |
|
||||
| `/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 |
|
||||
| `/data` | read-write by its actor; readable by every actor | the actor's state |
|
||||
| `/public` | read-write | the files that the actor publishes |
|
||||
| `/actor` | read-only | the `actor/` folder of the world |
|
||||
| `/config` | read-only | one non-secret global JSON file, owned by `shared` |
|
||||
| `/global` | read-write for `shared`, read-only for all other actors | backing directory containing that same `config` file |
|
||||
|
||||
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.
|
||||
An actor can write only its own `/data`, but can read every actor's `/data` through `/actor/<id>/data/`. `/data` is not private: any unprotected secret file there is readable by every actor.
|
||||
|
||||
`/actor/<id>/` holds everything about an actor: `actor.wasm`, `actor.json`
|
||||
(installed version, running, subscriptions, tapped, watches), `source/`, `public/`, `data/`,
|
||||
@@ -89,6 +79,28 @@ Files are plain POSIX files: a write is visible at once. To publish a file
|
||||
so that readers never see half of it, write a temporary file and `rename` it
|
||||
(the Rust SDK has `write_atomic`).
|
||||
|
||||
`/config` is global configuration, never credentials (in particular, never the
|
||||
LLM proxy token). The host initializes it to `{}` without reading or copying
|
||||
legacy actor settings. Configuration schema and typed edits belong to `shared`,
|
||||
not the host. Ordinary actors cannot write either `/config` or its alias
|
||||
`/global/config`: WASI mounts are read-only, with no message sender checks.
|
||||
|
||||
WASI preopens directories rather than individual files. The host mounts the
|
||||
isolated world `global/` directory at `/` read-only and at `/global` with the
|
||||
owner's write access. `shared` publishes atomically by writing a sibling such
|
||||
as `/global/config.tmp`, closing it, then renaming it to `/global/config`.
|
||||
New opens of `/config` see the replacement, including in already-running
|
||||
actors; an already-open file retains its old inode. The root mount does not
|
||||
expose the rest of the host world. Candidate tests get a scratch copy of
|
||||
`global/`, so even `shared` tests cannot modify the live file.
|
||||
|
||||
On disk, the one JSON file is `<world>/global/config`. `<world>/config` is a
|
||||
relative symlink to it, for native/world filesystem readers (including an
|
||||
export of the world directory). Host-side atomic writers must replace
|
||||
`<world>/global/config`, not replace the symlink. Starting an older world
|
||||
adds the empty file and alias without changing actor state; an existing
|
||||
regular `<world>/config` is moved into the backing directory on first use.
|
||||
|
||||
Send large or binary data as a path under `/actor/<id>/public/`, not inside a
|
||||
message.
|
||||
|
||||
@@ -143,15 +155,12 @@ the request: the request's `type` in past tense (`spawned`, `upgraded`,
|
||||
| `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 |
|
||||
| `tap`, `untap` | | receive (or not) a copy of every message the broker routes |
|
||||
| `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 |
|
||||
| `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 `/actor/...` path |
|
||||
| `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) |
|
||||
|
||||
**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.
|
||||
Paths are `/actor/...`, or the sender's `/public/...` or `/data/...`.
|
||||
|
||||
**Taps.** After `tap`, the sender gets a copy of every message that the
|
||||
broker routes (direct messages, replies, and publishes, between any actors,
|
||||
@@ -191,7 +200,7 @@ change of `path` or a path under it (`/actor/a` covers `/actor/a/x`, not
|
||||
- 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
|
||||
`{"type": "fs_changed", "path": "/actor", "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.
|
||||
@@ -265,11 +274,6 @@ The reply is `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.w
|
||||
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
|
||||
that dependency at the SDK of the host, which it publishes at
|
||||
`/actor/cargo/public/sdk/`.
|
||||
|
||||
+9
-7
@@ -255,9 +255,11 @@ impl Broker {
|
||||
}
|
||||
InstanceConfig {
|
||||
mounts: vec![
|
||||
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 },
|
||||
// WASI preopens directories, not files. This isolated directory
|
||||
// supplies /config without exposing the rest of the world.
|
||||
Mount { guest: "/".into(), host: scratch.map_or_else(|| self.world.global_dir(), |p| p.join("global")), writable: false },
|
||||
Mount { guest: "/global".into(), host: scratch.map_or_else(|| self.world.global_dir(), |p| p.join("global")), writable: id == "shared" },
|
||||
Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: false },
|
||||
Mount { guest: "/public".into(), host: own.join("public"), writable: true },
|
||||
Mount { guest: "/data".into(), host: own.join("data"), writable: true },
|
||||
],
|
||||
@@ -447,7 +449,7 @@ impl Broker {
|
||||
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"}));
|
||||
self.inject(&watchers, vfs::ID, json!({"type": "fs_changed", "path": "/actor", "op": "rescan"}));
|
||||
let report = self.scan().await;
|
||||
for change in &report {
|
||||
eprintln!("sausage: {change}");
|
||||
@@ -601,9 +603,9 @@ impl Broker {
|
||||
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 = global.ok_or_else(|| anyhow!("{path_s} is not under /actor"))?;
|
||||
let path = match path.trim_end_matches('/') {
|
||||
"" => "/".to_string(),
|
||||
"" => "/actor".to_string(),
|
||||
p => p.to_string(),
|
||||
};
|
||||
self.update_meta(&env.from, |m| {
|
||||
@@ -651,7 +653,7 @@ fn same_file(a: &Path, b: &Path) -> bool {
|
||||
|
||||
/// 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('/'))
|
||||
path.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
|
||||
}
|
||||
|
||||
pub fn truncate(s: &str, max: usize) -> String {
|
||||
|
||||
@@ -97,7 +97,7 @@ impl Candidate<'_> {
|
||||
let dir = self.broker.world.scratch_dir().join(format!("{}-{}-{n}", self.id, std::process::id()));
|
||||
let scratch = Scratch { dir: dir.clone() };
|
||||
let world = &self.broker.world;
|
||||
for (from, name) in [(world.data_dir(self.id), "data"), (world.public_dir(self.id), "public")] {
|
||||
for (from, name) in [(world.data_dir(self.id), "data"), (world.public_dir(self.id), "public"), (world.global_dir(), "global")] {
|
||||
std::fs::create_dir_all(dir.join(name))?;
|
||||
if from.is_dir() {
|
||||
world::copy_tree(&from, &dir.join(name))?;
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
//! The broker knows nothing about Rust. This actor is one way to get a
|
||||
//! `.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
|
||||
//! 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
|
||||
|
||||
@@ -48,6 +48,7 @@ impl Host {
|
||||
if lock.try_lock().is_err() {
|
||||
bail!("another sausage process runs {}", root.display());
|
||||
}
|
||||
world.ensure_config()?;
|
||||
let broker = Broker::new(world.clone(), runtime::Runtime::new()?)?;
|
||||
world::write_atomic(&world.public_dir("broker").join("PROTOCOL.md"), PROTOCOL.as_bytes())?;
|
||||
|
||||
|
||||
+3
-1
@@ -23,7 +23,9 @@ use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpVi
|
||||
use crate::broker::Broker;
|
||||
use crate::vfs::{Vfs, VfsState, VfsView};
|
||||
|
||||
/// A host folder that the guest sees at `guest`.
|
||||
/// A host folder that the guest sees at `guest`. File-like root paths such
|
||||
/// as `/config` use an isolated backing directory mounted at `/`; owners
|
||||
/// get a separate writable directory alias for atomic replacement.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Mount {
|
||||
pub guest: String,
|
||||
|
||||
+4
-2
@@ -6,6 +6,8 @@
|
||||
//! actor `<id>` with a message from `fs` and waits up to `ASK_TIMEOUT` for
|
||||
//! the reply. Other paths go to `wasmtime-wasi` unchanged except that an
|
||||
//! actor directory listing advertises `fs/` when its source docs declare it.
|
||||
//! `/config` and `/global/config` are native mount paths: they deliberately
|
||||
//! use WASI mount permissions rather than actor-served write requests.
|
||||
//!
|
||||
//! - `fs_read {path}` -> `fs_file {text | base64}`, `fs_dir {entries: [{name, type}]}`
|
||||
//! - `fs_write {path, text | base64}`, `fs_mkdir {path}`, `fs_remove {path}`,
|
||||
@@ -390,8 +392,8 @@ impl preopens::Host for VfsView<'_> {
|
||||
fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<types::Descriptor>, String)>> {
|
||||
let dirs = preopens::Host::get_directories(&mut self.inner)?;
|
||||
for (fd, name) in &dirs {
|
||||
if name == "/actor" || name == "/" {
|
||||
self.state.dirs.insert(fd.rep(), name.clone());
|
||||
if name == "/actor" {
|
||||
self.state.dirs.insert(fd.rep(), "/actor".to_string());
|
||||
}
|
||||
}
|
||||
Ok(dirs)
|
||||
|
||||
+8
-10
@@ -5,7 +5,7 @@
|
||||
//! 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
|
||||
//! (`/actor/...`). 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
|
||||
@@ -75,7 +75,7 @@ impl Pending {
|
||||
}
|
||||
|
||||
async fn run(broker: Arc<Broker>, mut events: mpsc::UnboundedReceiver<notify::Result<notify::Event>>) {
|
||||
let root = broker.world.root.clone();
|
||||
let root = broker.world.actors_root();
|
||||
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();
|
||||
@@ -122,11 +122,11 @@ async fn sleep_until(t: Option<Instant>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The world namespace path of a host path, unless it is outside the
|
||||
/// world or a temporary file of the host.
|
||||
/// The namespace path (`/actor/...`) of a host path, unless it is outside
|
||||
/// `actor/` 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();
|
||||
let mut out = String::from("/actor");
|
||||
for part in rel.components() {
|
||||
let name = part.as_os_str().to_str()?;
|
||||
if name.starts_with(TMP_PREFIX) {
|
||||
@@ -135,7 +135,7 @@ fn namespace(root: &Path, path: &Path) -> Option<String> {
|
||||
out.push('/');
|
||||
out.push_str(name);
|
||||
}
|
||||
Some(if out.is_empty() { "/".to_string() } else { out })
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// One change: namespace path, op, and the new path of a rename.
|
||||
@@ -219,7 +219,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_rename_is_one_change_and_temp_files_are_skipped() {
|
||||
let root = Path::new("/w");
|
||||
let root = Path::new("/w/actor");
|
||||
let now = Instant::now();
|
||||
let mut pending = HashMap::new();
|
||||
let ev = |kind, paths: &[&str]| {
|
||||
@@ -236,8 +236,7 @@ mod tests {
|
||||
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"]),
|
||||
ev(EventKind::Create(notify::event::CreateKind::File), &["/w/.lock"]),
|
||||
] {
|
||||
for (path, op, to) in changes(root, &e) {
|
||||
add(&mut pending, path, op, to, now);
|
||||
@@ -251,7 +250,6 @@ mod tests {
|
||||
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),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
+46
-19
@@ -1,17 +1,17 @@
|
||||
//! On-disk layout of a Sausage world. The folder is the source of truth:
|
||||
//! `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/`.
|
||||
//! watches the folder (`watch.rs`).
|
||||
//!
|
||||
//! ```text
|
||||
//! <world>/ mounted at /, readable and writable by every actor
|
||||
//! config/ global configuration by convention (no secrets)
|
||||
//! <world>/
|
||||
//! global/config non-secret global JSON (shared owns writes)
|
||||
//! config -> global/config world-facing read alias
|
||||
//! actor/<id>/
|
||||
//! actor.wasm the current version
|
||||
//! actor.json version, running, subscriptions, tapped, watches (broker)
|
||||
//! source/ source of the current version
|
||||
//! public/ published files, readable and writable by everyone
|
||||
//! data/ state, readable and writable by everyone
|
||||
//! public/ written by the actor, read-only for others
|
||||
//! data/ state, writable only by the actor
|
||||
//! stderr.log stderr of the actor and notes of the broker
|
||||
//! versions/<n>/ every version that was installed: actor.wasm, source/, meta.json
|
||||
//! ```
|
||||
@@ -91,6 +91,37 @@ impl World {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
/// Isolated backing directory for the root /config mount and the
|
||||
/// owner's writable /global alias. Never copy legacy actor config here.
|
||||
pub fn global_dir(&self) -> PathBuf {
|
||||
self.root.join("global")
|
||||
}
|
||||
|
||||
pub fn config_path(&self) -> PathBuf {
|
||||
self.global_dir().join("config")
|
||||
}
|
||||
|
||||
/// Add the non-secret file to new and pre-existing worlds. The symlink
|
||||
/// is for native world readers; actor mounts use the backing directory.
|
||||
pub fn ensure_config(&self) -> Result<()> {
|
||||
std::fs::create_dir_all(self.global_dir())?;
|
||||
let alias = self.root.join("config");
|
||||
if !self.config_path().exists() {
|
||||
if alias.is_file() && !alias.is_symlink() {
|
||||
std::fs::rename(&alias, self.config_path())?;
|
||||
} else {
|
||||
write_atomic(&self.config_path(), b"{}\n")?;
|
||||
}
|
||||
}
|
||||
match std::fs::read_link(&alias) {
|
||||
Ok(target) if target == Path::new("global/config") => {}
|
||||
Ok(_) => bail!("{} must point to global/config", alias.display()),
|
||||
Err(_) if alias.try_exists()? => bail!("{} already exists", alias.display()),
|
||||
Err(_) => std::os::unix::fs::symlink("global/config", alias)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn actors_root(&self) -> PathBuf {
|
||||
self.root.join("actor")
|
||||
}
|
||||
@@ -133,7 +164,7 @@ impl World {
|
||||
for id in NATIVE {
|
||||
world.ensure_actor_dirs(id)?;
|
||||
}
|
||||
std::fs::create_dir_all(world.root.join("config"))?;
|
||||
world.ensure_config()?;
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
@@ -194,9 +225,8 @@ impl World {
|
||||
|
||||
/// Map a path in an actor's namespace to a host path.
|
||||
///
|
||||
/// - Absolute paths are relative to the world root.
|
||||
/// - `/self/...` aliases `sender`'s folder; `/public/...` and `/data/...`
|
||||
/// alias its corresponding children.
|
||||
/// - `/actor/<id>/...` is the global filesystem.
|
||||
/// - `/public/...` and `/data/...` belong to `sender`.
|
||||
/// - Native senders (the console) can also give absolute host paths.
|
||||
pub fn resolve(&self, sender: &str, path: &str) -> Result<PathBuf> {
|
||||
let under = |base: PathBuf, rest: &str| -> Result<PathBuf> {
|
||||
@@ -213,12 +243,12 @@ impl World {
|
||||
let prefixed = |prefix: &str| {
|
||||
path.strip_prefix(prefix).filter(|rest| rest.is_empty() || rest.starts_with('/'))
|
||||
};
|
||||
if path == "/config" || path == "/global/config" {
|
||||
return Ok(self.config_path());
|
||||
}
|
||||
if let Some(rest) = prefixed("/actor") {
|
||||
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") {
|
||||
return under(self.public_dir(sender), rest);
|
||||
}
|
||||
@@ -228,16 +258,13 @@ impl World {
|
||||
if NATIVE.contains(&sender) && Path::new(path).is_absolute() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
if Path::new(path).is_absolute() {
|
||||
return under(self.root.clone(), path);
|
||||
}
|
||||
bail!("cannot resolve path {path} for {sender}: use an absolute world path")
|
||||
bail!("cannot resolve path {path} for {sender}: use /actor/<id>/..., /public/... or /data/...")
|
||||
}
|
||||
|
||||
/// The path of a host file in the global filesystem, if it has one.
|
||||
pub fn global_path(&self, host: &Path) -> Option<String> {
|
||||
let rel = host.strip_prefix(&self.root).ok()?;
|
||||
Some(format!("/{}", rel.display()))
|
||||
let rel = host.strip_prefix(self.actors_root()).ok()?;
|
||||
Some(format!("/actor/{}", rel.display()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
[package]
|
||||
name = "cargo-c"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
sausage-actor = "0.1"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
|
||||
[workspace]
|
||||
@@ -1,5 +0,0 @@
|
||||
// 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 +0,0 @@
|
||||
int c_add(int a, int b) { return a + b; }
|
||||
@@ -1,25 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
+88
-69
@@ -47,6 +47,83 @@ async fn receive_shutdown_messages(
|
||||
.expect("shutdown output or lifecycle reply did not arrive");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_config_is_readable_owner_written_and_atomically_replaced() {
|
||||
let world = create_world("global-config");
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let wasm = toolbox_wasm().clone();
|
||||
for actor in ["shared", "reader"] {
|
||||
let source = passing_source(&world, actor);
|
||||
spawn_type(&host, &mut mailbox, actor, &wasm, &source).await;
|
||||
}
|
||||
let initial = ask_type(&host, &mut mailbox, "reader", json!({"type": "read", "path": "/config"}), "content").await;
|
||||
assert_eq!(initial.body["text"], "{}\n");
|
||||
for path in ["/config", "/global/config", "/global/config.tmp", "/actor/shared/data/../../../../global/config"] {
|
||||
ask_type(&host, &mut mailbox, "reader", json!({"type": "write", "path": path, "text": "not allowed"}), "error").await;
|
||||
}
|
||||
ask_type(&host, &mut mailbox, "reader", json!({"type": "rename", "path": "/config", "to": "/global/stolen"}), "error").await;
|
||||
// The root view stays read-only even for the owner; publication uses its
|
||||
// writable directory alias, with a sibling temporary file and rename.
|
||||
ask_type(&host, &mut mailbox, "shared", json!({"type": "write", "path": "/config", "text": "not allowed"}), "error").await;
|
||||
let next = "{\"llm\":{\"thinking\":\"medium\"}}\n";
|
||||
ask_type(&host, &mut mailbox, "shared", json!({"type": "write", "path": "/global/config.tmp", "text": next}), "ok").await;
|
||||
let before = ask_type(&host, &mut mailbox, "reader", json!({"type": "read", "path": "/config"}), "content").await;
|
||||
assert_eq!(before.body["text"], "{}\n");
|
||||
// A native/world reader holding the old inode sees the complete old file.
|
||||
let mut old = std::fs::File::open(world.root.join("config")).unwrap();
|
||||
ask_type(&host, &mut mailbox, "shared", json!({"type": "rename", "path": "/global/config.tmp", "to": "/global/config"}), "ok").await;
|
||||
let after = ask_type(&host, &mut mailbox, "reader", json!({"type": "read", "path": "/config"}), "content").await;
|
||||
assert_eq!(after.body["text"], next);
|
||||
assert_eq!(std::fs::read_to_string(world.root.join("config")).unwrap(), next);
|
||||
assert_eq!(std::fs::read_to_string(world.resolve("reader", "/config").unwrap()).unwrap(), next);
|
||||
let mut previous = String::new();
|
||||
std::io::Read::read_to_string(&mut old, &mut previous).unwrap();
|
||||
assert_eq!(previous, "{}\n");
|
||||
assert!(!world.global_dir().join("config.tmp").exists());
|
||||
// The mount must not give shared writable access to any other world data.
|
||||
ask_type(&host, &mut mailbox, "shared", json!({"type": "write", "path": "/global/../actor/reader/data/escape", "text": "no"}), "error").await;
|
||||
host.stop().await;
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let persisted = ask_type(&host, &mut mailbox, "reader", json!({"type": "read", "path": "/config"}), "content").await;
|
||||
assert_eq!(persisted.body["text"], next);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_config_candidates_cannot_change_the_live_file() {
|
||||
let world = create_world("candidate-global-config");
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let source = source_with_test(&world, "shared", json!({"steps": [
|
||||
{"send": {"type": "write", "path": "/global/next", "text": "{\"test\":true}"}, "expect": {"type": "ok"}},
|
||||
{"send": {"type": "rename", "path": "/global/next", "to": "/global/config"}, "expect": {"type": "ok"}},
|
||||
{"send": {"type": "read", "path": "/config"}, "expect": {"type": "content", "text": "{\"test\":true}"}}
|
||||
]}));
|
||||
spawn_type(&host, &mut mailbox, "shared", toolbox_wasm(), &source).await;
|
||||
assert_eq!(std::fs::read_to_string(world.config_path()).unwrap(), "{}\n");
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_config_is_added_to_existing_worlds_without_copying_actor_settings() {
|
||||
let world = create_world("old-world-config");
|
||||
std::fs::remove_file(world.root.join("config")).unwrap();
|
||||
std::fs::remove_dir_all(world.global_dir()).unwrap();
|
||||
world.ensure_actor_dirs("shared").unwrap();
|
||||
// Synthetic marker only; no actual credentials are read in this test.
|
||||
std::fs::write(world.data_dir("shared").join("config.json"), "legacy-private-marker").unwrap();
|
||||
let (host, _) = start(&world).await;
|
||||
assert_eq!(std::fs::read_to_string(world.root.join("config")).unwrap(), "{}\n");
|
||||
assert_eq!(std::fs::read_to_string(world.data_dir("shared").join("config.json")).unwrap(), "legacy-private-marker");
|
||||
host.stop().await;
|
||||
// Preserve an operator-provided global config when adding the mounts.
|
||||
std::fs::remove_file(world.root.join("config")).unwrap();
|
||||
std::fs::remove_dir_all(world.global_dir()).unwrap();
|
||||
std::fs::write(world.root.join("config"), "{\"operator\":true}").unwrap();
|
||||
let (host, _) = start(&world).await;
|
||||
assert_eq!(std::fs::read_to_string(world.root.join("config")).unwrap(), "{\"operator\":true}");
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn routes_replies_publishes_and_reports_undeliverable() {
|
||||
let world = create_world("routing");
|
||||
@@ -140,20 +217,20 @@ async fn exit_request_is_answered_and_signals_the_host() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filesystem_is_writable_and_public_aliases_share_files() {
|
||||
async fn filesystem_permissions_and_public_sharing() {
|
||||
let (_, host, mut mailbox) = filesystem_world("filesystem-permissions").await;
|
||||
for path in [
|
||||
"/actor/reader/public/shared.txt",
|
||||
"/actor/writer/public/shared.txt",
|
||||
"/actor/reader/public/denied.txt",
|
||||
"/actor/writer/public/denied.txt",
|
||||
] {
|
||||
let written = ask(
|
||||
let denied = ask(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"writer",
|
||||
json!({"type": "write", "path": path, "text": "shared"}),
|
||||
json!({"type": "write", "path": path, "text": "denied"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(written.body["type"], "ok", "write to {path} failed");
|
||||
assert_eq!(denied.body["type"], "error", "write to {path} succeeded");
|
||||
}
|
||||
|
||||
let written = ask(
|
||||
@@ -176,7 +253,7 @@ async fn filesystem_is_writable_and_public_aliases_share_files() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actors_can_read_and_write_other_actor_data() {
|
||||
async fn actors_can_read_but_not_write_other_actor_data() {
|
||||
let (_, host, mut mailbox) = filesystem_world("actor-data-sharing").await;
|
||||
ask_type(
|
||||
&host,
|
||||
@@ -201,10 +278,10 @@ async fn actors_can_read_and_write_other_actor_data() {
|
||||
&mut mailbox,
|
||||
"reader",
|
||||
json!({"type": "write", "path": "/actor/writer/data/x", "text": "changed"}),
|
||||
"ok",
|
||||
"error",
|
||||
)
|
||||
.await;
|
||||
let changed = ask_type(
|
||||
let unchanged = ask_type(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"writer",
|
||||
@@ -212,49 +289,7 @@ async fn actors_can_read_and_write_other_actor_data() {
|
||||
"content",
|
||||
)
|
||||
.await;
|
||||
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");
|
||||
}
|
||||
assert_eq!(unchanged.body["text"], "shared");
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
@@ -769,22 +804,6 @@ async fn cargo_actor_runs_unit_tests_by_default_and_can_skip_them() {
|
||||
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]
|
||||
async fn rejects_invalid_actor_ids() {
|
||||
let world = create_world("invalid-id");
|
||||
@@ -1245,7 +1264,7 @@ async fn a_watcher_overflow_sends_one_rescan_and_runs_a_full_pass() {
|
||||
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"});
|
||||
let rescan = json!({"type": "fs_changed", "path": "/actor", "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;
|
||||
|
||||
@@ -26,14 +26,6 @@
|
||||
# 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";
|
||||
@@ -56,12 +48,9 @@
|
||||
cargoExtraArgs = "--locked -p sausage";
|
||||
doCheck = false;
|
||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||
# The `cargo` actor runs cargo at runtime. Build scripts need a C linker,
|
||||
# and C code for WASI needs the WASI clang.
|
||||
# The `cargo` actor runs cargo at runtime. Build scripts need a C linker.
|
||||
postInstall = ''
|
||||
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}
|
||||
wrapProgram $out/bin/sausage --prefix PATH : ${runtimePath}
|
||||
'';
|
||||
meta.mainProgram = "sausage";
|
||||
});
|
||||
@@ -95,7 +84,7 @@
|
||||
# The binary the host loop runs (`nix run`), built from the same
|
||||
# cached dependencies as the tests.
|
||||
checks.package = sausage;
|
||||
checks.tests = craneLib.cargoTest (common // wasiCcEnv // {
|
||||
checks.tests = craneLib.cargoTest (common // {
|
||||
inherit cargoArtifacts;
|
||||
cargoExtraArgs = "--locked ${testPackages}";
|
||||
# Nothing builds on the test run's target folder: don't store it.
|
||||
@@ -125,35 +114,7 @@
|
||||
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
|
||||
printf 'fn main() {}\n' > "$source/src/main.rs"
|
||||
cat > "$source/tests/probe.rs" <<'RUST'
|
||||
#[test]
|
||||
fn native_test_harness_runs() {
|
||||
@@ -200,11 +161,11 @@
|
||||
apps.default = { type = "app"; program = "${sausage}/bin/sausage"; };
|
||||
apps.loop = { type = "app"; program = "${loop}/bin/sausage-loop"; };
|
||||
|
||||
devShells.default = pkgs.mkShell (wasiCcEnv // {
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = [ rust pkgs.wasmtime pkgs.wasm-tools ];
|
||||
shellHook = ''
|
||||
export CARGO_HOME="$PWD/.cargo-home"
|
||||
'';
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user