Files
sausage/PROTOCOL.md
T
surma 9d575b6a62 Host: fixes from the review, with regression tests
- one live instance per actor: check after the lock; dropping an instance kills it
- state snapshots keep target/ and .git/; only source copies skip them
- one shutdown deadline; output during shutdown is still routed
- deliveries route stdout meanwhile; a stuck stdin kills the instance
  (no partial lines) and the sender gets undeliverable; control requests
  are not blocked by a stuck delivery
- Host::stop aborts running broker and cargo requests first
- compile a version before installing it; roll back if it does not start
- unique archive names; a failed archive keeps the actor manageable
- commit on close: truncate without create; directory renames
- candidates see SAUSAGE_VERSION and the existing state of a new actor
- the host publishes PROTOCOL.md at /actor/broker/public/
2026-10-15 12:03:26 +01:00

146 lines
5.8 KiB
Markdown

# The Sausage protocol
This document 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
- **outgoing HTTP and HTTPS** through `wasi:http` (no raw sockets)
- **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"`.
- `to` is the recipient. Without `to`, the broker publishes the message to
every subscriber of the sender.
- `id` is optional. 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. There is no request/response: a reply is a new
message whose `reply_to` is the ID of the message it answers.
- Delivery is at most once. Each actor has a mailbox of 1,000 messages. The
mailbox 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 a message waits 30 seconds for the
actor to read it, the broker kills and restarts the actor, and the sender
gets `undeliverable`.
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 | private state of the actor |
| `/public` | read-write | files that the actor publishes |
| `/actor` | read-only | the `actor/` folder of the world |
`/actor/<id>/` contains `actor.wasm`, `actor.json`, `source/`, `public/`,
`status.json`, `stderr.log`, and `versions/<n>/` of every actor. The `data/`
folders (and the data snapshots in `versions/`) are hidden.
A new or truncated file becomes visible when the writer closes it. Before
that, readers see the old file or no file. If the instance crashes, the write
is lost. A write to an existing file without truncate (append, edit) goes in
place.
Send large or binary data as a path under `/actor/<id>/public/`, not inside a
message.
## The broker
Requests to `broker` (the reply has the same `reply_to`):
| `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 and its data |
| `stop`, `start` | `actor` | stop or start an actor |
| `remove` | `actor` | move the actor's folder to `archive/` |
| `subscribe`, `unsubscribe` | `actor` | receive (or not) what `actor` publishes |
| `scan` | | boot new or changed `actor.wasm` files |
| `list` | | all actors and their state |
Paths: `/actor/...`, `/public/...` (the sender's), or `/data/...` (the
sender's).
**Versions.** `versions/<n>/` keeps every version that the broker saw:
`actor.wasm`, `source/`, and `meta.json`. A version that the broker refused
has `"rejected"` in its `meta.json`.
**Tests.** Before a spawn or an upgrade, the broker runs the candidate on a
copy of the actor's `/data` and `/public` (empty for a new actor), without
HTTP. The candidate sees its future `SAUSAGE_VERSION` and
`SAUSAGE_CANDIDATE=1`. The tests are JSON
files in `source/tests/*.json`:
```json
{
"description": "add, then list",
"steps": [
{"send": {"type": "add", "text": "milk"}, "expect": {"type": "added"}},
{"send": {"type": "list"}, "expect": [{"type": "notes"}]}
]
}
```
An `expect` entry matches when it is a subset of the reply body. A step can
also have `from` (the sender, default `test`) and `to` (the expected
recipient). Each file runs in a fresh instance. Without test files, the
candidate must stay up for 2 seconds.
**Swap.** The broker holds the mailbox, stops the old instance, saves a
snapshot of `/data` and `/public` in the old version's folder, installs the new
files, and starts the new instance.
**Crashes.** The broker restarts a crashed actor (0.5 s, then doubling, at
most 30 s). It keeps the message that the actor got just before the crash,
and delivers it again after the next upgrade. A new version that crashes 3
times within 30 seconds after an upgrade goes back to the previous version,
with its data. The actor that asked for the upgrade gets `rolled_back`.
**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, the
broker copies it to `versions/<n>/` and upgrades to it. If the upgrade fails,
the installed files come back.
## 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", ...}`
or `{"type": "build_failed", "errors": "..."}`. A Rust actor depends on the SDK
with `sausage-actor = "0.1"`. `cargo` points that dependency at the SDK of the
host.