Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7eb96bbbb | ||
|
|
2415bb361f | ||
|
|
a536d873e6 | ||
|
|
34e2f35272 | ||
|
|
f932ab9719 | ||
|
|
3d3f6f35fb | ||
|
|
f1af07506f | ||
|
|
a72e42a8c9 | ||
|
|
01a821ab72 | ||
|
|
c204e1ef0c | ||
|
|
6923ca3e60 | ||
|
|
c504532513 | ||
|
|
eb35a79698 | ||
|
|
bfcd71e2f4 | ||
|
|
172a776eec | ||
|
|
6a9d35ceb8 | ||
|
|
22590c608d | ||
|
|
3bc462ffd2 | ||
|
|
f008d0cc60 | ||
|
|
81daddfd70 | ||
|
|
e8df22fe06 | ||
|
|
0995824c01 |
Generated
+97
@@ -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
@@ -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(¬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.
|
||||||
+86
-21
@@ -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
|
||||||
@@ -110,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,
|
||||||
@@ -151,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>/`
|
||||||
@@ -182,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
|
||||||
|
|
||||||
@@ -201,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
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ From a clone (`git clone https://gitea.surma.technology/surma/sausage`), the
|
|||||||
same commands are `nix run`, `nix run -- init`, `nix run -- run` and
|
same commands are `nix run`, `nix run -- init`, `nix run -- run` and
|
||||||
`nix run .#loop`.
|
`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:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -66,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
@@ -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 })
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
+176
-47
@@ -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,51 +93,84 @@ 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 mut cmd = Command::new("cargo");
|
let target_dir = data.join("target");
|
||||||
cmd.arg("build")
|
let tests = if skip_tests {
|
||||||
.arg("--release")
|
TestResults { output: "skipped (skip_tests=true)".into(), ..TestResults::default() }
|
||||||
.arg("--target")
|
} else {
|
||||||
.arg(TARGET)
|
match run_tests(&build_dir, &target_dir).await {
|
||||||
.arg("--message-format=json-render-diagnostics")
|
Ok(tests) => tests,
|
||||||
.arg("--target-dir")
|
Err(error) => {
|
||||||
.arg(data.join("target"))
|
let output = format!("cannot run cargo tests: {error:#}");
|
||||||
.current_dir(&build_dir)
|
return Err(BuildFailure {
|
||||||
.kill_on_drop(true);
|
reason: output.clone(),
|
||||||
let out = cmd.output().await.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?;
|
tests: TestResults { failed: 1, output, ..TestResults::default() },
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
});
|
||||||
if !out.status.success() {
|
}
|
||||||
bail!("{}", strip_progress(&stderr));
|
|
||||||
}
|
|
||||||
let mut artifact = None;
|
|
||||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
|
||||||
if let Ok(v) = serde_json::from_str::<Value>(line)
|
|
||||||
&& v["reason"] == "compiler-artifact"
|
|
||||||
&& let Some(exe) = v["executable"].as_str()
|
|
||||||
{
|
|
||||||
artifact = Some(PathBuf::from(exe));
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
if tests.failed > 0 {
|
||||||
|
return Err(BuildFailure { reason: "unit tests failed".into(), tests });
|
||||||
}
|
}
|
||||||
let artifact = artifact.ok_or_else(|| anyhow!("cargo built no binary. Does the crate have a src/main.rs?"))?;
|
|
||||||
|
|
||||||
let n = next_build_number(&self.world.public_dir(ID).join("builds"))?;
|
let build_result: Result<BuildOutput> = async {
|
||||||
let dest_dir = self.world.public_dir(ID).join("builds").join(n.to_string());
|
let mut cmd = Command::new("cargo");
|
||||||
let name = artifact.file_name().context("artifact has no file name")?;
|
cmd.arg("build")
|
||||||
let dest = dest_dir.join(name);
|
.arg("--release")
|
||||||
world::write_atomic(&dest, &std::fs::read(&artifact)?)?;
|
.arg("--target")
|
||||||
Ok(BuildOutput { wasm: dest, seconds: started.elapsed().as_secs_f64(), warnings: strip_progress(&stderr) })
|
.arg(TARGET)
|
||||||
|
.arg("--message-format=json-render-diagnostics")
|
||||||
|
.arg("--target-dir")
|
||||||
|
.arg(&target_dir)
|
||||||
|
.current_dir(&build_dir)
|
||||||
|
.kill_on_drop(true);
|
||||||
|
let out = cmd.output().await.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?;
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!("{}", strip_progress(&stderr));
|
||||||
|
}
|
||||||
|
let mut artifact = None;
|
||||||
|
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||||
|
if let Ok(v) = serde_json::from_str::<Value>(line)
|
||||||
|
&& v["reason"] == "compiler-artifact"
|
||||||
|
&& let Some(exe) = v["executable"].as_str()
|
||||||
|
{
|
||||||
|
artifact = Some(PathBuf::from(exe));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let artifact = artifact.ok_or_else(|| anyhow!("cargo built no binary. Does the crate have a src/main.rs?"))?;
|
||||||
|
|
||||||
|
let n = next_build_number(&self.world.public_dir(ID).join("builds"))?;
|
||||||
|
let dest_dir = self.world.public_dir(ID).join("builds").join(n.to_string());
|
||||||
|
let name = artifact.file_name().context("artifact has no file name")?;
|
||||||
|
let dest = dest_dir.join(name);
|
||||||
|
world::write_atomic(&dest, &std::fs::read(&artifact)?)?;
|
||||||
|
Ok(BuildOutput {
|
||||||
|
wasm: dest,
|
||||||
|
seconds: started.elapsed().as_secs_f64(),
|
||||||
|
warnings: strip_progress(&stderr),
|
||||||
|
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,22 +192,76 @@ 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);
|
||||||
let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string());
|
match self.build(&source, skip_tests).await {
|
||||||
Ok(json!({
|
Ok(out) => {
|
||||||
"type": "built",
|
let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string());
|
||||||
"wasm": wasm,
|
Ok(json!({
|
||||||
"source": source_s,
|
"type": "built",
|
||||||
"built_by": ID,
|
"wasm": wasm,
|
||||||
"seconds": (out.seconds * 10.0).round() / 10.0,
|
"source": source_s,
|
||||||
"warnings": out.warnings,
|
"built_by": ID,
|
||||||
}))
|
"seconds": (out.seconds * 10.0).round() / 10.0,
|
||||||
|
"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
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-8
@@ -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};
|
||||||
@@ -17,6 +18,11 @@ static AGENT_SOURCE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent");
|
|||||||
#[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: Option<Cmd>,
|
command: Option<Cmd>,
|
||||||
}
|
}
|
||||||
@@ -85,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 {
|
||||||
_ = console::run_input(host.broker.clone()) => false,
|
Some(signals) => {
|
||||||
_ = broker.exit_requested() => true,
|
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,
|
||||||
|
_ = broker.exit_requested() => true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
println!("Stopping actors …");
|
println!("Stopping actors …");
|
||||||
host.stop().await;
|
host.stop().await;
|
||||||
@@ -113,16 +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();
|
||||||
|
let headless = cli.headless || std::env::var("SAUSAGE_HEADLESS").is_ok_and(|v| !v.is_empty() && v != "0");
|
||||||
|
match cli.command {
|
||||||
Some(Cmd::Init { dir }) => init(dir).await,
|
Some(Cmd::Init { dir }) => init(dir).await,
|
||||||
Some(Cmd::Run { dir }) => run(dir).await,
|
Some(Cmd::Run { dir }) => run(dir, headless).await,
|
||||||
None => {
|
None => {
|
||||||
let dir = PathBuf::from("world");
|
let dir = PathBuf::from("world");
|
||||||
if !dir.join("actor").is_dir() {
|
if !dir.join("actor").is_dir() {
|
||||||
println!("No world in ./world yet, creating it (same as `sausage init`).");
|
println!("No world in ./world yet, creating it (same as `sausage init`).");
|
||||||
init(dir.clone()).await?;
|
init(dir.clone()).await?;
|
||||||
}
|
}
|
||||||
run(dir).await
|
run(dir, headless).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-6
@@ -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;
|
||||||
self.start().await?;
|
if self.running {
|
||||||
Ok(json!({"type": "rolled_back", "actor": self.id, "from": from, "to": version}))
|
self.start().await?;
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
|||||||
+2
-2
@@ -390,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.dirs.insert(fd.rep(), "/actor".to_string());
|
self.state.dirs.insert(fd.rep(), name.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(dirs)
|
Ok(dirs)
|
||||||
|
|||||||
@@ -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: ¬ify::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
@@ -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()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,3 +39,47 @@ fn bare_command_without_a_world_starts_init() {
|
|||||||
// It asks before it creates anything.
|
// It asks before it creates anything.
|
||||||
assert!(!dir.join("world").exists());
|
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:?}");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "cargo-c"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
sausage-actor = "0.1"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
cc = "1"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
int c_add(int a, int b) { return a + b; }
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+301
-25
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,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");
|
||||||
@@ -611,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",
|
||||||
@@ -627,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",
|
||||||
@@ -641,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");
|
||||||
@@ -974,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
@@ -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"
|
||||||
|
|||||||
@@ -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,21 +21,50 @@
|
|||||||
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>`
|
||||||
# (without a folder: bare `sausage`, i.e. ./world, created if needed)
|
# (without a folder: bare `sausage`, i.e. ./world, created if needed)
|
||||||
# from the public repo, again and again: after a crash or a broker
|
# from the public repo, again and again: after a crash or a broker
|
||||||
@@ -62,24 +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}/bin/sausage-loop"; };
|
apps.loop = { type = "app"; program = "${loop}/bin/sausage-loop"; };
|
||||||
|
|
||||||
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"
|
||||||
'';
|
'';
|
||||||
};
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user