18 KiB
The Sausage protocol
This is the contract between the core and the plugins. A plugin in any
language that follows it works in a Sausage world. The core shows this file
at /proc/core/PROTOCOL.md. DECISIONS.md in the repository says why the
contract is the way it is.
Rust plugins use the SDK sausage-plugin. Its source is at
/plugins/cargo/sdk/, and its crate docs explain each item.
1. Terms
| Term | Meaning |
|---|---|
| world | The folder that a sausage host runs. It is / for every process. |
| core | The host. It runs processes, routes calls, serves the virtual files, and keeps the journal. |
| plugin | A folder /plugins/<name>/ with a plugin.wasm. A plugin does not run by itself. |
| process | One run of a plugin version. Its ID is <plugin>-<uuidv7>, for example notes-0197f3a2-7c1e-7d2b-9a41-5f0c2e8b1d34. |
| call | A JSON-RPC request. notes.add calls the method add of the plugin notes. |
| interface | A set of methods. A plugin has one main interface. It can also declare endpoint interfaces. |
| endpoint | An interface that a running process serves on a TCP port. An endpoint file tells others where it is. |
| service | A call in /config/boot.json. The core starts it at boot and again after an upgrade of its plugin. |
| import | An interface of another plugin that a plugin uses. |
| provider, consumer | The plugin that has an interface, and a plugin that imports it. |
| journal | The central log of the world. |
2. The world folder
<world>/
plugins/<name>/
plugin.wasm the current version
plugin.prev.wasm the version before, for `core.revert`
source/ the source. `cargo.build` builds it from here.
imports/<plugin>.json descriptions of the imported plugins
state/<plugin>/ persistent state of a plugin, by convention
state/core/journal.sqlite the journal
config/ global configuration
boot.json the services
agent.json API, base URL, model, and token of the agent
- Every process can read and write all of it. There are no permissions.
- Plugin names use
a-z,0-9,-, and_. The namecoreis reserved. - The world folder must not contain
proc,run, orself. These names are mount points (section 4). The host does not start a world that has them.
3. Calls and processes
3.1 The wire protocol
- A plugin is a
wasi:cli/commandcomponent forwasm32-wasip2. - stdin and stdout carry JSON-RPC 2.0, one message per line.
- A line is a message only if it is a JSON object with
"jsonrpc": "2.0". The core discards every other line on stdout. For the first discarded line of a process, it writes one warning to the journal. - Each line on stderr goes to the journal. Log to stderr.
paramsis always an object. Batches are not supported.- Write each message in one write, and write a newline before it. Then a
print!without a newline cannot damage a message.
3.2 Method names
- A method name has the form
<target>.<method>. The core splits it at the first.. notes.addstarts a new process ofnotesand sends it the requestadd.notes.rpc.discoversends itrpc.discover.core.<method>is a method of the core (section 9).- Method names that start with
rpc.are reserved:rpc.discover,rpc.retired, andrpc.endpoint.
3.3 What a call does
- The core gives the call a UUIDv7 and starts the process
<plugin>-<uuid>with the current version of the plugin. - The core writes the request to the stdin of the process. The request ID is the process ID, and the method has no plugin prefix.
- The process writes the response to stdout. The core sends the response to the caller, with the caller's request ID.
- If the process ends before it answers, the caller gets the error -32002.
Its
datahas the exit status and the last journal lines of the process. Before the core decides this, it reads the stdout of the process to the end.
- A process can write calls to stdout at any time, to the core and to plugins. The responses come on stdin. A process can have many calls in flight.
- The core keys each pending request by the process and the exact JSON ID. The core drops a response with an unknown or late ID.
- A notification to
plugin.method(without an ID) starts an attached process. That process gets the notification without an ID, and nobody gets a result.
3.4 Detached calls
core.call {"method": "www.start", "params": {...}, "detach": true} makes the
call and returns its result to the caller. The new process has no parent, so
it does not end with its caller.
Sent as a notification (without an ID), the same core.call starts the
process and forgets it.
3.5 Notifications to the caller
A process can tell its caller about progress. It sends the notification
core.notify {"name": "text", "params": {...}}. The core gives it to the
caller as core.notify {"call": <ID of the caller's request>, "name": "text", "params": {...}}. After the call has ended, the core drops such
notifications.
So a process never needs to know who called it.
3.6 Lifetime
A process lives until main returns. A process that uses the SDK lives
while one of these runs:
- a handler of a call,
- a background task,
- an endpoint that listens.
A process has three states:
- running
- stopping: the core has closed stdin. EOF on stdin means "stop now".
- New calls of the process fail at once, and its pending calls fail.
- A stop hook can still write files and send notifications. It cannot make calls, because no response can arrive.
- After 5 seconds, the core kills the process.
- ended: the core releases the locks of the process (section 6) and
removes its folder in
/proc.
Rules:
core.stop {pid}starts the stopping state.core.kill {pid}kills the process at once.- When a process ends, the core stops each of its attached children. All children stop in parallel, so a whole tree takes 5 seconds at most.
- A detached process has no parent. Only
core.stop,core.kill, an upgrade (section 3.7), and the end of the host stop it. - At host shutdown, the core stops every process in parallel.
Note: on Unix, children outlive their parent. Here, an attached child ends
with its parent. Only detach makes a process independent.
3.7 Upgrades
A process is an instance of one plugin version. When a new version becomes active (section 10.3):
- New calls get the new version at once.
- Each process of an older version gets a grace period to finish its work.
The default is 60 seconds. A plugin can declare its own value in
rpc.discover(x-sausage-grace). Then the core stops the process. - When the old process of a service has ended, the core starts the service again with the new version.
- When the last process of an older version has ended, the core starts a
process of the new version and calls
rpc.retired {"previous": "<sha256 of the older file>"}. A plugin can use this hook to migrate its state, or to write a marker that its other new processes wait for. The result goes to the journal.
During the grace period, an old process can call a method that a new provider no longer has.
3.8 Environment and network
SAUSAGE_PID: the process ID.SAUSAGE_PLUGIN: the plugin name.SAUSAGE_PARENT: the parent:console,core, or a process ID.
Every process gets the network: outgoing HTTP and HTTPS through wasi:http,
and TCP, UDP, and DNS through wasi:sockets.
A process that computes for a long time does not block other processes. The core interrupts it every 10 milliseconds.
3.9 Errors
The standard JSON-RPC codes apply. The core adds these codes:
| Code | Meaning |
|---|---|
| -32001 | There is no such plugin. |
| -32002 | The process ended without an answer. data has the exit status and the last journal lines. |
| -32003 | The activation was rejected. data has the reasons. |
4. The filesystem
Each process gets one preopen, /. The core resolves each path through a
mount table:
| Path | What it is | Access |
|---|---|---|
/ |
the world folder | read-write |
/proc |
generated by the core | read-only |
/self |
the folder /proc/<own pid>/, in memory |
read-write |
/run |
in memory, shared, empty at each host start | read-write |
A listing of / shows proc, run, and self. A rename across two of
these areas fails.
/proc/
core/
PROTOCOL.md this file
graph.json plugins, imports, and the result of each check
graph.dot the same graph for Graphviz
locks.json held locks and waiting processes
plugins/<name>/
discover.json the rpc.discover document of the current plugin.wasm
client.rs the Rust code that import!("<name>") generates
<pid>/
info.json plugin, version, method, params, parent, detached, start time, state
log the journal lines of this process
<files> the files that the process writes to /self
Rules:
- A process writes its own folder only through
/self. Writes through/procalways fail. - In a process folder, the names
info.json,log, andfsbelong to the core. A write to/self/info.jsonfails. - A read of a generated file returns a snapshot that ends. For example,
/proc/<pid>/logreturns the lines up to the time of the open. - The folder of a process goes away when the process ends. Its files are in
memory. Large or persistent data belongs in
/state. - Do not send a
/selfpath to another process. There it means a different folder, likelocalhoston another machine.
Principle: inspect through files, act through calls.
5. Endpoints
An endpoint is a JSON-RPC interface that a running process serves over TCP, for example the control interface of a web server.
- The process binds a free port on 127.0.0.1. When the listener is ready, it
writes an endpoint file in one atomic step (write a temporary file, then
rename it). The file has one line: the address, the owner process, and the
interface. Example:
tcp:127.0.0.1:41234 www-0197… control. - Usually the file is in
/self. A service that others must find uses a fixed path in/run, for example/run/secrets. - A client reads the file and connects. Its first request is
rpc.endpoint, which returns{"owner", "plugin", "interface"}. If they do not match the file, the client disconnects. So a stale file or a reused port cannot connect a client to the wrong process. - JSON-RPC runs on the connection, one message per line, in both directions.
Notifications of a handler (
core.notify) go over the connection. - A plugin declares its endpoint interfaces in
rpc.discover(x-sausage-endpoints).
6. Locks
core.lock {"path": "/state/notes/notes.json"}returns{"held": true}when the caller holds the lock.- Each acquisition gets its own place in a FIFO queue. The same process cannot take one lock twice: its second acquisition waits behind its first.
- With
"wait": false, the call returns at once with{"held": true}or{"held": false}. core.unlock {path}(a notification) releases the lock.- When a process ends, the core releases all of its locks, also after a crash or a kill. It waits until the last file operation of the process has returned. So a write of a killed process cannot land after the next process got the lock.
- Locks are advisory. The core does not block writes to the file: the path is only a name.
- SQLite has no file locks in WASI. Hold the lock while the database is open.
7. The journal
- An SQLite file at
/state/core/journal.sqlite. Only the core writes it. - Each entry has a UUIDv7 (it sorts by time), the process ID, the plugin, the
stream (
stderrorcore), and the line. - The core writes its own events too: start (with method and params), exit status, stop, kill, upgrades, and the warning about discarded stdout.
core.log {plugin?, pid?, since?, until?, grep?, limit?}queries the journal./proc/<pid>/logshows the lines of one running process.
8. Boot and services
At start, the core starts each service in /config/boot.json, in order:
[
{"method": "secrets.serve", "params": {}},
{"method": "www.start", "params": {"port": 8080}}
]
- A service is a detached call with the parent
core. The core waits for its result before it starts the next one. The result shows that the service is ready. - If a service fails, the core writes the error to the journal and continues.
- After an upgrade of the plugin of a service, the core starts the service again with the new version (section 3.7).
- The core does not restart other detached processes.
- A singleton is a service with a fixed endpoint in
/run. Only the core starts it, so two instances cannot start at the same time.
9. The core API
| Method | Params | Result |
|---|---|---|
core.call |
method, params, detach? |
the result of the method |
core.notify |
name, params |
none (a notification) |
core.stop |
pid |
{} when the process has ended |
core.kill |
pid |
{} |
core.lock |
path, wait? |
{"held": bool} |
core.unlock |
path |
none (a notification) |
core.activate |
plugin and wasm, or plugins: [{plugin, wasm}]. force? |
the new rpc.discover documents |
core.revert |
plugin, force? |
like core.activate |
core.log |
filters (section 7) | journal entries |
core.exit |
none | {}. Then the host stops with exit code 75. |
Everything else is a file.
10. Interfaces
10.1 rpc.discover
Each plugin answers rpc.discover with an OpenRPC 1.2 document. The schemas
are JSON Schema Draft 7, as OpenRPC requires. Shared schemas are in
components.schemas, and $refs point to #/components/schemas/<Name>.
Params are by name. The document can have these extension fields:
x-sausage-paramsfor each method: a$refto the schema of the params object.x-sausage-endpoints: the endpoint interfaces by name, each with itsmethods.x-sausage-imports: for each provider, the part of its document that the plugin uses.x-sausage-grace: the grace period in seconds.
The schema of params describes what the provider can read. The schema of a result describes what the provider writes. Descriptions are part of the interface: other agents read them to use it.
The core starts a process only to call rpc.discover. Do not do expensive
work at start.
10.2 Compatibility
A provider can add methods, optional params, and result fields. For each imported method:
- The provider has the method.
- Each params value that the consumer can send is valid for the provider.
- Each result that the provider can return is valid for the consumer.
- The same rules apply to endpoint interfaces.
- There are no version numbers. This check replaces them.
- The check covers a fixed subset of JSON Schema: objects with
propertiesandrequired, the primitive types, arrays, optional and nullable values, string enums, integer ranges, and$ref. A construct outside the subset fails the check. - The check covers calls, not state. Old and new processes share the files in
/state, andcore.revertrestores only code. A plugin must keep its state format compatible, or migrate it inrpc.retired.
10.3 Activation
core.activate {"plugin": "notes", "wasm": "/state/cargo/builds/7/notes.wasm"}:
- The core copies the file. All further steps use this copy, even if the file changes.
- It starts the copy and calls
rpc.discover. - It waits until it has the documents of all plugins (section 10.4).
- It checks the imports of the new version against the current providers.
- It checks each current consumer of the plugin against the new version.
- If all checks pass,
plugin.wasmbecomesplugin.prev.wasm, and the copy becomesplugin.wasm. Then the upgrade starts (section 3.7).
If a check fails, nothing changes, and the error -32003 lists each problem, with a counterexample where possible.
- The core runs one activation at a time.
plugins: [...]activates several plugins together. The checks then use the new set.force: trueskips steps 4 and 5.core.revert {plugin}activatesplugin.prev.wasmin the same way, and the current version becomesplugin.prev.wasm.- A person can also copy a file to
plugin.wasmby hand. That skips the checks.
10.4 The graph
- The core keeps the
rpc.discoverdocuments of all plugins, by file hash. At start, it gets them in the background. - A file watcher on
/plugins/*/plugin.wasmupdates them, for example after a copy by hand./proc/core/graph.jsonthen shows each broken import, and the journal records it. - An import of a plugin that does not exist is "missing".
11. Building and tests
The core has one native plugin: cargo. cargo.build {"plugin": "notes"}
builds /plugins/notes/source/:
- It copies the source once. The tests and the build both use this copy.
- It runs the tests under WASI.
- It builds the release
.wasmforwasm32-wasip2. - It returns
{"wasm": "/state/cargo/builds/<n>/notes.wasm", "tests": {"passed", "failed", "output"}, "warnings"}. If a step fails, it returns an error with the compiler or test output.
- A call to
cargo.buildgets a process ID, a folder in/proc, and journal entries, like other calls. - The dependency
sausage-plugin = "0.1"points to the SDK copy at/plugins/cargo/sdk/. - Build scripts can compile C:
CC_wasm32_wasip2andAR_wasm32_wasip2point to a WASI clang. - The build does not activate the result. Call
core.activatenext.
Tests:
- Each test runs in its own instance, so a panic ends only its own test.
- The normal Rust test setup works:
#[test],tests/*.rs, name filters, and--ignored.#[should_panic]cannot work, because WASI cannot catch a panic. - Each test gets an empty temporary world as
/, the package folder at its path, and its own/proc,/self, and/run. - The test runner answers
core.lock,core.unlock,core.notify, andcore.log. A call to a plugin fails: a test has no other plugins.