Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0951ba18e1 | ||
|
|
d0876626df | ||
|
|
c1f182d9b6 | ||
|
|
49453fa9af | ||
|
|
2ff30ecf70 | ||
|
|
e1409b93f2 | ||
|
|
3435b556fb | ||
|
|
567646ba87 | ||
|
|
bcde6ba325 | ||
|
|
599ee510cb | ||
|
|
285f77a9e2 | ||
|
|
c636eb0803 | ||
|
|
f9260faa5b | ||
|
|
11144b947e | ||
|
|
cee862275c | ||
|
|
2c06f34931 | ||
|
|
9d575b6a62 | ||
|
|
69cf80a114 | ||
|
|
1cd8971aa3 | ||
|
|
bfd9339d25 | ||
|
|
868b7aa3e4 | ||
|
|
bb734a5c74 | ||
|
|
7f76eab161 | ||
|
|
0a8e8c2e3c | ||
|
|
c9d9864743 | ||
|
|
1f49d0f7bb |
@@ -0,0 +1,11 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 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/`.
|
||||||
|
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 message.
|
||||||
|
5. **Raw and minimal.** Sausage has no permissions, sandboxing beyond WASI,
|
||||||
|
limits, or authentication. Do not add them to the core. Add nothing to
|
||||||
|
the host that an actor (or the agent, by changing itself) could do.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `cli/`: the host (`sausage` binary and library).
|
||||||
|
- `lib.rs`: `Host`, which starts and stops a world.
|
||||||
|
- `broker.rs`: routing, mailboxes, the version store, broker requests.
|
||||||
|
- `supervisor.rs`: runs one WASM actor (restarts, swaps, rollbacks).
|
||||||
|
- `runtime.rs`: Wasmtime instances and what they get (mounts, network).
|
||||||
|
- `candidate.rs`: tests a new version. `cargo_actor.rs`: builds Rust.
|
||||||
|
- `world.rs`: the on-disk layout. `console.rs`: the terminal.
|
||||||
|
- `sdk/`: `sausage-actor`, the SDK for Rust actors. Never published: the
|
||||||
|
`cargo` actor points `sausage-actor = "0.1"` at its local copy.
|
||||||
|
- `agent/`: the agent actor. `sausage init` embeds and builds it.
|
||||||
|
- `examples/`: small actors. They are not workspace members.
|
||||||
|
- `PROTOCOL.md`: the contract between the host and the actors. Change it in
|
||||||
|
the same commit as the behavior. `DECISIONS.md` says why; when the two
|
||||||
|
disagree, `PROTOCOL.md` and the code win.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix flake check -L # all tests below; CI runs this on every push
|
||||||
|
nix develop -c cargo test -p sausage -p sausage-actor -p agent
|
||||||
|
nix develop -c cargo build --release -p agent --target wasm32-wasip2
|
||||||
|
nix develop -c cargo clippy --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
`nix run -- init` and `nix run -- run` must keep working without other
|
||||||
|
setup.
|
||||||
Generated
+28
-1
@@ -15,7 +15,7 @@ dependencies = [
|
|||||||
name = "agent"
|
name = "agent"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-channel",
|
"sausage-actor",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"wstd",
|
"wstd",
|
||||||
@@ -1593,11 +1593,22 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"toml_edit",
|
||||||
"wasmtime",
|
"wasmtime",
|
||||||
"wasmtime-wasi",
|
"wasmtime-wasi",
|
||||||
"wasmtime-wasi-http",
|
"wasmtime-wasi-http",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sausage-actor"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"wstd",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -1929,6 +1940,19 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.25.17+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_parser"
|
name = "toml_parser"
|
||||||
version = "1.1.5+spec-1.1.0"
|
version = "1.1.5+spec-1.1.0"
|
||||||
@@ -2791,6 +2815,9 @@ name = "winnow"
|
|||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winx"
|
name = "winx"
|
||||||
|
|||||||
+6
-1
@@ -1,5 +1,10 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["cli", "agent"]
|
members = ["cli", "agent", "sdk"]
|
||||||
default-members = ["cli"]
|
default-members = ["cli"]
|
||||||
exclude = ["examples"]
|
exclude = ["examples"]
|
||||||
|
|
||||||
|
# Wasmtime compiles components with Cranelift. Optimize dependencies in dev
|
||||||
|
# builds, so that tests and debug runs start actors quickly.
|
||||||
|
[profile.dev.package."*"]
|
||||||
|
opt-level = 2
|
||||||
|
|||||||
+46
-145
@@ -1,154 +1,55 @@
|
|||||||
# Decisions
|
# Decisions
|
||||||
|
|
||||||
This file lists the design decisions of the spike. **(you)** marks a decision
|
Why Sausage is the way it is. `PROTOCOL.md` and the code own the details;
|
||||||
that Surma made. **(spike)** marks a decision that Scout made alone during the
|
when this file disagrees with them, they win.
|
||||||
night and that needs review.
|
|
||||||
|
|
||||||
## Architecture
|
## The shape
|
||||||
|
|
||||||
- **(you)** Actors are WASI commands (`wasm32-wasip2`). They use stdin, stdout,
|
- **Actors are WASI commands, not components with interfaces.** stdin,
|
||||||
files, and HTTP. No WIT interfaces, no component composition.
|
stdout, files, HTTP, and sockets are enough, and every language can do
|
||||||
- **(you)** Actors exchange newline-delimited JSON through the host, which is
|
them.
|
||||||
the broker. No built-in request/response. A reply is a message with
|
- **Messages are JSON lines through a broker, fire and forget.** Like
|
||||||
`reply_to`. Delivery is at most once.
|
Erlang: no acknowledgments, no request/response. A reply is a message with
|
||||||
- **(you)** The broker accepts only `.wasm` files. It knows no source language.
|
`reply_to`. Sausage is not a reliable message bus.
|
||||||
- **(you)** `cargo` is a built-in Rust compiler actor. **(spike)** It is native
|
- **The broker accepts only `.wasm` files.** It knows no source language.
|
||||||
(host code), because cargo cannot run inside WASM.
|
`cargo` is one compiler actor; others can add more languages.
|
||||||
- **(you)** The agent is a normal WASM actor with the fixed ID `agent`. It can
|
- **The world folder is the source of truth.** Everything about an actor is
|
||||||
read and upgrade its own source.
|
in `actor/<id>/`. Copy a folder into another world, and it runs there.
|
||||||
- **(spike)** Three native actors: `broker`, `console`, `cargo`.
|
- **The agent is a normal WASM actor.** It can read and upgrade its own
|
||||||
- **(you)** One Cargo workspace with two crates: `cli` (binary `sausage`) and
|
source, and change how it does inference.
|
||||||
`agent`. **(spike)** Example actors are in `examples/`, outside the workspace.
|
|
||||||
- **(you)** `sausage init` builds the agent and asks for the API shape, the
|
|
||||||
endpoint, the model, and the token. **(spike)** The token can also come from
|
|
||||||
`SAUSAGE_API_TOKEN`. The source contains no endpoint and no token.
|
|
||||||
|
|
||||||
## Messages
|
## Minimal on purpose
|
||||||
|
|
||||||
- **(spike)** Stdin line: `{"id", "from", "reply_to"?, "body"}`.
|
The harness does only what it needs to keep going. Everything else can be an
|
||||||
Stdout line: `{"to"?, "id"?, "reply_to"?, "body"}`.
|
actor, or a change that the agent makes to itself.
|
||||||
- **(spike)** The broker sets `from`. An actor can choose its own `id`. The
|
|
||||||
broker then uses `"<sender>/<id>"`, so IDs cannot collide or be faked.
|
|
||||||
Without an `id`, the broker assigns `m-<n>`.
|
|
||||||
- **(you)** Without `to`, a message goes to the subscribers of the sender.
|
|
||||||
**(spike)** `subscribe` / `unsubscribe` messages to the broker. The registry
|
|
||||||
stores subscriptions, so they survive restarts and upgrades. `init`
|
|
||||||
subscribes `console` to `agent`.
|
|
||||||
- **(spike)** Errors from routing come back as
|
|
||||||
`{"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.
|
|
||||||
|
|
||||||
## Filesystem
|
- **The host is not self-modifiable; actors are.** So the host keeps only
|
||||||
|
what an actor cannot do for itself: routing, running instances, versions,
|
||||||
|
tests before an install, and the rollback of a version that crash-loops.
|
||||||
|
Without that rollback, a broken self-upgrade would end the agent.
|
||||||
|
- **No permissions, no limits, no hiding.** Any actor can message, upgrade,
|
||||||
|
or stop any actor, read every folder, and use the network. People who want rules
|
||||||
|
can build them.
|
||||||
|
- **No re-delivery and no state snapshots.** A crash loses the message, and
|
||||||
|
all versions of an actor share `data/` and `public/`. A rollback restores
|
||||||
|
code, not data.
|
||||||
|
- **The agent is dumb.** It has a short prompt, a few tools, an append-only
|
||||||
|
history that it sends in full, and no compaction. When the context is too
|
||||||
|
large, it stops sending the oldest turns, so it never gets stuck.
|
||||||
|
|
||||||
- **(you)** `/data` private, `/public` own public files,
|
## Small choices
|
||||||
`/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.
|
|
||||||
|
|
||||||
## Broker requests
|
- **Plain POSIX files.** An earlier version staged every write until close.
|
||||||
|
That broke `tempfile`, git, and SQLite, which expect normal files. Atomic
|
||||||
- **(spike)** `spawn`, `upgrade`, `rollback`, `stop`, `start`, `remove`,
|
publishing is the usual temporary file + `rename`.
|
||||||
`subscribe`, `unsubscribe`, `list`.
|
- **Test first, then switch.** A new version is tested where it lies. Only
|
||||||
- **(you)** `spawn` and `upgrade` take `wasm`, plus optional `source` and
|
a version that passes goes to `versions/`, and the old one runs until
|
||||||
`built_by` metadata. **(spike)** The broker copies the `.wasm` file and the
|
then. `versions/` keeps every version that was installed, so a rollback
|
||||||
source folder into `broker/versions/<id>/<n>/`, with `meta.json` (paths,
|
can always go back. A file that fails stays where it was put.
|
||||||
builder, requester, time).
|
- **Tests are JSON transcripts** next to the source. They run on a copy of
|
||||||
- **(spike)** Path resolution: `/actor/...` is global, and `/public/...` and
|
the actor's state, without network, so a test cannot send real requests.
|
||||||
`/data/...` belong to the sender. `..` is rejected. Native senders (the
|
- **`broker` and `cargo` never answer a reply,** so no two actors answer
|
||||||
console) can give absolute host paths.
|
each other forever. The agent starts a turn only for text that is not a
|
||||||
- **(you)** Anyone can upgrade anyone. No permissions.
|
reply, for the same reason.
|
||||||
- **(spike)** Actor IDs: `a-z`, `0-9`, `-`, `_`, at most 64 characters.
|
- **The SDK is not published.** `cargo` points `sausage-actor = "0.1"` at its
|
||||||
- **(spike)** `remove` archives every folder of the actor to `archive/`. Nothing
|
own copy, which it also shows in `/actor/cargo/public/sdk/`.
|
||||||
is deleted.
|
|
||||||
|
|
||||||
## Upgrades, tests, rollback
|
|
||||||
|
|
||||||
- **(spike)** The tests are message transcripts in `source/tests/*.jsonl`:
|
|
||||||
`{"send": body}` and `{"expect": subset-of-body, "to"?}`. Each file runs in
|
|
||||||
a fresh candidate on a copy of the actor's `/data` and `/public`. The
|
|
||||||
candidate has no HTTP access. An `expect` waits up to 10 s. Without tests,
|
|
||||||
the candidate must stay up for 2 s (smoke test).
|
|
||||||
- **(spike)** Swap: hold the mailbox, stop the old instance gracefully,
|
|
||||||
snapshot `/data` + `/public` into the old version's folder, install the new
|
|
||||||
source in `/actor/<id>/source`, start the new instance, release the mailbox.
|
|
||||||
- **(spike)** Restart after a crash: backoff 0.5 s × 2ⁿ, at most 30 s. The
|
|
||||||
counter resets after 60 s of uptime.
|
|
||||||
- **(spike)** Parked message: when an instance traps, the broker keeps the
|
|
||||||
last delivered message. It delivers that message again after the next
|
|
||||||
upgrade, not after a plain restart (that would loop).
|
|
||||||
- **(spike)** Crash loop: 3 crashes within 30 s after an upgrade → automatic
|
|
||||||
rollback to the previous version, with its data snapshot. The broker
|
|
||||||
informs the actor that requested the upgrade.
|
|
||||||
- **(spike)** `rollback` without `version` goes to the highest older version.
|
|
||||||
It restores that version's snapshot, and it first snapshots the current
|
|
||||||
state, so a roll-forward is possible.
|
|
||||||
- **(spike)** Versions are per actor, not global.
|
|
||||||
|
|
||||||
## Limits
|
|
||||||
|
|
||||||
- **(spike)** Mailbox: 1,000 messages. When it is full, the sender gets
|
|
||||||
`undeliverable`.
|
|
||||||
- **(spike)** The broker waits 30 s when it writes to an actor's stdin. Then it
|
|
||||||
drops the message and logs it.
|
|
||||||
- **(spike)** Time slices: the epoch advances every 10 ms, and each guest
|
|
||||||
yields at each epoch.
|
|
||||||
- **(spike)** One `sausage run` per world (file lock on `world/.lock`).
|
|
||||||
- **(spike)** HTTP for all running actors, with no allow-list yet.
|
|
||||||
|
|
||||||
## Agent
|
|
||||||
|
|
||||||
- **(you)** API shapes: OpenAI Responses and Anthropic Messages, chosen by
|
|
||||||
`"api"` in `/data/config.json`.
|
|
||||||
- **(spike)** Config fields: `api`, `base_url`, `model`, `token`, optional
|
|
||||||
`reasoning_effort` and `max_tokens` (16,000). OpenAI: `POST
|
|
||||||
{base_url}/v1/responses` with `store: false` and encrypted reasoning passed
|
|
||||||
back. Anthropic: `POST {base_url}/v1/messages` with `x-api-key`.
|
|
||||||
- **(spike)** One shared conversation in `/data/history.json`, in a neutral
|
|
||||||
format, so the API shape can change. The history is limited to 600 entries,
|
|
||||||
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.
|
|
||||||
|
|||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
# The Sausage protocol
|
||||||
|
|
||||||
|
This is the contract between the host and the actors. An actor in any
|
||||||
|
language that follows it works in Sausage. The host publishes this file at
|
||||||
|
`/actor/broker/public/PROTOCOL.md`, so every actor can read it.
|
||||||
|
|
||||||
|
## Actors
|
||||||
|
|
||||||
|
An actor is a WASI 0.2 command (`wasm32-wasip2`): a program with `main`. The
|
||||||
|
host runs one instance per actor. An instance can run forever.
|
||||||
|
|
||||||
|
The instance gets:
|
||||||
|
|
||||||
|
- **stdin:** one message per line, from the broker
|
||||||
|
- **stdout:** one message per line, to the broker
|
||||||
|
- **stderr:** free text, appended to `/actor/<id>/stderr.log`
|
||||||
|
- **environment:** `SAUSAGE_ACTOR` (its ID), `SAUSAGE_VERSION` (its version),
|
||||||
|
and `SAUSAGE_CANDIDATE=1` during a test run
|
||||||
|
- **the network:** outgoing HTTP and HTTPS through `wasi:http` (the host does
|
||||||
|
TLS), and TCP, UDP, and DNS through `wasi:sockets`, including listening on
|
||||||
|
a port of the host
|
||||||
|
- **the filesystem** described below
|
||||||
|
|
||||||
|
## Messages
|
||||||
|
|
||||||
|
A line on **stdin**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id": "m-17", "from": "console", "reply_to": "agent/a5", "body": {"type": "text", "text": "hi"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
A line on **stdout**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"to": "console", "id": "a6", "reply_to": "m-17", "body": {"type": "text", "text": "hello"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `body` is required. By convention it is an object with a `"type"`.
|
||||||
|
Text for people is `{"type": "text", "text": "..."}`.
|
||||||
|
- `to` is the recipient. Without `to`, the broker publishes the message to
|
||||||
|
every subscriber of the sender.
|
||||||
|
- `id` is an optional string. If the actor sets it, the message ID is `"<sender>/<id>"`.
|
||||||
|
Otherwise, the broker assigns `m-<n>`. The broker always sets `from`.
|
||||||
|
- `reply_to` marks a reply: its value is the ID of the message it answers.
|
||||||
|
There is no request/response. A reply is a new message.
|
||||||
|
- **Never answer a reply,** or two actors can answer each other forever.
|
||||||
|
`broker` and `cargo` ignore messages that have a `reply_to`.
|
||||||
|
- Messages are fire and forget: delivery is at most once. Each actor has a
|
||||||
|
mailbox of 1,000 messages, which keeps messages while the actor restarts
|
||||||
|
or is upgraded. If the broker cannot deliver a message, the sender gets
|
||||||
|
`{"type": "undeliverable", "reason": "...", "to": "..."}` from `broker`.
|
||||||
|
- An actor must keep reading stdin. If the broker must stop or upgrade an
|
||||||
|
actor while a message waits for it to read stdin, it kills the actor.
|
||||||
|
|
||||||
|
Before the broker stops an instance, it sends
|
||||||
|
`{"id": "broker/shutdown", "from": "broker", "body": {"type": "shutdown"}}`
|
||||||
|
and then closes stdin. The broker still routes what the actor writes until
|
||||||
|
it exits. After 5 seconds, it kills the instance.
|
||||||
|
|
||||||
|
## Filesystem
|
||||||
|
|
||||||
|
Each instance sees three folders:
|
||||||
|
|
||||||
|
| Path | Access | Contents |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/data` | read-write | the actor's state |
|
||||||
|
| `/public` | read-write | the files that the actor publishes |
|
||||||
|
| `/actor` | read-only | the `actor/` folder of the world |
|
||||||
|
|
||||||
|
`/actor/<id>/` holds everything about an actor: `actor.wasm`, `actor.json`
|
||||||
|
(installed version, running, subscriptions, tapped), `source/`, `public/`, `data/`,
|
||||||
|
`stderr.log`, and `versions/<n>/`. `ls /actor` lists all actors.
|
||||||
|
|
||||||
|
Files are plain POSIX files: a write is visible at once. To publish a file
|
||||||
|
so that readers never see half of it, write a temporary file and `rename` it
|
||||||
|
(the Rust SDK has `write_atomic`).
|
||||||
|
|
||||||
|
Send large or binary data as a path under `/actor/<id>/public/`, not inside a
|
||||||
|
message.
|
||||||
|
|
||||||
|
### Actor-served files: `/actor/<id>/fs/`
|
||||||
|
|
||||||
|
Files under `/actor/<id>/fs/` are not on disk: the actor `<id>` serves them.
|
||||||
|
Every actor can read and write them. The host turns each file operation into a
|
||||||
|
message from `fs` to `<id>` and waits up to 10 seconds for the reply. `path`
|
||||||
|
is relative to `fs/` and starts with `/` (the folder itself is `/`).
|
||||||
|
|
||||||
|
| Request | Reply |
|
||||||
|
| --- | --- |
|
||||||
|
| `{"type": "fs_read", "path"}` | `{"type": "fs_file", "text"}` (or `"base64"` instead of `"text"`), or `{"type": "fs_dir", "entries": [{"name", "type": "file" \| "dir"}]}` |
|
||||||
|
| `{"type": "fs_write", "path", "text"}` (or `"base64"` for non-UTF-8 data) | `{"type": "fs_done"}` |
|
||||||
|
| `{"type": "fs_mkdir", "path"}`, `{"type": "fs_remove", "path"}` | `{"type": "fs_done"}` |
|
||||||
|
| `{"type": "fs_rename", "path", "to"}` | `{"type": "fs_done"}` |
|
||||||
|
|
||||||
|
Any request can be answered with `{"type": "fs_error", "errno": "no-entry"}`.
|
||||||
|
`errno` is a `wasi:filesystem` error code: `no-entry`, `exist`,
|
||||||
|
`not-directory`, `is-directory`, `not-empty`, `access`, `not-permitted`,
|
||||||
|
`invalid`, `unsupported`, `busy`, or `io` (also for anything else).
|
||||||
|
|
||||||
|
- Opening a file sends `fs_read` once. Reads come from that copy.
|
||||||
|
- After a change, closing (or syncing) the file sends the whole file in one
|
||||||
|
`fs_write`.
|
||||||
|
- No reply within 10 seconds, or an actor that does not exist, makes the
|
||||||
|
operation fail (`io`, or `no-entry`). An actor that opens its own `fs/`
|
||||||
|
waits for itself and fails after the timeout.
|
||||||
|
- `fs` is reserved: no actor can have that ID.
|
||||||
|
|
||||||
|
## The broker
|
||||||
|
|
||||||
|
Requests to `broker`. The answer is a message whose `reply_to` is the ID of
|
||||||
|
the request: the request's `type` in past tense (`spawned`, `upgraded`,
|
||||||
|
`rolled_back`, `stopped`, `started`, `removed`, `subscribed`, `unsubscribed`,
|
||||||
|
`tapping`, `untapped`, `scanned`, `exiting`), or `{"type": "error", "reason": "..."}`.
|
||||||
|
|
||||||
|
| `type` | Fields | Effect |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `spawn` | `actor`, `wasm`, `source`?, `built_by`? | test, then start a new actor |
|
||||||
|
| `upgrade` | `actor`, `wasm`, `source`?, `built_by`? | test, then swap the version |
|
||||||
|
| `rollback` | `actor`, `version` | go back to an older version |
|
||||||
|
| `stop`, `start` | `actor` | stop or start an actor |
|
||||||
|
| `remove` | `actor` | stop the actor (normal shutdown), then delete its whole folder `/actor/<id>/` (all versions, `data/`, `public/`, logs); answer `{"type": "removed", "actor"}`. Messages to it are then undeliverable, and the ID can be spawned again. An unknown actor or a native one (`broker`, `console`, `cargo`, `fs`) is an `error` |
|
||||||
|
| `subscribe`, `unsubscribe` | `actor` | receive (or not) what `actor` publishes |
|
||||||
|
| `tap`, `untap` | | receive (or not) a copy of every message the broker routes |
|
||||||
|
| `scan` | | boot new or changed `actor.wasm` files |
|
||||||
|
| `exit` | | answer `exiting`, stop every actor, and end the `sausage` process (a wrapper like `nix run .#loop` starts it again, updated) |
|
||||||
|
|
||||||
|
Paths are `/actor/...`, or the sender's `/public/...` or `/data/...`.
|
||||||
|
|
||||||
|
**Taps.** After `tap`, the sender gets a copy of every message that the
|
||||||
|
broker routes (direct messages, replies, and publishes, between any actors,
|
||||||
|
including its own), for debugging, logging, or a live view of the world:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "tapped", "message": {"id": "m-17", "from": "console", "to": "agent", "reply_to": null, "body": {...}}, "ts": 1792132579067}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `to` is `null` for a publish: one copy per publish, not per subscriber.
|
||||||
|
- `ts` is the time of routing, in milliseconds since the Unix epoch.
|
||||||
|
- Copies come from `broker`. They are not routed themselves, so they are
|
||||||
|
never tapped, and each message is tapped once per tapper.
|
||||||
|
- A copy that does not fit into the tapper's mailbox is dropped; routing
|
||||||
|
never waits for a tapper, and nobody gets `undeliverable` for a copy.
|
||||||
|
- Never answer a copy: the answer would be tapped again, forever.
|
||||||
|
- Like subscriptions, a tap is saved in `actor.json` (`"tapped": true`) and
|
||||||
|
survives restarts and upgrades. `untap` ends it.
|
||||||
|
|
||||||
|
**Versions.** A new version (from `spawn`, `upgrade`, or a changed
|
||||||
|
`actor.wasm` on disk) is tested first, while the old version keeps running.
|
||||||
|
Only when its tests pass does the broker copy it to `versions/<n>/`
|
||||||
|
(`actor.wasm`, `source/`, `meta.json`) and switch to it. So `versions/` holds
|
||||||
|
exactly the versions that were installed, and instances always run from
|
||||||
|
there. If the tests fail, nothing changes, and the sender gets an `error`
|
||||||
|
with the reason. All versions share the actor's `data/` and `public/`: a
|
||||||
|
new version must read the data of the old one.
|
||||||
|
|
||||||
|
**Tests.** The broker runs the candidate on a copy of the actor's `data/`
|
||||||
|
and `public/`, without network. The tests are JSON files in
|
||||||
|
`<source>/tests/*.json`, one scenario per file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"description": "add, then list",
|
||||||
|
"steps": [
|
||||||
|
{"send": {"type": "add", "text": "milk"}, "expect": {"type": "added"}},
|
||||||
|
{"send": {"type": "list"}, "expect": [{"type": "notes"}]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each step sends `send` (from `from`, default `test`), then reads the next
|
||||||
|
line that the candidate writes for each `expect` entry, in order, within 10
|
||||||
|
seconds. An entry matches when it is a subset of the message body. With
|
||||||
|
`to`, the message must also go to that recipient. Each file runs in a fresh
|
||||||
|
instance. A version without test files passes.
|
||||||
|
|
||||||
|
**Swap.** The broker holds the mailbox, stops the old instance, makes the
|
||||||
|
top-level `actor.wasm` and `source/` a copy of the new version, and starts
|
||||||
|
it. A rollback does the same with the older version.
|
||||||
|
|
||||||
|
**Crashes.** The broker restarts a crashed actor after 0.5 s, then doubles
|
||||||
|
the delay up to 30 s. A new version that crashes 3 times within 30 seconds
|
||||||
|
after an upgrade goes back to the previous version. The actor that asked for
|
||||||
|
the upgrade gets `{"type": "rolled_back", "from", "to", "reason", "last_exit"}`.
|
||||||
|
|
||||||
|
**The folder is the truth.** At start, and on `scan`, the broker boots every
|
||||||
|
`actor/<id>/actor.wasm`. If the file differs from the installed version, it
|
||||||
|
is a new version, tested with the tests in `actor/<id>/source/`. If its
|
||||||
|
tests fail, the file stays where it is, the reason goes to `stderr.log`, and
|
||||||
|
the next scan tries again.
|
||||||
|
|
||||||
|
## The `cargo` actor
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "build", "source": "/actor/agent/public/drafts/notes"}
|
||||||
|
```
|
||||||
|
|
||||||
|
The reply is `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", "built_by": "cargo", ...}`
|
||||||
|
or `{"type": "build_failed", "errors": "..."}`. Pass `wasm`, `source`, and
|
||||||
|
`built_by` on to `spawn` or `upgrade`.
|
||||||
|
|
||||||
|
A Rust actor depends on the SDK with `sausage-actor = "0.1"`. `cargo` points
|
||||||
|
that dependency at the SDK of the host, which it publishes at
|
||||||
|
`/actor/cargo/public/sdk/`.
|
||||||
@@ -2,64 +2,74 @@
|
|||||||
|
|
||||||
You can see how the sausage is made.
|
You can see how the sausage is made.
|
||||||
|
|
||||||
Sausage is a system of actors that you grow by talking to it. Every actor is a
|
Sausage is a world of small programs ("actors") that you grow by talking to
|
||||||
WASI program (a `.wasm` file) with stdin and stdout. Actors send each other
|
it. Each actor is a WASI program with stdin and stdout. Actors send each other
|
||||||
newline-delimited JSON messages through a broker. The agent is an actor too:
|
JSON messages, one per line, through a broker. The agent is an actor too: it
|
||||||
it does inference over HTTP, writes new actors, and can change its own code.
|
writes new actors in Rust, builds them, tests them, starts them, and can
|
||||||
|
change its own code.
|
||||||
|
|
||||||
Inspired by [Jiti](https://ghuntley.com/lisp/), but with WASM instead of a
|
## Start
|
||||||
Lisp image.
|
|
||||||
|
|
||||||
## Quick start
|
You need [Nix](https://nixos.org/download) with flakes, and an API token for
|
||||||
|
an LLM (OpenAI Responses or Anthropic Messages API). No clone needed:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix develop
|
nix run git+https://gitea.surma.technology/surma/sausage -- init # creates ./world and builds the agent (a few minutes)
|
||||||
cargo build --release
|
nix run git+https://gitea.surma.technology/surma/sausage -- run # starts the world and a console
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`init` builds the agent (the first build takes a few minutes) and writes its
|
`init` asks for the API shape (`openai-responses` or `anthropic-messages`),
|
||||||
configuration to `world/private/agent/data/config.json`. The source contains
|
the base URL, the model, and the token. It reads `SAUSAGE_API`,
|
||||||
no endpoint and no token.
|
`SAUSAGE_BASE_URL`, `SAUSAGE_MODEL`, and `SAUSAGE_API_TOKEN` first. The
|
||||||
|
values go to `world/actor/agent/data/config.json`. Both commands take another
|
||||||
|
folder as an argument, for example `... -- run my-world`.
|
||||||
|
|
||||||
|
To keep a world on the newest version, run it in a loop:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix run git+https://gitea.surma.technology/surma/sausage#loop -- my-world
|
||||||
|
```
|
||||||
|
|
||||||
|
The loop runs `nix run --refresh git+https://gitea.surma.technology/surma/sausage -- run my-world`.
|
||||||
|
When the host crashes or an actor sends `@broker {"type":"exit"}`, the loop
|
||||||
|
fetches the newest commit and starts it again. `/quit` ends the loop. Set
|
||||||
|
`SAUSAGE_FLAKE` to run another flake URL instead, for example your fork.
|
||||||
|
|
||||||
|
From a clone (`git clone https://gitea.surma.technology/surma/sausage`), the
|
||||||
|
same commands are `nix run -- init`, `nix run -- run` and `nix run .#loop`.
|
||||||
|
|
||||||
In the console:
|
In the console:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
hello → text to the agent
|
write an actor "notes" that stores notes, and start it → text to the agent
|
||||||
@notes {"type":"add","text":"milk"} → a JSON message to an actor
|
@notes {"type":"add","text":"milk"} → a JSON message to an actor
|
||||||
@agent what actors exist? → text to an actor
|
@agent hello → text to an actor
|
||||||
/deploy notes examples/notes → build with cargo, then spawn or upgrade
|
|
||||||
/list → list actors
|
|
||||||
/await agent 300 → wait for the next message (scripts)
|
|
||||||
/quit
|
/quit
|
||||||
```
|
```
|
||||||
|
|
||||||
## Concepts
|
## The world folder
|
||||||
|
|
||||||
- **Actor**: a `wasi:cli/command` program. It reads messages on stdin and
|
The folder is the source of truth. Every actor lives in `world/actor/<id>/`:
|
||||||
writes messages on stdout. Logs go to stderr.
|
|
||||||
- **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
|
|
||||||
|
|
||||||
A write becomes visible when the writer closes the file (commit on close).
|
```text
|
||||||
- **Upgrade**: the broker tests the candidate on a copy of `/data` and
|
actor.wasm the current version
|
||||||
`/public` (transcripts in `source/tests/*.jsonl`). Then it holds new
|
actor.json installed version, running, subscriptions
|
||||||
messages, stops the old version, starts the new one, and delivers the held
|
source/ the source of the current version
|
||||||
messages. A new version that crashes 3 times in 30 seconds is rolled back.
|
public/ files that the actor publishes
|
||||||
|
data/ the actor's state
|
||||||
|
stderr.log what the actor and the broker write about it
|
||||||
|
versions/<n>/ every installed version: actor.wasm, source/, meta.json
|
||||||
|
```
|
||||||
|
|
||||||
See [DECISIONS.md](DECISIONS.md) for every design decision and
|
To add or replace an actor by hand, put an `actor.wasm` (and its `source/`,
|
||||||
[SPIKE.md](SPIKE.md) for the spike results.
|
with the tests in `source/tests/`) into its folder. Then restart the world or
|
||||||
|
send `@broker {"type":"scan"}`. The broker tests the file before it uses it.
|
||||||
|
If the tests fail, the old version keeps running, and `stderr.log` says why.
|
||||||
|
|
||||||
|
## More
|
||||||
|
|
||||||
|
- [PROTOCOL.md](PROTOCOL.md): messages, the filesystem, the broker, `cargo`.
|
||||||
|
Read this to write an actor in any language.
|
||||||
|
- [`sdk/`](sdk/): `sausage-actor`, the SDK for Rust actors.
|
||||||
|
- [`examples/notes`](examples/notes): a small actor with a test.
|
||||||
|
- [AGENTS.md](AGENTS.md): how to work on Sausage itself.
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
# 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).
|
|
||||||
+1
-1
@@ -5,7 +5,7 @@ edition = "2024"
|
|||||||
description = "The Sausage agent actor."
|
description = "The Sausage agent actor."
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-channel = "2"
|
sausage-actor = { version = "0.1", path = "../sdk", features = ["async"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
wstd = "0.6"
|
wstd = "0.6"
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//! History window selection and context-size error detection.
|
||||||
|
|
||||||
|
use crate::llm::Entry;
|
||||||
|
|
||||||
|
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 !matches!(status.split_whitespace().next(), Some("400" | "413")) {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-21
@@ -6,7 +6,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use wstd::http::{Body, Client, Request};
|
use wstd::http::{Body, Client, Request};
|
||||||
use wstd::time::Duration;
|
|
||||||
|
|
||||||
#[derive(Deserialize, Clone)]
|
#[derive(Deserialize, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
@@ -18,19 +17,19 @@ pub struct Config {
|
|||||||
/// Optional reasoning effort for OpenAI models ("low", "medium", ...).
|
/// Optional reasoning effort for OpenAI models ("low", "medium", ...).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub reasoning_effort: Option<String>,
|
pub reasoning_effort: Option<String>,
|
||||||
#[serde(default = "default_max_tokens")]
|
#[serde(default)]
|
||||||
pub max_tokens: u32,
|
pub max_tokens: Option<u32>,
|
||||||
}
|
|
||||||
|
|
||||||
fn default_max_tokens() -> u32 {
|
|
||||||
16000
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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 {
|
||||||
User { text: String },
|
User { text: String },
|
||||||
Assistant { text: String },
|
Assistant {
|
||||||
|
text: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
phase: Option<String>,
|
||||||
|
},
|
||||||
ToolCall { id: String, name: String, arguments: String },
|
ToolCall { id: String, name: String, arguments: String },
|
||||||
ToolResult { id: String, output: String },
|
ToolResult { id: String, output: String },
|
||||||
/// A provider item that only this API shape understands (OpenAI reasoning).
|
/// A provider item that only this API shape understands (OpenAI reasoning).
|
||||||
@@ -57,17 +56,13 @@ async fn post(url: &str, headers: &[(&str, String)], body: &Value) -> Result<Val
|
|||||||
req = req.header(*k, v.as_str());
|
req = req.header(*k, v.as_str());
|
||||||
}
|
}
|
||||||
let req = req.body(Body::from(body.to_string())).map_err(|e| format!("request: {e}"))?;
|
let req = req.body(Body::from(body.to_string())).map_err(|e| format!("request: {e}"))?;
|
||||||
let mut client = Client::new();
|
let client = Client::new();
|
||||||
client.set_first_byte_timeout(Duration::from_secs(600));
|
|
||||||
client.set_between_bytes_timeout(Duration::from_secs(600));
|
|
||||||
let resp = client.send(req).await.map_err(|e| format!("HTTP request failed: {e:?}"))?;
|
let resp = client.send(req).await.map_err(|e| format!("HTTP request failed: {e:?}"))?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let mut body = resp.into_body();
|
let mut body = resp.into_body();
|
||||||
let text = body.str_contents().await.map_err(|e| format!("reading the response failed: {e:?}"))?.to_string();
|
let text = body.str_contents().await.map_err(|e| format!("reading the response failed: {e:?}"))?.to_string();
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let mut t = text;
|
return Err(format!("HTTP {status}: {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}"))
|
||||||
}
|
}
|
||||||
@@ -77,7 +72,13 @@ async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -
|
|||||||
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 } => input.push(json!({"role": "assistant", "content": text})),
|
Entry::Assistant { text, phase } => {
|
||||||
|
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}))
|
||||||
}
|
}
|
||||||
@@ -99,8 +100,10 @@ async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -
|
|||||||
"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 {
|
||||||
|
body["max_output_tokens"] = json!(max_tokens);
|
||||||
|
}
|
||||||
if let Some(effort) = &cfg.reasoning_effort {
|
if let Some(effort) = &cfg.reasoning_effort {
|
||||||
body["reasoning"] = json!({"effort": effort});
|
body["reasoning"] = json!({"effort": effort});
|
||||||
}
|
}
|
||||||
@@ -120,8 +123,9 @@ async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -
|
|||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if !text.is_empty() {
|
let phase = item["phase"].as_str().map(str::to_string);
|
||||||
out.push(Entry::Assistant { text });
|
if !text.is_empty() || phase.is_some() {
|
||||||
|
out.push(Entry::Assistant { text, phase });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("function_call") => out.push(Entry::ToolCall {
|
Some("function_call") => out.push(Entry::ToolCall {
|
||||||
@@ -149,7 +153,9 @@ async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]
|
|||||||
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 } => push("assistant", json!({"type": "text", "text": text})),
|
Entry::Assistant { text, .. } => {
|
||||||
|
push("assistant", json!({"type": "text", "text": text}))
|
||||||
|
}
|
||||||
Entry::ToolCall { id, name, arguments } => {
|
Entry::ToolCall { id, name, arguments } => {
|
||||||
let input: Value = serde_json::from_str(arguments).unwrap_or(json!({}));
|
let input: Value = serde_json::from_str(arguments).unwrap_or(json!({}));
|
||||||
push("assistant", json!({"type": "tool_use", "id": id, "name": name, "input": input}))
|
push("assistant", json!({"type": "tool_use", "id": id, "name": name, "input": input}))
|
||||||
@@ -170,7 +176,7 @@ async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]
|
|||||||
"system": system,
|
"system": system,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"tools": tools,
|
"tools": tools,
|
||||||
"max_tokens": cfg.max_tokens,
|
"max_tokens": cfg.max_tokens.unwrap_or(32000),
|
||||||
});
|
});
|
||||||
let url = format!("{}/v1/messages", cfg.base_url);
|
let url = format!("{}/v1/messages", cfg.base_url);
|
||||||
let resp = post(
|
let resp = post(
|
||||||
@@ -185,7 +191,7 @@ async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]
|
|||||||
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 });
|
out.push(Entry::Assistant { text, phase: None });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("tool_use") => out.push(Entry::ToolCall {
|
Some("tool_use") => out.push(Entry::ToolCall {
|
||||||
|
|||||||
+239
-420
@@ -1,242 +1,92 @@
|
|||||||
//! The Sausage agent: a normal WASM actor that does inference over HTTP and
|
//! The Sausage agent actor.
|
||||||
//! grows the world through messages to `cargo` and `broker`.
|
|
||||||
//!
|
|
||||||
//! Configuration: `/data/config.json` (see `llm::Config`).
|
|
||||||
//! Conversation: `/data/history.json` (one shared conversation).
|
|
||||||
|
|
||||||
|
mod history;
|
||||||
mod llm;
|
mod llm;
|
||||||
|
mod tools;
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use history::{is_too_large, next_turn_start};
|
||||||
|
use llm::{Config, Entry};
|
||||||
use async_channel::{Receiver, Sender};
|
use sausage_actor::{Inbox, Message, Value, json, publish, send};
|
||||||
use llm::{Config, Entry, Tool};
|
use std::collections::{HashSet, VecDeque};
|
||||||
use serde_json::{Value, json};
|
use std::io::Write;
|
||||||
use wstd::future::FutureExt;
|
use wstd::future::FutureExt;
|
||||||
use wstd::io::{AsyncRead, AsyncWrite};
|
|
||||||
use wstd::time::{Duration, Instant};
|
use wstd::time::{Duration, Instant};
|
||||||
|
|
||||||
const SYSTEM: &str = include_str!("prompt.md");
|
const SYSTEM: &str = include_str!("prompt.md");
|
||||||
const CONFIG: &str = "/data/config.json";
|
const CONFIG: &str = "/data/config.json";
|
||||||
const HISTORY: &str = "/data/history.json";
|
const HISTORY: &str = "/data/history.jsonl";
|
||||||
/// An interrupted turn (sender and message ID), resumed at the next start.
|
const HISTORY_SKIP: &str = "/data/history.skip";
|
||||||
const TURN: &str = "/data/turn.json";
|
const TURN: &str = "/data/turn.json";
|
||||||
const MAX_STEPS: usize = 40;
|
|
||||||
const MAX_HISTORY: usize = 600;
|
|
||||||
const MAX_TOOL_OUTPUT: usize = 30_000;
|
|
||||||
const MAX_NOTES: usize = 50;
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
wstd::runtime::block_on(async {
|
wstd::runtime::block_on(async { Agent::new().run().await });
|
||||||
let (tx, rx) = async_channel::unbounded();
|
|
||||||
wstd::runtime::spawn(read_stdin(tx)).detach();
|
|
||||||
Agent::new(rx).run().await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read NDJSON messages from stdin into the channel.
|
|
||||||
async fn read_stdin(tx: Sender<Value>) {
|
|
||||||
let mut stdin = wstd::io::stdin();
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
let mut chunk = vec![0u8; 16 * 1024];
|
|
||||||
loop {
|
|
||||||
match stdin.read(&mut chunk).await {
|
|
||||||
Ok(0) | Err(_) => break,
|
|
||||||
Ok(n) => {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tx.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate(mut s: String, max: usize) -> String {
|
|
||||||
if s.len() > max {
|
|
||||||
let mut end = max;
|
|
||||||
while !s.is_char_boundary(end) {
|
|
||||||
end -= 1;
|
|
||||||
}
|
|
||||||
let total = s.len();
|
|
||||||
s.truncate(end);
|
|
||||||
s.push_str(&format!("\n… (truncated, {total} bytes in total)"));
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tools() -> Vec<Tool> {
|
|
||||||
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 {
|
struct Agent {
|
||||||
rx: Receiver<Value>,
|
inbox: Inbox,
|
||||||
stdout: wstd::io::Stdout,
|
|
||||||
history: Vec<Entry>,
|
history: Vec<Entry>,
|
||||||
/// Text messages that start a new turn later.
|
history_skip: usize,
|
||||||
queue: VecDeque<Value>,
|
active: bool,
|
||||||
/// Other messages that arrived while the agent was idle.
|
queue: VecDeque<Message>,
|
||||||
notes: Vec<Value>,
|
notes: Vec<Message>,
|
||||||
seq: u64,
|
|
||||||
shutdown: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Agent {
|
impl Agent {
|
||||||
fn new(rx: Receiver<Value>) -> Self {
|
fn new() -> Self {
|
||||||
let history = std::fs::read_to_string(HISTORY)
|
let history = std::fs::read_to_string(HISTORY)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| serde_json::from_str(line).ok())
|
||||||
|
.collect();
|
||||||
|
let history_skip = std::fs::read_to_string(HISTORY_SKIP)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| serde_json::from_str(&s).ok())
|
.and_then(|skip| skip.trim().parse::<usize>().ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or(0);
|
||||||
|
let active = std::fs::read_to_string(TURN)
|
||||||
|
.ok()
|
||||||
|
.and_then(|contents| serde_json::from_str::<bool>(&contents).ok())
|
||||||
|
.unwrap_or(false);
|
||||||
Self {
|
Self {
|
||||||
rx,
|
inbox: Inbox::start(),
|
||||||
stdout: wstd::io::stdout(),
|
|
||||||
history,
|
history,
|
||||||
|
history_skip,
|
||||||
|
active,
|
||||||
queue: VecDeque::new(),
|
queue: VecDeque::new(),
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
seq: 0,
|
|
||||||
shutdown: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run(mut self) {
|
fn add_history(&mut self, entry: Entry) {
|
||||||
eprintln!("agent: started with {} history entries", self.history.len());
|
let line = serde_json::to_string(&entry).unwrap();
|
||||||
self.resume().await;
|
match std::fs::OpenOptions::new()
|
||||||
while !self.shutdown {
|
.create(true)
|
||||||
let msg = match self.queue.pop_front() {
|
.append(true)
|
||||||
Some(m) => m,
|
.open(HISTORY)
|
||||||
None => match self.rx.recv().await {
|
{
|
||||||
Ok(m) => m,
|
Ok(mut file) => {
|
||||||
Err(_) => break,
|
if let Err(e) = writeln!(file, "{line}") {
|
||||||
},
|
eprintln!("agent: cannot append history: {e}");
|
||||||
};
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(e) => eprintln!("agent: cannot append history: {e}"),
|
||||||
}
|
}
|
||||||
self.save();
|
self.history.push(entry);
|
||||||
eprintln!("agent: stopped");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Continue a turn that a restart (usually an upgrade of the agent itself)
|
fn finish_turn(&mut self) {
|
||||||
/// interrupted.
|
self.active = false;
|
||||||
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 _ = 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;
|
|
||||||
};
|
fn closing(&self) -> bool {
|
||||||
// Every tool call needs a result, or the APIs reject the history.
|
self.inbox.is_closed()
|
||||||
let answered: std::collections::HashSet<String> = self
|
}
|
||||||
|
|
||||||
|
fn repair_dangling_tools(&mut self) {
|
||||||
|
let answered: HashSet<String> = self
|
||||||
.history
|
.history
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|e| match e {
|
.filter_map(|entry| match entry {
|
||||||
Entry::ToolResult { id, .. } => Some(id.clone()),
|
Entry::ToolResult { id, .. } => Some(id.clone()),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
@@ -244,277 +94,246 @@ impl Agent {
|
|||||||
let dangling: Vec<String> = self
|
let dangling: Vec<String> = self
|
||||||
.history
|
.history
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|e| match e {
|
.filter_map(|entry| match entry {
|
||||||
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 {
|
||||||
self.history.push(Entry::ToolResult {
|
self.add_history(Entry::ToolResult {
|
||||||
id,
|
id,
|
||||||
output: json!({"error": "interrupted: the agent restarted before this 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) {
|
fn turn_text(&self, message: &Message) -> String {
|
||||||
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!(
|
let mut text = format!(
|
||||||
"Message {} from {}:\n{}",
|
"Message {} from {}:\n{}",
|
||||||
id.as_str().unwrap_or("?"),
|
message.id,
|
||||||
from.as_str().unwrap_or("?"),
|
message.from,
|
||||||
msg["body"]["text"].as_str().unwrap_or("")
|
message.body["text"].as_str().unwrap_or("")
|
||||||
);
|
);
|
||||||
if !self.notes.is_empty() {
|
if !self.notes.is_empty() {
|
||||||
let notes: Vec<String> = self.notes.drain(..).map(|n| n.to_string()).collect();
|
let notes = self
|
||||||
text = format!("Messages that arrived while you were idle:\n{}\n\n{text}", notes.join("\n"));
|
.notes
|
||||||
|
.iter()
|
||||||
|
.map(|note| serde_json::to_string(note).unwrap())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
text = format!("Messages that arrived while you were idle:\n{notes}\n\n{text}");
|
||||||
}
|
}
|
||||||
self.history.push(Entry::User { text });
|
text
|
||||||
self.steps(&cfg, from, id).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run inference and tools until the model answers. If a shutdown
|
fn not_configured(&mut self, error: String) {
|
||||||
/// interrupts the turn, remember it in TURN so the next start resumes it.
|
let _ = publish(json!({
|
||||||
async fn steps(&mut self, cfg: &Config, from: Value, id: Value) {
|
"type": "text",
|
||||||
let tools = tools();
|
"text": format!("I am not configured: cannot read {CONFIG}: {error}")
|
||||||
let cfg = cfg.clone();
|
}));
|
||||||
for _ in 0..MAX_STEPS {
|
self.finish_turn();
|
||||||
let started = Instant::now();
|
}
|
||||||
let result = llm::complete(&cfg, SYSTEM, &tools, &self.history).await;
|
|
||||||
let entries = match result {
|
async fn run(mut self) {
|
||||||
Ok(e) => e,
|
self.repair_dangling_tools();
|
||||||
|
if self.active {
|
||||||
|
self.turn(None).await;
|
||||||
|
if self.closing() && self.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
let message = match self.queue.pop_front() {
|
||||||
|
Some(message) => message,
|
||||||
|
None => match self.inbox.recv().await {
|
||||||
|
Some(message) => message,
|
||||||
|
None => break,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if starts_turn(&message) {
|
||||||
|
self.turn(Some(message)).await;
|
||||||
|
if self.closing() && self.active {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.notes.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn turn(&mut self, message: Option<Message>) {
|
||||||
|
let current_start = if message.is_some() {
|
||||||
|
self.active = true;
|
||||||
|
let _ = std::fs::write(TURN, "true");
|
||||||
|
self.history.len()
|
||||||
|
} else {
|
||||||
|
let current_start = self
|
||||||
|
.history
|
||||||
|
.iter()
|
||||||
|
.rposition(|entry| matches!(entry, Entry::User { text } if !text.starts_with("[note from your runtime]")))
|
||||||
|
.unwrap_or(self.history.len());
|
||||||
|
let version = std::env::var("SAUSAGE_VERSION").unwrap_or_default();
|
||||||
|
self.add_history(Entry::User {
|
||||||
|
text: format!(
|
||||||
|
"[note from your runtime] you restarted and now run version {version}. Continue the active turn."
|
||||||
|
),
|
||||||
|
});
|
||||||
|
current_start
|
||||||
|
};
|
||||||
|
let cfg = match load_config() {
|
||||||
|
Ok(cfg) => cfg,
|
||||||
|
Err(error) => {
|
||||||
|
self.not_configured(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(message) = message {
|
||||||
|
let text = self.turn_text(&message);
|
||||||
|
self.notes.clear();
|
||||||
|
self.add_history(Entry::User { text });
|
||||||
|
}
|
||||||
|
self.steps(&cfg, current_start).await;
|
||||||
|
if !self.closing() {
|
||||||
|
self.finish_turn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn steps(&mut self, cfg: &Config, current_start: usize) {
|
||||||
|
let tools = tools::definitions();
|
||||||
|
loop {
|
||||||
|
if self.closing() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let start = next_turn_start(&self.history, self.history_skip, current_start)
|
||||||
|
.unwrap_or(current_start);
|
||||||
|
if start != self.history_skip {
|
||||||
|
self.history_skip = start;
|
||||||
|
let _ = std::fs::write(HISTORY_SKIP, start.to_string());
|
||||||
|
}
|
||||||
|
let response = llm::complete(cfg, SYSTEM, &tools, &self.history[start..]).await;
|
||||||
|
let entries = match response {
|
||||||
|
Ok(entries) => entries,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("agent: inference failed: {e}");
|
if is_too_large(&e) {
|
||||||
self.history.push(Entry::Assistant { text: format!("(inference failed: {e})") });
|
if let Some(next) =
|
||||||
self.save();
|
next_turn_start(&self.history, start.saturating_add(1), current_start)
|
||||||
self.reply(&from, &id, format!("Inference failed: {e}")).await;
|
{
|
||||||
|
self.history_skip = next;
|
||||||
|
let _ = std::fs::write(HISTORY_SKIP, next.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let text = format!("Inference failed: {e}");
|
||||||
|
self.add_history(Entry::Assistant {
|
||||||
|
text: text.clone(),
|
||||||
|
phase: None,
|
||||||
|
});
|
||||||
|
let _ = publish(json!({"type": "text", "text": text}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
eprintln!("agent: inference took {:?}", started.elapsed());
|
|
||||||
let calls: Vec<(String, String, String)> = entries
|
let calls: Vec<(String, String, String)> = entries
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|e| match e {
|
.filter_map(|entry| match entry {
|
||||||
Entry::ToolCall { id, name, arguments } => Some((id.clone(), name.clone(), arguments.clone())),
|
Entry::ToolCall {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
arguments,
|
||||||
|
} => Some((id.clone(), name.clone(), arguments.clone())),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let answer: Vec<String> = entries
|
for entry in entries {
|
||||||
.iter()
|
if let Entry::Assistant { text, .. } = &entry {
|
||||||
.filter_map(|e| match e {
|
if !text.is_empty() {
|
||||||
Entry::Assistant { text } => Some(text.clone()),
|
let _ = publish(json!({"type": "text", "text": text}));
|
||||||
_ => None,
|
}
|
||||||
})
|
}
|
||||||
.collect();
|
self.add_history(entry);
|
||||||
self.history.extend(entries);
|
}
|
||||||
|
if self.closing() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if calls.is_empty() {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
for (call_id, name, args) in calls {
|
for (id, name, args) in calls {
|
||||||
|
if self.closing() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let output = self.tool(&name, &args).await;
|
let output = self.tool(&name, &args).await;
|
||||||
self.history.push(Entry::ToolResult { id: call_id, output: truncate(output.to_string(), MAX_TOOL_OUTPUT) });
|
self.add_history(Entry::ToolResult {
|
||||||
}
|
id,
|
||||||
self.save();
|
output: output.to_string(),
|
||||||
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;
|
}
|
||||||
|
|
||||||
|
fn stash(&mut self, message: Message, others: &mut Vec<Value>) {
|
||||||
|
if starts_turn(&message) {
|
||||||
|
others.push(json!({"queued_for_a_later_turn": true, "message": message}));
|
||||||
|
self.queue.push_back(message);
|
||||||
|
} else {
|
||||||
|
others.push(serde_json::to_value(message).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_inbox(&mut self, others: &mut Vec<Value>) {
|
||||||
|
while let Some(message) = self.inbox.try_recv() {
|
||||||
|
self.stash(message, others);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn tool(&mut self, name: &str, args: &str) -> Value {
|
async fn tool(&mut self, name: &str, args: &str) -> Value {
|
||||||
let a: Value = match serde_json::from_str(args) {
|
let args: Value = match serde_json::from_str(args) {
|
||||||
Ok(v) => v,
|
Ok(args) => args,
|
||||||
Err(e) => return json!({"error": format!("arguments are not valid JSON: {e}")}),
|
Err(error) => return json!({"error": format!("arguments are not valid JSON: {error}")}),
|
||||||
};
|
};
|
||||||
let path = |k: &str| a[k].as_str().unwrap_or_default().to_string();
|
if name == "send_message" {
|
||||||
let err = |e: std::io::Error| json!({"error": e.to_string()});
|
return self.send_message(&args).await;
|
||||||
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}")}),
|
|
||||||
}
|
}
|
||||||
|
tools::run(name, &args).unwrap_or_else(|error| json!({"error": error.to_string()}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_message(&mut self, a: &Value) -> Value {
|
async fn send_message(&mut self, args: &Value) -> Value {
|
||||||
let Some(to) = a["to"].as_str() else { return json!({"error": "missing \"to\""}) };
|
let Some(to) = args["to"].as_str() else {
|
||||||
if !a["body"].is_object() {
|
return json!({"error": "missing \"to\""});
|
||||||
|
};
|
||||||
|
if !args["body"].is_object() {
|
||||||
return json!({"error": "\"body\" must be a JSON object"});
|
return json!({"error": "\"body\" must be a JSON object"});
|
||||||
}
|
}
|
||||||
self.seq += 1;
|
let id = send(to, args["body"].clone());
|
||||||
let own = format!("a{}-{}", wstd_now_ms(), self.seq);
|
let wait = args["wait_seconds"].as_f64().unwrap_or(0.0).max(0.0);
|
||||||
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 reply = Value::Null;
|
||||||
let mut others = Vec::new();
|
let mut others = Vec::new();
|
||||||
if wait > 0.0 {
|
if wait > 0.0 {
|
||||||
let deadline = Instant::now() + Duration::from_secs_f64(wait);
|
let deadline = Instant::now() + Duration::from_secs_f64(wait);
|
||||||
loop {
|
loop {
|
||||||
match self.rx.recv().timeout(deadline).await {
|
match self.inbox.recv().timeout(deadline).await {
|
||||||
Ok(Ok(m)) => {
|
Ok(Some(message)) if message.reply_to.as_deref() == Some(&id) => {
|
||||||
if m["reply_to"] == full.as_str() {
|
reply = serde_json::to_value(message).unwrap();
|
||||||
reply = m;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
self.stash(m, &mut others);
|
|
||||||
if self.shutdown {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Err(_)) => {
|
|
||||||
// stdin closed: the broker stops us.
|
|
||||||
self.shutdown = true;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
Ok(Some(message)) => self.stash(message, &mut others),
|
||||||
|
Ok(None) => break,
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
json!({"id": full, "reply": reply, "waited": wait > 0.0, "other_messages": others})
|
self.drain_inbox(&mut others);
|
||||||
|
json!({"id": id, "reply": reply, "waited": wait > 0.0, "other_messages": others})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A text message that is not a reply starts a turn. Replies never do, so
|
fn load_config() -> Result<Config, String> {
|
||||||
/// two actors cannot keep the agent in an endless conversation.
|
std::fs::read_to_string(CONFIG)
|
||||||
fn starts_turn(m: &Value) -> bool {
|
.map_err(|e| e.to_string())
|
||||||
m["body"]["type"] == "text" && m.get("reply_to").is_none_or(Value::is_null)
|
.and_then(|contents| serde_json::from_str(&contents).map_err(|e| e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wstd_now_ms() -> u64 {
|
fn starts_turn(message: &Message) -> bool {
|
||||||
std::time::SystemTime::now()
|
message.kind() == "text" && message.reply_to.is_none()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_millis() as u64)
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-80
@@ -1,80 +1,5 @@
|
|||||||
You are the agent of a Sausage world. Your actor ID is `agent`.
|
You are the `agent` actor in a Sausage world of actors.
|
||||||
|
People talk to you through other actors. Your text goes to your subscribers.
|
||||||
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.
|
Before writing an actor, read `/actor/broker/public/PROTOCOL.md` for messages, the broker, `cargo`, and tests.
|
||||||
|
Read the SDK at `/actor/cargo/public/sdk/` before you write an actor.
|
||||||
# Messages
|
You can change your own source at `/actor/agent/source`.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
A WASM actor reads one JSON object per line on stdin:
|
|
||||||
`{"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.
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//! Tool descriptions and file operations for the agent.
|
||||||
|
|
||||||
|
use crate::llm::Tool;
|
||||||
|
use sausage_actor::{Value, json};
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
pub fn definitions() -> Vec<Tool> {
|
||||||
|
vec![
|
||||||
|
Tool {
|
||||||
|
name: "send_message",
|
||||||
|
description: "Send an actor a JSON body with a `type`. `broker` and `cargo` are built-in actors; see `/actor/broker/public/PROTOCOL.md`. With `wait_seconds`, the reply's `reply_to` matches the returned ID. Returns the ID, reply, and other messages received.",
|
||||||
|
parameters: schema(
|
||||||
|
json!({
|
||||||
|
"to": {"type": "string", "description": "Actor ID"},
|
||||||
|
"body": {"type": "object", "description": "JSON object with a type field"},
|
||||||
|
"wait_seconds": {"type": "number", "description": "Seconds to wait for a reply. Default 0."}
|
||||||
|
}),
|
||||||
|
&["to", "body"],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
name: "read_file",
|
||||||
|
description: "Read a text file under `/data`, `/public`, or `/actor/<id>`. Reads all bytes by default; `offset` and `max_bytes` are optional.",
|
||||||
|
parameters: schema(
|
||||||
|
json!({
|
||||||
|
"path": {"type": "string"},
|
||||||
|
"offset": {"type": "integer", "description": "Byte offset (default 0)"},
|
||||||
|
"max_bytes": {"type": "integer", "description": "Optional maximum number of bytes to read"}
|
||||||
|
}),
|
||||||
|
&["path"],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
name: "write_file",
|
||||||
|
description: "Write text under `/data` or `/public`. Parent folders are created.",
|
||||||
|
parameters: schema(
|
||||||
|
json!({"path": {"type": "string"}, "content": {"type": "string"}}),
|
||||||
|
&["path", "content"],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
name: "list_dir",
|
||||||
|
description: "List entries in a directory under `/data`, `/public`, or `/actor/<id>`. Returns each name, type, and size.",
|
||||||
|
parameters: schema(json!({"path": {"type": "string"}}), &["path"]),
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
name: "copy_dir",
|
||||||
|
description: "Copy a directory tree, for example `/actor/<id>/source` to `/public/drafts/<id>`. Skips `target/` folders.",
|
||||||
|
parameters: schema(
|
||||||
|
json!({"from": {"type": "string"}, "to": {"type": "string"}}),
|
||||||
|
&["from", "to"],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema(properties: Value, required: &[&str]) -> Value {
|
||||||
|
json!({"type": "object", "properties": properties, "required": required})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(name: &str, args: &Value) -> io::Result<Value> {
|
||||||
|
match name {
|
||||||
|
"read_file" => read_file(args),
|
||||||
|
"write_file" => write_file(args),
|
||||||
|
"list_dir" => list_dir(args),
|
||||||
|
"copy_dir" => copy_dir(args),
|
||||||
|
other => Ok(json!({"error": format!("unknown tool {other}")})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_file(args: &Value) -> io::Result<Value> {
|
||||||
|
let path = args["path"].as_str().unwrap_or_default();
|
||||||
|
let bytes = std::fs::read(path)?;
|
||||||
|
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(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_dir(args: &Value) -> io::Result<Value> {
|
||||||
|
let count = copy_tree(
|
||||||
|
args["from"].as_str().unwrap_or_default(),
|
||||||
|
args["to"].as_str().unwrap_or_default(),
|
||||||
|
)?;
|
||||||
|
Ok(json!({"copied_files": count}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"description": "the agent answers text with text, even without config or network",
|
||||||
|
"steps": [
|
||||||
|
{"send": {"type": "text", "text": "Hello"}, "expect": {"type": "text"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// The agent answers a text message with text, even without config or network.
|
|
||||||
{"send": {"type": "text", "text": "Hello"}}
|
|
||||||
{"expect": {"type": "text"}}
|
|
||||||
@@ -18,6 +18,7 @@ include_dir = "0.7"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
toml_edit = "0.25"
|
||||||
wasmtime = "49"
|
wasmtime = "49"
|
||||||
wasmtime-wasi = "49"
|
wasmtime-wasi = "49"
|
||||||
wasmtime-wasi-http = "49"
|
wasmtime-wasi-http = "49"
|
||||||
|
|||||||
+341
-621
File diff suppressed because it is too large
Load Diff
+76
-108
@@ -1,18 +1,25 @@
|
|||||||
//! Tests for a candidate version, before the broker installs it.
|
//! Tests for a candidate version, before the broker installs it.
|
||||||
//!
|
//!
|
||||||
//! A test is a message transcript in `source/tests/*.jsonl`. Each line is one
|
//! A test is a JSON file in `source/tests/*.json`, one scenario per file:
|
||||||
//! of:
|
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```json
|
||||||
//! {"send": {...body...}} send a message to the candidate
|
//! {
|
||||||
//! {"send": {...}, "from": "someone"} with an explicit sender
|
//! "description": "add, then list",
|
||||||
//! {"expect": {...subset of body...}} the next message from the candidate
|
//! "steps": [
|
||||||
//! {"expect": {...}, "to": "someone"} and its recipient
|
//! {"send": {"type": "add", "text": "milk"}, "expect": {"type": "added"}},
|
||||||
|
//! {"send": {"type": "list"}, "from": "someone", "expect": [{"type": "notes"}], "to": "someone"}
|
||||||
|
//! ]
|
||||||
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
|
//! Each step sends `send` (optional, from `from` or "test"), then reads one
|
||||||
|
//! message for each entry of `expect` (an object or a list). An expected body
|
||||||
|
//! matches if it is a subset of the actual body. With `to`, each message must
|
||||||
|
//! also go to that recipient.
|
||||||
|
//!
|
||||||
//! Each file runs in a fresh instance on a copy of the actor's `/data` and
|
//! 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
|
//! `/public` (empty folders for a new actor), without network. A version
|
||||||
//! candidate must start and stay up for `SMOKE_TIME`.
|
//! without test files passes.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -22,11 +29,9 @@ use serde_json::{Value, json};
|
|||||||
use wasmtime::component::Component;
|
use wasmtime::component::Component;
|
||||||
|
|
||||||
use crate::broker::{Broker, SHUTDOWN_DEADLINE, shutdown_line, truncate};
|
use crate::broker::{Broker, SHUTDOWN_DEADLINE, shutdown_line, truncate};
|
||||||
use crate::fs::Mount;
|
use crate::runtime::Instance;
|
||||||
use crate::runtime::{Instance, InstanceConfig};
|
|
||||||
use crate::world;
|
use crate::world;
|
||||||
|
|
||||||
const SMOKE_TIME: Duration = Duration::from_secs(2);
|
|
||||||
const EXPECT_TIMEOUT: Duration = Duration::from_secs(10);
|
const EXPECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
/// `expected` matches `actual` if every key of an expected object exists in
|
/// `expected` matches `actual` if every key of an expected object exists in
|
||||||
@@ -59,130 +64,93 @@ fn stderr_tail(path: &Path) -> String {
|
|||||||
text[s..].to_string()
|
text[s..].to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn test(
|
/// The version under test, which starts from a copy of the actor's state.
|
||||||
broker: &Broker,
|
struct Candidate<'a> {
|
||||||
id: &str,
|
broker: &'a Broker,
|
||||||
component: &Component,
|
id: &'a str,
|
||||||
source: &Path,
|
version: u64,
|
||||||
data: Option<&Path>,
|
component: &'a Component,
|
||||||
public: Option<&Path>,
|
}
|
||||||
) -> Result<Value> {
|
|
||||||
|
/// Run every test file of `<source>/tests/`.
|
||||||
|
pub async fn test(broker: &Broker, id: &str, version: u64, component: &Component, source: Option<&Path>) -> Result<Value> {
|
||||||
|
let candidate = Candidate { broker, id, version, component };
|
||||||
|
let Some(source) = source else { return Ok(json!({"passed": []})) };
|
||||||
let mut files: Vec<_> = std::fs::read_dir(source.join("tests"))
|
let mut files: Vec<_> = std::fs::read_dir(source.join("tests"))
|
||||||
.map(|rd| {
|
.map(|rd| rd.filter_map(|e| Some(e.ok()?.path())).filter(|p| p.extension().is_some_and(|x| x == "json")).collect())
|
||||||
rd.filter_map(|e| e.ok())
|
|
||||||
.map(|e| e.path())
|
|
||||||
.filter(|p| p.extension().is_some_and(|x| x == "jsonl"))
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
files.sort();
|
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();
|
let mut passed = Vec::new();
|
||||||
for file in &files {
|
for file in &files {
|
||||||
let name = file.file_name().unwrap().to_string_lossy().to_string();
|
let name = file.file_name().unwrap().to_string_lossy().to_string();
|
||||||
run_file(broker, id, component, data, public, file)
|
candidate.run_file(file).await.map_err(|e| anyhow!("test {name} failed: {e:#}"))?;
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow!("test {name} failed: {e:#}"))?;
|
|
||||||
passed.push(name);
|
passed.push(name);
|
||||||
}
|
}
|
||||||
Ok(json!({"passed": passed}))
|
Ok(json!({"passed": passed}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start(broker: &Broker, id: &str, component: &Component, data: Option<&Path>, public: Option<&Path>) -> Result<(Instance, Scratch)> {
|
impl Candidate<'_> {
|
||||||
let dir = broker.world.scratch_dir().join(format!("{id}-{}-{}", world::now(), rand_suffix()));
|
/// Start an instance on a fresh copy of the state, without network.
|
||||||
|
fn start(&self) -> Result<(Instance, Scratch)> {
|
||||||
|
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
|
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let dir = self.broker.world.scratch_dir().join(format!("{}-{}-{n}", self.id, std::process::id()));
|
||||||
let scratch = Scratch { dir: dir.clone() };
|
let scratch = Scratch { dir: dir.clone() };
|
||||||
std::fs::create_dir_all(dir.join("data"))?;
|
let world = &self.broker.world;
|
||||||
std::fs::create_dir_all(dir.join("public"))?;
|
for (from, name) in [(world.data_dir(self.id), "data"), (world.public_dir(self.id), "public")] {
|
||||||
if let Some(d) = data.filter(|d| d.is_dir()) {
|
std::fs::create_dir_all(dir.join(name))?;
|
||||||
world::copy_tree(d, &dir.join("data"))?;
|
if from.is_dir() {
|
||||||
|
world::copy_tree(&from, &dir.join(name))?;
|
||||||
}
|
}
|
||||||
if let Some(p) = public.filter(|p| p.is_dir()) {
|
|
||||||
world::copy_tree(p, &dir.join("public"))?;
|
|
||||||
}
|
}
|
||||||
let cfg = InstanceConfig {
|
let cfg = self.broker.instance_config(self.id, self.version, Some(&dir));
|
||||||
mounts: vec![
|
Ok((self.broker.runtime.start(self.component, cfg)?, scratch))
|
||||||
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 {
|
async fn run_file(&self, file: &Path) -> Result<()> {
|
||||||
use std::hash::{BuildHasher, Hasher};
|
let test: Value = serde_json::from_str(&std::fs::read_to_string(file)?).map_err(|e| anyhow!("invalid JSON: {e}"))?;
|
||||||
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
|
let steps = test["steps"].as_array().ok_or_else(|| anyhow!("missing \"steps\" list"))?;
|
||||||
h.write_u64(std::process::id() as u64);
|
let (mut inst, scratch) = self.start()?;
|
||||||
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 stderr = scratch.dir.join("stderr.log");
|
||||||
let mut sent = 0u32;
|
// Lines that the candidate wrote while a step was sent.
|
||||||
for (n, line) in text.lines().enumerate() {
|
let mut early = std::collections::VecDeque::new();
|
||||||
let line = line.trim();
|
for (n, step) in steps.iter().enumerate() {
|
||||||
if line.is_empty() || line.starts_with("//") {
|
let n = n + 1;
|
||||||
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") {
|
if let Some(body) = step.get("send") {
|
||||||
sent += 1;
|
let from = step["from"].as_str().unwrap_or("test");
|
||||||
let from = step.get("from").and_then(Value::as_str).unwrap_or("test");
|
let env = json!({"id": format!("test/{n}"), "from": from, "body": body});
|
||||||
let env = json!({"id": format!("test/{sent}"), "from": from, "body": body});
|
inst.send_line(&env.to_string(), |line| early.push_back(line))
|
||||||
inst.send_line(&env.to_string())
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("line {}: candidate does not read stdin: {e}", n + 1))?;
|
.map_err(|e| anyhow!("step {n}: candidate does not read stdin: {e}"))?;
|
||||||
} else if let Some(expected) = step.get("expect") {
|
}
|
||||||
let got = tokio::time::timeout(EXPECT_TIMEOUT, inst.stdout.recv()).await;
|
let expected = match step.get("expect") {
|
||||||
let got = match got {
|
None => vec![],
|
||||||
Err(_) => bail!("line {}: no message within {EXPECT_TIMEOUT:?}; expected {expected}", n + 1),
|
Some(Value::Array(list)) => list.clone(),
|
||||||
|
Some(one) => vec![one.clone()],
|
||||||
|
};
|
||||||
|
for expected in expected {
|
||||||
|
let next = async { if early.is_empty() { inst.stdout.recv().await } else { early.pop_front() } };
|
||||||
|
let line = match tokio::time::timeout(EXPECT_TIMEOUT, next).await {
|
||||||
|
Err(_) => bail!("step {n}: no message within {EXPECT_TIMEOUT:?}; expected {expected}"),
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
let exit = inst.wait().await;
|
let exit = inst.wait().await;
|
||||||
bail!("line {}: candidate ended ({exit}); expected {expected}. stderr:\n{}", n + 1, stderr_tail(&stderr))
|
bail!("step {n}: candidate ended ({exit}); expected {expected}. stderr:\n{}", stderr_tail(&stderr))
|
||||||
}
|
}
|
||||||
Ok(Some(l)) => l,
|
Ok(Some(l)) => l,
|
||||||
};
|
};
|
||||||
let msg: Value = serde_json::from_str(&got)
|
let msg: Value = serde_json::from_str(&line)
|
||||||
.map_err(|_| anyhow!("line {}: candidate wrote a non-JSON line: {}", n + 1, truncate(&got, 300)))?;
|
.map_err(|_| anyhow!("step {n}: candidate wrote a non-JSON line: {}", truncate(&line, 300)))?;
|
||||||
let body = msg.get("body").cloned().unwrap_or(Value::Null);
|
if !subset_match(&expected, &msg["body"]) {
|
||||||
if !subset_match(expected, &body) {
|
bail!("step {n}: expected body {expected}, got {}", truncate(&msg["body"].to_string(), 1000));
|
||||||
bail!("line {}: expected body {expected}, got {}", n + 1, truncate(&body.to_string(), 1000));
|
|
||||||
}
|
}
|
||||||
if let Some(to) = step.get("to")
|
if let Some(to) = step.get("to")
|
||||||
&& msg.get("to") != Some(to)
|
&& msg.get("to") != Some(to)
|
||||||
{
|
{
|
||||||
bail!("line {}: expected recipient {to}, got {}", n + 1, msg.get("to").cloned().unwrap_or(Value::Null));
|
bail!("step {n}: expected recipient {to}, got {}", msg.get("to").unwrap_or(&Value::Null));
|
||||||
}
|
|
||||||
} else {
|
|
||||||
bail!("line {}: use \"send\" or \"expect\"", n + 1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE).await;
|
}
|
||||||
|
inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE, |_| {}).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-39
@@ -6,22 +6,31 @@
|
|||||||
//!
|
//!
|
||||||
//! The broker knows nothing about Rust. This actor is one way to get a
|
//! The broker knows nothing about Rust. This actor is one way to get a
|
||||||
//! `.wasm` file. Other actors can add other languages.
|
//! `.wasm` file. Other actors can add other languages.
|
||||||
|
//!
|
||||||
|
//! Actors depend on the SDK with `sausage-actor = "0.1"`. The SDK is not
|
||||||
|
//! published: this actor copies the SDK of this host to `public/sdk/` (so
|
||||||
|
//! that every actor can read it), and points the dependency there in the
|
||||||
|
//! build copy of `Cargo.toml`.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Instant;
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tokio::sync::{Mutex, mpsc};
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::broker::{Broker, Envelope, truncate};
|
use include_dir::{Dir, include_dir};
|
||||||
|
|
||||||
|
use crate::broker::{Broker, Envelope};
|
||||||
use crate::world::{self, World};
|
use crate::world::{self, World};
|
||||||
|
|
||||||
pub const ID: &str = "cargo";
|
pub const ID: &str = "cargo";
|
||||||
pub const TARGET: &str = "wasm32-wasip2";
|
pub const TARGET: &str = "wasm32-wasip2";
|
||||||
const BUILD_TIMEOUT: Duration = Duration::from_secs(900);
|
|
||||||
|
/// The source of the `sausage-actor` SDK.
|
||||||
|
static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk");
|
||||||
|
|
||||||
pub struct BuildOutput {
|
pub struct BuildOutput {
|
||||||
/// Host path of the published `.wasm` file.
|
/// Host path of the published `.wasm` file.
|
||||||
@@ -30,15 +39,16 @@ pub struct BuildOutput {
|
|||||||
pub warnings: String,
|
pub warnings: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All builds share one target folder, so they run one at a time.
|
||||||
pub struct Cargo {
|
pub struct Cargo {
|
||||||
world: World,
|
world: World,
|
||||||
/// One build at a time: all builds share one target folder.
|
|
||||||
lock: Mutex<()>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cargo {
|
impl Cargo {
|
||||||
pub fn new(world: World) -> Self {
|
/// Create the actor and install the SDK of this host into `public/sdk/`.
|
||||||
Self { world, lock: Mutex::new(()) }
|
pub fn new(world: World) -> Result<Self> {
|
||||||
|
write_dir(&SDK, &world.public_dir(ID).join("sdk"))?;
|
||||||
|
Ok(Self { world })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the crate in `source` (a host path) and publish the `.wasm` file
|
/// Build the crate in `source` (a host path) and publish the `.wasm` file
|
||||||
@@ -47,18 +57,12 @@ impl Cargo {
|
|||||||
if !source.join("Cargo.toml").is_file() {
|
if !source.join("Cargo.toml").is_file() {
|
||||||
bail!("{} has no Cargo.toml", source.display());
|
bail!("{} has no Cargo.toml", source.display());
|
||||||
}
|
}
|
||||||
let _guard = self.lock.lock().await;
|
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let data = self.world.data_dir(ID);
|
let data = self.world.data_dir(ID);
|
||||||
let key = format!("{:016x}", fxhash(source.to_string_lossy().as_bytes()));
|
let build_dir = data.join("work");
|
||||||
let build_dir = data.join("work").join(&key);
|
world::replace_source(source, &build_dir)?;
|
||||||
world::replace_tree(source, &build_dir)?;
|
|
||||||
// The crate must not join a parent workspace by accident.
|
|
||||||
let manifest = build_dir.join("Cargo.toml");
|
let manifest = build_dir.join("Cargo.toml");
|
||||||
let text = std::fs::read_to_string(&manifest)?;
|
std::fs::write(&manifest, prepare_manifest(&std::fs::read_to_string(&manifest)?, Some(&self.world.public_dir(ID).join("sdk")))?)?;
|
||||||
if !text.contains("[workspace]") {
|
|
||||||
std::fs::write(&manifest, format!("{text}\n[workspace]\n"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut cmd = Command::new("cargo");
|
let mut cmd = Command::new("cargo");
|
||||||
cmd.arg("build")
|
cmd.arg("build")
|
||||||
@@ -70,13 +74,10 @@ impl Cargo {
|
|||||||
.arg(data.join("target"))
|
.arg(data.join("target"))
|
||||||
.current_dir(&build_dir)
|
.current_dir(&build_dir)
|
||||||
.kill_on_drop(true);
|
.kill_on_drop(true);
|
||||||
let out = tokio::time::timeout(BUILD_TIMEOUT, cmd.output())
|
let out = cmd.output().await.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?;
|
||||||
.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();
|
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
bail!("{}", truncate(&strip_progress(&stderr), 20_000));
|
bail!("{}", strip_progress(&stderr));
|
||||||
}
|
}
|
||||||
let mut artifact = None;
|
let mut artifact = None;
|
||||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||||
@@ -97,16 +98,17 @@ impl Cargo {
|
|||||||
Ok(BuildOutput { wasm: dest, seconds: started.elapsed().as_secs_f64(), warnings: strip_progress(&stderr) })
|
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>) {
|
/// The native `cargo` actor. Builds run one at a time, in order.
|
||||||
|
/// Replies (messages with `reply_to`) get no answer.
|
||||||
|
pub async fn run_actor(self, broker: Arc<Broker>, mut mailbox: mpsc::Receiver<Envelope>) {
|
||||||
while let Some(env) = mailbox.recv().await {
|
while let Some(env) = mailbox.recv().await {
|
||||||
let this = self.clone();
|
if env.reply_to.is_some() {
|
||||||
let broker = broker.clone();
|
continue;
|
||||||
tokio::spawn(async move {
|
}
|
||||||
let body = this.handle(&broker, &env).await.unwrap_or_else(|e| {
|
let body = self.handle(&broker, &env).await.unwrap_or_else(|e| {
|
||||||
json!({"type": "build_failed", "source": env.body.get("source"), "errors": format!("{e:#}")})
|
json!({"type": "build_failed", "source": env.body.get("source"), "errors": format!("{e:#}")})
|
||||||
});
|
});
|
||||||
broker.send(ID, Some(&env.from), None, Some(&env.id), body);
|
broker.send(ID, Some(&env.from), None, Some(&env.id), body);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,15 +125,55 @@ impl Cargo {
|
|||||||
"source": source_s,
|
"source": source_s,
|
||||||
"built_by": ID,
|
"built_by": ID,
|
||||||
"seconds": (out.seconds * 10.0).round() / 10.0,
|
"seconds": (out.seconds * 10.0).round() / 10.0,
|
||||||
"warnings": truncate(&out.warnings, 4000),
|
"warnings": out.warnings,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
Some("shutdown") => Ok(json!({"type": "ignored"})),
|
|
||||||
other => bail!("unknown request {other:?}. Known: build"),
|
other => bail!("unknown request {other:?}. Known: build"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Point a `sausage-actor` dependency at `sdk` (keeping its features), and
|
||||||
|
/// add an empty `[workspace]`, so the crate never joins a parent workspace.
|
||||||
|
fn prepare_manifest(text: &str, sdk: Option<&Path>) -> Result<String> {
|
||||||
|
let mut doc: toml_edit::DocumentMut = text.parse()?;
|
||||||
|
if let Some(sdk) = sdk {
|
||||||
|
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
|
||||||
|
let Some(dep) = doc.get_mut(section).and_then(|t| t.get_mut("sausage-actor")) else { continue };
|
||||||
|
let mut table = dep.as_inline_table().cloned().unwrap_or_default();
|
||||||
|
for key in ["version", "path", "git", "branch", "tag", "rev", "registry"] {
|
||||||
|
table.remove(key);
|
||||||
|
}
|
||||||
|
table.insert("path", sdk.display().to_string().into());
|
||||||
|
*dep = toml_edit::value(table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if doc.get("workspace").is_none() {
|
||||||
|
doc["workspace"] = toml_edit::table();
|
||||||
|
}
|
||||||
|
Ok(doc.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the files of `dir` below `to`. Skip files that did not change, so
|
||||||
|
/// that cargo does not rebuild the SDK at every start.
|
||||||
|
fn write_dir(dir: &Dir<'_>, to: &Path) -> Result<()> {
|
||||||
|
for file in dir.files() {
|
||||||
|
let dest = to.join(file.path());
|
||||||
|
let contents = match file.path().file_name() {
|
||||||
|
Some(n) if n == "Cargo.toml" => prepare_manifest(file.contents_utf8().context("Cargo.toml")?, None)?.into_bytes(),
|
||||||
|
_ => file.contents().to_vec(),
|
||||||
|
};
|
||||||
|
if std::fs::read(&dest).ok() != Some(contents.clone()) {
|
||||||
|
std::fs::create_dir_all(dest.parent().context("no parent")?)?;
|
||||||
|
std::fs::write(&dest, contents)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for sub in dir.dirs() {
|
||||||
|
write_dir(sub, to)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn next_build_number(dir: &Path) -> Result<u64> {
|
fn next_build_number(dir: &Path) -> Result<u64> {
|
||||||
std::fs::create_dir_all(dir)?;
|
std::fs::create_dir_all(dir)?;
|
||||||
let max = std::fs::read_dir(dir)?
|
let max = std::fs::read_dir(dir)?
|
||||||
@@ -158,12 +200,3 @@ fn strip_progress(stderr: &str) -> String {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n")
|
.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
|
|
||||||
}
|
|
||||||
|
|||||||
+25
-165
@@ -3,19 +3,13 @@
|
|||||||
//! - `@<actor> <json>` sends a JSON body. `@<actor> <text>` sends
|
//! - `@<actor> <json>` sends a JSON body. `@<actor> <text>` sends
|
||||||
//! `{"type": "text", "text": ...}`.
|
//! `{"type": "text", "text": ...}`.
|
||||||
//! - A line without `@` goes to `agent` as text.
|
//! - A line without `@` goes to `agent` as text.
|
||||||
//! - `/deploy <actor> <folder>` builds a Rust folder with `cargo`, then spawns
|
//! - `/help`, `/quit`. EOF on stdin also quits.
|
||||||
//! or upgrades the actor.
|
|
||||||
//! - `/list`, `/sleep <seconds>`, `/help`, `/quit`.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::sync::Arc;
|
||||||
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::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::broker::{Broker, Envelope};
|
use crate::broker::{Broker, Envelope};
|
||||||
|
|
||||||
@@ -25,183 +19,49 @@ const HELP: &str = "\
|
|||||||
@<actor> <json> send a JSON body to an actor
|
@<actor> <json> send a JSON body to an actor
|
||||||
@<actor> <text> send {\"type\":\"text\",\"text\":...}
|
@<actor> <text> send {\"type\":\"text\",\"text\":...}
|
||||||
<text> send text to the agent
|
<text> send text to the agent
|
||||||
/deploy <actor> <dir> build a Rust folder with cargo, then spawn or upgrade
|
|
||||||
/list list actors
|
|
||||||
/sleep <seconds> wait (for scripts)
|
|
||||||
/await <actor> <secs> wait for the next message from an actor (for scripts)
|
|
||||||
/quit stop all actors and exit";
|
/quit stop all actors and exit";
|
||||||
|
|
||||||
pub struct Console {
|
/// Print a message: text bodies as text, everything else as JSON.
|
||||||
broker: Arc<Broker>,
|
|
||||||
waiters: Mutex<HashMap<String, oneshot::Sender<Envelope>>>,
|
|
||||||
seq: AtomicU64,
|
|
||||||
/// Number of messages received per sender, for `/await`.
|
|
||||||
seen: Mutex<HashMap<String, u64>>,
|
|
||||||
arrived: tokio::sync::Notify,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show(env: &Envelope) {
|
fn show(env: &Envelope) {
|
||||||
let reply = if env.reply_to.is_some() { " ↩" } else { "" };
|
let reply = if env.reply_to.is_some() { " ↩" } else { "" };
|
||||||
let b = &env.body;
|
match env.body["text"].as_str() {
|
||||||
if b["type"] == "text"
|
Some(text) if env.body["type"] == "text" => println!("[{}{reply}] {text}", env.from),
|
||||||
&& let Some(t) = b["text"].as_str()
|
_ => println!("[{}{reply}] {}", env.from, env.body),
|
||||||
{
|
|
||||||
println!("[{}{reply}] {t}", env.from);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Console {
|
/// Print every message that arrives for the console.
|
||||||
pub fn new(broker: Arc<Broker>) -> Arc<Self> {
|
pub async fn run_mailbox(mut mailbox: mpsc::Receiver<Envelope>) {
|
||||||
Arc::new(Self {
|
|
||||||
broker,
|
|
||||||
waiters: Mutex::new(HashMap::new()),
|
|
||||||
seq: AtomicU64::new(1),
|
|
||||||
seen: Mutex::new(HashMap::new()),
|
|
||||||
arrived: tokio::sync::Notify::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run_mailbox(self: Arc<Self>, mut mailbox: mpsc::Receiver<Envelope>) {
|
|
||||||
while let Some(env) = mailbox.recv().await {
|
while let Some(env) = mailbox.recv().await {
|
||||||
show(&env);
|
show(&env);
|
||||||
*self.seen.lock().unwrap().entry(env.from.clone()).or_default() += 1;
|
|
||||||
self.arrived.notify_waiters();
|
|
||||||
if let Some(r) = &env.reply_to
|
|
||||||
&& let Some(tx) = self.waiters.lock().unwrap().remove(r)
|
|
||||||
{
|
|
||||||
let _ = tx.send(env);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn send(&self, to: &str, body: Value) -> String {
|
/// Read lines until EOF or `/quit`, and send them as messages.
|
||||||
let own = format!("c{}", self.seq.fetch_add(1, Ordering::Relaxed));
|
pub async fn run_input(broker: Arc<Broker>) {
|
||||||
self.broker.send(ID, Some(to), Some(&own), None, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn request(&self, to: &str, body: Value, timeout: Duration) -> Result<Envelope> {
|
|
||||||
let own = format!("c{}", self.seq.fetch_add(1, Ordering::Relaxed));
|
|
||||||
let (tx, rx) = oneshot::channel();
|
|
||||||
self.waiters.lock().unwrap().insert(format!("{ID}/{own}"), tx);
|
|
||||||
self.broker.send(ID, Some(to), Some(&own), None, body);
|
|
||||||
tokio::time::timeout(timeout, rx)
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow!("no reply from {to} within {timeout:?}"))?
|
|
||||||
.map_err(|_| anyhow!("reply channel closed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn deploy(&self, actor: &str, dir: &str) -> Result<()> {
|
|
||||||
let path = std::path::absolute(dir)?;
|
|
||||||
let source = path.display().to_string();
|
|
||||||
println!("· building {source} …");
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read commands until EOF or `/quit`.
|
|
||||||
pub async fn run_input(self: Arc<Self>) {
|
|
||||||
println!("Sausage console. Type /help for commands.");
|
println!("Sausage console. Type /help for commands.");
|
||||||
let mut lines = BufReader::new(tokio::io::stdin()).lines();
|
let mut lines = BufReader::new(tokio::io::stdin()).lines();
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
if line.is_empty() || line.starts_with('#') {
|
let (to, body) = match line {
|
||||||
|
"" => continue,
|
||||||
|
"/quit" => break,
|
||||||
|
"/help" => {
|
||||||
|
println!("{HELP}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Err(e) = self.command(line).await {
|
_ => match line.strip_prefix('@') {
|
||||||
if e.to_string() == "quit" {
|
Some(rest) => {
|
||||||
break;
|
|
||||||
}
|
|
||||||
println!("! {e:#}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn command(&self, line: &str) -> Result<()> {
|
|
||||||
if let Some(rest) = line.strip_prefix('@') {
|
|
||||||
let (to, text) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
|
let (to, text) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
|
||||||
let text = text.trim();
|
let body = match serde_json::from_str::<Value>(text.trim()) {
|
||||||
let body = match serde_json::from_str::<Value>(text) {
|
|
||||||
Ok(v @ Value::Object(_)) => v,
|
Ok(v @ Value::Object(_)) => v,
|
||||||
_ => json!({"type": "text", "text": text}),
|
_ => json!({"type": "text", "text": text.trim()}),
|
||||||
};
|
};
|
||||||
self.send(to, body);
|
(to, body)
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
if let Some(cmd) = line.strip_prefix('/') {
|
None => ("agent", json!({"type": "text", "text": line})),
|
||||||
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?;
|
broker.send(ID, Some(to), None, None, body);
|
||||||
}
|
|
||||||
_ => bail!("unknown command. Type /help."),
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
self.send("agent", json!({"type": "text", "text": line}));
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-434
@@ -1,434 +0,0 @@
|
|||||||
//! 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
//! Sausage: grow a system of WASI actors by talking to it.
|
||||||
|
//!
|
||||||
|
//! The host runs the broker, the native actors (`broker`, `cargo`, and the
|
||||||
|
//! console), and one supervisor per WASM actor. See `PROTOCOL.md`.
|
||||||
|
|
||||||
|
pub mod broker;
|
||||||
|
pub mod candidate;
|
||||||
|
pub mod cargo_actor;
|
||||||
|
pub mod console;
|
||||||
|
pub mod runtime;
|
||||||
|
pub mod supervisor;
|
||||||
|
pub mod vfs;
|
||||||
|
pub mod world;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
pub use broker::{Broker, Envelope};
|
||||||
|
pub use world::World;
|
||||||
|
|
||||||
|
/// The contract between the host and the actors. The host publishes it at
|
||||||
|
/// `/actor/broker/public/PROTOCOL.md`, so that every actor can read it.
|
||||||
|
const PROTOCOL: &str = include_str!("../../PROTOCOL.md");
|
||||||
|
|
||||||
|
/// A running world.
|
||||||
|
pub struct Host {
|
||||||
|
pub world: World,
|
||||||
|
pub broker: Arc<Broker>,
|
||||||
|
native: Vec<tokio::task::JoinHandle<()>>,
|
||||||
|
_lock: std::fs::File,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Host {
|
||||||
|
/// Start the broker, `cargo`, and every registered actor of the world at
|
||||||
|
/// `root`. Only one host can run a world at a time.
|
||||||
|
pub async fn start(root: &Path) -> Result<Host> {
|
||||||
|
let world = World::new(std::path::absolute(root)?);
|
||||||
|
if !world.exists() {
|
||||||
|
bail!("{} is not a Sausage world. Create one with: sausage init", root.display());
|
||||||
|
}
|
||||||
|
let lock = std::fs::File::create(world.root.join(".lock"))?;
|
||||||
|
if lock.try_lock().is_err() {
|
||||||
|
bail!("another sausage process runs {}", root.display());
|
||||||
|
}
|
||||||
|
let broker = Broker::new(world.clone(), runtime::Runtime::new()?)?;
|
||||||
|
world::write_atomic(&world.public_dir("broker").join("PROTOCOL.md"), PROTOCOL.as_bytes())?;
|
||||||
|
|
||||||
|
let cargo = cargo_actor::Cargo::new(world.clone())?;
|
||||||
|
let native = vec![
|
||||||
|
tokio::spawn(broker.clone().run_actor(broker.open_mailbox("broker"))),
|
||||||
|
tokio::spawn(cargo.run_actor(broker.clone(), broker.open_mailbox(cargo_actor::ID))),
|
||||||
|
];
|
||||||
|
for change in broker.scan().await {
|
||||||
|
eprintln!("sausage: {change}");
|
||||||
|
}
|
||||||
|
Ok(Host { world, broker, native, _lock: lock })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a native actor with the ID `id` and return its mailbox.
|
||||||
|
pub fn mailbox(&self, id: &str) -> mpsc::Receiver<Envelope> {
|
||||||
|
self.broker.open_mailbox(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the broker and `cargo` (with the requests they still run), then
|
||||||
|
/// every actor, gracefully.
|
||||||
|
pub async fn stop(self) {
|
||||||
|
for task in &self.native {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
for task in self.native {
|
||||||
|
let _ = task.await;
|
||||||
|
}
|
||||||
|
self.broker.shutdown_all().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
-114
@@ -1,24 +1,13 @@
|
|||||||
//! Sausage: grow a system of WASI actors by talking to it.
|
|
||||||
|
|
||||||
mod broker;
|
|
||||||
mod candidate;
|
|
||||||
mod cargo_actor;
|
|
||||||
mod console;
|
|
||||||
mod fs;
|
|
||||||
mod runtime;
|
|
||||||
mod world;
|
|
||||||
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use include_dir::{Dir, include_dir};
|
use include_dir::{Dir, include_dir};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::broker::Broker;
|
use sausage::world::{ActorMeta, World};
|
||||||
use crate::world::{NATIVE, Registry, VersionMeta, World};
|
use sausage::{Host, cargo_actor, console};
|
||||||
|
|
||||||
/// The source of the agent actor. `init` builds it like any other actor.
|
/// 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_SOURCE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent");
|
||||||
@@ -33,19 +22,13 @@ struct Cli {
|
|||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Cmd {
|
enum Cmd {
|
||||||
/// Create a new world and build the agent actor.
|
/// Create a new world and build the agent actor.
|
||||||
|
///
|
||||||
|
/// Reads SAUSAGE_API, SAUSAGE_BASE_URL, SAUSAGE_MODEL and
|
||||||
|
/// SAUSAGE_API_TOKEN, and asks for every value that is missing.
|
||||||
Init {
|
Init {
|
||||||
/// Folder of the new world.
|
/// 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.
|
/// Start the broker, all actors, and the console.
|
||||||
Run {
|
Run {
|
||||||
@@ -54,7 +37,13 @@ enum Cmd {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prompt(question: &str, default: Option<&str>) -> Result<String> {
|
/// The value of `var`, or the answer to `question`.
|
||||||
|
fn setting(var: &str, question: &str, default: Option<&str>) -> Result<String> {
|
||||||
|
if let Ok(v) = std::env::var(var)
|
||||||
|
&& !v.is_empty()
|
||||||
|
{
|
||||||
|
return Ok(v);
|
||||||
|
}
|
||||||
match default {
|
match default {
|
||||||
Some(d) => print!("{question} [{d}]: "),
|
Some(d) => print!("{question} [{d}]: "),
|
||||||
None => print!("{question}: "),
|
None => print!("{question}: "),
|
||||||
@@ -62,123 +51,68 @@ fn prompt(question: &str, default: Option<&str>) -> Result<String> {
|
|||||||
std::io::stdout().flush()?;
|
std::io::stdout().flush()?;
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
std::io::stdin().read_line(&mut line)?;
|
std::io::stdin().read_line(&mut line)?;
|
||||||
let v = line.trim().to_string();
|
match (line.trim(), default) {
|
||||||
match (v.is_empty(), default) {
|
("", Some(d)) => Ok(d.to_string()),
|
||||||
(true, Some(d)) => Ok(d.to_string()),
|
("", None) => bail!("{question} is required (or set {var})"),
|
||||||
(true, None) => bail!("{question} is required"),
|
(v, _) => Ok(v.to_string()),
|
||||||
_ => Ok(v),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn init(dir: PathBuf, api: Option<String>, base_url: Option<String>, model: Option<String>) -> Result<()> {
|
async fn init(dir: PathBuf) -> Result<()> {
|
||||||
let world = World::new(std::path::absolute(&dir)?);
|
let api = setting("SAUSAGE_API", "API shape (openai-responses or anthropic-messages)", Some("openai-responses"))?;
|
||||||
if world.exists() {
|
|
||||||
bail!("{} is already a Sausage world", dir.display());
|
|
||||||
}
|
|
||||||
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" {
|
if api != "openai-responses" && api != "anthropic-messages" {
|
||||||
bail!("unknown API shape {api:?}");
|
bail!("unknown API shape {api:?}");
|
||||||
}
|
}
|
||||||
let base_url = match base_url {
|
let base_url = setting("SAUSAGE_BASE_URL", "API base URL", None)?;
|
||||||
Some(u) => u,
|
let model = setting("SAUSAGE_MODEL", "Model", None)?;
|
||||||
None => prompt("API base URL", None)?,
|
let token = setting("SAUSAGE_API_TOKEN", "API token", 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)?,
|
|
||||||
};
|
|
||||||
|
|
||||||
std::fs::create_dir_all(&world.root)?;
|
let world = World::create(&dir)?;
|
||||||
let mut reg = Registry::default();
|
let console = ActorMeta { subscriptions: ["agent".to_string()].into(), ..world.read_meta(console::ID) };
|
||||||
reg.subscriptions.entry("agent".into()).or_default().insert("console".into());
|
world.write_meta(console::ID, &console)?;
|
||||||
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(),
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The agent's private configuration. Only the agent can read /data.
|
// The agent's configuration, in its /data.
|
||||||
world.ensure_actor_dirs("agent")?;
|
world.ensure_actor_dirs("agent")?;
|
||||||
let config = json!({"api": api, "base_url": base_url.trim_end_matches('/'), "model": model, "token": token});
|
let config = json!({"api": api, "base_url": base_url.trim_end_matches('/'), "model": model, "token": token});
|
||||||
let config_path = world.data_dir("agent").join("config.json");
|
let config_path = world.data_dir("agent").join("config.json");
|
||||||
std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
|
std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the agent with the same path as every other Rust actor.
|
// The agent is an ordinary actor folder: `run` finds its `actor.wasm`,
|
||||||
let source = world.root.join("broker").join("seed").join("agent");
|
// tests it, and installs it as version 1.
|
||||||
|
let source = world.source_dir("agent");
|
||||||
std::fs::create_dir_all(&source)?;
|
std::fs::create_dir_all(&source)?;
|
||||||
AGENT_SOURCE.extract(&source).context("extract agent source")?;
|
AGENT_SOURCE.extract(&source).context("extract agent source")?;
|
||||||
println!("Building the agent actor (the first build takes a few minutes) …");
|
println!("Building the agent actor (the first build takes a few minutes) …");
|
||||||
let cargo = cargo_actor::Cargo::new(world.clone());
|
let out = cargo_actor::Cargo::new(world.clone())?.build(&source).await.context("build agent")?;
|
||||||
let out = cargo.build(&source).await.context("build agent")?;
|
std::fs::copy(&out.wasm, world.wasm_path("agent"))?;
|
||||||
println!("Built in {:.1} s.", out.seconds);
|
println!("Built in {:.1} s. Start the world with: sausage run {}", out.seconds, dir.display());
|
||||||
|
|
||||||
let runtime = runtime::Runtime::new()?;
|
|
||||||
let broker = Broker::new(world.clone(), runtime)?;
|
|
||||||
let meta = VersionMeta {
|
|
||||||
version: 1,
|
|
||||||
wasm: world.global_path(&out.wasm).unwrap_or_default(),
|
|
||||||
source: Some(source.display().to_string()),
|
|
||||||
built_by: Some(cargo_actor::ID.into()),
|
|
||||||
requested_by: "init".into(),
|
|
||||||
created_at: world::now(),
|
|
||||||
};
|
|
||||||
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());
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run(dir: PathBuf) -> Result<()> {
|
async fn run(dir: PathBuf) -> Result<()> {
|
||||||
let world = World::new(std::path::absolute(&dir)?);
|
let host = Host::start(&dir).await?;
|
||||||
if !world.exists() {
|
tokio::spawn(console::run_mailbox(host.mailbox(console::ID)));
|
||||||
bail!("{} is not a Sausage world. Create one with: sausage init {}", dir.display(), dir.display());
|
let broker = host.broker.clone();
|
||||||
}
|
let exit = tokio::select! {
|
||||||
// One host process per world.
|
_ = console::run_input(host.broker.clone()) => false,
|
||||||
let lock = std::fs::File::create(world.root.join(".lock"))?;
|
_ = broker.exit_requested() => true,
|
||||||
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 …");
|
println!("Stopping actors …");
|
||||||
broker.shutdown_all().await;
|
host.stop().await;
|
||||||
drop(lock);
|
if exit {
|
||||||
|
// The console still blocks on a stdin read that cannot be cancelled,
|
||||||
|
// so the runtime would not shut down on its own. Exit code 75 tells
|
||||||
|
// `nix run .#loop` to start the host again (0 = /quit = stop the loop).
|
||||||
|
println!("Exiting, as an actor asked the broker to.");
|
||||||
|
std::process::exit(75);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
match Cli::parse().command {
|
||||||
match cli.command {
|
Cmd::Init { dir } => init(dir).await,
|
||||||
Cmd::Init { dir, api, base_url, model } => init(dir, api, base_url, model).await,
|
|
||||||
Cmd::Run { dir } => run(dir).await,
|
Cmd::Run { dir } => run(dir).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-60
@@ -4,7 +4,7 @@
|
|||||||
//! 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, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Weak};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -14,22 +14,21 @@ 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::filesystem::WasiFilesystemCtxView;
|
||||||
use wasmtime_wasi::p2::bindings::Command;
|
use wasmtime_wasi::p2::bindings::Command;
|
||||||
use wasmtime_wasi::p2::bindings::filesystem::{preopens, types};
|
use wasmtime_wasi::p2::bindings::filesystem;
|
||||||
use wasmtime_wasi::{FsPerms, I32Exit, WasiCtx, WasiCtxView, WasiView};
|
use wasmtime_wasi::{FsPerms, I32Exit, WasiCtx, WasiCtxView, WasiView};
|
||||||
use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView};
|
use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView};
|
||||||
|
|
||||||
use crate::fs::{ActorFs, ActorFsData, FsExt, Mount};
|
use crate::broker::Broker;
|
||||||
|
use crate::vfs::{Vfs, VfsState, VfsView};
|
||||||
|
|
||||||
pub struct WasiPart {
|
/// A host folder that the guest sees at `guest`.
|
||||||
ctx: WasiCtx,
|
#[derive(Clone, Debug)]
|
||||||
table: ResourceTable,
|
pub struct Mount {
|
||||||
}
|
pub guest: String,
|
||||||
|
pub host: PathBuf,
|
||||||
impl WasiView for WasiPart {
|
pub writable: bool,
|
||||||
fn ctx(&mut self) -> WasiCtxView<'_> {
|
|
||||||
WasiCtxView { ctx: &mut self.ctx, table: &mut self.table }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct HttpHooks {
|
pub struct HttpHooks {
|
||||||
@@ -67,28 +66,26 @@ impl WasiHttpHooks for HttpHooks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct ActorState {
|
pub struct ActorState {
|
||||||
wasi: WasiPart,
|
ctx: WasiCtx,
|
||||||
fsx: FsExt,
|
table: ResourceTable,
|
||||||
http: WasiHttpCtx,
|
http: WasiHttpCtx,
|
||||||
hooks: HttpHooks,
|
hooks: HttpHooks,
|
||||||
|
vfs: VfsState,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vfs_view(s: &mut ActorState) -> VfsView<'_> {
|
||||||
|
VfsView { inner: WasiFilesystemCtxView { ctx: s.ctx.filesystem(), table: &mut s.table }, state: &mut s.vfs }
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WasiView for ActorState {
|
impl WasiView for ActorState {
|
||||||
fn ctx(&mut self) -> WasiCtxView<'_> {
|
fn ctx(&mut self) -> WasiCtxView<'_> {
|
||||||
self.wasi.ctx()
|
WasiCtxView { ctx: &mut self.ctx, table: &mut self.table }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WasiHttpView for ActorState {
|
impl WasiHttpView for ActorState {
|
||||||
fn http(&mut self) -> WasiHttpCtxView<'_> {
|
fn http(&mut self) -> WasiHttpCtxView<'_> {
|
||||||
WasiHttpCtxView { ctx: &mut self.http, table: &mut self.wasi.table, hooks: &mut self.hooks }
|
WasiHttpCtxView { 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,12 +105,6 @@ 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 {
|
||||||
@@ -126,24 +117,52 @@ impl std::fmt::Display for Exit {
|
|||||||
|
|
||||||
pub struct InstanceConfig {
|
pub struct InstanceConfig {
|
||||||
pub mounts: Vec<Mount>,
|
pub mounts: Vec<Mount>,
|
||||||
pub allow_http: bool,
|
/// Outgoing HTTP, and TCP, UDP, and DNS through `wasi:sockets`.
|
||||||
|
pub network: bool,
|
||||||
pub stderr_log: PathBuf,
|
pub stderr_log: PathBuf,
|
||||||
pub env: Vec<(String, String)>,
|
pub env: Vec<(String, String)>,
|
||||||
|
/// The actor that runs in the instance.
|
||||||
|
pub actor: String,
|
||||||
|
/// Asks the actors that serve `/actor/<id>/fs/` (see `vfs.rs`).
|
||||||
|
pub broker: Weak<Broker>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A running guest. Dropping it kills the guest.
|
||||||
pub struct Instance {
|
pub struct Instance {
|
||||||
stdin: DuplexStream,
|
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>,
|
||||||
task: JoinHandle<Exit>,
|
task: JoinHandle<Exit>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for Instance {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Instance {
|
impl Instance {
|
||||||
/// Write one line to the guest's stdin.
|
/// Write one line to the guest's stdin, in one write. Meanwhile, pass the
|
||||||
pub async fn send_line(&mut self, line: &str) -> std::io::Result<()> {
|
/// lines that the guest writes to `route`: a guest that blocks on a full
|
||||||
self.stdin.write_all(line.as_bytes()).await?;
|
/// stdout would never read its stdin.
|
||||||
self.stdin.write_all(b"\n").await?;
|
pub async fn send_line(&mut self, line: &str, mut route: impl FnMut(String)) -> std::io::Result<()> {
|
||||||
self.stdin.flush().await
|
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.
|
||||||
@@ -159,19 +178,35 @@ impl Instance {
|
|||||||
self.task.is_finished()
|
self.task.is_finished()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send `shutdown`, close stdin, and wait. Kill the guest after `deadline`.
|
/// Kill the guest. Its stdout closes when it has ended.
|
||||||
pub async fn stop(mut self, shutdown_line: &str, deadline: Duration) -> Exit {
|
pub fn kill(&self) {
|
||||||
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();
|
self.task.abort();
|
||||||
let _ = (&mut self.task).await;
|
|
||||||
Exit::Trap("killed after shutdown deadline".into())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send `shutdown_line`, close stdin, and wait until the guest ends.
|
||||||
|
/// Pass every line that the guest writes meanwhile to `route`. Kill the
|
||||||
|
/// guest when `deadline` has passed.
|
||||||
|
pub async fn stop(mut self, shutdown_line: &str, deadline: Duration, mut route: impl FnMut(String)) -> Exit {
|
||||||
|
let stdin = self.stdin.take();
|
||||||
|
let stdout = &mut self.stdout;
|
||||||
|
let finished = tokio::time::timeout(deadline, async {
|
||||||
|
let close = async {
|
||||||
|
if let Some(mut stdin) = stdin {
|
||||||
|
let _ = stdin.write_all(format!("{shutdown_line}\n").as_bytes()).await;
|
||||||
|
let _ = stdin.shutdown().await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let drain = async {
|
||||||
|
while let Some(line) = stdout.recv().await {
|
||||||
|
route(line);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::join!(close, drain);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match finished {
|
||||||
|
Ok(()) => self.wait().await,
|
||||||
|
Err(_) => Exit::Trap("killed after shutdown deadline".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,10 +238,10 @@ impl Runtime {
|
|||||||
let mut linker: Linker<ActorState> = 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)?;
|
||||||
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.
|
// Replace `wasi:filesystem` with the wrapper that serves `/actor/<id>/fs/`.
|
||||||
linker.allow_shadowing(true);
|
linker.allow_shadowing(true);
|
||||||
types::add_to_linker::<ActorState, ActorFsData>(&mut linker, actor_fs)?;
|
filesystem::types::add_to_linker::<ActorState, Vfs>(&mut linker, vfs_view)?;
|
||||||
preopens::add_to_linker::<ActorState, ActorFsData>(&mut linker, actor_fs)?;
|
filesystem::preopens::add_to_linker::<ActorState, Vfs>(&mut linker, vfs_view)?;
|
||||||
linker.allow_shadowing(false);
|
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.
|
||||||
@@ -238,6 +273,9 @@ impl Runtime {
|
|||||||
.with_context(|| format!("open {}", cfg.stderr_log.display()))?;
|
.with_context(|| format!("open {}", cfg.stderr_log.display()))?;
|
||||||
|
|
||||||
let mut b = WasiCtx::builder();
|
let mut b = WasiCtx::builder();
|
||||||
|
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, tokio::fs::File::from_std(stderr)));
|
b.stderr(AsyncStdoutStream::new(8192, tokio::fs::File::from_std(stderr)));
|
||||||
@@ -251,10 +289,11 @@ impl Runtime {
|
|||||||
.with_context(|| format!("mount {} at {}", m.host.display(), m.guest))?;
|
.with_context(|| format!("mount {} at {}", m.host.display(), m.guest))?;
|
||||||
}
|
}
|
||||||
let state = ActorState {
|
let state = ActorState {
|
||||||
wasi: WasiPart { ctx: b.build(), table: ResourceTable::new() },
|
ctx: b.build(),
|
||||||
fsx: FsExt::new(cfg.mounts),
|
table: ResourceTable::new(),
|
||||||
http: WasiHttpCtx::new(),
|
http: WasiHttpCtx::new(),
|
||||||
hooks: HttpHooks { allow: cfg.allow_http },
|
hooks: HttpHooks { allow: cfg.network },
|
||||||
|
vfs: VfsState::new(cfg.broker.clone(), cfg.actor.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (line_tx, line_rx) = mpsc::channel(1024);
|
let (line_tx, line_rx) = mpsc::channel(1024);
|
||||||
@@ -278,7 +317,7 @@ impl Runtime {
|
|||||||
cmd.wasi_cli_run().call_run(&mut store).await
|
cmd.wasi_cli_run().call_run(&mut store).await
|
||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
let exit = match result {
|
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>() {
|
||||||
@@ -291,14 +330,9 @@ impl Runtime {
|
|||||||
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 { stdin: host_in, stdout: line_rx, task })
|
Ok(Instance { stdin: Some(host_in), stdout: line_rx, task })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
//! The supervisor of one WASM actor: it runs the instance, feeds it the
|
||||||
|
//! mailbox, routes its output, restarts it after a crash, and swaps versions.
|
||||||
|
//!
|
||||||
|
//! The broker talks to a supervisor only through `Control` requests.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use tokio::sync::{mpsc, oneshot};
|
||||||
|
use wasmtime::component::Component;
|
||||||
|
|
||||||
|
use crate::broker::{Broker, Envelope, SHUTDOWN_DEADLINE, shutdown_line};
|
||||||
|
use crate::runtime::{Exit, Instance};
|
||||||
|
|
||||||
|
/// A new version that crashes this often within `CRASH_WINDOW` after an
|
||||||
|
/// upgrade is rolled back automatically.
|
||||||
|
const CRASH_LIMIT: usize = 3;
|
||||||
|
const CRASH_WINDOW: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
pub type Reply = oneshot::Sender<Result<Value>>;
|
||||||
|
|
||||||
|
pub enum Control {
|
||||||
|
/// Swap to `version` (already in `versions/`, compiled as `component`).
|
||||||
|
Upgrade { version: u64, component: Component, requester: String, reply: Reply },
|
||||||
|
Rollback { version: u64, reply: Reply },
|
||||||
|
Stop { reply: Reply },
|
||||||
|
Start { reply: Reply },
|
||||||
|
Shutdown { reply: Reply },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The time after an upgrade in which crashes count toward a rollback.
|
||||||
|
struct Probation {
|
||||||
|
previous: u64,
|
||||||
|
since: Instant,
|
||||||
|
crashes: usize,
|
||||||
|
requester: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Supervisor {
|
||||||
|
broker: Arc<Broker>,
|
||||||
|
id: String,
|
||||||
|
version: u64,
|
||||||
|
running: bool,
|
||||||
|
mailbox: mpsc::Receiver<Envelope>,
|
||||||
|
control: mpsc::Receiver<Control>,
|
||||||
|
component: Option<Component>,
|
||||||
|
instance: Option<Instance>,
|
||||||
|
started_at: Instant,
|
||||||
|
restarts: u32,
|
||||||
|
restart_at: Option<Instant>,
|
||||||
|
probation: Option<Probation>,
|
||||||
|
/// A control request that arrived during a blocked delivery.
|
||||||
|
next_control: Option<Control>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn next_line(instance: &mut Option<Instance>) -> Option<String> {
|
||||||
|
match instance {
|
||||||
|
Some(i) => i.stdout.recv().await,
|
||||||
|
None => std::future::pending().await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sleep_until(t: Option<Instant>) {
|
||||||
|
match t {
|
||||||
|
Some(t) => tokio::time::sleep_until(t.into()).await,
|
||||||
|
None => std::future::pending().await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Supervisor {
|
||||||
|
pub fn new(
|
||||||
|
broker: Arc<Broker>,
|
||||||
|
id: String,
|
||||||
|
version: u64,
|
||||||
|
running: bool,
|
||||||
|
mailbox: mpsc::Receiver<Envelope>,
|
||||||
|
control: mpsc::Receiver<Control>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
broker,
|
||||||
|
id,
|
||||||
|
version,
|
||||||
|
running,
|
||||||
|
mailbox,
|
||||||
|
control,
|
||||||
|
component: None,
|
||||||
|
instance: None,
|
||||||
|
started_at: Instant::now(),
|
||||||
|
restarts: 0,
|
||||||
|
restart_at: None,
|
||||||
|
probation: None,
|
||||||
|
next_control: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(mut self) {
|
||||||
|
loop {
|
||||||
|
if let Some(ctl) = self.next_control.take() {
|
||||||
|
if self.handle(ctl).await {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.running && self.instance.is_none() && self.restart_at.is_none_or(|t| Instant::now() >= t) {
|
||||||
|
self.restart_at = None;
|
||||||
|
let _ = self.start().await;
|
||||||
|
}
|
||||||
|
let has_instance = self.instance.is_some();
|
||||||
|
tokio::select! {
|
||||||
|
ctl = self.control.recv() => {
|
||||||
|
let Some(ctl) = ctl else { break };
|
||||||
|
if self.handle(ctl).await {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg = self.mailbox.recv(), if has_instance => {
|
||||||
|
let Some(msg) = msg else { break };
|
||||||
|
self.deliver(msg).await;
|
||||||
|
}
|
||||||
|
line = next_line(&mut self.instance) => match line {
|
||||||
|
Some(line) => self.broker.route_line(&self.id, &line),
|
||||||
|
None => {
|
||||||
|
let exit = self.instance.as_mut().unwrap().wait().await;
|
||||||
|
self.instance = None;
|
||||||
|
self.on_exit(exit).await;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ = sleep_until(self.restart_at), if !has_instance && self.running => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a message to the instance's stdin, and route its output
|
||||||
|
/// meanwhile. If a control request arrives before the actor reads the
|
||||||
|
/// message, the instance is killed (a partly written line would corrupt
|
||||||
|
/// the next message) and the request runs.
|
||||||
|
async fn deliver(&mut self, msg: Envelope) {
|
||||||
|
let Some(inst) = self.instance.as_mut() else { return };
|
||||||
|
let (broker, id, line) = (self.broker.clone(), self.id.clone(), msg.to_line());
|
||||||
|
let stuck = tokio::select! {
|
||||||
|
written = inst.send_line(&line, |l| broker.route_line(&id, &l)) => match written {
|
||||||
|
Ok(()) => return,
|
||||||
|
// The instance is ending. The main loop sees its closed stdout.
|
||||||
|
Err(_) => false,
|
||||||
|
},
|
||||||
|
Some(ctl) = self.control.recv() => {
|
||||||
|
self.next_control = Some(ctl);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if msg.from != "broker" {
|
||||||
|
let body = json!({"type": "undeliverable", "reason": "the actor stopped", "to": self.id});
|
||||||
|
self.broker.send("broker", Some(&msg.from), None, Some(&msg.id), body);
|
||||||
|
}
|
||||||
|
if stuck && let Some(inst) = &self.instance {
|
||||||
|
self.broker.log(&self.id, "killed: it did not read stdin when the broker needed it");
|
||||||
|
inst.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start an instance of the current version. If that fails, the actor
|
||||||
|
/// stays stopped until the next start, upgrade, or rollback.
|
||||||
|
async fn start(&mut self) -> Result<()> {
|
||||||
|
let started = self.try_start();
|
||||||
|
match &started {
|
||||||
|
Ok(()) => self.started_at = Instant::now(),
|
||||||
|
Err(e) => {
|
||||||
|
self.broker.log(&self.id, &format!("cannot start version {}: {e:#}", self.version));
|
||||||
|
self.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
started
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_start(&mut self) -> Result<()> {
|
||||||
|
if self.component.is_none() {
|
||||||
|
self.component = Some(self.broker.load_version(&self.id, self.version)?);
|
||||||
|
}
|
||||||
|
let cfg = self.broker.instance_config(&self.id, self.version, None);
|
||||||
|
self.instance = Some(self.broker.runtime.start(self.component.as_ref().unwrap(), cfg)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the instance gracefully. Its last messages still get routed.
|
||||||
|
async fn stop(&mut self) {
|
||||||
|
let Some(inst) = self.instance.take() else { return };
|
||||||
|
let (broker, id) = (self.broker.clone(), self.id.clone());
|
||||||
|
let exit = inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE, |line| broker.route_line(&id, &line)).await;
|
||||||
|
self.broker.log(&self.id, &format!("stopped: {exit}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_exit(&mut self, exit: Exit) {
|
||||||
|
self.broker.log(&self.id, &format!("instance ended unexpectedly: {exit}"));
|
||||||
|
if let Some(p) = &mut self.probation
|
||||||
|
&& p.since.elapsed() < CRASH_WINDOW
|
||||||
|
{
|
||||||
|
p.crashes += 1;
|
||||||
|
if p.crashes >= CRASH_LIMIT {
|
||||||
|
let (from, to, requester) = (self.version, p.previous, p.requester.clone());
|
||||||
|
self.broker.log(&self.id, &format!("version {from} crashed {CRASH_LIMIT} times after the upgrade; rolling back to {to}"));
|
||||||
|
let body = match self.rollback(to).await {
|
||||||
|
Ok(_) => json!({"type": "rolled_back", "actor": self.id, "from": from, "to": to,
|
||||||
|
"reason": "crash loop after upgrade", "last_exit": exit.to_string()}),
|
||||||
|
Err(e) => json!({"type": "error", "actor": self.id, "reason": format!("automatic rollback failed: {e:#}")}),
|
||||||
|
};
|
||||||
|
self.broker.send("broker", Some(&requester), None, None, body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Restart after 0.5 s, doubling up to 30 s; a minute of uptime resets it.
|
||||||
|
if self.started_at.elapsed() > Duration::from_secs(60) {
|
||||||
|
self.restarts = 0;
|
||||||
|
}
|
||||||
|
let delay = Duration::from_millis(500 << self.restarts.min(6)).min(Duration::from_secs(30));
|
||||||
|
self.restarts += 1;
|
||||||
|
self.restart_at = Some(Instant::now() + delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make `version` current. It is compiled first (unless `component` is
|
||||||
|
/// given): if that fails, nothing changes. `data/` and `public/` stay as
|
||||||
|
/// they are.
|
||||||
|
async fn install(&mut self, version: u64, component: Option<Component>) -> Result<()> {
|
||||||
|
let component = match component {
|
||||||
|
Some(c) => c,
|
||||||
|
None => self.broker.load_version(&self.id, version)?,
|
||||||
|
};
|
||||||
|
self.stop().await;
|
||||||
|
self.broker.install(&self.id, version)?;
|
||||||
|
self.version = version;
|
||||||
|
self.component = Some(component);
|
||||||
|
self.running = true;
|
||||||
|
self.restarts = 0;
|
||||||
|
self.restart_at = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upgrade(&mut self, version: u64, component: Component, requester: String) -> Result<Value> {
|
||||||
|
let previous = self.version;
|
||||||
|
self.install(version, Some(component)).await?;
|
||||||
|
self.probation = Some(Probation { previous, since: Instant::now(), crashes: 0, requester });
|
||||||
|
if let Err(e) = self.start().await {
|
||||||
|
self.rollback(previous).await?;
|
||||||
|
bail!("version {version} did not start ({e:#}); rolled back to {previous}");
|
||||||
|
}
|
||||||
|
Ok(json!({"type": "upgraded", "actor": self.id, "version": version, "previous": previous}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rollback(&mut self, version: u64) -> Result<Value> {
|
||||||
|
let from = self.version;
|
||||||
|
self.install(version, None).await?;
|
||||||
|
self.probation = None;
|
||||||
|
self.start().await?;
|
||||||
|
Ok(json!({"type": "rolled_back", "actor": self.id, "from": from, "to": version}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a control request. Returns `true` when the supervisor ends.
|
||||||
|
async fn handle(&mut self, ctl: Control) -> bool {
|
||||||
|
match ctl {
|
||||||
|
Control::Upgrade { version, component, requester, reply } => {
|
||||||
|
let _ = reply.send(self.upgrade(version, component, requester).await);
|
||||||
|
}
|
||||||
|
Control::Rollback { version, reply } => {
|
||||||
|
let _ = reply.send(self.rollback(version).await);
|
||||||
|
}
|
||||||
|
Control::Stop { reply } => {
|
||||||
|
self.running = false;
|
||||||
|
self.stop().await;
|
||||||
|
let r = self.broker.set_running(&self.id, false);
|
||||||
|
let _ = reply.send(r.map(|_| json!({"type": "stopped", "actor": self.id})));
|
||||||
|
}
|
||||||
|
Control::Start { reply } => {
|
||||||
|
self.running = true;
|
||||||
|
self.restart_at = None;
|
||||||
|
let r = self.broker.set_running(&self.id, true);
|
||||||
|
let _ = reply.send(r.map(|_| json!({"type": "started", "actor": self.id})));
|
||||||
|
}
|
||||||
|
Control::Shutdown { reply } => {
|
||||||
|
self.stop().await;
|
||||||
|
let _ = reply.send(Ok(json!({"type": "shut_down"})));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
+716
@@ -0,0 +1,716 @@
|
|||||||
|
//! Actor-served files: `/actor/<id>/fs/...` (see PROTOCOL.md, "Actor-served
|
||||||
|
//! files").
|
||||||
|
//!
|
||||||
|
//! This module wraps the `wasi:filesystem` implementation of `wasmtime-wasi`.
|
||||||
|
//! A path under `/actor/<id>/fs/` does not reach the disk: the host asks the
|
||||||
|
//! actor `<id>` with a message from `fs` and waits up to `ASK_TIMEOUT` for
|
||||||
|
//! the reply. Every other path goes to `wasmtime-wasi` unchanged.
|
||||||
|
//!
|
||||||
|
//! - `fs_read {path}` -> `fs_file {text | base64}`, `fs_dir {entries: [{name, type}]}`
|
||||||
|
//! - `fs_write {path, text | base64}`, `fs_mkdir {path}`, `fs_remove {path}`,
|
||||||
|
//! `fs_rename {path, to}` -> `fs_done`
|
||||||
|
//! - any of them -> `fs_error {errno}` (a `wasi:filesystem` error-code name)
|
||||||
|
//!
|
||||||
|
//! A file is read once when it is opened. After a change, it is written back
|
||||||
|
//! in one `fs_write` when it is synced or closed.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, Weak};
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use wasmtime::component::{HasData, Resource};
|
||||||
|
use wasmtime_wasi::filesystem::WasiFilesystemCtxView;
|
||||||
|
use wasmtime_wasi::p2::bindings::filesystem::types::{
|
||||||
|
self, DescriptorFlags, DescriptorType, DirectoryEntry, ErrorCode, HostDescriptor, HostDirectoryEntryStream, OpenFlags,
|
||||||
|
};
|
||||||
|
use wasmtime_wasi::p2::bindings::filesystem::preopens;
|
||||||
|
use wasmtime_wasi::p2::pipe::MemoryInputPipe;
|
||||||
|
use wasmtime_wasi::p2::{DynInputStream, DynOutputStream, FsError, FsResult, OutputStream, Pollable, StreamResult};
|
||||||
|
|
||||||
|
use crate::broker::Broker;
|
||||||
|
|
||||||
|
/// The `from` of the host's requests. Replies to it go back to the waiting call.
|
||||||
|
pub const ID: &str = "fs";
|
||||||
|
pub const ASK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Per-instance state.
|
||||||
|
pub struct VfsState {
|
||||||
|
broker: Weak<Broker>,
|
||||||
|
/// The actor of this instance, for notes in its `stderr.log`.
|
||||||
|
me: String,
|
||||||
|
/// Descriptors of the `/actor` preopen.
|
||||||
|
roots: HashSet<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VfsState {
|
||||||
|
pub fn new(broker: Weak<Broker>, me: String) -> Self {
|
||||||
|
Self { broker, me, roots: HashSet::new() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`HasData`] for the linker: the filesystem functions get a [`VfsView`].
|
||||||
|
pub struct Vfs;
|
||||||
|
|
||||||
|
impl HasData for Vfs {
|
||||||
|
type Data<'a> = VfsView<'a>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VfsView<'a> {
|
||||||
|
pub inner: WasiFilesystemCtxView<'a>,
|
||||||
|
pub state: &'a mut VfsState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A descriptor of an actor-served file or folder (in the resource table in
|
||||||
|
/// place of a `wasmtime-wasi` descriptor).
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum Virt {
|
||||||
|
Dir { actor: String, path: String },
|
||||||
|
File(VFile),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct VFile {
|
||||||
|
actor: String,
|
||||||
|
path: String,
|
||||||
|
data: Arc<Mutex<Vec<u8>>>,
|
||||||
|
dirty: Arc<AtomicBool>,
|
||||||
|
write: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A directory-entry-stream of an actor-served folder.
|
||||||
|
struct VDir(std::vec::IntoIter<DirectoryEntry>);
|
||||||
|
|
||||||
|
enum Node {
|
||||||
|
File(Vec<u8>),
|
||||||
|
Dir(Vec<DirectoryEntry>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the requests. Owned, so that no borrow of the view lives across an await.
|
||||||
|
struct Asker {
|
||||||
|
broker: Option<Arc<Broker>>,
|
||||||
|
me: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Asker {
|
||||||
|
async fn ask(&self, actor: &str, body: Value) -> Result<Value, ErrorCode> {
|
||||||
|
let broker = self.broker.as_ref().ok_or(ErrorCode::Io)?;
|
||||||
|
let what = format!("{} {} to {actor}", body["type"].as_str().unwrap_or(""), body["path"].as_str().unwrap_or(""));
|
||||||
|
match broker.ask(actor, body).await {
|
||||||
|
None => {
|
||||||
|
broker.log(&self.me, &format!("{what}: no reply within {} s", ASK_TIMEOUT.as_secs()));
|
||||||
|
Err(ErrorCode::Io)
|
||||||
|
}
|
||||||
|
Some(reply) => match reply["type"].as_str() {
|
||||||
|
Some("fs_error") => Err(errno(&reply["errno"])),
|
||||||
|
Some("undeliverable") => Err(ErrorCode::NoEntry),
|
||||||
|
_ => Ok(reply),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read(&self, actor: &str, path: &str) -> Result<Node, ErrorCode> {
|
||||||
|
let reply = self.ask(actor, json!({"type": "fs_read", "path": path})).await?;
|
||||||
|
match reply["type"].as_str() {
|
||||||
|
Some("fs_file") => content(&reply).map(Node::File),
|
||||||
|
Some("fs_dir") => Ok(Node::Dir(
|
||||||
|
reply["entries"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|e| {
|
||||||
|
let type_ = if e["type"] == "dir" { DescriptorType::Directory } else { DescriptorType::RegularFile };
|
||||||
|
Some(DirectoryEntry { type_, name: e["name"].as_str()?.to_string() })
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)),
|
||||||
|
_ => {
|
||||||
|
if let Some(b) = &self.broker {
|
||||||
|
b.log(&self.me, &format!("fs_read {path} to {actor}: unexpected reply {reply}"));
|
||||||
|
}
|
||||||
|
Err(ErrorCode::Io)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn change(&self, actor: &str, body: Value) -> FsResult<()> {
|
||||||
|
self.ask(actor, body).await.map(|_| ()).map_err(FsError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn flush(&self, f: &VFile) -> FsResult<()> {
|
||||||
|
if f.dirty.swap(false, Ordering::SeqCst)
|
||||||
|
&& let Err(e) = self.ask(&f.actor, write_body(f)).await
|
||||||
|
{
|
||||||
|
f.dirty.store(true, Ordering::SeqCst);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn errno(v: &Value) -> ErrorCode {
|
||||||
|
match v.as_str().unwrap_or("") {
|
||||||
|
"no-entry" => ErrorCode::NoEntry,
|
||||||
|
"exist" => ErrorCode::Exist,
|
||||||
|
"not-directory" => ErrorCode::NotDirectory,
|
||||||
|
"is-directory" => ErrorCode::IsDirectory,
|
||||||
|
"not-empty" => ErrorCode::NotEmpty,
|
||||||
|
"access" => ErrorCode::Access,
|
||||||
|
"not-permitted" => ErrorCode::NotPermitted,
|
||||||
|
"invalid" => ErrorCode::Invalid,
|
||||||
|
"unsupported" => ErrorCode::Unsupported,
|
||||||
|
"busy" => ErrorCode::Busy,
|
||||||
|
_ => ErrorCode::Io,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content(v: &Value) -> Result<Vec<u8>, ErrorCode> {
|
||||||
|
if let Some(text) = v["text"].as_str() {
|
||||||
|
return Ok(text.as_bytes().to_vec());
|
||||||
|
}
|
||||||
|
match v["base64"].as_str() {
|
||||||
|
Some(b) => base64_decode(b).ok_or(ErrorCode::IllegalByteSequence),
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_body(f: &VFile) -> Value {
|
||||||
|
let data = f.data.lock().unwrap().clone();
|
||||||
|
match String::from_utf8(data) {
|
||||||
|
Ok(text) => json!({"type": "fs_write", "path": f.path, "text": text}),
|
||||||
|
Err(e) => json!({"type": "fs_write", "path": f.path, "base64": base64_encode(e.as_bytes())}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `rel` resolved against the folder `base`, as an absolute path.
|
||||||
|
fn join(base: &str, rel: &str) -> String {
|
||||||
|
let mut parts: Vec<&str> = base.split('/').filter(|p| !p.is_empty()).collect();
|
||||||
|
for p in rel.split('/') {
|
||||||
|
match p {
|
||||||
|
"" | "." => {}
|
||||||
|
".." => {
|
||||||
|
parts.pop();
|
||||||
|
}
|
||||||
|
p => parts.push(p),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!("/{}", parts.join("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stat(type_: DescriptorType, size: u64) -> types::DescriptorStat {
|
||||||
|
types::DescriptorStat {
|
||||||
|
type_,
|
||||||
|
link_count: 1,
|
||||||
|
size,
|
||||||
|
data_access_timestamp: None,
|
||||||
|
data_modification_timestamp: None,
|
||||||
|
status_change_timestamp: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_stat(node: &Node) -> types::DescriptorStat {
|
||||||
|
match node {
|
||||||
|
Node::File(data) => stat(DescriptorType::RegularFile, data.len() as u64),
|
||||||
|
Node::Dir(_) => stat(DescriptorType::Directory, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash(actor: &str, path: &str) -> types::MetadataHashValue {
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
(actor, path).hash(&mut h);
|
||||||
|
types::MetadataHashValue { lower: h.finish(), upper: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_at(data: &mut Vec<u8>, at: usize, bytes: &[u8]) {
|
||||||
|
if data.len() < at + bytes.len() {
|
||||||
|
data.resize(at + bytes.len(), 0);
|
||||||
|
}
|
||||||
|
data[at..at + bytes.len()].copy_from_slice(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An output stream into an actor-served file (`pos: None` appends).
|
||||||
|
struct VWriter {
|
||||||
|
file: VFile,
|
||||||
|
pos: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasmtime_wasi::async_trait]
|
||||||
|
impl Pollable for VWriter {
|
||||||
|
async fn ready(&mut self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasmtime_wasi::async_trait]
|
||||||
|
impl OutputStream for VWriter {
|
||||||
|
fn write(&mut self, bytes: Bytes) -> StreamResult<()> {
|
||||||
|
let mut data = self.file.data.lock().unwrap();
|
||||||
|
let at = self.pos.unwrap_or(data.len());
|
||||||
|
write_at(&mut data, at, &bytes);
|
||||||
|
if let Some(p) = &mut self.pos {
|
||||||
|
*p += bytes.len();
|
||||||
|
}
|
||||||
|
self.file.dirty.store(true, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn flush(&mut self) -> StreamResult<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn check_write(&mut self) -> StreamResult<usize> {
|
||||||
|
Ok(64 * 1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VfsView<'_> {
|
||||||
|
fn asker(&self) -> Asker {
|
||||||
|
Asker { broker: self.state.broker.upgrade(), me: self.state.me.clone() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn virt(&mut self, fd: &Resource<types::Descriptor>) -> Option<Virt> {
|
||||||
|
self.inner.table.get_any_mut(fd.rep()).ok()?.downcast_ref::<Virt>().cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file(&mut self, fd: &Resource<types::Descriptor>) -> Option<FsResult<VFile>> {
|
||||||
|
match self.virt(fd)? {
|
||||||
|
Virt::File(f) => Some(Ok(f)),
|
||||||
|
Virt::Dir { .. } => Some(Err(ErrorCode::IsDirectory.into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The actor and its path, if `path` relative to `fd` is actor-served.
|
||||||
|
fn target(&mut self, fd: &Resource<types::Descriptor>, path: &str) -> Option<(String, String)> {
|
||||||
|
match self.virt(fd) {
|
||||||
|
Some(Virt::Dir { actor, path: base }) => Some((actor, join(&base, path))),
|
||||||
|
Some(Virt::File(f)) => Some((f.actor, join(&f.path, path))),
|
||||||
|
None if self.state.roots.contains(&fd.rep()) => {
|
||||||
|
let full = join("/", path);
|
||||||
|
let mut parts = full.splitn(4, '/').skip(1);
|
||||||
|
let (actor, fs, rest) = (parts.next()?, parts.next()?, parts.next().unwrap_or(""));
|
||||||
|
(fs == "fs" && !actor.is_empty()).then(|| (actor.to_string(), format!("/{rest}")))
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, v: Virt) -> FsResult<Resource<types::Descriptor>> {
|
||||||
|
let r = self.inner.table.push(v)?;
|
||||||
|
Ok(Resource::new_own(r.rep()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open(&mut self, actor: String, path: String, oflags: OpenFlags, flags: DescriptorFlags) -> FsResult<Resource<types::Descriptor>> {
|
||||||
|
let write = flags.contains(DescriptorFlags::WRITE);
|
||||||
|
let create = oflags.contains(OpenFlags::CREATE);
|
||||||
|
let exclusive = create && oflags.contains(OpenFlags::EXCLUSIVE);
|
||||||
|
let virt = match self.asker().read(&actor, &path).await {
|
||||||
|
Ok(_) if exclusive => return Err(ErrorCode::Exist.into()),
|
||||||
|
Ok(Node::Dir(_)) if write => return Err(ErrorCode::IsDirectory.into()),
|
||||||
|
Ok(Node::Dir(_)) => Virt::Dir { actor, path },
|
||||||
|
Ok(Node::File(_)) if oflags.contains(OpenFlags::DIRECTORY) => return Err(ErrorCode::NotDirectory.into()),
|
||||||
|
Ok(Node::File(data)) => {
|
||||||
|
let truncate = oflags.contains(OpenFlags::TRUNCATE);
|
||||||
|
let data = if truncate { Vec::new() } else { data };
|
||||||
|
Virt::File(VFile { actor, path, data: Arc::new(Mutex::new(data)), dirty: Arc::new(AtomicBool::new(truncate)), write })
|
||||||
|
}
|
||||||
|
Err(ErrorCode::NoEntry) if create && !oflags.contains(OpenFlags::DIRECTORY) => Virt::File(VFile {
|
||||||
|
actor,
|
||||||
|
path,
|
||||||
|
data: Arc::default(),
|
||||||
|
dirty: Arc::new(AtomicBool::new(true)),
|
||||||
|
write,
|
||||||
|
}),
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
self.push(virt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send the changed file without waiting (the descriptor is gone). The
|
||||||
|
/// request leaves now, so later requests of this instance come after it.
|
||||||
|
fn write_back_later(&self, f: &VFile) {
|
||||||
|
let Some(broker) = self.state.broker.upgrade() else { return };
|
||||||
|
let reply = broker.ask(&f.actor, write_body(f));
|
||||||
|
let (me, what) = (self.state.me.clone(), format!("fs_write {} to {}", f.path, f.actor));
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match reply.await {
|
||||||
|
Some(r) if r["type"] != "fs_error" && r["type"] != "undeliverable" => {}
|
||||||
|
r => broker.log(&me, &format!("{what} failed: {}", r.map_or("no reply".to_string(), |r| r.to_string()))),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl preopens::Host for VfsView<'_> {
|
||||||
|
fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<types::Descriptor>, String)>> {
|
||||||
|
let dirs = preopens::Host::get_directories(&mut self.inner)?;
|
||||||
|
for (fd, name) in &dirs {
|
||||||
|
if name == "/actor" {
|
||||||
|
self.state.roots.insert(fd.rep());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(dirs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl types::Host for VfsView<'_> {
|
||||||
|
fn convert_error_code(&mut self, err: FsError) -> wasmtime::Result<ErrorCode> {
|
||||||
|
types::Host::convert_error_code(&mut self.inner, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filesystem_error_code(&mut self, err: Resource<wasmtime::Error>) -> wasmtime::Result<Option<ErrorCode>> {
|
||||||
|
types::Host::filesystem_error_code(&mut self.inner, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostDescriptor for VfsView<'_> {
|
||||||
|
async fn advise(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize, len: types::Filesize, advice: types::Advice) -> FsResult<()> {
|
||||||
|
if self.virt(&fd).is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
HostDescriptor::advise(&mut self.inner, fd, offset, len, advice).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_data(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
|
||||||
|
match self.virt(&fd) {
|
||||||
|
Some(Virt::File(f)) => self.asker().flush(&f).await,
|
||||||
|
Some(Virt::Dir { .. }) => Ok(()),
|
||||||
|
None => HostDescriptor::sync_data(&mut self.inner, fd).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_flags(&mut self, fd: Resource<types::Descriptor>) -> FsResult<DescriptorFlags> {
|
||||||
|
match self.virt(&fd) {
|
||||||
|
Some(Virt::File(f)) if f.write => Ok(DescriptorFlags::READ | DescriptorFlags::WRITE),
|
||||||
|
Some(Virt::File(_)) => Ok(DescriptorFlags::READ),
|
||||||
|
Some(Virt::Dir { .. }) => Ok(DescriptorFlags::READ | DescriptorFlags::MUTATE_DIRECTORY),
|
||||||
|
None => HostDescriptor::get_flags(&mut self.inner, fd).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_type(&mut self, fd: Resource<types::Descriptor>) -> FsResult<DescriptorType> {
|
||||||
|
match self.virt(&fd) {
|
||||||
|
Some(Virt::File(_)) => Ok(DescriptorType::RegularFile),
|
||||||
|
Some(Virt::Dir { .. }) => Ok(DescriptorType::Directory),
|
||||||
|
None => HostDescriptor::get_type(&mut self.inner, fd).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_size(&mut self, fd: Resource<types::Descriptor>, size: types::Filesize) -> FsResult<()> {
|
||||||
|
match self.file(&fd) {
|
||||||
|
Some(f) => {
|
||||||
|
let f = f?;
|
||||||
|
if !f.write {
|
||||||
|
return Err(ErrorCode::NotPermitted.into());
|
||||||
|
}
|
||||||
|
f.data.lock().unwrap().resize(size as usize, 0);
|
||||||
|
f.dirty.store(true, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => HostDescriptor::set_size(&mut self.inner, fd, size).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_times(&mut self, fd: Resource<types::Descriptor>, atim: types::NewTimestamp, mtim: types::NewTimestamp) -> FsResult<()> {
|
||||||
|
if self.virt(&fd).is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
HostDescriptor::set_times(&mut self.inner, fd, atim, mtim).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read(&mut self, fd: Resource<types::Descriptor>, len: types::Filesize, offset: types::Filesize) -> FsResult<(Vec<u8>, bool)> {
|
||||||
|
match self.file(&fd) {
|
||||||
|
Some(f) => {
|
||||||
|
let f = f?;
|
||||||
|
let data = f.data.lock().unwrap();
|
||||||
|
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(data.len());
|
||||||
|
let end = start.saturating_add(usize::try_from(len).unwrap_or(usize::MAX)).min(data.len());
|
||||||
|
Ok((data[start..end].to_vec(), start == end))
|
||||||
|
}
|
||||||
|
None => HostDescriptor::read(&mut self.inner, fd, len, offset).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write(&mut self, fd: Resource<types::Descriptor>, buf: Vec<u8>, offset: types::Filesize) -> FsResult<types::Filesize> {
|
||||||
|
match self.file(&fd) {
|
||||||
|
Some(f) => {
|
||||||
|
let f = f?;
|
||||||
|
if !f.write {
|
||||||
|
return Err(ErrorCode::NotPermitted.into());
|
||||||
|
}
|
||||||
|
write_at(&mut f.data.lock().unwrap(), offset as usize, &buf);
|
||||||
|
f.dirty.store(true, Ordering::SeqCst);
|
||||||
|
Ok(buf.len() as u64)
|
||||||
|
}
|
||||||
|
None => HostDescriptor::write(&mut self.inner, fd, buf, offset).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_directory(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Resource<types::DirectoryEntryStream>> {
|
||||||
|
match self.virt(&fd) {
|
||||||
|
Some(Virt::Dir { actor, path }) => match self.asker().read(&actor, &path).await? {
|
||||||
|
Node::Dir(entries) => {
|
||||||
|
let r = self.inner.table.push(VDir(entries.into_iter()))?;
|
||||||
|
Ok(Resource::new_own(r.rep()))
|
||||||
|
}
|
||||||
|
Node::File(_) => Err(ErrorCode::NotDirectory.into()),
|
||||||
|
},
|
||||||
|
Some(Virt::File(_)) => Err(ErrorCode::NotDirectory.into()),
|
||||||
|
None => HostDescriptor::read_directory(&mut self.inner, fd).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
|
||||||
|
match self.virt(&fd) {
|
||||||
|
Some(Virt::File(f)) => self.asker().flush(&f).await,
|
||||||
|
Some(Virt::Dir { .. }) => Ok(()),
|
||||||
|
None => HostDescriptor::sync(&mut self.inner, fd).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
||||||
|
match self.target(&fd, &path) {
|
||||||
|
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_mkdir", "path": path})).await,
|
||||||
|
None => HostDescriptor::create_directory_at(&mut self.inner, fd, path).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stat(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorStat> {
|
||||||
|
match self.virt(&fd) {
|
||||||
|
Some(Virt::File(f)) => Ok(stat(DescriptorType::RegularFile, f.data.lock().unwrap().len() as u64)),
|
||||||
|
Some(Virt::Dir { .. }) => Ok(stat(DescriptorType::Directory, 0)),
|
||||||
|
None => HostDescriptor::stat(&mut self.inner, fd).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stat_at(&mut self, fd: Resource<types::Descriptor>, path_flags: types::PathFlags, path: String) -> FsResult<types::DescriptorStat> {
|
||||||
|
match self.target(&fd, &path) {
|
||||||
|
Some((actor, path)) => Ok(node_stat(&self.asker().read(&actor, &path).await?)),
|
||||||
|
None => HostDescriptor::stat_at(&mut self.inner, fd, path_flags, path).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_times_at(
|
||||||
|
&mut self,
|
||||||
|
fd: Resource<types::Descriptor>,
|
||||||
|
path_flags: types::PathFlags,
|
||||||
|
path: String,
|
||||||
|
atim: types::NewTimestamp,
|
||||||
|
mtim: types::NewTimestamp,
|
||||||
|
) -> FsResult<()> {
|
||||||
|
if self.target(&fd, &path).is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
HostDescriptor::set_times_at(&mut self.inner, fd, path_flags, path, atim, mtim).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn link_at(
|
||||||
|
&mut self,
|
||||||
|
fd: Resource<types::Descriptor>,
|
||||||
|
old_path_flags: types::PathFlags,
|
||||||
|
old_path: String,
|
||||||
|
new_descriptor: Resource<types::Descriptor>,
|
||||||
|
new_path: String,
|
||||||
|
) -> FsResult<()> {
|
||||||
|
if self.target(&fd, &old_path).is_some() || self.target(&new_descriptor, &new_path).is_some() {
|
||||||
|
return Err(ErrorCode::Unsupported.into());
|
||||||
|
}
|
||||||
|
HostDescriptor::link_at(&mut self.inner, fd, old_path_flags, old_path, new_descriptor, new_path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_at(
|
||||||
|
&mut self,
|
||||||
|
fd: Resource<types::Descriptor>,
|
||||||
|
path_flags: types::PathFlags,
|
||||||
|
path: String,
|
||||||
|
oflags: OpenFlags,
|
||||||
|
flags: DescriptorFlags,
|
||||||
|
) -> FsResult<Resource<types::Descriptor>> {
|
||||||
|
match self.target(&fd, &path) {
|
||||||
|
Some((actor, path)) => self.open(actor, path, oflags, flags).await,
|
||||||
|
None => HostDescriptor::open_at(&mut self.inner, fd, path_flags, path, oflags, flags).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drop(&mut self, fd: Resource<types::Descriptor>) -> wasmtime::Result<()> {
|
||||||
|
if self.virt(&fd).is_some() {
|
||||||
|
if let Virt::File(f) = self.inner.table.delete(Resource::<Virt>::new_own(fd.rep()))?
|
||||||
|
&& f.dirty.load(Ordering::SeqCst)
|
||||||
|
{
|
||||||
|
self.write_back_later(&f);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.state.roots.remove(&fd.rep());
|
||||||
|
HostDescriptor::drop(&mut self.inner, fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn readlink_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<String> {
|
||||||
|
if self.target(&fd, &path).is_some() {
|
||||||
|
return Err(ErrorCode::Invalid.into());
|
||||||
|
}
|
||||||
|
HostDescriptor::readlink_at(&mut self.inner, fd, path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
||||||
|
match self.target(&fd, &path) {
|
||||||
|
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_remove", "path": path})).await,
|
||||||
|
None => HostDescriptor::remove_directory_at(&mut self.inner, fd, path).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename_at(&mut self, fd: Resource<types::Descriptor>, old_path: String, new_fd: Resource<types::Descriptor>, new_path: String) -> FsResult<()> {
|
||||||
|
match (self.target(&fd, &old_path), self.target(&new_fd, &new_path)) {
|
||||||
|
(None, None) => HostDescriptor::rename_at(&mut self.inner, fd, old_path, new_fd, new_path).await,
|
||||||
|
(Some((a, from)), Some((b, to))) if a == b => {
|
||||||
|
self.asker().change(&a, json!({"type": "fs_rename", "path": from, "to": to})).await
|
||||||
|
}
|
||||||
|
_ => Err(ErrorCode::Unsupported.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn symlink_at(&mut self, fd: Resource<types::Descriptor>, src_path: String, dest_path: String) -> FsResult<()> {
|
||||||
|
if self.target(&fd, &dest_path).is_some() {
|
||||||
|
return Err(ErrorCode::Unsupported.into());
|
||||||
|
}
|
||||||
|
HostDescriptor::symlink_at(&mut self.inner, fd, src_path, dest_path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unlink_file_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
||||||
|
match self.target(&fd, &path) {
|
||||||
|
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_remove", "path": path})).await,
|
||||||
|
None => HostDescriptor::unlink_file_at(&mut self.inner, fd, path).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_via_stream(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize) -> FsResult<Resource<DynInputStream>> {
|
||||||
|
match self.file(&fd) {
|
||||||
|
Some(f) => {
|
||||||
|
let f = f?;
|
||||||
|
let data = f.data.lock().unwrap();
|
||||||
|
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(data.len());
|
||||||
|
let stream: DynInputStream = Box::new(MemoryInputPipe::new(data[start..].to_vec()));
|
||||||
|
std::mem::drop(data);
|
||||||
|
Ok(self.inner.table.push(stream)?)
|
||||||
|
}
|
||||||
|
None => HostDescriptor::read_via_stream(&mut self.inner, fd, offset),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_via_stream(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize) -> FsResult<Resource<DynOutputStream>> {
|
||||||
|
match self.file(&fd) {
|
||||||
|
Some(f) => {
|
||||||
|
let f = f?;
|
||||||
|
if !f.write {
|
||||||
|
return Err(ErrorCode::NotPermitted.into());
|
||||||
|
}
|
||||||
|
let stream: DynOutputStream = Box::new(VWriter { file: f, pos: Some(offset as usize) });
|
||||||
|
Ok(self.inner.table.push(stream)?)
|
||||||
|
}
|
||||||
|
None => HostDescriptor::write_via_stream(&mut self.inner, fd, offset),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_via_stream(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Resource<DynOutputStream>> {
|
||||||
|
match self.file(&fd) {
|
||||||
|
Some(f) => {
|
||||||
|
let f = f?;
|
||||||
|
if !f.write {
|
||||||
|
return Err(ErrorCode::NotPermitted.into());
|
||||||
|
}
|
||||||
|
let stream: DynOutputStream = Box::new(VWriter { file: f, pos: None });
|
||||||
|
Ok(self.inner.table.push(stream)?)
|
||||||
|
}
|
||||||
|
None => HostDescriptor::append_via_stream(&mut self.inner, fd),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_same_object(&mut self, a: Resource<types::Descriptor>, b: Resource<types::Descriptor>) -> wasmtime::Result<bool> {
|
||||||
|
let key = |v: Virt| match v {
|
||||||
|
Virt::Dir { actor, path } => (actor, path),
|
||||||
|
Virt::File(f) => (f.actor, f.path),
|
||||||
|
};
|
||||||
|
match (self.virt(&a), self.virt(&b)) {
|
||||||
|
(None, None) => HostDescriptor::is_same_object(&mut self.inner, a, b).await,
|
||||||
|
(Some(x), Some(y)) => Ok(key(x) == key(y)),
|
||||||
|
_ => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn metadata_hash(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::MetadataHashValue> {
|
||||||
|
match self.virt(&fd) {
|
||||||
|
Some(Virt::Dir { actor, path }) => Ok(hash(&actor, &path)),
|
||||||
|
Some(Virt::File(f)) => Ok(hash(&f.actor, &f.path)),
|
||||||
|
None => HostDescriptor::metadata_hash(&mut self.inner, fd).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn metadata_hash_at(&mut self, fd: Resource<types::Descriptor>, path_flags: types::PathFlags, path: String) -> FsResult<types::MetadataHashValue> {
|
||||||
|
match self.target(&fd, &path) {
|
||||||
|
Some((actor, path)) => Ok(hash(&actor, &path)),
|
||||||
|
None => HostDescriptor::metadata_hash_at(&mut self.inner, fd, path_flags, path).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostDirectoryEntryStream for VfsView<'_> {
|
||||||
|
async fn read_directory_entry(&mut self, stream: Resource<types::DirectoryEntryStream>) -> FsResult<Option<DirectoryEntry>> {
|
||||||
|
if let Some(dir) = self.inner.table.get_any_mut(stream.rep()).ok().and_then(|a| a.downcast_mut::<VDir>()) {
|
||||||
|
return Ok(dir.0.next());
|
||||||
|
}
|
||||||
|
HostDirectoryEntryStream::read_directory_entry(&mut self.inner, stream).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drop(&mut self, stream: Resource<types::DirectoryEntryStream>) -> wasmtime::Result<()> {
|
||||||
|
let ours = self.inner.table.get_any_mut(stream.rep()).ok().is_some_and(|a| a.is::<VDir>());
|
||||||
|
if ours {
|
||||||
|
self.inner.table.delete(Resource::<VDir>::new_own(stream.rep()))?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
HostDirectoryEntryStream::drop(&mut self.inner, stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
fn base64_encode(bytes: &[u8]) -> String {
|
||||||
|
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||||
|
for chunk in bytes.chunks(3) {
|
||||||
|
let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | (*b as u32) << (16 - 8 * i));
|
||||||
|
for i in 0..4 {
|
||||||
|
out.push(if i <= chunk.len() { B64[(n >> (18 - 6 * i) & 63) as usize] as char } else { '=' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base64_decode(text: &str) -> Option<Vec<u8>> {
|
||||||
|
let (mut out, mut n, mut bits) = (Vec::new(), 0u32, 0);
|
||||||
|
for c in text.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
|
||||||
|
n = (n << 6 | B64.iter().position(|b| *b == c)? as u32) & 0xFF_FFFF;
|
||||||
|
bits += 6;
|
||||||
|
if bits >= 8 {
|
||||||
|
bits -= 8;
|
||||||
|
out.push((n >> bits) as u8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base64_round_trip() {
|
||||||
|
for s in ["", "a", "ab", "abc", "abcd", "\u{0}\u{ff}binary"] {
|
||||||
|
let enc = base64_encode(s.as_bytes());
|
||||||
|
assert_eq!(base64_decode(&enc).unwrap(), s.as_bytes());
|
||||||
|
}
|
||||||
|
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_resolves_dots() {
|
||||||
|
assert_eq!(join("/a/b", "../c/./d"), "/a/c/d");
|
||||||
|
assert_eq!(join("/", "x/fs"), "/x/fs");
|
||||||
|
assert_eq!(join("/", ".."), "/");
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
-70
@@ -1,47 +1,58 @@
|
|||||||
//! On-disk layout of a Sausage workspace ("world").
|
//! On-disk layout of a Sausage world. The folder is the source of truth:
|
||||||
|
//! `sausage run` boots every actor that it finds in `actor/`.
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! <world>/
|
//! <world>/
|
||||||
//! sausage.json registry: actors, versions, subscriptions
|
//! actor/<id>/
|
||||||
//! actor/<id>/public/ written by the actor, read-only for others
|
//! actor.wasm the current version
|
||||||
//! actor/<id>/source/ source of the running version (broker)
|
//! actor.json version, running, subscriptions, tapped (broker)
|
||||||
//! actor/<id>/status.json state of the actor (broker)
|
//! source/ source of the current version
|
||||||
//! actor/<id>/stderr.log stderr of the actor (broker)
|
//! public/ written by the actor, read-only for others
|
||||||
//! private/<id>/data/ private state of the actor
|
//! data/ state, writable only by the actor
|
||||||
//! broker/versions/<id>/<n>/ actor.wasm, source/, meta.json, snapshot/
|
//! stderr.log stderr of the actor and notes of the broker
|
||||||
//! archive/ removed actors
|
//! versions/<n>/ every version that was installed: actor.wasm, source/, meta.json
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::BTreeSet;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::fs::TMP_PREFIX;
|
/// Name prefix of the host's temporary files and folders. Copies skip them.
|
||||||
|
pub const TMP_PREFIX: &str = ".sausage-tmp-";
|
||||||
|
|
||||||
/// IDs of the actors that the host implements natively.
|
/// IDs of the actors that the host implements natively. `fs` sends the
|
||||||
pub const NATIVE: &[&str] = &["broker", "console", "cargo"];
|
/// requests for actor-served files (`vfs.rs`).
|
||||||
|
pub const NATIVE: &[&str] = &["broker", "console", "cargo", "fs"];
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct World {
|
pub struct World {
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize, Clone)]
|
/// `actor/<id>/actor.json`.
|
||||||
pub struct Registry {
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||||
pub actors: BTreeMap<String, ActorRecord>,
|
pub struct ActorMeta {
|
||||||
/// Publisher ID → subscriber IDs.
|
/// The installed version. `None` for native actors and for a new
|
||||||
pub subscriptions: BTreeMap<String, BTreeSet<String>>,
|
/// `actor.wasm` that the broker has not installed yet.
|
||||||
}
|
#[serde(default)]
|
||||||
|
pub version: Option<u64>,
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[serde(default = "yes")]
|
||||||
pub struct ActorRecord {
|
|
||||||
pub version: u64,
|
|
||||||
pub next_version: u64,
|
|
||||||
pub running: bool,
|
pub running: bool,
|
||||||
|
/// The actors whose published messages this actor receives.
|
||||||
|
#[serde(default)]
|
||||||
|
pub subscriptions: BTreeSet<String>,
|
||||||
|
/// Whether this actor gets a copy of every routed message (`tap`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub tapped: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn yes() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `versions/<n>/meta.json`.
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||||
pub struct VersionMeta {
|
pub struct VersionMeta {
|
||||||
pub version: u64,
|
pub version: u64,
|
||||||
@@ -51,7 +62,7 @@ pub struct VersionMeta {
|
|||||||
pub source: Option<String>,
|
pub source: Option<String>,
|
||||||
/// The actor that built the `.wasm` file.
|
/// The actor that built the `.wasm` file.
|
||||||
pub built_by: Option<String>,
|
pub built_by: Option<String>,
|
||||||
/// The actor that requested the spawn or upgrade.
|
/// The actor that requested the version ("scan" for a file found on disk).
|
||||||
pub requested_by: String,
|
pub requested_by: String,
|
||||||
pub created_at: u64,
|
pub created_at: u64,
|
||||||
}
|
}
|
||||||
@@ -74,15 +85,15 @@ impl World {
|
|||||||
Self { root: root.into() }
|
Self { root: root.into() }
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
pub fn actors_root(&self) -> PathBuf {
|
||||||
self.root.join("actor")
|
self.root.join("actor")
|
||||||
}
|
}
|
||||||
|
pub fn actor_dir(&self, id: &str) -> PathBuf {
|
||||||
|
self.actors_root().join(id)
|
||||||
|
}
|
||||||
|
pub fn wasm_path(&self, id: &str) -> PathBuf {
|
||||||
|
self.actor_dir(id).join("actor.wasm")
|
||||||
|
}
|
||||||
pub fn public_dir(&self, id: &str) -> PathBuf {
|
pub fn public_dir(&self, id: &str) -> PathBuf {
|
||||||
self.actor_dir(id).join("public")
|
self.actor_dir(id).join("public")
|
||||||
}
|
}
|
||||||
@@ -90,37 +101,88 @@ impl World {
|
|||||||
self.actor_dir(id).join("source")
|
self.actor_dir(id).join("source")
|
||||||
}
|
}
|
||||||
pub fn data_dir(&self, id: &str) -> PathBuf {
|
pub fn data_dir(&self, id: &str) -> PathBuf {
|
||||||
self.root.join("private").join(id).join("data")
|
self.actor_dir(id).join("data")
|
||||||
}
|
}
|
||||||
pub fn versions_dir(&self, id: &str) -> PathBuf {
|
pub fn versions_dir(&self, id: &str) -> PathBuf {
|
||||||
self.root.join("broker").join("versions").join(id)
|
self.actor_dir(id).join("versions")
|
||||||
}
|
}
|
||||||
pub fn version_dir(&self, id: &str, v: u64) -> PathBuf {
|
pub fn version_dir(&self, id: &str, v: u64) -> PathBuf {
|
||||||
self.versions_dir(id).join(v.to_string())
|
self.versions_dir(id).join(v.to_string())
|
||||||
}
|
}
|
||||||
|
/// Private scratch space of the broker (candidate copies, seeds).
|
||||||
pub fn scratch_dir(&self) -> PathBuf {
|
pub fn scratch_dir(&self) -> PathBuf {
|
||||||
self.root.join("broker").join("scratch")
|
self.data_dir("broker").join("scratch")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn exists(&self) -> bool {
|
pub fn exists(&self) -> bool {
|
||||||
self.registry_path().is_file()
|
self.actors_root().is_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an empty world with the folders of the native actors.
|
||||||
|
pub fn create(root: &Path) -> Result<World> {
|
||||||
|
let world = World::new(std::path::absolute(root)?);
|
||||||
|
if world.exists() {
|
||||||
|
bail!("{} is already a Sausage world", root.display());
|
||||||
|
}
|
||||||
|
for id in NATIVE {
|
||||||
|
world.ensure_actor_dirs(id)?;
|
||||||
|
}
|
||||||
|
Ok(world)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create the directories of one actor.
|
|
||||||
pub fn ensure_actor_dirs(&self, id: &str) -> Result<()> {
|
pub fn ensure_actor_dirs(&self, id: &str) -> Result<()> {
|
||||||
std::fs::create_dir_all(self.public_dir(id))?;
|
std::fs::create_dir_all(self.public_dir(id))?;
|
||||||
std::fs::create_dir_all(self.data_dir(id))?;
|
std::fs::create_dir_all(self.data_dir(id))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_registry(&self) -> Result<Registry> {
|
/// IDs of every folder in `actor/`, sorted.
|
||||||
let text = std::fs::read_to_string(self.registry_path())
|
pub fn actor_ids(&self) -> Result<Vec<String>> {
|
||||||
.with_context(|| format!("no Sausage world at {}", self.root.display()))?;
|
let mut ids: Vec<String> = std::fs::read_dir(self.actors_root())?
|
||||||
Ok(serde_json::from_str(&text)?)
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.path().is_dir())
|
||||||
|
.filter_map(|e| e.file_name().into_string().ok())
|
||||||
|
.filter(|id| valid_id(id))
|
||||||
|
.collect();
|
||||||
|
ids.sort();
|
||||||
|
Ok(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_registry(&self, reg: &Registry) -> Result<()> {
|
pub fn read_meta(&self, id: &str) -> ActorMeta {
|
||||||
write_atomic(&self.registry_path(), serde_json::to_string_pretty(reg)?.as_bytes())
|
std::fs::read_to_string(self.actor_dir(id).join("actor.json"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or(ActorMeta { running: true, ..Default::default() })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_meta(&self, id: &str, meta: &ActorMeta) -> Result<()> {
|
||||||
|
write_atomic(&self.actor_dir(id).join("actor.json"), serde_json::to_string_pretty(meta)?.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make `actor.wasm` and `source/` a copy of `versions/<version>/`,
|
||||||
|
/// unless `actor.wasm` already is that version (it came from there).
|
||||||
|
pub fn install_files(&self, id: &str, version: u64) -> Result<()> {
|
||||||
|
let dir = self.version_dir(id, version);
|
||||||
|
self.ensure_actor_dirs(id)?;
|
||||||
|
let wasm = std::fs::read(dir.join("actor.wasm"))?;
|
||||||
|
if std::fs::read(self.wasm_path(id)).ok().as_ref() == Some(&wasm) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
write_atomic(&self.wasm_path(id), &wasm)?;
|
||||||
|
replace_tree(&dir.join("source"), &self.source_dir(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version numbers in `versions/`, ascending.
|
||||||
|
pub fn versions(&self, id: &str) -> Vec<u64> {
|
||||||
|
let mut v: Vec<u64> = std::fs::read_dir(self.versions_dir(id))
|
||||||
|
.map(|rd| rd.filter_map(|e| e.ok()?.file_name().to_str()?.parse().ok()).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
v.sort();
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_version(&self, id: &str) -> u64 {
|
||||||
|
self.versions(id).last().map_or(1, |v| v + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a path in an actor's namespace to a host path.
|
/// Map a path in an actor's namespace to a host path.
|
||||||
@@ -129,25 +191,28 @@ impl World {
|
|||||||
/// - `/public/...` and `/data/...` belong to `sender`.
|
/// - `/public/...` and `/data/...` belong to `sender`.
|
||||||
/// - Native senders (the console) can also give absolute host paths.
|
/// - Native senders (the console) can also give absolute host paths.
|
||||||
pub fn resolve(&self, sender: &str, path: &str) -> Result<PathBuf> {
|
pub fn resolve(&self, sender: &str, path: &str) -> Result<PathBuf> {
|
||||||
let clean = |base: PathBuf, rest: &str| -> Result<PathBuf> {
|
let under = |base: PathBuf, rest: &str| -> Result<PathBuf> {
|
||||||
let mut out = base.clone();
|
let mut out = base;
|
||||||
for c in Path::new(rest).components() {
|
for c in Path::new(rest).components() {
|
||||||
match c {
|
match c {
|
||||||
std::path::Component::Normal(n) => out.push(n),
|
Component::Normal(n) => out.push(n),
|
||||||
std::path::Component::CurDir | std::path::Component::RootDir => {}
|
Component::CurDir | Component::RootDir => {}
|
||||||
_ => bail!("path must not contain '..': {path}"),
|
_ => bail!("path must not contain '..': {path}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
};
|
};
|
||||||
if let Some(rest) = path.strip_prefix("/actor/") {
|
let prefixed = |prefix: &str| {
|
||||||
return clean(self.actors_root(), rest);
|
path.strip_prefix(prefix).filter(|rest| rest.is_empty() || rest.starts_with('/'))
|
||||||
|
};
|
||||||
|
if let Some(rest) = prefixed("/actor") {
|
||||||
|
return under(self.actors_root(), rest);
|
||||||
}
|
}
|
||||||
if let Some(rest) = path.strip_prefix("/public/").or(path.strip_prefix("/public").filter(|r| r.is_empty())) {
|
if let Some(rest) = prefixed("/public") {
|
||||||
return clean(self.public_dir(sender), rest);
|
return under(self.public_dir(sender), rest);
|
||||||
}
|
}
|
||||||
if let Some(rest) = path.strip_prefix("/data/").or(path.strip_prefix("/data").filter(|r| r.is_empty())) {
|
if let Some(rest) = prefixed("/data") {
|
||||||
return clean(self.data_dir(sender), rest);
|
return under(self.data_dir(sender), rest);
|
||||||
}
|
}
|
||||||
if NATIVE.contains(&sender) && Path::new(path).is_absolute() {
|
if NATIVE.contains(&sender) && Path::new(path).is_absolute() {
|
||||||
return Ok(PathBuf::from(path));
|
return Ok(PathBuf::from(path));
|
||||||
@@ -166,33 +231,34 @@ impl World {
|
|||||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||||
let dir = path.parent().context("path has no parent")?;
|
let dir = path.parent().context("path has no parent")?;
|
||||||
std::fs::create_dir_all(dir)?;
|
std::fs::create_dir_all(dir)?;
|
||||||
let tmp = dir.join(format!(
|
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
|
||||||
"{TMP_PREFIX}host-{}-{}",
|
let tmp = dir.join(format!("{TMP_PREFIX}host-{}-{name}", std::process::id()));
|
||||||
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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy a directory tree. Skips `target/` folders and uncommitted files.
|
/// Folders that a copy of a source folder leaves out.
|
||||||
|
const BUILD_DIRS: &[&str] = &["target", ".git"];
|
||||||
|
|
||||||
|
/// Copy a directory tree, except uncommitted files.
|
||||||
pub fn copy_tree(from: &Path, to: &Path) -> Result<()> {
|
pub fn copy_tree(from: &Path, to: &Path) -> Result<()> {
|
||||||
|
copy_filtered(from, to, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_filtered(from: &Path, to: &Path, skip: &[&str]) -> Result<()> {
|
||||||
std::fs::create_dir_all(to)?;
|
std::fs::create_dir_all(to)?;
|
||||||
for entry in std::fs::read_dir(from).with_context(|| format!("read {}", from.display()))? {
|
for entry in std::fs::read_dir(from).with_context(|| format!("read {}", from.display()))? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let name = entry.file_name();
|
let name = entry.file_name();
|
||||||
let name_str = name.to_string_lossy();
|
let name = name.to_string_lossy();
|
||||||
if name_str.starts_with(TMP_PREFIX) {
|
if name.starts_with(TMP_PREFIX) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let dst = to.join(&*name);
|
||||||
let ty = entry.file_type()?;
|
let ty = entry.file_type()?;
|
||||||
let dst = to.join(&name);
|
if ty.is_dir() && !skip.contains(&&*name) {
|
||||||
if ty.is_dir() {
|
copy_filtered(&entry.path(), &dst, skip)?;
|
||||||
if name_str == "target" || name_str == ".git" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
copy_tree(&entry.path(), &dst)?;
|
|
||||||
} else if ty.is_file() {
|
} else if ty.is_file() {
|
||||||
std::fs::copy(entry.path(), &dst)?;
|
std::fs::copy(entry.path(), &dst)?;
|
||||||
}
|
}
|
||||||
@@ -200,9 +266,18 @@ pub fn copy_tree(from: &Path, to: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the directory `dst` with a copy of `src`, as close to atomic as a
|
/// Replace the directory `dst` with a copy of `src` (an empty folder if `src`
|
||||||
/// directory can be: copy to a sibling, then swap with two renames.
|
/// does not exist): copy to a sibling, then swap with two renames.
|
||||||
pub fn replace_tree(src: &Path, dst: &Path) -> Result<()> {
|
pub fn replace_tree(src: &Path, dst: &Path) -> Result<()> {
|
||||||
|
replace_filtered(src, dst, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `replace_tree`, for a source folder: leaves out `target/` and `.git/`.
|
||||||
|
pub fn replace_source(src: &Path, dst: &Path) -> Result<()> {
|
||||||
|
replace_filtered(src, dst, BUILD_DIRS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_filtered(src: &Path, dst: &Path, skip: &[&str]) -> Result<()> {
|
||||||
let parent = dst.parent().context("path has no parent")?;
|
let parent = dst.parent().context("path has no parent")?;
|
||||||
let name = dst.file_name().and_then(|n| n.to_str()).unwrap_or("dir");
|
let name = dst.file_name().and_then(|n| n.to_str()).unwrap_or("dir");
|
||||||
let fresh = parent.join(format!("{TMP_PREFIX}new-{name}"));
|
let fresh = parent.join(format!("{TMP_PREFIX}new-{name}"));
|
||||||
@@ -210,7 +285,7 @@ pub fn replace_tree(src: &Path, dst: &Path) -> Result<()> {
|
|||||||
let _ = std::fs::remove_dir_all(&fresh);
|
let _ = std::fs::remove_dir_all(&fresh);
|
||||||
let _ = std::fs::remove_dir_all(&old);
|
let _ = std::fs::remove_dir_all(&old);
|
||||||
if src.is_dir() {
|
if src.is_dir() {
|
||||||
copy_tree(src, &fresh)?;
|
copy_filtered(src, &fresh, skip)?;
|
||||||
} else {
|
} else {
|
||||||
std::fs::create_dir_all(&fresh)?;
|
std::fs::create_dir_all(&fresh)?;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+105
@@ -0,0 +1,105 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.154"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toolbox"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "fstest"
|
name = "toolbox"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{self, BufRead, Write};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
fn send(out: &mut impl Write, message: Value) {
|
||||||
|
writeln!(out, "{message}").unwrap();
|
||||||
|
out.flush().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(out: &mut impl Write, message: &Value, body: Value) {
|
||||||
|
send(
|
||||||
|
out,
|
||||||
|
json!({"to": message["from"], "reply_to": message["id"], "body": body}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to `address`, send one line, and return the line that comes back.
|
||||||
|
fn tcp_line(address: &str, line: &str) -> io::Result<String> {
|
||||||
|
let mut stream = std::net::TcpStream::connect(address)?;
|
||||||
|
writeln!(stream, "{line}")?;
|
||||||
|
let mut answer = String::new();
|
||||||
|
io::BufReader::new(stream).read_line(&mut answer)?;
|
||||||
|
Ok(answer.trim_end().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn io_error(error: io::Error) -> Value {
|
||||||
|
json!({"type": "error", "error": error.to_string()})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let actor = std::env::var("SAUSAGE_ACTOR").unwrap_or_default();
|
||||||
|
let version = std::env::var("SAUSAGE_VERSION").unwrap_or_else(|_| "candidate".to_string());
|
||||||
|
let candidate = std::env::var_os("SAUSAGE_CANDIDATE").is_some();
|
||||||
|
if actor == "crashloop" && version == "2" && !candidate {
|
||||||
|
std::fs::write("/data/state", "bad-v2").unwrap();
|
||||||
|
panic!("version 2 crashes on startup");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut open = HashMap::<String, std::fs::File>::new();
|
||||||
|
// Files that this actor serves at /actor/<id>/fs/ (path -> text).
|
||||||
|
let mut served = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
let stdin = io::stdin();
|
||||||
|
let mut out = io::stdout().lock();
|
||||||
|
let mut emit_shutdown = false;
|
||||||
|
if actor == "spawn-race" && !candidate {
|
||||||
|
let stamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
std::fs::write(format!("/data/start-{stamp}"), "started").unwrap();
|
||||||
|
}
|
||||||
|
for line in stdin.lock().lines() {
|
||||||
|
let Ok(line) = line else { break };
|
||||||
|
let Ok(message) = serde_json::from_str::<Value>(&line) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let body = &message["body"];
|
||||||
|
let path = body["path"].as_str().unwrap_or_default().to_string();
|
||||||
|
let text = body["text"].as_str().unwrap_or_default();
|
||||||
|
let response = match body["type"].as_str() {
|
||||||
|
Some("shutdown") => {
|
||||||
|
if actor == "queue" {
|
||||||
|
let _ = std::fs::write("/data/shutdown", "stopping");
|
||||||
|
std::thread::sleep(Duration::from_millis(800));
|
||||||
|
}
|
||||||
|
if emit_shutdown {
|
||||||
|
send(
|
||||||
|
&mut out,
|
||||||
|
json!({"to": "console", "reply_to": message["id"], "body": {"type": "shutdown-reply"}}),
|
||||||
|
);
|
||||||
|
send(&mut out, json!({"body": {"type": "shutdown-publish"}}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some("echo") => json!({"type": "echo", "value": body["value"]}),
|
||||||
|
Some("fs_read") => {
|
||||||
|
let dir = if path == "/" { "/".to_string() } else { format!("{path}/") };
|
||||||
|
let mut names: Vec<String> = served
|
||||||
|
.keys()
|
||||||
|
.filter_map(|k| k.strip_prefix(&dir))
|
||||||
|
.map(|rest| match rest.split_once('/') {
|
||||||
|
Some((first, _)) => format!("{first}/"),
|
||||||
|
None => rest.to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
names.dedup();
|
||||||
|
if let Some(text) = served.get(&path) {
|
||||||
|
json!({"type": "fs_file", "text": text})
|
||||||
|
} else if path == "/" || !names.is_empty() {
|
||||||
|
let entries: Vec<Value> = names
|
||||||
|
.iter()
|
||||||
|
.map(|n| match n.strip_suffix('/') {
|
||||||
|
Some(d) => json!({"name": d, "type": "dir"}),
|
||||||
|
None => json!({"name": n, "type": "file"}),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
json!({"type": "fs_dir", "entries": entries})
|
||||||
|
} else {
|
||||||
|
json!({"type": "fs_error", "errno": "no-entry"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("fs_write") => {
|
||||||
|
served.insert(path.clone(), text.to_string());
|
||||||
|
json!({"type": "fs_done"})
|
||||||
|
}
|
||||||
|
Some("fs_remove") => match served.remove(&path) {
|
||||||
|
Some(_) => json!({"type": "fs_done"}),
|
||||||
|
None => json!({"type": "fs_error", "errno": "no-entry"}),
|
||||||
|
},
|
||||||
|
Some("publish") => {
|
||||||
|
send(
|
||||||
|
&mut out,
|
||||||
|
json!({"body": {"type": "published", "value": body["value"]}}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Some("version") => json!({"type": "version", "version": version}),
|
||||||
|
Some("ordered") => json!({"type": "ordered", "n": body["n"], "version": version}),
|
||||||
|
Some("write") => match std::fs::write(&path, text) {
|
||||||
|
Ok(()) => json!({"type": "ok"}),
|
||||||
|
Err(error) => io_error(error),
|
||||||
|
},
|
||||||
|
Some("mkdir") => match std::fs::create_dir_all(&path) {
|
||||||
|
Ok(()) => json!({"type": "ok"}),
|
||||||
|
Err(error) => io_error(error),
|
||||||
|
},
|
||||||
|
Some("rename") => match std::fs::rename(&path, body["to"].as_str().unwrap_or_default())
|
||||||
|
{
|
||||||
|
Ok(()) => json!({"type": "ok"}),
|
||||||
|
Err(error) => io_error(error),
|
||||||
|
},
|
||||||
|
Some("open") => {
|
||||||
|
let result = (|| -> io::Result<()> {
|
||||||
|
let mut file = std::fs::File::create(&path)?;
|
||||||
|
file.write_all(text.as_bytes())?;
|
||||||
|
open.insert(path.clone(), file);
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
match result {
|
||||||
|
Ok(()) => json!({"type": "opened"}),
|
||||||
|
Err(error) => io_error(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("close") => json!({"type": "closed", "closed": open.remove(&path).is_some()}),
|
||||||
|
Some("arm-shutdown") => {
|
||||||
|
emit_shutdown = true;
|
||||||
|
json!({"type": "armed"})
|
||||||
|
}
|
||||||
|
Some("ignore-stdin") => json!({"type": "ignoring"}),
|
||||||
|
Some("tcp") => match tcp_line(body["address"].as_str().unwrap_or_default(), text) {
|
||||||
|
Ok(line) => json!({"type": "tcp", "line": line}),
|
||||||
|
Err(error) => io_error(error),
|
||||||
|
},
|
||||||
|
Some("read") => match std::fs::read_to_string(&path) {
|
||||||
|
Ok(text) => json!({"type": "content", "text": text}),
|
||||||
|
Err(error) => io_error(error),
|
||||||
|
},
|
||||||
|
Some("list") => match std::fs::read_dir(&path) {
|
||||||
|
Ok(entries) => {
|
||||||
|
let mut names: Vec<String> = entries
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.map(|entry| entry.file_name().to_string_lossy().to_string())
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
json!({"type": "entries", "names": names})
|
||||||
|
}
|
||||||
|
Err(error) => io_error(error),
|
||||||
|
},
|
||||||
|
_ => json!({"type": "error", "error": "unknown message"}),
|
||||||
|
};
|
||||||
|
reply(&mut out, &message, response);
|
||||||
|
if body["type"] == "ignore-stdin" {
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(Duration::from_secs(60));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
//! Integration tests for the agent's inference and restart behavior.
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use common::{create_world, receive_message, receive_reply, request, source_with_test, start};
|
||||||
|
use sausage::{Envelope, Host, World};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::sync::{mpsc, oneshot};
|
||||||
|
use tokio::task::{JoinHandle, JoinSet};
|
||||||
|
|
||||||
|
const APIS: [Api; 2] = [Api::OpenAi, Api::Anthropic];
|
||||||
|
const TEST_TOKEN: &str = "fake-token";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Api {
|
||||||
|
OpenAi,
|
||||||
|
Anthropic,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Api {
|
||||||
|
fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::OpenAi => "openai-responses",
|
||||||
|
Self::Anthropic => "anthropic-messages",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::OpenAi => "/v1/responses",
|
||||||
|
Self::Anthropic => "/v1/messages",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_response(self, text: &str) -> Value {
|
||||||
|
match self {
|
||||||
|
Self::OpenAi => {
|
||||||
|
json!({"output": [{"type": "message", "content": [{"type": "output_text", "text": text}]}]})
|
||||||
|
}
|
||||||
|
Self::Anthropic => json!({"content": [{"type": "text", "text": text}]}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_response(self, id: &str, name: &str, arguments: &Value) -> Value {
|
||||||
|
match self {
|
||||||
|
Self::OpenAi => {
|
||||||
|
json!({"output": [{"type": "function_call", "call_id": id, "name": name, "arguments": arguments.to_string()}]})
|
||||||
|
}
|
||||||
|
Self::Anthropic => {
|
||||||
|
json!({"content": [{"type": "tool_use", "id": id, "name": name, "input": arguments}]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_request(self, request: &HttpRequest) {
|
||||||
|
assert_eq!(request.method, "POST");
|
||||||
|
assert_eq!(request.path, self.path());
|
||||||
|
assert_eq!(request.headers["content-type"], "application/json");
|
||||||
|
assert_eq!(request.body["model"], "fake-model");
|
||||||
|
match self {
|
||||||
|
Self::OpenAi => assert_eq!(request.headers["authorization"], "Bearer fake-token"),
|
||||||
|
Self::Anthropic => {
|
||||||
|
assert_eq!(request.headers["x-api-key"], TEST_TOKEN);
|
||||||
|
assert_eq!(request.headers["anthropic-version"], "2023-06-01");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HttpRequest {
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
headers: HashMap<String, String>,
|
||||||
|
body: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReplyPlan {
|
||||||
|
Send(Value),
|
||||||
|
Hold(Value, oneshot::Receiver<()>),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FakeServer {
|
||||||
|
base_url: String,
|
||||||
|
plans: mpsc::Sender<ReplyPlan>,
|
||||||
|
requests: mpsc::Receiver<HttpRequest>,
|
||||||
|
task: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeServer {
|
||||||
|
async fn start() -> Self {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let (plans, mut plan_rx) = mpsc::channel(16);
|
||||||
|
let (request_tx, requests) = mpsc::channel(16);
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let mut handlers = JoinSet::new();
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
accepted = listener.accept() => {
|
||||||
|
let (mut stream, _) = accepted.expect("fake listener accept failed");
|
||||||
|
let request = read_http_request(&mut stream).await;
|
||||||
|
let Some(plan) = plan_rx.recv().await else { break };
|
||||||
|
if request_tx.send(request).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
handlers.spawn(async move {
|
||||||
|
let (body, release) = match plan {
|
||||||
|
ReplyPlan::Send(body) => (body, None),
|
||||||
|
ReplyPlan::Hold(body, release) => (body, Some(release)),
|
||||||
|
};
|
||||||
|
if let Some(release) = release {
|
||||||
|
let _ = release.await;
|
||||||
|
}
|
||||||
|
write_http_response(stream, body).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(_) = handlers.join_next(), if !handlers.is_empty() => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
base_url: format!("http://127.0.0.1:{}", address.port()),
|
||||||
|
plans,
|
||||||
|
requests,
|
||||||
|
task,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn answer(&self, body: Value) {
|
||||||
|
self.plans.send(ReplyPlan::Send(body)).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn hold(&self, body: Value) -> oneshot::Sender<()> {
|
||||||
|
let (release, wait) = oneshot::channel();
|
||||||
|
self.plans.send(ReplyPlan::Hold(body, wait)).await.unwrap();
|
||||||
|
release
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn next_request(&mut self) -> HttpRequest {
|
||||||
|
tokio::time::timeout(Duration::from_secs(20), self.requests.recv())
|
||||||
|
.await
|
||||||
|
.expect("timed out waiting for fake inference request")
|
||||||
|
.expect("fake server request channel closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for FakeServer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_http_request(stream: &mut TcpStream) -> HttpRequest {
|
||||||
|
let mut received = Vec::new();
|
||||||
|
let mut chunk = [0; 4096];
|
||||||
|
let header_end = loop {
|
||||||
|
let count = stream.read(&mut chunk).await.expect("read HTTP request");
|
||||||
|
assert_ne!(count, 0, "client closed before HTTP headers arrived");
|
||||||
|
received.extend_from_slice(&chunk[..count]);
|
||||||
|
if let Some(end) = received.windows(4).position(|part| part == b"\r\n\r\n") {
|
||||||
|
break end;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let header_text = String::from_utf8_lossy(&received[..header_end]);
|
||||||
|
let mut lines = header_text.split("\r\n");
|
||||||
|
let mut request_line = lines.next().unwrap().split_whitespace();
|
||||||
|
let method = request_line.next().unwrap().to_string();
|
||||||
|
let path = request_line.next().unwrap().to_string();
|
||||||
|
let mut headers = HashMap::new();
|
||||||
|
for line in lines {
|
||||||
|
if let Some((name, value)) = line.split_once(':') {
|
||||||
|
headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let body_start = header_end + 4;
|
||||||
|
let request_body = if let Some(length) = headers.get("content-length") {
|
||||||
|
let body_length = length.parse::<usize>().unwrap();
|
||||||
|
while received.len() < body_start + body_length {
|
||||||
|
let count = stream.read(&mut chunk).await.expect("read HTTP body");
|
||||||
|
assert_ne!(count, 0, "client closed before HTTP body arrived");
|
||||||
|
received.extend_from_slice(&chunk[..count]);
|
||||||
|
}
|
||||||
|
received[body_start..body_start + body_length].to_vec()
|
||||||
|
} else {
|
||||||
|
assert!(
|
||||||
|
headers
|
||||||
|
.get("transfer-encoding")
|
||||||
|
.is_some_and(|value| value.eq_ignore_ascii_case("chunked")),
|
||||||
|
"request has neither Content-Length nor chunked transfer encoding: {headers:?}"
|
||||||
|
);
|
||||||
|
read_chunked_body(stream, &mut received, body_start).await
|
||||||
|
};
|
||||||
|
let body = serde_json::from_slice(&request_body).unwrap();
|
||||||
|
HttpRequest {
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_chunked_body(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
received: &mut Vec<u8>,
|
||||||
|
mut offset: usize,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut body = Vec::new();
|
||||||
|
loop {
|
||||||
|
let line_end = loop {
|
||||||
|
if let Some(end) = received[offset..]
|
||||||
|
.windows(2)
|
||||||
|
.position(|part| part == b"\r\n")
|
||||||
|
{
|
||||||
|
break offset + end;
|
||||||
|
}
|
||||||
|
let mut chunk = [0; 4096];
|
||||||
|
let count = stream.read(&mut chunk).await.expect("read chunk size");
|
||||||
|
assert_ne!(count, 0, "client closed before chunk size arrived");
|
||||||
|
received.extend_from_slice(&chunk[..count]);
|
||||||
|
};
|
||||||
|
let size = String::from_utf8_lossy(&received[offset..line_end]);
|
||||||
|
let size = size.split(';').next().unwrap().trim();
|
||||||
|
let size = usize::from_str_radix(size, 16).unwrap();
|
||||||
|
offset = line_end + 2;
|
||||||
|
if size == 0 {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
while received.len() < offset + size + 2 {
|
||||||
|
let mut chunk = [0; 4096];
|
||||||
|
let count = stream.read(&mut chunk).await.expect("read chunk data");
|
||||||
|
assert_ne!(count, 0, "client closed before chunk data arrived");
|
||||||
|
received.extend_from_slice(&chunk[..count]);
|
||||||
|
}
|
||||||
|
body.extend_from_slice(&received[offset..offset + size]);
|
||||||
|
assert_eq!(&received[offset + size..offset + size + 2], b"\r\n");
|
||||||
|
offset += size + 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_http_response(mut stream: TcpStream, body: Value) {
|
||||||
|
let body = serde_json::to_vec(&body).unwrap();
|
||||||
|
let head = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
let mut response = head.into_bytes();
|
||||||
|
response.extend_from_slice(&body);
|
||||||
|
let _ = stream.write_all(&response).await;
|
||||||
|
let _ = stream.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn agent_wasm() -> &'static PathBuf {
|
||||||
|
static WASM: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
WASM.get_or_init(|| {
|
||||||
|
let root = common::repo_root();
|
||||||
|
let output = Command::new("cargo")
|
||||||
|
.args([
|
||||||
|
"build",
|
||||||
|
"--release",
|
||||||
|
"-p",
|
||||||
|
"agent",
|
||||||
|
"--target",
|
||||||
|
"wasm32-wasip2",
|
||||||
|
"--target-dir",
|
||||||
|
"target/test-actors",
|
||||||
|
])
|
||||||
|
.current_dir(&root)
|
||||||
|
.output()
|
||||||
|
.expect("run cargo to build the agent actor");
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"building agent failed\nstdout:\n{}\nstderr:\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let wasm = root.join("target/test-actors/wasm32-wasip2/release/agent.wasm");
|
||||||
|
assert!(
|
||||||
|
wasm.is_file(),
|
||||||
|
"agent build did not create {}",
|
||||||
|
wasm.display()
|
||||||
|
);
|
||||||
|
wasm
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_agent(
|
||||||
|
api: Api,
|
||||||
|
fake: &FakeServer,
|
||||||
|
label: &str,
|
||||||
|
) -> (World, Host, mpsc::Receiver<Envelope>) {
|
||||||
|
let world = create_world(label);
|
||||||
|
let data = world.data_dir("agent");
|
||||||
|
std::fs::create_dir_all(&data).unwrap();
|
||||||
|
let config = json!({"api": api.name(), "base_url": fake.base_url, "model": "fake-model", "token": TEST_TOKEN});
|
||||||
|
std::fs::write(
|
||||||
|
data.join("config.json"),
|
||||||
|
serde_json::to_vec(&config).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let source = source_with_test(
|
||||||
|
&world,
|
||||||
|
"agent-candidate",
|
||||||
|
json!({"steps": [{"send": {"type": "text", "text": "candidate"}, "expect": {"type": "text"}}]}),
|
||||||
|
);
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let spawned = common::spawn_actor(&host, &mut mailbox, "agent", agent_wasm(), &source).await;
|
||||||
|
assert_eq!(
|
||||||
|
spawned.body["type"], "spawned",
|
||||||
|
"agent spawn failed: {spawned:?}"
|
||||||
|
);
|
||||||
|
let subscribed = request(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
json!({"type": "subscribe", "actor": "agent"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(subscribed.body["type"], "subscribed");
|
||||||
|
(world, host, mailbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_text(host: &Host, text: &str) -> String {
|
||||||
|
host.broker.send(
|
||||||
|
"console",
|
||||||
|
Some("agent"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
json!({"type": "text", "text": text}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_published_text(mailbox: &mut mpsc::Receiver<Envelope>, text: &str) {
|
||||||
|
let message = receive_message(mailbox, Duration::from_secs(20)).await;
|
||||||
|
assert_eq!(message.from, "agent");
|
||||||
|
assert_eq!(message.to, "console");
|
||||||
|
assert_eq!(message.reply_to, None);
|
||||||
|
assert_eq!(message.body, json!({"type": "text", "text": text}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history(world: &World) -> Vec<Value> {
|
||||||
|
std::fs::read_to_string(world.data_dir("agent").join("history.jsonl"))
|
||||||
|
.unwrap()
|
||||||
|
.lines()
|
||||||
|
.map(|line| serde_json::from_str(line).unwrap())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_history_tools_have_results(history: &[Value]) {
|
||||||
|
let calls: Vec<_> = history
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry["kind"] == "tool_call")
|
||||||
|
.collect();
|
||||||
|
let results: Vec<_> = history
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry["kind"] == "tool_result")
|
||||||
|
.collect();
|
||||||
|
assert_eq!(calls.len(), results.len());
|
||||||
|
for call in calls {
|
||||||
|
assert!(
|
||||||
|
results.iter().any(|result| result["id"] == call["id"]),
|
||||||
|
"tool call has no result: {call}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_tool_pair(
|
||||||
|
request: &HttpRequest,
|
||||||
|
api: Api,
|
||||||
|
id: &str,
|
||||||
|
name: &str,
|
||||||
|
arguments: &Value,
|
||||||
|
) -> String {
|
||||||
|
match api {
|
||||||
|
Api::OpenAi => {
|
||||||
|
let input = request.body["input"].as_array().unwrap();
|
||||||
|
let call_index = input
|
||||||
|
.iter()
|
||||||
|
.position(|item| item["type"] == "function_call")
|
||||||
|
.unwrap();
|
||||||
|
let result_index = input
|
||||||
|
.iter()
|
||||||
|
.position(|item| item["type"] == "function_call_output")
|
||||||
|
.unwrap();
|
||||||
|
assert!(call_index < result_index);
|
||||||
|
let call = &input[call_index];
|
||||||
|
assert_eq!(call["call_id"], id);
|
||||||
|
assert_eq!(call["name"], name);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(call["arguments"].as_str().unwrap()).unwrap(),
|
||||||
|
*arguments
|
||||||
|
);
|
||||||
|
let result = &input[result_index];
|
||||||
|
assert_eq!(result["call_id"], id);
|
||||||
|
result["output"].as_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
Api::Anthropic => {
|
||||||
|
let messages = request.body["messages"].as_array().unwrap();
|
||||||
|
let mut call_position = None;
|
||||||
|
let mut result_position = None;
|
||||||
|
let mut call = None;
|
||||||
|
let mut result = None;
|
||||||
|
for (index, message) in messages.iter().enumerate() {
|
||||||
|
let Some(blocks) = message["content"].as_array() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for block in blocks {
|
||||||
|
match block["type"].as_str() {
|
||||||
|
Some("tool_use") => {
|
||||||
|
call_position = Some(index);
|
||||||
|
call = Some(block);
|
||||||
|
}
|
||||||
|
Some("tool_result") => {
|
||||||
|
result_position = Some(index);
|
||||||
|
result = Some(block);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(call_position.unwrap() < result_position.unwrap());
|
||||||
|
let call = call.unwrap();
|
||||||
|
assert_eq!(call["id"], id);
|
||||||
|
assert_eq!(call["name"], name);
|
||||||
|
assert_eq!(call["input"], *arguments);
|
||||||
|
let result = result.unwrap();
|
||||||
|
assert_eq!(result["tool_use_id"], id);
|
||||||
|
result["content"].as_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn text_answers_use_both_inference_apis() {
|
||||||
|
for api in APIS {
|
||||||
|
let mut fake = FakeServer::start().await;
|
||||||
|
fake.answer(api.text_response("hello from the fake model"))
|
||||||
|
.await;
|
||||||
|
let (_, host, mut mailbox) =
|
||||||
|
start_agent(api, &fake, &format!("agent-text-{}", api.name())).await;
|
||||||
|
send_text(&host, "say hello");
|
||||||
|
api.check_request(&fake.next_request().await);
|
||||||
|
assert_published_text(&mut mailbox, "hello from the fake model").await;
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tool_calls_pair_with_results_in_both_inference_apis() {
|
||||||
|
let args = json!({"path": "/actor"});
|
||||||
|
for api in APIS {
|
||||||
|
let mut fake = FakeServer::start().await;
|
||||||
|
fake.answer(api.tool_response("list-1", "list_dir", &args))
|
||||||
|
.await;
|
||||||
|
let (world, host, mut mailbox) =
|
||||||
|
start_agent(api, &fake, &format!("agent-tools-{}", api.name())).await;
|
||||||
|
send_text(&host, "list actors");
|
||||||
|
api.check_request(&fake.next_request().await);
|
||||||
|
fake.answer(api.text_response("the actor list is ready"))
|
||||||
|
.await;
|
||||||
|
let second = fake.next_request().await;
|
||||||
|
api.check_request(&second);
|
||||||
|
let output = assert_tool_pair(&second, api, "list-1", "list_dir", &args);
|
||||||
|
let result: Value = serde_json::from_str(&output).unwrap();
|
||||||
|
assert!(
|
||||||
|
result["entries"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry["name"] == "agent")
|
||||||
|
);
|
||||||
|
assert_published_text(&mut mailbox, "the actor list is ready").await;
|
||||||
|
|
||||||
|
host.stop().await;
|
||||||
|
let history = history(&world);
|
||||||
|
assert_eq!(
|
||||||
|
history
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry["kind"].as_str().unwrap())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["user", "tool_call", "tool_result", "assistant"]
|
||||||
|
);
|
||||||
|
assert_eq!(history[1]["id"], "list-1");
|
||||||
|
assert_eq!(history[2]["id"], "list-1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn restart_resumes_a_turn_with_completed_tool_history() {
|
||||||
|
let args = json!({"path": "/actor"});
|
||||||
|
for api in [Api::OpenAi] {
|
||||||
|
let mut fake = FakeServer::start().await;
|
||||||
|
fake.answer(api.tool_response("list-1", "list_dir", &args))
|
||||||
|
.await;
|
||||||
|
let (world, host, mut mailbox) =
|
||||||
|
start_agent(api, &fake, &format!("agent-resume-{}", api.name())).await;
|
||||||
|
let id = send_text(&host, "list actors before the restart");
|
||||||
|
api.check_request(&fake.next_request().await);
|
||||||
|
let release = fake.hold(api.text_response("discarded response")).await;
|
||||||
|
let second = fake.next_request().await;
|
||||||
|
api.check_request(&second);
|
||||||
|
assert_tool_pair(&second, api, "list-1", "list_dir", &args);
|
||||||
|
|
||||||
|
let stopped = request(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
json!({"type": "stop", "actor": "agent"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(stopped.body["type"], "stopped");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_slice::<Value>(
|
||||||
|
&std::fs::read(world.data_dir("agent").join("turn.json")).unwrap()
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
json!(true)
|
||||||
|
);
|
||||||
|
let _ = release.send(());
|
||||||
|
fake.answer(api.text_response("resumed answer")).await;
|
||||||
|
let start_id = host.broker.send(
|
||||||
|
"console",
|
||||||
|
Some("broker"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
json!({"type": "start", "actor": "agent"}),
|
||||||
|
);
|
||||||
|
let resumed = fake.next_request().await;
|
||||||
|
api.check_request(&resumed);
|
||||||
|
assert_tool_pair(&resumed, api, "list-1", "list_dir", &args);
|
||||||
|
let started = receive_reply(&mut mailbox, &start_id, Duration::from_secs(20)).await;
|
||||||
|
assert_eq!(started.body["type"], "started");
|
||||||
|
assert_published_text(&mut mailbox, "resumed answer").await;
|
||||||
|
|
||||||
|
let history = history(&world);
|
||||||
|
assert_history_tools_have_results(&history);
|
||||||
|
assert_eq!(
|
||||||
|
history
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry["kind"] == "user"
|
||||||
|
&& entry["text"].as_str().unwrap_or_default().contains(&id))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use sausage::{Envelope, Host, World};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
const REPLY_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
static WORLD_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
pub fn repo_root() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.to_path_buf()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_world(label: &str) -> World {
|
||||||
|
let nonce = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let sequence = WORLD_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let path = repo_root()
|
||||||
|
.join("target/test-worlds")
|
||||||
|
.join(format!("{label}-{}-{nonce}-{sequence}", std::process::id()));
|
||||||
|
World::create(&path).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn actor_fixture_source(name: &str) -> PathBuf {
|
||||||
|
repo_root().join("cli/tests/actors").join(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cargo_source(name: &str) -> PathBuf {
|
||||||
|
repo_root().join("cli/tests/crates").join(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toolbox_wasm() -> &'static PathBuf {
|
||||||
|
static WASM: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
WASM.get_or_init(|| build_fixture("toolbox"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_fixture(name: &str) -> PathBuf {
|
||||||
|
let root = repo_root();
|
||||||
|
let manifest = actor_fixture_source(name).join("Cargo.toml");
|
||||||
|
let target_dir = root.join("target/test-actors");
|
||||||
|
let output = Command::new("cargo")
|
||||||
|
.args([
|
||||||
|
"build",
|
||||||
|
"--release",
|
||||||
|
"--target",
|
||||||
|
"wasm32-wasip2",
|
||||||
|
"--target-dir",
|
||||||
|
])
|
||||||
|
.arg(&target_dir)
|
||||||
|
.arg("--manifest-path")
|
||||||
|
.arg(&manifest)
|
||||||
|
.current_dir(&root)
|
||||||
|
.output()
|
||||||
|
.expect("run cargo to build the test actor");
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"building fixture {name} failed\nstdout:\n{}\nstderr:\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let wasm = target_dir
|
||||||
|
.join("wasm32-wasip2/release")
|
||||||
|
.join(format!("{name}.wasm"));
|
||||||
|
assert!(
|
||||||
|
wasm.is_file(),
|
||||||
|
"fixture build did not create {}",
|
||||||
|
wasm.display()
|
||||||
|
);
|
||||||
|
wasm
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn passing_source(world: &World, name: &str) -> PathBuf {
|
||||||
|
source_with_test(
|
||||||
|
world,
|
||||||
|
name,
|
||||||
|
json!({
|
||||||
|
"steps": [{
|
||||||
|
"send": {"type": "echo", "value": "candidate"},
|
||||||
|
"expect": {"type": "echo", "value": "candidate"}
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn source_with_test(world: &World, name: &str, test: Value) -> PathBuf {
|
||||||
|
let source = world.source_dir("console").join("test-fixtures").join(name);
|
||||||
|
std::fs::create_dir_all(source.join("tests")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
source.join("tests/candidate.json"),
|
||||||
|
serde_json::to_vec(&test).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
source
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(world: &World) -> (Host, mpsc::Receiver<Envelope>) {
|
||||||
|
let host = Host::start(&world.root).await.unwrap();
|
||||||
|
let mailbox = host.mailbox("console");
|
||||||
|
(host, mailbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn request(host: &Host, mailbox: &mut mpsc::Receiver<Envelope>, body: Value) -> Envelope {
|
||||||
|
let id = host
|
||||||
|
.broker
|
||||||
|
.send("console", Some("broker"), None, None, body);
|
||||||
|
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn receive_message(
|
||||||
|
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Envelope {
|
||||||
|
tokio::time::timeout(timeout, mailbox.recv())
|
||||||
|
.await
|
||||||
|
.expect("timed out waiting for a message")
|
||||||
|
.expect("console mailbox closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn receive_reply(
|
||||||
|
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||||
|
id: &str,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Envelope {
|
||||||
|
tokio::time::timeout(timeout, async {
|
||||||
|
loop {
|
||||||
|
let envelope = mailbox.recv().await.expect("console mailbox closed");
|
||||||
|
if envelope.reply_to.as_deref() == Some(id) {
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("timed out waiting for reply to {id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn receive_replies(
|
||||||
|
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||||
|
ids: &[String],
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Vec<Envelope> {
|
||||||
|
let mut replies = Vec::with_capacity(ids.len());
|
||||||
|
tokio::time::timeout(timeout, async {
|
||||||
|
while replies.len() < ids.len() {
|
||||||
|
let message = mailbox.recv().await.expect("console mailbox closed");
|
||||||
|
assert!(
|
||||||
|
ids.iter()
|
||||||
|
.any(|id| message.reply_to.as_deref() == Some(id.as_str())),
|
||||||
|
"unexpected message while waiting for replies: {message:?}"
|
||||||
|
);
|
||||||
|
replies.push(message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("timed out waiting for replies");
|
||||||
|
replies
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ask(
|
||||||
|
host: &Host,
|
||||||
|
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||||
|
actor: &str,
|
||||||
|
body: Value,
|
||||||
|
) -> Envelope {
|
||||||
|
let id = host.broker.send("console", Some(actor), None, None, body);
|
||||||
|
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ask_type(
|
||||||
|
host: &Host,
|
||||||
|
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||||
|
actor: &str,
|
||||||
|
body: Value,
|
||||||
|
expected: &str,
|
||||||
|
) -> Envelope {
|
||||||
|
let reply = ask(host, mailbox, actor, body).await;
|
||||||
|
assert_eq!(reply.body["type"], expected, "unexpected reply: {reply:?}");
|
||||||
|
reply
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_request(host: &Host, actor: &str, wasm: &Path, source: &Path) -> String {
|
||||||
|
host.broker.send(
|
||||||
|
"console",
|
||||||
|
Some("broker"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
json!({
|
||||||
|
"type": "spawn",
|
||||||
|
"actor": actor,
|
||||||
|
"wasm": wasm.display().to_string(),
|
||||||
|
"source": source.display().to_string()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn spawn_actor(
|
||||||
|
host: &Host,
|
||||||
|
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||||
|
actor: &str,
|
||||||
|
wasm: &Path,
|
||||||
|
source: &Path,
|
||||||
|
) -> Envelope {
|
||||||
|
let id = spawn_request(host, actor, wasm, source);
|
||||||
|
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn spawn_type(
|
||||||
|
host: &Host,
|
||||||
|
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||||
|
actor: &str,
|
||||||
|
wasm: &Path,
|
||||||
|
source: &Path,
|
||||||
|
) -> Envelope {
|
||||||
|
let reply = spawn_actor(host, mailbox, actor, wasm, source).await;
|
||||||
|
assert_eq!(
|
||||||
|
reply.body["type"], "spawned",
|
||||||
|
"unexpected spawn reply: {reply:?}"
|
||||||
|
);
|
||||||
|
reply
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upgrade_actor(
|
||||||
|
host: &Host,
|
||||||
|
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||||
|
actor: &str,
|
||||||
|
wasm: &Path,
|
||||||
|
source: &Path,
|
||||||
|
) -> Envelope {
|
||||||
|
request(
|
||||||
|
host,
|
||||||
|
mailbox,
|
||||||
|
json!({
|
||||||
|
"type": "upgrade",
|
||||||
|
"actor": actor,
|
||||||
|
"wasm": wasm.display().to_string(),
|
||||||
|
"source": source.display().to_string()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "crasher"
|
name = "cargo-fail"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[workspace]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
missing_function();
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "echo"
|
name = "cargo-valid"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[workspace]
|
||||||
serde_json = "1"
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,911 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use common::*;
|
||||||
|
use sausage::{Envelope, Host, World};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
async fn filesystem_world(label: &str) -> (World, Host, mpsc::Receiver<Envelope>) {
|
||||||
|
let world = create_world(label);
|
||||||
|
let wasm = toolbox_wasm().clone();
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
for actor in ["writer", "reader"] {
|
||||||
|
let source = passing_source(&world, actor);
|
||||||
|
let spawned = spawn_actor(&host, &mut mailbox, actor, &wasm, &source).await;
|
||||||
|
assert_eq!(spawned.body["type"], "spawned");
|
||||||
|
}
|
||||||
|
(world, host, mailbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive_shutdown_messages(
|
||||||
|
mailbox: &mut mpsc::Receiver<Envelope>,
|
||||||
|
id: &str,
|
||||||
|
expected: &str,
|
||||||
|
) {
|
||||||
|
let (mut result, mut reply, mut publish) = (false, false, false);
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
|
while !result || !reply || !publish {
|
||||||
|
let message = mailbox.recv().await.expect("console mailbox closed");
|
||||||
|
if message.reply_to.as_deref() == Some(id) {
|
||||||
|
assert_eq!(message.body["type"], expected);
|
||||||
|
result = true;
|
||||||
|
} else if message.reply_to.as_deref() == Some("broker/shutdown") {
|
||||||
|
assert_eq!(message.from, "shutdown-output");
|
||||||
|
assert_eq!(message.body["type"], "shutdown-reply");
|
||||||
|
reply = true;
|
||||||
|
} else {
|
||||||
|
assert_eq!(message.from, "shutdown-output");
|
||||||
|
assert_eq!(message.reply_to, None);
|
||||||
|
assert_eq!(message.body["type"], "shutdown-publish");
|
||||||
|
publish = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("shutdown output or lifecycle reply did not arrive");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn routes_replies_publishes_and_reports_undeliverable() {
|
||||||
|
let world = create_world("routing");
|
||||||
|
let wasm = toolbox_wasm().clone();
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
|
||||||
|
let missing_id = host.broker.send(
|
||||||
|
"console",
|
||||||
|
Some("missing"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
json!({"type": "hello"}),
|
||||||
|
);
|
||||||
|
let missing = receive_reply(&mut mailbox, &missing_id, Duration::from_secs(10)).await;
|
||||||
|
assert_eq!(missing.body["type"], "undeliverable");
|
||||||
|
assert_eq!(missing.body["to"], "missing");
|
||||||
|
|
||||||
|
let source = passing_source(&world, "router");
|
||||||
|
let spawned = spawn_actor(&host, &mut mailbox, "router", &wasm, &source).await;
|
||||||
|
assert_eq!(spawned.body["type"], "spawned");
|
||||||
|
|
||||||
|
let echo = ask(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
"router",
|
||||||
|
json!({"type": "echo", "value": "hello"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(echo.from, "router");
|
||||||
|
assert_eq!(echo.body, json!({"type": "echo", "value": "hello"}));
|
||||||
|
|
||||||
|
let subscribed = request(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
json!({"type": "subscribe", "actor": "router"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(subscribed.body["type"], "subscribed");
|
||||||
|
|
||||||
|
host.broker.send(
|
||||||
|
"console",
|
||||||
|
Some("router"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
json!({"type": "publish", "value": "news"}),
|
||||||
|
);
|
||||||
|
let published = receive_message(&mut mailbox, Duration::from_secs(10)).await;
|
||||||
|
assert_eq!(published.from, "router");
|
||||||
|
assert_eq!(
|
||||||
|
published.body,
|
||||||
|
json!({"type": "published", "value": "news"})
|
||||||
|
);
|
||||||
|
assert_eq!(published.reply_to, None);
|
||||||
|
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn broker_does_not_answer_messages_with_reply_to() {
|
||||||
|
let world = create_world("broker-reply-to");
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let scanned = request(&host, &mut mailbox, json!({"type": "scan"})).await;
|
||||||
|
assert_eq!(scanned.body["type"], "scanned");
|
||||||
|
|
||||||
|
host.broker.send(
|
||||||
|
"console",
|
||||||
|
Some("broker"),
|
||||||
|
None,
|
||||||
|
Some("upstream-message"),
|
||||||
|
json!({"type": "scan"}),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), mailbox.recv())
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"broker replied to a message that already had reply_to"
|
||||||
|
);
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn exit_request_is_answered_and_signals_the_host() {
|
||||||
|
let world = create_world("broker-exit");
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let exiting = request(&host, &mut mailbox, json!({"type": "exit"})).await;
|
||||||
|
assert_eq!(exiting.body["type"], "exiting");
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), host.broker.exit_requested())
|
||||||
|
.await
|
||||||
|
.expect("exit request did not reach the host");
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn filesystem_permissions_and_public_sharing() {
|
||||||
|
let (_, host, mut mailbox) = filesystem_world("filesystem-permissions").await;
|
||||||
|
for path in [
|
||||||
|
"/actor/reader/public/denied.txt",
|
||||||
|
"/actor/writer/public/denied.txt",
|
||||||
|
] {
|
||||||
|
let denied = ask(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
"writer",
|
||||||
|
json!({"type": "write", "path": path, "text": "denied"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(denied.body["type"], "error", "write to {path} succeeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
let written = ask(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
"writer",
|
||||||
|
json!({"type": "write", "path": "/public/shared.txt", "text": "public"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(written.body["type"], "ok");
|
||||||
|
let shared = ask(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
"reader",
|
||||||
|
json!({"type": "read", "path": "/actor/writer/public/shared.txt"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(shared.body, json!({"type": "content", "text": "public"}));
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn actors_can_read_but_not_write_other_actor_data() {
|
||||||
|
let (_, host, mut mailbox) = filesystem_world("actor-data-sharing").await;
|
||||||
|
ask_type(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
"writer",
|
||||||
|
json!({"type": "write", "path": "/data/x", "text": "shared"}),
|
||||||
|
"ok",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let shared = ask_type(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
"reader",
|
||||||
|
json!({"type": "read", "path": "/actor/writer/data/x"}),
|
||||||
|
"content",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(shared.body["text"], "shared");
|
||||||
|
|
||||||
|
ask_type(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
"reader",
|
||||||
|
json!({"type": "write", "path": "/actor/writer/data/x", "text": "changed"}),
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let unchanged = ask_type(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
"writer",
|
||||||
|
json!({"type": "read", "path": "/data/x"}),
|
||||||
|
"content",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(unchanged.body["text"], "shared");
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn actors_serve_files_under_fs() {
|
||||||
|
// "writer" serves its fs/ from memory (toolbox fs_read / fs_write / fs_remove).
|
||||||
|
let (_, host, mut mailbox) = filesystem_world("actor-fs").await;
|
||||||
|
let file = "/actor/writer/fs/notes/a.txt";
|
||||||
|
let write = json!({"type": "write", "path": file, "text": "hello"});
|
||||||
|
ask_type(&host, &mut mailbox, "reader", write, "ok").await;
|
||||||
|
// The write reached the owner as one fs_write message.
|
||||||
|
let direct = json!({"type": "fs_read", "path": "/notes/a.txt"});
|
||||||
|
let served = ask_type(&host, &mut mailbox, "writer", direct, "fs_file").await;
|
||||||
|
assert_eq!(served.body["text"], "hello");
|
||||||
|
|
||||||
|
let read = json!({"type": "read", "path": file});
|
||||||
|
assert_eq!(ask_type(&host, &mut mailbox, "reader", read, "content").await.body["text"], "hello");
|
||||||
|
let list = json!({"type": "list", "path": "/actor/writer/fs/notes"});
|
||||||
|
assert_eq!(ask_type(&host, &mut mailbox, "reader", list, "entries").await.body["names"], json!(["a.txt"]));
|
||||||
|
let root = json!({"type": "list", "path": "/actor/writer/fs"});
|
||||||
|
assert_eq!(ask_type(&host, &mut mailbox, "reader", root, "entries").await.body["names"], json!(["notes"]));
|
||||||
|
|
||||||
|
// fs_error and undeliverable become errors of the file operation.
|
||||||
|
for path in ["/actor/writer/fs/missing.txt", "/actor/nobody/fs/x"] {
|
||||||
|
ask_type(&host, &mut mailbox, "reader", json!({"type": "read", "path": path}), "error").await;
|
||||||
|
}
|
||||||
|
// An actor that does not answer (here: it is busy reading itself) fails after the timeout.
|
||||||
|
let id = host.broker.send("console", Some("reader"), None, None, json!({"type": "read", "path": "/actor/reader/fs/x"}));
|
||||||
|
let timed_out = receive_reply(&mut mailbox, &id, Duration::from_secs(20)).await;
|
||||||
|
assert_eq!(timed_out.body["type"], "error");
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn files_are_plain_posix_files() {
|
||||||
|
let (_, host, mut mailbox) = filesystem_world("posix").await;
|
||||||
|
// An open file is visible by path, to the writer and to every reader.
|
||||||
|
let open = json!({"type": "open", "path": "/public/new.txt", "text": "new"});
|
||||||
|
ask_type(&host, &mut mailbox, "writer", open, "opened").await;
|
||||||
|
let own = json!({"type": "read", "path": "/public/new.txt"});
|
||||||
|
assert_eq!(ask_type(&host, &mut mailbox, "writer", own, "content").await.body["text"], "new");
|
||||||
|
let other = json!({"type": "read", "path": "/actor/writer/public/new.txt"});
|
||||||
|
assert_eq!(ask_type(&host, &mut mailbox, "reader", other, "content").await.body["text"], "new");
|
||||||
|
// It can be renamed while open.
|
||||||
|
let rename = json!({"type": "rename", "path": "/public/new.txt", "to": "/public/moved.txt"});
|
||||||
|
ask_type(&host, &mut mailbox, "writer", rename, "ok").await;
|
||||||
|
ask_type(&host, &mut mailbox, "writer", json!({"type": "close", "path": "/public/new.txt"}), "closed").await;
|
||||||
|
let moved = json!({"type": "read", "path": "/actor/writer/public/moved.txt"});
|
||||||
|
assert_eq!(ask_type(&host, &mut mailbox, "reader", moved, "content").await.body["text"], "new");
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn filesystem_rejects_paths_that_escape_data() {
|
||||||
|
let (_, host, mut mailbox) = filesystem_world("filesystem-path-escape").await;
|
||||||
|
let escaped = ask(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
"writer",
|
||||||
|
json!({"type": "write", "path": "/data/../../..", "text": "outside"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(escaped.body["type"], "error");
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upgrades_test_candidates_keep_old_versions_and_queue_messages_in_order() {
|
||||||
|
let world = create_world("upgrade");
|
||||||
|
let wasm = toolbox_wasm().clone();
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let passing = passing_source(&world, "passing");
|
||||||
|
let failing = source_with_test(
|
||||||
|
&world,
|
||||||
|
"failing",
|
||||||
|
json!({
|
||||||
|
"steps": [{
|
||||||
|
"send": {"type": "echo", "value": "candidate"},
|
||||||
|
"expect": {"type": "wrong"}
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
spawn_actor(&host, &mut mailbox, "queue", &wasm, &passing)
|
||||||
|
.await
|
||||||
|
.body["type"],
|
||||||
|
"spawned"
|
||||||
|
);
|
||||||
|
|
||||||
|
let failed = upgrade_actor(&host, &mut mailbox, "queue", &wasm, &failing).await;
|
||||||
|
assert_eq!(failed.body["type"], "error");
|
||||||
|
let old_version = ask(&host, &mut mailbox, "queue", json!({"type": "version"})).await;
|
||||||
|
assert_eq!(old_version.body["version"], "1");
|
||||||
|
// A version that fails its tests never enters versions/.
|
||||||
|
assert!(!world.actor_dir("queue").join("versions/2").exists());
|
||||||
|
|
||||||
|
let upgrade_id = host.broker.send(
|
||||||
|
"console",
|
||||||
|
Some("broker"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
json!({"type": "upgrade", "actor": "queue", "wasm": wasm.display().to_string(), "source": passing.display().to_string()}),
|
||||||
|
);
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
|
while !world.data_dir("queue").join("shutdown").is_file() {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("old actor did not enter shutdown during upgrade");
|
||||||
|
|
||||||
|
let mut message_ids = Vec::new();
|
||||||
|
for n in 1..=3 {
|
||||||
|
message_ids.push(host.broker.send(
|
||||||
|
"console",
|
||||||
|
Some("queue"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
json!({"type": "ordered", "n": n}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut upgrade_result = None;
|
||||||
|
let mut actor_replies = Vec::new();
|
||||||
|
tokio::time::timeout(Duration::from_secs(15), async {
|
||||||
|
while upgrade_result.is_none() || actor_replies.len() < message_ids.len() {
|
||||||
|
let envelope = mailbox.recv().await.expect("console mailbox closed");
|
||||||
|
if envelope.reply_to.as_deref() == Some(upgrade_id.as_str()) {
|
||||||
|
upgrade_result = Some(envelope);
|
||||||
|
} else if message_ids
|
||||||
|
.iter()
|
||||||
|
.any(|id| envelope.reply_to.as_deref() == Some(id.as_str()))
|
||||||
|
{
|
||||||
|
actor_replies.push(envelope);
|
||||||
|
} else {
|
||||||
|
panic!("unexpected message during queued upgrade: {envelope:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("upgrade did not deliver queued messages");
|
||||||
|
|
||||||
|
assert_eq!(upgrade_result.unwrap().body["type"], "upgraded");
|
||||||
|
let actual_ids: Vec<String> = actor_replies
|
||||||
|
.iter()
|
||||||
|
.map(|message| message.reply_to.clone().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(actual_ids, message_ids);
|
||||||
|
for (index, envelope) in actor_replies.iter().enumerate() {
|
||||||
|
assert_eq!(envelope.from, "queue");
|
||||||
|
assert_eq!(envelope.body["type"], "ordered");
|
||||||
|
assert_eq!(envelope.body["n"], json!(index + 1));
|
||||||
|
assert_eq!(envelope.body["version"], "2");
|
||||||
|
}
|
||||||
|
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rolls_back_a_crash_loop_to_the_previous_version_without_restoring_data() {
|
||||||
|
let world = create_world("crash-loop");
|
||||||
|
let wasm = toolbox_wasm().clone();
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let source = passing_source(&world, "source");
|
||||||
|
assert_eq!(
|
||||||
|
spawn_actor(&host, &mut mailbox, "crashloop", &wasm, &source)
|
||||||
|
.await
|
||||||
|
.body["type"],
|
||||||
|
"spawned"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ask(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
"crashloop",
|
||||||
|
json!({"type": "write", "path": "/data/state", "text": "stable"})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.body["type"],
|
||||||
|
"ok"
|
||||||
|
);
|
||||||
|
|
||||||
|
let upgrade_id = host.broker.send(
|
||||||
|
"console",
|
||||||
|
Some("broker"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
json!({"type": "upgrade", "actor": "crashloop", "wasm": wasm.display().to_string(), "source": source.display().to_string()}),
|
||||||
|
);
|
||||||
|
let mut upgraded: Option<Value> = None;
|
||||||
|
let mut rolled_back: Option<Value> = None;
|
||||||
|
tokio::time::timeout(Duration::from_secs(35), async {
|
||||||
|
while upgraded.is_none() || rolled_back.is_none() {
|
||||||
|
let envelope = mailbox.recv().await.expect("console mailbox closed");
|
||||||
|
let kind = envelope.body["type"].as_str().map(str::to_string);
|
||||||
|
match kind.as_deref() {
|
||||||
|
Some("upgraded") if envelope.reply_to.as_deref() == Some(upgrade_id.as_str()) => {
|
||||||
|
upgraded = Some(envelope.body);
|
||||||
|
}
|
||||||
|
Some("rolled_back") => rolled_back = Some(envelope.body),
|
||||||
|
other => panic!("unexpected automatic rollback message: {other:?}, {envelope:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("crash loop did not trigger automatic rollback");
|
||||||
|
|
||||||
|
assert_eq!(upgraded.unwrap()["version"], 2);
|
||||||
|
let rollback = rolled_back.unwrap();
|
||||||
|
assert_eq!(rollback["actor"], "crashloop");
|
||||||
|
assert_eq!(rollback["from"], 2);
|
||||||
|
assert_eq!(rollback["to"], 1);
|
||||||
|
assert_eq!(rollback["reason"], "crash loop after upgrade");
|
||||||
|
|
||||||
|
let version = ask(&host, &mut mailbox, "crashloop", json!({"type": "version"})).await;
|
||||||
|
assert_eq!(version.body["version"], "1");
|
||||||
|
let current = ask(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
"crashloop",
|
||||||
|
json!({"type": "read", "path": "/data/state"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(current.body["text"], "bad-v2");
|
||||||
|
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn explicit_rollback_keeps_current_data() {
|
||||||
|
let world = create_world("explicit-rollback");
|
||||||
|
let wasm = toolbox_wasm().clone();
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let source = passing_source(&world, "source");
|
||||||
|
assert_eq!(
|
||||||
|
spawn_actor(&host, &mut mailbox, "recover", &wasm, &source)
|
||||||
|
.await
|
||||||
|
.body["type"],
|
||||||
|
"spawned"
|
||||||
|
);
|
||||||
|
ask(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
"recover",
|
||||||
|
json!({"type": "write", "path": "/data/state", "text": "version-one"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let upgraded = upgrade_actor(&host, &mut mailbox, "recover", &wasm, &source).await;
|
||||||
|
assert_eq!(upgraded.body["type"], "upgraded");
|
||||||
|
ask(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
"recover",
|
||||||
|
json!({"type": "write", "path": "/data/state", "text": "version-two"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let rolled_back = request(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
json!({"type": "rollback", "actor": "recover", "version": 1}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(rolled_back.body["type"], "rolled_back");
|
||||||
|
assert_eq!(rolled_back.body["from"], 2);
|
||||||
|
assert_eq!(rolled_back.body["to"], 1);
|
||||||
|
let version = ask(&host, &mut mailbox, "recover", json!({"type": "version"})).await;
|
||||||
|
assert_eq!(version.body["version"], "1");
|
||||||
|
let current = ask(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
"recover",
|
||||||
|
json!({"type": "read", "path": "/data/state"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(current.body["text"], "version-two");
|
||||||
|
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remove_shuts_an_actor_down_and_deletes_its_folder() {
|
||||||
|
let world = create_world("remove");
|
||||||
|
let wasm = toolbox_wasm().clone();
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let source = passing_source(&world, "source");
|
||||||
|
spawn_type(&host, &mut mailbox, "doomed", &wasm, &source).await;
|
||||||
|
let write = json!({"type": "write", "path": "/data/state", "text": "x"});
|
||||||
|
ask_type(&host, &mut mailbox, "doomed", write, "ok").await;
|
||||||
|
ask_type(&host, &mut mailbox, "doomed", json!({"type": "arm-shutdown"}), "armed").await;
|
||||||
|
|
||||||
|
// Normal shutdown: the actor gets "shutdown" and its last message is routed.
|
||||||
|
let id = host.broker.send("console", Some("broker"), None, None, json!({"type": "remove", "actor": "doomed"}));
|
||||||
|
let (mut removed, mut last_words) = (false, false);
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
|
while !removed || !last_words {
|
||||||
|
let message = mailbox.recv().await.expect("console mailbox closed");
|
||||||
|
if message.reply_to.as_deref() == Some(id.as_str()) {
|
||||||
|
assert_eq!(message.body, json!({"type": "removed", "actor": "doomed"}));
|
||||||
|
removed = true;
|
||||||
|
} else if message.reply_to.as_deref() == Some("broker/shutdown") {
|
||||||
|
assert_eq!(message.body["type"], "shutdown-reply");
|
||||||
|
last_words = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("remove did not answer or did not shut the actor down");
|
||||||
|
assert!(!world.actor_dir("doomed").exists());
|
||||||
|
|
||||||
|
let gone = ask(&host, &mut mailbox, "doomed", json!({"type": "echo", "value": "hi"})).await;
|
||||||
|
assert_eq!(gone.body["type"], "undeliverable");
|
||||||
|
let again = request(&host, &mut mailbox, json!({"type": "remove", "actor": "doomed"})).await;
|
||||||
|
assert_eq!(again.body["type"], "error");
|
||||||
|
let broker = request(&host, &mut mailbox, json!({"type": "remove", "actor": "broker"})).await;
|
||||||
|
assert_eq!(broker.body["type"], "error");
|
||||||
|
assert!(world.actor_dir("broker").exists());
|
||||||
|
|
||||||
|
// The ID is free again: a new spawn starts at version 1 with empty data.
|
||||||
|
assert_eq!(spawn_type(&host, &mut mailbox, "doomed", &wasm, &source).await.body["version"], 1);
|
||||||
|
let read = json!({"type": "read", "path": "/data/state"});
|
||||||
|
ask_type(&host, &mut mailbox, "doomed", read, "error").await;
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_broker_request_lists_the_known_ones() {
|
||||||
|
let world = create_world("unknown-request");
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let unknown = request(&host, &mut mailbox, json!({"type": "frobnicate"})).await;
|
||||||
|
assert_eq!(unknown.body["type"], "error");
|
||||||
|
let reason = unknown.body["reason"].as_str().unwrap();
|
||||||
|
assert!(reason.contains("remove") && reason.contains("tap"), "{reason}");
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cargo_actor_builds_valid_crates_and_reports_compiler_errors() {
|
||||||
|
let world = create_world("cargo-actor");
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let valid_source = cargo_source("cargo-valid");
|
||||||
|
let valid_id = host.broker.send(
|
||||||
|
"console",
|
||||||
|
Some("cargo"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
json!({"type": "build", "source": valid_source.display().to_string()}),
|
||||||
|
);
|
||||||
|
let built = receive_reply(&mut mailbox, &valid_id, Duration::from_secs(120)).await;
|
||||||
|
assert_eq!(built.body["type"], "built");
|
||||||
|
let wasm = built.body["wasm"].as_str().unwrap();
|
||||||
|
assert!(wasm.ends_with(".wasm"));
|
||||||
|
assert!(host.world.resolve("console", wasm).unwrap().is_file());
|
||||||
|
|
||||||
|
let invalid_source = cargo_source("cargo-fail");
|
||||||
|
let invalid_id = host.broker.send(
|
||||||
|
"console",
|
||||||
|
Some("cargo"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
json!({"type": "build", "source": invalid_source.display().to_string()}),
|
||||||
|
);
|
||||||
|
let failed = receive_reply(&mut mailbox, &invalid_id, Duration::from_secs(120)).await;
|
||||||
|
assert_eq!(failed.body["type"], "build_failed");
|
||||||
|
assert!(
|
||||||
|
failed.body["errors"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("missing_function")
|
||||||
|
);
|
||||||
|
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_invalid_actor_ids() {
|
||||||
|
let world = create_world("invalid-id");
|
||||||
|
let wasm = toolbox_wasm().clone();
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let invalid = request(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
json!({"type": "spawn", "actor": "bad/actor", "wasm": wasm.display().to_string()}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(invalid.body["type"], "error");
|
||||||
|
assert!(
|
||||||
|
invalid.body["reason"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("invalid actor ID")
|
||||||
|
);
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_parent_paths() {
|
||||||
|
let world = create_world("parent-path");
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let parent_path = request(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
json!({"type": "spawn", "actor": "safe", "wasm": "/public/../escape.wasm"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(parent_path.body["type"], "error");
|
||||||
|
assert!(
|
||||||
|
parent_path.body["reason"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("must not contain '..'")
|
||||||
|
);
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_spawns_of_one_id_leave_one_live_instance() {
|
||||||
|
let world = create_world("concurrent-spawn");
|
||||||
|
let wasm = toolbox_wasm().clone();
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let source = passing_source(&world, "source");
|
||||||
|
let ids = [
|
||||||
|
spawn_request(&host, "spawn-race", &wasm, &source),
|
||||||
|
spawn_request(&host, "spawn-race", &wasm, &source),
|
||||||
|
];
|
||||||
|
let replies = receive_replies(&mut mailbox, &ids, Duration::from_secs(20)).await;
|
||||||
|
let spawned = replies
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.body["type"] == "spawned")
|
||||||
|
.count();
|
||||||
|
let failed = replies.iter().filter(|r| r.body["type"] == "error").count();
|
||||||
|
assert_eq!((spawned, failed), (1, 1));
|
||||||
|
|
||||||
|
let echo = ask_type(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
"spawn-race",
|
||||||
|
json!({"type": "echo", "value": "live"}),
|
||||||
|
"echo",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(echo.body["value"], "live");
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
let list = ask_type(
|
||||||
|
&host,
|
||||||
|
&mut mailbox,
|
||||||
|
"spawn-race",
|
||||||
|
json!({"type": "list", "path": "/data"}),
|
||||||
|
"entries",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let starts = list.body["names"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|n| n.as_str().unwrap().starts_with("start-"))
|
||||||
|
.count();
|
||||||
|
assert_eq!(starts, 1);
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stop_finishes_when_actor_stdin_is_full() {
|
||||||
|
let world = create_world("full-stdin-stop");
|
||||||
|
let wasm = toolbox_wasm().clone();
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let source = passing_source(&world, "source");
|
||||||
|
spawn_type(&host, &mut mailbox, "full-stdin", &wasm, &source).await;
|
||||||
|
let ignore = json!({"type": "ignore-stdin"});
|
||||||
|
ask_type(&host, &mut mailbox, "full-stdin", ignore, "ignoring").await;
|
||||||
|
let large = json!({"type": "echo", "value": "x".repeat(256 * 1024)});
|
||||||
|
host.broker
|
||||||
|
.send("console", Some("full-stdin"), None, None, large);
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
let stop = json!({"type": "stop", "actor": "full-stdin"});
|
||||||
|
let id = host
|
||||||
|
.broker
|
||||||
|
.send("console", Some("broker"), None, None, stop);
|
||||||
|
let stopped = tokio::time::timeout(
|
||||||
|
Duration::from_secs(6),
|
||||||
|
receive_reply(&mut mailbox, &id, Duration::from_secs(10)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("stop exceeded the five-second shutdown deadline");
|
||||||
|
assert_eq!(stopped.body["type"], "stopped");
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn shutdown_reply_and_publish_survive_stop_and_upgrade() {
|
||||||
|
let world = create_world("shutdown-output");
|
||||||
|
let wasm = toolbox_wasm().clone();
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let source = passing_source(&world, "source");
|
||||||
|
spawn_type(&host, &mut mailbox, "shutdown-output", &wasm, &source).await;
|
||||||
|
let subscribe = json!({"type": "subscribe", "actor": "shutdown-output"});
|
||||||
|
ask_type(&host, &mut mailbox, "broker", subscribe, "subscribed").await;
|
||||||
|
|
||||||
|
for operation in ["stop", "upgrade"] {
|
||||||
|
let arm = json!({"type": "arm-shutdown"});
|
||||||
|
ask_type(&host, &mut mailbox, "shutdown-output", arm, "armed").await;
|
||||||
|
let body = match operation {
|
||||||
|
"stop" => json!({"type": "stop", "actor": "shutdown-output"}),
|
||||||
|
_ => {
|
||||||
|
json!({"type": "upgrade", "actor": "shutdown-output", "wasm": wasm.display().to_string(), "source": source.display().to_string()})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let id = host
|
||||||
|
.broker
|
||||||
|
.send("console", Some("broker"), None, None, body);
|
||||||
|
receive_shutdown_messages(
|
||||||
|
&mut mailbox,
|
||||||
|
&id,
|
||||||
|
if operation == "stop" {
|
||||||
|
"stopped"
|
||||||
|
} else {
|
||||||
|
"upgraded"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if operation == "stop" {
|
||||||
|
let start = json!({"type": "start", "actor": "shutdown-output"});
|
||||||
|
ask_type(&host, &mut mailbox, "broker", start, "started").await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn candidates_receive_version_and_preexisting_spawn_state() {
|
||||||
|
let world = create_world("candidate-context");
|
||||||
|
let wasm = toolbox_wasm().clone();
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let version_step =
|
||||||
|
json!({"send": {"type": "version"}, "expect": {"type": "version", "version": "1"}});
|
||||||
|
let version_source = source_with_test(&world, "version", json!({"steps": [version_step]}));
|
||||||
|
let state_step = json!({"send": {"type": "read", "path": "/data/state.json"}, "expect": {"type": "content", "text": "seeded"}});
|
||||||
|
let state_source = source_with_test(&world, "state", json!({"steps": [state_step]}));
|
||||||
|
let state_dir = world.data_dir("stateful");
|
||||||
|
std::fs::create_dir_all(&state_dir).unwrap();
|
||||||
|
std::fs::write(state_dir.join("state.json"), "seeded").unwrap();
|
||||||
|
let ids = [
|
||||||
|
spawn_request(&host, "versioned", &wasm, &version_source),
|
||||||
|
spawn_request(&host, "stateful", &wasm, &state_source),
|
||||||
|
];
|
||||||
|
let replies = receive_replies(&mut mailbox, &ids, Duration::from_secs(20)).await;
|
||||||
|
let successful = replies
|
||||||
|
.iter()
|
||||||
|
.filter(|reply| reply.body["type"] == "spawned")
|
||||||
|
.count();
|
||||||
|
assert_eq!(successful, 2);
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn running_actors_have_sockets_and_candidates_do_not() {
|
||||||
|
// A TCP server on the host that answers each line with "echo: <line>".
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let address = listener.local_addr().unwrap().to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok((stream, _)) = listener.accept().await {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
|
||||||
|
let (read, mut write) = stream.into_split();
|
||||||
|
let mut lines = tokio::io::BufReader::new(read).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
let _ = write.write_all(format!("echo: {line}\n").as_bytes()).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let world = create_world("sockets");
|
||||||
|
let wasm = toolbox_wasm().clone();
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let tcp = json!({"type": "tcp", "address": address, "text": "hi"});
|
||||||
|
// The candidate test expects the connection to fail.
|
||||||
|
let offline = source_with_test(&world, "offline", json!({"steps": [{"send": tcp, "expect": {"type": "error"}}]}));
|
||||||
|
assert_eq!(spawn_actor(&host, &mut mailbox, "net", &wasm, &offline).await.body["type"], "spawned");
|
||||||
|
|
||||||
|
let answer = ask(&host, &mut mailbox, "net", tcp).await;
|
||||||
|
assert_eq!(answer.body, json!({"type": "tcp", "line": "echo: hi"}));
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every message that arrives until the mailbox is quiet for `quiet`.
|
||||||
|
async fn drain(mailbox: &mut mpsc::Receiver<Envelope>, quiet: Duration) -> Vec<Envelope> {
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
while let Ok(Some(message)) = tokio::time::timeout(quiet, mailbox.recv()).await {
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tap_copies_every_routed_message_once() {
|
||||||
|
let world = create_world("tap");
|
||||||
|
let wasm = toolbox_wasm().clone();
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let source = passing_source(&world, "source");
|
||||||
|
spawn_type(&host, &mut mailbox, "tapped", &wasm, &source).await;
|
||||||
|
let subscribe = json!({"type": "subscribe", "actor": "tapped"});
|
||||||
|
assert_eq!(request(&host, &mut mailbox, subscribe).await.body["type"], "subscribed");
|
||||||
|
assert_eq!(request(&host, &mut mailbox, json!({"type": "tap"})).await.body["type"], "tapping");
|
||||||
|
|
||||||
|
let echo = json!({"type": "echo", "value": "hi"});
|
||||||
|
let echo_id = host.broker.send("console", Some("tapped"), None, None, echo.clone());
|
||||||
|
let publish = json!({"type": "publish", "value": "news"});
|
||||||
|
host.broker.send("console", Some("tapped"), None, None, publish);
|
||||||
|
let messages = drain(&mut mailbox, Duration::from_secs(2)).await;
|
||||||
|
let copies: Vec<&Value> = messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.from == "broker" && m.body["type"] == "tapped")
|
||||||
|
.map(|m| &m.body)
|
||||||
|
.collect();
|
||||||
|
// 4 copies (2 requests, 1 reply, 1 publish) + the reply + the publish itself.
|
||||||
|
assert_eq!((copies.len(), messages.len()), (4, 6), "{messages:?}");
|
||||||
|
for copy in &copies {
|
||||||
|
assert!(copy["ts"].as_u64().unwrap() > 0, "{copy}");
|
||||||
|
}
|
||||||
|
let count = |want: Value| copies.iter().filter(|c| c["message"] == want).count();
|
||||||
|
let request_copy = json!({"id": echo_id, "from": "console", "to": "tapped", "reply_to": null, "body": echo});
|
||||||
|
assert_eq!(count(request_copy), 1, "{copies:?}");
|
||||||
|
let reply_copies = copies
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c["message"]["reply_to"] == echo_id.as_str() && c["message"]["from"] == "tapped" && c["message"]["to"] == "console")
|
||||||
|
.count();
|
||||||
|
assert_eq!(reply_copies, 1, "{copies:?}");
|
||||||
|
let publish_copy = copies.iter().find(|c| c["message"]["body"]["type"] == "published").expect("publish not tapped");
|
||||||
|
assert_eq!(publish_copy["message"]["from"], "tapped");
|
||||||
|
assert_eq!(publish_copy["message"]["to"], Value::Null);
|
||||||
|
|
||||||
|
assert_eq!(request(&host, &mut mailbox, json!({"type": "untap"})).await.body["type"], "untapped");
|
||||||
|
let echo_id = host.broker.send("console", Some("tapped"), None, None, json!({"type": "echo", "value": "quiet"}));
|
||||||
|
let messages = drain(&mut mailbox, Duration::from_secs(1)).await;
|
||||||
|
assert_eq!(messages.len(), 1, "{messages:?}");
|
||||||
|
assert_eq!(messages[0].reply_to.as_deref(), Some(echo_id.as_str()));
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tap_survives_a_restart() {
|
||||||
|
let world = create_world("tap-restart");
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
assert_eq!(request(&host, &mut mailbox, json!({"type": "tap"})).await.body["type"], "tapping");
|
||||||
|
host.stop().await;
|
||||||
|
assert!(world.read_meta("console").tapped);
|
||||||
|
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
let id = host.broker.send("console", Some("nobody"), None, None, json!({"type": "hello"}));
|
||||||
|
let copy = tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
|
loop {
|
||||||
|
let m = mailbox.recv().await.expect("console mailbox closed");
|
||||||
|
if m.body["type"] == "tapped" && m.body["message"]["id"] == id.as_str() {
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("no tapped copy after the restart");
|
||||||
|
assert_eq!(copy.body["message"]["to"], "nobody");
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_full_tapper_mailbox_drops_copies_without_blocking() {
|
||||||
|
let world = create_world("tap-full");
|
||||||
|
let (host, mut mailbox) = start(&world).await;
|
||||||
|
// "ghost" taps and never reads its mailbox.
|
||||||
|
let mut ghost = host.mailbox("ghost");
|
||||||
|
let mut sink = host.mailbox("sink");
|
||||||
|
host.broker.send("ghost", Some("broker"), None, None, json!({"type": "tap"}));
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
|
while !world.read_meta("ghost").tapped {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("tap was not saved");
|
||||||
|
|
||||||
|
let total = sausage::broker::MAILBOX_SIZE + 500;
|
||||||
|
for n in 0..total {
|
||||||
|
host.broker.send("console", Some("sink"), None, None, json!({"type": "n", "n": n}));
|
||||||
|
assert_eq!(sink.try_recv().unwrap().body["n"], n);
|
||||||
|
}
|
||||||
|
assert!(mailbox.try_recv().is_err(), "a dropped copy reported something to the sender");
|
||||||
|
let mut held = 0;
|
||||||
|
while ghost.try_recv().is_ok() {
|
||||||
|
held += 1;
|
||||||
|
}
|
||||||
|
assert_eq!(held, sausage::broker::MAILBOX_SIZE);
|
||||||
|
host.broker.send("console", Some("sink"), None, None, json!({"type": "after"}));
|
||||||
|
assert_eq!(ghost.try_recv().unwrap().body["type"], "tapped");
|
||||||
|
host.stop().await;
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "bench"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
serde_json = "1"
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
//! {"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,6 +0,0 @@
|
|||||||
//! 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,15 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "fetch"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
serde_json = "1"
|
|
||||||
wstd = "0.6"
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
//! 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;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
//! 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+115
@@ -0,0 +1,115 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notes"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"sausage-actor",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sausage-actor"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.154"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
@@ -4,4 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
sausage-actor = { version = "0.1", path = "../../sdk" }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
//! Keeps a list of notes in /data/notes.json and publishes them as
|
//! Version 2 adds a note count and fixes the crash command.
|
||||||
//! /public/notes.txt. Version 2: adds `count` and no longer crashes.
|
use sausage_actor::{json, messages, reply};
|
||||||
use serde_json::{Value, json};
|
|
||||||
use std::io::{BufRead, Write};
|
|
||||||
|
|
||||||
const DATA: &str = "/data/notes.json";
|
const DATA: &str = "/data/notes.json";
|
||||||
|
|
||||||
@@ -13,27 +11,21 @@ fn save(notes: &[String]) {
|
|||||||
fn main() {
|
fn main() {
|
||||||
let mut notes: Vec<String> = std::fs::read_to_string(DATA)
|
let mut notes: Vec<String> = std::fs::read_to_string(DATA)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| serde_json::from_str(&s).ok())
|
.and_then(|text| serde_json::from_str(&text).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let mut out = std::io::stdout().lock();
|
for message in messages() {
|
||||||
for line in std::io::stdin().lock().lines() {
|
let body = &message.body;
|
||||||
let Ok(line) = line else { break };
|
let response = match message.kind() {
|
||||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
"add" => {
|
||||||
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());
|
notes.push(body["text"].as_str().unwrap_or_default().to_string());
|
||||||
save(¬es);
|
save(¬es);
|
||||||
json!({"type": "added", "count": notes.len()})
|
json!({"type": "added", "count": notes.len()})
|
||||||
}
|
}
|
||||||
Some("list") => json!({"type": "notes", "items": notes}),
|
"list" => json!({"type": "notes", "items": notes}),
|
||||||
Some("count") => json!({"type": "count", "count": notes.len(), "version": 2}),
|
"count" => json!({"type": "count", "count": notes.len(), "version": 2}),
|
||||||
// Version 2 fixes the crash.
|
"crash" => json!({"type": "not_crashing", "version": 2}),
|
||||||
Some("crash") => json!({"type": "not_crashing", "version": 2}),
|
|
||||||
_ => json!({"type": "error", "reason": "unknown message"}),
|
_ => json!({"type": "error", "reason": "unknown message"}),
|
||||||
};
|
};
|
||||||
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
|
let _ = reply(&message, response);
|
||||||
out.flush().unwrap();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"description": "version 2 counts notes and no longer crashes",
|
||||||
|
"steps": [
|
||||||
|
{"send": {"type": "add", "text": "test note"}, "expect": {"type": "added"}},
|
||||||
|
{"send": {"type": "list"}, "expect": {"type": "notes"}},
|
||||||
|
{"send": {"type": "count"}, "expect": {"type": "count", "version": 2}},
|
||||||
|
{"send": {"type": "crash"}, "expect": {"type": "not_crashing"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{"send": {"type": "add", "text": "test note"}}
|
|
||||||
{"expect": {"type": "added"}}
|
|
||||||
{"send": {"type": "list"}}
|
|
||||||
{"expect": {"type": "notes"}}
|
|
||||||
{"send": {"type": "count"}}
|
|
||||||
{"expect": {"type": "count", "version": 2}}
|
|
||||||
{"send": {"type": "crash"}}
|
|
||||||
{"expect": {"type": "not_crashing"}}
|
|
||||||
Generated
+115
@@ -0,0 +1,115 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notes"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"sausage-actor",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sausage-actor"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.154"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
@@ -4,4 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
sausage-actor = { version = "0.1", path = "../../sdk" }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
|||||||
+10
-17
@@ -1,7 +1,5 @@
|
|||||||
//! Keeps a list of notes in /data/notes.json and publishes them as
|
//! Keep notes in `/data/notes.json` and publish them at `/public/notes.txt`.
|
||||||
//! /public/notes.txt.
|
use sausage_actor::{json, messages, reply};
|
||||||
use serde_json::{Value, json};
|
|
||||||
use std::io::{BufRead, Write};
|
|
||||||
|
|
||||||
const DATA: &str = "/data/notes.json";
|
const DATA: &str = "/data/notes.json";
|
||||||
|
|
||||||
@@ -13,25 +11,20 @@ fn save(notes: &[String]) {
|
|||||||
fn main() {
|
fn main() {
|
||||||
let mut notes: Vec<String> = std::fs::read_to_string(DATA)
|
let mut notes: Vec<String> = std::fs::read_to_string(DATA)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| serde_json::from_str(&s).ok())
|
.and_then(|text| serde_json::from_str(&text).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let mut out = std::io::stdout().lock();
|
for message in messages() {
|
||||||
for line in std::io::stdin().lock().lines() {
|
let body = &message.body;
|
||||||
let Ok(line) = line else { break };
|
let response = match message.kind() {
|
||||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
"add" => {
|
||||||
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());
|
notes.push(body["text"].as_str().unwrap_or_default().to_string());
|
||||||
save(¬es);
|
save(¬es);
|
||||||
json!({"type": "added", "count": notes.len()})
|
json!({"type": "added", "count": notes.len()})
|
||||||
}
|
}
|
||||||
Some("list") => json!({"type": "notes", "items": notes}),
|
"list" => json!({"type": "notes", "items": notes}),
|
||||||
Some("crash") => panic!("asked to crash"),
|
"crash" => panic!("asked to crash"),
|
||||||
_ => json!({"type": "error", "reason": "unknown message"}),
|
_ => json!({"type": "error", "reason": "unknown message"}),
|
||||||
};
|
};
|
||||||
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
|
let _ = reply(&message, response);
|
||||||
out.flush().unwrap();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"description": "add a note, then list the notes",
|
||||||
|
"steps": [
|
||||||
|
{"send": {"type": "add", "text": "test note"}, "expect": {"type": "added"}},
|
||||||
|
{"send": {"type": "list"}, "expect": {"type": "notes"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{"send": {"type": "add", "text": "test note"}}
|
|
||||||
{"expect": {"type": "added"}}
|
|
||||||
{"send": {"type": "list"}}
|
|
||||||
{"expect": {"type": "notes"}}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "ticker"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
serde_json = "1"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
//! 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+34
@@ -1,5 +1,23 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"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,
|
||||||
@@ -18,6 +36,7 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"rust-overlay": "rust-overlay"
|
"rust-overlay": "rust-overlay"
|
||||||
}
|
}
|
||||||
@@ -41,6 +60,21 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -1,26 +1,83 @@
|
|||||||
{
|
{
|
||||||
description = "Sausage: WASI actors with a message broker";
|
description = "Sausage: grow a system of WASI actors by talking to it";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||||
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { nixpkgs, rust-overlay, ... }:
|
outputs = { nixpkgs, flake-utils, rust-overlay, ... }:
|
||||||
|
flake-utils.lib.eachSystem flake-utils.lib.allSystems (system:
|
||||||
let
|
let
|
||||||
system = "x86_64-linux";
|
pkgs = import nixpkgs {
|
||||||
pkgs = import nixpkgs { inherit system; overlays = [ rust-overlay.overlays.default ]; };
|
inherit system;
|
||||||
|
overlays = [ rust-overlay.overlays.default ];
|
||||||
|
};
|
||||||
|
# The toolchain that builds the host and, at runtime, the actors.
|
||||||
rust = pkgs.rust-bin.stable.latest.default.override {
|
rust = pkgs.rust-bin.stable.latest.default.override {
|
||||||
targets = [ "wasm32-wasip2" ];
|
targets = [ "wasm32-wasip2" ];
|
||||||
extensions = [ "rust-src" ];
|
extensions = [ "rust-src" ];
|
||||||
};
|
};
|
||||||
in {
|
rustPlatform = pkgs.makeRustPlatform { cargo = rust; rustc = rust; };
|
||||||
devShells.${system}.default = pkgs.mkShell {
|
sausage = rustPlatform.buildRustPackage {
|
||||||
packages = [ rust pkgs.wasmtime pkgs.wasm-tools pkgs.pkg-config pkgs.openssl ];
|
pname = "sausage";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./.;
|
||||||
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
|
cargoBuildFlags = [ "-p" "sausage" ];
|
||||||
|
doCheck = false;
|
||||||
|
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||||
|
# The `cargo` actor runs cargo at runtime. Build scripts need a C linker.
|
||||||
|
postInstall = ''
|
||||||
|
wrapProgram $out/bin/sausage --prefix PATH : ${pkgs.lib.makeBinPath [ rust pkgs.stdenv.cc ]}
|
||||||
|
'';
|
||||||
|
meta.mainProgram = "sausage";
|
||||||
|
};
|
||||||
|
# `nix run .#loop -- <world>` runs the newest `sausage run <world>`
|
||||||
|
# from the public repo, again and again: after a crash or a broker
|
||||||
|
# `exit` request it refreshes the flake and starts the host again,
|
||||||
|
# so the world updates itself. `/quit` (exit code 0) ends the loop.
|
||||||
|
# SAUSAGE_FLAKE overrides where it fetches from (any flake URL,
|
||||||
|
# e.g. a fork or a local checkout).
|
||||||
|
repoUrl = "git+https://gitea.surma.technology/surma/sausage";
|
||||||
|
loop = pkgs.writeShellScriptBin "sausage-loop" ''
|
||||||
|
url="''${SAUSAGE_FLAKE:-${repoUrl}}"
|
||||||
|
if [ $# -eq 0 ]; then set -- world; fi
|
||||||
|
while true; do
|
||||||
|
nix run --refresh "$url#default" -- run "$@"
|
||||||
|
code=$?
|
||||||
|
if [ "$code" -eq 0 ]; then exit 0; fi
|
||||||
|
echo "sausage-loop: sausage exited with $code, starting the newest version in 2 s …" >&2
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages.default = sausage;
|
||||||
|
|
||||||
|
# `nix flake check` runs every test of the host, the SDK, and the
|
||||||
|
# agent. The tests build WASM actors with cargo, offline, from the
|
||||||
|
# vendored crates of Cargo.lock.
|
||||||
|
checks.tests = sausage.overrideAttrs (old: {
|
||||||
|
pname = "sausage-tests";
|
||||||
|
doCheck = true;
|
||||||
|
cargoTestFlags = [ "-p" "sausage" "-p" "sausage-actor" "-p" "agent" ];
|
||||||
|
preCheck = ''
|
||||||
|
# Its own lock file pins crates that the vendored set may lack.
|
||||||
|
rm -f cli/tests/actors/toolbox/Cargo.lock
|
||||||
|
'';
|
||||||
|
});
|
||||||
|
|
||||||
|
apps.default = { type = "app"; program = "${sausage}/bin/sausage"; };
|
||||||
|
apps.loop = { type = "app"; program = "${loop}/bin/sausage-loop"; };
|
||||||
|
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
packages = [ rust pkgs.wasmtime pkgs.wasm-tools ];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export CARGO_HOME="$PWD/.cargo-home"
|
export CARGO_HOME="$PWD/.cargo-home"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "sausage-actor"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "SDK for Sausage actors."
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
async = ["dep:async-channel", "dep:wstd"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-channel = { version = "2", optional = true }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
wstd = { version = "0.6", optional = true }
|
||||||
+229
@@ -0,0 +1,229 @@
|
|||||||
|
//! Small runtime helpers for Sausage actors.
|
||||||
|
|
||||||
|
pub use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io::BufRead;
|
||||||
|
|
||||||
|
/// A message that an actor receives from the broker.
|
||||||
|
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||||
|
pub struct Message {
|
||||||
|
/// The message ID.
|
||||||
|
pub id: String,
|
||||||
|
/// The ID of the sending actor.
|
||||||
|
pub from: String,
|
||||||
|
/// The ID of the message that this message answers.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reply_to: Option<String>,
|
||||||
|
/// The message body.
|
||||||
|
pub body: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Message {
|
||||||
|
/// Return the body's `type` string, or an empty string when it is missing.
|
||||||
|
pub fn kind(&self) -> &str {
|
||||||
|
self.body["type"].as_str().unwrap_or("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(line: &[u8]) -> Option<Message> {
|
||||||
|
serde_json::from_slice::<Message>(line).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_shutdown(message: &Message) -> bool {
|
||||||
|
message.from == "broker" && message.kind() == "shutdown"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return this actor's ID from `SAUSAGE_ACTOR`, or an empty string if unset.
|
||||||
|
pub fn me() -> String {
|
||||||
|
std::env::var("SAUSAGE_ACTOR").unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read messages from stdin until EOF or a shutdown message from the broker.
|
||||||
|
///
|
||||||
|
/// Invalid JSON lines are skipped.
|
||||||
|
pub fn messages() -> impl Iterator<Item = Message> {
|
||||||
|
let stdin = std::io::stdin();
|
||||||
|
let mut lines = stdin.lock().lines();
|
||||||
|
std::iter::from_fn(move || {
|
||||||
|
loop {
|
||||||
|
let Ok(line) = lines.next()? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(message) = parse(line.as_bytes()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
return (!is_shutdown(&message)).then_some(message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a message to `to` and return its full ID (`<actor>/<own-id>`).
|
||||||
|
pub fn send(to: &str, body: Value) -> String {
|
||||||
|
let id = next_id();
|
||||||
|
write_message(json!({"to": to, "id": id, "body": body}));
|
||||||
|
format!("{}/{id}", me())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reply to `message` and return the new message's full ID.
|
||||||
|
pub fn reply(message: &Message, body: Value) -> String {
|
||||||
|
let id = next_id();
|
||||||
|
write_message(json!({
|
||||||
|
"to": message.from,
|
||||||
|
"id": id,
|
||||||
|
"reply_to": message.id,
|
||||||
|
"body": body,
|
||||||
|
}));
|
||||||
|
format!("{}/{id}", me())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a file so that readers see either the old or the new content,
|
||||||
|
/// never half of it: write a temporary file next to `path`, then rename it.
|
||||||
|
pub fn write_atomic(path: impl AsRef<std::path::Path>, contents: impl AsRef<[u8]>) -> std::io::Result<()> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
let tmp = path.with_file_name(format!(".{}.tmp-{}", path.file_name().unwrap_or_default().to_string_lossy(), next_id()));
|
||||||
|
std::fs::write(&tmp, contents)?;
|
||||||
|
std::fs::rename(&tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish a message to this actor's subscribers and return its full ID.
|
||||||
|
pub fn publish(body: Value) -> String {
|
||||||
|
let id = next_id();
|
||||||
|
write_message(json!({"id": id, "body": body}));
|
||||||
|
format!("{}/{id}", me())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_message(message: Value) {
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
let mut stdout = std::io::stdout().lock();
|
||||||
|
writeln!(stdout, "{message}").unwrap();
|
||||||
|
stdout.flush().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_id() -> String {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
static START: OnceLock<u128> = OnceLock::new();
|
||||||
|
static SEQUENCE: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
let start = START.get_or_init(|| {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
});
|
||||||
|
format!("{start}-{}", SEQUENCE.fetch_add(1, Ordering::Relaxed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An asynchronous inbox for actors that use HTTP or timers.
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
pub struct Inbox {
|
||||||
|
rx: async_channel::Receiver<Message>,
|
||||||
|
_reader: wstd::runtime::Task<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl Inbox {
|
||||||
|
/// Start the stdin reader inside the wstd runtime and return its inbox.
|
||||||
|
/// The inbox owns the reader task and must be dropped to cancel it.
|
||||||
|
pub fn start() -> Self {
|
||||||
|
let (tx, rx) = async_channel::unbounded();
|
||||||
|
let reader = wstd::runtime::spawn(read_messages(tx));
|
||||||
|
Self { rx, _reader: reader }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for the next message, or return `None` at EOF or shutdown.
|
||||||
|
pub async fn recv(&self) -> Option<Message> {
|
||||||
|
self.rx.recv().await.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the next available message without waiting.
|
||||||
|
pub fn try_recv(&self) -> Option<Message> {
|
||||||
|
self.rx.try_recv().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return whether the reader closed the inbox, even when messages remain buffered.
|
||||||
|
pub fn is_closed(&self) -> bool {
|
||||||
|
self.rx.is_closed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
async fn read_messages(tx: async_channel::Sender<Message>) {
|
||||||
|
use wstd::io::AsyncRead;
|
||||||
|
|
||||||
|
let mut stdin = wstd::io::stdin();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut chunk = [0; 8192];
|
||||||
|
loop {
|
||||||
|
let Ok(n) = stdin.read(&mut chunk).await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&chunk[..n]);
|
||||||
|
while let Some(pos) = buf.iter().position(|b| *b == b'\n') {
|
||||||
|
let message = parse(&buf[..pos]);
|
||||||
|
buf.drain(..=pos);
|
||||||
|
let Some(message) = message else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if is_shutdown(&message) {
|
||||||
|
tx.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if tx.send(message).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kind_reads_body_type() {
|
||||||
|
let message: Message = serde_json::from_value(json!({
|
||||||
|
"id": "broker/1",
|
||||||
|
"from": "broker",
|
||||||
|
"body": {"type": "ping"}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(message.kind(), "ping");
|
||||||
|
assert_eq!(message.reply_to, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kind_is_empty_when_type_is_missing() {
|
||||||
|
let message: Message = serde_json::from_value(json!({
|
||||||
|
"id": "broker/1",
|
||||||
|
"from": "broker",
|
||||||
|
"body": {}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(message.kind(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_atomic_replaces_the_file() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("sausage-actor-{}", next_id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("state.json");
|
||||||
|
write_atomic(&path, "old").unwrap();
|
||||||
|
write_atomic(&path, "new").unwrap();
|
||||||
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
|
||||||
|
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_ids_differ() {
|
||||||
|
assert_ne!(next_id(), next_id());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user