Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c91747ce6 |
@@ -1,4 +1,4 @@
|
||||
# Runs on a self-hosted Nix runner (label `nixos`, host executor).
|
||||
# Runs on the Nix runner on nexus (label `nixos`, host executor).
|
||||
name: ci
|
||||
on: [push, pull_request]
|
||||
|
||||
|
||||
+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
-864
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/`.
|
||||
|
||||
+144
-584
@@ -1,624 +1,184 @@
|
||||
# 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 three 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 | the actor's state |
|
||||
| `/public` | read-write | the files that the actor publishes |
|
||||
| `/actor` | read-only | the `actor/` folder of the world |
|
||||
|
||||
A listing of `/` shows `proc`, `run`, and `self`. A rename across two of
|
||||
these areas fails.
|
||||
`/actor/<id>/` holds everything about an actor: `actor.wasm`, `actor.json`
|
||||
(installed version, running, subscriptions), `source/`, `public/`, `data/`,
|
||||
`stderr.log`, and `versions/<n>/`. `ls /actor` lists all actors.
|
||||
|
||||
```text
|
||||
/proc/
|
||||
core/
|
||||
PROTOCOL.md this file
|
||||
discover.json core.rpc.discover document
|
||||
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
|
||||
```
|
||||
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`).
|
||||
|
||||
Rules:
|
||||
Send large or binary data as a path under `/actor/<id>/public/`, not inside a
|
||||
message.
|
||||
|
||||
- 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.
|
||||
### Actor-served files: `/actor/<id>/fs/`
|
||||
|
||||
Principle: **inspect through files, act through calls.**
|
||||
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 `/`).
|
||||
|
||||
### File watching
|
||||
| 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"}` |
|
||||
|
||||
`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.
|
||||
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).
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
## The broker
|
||||
|
||||
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.
|
||||
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`,
|
||||
`scanned`, `exiting`), or `{"type": "error", "reason": "..."}`.
|
||||
|
||||
## 5. Endpoints
|
||||
|
||||
An endpoint is a JSON-RPC interface that a running process serves over TCP,
|
||||
for example the control interface of a web server.
|
||||
|
||||
- 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`).
|
||||
|
||||
## 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.rpc.discover` | none | OpenRPC core API document (also `/proc/core/discover.json`) |
|
||||
| `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 |
|
||||
| `rollback` | `actor`, `version` | go back to an older version |
|
||||
| `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 |
|
||||
| `scan` | | boot new or changed `actor.wasm` files |
|
||||
| `exit` | | answer `exiting`, stop every actor, and end the `sausage` process (a wrapper like `nix run .#loop` starts it again, updated) |
|
||||
|
||||
### Host restart
|
||||
Paths are `/actor/...`, or the sender's `/public/...` or `/data/...`.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
`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.
|
||||
**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 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
|
||||
{
|
||||
"description": "add, then list",
|
||||
"steps": [
|
||||
{"send": {"type": "add", "text": "milk"}, "expect": {"type": "added"}},
|
||||
{"send": {"type": "list"}, "expect": [{"type": "notes"}]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`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.
|
||||
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.
|
||||
|
||||
Everything else is a file.
|
||||
**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 rollback does the same with the older version.
|
||||
|
||||
## 10. Interfaces
|
||||
**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"}`.
|
||||
|
||||
### 10.1 `rpc.discover`
|
||||
**The folder is the truth.** At start, and on `scan`, the broker boots every
|
||||
`actor/<id>/actor.wasm`. If the file differs from the installed version, it
|
||||
is a new version, tested with the tests in `actor/<id>/source/`. If its
|
||||
tests fail, the file stays where it is, the reason goes to `stderr.log`, and
|
||||
the next scan tries again.
|
||||
|
||||
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:
|
||||
## The `cargo` actor
|
||||
|
||||
- `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).
|
||||
```json
|
||||
{"type": "build", "source": "/actor/agent/public/drafts/notes"}
|
||||
```
|
||||
|
||||
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.
|
||||
The reply is `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", "built_by": "cargo", ...}`
|
||||
or `{"type": "build_failed", "errors": "..."}`. Pass `wasm`, `source`, and
|
||||
`built_by` on to `spawn` or `upgrade`.
|
||||
|
||||
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.
|
||||
|
||||
### 10.2 Compatibility
|
||||
|
||||
A provider can add methods, optional params, and result fields. For each
|
||||
imported method:
|
||||
|
||||
1. The provider has the method.
|
||||
2. Each params value that the consumer can send is valid for the provider.
|
||||
3. Each result that the provider can return is valid for the consumer.
|
||||
|
||||
- The same rules apply to endpoint interfaces.
|
||||
- There are no version numbers. This check replaces them.
|
||||
- The check 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`.
|
||||
|
||||
### 10.3 Activation
|
||||
|
||||
`core.activate {"plugin": "notes", "wasm": "/state/cargo/builds/7/notes.wasm"}`:
|
||||
|
||||
1. The core copies the file. All further steps use this copy, even if the
|
||||
file changes.
|
||||
2. It starts the copy and calls `rpc.discover`.
|
||||
3. It 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.
|
||||
|
||||
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,125 +2,69 @@
|
||||
|
||||
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.
|
||||
|
||||
```sh
|
||||
nix run git+https://gitea.surma.technology/surma/sausage
|
||||
nix run -- init # creates ./world and builds the agent (a few minutes)
|
||||
nix run -- run # starts the world and a console
|
||||
```
|
||||
|
||||
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
|
||||
existing world. The two steps on their own:
|
||||
|
||||
```sh
|
||||
nix run git+https://gitea.surma.technology/surma/sausage -- init # creates ./world and builds the agent
|
||||
nix run git+https://gitea.surma.technology/surma/sausage -- run # starts the world and a console
|
||||
```
|
||||
|
||||
`init` asks 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 `nix run -- run my-world`.
|
||||
|
||||
To keep a world on the newest version, run it in a loop:
|
||||
|
||||
```sh
|
||||
nix run git+https://gitea.surma.technology/surma/sausage#loop # ./world, created if needed
|
||||
nix run git+https://gitea.surma.technology/surma/sausage#loop -- my-world # an existing world
|
||||
nix run .#loop -- my-world # or .#loop-internal (Gitea on the LAN)
|
||||
```
|
||||
|
||||
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 loop runs `nix run --refresh git+ssh://…/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.
|
||||
|
||||
**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.
|
||||
In the console:
|
||||
|
||||
From a clone (`git clone https://gitea.surma.technology/surma/sausage`), the
|
||||
same commands are `nix run`, `nix run -- init`, `nix run -- run`, and
|
||||
`nix run .#loop`.
|
||||
|
||||
### Headless
|
||||
|
||||
`--headless` (or `SAUSAGE_HEADLESS=1`) turns the console input off. The host
|
||||
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):
|
||||
|
||||
```sh
|
||||
nix run git+https://gitea.surma.technology/surma/sausage#loop -- --headless my-world
|
||||
```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 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` | |
|
||||
|
||||
## 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/ the actor's state
|
||||
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. Then restart the world or
|
||||
send `@broker {"type":"scan"}`. 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"));
|
||||
|
||||
+43
-494
@@ -1,386 +1,73 @@
|
||||
//! 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 ("low", "medium", ...).
|
||||
#[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.
|
||||
#[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))]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thinking levels, lowest first.
|
||||
pub const LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
/// The level nearest to `want` (lower on a tie) among those a 400 error lists in quotes (else all levels), not
|
||||
/// yet `tried`. None: nothing left, so the request goes out without an effort field (model default).
|
||||
fn fallback_effort(want: &str, error: &str, tried: &[String]) -> Option<String> {
|
||||
let listed: Vec<&str> = error
|
||||
.split('\'')
|
||||
.skip(1)
|
||||
.step_by(2)
|
||||
.filter(|w| LEVELS.contains(w))
|
||||
.collect();
|
||||
let pool = if listed.iter().any(|l| *l != want) {
|
||||
listed
|
||||
} else {
|
||||
LEVELS.to_vec()
|
||||
};
|
||||
// `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);
|
||||
pool.into_iter()
|
||||
.filter(|l| *l != want && !tried.iter().any(|t| t == l))
|
||||
.min_by_key(|l| {
|
||||
let i = LEVELS.iter().position(|x| x == l).unwrap_or(1) as i32;
|
||||
((i - at).abs(), i > at)
|
||||
})
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::fallback_effort;
|
||||
|
||||
#[test]
|
||||
fn 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")
|
||||
);
|
||||
// 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
},
|
||||
/// 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,
|
||||
},
|
||||
ToolCall { id: String, name: String, arguments: String },
|
||||
ToolResult { id: String, output: String },
|
||||
/// A provider item that only this API shape understands (OpenAI reasoning).
|
||||
Opaque { api: String, item: Value },
|
||||
}
|
||||
|
||||
/// 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());
|
||||
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,
|
||||
other => return Err(format!("unknown API shape {other:?}")),
|
||||
};
|
||||
match (result, effort.clone()) {
|
||||
// The model rejected the effort level: retry with the nearest accepted one (thinking stays on).
|
||||
(Err(e), Some(current)) if e.starts_with("HTTP 400") && e.contains("effort") => {
|
||||
tried.push(current.clone());
|
||||
effort = fallback_effort(want.as_deref().unwrap_or(""), &e, &tried);
|
||||
eprintln!(
|
||||
"agent: {} rejected thinking effort {current:?}; retrying with {effort:?}: {e}",
|
||||
cfg.model
|
||||
);
|
||||
}
|
||||
(result, _) => return result,
|
||||
}
|
||||
pub async fn complete(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
|
||||
match cfg.api.as_str() {
|
||||
"openai-responses" => openai(cfg, system, tools, history).await,
|
||||
"anthropic-messages" => anthropic(cfg, system, tools, history).await,
|
||||
other => Err(format!("unknown API shape {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]) -> Result<Vec<Entry>, String> {
|
||||
let mut input = Vec::new();
|
||||
for e in history {
|
||||
match e {
|
||||
@@ -398,7 +85,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 { .. } => {}
|
||||
}
|
||||
}
|
||||
@@ -417,11 +104,11 @@ async fn openai(
|
||||
if let Some(max_tokens) = cfg.max_tokens {
|
||||
body["max_output_tokens"] = json!(max_tokens);
|
||||
}
|
||||
if let Some(effort) = effort {
|
||||
if let Some(effort) = &cfg.reasoning_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 +133,17 @@ 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],
|
||||
effort: Option<&str>,
|
||||
) -> Result<Vec<Entry>, String> {
|
||||
let disabled = cfg.thinking == Some(Thinking::Off);
|
||||
async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
|
||||
// Merge consecutive blocks of the same role into one message.
|
||||
let mut messages: Vec<(String, Vec<Value>)> = Vec::new();
|
||||
let mut push = |role: &str, block: Value| match messages.last_mut() {
|
||||
@@ -488,64 +156,35 @@ 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}),
|
||||
),
|
||||
// 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::ToolResult { id, output } => {
|
||||
push("user", json!({"type": "tool_result", "tool_use_id": id, "content": output}))
|
||||
}
|
||||
Entry::Opaque { .. } => {}
|
||||
}
|
||||
}
|
||||
let messages: Vec<Value> = messages
|
||||
.into_iter()
|
||||
.map(|(role, content)| json!({"role": role, "content": content}))
|
||||
.collect();
|
||||
let 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!({
|
||||
let body = json!({
|
||||
"model": cfg.model,
|
||||
"system": system,
|
||||
"messages": messages,
|
||||
"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});
|
||||
}
|
||||
}
|
||||
}
|
||||
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 +199,8 @@ 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,
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
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), "");
|
||||
}
|
||||
}
|
||||
+5
-10
@@ -1,10 +1,5 @@
|
||||
You are the Sausage agent. Answer the person. Use tools to inspect or change the world.
|
||||
|
||||
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.
|
||||
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`.
|
||||
|
||||
+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"}}
|
||||
]
|
||||
}
|
||||
+2
-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,14 @@ 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"
|
||||
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,544 @@
|
||||
//! The broker: message routing, mailboxes, subscriptions, the version store,
|
||||
//! and the requests to `broker` (spawn, upgrade, rollback, stop, start,
|
||||
//! remove, subscribe, unsubscribe, scan, 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: `scan` boots every
|
||||
//! `actor/<id>/actor.wasm` and treats a changed file as 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>> {
|
||||
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: Vec<String> = match to {
|
||||
Some(to) => vec![to.to_string()],
|
||||
None => self
|
||||
.metas
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(_, m)| m.subscriptions.contains(from))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect(),
|
||||
};
|
||||
for to in recipients {
|
||||
self.deliver(Envelope {
|
||||
id: id.clone(),
|
||||
from: from.to_string(),
|
||||
to,
|
||||
reply_to: reply_to.map(str::to_string),
|
||||
body: body.clone(),
|
||||
});
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// Route one stdout line of a WASM actor.
|
||||
pub fn route_line(&self, from: &str, line: &str) {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return;
|
||||
}
|
||||
let 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 },
|
||||
],
|
||||
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`.
|
||||
pub fn install(&self, id: &str, version: u64) -> Result<()> {
|
||||
self.world.install_files(id, version)?;
|
||||
self.update_meta(id, |m| {
|
||||
m.version = Some(version);
|
||||
m.running = true;
|
||||
})
|
||||
}
|
||||
|
||||
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)?;
|
||||
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/`. Called at start and on `scan`.
|
||||
///
|
||||
/// - 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 for the next scan.
|
||||
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 NATIVE.contains(&id.as_str()) || !self.world.wasm_path(&id).is_file() {
|
||||
continue;
|
||||
}
|
||||
let lock = self.op_lock(&id);
|
||||
let _guard = lock.lock().await;
|
||||
if let Some(r) = self.scan_actor(&id).await {
|
||||
report.push(r);
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
async fn scan_actor(self: &Arc<Self>, id: &str) -> Option<Value> {
|
||||
let meta = self.world.read_meta(id);
|
||||
let installed = meta.version.filter(|v| self.world.version_dir(id, *v).join("actor.wasm").is_file());
|
||||
self.metas.lock().unwrap().insert(id.to_string(), meta.clone());
|
||||
if let Some(v) = installed
|
||||
&& !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}))
|
||||
}
|
||||
"scan" => Ok(json!({"type": "scanned", "changes": self.scan().await})),
|
||||
"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, scan, 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)
|
||||
}
|
||||
|
||||
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,202 @@
|
||||
//! The native `cargo` actor: builds Rust source folders into `.wasm` files.
|
||||
//!
|
||||
//! Request: `{"type": "build", "source": "/actor/<id>/public/<folder>"}`
|
||||
//! Replies: `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", ...}`
|
||||
//! `{"type": "build_failed", "errors": "..."}`
|
||||
//!
|
||||
//! The broker knows nothing about Rust. This actor is one way to get a
|
||||
//! `.wasm` file. Other actors can add other languages.
|
||||
//!
|
||||
//! 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");
|
||||
|
||||
pub struct BuildOutput {
|
||||
/// Host path of the published `.wasm` file.
|
||||
pub wasm: PathBuf,
|
||||
pub seconds: f64,
|
||||
pub warnings: String,
|
||||
}
|
||||
|
||||
/// 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 })
|
||||
}
|
||||
|
||||
/// Build the crate in `source` (a host path) and publish the `.wasm` file
|
||||
/// under `/actor/cargo/public/builds/<n>/`.
|
||||
pub async fn build(&self, source: &Path) -> Result<BuildOutput> {
|
||||
if !source.join("Cargo.toml").is_file() {
|
||||
bail!("{} has no Cargo.toml", source.display());
|
||||
}
|
||||
let 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");
|
||||
std::fs::write(&manifest, prepare_manifest(&std::fs::read_to_string(&manifest)?, Some(&self.world.public_dir(ID).join("sdk")))?)?;
|
||||
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.arg("build")
|
||||
.arg("--release")
|
||||
.arg("--target")
|
||||
.arg(TARGET)
|
||||
.arg("--message-format=json-render-diagnostics")
|
||||
.arg("--target-dir")
|
||||
.arg(data.join("target"))
|
||||
.current_dir(&build_dir)
|
||||
.kill_on_drop(true);
|
||||
let out = 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) })
|
||||
}
|
||||
|
||||
/// 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:#}")})
|
||||
});
|
||||
broker.send(ID, Some(&env.from), None, Some(&env.id), body);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(&self, broker: &Broker, env: &Envelope) -> Result<Value> {
|
||||
match env.body.get("type").and_then(Value::as_str) {
|
||||
Some("build") => {
|
||||
let source_s = env.body.get("source").and_then(Value::as_str).ok_or_else(|| anyhow!("missing \"source\""))?;
|
||||
let source = broker.world.resolve(&env.from, source_s)?;
|
||||
let out = self.build(&source).await?;
|
||||
let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string());
|
||||
Ok(json!({
|
||||
"type": "built",
|
||||
"wasm": wasm,
|
||||
"source": source_s,
|
||||
"built_by": ID,
|
||||
"seconds": (out.seconds * 10.0).round() / 10.0,
|
||||
"warnings": out.warnings,
|
||||
}))
|
||||
}
|
||||
other => bail!("unknown request {other:?}. Known: build"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
+54
-301
@@ -1,314 +1,67 @@
|
||||
//! 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.
|
||||
|
||||
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,19 +0,0 @@
|
||||
{
|
||||
"openrpc": "1.2.6",
|
||||
"info": {"title":"core", "version":"0.1.0"},
|
||||
"methods": [
|
||||
{"name": "rpc.discover", "summary": "Describe the native core API.", "description": "Describe the native core API.", "paramStructure": "by-name", "params": [], "result": {"name": "result", "schema": {"type": "object"}}},
|
||||
{"name": "watch", "summary": "Keep this call open until the caller process ends. First core.notify is armed, then changed after successful plugin-originated file mutations. Scan only after armed; rescan after changed. Events may coalesce; files are the truth. No polling or cancellation API. See PROTOCOL section 4.", "description": "Keep this call open until the caller process ends. First core.notify is armed, then changed after successful plugin-originated file mutations. Scan only after armed; rescan after changed. Events may coalesce; files are the truth. No polling or cancellation API. See PROTOCOL section 4.", "paramStructure": "by-name", "params": [{"name": "path", "required": true, "description": "Virtual file or directory path, including descendants; need not exist. /self is relative to the caller.", "schema": {"type": "string"}}], "result": {"name": "result", "schema": {"type": "null"}}},
|
||||
{"name": "call", "summary": "Make a call and return its result; detach lets the new process outlive its caller.", "description": "Make a call and return its result; detach lets the new process outlive its caller.", "paramStructure": "by-name", "params": [{"name": "method", "required": true, "description": "Qualified method.", "schema": {"type": "string"}}, {"name": "params", "required": true, "description": "Named parameters.", "schema": {"type": "object"}}, {"name": "detach", "required": false, "description": "Detach the new process.", "schema": {"type": "boolean"}}], "result": {"name": "result", "schema": {}}},
|
||||
{"name": "notify", "summary": "Notify this process's caller while its call is open.", "description": "Notify this process's caller while its call is open.", "paramStructure": "by-name", "params": [{"name": "name", "required": true, "description": "Event name.", "schema": {"type": "string"}}, {"name": "params", "required": false, "description": "Event payload.", "schema": {}}], "result": {"name": "result", "schema": {"type": "null"}}},
|
||||
{"name": "stop", "summary": "End a process and await cleanup.", "description": "End a process and await cleanup.", "paramStructure": "by-name", "params": [{"name": "pid", "required": true, "description": "Process ID.", "schema": {"type": "string"}}], "result": {"name": "result", "schema": {"type": "object"}}},
|
||||
{"name": "kill", "summary": "End a process and await cleanup.", "description": "End a process and await cleanup.", "paramStructure": "by-name", "params": [{"name": "pid", "required": true, "description": "Process ID.", "schema": {"type": "string"}}], "result": {"name": "result", "schema": {"type": "object"}}},
|
||||
{"name": "lock", "summary": "Acquire a FIFO process-lifetime lock.", "description": "Acquire a FIFO process-lifetime lock.", "paramStructure": "by-name", "params": [{"name": "path", "required": true, "description": "Lock key.", "schema": {"type": "string"}}, {"name": "wait", "required": false, "description": "Wait for acquisition (default true).", "schema": {"type": "boolean"}}], "result": {"name": "result", "schema": {"type": "object", "properties": {"held": {"type": "boolean"}}, "required": ["held"]}}},
|
||||
{"name": "unlock", "summary": "Release an owned lock.", "description": "Release an owned lock.", "paramStructure": "by-name", "params": [{"name": "path", "required": true, "description": "Lock key.", "schema": {"type": "string"}}], "result": {"name": "result", "schema": {"type": "null"}}},
|
||||
{"name": "activate", "summary": "Install one or more checked plugin versions. See PROTOCOL section 10.", "description": "Install one or more checked plugin versions. See PROTOCOL section 10.", "paramStructure": "by-name", "params": [{"name": "plugin", "required": false, "description": "Single plugin name.", "schema": {"type": "string"}}, {"name": "wasm", "required": false, "description": "Candidate path.", "schema": {"type": "string"}}, {"name": "plugins", "required": false, "description": "Batch candidates.", "schema": {"type": "array", "items": {"type": "object", "properties": {"plugin": {"type": "string"}, "wasm": {"type": "string"}}, "required": ["plugin", "wasm"]}}}, {"name": "force", "required": false, "description": "Skip compatibility rejection.", "schema": {"type": "boolean"}}], "result": {"name": "result", "schema": {"type": "object"}}},
|
||||
{"name": "revert", "summary": "Restore the previous plugin version.", "description": "Restore the previous plugin version.", "paramStructure": "by-name", "params": [{"name": "plugin", "required": true, "description": "Plugin name.", "schema": {"type": "string"}}, {"name": "force", "required": false, "description": "Skip compatibility rejection.", "schema": {"type": "boolean"}}], "result": {"name": "result", "schema": {"type": "object"}}},
|
||||
{"name": "log", "summary": "Read the central journal (PROTOCOL section 7).", "description": "Read the central journal (PROTOCOL section 7).", "paramStructure": "by-name", "params": [{"name": "pid", "required": false, "description": "Process ID.", "schema": {"type": "string"}}, {"name": "plugin", "required": false, "description": "Plugin name.", "schema": {"type": "string"}}, {"name": "since", "required": false, "description": "ISO 8601 start time.", "schema": {"type": "string"}}, {"name": "until", "required": false, "description": "ISO 8601 end time.", "schema": {"type": "string"}}, {"name": "grep", "required": false, "description": "Line substring.", "schema": {"type": "string"}}, {"name": "limit", "required": false, "description": "Maximum entries (default 100).", "schema": {"type": "integer"}}], "result": {"name": "result", "schema": {"type": "array", "items": {"type": "object"}}}},
|
||||
{"name": "restart", "summary": "Request clean host restart through the loop supervisor; needs explicit operator approval.", "description": "Request clean host restart through the loop supervisor; needs explicit operator approval.", "paramStructure": "by-name", "params": [], "result": {"name": "result", "schema": {"type": "object"}}},
|
||||
{"name": "exit", "summary": "Request clean host restart through the loop supervisor; needs explicit operator approval.", "description": "Request clean host restart through the loop supervisor; needs explicit operator approval.", "paramStructure": "by-name", "params": [], "result": {"name": "result", "schema": {"type": "object"}}}
|
||||
]
|
||||
}
|
||||
@@ -1,76 +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 {
|
||||
"rpc.discover" => Ok(serde_json::from_str(include_str!("core.json")).unwrap()),
|
||||
"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,328 +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();
|
||||
files.insert(
|
||||
"/discover.json",
|
||||
|| Ok(include_bytes!("core.json").to_vec()),
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
+74
-27
@@ -1,31 +1,78 @@
|
||||
//! 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), and one supervisor per WASM actor. 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 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<()>>,
|
||||
_lock: std::fs::File,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Start the broker, `cargo`, and every registered actor of the world at
|
||||
/// `root`. Only one host can run a world at a time.
|
||||
pub async fn start(root: &Path) -> Result<Host> {
|
||||
let world = World::new(std::path::absolute(root)?);
|
||||
if !world.exists() {
|
||||
bail!("{} is not a Sausage world. Create one with: sausage init", root.display());
|
||||
}
|
||||
let lock = std::fs::File::create(world.root.join(".lock"))?;
|
||||
if lock.try_lock().is_err() {
|
||||
bail!("another sausage process runs {}", root.display());
|
||||
}
|
||||
let broker = Broker::new(world.clone(), runtime::Runtime::new()?)?;
|
||||
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))),
|
||||
];
|
||||
for change in broker.scan().await {
|
||||
eprintln!("sausage: {change}");
|
||||
}
|
||||
Ok(Host { world, broker, native, _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 broker and `cargo` (with the requests they still run), then
|
||||
/// every actor, gracefully.
|
||||
pub async fn stop(self) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
-162
@@ -1,190 +1,118 @@
|
||||
//! 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 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");
|
||||
|
||||
static AGENT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent/src");
|
||||
mod test_runner;
|
||||
#[derive(Parser)]
|
||||
#[command(version)]
|
||||
#[command(version, about)]
|
||||
struct Cli {
|
||||
#[arg(long, global = true)]
|
||||
headless: bool,
|
||||
#[arg(long, global = true)]
|
||||
cache_dir: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
command: Option<Cmd>,
|
||||
command: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Create a new world and build the agent actor.
|
||||
///
|
||||
/// Reads SAUSAGE_API, SAUSAGE_BASE_URL, SAUSAGE_MODEL and
|
||||
/// SAUSAGE_API_TOKEN, and asks for every value that is missing.
|
||||
Init {
|
||||
/// Folder of the new world.
|
||||
#[arg(default_value = "world")]
|
||||
dir: PathBuf,
|
||||
},
|
||||
/// Start the broker, all actors, and the console.
|
||||
Run {
|
||||
#[arg(default_value = "world")]
|
||||
dir: PathBuf,
|
||||
},
|
||||
/// 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).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(())
|
||||
}
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<std::process::ExitCode> {
|
||||
let cli = Cli::parse();
|
||||
let _terminal = TerminalMode(nix::sys::termios::tcgetattr(std::io::stdin()).ok());
|
||||
let command = match cli.command {
|
||||
Some(command) => command,
|
||||
None => {
|
||||
let dir = PathBuf::from("world");
|
||||
if !dir.join("config/boot.yaml").is_file() {
|
||||
init(&dir, cli.cache_dir.as_deref()).await?;
|
||||
}
|
||||
Cmd::Run { dir }
|
||||
}
|
||||
|
||||
async fn run(dir: PathBuf) -> Result<()> {
|
||||
let host = Host::start(&dir).await?;
|
||||
tokio::spawn(console::run_mailbox(host.mailbox(console::ID)));
|
||||
let broker = host.broker.clone();
|
||||
let exit = tokio::select! {
|
||||
_ = console::run_input(host.broker.clone()) => false,
|
||||
_ = broker.exit_requested() => true,
|
||||
};
|
||||
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?;
|
||||
}
|
||||
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<()> {
|
||||
match Cli::parse().command {
|
||||
Cmd::Init { dir } => init(dir).await,
|
||||
Cmd::Run { dir } => run(dir).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;
|
||||
|
||||
// Agreed systemd-derived values: /docs/design/plugin-handover/design.md,
|
||||
// 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,287 @@
|
||||
//! 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.
|
||||
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.version = version;
|
||||
self.component = Some(component);
|
||||
self.running = true;
|
||||
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?;
|
||||
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}))
|
||||
}
|
||||
|
||||
async fn rollback(&mut self, version: u64) -> Result<Value> {
|
||||
let from = self.version;
|
||||
self.install(version, None).await?;
|
||||
self.probation = None;
|
||||
self.start().await?;
|
||||
Ok(json!({"type": "rolled_back", "actor": self.id, "from": from, "to": version}))
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
+716
@@ -0,0 +1,716 @@
|
||||
//! 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. Every other path goes to `wasmtime-wasi` unchanged.
|
||||
//!
|
||||
//! - `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::HashSet;
|
||||
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,
|
||||
/// Descriptors of the `/actor` preopen.
|
||||
roots: HashSet<u32>,
|
||||
}
|
||||
|
||||
impl VfsState {
|
||||
pub fn new(broker: Weak<Broker>, me: String) -> Self {
|
||||
Self { broker, me, roots: HashSet::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("/"))
|
||||
}
|
||||
|
||||
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 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 if self.state.roots.contains(&fd.rep()) => {
|
||||
let full = join("/", path);
|
||||
let mut parts = full.splitn(4, '/').skip(1);
|
||||
let (actor, fs, rest) = (parts.next()?, parts.next()?, parts.next().unwrap_or(""));
|
||||
(fs == "fs" && !actor.is_empty()).then(|| (actor.to_string(), format!("/{rest}")))
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
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.roots.insert(fd.rep());
|
||||
}
|
||||
}
|
||||
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 => 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 => HostDescriptor::open_at(&mut self.inner, fd, path_flags, path, oflags, flags).await,
|
||||
}
|
||||
}
|
||||
|
||||
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.roots.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 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("/", ".."), "/");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+270
-46
@@ -1,71 +1,295 @@
|
||||
//! 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/`.
|
||||
//!
|
||||
//! ```text
|
||||
//! <world>/
|
||||
//! actor/<id>/
|
||||
//! actor.wasm the current version
|
||||
//! actor.json version, running, subscriptions (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>,
|
||||
}
|
||||
|
||||
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 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>/...` is the global filesystem.
|
||||
/// - `/public/...` and `/data/...` belong to `sender`.
|
||||
/// - Native senders (the console) can also give absolute host paths.
|
||||
pub fn resolve(&self, sender: &str, path: &str) -> Result<PathBuf> {
|
||||
let under = |base: PathBuf, rest: &str| -> Result<PathBuf> {
|
||||
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("/public") {
|
||||
return under(self.public_dir(sender), rest);
|
||||
}
|
||||
for dir in ["plugins", "state/core", "config"] {
|
||||
std::fs::create_dir_all(path.join(dir))?;
|
||||
if let Some(rest) = prefixed("/data") {
|
||||
return under(self.data_dir(sender), rest);
|
||||
}
|
||||
std::fs::write(path.join("config/boot.yaml"), "[]\n")?;
|
||||
Self::open(path)
|
||||
if NATIVE.contains(&sender) && Path::new(path).is_absolute() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
bail!("cannot resolve path {path} for {sender}: use /actor/<id>/..., /public/... or /data/...")
|
||||
}
|
||||
/// 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,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,6 +0,0 @@
|
||||
[package]
|
||||
name = "cargo-test-fail"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
@@ -1,13 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
//! 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);
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn arithmetic() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
fn main() {}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
//! Checks that cargo invokes integration test binaries through the WASI runner.
|
||||
#[test]
|
||||
fn integration_case() {
|
||||
println!("INTEGRATION_CASE");
|
||||
}
|
||||
@@ -0,0 +1,799 @@
|
||||
mod common;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use common::*;
|
||||
use sausage::{Envelope, Host, World};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
async fn filesystem_world(label: &str) -> (World, Host, mpsc::Receiver<Envelope>) {
|
||||
let world = create_world(label);
|
||||
let wasm = toolbox_wasm().clone();
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
for actor in ["writer", "reader"] {
|
||||
let source = passing_source(&world, actor);
|
||||
let spawned = spawn_actor(&host, &mut mailbox, actor, &wasm, &source).await;
|
||||
assert_eq!(spawned.body["type"], "spawned");
|
||||
}
|
||||
(world, host, mailbox)
|
||||
}
|
||||
|
||||
async fn receive_shutdown_messages(
|
||||
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||
id: &str,
|
||||
expected: &str,
|
||||
) {
|
||||
let (mut result, mut reply, mut publish) = (false, false, false);
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while !result || !reply || !publish {
|
||||
let message = mailbox.recv().await.expect("console mailbox closed");
|
||||
if message.reply_to.as_deref() == Some(id) {
|
||||
assert_eq!(message.body["type"], expected);
|
||||
result = true;
|
||||
} else if message.reply_to.as_deref() == Some("broker/shutdown") {
|
||||
assert_eq!(message.from, "shutdown-output");
|
||||
assert_eq!(message.body["type"], "shutdown-reply");
|
||||
reply = true;
|
||||
} else {
|
||||
assert_eq!(message.from, "shutdown-output");
|
||||
assert_eq!(message.reply_to, None);
|
||||
assert_eq!(message.body["type"], "shutdown-publish");
|
||||
publish = true;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("shutdown output or lifecycle reply did not arrive");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn routes_replies_publishes_and_reports_undeliverable() {
|
||||
let world = create_world("routing");
|
||||
let wasm = toolbox_wasm().clone();
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
|
||||
let missing_id = host.broker.send(
|
||||
"console",
|
||||
Some("missing"),
|
||||
None,
|
||||
None,
|
||||
json!({"type": "hello"}),
|
||||
);
|
||||
let missing = receive_reply(&mut mailbox, &missing_id, Duration::from_secs(10)).await;
|
||||
assert_eq!(missing.body["type"], "undeliverable");
|
||||
assert_eq!(missing.body["to"], "missing");
|
||||
|
||||
let source = passing_source(&world, "router");
|
||||
let spawned = spawn_actor(&host, &mut mailbox, "router", &wasm, &source).await;
|
||||
assert_eq!(spawned.body["type"], "spawned");
|
||||
|
||||
let echo = ask(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"router",
|
||||
json!({"type": "echo", "value": "hello"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(echo.from, "router");
|
||||
assert_eq!(echo.body, json!({"type": "echo", "value": "hello"}));
|
||||
|
||||
let subscribed = request(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
json!({"type": "subscribe", "actor": "router"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(subscribed.body["type"], "subscribed");
|
||||
|
||||
host.broker.send(
|
||||
"console",
|
||||
Some("router"),
|
||||
None,
|
||||
None,
|
||||
json!({"type": "publish", "value": "news"}),
|
||||
);
|
||||
let published = receive_message(&mut mailbox, Duration::from_secs(10)).await;
|
||||
assert_eq!(published.from, "router");
|
||||
assert_eq!(
|
||||
published.body,
|
||||
json!({"type": "published", "value": "news"})
|
||||
);
|
||||
assert_eq!(published.reply_to, None);
|
||||
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn broker_does_not_answer_messages_with_reply_to() {
|
||||
let world = create_world("broker-reply-to");
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let scanned = request(&host, &mut mailbox, json!({"type": "scan"})).await;
|
||||
assert_eq!(scanned.body["type"], "scanned");
|
||||
|
||||
host.broker.send(
|
||||
"console",
|
||||
Some("broker"),
|
||||
None,
|
||||
Some("upstream-message"),
|
||||
json!({"type": "scan"}),
|
||||
);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(1), mailbox.recv())
|
||||
.await
|
||||
.is_err(),
|
||||
"broker replied to a message that already had reply_to"
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exit_request_is_answered_and_signals_the_host() {
|
||||
let world = create_world("broker-exit");
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let exiting = request(&host, &mut mailbox, json!({"type": "exit"})).await;
|
||||
assert_eq!(exiting.body["type"], "exiting");
|
||||
tokio::time::timeout(Duration::from_secs(1), host.broker.exit_requested())
|
||||
.await
|
||||
.expect("exit request did not reach the host");
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filesystem_permissions_and_public_sharing() {
|
||||
let (_, host, mut mailbox) = filesystem_world("filesystem-permissions").await;
|
||||
for path in [
|
||||
"/actor/reader/public/denied.txt",
|
||||
"/actor/writer/public/denied.txt",
|
||||
] {
|
||||
let denied = ask(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"writer",
|
||||
json!({"type": "write", "path": path, "text": "denied"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(denied.body["type"], "error", "write to {path} succeeded");
|
||||
}
|
||||
|
||||
let written = ask(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"writer",
|
||||
json!({"type": "write", "path": "/public/shared.txt", "text": "public"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(written.body["type"], "ok");
|
||||
let shared = ask(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"reader",
|
||||
json!({"type": "read", "path": "/actor/writer/public/shared.txt"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(shared.body, json!({"type": "content", "text": "public"}));
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actors_can_read_but_not_write_other_actor_data() {
|
||||
let (_, host, mut mailbox) = filesystem_world("actor-data-sharing").await;
|
||||
ask_type(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"writer",
|
||||
json!({"type": "write", "path": "/data/x", "text": "shared"}),
|
||||
"ok",
|
||||
)
|
||||
.await;
|
||||
let shared = ask_type(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"reader",
|
||||
json!({"type": "read", "path": "/actor/writer/data/x"}),
|
||||
"content",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(shared.body["text"], "shared");
|
||||
|
||||
ask_type(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"reader",
|
||||
json!({"type": "write", "path": "/actor/writer/data/x", "text": "changed"}),
|
||||
"error",
|
||||
)
|
||||
.await;
|
||||
let unchanged = ask_type(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"writer",
|
||||
json!({"type": "read", "path": "/data/x"}),
|
||||
"content",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(unchanged.body["text"], "shared");
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actors_serve_files_under_fs() {
|
||||
// "writer" serves its fs/ from memory (toolbox fs_read / fs_write / fs_remove).
|
||||
let (_, host, mut mailbox) = filesystem_world("actor-fs").await;
|
||||
let file = "/actor/writer/fs/notes/a.txt";
|
||||
let write = json!({"type": "write", "path": file, "text": "hello"});
|
||||
ask_type(&host, &mut mailbox, "reader", write, "ok").await;
|
||||
// The write reached the owner as one fs_write message.
|
||||
let direct = json!({"type": "fs_read", "path": "/notes/a.txt"});
|
||||
let served = ask_type(&host, &mut mailbox, "writer", direct, "fs_file").await;
|
||||
assert_eq!(served.body["text"], "hello");
|
||||
|
||||
let read = json!({"type": "read", "path": file});
|
||||
assert_eq!(ask_type(&host, &mut mailbox, "reader", read, "content").await.body["text"], "hello");
|
||||
let list = json!({"type": "list", "path": "/actor/writer/fs/notes"});
|
||||
assert_eq!(ask_type(&host, &mut mailbox, "reader", list, "entries").await.body["names"], json!(["a.txt"]));
|
||||
let root = json!({"type": "list", "path": "/actor/writer/fs"});
|
||||
assert_eq!(ask_type(&host, &mut mailbox, "reader", root, "entries").await.body["names"], json!(["notes"]));
|
||||
|
||||
// fs_error and undeliverable become errors of the file operation.
|
||||
for path in ["/actor/writer/fs/missing.txt", "/actor/nobody/fs/x"] {
|
||||
ask_type(&host, &mut mailbox, "reader", json!({"type": "read", "path": path}), "error").await;
|
||||
}
|
||||
// An actor that does not answer (here: it is busy reading itself) fails after the timeout.
|
||||
let id = host.broker.send("console", Some("reader"), None, None, json!({"type": "read", "path": "/actor/reader/fs/x"}));
|
||||
let timed_out = receive_reply(&mut mailbox, &id, Duration::from_secs(20)).await;
|
||||
assert_eq!(timed_out.body["type"], "error");
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_are_plain_posix_files() {
|
||||
let (_, host, mut mailbox) = filesystem_world("posix").await;
|
||||
// An open file is visible by path, to the writer and to every reader.
|
||||
let open = json!({"type": "open", "path": "/public/new.txt", "text": "new"});
|
||||
ask_type(&host, &mut mailbox, "writer", open, "opened").await;
|
||||
let own = json!({"type": "read", "path": "/public/new.txt"});
|
||||
assert_eq!(ask_type(&host, &mut mailbox, "writer", own, "content").await.body["text"], "new");
|
||||
let other = json!({"type": "read", "path": "/actor/writer/public/new.txt"});
|
||||
assert_eq!(ask_type(&host, &mut mailbox, "reader", other, "content").await.body["text"], "new");
|
||||
// It can be renamed while open.
|
||||
let rename = json!({"type": "rename", "path": "/public/new.txt", "to": "/public/moved.txt"});
|
||||
ask_type(&host, &mut mailbox, "writer", rename, "ok").await;
|
||||
ask_type(&host, &mut mailbox, "writer", json!({"type": "close", "path": "/public/new.txt"}), "closed").await;
|
||||
let moved = json!({"type": "read", "path": "/actor/writer/public/moved.txt"});
|
||||
assert_eq!(ask_type(&host, &mut mailbox, "reader", moved, "content").await.body["text"], "new");
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filesystem_rejects_paths_that_escape_data() {
|
||||
let (_, host, mut mailbox) = filesystem_world("filesystem-path-escape").await;
|
||||
let escaped = ask(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"writer",
|
||||
json!({"type": "write", "path": "/data/../../..", "text": "outside"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(escaped.body["type"], "error");
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upgrades_test_candidates_keep_old_versions_and_queue_messages_in_order() {
|
||||
let world = create_world("upgrade");
|
||||
let wasm = toolbox_wasm().clone();
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let passing = passing_source(&world, "passing");
|
||||
let failing = source_with_test(
|
||||
&world,
|
||||
"failing",
|
||||
json!({
|
||||
"steps": [{
|
||||
"send": {"type": "echo", "value": "candidate"},
|
||||
"expect": {"type": "wrong"}
|
||||
}]
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
spawn_actor(&host, &mut mailbox, "queue", &wasm, &passing)
|
||||
.await
|
||||
.body["type"],
|
||||
"spawned"
|
||||
);
|
||||
|
||||
let failed = upgrade_actor(&host, &mut mailbox, "queue", &wasm, &failing).await;
|
||||
assert_eq!(failed.body["type"], "error");
|
||||
let old_version = ask(&host, &mut mailbox, "queue", json!({"type": "version"})).await;
|
||||
assert_eq!(old_version.body["version"], "1");
|
||||
// A version that fails its tests never enters versions/.
|
||||
assert!(!world.actor_dir("queue").join("versions/2").exists());
|
||||
|
||||
let upgrade_id = host.broker.send(
|
||||
"console",
|
||||
Some("broker"),
|
||||
None,
|
||||
None,
|
||||
json!({"type": "upgrade", "actor": "queue", "wasm": wasm.display().to_string(), "source": passing.display().to_string()}),
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while !world.data_dir("queue").join("shutdown").is_file() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("old actor did not enter shutdown during upgrade");
|
||||
|
||||
let mut message_ids = Vec::new();
|
||||
for n in 1..=3 {
|
||||
message_ids.push(host.broker.send(
|
||||
"console",
|
||||
Some("queue"),
|
||||
None,
|
||||
None,
|
||||
json!({"type": "ordered", "n": n}),
|
||||
));
|
||||
}
|
||||
|
||||
let mut upgrade_result = None;
|
||||
let mut actor_replies = Vec::new();
|
||||
tokio::time::timeout(Duration::from_secs(15), async {
|
||||
while upgrade_result.is_none() || actor_replies.len() < message_ids.len() {
|
||||
let envelope = mailbox.recv().await.expect("console mailbox closed");
|
||||
if envelope.reply_to.as_deref() == Some(upgrade_id.as_str()) {
|
||||
upgrade_result = Some(envelope);
|
||||
} else if message_ids
|
||||
.iter()
|
||||
.any(|id| envelope.reply_to.as_deref() == Some(id.as_str()))
|
||||
{
|
||||
actor_replies.push(envelope);
|
||||
} else {
|
||||
panic!("unexpected message during queued upgrade: {envelope:?}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("upgrade did not deliver queued messages");
|
||||
|
||||
assert_eq!(upgrade_result.unwrap().body["type"], "upgraded");
|
||||
let actual_ids: Vec<String> = actor_replies
|
||||
.iter()
|
||||
.map(|message| message.reply_to.clone().unwrap())
|
||||
.collect();
|
||||
assert_eq!(actual_ids, message_ids);
|
||||
for (index, envelope) in actor_replies.iter().enumerate() {
|
||||
assert_eq!(envelope.from, "queue");
|
||||
assert_eq!(envelope.body["type"], "ordered");
|
||||
assert_eq!(envelope.body["n"], json!(index + 1));
|
||||
assert_eq!(envelope.body["version"], "2");
|
||||
}
|
||||
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rolls_back_a_crash_loop_to_the_previous_version_without_restoring_data() {
|
||||
let world = create_world("crash-loop");
|
||||
let wasm = toolbox_wasm().clone();
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let source = passing_source(&world, "source");
|
||||
assert_eq!(
|
||||
spawn_actor(&host, &mut mailbox, "crashloop", &wasm, &source)
|
||||
.await
|
||||
.body["type"],
|
||||
"spawned"
|
||||
);
|
||||
assert_eq!(
|
||||
ask(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"crashloop",
|
||||
json!({"type": "write", "path": "/data/state", "text": "stable"})
|
||||
)
|
||||
.await
|
||||
.body["type"],
|
||||
"ok"
|
||||
);
|
||||
|
||||
let upgrade_id = host.broker.send(
|
||||
"console",
|
||||
Some("broker"),
|
||||
None,
|
||||
None,
|
||||
json!({"type": "upgrade", "actor": "crashloop", "wasm": wasm.display().to_string(), "source": source.display().to_string()}),
|
||||
);
|
||||
let mut upgraded: Option<Value> = None;
|
||||
let mut rolled_back: Option<Value> = None;
|
||||
tokio::time::timeout(Duration::from_secs(35), async {
|
||||
while upgraded.is_none() || rolled_back.is_none() {
|
||||
let envelope = mailbox.recv().await.expect("console mailbox closed");
|
||||
let kind = envelope.body["type"].as_str().map(str::to_string);
|
||||
match kind.as_deref() {
|
||||
Some("upgraded") if envelope.reply_to.as_deref() == Some(upgrade_id.as_str()) => {
|
||||
upgraded = Some(envelope.body);
|
||||
}
|
||||
Some("rolled_back") => rolled_back = Some(envelope.body),
|
||||
other => panic!("unexpected automatic rollback message: {other:?}, {envelope:?}"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("crash loop did not trigger automatic rollback");
|
||||
|
||||
assert_eq!(upgraded.unwrap()["version"], 2);
|
||||
let rollback = rolled_back.unwrap();
|
||||
assert_eq!(rollback["actor"], "crashloop");
|
||||
assert_eq!(rollback["from"], 2);
|
||||
assert_eq!(rollback["to"], 1);
|
||||
assert_eq!(rollback["reason"], "crash loop after upgrade");
|
||||
|
||||
let version = ask(&host, &mut mailbox, "crashloop", json!({"type": "version"})).await;
|
||||
assert_eq!(version.body["version"], "1");
|
||||
let current = ask(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"crashloop",
|
||||
json!({"type": "read", "path": "/data/state"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(current.body["text"], "bad-v2");
|
||||
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_rollback_keeps_current_data() {
|
||||
let world = create_world("explicit-rollback");
|
||||
let wasm = toolbox_wasm().clone();
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let source = passing_source(&world, "source");
|
||||
assert_eq!(
|
||||
spawn_actor(&host, &mut mailbox, "recover", &wasm, &source)
|
||||
.await
|
||||
.body["type"],
|
||||
"spawned"
|
||||
);
|
||||
ask(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"recover",
|
||||
json!({"type": "write", "path": "/data/state", "text": "version-one"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let upgraded = upgrade_actor(&host, &mut mailbox, "recover", &wasm, &source).await;
|
||||
assert_eq!(upgraded.body["type"], "upgraded");
|
||||
ask(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"recover",
|
||||
json!({"type": "write", "path": "/data/state", "text": "version-two"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let rolled_back = request(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
json!({"type": "rollback", "actor": "recover", "version": 1}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(rolled_back.body["type"], "rolled_back");
|
||||
assert_eq!(rolled_back.body["from"], 2);
|
||||
assert_eq!(rolled_back.body["to"], 1);
|
||||
let version = ask(&host, &mut mailbox, "recover", json!({"type": "version"})).await;
|
||||
assert_eq!(version.body["version"], "1");
|
||||
let current = ask(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"recover",
|
||||
json!({"type": "read", "path": "/data/state"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(current.body["text"], "version-two");
|
||||
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_shuts_an_actor_down_and_deletes_its_folder() {
|
||||
let world = create_world("remove");
|
||||
let wasm = toolbox_wasm().clone();
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let source = passing_source(&world, "source");
|
||||
spawn_type(&host, &mut mailbox, "doomed", &wasm, &source).await;
|
||||
let write = json!({"type": "write", "path": "/data/state", "text": "x"});
|
||||
ask_type(&host, &mut mailbox, "doomed", write, "ok").await;
|
||||
ask_type(&host, &mut mailbox, "doomed", json!({"type": "arm-shutdown"}), "armed").await;
|
||||
|
||||
// Normal shutdown: the actor gets "shutdown" and its last message is routed.
|
||||
let id = host.broker.send("console", Some("broker"), None, None, json!({"type": "remove", "actor": "doomed"}));
|
||||
let (mut removed, mut last_words) = (false, false);
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while !removed || !last_words {
|
||||
let message = mailbox.recv().await.expect("console mailbox closed");
|
||||
if message.reply_to.as_deref() == Some(id.as_str()) {
|
||||
assert_eq!(message.body, json!({"type": "removed", "actor": "doomed"}));
|
||||
removed = true;
|
||||
} else if message.reply_to.as_deref() == Some("broker/shutdown") {
|
||||
assert_eq!(message.body["type"], "shutdown-reply");
|
||||
last_words = true;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("remove did not answer or did not shut the actor down");
|
||||
assert!(!world.actor_dir("doomed").exists());
|
||||
|
||||
let gone = ask(&host, &mut mailbox, "doomed", json!({"type": "echo", "value": "hi"})).await;
|
||||
assert_eq!(gone.body["type"], "undeliverable");
|
||||
let again = request(&host, &mut mailbox, json!({"type": "remove", "actor": "doomed"})).await;
|
||||
assert_eq!(again.body["type"], "error");
|
||||
let broker = request(&host, &mut mailbox, json!({"type": "remove", "actor": "broker"})).await;
|
||||
assert_eq!(broker.body["type"], "error");
|
||||
assert!(world.actor_dir("broker").exists());
|
||||
|
||||
// The ID is free again: a new spawn starts at version 1 with empty data.
|
||||
assert_eq!(spawn_type(&host, &mut mailbox, "doomed", &wasm, &source).await.body["version"], 1);
|
||||
let read = json!({"type": "read", "path": "/data/state"});
|
||||
ask_type(&host, &mut mailbox, "doomed", read, "error").await;
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_broker_request_lists_the_known_ones() {
|
||||
let world = create_world("unknown-request");
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let unknown = request(&host, &mut mailbox, json!({"type": "frobnicate"})).await;
|
||||
assert_eq!(unknown.body["type"], "error");
|
||||
assert!(unknown.body["reason"].as_str().unwrap().contains("remove"));
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cargo_actor_builds_valid_crates_and_reports_compiler_errors() {
|
||||
let world = create_world("cargo-actor");
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let valid_source = cargo_source("cargo-valid");
|
||||
let valid_id = host.broker.send(
|
||||
"console",
|
||||
Some("cargo"),
|
||||
None,
|
||||
None,
|
||||
json!({"type": "build", "source": valid_source.display().to_string()}),
|
||||
);
|
||||
let built = receive_reply(&mut mailbox, &valid_id, Duration::from_secs(120)).await;
|
||||
assert_eq!(built.body["type"], "built");
|
||||
let wasm = built.body["wasm"].as_str().unwrap();
|
||||
assert!(wasm.ends_with(".wasm"));
|
||||
assert!(host.world.resolve("console", wasm).unwrap().is_file());
|
||||
|
||||
let invalid_source = cargo_source("cargo-fail");
|
||||
let invalid_id = host.broker.send(
|
||||
"console",
|
||||
Some("cargo"),
|
||||
None,
|
||||
None,
|
||||
json!({"type": "build", "source": invalid_source.display().to_string()}),
|
||||
);
|
||||
let failed = receive_reply(&mut mailbox, &invalid_id, Duration::from_secs(120)).await;
|
||||
assert_eq!(failed.body["type"], "build_failed");
|
||||
assert!(
|
||||
failed.body["errors"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("missing_function")
|
||||
);
|
||||
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_actor_ids() {
|
||||
let world = create_world("invalid-id");
|
||||
let wasm = toolbox_wasm().clone();
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let invalid = request(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
json!({"type": "spawn", "actor": "bad/actor", "wasm": wasm.display().to_string()}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(invalid.body["type"], "error");
|
||||
assert!(
|
||||
invalid.body["reason"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("invalid actor ID")
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_parent_paths() {
|
||||
let world = create_world("parent-path");
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let parent_path = request(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
json!({"type": "spawn", "actor": "safe", "wasm": "/public/../escape.wasm"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(parent_path.body["type"], "error");
|
||||
assert!(
|
||||
parent_path.body["reason"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("must not contain '..'")
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_spawns_of_one_id_leave_one_live_instance() {
|
||||
let world = create_world("concurrent-spawn");
|
||||
let wasm = toolbox_wasm().clone();
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let source = passing_source(&world, "source");
|
||||
let ids = [
|
||||
spawn_request(&host, "spawn-race", &wasm, &source),
|
||||
spawn_request(&host, "spawn-race", &wasm, &source),
|
||||
];
|
||||
let replies = receive_replies(&mut mailbox, &ids, Duration::from_secs(20)).await;
|
||||
let spawned = replies
|
||||
.iter()
|
||||
.filter(|r| r.body["type"] == "spawned")
|
||||
.count();
|
||||
let failed = replies.iter().filter(|r| r.body["type"] == "error").count();
|
||||
assert_eq!((spawned, failed), (1, 1));
|
||||
|
||||
let echo = ask_type(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"spawn-race",
|
||||
json!({"type": "echo", "value": "live"}),
|
||||
"echo",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(echo.body["value"], "live");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let list = ask_type(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"spawn-race",
|
||||
json!({"type": "list", "path": "/data"}),
|
||||
"entries",
|
||||
)
|
||||
.await;
|
||||
let starts = list.body["names"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|n| n.as_str().unwrap().starts_with("start-"))
|
||||
.count();
|
||||
assert_eq!(starts, 1);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_finishes_when_actor_stdin_is_full() {
|
||||
let world = create_world("full-stdin-stop");
|
||||
let wasm = toolbox_wasm().clone();
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let source = passing_source(&world, "source");
|
||||
spawn_type(&host, &mut mailbox, "full-stdin", &wasm, &source).await;
|
||||
let ignore = json!({"type": "ignore-stdin"});
|
||||
ask_type(&host, &mut mailbox, "full-stdin", ignore, "ignoring").await;
|
||||
let large = json!({"type": "echo", "value": "x".repeat(256 * 1024)});
|
||||
host.broker
|
||||
.send("console", Some("full-stdin"), None, None, large);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let stop = json!({"type": "stop", "actor": "full-stdin"});
|
||||
let id = host
|
||||
.broker
|
||||
.send("console", Some("broker"), None, None, stop);
|
||||
let stopped = tokio::time::timeout(
|
||||
Duration::from_secs(6),
|
||||
receive_reply(&mut mailbox, &id, Duration::from_secs(10)),
|
||||
)
|
||||
.await
|
||||
.expect("stop exceeded the five-second shutdown deadline");
|
||||
assert_eq!(stopped.body["type"], "stopped");
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_reply_and_publish_survive_stop_and_upgrade() {
|
||||
let world = create_world("shutdown-output");
|
||||
let wasm = toolbox_wasm().clone();
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let source = passing_source(&world, "source");
|
||||
spawn_type(&host, &mut mailbox, "shutdown-output", &wasm, &source).await;
|
||||
let subscribe = json!({"type": "subscribe", "actor": "shutdown-output"});
|
||||
ask_type(&host, &mut mailbox, "broker", subscribe, "subscribed").await;
|
||||
|
||||
for operation in ["stop", "upgrade"] {
|
||||
let arm = json!({"type": "arm-shutdown"});
|
||||
ask_type(&host, &mut mailbox, "shutdown-output", arm, "armed").await;
|
||||
let body = match operation {
|
||||
"stop" => json!({"type": "stop", "actor": "shutdown-output"}),
|
||||
_ => {
|
||||
json!({"type": "upgrade", "actor": "shutdown-output", "wasm": wasm.display().to_string(), "source": source.display().to_string()})
|
||||
}
|
||||
};
|
||||
let id = host
|
||||
.broker
|
||||
.send("console", Some("broker"), None, None, body);
|
||||
receive_shutdown_messages(
|
||||
&mut mailbox,
|
||||
&id,
|
||||
if operation == "stop" {
|
||||
"stopped"
|
||||
} else {
|
||||
"upgraded"
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if operation == "stop" {
|
||||
let start = json!({"type": "start", "actor": "shutdown-output"});
|
||||
ask_type(&host, &mut mailbox, "broker", start, "started").await;
|
||||
}
|
||||
}
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn candidates_receive_version_and_preexisting_spawn_state() {
|
||||
let world = create_world("candidate-context");
|
||||
let wasm = toolbox_wasm().clone();
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let version_step =
|
||||
json!({"send": {"type": "version"}, "expect": {"type": "version", "version": "1"}});
|
||||
let version_source = source_with_test(&world, "version", json!({"steps": [version_step]}));
|
||||
let state_step = json!({"send": {"type": "read", "path": "/data/state.json"}, "expect": {"type": "content", "text": "seeded"}});
|
||||
let state_source = source_with_test(&world, "state", json!({"steps": [state_step]}));
|
||||
let state_dir = world.data_dir("stateful");
|
||||
std::fs::create_dir_all(&state_dir).unwrap();
|
||||
std::fs::write(state_dir.join("state.json"), "seeded").unwrap();
|
||||
let ids = [
|
||||
spawn_request(&host, "versioned", &wasm, &version_source),
|
||||
spawn_request(&host, "stateful", &wasm, &state_source),
|
||||
];
|
||||
let replies = receive_replies(&mut mailbox, &ids, Duration::from_secs(20)).await;
|
||||
let successful = replies
|
||||
.iter()
|
||||
.filter(|reply| reply.body["type"] == "spawned")
|
||||
.count();
|
||||
assert_eq!(successful, 2);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn running_actors_have_sockets_and_candidates_do_not() {
|
||||
// A TCP server on the host that answers each line with "echo: <line>".
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap().to_string();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((stream, _)) = listener.accept().await {
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
|
||||
let (read, mut write) = stream.into_split();
|
||||
let mut lines = tokio::io::BufReader::new(read).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let _ = write.write_all(format!("echo: {line}\n").as_bytes()).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let world = create_world("sockets");
|
||||
let wasm = toolbox_wasm().clone();
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
let tcp = json!({"type": "tcp", "address": address, "text": "hi"});
|
||||
// The candidate test expects the connection to fail.
|
||||
let offline = source_with_test(&world, "offline", json!({"steps": [{"send": tcp, "expect": {"type": "error"}}]}));
|
||||
assert_eq!(spawn_actor(&host, &mut mailbox, "net", &wasm, &offline).await.body["type"], "spawned");
|
||||
|
||||
let answer = ask(&host, &mut mailbox, "net", tcp).await;
|
||||
assert_eq!(answer.body, json!({"type": "tcp", "line": "echo: hi"}));
|
||||
host.stop().await;
|
||||
}
|
||||
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,195 +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;
|
||||
}
|
||||
|
||||
/// PROTOCOL 9: core discovery includes the armed watch and matches its proc projection.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn discovery() {
|
||||
let (_dir, h) = start().await;
|
||||
let doc = call(&h, "core.rpc.discover", json!({})).await;
|
||||
let projected = call(&h, "probe.read", json!({"path":"/proc/core/discover.json"})).await;
|
||||
assert_eq!(
|
||||
doc,
|
||||
serde_json::from_str::<Value>(projected.as_str().unwrap()).unwrap()
|
||||
);
|
||||
let watch = doc["methods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|m| m["name"] == "watch")
|
||||
.unwrap();
|
||||
assert_eq!(watch["params"][0]["name"], "path");
|
||||
assert!(watch["description"].as_str().unwrap().contains("armed"));
|
||||
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,69 +0,0 @@
|
||||
# Disposable handover rehearsal — SAU-384
|
||||
|
||||
Authority: [/docs/design/plugin-handover/design.md](/docs/design/plugin-handover/design.md), Rollout; [/docs/symphony/artifacts/SAU-356/plan.md](/docs/symphony/artifacts/SAU-356/plan.md), Approval and operator gates.
|
||||
|
||||
**Status: rehearsal complete, ready for review; not a live execution approval.** Rehearsal evidence and the reviewed target bundle are under [/docs/symphony/artifacts/SAU-384](/docs/symphony/artifacts/SAU-384). No shipped plugin or host behavior changes on this branch. The temporary migration implementation and native controller were executed only in a throwaway box and deleted after the successful migration verification. Their hashes/deletion record and source-as-log remain, not an executable migration tool.
|
||||
|
||||
## Closed integration gaps
|
||||
|
||||
- **Round 4, run 1392:** allocated-block/directory/sparse-copy space budgeting; disk-independent foreground progress/failure/pidfd-death monitor; incomplete pre-plan backup cleanup before abort writes plus low-space refusal; forward-only post-start continue with durable locked proof and stable continuation ID; guarded atomic-temporary cleanup; converter dry-run before freeze. Shared-filesystem ENOSPC (world/root/log at zero free bytes), each post-start RPC failure including lost committed send reply, real 120 s registration timeout, partial atomic crash/abort, wrapper-exec handshake/resume, sidecars and full workspace passed. Tools deleted after verified success. Source/logs, fixture failures and limits: [rework-1392.md](rework-1392.md). Live plan is still approval-gated.
|
||||
|
||||
- **Round 3, run 1389:** deferred approved git 9cc9fa6f is bundled/preflighted, activated only after target cred is live and Surma unlocks. Real old/new Git + new cred loopback tests covered granted SSH, locked denial and new shared SSH. Added real-driver pre-start abort/restore/old-owner relaunch, live consistent-copy/executable-only caller guards, special-file/free-space gates, finite registration deadline/failure notification, exact pre-cut prev images and durable arm-marker crash recovery. Real partial-swap/ENOSPC/audit failures, arm interruption, final Herdr cutover/preservation/sidecars and full workspace (208 host tests) passed. Tool/controllers deleted after verification. Exact logs, selection and limits: [rework-1389.md](rework-1389.md); proposed live recovery/roots-side-effect plan: [runbook](runbook.md).
|
||||
|
||||
- **Review rework, run 1381:** one detached external CLI now owns freeze→backup/plan/audit→apply→same-Herdr-pane launch and publishes a new explicit mediator verification message. Caller/controller death and interrupted planning resume passed. Plan/apply check the actual current-boot frozen checkpoint; old/target GC roots and permanent pinned owner wrappers, stopped-PTY TERM+CONT, SQLite sidecars and whole-world scale were tested. Tool/controllers were logged/deleted after verification; current workspace tests passed again. See [rework-1381.md](rework-1381.md) for exact evidence, limitations and disclosure of an early shared-Nix stale-root cleanup (all referenced targets/PIDs were absent on the host; subsequent tests used private Nix state). [Runbook](runbook.md) now assigns all downtime work to that external controller, not the cut old mediator. Live launch/release still require the explicit operator approval described there.
|
||||
- **Run 1373:** after mediator installed the observed public host key, approved read-only SSH inspection identified the actual Herdr pane, outer `sausage-loop`, world path, native architecture, old package/derivation/source and runtime closure. The actual old Nix package/loop → stop-all → offline swap → exact new Nix release package/loop passed in the disposable live-size world. A non-parent external controller separately proved loop-first stop, interruption/resumption, stale-PID refusal and safe frozen rerun. A real core.restart after loop exit could not respawn the host. See [native evidence](resume-1373.md).
|
||||
- **Run 1369:** armed/exit-race delay-gate tests passed on real core with actual target WASM; replacement orchestrator readiness/one-owner/all-watch arming passed. Hooks existed only in a disposable host copy, never the selected host artifact or committed code.
|
||||
- **Rollback:** actual old-manager/gateway tests prove **unsubscribe works; ID-only re-key does not**. The old binary ignores best_effort and replays history to a renamed ID. The runbook mandates the card-authorized unsubscribe option and prevents web re-registration during a session revert. A physical browser/SSE display is not claimed.
|
||||
- Final selected coherent bundle is **`cutover-targets-1389.tar.gz`** (SHA-256 `31dbb95b796c1fedfd8c37a8b2354cb0977face8653345dc026cc1bbdefac18b`), **23 payloads**, including deferred approved **git 9cc9fa6f / 177 tests**. Same Nix native package/closure, approved cred main `0db40101` build **1636 / 274 tests**, other reviewed plugins/web assets and SDK `src/lib.rs`; no diagnostic debug host. Git is not swapped offline; Surma unlock precedes its mediator activation. Earlier bundles and runs remain historical evidence. Final exact-driver native/preservation/sidecar/failure and full-workspace gates passed again; executable/controller deletion and source-as-log are in rework-1389.md.
|
||||
|
||||
The final frozen inventory/caller-schema/hash check remains an operator gate at the actual approved shutdown, not something a read-only rehearsal can perform in advance. Live execution still requires Surma's go and mediator/orchestrator action; no approval request is pending.
|
||||
|
||||
## What was built and exercised
|
||||
|
||||
- Host source `e09c4f9f0ef6b5833d6be066771eecd9e0a1f572`. Original debug native controller exercised real `sausage::Host`, VFS, watches, locks, service supervisor and WASM with synthetic/loopback effects. Run 1373 additionally built/rehearsed the actual **Nix release wrapper/package** `/nix/store/spyd9rix6r38m6vxfppyy9z83yp5dh85-sausage-0.1.0`; its empty-caller-PATH runtime check built/tested a real WASI+C plugin. Workspace suite passed again; all 105 Rust source files matched the tested tree. Neither host was released/deployed or selected by the live launcher. Native artifact/closure hashes are in `evidence/native-host-manifest.json`.
|
||||
- Reviewed session `cb844151` / SAU-409 final; orchestrator `c80a5854` / SAU-408; web `d883fbf2` / SAU-403; agent/board retained reviewed components. Fresh card-worktree builds: mail **1600 / 53 tests**, cred **1601 / 272**, cron **1602 / 24**, rooms **1603 / 59**, all passed. No activation. Component/source provenance and every bundled file hash are in `evidence/manifest.json`.
|
||||
- Frontend is exactly approved commit `3d76026749a5d21722033bbfe8743ac368946b0f`, retained production build from SAU-375 rework-1318. It remains `publish:hold`; no publication was attempted.
|
||||
- World SDK runtime sources already matched the selected host SDK. The only needed source-copy delta is **`src/lib.rs`**, documenting effective grace. Preserve world-owned Cargo manifests, relocated `interface`, and imports. A first broad SDK-copy attempt incorrectly included repository-relative Cargo paths; comparison caught this before any live change, and the final migration used only the one-file patch. Never copy the repository's SDK Cargo manifests over the world SDK.
|
||||
- Accepted SAU-362 byte snapshot: **1,491 sessions / 580,355,405 bytes**, including **70,689,796-byte default history**. Pristine snapshot remained unpreopened. Executable clones used synthetic model settings; their historical byte volume was retained. Source snapshot consistency is per-file, not a transactional live snapshot.
|
||||
|
||||
## Successful checks and limits
|
||||
|
||||
| Check | Evidence/result |
|
||||
|---|---|
|
||||
| Old ownership → stop-all → offline swap → target boot | Old `mail.start`, old session `send` and detached ledger processes inventoried. Native external controller awaited `Host.stop`, verified zero remaining processes and host exit, then backed up/transformed/swapped. Final shutdown **0.0958 s** in this fixture. One target manager, no mail deliverer, cred locked. |
|
||||
| Partial swap + rerun | Injected interruption after operation 3; hash-guarded completion and repeated installed-phase apply preserved all **4,374 non-target inventoried files**. No live/new work reset. |
|
||||
| Exact losses/preservation | Only `pending.jsonl`, `cancel`, and unclaimed pending non-Surma delivery rows selected for removal. Original mail messages, done/Surma copies and uncertain `unknown` deliveries retained without replay; scheduled SQL IDs, calendar/outcome fields and non-session JSON bytes retained. Synthetic grant DB bytes preserved. Loss inventory backs every removed file/row. |
|
||||
| Offline failure boundaries | Before-start rollback restored exact bytes; conflicting outside edits refused; ambiguous legacy `interrupt:true` refused without mutation. After-start apply and whole-world rollback both refused. |
|
||||
| Default/advisor HTTP replay | Three identical same-ID POSTs each, before/after consumption; exactly one answered run per session. Default returned null on repeats; advisor returned its same receipt. |
|
||||
| Explicit open target run | A run-start/consumed record with a finished-looking assistant tail, but no run-end, recovered under the **same agent-run UUID**, with one start. Legacy finished-looking history alone produced no target event/run. |
|
||||
| Real VFS/runner-boundary work | Actual SDK `armed` then plugin-write `changed`; five callback-boundary runs. Run 1369 additionally forced publication before runner `armed` and after its empty-mailbox exit decision while detached completion was held; no lost/duplicate inputs (disposable host delay gates, actual target WASM). |
|
||||
| Real core detached errors | Full nested error `data` survived a detached `core.call`. |
|
||||
| Real cron research deadline | **305.427 s** send→stopped in the final gate; real cron invoked warning and Stop. The 305-second test deadline is the existing 300-second warning interval plus design grace 5 seconds, not a new default. Held inference was cut, no follow-up run. Original configured research default remains 1,200 seconds. |
|
||||
| Effective grace / crash supervision | Passing native host suite exercises real WASI uniform-grace and before/after-readiness crash-supervision fixtures. Deliberate `core.kill` is NOT a crash: it disables that boot service; an initial rehearsal expecting its restart failed, correctly, and was replaced by the existing crash tests. |
|
||||
| Orchestrator upgrade/idle | Real file-watcher upgrade, FIFO singleton release, old exit before one replacement start event. Paused empty-board/live-size-world host CPU **0.02 s / 20.0002 s ≈ 0.10%**. This is whole-host idle CPU, not per-plugin CPU or live active-work load. Run 1369 additionally verified target acknowledgement, one owner and all 13 core watches armed after old exit. |
|
||||
|
||||
### Live-size latency after real records exist
|
||||
|
||||
After producing actual run-start/run-end/mailbox-consumed records, subsequent full boot measured **0.544 s** from manager service-start to the next boot service-start (includes discovery/ack overhead). Narrow registration catch-up samples ranged **0.0239 s to 12.0419 s** in the integrated world: the long sample coincided with other synthetic recovery/surfacing work, and the untagged sink cannot isolate attribution. Do not present this as a bounded manager-only benchmark.
|
||||
|
||||
The clean final live-size end-to-end gate measured manager acknowledgement **0.6078 s**, registration catch-up **0.0128 s**, and `mail.send` publication to default **0.5376 s**. The integrated post-record mail sample was **0.5235 s**. Both publication samples are below the existing orchestrator **3 s** and cred **10 s** caller budgets (SAU-400 report cites the originating SAU-366 review). These warm-cache synthetic-loopback real-core observations are not live latency guarantees. First scanning still delays first new input after the fast boot acknowledgement.
|
||||
|
||||
## Resume verification
|
||||
|
||||
Run 1369 forced recompilation and repeated the complete workspace suite on the restored original host source: pass, including **208 host integration tests**. All **144 source files** matched the selected source snapshot. Full log `/logs/box/2026-10-31/tc-asa3yr-exec-3838117964.stderr` (host tests lines 64–434; equivalence line 785). New tests and their limitations are detailed in `resume-1369.md`; new test controllers and instrumentation were deleted after successful checks. Original bundle and production source are unchanged.
|
||||
|
||||
## Evidence and failures
|
||||
|
||||
All following logs are under `/logs/box/2026-10-31/`:
|
||||
|
||||
- Full workspace pass: `tc-asa3yr-exec-2320283969.stderr`. First cold-cache/full-load failure: `tc-asa3yr-exec-158345583.stderr`, missing offline async-task and a queued-build timing test. Populated the separate SDK-probe cache; entire suite then passed unchanged.
|
||||
- Final stop/swap/start: `tc-asa3yr-exec-1922130515.stderr`; offline fault/grant preservation: `tc-asa3yr-exec-616022371.stderr`.
|
||||
- Final live-size HTTP/deadline gate: `tc-asa3yr-exec-1243993190.stderr`. Earlier malformed mail `to` scalar, 30-second startup observation bound and blank JSONL parser mistakes were harness errors; corrected to a sequence, existing 120-second test budget and blank-line skipping. Final gate passed.
|
||||
- Open-run recovery and boundary runs: `tc-asa3yr-exec-903419814.stderr` (subsequent console-watch attempt failed). A console is not a running watch caller; the final SDK guest watch passed in `tc-asa3yr-exec-3261552495.stderr`. Same log retains the real upgrade trace and idle sample. An identical already-acknowledged registration correctly produced no second catch-up; the final probe used a fresh registration identity.
|
||||
- Actual old-manager unsubscribe/re-key comparison: `tc-asa3yr-exec-3307393394.stderr`.
|
||||
- Native artifact architecture/runtime paths: `tc-asa3yr-exec-899103518.stderr`; bundle build/CLI: `tc-asa3yr-exec-1075679130.stderr`.
|
||||
- Executed temporary tool and controller **as log only**, followed by deletion proof: `tc-asa3yr-exec-2902726782.stderr` (31,475 bytes). `evidence/tool-deletion.json` lists removed executables. No transition code was put in a shipped plugin.
|
||||
|
||||
The initial broad-copy fixture and earlier failed tests are not the final kit. `evidence/migration-plan.json` identifies the final verified migration. Provider billing/cancellation, power loss, hung host filesystem behavior, physical browsers and independent per-plugin recovery follow-ups are not claimed complete. No live DB was opened; the accepted session snapshot was reused. No live config/SDK change, stop, activation, release, restart, push, deployment or `/www` write occurred.
|
||||
|
||||
See [operator runbook](runbook.md) for exact owner/package/selector steps, frozen-audit and approval gates. [Run 1373](resume-1373.md) records the final native rehearsal and remaining unverified live-only boundaries.
|
||||
@@ -1,39 +0,0 @@
|
||||
# SAU-384 resume — run 1369
|
||||
|
||||
Authority remains the card, design Rollout and SAU-356 approval gates. No live cut-over has been authorized or executed.
|
||||
|
||||
## Results added
|
||||
|
||||
- **Armed race passed:** actual target session WASM on real core, held immediately after core registered the runner's watch but before `armed`; a second real `session.send` published during the hold. Both IDs were consumed once in one run.
|
||||
- **Exit race passed:** held only delivery of the runner's initial result after the target's empty-mailbox exit decision. A second real publication arrived while the manager still considered that runner busy. Releasing detached completion produced exactly two runs, each consuming its own one ID. At publication the original runner had already exited; only the manager remained. No new timer/polling was added.
|
||||
- **Orchestrator upgrade readiness passed:** actual old SAU-370 component → selected SAU-408 component through the native plugin file watcher, old exit at 13:23:42.625Z before target start at 13:23:42.747Z, successful target acknowledgement and exactly one new owner. All 13 paths in `service.rs::WATCHES` armed in the target process. Prior whole-host idle duty measurement remains ~0.10% over 20 s in the paused, empty-board/live-size fixture; not a live workload guarantee.
|
||||
- **Actual gateway rollback passed:** new gateway first made its real `web-chat` best-effort registration; without another `web.start`, the session binary reverted to the actual pre-best-effort manager. Explicit unsubscribe caused zero `web.session_event` calls through the old manager's catch-up barrier. ID-only re-key caused one historical gateway callback. Therefore use the card's **unsubscribe** option; do not offer ID-only re-key as safe. This checks the actual callback/endpoint, not a physical browser/SSE display.
|
||||
- Restored production host source after disposable observation/delay hooks, forced recompilation, and reran `cargo test --workspace`: pass, including 208 native host integration tests and grace/supervision tests. All 144 source files matched the selected source snapshot. No source behavior changed in any committed plugin or host.
|
||||
|
||||
## Evidence
|
||||
|
||||
Under `/docs/symphony/artifacts/SAU-384/evidence/`:
|
||||
`resume-races-summary.json`, `resume-upgrade-summary.json`, `resume-upgrade-wire.json`, `resume-rollback-summary.json`, `resume-tool-deletion.json`, `resume-source-equivalence.json`.
|
||||
|
||||
Full logs under `/logs/box/2026-10-31/`:
|
||||
- `tc-asa3yr-exec-1457078935.stderr`: race passes, followed by the initial upgrade-observation failure. Immediate swap raced initial asynchronous graph discovery; no old-version discovery baseline existed yet. Next method waited once for discovery completion, not a liveness loop.
|
||||
- `tc-asa3yr-exec-870753431.stderr`: successful upgrade readiness/order.
|
||||
- `tc-asa3yr-exec-4006214778.stderr`: actual gateway unsubscribe/re-key comparison.
|
||||
- `tc-asa3yr-exec-621695466.stderr`: executed tests/controller and host-only instrumentation as log, then deletion. No executable one-off/controller copies remain.
|
||||
- `tc-asa3yr-exec-3838117964.stderr`: forced-recompile full workspace pass; host tests lines 64–434, source equivalence line 785. Earlier unforced repeat also passed but is not used to establish recompilation.
|
||||
|
||||
The original migration tool remains deleted. Recreated only disposable test controllers to close the above gaps; deleted those too after verification. Retained target bundle hash/artifacts are unchanged. Source-as-log is evidence, not a retained executable migration kit.
|
||||
|
||||
## Remaining work / approval wait
|
||||
|
||||
Only the actual external launcher/native installation manifest is still unresolved. Source `/repos/nixenv/machines/citadel/default.nix:22–27` disables `services.sausage`; `/repos/nixenv/machines/nexus/service-sausage.nix:10–11` describes the current Herdr panel. These sources do not pin the live panel, world path, launcher command, restart owner, stop completion API, executable/runtime closure, or architecture. Do not substitute `systemctl stop sausage` or `core.restart`.
|
||||
|
||||
Read-only systemd-web GETs confirmed no active system `sausage.service`, and user-manager journal entries mention `/home/surma/.config/herdr/herdr.sock`; they still do not identify the currently running panel. Sources used:
|
||||
- `http://citadel-systemd.nexus.hosts.10.0.0.2.nip.io:8081/api/units?type=service`
|
||||
- `http://citadel-systemd.nexus.hosts.10.0.0.2.nip.io:8081/api/logs?unit=user%401000.service&lines=100&boot=0`
|
||||
|
||||
No attached grant existed. One credential request is pending: **055995a51608c57dd25b323bea731fcb**, `id_scout`, `sub:card:SAU-384`, card duration, for **read-only** Citadel launcher inspection. The initial attempt supplied the immutable item ID, but live request expects a name and rejected it before filing; retry with `id_scout` filed the sole request. Do not request again; reuse it on resume. A credential approval is not permission to stop/restart/deploy.
|
||||
|
||||
After approval: inspect actual process/parent/Herdr configuration read-only; pin owner, world path, synchronous stop/start/completion mechanism and correct native package/closure. Finish/rehearse that specific outer host swap or report a precise unresolved interface. Do not silently claim that the existing target-host/native-controller rehearsal replaced the old native service package. The current local host bundle is an aarch64-linux debug-profile stripped binary, not a validated live installation package.
|
||||
|
||||
The surviving throwaway box is `tc-asa3yr`; temporary build/state copies remain there until its TTL. It contains no executable migration controller. If it expires, restore from retained target bundle/evidence and read the source logs, not a shipped plugin compatibility path. No pushes, activations, restarts, releases, config/SDK mutation, or `/www` writes occurred in the live world.
|
||||
@@ -1,32 +0,0 @@
|
||||
# SAU-384 resume — run 1372
|
||||
|
||||
No live operation is authorized or performed. The existing rehearsal and target bundle are unchanged.
|
||||
|
||||
## Read-only launcher inspection: trust prerequisite
|
||||
|
||||
The existing card-duration SSH grant was approved; no new credential request was filed. `ssh.exec` to `citadel.vpn.surma.technology`, login `surma`, with the read-only command `uname -m; ps -eo pid,ppid,user,args | grep -E '([h]erdr|[s]ausage)'` was refused **before authentication/command execution** because the host is unknown. The refusal reported:
|
||||
|
||||
```text
|
||||
SHA256:Fj3jS6DuV9SAo4PVQl/hTLI9SZPqWDwEd7E5hemfFkE
|
||||
citadel.vpn.surma.technology ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB8gP37Pnb3EyyQnkR2VdeJNzGU3OAuyJc3E3a8XpDaV
|
||||
```
|
||||
|
||||
The SSH skill permits installing this observed key without another person approval, but this card explicitly forbids worker live-configuration changes. Therefore the **mediator** must add the observed line to `/config/known_hosts`, preserving existing entries, then resume this worker. Surma does nothing. No private key was requested or fetched. `ssh.hosts_status` confirmed there is no already-trusted Citadel alias. The preceding run already tried the preferred read-only systemd-web route: it cannot identify the Herdr panel/native installation boundary. Do not substitute Nexus credentials, forward another host's SSH agent, bypass host checking, or treat this as permission to stop anything.
|
||||
|
||||
After trust is installed: reuse the attached grant, inspect launcher/process parents/Herdr config read-only; pin actual world path, architecture, executable/runtime closure and event/completion stop/start interface; then build/rehearse that specific outer host swap in a disposable box. No live stop, restart, config change or deployment is part of the resume.
|
||||
|
||||
## Caller-store inspection (not a frozen live audit)
|
||||
|
||||
`evidence/resume-1372-caller-inventory.json` records a read-only observation via `cron.list`, `board.hook_list`, boot-file inspection and current-main schema inspection. All eight cron jobs currently have `next_run:null`; six are historical `session.send` jobs with the legacy inner `detach:true`. Convert their executable JSON fields once with the already-rehearsed cron procedure, preserving SQL IDs, schedule/outcome fields and prompt bytes. References to old RPCs *inside their prompt strings* are history/instructions, not nested executable configuration; do not rewrite or replay them.
|
||||
|
||||
Five board hooks were observed. Only `mediator-done` calls `session.send`; its `{session,text}` payload already fits the target contract without legacy detach/notify/interrupt fields. Target board supplies a fresh per-attempt input ID and follow-up kind. No rewrite is needed for that observed hook. Other hooks target mail/orchestrator and must remain intact.
|
||||
|
||||
The frozen operator audit must additionally include board `invariant_target.data` (an arbitrary RPC template without a read-only target-get API), hooks and historical `hook_attempts`, board subscriptions/batches, room workflow/action records, orchestrator run/skill-review/host-job records and target session event registrations if present. Distinguish resumable target inputs from old diagnostic snapshots and never infer safe replay from a stored call-looking string. Current schemas are pointers, not substitutes for predecessor decoding or the actual frozen bytes. This session does **not** claim that final audit complete.
|
||||
|
||||
## Evidence/checks
|
||||
|
||||
- SSH refusal/public trust metadata: `evidence/resume-1372-ssh-trust.json`. No secret/JWT is copied to artifacts.
|
||||
- Existing box `tc-asa3yr` was inspected, not restarted. Executed controllers remain absent, including Python cache contents. Inspection log: `/logs/box/2026-10-31/tc-asa3yr-exec-803562803.stderr`, lines 1–40; the initial directory-list command returned 2 only because optional guessed directories did not exist, next inspection succeeded.
|
||||
- Host worktree fetched origin and merged local `main`: already current at documentation commit `8bf2a3c661ead774689e508ed45f609b40f60dd1`. Initial merge used `target` instead of the typed `rev` argument and was rejected without changes; corrected call succeeded.
|
||||
- JSON/caller/trust/manifest assertions and controller/cache-absence checks passed: `/logs/box/2026-10-31/tc-asa3yr-exec-666665822.stderr`. All 20 retained target file sizes/SHA-256 matched the updated manifest: `/logs/box/2026-10-31/tc-asa3yr-exec-3598104138.stderr`. No executable test/controller was written for these inline checks.
|
||||
- No Rust/plugin behavior changed or rebuilt. Run 1369's forced-recompile workspace pass and builds 1600–1603 remain the relevant gates. Independent plugin recovery follow-ups remain independent.
|
||||
@@ -1,74 +0,0 @@
|
||||
# SAU-384 resume — run 1373
|
||||
|
||||
**Ready for review, not live execution.** Authority remains the card/design Rollout and SAU-356 approval gates. No host/session/cred operation, push or web publication was authorized or performed. No shipped host/plugin behavior changed.
|
||||
|
||||
## Actual owner and native package resolved
|
||||
|
||||
After mediator installed the observed host key, reused the approved SSH grant for **read-only** inspection as `surma`. The actual owner is Herdr workspace **w7**, tab **w7:t5**, pane **w7:p6**. Its shell runs `/nix/store/pz2b94a2v99r3msljdz82h7n3i81hhh5-sausage-loop/bin/sausage-loop /home/surma/world/`. This is not a systemd Sausage unit. The loop uses a mutable remote flake, exits on zero and refreshes/restarts after any nonzero exit (its existing sleep is 2 s). Therefore stopping plugins or requesting core.restart is not a deliberate native stop boundary.
|
||||
|
||||
Citadel is **aarch64-linux**. Observed old package, derivation, original source and wrapper/binary hashes are pinned in `evidence/resume-1373-inspection.json`. Read the old derivation's actual CLI source: SIGTERM awaits `Host.stop`; an already-requested restart can still return 75. Do not reuse observed PIDs as constants.
|
||||
|
||||
Built the exact Nix **release** package from the unchanged card host source:
|
||||
|
||||
- `/nix/store/spyd9rix6r38m6vxfppyy9z83yp5dh85-sausage-0.1.0`
|
||||
- `bin/.sausage-wrapped` SHA-256 `683623c4c77dbcfff590c9f0aa78f045d07935a466836a099ccc53d7336853cb`
|
||||
- Package NAR and every closure path/hash: `evidence/native-host-manifest.json`. ELF is AArch64 with the pinned Nix glibc interpreter. The closure equals the observed old package's closure except for the host root itself.
|
||||
- Empty caller PATH runtime check **passed**, building/testing an actual WASI plugin with C and the packaged toolchain; it was rebuilt with visible output, not inferred from `--version`.
|
||||
|
||||
The box shares Citadel's Nix store: this build made an inert store path visible there, **not** selected by the live launcher. No live package/configuration/boot/SDK was changed.
|
||||
|
||||
## Native end-to-end and interruption tests
|
||||
|
||||
The disposable live-size world reused the accepted SAU-362 snapshot (1,491 sessions / 580,355,405 bytes). Executed the actual old Nix package and inspected loop, actual old mail/session components and actual target gateway; then:
|
||||
|
||||
1. Recorded old plugin ownership through the real gateway/proc projection.
|
||||
2. External controller terminated the **outer loop first**, awaited its pidfd exit, then SIGTERM'd the exact old host and awaited its exit. The host survived its parent until its own graceful shutdown; controller survived outside the group. One final group inventory was empty; no process polling.
|
||||
3. Backed up frozen state, applied the approved queue/mail losses and cron conversion, injected interruption after operation 3, completed and repeated the hash-guarded offline plan.
|
||||
4. Changed a static external selector from the old to the exact new package. Marked started before launch. Started the same actual loop with explicit `SAUSAGE_FLAKE=path:<selector>`.
|
||||
5. Verified the running OS executable was the new Nix binary; exactly one target manager, no old mail deliverer, locked cred, real subscriber-registration completion. After-start apply/whole-world rollback refused. Marked verified, then stopped cleanly.
|
||||
|
||||
Separate **non-parent controller** test used the reusable one-off `native_freeze.py` positional interface, not Python's parent-only wait(): pidfds pin the old loop/host, boot ID/start ticks/executable/group guard identities, owner exit precedes host stop. It proved interruption after owner exit then resume, stale-PID refusal without stopping the host, successful freeze and harmless frozen rerun. Another real-core test requested **core.restart after loop exit**: host exited and no owner/process in the old group could restart it. These tests close the external-owner gap; earlier run 1369 remains the evidence for armed/exit races, all-watch orchestrator upgrade ordering, and actual gateway rollback.
|
||||
|
||||
Native gate inventory contained 4,434 files. Of the 4,372 non-operation files, 4,370 stayed byte-identical after target activity; the only changes were the expected manager registration file and core journal. Histories/settings/mail/jobs/grants outside intended operations remained equal. Prior offline tests additionally proved exact losses, unknown mail retention, SQL ID/calendar/outcome preservation, grant preservation and rollback before start.
|
||||
|
||||
## Caller-store observation
|
||||
|
||||
Read-only SQLite connections (`mode=ro`, query_only, read transactions) inspected board, cron and rooms; JSON workflow/config stores were also inventoried without printing prompts or grants. See `evidence/resume-1373-inspection.json`:
|
||||
|
||||
- Six legacy cron session templates need the already-tested one-time conversion. Do not execute historical jobs or rewrite RPC words inside their prompts.
|
||||
- Both board's mediator hook **and invariant target** are already target-valid `{session,text}` calls. Preserve their bytes.
|
||||
- Eight historical running board session-attempt snapshots are diagnostic/uncertain, **never replayed** (`hooks.rs` explicitly documents this). Zero board subscriptions/batches were observed.
|
||||
- One abandoned room has no pending turn/review/split. Orchestrator run/skill-review records have no embedded old session/agent call objects; zero host jobs and no live session-manager registrations were observed. Preserve typed workflow records.
|
||||
- Three board runs had no outcome at observation, including this worker. This is old in-flight work, not proof of settlement or permission to replay it.
|
||||
|
||||
The operator must repeat this audit on the **frozen** backup at the approved shutdown. This changing live observation cannot be that future snapshot. Unknown executable references block preflight; no shipped compatibility path was added.
|
||||
|
||||
## Selected review artifacts and live steps
|
||||
|
||||
Selected coherent bundle: **`/docs/symphony/artifacts/SAU-384/cutover-targets.tar.gz`** (24,591,858 bytes), SHA-256 `1bbcae4ef615ca8394b0c53b9a408441a49f8a618f059335c3bc248d958977c4`. The 22 payloads are hash-pinned in `evidence/manifest.json`; bundle also carries that manifest. It contains the native NAR/closure manifest, reviewed plugins, approved web `3d760267`, and SDK **`src/lib.rs` only**. It excludes the earlier diagnostic debug host. Older `target-artifacts.tar.gz` remains immutable prior evidence, not the selected native install.
|
||||
|
||||
[Runbook](runbook.md) now gives actual owner/world/package paths, exact loop-first stop/checkpoint interface, proposed external root `/home/surma/sau-384-cutover` (absent at inspection), pinned selector and restart command, five-entry boot order (session before web; no mail.start), frozen caller conversion, web-release/rollback boundaries and mandatory executable deletion. Surma's go precedes mediator's live core/session/cred operation; every push separately needs go. Only mediator/orchestrator lands/executes; SAU-375 stays publish:hold until session and web are active, then orchestrator.release.
|
||||
|
||||
All five executed one-off/migration/test scripts were **deleted after verified success**; Python bytecode cache was empty. Hashes/deletion proof: `evidence/native-1373/native-tool-deletion.json`. Source is retained **as log only**, never as a shipped plugin or retained migration executable. The live mediator must reconstruct its reviewed temporary copy, execute only after go, and delete it plus executable caches after verification too. Static target selector configuration must remain for the pinned native loop; it is not a migration tool.
|
||||
|
||||
## Exact checks/logs
|
||||
|
||||
All full logs below are `/logs/box/2026-10-31/`:
|
||||
|
||||
- Nix package build/CLI: `tc-asa3yr-exec-1149169379.stderr`; packaged empty-PATH WASI+C gate: `tc-asa3yr-exec-395020656.stderr`.
|
||||
- Actual native old stop/offline interruption/swap/new boot: `tc-asa3yr-exec-3989255528.stderr`.
|
||||
- Non-parent controller interruption/stale-PID/frozen-rerun: `tc-asa3yr-exec-4245550011.stderr`; real core.restart after owner death: `tc-asa3yr-exec-2683606349.stderr`.
|
||||
- Native architecture/closure equivalence/hashes: `tc-asa3yr-exec-1926991205.stderr`.
|
||||
- Repeated `cargo test --workspace`: **pass**, including 208 host integration tests; `tc-asa3yr-exec-844023236.stderr`. All 105 Rust sources matched package/workspace copies. Run 1369's forced-recompile/source equivalence remains the earlier independent gate.
|
||||
- Executed source-as-log and deletion: `tc-asa3yr-exec-2945036769.stderr`; 22 payload hashes/controller absence: `tc-asa3yr-exec-643070322.stderr`.
|
||||
- New inventories/plan/losses/target process records/closure/source equivalence: `evidence/native-1373/`.
|
||||
|
||||
Prior builds remain mail **1600**, cred **1601**, cron **1602**, rooms **1603**; no plugin source changed. Their remote-less repos have no origin (fetch correctly rejected); merging shared local main was already-current for all four. Host fetch/merge was already current before work; final commit/fetch/merge is reported separately.
|
||||
|
||||
Failed approaches were corrected, not blockers: one incorrectly transcribed grant was rejected before any SSH command; the exact attached grant succeeded. zsh wildcard expansion and missing PATH tools were replaced by explicit installed Nix paths. `core.ps` does not exist; the native gate switched to the actual gateway `/fs/proc` projection and passed. Nix store logs were unavailable, so rebuilt the runtime check with visible `-L` output. One evidence-copy Path/string expression failed; corrected and resumed without re-executing migration. Full failure logs remain under /logs.
|
||||
|
||||
## Limits, not silently claimed complete
|
||||
|
||||
No new production-latency guarantee is claimed. Prior post-record real-core live-size samples remain: final manager acknowledgement **0.6078 s**, registration catch-up **0.0128 s**, mail.send-to-default publication **0.5376 s** (integrated **0.5235 s**), below callers' existing **3 s / 10 s** budgets. The integrated catch-up outlier **12.0419 s** remains disclosed. Orchestrator idle whole-host CPU was ~**0.10%** in the paused fixture; prior real cron warning/stop took **305.427 s**, and detached error data passed. These measurements and their synthetic/warm-cache limits are in implementation.md.
|
||||
|
||||
Live shutdown/frozen backup/push/activation/release/publication, physical browser display, power loss and provider cancellation/billing remain unverified/out of scope. Old unrecorded runs are not automatically inferred complete/resumed. Independent per-plugin recovery follow-ups remain independent; the design accepts first-update cuts until they land.
|
||||
@@ -1,56 +0,0 @@
|
||||
# SAU-384 review rework — run 1381
|
||||
|
||||
**Ready for review, not live execution.** This changes only local documentation and disposable one-off tooling. No shipped host/plugin behavior, live boot/SDK/launcher, activation, restart, publication, release or push changed. Surma's go still precedes the mediator's live operation and every push.
|
||||
|
||||
## Review findings addressed
|
||||
|
||||
1. **No mediator needed during downtime.** Replaced the disconnected procedures with one external CLI, `driver.py start → arm → detached run → verify`. `start` acknowledges a new OS session and blocks it on an arm FIFO. After arm the detached driver owns native freeze, immutable/resumable full backup, planning/frozen audit, offline apply, selectors, `started`, launch and readiness. It uses the exact pinned Herdr pane/shell, then publishes a **new explicit default verification message**, with a stable UUID, because the cut old mediator run is not recoverable. The last real-Core/Herdr test killed the invoking shell shim, then killed the detached controller at frozen, resumed it, and reached target in the same shell. Actual target run-start and mailbox-consumed records named that new verification message once. A real live SSH launch was not performed; the runbook makes the two finite foreground SSH launch/arm commands, and the required explicit go for that exception to SSH's background-work policy, part of the proposed operator plan.
|
||||
2. **Actual frozen-state gates.** `Kit.plan`, apply, starting and pre-start rollback require the real `freeze.json`, phase frozen, current boot ID, matching world, dead pinned owners, empty old group, no subsequently running host, and unchanged checkpoint bytes. A caller-supplied stopped literal is gone. Captured/wrong-boot checkpoints, running owner, changed config/SQL projection and after-start resume refuse before mutation. Offline retries accept only their saved before/after images.
|
||||
3. **Persistent installation pins.** Driver registers/verifies GC roots for exact old/target/loop packages and creates old/target selector and owner artifacts before freeze, so pre-start recovery already has the explicit old owner. Both wrappers export their own pinned selector. Cold restart in the same Herdr shell, without ambient SAUSAGE_FLAKE, ran the exact target; the explicit old wrapper ran the exact old package on a separate old-backup clone, never over new target work. Runbook prohibits bare loop/remote-default starts and explains that core.restart restarts the pin, not self-updates. Future host updates need an approved package/root/selector change; roots are not removed with migration scripts.
|
||||
4. **Real PTY job control.** Used actual Herdr 0.9.0 and an interactive zsh job-control shell, not setsid+piped stdin alone. Loop was foreground process-group leader in the shell's session. Freeze sends TERM then CONT before pidfd wait. Separate injection left the real host in observed **T** state after outer-owner exit; TERM+CONT completed shutdown with no surviving group member. This exercises the stopped-state hazard; it does not claim every timing of naturally delivered SIGTTIN.
|
||||
5. **Planning, SQLite and scale.** Incomplete backup resumes the same inventory/backup without cleanup or replacement. Streaming hashlib.file_digest avoids whole-file memory reads; os.walk/copy preserve directories and non-followed symlinks (including broken links). Complete DB sidecar families are backed up physically, recovered only in disposable copies, staged with SQLite backup in DELETE mode, and old sidecars removed before main-image replacement. Committed WAL and hot uncommitted rollback-journal fixtures passed interruption/rerun, exact losses, preserved job IDs/calendar/outcomes/generic JSON/unknown mail, and exact pre-start rollback. A full logical-size/count/bucket/directory-matched **synthetic** replica measured the whole-world backup path below; it was not an exact live-byte snapshot.
|
||||
|
||||
## Evidence and exact gates
|
||||
|
||||
Evidence directory: `/docs/symphony/artifacts/SAU-384/evidence/rework-1381/`. It contains only JSON manifests/inventories/checkpoints/results, not migration executables. `sandbox-operator-manifest.json` records the tested invocation; **do not execute it live**. `live-manifest.template.json` deliberately requires fresh identities, typed caller review/guards and a new stable continuation UUID before approval. Frozen guard tests used the real cron table projection; the live table/file set must include the board/config/registration review described in the runbook.
|
||||
|
||||
All logs below are `/logs/box/2026-10-31/`:
|
||||
|
||||
| Gate | Full stderr log / result |
|
||||
|---|---|
|
||||
| Final caller-killed + controller-killed/resumed + interrupted planning + same-Herdr-pane target chain | `tc-asa3yr-exec-775822946.stderr` — pass. 4,436 frozen regular files / 627,157,113 bytes in this session-size integration fixture; backup/hash stage 4.617 s. Earlier final chains also passed (`…-2599916298`, `…-2393656781`, `…-2876439273`). |
|
||||
| Final process ownership/locked cred/new explicit verifier/preservation; stopped-PTY CONT; cold pinned target; explicit old package on old clone | `tc-asa3yr-exec-3942858286.stderr` — pass. `post-target-processes.json`, `post-preservation.json`, `post-continuation-records.json`, `post-pty-stop-continue.json`; only planned paths and enumerated new activity changed. |
|
||||
| WAL + hot journal + interruption + exact rollback, repeated | `tc-asa3yr-exec-1595763713.stderr` — pass; first pass `…-1628589419`. `db-sidecars.json`. |
|
||||
| Whole-world scale | `tc-asa3yr-exec-3620999999.stderr` — **138,444 files, 12,441,162,164 bytes, 45,310 directories**. Inventory **70.555 s**, copy plus double hash verification **135.385 s**, combined **205.940 s**. Exact functions used by Kit.plan; synthetic bytes/names, observed live size buckets/counts, warm-cache box. No cold-storage downtime SLA. |
|
||||
| Current source equivalence + cargo test --workspace | `tc-asa3yr-exec-430706202.stderr` — pass; 121 Rust/Cargo inputs plus root Cargo/flake inputs matched current card tree. **208 host integration tests**, lines **64–431**, plus all workspace suites/doc tests. No Rust host source changed. The final cred main refresh/rebuild and repeated gates are below. |
|
||||
| Negative config/frozen caller controls, selected bundle rehash, verify and deletion | `tc-asa3yr-exec-3977808892.stderr` — pass; `negative-controls.json`, `bundle-recheck.json`, `tool-deletion.json`, `executed-test-deletion.json`. |
|
||||
| Source-as-log reconstruction/hash/AST check; no scripts/bytecode recreated | `tc-asa3yr-exec-3884064136.stderr` — pass; `source-log-roundtrip.json`. |
|
||||
|
||||
### Reconstructable, but deleted after success
|
||||
|
||||
The executed tool was three small files, not a plugin or host patch. Exact source survives **as log only** in `tc-asa3yr-exec-3977808892.stderr`:
|
||||
|
||||
- `cutover_once.py`: lines **7–152**, SHA-256 `202eb7e635d675bb3960317913ae154e4f699fb7f5fe25a53ef34c965d0869c5`;
|
||||
- `freeze.py`: lines **154–226**, SHA-256 `d83d3d4035e5f59e219cff949be3455718a756a9c2ed4442119d4883a72fedfe`;
|
||||
- `driver.py`: lines **228–393**, SHA-256 `7a26cfba9873541404f9661d5b0117f566301d408448cbb509a965d7900037aa`.
|
||||
|
||||
The verify CLI was actually executed, marked the rehearsal verified, logged/deleted these files, and removed caches. All executed test controllers were also logged/deleted; the named disposable Herdr server was stopped. Hash reconstruction was checked **in memory**, without recreating executable files. The live runbook mandates the same deletion. Permanent owner/selector artifacts are installation artifacts, represented as bytes/hashes in `permanent-launcher-artifacts.json`, not retained migration code.
|
||||
|
||||
## Final main refresh and selected builds
|
||||
|
||||
The final required merge found cred main advanced to approved **SAU-418, `0db401012cae99b4424670d5c07a346281e30e75`** (plain anyone-may-use switch/shared SSH metadata and locked no-candidate fetch behavior). Inspected that diff and fast-forwarded the card worktree without discarding either side. No storage-schema or handover lifecycle delta was introduced. Rebuilt current cred: **1636 / 274 tests passed**, `/state/cargo/builds/1636/cred.wasm`; full output `/logs/cargo/2026-10-31/01a19281-a8ed-7127-914a-3ba4b5c15d17.{stdout,stderr}.log`.
|
||||
|
||||
Final selection is now **`/docs/symphony/artifacts/SAU-384/cutover-targets-1381.tar.gz`**, **24,590,972 bytes**, SHA-256 **`9295c5fddbf5d68cbfd525602ec67e7dd895bf6afb99324191a6c514282eac57`**. Only the cred payload/provenance changed; all other 21 payloads, native host and SDK selection remain as reviewed. The original bundle remains immutable prior evidence. Current `evidence/manifest.json` and `evidence/rework-1381/main-refresh/selection.json` pin the final bundle. Host remains `/nix/store/spyd9rix6r38m6vxfppyy9z83yp5dh85-sausage-0.1.0`; SDK-copy **src/lib.rs only**. Mail/cron/rooms remain **1600/1602/1603**; approved web **3d760267** stays held until mediator/orchestrator release. No plugin implementation edits by this worker.
|
||||
|
||||
Reconstructed the same three tool sources from their hash-checked log solely for this required new-main rerun, then deleted them again after verified success. Repeated caller/controller death/resume and actual Herdr cutover with the new cred bundle passed (`tc-asa3yr-exec-629237259.stderr`, same log directory). The first preservation assertion additionally encountered normal core-journal WAL/SHM activity; explicitly classified those journal sidecars as activity rather than migration loss. Final same-pane ownership/locked cred/fresh verifier/preservation/stopped-PTY/cold target/old-clone gates, negative controls and full `cargo test --workspace` all passed **`tc-asa3yr-exec-4223877022.stderr`** (208 host integration tests). Sidecar/rollback tests also repeated successfully. Final verify/source-as-log/deletion proof: **`tc-asa3yr-exec-741401146.stderr`**, **`evidence/rework-1381/main-refresh/{tool-deletion,executed-test-deletion}.json`**. Tool source hashes are unchanged from the reconstruction list above; no executable/cache remains. Final live template and sandbox manifests are under that `main-refresh/` evidence directory. Earlier replay/race/cron/watch/scale gates remain cumulative evidence; they were not silently relabeled as freshly rerun.
|
||||
|
||||
## Failures, corrections and limits
|
||||
|
||||
- Whole-tree transfer through box.put_tree rejected the 64 MiB service cap for repos/plugins/logs. Next method: read-only host size histogram, then regular-file size-bucket replica and the exact backup routines. This measures whole-world **scale**, not original live contents/path topology. The original accepted session byte snapshot still supplies the real-history integration volume.
|
||||
- Initial PTY fixtures lacked state/core for console history, then matched an http-prefixed address against a bare address. Corrected fixture and barrier. Typeahead from two early console commands lost the second; final driver queues **one** subscription command. An early Herdr multiline output matcher stalled; inspection showed Herdr wait-output itself polls, so final code removed it entirely in favor of inotify completion. The actual registration path is `state/session/manager/registrations/subscriptions.json`, now corrected.
|
||||
- **Shared-store foot gun:** an early default-store root query pruned **1,144 stale auto-root links and 571 stale temporary-root files** in shared Nix state. This was not an authorized live-config operation and no GC/package deletion was requested. Immediately stopped that method. Read-only SSH inspection on Citadel found **none** of those referenced targets and **none** of those PID numbers existing on the host, so no active roots required restoration. All subsequent root tests used a **private local Nix state database**, loaded from read-only closure metadata; no shared root query was repeated. Exact evidence/log: `gc-root-incident.json`, `tc-asa3yr-exec-2216542428.stderr`. This incidental stale-registration cleanup is disclosed rather than claiming literally zero host-state writes.
|
||||
- One post-start assertion read history before the new verification message was consumed. Replaced it with arm-then-scan/history-write completion. In the final gate publication→run-start was **23.753 s**, →consumption **24.530 s**, queued behind startup reconciliation. This is **not** mail.send publication latency. Initial acknowledgement is not full scan completion.
|
||||
|
||||
Prior accepted replay/race/rollback/deadline/grace/crash-supervision/event-driven orchestrator evidence remains unchanged. Post-record manager ack **0.6078 s**, catch-up **0.0128 s**, mail publication **0.5376 s** / integrated **0.5235 s** remain below existing **3 s / 10 s** publication budgets; integrated catch-up outlier **12.0419 s**, ~0.10% idle duty, detached error propagation and **305.427 s** real cron warning/stop remain disclosed in implementation.md. This rework does not replace those measurements with a claim that the initial scan is fast.
|
||||
|
||||
Still unverified/out of scope: actual approved live cut-over/frozen backup and complete production caller guards, live SSH detach/Herdr w7:p6 execution, orchestrator.release/publication/physical browser, cold-storage timing, power loss, provider cancellation/billing and independent recovery follow-ups. The live launch exception needs explicit Surma go as part of the proposed plan; no such approval is claimed or requested by executing anything here.
|
||||
@@ -1,53 +0,0 @@
|
||||
# SAU-384 round 3 — run 1389
|
||||
|
||||
Ready for review; **not authorization for live execution**. Addresses `/docs/symphony/artifacts/SAU-384/extra-round-3.md` and review 1388. Only documentation and disposable tooling changed; no transition code in any shipped plugin/host.
|
||||
|
||||
## Reviewed selection and proposed plan
|
||||
|
||||
- Selected bundle: `cutover-targets-1389.tar.gz`, **27,271,968 bytes**, SHA-256 **31dbb95b796c1fedfd8c37a8b2354cb0977face8653345dc026cc1bbdefac18b**. Embedded manifest SHA-256 **b48f2dacda3f348a67574767c6accfae1f1102227f94f86868ec1436bd4df394**; all **23** payload sizes/hashes checked. Prior bundles are immutable historical evidence.
|
||||
- New deferred payload: approved git **9cc9fa6f1c9f01ce1a53d8042623bde60e026637**, `/state/orchestrator/builds/git/9cc9fa6f1c9f01ce1a53d8042623bde60e026637.wasm` (177 tests, not rebuilt here). Staged world copy: `/docs/symphony/artifacts/SAU-384/git-9cc9fa6f.wasm`. Both manifest `deferred_plugins` and operator `deferred_artifacts` are hash-checked before freeze. **Not installed offline**: mediator activates it only after target cred is live and Surma unlocks through the UI. The runbook uses the real `core.activate` **wasm** argument and includes the activation check before web release/final deletion.
|
||||
- Cred remains approved **0db40101**, build **1636 / 274 tests**. Mail/cron/rooms remain **1600/1602/1603**. No new plugin build/source edit this run. Native host remains `/nix/store/spyd9rix6r38m6vxfppyy9z83yp5dh85-sausage-0.1.0`, source e09c4f9f; old host mggvibk8 and loop pz2b94a2 remain pinned. SDK-copy list: **src/lib.rs only**. Web remains approved **3d760267**, release held until session/web/verification prerequisites.
|
||||
- Exact external commands, operator/approval boundaries, checkpoint archive/retry and rollback rules: [runbook.md](runbook.md). Live manifest template: `evidence/round3-1389/live-manifest.template.json`. Resolve fresh identities/caller guards before go; sandbox manifests are evidence, not live instructions.
|
||||
- **Expected live Nix side effect requiring inclusion in Surma's go:** three `nix-store --query --roots` calls, for old host, target host and loop, may prune stale auto-root and temporary-root registrations. They do not delete store packages. Five root registrations (those three plus controller/Herdr) remain in the proposed operation. This round used only the **private rehearsal Nix DB**; no shared/live roots query was repeated. Prior incident remains disclosed in rework-1381.md.
|
||||
|
||||
## Six findings fixed
|
||||
|
||||
1. **Git and interim compatibility.** Actual live old WASM (reported source f089b56d; SHA-256 4b460bce9b67552e223bb209591009056d409b77290182f6cf142adc8c4c97f4) ran against real new cred in a separate synthetic world. Explicit-grant SSH clone authenticated with a real loopback sshd, recorded exactly one sign use and fetched the synthetic commit. Locked use returned `vault_locked`. Old grant-free shared SSH still fails requesting a local key; old grant-free HTTPS retains its old config/anonymous behavior, not the new sharing switch (source checked). Old granted HTTPS reached/was accepted by new cred and reserved one use, then the deliberately closed loopback destination returned `network_error` plus capture; **successful HTTPS transfer is not claimed**. After actual synthetic unlock and new-git activation, shared grant-free SSH clone succeeded. Do not schedule Git network work in the locked/old-git interval, introduce key/config-secret workarounds, or blindly retry an already-started caller's errors. Local status/commit/main merges are unaffected. Live secret-backed operations/pushes remain untested and separately approval-gated.
|
||||
2. **Pre-start abort and human operator.** `check`/start, arm and immediate pre-freeze gates inspect consistent live SQLite backup-API copies, special file types, caller/file/artifact guards and free space. Space budget derives from current regular-file logical bytes plus the actual staged plugin/prev/SDK and recovery/stage/audit DB copies; no arbitrary multiplier/headroom/default. Growth or later ENOSPC is still possible. Driver now has a real `abort` CLI: exclusively lock, finish freeze if begun, restore only approved before/after paths from the complete immutable backup, verify full frozen inventory, then checkpoint-before-launch the explicit old owner in the same pane. No completed plan means apply never began: do not restore an incomplete backup. Restore is resumable; after `old_start_attempted` inspect instead of launching twice. Surma is the named external operator, with independent terminal log monitoring, `failure.json`, and best-effort Herdr notification (submission result retained; physical display not claimed). Runbook gives exact resume/abort and evidence-archive/re-resolution commands. After target `started`, automatic resume/restore/relaunch stays forbidden. Missing registration now exits on one absolute **120 s** deadline from cron/src/types.rs's existing registration/publication budget and the earlier real-core fixture bound; Herdr/RPC calls are finite too. No polling for quiescence or availability.
|
||||
3. **Executable-only SQL guards.** Cron projects id/method/params_json/cron/at/timezone/detach/call_timeout_secs, not next/last/result/occurrence bookkeeping. Board excludes last_sent/ended and batch IDs/lines/due; unions active subscription config with pending config snapshots so creating/delivering the same active configuration is not drift. New/changed executable configuration still refuses. Final automatic subscription removal changes the executable set and requires refresh or pre-start abort, explicitly documented. Add any planned mediator cron wake-up **before** resolving/approving operator.json. No caller/boot/plugin/SDK edits between resolution and freeze; arm checks again. No in-progress manifest edits.
|
||||
4. **prev contract.** Every offline-swapped plugin's prev is now its exact **frozen pre-cut current**, under before/after guards and restore. Tests checked all nine, including paths where an older prev existed. Git waits for normal later activation/prev behavior. This makes the fallback image meaningful, **not** a license for blind incompatible individual `core.revert`; coordinated rollback and unsubscribe-before-session-revert remain mandatory.
|
||||
5. **Roots side effect.** Listed above and in the live runbook/approval plan, rather than describing roots queries as mutation-free. Subsequent tests stayed private.
|
||||
6. **Arm race.** Replaced reader-dependent FIFO with atomic, manifest-bound **arm.json** and an arm-then-scan inotify watch. Idempotent arm returns with no reader. `resume` accepts waiting_for_arm; a kill after marker observation but before saving armed reuses the same marker. No manual FIFO writer/cleanup is needed.
|
||||
|
||||
## Executed gates and full logs
|
||||
|
||||
Prefix: `/logs/box/2026-10-31/tc-asa3yr-exec-`.
|
||||
|
||||
| Gate | Log / outcome |
|
||||
|---|---|
|
||||
| Final exact tool, caller killed + controller killed/resumed, incomplete backup resume, real Herdr job-control stop/swap/start, same shell, post-start preservation/new verifier, prev hashes, pinned cold restart and old clone | **3699755631.stderr**, pass. Final backup 4,436 files / 627,157,113 bytes, 4.559 s (session-size integration, not full-world downtime). No old mail owner; one manager; cred locked. |
|
||||
| Live special-file/zero-space negative, mutable cron vs changed payload, pre-arm drift, frozen SQL mismatch -> real-driver abort/old relaunch, consumed-arm/save kill/resume, post-start abort refusal | **1485773773.stderr**, pass. Zero-space gate used a test-only mocked disk-usage response; actual ENOSPC tested below. |
|
||||
| Real-driver partial swap -> abort/restore/old relaunch; actual backup ENOSPC on a private tmpfs introduced after good preflight -> untouched-old relaunch; lost console subscription -> bounded clean exit | **4142682240.stderr**, pass. Registration wait 120 s; elapsed 132.493 s including backup/launch. Started checkpoint refused both resume and abort. |
|
||||
| Real old/new Git + new cred, loopback synthetic OpenSSH/HTTP negative, unlock then deferred activation | **99781693.stderr**, pass. No live key/grant/remote used. |
|
||||
| SQLite WAL + genuinely hot rollback journal, exact losses, preserved job IDs/calendar/outcomes/generic JSON/uncertain mail, apply interruption/rerun and exact physical restore | **1727732043.stderr**, pass. |
|
||||
| Board executable projections and old-select-star negative control; template/deferred-artifact checks; final driver's actual Herdr failure notification + abort | **1276236846.stderr**, pass. |
|
||||
| `cargo test --workspace` against unchanged production source | **2259546796.stderr**, pass: **208 host integration tests, lines 62–431**, plus all workspace suites/doc tests. 133 Rust/Cargo/flake inputs matched card tree. No Rust implementation change. |
|
||||
| Executed verify mode, complete source-as-log and deletion | **2013747101.stderr**, pass. See hashes/ranges below. |
|
||||
|
||||
Prior accepted race/replay/deadline/core-watch/upgrade/idle/grace/crash-supervision evidence is cumulative, not silently rerun or claimed fixed here. Prior live-size scale sample remains **138,444 files / 12,441,162,164 bytes / 45,310 directories**, synthetic size-bucket match, 205.940 s inventory+copy/hash, warm cache. Post-record manager ack 0.6078 s, registration catch-up 0.0128 s, mail publication 0.5376 s (integrated 0.5235 s) remain under caller publication budgets **3 s orchestrator / 10 s cred**; catch-up outlier 12.0419 s and initial-verifier startup scan delay 23.753/24.530 s remain disclosed. No production downtime/latency guarantee.
|
||||
|
||||
## Deleted executable and reconstruction receipt
|
||||
|
||||
Only source-as-log remains in **2013747101.stderr**. Reconstruct exactly three files for the later approved operation, hash-check before use; do not reconstruct test controllers:
|
||||
|
||||
- cutover_once.py: lines **2–164**, SHA-256 **3adf65ebb0b3401a0d1976524f35f7450e907ad32f907dcbb1cd2f7e0a7e5d3a**;
|
||||
- freeze.py: lines **166–238**, SHA-256 **d83d3d4035e5f59e219cff949be3455718a756a9c2ed4442119d4883a72fedfe**;
|
||||
- driver.py: lines **240–503**, SHA-256 **686167318c84a32c1010daf4517f036bcb654384cadab987fa47fb13deecd2bf**.
|
||||
|
||||
`verify` actually logged/deleted the executable after successful rehearsal. Executed test/patch/controllers and test launchers were logged/deleted too, with bytecode caches removed. `tool-deletion.json` and `executed-test-deletion.json` are under `evidence/round3-1389/`. Permanent pinned owner/selector configuration is not migration code; it stays rooted through the recovery window. The live runbook still requires mediator deletion after verified success.
|
||||
|
||||
## Failures and limits
|
||||
|
||||
Tried next methods, not blockers: initial generated SQL fixture had quoting syntax wrong (corrected); generated lockfiles made whole-directory diff reject (compared actual Rust/Cargo/flake input bytes, then ran full suite); final-sidecar extraction included a trailing log status (trimmed); a post-check initially read the previous round's stopped-world manifest (corrected to this round, then passed). Synthetic SSH setup needed explicit typed key metadata, a sandbox-only sshd account and an unlocked sandbox login account; default root was locked. A failed fixture left two loopback sshd listeners, later explicitly identified/stopped. Initial sandbox activation used `path`; core required `wasm`, corrected and the complete final Git gate passed. No failed test was relabeled successful; full errors remain in logs.
|
||||
|
||||
Still unverified/out of scope: approved live cut-over/frozen caller snapshot, actual live SSH-detach/pane w7:p6 execution, Surma's real unlock/new-git live networking, physical Herdr notification/browser display, release/publication, cold-storage timing, power loss, provider cancellation/billing and independent per-plugin recovery follow-ups. No live activation/restart/config/owner change, push, release, deploy or /www publication this run. Surma's go is still required before mediator core/session/cred operation, detached SSH exception, roots side effects and **every push**.
|
||||
@@ -1,60 +0,0 @@
|
||||
# SAU-384 round 4 — run 1392
|
||||
|
||||
Ready for review; **not live execution approval**. Addresses all five findings in extra-round-4.md. No shipped Rust/plugin behavior changed. Current-main source and state contracts were rechecked; all work was in the card tree, review artifacts and disposable box tc-zg0j56.
|
||||
|
||||
## Selection and operator plan
|
||||
|
||||
The selected bundle is unchanged: `cutover-targets-1389.tar.gz`, **31dbb95b796c1fedfd8c37a8b2354cb0977face8653345dc026cc1bbdefac18b**, 23 payloads. All payload sizes/hashes and the native closure hashes were freshly checked. Host remains `/nix/store/spyd9rix6r38m6vxfppyy9z83yp5dh85-sausage-0.1.0` from e09c4f9f. Cred remains 0db40101 / build 1636 (274 tests); mail/cron/rooms remain 1600/1602/1603. Deferred git remains approved 9cc9fa6f, activated only after target cred is live and Surma unlocks. No new WASM/package build. SDK-copy list: **src/lib.rs only**. Approved web 3d760267 remains held until the operator releases SAU-375 after session/web and verification prerequisites.
|
||||
|
||||
Exact commands and approval boundaries are in [runbook.md](runbook.md). Current non-executable operator template: `evidence/round4-1392/live-manifest.template.json`. It still requires fresh caller/owner identities, guards and explicit Surma go. The only executable source is reconstructable **as log**, not retained as a tool. Surma is the external human recovery operator. The old mediator is deliberately cut; the driver publishes a new explicit default verification input, not inferred recovery.
|
||||
|
||||
## Five findings fixed
|
||||
|
||||
1. **Shared-filesystem ENOSPC.** Space preflight measures POSIX allocated blocks for files/directories/root/non-followed links, and charges dense filesystem-fragment-rounded size where copy2 would expand a sparse file. It includes the concrete staging/prev/SDK/DB-copy roles; receipts save all measurements. Removed the false never-underestimates claim: metadata/normalization/growth/competing writers remain uncertainty. A separate sparse/tiny/directory fixture copied **4,599,808 allocated bytes** from **12,288 source-allocated bytes**, below its measured **4,603,904-byte** budget. No live margin/default was invented.
|
||||
|
||||
Mandatory foreground `driver.py monitor` receives memory-only Unix datagrams for inventory/backup progress and failure, and kernel pidfd controller-exit completion. Every start/resume waits for its own monitor; arm requires it. Failure JSON/log writes are best effort and cannot prevent CUTOVER FAILED or the independent Herdr notification attempt. A terminal still reports controller death on SIGKILL; no heartbeat/polling loop. Notification submission was tested, but returned shown:false/disabled; physical display is not claimed.
|
||||
|
||||
Actual ENOSPC used **one shared tmpfs for world, external root and driver.log**, device 83, **0 free bytes**, capacity 254,504,960 bytes derived from measured fixture/budget allocations. It failed during a real backup write after good preflight. Monitor showed failure and death despite unwritable disk evidence. Pre-plan abort removes only the reproducible incomplete backup subtree before any checkpoint write, retaining planning/inventory evidence. If the competing writer still fills the disk, it refuses before saving/launching. After that known test file was freed, the same abort restored coherent old ownership and readiness. Complete backups are never deleted. Every abort (with or without a completed plan) now checks space before its first checkpoint save; a second shared-filesystem zero-free-byte fixture proved the complete backup/checkpoint remained exact, then old relaunch worked after freeing only the known competing test file. Space is checked again before old relaunch. This is not proof against every filesystem's ENOSPC/power-loss behavior.
|
||||
2. **Post-start forward completion.** New `continue operator.json` locks, checks the bound manifest, exact pane/shell, exactly one target host and target plugin hashes, and performs only missing/read-retry-safe post-start RPCs. It never launches/applies/restores. Actual locked cred.status is saved durably before unlock; subscribe uses the stable cutover registration; send reuses the exact continuation UUID/text. It reaches awaiting_verification, then normal verify/delete is reachable. Real failures of **cred.status, subscribe, and send**, including send committed before its acknowledgement was lost, all recovered with one run-start/consumption. A real **120 s** absolute registration timeout recovered the same way without another owner. The value remains cron/src/types.rs's existing registration/publication bound. Runbook gives exact continue command, RPC payload derivation, locked-proof/unlock ordering and local Surma fallback if live SSH signing is unavailable.
|
||||
3. **Atomic temporary cleanup.** A child was SIGKILLed inside atomic after writing a real partial plugin replacement temporary, before rename. Abort removes only a saved operation's newly created .cutover-tmp, absent in frozen inventory and prefix-matching an approved before/after image; unknown bytes/links refuse. Full frozen inventory equality passed inside rollback before old launch. Resume still overwrites/completes its own temporary. No arbitrary world cleanup.
|
||||
4. **Converter pre-freeze dry run.** check/start, arm and immediate pre-freeze checks use the same pure cron converter as plan, on SQLite backup-API memory copies. interrupt:true, invalid text and non-object params refuse without freeze/mutation. Negative control: round-3 checker accepted the same invalid object payload, new checker refused. No compatibility code in a plugin.
|
||||
5. **Roots/wake-up documentation.** Every controller resume repeats all three roots queries and their possible stale-registration pruning; this remains part of the proposed Surma-approved live plan. This round queried roots only against a private rehearsal Nix DB. No cron wake-up is needed: the stable explicit driver continuation owns handoff. A due-during-downtime job is skipped; a just-before-freeze input can enter an old pending queue that is intentionally dropped. Neither is replayed. The runbook disallows relying on guessed backup duration as readiness.
|
||||
|
||||
Testing also exposed a real **Nix Python wrapper exec identity race** in parent-side process capture. The launcher now receives the child's actual post-wrapper identity over a pipe-completion handshake. Controller kill/resume proved exact identity, a fresh monitor requirement and successful same-pane continuation. No liveness/endpoint polling was added.
|
||||
|
||||
## Full logs and evidence
|
||||
|
||||
All log names below have prefix `/logs/box/2026-10-31/tc-zg0j56-exec-`. Evidence is `evidence/round4-1392/`.
|
||||
|
||||
| Gate | Full stderr / outcome |
|
||||
|---|---|
|
||||
| Final shared world/root/log ENOSPC, pre-save incomplete-backup deletion, low-space refusal, old readiness; partial atomic SIGKILL/abort; all three post-start RPC failures; real registration timeout | **166388571.stderr**, clean combined pass on final tool source. Evidence under `final/`, including shared-enospc.json, atomic-abort.json, *-reviewed.json and registration-timeout.json. |
|
||||
| Complete-plan ENOSPC on one shared filesystem: pre-write refusal, checkpoint/full-backup equality, old readiness after known competing-data cleanup | **4094731865.stderr**, pass; `final/complete-plan-enospc.json`. |
|
||||
| Controller wrapper handshake, killed-controller pidfd, resume waits for fresh monitor, target completion | **537908850.stderr**, pass; `resume-handshake.json`, both monitor logs. |
|
||||
| Converter pre-freeze refusals | **1954198187.stderr**, pass; `dryrun.json`. |
|
||||
| Negative control + sparse allocation + all payload/native closure hashes + **133** final source/build inputs | **1314968883.stderr**, pass; `converter-negative-control.json`, `allocated-space.json`, `source-and-bundle.json`. |
|
||||
| Committed WAL/hot rollback journal; exact pending/cancel/mail losses; preserved mail/jobs/IDs/calendar/outcomes/non-target JSON; apply interruption/rerun and physical rollback | **2622539348.stderr**, pass; `sqlite-sidecars.json`. |
|
||||
| cargo test --workspace, unchanged final production source | **3315145279.stderr**, pass; **208 host integration tests**, result at line **433**, plus all workspace suites/doc tests. |
|
||||
| Fresh target one-manager/locked-cred/original-byte preservation/pre-cut-prev verification; actual verify CLI and complete tool/test deletion | **3030415406.stderr**, pass; `final/` final-ownership-preservation.json, verification.json, final-verified-driver.json, tool-deletion.json, test-deletion.json. Earlier verify/deletion pass: 1403220230.stderr. |
|
||||
| In-memory source-log SHA/AST reconstruction; executables/bytecode still absent | **2019074395.stderr**, pass; `final/source-log-roundtrip.json`. Earlier docs/JSON/absence checks: **2041020851.stderr**; final docs/JSON/absence/23-payload/133-source-input validation: **1011452644.stderr**, `final/final-validation.json`. |
|
||||
|
||||
### Executed source deleted after verified success
|
||||
|
||||
Reconstruct only these three files from **3030415406.stderr** for a later approved live operation; do not reconstruct tests:
|
||||
|
||||
- cutover_once.py: source lines **2–189**, SHA-256 **84b13644bd80c6b331dcd8df9bf79339f62fedb99f7dd376535b2bca7b0afd96**;
|
||||
- freeze.py: source lines **192–263**, SHA-256 **d83d3d4035e5f59e219cff949be3455718a756a9c2ed4442119d4883a72fedfe**;
|
||||
- driver.py: source lines **266–669**, SHA-256 **5c3cba1a69b8b5e8f82ea83caa0697af494bf93dd0297f0c1b5231192cbb5fad**.
|
||||
|
||||
After generalizing the no-write space gate to complete-plan and untouched abort too, the source was reconstructed for the final affected rerun, then the real verify command again marked that rehearsal verified, logged/deleted the three files and caches; all executed/reconstructed tests/patches/wrapper were logged/deleted too. Permanent owner/selector installation artifacts are distinct, not migration code. Live runbook still mandates deletion after verified success.
|
||||
|
||||
## Failed approaches and limits
|
||||
|
||||
- First interpreter alias linked only bin/, losing Nix Python's site-package context. Linked the complete existing interpreter environment; imports and gates then passed.
|
||||
- Cold workspace cache made six offline guest builds miss async-task; populated the disposable cache and reran the **whole workspace**, 208/208 host tests, unchanged source. Initial full log **2236066973.stderr**, missing dependency at lines 755–785; final pass above. No retry-without-diagnosis claim.
|
||||
- First shared filesystem test refused **before freeze** because actual source growth exceeded its provisioned measurement. Recorded that valid negative control, then provisioned the test from measured fixture/budget roles before inducing deliberate post-preflight pressure. A later test filled before the backup had copied any data: abort correctly needed a no-write space check before its first save; fixed and repeated with a before-backup event gate. Final shared test reached zero free bytes during copy and the final combined gate passed cleanly. Earlier fixture checkpoint-name collisions, an existing evidence-export directory and a busy mount affected cleanup/export only; used fresh identities/paths and explicit disposable-pane cleanup, never edited migration checkpoints. Mounts were left for disposable-box teardown, not live cleanup.
|
||||
- First post-start fixture omitted session prompt files, so it correctly recorded run-end error rather than mailbox consumption. Added synthetic prompts and repeated all three failure modes from fresh worlds; each then produced exactly one consumed continuation.
|
||||
|
||||
This round's failure fixtures are **small synthetic real-core/Herdr worlds**, not another live-size benchmark. Prior accepted live-size race/replay/core-watch/upgrade/deadline/idle/grace/crash-supervision, scale and Git compatibility/unlocked activation evidence remain cumulative. Startup 0.6078 s, catch-up 0.0128 s, mail publication 0.5376 s vs callers' 3 s/10 s limits, catch-up outlier 12.0419 s and initial-scan delay remain disclosed in earlier reports; not remeasured or upgraded to guarantees here.
|
||||
|
||||
Unverified/out of scope: actual approved live cut-over/frozen caller audit, live detached SSH/Herdr w7:p6 execution, live SSH signing through target cred, Surma's real unlock/new-git external networking, physical notifications/browser, release/publication, cold-storage timing, power loss, provider cancellation/billing and independent per-plugin recovery follow-ups. Surma's external terminal has exact continue/verify commands, so final completion is not dependent on the untested live signer. No push, live activation/restart/config/owner change, release, deployment or /www publication. Surma go still precedes mediator core/session/cred operation, the proposed detached-SSH exception/root side effects, and **every push**.
|
||||
@@ -1,234 +0,0 @@
|
||||
# One-off handover runbook — rehearsed; live approval required
|
||||
|
||||
Authority: [design / Rollout](/docs/design/plugin-handover/design.md), [SAU-356 gates](/docs/symphony/artifacts/SAU-356/plan.md). Results: [initial](implementation.md), [races/rollback](resume-1369.md), [native package](resume-1373.md), [external driver rework](rework-1381.md), [round-3 recovery/git gates](rework-1389.md), [round-4 failure paths](rework-1392.md). This is a proposed live plan, **not authorization**. Workers never execute it. Mediator/orchestrator lands and executes only after Surma's go; every push separately needs a fresh go.
|
||||
|
||||
## 0. Prepare everything while the old world is still available
|
||||
|
||||
Do not begin downtime with scripts, caller decisions or launch instructions still to be assembled.
|
||||
|
||||
### Reviewed selection
|
||||
|
||||
- Bundle: `/docs/symphony/artifacts/SAU-384/cutover-targets-1389.tar.gz`, SHA-256 `31dbb95b796c1fedfd8c37a8b2354cb0977face8653345dc026cc1bbdefac18b`. It includes final-main cred `0db40101`, build **1636 / 274 tests**, preserving approved SAU-418 instead of downgrading it. Git **9cc9fa6f1c9f01ce1a53d8042623bde60e026637**, approved SAU-418 artifact `/state/orchestrator/builds/git/9cc9fa6f1c9f01ce1a53d8042623bde60e026637.wasm` (177 tests), is included as **deferred_plugins/deferred_artifacts**, not an offline replacement. It activates only after target cred is live AND Surma unlocks it in the UI. Earlier bundles are immutable historical evidence, not this selection. Verify all **23** payloads (including deferred git) against its embedded `manifest.json`. `target-artifacts.tar.gz` is older debug-host evidence, **not** this installation.
|
||||
- Native host: `/nix/store/spyd9rix6r38m6vxfppyy9z83yp5dh85-sausage-0.1.0`, aarch64-linux Nix release package from unchanged host source `e09c4f9f`. The embedded `native-host-manifest.json` pins the NAR, binary and complete runtime closure. Import the reviewed NAR if needed; never overwrite immutable store files or substitute a diagnostic host. Import/deployment is an operator action, never a worker action.
|
||||
- Old package for this observed baseline: `/nix/store/mggvibk8lcgv2qw28jimrl60mi0qm41p-sausage-0.1.0`. Re-identify it and the owner before go; observed PIDs are not constants.
|
||||
- Actual owner: Herdr **w7:p6**, tab w7:t5, not a systemd Sausage unit. Its loop is `/nix/store/pz2b94a2v99r3msljdz82h7n3i81hhh5-sausage-loop/bin/sausage-loop /home/surma/world/`. Read `herdr pane process-info --pane w7:p6` once to resolve current shell/loop/host identities. Do not close Herdr or unrelated panes.
|
||||
- SDK copy list is **`src/lib.rs` only**. Preserve world-owned Cargo manifests, relocated `interface`, imports and all other SDK bytes. Resolve a changed baseline before proceeding.
|
||||
- Web is approved commit `3d76026749a5d21722033bbfe8743ac368946b0f` (SAU-375), held by `publish:hold`. Do not release it before target session and web are active.
|
||||
|
||||
### External driver and resolved operator manifest
|
||||
|
||||
Use external root `/home/surma/sau-384-cutover`, outside the world and stopped process group. Stage the extracted bundle under `targets/`. Reconstruct **only** `cutover_once.py`, `freeze.py`, and `driver.py` under `tool/` from the source-as-log headings in `/logs/box/2026-10-31/tc-zg0j56-exec-3030415406.stderr`; check their hashes against `evidence/round4-1392/final/tool-deletion.json`. These are the executed round-4 versions (source lines 2–189, 192–263, 266–669 respectively), not earlier scripts. Hashes/ranges are in evidence/round4-1392/final/source-log-roundtrip.json. They have a real CLI entry point. Do not reconstruct rehearsal/test controllers.
|
||||
|
||||
The tested interpreter is `/nix/store/fixvq6n42pmvy49yh6kfmpax1s3j8md2-python3-3.14.7-env/bin/python3`, with **inotify-simple 2.0.1**. Resolve its closure availability before go. After go and before starting the driver, pin its package and the inspected Herdr package with host-visible GC roots for the operation (`nix-store --realise <exact-package> --add-root <external-root>/gc-controller --indirect`, and equivalently `gc-herdr`). These root writes/imports are included in the approved live plan, not preparation actions authorized to a worker. The driver itself registers and verifies **old host, target host and loop** roots at `<root>/gc-{old,target,loop}` before freeze. Use the **host namespace**, not a box talking to shared Nix state: `/rework` roots in a box are not host-visible. Do not run GC as a test. Keep old/target/loop roots and both selectors for recovery after tool deletion.
|
||||
|
||||
Start from `evidence/round4-1392/live-manifest.template.json`, a **non-executable template**, and resolve every placeholder before approval:
|
||||
|
||||
- native paths, Herdr binary/context, pane and current shell/loop/host PIDs;
|
||||
- external root, world, bundle, embedded manifest SHA, offline artifact paths **and deferred git**, SDK path, exact target boot list; root and world are on the same filesystem in this plan; resolve a different storage topology before go;
|
||||
- complete old in-flight `/proc/*/info.json` and native-owner inventory, plus uncertain external effects; a finished-looking assistant tail is not settlement;
|
||||
- a new continuation UUID, generated **once**, and the explicit new mediator verification instruction. Keep that UUID on all retries;
|
||||
- typed executable-caller review and its frozen guards below.
|
||||
|
||||
Do not copy sandbox PIDs, `gc_store`, or any `test_*` gate into a live manifest. The template deliberately omits those test-only fields. Record/hash the fully resolved `operator.json`; driver checkpoints bind to its exact bytes. Resolve a schema/selection discrepancy while the old world is available, not during downtime.
|
||||
|
||||
### Executable caller review and frozen guards
|
||||
|
||||
Use predecessor formats: session source `f9122585` Paths/queue; cron `ba75c102` and current cron README one-off procedure. Do not substring-rewrite prompts or diagnostics. Review current stores as in `resume-1373.md`, then encode the **approved** file hashes and deterministic SQL projections in `caller_file_guards` and `caller_sql_guards`:
|
||||
|
||||
- cron `state/cron/jobs.sqlite`: use the template's ordered `id, json_extract(data,...)` projection of **method, params_json, cron, at, timezone, detach, call_timeout_secs**. Do not hash next_run, last_run, last_result_json or last_occurrence_id;
|
||||
- board `state/board/board.sqlite`: ordered `id,data` for hooks/invariant_target; `id,target,session,data` for subscriptions; and `select id,data from subscriptions union select subscription,data from subscription_batches order by 1,2` for executable subscription snapshots. Ignore last_sent/ended, transient batch IDs/lines/due; a pending delivery of the same active configuration does not change this union. Historical hook-attempt snapshots remain diagnostics and **must not replay**. Automatic final subscription removal changes the executable set; resolve/refresh before arm, or take the pre-start abort path if it happens after arm;
|
||||
- boot and other discovered executable configuration/manager registrations. Review typed room/workflow/orchestrator stores too; preserve their histories/settings and non-executable records. Do not mistake prompt text for an RPC template.
|
||||
|
||||
A SQL guard hashes `sha256(json.dumps(rows,separators=(',',':')).encode())`, where `rows = connection.execute(sql).fetchall()` in the approved ordered projection. JSON strings inside rows retain their exact bytes. These projections, including all column names, live in the current template and were tested against mutable cron/board bookkeeping. Any genuinely new executable store/field still needs review; do not silently omit it. The observed live baseline had **six cron templates to convert**, already-target-valid board hook/invariant calls, no subscription batches and no live manager registrations. This is an observation, not a future frozen snapshot. Unknown executable references, `session.work`, `session.cancel`, ambiguous `interrupt:true`, invalid text or unexplained notify destinations stop preflight for explicit resolution.
|
||||
|
||||
**Before start, again before arm, and once immediately before freeze**, `check`/driver checks artifact and file guards, SQL projections on consistent live SQLite backup-API copies, special-file types (without hashing mutable bytes), space, and a **dry run of the same cron converter used by plan**. Object/text/interrupt refusals therefore occur before downtime, not just in plan. The dry run never rewrites the live DB. After freeze, guards run on disposable complete DB copies before apply; immutable backup evidence is never opened/recovered in SQLite.
|
||||
|
||||
**Space is an allocated-block measurement, not a guarantee.** Root/world must have the same filesystem device in this reviewed layout. Walk regular files, directories (including the root) and non-followed symlinks; charge each at the larger of POSIX `st_blocks × 512` and its logical size rounded up to that filesystem's `f_frsize`. This includes sparse-file expansion by copy2, not just logical file bytes. Add the concrete staged plugin/prev/SDK allocations and the three existing DB-copy roles (recovery, staging, audit). Save source allocation, backup allocation budget, directory/file counts, fragment size and required/free bytes in `preflight-live.json`; refuse before freeze unless free covers that budget. The fresh sparse/tiny-file/directory fixture measured 12,288 source-allocated bytes and 4,599,808 copied bytes against a 4,603,904-byte budget (see evidence/round4-1392/allocated-space.json for actual measured values). **No claim that this can never underestimate:** metadata, DB normalization, growth, changed mounts and competing writers can still exhaust space. Resolve storage uncertainty in the approved maintenance plan, not with an invented margin.
|
||||
|
||||
Add any genuinely new executable store to the manifest before arm. From resolution until freeze: no caller/boot/SDK/plugin changes; routine cron next/last/result and subscription delivery bookkeeping is permitted. If new intent is needed, cancel unarmed or abort pre-start, re-resolve, and approve the changed manifest; never edit a hash-bound in-progress manifest.
|
||||
|
||||
**No cron wake-up is required:** the external driver publishes the explicit new mediator input. Do not rely on a timed wake-up for controller recovery. If a fallback wake-up is explicitly wanted, add/review it **before** resolving operator.json, not between resolution and freeze. It is useful only after the target is running and can accept new input. One due during downtime is skipped by cron; one firing immediately before freeze can enter an old pending queue that this approved cut deliberately drops. Neither is migrated/replayed. A guessed wall-clock time after the estimated backup duration is not readiness proof; use the driver's event/completion evidence and stable continuation instead.
|
||||
|
||||
### Approval and external launch surface
|
||||
|
||||
Surma's go must explicitly cover this resolved manifest, the stop-all core/session/cred cut, the detached external controller launch/arm, pinned launcher installation and the post-start verification/release/deletion plan. This card's outside-world-controller scope requires a controller that survives the mediator; it does **not** authorize executing it now.
|
||||
|
||||
The proposed concrete execution surface is **two finite foreground `ssh.exec` calls as surma on citadel.vpn.surma.technology**, using the existing approved signing grant, with commands `cd /home/surma/sau-384-cutover && <the start command below>` and then the same prefix with `<the arm command below>`. Each command terminates; the reviewed native CLI, not a shell `&`, creates the detached OS child. **Ask Surma's go to explicitly authorize this planned exception to the ssh-exec skill's usual prohibition on creating background work through exec strings. Until that explicit authorization, do not launch.** Do not improvise a tunnel, SSH PTY, new worker session or an in-world controller. This is a fully prepared proposed live operation, not a claim that the current worker has permission or has tested a live SSH launch. The same finite foreground surface executes the final verify/delete command after target verification. After arm, loss of the old mediator/SSH RPC result is expected and does not terminate the already-detached controller; inspect the external checkpoint instead of retrying start.
|
||||
|
||||
## 1. Surma monitors; mediator starts/arms the external controller
|
||||
|
||||
Close ingress and record accepted first-update cuts before arm. **Surma is the external human recovery operator while the world is down.** He uses an independent Citadel terminal, never an in-world session/tool process. After mediator `start` acknowledges, Surma runs the foreground monitor below and leaves it visible. It binds a local Unix datagram socket and records the monitor/controller identities before arm. The controller waits for **its own** monitor on every start/resume; arm refuses without it. If an old monitor was killed, a new monitor removes its stale socket only after the saved exact process identity is dead; never unlink a live monitor's socket by hand.
|
||||
|
||||
The monitor receives inventory begin/completion and completed backup-entry progress **without writing disk**, plus `CUTOVER FAILED` and the notification outcome. It also waits on the controller's **pidfd**: normal exit, SIGKILL or crash prints `CONTROLLER EXITED`, even when there is no log/failure.json. Progress datagrams are best effort; the independent death event is not. No heartbeat/liveness/endpoint polling or backup-duration timeout. Read `driver.json` and inspect the pane once after exit; distinguish awaiting_verification from failure instead of blindly restarting.
|
||||
|
||||
Disk-backed `failure.json`, `failure-notification.json` and driver.log are best effort. Each may be unwritable at ENOSPC; none is a prerequisite for the failure signal or the Herdr notification attempt. One best-effort Herdr notification `Cut-over needs Surma` is submitted without first saving failure.json. Rehearsal submission returned shown:false/disabled; neither delivery nor a physical display is guaranteed. **Keep the external terminal monitor attached:** a silent tail of driver.log is not sufficient during backup. Whole-machine power loss can stop both controller and monitor and is not claimed covered.
|
||||
|
||||
From the external root, on the approved host execution surface, mediator first runs the finite preflight command, then starts:
|
||||
|
||||
```sh
|
||||
PYTHONDONTWRITEBYTECODE=1 /nix/store/fixvq6n42pmvy49yh6kfmpax1s3j8md2-python3-3.14.7-env/bin/python3 -B tool/driver.py check operator.json
|
||||
PYTHONDONTWRITEBYTECODE=1 /nix/store/fixvq6n42pmvy49yh6kfmpax1s3j8md2-python3-3.14.7-env/bin/python3 -B tool/driver.py start operator.json
|
||||
```
|
||||
|
||||
`start` launches a new OS session with stdin closed and output in `<root>/driver.log`. A pipe completion handshake records the child's **actual post-Nix-wrapper-exec identity**, rather than racing an interpreter exec. It returns an acknowledgement and leaves the child waiting on inotify for its own monitor, then **durable arm.json**. There is no arm FIFO or reader-dependent write. Check that receipt/checkpoint once. Surma now runs in his independent terminal:
|
||||
|
||||
```sh
|
||||
cd /home/surma/sau-384-cutover
|
||||
PYTHONDONTWRITEBYTECODE=1 /nix/store/fixvq6n42pmvy49yh6kfmpax1s3j8md2-python3-3.14.7-env/bin/python3 -B tool/driver.py monitor operator.json
|
||||
```
|
||||
|
||||
Wait for `MONITOR attached; arm may now proceed` in that terminal. Then mediator runs the finite arm command:
|
||||
|
||||
```sh
|
||||
PYTHONDONTWRITEBYTECODE=1 /nix/store/fixvq6n42pmvy49yh6kfmpax1s3j8md2-python3-3.14.7-env/bin/python3 -B tool/driver.py arm operator.json
|
||||
```
|
||||
|
||||
`arm` requires the current monitor, repeats live preflight and atomically writes a manifest-bound marker. Re-arming is idempotent while waiting with that monitor; without it arm refuses finitely, never blocks on a missing FIFO reader. A crash after observing the marker but before saving `armed` resumes from `waiting_for_arm` and reads the same marker.
|
||||
|
||||
From here **the external driver, not the old mediator**, owns steps 2–4. The old default run and its tool calls are deliberately cut. They are not recovered from legacy history. The driver uses an external advisory lock to exclude another controller. Rehearsal killed both the invoking caller shim and, separately, the detached controller after freeze; checkpoint resume completed the chain.
|
||||
|
||||
## 2. Driver: freeze → full backup → plan/audit → offline apply
|
||||
|
||||
The driver verifies reviewed bundle/plugin/SDK bytes and native closure hashes, registers real GC roots, and creates hash-guarded **old and target selector/owner artifacts before freeze**. It then:
|
||||
|
||||
1. Pins boot ID, start ticks, executable and group for both native processes; confirms controller is outside their group. Signals the **outer loop first** and awaits its pidfd exit. Saves `owner_stopped`.
|
||||
2. Signals the exact host **SIGTERM then SIGCONT**, so a job stopped by background-PTY SIGTTIN can handle termination, and awaits host exit. Checks the saved group once for unexpected survivors; never blindly kills them. Saves `freeze.json` phase **`frozen`**, world path, boot ID and empty group inventory. No process/endpoint polling, sleeps or stop-request counting. Host shutdown stops all plugin/agent trees and suppresses restart admission. `core.restart` alone is not stop-all; after the loop is gone, exit 75 cannot respawn it.
|
||||
3. Makes the complete frozen backup outside the world. Streaming SHA-256 does not read whole files into memory. Directory inventory and copy preserve empty directories and symlinks **without following links**. An interrupted plan resumes the same immutable input inventory and partially completed backup; it neither overwrites an earlier backup nor requires deleting the plan directory. Progress is sent to the external terminal monitor. A pre-plan abort may discard only that reproducible incomplete backup, as described below. Unknown images refuse.
|
||||
4. Stages the approved transformations, then checks the frozen caller guards. **Both plan and apply independently require the actual frozen checkpoint**, same world/current boot, dead pinned owners, empty old group and no subsequently running host for this world. A caller-supplied `{remaining:0}` literal is not accepted. Changed checkpoint bytes also refuse.
|
||||
5. Stages **plugin.prev.wasm = that plugin's frozen pre-cut plugin.wasm** for every offline-swapped plugin, then target plugin.wasm. Both prev/current writes have before/after hashes and participate in restore. It never retains the unrelated older prev as the cut-over fallback. Git is deliberately untouched here; its later standard activation will save its own preceding binary. Applies hash-guarded before/after operations offline. File replacements fsync temporary bytes, rename, and fsync the parent. A third image refuses. If atomic replacement dies before rename, abort removes only that saved operation’s new `.cutover-tmp`, absent from the frozen inventory and byte-prefix-matching its approved before/after image; unknown temporaries refuse. Resume overwrites/completes its own temporary. `migration/plan.json` reaches `installed` only after all operations complete.
|
||||
|
||||
Exact logical losses are **only** old session `pending.jsonl`, `cancel`, and unclaimed `mail.deliveries` rows with `state='pending'` and recipient not `surma`. Original messages, Surma copies, done rows and uncertain `unknown` deliveries stay; do not replay them. Loss records and original bytes/rows remain in backup/evidence. Histories/settings, scheduled SQL IDs/calendar/outcome fields, grants/requests and unrelated files remain intact.
|
||||
|
||||
Cron conversion: require an object and nonempty text, refuse `interrupt:true`, remove false/absent legacy interrupt, inner detach/notify/permanent ID, set absent kind `follow-up`, outer detach false. Preserve other fields and non-session JSON bytes. Review removed notify destinations; no invented answer-forwarder.
|
||||
|
||||
**SQLite sidecars:** copy the complete DB plus `-journal/-wal/-shm` family into immutable backup first. Recover only another disposable copy; use SQLite's backup API to stage a consistent DELETE-journal image. Plan removal of old sidecars **before** replacing the main DB, retaining the original physical family. This is normalization, not additional logical data loss. Tests covered committed WAL data, a genuinely hot uncommitted rollback journal, interruption and exact pre-start restore.
|
||||
|
||||
Full-world backup is not instantaneous: the 138,444-file / 12,441,162,164-byte / 45,310-directory **size-bucket-matched synthetic** replica took **70.55 s inventory + 135.39 s copy/hash verification = 205.94 s** in the box. This includes repos/plugins/logs, unlike the earlier session-sized gate. It is warm-cache scale evidence, not an exact live-byte or cold-storage downtime guarantee. Allow that uncertainty in the approved maintenance plan; do not invent a timeout and kill the controller mid-copy. Power-loss recovery is not claimed.
|
||||
|
||||
## 3. Driver: ordered boot installation and pinned owner selection
|
||||
|
||||
The offline target boot list is:
|
||||
|
||||
```json
|
||||
[
|
||||
{"method":"session.manager","params":{}},
|
||||
{"method":"web.start","params":{}},
|
||||
{"method":"cred.serve","params":{}},
|
||||
{"method":"orchestrator.start","params":{}},
|
||||
{"method":"cron.start","params":{}}
|
||||
]
|
||||
```
|
||||
|
||||
Manager precedes web, so `web-chat` becomes best-effort. No `mail.start` deliverer remains. Resolve any extra actual boot entry before approval rather than deleting it accidentally.
|
||||
|
||||
The permanent `<root>/target-owner` exports `SAUSAGE_FLAKE=path:<root>/target-selector` itself, then execs the inspected loop with the exact world path. The selector's sole app is the pinned target package. `<root>/old-owner` similarly selects the **explicit old package**, never the remote default. These are permanent installation/launcher artifacts, **not migration code**. Their bytes/hashes are retained as manifest evidence; do not delete them with `tool/`.
|
||||
|
||||
**Every later pane/cold restart uses `/home/surma/sau-384-cutover/target-owner`, with no caller environment required. Never invoke bare sausage-loop or the mutable gitea default for this world.** Keep this command in the operator's pane-restart procedure. Rehearsal cold-launched it in the same Herdr shell without ambient SAUSAGE_FLAKE and observed the target executable. Earlier cold rollback exercised the explicit old wrapper against a separate old-backup clone; pre-start abort gates also exercised it against the exactly restored original frozen fixture, never over new target work.
|
||||
|
||||
`core.restart` now restarts the pinned reviewed host; it does **not** self-update from gitea. Future host updates require a separately approved package/closure/root/selector change. A remote push alone must never silently choose an old host against target state. Keep both GC roots through the rollback window; retire the old root only after an explicit decision, not tool deletion.
|
||||
|
||||
**Expected approved live Nix side effect:** each controller run makes three `nix-store --query --roots` calls, one each for old package, target package and loop, immediately after rooting those paths and before freeze/resume of offline work. **Every resume repeats all three queries and their possible pruning**, even if the roots already exist. Monitor/check/arm/continue/verify alone do not make those roots queries. Those queries can prune stale auto-root registrations and stale temporary-root files in Nix state, even though they do not delete store packages. Surma's go must include that side effect (and the five root registrations including controller/Herdr); the earlier shared-store incident is disclosed in rework-1381.md. Rehearsal now uses only a private Nix DB; no live root query was rerun.
|
||||
|
||||
## 4. Driver: started → launch in the same pane → fresh verifier
|
||||
|
||||
The driver checks the pinned shell still owns the selected pane and no unexpected foreground process exists. It checkpoints **`started` before any launch attempt**, then submits the permanent target-owner path once via `herdr pane run` to the manifest's pane (live **w7:p6**). No shell reconstruction or in-world mediator action is needed during downtime.
|
||||
|
||||
Readiness is event driven: arm a kernel inotify watch on `state/session/manager/registrations`, queue one `session.subscribe` command in the console, and await its unique registration file publication. The wait has **one absolute 120 s deadline**, from cron `src/types.rs` AddParams' existing registration/publication budget (also the earlier test_driver completion bound); it is not a periodic scan. Herdr/RPC calls use that existing finite bound. Missing registration or a failed post-start `cred.status`/subscribe/send produces the disk-independent failure/death signal and exits, keeping **`started`**. Never resume, restore or launch again automatically. Herdr wait-output is internally polling and is not used.
|
||||
|
||||
### Forward completion after a post-start failure (Surma's independent terminal)
|
||||
|
||||
1. Read checkpoint/monitor and inspect Herdr pane/native identities once. If target is not actually running, preserve all new work and obtain a narrow forward repair plan; this mode will refuse and **does not launch it**. Do not relaunch blindly, restore an old snapshot or hand-edit the checkpoint. If only registration/publication failed but exactly one target is running, use:
|
||||
|
||||
```sh
|
||||
cd /home/surma/sau-384-cutover
|
||||
PY=/nix/store/fixvq6n42pmvy49yh6kfmpax1s3j8md2-python3-3.14.7-env/bin/python3
|
||||
PYTHONDONTWRITEBYTECODE=1 "$PY" -B tool/driver.py continue operator.json
|
||||
```
|
||||
|
||||
2. `continue` exclusively locks, binds the unchanged manifest, inspects that exact shell/pane, exactly one target host for this world and all offline target plugin hashes. It never freezes, applies, restores or launches. It then performs the **skipped exact RPCs**, through operator.json's gateway: `cred.status {}` must report locked and is durably saved as `driver.json.locked_cred_status`/`locked_cred:true` **before Surma unlocks**; `session.subscribe` uses id `cutover:<continuation_id>`, session `cutover-readiness`, method `web.session_event`, best_effort true; `session.send` uses session default, the **same** continuation_id, kind follow-up and exact continuation_text. It saves the receipt and reaches `awaiting_verification`. A recorded locked-startup proof is reused if a later retry occurs after UI unlock; absent that proof, unlocking first is a discrepancy, not permission to fake the check.
|
||||
3. If a method/reply fails again, keep target work, inspect the error and fix only that cause; repeat the **same continue command**, not migration/start. Stable subscribe/message IDs make this safe even when send committed and only its reply was lost. Both started and awaiting_verification are accepted. Tests injected each RPC failure and committed-then-lost send acknowledgement; one continuation run-start/consumption remained. The real 120 s registration timeout also recovered through this command without a second owner.
|
||||
4. `verify` still requires awaiting_verification. Run continue first when phase is started; there is no direct started→verified override. The fresh mediator can then verify/ask for unlock/release as §5; if locked cred prevents SSH signing, Surma performs the same approved local external commands. Live SSH/new-cred signing is not claimed tested. No hand-edited checkpoint or new continuation UUID is needed.
|
||||
|
||||
Finally the driver publishes the manifest's **new explicit default verification message** with its stable UUID and records `awaiting_verification`. The manager, not the driver, runs it. This names the new mediator as verifier without claiming the cut default run was recovered. The test produced one run-start and mailbox-consumed pair for that message. Publication is not immediate model execution: in the last gate it waited **23.753 s to run-start / 24.530 s to consumption** behind initial reconciliation. Do not confuse that with publication latency or a completed startup scan.
|
||||
|
||||
No automatic relaunch, whole-world restore or reapply is allowed after `started`. If launch/acknowledgement is lost, inspect the pane/checkpoint once before any repair. Do not launch a second owner blindly.
|
||||
|
||||
## 5. New mediator: locked proof → Surma unlock → git → release web → delete tool
|
||||
|
||||
The explicit new verification run reads this runbook and in-world ownership/cred state. While cred is locked, Surma checks the external manifest/checkpoint/migration evidence in his independent terminal; the new mediator can read it through approved SSH only after UI unlock if signing works. Surma can also perform the final local verify command, so no vault-key transfer or untested signer is required to finish. Keep ingress closed until checks pass:
|
||||
|
||||
- one target manager and one runner ownership lock per running session; no old owners, mail deliverer or old orchestrator service;
|
||||
- cred **locked**, no vault/key transfer; grants/requests/settings preserved. Interrupted callers reuse their existing stable credential-request keys, not automatic retries of secret-backed mutations;
|
||||
- explicit target run-start without run-end is the only automatic cut-run recovery; old finished-looking history alone is neither settlement nor permission to resume;
|
||||
- durable event/mailbox catch-up and same-ID default/advisor POST replay evidence holds; only intended loss/conversion/schema/new-activity differences exist. Surface uncertain mail/tool outcomes;
|
||||
- scheduled jobs and non-target state match the inventory; real new mediator/runner activity is distinguished from migration changes;
|
||||
- design effective grace 5 s and separate EOF-stop 5 s remain; crash supervision is enabled. Deliberate core.stop/kill disables service registration and is **not** a crash-supervision test.
|
||||
|
||||
Prior post-record live-size samples remain manager ack **0.6078 s**, registration catch-up **0.0128 s**, mail.send-to-default publication **0.5376 s** (integrated 0.5235 s), below existing orchestrator **3 s** / cred **10 s** caller budgets. Disclose integrated catch-up **12.0419 s** and the new initial-scan delay above. They are synthetic/warm-cache observations, not production latency guarantees. Prior real-core watch/upgrade/all-watch readiness, ~0.10% whole-host idle duty, detached error data and real cron warning/stop evidence remain linked in the result docs.
|
||||
|
||||
Before any Git network work, record locked-startup proof, ask **Surma to unlock the target vault through the existing UI**, then check `cred.status` reports unlocked for that same target service. Wait here if Surma is unavailable; never transfer a key/password. **Only after that**, mediator/orchestrator activates the reviewed git artifact:
|
||||
|
||||
```json
|
||||
{"method":"core.activate","params":{"plugin":"git","wasm":"/docs/symphony/artifacts/SAU-384/git-9cc9fa6f.wasm"}}
|
||||
```
|
||||
|
||||
This is a proposed live operator step covered by the go, not a worker action. The permanent world artifact above must be staged/hash-checked from the selected bundle before go; the driver preflights its external `deferred_artifacts.git` copy too. Verify active git hash equals manifest `files["git.wasm"].sha256` before releasing Git callers. See `/plugins/git/source/{VAULT-HTTPS.md,VAULT-SSH.md}`; both require cred first, live and unlocked. No push is part of verification; **every** later push still needs a separate fresh Surma go. Final proof includes `git_activated_after_unlock`.
|
||||
|
||||
Interim compatibility: observed live Git **f089b56d** still speaks new cred's explicit-grant fetch/item_get/public_key/sign contracts. Actual old-WASM + new-cred loopback SSH clone signed/authenticated successfully with a synthetic explicit grant after unlock; locked use returned vault_locked. Granted HTTPS reached new cred, reserved exactly one use, then the deliberately closed loopback port returned network_error with a capture (no successful HTTPS transfer claimed). Old grant-free shared SSH fails asking for a local key; old grant-free HTTPS retains its old config/anonymous path, not new anyone_may_use selection. Therefore do not schedule Git network work during the locked/old-git interval, do not introduce local key/config-secret workarounds, and surface any already-started caller's lock/missing-key error rather than blindly retrying it. Local git status/commit/authoritative-main merges remain available. New Git shared SSH clone was tested after real synthetic unlock/activation.
|
||||
|
||||
Only now use **`orchestrator.release` for SAU-375** to release its approved commit. Inspect that method's current typed arguments; do not invent them. Verify published asset hashes and ingress. No worker publishes anything. The rework tested approved asset hashes in temporary output, **not an actual live release or physical browser**.
|
||||
|
||||
The Surma unlock above is a prerequisite for using the existing approved signing grant for the final external SSH verification/deletion command (live signing interoperability is still unverified); it is not a second vault handover. Never ask for Surma's password or transfer the old vault key. Read-only gateway checks and checkpoint inspection do not justify bypassing the lock. If unlock is not available, remain `awaiting_verification`, keep git activation/ingress/release gated, and do not reconstruct or rerun the migration.
|
||||
|
||||
Write external `verification.json` with evidence links and the required true checks named by the driver's `verify` mode: `one_manager`, `no_old_owner`, `locked_cred`, `non_target_preserved`, `losses_exact`, `catchup_and_replay`, `web_released_and_hashes`, `ingress_verified`, `git_activated_after_unlock`. They are operator attestations after the checks, not substitutes for them. From the external root, mediator runs the finite command below after target cred is unlocked and signing works; **Surma may instead run the identical command in his independent Citadel terminal**, avoiding any dependency on in-world SSH signing:
|
||||
|
||||
```sh
|
||||
PYTHONDONTWRITEBYTECODE=1 /nix/store/fixvq6n42pmvy49yh6kfmpax1s3j8md2-python3-3.14.7-env/bin/python3 -B tool/driver.py verify operator.json verification.json 2>> tool-source-and-delete.log
|
||||
```
|
||||
|
||||
It marks the saved plan/driver verified, logs source hashes/source-as-log, and **deletes the three migration executables and bytecode caches**. Confirm `tool-deletion.json` and absence once. Delete any other reconstructed executable/controller copies too. Retain only manifests, backup/inventory/loss/caller-audit records and logs as migration evidence, plus the separately reviewed target/permanent launcher artifacts. Rehearsal deleted all executed migration/test controllers before exporting evidence. If verification/deletion is interrupted, inspect the verified checkpoint and delete only remaining hash-matched tool copies; do not reconstruct and rerun migration effects.
|
||||
|
||||
## Failure/rollback boundaries
|
||||
|
||||
| Boundary | Exact recovery rule |
|
||||
|---|---|
|
||||
| Waiting for arm, including consumed-marker/save race | World untouched. Repeated arm is finite even with no reader. After the saved controller exits, `resume` accepts waiting_for_arm and the durable marker; attach a new `monitor` for that replacement before it continues. Or run abort (untouched) after obtaining the external controller lock. |
|
||||
| Armed/captured/owner_stopped/frozen; no target attempt | External operator can use `... driver.py resume operator.json` **only after the saved controller has exited**. It detaches a replacement and returns; Surma then runs the same foreground monitor command. The replacement waits for its own monitor receipt before effects. Same boot/identities/manifest/backup/plan must pass. No agent needs to exist. Both caller-killed and controller-killed resume were rehearsed. |
|
||||
| Interrupted planning/swap, no start attempt | Resume the same immutable backup/plan with before/after hashes. A caller/config after-image is accepted only if that exact saved operation approved it; third images refuse. Alternatively Surma runs the real driver's **abort** mode below: it restores every approved changed path from the immutable complete backup, verifies the full frozen inventory, and launches the explicit old-owner in the selected pane. It never restores an incomplete backup. If planning never completed, apply cannot have started: abort first discards only `migration/backup` (including its partial copy temporaries), retaining planning/inventory/log evidence, then checks space **before any checkpoint save**. If enough space is available, it verifies any saved inventory and relaunches the untouched old world. If a competing writer still fills the disk it refuses before writes/launch; Surma must free only identified, separately approved non-world data and repeat abort. Never delete a complete backup/plan or unrelated world data to make space. Restore is before/after-hash guarded, resumable, and writes main DBs before their old sidecars without opening them. Never partially start mixed contracts. A frozen audit discrepancy is an offline stop, not permission to improvise a conversion. |
|
||||
| `started` or later, even if boot seems failed | No automatic whole-world rollback/reapply/relaunch. New histories, mail, jobs, grants or external effects may exist. Preserve them. Use the forward-only continue command above for readiness/post-start RPC failure with one healthy target; otherwise plan a narrow forward repair or coordinated revert. |
|
||||
| Any plugin core.revert | Offline-swapped prev now means exactly pre-cut current (hash-tested), not pre-pre-cut. **Do not use blind individual core.revert across incompatible contracts.** Coordinate providers/callers and preserve new work; the session prerequisite below still applies. Later git activation uses normal core prev semantics. |
|
||||
| Session revert after target activity | Close gateway/ingress. While new manager is available, **unsubscribe web-chat**, verify removal, and prevent gateway re-registration while the old session binary runs. Never call web.start against that old manager or restore a registry backup that reintroduces it. Actual old-manager/gateway tests proved unsubscribe safe and ID-only re-key unsafe: the old binary ignores best_effort and replays history once. |
|
||||
| Host reboot / changed PID identity / unexpected survivor | Refuse saved native identities; re-inventory externally. Never signal a reused PID, blindly kill a group, or infer quiescence from a stop request. |
|
||||
|
||||
### Exact external failure commands (Surma, not an in-world mediator)
|
||||
|
||||
In the independent Citadel terminal, `cd /home/surma/sau-384-cutover`; read `driver.json`, monitor output, any available `failure.json`/`driver.log`, and the pane once. At ENOSPC those disk-backed diagnostics may be absent or truncated; the terminal failure/death signal is authoritative that the controller ended, not that rollback is safe. Commands use the pinned interpreter:
|
||||
|
||||
```sh
|
||||
PY=/nix/store/fixvq6n42pmvy49yh6kfmpax1s3j8md2-python3-3.14.7-env/bin/python3
|
||||
# Same manifest/boot/owners, known exited controller, pre-start only:
|
||||
PYTHONDONTWRITEBYTECODE=1 "$PY" -B tool/driver.py resume operator.json
|
||||
# After resume acknowledges, attach its new foreground monitor before effects:
|
||||
PYTHONDONTWRITEBYTECODE=1 "$PY" -B tool/driver.py monitor operator.json
|
||||
# Or abandon pre-start attempt, restore coherent old bytes, submit old-owner once:
|
||||
PYTHONDONTWRITEBYTECODE=1 "$PY" -B tool/driver.py abort operator.json
|
||||
```
|
||||
|
||||
Choose **one**, not both. Both use a nonblocking exclusive controller lock; do not delete the lock file or bypass it. Abort accepts waiting/armed/frozen/installed/restoring/restored only. It checkpoints `old_start_attempted` **before** pane launch, then `old_launched`. If interrupted during restoring/restored, repeat abort on the same manifest/checkpoints; if `old_start_attempted`/`old_launched`, inspect the pane instead of repeating it. Abort is forbidden after any target `started` attempt. Never call Kit.rollback by hand or relaunch a bare loop. If the pinned shell/pane changed, stop for explicit identity resolution; do not guess a replacement.
|
||||
|
||||
After abort, Surma verifies exactly one old package owner in the pane and old world readiness before retry. Preserve the frozen evidence; the sole permitted deletion of backup evidence before verified success is the **reproducible incomplete pre-plan backup subtree** in the guarded abort path above. Complete backups are never deleted by abort. Every abort, including one with a completed plan, checks space before its first checkpoint save; a full disk is a deliberate pre-write refusal, not a failed checkpoint write. Abort rechecks the same measured space budget again immediately before old launch; if it refuses, free only identified/approved competing non-world data, not histories, jobs or complete backup evidence, then repeat abort on the same checkpoint. For a clean retry **only after old_launched and verification**, archive checkpoints (not owners/selectors/GC roots/tool):
|
||||
|
||||
```sh
|
||||
# Pick a fresh, unused archive path and record it; this example is this run.
|
||||
ARCHIVE=attempt-before-retry
|
||||
PYTHONDONTWRITEBYTECODE=1 "$PY" -B -c 'import json; assert json.load(open("driver.json"))["phase"] == "old_launched"'
|
||||
mkdir -- "$ARCHIVE"
|
||||
for p in driver.json freeze.json arm.json controller-process.json migration driver.log failure.json failure-notification.json preflight-live.json monitor.json poststart-inspection.json; do
|
||||
if [ -e "$p" ]; then mv -- "$p" "$ARCHIVE/"; fi
|
||||
done
|
||||
cp -- operator.json "$ARCHIVE/operator.json"
|
||||
```
|
||||
|
||||
Re-identify the now-running old loop/host and pane shell; refresh old-work/caller guards (including any wake-up) and approval for the new resolved `operator.json`, retaining the stable continuation UUID if no target start was attempted. Then check/start/arm as §1. Do not merely edit a live checkpoint or delete the backup. For aborted_untouched, no old relaunch is necessary; archive only after verifying the original owners are still running. No such reset/archive is allowed for started/awaiting_verification/verified or unknown owners: retain new work and plan a forward repair.
|
||||
|
||||
Independent plugin recovery/accounting/provider-cancellation follow-ups remain independent. Accepted first-update cuts and uncertain external effects are not silently solved by this kit, grace=5, or a successful rehearsal.
|
||||
@@ -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"}}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user