Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48b4287287 |
+1
-5
@@ -1,8 +1,4 @@
|
||||
target/
|
||||
/target
|
||||
/.cargo-home
|
||||
/world
|
||||
/result
|
||||
|
||||
# Lock files of test and example crates: they resolve against the workspace lock.
|
||||
**/tests/**/Cargo.lock
|
||||
/examples/*/Cargo.lock
|
||||
|
||||
@@ -5,30 +5,30 @@ Keep it that way.
|
||||
|
||||
## Priorities
|
||||
|
||||
1. **Correct.** Every behavior in `PROTOCOL.md` has a test in `cli/tests/host/`.
|
||||
1. **Correct.** Every behavior in `PROTOCOL.md` has a test in `cli/tests/`.
|
||||
2. **Simple and short.** Prefer the obvious code. No abstraction for one
|
||||
caller. No configuration that nobody asked for.
|
||||
3. **Readable.** A new reader must understand a file without the others.
|
||||
Each module starts with a doc comment that says what it owns.
|
||||
4. **Efficient** on the paths that run per call.
|
||||
4. **Efficient** on the paths that run per message.
|
||||
5. **Raw and minimal.** Sausage has no permissions, sandboxing beyond WASI,
|
||||
limits, or authentication. Do not add them to the core. Add nothing to
|
||||
the host that a plugin (or the agent, by changing itself) could do.
|
||||
the host that an actor (or the agent, by changing itself) could do.
|
||||
|
||||
## Layout
|
||||
|
||||
- `cli/`: the host (`sausage` binary and library): the core, the native
|
||||
`cargo` plugin, the WASI test runner, and the console.
|
||||
- `vfs/`: `sausage-vfs`, the virtual filesystem for WASI guests. It knows
|
||||
nothing about Sausage.
|
||||
- `interface/`: `sausage-interface`: OpenRPC documents, the compatibility
|
||||
check, and the generator of client code.
|
||||
- `sdk/`: `sausage-plugin`, the SDK for Rust plugins, and `sdk/macros/`, its
|
||||
macros. Never published: `cargo` points `sausage-plugin = "0.1"` at its
|
||||
local copy.
|
||||
- `agent/`: the agent plugin. `sausage init` embeds and builds it.
|
||||
- `examples/`: small plugins. They are not workspace members.
|
||||
- `PROTOCOL.md`: the contract between the core and the plugins. Change it in
|
||||
- `cli/`: the host (`sausage` binary and library).
|
||||
- `lib.rs`: `Host`, which starts and stops a world.
|
||||
- `broker.rs`: routing, mailboxes, the version store, broker requests.
|
||||
- `supervisor.rs`: runs one WASM actor (restarts, swaps, rollbacks).
|
||||
- `runtime.rs`: Wasmtime instances and what they get (mounts, network).
|
||||
- `candidate.rs`: tests a new version. `cargo_actor.rs`: builds Rust.
|
||||
- `world.rs`: the on-disk layout. `console.rs`: the terminal.
|
||||
- `sdk/`: `sausage-actor`, the SDK for Rust actors. Never published: the
|
||||
`cargo` actor points `sausage-actor = "0.1"` at its local copy.
|
||||
- `agent/`: the agent actor. `sausage init` embeds and builds it.
|
||||
- `examples/`: small actors. They are not workspace members.
|
||||
- `PROTOCOL.md`: the contract between the host and the actors. Change it in
|
||||
the same commit as the behavior. `DECISIONS.md` says why; when the two
|
||||
disagree, `PROTOCOL.md` and the code win.
|
||||
|
||||
@@ -36,21 +36,10 @@ Keep it that way.
|
||||
|
||||
```sh
|
||||
nix flake check -L # all tests below; CI runs this on every push
|
||||
nix develop -c cargo test --workspace # fast with a warm cache: run it during development
|
||||
nix develop -c cargo test -p sausage --test host -- core:: # one topic of the host tests
|
||||
nix develop -c cargo test -p sausage -p sausage-actor -p agent
|
||||
nix develop -c cargo build --release -p agent --target wasm32-wasip2
|
||||
nix develop -c cargo clippy --workspace
|
||||
```
|
||||
|
||||
`nix run -- init` and `nix run -- run` must keep working without other
|
||||
setup.
|
||||
|
||||
Tests must stay fast:
|
||||
|
||||
- Only a few tests may wait for a timing (grace periods, deadlines).
|
||||
- Poll for a condition with a deadline. Do not sleep.
|
||||
- Do not test unrealistic load, such as many fast cycles.
|
||||
- `#[ignore]` only disables a test for a short time, with the reason in the
|
||||
attribute.
|
||||
- Tests that build plugins use the persistent cache in
|
||||
`env!("CARGO_TARGET_TMPDIR")`.
|
||||
|
||||
Generated
+35
-767
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -1,11 +1,10 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["cli", "vfs", "interface", "sdk", "sdk/macros", "agent"]
|
||||
members = ["cli", "agent", "sdk"]
|
||||
default-members = ["cli"]
|
||||
# Examples are standalone plugins, each with its own empty [workspace].
|
||||
exclude = ["examples"]
|
||||
|
||||
# Wasmtime compiles components with Cranelift. Optimize dependencies in dev
|
||||
# builds, so that tests and debug runs start plugins quickly.
|
||||
# builds, so that tests and debug runs start actors quickly.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
+40
-105
@@ -1,120 +1,55 @@
|
||||
# Decisions
|
||||
|
||||
Why Sausage is the way it is. `PROTOCOL.md` and the code own the details.
|
||||
When this file disagrees with them, they win.
|
||||
Why Sausage is the way it is. `PROTOCOL.md` and the code own the details;
|
||||
when this file disagrees with them, they win.
|
||||
|
||||
## The shape
|
||||
|
||||
- **JSON-RPC, not component interfaces.** Every language can read and write
|
||||
lines of JSON, and models know JSON-RPC from LSP and MCP. A call has a
|
||||
result, like an LLM tool call.
|
||||
- **A process per call.** WASI has no threads, so processes give
|
||||
parallelism. 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. A long-running process must ask for `detach`.
|
||||
- **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.
|
||||
- **The core accepts only `.wasm` files.** It knows no source language.
|
||||
`cargo` is one way to get a `.wasm` file.
|
||||
- **The world folder is the source of truth.** Copy a plugin folder into
|
||||
another world, and it works there.
|
||||
- **The agent is a normal plugin.** It can read and upgrade its own source,
|
||||
and change how it does inference.
|
||||
|
||||
## Interfaces
|
||||
|
||||
- **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. It never has to guess.
|
||||
- **Docs are part of the interface.** Other agents work from them, so the
|
||||
SDK requires a doc comment on each method, type, and field.
|
||||
- **The check covers calls, not state.** Old and new versions share
|
||||
`/state`. A plugin keeps its state format compatible, or migrates it in
|
||||
`rpc.retired`.
|
||||
- **Actors are WASI commands, not components with interfaces.** stdin,
|
||||
stdout, files, HTTP, and sockets are enough, and every language can do
|
||||
them.
|
||||
- **Messages are JSON lines through a broker, fire and forget.** Like
|
||||
Erlang: no acknowledgments, no request/response. A reply is a message with
|
||||
`reply_to`. Sausage is not a reliable message bus.
|
||||
- **The broker accepts only `.wasm` files.** It knows no source language.
|
||||
`cargo` is one compiler actor; others can add more languages.
|
||||
- **The world folder is the source of truth.** Everything about an actor is
|
||||
in `actor/<id>/`. Copy a folder into another world, and it runs there.
|
||||
- **The agent is a normal WASM actor.** It can read and upgrade its own
|
||||
source, and change how it does inference.
|
||||
|
||||
## Minimal on purpose
|
||||
|
||||
The core does only what a plugin cannot do for itself. Everything else can be
|
||||
a plugin, or a change that the agent makes to itself.
|
||||
The harness does only what it needs to keep going. Everything else can be an
|
||||
actor, or a change that the agent makes to itself.
|
||||
|
||||
- **No permissions, no limits, no hiding.** Any process can call, upgrade,
|
||||
or stop any plugin, read and write every file, and use the network. The
|
||||
checks protect against mistakes, not against plugins. Locks are advisory.
|
||||
- **Locks are in the core.** A plugin cannot see that another process died,
|
||||
so it cannot release that process's lock. The core can.
|
||||
- **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.
|
||||
- **The host is not self-modifiable; actors are.** So the host keeps only
|
||||
what an actor cannot do for itself: routing, running instances, versions,
|
||||
tests before an install, and the rollback of a version that crash-loops.
|
||||
Without that rollback, a broken self-upgrade would end the agent.
|
||||
- **No permissions, no limits, no hiding.** Any actor can message, upgrade,
|
||||
or stop any actor, read every folder, and use the network. People who want rules
|
||||
can build them.
|
||||
- **No re-delivery and no state snapshots.** A crash loses the message, and
|
||||
all versions of an actor share `data/` and `public/`. A rollback restores
|
||||
code, not data.
|
||||
- **The agent is dumb.** It has a short prompt, a few tools, an append-only
|
||||
history that it sends in full, and no compaction. When the context is too
|
||||
large, it stops sending the oldest turns, so it never gets stuck.
|
||||
|
||||
## Small choices
|
||||
|
||||
- **Restart is a clean stop supervised by `#loop`.** `core.restart` requires
|
||||
the host startup marker `SAUSAGE_LOOP=1`, which the loop exports for its
|
||||
invocation. Without a supervisor, restarting would only stop the world.
|
||||
A single pending request answers first, allows 250 ms for delivery, then
|
||||
signals the existing shutdown path; it does not use `process::exit` or
|
||||
bypass detached-process cleanup, lock release, or journal draining. The
|
||||
executable returns dedicated restart code **75** after cleanup. `#loop`
|
||||
refreshes and starts again on 75 (and other nonzero exits); 0 stops it.
|
||||
`core.exit` is a compatibility alias with the same supervisor gate,
|
||||
response grace, and coalescing; new callers use `core.restart`.
|
||||
**HARD RULE: the mediator and all agents NEVER call `core.restart` without
|
||||
the person's explicit approval each time.** This
|
||||
approval rule also covers legacy `core.exit`; it must not bypass the gate.
|
||||
|
||||
- **YAML for configuration, JSON for data.** A person edits configuration, so
|
||||
it needs comments. `init` writes commented templates instead of asking
|
||||
questions, so there is one way to configure a world.
|
||||
- **Plain POSIX files.** Programs like `tempfile`, git, and SQLite expect
|
||||
normal files. Atomic writes use a temporary file and `rename`.
|
||||
- **Tests run under WASI.** `cargo test` on the host cannot find failures
|
||||
that occur only in WASI, for example a missing WASI function or a panic at
|
||||
start. The test runner uses the same Wasmtime and the same mounts as the
|
||||
core.
|
||||
- **Tests never run without a timeout.** `cargo.build` serializes its work
|
||||
folder, so one hung test or build script must not block all later builds.
|
||||
`timeout_secs` defaults to 900 seconds after mutex acquisition, excluding
|
||||
queue time; `test_timeout_secs` defaults to 120 seconds per WASI instance.
|
||||
Both are optional positive integers, never null/unlimited. Test timeouts name
|
||||
the failing test and let other instances finish. Build deadlines and
|
||||
cancellation kill cargo's process group before dropping the mutex; timeout
|
||||
errors retain the phase, elapsed time, and recent output. Source preparation
|
||||
and publication retain serialization while their blocking filesystem work
|
||||
finishes, even if cancelled, to avoid overlapping writes to the work folder.
|
||||
- **The SDK is not published.** `cargo` points `sausage-plugin = "0.1"` at
|
||||
its own copy, which it also shows in `/plugins/cargo/sdk/`.
|
||||
- **Compiled code is a cache, not state.** It lives outside the world, in
|
||||
one cache folder that all worlds share: `wasmtime/` (owned by Wasmtime,
|
||||
whose cleanup deletes unknown entries), `cargo-target/`, and
|
||||
`sdk/<hash>/`. A path that depends only on the content of the SDK lets
|
||||
cargo reuse the compiled SDK in every world. Tests run in the release
|
||||
profile, so that they share compiled crates with the release build.
|
||||
- **mold links host code where Nix provides it.** On Linux, the dev shell,
|
||||
the Nix builds, and the packaged `sausage` (for the proc macros and build
|
||||
scripts of plugins) link with mold, much faster than GNU ld. Plugins for
|
||||
`wasm32-wasip2` link with rust-lld. Without Nix, the default linker stays.
|
||||
- **No LTO.** Measured: fat LTO makes the binary 28 % smaller, but compiles
|
||||
plugins and answers calls at most 5–8 % faster, for 4 more minutes per
|
||||
release build. Thin LTO brings nothing.
|
||||
- **Discovery has a deadline of 10 seconds.** It is an internal call of the
|
||||
core, not a limit on plugins. Without it, one plugin that does not answer
|
||||
would block every activation.
|
||||
- **A broken plugin blocks nobody.** A plugin without a document is already
|
||||
broken, so the check cannot protect it. The graph shows it as
|
||||
"unavailable", and `core.revert` and `force` always work.
|
||||
- **`rpc.retired` is best effort.** It does not run if the host stops first.
|
||||
A persistent queue of retirements would be more core for a rare case, so a
|
||||
new version checks its own migration marker instead.
|
||||
- **Plain POSIX files.** An earlier version staged every write until close.
|
||||
That broke `tempfile`, git, and SQLite, which expect normal files. Atomic
|
||||
publishing is the usual temporary file + `rename`.
|
||||
- **Test first, then switch.** A new version is tested where it lies. Only
|
||||
a version that passes goes to `versions/`, and the old one runs until
|
||||
then. `versions/` keeps every version that was installed, so a rollback
|
||||
can always go back. A file that fails stays where it was put.
|
||||
- **Tests are JSON transcripts** next to the source. They run on a copy of
|
||||
the actor's state, without network, so a test cannot send real requests.
|
||||
- **`broker` and `cargo` never answer a reply,** so no two actors answer
|
||||
each other forever. The agent starts a turn only for text that is not a
|
||||
reply, for the same reason.
|
||||
- **The SDK is not published.** `cargo` points `sausage-actor = "0.1"` at its
|
||||
own copy, which it also shows in `/actor/cargo/public/sdk/`.
|
||||
|
||||
+208
-571
@@ -1,622 +1,259 @@
|
||||
# The Sausage protocol
|
||||
|
||||
This is the contract between the core and the plugins. A plugin in any
|
||||
language that follows it works in a Sausage world. The core shows this file
|
||||
at `/proc/core/PROTOCOL.md`. `DECISIONS.md` in the repository says why the
|
||||
contract is the way it is.
|
||||
This is the contract between the host and the actors. An actor in any
|
||||
language that follows it works in Sausage. The host publishes this file at
|
||||
`/actor/broker/public/PROTOCOL.md`, so every actor can read it.
|
||||
|
||||
Rust plugins use the SDK `sausage-plugin`. Its source is at
|
||||
`/plugins/cargo/sdk/`, and its crate docs explain each item.
|
||||
## Actors
|
||||
|
||||
## 1. Terms
|
||||
An actor is a WASI 0.2 command (`wasm32-wasip2`): a program with `main`. The
|
||||
host runs one instance per actor. An instance can run forever.
|
||||
|
||||
| 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.yaml`. The core starts it at boot, restarts it after a crash, and starts it again after an upgrade of its plugin. |
|
||||
| import | An interface of another plugin that a plugin uses. |
|
||||
| provider, consumer | The plugin that has an interface, and a plugin that imports it. |
|
||||
| journal | The central log of the world. |
|
||||
The instance gets:
|
||||
|
||||
## 2. The world folder
|
||||
- **stdin:** one message per line, from the broker
|
||||
- **stdout:** one message per line, to the broker
|
||||
- **stderr:** free text, appended to `/actor/<id>/stderr.log`
|
||||
- **environment:** `SAUSAGE_ACTOR` (its ID), `SAUSAGE_VERSION` (its version),
|
||||
and `SAUSAGE_CANDIDATE=1` during a test run
|
||||
- **the network:** outgoing HTTP and HTTPS through `wasi:http` (the host does
|
||||
TLS), and TCP, UDP, and DNS through `wasi:sockets`, including listening on
|
||||
a port of the host
|
||||
- **the filesystem** described below
|
||||
|
||||
```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
|
||||
logs/core/journal.sqlite the journal
|
||||
state/core/ console history (state, not logs)
|
||||
config/ global configuration
|
||||
boot.yaml the services
|
||||
agent.yaml the API, base URL, model, and key of the agent
|
||||
## Messages
|
||||
|
||||
A line on **stdin**:
|
||||
|
||||
```json
|
||||
{"id": "m-17", "from": "console", "reply_to": "agent/a5", "body": {"type": "text", "text": "hi"}}
|
||||
```
|
||||
|
||||
- Every process can read and write all of it. There are no permissions.
|
||||
- Configuration files are YAML, with comments. Data and interface files are
|
||||
JSON.
|
||||
- Plugin names use `a-z`, `0-9`, `-`, and `_`. The name `core` is reserved.
|
||||
- The world folder must not contain `proc`, `run`, or `self`. These names are
|
||||
mount points (section 4). The host does not start a world that has them.
|
||||
A line on **stdout**:
|
||||
|
||||
## 3. Calls and processes
|
||||
```json
|
||||
{"to": "console", "id": "a6", "reply_to": "m-17", "body": {"type": "text", "text": "hello"}}
|
||||
```
|
||||
|
||||
### 3.1 The wire protocol
|
||||
- `body` is required. By convention it is an object with a `"type"`.
|
||||
Text for people is `{"type": "text", "text": "..."}`.
|
||||
- `to` is the recipient. Without `to`, the broker publishes the message to
|
||||
every subscriber of the sender.
|
||||
- `id` is an optional string. If the actor sets it, the message ID is `"<sender>/<id>"`.
|
||||
Otherwise, the broker assigns `m-<n>`. The broker always sets `from`.
|
||||
- `reply_to` marks a reply: its value is the ID of the message it answers.
|
||||
There is no request/response. A reply is a new message.
|
||||
- **Never answer a reply,** or two actors can answer each other forever.
|
||||
`broker` and `cargo` ignore messages that have a `reply_to`.
|
||||
- Messages are fire and forget: delivery is at most once. Each actor has a
|
||||
mailbox of 1,000 messages, which keeps messages while the actor restarts
|
||||
or is upgraded. If the broker cannot deliver a message, the sender gets
|
||||
`{"type": "undeliverable", "reason": "...", "to": "..."}` from `broker`.
|
||||
- An actor must keep reading stdin. If the broker must stop or upgrade an
|
||||
actor while a message waits for it to read stdin, it kills the actor.
|
||||
|
||||
- A plugin is a `wasi:cli/command` component for `wasm32-wasip2`.
|
||||
- stdin and stdout carry JSON-RPC 2.0, one message per line.
|
||||
- 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.
|
||||
- Each line on stderr goes to the journal. Log to stderr.
|
||||
- `params` is always an object. Batches are not supported.
|
||||
- Write each message in one write, and write a newline before it. Then a
|
||||
`print!` without a newline cannot damage a message.
|
||||
Before the broker stops an instance, it sends
|
||||
`{"id": "broker/shutdown", "from": "broker", "body": {"type": "shutdown"}}`
|
||||
and then closes stdin. The broker still routes what the actor writes until
|
||||
it exits. After 5 seconds, it kills the instance.
|
||||
|
||||
### 3.2 Method names
|
||||
## Filesystem
|
||||
|
||||
- A method name has the form `<target>.<method>`. The core splits it at the
|
||||
first `.`.
|
||||
- `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 9).
|
||||
- Method names that start with `rpc.` are reserved: `rpc.discover`,
|
||||
`rpc.superseded`, `rpc.retired`, and `rpc.endpoint`.
|
||||
Each instance sees four folders:
|
||||
|
||||
### 3.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, and the method has no plugin prefix.
|
||||
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.
|
||||
- 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.
|
||||
|
||||
### 3.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.
|
||||
|
||||
### 3.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.
|
||||
|
||||
### 3.6 Lifetime
|
||||
|
||||
A process lives until `main` returns. A process that uses the SDK lives
|
||||
while one of these runs:
|
||||
|
||||
- a handler of a call,
|
||||
- a background task,
|
||||
- an endpoint that listens.
|
||||
|
||||
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 6) 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 3.7), and the end of the host stop it.
|
||||
- At host shutdown, the core starts no new processes, and it 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.
|
||||
|
||||
### 3.7 Upgrades
|
||||
|
||||
A process is an instance of one plugin version. When a new version becomes
|
||||
active (section 10.3):
|
||||
|
||||
1. New calls get the new version at once.
|
||||
2. At grace start, the core sends each process of an older version the
|
||||
JSON-RPC notification `rpc.superseded {"current": "<sha256 of the new file>"}`
|
||||
on stdin, without an ID. The process is still running: calls and responses
|
||||
continue to work. It can stop accepting work, finish its in-flight calls,
|
||||
and exit early. Ignoring the notice changes nothing. The grace period is
|
||||
the upper bound before the core stops the process (section 3.6); its
|
||||
duration is 5 seconds for every plugin. Discovery metadata, including an
|
||||
explicit `x-sausage-grace`, does not change it. After grace, stdin closes
|
||||
and the separate 5-second stop budget applies (section 3.6): at most two
|
||||
5-second windows. Discovery-only processes do not receive this notice or
|
||||
enter upgrade grace; they follow the discovery lifetime (section 10.1).
|
||||
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.
|
||||
|
||||
If the host stops before the last old process has ended, `rpc.retired` does
|
||||
not run for that version. So a new version must also work with state that
|
||||
was not migrated. Check for a migration marker, and migrate when it is
|
||||
missing.
|
||||
|
||||
### 3.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`.
|
||||
|
||||
A process that computes for a long time does not block other processes. The
|
||||
core interrupts it every 10 milliseconds.
|
||||
|
||||
### 3.9 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. |
|
||||
| -32004 | `cargo.build` failed. `data` has the `output` of the failed step and the `tests`. |
|
||||
|
||||
## 4. The filesystem
|
||||
|
||||
Each process gets one preopen, `/`. The core resolves each path through a
|
||||
mount table:
|
||||
|
||||
| Path | What it is | Access |
|
||||
| Path | Access | Contents |
|
||||
| --- | --- | --- |
|
||||
| `/` | the world folder | read-write |
|
||||
| `/proc` | generated by the core | read-only |
|
||||
| `/self` | the folder `/proc/<own pid>/`, in memory | read-write |
|
||||
| `/run` | in memory, shared, empty at each host start | read-write |
|
||||
| `/data` | read-write by its actor; readable by every actor | the actor's state |
|
||||
| `/public` | read-write | the files that the actor publishes |
|
||||
| `/actor` | read-only | the `actor/` folder of the world |
|
||||
| `/config` | read-write by every actor | the world's shared `config/` folder; persists across restarts |
|
||||
|
||||
A listing of `/` shows `proc`, `run`, and `self`. A rename across two of
|
||||
these areas fails.
|
||||
`/config` is the same world folder for every instance, including candidates;
|
||||
writers coordinate by convention, not permissions. Do not put secrets there.
|
||||
|
||||
```text
|
||||
/proc/
|
||||
core/
|
||||
PROTOCOL.md this file
|
||||
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
|
||||
```
|
||||
An actor can write only its own `/data`, but can read every actor's `/data` through `/actor/<id>/data/`. `/data` is not private: any unprotected secret file there is readable by every actor.
|
||||
|
||||
Rules:
|
||||
`/actor/<id>/` holds everything about an actor: `actor.wasm`, `actor.json`
|
||||
(installed version, running, subscriptions, tapped, watches), `source/`, `public/`, `data/`,
|
||||
`stderr.log`, and `versions/<n>/`. `ls /actor` lists all actors.
|
||||
|
||||
- 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.
|
||||
Files are plain POSIX files: a write is visible at once. To publish a file
|
||||
so that readers never see half of it, write a temporary file and `rename` it
|
||||
(the Rust SDK has `write_atomic`).
|
||||
|
||||
Principle: **inspect through files, act through calls.**
|
||||
Send large or binary data as a path under `/actor/<id>/public/`, not inside a
|
||||
message.
|
||||
|
||||
### File watching
|
||||
### Actor-served files: `/actor/<id>/fs/`
|
||||
|
||||
`core.watch {"path": "/state/session/mailbox"}` stays open for the caller
|
||||
process's lifetime. It needs a request ID and a running plugin caller.
|
||||
Its first `core.notify` is `"armed"`, with empty params. Only then scan the
|
||||
files. Writes before arming are visible in that scan; writes after arming
|
||||
produce `"changed"` notifications (also with empty params). Rescan after
|
||||
each notification; files, not event counts, are the truth.
|
||||
Files under `/actor/<id>/fs/` are not on disk: the actor `<id>` serves them.
|
||||
Every actor can read and write them. The host turns each file operation into a
|
||||
message from `fs` to `<id>` and waits up to 10 seconds for the reply. `path`
|
||||
is relative to `fs/` and starts with `/` (the folder itself is `/`).
|
||||
|
||||
Notifications follow successful plugin-originated file mutations: creation,
|
||||
truncation, descriptor/stream writes, rename (both source and destination),
|
||||
and deletion, including mounted `/run` and `/self` files. Paths are normalized
|
||||
in the virtual namespace; `/self` means the caller's `/proc/<pid>` folder.
|
||||
A watch matches itself and descendants, not sibling prefixes. Moving or
|
||||
removing an ancestor also invalidates it. Paths need not exist when armed.
|
||||
Open file writes follow plugin renames; unlinked/replaced handles no longer
|
||||
name visible files. External host writes are outside this contract.
|
||||
To make this virtual directory discoverable, an actor that serves it declares
|
||||
`Actor-served files` and `fs_read` in its leading `//!` documentation in
|
||||
`source/src/main.rs`. The host uses that declaration when listing
|
||||
`/actor/<id>/`; it does not probe every actor (which could stall on actors that
|
||||
do not handle filesystem messages). This is discovery metadata only: opening
|
||||
and reading the path still requires a valid `fs_read` reply.
|
||||
|
||||
Changes may coalesce, including a change already visible in the initial
|
||||
scan, but a change after the last notification always produces another.
|
||||
Delivery holds no filesystem locks. Observation does not serialize descriptor
|
||||
IO; only overlapping opens and rename/removal wait for one another.
|
||||
Watch the narrowest useful path: delivery is not rate-limited, so watching a
|
||||
busy parent can cause a rescan for every write.
|
||||
| Request | Reply |
|
||||
| --- | --- |
|
||||
| `{"type": "fs_read", "path"}` | `{"type": "fs_file", "text"}` (or `"base64"` instead of `"text"`), or `{"type": "fs_dir", "entries": [{"name", "type": "file" \| "dir"}]}` |
|
||||
| `{"type": "fs_write", "path", "text"}` (or `"base64"` for non-UTF-8 data) | `{"type": "fs_done"}` |
|
||||
| `{"type": "fs_mkdir", "path"}`, `{"type": "fs_remove", "path"}` | `{"type": "fs_done"}` |
|
||||
| `{"type": "fs_rename", "path", "to"}` | `{"type": "fs_done"}` |
|
||||
|
||||
The host-side watch ends only when its caller process exits, not on a change
|
||||
notification; there is no cancellation API or polling/retry loop. SDK callers
|
||||
should poll the watch future alongside their handler's work and drop it on
|
||||
return, not keep it in a background task that prevents an idle process from
|
||||
exiting. Dropping the future removes the SDK's pending entry and callback, so
|
||||
late notifications and responses are ignored; it does not cancel the host
|
||||
watch. A long-lived service keeps one watch per purpose for its lifetime,
|
||||
rather than repeatedly opening and dropping watches.
|
||||
Any request can be answered with `{"type": "fs_error", "errno": "no-entry"}`.
|
||||
`errno` is a `wasi:filesystem` error code: `no-entry`, `exist`,
|
||||
`not-directory`, `is-directory`, `not-empty`, `access`, `not-permitted`,
|
||||
`invalid`, `unsupported`, `busy`, or `io` (also for anything else).
|
||||
|
||||
## 5. Endpoints
|
||||
- Opening a file sends `fs_read` once. Reads come from that copy.
|
||||
- After a change, closing (or syncing) the file sends the whole file in one
|
||||
`fs_write`.
|
||||
- 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/`
|
||||
waits for itself and fails after the timeout.
|
||||
- `fs` is reserved: no actor can have that ID. It also sends `fs_changed`
|
||||
to watchers (see Watches).
|
||||
|
||||
An endpoint is a JSON-RPC interface that a running process serves over TCP,
|
||||
for example the control interface of a web server.
|
||||
## The broker
|
||||
|
||||
- The process binds a free port on 127.0.0.1. When the listener is ready, it
|
||||
writes an endpoint file in one atomic step (write a temporary file, then
|
||||
rename it). The file has one line: the address, the owner process, and the
|
||||
interface. Example: `tcp:127.0.0.1:41234 www-0197… control`.
|
||||
- Usually the file is in `/self`. A service that others must find uses a
|
||||
fixed path in `/run`, for example `/run/secrets`.
|
||||
- A client reads the file and connects. Its first request is
|
||||
`rpc.endpoint`, which returns `{"owner", "plugin", "interface"}`. If they
|
||||
do not match the file, the client disconnects. So a stale file or a reused
|
||||
port cannot connect a client to the wrong process.
|
||||
- JSON-RPC runs on the connection, one message per line, in both directions.
|
||||
Notifications of a handler (`core.notify`) go over the connection.
|
||||
- A plugin declares its endpoint interfaces in `rpc.discover`
|
||||
(`x-sausage-endpoints`).
|
||||
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`,
|
||||
`rolled_back`, `stopped`, `started`, `removed`, `subscribed`, `unsubscribed`,
|
||||
`tapping`, `untapped`, `watching`, `unwatched`, `exiting`), or `{"type": "error", "reason": "..."}`.
|
||||
|
||||
## 6. Locks
|
||||
|
||||
- `core.lock {"path": "/state/notes/notes.json"}` returns `{"held": true}`
|
||||
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}` (a notification) releases the lock.
|
||||
- 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. 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.
|
||||
|
||||
## 7. The journal
|
||||
|
||||
- New entries go to `/logs/core/journal.sqlite`. Only the core writes it.
|
||||
An existing `/state/core/journal.sqlite` stays in place and is opened
|
||||
read-only; queries merge both databases by entry ID before applying the
|
||||
limit. The host never renames a live SQLite/WAL trio. Console history stays
|
||||
in `/state/core`. No journal or log files are automatically pruned.
|
||||
- If a journal transaction fails, the complete batch (including original
|
||||
IDs and timestamps) is saved in an exclusive, synced JSON file under
|
||||
`/logs/core/failed-batches/`; the host reports its path on stderr. These
|
||||
files are recovery artifacts, not query results: recovery/import is
|
||||
deliberate, using IDs to avoid duplicates, and never deletes the source.
|
||||
If spooling also fails, entries stay in memory and retry on the next
|
||||
command (no polling); at writer shutdown their full text is reported to
|
||||
host stderr. Storage failure plus host loss can still lose unsaved data.
|
||||
- Each entry has a UUIDv7 (it sorts by time), the process ID, the plugin, the
|
||||
stream (`stderr` or `core`), and the line.
|
||||
- Traps retain their full diagnostic text in the journal; short exit-status
|
||||
displays are UTF-8-boundary-safe.
|
||||
- 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.
|
||||
|
||||
## 8. Boot and services
|
||||
|
||||
At start, the core starts each service in `/config/boot.yaml`, in order:
|
||||
|
||||
```yaml
|
||||
- method: secrets.serve
|
||||
params: {}
|
||||
- method: www.start
|
||||
params:
|
||||
port: 8080
|
||||
```
|
||||
|
||||
- A service is a detached call with the parent `core`. The core waits for
|
||||
its result before it starts the next one. The result shows that the
|
||||
service is ready.
|
||||
- If a service fails before answering, the core writes the error to the
|
||||
journal and continues booting. A failure to admit a process (for example,
|
||||
a missing plugin or invalid parameters) is logged, not retried in a loop.
|
||||
- The core supervises the process lifetime, not its readiness response. A
|
||||
nonzero exit, trap or failed host task restarts the same service with its
|
||||
original method and parameters, including a crash before readiness. A
|
||||
readiness response alone never triggers a restart; a clean exit does not
|
||||
count as a crash or trigger an immediate restart.
|
||||
- More than 5 crashes within 10 seconds gives up on that service and writes
|
||||
`service gave up` to the journal. Each service has an independent sliding
|
||||
window; crashes exactly 10 seconds old expire. There is no retry delay or
|
||||
periodic liveness check. Giving up is latched for this host lifetime:
|
||||
neither elapsed time nor an upgrade re-arms it.
|
||||
- After an upgrade of the plugin of a service, the same supervisor starts
|
||||
it again with the current version after its old process ends (section
|
||||
3.7). Superseded exits are retirement, not crashes. Admission and cleanup
|
||||
reserve a single owner/attempt, so an upgrade racing a crash cannot start
|
||||
two owners. A matching boot-service start from `rpc.retired` is ignored
|
||||
(returns `null`), even while restart is pending or after giving up; older
|
||||
plugins' fallback starts do not bypass supervision.
|
||||
- An explicit `core.stop` or `core.kill` disables automatic attempts for
|
||||
that service, including during an upgrade. Host shutdown starts no new
|
||||
processes. 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.
|
||||
|
||||
## 9. The core API
|
||||
|
||||
| Method | Params | Result |
|
||||
| `type` | Fields | Effect |
|
||||
| --- | --- | --- |
|
||||
| `core.call` | `method`, `params`, `detach`? | the result of the method |
|
||||
| `core.watch` | `path` | stays open; `armed`, then `changed` notifications (section 4) |
|
||||
| `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`? | short activation summaries, keyed by plugin |
|
||||
| `core.revert` | `plugin`, `force`? | like `core.activate` |
|
||||
| `core.log` | filters (section 7) | journal entries |
|
||||
| `core.restart` | none | `{}`. With `SAUSAGE_LOOP=1`, schedule clean shutdown and exit code 75. |
|
||||
| `core.exit` | none | Alias of `core.restart`: same supervisor gate, response, coalescing, and exit code 75. |
|
||||
| `spawn` | `actor`, `wasm`, `source`?, `built_by`? | test, then start a new actor |
|
||||
| `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; like `upgrade`, a stopped actor stays stopped. The answer has `running` |
|
||||
| `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` |
|
||||
| `subscribe`, `unsubscribe` | `actor` | receive (or not) what `actor` publishes |
|
||||
| `tap`, `untap` | | receive (or not) a copy of every message the broker routes |
|
||||
| `watch`, `unwatch` | `path` | receive (or not) `fs_changed` for every change of `path` or anything under it; answer `{"type": "watching" \| "unwatched", "path"}` with the path as an `/actor/...` path |
|
||||
| `exit` | | answer `exiting`, stop every actor, and end the `sausage` process with exit code 75 (a wrapper like `nix run .#loop` starts it again, updated) |
|
||||
|
||||
### Host restart
|
||||
**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.
|
||||
|
||||
**HARD RULE: the mediator and all agents NEVER call `core.restart` without
|
||||
the person's explicit approval each time.** The same approval rule applies
|
||||
to the legacy alias `core.exit`; it uses the same gate and pending request.
|
||||
Paths are `/actor/...`, `/config/...`, or the sender's `/public/...` or `/data/...`.
|
||||
|
||||
`core.restart {}` is enabled only when the host starts with `SAUSAGE_LOOP=1`.
|
||||
The host captures the marker at startup. An absent marker (or any value other
|
||||
than `1`) returns error -32600: `restart needs the loop supervisor
|
||||
(SAUSAGE_LOOP not set); start with SAUSAGE_LOOP=1`, without scheduling an exit.
|
||||
This is a supervisor marker, not a permission system.
|
||||
**Taps.** After `tap`, the sender gets a copy of every message that the
|
||||
broker routes (direct messages, replies, and publishes, between any actors,
|
||||
including its own), for debugging, logging, or a live view of the world:
|
||||
|
||||
The method returns `{}` immediately and schedules the shutdown request after
|
||||
250 milliseconds to let the response reach the caller before stdin closes.
|
||||
Repeated requests while a restart is pending return `{}` without scheduling
|
||||
another restart or extending that delay. The executable then uses the same
|
||||
`Host::stop` path as a normal stop: reject new processes, stop all processes
|
||||
(including detached ones) in parallel with the normal five-second grace,
|
||||
wait for outstanding file operations, release locks, and drain journal writes.
|
||||
Only after that does the executable return the dedicated restart exit code
|
||||
**75**. The library signals this path; it never kills its embedding process.
|
||||
```json
|
||||
{"type": "tapped", "message": {"id": "m-17", "from": "console", "to": "agent", "reply_to": null, "body": {...}}, "ts": 1792132579067}
|
||||
```
|
||||
|
||||
`nix run .#loop -- <world>` sets `SAUSAGE_LOOP=1` on the host invocation and
|
||||
starts the newest host again after exit code 75 (and, as before, other nonzero
|
||||
exits). Exit code 0 ends the loop. If using an older loop script, the person
|
||||
must set `SAUSAGE_LOOP=1` in that supervisor's environment; do not set it for
|
||||
an unsupervised host. The NixEnv service invokes this flake's `#loop`, so a
|
||||
new loop script supplies the marker without changes to NixEnv.
|
||||
- `to` is `null` for a publish: one copy per publish, not per subscriber.
|
||||
- `ts` is the time of routing, in milliseconds since the Unix epoch.
|
||||
- Copies come from `broker`. They are not routed themselves, so they are
|
||||
never tapped, and each message is tapped once per tapper.
|
||||
- A copy that does not fit into the tapper's mailbox is dropped; routing
|
||||
never waits for a tapper, and nobody gets `undeliverable` for a copy.
|
||||
- Never answer a copy: the answer would be tapped again, forever.
|
||||
- Like subscriptions, a tap is saved in `actor.json` (`"tapped": true`) and
|
||||
survives restarts and upgrades. `untap` ends it.
|
||||
|
||||
Everything else is a file.
|
||||
**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`):
|
||||
|
||||
## 10. Interfaces
|
||||
```json
|
||||
{"type": "fs_changed", "path": "/actor/notes/public/list.md", "op": "write"}
|
||||
```
|
||||
|
||||
### 10.1 `rpc.discover`
|
||||
- `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": "/actor", "op": "rescan"}`: anything may
|
||||
have changed.
|
||||
- A watch is saved in `actor.json` (`"watches": [...]`) and survives
|
||||
restarts and upgrades. `unwatch` with the same path ends it.
|
||||
|
||||
Each plugin answers `rpc.discover` with an OpenRPC 1.2 document. The schemas
|
||||
are JSON Schema Draft 7, as OpenRPC requires. Shared schemas are in
|
||||
`components.schemas`, and `$ref`s point to `#/components/schemas/<Name>`.
|
||||
Params are by name. The document can have these extension fields:
|
||||
**Versions.** A new version (from `spawn`, `upgrade`, or a changed
|
||||
`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>/`
|
||||
(`actor.wasm`, `source/`, `meta.json`) and switch to it. So `versions/` holds
|
||||
exactly the versions that were installed, and instances always run from
|
||||
there. If the tests fail, nothing changes, and the sender gets an `error`
|
||||
with the reason. All versions share the actor's `data/` and `public/`: a
|
||||
new version must read the data of the old one.
|
||||
|
||||
- `x-sausage-params` for each method with params: a `$ref` to the schema of
|
||||
the params object.
|
||||
- `x-sausage-endpoints`: the endpoint interfaces by name, each with its
|
||||
`methods`.
|
||||
- `x-sausage-imports`: for each provider, the part of its document that the
|
||||
plugin uses.
|
||||
- `x-sausage-grace`: legacy grace declaration in seconds. The core ignores
|
||||
this field; upgrade grace is always 5 seconds (section 3.7).
|
||||
**Tests.** The broker runs the candidate on a copy of the actor's `data/`
|
||||
and `public/`, without network. The tests are JSON files in
|
||||
`<source>/tests/*.json`, one scenario per file:
|
||||
|
||||
The schema of params describes what the provider can read. The schema of a
|
||||
result describes what the provider writes. Descriptions are part of the
|
||||
interface: other agents read them to use it.
|
||||
```json
|
||||
{
|
||||
"description": "add, then list",
|
||||
"steps": [
|
||||
{"send": {"type": "add", "text": "milk"}, "expect": {"type": "added"}},
|
||||
{"send": {"type": "list"}, "expect": [{"type": "notes"}]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The core starts a process only to call `rpc.discover`, and it stops that
|
||||
process after the answer. Do not do expensive work at start. If no answer
|
||||
comes within 10 seconds, the discovery fails.
|
||||
Each step sends `send` (from `from`, default `test`), then reads the next
|
||||
line that the candidate writes for each `expect` entry, in order, within 10
|
||||
seconds. An entry matches when it is a subset of the message body. With
|
||||
`to`, the message must also go to that recipient. Each file runs in a fresh
|
||||
instance. A version without test files passes.
|
||||
|
||||
### 10.2 Compatibility
|
||||
**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
|
||||
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.
|
||||
|
||||
A provider can add methods, optional params, and result fields. For each
|
||||
imported method:
|
||||
**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
|
||||
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"}`.
|
||||
|
||||
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 folder is the truth.** At start, the broker boots every
|
||||
`actor/<id>/actor.wasm`. Afterwards, the file watcher reports a new or
|
||||
changed `actor/<id>/actor.wasm` (or a new folder `actor/<id>/`) once it is
|
||||
quiet for about 100 ms: a new file becomes a new actor, a changed one an
|
||||
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 same rules apply to endpoint interfaces.
|
||||
- There are no version numbers. This check replaces them.
|
||||
- The check covers a fixed subset of JSON Schema: objects with `properties`
|
||||
and `required`, the primitive types, arrays, optional and nullable values,
|
||||
string enums, integer ranges, and `$ref`. A construct outside the subset
|
||||
fails the check.
|
||||
- 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`.
|
||||
## The `cargo` actor
|
||||
|
||||
### 10.3 Activation
|
||||
```json
|
||||
{"type": "build", "source": "/actor/agent/public/drafts/notes"}
|
||||
```
|
||||
|
||||
`core.activate {"plugin": "notes", "wasm": "/state/cargo/builds/7/notes.wasm"}`:
|
||||
The build request may include `"skip_tests": true` to skip Rust unit tests.
|
||||
By default, `cargo` first runs `cargo test` for the host target (not WASI),
|
||||
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.
|
||||
|
||||
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 gets the current documents of all plugins (section 10.4). A plugin
|
||||
whose discovery fails is "unavailable". Its imports cannot be checked,
|
||||
but it does not block the activation of other plugins.
|
||||
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 3.7). If the copy
|
||||
has the same hash as the current version, nothing changes.
|
||||
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`.
|
||||
|
||||
If a check fails, nothing changes, and the error -32003 lists each problem,
|
||||
with a counterexample where possible.
|
||||
|
||||
The result is an object keyed by plugin name. Each value contains:
|
||||
|
||||
- `plugin`: the plugin name.
|
||||
- `wasm`: the installed world path (`/plugins/<name>/plugin.wasm`).
|
||||
- `hash`: the SHA-256 of the installed copy.
|
||||
- `previous`: the replaced version's SHA-256, or `null` on first installation.
|
||||
For an identical-hash no-op, this is the current hash.
|
||||
- `method_count`: the number of methods in the new main interface.
|
||||
- `added`, `removed`, `changed`: sorted method-name arrays compared with the
|
||||
previous discovery document. A change to a method description or any
|
||||
transitively referenced local schema counts as changed. Unrelated schemas
|
||||
and endpoint interfaces do not affect these arrays. With no previous
|
||||
document, all methods are added.
|
||||
- `interface_check`: `"passed"` after successful checks, or `"skipped"` with
|
||||
`force: true` (not a claim of compatibility).
|
||||
|
||||
The result does not include schemas. The full document remains available
|
||||
through `<plugin>.rpc.discover` and `/proc/core/plugins/<name>/discover.json`.
|
||||
An identical-hash no-op and `core.revert` return the same summary shape.
|
||||
|
||||
- The core runs one activation at a time.
|
||||
- `plugins: [...]` activates several plugins together. The checks then use
|
||||
the new set.
|
||||
- `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.
|
||||
|
||||
### 10.4 The graph
|
||||
|
||||
- The core keeps the `rpc.discover` documents of all plugins, 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. `/proc/core/graph.json` then shows each broken import, and
|
||||
the journal records it.
|
||||
- An import of a plugin that does not exist is "missing". An import of a
|
||||
plugin whose discovery failed is "unavailable".
|
||||
|
||||
## 11. Building and tests
|
||||
|
||||
The core has one native plugin: `cargo`. `cargo.build {"plugin": "notes"}`
|
||||
builds `/plugins/notes/source/`:
|
||||
|
||||
1. It copies the source once. The tests and the build both use this copy.
|
||||
2. It runs the tests under WASI, in the release profile.
|
||||
3. It builds the release `.wasm` for `wasm32-wasip2`. The tests and the build
|
||||
share the compiled dependencies.
|
||||
4. It returns `{"wasm": "/state/cargo/builds/<n>/notes.wasm", "tests":
|
||||
{"passed", "failed", "output"}, "warnings", "logs"}`. If a step fails, it returns
|
||||
an error with the compiler or test output.
|
||||
|
||||
- Before running commands, it creates `/logs/cargo/<YYYY-MM-DD>/<build-id>.stdout.log`
|
||||
and `.stderr.log` (UTC date, unique build ID). Both test and release phases
|
||||
append their raw bytes directly to these files, before any parsing, progress
|
||||
filtering or UTF-8 conversion. This includes successful JSON build output.
|
||||
`/logs` is a top-level world folder, created if missing, not under `/state`.
|
||||
- Success results and error -32004 `data` include `logs`:
|
||||
`{"stdout":{"path":"/logs/cargo/...stdout.log","bytes":123},
|
||||
"stderr":{"path":"/logs/cargo/...stderr.log","bytes":45},"total_bytes":168}`.
|
||||
Counts are raw bytes, not characters. `logs` is null only if log creation
|
||||
itself fails. Preparation failures otherwise include empty log files.
|
||||
Inline diagnostics may be filtered or shortened; the files are the full truth.
|
||||
- Logs remain after failure, total/test timeout, and cancellation. Cancellation
|
||||
need not return a result; its files remain discoverable in `/logs/cargo/`.
|
||||
Pruning numeric `/state/cargo/builds/` artifact folders does not prune logs.
|
||||
- Optional `timeout_secs` is an integer of at least 1, default **900** (not
|
||||
null or unlimited). The wall-clock budget starts when the build acquires its
|
||||
mutex, excluding queue time, and covers tests and release compilation together.
|
||||
On expiry, cargo and its descendant process group are killed and the mutex is
|
||||
released. Error -32004 has reason/message `timeout`, with `data.reason`,
|
||||
`data.elapsed_secs`, `data.step` (`tests` or `release build`), `data.tests`,
|
||||
and `data.output` containing the last 50 output lines.
|
||||
- Optional `test_timeout_secs` is an integer of at least 1, default **120**.
|
||||
Each WASI test instance has this wall-clock budget. On expiry, the instance
|
||||
is killed, its named test fails with `timed out after N s`, and remaining
|
||||
tests continue. The overall build budget still applies.
|
||||
- Cancellation kills cargo's process group, including build scripts and test
|
||||
runners, before releasing the build mutex. An attached build also stops when
|
||||
its caller ends (under the normal process stopping deadline).
|
||||
- A call to `cargo.build` gets a process ID, a folder in `/proc`, and journal
|
||||
entries, like other calls.
|
||||
- `/plugins/cargo/sdk/` is the SDK used by builds. The dependency
|
||||
`sausage-plugin = "0.1"` points directly to this folder, including its macros
|
||||
and interface crate. Edits apply to the next build without a host restart.
|
||||
- The host seeds a missing SDK folder from its embedded copy at startup or
|
||||
before a build. An existing folder is never overwritten or repaired, even
|
||||
after a host upgrade. To reset to the host's bundled SDK, remove the whole
|
||||
folder; the next startup or build re-seeds it. Back up local edits first.
|
||||
- Compiled crates stay in the host's cache, outside the world, and all worlds
|
||||
share them. So a second build compiles only what changed.
|
||||
- Build scripts can compile C: `CC_wasm32_wasip2` and `AR_wasm32_wasip2`
|
||||
point to a WASI clang.
|
||||
- The build does not activate the result. Call `core.activate` next.
|
||||
|
||||
Tests:
|
||||
|
||||
- Each test runs in its own instance, so a panic ends only its own test.
|
||||
- The normal Rust test setup works: `#[test]`, `tests/*.rs`, name filters,
|
||||
and `--ignored`. `#[should_panic]` cannot work, because WASI cannot catch
|
||||
a panic.
|
||||
- Each test gets an empty temporary world as `/`, the package folder at its
|
||||
host path, and its own `/proc`, `/self`, and `/run`.
|
||||
- The current folder of a test is `/`, not the package folder. Read fixture
|
||||
files through `env!("CARGO_MANIFEST_DIR")`.
|
||||
- The test runner answers `core.lock`, `core.unlock`, `core.notify`, and
|
||||
`core.log`. A call to a plugin fails: a test has no other plugins.
|
||||
|
||||
## 12. Embedded agent tool output
|
||||
|
||||
The agent shipped by `sausage init` uses a context-size guard of 50 KiB or
|
||||
2000 lines, not a data-retention limit. Oversized tool results, including
|
||||
errors, are saved in full under `/logs/agent/<date>/default/` before history
|
||||
and provider delivery receive an excerpt. Overflow JSON string values are
|
||||
rendered as physical text lines so the reference's one-based inclusive line
|
||||
ranges can be followed with `read_file {path, line_offset, line_limit}`
|
||||
(zero-based offset; default 200 lines). Huge single lines give exact byte
|
||||
ranges and continuation offsets. Command/log/build/test results show the
|
||||
tail; other tools show the head. Reading an existing `/logs` file excerpts
|
||||
that same file without making another archive. Archives are exclusive and
|
||||
never automatically deleted. If archival fails, the agent reports the error
|
||||
to stderr and keeps the complete result instead of discarding it. Existing
|
||||
history formats and paths are unchanged.
|
||||
A Rust actor depends on the SDK with `sausage-actor = "0.1"`. `cargo` points
|
||||
that dependency at the SDK of the host, which it publishes at
|
||||
`/actor/cargo/public/sdk/`.
|
||||
|
||||
@@ -2,25 +2,23 @@
|
||||
|
||||
You can see how the sausage is made.
|
||||
|
||||
Sausage is a world of small WASI programs ("plugins") that you grow by
|
||||
talking to it. A plugin is a `.wasm` file with JSON-RPC methods. Each call
|
||||
to a plugin starts a new process. The agent is a plugin too: it writes new
|
||||
plugins in Rust, builds them, tests them, activates them, and can change its
|
||||
own code.
|
||||
Sausage is a world of small programs ("actors") that you grow by talking to
|
||||
it. Each actor is a WASI program with stdin and stdout. Actors send each other
|
||||
JSON messages, one per line, through a broker. The agent is an actor too: it
|
||||
writes new actors in Rust, builds them, tests them, starts them, and can
|
||||
change its own code.
|
||||
|
||||
## Start
|
||||
|
||||
You need [Nix](https://nixos.org/download) with flakes, and an LLM: the OpenAI
|
||||
Responses API, an OpenAI-compatible Chat Completions API (OpenRouter, Ollama,
|
||||
and most other providers), or the Anthropic Messages API. No clone needed:
|
||||
You need [Nix](https://nixos.org/download) with flakes, and an API token for
|
||||
an LLM (OpenAI Responses or Anthropic Messages API). No clone needed:
|
||||
|
||||
```sh
|
||||
nix run git+https://gitea.surma.technology/surma/sausage
|
||||
```
|
||||
|
||||
This creates a world in `./world` the first time (it builds the agent: the
|
||||
first build on a machine takes a few minutes, later worlds reuse the compiled
|
||||
crates), then starts it with a console. Later runs start the
|
||||
This creates a world in `./world` the first time (it builds the agent, which
|
||||
takes a few minutes), then starts it with a console. Later runs start the
|
||||
existing world. The two steps on their own:
|
||||
|
||||
```sh
|
||||
@@ -28,15 +26,11 @@ nix run git+https://gitea.surma.technology/surma/sausage -- init # creates ./w
|
||||
nix run git+https://gitea.surma.technology/surma/sausage -- run # starts the world and a console
|
||||
```
|
||||
|
||||
`init` asks nothing. It writes `world/config/agent.yaml`, with a comment for
|
||||
each field and an example for each API. Edit it before the first chat: choose
|
||||
the API, the base URL, the model, the key, and the thinking level. Until then, a chat answers with
|
||||
an error that names the missing field. Both commands take another folder as
|
||||
an argument, for example `... -- run my-world`.
|
||||
|
||||
Compiled code is cached outside the world, in `$XDG_CACHE_HOME/sausage/`
|
||||
(`~/.cache/sausage/` without the variable). All worlds share it.
|
||||
`--cache-dir <folder>` changes it for `init` and `run`.
|
||||
`init` asks for the API shape (`openai-responses` or `anthropic-messages`),
|
||||
the base URL, the model, and the token. It reads `SAUSAGE_API`,
|
||||
`SAUSAGE_BASE_URL`, `SAUSAGE_MODEL`, and `SAUSAGE_API_TOKEN` first. The
|
||||
values go to `world/actor/agent/data/config.json`. Both commands take another
|
||||
folder as an argument, for example `... -- run my-world`.
|
||||
|
||||
To keep a world on the newest version, run it in a loop:
|
||||
|
||||
@@ -45,82 +39,60 @@ nix run git+https://gitea.surma.technology/surma/sausage#loop # ./
|
||||
nix run git+https://gitea.surma.technology/surma/sausage#loop -- my-world # an existing world
|
||||
```
|
||||
|
||||
When the host crashes or a plugin calls `core.restart`, the loop fetches the
|
||||
newest commit and starts it again. The loop sets `SAUSAGE_LOOP=1` to enable
|
||||
that RPC; without it, restart returns an error and leaves the host running.
|
||||
Restart answers first, then cleanly stops all processes and exits with code
|
||||
75. `quit` (code 0) ends the loop. Set `SAUSAGE_FLAKE` to run another flake
|
||||
URL instead, for example your fork.
|
||||
|
||||
**The mediator and all agents NEVER call `core.restart` without the person's
|
||||
explicit approval each time.** This also applies to legacy `core.exit`, an
|
||||
alias with the same supervisor gate, response-before-exit behavior, and
|
||||
request coalescing.
|
||||
See [the protocol](PROTOCOL.md#host-restart) for the shutdown contract.
|
||||
The loop runs `nix run --refresh git+https://gitea.surma.technology/surma/sausage -- run my-world`.
|
||||
When the host crashes or an actor sends `@broker {"type":"exit"}`, the loop
|
||||
fetches the newest commit and starts it again. `/quit` ends the loop. Set
|
||||
`SAUSAGE_FLAKE` to run another flake URL instead, for example your fork.
|
||||
|
||||
From a clone (`git clone https://gitea.surma.technology/surma/sausage`), the
|
||||
same commands are `nix run`, `nix run -- init`, `nix run -- run`, and
|
||||
same commands are `nix run`, `nix run -- init`, `nix run -- run` and
|
||||
`nix run .#loop`.
|
||||
|
||||
### Headless
|
||||
|
||||
`--headless` (or `SAUSAGE_HEADLESS=1`) turns the console input off. The host
|
||||
runs until SIGTERM or SIGINT (exit code 0) or until a plugin calls
|
||||
`core.restart` (requires `SAUSAGE_LOOP=1`; exit code 75 restarts the loop):
|
||||
`--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
|
||||
```
|
||||
|
||||
## The console
|
||||
In the console:
|
||||
|
||||
| Input | Effect |
|
||||
| --- | --- |
|
||||
| text | a message to the agent (`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 of a plugin, with params and docs |
|
||||
| `ps` | the running processes |
|
||||
| `log [plugin or pid] [-n N]` | the journal |
|
||||
| `activate <plugin> <wasm>`, `revert <plugin>` | activate a new version, or go back |
|
||||
| `stop <pid>`, `kill <pid>` | stop a process |
|
||||
| `help`, `quit` | |
|
||||
```text
|
||||
write an actor "notes" that stores notes, and start it → text to the agent
|
||||
@notes {"type":"add","text":"milk"} → a JSON message to an actor
|
||||
@agent hello → text to an actor
|
||||
/quit
|
||||
```
|
||||
|
||||
## The world folder
|
||||
|
||||
The folder is the source of truth. Every actor lives in `world/actor/<id>/`:
|
||||
|
||||
```text
|
||||
plugins/<name>/plugin.wasm the current version of a plugin
|
||||
plugins/<name>/source/ its source
|
||||
state/<plugin>/ the state of a plugin, by convention
|
||||
state/core/journal.sqlite the journal: stderr of all processes, and core events
|
||||
config/boot.yaml ordered boot services, supervised for crashes (PROTOCOL §8)
|
||||
config/agent.yaml the configuration of the agent
|
||||
actor.wasm the current version
|
||||
actor.json installed version, running, subscriptions
|
||||
source/ the source of the current version
|
||||
public/ files that the actor publishes
|
||||
data/ actor state; owner-writable, world-readable via /actor/<id>/data/ (not private)
|
||||
stderr.log what the actor and the broker write about it
|
||||
versions/<n>/ every installed version: actor.wasm, source/, meta.json
|
||||
```
|
||||
|
||||
Boot services restart on process crashes, not on readiness responses. More
|
||||
than 5 crashes within 10 seconds stops automatic attempts for that service
|
||||
and journals `service gave up`; an upgrade does not reset that decision.
|
||||
Explicitly stopping or killing a service also leaves it stopped for this
|
||||
host lifetime. See [Boot and services](PROTOCOL.md#8-boot-and-services).
|
||||
|
||||
Rust builds use the SDK in `plugins/cargo/sdk/` directly. The host seeds this
|
||||
folder only when it is missing; restarts and host upgrades preserve existing
|
||||
files. SDK edits take effect on the next `cargo.build`, without a restart.
|
||||
To reset to the host's bundled SDK, back up any edits and remove the whole
|
||||
folder; the next startup or build re-seeds it.
|
||||
|
||||
Each process also sees `/proc` (the processes and the state of the core),
|
||||
`/self` (its own folder in `/proc`), and `/run` (shared files in memory).
|
||||
|
||||
To replace a plugin by hand, copy a `.wasm` file to its `plugin.wasm`. That
|
||||
skips the interface checks. `revert <plugin>` goes back to the version
|
||||
before the last activation.
|
||||
To add or replace an actor by hand, put an `actor.wasm` (and its `source/`,
|
||||
with the tests in `source/tests/`) into its folder; the host watches the
|
||||
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.
|
||||
|
||||
## More
|
||||
|
||||
- [PROTOCOL.md](PROTOCOL.md): calls, processes, the filesystem, locks,
|
||||
interfaces, and `cargo`. Read this to write a plugin in any language.
|
||||
- [DECISIONS.md](DECISIONS.md): why Sausage is the way it is.
|
||||
- [`sdk/`](sdk/): `sausage-plugin`, the SDK for Rust plugins.
|
||||
- [`examples/`](examples/): small plugins.
|
||||
- [PROTOCOL.md](PROTOCOL.md): messages, the filesystem, the broker, `cargo`.
|
||||
Read this to write an actor in any language.
|
||||
- [`sdk/`](sdk/): `sausage-actor`, the SDK for Rust actors.
|
||||
- [`examples/notes`](examples/notes): a small actor with a test.
|
||||
- [AGENTS.md](AGENTS.md): how to work on Sausage itself.
|
||||
|
||||
+2
-5
@@ -2,13 +2,10 @@
|
||||
name = "agent"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "The Sausage agent plugin."
|
||||
description = "The Sausage agent actor."
|
||||
|
||||
[dependencies]
|
||||
sausage-plugin = { version = "0.1", path = "../sdk" }
|
||||
sausage-actor = { version = "0.1", path = "../sdk", features = ["async"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml_ng = "0.10"
|
||||
|
||||
[target.'cfg(target_os = "wasi")'.dependencies]
|
||||
wstd = "0.6"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use crate::llm::Entry;
|
||||
|
||||
/// Recognize provider errors that permit a retry with fewer complete turns.
|
||||
pub fn is_too_large(error: &str) -> bool {
|
||||
let Some((status, body)) = error
|
||||
.strip_prefix("HTTP ")
|
||||
@@ -10,10 +9,7 @@ pub fn is_too_large(error: &str) -> bool {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if status.split_whitespace().next() == Some("413") {
|
||||
return true;
|
||||
}
|
||||
if status.split_whitespace().next() != Some("400") {
|
||||
if !matches!(status.split_whitespace().next(), Some("400" | "413")) {
|
||||
return false;
|
||||
}
|
||||
let body = body.to_ascii_lowercase();
|
||||
@@ -27,7 +23,6 @@ pub fn is_too_large(error: &str) -> bool {
|
||||
.any(|term| body.contains(term)))
|
||||
}
|
||||
|
||||
/// Find a user-turn boundary without discarding any of the current turn.
|
||||
pub fn next_turn_start(history: &[Entry], from: usize, current: usize) -> Option<usize> {
|
||||
let end = current.min(history.len().checked_sub(1)?);
|
||||
(from..=end).find(|&index| matches!(&history[index], Entry::User { .. }))
|
||||
@@ -41,8 +36,6 @@ mod tests {
|
||||
fn detects_context_size_errors_only_for_http_400_or_413() {
|
||||
assert!(is_too_large("HTTP 400: context_length_exceeded"));
|
||||
assert!(is_too_large("HTTP 413: prompt is too long"));
|
||||
assert!(is_too_large("HTTP 413: {\"type\":\"request_too_large\"}"));
|
||||
assert!(is_too_large("HTTP 413: {}"));
|
||||
assert!(is_too_large("HTTP 400: maximum context length"));
|
||||
assert!(is_too_large("HTTP 400: request is too large"));
|
||||
assert!(!is_too_large("HTTP 500: prompt is too long"));
|
||||
|
||||
+64
-441
@@ -1,118 +1,29 @@
|
||||
//! Inference through OpenAI Responses, Chat Completions, and Anthropic Messages.
|
||||
//! Inference through two API shapes: OpenAI Responses and Anthropic Messages.
|
||||
//!
|
||||
//! The conversation is stored in a neutral format, so the agent can switch
|
||||
//! between the three shapes.
|
||||
//! between the two shapes.
|
||||
|
||||
use sausage_plugin::schema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
#[cfg(target_os = "wasi")]
|
||||
use wstd::http::{Body, Client, Request};
|
||||
|
||||
/// Inference configuration from `/config/agent.yaml`.
|
||||
#[derive(Deserialize, Clone, Default)]
|
||||
#[serde(default)]
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct Config {
|
||||
/// `openai-responses`, `openai-chat-completions`, or `anthropic-messages`.
|
||||
/// `openai-responses` or `anthropic-messages`.
|
||||
pub api: String,
|
||||
/// The SDK base URL: versioned for OpenAI, unversioned for Anthropic.
|
||||
pub base_url: String,
|
||||
/// The provider's model identifier.
|
||||
pub model: String,
|
||||
/// The API credential sent in the provider's authentication header.
|
||||
pub token: String,
|
||||
/// Permit an empty token and omit authentication headers.
|
||||
pub allow_no_token: bool,
|
||||
/// An optional cap on generated tokens.
|
||||
/// Optional reasoning effort for OpenAI models only (wins over `thinking` there).
|
||||
#[serde(default)]
|
||||
pub reasoning_effort: Option<String>,
|
||||
#[serde(default)]
|
||||
pub max_tokens: Option<u32>,
|
||||
/// The thinking level of each turn. Unset: the model's default.
|
||||
/// 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<Thinking>,
|
||||
}
|
||||
|
||||
/// How much the model thinks before it answers.
|
||||
///
|
||||
/// OpenAI Responses gets `reasoning.effort`, Chat Completions gets `reasoning_effort`, and Anthropic
|
||||
/// gets adaptive thinking with `output_config.effort`. If the model rejects a level, the agent uses
|
||||
/// the nearest level that the model accepts, never `off`.
|
||||
#[schema]
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Thinking {
|
||||
/// No thinking: OpenAI gets the effort `none`, and Anthropic gets disabled thinking. If an
|
||||
/// OpenAI model cannot switch thinking off, the agent uses `low`.
|
||||
Off,
|
||||
/// The least thinking.
|
||||
Low,
|
||||
/// Moderate thinking.
|
||||
Medium,
|
||||
/// Much thinking.
|
||||
High,
|
||||
/// More thinking than `high`, where the model supports it.
|
||||
Xhigh,
|
||||
/// The most thinking that the model supports.
|
||||
Max,
|
||||
}
|
||||
|
||||
impl Thinking {
|
||||
/// The effort that an OpenAI request carries: `none`, or one of `LEVELS`.
|
||||
fn effort(self) -> &'static str {
|
||||
match self {
|
||||
Thinking::Off => "none",
|
||||
Thinking::Low => "low",
|
||||
Thinking::Medium => "medium",
|
||||
Thinking::High => "high",
|
||||
Thinking::Xhigh => "xhigh",
|
||||
Thinking::Max => "max",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self, String> {
|
||||
let bytes = std::fs::read("/config/agent.yaml")
|
||||
.map_err(|e| format!("/config/agent.yaml: missing configuration file: {e}"))?;
|
||||
Self::parse(&bytes)
|
||||
}
|
||||
|
||||
fn parse(bytes: &[u8]) -> Result<Self, String> {
|
||||
let cfg = serde_yaml_ng::from_slice::<Option<Self>>(bytes)
|
||||
.map_err(|e| format!("/config/agent.yaml: {e}"))?
|
||||
.unwrap_or_default();
|
||||
let invalid = if ![
|
||||
"openai-responses",
|
||||
"openai-chat-completions",
|
||||
"anthropic-messages",
|
||||
]
|
||||
.contains(&cfg.api.as_str())
|
||||
{
|
||||
Some("api (missing or unknown API shape)")
|
||||
} else if cfg.base_url.trim().is_empty() {
|
||||
Some("base_url")
|
||||
} else if cfg.model.trim().is_empty() {
|
||||
Some("model")
|
||||
} else if (cfg.token.trim().is_empty() && !cfg.allow_no_token)
|
||||
|| cfg.token == "CHANGE_ME"
|
||||
|| cfg.token.starts_with("YOUR_")
|
||||
{
|
||||
Some("token (set a key, or set token: \"\" and allow_no_token: true)")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(field) = invalid {
|
||||
return Err(format!("/config/agent.yaml: configure field {field}"));
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
fn bearer(&self) -> Vec<(&'static str, String)> {
|
||||
if self.token.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![("authorization", format!("Bearer {}", self.token))]
|
||||
}
|
||||
}
|
||||
pub thinking: Option<String>,
|
||||
}
|
||||
|
||||
/// Thinking levels, lowest first.
|
||||
@@ -121,22 +32,9 @@ 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()
|
||||
};
|
||||
// `none` is below all levels, so its nearest level is `low`.
|
||||
let at = LEVELS
|
||||
.iter()
|
||||
.position(|l| *l == want)
|
||||
.map_or(-1, |i| i as i32);
|
||||
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| {
|
||||
@@ -150,178 +48,50 @@ fn fallback_effort(want: &str, error: &str, tried: &[String]) -> Option<String>
|
||||
mod tests {
|
||||
use super::fallback_effort;
|
||||
|
||||
#[test]
|
||||
fn yaml_configuration_and_credentials() {
|
||||
use super::Config;
|
||||
for text in [
|
||||
"",
|
||||
"api: unknown",
|
||||
"api: openai-responses\nbase_url: url\nmodel: model\ntoken: CHANGE_ME",
|
||||
"api: openai-responses\nbase_url: url\nmodel: model\ntoken: ''",
|
||||
] {
|
||||
assert!(
|
||||
Config::parse(text.as_bytes())
|
||||
.err()
|
||||
.unwrap()
|
||||
.contains("/config/agent.yaml")
|
||||
);
|
||||
}
|
||||
let cfg = Config::parse(b"api: openai-chat-completions\nbase_url: http://localhost/v1\nmodel: local\ntoken: ''\nallow_no_token: true").unwrap();
|
||||
assert!(cfg.bearer().is_empty());
|
||||
assert_eq!(cfg.thinking, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_levels() {
|
||||
use super::{Config, LEVELS, Thinking};
|
||||
let base = "api: openai-responses\nbase_url: url\nmodel: model\ntoken: key\nthinking: ";
|
||||
let parse = |level: &str| Config::parse(format!("{base}{level}").as_bytes());
|
||||
assert_eq!(parse("off").unwrap().thinking, Some(Thinking::Off));
|
||||
assert_eq!(Thinking::Off.effort(), "none");
|
||||
for level in LEVELS {
|
||||
assert_eq!(parse(level).unwrap().thinking.unwrap().effort(), level);
|
||||
}
|
||||
let error = parse("hihg").err().unwrap();
|
||||
assert!(error.contains("/config/agent.yaml"), "{error}");
|
||||
assert!(error.contains("thinking"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_request_and_response_mapping() {
|
||||
use super::*;
|
||||
let cfg = Config {
|
||||
model: "test".into(),
|
||||
max_tokens: Some(100),
|
||||
..Config::default()
|
||||
};
|
||||
let response = json!({"choices":[{"message":{"content":"interim","tool_calls":[{"id":"one","function":{"name":"read_file","arguments":"{}"}},{"id":"two","function":{"name":"read_file","arguments":"{}"}}]}}]});
|
||||
let mut history = chat_entries(&response);
|
||||
assert_eq!(history.len(), 3);
|
||||
history.push(Entry::ToolResult {
|
||||
id: "one".into(),
|
||||
output: "error".into(),
|
||||
});
|
||||
let tools = [Tool {
|
||||
name: "read_file",
|
||||
description: "read",
|
||||
parameters: json!({"type":"object"}),
|
||||
}];
|
||||
let body = chat_body(&cfg, "system", &tools, &history, Some("high"));
|
||||
assert_eq!(body["messages"][1]["content"], "interim");
|
||||
assert_eq!(
|
||||
body["messages"][1]["tool_calls"].as_array().unwrap().len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
body["messages"][2],
|
||||
json!({"role":"tool","tool_call_id":"one","content":"error"})
|
||||
);
|
||||
assert_eq!(body["tools"][0]["function"]["name"], "read_file");
|
||||
assert_eq!(body["reasoning_effort"], "high");
|
||||
assert_eq!(body["max_completion_tokens"], 100);
|
||||
assert!(
|
||||
matches!(&chat_entries(&json!({"choices":[{"message":{"content":"final"}}]}))[0], Entry::Assistant {text,..} if text == "final")
|
||||
);
|
||||
}
|
||||
|
||||
#[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: 'max'. Supported values are: 'none', 'minimal', 'low', 'medium', and 'high'.";
|
||||
assert_eq!(
|
||||
fallback_effort("max", openai, &["max".into()]).as_deref(),
|
||||
Some("high")
|
||||
);
|
||||
// A thinking-only model rejects `none`: use the lowest level.
|
||||
let glm = "HTTP 400: GLM-5.3 is a thinking-only model; disabling thinking (reasoning_effort='none') is not supported.";
|
||||
assert_eq!(
|
||||
fallback_effort("none", glm, &["none".into()]).as_deref(),
|
||||
Some("low")
|
||||
);
|
||||
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")
|
||||
);
|
||||
assert_eq!(fallback_effort("max", "HTTP 400: effort max not supported", &["max".into()]).as_deref(), Some("xhigh"));
|
||||
}
|
||||
}
|
||||
|
||||
/// One append-only entry in the provider-neutral conversation.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Entry {
|
||||
/// A person's contribution starts a new turn.
|
||||
User {
|
||||
/// The complete contribution.
|
||||
text: String,
|
||||
},
|
||||
/// Text produced by the model.
|
||||
User { text: String },
|
||||
Assistant {
|
||||
/// The generated text.
|
||||
text: String,
|
||||
/// The optional provider phase, preserved for subsequent requests.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
phase: Option<String>,
|
||||
},
|
||||
/// A requested tool operation.
|
||||
ToolCall {
|
||||
/// The provider's correlation identifier.
|
||||
id: String,
|
||||
/// The tool name.
|
||||
name: String,
|
||||
/// The JSON parameter object as text.
|
||||
arguments: String,
|
||||
},
|
||||
/// The outcome of a tool operation, including tool errors.
|
||||
ToolResult {
|
||||
/// The correlation identifier of the tool call.
|
||||
id: String,
|
||||
/// The JSON outcome as text.
|
||||
output: String,
|
||||
},
|
||||
ToolCall { id: String, name: String, arguments: String },
|
||||
ToolResult { id: String, output: String },
|
||||
/// A provider item that only this API shape understands (OpenAI reasoning, Anthropic thinking).
|
||||
Opaque {
|
||||
/// The API shape that owns this entry.
|
||||
api: String,
|
||||
/// The model that produced this item. Legacy items without a model are not replayed.
|
||||
#[serde(default)]
|
||||
model: String,
|
||||
/// The original provider object.
|
||||
item: Value,
|
||||
},
|
||||
Opaque { api: String, item: Value },
|
||||
}
|
||||
|
||||
/// A tool contract shared by all API shapes.
|
||||
pub struct Tool {
|
||||
/// The function identifier exposed to the model.
|
||||
pub name: &'static str,
|
||||
/// The explanation of the tool's behavior.
|
||||
pub description: &'static str,
|
||||
/// The JSON Schema for named parameters.
|
||||
pub parameters: Value,
|
||||
}
|
||||
|
||||
/// Request the next model step, with provider-specific reasoning items intact.
|
||||
pub async fn complete(
|
||||
cfg: &Config,
|
||||
system: &str,
|
||||
tools: &[Tool],
|
||||
history: &[Entry],
|
||||
) -> Result<Vec<Entry>, String> {
|
||||
let want = cfg.thinking.map(|level| level.effort().to_string());
|
||||
pub async fn complete(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
|
||||
let want = match cfg.api.as_str() {
|
||||
"openai-responses" => cfg.reasoning_effort.clone().or_else(|| cfg.thinking.clone()),
|
||||
_ => cfg.thinking.clone(),
|
||||
}
|
||||
.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,
|
||||
"openai-chat-completions" => chat(cfg, system, tools, history, effort.as_deref()).await,
|
||||
"anthropic-messages" => anthropic(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()) {
|
||||
@@ -329,58 +99,31 @@ pub async fn complete(
|
||||
(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
|
||||
);
|
||||
eprintln!("agent: {} rejected thinking effort {current:?}; retrying with {effort:?}: {e}", cfg.model);
|
||||
}
|
||||
(result, _) => return result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Native unit tests exercise history logic without an HTTP runtime.
|
||||
#[cfg(not(target_os = "wasi"))]
|
||||
async fn post(_url: &str, _headers: &[(&str, String)], _body: &Value) -> Result<Value, String> {
|
||||
Err("inference requires the WASI HTTP runtime".into())
|
||||
}
|
||||
|
||||
/// Send JSON through wasi:http and retain error bodies for context-size detection.
|
||||
#[cfg(target_os = "wasi")]
|
||||
async fn post(url: &str, headers: &[(&str, String)], body: &Value) -> Result<Value, String> {
|
||||
let mut req = Request::post(url).header("content-type", "application/json");
|
||||
for (k, v) in headers {
|
||||
req = req.header(*k, v.as_str());
|
||||
}
|
||||
let req = req
|
||||
.body(Body::from(body.to_string()))
|
||||
.map_err(|e| format!("request: {e}"))?;
|
||||
let req = req.body(Body::from(body.to_string())).map_err(|e| format!("request: {e}"))?;
|
||||
let client = Client::new();
|
||||
let resp = client
|
||||
.send(req)
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request failed: {e:?}"))?;
|
||||
let resp = client.send(req).await.map_err(|e| format!("HTTP request failed: {e:?}"))?;
|
||||
let status = resp.status();
|
||||
let mut body = resp.into_body();
|
||||
let text = body
|
||||
.str_contents()
|
||||
.await
|
||||
.map_err(|e| format!("reading the response failed: {e:?}"))?
|
||||
.to_string();
|
||||
let text = body.str_contents().await.map_err(|e| format!("reading the response failed: {e:?}"))?.to_string();
|
||||
if !status.is_success() {
|
||||
return Err(format!("HTTP {status}: {text}"));
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|e| format!("invalid JSON in response: {e}"))
|
||||
}
|
||||
|
||||
/// Translate neutral history to OpenAI Responses and preserve returned reasoning items.
|
||||
async fn openai(
|
||||
cfg: &Config,
|
||||
system: &str,
|
||||
tools: &[Tool],
|
||||
history: &[Entry],
|
||||
effort: Option<&str>,
|
||||
) -> 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();
|
||||
for e in history {
|
||||
match e {
|
||||
@@ -398,7 +141,7 @@ async fn openai(
|
||||
Entry::ToolResult { id, output } => {
|
||||
input.push(json!({"type": "function_call_output", "call_id": id, "output": output}))
|
||||
}
|
||||
Entry::Opaque { api, model, item } if api == "openai-responses" && model == &cfg.model => input.push(item.clone()),
|
||||
Entry::Opaque { api, item } if api == "openai-responses" => input.push(item.clone()),
|
||||
Entry::Opaque { .. } => {}
|
||||
}
|
||||
}
|
||||
@@ -420,8 +163,8 @@ async fn openai(
|
||||
if let Some(effort) = effort {
|
||||
body["reasoning"] = json!({"effort": effort});
|
||||
}
|
||||
let url = format!("{}/responses", cfg.base_url.trim_end_matches('/'));
|
||||
let resp = post(&url, &cfg.bearer(), &body).await?;
|
||||
let url = format!("{}/v1/responses", cfg.base_url);
|
||||
let resp = post(&url, &[("authorization", format!("Bearer {}", cfg.token))], &body).await?;
|
||||
let mut out = Vec::new();
|
||||
for item in resp["output"].as_array().cloned().unwrap_or_default() {
|
||||
match item["type"].as_str() {
|
||||
@@ -446,36 +189,24 @@ async fn openai(
|
||||
name: item["name"].as_str().unwrap_or_default().to_string(),
|
||||
arguments: item["arguments"].as_str().unwrap_or("{}").to_string(),
|
||||
}),
|
||||
Some("reasoning") => out.push(Entry::Opaque {
|
||||
api: "openai-responses".into(),
|
||||
model: cfg.model.clone(),
|
||||
item,
|
||||
}),
|
||||
Some("reasoning") => out.push(Entry::Opaque { api: "openai-responses".into(), item }),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if resp["status"] == "incomplete"
|
||||
&& !out
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, Entry::Assistant { text, .. } if !text.is_empty()))
|
||||
{
|
||||
return Err(format!(
|
||||
"incomplete response: {}",
|
||||
resp["incomplete_details"]
|
||||
));
|
||||
if resp["status"] == "incomplete" && out.is_empty() {
|
||||
return Err(format!("incomplete response: {}", resp["incomplete_details"]));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Translate neutral history to Anthropic Messages and merge consecutive roles.
|
||||
async fn anthropic(
|
||||
cfg: &Config,
|
||||
system: &str,
|
||||
tools: &[Tool],
|
||||
history: &[Entry],
|
||||
thinking: bool,
|
||||
effort: Option<&str>,
|
||||
) -> Result<Vec<Entry>, String> {
|
||||
let disabled = cfg.thinking == Some(Thinking::Off);
|
||||
// Merge consecutive blocks of the same role into one message.
|
||||
let mut messages: Vec<(String, Vec<Value>)> = Vec::new();
|
||||
let mut push = |role: &str, block: Value| match messages.last_mut() {
|
||||
@@ -488,39 +219,22 @@ async fn anthropic(
|
||||
Entry::Assistant { text, .. } => {
|
||||
push("assistant", json!({"type": "text", "text": text}))
|
||||
}
|
||||
Entry::ToolCall {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
} => {
|
||||
Entry::ToolCall { id, name, arguments } => {
|
||||
let input: Value = serde_json::from_str(arguments).unwrap_or(json!({}));
|
||||
push(
|
||||
"assistant",
|
||||
json!({"type": "tool_use", "id": id, "name": name, "input": input}),
|
||||
)
|
||||
push("assistant", json!({"type": "tool_use", "id": id, "name": name, "input": input}))
|
||||
}
|
||||
Entry::ToolResult { id, output } => {
|
||||
push("user", json!({"type": "tool_result", "tool_use_id": id, "content": output}))
|
||||
}
|
||||
Entry::ToolResult { id, 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, model, item }
|
||||
if api == "anthropic-messages" && model == &cfg.model && !disabled =>
|
||||
{
|
||||
push("assistant", item.clone())
|
||||
}
|
||||
Entry::Opaque { api, item } if api == "anthropic-messages" && thinking => push("assistant", item.clone()),
|
||||
Entry::Opaque { .. } => {}
|
||||
}
|
||||
}
|
||||
let messages: Vec<Value> = messages
|
||||
.into_iter()
|
||||
.map(|(role, content)| json!({"role": role, "content": content}))
|
||||
.collect();
|
||||
let messages: Vec<Value> = messages.into_iter().map(|(role, content)| json!({"role": role, "content": content})).collect();
|
||||
let tools: Vec<Value> = tools
|
||||
.iter()
|
||||
.map(
|
||||
|t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters}),
|
||||
)
|
||||
.map(|t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters}))
|
||||
.collect();
|
||||
let mut body = json!({
|
||||
"model": cfg.model,
|
||||
@@ -529,23 +243,19 @@ async fn anthropic(
|
||||
"tools": tools,
|
||||
"max_tokens": cfg.max_tokens.unwrap_or(32000),
|
||||
});
|
||||
// Unset: the model's default. Newer models think by default.
|
||||
match cfg.thinking {
|
||||
None => {}
|
||||
Some(Thinking::Off) => body["thinking"] = json!({"type": "disabled"}),
|
||||
Some(_) => {
|
||||
body["thinking"] = json!({"type": "adaptive"});
|
||||
if let Some(effort) = effort {
|
||||
body["output_config"] = json!({"effort": effort});
|
||||
}
|
||||
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.trim_end_matches('/'));
|
||||
let mut headers = vec![("anthropic-version", "2023-06-01".to_string())];
|
||||
if !cfg.token.is_empty() {
|
||||
headers.push(("x-api-key", cfg.token.clone()));
|
||||
}
|
||||
let resp = post(&url, &headers, &body).await?;
|
||||
let url = format!("{}/v1/messages", cfg.base_url);
|
||||
let resp = post(
|
||||
&url,
|
||||
&[("x-api-key", cfg.token.clone()), ("anthropic-version", "2023-06-01".to_string())],
|
||||
&body,
|
||||
)
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
for block in resp["content"].as_array().cloned().unwrap_or_default() {
|
||||
match block["type"].as_str() {
|
||||
@@ -560,98 +270,11 @@ async fn anthropic(
|
||||
name: block["name"].as_str().unwrap_or_default().to_string(),
|
||||
arguments: block["input"].to_string(),
|
||||
}),
|
||||
Some("thinking") | Some("redacted_thinking") => out.push(Entry::Opaque {
|
||||
api: "anthropic-messages".into(),
|
||||
model: cfg.model.clone(),
|
||||
item: block,
|
||||
}),
|
||||
Some("thinking") | Some("redacted_thinking") => {
|
||||
out.push(Entry::Opaque { api: "anthropic-messages".into(), item: block })
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Translate neutral entries into the Chat Completions message format.
|
||||
fn chat_body(
|
||||
cfg: &Config,
|
||||
system: &str,
|
||||
tools: &[Tool],
|
||||
history: &[Entry],
|
||||
effort: Option<&str>,
|
||||
) -> Value {
|
||||
let mut messages = vec![json!({"role":"system", "content":system})];
|
||||
for entry in history {
|
||||
match entry {
|
||||
Entry::User { text } => messages.push(json!({"role":"user","content":text})),
|
||||
Entry::Assistant { text, .. } => {
|
||||
messages.push(json!({"role":"assistant","content":text}))
|
||||
}
|
||||
Entry::ToolCall {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
} => {
|
||||
if messages.last().unwrap()["role"] != "assistant" {
|
||||
messages.push(json!({"role":"assistant","content":null}));
|
||||
}
|
||||
let last = messages.last_mut().unwrap();
|
||||
if !last["tool_calls"].is_array() {
|
||||
last["tool_calls"] = json!([]);
|
||||
}
|
||||
last["tool_calls"].as_array_mut().unwrap().push(json!({"id":id,"type":"function","function":{"name":name,"arguments":arguments}}));
|
||||
}
|
||||
Entry::ToolResult { id, output } => {
|
||||
messages.push(json!({"role":"tool","tool_call_id":id,"content":output}))
|
||||
}
|
||||
Entry::Opaque { .. } => {}
|
||||
}
|
||||
}
|
||||
let tools: Vec<Value> = tools.iter().map(|t| json!({"type":"function","function":{"name":t.name,"description":t.description,"parameters":t.parameters}})).collect();
|
||||
let mut body = json!({"model":cfg.model,"messages":messages,"tools":tools});
|
||||
if let Some(max) = cfg.max_tokens {
|
||||
body["max_completion_tokens"] = json!(max);
|
||||
}
|
||||
if let Some(effort) = effort {
|
||||
body["reasoning_effort"] = json!(effort);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
/// Map text and tool calls back into the shared history.
|
||||
fn chat_entries(response: &Value) -> Vec<Entry> {
|
||||
let message = &response["choices"][0]["message"];
|
||||
let mut entries = Vec::new();
|
||||
if let Some(text) = message["content"].as_str().filter(|s| !s.is_empty()) {
|
||||
entries.push(Entry::Assistant {
|
||||
text: text.into(),
|
||||
phase: None,
|
||||
});
|
||||
}
|
||||
for call in message["tool_calls"].as_array().into_iter().flatten() {
|
||||
entries.push(Entry::ToolCall {
|
||||
id: call["id"].as_str().unwrap_or_default().into(),
|
||||
name: call["function"]["name"].as_str().unwrap_or_default().into(),
|
||||
arguments: call["function"]["arguments"]
|
||||
.as_str()
|
||||
.unwrap_or("{}")
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
cfg: &Config,
|
||||
system: &str,
|
||||
tools: &[Tool],
|
||||
history: &[Entry],
|
||||
effort: Option<&str>,
|
||||
) -> Result<Vec<Entry>, String> {
|
||||
let response = post(
|
||||
&format!("{}/chat/completions", cfg.base_url.trim_end_matches('/')),
|
||||
&cfg.bearer(),
|
||||
&chat_body(cfg, system, tools, history, effort),
|
||||
)
|
||||
.await?;
|
||||
Ok(chat_entries(&response))
|
||||
}
|
||||
|
||||
+306
-178
@@ -1,211 +1,339 @@
|
||||
//! Answer chat calls with an LLM and tools, under one lock for the complete turn.
|
||||
//! The Sausage agent actor.
|
||||
|
||||
mod history;
|
||||
mod llm;
|
||||
mod tools;
|
||||
|
||||
use history::{is_too_large, next_turn_start};
|
||||
use llm::{Config, Entry, Thinking};
|
||||
use sausage_plugin::{Ctx, Error, Result, lock, rpc, schema};
|
||||
use serde_json::json;
|
||||
use llm::{Config, Entry};
|
||||
use sausage_actor::{Inbox, Message, Value, json, publish, send};
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::io::Write;
|
||||
use wstd::future::FutureExt;
|
||||
use wstd::time::{Duration, Instant};
|
||||
|
||||
/// The instructions sent with every inference request.
|
||||
const SYSTEM: &str = include_str!("prompt.md");
|
||||
/// The shared, append-only conversation.
|
||||
const HISTORY: &str = "/state/agent/history.jsonl";
|
||||
/// The first retained turn after a context-size rejection.
|
||||
const SKIP: &str = "/state/agent/history.skip";
|
||||
/// Each chat process reads the shared history after it acquires the lock.
|
||||
struct Agent;
|
||||
/// The person's next contribution to the conversation.
|
||||
#[schema]
|
||||
struct ChatParams {
|
||||
/// The complete text of the person's request.
|
||||
text: String,
|
||||
/// The thinking level of this turn only. Without it, the turn uses `thinking` from
|
||||
/// `/config/agent.yaml`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
thinking: Option<Thinking>,
|
||||
const CONFIG: &str = "/data/config.json";
|
||||
const HISTORY: &str = "/data/history.jsonl";
|
||||
const HISTORY_SKIP: &str = "/data/history.skip";
|
||||
const TURN: &str = "/data/turn.json";
|
||||
|
||||
fn main() {
|
||||
wstd::runtime::block_on(async { Agent::new().run().await });
|
||||
}
|
||||
|
||||
struct Agent {
|
||||
inbox: Inbox,
|
||||
history: Vec<Entry>,
|
||||
history_skip: usize,
|
||||
active: bool,
|
||||
queue: VecDeque<Message>,
|
||||
notes: Vec<Message>,
|
||||
}
|
||||
|
||||
#[rpc(grace_seconds = 600)]
|
||||
impl Agent {
|
||||
/// Answer a person, with interim text notifications and a final text result.
|
||||
///
|
||||
/// The history lock covers inference and tools, so concurrent chats cannot interleave turns.
|
||||
async fn chat(&self, ctx: &Ctx, p: ChatParams) -> Result<String> {
|
||||
let _guard = lock(HISTORY).await?;
|
||||
std::fs::create_dir_all("/state/agent")?;
|
||||
let mut cfg = Config::load().map_err(|error| Error::new(-32603, error))?;
|
||||
cfg.thinking = p.thinking.or(cfg.thinking);
|
||||
let mut entries = load_history(HISTORY)?;
|
||||
repair_tools(&mut entries)?;
|
||||
let current = entries.len();
|
||||
append(&mut entries, Entry::User { text: p.text })?;
|
||||
let mut start = std::fs::read_to_string(SKIP)
|
||||
fn new() -> Self {
|
||||
let history = std::fs::read_to_string(HISTORY)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str(line).ok())
|
||||
.collect();
|
||||
let history_skip = std::fs::read_to_string(HISTORY_SKIP)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.and_then(|skip| skip.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
start = next_turn_start(&entries, start, current).unwrap_or(current);
|
||||
let definitions = tools::definitions();
|
||||
let active = std::fs::read_to_string(TURN)
|
||||
.ok()
|
||||
.and_then(|contents| serde_json::from_str::<bool>(&contents).ok())
|
||||
.unwrap_or(false);
|
||||
Self {
|
||||
inbox: Inbox::start(),
|
||||
history,
|
||||
history_skip,
|
||||
active,
|
||||
queue: VecDeque::new(),
|
||||
notes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_history(&mut self, entry: Entry) {
|
||||
let line = serde_json::to_string(&entry).unwrap();
|
||||
match std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(HISTORY)
|
||||
{
|
||||
Ok(mut file) => {
|
||||
if let Err(e) = writeln!(file, "{line}") {
|
||||
eprintln!("agent: cannot append history: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("agent: cannot append history: {e}"),
|
||||
}
|
||||
self.history.push(entry);
|
||||
}
|
||||
|
||||
fn finish_turn(&mut self) {
|
||||
self.active = false;
|
||||
let _ = std::fs::remove_file(TURN);
|
||||
}
|
||||
|
||||
fn closing(&self) -> bool {
|
||||
self.inbox.is_closed()
|
||||
}
|
||||
|
||||
fn repair_dangling_tools(&mut self) {
|
||||
let answered: HashSet<String> = self
|
||||
.history
|
||||
.iter()
|
||||
.filter_map(|entry| match entry {
|
||||
Entry::ToolResult { id, .. } => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let dangling: Vec<String> = self
|
||||
.history
|
||||
.iter()
|
||||
.filter_map(|entry| match entry {
|
||||
Entry::ToolCall { id, .. } if !answered.contains(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for id in dangling {
|
||||
self.add_history(Entry::ToolResult {
|
||||
id,
|
||||
output:
|
||||
json!({"error": "interrupted: the agent restarted before this tool finished"})
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn turn_text(&self, message: &Message) -> String {
|
||||
let mut text = format!(
|
||||
"Message {} from {}:\n{}",
|
||||
message.id,
|
||||
message.from,
|
||||
message.body["text"].as_str().unwrap_or("")
|
||||
);
|
||||
if !self.notes.is_empty() {
|
||||
let notes = self
|
||||
.notes
|
||||
.iter()
|
||||
.map(|note| serde_json::to_string(note).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
text = format!("Messages that arrived while you were idle:\n{notes}\n\n{text}");
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn not_configured(&mut self, error: String) {
|
||||
let _ = publish(json!({
|
||||
"type": "text",
|
||||
"text": format!("I am not configured: cannot read {CONFIG}: {error}")
|
||||
}));
|
||||
self.finish_turn();
|
||||
}
|
||||
|
||||
async fn run(mut self) {
|
||||
self.repair_dangling_tools();
|
||||
if self.active {
|
||||
self.turn(None).await;
|
||||
if self.closing() && self.active {
|
||||
return;
|
||||
}
|
||||
}
|
||||
loop {
|
||||
let response = match llm::complete(&cfg, SYSTEM, &definitions, &entries[start..]).await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
if is_too_large(&error)
|
||||
&& let Some(next) = next_turn_start(&entries, start + 1, current)
|
||||
{
|
||||
start = next;
|
||||
continue;
|
||||
let message = match self.queue.pop_front() {
|
||||
Some(message) => message,
|
||||
None => match self.inbox.recv().await {
|
||||
Some(message) => message,
|
||||
None => break,
|
||||
},
|
||||
};
|
||||
if starts_turn(&message) {
|
||||
self.turn(Some(message)).await;
|
||||
if self.closing() && self.active {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
self.notes.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn turn(&mut self, message: Option<Message>) {
|
||||
let current_start = if message.is_some() {
|
||||
self.active = true;
|
||||
let _ = std::fs::write(TURN, "true");
|
||||
self.history.len()
|
||||
} else {
|
||||
let current_start = self
|
||||
.history
|
||||
.iter()
|
||||
.rposition(|entry| matches!(entry, Entry::User { text } if !text.starts_with("[note from your runtime]")))
|
||||
.unwrap_or(self.history.len());
|
||||
let version = std::env::var("SAUSAGE_VERSION").unwrap_or_default();
|
||||
self.add_history(Entry::User {
|
||||
text: format!(
|
||||
"[note from your runtime] you restarted and now run version {version}. Continue the active turn."
|
||||
),
|
||||
});
|
||||
current_start
|
||||
};
|
||||
let cfg = match load_config() {
|
||||
Ok(cfg) => cfg,
|
||||
Err(error) => {
|
||||
self.not_configured(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(message) = message {
|
||||
let text = self.turn_text(&message);
|
||||
self.notes.clear();
|
||||
self.add_history(Entry::User { text });
|
||||
}
|
||||
self.steps(&cfg, current_start).await;
|
||||
if !self.closing() {
|
||||
self.finish_turn();
|
||||
}
|
||||
}
|
||||
|
||||
async fn steps(&mut self, cfg: &Config, current_start: usize) {
|
||||
let tools = tools::definitions();
|
||||
loop {
|
||||
if self.closing() {
|
||||
return;
|
||||
}
|
||||
let start = next_turn_start(&self.history, self.history_skip, current_start)
|
||||
.unwrap_or(current_start);
|
||||
if start != self.history_skip {
|
||||
self.history_skip = start;
|
||||
let _ = std::fs::write(HISTORY_SKIP, start.to_string());
|
||||
}
|
||||
let response = llm::complete(cfg, SYSTEM, &tools, &self.history[start..]).await;
|
||||
let entries = match response {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
if is_too_large(&e) {
|
||||
if let Some(next) =
|
||||
next_turn_start(&self.history, start.saturating_add(1), current_start)
|
||||
{
|
||||
self.history_skip = next;
|
||||
let _ = std::fs::write(HISTORY_SKIP, next.to_string());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return Err(Error::new(-32603, error));
|
||||
let text = format!("Inference failed: {e}");
|
||||
self.add_history(Entry::Assistant {
|
||||
text: text.clone(),
|
||||
phase: None,
|
||||
});
|
||||
let _ = publish(json!({"type": "text", "text": text}));
|
||||
return;
|
||||
}
|
||||
};
|
||||
std::fs::write(SKIP, start.to_string())?;
|
||||
let has_tools = response
|
||||
let calls: Vec<(String, String, String)> = entries
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, Entry::ToolCall { .. }));
|
||||
let mut final_text = String::new();
|
||||
let mut calls = Vec::new();
|
||||
for entry in response {
|
||||
match &entry {
|
||||
Entry::Assistant { text, phase }
|
||||
if has_tools || phase.as_deref() == Some("commentary") =>
|
||||
{
|
||||
ctx.notify("text", json!({"text":text}))?
|
||||
}
|
||||
Entry::Assistant { text, .. } => {
|
||||
if !final_text.is_empty() && !text.is_empty() {
|
||||
final_text.push_str("\n\n");
|
||||
}
|
||||
final_text.push_str(text);
|
||||
}
|
||||
.filter_map(|entry| match entry {
|
||||
Entry::ToolCall {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
} => calls.push((id.clone(), name.clone(), arguments.clone())),
|
||||
_ => {}
|
||||
} => Some((id.clone(), name.clone(), arguments.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for entry in entries {
|
||||
if let Entry::Assistant { text, .. } = &entry {
|
||||
if !text.is_empty() {
|
||||
let _ = publish(json!({"type": "text", "text": text}));
|
||||
}
|
||||
}
|
||||
append(&mut entries, entry)?;
|
||||
self.add_history(entry);
|
||||
}
|
||||
if !has_tools {
|
||||
return Ok(final_text);
|
||||
if self.closing() {
|
||||
return;
|
||||
}
|
||||
for (id, name, arguments) in calls {
|
||||
let args = serde_json::from_str::<serde_json::Value>(&arguments);
|
||||
let output = match &args {
|
||||
Ok(args) => tools::run(&name, args)
|
||||
.await
|
||||
.unwrap_or_else(|error| json!({"error":error})),
|
||||
Err(error) => json!({"error":format!("arguments are not valid JSON: {error}")}),
|
||||
};
|
||||
let output = if name == "read_file"
|
||||
&& args
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|a| a["path"].as_str())
|
||||
.is_some_and(|p| p.starts_with("/logs/"))
|
||||
{
|
||||
output["content"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| output.to_string())
|
||||
} else {
|
||||
output.to_string()
|
||||
};
|
||||
let tail = args
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|a| a["method"].as_str())
|
||||
.is_some_and(|m| {
|
||||
[".exec", ".log", ".build", ".test"]
|
||||
.iter()
|
||||
.any(|suffix| m.ends_with(suffix))
|
||||
});
|
||||
let output = tools::overflow::guard(output, "default", &id, tail);
|
||||
append(&mut entries, Entry::ToolResult { id, output })?;
|
||||
if calls.is_empty() {
|
||||
return;
|
||||
}
|
||||
for (id, name, args) in calls {
|
||||
if self.closing() {
|
||||
return;
|
||||
}
|
||||
let output = self.tool(&name, &args).await;
|
||||
self.add_history(Entry::ToolResult {
|
||||
id,
|
||||
output: output.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read persisted entries in their original order. A new world has no history file.
|
||||
fn load_history(path: &str) -> Result<Vec<Entry>> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => Ok(text
|
||||
.lines()
|
||||
.filter_map(|line| match serde_json::from_str(line) {
|
||||
Ok(entry) => Some(entry),
|
||||
Err(error) => {
|
||||
eprintln!("skip damaged history line: {error}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
/// Persist an entry before it enters the current inference context.
|
||||
fn append(history: &mut Vec<Entry>, entry: Entry) -> Result<()> {
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(HISTORY)?;
|
||||
file.write_all(format!("{}\n", serde_json::to_string(&entry)?).as_bytes())?;
|
||||
history.push(entry);
|
||||
Ok(())
|
||||
}
|
||||
/// Complete interrupted tool pairs before a new inference request.
|
||||
fn repair_tools(history: &mut Vec<Entry>) -> Result<()> {
|
||||
let answered: std::collections::HashSet<_> = history
|
||||
.iter()
|
||||
.filter_map(|entry| match entry {
|
||||
Entry::ToolResult { id, .. } => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let dangling: Vec<_> = history
|
||||
.iter()
|
||||
.filter_map(|entry| match entry {
|
||||
Entry::ToolCall { id, .. } if !answered.contains(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for id in dangling {
|
||||
append(
|
||||
history,
|
||||
Entry::ToolResult {
|
||||
id,
|
||||
output: json!({"error":"interrupted before the tool finished"}).to_string(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Start the plugin's JSON-RPC transport.
|
||||
fn main() {
|
||||
sausage_plugin::run(Agent);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn damaged_history_keeps_valid_entries() {
|
||||
let path = if cfg!(target_os = "wasi") {
|
||||
std::path::PathBuf::from("/history-test.jsonl")
|
||||
fn stash(&mut self, message: Message, others: &mut Vec<Value>) {
|
||||
if starts_turn(&message) {
|
||||
others.push(json!({"queued_for_a_later_turn": true, "message": message}));
|
||||
self.queue.push_back(message);
|
||||
} else {
|
||||
std::env::temp_dir().join(format!("sausage-history-{}", std::process::id()))
|
||||
others.push(serde_json::to_value(message).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_inbox(&mut self, others: &mut Vec<Value>) {
|
||||
while let Some(message) = self.inbox.try_recv() {
|
||||
self.stash(message, others);
|
||||
}
|
||||
}
|
||||
|
||||
async fn tool(&mut self, name: &str, args: &str) -> Value {
|
||||
let args: Value = match serde_json::from_str(args) {
|
||||
Ok(args) => args,
|
||||
Err(error) => return json!({"error": format!("arguments are not valid JSON: {error}")}),
|
||||
};
|
||||
std::fs::write(
|
||||
&path,
|
||||
"{\"kind\":\"user\",\"text\":\"preserved\"}\n{\"kind\":",
|
||||
)
|
||||
.unwrap();
|
||||
let history = super::load_history(path.to_str().unwrap()).unwrap();
|
||||
assert_eq!(history.len(), 1);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
if name == "send_message" {
|
||||
return self.send_message(&args).await;
|
||||
}
|
||||
tools::run(name, &args).unwrap_or_else(|error| json!({"error": error.to_string()}))
|
||||
}
|
||||
|
||||
async fn send_message(&mut self, args: &Value) -> Value {
|
||||
let Some(to) = args["to"].as_str() else {
|
||||
return json!({"error": "missing \"to\""});
|
||||
};
|
||||
if !args["body"].is_object() {
|
||||
return json!({"error": "\"body\" must be a JSON object"});
|
||||
}
|
||||
let id = send(to, args["body"].clone());
|
||||
let wait = args["wait_seconds"].as_f64().unwrap_or(0.0).max(0.0);
|
||||
let mut reply = Value::Null;
|
||||
let mut others = Vec::new();
|
||||
if wait > 0.0 {
|
||||
let deadline = Instant::now() + Duration::from_secs_f64(wait);
|
||||
loop {
|
||||
match self.inbox.recv().timeout(deadline).await {
|
||||
Ok(Some(message)) if message.reply_to.as_deref() == Some(&id) => {
|
||||
reply = serde_json::to_value(message).unwrap();
|
||||
break;
|
||||
}
|
||||
Ok(Some(message)) => self.stash(message, &mut others),
|
||||
Ok(None) => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
self.drain_inbox(&mut others);
|
||||
json!({"id": id, "reply": reply, "waited": wait > 0.0, "other_messages": others})
|
||||
}
|
||||
}
|
||||
|
||||
fn load_config() -> Result<Config, String> {
|
||||
std::fs::read_to_string(CONFIG)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|contents| serde_json::from_str(&contents).map_err(|e| e.to_string()))
|
||||
}
|
||||
|
||||
fn starts_turn(message: &Message) -> bool {
|
||||
message.kind() == "text" && message.reply_to.is_none()
|
||||
}
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
//! Context-size guard; full output is in the referenced file. Never a data limit.
|
||||
use std::io::{self, Write};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
// One policy point: Pi's 50 KiB / 2000 lines, including room for the reference.
|
||||
const CONTEXT_LIMIT: (usize, usize) = (50 * 1024, 2000);
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn component(s: &str) -> String {
|
||||
s.bytes()
|
||||
.take(80)
|
||||
.map(|b| {
|
||||
if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' {
|
||||
b as char
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn exceeds_limit(output: &str) -> bool {
|
||||
output.len() > CONTEXT_LIMIT.0 || output.lines().count() > CONTEXT_LIMIT.1
|
||||
}
|
||||
|
||||
/// Save before excerpting. On storage failure return all data, not a false reference.
|
||||
pub fn guard(output: String, session: &str, id: &str, tail: bool) -> String {
|
||||
if !exceeds_limit(&output) {
|
||||
return output;
|
||||
}
|
||||
// Decode only results that crossed the context guard. Archive physical
|
||||
// string lines, not JSON escapes, even if decoding makes the text smaller.
|
||||
let output = serde_json::from_str::<serde_json::Value>(&output)
|
||||
.map(|value| super::render(&value))
|
||||
.unwrap_or(output);
|
||||
let pid = std::env::var("SAUSAGE_PID").unwrap_or_else(|_| "test".into());
|
||||
let dir = format!("/logs/agent/{}/{}", date(), component(session));
|
||||
let saved = (|| -> io::Result<String> {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
// create_new prevents overwriting even if an ID is reused or a process restarts.
|
||||
loop {
|
||||
let path = format!(
|
||||
"{dir}/{}-{}-{}.txt",
|
||||
component(id),
|
||||
component(&pid),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
match std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)
|
||||
{
|
||||
Ok(mut f) => {
|
||||
f.write_all(output.as_bytes())?;
|
||||
return Ok(path);
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
})();
|
||||
match saved {
|
||||
Ok(path) => excerpt(&output, &path, tail, 0, output.len()),
|
||||
Err(e) => {
|
||||
eprintln!("overflow archive failed: {e}; retaining complete tool result");
|
||||
output
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn date() -> String {
|
||||
let days = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
/ 86400;
|
||||
let z = days as i64 + 719468;
|
||||
let era = z / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = mp + if mp < 10 { 3 } else { -9 };
|
||||
format!("{:04}-{m:02}-{d:02}", y + i64::from(m <= 2))
|
||||
}
|
||||
|
||||
/// Excerpt an existing archive in place. Byte/line ranges refer to that file,
|
||||
/// including when a caller requests an oversized line or the entire archive.
|
||||
pub fn existing(bytes: &[u8], path: &str, args: &serde_json::Value) -> String {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let (mut from, mut to) = if let Some(line) = args["line_offset"].as_u64() {
|
||||
let from = text
|
||||
.split_inclusive('\n')
|
||||
.take(line as usize)
|
||||
.map(str::len)
|
||||
.sum::<usize>();
|
||||
let len = text[from..]
|
||||
.split_inclusive('\n')
|
||||
.take(args["line_limit"].as_u64().unwrap_or(200) as usize)
|
||||
.map(str::len)
|
||||
.sum::<usize>();
|
||||
(from, from + len)
|
||||
} else {
|
||||
let from = (args["offset"].as_u64().unwrap_or(0) as usize).min(text.len());
|
||||
(
|
||||
from,
|
||||
args["max_bytes"].as_u64().map_or(text.len(), |n| {
|
||||
from.saturating_add(n as usize).min(text.len())
|
||||
}),
|
||||
)
|
||||
};
|
||||
while !text.is_char_boundary(from) {
|
||||
from += 1;
|
||||
}
|
||||
while to > from && !text.is_char_boundary(to) {
|
||||
to -= 1;
|
||||
}
|
||||
to = to.max(from);
|
||||
excerpt(&text, path, false, from, to)
|
||||
}
|
||||
|
||||
fn excerpt(output: &str, path: &str, tail: bool, from: usize, to: usize) -> String {
|
||||
let selected = &output[from..to];
|
||||
let lines: Vec<&str> = selected.split_inclusive('\n').collect();
|
||||
let budget = CONTEXT_LIMIT.0.saturating_sub(1024);
|
||||
let mut start = 0;
|
||||
let mut end = lines.len();
|
||||
let mut bytes = 0;
|
||||
if tail {
|
||||
start = end;
|
||||
while start > 0
|
||||
&& end - start < CONTEXT_LIMIT.1.saturating_sub(2)
|
||||
&& bytes + lines[start - 1].len() <= budget
|
||||
{
|
||||
start -= 1;
|
||||
bytes += lines[start].len();
|
||||
}
|
||||
} else {
|
||||
end = 0;
|
||||
while end < lines.len()
|
||||
&& end < CONTEXT_LIMIT.1.saturating_sub(2)
|
||||
&& bytes + lines[end].len() <= budget
|
||||
{
|
||||
bytes += lines[end].len();
|
||||
end += 1;
|
||||
}
|
||||
}
|
||||
let mut shown = lines[start..end].concat();
|
||||
// A single huge line: preserve valid UTF-8 and explicitly label a partial line.
|
||||
let partial = start == end;
|
||||
if partial && !lines.is_empty() {
|
||||
if tail {
|
||||
start = lines.len() - 1;
|
||||
let line = lines[start];
|
||||
let mut from = line.len().saturating_sub(budget);
|
||||
while !line.is_char_boundary(from) {
|
||||
from += 1;
|
||||
}
|
||||
shown.push_str(&line[from..]);
|
||||
} else {
|
||||
let mut to = budget.min(lines[0].len());
|
||||
while !lines[0].is_char_boundary(to) {
|
||||
to -= 1;
|
||||
}
|
||||
shown.push_str(&lines[0][..to]);
|
||||
}
|
||||
}
|
||||
let mut byte_start = from + lines[..start].iter().map(|s| s.len()).sum::<usize>();
|
||||
if partial && tail {
|
||||
byte_start = to - shown.len();
|
||||
}
|
||||
let byte_end = byte_start + shown.len();
|
||||
let line_start = output[..byte_start].bytes().filter(|b| *b == b'\n').count();
|
||||
let line_end = line_start + shown.split_inclusive('\n').count();
|
||||
let total_lines = output.split_inclusive('\n').count();
|
||||
let partial = partial
|
||||
|| (from > 0 && !output[..from].ends_with('\n'))
|
||||
|| (to < output.len() && !output[..to].ends_with('\n'));
|
||||
let read = if partial {
|
||||
format!(
|
||||
"Shown byte range [{byte_start},{byte_end}); continue with read_file path={path}, offset={byte_end}, max_bytes={budget} (or choose an earlier byte offset)"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Read with read_file path={path}, line_offset={line_start}, line_limit={}; next line_offset={line_end}",
|
||||
line_end - line_start
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"{shown}\n[Showing lines {}-{} of {}{}; total {} bytes. Full output: {path}. {read}]",
|
||||
line_start + 1,
|
||||
line_end,
|
||||
total_lines,
|
||||
if partial { " (partial line)" } else { "" },
|
||||
output.len()
|
||||
)
|
||||
}
|
||||
|
||||
/// Zero-based line offsets; byte-based reads remain unchanged when absent.
|
||||
pub fn read_lines(bytes: &[u8], offset: usize, limit: usize) -> String {
|
||||
String::from_utf8_lossy(bytes)
|
||||
.split_inclusive('\n')
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn full_archive_and_context_guard() {
|
||||
let original = "😀 line\n".repeat(5400);
|
||||
for tail in [false, true] {
|
||||
let shown = guard(original.clone(), "guard-test", "call", tail);
|
||||
assert!(shown.len() <= CONTEXT_LIMIT.0);
|
||||
assert!(shown.lines().count() <= CONTEXT_LIMIT.1);
|
||||
assert!(shown.contains(if tail {
|
||||
"Showing lines 3403-5400"
|
||||
} else {
|
||||
"Showing lines 1-1998"
|
||||
}));
|
||||
let path = shown
|
||||
.split("Full output: ")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split(". Read with")
|
||||
.next()
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(path).unwrap(), original);
|
||||
}
|
||||
assert_eq!(guard("small".into(), "test", "id", false), "small");
|
||||
}
|
||||
#[test]
|
||||
fn archive_failure_does_not_discard_data() {
|
||||
std::fs::create_dir_all(format!("/logs/agent/{}", date())).unwrap();
|
||||
std::fs::write(format!("/logs/agent/{}/not-a-directory", date()), "block").unwrap();
|
||||
let output = "no loss\n".repeat(6000);
|
||||
assert_eq!(
|
||||
guard(output.clone(), "not-a-directory", "test", false),
|
||||
output
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn huge_line_and_exact_boundaries() {
|
||||
for tail in [false, true] {
|
||||
let result = guard("😀".repeat(40000), "test", "utf8", tail);
|
||||
assert!(result.len() <= CONTEXT_LIMIT.0);
|
||||
assert!(result.contains("partial line"));
|
||||
}
|
||||
let exact = "a\n".repeat(CONTEXT_LIMIT.1);
|
||||
assert_eq!(guard(exact.clone(), "test", "exact", false), exact);
|
||||
assert_eq!(read_lines(b"one\ntwo\nthree", 1, 1), "two\n");
|
||||
assert_eq!(read_lines(b"one", 3, 1), "");
|
||||
}
|
||||
}
|
||||
+6
-9
@@ -1,10 +1,7 @@
|
||||
You are the Sausage agent. Answer the person. Use tools to inspect or change the world.
|
||||
You are the `agent` actor in a Sausage world of actors.
|
||||
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.
|
||||
Read the SDK at `/actor/cargo/public/sdk/` before you write an actor.
|
||||
You can change your own source at `/actor/agent/source`.
|
||||
|
||||
Read the contract at `/proc/core/PROTOCOL.md`. The Rust plugin SDK and its documentation are at `/plugins/cargo/sdk/`.
|
||||
Build a plugin with `cargo.build`. Activate its WASM file with `core.activate`.
|
||||
Your own source is at `/plugins/agent/source/`. You can change and rebuild it like any other plugin.
|
||||
HARD RULE: the mediator and all agents NEVER call `core.restart` (or its alias `core.exit`) without the person's explicit approval each time.
|
||||
Restart requires the host to run under `#loop` (`SAUSAGE_LOOP=1`); it answers before clean shutdown with exit code 75.
|
||||
Never call `agent.chat`. It would wait on the history lock that your current turn holds.
|
||||
Write logs to stderr. Use call results and notifications for communication.
|
||||
The interface check does not cover persistent state. Preserve state compatibility when you change a plugin.
|
||||
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
-145
@@ -1,84 +1,38 @@
|
||||
//! Tool descriptions and file operations for the agent.
|
||||
|
||||
use crate::llm::Tool;
|
||||
use serde_json::{Value, json};
|
||||
use sausage_actor::{Value, json};
|
||||
use std::io;
|
||||
|
||||
#[path = "overflow.rs"]
|
||||
pub mod overflow;
|
||||
|
||||
/// Human-readable, lossless text representation of tool JSON. String values
|
||||
/// retain real newlines, with JSON-quoted field/index labels for structure.
|
||||
fn render(value: &Value) -> String {
|
||||
fn visit(value: &Value, label: &str, out: &mut String) {
|
||||
match value {
|
||||
Value::Object(fields) => {
|
||||
if fields.is_empty() {
|
||||
out.push_str(&format!("{label}: {{}}\n"));
|
||||
}
|
||||
for (key, value) in fields {
|
||||
visit(
|
||||
value,
|
||||
&format!("{label}[{}]", serde_json::to_string(key).unwrap()),
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
if items.is_empty() {
|
||||
out.push_str(&format!("{label}: []\n"));
|
||||
}
|
||||
for (i, value) in items.iter().enumerate() {
|
||||
visit(value, &format!("{label}[{i}]"), out);
|
||||
}
|
||||
}
|
||||
Value::String(text) => {
|
||||
out.push_str(&format!("{label} (string, {} bytes):\n", text.len()));
|
||||
out.push_str(text);
|
||||
out.push('\n');
|
||||
}
|
||||
_ => {
|
||||
out.push_str(&format!("{label}: {value}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = String::new();
|
||||
visit(value, "$", &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Describe the five tools sent to either inference API.
|
||||
pub fn definitions() -> Vec<Tool> {
|
||||
vec![
|
||||
Tool {
|
||||
name: "call",
|
||||
description: "Call a plugin method through the core. Await its result. Set detach to let its process survive this caller. Large results show an excerpt with a full-output /logs reference.",
|
||||
name: "send_message",
|
||||
description: "Send an actor a JSON body with a `type`. `broker` and `cargo` are built-in actors; see `/actor/broker/public/PROTOCOL.md`. With `wait_seconds`, the reply's `reply_to` matches the returned ID. Returns the ID, reply, and other messages received.",
|
||||
parameters: schema(
|
||||
json!({
|
||||
"method": {"type": "string", "description": "Qualified plugin method"},
|
||||
"params": {"type": "object", "description": "Named method parameters"},
|
||||
"detach": {"type": "boolean", "description": "Detach the new process. Default false."}
|
||||
"to": {"type": "string", "description": "Actor ID"},
|
||||
"body": {"type": "object", "description": "JSON object with a type field"},
|
||||
"wait_seconds": {"type": "number", "description": "Seconds to wait for a reply. Default 0."}
|
||||
}),
|
||||
&["method", "params"],
|
||||
&["to", "body"],
|
||||
),
|
||||
},
|
||||
Tool {
|
||||
name: "read_file",
|
||||
description: "Read a text file in the world, including `/proc`. `offset`/`max_bytes` select bytes; `line_offset`/`line_limit` select zero-based lines (default 200). Large results show an excerpt with a full-output /logs reference.",
|
||||
description: "Read a text file under `/data`, `/public`, or `/actor/<id>`. Reads all bytes by default; `offset` and `max_bytes` are optional.",
|
||||
parameters: schema(
|
||||
json!({
|
||||
"path": {"type": "string"},
|
||||
"offset": {"type": "integer", "description": "Byte offset (default 0)"},
|
||||
"max_bytes": {"type": "integer", "description": "Optional maximum number of bytes to read"},
|
||||
"line_offset": {"type": "integer", "description": "Zero-based starting line; overrides bytes"},
|
||||
"line_limit": {"type": "integer", "description": "Number of lines; default 200"}
|
||||
"max_bytes": {"type": "integer", "description": "Optional maximum number of bytes to read"}
|
||||
}),
|
||||
&["path"],
|
||||
),
|
||||
},
|
||||
Tool {
|
||||
name: "write_file",
|
||||
description: "Write a text file in the world. Parent folders are created. Large results show an excerpt with a full-output /logs reference.",
|
||||
description: "Write text under `/data` or `/public`. Parent folders are created.",
|
||||
parameters: schema(
|
||||
json!({"path": {"type": "string"}, "content": {"type": "string"}}),
|
||||
&["path", "content"],
|
||||
@@ -86,12 +40,12 @@ pub fn definitions() -> Vec<Tool> {
|
||||
},
|
||||
Tool {
|
||||
name: "list_dir",
|
||||
description: "List entries in a world directory. Returns each name, type, and size. Large results show an excerpt with a full-output /logs reference.",
|
||||
description: "List entries in a directory under `/data`, `/public`, or `/actor/<id>`. Returns each name, type, and size.",
|
||||
parameters: schema(json!({"path": {"type": "string"}}), &["path"]),
|
||||
},
|
||||
Tool {
|
||||
name: "copy_dir",
|
||||
description: "Copy a directory tree, for example a plugin source to a draft folder. Skips `target/` folders. Large results show an excerpt with a full-output /logs reference.",
|
||||
description: "Copy a directory tree, for example `/actor/<id>/source` to `/public/drafts/<id>`. Skips `target/` folders.",
|
||||
parameters: schema(
|
||||
json!({"from": {"type": "string"}, "to": {"type": "string"}}),
|
||||
&["from", "to"],
|
||||
@@ -100,47 +54,23 @@ pub fn definitions() -> Vec<Tool> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Create the named-parameter object required by a tool.
|
||||
fn schema(properties: Value, required: &[&str]) -> Value {
|
||||
json!({"type": "object", "properties": properties, "required": required})
|
||||
}
|
||||
|
||||
/// Execute a tool. The chat loop returns errors to the model as tool results.
|
||||
pub async fn run(name: &str, args: &Value) -> sausage_plugin::Result<Value> {
|
||||
if name == "call" {
|
||||
let method = args["method"]
|
||||
.as_str()
|
||||
.ok_or_else(|| sausage_plugin::Error::new(-32602, "missing method"))?;
|
||||
let call = sausage_plugin::call(method, args.get("params").cloned().unwrap_or(json!({})));
|
||||
return if args["detach"] == true {
|
||||
call.detach().await
|
||||
} else {
|
||||
call.await
|
||||
};
|
||||
}
|
||||
Ok(match name {
|
||||
pub fn run(name: &str, args: &Value) -> io::Result<Value> {
|
||||
match name {
|
||||
"read_file" => read_file(args),
|
||||
"write_file" => write_file(args),
|
||||
"list_dir" => list_dir(args),
|
||||
"copy_dir" => copy_dir(args),
|
||||
other => Ok(json!({"error": format!("unknown tool {other}")})),
|
||||
}?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an optional byte range and report the complete file size.
|
||||
fn read_file(args: &Value) -> io::Result<Value> {
|
||||
let path = args["path"].as_str().unwrap_or_default();
|
||||
let bytes = std::fs::read(path)?;
|
||||
if path.starts_with("/logs/") {
|
||||
return Ok(
|
||||
json!({"path":path,"size":bytes.len(),"content":overflow::existing(&bytes, path, args)}),
|
||||
);
|
||||
}
|
||||
if let Some(line) = args["line_offset"].as_u64() {
|
||||
return Ok(
|
||||
json!({"path":path,"size":bytes.len(),"line_offset":line,"content":overflow::read_lines(&bytes, line as usize, args["line_limit"].as_u64().unwrap_or(200) as usize)}),
|
||||
);
|
||||
}
|
||||
let offset = (args["offset"].as_u64().unwrap_or(0) as usize).min(bytes.len());
|
||||
let end = args["max_bytes"].as_u64().map_or(bytes.len(), |max| {
|
||||
offset.saturating_add(max as usize).min(bytes.len())
|
||||
@@ -154,7 +84,6 @@ fn read_file(args: &Value) -> io::Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create parent directories and replace a text file.
|
||||
fn write_file(args: &Value) -> io::Result<Value> {
|
||||
let path = args["path"].as_str().unwrap_or_default();
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
@@ -164,7 +93,6 @@ fn write_file(args: &Value) -> io::Result<Value> {
|
||||
Ok(json!({"written": path}))
|
||||
}
|
||||
|
||||
/// Return directory metadata in name order.
|
||||
fn list_dir(args: &Value) -> io::Result<Value> {
|
||||
let mut entries: Vec<Value> = std::fs::read_dir(args["path"].as_str().unwrap_or_default())?
|
||||
.filter_map(|entry| entry.ok())
|
||||
@@ -181,17 +109,7 @@ fn list_dir(args: &Value) -> io::Result<Value> {
|
||||
Ok(json!({"entries": entries}))
|
||||
}
|
||||
|
||||
/// Copy a tree and report its file count.
|
||||
fn copy_dir(args: &Value) -> io::Result<Value> {
|
||||
let from = std::fs::canonicalize(args["from"].as_str().unwrap_or_default())?;
|
||||
let to = args["to"].as_str().unwrap_or_default();
|
||||
std::fs::create_dir_all(to)?;
|
||||
if std::fs::canonicalize(to)?.starts_with(&from) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"destination is inside the source",
|
||||
));
|
||||
}
|
||||
let count = copy_tree(
|
||||
args["from"].as_str().unwrap_or_default(),
|
||||
args["to"].as_str().unwrap_or_default(),
|
||||
@@ -199,7 +117,6 @@ fn copy_dir(args: &Value) -> io::Result<Value> {
|
||||
Ok(json!({"copied_files": count}))
|
||||
}
|
||||
|
||||
/// Copy files recursively without build artifacts in target directories.
|
||||
fn copy_tree(from: &str, to: &str) -> io::Result<u64> {
|
||||
std::fs::create_dir_all(to)?;
|
||||
let mut count = 0;
|
||||
@@ -219,50 +136,3 @@ fn copy_tree(from: &str, to: &str) -> io::Result<u64> {
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn overflow_json_can_be_followed_by_line_without_rearchiving() {
|
||||
let value = serde_json::json!({"stdout":"hello 😀\n".repeat(4000),"exit":1,"error":{"text":"failure\n".repeat(4000)}});
|
||||
let expected = super::render(&value);
|
||||
let shown = super::overflow::guard(value.to_string(), "json-test", "call", true);
|
||||
assert!(shown.len() <= 50 * 1024 && shown.lines().count() <= 2000);
|
||||
let path = shown
|
||||
.split("Full output: ")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split(". Read with")
|
||||
.next()
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(path).unwrap(), expected);
|
||||
let parent = std::path::Path::new(path).parent().unwrap();
|
||||
let count = std::fs::read_dir(parent).unwrap().count();
|
||||
let args = serde_json::json!({"path":path,"line_offset":30,"line_limit":5});
|
||||
let read = super::read_file(&args).unwrap();
|
||||
let read = read["content"].as_str().unwrap();
|
||||
assert!(read.contains("Showing lines 31-35"));
|
||||
assert!(read.contains(path));
|
||||
assert_eq!(std::fs::read_dir(parent).unwrap().count(), count);
|
||||
assert!(
|
||||
super::overflow::existing(
|
||||
expected.as_bytes(),
|
||||
path,
|
||||
&serde_json::json!({"line_offset":u64::MAX})
|
||||
)
|
||||
.contains("Full output:")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn copy_rejects_a_destination_inside_the_source() {
|
||||
let root = if cfg!(target_os = "wasi") {
|
||||
std::path::PathBuf::from("/copy-test")
|
||||
} else {
|
||||
std::env::temp_dir().join(format!("sausage-copy-{}", std::process::id()))
|
||||
};
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let result = super::copy_dir(&serde_json::json!({"from":root,"to":root.join("target")}));
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"description": "the agent answers text with text, even without config or network",
|
||||
"steps": [
|
||||
{"send": {"type": "text", "text": "Hello"}, "expect": {"type": "text"}}
|
||||
]
|
||||
}
|
||||
+3
-20
@@ -2,7 +2,7 @@
|
||||
name = "sausage"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Grow a system of WASI plugins by talking to it."
|
||||
description = "Grow a system of WASI actors by talking to it."
|
||||
|
||||
[[bin]]
|
||||
name = "sausage"
|
||||
@@ -11,32 +11,15 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
bytes = "1"
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
http = "1"
|
||||
http-body-util = "0.1"
|
||||
include_dir = "0.7"
|
||||
notify = "8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml_ng = "0.10"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
toml_edit = "0.25"
|
||||
wasmtime = "49"
|
||||
wasmtime-wasi = "49"
|
||||
wasmtime-wasi-http = "49"
|
||||
|
||||
uuid = { version = "1", features = ["v7"] }
|
||||
sha2 = "0.10"
|
||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||
sausage-vfs = { version = "0.1.0", path = "../vfs" }
|
||||
sausage-interface = { version = "0.1.0", path = "../interface" }
|
||||
notify = "8.2.0"
|
||||
rustyline = "19"
|
||||
nix = { version = "0.31.3", features = ["signal", "process", "term"] }
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock"] }
|
||||
tempfile = "3.27.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["test-util"] }
|
||||
hyper = { version = "1.12.0", features = ["server", "http1"] }
|
||||
hyper-util = { version = "0.1.21", features = ["tokio"] }
|
||||
sausage-interface = { path = "../interface" }
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
//! Serialized candidate-copy activation and revert under PROTOCOL.md section 10.3.
|
||||
use crate::{
|
||||
Host, RpcResult, error,
|
||||
graph::{Interface, io_error, problems, read_hashed},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
/// Method descriptions plus their reachable local references, without expanding cycles.
|
||||
fn methods(document: &Value) -> BTreeMap<String, Value> {
|
||||
fn references(value: &Value, document: &Value, found: &mut BTreeMap<String, Value>) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if let Some(reference) = object.get("$ref").and_then(Value::as_str)
|
||||
&& let Some(pointer) = reference.strip_prefix('#')
|
||||
&& !found.contains_key(reference)
|
||||
&& let Some(target) = document.pointer(pointer)
|
||||
{
|
||||
found.insert(reference.into(), target.clone());
|
||||
references(target, document, found);
|
||||
}
|
||||
for child in object.values() {
|
||||
references(child, document, found);
|
||||
}
|
||||
}
|
||||
Value::Array(array) => {
|
||||
for child in array {
|
||||
references(child, document, found);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
document["methods"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|method| {
|
||||
let name = method["name"].as_str()?;
|
||||
let mut found = BTreeMap::new();
|
||||
references(method, document, &mut found);
|
||||
Some((name.into(), json!({"method":method,"references":found})))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Candidate {
|
||||
plugin: String,
|
||||
copy: PathBuf,
|
||||
interface: Interface,
|
||||
}
|
||||
impl Host {
|
||||
/// Copies first, discovers that copy, checks the combined graph, then installs it.
|
||||
pub(crate) async fn activation(&self, _caller: &str, method: &str, params: Value) -> RpcResult {
|
||||
let host = self.clone();
|
||||
let method = method.to_owned();
|
||||
tokio::spawn(async move { host.activation_body(&method, params).await })
|
||||
.await
|
||||
.map_err(|failure| error(-32603, &failure.to_string()))?
|
||||
}
|
||||
async fn activation_body(&self, method: &str, params: Value) -> RpcResult {
|
||||
let _activation = self.0.activations.lock().await;
|
||||
let mut candidates = Vec::new();
|
||||
let result = self
|
||||
.activate_candidates(method, ¶ms, &mut candidates)
|
||||
.await;
|
||||
for candidate in candidates {
|
||||
let _ = tokio::fs::remove_file(candidate.copy).await;
|
||||
}
|
||||
result
|
||||
}
|
||||
/// Prepares every member before graph checks or changes to installed files.
|
||||
async fn activate_candidates(
|
||||
&self,
|
||||
method: &str,
|
||||
params: &Value,
|
||||
candidates: &mut Vec<Candidate>,
|
||||
) -> RpcResult {
|
||||
let requests = params
|
||||
.get("plugins")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| vec![params.clone()]);
|
||||
if requests.is_empty() {
|
||||
return Err(error(-32602, "plugins must not be empty"));
|
||||
}
|
||||
for request in requests {
|
||||
let plugin = request["plugin"]
|
||||
.as_str()
|
||||
.ok_or_else(|| error(-32602, "plugin must be a string"))?;
|
||||
if plugin.is_empty()
|
||||
|| !plugin.bytes().all(|byte| {
|
||||
byte.is_ascii_lowercase()
|
||||
|| byte.is_ascii_digit()
|
||||
|| byte == b'_'
|
||||
|| byte == b'-'
|
||||
})
|
||||
|| candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.plugin == plugin)
|
||||
{
|
||||
return Err(error(-32602, "invalid or duplicate plugin"));
|
||||
}
|
||||
let directory = self.0.world.plugin_dir(plugin);
|
||||
tokio::fs::create_dir_all(&directory)
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
let source = if method == "revert" {
|
||||
directory.join("plugin.prev.wasm")
|
||||
} else {
|
||||
let source = PathBuf::from(
|
||||
request["wasm"]
|
||||
.as_str()
|
||||
.ok_or_else(|| error(-32602, "wasm must be a string"))?,
|
||||
);
|
||||
if source.starts_with(&self.0.world.root) {
|
||||
source
|
||||
} else {
|
||||
self.0
|
||||
.world
|
||||
.root
|
||||
.join(source.strip_prefix("/").unwrap_or(&source))
|
||||
}
|
||||
};
|
||||
let copy = directory.join(format!("activate-{}.wasm", uuid::Uuid::now_v7()));
|
||||
// Read once. Discovery and installation both use the same private bytes.
|
||||
let (bytes, hash) = read_hashed(source).await.map_err(io_error)?;
|
||||
tokio::fs::write(©, &bytes).await.map_err(io_error)?;
|
||||
self.0
|
||||
.journal
|
||||
.write("core", plugin, "core", "activation copied candidate");
|
||||
let discovery = self.discover_copy(plugin, ©, hash).await;
|
||||
let interface = match discovery {
|
||||
Ok(interface) => interface,
|
||||
Err(error) => {
|
||||
let _ = tokio::fs::remove_file(copy).await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
self.0
|
||||
.journal
|
||||
.write("core", plugin, "core", "activation discovered candidate");
|
||||
candidates.push(Candidate {
|
||||
plugin: plugin.into(),
|
||||
copy,
|
||||
interface,
|
||||
});
|
||||
}
|
||||
let names: Vec<_> = candidates
|
||||
.iter()
|
||||
.map(|candidate| candidate.plugin.clone())
|
||||
.collect();
|
||||
self.refresh_graph_except(&names).await?;
|
||||
let mut previous = self.0.graph.lock().unwrap().plugins.clone();
|
||||
// Startup discovery may not yet have visited a replaced plugin. Discover
|
||||
// its immutable old copy for the diff, but never prevent repairing it.
|
||||
for candidate in candidates.iter() {
|
||||
if let Some(old) = previous.get_mut(&candidate.plugin)
|
||||
&& old.document.is_null()
|
||||
&& old.error.is_none()
|
||||
{
|
||||
if old.hash == candidate.interface.hash {
|
||||
old.document = candidate.interface.document.clone();
|
||||
continue;
|
||||
}
|
||||
let directory = self.0.world.plugin_dir(&candidate.plugin);
|
||||
if let Ok((bytes, hash)) = read_hashed(directory.join("plugin.wasm")).await {
|
||||
let copy = directory.join(format!("discover-{}.wasm", uuid::Uuid::now_v7()));
|
||||
if tokio::fs::write(©, bytes).await.is_ok()
|
||||
&& let Ok(interface) =
|
||||
self.discover_copy(&candidate.plugin, ©, hash).await
|
||||
{
|
||||
*old = interface;
|
||||
}
|
||||
let _ = tokio::fs::remove_file(copy).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut proposed = previous.clone();
|
||||
for candidate in candidates.iter() {
|
||||
proposed.insert(candidate.plugin.clone(), candidate.interface.clone());
|
||||
}
|
||||
if params["force"] != true {
|
||||
let failures = problems(&proposed, Some(&names));
|
||||
if !failures.is_empty() {
|
||||
self.0.journal.write(
|
||||
"core",
|
||||
"core",
|
||||
"core",
|
||||
&format!("activation rejected {}", json!(failures)),
|
||||
);
|
||||
let details = failures
|
||||
.iter()
|
||||
.map(|problem| {
|
||||
format!(
|
||||
"- {} imports {}: {}",
|
||||
problem["consumer"],
|
||||
problem["provider"],
|
||||
problem["display"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| problem["message"]
|
||||
.as_str()
|
||||
.unwrap_or("incompatible import"))
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
return Err(
|
||||
json!({"code":-32003,"message":format!("{} was not activated: consumers break.\n{details}\nUpdate consumers first, or activate both together.",names.join(", ")),"data":failures}),
|
||||
);
|
||||
}
|
||||
}
|
||||
self.install_candidates(candidates, previous, proposed, params["force"] != true)
|
||||
.await
|
||||
}
|
||||
/// Installs staged files and snapshots all old admissions under the shared write gate.
|
||||
async fn install_candidates(
|
||||
&self,
|
||||
candidates: &[Candidate],
|
||||
previous: BTreeMap<String, Interface>,
|
||||
proposed: BTreeMap<String, Interface>,
|
||||
checked: bool,
|
||||
) -> RpcResult {
|
||||
let admission = self.0.admission.write().await;
|
||||
// Every candidate already has a temporary name before any installed file changes.
|
||||
for candidate in candidates {
|
||||
if previous
|
||||
.get(&candidate.plugin)
|
||||
.is_some_and(|old| old.hash == candidate.interface.hash)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let directory = self.0.world.plugin_dir(&candidate.plugin);
|
||||
let current = directory.join("plugin.wasm");
|
||||
if tokio::fs::try_exists(¤t).await.map_err(io_error)? {
|
||||
let backup = candidate.copy.with_extension("previous");
|
||||
tokio::fs::hard_link(¤t, &backup)
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
tokio::fs::rename(&backup, directory.join("plugin.prev.wasm"))
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
}
|
||||
tokio::fs::rename(&candidate.copy, ¤t)
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
}
|
||||
let mut upgrades = Vec::new();
|
||||
let mut summaries = serde_json::Map::new();
|
||||
for candidate in candidates {
|
||||
let old = previous.get(&candidate.plugin);
|
||||
let before = methods(old.map(|old| &old.document).unwrap_or(&Value::Null));
|
||||
let after = methods(&candidate.interface.document);
|
||||
let added: Vec<_> = after
|
||||
.keys()
|
||||
.filter(|name| !before.contains_key(*name))
|
||||
.collect();
|
||||
let removed: Vec<_> = before
|
||||
.keys()
|
||||
.filter(|name| !after.contains_key(*name))
|
||||
.collect();
|
||||
let changed: Vec<_> = after
|
||||
.iter()
|
||||
.filter(|(name, method)| before.get(*name).is_some_and(|old| old != *method))
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
summaries.insert(candidate.plugin.clone(), json!({
|
||||
"plugin": candidate.plugin,
|
||||
"wasm": format!("/plugins/{}/plugin.wasm", candidate.plugin),
|
||||
"hash": candidate.interface.hash,
|
||||
"previous": old.map(|old| &old.hash).filter(|hash| !hash.is_empty()),
|
||||
"method_count": candidate.interface.document["methods"].as_array().map_or(0, Vec::len),
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"changed": changed,
|
||||
"interface_check": if checked { "passed" } else { "skipped" },
|
||||
}));
|
||||
if let Some(old) = previous
|
||||
.get(&candidate.plugin)
|
||||
.filter(|old| !old.hash.is_empty() && old.hash != candidate.interface.hash)
|
||||
{
|
||||
upgrades.push(self.snapshot_upgrade(
|
||||
&candidate.plugin,
|
||||
old.hash.clone(),
|
||||
&candidate.interface.hash,
|
||||
));
|
||||
}
|
||||
self.0.journal.write(
|
||||
"core",
|
||||
&candidate.plugin,
|
||||
"core",
|
||||
&format!("activation installed {}", candidate.interface.hash),
|
||||
);
|
||||
}
|
||||
self.publish_graph(proposed);
|
||||
drop(admission);
|
||||
for upgrade in upgrades {
|
||||
self.begin_upgrade(upgrade);
|
||||
}
|
||||
Ok(Value::Object(summaries))
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
# The configuration of the agent. Edit it before the first chat.
|
||||
# api selects the request format. Choose one complete example below.
|
||||
api: openai-responses
|
||||
# OpenAI base URLs include /v1. Do not include /responses or /chat/completions.
|
||||
base_url: https://api.openai.com/v1
|
||||
# model is the provider's model identifier.
|
||||
model: gpt-6.1-sol
|
||||
# token is the API key. Replace CHANGE_ME with your key.
|
||||
# An empty token requires allow_no_token: true. Then no authentication header is sent.
|
||||
token: CHANGE_ME
|
||||
allow_no_token: false
|
||||
# thinking is how much the model thinks before it answers:
|
||||
# off, low, medium, high, xhigh, or max. Remove the line to use the model's default,
|
||||
# for example for a model without thinking.
|
||||
# If the model rejects a level, the agent uses the nearest level that the model accepts.
|
||||
# If the model cannot switch thinking off, off becomes low.
|
||||
# A caller can set another level for one turn: agent.chat {"text": "...", "thinking": "high"}.
|
||||
# The agent reads this file at the start of each turn, so a change applies to the next turn.
|
||||
thinking: medium
|
||||
# Optional output token cap. Anthropic defaults to 32000 if omitted.
|
||||
# max_tokens: 32000
|
||||
|
||||
# OpenAI Responses example (base_url includes the version):
|
||||
# api: openai-responses
|
||||
# base_url: https://api.openai.com/v1
|
||||
# model: gpt-6.1-sol
|
||||
# token: YOUR_OPENAI_KEY
|
||||
# allow_no_token: false
|
||||
# thinking: medium
|
||||
|
||||
# OpenRouter example (OpenAI Chat Completions, the format of most providers):
|
||||
# api: openai-chat-completions
|
||||
# base_url: https://openrouter.ai/api/v1
|
||||
# model: anthropic/claude-sonnet-5.5
|
||||
# token: YOUR_OPENROUTER_KEY
|
||||
# allow_no_token: false
|
||||
# thinking: medium
|
||||
|
||||
# Local Ollama example (OpenAI Chat Completions, no key):
|
||||
# api: openai-chat-completions
|
||||
# base_url: http://localhost:11434/v1
|
||||
# model: llama3.2
|
||||
# token: ""
|
||||
# allow_no_token: true
|
||||
|
||||
# Anthropic Messages example (base_url excludes the version):
|
||||
# api: anthropic-messages
|
||||
# base_url: https://api.anthropic.com
|
||||
# model: claude-sonnet-5-5
|
||||
# token: YOUR_ANTHROPIC_KEY
|
||||
# allow_no_token: false
|
||||
# thinking: medium
|
||||
@@ -1,12 +0,0 @@
|
||||
# The core starts these services in order. An empty list starts no services.
|
||||
# Each method is a plugin.method name. params contains its named arguments.
|
||||
# The core waits for each result before it starts the next service.
|
||||
# After a plugin upgrade, the core restarts that plugin's services.
|
||||
# Host restart is separate: #loop sets SAUSAGE_LOOP=1 to enable core.restart
|
||||
# (and its alias core.exit). It answers before clean shutdown with exit code 75.
|
||||
# Agents need the person's explicit approval each time; never add it as a service.
|
||||
# Example (replace [] with this list):
|
||||
# - method: www.start
|
||||
# params:
|
||||
# port: 8080
|
||||
[]
|
||||
@@ -0,0 +1,667 @@
|
||||
//! The broker: message routing, mailboxes, subscriptions, taps, watches, the
|
||||
//! version store, and the requests to `broker` (spawn, upgrade, rollback,
|
||||
//! stop, start, remove, subscribe, unsubscribe, tap, untap, watch, unwatch,
|
||||
//! exit). `supervisor.rs` runs each WASM actor.
|
||||
//!
|
||||
//! The broker accepts only `.wasm` files. It does not know any source language.
|
||||
//! The world folder is the source of truth: at start, the broker boots every
|
||||
//! `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::path::Path;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use wasmtime::component::Component;
|
||||
|
||||
use crate::candidate;
|
||||
use crate::runtime::{InstanceConfig, Mount, Runtime};
|
||||
use crate::supervisor::{Control, Reply, Supervisor};
|
||||
use crate::vfs;
|
||||
use crate::world::{self, ActorMeta, NATIVE, VersionMeta, World};
|
||||
|
||||
pub const MAILBOX_SIZE: usize = 1000;
|
||||
pub const SHUTDOWN_DEADLINE: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Envelope {
|
||||
pub id: String,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub reply_to: Option<String>,
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
impl Envelope {
|
||||
/// The NDJSON line that the recipient reads on stdin.
|
||||
pub fn to_line(&self) -> String {
|
||||
let mut v = json!({"id": self.id, "from": self.from, "body": self.body});
|
||||
if let Some(r) = &self.reply_to {
|
||||
v["reply_to"] = json!(r);
|
||||
}
|
||||
v.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shutdown_line() -> String {
|
||||
json!({"id": "broker/shutdown", "from": "broker", "body": {"type": "shutdown"}}).to_string()
|
||||
}
|
||||
|
||||
pub struct Broker {
|
||||
pub world: World,
|
||||
pub runtime: Arc<Runtime>,
|
||||
mailboxes: Mutex<HashMap<String, mpsc::Sender<Envelope>>>,
|
||||
controls: Mutex<HashMap<String, mpsc::Sender<Control>>>,
|
||||
/// `actor.json` of every actor, native and WASM.
|
||||
metas: Mutex<BTreeMap<String, ActorMeta>>,
|
||||
op_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||
seq: AtomicU64,
|
||||
/// Set by the `exit` request: the host process should stop.
|
||||
exit: tokio::sync::Notify,
|
||||
/// Requests of the host from `fs` (see `vfs.rs`) that wait for a reply, by message ID.
|
||||
asks: Mutex<HashMap<String, oneshot::Sender<Value>>>,
|
||||
me: std::sync::Weak<Broker>,
|
||||
}
|
||||
|
||||
impl Broker {
|
||||
pub fn new(world: World, runtime: Arc<Runtime>) -> Result<Arc<Self>> {
|
||||
std::fs::create_dir_all(world.config_dir())?;
|
||||
let metas = world.actor_ids()?.into_iter().map(|id| (id.clone(), world.read_meta(&id))).collect();
|
||||
Ok(Arc::new_cyclic(|me| Self {
|
||||
world,
|
||||
runtime,
|
||||
mailboxes: Mutex::new(HashMap::new()),
|
||||
controls: Mutex::new(HashMap::new()),
|
||||
metas: Mutex::new(metas),
|
||||
op_locks: Mutex::new(HashMap::new()),
|
||||
seq: AtomicU64::new(1),
|
||||
exit: tokio::sync::Notify::new(),
|
||||
asks: Mutex::new(HashMap::new()),
|
||||
me: me.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Send `body` from `fs` to `to` now. The future resolves to the reply
|
||||
/// body, or `None` after `vfs::ASK_TIMEOUT`.
|
||||
pub fn ask(self: &Arc<Self>, to: &str, body: Value) -> impl Future<Output = Option<Value>> + Send + 'static {
|
||||
let own = format!("q{}", self.seq.fetch_add(1, Ordering::Relaxed));
|
||||
let id = format!("{}/{own}", vfs::ID);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.asks.lock().unwrap().insert(id.clone(), tx);
|
||||
self.send(vfs::ID, Some(to), Some(&own), None, body);
|
||||
let this = self.clone();
|
||||
async move {
|
||||
let reply = tokio::time::timeout(vfs::ASK_TIMEOUT, rx).await;
|
||||
this.asks.lock().unwrap().remove(&id);
|
||||
reply.ok()?.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait until an actor sends `{"type": "exit"}` to `broker`. The `sausage`
|
||||
/// binary then stops all actors and exits, so that a wrapper such as
|
||||
/// `nix run .#loop` can start a fresh (updated) host.
|
||||
pub async fn exit_requested(&self) {
|
||||
self.exit.notified().await;
|
||||
}
|
||||
|
||||
pub fn meta(&self, id: &str) -> Option<ActorMeta> {
|
||||
self.metas.lock().unwrap().get(id).cloned()
|
||||
}
|
||||
|
||||
/// Change the `actor.json` of `id` (creating it if needed) and save it.
|
||||
fn update_meta(&self, id: &str, f: impl FnOnce(&mut ActorMeta)) -> Result<()> {
|
||||
let mut metas = self.metas.lock().unwrap();
|
||||
let meta = metas.entry(id.to_string()).or_insert_with(|| ActorMeta { running: true, ..Default::default() });
|
||||
f(meta);
|
||||
self.world.write_meta(id, meta)
|
||||
}
|
||||
|
||||
/// Create the mailbox of an actor. Messages wait here while the actor is
|
||||
/// down, during a swap, or while it restarts.
|
||||
pub fn open_mailbox(&self, id: &str) -> mpsc::Receiver<Envelope> {
|
||||
let (tx, rx) = mpsc::channel(MAILBOX_SIZE);
|
||||
self.mailboxes.lock().unwrap().insert(id.to_string(), tx);
|
||||
rx
|
||||
}
|
||||
|
||||
/// Send a message. `to: None` publishes to the subscribers of `from`.
|
||||
/// With `own_id`, the message ID is `"<from>/<own_id>"`. Returns the ID.
|
||||
pub fn send(&self, from: &str, to: Option<&str>, own_id: Option<&str>, reply_to: Option<&str>, body: Value) -> String {
|
||||
let id = match own_id {
|
||||
Some(own) => format!("{from}/{own}"),
|
||||
None => format!("m-{}", self.seq.fetch_add(1, Ordering::Relaxed)),
|
||||
};
|
||||
let (recipients, tappers) = {
|
||||
let metas = self.metas.lock().unwrap();
|
||||
let recipients: Vec<String> = match to {
|
||||
Some(to) => vec![to.to_string()],
|
||||
None => metas
|
||||
.iter()
|
||||
.filter(|(_, m)| m.subscriptions.contains(from))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect(),
|
||||
};
|
||||
let tappers: Vec<String> = metas.iter().filter(|(_, m)| m.tapped).map(|(id, _)| id.clone()).collect();
|
||||
(recipients, tappers)
|
||||
};
|
||||
if !tappers.is_empty() {
|
||||
let message = json!({"id": id, "from": from, "to": to, "reply_to": reply_to, "body": body});
|
||||
self.tap(&tappers, message);
|
||||
}
|
||||
for to in recipients {
|
||||
self.deliver(Envelope {
|
||||
id: id.clone(),
|
||||
from: from.to_string(),
|
||||
to,
|
||||
reply_to: reply_to.map(str::to_string),
|
||||
body: body.clone(),
|
||||
});
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// Give every tapper a copy of one routed message. Copies skip `send`, so
|
||||
/// they are never tapped themselves; a full mailbox drops the copy.
|
||||
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);
|
||||
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();
|
||||
for id in to {
|
||||
if let Some(tx) = mailboxes.get(id) {
|
||||
let _ = tx.try_send(Envelope {
|
||||
id: format!("m-{}", self.seq.fetch_add(1, Ordering::Relaxed)),
|
||||
from: from.into(),
|
||||
to: id.clone(),
|
||||
reply_to: None,
|
||||
body: body.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Route one stdout line of a WASM actor.
|
||||
pub fn route_line(&self, from: &str, line: &str) {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(Value::Object(obj)) = serde_json::from_str::<Value>(line) else {
|
||||
self.log(from, &format!("stdout line is not a JSON object, ignored: {}", truncate(line, 500)));
|
||||
return;
|
||||
};
|
||||
let Some(body) = obj.get("body").cloned() else {
|
||||
self.log(from, &format!("message without \"body\", ignored: {}", truncate(line, 500)));
|
||||
return;
|
||||
};
|
||||
let own_id = obj.get("id").and_then(Value::as_str);
|
||||
let to = obj.get("to").and_then(Value::as_str);
|
||||
let reply_to = obj.get("reply_to").and_then(Value::as_str);
|
||||
self.send(from, to, own_id, reply_to, body);
|
||||
}
|
||||
|
||||
fn deliver(&self, env: Envelope) {
|
||||
if env.to == vfs::ID {
|
||||
let waiting = env.reply_to.as_ref().and_then(|r| self.asks.lock().unwrap().remove(r));
|
||||
if let Some(tx) = waiting {
|
||||
let _ = tx.send(env.body);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let tx = self.mailboxes.lock().unwrap().get(&env.to).cloned();
|
||||
let error = match tx {
|
||||
None => format!("no actor with ID {}", env.to),
|
||||
Some(tx) => match tx.try_send(env.clone()) {
|
||||
Ok(()) => return,
|
||||
Err(mpsc::error::TrySendError::Full(_)) => format!("mailbox of {} is full", env.to),
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => format!("actor {} is gone", env.to),
|
||||
},
|
||||
};
|
||||
if env.from != "broker" {
|
||||
let body = json!({"type": "undeliverable", "reason": error, "to": env.to});
|
||||
self.send("broker", Some(&env.from), None, Some(&env.id), body);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a host note to the stderr log of an actor.
|
||||
pub fn log(&self, id: &str, text: &str) {
|
||||
use std::io::Write;
|
||||
let path = self.world.actor_dir(id).join("stderr.log");
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(f, "[sausage {}] {text}", world::now());
|
||||
}
|
||||
}
|
||||
|
||||
fn op_lock(&self, id: &str) -> Arc<tokio::sync::Mutex<()>> {
|
||||
self.op_locks.lock().unwrap().entry(id.to_string()).or_default().clone()
|
||||
}
|
||||
|
||||
/// What an instance of `id` gets (see PROTOCOL.md). A candidate runs in
|
||||
/// `scratch`, which holds a copy of the actor's `data/` and `public/`,
|
||||
/// and has no network.
|
||||
pub fn instance_config(&self, id: &str, version: u64, scratch: Option<&Path>) -> InstanceConfig {
|
||||
let own = scratch.map_or_else(|| self.world.actor_dir(id), Path::to_path_buf);
|
||||
let mut env = vec![("SAUSAGE_ACTOR".into(), id.to_string()), ("SAUSAGE_VERSION".into(), version.to_string())];
|
||||
if scratch.is_some() {
|
||||
env.push(("SAUSAGE_CANDIDATE".into(), "1".into()));
|
||||
}
|
||||
InstanceConfig {
|
||||
mounts: vec![
|
||||
Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: false },
|
||||
Mount { guest: "/public".into(), host: own.join("public"), writable: true },
|
||||
Mount { guest: "/data".into(), host: own.join("data"), writable: true },
|
||||
Mount { guest: "/config".into(), host: self.world.config_dir(), writable: true },
|
||||
],
|
||||
network: scratch.is_none(),
|
||||
stderr_log: own.join("stderr.log"),
|
||||
env,
|
||||
actor: id.to_string(),
|
||||
broker: self.me.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
fn start_supervisor(self: &Arc<Self>, id: &str, version: u64, running: bool) {
|
||||
let mailbox = self.open_mailbox(id);
|
||||
let (ctl_tx, ctl_rx) = mpsc::channel(16);
|
||||
self.controls.lock().unwrap().insert(id.to_string(), ctl_tx);
|
||||
tokio::spawn(Supervisor::new(self.clone(), id.to_string(), version, running, mailbox, ctl_rx).run());
|
||||
}
|
||||
|
||||
fn is_supervised(&self, id: &str) -> bool {
|
||||
self.controls.lock().unwrap().contains_key(id)
|
||||
}
|
||||
|
||||
async fn control(&self, id: &str, make: impl FnOnce(Reply) -> Control) -> Result<Value> {
|
||||
let tx = self.controls.lock().unwrap().get(id).cloned().ok_or_else(|| anyhow!("no WASM actor with ID {id}"))?;
|
||||
let (reply, rx) = oneshot::channel();
|
||||
tx.send(make(reply)).await.map_err(|_| anyhow!("supervisor of {id} is gone"))?;
|
||||
rx.await.map_err(|_| anyhow!("supervisor of {id} did not answer"))?
|
||||
}
|
||||
|
||||
/// Stop every actor gracefully, in parallel (host shutdown). The
|
||||
/// `running` flags stay, so the next `sausage run` starts them again.
|
||||
pub async fn shutdown_all(self: &Arc<Self>) {
|
||||
let ids: Vec<String> = self.controls.lock().unwrap().keys().cloned().collect();
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
for id in ids {
|
||||
let this = self.clone();
|
||||
tasks.spawn(async move { this.control(&id, |reply| Control::Shutdown { reply }).await });
|
||||
}
|
||||
tasks.join_all().await;
|
||||
}
|
||||
|
||||
/// Compile `versions/<version>/actor.wasm`.
|
||||
pub fn load_version(&self, id: &str, version: u64) -> Result<Component> {
|
||||
self.load(id, &self.world.version_dir(id, version).join("actor.wasm"))
|
||||
}
|
||||
|
||||
fn load(&self, id: &str, wasm: &Path) -> Result<Component> {
|
||||
let started = Instant::now();
|
||||
let component = self.runtime.load(wasm)?;
|
||||
self.log(id, &format!("compiled {} in {} ms", wasm.display(), started.elapsed().as_millis()));
|
||||
Ok(component)
|
||||
}
|
||||
|
||||
/// Test a new version where it lies. Only when the tests pass, copy it
|
||||
/// to `versions/<n>/`: that folder holds only versions that run(ran).
|
||||
/// Returns the component and the test report.
|
||||
async fn try_version(&self, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result<(Component, Value)> {
|
||||
let component = self.load(id, wasm)?;
|
||||
let tests = candidate::test(self, id, meta.version, &component, source).await;
|
||||
if let Err(e) = &tests {
|
||||
self.log(id, &format!("version {} from {} failed its tests: {e:#}", meta.version, meta.wasm));
|
||||
}
|
||||
let tests = tests?;
|
||||
let dir = self.world.version_dir(id, meta.version);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
std::fs::copy(wasm, dir.join("actor.wasm")).with_context(|| format!("copy {}", wasm.display()))?;
|
||||
world::replace_source(source.unwrap_or(&dir.join("no-source")), &dir.join("source"))?;
|
||||
world::write_atomic(&dir.join("meta.json"), serde_json::to_string_pretty(meta)?.as_bytes())?;
|
||||
Ok((component, tests))
|
||||
}
|
||||
|
||||
/// Make `version` the current `actor.wasm`, `source/`, and `actor.json`,
|
||||
/// and record whether the actor runs.
|
||||
pub fn install(&self, id: &str, version: u64, running: bool) -> Result<()> {
|
||||
self.world.install_files(id, version)?;
|
||||
self.update_meta(id, |m| {
|
||||
m.version = Some(version);
|
||||
m.running = running;
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_running(&self, id: &str, running: bool) -> Result<()> {
|
||||
self.update_meta(id, |m| m.running = running)
|
||||
}
|
||||
|
||||
/// Create a new actor from a `.wasm` file: test it, install it, start it.
|
||||
pub async fn create_actor(self: &Arc<Self>, id: &str, wasm: &Path, source: Option<&Path>, meta: VersionMeta) -> Result<Value> {
|
||||
if !world::valid_id(id) {
|
||||
bail!("invalid actor ID {id:?}: use a-z, 0-9, '-' and '_'");
|
||||
}
|
||||
let lock = self.op_lock(id);
|
||||
let _guard = lock.lock().await;
|
||||
if NATIVE.contains(&id) || self.is_supervised(id) || self.world.wasm_path(id).exists() {
|
||||
bail!("an actor with ID {id} already exists");
|
||||
}
|
||||
let meta = VersionMeta { version: self.world.next_version(id), ..meta };
|
||||
self.create_version(id, &meta, wasm, source).await
|
||||
}
|
||||
|
||||
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?;
|
||||
self.install(id, meta.version, true)?;
|
||||
self.start_supervisor(id, meta.version, true);
|
||||
Ok(json!({"type": "spawned", "actor": id, "version": meta.version, "tests": tests}))
|
||||
}
|
||||
|
||||
pub async fn upgrade_actor(self: &Arc<Self>, id: &str, wasm: &Path, source: Option<&Path>, meta: VersionMeta) -> Result<Value> {
|
||||
let lock = self.op_lock(id);
|
||||
let _guard = lock.lock().await;
|
||||
if !self.is_supervised(id) {
|
||||
bail!("no WASM actor with ID {id}");
|
||||
}
|
||||
let version = self.world.next_version(id);
|
||||
self.upgrade_version(id, &VersionMeta { version, ..meta }, wasm, source).await
|
||||
}
|
||||
|
||||
async fn upgrade_version(self: &Arc<Self>, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result<Value> {
|
||||
let (component, tests) = self.try_version(id, meta, wasm, source).await?;
|
||||
let (version, requester) = (meta.version, meta.requested_by.clone());
|
||||
let mut result = self.control(id, |reply| Control::Upgrade { version, component, requester, reply }).await?;
|
||||
result["tests"] = tests;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Stop an actor (normal shutdown) and delete its folder `actor/<id>/`.
|
||||
/// Afterwards the ID is unknown: messages to it are undeliverable, and
|
||||
/// it can be spawned again from scratch.
|
||||
pub async fn remove_actor(&self, id: &str) -> Result<Value> {
|
||||
if NATIVE.contains(&id) {
|
||||
bail!("{id} is part of the host and cannot be removed");
|
||||
}
|
||||
if !world::valid_id(id) {
|
||||
bail!("invalid actor ID {id:?}");
|
||||
}
|
||||
let lock = self.op_lock(id);
|
||||
let _guard = lock.lock().await;
|
||||
let dir = self.world.actor_dir(id);
|
||||
if !self.is_supervised(id) && !dir.is_dir() {
|
||||
bail!("no actor with ID {id}");
|
||||
}
|
||||
if self.is_supervised(id) {
|
||||
self.control(id, |reply| Control::Shutdown { reply }).await?;
|
||||
}
|
||||
// Without its mailbox, messages to `id` are undeliverable.
|
||||
self.controls.lock().unwrap().remove(id);
|
||||
self.mailboxes.lock().unwrap().remove(id);
|
||||
self.metas.lock().unwrap().remove(id);
|
||||
if dir.exists() {
|
||||
std::fs::remove_dir_all(&dir).with_context(|| format!("delete {}", dir.display()))?;
|
||||
}
|
||||
Ok(json!({"type": "removed", "actor": id}))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// - 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,
|
||||
/// nothing changes, and the file stays until it changes again.
|
||||
pub async fn scan(self: &Arc<Self>) -> Vec<Value> {
|
||||
let mut report = Vec::new();
|
||||
for id in self.world.actor_ids().unwrap_or_default() {
|
||||
if let Some(r) = self.boot_actor(&id).await {
|
||||
report.push(r);
|
||||
}
|
||||
}
|
||||
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": "/actor", "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> {
|
||||
// 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());
|
||||
if let Some(v) = installed
|
||||
&& !self.is_supervised(id)
|
||||
{
|
||||
self.start_supervisor(id, v, meta.running);
|
||||
}
|
||||
let wasm = self.world.wasm_path(id);
|
||||
if installed.is_some_and(|v| same_file(&wasm, &self.world.version_dir(id, v).join("actor.wasm"))) {
|
||||
return None;
|
||||
}
|
||||
let source = self.world.source_dir(id);
|
||||
let vmeta = VersionMeta {
|
||||
version: self.world.next_version(id),
|
||||
wasm: format!("/actor/{id}/actor.wasm"),
|
||||
source: Some(format!("/actor/{id}/source")),
|
||||
built_by: None,
|
||||
requested_by: "scan".into(),
|
||||
created_at: world::now(),
|
||||
};
|
||||
let source = source.is_dir().then_some(source.as_path());
|
||||
let result = match installed {
|
||||
None => self.create_version(id, &vmeta, &wasm, source).await,
|
||||
Some(_) => self.upgrade_version(id, &vmeta, &wasm, source).await,
|
||||
};
|
||||
Some(result.unwrap_or_else(|e| json!({"type": "error", "actor": id, "reason": format!("{e:#}")})))
|
||||
}
|
||||
|
||||
/// Handle one message to `broker`.
|
||||
async fn handle(self: Arc<Self>, env: Envelope) {
|
||||
let body = self.clone().command(&env).await.unwrap_or_else(|e| {
|
||||
json!({"type": "error", "request": env.body.get("type"), "reason": format!("{e:#}")})
|
||||
});
|
||||
self.send("broker", Some(&env.from), None, Some(&env.id), body);
|
||||
}
|
||||
|
||||
async fn command(self: Arc<Self>, env: &Envelope) -> Result<Value> {
|
||||
let b = &env.body;
|
||||
let ty = b["type"].as_str().unwrap_or("");
|
||||
let actor = || b["actor"].as_str().map(str::to_string).ok_or_else(|| anyhow!("missing \"actor\""));
|
||||
let version_meta = || -> Result<(std::path::PathBuf, Option<std::path::PathBuf>, VersionMeta)> {
|
||||
let wasm_s = b["wasm"].as_str().ok_or_else(|| anyhow!("missing \"wasm\""))?;
|
||||
let wasm = self.world.resolve(&env.from, wasm_s)?;
|
||||
if !wasm.is_file() {
|
||||
bail!("{wasm_s} is not a file");
|
||||
}
|
||||
let source_s = b["source"].as_str();
|
||||
let source = source_s.map(|s| self.world.resolve(&env.from, s)).transpose()?;
|
||||
if let (Some(s), Some(p)) = (source_s, &source)
|
||||
&& !p.is_dir()
|
||||
{
|
||||
bail!("{s} is not a folder");
|
||||
}
|
||||
let meta = VersionMeta {
|
||||
version: 0,
|
||||
wasm: wasm_s.to_string(),
|
||||
source: source_s.map(str::to_string),
|
||||
built_by: b["built_by"].as_str().map(str::to_string),
|
||||
requested_by: env.from.clone(),
|
||||
created_at: world::now(),
|
||||
};
|
||||
Ok((wasm, source, meta))
|
||||
};
|
||||
match ty {
|
||||
"spawn" => {
|
||||
let (wasm, source, meta) = version_meta()?;
|
||||
self.create_actor(&actor()?, &wasm, source.as_deref(), meta).await
|
||||
}
|
||||
"upgrade" => {
|
||||
let (wasm, source, meta) = version_meta()?;
|
||||
self.upgrade_actor(&actor()?, &wasm, source.as_deref(), meta).await
|
||||
}
|
||||
"rollback" => {
|
||||
let id = actor()?;
|
||||
let version = b["version"].as_u64().ok_or_else(|| anyhow!("missing \"version\""))?;
|
||||
let lock = self.op_lock(&id);
|
||||
let _guard = lock.lock().await;
|
||||
self.control(&id, |reply| Control::Rollback { version, reply }).await
|
||||
}
|
||||
"stop" => self.control(&actor()?, |reply| Control::Stop { reply }).await,
|
||||
"start" => self.control(&actor()?, |reply| Control::Start { reply }).await,
|
||||
"remove" => self.remove_actor(&actor()?).await,
|
||||
"subscribe" | "unsubscribe" => {
|
||||
let publisher = actor()?;
|
||||
let on = ty == "subscribe";
|
||||
self.update_meta(&env.from, |m| {
|
||||
if on {
|
||||
m.subscriptions.insert(publisher.clone());
|
||||
} else {
|
||||
m.subscriptions.remove(&publisher);
|
||||
}
|
||||
})?;
|
||||
Ok(json!({"type": format!("{ty}d"), "actor": publisher}))
|
||||
}
|
||||
"tap" | "untap" => {
|
||||
let on = ty == "tap";
|
||||
self.update_meta(&env.from, |m| m.tapped = on)?;
|
||||
let answer = if on { "tapping" } else { "untapped" };
|
||||
Ok(json!({"type": answer}))
|
||||
}
|
||||
"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 /actor"))?;
|
||||
let path = match path.trim_end_matches('/') {
|
||||
"" => "/actor".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" => {
|
||||
self.log("broker", &format!("exit requested by {}", env.from));
|
||||
// notify_one keeps the permit if nobody waits yet.
|
||||
self.exit.notify_one();
|
||||
Ok(json!({"type": "exiting"}))
|
||||
}
|
||||
other => bail!("unknown broker request {other:?}. Known: spawn, upgrade, rollback, stop, start, remove, subscribe, unsubscribe, tap, untap, watch, unwatch, exit"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The native `broker` actor. Requests run concurrently. Aborting this
|
||||
/// task aborts the requests that still run. Replies (messages with
|
||||
/// `reply_to`) get no answer, so that two actors never answer each other
|
||||
/// forever.
|
||||
pub async fn run_actor(self: Arc<Self>, mut mailbox: mpsc::Receiver<Envelope>) {
|
||||
let mut requests = tokio::task::JoinSet::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
env = mailbox.recv() => match env {
|
||||
Some(env) if env.reply_to.is_none() => { requests.spawn(self.clone().handle(env)); }
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
},
|
||||
Some(_) = requests.join_next() => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_file(a: &Path, b: &Path) -> bool {
|
||||
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 {
|
||||
path.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
|
||||
}
|
||||
|
||||
pub fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut end = max;
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}… ({} bytes)", &s[..end], s.len())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Tests for a candidate version, before the broker installs it.
|
||||
//!
|
||||
//! A test is a JSON file in `source/tests/*.json`, one scenario per file:
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "description": "add, then list",
|
||||
//! "steps": [
|
||||
//! {"send": {"type": "add", "text": "milk"}, "expect": {"type": "added"}},
|
||||
//! {"send": {"type": "list"}, "from": "someone", "expect": [{"type": "notes"}], "to": "someone"}
|
||||
//! ]
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Each step sends `send` (optional, from `from` or "test"), then reads one
|
||||
//! message for each entry of `expect` (an object or a list). An expected body
|
||||
//! matches if it is a subset of the actual body. With `to`, each message must
|
||||
//! also go to that recipient.
|
||||
//!
|
||||
//! Each file runs in a fresh instance on a copy of the actor's `/data` and
|
||||
//! `/public` (empty folders for a new actor), without network. A version
|
||||
//! without test files passes.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use serde_json::{Value, json};
|
||||
use wasmtime::component::Component;
|
||||
|
||||
use crate::broker::{Broker, SHUTDOWN_DEADLINE, shutdown_line, truncate};
|
||||
use crate::runtime::Instance;
|
||||
use crate::world;
|
||||
|
||||
const EXPECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// `expected` matches `actual` if every key of an expected object exists in
|
||||
/// the actual object with a matching value. Other values must be equal.
|
||||
pub fn subset_match(expected: &Value, actual: &Value) -> bool {
|
||||
match (expected, actual) {
|
||||
(Value::Object(e), Value::Object(a)) => e.iter().all(|(k, v)| a.get(k).is_some_and(|av| subset_match(v, av))),
|
||||
(Value::Array(e), Value::Array(a)) => e.len() == a.len() && e.iter().zip(a).all(|(x, y)| subset_match(x, y)),
|
||||
_ => expected == actual,
|
||||
}
|
||||
}
|
||||
|
||||
struct Scratch {
|
||||
dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn stderr_tail(path: &Path) -> String {
|
||||
let text = std::fs::read_to_string(path).unwrap_or_default();
|
||||
let start = text.len().saturating_sub(2000);
|
||||
let mut s = start;
|
||||
while !text.is_char_boundary(s) {
|
||||
s += 1;
|
||||
}
|
||||
text[s..].to_string()
|
||||
}
|
||||
|
||||
/// The version under test, which starts from a copy of the actor's state.
|
||||
struct Candidate<'a> {
|
||||
broker: &'a Broker,
|
||||
id: &'a str,
|
||||
version: u64,
|
||||
component: &'a Component,
|
||||
}
|
||||
|
||||
/// Run every test file of `<source>/tests/`.
|
||||
pub async fn test(broker: &Broker, id: &str, version: u64, component: &Component, source: Option<&Path>) -> Result<Value> {
|
||||
let candidate = Candidate { broker, id, version, component };
|
||||
let Some(source) = source else { return Ok(json!({"passed": []})) };
|
||||
let mut files: Vec<_> = std::fs::read_dir(source.join("tests"))
|
||||
.map(|rd| rd.filter_map(|e| Some(e.ok()?.path())).filter(|p| p.extension().is_some_and(|x| x == "json")).collect())
|
||||
.unwrap_or_default();
|
||||
files.sort();
|
||||
let mut passed = Vec::new();
|
||||
for file in &files {
|
||||
let name = file.file_name().unwrap().to_string_lossy().to_string();
|
||||
candidate.run_file(file).await.map_err(|e| anyhow!("test {name} failed: {e:#}"))?;
|
||||
passed.push(name);
|
||||
}
|
||||
Ok(json!({"passed": passed}))
|
||||
}
|
||||
|
||||
impl Candidate<'_> {
|
||||
/// Start an instance on a fresh copy of the state, without network.
|
||||
fn start(&self) -> Result<(Instance, Scratch)> {
|
||||
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = self.broker.world.scratch_dir().join(format!("{}-{}-{n}", self.id, std::process::id()));
|
||||
let scratch = Scratch { dir: dir.clone() };
|
||||
let world = &self.broker.world;
|
||||
for (from, name) in [(world.data_dir(self.id), "data"), (world.public_dir(self.id), "public")] {
|
||||
std::fs::create_dir_all(dir.join(name))?;
|
||||
if from.is_dir() {
|
||||
world::copy_tree(&from, &dir.join(name))?;
|
||||
}
|
||||
}
|
||||
let cfg = self.broker.instance_config(self.id, self.version, Some(&dir));
|
||||
Ok((self.broker.runtime.start(self.component, cfg)?, scratch))
|
||||
}
|
||||
|
||||
async fn run_file(&self, file: &Path) -> Result<()> {
|
||||
let test: Value = serde_json::from_str(&std::fs::read_to_string(file)?).map_err(|e| anyhow!("invalid JSON: {e}"))?;
|
||||
let steps = test["steps"].as_array().ok_or_else(|| anyhow!("missing \"steps\" list"))?;
|
||||
let (mut inst, scratch) = self.start()?;
|
||||
let stderr = scratch.dir.join("stderr.log");
|
||||
// Lines that the candidate wrote while a step was sent.
|
||||
let mut early = std::collections::VecDeque::new();
|
||||
for (n, step) in steps.iter().enumerate() {
|
||||
let n = n + 1;
|
||||
if let Some(body) = step.get("send") {
|
||||
let from = step["from"].as_str().unwrap_or("test");
|
||||
let env = json!({"id": format!("test/{n}"), "from": from, "body": body});
|
||||
inst.send_line(&env.to_string(), |line| early.push_back(line))
|
||||
.await
|
||||
.map_err(|e| anyhow!("step {n}: candidate does not read stdin: {e}"))?;
|
||||
}
|
||||
let expected = match step.get("expect") {
|
||||
None => vec![],
|
||||
Some(Value::Array(list)) => list.clone(),
|
||||
Some(one) => vec![one.clone()],
|
||||
};
|
||||
for expected in expected {
|
||||
let next = async { if early.is_empty() { inst.stdout.recv().await } else { early.pop_front() } };
|
||||
let line = match tokio::time::timeout(EXPECT_TIMEOUT, next).await {
|
||||
Err(_) => bail!("step {n}: no message within {EXPECT_TIMEOUT:?}; expected {expected}"),
|
||||
Ok(None) => {
|
||||
let exit = inst.wait().await;
|
||||
bail!("step {n}: candidate ended ({exit}); expected {expected}. stderr:\n{}", stderr_tail(&stderr))
|
||||
}
|
||||
Ok(Some(l)) => l,
|
||||
};
|
||||
let msg: Value = serde_json::from_str(&line)
|
||||
.map_err(|_| anyhow!("step {n}: candidate wrote a non-JSON line: {}", truncate(&line, 300)))?;
|
||||
if !subset_match(&expected, &msg["body"]) {
|
||||
bail!("step {n}: expected body {expected}, got {}", truncate(&msg["body"].to_string(), 1000));
|
||||
}
|
||||
if let Some(to) = step.get("to")
|
||||
&& msg.get("to") != Some(to)
|
||||
{
|
||||
bail!("step {n}: expected recipient {to}, got {}", msg.get("to").unwrap_or(&Value::Null));
|
||||
}
|
||||
}
|
||||
}
|
||||
inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE, |_| {}).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,836 +0,0 @@
|
||||
//! Native cargo builds, one immutable source copy, SDK installation, and process-group cancellation.
|
||||
use crate::{Host, world::World};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use include_dir::{Dir, include_dir};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::{process::Command, time::Instant};
|
||||
|
||||
/// Default wall-clock budget after acquiring the build mutex (not queue time).
|
||||
pub const DEFAULT_TIMEOUT_SECS: u64 = 900;
|
||||
/// Default wall-clock budget for each WASI test instance.
|
||||
pub const DEFAULT_TEST_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
/// Wall-clock budgets for a build and its individual WASI tests.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct BuildOptions {
|
||||
pub timeout_secs: u64,
|
||||
pub test_timeout_secs: u64,
|
||||
}
|
||||
impl Default for BuildOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timeout_secs: DEFAULT_TIMEOUT_SECS,
|
||||
test_timeout_secs: DEFAULT_TEST_TIMEOUT_SECS,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Optional RPC seconds are positive integers; null does not disable a deadline.
|
||||
fn seconds(params: &Value, name: &str, default: u64) -> Result<u64, Value> {
|
||||
match params.get(name) {
|
||||
None => Ok(default),
|
||||
Some(value) => value
|
||||
.as_u64()
|
||||
.filter(|seconds| *seconds >= 1)
|
||||
.ok_or_else(|| {
|
||||
crate::error(-32602, &format!("{name} must be an integer of at least 1"))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The native build plugin.
|
||||
pub const ID: &str = "cargo";
|
||||
/// The target used for builds and tests.
|
||||
pub const TARGET: &str = "wasm32-wasip2";
|
||||
static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/src");
|
||||
static MACROS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/macros/src");
|
||||
static INTERFACE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../interface/src");
|
||||
|
||||
#[derive(Clone, Default, Debug, serde::Serialize)]
|
||||
/// Test totals and captured output.
|
||||
pub struct TestResults {
|
||||
pub passed: u64,
|
||||
pub failed: u64,
|
||||
pub output: String,
|
||||
}
|
||||
/// Raw, unfiltered output shared by the test and release phases of one build.
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct BuildLogs {
|
||||
pub stdout: StreamLog,
|
||||
pub stderr: StreamLog,
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct StreamLog {
|
||||
pub path: String,
|
||||
pub bytes: u64,
|
||||
}
|
||||
/// Persistent descriptors are inherited by cargo, so cancellation cannot lose
|
||||
/// bytes already written by cargo or its descendants. No buffered writer is used.
|
||||
struct LogFiles {
|
||||
stdout: std::fs::File,
|
||||
stderr: std::fs::File,
|
||||
stdout_path: PathBuf,
|
||||
stderr_path: PathBuf,
|
||||
}
|
||||
impl LogFiles {
|
||||
fn create(world: &Path) -> Result<Self> {
|
||||
let directory = world
|
||||
.join("logs/cargo")
|
||||
.join(chrono::Utc::now().format("%Y-%m-%d").to_string());
|
||||
std::fs::create_dir_all(&directory).context("create cargo log directory")?;
|
||||
let id = uuid::Uuid::now_v7();
|
||||
let stdout_path = directory.join(format!("{id}.stdout.log"));
|
||||
let stderr_path = directory.join(format!("{id}.stderr.log"));
|
||||
let create = |path: &Path| {
|
||||
std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("create {}", path.display()))
|
||||
};
|
||||
Ok(Self {
|
||||
stdout: create(&stdout_path)?,
|
||||
stderr: create(&stderr_path)?,
|
||||
stdout_path,
|
||||
stderr_path,
|
||||
})
|
||||
}
|
||||
fn info(&self, world: &Path) -> Result<BuildLogs> {
|
||||
let stream = |file: &std::fs::File, path: &Path| -> Result<StreamLog> {
|
||||
Ok(StreamLog {
|
||||
path: format!("/{}", path.strip_prefix(world)?.display()),
|
||||
bytes: file.metadata()?.len(),
|
||||
})
|
||||
};
|
||||
let stdout = stream(&self.stdout, &self.stdout_path)?;
|
||||
let stderr = stream(&self.stderr, &self.stderr_path)?;
|
||||
Ok(BuildLogs {
|
||||
total_bytes: stdout.bytes + stderr.bytes,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
fn read(&self, path: &Path, offset: u64) -> Result<Vec<u8>> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut file =
|
||||
std::fs::File::open(path).with_context(|| format!("read {}", path.display()))?;
|
||||
file.seek(SeekFrom::Start(offset))?;
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
/// A successful build, usable without a running host.
|
||||
#[derive(Debug)]
|
||||
pub struct BuildOutput {
|
||||
pub logs: Option<BuildLogs>,
|
||||
pub wasm: PathBuf,
|
||||
pub warnings: String,
|
||||
pub tests: TestResults,
|
||||
}
|
||||
/// A failed compiler or test phase, with its complete diagnostics.
|
||||
#[derive(Debug)]
|
||||
pub struct BuildFailure {
|
||||
pub logs: Option<BuildLogs>,
|
||||
pub reason: String,
|
||||
pub tests: TestResults,
|
||||
pub output: String,
|
||||
pub elapsed_secs: Option<f64>,
|
||||
pub step: Option<&'static str>,
|
||||
}
|
||||
impl std::fmt::Display for BuildFailure {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(formatter, "{}\n{}", self.reason, self.output)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for BuildFailure {}
|
||||
impl From<anyhow::Error> for BuildFailure {
|
||||
fn from(error: anyhow::Error) -> Self {
|
||||
Self {
|
||||
logs: None,
|
||||
reason: "build preparation failed".into(),
|
||||
output: format!("{error:#}"),
|
||||
tests: TestResults::default(),
|
||||
elapsed_secs: None,
|
||||
step: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A host-independent builder. Each builder serializes access to its work and target folders.
|
||||
pub struct Cargo {
|
||||
world: World,
|
||||
builds: Arc<tokio::sync::Mutex<()>>,
|
||||
runner: PathBuf,
|
||||
target: PathBuf,
|
||||
sdk: PathBuf,
|
||||
}
|
||||
impl Cargo {
|
||||
/// Seeds a missing world SDK and selects the current sausage executable as runner.
|
||||
pub fn new(world: World, cache: &Path) -> Result<Self> {
|
||||
let mut runner = std::env::current_exe()?;
|
||||
if runner
|
||||
.parent()
|
||||
.is_some_and(|parent| parent.file_name().is_some_and(|name| name == "deps"))
|
||||
{
|
||||
runner = runner.parent().unwrap().parent().unwrap().join("sausage");
|
||||
}
|
||||
Self::with_runner(world, runner, cache)
|
||||
}
|
||||
/// Seeds a missing world SDK and accepts an explicit sausage binary, including for init integrations.
|
||||
pub fn with_runner(world: World, runner: PathBuf, cache: &Path) -> Result<Self> {
|
||||
let sdk = world.plugin_dir(ID).join("sdk");
|
||||
Self::seed_sdk(&sdk)?;
|
||||
Ok(Self {
|
||||
sdk,
|
||||
target: std::path::absolute(cache)?.join("cargo-target"),
|
||||
world,
|
||||
runner,
|
||||
builds: Arc::new(tokio::sync::Mutex::new(())),
|
||||
})
|
||||
}
|
||||
/// Seeds only an absent SDK; an existing world owns all of its SDK files.
|
||||
fn seed_sdk(sdk: &Path) -> Result<()> {
|
||||
if sdk.try_exists()? {
|
||||
return Ok(());
|
||||
}
|
||||
let mut files = std::collections::BTreeMap::new();
|
||||
collect_files(&SDK, Path::new("src"), &mut files);
|
||||
collect_files(&MACROS, Path::new("macros/src"), &mut files);
|
||||
collect_files(&INTERFACE, Path::new("interface/src"), &mut files);
|
||||
for (manifest, destination, interface_path) in [
|
||||
(
|
||||
include_str!("../../sdk/Cargo.toml"),
|
||||
PathBuf::from("Cargo.toml"),
|
||||
Some("interface"),
|
||||
),
|
||||
(
|
||||
include_str!("../../sdk/macros/Cargo.toml"),
|
||||
PathBuf::from("macros/Cargo.toml"),
|
||||
Some("../interface"),
|
||||
),
|
||||
(
|
||||
include_str!("../../interface/Cargo.toml"),
|
||||
PathBuf::from("interface/Cargo.toml"),
|
||||
None,
|
||||
),
|
||||
] {
|
||||
let mut document: toml_edit::DocumentMut = manifest.parse()?;
|
||||
if !document.contains_key("workspace") {
|
||||
document["workspace"] = toml_edit::table();
|
||||
}
|
||||
if let Some(path) = interface_path {
|
||||
for section in ["dependencies", "dev-dependencies"] {
|
||||
if let Some(dependency) = document
|
||||
.get_mut(section)
|
||||
.and_then(|table| table.get_mut("sausage-interface"))
|
||||
{
|
||||
dependency["path"] = toml_edit::value(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
files.insert(destination, document.to_string().into_bytes());
|
||||
}
|
||||
let parent = sdk.parent().context("SDK has no parent")?;
|
||||
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||
let staging = tempfile::Builder::new()
|
||||
.prefix("seed-sdk-")
|
||||
.tempdir_in(parent)
|
||||
.with_context(|| format!("stage SDK in {}", parent.display()))?;
|
||||
for (path, bytes) in &files {
|
||||
write_changed(&staging.path().join(path), bytes)?;
|
||||
}
|
||||
// Another builder may have seeded the world while we prepared the files.
|
||||
if !sdk.try_exists()?
|
||||
&& let Err(error) = std::fs::rename(staging.path(), sdk)
|
||||
&& !sdk.try_exists()?
|
||||
{
|
||||
return Err(error).with_context(|| format!("seed {}", sdk.display()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Registers cargo.build as a cancellable native process.
|
||||
pub fn register(self, host: &Host) {
|
||||
let builder = Arc::new(self);
|
||||
host.register_native(ID,Arc::new(move |_context,method,params| {
|
||||
let builder=builder.clone();
|
||||
Box::pin(async move {
|
||||
if method=="rpc.discover" { return Ok(json!({"openrpc":"1.2.6","info":{"title":"cargo","version":"native"},"methods":[{"name":"build","paramStructure":"by-name","description":"Copies source, runs bounded WASI tests, then builds a release component. Deadlines kill cargo and its descendants; cancellation releases the build mutex.","params":[{"name":"plugin","required":true,"schema":{"type":"string"}},{"name":"timeout_secs","required":false,"description":"Total wall-clock limit in seconds for tests and release build after acquiring the build mutex (queue time excluded). Default 900; minimum 1. Cannot be disabled.","schema":{"type":"integer","minimum":1,"default":DEFAULT_TIMEOUT_SECS}},{"name":"test_timeout_secs","required":false,"description":"Wall-clock limit in seconds for each WASI test instance. Default 120; minimum 1. A timed-out test is named and fails; remaining tests continue.","schema":{"type":"integer","minimum":1,"default":DEFAULT_TEST_TIMEOUT_SECS}}],"result":{"name":"build","schema":{"type":"object","required":["wasm","tests","warnings","logs"],"properties":{"logs":{"type":"object","required":["stdout","stderr","total_bytes"],"properties":{"stdout":{"type":"object","required":["path","bytes"],"properties":{"path":{"type":"string"},"bytes":{"type":"integer","minimum":0}}},"stderr":{"type":"object","required":["path","bytes"],"properties":{"path":{"type":"string"},"bytes":{"type":"integer","minimum":0}}},"total_bytes":{"type":"integer","minimum":0}}},"wasm":{"type":"string"},"warnings":{"type":"string"},"tests":{"type":"object","required":["passed","failed","output"],"properties":{"passed":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0},"output":{"type":"string"}}}}}},"errors":[{"code":-32004,"message":"Build failed"}]}]})); }
|
||||
if method!="build" { return Err(crate::error(-32601,"cargo method not found")); }
|
||||
let plugin=params["plugin"].as_str().ok_or_else(||crate::error(-32602,"plugin must be a string"))?;
|
||||
let options = BuildOptions {
|
||||
timeout_secs: seconds(¶ms, "timeout_secs", DEFAULT_TIMEOUT_SECS)?,
|
||||
test_timeout_secs: seconds(¶ms, "test_timeout_secs", DEFAULT_TEST_TIMEOUT_SECS)?,
|
||||
};
|
||||
match builder.build_with_options(&builder.world.plugin_dir(plugin).join("source"), options).await {
|
||||
Ok(output)=>Ok(json!({"wasm":format!("/{}",output.wasm.strip_prefix(&builder.world.root).unwrap().display()),"tests":output.tests,"warnings":output.warnings,"logs":output.logs})),
|
||||
Err(failure)=>Err(json!({"code":-32004,"message":failure.reason,"data":{"reason":failure.reason,"tests":failure.tests,"output":failure.output,"elapsed_secs":failure.elapsed_secs,"step":failure.step,"logs":failure.logs}})),
|
||||
}
|
||||
})
|
||||
}));
|
||||
}
|
||||
/// Copies source once, runs WASI tests, then publishes a release component under state/cargo/builds.
|
||||
pub async fn build(&self, source: &Path) -> std::result::Result<BuildOutput, BuildFailure> {
|
||||
self.build_with_options(source, BuildOptions::default())
|
||||
.await
|
||||
}
|
||||
/// Like build, with explicit positive wall-clock budgets. Waiting for the mutex is unbounded.
|
||||
pub async fn build_with_options(
|
||||
&self,
|
||||
source: &Path,
|
||||
options: BuildOptions,
|
||||
) -> std::result::Result<BuildOutput, BuildFailure> {
|
||||
let logs = LogFiles::create(&self.world.root)?;
|
||||
let result = self.build_logged(source, options, &logs).await;
|
||||
let info = logs.info(&self.world.root)?;
|
||||
match result {
|
||||
Ok(mut output) => {
|
||||
output.logs = Some(info);
|
||||
Ok(output)
|
||||
}
|
||||
Err(mut failure) => {
|
||||
failure.logs = Some(info);
|
||||
Err(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn build_logged(
|
||||
&self,
|
||||
source: &Path,
|
||||
options: BuildOptions,
|
||||
logs: &LogFiles,
|
||||
) -> std::result::Result<BuildOutput, BuildFailure> {
|
||||
if options.timeout_secs == 0 || options.test_timeout_secs == 0 {
|
||||
return Err(anyhow::anyhow!("timeouts must be at least 1 second").into());
|
||||
}
|
||||
let build_guard = self.builds.clone().lock_owned().await;
|
||||
let started = Instant::now();
|
||||
let deadline = started
|
||||
.checked_add(Duration::from_secs(options.timeout_secs))
|
||||
.ok_or_else(|| anyhow::anyhow!("timeout_secs is too large"))?;
|
||||
let work = self.world.state_dir(ID).join("work");
|
||||
let target = self.target.clone();
|
||||
let temporary = self.world.state_dir(ID).join("tmp");
|
||||
let cleanup = temporary.clone();
|
||||
let source = source.to_path_buf();
|
||||
let copy = work.clone();
|
||||
let sdk = self.sdk.clone();
|
||||
let build_guard = tokio::task::spawn_blocking(move || -> Result<_> {
|
||||
Self::seed_sdk(&sdk)?;
|
||||
if cleanup.exists() {
|
||||
std::fs::remove_dir_all(&cleanup)
|
||||
.with_context(|| format!("remove {}", cleanup.display()))?;
|
||||
}
|
||||
std::fs::create_dir_all(&cleanup)
|
||||
.with_context(|| format!("create {}", cleanup.display()))?;
|
||||
if copy.exists() {
|
||||
std::fs::remove_dir_all(©)
|
||||
.with_context(|| format!("remove {}", copy.display()))?;
|
||||
}
|
||||
copy_source(&source, ©, true)?;
|
||||
let manifest = copy.join("Cargo.toml");
|
||||
let text = std::fs::read_to_string(&manifest)
|
||||
.with_context(|| format!("read {}", manifest.display()))?;
|
||||
std::fs::write(
|
||||
&manifest,
|
||||
prepare_manifest(&text, &sdk)
|
||||
.with_context(|| format!("prepare {}", manifest.display()))?,
|
||||
)
|
||||
.with_context(|| format!("write {}", manifest.display()))?;
|
||||
Ok(build_guard)
|
||||
})
|
||||
.await
|
||||
.map_err(anyhow::Error::from)??;
|
||||
let mut test = Command::new("cargo");
|
||||
test.args([
|
||||
"test",
|
||||
"--no-fail-fast",
|
||||
"--release",
|
||||
"--target",
|
||||
TARGET,
|
||||
"--target-dir",
|
||||
])
|
||||
.arg(&target)
|
||||
.current_dir(&work)
|
||||
.env("TMPDIR", &temporary)
|
||||
.env(
|
||||
"SAUSAGE_TEST_TIMEOUT_SECS",
|
||||
options.test_timeout_secs.to_string(),
|
||||
)
|
||||
.arg("--config")
|
||||
.arg(format!(
|
||||
"target.wasm32-wasip2.runner={}",
|
||||
json!([self.runner, "test-runner"])
|
||||
));
|
||||
let output = run_command(
|
||||
test,
|
||||
started,
|
||||
deadline,
|
||||
"tests",
|
||||
TestResults::default(),
|
||||
logs,
|
||||
)
|
||||
.await?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let tests = TestResults {
|
||||
passed: test_count(&stdout, "passed"),
|
||||
failed: if output.status.success() {
|
||||
0
|
||||
} else {
|
||||
test_count(&stdout, "failed").max(1)
|
||||
},
|
||||
output: strip_progress(&format!("{stdout}\n{stderr}")),
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Err(BuildFailure {
|
||||
logs: None,
|
||||
reason: "WASI tests failed".into(),
|
||||
output: tests.output.clone(),
|
||||
tests,
|
||||
elapsed_secs: None,
|
||||
step: None,
|
||||
});
|
||||
}
|
||||
let mut build = Command::new("cargo");
|
||||
build
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
"--target",
|
||||
TARGET,
|
||||
"--message-format=json-render-diagnostics",
|
||||
"--target-dir",
|
||||
])
|
||||
.arg(&target)
|
||||
.current_dir(&work);
|
||||
let output = run_command(
|
||||
build,
|
||||
started,
|
||||
deadline,
|
||||
"release build",
|
||||
tests.clone(),
|
||||
logs,
|
||||
)
|
||||
.await?;
|
||||
let result = async {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let diagnostics = stderr.to_string();
|
||||
let mut artifacts = Vec::new();
|
||||
for line in stdout.lines() {
|
||||
if let Ok(message) = serde_json::from_str::<Value>(line)
|
||||
&& message["reason"] == "compiler-artifact"
|
||||
&& message["target"]["kind"]
|
||||
.as_array()
|
||||
.is_some_and(|kinds| kinds.iter().any(|kind| kind == "bin"))
|
||||
&& let Some(executable) = message["executable"].as_str()
|
||||
{
|
||||
artifacts.push(PathBuf::from(executable));
|
||||
}
|
||||
}
|
||||
if !output.status.success() {
|
||||
bail!("{diagnostics}");
|
||||
}
|
||||
if artifacts.len() != 1 {
|
||||
bail!(
|
||||
"cargo must build exactly one binary, found {}",
|
||||
artifacts.len()
|
||||
);
|
||||
}
|
||||
let artifact = artifacts.pop().unwrap();
|
||||
let builds = self.world.state_dir(ID).join("builds");
|
||||
let destination = tokio::task::spawn_blocking(move || {
|
||||
let _guard = build_guard;
|
||||
publish(&builds, &artifact)
|
||||
})
|
||||
.await??;
|
||||
Ok(BuildOutput {
|
||||
logs: None,
|
||||
wasm: destination,
|
||||
warnings: strip_progress(&diagnostics),
|
||||
tests: tests.clone(),
|
||||
})
|
||||
}
|
||||
.await;
|
||||
result.map_err(|error: anyhow::Error| BuildFailure {
|
||||
logs: None,
|
||||
reason: "release build failed".into(),
|
||||
output: format!("{error:#}"),
|
||||
tests,
|
||||
elapsed_secs: None,
|
||||
step: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
/// Kills cargo and every descendant, including tests and build scripts, on cancellation.
|
||||
struct ProcessGroup(u32);
|
||||
impl Drop for ProcessGroup {
|
||||
fn drop(&mut self) {
|
||||
let _ = nix::sys::signal::killpg(
|
||||
nix::unistd::Pid::from_raw(self.0 as i32),
|
||||
nix::sys::signal::Signal::SIGKILL,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Starts one process group and retains its guard across all output collection.
|
||||
async fn run_command(
|
||||
mut command: Command,
|
||||
started: Instant,
|
||||
deadline: Instant,
|
||||
step: &'static str,
|
||||
mut tests: TestResults,
|
||||
logs: &LogFiles,
|
||||
) -> std::result::Result<std::process::Output, BuildFailure> {
|
||||
command
|
||||
.process_group(0)
|
||||
.kill_on_drop(true)
|
||||
.stdin(std::process::Stdio::null())
|
||||
// Child writes go straight to persistent files: no pipe buffers or reader
|
||||
// tasks can be dropped on cancellation. Parsing happens only afterwards.
|
||||
.stdout(logs.stdout.try_clone().context("clone stdout log")?)
|
||||
.stderr(logs.stderr.try_clone().context("clone stderr log")?);
|
||||
let out_start = logs.stdout.metadata().context("stat stdout log")?.len();
|
||||
let err_start = logs.stderr.metadata().context("stat stderr log")?.len();
|
||||
let mut child = command.spawn().context("start cargo")?;
|
||||
let group = ProcessGroup(child.id().unwrap());
|
||||
|
||||
let (status, timed_out) = {
|
||||
let collect = child.wait();
|
||||
tokio::pin!(collect);
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
// Kill the entire tree before waiting for EOF/reaping cargo. The guard also
|
||||
// covers cancellation while collecting, and runs before the mutex drops.
|
||||
let _ = nix::sys::signal::killpg(nix::unistd::Pid::from_raw(group.0 as i32), nix::sys::signal::Signal::SIGKILL);
|
||||
(collect.await, true)
|
||||
}
|
||||
status = &mut collect => (status, false),
|
||||
}
|
||||
};
|
||||
let status = status.map_err(anyhow::Error::from)?;
|
||||
// Also stop any descendants that kept the streams open after cargo exited.
|
||||
drop(group);
|
||||
let out = logs.read(&logs.stdout_path, out_start)?;
|
||||
let err = logs.read(&logs.stderr_path, err_start)?;
|
||||
if timed_out {
|
||||
let output = format!(
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&out),
|
||||
String::from_utf8_lossy(&err)
|
||||
);
|
||||
let output = output
|
||||
.trim_end()
|
||||
.lines()
|
||||
.rev()
|
||||
.take(50)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if step == "tests" {
|
||||
tests.passed = test_count(&String::from_utf8_lossy(&out), "passed");
|
||||
tests.failed = test_count(&String::from_utf8_lossy(&out), "failed");
|
||||
tests.output = output.clone();
|
||||
}
|
||||
return Err(BuildFailure {
|
||||
logs: None,
|
||||
reason: "timeout".into(),
|
||||
tests,
|
||||
output,
|
||||
elapsed_secs: Some(started.elapsed().as_secs_f64()),
|
||||
step: Some(step),
|
||||
});
|
||||
}
|
||||
Ok(std::process::Output {
|
||||
status,
|
||||
stdout: out,
|
||||
stderr: err,
|
||||
})
|
||||
}
|
||||
/// Copies only source, excluding build outputs and git internals at every level.
|
||||
fn copy_source(source: &Path, destination: &Path, root: bool) -> Result<()> {
|
||||
std::fs::create_dir_all(destination)
|
||||
.with_context(|| format!("create {}", destination.display()))?;
|
||||
for entry in std::fs::read_dir(source).with_context(|| format!("read {}", source.display()))? {
|
||||
let entry = entry.with_context(|| format!("read entry in {}", source.display()))?;
|
||||
if root && matches!(entry.file_name().to_str(), Some("target" | ".git")) {
|
||||
continue;
|
||||
}
|
||||
let from = entry.path();
|
||||
let to = destination.join(entry.file_name());
|
||||
if from.is_dir() {
|
||||
copy_source(&from, &to, false)?;
|
||||
} else {
|
||||
std::fs::copy(&from, &to)
|
||||
.with_context(|| format!("copy {} to {}", from.display(), to.display()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Repoints SDK dependencies without discarding feature flags and detaches the copied crate's workspace.
|
||||
fn prepare_manifest(text: &str, sdk: &Path) -> Result<String> {
|
||||
let mut document: toml_edit::DocumentMut = text.parse()?;
|
||||
rewrite_dependencies(document.as_table_mut(), sdk);
|
||||
if !document.contains_key("workspace") {
|
||||
document["workspace"] = toml_edit::table();
|
||||
}
|
||||
Ok(document.to_string())
|
||||
}
|
||||
/// Rewrites dependency entries recursively, including target and workspace tables.
|
||||
fn rewrite_dependencies(table: &mut dyn toml_edit::TableLike, sdk: &Path) {
|
||||
for (name, item) in table.iter_mut() {
|
||||
if matches!(
|
||||
&*name,
|
||||
"dependencies" | "dev-dependencies" | "build-dependencies"
|
||||
) {
|
||||
if let Some(dependencies) = item.as_table_like_mut() {
|
||||
for (name, dependency) in dependencies.iter_mut() {
|
||||
if dependency
|
||||
.get("workspace")
|
||||
.and_then(toml_edit::Item::as_bool)
|
||||
== Some(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if &*name == "sausage-plugin"
|
||||
|| dependency.get("package").and_then(toml_edit::Item::as_str)
|
||||
== Some("sausage-plugin")
|
||||
{
|
||||
if dependency.is_str() {
|
||||
let version = dependency.as_str().unwrap().to_string();
|
||||
*dependency = toml_edit::value(toml_edit::InlineTable::new());
|
||||
dependency["version"] = toml_edit::value(version);
|
||||
}
|
||||
dependency["path"] = toml_edit::value(sdk.display().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(nested) = item.as_table_like_mut() {
|
||||
rewrite_dependencies(nested, sdk);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Publishes a complete artifact and retains only the ten newest numeric build folders.
|
||||
fn publish(builds: &Path, artifact: &Path) -> Result<PathBuf> {
|
||||
std::fs::create_dir_all(builds).with_context(|| format!("create {}", builds.display()))?;
|
||||
let mut numbers: Vec<u64> = std::fs::read_dir(builds)
|
||||
.with_context(|| format!("read {}", builds.display()))?
|
||||
.filter_map(|entry| entry.ok()?.file_name().to_str()?.parse().ok())
|
||||
.collect();
|
||||
numbers.sort_unstable();
|
||||
let number = numbers.last().copied().unwrap_or(0) + 1;
|
||||
let destination = builds
|
||||
.join(number.to_string())
|
||||
.join(artifact.file_name().unwrap());
|
||||
crate::world::write_atomic(
|
||||
&destination,
|
||||
&std::fs::read(artifact).with_context(|| format!("read {}", artifact.display()))?,
|
||||
)
|
||||
.with_context(|| format!("publish {}", destination.display()))?;
|
||||
for old in numbers.iter().take(numbers.len().saturating_sub(9)) {
|
||||
let path = builds.join(old.to_string());
|
||||
std::fs::remove_dir_all(&path).with_context(|| format!("remove {}", path.display()))?;
|
||||
}
|
||||
Ok(destination)
|
||||
}
|
||||
/// Writes embedded sources only when their bytes changed.
|
||||
fn write_changed(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
if std::fs::read(path).ok().as_deref() != Some(bytes) {
|
||||
std::fs::create_dir_all(path.parent().unwrap())
|
||||
.with_context(|| format!("create parent of {}", path.display()))?;
|
||||
std::fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Collects embedded source files for seeding a missing world SDK.
|
||||
fn collect_files(
|
||||
directory: &Dir<'_>,
|
||||
destination: &Path,
|
||||
files: &mut std::collections::BTreeMap<PathBuf, Vec<u8>>,
|
||||
) {
|
||||
for file in directory.files() {
|
||||
files.insert(destination.join(file.path()), file.contents().to_vec());
|
||||
}
|
||||
for directory in directory.dirs() {
|
||||
collect_files(directory, destination, files);
|
||||
}
|
||||
}
|
||||
/// Counts only final libtest summaries, never individual status lines.
|
||||
fn test_count(output: &str, field: &str) -> u64 {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("test result:"))
|
||||
.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()
|
||||
}
|
||||
/// Removes progress lines while retaining compiler diagnostics.
|
||||
fn strip_progress(output: &str) -> String {
|
||||
output
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
![
|
||||
"Compiling ",
|
||||
"Finished ",
|
||||
"Blocking ",
|
||||
"Updating ",
|
||||
"Locking ",
|
||||
"Adding ",
|
||||
]
|
||||
.iter()
|
||||
.any(|prefix| line.trim_start().starts_with(prefix))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod timeout_tests {
|
||||
use super::*;
|
||||
|
||||
/// Streams retain exact bytes, including non-UTF8, progress and JSON; phases append.
|
||||
#[tokio::test]
|
||||
async fn raw_streams_append_before_parsing() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let logs = LogFiles::create(directory.path()).unwrap();
|
||||
for _ in 0..2 {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "printf '\\377raw\\r'; printf ' Compiling progress\\n{\"reason\":\"compiler-artifact\"}\\n' >&2; exit 3"]);
|
||||
let now = Instant::now();
|
||||
let output = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
run_command(
|
||||
command,
|
||||
now,
|
||||
now + Duration::from_secs(3),
|
||||
"tests",
|
||||
TestResults::default(),
|
||||
&logs,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert_eq!(output.stdout, b"\xffraw\r");
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("Compiling progress"));
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(&logs.stdout_path).unwrap(),
|
||||
b"\xffraw\r\xffraw\r"
|
||||
);
|
||||
let info = logs.info(directory.path()).unwrap();
|
||||
assert_eq!(info.stdout.bytes, 10);
|
||||
assert_eq!(info.total_bytes, info.stdout.bytes + info.stderr.bytes);
|
||||
assert!(info.stdout.path.starts_with("/logs/cargo/"));
|
||||
assert!(!directory.path().join("state/cargo").exists());
|
||||
}
|
||||
|
||||
/// Cancellation leaves kernel-written data intact, even without a newline.
|
||||
#[tokio::test]
|
||||
async fn cancelled_command_keeps_logs() {
|
||||
use notify::Watcher;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let logs = LogFiles::create(directory.path()).unwrap();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut watcher =
|
||||
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
|
||||
if let Ok(event) = event {
|
||||
if event
|
||||
.paths
|
||||
.iter()
|
||||
.any(|path| path.file_name().is_some_and(|name| name == "ready"))
|
||||
{
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
watcher
|
||||
.watch(directory.path(), notify::RecursiveMode::NonRecursive)
|
||||
.unwrap();
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "printf 'partial\\377'; printf 'stderr\\r' >&2; touch \"$1/ready\"; exec sleep 1000", "sh"]).arg(directory.path());
|
||||
let now = Instant::now();
|
||||
let task = tokio::spawn(async move {
|
||||
run_command(
|
||||
command,
|
||||
now,
|
||||
now + Duration::from_secs(60),
|
||||
"tests",
|
||||
TestResults::default(),
|
||||
&logs,
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(5), rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
task.abort();
|
||||
assert!(task.await.unwrap_err().is_cancelled());
|
||||
let paths: Vec<_> = std::fs::read_dir(directory.path().join("logs/cargo"))
|
||||
.unwrap()
|
||||
.flat_map(|day| std::fs::read_dir(day.unwrap().path()).unwrap())
|
||||
.map(|file| file.unwrap().path())
|
||||
.collect();
|
||||
assert_eq!(paths.len(), 2);
|
||||
for path in paths {
|
||||
let expected: &[u8] = if path.to_string_lossy().ends_with(".stdout.log") {
|
||||
b"partial\xff"
|
||||
} else {
|
||||
b"stderr\r"
|
||||
};
|
||||
assert_eq!(std::fs::read(path).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deadline errors retain the shared start time, phase, test totals, and output tail.
|
||||
#[tokio::test]
|
||||
async fn timeout_diagnostics() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
"i=0; while [ $i -lt 100 ]; do echo diagnostic-$i; i=$((i+1)); done; exec sleep 1000",
|
||||
]);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let logs = LogFiles::create(directory.path()).unwrap();
|
||||
let now = Instant::now();
|
||||
let failure = run_command(
|
||||
command,
|
||||
now - Duration::from_secs(10),
|
||||
now + Duration::from_secs(1),
|
||||
"release build",
|
||||
TestResults {
|
||||
passed: 7,
|
||||
failed: 0,
|
||||
output: "successful tests".into(),
|
||||
},
|
||||
&logs,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(failure.reason, "timeout");
|
||||
assert_eq!(failure.step, Some("release build"));
|
||||
assert!(failure.elapsed_secs.unwrap() >= 11.0);
|
||||
assert_eq!(failure.tests.passed, 7);
|
||||
assert_eq!(failure.tests.output, "successful tests");
|
||||
assert_eq!(failure.output.lines().count(), 50);
|
||||
assert!(failure.output.contains("diagnostic-99"));
|
||||
assert!(!failure.output.contains("diagnostic-0\n"));
|
||||
let raw = std::fs::read_to_string(&logs.stdout_path).unwrap();
|
||||
assert_eq!(raw.lines().count(), 100);
|
||||
assert!(raw.starts_with("diagnostic-0\n"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
//! The native `cargo` actor: builds Rust source folders into `.wasm` files.
|
||||
//!
|
||||
//! Request: `{"type": "build", "source": "/actor/<id>/public/<folder>"}`
|
||||
//! optional `"skip_tests": true` skips native `cargo test` (tests run by default).
|
||||
//! 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
|
||||
//! `.wasm` file. Other actors can add other languages.
|
||||
//!
|
||||
//! Actors depend on the SDK with `sausage-actor = "0.1"`. The SDK is not
|
||||
//! published: this actor copies the SDK of this host to `public/sdk/` (so
|
||||
//! that every actor can read it), and points the dependency there in the
|
||||
//! build copy of `Cargo.toml`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use include_dir::{Dir, include_dir};
|
||||
|
||||
use crate::broker::{Broker, Envelope};
|
||||
use crate::world::{self, World};
|
||||
|
||||
pub const ID: &str = "cargo";
|
||||
pub const TARGET: &str = "wasm32-wasip2";
|
||||
|
||||
/// The source of the `sausage-actor` 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 {
|
||||
/// Host path of the published `.wasm` file.
|
||||
pub wasm: PathBuf,
|
||||
pub seconds: f64,
|
||||
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.
|
||||
pub struct Cargo {
|
||||
world: World,
|
||||
}
|
||||
|
||||
impl Cargo {
|
||||
/// Create the actor and install the SDK of this host into `public/sdk/`.
|
||||
pub fn new(world: World) -> Result<Self> {
|
||||
write_dir(&SDK, &world.public_dir(ID).join("sdk"))?;
|
||||
Ok(Self { world })
|
||||
}
|
||||
|
||||
/// Run native `cargo test` unless opted out, then build and publish the
|
||||
/// crate's `wasm32-wasip2` binary under `/actor/cargo/public/builds/<n>/`.
|
||||
pub async fn build(&self, source: &Path, skip_tests: bool) -> std::result::Result<BuildOutput, BuildFailure> {
|
||||
if !source.join("Cargo.toml").is_file() {
|
||||
return Err(BuildFailure {
|
||||
reason: format!("{} has no Cargo.toml", source.display()),
|
||||
tests: TestResults::default(),
|
||||
});
|
||||
}
|
||||
let started = Instant::now();
|
||||
let data = self.world.data_dir(ID);
|
||||
let build_dir = data.join("work");
|
||||
world::replace_source(source, &build_dir)?;
|
||||
let manifest = build_dir.join("Cargo.toml");
|
||||
let original = std::fs::read_to_string(&manifest).context("read build manifest")?;
|
||||
let prepared = prepare_manifest(&original, Some(&self.world.public_dir(ID).join("sdk")))?;
|
||||
std::fs::write(&manifest, prepared).context("write prepared manifest")?;
|
||||
|
||||
let target_dir = data.join("target");
|
||||
let tests = if skip_tests {
|
||||
TestResults { output: "skipped (skip_tests=true)".into(), ..TestResults::default() }
|
||||
} else {
|
||||
match run_tests(&build_dir, &target_dir).await {
|
||||
Ok(tests) => tests,
|
||||
Err(error) => {
|
||||
let output = format!("cannot run cargo tests: {error:#}");
|
||||
return Err(BuildFailure {
|
||||
reason: output.clone(),
|
||||
tests: TestResults { failed: 1, output, ..TestResults::default() },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
if tests.failed > 0 {
|
||||
return Err(BuildFailure { reason: "unit tests failed".into(), tests });
|
||||
}
|
||||
|
||||
let build_result: Result<BuildOutput> = async {
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.arg("build")
|
||||
.arg("--release")
|
||||
.arg("--target")
|
||||
.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.
|
||||
/// Replies (messages with `reply_to`) get no answer.
|
||||
pub async fn run_actor(self, broker: Arc<Broker>, mut mailbox: mpsc::Receiver<Envelope>) {
|
||||
while let Some(env) = mailbox.recv().await {
|
||||
if env.reply_to.is_some() {
|
||||
continue;
|
||||
}
|
||||
let body = self.handle(&broker, &env).await.unwrap_or_else(|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);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(&self, broker: &Broker, env: &Envelope) -> Result<Value> {
|
||||
match env.body.get("type").and_then(Value::as_str) {
|
||||
Some("build") => {
|
||||
let source_s = env.body.get("source").and_then(Value::as_str).ok_or_else(|| anyhow!("missing \"source\""))?;
|
||||
let source = broker.world.resolve(&env.from, source_s)?;
|
||||
let skip_tests = env.body.get("skip_tests").and_then(Value::as_bool).unwrap_or(false);
|
||||
match self.build(&source, skip_tests).await {
|
||||
Ok(out) => {
|
||||
let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string());
|
||||
Ok(json!({
|
||||
"type": "built",
|
||||
"wasm": wasm,
|
||||
"source": source_s,
|
||||
"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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// add an empty `[workspace]`, so the crate never joins a parent workspace.
|
||||
fn prepare_manifest(text: &str, sdk: Option<&Path>) -> Result<String> {
|
||||
let mut doc: toml_edit::DocumentMut = text.parse()?;
|
||||
if let Some(sdk) = sdk {
|
||||
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
|
||||
let Some(dep) = doc.get_mut(section).and_then(|t| t.get_mut("sausage-actor")) else { continue };
|
||||
let mut table = dep.as_inline_table().cloned().unwrap_or_default();
|
||||
for key in ["version", "path", "git", "branch", "tag", "rev", "registry"] {
|
||||
table.remove(key);
|
||||
}
|
||||
table.insert("path", sdk.display().to_string().into());
|
||||
*dep = toml_edit::value(table);
|
||||
}
|
||||
}
|
||||
if doc.get("workspace").is_none() {
|
||||
doc["workspace"] = toml_edit::table();
|
||||
}
|
||||
Ok(doc.to_string())
|
||||
}
|
||||
|
||||
/// Write the files of `dir` below `to`. Skip files that did not change, so
|
||||
/// that cargo does not rebuild the SDK at every start.
|
||||
fn write_dir(dir: &Dir<'_>, to: &Path) -> Result<()> {
|
||||
for file in dir.files() {
|
||||
let dest = to.join(file.path());
|
||||
let contents = match file.path().file_name() {
|
||||
Some(n) if n == "Cargo.toml" => prepare_manifest(file.contents_utf8().context("Cargo.toml")?, None)?.into_bytes(),
|
||||
_ => file.contents().to_vec(),
|
||||
};
|
||||
if std::fs::read(&dest).ok() != Some(contents.clone()) {
|
||||
std::fs::create_dir_all(dest.parent().context("no parent")?)?;
|
||||
std::fs::write(&dest, contents)?;
|
||||
}
|
||||
}
|
||||
for sub in dir.dirs() {
|
||||
write_dir(sub, to)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_build_number(dir: &Path) -> Result<u64> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let max = std::fs::read_dir(dir)?
|
||||
.filter_map(|e| e.ok()?.file_name().to_str()?.parse::<u64>().ok())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
Ok(max + 1)
|
||||
}
|
||||
|
||||
/// Remove cargo's "Compiling ..." progress lines. Keep diagnostics.
|
||||
fn strip_progress(stderr: &str) -> String {
|
||||
stderr
|
||||
.lines()
|
||||
.filter(|l| {
|
||||
let t = l.trim_start();
|
||||
!(t.starts_with("Compiling ")
|
||||
|| t.starts_with("Downloaded ")
|
||||
|| t.starts_with("Downloading ")
|
||||
|| t.starts_with("Updating ")
|
||||
|| t.starts_with("Locking ")
|
||||
|| t.starts_with("Adding ")
|
||||
|| t.starts_with("Blocking "))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
//! Metadata-keyed component loads and weak compiled versions; candidate copies never enter the path cache.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// PROTOCOL 10.3: a pending component load does not occupy admission.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn loading_does_not_hold_admission() {
|
||||
let folder =
|
||||
std::env::temp_dir().join(format!("sausage-admission-{}", uuid::Uuid::now_v7()));
|
||||
let world = crate::world::World::create(&folder).unwrap();
|
||||
let path = world.plugin_dir("probe").join("plugin.wasm");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, []).unwrap();
|
||||
let host = crate::Host::start_with_cache(&folder, &folder.join("cache"))
|
||||
.await
|
||||
.unwrap();
|
||||
let entry = host
|
||||
.0
|
||||
.components
|
||||
.paths
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(path)
|
||||
.or_default()
|
||||
.clone();
|
||||
let blocked = entry.lock().await;
|
||||
let caller = host.clone();
|
||||
let pending =
|
||||
tokio::spawn(async move { caller.call("probe.echo", serde_json::json!({})).await });
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
while Arc::strong_count(&entry) < 3 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let admitted = host.0.admission.try_write().is_ok();
|
||||
drop(blocked);
|
||||
assert!(pending.await.unwrap().is_err());
|
||||
host.stop().await;
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
assert!(admitted, "a component load held the admission gate");
|
||||
}
|
||||
/// PROTOCOL 10.3: temporary discovery and activation copies never accumulate cached paths.
|
||||
#[tokio::test]
|
||||
async fn candidate_copies_do_not_enter_path_cache() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let target = root.join("target/core/guests");
|
||||
assert!(
|
||||
std::process::Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--offline",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"--manifest-path"
|
||||
])
|
||||
.arg(root.join("cli/tests/plugins/probe/Cargo.toml"))
|
||||
.arg("--target-dir")
|
||||
.arg(&target)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
let folder =
|
||||
std::env::temp_dir().join(format!("sausage-component-cache-{}", uuid::Uuid::now_v7()));
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let runtime = Runtime::new(&root.join("target/core/cache-test")).unwrap();
|
||||
let cache = Components::default();
|
||||
for name in ["discover-test.wasm", "activate-test.wasm"] {
|
||||
let path = folder.join(name);
|
||||
std::fs::copy(target.join("wasm32-wasip2/debug/probe.wasm"), &path).unwrap();
|
||||
let loaded = cache.load(path.clone(), runtime.clone()).await.unwrap();
|
||||
assert!(cache.paths.lock().unwrap().is_empty());
|
||||
let weak = Arc::downgrade(&loaded.prepared);
|
||||
drop(loaded);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
assert!(weak.upgrade().is_none());
|
||||
}
|
||||
std::fs::remove_dir(folder).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
use crate::{
|
||||
error,
|
||||
runtime::{Prepared, Runtime},
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex, Weak},
|
||||
time::SystemTime,
|
||||
};
|
||||
use tokio::sync::{Mutex as AsyncMutex, OnceCell};
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Fingerprint {
|
||||
size: u64,
|
||||
modified: SystemTime,
|
||||
#[cfg(unix)]
|
||||
identity: (u64, u64, i64, i64),
|
||||
}
|
||||
impl Fingerprint {
|
||||
pub(crate) async fn read(path: &Path) -> Result<Self, serde_json::Value> {
|
||||
let metadata = tokio::fs::metadata(path)
|
||||
.await
|
||||
.map_err(|_| error(-32001, "no such plugin"))?;
|
||||
Ok(Self {
|
||||
size: metadata.len(),
|
||||
modified: metadata
|
||||
.modified()
|
||||
.map_err(|e| error(-32603, &e.to_string()))?,
|
||||
#[cfg(unix)]
|
||||
identity: {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
(
|
||||
metadata.dev(),
|
||||
metadata.ino(),
|
||||
metadata.ctime(),
|
||||
metadata.ctime_nsec(),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Loaded {
|
||||
pub(crate) prepared: Arc<Prepared>,
|
||||
pub(crate) hash: String,
|
||||
pub(crate) fingerprint: Fingerprint,
|
||||
// Retain the per-hash cell only while a cached path refers to this version.
|
||||
_compiled: Arc<OnceCell<Arc<Prepared>>>,
|
||||
}
|
||||
type PathEntry = Arc<AsyncMutex<Option<Loaded>>>;
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Components {
|
||||
paths: Mutex<HashMap<PathBuf, PathEntry>>,
|
||||
compiled: Mutex<HashMap<String, Weak<OnceCell<Arc<Prepared>>>>>,
|
||||
}
|
||||
impl Components {
|
||||
pub(crate) async fn load(
|
||||
&self,
|
||||
path: PathBuf,
|
||||
runtime: Arc<Runtime>,
|
||||
) -> Result<Loaded, serde_json::Value> {
|
||||
let candidate = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with("activate-") || n.starts_with("discover-"));
|
||||
let entry = if candidate {
|
||||
Arc::default()
|
||||
} else {
|
||||
self.paths
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(path.clone())
|
||||
.or_default()
|
||||
.clone()
|
||||
};
|
||||
let mut cached = entry.lock().await;
|
||||
let fingerprint = match Fingerprint::read(&path).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => {
|
||||
*cached = None;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Some(version) = cached.as_ref()
|
||||
&& version.fingerprint == fingerprint
|
||||
{
|
||||
return Ok(version.clone());
|
||||
}
|
||||
let (bytes, hash) = tokio::task::spawn_blocking(move || -> std::io::Result<_> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let hash = format!("{:x}", Sha256::digest(&bytes));
|
||||
Ok((bytes, hash))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| error(-32603, &e.to_string()))?
|
||||
.map_err(|e| error(-32603, &e.to_string()))?;
|
||||
let compiled = {
|
||||
let mut versions = self.compiled.lock().unwrap();
|
||||
versions.retain(|_, v| v.strong_count() > 0);
|
||||
if let Some(cell) = versions.get(&hash).and_then(Weak::upgrade) {
|
||||
cell
|
||||
} else {
|
||||
let cell = Arc::new(OnceCell::new());
|
||||
versions.insert(hash.clone(), Arc::downgrade(&cell));
|
||||
cell
|
||||
}
|
||||
};
|
||||
let prepared = compiled
|
||||
.get_or_try_init(|| async move {
|
||||
tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
|
||||
let component = runtime.compile(&bytes)?;
|
||||
Ok(Arc::new(runtime.prepare(&component)))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| error(-32603, &e.to_string()))?
|
||||
.map_err(|e| error(-32603, &e.to_string()))
|
||||
})
|
||||
.await?
|
||||
.clone();
|
||||
let loaded = Loaded {
|
||||
prepared,
|
||||
hash,
|
||||
fingerprint,
|
||||
_compiled: compiled,
|
||||
};
|
||||
if !candidate {
|
||||
*cached = Some(loaded.clone());
|
||||
}
|
||||
Ok(loaded)
|
||||
}
|
||||
}
|
||||
+55
-301
@@ -1,314 +1,68 @@
|
||||
//! Sequential console commands, line editing, and live notifications.
|
||||
use crate::Host;
|
||||
use anyhow::Result;
|
||||
use rustyline::{DefaultEditor, ExternalPrinter, error::ReadlineError};
|
||||
use sausage_vfs::{Layer, Open};
|
||||
//! The native `console` actor: your terminal.
|
||||
//!
|
||||
//! - `@<actor> <json>` sends a JSON body. `@<actor> <text>` sends
|
||||
//! `{"type": "text", "text": ...}`.
|
||||
//! - A line without `@` goes to `agent` as text.
|
||||
//! - `/help`, `/quit`. EOF on stdin also quits (not with `--headless`, which
|
||||
//! never reads stdin).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
const HELP: &str = "text chat with the agent
|
||||
plugin.method {json} call a plugin
|
||||
ls [path], cat <path>, methods <plugin>
|
||||
ps, log [plugin or pid] [-n N]
|
||||
activate <plugin> <wasm>, revert <plugin>
|
||||
stop <pid>, kill <pid>, help, quit";
|
||||
use crate::broker::{Broker, Envelope};
|
||||
|
||||
/// Read a complete file through the host filesystem.
|
||||
async fn read(host: &Host, path: &str) -> Result<String> {
|
||||
let file = host
|
||||
.filesystem()
|
||||
.await?
|
||||
.open(
|
||||
path,
|
||||
Open {
|
||||
follow: true,
|
||||
..Open::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let chunk = file.read(bytes.len() as u64, 65536).await?;
|
||||
if chunk.is_empty() {
|
||||
return Ok(String::from_utf8(bytes)?);
|
||||
}
|
||||
bytes.extend(chunk);
|
||||
pub const ID: &str = "console";
|
||||
|
||||
const HELP: &str = "\
|
||||
@<actor> <json> send a JSON body to an actor
|
||||
@<actor> <text> send {\"type\":\"text\",\"text\":...}
|
||||
<text> send text to the agent
|
||||
/quit stop all actors and exit";
|
||||
|
||||
/// Print a message: text bodies as text, everything else as JSON.
|
||||
fn show(env: &Envelope) {
|
||||
let reply = if env.reply_to.is_some() { " ↩" } else { "" };
|
||||
match env.body["text"].as_str() {
|
||||
Some(text) if env.body["type"] == "text" => println!("[{}{reply}] {text}", env.from),
|
||||
_ => println!("[{}{reply}] {}", env.from, env.body),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognize the process IDs accepted by stop, kill, and log.
|
||||
fn is_pid(text: &str) -> bool {
|
||||
text.rsplit_once('-').is_some()
|
||||
&& text
|
||||
.get(text.len().saturating_sub(36)..)
|
||||
.is_some_and(|id| uuid::Uuid::try_parse(id).is_ok())
|
||||
/// Print every message that arrives for the console.
|
||||
pub async fn run_mailbox(mut mailbox: mpsc::Receiver<Envelope>) {
|
||||
while let Some(env) = mailbox.recv().await {
|
||||
show(&env);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render method signatures and their documentation.
|
||||
fn methods(plugin: &str, doc: &Value) -> String {
|
||||
let mut lines = Vec::new();
|
||||
for method in doc["methods"].as_array().into_iter().flatten() {
|
||||
let params = method["params"].as_array().cloned().unwrap_or_default();
|
||||
let signature = params
|
||||
.iter()
|
||||
.map(|p| {
|
||||
format!(
|
||||
"{}: {}",
|
||||
p["name"].as_str().unwrap_or("?"),
|
||||
p["schema"]["type"].as_str().unwrap_or("any")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
lines.push(format!(
|
||||
"{plugin}.{}({signature}) -> {}",
|
||||
method["name"].as_str().unwrap_or("?"),
|
||||
method["result"]["schema"]["type"].as_str().unwrap_or("any")
|
||||
));
|
||||
for field in ["summary", "description"] {
|
||||
if let Some(text) = method[field].as_str() {
|
||||
lines.push(format!(" {text}"));
|
||||
/// Read lines until EOF or `/quit`, and send them as messages.
|
||||
pub async fn run_input(broker: Arc<Broker>) {
|
||||
println!("Sausage console. Type /help for commands.");
|
||||
let mut lines = BufReader::new(tokio::io::stdin()).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let line = line.trim();
|
||||
let (to, body) = match line {
|
||||
"" => continue,
|
||||
"/quit" => break,
|
||||
"/help" => {
|
||||
println!("{HELP}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for p in params {
|
||||
if let Some(text) = p["description"].as_str() {
|
||||
lines.push(format!(" {}: {text}", p["name"].as_str().unwrap_or("?")));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Parse journal filters, or treat invalid arguments as chat.
|
||||
fn log_params(words: &[&str]) -> Option<Value> {
|
||||
let mut params = json!({});
|
||||
let mut words = words.iter();
|
||||
while let Some(word) = words.next() {
|
||||
if *word == "-n" {
|
||||
params["limit"] = json!(words.next()?.parse::<u64>().ok()?);
|
||||
} else if params.get("plugin").is_none() && params.get("pid").is_none() {
|
||||
params[if is_pid(word) { "pid" } else { "plugin" }] = json!(word);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(params)
|
||||
}
|
||||
|
||||
/// Execute one line and return its unlabelled result.
|
||||
pub async fn execute_line(host: &Host, line: &str) -> Result<String> {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let (command, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
|
||||
let rest = rest.trim();
|
||||
let words: Vec<_> = rest.split_whitespace().collect();
|
||||
match (command, words.as_slice()) {
|
||||
("help", []) => return Ok(HELP.into()),
|
||||
("quit", []) => return Ok("Goodbye.".into()),
|
||||
("cat", [path]) => return read(host, path).await,
|
||||
("ls", [] | [_]) => {
|
||||
let mut entries = host
|
||||
.filesystem()
|
||||
.await?
|
||||
.list(words.first().copied().unwrap_or("/"))
|
||||
.await?;
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
return Ok(entries
|
||||
.iter()
|
||||
.map(|e| format!("{}{}", e.name, if e.directory { "/" } else { "" }))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"));
|
||||
}
|
||||
("methods", [plugin]) => {
|
||||
let _activation = host.0.activations.lock().await;
|
||||
host.refresh_graph()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
return Ok(methods(
|
||||
plugin,
|
||||
&serde_json::from_str(
|
||||
&read(host, &format!("/proc/core/plugins/{plugin}/discover.json")).await?,
|
||||
)?,
|
||||
));
|
||||
}
|
||||
("ps", []) => {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_millis();
|
||||
let mut processes: Vec<_> = host.processes().into_iter().collect();
|
||||
processes.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut lines = vec!["PID PLUGIN METHOD PARENT STATE AGE".into()];
|
||||
lines.extend(processes.iter().map(|(pid, p)| {
|
||||
format!(
|
||||
"{pid} {} {} {} {} {}s",
|
||||
p.plugin,
|
||||
p.method,
|
||||
p.parent.as_deref().unwrap_or("-"),
|
||||
p.state,
|
||||
now.saturating_sub(p.start_time) / 1000
|
||||
)
|
||||
}));
|
||||
return Ok(lines.join("\n"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let explicit = (rest.is_empty() || rest.starts_with('{'))
|
||||
&& command
|
||||
.split_once('.')
|
||||
.is_some_and(|(p, m)| !p.is_empty() && !m.is_empty());
|
||||
let (method, params) = match (command, words.as_slice()) {
|
||||
("activate", [plugin, wasm]) => {
|
||||
("core.activate".into(), json!({"plugin":plugin,"wasm":wasm}))
|
||||
}
|
||||
("revert", [plugin]) => ("core.revert".into(), json!({"plugin":plugin})),
|
||||
("stop" | "kill", [pid]) if is_pid(pid) => (format!("core.{command}"), json!({"pid":pid})),
|
||||
("log", _) if log_params(&words).is_some() => {
|
||||
("core.log".into(), log_params(&words).unwrap())
|
||||
}
|
||||
_ if explicit => (
|
||||
command.into(),
|
||||
if rest.is_empty() {
|
||||
json!({})
|
||||
} else {
|
||||
serde_json::from_str(rest)?
|
||||
},
|
||||
),
|
||||
_ => ("agent.chat".into(), json!({"text":line})),
|
||||
};
|
||||
let value = match host.call(&method, params).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Ok(format!("error: {}", serde_json::to_string_pretty(&error)?)),
|
||||
};
|
||||
if method == "agent.chat" && !explicit {
|
||||
return Ok(value["text"]
|
||||
.as_str()
|
||||
.or_else(|| value.as_str())
|
||||
.map(str::to_owned)
|
||||
.unwrap_or(serde_json::to_string_pretty(&value)?));
|
||||
}
|
||||
if method == "core.log" && command == "log" {
|
||||
return value
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let time = chrono::DateTime::parse_from_rfc3339(e["time"].as_str().unwrap())?
|
||||
.with_timezone(&chrono::Local);
|
||||
let pid = e["pid"].as_str().unwrap();
|
||||
let prefix = format!("{}-", e["plugin"].as_str().unwrap());
|
||||
let short = pid
|
||||
.strip_prefix(&prefix)
|
||||
.map(|id| format!("{prefix}{}", &id[..8]))
|
||||
.unwrap_or(pid.into());
|
||||
Ok(format!(
|
||||
"{} {short} {} {}",
|
||||
time.format("%H:%M:%S%.3f"),
|
||||
e["stream"].as_str().unwrap(),
|
||||
e["line"].as_str().unwrap()
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.map(|lines| lines.join("\n"));
|
||||
}
|
||||
Ok(serde_json::to_string_pretty(&value)?)
|
||||
}
|
||||
|
||||
/// Dim text notifications and preserve other payloads.
|
||||
pub fn notification_text(value: &Value) -> String {
|
||||
let params = &value["params"];
|
||||
if params["name"] != "text" {
|
||||
return value.to_string();
|
||||
}
|
||||
let payload = ¶ms["params"];
|
||||
let text = payload["text"]
|
||||
.as_str()
|
||||
.or_else(|| payload.as_str())
|
||||
.map(str::to_owned)
|
||||
.unwrap_or(payload.to_string());
|
||||
format!("\x1b[2m{text}\x1b[0m")
|
||||
}
|
||||
|
||||
/// Read and execute one line at a time. Interrupting a wait does not cancel its host process.
|
||||
pub async fn run_input(host: Host) -> Result<()> {
|
||||
let terminal = std::io::IsTerminal::is_terminal(&std::io::stdin());
|
||||
let history = host.0.world.state_dir("core").join("history");
|
||||
let mut editor = if terminal {
|
||||
Some(DefaultEditor::new()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(editor) = &mut editor {
|
||||
let _ = editor.load_history(&history);
|
||||
}
|
||||
let mut printer = editor
|
||||
.as_mut()
|
||||
.map(|e| e.create_external_printer())
|
||||
.transpose()?;
|
||||
let (request, requests) =
|
||||
std::sync::mpsc::channel::<tokio::sync::oneshot::Sender<Result<Option<String>>>>();
|
||||
std::thread::spawn(move || {
|
||||
for response in requests {
|
||||
let line = (|| -> Result<_> {
|
||||
if let Some(editor) = &mut editor {
|
||||
match editor.readline("> ") {
|
||||
Ok(line) => {
|
||||
editor.add_history_entry(&line)?;
|
||||
editor.save_history(&history)?;
|
||||
Ok(Some(line))
|
||||
}
|
||||
Err(ReadlineError::Interrupted | ReadlineError::Eof) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
} else {
|
||||
std::io::stdin()
|
||||
.lines()
|
||||
.next()
|
||||
.transpose()
|
||||
.map_err(Into::into)
|
||||
_ => match line.strip_prefix('@') {
|
||||
Some(rest) => {
|
||||
let (to, text) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
|
||||
let body = match serde_json::from_str::<Value>(text.trim()) {
|
||||
Ok(v @ Value::Object(_)) => v,
|
||||
_ => json!({"type": "text", "text": text.trim()}),
|
||||
};
|
||||
(to, body)
|
||||
}
|
||||
})();
|
||||
if response.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut notifications = host.subscribe();
|
||||
let mut notification_task = tokio::task::JoinSet::new();
|
||||
notification_task.spawn(async move {
|
||||
loop {
|
||||
match notifications.recv().await {
|
||||
Ok(value) => {
|
||||
let text = notification_text(&value);
|
||||
if let Some(printer) = &mut printer {
|
||||
let _ = printer.print(format!("{text}\n"));
|
||||
} else {
|
||||
println!("{text}");
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
|
||||
loop {
|
||||
let (send, line) = tokio::sync::oneshot::channel();
|
||||
request.send(send)?;
|
||||
let line =
|
||||
tokio::select! { result = line => result??, _ = interrupt.recv() => return Ok(()) };
|
||||
let Some(line) = line else {
|
||||
return Ok(());
|
||||
None => ("agent", json!({"type": "text", "text": line})),
|
||||
},
|
||||
};
|
||||
let text = tokio::select! {
|
||||
result = execute_line(&host, &line) => result.unwrap_or_else(|e| format!("error: {e}")),
|
||||
_ = interrupt.recv() => "stopped waiting; the call continues, see ps".into(),
|
||||
};
|
||||
if !text.is_empty() {
|
||||
println!("{text}");
|
||||
}
|
||||
if line.trim() == "quit" {
|
||||
return Ok(());
|
||||
}
|
||||
broker.send(ID, Some(to), None, None, body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
//! The native core methods and their JSON parameters.
|
||||
use crate::{Host, RpcResult, error};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
impl Host {
|
||||
/// Execute a core method without starting a guest process.
|
||||
pub(crate) async fn core(
|
||||
&self,
|
||||
caller: &str,
|
||||
id: Option<Value>,
|
||||
method: &str,
|
||||
p: Value,
|
||||
) -> RpcResult {
|
||||
match method {
|
||||
"watch" => self.watch(caller, id, required_str(&p, "path")?).await,
|
||||
"call" => {
|
||||
self.route(
|
||||
caller.into(),
|
||||
id,
|
||||
required_str(&p, "method")?.into(),
|
||||
p.get("params").cloned().unwrap_or(json!({})),
|
||||
p["detach"].as_bool().unwrap_or(false),
|
||||
)
|
||||
.await
|
||||
}
|
||||
"notify" => {
|
||||
self.notify(
|
||||
caller,
|
||||
required_str(&p, "name")?,
|
||||
p.get("params").cloned().unwrap_or(json!({})),
|
||||
);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"kill" => {
|
||||
let pid = required_str(&p, "pid")?;
|
||||
let ended = self
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get(pid)
|
||||
.map(|p| p.ended.subscribe());
|
||||
self.kill(pid);
|
||||
if let Some(mut ended) = ended {
|
||||
let _ = ended.wait_for(|ended| *ended).await;
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"stop" => {
|
||||
if let Some(mut n) = self.begin_stop(required_str(&p, "pid")?) {
|
||||
let _ = n.wait_for(|ended| *ended).await;
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"lock" => {
|
||||
self.lock(caller, required_str(&p, "path")?, p["wait"] != false)
|
||||
.await
|
||||
}
|
||||
"unlock" => {
|
||||
self.unlock(caller, required_str(&p, "path")?);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"log" => Ok(self.0.journal.query(p).await),
|
||||
"activate" | "revert" => self.activation(caller, method, p).await,
|
||||
"restart" | "exit" => self.request_restart(),
|
||||
_ => Err(error(-32601, "method not found")),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn required_str<'a>(params: &'a Value, name: &str) -> Result<&'a str, Value> {
|
||||
params[name]
|
||||
.as_str()
|
||||
.ok_or_else(|| error(-32602, &format!("{name} must be a string")))
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
//! Execution of native futures and WASI processes, including transport drainage.
|
||||
use crate::{
|
||||
Host, NativeContext, NativePlugin,
|
||||
process::ProcessInfo,
|
||||
runtime::{Instance, InstanceConfig},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
impl Host {
|
||||
/// Run one process and retain its cleanup guard until all transport reads finish.
|
||||
pub(crate) async fn run_process(
|
||||
&self,
|
||||
pid: String,
|
||||
info: ProcessInfo,
|
||||
component: Option<std::sync::Arc<crate::runtime::Prepared>>,
|
||||
native: Option<NativePlugin>,
|
||||
incoming: mpsc::UnboundedReceiver<Value>,
|
||||
has_id: bool,
|
||||
) -> String {
|
||||
if let Some(native) = native {
|
||||
let context = NativeContext {
|
||||
host: self.clone(),
|
||||
pid: pid.clone(),
|
||||
};
|
||||
let result = native(context, info.method, info.params).await;
|
||||
self.answer(&pid, result);
|
||||
return "exit 0".into();
|
||||
}
|
||||
let result = self
|
||||
.run_guest(&pid, &info, &component.unwrap(), incoming, has_id)
|
||||
.await;
|
||||
result.unwrap_or_else(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Drain stdout before deciding whether the guest returned an answer.
|
||||
async fn run_guest(
|
||||
&self,
|
||||
pid: &str,
|
||||
info: &ProcessInfo,
|
||||
component: &crate::runtime::Prepared,
|
||||
mut incoming: mpsc::UnboundedReceiver<Value>,
|
||||
has_id: bool,
|
||||
) -> anyhow::Result<String> {
|
||||
let (memory, tracker, store_ended) = {
|
||||
let state = self.0.state.lock().unwrap();
|
||||
let p = state.processes.get(pid).unwrap();
|
||||
(p.memory.clone(), p.files.clone(), p.store_ended.clone())
|
||||
};
|
||||
let config = InstanceConfig {
|
||||
filesystem: self.mounts(Some(pid.into()), memory, tracker).await?,
|
||||
store_ended,
|
||||
network: true,
|
||||
env: vec![
|
||||
("SAUSAGE_PID".into(), pid.into()),
|
||||
("SAUSAGE_PLUGIN".into(), info.plugin.clone()),
|
||||
(
|
||||
"SAUSAGE_PARENT".into(),
|
||||
info.parent.clone().unwrap_or_else(|| "core".into()),
|
||||
),
|
||||
],
|
||||
};
|
||||
let mut instance = self
|
||||
.0
|
||||
.runtime
|
||||
.start_logged(
|
||||
component,
|
||||
config,
|
||||
self.0.journal.clone(),
|
||||
pid.into(),
|
||||
info.plugin.clone(),
|
||||
)
|
||||
.await?;
|
||||
let mut initial = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": info.method,
|
||||
"params": info.params,
|
||||
});
|
||||
if has_id {
|
||||
initial["id"] = json!(pid);
|
||||
}
|
||||
let mut stdin_open = self.forward_input(pid, &mut instance, Some(initial)).await;
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = instance.stdout.recv() => {
|
||||
if !self.stdout_line(pid,line) { break; }
|
||||
}
|
||||
value = incoming.recv(), if stdin_open => {
|
||||
stdin_open = self.forward_input(pid,&mut instance,value).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(instance.wait().await.to_string())
|
||||
}
|
||||
|
||||
/// Close stdin only after the queued responses reach the guest.
|
||||
async fn forward_input(
|
||||
&self,
|
||||
pid: &str,
|
||||
instance: &mut Instance,
|
||||
value: Option<Value>,
|
||||
) -> bool {
|
||||
if let Some(value) = value
|
||||
&& self.write_input(pid, instance, value).await.is_ok()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
instance.stdin.take();
|
||||
false
|
||||
}
|
||||
async fn write_input(
|
||||
&self,
|
||||
pid: &str,
|
||||
instance: &mut Instance,
|
||||
value: Value,
|
||||
) -> std::io::Result<()> {
|
||||
instance
|
||||
.send_line(&value.to_string(), |line| self.output(pid, line))
|
||||
.await
|
||||
}
|
||||
fn stdout_line(&self, pid: &str, line: Option<String>) -> bool {
|
||||
if let Some(line) = line {
|
||||
self.output(pid, line);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
//! Per-process mount tables, reserved self files, and the live process namespace.
|
||||
use crate::Host;
|
||||
use sausage_vfs::{
|
||||
Entry, File, Generated, Layer, Memory, Mounts, Open, Process, Result, Stat, async_trait,
|
||||
};
|
||||
use std::{io, sync::Arc};
|
||||
|
||||
fn denied<T>() -> Result<T> {
|
||||
Err(io::ErrorKind::ReadOnlyFilesystem.into())
|
||||
}
|
||||
fn missing<T>() -> Result<T> {
|
||||
Err(io::ErrorKind::NotFound.into())
|
||||
}
|
||||
fn directory() -> Stat {
|
||||
Stat {
|
||||
directory: true,
|
||||
symlink: false,
|
||||
size: 0,
|
||||
accessed: None,
|
||||
modified: None,
|
||||
}
|
||||
}
|
||||
fn entry(name: String, directory: bool) -> Entry {
|
||||
Entry {
|
||||
name,
|
||||
directory,
|
||||
symlink: false,
|
||||
}
|
||||
}
|
||||
fn reserved(path: &str) -> bool {
|
||||
matches!(
|
||||
path.trim_start_matches('/').split('/').next(),
|
||||
Some("info.json" | "log" | "fs")
|
||||
)
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Create a console filesystem view without adding a process. Its `/self` is private and ephemeral.
|
||||
pub async fn filesystem(&self) -> anyhow::Result<Mounts> {
|
||||
self.mounts(None, Memory::default(), Process::default())
|
||||
.await
|
||||
}
|
||||
pub(crate) async fn mounts(
|
||||
&self,
|
||||
pid: Option<String>,
|
||||
memory: Memory,
|
||||
tracker: Process,
|
||||
) -> anyhow::Result<Mounts> {
|
||||
let root = self.0.world.root.clone();
|
||||
let operations = tracker.clone();
|
||||
let host = tracker
|
||||
.clone()
|
||||
.blocking(move || sausage_vfs::Host::new(root, tracker))
|
||||
.await?;
|
||||
let mut mounts = Mounts::default();
|
||||
mounts.mount(
|
||||
"/",
|
||||
self.0
|
||||
.observer
|
||||
.layer(Arc::new(host), "/", operations.clone()),
|
||||
false,
|
||||
);
|
||||
mounts.mount("/proc", Arc::new(Proc(self.clone())), true);
|
||||
mounts.mount(
|
||||
"/self",
|
||||
self.0.observer.layer(
|
||||
Arc::new(SelfFolder {
|
||||
host: self.clone(),
|
||||
pid: pid.clone(),
|
||||
memory,
|
||||
}),
|
||||
&format!("/proc/{}", pid.as_deref().unwrap_or("console")),
|
||||
operations.clone(),
|
||||
),
|
||||
false,
|
||||
);
|
||||
mounts.mount(
|
||||
"/run",
|
||||
self.0
|
||||
.observer
|
||||
.layer(Arc::new(self.0.run.clone()), "/run", operations),
|
||||
false,
|
||||
);
|
||||
Ok(mounts)
|
||||
}
|
||||
fn core_files(&self) -> Generated {
|
||||
let mut files = Generated::default();
|
||||
let protocol = include_bytes!("../../PROTOCOL.md");
|
||||
files.insert("/PROTOCOL.md", move || Ok(protocol.to_vec()));
|
||||
let graph = self.graph_json();
|
||||
files.insert("/graph.json", move || Ok(graph.clone()));
|
||||
let dot = self.graph_dot();
|
||||
files.insert("/graph.dot", move || Ok(dot.clone()));
|
||||
let locks = {
|
||||
let state = self.0.state.lock().unwrap();
|
||||
let locks: std::collections::BTreeMap<_, _> = state
|
||||
.locks
|
||||
.iter()
|
||||
.map(|(path, lock)| {
|
||||
let waiting: Vec<_> = lock.queue.iter().map(|(pid, _)| pid).collect();
|
||||
let info = serde_json::json!({
|
||||
"owner": lock.owner,
|
||||
"waiting": waiting,
|
||||
});
|
||||
(path.clone(), info)
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_vec(&locks).unwrap()
|
||||
};
|
||||
files.insert("/locks.json", move || Ok(locks.clone()));
|
||||
for name in self.discovered_plugins() {
|
||||
if let Some(doc) = self.discover_document(&name) {
|
||||
let bytes = serde_json::to_vec(&doc).unwrap();
|
||||
files.insert(&format!("/plugins/{name}/discover.json"), move || {
|
||||
Ok(bytes.clone())
|
||||
});
|
||||
files.insert(&format!("/plugins/{name}/client.rs"), move || {
|
||||
sausage_interface::client_rs(&name, &doc)
|
||||
.map(String::into_bytes)
|
||||
.map_err(io::Error::other)
|
||||
});
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
}
|
||||
struct Proc(Host);
|
||||
impl Proc {
|
||||
fn folder(&self, path: &str) -> Result<(SelfFolder, String)> {
|
||||
let (pid, rest) = path
|
||||
.trim_start_matches('/')
|
||||
.split_once('/')
|
||||
.unwrap_or((path.trim_start_matches('/'), ""));
|
||||
let memory = self
|
||||
.0
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get(pid)
|
||||
.map(|p| p.memory.clone())
|
||||
.ok_or(io::ErrorKind::NotFound)?;
|
||||
Ok((
|
||||
SelfFolder {
|
||||
host: self.0.clone(),
|
||||
pid: Some(pid.into()),
|
||||
memory,
|
||||
},
|
||||
format!("/{rest}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl Layer for Proc {
|
||||
async fn stat(&self, path: &str, follow: bool) -> Result<Stat> {
|
||||
let path = path.trim_end_matches('/');
|
||||
let path = if path.is_empty() { "/" } else { path };
|
||||
if matches!(path.trim_end_matches('/'), "" | "/core" | "/core/plugins") {
|
||||
return Ok(directory());
|
||||
}
|
||||
if let Some(path) = path.strip_prefix("/core/") {
|
||||
return self.0.core_files().stat(&format!("/{path}"), follow).await;
|
||||
}
|
||||
let (folder, path) = self.folder(path)?;
|
||||
folder.stat(&path, follow).await
|
||||
}
|
||||
async fn list(&self, path: &str) -> Result<Vec<Entry>> {
|
||||
let path = path.trim_end_matches('/');
|
||||
let path = if path.is_empty() { "/" } else { path };
|
||||
if path == "/" {
|
||||
let mut entries = vec![entry("core".into(), true)];
|
||||
entries.extend(self.0.processes().into_keys().map(|pid| entry(pid, true)));
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
return Ok(entries);
|
||||
}
|
||||
if path.trim_end_matches('/') == "/core/plugins" {
|
||||
return Ok(self
|
||||
.0
|
||||
.discovered_plugins()
|
||||
.into_iter()
|
||||
.filter(|name| self.0.discover_document(name).is_some())
|
||||
.map(|name| entry(name, true))
|
||||
.collect());
|
||||
}
|
||||
if path.trim_end_matches('/') == "/core" {
|
||||
let mut entries = self.0.core_files().list("/").await?;
|
||||
if !entries.iter().any(|e| e.name == "plugins") {
|
||||
entries.push(entry("plugins".into(), true));
|
||||
}
|
||||
return Ok(entries);
|
||||
}
|
||||
if let Some(path) = path.strip_prefix("/core/") {
|
||||
return self.0.core_files().list(&format!("/{path}")).await;
|
||||
}
|
||||
let (folder, path) = self.folder(path)?;
|
||||
folder.list(&path).await
|
||||
}
|
||||
async fn open(&self, path: &str, options: Open) -> Result<Arc<dyn File>> {
|
||||
let path = path.trim_end_matches('/');
|
||||
let path = if path.is_empty() { "/" } else { path };
|
||||
if options.write || options.create || options.truncate {
|
||||
return denied();
|
||||
}
|
||||
if matches!(path.trim_end_matches('/'), "" | "/core" | "/core/plugins") {
|
||||
return Memory::default().open("/", options).await;
|
||||
}
|
||||
if let Some(path) = path.strip_prefix("/core/") {
|
||||
return self.0.core_files().open(&format!("/{path}"), options).await;
|
||||
}
|
||||
let (folder, path) = self.folder(path)?;
|
||||
folder.open(&path, options).await
|
||||
}
|
||||
async fn mkdir(&self, _: &str) -> Result<()> {
|
||||
denied()
|
||||
}
|
||||
async fn remove_file(&self, _: &str) -> Result<()> {
|
||||
denied()
|
||||
}
|
||||
async fn remove_dir(&self, _: &str) -> Result<()> {
|
||||
denied()
|
||||
}
|
||||
async fn rename(&self, _: &str, _: &str) -> Result<()> {
|
||||
denied()
|
||||
}
|
||||
}
|
||||
struct SelfFolder {
|
||||
host: Host,
|
||||
pid: Option<String>,
|
||||
memory: Memory,
|
||||
}
|
||||
impl SelfFolder {
|
||||
async fn generated(&self, path: &str) -> Result<Generated> {
|
||||
let pid = self.pid.as_ref().ok_or(io::ErrorKind::NotFound)?;
|
||||
let bytes = match path {
|
||||
"/info.json" => {
|
||||
let info = self
|
||||
.host
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get(pid)
|
||||
.map(|p| p.info.clone())
|
||||
.ok_or(io::ErrorKind::NotFound)?;
|
||||
serde_json::to_vec(&info).unwrap()
|
||||
}
|
||||
"/log" => {
|
||||
let lines = self
|
||||
.host
|
||||
.0
|
||||
.journal
|
||||
.query(serde_json::json!({"pid":pid,"limit":i64::MAX}))
|
||||
.await;
|
||||
let mut bytes = Vec::new();
|
||||
for line in lines.as_array().unwrap() {
|
||||
serde_json::to_writer(&mut bytes, line).unwrap();
|
||||
bytes.push(b'\n');
|
||||
}
|
||||
bytes
|
||||
}
|
||||
_ => return missing(),
|
||||
};
|
||||
let mut files = Generated::default();
|
||||
files.insert(path, move || Ok(bytes.clone()));
|
||||
Ok(files)
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl Layer for SelfFolder {
|
||||
async fn stat(&self, path: &str, follow: bool) -> Result<Stat> {
|
||||
if reserved(path) {
|
||||
self.generated(path).await?.stat(path, follow).await
|
||||
} else {
|
||||
self.memory.stat(path, follow).await
|
||||
}
|
||||
}
|
||||
async fn list(&self, path: &str) -> Result<Vec<Entry>> {
|
||||
let mut entries = self.memory.list(path).await?;
|
||||
if path == "/" && self.pid.is_some() {
|
||||
entries.extend([entry("info.json".into(), false), entry("log".into(), false)]);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
async fn open(&self, path: &str, options: Open) -> Result<Arc<dyn File>> {
|
||||
if reserved(path) {
|
||||
if options.write || options.create || options.truncate {
|
||||
return denied();
|
||||
}
|
||||
self.generated(path).await?.open(path, options).await
|
||||
} else {
|
||||
self.memory.open(path, options).await
|
||||
}
|
||||
}
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
if reserved(path) {
|
||||
denied()
|
||||
} else {
|
||||
self.memory.mkdir(path).await
|
||||
}
|
||||
}
|
||||
async fn remove_file(&self, path: &str) -> Result<()> {
|
||||
if reserved(path) {
|
||||
denied()
|
||||
} else {
|
||||
self.memory.remove_file(path).await
|
||||
}
|
||||
}
|
||||
async fn remove_dir(&self, path: &str) -> Result<()> {
|
||||
if reserved(path) || path == "/" {
|
||||
denied()
|
||||
} else {
|
||||
self.memory.remove_dir(path).await
|
||||
}
|
||||
}
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
if reserved(from) || reserved(to) || from == "/" || to == "/" {
|
||||
denied()
|
||||
} else {
|
||||
self.memory.rename(from, to).await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
//! Cached discovery documents and import checks under PROTOCOL.md section 10.4.
|
||||
//! Refreshes use immutable copies. The proc layer only reads serialized cache entries.
|
||||
use crate::{Host, RpcResult};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Interface {
|
||||
pub(crate) hash: String,
|
||||
pub(crate) document: Value,
|
||||
pub(crate) error: Option<Value>,
|
||||
}
|
||||
pub(crate) struct Graph {
|
||||
pub(crate) plugins: BTreeMap<String, Interface>,
|
||||
json: Vec<u8>,
|
||||
dot: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Default for Graph {
|
||||
/// Exposes a valid empty graph before background discovery completes.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
plugins: BTreeMap::new(),
|
||||
json: b"{\"plugins\":{}}".to_vec(),
|
||||
dot: b"digraph plugins {}\n".to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hashes exactly the bytes copied for discovery or installation.
|
||||
pub(crate) fn hash(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
/// Reads and hashes one immutable snapshot without blocking an async worker.
|
||||
pub(crate) async fn read_hashed(path: PathBuf) -> std::io::Result<(Vec<u8>, String)> {
|
||||
tokio::task::spawn_blocking(move || -> std::io::Result<_> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let hash = hash(&bytes);
|
||||
Ok((bytes, hash))
|
||||
})
|
||||
.await
|
||||
.map_err(std::io::Error::other)?
|
||||
}
|
||||
|
||||
/// Lists broken imports, optionally restricted to changed consumers or providers.
|
||||
pub(crate) fn problems(
|
||||
plugins: &BTreeMap<String, Interface>,
|
||||
changed: Option<&[String]>,
|
||||
) -> Vec<Value> {
|
||||
let mut failures = Vec::new();
|
||||
for (consumer, interface) in plugins {
|
||||
if let Some(imports) = interface.document["x-sausage-imports"].as_object() {
|
||||
for (provider, expected) in imports {
|
||||
if changed
|
||||
.is_some_and(|names| !names.contains(consumer) && !names.contains(provider))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(actual) = plugins.get(provider) else {
|
||||
failures.push(json!({"consumer":consumer,"provider":provider,"status":"missing","message":"the provider does not exist"}));
|
||||
continue;
|
||||
};
|
||||
if let Some(error) = &actual.error {
|
||||
failures.push(json!({"consumer":consumer,"provider":provider,"status":"unavailable","message":"discovery failed","error":error}));
|
||||
continue;
|
||||
}
|
||||
for problem in sausage_interface::compat::check(expected, &actual.document) {
|
||||
failures.push(json!({"consumer":consumer,"provider":provider,"status":"broken","method":problem.method,"message":problem.message,"counterexample":problem.counterexample,"display":problem.to_string()}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// The cached JSON graph. This does not perform filesystem or RPC operations.
|
||||
pub(crate) fn graph_json(&self) -> Vec<u8> {
|
||||
self.0.graph.lock().unwrap().json.clone()
|
||||
}
|
||||
/// The cached DOT graph, with an edge for every import.
|
||||
pub(crate) fn graph_dot(&self) -> Vec<u8> {
|
||||
self.0.graph.lock().unwrap().dot.clone()
|
||||
}
|
||||
/// Plugin names currently present in the discovery cache.
|
||||
pub(crate) fn discovered_plugins(&self) -> Vec<String> {
|
||||
self.0
|
||||
.graph
|
||||
.lock()
|
||||
.unwrap()
|
||||
.plugins
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
/// The current document, if discovery succeeded.
|
||||
pub(crate) fn discover_document(&self, plugin: &str) -> Option<Value> {
|
||||
self.0
|
||||
.graph
|
||||
.lock()
|
||||
.unwrap()
|
||||
.plugins
|
||||
.get(plugin)
|
||||
.filter(|entry| entry.error.is_none())
|
||||
.map(|entry| entry.document.clone())
|
||||
}
|
||||
/// Discovers a private copy and caches successful documents by file hash.
|
||||
pub(crate) async fn discover_copy(
|
||||
&self,
|
||||
plugin: &str,
|
||||
path: &Path,
|
||||
hash: String,
|
||||
) -> Result<Interface, Value> {
|
||||
let request = json!(uuid::Uuid::now_v7().to_string());
|
||||
let discovery = self.route_from(
|
||||
"core".into(),
|
||||
Some(request.clone()),
|
||||
format!("{plugin}.rpc.discover"),
|
||||
json!({}),
|
||||
true,
|
||||
Some(path.to_path_buf()),
|
||||
);
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(10), discovery).await;
|
||||
let processes: Vec<_> = self
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.iter()
|
||||
.filter(|(_, process)| process.id.as_ref() == Some(&request))
|
||||
.map(|(pid, _)| pid.clone())
|
||||
.collect();
|
||||
for pid in processes {
|
||||
self.begin_stop(&pid);
|
||||
}
|
||||
let document = result
|
||||
.map_err(|_| crate::error(-32003, "plugin discovery timed out after 10 seconds"))??;
|
||||
if !document["methods"].is_array() {
|
||||
return Err(crate::error(
|
||||
-32003,
|
||||
"rpc.discover did not return an OpenRPC document",
|
||||
));
|
||||
}
|
||||
Ok(Interface {
|
||||
hash,
|
||||
document,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
/// Refreshes every installed plugin while the caller holds the activation mutex.
|
||||
pub(crate) async fn refresh_graph(&self) -> RpcResult {
|
||||
self.refresh_graph_except(&[]).await
|
||||
}
|
||||
pub(crate) async fn refresh_graph_except(&self, candidates: &[String]) -> RpcResult {
|
||||
let mut directory = tokio::fs::read_dir(self.0.world.root.join("plugins"))
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
let mut plugins = BTreeMap::new();
|
||||
while let Some(entry) = directory.next_entry().await.map_err(io_error)? {
|
||||
if !entry.file_type().await.map_err(io_error)?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let source = entry.path().join("plugin.wasm");
|
||||
let (bytes, hash) = match read_hashed(source).await {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => {
|
||||
plugins.insert(
|
||||
name,
|
||||
Interface {
|
||||
hash: String::new(),
|
||||
document: Value::Null,
|
||||
error: Some(io_error(error)),
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let previous = self.0.graph.lock().unwrap().plugins.get(&name).cloned();
|
||||
let interface = if candidates.contains(&name) {
|
||||
previous
|
||||
.filter(|entry| entry.hash == hash)
|
||||
.unwrap_or(Interface {
|
||||
hash,
|
||||
document: Value::Null,
|
||||
error: None,
|
||||
})
|
||||
} else if let Some(previous) =
|
||||
previous.filter(|entry| entry.hash == hash && entry.error.is_none())
|
||||
{
|
||||
previous
|
||||
} else {
|
||||
let copy = entry
|
||||
.path()
|
||||
.join(format!("discover-{}.wasm", uuid::Uuid::now_v7()));
|
||||
let result = match tokio::fs::write(©, &bytes).await {
|
||||
Ok(()) => self.discover_copy(&name, ©, hash.clone()).await,
|
||||
Err(error) => Err(io_error(error)),
|
||||
};
|
||||
let _ = tokio::fs::remove_file(©).await;
|
||||
match result {
|
||||
Ok(interface) => interface,
|
||||
Err(error) => Interface {
|
||||
hash,
|
||||
document: Value::Null,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
};
|
||||
plugins.insert(name, interface);
|
||||
}
|
||||
let admission = self.0.admission.write().await;
|
||||
let old_plugins = self.0.graph.lock().unwrap().plugins.clone();
|
||||
let mut upgrades = Vec::new();
|
||||
for (plugin, current) in &plugins {
|
||||
if let Some(old) = old_plugins.get(plugin).filter(|old| {
|
||||
!old.hash.is_empty() && old.hash != current.hash && current.error.is_none()
|
||||
}) {
|
||||
upgrades.push(self.snapshot_upgrade(plugin, old.hash.clone(), ¤t.hash));
|
||||
}
|
||||
}
|
||||
self.publish_graph(plugins);
|
||||
drop(admission);
|
||||
for upgrade in upgrades {
|
||||
self.begin_upgrade(upgrade);
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
/// Rebuilds serialized snapshots after a watcher refresh or an activation.
|
||||
pub(crate) fn publish_graph(&self, plugins: BTreeMap<String, Interface>) {
|
||||
let failures = problems(&plugins, None);
|
||||
for failure in &failures {
|
||||
self.0
|
||||
.journal
|
||||
.write("core", "core", "core", &format!("broken import {failure}"));
|
||||
}
|
||||
let mut entries = serde_json::Map::new();
|
||||
let mut dot = String::from("digraph plugins {\n");
|
||||
for (name, interface) in &plugins {
|
||||
dot.push_str(&format!(" {};\n", json!(name)));
|
||||
let mut imports = serde_json::Map::new();
|
||||
for provider in interface.document["x-sausage-imports"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flat_map(|imports| imports.keys())
|
||||
{
|
||||
let issues: Vec<_> = failures
|
||||
.iter()
|
||||
.filter(|problem| {
|
||||
problem["consumer"] == *name && problem["provider"] == *provider
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let status = issues
|
||||
.first()
|
||||
.and_then(|problem| problem["status"].as_str())
|
||||
.unwrap_or("compatible");
|
||||
imports.insert(provider.clone(), json!({"status":status,"problems":issues}));
|
||||
dot.push_str(&format!(
|
||||
" {} -> {} [label={}];\n",
|
||||
json!(name),
|
||||
json!(provider),
|
||||
json!(status)
|
||||
));
|
||||
}
|
||||
entries.insert(
|
||||
name.clone(),
|
||||
json!({"hash":interface.hash,"imports":imports,"error":interface.error}),
|
||||
);
|
||||
}
|
||||
dot.push_str("}\n");
|
||||
let mut graph = self.0.graph.lock().unwrap();
|
||||
graph.json = serde_json::to_vec(&json!({"plugins":entries})).unwrap();
|
||||
graph.dot = dot.into_bytes();
|
||||
graph.plugins = plugins;
|
||||
}
|
||||
}
|
||||
/// Converts a filesystem failure into a JSON-RPC internal error.
|
||||
pub(crate) fn io_error(error: std::io::Error) -> Value {
|
||||
crate::error(-32603, &error.to_string())
|
||||
}
|
||||
-170
@@ -1,170 +0,0 @@
|
||||
//! Host startup, boot calls, and the public library API.
|
||||
use crate::{RpcResult, components, journal, runtime::Runtime, world::World};
|
||||
use crate::{
|
||||
locks::Lock,
|
||||
native::NativePlugin,
|
||||
process::{Process, ProcessInfo},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use tokio::sync::watch;
|
||||
use uuid::Uuid;
|
||||
pub(crate) struct State {
|
||||
pub(crate) processes: HashMap<String, Process>,
|
||||
pub(crate) locks: HashMap<String, Lock>,
|
||||
pub(crate) native: HashMap<String, NativePlugin>,
|
||||
pub(crate) services: Vec<crate::services::Service>,
|
||||
}
|
||||
pub(crate) struct Inner {
|
||||
pub(crate) world: World,
|
||||
pub(crate) runtime: Arc<Runtime>,
|
||||
pub(crate) state: Mutex<State>,
|
||||
pub(crate) journal: Arc<journal::Journal>,
|
||||
pub(crate) notifications: tokio::sync::broadcast::Sender<Value>,
|
||||
pub(crate) exit: watch::Sender<bool>,
|
||||
pub(crate) loop_supervised: bool,
|
||||
pub(crate) restart_pending: std::sync::atomic::AtomicBool,
|
||||
pub(crate) components: components::Components,
|
||||
/// Calls hold it for reading from the choice of a version until their
|
||||
/// process is in the table. Activation holds it for writing while it
|
||||
/// installs a version, so that it sees every process of the old version.
|
||||
pub(crate) admission: tokio::sync::RwLock<()>,
|
||||
/// Set when the host starts to stop. After that, the core starts no new
|
||||
/// processes, and upgrade tasks do nothing.
|
||||
pub(crate) shutdown: std::sync::atomic::AtomicBool,
|
||||
pub(crate) run: sausage_vfs::Memory,
|
||||
pub(crate) watches: crate::watches::Watches,
|
||||
pub(crate) observer: sausage_vfs::observe::Observer,
|
||||
pub(crate) graph: Mutex<crate::graph::Graph>,
|
||||
pub(crate) activations: tokio::sync::Mutex<()>,
|
||||
pub(crate) watcher: Mutex<Option<notify::RecommendedWatcher>>,
|
||||
pub(crate) retirements: Mutex<std::collections::HashSet<(String, String)>>,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
/// A cloneable handle to a world and its running processes.
|
||||
pub struct Host(pub(crate) Arc<Inner>);
|
||||
|
||||
impl Host {
|
||||
/// Start a world with the default compile cache and its ordered boot calls.
|
||||
pub async fn start(path: &Path) -> anyhow::Result<Self> {
|
||||
let base = std::env::var_os("XDG_CACHE_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::PathBuf::from(std::env::var_os("HOME").unwrap_or_default())
|
||||
.join(".cache")
|
||||
});
|
||||
Self::start_with_cache(path, &base.join("sausage")).await
|
||||
}
|
||||
/// Start a world with an explicit compile cache folder.
|
||||
pub async fn start_with_cache(path: &Path, cache: &Path) -> anyhow::Result<Self> {
|
||||
let path = path.to_path_buf();
|
||||
let cargo_cache = cache.to_path_buf();
|
||||
let cache = cache.to_path_buf();
|
||||
let (world, runtime) = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
|
||||
Ok((World::open(&path)?, Runtime::new(&cache)?))
|
||||
})
|
||||
.await??;
|
||||
let journal = journal::Journal::open_with_legacy(
|
||||
world.root.join("logs/core/journal.sqlite"),
|
||||
Some(world.state_dir("core").join("journal.sqlite")),
|
||||
)
|
||||
.await?;
|
||||
let host = Self::with_parts(world, runtime, journal);
|
||||
let cargo_world = host.0.world.clone();
|
||||
let cargo = tokio::task::spawn_blocking(move || {
|
||||
crate::cargo::Cargo::new(cargo_world, &cargo_cache)
|
||||
})
|
||||
.await??;
|
||||
cargo.register(&host);
|
||||
host.boot().await?;
|
||||
Ok(host)
|
||||
}
|
||||
/// Creates an empty test world with a shared engine and an in-memory journal.
|
||||
pub async fn test_world(path: &Path, runtime: Arc<Runtime>) -> anyhow::Result<Self> {
|
||||
std::fs::create_dir_all(path)?;
|
||||
Ok(Self::with_parts(
|
||||
World::open(path)?,
|
||||
runtime,
|
||||
journal::Journal::memory().await?,
|
||||
))
|
||||
}
|
||||
fn with_parts(world: World, runtime: Arc<Runtime>, journal: journal::Journal) -> Self {
|
||||
let watches = crate::watches::Watches::default();
|
||||
let events = watches.clone();
|
||||
let observer =
|
||||
sausage_vfs::observe::Observer::new(move |path| crate::watches::changed(&events, path));
|
||||
Self(Arc::new(Inner {
|
||||
world,
|
||||
runtime,
|
||||
journal: Arc::new(journal),
|
||||
state: Mutex::new(State {
|
||||
processes: HashMap::new(),
|
||||
locks: HashMap::new(),
|
||||
native: HashMap::new(),
|
||||
services: Vec::new(),
|
||||
}),
|
||||
notifications: tokio::sync::broadcast::channel(1024).0,
|
||||
exit: watch::channel(false).0,
|
||||
loop_supervised: crate::restart::enabled(std::env::var_os("SAUSAGE_LOOP").as_deref()),
|
||||
restart_pending: std::sync::atomic::AtomicBool::new(false),
|
||||
components: components::Components::default(),
|
||||
admission: tokio::sync::RwLock::new(()),
|
||||
shutdown: std::sync::atomic::AtomicBool::new(false),
|
||||
run: sausage_vfs::Memory::default(),
|
||||
watches,
|
||||
observer,
|
||||
graph: Mutex::new(crate::graph::Graph::default()),
|
||||
activations: tokio::sync::Mutex::new(()),
|
||||
watcher: Mutex::new(None),
|
||||
retirements: Mutex::new(std::collections::HashSet::new()),
|
||||
}))
|
||||
}
|
||||
/// Register a native plugin for subsequent calls.
|
||||
pub fn register_native(&self, name: &str, plugin: NativePlugin) {
|
||||
self.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.native
|
||||
.insert(name.into(), plugin);
|
||||
}
|
||||
/// Subscribe to notifications addressed to the console.
|
||||
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<Value> {
|
||||
self.0.notifications.subscribe()
|
||||
}
|
||||
/// Snapshot the metadata of each process that has not ended.
|
||||
pub fn processes(&self) -> HashMap<String, ProcessInfo> {
|
||||
self.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.iter()
|
||||
.map(|(p, s)| (p.clone(), s.info.clone()))
|
||||
.collect()
|
||||
}
|
||||
/// Make a console-side call and wait for its JSON result.
|
||||
pub async fn call(&self, method: &str, params: Value) -> RpcResult {
|
||||
self.route(
|
||||
"console".into(),
|
||||
Some(json!(Uuid::now_v7().to_string())),
|
||||
method.into(),
|
||||
params,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
/// Wait until core.restart (or its alias core.exit) requests shutdown after its response grace.
|
||||
pub async fn exit_requested(&self) {
|
||||
let mut exit = self.0.exit.subscribe();
|
||||
let _ = exit.wait_for(|requested| *requested).await;
|
||||
}
|
||||
/// Return whether a restart has reached the normal shutdown path.
|
||||
pub fn restart_requested(&self) -> bool {
|
||||
*self.0.exit.borrow()
|
||||
}
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
//! A dedicated SQLite writer batches entries and serves ordered queries without propagating disk failures into the host.
|
||||
use rusqlite::{Connection, OpenFlags, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::Write;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::PathBuf,
|
||||
sync::{Mutex, mpsc},
|
||||
};
|
||||
use tokio::sync::oneshot;
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Entry {
|
||||
time: String,
|
||||
id: String,
|
||||
pid: String,
|
||||
plugin: String,
|
||||
stream: String,
|
||||
line: String,
|
||||
}
|
||||
enum Command {
|
||||
Write(Entry),
|
||||
Query(Value, oneshot::Sender<Value>),
|
||||
}
|
||||
pub(crate) struct Journal {
|
||||
sender: mpsc::Sender<Command>,
|
||||
warned: Mutex<HashSet<String>>,
|
||||
spool: PathBuf,
|
||||
}
|
||||
impl Journal {
|
||||
pub(crate) async fn memory() -> anyhow::Result<Self> {
|
||||
Self::open(PathBuf::from(":memory:")).await
|
||||
}
|
||||
pub(crate) async fn open(path: PathBuf) -> anyhow::Result<Self> {
|
||||
Self::open_with_legacy(path, None).await
|
||||
}
|
||||
pub(crate) async fn open_with_legacy(
|
||||
path: PathBuf,
|
||||
legacy: Option<PathBuf>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let spool = path
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."))
|
||||
.join("failed-batches");
|
||||
let worker_spool = spool.clone();
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let (ready, initialized) = oneshot::channel();
|
||||
std::thread::spawn(move || {
|
||||
match (|| -> anyhow::Result<_> {
|
||||
let db = open_database(path)?;
|
||||
// Never rename, checkpoint, or write the old SQLite/WAL files.
|
||||
let old = legacy
|
||||
.filter(|p| p.exists())
|
||||
.map(|p| Connection::open_with_flags(p, OpenFlags::SQLITE_OPEN_READ_ONLY))
|
||||
.transpose()?;
|
||||
Ok((db, old))
|
||||
})() {
|
||||
Ok((mut db, old)) => {
|
||||
let _ = ready.send(Ok(()));
|
||||
let mut pending = Vec::new();
|
||||
while let Ok(first) = receiver.recv() {
|
||||
let mut batch = vec![first];
|
||||
batch.extend(receiver.try_iter().take(255));
|
||||
pending = write_batch(&mut db, old.as_ref(), &worker_spool, pending, batch);
|
||||
}
|
||||
if !pending.is_empty() {
|
||||
// Both storage locations failed. Last resort: retain full text in host stderr.
|
||||
eprintln!(
|
||||
"journal shutdown with unpersisted entries: {}",
|
||||
serde_json::to_string(&pending).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("journal open failed: {error}");
|
||||
let _ = ready.send(Err(error));
|
||||
}
|
||||
}
|
||||
});
|
||||
initialized.await??;
|
||||
Ok(Self {
|
||||
sender,
|
||||
warned: Mutex::new(HashSet::new()),
|
||||
spool,
|
||||
})
|
||||
}
|
||||
pub(crate) fn write(&self, pid: &str, plugin: &str, stream: &str, line: &str) {
|
||||
let entry = Entry {
|
||||
time: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||
id: uuid::Uuid::now_v7().to_string(),
|
||||
pid: pid.into(),
|
||||
plugin: plugin.into(),
|
||||
stream: stream.into(),
|
||||
line: line.into(),
|
||||
};
|
||||
if let Err(mpsc::SendError(Command::Write(entry))) = self.sender.send(Command::Write(entry))
|
||||
{
|
||||
eprintln!("journal writer stopped; saving entry outside SQLite");
|
||||
if let Err(error) = spool_batch(&self.spool, std::slice::from_ref(&entry)) {
|
||||
eprintln!(
|
||||
"journal spool failed: {error}; unpersisted entry: {}",
|
||||
serde_json::to_string(&entry).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn warning(&self, pid: &str, plugin: &str) {
|
||||
if self.warned.lock().unwrap().insert(pid.into()) {
|
||||
self.write(pid, plugin, "core", "warning: discarded stdout");
|
||||
}
|
||||
}
|
||||
pub(crate) fn forget(&self, pid: &str) {
|
||||
self.warned.lock().unwrap().remove(pid);
|
||||
}
|
||||
pub(crate) fn query_later(&self, filters: Value) -> oneshot::Receiver<Value> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
if self.sender.send(Command::Query(filters, tx)).is_err() {
|
||||
eprintln!("journal writer stopped");
|
||||
}
|
||||
rx
|
||||
}
|
||||
pub(crate) async fn query(&self, filters: Value) -> Value {
|
||||
self.query_later(filters)
|
||||
.await
|
||||
.unwrap_or_else(|_| json!([]))
|
||||
}
|
||||
}
|
||||
fn open_database(path: PathBuf) -> anyhow::Result<Connection> {
|
||||
if path != std::path::Path::new(":memory:") {
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
}
|
||||
let db = Connection::open(path)?;
|
||||
db.execute_batch("PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
CREATE TABLE IF NOT EXISTS journal (id TEXT PRIMARY KEY, time TEXT, pid TEXT, plugin TEXT, stream TEXT, line TEXT);
|
||||
CREATE INDEX IF NOT EXISTS journal_pid ON journal(pid);
|
||||
CREATE INDEX IF NOT EXISTS journal_plugin ON journal(plugin);")?;
|
||||
Ok(db)
|
||||
}
|
||||
/// Exclusive, synced JSON files preserve failed transactions without a busy retry loop.
|
||||
/// Recovery is deliberate: files are never automatically removed or replayed.
|
||||
fn spool_batch(dir: &std::path::Path, entries: &[Entry]) -> anyhow::Result<PathBuf> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let path = dir.join(format!("{}.json", uuid::Uuid::now_v7()));
|
||||
// A failed/partial write must not look like a complete recovery batch.
|
||||
let mut file = tempfile::NamedTempFile::new_in(dir)?;
|
||||
serde_json::to_writer(&mut file, entries)?;
|
||||
file.write_all(b"\n")?;
|
||||
file.as_file().sync_all()?;
|
||||
file.persist_noclobber(&path)?;
|
||||
std::fs::File::open(dir)?.sync_all()?;
|
||||
eprintln!(
|
||||
"journal failed batch preserved: {} ({} entries)",
|
||||
path.display(),
|
||||
entries.len()
|
||||
);
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn write_batch(
|
||||
db: &mut Connection,
|
||||
old: Option<&Connection>,
|
||||
spool: &std::path::Path,
|
||||
mut pending: Vec<Entry>,
|
||||
batch: Vec<Command>,
|
||||
) -> Vec<Entry> {
|
||||
let mut queries = Vec::new();
|
||||
for command in batch {
|
||||
match command {
|
||||
Command::Write(entry) => pending.push(entry),
|
||||
Command::Query(filters, response) => queries.push((filters, response)),
|
||||
}
|
||||
}
|
||||
if !pending.is_empty() {
|
||||
let write = (|| -> rusqlite::Result<()> {
|
||||
let transaction = db.transaction()?;
|
||||
for e in &pending {
|
||||
transaction.execute("INSERT OR IGNORE INTO journal (id,time,pid,plugin,stream,line) VALUES (?1,?2,?3,?4,?5,?6)",params![e.id,e.time,e.pid,e.plugin,e.stream,e.line])?;
|
||||
}
|
||||
transaction.commit()
|
||||
})();
|
||||
match write {
|
||||
Ok(()) => pending.clear(),
|
||||
Err(error) => {
|
||||
eprintln!("journal write failed: {error}");
|
||||
match spool_batch(spool, &pending) {
|
||||
Ok(_) => pending.clear(),
|
||||
Err(error) => eprintln!(
|
||||
"journal spool failed: {error}; retaining {} entries in memory until next command",
|
||||
pending.len()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (filters, response) in queries {
|
||||
let value = query_all(db, old, &filters).unwrap_or_else(|error| {
|
||||
eprintln!("journal query failed: {error}");
|
||||
json!([])
|
||||
});
|
||||
let _ = response.send(value);
|
||||
}
|
||||
pending
|
||||
}
|
||||
|
||||
fn query_all(
|
||||
db: &Connection,
|
||||
old: Option<&Connection>,
|
||||
filters: &Value,
|
||||
) -> rusqlite::Result<Value> {
|
||||
let mut entries = query_database(db, filters)?.as_array().unwrap().clone();
|
||||
if let Some(old) = old {
|
||||
entries.extend(
|
||||
query_database(old, filters)?
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.cloned(),
|
||||
);
|
||||
entries.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
|
||||
entries.dedup_by(|a, b| a["id"] == b["id"]);
|
||||
let limit = filters["limit"].as_i64().unwrap_or(100);
|
||||
if limit >= 0 {
|
||||
entries.drain(..entries.len().saturating_sub(limit as usize));
|
||||
}
|
||||
}
|
||||
Ok(json!(entries))
|
||||
}
|
||||
fn query_database(db: &Connection, filters: &Value) -> rusqlite::Result<Value> {
|
||||
let mut statement = db.prepare(
|
||||
"SELECT id,time,pid,plugin,stream,line FROM journal
|
||||
WHERE (?1 IS NULL OR plugin=?1) AND (?2 IS NULL OR pid=?2)
|
||||
AND (?3 IS NULL OR id>=?3) AND (?4 IS NULL OR id<=?4)
|
||||
AND (?5 IS NULL OR instr(line,?5)>0) ORDER BY id DESC LIMIT ?6",
|
||||
)?;
|
||||
let rows=statement.query_map(params![filters["plugin"].as_str(),filters["pid"].as_str(),filters["since"].as_str(),filters["until"].as_str(),filters["grep"].as_str(),filters["limit"].as_i64().unwrap_or(100)],|row| Ok(json!({
|
||||
"id":row.get::<_,String>(0)?,"time":row.get::<_,String>(1)?,"pid":row.get::<_,String>(2)?,"plugin":row.get::<_,String>(3)?,"stream":row.get::<_,String>(4)?,"line":row.get::<_,String>(5)?,
|
||||
})))?;
|
||||
let mut entries: Vec<_> = rows.collect::<rusqlite::Result<_>>()?;
|
||||
entries.reverse();
|
||||
Ok(json!(entries))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn entry(line: &str) -> Entry {
|
||||
Entry {
|
||||
id: uuid::Uuid::now_v7().to_string(),
|
||||
time: "2026-10-26T12:00:00.000Z".into(),
|
||||
pid: "pid".into(),
|
||||
plugin: "test".into(),
|
||||
stream: "core".into(),
|
||||
line: line.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_transaction_is_spooled_in_full_and_queries_finish() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut db = open_database(PathBuf::from(":memory:")).unwrap();
|
||||
db.execute_batch("PRAGMA query_only=ON").unwrap();
|
||||
let original = "😀 full failure\n".repeat(6000);
|
||||
let (tx, mut rx) = oneshot::channel();
|
||||
let pending = write_batch(
|
||||
&mut db,
|
||||
None,
|
||||
dir.path(),
|
||||
vec![],
|
||||
vec![
|
||||
Command::Write(entry(&original)),
|
||||
Command::Query(json!({}), tx),
|
||||
],
|
||||
);
|
||||
assert!(pending.is_empty());
|
||||
assert_eq!(rx.try_recv().unwrap(), json!([]));
|
||||
let path = std::fs::read_dir(dir.path())
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.path();
|
||||
let saved: Vec<Entry> = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
assert_eq!(saved.len(), 1);
|
||||
assert_eq!(saved[0].line, original);
|
||||
assert_eq!(saved[0].time, "2026-10-26T12:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_spool_retries_on_next_command_without_duplicates() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let blocked = dir.path().join("blocked");
|
||||
std::fs::write(&blocked, "not a directory").unwrap();
|
||||
let mut db = open_database(PathBuf::from(":memory:")).unwrap();
|
||||
db.execute_batch("PRAGMA query_only=ON").unwrap();
|
||||
let pending = write_batch(
|
||||
&mut db,
|
||||
None,
|
||||
&blocked,
|
||||
vec![],
|
||||
vec![Command::Write(entry("retained"))],
|
||||
);
|
||||
assert_eq!(pending.len(), 1);
|
||||
db.execute_batch("PRAGMA query_only=OFF").unwrap();
|
||||
let (tx, mut rx) = oneshot::channel();
|
||||
let pending = write_batch(
|
||||
&mut db,
|
||||
None,
|
||||
&blocked,
|
||||
pending,
|
||||
vec![Command::Query(json!({}), tx)],
|
||||
);
|
||||
assert!(pending.is_empty());
|
||||
assert_eq!(rx.try_recv().unwrap()[0]["line"], "retained");
|
||||
assert_eq!(
|
||||
query_all(&db, None, &json!({}))
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_wal_and_new_journal_share_filters_order_and_limit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let legacy = dir.path().join("state/core/journal.sqlite");
|
||||
let mut old = open_database(legacy.clone()).unwrap();
|
||||
write_batch(
|
||||
&mut old,
|
||||
None,
|
||||
dir.path(),
|
||||
vec![],
|
||||
vec![Command::Write(entry("old match"))],
|
||||
);
|
||||
// Leave the WAL connection open: opening the new journal must not move its files.
|
||||
let wal = legacy.with_extension("sqlite-wal");
|
||||
let before = std::fs::read(&wal).unwrap();
|
||||
let new = dir.path().join("logs/core/journal.sqlite");
|
||||
let journal = Journal::open_with_legacy(new.clone(), Some(legacy.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
journal.write("pid", "test", "core", "new match");
|
||||
let entries = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
journal.query(json!({"pid":"pid","grep":"match","limit":-1})),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(entries.as_array().unwrap().len(), 2);
|
||||
assert_eq!(entries[0]["line"], "old match");
|
||||
assert_eq!(entries[1]["line"], "new match");
|
||||
assert_eq!(
|
||||
journal.query(json!({"limit":1})).await[0]["line"],
|
||||
"new match"
|
||||
);
|
||||
assert_eq!(
|
||||
journal.query(json!({"until":entries[0]["id"]})).await[0]["line"],
|
||||
"old match"
|
||||
);
|
||||
assert_eq!(
|
||||
journal
|
||||
.query(json!({"since":entries[1]["id"],"plugin":"test"}))
|
||||
.await[0]["line"],
|
||||
"new match"
|
||||
);
|
||||
assert_eq!(journal.query(json!({"grep":"absent"})).await, json!([]));
|
||||
assert_eq!(std::fs::read(wal).unwrap(), before);
|
||||
assert!(legacy.exists() && new.exists());
|
||||
assert_eq!(
|
||||
query_database(&old, &json!({}))
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// PROTOCOL 7: completed processes do not leave warning identities in memory.
|
||||
#[tokio::test]
|
||||
async fn completion_forgets_warning_identity() {
|
||||
let folder = std::env::temp_dir().join(format!("sausage-warning-{}", uuid::Uuid::now_v7()));
|
||||
crate::world::World::create(&folder).unwrap();
|
||||
let host = crate::Host::start_with_cache(&folder, &folder.join("cache"))
|
||||
.await
|
||||
.unwrap();
|
||||
host.register_native(
|
||||
"warn",
|
||||
std::sync::Arc::new(|ctx, _, _| {
|
||||
Box::pin(async move {
|
||||
ctx.host.output(&ctx.pid, "not JSON".into());
|
||||
Ok(json!(ctx.pid))
|
||||
})
|
||||
}),
|
||||
);
|
||||
let pid = host.call("warn.once", json!({})).await.unwrap();
|
||||
host.call("core.stop", json!({"pid":pid})).await.unwrap();
|
||||
let retained = host
|
||||
.0
|
||||
.journal
|
||||
.warned
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(pid.as_str().unwrap());
|
||||
host.stop().await;
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
assert!(!retained);
|
||||
}
|
||||
}
|
||||
+82
-27
@@ -1,31 +1,86 @@
|
||||
//! A host for worlds of JSON-RPC plugins.
|
||||
mod activation;
|
||||
pub mod cargo;
|
||||
mod components;
|
||||
//! Sausage: grow a system of WASI actors by talking to it.
|
||||
//!
|
||||
//! The host runs the broker, the native actors (`broker`, `cargo`, and the
|
||||
//! console), one supervisor per WASM actor, and the file watcher of the
|
||||
//! world folder (`watch.rs`). See `PROTOCOL.md`.
|
||||
|
||||
pub mod broker;
|
||||
pub mod candidate;
|
||||
pub mod cargo_actor;
|
||||
pub mod console;
|
||||
mod core;
|
||||
mod execution;
|
||||
mod filesystem;
|
||||
mod graph;
|
||||
mod host;
|
||||
mod journal;
|
||||
mod locks;
|
||||
mod native;
|
||||
mod process;
|
||||
mod restart;
|
||||
mod routing;
|
||||
pub mod runtime;
|
||||
mod services;
|
||||
mod upgrades;
|
||||
mod watcher;
|
||||
mod watches;
|
||||
pub mod supervisor;
|
||||
pub mod vfs;
|
||||
pub mod watch;
|
||||
pub mod world;
|
||||
pub use host::Host;
|
||||
pub use native::{NativeContext, NativeFuture, NativePlugin};
|
||||
pub use process::ProcessInfo;
|
||||
use serde_json::{Value, json};
|
||||
/// A JSON result or a JSON-RPC error object.
|
||||
pub type RpcResult = Result<Value, Value>;
|
||||
fn error(code: i64, text: &str) -> Value {
|
||||
json!({"code":code,"message":text})
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub use broker::{Broker, Envelope};
|
||||
pub use world::World;
|
||||
|
||||
/// The contract between the host and the actors. The host publishes it at
|
||||
/// `/actor/broker/public/PROTOCOL.md`, so that every actor can read it.
|
||||
const PROTOCOL: &str = include_str!("../../PROTOCOL.md");
|
||||
|
||||
/// A running world.
|
||||
pub struct Host {
|
||||
pub world: World,
|
||||
pub broker: Arc<Broker>,
|
||||
native: Vec<tokio::task::JoinHandle<()>>,
|
||||
watcher: Option<watch::Watcher>,
|
||||
_lock: std::fs::File,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Start the broker, `cargo`, the file watcher, and every registered
|
||||
/// actor of the world at `root`. Only one host can run a world at a time.
|
||||
pub async fn start(root: &Path) -> Result<Host> {
|
||||
let world = World::new(std::path::absolute(root)?);
|
||||
if !world.exists() {
|
||||
bail!("{} is not a Sausage world. Create one with: sausage init", root.display());
|
||||
}
|
||||
let lock = std::fs::File::create(world.root.join(".lock"))?;
|
||||
if lock.try_lock().is_err() {
|
||||
bail!("another sausage process runs {}", root.display());
|
||||
}
|
||||
let broker = Broker::new(world.clone(), runtime::Runtime::new()?)?;
|
||||
world::write_atomic(&world.public_dir("broker").join("PROTOCOL.md"), PROTOCOL.as_bytes())?;
|
||||
|
||||
let cargo = cargo_actor::Cargo::new(world.clone())?;
|
||||
let native = vec![
|
||||
tokio::spawn(broker.clone().run_actor(broker.open_mailbox("broker"))),
|
||||
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 {
|
||||
eprintln!("sausage: {change}");
|
||||
}
|
||||
Ok(Host { world, broker, native, watcher, _lock: lock })
|
||||
}
|
||||
|
||||
/// Register a native actor with the ID `id` and return its mailbox.
|
||||
pub fn mailbox(&self, id: &str) -> mpsc::Receiver<Envelope> {
|
||||
self.broker.open_mailbox(id)
|
||||
}
|
||||
|
||||
/// Stop the file watcher, the broker and `cargo` (with the requests they
|
||||
/// still run), then every actor, gracefully.
|
||||
pub async fn stop(mut self) {
|
||||
drop(self.watcher.take());
|
||||
for task in &self.native {
|
||||
task.abort();
|
||||
}
|
||||
for task in self.native {
|
||||
let _ = task.await;
|
||||
}
|
||||
self.broker.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Advisory FIFO locks and cleanup of ended processes.
|
||||
use crate::error;
|
||||
use crate::{Host, RpcResult, host::State};
|
||||
use serde_json::json;
|
||||
use std::collections::VecDeque;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
pub(crate) struct Lock {
|
||||
pub(crate) owner: String,
|
||||
pub(crate) queue: VecDeque<(String, oneshot::Sender<RpcResult>)>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Acquire a FIFO queue position, or report whether the lock is free.
|
||||
pub(crate) async fn lock(&self, caller: &str, path: &str, wait: bool) -> RpcResult {
|
||||
let rx = {
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
if caller != "console"
|
||||
&& caller != "core"
|
||||
&& !state
|
||||
.processes
|
||||
.get(caller)
|
||||
.is_some_and(|p| p.info.state == "running")
|
||||
{
|
||||
return Err(error(-32600, "process is stopping or ended"));
|
||||
}
|
||||
if let Some(lock) = state.locks.get_mut(path) {
|
||||
if !wait {
|
||||
return Ok(json!({"held":false}));
|
||||
}
|
||||
let (tx, rx) = oneshot::channel();
|
||||
lock.queue.push_back((caller.into(), tx));
|
||||
Some(rx)
|
||||
} else {
|
||||
state.locks.insert(
|
||||
path.into(),
|
||||
Lock {
|
||||
owner: caller.into(),
|
||||
queue: VecDeque::new(),
|
||||
},
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
match rx {
|
||||
Some(rx) => rx
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(error(-32603, "lock canceled"))),
|
||||
None => Ok(json!({"held":true})),
|
||||
}
|
||||
}
|
||||
/// Release a held lock only when this process owns it.
|
||||
pub(crate) fn unlock(&self, caller: &str, path: &str) {
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
if state
|
||||
.locks
|
||||
.get(path)
|
||||
.is_some_and(|lock| lock.owner == caller)
|
||||
{
|
||||
grant(&mut state, path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove queued acquisitions and grant the next owner of each held lock.
|
||||
pub(crate) fn release_locks(state: &mut State, pid: &str) {
|
||||
for lock in state.locks.values_mut() {
|
||||
lock.queue.retain(|(p, _)| p != pid);
|
||||
}
|
||||
let paths: Vec<_> = state
|
||||
.locks
|
||||
.iter()
|
||||
.filter(|(_, l)| l.owner == pid)
|
||||
.map(|(p, _)| p.clone())
|
||||
.collect();
|
||||
for path in paths {
|
||||
grant(state, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn grant(state: &mut State, path: &str) {
|
||||
if let Some(mut lock) = state.locks.remove(path) {
|
||||
while let Some((owner, tx)) = lock.queue.pop_front() {
|
||||
if tx.send(Ok(json!({"held":true}))).is_ok() {
|
||||
lock.owner = owner;
|
||||
state.locks.insert(path.into(), lock);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
-154
@@ -1,190 +1,164 @@
|
||||
//! Command parsing and the minimal line console.
|
||||
use anyhow::{Result, bail};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::{Parser, Subcommand};
|
||||
use include_dir::{Dir, include_dir};
|
||||
use sausage::{Host, world::World};
|
||||
use std::path::{Path, PathBuf};
|
||||
use serde_json::json;
|
||||
use tokio::signal::unix::Signal;
|
||||
|
||||
static AGENT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent/src");
|
||||
mod test_runner;
|
||||
use sausage::world::{ActorMeta, World};
|
||||
use sausage::{Host, cargo_actor, console};
|
||||
|
||||
/// The source of the agent actor. `init` builds it like any other actor.
|
||||
static AGENT_SOURCE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent");
|
||||
|
||||
// Without a command, `sausage` runs the world in `./world` and creates it
|
||||
// first (like `init`) if it does not exist yet.
|
||||
#[derive(Parser)]
|
||||
#[command(version)]
|
||||
#[command(version, about)]
|
||||
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,
|
||||
#[arg(long, global = true)]
|
||||
cache_dir: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
command: Option<Cmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Create a new world and build the agent actor.
|
||||
///
|
||||
/// Reads SAUSAGE_API, SAUSAGE_BASE_URL, SAUSAGE_MODEL and
|
||||
/// SAUSAGE_API_TOKEN, and asks for every value that is missing.
|
||||
Init {
|
||||
/// Folder of the new world.
|
||||
#[arg(default_value = "world")]
|
||||
dir: PathBuf,
|
||||
},
|
||||
/// Start the broker, all actors, and the console.
|
||||
Run {
|
||||
#[arg(default_value = "world")]
|
||||
dir: PathBuf,
|
||||
},
|
||||
/// Run a WASI libtest component in isolated instances.
|
||||
TestRunner {
|
||||
wasm: PathBuf,
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
args: Vec<String>,
|
||||
},
|
||||
}
|
||||
fn headless(value: &str) -> std::result::Result<bool, String> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"1" | "true" => Ok(true),
|
||||
"" | "0" | "false" => Ok(false),
|
||||
_ => Err("expected 1, true, 0, false, or an empty value".into()),
|
||||
|
||||
/// The value of `var`, or the answer to `question`.
|
||||
fn setting(var: &str, question: &str, default: Option<&str>) -> Result<String> {
|
||||
if let Ok(v) = std::env::var(var)
|
||||
&& !v.is_empty()
|
||||
{
|
||||
return Ok(v);
|
||||
}
|
||||
match default {
|
||||
Some(d) => print!("{question} [{d}]: "),
|
||||
None => print!("{question}: "),
|
||||
}
|
||||
std::io::stdout().flush()?;
|
||||
let mut line = String::new();
|
||||
std::io::stdin().read_line(&mut line)?;
|
||||
match (line.trim(), default) {
|
||||
("", Some(d)) => Ok(d.to_string()),
|
||||
("", None) => bail!("{question} is required (or set {var})"),
|
||||
(v, _) => Ok(v.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// Readline can still wait on its detached thread when the host exits.
|
||||
struct TerminalMode(Option<nix::sys::termios::Termios>);
|
||||
impl Drop for TerminalMode {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mode) = &self.0 {
|
||||
let _ = nix::sys::termios::tcsetattr(
|
||||
std::io::stdin(),
|
||||
nix::sys::termios::SetArg::TCSANOW,
|
||||
mode,
|
||||
);
|
||||
}
|
||||
async fn init(dir: PathBuf) -> Result<()> {
|
||||
let api = setting("SAUSAGE_API", "API shape (openai-responses or anthropic-messages)", Some("openai-responses"))?;
|
||||
if api != "openai-responses" && api != "anthropic-messages" {
|
||||
bail!("unknown API shape {api:?}");
|
||||
}
|
||||
}
|
||||
let base_url = setting("SAUSAGE_BASE_URL", "API base URL", None)?;
|
||||
let model = setting("SAUSAGE_MODEL", "Model", None)?;
|
||||
let token = setting("SAUSAGE_API_TOKEN", "API token", None)?;
|
||||
|
||||
/// Creates a world with editable configuration and builds its embedded agent.
|
||||
async fn init(dir: &Path, cache: Option<&Path>) -> Result<()> {
|
||||
if dir.exists() {
|
||||
bail!("world already exists: {}", dir.display());
|
||||
}
|
||||
let parent = dir
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."));
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let staging = tempfile::Builder::new()
|
||||
.prefix(".sausage-init-")
|
||||
.tempdir_in(parent)?;
|
||||
let world = World::create(&staging.path().join("world"))?;
|
||||
std::fs::write(
|
||||
world.root.join("config/agent.yaml"),
|
||||
include_str!("agent.yaml"),
|
||||
)?;
|
||||
std::fs::write(
|
||||
world.root.join("config/boot.yaml"),
|
||||
include_str!("boot.yaml"),
|
||||
)?;
|
||||
let cache = cache.map(Path::to_path_buf).unwrap_or_else(|| {
|
||||
let base = std::env::var_os("XDG_CACHE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".cache")
|
||||
});
|
||||
base.join("sausage")
|
||||
});
|
||||
let builder = sausage::cargo::Cargo::new(world.clone(), &cache)?;
|
||||
let source = world.plugin_dir("agent").join("source");
|
||||
std::fs::create_dir_all(source.join("src"))?;
|
||||
AGENT.extract(source.join("src"))?;
|
||||
std::fs::write(
|
||||
source.join("Cargo.toml"),
|
||||
include_str!("../../agent/Cargo.toml"),
|
||||
)?;
|
||||
std::fs::write(
|
||||
source.join("Cargo.lock"),
|
||||
include_bytes!("../../Cargo.lock"),
|
||||
)?;
|
||||
println!("The first agent build takes a few minutes.");
|
||||
let output = builder.build(&source).await?;
|
||||
std::fs::copy(output.wasm, world.plugin_dir("agent").join("plugin.wasm"))?;
|
||||
std::fs::rename(&world.root, dir)?;
|
||||
println!(
|
||||
"Edit {} before chatting.",
|
||||
dir.join("config/agent.yaml").display()
|
||||
);
|
||||
println!("Start the world with: sausage run {}", dir.display());
|
||||
let world = World::create(&dir)?;
|
||||
let console = ActorMeta { subscriptions: ["agent".to_string()].into(), ..world.read_meta(console::ID) };
|
||||
world.write_meta(console::ID, &console)?;
|
||||
|
||||
// The agent's configuration, in its /data.
|
||||
world.ensure_actor_dirs("agent")?;
|
||||
let config = json!({"api": api, "base_url": base_url.trim_end_matches('/'), "model": model, "token": token});
|
||||
let config_path = world.data_dir("agent").join("config.json");
|
||||
std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
|
||||
|
||||
// The agent is an ordinary actor folder: `run` finds its `actor.wasm`,
|
||||
// tests it, and installs it as version 1.
|
||||
let source = world.source_dir("agent");
|
||||
std::fs::create_dir_all(&source)?;
|
||||
AGENT_SOURCE.extract(&source).context("extract agent source")?;
|
||||
println!("Building the agent actor (the first build takes a few minutes) …");
|
||||
let out = cargo_actor::Cargo::new(world.clone())?.build(&source, false).await.context("build 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());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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?;
|
||||
tokio::spawn(console::run_mailbox(host.mailbox(console::ID)));
|
||||
let broker = host.broker.clone();
|
||||
let exit = match signals {
|
||||
Some(signals) => {
|
||||
println!("Sausage headless: running until SIGTERM or an exit request.");
|
||||
tokio::select! {
|
||||
_ = termination(signals) => false,
|
||||
_ = broker.exit_requested() => true,
|
||||
}
|
||||
}
|
||||
None => tokio::select! {
|
||||
_ = console::run_input(host.broker.clone()) => false,
|
||||
_ = broker.exit_requested() => true,
|
||||
},
|
||||
};
|
||||
println!("Stopping actors …");
|
||||
host.stop().await;
|
||||
if exit {
|
||||
// The console still blocks on a stdin read that cannot be cancelled,
|
||||
// so the runtime would not shut down on its own. Exit code 75 tells
|
||||
// `nix run .#loop` to start the host again (0 = /quit = stop the loop).
|
||||
println!("Exiting, as an actor asked the broker to.");
|
||||
std::process::exit(75);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<std::process::ExitCode> {
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let _terminal = TerminalMode(nix::sys::termios::tcgetattr(std::io::stdin()).ok());
|
||||
let command = match cli.command {
|
||||
Some(command) => command,
|
||||
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::Run { dir }) => run(dir, headless).await,
|
||||
None => {
|
||||
let dir = PathBuf::from("world");
|
||||
if !dir.join("config/boot.yaml").is_file() {
|
||||
init(&dir, cli.cache_dir.as_deref()).await?;
|
||||
if !dir.join("actor").is_dir() {
|
||||
println!("No world in ./world yet, creating it (same as `sausage init`).");
|
||||
init(dir.clone()).await?;
|
||||
}
|
||||
Cmd::Run { dir }
|
||||
}
|
||||
};
|
||||
match command {
|
||||
Cmd::TestRunner { wasm, args } => {
|
||||
if !test_runner::run(&wasm, args).await? {
|
||||
return Ok(std::process::ExitCode::FAILURE);
|
||||
}
|
||||
}
|
||||
Cmd::Init { dir } => init(&dir, cli.cache_dir.as_deref()).await?,
|
||||
Cmd::Run { dir } => {
|
||||
let headless = cli.headless
|
||||
|| std::env::var("SAUSAGE_HEADLESS")
|
||||
.ok()
|
||||
.map(|value| headless(&value).map_err(anyhow::Error::msg))
|
||||
.transpose()?
|
||||
.unwrap_or(false);
|
||||
let mut term =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||
let mut interrupt =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
|
||||
let start = async {
|
||||
match cli.cache_dir {
|
||||
Some(cache) => Host::start_with_cache(&dir, &cache).await,
|
||||
None => Host::start(&dir).await,
|
||||
}
|
||||
};
|
||||
let host = tokio::select! {
|
||||
result=start=>result?,
|
||||
_=term.recv()=>return Ok(std::process::ExitCode::SUCCESS),
|
||||
_=interrupt.recv()=>return Ok(std::process::ExitCode::SUCCESS),
|
||||
};
|
||||
let mut notifications = host.subscribe();
|
||||
let notification_task = headless.then(|| {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match notifications.recv().await {
|
||||
Ok(notification) => println!("{notification}"),
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
let console = sausage::console::run_input(host.clone());
|
||||
let mut console_result = Ok(());
|
||||
let exit = tokio::select! {
|
||||
_=host.exit_requested()=>true,
|
||||
_=term.recv()=>false,
|
||||
_=interrupt.recv(), if headless=>false,
|
||||
result = console, if !headless => {
|
||||
console_result=result;
|
||||
false
|
||||
},
|
||||
};
|
||||
let exit = exit || host.restart_requested();
|
||||
host.stop().await;
|
||||
if let Some(task) = notification_task {
|
||||
task.abort();
|
||||
}
|
||||
if exit {
|
||||
return Ok(std::process::ExitCode::from(75));
|
||||
}
|
||||
console_result?;
|
||||
run(dir, headless).await
|
||||
}
|
||||
}
|
||||
Ok(std::process::ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
//! Native plugin futures and their process context.
|
||||
use crate::{Host, RpcResult};
|
||||
use serde_json::{Value, json};
|
||||
use std::{future::Future, pin::Pin, sync::Arc};
|
||||
use uuid::Uuid;
|
||||
/// The cancellable future of a native process.
|
||||
pub type NativeFuture = Pin<Box<dyn Future<Output = RpcResult> + Send>>;
|
||||
/// A handler for the methods of one native plugin.
|
||||
pub type NativePlugin = Arc<dyn Fn(NativeContext, String, Value) -> NativeFuture + Send + Sync>;
|
||||
#[derive(Clone)]
|
||||
/// The process identity and host access supplied to a native handler.
|
||||
pub struct NativeContext {
|
||||
/// The host that owns this native process.
|
||||
pub host: Host,
|
||||
/// The ID assigned to this native process.
|
||||
pub pid: String,
|
||||
}
|
||||
impl NativeContext {
|
||||
/// Make an outgoing call with this native process as its caller.
|
||||
pub async fn call(&self, method: &str, params: Value) -> RpcResult {
|
||||
self.host
|
||||
.route(
|
||||
self.pid.clone(),
|
||||
Some(json!(Uuid::now_v7().to_string())),
|
||||
method.into(),
|
||||
params,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
/// Send progress to this process's caller while its call remains unanswered.
|
||||
pub fn notify(&self, name: &str, params: Value) {
|
||||
self.host.notify(&self.pid, name, params);
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
//! Process metadata, process lifetime, and attached children.
|
||||
use crate::{Host, RpcResult};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
/// A snapshot of process metadata for inspection and the VFS.
|
||||
pub struct ProcessInfo {
|
||||
/// The plugin that supplied the process code.
|
||||
pub plugin: String,
|
||||
/// The SHA-256 of its component, or native for a native plugin.
|
||||
pub version: String,
|
||||
/// The method without its plugin prefix.
|
||||
pub method: String,
|
||||
/// The object supplied by the caller.
|
||||
pub params: Value,
|
||||
/// The attached parent, or no parent for a detached process.
|
||||
pub parent: Option<String>,
|
||||
/// Whether this process survives the end of its caller.
|
||||
pub detached: bool,
|
||||
/// The start time in milliseconds since the Unix epoch.
|
||||
pub start_time: u128,
|
||||
/// The current lifecycle state.
|
||||
pub state: String,
|
||||
}
|
||||
pub(crate) struct Process {
|
||||
pub(crate) info: ProcessInfo,
|
||||
pub(crate) memory: sausage_vfs::Memory,
|
||||
pub(crate) files: sausage_vfs::Process,
|
||||
pub(crate) store_ended: watch::Sender<bool>,
|
||||
pub(crate) input: Option<mpsc::UnboundedSender<Value>>,
|
||||
pub(crate) abort: Option<tokio::task::AbortHandle>,
|
||||
pub(crate) caller: String,
|
||||
pub(crate) id: Option<Value>,
|
||||
pub(crate) answer: Option<oneshot::Sender<RpcResult>>,
|
||||
pub(crate) ended: watch::Sender<bool>,
|
||||
pub(crate) stopped: bool,
|
||||
pub(crate) superseded: bool,
|
||||
}
|
||||
impl Host {
|
||||
/// Called only after the execution task ends. Wait for the store and file operations before releasing locks.
|
||||
pub(crate) async fn finish(&self, pid: &str, status: &str) {
|
||||
let (files, mut store_ended) = {
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
let Some(p) = state.processes.get_mut(pid) else {
|
||||
return;
|
||||
};
|
||||
p.info.state = "stopping".into();
|
||||
(p.files.clone(), p.store_ended.subscribe())
|
||||
};
|
||||
let _ = store_ended.wait_for(|ended| *ended).await;
|
||||
files.idle().await;
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
let process = state.processes.remove(pid);
|
||||
if let Some(p) = process {
|
||||
self.0.journal.write(pid, &p.info.plugin, "core", status);
|
||||
let logs = p
|
||||
.answer
|
||||
.as_ref()
|
||||
.map(|_| self.0.journal.query_later(json!({"pid":pid,"limit":20})));
|
||||
self.0.journal.forget(pid);
|
||||
Self::release_locks(&mut state, pid);
|
||||
self.service_ended(&mut state, pid, status, p.stopped, p.superseded);
|
||||
let status = status.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Some(answer) = p.answer {
|
||||
let lines = logs.unwrap().await.unwrap_or_else(|_| json!([]));
|
||||
let failure = json!({
|
||||
"code": -32002,
|
||||
"message": "process ended without an answer",
|
||||
"data": { "status": status, "lines": lines }
|
||||
});
|
||||
let _ = answer.send(Err(failure));
|
||||
}
|
||||
p.ended.send_replace(true);
|
||||
});
|
||||
let children: Vec<_> = state
|
||||
.processes
|
||||
.iter()
|
||||
.filter(|(_, p)| p.info.parent.as_deref() == Some(pid))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
drop(state);
|
||||
for child in children {
|
||||
self.begin_stop(&child);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Close stdin and start the same five-second deadline for each attached child.
|
||||
pub(crate) fn begin_stop(&self, pid: &str) -> Option<watch::Receiver<bool>> {
|
||||
self.stop_process(pid, false)
|
||||
}
|
||||
/// Grace expiry is retirement, not an operator stopping the service.
|
||||
pub(crate) fn begin_retire(&self, pid: &str) -> Option<watch::Receiver<bool>> {
|
||||
self.stop_process(pid, true)
|
||||
}
|
||||
fn stop_process(&self, pid: &str, retirement: bool) -> Option<watch::Receiver<bool>> {
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
let p = state.processes.get_mut(pid)?;
|
||||
if !retirement {
|
||||
p.superseded = false;
|
||||
}
|
||||
let ended = p.ended.subscribe();
|
||||
if p.info.state == "stopping" {
|
||||
return Some(ended);
|
||||
}
|
||||
p.info.state = "stopping".into();
|
||||
p.stopped = true;
|
||||
p.input.take();
|
||||
self.0.journal.write(pid, &p.info.plugin, "core", "stop");
|
||||
for lock in state.locks.values_mut() {
|
||||
lock.queue.retain(|(queued, _)| queued != pid);
|
||||
}
|
||||
let children: Vec<_> = state
|
||||
.processes
|
||||
.iter()
|
||||
.filter(|(_, p)| p.info.parent.as_deref() == Some(pid))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
drop(state);
|
||||
for child in children {
|
||||
self.begin_stop(&child);
|
||||
}
|
||||
let h = self.clone();
|
||||
let pid = pid.to_string();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
h.kill_process(&pid, retirement);
|
||||
});
|
||||
Some(ended)
|
||||
}
|
||||
/// Abort a process task and perform its ended-state cleanup.
|
||||
pub(crate) fn kill(&self, pid: &str) {
|
||||
self.kill_process(pid, false);
|
||||
}
|
||||
fn kill_process(&self, pid: &str, retirement: bool) {
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
for lock in state.locks.values_mut() {
|
||||
lock.queue.retain(|(queued, _)| queued != pid);
|
||||
}
|
||||
if let Some(p) = state.processes.get_mut(pid) {
|
||||
if !retirement {
|
||||
p.superseded = false;
|
||||
}
|
||||
p.stopped = true;
|
||||
p.info.state = "stopping".into();
|
||||
p.input.take();
|
||||
self.0.journal.write(pid, &p.info.plugin, "core", "kill");
|
||||
if let Some(a) = &p.abort {
|
||||
a.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Stop every process in parallel and wait for journal writes to finish.
|
||||
pub async fn stop(&self) {
|
||||
{
|
||||
let _state = self.0.state.lock().unwrap();
|
||||
self.0
|
||||
.shutdown
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
loop {
|
||||
let ids: Vec<_> = self.processes().into_keys().collect();
|
||||
if ids.is_empty() {
|
||||
break;
|
||||
}
|
||||
let mut waits = Vec::new();
|
||||
for pid in ids {
|
||||
if let Some(n) = self.begin_stop(&pid) {
|
||||
waits.push(n);
|
||||
}
|
||||
}
|
||||
for mut n in waits {
|
||||
let _ = n.wait_for(|ended| *ended).await;
|
||||
}
|
||||
}
|
||||
self.0.journal.query(json!({"limit":0})).await;
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
//! Supervisor-gated restart requests; the executable still owns normal shutdown.
|
||||
use crate::{Host, RpcResult, error};
|
||||
use serde_json::json;
|
||||
use std::{ffi::OsStr, sync::atomic::Ordering, time::Duration};
|
||||
|
||||
pub(crate) fn enabled(value: Option<&OsStr>) -> bool {
|
||||
value == Some(OsStr::new("1"))
|
||||
}
|
||||
|
||||
impl Host {
|
||||
pub(crate) fn request_restart(&self) -> RpcResult {
|
||||
if !self.0.loop_supervised {
|
||||
return Err(error(
|
||||
-32600,
|
||||
"restart needs the loop supervisor (SAUSAGE_LOOP not set); start with SAUSAGE_LOOP=1",
|
||||
));
|
||||
}
|
||||
if !self.0.restart_pending.swap(true, Ordering::SeqCst) {
|
||||
let host = self.clone();
|
||||
tokio::spawn(async move {
|
||||
// Let the RPC response reach the caller before closing any stdin,
|
||||
// including that of a detached gateway carrying this response.
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
host.0.exit.send_replace(true);
|
||||
});
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{runtime::Runtime, world::World};
|
||||
use std::sync::Arc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[test]
|
||||
fn only_explicit_loop_marker_enables_restart() {
|
||||
assert!(!enabled(None));
|
||||
for value in ["", "0", "true", "yes", "2"] {
|
||||
assert!(!enabled(Some(OsStr::new(value))));
|
||||
}
|
||||
assert!(enabled(Some(OsStr::new("1"))));
|
||||
}
|
||||
|
||||
async fn host(supervised: bool) -> (tempfile::TempDir, Host) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
World::create(&dir.path().join("world")).unwrap();
|
||||
let runtime = Runtime::new(&dir.path().join("cache")).unwrap();
|
||||
let mut host = Host::test_world(&dir.path().join("world"), runtime)
|
||||
.await
|
||||
.unwrap();
|
||||
// No global environment mutation: parallel tests have independent gates.
|
||||
Arc::get_mut(&mut host.0).unwrap().loop_supervised = supervised;
|
||||
(dir, host)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_without_supervisor_never_triggers_exit() {
|
||||
timeout(Duration::from_secs(10), async {
|
||||
let (_dir, host) = host(false).await;
|
||||
for method in ["core.restart", "core.exit"] {
|
||||
let error = host.call(method, json!({})).await.unwrap_err();
|
||||
assert_eq!(error["code"], -32600);
|
||||
assert!(
|
||||
error["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("SAUSAGE_LOOP not set")
|
||||
);
|
||||
let indirect = host
|
||||
.call("core.call", json!({"method":method}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(indirect, error);
|
||||
}
|
||||
assert!(!host.0.restart_pending.load(Ordering::SeqCst));
|
||||
assert!(
|
||||
timeout(Duration::from_millis(300), host.exit_requested())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
host.stop().await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_and_exit_share_response_grace_and_coalescing() {
|
||||
timeout(Duration::from_secs(10), async {
|
||||
for first in ["core.restart", "core.exit"] {
|
||||
let (_dir, host) = host(true).await;
|
||||
// Same dispatch as a plugin's core.call, without killing this runner.
|
||||
assert_eq!(
|
||||
host.call("core.call", json!({"method":first}))
|
||||
.await
|
||||
.unwrap(),
|
||||
json!({})
|
||||
);
|
||||
assert!(!host.restart_requested());
|
||||
assert!(host.0.restart_pending.load(Ordering::SeqCst));
|
||||
assert_eq!(
|
||||
host.call("core.restart", json!({})).await.unwrap(),
|
||||
json!({})
|
||||
);
|
||||
timeout(Duration::from_secs(1), host.exit_requested())
|
||||
.await
|
||||
.unwrap();
|
||||
// A duplicate after the trigger must not schedule a second task.
|
||||
host.0.exit.send_replace(false);
|
||||
assert_eq!(
|
||||
host.call("core.restart", json!({})).await.unwrap(),
|
||||
json!({})
|
||||
);
|
||||
assert_eq!(host.call("core.exit", json!({})).await.unwrap(), json!({}));
|
||||
assert!(
|
||||
timeout(Duration::from_millis(300), host.exit_requested())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
//! JSON-RPC routing and responses between callers and processes.
|
||||
use crate::{
|
||||
Host, NativeFuture, RpcResult, error,
|
||||
process::{Process, ProcessInfo},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn params_not_object(params: &Value) -> Value {
|
||||
let kind = match params {
|
||||
Value::String(_) => "a string",
|
||||
Value::Array(_) => "an array",
|
||||
Value::Number(_) => "a number",
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "a boolean",
|
||||
Value::Object(_) => "an object",
|
||||
};
|
||||
error(
|
||||
-32602,
|
||||
&format!(
|
||||
"params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string."
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Queue an incoming response or notification for its destination.
|
||||
pub(crate) fn send(&self, pid: &str, value: Value) {
|
||||
if pid == "console" {
|
||||
let _ = self.0.notifications.send(value);
|
||||
} else if let Some(tx) = self
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get(pid)
|
||||
.and_then(|p| p.input.as_ref())
|
||||
{
|
||||
let _ = tx.send(value);
|
||||
}
|
||||
}
|
||||
/// Remap progress to the caller's original request ID.
|
||||
pub(crate) fn notify(&self, pid: &str, name: &str, params: Value) {
|
||||
let target = self
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get(pid)
|
||||
.and_then(|p| p.answer.as_ref().map(|_| (p.caller.clone(), p.id.clone())));
|
||||
if let Some((caller, Some(id))) = target {
|
||||
self.call_notify(&caller, &id, name, params);
|
||||
}
|
||||
}
|
||||
/// Deliver a notification on an open core call using the same envelope as plugin progress.
|
||||
pub(crate) fn call_notify(&self, caller: &str, id: &Value, name: &str, params: Value) {
|
||||
self.send(
|
||||
caller,
|
||||
json!({
|
||||
"jsonrpc":"2.0", "method":"core.notify",
|
||||
"params": {"call":id, "name":name, "params":params}
|
||||
}),
|
||||
);
|
||||
}
|
||||
/// Validate and route a request, then start its native or WASI process.
|
||||
pub(crate) fn route(
|
||||
&self,
|
||||
caller: String,
|
||||
id: Option<Value>,
|
||||
method: String,
|
||||
params: Value,
|
||||
detached: bool,
|
||||
) -> NativeFuture {
|
||||
self.route_from(caller, id, method, params, detached, None)
|
||||
}
|
||||
/// Like `route`, but a WASI process runs the component at `wasm` instead
|
||||
/// of the current `plugin.wasm` of its plugin. Activation uses this to
|
||||
/// call `rpc.discover` on a candidate file.
|
||||
pub(crate) fn route_from(
|
||||
&self,
|
||||
caller: String,
|
||||
id: Option<Value>,
|
||||
method: String,
|
||||
params: Value,
|
||||
detached: bool,
|
||||
wasm: Option<std::path::PathBuf>,
|
||||
) -> NativeFuture {
|
||||
let host = self.clone();
|
||||
Box::pin(async move {
|
||||
if !params.is_object() {
|
||||
return Err(params_not_object(¶ms));
|
||||
}
|
||||
if caller != "console" && caller != "core" {
|
||||
let state = host.0.state.lock().unwrap();
|
||||
let Some(process) = state.processes.get(&caller) else {
|
||||
return Err(error(-32600, "process ended"));
|
||||
};
|
||||
if process.info.state != "running" && method != "core.notify" {
|
||||
return Err(error(-32600, "process is stopping"));
|
||||
}
|
||||
}
|
||||
let (plugin, method) = method
|
||||
.split_once('.')
|
||||
.ok_or_else(|| error(-32601, "method not found"))?;
|
||||
if plugin == "core" {
|
||||
return host.core(&caller, id, method, params).await;
|
||||
}
|
||||
if plugin.is_empty()
|
||||
|| !plugin
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
|
||||
{
|
||||
return Err(error(-32601, "invalid plugin name"));
|
||||
}
|
||||
let path = wasm.unwrap_or_else(|| host.0.world.plugin_dir(plugin).join("plugin.wasm"));
|
||||
let (native, component, version, admission) = loop {
|
||||
if host.0.shutdown.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return Err(error(-32600, "host is stopping"));
|
||||
}
|
||||
let native = host.0.state.lock().unwrap().native.get(plugin).cloned();
|
||||
let loaded = if native.is_none() {
|
||||
Some(
|
||||
host.0
|
||||
.components
|
||||
.load(path.clone(), host.0.runtime.clone())
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let admission = host.0.admission.read().await;
|
||||
if let Some(loaded) = loaded {
|
||||
if crate::components::Fingerprint::read(&path).await? != loaded.fingerprint {
|
||||
drop(admission);
|
||||
continue;
|
||||
}
|
||||
break (native, Some(loaded.prepared), loaded.hash, admission);
|
||||
}
|
||||
break (native, None, "native".into(), admission);
|
||||
};
|
||||
let pid = format!("{plugin}-{}", Uuid::now_v7());
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let (input, incoming) = mpsc::unbounded_channel::<Value>();
|
||||
let info = ProcessInfo {
|
||||
plugin: plugin.into(),
|
||||
version,
|
||||
method: method.into(),
|
||||
params: params.clone(),
|
||||
parent: if detached { None } else { Some(caller.clone()) },
|
||||
detached,
|
||||
start_time: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis(),
|
||||
state: "running".into(),
|
||||
};
|
||||
{
|
||||
let mut state = host.0.state.lock().unwrap();
|
||||
if host.0.shutdown.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return Err(error(-32600, "host is stopping"));
|
||||
}
|
||||
if caller != "console"
|
||||
&& caller != "core"
|
||||
&& !state
|
||||
.processes
|
||||
.get(&caller)
|
||||
.is_some_and(|p| p.info.state == "running")
|
||||
{
|
||||
return Err(error(-32600, "caller stopped or ended"));
|
||||
}
|
||||
// Older plugins may still try to start their boot service from
|
||||
// rpc.retired. The supervisor owns it even while a restart is
|
||||
// pending, or after it has given up. Do not create a second owner.
|
||||
if state
|
||||
.processes
|
||||
.get(&caller)
|
||||
.is_some_and(|p| p.info.method == "rpc.retired")
|
||||
&& state
|
||||
.services
|
||||
.iter()
|
||||
.any(|s| s.method == format!("{plugin}.{method}") && s.params == params)
|
||||
{
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if caller == "core"
|
||||
&& let Some(service) = state
|
||||
.services
|
||||
.iter_mut()
|
||||
.find(|s| Some(&s.request) == id.as_ref())
|
||||
{
|
||||
if !service.enabled || service.owner.is_some() {
|
||||
return Err(error(-32600, "service attempt is no longer current"));
|
||||
}
|
||||
service.owner = Some(pid.clone());
|
||||
service.pending = false;
|
||||
service.version = Some(info.version.clone());
|
||||
}
|
||||
host.0
|
||||
.journal
|
||||
.write(&pid, plugin, "core", &format!("start {method} {params}"));
|
||||
state.processes.insert(
|
||||
pid.clone(),
|
||||
Process {
|
||||
info: info.clone(),
|
||||
memory: sausage_vfs::Memory::default(),
|
||||
files: sausage_vfs::Process::default(),
|
||||
store_ended: watch::channel(true).0,
|
||||
input: Some(input),
|
||||
abort: None,
|
||||
caller,
|
||||
id: id.clone(),
|
||||
answer: if id.is_some() { Some(tx) } else { None },
|
||||
ended: watch::channel(false).0,
|
||||
stopped: false,
|
||||
superseded: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
let h = host.clone();
|
||||
let p = pid.clone();
|
||||
let has_id = id.is_some();
|
||||
let (start, ready) = oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let _ = ready.await;
|
||||
h.run_process(p, info, component, native, incoming, has_id)
|
||||
.await
|
||||
});
|
||||
if let Some(p) = host.0.state.lock().unwrap().processes.get_mut(&pid) {
|
||||
p.abort = Some(task.abort_handle());
|
||||
if p.info.state == "stopping" {
|
||||
task.abort();
|
||||
}
|
||||
} else {
|
||||
task.abort();
|
||||
}
|
||||
drop(admission);
|
||||
let cleanup = host.clone();
|
||||
let finished_pid = pid.clone();
|
||||
tokio::spawn(async move {
|
||||
let status = task.await.unwrap_or_else(|error| {
|
||||
if error.is_cancelled() {
|
||||
"killed".into()
|
||||
} else {
|
||||
format!("host task failed: {error}")
|
||||
}
|
||||
});
|
||||
cleanup.finish(&finished_pid, &status).await;
|
||||
});
|
||||
let _ = start.send(());
|
||||
if id.is_none() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
rx.await
|
||||
.unwrap_or_else(|_| Err(error(-32002, "process ended")))
|
||||
})
|
||||
}
|
||||
/// Complete the initial call once and discard late responses.
|
||||
pub(crate) fn answer(&self, pid: &str, result: RpcResult) {
|
||||
if let Some(tx) = self
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get_mut(pid)
|
||||
.and_then(|p| p.answer.take())
|
||||
{
|
||||
let _ = tx.send(result);
|
||||
}
|
||||
}
|
||||
/// Classify a stdout line and route requests or the initial response.
|
||||
pub(crate) fn output(&self, pid: &str, line: String) {
|
||||
if line.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(v) = serde_json::from_str::<Value>(&line) else {
|
||||
self.discard(pid);
|
||||
return;
|
||||
};
|
||||
if !v.is_object() || v["jsonrpc"] != "2.0" {
|
||||
self.discard(pid);
|
||||
return;
|
||||
}
|
||||
if let Some(method) = v["method"].as_str() {
|
||||
if method == "core.notify" || method == "core.unlock" {
|
||||
// Deliver progress before a following response removes its destination.
|
||||
let params = v.get("params").cloned().unwrap_or(json!({}));
|
||||
let result = if !params.is_object() {
|
||||
Err(params_not_object(¶ms))
|
||||
} else if method == "core.unlock" {
|
||||
if let Some(path) = params["path"].as_str() {
|
||||
self.unlock(pid, path);
|
||||
Ok(Value::Null)
|
||||
} else {
|
||||
Err(error(-32602, "path must be a string"))
|
||||
}
|
||||
} else if let Some(name) = params["name"].as_str() {
|
||||
self.notify(
|
||||
pid,
|
||||
name,
|
||||
params.get("params").cloned().unwrap_or(json!({})),
|
||||
);
|
||||
Ok(Value::Null)
|
||||
} else {
|
||||
Err(error(-32602, "name must be a string"))
|
||||
};
|
||||
if let Some(id) = v.get("id") {
|
||||
let response = match result {
|
||||
Ok(result) => json!({"jsonrpc":"2.0", "id":id, "result":result}),
|
||||
Err(error) => json!({"jsonrpc":"2.0", "id":id, "error":error}),
|
||||
};
|
||||
self.send(pid, response);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let h = self.clone();
|
||||
let caller = pid.to_string();
|
||||
let method = method.to_string();
|
||||
tokio::spawn(async move {
|
||||
let id = v.get("id").cloned();
|
||||
let result = h
|
||||
.route(
|
||||
caller.clone(),
|
||||
id.clone(),
|
||||
method,
|
||||
v.get("params").cloned().unwrap_or(json!({})),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
if let Some(id) = id {
|
||||
let value = match result {
|
||||
Ok(result) => json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": result
|
||||
}),
|
||||
Err(error) => json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": error
|
||||
}),
|
||||
};
|
||||
h.send(&caller, value);
|
||||
}
|
||||
});
|
||||
} else if v["id"] == pid {
|
||||
self.answer(
|
||||
pid,
|
||||
if let Some(e) = v.get("error").filter(|e| !e.is_null()) {
|
||||
Err(e.clone())
|
||||
} else {
|
||||
Ok(v["result"].clone())
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
pub(crate) fn discard(&self, pid: &str) {
|
||||
// The journal deduplicates this warning for each process.
|
||||
if let Some(plugin) = self
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get(pid)
|
||||
.map(|p| p.info.plugin.clone())
|
||||
{
|
||||
self.0.journal.warning(pid, &plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// PROTOCOL 6: an unlock from stdout completes before output returns.
|
||||
#[tokio::test]
|
||||
async fn stdout_unlock_is_synchronous() {
|
||||
let folder = std::env::temp_dir().join(format!("sausage-unlock-{}", Uuid::now_v7()));
|
||||
crate::world::World::create(&folder).unwrap();
|
||||
let host = Host::start_with_cache(&folder, &folder.join("cache"))
|
||||
.await
|
||||
.unwrap();
|
||||
host.lock("core", "ordered", true).await.unwrap();
|
||||
host.output(
|
||||
"core",
|
||||
r#"{"jsonrpc":"2.0","method":"core.unlock","params":{"path":"ordered"}}"#.into(),
|
||||
);
|
||||
let held = host.lock("console", "ordered", false).await.unwrap();
|
||||
host.stop().await;
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
assert_eq!(held, json!({"held":true}));
|
||||
}
|
||||
}
|
||||
+125
-227
@@ -1,29 +1,36 @@
|
||||
//! Wasmtime engine and process instances.
|
||||
//! Wasmtime engine and actor instances.
|
||||
//!
|
||||
//! An instance is one run of a `wasi:cli/command` component. The host writes
|
||||
//! NDJSON lines to its stdin and reads NDJSON lines from its stdout.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use wasmtime::component::{Component, Linker, ResourceTable};
|
||||
use wasmtime::{Config, Engine, Store};
|
||||
use wasmtime_wasi::cli::{AsyncStdinStream, AsyncStdoutStream};
|
||||
|
||||
use wasmtime_wasi::p2::bindings::CommandPre;
|
||||
|
||||
/// A component with its imports resolved once for this runtime.
|
||||
pub(crate) struct Prepared(Result<CommandPre<ProcessState>, String>);
|
||||
|
||||
use wasmtime_wasi::{I32Exit, WasiCtx, WasiCtxView, WasiView};
|
||||
use wasmtime_wasi::filesystem::WasiFilesystemCtxView;
|
||||
use wasmtime_wasi::p2::bindings::Command;
|
||||
use wasmtime_wasi::p2::bindings::filesystem;
|
||||
use wasmtime_wasi::{FsPerms, I32Exit, WasiCtx, WasiCtxView, WasiView};
|
||||
use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView};
|
||||
|
||||
/// The HTTP transport hooks supplied to WASI processes.
|
||||
use crate::broker::Broker;
|
||||
use crate::vfs::{Vfs, VfsState, VfsView};
|
||||
|
||||
/// A host folder that the guest sees at `guest`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Mount {
|
||||
pub guest: String,
|
||||
pub host: PathBuf,
|
||||
pub writable: bool,
|
||||
}
|
||||
|
||||
pub struct HttpHooks {
|
||||
allow: bool,
|
||||
}
|
||||
@@ -58,38 +65,33 @@ impl WasiHttpHooks for HttpHooks {
|
||||
}
|
||||
}
|
||||
|
||||
/// The WASI and HTTP resources owned by one store.
|
||||
pub struct ProcessState {
|
||||
pub struct ActorState {
|
||||
ctx: WasiCtx,
|
||||
table: ResourceTable,
|
||||
http: WasiHttpCtx,
|
||||
hooks: HttpHooks,
|
||||
fs: sausage_vfs::adapter::Adapter,
|
||||
vfs: VfsState,
|
||||
}
|
||||
|
||||
impl WasiView for ProcessState {
|
||||
fn vfs_view(s: &mut ActorState) -> VfsView<'_> {
|
||||
VfsView { inner: WasiFilesystemCtxView { ctx: s.ctx.filesystem(), table: &mut s.table }, state: &mut s.vfs }
|
||||
}
|
||||
|
||||
impl WasiView for ActorState {
|
||||
fn ctx(&mut self) -> WasiCtxView<'_> {
|
||||
WasiCtxView {
|
||||
ctx: &mut self.ctx,
|
||||
table: &mut self.table,
|
||||
}
|
||||
WasiCtxView { ctx: &mut self.ctx, table: &mut self.table }
|
||||
}
|
||||
}
|
||||
|
||||
impl WasiHttpView for ProcessState {
|
||||
impl WasiHttpView for ActorState {
|
||||
fn http(&mut self) -> WasiHttpCtxView<'_> {
|
||||
WasiHttpCtxView {
|
||||
ctx: &mut self.http,
|
||||
table: &mut self.table,
|
||||
hooks: &mut self.hooks,
|
||||
}
|
||||
WasiHttpCtxView { ctx: &mut self.http, table: &mut self.table, hooks: &mut self.hooks }
|
||||
}
|
||||
}
|
||||
|
||||
/// A Wasmtime engine and linker shared by the world.
|
||||
pub struct Runtime {
|
||||
engine: Engine,
|
||||
linker: Linker<ProcessState>,
|
||||
linker: Linker<ActorState>,
|
||||
}
|
||||
|
||||
/// How an instance ended.
|
||||
@@ -113,50 +115,29 @@ impl std::fmt::Display for Exit {
|
||||
}
|
||||
}
|
||||
|
||||
/// The filesystem, network, and environment supplied to one process.
|
||||
pub struct InstanceConfig {
|
||||
/// The virtual namespace exposed through the single root preopen.
|
||||
pub filesystem: sausage_vfs::Mounts,
|
||||
/// The store completion barrier used by process cleanup.
|
||||
pub store_ended: tokio::sync::watch::Sender<bool>,
|
||||
pub mounts: Vec<Mount>,
|
||||
/// Outgoing HTTP, and TCP, UDP, and DNS through `wasi:sockets`.
|
||||
pub network: bool,
|
||||
/// The environment variables supplied to the guest.
|
||||
pub stderr_log: PathBuf,
|
||||
pub env: Vec<(String, String)>,
|
||||
/// The actor that runs in the instance.
|
||||
pub actor: String,
|
||||
/// Asks the actors that serve `/actor/<id>/fs/` (see `vfs.rs`).
|
||||
pub broker: Weak<Broker>,
|
||||
}
|
||||
|
||||
/// A running guest. Dropping it kills the guest.
|
||||
pub struct Instance {
|
||||
/// The host end of stdin, removed when the process stops.
|
||||
pub stdin: Option<DuplexStream>,
|
||||
stdin: Option<DuplexStream>,
|
||||
/// Lines that the guest writes to stdout. Closed when the guest ends.
|
||||
pub stdout: mpsc::Receiver<String>,
|
||||
/// The stderr lines that the process writes.
|
||||
pub stderr: mpsc::Receiver<String>,
|
||||
stderr_task: Option<JoinHandle<()>>,
|
||||
task: Option<JoinHandle<Exit>>,
|
||||
store_ended: tokio::sync::watch::Sender<bool>,
|
||||
task: JoinHandle<Exit>,
|
||||
}
|
||||
|
||||
impl Drop for Instance {
|
||||
fn drop(&mut self) {
|
||||
let task = self.task.take();
|
||||
let stderr = self.stderr_task.take();
|
||||
if let Some(task) = &task {
|
||||
task.abort();
|
||||
}
|
||||
if task.is_some() || stderr.is_some() {
|
||||
let ended = self.store_ended.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Some(task) = task {
|
||||
let _ = task.await;
|
||||
}
|
||||
if let Some(stderr) = stderr {
|
||||
let _ = stderr.await;
|
||||
}
|
||||
ended.send_replace(true);
|
||||
});
|
||||
}
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,11 +145,7 @@ impl Instance {
|
||||
/// Write one line to the guest's stdin, in one write. Meanwhile, pass the
|
||||
/// lines that the guest writes to `route`: a guest that blocks on a full
|
||||
/// stdout would never read its stdin.
|
||||
pub async fn send_line(
|
||||
&mut self,
|
||||
line: &str,
|
||||
mut route: impl FnMut(String),
|
||||
) -> std::io::Result<()> {
|
||||
pub async fn send_line(&mut self, line: &str, mut route: impl FnMut(String)) -> std::io::Result<()> {
|
||||
let stdin = self.stdin.as_mut().ok_or(std::io::ErrorKind::BrokenPipe)?;
|
||||
let bytes = format!("{line}\n");
|
||||
let write = async {
|
||||
@@ -190,19 +167,55 @@ impl Instance {
|
||||
|
||||
/// Wait until the guest ends.
|
||||
pub async fn wait(&mut self) -> Exit {
|
||||
let result = self.task.as_mut().unwrap().await;
|
||||
self.task.take();
|
||||
if let Some(stderr) = self.stderr_task.as_mut() {
|
||||
let _ = stderr.await;
|
||||
}
|
||||
self.stderr_task.take();
|
||||
self.store_ended.send_replace(true);
|
||||
match result {
|
||||
match (&mut self.task).await {
|
||||
Ok(exit) => exit,
|
||||
Err(e) if e.is_cancelled() => Exit::Trap("killed".into()),
|
||||
Err(e) => Exit::Trap(format!("host task failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.task.is_finished()
|
||||
}
|
||||
|
||||
/// Kill the guest. Its stdout closes when it has ended.
|
||||
pub fn kill(&self) {
|
||||
self.task.abort();
|
||||
}
|
||||
|
||||
/// Send `shutdown_line`, close stdin, and wait until the guest ends.
|
||||
/// Pass every line that the guest writes meanwhile to `route`. Kill the
|
||||
/// guest when `deadline` has passed.
|
||||
pub async fn stop(mut self, shutdown_line: &str, deadline: Duration, mut route: impl FnMut(String)) -> Exit {
|
||||
let stdin = self.stdin.take();
|
||||
let stdout = &mut self.stdout;
|
||||
let finished = tokio::time::timeout(deadline, async {
|
||||
let close = async {
|
||||
if let Some(mut stdin) = stdin {
|
||||
let _ = stdin.write_all(format!("{shutdown_line}\n").as_bytes()).await;
|
||||
let _ = stdin.shutdown().await;
|
||||
}
|
||||
};
|
||||
let drain = async {
|
||||
while let Some(line) = stdout.recv().await {
|
||||
route(line);
|
||||
}
|
||||
};
|
||||
tokio::join!(close, drain);
|
||||
})
|
||||
.await;
|
||||
match finished {
|
||||
Ok(()) => self.wait().await,
|
||||
Err(_) => Exit::Trap("killed after shutdown deadline".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn log_note(path: &Path, text: &str) {
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(f, "[sausage] {text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The most useful line of a trap: the `wasm trap: ...` reason.
|
||||
@@ -212,188 +225,95 @@ fn short_trap(full: &str) -> String {
|
||||
.find_map(|l| l.find("wasm trap:").map(|i| l[i..].to_string()))
|
||||
.unwrap_or_else(|| full.lines().next().unwrap_or("trap").to_string());
|
||||
let mut line = line.trim().to_string();
|
||||
let mut end = line.len().min(300);
|
||||
while !line.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
line.truncate(end);
|
||||
line.truncate(300);
|
||||
line
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
/// Create an engine with disk caching and a ten-millisecond epoch tick.
|
||||
pub fn new(cache: &Path) -> Result<Arc<Self>> {
|
||||
pub fn new() -> Result<Arc<Self>> {
|
||||
let mut config = Config::new();
|
||||
config.epoch_interruption(true);
|
||||
let disk_cache = (|| -> Result<_> {
|
||||
let cache = cache.join("wasmtime");
|
||||
std::fs::create_dir_all(&cache)?;
|
||||
let mut cache_config = wasmtime::CacheConfig::new();
|
||||
cache_config.with_directory(cache.canonicalize()?);
|
||||
Ok(wasmtime::Cache::new(cache_config)?)
|
||||
})();
|
||||
match disk_cache {
|
||||
Ok(cache) => {
|
||||
config.cache(Some(cache));
|
||||
}
|
||||
Err(error) => eprintln!(
|
||||
"compile cache {} unavailable: {error}; running without disk cache",
|
||||
cache.display()
|
||||
),
|
||||
}
|
||||
let engine = Engine::new(&config)?;
|
||||
|
||||
let mut linker: Linker<ProcessState> = Linker::new(&engine);
|
||||
let mut linker: Linker<ActorState> = Linker::new(&engine);
|
||||
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
|
||||
sausage_vfs::adapter::add_to_linker(&mut linker, |state| sausage_vfs::adapter::View {
|
||||
adapter: &mut state.fs,
|
||||
table: &mut state.table,
|
||||
})?;
|
||||
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
|
||||
// Replace `wasi:filesystem` with the wrapper that serves `/actor/<id>/fs/`.
|
||||
linker.allow_shadowing(true);
|
||||
filesystem::types::add_to_linker::<ActorState, Vfs>(&mut linker, vfs_view)?;
|
||||
filesystem::preopens::add_to_linker::<ActorState, Vfs>(&mut linker, vfs_view)?;
|
||||
linker.allow_shadowing(false);
|
||||
|
||||
// Time slices: every 10 ms each running guest yields to the scheduler.
|
||||
let runtime = Arc::new(Self { engine, linker });
|
||||
let ticker = Arc::downgrade(&runtime);
|
||||
let ticker = engine.clone();
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
let Some(runtime) = ticker.upgrade() else {
|
||||
break;
|
||||
};
|
||||
runtime.engine.increment_epoch();
|
||||
ticker.increment_epoch();
|
||||
}
|
||||
});
|
||||
|
||||
Ok(runtime)
|
||||
Ok(Arc::new(Self { engine, linker }))
|
||||
}
|
||||
|
||||
/// Compile component bytes through Wasmtime's disk cache.
|
||||
pub fn compile(&self, bytes: &[u8]) -> Result<Component> {
|
||||
Ok(Component::new(&self.engine, bytes)?)
|
||||
pub fn load(&self, wasm: &Path) -> Result<Component> {
|
||||
Component::from_file(&self.engine, wasm)
|
||||
.map_err(anyhow::Error::from)
|
||||
.with_context(|| format!("load {}", wasm.display()))
|
||||
}
|
||||
|
||||
pub(crate) fn prepare(&self, component: &Component) -> Prepared {
|
||||
// Preserve link failures as process exits, rather than load errors.
|
||||
Prepared(
|
||||
self.linker
|
||||
.instantiate_pre(component)
|
||||
.and_then(CommandPre::new)
|
||||
.map_err(|error| error.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn start_logged(
|
||||
self: &Arc<Self>,
|
||||
prepared: &Prepared,
|
||||
cfg: InstanceConfig,
|
||||
journal: Arc<crate::journal::Journal>,
|
||||
pid: String,
|
||||
plugin: String,
|
||||
) -> Result<Instance> {
|
||||
self.start_inner(prepared.0.clone(), cfg, &[], Some((journal, pid, plugin)))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Start a WASI task with the supplied virtual namespace.
|
||||
pub async fn start(
|
||||
self: &Arc<Self>,
|
||||
component: &Component,
|
||||
cfg: InstanceConfig,
|
||||
) -> Result<Instance> {
|
||||
self.start_with_args(component, cfg, &[]).await
|
||||
}
|
||||
|
||||
/// Start a WASI task with explicit CLI arguments, including its program name.
|
||||
pub async fn start_with_args(
|
||||
self: &Arc<Self>,
|
||||
component: &Component,
|
||||
cfg: InstanceConfig,
|
||||
args: &[String],
|
||||
) -> Result<Instance> {
|
||||
self.start_inner(self.prepare(component).0, cfg, args, None)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn start_inner(
|
||||
self: &Arc<Self>,
|
||||
prepared: Result<CommandPre<ProcessState>, String>,
|
||||
cfg: InstanceConfig,
|
||||
args: &[String],
|
||||
log: Option<(Arc<crate::journal::Journal>, String, String)>,
|
||||
) -> Result<Instance> {
|
||||
pub fn start(self: &Arc<Self>, component: &Component, cfg: InstanceConfig) -> Result<Instance> {
|
||||
let (host_in, guest_in) = tokio::io::duplex(64 * 1024);
|
||||
let (guest_out, host_out) = tokio::io::duplex(64 * 1024);
|
||||
|
||||
let (guest_err, host_err) = tokio::io::duplex(64 * 1024);
|
||||
let stderr = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&cfg.stderr_log)
|
||||
.with_context(|| format!("open {}", cfg.stderr_log.display()))?;
|
||||
|
||||
let mut b = WasiCtx::builder();
|
||||
b.args(args);
|
||||
if cfg.network {
|
||||
b.inherit_network()
|
||||
.allow_tcp(true)
|
||||
.allow_udp(true)
|
||||
.allow_ip_name_lookup(true);
|
||||
b.inherit_network().allow_tcp(true).allow_udp(true).allow_ip_name_lookup(true);
|
||||
}
|
||||
b.stdin(AsyncStdinStream::new(guest_in));
|
||||
b.stdout(AsyncStdoutStream::new(8192, guest_out));
|
||||
b.stderr(AsyncStdoutStream::new(8192, guest_err));
|
||||
b.stderr(AsyncStdoutStream::new(8192, tokio::fs::File::from_std(stderr)));
|
||||
for (k, v) in &cfg.env {
|
||||
b.env(k, v);
|
||||
}
|
||||
|
||||
let state = ProcessState {
|
||||
for m in &cfg.mounts {
|
||||
let perms = if m.writable { FsPerms::ReadWrite } else { FsPerms::ReadOnly };
|
||||
b.preopened_dir(&m.host, &m.guest, perms)
|
||||
.map_err(anyhow::Error::from)
|
||||
.with_context(|| format!("mount {} at {}", m.host.display(), m.guest))?;
|
||||
}
|
||||
let state = ActorState {
|
||||
ctx: b.build(),
|
||||
table: ResourceTable::new(),
|
||||
http: WasiHttpCtx::new(),
|
||||
hooks: HttpHooks { allow: cfg.network },
|
||||
fs: sausage_vfs::adapter::Adapter::new(cfg.filesystem),
|
||||
vfs: VfsState::new(cfg.broker.clone(), cfg.actor.clone()),
|
||||
};
|
||||
|
||||
let (line_tx, line_rx) = mpsc::channel(1024);
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(host_out);
|
||||
let mut bytes = Vec::new();
|
||||
while let Ok(n) = reader.read_until(b'\n', &mut bytes).await {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
// Preserve a non-blank invalid line so routing emits its warning.
|
||||
let line = String::from_utf8(std::mem::take(&mut bytes))
|
||||
.unwrap_or_else(|_| "\u{fffd}".into());
|
||||
let mut lines = BufReader::new(host_out).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if line_tx.send(line).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let (err_tx, err_rx) = mpsc::channel(1024);
|
||||
let trap_log = log.clone();
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(host_err);
|
||||
let mut bytes = Vec::new();
|
||||
while let Ok(n) = reader.read_until(b'\n', &mut bytes).await {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
let line = String::from_utf8_lossy(&bytes)
|
||||
.trim_end_matches(['\r', '\n'])
|
||||
.to_string();
|
||||
bytes.clear();
|
||||
if let Some((journal, pid, plugin)) = &log {
|
||||
journal.write(pid, plugin, "stderr", &line);
|
||||
} else if err_tx.send(line).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let rt = self.clone();
|
||||
cfg.store_ended.send_replace(false);
|
||||
let component = component.clone();
|
||||
let stderr_log = cfg.stderr_log.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut store = Store::new(&rt.engine, state);
|
||||
store.epoch_deadline_async_yield_and_update(1);
|
||||
let result = async {
|
||||
let cmd = prepared
|
||||
.map_err(wasmtime::Error::msg)?
|
||||
.instantiate_async(&mut store)
|
||||
.await?;
|
||||
let cmd = Command::instantiate_async(&mut store, &component, &rt.linker).await?;
|
||||
cmd.wasi_cli_run().call_run(&mut store).await
|
||||
}
|
||||
.await;
|
||||
@@ -404,37 +324,15 @@ impl Runtime {
|
||||
Some(I32Exit(0)) => Exit::Clean,
|
||||
Some(I32Exit(c)) => Exit::Code(*c),
|
||||
None => {
|
||||
// Keep the full backtrace in the log, and a short reason in the status.
|
||||
let full = format!("{e:?}");
|
||||
if let Some((journal, pid, plugin)) = &trap_log {
|
||||
journal.write(pid, plugin, "core", &full);
|
||||
}
|
||||
log_note(&stderr_log, &format!("trap details:\n{full}"));
|
||||
Exit::Trap(short_trap(&full))
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Instance {
|
||||
stdin: Some(host_in),
|
||||
stdout: line_rx,
|
||||
stderr: err_rx,
|
||||
stderr_task: Some(stderr_task),
|
||||
task: Some(task),
|
||||
store_ended: cfg.store_ended,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod trap_text_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn short_trap_display_is_utf8_safe() {
|
||||
let full = format!("backtrace\nwasm trap: {}\nmore detail", "😀".repeat(1000));
|
||||
let status = short_trap(&full);
|
||||
assert!(status.len() <= 300);
|
||||
assert!(status.starts_with("wasm trap:"));
|
||||
assert_eq!(short_trap(""), "trap");
|
||||
assert_eq!(short_trap("first line\nsecond"), "first line");
|
||||
Ok(Instance { stdin: Some(host_in), stdout: line_rx, task })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
//! Ordered boot calls and the single restart owner for each service (PROTOCOL.md section 8).
|
||||
use crate::{Host, host::State};
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::VecDeque, sync::atomic::Ordering, time::Duration};
|
||||
use tokio::time::Instant;
|
||||
|
||||
// Systemd-derived values:
|
||||
// https://www.man7.org/linux/man-pages/man5/systemd-system.conf.5.html.
|
||||
const CRASH_LIMIT: usize = 5;
|
||||
const CRASH_WINDOW: Duration = Duration::from_secs(10);
|
||||
|
||||
pub(crate) struct Service {
|
||||
pub(crate) method: String,
|
||||
pub(crate) params: Value,
|
||||
pub(crate) request: Value,
|
||||
pub(crate) owner: Option<String>,
|
||||
pub(crate) pending: bool,
|
||||
pub(crate) version: Option<String>,
|
||||
pub(crate) enabled: bool,
|
||||
crashes: VecDeque<Instant>,
|
||||
}
|
||||
impl Service {
|
||||
fn crashed(&mut self, now: Instant) -> bool {
|
||||
while self
|
||||
.crashes
|
||||
.front()
|
||||
.is_some_and(|t| now.duration_since(*t) >= CRASH_WINDOW)
|
||||
{
|
||||
self.crashes.pop_front();
|
||||
}
|
||||
self.crashes.push_back(now);
|
||||
self.crashes.len() <= CRASH_LIMIT
|
||||
}
|
||||
}
|
||||
impl Host {
|
||||
/// Starts discovery in the background, then waits for each boot service in order.
|
||||
pub(crate) async fn boot(&self) -> anyhow::Result<()> {
|
||||
self.watch_plugins()?;
|
||||
let boot: Vec<Value> = serde_yaml_ng::from_slice(
|
||||
&tokio::fs::read(self.0.world.root.join("config/boot.yaml")).await?,
|
||||
)?;
|
||||
for service in boot {
|
||||
self.start_service(
|
||||
service["method"].as_str().unwrap_or("").into(),
|
||||
service["params"].clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Register once, before admission or readiness. Restarts reuse this registration.
|
||||
pub(crate) async fn start_service(&self, method: String, params: Value) {
|
||||
let request = json!(uuid::Uuid::now_v7().to_string());
|
||||
{
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
if self.0.shutdown.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
state.services.push(Service {
|
||||
method,
|
||||
params,
|
||||
request: request.clone(),
|
||||
owner: None,
|
||||
enabled: true,
|
||||
pending: true,
|
||||
version: None,
|
||||
crashes: VecDeque::new(),
|
||||
});
|
||||
}
|
||||
self.run_service(request).await;
|
||||
}
|
||||
/// Await readiness only; process cleanup, not this response, decides whether to restart.
|
||||
async fn run_service(&self, request: Value) {
|
||||
let (method, params) = {
|
||||
let state = self.0.state.lock().unwrap();
|
||||
if self.0.shutdown.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let Some(service) = state
|
||||
.services
|
||||
.iter()
|
||||
.find(|s| s.request == request && s.enabled)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
(service.method.clone(), service.params.clone())
|
||||
};
|
||||
self.0
|
||||
.journal
|
||||
.write("core", "core", "core", &format!("service start {method}"));
|
||||
let result = self
|
||||
.route("core".into(), Some(request.clone()), method, params, true)
|
||||
.await;
|
||||
{
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
if let Some(service) = state.services.iter_mut().find(|s| s.request == request) {
|
||||
service.pending = false;
|
||||
}
|
||||
}
|
||||
if let Err(error) = result {
|
||||
// Admission failures have no process lifetime to supervise. A process crash
|
||||
// before readiness is handled by finish just like one after readiness.
|
||||
self.0
|
||||
.journal
|
||||
.write("core", "core", "core", &format!("service failed {error}"));
|
||||
}
|
||||
}
|
||||
/// Called under the state lock after the old owner and its files/locks are gone.
|
||||
pub(crate) fn service_ended(
|
||||
&self,
|
||||
state: &mut State,
|
||||
pid: &str,
|
||||
status: &str,
|
||||
stopped: bool,
|
||||
superseded: bool,
|
||||
) {
|
||||
let Some(service) = state
|
||||
.services
|
||||
.iter_mut()
|
||||
.find(|s| s.owner.as_deref() == Some(pid))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
service.owner = None;
|
||||
if self.0.shutdown.load(Ordering::SeqCst) || !service.enabled {
|
||||
return;
|
||||
}
|
||||
if !superseded {
|
||||
if stopped {
|
||||
service.enabled = false;
|
||||
return;
|
||||
}
|
||||
if status == "exit 0" {
|
||||
return;
|
||||
}
|
||||
if !service.crashed(Instant::now()) {
|
||||
service.enabled = false;
|
||||
self.0.journal.write(
|
||||
"core",
|
||||
"core",
|
||||
"core",
|
||||
&format!(
|
||||
"service gave up {}: more than {CRASH_LIMIT} crashes within {} seconds",
|
||||
service.method,
|
||||
CRASH_WINDOW.as_secs(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.queue_service(service);
|
||||
}
|
||||
/// Reserve the next attempt before spawning it; upgrades and crashes share this owner.
|
||||
pub(crate) fn queue_service(&self, service: &mut Service) {
|
||||
service.request = json!(uuid::Uuid::now_v7().to_string());
|
||||
service.pending = true;
|
||||
let request = service.request.clone();
|
||||
let host = self.clone();
|
||||
tokio::spawn(async move {
|
||||
host.run_service(request).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,356 +0,0 @@
|
||||
//! Deterministic service supervision tests: native tasks stand in for guest lifetimes.
|
||||
use super::*;
|
||||
use crate::{NativePlugin, runtime::Runtime};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
|
||||
struct Rig {
|
||||
host: Host,
|
||||
starts: mpsc::UnboundedReceiver<(String, oneshot::Sender<()>)>,
|
||||
_world: tempfile::TempDir,
|
||||
}
|
||||
impl Rig {
|
||||
async fn new() -> Self {
|
||||
let world = tempfile::tempdir().unwrap();
|
||||
let runtime = Runtime::new(world.path()).unwrap();
|
||||
let host = Host::test_world(world.path(), runtime).await.unwrap();
|
||||
let (tx, starts) = mpsc::unbounded_channel();
|
||||
let plugin: NativePlugin = Arc::new(move |ctx, method, _| {
|
||||
let tx = tx.clone();
|
||||
Box::pin(async move {
|
||||
if method != "serve" {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let (end, ended) = oneshot::channel();
|
||||
tx.send((ctx.pid, end)).unwrap();
|
||||
let _ = ended.await;
|
||||
Ok(Value::Null)
|
||||
})
|
||||
});
|
||||
host.register_native("demo", plugin.clone());
|
||||
host.register_native("other", plugin);
|
||||
Self {
|
||||
host,
|
||||
starts,
|
||||
_world: world,
|
||||
}
|
||||
}
|
||||
async fn start(&mut self, method: &str) -> (String, oneshot::Sender<()>) {
|
||||
let host = self.host.clone();
|
||||
let method = method.to_string();
|
||||
tokio::spawn(async move {
|
||||
host.start_service(method, json!({"kept":true})).await;
|
||||
});
|
||||
self.next().await
|
||||
}
|
||||
async fn next(&mut self) -> (String, oneshot::Sender<()>) {
|
||||
// Reuse the host integration call bound (activation.rs::call).
|
||||
tokio::time::timeout(Duration::from_secs(60), self.starts.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
fn ended(&self, pid: &str) -> watch::Receiver<bool> {
|
||||
self.host.0.state.lock().unwrap().processes[pid]
|
||||
.ended
|
||||
.subscribe()
|
||||
}
|
||||
async fn crash(&self, pid: &str) {
|
||||
let mut ended = self.ended(pid);
|
||||
// Abort execution without asking the supervisor for an intentional stop.
|
||||
self.host.0.state.lock().unwrap().processes[pid]
|
||||
.abort
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.abort();
|
||||
ended.wait_for(|done| *done).await.unwrap();
|
||||
}
|
||||
async fn clean(&self, pid: &str, end: oneshot::Sender<()>) {
|
||||
let mut ended = self.ended(pid);
|
||||
end.send(()).unwrap();
|
||||
ended.wait_for(|done| *done).await.unwrap();
|
||||
}
|
||||
fn assert_single(&self, pid: &str, registrations: usize) {
|
||||
let state = self.host.0.state.lock().unwrap();
|
||||
assert_eq!(state.services.len(), registrations);
|
||||
let p = &state.processes[pid];
|
||||
assert_eq!(p.info.params, json!({"kept":true}));
|
||||
assert!(p.info.detached);
|
||||
assert_eq!(p.caller, "core");
|
||||
assert_eq!(
|
||||
state
|
||||
.services
|
||||
.iter()
|
||||
.filter(|s| s.owner.as_deref() == Some(pid))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
fn no_start(&mut self) {
|
||||
assert!(self.starts.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn crash_before_and_after_readiness_observes_process_not_response() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (pid, _end) = rig.start("demo.serve").await;
|
||||
rig.assert_single(&pid, 1);
|
||||
rig.crash(&pid).await; // No ready RPC response was sent.
|
||||
let (pid, end) = rig.next().await;
|
||||
rig.assert_single(&pid, 1);
|
||||
rig.host.answer(&pid, Ok(json!({"ready":true})));
|
||||
tokio::task::yield_now().await;
|
||||
rig.no_start(); // Ready does not end the service lifetime.
|
||||
rig.crash(&pid).await;
|
||||
drop(end);
|
||||
let (pid, end) = rig.next().await;
|
||||
rig.assert_single(&pid, 1);
|
||||
rig.clean(&pid, end).await;
|
||||
rig.no_start(); // A clean, intentional return is not a crash.
|
||||
rig.host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn fifth_restarts_sixth_gives_up_and_journals_once() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (mut pid, mut end) = rig.start("demo.serve").await;
|
||||
for crash in 1..=CRASH_LIMIT {
|
||||
rig.crash(&pid).await;
|
||||
drop(end);
|
||||
(pid, end) = rig.next().await;
|
||||
rig.assert_single(&pid, 1);
|
||||
assert_eq!(
|
||||
rig.host.0.state.lock().unwrap().services[0].crashes.len(),
|
||||
crash
|
||||
);
|
||||
}
|
||||
rig.crash(&pid).await;
|
||||
drop(end);
|
||||
rig.no_start();
|
||||
let logs = rig
|
||||
.host
|
||||
.call("core.log", json!({"grep":"service gave up"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(logs.as_array().unwrap().len(), 1);
|
||||
assert!(
|
||||
logs[0]["line"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("more than 5 crashes within 10 seconds")
|
||||
);
|
||||
assert!(!rig.host.0.state.lock().unwrap().services[0].enabled);
|
||||
// A retired hook cannot bypass give-up while its supervisor has no owner.
|
||||
rig.host.register_native(
|
||||
"fallback",
|
||||
Arc::new(move |ctx, _, _| {
|
||||
Box::pin(async move {
|
||||
ctx.call(
|
||||
"core.call",
|
||||
json!({"method":"demo.serve","params":{"kept":true},"detach":true}),
|
||||
)
|
||||
.await
|
||||
})
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
rig.host
|
||||
.call("fallback.rpc.retired", json!({}))
|
||||
.await
|
||||
.unwrap(),
|
||||
Value::Null
|
||||
);
|
||||
rig.no_start();
|
||||
// Neither time passing nor an upgrade re-arms a given-up service.
|
||||
tokio::time::advance(CRASH_WINDOW).await;
|
||||
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "new");
|
||||
rig.host.begin_upgrade(upgrade);
|
||||
tokio::task::yield_now().await;
|
||||
rig.no_start();
|
||||
rig.host.stop().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sliding_window_boundary_and_progress() {
|
||||
let now = Instant::now();
|
||||
let service = || Service {
|
||||
method: "demo.serve".into(),
|
||||
params: json!({}),
|
||||
request: json!("request"),
|
||||
owner: None,
|
||||
enabled: true,
|
||||
pending: false,
|
||||
version: None,
|
||||
crashes: VecDeque::new(),
|
||||
};
|
||||
let mut before = service();
|
||||
let mut exact = service();
|
||||
let mut after = service();
|
||||
for _ in 0..CRASH_LIMIT {
|
||||
assert!(before.crashed(now));
|
||||
assert!(exact.crashed(now));
|
||||
assert!(after.crashed(now));
|
||||
}
|
||||
assert!(!before.crashed(now + CRASH_WINDOW - Duration::from_nanos(1)));
|
||||
assert!(exact.crashed(now + CRASH_WINDOW));
|
||||
assert_eq!(exact.crashes.len(), 1);
|
||||
assert!(after.crashed(now + CRASH_WINDOW + Duration::from_nanos(1)));
|
||||
// Repeated progress beyond the window never accumulates old failures.
|
||||
for step in 2..=CRASH_LIMIT as u32 + 1 {
|
||||
assert!(exact.crashed(now + CRASH_WINDOW * step));
|
||||
assert_eq!(exact.crashes.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn services_have_independent_crash_budgets() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (mut a, mut a_end) = rig.start("demo.serve").await;
|
||||
let (b, b_end) = rig.start("other.serve").await;
|
||||
for _ in 0..CRASH_LIMIT {
|
||||
rig.crash(&a).await;
|
||||
drop(a_end);
|
||||
(a, a_end) = rig.next().await;
|
||||
}
|
||||
rig.crash(&a).await;
|
||||
drop(a_end);
|
||||
rig.crash(&b).await;
|
||||
drop(b_end);
|
||||
let (b, b_end) = rig.next().await;
|
||||
rig.assert_single(&b, 2);
|
||||
rig.clean(&b, b_end).await;
|
||||
rig.no_start();
|
||||
rig.host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn upgrade_crash_race_has_one_restart_and_retired_fallback_cannot_duplicate_it() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (pid, end) = rig.start("demo.serve").await;
|
||||
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "new");
|
||||
rig.crash(&pid).await; // Exits between snapshot and begin_upgrade.
|
||||
drop(end);
|
||||
rig.host.begin_upgrade(upgrade);
|
||||
let (pid, end) = rig.next().await;
|
||||
rig.assert_single(&pid, 1);
|
||||
assert!(
|
||||
rig.host.0.state.lock().unwrap().services[0]
|
||||
.crashes
|
||||
.is_empty()
|
||||
);
|
||||
// A native retired hook exercises the same routing path as a guest fallback.
|
||||
rig.host.register_native(
|
||||
"fallback",
|
||||
Arc::new(move |ctx, _, _| {
|
||||
Box::pin(async move {
|
||||
ctx.call(
|
||||
"core.call",
|
||||
json!({"method":"demo.serve","params":{"kept":true},"detach":true}),
|
||||
)
|
||||
.await
|
||||
})
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
rig.host
|
||||
.call("fallback.rpc.retired", json!({}))
|
||||
.await
|
||||
.unwrap(),
|
||||
Value::Null
|
||||
);
|
||||
rig.no_start();
|
||||
rig.clean(&pid, end).await;
|
||||
rig.host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn crash_before_upgrade_snapshot_also_has_one_restart() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (pid, end) = rig.start("demo.serve").await;
|
||||
rig.crash(&pid).await;
|
||||
drop(end);
|
||||
// Restart may be pending admission or already admitted: snapshot handles both.
|
||||
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "native");
|
||||
rig.host.begin_upgrade(upgrade);
|
||||
let (pid, end) = rig.next().await;
|
||||
rig.assert_single(&pid, 1);
|
||||
rig.no_start();
|
||||
rig.clean(&pid, end).await;
|
||||
rig.host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn pending_admission_is_single_owned_across_upgrade() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (pid, end) = rig.start("demo.serve").await;
|
||||
let host = rig.host.clone();
|
||||
let admission = host.0.admission.write().await;
|
||||
rig.crash(&pid).await;
|
||||
drop(end);
|
||||
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "native");
|
||||
rig.host.begin_upgrade(upgrade);
|
||||
assert!(rig.host.0.state.lock().unwrap().services[0].pending);
|
||||
drop(admission);
|
||||
let (pid, end) = rig.next().await;
|
||||
rig.assert_single(&pid, 1);
|
||||
rig.no_start();
|
||||
rig.clean(&pid, end).await;
|
||||
rig.host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn shutdown_rejects_restart_already_waiting_for_admission() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (pid, end) = rig.start("demo.serve").await;
|
||||
let host = rig.host.clone();
|
||||
let admission = host.0.admission.write().await;
|
||||
rig.crash(&pid).await;
|
||||
drop(end);
|
||||
rig.host.stop().await;
|
||||
drop(admission);
|
||||
tokio::task::yield_now().await;
|
||||
rig.no_start();
|
||||
assert!(rig.host.processes().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn clean_service_stays_dormant_until_upgrade_and_explicit_stop_stays_disabled() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (pid, end) = rig.start("demo.serve").await;
|
||||
rig.clean(&pid, end).await;
|
||||
rig.no_start();
|
||||
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "new");
|
||||
rig.host.begin_upgrade(upgrade);
|
||||
let (pid, end) = rig.next().await;
|
||||
rig.assert_single(&pid, 1);
|
||||
// An explicit stop stays disabled even if the task subsequently exits cleanly.
|
||||
let mut ended = rig.host.begin_stop(&pid).unwrap();
|
||||
end.send(()).unwrap();
|
||||
ended.wait_for(|done| *done).await.unwrap();
|
||||
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "newer");
|
||||
rig.host.begin_upgrade(upgrade);
|
||||
tokio::task::yield_now().await;
|
||||
rig.no_start();
|
||||
rig.host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn intentional_stop_and_shutdown_never_restart_even_during_upgrade() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (pid, end) = rig.start("demo.serve").await;
|
||||
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "new");
|
||||
// An explicit kill after supersession overrides the automatic upgrade restart.
|
||||
rig.host
|
||||
.call("core.kill", json!({"pid":pid}))
|
||||
.await
|
||||
.unwrap();
|
||||
drop(end);
|
||||
rig.host.begin_upgrade(upgrade);
|
||||
tokio::task::yield_now().await;
|
||||
rig.no_start();
|
||||
let (_pid, end) = rig.start("other.serve").await;
|
||||
rig.host.stop().await;
|
||||
drop(end);
|
||||
rig.no_start();
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! The supervisor of one WASM actor: it runs the instance, feeds it the
|
||||
//! mailbox, routes its output, restarts it after a crash, and swaps versions.
|
||||
//!
|
||||
//! The broker talks to a supervisor only through `Control` requests.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use wasmtime::component::Component;
|
||||
|
||||
use crate::broker::{Broker, Envelope, SHUTDOWN_DEADLINE, shutdown_line};
|
||||
use crate::runtime::{Exit, Instance};
|
||||
|
||||
/// A new version that crashes this often within `CRASH_WINDOW` after an
|
||||
/// upgrade is rolled back automatically.
|
||||
const CRASH_LIMIT: usize = 3;
|
||||
const CRASH_WINDOW: Duration = Duration::from_secs(30);
|
||||
|
||||
pub type Reply = oneshot::Sender<Result<Value>>;
|
||||
|
||||
pub enum Control {
|
||||
/// Swap to `version` (already in `versions/`, compiled as `component`).
|
||||
Upgrade { version: u64, component: Component, requester: String, reply: Reply },
|
||||
Rollback { version: u64, reply: Reply },
|
||||
Stop { reply: Reply },
|
||||
Start { reply: Reply },
|
||||
Shutdown { reply: Reply },
|
||||
}
|
||||
|
||||
/// The time after an upgrade in which crashes count toward a rollback.
|
||||
struct Probation {
|
||||
previous: u64,
|
||||
since: Instant,
|
||||
crashes: usize,
|
||||
requester: String,
|
||||
}
|
||||
|
||||
pub struct Supervisor {
|
||||
broker: Arc<Broker>,
|
||||
id: String,
|
||||
version: u64,
|
||||
running: bool,
|
||||
mailbox: mpsc::Receiver<Envelope>,
|
||||
control: mpsc::Receiver<Control>,
|
||||
component: Option<Component>,
|
||||
instance: Option<Instance>,
|
||||
started_at: Instant,
|
||||
restarts: u32,
|
||||
restart_at: Option<Instant>,
|
||||
probation: Option<Probation>,
|
||||
/// A control request that arrived during a blocked delivery.
|
||||
next_control: Option<Control>,
|
||||
}
|
||||
|
||||
async fn next_line(instance: &mut Option<Instance>) -> Option<String> {
|
||||
match instance {
|
||||
Some(i) => i.stdout.recv().await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep_until(t: Option<Instant>) {
|
||||
match t {
|
||||
Some(t) => tokio::time::sleep_until(t.into()).await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
}
|
||||
|
||||
impl Supervisor {
|
||||
pub fn new(
|
||||
broker: Arc<Broker>,
|
||||
id: String,
|
||||
version: u64,
|
||||
running: bool,
|
||||
mailbox: mpsc::Receiver<Envelope>,
|
||||
control: mpsc::Receiver<Control>,
|
||||
) -> Self {
|
||||
Self {
|
||||
broker,
|
||||
id,
|
||||
version,
|
||||
running,
|
||||
mailbox,
|
||||
control,
|
||||
component: None,
|
||||
instance: None,
|
||||
started_at: Instant::now(),
|
||||
restarts: 0,
|
||||
restart_at: None,
|
||||
probation: None,
|
||||
next_control: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(mut self) {
|
||||
loop {
|
||||
if let Some(ctl) = self.next_control.take() {
|
||||
if self.handle(ctl).await {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if self.running && self.instance.is_none() && self.restart_at.is_none_or(|t| Instant::now() >= t) {
|
||||
self.restart_at = None;
|
||||
let _ = self.start().await;
|
||||
}
|
||||
let has_instance = self.instance.is_some();
|
||||
tokio::select! {
|
||||
ctl = self.control.recv() => {
|
||||
let Some(ctl) = ctl else { break };
|
||||
if self.handle(ctl).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
msg = self.mailbox.recv(), if has_instance => {
|
||||
let Some(msg) = msg else { break };
|
||||
self.deliver(msg).await;
|
||||
}
|
||||
line = next_line(&mut self.instance) => match line {
|
||||
Some(line) => self.broker.route_line(&self.id, &line),
|
||||
None => {
|
||||
let exit = self.instance.as_mut().unwrap().wait().await;
|
||||
self.instance = None;
|
||||
self.on_exit(exit).await;
|
||||
}
|
||||
},
|
||||
_ = sleep_until(self.restart_at), if !has_instance && self.running => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a message to the instance's stdin, and route its output
|
||||
/// meanwhile. If a control request arrives before the actor reads the
|
||||
/// message, the instance is killed (a partly written line would corrupt
|
||||
/// the next message) and the request runs.
|
||||
async fn deliver(&mut self, msg: Envelope) {
|
||||
let Some(inst) = self.instance.as_mut() else { return };
|
||||
let (broker, id, line) = (self.broker.clone(), self.id.clone(), msg.to_line());
|
||||
let stuck = tokio::select! {
|
||||
written = inst.send_line(&line, |l| broker.route_line(&id, &l)) => match written {
|
||||
Ok(()) => return,
|
||||
// The instance is ending. The main loop sees its closed stdout.
|
||||
Err(_) => false,
|
||||
},
|
||||
Some(ctl) = self.control.recv() => {
|
||||
self.next_control = Some(ctl);
|
||||
true
|
||||
}
|
||||
};
|
||||
if msg.from != "broker" {
|
||||
let body = json!({"type": "undeliverable", "reason": "the actor stopped", "to": self.id});
|
||||
self.broker.send("broker", Some(&msg.from), None, Some(&msg.id), body);
|
||||
}
|
||||
if stuck && let Some(inst) = &self.instance {
|
||||
self.broker.log(&self.id, "killed: it did not read stdin when the broker needed it");
|
||||
inst.kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// Start an instance of the current version. If that fails, the actor
|
||||
/// stays stopped until the next start, upgrade, or rollback.
|
||||
async fn start(&mut self) -> Result<()> {
|
||||
let started = self.try_start();
|
||||
match &started {
|
||||
Ok(()) => self.started_at = Instant::now(),
|
||||
Err(e) => {
|
||||
self.broker.log(&self.id, &format!("cannot start version {}: {e:#}", self.version));
|
||||
self.running = false;
|
||||
}
|
||||
}
|
||||
started
|
||||
}
|
||||
|
||||
fn try_start(&mut self) -> Result<()> {
|
||||
if self.component.is_none() {
|
||||
self.component = Some(self.broker.load_version(&self.id, self.version)?);
|
||||
}
|
||||
let cfg = self.broker.instance_config(&self.id, self.version, None);
|
||||
self.instance = Some(self.broker.runtime.start(self.component.as_ref().unwrap(), cfg)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop the instance gracefully. Its last messages still get routed.
|
||||
async fn stop(&mut self) {
|
||||
let Some(inst) = self.instance.take() else { return };
|
||||
let (broker, id) = (self.broker.clone(), self.id.clone());
|
||||
let exit = inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE, |line| broker.route_line(&id, &line)).await;
|
||||
self.broker.log(&self.id, &format!("stopped: {exit}"));
|
||||
}
|
||||
|
||||
async fn on_exit(&mut self, exit: Exit) {
|
||||
self.broker.log(&self.id, &format!("instance ended unexpectedly: {exit}"));
|
||||
if let Some(p) = &mut self.probation
|
||||
&& p.since.elapsed() < CRASH_WINDOW
|
||||
{
|
||||
p.crashes += 1;
|
||||
if p.crashes >= CRASH_LIMIT {
|
||||
let (from, to, requester) = (self.version, p.previous, p.requester.clone());
|
||||
self.broker.log(&self.id, &format!("version {from} crashed {CRASH_LIMIT} times after the upgrade; rolling back to {to}"));
|
||||
let body = match self.rollback(to).await {
|
||||
Ok(_) => json!({"type": "rolled_back", "actor": self.id, "from": from, "to": to,
|
||||
"reason": "crash loop after upgrade", "last_exit": exit.to_string()}),
|
||||
Err(e) => json!({"type": "error", "actor": self.id, "reason": format!("automatic rollback failed: {e:#}")}),
|
||||
};
|
||||
self.broker.send("broker", Some(&requester), None, None, body);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Restart after 0.5 s, doubling up to 30 s; a minute of uptime resets it.
|
||||
if self.started_at.elapsed() > Duration::from_secs(60) {
|
||||
self.restarts = 0;
|
||||
}
|
||||
let delay = Duration::from_millis(500 << self.restarts.min(6)).min(Duration::from_secs(30));
|
||||
self.restarts += 1;
|
||||
self.restart_at = Some(Instant::now() + delay);
|
||||
}
|
||||
|
||||
/// Make `version` current. It is compiled first (unless `component` is
|
||||
/// given): if that fails, nothing changes. `data/` and `public/` stay as
|
||||
/// they are. A stopped actor stays stopped.
|
||||
async fn install(&mut self, version: u64, component: Option<Component>) -> Result<()> {
|
||||
let component = match component {
|
||||
Some(c) => c,
|
||||
None => self.broker.load_version(&self.id, version)?,
|
||||
};
|
||||
self.stop().await;
|
||||
self.broker.install(&self.id, version, self.running)?;
|
||||
self.version = version;
|
||||
self.component = Some(component);
|
||||
self.restarts = 0;
|
||||
self.restart_at = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upgrade(&mut self, version: u64, component: Component, requester: String) -> Result<Value> {
|
||||
let previous = self.version;
|
||||
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 });
|
||||
if let Err(e) = self.start().await {
|
||||
self.rollback(previous).await?;
|
||||
bail!("version {version} did not start ({e:#}); rolled back to {previous}");
|
||||
}
|
||||
Ok(json!({"type": "upgraded", "actor": self.id, "version": version, "previous": previous, "running": true}))
|
||||
}
|
||||
|
||||
async fn rollback(&mut self, version: u64) -> Result<Value> {
|
||||
let from = self.version;
|
||||
self.install(version, None).await?;
|
||||
self.probation = None;
|
||||
if self.running {
|
||||
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.
|
||||
async fn handle(&mut self, ctl: Control) -> bool {
|
||||
match ctl {
|
||||
Control::Upgrade { version, component, requester, reply } => {
|
||||
let _ = reply.send(self.upgrade(version, component, requester).await);
|
||||
}
|
||||
Control::Rollback { version, reply } => {
|
||||
let _ = reply.send(self.rollback(version).await);
|
||||
}
|
||||
Control::Stop { reply } => {
|
||||
self.running = false;
|
||||
self.stop().await;
|
||||
let r = self.broker.set_running(&self.id, false);
|
||||
let _ = reply.send(r.map(|_| json!({"type": "stopped", "actor": self.id})));
|
||||
}
|
||||
Control::Start { reply } => {
|
||||
self.running = true;
|
||||
self.restart_at = None;
|
||||
let r = self.broker.set_running(&self.id, true);
|
||||
let _ = reply.send(r.map(|_| json!({"type": "started", "actor": self.id})));
|
||||
}
|
||||
Control::Shutdown { reply } => {
|
||||
self.stop().await;
|
||||
let _ = reply.send(Ok(json!({"type": "shut_down"})));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
//! Cargo's WASI test runner: one compiled component, isolated instances, and a small core transport.
|
||||
use anyhow::{Result, bail};
|
||||
use sausage::{
|
||||
Host,
|
||||
runtime::{Exit, InstanceConfig, Runtime},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use wasmtime::component::Component;
|
||||
|
||||
/// A temporary world remains alive through the end of its WASI store.
|
||||
struct TestWorld(PathBuf);
|
||||
impl Drop for TestWorld {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
struct Run {
|
||||
exit: Exit,
|
||||
output: String,
|
||||
}
|
||||
/// Runs one instance and separates JSON-RPC requests from ordinary test output.
|
||||
async fn instance(
|
||||
runtime: Arc<Runtime>,
|
||||
component: Component,
|
||||
args: Vec<String>,
|
||||
package: PathBuf,
|
||||
) -> Result<Run> {
|
||||
let path = std::env::temp_dir().join(format!("sausage-test-{}", uuid::Uuid::now_v7()));
|
||||
let world = TestWorld(path);
|
||||
let host = Host::test_world(&world.0, runtime.clone()).await?;
|
||||
let tracker = sausage_vfs::Process::default();
|
||||
let mut filesystem = host.filesystem().await?;
|
||||
// Virtual ancestors keep directory entries of the absolute package mount traversable.
|
||||
for ancestor in package
|
||||
.ancestors()
|
||||
.skip(1)
|
||||
.filter(|path| *path != Path::new("/"))
|
||||
{
|
||||
filesystem.mount(
|
||||
ancestor.to_str().unwrap(),
|
||||
Arc::new(sausage_vfs::Memory::default()),
|
||||
false,
|
||||
);
|
||||
}
|
||||
filesystem.mount(
|
||||
package.to_str().unwrap(),
|
||||
Arc::new(sausage_vfs::Host::new(&package, tracker.clone())?),
|
||||
false,
|
||||
);
|
||||
let mut env: Vec<_> = std::env::vars_os()
|
||||
.filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
|
||||
.filter(|(key, _)| {
|
||||
["CARGO_", "RUST_", "SAUSAGE_"]
|
||||
.iter()
|
||||
.any(|prefix| key.starts_with(prefix))
|
||||
})
|
||||
.collect();
|
||||
env.extend([
|
||||
("SAUSAGE_PID".into(), "test".into()),
|
||||
("SAUSAGE_PLUGIN".into(), "test".into()),
|
||||
("SAUSAGE_PARENT".into(), "core".into()),
|
||||
]);
|
||||
let timeout_secs = std::env::var("SAUSAGE_TEST_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| *value >= 1)
|
||||
.unwrap_or(sausage::cargo::DEFAULT_TEST_TIMEOUT_SECS);
|
||||
let deadline = tokio::time::Instant::now()
|
||||
.checked_add(std::time::Duration::from_secs(timeout_secs))
|
||||
.ok_or_else(|| anyhow::anyhow!("test_timeout_secs is too large"))?;
|
||||
let (ended, mut ended_rx) = tokio::sync::watch::channel(true);
|
||||
let mut guest = runtime
|
||||
.start_with_args(
|
||||
&component,
|
||||
InstanceConfig {
|
||||
filesystem,
|
||||
store_ended: ended,
|
||||
network: true,
|
||||
env,
|
||||
},
|
||||
&args,
|
||||
)
|
||||
.await?;
|
||||
let mut pending = std::collections::VecDeque::new();
|
||||
let mut stderr = std::mem::replace(&mut guest.stderr, tokio::sync::mpsc::channel(1).1);
|
||||
let (errors, mut stderr_lines) = tokio::sync::mpsc::unbounded_channel();
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
while let Some(line) = stderr.recv().await {
|
||||
let _ = errors.send(line);
|
||||
}
|
||||
});
|
||||
let mut output = String::new();
|
||||
let mut stdout_open = true;
|
||||
let mut stderr_open = true;
|
||||
let mut handlers = tokio::task::JoinSet::new();
|
||||
let result = tokio::time::timeout_at(deadline, async {
|
||||
while stdout_open || stderr_open || !pending.is_empty() {
|
||||
tokio::select! {
|
||||
line=async { if let Some(line) = pending.pop_front() { Some(line) } else { guest.stdout.recv().await } }, if stdout_open || !pending.is_empty()=>match line {
|
||||
Some(line)=> {
|
||||
// Libtest can prefix its status line before SDK output. The SDK starts JSON on a fresh line.
|
||||
if let Ok(request)=serde_json::from_str::<Value>(&line) && request["jsonrpc"]=="2.0" && request["method"].is_string() {
|
||||
let method = request["method"].as_str().unwrap();
|
||||
if !matches!(method, "core.lock" | "core.log") {
|
||||
let params = request.get("params").cloned().unwrap_or(json!({}));
|
||||
let result = match method {
|
||||
"core.unlock" => host.call(method, params).await,
|
||||
"core.notify" => Ok(Value::Null),
|
||||
_ => Err(json!({"code":-32601,"message":"a test has no other plugins"})),
|
||||
};
|
||||
if let Some(id) = request.get("id") {
|
||||
let response = match result { Ok(result)=>json!({"jsonrpc":"2.0","id":id,"result":result}), Err(error)=>json!({"jsonrpc":"2.0","id":id,"error":error}) };
|
||||
if guest.send_line(&response.to_string(), |line| pending.push_back(line)).await.is_err() { guest.stdin.take(); }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let host = host.clone();
|
||||
handlers.spawn(async move {
|
||||
let method=request["method"].as_str().unwrap();
|
||||
let params=request.get("params").cloned().unwrap_or(json!({}));
|
||||
let result=match method {
|
||||
"core.lock"|"core.unlock"|"core.log"=>host.call(method,params).await,
|
||||
"core.notify"=>Ok(Value::Null),
|
||||
_=>Err(json!({"code":-32601,"message":"a test has no other plugins"})),
|
||||
};
|
||||
request.get("id").map(|id|match result {Ok(result)=>json!({"jsonrpc":"2.0","id":id,"result":result}),Err(error)=>json!({"jsonrpc":"2.0","id":id,"error":error})})
|
||||
});
|
||||
} else {output.push_str(line.trim_end_matches(['\r', '\n']));output.push('\n');}
|
||||
},None=>stdout_open=false,
|
||||
},
|
||||
line=stderr_lines.recv(), if stderr_open=>match line {Some(line)=>{output.push_str(&line);output.push('\n');},None=>stderr_open=false},
|
||||
response=handlers.join_next(), if !handlers.is_empty()=> {
|
||||
if let Some(response) = response.transpose()?.flatten()
|
||||
&& guest.send_line(&response.to_string(), |line| pending.push_back(line)).await.is_err() { guest.stdin.take(); }
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(guest.wait().await)
|
||||
}).await;
|
||||
handlers.abort_all();
|
||||
// Dropping the instance aborts even a CPU-bound WASI loop. Wait for the
|
||||
// store and outstanding filesystem work before removing its test world.
|
||||
drop(guest);
|
||||
let _ = ended_rx.wait_for(|ended| *ended).await;
|
||||
let _ = stderr_task.await;
|
||||
tracker.idle().await;
|
||||
host.stop().await;
|
||||
let exit =
|
||||
result.map_err(|_| anyhow::anyhow!("timed out after {timeout_secs} s\n{output}"))??;
|
||||
Ok(Run { exit, output })
|
||||
}
|
||||
/// Extracts libtest names from its stable list format.
|
||||
fn names(output: &str) -> Vec<String> {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_suffix(": test").map(str::to_owned))
|
||||
.collect()
|
||||
}
|
||||
/// Lists tests, selects libtest filters, and runs each selected test on the core pool.
|
||||
pub async fn run(wasm: &Path, args: Vec<String>) -> Result<bool> {
|
||||
let package = std::env::current_dir()?;
|
||||
let cache = std::env::var_os("XDG_CACHE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".cache")
|
||||
})
|
||||
.join("sausage");
|
||||
let compile_path = wasm.to_path_buf();
|
||||
let (runtime, component) = tokio::task::spawn_blocking(move || -> Result<_> {
|
||||
let runtime = Runtime::new(&cache)?;
|
||||
let component = runtime.compile(&std::fs::read(compile_path)?)?;
|
||||
Ok((runtime, component))
|
||||
})
|
||||
.await??;
|
||||
let mut listing_args = vec![wasm.display().to_string(), "--list".into()];
|
||||
listing_args.extend(args.iter().filter(|arg| arg.as_str() != "--list").cloned());
|
||||
let listing = instance(
|
||||
runtime.clone(),
|
||||
component.clone(),
|
||||
listing_args.clone(),
|
||||
package.clone(),
|
||||
)
|
||||
.await?;
|
||||
if !matches!(listing.exit, Exit::Clean) {
|
||||
bail!(
|
||||
"cannot list WASI tests: {}\n{}",
|
||||
listing.exit,
|
||||
listing.output
|
||||
);
|
||||
}
|
||||
if args.iter().any(|arg| arg == "--list") {
|
||||
print!("{}", listing.output);
|
||||
return Ok(true);
|
||||
}
|
||||
let selected = names(&listing.output);
|
||||
listing_args.push("--ignored".into());
|
||||
let ignored = if args
|
||||
.iter()
|
||||
.any(|arg| matches!(arg.as_str(), "--ignored" | "--include-ignored"))
|
||||
{
|
||||
Vec::new()
|
||||
} else {
|
||||
names(
|
||||
&instance(
|
||||
runtime.clone(),
|
||||
component.clone(),
|
||||
listing_args,
|
||||
package.clone(),
|
||||
)
|
||||
.await?
|
||||
.output,
|
||||
)
|
||||
};
|
||||
println!("running {} tests", selected.len());
|
||||
let cores = std::thread::available_parallelism()
|
||||
.map(usize::from)
|
||||
.unwrap_or(1);
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(cores));
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
let mut skipped = 0;
|
||||
for name in selected {
|
||||
if ignored.contains(&name) {
|
||||
println!("test {name} ... ignored");
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let runtime = runtime.clone();
|
||||
let component = component.clone();
|
||||
let package = package.clone();
|
||||
let semaphore = semaphore.clone();
|
||||
let arguments = vec![
|
||||
wasm.display().to_string(),
|
||||
"--exact".into(),
|
||||
name.clone(),
|
||||
"--nocapture".into(),
|
||||
"--test-threads=1".into(),
|
||||
"--include-ignored".into(),
|
||||
];
|
||||
tasks.spawn(async move {
|
||||
let _permit = semaphore.acquire_owned().await?;
|
||||
let result = instance(runtime, component, arguments, package).await;
|
||||
Ok::<_, anyhow::Error>((name, result))
|
||||
});
|
||||
}
|
||||
let mut passed = 0;
|
||||
let mut failed = 0;
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
let (name, result) = result??;
|
||||
let success = result
|
||||
.as_ref()
|
||||
.is_ok_and(|run| matches!(run.exit, Exit::Clean));
|
||||
if success {
|
||||
passed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
println!("test {name} ... {}", if success { "ok" } else { "FAILED" });
|
||||
match result {
|
||||
Ok(run) => {
|
||||
for line in run
|
||||
.output
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
!line.trim().is_empty()
|
||||
&& !line.starts_with("test result:")
|
||||
&& !matches!(*line, "running 1 test" | "running 0 tests")
|
||||
&& !matches!(*line, "ok" | "FAILED")
|
||||
})
|
||||
.map(|line| {
|
||||
line.strip_prefix(&format!("test {name} ... "))
|
||||
.unwrap_or(line)
|
||||
})
|
||||
.filter(|line| {
|
||||
!matches!(*line, "ok" | "FAILED" | "ignored") && !line.is_empty()
|
||||
})
|
||||
{
|
||||
// Indentation keeps ordinary test output separate from our libtest status and totals.
|
||||
println!(" {line}");
|
||||
}
|
||||
}
|
||||
Err(error) => println!("{error:#}"),
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"\ntest result: {}. {passed} passed; {failed} failed; {skipped} ignored; 0 measured; 0 filtered out",
|
||||
if failed == 0 { "ok" } else { "FAILED" }
|
||||
);
|
||||
Ok(failed == 0)
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
//! Grace periods, service restarts, and retirement of replaced versions (PROTOCOL.md section 3.7).
|
||||
use crate::Host;
|
||||
use serde_json::json;
|
||||
use std::{collections::BTreeMap, sync::atomic::Ordering, time::Duration};
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// Uniform host policy (PROTOCOL 3.7), independent of discovery metadata.
|
||||
const UPGRADE_GRACE: Duration = Duration::from_secs(5);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(crate) struct OldProcess {
|
||||
pid: String,
|
||||
version: String,
|
||||
ended: watch::Receiver<bool>,
|
||||
}
|
||||
pub(crate) struct Upgrade {
|
||||
plugin: String,
|
||||
previous: String,
|
||||
current: String,
|
||||
processes: Vec<OldProcess>,
|
||||
}
|
||||
impl Host {
|
||||
/// Snapshots old admissions while activation holds the admission write guard.
|
||||
pub(crate) fn snapshot_upgrade(
|
||||
&self,
|
||||
plugin: &str,
|
||||
previous: String,
|
||||
current: &str,
|
||||
) -> Upgrade {
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
let processes = state
|
||||
.processes
|
||||
.iter_mut()
|
||||
.filter(|(_, process)| {
|
||||
process.info.plugin == plugin
|
||||
&& process.info.version != current
|
||||
&& process.info.method != "rpc.discover"
|
||||
})
|
||||
.map(|(pid, process)| {
|
||||
// The service supervisor owns the restart, including an exit on
|
||||
// the superseded notice before the grace timer is installed.
|
||||
process.superseded = !process.stopped;
|
||||
OldProcess {
|
||||
pid: pid.clone(),
|
||||
version: process.info.version.clone(),
|
||||
ended: process.ended.subscribe(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// A cleanly returned service is dormant until an upgrade. A pending
|
||||
// admission will choose the current version itself. Disabled services
|
||||
// (explicit stop or crash-limit give-up) must never be re-armed here.
|
||||
for service in &mut state.services {
|
||||
if service.enabled
|
||||
&& !service.pending
|
||||
&& service.owner.is_none()
|
||||
&& service.version.as_deref() != Some(current)
|
||||
&& service
|
||||
.method
|
||||
.split_once('.')
|
||||
.is_some_and(|(name, _)| name == plugin)
|
||||
{
|
||||
self.queue_service(service);
|
||||
}
|
||||
}
|
||||
Upgrade {
|
||||
plugin: plugin.into(),
|
||||
previous,
|
||||
current: current.into(),
|
||||
processes,
|
||||
}
|
||||
}
|
||||
/// Lets old calls finish, stops them after grace, and starts each lifecycle hook once.
|
||||
pub(crate) fn begin_upgrade(&self, upgrade: Upgrade) {
|
||||
// Queue the notice before returning from activation, without waiting on guest code.
|
||||
for process in &upgrade.processes {
|
||||
self.send(
|
||||
&process.pid,
|
||||
json!({"jsonrpc":"2.0","method":"rpc.superseded","params":{"current":upgrade.current}}),
|
||||
);
|
||||
}
|
||||
let host = self.clone();
|
||||
tokio::spawn(async move {
|
||||
if host.0.shutdown.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
host.0.journal.write(
|
||||
"core",
|
||||
&upgrade.plugin,
|
||||
"core",
|
||||
&format!("upgrade grace {} seconds", UPGRADE_GRACE.as_secs()),
|
||||
);
|
||||
let mut versions: BTreeMap<String, Vec<watch::Receiver<bool>>> = BTreeMap::new();
|
||||
versions.entry(upgrade.previous).or_default();
|
||||
for process in upgrade.processes {
|
||||
versions
|
||||
.entry(process.version)
|
||||
.or_default()
|
||||
.push(process.ended.clone());
|
||||
let host = host.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut ended = process.ended;
|
||||
tokio::select! {
|
||||
_ = ended.wait_for(|ended|*ended) => (),
|
||||
_ = tokio::time::sleep(UPGRADE_GRACE) => { host.begin_retire(&process.pid); }
|
||||
}
|
||||
});
|
||||
}
|
||||
for (version, processes) in versions {
|
||||
let host = host.clone();
|
||||
let plugin = upgrade.plugin.clone();
|
||||
let retirement = (plugin.clone(), version.clone());
|
||||
if !host
|
||||
.0
|
||||
.retirements
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(retirement.clone())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
for mut ended in processes {
|
||||
let _ = ended.wait_for(|ended| *ended).await;
|
||||
}
|
||||
loop {
|
||||
if host.0.shutdown.load(Ordering::SeqCst) {
|
||||
host.0.retirements.lock().unwrap().remove(&retirement);
|
||||
return;
|
||||
}
|
||||
let live: Vec<_> = host
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.values()
|
||||
.filter(|process| {
|
||||
process.info.plugin == plugin
|
||||
&& process.info.version == version
|
||||
&& process.info.method != "rpc.discover"
|
||||
})
|
||||
.map(|process| process.ended.subscribe())
|
||||
.collect();
|
||||
if live.is_empty() {
|
||||
break;
|
||||
}
|
||||
for mut ended in live {
|
||||
let _ = ended.wait_for(|ended| *ended).await;
|
||||
}
|
||||
}
|
||||
if host
|
||||
.0
|
||||
.graph
|
||||
.lock()
|
||||
.unwrap()
|
||||
.plugins
|
||||
.get(&plugin)
|
||||
.is_some_and(|current| current.hash == version)
|
||||
{
|
||||
host.0.retirements.lock().unwrap().remove(&retirement);
|
||||
return;
|
||||
}
|
||||
host.0.journal.write(
|
||||
"core",
|
||||
&plugin,
|
||||
"core",
|
||||
&format!("retired start {version}"),
|
||||
);
|
||||
let result = host
|
||||
.route(
|
||||
"core".into(),
|
||||
Some(json!(uuid::Uuid::now_v7().to_string())),
|
||||
format!("{plugin}.rpc.retired"),
|
||||
json!({"previous":version}),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
host.0.journal.write(
|
||||
"core",
|
||||
&plugin,
|
||||
"core",
|
||||
&format!("retired result {result:?}"),
|
||||
);
|
||||
host.0.retirements.lock().unwrap().remove(&retirement);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
//! Controlled-clock coverage of both upgrade windows and discovery exclusion.
|
||||
use super::*;
|
||||
use crate::{
|
||||
process::{Process, ProcessInfo},
|
||||
runtime::Runtime,
|
||||
world::World,
|
||||
};
|
||||
use tokio::{
|
||||
sync::mpsc,
|
||||
task::JoinHandle,
|
||||
time::{Instant, advance},
|
||||
};
|
||||
|
||||
/// A process with active work that ignores both superseded and EOF.
|
||||
fn process(
|
||||
host: &Host,
|
||||
pid: &str,
|
||||
method: &str,
|
||||
) -> (mpsc::UnboundedReceiver<serde_json::Value>, JoinHandle<()>) {
|
||||
let (input, incoming) = mpsc::unbounded_channel();
|
||||
let task = tokio::spawn(std::future::pending());
|
||||
host.0.state.lock().unwrap().processes.insert(
|
||||
pid.into(),
|
||||
Process {
|
||||
info: ProcessInfo {
|
||||
plugin: "notes".into(),
|
||||
version: "old".into(),
|
||||
method: method.into(),
|
||||
params: json!({}),
|
||||
parent: None,
|
||||
detached: true,
|
||||
start_time: 0,
|
||||
state: "running".into(),
|
||||
},
|
||||
memory: sausage_vfs::Memory::default(),
|
||||
files: sausage_vfs::Process::default(),
|
||||
store_ended: watch::channel(true).0,
|
||||
input: Some(input),
|
||||
abort: Some(task.abort_handle()),
|
||||
caller: "console".into(),
|
||||
id: None,
|
||||
answer: None,
|
||||
ended: watch::channel(false).0,
|
||||
stopped: false,
|
||||
superseded: false,
|
||||
},
|
||||
);
|
||||
(incoming, task)
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.7/3.6: five seconds running, then five seconds stopping; discovery is untouched.
|
||||
#[tokio::test]
|
||||
async fn two_five_second_windows_and_discovery_exclusion() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("world");
|
||||
World::create(&root).unwrap();
|
||||
let host = Host::test_world(&root, Runtime::new(&dir.path().join("cache")).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let (mut input, work) = process(&host, "old-work", "work");
|
||||
let (mut discovery_input, discovery) = process(&host, "old-discovery", "rpc.discover");
|
||||
let upgrade = host.snapshot_upgrade("notes", "old".into(), "new");
|
||||
assert_eq!(upgrade.processes.len(), 1);
|
||||
tokio::time::pause();
|
||||
let start = Instant::now();
|
||||
host.begin_upgrade(upgrade);
|
||||
assert_eq!(
|
||||
input.recv().await.unwrap(),
|
||||
json!({"jsonrpc":"2.0","method":"rpc.superseded","params":{"current":"new"}})
|
||||
);
|
||||
// Run the upgrade dispatcher, then its per-process task, so the deadline is armed.
|
||||
tokio::task::yield_now().await;
|
||||
tokio::task::yield_now().await;
|
||||
let window = Duration::from_secs(5);
|
||||
assert_eq!(UPGRADE_GRACE, window);
|
||||
// tokio::time::sleep documents millisecond granularity; the paused driver
|
||||
// may round a deadline up by one tick. Check each window independently.
|
||||
let tick = Duration::from_millis(1);
|
||||
let before_deadline = window - tick;
|
||||
advance(before_deadline).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(host.processes()["old-work"].state, "running");
|
||||
assert!(!work.is_finished());
|
||||
advance(tick).await;
|
||||
assert!(
|
||||
input.recv().await.is_none(),
|
||||
"EOF at grace, even with active work"
|
||||
);
|
||||
let stopped = Instant::now();
|
||||
assert!((window..=window + tick).contains(&(stopped - start)));
|
||||
assert_eq!(host.processes()["old-work"].state, "stopping");
|
||||
// begin_retire schedules the independent PROTOCOL 3.6 deadline.
|
||||
tokio::task::yield_now().await;
|
||||
advance(before_deadline).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert!(
|
||||
!work.is_finished(),
|
||||
"on_stop still has its full five seconds"
|
||||
);
|
||||
advance(tick).await;
|
||||
assert!(work.await.unwrap_err().is_cancelled());
|
||||
assert!((window..=window + tick).contains(&(Instant::now() - stopped)));
|
||||
assert_eq!(host.processes()["old-discovery"].state, "running");
|
||||
assert!(matches!(
|
||||
discovery_input.try_recv(),
|
||||
Err(mpsc::error::TryRecvError::Empty)
|
||||
));
|
||||
assert!(!discovery.is_finished());
|
||||
discovery.abort();
|
||||
let _ = discovery.await;
|
||||
// Synthetic processes have no execution cleanup task; discard only this test table.
|
||||
host.0.shutdown.store(true, Ordering::SeqCst);
|
||||
host.0.state.lock().unwrap().processes.clear();
|
||||
host.stop().await;
|
||||
}
|
||||
+796
@@ -0,0 +1,796 @@
|
||||
//! Actor-served files: `/actor/<id>/fs/...` (see PROTOCOL.md, "Actor-served
|
||||
//! files").
|
||||
//!
|
||||
//! This module wraps the `wasi:filesystem` implementation of `wasmtime-wasi`.
|
||||
//! A path under `/actor/<id>/fs/` does not reach the disk: the host asks the
|
||||
//! actor `<id>` with a message from `fs` and waits up to `ASK_TIMEOUT` for
|
||||
//! the reply. Other paths go to `wasmtime-wasi` unchanged except that an
|
||||
//! actor directory listing advertises `fs/` when its source docs declare it.
|
||||
//!
|
||||
//! - `fs_read {path}` -> `fs_file {text | base64}`, `fs_dir {entries: [{name, type}]}`
|
||||
//! - `fs_write {path, text | base64}`, `fs_mkdir {path}`, `fs_remove {path}`,
|
||||
//! `fs_rename {path, to}` -> `fs_done`
|
||||
//! - any of them -> `fs_error {errno}` (a `wasi:filesystem` error-code name)
|
||||
//!
|
||||
//! A file is read once when it is opened. After a change, it is written back
|
||||
//! in one `fs_write` when it is synced or closed.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
use bytes::Bytes;
|
||||
use serde_json::{Value, json};
|
||||
use wasmtime::component::{HasData, Resource};
|
||||
use wasmtime_wasi::filesystem::WasiFilesystemCtxView;
|
||||
use wasmtime_wasi::p2::bindings::filesystem::types::{
|
||||
self, DescriptorFlags, DescriptorType, DirectoryEntry, ErrorCode, HostDescriptor, HostDirectoryEntryStream, OpenFlags,
|
||||
};
|
||||
use wasmtime_wasi::p2::bindings::filesystem::preopens;
|
||||
use wasmtime_wasi::p2::pipe::MemoryInputPipe;
|
||||
use wasmtime_wasi::p2::{DynInputStream, DynOutputStream, FsError, FsResult, OutputStream, Pollable, StreamResult};
|
||||
|
||||
use crate::broker::Broker;
|
||||
|
||||
/// The `from` of the host's requests. Replies to it go back to the waiting call.
|
||||
pub const ID: &str = "fs";
|
||||
pub const ASK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Per-instance state.
|
||||
pub struct VfsState {
|
||||
broker: Weak<Broker>,
|
||||
/// The actor of this instance, for notes in its `stderr.log`.
|
||||
me: String,
|
||||
/// Native directory descriptors and their absolute paths. Keeping every
|
||||
/// `/actor` child (not only the preopen) lets `fs/` work after traversal.
|
||||
dirs: HashMap<u32, String>,
|
||||
}
|
||||
|
||||
impl VfsState {
|
||||
pub fn new(broker: Weak<Broker>, me: String) -> Self {
|
||||
Self { broker, me, dirs: HashMap::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// [`HasData`] for the linker: the filesystem functions get a [`VfsView`].
|
||||
pub struct Vfs;
|
||||
|
||||
impl HasData for Vfs {
|
||||
type Data<'a> = VfsView<'a>;
|
||||
}
|
||||
|
||||
pub struct VfsView<'a> {
|
||||
pub inner: WasiFilesystemCtxView<'a>,
|
||||
pub state: &'a mut VfsState,
|
||||
}
|
||||
|
||||
/// A descriptor of an actor-served file or folder (in the resource table in
|
||||
/// place of a `wasmtime-wasi` descriptor).
|
||||
#[derive(Clone)]
|
||||
enum Virt {
|
||||
Dir { actor: String, path: String },
|
||||
File(VFile),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct VFile {
|
||||
actor: String,
|
||||
path: String,
|
||||
data: Arc<Mutex<Vec<u8>>>,
|
||||
dirty: Arc<AtomicBool>,
|
||||
write: bool,
|
||||
}
|
||||
|
||||
/// A directory-entry-stream of an actor-served folder.
|
||||
struct VDir(std::vec::IntoIter<DirectoryEntry>);
|
||||
|
||||
enum Node {
|
||||
File(Vec<u8>),
|
||||
Dir(Vec<DirectoryEntry>),
|
||||
}
|
||||
|
||||
/// Sends the requests. Owned, so that no borrow of the view lives across an await.
|
||||
struct Asker {
|
||||
broker: Option<Arc<Broker>>,
|
||||
me: String,
|
||||
}
|
||||
|
||||
impl Asker {
|
||||
async fn ask(&self, actor: &str, body: Value) -> Result<Value, ErrorCode> {
|
||||
let broker = self.broker.as_ref().ok_or(ErrorCode::Io)?;
|
||||
let what = format!("{} {} to {actor}", body["type"].as_str().unwrap_or(""), body["path"].as_str().unwrap_or(""));
|
||||
match broker.ask(actor, body).await {
|
||||
None => {
|
||||
broker.log(&self.me, &format!("{what}: no reply within {} s", ASK_TIMEOUT.as_secs()));
|
||||
Err(ErrorCode::Io)
|
||||
}
|
||||
Some(reply) => match reply["type"].as_str() {
|
||||
Some("fs_error") => Err(errno(&reply["errno"])),
|
||||
Some("undeliverable") => Err(ErrorCode::NoEntry),
|
||||
_ => Ok(reply),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn read(&self, actor: &str, path: &str) -> Result<Node, ErrorCode> {
|
||||
let reply = self.ask(actor, json!({"type": "fs_read", "path": path})).await?;
|
||||
match reply["type"].as_str() {
|
||||
Some("fs_file") => content(&reply).map(Node::File),
|
||||
Some("fs_dir") => Ok(Node::Dir(
|
||||
reply["entries"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let type_ = if e["type"] == "dir" { DescriptorType::Directory } else { DescriptorType::RegularFile };
|
||||
Some(DirectoryEntry { type_, name: e["name"].as_str()?.to_string() })
|
||||
})
|
||||
.collect(),
|
||||
)),
|
||||
_ => {
|
||||
if let Some(b) = &self.broker {
|
||||
b.log(&self.me, &format!("fs_read {path} to {actor}: unexpected reply {reply}"));
|
||||
}
|
||||
Err(ErrorCode::Io)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn change(&self, actor: &str, body: Value) -> FsResult<()> {
|
||||
self.ask(actor, body).await.map(|_| ()).map_err(FsError::from)
|
||||
}
|
||||
|
||||
async fn flush(&self, f: &VFile) -> FsResult<()> {
|
||||
if f.dirty.swap(false, Ordering::SeqCst)
|
||||
&& let Err(e) = self.ask(&f.actor, write_body(f)).await
|
||||
{
|
||||
f.dirty.store(true, Ordering::SeqCst);
|
||||
return Err(e.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn errno(v: &Value) -> ErrorCode {
|
||||
match v.as_str().unwrap_or("") {
|
||||
"no-entry" => ErrorCode::NoEntry,
|
||||
"exist" => ErrorCode::Exist,
|
||||
"not-directory" => ErrorCode::NotDirectory,
|
||||
"is-directory" => ErrorCode::IsDirectory,
|
||||
"not-empty" => ErrorCode::NotEmpty,
|
||||
"access" => ErrorCode::Access,
|
||||
"not-permitted" => ErrorCode::NotPermitted,
|
||||
"invalid" => ErrorCode::Invalid,
|
||||
"unsupported" => ErrorCode::Unsupported,
|
||||
"busy" => ErrorCode::Busy,
|
||||
_ => ErrorCode::Io,
|
||||
}
|
||||
}
|
||||
|
||||
fn content(v: &Value) -> Result<Vec<u8>, ErrorCode> {
|
||||
if let Some(text) = v["text"].as_str() {
|
||||
return Ok(text.as_bytes().to_vec());
|
||||
}
|
||||
match v["base64"].as_str() {
|
||||
Some(b) => base64_decode(b).ok_or(ErrorCode::IllegalByteSequence),
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_body(f: &VFile) -> Value {
|
||||
let data = f.data.lock().unwrap().clone();
|
||||
match String::from_utf8(data) {
|
||||
Ok(text) => json!({"type": "fs_write", "path": f.path, "text": text}),
|
||||
Err(e) => json!({"type": "fs_write", "path": f.path, "base64": base64_encode(e.as_bytes())}),
|
||||
}
|
||||
}
|
||||
|
||||
/// `rel` resolved against the folder `base`, as an absolute path.
|
||||
fn join(base: &str, rel: &str) -> String {
|
||||
let mut parts: Vec<&str> = base.split('/').filter(|p| !p.is_empty()).collect();
|
||||
for p in rel.split('/') {
|
||||
match p {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
parts.pop();
|
||||
}
|
||||
p => parts.push(p),
|
||||
}
|
||||
}
|
||||
format!("/{}", parts.join("/"))
|
||||
}
|
||||
|
||||
/// Split a global path under an actor's virtual `fs/` into its owner and
|
||||
/// actor-relative path.
|
||||
fn actor_fs_path(full: &str) -> Option<(String, String)> {
|
||||
let rest = full.strip_prefix("/actor/")?;
|
||||
let (actor, rest) = rest.split_once('/')?;
|
||||
let (fs, path) = rest.split_once('/').unwrap_or((rest, ""));
|
||||
(fs == "fs" && !actor.is_empty()).then(|| (actor.to_string(), format!("/{path}")))
|
||||
}
|
||||
|
||||
fn actor_root(path: &str) -> Option<&str> {
|
||||
let actor = path.strip_prefix("/actor/")?;
|
||||
(!actor.is_empty() && !actor.contains('/')).then_some(actor)
|
||||
}
|
||||
|
||||
/// An actor advertises its virtual filesystem in its leading `//!` docs.
|
||||
/// This is discovery metadata; actual reads still go through `fs_read`.
|
||||
fn declares_actor_fs(source: &str) -> bool {
|
||||
let docs = source
|
||||
.lines()
|
||||
.take_while(|line| line.trim().starts_with("//!"))
|
||||
.map(|line| line.trim().trim_start_matches("//!").trim())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.to_ascii_lowercase();
|
||||
docs.contains("actor-served files") && docs.contains("fs_read")
|
||||
}
|
||||
|
||||
fn stat(type_: DescriptorType, size: u64) -> types::DescriptorStat {
|
||||
types::DescriptorStat {
|
||||
type_,
|
||||
link_count: 1,
|
||||
size,
|
||||
data_access_timestamp: None,
|
||||
data_modification_timestamp: None,
|
||||
status_change_timestamp: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_stat(node: &Node) -> types::DescriptorStat {
|
||||
match node {
|
||||
Node::File(data) => stat(DescriptorType::RegularFile, data.len() as u64),
|
||||
Node::Dir(_) => stat(DescriptorType::Directory, 0),
|
||||
}
|
||||
}
|
||||
|
||||
fn hash(actor: &str, path: &str) -> types::MetadataHashValue {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(actor, path).hash(&mut h);
|
||||
types::MetadataHashValue { lower: h.finish(), upper: 0 }
|
||||
}
|
||||
|
||||
fn write_at(data: &mut Vec<u8>, at: usize, bytes: &[u8]) {
|
||||
if data.len() < at + bytes.len() {
|
||||
data.resize(at + bytes.len(), 0);
|
||||
}
|
||||
data[at..at + bytes.len()].copy_from_slice(bytes);
|
||||
}
|
||||
|
||||
/// An output stream into an actor-served file (`pos: None` appends).
|
||||
struct VWriter {
|
||||
file: VFile,
|
||||
pos: Option<usize>,
|
||||
}
|
||||
|
||||
#[wasmtime_wasi::async_trait]
|
||||
impl Pollable for VWriter {
|
||||
async fn ready(&mut self) {}
|
||||
}
|
||||
|
||||
#[wasmtime_wasi::async_trait]
|
||||
impl OutputStream for VWriter {
|
||||
fn write(&mut self, bytes: Bytes) -> StreamResult<()> {
|
||||
let mut data = self.file.data.lock().unwrap();
|
||||
let at = self.pos.unwrap_or(data.len());
|
||||
write_at(&mut data, at, &bytes);
|
||||
if let Some(p) = &mut self.pos {
|
||||
*p += bytes.len();
|
||||
}
|
||||
self.file.dirty.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
fn flush(&mut self) -> StreamResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn check_write(&mut self) -> StreamResult<usize> {
|
||||
Ok(64 * 1024)
|
||||
}
|
||||
}
|
||||
|
||||
impl VfsView<'_> {
|
||||
fn asker(&self) -> Asker {
|
||||
Asker { broker: self.state.broker.upgrade(), me: self.state.me.clone() }
|
||||
}
|
||||
|
||||
fn actor_serves_fs(&self, actor: &str) -> bool {
|
||||
self.state
|
||||
.broker
|
||||
.upgrade()
|
||||
.and_then(|broker| std::fs::read_to_string(broker.world.source_dir(actor).join("src/main.rs")).ok())
|
||||
.is_some_and(|source| declares_actor_fs(&source))
|
||||
}
|
||||
|
||||
fn virt(&mut self, fd: &Resource<types::Descriptor>) -> Option<Virt> {
|
||||
self.inner.table.get_any_mut(fd.rep()).ok()?.downcast_ref::<Virt>().cloned()
|
||||
}
|
||||
|
||||
fn file(&mut self, fd: &Resource<types::Descriptor>) -> Option<FsResult<VFile>> {
|
||||
match self.virt(fd)? {
|
||||
Virt::File(f) => Some(Ok(f)),
|
||||
Virt::Dir { .. } => Some(Err(ErrorCode::IsDirectory.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The actor and its path, if `path` relative to `fd` is actor-served.
|
||||
fn target(&mut self, fd: &Resource<types::Descriptor>, path: &str) -> Option<(String, String)> {
|
||||
match self.virt(fd) {
|
||||
Some(Virt::Dir { actor, path: base }) => Some((actor, join(&base, path))),
|
||||
Some(Virt::File(f)) => Some((f.actor, join(&f.path, path))),
|
||||
None => {
|
||||
let base = self.state.dirs.get(&fd.rep())?;
|
||||
actor_fs_path(&join(base, path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn native_directory_entries(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Vec<DirectoryEntry>> {
|
||||
let stream = HostDescriptor::read_directory(&mut self.inner, fd).await?;
|
||||
let stream_rep = stream.rep();
|
||||
let mut entries = Vec::new();
|
||||
loop {
|
||||
match HostDirectoryEntryStream::read_directory_entry(&mut self.inner, Resource::new_borrow(stream_rep)).await? {
|
||||
Some(entry) => entries.push(entry),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
HostDirectoryEntryStream::drop(&mut self.inner, stream).map_err(|_| ErrorCode::Io)?;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn push(&mut self, v: Virt) -> FsResult<Resource<types::Descriptor>> {
|
||||
let r = self.inner.table.push(v)?;
|
||||
Ok(Resource::new_own(r.rep()))
|
||||
}
|
||||
|
||||
async fn open(&mut self, actor: String, path: String, oflags: OpenFlags, flags: DescriptorFlags) -> FsResult<Resource<types::Descriptor>> {
|
||||
let write = flags.contains(DescriptorFlags::WRITE);
|
||||
let create = oflags.contains(OpenFlags::CREATE);
|
||||
let exclusive = create && oflags.contains(OpenFlags::EXCLUSIVE);
|
||||
let virt = match self.asker().read(&actor, &path).await {
|
||||
Ok(_) if exclusive => return Err(ErrorCode::Exist.into()),
|
||||
Ok(Node::Dir(_)) if write => return Err(ErrorCode::IsDirectory.into()),
|
||||
Ok(Node::Dir(_)) => Virt::Dir { actor, path },
|
||||
Ok(Node::File(_)) if oflags.contains(OpenFlags::DIRECTORY) => return Err(ErrorCode::NotDirectory.into()),
|
||||
Ok(Node::File(data)) => {
|
||||
let truncate = oflags.contains(OpenFlags::TRUNCATE);
|
||||
let data = if truncate { Vec::new() } else { data };
|
||||
Virt::File(VFile { actor, path, data: Arc::new(Mutex::new(data)), dirty: Arc::new(AtomicBool::new(truncate)), write })
|
||||
}
|
||||
Err(ErrorCode::NoEntry) if create && !oflags.contains(OpenFlags::DIRECTORY) => Virt::File(VFile {
|
||||
actor,
|
||||
path,
|
||||
data: Arc::default(),
|
||||
dirty: Arc::new(AtomicBool::new(true)),
|
||||
write,
|
||||
}),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
self.push(virt)
|
||||
}
|
||||
|
||||
/// Send the changed file without waiting (the descriptor is gone). The
|
||||
/// request leaves now, so later requests of this instance come after it.
|
||||
fn write_back_later(&self, f: &VFile) {
|
||||
let Some(broker) = self.state.broker.upgrade() else { return };
|
||||
let reply = broker.ask(&f.actor, write_body(f));
|
||||
let (me, what) = (self.state.me.clone(), format!("fs_write {} to {}", f.path, f.actor));
|
||||
tokio::spawn(async move {
|
||||
match reply.await {
|
||||
Some(r) if r["type"] != "fs_error" && r["type"] != "undeliverable" => {}
|
||||
r => broker.log(&me, &format!("{what} failed: {}", r.map_or("no reply".to_string(), |r| r.to_string()))),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl preopens::Host for VfsView<'_> {
|
||||
fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<types::Descriptor>, String)>> {
|
||||
let dirs = preopens::Host::get_directories(&mut self.inner)?;
|
||||
for (fd, name) in &dirs {
|
||||
if name == "/actor" {
|
||||
self.state.dirs.insert(fd.rep(), "/actor".to_string());
|
||||
}
|
||||
}
|
||||
Ok(dirs)
|
||||
}
|
||||
}
|
||||
|
||||
impl types::Host for VfsView<'_> {
|
||||
fn convert_error_code(&mut self, err: FsError) -> wasmtime::Result<ErrorCode> {
|
||||
types::Host::convert_error_code(&mut self.inner, err)
|
||||
}
|
||||
|
||||
fn filesystem_error_code(&mut self, err: Resource<wasmtime::Error>) -> wasmtime::Result<Option<ErrorCode>> {
|
||||
types::Host::filesystem_error_code(&mut self.inner, err)
|
||||
}
|
||||
}
|
||||
|
||||
impl HostDescriptor for VfsView<'_> {
|
||||
async fn advise(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize, len: types::Filesize, advice: types::Advice) -> FsResult<()> {
|
||||
if self.virt(&fd).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
HostDescriptor::advise(&mut self.inner, fd, offset, len, advice).await
|
||||
}
|
||||
|
||||
async fn sync_data(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
|
||||
match self.virt(&fd) {
|
||||
Some(Virt::File(f)) => self.asker().flush(&f).await,
|
||||
Some(Virt::Dir { .. }) => Ok(()),
|
||||
None => HostDescriptor::sync_data(&mut self.inner, fd).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_flags(&mut self, fd: Resource<types::Descriptor>) -> FsResult<DescriptorFlags> {
|
||||
match self.virt(&fd) {
|
||||
Some(Virt::File(f)) if f.write => Ok(DescriptorFlags::READ | DescriptorFlags::WRITE),
|
||||
Some(Virt::File(_)) => Ok(DescriptorFlags::READ),
|
||||
Some(Virt::Dir { .. }) => Ok(DescriptorFlags::READ | DescriptorFlags::MUTATE_DIRECTORY),
|
||||
None => HostDescriptor::get_flags(&mut self.inner, fd).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_type(&mut self, fd: Resource<types::Descriptor>) -> FsResult<DescriptorType> {
|
||||
match self.virt(&fd) {
|
||||
Some(Virt::File(_)) => Ok(DescriptorType::RegularFile),
|
||||
Some(Virt::Dir { .. }) => Ok(DescriptorType::Directory),
|
||||
None => HostDescriptor::get_type(&mut self.inner, fd).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_size(&mut self, fd: Resource<types::Descriptor>, size: types::Filesize) -> FsResult<()> {
|
||||
match self.file(&fd) {
|
||||
Some(f) => {
|
||||
let f = f?;
|
||||
if !f.write {
|
||||
return Err(ErrorCode::NotPermitted.into());
|
||||
}
|
||||
f.data.lock().unwrap().resize(size as usize, 0);
|
||||
f.dirty.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
None => HostDescriptor::set_size(&mut self.inner, fd, size).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_times(&mut self, fd: Resource<types::Descriptor>, atim: types::NewTimestamp, mtim: types::NewTimestamp) -> FsResult<()> {
|
||||
if self.virt(&fd).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
HostDescriptor::set_times(&mut self.inner, fd, atim, mtim).await
|
||||
}
|
||||
|
||||
async fn read(&mut self, fd: Resource<types::Descriptor>, len: types::Filesize, offset: types::Filesize) -> FsResult<(Vec<u8>, bool)> {
|
||||
match self.file(&fd) {
|
||||
Some(f) => {
|
||||
let f = f?;
|
||||
let data = f.data.lock().unwrap();
|
||||
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(data.len());
|
||||
let end = start.saturating_add(usize::try_from(len).unwrap_or(usize::MAX)).min(data.len());
|
||||
Ok((data[start..end].to_vec(), start == end))
|
||||
}
|
||||
None => HostDescriptor::read(&mut self.inner, fd, len, offset).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn write(&mut self, fd: Resource<types::Descriptor>, buf: Vec<u8>, offset: types::Filesize) -> FsResult<types::Filesize> {
|
||||
match self.file(&fd) {
|
||||
Some(f) => {
|
||||
let f = f?;
|
||||
if !f.write {
|
||||
return Err(ErrorCode::NotPermitted.into());
|
||||
}
|
||||
write_at(&mut f.data.lock().unwrap(), offset as usize, &buf);
|
||||
f.dirty.store(true, Ordering::SeqCst);
|
||||
Ok(buf.len() as u64)
|
||||
}
|
||||
None => HostDescriptor::write(&mut self.inner, fd, buf, offset).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_directory(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Resource<types::DirectoryEntryStream>> {
|
||||
match self.virt(&fd) {
|
||||
Some(Virt::Dir { actor, path }) => match self.asker().read(&actor, &path).await? {
|
||||
Node::Dir(entries) => {
|
||||
let r = self.inner.table.push(VDir(entries.into_iter()))?;
|
||||
Ok(Resource::new_own(r.rep()))
|
||||
}
|
||||
Node::File(_) => Err(ErrorCode::NotDirectory.into()),
|
||||
},
|
||||
Some(Virt::File(_)) => Err(ErrorCode::NotDirectory.into()),
|
||||
None => {
|
||||
let actor = self.state.dirs.get(&fd.rep()).and_then(|path| actor_root(path)).map(str::to_string);
|
||||
if actor.as_deref().is_some_and(|id| self.actor_serves_fs(id)) {
|
||||
let mut entries = self.native_directory_entries(fd).await?;
|
||||
if !entries.iter().any(|entry| entry.name == "fs") {
|
||||
entries.push(DirectoryEntry { type_: DescriptorType::Directory, name: "fs".to_string() });
|
||||
}
|
||||
let r = self.inner.table.push(VDir(entries.into_iter()))?;
|
||||
Ok(Resource::new_own(r.rep()))
|
||||
} else {
|
||||
HostDescriptor::read_directory(&mut self.inner, fd).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
|
||||
match self.virt(&fd) {
|
||||
Some(Virt::File(f)) => self.asker().flush(&f).await,
|
||||
Some(Virt::Dir { .. }) => Ok(()),
|
||||
None => HostDescriptor::sync(&mut self.inner, fd).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
||||
match self.target(&fd, &path) {
|
||||
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_mkdir", "path": path})).await,
|
||||
None => HostDescriptor::create_directory_at(&mut self.inner, fd, path).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn stat(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorStat> {
|
||||
match self.virt(&fd) {
|
||||
Some(Virt::File(f)) => Ok(stat(DescriptorType::RegularFile, f.data.lock().unwrap().len() as u64)),
|
||||
Some(Virt::Dir { .. }) => Ok(stat(DescriptorType::Directory, 0)),
|
||||
None => HostDescriptor::stat(&mut self.inner, fd).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn stat_at(&mut self, fd: Resource<types::Descriptor>, path_flags: types::PathFlags, path: String) -> FsResult<types::DescriptorStat> {
|
||||
match self.target(&fd, &path) {
|
||||
Some((actor, path)) => Ok(node_stat(&self.asker().read(&actor, &path).await?)),
|
||||
None => HostDescriptor::stat_at(&mut self.inner, fd, path_flags, path).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_times_at(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
path_flags: types::PathFlags,
|
||||
path: String,
|
||||
atim: types::NewTimestamp,
|
||||
mtim: types::NewTimestamp,
|
||||
) -> FsResult<()> {
|
||||
if self.target(&fd, &path).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
HostDescriptor::set_times_at(&mut self.inner, fd, path_flags, path, atim, mtim).await
|
||||
}
|
||||
|
||||
async fn link_at(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
old_path_flags: types::PathFlags,
|
||||
old_path: String,
|
||||
new_descriptor: Resource<types::Descriptor>,
|
||||
new_path: String,
|
||||
) -> FsResult<()> {
|
||||
if self.target(&fd, &old_path).is_some() || self.target(&new_descriptor, &new_path).is_some() {
|
||||
return Err(ErrorCode::Unsupported.into());
|
||||
}
|
||||
HostDescriptor::link_at(&mut self.inner, fd, old_path_flags, old_path, new_descriptor, new_path).await
|
||||
}
|
||||
|
||||
async fn open_at(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
path_flags: types::PathFlags,
|
||||
path: String,
|
||||
oflags: OpenFlags,
|
||||
flags: DescriptorFlags,
|
||||
) -> FsResult<Resource<types::Descriptor>> {
|
||||
match self.target(&fd, &path) {
|
||||
Some((actor, path)) => self.open(actor, path, oflags, flags).await,
|
||||
None => {
|
||||
let child_path = self.state.dirs.get(&fd.rep()).map(|base| join(base, &path));
|
||||
let opened = HostDescriptor::open_at(&mut self.inner, fd, path_flags, path, oflags, flags).await?;
|
||||
if let Some(path) = child_path {
|
||||
if HostDescriptor::get_type(&mut self.inner, Resource::new_borrow(opened.rep())).await? == DescriptorType::Directory {
|
||||
self.state.dirs.insert(opened.rep(), path);
|
||||
}
|
||||
}
|
||||
Ok(opened)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drop(&mut self, fd: Resource<types::Descriptor>) -> wasmtime::Result<()> {
|
||||
if self.virt(&fd).is_some() {
|
||||
if let Virt::File(f) = self.inner.table.delete(Resource::<Virt>::new_own(fd.rep()))?
|
||||
&& f.dirty.load(Ordering::SeqCst)
|
||||
{
|
||||
self.write_back_later(&f);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
self.state.dirs.remove(&fd.rep());
|
||||
HostDescriptor::drop(&mut self.inner, fd)
|
||||
}
|
||||
|
||||
async fn readlink_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<String> {
|
||||
if self.target(&fd, &path).is_some() {
|
||||
return Err(ErrorCode::Invalid.into());
|
||||
}
|
||||
HostDescriptor::readlink_at(&mut self.inner, fd, path).await
|
||||
}
|
||||
|
||||
async fn remove_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
||||
match self.target(&fd, &path) {
|
||||
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_remove", "path": path})).await,
|
||||
None => HostDescriptor::remove_directory_at(&mut self.inner, fd, path).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn rename_at(&mut self, fd: Resource<types::Descriptor>, old_path: String, new_fd: Resource<types::Descriptor>, new_path: String) -> FsResult<()> {
|
||||
match (self.target(&fd, &old_path), self.target(&new_fd, &new_path)) {
|
||||
(None, None) => HostDescriptor::rename_at(&mut self.inner, fd, old_path, new_fd, new_path).await,
|
||||
(Some((a, from)), Some((b, to))) if a == b => {
|
||||
self.asker().change(&a, json!({"type": "fs_rename", "path": from, "to": to})).await
|
||||
}
|
||||
_ => Err(ErrorCode::Unsupported.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn symlink_at(&mut self, fd: Resource<types::Descriptor>, src_path: String, dest_path: String) -> FsResult<()> {
|
||||
if self.target(&fd, &dest_path).is_some() {
|
||||
return Err(ErrorCode::Unsupported.into());
|
||||
}
|
||||
HostDescriptor::symlink_at(&mut self.inner, fd, src_path, dest_path).await
|
||||
}
|
||||
|
||||
async fn unlink_file_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
||||
match self.target(&fd, &path) {
|
||||
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_remove", "path": path})).await,
|
||||
None => HostDescriptor::unlink_file_at(&mut self.inner, fd, path).await,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_via_stream(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize) -> FsResult<Resource<DynInputStream>> {
|
||||
match self.file(&fd) {
|
||||
Some(f) => {
|
||||
let f = f?;
|
||||
let data = f.data.lock().unwrap();
|
||||
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(data.len());
|
||||
let stream: DynInputStream = Box::new(MemoryInputPipe::new(data[start..].to_vec()));
|
||||
std::mem::drop(data);
|
||||
Ok(self.inner.table.push(stream)?)
|
||||
}
|
||||
None => HostDescriptor::read_via_stream(&mut self.inner, fd, offset),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_via_stream(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize) -> FsResult<Resource<DynOutputStream>> {
|
||||
match self.file(&fd) {
|
||||
Some(f) => {
|
||||
let f = f?;
|
||||
if !f.write {
|
||||
return Err(ErrorCode::NotPermitted.into());
|
||||
}
|
||||
let stream: DynOutputStream = Box::new(VWriter { file: f, pos: Some(offset as usize) });
|
||||
Ok(self.inner.table.push(stream)?)
|
||||
}
|
||||
None => HostDescriptor::write_via_stream(&mut self.inner, fd, offset),
|
||||
}
|
||||
}
|
||||
|
||||
fn append_via_stream(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Resource<DynOutputStream>> {
|
||||
match self.file(&fd) {
|
||||
Some(f) => {
|
||||
let f = f?;
|
||||
if !f.write {
|
||||
return Err(ErrorCode::NotPermitted.into());
|
||||
}
|
||||
let stream: DynOutputStream = Box::new(VWriter { file: f, pos: None });
|
||||
Ok(self.inner.table.push(stream)?)
|
||||
}
|
||||
None => HostDescriptor::append_via_stream(&mut self.inner, fd),
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_same_object(&mut self, a: Resource<types::Descriptor>, b: Resource<types::Descriptor>) -> wasmtime::Result<bool> {
|
||||
let key = |v: Virt| match v {
|
||||
Virt::Dir { actor, path } => (actor, path),
|
||||
Virt::File(f) => (f.actor, f.path),
|
||||
};
|
||||
match (self.virt(&a), self.virt(&b)) {
|
||||
(None, None) => HostDescriptor::is_same_object(&mut self.inner, a, b).await,
|
||||
(Some(x), Some(y)) => Ok(key(x) == key(y)),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
async fn metadata_hash(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::MetadataHashValue> {
|
||||
match self.virt(&fd) {
|
||||
Some(Virt::Dir { actor, path }) => Ok(hash(&actor, &path)),
|
||||
Some(Virt::File(f)) => Ok(hash(&f.actor, &f.path)),
|
||||
None => HostDescriptor::metadata_hash(&mut self.inner, fd).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn metadata_hash_at(&mut self, fd: Resource<types::Descriptor>, path_flags: types::PathFlags, path: String) -> FsResult<types::MetadataHashValue> {
|
||||
match self.target(&fd, &path) {
|
||||
Some((actor, path)) => Ok(hash(&actor, &path)),
|
||||
None => HostDescriptor::metadata_hash_at(&mut self.inner, fd, path_flags, path).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HostDirectoryEntryStream for VfsView<'_> {
|
||||
async fn read_directory_entry(&mut self, stream: Resource<types::DirectoryEntryStream>) -> FsResult<Option<DirectoryEntry>> {
|
||||
if let Some(dir) = self.inner.table.get_any_mut(stream.rep()).ok().and_then(|a| a.downcast_mut::<VDir>()) {
|
||||
return Ok(dir.0.next());
|
||||
}
|
||||
HostDirectoryEntryStream::read_directory_entry(&mut self.inner, stream).await
|
||||
}
|
||||
|
||||
fn drop(&mut self, stream: Resource<types::DirectoryEntryStream>) -> wasmtime::Result<()> {
|
||||
let ours = self.inner.table.get_any_mut(stream.rep()).ok().is_some_and(|a| a.is::<VDir>());
|
||||
if ours {
|
||||
self.inner.table.delete(Resource::<VDir>::new_own(stream.rep()))?;
|
||||
return Ok(());
|
||||
}
|
||||
HostDirectoryEntryStream::drop(&mut self.inner, stream)
|
||||
}
|
||||
}
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
fn base64_encode(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||
for chunk in bytes.chunks(3) {
|
||||
let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | (*b as u32) << (16 - 8 * i));
|
||||
for i in 0..4 {
|
||||
out.push(if i <= chunk.len() { B64[(n >> (18 - 6 * i) & 63) as usize] as char } else { '=' });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn base64_decode(text: &str) -> Option<Vec<u8>> {
|
||||
let (mut out, mut n, mut bits) = (Vec::new(), 0u32, 0);
|
||||
for c in text.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
|
||||
n = (n << 6 | B64.iter().position(|b| *b == c)? as u32) & 0xFF_FFFF;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((n >> bits) as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn actor_fs_paths_and_discovery_docs() {
|
||||
assert_eq!(actor_fs_path("/actor/brain/fs"), Some(("brain".to_string(), "/".to_string())));
|
||||
assert_eq!(actor_fs_path("/actor/brain/fs/docs/readme.md"), Some(("brain".to_string(), "/docs/readme.md".to_string())));
|
||||
assert_eq!(actor_fs_path("/actor/brain/public/fs/file"), None);
|
||||
assert_eq!(actor_root("/actor/brain"), Some("brain"));
|
||||
assert_eq!(actor_root("/actor/brain/fs"), None);
|
||||
assert!(declares_actor_fs("//! brain\n//! Actor-served files include fs_read.\nfn main() {}"));
|
||||
assert!(!declares_actor_fs("//! actor without fs support\nfn main() {}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_round_trip() {
|
||||
for s in ["", "a", "ab", "abc", "abcd", "\u{0}\u{ff}binary"] {
|
||||
let enc = base64_encode(s.as_bytes());
|
||||
assert_eq!(base64_decode(&enc).unwrap(), s.as_bytes());
|
||||
}
|
||||
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_resolves_dots() {
|
||||
assert_eq!(join("/a/b", "../c/./d"), "/a/c/d");
|
||||
assert_eq!(join("/", "x/fs"), "/x/fs");
|
||||
assert_eq!(join("/", ".."), "/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! 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
|
||||
//! (`/actor/...`). Then the broker
|
||||
//!
|
||||
//! - sends `fs_changed` to the actors that watch the path (`watch` request),
|
||||
//! - starts or upgrades an actor whose `actor/<id>/actor.wasm` (or folder
|
||||
//! `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.actors_root();
|
||||
let mut pending: HashMap<String, Pending> = HashMap::new();
|
||||
// Actor checks and full passes. Dropping the set (when the host stops) aborts them.
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
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 namespace path (`/actor/...`) of a host path, unless it is outside
|
||||
/// `actor/` or a temporary file of the host.
|
||||
fn namespace(root: &Path, path: &Path) -> Option<String> {
|
||||
let rel = path.strip_prefix(root).ok()?;
|
||||
let mut out = String::from("/actor");
|
||||
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(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/actor");
|
||||
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), &["/w/.lock"]),
|
||||
] {
|
||||
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())),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
//! Background discovery and nonrecursive notifications for installed plugin versions.
|
||||
use crate::Host;
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
impl Host {
|
||||
/// Watches only the plugin root and its immediate folders. Discovery survives watcher failure.
|
||||
pub(crate) fn watch_plugins(&self) -> anyhow::Result<()> {
|
||||
let root = self.0.world.root.join("plugins");
|
||||
let watched_root = root.clone();
|
||||
let (events, mut incoming) = tokio::sync::mpsc::unbounded_channel();
|
||||
let setup = (|| -> anyhow::Result<_> {
|
||||
let mut watcher =
|
||||
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
|
||||
if let Ok(event) = event
|
||||
&& !matches!(event.kind, notify::EventKind::Access(_))
|
||||
&& event.paths.iter().any(|path| {
|
||||
path.parent() == Some(&watched_root)
|
||||
|| path.file_name().is_some_and(|name| name == "plugin.wasm")
|
||||
})
|
||||
{
|
||||
let _ = events.send(());
|
||||
}
|
||||
})?;
|
||||
watcher.watch(&root, RecursiveMode::NonRecursive)?;
|
||||
for entry in std::fs::read_dir(&root)? {
|
||||
let entry = entry?;
|
||||
if entry.file_type()?.is_dir() {
|
||||
watcher.watch(&entry.path(), RecursiveMode::NonRecursive)?;
|
||||
}
|
||||
}
|
||||
Ok(watcher)
|
||||
})();
|
||||
match setup {
|
||||
Ok(watcher) => *self.0.watcher.lock().unwrap() = Some(watcher),
|
||||
Err(error) => {
|
||||
self.0
|
||||
.journal
|
||||
.write("core", "core", "core", &format!("watcher failed {error}"))
|
||||
}
|
||||
}
|
||||
let weak = std::sync::Arc::downgrade(&self.0);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Some(inner) = weak.upgrade() else {
|
||||
break;
|
||||
};
|
||||
let host = Host(inner);
|
||||
// Root events also cover plugin folders created after startup.
|
||||
if let Some(watcher) = host.0.watcher.lock().unwrap().as_mut()
|
||||
&& let Ok(entries) = std::fs::read_dir(&root)
|
||||
{
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().is_ok_and(|kind| kind.is_dir())
|
||||
&& let Err(error) =
|
||||
watcher.watch(&entry.path(), RecursiveMode::NonRecursive)
|
||||
{
|
||||
host.0.journal.write(
|
||||
"core",
|
||||
"core",
|
||||
"core",
|
||||
&format!("watcher failed {error}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _activation = host.0.activations.lock().await;
|
||||
if let Err(error) = host.refresh_graph().await {
|
||||
host.0.journal.write(
|
||||
"core",
|
||||
"core",
|
||||
"core",
|
||||
&format!("discovery failed {error}"),
|
||||
);
|
||||
}
|
||||
drop(_activation);
|
||||
drop(host);
|
||||
if incoming.recv().await.is_none() {
|
||||
break;
|
||||
}
|
||||
while incoming.try_recv().is_ok() {}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
//! Armed plugin-file watches. A Notify permit coalesces changes without losing a later change.
|
||||
use crate::{Host, RpcResult, error};
|
||||
use sausage_vfs::observe::under;
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use tokio::sync::Notify;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) struct Watch {
|
||||
path: String,
|
||||
changed: Arc<Notify>,
|
||||
}
|
||||
pub(crate) type Watches = Arc<Mutex<HashMap<Uuid, Watch>>>;
|
||||
pub(crate) fn changed(watches: &Watches, path: &str) {
|
||||
let targets: Vec<_> = watches
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
// Removing/moving an ancestor also invalidates a watch inside that subtree.
|
||||
.filter(|w| under(path, &w.path) || under(&w.path, path))
|
||||
.map(|w| w.changed.clone())
|
||||
.collect();
|
||||
for changed in targets {
|
||||
changed.notify_one();
|
||||
}
|
||||
}
|
||||
struct Registration {
|
||||
watches: Watches,
|
||||
key: Uuid,
|
||||
}
|
||||
impl Drop for Registration {
|
||||
fn drop(&mut self) {
|
||||
self.watches.lock().unwrap().remove(&self.key);
|
||||
}
|
||||
}
|
||||
fn path(caller: &str, path: &str) -> String {
|
||||
let path = sausage_vfs::resolve("/", path);
|
||||
let path = path.trim_end_matches('/');
|
||||
if path.is_empty() {
|
||||
return "/".into();
|
||||
}
|
||||
if under(path, "/self") {
|
||||
format!("/proc/{caller}{}", &path[5..])
|
||||
} else {
|
||||
path.into()
|
||||
}
|
||||
}
|
||||
impl Host {
|
||||
pub(crate) async fn watch(&self, caller: &str, id: Option<Value>, target: &str) -> RpcResult {
|
||||
let id = id.ok_or_else(|| error(-32600, "watch needs a request ID"))?;
|
||||
let mut ended = self
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get(caller)
|
||||
.filter(|p| p.info.state == "running")
|
||||
.map(|p| p.ended.subscribe())
|
||||
.ok_or_else(|| error(-32600, "watch needs a running caller process"))?;
|
||||
let changed = Arc::new(Notify::new());
|
||||
let registration = Registration {
|
||||
watches: self.0.watches.clone(),
|
||||
key: Uuid::now_v7(),
|
||||
};
|
||||
self.0.watches.lock().unwrap().insert(
|
||||
registration.key,
|
||||
Watch {
|
||||
path: path(caller, target),
|
||||
changed: changed.clone(),
|
||||
},
|
||||
);
|
||||
self.call_notify(caller, &id, "armed", json!({}));
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = ended.wait_for(|ended| *ended) => return Ok(Value::Null),
|
||||
_ = changed.notified() => self.call_notify(caller, &id, "changed", json!({})),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// Pending changes coalesce; a change after consuming the last permit is retained.
|
||||
#[tokio::test]
|
||||
async fn coalescing_and_cleanup() {
|
||||
let watches = Watches::default();
|
||||
let n = Arc::new(Notify::new());
|
||||
let r = Registration {
|
||||
watches: watches.clone(),
|
||||
key: Uuid::now_v7(),
|
||||
};
|
||||
watches.lock().unwrap().insert(
|
||||
r.key,
|
||||
Watch {
|
||||
path: "/mailbox".into(),
|
||||
changed: n.clone(),
|
||||
},
|
||||
);
|
||||
changed(&watches, "/mailboxes/a");
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::ZERO, n.notified())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
changed(&watches, "/mailbox/a");
|
||||
changed(&watches, "/mailbox/b");
|
||||
n.notified().await;
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::ZERO, n.notified())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
changed(&watches, "/mailbox/c");
|
||||
n.notified().await;
|
||||
drop(r);
|
||||
assert!(watches.lock().unwrap().is_empty());
|
||||
changed(&watches, "/mailbox/d");
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::ZERO, n.notified())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn self_alias_and_normalization() {
|
||||
assert_eq!(path("writer", "/self/../self/a/"), "/proc/writer/a");
|
||||
assert_eq!(path("writer", "/selfish/a"), "/selfish/a");
|
||||
assert_eq!(path("writer", "/"), "/");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lifetime_tests {
|
||||
use super::*;
|
||||
/// The real caller's ended event releases the registration, without a cancellation RPC.
|
||||
#[tokio::test]
|
||||
async fn caller_death_removes_watch() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let runtime = crate::runtime::Runtime::new(&temp.path().join("cache")).unwrap();
|
||||
let host = Host::test_world(&temp.path().join("world"), runtime)
|
||||
.await
|
||||
.unwrap();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
host.register_native(
|
||||
"holder",
|
||||
Arc::new(move |ctx, _, _| {
|
||||
let tx = tx.clone();
|
||||
Box::pin(async move {
|
||||
tx.send(ctx.pid.clone()).unwrap();
|
||||
std::future::pending().await
|
||||
})
|
||||
}),
|
||||
);
|
||||
let h = host.clone();
|
||||
let task = tokio::spawn(async move { h.call("holder.hold", json!({})).await });
|
||||
let pid = rx.recv().await.unwrap();
|
||||
let mut watch = Box::pin(host.watch(&pid, Some(json!("watch")), "/mailbox"));
|
||||
assert!(
|
||||
watch
|
||||
.as_mut()
|
||||
.poll(&mut std::task::Context::from_waker(std::task::Waker::noop()))
|
||||
.is_pending()
|
||||
);
|
||||
assert_eq!(host.0.watches.lock().unwrap().len(), 1);
|
||||
host.call("core.kill", json!({"pid":pid})).await.unwrap();
|
||||
watch.await.unwrap();
|
||||
assert!(host.0.watches.lock().unwrap().is_empty());
|
||||
assert!(task.await.unwrap().is_err());
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
+284
-46
@@ -1,71 +1,309 @@
|
||||
//! World folders and atomic build output helpers.
|
||||
use anyhow::{Result, bail};
|
||||
use std::path::{Path, PathBuf};
|
||||
//! On-disk layout of a Sausage world. The folder is the source of truth:
|
||||
//! `sausage run` boots every actor that it finds in `actor/`, and then
|
||||
//! watches the folder (`watch.rs`).
|
||||
//!
|
||||
//! ```text
|
||||
//! <world>/
|
||||
//! config/ shared configuration, writable by every actor
|
||||
//! actor/<id>/
|
||||
//! actor.wasm the current version
|
||||
//! actor.json version, running, subscriptions, tapped, watches (broker)
|
||||
//! source/ source of the current version
|
||||
//! public/ written by the actor, read-only for others
|
||||
//! data/ state, writable only by the actor
|
||||
//! stderr.log stderr of the actor and notes of the broker
|
||||
//! versions/<n>/ every version that was installed: actor.wasm, source/, meta.json
|
||||
//! ```
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Name prefix of the host's temporary files and folders. Copies skip them.
|
||||
pub const TMP_PREFIX: &str = ".sausage-tmp-";
|
||||
|
||||
/// IDs of the actors that the host implements natively. `fs` sends the
|
||||
/// requests for actor-served files (`vfs.rs`).
|
||||
pub const NATIVE: &[&str] = &["broker", "console", "cargo", "fs"];
|
||||
|
||||
#[derive(Clone)]
|
||||
/// The absolute host folder shared by all processes.
|
||||
pub struct World {
|
||||
/// The absolute path of the world on the host.
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
/// `actor/<id>/actor.json`.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct ActorMeta {
|
||||
/// The installed version. `None` for native actors and for a new
|
||||
/// `actor.wasm` that the broker has not installed yet.
|
||||
#[serde(default)]
|
||||
pub version: Option<u64>,
|
||||
#[serde(default = "yes")]
|
||||
pub running: bool,
|
||||
/// The actors whose published messages this actor receives.
|
||||
#[serde(default)]
|
||||
pub subscriptions: BTreeSet<String>,
|
||||
/// Whether this actor gets a copy of every routed message (`tap`).
|
||||
#[serde(default)]
|
||||
pub tapped: bool,
|
||||
/// Namespace paths whose changes this actor gets as `fs_changed` (`watch`).
|
||||
#[serde(default)]
|
||||
pub watches: BTreeSet<String>,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// `versions/<n>/meta.json`.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct VersionMeta {
|
||||
pub version: u64,
|
||||
/// Path of the `.wasm` file, as the requester gave it.
|
||||
pub wasm: String,
|
||||
/// Path of the source folder, as the requester gave it.
|
||||
pub source: Option<String>,
|
||||
/// The actor that built the `.wasm` file.
|
||||
pub built_by: Option<String>,
|
||||
/// The actor that requested the version ("scan" for a file found on disk).
|
||||
pub requested_by: String,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
pub fn now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
impl World {
|
||||
/// Validate reserved root names and resolve the world path.
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
for name in ["proc", "run", "self"] {
|
||||
if path.join(name).symlink_metadata().is_ok() {
|
||||
bail!("reserved world path: {name}");
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
pub fn config_dir(&self) -> PathBuf {
|
||||
self.root.join("config")
|
||||
}
|
||||
pub fn actors_root(&self) -> PathBuf {
|
||||
self.root.join("actor")
|
||||
}
|
||||
pub fn actor_dir(&self, id: &str) -> PathBuf {
|
||||
self.actors_root().join(id)
|
||||
}
|
||||
pub fn wasm_path(&self, id: &str) -> PathBuf {
|
||||
self.actor_dir(id).join("actor.wasm")
|
||||
}
|
||||
pub fn public_dir(&self, id: &str) -> PathBuf {
|
||||
self.actor_dir(id).join("public")
|
||||
}
|
||||
pub fn source_dir(&self, id: &str) -> PathBuf {
|
||||
self.actor_dir(id).join("source")
|
||||
}
|
||||
pub fn data_dir(&self, id: &str) -> PathBuf {
|
||||
self.actor_dir(id).join("data")
|
||||
}
|
||||
pub fn versions_dir(&self, id: &str) -> PathBuf {
|
||||
self.actor_dir(id).join("versions")
|
||||
}
|
||||
pub fn version_dir(&self, id: &str, v: u64) -> PathBuf {
|
||||
self.versions_dir(id).join(v.to_string())
|
||||
}
|
||||
/// Private scratch space of the broker (candidate copies, seeds).
|
||||
pub fn scratch_dir(&self) -> PathBuf {
|
||||
self.data_dir("broker").join("scratch")
|
||||
}
|
||||
|
||||
pub fn exists(&self) -> bool {
|
||||
self.actors_root().is_dir()
|
||||
}
|
||||
|
||||
/// Create an empty world with the folders of the native actors.
|
||||
pub fn create(root: &Path) -> Result<World> {
|
||||
let world = World::new(std::path::absolute(root)?);
|
||||
if world.exists() {
|
||||
bail!("{} is already a Sausage world", root.display());
|
||||
}
|
||||
for id in NATIVE {
|
||||
world.ensure_actor_dirs(id)?;
|
||||
}
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
pub fn ensure_actor_dirs(&self, id: &str) -> Result<()> {
|
||||
std::fs::create_dir_all(self.public_dir(id))?;
|
||||
std::fs::create_dir_all(self.data_dir(id))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// IDs of every folder in `actor/`, sorted.
|
||||
pub fn actor_ids(&self) -> Result<Vec<String>> {
|
||||
let mut ids: Vec<String> = std::fs::read_dir(self.actors_root())?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.filter(|id| valid_id(id))
|
||||
.collect();
|
||||
ids.sort();
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
pub fn read_meta(&self, id: &str) -> ActorMeta {
|
||||
std::fs::read_to_string(self.actor_dir(id).join("actor.json"))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(ActorMeta { running: true, ..Default::default() })
|
||||
}
|
||||
|
||||
pub fn write_meta(&self, id: &str, meta: &ActorMeta) -> Result<()> {
|
||||
write_atomic(&self.actor_dir(id).join("actor.json"), serde_json::to_string_pretty(meta)?.as_bytes())
|
||||
}
|
||||
|
||||
/// Make `actor.wasm` and `source/` a copy of `versions/<version>/`,
|
||||
/// unless `actor.wasm` already is that version (it came from there).
|
||||
pub fn install_files(&self, id: &str, version: u64) -> Result<()> {
|
||||
let dir = self.version_dir(id, version);
|
||||
self.ensure_actor_dirs(id)?;
|
||||
let wasm = std::fs::read(dir.join("actor.wasm"))?;
|
||||
if std::fs::read(self.wasm_path(id)).ok().as_ref() == Some(&wasm) {
|
||||
return Ok(());
|
||||
}
|
||||
write_atomic(&self.wasm_path(id), &wasm)?;
|
||||
replace_tree(&dir.join("source"), &self.source_dir(id))
|
||||
}
|
||||
|
||||
/// Version numbers in `versions/`, ascending.
|
||||
pub fn versions(&self, id: &str) -> Vec<u64> {
|
||||
let mut v: Vec<u64> = std::fs::read_dir(self.versions_dir(id))
|
||||
.map(|rd| rd.filter_map(|e| e.ok()?.file_name().to_str()?.parse().ok()).collect())
|
||||
.unwrap_or_default();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
pub fn next_version(&self, id: &str) -> u64 {
|
||||
self.versions(id).last().map_or(1, |v| v + 1)
|
||||
}
|
||||
|
||||
/// Map a path in an actor's namespace to a host path.
|
||||
///
|
||||
/// - `/actor/<id>/...` and `/config/...` are world paths.
|
||||
/// - `/public/...` and `/data/...` belong to `sender`.
|
||||
/// - Native senders (the console) can also give absolute host paths.
|
||||
pub fn resolve(&self, sender: &str, path: &str) -> Result<PathBuf> {
|
||||
let under = |base: PathBuf, rest: &str| -> Result<PathBuf> {
|
||||
let mut out = base;
|
||||
for c in Path::new(rest).components() {
|
||||
match c {
|
||||
Component::Normal(n) => out.push(n),
|
||||
Component::CurDir | Component::RootDir => {}
|
||||
_ => bail!("path must not contain '..': {path}"),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
let prefixed = |prefix: &str| {
|
||||
path.strip_prefix(prefix).filter(|rest| rest.is_empty() || rest.starts_with('/'))
|
||||
};
|
||||
if let Some(rest) = prefixed("/actor") {
|
||||
return under(self.actors_root(), rest);
|
||||
}
|
||||
Ok(Self {
|
||||
root: path.canonicalize()?,
|
||||
})
|
||||
}
|
||||
/// Create the folders and empty boot configuration of a new world.
|
||||
pub fn create(path: &Path) -> Result<Self> {
|
||||
if path.exists() {
|
||||
bail!("world already exists: {}", path.display());
|
||||
if let Some(rest) = prefixed("/config") {
|
||||
return under(self.config_dir(), rest);
|
||||
}
|
||||
for dir in ["plugins", "state/core", "config"] {
|
||||
std::fs::create_dir_all(path.join(dir))?;
|
||||
if let Some(rest) = prefixed("/public") {
|
||||
return under(self.public_dir(sender), rest);
|
||||
}
|
||||
std::fs::write(path.join("config/boot.yaml"), "[]\n")?;
|
||||
Self::open(path)
|
||||
if let Some(rest) = prefixed("/data") {
|
||||
return under(self.data_dir(sender), rest);
|
||||
}
|
||||
if NATIVE.contains(&sender) && Path::new(path).is_absolute() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
bail!("cannot resolve path {path} for {sender}: use /actor/<id>/..., /config/..., /public/... or /data/...")
|
||||
}
|
||||
/// Return the folder that contains a plugin component.
|
||||
pub fn plugin_dir(&self, name: &str) -> PathBuf {
|
||||
self.root.join("plugins").join(name)
|
||||
}
|
||||
/// Return the persistent state folder of a plugin.
|
||||
pub fn state_dir(&self, name: &str) -> PathBuf {
|
||||
self.root.join("state").join(name)
|
||||
|
||||
/// The path of a host file in the global filesystem, if it has one.
|
||||
pub fn global_path(&self, host: &Path) -> Option<String> {
|
||||
let rel = host.strip_prefix(self.actors_root()).ok()?;
|
||||
Some(format!("/actor/{}", rel.display()))
|
||||
}
|
||||
}
|
||||
/// Write a temporary sibling and rename it over the destination.
|
||||
|
||||
/// Write a file so that readers never see a partial version.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
let dir = path.parent().context("path has no parent")?;
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
|
||||
let tmp = dir.join(format!("{TMP_PREFIX}host-{}-{name}", std::process::id()));
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
std::fs::rename(tmp, path)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
/// Replace a build copy with a source tree, without target folders.
|
||||
pub fn replace_source(from: &Path, to: &Path) -> Result<()> {
|
||||
if to.exists() {
|
||||
std::fs::remove_dir_all(to)?;
|
||||
}
|
||||
copy_tree(from, to)
|
||||
|
||||
/// Folders that a copy of a source folder leaves out.
|
||||
const BUILD_DIRS: &[&str] = &["target", ".git"];
|
||||
|
||||
/// Copy a directory tree, except uncommitted files.
|
||||
pub fn copy_tree(from: &Path, to: &Path) -> Result<()> {
|
||||
copy_filtered(from, to, &[])
|
||||
}
|
||||
fn copy_tree(from: &Path, to: &Path) -> Result<()> {
|
||||
|
||||
fn copy_filtered(from: &Path, to: &Path, skip: &[&str]) -> Result<()> {
|
||||
std::fs::create_dir_all(to)?;
|
||||
for entry in std::fs::read_dir(from)? {
|
||||
for entry in std::fs::read_dir(from).with_context(|| format!("read {}", from.display()))? {
|
||||
let entry = entry?;
|
||||
if entry.file_name() == "target" {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name.starts_with(TMP_PREFIX) {
|
||||
continue;
|
||||
}
|
||||
if entry.file_type()?.is_dir() {
|
||||
copy_tree(&entry.path(), &to.join(entry.file_name()))?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), to.join(entry.file_name()))?;
|
||||
let dst = to.join(&*name);
|
||||
let ty = entry.file_type()?;
|
||||
if ty.is_dir() && !skip.contains(&&*name) {
|
||||
copy_filtered(&entry.path(), &dst, skip)?;
|
||||
} else if ty.is_file() {
|
||||
std::fs::copy(entry.path(), &dst)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace the directory `dst` with a copy of `src` (an empty folder if `src`
|
||||
/// does not exist): copy to a sibling, then swap with two renames.
|
||||
pub fn replace_tree(src: &Path, dst: &Path) -> Result<()> {
|
||||
replace_filtered(src, dst, &[])
|
||||
}
|
||||
|
||||
/// Like `replace_tree`, for a source folder: leaves out `target/` and `.git/`.
|
||||
pub fn replace_source(src: &Path, dst: &Path) -> Result<()> {
|
||||
replace_filtered(src, dst, BUILD_DIRS)
|
||||
}
|
||||
|
||||
fn replace_filtered(src: &Path, dst: &Path, skip: &[&str]) -> Result<()> {
|
||||
let parent = dst.parent().context("path has no parent")?;
|
||||
let name = dst.file_name().and_then(|n| n.to_str()).unwrap_or("dir");
|
||||
let fresh = parent.join(format!("{TMP_PREFIX}new-{name}"));
|
||||
let old = parent.join(format!("{TMP_PREFIX}old-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&fresh);
|
||||
let _ = std::fs::remove_dir_all(&old);
|
||||
if src.is_dir() {
|
||||
copy_filtered(src, &fresh, skip)?;
|
||||
} else {
|
||||
std::fs::create_dir_all(&fresh)?;
|
||||
}
|
||||
if dst.exists() {
|
||||
std::fs::rename(dst, &old)?;
|
||||
}
|
||||
std::fs::rename(&fresh, dst)?;
|
||||
let _ = std::fs::remove_dir_all(&old);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Generated
+105
@@ -0,0 +1,105 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toolbox"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "activation-guest"
|
||||
name = "toolbox"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,180 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn send(out: &mut impl Write, message: Value) {
|
||||
writeln!(out, "{message}").unwrap();
|
||||
out.flush().unwrap();
|
||||
}
|
||||
|
||||
fn reply(out: &mut impl Write, message: &Value, body: Value) {
|
||||
send(
|
||||
out,
|
||||
json!({"to": message["from"], "reply_to": message["id"], "body": body}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Connect to `address`, send one line, and return the line that comes back.
|
||||
fn tcp_line(address: &str, line: &str) -> io::Result<String> {
|
||||
let mut stream = std::net::TcpStream::connect(address)?;
|
||||
writeln!(stream, "{line}")?;
|
||||
let mut answer = String::new();
|
||||
io::BufReader::new(stream).read_line(&mut answer)?;
|
||||
Ok(answer.trim_end().to_string())
|
||||
}
|
||||
|
||||
fn io_error(error: io::Error) -> Value {
|
||||
json!({"type": "error", "error": error.to_string()})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let actor = std::env::var("SAUSAGE_ACTOR").unwrap_or_default();
|
||||
let version = std::env::var("SAUSAGE_VERSION").unwrap_or_else(|_| "candidate".to_string());
|
||||
let candidate = std::env::var_os("SAUSAGE_CANDIDATE").is_some();
|
||||
if actor == "crashloop" && version == "2" && !candidate {
|
||||
std::fs::write("/data/state", "bad-v2").unwrap();
|
||||
panic!("version 2 crashes on startup");
|
||||
}
|
||||
|
||||
let mut open = HashMap::<String, std::fs::File>::new();
|
||||
// Files that this actor serves at /actor/<id>/fs/ (path -> text).
|
||||
let mut served = std::collections::BTreeMap::<String, String>::new();
|
||||
let stdin = io::stdin();
|
||||
let mut out = io::stdout().lock();
|
||||
let mut emit_shutdown = false;
|
||||
if actor == "spawn-race" && !candidate {
|
||||
let stamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::fs::write(format!("/data/start-{stamp}"), "started").unwrap();
|
||||
}
|
||||
for line in stdin.lock().lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let Ok(message) = serde_json::from_str::<Value>(&line) else {
|
||||
continue;
|
||||
};
|
||||
let body = &message["body"];
|
||||
let path = body["path"].as_str().unwrap_or_default().to_string();
|
||||
let text = body["text"].as_str().unwrap_or_default();
|
||||
let response = match body["type"].as_str() {
|
||||
Some("shutdown") => {
|
||||
if actor == "queue" {
|
||||
let _ = std::fs::write("/data/shutdown", "stopping");
|
||||
std::thread::sleep(Duration::from_millis(800));
|
||||
}
|
||||
if emit_shutdown {
|
||||
send(
|
||||
&mut out,
|
||||
json!({"to": "console", "reply_to": message["id"], "body": {"type": "shutdown-reply"}}),
|
||||
);
|
||||
send(&mut out, json!({"body": {"type": "shutdown-publish"}}));
|
||||
}
|
||||
break;
|
||||
}
|
||||
Some("echo") => json!({"type": "echo", "value": body["value"]}),
|
||||
Some("fs_read") => {
|
||||
let dir = if path == "/" { "/".to_string() } else { format!("{path}/") };
|
||||
let mut names: Vec<String> = served
|
||||
.keys()
|
||||
.filter_map(|k| k.strip_prefix(&dir))
|
||||
.map(|rest| match rest.split_once('/') {
|
||||
Some((first, _)) => format!("{first}/"),
|
||||
None => rest.to_string(),
|
||||
})
|
||||
.collect();
|
||||
names.dedup();
|
||||
if let Some(text) = served.get(&path) {
|
||||
json!({"type": "fs_file", "text": text})
|
||||
} else if path == "/" || !names.is_empty() {
|
||||
let entries: Vec<Value> = names
|
||||
.iter()
|
||||
.map(|n| match n.strip_suffix('/') {
|
||||
Some(d) => json!({"name": d, "type": "dir"}),
|
||||
None => json!({"name": n, "type": "file"}),
|
||||
})
|
||||
.collect();
|
||||
json!({"type": "fs_dir", "entries": entries})
|
||||
} else {
|
||||
json!({"type": "fs_error", "errno": "no-entry"})
|
||||
}
|
||||
}
|
||||
Some("fs_write") => {
|
||||
served.insert(path.clone(), text.to_string());
|
||||
json!({"type": "fs_done"})
|
||||
}
|
||||
Some("fs_remove") => match served.remove(&path) {
|
||||
Some(_) => json!({"type": "fs_done"}),
|
||||
None => json!({"type": "fs_error", "errno": "no-entry"}),
|
||||
},
|
||||
Some("publish") => {
|
||||
send(
|
||||
&mut out,
|
||||
json!({"body": {"type": "published", "value": body["value"]}}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Some("version") => json!({"type": "version", "version": version}),
|
||||
Some("ordered") => json!({"type": "ordered", "n": body["n"], "version": version}),
|
||||
Some("write") => match std::fs::write(&path, text) {
|
||||
Ok(()) => json!({"type": "ok"}),
|
||||
Err(error) => io_error(error),
|
||||
},
|
||||
Some("mkdir") => match std::fs::create_dir_all(&path) {
|
||||
Ok(()) => json!({"type": "ok"}),
|
||||
Err(error) => io_error(error),
|
||||
},
|
||||
Some("rename") => match std::fs::rename(&path, body["to"].as_str().unwrap_or_default())
|
||||
{
|
||||
Ok(()) => json!({"type": "ok"}),
|
||||
Err(error) => io_error(error),
|
||||
},
|
||||
Some("open") => {
|
||||
let result = (|| -> io::Result<()> {
|
||||
let mut file = std::fs::File::create(&path)?;
|
||||
file.write_all(text.as_bytes())?;
|
||||
open.insert(path.clone(), file);
|
||||
Ok(())
|
||||
})();
|
||||
match result {
|
||||
Ok(()) => json!({"type": "opened"}),
|
||||
Err(error) => io_error(error),
|
||||
}
|
||||
}
|
||||
Some("close") => json!({"type": "closed", "closed": open.remove(&path).is_some()}),
|
||||
Some("arm-shutdown") => {
|
||||
emit_shutdown = true;
|
||||
json!({"type": "armed"})
|
||||
}
|
||||
Some("ignore-stdin") => json!({"type": "ignoring"}),
|
||||
Some("tcp") => match tcp_line(body["address"].as_str().unwrap_or_default(), text) {
|
||||
Ok(line) => json!({"type": "tcp", "line": line}),
|
||||
Err(error) => io_error(error),
|
||||
},
|
||||
Some("read") => match std::fs::read_to_string(&path) {
|
||||
Ok(text) => json!({"type": "content", "text": text}),
|
||||
Err(error) => io_error(error),
|
||||
},
|
||||
Some("list") => match std::fs::read_dir(&path) {
|
||||
Ok(entries) => {
|
||||
let mut names: Vec<String> = entries
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| entry.file_name().to_string_lossy().to_string())
|
||||
.collect();
|
||||
names.sort();
|
||||
json!({"type": "entries", "names": names})
|
||||
}
|
||||
Err(error) => io_error(error),
|
||||
},
|
||||
_ => json!({"type": "error", "error": "unknown message"}),
|
||||
};
|
||||
reply(&mut out, &message, response);
|
||||
if body["type"] == "ignore-stdin" {
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
//! Integration tests for the agent's inference and restart behavior.
|
||||
|
||||
#[allow(dead_code)]
|
||||
mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{create_world, receive_message, receive_reply, request, source_with_test, start};
|
||||
use sausage::{Envelope, Host, World};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::task::{JoinHandle, JoinSet};
|
||||
|
||||
const APIS: [Api; 2] = [Api::OpenAi, Api::Anthropic];
|
||||
const TEST_TOKEN: &str = "fake-token";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Api {
|
||||
OpenAi,
|
||||
Anthropic,
|
||||
}
|
||||
|
||||
impl Api {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAi => "openai-responses",
|
||||
Self::Anthropic => "anthropic-messages",
|
||||
}
|
||||
}
|
||||
|
||||
fn path(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAi => "/v1/responses",
|
||||
Self::Anthropic => "/v1/messages",
|
||||
}
|
||||
}
|
||||
|
||||
fn text_response(self, text: &str) -> Value {
|
||||
match self {
|
||||
Self::OpenAi => {
|
||||
json!({"output": [{"type": "message", "content": [{"type": "output_text", "text": text}]}]})
|
||||
}
|
||||
Self::Anthropic => json!({"content": [{"type": "text", "text": text}]}),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_response(self, id: &str, name: &str, arguments: &Value) -> Value {
|
||||
match self {
|
||||
Self::OpenAi => {
|
||||
json!({"output": [{"type": "function_call", "call_id": id, "name": name, "arguments": arguments.to_string()}]})
|
||||
}
|
||||
Self::Anthropic => {
|
||||
json!({"content": [{"type": "tool_use", "id": id, "name": name, "input": arguments}]})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_request(self, request: &HttpRequest) {
|
||||
assert_eq!(request.method, "POST");
|
||||
assert_eq!(request.path, self.path());
|
||||
assert_eq!(request.headers["content-type"], "application/json");
|
||||
assert_eq!(request.body["model"], "fake-model");
|
||||
match self {
|
||||
Self::OpenAi => assert_eq!(request.headers["authorization"], "Bearer fake-token"),
|
||||
Self::Anthropic => {
|
||||
assert_eq!(request.headers["x-api-key"], TEST_TOKEN);
|
||||
assert_eq!(request.headers["anthropic-version"], "2023-06-01");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HttpRequest {
|
||||
method: String,
|
||||
path: String,
|
||||
headers: HashMap<String, String>,
|
||||
body: Value,
|
||||
}
|
||||
|
||||
enum ReplyPlan {
|
||||
Send(Value),
|
||||
Hold(Value, oneshot::Receiver<()>),
|
||||
}
|
||||
|
||||
struct FakeServer {
|
||||
base_url: String,
|
||||
plans: mpsc::Sender<ReplyPlan>,
|
||||
requests: mpsc::Receiver<HttpRequest>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl FakeServer {
|
||||
async fn start() -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (plans, mut plan_rx) = mpsc::channel(16);
|
||||
let (request_tx, requests) = mpsc::channel(16);
|
||||
let task = tokio::spawn(async move {
|
||||
let mut handlers = JoinSet::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
accepted = listener.accept() => {
|
||||
let (mut stream, _) = accepted.expect("fake listener accept failed");
|
||||
let request = read_http_request(&mut stream).await;
|
||||
let Some(plan) = plan_rx.recv().await else { break };
|
||||
if request_tx.send(request).await.is_err() {
|
||||
break;
|
||||
}
|
||||
handlers.spawn(async move {
|
||||
let (body, release) = match plan {
|
||||
ReplyPlan::Send(body) => (body, None),
|
||||
ReplyPlan::Hold(body, release) => (body, Some(release)),
|
||||
};
|
||||
if let Some(release) = release {
|
||||
let _ = release.await;
|
||||
}
|
||||
write_http_response(stream, body).await;
|
||||
});
|
||||
}
|
||||
Some(_) = handlers.join_next(), if !handlers.is_empty() => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
Self {
|
||||
base_url: format!("http://127.0.0.1:{}", address.port()),
|
||||
plans,
|
||||
requests,
|
||||
task,
|
||||
}
|
||||
}
|
||||
|
||||
async fn answer(&self, body: Value) {
|
||||
self.plans.send(ReplyPlan::Send(body)).await.unwrap();
|
||||
}
|
||||
|
||||
async fn hold(&self, body: Value) -> oneshot::Sender<()> {
|
||||
let (release, wait) = oneshot::channel();
|
||||
self.plans.send(ReplyPlan::Hold(body, wait)).await.unwrap();
|
||||
release
|
||||
}
|
||||
|
||||
async fn next_request(&mut self) -> HttpRequest {
|
||||
tokio::time::timeout(Duration::from_secs(20), self.requests.recv())
|
||||
.await
|
||||
.expect("timed out waiting for fake inference request")
|
||||
.expect("fake server request channel closed")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FakeServer {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_http_request(stream: &mut TcpStream) -> HttpRequest {
|
||||
let mut received = Vec::new();
|
||||
let mut chunk = [0; 4096];
|
||||
let header_end = loop {
|
||||
let count = stream.read(&mut chunk).await.expect("read HTTP request");
|
||||
assert_ne!(count, 0, "client closed before HTTP headers arrived");
|
||||
received.extend_from_slice(&chunk[..count]);
|
||||
if let Some(end) = received.windows(4).position(|part| part == b"\r\n\r\n") {
|
||||
break end;
|
||||
}
|
||||
};
|
||||
|
||||
let header_text = String::from_utf8_lossy(&received[..header_end]);
|
||||
let mut lines = header_text.split("\r\n");
|
||||
let mut request_line = lines.next().unwrap().split_whitespace();
|
||||
let method = request_line.next().unwrap().to_string();
|
||||
let path = request_line.next().unwrap().to_string();
|
||||
let mut headers = HashMap::new();
|
||||
for line in lines {
|
||||
if let Some((name, value)) = line.split_once(':') {
|
||||
headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let body_start = header_end + 4;
|
||||
let request_body = if let Some(length) = headers.get("content-length") {
|
||||
let body_length = length.parse::<usize>().unwrap();
|
||||
while received.len() < body_start + body_length {
|
||||
let count = stream.read(&mut chunk).await.expect("read HTTP body");
|
||||
assert_ne!(count, 0, "client closed before HTTP body arrived");
|
||||
received.extend_from_slice(&chunk[..count]);
|
||||
}
|
||||
received[body_start..body_start + body_length].to_vec()
|
||||
} else {
|
||||
assert!(
|
||||
headers
|
||||
.get("transfer-encoding")
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("chunked")),
|
||||
"request has neither Content-Length nor chunked transfer encoding: {headers:?}"
|
||||
);
|
||||
read_chunked_body(stream, &mut received, body_start).await
|
||||
};
|
||||
let body = serde_json::from_slice(&request_body).unwrap();
|
||||
HttpRequest {
|
||||
method,
|
||||
path,
|
||||
headers,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_chunked_body(
|
||||
stream: &mut TcpStream,
|
||||
received: &mut Vec<u8>,
|
||||
mut offset: usize,
|
||||
) -> Vec<u8> {
|
||||
let mut body = Vec::new();
|
||||
loop {
|
||||
let line_end = loop {
|
||||
if let Some(end) = received[offset..]
|
||||
.windows(2)
|
||||
.position(|part| part == b"\r\n")
|
||||
{
|
||||
break offset + end;
|
||||
}
|
||||
let mut chunk = [0; 4096];
|
||||
let count = stream.read(&mut chunk).await.expect("read chunk size");
|
||||
assert_ne!(count, 0, "client closed before chunk size arrived");
|
||||
received.extend_from_slice(&chunk[..count]);
|
||||
};
|
||||
let size = String::from_utf8_lossy(&received[offset..line_end]);
|
||||
let size = size.split(';').next().unwrap().trim();
|
||||
let size = usize::from_str_radix(size, 16).unwrap();
|
||||
offset = line_end + 2;
|
||||
if size == 0 {
|
||||
return body;
|
||||
}
|
||||
while received.len() < offset + size + 2 {
|
||||
let mut chunk = [0; 4096];
|
||||
let count = stream.read(&mut chunk).await.expect("read chunk data");
|
||||
assert_ne!(count, 0, "client closed before chunk data arrived");
|
||||
received.extend_from_slice(&chunk[..count]);
|
||||
}
|
||||
body.extend_from_slice(&received[offset..offset + size]);
|
||||
assert_eq!(&received[offset + size..offset + size + 2], b"\r\n");
|
||||
offset += size + 2;
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_http_response(mut stream: TcpStream, body: Value) {
|
||||
let body = serde_json::to_vec(&body).unwrap();
|
||||
let head = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let mut response = head.into_bytes();
|
||||
response.extend_from_slice(&body);
|
||||
let _ = stream.write_all(&response).await;
|
||||
let _ = stream.shutdown().await;
|
||||
}
|
||||
|
||||
fn agent_wasm() -> &'static PathBuf {
|
||||
static WASM: OnceLock<PathBuf> = OnceLock::new();
|
||||
WASM.get_or_init(|| {
|
||||
let root = common::repo_root();
|
||||
let output = Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
"-p",
|
||||
"agent",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"--target-dir",
|
||||
"target/test-actors",
|
||||
])
|
||||
.current_dir(&root)
|
||||
.output()
|
||||
.expect("run cargo to build the agent actor");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"building agent failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let wasm = root.join("target/test-actors/wasm32-wasip2/release/agent.wasm");
|
||||
assert!(
|
||||
wasm.is_file(),
|
||||
"agent build did not create {}",
|
||||
wasm.display()
|
||||
);
|
||||
wasm
|
||||
})
|
||||
}
|
||||
|
||||
async fn start_agent(
|
||||
api: Api,
|
||||
fake: &FakeServer,
|
||||
label: &str,
|
||||
) -> (World, Host, mpsc::Receiver<Envelope>) {
|
||||
let world = create_world(label);
|
||||
let data = world.data_dir("agent");
|
||||
std::fs::create_dir_all(&data).unwrap();
|
||||
let config = json!({"api": api.name(), "base_url": fake.base_url, "model": "fake-model", "token": TEST_TOKEN});
|
||||
std::fs::write(
|
||||
data.join("config.json"),
|
||||
serde_json::to_vec(&config).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let source = source_with_test(
|
||||
&world,
|
||||
"agent-candidate",
|
||||
json!({"steps": [{"send": {"type": "text", "text": "candidate"}, "expect": {"type": "text"}}]}),
|
||||
);
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let spawned = common::spawn_actor(&host, &mut mailbox, "agent", agent_wasm(), &source).await;
|
||||
assert_eq!(
|
||||
spawned.body["type"], "spawned",
|
||||
"agent spawn failed: {spawned:?}"
|
||||
);
|
||||
let subscribed = request(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
json!({"type": "subscribe", "actor": "agent"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(subscribed.body["type"], "subscribed");
|
||||
(world, host, mailbox)
|
||||
}
|
||||
|
||||
fn send_text(host: &Host, text: &str) -> String {
|
||||
host.broker.send(
|
||||
"console",
|
||||
Some("agent"),
|
||||
None,
|
||||
None,
|
||||
json!({"type": "text", "text": text}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn assert_published_text(mailbox: &mut mpsc::Receiver<Envelope>, text: &str) {
|
||||
let message = receive_message(mailbox, Duration::from_secs(20)).await;
|
||||
assert_eq!(message.from, "agent");
|
||||
assert_eq!(message.to, "console");
|
||||
assert_eq!(message.reply_to, None);
|
||||
assert_eq!(message.body, json!({"type": "text", "text": text}));
|
||||
}
|
||||
|
||||
fn history(world: &World) -> Vec<Value> {
|
||||
std::fs::read_to_string(world.data_dir("agent").join("history.jsonl"))
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_history_tools_have_results(history: &[Value]) {
|
||||
let calls: Vec<_> = history
|
||||
.iter()
|
||||
.filter(|entry| entry["kind"] == "tool_call")
|
||||
.collect();
|
||||
let results: Vec<_> = history
|
||||
.iter()
|
||||
.filter(|entry| entry["kind"] == "tool_result")
|
||||
.collect();
|
||||
assert_eq!(calls.len(), results.len());
|
||||
for call in calls {
|
||||
assert!(
|
||||
results.iter().any(|result| result["id"] == call["id"]),
|
||||
"tool call has no result: {call}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_tool_pair(
|
||||
request: &HttpRequest,
|
||||
api: Api,
|
||||
id: &str,
|
||||
name: &str,
|
||||
arguments: &Value,
|
||||
) -> String {
|
||||
match api {
|
||||
Api::OpenAi => {
|
||||
let input = request.body["input"].as_array().unwrap();
|
||||
let call_index = input
|
||||
.iter()
|
||||
.position(|item| item["type"] == "function_call")
|
||||
.unwrap();
|
||||
let result_index = input
|
||||
.iter()
|
||||
.position(|item| item["type"] == "function_call_output")
|
||||
.unwrap();
|
||||
assert!(call_index < result_index);
|
||||
let call = &input[call_index];
|
||||
assert_eq!(call["call_id"], id);
|
||||
assert_eq!(call["name"], name);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(call["arguments"].as_str().unwrap()).unwrap(),
|
||||
*arguments
|
||||
);
|
||||
let result = &input[result_index];
|
||||
assert_eq!(result["call_id"], id);
|
||||
result["output"].as_str().unwrap().to_string()
|
||||
}
|
||||
Api::Anthropic => {
|
||||
let messages = request.body["messages"].as_array().unwrap();
|
||||
let mut call_position = None;
|
||||
let mut result_position = None;
|
||||
let mut call = None;
|
||||
let mut result = None;
|
||||
for (index, message) in messages.iter().enumerate() {
|
||||
let Some(blocks) = message["content"].as_array() else {
|
||||
continue;
|
||||
};
|
||||
for block in blocks {
|
||||
match block["type"].as_str() {
|
||||
Some("tool_use") => {
|
||||
call_position = Some(index);
|
||||
call = Some(block);
|
||||
}
|
||||
Some("tool_result") => {
|
||||
result_position = Some(index);
|
||||
result = Some(block);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(call_position.unwrap() < result_position.unwrap());
|
||||
let call = call.unwrap();
|
||||
assert_eq!(call["id"], id);
|
||||
assert_eq!(call["name"], name);
|
||||
assert_eq!(call["input"], *arguments);
|
||||
let result = result.unwrap();
|
||||
assert_eq!(result["tool_use_id"], id);
|
||||
result["content"].as_str().unwrap().to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn text_answers_use_both_inference_apis() {
|
||||
for api in APIS {
|
||||
let mut fake = FakeServer::start().await;
|
||||
fake.answer(api.text_response("hello from the fake model"))
|
||||
.await;
|
||||
let (_, host, mut mailbox) =
|
||||
start_agent(api, &fake, &format!("agent-text-{}", api.name())).await;
|
||||
send_text(&host, "say hello");
|
||||
api.check_request(&fake.next_request().await);
|
||||
assert_published_text(&mut mailbox, "hello from the fake model").await;
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_calls_pair_with_results_in_both_inference_apis() {
|
||||
let args = json!({"path": "/actor"});
|
||||
for api in APIS {
|
||||
let mut fake = FakeServer::start().await;
|
||||
fake.answer(api.tool_response("list-1", "list_dir", &args))
|
||||
.await;
|
||||
let (world, host, mut mailbox) =
|
||||
start_agent(api, &fake, &format!("agent-tools-{}", api.name())).await;
|
||||
send_text(&host, "list actors");
|
||||
api.check_request(&fake.next_request().await);
|
||||
fake.answer(api.text_response("the actor list is ready"))
|
||||
.await;
|
||||
let second = fake.next_request().await;
|
||||
api.check_request(&second);
|
||||
let output = assert_tool_pair(&second, api, "list-1", "list_dir", &args);
|
||||
let result: Value = serde_json::from_str(&output).unwrap();
|
||||
assert!(
|
||||
result["entries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|entry| entry["name"] == "agent")
|
||||
);
|
||||
assert_published_text(&mut mailbox, "the actor list is ready").await;
|
||||
|
||||
host.stop().await;
|
||||
let history = history(&world);
|
||||
assert_eq!(
|
||||
history
|
||||
.iter()
|
||||
.map(|entry| entry["kind"].as_str().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
["user", "tool_call", "tool_result", "assistant"]
|
||||
);
|
||||
assert_eq!(history[1]["id"], "list-1");
|
||||
assert_eq!(history[2]["id"], "list-1");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_resumes_a_turn_with_completed_tool_history() {
|
||||
let args = json!({"path": "/actor"});
|
||||
for api in [Api::OpenAi] {
|
||||
let mut fake = FakeServer::start().await;
|
||||
fake.answer(api.tool_response("list-1", "list_dir", &args))
|
||||
.await;
|
||||
let (world, host, mut mailbox) =
|
||||
start_agent(api, &fake, &format!("agent-resume-{}", api.name())).await;
|
||||
let id = send_text(&host, "list actors before the restart");
|
||||
api.check_request(&fake.next_request().await);
|
||||
let release = fake.hold(api.text_response("discarded response")).await;
|
||||
let second = fake.next_request().await;
|
||||
api.check_request(&second);
|
||||
assert_tool_pair(&second, api, "list-1", "list_dir", &args);
|
||||
|
||||
let stopped = request(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
json!({"type": "stop", "actor": "agent"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(stopped.body["type"], "stopped");
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(
|
||||
&std::fs::read(world.data_dir("agent").join("turn.json")).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
json!(true)
|
||||
);
|
||||
let _ = release.send(());
|
||||
fake.answer(api.text_response("resumed answer")).await;
|
||||
let start_id = host.broker.send(
|
||||
"console",
|
||||
Some("broker"),
|
||||
None,
|
||||
None,
|
||||
json!({"type": "start", "actor": "agent"}),
|
||||
);
|
||||
let resumed = fake.next_request().await;
|
||||
api.check_request(&resumed);
|
||||
assert_tool_pair(&resumed, api, "list-1", "list_dir", &args);
|
||||
let started = receive_reply(&mut mailbox, &start_id, Duration::from_secs(20)).await;
|
||||
assert_eq!(started.body["type"], "started");
|
||||
assert_published_text(&mut mailbox, "resumed answer").await;
|
||||
|
||||
let history = history(&world);
|
||||
assert_history_tools_have_results(&history);
|
||||
assert_eq!(
|
||||
history
|
||||
.iter()
|
||||
.filter(|entry| entry["kind"] == "user"
|
||||
&& entry["text"].as_str().unwrap_or_default().contains(&id))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Tests of the `sausage` command line.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn empty_dir(label: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("target/test-cli")
|
||||
.join(format!("{label}-{}-{nonce}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// Bare `sausage` (as in `nix run <flake>`) without a world starts `init`
|
||||
/// for ./world instead of printing a usage error.
|
||||
#[test]
|
||||
fn bare_command_without_a_world_starts_init() {
|
||||
let dir = empty_dir("bare");
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_sausage"))
|
||||
.current_dir(&dir)
|
||||
.env_remove("SAUSAGE_API")
|
||||
.env_remove("SAUSAGE_BASE_URL")
|
||||
.env_remove("SAUSAGE_MODEL")
|
||||
.env_remove("SAUSAGE_API_TOKEN")
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// With no answers on stdin, init stops at the first required value.
|
||||
assert!(!output.status.success(), "stdout:\n{stdout}\nstderr:\n{stderr}");
|
||||
assert!(stdout.contains("No world in ./world yet"), "stdout:\n{stdout}\nstderr:\n{stderr}");
|
||||
assert!(stderr.contains("SAUSAGE_BASE_URL"), "stdout:\n{stdout}\nstderr:\n{stderr}");
|
||||
assert!(!stderr.contains("Usage:"), "stderr:\n{stderr}");
|
||||
// It asks before it creates anything.
|
||||
assert!(!dir.join("world").exists());
|
||||
}
|
||||
|
||||
/// Start a headless host on a fresh world with stdin at EOF; returns once it
|
||||
/// printed its ready line (printed after the signal handlers are installed).
|
||||
fn start_headless(label: &str) -> (std::process::Child, std::io::Lines<std::io::BufReader<std::process::ChildStdout>>) {
|
||||
use std::io::{BufRead, BufReader};
|
||||
let dir = empty_dir(label);
|
||||
let world = dir.join("world");
|
||||
sausage::World::create(&world).unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_sausage"))
|
||||
.args(["run", "--headless"])
|
||||
.arg(&world)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
|
||||
let line = lines.next().expect("host exited before it started").unwrap();
|
||||
assert!(line.contains("headless"), "unexpected first line: {line}");
|
||||
(child, lines)
|
||||
}
|
||||
|
||||
/// With stdin at EOF (as soon as the host starts), a headless host stays up.
|
||||
#[test]
|
||||
fn headless_ignores_stdin_eof() {
|
||||
let (mut child, _lines) = start_headless("headless-eof");
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
let alive = child.try_wait().unwrap().is_none();
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
assert!(alive, "headless host exited on stdin EOF");
|
||||
}
|
||||
|
||||
/// SIGTERM right after the ready line stops the actors and exits with 0.
|
||||
#[test]
|
||||
fn headless_stops_cleanly_on_sigterm_right_after_ready() {
|
||||
let (mut child, lines) = start_headless("headless-term");
|
||||
let pid = child.id().to_string();
|
||||
assert!(Command::new("kill").args(["-TERM", &pid]).status().unwrap().success());
|
||||
let rest: Vec<String> = lines.map(|l| l.unwrap()).collect();
|
||||
let status = child.wait().unwrap();
|
||||
assert!(status.success(), "exit status {status:?}, output {rest:?}");
|
||||
assert!(rest.iter().any(|l| l.contains("Stopping actors")), "output {rest:?}");
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use sausage::{Envelope, Host, World};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
const REPLY_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
static WORLD_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub fn repo_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.to_path_buf()
|
||||
}
|
||||
|
||||
pub fn create_world(label: &str) -> World {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let sequence = WORLD_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let path = repo_root()
|
||||
.join("target/test-worlds")
|
||||
.join(format!("{label}-{}-{nonce}-{sequence}", std::process::id()));
|
||||
World::create(&path).unwrap()
|
||||
}
|
||||
|
||||
pub fn actor_fixture_source(name: &str) -> PathBuf {
|
||||
repo_root().join("cli/tests/actors").join(name)
|
||||
}
|
||||
|
||||
pub fn cargo_source(name: &str) -> PathBuf {
|
||||
repo_root().join("cli/tests/crates").join(name)
|
||||
}
|
||||
|
||||
pub fn toolbox_wasm() -> &'static PathBuf {
|
||||
static WASM: OnceLock<PathBuf> = OnceLock::new();
|
||||
WASM.get_or_init(|| build_fixture("toolbox"))
|
||||
}
|
||||
|
||||
fn build_fixture(name: &str) -> PathBuf {
|
||||
let root = repo_root();
|
||||
let manifest = actor_fixture_source(name).join("Cargo.toml");
|
||||
let target_dir = root.join("target/test-actors");
|
||||
let output = Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"--target-dir",
|
||||
])
|
||||
.arg(&target_dir)
|
||||
.arg("--manifest-path")
|
||||
.arg(&manifest)
|
||||
.current_dir(&root)
|
||||
.output()
|
||||
.expect("run cargo to build the test actor");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"building fixture {name} failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let wasm = target_dir
|
||||
.join("wasm32-wasip2/release")
|
||||
.join(format!("{name}.wasm"));
|
||||
assert!(
|
||||
wasm.is_file(),
|
||||
"fixture build did not create {}",
|
||||
wasm.display()
|
||||
);
|
||||
wasm
|
||||
}
|
||||
|
||||
pub fn passing_source(world: &World, name: &str) -> PathBuf {
|
||||
source_with_test(
|
||||
world,
|
||||
name,
|
||||
json!({
|
||||
"steps": [{
|
||||
"send": {"type": "echo", "value": "candidate"},
|
||||
"expect": {"type": "echo", "value": "candidate"}
|
||||
}]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn source_with_test(world: &World, name: &str, test: Value) -> PathBuf {
|
||||
let source = world.source_dir("console").join("test-fixtures").join(name);
|
||||
std::fs::create_dir_all(source.join("tests")).unwrap();
|
||||
std::fs::write(
|
||||
source.join("tests/candidate.json"),
|
||||
serde_json::to_vec(&test).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
source
|
||||
}
|
||||
|
||||
pub async fn start(world: &World) -> (Host, mpsc::Receiver<Envelope>) {
|
||||
let host = Host::start(&world.root).await.unwrap();
|
||||
let mailbox = host.mailbox("console");
|
||||
(host, mailbox)
|
||||
}
|
||||
|
||||
pub async fn request(host: &Host, mailbox: &mut mpsc::Receiver<Envelope>, body: Value) -> Envelope {
|
||||
let id = host
|
||||
.broker
|
||||
.send("console", Some("broker"), None, None, body);
|
||||
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
|
||||
}
|
||||
|
||||
pub async fn receive_message(
|
||||
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||
timeout: Duration,
|
||||
) -> Envelope {
|
||||
tokio::time::timeout(timeout, mailbox.recv())
|
||||
.await
|
||||
.expect("timed out waiting for a message")
|
||||
.expect("console mailbox closed")
|
||||
}
|
||||
|
||||
pub async fn receive_reply(
|
||||
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||
id: &str,
|
||||
timeout: Duration,
|
||||
) -> Envelope {
|
||||
tokio::time::timeout(timeout, async {
|
||||
loop {
|
||||
let envelope = mailbox.recv().await.expect("console mailbox closed");
|
||||
if envelope.reply_to.as_deref() == Some(id) {
|
||||
return envelope;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("timed out waiting for reply to {id}"))
|
||||
}
|
||||
|
||||
pub async fn receive_replies(
|
||||
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||
ids: &[String],
|
||||
timeout: Duration,
|
||||
) -> Vec<Envelope> {
|
||||
let mut replies = Vec::with_capacity(ids.len());
|
||||
tokio::time::timeout(timeout, async {
|
||||
while replies.len() < ids.len() {
|
||||
let message = mailbox.recv().await.expect("console mailbox closed");
|
||||
assert!(
|
||||
ids.iter()
|
||||
.any(|id| message.reply_to.as_deref() == Some(id.as_str())),
|
||||
"unexpected message while waiting for replies: {message:?}"
|
||||
);
|
||||
replies.push(message);
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("timed out waiting for replies");
|
||||
replies
|
||||
}
|
||||
|
||||
pub async fn ask(
|
||||
host: &Host,
|
||||
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||
actor: &str,
|
||||
body: Value,
|
||||
) -> Envelope {
|
||||
let id = host.broker.send("console", Some(actor), None, None, body);
|
||||
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
|
||||
}
|
||||
|
||||
pub async fn ask_type(
|
||||
host: &Host,
|
||||
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||
actor: &str,
|
||||
body: Value,
|
||||
expected: &str,
|
||||
) -> Envelope {
|
||||
let reply = ask(host, mailbox, actor, body).await;
|
||||
assert_eq!(reply.body["type"], expected, "unexpected reply: {reply:?}");
|
||||
reply
|
||||
}
|
||||
|
||||
pub fn spawn_request(host: &Host, actor: &str, wasm: &Path, source: &Path) -> String {
|
||||
host.broker.send(
|
||||
"console",
|
||||
Some("broker"),
|
||||
None,
|
||||
None,
|
||||
json!({
|
||||
"type": "spawn",
|
||||
"actor": actor,
|
||||
"wasm": wasm.display().to_string(),
|
||||
"source": source.display().to_string()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn spawn_actor(
|
||||
host: &Host,
|
||||
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||
actor: &str,
|
||||
wasm: &Path,
|
||||
source: &Path,
|
||||
) -> Envelope {
|
||||
let id = spawn_request(host, actor, wasm, source);
|
||||
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
|
||||
}
|
||||
|
||||
pub async fn spawn_type(
|
||||
host: &Host,
|
||||
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||
actor: &str,
|
||||
wasm: &Path,
|
||||
source: &Path,
|
||||
) -> Envelope {
|
||||
let reply = spawn_actor(host, mailbox, actor, wasm, source).await;
|
||||
assert_eq!(
|
||||
reply.body["type"], "spawned",
|
||||
"unexpected spawn reply: {reply:?}"
|
||||
);
|
||||
reply
|
||||
}
|
||||
|
||||
pub async fn upgrade_actor(
|
||||
host: &Host,
|
||||
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||
actor: &str,
|
||||
wasm: &Path,
|
||||
source: &Path,
|
||||
) -> Envelope {
|
||||
request(
|
||||
host,
|
||||
mailbox,
|
||||
json!({
|
||||
"type": "upgrade",
|
||||
"actor": actor,
|
||||
"wasm": wasm.display().to_string(),
|
||||
"source": source.display().to_string()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
[package]
|
||||
name = "cargo-c"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
sausage-plugin = "0.1"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
|
||||
[workspace]
|
||||
@@ -1,5 +0,0 @@
|
||||
// Compile C with the `cc` crate: natively for the unit test, and with the
|
||||
// WASI clang (`CC_wasm32_wasip2`) for the actor.
|
||||
fn main() {
|
||||
cc::Build::new().file("src/add.c").compile("add");
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
int c_add(int a, int b) { return a + b; }
|
||||
@@ -1,25 +0,0 @@
|
||||
//! SDK plugin linked to a WASI C function.
|
||||
unsafe extern "C" {
|
||||
fn c_add(a: i32, b: i32) -> i32;
|
||||
}
|
||||
fn add(a: i32, b: i32) -> i32 {
|
||||
unsafe { c_add(a, b) }
|
||||
}
|
||||
struct Plugin;
|
||||
#[sausage_plugin::rpc]
|
||||
impl Plugin {
|
||||
/// Add two fixed numbers through C.
|
||||
fn sum(&self) -> sausage_plugin::Result<i32> {
|
||||
Ok(add(2, 3))
|
||||
}
|
||||
}
|
||||
fn main() {
|
||||
sausage_plugin::run(Plugin);
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn c_code_runs_under_wasi() {
|
||||
assert_eq!(super::add(2, 3), 5);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
[package]
|
||||
name = "cargo-hang"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
@@ -1,7 +0,0 @@
|
||||
//! A build script descendant that stays alive until the native call ends.
|
||||
fn main() {
|
||||
std::process::Command::new("sh")
|
||||
.args(["-c", "echo $$ > child.pid; exec sleep 1000"])
|
||||
.status()
|
||||
.unwrap();
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
fn main() {}
|
||||
@@ -1,13 +1,9 @@
|
||||
//! A panic must not prevent the runner from executing another test.
|
||||
fn main() {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_panics() {
|
||||
panic!("deliberate WASI panic");
|
||||
}
|
||||
#[test]
|
||||
fn z_survives() {
|
||||
assert_eq!(2 + 2, 4);
|
||||
fn deliberate_unit_test_failure() {
|
||||
assert_eq!(2 + 2, 5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
[package]
|
||||
name = "cargo-valid"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
sausage_plugin = { package = "sausage-plugin", version = "0.1", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["dep:sausage_plugin"]
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
//! Produces a warning so the build response can retain diagnostics.
|
||||
fn main() {
|
||||
println!("cargo:warning=fixture build warning");
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
fixture
|
||||
@@ -1,165 +1,9 @@
|
||||
//! SDK plugin and tests for the native build pipeline.
|
||||
struct Plugin;
|
||||
#[sausage_plugin::rpc]
|
||||
impl Plugin {
|
||||
/// Return a greeting.
|
||||
fn hello(&self) -> sausage_plugin::Result<String> {
|
||||
Ok("hello".into())
|
||||
}
|
||||
}
|
||||
fn main() {
|
||||
sausage_plugin::run(Plugin);
|
||||
}
|
||||
fn main() {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn arithmetic() {
|
||||
fn unit_tests_run_on_the_host_target() {
|
||||
assert_eq!(2 + 2, 4);
|
||||
}
|
||||
#[test]
|
||||
fn fixture_at_manifest_path() {
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/fixture.txt")).unwrap(),
|
||||
"fixture\n"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn current_directory() {
|
||||
println!(
|
||||
"WASI_CURRENT_DIR={}",
|
||||
std::env::current_dir().unwrap().display()
|
||||
);
|
||||
}
|
||||
#[sausage_plugin::test]
|
||||
async fn sdk_lock() {
|
||||
let guard = sausage_plugin::lock("/state/lock").await.unwrap();
|
||||
assert!(guard.held());
|
||||
let locks: sausage_plugin::serde_json::Value = sausage_plugin::serde_json::from_str(
|
||||
&std::fs::read_to_string("/proc/core/locks.json").unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(locks.as_object().unwrap().len(), 1);
|
||||
assert!(locks.get("/state/lock").is_some());
|
||||
let mut guard = Some(guard);
|
||||
let mut waiting = Box::pin(std::future::IntoFuture::into_future(sausage_plugin::lock(
|
||||
"/state/lock",
|
||||
)));
|
||||
let acquired = std::future::poll_fn(|context| {
|
||||
let result = std::future::Future::poll(waiting.as_mut(), context);
|
||||
if result.is_pending() {
|
||||
drop(guard.take());
|
||||
}
|
||||
result
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(acquired.held());
|
||||
let logs = sausage_plugin::call("core.log", sausage_plugin::serde_json::json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(logs.is_array());
|
||||
sausage_plugin::call(
|
||||
"core.notify",
|
||||
sausage_plugin::serde_json::json!({"name":"progress","params":{}}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
#[sausage_plugin::test]
|
||||
async fn other_plugin_fails() {
|
||||
let error = sausage_plugin::call("missing.method", sausage_plugin::serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("a test has no other plugins"));
|
||||
}
|
||||
#[sausage_plugin::test]
|
||||
async fn unlock_order() {
|
||||
for iteration in 0..300 {
|
||||
let guard = sausage_plugin::lock("/state/order").await.unwrap();
|
||||
assert!(guard.held());
|
||||
drop(guard);
|
||||
let guard = sausage_plugin::lock("/state/order")
|
||||
.try_now()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(guard.held(), "iteration {iteration}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn empty_world() {
|
||||
for path in [
|
||||
"/plugins",
|
||||
"/config",
|
||||
"/state",
|
||||
"/state/core/journal.sqlite",
|
||||
"/logs/core/journal.sqlite",
|
||||
] {
|
||||
assert!(!std::path::Path::new(path).exists(), "{path}");
|
||||
}
|
||||
let names: Vec<_> = std::fs::read_dir("/")
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name())
|
||||
.collect();
|
||||
// The runner mounts the package folder at its host path, so the first
|
||||
// folder of that path also appears in `/` (for example `tmp` or `build`).
|
||||
let package = env!("CARGO_MANIFEST_DIR")
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap();
|
||||
assert!(
|
||||
names.iter().all(|name| {
|
||||
["proc", "self", "run", package]
|
||||
.iter()
|
||||
.any(|allowed| name == allowed)
|
||||
}),
|
||||
"{names:?}"
|
||||
);
|
||||
for path in ["/proc", "/self", "/run"] {
|
||||
assert!(std::path::Path::new(path).is_dir());
|
||||
}
|
||||
assert!(std::fs::write("/proc/forbidden", "no").is_err());
|
||||
}
|
||||
fn isolated() {
|
||||
assert!(!std::path::Path::new("/self/own").exists());
|
||||
assert!(!std::path::Path::new("/run/own").exists());
|
||||
std::fs::write("/self/own", "self").unwrap();
|
||||
std::fs::write("/run/own", "run").unwrap();
|
||||
assert_eq!(std::fs::read_to_string("/self/own").unwrap(), "self");
|
||||
}
|
||||
#[test]
|
||||
fn isolation_one() {
|
||||
isolated();
|
||||
}
|
||||
#[test]
|
||||
fn isolation_two() {
|
||||
isolated();
|
||||
}
|
||||
#[test]
|
||||
fn environment() {
|
||||
for (key, _) in std::env::vars() {
|
||||
assert!(
|
||||
["CARGO_", "RUST_", "SAUSAGE_"]
|
||||
.iter()
|
||||
.any(|prefix| key.starts_with(prefix)),
|
||||
"{key}"
|
||||
);
|
||||
}
|
||||
assert!(std::env::var("PWD").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn late_response() {
|
||||
use std::io::Write;
|
||||
for id in 0..100 {
|
||||
println!(
|
||||
"\n{{\"jsonrpc\":\"2.0\",\"id\":{id},\"method\":\"core.log\",\"params\":{{}}}}"
|
||||
);
|
||||
}
|
||||
std::io::stdout().flush().unwrap();
|
||||
}
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn ignored_case() {
|
||||
assert_eq!(1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
//! Checks that cargo invokes integration test binaries through the WASI runner.
|
||||
#[test]
|
||||
fn integration_case() {
|
||||
println!("INTEGRATION_CASE");
|
||||
}
|
||||
+1230
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,669 +0,0 @@
|
||||
//! Run the agent in the real core against scripted OpenAI and Anthropic HTTP servers.
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper::{Response, server::conn::http1, service::service_fn};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use sausage::{Host, world::World};
|
||||
use sausage_vfs::Layer;
|
||||
use serde_json::{Value, json};
|
||||
use std::{convert::Infallible, path::PathBuf, sync::OnceLock, time::Duration};
|
||||
use tokio::{
|
||||
net::TcpListener,
|
||||
sync::{mpsc, oneshot},
|
||||
};
|
||||
|
||||
/// The two HTTP contracts supported by the agent.
|
||||
const APIS: [&str; 3] = [
|
||||
"openai-responses",
|
||||
"anthropic-messages",
|
||||
"openai-chat-completions",
|
||||
];
|
||||
/// A captured request and the script's delayed HTTP response.
|
||||
type Exchange = (String, Value, oneshot::Sender<(u16, Value)>);
|
||||
/// The script controls each response, including concurrent requests.
|
||||
pub(super) struct Llm {
|
||||
pub(super) url: String,
|
||||
api: &'static str,
|
||||
requests: mpsc::UnboundedReceiver<Exchange>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Llm {
|
||||
pub(super) async fn new(api: &'static str) -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let (tx, requests) = mpsc::unbounded_channel::<Exchange>();
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let service =
|
||||
service_fn(move |request: hyper::Request<hyper::body::Incoming>| {
|
||||
let tx = tx.clone();
|
||||
async move {
|
||||
assert_eq!(request.method(), "POST");
|
||||
// The local Chat Completions fixture explicitly disables authentication.
|
||||
if api == APIS[2] {
|
||||
assert!(!request.headers().contains_key("authorization"));
|
||||
}
|
||||
let path = request.uri().path().to_string();
|
||||
let bytes = request.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = serde_json::from_slice(&bytes).unwrap();
|
||||
let (reply, answer) = oneshot::channel();
|
||||
tx.send((path, body, reply)).unwrap();
|
||||
let (status, body) = answer.await.unwrap();
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", "application/json")
|
||||
.body(Full::new(Bytes::from(body.to_string())))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
});
|
||||
http1::Builder::new()
|
||||
.serve_connection(TokioIo::new(stream), service)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
});
|
||||
Self {
|
||||
url,
|
||||
api,
|
||||
requests,
|
||||
task,
|
||||
}
|
||||
}
|
||||
pub(super) async fn next(&mut self) -> (Value, oneshot::Sender<(u16, Value)>) {
|
||||
let (path, body, reply) =
|
||||
tokio::time::timeout(Duration::from_secs(60), self.requests.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
path,
|
||||
if self.api == APIS[0] {
|
||||
"/v1/responses"
|
||||
} else if self.api == APIS[2] {
|
||||
"/v1/chat/completions"
|
||||
} else {
|
||||
"/v1/messages"
|
||||
}
|
||||
);
|
||||
assert_eq!(body["model"], "scripted-test");
|
||||
(body, reply)
|
||||
}
|
||||
pub(super) fn answer(&self, text: &str, calls: &[(&str, Value)]) -> Value {
|
||||
if self.api == APIS[2] {
|
||||
let calls: Vec<Value> = calls.iter().enumerate().map(|(n,(name,args))| json!({"id":format!("tool-{n}"),"type":"function","function":{"name":name,"arguments":args.to_string()}})).collect();
|
||||
json!({"choices":[{"message":{"role":"assistant","content":text,"tool_calls":calls}}]})
|
||||
} else if self.api == APIS[0] {
|
||||
let mut output =
|
||||
vec![json!({"type":"message","content":[{"type":"output_text","text":text}]})];
|
||||
for (n, (name, args)) in calls.iter().enumerate() {
|
||||
output.push(json!({"type":"function_call","call_id":format!("tool-{n}"),"name":name,"arguments":args.to_string()}));
|
||||
}
|
||||
json!({"status":"completed","output":output})
|
||||
} else {
|
||||
let mut content = vec![json!({"type":"text","text":text})];
|
||||
for (n, (name, args)) in calls.iter().enumerate() {
|
||||
content.push(
|
||||
json!({"type":"tool_use","id":format!("tool-{n}"),"name":name,"input":args}),
|
||||
);
|
||||
}
|
||||
json!({"content":content,"stop_reason":if calls.is_empty(){"end_turn"}else{"tool_use"}})
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Llm {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn fixtures() -> &'static PathBuf {
|
||||
static TARGET: OnceLock<PathBuf> = OnceLock::new();
|
||||
TARGET.get_or_init(|| {
|
||||
super::common::build("agent", "agent");
|
||||
super::common::sdk_probe().parent().unwrap().to_owned()
|
||||
})
|
||||
}
|
||||
struct TestWorld(PathBuf);
|
||||
impl TestWorld {
|
||||
fn new(server: &Llm) -> Self {
|
||||
let path = std::env::temp_dir().join(format!("sausage-agent-{}", uuid::Uuid::now_v7()));
|
||||
let world = World::create(&path).unwrap();
|
||||
for (plugin, binary) in [("agent", "agent"), ("probe", "sdk-probe")] {
|
||||
std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap();
|
||||
std::fs::copy(
|
||||
fixtures().join(format!("{binary}.wasm")),
|
||||
world.plugin_dir(plugin).join("plugin.wasm"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::write(path.join("config/agent.yaml"), serde_yaml_ng::to_string(&json!({"api":server.api,"base_url":if server.api == APIS[1] {server.url.clone()} else {format!("{}/v1",server.url)},"model":"scripted-test","token":if server.api == APIS[2] {""} else {"fake-token"},"allow_no_token":server.api == APIS[2]})).unwrap()).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
async fn start(&self) -> Host {
|
||||
Host::start_with_cache(
|
||||
&self.0,
|
||||
std::path::Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
fn history(&self) -> String {
|
||||
std::fs::read_to_string(self.0.join("state/agent/history.jsonl")).unwrap()
|
||||
}
|
||||
}
|
||||
impl Drop for TestWorld {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn chat(host: &Host, text: &str) -> tokio::task::JoinHandle<Value> {
|
||||
let host = host.clone();
|
||||
let text = text.to_owned();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(90),
|
||||
host.call("agent.chat", json!({"text":text})),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
fn input(body: &Value, api: &str) -> Value {
|
||||
body[if api == APIS[0] { "input" } else { "messages" }].clone()
|
||||
}
|
||||
fn outputs(body: &Value, api: &str) -> Vec<Value> {
|
||||
if api == APIS[2] {
|
||||
body["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|e| e["role"] == "tool")
|
||||
.map(|e| serde_json::from_str(e["content"].as_str().unwrap()).unwrap())
|
||||
.collect()
|
||||
} else if api == APIS[0] {
|
||||
body["input"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|e| e["type"] == "function_call_output")
|
||||
.map(|e| serde_json::from_str(e["output"].as_str().unwrap()).unwrap())
|
||||
.collect()
|
||||
} else {
|
||||
body["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.flat_map(|e| e["content"].as_array().unwrap())
|
||||
.filter(|e| e["type"] == "tool_result")
|
||||
.map(|e| serde_json::from_str(e["content"].as_str().unwrap()).unwrap())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalid configuration names the file and the field before any inference request.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unconfigured_chat_names_the_configuration() {
|
||||
let server = Llm::new(APIS[0]).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let path = world.0.join("config/agent.yaml");
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
let host = world.start().await;
|
||||
let error = host
|
||||
.call("agent.chat", json!({"text":"hello"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("/config/agent.yaml")
|
||||
);
|
||||
for (config, field) in [
|
||||
("", "api"),
|
||||
("api: unknown", "api"),
|
||||
("api: openai-responses", "base_url"),
|
||||
(
|
||||
"api: openai-responses\nbase_url: http://localhost/v1",
|
||||
"model",
|
||||
),
|
||||
(
|
||||
"api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: ''",
|
||||
"token",
|
||||
),
|
||||
(
|
||||
"api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: CHANGE_ME",
|
||||
"token",
|
||||
),
|
||||
(
|
||||
"api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: key\nthinking: hihg",
|
||||
"thinking",
|
||||
),
|
||||
] {
|
||||
std::fs::write(&path, config).unwrap();
|
||||
let error = host
|
||||
.call("agent.chat", json!({"text":"hello"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
let message = error["message"].as_str().unwrap();
|
||||
assert!(message.contains("/config/agent.yaml"), "{message}");
|
||||
assert!(message.contains(field), "{message}");
|
||||
}
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// All API shapes return final text and retain the complete history after a core restart.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn final_answers_and_history_survive_restart() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let turn = chat(&host, "first request alpha");
|
||||
let (body, reply) = server.next().await;
|
||||
assert!(
|
||||
input(&body, api)
|
||||
.to_string()
|
||||
.contains("first request alpha")
|
||||
);
|
||||
assert_eq!(body["tools"].as_array().unwrap().len(), 5);
|
||||
reply
|
||||
.send((200, server.answer("first answer alpha", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(turn.await.unwrap(), "first answer alpha");
|
||||
host.stop().await;
|
||||
let prefix = world.history();
|
||||
drop(host);
|
||||
let host = world.start().await;
|
||||
let turn = chat(&host, "second request beta");
|
||||
let (body, reply) = server.next().await;
|
||||
let text = input(&body, api).to_string();
|
||||
for expected in [
|
||||
"first request alpha",
|
||||
"first answer alpha",
|
||||
"second request beta",
|
||||
] {
|
||||
assert!(text.contains(expected));
|
||||
}
|
||||
reply
|
||||
.send((200, server.answer("second answer beta", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(turn.await.unwrap(), "second answer beta");
|
||||
assert!(world.history().starts_with(&prefix));
|
||||
let entries: Vec<Value> = world
|
||||
.history()
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
entries
|
||||
.iter()
|
||||
.map(|e| e["kind"].as_str().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
["user", "assistant", "user", "assistant"]
|
||||
);
|
||||
let doc = host.call("agent.rpc.discover", json!({})).await.unwrap();
|
||||
assert_eq!(doc["x-sausage-grace"], 600);
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Both API shapes execute every tool, return tool errors, and deliver interim text.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn tools_and_interim_notifications() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let mut events = host.subscribe();
|
||||
let turn = chat(&host, "exercise the tools");
|
||||
let (_, reply) = server.next().await;
|
||||
let calls = [
|
||||
(
|
||||
"call",
|
||||
json!({"method":"probe.add","params":{"a":4,"b":5},"detach":true}),
|
||||
),
|
||||
("read_file", json!({"path":"/proc/core/PROTOCOL.md"})),
|
||||
(
|
||||
"write_file",
|
||||
json!({"path":"/state/work/source/note.txt","content":"saved"}),
|
||||
),
|
||||
("list_dir", json!({"path":"/state/work/source"})),
|
||||
(
|
||||
"copy_dir",
|
||||
json!({"from":"/state/work/source","to":"/state/work/copied"}),
|
||||
),
|
||||
("read_file", json!({"path":"/state/work/missing"})),
|
||||
("call", json!({"method":"absent.run","params":{}})),
|
||||
("call", json!({"method":"probe.fail","params":{}})),
|
||||
];
|
||||
reply
|
||||
.send((200, server.answer("I will inspect the world.", &calls)))
|
||||
.unwrap();
|
||||
let (body, reply) = server.next().await;
|
||||
let results = outputs(&body, api);
|
||||
assert_eq!(results.len(), 8);
|
||||
assert_eq!(results[0], 9);
|
||||
assert!(results[1]["content"].as_str().unwrap().contains("JSON-RPC"));
|
||||
assert_eq!(results[2]["written"], "/state/work/source/note.txt");
|
||||
assert_eq!(results[3]["entries"][0]["name"], "note.txt");
|
||||
assert_eq!(results[4]["copied_files"], 1);
|
||||
assert!(results[5]["error"]["message"].is_string());
|
||||
assert!(results[6]["error"]["code"].is_number());
|
||||
assert_eq!(
|
||||
results[7]["error"],
|
||||
json!({"code":-32010,"message":"deliberate","data":{"diagnostic":"probe error details"}})
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(world.0.join("state/work/copied/note.txt")).unwrap(),
|
||||
"saved"
|
||||
);
|
||||
let event = tokio::time::timeout(Duration::from_secs(5), events.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(event["params"]["name"], "text");
|
||||
assert_eq!(
|
||||
event["params"]["params"]["text"],
|
||||
"I will inspect the world."
|
||||
);
|
||||
reply
|
||||
.send((200, server.answer("The tools finished.", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(turn.await.unwrap(), "The tools finished.");
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// The history lock holds the second chat until the complete first turn ends.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn concurrent_chats_serialize_complete_turns() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let first = chat(&host, "first concurrent request");
|
||||
let (_, reply) = server.next().await;
|
||||
let second = chat(&host, "second concurrent request");
|
||||
let view = host.filesystem().await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
let file = view
|
||||
.open("/proc/core/locks.json", sausage_vfs::Open::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let locks: Value =
|
||||
serde_json::from_slice(&file.read(0, 65536).await.unwrap()).unwrap();
|
||||
if locks["/state/agent/history.jsonl"]["waiting"]
|
||||
.as_array()
|
||||
.is_some_and(|waiting| waiting.len() == 1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(server.requests.try_recv().is_err());
|
||||
reply
|
||||
.send((200, server.answer("first concurrent answer", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(first.await.unwrap(), "first concurrent answer");
|
||||
let (body, reply) = server.next().await;
|
||||
let text = input(&body, api).to_string();
|
||||
assert!(text.contains("first concurrent answer"));
|
||||
assert!(text.contains("second concurrent request"));
|
||||
reply
|
||||
.send((200, server.answer("second concurrent answer", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(second.await.unwrap(), "second concurrent answer");
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Context-size failures discard old complete turns without changing the append-only history.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn context_window_advances_by_complete_turns() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let first = chat(&host, "old turn");
|
||||
let (_, reply) = server.next().await;
|
||||
reply.send((200, server.answer("old answer", &[]))).unwrap();
|
||||
first.await.unwrap();
|
||||
let second = chat(&host, "current turn");
|
||||
let (_, reply) = server.next().await;
|
||||
reply
|
||||
.send((400, json!({"error":"context_length_exceeded"})))
|
||||
.unwrap();
|
||||
let (body, reply) = server.next().await;
|
||||
let text = input(&body, api).to_string();
|
||||
assert!(!text.contains("old turn"));
|
||||
assert!(!text.contains("old answer"));
|
||||
assert!(text.contains("current turn"));
|
||||
reply
|
||||
.send((200, server.answer("current answer", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(second.await.unwrap(), "current answer");
|
||||
assert_eq!(world.history().lines().count(), 4);
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Oversized tool outputs are bounded, and failed retries do not commit a new history cursor.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn oversized_current_turn_preserves_the_previous_cursor() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
std::fs::write(world.0.join("large.txt"), "x".repeat(200_000)).unwrap();
|
||||
let host = world.start().await;
|
||||
let first = chat(&host, "old retained turn");
|
||||
let (_, reply) = server.next().await;
|
||||
reply
|
||||
.send((200, server.answer("old retained answer", &[])))
|
||||
.unwrap();
|
||||
first.await.unwrap();
|
||||
let skip = world.0.join("state/agent/history.skip");
|
||||
std::fs::write(&skip, "0").unwrap();
|
||||
let caller = host.clone();
|
||||
let turn = tokio::spawn(async move {
|
||||
caller
|
||||
.call("agent.chat", json!({"text":"read a large tool result"}))
|
||||
.await
|
||||
});
|
||||
let (_, reply) = server.next().await;
|
||||
reply
|
||||
.send((
|
||||
200,
|
||||
server.answer("", &[("read_file", json!({"path":"/large.txt"}))]),
|
||||
))
|
||||
.unwrap();
|
||||
let (body, reply) = server.next().await;
|
||||
let serialized = input(&body, api).to_string();
|
||||
assert!(serialized.contains("Full output: /logs/agent/"));
|
||||
let history = world.history();
|
||||
let result: Value = history
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str::<Value>(line).unwrap())
|
||||
.find(|entry| {
|
||||
entry["output"]
|
||||
.as_str()
|
||||
.is_some_and(|out| out.contains("Full output: "))
|
||||
})
|
||||
.unwrap();
|
||||
let output = result["output"].as_str().unwrap();
|
||||
let path = output
|
||||
.split("Full output: ")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split(". ")
|
||||
.next()
|
||||
.unwrap();
|
||||
let archive = std::fs::read_to_string(world.0.join(path.trim_start_matches('/'))).unwrap();
|
||||
assert!(archive.contains(&"x".repeat(200_000)));
|
||||
assert!(output.len() <= 50 * 1024);
|
||||
assert!(serialized.len() < 70_000);
|
||||
reply
|
||||
.send((413, json!({"type":"request_too_large"})))
|
||||
.unwrap();
|
||||
let (body, reply) = server.next().await;
|
||||
assert!(!input(&body, api).to_string().contains("old retained turn"));
|
||||
reply
|
||||
.send((413, json!({"type":"request_too_large"})))
|
||||
.unwrap();
|
||||
assert!(turn.await.unwrap().is_err());
|
||||
assert_eq!(std::fs::read_to_string(&skip).unwrap(), "0");
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider-private items are replayed only for their original API and model.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn private_items_are_scoped_to_the_model() {
|
||||
for api in [APIS[0], APIS[1]] {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
// Without a thinking level, newer models think by default, so their items return.
|
||||
std::fs::create_dir_all(world.0.join("state/agent")).unwrap();
|
||||
let mut entries = vec![json!({"kind":"user","text":"old turn"})];
|
||||
for (model, marker) in [
|
||||
("another-model", "old-private-marker"),
|
||||
("scripted-test", "current-private-marker"),
|
||||
] {
|
||||
let item = if api == APIS[0] {
|
||||
json!({"type":"reasoning","encrypted_content":marker})
|
||||
} else {
|
||||
json!({"type":"thinking","thinking":"private","signature":marker})
|
||||
};
|
||||
entries.push(json!({"kind":"opaque","api":api,"model":model,"item":item}));
|
||||
}
|
||||
entries.push(json!({"kind":"assistant","text":"old answer"}));
|
||||
std::fs::write(
|
||||
world.0.join("state/agent/history.jsonl"),
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| format!("{entry}\n"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
let host = world.start().await;
|
||||
let turn = chat(&host, "next turn");
|
||||
let (body, reply) = server.next().await;
|
||||
let text = input(&body, api).to_string();
|
||||
assert!(!text.contains("old-private-marker"));
|
||||
assert!(text.contains("current-private-marker"));
|
||||
reply.send((200, server.answer("done", &[]))).unwrap();
|
||||
assert_eq!(turn.await.unwrap(), "done");
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Each API gets the configured thinking level. A level in the call applies to that turn only.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn thinking_levels_reach_each_api() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let config_path = world.0.join("config/agent.yaml");
|
||||
let mut config: Value =
|
||||
serde_yaml_ng::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
|
||||
config["thinking"] = json!("high");
|
||||
std::fs::write(config_path, serde_yaml_ng::to_string(&config).unwrap()).unwrap();
|
||||
let host = world.start().await;
|
||||
// Off is `none` for OpenAI, and disabled thinking without an effort for Anthropic.
|
||||
let off = if api == APIS[1] { None } else { Some("none") };
|
||||
for (thinking, effort) in [
|
||||
(None, Some("high")),
|
||||
(Some("low"), Some("low")),
|
||||
(Some("off"), off),
|
||||
(None, Some("high")),
|
||||
] {
|
||||
let mut params = json!({"text":"hello"});
|
||||
if let Some(thinking) = thinking {
|
||||
params["thinking"] = json!(thinking);
|
||||
}
|
||||
let caller = host.clone();
|
||||
let turn =
|
||||
tokio::spawn(async move { caller.call("agent.chat", params).await.unwrap() });
|
||||
let (body, reply) = server.next().await;
|
||||
let sent = if api == APIS[0] {
|
||||
&body["reasoning"]["effort"]
|
||||
} else if api == APIS[2] {
|
||||
&body["reasoning_effort"]
|
||||
} else {
|
||||
let mode = if thinking == Some("off") {
|
||||
"disabled"
|
||||
} else {
|
||||
"adaptive"
|
||||
};
|
||||
assert_eq!(body["thinking"]["type"], mode, "{body}");
|
||||
&body["output_config"]["effort"]
|
||||
};
|
||||
assert_eq!(sent.as_str(), effort, "{api} {thinking:?}");
|
||||
reply.send((200, server.answer("done", &[]))).unwrap();
|
||||
assert_eq!(turn.await.unwrap(), "done");
|
||||
}
|
||||
let error = host
|
||||
.call("agent.chat", json!({"text":"hello","thinking":"hihg"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error["code"], -32602, "{error}");
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate assistant messages retain a separator in the final answer.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn final_texts_have_a_separator() {
|
||||
for api in [APIS[0], APIS[1]] {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let turn = chat(&host, "two paragraphs");
|
||||
let (_, reply) = server.next().await;
|
||||
let mut answer = server.answer("first", &[]);
|
||||
if api == APIS[0] {
|
||||
answer["output"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!({"type":"message","content":[{"type":"output_text","text":"second"}]}));
|
||||
} else {
|
||||
answer["content"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!({"type":"text","text":"second"}));
|
||||
}
|
||||
reply.send((200, answer)).unwrap();
|
||||
assert_eq!(turn.await.unwrap(), "first\n\nsecond");
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// An incomplete response containing reasoning but no text is an error.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn incomplete_without_text_is_an_error() {
|
||||
let mut server = Llm::new(APIS[0]).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let caller = host.clone();
|
||||
let turn =
|
||||
tokio::spawn(async move { caller.call("agent.chat", json!({"text":"answer"})).await });
|
||||
let (_, reply) = server.next().await;
|
||||
reply.send((200, json!({"status":"incomplete","output":[{"type":"reasoning","encrypted_content":"private"}]}))).unwrap();
|
||||
assert!(
|
||||
turn.await.unwrap().unwrap_err()["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("incomplete response")
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,450 +0,0 @@
|
||||
//! Binary contracts for atomic init, environment flags, signals, and the piped console.
|
||||
use sausage::{Host, world::World};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::AsyncWriteExt,
|
||||
process::{Child, Command},
|
||||
};
|
||||
|
||||
use super::common::{CACHE, probe};
|
||||
|
||||
fn binary() -> Command {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_sausage"));
|
||||
command
|
||||
.arg("--cache-dir")
|
||||
.arg(CACHE)
|
||||
.env_remove("SAUSAGE_HEADLESS")
|
||||
.env_remove("SAUSAGE_LOOP")
|
||||
.kill_on_drop(true);
|
||||
command
|
||||
}
|
||||
fn world() -> (tempfile::TempDir, PathBuf) {
|
||||
let folder = tempfile::tempdir().unwrap();
|
||||
let world = World::create(&folder.path().join("world")).unwrap();
|
||||
(folder, world.root)
|
||||
}
|
||||
async fn status(child: &mut Child) -> std::process::ExitStatus {
|
||||
tokio::time::timeout(Duration::from_secs(15), child.wait())
|
||||
.await
|
||||
.expect("CLI did not exit")
|
||||
.unwrap()
|
||||
}
|
||||
async fn journal_exists(world: &Path) {
|
||||
tokio::time::timeout(Duration::from_secs(15), async {
|
||||
while !world.join("logs/core/journal.sqlite").exists() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
fn signal(child: &Child, signal: nix::sys::signal::Signal) {
|
||||
nix::sys::signal::kill(
|
||||
nix::unistd::Pid::from_raw(child.id().unwrap() as i32),
|
||||
signal,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
fn install_probe(world: &Path) {
|
||||
std::fs::create_dir_all(world.join("plugins/probe")).unwrap();
|
||||
std::fs::copy(probe(), world.join("plugins/probe/plugin.wasm")).unwrap();
|
||||
}
|
||||
|
||||
/// Failed init leaves no world; successful init seeds the restart policy and a working agent.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn init_builds_agent() {
|
||||
tokio::time::timeout(Duration::from_secs(300), init_builds_agent_inner())
|
||||
.await
|
||||
.expect("init and seeded-agent checks exceeded 300 seconds");
|
||||
}
|
||||
|
||||
async fn init_builds_agent_inner() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let world = temporary.path().join("world");
|
||||
// Legacy configuration variables must not affect the templates.
|
||||
let configured = || {
|
||||
let mut command = binary();
|
||||
command
|
||||
.current_dir(temporary.path())
|
||||
.env("SAUSAGE_API", "not-an-api")
|
||||
.env("SAUSAGE_BASE_URL", "http://127.0.0.1:1/")
|
||||
.env("SAUSAGE_MODEL", "init-test")
|
||||
.env("SAUSAGE_API_TOKEN", "fake-token")
|
||||
.stdin(Stdio::null());
|
||||
command
|
||||
};
|
||||
let failed = configured()
|
||||
.arg("init")
|
||||
.env("SAUSAGE_HEADLESS", "not-a-boolean")
|
||||
.env("RUSTC", "/nonexistent-sausage-rustc")
|
||||
.output()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!failed.status.success());
|
||||
assert!(String::from_utf8_lossy(&failed.stderr).contains("nonexistent-sausage-rustc"));
|
||||
assert!(!world.exists());
|
||||
let output = configured().arg("init").output().await.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stdout).contains("first agent build takes a few minutes")
|
||||
);
|
||||
// Bare sausage reopens the completed world instead of trying to recreate it.
|
||||
assert!(configured().output().await.unwrap().status.success());
|
||||
let prompt = std::fs::read_to_string(world.join("plugins/agent/source/src/prompt.md")).unwrap();
|
||||
assert!(prompt.contains("HARD RULE: the mediator and all agents NEVER call `core.restart`"));
|
||||
assert!(prompt.contains("without the person's explicit approval each time"));
|
||||
assert!(prompt.contains("alias `core.exit`"));
|
||||
for text in [
|
||||
"`#loop`",
|
||||
"SAUSAGE_LOOP=1",
|
||||
"answers before",
|
||||
"exit code 75",
|
||||
] {
|
||||
assert!(prompt.contains(text), "seeded prompt missing {text}");
|
||||
}
|
||||
let boot = std::fs::read_to_string(world.join("config/boot.yaml")).unwrap();
|
||||
assert!(boot.contains("SAUSAGE_LOOP=1"));
|
||||
let mut config: Value =
|
||||
serde_yaml_ng::from_slice(&std::fs::read(world.join("config/agent.yaml")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(config["token"], "CHANGE_ME");
|
||||
assert_eq!(config["api"], "openai-responses");
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("config/agent.yaml"));
|
||||
assert_eq!(
|
||||
serde_yaml_ng::from_slice::<Value>(&std::fs::read(world.join("config/boot.yaml")).unwrap())
|
||||
.unwrap(),
|
||||
json!([])
|
||||
);
|
||||
for file in [
|
||||
"plugins/cargo/sdk/Cargo.toml",
|
||||
"plugins/cargo/sdk/src/lib.rs",
|
||||
"plugins/agent/source/Cargo.toml",
|
||||
"plugins/agent/source/src/main.rs",
|
||||
"plugins/agent/plugin.wasm",
|
||||
] {
|
||||
assert!(world.join(file).is_file(), "missing {file}");
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(world.join("plugins/agent/source/Cargo.lock")).unwrap(),
|
||||
include_bytes!("../../../Cargo.lock")
|
||||
);
|
||||
assert!(!world.join("state/cargo/target").exists());
|
||||
assert!(
|
||||
Path::new(CACHE)
|
||||
.join("cargo-target/wasm32-wasip2/release")
|
||||
.is_dir()
|
||||
);
|
||||
let host = Host::start_with_cache(&world, Path::new(CACHE))
|
||||
.await
|
||||
.unwrap();
|
||||
let doc = host.call("agent.rpc.discover", json!({})).await.unwrap();
|
||||
assert_eq!(doc["x-sausage-grace"], 600);
|
||||
assert!(
|
||||
doc["methods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|method| method["name"] == "chat")
|
||||
);
|
||||
let error = host
|
||||
.call("agent.chat", json!({"text":"hello"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("/config/agent.yaml")
|
||||
);
|
||||
assert!(error["message"].as_str().unwrap().contains("token"));
|
||||
let mut server = super::agent::Llm::new("anthropic-messages").await;
|
||||
config["api"] = json!("anthropic-messages");
|
||||
config["base_url"] = json!(server.url);
|
||||
config["model"] = json!("scripted-test");
|
||||
config["token"] = json!("fake-token");
|
||||
std::fs::write(
|
||||
world.join("config/agent.yaml"),
|
||||
serde_yaml_ng::to_string(&config).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let caller = host.clone();
|
||||
let turn =
|
||||
tokio::spawn(async move { caller.call("agent.chat", json!({"text":"hello"})).await });
|
||||
let (_, reply) = server.next().await;
|
||||
reply
|
||||
.send((200, server.answer("configured agent", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(turn.await.unwrap().unwrap(), "configured agent");
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// Both headless forms ignore EOF. False and empty values preserve the plain console.
|
||||
#[tokio::test]
|
||||
async fn headless_flag_and_environment() {
|
||||
for value in [None, Some("1"), Some("true")] {
|
||||
let (_folder, world) = world();
|
||||
let mut command = binary();
|
||||
command.arg("run").arg(&world).stdin(Stdio::null());
|
||||
if let Some(value) = value {
|
||||
command.env("SAUSAGE_HEADLESS", value);
|
||||
} else {
|
||||
command.arg("--headless");
|
||||
}
|
||||
let mut child = command.spawn().unwrap();
|
||||
journal_exists(&world).await;
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(300), child.wait())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
signal(&child, nix::sys::signal::Signal::SIGTERM);
|
||||
assert_eq!(status(&mut child).await.code(), Some(0));
|
||||
}
|
||||
for value in ["0", ""] {
|
||||
let (_folder, world) = world();
|
||||
let mut child = binary()
|
||||
.arg("run")
|
||||
.arg(world)
|
||||
.env("SAUSAGE_HEADLESS", value)
|
||||
.stdin(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
assert_eq!(status(&mut child).await.code(), Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Signals interrupt an unanswered boot service instead of waiting forever.
|
||||
#[tokio::test]
|
||||
async fn signals_work_during_boot() {
|
||||
for sig in [
|
||||
nix::sys::signal::Signal::SIGTERM,
|
||||
nix::sys::signal::Signal::SIGINT,
|
||||
] {
|
||||
let (_folder, world) = world();
|
||||
std::fs::write(
|
||||
world.join("config/boot.yaml"),
|
||||
json!([
|
||||
{"method":"core.lock","params":{"path":"boot"}},
|
||||
{"method":"core.lock","params":{"path":"boot"}}
|
||||
])
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut child = binary()
|
||||
.arg("run")
|
||||
.arg(&world)
|
||||
.arg("--headless")
|
||||
.stdin(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
journal_exists(&world).await;
|
||||
tokio::time::timeout(Duration::from_secs(15), async {
|
||||
loop {
|
||||
let db =
|
||||
rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
|
||||
if let Ok(2) = db.query_row(
|
||||
"SELECT count(*) FROM journal WHERE line='service start core.lock'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
) {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
signal(&child, sig);
|
||||
assert_eq!(status(&mut child).await.code(), Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Piped input supports commands, quit, and the restart exit code.
|
||||
#[tokio::test]
|
||||
async fn piped_console_and_exit_codes() {
|
||||
for (line, supervised, expected) in [
|
||||
("help\n", false, 0),
|
||||
("quit\n", false, 0),
|
||||
("core.restart\n", false, 0),
|
||||
("core.exit\n", false, 0),
|
||||
("core.restart\n", true, 75),
|
||||
("core.exit\n", true, 75),
|
||||
] {
|
||||
let (_folder, world) = world();
|
||||
let mut command = binary();
|
||||
command.arg("run").arg(world);
|
||||
if supervised {
|
||||
command.env("SAUSAGE_LOOP", "1");
|
||||
}
|
||||
let mut child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
// Supervised calls must finish via restart, not a racing console EOF.
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
input.write_all(line.as_bytes()).await.unwrap();
|
||||
if !supervised {
|
||||
drop(input);
|
||||
}
|
||||
let output = tokio::time::timeout(Duration::from_secs(15), child.wait_with_output())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(expected));
|
||||
if !supervised && line.starts_with("core.") {
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("SAUSAGE_LOOP not set"));
|
||||
}
|
||||
if expected == 0 {
|
||||
assert!(!output.stdout.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A console input error still stops live actors and flushes the journal.
|
||||
#[tokio::test]
|
||||
async fn console_error_stops_host() {
|
||||
let (_folder, world) = world();
|
||||
install_probe(&world);
|
||||
std::fs::write(
|
||||
world.join("config/boot.yaml"),
|
||||
json!([{"method":"probe.serve","params":{}}]).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut child = binary()
|
||||
.arg("run")
|
||||
.arg(&world)
|
||||
.stdin(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(&[255, 10])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status(&mut child).await.code(), Some(1));
|
||||
let db = rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
|
||||
let stopped: i64 = db
|
||||
.query_row(
|
||||
"SELECT count(*) FROM journal WHERE plugin='probe' AND line='stop'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(stopped > 0);
|
||||
}
|
||||
|
||||
/// SIGINT stops only the wait, so ps can still find the live call.
|
||||
#[tokio::test]
|
||||
async fn interrupt_leaves_call_running() {
|
||||
let (_folder, world) = world();
|
||||
install_probe(&world);
|
||||
let mut child = binary()
|
||||
.arg("run")
|
||||
.arg(&world)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
input.write_all(b"probe.busy\n").await.unwrap();
|
||||
let pid = tokio::time::timeout(Duration::from_secs(20), async {
|
||||
loop {
|
||||
if world.join("logs/core/journal.sqlite").exists() {
|
||||
let db =
|
||||
rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
|
||||
if let Ok(pid) = db.query_row(
|
||||
"SELECT pid FROM journal WHERE plugin='probe' AND line='start busy {}'",
|
||||
[],
|
||||
|r| r.get::<_, String>(0),
|
||||
) {
|
||||
break pid;
|
||||
}
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
signal(&child, nix::sys::signal::Signal::SIGINT);
|
||||
input
|
||||
.write_all(format!("ps\nkill {pid}\nquit\n").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
drop(input);
|
||||
let output = tokio::time::timeout(Duration::from_secs(20), child.wait_with_output())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let text = String::from_utf8(output.stdout).unwrap();
|
||||
assert_eq!(output.status.code(), Some(0), "{text}");
|
||||
assert!(
|
||||
text.contains("stopped waiting; the call continues, see ps"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(&format!("{pid} probe busy console running")),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Piped calls wait for activation, and EOF or quit preserves the last result.
|
||||
#[tokio::test]
|
||||
async fn piped_console_runs_dependent_commands_in_order() {
|
||||
for ending in ["", "quit\n"] {
|
||||
let (_folder, world) = world();
|
||||
std::fs::copy(probe(), world.join("candidate.wasm")).unwrap();
|
||||
std::fs::create_dir_all(world.join("state/probe")).unwrap();
|
||||
std::fs::write(world.join("state/probe/discover.json"), r#"{"methods":[]}"#).unwrap();
|
||||
let activate = r#"core.activate {"plugin":"notes","wasm":"candidate.wasm","force":true}"#;
|
||||
let call = r#"notes.echo {"ordered":true}"#;
|
||||
let mut child = binary()
|
||||
.arg("run")
|
||||
.arg(&world)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(format!("{activate}\n{call}\n{ending}").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
let output = tokio::time::timeout(Duration::from_secs(20), child.wait_with_output())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
assert_eq!(
|
||||
output.status.code(),
|
||||
Some(0),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(!stdout.contains("error:"), "{stdout}");
|
||||
assert!(stdout.contains(r#""ordered": true"#), "{stdout}");
|
||||
assert!(!stdout.contains("> core.activate"), "{stdout}");
|
||||
if !ending.is_empty() {
|
||||
assert!(
|
||||
stdout.find(r#""ordered": true"#).unwrap() < stdout.find("Goodbye.").unwrap(),
|
||||
"{stdout}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
//! Persistent caches and guest builds shared by the host integration modules.
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
pub const CACHE: &str = concat!(env!("CARGO_TARGET_TMPDIR"), "/cache");
|
||||
|
||||
pub fn build(package: &str, binary: &str) -> PathBuf {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let target = Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/guests"));
|
||||
assert!(
|
||||
std::process::Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--offline",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"--manifest-path"
|
||||
])
|
||||
.arg(root.join(package).join("Cargo.toml"))
|
||||
.arg("--target-dir")
|
||||
.arg(target)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
target
|
||||
.join("wasm32-wasip2/debug")
|
||||
.join(format!("{binary}.wasm"))
|
||||
}
|
||||
|
||||
pub fn probe() -> PathBuf {
|
||||
static WASM: OnceLock<PathBuf> = OnceLock::new();
|
||||
WASM.get_or_init(|| build("cli/tests/plugins/probe", "probe"))
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn sdk_probe() -> PathBuf {
|
||||
static WASM: OnceLock<PathBuf> = OnceLock::new();
|
||||
WASM.get_or_init(|| build("sdk/tests/plugins/probe", "sdk-probe"))
|
||||
.clone()
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
//! Console command contracts against a temporary world and the raw probe component.
|
||||
use super::common::probe as fixture;
|
||||
use sausage::{
|
||||
Host,
|
||||
console::{execute_line, notification_text},
|
||||
world::World,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
struct TestWorld(PathBuf);
|
||||
impl TestWorld {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!("sausage-console-{}", uuid::Uuid::now_v7()));
|
||||
let world = World::create(&root).unwrap();
|
||||
for plugin in ["probe", "agent"] {
|
||||
std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap();
|
||||
std::fs::copy(fixture(), world.plugin_dir(plugin).join("plugin.wasm")).unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(world.state_dir("probe")).unwrap();
|
||||
std::fs::write(world.state_dir("probe").join("discover.json"),json!({"methods":[{"name":"echo","description":"Echo supplied text.","params":[{"name":"text","description":"The input text.","schema":{"type":"string"}}],"result":{"schema":{"type":"string"}}}]}).to_string()).unwrap();
|
||||
let mut candidate = std::fs::read(fixture()).unwrap();
|
||||
// A custom section creates a new version without changing probe behavior.
|
||||
candidate.extend_from_slice(&[0, 2, 1, b'c']);
|
||||
std::fs::write(root.join("candidate.wasm"), candidate).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
async fn start(&self) -> Host {
|
||||
let cache = PathBuf::from(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache"));
|
||||
Host::start_with_cache(&self.0, &cache).await.unwrap()
|
||||
}
|
||||
}
|
||||
impl Drop for TestWorld {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
async fn command(host: &Host, line: &str) -> String {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(60), execute_line(host, line))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Explicit calls return indented JSON data, and RPC errors remain visible.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn rpc_call() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let result = command(&host, "probe.echo {\"answer\":42}").await;
|
||||
assert!(result.contains("\n \"answer\": 42\n"));
|
||||
assert!(command(&host, "absent.echo {}").await.contains("error:"));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&command(&host, r#"agent.chat {"text":"explicit"}"#).await)
|
||||
.unwrap(),
|
||||
json!({"text":"explicit"})
|
||||
);
|
||||
assert_eq!(
|
||||
command(&host, "probe.echo not-json").await,
|
||||
"probe.echo not-json"
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// Plain text calls agent.chat and its interim notification uses dim styling.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn chat() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let mut notifications = host.subscribe();
|
||||
assert_eq!(command(&host, "hello agent").await, "hello agent");
|
||||
let value = notifications.recv().await.unwrap();
|
||||
assert_eq!(notification_text(&value), "\x1b[2minterim\x1b[0m");
|
||||
assert_eq!(
|
||||
command(&host, "Hello. Another sentence.").await,
|
||||
"Hello. Another sentence."
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// Invalid command arguments and dotted prose remain agent chat.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn prose_is_not_a_command() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
for line in [
|
||||
"help me write a notes plugin",
|
||||
"ps is useful",
|
||||
"stop it",
|
||||
"kill that",
|
||||
"cat",
|
||||
"methods probe extra",
|
||||
"ls too many words",
|
||||
"log -n nope",
|
||||
"README.md says hello",
|
||||
"agent.chat not-json",
|
||||
"e.g. write notes",
|
||||
] {
|
||||
assert_eq!(command(&host, line).await, line);
|
||||
}
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// Folder listings include the live mounts and accept an explicit folder.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ls() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let result = command(&host, "ls").await;
|
||||
for name in ["proc/", "run/", "self/"] {
|
||||
assert!(result.contains(name));
|
||||
}
|
||||
assert!(command(&host, "ls /proc/core").await.contains("plugins/"));
|
||||
host.stop().await;
|
||||
}
|
||||
/// Cat reads multiple chunks and finite generated snapshots through the VFS.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn cat() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let text = "x".repeat(150_000);
|
||||
std::fs::write(world.0.join("large"), &text).unwrap();
|
||||
assert_eq!(command(&host, "cat /large").await, text);
|
||||
host.call("probe.write", json!({"path":"/run/shared","text":"memory"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(command(&host, "cat /run/shared").await, "memory");
|
||||
assert!(
|
||||
command(&host, "cat /proc/core/PROTOCOL.md")
|
||||
.await
|
||||
.contains("filesystem")
|
||||
);
|
||||
assert!(execute_line(&host, "cat /missing").await.is_err());
|
||||
host.stop().await;
|
||||
}
|
||||
/// Method summaries include parameter types, result types, and documentation.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn methods() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let result = command(&host, "methods probe").await;
|
||||
for text in [
|
||||
"probe.echo(text: string) -> string",
|
||||
"Echo supplied text.",
|
||||
"The input text.",
|
||||
] {
|
||||
assert!(result.contains(text), "{result}");
|
||||
}
|
||||
host.stop().await;
|
||||
}
|
||||
/// Ps includes every requested column and the live process metadata.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ps() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let pid = host.call("probe.serve", json!({})).await.unwrap();
|
||||
let text = command(&host, "ps").await;
|
||||
assert!(text.contains("PID PLUGIN METHOD PARENT STATE AGE"));
|
||||
assert!(text.contains(pid.as_str().unwrap()));
|
||||
assert!(text.contains("probe serve console"));
|
||||
host.stop().await;
|
||||
}
|
||||
/// Log formats local times and short pids, while core.log preserves JSON and filters.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn log() {
|
||||
let world = TestWorld::new();
|
||||
std::fs::create_dir_all(world.0.join("plugins/data-logger")).unwrap();
|
||||
std::fs::copy(fixture(), world.0.join("plugins/data-logger/plugin.wasm")).unwrap();
|
||||
let host = world.start().await;
|
||||
host.call("data-logger.echo", json!({})).await.unwrap();
|
||||
// Finish discovery before comparing two snapshots of the journal.
|
||||
command(&host, "methods data-logger").await;
|
||||
let pid = host.call("probe.serve", json!({})).await.unwrap();
|
||||
for (line, filters) in [
|
||||
("log".to_owned(), json!({})),
|
||||
(
|
||||
"log data-logger -n 1".to_owned(),
|
||||
json!({"plugin":"data-logger","limit":1}),
|
||||
),
|
||||
(
|
||||
"log probe -n 1".to_owned(),
|
||||
json!({"plugin":"probe","limit":1}),
|
||||
),
|
||||
(
|
||||
format!("log -n 1 {}", pid.as_str().unwrap()),
|
||||
json!({"pid":pid,"limit":1}),
|
||||
),
|
||||
] {
|
||||
let raw = command(&host, &format!("core.log {filters}")).await;
|
||||
let entries: Value = serde_json::from_str(&raw).unwrap();
|
||||
let entries = entries.as_array().unwrap();
|
||||
assert!(!entries.is_empty());
|
||||
let printed = command(&host, &line).await;
|
||||
let lines: Vec<_> = printed.lines().collect();
|
||||
assert_eq!(lines.len(), entries.len(), "{printed}");
|
||||
if line != "log" {
|
||||
assert_eq!(entries.len(), 1);
|
||||
}
|
||||
for (line, entry) in lines.into_iter().zip(entries) {
|
||||
let time = chrono::DateTime::parse_from_rfc3339(entry["time"].as_str().unwrap())
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Local)
|
||||
.format("%H:%M:%S%.3f")
|
||||
.to_string();
|
||||
let plugin = entry["plugin"].as_str().unwrap();
|
||||
let pid = entry["pid"].as_str().unwrap();
|
||||
let prefix = format!("{plugin}-");
|
||||
let short = pid
|
||||
.strip_prefix(&prefix)
|
||||
.map(|uuid| format!("{prefix}{}", &uuid[..8]))
|
||||
.unwrap_or_else(|| pid.to_owned());
|
||||
assert_eq!(
|
||||
line,
|
||||
format!(
|
||||
"{} {} {} {}",
|
||||
time,
|
||||
short,
|
||||
entry["stream"].as_str().unwrap(),
|
||||
entry["line"].as_str().unwrap()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(command(&host, "log absent-plugin").await, "");
|
||||
assert_eq!(command(&host, "log -n").await, "log -n");
|
||||
host.stop().await;
|
||||
}
|
||||
/// Activate installs a candidate through core.activate.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn activate() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let result = command(&host, "activate probe candidate.wasm").await;
|
||||
assert!(!result.starts_with("error:"), "{result}");
|
||||
assert!(world.0.join("plugins/probe/plugin.prev.wasm").exists());
|
||||
host.stop().await;
|
||||
}
|
||||
/// Revert restores the previous component through core.revert.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn revert() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
command(&host, "activate probe candidate.wasm").await;
|
||||
let result = command(&host, "revert probe").await;
|
||||
assert!(!result.starts_with("error:"), "{result}");
|
||||
host.stop().await;
|
||||
}
|
||||
/// Stop closes a live process gracefully.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stop() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let pid = host.call("probe.serve", json!({})).await.unwrap();
|
||||
command(&host, &format!("stop {}", pid.as_str().unwrap())).await;
|
||||
assert!(!host.processes().contains_key(pid.as_str().unwrap()));
|
||||
host.stop().await;
|
||||
}
|
||||
/// Kill ends a live process immediately.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kill() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let pid = host.call("probe.serve", json!({})).await.unwrap();
|
||||
command(&host, &format!("kill {}", pid.as_str().unwrap())).await;
|
||||
assert!(!host.processes().contains_key(pid.as_str().unwrap()));
|
||||
host.stop().await;
|
||||
}
|
||||
/// Help lists all console commands.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn help() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let text = command(&host, "help").await;
|
||||
for name in [
|
||||
"agent",
|
||||
"plugin.method",
|
||||
"ls",
|
||||
"cat",
|
||||
"methods",
|
||||
"ps",
|
||||
"log",
|
||||
"activate",
|
||||
"revert",
|
||||
"stop",
|
||||
"kill",
|
||||
"help",
|
||||
"quit",
|
||||
] {
|
||||
assert!(text.contains(name));
|
||||
}
|
||||
host.stop().await;
|
||||
}
|
||||
/// Quit produces a final message, and blank input does nothing.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn quit() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
assert_eq!(command(&host, "quit").await, "Goodbye.");
|
||||
assert_eq!(command(&host, " ").await, "");
|
||||
host.stop().await;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
//! Host integration contracts run in one parallel libtest pool.
|
||||
mod activation;
|
||||
mod agent;
|
||||
mod cargo;
|
||||
mod cli;
|
||||
mod common;
|
||||
mod console;
|
||||
mod core;
|
||||
mod sdk;
|
||||
mod watches;
|
||||
@@ -1,399 +0,0 @@
|
||||
//! Exercise SDK plugins through the real host, without a fake core.
|
||||
use sausage::{Host, world::World};
|
||||
use serde_json::{Value, json};
|
||||
use std::{path::PathBuf, sync::OnceLock, time::Duration};
|
||||
|
||||
fn fixtures() -> &'static PathBuf {
|
||||
static TARGET: OnceLock<PathBuf> = OnceLock::new();
|
||||
TARGET.get_or_init(|| {
|
||||
for (package, binary) in [
|
||||
("examples/notes", "notes"),
|
||||
("examples/consumer", "consumer"),
|
||||
("examples/www", "www"),
|
||||
("examples/secrets", "secrets"),
|
||||
("sdk/tests/plugins/consumer", "sdk-consumer"),
|
||||
] {
|
||||
super::common::build(package, binary);
|
||||
}
|
||||
super::common::sdk_probe().parent().unwrap().to_owned()
|
||||
})
|
||||
}
|
||||
struct TestWorld(PathBuf);
|
||||
impl TestWorld {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("sausage-sdk-{}", uuid::Uuid::now_v7()));
|
||||
let world = World::create(&path).unwrap();
|
||||
for (plugin, binary) in [
|
||||
("notes", "notes"),
|
||||
("consumer", "consumer"),
|
||||
("www", "www"),
|
||||
("secrets", "secrets"),
|
||||
("probe", "sdk-probe"),
|
||||
("typed", "sdk-consumer"),
|
||||
] {
|
||||
std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap();
|
||||
std::fs::copy(
|
||||
fixtures().join(format!("{binary}.wasm")),
|
||||
world.plugin_dir(plugin).join("plugin.wasm"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
Self(path)
|
||||
}
|
||||
async fn start(&self) -> Host {
|
||||
Host::start_with_cache(
|
||||
&self.0,
|
||||
std::path::Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
impl Drop for TestWorld {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
async fn call(host: &Host, method: &str, params: Value) -> Value {
|
||||
tokio::time::timeout(Duration::from_secs(60), host.call(method, params))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Concurrent read-modify-write calls retain every note under the SDK lock.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn notes_lock_preserves_parallel_additions() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
assert_eq!(
|
||||
call(&host, "notes.add", json!({"text":"first"})).await["count"],
|
||||
1
|
||||
);
|
||||
let calls = (0..12)
|
||||
.map(|n| {
|
||||
let h = host.clone();
|
||||
tokio::spawn(
|
||||
async move { call(&h, "notes.add", json!({"text":format!("note-{n}")})).await },
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for task in calls {
|
||||
task.await.unwrap();
|
||||
}
|
||||
let notes = call(&host, "notes.list", json!({})).await;
|
||||
assert_eq!(notes.as_array().unwrap().len(), 13);
|
||||
assert_eq!(notes[0], "first");
|
||||
for n in 0..12 {
|
||||
assert!(
|
||||
notes
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!(format!("note-{n}")))
|
||||
);
|
||||
}
|
||||
host.stop().await;
|
||||
}
|
||||
/// The example consumer invokes notes through its generated import.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn generated_notes_import() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
assert_eq!(
|
||||
call(&host, "consumer.note", json!({"text":"typed"})).await,
|
||||
1
|
||||
);
|
||||
assert_eq!(call(&host, "notes.list", json!({})).await, json!(["typed"]));
|
||||
host.stop().await;
|
||||
}
|
||||
/// Discovery retains the complete document and satisfies the consumer contract.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn discovery_is_compatible() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let actual = call(&host, "notes.rpc.discover", json!({})).await;
|
||||
let consumer = call(&host, "consumer.rpc.discover", json!({})).await;
|
||||
let expected: Value = serde_json::from_str(include_str!(
|
||||
"../../../examples/consumer/imports/notes.json"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(actual["openrpc"], "1.2.6");
|
||||
assert!(actual["components"]["schemas"]["AddParams"].is_object());
|
||||
assert!(
|
||||
actual["methods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|m| m["name"] == "list")
|
||||
.unwrap()
|
||||
.get("x-sausage-params")
|
||||
.is_none()
|
||||
);
|
||||
assert!(actual["components"]["schemas"].get("Empty").is_none());
|
||||
assert!(
|
||||
sausage_interface::compat::check(&consumer["x-sausage-imports"]["notes"], &actual)
|
||||
.is_empty()
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// Detached SDK calls receive progress and forward it through core.notify.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn detached_call_notifications() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let mut events = host.subscribe();
|
||||
assert_eq!(call(&host, "probe.relay", json!({})).await, 42);
|
||||
let event = tokio::time::timeout(Duration::from_secs(5), events.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(event["params"]["name"], "step");
|
||||
host.stop().await;
|
||||
}
|
||||
/// Detached typed calls complete and detached services survive their original caller.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn detached_service_survives_parent() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
assert_eq!(call(&host, "typed.run", json!({})).await, 5);
|
||||
let child = call(&host, "probe.launch", json!({})).await;
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while host.processes().values().any(|p| p.method == "launch") {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(host.processes().contains_key(child.as_str().unwrap()));
|
||||
host.stop().await;
|
||||
}
|
||||
/// A core.stop request closes stdin and executes the SDK stop hook.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stop_runs_hook() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let pid = call(&host, "probe.service", json!({})).await;
|
||||
call(&host, "core.stop", json!({"pid":pid})).await;
|
||||
assert_eq!(std::fs::read(world.0.join("stopped")).unwrap(), b"yes");
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 3.7: an idle SDK listener opts into drain and exits before EOF/grace.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn superseded_idle_listener_drains_before_grace() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let (notice, mut notices) = tokio::sync::mpsc::unbounded_channel();
|
||||
host.register_native(
|
||||
"other",
|
||||
std::sync::Arc::new(move |_, method, _| {
|
||||
let notice = notice.clone();
|
||||
Box::pin(async move {
|
||||
assert_eq!(method, "superseded");
|
||||
notice.send(std::time::Instant::now()).unwrap();
|
||||
Ok(json!({}))
|
||||
})
|
||||
}),
|
||||
);
|
||||
let pid = call(&host, "probe.drain_service", json!({})).await;
|
||||
// Replace with a different real component; force only skips unrelated test consumers.
|
||||
std::fs::copy(
|
||||
fixtures().join("notes.wasm"),
|
||||
world.0.join("state/next.wasm"),
|
||||
)
|
||||
.unwrap();
|
||||
call(
|
||||
&host,
|
||||
"core.activate",
|
||||
json!({"plugin":"probe","wasm":"/state/next.wasm","force":true}),
|
||||
)
|
||||
.await;
|
||||
// Activation preparation/discovery is outside upgrade grace. Time from the
|
||||
// hook's outgoing call, not from the start of candidate compilation.
|
||||
let before = tokio::time::timeout(Duration::from_secs(60), notices.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
// Waiting on its lifetime lock is an exit event, not process-liveness polling.
|
||||
call(&host, "core.lock", json!({"path":"/drain-owner"})).await;
|
||||
assert!(before.elapsed() < Duration::from_secs(5));
|
||||
assert!(!host.processes().contains_key(pid.as_str().unwrap()));
|
||||
assert!(
|
||||
!world
|
||||
.0
|
||||
.join(format!("stopped-{}", pid.as_str().unwrap()))
|
||||
.exists(),
|
||||
"drain must not run on_stop (discovery processes may run their own hooks)"
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// SDK framing carries payloads above 64 KiB in both directions.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn large_messages() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let text = "z".repeat(192 * 1024);
|
||||
assert_eq!(call(&host, "probe.echo", json!({"text":text})).await, text);
|
||||
host.stop().await;
|
||||
}
|
||||
/// Normal SDK stdout never produces the discarded-output journal warning.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn no_discarded_stdout() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
call(&host, "probe.echo", json!({"text":"framing"})).await;
|
||||
host.stop().await;
|
||||
assert_eq!(
|
||||
call(&host, "core.log", json!({"grep":"discarded stdout"})).await,
|
||||
json!([])
|
||||
);
|
||||
}
|
||||
/// Two SDK processes exchange endpoint requests and progress at a world path.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn world_endpoint() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
call(&host, "probe.service", json!({})).await;
|
||||
assert_eq!(call(&host, "typed.world_endpoint", json!({})).await, 7);
|
||||
host.stop().await;
|
||||
}
|
||||
/// The HTTP example exposes its self endpoint through the process namespace.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn www_control_through_proc() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
assert_eq!(
|
||||
call(&host, "consumer.demo", json!({"text":"web","port":0})).await,
|
||||
0
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// The vault example publishes a fixed endpoint in the shared run namespace.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn secrets_fixed_endpoint() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
call(&host, "secrets.serve", json!({})).await;
|
||||
assert_eq!(call(&host, "consumer.vault", json!({})).await, "hello");
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.6: a background task retains its SDK process until the task completes.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn background_task_retains_process() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let pid = call(&host, "probe.background_hold", json!({})).await;
|
||||
assert!(host.processes().contains_key(pid.as_str().unwrap()));
|
||||
assert_eq!(call(&host, "notes.list", json!({})).await, json!([]));
|
||||
assert!(host.processes().contains_key(pid.as_str().unwrap()));
|
||||
std::fs::write(world.0.join("release-background"), "release").unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while host.processes().contains_key(pid.as_str().unwrap()) {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(world.0.join("background-ended")).unwrap(),
|
||||
"done"
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 5: stale endpoint identities and interfaces fail through genuine SDK processes.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn endpoint_handshake_rejects_mismatch() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let pid = call(&host, "probe.service", json!({})).await;
|
||||
let path = world.0.join("state/control");
|
||||
let original = std::fs::read_to_string(&path).unwrap();
|
||||
let words: Vec<_> = original.split_whitespace().collect();
|
||||
for changed in [
|
||||
format!("{} wrong-owner {}\n", words[0], words[2]),
|
||||
format!("{} {} wrong-interface\n", words[0], words[1]),
|
||||
] {
|
||||
std::fs::write(&path, changed).unwrap();
|
||||
let error = host
|
||||
.call("typed.world_endpoint", json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error["code"], -32000);
|
||||
assert!(error["message"].as_str().unwrap().contains("mismatch"));
|
||||
assert!(host.processes().contains_key(pid.as_str().unwrap()));
|
||||
}
|
||||
std::fs::write(path, original).unwrap();
|
||||
assert_eq!(call(&host, "typed.world_endpoint", json!({})).await, 7);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 10.1 and 5: discovery through core retains params, endpoints, imports, and grace.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn discovery_extensions_through_core() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let provider = call(&host, "probe.rpc.discover", json!({})).await;
|
||||
assert_eq!(provider["openrpc"], "1.2.6");
|
||||
assert_eq!(provider["x-sausage-grace"], 12);
|
||||
assert!(
|
||||
provider["x-sausage-endpoints"]["control"]["methods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|m| m["name"] == "stats")
|
||||
);
|
||||
let add = provider["methods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|m| m["name"] == "add")
|
||||
.unwrap();
|
||||
assert_eq!(add["paramStructure"], "by-name");
|
||||
let reference = add["x-sausage-params"]["$ref"].as_str().unwrap();
|
||||
assert_eq!(
|
||||
provider.pointer(reference.trim_start_matches('#')).unwrap()["type"],
|
||||
"object"
|
||||
);
|
||||
let consumer = call(&host, "typed.rpc.discover", json!({})).await;
|
||||
assert!(
|
||||
consumer["x-sausage-imports"]["probe"]["methods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|m| m["name"] == "add")
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.2: rpc.discover is SDK control traffic and endpoint control is not a main method.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reserved_rpc_control_names() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
assert!(call(&host, "probe.rpc.discover", json!({})).await["methods"].is_array());
|
||||
for method in ["probe.rpc.endpoint", "probe.rpc.unknown"] {
|
||||
assert_eq!(
|
||||
host.call(method, json!({})).await.unwrap_err()["code"],
|
||||
-32601
|
||||
);
|
||||
}
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.1: the SDK's leading newline isolates an unfinished diagnostic from its response.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn leading_newline_protects_sdk_response() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
assert_eq!(
|
||||
call(&host, "probe.framed_output", json!({})).await,
|
||||
"intact"
|
||||
);
|
||||
host.stop().await;
|
||||
let warnings = call(&host, "core.log", json!({"grep":"discarded stdout"})).await;
|
||||
assert_eq!(warnings.as_array().unwrap().len(), 1);
|
||||
assert!(!warnings.to_string().contains("partial diagnostic"));
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
//! Real SDK watch callbacks and raw WASI filesystem mutations share the host namespace.
|
||||
use super::common::{CACHE, probe, sdk_probe};
|
||||
use sausage::{Host, world::World};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Duration;
|
||||
|
||||
// Same call bound as core.rs; no sleeps or polling.
|
||||
async fn call(h: &Host, method: &str, p: Value) -> Value {
|
||||
tokio::time::timeout(Duration::from_secs(20), h.call(method, p))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
async fn event(h: &Host, rx: &mut tokio::sync::broadcast::Receiver<Value>, name: &str) -> Value {
|
||||
let result = tokio::time::timeout(Duration::from_secs(20), async {
|
||||
loop {
|
||||
let e = rx.recv().await.unwrap();
|
||||
if e["params"]["name"] == name {
|
||||
return e["params"]["params"].clone();
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"JOURNAL {}",
|
||||
h.call("core.log", json!({"limit":100})).await.unwrap()
|
||||
);
|
||||
panic!("{e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn start() -> (tempfile::TempDir, Host) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let world = World::create(&dir.path().join("world")).unwrap();
|
||||
for (name, wasm) in [("probe", probe()), ("sdk", sdk_probe())] {
|
||||
std::fs::create_dir_all(world.plugin_dir(name)).unwrap();
|
||||
std::fs::copy(wasm, world.plugin_dir(name).join("plugin.wasm")).unwrap();
|
||||
}
|
||||
let host = Host::start_with_cache(&world.root, std::path::Path::new(CACHE))
|
||||
.await
|
||||
.unwrap();
|
||||
(dir, host)
|
||||
}
|
||||
fn watch(h: &Host, path: &str) -> tokio::task::JoinHandle<sausage::RpcResult> {
|
||||
let (h, path) = (h.clone(), path.to_owned());
|
||||
tokio::spawn(async move { h.call("sdk.watch", json!({"path":path})).await })
|
||||
}
|
||||
async fn mutate(h: &Host, op: &str, path: &str) {
|
||||
call(h, "probe.mutate", json!({"op":op,"path":path})).await;
|
||||
}
|
||||
/// PROTOCOL 4: pre-arm state is in the first scan; each post-scan mutation wakes
|
||||
/// the SDK without completing the watch. Covers descriptor/stream/truncate paths.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn armed_scan_and_later_changes() {
|
||||
let (_dir, h) = start().await;
|
||||
mutate(&h, "mkdir", "/state/mailbox").await;
|
||||
call(
|
||||
&h,
|
||||
"probe.write",
|
||||
json!({"path":"/state/mailbox/before", "text":"before"}),
|
||||
)
|
||||
.await;
|
||||
let mut rx = h.subscribe();
|
||||
let task = watch(&h, "/state/mailbox/../mailbox/");
|
||||
let armed = event(&h, &mut rx, "armed").await;
|
||||
assert_eq!(armed["entries"], json!(["before"]));
|
||||
assert!(!task.is_finished());
|
||||
for (op, expected) in [
|
||||
("descriptor", "descriptor"),
|
||||
("stream", "streamptor"),
|
||||
("append", "streamptorappend"),
|
||||
("size", ""),
|
||||
("descriptor", "descriptor"),
|
||||
("truncate", ""),
|
||||
] {
|
||||
mutate(&h, op, "/state/mailbox/before").await;
|
||||
let mut scan = event(&h, &mut rx, "changed").await;
|
||||
while scan["text"] != expected {
|
||||
scan = event(&h, &mut rx, "changed").await;
|
||||
}
|
||||
}
|
||||
mutate(&h, "burst", "/state/mailbox").await;
|
||||
let mut scanned = event(&h, &mut rx, "changed").await;
|
||||
while !scanned["entries"].as_array().unwrap().contains(&json!("4")) {
|
||||
scanned = event(&h, &mut rx, "changed").await;
|
||||
}
|
||||
while rx.try_recv().is_ok() {}
|
||||
mutate(&h, "delete", "/state/mailbox/4").await;
|
||||
let mut scanned = event(&h, &mut rx, "changed").await;
|
||||
while scanned["entries"].as_array().unwrap().contains(&json!("4")) {
|
||||
scanned = event(&h, &mut rx, "changed").await;
|
||||
}
|
||||
call(&h, "core.kill", json!({"pid":armed["pid"]})).await;
|
||||
assert!(task.await.unwrap().is_err());
|
||||
h.stop().await;
|
||||
}
|
||||
/// PROTOCOL 4: atomic publication, rename source/destination, mounted paths,
|
||||
/// absent paths, and caller-death cleanup. No external host writes are required.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn rename_delete_and_mounts() {
|
||||
let (_dir, h) = start().await;
|
||||
for base in ["/state/inbox", "/run/inbox"] {
|
||||
let mut rx = h.subscribe();
|
||||
let task = watch(&h, base);
|
||||
let armed = event(&h, &mut rx, "armed").await;
|
||||
assert_eq!(armed["entries"], json!([]));
|
||||
let staging = format!("{base}-staging");
|
||||
mutate(&h, "mkdir", &staging).await;
|
||||
call(
|
||||
&h,
|
||||
"probe.mutate",
|
||||
json!({"op":"rename","path":staging,"to":base}),
|
||||
)
|
||||
.await;
|
||||
event(&h, &mut rx, "changed").await;
|
||||
mutate(&h, "atomic", &format!("{base}/published")).await;
|
||||
let mut scan = event(&h, &mut rx, "changed").await;
|
||||
while !scan["entries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("published"))
|
||||
{
|
||||
scan = event(&h, &mut rx, "changed").await;
|
||||
}
|
||||
assert_eq!(
|
||||
call(
|
||||
&h,
|
||||
"probe.read",
|
||||
json!({"path":format!("{base}/published")})
|
||||
)
|
||||
.await,
|
||||
"complete"
|
||||
);
|
||||
while rx.try_recv().is_ok() {}
|
||||
call(
|
||||
&h,
|
||||
"probe.mutate",
|
||||
json!({"op":"rename","path":base,"to":format!("{base}-moved")}),
|
||||
)
|
||||
.await;
|
||||
let mut scan = event(&h, &mut rx, "changed").await;
|
||||
while scan["entries"] != json!([]) {
|
||||
scan = event(&h, &mut rx, "changed").await;
|
||||
}
|
||||
call(&h, "core.kill", json!({"pid":armed["pid"]})).await;
|
||||
assert!(task.await.unwrap().is_err());
|
||||
}
|
||||
// /self watches bind to this caller, and mutations are visible via /proc/<pid>.
|
||||
let mut rx = h.subscribe();
|
||||
let task = watch(&h, "/self");
|
||||
let armed = event(&h, &mut rx, "armed").await;
|
||||
assert!(
|
||||
armed["entries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("owned"))
|
||||
);
|
||||
event(&h, &mut rx, "changed").await;
|
||||
assert_eq!(
|
||||
call(
|
||||
&h,
|
||||
"probe.read",
|
||||
json!({"path":format!("/proc/{}/owned", armed["pid"].as_str().unwrap())})
|
||||
)
|
||||
.await,
|
||||
"visible via proc"
|
||||
);
|
||||
call(&h, "core.kill", json!({"pid":armed["pid"]})).await;
|
||||
assert!(task.await.unwrap().is_err());
|
||||
h.stop().await;
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
//! Raw discovery and lifecycle guest for activation protocol tests.
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{self, BufRead, Write};
|
||||
/// Emits one flushed JSON-RPC response.
|
||||
fn send(value: Value) {
|
||||
println!("{value}");
|
||||
io::stdout().flush().unwrap();
|
||||
}
|
||||
/// Calls a provider after activation releases an old consumer process.
|
||||
fn deferred(request: &Value, plugin: &str, lines: &mut impl Iterator<Item = io::Result<String>>) {
|
||||
send(
|
||||
json!({"jsonrpc":"2.0","id":request["id"],"result":std::env::var("SAUSAGE_PID").unwrap()}),
|
||||
);
|
||||
let before = std::time::Instant::now();
|
||||
while !std::path::Path::new(&format!("/state/{plugin}/release-call")).exists() {
|
||||
assert!(before.elapsed() < std::time::Duration::from_secs(30));
|
||||
}
|
||||
send(json!({"jsonrpc":"2.0","id":"deferred","method":"notes.legacy","params":{}}));
|
||||
for line in lines {
|
||||
let response: Value = serde_json::from_str(&line.unwrap()).unwrap();
|
||||
if response["id"] == "deferred" {
|
||||
std::fs::write(format!("/state/{plugin}/call-result"), response.to_string()).unwrap();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Answers a method that version two removes from its interface.
|
||||
fn legacy(request: &Value, document: &Value, version: &str) {
|
||||
if document["methods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|m| m["name"] == "legacy")
|
||||
{
|
||||
send(json!({"jsonrpc":"2.0","id":request["id"],"result":version}));
|
||||
} else {
|
||||
send(
|
||||
json!({"jsonrpc":"2.0","id":request["id"],"error":{"code":-32601,"message":"legacy removed","data":{"version":version}}}),
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Reads a version-specific discovery document without the SDK.
|
||||
fn main() {
|
||||
let version = env!("FIXTURE_VERSION");
|
||||
let plugin = std::env::var("SAUSAGE_PLUGIN").unwrap();
|
||||
let mut lines = io::stdin().lock().lines();
|
||||
let request: Value = serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap();
|
||||
let document: Value =
|
||||
serde_json::from_slice(&std::fs::read(format!("/state/{plugin}/{version}.json")).unwrap())
|
||||
.unwrap();
|
||||
let result = match request["method"].as_str().unwrap() {
|
||||
"deferred" => {
|
||||
deferred(&request, &plugin, &mut lines);
|
||||
return;
|
||||
}
|
||||
"legacy" => {
|
||||
legacy(&request, &document, version);
|
||||
return;
|
||||
}
|
||||
"rpc.discover" => {
|
||||
if document["x-test-no-answer"] == true {
|
||||
for _ in &mut lines {}
|
||||
return;
|
||||
}
|
||||
if document["x-test-wait"] == true {
|
||||
std::fs::write(format!("/state/{plugin}/discover-started"), "ready").unwrap();
|
||||
let before = std::time::Instant::now();
|
||||
while !std::path::Path::new(&format!("/state/{plugin}/discover-release")).exists() {
|
||||
assert!(before.elapsed() < std::time::Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
document
|
||||
}
|
||||
"read" => {
|
||||
let text =
|
||||
std::fs::read_to_string(request["params"]["path"].as_str().unwrap()).unwrap();
|
||||
serde_json::from_str(&text).unwrap_or(json!(text))
|
||||
}
|
||||
"rpc.retired" => {
|
||||
std::fs::write(
|
||||
format!("/state/{plugin}/retired"),
|
||||
request["params"]["previous"].as_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
json!({"retired":version})
|
||||
}
|
||||
"work" => {
|
||||
// Keep an actual request unanswered through grace; notices do not drain it.
|
||||
send(
|
||||
json!({"jsonrpc":"2.0","method":"core.notify","params":{"name":"ready","params":{}}}),
|
||||
);
|
||||
for _ in lines {}
|
||||
return;
|
||||
}
|
||||
"serve" | "hold" => {
|
||||
// One genuine process failure, followed by a healthy service, without
|
||||
// timers or liveness polling in either the fixture or supervisor.
|
||||
let crash = request["params"]["crash_once"].as_str();
|
||||
let first = crash.is_some()
|
||||
&& std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(format!("/state/{plugin}/crashed-once"))
|
||||
.is_ok();
|
||||
if first && crash == Some("before") {
|
||||
std::process::exit(1);
|
||||
}
|
||||
send(json!({"jsonrpc":"2.0","id":request["id"],"result":{"version":version}}));
|
||||
if first && crash == Some("after") {
|
||||
std::process::exit(1);
|
||||
}
|
||||
if request["method"] == "serve" {
|
||||
std::fs::write(format!("/state/{plugin}/service"), version).unwrap();
|
||||
}
|
||||
for line in lines {
|
||||
let Ok(message) = serde_json::from_str::<Value>(&line.unwrap()) else {
|
||||
continue;
|
||||
};
|
||||
if message["method"] == "rpc.superseded" {
|
||||
std::fs::write(
|
||||
format!(
|
||||
"/state/{plugin}/superseded-{}",
|
||||
std::env::var("SAUSAGE_PID").unwrap()
|
||||
),
|
||||
message.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
if request["params"]["exit_on_superseded"] == true {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::write(format!("/state/{plugin}/ended-{version}"), "ended").unwrap();
|
||||
return;
|
||||
}
|
||||
_ => json!({"version":version}),
|
||||
};
|
||||
send(json!({"jsonrpc":"2.0","id":request["id"],"result":result}));
|
||||
if request["method"] == "rpc.discover" && result["x-test-linger"] == true {
|
||||
if result["x-test-stubborn"] == true {
|
||||
let before = std::time::Instant::now();
|
||||
while !std::path::Path::new(&format!("/state/{plugin}/discovery-exit")).exists() {
|
||||
assert!(before.elapsed() < std::time::Duration::from_secs(30));
|
||||
}
|
||||
} else {
|
||||
for _ in lines {}
|
||||
}
|
||||
std::fs::write(
|
||||
format!("/state/{plugin}/discovery-ended-{version}"),
|
||||
"ended",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,589 +0,0 @@
|
||||
//! Raw JSON-RPC methods used by the host's protocol tests.
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{self, BufRead, Read, Write};
|
||||
type Lines = io::Lines<io::BufReader<io::Stdin>>;
|
||||
|
||||
fn send(value: Value) {
|
||||
let mut output = io::stdout().lock();
|
||||
writeln!(output, "\n{value}").unwrap();
|
||||
output.flush().unwrap();
|
||||
}
|
||||
fn receive(lines: &mut Lines) -> Value {
|
||||
serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap()
|
||||
}
|
||||
fn echo(params: &Value) -> Value {
|
||||
params.clone()
|
||||
}
|
||||
fn chat(params: &Value) -> Value {
|
||||
send(json!({"jsonrpc":"2.0", "method":"core.notify", "params":{
|
||||
"name":"text", "params":{"text":"interim"}
|
||||
}}));
|
||||
json!({"text":params["text"]})
|
||||
}
|
||||
fn discover(params: &Value) -> Value {
|
||||
if let Ok(bytes) = std::fs::read("/state/probe/discover.json") {
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
} else {
|
||||
params.clone()
|
||||
}
|
||||
}
|
||||
fn environment() -> Value {
|
||||
json!({
|
||||
"pid": std::env::var("SAUSAGE_PID").unwrap(),
|
||||
"plugin": std::env::var("SAUSAGE_PLUGIN").unwrap(),
|
||||
"parent": std::env::var("SAUSAGE_PARENT").unwrap(),
|
||||
})
|
||||
}
|
||||
fn multicall(lines: &mut Lines) -> Value {
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "probe.echo",
|
||||
"params": {"n": 1}
|
||||
}));
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": "1",
|
||||
"method": "probe.echo",
|
||||
"params": {"n": 2}
|
||||
}));
|
||||
for _ in 0..2 {
|
||||
let response = receive(lines);
|
||||
if response["id"] == 1 {
|
||||
assert_eq!(response["result"]["n"], 1);
|
||||
} else {
|
||||
assert_eq!(response["id"], "1");
|
||||
assert_eq!(response["result"]["n"], 2);
|
||||
}
|
||||
}
|
||||
json!({"count":2})
|
||||
}
|
||||
fn notification(params: &Value) -> Value {
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "probe.write",
|
||||
"params": params
|
||||
}));
|
||||
let before = std::time::Instant::now();
|
||||
loop {
|
||||
let contents = std::fs::read_to_string(params["path"].as_str().unwrap()).ok();
|
||||
if contents.as_deref() == params["text"].as_str() {
|
||||
break;
|
||||
}
|
||||
assert!(before.elapsed() < std::time::Duration::from_secs(10));
|
||||
}
|
||||
json!({})
|
||||
}
|
||||
fn unknown_id() {
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": "late",
|
||||
"result": 7
|
||||
}));
|
||||
}
|
||||
fn wait_eof(lines: &mut Lines) {
|
||||
std::fs::write("/state/waiting", "yes").unwrap();
|
||||
send(json!({"jsonrpc":"2.0","method":"core.notify","params":{
|
||||
"name":"waiting-eof", "params":{"pid":std::env::var("SAUSAGE_PID").unwrap()}
|
||||
}}));
|
||||
for _ in lines {}
|
||||
std::fs::write("/state/stopped", "yes").unwrap();
|
||||
}
|
||||
fn busy() -> Value {
|
||||
loop {
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
}
|
||||
fn exit_without_answer() {
|
||||
eprintln!("last line");
|
||||
}
|
||||
fn junk() -> Value {
|
||||
io::stdout().write_all(&[255, 10]).unwrap();
|
||||
println!("junk\n{{}}\n[]");
|
||||
eprintln!("stderr line");
|
||||
json!({})
|
||||
}
|
||||
fn invalid_utf8() -> Value {
|
||||
io::stdout().write_all(&[255, 10]).unwrap();
|
||||
json!({})
|
||||
}
|
||||
fn blank() -> Value {
|
||||
println!("\n \t\n");
|
||||
json!({})
|
||||
}
|
||||
fn burst(request: &Value) {
|
||||
let count = request["params"]["count"].as_u64().unwrap();
|
||||
let mut output = String::new();
|
||||
for index in 0..count {
|
||||
let notification = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "core.notify",
|
||||
"params": {"name": "burst", "params": {"index": index}}
|
||||
});
|
||||
output.push_str(¬ification.to_string());
|
||||
output.push('\n');
|
||||
}
|
||||
let response = json!({"jsonrpc":"2.0", "id":request["id"], "result":count});
|
||||
output.push_str(&response.to_string());
|
||||
output.push('\n');
|
||||
let mut stdout = io::stdout().lock();
|
||||
stdout.write_all(output.as_bytes()).unwrap();
|
||||
stdout.flush().unwrap();
|
||||
}
|
||||
fn notify() -> Value {
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "core.notify",
|
||||
"params": {
|
||||
"name": "progress",
|
||||
"params": {"n": 1}
|
||||
}
|
||||
}));
|
||||
json!({})
|
||||
}
|
||||
fn serve(request: &Value, lines: &mut Lines) {
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": request["id"],
|
||||
"result": std::env::var("SAUSAGE_PID").unwrap()
|
||||
}));
|
||||
for _ in lines {}
|
||||
}
|
||||
fn network(params: &Value) -> Value {
|
||||
let mut socket = std::net::TcpStream::connect(params["tcp"].as_str().unwrap()).unwrap();
|
||||
let mut text = String::new();
|
||||
socket.read_to_string(&mut text).unwrap();
|
||||
let udp = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
udp.send_to(b"udp", params["udp"].as_str().unwrap())
|
||||
.unwrap();
|
||||
json!(text)
|
||||
}
|
||||
fn read_file(params: &Value) -> Value {
|
||||
json!(std::fs::read_to_string(params["path"].as_str().unwrap()).unwrap())
|
||||
}
|
||||
fn write_file(params: &Value) -> Value {
|
||||
std::fs::write(
|
||||
params["path"].as_str().unwrap(),
|
||||
params["text"].as_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
json!({})
|
||||
}
|
||||
fn outgoing_call(params: &Value, lines: &mut Lines) -> Value {
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": {"nested": 7},
|
||||
"method": params["method"],
|
||||
"params": params["params"]
|
||||
}));
|
||||
loop {
|
||||
let response = receive(lines);
|
||||
if response["id"] == json!({"nested":7}) {
|
||||
return response["result"].clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn lock(params: &Value, lines: &mut Lines) -> Value {
|
||||
outgoing_call(&json!({"method":"core.lock", "params":params}), lines)
|
||||
}
|
||||
fn filesystem(params: &Value) -> Value {
|
||||
use std::fs;
|
||||
let pid = std::env::var("SAUSAGE_PID").unwrap();
|
||||
match params["topic"].as_str().unwrap() {
|
||||
"root" => {
|
||||
let preopens = wasip2::filesystem::preopens::get_directories();
|
||||
assert_eq!(preopens.len(), 1);
|
||||
assert_eq!(preopens[0].1, "/");
|
||||
let names: Vec<_> = fs::read_dir("/")
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
||||
.collect();
|
||||
for name in ["proc", "self", "run"] {
|
||||
assert!(names.iter().any(|n| n == name));
|
||||
}
|
||||
fs::write("/state/root", "world").unwrap();
|
||||
fs::write("/run/shared", "shared").unwrap();
|
||||
assert!(fs::rename("/run/shared", "/state/moved").is_err());
|
||||
assert!(fs::rename("/state/root", "/self/moved").is_err());
|
||||
assert_eq!(fs::read_to_string("/run/../state/root").unwrap(), "world");
|
||||
}
|
||||
"reserved" => {
|
||||
for name in ["info.json", "log", "fs"] {
|
||||
assert!(fs::write(format!("/self/{name}"), "bad").is_err());
|
||||
assert!(fs::create_dir(format!("/self/{name}")).is_err());
|
||||
fs::write("/self/temp", "ok").unwrap();
|
||||
assert!(fs::rename("/self/temp", format!("/self/{name}")).is_err());
|
||||
fs::remove_file("/self/temp").unwrap();
|
||||
}
|
||||
}
|
||||
"self" => {
|
||||
fs::write("/self/endpoint.tmp", "endpoint").unwrap();
|
||||
fs::rename("/self/endpoint.tmp", "/self/endpoint").unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(format!("/proc/{pid}/endpoint")).unwrap(),
|
||||
"endpoint"
|
||||
);
|
||||
assert!(fs::write(format!("/proc/{pid}/endpoint"), "bad").is_err());
|
||||
assert!(fs::remove_file(format!("/proc/{pid}/endpoint")).is_err());
|
||||
let info: Value =
|
||||
serde_json::from_slice(&fs::read("/self/info.json").unwrap()).unwrap();
|
||||
assert_eq!(info["plugin"], "probe");
|
||||
assert_eq!(info["method"], "filesystem");
|
||||
return json!(pid);
|
||||
}
|
||||
"isolated" => {
|
||||
assert!(!fs::exists("/self/endpoint").unwrap());
|
||||
}
|
||||
"snapshot" => {
|
||||
eprintln!("before snapshot");
|
||||
let mut file = fs::File::open("/self/log").unwrap();
|
||||
eprintln!("after snapshot");
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents).unwrap();
|
||||
assert!(contents.contains("start filesystem"));
|
||||
assert!(!contents.contains("after snapshot"));
|
||||
assert_eq!(file.read(&mut [0; 1]).unwrap(), 0);
|
||||
}
|
||||
"core" => {
|
||||
assert!(
|
||||
fs::read_to_string("/proc/core/PROTOCOL.md")
|
||||
.unwrap()
|
||||
.contains("## 4. The filesystem")
|
||||
);
|
||||
for name in ["graph.json", "locks.json"] {
|
||||
let _: Value =
|
||||
serde_json::from_slice(&fs::read(format!("/proc/core/{name}")).unwrap())
|
||||
.unwrap();
|
||||
}
|
||||
assert!(
|
||||
fs::read_to_string("/proc/core/graph.dot")
|
||||
.unwrap()
|
||||
.contains("digraph")
|
||||
);
|
||||
assert!(fs::read_dir("/proc/core/plugins").is_ok());
|
||||
assert!(fs::write("/proc/core/graph.json", "bad").is_err());
|
||||
}
|
||||
_ => panic!("unknown filesystem topic"),
|
||||
}
|
||||
json!({})
|
||||
}
|
||||
fn write_loop(request: &Value, lines: &mut Lines) {
|
||||
assert_eq!(
|
||||
lock(&json!({"path":"/state/loop"}), lines),
|
||||
json!({"held":true})
|
||||
);
|
||||
std::fs::write("/state/loop", "old").unwrap();
|
||||
send(
|
||||
json!({"jsonrpc":"2.0", "id":request["id"], "result":std::env::var("SAUSAGE_PID").unwrap()}),
|
||||
);
|
||||
loop {
|
||||
std::fs::write("/state/loop", "old").unwrap();
|
||||
}
|
||||
}
|
||||
fn locked_write(lines: &mut Lines) -> Value {
|
||||
assert_eq!(
|
||||
lock(&json!({"path":"/state/loop"}), lines),
|
||||
json!({"held":true})
|
||||
);
|
||||
std::fs::write("/state/loop", "new").unwrap();
|
||||
outgoing_call(&json!({"method":"probe.echo","params":{}}), lines);
|
||||
assert_eq!(std::fs::read_to_string("/state/loop").unwrap(), "new");
|
||||
json!({})
|
||||
}
|
||||
fn unlock_answer(request: &Value, lines: &mut Lines) {
|
||||
lock(&json!({"path":"ordered-unlock"}), lines);
|
||||
let unlock = json!({"jsonrpc":"2.0","method":"core.unlock","params":{"path":"ordered-unlock"}});
|
||||
let answer =
|
||||
json!({"jsonrpc":"2.0","id":request["id"],"result":std::env::var("SAUSAGE_PID").unwrap()});
|
||||
let mut stdout = io::stdout().lock();
|
||||
writeln!(stdout, "{unlock}\n{answer}").unwrap();
|
||||
stdout.flush().unwrap();
|
||||
drop(stdout);
|
||||
for _ in lines {}
|
||||
}
|
||||
fn notification_child(lines: &mut Lines) -> Value {
|
||||
send(json!({"jsonrpc":"2.0","method":"probe.orphan_notify","params":{}}));
|
||||
let before = std::time::Instant::now();
|
||||
let pid = loop {
|
||||
if let Ok(pid) = std::fs::read_to_string("/state/orphan") {
|
||||
break pid;
|
||||
}
|
||||
assert!(before.elapsed() < std::time::Duration::from_secs(10));
|
||||
};
|
||||
send(json!({"jsonrpc":"2.0","id":7,"method":"core.stop","params":{"pid":pid}}));
|
||||
let mut count = 0;
|
||||
loop {
|
||||
let response = receive(lines);
|
||||
if response["method"] == "core.notify" {
|
||||
count += 1;
|
||||
}
|
||||
if response["id"] == 7 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
json!(count)
|
||||
}
|
||||
fn batch(lines: &mut Lines) -> Value {
|
||||
send(
|
||||
json!([{"jsonrpc":"2.0","method":"probe.write","params":{"path":"/state/batch","text":"bad"}}]),
|
||||
);
|
||||
outgoing_call(
|
||||
&json!({"method":"probe.echo","params":{"barrier":true}}),
|
||||
lines,
|
||||
);
|
||||
json!(!std::path::Path::new("/state/batch").exists())
|
||||
}
|
||||
fn late_answer(request: &Value, lines: &mut Lines) {
|
||||
send(json!({"jsonrpc":"2.0","id":request["id"],"result":42}));
|
||||
send(json!({"jsonrpc":"2.0","id":request["id"],"result":99}));
|
||||
for _ in lines {}
|
||||
}
|
||||
fn nested_environment(lines: &mut Lines) -> Value {
|
||||
let child = outgoing_call(&json!({"method":"probe.env","params":{}}), lines);
|
||||
json!({"caller":std::env::var("SAUSAGE_PID").unwrap(),"child":child})
|
||||
}
|
||||
fn forgotten(request: &Value, lines: &mut Lines) {
|
||||
std::fs::write("/state/forgotten", environment().to_string()).unwrap();
|
||||
serve(request, lines);
|
||||
}
|
||||
fn forget(lines: &mut Lines) -> Value {
|
||||
send(
|
||||
json!({"jsonrpc":"2.0","method":"core.call","params":{"method":"probe.forgotten","params":{},"detach":true}}),
|
||||
);
|
||||
let before = std::time::Instant::now();
|
||||
while !std::path::Path::new("/state/forgotten").exists() {
|
||||
assert!(before.elapsed() < std::time::Duration::from_secs(10));
|
||||
}
|
||||
send(json!({"jsonrpc":"2.0","id":"barrier","method":"probe.echo","params":{}}));
|
||||
let response = receive(lines);
|
||||
assert_eq!(
|
||||
response["id"], "barrier",
|
||||
"a forgotten call must not send a result"
|
||||
);
|
||||
environment()
|
||||
}
|
||||
fn tree(request: &Value, lines: &mut Lines) {
|
||||
let depth = request["params"]["depth"].as_u64().unwrap();
|
||||
if depth > 0 {
|
||||
outgoing_call(
|
||||
&json!({"method":"probe.tree","params":{"depth":depth-1}}),
|
||||
lines,
|
||||
);
|
||||
}
|
||||
send(
|
||||
json!({"jsonrpc":"2.0","id":request["id"],"result":std::env::var("SAUSAGE_PID").unwrap()}),
|
||||
);
|
||||
busy();
|
||||
}
|
||||
fn dispatch(request: &Value, lines: &mut Lines) -> Option<Value> {
|
||||
let params = &request["params"];
|
||||
let result = match request["method"].as_str().unwrap() {
|
||||
"batch" => batch(lines),
|
||||
"trap" => std::arch::wasm32::unreachable(),
|
||||
"nested_environment" => nested_environment(lines),
|
||||
"forget" => forget(lines),
|
||||
"forgotten" => {
|
||||
forgotten(request, lines);
|
||||
return None;
|
||||
}
|
||||
"tree" => {
|
||||
tree(request, lines);
|
||||
return None;
|
||||
}
|
||||
"late_answer" => {
|
||||
late_answer(request, lines);
|
||||
return None;
|
||||
}
|
||||
"invalid_stderr" => {
|
||||
io::stderr().write_all(&[255, 10]).unwrap();
|
||||
eprintln!("after invalid UTF-8");
|
||||
json!(std::env::var("SAUSAGE_PID").unwrap())
|
||||
}
|
||||
"unlock_answer" => {
|
||||
unlock_answer(request, lines);
|
||||
return None;
|
||||
}
|
||||
"null_error" => {
|
||||
send(json!({"jsonrpc":"2.0","id":request["id"],"error":null,"result":42}));
|
||||
return None;
|
||||
}
|
||||
"notification_child" => notification_child(lines),
|
||||
"orphan_notify" => {
|
||||
std::fs::write("/state/orphan", std::env::var("SAUSAGE_PID").unwrap()).unwrap();
|
||||
notify()
|
||||
}
|
||||
"self_serve" => {
|
||||
std::fs::write("/self/endpoint", "endpoint").unwrap();
|
||||
serve(request, lines);
|
||||
return None;
|
||||
}
|
||||
"filesystem" => filesystem(params),
|
||||
"write_loop" => {
|
||||
write_loop(request, lines);
|
||||
return None;
|
||||
}
|
||||
"locked_write" => locked_write(lines),
|
||||
"echo" => echo(params),
|
||||
"chat" => chat(params),
|
||||
"rpc.discover" => discover(params),
|
||||
"env" => environment(),
|
||||
"multicall" => multicall(lines),
|
||||
"notification" => notification(params),
|
||||
"unknownid" => {
|
||||
unknown_id();
|
||||
return None;
|
||||
}
|
||||
"request_id" => request["id"].clone(),
|
||||
"wait_eof" => {
|
||||
wait_eof(lines);
|
||||
return None;
|
||||
}
|
||||
"busy" => busy(),
|
||||
"exit" => {
|
||||
exit_without_answer();
|
||||
return None;
|
||||
}
|
||||
"junk" => junk(),
|
||||
"blank" => blank(),
|
||||
"invalid_utf8" => invalid_utf8(),
|
||||
"notify" => notify(),
|
||||
"burst" => {
|
||||
burst(request);
|
||||
return None;
|
||||
}
|
||||
"restart" => {
|
||||
assert_eq!(
|
||||
lock(&json!({"path":"/state/restart-stop"}), lines),
|
||||
json!({"held":true})
|
||||
);
|
||||
assert_eq!(
|
||||
outgoing_call(&json!({"method":"core.restart", "params":{}}), lines),
|
||||
json!({})
|
||||
);
|
||||
std::fs::write("/state/restart-answer", "received").unwrap();
|
||||
serve(request, lines);
|
||||
std::fs::write("/state/restart-stop", "persisted after EOF").unwrap();
|
||||
eprintln!("restart stop hook finished");
|
||||
return None;
|
||||
}
|
||||
"serve" => {
|
||||
serve(request, lines);
|
||||
return None;
|
||||
}
|
||||
"network" => network(params),
|
||||
"mutate" => mutate(params),
|
||||
"read" => read_file(params),
|
||||
"write" => write_file(params),
|
||||
"raw_call" => {
|
||||
send(params.clone());
|
||||
receive(lines)
|
||||
}
|
||||
"call" => outgoing_call(params, lines),
|
||||
"lock" => lock(params, lines),
|
||||
_ => {
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": request["id"],
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": "method not found"
|
||||
}
|
||||
}));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(result)
|
||||
}
|
||||
fn main() {
|
||||
if let Ok(mode) = std::fs::read_to_string("/state/early") {
|
||||
if mode == "flood" {
|
||||
for index in 0..5000 {
|
||||
eprintln!("flood {index} {}", "x".repeat(128));
|
||||
}
|
||||
} else {
|
||||
eprintln!("last early log");
|
||||
if mode == "answer" {
|
||||
send(
|
||||
json!({"jsonrpc":"2.0","id":std::env::var("SAUSAGE_PID").unwrap(),"result":"early"}),
|
||||
);
|
||||
}
|
||||
std::process::exit(23);
|
||||
}
|
||||
}
|
||||
let mut lines = io::BufReader::new(io::stdin()).lines();
|
||||
let request = receive(&mut lines);
|
||||
if let Some(result) = dispatch(&request, &mut lines)
|
||||
&& let Some(id) = request.get("id")
|
||||
{
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": result
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate mutation calls let the host await a scan before each subsequent change.
|
||||
fn mutate(p: &Value) -> Value {
|
||||
use wasip2::filesystem::{
|
||||
preopens,
|
||||
types::{DescriptorFlags as D, OpenFlags as O, PathFlags as P},
|
||||
};
|
||||
let path = p["path"].as_str().unwrap();
|
||||
match p["op"].as_str().unwrap() {
|
||||
"mkdir" => std::fs::create_dir_all(path).unwrap(),
|
||||
"rename" => std::fs::rename(path, p["to"].as_str().unwrap()).unwrap(),
|
||||
"delete" => std::fs::remove_file(path).unwrap(),
|
||||
"rmdir" => std::fs::remove_dir(path).unwrap(),
|
||||
"atomic" => {
|
||||
let temp = format!("{path}.tmp");
|
||||
std::fs::write(&temp, "complete").unwrap();
|
||||
std::fs::rename(temp, path).unwrap();
|
||||
}
|
||||
"burst" => {
|
||||
for i in 0..5 {
|
||||
std::fs::write(format!("{path}/{i}"), "complete").unwrap();
|
||||
}
|
||||
}
|
||||
op => {
|
||||
let root = preopens::get_directories().remove(0).0;
|
||||
let file = root
|
||||
.open_at(
|
||||
P::empty(),
|
||||
path.trim_start_matches('/'),
|
||||
O::empty(),
|
||||
D::READ | D::WRITE,
|
||||
)
|
||||
.unwrap();
|
||||
match op {
|
||||
"descriptor" => {
|
||||
file.write(b"descriptor", 0).unwrap();
|
||||
}
|
||||
"stream" => {
|
||||
file.write_via_stream(0)
|
||||
.unwrap()
|
||||
.blocking_write_and_flush(b"stream")
|
||||
.unwrap();
|
||||
}
|
||||
"append" => {
|
||||
file.append_via_stream()
|
||||
.unwrap()
|
||||
.blocking_write_and_flush(b"append")
|
||||
.unwrap();
|
||||
}
|
||||
"size" => file.set_size(0).unwrap(),
|
||||
"truncate" => {
|
||||
root.open_at(
|
||||
P::empty(),
|
||||
path.trim_start_matches('/'),
|
||||
O::TRUNCATE,
|
||||
D::WRITE,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
_ => panic!("unknown mutation"),
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({})
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
[package]
|
||||
name = "consumer"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
[dependencies]
|
||||
sausage-plugin = { path = "../../sdk" }
|
||||
@@ -1,85 +0,0 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"AddParams": {
|
||||
"description": "Parameters for a new note.",
|
||||
"properties": {
|
||||
"text": {
|
||||
"description": "The complete note text, including any newlines.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"text"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Added": {
|
||||
"description": "The result after a note enters the list.",
|
||||
"properties": {
|
||||
"count": {
|
||||
"description": "The number of notes, including the new note.",
|
||||
"format": "uint",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"count"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"title": "notes",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"methods": [
|
||||
{
|
||||
"description": "Add a note and return the new count.",
|
||||
"name": "add",
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"description": "The complete note text, including any newlines.",
|
||||
"name": "text",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "The complete note text, including any newlines.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Added"
|
||||
}
|
||||
},
|
||||
"summary": "Add a note and return the new count.",
|
||||
"x-sausage-params": {
|
||||
"$ref": "#/components/schemas/AddParams"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "List all notes, oldest first.",
|
||||
"name": "list",
|
||||
"paramStructure": "by-name",
|
||||
"params": [],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"summary": "List all notes, oldest first."
|
||||
}
|
||||
],
|
||||
"openrpc": "1.2.6",
|
||||
"x-sausage-endpoints": {},
|
||||
"x-sausage-imports": {}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"GetParams": {
|
||||
"description": "The name of a secret to read.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "The unique name within this vault.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PutParams": {
|
||||
"description": "The name and value of a secret.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "The unique name within this vault.",
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"description": "The text to store until process exit.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"value"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"title": "secrets",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"methods": [
|
||||
{
|
||||
"description": "Open the in-memory vault and publish its endpoint.",
|
||||
"name": "serve",
|
||||
"paramStructure": "by-name",
|
||||
"params": [],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"summary": "Open the in-memory vault and publish its endpoint."
|
||||
}
|
||||
],
|
||||
"openrpc": "1.2.6",
|
||||
"x-sausage-endpoints": {
|
||||
"vault": {
|
||||
"methods": [
|
||||
{
|
||||
"description": "Store a secret and report its name to the caller.",
|
||||
"name": "put",
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"description": "The unique name within this vault.",
|
||||
"name": "name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "The unique name within this vault.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "The text to store until process exit.",
|
||||
"name": "value",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "The text to store until process exit.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"summary": "Store a secret and report its name to the caller.",
|
||||
"x-sausage-params": {
|
||||
"$ref": "#/components/schemas/PutParams"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Read a secret, or return null when its name is absent.",
|
||||
"name": "get",
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"description": "The unique name within this vault.",
|
||||
"name": "name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "The unique name within this vault.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"summary": "Read a secret, or return null when its name is absent.",
|
||||
"x-sausage-params": {
|
||||
"$ref": "#/components/schemas/GetParams"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"x-sausage-imports": {}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"StartParams": {
|
||||
"description": "The server configuration.",
|
||||
"properties": {
|
||||
"port": {
|
||||
"description": "The HTTP port on all network interfaces.",
|
||||
"format": "uint16",
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"port"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"title": "www",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"methods": [
|
||||
{
|
||||
"description": "Start the HTTP server and return its process ID.",
|
||||
"name": "start",
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"description": "The HTTP port on all network interfaces.",
|
||||
"name": "port",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "The HTTP port on all network interfaces.",
|
||||
"format": "uint16",
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"summary": "Start the HTTP server and return its process ID.",
|
||||
"x-sausage-params": {
|
||||
"$ref": "#/components/schemas/StartParams"
|
||||
}
|
||||
}
|
||||
],
|
||||
"openrpc": "1.2.6",
|
||||
"x-sausage-endpoints": {
|
||||
"control": {
|
||||
"methods": [
|
||||
{
|
||||
"description": "Count the HTTP requests this server answered.",
|
||||
"name": "stats",
|
||||
"paramStructure": "by-name",
|
||||
"params": [],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"format": "uint64",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"summary": "Count the HTTP requests this server answered."
|
||||
},
|
||||
{
|
||||
"description": "End this process after the response reaches the client.",
|
||||
"name": "stop",
|
||||
"paramStructure": "by-name",
|
||||
"params": [],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"summary": "End this process after the response reaches the client."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"x-sausage-grace": 5,
|
||||
"x-sausage-imports": {}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
//! Use typed calls from discovered provider documents in `imports/`.
|
||||
//! Generated modules own the wire types, so calls need no manual JSON.
|
||||
//! The detached HTTP service survives this consumer. Its endpoint refers to that service's process-local state.
|
||||
//! Copy fresh documents from `/proc/core/plugins/<provider>/discover.json` after an interface changes.
|
||||
use sausage_plugin::{Result, rpc, schema};
|
||||
sausage_plugin::import!("notes");
|
||||
sausage_plugin::import!("www");
|
||||
sausage_plugin::import!("secrets");
|
||||
/// Demonstrate typed imports without manual JSON messages.
|
||||
struct Consumer;
|
||||
/// A note for the imported notes plugin.
|
||||
#[schema]
|
||||
struct NoteParams {
|
||||
/// The text to append.
|
||||
text: String,
|
||||
}
|
||||
/// The text and HTTP port for this demonstration.
|
||||
#[schema]
|
||||
struct DemoParams {
|
||||
/// The note text to append.
|
||||
text: String,
|
||||
/// The port for the detached HTTP server.
|
||||
port: u16,
|
||||
}
|
||||
#[rpc]
|
||||
impl Consumer {
|
||||
/// Add a note through the generated import and return its count.
|
||||
async fn note(&self, p: NoteParams) -> Result<usize> {
|
||||
Ok(notes::add(notes::AddParams { text: p.text }).await?.count as usize)
|
||||
}
|
||||
/// Add a note, start a server, and inspect its control endpoint.
|
||||
async fn demo(&self, p: DemoParams) -> Result<u64> {
|
||||
let added = notes::add(notes::AddParams { text: p.text }).await?;
|
||||
eprintln!("{} notes", added.count);
|
||||
let pid = www::start(www::StartParams { port: p.port })
|
||||
.detach()
|
||||
.await?;
|
||||
let control = www::control::connect(format!("/proc/{pid}/control")).await?;
|
||||
control.stats().await
|
||||
}
|
||||
/// Store and read a value through the boot service's fixed endpoint.
|
||||
async fn vault(&self) -> Result<Option<String>> {
|
||||
let vault = secrets::vault::connect("/run/secrets").await?;
|
||||
vault
|
||||
.put(secrets::PutParams {
|
||||
name: "example".into(),
|
||||
value: "hello".into(),
|
||||
})
|
||||
.on_notify(|name, params| eprintln!("{name}: {params}"))
|
||||
.await?;
|
||||
vault
|
||||
.get(secrets::GetParams {
|
||||
name: "example".into(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
/// Start the SDK transport for this plugin.
|
||||
fn main() {
|
||||
sausage_plugin::run(Consumer)
|
||||
}
|
||||
/// Tests check generated wire types without other plugins in the test world.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// Generated params retain their wire field names.
|
||||
#[test]
|
||||
fn generated_params_use_text() {
|
||||
let value = sausage_plugin::serde_json::to_value(notes::AddParams {
|
||||
text: "milk".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(value, sausage_plugin::serde_json::json!({"text":"milk"}));
|
||||
}
|
||||
/// A generated result decodes without another plugin in the test world.
|
||||
#[test]
|
||||
fn generated_result_decodes_count() {
|
||||
let added: notes::Added = sausage_plugin::serde_json::from_str(r#"{"count":2}"#).unwrap();
|
||||
assert_eq!(added.count, 2);
|
||||
}
|
||||
}
|
||||
Generated
+115
@@ -0,0 +1,115 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "notes"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"sausage-actor",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sausage-actor"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -1,8 +1,10 @@
|
||||
[package]
|
||||
name = "probe"
|
||||
name = "notes"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
sausage-actor = { version = "0.1", path = "../../sdk" }
|
||||
serde_json = "1"
|
||||
wasip2 = "1"
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Version 2 adds a note count and fixes the crash command.
|
||||
use sausage_actor::{json, messages, reply};
|
||||
|
||||
const DATA: &str = "/data/notes.json";
|
||||
|
||||
fn save(notes: &[String]) {
|
||||
std::fs::write(DATA, serde_json::to_string(notes).unwrap()).unwrap();
|
||||
std::fs::write("/public/notes.txt", notes.join("\n") + "\n").unwrap();
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut notes: Vec<String> = std::fs::read_to_string(DATA)
|
||||
.ok()
|
||||
.and_then(|text| serde_json::from_str(&text).ok())
|
||||
.unwrap_or_default();
|
||||
for message in messages() {
|
||||
let body = &message.body;
|
||||
let response = match message.kind() {
|
||||
"add" => {
|
||||
notes.push(body["text"].as_str().unwrap_or_default().to_string());
|
||||
save(¬es);
|
||||
json!({"type": "added", "count": notes.len()})
|
||||
}
|
||||
"list" => json!({"type": "notes", "items": notes}),
|
||||
"count" => json!({"type": "count", "count": notes.len(), "version": 2}),
|
||||
"crash" => json!({"type": "not_crashing", "version": 2}),
|
||||
_ => json!({"type": "error", "reason": "unknown message"}),
|
||||
};
|
||||
let _ = reply(&message, response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"description": "version 2 counts notes and no longer crashes",
|
||||
"steps": [
|
||||
{"send": {"type": "add", "text": "test note"}, "expect": {"type": "added"}},
|
||||
{"send": {"type": "list"}, "expect": {"type": "notes"}},
|
||||
{"send": {"type": "count"}, "expect": {"type": "count", "version": 2}},
|
||||
{"send": {"type": "crash"}, "expect": {"type": "not_crashing"}}
|
||||
]
|
||||
}
|
||||
Generated
+115
@@ -0,0 +1,115 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "notes"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"sausage-actor",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sausage-actor"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -2,6 +2,9 @@
|
||||
name = "notes"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
sausage-plugin = { path = "../../sdk" }
|
||||
sausage-actor = { version = "0.1", path = "../../sdk" }
|
||||
serde_json = "1"
|
||||
|
||||
[workspace]
|
||||
|
||||
+26
-80
@@ -1,84 +1,30 @@
|
||||
//! Persistent notes in `/state/notes/notes.json`.
|
||||
//! Each call has its own process. The add method locks the file before its read-modify-write operation.
|
||||
//! Atomic replacement lets readers observe a complete list without a lock.
|
||||
use sausage_plugin::serde_json;
|
||||
use sausage_plugin::{Result, lock, rpc, schema, state_dir, write_atomic};
|
||||
/// The stateless notes plugin reads its file on each call.
|
||||
struct Notes;
|
||||
/// Parameters for a new note.
|
||||
#[schema]
|
||||
struct AddParams {
|
||||
/// The complete note text, including any newlines.
|
||||
text: String,
|
||||
}
|
||||
/// The result after a note enters the list.
|
||||
#[schema]
|
||||
struct Added {
|
||||
/// The number of notes, including the new note.
|
||||
count: usize,
|
||||
}
|
||||
#[rpc]
|
||||
impl Notes {
|
||||
/// Add a note and return the new count.
|
||||
async fn add(&self, AddParams { text }: AddParams) -> Result<Added> {
|
||||
let path = state_dir().join("notes.json");
|
||||
let _guard = lock(&path).await?;
|
||||
let mut notes = load()?;
|
||||
notes.push(text);
|
||||
std::fs::create_dir_all(state_dir())?;
|
||||
write_atomic(path, serde_json::to_vec(¬es)?)?;
|
||||
Ok(Added { count: notes.len() })
|
||||
}
|
||||
/// List all notes, oldest first.
|
||||
fn list(&self) -> Result<Vec<String>> {
|
||||
load()
|
||||
}
|
||||
}
|
||||
/// Read the list, or return an empty list when the file does not exist.
|
||||
fn load() -> Result<Vec<String>> {
|
||||
match std::fs::read(state_dir().join("notes.json")) {
|
||||
Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
/// Start the SDK transport for notes.
|
||||
fn main() {
|
||||
sausage_plugin::run(Notes)
|
||||
//! Keep notes in `/data/notes.json` and publish them at `/public/notes.txt`.
|
||||
use sausage_actor::{json, messages, reply};
|
||||
|
||||
const DATA: &str = "/data/notes.json";
|
||||
|
||||
fn save(notes: &[String]) {
|
||||
std::fs::write(DATA, serde_json::to_string(notes).unwrap()).unwrap();
|
||||
std::fs::write("/public/notes.txt", notes.join("\n") + "\n").unwrap();
|
||||
}
|
||||
|
||||
/// Tests run in fresh worlds under the WASI test runner.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// The core lock protects two sequential additions and their counts.
|
||||
#[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"]);
|
||||
}
|
||||
/// A fresh test world has no notes file.
|
||||
#[test]
|
||||
fn list_is_empty_without_a_file() {
|
||||
assert!(Notes.list().unwrap().is_empty());
|
||||
fn main() {
|
||||
let mut notes: Vec<String> = std::fs::read_to_string(DATA)
|
||||
.ok()
|
||||
.and_then(|text| serde_json::from_str(&text).ok())
|
||||
.unwrap_or_default();
|
||||
for message in messages() {
|
||||
let body = &message.body;
|
||||
let response = match message.kind() {
|
||||
"add" => {
|
||||
notes.push(body["text"].as_str().unwrap_or_default().to_string());
|
||||
save(¬es);
|
||||
json!({"type": "added", "count": notes.len()})
|
||||
}
|
||||
"list" => json!({"type": "notes", "items": notes}),
|
||||
"crash" => panic!("asked to crash"),
|
||||
_ => json!({"type": "error", "reason": "unknown message"}),
|
||||
};
|
||||
let _ = reply(&message, response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"description": "add a note, then list the notes",
|
||||
"steps": [
|
||||
{"send": {"type": "add", "text": "test note"}, "expect": {"type": "added"}},
|
||||
{"send": {"type": "list"}, "expect": {"type": "notes"}}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
[package]
|
||||
name = "secrets"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
[dependencies]
|
||||
sausage-plugin = { path = "../../sdk" }
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Keep a process-local vault behind a fixed endpoint at `/run/secrets`.
|
||||
//! Start `secrets.serve` as a detached boot service. Its memory lasts until process exit.
|
||||
use sausage_plugin::{Ctx, Result, listen, rpc, schema};
|
||||
use std::{cell::RefCell, collections::BTreeMap, rc::Rc};
|
||||
/// The service entry point publishes one process-local vault.
|
||||
struct Secrets;
|
||||
#[rpc(endpoints(vault=Vault))]
|
||||
impl Secrets {
|
||||
/// Open the in-memory vault and publish its endpoint.
|
||||
async fn serve(&self) -> Result<()> {
|
||||
listen("/run/secrets", Rc::new(Vault::default())).await
|
||||
}
|
||||
}
|
||||
/// The endpoint owns secrets in memory, not on disk.
|
||||
#[derive(Default)]
|
||||
struct Vault {
|
||||
/// Named values disappear when this process ends.
|
||||
entries: RefCell<BTreeMap<String, String>>,
|
||||
}
|
||||
/// The name and value of a secret.
|
||||
#[schema]
|
||||
struct PutParams {
|
||||
/// The unique name within this vault.
|
||||
name: String,
|
||||
/// The text to store until process exit.
|
||||
value: String,
|
||||
}
|
||||
/// The name of a secret to read.
|
||||
#[schema]
|
||||
struct GetParams {
|
||||
/// The unique name within this vault.
|
||||
name: String,
|
||||
}
|
||||
#[rpc]
|
||||
impl Vault {
|
||||
/// Store a secret and report its name to the caller.
|
||||
fn put(&self, ctx: &Ctx, p: PutParams) -> Result<()> {
|
||||
ctx.notify("stored", sausage_plugin::serde_json::json!({"name":p.name}))?;
|
||||
self.entries.borrow_mut().insert(p.name, p.value);
|
||||
Ok(())
|
||||
}
|
||||
/// Read a secret, or return null when its name is absent.
|
||||
fn get(&self, p: GetParams) -> Result<Option<String>> {
|
||||
Ok(self.entries.borrow().get(&p.name).cloned())
|
||||
}
|
||||
}
|
||||
/// Start the SDK transport for the vault service.
|
||||
fn main() {
|
||||
sausage_plugin::run(Secrets)
|
||||
}
|
||||
/// Tests verify absent keys and process-local storage.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// Missing names return null instead of an RPC error.
|
||||
#[test]
|
||||
fn absent_secret() {
|
||||
assert_eq!(
|
||||
Vault::default()
|
||||
.get(GetParams {
|
||||
name: "missing".into()
|
||||
})
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
/// Store a value and retrieve it through the same process-local vault.
|
||||
#[sausage_plugin::test]
|
||||
async fn stores_secret() {
|
||||
let vault = Vault::default();
|
||||
vault
|
||||
.put(
|
||||
&Ctx::default(),
|
||||
PutParams {
|
||||
name: "token".into(),
|
||||
value: "example".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
vault
|
||||
.get(GetParams {
|
||||
name: "token".into()
|
||||
})
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("example")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
[package]
|
||||
name = "www"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
[dependencies]
|
||||
sausage-plugin = { path = "../../sdk" }
|
||||
wstd = "0.6"
|
||||
@@ -1,97 +0,0 @@
|
||||
//! Serve files from `/state/www` and expose a control endpoint at `/self/control`.
|
||||
//! Start this process detached or as a service so it outlives its caller.
|
||||
//! Clients read `/proc/<pid>/control`. A fresh core call cannot inspect this process's counters.
|
||||
use sausage_plugin::{Ctx, Result, background, listen, pid, rpc, schema, state_dir};
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
use wstd::{
|
||||
io::{AsyncRead, AsyncWrite},
|
||||
iter::AsyncIterator,
|
||||
net::TcpListener,
|
||||
};
|
||||
/// The HTTP plugin starts a server and returns its process ID.
|
||||
struct Www;
|
||||
/// The server configuration.
|
||||
#[schema]
|
||||
struct StartParams {
|
||||
/// The HTTP port on all network interfaces.
|
||||
port: u16,
|
||||
}
|
||||
#[rpc(endpoints(control=Control),grace_seconds=5)]
|
||||
impl Www {
|
||||
/// Start the HTTP server and return its process ID.
|
||||
async fn start(&self, p: StartParams) -> Result<String> {
|
||||
let tcp = TcpListener::bind(&format!("0.0.0.0:{}", p.port)).await?;
|
||||
let control = Rc::new(Control { hits: Cell::new(0) });
|
||||
listen("/self/control", control.clone()).await?;
|
||||
background(serve(tcp, control));
|
||||
Ok(pid())
|
||||
}
|
||||
}
|
||||
/// Shared process-local state for the HTTP task and its endpoint.
|
||||
struct Control {
|
||||
/// The number of answered requests.
|
||||
hits: Cell<u64>,
|
||||
}
|
||||
#[rpc]
|
||||
impl Control {
|
||||
/// Count the HTTP requests this server answered.
|
||||
fn stats(&self) -> Result<u64> {
|
||||
Ok(self.hits.get())
|
||||
}
|
||||
/// End this process after the response reaches the client.
|
||||
fn stop(&self, ctx: &Ctx) -> Result<()> {
|
||||
ctx.exit_after_response();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
/// Answer HTTP requests with files and count all responses, including missing files.
|
||||
async fn serve(tcp: TcpListener, control: Rc<Control>) {
|
||||
let mut incoming = tcp.incoming();
|
||||
while let Some(Ok(mut stream)) = incoming.next().await {
|
||||
let mut request = [0; 8192];
|
||||
let Ok(size) = stream.read(&mut request).await else {
|
||||
continue;
|
||||
};
|
||||
let line = String::from_utf8_lossy(&request[..size]);
|
||||
let path = line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.unwrap_or("/")
|
||||
.trim_start_matches('/');
|
||||
let (status, body) = match std::fs::read(state_dir().join(if path.is_empty() {
|
||||
"index.html"
|
||||
} else {
|
||||
path
|
||||
})) {
|
||||
Ok(body) => ("200 OK", body),
|
||||
Err(_) => ("404 Not Found", b"Not found".to_vec()),
|
||||
};
|
||||
control.hits.set(control.hits.get() + 1);
|
||||
let header = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(header.as_bytes()).await;
|
||||
let _ = stream.write_all(&body).await;
|
||||
let _ = stream.flush().await;
|
||||
}
|
||||
}
|
||||
/// Start the SDK transport for the HTTP plugin.
|
||||
fn main() {
|
||||
sausage_plugin::run(Www)
|
||||
}
|
||||
/// Tests check local counters and the service entry point.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// Control reports its shared counter without a core round trip.
|
||||
#[test]
|
||||
fn stats_reads_counter() {
|
||||
assert_eq!(Control { hits: Cell::new(3) }.stats().unwrap(), 3);
|
||||
}
|
||||
/// The listener binds an ephemeral port and publishes its control endpoint.
|
||||
#[sausage_plugin::test]
|
||||
async fn starts_on_available_port() {
|
||||
assert_eq!(Www.start(StartParams { port: 0 }).await.unwrap(), pid());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
description = "Sausage: grow a system of WASI plugins by talking to it";
|
||||
description = "Sausage: grow a system of WASI actors by talking to it";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
@@ -16,43 +16,24 @@
|
||||
inherit system;
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
};
|
||||
# The toolchain that builds the host and, at runtime, the plugins.
|
||||
# The toolchain that builds the host and, at runtime, the actors.
|
||||
rust = pkgs.rust-bin.stable.latest.default.override {
|
||||
targets = [ "wasm32-wasip2" ];
|
||||
extensions = [ "rust-src" ];
|
||||
};
|
||||
# The native cargo plugin needs Cargo, rustc (with wasm32-wasip2), and
|
||||
# The runtime cargo actor needs Cargo, rustc (with wasm32-wasip2), and
|
||||
# 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.
|
||||
# mold links host code much faster than GNU ld. Only on Linux, and only
|
||||
# for the host target: plugins for wasm32-wasip2 link with rust-lld. Nix
|
||||
# brings mold along, so a build without Nix keeps the default linker.
|
||||
mold = pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.mold ];
|
||||
hostTarget = pkgs.lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ]
|
||||
pkgs.stdenv.hostPlatform.rust.rustcTarget);
|
||||
moldVar = "CARGO_TARGET_${hostTarget}_RUSTFLAGS";
|
||||
moldEnv = pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
|
||||
${moldVar} = "-C link-arg=-fuse-ld=mold";
|
||||
};
|
||||
runtimePath = pkgs.lib.makeBinPath ([ rust pkgs.stdenv.cc ] ++ mold);
|
||||
# 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";
|
||||
};
|
||||
runtimePath = pkgs.lib.makeBinPath [ rust pkgs.stdenv.cc ];
|
||||
craneLib = (crane.mkLib pkgs).overrideToolchain rust;
|
||||
common = {
|
||||
pname = "sausage";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
nativeBuildInputs = mold;
|
||||
} // moldEnv;
|
||||
};
|
||||
# The test packages for `checks.tests`.
|
||||
testPackages = "--workspace";
|
||||
testPackages = "-p sausage -p sausage-actor -p agent";
|
||||
cargoArtifacts = craneLib.buildDepsOnly (common // {
|
||||
doCheck = false;
|
||||
# The dependencies of the host binary and of the tests.
|
||||
@@ -66,22 +47,18 @@
|
||||
doNotRemoveReferencesToRustToolchain = true;
|
||||
cargoExtraArgs = "--locked -p sausage";
|
||||
doCheck = false;
|
||||
nativeBuildInputs = [ pkgs.makeWrapper ] ++ mold;
|
||||
# The native `cargo` plugin runs cargo at runtime. Build scripts need a C linker,
|
||||
# and C code for WASI needs the WASI clang.
|
||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||
# The `cargo` actor runs cargo at runtime. Build scripts need a C linker.
|
||||
postInstall = ''
|
||||
wrapProgram $out/bin/sausage --prefix PATH : ${runtimePath} \
|
||||
--set-default CC_wasm32_wasip2 ${wasiCcEnv.CC_wasm32_wasip2} \
|
||||
--set-default AR_wasm32_wasip2 ${wasiCcEnv.AR_wasm32_wasip2} \
|
||||
${pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux "--set-default ${moldVar} '-C link-arg=-fuse-ld=mold'"}
|
||||
wrapProgram $out/bin/sausage --prefix PATH : ${runtimePath}
|
||||
'';
|
||||
meta.mainProgram = "sausage";
|
||||
});
|
||||
# `nix run .#loop -- <world>` runs the newest `sausage run <world>`
|
||||
# (without a folder: bare `sausage`, i.e. ./world, created if needed)
|
||||
# from the public repo, again and again: after a crash or a call of
|
||||
# `core.restart` (exit code 75) it refreshes the flake and starts the host again, so
|
||||
# the world updates itself. `quit` (exit code 0) ends the loop.
|
||||
# from the public repo, again and again: after a crash or a broker
|
||||
# `exit` request it refreshes the flake and starts the host again,
|
||||
# so the world updates itself. `/quit` (exit code 0) ends the loop.
|
||||
# SAUSAGE_FLAKE overrides where it fetches from (any flake URL,
|
||||
# e.g. a fork or a local checkout).
|
||||
repoUrl = "git+https://gitea.surma.technology/surma/sausage";
|
||||
@@ -90,7 +67,7 @@
|
||||
# No folder: bare `sausage` (creates ./world first if needed).
|
||||
if [ $# -gt 0 ]; then set -- run "$@"; fi
|
||||
while true; do
|
||||
SAUSAGE_LOOP=1 nix run --refresh "$url#default" -- "$@"
|
||||
nix run --refresh "$url#default" -- "$@"
|
||||
code=$?
|
||||
if [ "$code" -eq 0 ]; then exit 0; fi
|
||||
echo "sausage-loop: sausage exited with $code, starting the newest version in 2 s …" >&2
|
||||
@@ -101,33 +78,35 @@
|
||||
{
|
||||
packages.default = sausage;
|
||||
|
||||
# `nix flake check` runs every test of the workspace. The tests build
|
||||
# WASM plugins with cargo, offline, from the vendored crates of
|
||||
# Cargo.lock.
|
||||
# `nix flake check` runs every test of the host, the SDK, and the
|
||||
# agent. The tests build WASM actors with cargo, offline, from the
|
||||
# vendored crates of Cargo.lock.
|
||||
# The binary the host loop runs (`nix run`), built from the same
|
||||
# cached dependencies as the tests.
|
||||
checks.package = sausage;
|
||||
checks.tests = craneLib.cargoTest (common // wasiCcEnv // {
|
||||
checks.tests = craneLib.cargoTest (common // {
|
||||
inherit cargoArtifacts;
|
||||
cargoExtraArgs = "--locked ${testPackages}";
|
||||
# Nothing builds on the test run's target folder: don't store it.
|
||||
doInstallCargoArtifacts = false;
|
||||
# The SDK tests run sample plugins with the wasmtime CLI.
|
||||
nativeBuildInputs = [ pkgs.wasmtime ] ++ mold;
|
||||
preCheck = ''
|
||||
# Its own lock file pins crates that the vendored set may lack.
|
||||
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 `cargo.build`: the tests under WASI and the wasm32-wasip2
|
||||
# build, with C code from the packaged WASI clang.
|
||||
# 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"
|
||||
mkdir -p "$world/plugins" "$world/state" "$world/config"
|
||||
echo "[]" > "$world/config/boot.yaml"
|
||||
source="$world/plugins/probe/source"
|
||||
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]
|
||||
@@ -135,37 +114,10 @@
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
TOML
|
||||
# C code for WASI, compiled with the packaged WASI clang.
|
||||
printf 'int probe_add(int a, int b) { return a + b; }\n' > "$source/src/add.c"
|
||||
cat > "$source/build.rs" <<'RUST'
|
||||
use std::process::Command;
|
||||
fn main() {
|
||||
if std::env::var("TARGET").unwrap() != "wasm32-wasip2" {
|
||||
return;
|
||||
}
|
||||
let out = std::env::var("OUT_DIR").unwrap();
|
||||
let cc = std::env::var("CC_wasm32_wasip2").expect("CC_wasm32_wasip2");
|
||||
let ar = std::env::var("AR_wasm32_wasip2").expect("AR_wasm32_wasip2");
|
||||
let object = format!("{out}/add.o");
|
||||
assert!(Command::new(cc).args(["-c", "src/add.c", "-o", &object]).status().unwrap().success());
|
||||
assert!(Command::new(ar).args(["crs", &format!("{out}/libadd.a"), &object]).status().unwrap().success());
|
||||
println!("cargo:rustc-link-search=native={out}");
|
||||
println!("cargo:rustc-link-lib=static=add");
|
||||
}
|
||||
RUST
|
||||
cat > "$source/src/main.rs" <<'RUST'
|
||||
#[cfg(target_os = "wasi")]
|
||||
unsafe extern "C" {
|
||||
fn probe_add(a: i32, b: i32) -> i32;
|
||||
}
|
||||
fn main() {
|
||||
#[cfg(target_os = "wasi")]
|
||||
assert_eq!(unsafe { probe_add(2, 3) }, 5);
|
||||
}
|
||||
RUST
|
||||
printf 'fn main() {}\n' > "$source/src/main.rs"
|
||||
cat > "$source/tests/probe.rs" <<'RUST'
|
||||
#[test]
|
||||
fn test_runs_under_wasi() {
|
||||
fn native_test_harness_runs() {
|
||||
assert_eq!(2 + 2, 4);
|
||||
}
|
||||
RUST
|
||||
@@ -178,23 +130,29 @@
|
||||
hostOutput=''${host[0]}
|
||||
hostPid=$host_PID
|
||||
trap 'kill "$hostPid" 2>/dev/null || true' EXIT
|
||||
printf '%s\n' 'cargo.build {"plugin":"probe"}' >&"$hostInput"
|
||||
printf '%s\n' '@cargo {"type":"build","source":"/actor/console/public/probe"}' >&"$hostInput"
|
||||
built=0
|
||||
while IFS= read -r -t 600 line <&"$hostOutput"; do
|
||||
while IFS= read -r -t 300 line <&"$hostOutput"; do
|
||||
echo "$line"
|
||||
case "$line" in
|
||||
*'error:'*)
|
||||
echo "packaged cargo.build failed with an empty caller PATH" >&2
|
||||
*'"type":"build_failed"'*)
|
||||
echo "packaged cargo actor build failed with an empty caller PATH" >&2
|
||||
exit 1
|
||||
;;
|
||||
*'"passed": 1'*) built=1; break ;;
|
||||
*'"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, or its test did not pass" >&2
|
||||
echo "packaged host did not build the probe" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' 'quit' >&"$hostInput"
|
||||
printf '%s\n' '/quit' >&"$hostInput"
|
||||
wait "$hostPid"
|
||||
mkdir -p "$out"
|
||||
touch "$out/passed"
|
||||
@@ -203,11 +161,11 @@
|
||||
apps.default = { type = "app"; program = "${sausage}/bin/sausage"; };
|
||||
apps.loop = { type = "app"; program = "${loop}/bin/sausage-loop"; };
|
||||
|
||||
devShells.default = pkgs.mkShell (wasiCcEnv // moldEnv // {
|
||||
packages = [ rust pkgs.wasmtime pkgs.wasm-tools ] ++ mold;
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = [ rust pkgs.wasmtime pkgs.wasm-tools ];
|
||||
shellHook = ''
|
||||
export CARGO_HOME="$PWD/.cargo-home"
|
||||
'';
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user