Compare commits
3
Commits
dc81f78c20
..
spike
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56d02befc8 | ||
|
|
0be152dda9 | ||
|
|
83bbbb33a8 |
@@ -1,11 +0,0 @@
|
|||||||
# Runs on a self-hosted Nix runner (label `nixos`, host executor).
|
|
||||||
name: ci
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: nixos
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: nix flake check
|
|
||||||
run: nix flake check -L --show-trace
|
|
||||||
+1
-5
@@ -1,8 +1,4 @@
|
|||||||
target/
|
/target
|
||||||
/.cargo-home
|
/.cargo-home
|
||||||
/world
|
/world
|
||||||
/result
|
/result
|
||||||
|
|
||||||
# Lock files of test and example crates: they resolve against the workspace lock.
|
|
||||||
**/tests/**/Cargo.lock
|
|
||||||
/examples/*/Cargo.lock
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
# Working on Sausage
|
|
||||||
|
|
||||||
Sausage is meant to be read and changed by agents, including its own agent.
|
|
||||||
Keep it that way.
|
|
||||||
|
|
||||||
## Priorities
|
|
||||||
|
|
||||||
1. **Correct.** Every behavior in `PROTOCOL.md` has a test in `cli/tests/host/`.
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
|
||||||
the same commit as the behavior. `DECISIONS.md` says why; when the two
|
|
||||||
disagree, `PROTOCOL.md` and the code win.
|
|
||||||
|
|
||||||
## Checks
|
|
||||||
|
|
||||||
```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 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
+33
-889
File diff suppressed because it is too large
Load Diff
+1
-7
@@ -1,11 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["cli", "vfs", "interface", "sdk", "sdk/macros", "agent"]
|
members = ["cli", "agent"]
|
||||||
default-members = ["cli"]
|
default-members = ["cli"]
|
||||||
# Examples are standalone plugins, each with its own empty [workspace].
|
|
||||||
exclude = ["examples"]
|
exclude = ["examples"]
|
||||||
|
|
||||||
# Wasmtime compiles components with Cranelift. Optimize dependencies in dev
|
|
||||||
# builds, so that tests and debug runs start plugins quickly.
|
|
||||||
[profile.dev.package."*"]
|
|
||||||
opt-level = 2
|
|
||||||
|
|||||||
+142
-108
@@ -1,120 +1,154 @@
|
|||||||
# Decisions
|
# Decisions
|
||||||
|
|
||||||
Why Sausage is the way it is. `PROTOCOL.md` and the code own the details.
|
This file lists the design decisions of the spike. **(you)** marks a decision
|
||||||
When this file disagrees with them, they win.
|
that Surma made. **(spike)** marks a decision that Scout made alone during the
|
||||||
|
night and that needs review.
|
||||||
|
|
||||||
## The shape
|
## Architecture
|
||||||
|
|
||||||
- **JSON-RPC, not component interfaces.** Every language can read and write
|
- **(you)** Actors are WASI commands (`wasm32-wasip2`). They use stdin, stdout,
|
||||||
lines of JSON, and models know JSON-RPC from LSP and MCP. A call has a
|
files, and HTTP. No WIT interfaces, no component composition.
|
||||||
result, like an LLM tool call.
|
- **(you)** Actors exchange newline-delimited JSON through the host, which is
|
||||||
- **A process per call.** WASI has no threads, so processes give
|
the broker. No built-in request/response. A reply is a message with
|
||||||
parallelism. A new version applies at the next call, and old processes
|
`reply_to`. Delivery is at most once.
|
||||||
retire after a grace period.
|
- **(you)** The broker accepts only `.wasm` files. It knows no source language.
|
||||||
- **Attached by default.** A process does not outlive its caller by
|
- **(you)** `cargo` is a built-in Rust compiler actor. **(spike)** It is native
|
||||||
accident. A long-running process must ask for `detach`.
|
(host code), because cargo cannot run inside WASM.
|
||||||
- **Services in one file.** Only the core starts and restarts them, so
|
- **(you)** The agent is a normal WASM actor with the fixed ID `agent`. It can
|
||||||
upgrades and singletons need no logic in plugins.
|
read and upgrade its own source.
|
||||||
- **Files to inspect, calls to act.** This keeps the core API small, and the
|
- **(spike)** Three native actors: `broker`, `console`, `cargo`.
|
||||||
agent's file tools are enough to explore the world.
|
- **(you)** One Cargo workspace with two crates: `cli` (binary `sausage`) and
|
||||||
- **The core accepts only `.wasm` files.** It knows no source language.
|
`agent`. **(spike)** Example actors are in `examples/`, outside the workspace.
|
||||||
`cargo` is one way to get a `.wasm` file.
|
- **(you)** `sausage init` builds the agent and asks for the API shape, the
|
||||||
- **The world folder is the source of truth.** Copy a plugin folder into
|
endpoint, the model, and the token. **(spike)** The token can also come from
|
||||||
another world, and it works there.
|
`SAUSAGE_API_TOKEN`. The source contains no endpoint and no token.
|
||||||
- **The agent is a normal plugin.** It can read and upgrade its own source,
|
|
||||||
and change how it does inference.
|
|
||||||
|
|
||||||
## Interfaces
|
## Messages
|
||||||
|
|
||||||
- **Generated imports and interface checks, without version numbers.** A
|
- **(spike)** Stdin line: `{"id", "from", "reply_to"?, "body"}`.
|
||||||
model that translates a description by hand makes errors. A generator does
|
Stdout line: `{"to"?, "id"?, "reply_to"?, "body"}`.
|
||||||
not. The check catches the most common error in a world that changes
|
- **(spike)** The broker sets `from`. An actor can choose its own `id`. The
|
||||||
itself: a change to one plugin breaks another one.
|
broker then uses `"<sender>/<id>"`, so IDs cannot collide or be faked.
|
||||||
- **A small schema subset.** With Draft 7 and a fixed subset, our own check
|
Without an `id`, the broker assigns `m-<n>`.
|
||||||
can decide every case. It never has to guess.
|
- **(you)** Without `to`, a message goes to the subscribers of the sender.
|
||||||
- **Docs are part of the interface.** Other agents work from them, so the
|
**(spike)** `subscribe` / `unsubscribe` messages to the broker. The registry
|
||||||
SDK requires a doc comment on each method, type, and field.
|
stores subscriptions, so they survive restarts and upgrades. `init`
|
||||||
- **The check covers calls, not state.** Old and new versions share
|
subscribes `console` to `agent`.
|
||||||
`/state`. A plugin keeps its state format compatible, or migrates it in
|
- **(spike)** Errors from routing come back as
|
||||||
`rpc.retired`.
|
`{"type": "undeliverable", "reason", "to"}` from `broker`.
|
||||||
|
- **(spike)** Lifecycle: `{"type": "shutdown"}` from `broker`, then EOF on stdin.
|
||||||
|
After 5 seconds the broker kills the instance.
|
||||||
|
- **(spike)** Lines on stdout that are not JSON objects with `body` go to the
|
||||||
|
actor's `stderr.log` with a note.
|
||||||
|
|
||||||
## Minimal on purpose
|
## Filesystem
|
||||||
|
|
||||||
The core does only what a plugin cannot do for itself. Everything else can be
|
- **(you)** `/data` private, `/public` own public files,
|
||||||
a plugin, or a change that the agent makes to itself.
|
`/actor/<id>/public` and `/actor/<id>/source` read-only for everyone.
|
||||||
|
- **(spike)** The broker also writes `/actor/<id>/status.json` and
|
||||||
|
`/actor/<id>/stderr.log`. `ls /actor` is the registry.
|
||||||
|
- **(you)** Commit on close: a write becomes visible only when the writer
|
||||||
|
closes the file. **(spike)** This also applies to `/data`. Implementation: a
|
||||||
|
wrapper around the `wasmtime-wasi` filesystem. An open for write goes to a
|
||||||
|
hidden `.sausage-tmp-*` file next to the target. The descriptor drop renames
|
||||||
|
it into place. Details:
|
||||||
|
- A normal exit commits every open file (process exit closes files).
|
||||||
|
- A trap or kill discards uncommitted writes.
|
||||||
|
- An append or an edit without truncate copies the old content first.
|
||||||
|
- Other descriptors see the old content until the close.
|
||||||
|
- Directory operations take effect immediately.
|
||||||
|
- The layer hides `.sausage-tmp-*` names from listings, `stat`, `open`,
|
||||||
|
`rename`, and `unlink`.
|
||||||
|
- Symlinks are disabled (they would break the path tracking).
|
||||||
|
- No `fsync`. A power loss can lose committed data.
|
||||||
|
- **(spike)** Raw sockets are not available (no network permission), but the
|
||||||
|
`wasi:sockets` interfaces are linked, so programs that import them still
|
||||||
|
start.
|
||||||
|
|
||||||
- **No permissions, no limits, no hiding.** Any process can call, upgrade,
|
## Broker requests
|
||||||
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 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
|
- **(spike)** `spawn`, `upgrade`, `rollback`, `stop`, `start`, `remove`,
|
||||||
|
`subscribe`, `unsubscribe`, `list`.
|
||||||
|
- **(you)** `spawn` and `upgrade` take `wasm`, plus optional `source` and
|
||||||
|
`built_by` metadata. **(spike)** The broker copies the `.wasm` file and the
|
||||||
|
source folder into `broker/versions/<id>/<n>/`, with `meta.json` (paths,
|
||||||
|
builder, requester, time).
|
||||||
|
- **(spike)** Path resolution: `/actor/...` is global, and `/public/...` and
|
||||||
|
`/data/...` belong to the sender. `..` is rejected. Native senders (the
|
||||||
|
console) can give absolute host paths.
|
||||||
|
- **(you)** Anyone can upgrade anyone. No permissions.
|
||||||
|
- **(spike)** Actor IDs: `a-z`, `0-9`, `-`, `_`, at most 64 characters.
|
||||||
|
- **(spike)** `remove` archives every folder of the actor to `archive/`. Nothing
|
||||||
|
is deleted.
|
||||||
|
|
||||||
- **Restart is a clean stop supervised by `#loop`.** `core.restart` requires
|
## Upgrades, tests, rollback
|
||||||
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
|
- **(spike)** The tests are message transcripts in `source/tests/*.jsonl`:
|
||||||
it needs comments. `init` writes commented templates instead of asking
|
`{"send": body}` and `{"expect": subset-of-body, "to"?}`. Each file runs in
|
||||||
questions, so there is one way to configure a world.
|
a fresh candidate on a copy of the actor's `/data` and `/public`. The
|
||||||
- **Plain POSIX files.** Programs like `tempfile`, git, and SQLite expect
|
candidate has no HTTP access. An `expect` waits up to 10 s. Without tests,
|
||||||
normal files. Atomic writes use a temporary file and `rename`.
|
the candidate must stay up for 2 s (smoke test).
|
||||||
- **Tests run under WASI.** `cargo test` on the host cannot find failures
|
- **(spike)** Swap: hold the mailbox, stop the old instance gracefully,
|
||||||
that occur only in WASI, for example a missing WASI function or a panic at
|
snapshot `/data` + `/public` into the old version's folder, install the new
|
||||||
start. The test runner uses the same Wasmtime and the same mounts as the
|
source in `/actor/<id>/source`, start the new instance, release the mailbox.
|
||||||
core.
|
- **(spike)** Restart after a crash: backoff 0.5 s × 2ⁿ, at most 30 s. The
|
||||||
- **Tests never run without a timeout.** `cargo.build` serializes its work
|
counter resets after 60 s of uptime.
|
||||||
folder, so one hung test or build script must not block all later builds.
|
- **(spike)** Parked message: when an instance traps, the broker keeps the
|
||||||
`timeout_secs` defaults to 900 seconds after mutex acquisition, excluding
|
last delivered message. It delivers that message again after the next
|
||||||
queue time; `test_timeout_secs` defaults to 120 seconds per WASI instance.
|
upgrade, not after a plain restart (that would loop).
|
||||||
Both are optional positive integers, never null/unlimited. Test timeouts name
|
- **(spike)** Crash loop: 3 crashes within 30 s after an upgrade → automatic
|
||||||
the failing test and let other instances finish. Build deadlines and
|
rollback to the previous version, with its data snapshot. The broker
|
||||||
cancellation kill cargo's process group before dropping the mutex; timeout
|
informs the actor that requested the upgrade.
|
||||||
errors retain the phase, elapsed time, and recent output. Source preparation
|
- **(spike)** `rollback` without `version` goes to the highest older version.
|
||||||
and publication retain serialization while their blocking filesystem work
|
It restores that version's snapshot, and it first snapshots the current
|
||||||
finishes, even if cancelled, to avoid overlapping writes to the work folder.
|
state, so a roll-forward is possible.
|
||||||
- **The SDK is not published.** `cargo` points `sausage-plugin = "0.1"` at
|
- **(spike)** Versions are per actor, not global.
|
||||||
its own copy, which it also shows in `/plugins/cargo/sdk/`.
|
|
||||||
- **Compiled code is a cache, not state.** It lives outside the world, in
|
## Limits
|
||||||
one cache folder that all worlds share: `wasmtime/` (owned by Wasmtime,
|
|
||||||
whose cleanup deletes unknown entries), `cargo-target/`, and
|
- **(spike)** Mailbox: 1,000 messages. When it is full, the sender gets
|
||||||
`sdk/<hash>/`. A path that depends only on the content of the SDK lets
|
`undeliverable`.
|
||||||
cargo reuse the compiled SDK in every world. Tests run in the release
|
- **(spike)** The broker waits 30 s when it writes to an actor's stdin. Then it
|
||||||
profile, so that they share compiled crates with the release build.
|
drops the message and logs it.
|
||||||
- **mold links host code where Nix provides it.** On Linux, the dev shell,
|
- **(spike)** Time slices: the epoch advances every 10 ms, and each guest
|
||||||
the Nix builds, and the packaged `sausage` (for the proc macros and build
|
yields at each epoch.
|
||||||
scripts of plugins) link with mold, much faster than GNU ld. Plugins for
|
- **(spike)** One `sausage run` per world (file lock on `world/.lock`).
|
||||||
`wasm32-wasip2` link with rust-lld. Without Nix, the default linker stays.
|
- **(spike)** HTTP for all running actors, with no allow-list yet.
|
||||||
- **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
|
## Agent
|
||||||
release build. Thin LTO brings nothing.
|
|
||||||
- **Discovery has a deadline of 10 seconds.** It is an internal call of the
|
- **(you)** API shapes: OpenAI Responses and Anthropic Messages, chosen by
|
||||||
core, not a limit on plugins. Without it, one plugin that does not answer
|
`"api"` in `/data/config.json`.
|
||||||
would block every activation.
|
- **(spike)** Config fields: `api`, `base_url`, `model`, `token`, optional
|
||||||
- **A broken plugin blocks nobody.** A plugin without a document is already
|
`reasoning_effort` and `max_tokens` (16,000). OpenAI: `POST
|
||||||
broken, so the check cannot protect it. The graph shows it as
|
{base_url}/v1/responses` with `store: false` and encrypted reasoning passed
|
||||||
"unavailable", and `core.revert` and `force` always work.
|
back. Anthropic: `POST {base_url}/v1/messages` with `x-api-key`.
|
||||||
- **`rpc.retired` is best effort.** It does not run if the host stops first.
|
- **(spike)** One shared conversation in `/data/history.json`, in a neutral
|
||||||
A persistent queue of retirements would be more core for a rare case, so a
|
format, so the API shape can change. The history is limited to 600 entries,
|
||||||
new version checks its own migration marker instead.
|
with the cut at a user message. There is no compaction yet.
|
||||||
|
- **(spike)** A turn starts only for `{"type": "text"}` messages *without*
|
||||||
|
`reply_to`. Replies never start turns, so two actors cannot keep the agent
|
||||||
|
in an endless loop. Other messages are collected and shown at the next
|
||||||
|
turn.
|
||||||
|
- **(spike)** Tools: `send_message` (with optional wait for the reply),
|
||||||
|
`wait_for_messages`, `read_file`, `list_dir`, `write_file`, `copy_dir`,
|
||||||
|
`remove_path`. At most 40 steps per turn. Tool output is cut at 30 KB.
|
||||||
|
- **(spike)** The final text of a turn goes to the sender, as a reply to the
|
||||||
|
message that started the turn.
|
||||||
|
- **(spike)** A shutdown during a turn saves the turn to `/data/turn.json`. The
|
||||||
|
next start (for example after a self-upgrade) resumes the turn and answers
|
||||||
|
the original message.
|
||||||
|
|
||||||
|
## Console
|
||||||
|
|
||||||
|
- **(spike)** `@<actor> <json|text>`, plain text to `agent`, `/deploy`,
|
||||||
|
`/list`, `/sleep`, `/await`, `/help`, `/quit`. `/sleep` and `/await` exist
|
||||||
|
for scripted tests.
|
||||||
|
|
||||||
|
## Known risks (accepted for now)
|
||||||
|
|
||||||
|
- Build scripts and proc macros run as native code in the host environment.
|
||||||
|
- Any actor can upgrade the agent with code that reads the token.
|
||||||
|
- Any actor can message, stop, upgrade, or remove any actor.
|
||||||
|
- A full copy of `/data` and `/public` at every upgrade is slow for large data.
|
||||||
|
- Mailboxes are in memory. A host crash loses queued messages.
|
||||||
|
|||||||
-554
@@ -1,554 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
Rust plugins use the SDK `sausage-plugin`. Its source is at
|
|
||||||
`/plugins/cargo/sdk/`, and its crate docs explain each item.
|
|
||||||
|
|
||||||
## 1. Terms
|
|
||||||
|
|
||||||
| Term | Meaning |
|
|
||||||
| --- | --- |
|
|
||||||
| world | The folder that a `sausage` host runs. It is `/` for every process. |
|
|
||||||
| core | The host. It runs processes, routes calls, serves the virtual files, and keeps the journal. |
|
|
||||||
| plugin | A folder `/plugins/<name>/` with a `plugin.wasm`. A plugin does not run by itself. |
|
|
||||||
| process | One run of a plugin version. Its ID is `<plugin>-<uuidv7>`, for example `notes-0197f3a2-7c1e-7d2b-9a41-5f0c2e8b1d34`. |
|
|
||||||
| call | A JSON-RPC request. `notes.add` calls the method `add` of the plugin `notes`. |
|
|
||||||
| interface | A set of methods. A plugin has one main interface. It can also declare endpoint interfaces. |
|
|
||||||
| endpoint | An interface that a running process serves on a TCP port. An endpoint file tells others where it is. |
|
|
||||||
| service | A call in `/config/boot.yaml`. The core starts it at boot and 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. |
|
|
||||||
|
|
||||||
## 2. The world folder
|
|
||||||
|
|
||||||
```text
|
|
||||||
<world>/
|
|
||||||
plugins/<name>/
|
|
||||||
plugin.wasm the current version
|
|
||||||
plugin.prev.wasm the version before, for `core.revert`
|
|
||||||
source/ the source. `cargo.build` builds it from here.
|
|
||||||
imports/<plugin>.json descriptions of the imported plugins
|
|
||||||
state/<plugin>/ persistent state of a plugin, by convention
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 3. Calls and processes
|
|
||||||
|
|
||||||
### 3.1 The wire protocol
|
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
### 3.2 Method names
|
|
||||||
|
|
||||||
- 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.retired`, and `rpc.endpoint`.
|
|
||||||
|
|
||||||
### 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. Each process of an older version gets a grace period to finish its work.
|
|
||||||
The default is 60 seconds. A plugin can declare its own value in
|
|
||||||
`rpc.discover` (`x-sausage-grace`). Then the core stops the process.
|
|
||||||
3. When the old process of a service has ended, the core starts the service
|
|
||||||
again with the new version.
|
|
||||||
4. When the last process of an older version has ended, the core starts a
|
|
||||||
process of the new version and calls `rpc.retired {"previous":
|
|
||||||
"<sha256 of the older file>"}`. A plugin can use this hook to migrate its
|
|
||||||
state, or to write a marker that its other new processes wait for. The
|
|
||||||
result goes to the journal.
|
|
||||||
|
|
||||||
During the grace period, an old process can call a method that a new
|
|
||||||
provider no longer has.
|
|
||||||
|
|
||||||
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 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `/` | 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 |
|
|
||||||
|
|
||||||
A listing of `/` shows `proc`, `run`, and `self`. A rename across two of
|
|
||||||
these areas fails.
|
|
||||||
|
|
||||||
```text
|
|
||||||
/proc/
|
|
||||||
core/
|
|
||||||
PROTOCOL.md this file
|
|
||||||
graph.json plugins, imports, and the result of each check
|
|
||||||
graph.dot the same graph for Graphviz
|
|
||||||
locks.json held locks and waiting processes
|
|
||||||
plugins/<name>/
|
|
||||||
discover.json the rpc.discover document of the current plugin.wasm
|
|
||||||
client.rs the Rust code that import!("<name>") generates
|
|
||||||
<pid>/
|
|
||||||
info.json plugin, version, method, params, parent, detached, start time, state
|
|
||||||
log the journal lines of this process
|
|
||||||
<files> the files that the process writes to /self
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- A process writes its own folder only through `/self`. Writes through
|
|
||||||
`/proc` always fail.
|
|
||||||
- In a process folder, the names `info.json`, `log`, and `fs` belong to the
|
|
||||||
core. A write to `/self/info.json` fails.
|
|
||||||
- A read of a generated file returns a snapshot that ends. For example,
|
|
||||||
`/proc/<pid>/log` returns the lines up to the time of the open.
|
|
||||||
- The folder of a process goes away when the process ends. Its files are in
|
|
||||||
memory. Large or persistent data belongs in `/state`.
|
|
||||||
- Do not send a `/self` path to another process. There it means a different
|
|
||||||
folder, like `localhost` on another machine.
|
|
||||||
|
|
||||||
Principle: **inspect through files, act through calls.**
|
|
||||||
|
|
||||||
## 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, the core writes the error to the journal and continues.
|
|
||||||
- After an upgrade of the plugin of a service, the core starts the service
|
|
||||||
again with the new version (section 3.7).
|
|
||||||
- The core does not restart other detached processes.
|
|
||||||
- A singleton is a service with a fixed endpoint in `/run`. Only the core
|
|
||||||
starts it, so two instances cannot start at the same time.
|
|
||||||
|
|
||||||
## 9. The core API
|
|
||||||
|
|
||||||
| Method | Params | Result |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `core.call` | `method`, `params`, `detach`? | the result of the method |
|
|
||||||
| `core.notify` | `name`, `params` | none (a notification) |
|
|
||||||
| `core.stop` | `pid` | `{}` when the process has ended |
|
|
||||||
| `core.kill` | `pid` | `{}` |
|
|
||||||
| `core.lock` | `path`, `wait`? | `{"held": bool}` |
|
|
||||||
| `core.unlock` | `path` | none (a notification) |
|
|
||||||
| `core.activate` | `plugin` and `wasm`, or `plugins: [{plugin, wasm}]`. `force`? | 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. |
|
|
||||||
|
|
||||||
### Host restart
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|
||||||
Everything else is a file.
|
|
||||||
|
|
||||||
## 10. Interfaces
|
|
||||||
|
|
||||||
### 10.1 `rpc.discover`
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
- `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`: the grace period in seconds.
|
|
||||||
|
|
||||||
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 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/` shows the source of the SDK. The dependency
|
|
||||||
`sausage-plugin = "0.1"` points to the same files in the host's cache.
|
|
||||||
- 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.
|
|
||||||
@@ -2,113 +2,64 @@
|
|||||||
|
|
||||||
You can see how the sausage is made.
|
You can see how the sausage is made.
|
||||||
|
|
||||||
Sausage is a world of small WASI programs ("plugins") that you grow by
|
Sausage is a system of actors that you grow by talking to it. Every actor is a
|
||||||
talking to it. A plugin is a `.wasm` file with JSON-RPC methods. Each call
|
WASI program (a `.wasm` file) with stdin and stdout. Actors send each other
|
||||||
to a plugin starts a new process. The agent is a plugin too: it writes new
|
newline-delimited JSON messages through a broker. The agent is an actor too:
|
||||||
plugins in Rust, builds them, tests them, activates them, and can change its
|
it does inference over HTTP, writes new actors, and can change its own code.
|
||||||
own code.
|
|
||||||
|
|
||||||
## Start
|
Inspired by [Jiti](https://ghuntley.com/lisp/), but with WASM instead of a
|
||||||
|
Lisp image.
|
||||||
|
|
||||||
You need [Nix](https://nixos.org/download) with flakes, and an LLM: the OpenAI
|
## Quick start
|
||||||
Responses API, an OpenAI-compatible Chat Completions API (OpenRouter, Ollama,
|
|
||||||
and most other providers), or the Anthropic Messages API. No clone needed:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix run git+https://gitea.surma.technology/surma/sausage
|
nix develop
|
||||||
|
cargo build --release
|
||||||
|
export SAUSAGE_API_TOKEN=... # or type it at the prompt
|
||||||
|
./target/release/sausage init world --api openai-responses \
|
||||||
|
--base-url https://api.example.com --model some-model
|
||||||
|
./target/release/sausage run world
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates a world in `./world` the first time (it builds the agent: the
|
`init` builds the agent (the first build takes a few minutes) and writes its
|
||||||
first build on a machine takes a few minutes, later worlds reuse the compiled
|
configuration to `world/private/agent/data/config.json`. The source contains
|
||||||
crates), then starts it with a console. Later runs start the
|
no endpoint and no token.
|
||||||
existing world. The two steps on their own:
|
|
||||||
|
|
||||||
```sh
|
In the console:
|
||||||
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`.
|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
When the host crashes or a plugin calls `core.restart`, the loop fetches the
|
|
||||||
newest commit and starts it again. The loop sets `SAUSAGE_LOOP=1` to enable
|
|
||||||
that RPC; without it, restart returns an error and leaves the host running.
|
|
||||||
Restart answers first, then cleanly stops all processes and exits with code
|
|
||||||
75. `quit` (code 0) ends the loop. Set `SAUSAGE_FLAKE` to run another flake
|
|
||||||
URL instead, for example your fork.
|
|
||||||
|
|
||||||
**The mediator and all agents NEVER call `core.restart` without the person's
|
|
||||||
explicit approval each time.** This also applies to legacy `core.exit`, an
|
|
||||||
alias with the same supervisor gate, response-before-exit behavior, and
|
|
||||||
request coalescing.
|
|
||||||
See [the protocol](PROTOCOL.md#host-restart) for the shutdown contract.
|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
plugins/<name>/plugin.wasm the current version of a plugin
|
hello → text to the agent
|
||||||
plugins/<name>/source/ its source
|
@notes {"type":"add","text":"milk"} → a JSON message to an actor
|
||||||
state/<plugin>/ the state of a plugin, by convention
|
@agent what actors exist? → text to an actor
|
||||||
state/core/journal.sqlite the journal: stderr of all processes, and core events
|
/deploy notes examples/notes → build with cargo, then spawn or upgrade
|
||||||
config/boot.yaml the services that start with the world
|
/list → list actors
|
||||||
config/agent.yaml the configuration of the agent
|
/await agent 300 → wait for the next message (scripts)
|
||||||
|
/quit
|
||||||
```
|
```
|
||||||
|
|
||||||
Each process also sees `/proc` (the processes and the state of the core),
|
## Concepts
|
||||||
`/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
|
- **Actor**: a `wasi:cli/command` program. It reads messages on stdin and
|
||||||
skips the interface checks. `revert <plugin>` goes back to the version
|
writes messages on stdout. Logs go to stderr.
|
||||||
before the last activation.
|
- **Message**: `{"id", "from", "reply_to"?, "body"}` on stdin;
|
||||||
|
`{"to"?, "id"?, "reply_to"?, "body"}` on stdout. No `to` = publish to
|
||||||
|
subscribers. There is no request/response: a reply is a new message with
|
||||||
|
`reply_to`. Delivery is at most once.
|
||||||
|
- **Broker**: routes messages, owns mailboxes, and runs actors. It accepts
|
||||||
|
only `.wasm` files: `spawn`, `upgrade`, `rollback`, `stop`, `start`,
|
||||||
|
`remove`, `subscribe`, `unsubscribe`, `list`.
|
||||||
|
- **cargo**: the built-in Rust compiler actor. `build` a source folder, get a
|
||||||
|
`.wasm` path back. Other actors can add other languages.
|
||||||
|
- **Filesystem** of each actor:
|
||||||
|
- `/data`: private state
|
||||||
|
- `/public`: its public files
|
||||||
|
- `/actor/<id>/{public,source,status.json,stderr.log}`: every actor, read-only
|
||||||
|
|
||||||
## More
|
A write becomes visible when the writer closes the file (commit on close).
|
||||||
|
- **Upgrade**: the broker tests the candidate on a copy of `/data` and
|
||||||
|
`/public` (transcripts in `source/tests/*.jsonl`). Then it holds new
|
||||||
|
messages, stops the old version, starts the new one, and delivers the held
|
||||||
|
messages. A new version that crashes 3 times in 30 seconds is rolled back.
|
||||||
|
|
||||||
- [PROTOCOL.md](PROTOCOL.md): calls, processes, the filesystem, locks,
|
See [DECISIONS.md](DECISIONS.md) for every design decision and
|
||||||
interfaces, and `cargo`. Read this to write a plugin in any language.
|
[SPIKE.md](SPIKE.md) for the spike results.
|
||||||
- [DECISIONS.md](DECISIONS.md): why Sausage is the way it is.
|
|
||||||
- [`sdk/`](sdk/): `sausage-plugin`, the SDK for Rust plugins.
|
|
||||||
- [`examples/`](examples/): small plugins.
|
|
||||||
- [AGENTS.md](AGENTS.md): how to work on Sausage itself.
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Spike results
|
||||||
|
|
||||||
|
Date: 2026-10-15. Host: Scout container (8 cores), Rust 1.99, Wasmtime 49.0.3,
|
||||||
|
`wstd` 0.6.8. All numbers come from `sausage run` with release builds.
|
||||||
|
|
||||||
|
## 1. Host, broker, routing, filesystem — works
|
||||||
|
|
||||||
|
- `sausage init` builds the agent and registers it. `sausage run` starts the
|
||||||
|
broker, `cargo`, the console, and every registered actor.
|
||||||
|
- Routing, replies (`reply_to`), publish to subscribers, and `undeliverable`
|
||||||
|
errors work.
|
||||||
|
- **Read-only:** a write to `/actor/<other>/public/...` fails with "Operation
|
||||||
|
not permitted". A write to `/actor/<self>/public/...` fails too. A write to
|
||||||
|
`/public/...` works.
|
||||||
|
- **Commit on close:** while `fs-a` holds `/public/y.txt` open, `fs-b` gets
|
||||||
|
"No such file". `fs-a` itself also gets it through a second descriptor. The
|
||||||
|
temporary file does not appear in listings. After the close, `fs-b` reads
|
||||||
|
the content.
|
||||||
|
- **Append:** `append` to an existing file copies the old content. Readers
|
||||||
|
see the old version until the close.
|
||||||
|
- **Crash with an open file:** the write is discarded, and the old version
|
||||||
|
stays.
|
||||||
|
- **Escapes:** `/data/../../../../etc/hostname` and
|
||||||
|
`/actor/../private/agent/data/config.json` fail.
|
||||||
|
- **Hard kill of the host:** after `kill`, no `.sausage-tmp-*` files remained,
|
||||||
|
and the next `sausage run` started every actor again.
|
||||||
|
|
||||||
|
## 2. Async stdin + HTTPS in one actor — works
|
||||||
|
|
||||||
|
`examples/fetch` (`wstd`) handles `ping` at once while a 2-second HTTPS
|
||||||
|
request runs. HTTPS and plain HTTP both work through `wasi:http`, and the host
|
||||||
|
does TLS. Bodies go to `/public/fetch/<n>`, and the reply contains the
|
||||||
|
global path.
|
||||||
|
|
||||||
|
One server (`http://neverssl.com/`) never answered. `curl` also hangs on it,
|
||||||
|
so the server is the cause. But a request without a timeout keeps its task
|
||||||
|
alive, and the actor then did not exit on `shutdown`. The broker killed it
|
||||||
|
after 5 s, as designed. Actors should set HTTP timeouts.
|
||||||
|
|
||||||
|
## 3. Upgrade cycle — works
|
||||||
|
|
||||||
|
- **No loss during a swap:** `ticker` sent 200 messages to `notes` at 50 ms
|
||||||
|
intervals. `notes` was upgraded from v1 to v2 during the stream. The swap
|
||||||
|
happened after tick 68. The v1 snapshot has ticks 1–68, and v2 has ticks
|
||||||
|
1–200, in order, with no gaps.
|
||||||
|
- **Parked message:** `{"type":"crash"}` crashed v1. The broker restarted v1
|
||||||
|
and kept the message. After the upgrade to v2 (which handles `crash`), the
|
||||||
|
broker delivered it again, and v2 answered `not_crashing`.
|
||||||
|
- **Crash loop:** an upgrade to `examples/crasher` passed the smoke test (it
|
||||||
|
crashes after 3 s). It crashed 3 times in about 10 s. The broker rolled back
|
||||||
|
to v2 with its data and sent `rolled_back` to the console.
|
||||||
|
- **Explicit rollback** v2 → v1 restored the v1 snapshot.
|
||||||
|
- **Rejections:** a compiler error comes back as `build_failed` with the
|
||||||
|
rendered diagnostics. A failing transcript rejects the upgrade, and the old
|
||||||
|
version keeps running. Invalid IDs and `..` paths are rejected.
|
||||||
|
|
||||||
|
## 4. Agent — works with both API shapes
|
||||||
|
|
||||||
|
- **`gpt-6-luna`** (OpenAI Responses) through the vendors proxy:
|
||||||
|
- Asked "which actors exist", it called `broker` `list` and answered in
|
||||||
|
about 4 s (2 inferences).
|
||||||
|
- Asked for a `counter` actor, it took these steps without help:
|
||||||
|
1. It wrote `Cargo.toml`, `src/main.rs`, and `tests/basic.jsonl`.
|
||||||
|
2. It built the crate with `cargo` (1.9 s).
|
||||||
|
3. It spawned the actor (the tests passed).
|
||||||
|
4. It sent increment, increment, get (1, 2, 2).
|
||||||
|
5. It read `/actor/counter/public/value.txt`.
|
||||||
|
- **Self-upgrade:** asked to change its own prompt, it took these steps:
|
||||||
|
1. It copied its source and changed only `prompt.md` (a 3-line diff).
|
||||||
|
2. It built the change and upgraded `agent`.
|
||||||
|
3. The upgrade stopped it in the middle of the turn. The new version
|
||||||
|
resumed the turn from `/data/turn.json` and answered with the new
|
||||||
|
signature.
|
||||||
|
- **`claude-haiku-4-5`** (Anthropic Messages): the same conversation
|
||||||
|
continued, with tool calls.
|
||||||
|
|
||||||
|
## 5. Measurements
|
||||||
|
|
||||||
|
| What | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| Round trip actor → actor → actor | p50 88 µs, p99 172 µs |
|
||||||
|
| Sequential round trips | ~10,400 per second |
|
||||||
|
| Warm `cargo` build, small actor | 0.5–1.9 s |
|
||||||
|
| Warm `cargo` build, agent | 5.5 s |
|
||||||
|
| First build of the agent (`init`) | 17 s |
|
||||||
|
| Component compile, small actor | ~70 ms |
|
||||||
|
| Component compile, agent (870 KB) | ~290 ms |
|
||||||
|
| Host start, 8 actors compiled in parallel | ~0.8 s each |
|
||||||
|
| Host RSS with 8 running actors | ~117 MB |
|
||||||
|
| First release build of `sausage` | ~9 min (Wasmtime) |
|
||||||
|
|
||||||
|
Caveat: `~/.cargo/config.toml` in this container sets `kache` as the
|
||||||
|
`rustc-wrapper`. `kache` caches compiler outputs across projects, so the
|
||||||
|
first build of the agent (17 s) is probably faster than on a clean machine.
|
||||||
|
|
||||||
|
## Next steps I suggest
|
||||||
|
|
||||||
|
1. A `matrix` actor (WASM, `wstd`, long-polled `/sync`). The agent could write
|
||||||
|
it, with you checking the result.
|
||||||
|
2. Cache compiled components (`Component::serialize`, `.cwasm` files) to
|
||||||
|
remove the compile time at host start.
|
||||||
|
3. A network allow-list per actor, for example in a manifest in the source
|
||||||
|
folder.
|
||||||
|
4. A sandbox for builds (for example `bwrap`), because build scripts run
|
||||||
|
natively.
|
||||||
|
5. Context compaction for the agent's conversation.
|
||||||
|
6. A better console (line editing, history).
|
||||||
+2
-5
@@ -2,13 +2,10 @@
|
|||||||
name = "agent"
|
name = "agent"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "The Sausage agent plugin."
|
description = "The Sausage agent actor."
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
sausage-plugin = { version = "0.1", path = "../sdk" }
|
async-channel = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml_ng = "0.10"
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "wasi")'.dependencies]
|
|
||||||
wstd = "0.6"
|
wstd = "0.6"
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
//! History window selection and context-size error detection.
|
|
||||||
|
|
||||||
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 ")
|
|
||||||
.and_then(|error| error.split_once(':'))
|
|
||||||
else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if status.split_whitespace().next() == Some("413") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if status.split_whitespace().next() != Some("400") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let body = body.to_ascii_lowercase();
|
|
||||||
body.contains("context_length_exceeded")
|
|
||||||
|| body.contains("maximum context length")
|
|
||||||
|| body.contains("context length exceeded")
|
|
||||||
|| body.contains("too large")
|
|
||||||
|| (body.contains("too long")
|
|
||||||
&& ["context", "input", "prompt"]
|
|
||||||
.iter()
|
|
||||||
.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 { .. }))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
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"));
|
|
||||||
assert!(!is_too_large("HTTP 400: invalid token"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn history_skip_advances_by_whole_user_turns() {
|
|
||||||
let history = vec![
|
|
||||||
Entry::User {
|
|
||||||
text: "first".into(),
|
|
||||||
},
|
|
||||||
Entry::ToolCall {
|
|
||||||
id: "a".into(),
|
|
||||||
name: "tool".into(),
|
|
||||||
arguments: "{}".into(),
|
|
||||||
},
|
|
||||||
Entry::ToolResult {
|
|
||||||
id: "a".into(),
|
|
||||||
output: "done".into(),
|
|
||||||
},
|
|
||||||
Entry::User {
|
|
||||||
text: "second".into(),
|
|
||||||
},
|
|
||||||
Entry::ToolCall {
|
|
||||||
id: "b".into(),
|
|
||||||
name: "tool".into(),
|
|
||||||
arguments: "{}".into(),
|
|
||||||
},
|
|
||||||
Entry::ToolResult {
|
|
||||||
id: "b".into(),
|
|
||||||
output: "done".into(),
|
|
||||||
},
|
|
||||||
Entry::User {
|
|
||||||
text: "current".into(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
assert_eq!(next_turn_start(&history, 0, 6), Some(0));
|
|
||||||
assert_eq!(next_turn_start(&history, 1, 6), Some(3));
|
|
||||||
assert_eq!(next_turn_start(&history, 4, 6), Some(6));
|
|
||||||
assert_eq!(next_turn_start(&history, usize::MAX, 6).unwrap_or(6), 6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+61
-518
@@ -1,404 +1,90 @@
|
|||||||
//! 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
|
//! 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::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
#[cfg(target_os = "wasi")]
|
|
||||||
use wstd::http::{Body, Client, Request};
|
use wstd::http::{Body, Client, Request};
|
||||||
|
use wstd::time::Duration;
|
||||||
|
|
||||||
/// Inference configuration from `/config/agent.yaml`.
|
#[derive(Deserialize, Clone)]
|
||||||
#[derive(Deserialize, Clone, Default)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
/// `openai-responses`, `openai-chat-completions`, or `anthropic-messages`.
|
/// `openai-responses` or `anthropic-messages`.
|
||||||
pub api: String,
|
pub api: String,
|
||||||
/// The SDK base URL: versioned for OpenAI, unversioned for Anthropic.
|
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
/// The provider's model identifier.
|
|
||||||
pub model: String,
|
pub model: String,
|
||||||
/// The API credential sent in the provider's authentication header.
|
|
||||||
pub token: String,
|
pub token: String,
|
||||||
/// Permit an empty token and omit authentication headers.
|
/// Optional reasoning effort for OpenAI models ("low", "medium", ...).
|
||||||
pub allow_no_token: bool,
|
|
||||||
/// An optional cap on generated tokens.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub max_tokens: Option<u32>,
|
pub reasoning_effort: Option<String>,
|
||||||
/// The thinking level of each turn. Unset: the model's default.
|
#[serde(default = "default_max_tokens")]
|
||||||
#[serde(default)]
|
pub max_tokens: u32,
|
||||||
pub thinking: Option<Thinking>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How much the model thinks before it answers.
|
fn default_max_tokens() -> u32 {
|
||||||
///
|
16000
|
||||||
/// 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)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum Entry {
|
pub enum Entry {
|
||||||
/// A person's contribution starts a new turn.
|
User { text: String },
|
||||||
User {
|
Assistant { text: String },
|
||||||
/// The complete contribution.
|
ToolCall { id: String, name: String, arguments: String },
|
||||||
text: String,
|
ToolResult { id: String, output: String },
|
||||||
},
|
/// A provider item that only this API shape understands (OpenAI reasoning).
|
||||||
/// Text produced by the model.
|
Opaque { api: String, item: Value },
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tool contract shared by all API shapes.
|
|
||||||
pub struct Tool {
|
pub struct Tool {
|
||||||
/// The function identifier exposed to the model.
|
|
||||||
pub name: &'static str,
|
pub name: &'static str,
|
||||||
/// The explanation of the tool's behavior.
|
|
||||||
pub description: &'static str,
|
pub description: &'static str,
|
||||||
/// The JSON Schema for named parameters.
|
|
||||||
pub parameters: Value,
|
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> {
|
||||||
pub async fn complete(
|
match cfg.api.as_str() {
|
||||||
cfg: &Config,
|
"openai-responses" => openai(cfg, system, tools, history).await,
|
||||||
system: &str,
|
"anthropic-messages" => anthropic(cfg, system, tools, history).await,
|
||||||
tools: &[Tool],
|
other => Err(format!("unknown API shape {other:?}")),
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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> {
|
async fn post(url: &str, headers: &[(&str, String)], body: &Value) -> Result<Value, String> {
|
||||||
let mut req = Request::post(url).header("content-type", "application/json");
|
let mut req = Request::post(url).header("content-type", "application/json");
|
||||||
for (k, v) in headers {
|
for (k, v) in headers {
|
||||||
req = req.header(*k, v.as_str());
|
req = req.header(*k, v.as_str());
|
||||||
}
|
}
|
||||||
let req = req
|
let req = req.body(Body::from(body.to_string())).map_err(|e| format!("request: {e}"))?;
|
||||||
.body(Body::from(body.to_string()))
|
let mut client = Client::new();
|
||||||
.map_err(|e| format!("request: {e}"))?;
|
client.set_first_byte_timeout(Duration::from_secs(600));
|
||||||
let client = Client::new();
|
client.set_between_bytes_timeout(Duration::from_secs(600));
|
||||||
let resp = client
|
let resp = client.send(req).await.map_err(|e| format!("HTTP request failed: {e:?}"))?;
|
||||||
.send(req)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("HTTP request failed: {e:?}"))?;
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let mut body = resp.into_body();
|
let mut body = resp.into_body();
|
||||||
let text = body
|
let text = body.str_contents().await.map_err(|e| format!("reading the response failed: {e:?}"))?.to_string();
|
||||||
.str_contents()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("reading the response failed: {e:?}"))?
|
|
||||||
.to_string();
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
return Err(format!("HTTP {status}: {text}"));
|
let mut t = text;
|
||||||
|
t.truncate(2000);
|
||||||
|
return Err(format!("HTTP {status}: {t}"));
|
||||||
}
|
}
|
||||||
serde_json::from_str(&text).map_err(|e| format!("invalid JSON in response: {e}"))
|
serde_json::from_str(&text).map_err(|e| format!("invalid JSON in response: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Translate neutral history to OpenAI Responses and preserve returned reasoning items.
|
async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
|
||||||
async fn openai(
|
|
||||||
cfg: &Config,
|
|
||||||
system: &str,
|
|
||||||
tools: &[Tool],
|
|
||||||
history: &[Entry],
|
|
||||||
effort: Option<&str>,
|
|
||||||
) -> Result<Vec<Entry>, String> {
|
|
||||||
let mut input = Vec::new();
|
let mut input = Vec::new();
|
||||||
for e in history {
|
for e in history {
|
||||||
match e {
|
match e {
|
||||||
Entry::User { text } => input.push(json!({"role": "user", "content": text})),
|
Entry::User { text } => input.push(json!({"role": "user", "content": text})),
|
||||||
Entry::Assistant { text, phase } => {
|
Entry::Assistant { text } => input.push(json!({"role": "assistant", "content": text})),
|
||||||
let mut item = json!({"role": "assistant", "content": text});
|
|
||||||
if let Some(phase) = phase {
|
|
||||||
item["phase"] = json!(phase);
|
|
||||||
}
|
|
||||||
input.push(item);
|
|
||||||
}
|
|
||||||
Entry::ToolCall { id, name, arguments } => {
|
Entry::ToolCall { id, name, arguments } => {
|
||||||
input.push(json!({"type": "function_call", "call_id": id, "name": name, "arguments": arguments}))
|
input.push(json!({"type": "function_call", "call_id": id, "name": name, "arguments": arguments}))
|
||||||
}
|
}
|
||||||
Entry::ToolResult { id, output } => {
|
Entry::ToolResult { id, output } => {
|
||||||
input.push(json!({"type": "function_call_output", "call_id": id, "output": 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 { .. } => {}
|
Entry::Opaque { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -413,15 +99,13 @@ async fn openai(
|
|||||||
"tools": tools,
|
"tools": tools,
|
||||||
"store": false,
|
"store": false,
|
||||||
"include": ["reasoning.encrypted_content"],
|
"include": ["reasoning.encrypted_content"],
|
||||||
|
"max_output_tokens": cfg.max_tokens,
|
||||||
});
|
});
|
||||||
if let Some(max_tokens) = cfg.max_tokens {
|
if let Some(effort) = &cfg.reasoning_effort {
|
||||||
body["max_output_tokens"] = json!(max_tokens);
|
|
||||||
}
|
|
||||||
if let Some(effort) = effort {
|
|
||||||
body["reasoning"] = json!({"effort": effort});
|
body["reasoning"] = json!({"effort": effort});
|
||||||
}
|
}
|
||||||
let url = format!("{}/responses", cfg.base_url.trim_end_matches('/'));
|
let url = format!("{}/v1/responses", cfg.base_url);
|
||||||
let resp = post(&url, &cfg.bearer(), &body).await?;
|
let resp = post(&url, &[("authorization", format!("Bearer {}", cfg.token))], &body).await?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for item in resp["output"].as_array().cloned().unwrap_or_default() {
|
for item in resp["output"].as_array().cloned().unwrap_or_default() {
|
||||||
match item["type"].as_str() {
|
match item["type"].as_str() {
|
||||||
@@ -436,9 +120,8 @@ async fn openai(
|
|||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let phase = item["phase"].as_str().map(str::to_string);
|
if !text.is_empty() {
|
||||||
if !text.is_empty() || phase.is_some() {
|
out.push(Entry::Assistant { text });
|
||||||
out.push(Entry::Assistant { text, phase });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("function_call") => out.push(Entry::ToolCall {
|
Some("function_call") => out.push(Entry::ToolCall {
|
||||||
@@ -446,36 +129,17 @@ async fn openai(
|
|||||||
name: item["name"].as_str().unwrap_or_default().to_string(),
|
name: item["name"].as_str().unwrap_or_default().to_string(),
|
||||||
arguments: item["arguments"].as_str().unwrap_or("{}").to_string(),
|
arguments: item["arguments"].as_str().unwrap_or("{}").to_string(),
|
||||||
}),
|
}),
|
||||||
Some("reasoning") => out.push(Entry::Opaque {
|
Some("reasoning") => out.push(Entry::Opaque { api: "openai-responses".into(), item }),
|
||||||
api: "openai-responses".into(),
|
|
||||||
model: cfg.model.clone(),
|
|
||||||
item,
|
|
||||||
}),
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if resp["status"] == "incomplete"
|
if resp["status"] == "incomplete" && out.is_empty() {
|
||||||
&& !out
|
return Err(format!("incomplete response: {}", resp["incomplete_details"]));
|
||||||
.iter()
|
|
||||||
.any(|entry| matches!(entry, Entry::Assistant { text, .. } if !text.is_empty()))
|
|
||||||
{
|
|
||||||
return Err(format!(
|
|
||||||
"incomplete response: {}",
|
|
||||||
resp["incomplete_details"]
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Translate neutral history to Anthropic Messages and merge consecutive roles.
|
async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
|
||||||
async fn anthropic(
|
|
||||||
cfg: &Config,
|
|
||||||
system: &str,
|
|
||||||
tools: &[Tool],
|
|
||||||
history: &[Entry],
|
|
||||||
effort: Option<&str>,
|
|
||||||
) -> Result<Vec<Entry>, String> {
|
|
||||||
let disabled = cfg.thinking == Some(Thinking::Off);
|
|
||||||
// Merge consecutive blocks of the same role into one message.
|
// Merge consecutive blocks of the same role into one message.
|
||||||
let mut messages: Vec<(String, Vec<Value>)> = Vec::new();
|
let mut messages: Vec<(String, Vec<Value>)> = Vec::new();
|
||||||
let mut push = |role: &str, block: Value| match messages.last_mut() {
|
let mut push = |role: &str, block: Value| match messages.last_mut() {
|
||||||
@@ -485,74 +149,43 @@ async fn anthropic(
|
|||||||
for e in history {
|
for e in history {
|
||||||
match e {
|
match e {
|
||||||
Entry::User { text } => push("user", json!({"type": "text", "text": text})),
|
Entry::User { text } => push("user", json!({"type": "text", "text": text})),
|
||||||
Entry::Assistant { text, .. } => {
|
Entry::Assistant { text } => push("assistant", json!({"type": "text", "text": 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!({}));
|
let input: Value = serde_json::from_str(arguments).unwrap_or(json!({}));
|
||||||
push(
|
push("assistant", json!({"type": "tool_use", "id": id, "name": name, "input": input}))
|
||||||
"assistant",
|
|
||||||
json!({"type": "tool_use", "id": id, "name": name, "input": input}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Entry::ToolResult { id, output } => push(
|
Entry::ToolResult { id, output } => {
|
||||||
"user",
|
push("user", json!({"type": "tool_result", "tool_use_id": id, "content": output}))
|
||||||
json!({"type": "tool_result", "tool_use_id": id, "content": output}),
|
|
||||||
),
|
|
||||||
// Thinking blocks go back unchanged (with their signature): required next to tool use.
|
|
||||||
Entry::Opaque { api, model, item }
|
|
||||||
if api == "anthropic-messages" && model == &cfg.model && !disabled =>
|
|
||||||
{
|
|
||||||
push("assistant", item.clone())
|
|
||||||
}
|
}
|
||||||
Entry::Opaque { .. } => {}
|
Entry::Opaque { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let messages: Vec<Value> = messages
|
let messages: Vec<Value> = messages.into_iter().map(|(role, content)| json!({"role": role, "content": content})).collect();
|
||||||
.into_iter()
|
|
||||||
.map(|(role, content)| json!({"role": role, "content": content}))
|
|
||||||
.collect();
|
|
||||||
let tools: Vec<Value> = tools
|
let tools: Vec<Value> = tools
|
||||||
.iter()
|
.iter()
|
||||||
.map(
|
.map(|t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters}))
|
||||||
|t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters}),
|
|
||||||
)
|
|
||||||
.collect();
|
.collect();
|
||||||
let mut body = json!({
|
let body = json!({
|
||||||
"model": cfg.model,
|
"model": cfg.model,
|
||||||
"system": system,
|
"system": system,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"tools": tools,
|
"tools": tools,
|
||||||
"max_tokens": cfg.max_tokens.unwrap_or(32000),
|
"max_tokens": cfg.max_tokens,
|
||||||
});
|
});
|
||||||
// Unset: the model's default. Newer models think by default.
|
let url = format!("{}/v1/messages", cfg.base_url);
|
||||||
match cfg.thinking {
|
let resp = post(
|
||||||
None => {}
|
&url,
|
||||||
Some(Thinking::Off) => body["thinking"] = json!({"type": "disabled"}),
|
&[("x-api-key", cfg.token.clone()), ("anthropic-version", "2023-06-01".to_string())],
|
||||||
Some(_) => {
|
&body,
|
||||||
body["thinking"] = json!({"type": "adaptive"});
|
)
|
||||||
if let Some(effort) = effort {
|
.await?;
|
||||||
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 mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for block in resp["content"].as_array().cloned().unwrap_or_default() {
|
for block in resp["content"].as_array().cloned().unwrap_or_default() {
|
||||||
match block["type"].as_str() {
|
match block["type"].as_str() {
|
||||||
Some("text") => {
|
Some("text") => {
|
||||||
let text = block["text"].as_str().unwrap_or_default().to_string();
|
let text = block["text"].as_str().unwrap_or_default().to_string();
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
out.push(Entry::Assistant { text, phase: None });
|
out.push(Entry::Assistant { text });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("tool_use") => out.push(Entry::ToolCall {
|
Some("tool_use") => out.push(Entry::ToolCall {
|
||||||
@@ -560,98 +193,8 @@ async fn anthropic(
|
|||||||
name: block["name"].as_str().unwrap_or_default().to_string(),
|
name: block["name"].as_str().unwrap_or_default().to_string(),
|
||||||
arguments: block["input"].to_string(),
|
arguments: block["input"].to_string(),
|
||||||
}),
|
}),
|
||||||
Some("thinking") | Some("redacted_thinking") => out.push(Entry::Opaque {
|
|
||||||
api: "anthropic-messages".into(),
|
|
||||||
model: cfg.model.clone(),
|
|
||||||
item: block,
|
|
||||||
}),
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(out)
|
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))
|
|
||||||
}
|
|
||||||
|
|||||||
+491
-182
@@ -1,211 +1,520 @@
|
|||||||
//! Answer chat calls with an LLM and tools, under one lock for the complete turn.
|
//! The Sausage agent: a normal WASM actor that does inference over HTTP and
|
||||||
mod history;
|
//! grows the world through messages to `cargo` and `broker`.
|
||||||
|
//!
|
||||||
|
//! Configuration: `/data/config.json` (see `llm::Config`).
|
||||||
|
//! Conversation: `/data/history.json` (one shared conversation).
|
||||||
|
|
||||||
mod llm;
|
mod llm;
|
||||||
mod tools;
|
|
||||||
|
|
||||||
use history::{is_too_large, next_turn_start};
|
use std::collections::VecDeque;
|
||||||
use llm::{Config, Entry, Thinking};
|
|
||||||
use sausage_plugin::{Ctx, Error, Result, lock, rpc, schema};
|
use async_channel::{Receiver, Sender};
|
||||||
use serde_json::json;
|
use llm::{Config, Entry, Tool};
|
||||||
use std::io::Write;
|
use serde_json::{Value, json};
|
||||||
|
use wstd::future::FutureExt;
|
||||||
|
use wstd::io::{AsyncRead, AsyncWrite};
|
||||||
|
use wstd::time::{Duration, Instant};
|
||||||
|
|
||||||
/// The instructions sent with every inference request.
|
|
||||||
const SYSTEM: &str = include_str!("prompt.md");
|
const SYSTEM: &str = include_str!("prompt.md");
|
||||||
/// The shared, append-only conversation.
|
const CONFIG: &str = "/data/config.json";
|
||||||
const HISTORY: &str = "/state/agent/history.jsonl";
|
const HISTORY: &str = "/data/history.json";
|
||||||
/// The first retained turn after a context-size rejection.
|
/// An interrupted turn (sender and message ID), resumed at the next start.
|
||||||
const SKIP: &str = "/state/agent/history.skip";
|
const TURN: &str = "/data/turn.json";
|
||||||
/// Each chat process reads the shared history after it acquires the lock.
|
const MAX_STEPS: usize = 40;
|
||||||
struct Agent;
|
const MAX_HISTORY: usize = 600;
|
||||||
/// The person's next contribution to the conversation.
|
const MAX_TOOL_OUTPUT: usize = 30_000;
|
||||||
#[schema]
|
const MAX_NOTES: usize = 50;
|
||||||
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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rpc(grace_seconds = 600)]
|
fn main() {
|
||||||
impl Agent {
|
wstd::runtime::block_on(async {
|
||||||
/// Answer a person, with interim text notifications and a final text result.
|
let (tx, rx) = async_channel::unbounded();
|
||||||
///
|
wstd::runtime::spawn(read_stdin(tx)).detach();
|
||||||
/// The history lock covers inference and tools, so concurrent chats cannot interleave turns.
|
Agent::new(rx).run().await;
|
||||||
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)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.trim().parse().ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
start = next_turn_start(&entries, start, current).unwrap_or(current);
|
|
||||||
let definitions = tools::definitions();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
return Err(Error::new(-32603, error));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
std::fs::write(SKIP, start.to_string())?;
|
|
||||||
let has_tools = response
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
Entry::ToolCall {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
arguments,
|
|
||||||
} => calls.push((id.clone(), name.clone(), arguments.clone())),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
append(&mut entries, entry)?;
|
|
||||||
}
|
|
||||||
if !has_tools {
|
|
||||||
return Ok(final_text);
|
|
||||||
}
|
|
||||||
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 })?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read persisted entries in their original order. A new world has no history file.
|
/// Read NDJSON messages from stdin into the channel.
|
||||||
fn load_history(path: &str) -> Result<Vec<Entry>> {
|
async fn read_stdin(tx: Sender<Value>) {
|
||||||
match std::fs::read_to_string(path) {
|
let mut stdin = wstd::io::stdin();
|
||||||
Ok(text) => Ok(text
|
let mut buf = Vec::new();
|
||||||
.lines()
|
let mut chunk = vec![0u8; 16 * 1024];
|
||||||
.filter_map(|line| match serde_json::from_str(line) {
|
loop {
|
||||||
Ok(entry) => Some(entry),
|
match stdin.read(&mut chunk).await {
|
||||||
Err(error) => {
|
Ok(0) | Err(_) => break,
|
||||||
eprintln!("skip damaged history line: {error}");
|
Ok(n) => {
|
||||||
None
|
buf.extend_from_slice(&chunk[..n]);
|
||||||
|
while let Some(pos) = buf.iter().position(|b| *b == b'\n') {
|
||||||
|
let line: Vec<u8> = buf.drain(..=pos).collect();
|
||||||
|
if let Ok(v) = serde_json::from_slice::<Value>(&line)
|
||||||
|
&& tx.send(v).await.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()),
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
|
|
||||||
Err(error) => Err(error.into()),
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.close();
|
||||||
}
|
}
|
||||||
/// Persist an entry before it enters the current inference context.
|
|
||||||
fn append(history: &mut Vec<Entry>, entry: Entry) -> Result<()> {
|
fn truncate(mut s: String, max: usize) -> String {
|
||||||
let mut file = std::fs::OpenOptions::new()
|
if s.len() > max {
|
||||||
.create(true)
|
let mut end = max;
|
||||||
.append(true)
|
while !s.is_char_boundary(end) {
|
||||||
.open(HISTORY)?;
|
end -= 1;
|
||||||
file.write_all(format!("{}\n", serde_json::to_string(&entry)?).as_bytes())?;
|
}
|
||||||
history.push(entry);
|
let total = s.len();
|
||||||
Ok(())
|
s.truncate(end);
|
||||||
|
s.push_str(&format!("\n… (truncated, {total} bytes in total)"));
|
||||||
|
}
|
||||||
|
s
|
||||||
}
|
}
|
||||||
/// Complete interrupted tool pairs before a new inference request.
|
|
||||||
fn repair_tools(history: &mut Vec<Entry>) -> Result<()> {
|
fn tools() -> Vec<Tool> {
|
||||||
let answered: std::collections::HashSet<_> = history
|
vec![
|
||||||
|
Tool {
|
||||||
|
name: "send_message",
|
||||||
|
description: "Send a message to an actor. With wait_seconds > 0, wait for the reply (a message whose reply_to is the ID of this message). Returns the message ID, the reply (or null), and other messages that arrived meanwhile.",
|
||||||
|
parameters: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"to": {"type": "string", "description": "Actor ID"},
|
||||||
|
"body": {"type": "object", "description": "Message body. Must have a \"type\" field."},
|
||||||
|
"wait_seconds": {"type": "number", "description": "Seconds to wait for the reply. 0 = do not wait."}
|
||||||
|
},
|
||||||
|
"required": ["to", "body"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
name: "wait_for_messages",
|
||||||
|
description: "Wait up to `seconds` for messages to arrive and return them.",
|
||||||
|
parameters: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"seconds": {"type": "number"}},
|
||||||
|
"required": ["seconds"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
name: "read_file",
|
||||||
|
description: "Read a text file. Paths: /data/..., /public/..., /actor/<id>/...",
|
||||||
|
parameters: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string"},
|
||||||
|
"offset": {"type": "integer", "description": "Byte offset (default 0)"},
|
||||||
|
"max_bytes": {"type": "integer", "description": "Default 30000"}
|
||||||
|
},
|
||||||
|
"required": ["path"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
name: "list_dir",
|
||||||
|
description: "List a folder.",
|
||||||
|
parameters: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"path": {"type": "string"}},
|
||||||
|
"required": ["path"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
name: "write_file",
|
||||||
|
description: "Write a text file (only under /data or /public). Creates parent folders.",
|
||||||
|
parameters: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
|
||||||
|
"required": ["path", "content"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
name: "copy_dir",
|
||||||
|
description: "Copy a folder tree, for example /actor/<id>/source to /public/drafts/<id>. Skips target/ folders.",
|
||||||
|
parameters: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"from": {"type": "string"}, "to": {"type": "string"}},
|
||||||
|
"required": ["from", "to"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
name: "remove_path",
|
||||||
|
description: "Remove a file or folder under /data or /public.",
|
||||||
|
parameters: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"path": {"type": "string"}},
|
||||||
|
"required": ["path"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_tree(from: &str, to: &str) -> std::io::Result<u64> {
|
||||||
|
std::fs::create_dir_all(to)?;
|
||||||
|
let mut n = 0;
|
||||||
|
for entry in std::fs::read_dir(from)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let src = format!("{from}/{name}");
|
||||||
|
let dst = format!("{to}/{name}");
|
||||||
|
if entry.file_type()?.is_dir() {
|
||||||
|
if name != "target" {
|
||||||
|
n += copy_tree(&src, &dst)?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
std::fs::copy(&src, &dst)?;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Agent {
|
||||||
|
rx: Receiver<Value>,
|
||||||
|
stdout: wstd::io::Stdout,
|
||||||
|
history: Vec<Entry>,
|
||||||
|
/// Text messages that start a new turn later.
|
||||||
|
queue: VecDeque<Value>,
|
||||||
|
/// Other messages that arrived while the agent was idle.
|
||||||
|
notes: Vec<Value>,
|
||||||
|
seq: u64,
|
||||||
|
shutdown: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Agent {
|
||||||
|
fn new(rx: Receiver<Value>) -> Self {
|
||||||
|
let history = std::fs::read_to_string(HISTORY)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Self {
|
||||||
|
rx,
|
||||||
|
stdout: wstd::io::stdout(),
|
||||||
|
history,
|
||||||
|
queue: VecDeque::new(),
|
||||||
|
notes: Vec::new(),
|
||||||
|
seq: 0,
|
||||||
|
shutdown: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(mut self) {
|
||||||
|
eprintln!("agent: started with {} history entries", self.history.len());
|
||||||
|
self.resume().await;
|
||||||
|
while !self.shutdown {
|
||||||
|
let msg = match self.queue.pop_front() {
|
||||||
|
Some(m) => m,
|
||||||
|
None => match self.rx.recv().await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => break,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if msg["body"]["type"] == "shutdown" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if starts_turn(&msg) {
|
||||||
|
self.turn(msg).await;
|
||||||
|
} else {
|
||||||
|
self.notes.push(msg);
|
||||||
|
if self.notes.len() > MAX_NOTES {
|
||||||
|
self.notes.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.save();
|
||||||
|
eprintln!("agent: stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Continue a turn that a restart (usually an upgrade of the agent itself)
|
||||||
|
/// interrupted.
|
||||||
|
async fn resume(&mut self) {
|
||||||
|
let Some(turn) = std::fs::read_to_string(TURN).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = std::fs::remove_file(TURN);
|
||||||
|
let Ok(cfg) = std::fs::read_to_string(CONFIG).map_err(|_| ()).and_then(|s| serde_json::from_str::<Config>(&s).map_err(|_| ())) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Every tool call needs a result, or the APIs reject the history.
|
||||||
|
let answered: std::collections::HashSet<String> = self
|
||||||
|
.history
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|entry| match entry {
|
.filter_map(|e| match e {
|
||||||
Entry::ToolResult { id, .. } => Some(id.clone()),
|
Entry::ToolResult { id, .. } => Some(id.clone()),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let dangling: Vec<_> = history
|
let dangling: Vec<String> = self
|
||||||
|
.history
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|entry| match entry {
|
.filter_map(|e| match e {
|
||||||
Entry::ToolCall { id, .. } if !answered.contains(id) => Some(id.clone()),
|
Entry::ToolCall { id, .. } if !answered.contains(id) => Some(id.clone()),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
for id in dangling {
|
for id in dangling {
|
||||||
append(
|
self.history.push(Entry::ToolResult {
|
||||||
history,
|
|
||||||
Entry::ToolResult {
|
|
||||||
id,
|
id,
|
||||||
output: json!({"error":"interrupted before the tool finished"}).to_string(),
|
output: json!({"error": "interrupted: the agent restarted before this tool finished"}).to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let version = std::env::var("SAUSAGE_VERSION").unwrap_or_default();
|
||||||
|
self.history.push(Entry::User {
|
||||||
|
text: format!(
|
||||||
|
"[note from your runtime] You restarted and now run version {version}. The restart interrupted your last turn (probably your own upgrade). Check the state you need, then finish the turn and answer the original message."
|
||||||
|
),
|
||||||
|
});
|
||||||
|
eprintln!("agent: resuming an interrupted turn");
|
||||||
|
self.steps(&cfg, turn["from"].clone(), turn["id"].clone()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&mut self) {
|
||||||
|
if self.history.len() > MAX_HISTORY {
|
||||||
|
let start = self.history.len() - MAX_HISTORY;
|
||||||
|
let cut = (start..self.history.len())
|
||||||
|
.find(|i| matches!(self.history[*i], Entry::User { .. }))
|
||||||
|
.unwrap_or(start);
|
||||||
|
self.history.drain(..cut);
|
||||||
|
}
|
||||||
|
if let Ok(s) = serde_json::to_string(&self.history)
|
||||||
|
&& let Err(e) = std::fs::write(HISTORY, s)
|
||||||
|
{
|
||||||
|
eprintln!("agent: cannot save history: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn emit(&mut self, msg: Value) {
|
||||||
|
let mut line = msg.to_string();
|
||||||
|
line.push('\n');
|
||||||
|
if self.stdout.write_all(line.as_bytes()).await.is_err() || self.stdout.flush().await.is_err() {
|
||||||
|
eprintln!("agent: cannot write to stdout");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reply(&mut self, to: &Value, reply_to: &Value, text: String) {
|
||||||
|
self.emit(json!({"to": to, "reply_to": reply_to, "body": {"type": "text", "text": text}})).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort a message that arrived during a turn.
|
||||||
|
fn stash(&mut self, m: Value, others: &mut Vec<Value>) {
|
||||||
|
if m["body"]["type"] == "shutdown" {
|
||||||
|
self.shutdown = true;
|
||||||
|
} else if starts_turn(&m) {
|
||||||
|
others.push(json!({"queued_for_a_later_turn": true, "message": m}));
|
||||||
|
self.queue.push_back(m);
|
||||||
|
} else {
|
||||||
|
others.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn turn(&mut self, msg: Value) {
|
||||||
|
let from = msg["from"].clone();
|
||||||
|
let id = msg["id"].clone();
|
||||||
|
let cfg: Config = match std::fs::read_to_string(CONFIG).map_err(|e| e.to_string()).and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string())) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
self.reply(&from, &id, format!("I am not configured: cannot read {CONFIG}: {e}")).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut text = format!(
|
||||||
|
"Message {} from {}:\n{}",
|
||||||
|
id.as_str().unwrap_or("?"),
|
||||||
|
from.as_str().unwrap_or("?"),
|
||||||
|
msg["body"]["text"].as_str().unwrap_or("")
|
||||||
|
);
|
||||||
|
if !self.notes.is_empty() {
|
||||||
|
let notes: Vec<String> = self.notes.drain(..).map(|n| n.to_string()).collect();
|
||||||
|
text = format!("Messages that arrived while you were idle:\n{}\n\n{text}", notes.join("\n"));
|
||||||
|
}
|
||||||
|
self.history.push(Entry::User { text });
|
||||||
|
self.steps(&cfg, from, id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run inference and tools until the model answers. If a shutdown
|
||||||
|
/// interrupts the turn, remember it in TURN so the next start resumes it.
|
||||||
|
async fn steps(&mut self, cfg: &Config, from: Value, id: Value) {
|
||||||
|
let tools = tools();
|
||||||
|
let cfg = cfg.clone();
|
||||||
|
for _ in 0..MAX_STEPS {
|
||||||
|
let started = Instant::now();
|
||||||
|
let result = llm::complete(&cfg, SYSTEM, &tools, &self.history).await;
|
||||||
|
let entries = match result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("agent: inference failed: {e}");
|
||||||
|
self.history.push(Entry::Assistant { text: format!("(inference failed: {e})") });
|
||||||
|
self.save();
|
||||||
|
self.reply(&from, &id, format!("Inference failed: {e}")).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
eprintln!("agent: inference took {:?}", started.elapsed());
|
||||||
|
let calls: Vec<(String, String, String)> = entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
Entry::ToolCall { id, name, arguments } => Some((id.clone(), name.clone(), arguments.clone())),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let answer: Vec<String> = entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
Entry::Assistant { text } => Some(text.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
self.history.extend(entries);
|
||||||
|
if calls.is_empty() {
|
||||||
|
self.save();
|
||||||
|
let answer = if answer.is_empty() { "(no answer)".to_string() } else { answer.join("\n\n") };
|
||||||
|
self.reply(&from, &id, answer).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (call_id, name, args) in calls {
|
||||||
|
let output = self.tool(&name, &args).await;
|
||||||
|
self.history.push(Entry::ToolResult { id: call_id, output: truncate(output.to_string(), MAX_TOOL_OUTPUT) });
|
||||||
|
}
|
||||||
|
self.save();
|
||||||
|
if self.shutdown {
|
||||||
|
let _ = std::fs::write(TURN, json!({"from": from, "id": id}).to_string());
|
||||||
|
eprintln!("agent: turn interrupted by shutdown; it resumes after the restart");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.reply(&from, &id, format!("I stopped after {MAX_STEPS} steps. Tell me to continue if you want.")).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn tool(&mut self, name: &str, args: &str) -> Value {
|
||||||
|
let a: Value = match serde_json::from_str(args) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return json!({"error": format!("arguments are not valid JSON: {e}")}),
|
||||||
|
};
|
||||||
|
let path = |k: &str| a[k].as_str().unwrap_or_default().to_string();
|
||||||
|
let err = |e: std::io::Error| json!({"error": e.to_string()});
|
||||||
|
match name {
|
||||||
|
"send_message" => self.send_message(&a).await,
|
||||||
|
"wait_for_messages" => {
|
||||||
|
let secs = a["seconds"].as_f64().unwrap_or(10.0).clamp(0.0, 900.0);
|
||||||
|
let mut others = Vec::new();
|
||||||
|
match self.rx.recv().timeout(Duration::from_secs_f64(secs)).await {
|
||||||
|
Ok(Ok(m)) => {
|
||||||
|
self.stash(m, &mut others);
|
||||||
|
while let Ok(m) = self.rx.try_recv() {
|
||||||
|
self.stash(m, &mut others);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(_)) => self.shutdown = true,
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
json!({"messages": others})
|
||||||
|
}
|
||||||
|
"read_file" => {
|
||||||
|
let p = path("path");
|
||||||
|
match std::fs::read(&p) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let offset = (a["offset"].as_u64().unwrap_or(0) as usize).min(bytes.len());
|
||||||
|
let max = a["max_bytes"].as_u64().unwrap_or(30_000) as usize;
|
||||||
|
let end = (offset + max).min(bytes.len());
|
||||||
|
json!({
|
||||||
|
"path": p, "size": bytes.len(), "offset": offset,
|
||||||
|
"content": String::from_utf8_lossy(&bytes[offset..end]),
|
||||||
|
"truncated": end < bytes.len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"list_dir" => match std::fs::read_dir(path("path")) {
|
||||||
|
Ok(rd) => {
|
||||||
|
let mut entries: Vec<Value> = rd
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.take(500)
|
||||||
|
.map(|e| {
|
||||||
|
let meta = e.metadata().ok();
|
||||||
|
json!({
|
||||||
|
"name": e.file_name().to_string_lossy(),
|
||||||
|
"type": if meta.as_ref().is_some_and(|m| m.is_dir()) { "dir" } else { "file" },
|
||||||
|
"size": meta.map(|m| m.len()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
entries.sort_by(|x, y| x["name"].as_str().cmp(&y["name"].as_str()));
|
||||||
|
json!({"entries": entries})
|
||||||
|
}
|
||||||
|
Err(e) => err(e),
|
||||||
},
|
},
|
||||||
)?;
|
"write_file" => {
|
||||||
|
let p = path("path");
|
||||||
|
if let Some(parent) = std::path::Path::new(&p).parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
match std::fs::write(&p, a["content"].as_str().unwrap_or_default()) {
|
||||||
|
Ok(()) => json!({"written": p}),
|
||||||
|
Err(e) => err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"copy_dir" => match copy_tree(&path("from"), &path("to")) {
|
||||||
|
Ok(n) => json!({"copied_files": n}),
|
||||||
|
Err(e) => err(e),
|
||||||
|
},
|
||||||
|
"remove_path" => {
|
||||||
|
let p = path("path");
|
||||||
|
let r = if std::path::Path::new(&p).is_dir() { std::fs::remove_dir_all(&p) } else { std::fs::remove_file(&p) };
|
||||||
|
match r {
|
||||||
|
Ok(()) => json!({"removed": p}),
|
||||||
|
Err(e) => err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => json!({"error": format!("unknown tool {other}")}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_message(&mut self, a: &Value) -> Value {
|
||||||
|
let Some(to) = a["to"].as_str() else { return json!({"error": "missing \"to\""}) };
|
||||||
|
if !a["body"].is_object() {
|
||||||
|
return json!({"error": "\"body\" must be a JSON object"});
|
||||||
|
}
|
||||||
|
self.seq += 1;
|
||||||
|
let own = format!("a{}-{}", wstd_now_ms(), self.seq);
|
||||||
|
let full = format!("agent/{own}");
|
||||||
|
self.emit(json!({"to": to, "id": own, "body": a["body"]})).await;
|
||||||
|
let wait = a["wait_seconds"].as_f64().unwrap_or(0.0).clamp(0.0, 900.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.rx.recv().timeout(deadline).await {
|
||||||
|
Ok(Ok(m)) => {
|
||||||
|
if m["reply_to"] == full.as_str() {
|
||||||
|
reply = m;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
self.stash(m, &mut others);
|
||||||
|
if self.shutdown {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(_)) => {
|
||||||
|
// stdin closed: the broker stops us.
|
||||||
|
self.shutdown = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json!({"id": full, "reply": reply, "waited": wait > 0.0, "other_messages": others})
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
/// Start the plugin's JSON-RPC transport.
|
|
||||||
fn main() {
|
|
||||||
sausage_plugin::run(Agent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
/// A text message that is not a reply starts a turn. Replies never do, so
|
||||||
mod tests {
|
/// two actors cannot keep the agent in an endless conversation.
|
||||||
#[test]
|
fn starts_turn(m: &Value) -> bool {
|
||||||
fn damaged_history_keeps_valid_entries() {
|
m["body"]["type"] == "text" && m.get("reply_to").is_none_or(Value::is_null)
|
||||||
let path = if cfg!(target_os = "wasi") {
|
}
|
||||||
std::path::PathBuf::from("/history-test.jsonl")
|
|
||||||
} else {
|
fn wstd_now_ms() -> u64 {
|
||||||
std::env::temp_dir().join(format!("sausage-history-{}", std::process::id()))
|
std::time::SystemTime::now()
|
||||||
};
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
std::fs::write(
|
.map(|d| d.as_millis() as u64)
|
||||||
&path,
|
.unwrap_or(0)
|
||||||
"{\"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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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), "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+79
-9
@@ -1,10 +1,80 @@
|
|||||||
You are the Sausage agent. Answer the person. Use tools to inspect or change the world.
|
You are the agent of a Sausage world. Your actor ID is `agent`.
|
||||||
|
|
||||||
Read the contract at `/proc/core/PROTOCOL.md`. The Rust plugin SDK and its documentation are at `/plugins/cargo/sdk/`.
|
Sausage is a system of actors. Each actor is a WASI program (a `.wasm` file). Actors talk only through messages. People talk to you through other actors, for example `console` (a terminal) or later `matrix`. You help them by using actors, and by writing, testing, and upgrading actors. You can also change your own code.
|
||||||
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.
|
# Messages
|
||||||
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.
|
Every message has a sender (`from`), an ID, an optional `reply_to` (the ID of the message it answers), and a JSON `body`. A body is an object with a `"type"` field. There is no request/response: a reply is a new message with `reply_to` set. Delivery is at most once.
|
||||||
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.
|
A WASM actor reads one JSON object per line on stdin:
|
||||||
The interface check does not cover persistent state. Preserve state compatibility when you change a plugin.
|
`{"id":"m-17","from":"console","body":{...}}`
|
||||||
|
and writes one JSON object per line on stdout:
|
||||||
|
`{"to":"console","reply_to":"m-17","body":{...}}`
|
||||||
|
Without `"to"`, the broker publishes the message to the subscribers of the sender. An actor may set its own `"id"`. The broker then uses `"<sender>/<id>"`. stdout is only for messages. Logs go to stderr. The broker sends `{"type":"shutdown"}` before it stops an actor. EOF on stdin means "stop now".
|
||||||
|
|
||||||
|
# Filesystem
|
||||||
|
|
||||||
|
Each actor sees:
|
||||||
|
- `/data`: private state, read-write. Only the actor itself sees it.
|
||||||
|
- `/public`: its public files, read-write.
|
||||||
|
- `/actor/<id>/public`: the public files of every actor, read-only.
|
||||||
|
- `/actor/<id>/source`: the source of the running version of every actor, read-only.
|
||||||
|
- `/actor/<id>/status.json` and `/actor/<id>/stderr.log`: state and stderr log of every actor.
|
||||||
|
|
||||||
|
`ls /actor` lists all actors. A write becomes visible to other actors only when the writer closes the file. Send large or binary data as a file path, not in a message.
|
||||||
|
|
||||||
|
# Built-in actors
|
||||||
|
|
||||||
|
`broker` (only accepts `.wasm` files, knows no source language):
|
||||||
|
- `{"type":"spawn","actor":"<new-id>","wasm":"<path>","source":"<folder>","built_by":"cargo"}`
|
||||||
|
- `{"type":"upgrade","actor":"<id>","wasm":"<path>","source":"<folder>","built_by":"cargo"}`
|
||||||
|
- `{"type":"rollback","actor":"<id>"}` (optional `"version"`)
|
||||||
|
- `{"type":"stop","actor":"<id>"}`, `{"type":"start","actor":"<id>"}`, `{"type":"remove","actor":"<id>"}` (archives, never deletes)
|
||||||
|
- `{"type":"subscribe","actor":"<id>"}`, `{"type":"unsubscribe","actor":"<id>"}`, `{"type":"list"}`
|
||||||
|
|
||||||
|
Before an upgrade or spawn, the broker tests the candidate on a copy of the actor's `/data` and `/public`, with no HTTP access. The tests are message transcripts in `<source>/tests/*.jsonl`. Each line is `{"send": <body>}` or `{"expect": <subset of the reply body>}` (optional `"to"`). Without test files, the candidate must start and stay up for 2 seconds. If a new version crashes 3 times within 30 seconds, the broker rolls it back automatically. The broker keeps the message that crashed an actor and delivers it again after the next upgrade.
|
||||||
|
|
||||||
|
`cargo` (built-in Rust compiler):
|
||||||
|
- `{"type":"build","source":"<folder with Cargo.toml>"}` → `{"type":"built","wasm":"/actor/cargo/public/builds/<n>/<name>.wasm",...}` or `{"type":"build_failed","errors":"..."}`. A build can take a minute. Wait up to 600 seconds for the reply.
|
||||||
|
|
||||||
|
# Writing an actor in Rust
|
||||||
|
|
||||||
|
1. Write the crate to `/public/drafts/<name>/`: `Cargo.toml`, `src/main.rs`, and `tests/*.jsonl`. Use `edition = "2024"`. Use `serde_json` for messages.
|
||||||
|
2. Send `build` to `cargo` with the folder path `/actor/agent/public/drafts/<name>` and wait for the reply. Fix compiler errors and build again.
|
||||||
|
3. Send `spawn` (new actor) or `upgrade` (existing actor) to `broker` with the `wasm` path from the build reply and the same `source` folder. Wait for the reply.
|
||||||
|
4. Send a message to the new actor to check it.
|
||||||
|
|
||||||
|
A simple actor that only reacts to messages can block on stdin:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::io::{BufRead, Write};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut out = std::io::stdout().lock();
|
||||||
|
for line in std::io::stdin().lock().lines() {
|
||||||
|
let Ok(line) = line else { break };
|
||||||
|
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||||
|
let body = &msg["body"];
|
||||||
|
let reply = match body["type"].as_str() {
|
||||||
|
Some("shutdown") => break,
|
||||||
|
Some("ping") => json!({"type": "pong"}),
|
||||||
|
_ => json!({"type": "error", "reason": "unknown message"}),
|
||||||
|
};
|
||||||
|
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
|
||||||
|
out.flush().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
An actor that needs HTTP or timers and must also read messages uses the async `wstd` crate (`wstd = "0.6"`): `#[wstd::main] async fn main()`, `wstd::io::stdin()` (`AsyncRead`), `wstd::io::stdout()` (`AsyncWrite`, `write_all`, `flush`), `wstd::http::{Client, Request, Body}` (`Client::new().send(Request::get(url).body(Body::empty())?).await?`, then `response.into_body().contents().await?`), `wstd::task::sleep(wstd::time::Duration::from_secs(n)).await`, and `wstd::runtime::spawn(future)`. Actors have outgoing HTTP and HTTPS only, no raw sockets. Threads are not available. Store state in `/data` so it survives restarts and upgrades. A new version must read the data format of the old version.
|
||||||
|
|
||||||
|
# Changing yourself
|
||||||
|
|
||||||
|
Your own source is at `/actor/agent/source`. To change yourself, copy it to `/public/drafts/agent`, edit the copy, build it, and upgrade `agent`. The upgrade restarts you, and the conversation continues from `/data`. Be careful: if the new version cannot answer, the person must roll you back through the console. Change yourself only when the person asks for it.
|
||||||
|
|
||||||
|
# How to work
|
||||||
|
|
||||||
|
- Use tools to look at the real state before you claim anything. Read files instead of guessing.
|
||||||
|
- Keep answers short and concrete. Say what you changed and whether it worked.
|
||||||
|
- Your final text in a turn goes back to the person who wrote to you.
|
||||||
|
|||||||
@@ -1,268 +0,0 @@
|
|||||||
//! Tool descriptions and file operations for the agent.
|
|
||||||
|
|
||||||
use crate::llm::Tool;
|
|
||||||
use serde_json::{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.",
|
|
||||||
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."}
|
|
||||||
}),
|
|
||||||
&["method", "params"],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
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.",
|
|
||||||
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"}
|
|
||||||
}),
|
|
||||||
&["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.",
|
|
||||||
parameters: schema(
|
|
||||||
json!({"path": {"type": "string"}, "content": {"type": "string"}}),
|
|
||||||
&["path", "content"],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
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.",
|
|
||||||
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.",
|
|
||||||
parameters: schema(
|
|
||||||
json!({"from": {"type": "string"}, "to": {"type": "string"}}),
|
|
||||||
&["from", "to"],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 {
|
|
||||||
"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())
|
|
||||||
});
|
|
||||||
Ok(json!({
|
|
||||||
"path": path,
|
|
||||||
"size": bytes.len(),
|
|
||||||
"offset": offset,
|
|
||||||
"content": String::from_utf8_lossy(&bytes[offset..end]),
|
|
||||||
"truncated": end < bytes.len(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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() {
|
|
||||||
let _ = std::fs::create_dir_all(parent);
|
|
||||||
}
|
|
||||||
std::fs::write(path, args["content"].as_str().unwrap_or_default())?;
|
|
||||||
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())
|
|
||||||
.map(|entry| {
|
|
||||||
let metadata = entry.metadata().ok();
|
|
||||||
json!({
|
|
||||||
"name": entry.file_name().to_string_lossy(),
|
|
||||||
"type": if metadata.as_ref().is_some_and(|meta| meta.is_dir()) { "dir" } else { "file" },
|
|
||||||
"size": metadata.map(|meta| meta.len()),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
entries.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str()));
|
|
||||||
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(),
|
|
||||||
)?;
|
|
||||||
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;
|
|
||||||
for entry in std::fs::read_dir(from)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
|
||||||
let src = format!("{from}/{name}");
|
|
||||||
let dst = format!("{to}/{name}");
|
|
||||||
if entry.file_type()?.is_dir() {
|
|
||||||
if name != "target" {
|
|
||||||
count += copy_tree(&src, &dst)?;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
std::fs::copy(src, dst)?;
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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,3 @@
|
|||||||
|
// The agent answers a text message with text, even without config or network.
|
||||||
|
{"send": {"type": "text", "text": "Hello"}}
|
||||||
|
{"expect": {"type": "text"}}
|
||||||
+2
-21
@@ -2,7 +2,7 @@
|
|||||||
name = "sausage"
|
name = "sausage"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
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]]
|
[[bin]]
|
||||||
name = "sausage"
|
name = "sausage"
|
||||||
@@ -11,32 +11,13 @@ path = "src/main.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
http = "1"
|
http = "1"
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml_ng = "0.10"
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
toml_edit = "0.25"
|
|
||||||
wasmtime = "49"
|
wasmtime = "49"
|
||||||
wasmtime-wasi = "49"
|
wasmtime-wasi = "49"
|
||||||
wasmtime-wasi-http = "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,307 +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)
|
|
||||||
{
|
|
||||||
let grace = old.document["x-sausage-grace"]
|
|
||||||
.as_f64()
|
|
||||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
|
||||||
.unwrap_or(60.0);
|
|
||||||
upgrades.push(self.snapshot_upgrade(
|
|
||||||
&candidate.plugin,
|
|
||||||
old.hash.clone(),
|
|
||||||
&candidate.interface.hash,
|
|
||||||
grace,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
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,856 @@
|
|||||||
|
//! The broker: message routing, mailboxes, subscriptions, and the lifecycle of
|
||||||
|
//! WASM actors (spawn, upgrade, rollback, stop, start, remove).
|
||||||
|
//!
|
||||||
|
//! The broker accepts only `.wasm` files. It does not know any source language.
|
||||||
|
|
||||||
|
use std::collections::{BTreeSet, HashMap};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
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::fs::Mount;
|
||||||
|
use crate::runtime::{Exit, Instance, InstanceConfig, Runtime};
|
||||||
|
use crate::world::{self, ActorRecord, NATIVE, Registry, VersionMeta, World};
|
||||||
|
|
||||||
|
pub const MAILBOX_SIZE: usize = 1000;
|
||||||
|
pub const SHUTDOWN_DEADLINE: Duration = Duration::from_secs(5);
|
||||||
|
/// A new version that crashes this often within `CRASH_WINDOW` after a swap
|
||||||
|
/// is rolled back automatically.
|
||||||
|
const CRASH_LIMIT: usize = 3;
|
||||||
|
const CRASH_WINDOW: Duration = Duration::from_secs(30);
|
||||||
|
const STDIN_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
#[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()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Control {
|
||||||
|
Upgrade { version: u64, requester: String, reply: oneshot::Sender<Result<Value>> },
|
||||||
|
Rollback { target: Option<u64>, reply: oneshot::Sender<Result<Value>> },
|
||||||
|
Stop { reply: oneshot::Sender<Result<Value>> },
|
||||||
|
Start { reply: oneshot::Sender<Result<Value>> },
|
||||||
|
Remove { reply: oneshot::Sender<Result<Value>> },
|
||||||
|
Shutdown { reply: oneshot::Sender<Result<Value>> },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Broker {
|
||||||
|
pub world: World,
|
||||||
|
pub runtime: Arc<Runtime>,
|
||||||
|
mailboxes: Mutex<HashMap<String, mpsc::Sender<Envelope>>>,
|
||||||
|
controls: Mutex<HashMap<String, mpsc::Sender<Control>>>,
|
||||||
|
registry: Mutex<Registry>,
|
||||||
|
op_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||||
|
seq: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Broker {
|
||||||
|
pub fn new(world: World, runtime: Arc<Runtime>) -> Result<Arc<Self>> {
|
||||||
|
let registry = world.load_registry()?;
|
||||||
|
Ok(Arc::new(Self {
|
||||||
|
world,
|
||||||
|
runtime,
|
||||||
|
mailboxes: Mutex::new(HashMap::new()),
|
||||||
|
controls: Mutex::new(HashMap::new()),
|
||||||
|
registry: Mutex::new(registry),
|
||||||
|
op_locks: Mutex::new(HashMap::new()),
|
||||||
|
seq: AtomicU64::new(1),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn registry(&self) -> Registry {
|
||||||
|
self.registry.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_registry(&self, f: impl FnOnce(&mut Registry)) -> Result<()> {
|
||||||
|
let mut reg = self.registry.lock().unwrap();
|
||||||
|
f(&mut reg);
|
||||||
|
self.world.save_registry(®)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_id(&self) -> String {
|
||||||
|
format!("m-{}", self.seq.fetch_add(1, Ordering::Relaxed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a message from a native actor. `to: None` publishes to the
|
||||||
|
/// subscribers of `from`. Returns the message ID.
|
||||||
|
pub fn send(&self, from: &str, to: Option<&str>, id: Option<&str>, reply_to: Option<&str>, body: Value) -> String {
|
||||||
|
let id = match id {
|
||||||
|
Some(own) => format!("{from}/{own}"),
|
||||||
|
None => self.next_id(),
|
||||||
|
};
|
||||||
|
let recipients: Vec<String> = match to {
|
||||||
|
Some(to) => vec![to.to_string()],
|
||||||
|
None => self
|
||||||
|
.registry
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.subscriptions
|
||||||
|
.get(from)
|
||||||
|
.map(|s| s.iter().cloned().collect())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
};
|
||||||
|
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 parsed: Option<Value> = serde_json::from_str(line).ok();
|
||||||
|
let Some(Value::Object(obj)) = parsed 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 to = obj.get("to").and_then(Value::as_str);
|
||||||
|
let own_id = obj.get("id").and_then(|v| match v {
|
||||||
|
Value::String(s) => Some(s.clone()),
|
||||||
|
Value::Number(n) => Some(n.to_string()),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let reply_to = obj.get("reply_to").and_then(Value::as_str);
|
||||||
|
self.send(from, to, own_id.as_deref(), reply_to, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deliver(&self, env: Envelope) {
|
||||||
|
let tx = self.mailboxes.lock().unwrap().get(&env.to).cloned();
|
||||||
|
let error = match tx {
|
||||||
|
None => Some(format!("no actor with ID {}", env.to)),
|
||||||
|
Some(tx) => match tx.try_send(env.clone()) {
|
||||||
|
Ok(()) => None,
|
||||||
|
Err(mpsc::error::TrySendError::Full(_)) => Some(format!("mailbox of {} is full", env.to)),
|
||||||
|
Err(mpsc::error::TrySendError::Closed(_)) => Some(format!("actor {} is gone", env.to)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if let Some(reason) = error
|
||||||
|
&& env.from != "broker"
|
||||||
|
&& env.from != "test"
|
||||||
|
{
|
||||||
|
self.send(
|
||||||
|
"broker",
|
||||||
|
Some(&env.from),
|
||||||
|
None,
|
||||||
|
Some(&env.id),
|
||||||
|
json!({"type": "undeliverable", "reason": reason, "to": env.to}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mounts of a running actor.
|
||||||
|
pub fn mounts(&self, id: &str) -> Vec<Mount> {
|
||||||
|
vec![
|
||||||
|
Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: false },
|
||||||
|
Mount { guest: "/public".into(), host: self.world.public_dir(id), writable: true },
|
||||||
|
Mount { guest: "/data".into(), host: self.world.data_dir(id), writable: true },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Start the supervisors of all registered WASM actors.
|
||||||
|
pub fn start_all(self: &Arc<Self>) {
|
||||||
|
let reg = self.registry();
|
||||||
|
for (id, rec) in reg.actors {
|
||||||
|
self.start_supervisor(&id, rec.version, rec.running);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
let sv = Supervisor::new(self.clone(), id.to_string(), version, running, mailbox, ctl_rx);
|
||||||
|
tokio::spawn(sv.run());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn control(&self, id: &str, make: impl FnOnce(oneshot::Sender<Result<Value>>) -> 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 (host shutdown). The registry keeps their
|
||||||
|
/// `running` flags, so the next `sausage run` starts them again.
|
||||||
|
pub async fn shutdown_all(&self) {
|
||||||
|
let ids: Vec<String> = self.controls.lock().unwrap().keys().cloned().collect();
|
||||||
|
let mut waits = Vec::new();
|
||||||
|
for id in ids {
|
||||||
|
waits.push(async move {
|
||||||
|
let _ = self.control(&id, |reply| Control::Shutdown { reply }).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
futures_join_all(waits).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a new version: copy the `.wasm` file and the source folder into
|
||||||
|
/// the version store. Returns the version directory and the component.
|
||||||
|
fn store_version(&self, id: &str, version: u64, wasm: &Path, source: Option<&Path>, meta: &VersionMeta) -> Result<(PathBuf, Component)> {
|
||||||
|
let dir = self.world.version_dir(id, version);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
std::fs::copy(wasm, dir.join("actor.wasm")).with_context(|| format!("copy {}", wasm.display()))?;
|
||||||
|
match source {
|
||||||
|
Some(src) => world::copy_tree(src, &dir.join("source"))?,
|
||||||
|
None => std::fs::create_dir_all(dir.join("source"))?,
|
||||||
|
}
|
||||||
|
world::write_atomic(&dir.join("meta.json"), serde_json::to_string_pretty(meta)?.as_bytes())?;
|
||||||
|
let component = self.runtime.load(&dir.join("actor.wasm"))?;
|
||||||
|
Ok((dir, component))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new actor from a `.wasm` file. With `start`, also start its
|
||||||
|
/// supervisor (in a running host). Without `start` (during `init`), only
|
||||||
|
/// register it.
|
||||||
|
pub async fn create_actor(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
id: &str,
|
||||||
|
wasm: &Path,
|
||||||
|
source: Option<&Path>,
|
||||||
|
meta: VersionMeta,
|
||||||
|
start: bool,
|
||||||
|
) -> Result<Value> {
|
||||||
|
if !world::valid_id(id) {
|
||||||
|
bail!("invalid actor ID {id:?}: use a-z, 0-9, '-' and '_'");
|
||||||
|
}
|
||||||
|
if NATIVE.contains(&id) || self.registry().actors.contains_key(id) {
|
||||||
|
bail!("an actor with ID {id} already exists");
|
||||||
|
}
|
||||||
|
let lock = self.op_lock(id);
|
||||||
|
let _guard = lock.lock().await;
|
||||||
|
let (dir, component) = self.store_version(id, 1, wasm, source, &meta)?;
|
||||||
|
let report = candidate::test(self, id, &component, &dir.join("source"), None, None).await;
|
||||||
|
let report = match report {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = std::fs::remove_dir_all(self.world.versions_dir(id));
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.world.ensure_actor_dirs(id)?;
|
||||||
|
world::replace_tree(&dir.join("source"), &self.world.source_dir(id))?;
|
||||||
|
self.update_registry(|r| {
|
||||||
|
r.actors.insert(id.to_string(), ActorRecord { version: 1, next_version: 2, running: true });
|
||||||
|
})?;
|
||||||
|
write_status(&self.world, id, &json!({"id": id, "version": 1, "state": "created"}));
|
||||||
|
if start {
|
||||||
|
self.start_supervisor(id, 1, true);
|
||||||
|
}
|
||||||
|
Ok(json!({"type": "spawned", "actor": id, "version": 1, "tests": report}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upgrade_actor(self: &Arc<Self>, id: &str, wasm: &Path, source: Option<&Path>, mut meta: VersionMeta) -> Result<Value> {
|
||||||
|
let lock = self.op_lock(id);
|
||||||
|
let _guard = lock.lock().await;
|
||||||
|
let rec = self.registry().actors.get(id).cloned().ok_or_else(|| anyhow!("no WASM actor with ID {id}"))?;
|
||||||
|
let version = rec.next_version;
|
||||||
|
self.update_registry(|r| {
|
||||||
|
if let Some(a) = r.actors.get_mut(id) {
|
||||||
|
a.next_version = version + 1;
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
meta.version = version;
|
||||||
|
let (dir, component) = self.store_version(id, version, wasm, source, &meta)?;
|
||||||
|
let report = candidate::test(
|
||||||
|
self,
|
||||||
|
id,
|
||||||
|
&component,
|
||||||
|
&dir.join("source"),
|
||||||
|
Some(&self.world.data_dir(id)),
|
||||||
|
Some(&self.world.public_dir(id)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Err(e) = report {
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
let mut result = self
|
||||||
|
.control(id, |reply| Control::Upgrade { version, requester: meta.requested_by.clone(), reply })
|
||||||
|
.await?;
|
||||||
|
result["tests"] = report.unwrap();
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle one message to `broker`.
|
||||||
|
async fn handle(self: Arc<Self>, env: Envelope) {
|
||||||
|
let result = self.clone().command(&env).await;
|
||||||
|
let body = match result {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(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.get("type").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let actor = || -> Result<String> {
|
||||||
|
b.get("actor").and_then(Value::as_str).map(str::to_string).ok_or_else(|| anyhow!("missing \"actor\""))
|
||||||
|
};
|
||||||
|
let meta = |version: u64| -> Result<(PathBuf, Option<PathBuf>, VersionMeta)> {
|
||||||
|
let wasm_s = b.get("wasm").and_then(Value::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.get("source").and_then(Value::as_str);
|
||||||
|
let source = source_s.map(|s| self.world.resolve(&env.from, s)).transpose()?;
|
||||||
|
if let Some(s) = &source
|
||||||
|
&& !s.is_dir()
|
||||||
|
{
|
||||||
|
bail!("{} is not a folder", source_s.unwrap());
|
||||||
|
}
|
||||||
|
let m = VersionMeta {
|
||||||
|
version,
|
||||||
|
wasm: wasm_s.to_string(),
|
||||||
|
source: source_s.map(str::to_string),
|
||||||
|
built_by: b.get("built_by").and_then(Value::as_str).map(str::to_string),
|
||||||
|
requested_by: env.from.clone(),
|
||||||
|
created_at: world::now(),
|
||||||
|
};
|
||||||
|
Ok((wasm, source, m))
|
||||||
|
};
|
||||||
|
match ty {
|
||||||
|
"spawn" => {
|
||||||
|
let id = actor()?;
|
||||||
|
let (wasm, source, m) = meta(1)?;
|
||||||
|
self.create_actor(&id, &wasm, source.as_deref(), m, true).await
|
||||||
|
}
|
||||||
|
"upgrade" => {
|
||||||
|
let id = actor()?;
|
||||||
|
let (wasm, source, m) = meta(0)?;
|
||||||
|
self.upgrade_actor(&id, &wasm, source.as_deref(), m).await
|
||||||
|
}
|
||||||
|
"rollback" => {
|
||||||
|
let id = actor()?;
|
||||||
|
let target = b.get("version").and_then(Value::as_u64);
|
||||||
|
let lock = self.op_lock(&id);
|
||||||
|
let _g = lock.lock().await;
|
||||||
|
self.control(&id, |reply| Control::Rollback { target, reply }).await
|
||||||
|
}
|
||||||
|
"stop" => self.control(&actor()?, |reply| Control::Stop { reply }).await,
|
||||||
|
"start" => self.control(&actor()?, |reply| Control::Start { reply }).await,
|
||||||
|
"remove" => {
|
||||||
|
let id = actor()?;
|
||||||
|
let lock = self.op_lock(&id);
|
||||||
|
let _g = lock.lock().await;
|
||||||
|
let r = self.control(&id, |reply| Control::Remove { reply }).await?;
|
||||||
|
self.mailboxes.lock().unwrap().remove(&id);
|
||||||
|
self.controls.lock().unwrap().remove(&id);
|
||||||
|
self.update_registry(|reg| {
|
||||||
|
reg.actors.remove(&id);
|
||||||
|
reg.subscriptions.remove(&id);
|
||||||
|
for subs in reg.subscriptions.values_mut() {
|
||||||
|
subs.remove(&id);
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(r)
|
||||||
|
}
|
||||||
|
"subscribe" | "unsubscribe" => {
|
||||||
|
let publisher = actor()?;
|
||||||
|
let sub = env.from.clone();
|
||||||
|
let on = ty == "subscribe";
|
||||||
|
self.update_registry(|reg| {
|
||||||
|
let set = reg.subscriptions.entry(publisher.clone()).or_insert_with(BTreeSet::new);
|
||||||
|
if on {
|
||||||
|
set.insert(sub.clone());
|
||||||
|
} else {
|
||||||
|
set.remove(&sub);
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(json!({"type": format!("{ty}d"), "actor": publisher}))
|
||||||
|
}
|
||||||
|
"list" => {
|
||||||
|
let reg = self.registry();
|
||||||
|
let mut actors: Vec<Value> = NATIVE.iter().map(|id| json!({"id": id, "kind": "native"})).collect();
|
||||||
|
for (id, rec) in reg.actors {
|
||||||
|
let status = std::fs::read_to_string(self.world.actor_dir(&id).join("status.json"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str::<Value>(&s).ok());
|
||||||
|
actors.push(json!({
|
||||||
|
"id": id, "kind": "wasm", "version": rec.version, "running": rec.running,
|
||||||
|
"state": status.as_ref().and_then(|s| s.get("state")).cloned(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(json!({"type": "actors", "actors": actors, "subscriptions": reg.subscriptions}))
|
||||||
|
}
|
||||||
|
"shutdown" => Ok(json!({"type": "ignored"})),
|
||||||
|
other => bail!("unknown broker request {other:?}. Known: spawn, upgrade, rollback, stop, start, remove, subscribe, unsubscribe, list"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The native `broker` actor.
|
||||||
|
pub async fn run_actor(self: Arc<Self>, mut mailbox: mpsc::Receiver<Envelope>) {
|
||||||
|
while let Some(env) = mailbox.recv().await {
|
||||||
|
tokio::spawn(self.clone().handle(env));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn futures_join_all<F: Future<Output = ()>>(futs: Vec<F>) {
|
||||||
|
let mut set = Vec::new();
|
||||||
|
for f in futs {
|
||||||
|
set.push(Box::pin(f));
|
||||||
|
}
|
||||||
|
for f in set {
|
||||||
|
f.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_status(world: &World, id: &str, status: &Value) {
|
||||||
|
let mut s = status.clone();
|
||||||
|
s["updated_at"] = json!(world::now());
|
||||||
|
let _ = world::write_atomic(
|
||||||
|
&world.actor_dir(id).join("status.json"),
|
||||||
|
serde_json::to_string_pretty(&s).unwrap_or_default().as_bytes(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Supervisor: owns one WASM actor and its instance.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Supervisor {
|
||||||
|
broker: Arc<Broker>,
|
||||||
|
id: String,
|
||||||
|
version: u64,
|
||||||
|
desired_running: bool,
|
||||||
|
mailbox: mpsc::Receiver<Envelope>,
|
||||||
|
control: mpsc::Receiver<Control>,
|
||||||
|
component: Option<Component>,
|
||||||
|
instance: Option<Instance>,
|
||||||
|
state: &'static str,
|
||||||
|
started_at: Instant,
|
||||||
|
restarts: u32,
|
||||||
|
restart_at: Option<Instant>,
|
||||||
|
last_exit: Option<String>,
|
||||||
|
last_delivered: Option<Envelope>,
|
||||||
|
/// The message that the actor got just before it crashed. The broker
|
||||||
|
/// delivers it again after the next upgrade.
|
||||||
|
parked: Option<Envelope>,
|
||||||
|
redeliver: Option<Envelope>,
|
||||||
|
previous: Option<u64>,
|
||||||
|
swapped_at: Option<Instant>,
|
||||||
|
crashes_since_swap: usize,
|
||||||
|
upgrade_requester: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recv_line(instance: &mut Option<Instance>) -> Option<String> {
|
||||||
|
match instance {
|
||||||
|
Some(i) => i.stdout.recv().await,
|
||||||
|
None => std::future::pending().await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sleep_until_opt(t: Option<Instant>) {
|
||||||
|
match t {
|
||||||
|
Some(t) => tokio::time::sleep_until(t.into()).await,
|
||||||
|
None => std::future::pending().await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Supervisor {
|
||||||
|
fn new(
|
||||||
|
broker: Arc<Broker>,
|
||||||
|
id: String,
|
||||||
|
version: u64,
|
||||||
|
running: bool,
|
||||||
|
mailbox: mpsc::Receiver<Envelope>,
|
||||||
|
control: mpsc::Receiver<Control>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
broker,
|
||||||
|
id,
|
||||||
|
version,
|
||||||
|
desired_running: running,
|
||||||
|
mailbox,
|
||||||
|
control,
|
||||||
|
component: None,
|
||||||
|
instance: None,
|
||||||
|
state: if running { "starting" } else { "stopped" },
|
||||||
|
started_at: Instant::now(),
|
||||||
|
restarts: 0,
|
||||||
|
restart_at: None,
|
||||||
|
last_exit: None,
|
||||||
|
last_delivered: None,
|
||||||
|
parked: None,
|
||||||
|
redeliver: None,
|
||||||
|
previous: None,
|
||||||
|
swapped_at: None,
|
||||||
|
crashes_since_swap: 0,
|
||||||
|
upgrade_requester: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn world(&self) -> &World {
|
||||||
|
&self.broker.world
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status(&self) {
|
||||||
|
write_status(
|
||||||
|
self.world(),
|
||||||
|
&self.id,
|
||||||
|
&json!({
|
||||||
|
"id": self.id,
|
||||||
|
"version": self.version,
|
||||||
|
"state": self.state,
|
||||||
|
"restarts": self.restarts,
|
||||||
|
"last_exit": self.last_exit,
|
||||||
|
"parked_message": self.parked.as_ref().map(|m| json!({"id": m.id, "from": m.from, "body": m.body})),
|
||||||
|
"previous_version": self.previous,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(mut self) {
|
||||||
|
self.status();
|
||||||
|
loop {
|
||||||
|
if self.desired_running && self.instance.is_none() && self.restart_at.is_none_or(|t| Instant::now() >= t) {
|
||||||
|
self.restart_at = None;
|
||||||
|
self.start_instance().await;
|
||||||
|
}
|
||||||
|
let has_instance = self.instance.is_some();
|
||||||
|
let waiting = !has_instance && self.desired_running;
|
||||||
|
tokio::select! {
|
||||||
|
ctl = self.control.recv() => {
|
||||||
|
let Some(ctl) = ctl else { break };
|
||||||
|
if self.handle_control(ctl).await {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg = self.mailbox.recv(), if has_instance => {
|
||||||
|
let Some(msg) = msg else { break };
|
||||||
|
self.deliver(msg).await;
|
||||||
|
}
|
||||||
|
line = recv_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_opt(self.restart_at), if waiting => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn deliver(&mut self, msg: Envelope) {
|
||||||
|
let Some(inst) = self.instance.as_mut() else { return };
|
||||||
|
let line = msg.to_line();
|
||||||
|
match tokio::time::timeout(STDIN_WRITE_TIMEOUT, inst.send_line(&line)).await {
|
||||||
|
Ok(Ok(())) => self.last_delivered = Some(msg),
|
||||||
|
Ok(Err(_)) => {} // The instance is ending. The exit path handles it.
|
||||||
|
Err(_) => {
|
||||||
|
let note = format!("actor did not read stdin for {STDIN_WRITE_TIMEOUT:?}; message {} dropped", msg.id);
|
||||||
|
self.broker.log(&self.id, ¬e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_instance(&mut self) {
|
||||||
|
if self.component.is_none() {
|
||||||
|
let path = self.world().version_dir(&self.id, self.version).join("actor.wasm");
|
||||||
|
let t = Instant::now();
|
||||||
|
match self.broker.runtime.load(&path) {
|
||||||
|
Ok(c) => {
|
||||||
|
self.broker.log(&self.id, &format!("compiled version {} in {} ms", self.version, t.elapsed().as_millis()));
|
||||||
|
self.component = Some(c);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.broker.log(&self.id, &format!("cannot load version {}: {e:#}", self.version));
|
||||||
|
self.state = "broken";
|
||||||
|
self.desired_running = false;
|
||||||
|
self.status();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cfg = InstanceConfig {
|
||||||
|
mounts: self.broker.mounts(&self.id),
|
||||||
|
allow_http: true,
|
||||||
|
stderr_log: self.world().actor_dir(&self.id).join("stderr.log"),
|
||||||
|
env: vec![
|
||||||
|
("SAUSAGE_ACTOR".into(), self.id.clone()),
|
||||||
|
("SAUSAGE_VERSION".into(), self.version.to_string()),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
match self.broker.runtime.start(self.component.as_ref().unwrap(), cfg) {
|
||||||
|
Ok(inst) => {
|
||||||
|
self.instance = Some(inst);
|
||||||
|
self.state = "running";
|
||||||
|
self.started_at = Instant::now();
|
||||||
|
self.status();
|
||||||
|
if let Some(msg) = self.redeliver.take() {
|
||||||
|
self.broker.log(&self.id, &format!("delivering parked message {} again", msg.id));
|
||||||
|
self.deliver(msg).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.broker.log(&self.id, &format!("cannot start: {e:#}"));
|
||||||
|
self.state = "broken";
|
||||||
|
self.desired_running = false;
|
||||||
|
self.status();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_instance(&mut self) -> Option<Exit> {
|
||||||
|
let inst = self.instance.take()?;
|
||||||
|
let exit = inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE).await;
|
||||||
|
self.broker.log(&self.id, &format!("stopped: {exit}"));
|
||||||
|
self.last_exit = Some(exit.to_string());
|
||||||
|
Some(exit)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_exit(&mut self, exit: Exit) {
|
||||||
|
self.broker.log(&self.id, &format!("instance ended unexpectedly: {exit}"));
|
||||||
|
self.last_exit = Some(exit.to_string());
|
||||||
|
if !exit.is_clean()
|
||||||
|
&& let Some(m) = self.last_delivered.take()
|
||||||
|
{
|
||||||
|
self.parked = Some(m);
|
||||||
|
}
|
||||||
|
if self.started_at.elapsed() > Duration::from_secs(60) {
|
||||||
|
self.restarts = 0;
|
||||||
|
}
|
||||||
|
if let (Some(prev), Some(at)) = (self.previous, self.swapped_at)
|
||||||
|
&& at.elapsed() < CRASH_WINDOW
|
||||||
|
{
|
||||||
|
self.crashes_since_swap += 1;
|
||||||
|
if self.crashes_since_swap >= CRASH_LIMIT {
|
||||||
|
let from = self.version;
|
||||||
|
self.broker.log(&self.id, &format!("version {from} crashed {CRASH_LIMIT} times after the upgrade; rolling back to {prev}"));
|
||||||
|
let result = self.rollback(Some(prev)).await;
|
||||||
|
let body = match result {
|
||||||
|
Ok(_) => json!({"type": "rolled_back", "actor": self.id, "from": from, "to": prev, "reason": "crash loop after upgrade", "last_exit": exit.to_string()}),
|
||||||
|
Err(e) => json!({"type": "error", "actor": self.id, "reason": format!("automatic rollback failed: {e:#}")}),
|
||||||
|
};
|
||||||
|
if let Some(r) = self.upgrade_requester.clone() {
|
||||||
|
self.broker.send("broker", Some(&r), None, None, body);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let delay = Duration::from_millis(500 * 2u64.pow(self.restarts.min(6))).min(Duration::from_secs(30));
|
||||||
|
self.restarts += 1;
|
||||||
|
self.restart_at = Some(Instant::now() + delay);
|
||||||
|
self.state = "restarting";
|
||||||
|
self.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_current(&self) -> Result<()> {
|
||||||
|
let snap = self.world().version_dir(&self.id, self.version).join("snapshot");
|
||||||
|
world::replace_tree(&self.world().data_dir(&self.id), &snap.join("data"))?;
|
||||||
|
world::replace_tree(&self.world().public_dir(&self.id), &snap.join("public"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(&mut self, version: u64) -> Result<()> {
|
||||||
|
let src = self.world().version_dir(&self.id, version).join("source");
|
||||||
|
world::replace_tree(&src, &self.world().source_dir(&self.id))?;
|
||||||
|
self.version = version;
|
||||||
|
self.component = None;
|
||||||
|
let id = self.id.clone();
|
||||||
|
self.broker.update_registry(|r| {
|
||||||
|
if let Some(a) = r.actors.get_mut(&id) {
|
||||||
|
a.version = version;
|
||||||
|
a.running = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upgrade(&mut self, version: u64, requester: String) -> Result<Value> {
|
||||||
|
self.stop_instance().await;
|
||||||
|
self.snapshot_current()?;
|
||||||
|
let previous = self.version;
|
||||||
|
self.install(version)?;
|
||||||
|
self.previous = Some(previous);
|
||||||
|
self.swapped_at = Some(Instant::now());
|
||||||
|
self.crashes_since_swap = 0;
|
||||||
|
self.restarts = 0;
|
||||||
|
self.restart_at = None;
|
||||||
|
self.upgrade_requester = Some(requester);
|
||||||
|
self.redeliver = self.parked.take();
|
||||||
|
self.desired_running = true;
|
||||||
|
self.start_instance().await;
|
||||||
|
Ok(json!({"type": "upgraded", "actor": self.id, "version": version, "previous": previous}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rollback(&mut self, target: Option<u64>) -> Result<Value> {
|
||||||
|
let target = match target {
|
||||||
|
Some(t) => t,
|
||||||
|
None => (1..self.version)
|
||||||
|
.rev()
|
||||||
|
.find(|v| self.world().version_dir(&self.id, *v).join("actor.wasm").is_file())
|
||||||
|
.ok_or_else(|| anyhow!("{} has no version before {}", self.id, self.version))?,
|
||||||
|
};
|
||||||
|
let dir = self.world().version_dir(&self.id, target);
|
||||||
|
if !dir.join("actor.wasm").is_file() {
|
||||||
|
bail!("{} has no version {target}", self.id);
|
||||||
|
}
|
||||||
|
self.stop_instance().await;
|
||||||
|
self.snapshot_current()?;
|
||||||
|
let from = self.version;
|
||||||
|
let snap = dir.join("snapshot");
|
||||||
|
if snap.is_dir() {
|
||||||
|
world::replace_tree(&snap.join("data"), &self.world().data_dir(&self.id))?;
|
||||||
|
world::replace_tree(&snap.join("public"), &self.world().public_dir(&self.id))?;
|
||||||
|
}
|
||||||
|
self.install(target)?;
|
||||||
|
self.previous = None;
|
||||||
|
self.swapped_at = None;
|
||||||
|
self.restarts = 0;
|
||||||
|
self.restart_at = None;
|
||||||
|
self.desired_running = true;
|
||||||
|
self.start_instance().await;
|
||||||
|
Ok(json!({"type": "rolled_back", "actor": self.id, "from": from, "to": target, "restored_state": snap.is_dir()}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when the supervisor must end.
|
||||||
|
async fn handle_control(&mut self, ctl: Control) -> bool {
|
||||||
|
match ctl {
|
||||||
|
Control::Upgrade { version, requester, reply } => {
|
||||||
|
let r = self.upgrade(version, requester).await;
|
||||||
|
let _ = reply.send(r);
|
||||||
|
}
|
||||||
|
Control::Rollback { target, reply } => {
|
||||||
|
let r = self.rollback(target).await;
|
||||||
|
let _ = reply.send(r);
|
||||||
|
}
|
||||||
|
Control::Stop { reply } => {
|
||||||
|
self.desired_running = false;
|
||||||
|
self.restart_at = None;
|
||||||
|
self.stop_instance().await;
|
||||||
|
self.state = "stopped";
|
||||||
|
let id = self.id.clone();
|
||||||
|
let r = self.broker.update_registry(|r| {
|
||||||
|
if let Some(a) = r.actors.get_mut(&id) {
|
||||||
|
a.running = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _ = reply.send(r.map(|_| json!({"type": "stopped", "actor": self.id})));
|
||||||
|
}
|
||||||
|
Control::Start { reply } => {
|
||||||
|
self.desired_running = true;
|
||||||
|
self.restart_at = None;
|
||||||
|
let id = self.id.clone();
|
||||||
|
let r = self.broker.update_registry(|r| {
|
||||||
|
if let Some(a) = r.actors.get_mut(&id) {
|
||||||
|
a.running = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _ = reply.send(r.map(|_| json!({"type": "started", "actor": self.id})));
|
||||||
|
}
|
||||||
|
Control::Remove { reply } => {
|
||||||
|
self.desired_running = false;
|
||||||
|
self.stop_instance().await;
|
||||||
|
let r = self.archive();
|
||||||
|
let _ = reply.send(r);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Control::Shutdown { reply } => {
|
||||||
|
self.stop_instance().await;
|
||||||
|
self.state = "host stopped";
|
||||||
|
self.status();
|
||||||
|
let _ = reply.send(Ok(json!({"type": "shut_down"})));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.status();
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move every folder of the actor into `archive/`. Nothing is deleted.
|
||||||
|
fn archive(&self) -> Result<Value> {
|
||||||
|
let dest = self.world().root.join("archive").join(format!("{}-{}", self.id, world::now()));
|
||||||
|
std::fs::create_dir_all(&dest)?;
|
||||||
|
for (from, name) in [
|
||||||
|
(self.world().actor_dir(&self.id), "actor"),
|
||||||
|
(self.world().root.join("private").join(&self.id), "private"),
|
||||||
|
(self.world().versions_dir(&self.id), "versions"),
|
||||||
|
] {
|
||||||
|
if from.exists() {
|
||||||
|
std::fs::rename(&from, dest.join(name))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(json!({"type": "removed", "actor": self.id, "archive": dest.display().to_string()}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
//! Tests for a candidate version, before the broker installs it.
|
||||||
|
//!
|
||||||
|
//! A test is a message transcript in `source/tests/*.jsonl`. Each line is one
|
||||||
|
//! of:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! {"send": {...body...}} send a message to the candidate
|
||||||
|
//! {"send": {...}, "from": "someone"} with an explicit sender
|
||||||
|
//! {"expect": {...subset of body...}} the next message from the candidate
|
||||||
|
//! {"expect": {...}, "to": "someone"} and its recipient
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Each file runs in a fresh instance on a copy of the actor's `/data` and
|
||||||
|
//! `/public`. The candidate has no HTTP access. Without test files, the
|
||||||
|
//! candidate must start and stay up for `SMOKE_TIME`.
|
||||||
|
|
||||||
|
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::fs::Mount;
|
||||||
|
use crate::runtime::{Instance, InstanceConfig};
|
||||||
|
use crate::world;
|
||||||
|
|
||||||
|
const SMOKE_TIME: Duration = Duration::from_secs(2);
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn test(
|
||||||
|
broker: &Broker,
|
||||||
|
id: &str,
|
||||||
|
component: &Component,
|
||||||
|
source: &Path,
|
||||||
|
data: Option<&Path>,
|
||||||
|
public: Option<&Path>,
|
||||||
|
) -> Result<Value> {
|
||||||
|
let mut files: Vec<_> = std::fs::read_dir(source.join("tests"))
|
||||||
|
.map(|rd| {
|
||||||
|
rd.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| p.extension().is_some_and(|x| x == "jsonl"))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
files.sort();
|
||||||
|
|
||||||
|
if files.is_empty() {
|
||||||
|
let (mut inst, scratch) = start(broker, id, component, data, public)?;
|
||||||
|
tokio::time::sleep(SMOKE_TIME).await;
|
||||||
|
if inst.is_finished() {
|
||||||
|
let exit = inst.wait().await;
|
||||||
|
bail!(
|
||||||
|
"candidate ended during the smoke test ({exit}). stderr:\n{}",
|
||||||
|
stderr_tail(&scratch.dir.join("stderr.log"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE).await;
|
||||||
|
return Ok(json!({"smoke_test": "passed", "files": 0}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut passed = Vec::new();
|
||||||
|
for file in &files {
|
||||||
|
let name = file.file_name().unwrap().to_string_lossy().to_string();
|
||||||
|
run_file(broker, id, component, data, public, file)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("test {name} failed: {e:#}"))?;
|
||||||
|
passed.push(name);
|
||||||
|
}
|
||||||
|
Ok(json!({"passed": passed}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(broker: &Broker, id: &str, component: &Component, data: Option<&Path>, public: Option<&Path>) -> Result<(Instance, Scratch)> {
|
||||||
|
let dir = broker.world.scratch_dir().join(format!("{id}-{}-{}", world::now(), rand_suffix()));
|
||||||
|
let scratch = Scratch { dir: dir.clone() };
|
||||||
|
std::fs::create_dir_all(dir.join("data"))?;
|
||||||
|
std::fs::create_dir_all(dir.join("public"))?;
|
||||||
|
if let Some(d) = data.filter(|d| d.is_dir()) {
|
||||||
|
world::copy_tree(d, &dir.join("data"))?;
|
||||||
|
}
|
||||||
|
if let Some(p) = public.filter(|p| p.is_dir()) {
|
||||||
|
world::copy_tree(p, &dir.join("public"))?;
|
||||||
|
}
|
||||||
|
let cfg = InstanceConfig {
|
||||||
|
mounts: vec![
|
||||||
|
Mount { guest: "/actor".into(), host: broker.world.actors_root(), writable: false },
|
||||||
|
Mount { guest: "/public".into(), host: dir.join("public"), writable: true },
|
||||||
|
Mount { guest: "/data".into(), host: dir.join("data"), writable: true },
|
||||||
|
],
|
||||||
|
allow_http: false,
|
||||||
|
stderr_log: dir.join("stderr.log"),
|
||||||
|
env: vec![("SAUSAGE_ACTOR".into(), id.to_string()), ("SAUSAGE_CANDIDATE".into(), "1".into())],
|
||||||
|
};
|
||||||
|
Ok((broker.runtime.start(component, cfg)?, scratch))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rand_suffix() -> u32 {
|
||||||
|
use std::hash::{BuildHasher, Hasher};
|
||||||
|
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
|
||||||
|
h.write_u64(std::process::id() as u64);
|
||||||
|
h.finish() as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_file(
|
||||||
|
broker: &Broker,
|
||||||
|
id: &str,
|
||||||
|
component: &Component,
|
||||||
|
data: Option<&Path>,
|
||||||
|
public: Option<&Path>,
|
||||||
|
file: &Path,
|
||||||
|
) -> Result<()> {
|
||||||
|
let text = std::fs::read_to_string(file)?;
|
||||||
|
let (mut inst, scratch) = start(broker, id, component, data, public)?;
|
||||||
|
let stderr = scratch.dir.join("stderr.log");
|
||||||
|
let mut sent = 0u32;
|
||||||
|
for (n, line) in text.lines().enumerate() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with("//") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let step: Value = serde_json::from_str(line).map_err(|e| anyhow!("line {}: invalid JSON: {e}", n + 1))?;
|
||||||
|
if let Some(body) = step.get("send") {
|
||||||
|
sent += 1;
|
||||||
|
let from = step.get("from").and_then(Value::as_str).unwrap_or("test");
|
||||||
|
let env = json!({"id": format!("test/{sent}"), "from": from, "body": body});
|
||||||
|
inst.send_line(&env.to_string())
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("line {}: candidate does not read stdin: {e}", n + 1))?;
|
||||||
|
} else if let Some(expected) = step.get("expect") {
|
||||||
|
let got = tokio::time::timeout(EXPECT_TIMEOUT, inst.stdout.recv()).await;
|
||||||
|
let got = match got {
|
||||||
|
Err(_) => bail!("line {}: no message within {EXPECT_TIMEOUT:?}; expected {expected}", n + 1),
|
||||||
|
Ok(None) => {
|
||||||
|
let exit = inst.wait().await;
|
||||||
|
bail!("line {}: candidate ended ({exit}); expected {expected}. stderr:\n{}", n + 1, stderr_tail(&stderr))
|
||||||
|
}
|
||||||
|
Ok(Some(l)) => l,
|
||||||
|
};
|
||||||
|
let msg: Value = serde_json::from_str(&got)
|
||||||
|
.map_err(|_| anyhow!("line {}: candidate wrote a non-JSON line: {}", n + 1, truncate(&got, 300)))?;
|
||||||
|
let body = msg.get("body").cloned().unwrap_or(Value::Null);
|
||||||
|
if !subset_match(expected, &body) {
|
||||||
|
bail!("line {}: expected body {expected}, got {}", n + 1, truncate(&body.to_string(), 1000));
|
||||||
|
}
|
||||||
|
if let Some(to) = step.get("to")
|
||||||
|
&& msg.get("to") != Some(to)
|
||||||
|
{
|
||||||
|
bail!("line {}: expected recipient {to}, got {}", n + 1, msg.get("to").cloned().unwrap_or(Value::Null));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bail!("line {}: use \"send\" or \"expect\"", n + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,846 +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 sha2::{Digest, Sha256};
|
|
||||||
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,
|
|
||||||
sdk_files: Arc<std::collections::BTreeMap<PathBuf, Vec<u8>>>,
|
|
||||||
}
|
|
||||||
impl Cargo {
|
|
||||||
/// Installs changed SDK files 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)
|
|
||||||
}
|
|
||||||
/// Installs the 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");
|
|
||||||
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 mut hash = Sha256::new();
|
|
||||||
for (path, bytes) in &files {
|
|
||||||
let name = path.to_str().unwrap().as_bytes();
|
|
||||||
hash.update((name.len() as u64).to_le_bytes());
|
|
||||||
hash.update(name);
|
|
||||||
hash.update((bytes.len() as u64).to_le_bytes());
|
|
||||||
hash.update(bytes);
|
|
||||||
write_changed(&sdk.join(path), bytes)?;
|
|
||||||
}
|
|
||||||
let cached = std::path::absolute(cache)?
|
|
||||||
.join("sdk")
|
|
||||||
.join(format!("{:x}", hash.finalize()));
|
|
||||||
Ok(Self {
|
|
||||||
sdk: cached,
|
|
||||||
sdk_files: Arc::new(files),
|
|
||||||
target: std::path::absolute(cache)?.join("cargo-target"),
|
|
||||||
world,
|
|
||||||
runner,
|
|
||||||
builds: Arc::new(tokio::sync::Mutex::new(())),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/// 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 sdk_files = self.sdk_files.clone();
|
|
||||||
let build_guard = tokio::task::spawn_blocking(move || -> Result<_> {
|
|
||||||
// Host startup installs only the readable world copy. Builds populate the cache.
|
|
||||||
if !sdk.is_dir() {
|
|
||||||
let parent = sdk.parent().unwrap();
|
|
||||||
std::fs::create_dir_all(parent)
|
|
||||||
.with_context(|| format!("create {}", parent.display()))?;
|
|
||||||
let staging = tempfile::Builder::new()
|
|
||||||
.prefix("install-")
|
|
||||||
.tempdir_in(parent)
|
|
||||||
.with_context(|| format!("stage SDK in {}", parent.display()))?;
|
|
||||||
for (path, bytes) in sdk_files.iter() {
|
|
||||||
write_changed(&staging.path().join(path), bytes)?;
|
|
||||||
}
|
|
||||||
// Another builder can publish the same complete SDK first.
|
|
||||||
if let Err(error) = std::fs::rename(staging.path(), &sdk)
|
|
||||||
&& !sdk.is_dir()
|
|
||||||
{
|
|
||||||
return Err(error).with_context(|| format!("install {}", sdk.display()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 deterministic hashing and installation.
|
|
||||||
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,169 @@
|
|||||||
|
//! 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.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use tokio::process::Command;
|
||||||
|
use tokio::sync::{Mutex, mpsc};
|
||||||
|
|
||||||
|
use crate::broker::{Broker, Envelope, truncate};
|
||||||
|
use crate::world::{self, World};
|
||||||
|
|
||||||
|
pub const ID: &str = "cargo";
|
||||||
|
pub const TARGET: &str = "wasm32-wasip2";
|
||||||
|
const BUILD_TIMEOUT: Duration = Duration::from_secs(900);
|
||||||
|
|
||||||
|
pub struct BuildOutput {
|
||||||
|
/// Host path of the published `.wasm` file.
|
||||||
|
pub wasm: PathBuf,
|
||||||
|
pub seconds: f64,
|
||||||
|
pub warnings: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Cargo {
|
||||||
|
world: World,
|
||||||
|
/// One build at a time: all builds share one target folder.
|
||||||
|
lock: Mutex<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cargo {
|
||||||
|
pub fn new(world: World) -> Self {
|
||||||
|
Self { world, lock: Mutex::new(()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 _guard = self.lock.lock().await;
|
||||||
|
let started = Instant::now();
|
||||||
|
let data = self.world.data_dir(ID);
|
||||||
|
let key = format!("{:016x}", fxhash(source.to_string_lossy().as_bytes()));
|
||||||
|
let build_dir = data.join("work").join(&key);
|
||||||
|
world::replace_tree(source, &build_dir)?;
|
||||||
|
// The crate must not join a parent workspace by accident.
|
||||||
|
let manifest = build_dir.join("Cargo.toml");
|
||||||
|
let text = std::fs::read_to_string(&manifest)?;
|
||||||
|
if !text.contains("[workspace]") {
|
||||||
|
std::fs::write(&manifest, format!("{text}\n[workspace]\n"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = tokio::time::timeout(BUILD_TIMEOUT, cmd.output())
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("cargo build took longer than {BUILD_TIMEOUT:?}"))?
|
||||||
|
.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!("{}", truncate(&strip_progress(&stderr), 20_000));
|
||||||
|
}
|
||||||
|
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) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_actor(self: Arc<Self>, broker: Arc<Broker>, mut mailbox: mpsc::Receiver<Envelope>) {
|
||||||
|
while let Some(env) = mailbox.recv().await {
|
||||||
|
let this = self.clone();
|
||||||
|
let broker = broker.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let body = this.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": truncate(&out.warnings, 4000),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Some("shutdown") => Ok(json!({"type": "ignored"})),
|
||||||
|
other => bail!("unknown request {other:?}. Known: build"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fxhash(bytes: &[u8]) -> u64 {
|
||||||
|
let mut h: u64 = 0xcbf29ce484222325;
|
||||||
|
for b in bytes {
|
||||||
|
h ^= *b as u64;
|
||||||
|
h = h.wrapping_mul(0x100000001b3);
|
||||||
|
}
|
||||||
|
h
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+177
-284
@@ -1,314 +1,207 @@
|
|||||||
//! Sequential console commands, line editing, and live notifications.
|
//! The native `console` actor: your terminal.
|
||||||
use crate::Host;
|
//!
|
||||||
use anyhow::Result;
|
//! - `@<actor> <json>` sends a JSON body. `@<actor> <text>` sends
|
||||||
use rustyline::{DefaultEditor, ExternalPrinter, error::ReadlineError};
|
//! `{"type": "text", "text": ...}`.
|
||||||
use sausage_vfs::{Layer, Open};
|
//! - A line without `@` goes to `agent` as text.
|
||||||
|
//! - `/deploy <actor> <folder>` builds a Rust folder with `cargo`, then spawns
|
||||||
|
//! or upgrades the actor.
|
||||||
|
//! - `/list`, `/sleep <seconds>`, `/help`, `/quit`.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Result, anyhow, bail};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio::sync::{mpsc, oneshot};
|
||||||
|
|
||||||
const HELP: &str = "text chat with the agent
|
use crate::broker::{Broker, Envelope};
|
||||||
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";
|
|
||||||
|
|
||||||
/// Read a complete file through the host filesystem.
|
pub const ID: &str = "console";
|
||||||
async fn read(host: &Host, path: &str) -> Result<String> {
|
|
||||||
let file = host
|
const HELP: &str = "\
|
||||||
.filesystem()
|
@<actor> <json> send a JSON body to an actor
|
||||||
.await?
|
@<actor> <text> send {\"type\":\"text\",\"text\":...}
|
||||||
.open(
|
<text> send text to the agent
|
||||||
path,
|
/deploy <actor> <dir> build a Rust folder with cargo, then spawn or upgrade
|
||||||
Open {
|
/list list actors
|
||||||
follow: true,
|
/sleep <seconds> wait (for scripts)
|
||||||
..Open::default()
|
/await <actor> <secs> wait for the next message from an actor (for scripts)
|
||||||
},
|
/quit stop all actors and exit";
|
||||||
)
|
|
||||||
.await?;
|
pub struct Console {
|
||||||
let mut bytes = Vec::new();
|
broker: Arc<Broker>,
|
||||||
loop {
|
waiters: Mutex<HashMap<String, oneshot::Sender<Envelope>>>,
|
||||||
let chunk = file.read(bytes.len() as u64, 65536).await?;
|
seq: AtomicU64,
|
||||||
if chunk.is_empty() {
|
/// Number of messages received per sender, for `/await`.
|
||||||
return Ok(String::from_utf8(bytes)?);
|
seen: Mutex<HashMap<String, u64>>,
|
||||||
|
arrived: tokio::sync::Notify,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show(env: &Envelope) {
|
||||||
|
let reply = if env.reply_to.is_some() { " ↩" } else { "" };
|
||||||
|
let b = &env.body;
|
||||||
|
if b["type"] == "text"
|
||||||
|
&& let Some(t) = b["text"].as_str()
|
||||||
|
{
|
||||||
|
println!("[{}{reply}] {t}", env.from);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
bytes.extend(chunk);
|
let mut compact = b.clone();
|
||||||
|
let mut extra = Vec::new();
|
||||||
|
if let Some(obj) = compact.as_object_mut() {
|
||||||
|
for key in ["errors", "warnings", "reason"] {
|
||||||
|
if let Some(Value::String(s)) = obj.get(key)
|
||||||
|
&& s.contains('\n')
|
||||||
|
{
|
||||||
|
extra.push((key, s.clone()));
|
||||||
|
obj.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("[{}{reply}] {compact}", env.from);
|
||||||
|
for (k, s) in extra {
|
||||||
|
println!(" {k}:\n{}", s.lines().map(|l| format!(" {l}")).collect::<Vec<_>>().join("\n"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recognize the process IDs accepted by stop, kill, and log.
|
impl Console {
|
||||||
fn is_pid(text: &str) -> bool {
|
pub fn new(broker: Arc<Broker>) -> Arc<Self> {
|
||||||
text.rsplit_once('-').is_some()
|
Arc::new(Self {
|
||||||
&& text
|
broker,
|
||||||
.get(text.len().saturating_sub(36)..)
|
waiters: Mutex::new(HashMap::new()),
|
||||||
.is_some_and(|id| uuid::Uuid::try_parse(id).is_ok())
|
seq: AtomicU64::new(1),
|
||||||
}
|
seen: Mutex::new(HashMap::new()),
|
||||||
|
arrived: tokio::sync::Notify::new(),
|
||||||
/// 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}"));
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
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.
|
pub async fn run_mailbox(self: Arc<Self>, mut mailbox: mpsc::Receiver<Envelope>) {
|
||||||
fn log_params(words: &[&str]) -> Option<Value> {
|
while let Some(env) = mailbox.recv().await {
|
||||||
let mut params = json!({});
|
show(&env);
|
||||||
let mut words = words.iter();
|
*self.seen.lock().unwrap().entry(env.from.clone()).or_default() += 1;
|
||||||
while let Some(word) = words.next() {
|
self.arrived.notify_waiters();
|
||||||
if *word == "-n" {
|
if let Some(r) = &env.reply_to
|
||||||
params["limit"] = json!(words.next()?.parse::<u64>().ok()?);
|
&& let Some(tx) = self.waiters.lock().unwrap().remove(r)
|
||||||
} else if params.get("plugin").is_none() && params.get("pid").is_none() {
|
{
|
||||||
params[if is_pid(word) { "pid" } else { "plugin" }] = json!(word);
|
let _ = tx.send(env);
|
||||||
} else {
|
}
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute one line and return its unlabelled result.
|
fn send(&self, to: &str, body: Value) -> String {
|
||||||
pub async fn execute_line(host: &Host, line: &str) -> Result<String> {
|
let own = format!("c{}", self.seq.fetch_add(1, Ordering::Relaxed));
|
||||||
let line = line.trim();
|
self.broker.send(ID, Some(to), Some(&own), None, body)
|
||||||
if line.is_empty() {
|
|
||||||
return Ok(String::new());
|
|
||||||
}
|
}
|
||||||
let (command, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
|
|
||||||
let rest = rest.trim();
|
async fn request(&self, to: &str, body: Value, timeout: Duration) -> Result<Envelope> {
|
||||||
let words: Vec<_> = rest.split_whitespace().collect();
|
let own = format!("c{}", self.seq.fetch_add(1, Ordering::Relaxed));
|
||||||
match (command, words.as_slice()) {
|
let (tx, rx) = oneshot::channel();
|
||||||
("help", []) => return Ok(HELP.into()),
|
self.waiters.lock().unwrap().insert(format!("{ID}/{own}"), tx);
|
||||||
("quit", []) => return Ok("Goodbye.".into()),
|
self.broker.send(ID, Some(to), Some(&own), None, body);
|
||||||
("cat", [path]) => return read(host, path).await,
|
tokio::time::timeout(timeout, rx)
|
||||||
("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
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
.map_err(|_| anyhow!("no reply from {to} within {timeout:?}"))?
|
||||||
return Ok(methods(
|
.map_err(|_| anyhow!("reply channel closed"))
|
||||||
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.
|
async fn deploy(&self, actor: &str, dir: &str) -> Result<()> {
|
||||||
pub fn notification_text(value: &Value) -> String {
|
let path = std::path::absolute(dir)?;
|
||||||
let params = &value["params"];
|
let source = path.display().to_string();
|
||||||
if params["name"] != "text" {
|
println!("· building {source} …");
|
||||||
return value.to_string();
|
let built = self.request(crate::cargo_actor::ID, json!({"type": "build", "source": source}), Duration::from_secs(900)).await?;
|
||||||
|
if built.body["type"] != "built" {
|
||||||
|
bail!("build failed");
|
||||||
|
}
|
||||||
|
let exists = self.broker.registry().actors.contains_key(actor);
|
||||||
|
let ty = if exists { "upgrade" } else { "spawn" };
|
||||||
|
println!("· {ty} {actor} …");
|
||||||
|
let body = json!({
|
||||||
|
"type": ty,
|
||||||
|
"actor": actor,
|
||||||
|
"wasm": built.body["wasm"],
|
||||||
|
"source": source,
|
||||||
|
"built_by": built.body["built_by"],
|
||||||
|
});
|
||||||
|
let r = self.request("broker", body, Duration::from_secs(300)).await?;
|
||||||
|
if r.body["type"] == "error" {
|
||||||
|
bail!("{ty} failed");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
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.
|
/// Read commands until EOF or `/quit`.
|
||||||
pub async fn run_input(host: Host) -> Result<()> {
|
pub async fn run_input(self: Arc<Self>) {
|
||||||
let terminal = std::io::IsTerminal::is_terminal(&std::io::stdin());
|
println!("Sausage console. Type /help for commands.");
|
||||||
let history = host.0.world.state_dir("core").join("history");
|
let mut lines = BufReader::new(tokio::io::stdin()).lines();
|
||||||
let mut editor = if terminal {
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
Some(DefaultEditor::new()?)
|
let line = line.trim();
|
||||||
} else {
|
if line.is_empty() || line.starts_with('#') {
|
||||||
None
|
continue;
|
||||||
};
|
|
||||||
if let Some(editor) = &mut editor {
|
|
||||||
let _ = editor.load_history(&history);
|
|
||||||
}
|
}
|
||||||
let mut printer = editor
|
if let Err(e) = self.command(line).await {
|
||||||
.as_mut()
|
if e.to_string() == "quit" {
|
||||||
.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)
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
if response.send(line).is_err() {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
println!("! {e:#}");
|
||||||
});
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
async fn command(&self, line: &str) -> Result<()> {
|
||||||
let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
|
if let Some(rest) = line.strip_prefix('@') {
|
||||||
loop {
|
let (to, text) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
|
||||||
let (send, line) = tokio::sync::oneshot::channel();
|
let text = text.trim();
|
||||||
request.send(send)?;
|
let body = match serde_json::from_str::<Value>(text) {
|
||||||
let line =
|
Ok(v @ Value::Object(_)) => v,
|
||||||
tokio::select! { result = line => result??, _ = interrupt.recv() => return Ok(()) };
|
_ => json!({"type": "text", "text": text}),
|
||||||
let Some(line) = line else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
};
|
||||||
let text = tokio::select! {
|
self.send(to, body);
|
||||||
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
if let Some(cmd) = line.strip_prefix('/') {
|
||||||
|
let mut parts = cmd.split_whitespace();
|
||||||
|
match parts.next() {
|
||||||
|
Some("help") => println!("{HELP}"),
|
||||||
|
Some("quit") => bail!("quit"),
|
||||||
|
Some("list") => {
|
||||||
|
self.send("broker", json!({"type": "list"}));
|
||||||
|
}
|
||||||
|
Some("sleep") => {
|
||||||
|
let secs: f64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(1.0);
|
||||||
|
tokio::time::sleep(Duration::from_secs_f64(secs)).await;
|
||||||
|
}
|
||||||
|
Some("await") => {
|
||||||
|
let from = parts.next().unwrap_or("agent").to_string();
|
||||||
|
let secs: f64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(60.0);
|
||||||
|
let count = |c: &Self| c.seen.lock().unwrap().get(&from).copied().unwrap_or(0);
|
||||||
|
let before = count(self);
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs_f64(secs);
|
||||||
|
loop {
|
||||||
|
let notified = self.arrived.notified();
|
||||||
|
tokio::pin!(notified);
|
||||||
|
notified.as_mut().enable();
|
||||||
|
if count(self) != before {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if tokio::time::timeout_at(deadline, notified).await.is_err() {
|
||||||
|
bail!("no message from {from} within {secs} s");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("deploy") => {
|
||||||
|
let (Some(actor), Some(dir)) = (parts.next(), parts.next()) else {
|
||||||
|
bail!("usage: /deploy <actor> <dir>");
|
||||||
|
};
|
||||||
|
self.deploy(actor, dir).await?;
|
||||||
|
}
|
||||||
|
_ => bail!("unknown command. Type /help."),
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.send("agent", json!({"type": "text", "text": line}));
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +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 {
|
|
||||||
"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,307 +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 host = tracker
|
|
||||||
.clone()
|
|
||||||
.blocking(move || sausage_vfs::Host::new(root, tracker))
|
|
||||||
.await?;
|
|
||||||
let mut mounts = Mounts::default();
|
|
||||||
mounts.mount("/", Arc::new(host), false);
|
|
||||||
mounts.mount("/proc", Arc::new(Proc(self.clone())), true);
|
|
||||||
mounts.mount(
|
|
||||||
"/self",
|
|
||||||
Arc::new(SelfFolder {
|
|
||||||
host: self.clone(),
|
|
||||||
pid,
|
|
||||||
memory,
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
mounts.mount("/run", Arc::new(self.0.run.clone()), false);
|
|
||||||
Ok(mounts)
|
|
||||||
}
|
|
||||||
fn core_files(&self) -> Generated {
|
|
||||||
let mut files = Generated::default();
|
|
||||||
let protocol = include_bytes!("../../PROTOCOL.md");
|
|
||||||
files.insert("/PROTOCOL.md", move || Ok(protocol.to_vec()));
|
|
||||||
let graph = self.graph_json();
|
|
||||||
files.insert("/graph.json", move || Ok(graph.clone()));
|
|
||||||
let dot = self.graph_dot();
|
|
||||||
files.insert("/graph.dot", move || Ok(dot.clone()));
|
|
||||||
let locks = {
|
|
||||||
let state = self.0.state.lock().unwrap();
|
|
||||||
let locks: std::collections::BTreeMap<_, _> = state
|
|
||||||
.locks
|
|
||||||
.iter()
|
|
||||||
.map(|(path, lock)| {
|
|
||||||
let waiting: Vec<_> = lock.queue.iter().map(|(pid, _)| pid).collect();
|
|
||||||
let info = serde_json::json!({
|
|
||||||
"owner": lock.owner,
|
|
||||||
"waiting": waiting,
|
|
||||||
});
|
|
||||||
(path.clone(), info)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
serde_json::to_vec(&locks).unwrap()
|
|
||||||
};
|
|
||||||
files.insert("/locks.json", move || Ok(locks.clone()));
|
|
||||||
for name in self.discovered_plugins() {
|
|
||||||
if let Some(doc) = self.discover_document(&name) {
|
|
||||||
let bytes = serde_json::to_vec(&doc).unwrap();
|
|
||||||
files.insert(&format!("/plugins/{name}/discover.json"), move || {
|
|
||||||
Ok(bytes.clone())
|
|
||||||
});
|
|
||||||
files.insert(&format!("/plugins/{name}/client.rs"), move || {
|
|
||||||
sausage_interface::client_rs(&name, &doc)
|
|
||||||
.map(String::into_bytes)
|
|
||||||
.map_err(io::Error::other)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
files
|
|
||||||
}
|
|
||||||
}
|
|
||||||
struct Proc(Host);
|
|
||||||
impl Proc {
|
|
||||||
fn folder(&self, path: &str) -> Result<(SelfFolder, String)> {
|
|
||||||
let (pid, rest) = path
|
|
||||||
.trim_start_matches('/')
|
|
||||||
.split_once('/')
|
|
||||||
.unwrap_or((path.trim_start_matches('/'), ""));
|
|
||||||
let memory = self
|
|
||||||
.0
|
|
||||||
.0
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.processes
|
|
||||||
.get(pid)
|
|
||||||
.map(|p| p.memory.clone())
|
|
||||||
.ok_or(io::ErrorKind::NotFound)?;
|
|
||||||
Ok((
|
|
||||||
SelfFolder {
|
|
||||||
host: self.0.clone(),
|
|
||||||
pid: Some(pid.into()),
|
|
||||||
memory,
|
|
||||||
},
|
|
||||||
format!("/{rest}"),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[async_trait]
|
|
||||||
impl Layer for Proc {
|
|
||||||
async fn stat(&self, path: &str, follow: bool) -> Result<Stat> {
|
|
||||||
let path = path.trim_end_matches('/');
|
|
||||||
let path = if path.is_empty() { "/" } else { path };
|
|
||||||
if matches!(path.trim_end_matches('/'), "" | "/core" | "/core/plugins") {
|
|
||||||
return Ok(directory());
|
|
||||||
}
|
|
||||||
if let Some(path) = path.strip_prefix("/core/") {
|
|
||||||
return self.0.core_files().stat(&format!("/{path}"), follow).await;
|
|
||||||
}
|
|
||||||
let (folder, path) = self.folder(path)?;
|
|
||||||
folder.stat(&path, follow).await
|
|
||||||
}
|
|
||||||
async fn list(&self, path: &str) -> Result<Vec<Entry>> {
|
|
||||||
let path = path.trim_end_matches('/');
|
|
||||||
let path = if path.is_empty() { "/" } else { path };
|
|
||||||
if path == "/" {
|
|
||||||
let mut entries = vec![entry("core".into(), true)];
|
|
||||||
entries.extend(self.0.processes().into_keys().map(|pid| entry(pid, true)));
|
|
||||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
|
||||||
return Ok(entries);
|
|
||||||
}
|
|
||||||
if path.trim_end_matches('/') == "/core/plugins" {
|
|
||||||
return Ok(self
|
|
||||||
.0
|
|
||||||
.discovered_plugins()
|
|
||||||
.into_iter()
|
|
||||||
.filter(|name| self.0.discover_document(name).is_some())
|
|
||||||
.map(|name| entry(name, true))
|
|
||||||
.collect());
|
|
||||||
}
|
|
||||||
if path.trim_end_matches('/') == "/core" {
|
|
||||||
let mut entries = self.0.core_files().list("/").await?;
|
|
||||||
if !entries.iter().any(|e| e.name == "plugins") {
|
|
||||||
entries.push(entry("plugins".into(), true));
|
|
||||||
}
|
|
||||||
return Ok(entries);
|
|
||||||
}
|
|
||||||
if let Some(path) = path.strip_prefix("/core/") {
|
|
||||||
return self.0.core_files().list(&format!("/{path}")).await;
|
|
||||||
}
|
|
||||||
let (folder, path) = self.folder(path)?;
|
|
||||||
folder.list(&path).await
|
|
||||||
}
|
|
||||||
async fn open(&self, path: &str, options: Open) -> Result<Arc<dyn File>> {
|
|
||||||
let path = path.trim_end_matches('/');
|
|
||||||
let path = if path.is_empty() { "/" } else { path };
|
|
||||||
if options.write || options.create || options.truncate {
|
|
||||||
return denied();
|
|
||||||
}
|
|
||||||
if matches!(path.trim_end_matches('/'), "" | "/core" | "/core/plugins") {
|
|
||||||
return Memory::default().open("/", options).await;
|
|
||||||
}
|
|
||||||
if let Some(path) = path.strip_prefix("/core/") {
|
|
||||||
return self.0.core_files().open(&format!("/{path}"), options).await;
|
|
||||||
}
|
|
||||||
let (folder, path) = self.folder(path)?;
|
|
||||||
folder.open(&path, options).await
|
|
||||||
}
|
|
||||||
async fn mkdir(&self, _: &str) -> Result<()> {
|
|
||||||
denied()
|
|
||||||
}
|
|
||||||
async fn remove_file(&self, _: &str) -> Result<()> {
|
|
||||||
denied()
|
|
||||||
}
|
|
||||||
async fn remove_dir(&self, _: &str) -> Result<()> {
|
|
||||||
denied()
|
|
||||||
}
|
|
||||||
async fn rename(&self, _: &str, _: &str) -> Result<()> {
|
|
||||||
denied()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
struct SelfFolder {
|
|
||||||
host: Host,
|
|
||||||
pid: Option<String>,
|
|
||||||
memory: Memory,
|
|
||||||
}
|
|
||||||
impl SelfFolder {
|
|
||||||
async fn generated(&self, path: &str) -> Result<Generated> {
|
|
||||||
let pid = self.pid.as_ref().ok_or(io::ErrorKind::NotFound)?;
|
|
||||||
let bytes = match path {
|
|
||||||
"/info.json" => {
|
|
||||||
let info = self
|
|
||||||
.host
|
|
||||||
.0
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.processes
|
|
||||||
.get(pid)
|
|
||||||
.map(|p| p.info.clone())
|
|
||||||
.ok_or(io::ErrorKind::NotFound)?;
|
|
||||||
serde_json::to_vec(&info).unwrap()
|
|
||||||
}
|
|
||||||
"/log" => {
|
|
||||||
let lines = self
|
|
||||||
.host
|
|
||||||
.0
|
|
||||||
.journal
|
|
||||||
.query(serde_json::json!({"pid":pid,"limit":i64::MAX}))
|
|
||||||
.await;
|
|
||||||
let mut bytes = Vec::new();
|
|
||||||
for line in lines.as_array().unwrap() {
|
|
||||||
serde_json::to_writer(&mut bytes, line).unwrap();
|
|
||||||
bytes.push(b'\n');
|
|
||||||
}
|
|
||||||
bytes
|
|
||||||
}
|
|
||||||
_ => return missing(),
|
|
||||||
};
|
|
||||||
let mut files = Generated::default();
|
|
||||||
files.insert(path, move || Ok(bytes.clone()));
|
|
||||||
Ok(files)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[async_trait]
|
|
||||||
impl Layer for SelfFolder {
|
|
||||||
async fn stat(&self, path: &str, follow: bool) -> Result<Stat> {
|
|
||||||
if reserved(path) {
|
|
||||||
self.generated(path).await?.stat(path, follow).await
|
|
||||||
} else {
|
|
||||||
self.memory.stat(path, follow).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async fn list(&self, path: &str) -> Result<Vec<Entry>> {
|
|
||||||
let mut entries = self.memory.list(path).await?;
|
|
||||||
if path == "/" && self.pid.is_some() {
|
|
||||||
entries.extend([entry("info.json".into(), false), entry("log".into(), false)]);
|
|
||||||
}
|
|
||||||
Ok(entries)
|
|
||||||
}
|
|
||||||
async fn open(&self, path: &str, options: Open) -> Result<Arc<dyn File>> {
|
|
||||||
if reserved(path) {
|
|
||||||
if options.write || options.create || options.truncate {
|
|
||||||
return denied();
|
|
||||||
}
|
|
||||||
self.generated(path).await?.open(path, options).await
|
|
||||||
} else {
|
|
||||||
self.memory.open(path, options).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
|
||||||
if reserved(path) {
|
|
||||||
denied()
|
|
||||||
} else {
|
|
||||||
self.memory.mkdir(path).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async fn remove_file(&self, path: &str) -> Result<()> {
|
|
||||||
if reserved(path) {
|
|
||||||
denied()
|
|
||||||
} else {
|
|
||||||
self.memory.remove_file(path).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async fn remove_dir(&self, path: &str) -> Result<()> {
|
|
||||||
if reserved(path) || path == "/" {
|
|
||||||
denied()
|
|
||||||
} else {
|
|
||||||
self.memory.remove_dir(path).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
|
||||||
if reserved(from) || reserved(to) || from == "/" || to == "/" {
|
|
||||||
denied()
|
|
||||||
} else {
|
|
||||||
self.memory.rename(from, to).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+434
@@ -0,0 +1,434 @@
|
|||||||
|
//! Filesystem layer with commit-on-close semantics.
|
||||||
|
//!
|
||||||
|
//! The layer wraps the wasmtime-wasi `wasi:filesystem` implementation. For
|
||||||
|
//! mounts with `commit_on_close`, an open for write goes to a hidden temporary
|
||||||
|
//! file next to the target. When the guest drops the descriptor (close), the
|
||||||
|
//! layer renames the temporary file into place. Until then, no other reader
|
||||||
|
//! sees the change. If the instance traps or is killed, the changes are lost.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
|
use wasmtime::component::{HasData, Resource};
|
||||||
|
use wasmtime_wasi::filesystem::{Descriptor, WasiFilesystemCtxView};
|
||||||
|
use wasmtime_wasi::p2::bindings::filesystem::types::{
|
||||||
|
self, ErrorCode, HostDescriptor, HostDirectoryEntryStream,
|
||||||
|
};
|
||||||
|
use wasmtime_wasi::p2::bindings::filesystem::preopens;
|
||||||
|
use wasmtime_wasi::p2::{DynInputStream, DynOutputStream, FsError, FsResult};
|
||||||
|
|
||||||
|
/// Name prefix of uncommitted files. The layer hides these names from guests.
|
||||||
|
pub const TMP_PREFIX: &str = ".sausage-tmp-";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Mount {
|
||||||
|
pub guest: String,
|
||||||
|
pub host: PathBuf,
|
||||||
|
pub writable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct DirInfo {
|
||||||
|
host: PathBuf,
|
||||||
|
root: PathBuf,
|
||||||
|
commit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Pending {
|
||||||
|
tmp: PathBuf,
|
||||||
|
target: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-instance state of the layer.
|
||||||
|
pub struct FsExt {
|
||||||
|
mounts: Vec<Mount>,
|
||||||
|
dirs: HashMap<u32, DirInfo>,
|
||||||
|
pending: HashMap<u32, Pending>,
|
||||||
|
counter: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsExt {
|
||||||
|
pub fn new(mounts: Vec<Mount>) -> Self {
|
||||||
|
Self { mounts, dirs: HashMap::new(), pending: HashMap::new(), counter: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit every open write. Use this when the guest exits normally,
|
||||||
|
/// because process exit closes all files.
|
||||||
|
pub fn commit_all(&mut self) {
|
||||||
|
for (_, p) in self.pending.drain() {
|
||||||
|
commit(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for FsExt {
|
||||||
|
/// Discard writes that were not committed (trap, kill).
|
||||||
|
fn drop(&mut self) {
|
||||||
|
for (_, p) in self.pending.drain() {
|
||||||
|
let _ = std::fs::remove_file(&p.tmp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit(p: Pending) {
|
||||||
|
if let Err(e) = std::fs::rename(&p.tmp, &p.target) {
|
||||||
|
eprintln!("sausage: commit of {} failed: {e}", p.target.display());
|
||||||
|
let _ = std::fs::remove_file(&p.tmp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_tmp_name(path: &str) -> bool {
|
||||||
|
path.rsplit('/').next().is_some_and(|n| n.starts_with(TMP_PREFIX))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Join `rel` to the directory `dir` lexically. Return `None` if the result
|
||||||
|
/// leaves the mount root.
|
||||||
|
fn resolve(dir: &DirInfo, rel: &str) -> Option<PathBuf> {
|
||||||
|
let mut out = dir.host.clone();
|
||||||
|
for c in Path::new(rel).components() {
|
||||||
|
match c {
|
||||||
|
Component::Normal(n) => out.push(n),
|
||||||
|
Component::CurDir => {}
|
||||||
|
Component::ParentDir => {
|
||||||
|
if !out.pop() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Component::RootDir | Component::Prefix(_) => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.starts_with(&dir.root).then_some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn io_err(e: std::io::Error) -> FsError {
|
||||||
|
ErrorCode::from(e).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ActorFs<'a> {
|
||||||
|
pub inner: WasiFilesystemCtxView<'a>,
|
||||||
|
pub ext: &'a mut FsExt,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ActorFsData;
|
||||||
|
|
||||||
|
impl HasData for ActorFsData {
|
||||||
|
type Data<'a> = ActorFs<'a>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl preopens::Host for ActorFs<'_> {
|
||||||
|
fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<Descriptor>, String)>> {
|
||||||
|
let dirs = preopens::Host::get_directories(&mut self.inner)?;
|
||||||
|
for (r, name) in &dirs {
|
||||||
|
if let Some(m) = self.ext.mounts.iter().find(|m| &m.guest == name) {
|
||||||
|
self.ext.dirs.insert(
|
||||||
|
r.rep(),
|
||||||
|
DirInfo { host: m.host.clone(), root: m.host.clone(), commit: m.writable },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(dirs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl types::Host for ActorFs<'_> {
|
||||||
|
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 ActorFs<'_> {
|
||||||
|
async fn advise(
|
||||||
|
&mut self,
|
||||||
|
fd: Resource<types::Descriptor>,
|
||||||
|
offset: types::Filesize,
|
||||||
|
len: types::Filesize,
|
||||||
|
advice: types::Advice,
|
||||||
|
) -> FsResult<()> {
|
||||||
|
HostDescriptor::advise(&mut self.inner, fd, offset, len, advice).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_data(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
|
||||||
|
HostDescriptor::sync_data(&mut self.inner, fd).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_flags(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorFlags> {
|
||||||
|
HostDescriptor::get_flags(&mut self.inner, fd).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_type(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorType> {
|
||||||
|
HostDescriptor::get_type(&mut self.inner, fd).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_size(&mut self, fd: Resource<types::Descriptor>, size: types::Filesize) -> FsResult<()> {
|
||||||
|
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<()> {
|
||||||
|
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)> {
|
||||||
|
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> {
|
||||||
|
HostDescriptor::write(&mut self.inner, fd, buf, offset).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_directory(
|
||||||
|
&mut self,
|
||||||
|
fd: Resource<types::Descriptor>,
|
||||||
|
) -> FsResult<Resource<types::DirectoryEntryStream>> {
|
||||||
|
HostDescriptor::read_directory(&mut self.inner, fd).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
|
||||||
|
HostDescriptor::sync(&mut self.inner, fd).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
||||||
|
HostDescriptor::create_directory_at(&mut self.inner, fd, path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stat(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorStat> {
|
||||||
|
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> {
|
||||||
|
if is_tmp_name(&path) {
|
||||||
|
return Err(ErrorCode::NoEntry.into());
|
||||||
|
}
|
||||||
|
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<()> {
|
||||||
|
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<()> {
|
||||||
|
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: types::OpenFlags,
|
||||||
|
flags: types::DescriptorFlags,
|
||||||
|
) -> FsResult<Resource<types::Descriptor>> {
|
||||||
|
if is_tmp_name(&path) {
|
||||||
|
return Err(ErrorCode::NoEntry.into());
|
||||||
|
}
|
||||||
|
let parent = self.ext.dirs.get(&fd.rep()).cloned();
|
||||||
|
|
||||||
|
// Commit-on-close: redirect writes to a hidden temporary file.
|
||||||
|
if let Some(dir) = parent.as_ref().filter(|d| d.commit)
|
||||||
|
&& flags.contains(types::DescriptorFlags::WRITE)
|
||||||
|
&& !oflags.contains(types::OpenFlags::DIRECTORY)
|
||||||
|
{
|
||||||
|
let target = resolve(dir, &path).ok_or(FsError::from(ErrorCode::NotPermitted))?;
|
||||||
|
let meta = std::fs::metadata(&target).ok();
|
||||||
|
if meta.as_ref().is_some_and(|m| m.is_dir()) {
|
||||||
|
return Err(ErrorCode::IsDirectory.into());
|
||||||
|
}
|
||||||
|
let exists = meta.is_some();
|
||||||
|
if exists && oflags.contains(types::OpenFlags::EXCLUSIVE) {
|
||||||
|
return Err(ErrorCode::Exist.into());
|
||||||
|
}
|
||||||
|
if !exists && !oflags.contains(types::OpenFlags::CREATE) {
|
||||||
|
return Err(ErrorCode::NoEntry.into());
|
||||||
|
}
|
||||||
|
let target_dir = target.parent().ok_or(FsError::from(ErrorCode::NotPermitted))?;
|
||||||
|
if !target_dir.is_dir() {
|
||||||
|
return Err(ErrorCode::NoEntry.into());
|
||||||
|
}
|
||||||
|
self.ext.counter += 1;
|
||||||
|
let tmp_name = format!("{TMP_PREFIX}{}-{}", std::process::id(), self.ext.counter);
|
||||||
|
let tmp_rel = match path.rsplit_once('/') {
|
||||||
|
Some((d, _)) => format!("{d}/{tmp_name}"),
|
||||||
|
None => tmp_name.clone(),
|
||||||
|
};
|
||||||
|
let tmp = target_dir.join(&tmp_name);
|
||||||
|
let truncate = oflags.contains(types::OpenFlags::TRUNCATE);
|
||||||
|
if exists && !truncate {
|
||||||
|
std::fs::copy(&target, &tmp).map_err(io_err)?;
|
||||||
|
}
|
||||||
|
let oflags = (oflags | types::OpenFlags::CREATE)
|
||||||
|
& !(types::OpenFlags::EXCLUSIVE | types::OpenFlags::TRUNCATE);
|
||||||
|
return match HostDescriptor::open_at(&mut self.inner, fd, path_flags, tmp_rel, oflags, flags).await {
|
||||||
|
Ok(r) => {
|
||||||
|
self.ext.pending.insert(r.rep(), Pending { tmp, target });
|
||||||
|
Ok(r)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let r = HostDescriptor::open_at(&mut self.inner, fd, path_flags, path.clone(), oflags, flags).await?;
|
||||||
|
if let Some(dir) = parent
|
||||||
|
&& matches!(self.inner.table.get(&r)?, Descriptor::Dir(_))
|
||||||
|
&& let Some(host) = resolve(&dir, &path)
|
||||||
|
{
|
||||||
|
self.ext.dirs.insert(r.rep(), DirInfo { host, root: dir.root, commit: dir.commit });
|
||||||
|
}
|
||||||
|
Ok(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drop(&mut self, fd: Resource<types::Descriptor>) -> wasmtime::Result<()> {
|
||||||
|
let rep = fd.rep();
|
||||||
|
HostDescriptor::drop(&mut self.inner, fd)?;
|
||||||
|
self.ext.dirs.remove(&rep);
|
||||||
|
if let Some(p) = self.ext.pending.remove(&rep) {
|
||||||
|
commit(p);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn readlink_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<String> {
|
||||||
|
HostDescriptor::readlink_at(&mut self.inner, fd, path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
||||||
|
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<()> {
|
||||||
|
if is_tmp_name(&old_path) || is_tmp_name(&new_path) {
|
||||||
|
return Err(ErrorCode::NoEntry.into());
|
||||||
|
}
|
||||||
|
HostDescriptor::rename_at(&mut self.inner, fd, old_path, new_fd, new_path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn symlink_at(
|
||||||
|
&mut self,
|
||||||
|
_fd: Resource<types::Descriptor>,
|
||||||
|
_src_path: String,
|
||||||
|
_dest_path: String,
|
||||||
|
) -> FsResult<()> {
|
||||||
|
// Symlinks would break the lexical path tracking of commit-on-close.
|
||||||
|
Err(ErrorCode::NotPermitted.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unlink_file_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
||||||
|
if is_tmp_name(&path) {
|
||||||
|
return Err(ErrorCode::NoEntry.into());
|
||||||
|
}
|
||||||
|
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>> {
|
||||||
|
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>> {
|
||||||
|
HostDescriptor::write_via_stream(&mut self.inner, fd, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_via_stream(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Resource<DynOutputStream>> {
|
||||||
|
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> {
|
||||||
|
HostDescriptor::is_same_object(&mut self.inner, a, b).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn metadata_hash(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::MetadataHashValue> {
|
||||||
|
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> {
|
||||||
|
HostDescriptor::metadata_hash_at(&mut self.inner, fd, path_flags, path).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostDirectoryEntryStream for ActorFs<'_> {
|
||||||
|
async fn read_directory_entry(
|
||||||
|
&mut self,
|
||||||
|
stream: Resource<types::DirectoryEntryStream>,
|
||||||
|
) -> FsResult<Option<types::DirectoryEntry>> {
|
||||||
|
let rep = stream.rep();
|
||||||
|
loop {
|
||||||
|
let entry = HostDirectoryEntryStream::read_directory_entry(
|
||||||
|
&mut self.inner,
|
||||||
|
Resource::new_borrow(rep),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
match entry {
|
||||||
|
Some(e) if e.name.starts_with(TMP_PREFIX) => continue,
|
||||||
|
other => return Ok(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drop(&mut self, stream: Resource<types::DirectoryEntryStream>) -> wasmtime::Result<()> {
|
||||||
|
HostDirectoryEntryStream::drop(&mut self.inner, stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,297 +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()
|
|
||||||
}) {
|
|
||||||
let grace = old.document["x-sausage-grace"]
|
|
||||||
.as_f64()
|
|
||||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
|
||||||
.unwrap_or(60.0);
|
|
||||||
upgrades.push(self.snapshot_upgrade(
|
|
||||||
plugin,
|
|
||||||
old.hash.clone(),
|
|
||||||
¤t.hash,
|
|
||||||
grace,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
-162
@@ -1,162 +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) 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 {
|
|
||||||
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(),
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
//! A host for worlds of JSON-RPC plugins.
|
|
||||||
mod activation;
|
|
||||||
pub mod cargo;
|
|
||||||
mod components;
|
|
||||||
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;
|
|
||||||
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})
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+154
-160
@@ -1,190 +1,184 @@
|
|||||||
//! Command parsing and the minimal line console.
|
//! Sausage: grow a system of WASI actors by talking to it.
|
||||||
use anyhow::{Result, bail};
|
|
||||||
|
mod broker;
|
||||||
|
mod candidate;
|
||||||
|
mod cargo_actor;
|
||||||
|
mod console;
|
||||||
|
mod fs;
|
||||||
|
mod runtime;
|
||||||
|
mod world;
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use include_dir::{Dir, include_dir};
|
use include_dir::{Dir, include_dir};
|
||||||
use sausage::{Host, world::World};
|
use serde_json::json;
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
use crate::broker::Broker;
|
||||||
|
use crate::world::{NATIVE, Registry, VersionMeta, World};
|
||||||
|
|
||||||
|
/// 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)]
|
#[derive(Parser)]
|
||||||
#[command(version)]
|
#[command(version, about)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[arg(long, global = true)]
|
|
||||||
headless: bool,
|
|
||||||
#[arg(long, global = true)]
|
|
||||||
cache_dir: Option<PathBuf>,
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Option<Cmd>,
|
command: Cmd,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Cmd {
|
enum Cmd {
|
||||||
|
/// Create a new world and build the agent actor.
|
||||||
Init {
|
Init {
|
||||||
|
/// Folder of the new world.
|
||||||
#[arg(default_value = "world")]
|
#[arg(default_value = "world")]
|
||||||
dir: PathBuf,
|
dir: PathBuf,
|
||||||
|
/// API shape: `openai-responses` or `anthropic-messages`.
|
||||||
|
#[arg(long)]
|
||||||
|
api: Option<String>,
|
||||||
|
/// Base URL of the inference API (for example `https://api.openai.com`).
|
||||||
|
#[arg(long)]
|
||||||
|
base_url: Option<String>,
|
||||||
|
/// Model ID.
|
||||||
|
#[arg(long)]
|
||||||
|
model: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// Start the broker, all actors, and the console.
|
||||||
Run {
|
Run {
|
||||||
#[arg(default_value = "world")]
|
#[arg(default_value = "world")]
|
||||||
dir: PathBuf,
|
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() {
|
fn prompt(question: &str, default: Option<&str>) -> Result<String> {
|
||||||
"1" | "true" => Ok(true),
|
match default {
|
||||||
"" | "0" | "false" => Ok(false),
|
Some(d) => print!("{question} [{d}]: "),
|
||||||
_ => Err("expected 1, true, 0, false, or an empty value".into()),
|
None => print!("{question}: "),
|
||||||
|
}
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
let mut line = String::new();
|
||||||
|
std::io::stdin().read_line(&mut line)?;
|
||||||
|
let v = line.trim().to_string();
|
||||||
|
match (v.is_empty(), default) {
|
||||||
|
(true, Some(d)) => Ok(d.to_string()),
|
||||||
|
(true, None) => bail!("{question} is required"),
|
||||||
|
_ => Ok(v),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Readline can still wait on its detached thread when the host exits.
|
async fn init(dir: PathBuf, api: Option<String>, base_url: Option<String>, model: Option<String>) -> Result<()> {
|
||||||
struct TerminalMode(Option<nix::sys::termios::Termios>);
|
let world = World::new(std::path::absolute(&dir)?);
|
||||||
impl Drop for TerminalMode {
|
if world.exists() {
|
||||||
fn drop(&mut self) {
|
bail!("{} is already a Sausage world", dir.display());
|
||||||
if let Some(mode) = &self.0 {
|
|
||||||
let _ = nix::sys::termios::tcsetattr(
|
|
||||||
std::io::stdin(),
|
|
||||||
nix::sys::termios::SetArg::TCSANOW,
|
|
||||||
mode,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
let api = match api {
|
||||||
|
Some(a) => a,
|
||||||
|
None => prompt("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 = match base_url {
|
||||||
|
Some(u) => u,
|
||||||
|
None => prompt("API base URL", None)?,
|
||||||
|
};
|
||||||
|
let model = match model {
|
||||||
|
Some(m) => m,
|
||||||
|
None => prompt("Model", None)?,
|
||||||
|
};
|
||||||
|
let token = match std::env::var("SAUSAGE_API_TOKEN") {
|
||||||
|
Ok(t) if !t.is_empty() => t,
|
||||||
|
_ => prompt("API token", None)?,
|
||||||
|
};
|
||||||
|
|
||||||
/// Creates a world with editable configuration and builds its embedded agent.
|
std::fs::create_dir_all(&world.root)?;
|
||||||
async fn init(dir: &Path, cache: Option<&Path>) -> Result<()> {
|
let mut reg = Registry::default();
|
||||||
if dir.exists() {
|
reg.subscriptions.entry("agent".into()).or_default().insert("console".into());
|
||||||
bail!("world already exists: {}", dir.display());
|
world.save_registry(®)?;
|
||||||
|
for id in NATIVE {
|
||||||
|
world.ensure_actor_dirs(id)?;
|
||||||
|
world::write_atomic(
|
||||||
|
&world.actor_dir(id).join("status.json"),
|
||||||
|
serde_json::to_string_pretty(&json!({"id": id, "kind": "native", "state": "built-in"}))?.as_bytes(),
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
let parent = dir
|
|
||||||
.parent()
|
// The agent's private configuration. Only the agent can read /data.
|
||||||
.filter(|p| !p.as_os_str().is_empty())
|
world.ensure_actor_dirs("agent")?;
|
||||||
.unwrap_or(Path::new("."));
|
let config = json!({"api": api, "base_url": base_url.trim_end_matches('/'), "model": model, "token": token});
|
||||||
std::fs::create_dir_all(parent)?;
|
let config_path = world.data_dir("agent").join("config.json");
|
||||||
let staging = tempfile::Builder::new()
|
std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
|
||||||
.prefix(".sausage-init-")
|
#[cfg(unix)]
|
||||||
.tempdir_in(parent)?;
|
{
|
||||||
let world = World::create(&staging.path().join("world"))?;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
std::fs::write(
|
std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600))?;
|
||||||
world.root.join("config/agent.yaml"),
|
}
|
||||||
include_str!("agent.yaml"),
|
|
||||||
)?;
|
// Build the agent with the same path as every other Rust actor.
|
||||||
std::fs::write(
|
let source = world.root.join("broker").join("seed").join("agent");
|
||||||
world.root.join("config/boot.yaml"),
|
std::fs::create_dir_all(&source)?;
|
||||||
include_str!("boot.yaml"),
|
AGENT_SOURCE.extract(&source).context("extract agent source")?;
|
||||||
)?;
|
println!("Building the agent actor (the first build takes a few minutes) …");
|
||||||
let cache = cache.map(Path::to_path_buf).unwrap_or_else(|| {
|
let cargo = cargo_actor::Cargo::new(world.clone());
|
||||||
let base = std::env::var_os("XDG_CACHE_HOME")
|
let out = cargo.build(&source).await.context("build agent")?;
|
||||||
.map(PathBuf::from)
|
println!("Built in {:.1} s.", out.seconds);
|
||||||
.unwrap_or_else(|| {
|
|
||||||
PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".cache")
|
let runtime = runtime::Runtime::new()?;
|
||||||
});
|
let broker = Broker::new(world.clone(), runtime)?;
|
||||||
base.join("sausage")
|
let meta = VersionMeta {
|
||||||
});
|
version: 1,
|
||||||
let builder = sausage::cargo::Cargo::new(world.clone(), &cache)?;
|
wasm: world.global_path(&out.wasm).unwrap_or_default(),
|
||||||
let source = world.plugin_dir("agent").join("source");
|
source: Some(source.display().to_string()),
|
||||||
std::fs::create_dir_all(source.join("src"))?;
|
built_by: Some(cargo_actor::ID.into()),
|
||||||
AGENT.extract(source.join("src"))?;
|
requested_by: "init".into(),
|
||||||
std::fs::write(
|
created_at: world::now(),
|
||||||
source.join("Cargo.toml"),
|
};
|
||||||
include_str!("../../agent/Cargo.toml"),
|
broker.create_actor("agent", &out.wasm, Some(&source), meta, false).await?;
|
||||||
)?;
|
println!("World ready at {}. Start it with: sausage run {}", world.root.display(), dir.display());
|
||||||
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());
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<std::process::ExitCode> {
|
async fn run(dir: PathBuf) -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let world = World::new(std::path::absolute(&dir)?);
|
||||||
let _terminal = TerminalMode(nix::sys::termios::tcgetattr(std::io::stdin()).ok());
|
if !world.exists() {
|
||||||
let command = match cli.command {
|
bail!("{} is not a Sausage world. Create one with: sausage init {}", dir.display(), dir.display());
|
||||||
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 }
|
// One host process per world.
|
||||||
|
let lock = std::fs::File::create(world.root.join(".lock"))?;
|
||||||
|
if lock.try_lock().is_err() {
|
||||||
|
bail!("another sausage process runs {}", dir.display());
|
||||||
|
}
|
||||||
|
let runtime = runtime::Runtime::new()?;
|
||||||
|
let broker = Broker::new(world.clone(), runtime)?;
|
||||||
|
|
||||||
|
let rx = broker.open_mailbox("broker");
|
||||||
|
tokio::spawn(broker.clone().run_actor(rx));
|
||||||
|
|
||||||
|
let rx = broker.open_mailbox(cargo_actor::ID);
|
||||||
|
let cargo = Arc::new(cargo_actor::Cargo::new(world.clone()));
|
||||||
|
tokio::spawn(cargo.run_actor(broker.clone(), rx));
|
||||||
|
|
||||||
|
let rx = broker.open_mailbox(console::ID);
|
||||||
|
let console = console::Console::new(broker.clone());
|
||||||
|
tokio::spawn(console.clone().run_mailbox(rx));
|
||||||
|
|
||||||
|
broker.start_all();
|
||||||
|
console.run_input().await;
|
||||||
|
println!("Stopping actors …");
|
||||||
|
broker.shutdown_all().await;
|
||||||
|
drop(lock);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
match cli.command {
|
||||||
|
Cmd::Init { dir, api, base_url, model } => init(dir, api, base_url, model).await,
|
||||||
|
Cmd::Run { dir } => run(dir).await,
|
||||||
}
|
}
|
||||||
};
|
|
||||||
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?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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,157 +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>,
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
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>> {
|
|
||||||
let mut state = self.0.state.lock().unwrap();
|
|
||||||
let p = state.processes.get_mut(pid)?;
|
|
||||||
let ended = p.ended.subscribe();
|
|
||||||
if p.info.state == "stopping" {
|
|
||||||
return Some(ended);
|
|
||||||
}
|
|
||||||
p.info.state = "stopping".into();
|
|
||||||
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(&pid);
|
|
||||||
});
|
|
||||||
Some(ended)
|
|
||||||
}
|
|
||||||
/// Abort a process task and perform its ended-state cleanup.
|
|
||||||
pub(crate) fn kill(&self, pid: &str) {
|
|
||||||
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) {
|
|
||||||
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,346 +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;
|
|
||||||
|
|
||||||
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, id)) = target {
|
|
||||||
let notification = json!({
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"method": "core.notify",
|
|
||||||
"params": { "call": id, "name": name, "params": params }
|
|
||||||
});
|
|
||||||
self.send(&caller, notification);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// 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(error(-32602, "params must be an object"));
|
|
||||||
}
|
|
||||||
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"));
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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(error(-32602, "params must be an object"))
|
|
||||||
} 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
-261
@@ -1,29 +1,37 @@
|
|||||||
//! Wasmtime engine and process instances.
|
//! Wasmtime engine and actor instances.
|
||||||
//!
|
//!
|
||||||
//! An instance is one run of a `wasi:cli/command` component. The host writes
|
//! 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.
|
//! NDJSON lines to its stdin and reads NDJSON lines from its stdout.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use wasmtime::component::{Component, Linker, ResourceTable};
|
use wasmtime::component::{Component, Linker, ResourceTable};
|
||||||
use wasmtime::{Config, Engine, Store};
|
use wasmtime::{Config, Engine, Store};
|
||||||
use wasmtime_wasi::cli::{AsyncStdinStream, AsyncStdoutStream};
|
use wasmtime_wasi::cli::{AsyncStdinStream, AsyncStdoutStream};
|
||||||
|
use wasmtime_wasi::p2::bindings::Command;
|
||||||
use wasmtime_wasi::p2::bindings::CommandPre;
|
use wasmtime_wasi::p2::bindings::filesystem::{preopens, types};
|
||||||
|
use wasmtime_wasi::{FsPerms, I32Exit, WasiCtx, WasiCtxView, WasiView};
|
||||||
/// 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_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView};
|
use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView};
|
||||||
|
|
||||||
/// The HTTP transport hooks supplied to WASI processes.
|
use crate::fs::{ActorFs, ActorFsData, FsExt, Mount};
|
||||||
|
|
||||||
|
pub struct WasiPart {
|
||||||
|
ctx: WasiCtx,
|
||||||
|
table: ResourceTable,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WasiView for WasiPart {
|
||||||
|
fn ctx(&mut self) -> WasiCtxView<'_> {
|
||||||
|
WasiCtxView { ctx: &mut self.ctx, table: &mut self.table }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct HttpHooks {
|
pub struct HttpHooks {
|
||||||
allow: bool,
|
allow: bool,
|
||||||
}
|
}
|
||||||
@@ -58,38 +66,35 @@ impl WasiHttpHooks for HttpHooks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The WASI and HTTP resources owned by one store.
|
pub struct ActorState {
|
||||||
pub struct ProcessState {
|
wasi: WasiPart,
|
||||||
ctx: WasiCtx,
|
fsx: FsExt,
|
||||||
table: ResourceTable,
|
|
||||||
http: WasiHttpCtx,
|
http: WasiHttpCtx,
|
||||||
hooks: HttpHooks,
|
hooks: HttpHooks,
|
||||||
fs: sausage_vfs::adapter::Adapter,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WasiView for ProcessState {
|
impl WasiView for ActorState {
|
||||||
fn ctx(&mut self) -> WasiCtxView<'_> {
|
fn ctx(&mut self) -> WasiCtxView<'_> {
|
||||||
WasiCtxView {
|
self.wasi.ctx()
|
||||||
ctx: &mut self.ctx,
|
|
||||||
table: &mut self.table,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WasiHttpView for ProcessState {
|
impl WasiHttpView for ActorState {
|
||||||
fn http(&mut self) -> WasiHttpCtxView<'_> {
|
fn http(&mut self) -> WasiHttpCtxView<'_> {
|
||||||
WasiHttpCtxView {
|
WasiHttpCtxView { ctx: &mut self.http, table: &mut self.wasi.table, hooks: &mut self.hooks }
|
||||||
ctx: &mut self.http,
|
|
||||||
table: &mut self.table,
|
|
||||||
hooks: &mut self.hooks,
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actor_fs(s: &mut ActorState) -> ActorFs<'_> {
|
||||||
|
ActorFs {
|
||||||
|
inner: wasmtime_wasi::filesystem::WasiFilesystemView::filesystem(&mut s.wasi),
|
||||||
|
ext: &mut s.fsx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A Wasmtime engine and linker shared by the world.
|
|
||||||
pub struct Runtime {
|
pub struct Runtime {
|
||||||
engine: Engine,
|
engine: Engine,
|
||||||
linker: Linker<ProcessState>,
|
linker: Linker<ActorState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How an instance ended.
|
/// How an instance ended.
|
||||||
@@ -103,6 +108,12 @@ pub enum Exit {
|
|||||||
Trap(String),
|
Trap(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Exit {
|
||||||
|
pub fn is_clean(&self) -> bool {
|
||||||
|
matches!(self, Exit::Clean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for Exit {
|
impl std::fmt::Display for Exit {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
@@ -113,96 +124,63 @@ impl std::fmt::Display for Exit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The filesystem, network, and environment supplied to one process.
|
|
||||||
pub struct InstanceConfig {
|
pub struct InstanceConfig {
|
||||||
/// The virtual namespace exposed through the single root preopen.
|
pub mounts: Vec<Mount>,
|
||||||
pub filesystem: sausage_vfs::Mounts,
|
pub allow_http: bool,
|
||||||
/// The store completion barrier used by process cleanup.
|
pub stderr_log: PathBuf,
|
||||||
pub store_ended: tokio::sync::watch::Sender<bool>,
|
|
||||||
/// Outgoing HTTP, and TCP, UDP, and DNS through `wasi:sockets`.
|
|
||||||
pub network: bool,
|
|
||||||
/// The environment variables supplied to the guest.
|
|
||||||
pub env: Vec<(String, String)>,
|
pub env: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A running guest. Dropping it kills the guest.
|
|
||||||
pub struct Instance {
|
pub struct Instance {
|
||||||
/// The host end of stdin, removed when the process stops.
|
stdin: DuplexStream,
|
||||||
pub stdin: Option<DuplexStream>,
|
|
||||||
/// Lines that the guest writes to stdout. Closed when the guest ends.
|
/// Lines that the guest writes to stdout. Closed when the guest ends.
|
||||||
pub stdout: mpsc::Receiver<String>,
|
pub stdout: mpsc::Receiver<String>,
|
||||||
/// The stderr lines that the process writes.
|
task: JoinHandle<Exit>,
|
||||||
pub stderr: mpsc::Receiver<String>,
|
|
||||||
stderr_task: Option<JoinHandle<()>>,
|
|
||||||
task: Option<JoinHandle<Exit>>,
|
|
||||||
store_ended: tokio::sync::watch::Sender<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Instance {
|
impl Instance {
|
||||||
/// Write one line to the guest's stdin, in one write. Meanwhile, pass the
|
/// Write one line to the guest's stdin.
|
||||||
/// lines that the guest writes to `route`: a guest that blocks on a full
|
pub async fn send_line(&mut self, line: &str) -> std::io::Result<()> {
|
||||||
/// stdout would never read its stdin.
|
self.stdin.write_all(line.as_bytes()).await?;
|
||||||
pub async fn send_line(
|
self.stdin.write_all(b"\n").await?;
|
||||||
&mut self,
|
self.stdin.flush().await
|
||||||
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 {
|
|
||||||
stdin.write_all(bytes.as_bytes()).await?;
|
|
||||||
stdin.flush().await
|
|
||||||
};
|
|
||||||
tokio::pin!(write);
|
|
||||||
let mut open = true;
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
done = &mut write => return done,
|
|
||||||
out = self.stdout.recv(), if open => match out {
|
|
||||||
Some(out) => route(out),
|
|
||||||
None => open = false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wait until the guest ends.
|
/// Wait until the guest ends.
|
||||||
pub async fn wait(&mut self) -> Exit {
|
pub async fn wait(&mut self) -> Exit {
|
||||||
let result = self.task.as_mut().unwrap().await;
|
match (&mut self.task).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 {
|
|
||||||
Ok(exit) => exit,
|
Ok(exit) => exit,
|
||||||
Err(e) if e.is_cancelled() => Exit::Trap("killed".into()),
|
Err(e) if e.is_cancelled() => Exit::Trap("killed".into()),
|
||||||
Err(e) => Exit::Trap(format!("host task failed: {e}")),
|
Err(e) => Exit::Trap(format!("host task failed: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_finished(&self) -> bool {
|
||||||
|
self.task.is_finished()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send `shutdown`, close stdin, and wait. Kill the guest after `deadline`.
|
||||||
|
pub async fn stop(mut self, shutdown_line: &str, deadline: Duration) -> Exit {
|
||||||
|
let _ = self.send_line(shutdown_line).await;
|
||||||
|
let _ = self.stdin.shutdown().await;
|
||||||
|
drop(self.stdin);
|
||||||
|
match tokio::time::timeout(deadline, &mut self.task).await {
|
||||||
|
Ok(Ok(exit)) => exit,
|
||||||
|
Ok(Err(e)) => Exit::Trap(format!("host task failed: {e}")),
|
||||||
|
Err(_) => {
|
||||||
|
self.task.abort();
|
||||||
|
let _ = (&mut self.task).await;
|
||||||
|
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.
|
/// The most useful line of a trap: the `wasm trap: ...` reason.
|
||||||
@@ -212,229 +190,115 @@ fn short_trap(full: &str) -> String {
|
|||||||
.find_map(|l| l.find("wasm trap:").map(|i| l[i..].to_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());
|
.unwrap_or_else(|| full.lines().next().unwrap_or("trap").to_string());
|
||||||
let mut line = line.trim().to_string();
|
let mut line = line.trim().to_string();
|
||||||
let mut end = line.len().min(300);
|
line.truncate(300);
|
||||||
while !line.is_char_boundary(end) {
|
|
||||||
end -= 1;
|
|
||||||
}
|
|
||||||
line.truncate(end);
|
|
||||||
line
|
line
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Runtime {
|
impl Runtime {
|
||||||
/// Create an engine with disk caching and a ten-millisecond epoch tick.
|
pub fn new() -> Result<Arc<Self>> {
|
||||||
pub fn new(cache: &Path) -> Result<Arc<Self>> {
|
|
||||||
let mut config = Config::new();
|
let mut config = Config::new();
|
||||||
config.epoch_interruption(true);
|
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 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)?;
|
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)?;
|
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
|
||||||
|
// Replace the filesystem with the commit-on-close layer.
|
||||||
|
linker.allow_shadowing(true);
|
||||||
|
types::add_to_linker::<ActorState, ActorFsData>(&mut linker, actor_fs)?;
|
||||||
|
preopens::add_to_linker::<ActorState, ActorFsData>(&mut linker, actor_fs)?;
|
||||||
|
linker.allow_shadowing(false);
|
||||||
|
|
||||||
// Time slices: every 10 ms each running guest yields to the scheduler.
|
// Time slices: every 10 ms each running guest yields to the scheduler.
|
||||||
let runtime = Arc::new(Self { engine, linker });
|
let ticker = engine.clone();
|
||||||
let ticker = Arc::downgrade(&runtime);
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(Duration::from_millis(10));
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
let Some(runtime) = ticker.upgrade() else {
|
ticker.increment_epoch();
|
||||||
break;
|
|
||||||
};
|
|
||||||
runtime.engine.increment_epoch();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(runtime)
|
Ok(Arc::new(Self { engine, linker }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compile component bytes through Wasmtime's disk cache.
|
pub fn load(&self, wasm: &Path) -> Result<Component> {
|
||||||
pub fn compile(&self, bytes: &[u8]) -> Result<Component> {
|
Component::from_file(&self.engine, wasm)
|
||||||
Ok(Component::new(&self.engine, bytes)?)
|
.map_err(anyhow::Error::from)
|
||||||
|
.with_context(|| format!("load {}", wasm.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn prepare(&self, component: &Component) -> Prepared {
|
pub fn start(self: &Arc<Self>, component: &Component, cfg: InstanceConfig) -> Result<Instance> {
|
||||||
// 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> {
|
|
||||||
let (host_in, guest_in) = tokio::io::duplex(64 * 1024);
|
let (host_in, guest_in) = tokio::io::duplex(64 * 1024);
|
||||||
let (guest_out, host_out) = 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();
|
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.stdin(AsyncStdinStream::new(guest_in));
|
b.stdin(AsyncStdinStream::new(guest_in));
|
||||||
b.stdout(AsyncStdoutStream::new(8192, guest_out));
|
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 {
|
for (k, v) in &cfg.env {
|
||||||
b.env(k, v);
|
b.env(k, v);
|
||||||
}
|
}
|
||||||
|
for m in &cfg.mounts {
|
||||||
let state = ProcessState {
|
let perms = if m.writable { FsPerms::ReadWrite } else { FsPerms::ReadOnly };
|
||||||
ctx: b.build(),
|
b.preopened_dir(&m.host, &m.guest, perms)
|
||||||
table: ResourceTable::new(),
|
.map_err(anyhow::Error::from)
|
||||||
|
.with_context(|| format!("mount {} at {}", m.host.display(), m.guest))?;
|
||||||
|
}
|
||||||
|
let state = ActorState {
|
||||||
|
wasi: WasiPart { ctx: b.build(), table: ResourceTable::new() },
|
||||||
|
fsx: FsExt::new(cfg.mounts),
|
||||||
http: WasiHttpCtx::new(),
|
http: WasiHttpCtx::new(),
|
||||||
hooks: HttpHooks { allow: cfg.network },
|
hooks: HttpHooks { allow: cfg.allow_http },
|
||||||
fs: sausage_vfs::adapter::Adapter::new(cfg.filesystem),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let (line_tx, line_rx) = mpsc::channel(1024);
|
let (line_tx, line_rx) = mpsc::channel(1024);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut reader = BufReader::new(host_out);
|
let mut lines = BufReader::new(host_out).lines();
|
||||||
let mut bytes = Vec::new();
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
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());
|
|
||||||
if line_tx.send(line).await.is_err() {
|
if line_tx.send(line).await.is_err() {
|
||||||
break;
|
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();
|
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 task = tokio::spawn(async move {
|
||||||
let mut store = Store::new(&rt.engine, state);
|
let mut store = Store::new(&rt.engine, state);
|
||||||
store.epoch_deadline_async_yield_and_update(1);
|
store.epoch_deadline_async_yield_and_update(1);
|
||||||
let result = async {
|
let result = async {
|
||||||
let cmd = prepared
|
let cmd = Command::instantiate_async(&mut store, &component, &rt.linker).await?;
|
||||||
.map_err(wasmtime::Error::msg)?
|
|
||||||
.instantiate_async(&mut store)
|
|
||||||
.await?;
|
|
||||||
cmd.wasi_cli_run().call_run(&mut store).await
|
cmd.wasi_cli_run().call_run(&mut store).await
|
||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
match result {
|
let exit = match result {
|
||||||
Ok(Ok(())) => Exit::Clean,
|
Ok(Ok(())) => Exit::Clean,
|
||||||
Ok(Err(())) => Exit::Code(1),
|
Ok(Err(())) => Exit::Code(1),
|
||||||
Err(e) => match e.downcast_ref::<I32Exit>() {
|
Err(e) => match e.downcast_ref::<I32Exit>() {
|
||||||
Some(I32Exit(0)) => Exit::Clean,
|
Some(I32Exit(0)) => Exit::Clean,
|
||||||
Some(I32Exit(c)) => Exit::Code(*c),
|
Some(I32Exit(c)) => Exit::Code(*c),
|
||||||
None => {
|
None => {
|
||||||
|
// Keep the full backtrace in the log, and a short reason in the status.
|
||||||
let full = format!("{e:?}");
|
let full = format!("{e:?}");
|
||||||
if let Some((journal, pid, plugin)) = &trap_log {
|
log_note(&stderr_log, &format!("trap details:\n{full}"));
|
||||||
journal.write(pid, plugin, "core", &full);
|
|
||||||
}
|
|
||||||
Exit::Trap(short_trap(&full))
|
Exit::Trap(short_trap(&full))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
// Process exit closes every file, so a clean exit commits.
|
||||||
|
if exit.is_clean() {
|
||||||
|
store.data_mut().fsx.commit_all();
|
||||||
}
|
}
|
||||||
|
exit
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Instance {
|
Ok(Instance { stdin: host_in, stdout: line_rx, task })
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
//! Ordered boot services and their identities for upgrade restarts (PROTOCOL.md section 8).
|
|
||||||
use crate::Host;
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub(crate) struct Service {
|
|
||||||
pub(crate) method: String,
|
|
||||||
pub(crate) params: Value,
|
|
||||||
pub(crate) request: Value,
|
|
||||||
pub(crate) starting: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
|
||||||
}
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
/// Records a unique request identity before the service can answer or exit.
|
|
||||||
pub(crate) async fn start_service(&self, method: String, params: Value) {
|
|
||||||
if self.0.shutdown.load(std::sync::atomic::Ordering::SeqCst) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let request = json!(uuid::Uuid::now_v7().to_string());
|
|
||||||
let starting = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
|
|
||||||
self.0.state.lock().unwrap().services.push(Service {
|
|
||||||
method: method.clone(),
|
|
||||||
params: params.clone(),
|
|
||||||
request: request.clone(),
|
|
||||||
starting: starting.clone(),
|
|
||||||
});
|
|
||||||
self.0
|
|
||||||
.journal
|
|
||||||
.write("core", "core", "core", &format!("service start {method}"));
|
|
||||||
if let Err(error) = self
|
|
||||||
.route("core".into(), Some(request), method, params, true)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
self.0
|
|
||||||
.journal
|
|
||||||
.write("core", "core", "core", &format!("service failed {error}"));
|
|
||||||
}
|
|
||||||
starting.store(false, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,197 +0,0 @@
|
|||||||
//! Grace periods, service restarts, and retirement of replaced versions (PROTOCOL.md section 3.7).
|
|
||||||
use crate::{Host, services::Service};
|
|
||||||
use serde_json::json;
|
|
||||||
use std::{collections::BTreeMap, sync::atomic::Ordering, time::Duration};
|
|
||||||
use tokio::sync::watch;
|
|
||||||
|
|
||||||
pub(crate) struct OldProcess {
|
|
||||||
pid: String,
|
|
||||||
version: String,
|
|
||||||
ended: watch::Receiver<bool>,
|
|
||||||
}
|
|
||||||
pub(crate) struct Upgrade {
|
|
||||||
plugin: String,
|
|
||||||
previous: String,
|
|
||||||
grace: Duration,
|
|
||||||
processes: Vec<OldProcess>,
|
|
||||||
services: Vec<(Service, Option<watch::Receiver<bool>>)>,
|
|
||||||
}
|
|
||||||
impl Host {
|
|
||||||
/// Snapshots old admissions while activation holds the admission write guard.
|
|
||||||
pub(crate) fn snapshot_upgrade(
|
|
||||||
&self,
|
|
||||||
plugin: &str,
|
|
||||||
previous: String,
|
|
||||||
current: &str,
|
|
||||||
grace: f64,
|
|
||||||
) -> Upgrade {
|
|
||||||
let mut state = self.0.state.lock().unwrap();
|
|
||||||
let processes = state
|
|
||||||
.processes
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, process)| {
|
|
||||||
process.info.plugin == plugin
|
|
||||||
&& process.info.version != current
|
|
||||||
&& process.info.method != "rpc.discover"
|
|
||||||
})
|
|
||||||
.map(|(pid, process)| OldProcess {
|
|
||||||
pid: pid.clone(),
|
|
||||||
version: process.info.version.clone(),
|
|
||||||
ended: process.ended.subscribe(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let mut services = Vec::new();
|
|
||||||
let mut retained = Vec::new();
|
|
||||||
for service in std::mem::take(&mut state.services) {
|
|
||||||
if service
|
|
||||||
.method
|
|
||||||
.split_once('.')
|
|
||||||
.is_some_and(|(name, _)| name == plugin)
|
|
||||||
{
|
|
||||||
let process = state
|
|
||||||
.processes
|
|
||||||
.values()
|
|
||||||
.find(|process| process.id.as_ref() == Some(&service.request));
|
|
||||||
if process.is_some_and(|process| process.info.version == current)
|
|
||||||
|| (process.is_none() && service.starting.load(Ordering::SeqCst))
|
|
||||||
{
|
|
||||||
retained.push(service);
|
|
||||||
} else {
|
|
||||||
services.push((service, process.map(|process| process.ended.subscribe())));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
retained.push(service);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.services = retained;
|
|
||||||
Upgrade {
|
|
||||||
plugin: plugin.into(),
|
|
||||||
previous,
|
|
||||||
grace: Duration::from_secs_f64(grace),
|
|
||||||
processes,
|
|
||||||
services,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// Lets old calls finish, stops them after grace, and starts each lifecycle hook once.
|
|
||||||
pub(crate) fn begin_upgrade(&self, upgrade: Upgrade) {
|
|
||||||
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_f64()),
|
|
||||||
);
|
|
||||||
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();
|
|
||||||
let grace = upgrade.grace;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut ended = process.ended;
|
|
||||||
tokio::select! {
|
|
||||||
_ = ended.wait_for(|ended|*ended) => (),
|
|
||||||
_ = tokio::time::sleep(grace) => { host.begin_stop(&process.pid); }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (service, ended) in upgrade.services {
|
|
||||||
let host = host.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Some(mut ended) = ended {
|
|
||||||
let _ = ended.wait_for(|ended| *ended).await;
|
|
||||||
}
|
|
||||||
if !host.0.shutdown.load(Ordering::SeqCst) {
|
|
||||||
host.start_service(service.method, service.params).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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,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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+196
-44
@@ -1,71 +1,223 @@
|
|||||||
//! World folders and atomic build output helpers.
|
//! On-disk layout of a Sausage workspace ("world").
|
||||||
use anyhow::{Result, bail};
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! <world>/
|
||||||
|
//! sausage.json registry: actors, versions, subscriptions
|
||||||
|
//! actor/<id>/public/ written by the actor, read-only for others
|
||||||
|
//! actor/<id>/source/ source of the running version (broker)
|
||||||
|
//! actor/<id>/status.json state of the actor (broker)
|
||||||
|
//! actor/<id>/stderr.log stderr of the actor (broker)
|
||||||
|
//! private/<id>/data/ private state of the actor
|
||||||
|
//! broker/versions/<id>/<n>/ actor.wasm, source/, meta.json, snapshot/
|
||||||
|
//! archive/ removed actors
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::fs::TMP_PREFIX;
|
||||||
|
|
||||||
|
/// IDs of the actors that the host implements natively.
|
||||||
|
pub const NATIVE: &[&str] = &["broker", "console", "cargo"];
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
/// The absolute host folder shared by all processes.
|
|
||||||
pub struct World {
|
pub struct World {
|
||||||
/// The absolute path of the world on the host.
|
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Registry {
|
||||||
|
pub actors: BTreeMap<String, ActorRecord>,
|
||||||
|
/// Publisher ID → subscriber IDs.
|
||||||
|
pub subscriptions: BTreeMap<String, BTreeSet<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
pub struct ActorRecord {
|
||||||
|
pub version: u64,
|
||||||
|
pub next_version: u64,
|
||||||
|
pub running: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 spawn or upgrade.
|
||||||
|
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 {
|
impl World {
|
||||||
/// Validate reserved root names and resolve the world path.
|
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||||
pub fn open(path: &Path) -> Result<Self> {
|
Self { root: root.into() }
|
||||||
for name in ["proc", "run", "self"] {
|
}
|
||||||
if path.join(name).symlink_metadata().is_ok() {
|
|
||||||
bail!("reserved world path: {name}");
|
pub fn registry_path(&self) -> PathBuf {
|
||||||
|
self.root.join("sausage.json")
|
||||||
|
}
|
||||||
|
pub fn actor_dir(&self, id: &str) -> PathBuf {
|
||||||
|
self.root.join("actor").join(id)
|
||||||
|
}
|
||||||
|
pub fn actors_root(&self) -> PathBuf {
|
||||||
|
self.root.join("actor")
|
||||||
|
}
|
||||||
|
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.root.join("private").join(id).join("data")
|
||||||
|
}
|
||||||
|
pub fn versions_dir(&self, id: &str) -> PathBuf {
|
||||||
|
self.root.join("broker").join("versions").join(id)
|
||||||
|
}
|
||||||
|
pub fn version_dir(&self, id: &str, v: u64) -> PathBuf {
|
||||||
|
self.versions_dir(id).join(v.to_string())
|
||||||
|
}
|
||||||
|
pub fn scratch_dir(&self) -> PathBuf {
|
||||||
|
self.root.join("broker").join("scratch")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exists(&self) -> bool {
|
||||||
|
self.registry_path().is_file()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the directories of one actor.
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_registry(&self) -> Result<Registry> {
|
||||||
|
let text = std::fs::read_to_string(self.registry_path())
|
||||||
|
.with_context(|| format!("no Sausage world at {}", self.root.display()))?;
|
||||||
|
Ok(serde_json::from_str(&text)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_registry(&self, reg: &Registry) -> Result<()> {
|
||||||
|
write_atomic(&self.registry_path(), serde_json::to_string_pretty(reg)?.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 clean = |base: PathBuf, rest: &str| -> Result<PathBuf> {
|
||||||
|
let mut out = base.clone();
|
||||||
|
for c in Path::new(rest).components() {
|
||||||
|
match c {
|
||||||
|
std::path::Component::Normal(n) => out.push(n),
|
||||||
|
std::path::Component::CurDir | std::path::Component::RootDir => {}
|
||||||
|
_ => bail!("path must not contain '..': {path}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(out)
|
||||||
root: path.canonicalize()?,
|
};
|
||||||
})
|
if let Some(rest) = path.strip_prefix("/actor/") {
|
||||||
|
return clean(self.actors_root(), rest);
|
||||||
}
|
}
|
||||||
/// Create the folders and empty boot configuration of a new world.
|
if let Some(rest) = path.strip_prefix("/public/").or(path.strip_prefix("/public").filter(|r| r.is_empty())) {
|
||||||
pub fn create(path: &Path) -> Result<Self> {
|
return clean(self.public_dir(sender), rest);
|
||||||
if path.exists() {
|
|
||||||
bail!("world already exists: {}", path.display());
|
|
||||||
}
|
}
|
||||||
for dir in ["plugins", "state/core", "config"] {
|
if let Some(rest) = path.strip_prefix("/data/").or(path.strip_prefix("/data").filter(|r| r.is_empty())) {
|
||||||
std::fs::create_dir_all(path.join(dir))?;
|
return clean(self.data_dir(sender), rest);
|
||||||
}
|
}
|
||||||
std::fs::write(path.join("config/boot.yaml"), "[]\n")?;
|
if NATIVE.contains(&sender) && Path::new(path).is_absolute() {
|
||||||
Self::open(path)
|
return Ok(PathBuf::from(path));
|
||||||
}
|
}
|
||||||
/// Return the folder that contains a plugin component.
|
bail!("cannot resolve path {path} for {sender}: use /actor/<id>/..., /public/... or /data/...")
|
||||||
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 {
|
/// The path of a host file in the global filesystem, if it has one.
|
||||||
self.root.join("state").join(name)
|
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<()> {
|
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
let dir = path.parent().context("path has no parent")?;
|
||||||
let tmp = path.with_extension("tmp");
|
std::fs::create_dir_all(dir)?;
|
||||||
|
let tmp = dir.join(format!(
|
||||||
|
"{TMP_PREFIX}host-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
path.file_name().and_then(|n| n.to_str()).unwrap_or("file")
|
||||||
|
));
|
||||||
std::fs::write(&tmp, bytes)?;
|
std::fs::write(&tmp, bytes)?;
|
||||||
std::fs::rename(tmp, path)?;
|
std::fs::rename(&tmp, path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
/// Replace a build copy with a source tree, without target folders.
|
|
||||||
pub fn replace_source(from: &Path, to: &Path) -> Result<()> {
|
/// Copy a directory tree. Skips `target/` folders and uncommitted files.
|
||||||
if to.exists() {
|
pub fn copy_tree(from: &Path, to: &Path) -> Result<()> {
|
||||||
std::fs::remove_dir_all(to)?;
|
|
||||||
}
|
|
||||||
copy_tree(from, to)
|
|
||||||
}
|
|
||||||
fn copy_tree(from: &Path, to: &Path) -> Result<()> {
|
|
||||||
std::fs::create_dir_all(to)?;
|
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?;
|
let entry = entry?;
|
||||||
if entry.file_name() == "target" {
|
let name = entry.file_name();
|
||||||
|
let name_str = name.to_string_lossy();
|
||||||
|
if name_str.starts_with(TMP_PREFIX) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if entry.file_type()?.is_dir() {
|
let ty = entry.file_type()?;
|
||||||
copy_tree(&entry.path(), &to.join(entry.file_name()))?;
|
let dst = to.join(&name);
|
||||||
} else {
|
if ty.is_dir() {
|
||||||
std::fs::copy(entry.path(), to.join(entry.file_name()))?;
|
if name_str == "target" || name_str == ".git" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
copy_tree(&entry.path(), &dst)?;
|
||||||
|
} else if ty.is_file() {
|
||||||
|
std::fs::copy(entry.path(), &dst)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the directory `dst` with a copy of `src`, as close to atomic as a
|
||||||
|
/// directory can be: copy to a sibling, then swap with two renames.
|
||||||
|
pub fn replace_tree(src: &Path, dst: &Path) -> 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_tree(src, &fresh)?;
|
||||||
|
} 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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
missing_function();
|
|
||||||
}
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
[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"]
|
|
||||||
|
|
||||||
[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 +0,0 @@
|
|||||||
//! 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
//! Checks that cargo invokes integration test binaries through the WASI runner.
|
|
||||||
#[test]
|
|
||||||
fn integration_case() {
|
|
||||||
println!("INTEGRATION_CASE");
|
|
||||||
}
|
|
||||||
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,9 +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;
|
|
||||||
@@ -1,349 +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;
|
|
||||||
}
|
|
||||||
/// 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,114 +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})
|
|
||||||
}
|
|
||||||
"serve" | "hold" => {
|
|
||||||
send(json!({"jsonrpc":"2.0","id":request["id"],"result":{"version":version}}));
|
|
||||||
if request["method"] == "serve" {
|
|
||||||
std::fs::write(format!("/state/{plugin}/service"), version).unwrap();
|
|
||||||
}
|
|
||||||
for _ in lines {}
|
|
||||||
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,516 +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();
|
|
||||||
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),
|
|
||||||
"read" => read_file(params),
|
|
||||||
"write" => write_file(params),
|
|
||||||
"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
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "activation-guest"
|
name = "bench"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[workspace]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//! {"type":"bench","target":"echo","n":1000}: ping-pong with the target, one
|
||||||
|
//! message at a time, and report the round-trip times.
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::io::{BufRead, Write};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut out = std::io::stdout().lock();
|
||||||
|
let mut lines = std::io::stdin().lock().lines();
|
||||||
|
while let Some(Ok(line)) = lines.next() {
|
||||||
|
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||||
|
match msg["body"]["type"].as_str() {
|
||||||
|
Some("shutdown") => break,
|
||||||
|
Some("bench") => {
|
||||||
|
let target = msg["body"]["target"].as_str().unwrap_or("echo").to_string();
|
||||||
|
let n = msg["body"]["n"].as_u64().unwrap_or(1000);
|
||||||
|
let mut times = Vec::new();
|
||||||
|
let started = Instant::now();
|
||||||
|
for i in 0..n {
|
||||||
|
let t = Instant::now();
|
||||||
|
writeln!(out, "{}", json!({"to": target, "id": i, "body": {"type": "ping", "i": i}})).unwrap();
|
||||||
|
out.flush().unwrap();
|
||||||
|
// Wait for the reply.
|
||||||
|
loop {
|
||||||
|
let Some(Ok(l)) = lines.next() else { return };
|
||||||
|
let Ok(r) = serde_json::from_str::<Value>(&l) else { continue };
|
||||||
|
if r["reply_to"].as_str().is_some_and(|s| s.ends_with(&format!("/{i}"))) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
times.push(t.elapsed().as_micros() as u64);
|
||||||
|
}
|
||||||
|
let total = started.elapsed().as_secs_f64();
|
||||||
|
times.sort();
|
||||||
|
let p = |q: f64| times[((times.len() as f64 - 1.0) * q) as usize];
|
||||||
|
let reply = json!({"type": "bench_result", "n": n, "total_s": total,
|
||||||
|
"msgs_per_s": (n as f64 / total).round(),
|
||||||
|
"p50_us": p(0.5), "p90_us": p(0.9), "p99_us": p(0.99), "max_us": p(1.0)});
|
||||||
|
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
|
||||||
|
out.flush().unwrap();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cargo-fail"
|
name = "crasher"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[workspace]
|
[dependencies]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//! A bad version: it starts, waits 3 seconds (long enough for the smoke
|
||||||
|
//! test), then panics. Used to test the automatic rollback.
|
||||||
|
fn main() {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||||
|
panic!("this version is broken");
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "probe"
|
name = "echo"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
[workspace]
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
wasip2 = "1"
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::io::{BufRead, Write};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut out = std::io::stdout().lock();
|
||||||
|
for line in std::io::stdin().lock().lines() {
|
||||||
|
let Ok(msg) = serde_json::from_str::<Value>(&line.unwrap()) else { continue };
|
||||||
|
if msg["body"]["type"] == "shutdown" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let env = json!({"to": msg["from"], "reply_to": msg["id"], "body": {"type": "echo", "got": msg["body"]}});
|
||||||
|
writeln!(out, "{env}").unwrap();
|
||||||
|
out.flush().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "www"
|
name = "fetch"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
[workspace]
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
sausage-plugin = { path = "../../sdk" }
|
serde_json = "1"
|
||||||
wstd = "0.6"
|
wstd = "0.6"
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
//! Fetches URLs over HTTP(S) and writes the body to /public/fetch/<n>.
|
||||||
|
//! Reads stdin and runs requests at the same time: while a fetch runs,
|
||||||
|
//! `ping` still answers at once.
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use wstd::http::{Body, Client, Request};
|
||||||
|
use wstd::io::{AsyncRead, AsyncWrite};
|
||||||
|
|
||||||
|
type Out = Rc<RefCell<Vec<String>>>;
|
||||||
|
|
||||||
|
async fn flush(out: &Out) {
|
||||||
|
let lines: Vec<String> = out.borrow_mut().drain(..).collect();
|
||||||
|
let mut stdout = wstd::io::stdout();
|
||||||
|
for l in lines {
|
||||||
|
let _ = stdout.write_all(format!("{l}\n").as_bytes()).await;
|
||||||
|
}
|
||||||
|
let _ = stdout.flush().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch(n: u64, msg: Value, out: Out) {
|
||||||
|
let url = msg["body"]["url"].as_str().unwrap_or_default().to_string();
|
||||||
|
let started = wstd::time::Instant::now();
|
||||||
|
let result = async {
|
||||||
|
let req = Request::get(&url).body(Body::empty()).map_err(|e| e.to_string())?;
|
||||||
|
let resp = Client::new().send(req).await.map_err(|e| format!("{e:?}"))?;
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
let bytes = resp.into_body().contents().await.map_err(|e| format!("{e:?}"))?.to_vec();
|
||||||
|
std::fs::create_dir_all("/public/fetch").map_err(|e| e.to_string())?;
|
||||||
|
let path = format!("/public/fetch/{n}");
|
||||||
|
std::fs::write(&path, &bytes).map_err(|e| e.to_string())?;
|
||||||
|
Ok::<_, String>((status, bytes.len(), path))
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
let ms = started.elapsed().as_millis();
|
||||||
|
let body = match result {
|
||||||
|
Ok((status, size, path)) => {
|
||||||
|
let id = std::env::var("SAUSAGE_ACTOR").unwrap_or("fetch".into());
|
||||||
|
json!({"type": "fetched", "status": status, "bytes": size, "path": path.replacen("/public", &format!("/actor/{id}/public"), 1), "ms": ms})
|
||||||
|
}
|
||||||
|
Err(e) => json!({"type": "error", "reason": e, "ms": ms}),
|
||||||
|
};
|
||||||
|
out.borrow_mut().push(json!({"to": msg["from"], "reply_to": msg["id"], "body": body}).to_string());
|
||||||
|
flush(&out).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
wstd::runtime::block_on(async {
|
||||||
|
let out: Out = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let mut stdin = wstd::io::stdin();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut chunk = vec![0u8; 8192];
|
||||||
|
let mut n = 0u64;
|
||||||
|
loop {
|
||||||
|
let Ok(len) = stdin.read(&mut chunk).await else { break };
|
||||||
|
if len == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&chunk[..len]);
|
||||||
|
while let Some(pos) = buf.iter().position(|b| *b == b'\n') {
|
||||||
|
let line: Vec<u8> = buf.drain(..=pos).collect();
|
||||||
|
let Ok(msg) = serde_json::from_slice::<Value>(&line) else { continue };
|
||||||
|
match msg["body"]["type"].as_str() {
|
||||||
|
Some("shutdown") => return,
|
||||||
|
Some("fetch") => {
|
||||||
|
n += 1;
|
||||||
|
wstd::runtime::spawn(fetch(n, msg, out.clone())).detach();
|
||||||
|
}
|
||||||
|
Some("ping") => {
|
||||||
|
out.borrow_mut().push(json!({"to": msg["from"], "reply_to": msg["id"], "body": {"type": "pong"}}).to_string());
|
||||||
|
flush(&out).await;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[package]
|
||||||
|
name = "fstest"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde_json = "1"
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//! Filesystem probe for the spike. Keeps files open across messages.
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{BufRead, Write};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut open: HashMap<String, std::fs::File> = HashMap::new();
|
||||||
|
let mut out = std::io::stdout().lock();
|
||||||
|
for line in std::io::stdin().lock().lines() {
|
||||||
|
let Ok(line) = line else { break };
|
||||||
|
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||||
|
let b = &msg["body"];
|
||||||
|
let path = b["path"].as_str().unwrap_or_default().to_string();
|
||||||
|
let r = |res: std::io::Result<Value>| match res {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => json!({"type": "error", "error": e.to_string()}),
|
||||||
|
};
|
||||||
|
let reply = match b["type"].as_str() {
|
||||||
|
Some("shutdown") => break,
|
||||||
|
Some("write") => r(std::fs::write(&path, b["text"].as_str().unwrap_or_default()).map(|_| json!({"type": "ok"}))),
|
||||||
|
Some("open") => r((|| {
|
||||||
|
let mut f = std::fs::File::create(&path)?;
|
||||||
|
f.write_all(b["text"].as_str().unwrap_or_default().as_bytes())?;
|
||||||
|
open.insert(path.clone(), f);
|
||||||
|
Ok(json!({"type": "ok", "open": true}))
|
||||||
|
})()),
|
||||||
|
Some("append") => r((|| {
|
||||||
|
let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
|
||||||
|
f.write_all(b["text"].as_str().unwrap_or_default().as_bytes())?;
|
||||||
|
open.insert(path.clone(), f);
|
||||||
|
Ok(json!({"type": "ok", "open": true}))
|
||||||
|
})()),
|
||||||
|
Some("close") => json!({"type": "ok", "closed": open.remove(&path).is_some()}),
|
||||||
|
Some("read") => r(std::fs::read_to_string(&path).map(|t| json!({"type": "content", "text": t}))),
|
||||||
|
Some("list") => r(std::fs::read_dir(&path).map(|rd| {
|
||||||
|
let mut names: Vec<String> = rd.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||||
|
names.sort();
|
||||||
|
json!({"type": "entries", "names": names})
|
||||||
|
})),
|
||||||
|
Some("remove") => r(std::fs::remove_file(&path).map(|_| json!({"type": "ok"}))),
|
||||||
|
Some("crash") => panic!("asked to crash"),
|
||||||
|
_ => json!({"type": "error", "error": "unknown message"}),
|
||||||
|
};
|
||||||
|
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
|
||||||
|
out.flush().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[package]
|
||||||
|
name = "notes"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde_json = "1"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
//! Keeps a list of notes in /data/notes.json and publishes them as
|
||||||
|
//! /public/notes.txt. Version 2: adds `count` and no longer crashes.
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::io::{BufRead, Write};
|
||||||
|
|
||||||
|
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(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut out = std::io::stdout().lock();
|
||||||
|
for line in std::io::stdin().lock().lines() {
|
||||||
|
let Ok(line) = line else { break };
|
||||||
|
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||||
|
let body = &msg["body"];
|
||||||
|
let reply = match body["type"].as_str() {
|
||||||
|
Some("shutdown") => break,
|
||||||
|
Some("add") => {
|
||||||
|
notes.push(body["text"].as_str().unwrap_or_default().to_string());
|
||||||
|
save(¬es);
|
||||||
|
json!({"type": "added", "count": notes.len()})
|
||||||
|
}
|
||||||
|
Some("list") => json!({"type": "notes", "items": notes}),
|
||||||
|
Some("count") => json!({"type": "count", "count": notes.len(), "version": 2}),
|
||||||
|
// Version 2 fixes the crash.
|
||||||
|
Some("crash") => json!({"type": "not_crashing", "version": 2}),
|
||||||
|
_ => json!({"type": "error", "reason": "unknown message"}),
|
||||||
|
};
|
||||||
|
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
|
||||||
|
out.flush().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{"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"}}
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
name = "notes"
|
name = "notes"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
[workspace]
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
sausage-plugin = { path = "../../sdk" }
|
serde_json = "1"
|
||||||
|
|||||||
+32
-79
@@ -1,84 +1,37 @@
|
|||||||
//! Persistent notes in `/state/notes/notes.json`.
|
//! Keeps a list of notes in /data/notes.json and publishes them as
|
||||||
//! Each call has its own process. The add method locks the file before its read-modify-write operation.
|
//! /public/notes.txt.
|
||||||
//! Atomic replacement lets readers observe a complete list without a lock.
|
use serde_json::{Value, json};
|
||||||
use sausage_plugin::serde_json;
|
use std::io::{BufRead, Write};
|
||||||
use sausage_plugin::{Result, lock, rpc, schema, state_dir, write_atomic};
|
|
||||||
/// The stateless notes plugin reads its file on each call.
|
const DATA: &str = "/data/notes.json";
|
||||||
struct Notes;
|
|
||||||
/// Parameters for a new note.
|
fn save(notes: &[String]) {
|
||||||
#[schema]
|
std::fs::write(DATA, serde_json::to_string(notes).unwrap()).unwrap();
|
||||||
struct AddParams {
|
std::fs::write("/public/notes.txt", notes.join("\n") + "\n").unwrap();
|
||||||
/// The complete note text, including any newlines.
|
|
||||||
text: String,
|
|
||||||
}
|
|
||||||
/// The result after a note enters the list.
|
|
||||||
#[schema]
|
|
||||||
struct Added {
|
|
||||||
/// The number of notes, including the new note.
|
|
||||||
count: usize,
|
|
||||||
}
|
|
||||||
#[rpc]
|
|
||||||
impl Notes {
|
|
||||||
/// Add a note and return the new count.
|
|
||||||
async fn add(&self, AddParams { text }: AddParams) -> Result<Added> {
|
|
||||||
let path = state_dir().join("notes.json");
|
|
||||||
let _guard = lock(&path).await?;
|
|
||||||
let mut notes = load()?;
|
|
||||||
notes.push(text);
|
|
||||||
std::fs::create_dir_all(state_dir())?;
|
|
||||||
write_atomic(path, serde_json::to_vec(¬es)?)?;
|
|
||||||
Ok(Added { count: notes.len() })
|
|
||||||
}
|
|
||||||
/// List all notes, oldest first.
|
|
||||||
fn list(&self) -> Result<Vec<String>> {
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// Read the list, or return an empty list when the file does not exist.
|
|
||||||
fn load() -> Result<Vec<String>> {
|
|
||||||
match std::fs::read(state_dir().join("notes.json")) {
|
|
||||||
Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]),
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// Start the SDK transport for notes.
|
|
||||||
fn main() {
|
|
||||||
sausage_plugin::run(Notes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tests run in fresh worlds under the WASI test runner.
|
fn main() {
|
||||||
#[cfg(test)]
|
let mut notes: Vec<String> = std::fs::read_to_string(DATA)
|
||||||
mod tests {
|
.ok()
|
||||||
use super::*;
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
/// The core lock protects two sequential additions and their counts.
|
.unwrap_or_default();
|
||||||
#[sausage_plugin::test]
|
let mut out = std::io::stdout().lock();
|
||||||
async fn add_appends_and_counts() {
|
for line in std::io::stdin().lock().lines() {
|
||||||
assert_eq!(
|
let Ok(line) = line else { break };
|
||||||
Notes
|
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||||
.add(AddParams {
|
let body = &msg["body"];
|
||||||
text: "milk".into()
|
let reply = match body["type"].as_str() {
|
||||||
})
|
Some("shutdown") => break,
|
||||||
.await
|
Some("add") => {
|
||||||
.unwrap()
|
notes.push(body["text"].as_str().unwrap_or_default().to_string());
|
||||||
.count,
|
save(¬es);
|
||||||
1
|
json!({"type": "added", "count": notes.len()})
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
Notes
|
|
||||||
.add(AddParams {
|
|
||||||
text: "eggs".into()
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.count,
|
|
||||||
2
|
|
||||||
);
|
|
||||||
assert_eq!(Notes.list().unwrap(), ["milk", "eggs"]);
|
|
||||||
}
|
}
|
||||||
/// A fresh test world has no notes file.
|
Some("list") => json!({"type": "notes", "items": notes}),
|
||||||
#[test]
|
Some("crash") => panic!("asked to crash"),
|
||||||
fn list_is_empty_without_a_file() {
|
_ => json!({"type": "error", "reason": "unknown message"}),
|
||||||
assert!(Notes.list().unwrap().is_empty());
|
};
|
||||||
|
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
|
||||||
|
out.flush().unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{"send": {"type": "add", "text": "test note"}}
|
||||||
|
{"expect": {"type": "added"}}
|
||||||
|
{"send": {"type": "list"}}
|
||||||
|
{"expect": {"type": "notes"}}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "secrets"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
[workspace]
|
|
||||||
[dependencies]
|
|
||||||
sausage-plugin = { path = "../../sdk" }
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
//! Keep a process-local vault behind a fixed endpoint at `/run/secrets`.
|
|
||||||
//! Start `secrets.serve` as a detached boot service. Its memory lasts until process exit.
|
|
||||||
use sausage_plugin::{Ctx, Result, listen, rpc, schema};
|
|
||||||
use std::{cell::RefCell, collections::BTreeMap, rc::Rc};
|
|
||||||
/// The service entry point publishes one process-local vault.
|
|
||||||
struct Secrets;
|
|
||||||
#[rpc(endpoints(vault=Vault))]
|
|
||||||
impl Secrets {
|
|
||||||
/// Open the in-memory vault and publish its endpoint.
|
|
||||||
async fn serve(&self) -> Result<()> {
|
|
||||||
listen("/run/secrets", Rc::new(Vault::default())).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// The endpoint owns secrets in memory, not on disk.
|
|
||||||
#[derive(Default)]
|
|
||||||
struct Vault {
|
|
||||||
/// Named values disappear when this process ends.
|
|
||||||
entries: RefCell<BTreeMap<String, String>>,
|
|
||||||
}
|
|
||||||
/// The name and value of a secret.
|
|
||||||
#[schema]
|
|
||||||
struct PutParams {
|
|
||||||
/// The unique name within this vault.
|
|
||||||
name: String,
|
|
||||||
/// The text to store until process exit.
|
|
||||||
value: String,
|
|
||||||
}
|
|
||||||
/// The name of a secret to read.
|
|
||||||
#[schema]
|
|
||||||
struct GetParams {
|
|
||||||
/// The unique name within this vault.
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
#[rpc]
|
|
||||||
impl Vault {
|
|
||||||
/// Store a secret and report its name to the caller.
|
|
||||||
fn put(&self, ctx: &Ctx, p: PutParams) -> Result<()> {
|
|
||||||
ctx.notify("stored", sausage_plugin::serde_json::json!({"name":p.name}))?;
|
|
||||||
self.entries.borrow_mut().insert(p.name, p.value);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
/// Read a secret, or return null when its name is absent.
|
|
||||||
fn get(&self, p: GetParams) -> Result<Option<String>> {
|
|
||||||
Ok(self.entries.borrow().get(&p.name).cloned())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// Start the SDK transport for the vault service.
|
|
||||||
fn main() {
|
|
||||||
sausage_plugin::run(Secrets)
|
|
||||||
}
|
|
||||||
/// Tests verify absent keys and process-local storage.
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
/// Missing names return null instead of an RPC error.
|
|
||||||
#[test]
|
|
||||||
fn absent_secret() {
|
|
||||||
assert_eq!(
|
|
||||||
Vault::default()
|
|
||||||
.get(GetParams {
|
|
||||||
name: "missing".into()
|
|
||||||
})
|
|
||||||
.unwrap(),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
/// Store a value and retrieve it through the same process-local vault.
|
|
||||||
#[sausage_plugin::test]
|
|
||||||
async fn stores_secret() {
|
|
||||||
let vault = Vault::default();
|
|
||||||
vault
|
|
||||||
.put(
|
|
||||||
&Ctx::default(),
|
|
||||||
PutParams {
|
|
||||||
name: "token".into(),
|
|
||||||
value: "example".into(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
vault
|
|
||||||
.get(GetParams {
|
|
||||||
name: "token".into()
|
|
||||||
})
|
|
||||||
.unwrap()
|
|
||||||
.as_deref(),
|
|
||||||
Some("example")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[package]
|
||||||
|
name = "ticker"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde_json = "1"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//! Sends {"type":"add","text":"tick N"} to `notes` every 50 ms, N = 1..=200.
|
||||||
|
//! Does not read stdin, so the broker kills it at shutdown; that is fine.
|
||||||
|
use std::io::Write;
|
||||||
|
fn main() {
|
||||||
|
let mut out = std::io::stdout().lock();
|
||||||
|
for n in 1..=200 {
|
||||||
|
let msg = serde_json::json!({"to": "notes", "body": {"type": "add", "text": format!("tick {n}")}});
|
||||||
|
writeln!(out, "{msg}").unwrap();
|
||||||
|
out.flush().unwrap();
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
// Stay alive without doing anything.
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(3600));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
//! Serve files from `/state/www` and expose a control endpoint at `/self/control`.
|
|
||||||
//! Start this process detached or as a service so it outlives its caller.
|
|
||||||
//! Clients read `/proc/<pid>/control`. A fresh core call cannot inspect this process's counters.
|
|
||||||
use sausage_plugin::{Ctx, Result, background, listen, pid, rpc, schema, state_dir};
|
|
||||||
use std::{cell::Cell, rc::Rc};
|
|
||||||
use wstd::{
|
|
||||||
io::{AsyncRead, AsyncWrite},
|
|
||||||
iter::AsyncIterator,
|
|
||||||
net::TcpListener,
|
|
||||||
};
|
|
||||||
/// The HTTP plugin starts a server and returns its process ID.
|
|
||||||
struct Www;
|
|
||||||
/// The server configuration.
|
|
||||||
#[schema]
|
|
||||||
struct StartParams {
|
|
||||||
/// The HTTP port on all network interfaces.
|
|
||||||
port: u16,
|
|
||||||
}
|
|
||||||
#[rpc(endpoints(control=Control),grace_seconds=5)]
|
|
||||||
impl Www {
|
|
||||||
/// Start the HTTP server and return its process ID.
|
|
||||||
async fn start(&self, p: StartParams) -> Result<String> {
|
|
||||||
let tcp = TcpListener::bind(&format!("0.0.0.0:{}", p.port)).await?;
|
|
||||||
let control = Rc::new(Control { hits: Cell::new(0) });
|
|
||||||
listen("/self/control", control.clone()).await?;
|
|
||||||
background(serve(tcp, control));
|
|
||||||
Ok(pid())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// Shared process-local state for the HTTP task and its endpoint.
|
|
||||||
struct Control {
|
|
||||||
/// The number of answered requests.
|
|
||||||
hits: Cell<u64>,
|
|
||||||
}
|
|
||||||
#[rpc]
|
|
||||||
impl Control {
|
|
||||||
/// Count the HTTP requests this server answered.
|
|
||||||
fn stats(&self) -> Result<u64> {
|
|
||||||
Ok(self.hits.get())
|
|
||||||
}
|
|
||||||
/// End this process after the response reaches the client.
|
|
||||||
fn stop(&self, ctx: &Ctx) -> Result<()> {
|
|
||||||
ctx.exit_after_response();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// Answer HTTP requests with files and count all responses, including missing files.
|
|
||||||
async fn serve(tcp: TcpListener, control: Rc<Control>) {
|
|
||||||
let mut incoming = tcp.incoming();
|
|
||||||
while let Some(Ok(mut stream)) = incoming.next().await {
|
|
||||||
let mut request = [0; 8192];
|
|
||||||
let Ok(size) = stream.read(&mut request).await else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let line = String::from_utf8_lossy(&request[..size]);
|
|
||||||
let path = line
|
|
||||||
.split_whitespace()
|
|
||||||
.nth(1)
|
|
||||||
.unwrap_or("/")
|
|
||||||
.trim_start_matches('/');
|
|
||||||
let (status, body) = match std::fs::read(state_dir().join(if path.is_empty() {
|
|
||||||
"index.html"
|
|
||||||
} else {
|
|
||||||
path
|
|
||||||
})) {
|
|
||||||
Ok(body) => ("200 OK", body),
|
|
||||||
Err(_) => ("404 Not Found", b"Not found".to_vec()),
|
|
||||||
};
|
|
||||||
control.hits.set(control.hits.get() + 1);
|
|
||||||
let header = format!(
|
|
||||||
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
||||||
body.len()
|
|
||||||
);
|
|
||||||
let _ = stream.write_all(header.as_bytes()).await;
|
|
||||||
let _ = stream.write_all(&body).await;
|
|
||||||
let _ = stream.flush().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// Start the SDK transport for the HTTP plugin.
|
|
||||||
fn main() {
|
|
||||||
sausage_plugin::run(Www)
|
|
||||||
}
|
|
||||||
/// Tests check local counters and the service entry point.
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
/// Control reports its shared counter without a core round trip.
|
|
||||||
#[test]
|
|
||||||
fn stats_reads_counter() {
|
|
||||||
assert_eq!(Control { hits: Cell::new(3) }.stats().unwrap(), 3);
|
|
||||||
}
|
|
||||||
/// The listener binds an ephemeral port and publishes its control endpoint.
|
|
||||||
#[sausage_plugin::test]
|
|
||||||
async fn starts_on_available_port() {
|
|
||||||
assert_eq!(Www.start(StartParams { port: 0 }).await.unwrap(), pid());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-50
@@ -1,38 +1,5 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"crane": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1791754810,
|
|
||||||
"narHash": "sha256-cN0Mgk+sQtCJcGWs9H4M0CnLRAuC4I8aCwiBTcBJs6E=",
|
|
||||||
"owner": "ipetkov",
|
|
||||||
"repo": "crane",
|
|
||||||
"rev": "bd63bf1d0a0f6dcacd7549c714a671ee63b0db6a",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "ipetkov",
|
|
||||||
"repo": "crane",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1791914995,
|
|
||||||
"narHash": "sha256-Zf/xfQEAasnwnmEfHqW/4hjDE/s44yn2hD4RuVIZRjA=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "b7a79d3fea1e214f6d668d521cecec0c01e26544",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1791637130,
|
"lastModified": 1791637130,
|
||||||
@@ -51,8 +18,6 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"crane": "crane",
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"rust-overlay": "rust-overlay"
|
"rust-overlay": "rust-overlay"
|
||||||
}
|
}
|
||||||
@@ -76,21 +41,6 @@
|
|||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"systems": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user