Compare commits
329
Commits
old-v2/main
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
079ede7abb | ||
|
|
80500b2294 | ||
|
|
426e6c7f13 | ||
|
|
a536da3afd | ||
|
|
d59c94a0af | ||
|
|
3ba35d3ce6 | ||
|
|
e61358e7aa | ||
|
|
aa5f25b64c | ||
|
|
8e4f32b543 | ||
|
|
b304fdb1c5 | ||
|
|
12c85f5807 | ||
|
|
196d92e305 | ||
|
|
3b91cec3ee | ||
|
|
aa8c8a2818 | ||
|
|
80791f76a6 | ||
|
|
cc3ff75a67 | ||
|
|
e7b7cf113c | ||
|
|
7de4bc9746 | ||
|
|
ce2631da96 | ||
|
|
d679b37ba5 | ||
|
|
2dd57cc59c | ||
|
|
e7674f89d7 | ||
|
|
b4b2fbe33e | ||
|
|
257959a574 | ||
|
|
2edc47958b | ||
|
|
ac75939b6c | ||
|
|
a46d67e4ff | ||
|
|
dd79b573f1 | ||
|
|
93968ac6db | ||
|
|
4e17bb381c | ||
|
|
7c0256a8a4 | ||
|
|
21e154381b | ||
|
|
6a22f374e2 | ||
|
|
03452c1aef | ||
|
|
e4b59c032d | ||
|
|
f6149aa0e0 | ||
|
|
5713b9be20 | ||
|
|
e46f208c72 | ||
|
|
f681b90753 | ||
|
|
b36c6a2e7f | ||
|
|
18967eefe2 | ||
|
|
cf1cce7f41 | ||
|
|
359ecef28c | ||
|
|
f21099013d | ||
|
|
b9b64fb616 | ||
|
|
bd0097f8a8 | ||
|
|
cf48ff5b3a | ||
|
|
237fc3268d | ||
|
|
d44f1d9677 | ||
|
|
03ad478596 | ||
|
|
b5df7530a0 | ||
|
|
a37e02b374 | ||
|
|
f998570fae | ||
|
|
c2a970581d | ||
|
|
a0bb71b175 | ||
|
|
9df5c034a9 | ||
|
|
b45a3595fb | ||
|
|
85080bb74e | ||
|
|
7d22f03b63 | ||
|
|
d6e3158b80 | ||
|
|
49e88246c7 | ||
|
|
0b79bb4227 | ||
|
|
2e80085451 | ||
|
|
2934e59fda | ||
|
|
cf6f71039f | ||
|
|
a74ebd11ba | ||
|
|
e0c2d8bc8b | ||
|
|
4d3b25d6aa | ||
|
|
4fc3bbdcad | ||
|
|
76ba7b9630 | ||
|
|
3628159cd0 | ||
|
|
01eb37bd9a | ||
|
|
ce5d236a76 | ||
|
|
6576005014 | ||
|
|
eb8ba02546 | ||
|
|
fc07894d52 | ||
|
|
5a99fcfa25 | ||
|
|
464c9c67f9 | ||
|
|
43a75b7c3a | ||
|
|
c3fd40768f | ||
|
|
6fbb74d46a | ||
|
|
ea155ef70b | ||
|
|
14cee48a35 | ||
|
|
429f7265fb | ||
|
|
7c4f078f38 | ||
|
|
68a4e5ff8e | ||
|
|
8581ded6a8 | ||
|
|
9b67b60395 | ||
|
|
8f47412330 | ||
|
|
e5e631d903 | ||
|
|
5007b18374 | ||
|
|
586bae953d | ||
|
|
0935b75a80 | ||
|
|
652ea7960f | ||
|
|
a8716f85e1 | ||
|
|
ae79f1f283 | ||
|
|
ea58ca0514 | ||
|
|
c7606b8ae9 | ||
|
|
f5e1d7aed5 | ||
|
|
b82a1ff70a | ||
|
|
d72e40356c | ||
|
|
feab7055ad | ||
|
|
8afab591fe | ||
|
|
fcfaff6018 | ||
|
|
420066952c | ||
|
|
3339e18485 | ||
|
|
e6c13f2518 | ||
|
|
bddc5a17d7 | ||
|
|
6091d61ee8 | ||
|
|
276295bd7b | ||
|
|
71da18bfe8 | ||
|
|
2a69d6350e | ||
|
|
3930c6de1a | ||
|
|
a41139a70a | ||
|
|
62283e7733 | ||
|
|
82ee68a4fb | ||
|
|
63ac29d026 | ||
|
|
05c21f0eaf | ||
|
|
80f345e70b | ||
|
|
d6fad1300a | ||
|
|
d797857c94 | ||
|
|
1b6cac325c | ||
|
|
67b99c50a9 | ||
|
|
e939978e48 | ||
|
|
3a9798d7c1 | ||
|
|
de351919dd | ||
|
|
e02a4783f1 | ||
|
|
8421800ab9 | ||
|
|
33d799d0a6 | ||
|
|
fdda49394f | ||
|
|
b38da84b31 | ||
|
|
8c958aa351 | ||
|
|
1892642d8e | ||
|
|
acf761372e | ||
|
|
3c8502f3af | ||
|
|
441d62f99f | ||
|
|
ad795d8528 | ||
|
|
1aaaf91f57 | ||
|
|
4f11096c88 | ||
|
|
ffee43b33c | ||
|
|
994ff9da55 | ||
|
|
c8ff644870 | ||
|
|
6ba35b0683 | ||
|
|
b003a799f9 | ||
|
|
ab5d2417cb | ||
|
|
9d46ff8d09 | ||
|
|
9f706afdb0 | ||
|
|
15f26c55bb | ||
|
|
2f45b357ae | ||
|
|
3134be3ebf | ||
|
|
a366350789 | ||
|
|
4ae1ded5b6 | ||
|
|
0ed5e7befe | ||
|
|
afbafe1d0c | ||
|
|
88f1078d9d | ||
|
|
78dbe78bb1 | ||
|
|
217ac3fe03 | ||
|
|
cab9ceb6f6 | ||
|
|
361cfffb32 | ||
|
|
84266611ba | ||
|
|
38ce64fc2f | ||
|
|
f8d04192c9 | ||
|
|
092eb4772e | ||
|
|
7bd8bfe4d2 | ||
|
|
8319228e7c | ||
|
|
bdbec68197 | ||
|
|
0e81e16f5c | ||
|
|
69d6040924 | ||
|
|
ba6304eb91 | ||
|
|
5dc4833e28 | ||
|
|
2d5449b3dc | ||
|
|
c3c5b36d09 | ||
|
|
dae8dfe179 | ||
|
|
01c609404c | ||
|
|
57f528c2bc | ||
|
|
a440a2bcdf | ||
|
|
ad31954890 | ||
|
|
15bf342e10 | ||
|
|
983fd0a234 | ||
|
|
1d06547936 | ||
|
|
4ac1024e1c | ||
|
|
2458f4d036 | ||
|
|
c42d4c03fb | ||
|
|
6ebf4211c5 | ||
|
|
fcab84012d | ||
|
|
c8bf5e559e | ||
|
|
5cba2d36aa | ||
|
|
f90637a765 | ||
|
|
29abe8347c | ||
|
|
3f5b6082e6 | ||
|
|
b29ae6bbce | ||
|
|
4f3133da9e | ||
|
|
02f6341bf7 | ||
|
|
841e4ba6b6 | ||
|
|
74c80acdf8 | ||
|
|
d63482f02e | ||
|
|
d014222ac4 | ||
|
|
d349d20524 | ||
|
|
66d5c7eea4 | ||
|
|
ca9bd813db | ||
|
|
a66a933dc4 | ||
|
|
c9d2dd0b7c | ||
|
|
fe9306c38b | ||
|
|
7ee4059414 | ||
|
|
88e8f7a454 | ||
|
|
6c58e73319 | ||
|
|
a9a7f0961b | ||
|
|
ee015fce70 | ||
|
|
edeb681fb4 | ||
|
|
d5de1ad7c9 | ||
|
|
5849b855c6 | ||
|
|
5254e26ca1 | ||
|
|
58894c1315 | ||
|
|
6200a2e7c8 | ||
|
|
e53211eff1 | ||
|
|
0442ab2dce | ||
|
|
118f54a55b | ||
|
|
912e229f72 | ||
|
|
4b3f256375 | ||
|
|
f3fd6b76ca | ||
|
|
7ce839e908 | ||
|
|
0be19f3f9c | ||
|
|
4cc8707a20 | ||
|
|
250b5ab9f7 | ||
|
|
2f18f0fa4c | ||
|
|
62feb38d44 | ||
|
|
f419231ac4 | ||
|
|
08068886a3 | ||
|
|
e003fd6b40 | ||
|
|
a27d579264 | ||
|
|
90a4e2c1ce | ||
|
|
b8b3166943 | ||
|
|
c96e59417a | ||
|
|
ad6aa97ee1 | ||
|
|
72a4c2d539 | ||
|
|
576658a0c7 | ||
|
|
dc6d90b0d3 | ||
|
|
d79b2bc2e1 | ||
|
|
44797f1302 | ||
|
|
7e7e49bfa2 | ||
|
|
d07db09d80 | ||
|
|
85735ccf19 | ||
|
|
d26d6a788a | ||
|
|
2fc1fe48ba | ||
|
|
8e7cb5061a | ||
|
|
b605717b4d | ||
|
|
bcb3e4d8e5 | ||
|
|
a26721194e | ||
|
|
f74b16dea9 | ||
|
|
94f8d6d30c | ||
|
|
c387561f40 | ||
|
|
9d40a3fd73 | ||
|
|
7503f093d0 | ||
|
|
534fa50615 | ||
|
|
b807d5f31e | ||
|
|
ff542f4946 | ||
|
|
f46e5a8478 | ||
|
|
5d63dcfb18 | ||
|
|
e1cfaa93bf | ||
|
|
2d38101870 | ||
|
|
ce9418f2e4 | ||
|
|
c8525c3acb | ||
|
|
9d11ab381e | ||
|
|
0dc97c7682 | ||
|
|
9cae803323 | ||
|
|
54229adad2 | ||
|
|
4dd6161091 | ||
|
|
c91ecbf7a4 | ||
|
|
aaf6d9b54f | ||
|
|
a2bc0545a4 | ||
|
|
17d67aadcc | ||
|
|
45d099c3e4 | ||
|
|
9517515efe | ||
|
|
bb162abd4d | ||
|
|
904415063a | ||
|
|
c500ecd281 | ||
|
|
65464069d3 | ||
|
|
7c7656c4a8 | ||
|
|
fb83e0c067 | ||
|
|
5a00cf3feb | ||
|
|
e332e76807 | ||
|
|
aeb14a0b70 | ||
|
|
47948d7047 | ||
|
|
6c9eccaa21 | ||
|
|
86c5d8a4e4 | ||
|
|
e7be031238 | ||
|
|
f234b2d1e2 | ||
|
|
4568b8fe54 | ||
|
|
944504117d | ||
|
|
5b3e1393f5 | ||
|
|
e33d3fac3b | ||
|
|
b3d91d37e3 | ||
|
|
e8732ede15 | ||
|
|
a75094054e | ||
|
|
b3d3558b31 | ||
|
|
940ae61af6 | ||
|
|
b0a651868f | ||
|
|
abe420f0d4 | ||
|
|
c70948efe2 | ||
|
|
16a9a9eb6c | ||
|
|
30dcd4c2ca | ||
|
|
091f679905 | ||
|
|
cd324678fe | ||
|
|
457a966068 | ||
|
|
f2df0ce362 | ||
|
|
3e33a85a6c | ||
|
|
4f9919d8dd | ||
|
|
b39bbd4f18 | ||
|
|
1d2eb894a2 | ||
|
|
11569119e6 | ||
|
|
99a8e3a8be | ||
|
|
a7e99d52f5 | ||
|
|
608d0c3fc1 | ||
|
|
6daa53cca4 | ||
|
|
9e7e5d7b2a | ||
|
|
1dc09bc418 | ||
|
|
b5462cc366 | ||
|
|
ec6e20a9d7 | ||
|
|
b9e3e3677d | ||
|
|
665b8a2a03 | ||
|
|
afff3ccda5 | ||
|
|
4df2c2e837 | ||
|
|
57a47b069e | ||
|
|
5ce2a2f2bd | ||
|
|
4b27e9afd5 | ||
|
|
0fae0f9b10 | ||
|
|
964ef978c2 | ||
|
|
409da85124 | ||
|
|
9e9cde5241 |
@@ -0,0 +1,7 @@
|
||||
# WASI tools share the startup object built by userland/cwd-init/build.nu.
|
||||
[env]
|
||||
SAUSAGE_CWD_INIT = { value = "userland/cwd-init/init.o", relative = true }
|
||||
|
||||
[target.wasm32-wasip2]
|
||||
linker = "userland/cwd-init/link.nu"
|
||||
rustflags = ["-C", "link-arg=sausage-cwd-init"]
|
||||
+5
-2
@@ -3,6 +3,9 @@ target/
|
||||
/world
|
||||
/result
|
||||
|
||||
# Lock files of test and example crates: they resolve against the workspace lock.
|
||||
# The root lock covers the tools. Isolated test guests keep disposable locks.
|
||||
**/tests/**/Cargo.lock
|
||||
/examples/*/Cargo.lock
|
||||
/userland/**/Cargo.lock
|
||||
|
||||
# Cargo homes that the devshell creates where it is entered.
|
||||
.cargo-home/
|
||||
|
||||
@@ -1,56 +1,102 @@
|
||||
# Working on Sausage
|
||||
# Working on Sausage (v3)
|
||||
|
||||
Sausage is meant to be read and changed by agents, including its own agent.
|
||||
Keep it that way.
|
||||
Sausage is a world of WASI programs and durable agents. It is meant to be read and changed by agents,
|
||||
including its own. Keep it that way.
|
||||
|
||||
`README.md` describes the current build and layout.
|
||||
|
||||
## The boundary
|
||||
|
||||
- **The world is the sandbox.** Everything inside the world sees and changes the whole world. Do not add
|
||||
inner boundaries, permissions or per-agent views. The VFS is a safety net against mistakes, not a wall
|
||||
against attackers.
|
||||
- **Host calls pierce the sandbox.** The only host import beyond standard WASI is the process interface
|
||||
(spawn, wait, kill). Do not add another one. Everything else uses standard WASI 0.2 interfaces: files
|
||||
through the VFS, `wasi:http` (the host API on its reserved host name, credential injection, signing),
|
||||
and `wasi:sockets`.
|
||||
- **The credentials manager in the host is the one real security boundary.** Its managed secrets never enter
|
||||
the world. Approvals need a passkey. The exceptions are provider API keys in `/etc/sausage/config.yaml`.
|
||||
The user keeps these keys inside the world so agents can change their own setup.
|
||||
- **Native code is the exception.** The only native work for the world is the Rust builder, and it runs in
|
||||
user, mount and network namespaces.
|
||||
|
||||
## Priorities
|
||||
|
||||
1. **Correct.** Every behavior in `PROTOCOL.md` has a test in `cli/tests/host/`.
|
||||
2. **Simple and short.** Prefer the obvious code. No abstraction for one
|
||||
caller. No configuration that nobody asked for.
|
||||
3. **Readable.** A new reader must understand a file without the others.
|
||||
Each module starts with a doc comment that says what it owns.
|
||||
4. **Efficient** on the paths that run per call.
|
||||
5. **Raw and minimal.** Sausage has no permissions, sandboxing beyond WASI,
|
||||
limits, or authentication. Do not add them to the core. Add nothing to
|
||||
the host that a plugin (or the agent, by changing itself) could do.
|
||||
1. **Correct.** Every behavior has a test.
|
||||
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 command and per model turn.
|
||||
|
||||
## Layout
|
||||
|
||||
- `cli/`: the host (`sausage` binary and library): the core, the native
|
||||
`cargo` plugin, the WASI test runner, and the console.
|
||||
- `vfs/`: `sausage-vfs`, the virtual filesystem for WASI guests. It knows
|
||||
nothing about Sausage.
|
||||
- `interface/`: `sausage-interface`: OpenRPC documents, the compatibility
|
||||
check, and the generator of client code.
|
||||
- `sdk/`: `sausage-plugin`, the SDK for Rust plugins, and `sdk/macros/`, its
|
||||
macros. Never published: `cargo` points `sausage-plugin = "0.1"` at its
|
||||
local copy.
|
||||
- `agent/`: the agent plugin. `sausage init` embeds and builds it.
|
||||
- `examples/`: small plugins. They are not workspace members.
|
||||
- `PROTOCOL.md`: the contract between the core and the plugins. Change it in
|
||||
the same commit as the behavior. `DECISIONS.md` says why; when the two
|
||||
disagree, `PROTOCOL.md` and the code win.
|
||||
- `host/`: the host crate (package and binary `sausage`): the world, the process table, the harness.
|
||||
`host/src/exec.rs` is the contract between the harness and the process table.
|
||||
- `vfs/`: `sausage-vfs`, the virtual filesystem for WASI guests and for host code. It knows nothing
|
||||
about Sausage. Host code reads and writes the world only through it.
|
||||
- `userland/`: the default CLIs of a world, built for `wasm32-wasip2` in the root Cargo workspace.
|
||||
The patched Brush crates live in `userland/brush/source/` and share the root lock file.
|
||||
|
||||
## Programs in the world
|
||||
|
||||
- Agents write CLIs and servers. That they are WASI binaries is a detail, not a concept to explain.
|
||||
- The platform is WASI 0.2 components. Rust programs build for `wasm32-wasip2`. WASI 0.1 programs run
|
||||
through an adapter.
|
||||
|
||||
## Checks
|
||||
|
||||
```sh
|
||||
nix flake check -L # all tests below; CI runs this on every push
|
||||
nix develop -c cargo test --workspace # fast with a warm cache: run it during development
|
||||
nix develop -c cargo test -p sausage --test host -- core:: # one topic of the host tests
|
||||
nix develop -c cargo build --release -p agent --target wasm32-wasip2
|
||||
nix develop -c cargo clippy --workspace
|
||||
nix develop -c cargo test -p sausage -p sausage-vfs
|
||||
nix develop -c cargo clippy -p sausage -p sausage-vfs --all-targets -- -D warnings
|
||||
nix develop -c nu userland/bundle.nu
|
||||
# For one tool, select its package and WASI target explicitly:
|
||||
nix develop -c cargo build -p git --target wasm32-wasip2 --release
|
||||
```
|
||||
|
||||
`nix run -- init` and `nix run -- run` must keep working without other
|
||||
setup.
|
||||
`nix build` uses separate Crane pipelines for the native host and WASI userland.
|
||||
Go tools and QuickJS build separately. Nix assembles the bundle without `bundle.nu`.
|
||||
Both Nix and the incremental development bundle read the command names from `userland/tools.json`.
|
||||
Coreutils, diffutils, procps, and brush use relative symlinks to one binary each.
|
||||
|
||||
`nix flake check` checks the packaged host, its bundle, and the whole host/VFS test suite.
|
||||
Sandbox-incompatible tests print an explicit skip reason instead of a flake-level filter.
|
||||
The check sets `SAUSAGE_NIX_CHECK=1` for tests that need network access or unvendored fixture dependencies.
|
||||
|
||||
## Real end-to-end check
|
||||
|
||||
Before you report packaging, config, or model changes as complete, run this check outside the Nix sandbox.
|
||||
Use the required build slot wrapper and approved real provider keys.
|
||||
|
||||
```sh
|
||||
nu --no-config-file host/tests/e2e-real.nu /path/to/config.yaml
|
||||
```
|
||||
|
||||
The script builds the package and tests a fresh world, real models, a shell tool, and browser chat.
|
||||
It copies only the supplied config's providers and never changes the source world.
|
||||
It uses a free loopback port, not port 8080.
|
||||
A failure retains a private temporary folder with provider keys. Treat that folder as sensitive.
|
||||
The result table reports times in seconds.
|
||||
|
||||
## Tests
|
||||
|
||||
Host integration tests share one executable:
|
||||
|
||||
```sh
|
||||
cargo test -p sausage --test it
|
||||
cargo test -p sausage --test it -- chain::
|
||||
```
|
||||
|
||||
Use the required Nix environment and build slot wrapper for these commands.
|
||||
Add test modules under `host/tests/it/` and declare them in `it/main.rs`.
|
||||
Use fully qualified child test filters with `--exact`.
|
||||
See `host/tests/README.md` for fixture and isolation details.
|
||||
|
||||
Tests must stay fast:
|
||||
|
||||
- Only a few tests may wait for a timing (grace periods, deadlines).
|
||||
- Poll for a condition with a deadline. Do not sleep.
|
||||
- Do not test unrealistic load, such as many fast cycles.
|
||||
- `#[ignore]` only disables a test for a short time, with the reason in the
|
||||
attribute.
|
||||
- Tests that build plugins use the persistent cache in
|
||||
`env!("CARGO_TARGET_TMPDIR")`.
|
||||
- `#[ignore]` only disables a test for a short time, with the reason in the attribute.
|
||||
- Tests that build or compile guests use a persistent cache (for example under
|
||||
`env!("CARGO_TARGET_TMPDIR")`), so a warm run does not compile again.
|
||||
|
||||
Generated
+11096
-397
File diff suppressed because it is too large
Load Diff
+161
-6
@@ -1,11 +1,166 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["cli", "vfs", "interface", "sdk", "sdk/macros", "agent"]
|
||||
default-members = ["cli"]
|
||||
# Examples are standalone plugins, each with its own empty [workspace].
|
||||
exclude = ["examples"]
|
||||
members = [
|
||||
"host",
|
||||
"vfs",
|
||||
"userland/git",
|
||||
"userland/coreutils",
|
||||
"userland/procps",
|
||||
"userland/cwd-init/probe",
|
||||
"userland/ripgrep",
|
||||
"userland/diffutils",
|
||||
"userland/jaq",
|
||||
"userland/sed",
|
||||
"userland/grep",
|
||||
"userland/fd",
|
||||
"userland/spawn",
|
||||
"userland/findutils",
|
||||
"userland/exec-tools",
|
||||
"userland/ssh-client",
|
||||
"userland/ssh",
|
||||
"userland/curl",
|
||||
"userland/init",
|
||||
"userland/archive-tools",
|
||||
"userland/fs-tools",
|
||||
"userland/undo",
|
||||
"userland/cargo",
|
||||
"userland/web",
|
||||
"userland/brush/source/brush",
|
||||
"userland/brush/source/brush-shell",
|
||||
"userland/brush/source/brush-parser",
|
||||
"userland/brush/source/brush-core",
|
||||
"userland/brush/source/brush-clap-adapter",
|
||||
"userland/brush/source/brush-winnow-args-adapter",
|
||||
"userland/brush/source/brush-builtins",
|
||||
"userland/brush/source/brush-coreutils-builtins",
|
||||
"userland/brush/source/brush-experimental-builtins",
|
||||
"userland/brush/source/brush-interactive",
|
||||
"userland/brush/source/brush-test-harness",
|
||||
"userland/brush/source/brush-bundled-tests",
|
||||
"userland/brush/source/fuzz",
|
||||
"userland/brush/source/xtask",
|
||||
]
|
||||
default-members = ["host"]
|
||||
exclude = ["spikes"]
|
||||
|
||||
[workspace.package]
|
||||
authors = ["reuben olinsky"]
|
||||
categories = [
|
||||
"command-line-utilities",
|
||||
"development-tools",
|
||||
]
|
||||
edition = "2024"
|
||||
keywords = [
|
||||
"cli",
|
||||
"shell",
|
||||
"sh",
|
||||
"bash",
|
||||
"script",
|
||||
]
|
||||
license = "MIT"
|
||||
readme = "userland/brush/source/README.md"
|
||||
repository = "https://github.com/reubeno/brush"
|
||||
rust-version = "1.88.0"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unnameable_types = "deny"
|
||||
unsafe_op_in_unsafe_fn = "deny"
|
||||
unused_attributes = "deny"
|
||||
unused_lifetimes = "deny"
|
||||
unused_macro_rules = "deny"
|
||||
|
||||
[workspace.lints.rust.warnings]
|
||||
level = "deny"
|
||||
|
||||
[workspace.lints.rust.future_incompatible]
|
||||
level = "deny"
|
||||
|
||||
[workspace.lints.rust.missing_docs]
|
||||
level = "deny"
|
||||
|
||||
[workspace.lints.rust.nonstandard_style]
|
||||
level = "deny"
|
||||
|
||||
[workspace.lints.rust.rust_2018_idioms]
|
||||
level = "deny"
|
||||
priority = -1
|
||||
|
||||
[workspace.lints.rust.unknown_lints]
|
||||
level = "allow"
|
||||
priority = -100
|
||||
|
||||
[workspace.lints.rustdoc.all]
|
||||
level = "deny"
|
||||
priority = -1
|
||||
|
||||
[workspace.lints.clippy]
|
||||
expect_used = "deny"
|
||||
format_push_string = "deny"
|
||||
multiple_unsafe_ops_per_block = "deny"
|
||||
panic = "deny"
|
||||
panic_in_result_fn = "deny"
|
||||
string_lit_chars_any = "deny"
|
||||
string_slice = "deny"
|
||||
tests_outside_test_module = "deny"
|
||||
todo = "deny"
|
||||
unwrap_in_result = "deny"
|
||||
unwrap_used = "deny"
|
||||
undocumented_unsafe_blocks = "deny"
|
||||
bool_to_int_with_if = "allow"
|
||||
cognitive_complexity = "allow"
|
||||
collapsible_else_if = "allow"
|
||||
collapsible_if = "allow"
|
||||
if_not_else = "allow"
|
||||
if_same_then_else = "allow"
|
||||
match_same_arms = "allow"
|
||||
missing_errors_doc = "allow"
|
||||
must_use_candidate = "allow"
|
||||
multiple_crate_versions = "allow"
|
||||
option_if_let_else = "allow"
|
||||
redundant_closure_for_method_calls = "allow"
|
||||
redundant_else = "allow"
|
||||
redundant_pub_crate = "allow"
|
||||
result_large_err = "allow"
|
||||
similar_names = "allow"
|
||||
struct_excessive_bools = "allow"
|
||||
|
||||
[workspace.lints.clippy.all]
|
||||
level = "deny"
|
||||
priority = -1
|
||||
|
||||
[workspace.lints.clippy.pedantic]
|
||||
level = "deny"
|
||||
priority = -1
|
||||
|
||||
[workspace.lints.clippy.cargo]
|
||||
level = "deny"
|
||||
priority = -1
|
||||
|
||||
[workspace.lints.clippy.nursery]
|
||||
level = "deny"
|
||||
priority = -1
|
||||
|
||||
[workspace.lints.clippy.perf]
|
||||
level = "deny"
|
||||
priority = -1
|
||||
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
|
||||
# Wasmtime compiles components with Cranelift. Optimize dependencies in dev
|
||||
# builds, so that tests and debug runs start plugins quickly.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
[patch.crates-io.gix-tempfile]
|
||||
path = "userland/git/vendor/gix-tempfile"
|
||||
|
||||
[patch.crates-io.gix-pack]
|
||||
path = "userland/git/vendor/gix-pack"
|
||||
|
||||
[patch.crates-io.gix-index]
|
||||
path = "userland/git/vendor/gix-index"
|
||||
|
||||
[patch.crates-io.gix-odb]
|
||||
path = "userland/git/vendor/gix-odb"
|
||||
|
||||
[patch.crates-io.filetime]
|
||||
path = "userland/git/vendor/filetime"
|
||||
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
# Decisions
|
||||
|
||||
Why Sausage is the way it is. `PROTOCOL.md` and the code own the details.
|
||||
When this file disagrees with them, they win.
|
||||
|
||||
## The shape
|
||||
|
||||
- **JSON-RPC, not component interfaces.** Every language can read and write
|
||||
lines of JSON, and models know JSON-RPC from LSP and MCP. A call has a
|
||||
result, like an LLM tool call.
|
||||
- **A process per call.** WASI has no threads, so processes give
|
||||
parallelism. A new version applies at the next call, and old processes
|
||||
retire after a grace period.
|
||||
- **Attached by default.** A process does not outlive its caller by
|
||||
accident. A long-running process must ask for `detach`.
|
||||
- **Services in one file.** Only the core starts and restarts them, so
|
||||
upgrades and singletons need no logic in plugins.
|
||||
- **Files to inspect, calls to act.** This keeps the core API small, and the
|
||||
agent's file tools are enough to explore the world.
|
||||
- **The core accepts only `.wasm` files.** It knows no source language.
|
||||
`cargo` is one way to get a `.wasm` file.
|
||||
- **The world folder is the source of truth.** Copy a plugin folder into
|
||||
another world, and it works there.
|
||||
- **The agent is a normal plugin.** It can read and upgrade its own source,
|
||||
and change how it does inference.
|
||||
|
||||
## Interfaces
|
||||
|
||||
- **Generated imports and interface checks, without version numbers.** A
|
||||
model that translates a description by hand makes errors. A generator does
|
||||
not. The check catches the most common error in a world that changes
|
||||
itself: a change to one plugin breaks another one.
|
||||
- **A small schema subset.** With Draft 7 and a fixed subset, our own check
|
||||
can decide every case. It never has to guess.
|
||||
- **Docs are part of the interface.** Other agents work from them, so the
|
||||
SDK requires a doc comment on each method, type, and field.
|
||||
- **The check covers calls, not state.** Old and new versions share
|
||||
`/state`. A plugin keeps its state format compatible, or migrates it in
|
||||
`rpc.retired`.
|
||||
|
||||
## Minimal on purpose
|
||||
|
||||
The core does only what a plugin cannot do for itself. Everything else can be
|
||||
a plugin, or a change that the agent makes to itself.
|
||||
|
||||
- **No permissions, no limits, no hiding.** Any process can call, upgrade,
|
||||
or stop any plugin, read and write every file, and use the network. The
|
||||
checks protect against mistakes, not against plugins. Locks are advisory.
|
||||
- **Locks are in the core.** A plugin cannot see that another process died,
|
||||
so it cannot release that process's lock. The core can.
|
||||
- **Our own VFS, in its own crate.** It works on Linux and macOS, it makes
|
||||
`/proc` live, and plugin-served files need it later. Mounts now, overlays
|
||||
later.
|
||||
- **TCP endpoints for now.** They need no host code. VFS pipes can replace
|
||||
them later behind the same SDK API.
|
||||
- **Simple scheduling for now.** An epoch tick every 10 milliseconds keeps
|
||||
the core responsive and makes kills reliable.
|
||||
- **The agent is dumb.** It has a short prompt, a few tools, an append-only
|
||||
history that it sends in full, and no compaction. When the context is too
|
||||
large, it stops sending the oldest turns, so it never gets stuck.
|
||||
|
||||
## Small choices
|
||||
|
||||
- **Restart is a clean stop supervised by `#loop`.** `core.restart` requires
|
||||
the host startup marker `SAUSAGE_LOOP=1`, which the loop exports for its
|
||||
invocation. Without a supervisor, restarting would only stop the world.
|
||||
A single pending request answers first, allows 250 ms for delivery, then
|
||||
signals the existing shutdown path; it does not use `process::exit` or
|
||||
bypass detached-process cleanup, lock release, or journal draining. The
|
||||
executable returns dedicated restart code **75** after cleanup. `#loop`
|
||||
refreshes and starts again on 75 (and other nonzero exits); 0 stops it.
|
||||
`core.exit` is a compatibility alias with the same supervisor gate,
|
||||
response grace, and coalescing; new callers use `core.restart`.
|
||||
**HARD RULE: the mediator and all agents NEVER call `core.restart` without
|
||||
the person's explicit approval each time.** This
|
||||
approval rule also covers legacy `core.exit`; it must not bypass the gate.
|
||||
|
||||
- **YAML for configuration, JSON for data.** A person edits configuration, so
|
||||
it needs comments. `init` writes commented templates instead of asking
|
||||
questions, so there is one way to configure a world.
|
||||
- **Plain POSIX files.** Programs like `tempfile`, git, and SQLite expect
|
||||
normal files. Atomic writes use a temporary file and `rename`.
|
||||
- **Tests run under WASI.** `cargo test` on the host cannot find failures
|
||||
that occur only in WASI, for example a missing WASI function or a panic at
|
||||
start. The test runner uses the same Wasmtime and the same mounts as the
|
||||
core.
|
||||
- **Tests never run without a timeout.** `cargo.build` serializes its work
|
||||
folder, so one hung test or build script must not block all later builds.
|
||||
`timeout_secs` defaults to 900 seconds after mutex acquisition, excluding
|
||||
queue time; `test_timeout_secs` defaults to 120 seconds per WASI instance.
|
||||
Both are optional positive integers, never null/unlimited. Test timeouts name
|
||||
the failing test and let other instances finish. Build deadlines and
|
||||
cancellation kill cargo's process group before dropping the mutex; timeout
|
||||
errors retain the phase, elapsed time, and recent output. Source preparation
|
||||
and publication retain serialization while their blocking filesystem work
|
||||
finishes, even if cancelled, to avoid overlapping writes to the work folder.
|
||||
- **The SDK is not published.** `cargo` points `sausage-plugin = "0.1"` at
|
||||
its own copy, which it also shows in `/plugins/cargo/sdk/`.
|
||||
- **Compiled code is a cache, not state.** It lives outside the world, in
|
||||
one cache folder that all worlds share: `wasmtime/` (owned by Wasmtime,
|
||||
whose cleanup deletes unknown entries), `cargo-target/`, and
|
||||
`sdk/<hash>/`. A path that depends only on the content of the SDK lets
|
||||
cargo reuse the compiled SDK in every world. Tests run in the release
|
||||
profile, so that they share compiled crates with the release build.
|
||||
- **mold links host code where Nix provides it.** On Linux, the dev shell,
|
||||
the Nix builds, and the packaged `sausage` (for the proc macros and build
|
||||
scripts of plugins) link with mold, much faster than GNU ld. Plugins for
|
||||
`wasm32-wasip2` link with rust-lld. Without Nix, the default linker stays.
|
||||
- **No LTO.** Measured: fat LTO makes the binary 28 % smaller, but compiles
|
||||
plugins and answers calls at most 5–8 % faster, for 4 more minutes per
|
||||
release build. Thin LTO brings nothing.
|
||||
- **Discovery has a deadline of 10 seconds.** It is an internal call of the
|
||||
core, not a limit on plugins. Without it, one plugin that does not answer
|
||||
would block every activation.
|
||||
- **A broken plugin blocks nobody.** A plugin without a document is already
|
||||
broken, so the check cannot protect it. The graph shows it as
|
||||
"unavailable", and `core.revert` and `force` always work.
|
||||
- **`rpc.retired` is best effort.** It does not run if the host stops first.
|
||||
A persistent queue of retirements would be more core for a rare case, so a
|
||||
new version checks its own migration marker instead.
|
||||
-622
@@ -1,622 +0,0 @@
|
||||
# The Sausage protocol
|
||||
|
||||
This is the contract between the core and the plugins. A plugin in any
|
||||
language that follows it works in a Sausage world. The core shows this file
|
||||
at `/proc/core/PROTOCOL.md`. `DECISIONS.md` in the repository says why the
|
||||
contract is the way it is.
|
||||
|
||||
Rust plugins use the SDK `sausage-plugin`. Its source is at
|
||||
`/plugins/cargo/sdk/`, and its crate docs explain each item.
|
||||
|
||||
## 1. Terms
|
||||
|
||||
| Term | Meaning |
|
||||
| --- | --- |
|
||||
| world | The folder that a `sausage` host runs. It is `/` for every process. |
|
||||
| core | The host. It runs processes, routes calls, serves the virtual files, and keeps the journal. |
|
||||
| plugin | A folder `/plugins/<name>/` with a `plugin.wasm`. A plugin does not run by itself. |
|
||||
| process | One run of a plugin version. Its ID is `<plugin>-<uuidv7>`, for example `notes-0197f3a2-7c1e-7d2b-9a41-5f0c2e8b1d34`. |
|
||||
| call | A JSON-RPC request. `notes.add` calls the method `add` of the plugin `notes`. |
|
||||
| interface | A set of methods. A plugin has one main interface. It can also declare endpoint interfaces. |
|
||||
| endpoint | An interface that a running process serves on a TCP port. An endpoint file tells others where it is. |
|
||||
| service | A call in `/config/boot.yaml`. The core starts it at boot, restarts it after a crash, and starts it again after an upgrade of its plugin. |
|
||||
| import | An interface of another plugin that a plugin uses. |
|
||||
| provider, consumer | The plugin that has an interface, and a plugin that imports it. |
|
||||
| journal | The central log of the world. |
|
||||
|
||||
## 2. The world folder
|
||||
|
||||
```text
|
||||
<world>/
|
||||
plugins/<name>/
|
||||
plugin.wasm the current version
|
||||
plugin.prev.wasm the version before, for `core.revert`
|
||||
source/ the source. `cargo.build` builds it from here.
|
||||
imports/<plugin>.json descriptions of the imported plugins
|
||||
state/<plugin>/ persistent state of a plugin, by convention
|
||||
logs/core/journal.sqlite the journal
|
||||
state/core/ console history (state, not logs)
|
||||
config/ global configuration
|
||||
boot.yaml the services
|
||||
agent.yaml the API, base URL, model, and key of the agent
|
||||
```
|
||||
|
||||
- Every process can read and write all of it. There are no permissions.
|
||||
- Configuration files are YAML, with comments. Data and interface files are
|
||||
JSON.
|
||||
- Plugin names use `a-z`, `0-9`, `-`, and `_`. The name `core` is reserved.
|
||||
- The world folder must not contain `proc`, `run`, or `self`. These names are
|
||||
mount points (section 4). The host does not start a world that has them.
|
||||
|
||||
## 3. Calls and processes
|
||||
|
||||
### 3.1 The wire protocol
|
||||
|
||||
- A plugin is a `wasi:cli/command` component for `wasm32-wasip2`.
|
||||
- stdin and stdout carry JSON-RPC 2.0, one message per line.
|
||||
- A line is a message only if it is a JSON object with `"jsonrpc": "2.0"`.
|
||||
The core discards every other line on stdout. For the first discarded line
|
||||
of a process, it writes one warning to the journal.
|
||||
- Each line on stderr goes to the journal. Log to stderr.
|
||||
- `params` is always an object. Batches are not supported.
|
||||
- Write each message in one write, and write a newline before it. Then a
|
||||
`print!` without a newline cannot damage a message.
|
||||
|
||||
### 3.2 Method names
|
||||
|
||||
- A method name has the form `<target>.<method>`. The core splits it at the
|
||||
first `.`.
|
||||
- `notes.add` starts a new process of `notes` and sends it the request `add`.
|
||||
`notes.rpc.discover` sends it `rpc.discover`.
|
||||
- `core.<method>` is a method of the core (section 9).
|
||||
- Method names that start with `rpc.` are reserved: `rpc.discover`,
|
||||
`rpc.superseded`, `rpc.retired`, and `rpc.endpoint`.
|
||||
|
||||
### 3.3 What a call does
|
||||
|
||||
1. The core gives the call a UUIDv7 and starts the process `<plugin>-<uuid>`
|
||||
with the current version of the plugin.
|
||||
2. The core writes the request to the stdin of the process. The request ID is
|
||||
the process ID, and the method has no plugin prefix.
|
||||
3. The process writes the response to stdout. The core sends the response to
|
||||
the caller, with the caller's request ID.
|
||||
4. If the process ends before it answers, the caller gets the error -32002.
|
||||
Its `data` has the exit status and the last journal lines of the process.
|
||||
Before the core decides this, it reads the stdout of the process to the
|
||||
end.
|
||||
|
||||
- A process can write calls to stdout at any time, to the core and to
|
||||
plugins. The responses come on stdin. A process can have many calls in
|
||||
flight.
|
||||
- The core keys each pending request by the process and the exact JSON ID.
|
||||
The core drops a response with an unknown or late ID.
|
||||
- A notification to `plugin.method` (without an ID) starts an attached
|
||||
process. That process gets the notification without an ID, and nobody gets
|
||||
a result.
|
||||
|
||||
### 3.4 Detached calls
|
||||
|
||||
`core.call {"method": "www.start", "params": {...}, "detach": true}` makes the
|
||||
call and returns its result to the caller. The new process has no parent, so
|
||||
it does not end with its caller.
|
||||
|
||||
Sent as a notification (without an ID), the same `core.call` starts the
|
||||
process and forgets it.
|
||||
|
||||
### 3.5 Notifications to the caller
|
||||
|
||||
A process can tell its caller about progress. It sends the notification
|
||||
`core.notify {"name": "text", "params": {...}}`. The core gives it to the
|
||||
caller as `core.notify {"call": <ID of the caller's request>, "name": "text",
|
||||
"params": {...}}`. After the call has ended, the core drops such
|
||||
notifications.
|
||||
|
||||
So a process never needs to know who called it.
|
||||
|
||||
### 3.6 Lifetime
|
||||
|
||||
A process lives until `main` returns. A process that uses the SDK lives
|
||||
while one of these runs:
|
||||
|
||||
- a handler of a call,
|
||||
- a background task,
|
||||
- an endpoint that listens.
|
||||
|
||||
A process has three states:
|
||||
|
||||
1. **running**
|
||||
2. **stopping**: the core has closed stdin. EOF on stdin means "stop now".
|
||||
- New calls of the process fail at once, and its pending calls fail.
|
||||
- A stop hook can still write files and send notifications. It cannot
|
||||
make calls, because no response can arrive.
|
||||
- After 5 seconds, the core kills the process.
|
||||
3. **ended**: the core releases the locks of the process (section 6) and
|
||||
removes its folder in `/proc`.
|
||||
|
||||
Rules:
|
||||
|
||||
- `core.stop {pid}` starts the stopping state. `core.kill {pid}` kills the
|
||||
process at once.
|
||||
- When a process ends, the core stops each of its attached children. All
|
||||
children stop in parallel, so a whole tree takes 5 seconds at most.
|
||||
- A detached process has no parent. Only `core.stop`, `core.kill`, an
|
||||
upgrade (section 3.7), and the end of the host stop it.
|
||||
- At host shutdown, the core starts no new processes, and it stops every
|
||||
process in parallel.
|
||||
|
||||
Note: on Unix, children outlive their parent. Here, an attached child ends
|
||||
with its parent. Only `detach` makes a process independent.
|
||||
|
||||
### 3.7 Upgrades
|
||||
|
||||
A process is an instance of one plugin version. When a new version becomes
|
||||
active (section 10.3):
|
||||
|
||||
1. New calls get the new version at once.
|
||||
2. At grace start, the core sends each process of an older version the
|
||||
JSON-RPC notification `rpc.superseded {"current": "<sha256 of the new file>"}`
|
||||
on stdin, without an ID. The process is still running: calls and responses
|
||||
continue to work. It can stop accepting work, finish its in-flight calls,
|
||||
and exit early. Ignoring the notice changes nothing. The grace period is
|
||||
the upper bound before the core stops the process (section 3.6); its
|
||||
duration is 5 seconds for every plugin. Discovery metadata, including an
|
||||
explicit `x-sausage-grace`, does not change it. After grace, stdin closes
|
||||
and the separate 5-second stop budget applies (section 3.6): at most two
|
||||
5-second windows. Discovery-only processes do not receive this notice or
|
||||
enter upgrade grace; they follow the discovery lifetime (section 10.1).
|
||||
3. When the old process of a service has ended, the core starts the service
|
||||
again with the new version.
|
||||
4. When the last process of an older version has ended, the core starts a
|
||||
process of the new version and calls `rpc.retired {"previous":
|
||||
"<sha256 of the older file>"}`. A plugin can use this hook to migrate its
|
||||
state, or to write a marker that its other new processes wait for. The
|
||||
result goes to the journal.
|
||||
|
||||
During the grace period, an old process can call a method that a new
|
||||
provider no longer has.
|
||||
|
||||
If the host stops before the last old process has ended, `rpc.retired` does
|
||||
not run for that version. So a new version must also work with state that
|
||||
was not migrated. Check for a migration marker, and migrate when it is
|
||||
missing.
|
||||
|
||||
### 3.8 Environment and network
|
||||
|
||||
- `SAUSAGE_PID`: the process ID.
|
||||
- `SAUSAGE_PLUGIN`: the plugin name.
|
||||
- `SAUSAGE_PARENT`: the parent: `console`, `core`, or a process ID.
|
||||
|
||||
Every process gets the network: outgoing HTTP and HTTPS through `wasi:http`,
|
||||
and TCP, UDP, and DNS through `wasi:sockets`.
|
||||
|
||||
A process that computes for a long time does not block other processes. The
|
||||
core interrupts it every 10 milliseconds.
|
||||
|
||||
### 3.9 Errors
|
||||
|
||||
The standard JSON-RPC codes apply. The core adds these codes:
|
||||
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| -32001 | There is no such plugin. |
|
||||
| -32002 | The process ended without an answer. `data` has the exit status and the last journal lines. |
|
||||
| -32003 | The activation was rejected. `data` has the reasons. |
|
||||
| -32004 | `cargo.build` failed. `data` has the `output` of the failed step and the `tests`. |
|
||||
|
||||
## 4. The filesystem
|
||||
|
||||
Each process gets one preopen, `/`. The core resolves each path through a
|
||||
mount table:
|
||||
|
||||
| Path | What it is | Access |
|
||||
| --- | --- | --- |
|
||||
| `/` | the world folder | read-write |
|
||||
| `/proc` | generated by the core | read-only |
|
||||
| `/self` | the folder `/proc/<own pid>/`, in memory | read-write |
|
||||
| `/run` | in memory, shared, empty at each host start | read-write |
|
||||
|
||||
A listing of `/` shows `proc`, `run`, and `self`. A rename across two of
|
||||
these areas fails.
|
||||
|
||||
```text
|
||||
/proc/
|
||||
core/
|
||||
PROTOCOL.md this file
|
||||
graph.json plugins, imports, and the result of each check
|
||||
graph.dot the same graph for Graphviz
|
||||
locks.json held locks and waiting processes
|
||||
plugins/<name>/
|
||||
discover.json the rpc.discover document of the current plugin.wasm
|
||||
client.rs the Rust code that import!("<name>") generates
|
||||
<pid>/
|
||||
info.json plugin, version, method, params, parent, detached, start time, state
|
||||
log the journal lines of this process
|
||||
<files> the files that the process writes to /self
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- A process writes its own folder only through `/self`. Writes through
|
||||
`/proc` always fail.
|
||||
- In a process folder, the names `info.json`, `log`, and `fs` belong to the
|
||||
core. A write to `/self/info.json` fails.
|
||||
- A read of a generated file returns a snapshot that ends. For example,
|
||||
`/proc/<pid>/log` returns the lines up to the time of the open.
|
||||
- The folder of a process goes away when the process ends. Its files are in
|
||||
memory. Large or persistent data belongs in `/state`.
|
||||
- Do not send a `/self` path to another process. There it means a different
|
||||
folder, like `localhost` on another machine.
|
||||
|
||||
Principle: **inspect through files, act through calls.**
|
||||
|
||||
### File watching
|
||||
|
||||
`core.watch {"path": "/state/session/mailbox"}` stays open for the caller
|
||||
process's lifetime. It needs a request ID and a running plugin caller.
|
||||
Its first `core.notify` is `"armed"`, with empty params. Only then scan the
|
||||
files. Writes before arming are visible in that scan; writes after arming
|
||||
produce `"changed"` notifications (also with empty params). Rescan after
|
||||
each notification; files, not event counts, are the truth.
|
||||
|
||||
Notifications follow successful plugin-originated file mutations: creation,
|
||||
truncation, descriptor/stream writes, rename (both source and destination),
|
||||
and deletion, including mounted `/run` and `/self` files. Paths are normalized
|
||||
in the virtual namespace; `/self` means the caller's `/proc/<pid>` folder.
|
||||
A watch matches itself and descendants, not sibling prefixes. Moving or
|
||||
removing an ancestor also invalidates it. Paths need not exist when armed.
|
||||
Open file writes follow plugin renames; unlinked/replaced handles no longer
|
||||
name visible files. External host writes are outside this contract.
|
||||
|
||||
Changes may coalesce, including a change already visible in the initial
|
||||
scan, but a change after the last notification always produces another.
|
||||
Delivery holds no filesystem locks. Observation does not serialize descriptor
|
||||
IO; only overlapping opens and rename/removal wait for one another.
|
||||
Watch the narrowest useful path: delivery is not rate-limited, so watching a
|
||||
busy parent can cause a rescan for every write.
|
||||
|
||||
The host-side watch ends only when its caller process exits, not on a change
|
||||
notification; there is no cancellation API or polling/retry loop. SDK callers
|
||||
should poll the watch future alongside their handler's work and drop it on
|
||||
return, not keep it in a background task that prevents an idle process from
|
||||
exiting. Dropping the future removes the SDK's pending entry and callback, so
|
||||
late notifications and responses are ignored; it does not cancel the host
|
||||
watch. A long-lived service keeps one watch per purpose for its lifetime,
|
||||
rather than repeatedly opening and dropping watches.
|
||||
|
||||
## 5. Endpoints
|
||||
|
||||
An endpoint is a JSON-RPC interface that a running process serves over TCP,
|
||||
for example the control interface of a web server.
|
||||
|
||||
- The process binds a free port on 127.0.0.1. When the listener is ready, it
|
||||
writes an endpoint file in one atomic step (write a temporary file, then
|
||||
rename it). The file has one line: the address, the owner process, and the
|
||||
interface. Example: `tcp:127.0.0.1:41234 www-0197… control`.
|
||||
- Usually the file is in `/self`. A service that others must find uses a
|
||||
fixed path in `/run`, for example `/run/secrets`.
|
||||
- A client reads the file and connects. Its first request is
|
||||
`rpc.endpoint`, which returns `{"owner", "plugin", "interface"}`. If they
|
||||
do not match the file, the client disconnects. So a stale file or a reused
|
||||
port cannot connect a client to the wrong process.
|
||||
- JSON-RPC runs on the connection, one message per line, in both directions.
|
||||
Notifications of a handler (`core.notify`) go over the connection.
|
||||
- A plugin declares its endpoint interfaces in `rpc.discover`
|
||||
(`x-sausage-endpoints`).
|
||||
|
||||
## 6. Locks
|
||||
|
||||
- `core.lock {"path": "/state/notes/notes.json"}` returns `{"held": true}`
|
||||
when the caller holds the lock.
|
||||
- Each acquisition gets its own place in a FIFO queue. The same process
|
||||
cannot take one lock twice: its second acquisition waits behind its first.
|
||||
- With `"wait": false`, the call returns at once with `{"held": true}` or
|
||||
`{"held": false}`.
|
||||
- `core.unlock {path}` (a notification) releases the lock.
|
||||
- When a process ends, the core releases all of its locks, also after a
|
||||
crash or a kill. It waits until the last file operation of the process has
|
||||
returned. So a write of a killed process cannot land after the next
|
||||
process got the lock.
|
||||
- Locks are advisory. The core does not block writes to the file: the path
|
||||
is only a name.
|
||||
- SQLite has no file locks in WASI. Hold the lock while the database is open.
|
||||
|
||||
## 7. The journal
|
||||
|
||||
- New entries go to `/logs/core/journal.sqlite`. Only the core writes it.
|
||||
An existing `/state/core/journal.sqlite` stays in place and is opened
|
||||
read-only; queries merge both databases by entry ID before applying the
|
||||
limit. The host never renames a live SQLite/WAL trio. Console history stays
|
||||
in `/state/core`. No journal or log files are automatically pruned.
|
||||
- If a journal transaction fails, the complete batch (including original
|
||||
IDs and timestamps) is saved in an exclusive, synced JSON file under
|
||||
`/logs/core/failed-batches/`; the host reports its path on stderr. These
|
||||
files are recovery artifacts, not query results: recovery/import is
|
||||
deliberate, using IDs to avoid duplicates, and never deletes the source.
|
||||
If spooling also fails, entries stay in memory and retry on the next
|
||||
command (no polling); at writer shutdown their full text is reported to
|
||||
host stderr. Storage failure plus host loss can still lose unsaved data.
|
||||
- Each entry has a UUIDv7 (it sorts by time), the process ID, the plugin, the
|
||||
stream (`stderr` or `core`), and the line.
|
||||
- Traps retain their full diagnostic text in the journal; short exit-status
|
||||
displays are UTF-8-boundary-safe.
|
||||
- The core writes its own events too: start (with method and params), exit
|
||||
status, stop, kill, upgrades, and the warning about discarded stdout.
|
||||
- `core.log {plugin?, pid?, since?, until?, grep?, limit?}` queries the
|
||||
journal. `/proc/<pid>/log` shows the lines of one running process.
|
||||
|
||||
## 8. Boot and services
|
||||
|
||||
At start, the core starts each service in `/config/boot.yaml`, in order:
|
||||
|
||||
```yaml
|
||||
- method: secrets.serve
|
||||
params: {}
|
||||
- method: www.start
|
||||
params:
|
||||
port: 8080
|
||||
```
|
||||
|
||||
- A service is a detached call with the parent `core`. The core waits for
|
||||
its result before it starts the next one. The result shows that the
|
||||
service is ready.
|
||||
- If a service fails before answering, the core writes the error to the
|
||||
journal and continues booting. A failure to admit a process (for example,
|
||||
a missing plugin or invalid parameters) is logged, not retried in a loop.
|
||||
- The core supervises the process lifetime, not its readiness response. A
|
||||
nonzero exit, trap or failed host task restarts the same service with its
|
||||
original method and parameters, including a crash before readiness. A
|
||||
readiness response alone never triggers a restart; a clean exit does not
|
||||
count as a crash or trigger an immediate restart.
|
||||
- More than 5 crashes within 10 seconds gives up on that service and writes
|
||||
`service gave up` to the journal. Each service has an independent sliding
|
||||
window; crashes exactly 10 seconds old expire. There is no retry delay or
|
||||
periodic liveness check. Giving up is latched for this host lifetime:
|
||||
neither elapsed time nor an upgrade re-arms it.
|
||||
- After an upgrade of the plugin of a service, the same supervisor starts
|
||||
it again with the current version after its old process ends (section
|
||||
3.7). Superseded exits are retirement, not crashes. Admission and cleanup
|
||||
reserve a single owner/attempt, so an upgrade racing a crash cannot start
|
||||
two owners. A matching boot-service start from `rpc.retired` is ignored
|
||||
(returns `null`), even while restart is pending or after giving up; older
|
||||
plugins' fallback starts do not bypass supervision.
|
||||
- An explicit `core.stop` or `core.kill` disables automatic attempts for
|
||||
that service, including during an upgrade. Host shutdown starts no new
|
||||
processes. The core does not restart other detached processes.
|
||||
- A singleton is a service with a fixed endpoint in `/run`. Only the core
|
||||
starts it, so two instances cannot start at the same time.
|
||||
|
||||
## 9. The core API
|
||||
|
||||
| Method | Params | Result |
|
||||
| --- | --- | --- |
|
||||
| `core.call` | `method`, `params`, `detach`? | the result of the method |
|
||||
| `core.watch` | `path` | stays open; `armed`, then `changed` notifications (section 4) |
|
||||
| `core.notify` | `name`, `params` | none (a notification) |
|
||||
| `core.stop` | `pid` | `{}` when the process has ended |
|
||||
| `core.kill` | `pid` | `{}` |
|
||||
| `core.lock` | `path`, `wait`? | `{"held": bool}` |
|
||||
| `core.unlock` | `path` | none (a notification) |
|
||||
| `core.activate` | `plugin` and `wasm`, or `plugins: [{plugin, wasm}]`. `force`? | short activation summaries, keyed by plugin |
|
||||
| `core.revert` | `plugin`, `force`? | like `core.activate` |
|
||||
| `core.log` | filters (section 7) | journal entries |
|
||||
| `core.restart` | none | `{}`. With `SAUSAGE_LOOP=1`, schedule clean shutdown and exit code 75. |
|
||||
| `core.exit` | none | Alias of `core.restart`: same supervisor gate, response, coalescing, and exit code 75. |
|
||||
|
||||
### Host restart
|
||||
|
||||
**HARD RULE: the mediator and all agents NEVER call `core.restart` without
|
||||
the person's explicit approval each time.** The same approval rule applies
|
||||
to the legacy alias `core.exit`; it uses the same gate and pending request.
|
||||
|
||||
`core.restart {}` is enabled only when the host starts with `SAUSAGE_LOOP=1`.
|
||||
The host captures the marker at startup. An absent marker (or any value other
|
||||
than `1`) returns error -32600: `restart needs the loop supervisor
|
||||
(SAUSAGE_LOOP not set); start with SAUSAGE_LOOP=1`, without scheduling an exit.
|
||||
This is a supervisor marker, not a permission system.
|
||||
|
||||
The method returns `{}` immediately and schedules the shutdown request after
|
||||
250 milliseconds to let the response reach the caller before stdin closes.
|
||||
Repeated requests while a restart is pending return `{}` without scheduling
|
||||
another restart or extending that delay. The executable then uses the same
|
||||
`Host::stop` path as a normal stop: reject new processes, stop all processes
|
||||
(including detached ones) in parallel with the normal five-second grace,
|
||||
wait for outstanding file operations, release locks, and drain journal writes.
|
||||
Only after that does the executable return the dedicated restart exit code
|
||||
**75**. The library signals this path; it never kills its embedding process.
|
||||
|
||||
`nix run .#loop -- <world>` sets `SAUSAGE_LOOP=1` on the host invocation and
|
||||
starts the newest host again after exit code 75 (and, as before, other nonzero
|
||||
exits). Exit code 0 ends the loop. If using an older loop script, the person
|
||||
must set `SAUSAGE_LOOP=1` in that supervisor's environment; do not set it for
|
||||
an unsupervised host. The NixEnv service invokes this flake's `#loop`, so a
|
||||
new loop script supplies the marker without changes to NixEnv.
|
||||
|
||||
Everything else is a file.
|
||||
|
||||
## 10. Interfaces
|
||||
|
||||
### 10.1 `rpc.discover`
|
||||
|
||||
Each plugin answers `rpc.discover` with an OpenRPC 1.2 document. The schemas
|
||||
are JSON Schema Draft 7, as OpenRPC requires. Shared schemas are in
|
||||
`components.schemas`, and `$ref`s point to `#/components/schemas/<Name>`.
|
||||
Params are by name. The document can have these extension fields:
|
||||
|
||||
- `x-sausage-params` for each method with params: a `$ref` to the schema of
|
||||
the params object.
|
||||
- `x-sausage-endpoints`: the endpoint interfaces by name, each with its
|
||||
`methods`.
|
||||
- `x-sausage-imports`: for each provider, the part of its document that the
|
||||
plugin uses.
|
||||
- `x-sausage-grace`: legacy grace declaration in seconds. The core ignores
|
||||
this field; upgrade grace is always 5 seconds (section 3.7).
|
||||
|
||||
The schema of params describes what the provider can read. The schema of a
|
||||
result describes what the provider writes. Descriptions are part of the
|
||||
interface: other agents read them to use it.
|
||||
|
||||
The core starts a process only to call `rpc.discover`, and it stops that
|
||||
process after the answer. Do not do expensive work at start. If no answer
|
||||
comes within 10 seconds, the discovery fails.
|
||||
|
||||
### 10.2 Compatibility
|
||||
|
||||
A provider can add methods, optional params, and result fields. For each
|
||||
imported method:
|
||||
|
||||
1. The provider has the method.
|
||||
2. Each params value that the consumer can send is valid for the provider.
|
||||
3. Each result that the provider can return is valid for the consumer.
|
||||
|
||||
- The same rules apply to endpoint interfaces.
|
||||
- There are no version numbers. This check replaces them.
|
||||
- The check covers a fixed subset of JSON Schema: objects with `properties`
|
||||
and `required`, the primitive types, arrays, optional and nullable values,
|
||||
string enums, integer ranges, and `$ref`. A construct outside the subset
|
||||
fails the check.
|
||||
- The check covers calls, not state. Old and new processes share the files in
|
||||
`/state`, and `core.revert` restores only code. A plugin must keep its
|
||||
state format compatible, or migrate it in `rpc.retired`.
|
||||
|
||||
### 10.3 Activation
|
||||
|
||||
`core.activate {"plugin": "notes", "wasm": "/state/cargo/builds/7/notes.wasm"}`:
|
||||
|
||||
1. The core copies the file. All further steps use this copy, even if the
|
||||
file changes.
|
||||
2. It starts the copy and calls `rpc.discover`.
|
||||
3. It gets the current documents of all plugins (section 10.4). A plugin
|
||||
whose discovery fails is "unavailable". Its imports cannot be checked,
|
||||
but it does not block the activation of other plugins.
|
||||
4. It checks the imports of the new version against the current providers.
|
||||
5. It checks each current consumer of the plugin against the new version.
|
||||
6. If all checks pass, `plugin.wasm` becomes `plugin.prev.wasm`, and the copy
|
||||
becomes `plugin.wasm`. Then the upgrade starts (section 3.7). If the copy
|
||||
has the same hash as the current version, nothing changes.
|
||||
|
||||
If a check fails, nothing changes, and the error -32003 lists each problem,
|
||||
with a counterexample where possible.
|
||||
|
||||
The result is an object keyed by plugin name. Each value contains:
|
||||
|
||||
- `plugin`: the plugin name.
|
||||
- `wasm`: the installed world path (`/plugins/<name>/plugin.wasm`).
|
||||
- `hash`: the SHA-256 of the installed copy.
|
||||
- `previous`: the replaced version's SHA-256, or `null` on first installation.
|
||||
For an identical-hash no-op, this is the current hash.
|
||||
- `method_count`: the number of methods in the new main interface.
|
||||
- `added`, `removed`, `changed`: sorted method-name arrays compared with the
|
||||
previous discovery document. A change to a method description or any
|
||||
transitively referenced local schema counts as changed. Unrelated schemas
|
||||
and endpoint interfaces do not affect these arrays. With no previous
|
||||
document, all methods are added.
|
||||
- `interface_check`: `"passed"` after successful checks, or `"skipped"` with
|
||||
`force: true` (not a claim of compatibility).
|
||||
|
||||
The result does not include schemas. The full document remains available
|
||||
through `<plugin>.rpc.discover` and `/proc/core/plugins/<name>/discover.json`.
|
||||
An identical-hash no-op and `core.revert` return the same summary shape.
|
||||
|
||||
- The core runs one activation at a time.
|
||||
- `plugins: [...]` activates several plugins together. The checks then use
|
||||
the new set.
|
||||
- `force: true` skips steps 4 and 5.
|
||||
- `core.revert {plugin}` activates `plugin.prev.wasm` in the same way, and
|
||||
the current version becomes `plugin.prev.wasm`.
|
||||
- A person can also copy a file to `plugin.wasm` by hand. That skips the
|
||||
checks.
|
||||
|
||||
### 10.4 The graph
|
||||
|
||||
- The core keeps the `rpc.discover` documents of all plugins, by file hash.
|
||||
At start, it gets them in the background.
|
||||
- A file watcher on `/plugins/*/plugin.wasm` updates them, for example after
|
||||
a copy by hand. `/proc/core/graph.json` then shows each broken import, and
|
||||
the journal records it.
|
||||
- An import of a plugin that does not exist is "missing". An import of a
|
||||
plugin whose discovery failed is "unavailable".
|
||||
|
||||
## 11. Building and tests
|
||||
|
||||
The core has one native plugin: `cargo`. `cargo.build {"plugin": "notes"}`
|
||||
builds `/plugins/notes/source/`:
|
||||
|
||||
1. It copies the source once. The tests and the build both use this copy.
|
||||
2. It runs the tests under WASI, in the release profile.
|
||||
3. It builds the release `.wasm` for `wasm32-wasip2`. The tests and the build
|
||||
share the compiled dependencies.
|
||||
4. It returns `{"wasm": "/state/cargo/builds/<n>/notes.wasm", "tests":
|
||||
{"passed", "failed", "output"}, "warnings", "logs"}`. If a step fails, it returns
|
||||
an error with the compiler or test output.
|
||||
|
||||
- Before running commands, it creates `/logs/cargo/<YYYY-MM-DD>/<build-id>.stdout.log`
|
||||
and `.stderr.log` (UTC date, unique build ID). Both test and release phases
|
||||
append their raw bytes directly to these files, before any parsing, progress
|
||||
filtering or UTF-8 conversion. This includes successful JSON build output.
|
||||
`/logs` is a top-level world folder, created if missing, not under `/state`.
|
||||
- Success results and error -32004 `data` include `logs`:
|
||||
`{"stdout":{"path":"/logs/cargo/...stdout.log","bytes":123},
|
||||
"stderr":{"path":"/logs/cargo/...stderr.log","bytes":45},"total_bytes":168}`.
|
||||
Counts are raw bytes, not characters. `logs` is null only if log creation
|
||||
itself fails. Preparation failures otherwise include empty log files.
|
||||
Inline diagnostics may be filtered or shortened; the files are the full truth.
|
||||
- Logs remain after failure, total/test timeout, and cancellation. Cancellation
|
||||
need not return a result; its files remain discoverable in `/logs/cargo/`.
|
||||
Pruning numeric `/state/cargo/builds/` artifact folders does not prune logs.
|
||||
- Optional `timeout_secs` is an integer of at least 1, default **900** (not
|
||||
null or unlimited). The wall-clock budget starts when the build acquires its
|
||||
mutex, excluding queue time, and covers tests and release compilation together.
|
||||
On expiry, cargo and its descendant process group are killed and the mutex is
|
||||
released. Error -32004 has reason/message `timeout`, with `data.reason`,
|
||||
`data.elapsed_secs`, `data.step` (`tests` or `release build`), `data.tests`,
|
||||
and `data.output` containing the last 50 output lines.
|
||||
- Optional `test_timeout_secs` is an integer of at least 1, default **120**.
|
||||
Each WASI test instance has this wall-clock budget. On expiry, the instance
|
||||
is killed, its named test fails with `timed out after N s`, and remaining
|
||||
tests continue. The overall build budget still applies.
|
||||
- Cancellation kills cargo's process group, including build scripts and test
|
||||
runners, before releasing the build mutex. An attached build also stops when
|
||||
its caller ends (under the normal process stopping deadline).
|
||||
- A call to `cargo.build` gets a process ID, a folder in `/proc`, and journal
|
||||
entries, like other calls.
|
||||
- `/plugins/cargo/sdk/` is the SDK used by builds. The dependency
|
||||
`sausage-plugin = "0.1"` points directly to this folder, including its macros
|
||||
and interface crate. Edits apply to the next build without a host restart.
|
||||
- The host seeds a missing SDK folder from its embedded copy at startup or
|
||||
before a build. An existing folder is never overwritten or repaired, even
|
||||
after a host upgrade. To reset to the host's bundled SDK, remove the whole
|
||||
folder; the next startup or build re-seeds it. Back up local edits first.
|
||||
- Compiled crates stay in the host's cache, outside the world, and all worlds
|
||||
share them. So a second build compiles only what changed.
|
||||
- Build scripts can compile C: `CC_wasm32_wasip2` and `AR_wasm32_wasip2`
|
||||
point to a WASI clang.
|
||||
- The build does not activate the result. Call `core.activate` next.
|
||||
|
||||
Tests:
|
||||
|
||||
- Each test runs in its own instance, so a panic ends only its own test.
|
||||
- The normal Rust test setup works: `#[test]`, `tests/*.rs`, name filters,
|
||||
and `--ignored`. `#[should_panic]` cannot work, because WASI cannot catch
|
||||
a panic.
|
||||
- Each test gets an empty temporary world as `/`, the package folder at its
|
||||
host path, and its own `/proc`, `/self`, and `/run`.
|
||||
- The current folder of a test is `/`, not the package folder. Read fixture
|
||||
files through `env!("CARGO_MANIFEST_DIR")`.
|
||||
- The test runner answers `core.lock`, `core.unlock`, `core.notify`, and
|
||||
`core.log`. A call to a plugin fails: a test has no other plugins.
|
||||
|
||||
## 12. Embedded agent tool output
|
||||
|
||||
The agent shipped by `sausage init` uses a context-size guard of 50 KiB or
|
||||
2000 lines, not a data-retention limit. Oversized tool results, including
|
||||
errors, are saved in full under `/logs/agent/<date>/default/` before history
|
||||
and provider delivery receive an excerpt. Overflow JSON string values are
|
||||
rendered as physical text lines so the reference's one-based inclusive line
|
||||
ranges can be followed with `read_file {path, line_offset, line_limit}`
|
||||
(zero-based offset; default 200 lines). Huge single lines give exact byte
|
||||
ranges and continuation offsets. Command/log/build/test results show the
|
||||
tail; other tools show the head. Reading an existing `/logs` file excerpts
|
||||
that same file without making another archive. Archives are exclusive and
|
||||
never automatically deleted. If archival fails, the agent reports the error
|
||||
to stderr and keeps the complete result instead of discarding it. Existing
|
||||
history formats and paths are unchanged.
|
||||
@@ -1,126 +1,50 @@
|
||||
# Sausage
|
||||
# Sausage v3
|
||||
|
||||
You can see how the sausage is made.
|
||||
Sausage is a world of WASI programs and durable agents. The Rust host combines a process table, a shared virtual filesystem, and a durable JavaScript harness. Agents and their programs see the same world. The filesystem prevents mistakes, not attacks between agents. The host credentials manager is the intended security boundary. Coordination (branches, subagents, reviews, the lander) runs as world extensions.
|
||||
|
||||
Sausage is a world of small WASI programs ("plugins") that you grow by
|
||||
talking to it. A plugin is a `.wasm` file with JSON-RPC methods. Each call
|
||||
to a plugin starts a new process. The agent is a plugin too: it writes new
|
||||
plugins in Rust, builds them, tests them, activates them, and can change its
|
||||
own code.
|
||||
Read [Run Sausage](docs/RUNNING.md) for the console, approvals, models, extensions and the known gaps.
|
||||
|
||||
## Start
|
||||
|
||||
You need [Nix](https://nixos.org/download) with flakes, and an LLM: the OpenAI
|
||||
Responses API, an OpenAI-compatible Chat Completions API (OpenRouter, Ollama,
|
||||
and most other providers), or the Anthropic Messages API. No clone needed:
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
nix run git+https://gitea.surma.technology/surma/sausage
|
||||
nix build
|
||||
result/bin/sausage init /path/to/world
|
||||
result/bin/sausage run /path/to/world
|
||||
result/bin/sausage sh /path/to/world -c 'echo hi | tr a-z A-Z; git --version'
|
||||
```
|
||||
|
||||
This creates a world in `./world` the first time (it builds the agent: the
|
||||
first build on a machine takes a few minutes, later worlds reuse the compiled
|
||||
crates), then starts it with a console. Later runs start the
|
||||
existing world. The two steps on their own:
|
||||
The package includes the userland bundle.
|
||||
At world creation, the host copies its tools into `/bin` as `<command>.wasm` files.
|
||||
The bundle ships plain source files. `sausage init` creates their repository in `/src/sausage` with one commit on `main`.
|
||||
The world config lives at `/etc/sausage/config.yaml`.
|
||||
It defines the providers, main agent name, and shared system prompt.
|
||||
Run `sausage init`. Set `providers.<name>.api_key` in that config, then run `sausage run`.
|
||||
The host and chat start with empty keys. A model request with a missing key names the config file.
|
||||
Provider API keys in that file are intentionally visible to agents. Other managed secrets stay outside the world.
|
||||
Agent definitions live in `/etc/sausage/roster/<name>.md`, with YAML frontmatter and a role prompt as the body.
|
||||
Project definitions live in `.sausage/roster/*.md`.
|
||||
The chat and console use the configured main agent conversation. A rename keeps that conversation.
|
||||
Model, prompt, roster, and provider edits apply at the next run without a host restart.
|
||||
|
||||
The host creates `world/` for the shared filesystem and `host/` for private runtime state under the supplied directory.
|
||||
Services come from `/etc/systemd/system` in the world. `web.service` serves fresh `/srv/www` files on port 8080.
|
||||
Use `http://localhost:8080` for chat, passkeys, approvals, and secret entry.
|
||||
The first-passkey notice opens the Passkeys view. Pending requests link to `/#approve/ID`.
|
||||
The `/api/` mount forwards to `host.sausage`, including NDJSON streams. The host opens no separate port.
|
||||
|
||||
## Repository
|
||||
|
||||
- `host/`: the Rust host, process table, and durable harness. Its checked-in JavaScript bundles need no npm during a Nix build.
|
||||
- `vfs/`: the shared virtual filesystem and WASI adapters.
|
||||
- `userland/`: the default tools and patched Brush source, in the root Cargo workspace. `bundle.nu` builds them for `wasm32-wasip2`.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
nix run git+https://gitea.surma.technology/surma/sausage -- init # creates ./world and builds the agent
|
||||
nix run git+https://gitea.surma.technology/surma/sausage -- run # starts the world and a console
|
||||
nix develop
|
||||
cargo test -p sausage -p sausage-vfs
|
||||
nu userland/bundle.nu
|
||||
nix flake check
|
||||
```
|
||||
|
||||
`init` asks nothing. It writes `world/config/agent.yaml`, with a comment for
|
||||
each field and an example for each API. Edit it before the first chat: choose
|
||||
the API, the base URL, the model, the key, and the thinking level. Until then, a chat answers with
|
||||
an error that names the missing field. Both commands take another folder as
|
||||
an argument, for example `... -- run my-world`.
|
||||
|
||||
Compiled code is cached outside the world, in `$XDG_CACHE_HOME/sausage/`
|
||||
(`~/.cache/sausage/` without the variable). All worlds share it.
|
||||
`--cache-dir <folder>` changes it for `init` and `run`.
|
||||
|
||||
To keep a world on the newest version, run it in a loop:
|
||||
|
||||
```sh
|
||||
nix run git+https://gitea.surma.technology/surma/sausage#loop # ./world, created if needed
|
||||
nix run git+https://gitea.surma.technology/surma/sausage#loop -- my-world # an existing world
|
||||
```
|
||||
|
||||
When the host crashes or a plugin calls `core.restart`, the loop fetches the
|
||||
newest commit and starts it again. The loop sets `SAUSAGE_LOOP=1` to enable
|
||||
that RPC; without it, restart returns an error and leaves the host running.
|
||||
Restart answers first, then cleanly stops all processes and exits with code
|
||||
75. `quit` (code 0) ends the loop. Set `SAUSAGE_FLAKE` to run another flake
|
||||
URL instead, for example your fork.
|
||||
|
||||
**The mediator and all agents NEVER call `core.restart` without the person's
|
||||
explicit approval each time.** This also applies to legacy `core.exit`, an
|
||||
alias with the same supervisor gate, response-before-exit behavior, and
|
||||
request coalescing.
|
||||
See [the protocol](PROTOCOL.md#host-restart) for the shutdown contract.
|
||||
|
||||
From a clone (`git clone https://gitea.surma.technology/surma/sausage`), the
|
||||
same commands are `nix run`, `nix run -- init`, `nix run -- run`, and
|
||||
`nix run .#loop`.
|
||||
|
||||
### Headless
|
||||
|
||||
`--headless` (or `SAUSAGE_HEADLESS=1`) turns the console input off. The host
|
||||
runs until SIGTERM or SIGINT (exit code 0) or until a plugin calls
|
||||
`core.restart` (requires `SAUSAGE_LOOP=1`; exit code 75 restarts the loop):
|
||||
|
||||
```sh
|
||||
nix run git+https://gitea.surma.technology/surma/sausage#loop -- --headless my-world
|
||||
```
|
||||
|
||||
## The console
|
||||
|
||||
| Input | Effect |
|
||||
| --- | --- |
|
||||
| text | a message to the agent (`agent.chat`) |
|
||||
| `<plugin>.<method> {json}` | a call. The console prints the result. |
|
||||
| `ls [path]`, `cat <path>` | the filesystem as a process sees it, with `/proc` and `/run` |
|
||||
| `methods <plugin>` | the methods of a plugin, with params and docs |
|
||||
| `ps` | the running processes |
|
||||
| `log [plugin or pid] [-n N]` | the journal |
|
||||
| `activate <plugin> <wasm>`, `revert <plugin>` | activate a new version, or go back |
|
||||
| `stop <pid>`, `kill <pid>` | stop a process |
|
||||
| `help`, `quit` | |
|
||||
|
||||
## The world folder
|
||||
|
||||
```text
|
||||
plugins/<name>/plugin.wasm the current version of a plugin
|
||||
plugins/<name>/source/ its source
|
||||
state/<plugin>/ the state of a plugin, by convention
|
||||
state/core/journal.sqlite the journal: stderr of all processes, and core events
|
||||
config/boot.yaml ordered boot services, supervised for crashes (PROTOCOL §8)
|
||||
config/agent.yaml the configuration of the agent
|
||||
```
|
||||
|
||||
Boot services restart on process crashes, not on readiness responses. More
|
||||
than 5 crashes within 10 seconds stops automatic attempts for that service
|
||||
and journals `service gave up`; an upgrade does not reset that decision.
|
||||
Explicitly stopping or killing a service also leaves it stopped for this
|
||||
host lifetime. See [Boot and services](PROTOCOL.md#8-boot-and-services).
|
||||
|
||||
Rust builds use the SDK in `plugins/cargo/sdk/` directly. The host seeds this
|
||||
folder only when it is missing; restarts and host upgrades preserve existing
|
||||
files. SDK edits take effect on the next `cargo.build`, without a restart.
|
||||
To reset to the host's bundled SDK, back up any edits and remove the whole
|
||||
folder; the next startup or build re-seeds it.
|
||||
|
||||
Each process also sees `/proc` (the processes and the state of the core),
|
||||
`/self` (its own folder in `/proc`), and `/run` (shared files in memory).
|
||||
|
||||
To replace a plugin by hand, copy a `.wasm` file to its `plugin.wasm`. That
|
||||
skips the interface checks. `revert <plugin>` goes back to the version
|
||||
before the last activation.
|
||||
|
||||
## More
|
||||
|
||||
- [PROTOCOL.md](PROTOCOL.md): calls, processes, the filesystem, locks,
|
||||
interfaces, and `cargo`. Read this to write a plugin in any language.
|
||||
- [DECISIONS.md](DECISIONS.md): why Sausage is the way it is.
|
||||
- [`sdk/`](sdk/): `sausage-plugin`, the SDK for Rust plugins.
|
||||
- [`examples/`](examples/): small plugins.
|
||||
- [AGENTS.md](AGENTS.md): how to work on Sausage itself.
|
||||
The development shell provides Rust with `wasm32-wasip2`, wasmtime, and wasm-tools.
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "agent"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "The Sausage agent plugin."
|
||||
|
||||
[dependencies]
|
||||
sausage-plugin = { version = "0.1", path = "../sdk" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml_ng = "0.10"
|
||||
|
||||
[target.'cfg(target_os = "wasi")'.dependencies]
|
||||
wstd = "0.6"
|
||||
@@ -1,88 +0,0 @@
|
||||
//! History window selection and context-size error detection.
|
||||
|
||||
use crate::llm::Entry;
|
||||
|
||||
/// Recognize provider errors that permit a retry with fewer complete turns.
|
||||
pub fn is_too_large(error: &str) -> bool {
|
||||
let Some((status, body)) = error
|
||||
.strip_prefix("HTTP ")
|
||||
.and_then(|error| error.split_once(':'))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if status.split_whitespace().next() == Some("413") {
|
||||
return true;
|
||||
}
|
||||
if status.split_whitespace().next() != Some("400") {
|
||||
return false;
|
||||
}
|
||||
let body = body.to_ascii_lowercase();
|
||||
body.contains("context_length_exceeded")
|
||||
|| body.contains("maximum context length")
|
||||
|| body.contains("context length exceeded")
|
||||
|| body.contains("too large")
|
||||
|| (body.contains("too long")
|
||||
&& ["context", "input", "prompt"]
|
||||
.iter()
|
||||
.any(|term| body.contains(term)))
|
||||
}
|
||||
|
||||
/// Find a user-turn boundary without discarding any of the current turn.
|
||||
pub fn next_turn_start(history: &[Entry], from: usize, current: usize) -> Option<usize> {
|
||||
let end = current.min(history.len().checked_sub(1)?);
|
||||
(from..=end).find(|&index| matches!(&history[index], Entry::User { .. }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_context_size_errors_only_for_http_400_or_413() {
|
||||
assert!(is_too_large("HTTP 400: context_length_exceeded"));
|
||||
assert!(is_too_large("HTTP 413: prompt is too long"));
|
||||
assert!(is_too_large("HTTP 413: {\"type\":\"request_too_large\"}"));
|
||||
assert!(is_too_large("HTTP 413: {}"));
|
||||
assert!(is_too_large("HTTP 400: maximum context length"));
|
||||
assert!(is_too_large("HTTP 400: request is too large"));
|
||||
assert!(!is_too_large("HTTP 500: prompt is too long"));
|
||||
assert!(!is_too_large("HTTP 400: invalid token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_skip_advances_by_whole_user_turns() {
|
||||
let history = vec![
|
||||
Entry::User {
|
||||
text: "first".into(),
|
||||
},
|
||||
Entry::ToolCall {
|
||||
id: "a".into(),
|
||||
name: "tool".into(),
|
||||
arguments: "{}".into(),
|
||||
},
|
||||
Entry::ToolResult {
|
||||
id: "a".into(),
|
||||
output: "done".into(),
|
||||
},
|
||||
Entry::User {
|
||||
text: "second".into(),
|
||||
},
|
||||
Entry::ToolCall {
|
||||
id: "b".into(),
|
||||
name: "tool".into(),
|
||||
arguments: "{}".into(),
|
||||
},
|
||||
Entry::ToolResult {
|
||||
id: "b".into(),
|
||||
output: "done".into(),
|
||||
},
|
||||
Entry::User {
|
||||
text: "current".into(),
|
||||
},
|
||||
];
|
||||
assert_eq!(next_turn_start(&history, 0, 6), Some(0));
|
||||
assert_eq!(next_turn_start(&history, 1, 6), Some(3));
|
||||
assert_eq!(next_turn_start(&history, 4, 6), Some(6));
|
||||
assert_eq!(next_turn_start(&history, usize::MAX, 6).unwrap_or(6), 6);
|
||||
}
|
||||
}
|
||||
@@ -1,657 +0,0 @@
|
||||
//! Inference through OpenAI Responses, Chat Completions, and Anthropic Messages.
|
||||
//!
|
||||
//! The conversation is stored in a neutral format, so the agent can switch
|
||||
//! between the three shapes.
|
||||
|
||||
use sausage_plugin::schema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
#[cfg(target_os = "wasi")]
|
||||
use wstd::http::{Body, Client, Request};
|
||||
|
||||
/// Inference configuration from `/config/agent.yaml`.
|
||||
#[derive(Deserialize, Clone, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
/// `openai-responses`, `openai-chat-completions`, or `anthropic-messages`.
|
||||
pub api: String,
|
||||
/// The SDK base URL: versioned for OpenAI, unversioned for Anthropic.
|
||||
pub base_url: String,
|
||||
/// The provider's model identifier.
|
||||
pub model: String,
|
||||
/// The API credential sent in the provider's authentication header.
|
||||
pub token: String,
|
||||
/// Permit an empty token and omit authentication headers.
|
||||
pub allow_no_token: bool,
|
||||
/// An optional cap on generated tokens.
|
||||
#[serde(default)]
|
||||
pub max_tokens: Option<u32>,
|
||||
/// The thinking level of each turn. Unset: the model's default.
|
||||
#[serde(default)]
|
||||
pub thinking: Option<Thinking>,
|
||||
}
|
||||
|
||||
/// How much the model thinks before it answers.
|
||||
///
|
||||
/// OpenAI Responses gets `reasoning.effort`, Chat Completions gets `reasoning_effort`, and Anthropic
|
||||
/// gets adaptive thinking with `output_config.effort`. If the model rejects a level, the agent uses
|
||||
/// the nearest level that the model accepts, never `off`.
|
||||
#[schema]
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Thinking {
|
||||
/// No thinking: OpenAI gets the effort `none`, and Anthropic gets disabled thinking. If an
|
||||
/// OpenAI model cannot switch thinking off, the agent uses `low`.
|
||||
Off,
|
||||
/// The least thinking.
|
||||
Low,
|
||||
/// Moderate thinking.
|
||||
Medium,
|
||||
/// Much thinking.
|
||||
High,
|
||||
/// More thinking than `high`, where the model supports it.
|
||||
Xhigh,
|
||||
/// The most thinking that the model supports.
|
||||
Max,
|
||||
}
|
||||
|
||||
impl Thinking {
|
||||
/// The effort that an OpenAI request carries: `none`, or one of `LEVELS`.
|
||||
fn effort(self) -> &'static str {
|
||||
match self {
|
||||
Thinking::Off => "none",
|
||||
Thinking::Low => "low",
|
||||
Thinking::Medium => "medium",
|
||||
Thinking::High => "high",
|
||||
Thinking::Xhigh => "xhigh",
|
||||
Thinking::Max => "max",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self, String> {
|
||||
let bytes = std::fs::read("/config/agent.yaml")
|
||||
.map_err(|e| format!("/config/agent.yaml: missing configuration file: {e}"))?;
|
||||
Self::parse(&bytes)
|
||||
}
|
||||
|
||||
fn parse(bytes: &[u8]) -> Result<Self, String> {
|
||||
let cfg = serde_yaml_ng::from_slice::<Option<Self>>(bytes)
|
||||
.map_err(|e| format!("/config/agent.yaml: {e}"))?
|
||||
.unwrap_or_default();
|
||||
let invalid = if ![
|
||||
"openai-responses",
|
||||
"openai-chat-completions",
|
||||
"anthropic-messages",
|
||||
]
|
||||
.contains(&cfg.api.as_str())
|
||||
{
|
||||
Some("api (missing or unknown API shape)")
|
||||
} else if cfg.base_url.trim().is_empty() {
|
||||
Some("base_url")
|
||||
} else if cfg.model.trim().is_empty() {
|
||||
Some("model")
|
||||
} else if (cfg.token.trim().is_empty() && !cfg.allow_no_token)
|
||||
|| cfg.token == "CHANGE_ME"
|
||||
|| cfg.token.starts_with("YOUR_")
|
||||
{
|
||||
Some("token (set a key, or set token: \"\" and allow_no_token: true)")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(field) = invalid {
|
||||
return Err(format!("/config/agent.yaml: configure field {field}"));
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
fn bearer(&self) -> Vec<(&'static str, String)> {
|
||||
if self.token.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![("authorization", format!("Bearer {}", self.token))]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thinking levels, lowest first.
|
||||
pub const LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
/// The level nearest to `want` (lower on a tie) among those a 400 error lists in quotes (else all levels), not
|
||||
/// yet `tried`. None: nothing left, so the request goes out without an effort field (model default).
|
||||
fn fallback_effort(want: &str, error: &str, tried: &[String]) -> Option<String> {
|
||||
let listed: Vec<&str> = error
|
||||
.split('\'')
|
||||
.skip(1)
|
||||
.step_by(2)
|
||||
.filter(|w| LEVELS.contains(w))
|
||||
.collect();
|
||||
let pool = if listed.iter().any(|l| *l != want) {
|
||||
listed
|
||||
} else {
|
||||
LEVELS.to_vec()
|
||||
};
|
||||
// `none` is below all levels, so its nearest level is `low`.
|
||||
let at = LEVELS
|
||||
.iter()
|
||||
.position(|l| *l == want)
|
||||
.map_or(-1, |i| i as i32);
|
||||
pool.into_iter()
|
||||
.filter(|l| *l != want && !tried.iter().any(|t| t == l))
|
||||
.min_by_key(|l| {
|
||||
let i = LEVELS.iter().position(|x| x == l).unwrap_or(1) as i32;
|
||||
((i - at).abs(), i > at)
|
||||
})
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::fallback_effort;
|
||||
|
||||
#[test]
|
||||
fn yaml_configuration_and_credentials() {
|
||||
use super::Config;
|
||||
for text in [
|
||||
"",
|
||||
"api: unknown",
|
||||
"api: openai-responses\nbase_url: url\nmodel: model\ntoken: CHANGE_ME",
|
||||
"api: openai-responses\nbase_url: url\nmodel: model\ntoken: ''",
|
||||
] {
|
||||
assert!(
|
||||
Config::parse(text.as_bytes())
|
||||
.err()
|
||||
.unwrap()
|
||||
.contains("/config/agent.yaml")
|
||||
);
|
||||
}
|
||||
let cfg = Config::parse(b"api: openai-chat-completions\nbase_url: http://localhost/v1\nmodel: local\ntoken: ''\nallow_no_token: true").unwrap();
|
||||
assert!(cfg.bearer().is_empty());
|
||||
assert_eq!(cfg.thinking, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_levels() {
|
||||
use super::{Config, LEVELS, Thinking};
|
||||
let base = "api: openai-responses\nbase_url: url\nmodel: model\ntoken: key\nthinking: ";
|
||||
let parse = |level: &str| Config::parse(format!("{base}{level}").as_bytes());
|
||||
assert_eq!(parse("off").unwrap().thinking, Some(Thinking::Off));
|
||||
assert_eq!(Thinking::Off.effort(), "none");
|
||||
for level in LEVELS {
|
||||
assert_eq!(parse(level).unwrap().thinking.unwrap().effort(), level);
|
||||
}
|
||||
let error = parse("hihg").err().unwrap();
|
||||
assert!(error.contains("/config/agent.yaml"), "{error}");
|
||||
assert!(error.contains("thinking"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_request_and_response_mapping() {
|
||||
use super::*;
|
||||
let cfg = Config {
|
||||
model: "test".into(),
|
||||
max_tokens: Some(100),
|
||||
..Config::default()
|
||||
};
|
||||
let response = json!({"choices":[{"message":{"content":"interim","tool_calls":[{"id":"one","function":{"name":"read_file","arguments":"{}"}},{"id":"two","function":{"name":"read_file","arguments":"{}"}}]}}]});
|
||||
let mut history = chat_entries(&response);
|
||||
assert_eq!(history.len(), 3);
|
||||
history.push(Entry::ToolResult {
|
||||
id: "one".into(),
|
||||
output: "error".into(),
|
||||
});
|
||||
let tools = [Tool {
|
||||
name: "read_file",
|
||||
description: "read",
|
||||
parameters: json!({"type":"object"}),
|
||||
}];
|
||||
let body = chat_body(&cfg, "system", &tools, &history, Some("high"));
|
||||
assert_eq!(body["messages"][1]["content"], "interim");
|
||||
assert_eq!(
|
||||
body["messages"][1]["tool_calls"].as_array().unwrap().len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
body["messages"][2],
|
||||
json!({"role":"tool","tool_call_id":"one","content":"error"})
|
||||
);
|
||||
assert_eq!(body["tools"][0]["function"]["name"], "read_file");
|
||||
assert_eq!(body["reasoning_effort"], "high");
|
||||
assert_eq!(body["max_completion_tokens"], 100);
|
||||
assert!(
|
||||
matches!(&chat_entries(&json!({"choices":[{"message":{"content":"final"}}]}))[0], Entry::Assistant {text,..} if text == "final")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_accepted_level() {
|
||||
let anthropic =
|
||||
"HTTP 400: output_config.effort: Input should be 'low', 'medium', 'high' or 'max'";
|
||||
assert_eq!(
|
||||
fallback_effort("xhigh", anthropic, &["xhigh".into()]).as_deref(),
|
||||
Some("high")
|
||||
);
|
||||
let openai = "HTTP 400: Invalid value: 'max'. Supported values are: 'none', 'minimal', 'low', 'medium', and 'high'.";
|
||||
assert_eq!(
|
||||
fallback_effort("max", openai, &["max".into()]).as_deref(),
|
||||
Some("high")
|
||||
);
|
||||
// A thinking-only model rejects `none`: use the lowest level.
|
||||
let glm = "HTTP 400: GLM-5.3 is a thinking-only model; disabling thinking (reasoning_effort='none') is not supported.";
|
||||
assert_eq!(
|
||||
fallback_effort("none", glm, &["none".into()]).as_deref(),
|
||||
Some("low")
|
||||
);
|
||||
// No list in the error: step to the neighbour, never to off.
|
||||
assert_eq!(
|
||||
fallback_effort("max", "HTTP 400: effort max not supported", &["max".into()])
|
||||
.as_deref(),
|
||||
Some("xhigh")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One append-only entry in the provider-neutral conversation.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Entry {
|
||||
/// A person's contribution starts a new turn.
|
||||
User {
|
||||
/// The complete contribution.
|
||||
text: String,
|
||||
},
|
||||
/// Text produced by the model.
|
||||
Assistant {
|
||||
/// The generated text.
|
||||
text: String,
|
||||
/// The optional provider phase, preserved for subsequent requests.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
phase: Option<String>,
|
||||
},
|
||||
/// A requested tool operation.
|
||||
ToolCall {
|
||||
/// The provider's correlation identifier.
|
||||
id: String,
|
||||
/// The tool name.
|
||||
name: String,
|
||||
/// The JSON parameter object as text.
|
||||
arguments: String,
|
||||
},
|
||||
/// The outcome of a tool operation, including tool errors.
|
||||
ToolResult {
|
||||
/// The correlation identifier of the tool call.
|
||||
id: String,
|
||||
/// The JSON outcome as text.
|
||||
output: String,
|
||||
},
|
||||
/// A provider item that only this API shape understands (OpenAI reasoning, Anthropic thinking).
|
||||
Opaque {
|
||||
/// The API shape that owns this entry.
|
||||
api: String,
|
||||
/// The model that produced this item. Legacy items without a model are not replayed.
|
||||
#[serde(default)]
|
||||
model: String,
|
||||
/// The original provider object.
|
||||
item: Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// A tool contract shared by all API shapes.
|
||||
pub struct Tool {
|
||||
/// The function identifier exposed to the model.
|
||||
pub name: &'static str,
|
||||
/// The explanation of the tool's behavior.
|
||||
pub description: &'static str,
|
||||
/// The JSON Schema for named parameters.
|
||||
pub parameters: Value,
|
||||
}
|
||||
|
||||
/// Request the next model step, with provider-specific reasoning items intact.
|
||||
pub async fn complete(
|
||||
cfg: &Config,
|
||||
system: &str,
|
||||
tools: &[Tool],
|
||||
history: &[Entry],
|
||||
) -> Result<Vec<Entry>, String> {
|
||||
let want = cfg.thinking.map(|level| level.effort().to_string());
|
||||
let mut effort = want.clone();
|
||||
let mut tried = Vec::new();
|
||||
loop {
|
||||
let result = match cfg.api.as_str() {
|
||||
"openai-responses" => openai(cfg, system, tools, history, effort.as_deref()).await,
|
||||
"openai-chat-completions" => chat(cfg, system, tools, history, effort.as_deref()).await,
|
||||
"anthropic-messages" => anthropic(cfg, system, tools, history, effort.as_deref()).await,
|
||||
other => return Err(format!("unknown API shape {other:?}")),
|
||||
};
|
||||
match (result, effort.clone()) {
|
||||
// The model rejected the effort level: retry with the nearest accepted one (thinking stays on).
|
||||
(Err(e), Some(current)) if e.starts_with("HTTP 400") && e.contains("effort") => {
|
||||
tried.push(current.clone());
|
||||
effort = fallback_effort(want.as_deref().unwrap_or(""), &e, &tried);
|
||||
eprintln!(
|
||||
"agent: {} rejected thinking effort {current:?}; retrying with {effort:?}: {e}",
|
||||
cfg.model
|
||||
);
|
||||
}
|
||||
(result, _) => return result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Native unit tests exercise history logic without an HTTP runtime.
|
||||
#[cfg(not(target_os = "wasi"))]
|
||||
async fn post(_url: &str, _headers: &[(&str, String)], _body: &Value) -> Result<Value, String> {
|
||||
Err("inference requires the WASI HTTP runtime".into())
|
||||
}
|
||||
|
||||
/// Send JSON through wasi:http and retain error bodies for context-size detection.
|
||||
#[cfg(target_os = "wasi")]
|
||||
async fn post(url: &str, headers: &[(&str, String)], body: &Value) -> Result<Value, String> {
|
||||
let mut req = Request::post(url).header("content-type", "application/json");
|
||||
for (k, v) in headers {
|
||||
req = req.header(*k, v.as_str());
|
||||
}
|
||||
let req = req
|
||||
.body(Body::from(body.to_string()))
|
||||
.map_err(|e| format!("request: {e}"))?;
|
||||
let client = Client::new();
|
||||
let resp = client
|
||||
.send(req)
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request failed: {e:?}"))?;
|
||||
let status = resp.status();
|
||||
let mut body = resp.into_body();
|
||||
let text = body
|
||||
.str_contents()
|
||||
.await
|
||||
.map_err(|e| format!("reading the response failed: {e:?}"))?
|
||||
.to_string();
|
||||
if !status.is_success() {
|
||||
return Err(format!("HTTP {status}: {text}"));
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|e| format!("invalid JSON in response: {e}"))
|
||||
}
|
||||
|
||||
/// Translate neutral history to OpenAI Responses and preserve returned reasoning items.
|
||||
async fn openai(
|
||||
cfg: &Config,
|
||||
system: &str,
|
||||
tools: &[Tool],
|
||||
history: &[Entry],
|
||||
effort: Option<&str>,
|
||||
) -> Result<Vec<Entry>, String> {
|
||||
let mut input = Vec::new();
|
||||
for e in history {
|
||||
match e {
|
||||
Entry::User { text } => input.push(json!({"role": "user", "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 } => {
|
||||
input.push(json!({"type": "function_call", "call_id": id, "name": name, "arguments": arguments}))
|
||||
}
|
||||
Entry::ToolResult { id, output } => {
|
||||
input.push(json!({"type": "function_call_output", "call_id": id, "output": output}))
|
||||
}
|
||||
Entry::Opaque { api, model, item } if api == "openai-responses" && model == &cfg.model => input.push(item.clone()),
|
||||
Entry::Opaque { .. } => {}
|
||||
}
|
||||
}
|
||||
let tools: Vec<Value> = tools
|
||||
.iter()
|
||||
.map(|t| json!({"type": "function", "name": t.name, "description": t.description, "parameters": t.parameters}))
|
||||
.collect();
|
||||
let mut body = json!({
|
||||
"model": cfg.model,
|
||||
"instructions": system,
|
||||
"input": input,
|
||||
"tools": tools,
|
||||
"store": false,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
});
|
||||
if let Some(max_tokens) = cfg.max_tokens {
|
||||
body["max_output_tokens"] = json!(max_tokens);
|
||||
}
|
||||
if let Some(effort) = effort {
|
||||
body["reasoning"] = json!({"effort": effort});
|
||||
}
|
||||
let url = format!("{}/responses", cfg.base_url.trim_end_matches('/'));
|
||||
let resp = post(&url, &cfg.bearer(), &body).await?;
|
||||
let mut out = Vec::new();
|
||||
for item in resp["output"].as_array().cloned().unwrap_or_default() {
|
||||
match item["type"].as_str() {
|
||||
Some("message") => {
|
||||
let text: String = item["content"]
|
||||
.as_array()
|
||||
.map(|parts| {
|
||||
parts
|
||||
.iter()
|
||||
.filter(|p| p["type"] == "output_text")
|
||||
.filter_map(|p| p["text"].as_str())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let phase = item["phase"].as_str().map(str::to_string);
|
||||
if !text.is_empty() || phase.is_some() {
|
||||
out.push(Entry::Assistant { text, phase });
|
||||
}
|
||||
}
|
||||
Some("function_call") => out.push(Entry::ToolCall {
|
||||
id: item["call_id"].as_str().unwrap_or_default().to_string(),
|
||||
name: item["name"].as_str().unwrap_or_default().to_string(),
|
||||
arguments: item["arguments"].as_str().unwrap_or("{}").to_string(),
|
||||
}),
|
||||
Some("reasoning") => out.push(Entry::Opaque {
|
||||
api: "openai-responses".into(),
|
||||
model: cfg.model.clone(),
|
||||
item,
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if resp["status"] == "incomplete"
|
||||
&& !out
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, Entry::Assistant { text, .. } if !text.is_empty()))
|
||||
{
|
||||
return Err(format!(
|
||||
"incomplete response: {}",
|
||||
resp["incomplete_details"]
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Translate neutral history to Anthropic Messages and merge consecutive roles.
|
||||
async fn anthropic(
|
||||
cfg: &Config,
|
||||
system: &str,
|
||||
tools: &[Tool],
|
||||
history: &[Entry],
|
||||
effort: Option<&str>,
|
||||
) -> Result<Vec<Entry>, String> {
|
||||
let disabled = cfg.thinking == Some(Thinking::Off);
|
||||
// Merge consecutive blocks of the same role into one message.
|
||||
let mut messages: Vec<(String, Vec<Value>)> = Vec::new();
|
||||
let mut push = |role: &str, block: Value| match messages.last_mut() {
|
||||
Some((r, blocks)) if r == role => blocks.push(block),
|
||||
_ => messages.push((role.to_string(), vec![block])),
|
||||
};
|
||||
for e in history {
|
||||
match e {
|
||||
Entry::User { text } => push("user", json!({"type": "text", "text": text})),
|
||||
Entry::Assistant { text, .. } => {
|
||||
push("assistant", json!({"type": "text", "text": text}))
|
||||
}
|
||||
Entry::ToolCall {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
} => {
|
||||
let input: Value = serde_json::from_str(arguments).unwrap_or(json!({}));
|
||||
push(
|
||||
"assistant",
|
||||
json!({"type": "tool_use", "id": id, "name": name, "input": input}),
|
||||
)
|
||||
}
|
||||
Entry::ToolResult { id, output } => push(
|
||||
"user",
|
||||
json!({"type": "tool_result", "tool_use_id": id, "content": output}),
|
||||
),
|
||||
// Thinking blocks go back unchanged (with their signature): required next to tool use.
|
||||
Entry::Opaque { api, model, item }
|
||||
if api == "anthropic-messages" && model == &cfg.model && !disabled =>
|
||||
{
|
||||
push("assistant", item.clone())
|
||||
}
|
||||
Entry::Opaque { .. } => {}
|
||||
}
|
||||
}
|
||||
let messages: Vec<Value> = messages
|
||||
.into_iter()
|
||||
.map(|(role, content)| json!({"role": role, "content": content}))
|
||||
.collect();
|
||||
let tools: Vec<Value> = tools
|
||||
.iter()
|
||||
.map(
|
||||
|t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters}),
|
||||
)
|
||||
.collect();
|
||||
let mut body = json!({
|
||||
"model": cfg.model,
|
||||
"system": system,
|
||||
"messages": messages,
|
||||
"tools": tools,
|
||||
"max_tokens": cfg.max_tokens.unwrap_or(32000),
|
||||
});
|
||||
// Unset: the model's default. Newer models think by default.
|
||||
match cfg.thinking {
|
||||
None => {}
|
||||
Some(Thinking::Off) => body["thinking"] = json!({"type": "disabled"}),
|
||||
Some(_) => {
|
||||
body["thinking"] = json!({"type": "adaptive"});
|
||||
if let Some(effort) = effort {
|
||||
body["output_config"] = json!({"effort": effort});
|
||||
}
|
||||
}
|
||||
}
|
||||
let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/'));
|
||||
let mut headers = vec![("anthropic-version", "2023-06-01".to_string())];
|
||||
if !cfg.token.is_empty() {
|
||||
headers.push(("x-api-key", cfg.token.clone()));
|
||||
}
|
||||
let resp = post(&url, &headers, &body).await?;
|
||||
let mut out = Vec::new();
|
||||
for block in resp["content"].as_array().cloned().unwrap_or_default() {
|
||||
match block["type"].as_str() {
|
||||
Some("text") => {
|
||||
let text = block["text"].as_str().unwrap_or_default().to_string();
|
||||
if !text.is_empty() {
|
||||
out.push(Entry::Assistant { text, phase: None });
|
||||
}
|
||||
}
|
||||
Some("tool_use") => out.push(Entry::ToolCall {
|
||||
id: block["id"].as_str().unwrap_or_default().to_string(),
|
||||
name: block["name"].as_str().unwrap_or_default().to_string(),
|
||||
arguments: block["input"].to_string(),
|
||||
}),
|
||||
Some("thinking") | Some("redacted_thinking") => out.push(Entry::Opaque {
|
||||
api: "anthropic-messages".into(),
|
||||
model: cfg.model.clone(),
|
||||
item: block,
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Translate neutral entries into the Chat Completions message format.
|
||||
fn chat_body(
|
||||
cfg: &Config,
|
||||
system: &str,
|
||||
tools: &[Tool],
|
||||
history: &[Entry],
|
||||
effort: Option<&str>,
|
||||
) -> Value {
|
||||
let mut messages = vec![json!({"role":"system", "content":system})];
|
||||
for entry in history {
|
||||
match entry {
|
||||
Entry::User { text } => messages.push(json!({"role":"user","content":text})),
|
||||
Entry::Assistant { text, .. } => {
|
||||
messages.push(json!({"role":"assistant","content":text}))
|
||||
}
|
||||
Entry::ToolCall {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
} => {
|
||||
if messages.last().unwrap()["role"] != "assistant" {
|
||||
messages.push(json!({"role":"assistant","content":null}));
|
||||
}
|
||||
let last = messages.last_mut().unwrap();
|
||||
if !last["tool_calls"].is_array() {
|
||||
last["tool_calls"] = json!([]);
|
||||
}
|
||||
last["tool_calls"].as_array_mut().unwrap().push(json!({"id":id,"type":"function","function":{"name":name,"arguments":arguments}}));
|
||||
}
|
||||
Entry::ToolResult { id, output } => {
|
||||
messages.push(json!({"role":"tool","tool_call_id":id,"content":output}))
|
||||
}
|
||||
Entry::Opaque { .. } => {}
|
||||
}
|
||||
}
|
||||
let tools: Vec<Value> = tools.iter().map(|t| json!({"type":"function","function":{"name":t.name,"description":t.description,"parameters":t.parameters}})).collect();
|
||||
let mut body = json!({"model":cfg.model,"messages":messages,"tools":tools});
|
||||
if let Some(max) = cfg.max_tokens {
|
||||
body["max_completion_tokens"] = json!(max);
|
||||
}
|
||||
if let Some(effort) = effort {
|
||||
body["reasoning_effort"] = json!(effort);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
/// Map text and tool calls back into the shared history.
|
||||
fn chat_entries(response: &Value) -> Vec<Entry> {
|
||||
let message = &response["choices"][0]["message"];
|
||||
let mut entries = Vec::new();
|
||||
if let Some(text) = message["content"].as_str().filter(|s| !s.is_empty()) {
|
||||
entries.push(Entry::Assistant {
|
||||
text: text.into(),
|
||||
phase: None,
|
||||
});
|
||||
}
|
||||
for call in message["tool_calls"].as_array().into_iter().flatten() {
|
||||
entries.push(Entry::ToolCall {
|
||||
id: call["id"].as_str().unwrap_or_default().into(),
|
||||
name: call["function"]["name"].as_str().unwrap_or_default().into(),
|
||||
arguments: call["function"]["arguments"]
|
||||
.as_str()
|
||||
.unwrap_or("{}")
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
cfg: &Config,
|
||||
system: &str,
|
||||
tools: &[Tool],
|
||||
history: &[Entry],
|
||||
effort: Option<&str>,
|
||||
) -> Result<Vec<Entry>, String> {
|
||||
let response = post(
|
||||
&format!("{}/chat/completions", cfg.base_url.trim_end_matches('/')),
|
||||
&cfg.bearer(),
|
||||
&chat_body(cfg, system, tools, history, effort),
|
||||
)
|
||||
.await?;
|
||||
Ok(chat_entries(&response))
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
//! Answer chat calls with an LLM and tools, under one lock for the complete turn.
|
||||
mod history;
|
||||
mod llm;
|
||||
mod tools;
|
||||
|
||||
use history::{is_too_large, next_turn_start};
|
||||
use llm::{Config, Entry, Thinking};
|
||||
use sausage_plugin::{Ctx, Error, Result, lock, rpc, schema};
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
|
||||
/// The instructions sent with every inference request.
|
||||
const SYSTEM: &str = include_str!("prompt.md");
|
||||
/// The shared, append-only conversation.
|
||||
const HISTORY: &str = "/state/agent/history.jsonl";
|
||||
/// The first retained turn after a context-size rejection.
|
||||
const SKIP: &str = "/state/agent/history.skip";
|
||||
/// Each chat process reads the shared history after it acquires the lock.
|
||||
struct Agent;
|
||||
/// The person's next contribution to the conversation.
|
||||
#[schema]
|
||||
struct ChatParams {
|
||||
/// The complete text of the person's request.
|
||||
text: String,
|
||||
/// The thinking level of this turn only. Without it, the turn uses `thinking` from
|
||||
/// `/config/agent.yaml`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
thinking: Option<Thinking>,
|
||||
}
|
||||
|
||||
#[rpc(grace_seconds = 600)]
|
||||
impl Agent {
|
||||
/// Answer a person, with interim text notifications and a final text result.
|
||||
///
|
||||
/// The history lock covers inference and tools, so concurrent chats cannot interleave turns.
|
||||
async fn chat(&self, ctx: &Ctx, p: ChatParams) -> Result<String> {
|
||||
let _guard = lock(HISTORY).await?;
|
||||
std::fs::create_dir_all("/state/agent")?;
|
||||
let mut cfg = Config::load().map_err(|error| Error::new(-32603, error))?;
|
||||
cfg.thinking = p.thinking.or(cfg.thinking);
|
||||
let mut entries = load_history(HISTORY)?;
|
||||
repair_tools(&mut entries)?;
|
||||
let current = entries.len();
|
||||
append(&mut entries, Entry::User { text: p.text })?;
|
||||
let mut start = std::fs::read_to_string(SKIP)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.unwrap_or(0);
|
||||
start = next_turn_start(&entries, start, current).unwrap_or(current);
|
||||
let definitions = tools::definitions();
|
||||
loop {
|
||||
let response = match llm::complete(&cfg, SYSTEM, &definitions, &entries[start..]).await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
if is_too_large(&error)
|
||||
&& let Some(next) = next_turn_start(&entries, start + 1, current)
|
||||
{
|
||||
start = next;
|
||||
continue;
|
||||
}
|
||||
return Err(Error::new(-32603, error));
|
||||
}
|
||||
};
|
||||
std::fs::write(SKIP, start.to_string())?;
|
||||
let has_tools = response
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, Entry::ToolCall { .. }));
|
||||
let mut final_text = String::new();
|
||||
let mut calls = Vec::new();
|
||||
for entry in response {
|
||||
match &entry {
|
||||
Entry::Assistant { text, phase }
|
||||
if has_tools || phase.as_deref() == Some("commentary") =>
|
||||
{
|
||||
ctx.notify("text", json!({"text":text}))?
|
||||
}
|
||||
Entry::Assistant { text, .. } => {
|
||||
if !final_text.is_empty() && !text.is_empty() {
|
||||
final_text.push_str("\n\n");
|
||||
}
|
||||
final_text.push_str(text);
|
||||
}
|
||||
Entry::ToolCall {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
} => calls.push((id.clone(), name.clone(), arguments.clone())),
|
||||
_ => {}
|
||||
}
|
||||
append(&mut entries, entry)?;
|
||||
}
|
||||
if !has_tools {
|
||||
return Ok(final_text);
|
||||
}
|
||||
for (id, name, arguments) in calls {
|
||||
let args = serde_json::from_str::<serde_json::Value>(&arguments);
|
||||
let output = match &args {
|
||||
Ok(args) => tools::run(&name, args)
|
||||
.await
|
||||
.unwrap_or_else(|error| json!({"error":error})),
|
||||
Err(error) => json!({"error":format!("arguments are not valid JSON: {error}")}),
|
||||
};
|
||||
let output = if name == "read_file"
|
||||
&& args
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|a| a["path"].as_str())
|
||||
.is_some_and(|p| p.starts_with("/logs/"))
|
||||
{
|
||||
output["content"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| output.to_string())
|
||||
} else {
|
||||
output.to_string()
|
||||
};
|
||||
let tail = args
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|a| a["method"].as_str())
|
||||
.is_some_and(|m| {
|
||||
[".exec", ".log", ".build", ".test"]
|
||||
.iter()
|
||||
.any(|suffix| m.ends_with(suffix))
|
||||
});
|
||||
let output = tools::overflow::guard(output, "default", &id, tail);
|
||||
append(&mut entries, Entry::ToolResult { id, output })?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read persisted entries in their original order. A new world has no history file.
|
||||
fn load_history(path: &str) -> Result<Vec<Entry>> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => Ok(text
|
||||
.lines()
|
||||
.filter_map(|line| match serde_json::from_str(line) {
|
||||
Ok(entry) => Some(entry),
|
||||
Err(error) => {
|
||||
eprintln!("skip damaged history line: {error}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
/// Persist an entry before it enters the current inference context.
|
||||
fn append(history: &mut Vec<Entry>, entry: Entry) -> Result<()> {
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(HISTORY)?;
|
||||
file.write_all(format!("{}\n", serde_json::to_string(&entry)?).as_bytes())?;
|
||||
history.push(entry);
|
||||
Ok(())
|
||||
}
|
||||
/// Complete interrupted tool pairs before a new inference request.
|
||||
fn repair_tools(history: &mut Vec<Entry>) -> Result<()> {
|
||||
let answered: std::collections::HashSet<_> = history
|
||||
.iter()
|
||||
.filter_map(|entry| match entry {
|
||||
Entry::ToolResult { id, .. } => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let dangling: Vec<_> = history
|
||||
.iter()
|
||||
.filter_map(|entry| match entry {
|
||||
Entry::ToolCall { id, .. } if !answered.contains(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for id in dangling {
|
||||
append(
|
||||
history,
|
||||
Entry::ToolResult {
|
||||
id,
|
||||
output: json!({"error":"interrupted before the tool finished"}).to_string(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Start the plugin's JSON-RPC transport.
|
||||
fn main() {
|
||||
sausage_plugin::run(Agent);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn damaged_history_keeps_valid_entries() {
|
||||
let path = if cfg!(target_os = "wasi") {
|
||||
std::path::PathBuf::from("/history-test.jsonl")
|
||||
} else {
|
||||
std::env::temp_dir().join(format!("sausage-history-{}", std::process::id()))
|
||||
};
|
||||
std::fs::write(
|
||||
&path,
|
||||
"{\"kind\":\"user\",\"text\":\"preserved\"}\n{\"kind\":",
|
||||
)
|
||||
.unwrap();
|
||||
let history = super::load_history(path.to_str().unwrap()).unwrap();
|
||||
assert_eq!(history.len(), 1);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
//! Context-size guard; full output is in the referenced file. Never a data limit.
|
||||
use std::io::{self, Write};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
// One policy point: Pi's 50 KiB / 2000 lines, including room for the reference.
|
||||
const CONTEXT_LIMIT: (usize, usize) = (50 * 1024, 2000);
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn component(s: &str) -> String {
|
||||
s.bytes()
|
||||
.take(80)
|
||||
.map(|b| {
|
||||
if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' {
|
||||
b as char
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn exceeds_limit(output: &str) -> bool {
|
||||
output.len() > CONTEXT_LIMIT.0 || output.lines().count() > CONTEXT_LIMIT.1
|
||||
}
|
||||
|
||||
/// Save before excerpting. On storage failure return all data, not a false reference.
|
||||
pub fn guard(output: String, session: &str, id: &str, tail: bool) -> String {
|
||||
if !exceeds_limit(&output) {
|
||||
return output;
|
||||
}
|
||||
// Decode only results that crossed the context guard. Archive physical
|
||||
// string lines, not JSON escapes, even if decoding makes the text smaller.
|
||||
let output = serde_json::from_str::<serde_json::Value>(&output)
|
||||
.map(|value| super::render(&value))
|
||||
.unwrap_or(output);
|
||||
let pid = std::env::var("SAUSAGE_PID").unwrap_or_else(|_| "test".into());
|
||||
let dir = format!("/logs/agent/{}/{}", date(), component(session));
|
||||
let saved = (|| -> io::Result<String> {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
// create_new prevents overwriting even if an ID is reused or a process restarts.
|
||||
loop {
|
||||
let path = format!(
|
||||
"{dir}/{}-{}-{}.txt",
|
||||
component(id),
|
||||
component(&pid),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
match std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)
|
||||
{
|
||||
Ok(mut f) => {
|
||||
f.write_all(output.as_bytes())?;
|
||||
return Ok(path);
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
})();
|
||||
match saved {
|
||||
Ok(path) => excerpt(&output, &path, tail, 0, output.len()),
|
||||
Err(e) => {
|
||||
eprintln!("overflow archive failed: {e}; retaining complete tool result");
|
||||
output
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn date() -> String {
|
||||
let days = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
/ 86400;
|
||||
let z = days as i64 + 719468;
|
||||
let era = z / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = mp + if mp < 10 { 3 } else { -9 };
|
||||
format!("{:04}-{m:02}-{d:02}", y + i64::from(m <= 2))
|
||||
}
|
||||
|
||||
/// Excerpt an existing archive in place. Byte/line ranges refer to that file,
|
||||
/// including when a caller requests an oversized line or the entire archive.
|
||||
pub fn existing(bytes: &[u8], path: &str, args: &serde_json::Value) -> String {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let (mut from, mut to) = if let Some(line) = args["line_offset"].as_u64() {
|
||||
let from = text
|
||||
.split_inclusive('\n')
|
||||
.take(line as usize)
|
||||
.map(str::len)
|
||||
.sum::<usize>();
|
||||
let len = text[from..]
|
||||
.split_inclusive('\n')
|
||||
.take(args["line_limit"].as_u64().unwrap_or(200) as usize)
|
||||
.map(str::len)
|
||||
.sum::<usize>();
|
||||
(from, from + len)
|
||||
} else {
|
||||
let from = (args["offset"].as_u64().unwrap_or(0) as usize).min(text.len());
|
||||
(
|
||||
from,
|
||||
args["max_bytes"].as_u64().map_or(text.len(), |n| {
|
||||
from.saturating_add(n as usize).min(text.len())
|
||||
}),
|
||||
)
|
||||
};
|
||||
while !text.is_char_boundary(from) {
|
||||
from += 1;
|
||||
}
|
||||
while to > from && !text.is_char_boundary(to) {
|
||||
to -= 1;
|
||||
}
|
||||
to = to.max(from);
|
||||
excerpt(&text, path, false, from, to)
|
||||
}
|
||||
|
||||
fn excerpt(output: &str, path: &str, tail: bool, from: usize, to: usize) -> String {
|
||||
let selected = &output[from..to];
|
||||
let lines: Vec<&str> = selected.split_inclusive('\n').collect();
|
||||
let budget = CONTEXT_LIMIT.0.saturating_sub(1024);
|
||||
let mut start = 0;
|
||||
let mut end = lines.len();
|
||||
let mut bytes = 0;
|
||||
if tail {
|
||||
start = end;
|
||||
while start > 0
|
||||
&& end - start < CONTEXT_LIMIT.1.saturating_sub(2)
|
||||
&& bytes + lines[start - 1].len() <= budget
|
||||
{
|
||||
start -= 1;
|
||||
bytes += lines[start].len();
|
||||
}
|
||||
} else {
|
||||
end = 0;
|
||||
while end < lines.len()
|
||||
&& end < CONTEXT_LIMIT.1.saturating_sub(2)
|
||||
&& bytes + lines[end].len() <= budget
|
||||
{
|
||||
bytes += lines[end].len();
|
||||
end += 1;
|
||||
}
|
||||
}
|
||||
let mut shown = lines[start..end].concat();
|
||||
// A single huge line: preserve valid UTF-8 and explicitly label a partial line.
|
||||
let partial = start == end;
|
||||
if partial && !lines.is_empty() {
|
||||
if tail {
|
||||
start = lines.len() - 1;
|
||||
let line = lines[start];
|
||||
let mut from = line.len().saturating_sub(budget);
|
||||
while !line.is_char_boundary(from) {
|
||||
from += 1;
|
||||
}
|
||||
shown.push_str(&line[from..]);
|
||||
} else {
|
||||
let mut to = budget.min(lines[0].len());
|
||||
while !lines[0].is_char_boundary(to) {
|
||||
to -= 1;
|
||||
}
|
||||
shown.push_str(&lines[0][..to]);
|
||||
}
|
||||
}
|
||||
let mut byte_start = from + lines[..start].iter().map(|s| s.len()).sum::<usize>();
|
||||
if partial && tail {
|
||||
byte_start = to - shown.len();
|
||||
}
|
||||
let byte_end = byte_start + shown.len();
|
||||
let line_start = output[..byte_start].bytes().filter(|b| *b == b'\n').count();
|
||||
let line_end = line_start + shown.split_inclusive('\n').count();
|
||||
let total_lines = output.split_inclusive('\n').count();
|
||||
let partial = partial
|
||||
|| (from > 0 && !output[..from].ends_with('\n'))
|
||||
|| (to < output.len() && !output[..to].ends_with('\n'));
|
||||
let read = if partial {
|
||||
format!(
|
||||
"Shown byte range [{byte_start},{byte_end}); continue with read_file path={path}, offset={byte_end}, max_bytes={budget} (or choose an earlier byte offset)"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Read with read_file path={path}, line_offset={line_start}, line_limit={}; next line_offset={line_end}",
|
||||
line_end - line_start
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"{shown}\n[Showing lines {}-{} of {}{}; total {} bytes. Full output: {path}. {read}]",
|
||||
line_start + 1,
|
||||
line_end,
|
||||
total_lines,
|
||||
if partial { " (partial line)" } else { "" },
|
||||
output.len()
|
||||
)
|
||||
}
|
||||
|
||||
/// Zero-based line offsets; byte-based reads remain unchanged when absent.
|
||||
pub fn read_lines(bytes: &[u8], offset: usize, limit: usize) -> String {
|
||||
String::from_utf8_lossy(bytes)
|
||||
.split_inclusive('\n')
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn full_archive_and_context_guard() {
|
||||
let original = "😀 line\n".repeat(5400);
|
||||
for tail in [false, true] {
|
||||
let shown = guard(original.clone(), "guard-test", "call", tail);
|
||||
assert!(shown.len() <= CONTEXT_LIMIT.0);
|
||||
assert!(shown.lines().count() <= CONTEXT_LIMIT.1);
|
||||
assert!(shown.contains(if tail {
|
||||
"Showing lines 3403-5400"
|
||||
} else {
|
||||
"Showing lines 1-1998"
|
||||
}));
|
||||
let path = shown
|
||||
.split("Full output: ")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split(". Read with")
|
||||
.next()
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(path).unwrap(), original);
|
||||
}
|
||||
assert_eq!(guard("small".into(), "test", "id", false), "small");
|
||||
}
|
||||
#[test]
|
||||
fn archive_failure_does_not_discard_data() {
|
||||
std::fs::create_dir_all(format!("/logs/agent/{}", date())).unwrap();
|
||||
std::fs::write(format!("/logs/agent/{}/not-a-directory", date()), "block").unwrap();
|
||||
let output = "no loss\n".repeat(6000);
|
||||
assert_eq!(
|
||||
guard(output.clone(), "not-a-directory", "test", false),
|
||||
output
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn huge_line_and_exact_boundaries() {
|
||||
for tail in [false, true] {
|
||||
let result = guard("😀".repeat(40000), "test", "utf8", tail);
|
||||
assert!(result.len() <= CONTEXT_LIMIT.0);
|
||||
assert!(result.contains("partial line"));
|
||||
}
|
||||
let exact = "a\n".repeat(CONTEXT_LIMIT.1);
|
||||
assert_eq!(guard(exact.clone(), "test", "exact", false), exact);
|
||||
assert_eq!(read_lines(b"one\ntwo\nthree", 1, 1), "two\n");
|
||||
assert_eq!(read_lines(b"one", 3, 1), "");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
You are the Sausage agent. Answer the person. Use tools to inspect or change the world.
|
||||
|
||||
Read the contract at `/proc/core/PROTOCOL.md`. The Rust plugin SDK and its documentation are at `/plugins/cargo/sdk/`.
|
||||
Build a plugin with `cargo.build`. Activate its WASM file with `core.activate`.
|
||||
Your own source is at `/plugins/agent/source/`. You can change and rebuild it like any other plugin.
|
||||
HARD RULE: the mediator and all agents NEVER call `core.restart` (or its alias `core.exit`) without the person's explicit approval each time.
|
||||
Restart requires the host to run under `#loop` (`SAUSAGE_LOOP=1`); it answers before clean shutdown with exit code 75.
|
||||
Never call `agent.chat`. It would wait on the history lock that your current turn holds.
|
||||
Write logs to stderr. Use call results and notifications for communication.
|
||||
The interface check does not cover persistent state. Preserve state compatibility when you change a plugin.
|
||||
@@ -1,268 +0,0 @@
|
||||
//! Tool descriptions and file operations for the agent.
|
||||
|
||||
use crate::llm::Tool;
|
||||
use serde_json::{Value, json};
|
||||
use std::io;
|
||||
|
||||
#[path = "overflow.rs"]
|
||||
pub mod overflow;
|
||||
|
||||
/// Human-readable, lossless text representation of tool JSON. String values
|
||||
/// retain real newlines, with JSON-quoted field/index labels for structure.
|
||||
fn render(value: &Value) -> String {
|
||||
fn visit(value: &Value, label: &str, out: &mut String) {
|
||||
match value {
|
||||
Value::Object(fields) => {
|
||||
if fields.is_empty() {
|
||||
out.push_str(&format!("{label}: {{}}\n"));
|
||||
}
|
||||
for (key, value) in fields {
|
||||
visit(
|
||||
value,
|
||||
&format!("{label}[{}]", serde_json::to_string(key).unwrap()),
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
if items.is_empty() {
|
||||
out.push_str(&format!("{label}: []\n"));
|
||||
}
|
||||
for (i, value) in items.iter().enumerate() {
|
||||
visit(value, &format!("{label}[{i}]"), out);
|
||||
}
|
||||
}
|
||||
Value::String(text) => {
|
||||
out.push_str(&format!("{label} (string, {} bytes):\n", text.len()));
|
||||
out.push_str(text);
|
||||
out.push('\n');
|
||||
}
|
||||
_ => {
|
||||
out.push_str(&format!("{label}: {value}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = String::new();
|
||||
visit(value, "$", &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Describe the five tools sent to either inference API.
|
||||
pub fn definitions() -> Vec<Tool> {
|
||||
vec![
|
||||
Tool {
|
||||
name: "call",
|
||||
description: "Call a plugin method through the core. Await its result. Set detach to let its process survive this caller. Large results show an excerpt with a full-output /logs reference.",
|
||||
parameters: schema(
|
||||
json!({
|
||||
"method": {"type": "string", "description": "Qualified plugin method"},
|
||||
"params": {"type": "object", "description": "Named method parameters"},
|
||||
"detach": {"type": "boolean", "description": "Detach the new process. Default false."}
|
||||
}),
|
||||
&["method", "params"],
|
||||
),
|
||||
},
|
||||
Tool {
|
||||
name: "read_file",
|
||||
description: "Read a text file in the world, including `/proc`. `offset`/`max_bytes` select bytes; `line_offset`/`line_limit` select zero-based lines (default 200). Large results show an excerpt with a full-output /logs reference.",
|
||||
parameters: schema(
|
||||
json!({
|
||||
"path": {"type": "string"},
|
||||
"offset": {"type": "integer", "description": "Byte offset (default 0)"},
|
||||
"max_bytes": {"type": "integer", "description": "Optional maximum number of bytes to read"},
|
||||
"line_offset": {"type": "integer", "description": "Zero-based starting line; overrides bytes"},
|
||||
"line_limit": {"type": "integer", "description": "Number of lines; default 200"}
|
||||
}),
|
||||
&["path"],
|
||||
),
|
||||
},
|
||||
Tool {
|
||||
name: "write_file",
|
||||
description: "Write a text file in the world. Parent folders are created. Large results show an excerpt with a full-output /logs reference.",
|
||||
parameters: schema(
|
||||
json!({"path": {"type": "string"}, "content": {"type": "string"}}),
|
||||
&["path", "content"],
|
||||
),
|
||||
},
|
||||
Tool {
|
||||
name: "list_dir",
|
||||
description: "List entries in a world directory. Returns each name, type, and size. Large results show an excerpt with a full-output /logs reference.",
|
||||
parameters: schema(json!({"path": {"type": "string"}}), &["path"]),
|
||||
},
|
||||
Tool {
|
||||
name: "copy_dir",
|
||||
description: "Copy a directory tree, for example a plugin source to a draft folder. Skips `target/` folders. Large results show an excerpt with a full-output /logs reference.",
|
||||
parameters: schema(
|
||||
json!({"from": {"type": "string"}, "to": {"type": "string"}}),
|
||||
&["from", "to"],
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Create the named-parameter object required by a tool.
|
||||
fn schema(properties: Value, required: &[&str]) -> Value {
|
||||
json!({"type": "object", "properties": properties, "required": required})
|
||||
}
|
||||
|
||||
/// Execute a tool. The chat loop returns errors to the model as tool results.
|
||||
pub async fn run(name: &str, args: &Value) -> sausage_plugin::Result<Value> {
|
||||
if name == "call" {
|
||||
let method = args["method"]
|
||||
.as_str()
|
||||
.ok_or_else(|| sausage_plugin::Error::new(-32602, "missing method"))?;
|
||||
let call = sausage_plugin::call(method, args.get("params").cloned().unwrap_or(json!({})));
|
||||
return if args["detach"] == true {
|
||||
call.detach().await
|
||||
} else {
|
||||
call.await
|
||||
};
|
||||
}
|
||||
Ok(match name {
|
||||
"read_file" => read_file(args),
|
||||
"write_file" => write_file(args),
|
||||
"list_dir" => list_dir(args),
|
||||
"copy_dir" => copy_dir(args),
|
||||
other => Ok(json!({"error": format!("unknown tool {other}")})),
|
||||
}?)
|
||||
}
|
||||
|
||||
/// Read an optional byte range and report the complete file size.
|
||||
fn read_file(args: &Value) -> io::Result<Value> {
|
||||
let path = args["path"].as_str().unwrap_or_default();
|
||||
let bytes = std::fs::read(path)?;
|
||||
if path.starts_with("/logs/") {
|
||||
return Ok(
|
||||
json!({"path":path,"size":bytes.len(),"content":overflow::existing(&bytes, path, args)}),
|
||||
);
|
||||
}
|
||||
if let Some(line) = args["line_offset"].as_u64() {
|
||||
return Ok(
|
||||
json!({"path":path,"size":bytes.len(),"line_offset":line,"content":overflow::read_lines(&bytes, line as usize, args["line_limit"].as_u64().unwrap_or(200) as usize)}),
|
||||
);
|
||||
}
|
||||
let offset = (args["offset"].as_u64().unwrap_or(0) as usize).min(bytes.len());
|
||||
let end = args["max_bytes"].as_u64().map_or(bytes.len(), |max| {
|
||||
offset.saturating_add(max as usize).min(bytes.len())
|
||||
});
|
||||
Ok(json!({
|
||||
"path": path,
|
||||
"size": bytes.len(),
|
||||
"offset": offset,
|
||||
"content": String::from_utf8_lossy(&bytes[offset..end]),
|
||||
"truncated": end < bytes.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create parent directories and replace a text file.
|
||||
fn write_file(args: &Value) -> io::Result<Value> {
|
||||
let path = args["path"].as_str().unwrap_or_default();
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
std::fs::write(path, args["content"].as_str().unwrap_or_default())?;
|
||||
Ok(json!({"written": path}))
|
||||
}
|
||||
|
||||
/// Return directory metadata in name order.
|
||||
fn list_dir(args: &Value) -> io::Result<Value> {
|
||||
let mut entries: Vec<Value> = std::fs::read_dir(args["path"].as_str().unwrap_or_default())?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| {
|
||||
let metadata = entry.metadata().ok();
|
||||
json!({
|
||||
"name": entry.file_name().to_string_lossy(),
|
||||
"type": if metadata.as_ref().is_some_and(|meta| meta.is_dir()) { "dir" } else { "file" },
|
||||
"size": metadata.map(|meta| meta.len()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str()));
|
||||
Ok(json!({"entries": entries}))
|
||||
}
|
||||
|
||||
/// Copy a tree and report its file count.
|
||||
fn copy_dir(args: &Value) -> io::Result<Value> {
|
||||
let from = std::fs::canonicalize(args["from"].as_str().unwrap_or_default())?;
|
||||
let to = args["to"].as_str().unwrap_or_default();
|
||||
std::fs::create_dir_all(to)?;
|
||||
if std::fs::canonicalize(to)?.starts_with(&from) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"destination is inside the source",
|
||||
));
|
||||
}
|
||||
let count = copy_tree(
|
||||
args["from"].as_str().unwrap_or_default(),
|
||||
args["to"].as_str().unwrap_or_default(),
|
||||
)?;
|
||||
Ok(json!({"copied_files": count}))
|
||||
}
|
||||
|
||||
/// Copy files recursively without build artifacts in target directories.
|
||||
fn copy_tree(from: &str, to: &str) -> io::Result<u64> {
|
||||
std::fs::create_dir_all(to)?;
|
||||
let mut count = 0;
|
||||
for entry in std::fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let src = format!("{from}/{name}");
|
||||
let dst = format!("{to}/{name}");
|
||||
if entry.file_type()?.is_dir() {
|
||||
if name != "target" {
|
||||
count += copy_tree(&src, &dst)?;
|
||||
}
|
||||
} else {
|
||||
std::fs::copy(src, dst)?;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn overflow_json_can_be_followed_by_line_without_rearchiving() {
|
||||
let value = serde_json::json!({"stdout":"hello 😀\n".repeat(4000),"exit":1,"error":{"text":"failure\n".repeat(4000)}});
|
||||
let expected = super::render(&value);
|
||||
let shown = super::overflow::guard(value.to_string(), "json-test", "call", true);
|
||||
assert!(shown.len() <= 50 * 1024 && shown.lines().count() <= 2000);
|
||||
let path = shown
|
||||
.split("Full output: ")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split(". Read with")
|
||||
.next()
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(path).unwrap(), expected);
|
||||
let parent = std::path::Path::new(path).parent().unwrap();
|
||||
let count = std::fs::read_dir(parent).unwrap().count();
|
||||
let args = serde_json::json!({"path":path,"line_offset":30,"line_limit":5});
|
||||
let read = super::read_file(&args).unwrap();
|
||||
let read = read["content"].as_str().unwrap();
|
||||
assert!(read.contains("Showing lines 31-35"));
|
||||
assert!(read.contains(path));
|
||||
assert_eq!(std::fs::read_dir(parent).unwrap().count(), count);
|
||||
assert!(
|
||||
super::overflow::existing(
|
||||
expected.as_bytes(),
|
||||
path,
|
||||
&serde_json::json!({"line_offset":u64::MAX})
|
||||
)
|
||||
.contains("Full output:")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn copy_rejects_a_destination_inside_the_source() {
|
||||
let root = if cfg!(target_os = "wasi") {
|
||||
std::path::PathBuf::from("/copy-test")
|
||||
} else {
|
||||
std::env::temp_dir().join(format!("sausage-copy-{}", std::process::id()))
|
||||
};
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let result = super::copy_dir(&serde_json::json!({"from":root,"to":root.join("target")}));
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
[package]
|
||||
name = "sausage"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Grow a system of WASI plugins by talking to it."
|
||||
|
||||
[[bin]]
|
||||
name = "sausage"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
bytes = "1"
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
http = "1"
|
||||
http-body-util = "0.1"
|
||||
include_dir = "0.7"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml_ng = "0.10"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
toml_edit = "0.25"
|
||||
wasmtime = "49"
|
||||
wasmtime-wasi = "49"
|
||||
wasmtime-wasi-http = "49"
|
||||
|
||||
uuid = { version = "1", features = ["v7"] }
|
||||
sha2 = "0.10"
|
||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||
sausage-vfs = { version = "0.1.0", path = "../vfs" }
|
||||
sausage-interface = { version = "0.1.0", path = "../interface" }
|
||||
notify = "8.2.0"
|
||||
rustyline = "19"
|
||||
nix = { version = "0.31.3", features = ["signal", "process", "term"] }
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock"] }
|
||||
tempfile = "3.27.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["test-util"] }
|
||||
hyper = { version = "1.12.0", features = ["server", "http1"] }
|
||||
hyper-util = { version = "0.1.21", features = ["tokio"] }
|
||||
sausage-interface = { path = "../interface" }
|
||||
@@ -1,302 +0,0 @@
|
||||
//! Serialized candidate-copy activation and revert under PROTOCOL.md section 10.3.
|
||||
use crate::{
|
||||
Host, RpcResult, error,
|
||||
graph::{Interface, io_error, problems, read_hashed},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
/// Method descriptions plus their reachable local references, without expanding cycles.
|
||||
fn methods(document: &Value) -> BTreeMap<String, Value> {
|
||||
fn references(value: &Value, document: &Value, found: &mut BTreeMap<String, Value>) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if let Some(reference) = object.get("$ref").and_then(Value::as_str)
|
||||
&& let Some(pointer) = reference.strip_prefix('#')
|
||||
&& !found.contains_key(reference)
|
||||
&& let Some(target) = document.pointer(pointer)
|
||||
{
|
||||
found.insert(reference.into(), target.clone());
|
||||
references(target, document, found);
|
||||
}
|
||||
for child in object.values() {
|
||||
references(child, document, found);
|
||||
}
|
||||
}
|
||||
Value::Array(array) => {
|
||||
for child in array {
|
||||
references(child, document, found);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
document["methods"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|method| {
|
||||
let name = method["name"].as_str()?;
|
||||
let mut found = BTreeMap::new();
|
||||
references(method, document, &mut found);
|
||||
Some((name.into(), json!({"method":method,"references":found})))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Candidate {
|
||||
plugin: String,
|
||||
copy: PathBuf,
|
||||
interface: Interface,
|
||||
}
|
||||
impl Host {
|
||||
/// Copies first, discovers that copy, checks the combined graph, then installs it.
|
||||
pub(crate) async fn activation(&self, _caller: &str, method: &str, params: Value) -> RpcResult {
|
||||
let host = self.clone();
|
||||
let method = method.to_owned();
|
||||
tokio::spawn(async move { host.activation_body(&method, params).await })
|
||||
.await
|
||||
.map_err(|failure| error(-32603, &failure.to_string()))?
|
||||
}
|
||||
async fn activation_body(&self, method: &str, params: Value) -> RpcResult {
|
||||
let _activation = self.0.activations.lock().await;
|
||||
let mut candidates = Vec::new();
|
||||
let result = self
|
||||
.activate_candidates(method, ¶ms, &mut candidates)
|
||||
.await;
|
||||
for candidate in candidates {
|
||||
let _ = tokio::fs::remove_file(candidate.copy).await;
|
||||
}
|
||||
result
|
||||
}
|
||||
/// Prepares every member before graph checks or changes to installed files.
|
||||
async fn activate_candidates(
|
||||
&self,
|
||||
method: &str,
|
||||
params: &Value,
|
||||
candidates: &mut Vec<Candidate>,
|
||||
) -> RpcResult {
|
||||
let requests = params
|
||||
.get("plugins")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| vec![params.clone()]);
|
||||
if requests.is_empty() {
|
||||
return Err(error(-32602, "plugins must not be empty"));
|
||||
}
|
||||
for request in requests {
|
||||
let plugin = request["plugin"]
|
||||
.as_str()
|
||||
.ok_or_else(|| error(-32602, "plugin must be a string"))?;
|
||||
if plugin.is_empty()
|
||||
|| !plugin.bytes().all(|byte| {
|
||||
byte.is_ascii_lowercase()
|
||||
|| byte.is_ascii_digit()
|
||||
|| byte == b'_'
|
||||
|| byte == b'-'
|
||||
})
|
||||
|| candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.plugin == plugin)
|
||||
{
|
||||
return Err(error(-32602, "invalid or duplicate plugin"));
|
||||
}
|
||||
let directory = self.0.world.plugin_dir(plugin);
|
||||
tokio::fs::create_dir_all(&directory)
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
let source = if method == "revert" {
|
||||
directory.join("plugin.prev.wasm")
|
||||
} else {
|
||||
let source = PathBuf::from(
|
||||
request["wasm"]
|
||||
.as_str()
|
||||
.ok_or_else(|| error(-32602, "wasm must be a string"))?,
|
||||
);
|
||||
if source.starts_with(&self.0.world.root) {
|
||||
source
|
||||
} else {
|
||||
self.0
|
||||
.world
|
||||
.root
|
||||
.join(source.strip_prefix("/").unwrap_or(&source))
|
||||
}
|
||||
};
|
||||
let copy = directory.join(format!("activate-{}.wasm", uuid::Uuid::now_v7()));
|
||||
// Read once. Discovery and installation both use the same private bytes.
|
||||
let (bytes, hash) = read_hashed(source).await.map_err(io_error)?;
|
||||
tokio::fs::write(©, &bytes).await.map_err(io_error)?;
|
||||
self.0
|
||||
.journal
|
||||
.write("core", plugin, "core", "activation copied candidate");
|
||||
let discovery = self.discover_copy(plugin, ©, hash).await;
|
||||
let interface = match discovery {
|
||||
Ok(interface) => interface,
|
||||
Err(error) => {
|
||||
let _ = tokio::fs::remove_file(copy).await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
self.0
|
||||
.journal
|
||||
.write("core", plugin, "core", "activation discovered candidate");
|
||||
candidates.push(Candidate {
|
||||
plugin: plugin.into(),
|
||||
copy,
|
||||
interface,
|
||||
});
|
||||
}
|
||||
let names: Vec<_> = candidates
|
||||
.iter()
|
||||
.map(|candidate| candidate.plugin.clone())
|
||||
.collect();
|
||||
self.refresh_graph_except(&names).await?;
|
||||
let mut previous = self.0.graph.lock().unwrap().plugins.clone();
|
||||
// Startup discovery may not yet have visited a replaced plugin. Discover
|
||||
// its immutable old copy for the diff, but never prevent repairing it.
|
||||
for candidate in candidates.iter() {
|
||||
if let Some(old) = previous.get_mut(&candidate.plugin)
|
||||
&& old.document.is_null()
|
||||
&& old.error.is_none()
|
||||
{
|
||||
if old.hash == candidate.interface.hash {
|
||||
old.document = candidate.interface.document.clone();
|
||||
continue;
|
||||
}
|
||||
let directory = self.0.world.plugin_dir(&candidate.plugin);
|
||||
if let Ok((bytes, hash)) = read_hashed(directory.join("plugin.wasm")).await {
|
||||
let copy = directory.join(format!("discover-{}.wasm", uuid::Uuid::now_v7()));
|
||||
if tokio::fs::write(©, bytes).await.is_ok()
|
||||
&& let Ok(interface) =
|
||||
self.discover_copy(&candidate.plugin, ©, hash).await
|
||||
{
|
||||
*old = interface;
|
||||
}
|
||||
let _ = tokio::fs::remove_file(copy).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut proposed = previous.clone();
|
||||
for candidate in candidates.iter() {
|
||||
proposed.insert(candidate.plugin.clone(), candidate.interface.clone());
|
||||
}
|
||||
if params["force"] != true {
|
||||
let failures = problems(&proposed, Some(&names));
|
||||
if !failures.is_empty() {
|
||||
self.0.journal.write(
|
||||
"core",
|
||||
"core",
|
||||
"core",
|
||||
&format!("activation rejected {}", json!(failures)),
|
||||
);
|
||||
let details = failures
|
||||
.iter()
|
||||
.map(|problem| {
|
||||
format!(
|
||||
"- {} imports {}: {}",
|
||||
problem["consumer"],
|
||||
problem["provider"],
|
||||
problem["display"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| problem["message"]
|
||||
.as_str()
|
||||
.unwrap_or("incompatible import"))
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
return Err(
|
||||
json!({"code":-32003,"message":format!("{} was not activated: consumers break.\n{details}\nUpdate consumers first, or activate both together.",names.join(", ")),"data":failures}),
|
||||
);
|
||||
}
|
||||
}
|
||||
self.install_candidates(candidates, previous, proposed, params["force"] != true)
|
||||
.await
|
||||
}
|
||||
/// Installs staged files and snapshots all old admissions under the shared write gate.
|
||||
async fn install_candidates(
|
||||
&self,
|
||||
candidates: &[Candidate],
|
||||
previous: BTreeMap<String, Interface>,
|
||||
proposed: BTreeMap<String, Interface>,
|
||||
checked: bool,
|
||||
) -> RpcResult {
|
||||
let admission = self.0.admission.write().await;
|
||||
// Every candidate already has a temporary name before any installed file changes.
|
||||
for candidate in candidates {
|
||||
if previous
|
||||
.get(&candidate.plugin)
|
||||
.is_some_and(|old| old.hash == candidate.interface.hash)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let directory = self.0.world.plugin_dir(&candidate.plugin);
|
||||
let current = directory.join("plugin.wasm");
|
||||
if tokio::fs::try_exists(¤t).await.map_err(io_error)? {
|
||||
let backup = candidate.copy.with_extension("previous");
|
||||
tokio::fs::hard_link(¤t, &backup)
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
tokio::fs::rename(&backup, directory.join("plugin.prev.wasm"))
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
}
|
||||
tokio::fs::rename(&candidate.copy, ¤t)
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
}
|
||||
let mut upgrades = Vec::new();
|
||||
let mut summaries = serde_json::Map::new();
|
||||
for candidate in candidates {
|
||||
let old = previous.get(&candidate.plugin);
|
||||
let before = methods(old.map(|old| &old.document).unwrap_or(&Value::Null));
|
||||
let after = methods(&candidate.interface.document);
|
||||
let added: Vec<_> = after
|
||||
.keys()
|
||||
.filter(|name| !before.contains_key(*name))
|
||||
.collect();
|
||||
let removed: Vec<_> = before
|
||||
.keys()
|
||||
.filter(|name| !after.contains_key(*name))
|
||||
.collect();
|
||||
let changed: Vec<_> = after
|
||||
.iter()
|
||||
.filter(|(name, method)| before.get(*name).is_some_and(|old| old != *method))
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
summaries.insert(candidate.plugin.clone(), json!({
|
||||
"plugin": candidate.plugin,
|
||||
"wasm": format!("/plugins/{}/plugin.wasm", candidate.plugin),
|
||||
"hash": candidate.interface.hash,
|
||||
"previous": old.map(|old| &old.hash).filter(|hash| !hash.is_empty()),
|
||||
"method_count": candidate.interface.document["methods"].as_array().map_or(0, Vec::len),
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"changed": changed,
|
||||
"interface_check": if checked { "passed" } else { "skipped" },
|
||||
}));
|
||||
if let Some(old) = previous
|
||||
.get(&candidate.plugin)
|
||||
.filter(|old| !old.hash.is_empty() && old.hash != candidate.interface.hash)
|
||||
{
|
||||
upgrades.push(self.snapshot_upgrade(
|
||||
&candidate.plugin,
|
||||
old.hash.clone(),
|
||||
&candidate.interface.hash,
|
||||
));
|
||||
}
|
||||
self.0.journal.write(
|
||||
"core",
|
||||
&candidate.plugin,
|
||||
"core",
|
||||
&format!("activation installed {}", candidate.interface.hash),
|
||||
);
|
||||
}
|
||||
self.publish_graph(proposed);
|
||||
drop(admission);
|
||||
for upgrade in upgrades {
|
||||
self.begin_upgrade(upgrade);
|
||||
}
|
||||
Ok(Value::Object(summaries))
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
# The configuration of the agent. Edit it before the first chat.
|
||||
# api selects the request format. Choose one complete example below.
|
||||
api: openai-responses
|
||||
# OpenAI base URLs include /v1. Do not include /responses or /chat/completions.
|
||||
base_url: https://api.openai.com/v1
|
||||
# model is the provider's model identifier.
|
||||
model: gpt-6.1-sol
|
||||
# token is the API key. Replace CHANGE_ME with your key.
|
||||
# An empty token requires allow_no_token: true. Then no authentication header is sent.
|
||||
token: CHANGE_ME
|
||||
allow_no_token: false
|
||||
# thinking is how much the model thinks before it answers:
|
||||
# off, low, medium, high, xhigh, or max. Remove the line to use the model's default,
|
||||
# for example for a model without thinking.
|
||||
# If the model rejects a level, the agent uses the nearest level that the model accepts.
|
||||
# If the model cannot switch thinking off, off becomes low.
|
||||
# A caller can set another level for one turn: agent.chat {"text": "...", "thinking": "high"}.
|
||||
# The agent reads this file at the start of each turn, so a change applies to the next turn.
|
||||
thinking: medium
|
||||
# Optional output token cap. Anthropic defaults to 32000 if omitted.
|
||||
# max_tokens: 32000
|
||||
|
||||
# OpenAI Responses example (base_url includes the version):
|
||||
# api: openai-responses
|
||||
# base_url: https://api.openai.com/v1
|
||||
# model: gpt-6.1-sol
|
||||
# token: YOUR_OPENAI_KEY
|
||||
# allow_no_token: false
|
||||
# thinking: medium
|
||||
|
||||
# OpenRouter example (OpenAI Chat Completions, the format of most providers):
|
||||
# api: openai-chat-completions
|
||||
# base_url: https://openrouter.ai/api/v1
|
||||
# model: anthropic/claude-sonnet-5.5
|
||||
# token: YOUR_OPENROUTER_KEY
|
||||
# allow_no_token: false
|
||||
# thinking: medium
|
||||
|
||||
# Local Ollama example (OpenAI Chat Completions, no key):
|
||||
# api: openai-chat-completions
|
||||
# base_url: http://localhost:11434/v1
|
||||
# model: llama3.2
|
||||
# token: ""
|
||||
# allow_no_token: true
|
||||
|
||||
# Anthropic Messages example (base_url excludes the version):
|
||||
# api: anthropic-messages
|
||||
# base_url: https://api.anthropic.com
|
||||
# model: claude-sonnet-5-5
|
||||
# token: YOUR_ANTHROPIC_KEY
|
||||
# allow_no_token: false
|
||||
# thinking: medium
|
||||
@@ -1,12 +0,0 @@
|
||||
# The core starts these services in order. An empty list starts no services.
|
||||
# Each method is a plugin.method name. params contains its named arguments.
|
||||
# The core waits for each result before it starts the next service.
|
||||
# After a plugin upgrade, the core restarts that plugin's services.
|
||||
# Host restart is separate: #loop sets SAUSAGE_LOOP=1 to enable core.restart
|
||||
# (and its alias core.exit). It answers before clean shutdown with exit code 75.
|
||||
# Agents need the person's explicit approval each time; never add it as a service.
|
||||
# Example (replace [] with this list):
|
||||
# - method: www.start
|
||||
# params:
|
||||
# port: 8080
|
||||
[]
|
||||
@@ -1,836 +0,0 @@
|
||||
//! Native cargo builds, one immutable source copy, SDK installation, and process-group cancellation.
|
||||
use crate::{Host, world::World};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use include_dir::{Dir, include_dir};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::{process::Command, time::Instant};
|
||||
|
||||
/// Default wall-clock budget after acquiring the build mutex (not queue time).
|
||||
pub const DEFAULT_TIMEOUT_SECS: u64 = 900;
|
||||
/// Default wall-clock budget for each WASI test instance.
|
||||
pub const DEFAULT_TEST_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
/// Wall-clock budgets for a build and its individual WASI tests.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct BuildOptions {
|
||||
pub timeout_secs: u64,
|
||||
pub test_timeout_secs: u64,
|
||||
}
|
||||
impl Default for BuildOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timeout_secs: DEFAULT_TIMEOUT_SECS,
|
||||
test_timeout_secs: DEFAULT_TEST_TIMEOUT_SECS,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Optional RPC seconds are positive integers; null does not disable a deadline.
|
||||
fn seconds(params: &Value, name: &str, default: u64) -> Result<u64, Value> {
|
||||
match params.get(name) {
|
||||
None => Ok(default),
|
||||
Some(value) => value
|
||||
.as_u64()
|
||||
.filter(|seconds| *seconds >= 1)
|
||||
.ok_or_else(|| {
|
||||
crate::error(-32602, &format!("{name} must be an integer of at least 1"))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The native build plugin.
|
||||
pub const ID: &str = "cargo";
|
||||
/// The target used for builds and tests.
|
||||
pub const TARGET: &str = "wasm32-wasip2";
|
||||
static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/src");
|
||||
static MACROS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/macros/src");
|
||||
static INTERFACE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../interface/src");
|
||||
|
||||
#[derive(Clone, Default, Debug, serde::Serialize)]
|
||||
/// Test totals and captured output.
|
||||
pub struct TestResults {
|
||||
pub passed: u64,
|
||||
pub failed: u64,
|
||||
pub output: String,
|
||||
}
|
||||
/// Raw, unfiltered output shared by the test and release phases of one build.
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct BuildLogs {
|
||||
pub stdout: StreamLog,
|
||||
pub stderr: StreamLog,
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct StreamLog {
|
||||
pub path: String,
|
||||
pub bytes: u64,
|
||||
}
|
||||
/// Persistent descriptors are inherited by cargo, so cancellation cannot lose
|
||||
/// bytes already written by cargo or its descendants. No buffered writer is used.
|
||||
struct LogFiles {
|
||||
stdout: std::fs::File,
|
||||
stderr: std::fs::File,
|
||||
stdout_path: PathBuf,
|
||||
stderr_path: PathBuf,
|
||||
}
|
||||
impl LogFiles {
|
||||
fn create(world: &Path) -> Result<Self> {
|
||||
let directory = world
|
||||
.join("logs/cargo")
|
||||
.join(chrono::Utc::now().format("%Y-%m-%d").to_string());
|
||||
std::fs::create_dir_all(&directory).context("create cargo log directory")?;
|
||||
let id = uuid::Uuid::now_v7();
|
||||
let stdout_path = directory.join(format!("{id}.stdout.log"));
|
||||
let stderr_path = directory.join(format!("{id}.stderr.log"));
|
||||
let create = |path: &Path| {
|
||||
std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("create {}", path.display()))
|
||||
};
|
||||
Ok(Self {
|
||||
stdout: create(&stdout_path)?,
|
||||
stderr: create(&stderr_path)?,
|
||||
stdout_path,
|
||||
stderr_path,
|
||||
})
|
||||
}
|
||||
fn info(&self, world: &Path) -> Result<BuildLogs> {
|
||||
let stream = |file: &std::fs::File, path: &Path| -> Result<StreamLog> {
|
||||
Ok(StreamLog {
|
||||
path: format!("/{}", path.strip_prefix(world)?.display()),
|
||||
bytes: file.metadata()?.len(),
|
||||
})
|
||||
};
|
||||
let stdout = stream(&self.stdout, &self.stdout_path)?;
|
||||
let stderr = stream(&self.stderr, &self.stderr_path)?;
|
||||
Ok(BuildLogs {
|
||||
total_bytes: stdout.bytes + stderr.bytes,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
fn read(&self, path: &Path, offset: u64) -> Result<Vec<u8>> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut file =
|
||||
std::fs::File::open(path).with_context(|| format!("read {}", path.display()))?;
|
||||
file.seek(SeekFrom::Start(offset))?;
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
/// A successful build, usable without a running host.
|
||||
#[derive(Debug)]
|
||||
pub struct BuildOutput {
|
||||
pub logs: Option<BuildLogs>,
|
||||
pub wasm: PathBuf,
|
||||
pub warnings: String,
|
||||
pub tests: TestResults,
|
||||
}
|
||||
/// A failed compiler or test phase, with its complete diagnostics.
|
||||
#[derive(Debug)]
|
||||
pub struct BuildFailure {
|
||||
pub logs: Option<BuildLogs>,
|
||||
pub reason: String,
|
||||
pub tests: TestResults,
|
||||
pub output: String,
|
||||
pub elapsed_secs: Option<f64>,
|
||||
pub step: Option<&'static str>,
|
||||
}
|
||||
impl std::fmt::Display for BuildFailure {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(formatter, "{}\n{}", self.reason, self.output)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for BuildFailure {}
|
||||
impl From<anyhow::Error> for BuildFailure {
|
||||
fn from(error: anyhow::Error) -> Self {
|
||||
Self {
|
||||
logs: None,
|
||||
reason: "build preparation failed".into(),
|
||||
output: format!("{error:#}"),
|
||||
tests: TestResults::default(),
|
||||
elapsed_secs: None,
|
||||
step: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A host-independent builder. Each builder serializes access to its work and target folders.
|
||||
pub struct Cargo {
|
||||
world: World,
|
||||
builds: Arc<tokio::sync::Mutex<()>>,
|
||||
runner: PathBuf,
|
||||
target: PathBuf,
|
||||
sdk: PathBuf,
|
||||
}
|
||||
impl Cargo {
|
||||
/// Seeds a missing world SDK and selects the current sausage executable as runner.
|
||||
pub fn new(world: World, cache: &Path) -> Result<Self> {
|
||||
let mut runner = std::env::current_exe()?;
|
||||
if runner
|
||||
.parent()
|
||||
.is_some_and(|parent| parent.file_name().is_some_and(|name| name == "deps"))
|
||||
{
|
||||
runner = runner.parent().unwrap().parent().unwrap().join("sausage");
|
||||
}
|
||||
Self::with_runner(world, runner, cache)
|
||||
}
|
||||
/// Seeds a missing world SDK and accepts an explicit sausage binary, including for init integrations.
|
||||
pub fn with_runner(world: World, runner: PathBuf, cache: &Path) -> Result<Self> {
|
||||
let sdk = world.plugin_dir(ID).join("sdk");
|
||||
Self::seed_sdk(&sdk)?;
|
||||
Ok(Self {
|
||||
sdk,
|
||||
target: std::path::absolute(cache)?.join("cargo-target"),
|
||||
world,
|
||||
runner,
|
||||
builds: Arc::new(tokio::sync::Mutex::new(())),
|
||||
})
|
||||
}
|
||||
/// Seeds only an absent SDK; an existing world owns all of its SDK files.
|
||||
fn seed_sdk(sdk: &Path) -> Result<()> {
|
||||
if sdk.try_exists()? {
|
||||
return Ok(());
|
||||
}
|
||||
let mut files = std::collections::BTreeMap::new();
|
||||
collect_files(&SDK, Path::new("src"), &mut files);
|
||||
collect_files(&MACROS, Path::new("macros/src"), &mut files);
|
||||
collect_files(&INTERFACE, Path::new("interface/src"), &mut files);
|
||||
for (manifest, destination, interface_path) in [
|
||||
(
|
||||
include_str!("../../sdk/Cargo.toml"),
|
||||
PathBuf::from("Cargo.toml"),
|
||||
Some("interface"),
|
||||
),
|
||||
(
|
||||
include_str!("../../sdk/macros/Cargo.toml"),
|
||||
PathBuf::from("macros/Cargo.toml"),
|
||||
Some("../interface"),
|
||||
),
|
||||
(
|
||||
include_str!("../../interface/Cargo.toml"),
|
||||
PathBuf::from("interface/Cargo.toml"),
|
||||
None,
|
||||
),
|
||||
] {
|
||||
let mut document: toml_edit::DocumentMut = manifest.parse()?;
|
||||
if !document.contains_key("workspace") {
|
||||
document["workspace"] = toml_edit::table();
|
||||
}
|
||||
if let Some(path) = interface_path {
|
||||
for section in ["dependencies", "dev-dependencies"] {
|
||||
if let Some(dependency) = document
|
||||
.get_mut(section)
|
||||
.and_then(|table| table.get_mut("sausage-interface"))
|
||||
{
|
||||
dependency["path"] = toml_edit::value(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
files.insert(destination, document.to_string().into_bytes());
|
||||
}
|
||||
let parent = sdk.parent().context("SDK has no parent")?;
|
||||
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||
let staging = tempfile::Builder::new()
|
||||
.prefix("seed-sdk-")
|
||||
.tempdir_in(parent)
|
||||
.with_context(|| format!("stage SDK in {}", parent.display()))?;
|
||||
for (path, bytes) in &files {
|
||||
write_changed(&staging.path().join(path), bytes)?;
|
||||
}
|
||||
// Another builder may have seeded the world while we prepared the files.
|
||||
if !sdk.try_exists()?
|
||||
&& let Err(error) = std::fs::rename(staging.path(), sdk)
|
||||
&& !sdk.try_exists()?
|
||||
{
|
||||
return Err(error).with_context(|| format!("seed {}", sdk.display()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Registers cargo.build as a cancellable native process.
|
||||
pub fn register(self, host: &Host) {
|
||||
let builder = Arc::new(self);
|
||||
host.register_native(ID,Arc::new(move |_context,method,params| {
|
||||
let builder=builder.clone();
|
||||
Box::pin(async move {
|
||||
if method=="rpc.discover" { return Ok(json!({"openrpc":"1.2.6","info":{"title":"cargo","version":"native"},"methods":[{"name":"build","paramStructure":"by-name","description":"Copies source, runs bounded WASI tests, then builds a release component. Deadlines kill cargo and its descendants; cancellation releases the build mutex.","params":[{"name":"plugin","required":true,"schema":{"type":"string"}},{"name":"timeout_secs","required":false,"description":"Total wall-clock limit in seconds for tests and release build after acquiring the build mutex (queue time excluded). Default 900; minimum 1. Cannot be disabled.","schema":{"type":"integer","minimum":1,"default":DEFAULT_TIMEOUT_SECS}},{"name":"test_timeout_secs","required":false,"description":"Wall-clock limit in seconds for each WASI test instance. Default 120; minimum 1. A timed-out test is named and fails; remaining tests continue.","schema":{"type":"integer","minimum":1,"default":DEFAULT_TEST_TIMEOUT_SECS}}],"result":{"name":"build","schema":{"type":"object","required":["wasm","tests","warnings","logs"],"properties":{"logs":{"type":"object","required":["stdout","stderr","total_bytes"],"properties":{"stdout":{"type":"object","required":["path","bytes"],"properties":{"path":{"type":"string"},"bytes":{"type":"integer","minimum":0}}},"stderr":{"type":"object","required":["path","bytes"],"properties":{"path":{"type":"string"},"bytes":{"type":"integer","minimum":0}}},"total_bytes":{"type":"integer","minimum":0}}},"wasm":{"type":"string"},"warnings":{"type":"string"},"tests":{"type":"object","required":["passed","failed","output"],"properties":{"passed":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0},"output":{"type":"string"}}}}}},"errors":[{"code":-32004,"message":"Build failed"}]}]})); }
|
||||
if method!="build" { return Err(crate::error(-32601,"cargo method not found")); }
|
||||
let plugin=params["plugin"].as_str().ok_or_else(||crate::error(-32602,"plugin must be a string"))?;
|
||||
let options = BuildOptions {
|
||||
timeout_secs: seconds(¶ms, "timeout_secs", DEFAULT_TIMEOUT_SECS)?,
|
||||
test_timeout_secs: seconds(¶ms, "test_timeout_secs", DEFAULT_TEST_TIMEOUT_SECS)?,
|
||||
};
|
||||
match builder.build_with_options(&builder.world.plugin_dir(plugin).join("source"), options).await {
|
||||
Ok(output)=>Ok(json!({"wasm":format!("/{}",output.wasm.strip_prefix(&builder.world.root).unwrap().display()),"tests":output.tests,"warnings":output.warnings,"logs":output.logs})),
|
||||
Err(failure)=>Err(json!({"code":-32004,"message":failure.reason,"data":{"reason":failure.reason,"tests":failure.tests,"output":failure.output,"elapsed_secs":failure.elapsed_secs,"step":failure.step,"logs":failure.logs}})),
|
||||
}
|
||||
})
|
||||
}));
|
||||
}
|
||||
/// Copies source once, runs WASI tests, then publishes a release component under state/cargo/builds.
|
||||
pub async fn build(&self, source: &Path) -> std::result::Result<BuildOutput, BuildFailure> {
|
||||
self.build_with_options(source, BuildOptions::default())
|
||||
.await
|
||||
}
|
||||
/// Like build, with explicit positive wall-clock budgets. Waiting for the mutex is unbounded.
|
||||
pub async fn build_with_options(
|
||||
&self,
|
||||
source: &Path,
|
||||
options: BuildOptions,
|
||||
) -> std::result::Result<BuildOutput, BuildFailure> {
|
||||
let logs = LogFiles::create(&self.world.root)?;
|
||||
let result = self.build_logged(source, options, &logs).await;
|
||||
let info = logs.info(&self.world.root)?;
|
||||
match result {
|
||||
Ok(mut output) => {
|
||||
output.logs = Some(info);
|
||||
Ok(output)
|
||||
}
|
||||
Err(mut failure) => {
|
||||
failure.logs = Some(info);
|
||||
Err(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn build_logged(
|
||||
&self,
|
||||
source: &Path,
|
||||
options: BuildOptions,
|
||||
logs: &LogFiles,
|
||||
) -> std::result::Result<BuildOutput, BuildFailure> {
|
||||
if options.timeout_secs == 0 || options.test_timeout_secs == 0 {
|
||||
return Err(anyhow::anyhow!("timeouts must be at least 1 second").into());
|
||||
}
|
||||
let build_guard = self.builds.clone().lock_owned().await;
|
||||
let started = Instant::now();
|
||||
let deadline = started
|
||||
.checked_add(Duration::from_secs(options.timeout_secs))
|
||||
.ok_or_else(|| anyhow::anyhow!("timeout_secs is too large"))?;
|
||||
let work = self.world.state_dir(ID).join("work");
|
||||
let target = self.target.clone();
|
||||
let temporary = self.world.state_dir(ID).join("tmp");
|
||||
let cleanup = temporary.clone();
|
||||
let source = source.to_path_buf();
|
||||
let copy = work.clone();
|
||||
let sdk = self.sdk.clone();
|
||||
let build_guard = tokio::task::spawn_blocking(move || -> Result<_> {
|
||||
Self::seed_sdk(&sdk)?;
|
||||
if cleanup.exists() {
|
||||
std::fs::remove_dir_all(&cleanup)
|
||||
.with_context(|| format!("remove {}", cleanup.display()))?;
|
||||
}
|
||||
std::fs::create_dir_all(&cleanup)
|
||||
.with_context(|| format!("create {}", cleanup.display()))?;
|
||||
if copy.exists() {
|
||||
std::fs::remove_dir_all(©)
|
||||
.with_context(|| format!("remove {}", copy.display()))?;
|
||||
}
|
||||
copy_source(&source, ©, true)?;
|
||||
let manifest = copy.join("Cargo.toml");
|
||||
let text = std::fs::read_to_string(&manifest)
|
||||
.with_context(|| format!("read {}", manifest.display()))?;
|
||||
std::fs::write(
|
||||
&manifest,
|
||||
prepare_manifest(&text, &sdk)
|
||||
.with_context(|| format!("prepare {}", manifest.display()))?,
|
||||
)
|
||||
.with_context(|| format!("write {}", manifest.display()))?;
|
||||
Ok(build_guard)
|
||||
})
|
||||
.await
|
||||
.map_err(anyhow::Error::from)??;
|
||||
let mut test = Command::new("cargo");
|
||||
test.args([
|
||||
"test",
|
||||
"--no-fail-fast",
|
||||
"--release",
|
||||
"--target",
|
||||
TARGET,
|
||||
"--target-dir",
|
||||
])
|
||||
.arg(&target)
|
||||
.current_dir(&work)
|
||||
.env("TMPDIR", &temporary)
|
||||
.env(
|
||||
"SAUSAGE_TEST_TIMEOUT_SECS",
|
||||
options.test_timeout_secs.to_string(),
|
||||
)
|
||||
.arg("--config")
|
||||
.arg(format!(
|
||||
"target.wasm32-wasip2.runner={}",
|
||||
json!([self.runner, "test-runner"])
|
||||
));
|
||||
let output = run_command(
|
||||
test,
|
||||
started,
|
||||
deadline,
|
||||
"tests",
|
||||
TestResults::default(),
|
||||
logs,
|
||||
)
|
||||
.await?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let tests = TestResults {
|
||||
passed: test_count(&stdout, "passed"),
|
||||
failed: if output.status.success() {
|
||||
0
|
||||
} else {
|
||||
test_count(&stdout, "failed").max(1)
|
||||
},
|
||||
output: strip_progress(&format!("{stdout}\n{stderr}")),
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Err(BuildFailure {
|
||||
logs: None,
|
||||
reason: "WASI tests failed".into(),
|
||||
output: tests.output.clone(),
|
||||
tests,
|
||||
elapsed_secs: None,
|
||||
step: None,
|
||||
});
|
||||
}
|
||||
let mut build = Command::new("cargo");
|
||||
build
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
"--target",
|
||||
TARGET,
|
||||
"--message-format=json-render-diagnostics",
|
||||
"--target-dir",
|
||||
])
|
||||
.arg(&target)
|
||||
.current_dir(&work);
|
||||
let output = run_command(
|
||||
build,
|
||||
started,
|
||||
deadline,
|
||||
"release build",
|
||||
tests.clone(),
|
||||
logs,
|
||||
)
|
||||
.await?;
|
||||
let result = async {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let diagnostics = stderr.to_string();
|
||||
let mut artifacts = Vec::new();
|
||||
for line in stdout.lines() {
|
||||
if let Ok(message) = serde_json::from_str::<Value>(line)
|
||||
&& message["reason"] == "compiler-artifact"
|
||||
&& message["target"]["kind"]
|
||||
.as_array()
|
||||
.is_some_and(|kinds| kinds.iter().any(|kind| kind == "bin"))
|
||||
&& let Some(executable) = message["executable"].as_str()
|
||||
{
|
||||
artifacts.push(PathBuf::from(executable));
|
||||
}
|
||||
}
|
||||
if !output.status.success() {
|
||||
bail!("{diagnostics}");
|
||||
}
|
||||
if artifacts.len() != 1 {
|
||||
bail!(
|
||||
"cargo must build exactly one binary, found {}",
|
||||
artifacts.len()
|
||||
);
|
||||
}
|
||||
let artifact = artifacts.pop().unwrap();
|
||||
let builds = self.world.state_dir(ID).join("builds");
|
||||
let destination = tokio::task::spawn_blocking(move || {
|
||||
let _guard = build_guard;
|
||||
publish(&builds, &artifact)
|
||||
})
|
||||
.await??;
|
||||
Ok(BuildOutput {
|
||||
logs: None,
|
||||
wasm: destination,
|
||||
warnings: strip_progress(&diagnostics),
|
||||
tests: tests.clone(),
|
||||
})
|
||||
}
|
||||
.await;
|
||||
result.map_err(|error: anyhow::Error| BuildFailure {
|
||||
logs: None,
|
||||
reason: "release build failed".into(),
|
||||
output: format!("{error:#}"),
|
||||
tests,
|
||||
elapsed_secs: None,
|
||||
step: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
/// Kills cargo and every descendant, including tests and build scripts, on cancellation.
|
||||
struct ProcessGroup(u32);
|
||||
impl Drop for ProcessGroup {
|
||||
fn drop(&mut self) {
|
||||
let _ = nix::sys::signal::killpg(
|
||||
nix::unistd::Pid::from_raw(self.0 as i32),
|
||||
nix::sys::signal::Signal::SIGKILL,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Starts one process group and retains its guard across all output collection.
|
||||
async fn run_command(
|
||||
mut command: Command,
|
||||
started: Instant,
|
||||
deadline: Instant,
|
||||
step: &'static str,
|
||||
mut tests: TestResults,
|
||||
logs: &LogFiles,
|
||||
) -> std::result::Result<std::process::Output, BuildFailure> {
|
||||
command
|
||||
.process_group(0)
|
||||
.kill_on_drop(true)
|
||||
.stdin(std::process::Stdio::null())
|
||||
// Child writes go straight to persistent files: no pipe buffers or reader
|
||||
// tasks can be dropped on cancellation. Parsing happens only afterwards.
|
||||
.stdout(logs.stdout.try_clone().context("clone stdout log")?)
|
||||
.stderr(logs.stderr.try_clone().context("clone stderr log")?);
|
||||
let out_start = logs.stdout.metadata().context("stat stdout log")?.len();
|
||||
let err_start = logs.stderr.metadata().context("stat stderr log")?.len();
|
||||
let mut child = command.spawn().context("start cargo")?;
|
||||
let group = ProcessGroup(child.id().unwrap());
|
||||
|
||||
let (status, timed_out) = {
|
||||
let collect = child.wait();
|
||||
tokio::pin!(collect);
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
// Kill the entire tree before waiting for EOF/reaping cargo. The guard also
|
||||
// covers cancellation while collecting, and runs before the mutex drops.
|
||||
let _ = nix::sys::signal::killpg(nix::unistd::Pid::from_raw(group.0 as i32), nix::sys::signal::Signal::SIGKILL);
|
||||
(collect.await, true)
|
||||
}
|
||||
status = &mut collect => (status, false),
|
||||
}
|
||||
};
|
||||
let status = status.map_err(anyhow::Error::from)?;
|
||||
// Also stop any descendants that kept the streams open after cargo exited.
|
||||
drop(group);
|
||||
let out = logs.read(&logs.stdout_path, out_start)?;
|
||||
let err = logs.read(&logs.stderr_path, err_start)?;
|
||||
if timed_out {
|
||||
let output = format!(
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&out),
|
||||
String::from_utf8_lossy(&err)
|
||||
);
|
||||
let output = output
|
||||
.trim_end()
|
||||
.lines()
|
||||
.rev()
|
||||
.take(50)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if step == "tests" {
|
||||
tests.passed = test_count(&String::from_utf8_lossy(&out), "passed");
|
||||
tests.failed = test_count(&String::from_utf8_lossy(&out), "failed");
|
||||
tests.output = output.clone();
|
||||
}
|
||||
return Err(BuildFailure {
|
||||
logs: None,
|
||||
reason: "timeout".into(),
|
||||
tests,
|
||||
output,
|
||||
elapsed_secs: Some(started.elapsed().as_secs_f64()),
|
||||
step: Some(step),
|
||||
});
|
||||
}
|
||||
Ok(std::process::Output {
|
||||
status,
|
||||
stdout: out,
|
||||
stderr: err,
|
||||
})
|
||||
}
|
||||
/// Copies only source, excluding build outputs and git internals at every level.
|
||||
fn copy_source(source: &Path, destination: &Path, root: bool) -> Result<()> {
|
||||
std::fs::create_dir_all(destination)
|
||||
.with_context(|| format!("create {}", destination.display()))?;
|
||||
for entry in std::fs::read_dir(source).with_context(|| format!("read {}", source.display()))? {
|
||||
let entry = entry.with_context(|| format!("read entry in {}", source.display()))?;
|
||||
if root && matches!(entry.file_name().to_str(), Some("target" | ".git")) {
|
||||
continue;
|
||||
}
|
||||
let from = entry.path();
|
||||
let to = destination.join(entry.file_name());
|
||||
if from.is_dir() {
|
||||
copy_source(&from, &to, false)?;
|
||||
} else {
|
||||
std::fs::copy(&from, &to)
|
||||
.with_context(|| format!("copy {} to {}", from.display(), to.display()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Repoints SDK dependencies without discarding feature flags and detaches the copied crate's workspace.
|
||||
fn prepare_manifest(text: &str, sdk: &Path) -> Result<String> {
|
||||
let mut document: toml_edit::DocumentMut = text.parse()?;
|
||||
rewrite_dependencies(document.as_table_mut(), sdk);
|
||||
if !document.contains_key("workspace") {
|
||||
document["workspace"] = toml_edit::table();
|
||||
}
|
||||
Ok(document.to_string())
|
||||
}
|
||||
/// Rewrites dependency entries recursively, including target and workspace tables.
|
||||
fn rewrite_dependencies(table: &mut dyn toml_edit::TableLike, sdk: &Path) {
|
||||
for (name, item) in table.iter_mut() {
|
||||
if matches!(
|
||||
&*name,
|
||||
"dependencies" | "dev-dependencies" | "build-dependencies"
|
||||
) {
|
||||
if let Some(dependencies) = item.as_table_like_mut() {
|
||||
for (name, dependency) in dependencies.iter_mut() {
|
||||
if dependency
|
||||
.get("workspace")
|
||||
.and_then(toml_edit::Item::as_bool)
|
||||
== Some(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if &*name == "sausage-plugin"
|
||||
|| dependency.get("package").and_then(toml_edit::Item::as_str)
|
||||
== Some("sausage-plugin")
|
||||
{
|
||||
if dependency.is_str() {
|
||||
let version = dependency.as_str().unwrap().to_string();
|
||||
*dependency = toml_edit::value(toml_edit::InlineTable::new());
|
||||
dependency["version"] = toml_edit::value(version);
|
||||
}
|
||||
dependency["path"] = toml_edit::value(sdk.display().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(nested) = item.as_table_like_mut() {
|
||||
rewrite_dependencies(nested, sdk);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Publishes a complete artifact and retains only the ten newest numeric build folders.
|
||||
fn publish(builds: &Path, artifact: &Path) -> Result<PathBuf> {
|
||||
std::fs::create_dir_all(builds).with_context(|| format!("create {}", builds.display()))?;
|
||||
let mut numbers: Vec<u64> = std::fs::read_dir(builds)
|
||||
.with_context(|| format!("read {}", builds.display()))?
|
||||
.filter_map(|entry| entry.ok()?.file_name().to_str()?.parse().ok())
|
||||
.collect();
|
||||
numbers.sort_unstable();
|
||||
let number = numbers.last().copied().unwrap_or(0) + 1;
|
||||
let destination = builds
|
||||
.join(number.to_string())
|
||||
.join(artifact.file_name().unwrap());
|
||||
crate::world::write_atomic(
|
||||
&destination,
|
||||
&std::fs::read(artifact).with_context(|| format!("read {}", artifact.display()))?,
|
||||
)
|
||||
.with_context(|| format!("publish {}", destination.display()))?;
|
||||
for old in numbers.iter().take(numbers.len().saturating_sub(9)) {
|
||||
let path = builds.join(old.to_string());
|
||||
std::fs::remove_dir_all(&path).with_context(|| format!("remove {}", path.display()))?;
|
||||
}
|
||||
Ok(destination)
|
||||
}
|
||||
/// Writes embedded sources only when their bytes changed.
|
||||
fn write_changed(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
if std::fs::read(path).ok().as_deref() != Some(bytes) {
|
||||
std::fs::create_dir_all(path.parent().unwrap())
|
||||
.with_context(|| format!("create parent of {}", path.display()))?;
|
||||
std::fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Collects embedded source files for seeding a missing world SDK.
|
||||
fn collect_files(
|
||||
directory: &Dir<'_>,
|
||||
destination: &Path,
|
||||
files: &mut std::collections::BTreeMap<PathBuf, Vec<u8>>,
|
||||
) {
|
||||
for file in directory.files() {
|
||||
files.insert(destination.join(file.path()), file.contents().to_vec());
|
||||
}
|
||||
for directory in directory.dirs() {
|
||||
collect_files(directory, destination, files);
|
||||
}
|
||||
}
|
||||
/// Counts only final libtest summaries, never individual status lines.
|
||||
fn test_count(output: &str, field: &str) -> u64 {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("test result:"))
|
||||
.flat_map(|summary| summary.split(';'))
|
||||
.filter_map(|part| {
|
||||
let words: Vec<_> = part.split_whitespace().collect();
|
||||
let index = words.iter().position(|word| *word == field)?;
|
||||
words.get(index.checked_sub(1)?)?.parse::<u64>().ok()
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
/// Removes progress lines while retaining compiler diagnostics.
|
||||
fn strip_progress(output: &str) -> String {
|
||||
output
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
![
|
||||
"Compiling ",
|
||||
"Finished ",
|
||||
"Blocking ",
|
||||
"Updating ",
|
||||
"Locking ",
|
||||
"Adding ",
|
||||
]
|
||||
.iter()
|
||||
.any(|prefix| line.trim_start().starts_with(prefix))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod timeout_tests {
|
||||
use super::*;
|
||||
|
||||
/// Streams retain exact bytes, including non-UTF8, progress and JSON; phases append.
|
||||
#[tokio::test]
|
||||
async fn raw_streams_append_before_parsing() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let logs = LogFiles::create(directory.path()).unwrap();
|
||||
for _ in 0..2 {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "printf '\\377raw\\r'; printf ' Compiling progress\\n{\"reason\":\"compiler-artifact\"}\\n' >&2; exit 3"]);
|
||||
let now = Instant::now();
|
||||
let output = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
run_command(
|
||||
command,
|
||||
now,
|
||||
now + Duration::from_secs(3),
|
||||
"tests",
|
||||
TestResults::default(),
|
||||
&logs,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert_eq!(output.stdout, b"\xffraw\r");
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("Compiling progress"));
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(&logs.stdout_path).unwrap(),
|
||||
b"\xffraw\r\xffraw\r"
|
||||
);
|
||||
let info = logs.info(directory.path()).unwrap();
|
||||
assert_eq!(info.stdout.bytes, 10);
|
||||
assert_eq!(info.total_bytes, info.stdout.bytes + info.stderr.bytes);
|
||||
assert!(info.stdout.path.starts_with("/logs/cargo/"));
|
||||
assert!(!directory.path().join("state/cargo").exists());
|
||||
}
|
||||
|
||||
/// Cancellation leaves kernel-written data intact, even without a newline.
|
||||
#[tokio::test]
|
||||
async fn cancelled_command_keeps_logs() {
|
||||
use notify::Watcher;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let logs = LogFiles::create(directory.path()).unwrap();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut watcher =
|
||||
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
|
||||
if let Ok(event) = event {
|
||||
if event
|
||||
.paths
|
||||
.iter()
|
||||
.any(|path| path.file_name().is_some_and(|name| name == "ready"))
|
||||
{
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
watcher
|
||||
.watch(directory.path(), notify::RecursiveMode::NonRecursive)
|
||||
.unwrap();
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "printf 'partial\\377'; printf 'stderr\\r' >&2; touch \"$1/ready\"; exec sleep 1000", "sh"]).arg(directory.path());
|
||||
let now = Instant::now();
|
||||
let task = tokio::spawn(async move {
|
||||
run_command(
|
||||
command,
|
||||
now,
|
||||
now + Duration::from_secs(60),
|
||||
"tests",
|
||||
TestResults::default(),
|
||||
&logs,
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(5), rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
task.abort();
|
||||
assert!(task.await.unwrap_err().is_cancelled());
|
||||
let paths: Vec<_> = std::fs::read_dir(directory.path().join("logs/cargo"))
|
||||
.unwrap()
|
||||
.flat_map(|day| std::fs::read_dir(day.unwrap().path()).unwrap())
|
||||
.map(|file| file.unwrap().path())
|
||||
.collect();
|
||||
assert_eq!(paths.len(), 2);
|
||||
for path in paths {
|
||||
let expected: &[u8] = if path.to_string_lossy().ends_with(".stdout.log") {
|
||||
b"partial\xff"
|
||||
} else {
|
||||
b"stderr\r"
|
||||
};
|
||||
assert_eq!(std::fs::read(path).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deadline errors retain the shared start time, phase, test totals, and output tail.
|
||||
#[tokio::test]
|
||||
async fn timeout_diagnostics() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
"i=0; while [ $i -lt 100 ]; do echo diagnostic-$i; i=$((i+1)); done; exec sleep 1000",
|
||||
]);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let logs = LogFiles::create(directory.path()).unwrap();
|
||||
let now = Instant::now();
|
||||
let failure = run_command(
|
||||
command,
|
||||
now - Duration::from_secs(10),
|
||||
now + Duration::from_secs(1),
|
||||
"release build",
|
||||
TestResults {
|
||||
passed: 7,
|
||||
failed: 0,
|
||||
output: "successful tests".into(),
|
||||
},
|
||||
&logs,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(failure.reason, "timeout");
|
||||
assert_eq!(failure.step, Some("release build"));
|
||||
assert!(failure.elapsed_secs.unwrap() >= 11.0);
|
||||
assert_eq!(failure.tests.passed, 7);
|
||||
assert_eq!(failure.tests.output, "successful tests");
|
||||
assert_eq!(failure.output.lines().count(), 50);
|
||||
assert!(failure.output.contains("diagnostic-99"));
|
||||
assert!(!failure.output.contains("diagnostic-0\n"));
|
||||
let raw = std::fs::read_to_string(&logs.stdout_path).unwrap();
|
||||
assert_eq!(raw.lines().count(), 100);
|
||||
assert!(raw.starts_with("diagnostic-0\n"));
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
//! Metadata-keyed component loads and weak compiled versions; candidate copies never enter the path cache.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// PROTOCOL 10.3: a pending component load does not occupy admission.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn loading_does_not_hold_admission() {
|
||||
let folder =
|
||||
std::env::temp_dir().join(format!("sausage-admission-{}", uuid::Uuid::now_v7()));
|
||||
let world = crate::world::World::create(&folder).unwrap();
|
||||
let path = world.plugin_dir("probe").join("plugin.wasm");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, []).unwrap();
|
||||
let host = crate::Host::start_with_cache(&folder, &folder.join("cache"))
|
||||
.await
|
||||
.unwrap();
|
||||
let entry = host
|
||||
.0
|
||||
.components
|
||||
.paths
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(path)
|
||||
.or_default()
|
||||
.clone();
|
||||
let blocked = entry.lock().await;
|
||||
let caller = host.clone();
|
||||
let pending =
|
||||
tokio::spawn(async move { caller.call("probe.echo", serde_json::json!({})).await });
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
while Arc::strong_count(&entry) < 3 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let admitted = host.0.admission.try_write().is_ok();
|
||||
drop(blocked);
|
||||
assert!(pending.await.unwrap().is_err());
|
||||
host.stop().await;
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
assert!(admitted, "a component load held the admission gate");
|
||||
}
|
||||
/// PROTOCOL 10.3: temporary discovery and activation copies never accumulate cached paths.
|
||||
#[tokio::test]
|
||||
async fn candidate_copies_do_not_enter_path_cache() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let target = root.join("target/core/guests");
|
||||
assert!(
|
||||
std::process::Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--offline",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"--manifest-path"
|
||||
])
|
||||
.arg(root.join("cli/tests/plugins/probe/Cargo.toml"))
|
||||
.arg("--target-dir")
|
||||
.arg(&target)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
let folder =
|
||||
std::env::temp_dir().join(format!("sausage-component-cache-{}", uuid::Uuid::now_v7()));
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let runtime = Runtime::new(&root.join("target/core/cache-test")).unwrap();
|
||||
let cache = Components::default();
|
||||
for name in ["discover-test.wasm", "activate-test.wasm"] {
|
||||
let path = folder.join(name);
|
||||
std::fs::copy(target.join("wasm32-wasip2/debug/probe.wasm"), &path).unwrap();
|
||||
let loaded = cache.load(path.clone(), runtime.clone()).await.unwrap();
|
||||
assert!(cache.paths.lock().unwrap().is_empty());
|
||||
let weak = Arc::downgrade(&loaded.prepared);
|
||||
drop(loaded);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
assert!(weak.upgrade().is_none());
|
||||
}
|
||||
std::fs::remove_dir(folder).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
use crate::{
|
||||
error,
|
||||
runtime::{Prepared, Runtime},
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex, Weak},
|
||||
time::SystemTime,
|
||||
};
|
||||
use tokio::sync::{Mutex as AsyncMutex, OnceCell};
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Fingerprint {
|
||||
size: u64,
|
||||
modified: SystemTime,
|
||||
#[cfg(unix)]
|
||||
identity: (u64, u64, i64, i64),
|
||||
}
|
||||
impl Fingerprint {
|
||||
pub(crate) async fn read(path: &Path) -> Result<Self, serde_json::Value> {
|
||||
let metadata = tokio::fs::metadata(path)
|
||||
.await
|
||||
.map_err(|_| error(-32001, "no such plugin"))?;
|
||||
Ok(Self {
|
||||
size: metadata.len(),
|
||||
modified: metadata
|
||||
.modified()
|
||||
.map_err(|e| error(-32603, &e.to_string()))?,
|
||||
#[cfg(unix)]
|
||||
identity: {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
(
|
||||
metadata.dev(),
|
||||
metadata.ino(),
|
||||
metadata.ctime(),
|
||||
metadata.ctime_nsec(),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Loaded {
|
||||
pub(crate) prepared: Arc<Prepared>,
|
||||
pub(crate) hash: String,
|
||||
pub(crate) fingerprint: Fingerprint,
|
||||
// Retain the per-hash cell only while a cached path refers to this version.
|
||||
_compiled: Arc<OnceCell<Arc<Prepared>>>,
|
||||
}
|
||||
type PathEntry = Arc<AsyncMutex<Option<Loaded>>>;
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Components {
|
||||
paths: Mutex<HashMap<PathBuf, PathEntry>>,
|
||||
compiled: Mutex<HashMap<String, Weak<OnceCell<Arc<Prepared>>>>>,
|
||||
}
|
||||
impl Components {
|
||||
pub(crate) async fn load(
|
||||
&self,
|
||||
path: PathBuf,
|
||||
runtime: Arc<Runtime>,
|
||||
) -> Result<Loaded, serde_json::Value> {
|
||||
let candidate = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with("activate-") || n.starts_with("discover-"));
|
||||
let entry = if candidate {
|
||||
Arc::default()
|
||||
} else {
|
||||
self.paths
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(path.clone())
|
||||
.or_default()
|
||||
.clone()
|
||||
};
|
||||
let mut cached = entry.lock().await;
|
||||
let fingerprint = match Fingerprint::read(&path).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => {
|
||||
*cached = None;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Some(version) = cached.as_ref()
|
||||
&& version.fingerprint == fingerprint
|
||||
{
|
||||
return Ok(version.clone());
|
||||
}
|
||||
let (bytes, hash) = tokio::task::spawn_blocking(move || -> std::io::Result<_> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let hash = format!("{:x}", Sha256::digest(&bytes));
|
||||
Ok((bytes, hash))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| error(-32603, &e.to_string()))?
|
||||
.map_err(|e| error(-32603, &e.to_string()))?;
|
||||
let compiled = {
|
||||
let mut versions = self.compiled.lock().unwrap();
|
||||
versions.retain(|_, v| v.strong_count() > 0);
|
||||
if let Some(cell) = versions.get(&hash).and_then(Weak::upgrade) {
|
||||
cell
|
||||
} else {
|
||||
let cell = Arc::new(OnceCell::new());
|
||||
versions.insert(hash.clone(), Arc::downgrade(&cell));
|
||||
cell
|
||||
}
|
||||
};
|
||||
let prepared = compiled
|
||||
.get_or_try_init(|| async move {
|
||||
tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
|
||||
let component = runtime.compile(&bytes)?;
|
||||
Ok(Arc::new(runtime.prepare(&component)))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| error(-32603, &e.to_string()))?
|
||||
.map_err(|e| error(-32603, &e.to_string()))
|
||||
})
|
||||
.await?
|
||||
.clone();
|
||||
let loaded = Loaded {
|
||||
prepared,
|
||||
hash,
|
||||
fingerprint,
|
||||
_compiled: compiled,
|
||||
};
|
||||
if !candidate {
|
||||
*cached = Some(loaded.clone());
|
||||
}
|
||||
Ok(loaded)
|
||||
}
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
//! Sequential console commands, line editing, and live notifications.
|
||||
use crate::Host;
|
||||
use anyhow::Result;
|
||||
use rustyline::{DefaultEditor, ExternalPrinter, error::ReadlineError};
|
||||
use sausage_vfs::{Layer, Open};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const HELP: &str = "text chat with the agent
|
||||
plugin.method {json} call a plugin
|
||||
ls [path], cat <path>, methods <plugin>
|
||||
ps, log [plugin or pid] [-n N]
|
||||
activate <plugin> <wasm>, revert <plugin>
|
||||
stop <pid>, kill <pid>, help, quit";
|
||||
|
||||
/// Read a complete file through the host filesystem.
|
||||
async fn read(host: &Host, path: &str) -> Result<String> {
|
||||
let file = host
|
||||
.filesystem()
|
||||
.await?
|
||||
.open(
|
||||
path,
|
||||
Open {
|
||||
follow: true,
|
||||
..Open::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let chunk = file.read(bytes.len() as u64, 65536).await?;
|
||||
if chunk.is_empty() {
|
||||
return Ok(String::from_utf8(bytes)?);
|
||||
}
|
||||
bytes.extend(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognize the process IDs accepted by stop, kill, and log.
|
||||
fn is_pid(text: &str) -> bool {
|
||||
text.rsplit_once('-').is_some()
|
||||
&& text
|
||||
.get(text.len().saturating_sub(36)..)
|
||||
.is_some_and(|id| uuid::Uuid::try_parse(id).is_ok())
|
||||
}
|
||||
|
||||
/// Render method signatures and their documentation.
|
||||
fn methods(plugin: &str, doc: &Value) -> String {
|
||||
let mut lines = Vec::new();
|
||||
for method in doc["methods"].as_array().into_iter().flatten() {
|
||||
let params = method["params"].as_array().cloned().unwrap_or_default();
|
||||
let signature = params
|
||||
.iter()
|
||||
.map(|p| {
|
||||
format!(
|
||||
"{}: {}",
|
||||
p["name"].as_str().unwrap_or("?"),
|
||||
p["schema"]["type"].as_str().unwrap_or("any")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
lines.push(format!(
|
||||
"{plugin}.{}({signature}) -> {}",
|
||||
method["name"].as_str().unwrap_or("?"),
|
||||
method["result"]["schema"]["type"].as_str().unwrap_or("any")
|
||||
));
|
||||
for field in ["summary", "description"] {
|
||||
if let Some(text) = method[field].as_str() {
|
||||
lines.push(format!(" {text}"));
|
||||
}
|
||||
}
|
||||
for p in params {
|
||||
if let Some(text) = p["description"].as_str() {
|
||||
lines.push(format!(" {}: {text}", p["name"].as_str().unwrap_or("?")));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Parse journal filters, or treat invalid arguments as chat.
|
||||
fn log_params(words: &[&str]) -> Option<Value> {
|
||||
let mut params = json!({});
|
||||
let mut words = words.iter();
|
||||
while let Some(word) = words.next() {
|
||||
if *word == "-n" {
|
||||
params["limit"] = json!(words.next()?.parse::<u64>().ok()?);
|
||||
} else if params.get("plugin").is_none() && params.get("pid").is_none() {
|
||||
params[if is_pid(word) { "pid" } else { "plugin" }] = json!(word);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(params)
|
||||
}
|
||||
|
||||
/// Execute one line and return its unlabelled result.
|
||||
pub async fn execute_line(host: &Host, line: &str) -> Result<String> {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let (command, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
|
||||
let rest = rest.trim();
|
||||
let words: Vec<_> = rest.split_whitespace().collect();
|
||||
match (command, words.as_slice()) {
|
||||
("help", []) => return Ok(HELP.into()),
|
||||
("quit", []) => return Ok("Goodbye.".into()),
|
||||
("cat", [path]) => return read(host, path).await,
|
||||
("ls", [] | [_]) => {
|
||||
let mut entries = host
|
||||
.filesystem()
|
||||
.await?
|
||||
.list(words.first().copied().unwrap_or("/"))
|
||||
.await?;
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
return Ok(entries
|
||||
.iter()
|
||||
.map(|e| format!("{}{}", e.name, if e.directory { "/" } else { "" }))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"));
|
||||
}
|
||||
("methods", [plugin]) => {
|
||||
let _activation = host.0.activations.lock().await;
|
||||
host.refresh_graph()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
return Ok(methods(
|
||||
plugin,
|
||||
&serde_json::from_str(
|
||||
&read(host, &format!("/proc/core/plugins/{plugin}/discover.json")).await?,
|
||||
)?,
|
||||
));
|
||||
}
|
||||
("ps", []) => {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_millis();
|
||||
let mut processes: Vec<_> = host.processes().into_iter().collect();
|
||||
processes.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut lines = vec!["PID PLUGIN METHOD PARENT STATE AGE".into()];
|
||||
lines.extend(processes.iter().map(|(pid, p)| {
|
||||
format!(
|
||||
"{pid} {} {} {} {} {}s",
|
||||
p.plugin,
|
||||
p.method,
|
||||
p.parent.as_deref().unwrap_or("-"),
|
||||
p.state,
|
||||
now.saturating_sub(p.start_time) / 1000
|
||||
)
|
||||
}));
|
||||
return Ok(lines.join("\n"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let explicit = (rest.is_empty() || rest.starts_with('{'))
|
||||
&& command
|
||||
.split_once('.')
|
||||
.is_some_and(|(p, m)| !p.is_empty() && !m.is_empty());
|
||||
let (method, params) = match (command, words.as_slice()) {
|
||||
("activate", [plugin, wasm]) => {
|
||||
("core.activate".into(), json!({"plugin":plugin,"wasm":wasm}))
|
||||
}
|
||||
("revert", [plugin]) => ("core.revert".into(), json!({"plugin":plugin})),
|
||||
("stop" | "kill", [pid]) if is_pid(pid) => (format!("core.{command}"), json!({"pid":pid})),
|
||||
("log", _) if log_params(&words).is_some() => {
|
||||
("core.log".into(), log_params(&words).unwrap())
|
||||
}
|
||||
_ if explicit => (
|
||||
command.into(),
|
||||
if rest.is_empty() {
|
||||
json!({})
|
||||
} else {
|
||||
serde_json::from_str(rest)?
|
||||
},
|
||||
),
|
||||
_ => ("agent.chat".into(), json!({"text":line})),
|
||||
};
|
||||
let value = match host.call(&method, params).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Ok(format!("error: {}", serde_json::to_string_pretty(&error)?)),
|
||||
};
|
||||
if method == "agent.chat" && !explicit {
|
||||
return Ok(value["text"]
|
||||
.as_str()
|
||||
.or_else(|| value.as_str())
|
||||
.map(str::to_owned)
|
||||
.unwrap_or(serde_json::to_string_pretty(&value)?));
|
||||
}
|
||||
if method == "core.log" && command == "log" {
|
||||
return value
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let time = chrono::DateTime::parse_from_rfc3339(e["time"].as_str().unwrap())?
|
||||
.with_timezone(&chrono::Local);
|
||||
let pid = e["pid"].as_str().unwrap();
|
||||
let prefix = format!("{}-", e["plugin"].as_str().unwrap());
|
||||
let short = pid
|
||||
.strip_prefix(&prefix)
|
||||
.map(|id| format!("{prefix}{}", &id[..8]))
|
||||
.unwrap_or(pid.into());
|
||||
Ok(format!(
|
||||
"{} {short} {} {}",
|
||||
time.format("%H:%M:%S%.3f"),
|
||||
e["stream"].as_str().unwrap(),
|
||||
e["line"].as_str().unwrap()
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.map(|lines| lines.join("\n"));
|
||||
}
|
||||
Ok(serde_json::to_string_pretty(&value)?)
|
||||
}
|
||||
|
||||
/// Dim text notifications and preserve other payloads.
|
||||
pub fn notification_text(value: &Value) -> String {
|
||||
let params = &value["params"];
|
||||
if params["name"] != "text" {
|
||||
return value.to_string();
|
||||
}
|
||||
let payload = ¶ms["params"];
|
||||
let text = payload["text"]
|
||||
.as_str()
|
||||
.or_else(|| payload.as_str())
|
||||
.map(str::to_owned)
|
||||
.unwrap_or(payload.to_string());
|
||||
format!("\x1b[2m{text}\x1b[0m")
|
||||
}
|
||||
|
||||
/// Read and execute one line at a time. Interrupting a wait does not cancel its host process.
|
||||
pub async fn run_input(host: Host) -> Result<()> {
|
||||
let terminal = std::io::IsTerminal::is_terminal(&std::io::stdin());
|
||||
let history = host.0.world.state_dir("core").join("history");
|
||||
let mut editor = if terminal {
|
||||
Some(DefaultEditor::new()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(editor) = &mut editor {
|
||||
let _ = editor.load_history(&history);
|
||||
}
|
||||
let mut printer = editor
|
||||
.as_mut()
|
||||
.map(|e| e.create_external_printer())
|
||||
.transpose()?;
|
||||
let (request, requests) =
|
||||
std::sync::mpsc::channel::<tokio::sync::oneshot::Sender<Result<Option<String>>>>();
|
||||
std::thread::spawn(move || {
|
||||
for response in requests {
|
||||
let line = (|| -> Result<_> {
|
||||
if let Some(editor) = &mut editor {
|
||||
match editor.readline("> ") {
|
||||
Ok(line) => {
|
||||
editor.add_history_entry(&line)?;
|
||||
editor.save_history(&history)?;
|
||||
Ok(Some(line))
|
||||
}
|
||||
Err(ReadlineError::Interrupted | ReadlineError::Eof) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
} else {
|
||||
std::io::stdin()
|
||||
.lines()
|
||||
.next()
|
||||
.transpose()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
})();
|
||||
if response.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut notifications = host.subscribe();
|
||||
let mut notification_task = tokio::task::JoinSet::new();
|
||||
notification_task.spawn(async move {
|
||||
loop {
|
||||
match notifications.recv().await {
|
||||
Ok(value) => {
|
||||
let text = notification_text(&value);
|
||||
if let Some(printer) = &mut printer {
|
||||
let _ = printer.print(format!("{text}\n"));
|
||||
} else {
|
||||
println!("{text}");
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
|
||||
loop {
|
||||
let (send, line) = tokio::sync::oneshot::channel();
|
||||
request.send(send)?;
|
||||
let line =
|
||||
tokio::select! { result = line => result??, _ = interrupt.recv() => return Ok(()) };
|
||||
let Some(line) = line else {
|
||||
return Ok(());
|
||||
};
|
||||
let text = tokio::select! {
|
||||
result = execute_line(&host, &line) => result.unwrap_or_else(|e| format!("error: {e}")),
|
||||
_ = interrupt.recv() => "stopped waiting; the call continues, see ps".into(),
|
||||
};
|
||||
if !text.is_empty() {
|
||||
println!("{text}");
|
||||
}
|
||||
if line.trim() == "quit" {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//! The native core methods and their JSON parameters.
|
||||
use crate::{Host, RpcResult, error};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
impl Host {
|
||||
/// Execute a core method without starting a guest process.
|
||||
pub(crate) async fn core(
|
||||
&self,
|
||||
caller: &str,
|
||||
id: Option<Value>,
|
||||
method: &str,
|
||||
p: Value,
|
||||
) -> RpcResult {
|
||||
match method {
|
||||
"watch" => self.watch(caller, id, required_str(&p, "path")?).await,
|
||||
"call" => {
|
||||
self.route(
|
||||
caller.into(),
|
||||
id,
|
||||
required_str(&p, "method")?.into(),
|
||||
p.get("params").cloned().unwrap_or(json!({})),
|
||||
p["detach"].as_bool().unwrap_or(false),
|
||||
)
|
||||
.await
|
||||
}
|
||||
"notify" => {
|
||||
self.notify(
|
||||
caller,
|
||||
required_str(&p, "name")?,
|
||||
p.get("params").cloned().unwrap_or(json!({})),
|
||||
);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"kill" => {
|
||||
let pid = required_str(&p, "pid")?;
|
||||
let ended = self
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get(pid)
|
||||
.map(|p| p.ended.subscribe());
|
||||
self.kill(pid);
|
||||
if let Some(mut ended) = ended {
|
||||
let _ = ended.wait_for(|ended| *ended).await;
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"stop" => {
|
||||
if let Some(mut n) = self.begin_stop(required_str(&p, "pid")?) {
|
||||
let _ = n.wait_for(|ended| *ended).await;
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"lock" => {
|
||||
self.lock(caller, required_str(&p, "path")?, p["wait"] != false)
|
||||
.await
|
||||
}
|
||||
"unlock" => {
|
||||
self.unlock(caller, required_str(&p, "path")?);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"log" => Ok(self.0.journal.query(p).await),
|
||||
"activate" | "revert" => self.activation(caller, method, p).await,
|
||||
"restart" | "exit" => self.request_restart(),
|
||||
_ => Err(error(-32601, "method not found")),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn required_str<'a>(params: &'a Value, name: &str) -> Result<&'a str, Value> {
|
||||
params[name]
|
||||
.as_str()
|
||||
.ok_or_else(|| error(-32602, &format!("{name} must be a string")))
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
//! Execution of native futures and WASI processes, including transport drainage.
|
||||
use crate::{
|
||||
Host, NativeContext, NativePlugin,
|
||||
process::ProcessInfo,
|
||||
runtime::{Instance, InstanceConfig},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
impl Host {
|
||||
/// Run one process and retain its cleanup guard until all transport reads finish.
|
||||
pub(crate) async fn run_process(
|
||||
&self,
|
||||
pid: String,
|
||||
info: ProcessInfo,
|
||||
component: Option<std::sync::Arc<crate::runtime::Prepared>>,
|
||||
native: Option<NativePlugin>,
|
||||
incoming: mpsc::UnboundedReceiver<Value>,
|
||||
has_id: bool,
|
||||
) -> String {
|
||||
if let Some(native) = native {
|
||||
let context = NativeContext {
|
||||
host: self.clone(),
|
||||
pid: pid.clone(),
|
||||
};
|
||||
let result = native(context, info.method, info.params).await;
|
||||
self.answer(&pid, result);
|
||||
return "exit 0".into();
|
||||
}
|
||||
let result = self
|
||||
.run_guest(&pid, &info, &component.unwrap(), incoming, has_id)
|
||||
.await;
|
||||
result.unwrap_or_else(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Drain stdout before deciding whether the guest returned an answer.
|
||||
async fn run_guest(
|
||||
&self,
|
||||
pid: &str,
|
||||
info: &ProcessInfo,
|
||||
component: &crate::runtime::Prepared,
|
||||
mut incoming: mpsc::UnboundedReceiver<Value>,
|
||||
has_id: bool,
|
||||
) -> anyhow::Result<String> {
|
||||
let (memory, tracker, store_ended) = {
|
||||
let state = self.0.state.lock().unwrap();
|
||||
let p = state.processes.get(pid).unwrap();
|
||||
(p.memory.clone(), p.files.clone(), p.store_ended.clone())
|
||||
};
|
||||
let config = InstanceConfig {
|
||||
filesystem: self.mounts(Some(pid.into()), memory, tracker).await?,
|
||||
store_ended,
|
||||
network: true,
|
||||
env: vec![
|
||||
("SAUSAGE_PID".into(), pid.into()),
|
||||
("SAUSAGE_PLUGIN".into(), info.plugin.clone()),
|
||||
(
|
||||
"SAUSAGE_PARENT".into(),
|
||||
info.parent.clone().unwrap_or_else(|| "core".into()),
|
||||
),
|
||||
],
|
||||
};
|
||||
let mut instance = self
|
||||
.0
|
||||
.runtime
|
||||
.start_logged(
|
||||
component,
|
||||
config,
|
||||
self.0.journal.clone(),
|
||||
pid.into(),
|
||||
info.plugin.clone(),
|
||||
)
|
||||
.await?;
|
||||
let mut initial = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": info.method,
|
||||
"params": info.params,
|
||||
});
|
||||
if has_id {
|
||||
initial["id"] = json!(pid);
|
||||
}
|
||||
let mut stdin_open = self.forward_input(pid, &mut instance, Some(initial)).await;
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = instance.stdout.recv() => {
|
||||
if !self.stdout_line(pid,line) { break; }
|
||||
}
|
||||
value = incoming.recv(), if stdin_open => {
|
||||
stdin_open = self.forward_input(pid,&mut instance,value).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(instance.wait().await.to_string())
|
||||
}
|
||||
|
||||
/// Close stdin only after the queued responses reach the guest.
|
||||
async fn forward_input(
|
||||
&self,
|
||||
pid: &str,
|
||||
instance: &mut Instance,
|
||||
value: Option<Value>,
|
||||
) -> bool {
|
||||
if let Some(value) = value
|
||||
&& self.write_input(pid, instance, value).await.is_ok()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
instance.stdin.take();
|
||||
false
|
||||
}
|
||||
async fn write_input(
|
||||
&self,
|
||||
pid: &str,
|
||||
instance: &mut Instance,
|
||||
value: Value,
|
||||
) -> std::io::Result<()> {
|
||||
instance
|
||||
.send_line(&value.to_string(), |line| self.output(pid, line))
|
||||
.await
|
||||
}
|
||||
fn stdout_line(&self, pid: &str, line: Option<String>) -> bool {
|
||||
if let Some(line) = line {
|
||||
self.output(pid, line);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
//! Per-process mount tables, reserved self files, and the live process namespace.
|
||||
use crate::Host;
|
||||
use sausage_vfs::{
|
||||
Entry, File, Generated, Layer, Memory, Mounts, Open, Process, Result, Stat, async_trait,
|
||||
};
|
||||
use std::{io, sync::Arc};
|
||||
|
||||
fn denied<T>() -> Result<T> {
|
||||
Err(io::ErrorKind::ReadOnlyFilesystem.into())
|
||||
}
|
||||
fn missing<T>() -> Result<T> {
|
||||
Err(io::ErrorKind::NotFound.into())
|
||||
}
|
||||
fn directory() -> Stat {
|
||||
Stat {
|
||||
directory: true,
|
||||
symlink: false,
|
||||
size: 0,
|
||||
accessed: None,
|
||||
modified: None,
|
||||
}
|
||||
}
|
||||
fn entry(name: String, directory: bool) -> Entry {
|
||||
Entry {
|
||||
name,
|
||||
directory,
|
||||
symlink: false,
|
||||
}
|
||||
}
|
||||
fn reserved(path: &str) -> bool {
|
||||
matches!(
|
||||
path.trim_start_matches('/').split('/').next(),
|
||||
Some("info.json" | "log" | "fs")
|
||||
)
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Create a console filesystem view without adding a process. Its `/self` is private and ephemeral.
|
||||
pub async fn filesystem(&self) -> anyhow::Result<Mounts> {
|
||||
self.mounts(None, Memory::default(), Process::default())
|
||||
.await
|
||||
}
|
||||
pub(crate) async fn mounts(
|
||||
&self,
|
||||
pid: Option<String>,
|
||||
memory: Memory,
|
||||
tracker: Process,
|
||||
) -> anyhow::Result<Mounts> {
|
||||
let root = self.0.world.root.clone();
|
||||
let operations = tracker.clone();
|
||||
let host = tracker
|
||||
.clone()
|
||||
.blocking(move || sausage_vfs::Host::new(root, tracker))
|
||||
.await?;
|
||||
let mut mounts = Mounts::default();
|
||||
mounts.mount(
|
||||
"/",
|
||||
self.0
|
||||
.observer
|
||||
.layer(Arc::new(host), "/", operations.clone()),
|
||||
false,
|
||||
);
|
||||
mounts.mount("/proc", Arc::new(Proc(self.clone())), true);
|
||||
mounts.mount(
|
||||
"/self",
|
||||
self.0.observer.layer(
|
||||
Arc::new(SelfFolder {
|
||||
host: self.clone(),
|
||||
pid: pid.clone(),
|
||||
memory,
|
||||
}),
|
||||
&format!("/proc/{}", pid.as_deref().unwrap_or("console")),
|
||||
operations.clone(),
|
||||
),
|
||||
false,
|
||||
);
|
||||
mounts.mount(
|
||||
"/run",
|
||||
self.0
|
||||
.observer
|
||||
.layer(Arc::new(self.0.run.clone()), "/run", operations),
|
||||
false,
|
||||
);
|
||||
Ok(mounts)
|
||||
}
|
||||
fn core_files(&self) -> Generated {
|
||||
let mut files = Generated::default();
|
||||
let protocol = include_bytes!("../../PROTOCOL.md");
|
||||
files.insert("/PROTOCOL.md", move || Ok(protocol.to_vec()));
|
||||
let graph = self.graph_json();
|
||||
files.insert("/graph.json", move || Ok(graph.clone()));
|
||||
let dot = self.graph_dot();
|
||||
files.insert("/graph.dot", move || Ok(dot.clone()));
|
||||
let locks = {
|
||||
let state = self.0.state.lock().unwrap();
|
||||
let locks: std::collections::BTreeMap<_, _> = state
|
||||
.locks
|
||||
.iter()
|
||||
.map(|(path, lock)| {
|
||||
let waiting: Vec<_> = lock.queue.iter().map(|(pid, _)| pid).collect();
|
||||
let info = serde_json::json!({
|
||||
"owner": lock.owner,
|
||||
"waiting": waiting,
|
||||
});
|
||||
(path.clone(), info)
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_vec(&locks).unwrap()
|
||||
};
|
||||
files.insert("/locks.json", move || Ok(locks.clone()));
|
||||
for name in self.discovered_plugins() {
|
||||
if let Some(doc) = self.discover_document(&name) {
|
||||
let bytes = serde_json::to_vec(&doc).unwrap();
|
||||
files.insert(&format!("/plugins/{name}/discover.json"), move || {
|
||||
Ok(bytes.clone())
|
||||
});
|
||||
files.insert(&format!("/plugins/{name}/client.rs"), move || {
|
||||
sausage_interface::client_rs(&name, &doc)
|
||||
.map(String::into_bytes)
|
||||
.map_err(io::Error::other)
|
||||
});
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
}
|
||||
struct Proc(Host);
|
||||
impl Proc {
|
||||
fn folder(&self, path: &str) -> Result<(SelfFolder, String)> {
|
||||
let (pid, rest) = path
|
||||
.trim_start_matches('/')
|
||||
.split_once('/')
|
||||
.unwrap_or((path.trim_start_matches('/'), ""));
|
||||
let memory = self
|
||||
.0
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get(pid)
|
||||
.map(|p| p.memory.clone())
|
||||
.ok_or(io::ErrorKind::NotFound)?;
|
||||
Ok((
|
||||
SelfFolder {
|
||||
host: self.0.clone(),
|
||||
pid: Some(pid.into()),
|
||||
memory,
|
||||
},
|
||||
format!("/{rest}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl Layer for Proc {
|
||||
async fn stat(&self, path: &str, follow: bool) -> Result<Stat> {
|
||||
let path = path.trim_end_matches('/');
|
||||
let path = if path.is_empty() { "/" } else { path };
|
||||
if matches!(path.trim_end_matches('/'), "" | "/core" | "/core/plugins") {
|
||||
return Ok(directory());
|
||||
}
|
||||
if let Some(path) = path.strip_prefix("/core/") {
|
||||
return self.0.core_files().stat(&format!("/{path}"), follow).await;
|
||||
}
|
||||
let (folder, path) = self.folder(path)?;
|
||||
folder.stat(&path, follow).await
|
||||
}
|
||||
async fn list(&self, path: &str) -> Result<Vec<Entry>> {
|
||||
let path = path.trim_end_matches('/');
|
||||
let path = if path.is_empty() { "/" } else { path };
|
||||
if path == "/" {
|
||||
let mut entries = vec![entry("core".into(), true)];
|
||||
entries.extend(self.0.processes().into_keys().map(|pid| entry(pid, true)));
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
return Ok(entries);
|
||||
}
|
||||
if path.trim_end_matches('/') == "/core/plugins" {
|
||||
return Ok(self
|
||||
.0
|
||||
.discovered_plugins()
|
||||
.into_iter()
|
||||
.filter(|name| self.0.discover_document(name).is_some())
|
||||
.map(|name| entry(name, true))
|
||||
.collect());
|
||||
}
|
||||
if path.trim_end_matches('/') == "/core" {
|
||||
let mut entries = self.0.core_files().list("/").await?;
|
||||
if !entries.iter().any(|e| e.name == "plugins") {
|
||||
entries.push(entry("plugins".into(), true));
|
||||
}
|
||||
return Ok(entries);
|
||||
}
|
||||
if let Some(path) = path.strip_prefix("/core/") {
|
||||
return self.0.core_files().list(&format!("/{path}")).await;
|
||||
}
|
||||
let (folder, path) = self.folder(path)?;
|
||||
folder.list(&path).await
|
||||
}
|
||||
async fn open(&self, path: &str, options: Open) -> Result<Arc<dyn File>> {
|
||||
let path = path.trim_end_matches('/');
|
||||
let path = if path.is_empty() { "/" } else { path };
|
||||
if options.write || options.create || options.truncate {
|
||||
return denied();
|
||||
}
|
||||
if matches!(path.trim_end_matches('/'), "" | "/core" | "/core/plugins") {
|
||||
return Memory::default().open("/", options).await;
|
||||
}
|
||||
if let Some(path) = path.strip_prefix("/core/") {
|
||||
return self.0.core_files().open(&format!("/{path}"), options).await;
|
||||
}
|
||||
let (folder, path) = self.folder(path)?;
|
||||
folder.open(&path, options).await
|
||||
}
|
||||
async fn mkdir(&self, _: &str) -> Result<()> {
|
||||
denied()
|
||||
}
|
||||
async fn remove_file(&self, _: &str) -> Result<()> {
|
||||
denied()
|
||||
}
|
||||
async fn remove_dir(&self, _: &str) -> Result<()> {
|
||||
denied()
|
||||
}
|
||||
async fn rename(&self, _: &str, _: &str) -> Result<()> {
|
||||
denied()
|
||||
}
|
||||
}
|
||||
struct SelfFolder {
|
||||
host: Host,
|
||||
pid: Option<String>,
|
||||
memory: Memory,
|
||||
}
|
||||
impl SelfFolder {
|
||||
async fn generated(&self, path: &str) -> Result<Generated> {
|
||||
let pid = self.pid.as_ref().ok_or(io::ErrorKind::NotFound)?;
|
||||
let bytes = match path {
|
||||
"/info.json" => {
|
||||
let info = self
|
||||
.host
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get(pid)
|
||||
.map(|p| p.info.clone())
|
||||
.ok_or(io::ErrorKind::NotFound)?;
|
||||
serde_json::to_vec(&info).unwrap()
|
||||
}
|
||||
"/log" => {
|
||||
let lines = self
|
||||
.host
|
||||
.0
|
||||
.journal
|
||||
.query(serde_json::json!({"pid":pid,"limit":i64::MAX}))
|
||||
.await;
|
||||
let mut bytes = Vec::new();
|
||||
for line in lines.as_array().unwrap() {
|
||||
serde_json::to_writer(&mut bytes, line).unwrap();
|
||||
bytes.push(b'\n');
|
||||
}
|
||||
bytes
|
||||
}
|
||||
_ => return missing(),
|
||||
};
|
||||
let mut files = Generated::default();
|
||||
files.insert(path, move || Ok(bytes.clone()));
|
||||
Ok(files)
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl Layer for SelfFolder {
|
||||
async fn stat(&self, path: &str, follow: bool) -> Result<Stat> {
|
||||
if reserved(path) {
|
||||
self.generated(path).await?.stat(path, follow).await
|
||||
} else {
|
||||
self.memory.stat(path, follow).await
|
||||
}
|
||||
}
|
||||
async fn list(&self, path: &str) -> Result<Vec<Entry>> {
|
||||
let mut entries = self.memory.list(path).await?;
|
||||
if path == "/" && self.pid.is_some() {
|
||||
entries.extend([entry("info.json".into(), false), entry("log".into(), false)]);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
async fn open(&self, path: &str, options: Open) -> Result<Arc<dyn File>> {
|
||||
if reserved(path) {
|
||||
if options.write || options.create || options.truncate {
|
||||
return denied();
|
||||
}
|
||||
self.generated(path).await?.open(path, options).await
|
||||
} else {
|
||||
self.memory.open(path, options).await
|
||||
}
|
||||
}
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
if reserved(path) {
|
||||
denied()
|
||||
} else {
|
||||
self.memory.mkdir(path).await
|
||||
}
|
||||
}
|
||||
async fn remove_file(&self, path: &str) -> Result<()> {
|
||||
if reserved(path) {
|
||||
denied()
|
||||
} else {
|
||||
self.memory.remove_file(path).await
|
||||
}
|
||||
}
|
||||
async fn remove_dir(&self, path: &str) -> Result<()> {
|
||||
if reserved(path) || path == "/" {
|
||||
denied()
|
||||
} else {
|
||||
self.memory.remove_dir(path).await
|
||||
}
|
||||
}
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
if reserved(from) || reserved(to) || from == "/" || to == "/" {
|
||||
denied()
|
||||
} else {
|
||||
self.memory.rename(from, to).await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
//! Cached discovery documents and import checks under PROTOCOL.md section 10.4.
|
||||
//! Refreshes use immutable copies. The proc layer only reads serialized cache entries.
|
||||
use crate::{Host, RpcResult};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Interface {
|
||||
pub(crate) hash: String,
|
||||
pub(crate) document: Value,
|
||||
pub(crate) error: Option<Value>,
|
||||
}
|
||||
pub(crate) struct Graph {
|
||||
pub(crate) plugins: BTreeMap<String, Interface>,
|
||||
json: Vec<u8>,
|
||||
dot: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Default for Graph {
|
||||
/// Exposes a valid empty graph before background discovery completes.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
plugins: BTreeMap::new(),
|
||||
json: b"{\"plugins\":{}}".to_vec(),
|
||||
dot: b"digraph plugins {}\n".to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hashes exactly the bytes copied for discovery or installation.
|
||||
pub(crate) fn hash(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
/// Reads and hashes one immutable snapshot without blocking an async worker.
|
||||
pub(crate) async fn read_hashed(path: PathBuf) -> std::io::Result<(Vec<u8>, String)> {
|
||||
tokio::task::spawn_blocking(move || -> std::io::Result<_> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let hash = hash(&bytes);
|
||||
Ok((bytes, hash))
|
||||
})
|
||||
.await
|
||||
.map_err(std::io::Error::other)?
|
||||
}
|
||||
|
||||
/// Lists broken imports, optionally restricted to changed consumers or providers.
|
||||
pub(crate) fn problems(
|
||||
plugins: &BTreeMap<String, Interface>,
|
||||
changed: Option<&[String]>,
|
||||
) -> Vec<Value> {
|
||||
let mut failures = Vec::new();
|
||||
for (consumer, interface) in plugins {
|
||||
if let Some(imports) = interface.document["x-sausage-imports"].as_object() {
|
||||
for (provider, expected) in imports {
|
||||
if changed
|
||||
.is_some_and(|names| !names.contains(consumer) && !names.contains(provider))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(actual) = plugins.get(provider) else {
|
||||
failures.push(json!({"consumer":consumer,"provider":provider,"status":"missing","message":"the provider does not exist"}));
|
||||
continue;
|
||||
};
|
||||
if let Some(error) = &actual.error {
|
||||
failures.push(json!({"consumer":consumer,"provider":provider,"status":"unavailable","message":"discovery failed","error":error}));
|
||||
continue;
|
||||
}
|
||||
for problem in sausage_interface::compat::check(expected, &actual.document) {
|
||||
failures.push(json!({"consumer":consumer,"provider":provider,"status":"broken","method":problem.method,"message":problem.message,"counterexample":problem.counterexample,"display":problem.to_string()}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// The cached JSON graph. This does not perform filesystem or RPC operations.
|
||||
pub(crate) fn graph_json(&self) -> Vec<u8> {
|
||||
self.0.graph.lock().unwrap().json.clone()
|
||||
}
|
||||
/// The cached DOT graph, with an edge for every import.
|
||||
pub(crate) fn graph_dot(&self) -> Vec<u8> {
|
||||
self.0.graph.lock().unwrap().dot.clone()
|
||||
}
|
||||
/// Plugin names currently present in the discovery cache.
|
||||
pub(crate) fn discovered_plugins(&self) -> Vec<String> {
|
||||
self.0
|
||||
.graph
|
||||
.lock()
|
||||
.unwrap()
|
||||
.plugins
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
/// The current document, if discovery succeeded.
|
||||
pub(crate) fn discover_document(&self, plugin: &str) -> Option<Value> {
|
||||
self.0
|
||||
.graph
|
||||
.lock()
|
||||
.unwrap()
|
||||
.plugins
|
||||
.get(plugin)
|
||||
.filter(|entry| entry.error.is_none())
|
||||
.map(|entry| entry.document.clone())
|
||||
}
|
||||
/// Discovers a private copy and caches successful documents by file hash.
|
||||
pub(crate) async fn discover_copy(
|
||||
&self,
|
||||
plugin: &str,
|
||||
path: &Path,
|
||||
hash: String,
|
||||
) -> Result<Interface, Value> {
|
||||
let request = json!(uuid::Uuid::now_v7().to_string());
|
||||
let discovery = self.route_from(
|
||||
"core".into(),
|
||||
Some(request.clone()),
|
||||
format!("{plugin}.rpc.discover"),
|
||||
json!({}),
|
||||
true,
|
||||
Some(path.to_path_buf()),
|
||||
);
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(10), discovery).await;
|
||||
let processes: Vec<_> = self
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.iter()
|
||||
.filter(|(_, process)| process.id.as_ref() == Some(&request))
|
||||
.map(|(pid, _)| pid.clone())
|
||||
.collect();
|
||||
for pid in processes {
|
||||
self.begin_stop(&pid);
|
||||
}
|
||||
let document = result
|
||||
.map_err(|_| crate::error(-32003, "plugin discovery timed out after 10 seconds"))??;
|
||||
if !document["methods"].is_array() {
|
||||
return Err(crate::error(
|
||||
-32003,
|
||||
"rpc.discover did not return an OpenRPC document",
|
||||
));
|
||||
}
|
||||
Ok(Interface {
|
||||
hash,
|
||||
document,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
/// Refreshes every installed plugin while the caller holds the activation mutex.
|
||||
pub(crate) async fn refresh_graph(&self) -> RpcResult {
|
||||
self.refresh_graph_except(&[]).await
|
||||
}
|
||||
pub(crate) async fn refresh_graph_except(&self, candidates: &[String]) -> RpcResult {
|
||||
let mut directory = tokio::fs::read_dir(self.0.world.root.join("plugins"))
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
let mut plugins = BTreeMap::new();
|
||||
while let Some(entry) = directory.next_entry().await.map_err(io_error)? {
|
||||
if !entry.file_type().await.map_err(io_error)?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let source = entry.path().join("plugin.wasm");
|
||||
let (bytes, hash) = match read_hashed(source).await {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => {
|
||||
plugins.insert(
|
||||
name,
|
||||
Interface {
|
||||
hash: String::new(),
|
||||
document: Value::Null,
|
||||
error: Some(io_error(error)),
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let previous = self.0.graph.lock().unwrap().plugins.get(&name).cloned();
|
||||
let interface = if candidates.contains(&name) {
|
||||
previous
|
||||
.filter(|entry| entry.hash == hash)
|
||||
.unwrap_or(Interface {
|
||||
hash,
|
||||
document: Value::Null,
|
||||
error: None,
|
||||
})
|
||||
} else if let Some(previous) =
|
||||
previous.filter(|entry| entry.hash == hash && entry.error.is_none())
|
||||
{
|
||||
previous
|
||||
} else {
|
||||
let copy = entry
|
||||
.path()
|
||||
.join(format!("discover-{}.wasm", uuid::Uuid::now_v7()));
|
||||
let result = match tokio::fs::write(©, &bytes).await {
|
||||
Ok(()) => self.discover_copy(&name, ©, hash.clone()).await,
|
||||
Err(error) => Err(io_error(error)),
|
||||
};
|
||||
let _ = tokio::fs::remove_file(©).await;
|
||||
match result {
|
||||
Ok(interface) => interface,
|
||||
Err(error) => Interface {
|
||||
hash,
|
||||
document: Value::Null,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
};
|
||||
plugins.insert(name, interface);
|
||||
}
|
||||
let admission = self.0.admission.write().await;
|
||||
let old_plugins = self.0.graph.lock().unwrap().plugins.clone();
|
||||
let mut upgrades = Vec::new();
|
||||
for (plugin, current) in &plugins {
|
||||
if let Some(old) = old_plugins.get(plugin).filter(|old| {
|
||||
!old.hash.is_empty() && old.hash != current.hash && current.error.is_none()
|
||||
}) {
|
||||
upgrades.push(self.snapshot_upgrade(plugin, old.hash.clone(), ¤t.hash));
|
||||
}
|
||||
}
|
||||
self.publish_graph(plugins);
|
||||
drop(admission);
|
||||
for upgrade in upgrades {
|
||||
self.begin_upgrade(upgrade);
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
/// Rebuilds serialized snapshots after a watcher refresh or an activation.
|
||||
pub(crate) fn publish_graph(&self, plugins: BTreeMap<String, Interface>) {
|
||||
let failures = problems(&plugins, None);
|
||||
for failure in &failures {
|
||||
self.0
|
||||
.journal
|
||||
.write("core", "core", "core", &format!("broken import {failure}"));
|
||||
}
|
||||
let mut entries = serde_json::Map::new();
|
||||
let mut dot = String::from("digraph plugins {\n");
|
||||
for (name, interface) in &plugins {
|
||||
dot.push_str(&format!(" {};\n", json!(name)));
|
||||
let mut imports = serde_json::Map::new();
|
||||
for provider in interface.document["x-sausage-imports"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flat_map(|imports| imports.keys())
|
||||
{
|
||||
let issues: Vec<_> = failures
|
||||
.iter()
|
||||
.filter(|problem| {
|
||||
problem["consumer"] == *name && problem["provider"] == *provider
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let status = issues
|
||||
.first()
|
||||
.and_then(|problem| problem["status"].as_str())
|
||||
.unwrap_or("compatible");
|
||||
imports.insert(provider.clone(), json!({"status":status,"problems":issues}));
|
||||
dot.push_str(&format!(
|
||||
" {} -> {} [label={}];\n",
|
||||
json!(name),
|
||||
json!(provider),
|
||||
json!(status)
|
||||
));
|
||||
}
|
||||
entries.insert(
|
||||
name.clone(),
|
||||
json!({"hash":interface.hash,"imports":imports,"error":interface.error}),
|
||||
);
|
||||
}
|
||||
dot.push_str("}\n");
|
||||
let mut graph = self.0.graph.lock().unwrap();
|
||||
graph.json = serde_json::to_vec(&json!({"plugins":entries})).unwrap();
|
||||
graph.dot = dot.into_bytes();
|
||||
graph.plugins = plugins;
|
||||
}
|
||||
}
|
||||
/// Converts a filesystem failure into a JSON-RPC internal error.
|
||||
pub(crate) fn io_error(error: std::io::Error) -> Value {
|
||||
crate::error(-32603, &error.to_string())
|
||||
}
|
||||
-170
@@ -1,170 +0,0 @@
|
||||
//! Host startup, boot calls, and the public library API.
|
||||
use crate::{RpcResult, components, journal, runtime::Runtime, world::World};
|
||||
use crate::{
|
||||
locks::Lock,
|
||||
native::NativePlugin,
|
||||
process::{Process, ProcessInfo},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use tokio::sync::watch;
|
||||
use uuid::Uuid;
|
||||
pub(crate) struct State {
|
||||
pub(crate) processes: HashMap<String, Process>,
|
||||
pub(crate) locks: HashMap<String, Lock>,
|
||||
pub(crate) native: HashMap<String, NativePlugin>,
|
||||
pub(crate) services: Vec<crate::services::Service>,
|
||||
}
|
||||
pub(crate) struct Inner {
|
||||
pub(crate) world: World,
|
||||
pub(crate) runtime: Arc<Runtime>,
|
||||
pub(crate) state: Mutex<State>,
|
||||
pub(crate) journal: Arc<journal::Journal>,
|
||||
pub(crate) notifications: tokio::sync::broadcast::Sender<Value>,
|
||||
pub(crate) exit: watch::Sender<bool>,
|
||||
pub(crate) loop_supervised: bool,
|
||||
pub(crate) restart_pending: std::sync::atomic::AtomicBool,
|
||||
pub(crate) components: components::Components,
|
||||
/// Calls hold it for reading from the choice of a version until their
|
||||
/// process is in the table. Activation holds it for writing while it
|
||||
/// installs a version, so that it sees every process of the old version.
|
||||
pub(crate) admission: tokio::sync::RwLock<()>,
|
||||
/// Set when the host starts to stop. After that, the core starts no new
|
||||
/// processes, and upgrade tasks do nothing.
|
||||
pub(crate) shutdown: std::sync::atomic::AtomicBool,
|
||||
pub(crate) run: sausage_vfs::Memory,
|
||||
pub(crate) watches: crate::watches::Watches,
|
||||
pub(crate) observer: sausage_vfs::observe::Observer,
|
||||
pub(crate) graph: Mutex<crate::graph::Graph>,
|
||||
pub(crate) activations: tokio::sync::Mutex<()>,
|
||||
pub(crate) watcher: Mutex<Option<notify::RecommendedWatcher>>,
|
||||
pub(crate) retirements: Mutex<std::collections::HashSet<(String, String)>>,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
/// A cloneable handle to a world and its running processes.
|
||||
pub struct Host(pub(crate) Arc<Inner>);
|
||||
|
||||
impl Host {
|
||||
/// Start a world with the default compile cache and its ordered boot calls.
|
||||
pub async fn start(path: &Path) -> anyhow::Result<Self> {
|
||||
let base = std::env::var_os("XDG_CACHE_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::PathBuf::from(std::env::var_os("HOME").unwrap_or_default())
|
||||
.join(".cache")
|
||||
});
|
||||
Self::start_with_cache(path, &base.join("sausage")).await
|
||||
}
|
||||
/// Start a world with an explicit compile cache folder.
|
||||
pub async fn start_with_cache(path: &Path, cache: &Path) -> anyhow::Result<Self> {
|
||||
let path = path.to_path_buf();
|
||||
let cargo_cache = cache.to_path_buf();
|
||||
let cache = cache.to_path_buf();
|
||||
let (world, runtime) = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
|
||||
Ok((World::open(&path)?, Runtime::new(&cache)?))
|
||||
})
|
||||
.await??;
|
||||
let journal = journal::Journal::open_with_legacy(
|
||||
world.root.join("logs/core/journal.sqlite"),
|
||||
Some(world.state_dir("core").join("journal.sqlite")),
|
||||
)
|
||||
.await?;
|
||||
let host = Self::with_parts(world, runtime, journal);
|
||||
let cargo_world = host.0.world.clone();
|
||||
let cargo = tokio::task::spawn_blocking(move || {
|
||||
crate::cargo::Cargo::new(cargo_world, &cargo_cache)
|
||||
})
|
||||
.await??;
|
||||
cargo.register(&host);
|
||||
host.boot().await?;
|
||||
Ok(host)
|
||||
}
|
||||
/// Creates an empty test world with a shared engine and an in-memory journal.
|
||||
pub async fn test_world(path: &Path, runtime: Arc<Runtime>) -> anyhow::Result<Self> {
|
||||
std::fs::create_dir_all(path)?;
|
||||
Ok(Self::with_parts(
|
||||
World::open(path)?,
|
||||
runtime,
|
||||
journal::Journal::memory().await?,
|
||||
))
|
||||
}
|
||||
fn with_parts(world: World, runtime: Arc<Runtime>, journal: journal::Journal) -> Self {
|
||||
let watches = crate::watches::Watches::default();
|
||||
let events = watches.clone();
|
||||
let observer =
|
||||
sausage_vfs::observe::Observer::new(move |path| crate::watches::changed(&events, path));
|
||||
Self(Arc::new(Inner {
|
||||
world,
|
||||
runtime,
|
||||
journal: Arc::new(journal),
|
||||
state: Mutex::new(State {
|
||||
processes: HashMap::new(),
|
||||
locks: HashMap::new(),
|
||||
native: HashMap::new(),
|
||||
services: Vec::new(),
|
||||
}),
|
||||
notifications: tokio::sync::broadcast::channel(1024).0,
|
||||
exit: watch::channel(false).0,
|
||||
loop_supervised: crate::restart::enabled(std::env::var_os("SAUSAGE_LOOP").as_deref()),
|
||||
restart_pending: std::sync::atomic::AtomicBool::new(false),
|
||||
components: components::Components::default(),
|
||||
admission: tokio::sync::RwLock::new(()),
|
||||
shutdown: std::sync::atomic::AtomicBool::new(false),
|
||||
run: sausage_vfs::Memory::default(),
|
||||
watches,
|
||||
observer,
|
||||
graph: Mutex::new(crate::graph::Graph::default()),
|
||||
activations: tokio::sync::Mutex::new(()),
|
||||
watcher: Mutex::new(None),
|
||||
retirements: Mutex::new(std::collections::HashSet::new()),
|
||||
}))
|
||||
}
|
||||
/// Register a native plugin for subsequent calls.
|
||||
pub fn register_native(&self, name: &str, plugin: NativePlugin) {
|
||||
self.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.native
|
||||
.insert(name.into(), plugin);
|
||||
}
|
||||
/// Subscribe to notifications addressed to the console.
|
||||
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<Value> {
|
||||
self.0.notifications.subscribe()
|
||||
}
|
||||
/// Snapshot the metadata of each process that has not ended.
|
||||
pub fn processes(&self) -> HashMap<String, ProcessInfo> {
|
||||
self.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.iter()
|
||||
.map(|(p, s)| (p.clone(), s.info.clone()))
|
||||
.collect()
|
||||
}
|
||||
/// Make a console-side call and wait for its JSON result.
|
||||
pub async fn call(&self, method: &str, params: Value) -> RpcResult {
|
||||
self.route(
|
||||
"console".into(),
|
||||
Some(json!(Uuid::now_v7().to_string())),
|
||||
method.into(),
|
||||
params,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
/// Wait until core.restart (or its alias core.exit) requests shutdown after its response grace.
|
||||
pub async fn exit_requested(&self) {
|
||||
let mut exit = self.0.exit.subscribe();
|
||||
let _ = exit.wait_for(|requested| *requested).await;
|
||||
}
|
||||
/// Return whether a restart has reached the normal shutdown path.
|
||||
pub fn restart_requested(&self) -> bool {
|
||||
*self.0.exit.borrow()
|
||||
}
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
//! A dedicated SQLite writer batches entries and serves ordered queries without propagating disk failures into the host.
|
||||
use rusqlite::{Connection, OpenFlags, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::Write;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::PathBuf,
|
||||
sync::{Mutex, mpsc},
|
||||
};
|
||||
use tokio::sync::oneshot;
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Entry {
|
||||
time: String,
|
||||
id: String,
|
||||
pid: String,
|
||||
plugin: String,
|
||||
stream: String,
|
||||
line: String,
|
||||
}
|
||||
enum Command {
|
||||
Write(Entry),
|
||||
Query(Value, oneshot::Sender<Value>),
|
||||
}
|
||||
pub(crate) struct Journal {
|
||||
sender: mpsc::Sender<Command>,
|
||||
warned: Mutex<HashSet<String>>,
|
||||
spool: PathBuf,
|
||||
}
|
||||
impl Journal {
|
||||
pub(crate) async fn memory() -> anyhow::Result<Self> {
|
||||
Self::open(PathBuf::from(":memory:")).await
|
||||
}
|
||||
pub(crate) async fn open(path: PathBuf) -> anyhow::Result<Self> {
|
||||
Self::open_with_legacy(path, None).await
|
||||
}
|
||||
pub(crate) async fn open_with_legacy(
|
||||
path: PathBuf,
|
||||
legacy: Option<PathBuf>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let spool = path
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."))
|
||||
.join("failed-batches");
|
||||
let worker_spool = spool.clone();
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let (ready, initialized) = oneshot::channel();
|
||||
std::thread::spawn(move || {
|
||||
match (|| -> anyhow::Result<_> {
|
||||
let db = open_database(path)?;
|
||||
// Never rename, checkpoint, or write the old SQLite/WAL files.
|
||||
let old = legacy
|
||||
.filter(|p| p.exists())
|
||||
.map(|p| Connection::open_with_flags(p, OpenFlags::SQLITE_OPEN_READ_ONLY))
|
||||
.transpose()?;
|
||||
Ok((db, old))
|
||||
})() {
|
||||
Ok((mut db, old)) => {
|
||||
let _ = ready.send(Ok(()));
|
||||
let mut pending = Vec::new();
|
||||
while let Ok(first) = receiver.recv() {
|
||||
let mut batch = vec![first];
|
||||
batch.extend(receiver.try_iter().take(255));
|
||||
pending = write_batch(&mut db, old.as_ref(), &worker_spool, pending, batch);
|
||||
}
|
||||
if !pending.is_empty() {
|
||||
// Both storage locations failed. Last resort: retain full text in host stderr.
|
||||
eprintln!(
|
||||
"journal shutdown with unpersisted entries: {}",
|
||||
serde_json::to_string(&pending).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("journal open failed: {error}");
|
||||
let _ = ready.send(Err(error));
|
||||
}
|
||||
}
|
||||
});
|
||||
initialized.await??;
|
||||
Ok(Self {
|
||||
sender,
|
||||
warned: Mutex::new(HashSet::new()),
|
||||
spool,
|
||||
})
|
||||
}
|
||||
pub(crate) fn write(&self, pid: &str, plugin: &str, stream: &str, line: &str) {
|
||||
let entry = Entry {
|
||||
time: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||
id: uuid::Uuid::now_v7().to_string(),
|
||||
pid: pid.into(),
|
||||
plugin: plugin.into(),
|
||||
stream: stream.into(),
|
||||
line: line.into(),
|
||||
};
|
||||
if let Err(mpsc::SendError(Command::Write(entry))) = self.sender.send(Command::Write(entry))
|
||||
{
|
||||
eprintln!("journal writer stopped; saving entry outside SQLite");
|
||||
if let Err(error) = spool_batch(&self.spool, std::slice::from_ref(&entry)) {
|
||||
eprintln!(
|
||||
"journal spool failed: {error}; unpersisted entry: {}",
|
||||
serde_json::to_string(&entry).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn warning(&self, pid: &str, plugin: &str) {
|
||||
if self.warned.lock().unwrap().insert(pid.into()) {
|
||||
self.write(pid, plugin, "core", "warning: discarded stdout");
|
||||
}
|
||||
}
|
||||
pub(crate) fn forget(&self, pid: &str) {
|
||||
self.warned.lock().unwrap().remove(pid);
|
||||
}
|
||||
pub(crate) fn query_later(&self, filters: Value) -> oneshot::Receiver<Value> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
if self.sender.send(Command::Query(filters, tx)).is_err() {
|
||||
eprintln!("journal writer stopped");
|
||||
}
|
||||
rx
|
||||
}
|
||||
pub(crate) async fn query(&self, filters: Value) -> Value {
|
||||
self.query_later(filters)
|
||||
.await
|
||||
.unwrap_or_else(|_| json!([]))
|
||||
}
|
||||
}
|
||||
fn open_database(path: PathBuf) -> anyhow::Result<Connection> {
|
||||
if path != std::path::Path::new(":memory:") {
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
}
|
||||
let db = Connection::open(path)?;
|
||||
db.execute_batch("PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
CREATE TABLE IF NOT EXISTS journal (id TEXT PRIMARY KEY, time TEXT, pid TEXT, plugin TEXT, stream TEXT, line TEXT);
|
||||
CREATE INDEX IF NOT EXISTS journal_pid ON journal(pid);
|
||||
CREATE INDEX IF NOT EXISTS journal_plugin ON journal(plugin);")?;
|
||||
Ok(db)
|
||||
}
|
||||
/// Exclusive, synced JSON files preserve failed transactions without a busy retry loop.
|
||||
/// Recovery is deliberate: files are never automatically removed or replayed.
|
||||
fn spool_batch(dir: &std::path::Path, entries: &[Entry]) -> anyhow::Result<PathBuf> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let path = dir.join(format!("{}.json", uuid::Uuid::now_v7()));
|
||||
// A failed/partial write must not look like a complete recovery batch.
|
||||
let mut file = tempfile::NamedTempFile::new_in(dir)?;
|
||||
serde_json::to_writer(&mut file, entries)?;
|
||||
file.write_all(b"\n")?;
|
||||
file.as_file().sync_all()?;
|
||||
file.persist_noclobber(&path)?;
|
||||
std::fs::File::open(dir)?.sync_all()?;
|
||||
eprintln!(
|
||||
"journal failed batch preserved: {} ({} entries)",
|
||||
path.display(),
|
||||
entries.len()
|
||||
);
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn write_batch(
|
||||
db: &mut Connection,
|
||||
old: Option<&Connection>,
|
||||
spool: &std::path::Path,
|
||||
mut pending: Vec<Entry>,
|
||||
batch: Vec<Command>,
|
||||
) -> Vec<Entry> {
|
||||
let mut queries = Vec::new();
|
||||
for command in batch {
|
||||
match command {
|
||||
Command::Write(entry) => pending.push(entry),
|
||||
Command::Query(filters, response) => queries.push((filters, response)),
|
||||
}
|
||||
}
|
||||
if !pending.is_empty() {
|
||||
let write = (|| -> rusqlite::Result<()> {
|
||||
let transaction = db.transaction()?;
|
||||
for e in &pending {
|
||||
transaction.execute("INSERT OR IGNORE INTO journal (id,time,pid,plugin,stream,line) VALUES (?1,?2,?3,?4,?5,?6)",params![e.id,e.time,e.pid,e.plugin,e.stream,e.line])?;
|
||||
}
|
||||
transaction.commit()
|
||||
})();
|
||||
match write {
|
||||
Ok(()) => pending.clear(),
|
||||
Err(error) => {
|
||||
eprintln!("journal write failed: {error}");
|
||||
match spool_batch(spool, &pending) {
|
||||
Ok(_) => pending.clear(),
|
||||
Err(error) => eprintln!(
|
||||
"journal spool failed: {error}; retaining {} entries in memory until next command",
|
||||
pending.len()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (filters, response) in queries {
|
||||
let value = query_all(db, old, &filters).unwrap_or_else(|error| {
|
||||
eprintln!("journal query failed: {error}");
|
||||
json!([])
|
||||
});
|
||||
let _ = response.send(value);
|
||||
}
|
||||
pending
|
||||
}
|
||||
|
||||
fn query_all(
|
||||
db: &Connection,
|
||||
old: Option<&Connection>,
|
||||
filters: &Value,
|
||||
) -> rusqlite::Result<Value> {
|
||||
let mut entries = query_database(db, filters)?.as_array().unwrap().clone();
|
||||
if let Some(old) = old {
|
||||
entries.extend(
|
||||
query_database(old, filters)?
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.cloned(),
|
||||
);
|
||||
entries.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
|
||||
entries.dedup_by(|a, b| a["id"] == b["id"]);
|
||||
let limit = filters["limit"].as_i64().unwrap_or(100);
|
||||
if limit >= 0 {
|
||||
entries.drain(..entries.len().saturating_sub(limit as usize));
|
||||
}
|
||||
}
|
||||
Ok(json!(entries))
|
||||
}
|
||||
fn query_database(db: &Connection, filters: &Value) -> rusqlite::Result<Value> {
|
||||
let mut statement = db.prepare(
|
||||
"SELECT id,time,pid,plugin,stream,line FROM journal
|
||||
WHERE (?1 IS NULL OR plugin=?1) AND (?2 IS NULL OR pid=?2)
|
||||
AND (?3 IS NULL OR id>=?3) AND (?4 IS NULL OR id<=?4)
|
||||
AND (?5 IS NULL OR instr(line,?5)>0) ORDER BY id DESC LIMIT ?6",
|
||||
)?;
|
||||
let rows=statement.query_map(params![filters["plugin"].as_str(),filters["pid"].as_str(),filters["since"].as_str(),filters["until"].as_str(),filters["grep"].as_str(),filters["limit"].as_i64().unwrap_or(100)],|row| Ok(json!({
|
||||
"id":row.get::<_,String>(0)?,"time":row.get::<_,String>(1)?,"pid":row.get::<_,String>(2)?,"plugin":row.get::<_,String>(3)?,"stream":row.get::<_,String>(4)?,"line":row.get::<_,String>(5)?,
|
||||
})))?;
|
||||
let mut entries: Vec<_> = rows.collect::<rusqlite::Result<_>>()?;
|
||||
entries.reverse();
|
||||
Ok(json!(entries))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn entry(line: &str) -> Entry {
|
||||
Entry {
|
||||
id: uuid::Uuid::now_v7().to_string(),
|
||||
time: "2026-10-26T12:00:00.000Z".into(),
|
||||
pid: "pid".into(),
|
||||
plugin: "test".into(),
|
||||
stream: "core".into(),
|
||||
line: line.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_transaction_is_spooled_in_full_and_queries_finish() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut db = open_database(PathBuf::from(":memory:")).unwrap();
|
||||
db.execute_batch("PRAGMA query_only=ON").unwrap();
|
||||
let original = "😀 full failure\n".repeat(6000);
|
||||
let (tx, mut rx) = oneshot::channel();
|
||||
let pending = write_batch(
|
||||
&mut db,
|
||||
None,
|
||||
dir.path(),
|
||||
vec![],
|
||||
vec![
|
||||
Command::Write(entry(&original)),
|
||||
Command::Query(json!({}), tx),
|
||||
],
|
||||
);
|
||||
assert!(pending.is_empty());
|
||||
assert_eq!(rx.try_recv().unwrap(), json!([]));
|
||||
let path = std::fs::read_dir(dir.path())
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.path();
|
||||
let saved: Vec<Entry> = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
assert_eq!(saved.len(), 1);
|
||||
assert_eq!(saved[0].line, original);
|
||||
assert_eq!(saved[0].time, "2026-10-26T12:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_spool_retries_on_next_command_without_duplicates() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let blocked = dir.path().join("blocked");
|
||||
std::fs::write(&blocked, "not a directory").unwrap();
|
||||
let mut db = open_database(PathBuf::from(":memory:")).unwrap();
|
||||
db.execute_batch("PRAGMA query_only=ON").unwrap();
|
||||
let pending = write_batch(
|
||||
&mut db,
|
||||
None,
|
||||
&blocked,
|
||||
vec![],
|
||||
vec![Command::Write(entry("retained"))],
|
||||
);
|
||||
assert_eq!(pending.len(), 1);
|
||||
db.execute_batch("PRAGMA query_only=OFF").unwrap();
|
||||
let (tx, mut rx) = oneshot::channel();
|
||||
let pending = write_batch(
|
||||
&mut db,
|
||||
None,
|
||||
&blocked,
|
||||
pending,
|
||||
vec![Command::Query(json!({}), tx)],
|
||||
);
|
||||
assert!(pending.is_empty());
|
||||
assert_eq!(rx.try_recv().unwrap()[0]["line"], "retained");
|
||||
assert_eq!(
|
||||
query_all(&db, None, &json!({}))
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_wal_and_new_journal_share_filters_order_and_limit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let legacy = dir.path().join("state/core/journal.sqlite");
|
||||
let mut old = open_database(legacy.clone()).unwrap();
|
||||
write_batch(
|
||||
&mut old,
|
||||
None,
|
||||
dir.path(),
|
||||
vec![],
|
||||
vec![Command::Write(entry("old match"))],
|
||||
);
|
||||
// Leave the WAL connection open: opening the new journal must not move its files.
|
||||
let wal = legacy.with_extension("sqlite-wal");
|
||||
let before = std::fs::read(&wal).unwrap();
|
||||
let new = dir.path().join("logs/core/journal.sqlite");
|
||||
let journal = Journal::open_with_legacy(new.clone(), Some(legacy.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
journal.write("pid", "test", "core", "new match");
|
||||
let entries = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
journal.query(json!({"pid":"pid","grep":"match","limit":-1})),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(entries.as_array().unwrap().len(), 2);
|
||||
assert_eq!(entries[0]["line"], "old match");
|
||||
assert_eq!(entries[1]["line"], "new match");
|
||||
assert_eq!(
|
||||
journal.query(json!({"limit":1})).await[0]["line"],
|
||||
"new match"
|
||||
);
|
||||
assert_eq!(
|
||||
journal.query(json!({"until":entries[0]["id"]})).await[0]["line"],
|
||||
"old match"
|
||||
);
|
||||
assert_eq!(
|
||||
journal
|
||||
.query(json!({"since":entries[1]["id"],"plugin":"test"}))
|
||||
.await[0]["line"],
|
||||
"new match"
|
||||
);
|
||||
assert_eq!(journal.query(json!({"grep":"absent"})).await, json!([]));
|
||||
assert_eq!(std::fs::read(wal).unwrap(), before);
|
||||
assert!(legacy.exists() && new.exists());
|
||||
assert_eq!(
|
||||
query_database(&old, &json!({}))
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// PROTOCOL 7: completed processes do not leave warning identities in memory.
|
||||
#[tokio::test]
|
||||
async fn completion_forgets_warning_identity() {
|
||||
let folder = std::env::temp_dir().join(format!("sausage-warning-{}", uuid::Uuid::now_v7()));
|
||||
crate::world::World::create(&folder).unwrap();
|
||||
let host = crate::Host::start_with_cache(&folder, &folder.join("cache"))
|
||||
.await
|
||||
.unwrap();
|
||||
host.register_native(
|
||||
"warn",
|
||||
std::sync::Arc::new(|ctx, _, _| {
|
||||
Box::pin(async move {
|
||||
ctx.host.output(&ctx.pid, "not JSON".into());
|
||||
Ok(json!(ctx.pid))
|
||||
})
|
||||
}),
|
||||
);
|
||||
let pid = host.call("warn.once", json!({})).await.unwrap();
|
||||
host.call("core.stop", json!({"pid":pid})).await.unwrap();
|
||||
let retained = host
|
||||
.0
|
||||
.journal
|
||||
.warned
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(pid.as_str().unwrap());
|
||||
host.stop().await;
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
assert!(!retained);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
//! A host for worlds of JSON-RPC plugins.
|
||||
mod activation;
|
||||
pub mod cargo;
|
||||
mod components;
|
||||
pub mod console;
|
||||
mod core;
|
||||
mod execution;
|
||||
mod filesystem;
|
||||
mod graph;
|
||||
mod host;
|
||||
mod journal;
|
||||
mod locks;
|
||||
mod native;
|
||||
mod process;
|
||||
mod restart;
|
||||
mod routing;
|
||||
pub mod runtime;
|
||||
mod services;
|
||||
mod upgrades;
|
||||
mod watcher;
|
||||
mod watches;
|
||||
pub mod world;
|
||||
pub use host::Host;
|
||||
pub use native::{NativeContext, NativeFuture, NativePlugin};
|
||||
pub use process::ProcessInfo;
|
||||
use serde_json::{Value, json};
|
||||
/// A JSON result or a JSON-RPC error object.
|
||||
pub type RpcResult = Result<Value, Value>;
|
||||
fn error(code: i64, text: &str) -> Value {
|
||||
json!({"code":code,"message":text})
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Advisory FIFO locks and cleanup of ended processes.
|
||||
use crate::error;
|
||||
use crate::{Host, RpcResult, host::State};
|
||||
use serde_json::json;
|
||||
use std::collections::VecDeque;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
pub(crate) struct Lock {
|
||||
pub(crate) owner: String,
|
||||
pub(crate) queue: VecDeque<(String, oneshot::Sender<RpcResult>)>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Acquire a FIFO queue position, or report whether the lock is free.
|
||||
pub(crate) async fn lock(&self, caller: &str, path: &str, wait: bool) -> RpcResult {
|
||||
let rx = {
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
if caller != "console"
|
||||
&& caller != "core"
|
||||
&& !state
|
||||
.processes
|
||||
.get(caller)
|
||||
.is_some_and(|p| p.info.state == "running")
|
||||
{
|
||||
return Err(error(-32600, "process is stopping or ended"));
|
||||
}
|
||||
if let Some(lock) = state.locks.get_mut(path) {
|
||||
if !wait {
|
||||
return Ok(json!({"held":false}));
|
||||
}
|
||||
let (tx, rx) = oneshot::channel();
|
||||
lock.queue.push_back((caller.into(), tx));
|
||||
Some(rx)
|
||||
} else {
|
||||
state.locks.insert(
|
||||
path.into(),
|
||||
Lock {
|
||||
owner: caller.into(),
|
||||
queue: VecDeque::new(),
|
||||
},
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
match rx {
|
||||
Some(rx) => rx
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(error(-32603, "lock canceled"))),
|
||||
None => Ok(json!({"held":true})),
|
||||
}
|
||||
}
|
||||
/// Release a held lock only when this process owns it.
|
||||
pub(crate) fn unlock(&self, caller: &str, path: &str) {
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
if state
|
||||
.locks
|
||||
.get(path)
|
||||
.is_some_and(|lock| lock.owner == caller)
|
||||
{
|
||||
grant(&mut state, path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove queued acquisitions and grant the next owner of each held lock.
|
||||
pub(crate) fn release_locks(state: &mut State, pid: &str) {
|
||||
for lock in state.locks.values_mut() {
|
||||
lock.queue.retain(|(p, _)| p != pid);
|
||||
}
|
||||
let paths: Vec<_> = state
|
||||
.locks
|
||||
.iter()
|
||||
.filter(|(_, l)| l.owner == pid)
|
||||
.map(|(p, _)| p.clone())
|
||||
.collect();
|
||||
for path in paths {
|
||||
grant(state, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn grant(state: &mut State, path: &str) {
|
||||
if let Some(mut lock) = state.locks.remove(path) {
|
||||
while let Some((owner, tx)) = lock.queue.pop_front() {
|
||||
if tx.send(Ok(json!({"held":true}))).is_ok() {
|
||||
lock.owner = owner;
|
||||
state.locks.insert(path.into(), lock);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-190
@@ -1,190 +0,0 @@
|
||||
//! Command parsing and the minimal line console.
|
||||
use anyhow::{Result, bail};
|
||||
use clap::{Parser, Subcommand};
|
||||
use include_dir::{Dir, include_dir};
|
||||
use sausage::{Host, world::World};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
static AGENT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent/src");
|
||||
mod test_runner;
|
||||
#[derive(Parser)]
|
||||
#[command(version)]
|
||||
struct Cli {
|
||||
#[arg(long, global = true)]
|
||||
headless: bool,
|
||||
#[arg(long, global = true)]
|
||||
cache_dir: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
command: Option<Cmd>,
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
Init {
|
||||
#[arg(default_value = "world")]
|
||||
dir: PathBuf,
|
||||
},
|
||||
Run {
|
||||
#[arg(default_value = "world")]
|
||||
dir: PathBuf,
|
||||
},
|
||||
/// Run a WASI libtest component in isolated instances.
|
||||
TestRunner {
|
||||
wasm: PathBuf,
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
args: Vec<String>,
|
||||
},
|
||||
}
|
||||
fn headless(value: &str) -> std::result::Result<bool, String> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"1" | "true" => Ok(true),
|
||||
"" | "0" | "false" => Ok(false),
|
||||
_ => Err("expected 1, true, 0, false, or an empty value".into()),
|
||||
}
|
||||
}
|
||||
|
||||
// Readline can still wait on its detached thread when the host exits.
|
||||
struct TerminalMode(Option<nix::sys::termios::Termios>);
|
||||
impl Drop for TerminalMode {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mode) = &self.0 {
|
||||
let _ = nix::sys::termios::tcsetattr(
|
||||
std::io::stdin(),
|
||||
nix::sys::termios::SetArg::TCSANOW,
|
||||
mode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a world with editable configuration and builds its embedded agent.
|
||||
async fn init(dir: &Path, cache: Option<&Path>) -> Result<()> {
|
||||
if dir.exists() {
|
||||
bail!("world already exists: {}", dir.display());
|
||||
}
|
||||
let parent = dir
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."));
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let staging = tempfile::Builder::new()
|
||||
.prefix(".sausage-init-")
|
||||
.tempdir_in(parent)?;
|
||||
let world = World::create(&staging.path().join("world"))?;
|
||||
std::fs::write(
|
||||
world.root.join("config/agent.yaml"),
|
||||
include_str!("agent.yaml"),
|
||||
)?;
|
||||
std::fs::write(
|
||||
world.root.join("config/boot.yaml"),
|
||||
include_str!("boot.yaml"),
|
||||
)?;
|
||||
let cache = cache.map(Path::to_path_buf).unwrap_or_else(|| {
|
||||
let base = std::env::var_os("XDG_CACHE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".cache")
|
||||
});
|
||||
base.join("sausage")
|
||||
});
|
||||
let builder = sausage::cargo::Cargo::new(world.clone(), &cache)?;
|
||||
let source = world.plugin_dir("agent").join("source");
|
||||
std::fs::create_dir_all(source.join("src"))?;
|
||||
AGENT.extract(source.join("src"))?;
|
||||
std::fs::write(
|
||||
source.join("Cargo.toml"),
|
||||
include_str!("../../agent/Cargo.toml"),
|
||||
)?;
|
||||
std::fs::write(
|
||||
source.join("Cargo.lock"),
|
||||
include_bytes!("../../Cargo.lock"),
|
||||
)?;
|
||||
println!("The first agent build takes a few minutes.");
|
||||
let output = builder.build(&source).await?;
|
||||
std::fs::copy(output.wasm, world.plugin_dir("agent").join("plugin.wasm"))?;
|
||||
std::fs::rename(&world.root, dir)?;
|
||||
println!(
|
||||
"Edit {} before chatting.",
|
||||
dir.join("config/agent.yaml").display()
|
||||
);
|
||||
println!("Start the world with: sausage run {}", dir.display());
|
||||
Ok(())
|
||||
}
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<std::process::ExitCode> {
|
||||
let cli = Cli::parse();
|
||||
let _terminal = TerminalMode(nix::sys::termios::tcgetattr(std::io::stdin()).ok());
|
||||
let command = match cli.command {
|
||||
Some(command) => command,
|
||||
None => {
|
||||
let dir = PathBuf::from("world");
|
||||
if !dir.join("config/boot.yaml").is_file() {
|
||||
init(&dir, cli.cache_dir.as_deref()).await?;
|
||||
}
|
||||
Cmd::Run { dir }
|
||||
}
|
||||
};
|
||||
match command {
|
||||
Cmd::TestRunner { wasm, args } => {
|
||||
if !test_runner::run(&wasm, args).await? {
|
||||
return Ok(std::process::ExitCode::FAILURE);
|
||||
}
|
||||
}
|
||||
Cmd::Init { dir } => init(&dir, cli.cache_dir.as_deref()).await?,
|
||||
Cmd::Run { dir } => {
|
||||
let headless = cli.headless
|
||||
|| std::env::var("SAUSAGE_HEADLESS")
|
||||
.ok()
|
||||
.map(|value| headless(&value).map_err(anyhow::Error::msg))
|
||||
.transpose()?
|
||||
.unwrap_or(false);
|
||||
let mut term =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||
let mut interrupt =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
|
||||
let start = async {
|
||||
match cli.cache_dir {
|
||||
Some(cache) => Host::start_with_cache(&dir, &cache).await,
|
||||
None => Host::start(&dir).await,
|
||||
}
|
||||
};
|
||||
let host = tokio::select! {
|
||||
result=start=>result?,
|
||||
_=term.recv()=>return Ok(std::process::ExitCode::SUCCESS),
|
||||
_=interrupt.recv()=>return Ok(std::process::ExitCode::SUCCESS),
|
||||
};
|
||||
let mut notifications = host.subscribe();
|
||||
let notification_task = headless.then(|| {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match notifications.recv().await {
|
||||
Ok(notification) => println!("{notification}"),
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
let console = sausage::console::run_input(host.clone());
|
||||
let mut console_result = Ok(());
|
||||
let exit = tokio::select! {
|
||||
_=host.exit_requested()=>true,
|
||||
_=term.recv()=>false,
|
||||
_=interrupt.recv(), if headless=>false,
|
||||
result = console, if !headless => {
|
||||
console_result=result;
|
||||
false
|
||||
},
|
||||
};
|
||||
let exit = exit || host.restart_requested();
|
||||
host.stop().await;
|
||||
if let Some(task) = notification_task {
|
||||
task.abort();
|
||||
}
|
||||
if exit {
|
||||
return Ok(std::process::ExitCode::from(75));
|
||||
}
|
||||
console_result?;
|
||||
}
|
||||
}
|
||||
Ok(std::process::ExitCode::SUCCESS)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
//! Native plugin futures and their process context.
|
||||
use crate::{Host, RpcResult};
|
||||
use serde_json::{Value, json};
|
||||
use std::{future::Future, pin::Pin, sync::Arc};
|
||||
use uuid::Uuid;
|
||||
/// The cancellable future of a native process.
|
||||
pub type NativeFuture = Pin<Box<dyn Future<Output = RpcResult> + Send>>;
|
||||
/// A handler for the methods of one native plugin.
|
||||
pub type NativePlugin = Arc<dyn Fn(NativeContext, String, Value) -> NativeFuture + Send + Sync>;
|
||||
#[derive(Clone)]
|
||||
/// The process identity and host access supplied to a native handler.
|
||||
pub struct NativeContext {
|
||||
/// The host that owns this native process.
|
||||
pub host: Host,
|
||||
/// The ID assigned to this native process.
|
||||
pub pid: String,
|
||||
}
|
||||
impl NativeContext {
|
||||
/// Make an outgoing call with this native process as its caller.
|
||||
pub async fn call(&self, method: &str, params: Value) -> RpcResult {
|
||||
self.host
|
||||
.route(
|
||||
self.pid.clone(),
|
||||
Some(json!(Uuid::now_v7().to_string())),
|
||||
method.into(),
|
||||
params,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
/// Send progress to this process's caller while its call remains unanswered.
|
||||
pub fn notify(&self, name: &str, params: Value) {
|
||||
self.host.notify(&self.pid, name, params);
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
//! Process metadata, process lifetime, and attached children.
|
||||
use crate::{Host, RpcResult};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
/// A snapshot of process metadata for inspection and the VFS.
|
||||
pub struct ProcessInfo {
|
||||
/// The plugin that supplied the process code.
|
||||
pub plugin: String,
|
||||
/// The SHA-256 of its component, or native for a native plugin.
|
||||
pub version: String,
|
||||
/// The method without its plugin prefix.
|
||||
pub method: String,
|
||||
/// The object supplied by the caller.
|
||||
pub params: Value,
|
||||
/// The attached parent, or no parent for a detached process.
|
||||
pub parent: Option<String>,
|
||||
/// Whether this process survives the end of its caller.
|
||||
pub detached: bool,
|
||||
/// The start time in milliseconds since the Unix epoch.
|
||||
pub start_time: u128,
|
||||
/// The current lifecycle state.
|
||||
pub state: String,
|
||||
}
|
||||
pub(crate) struct Process {
|
||||
pub(crate) info: ProcessInfo,
|
||||
pub(crate) memory: sausage_vfs::Memory,
|
||||
pub(crate) files: sausage_vfs::Process,
|
||||
pub(crate) store_ended: watch::Sender<bool>,
|
||||
pub(crate) input: Option<mpsc::UnboundedSender<Value>>,
|
||||
pub(crate) abort: Option<tokio::task::AbortHandle>,
|
||||
pub(crate) caller: String,
|
||||
pub(crate) id: Option<Value>,
|
||||
pub(crate) answer: Option<oneshot::Sender<RpcResult>>,
|
||||
pub(crate) ended: watch::Sender<bool>,
|
||||
pub(crate) stopped: bool,
|
||||
pub(crate) superseded: bool,
|
||||
}
|
||||
impl Host {
|
||||
/// Called only after the execution task ends. Wait for the store and file operations before releasing locks.
|
||||
pub(crate) async fn finish(&self, pid: &str, status: &str) {
|
||||
let (files, mut store_ended) = {
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
let Some(p) = state.processes.get_mut(pid) else {
|
||||
return;
|
||||
};
|
||||
p.info.state = "stopping".into();
|
||||
(p.files.clone(), p.store_ended.subscribe())
|
||||
};
|
||||
let _ = store_ended.wait_for(|ended| *ended).await;
|
||||
files.idle().await;
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
let process = state.processes.remove(pid);
|
||||
if let Some(p) = process {
|
||||
self.0.journal.write(pid, &p.info.plugin, "core", status);
|
||||
let logs = p
|
||||
.answer
|
||||
.as_ref()
|
||||
.map(|_| self.0.journal.query_later(json!({"pid":pid,"limit":20})));
|
||||
self.0.journal.forget(pid);
|
||||
Self::release_locks(&mut state, pid);
|
||||
self.service_ended(&mut state, pid, status, p.stopped, p.superseded);
|
||||
let status = status.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Some(answer) = p.answer {
|
||||
let lines = logs.unwrap().await.unwrap_or_else(|_| json!([]));
|
||||
let failure = json!({
|
||||
"code": -32002,
|
||||
"message": "process ended without an answer",
|
||||
"data": { "status": status, "lines": lines }
|
||||
});
|
||||
let _ = answer.send(Err(failure));
|
||||
}
|
||||
p.ended.send_replace(true);
|
||||
});
|
||||
let children: Vec<_> = state
|
||||
.processes
|
||||
.iter()
|
||||
.filter(|(_, p)| p.info.parent.as_deref() == Some(pid))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
drop(state);
|
||||
for child in children {
|
||||
self.begin_stop(&child);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Close stdin and start the same five-second deadline for each attached child.
|
||||
pub(crate) fn begin_stop(&self, pid: &str) -> Option<watch::Receiver<bool>> {
|
||||
self.stop_process(pid, false)
|
||||
}
|
||||
/// Grace expiry is retirement, not an operator stopping the service.
|
||||
pub(crate) fn begin_retire(&self, pid: &str) -> Option<watch::Receiver<bool>> {
|
||||
self.stop_process(pid, true)
|
||||
}
|
||||
fn stop_process(&self, pid: &str, retirement: bool) -> Option<watch::Receiver<bool>> {
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
let p = state.processes.get_mut(pid)?;
|
||||
if !retirement {
|
||||
p.superseded = false;
|
||||
}
|
||||
let ended = p.ended.subscribe();
|
||||
if p.info.state == "stopping" {
|
||||
return Some(ended);
|
||||
}
|
||||
p.info.state = "stopping".into();
|
||||
p.stopped = true;
|
||||
p.input.take();
|
||||
self.0.journal.write(pid, &p.info.plugin, "core", "stop");
|
||||
for lock in state.locks.values_mut() {
|
||||
lock.queue.retain(|(queued, _)| queued != pid);
|
||||
}
|
||||
let children: Vec<_> = state
|
||||
.processes
|
||||
.iter()
|
||||
.filter(|(_, p)| p.info.parent.as_deref() == Some(pid))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
drop(state);
|
||||
for child in children {
|
||||
self.begin_stop(&child);
|
||||
}
|
||||
let h = self.clone();
|
||||
let pid = pid.to_string();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
h.kill_process(&pid, retirement);
|
||||
});
|
||||
Some(ended)
|
||||
}
|
||||
/// Abort a process task and perform its ended-state cleanup.
|
||||
pub(crate) fn kill(&self, pid: &str) {
|
||||
self.kill_process(pid, false);
|
||||
}
|
||||
fn kill_process(&self, pid: &str, retirement: bool) {
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
for lock in state.locks.values_mut() {
|
||||
lock.queue.retain(|(queued, _)| queued != pid);
|
||||
}
|
||||
if let Some(p) = state.processes.get_mut(pid) {
|
||||
if !retirement {
|
||||
p.superseded = false;
|
||||
}
|
||||
p.stopped = true;
|
||||
p.info.state = "stopping".into();
|
||||
p.input.take();
|
||||
self.0.journal.write(pid, &p.info.plugin, "core", "kill");
|
||||
if let Some(a) = &p.abort {
|
||||
a.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Stop every process in parallel and wait for journal writes to finish.
|
||||
pub async fn stop(&self) {
|
||||
{
|
||||
let _state = self.0.state.lock().unwrap();
|
||||
self.0
|
||||
.shutdown
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
loop {
|
||||
let ids: Vec<_> = self.processes().into_keys().collect();
|
||||
if ids.is_empty() {
|
||||
break;
|
||||
}
|
||||
let mut waits = Vec::new();
|
||||
for pid in ids {
|
||||
if let Some(n) = self.begin_stop(&pid) {
|
||||
waits.push(n);
|
||||
}
|
||||
}
|
||||
for mut n in waits {
|
||||
let _ = n.wait_for(|ended| *ended).await;
|
||||
}
|
||||
}
|
||||
self.0.journal.query(json!({"limit":0})).await;
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
//! Supervisor-gated restart requests; the executable still owns normal shutdown.
|
||||
use crate::{Host, RpcResult, error};
|
||||
use serde_json::json;
|
||||
use std::{ffi::OsStr, sync::atomic::Ordering, time::Duration};
|
||||
|
||||
pub(crate) fn enabled(value: Option<&OsStr>) -> bool {
|
||||
value == Some(OsStr::new("1"))
|
||||
}
|
||||
|
||||
impl Host {
|
||||
pub(crate) fn request_restart(&self) -> RpcResult {
|
||||
if !self.0.loop_supervised {
|
||||
return Err(error(
|
||||
-32600,
|
||||
"restart needs the loop supervisor (SAUSAGE_LOOP not set); start with SAUSAGE_LOOP=1",
|
||||
));
|
||||
}
|
||||
if !self.0.restart_pending.swap(true, Ordering::SeqCst) {
|
||||
let host = self.clone();
|
||||
tokio::spawn(async move {
|
||||
// Let the RPC response reach the caller before closing any stdin,
|
||||
// including that of a detached gateway carrying this response.
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
host.0.exit.send_replace(true);
|
||||
});
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{runtime::Runtime, world::World};
|
||||
use std::sync::Arc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[test]
|
||||
fn only_explicit_loop_marker_enables_restart() {
|
||||
assert!(!enabled(None));
|
||||
for value in ["", "0", "true", "yes", "2"] {
|
||||
assert!(!enabled(Some(OsStr::new(value))));
|
||||
}
|
||||
assert!(enabled(Some(OsStr::new("1"))));
|
||||
}
|
||||
|
||||
async fn host(supervised: bool) -> (tempfile::TempDir, Host) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
World::create(&dir.path().join("world")).unwrap();
|
||||
let runtime = Runtime::new(&dir.path().join("cache")).unwrap();
|
||||
let mut host = Host::test_world(&dir.path().join("world"), runtime)
|
||||
.await
|
||||
.unwrap();
|
||||
// No global environment mutation: parallel tests have independent gates.
|
||||
Arc::get_mut(&mut host.0).unwrap().loop_supervised = supervised;
|
||||
(dir, host)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_without_supervisor_never_triggers_exit() {
|
||||
timeout(Duration::from_secs(10), async {
|
||||
let (_dir, host) = host(false).await;
|
||||
for method in ["core.restart", "core.exit"] {
|
||||
let error = host.call(method, json!({})).await.unwrap_err();
|
||||
assert_eq!(error["code"], -32600);
|
||||
assert!(
|
||||
error["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("SAUSAGE_LOOP not set")
|
||||
);
|
||||
let indirect = host
|
||||
.call("core.call", json!({"method":method}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(indirect, error);
|
||||
}
|
||||
assert!(!host.0.restart_pending.load(Ordering::SeqCst));
|
||||
assert!(
|
||||
timeout(Duration::from_millis(300), host.exit_requested())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
host.stop().await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_and_exit_share_response_grace_and_coalescing() {
|
||||
timeout(Duration::from_secs(10), async {
|
||||
for first in ["core.restart", "core.exit"] {
|
||||
let (_dir, host) = host(true).await;
|
||||
// Same dispatch as a plugin's core.call, without killing this runner.
|
||||
assert_eq!(
|
||||
host.call("core.call", json!({"method":first}))
|
||||
.await
|
||||
.unwrap(),
|
||||
json!({})
|
||||
);
|
||||
assert!(!host.restart_requested());
|
||||
assert!(host.0.restart_pending.load(Ordering::SeqCst));
|
||||
assert_eq!(
|
||||
host.call("core.restart", json!({})).await.unwrap(),
|
||||
json!({})
|
||||
);
|
||||
timeout(Duration::from_secs(1), host.exit_requested())
|
||||
.await
|
||||
.unwrap();
|
||||
// A duplicate after the trigger must not schedule a second task.
|
||||
host.0.exit.send_replace(false);
|
||||
assert_eq!(
|
||||
host.call("core.restart", json!({})).await.unwrap(),
|
||||
json!({})
|
||||
);
|
||||
assert_eq!(host.call("core.exit", json!({})).await.unwrap(), json!({}));
|
||||
assert!(
|
||||
timeout(Duration::from_millis(300), host.exit_requested())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
//! JSON-RPC routing and responses between callers and processes.
|
||||
use crate::{
|
||||
Host, NativeFuture, RpcResult, error,
|
||||
process::{Process, ProcessInfo},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn params_not_object(params: &Value) -> Value {
|
||||
let kind = match params {
|
||||
Value::String(_) => "a string",
|
||||
Value::Array(_) => "an array",
|
||||
Value::Number(_) => "a number",
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "a boolean",
|
||||
Value::Object(_) => "an object",
|
||||
};
|
||||
error(
|
||||
-32602,
|
||||
&format!(
|
||||
"params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string."
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Queue an incoming response or notification for its destination.
|
||||
pub(crate) fn send(&self, pid: &str, value: Value) {
|
||||
if pid == "console" {
|
||||
let _ = self.0.notifications.send(value);
|
||||
} else if let Some(tx) = self
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get(pid)
|
||||
.and_then(|p| p.input.as_ref())
|
||||
{
|
||||
let _ = tx.send(value);
|
||||
}
|
||||
}
|
||||
/// Remap progress to the caller's original request ID.
|
||||
pub(crate) fn notify(&self, pid: &str, name: &str, params: Value) {
|
||||
let target = self
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get(pid)
|
||||
.and_then(|p| p.answer.as_ref().map(|_| (p.caller.clone(), p.id.clone())));
|
||||
if let Some((caller, Some(id))) = target {
|
||||
self.call_notify(&caller, &id, name, params);
|
||||
}
|
||||
}
|
||||
/// Deliver a notification on an open core call using the same envelope as plugin progress.
|
||||
pub(crate) fn call_notify(&self, caller: &str, id: &Value, name: &str, params: Value) {
|
||||
self.send(
|
||||
caller,
|
||||
json!({
|
||||
"jsonrpc":"2.0", "method":"core.notify",
|
||||
"params": {"call":id, "name":name, "params":params}
|
||||
}),
|
||||
);
|
||||
}
|
||||
/// Validate and route a request, then start its native or WASI process.
|
||||
pub(crate) fn route(
|
||||
&self,
|
||||
caller: String,
|
||||
id: Option<Value>,
|
||||
method: String,
|
||||
params: Value,
|
||||
detached: bool,
|
||||
) -> NativeFuture {
|
||||
self.route_from(caller, id, method, params, detached, None)
|
||||
}
|
||||
/// Like `route`, but a WASI process runs the component at `wasm` instead
|
||||
/// of the current `plugin.wasm` of its plugin. Activation uses this to
|
||||
/// call `rpc.discover` on a candidate file.
|
||||
pub(crate) fn route_from(
|
||||
&self,
|
||||
caller: String,
|
||||
id: Option<Value>,
|
||||
method: String,
|
||||
params: Value,
|
||||
detached: bool,
|
||||
wasm: Option<std::path::PathBuf>,
|
||||
) -> NativeFuture {
|
||||
let host = self.clone();
|
||||
Box::pin(async move {
|
||||
if !params.is_object() {
|
||||
return Err(params_not_object(¶ms));
|
||||
}
|
||||
if caller != "console" && caller != "core" {
|
||||
let state = host.0.state.lock().unwrap();
|
||||
let Some(process) = state.processes.get(&caller) else {
|
||||
return Err(error(-32600, "process ended"));
|
||||
};
|
||||
if process.info.state != "running" && method != "core.notify" {
|
||||
return Err(error(-32600, "process is stopping"));
|
||||
}
|
||||
}
|
||||
let (plugin, method) = method
|
||||
.split_once('.')
|
||||
.ok_or_else(|| error(-32601, "method not found"))?;
|
||||
if plugin == "core" {
|
||||
return host.core(&caller, id, method, params).await;
|
||||
}
|
||||
if plugin.is_empty()
|
||||
|| !plugin
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
|
||||
{
|
||||
return Err(error(-32601, "invalid plugin name"));
|
||||
}
|
||||
let path = wasm.unwrap_or_else(|| host.0.world.plugin_dir(plugin).join("plugin.wasm"));
|
||||
let (native, component, version, admission) = loop {
|
||||
if host.0.shutdown.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return Err(error(-32600, "host is stopping"));
|
||||
}
|
||||
let native = host.0.state.lock().unwrap().native.get(plugin).cloned();
|
||||
let loaded = if native.is_none() {
|
||||
Some(
|
||||
host.0
|
||||
.components
|
||||
.load(path.clone(), host.0.runtime.clone())
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let admission = host.0.admission.read().await;
|
||||
if let Some(loaded) = loaded {
|
||||
if crate::components::Fingerprint::read(&path).await? != loaded.fingerprint {
|
||||
drop(admission);
|
||||
continue;
|
||||
}
|
||||
break (native, Some(loaded.prepared), loaded.hash, admission);
|
||||
}
|
||||
break (native, None, "native".into(), admission);
|
||||
};
|
||||
let pid = format!("{plugin}-{}", Uuid::now_v7());
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let (input, incoming) = mpsc::unbounded_channel::<Value>();
|
||||
let info = ProcessInfo {
|
||||
plugin: plugin.into(),
|
||||
version,
|
||||
method: method.into(),
|
||||
params: params.clone(),
|
||||
parent: if detached { None } else { Some(caller.clone()) },
|
||||
detached,
|
||||
start_time: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis(),
|
||||
state: "running".into(),
|
||||
};
|
||||
{
|
||||
let mut state = host.0.state.lock().unwrap();
|
||||
if host.0.shutdown.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return Err(error(-32600, "host is stopping"));
|
||||
}
|
||||
if caller != "console"
|
||||
&& caller != "core"
|
||||
&& !state
|
||||
.processes
|
||||
.get(&caller)
|
||||
.is_some_and(|p| p.info.state == "running")
|
||||
{
|
||||
return Err(error(-32600, "caller stopped or ended"));
|
||||
}
|
||||
// Older plugins may still try to start their boot service from
|
||||
// rpc.retired. The supervisor owns it even while a restart is
|
||||
// pending, or after it has given up. Do not create a second owner.
|
||||
if state
|
||||
.processes
|
||||
.get(&caller)
|
||||
.is_some_and(|p| p.info.method == "rpc.retired")
|
||||
&& state
|
||||
.services
|
||||
.iter()
|
||||
.any(|s| s.method == format!("{plugin}.{method}") && s.params == params)
|
||||
{
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if caller == "core"
|
||||
&& let Some(service) = state
|
||||
.services
|
||||
.iter_mut()
|
||||
.find(|s| Some(&s.request) == id.as_ref())
|
||||
{
|
||||
if !service.enabled || service.owner.is_some() {
|
||||
return Err(error(-32600, "service attempt is no longer current"));
|
||||
}
|
||||
service.owner = Some(pid.clone());
|
||||
service.pending = false;
|
||||
service.version = Some(info.version.clone());
|
||||
}
|
||||
host.0
|
||||
.journal
|
||||
.write(&pid, plugin, "core", &format!("start {method} {params}"));
|
||||
state.processes.insert(
|
||||
pid.clone(),
|
||||
Process {
|
||||
info: info.clone(),
|
||||
memory: sausage_vfs::Memory::default(),
|
||||
files: sausage_vfs::Process::default(),
|
||||
store_ended: watch::channel(true).0,
|
||||
input: Some(input),
|
||||
abort: None,
|
||||
caller,
|
||||
id: id.clone(),
|
||||
answer: if id.is_some() { Some(tx) } else { None },
|
||||
ended: watch::channel(false).0,
|
||||
stopped: false,
|
||||
superseded: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
let h = host.clone();
|
||||
let p = pid.clone();
|
||||
let has_id = id.is_some();
|
||||
let (start, ready) = oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let _ = ready.await;
|
||||
h.run_process(p, info, component, native, incoming, has_id)
|
||||
.await
|
||||
});
|
||||
if let Some(p) = host.0.state.lock().unwrap().processes.get_mut(&pid) {
|
||||
p.abort = Some(task.abort_handle());
|
||||
if p.info.state == "stopping" {
|
||||
task.abort();
|
||||
}
|
||||
} else {
|
||||
task.abort();
|
||||
}
|
||||
drop(admission);
|
||||
let cleanup = host.clone();
|
||||
let finished_pid = pid.clone();
|
||||
tokio::spawn(async move {
|
||||
let status = task.await.unwrap_or_else(|error| {
|
||||
if error.is_cancelled() {
|
||||
"killed".into()
|
||||
} else {
|
||||
format!("host task failed: {error}")
|
||||
}
|
||||
});
|
||||
cleanup.finish(&finished_pid, &status).await;
|
||||
});
|
||||
let _ = start.send(());
|
||||
if id.is_none() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
rx.await
|
||||
.unwrap_or_else(|_| Err(error(-32002, "process ended")))
|
||||
})
|
||||
}
|
||||
/// Complete the initial call once and discard late responses.
|
||||
pub(crate) fn answer(&self, pid: &str, result: RpcResult) {
|
||||
if let Some(tx) = self
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get_mut(pid)
|
||||
.and_then(|p| p.answer.take())
|
||||
{
|
||||
let _ = tx.send(result);
|
||||
}
|
||||
}
|
||||
/// Classify a stdout line and route requests or the initial response.
|
||||
pub(crate) fn output(&self, pid: &str, line: String) {
|
||||
if line.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(v) = serde_json::from_str::<Value>(&line) else {
|
||||
self.discard(pid);
|
||||
return;
|
||||
};
|
||||
if !v.is_object() || v["jsonrpc"] != "2.0" {
|
||||
self.discard(pid);
|
||||
return;
|
||||
}
|
||||
if let Some(method) = v["method"].as_str() {
|
||||
if method == "core.notify" || method == "core.unlock" {
|
||||
// Deliver progress before a following response removes its destination.
|
||||
let params = v.get("params").cloned().unwrap_or(json!({}));
|
||||
let result = if !params.is_object() {
|
||||
Err(params_not_object(¶ms))
|
||||
} else if method == "core.unlock" {
|
||||
if let Some(path) = params["path"].as_str() {
|
||||
self.unlock(pid, path);
|
||||
Ok(Value::Null)
|
||||
} else {
|
||||
Err(error(-32602, "path must be a string"))
|
||||
}
|
||||
} else if let Some(name) = params["name"].as_str() {
|
||||
self.notify(
|
||||
pid,
|
||||
name,
|
||||
params.get("params").cloned().unwrap_or(json!({})),
|
||||
);
|
||||
Ok(Value::Null)
|
||||
} else {
|
||||
Err(error(-32602, "name must be a string"))
|
||||
};
|
||||
if let Some(id) = v.get("id") {
|
||||
let response = match result {
|
||||
Ok(result) => json!({"jsonrpc":"2.0", "id":id, "result":result}),
|
||||
Err(error) => json!({"jsonrpc":"2.0", "id":id, "error":error}),
|
||||
};
|
||||
self.send(pid, response);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let h = self.clone();
|
||||
let caller = pid.to_string();
|
||||
let method = method.to_string();
|
||||
tokio::spawn(async move {
|
||||
let id = v.get("id").cloned();
|
||||
let result = h
|
||||
.route(
|
||||
caller.clone(),
|
||||
id.clone(),
|
||||
method,
|
||||
v.get("params").cloned().unwrap_or(json!({})),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
if let Some(id) = id {
|
||||
let value = match result {
|
||||
Ok(result) => json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": result
|
||||
}),
|
||||
Err(error) => json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": error
|
||||
}),
|
||||
};
|
||||
h.send(&caller, value);
|
||||
}
|
||||
});
|
||||
} else if v["id"] == pid {
|
||||
self.answer(
|
||||
pid,
|
||||
if let Some(e) = v.get("error").filter(|e| !e.is_null()) {
|
||||
Err(e.clone())
|
||||
} else {
|
||||
Ok(v["result"].clone())
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
pub(crate) fn discard(&self, pid: &str) {
|
||||
// The journal deduplicates this warning for each process.
|
||||
if let Some(plugin) = self
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get(pid)
|
||||
.map(|p| p.info.plugin.clone())
|
||||
{
|
||||
self.0.journal.warning(pid, &plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// PROTOCOL 6: an unlock from stdout completes before output returns.
|
||||
#[tokio::test]
|
||||
async fn stdout_unlock_is_synchronous() {
|
||||
let folder = std::env::temp_dir().join(format!("sausage-unlock-{}", Uuid::now_v7()));
|
||||
crate::world::World::create(&folder).unwrap();
|
||||
let host = Host::start_with_cache(&folder, &folder.join("cache"))
|
||||
.await
|
||||
.unwrap();
|
||||
host.lock("core", "ordered", true).await.unwrap();
|
||||
host.output(
|
||||
"core",
|
||||
r#"{"jsonrpc":"2.0","method":"core.unlock","params":{"path":"ordered"}}"#.into(),
|
||||
);
|
||||
let held = host.lock("console", "ordered", false).await.unwrap();
|
||||
host.stop().await;
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
assert_eq!(held, json!({"held":true}));
|
||||
}
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
//! Wasmtime engine and process instances.
|
||||
//!
|
||||
//! An instance is one run of a `wasi:cli/command` component. The host writes
|
||||
//! NDJSON lines to its stdin and reads NDJSON lines from its stdout.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use wasmtime::component::{Component, Linker, ResourceTable};
|
||||
use wasmtime::{Config, Engine, Store};
|
||||
use wasmtime_wasi::cli::{AsyncStdinStream, AsyncStdoutStream};
|
||||
|
||||
use wasmtime_wasi::p2::bindings::CommandPre;
|
||||
|
||||
/// A component with its imports resolved once for this runtime.
|
||||
pub(crate) struct Prepared(Result<CommandPre<ProcessState>, String>);
|
||||
|
||||
use wasmtime_wasi::{I32Exit, WasiCtx, WasiCtxView, WasiView};
|
||||
use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView};
|
||||
|
||||
/// The HTTP transport hooks supplied to WASI processes.
|
||||
pub struct HttpHooks {
|
||||
allow: bool,
|
||||
}
|
||||
|
||||
impl WasiHttpHooks for HttpHooks {
|
||||
fn send_request(
|
||||
&mut self,
|
||||
request: http::Request<wasmtime_wasi_http::WasiBody>,
|
||||
options: Option<wasmtime_wasi_http::RequestOptions>,
|
||||
fut: Box<dyn Future<Output = Result<(), wasmtime_wasi_http::Error>> + Send>,
|
||||
) -> Box<
|
||||
dyn Future<
|
||||
Output = wasmtime_wasi_http::Result<(
|
||||
http::Response<wasmtime_wasi_http::WasiBody>,
|
||||
Box<dyn Future<Output = Result<(), wasmtime_wasi_http::Error>> + Send>,
|
||||
)>,
|
||||
> + Send,
|
||||
> {
|
||||
_ = fut;
|
||||
let allow = self.allow;
|
||||
Box::new(async move {
|
||||
use http_body_util::BodyExt;
|
||||
if !allow {
|
||||
return Err(wasmtime_wasi_http::Error::HttpRequestDenied);
|
||||
}
|
||||
let (res, io) = wasmtime_wasi_http::default_send_request(request, options).await?;
|
||||
Ok((
|
||||
res.map(BodyExt::boxed_unsync),
|
||||
Box::new(io) as Box<dyn Future<Output = _> + Send>,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The WASI and HTTP resources owned by one store.
|
||||
pub struct ProcessState {
|
||||
ctx: WasiCtx,
|
||||
table: ResourceTable,
|
||||
http: WasiHttpCtx,
|
||||
hooks: HttpHooks,
|
||||
fs: sausage_vfs::adapter::Adapter,
|
||||
}
|
||||
|
||||
impl WasiView for ProcessState {
|
||||
fn ctx(&mut self) -> WasiCtxView<'_> {
|
||||
WasiCtxView {
|
||||
ctx: &mut self.ctx,
|
||||
table: &mut self.table,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WasiHttpView for ProcessState {
|
||||
fn http(&mut self) -> WasiHttpCtxView<'_> {
|
||||
WasiHttpCtxView {
|
||||
ctx: &mut self.http,
|
||||
table: &mut self.table,
|
||||
hooks: &mut self.hooks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Wasmtime engine and linker shared by the world.
|
||||
pub struct Runtime {
|
||||
engine: Engine,
|
||||
linker: Linker<ProcessState>,
|
||||
}
|
||||
|
||||
/// How an instance ended.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Exit {
|
||||
/// `main` returned or the guest called `exit(0)`.
|
||||
Clean,
|
||||
/// The guest exited with a nonzero code.
|
||||
Code(i32),
|
||||
/// The guest trapped (panic, invalid memory access, ...).
|
||||
Trap(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Exit {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Exit::Clean => write!(f, "exit 0"),
|
||||
Exit::Code(c) => write!(f, "exit {c}"),
|
||||
Exit::Trap(t) => write!(f, "trap: {t}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The filesystem, network, and environment supplied to one process.
|
||||
pub struct InstanceConfig {
|
||||
/// The virtual namespace exposed through the single root preopen.
|
||||
pub filesystem: sausage_vfs::Mounts,
|
||||
/// The store completion barrier used by process cleanup.
|
||||
pub store_ended: tokio::sync::watch::Sender<bool>,
|
||||
/// Outgoing HTTP, and TCP, UDP, and DNS through `wasi:sockets`.
|
||||
pub network: bool,
|
||||
/// The environment variables supplied to the guest.
|
||||
pub env: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// A running guest. Dropping it kills the guest.
|
||||
pub struct Instance {
|
||||
/// The host end of stdin, removed when the process stops.
|
||||
pub stdin: Option<DuplexStream>,
|
||||
/// Lines that the guest writes to stdout. Closed when the guest ends.
|
||||
pub stdout: mpsc::Receiver<String>,
|
||||
/// The stderr lines that the process writes.
|
||||
pub stderr: mpsc::Receiver<String>,
|
||||
stderr_task: Option<JoinHandle<()>>,
|
||||
task: Option<JoinHandle<Exit>>,
|
||||
store_ended: tokio::sync::watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl Drop for Instance {
|
||||
fn drop(&mut self) {
|
||||
let task = self.task.take();
|
||||
let stderr = self.stderr_task.take();
|
||||
if let Some(task) = &task {
|
||||
task.abort();
|
||||
}
|
||||
if task.is_some() || stderr.is_some() {
|
||||
let ended = self.store_ended.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Some(task) = task {
|
||||
let _ = task.await;
|
||||
}
|
||||
if let Some(stderr) = stderr {
|
||||
let _ = stderr.await;
|
||||
}
|
||||
ended.send_replace(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Instance {
|
||||
/// Write one line to the guest's stdin, in one write. Meanwhile, pass the
|
||||
/// lines that the guest writes to `route`: a guest that blocks on a full
|
||||
/// stdout would never read its stdin.
|
||||
pub async fn send_line(
|
||||
&mut self,
|
||||
line: &str,
|
||||
mut route: impl FnMut(String),
|
||||
) -> std::io::Result<()> {
|
||||
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.
|
||||
pub async fn wait(&mut self) -> Exit {
|
||||
let result = self.task.as_mut().unwrap().await;
|
||||
self.task.take();
|
||||
if let Some(stderr) = self.stderr_task.as_mut() {
|
||||
let _ = stderr.await;
|
||||
}
|
||||
self.stderr_task.take();
|
||||
self.store_ended.send_replace(true);
|
||||
match result {
|
||||
Ok(exit) => exit,
|
||||
Err(e) if e.is_cancelled() => Exit::Trap("killed".into()),
|
||||
Err(e) => Exit::Trap(format!("host task failed: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The most useful line of a trap: the `wasm trap: ...` reason.
|
||||
fn short_trap(full: &str) -> String {
|
||||
let line = full
|
||||
.lines()
|
||||
.find_map(|l| l.find("wasm trap:").map(|i| l[i..].to_string()))
|
||||
.unwrap_or_else(|| full.lines().next().unwrap_or("trap").to_string());
|
||||
let mut line = line.trim().to_string();
|
||||
let mut end = line.len().min(300);
|
||||
while !line.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
line.truncate(end);
|
||||
line
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
/// Create an engine with disk caching and a ten-millisecond epoch tick.
|
||||
pub fn new(cache: &Path) -> Result<Arc<Self>> {
|
||||
let mut config = Config::new();
|
||||
config.epoch_interruption(true);
|
||||
let disk_cache = (|| -> Result<_> {
|
||||
let cache = cache.join("wasmtime");
|
||||
std::fs::create_dir_all(&cache)?;
|
||||
let mut cache_config = wasmtime::CacheConfig::new();
|
||||
cache_config.with_directory(cache.canonicalize()?);
|
||||
Ok(wasmtime::Cache::new(cache_config)?)
|
||||
})();
|
||||
match disk_cache {
|
||||
Ok(cache) => {
|
||||
config.cache(Some(cache));
|
||||
}
|
||||
Err(error) => eprintln!(
|
||||
"compile cache {} unavailable: {error}; running without disk cache",
|
||||
cache.display()
|
||||
),
|
||||
}
|
||||
let engine = Engine::new(&config)?;
|
||||
|
||||
let mut linker: Linker<ProcessState> = Linker::new(&engine);
|
||||
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
|
||||
sausage_vfs::adapter::add_to_linker(&mut linker, |state| sausage_vfs::adapter::View {
|
||||
adapter: &mut state.fs,
|
||||
table: &mut state.table,
|
||||
})?;
|
||||
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
|
||||
// Time slices: every 10 ms each running guest yields to the scheduler.
|
||||
let runtime = Arc::new(Self { engine, linker });
|
||||
let ticker = Arc::downgrade(&runtime);
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
let Some(runtime) = ticker.upgrade() else {
|
||||
break;
|
||||
};
|
||||
runtime.engine.increment_epoch();
|
||||
}
|
||||
});
|
||||
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
/// Compile component bytes through Wasmtime's disk cache.
|
||||
pub fn compile(&self, bytes: &[u8]) -> Result<Component> {
|
||||
Ok(Component::new(&self.engine, bytes)?)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare(&self, component: &Component) -> Prepared {
|
||||
// Preserve link failures as process exits, rather than load errors.
|
||||
Prepared(
|
||||
self.linker
|
||||
.instantiate_pre(component)
|
||||
.and_then(CommandPre::new)
|
||||
.map_err(|error| error.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn start_logged(
|
||||
self: &Arc<Self>,
|
||||
prepared: &Prepared,
|
||||
cfg: InstanceConfig,
|
||||
journal: Arc<crate::journal::Journal>,
|
||||
pid: String,
|
||||
plugin: String,
|
||||
) -> Result<Instance> {
|
||||
self.start_inner(prepared.0.clone(), cfg, &[], Some((journal, pid, plugin)))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Start a WASI task with the supplied virtual namespace.
|
||||
pub async fn start(
|
||||
self: &Arc<Self>,
|
||||
component: &Component,
|
||||
cfg: InstanceConfig,
|
||||
) -> Result<Instance> {
|
||||
self.start_with_args(component, cfg, &[]).await
|
||||
}
|
||||
|
||||
/// Start a WASI task with explicit CLI arguments, including its program name.
|
||||
pub async fn start_with_args(
|
||||
self: &Arc<Self>,
|
||||
component: &Component,
|
||||
cfg: InstanceConfig,
|
||||
args: &[String],
|
||||
) -> Result<Instance> {
|
||||
self.start_inner(self.prepare(component).0, cfg, args, None)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn start_inner(
|
||||
self: &Arc<Self>,
|
||||
prepared: Result<CommandPre<ProcessState>, String>,
|
||||
cfg: InstanceConfig,
|
||||
args: &[String],
|
||||
log: Option<(Arc<crate::journal::Journal>, String, String)>,
|
||||
) -> Result<Instance> {
|
||||
let (host_in, guest_in) = tokio::io::duplex(64 * 1024);
|
||||
let (guest_out, host_out) = tokio::io::duplex(64 * 1024);
|
||||
|
||||
let (guest_err, host_err) = tokio::io::duplex(64 * 1024);
|
||||
let mut b = WasiCtx::builder();
|
||||
b.args(args);
|
||||
if cfg.network {
|
||||
b.inherit_network()
|
||||
.allow_tcp(true)
|
||||
.allow_udp(true)
|
||||
.allow_ip_name_lookup(true);
|
||||
}
|
||||
b.stdin(AsyncStdinStream::new(guest_in));
|
||||
b.stdout(AsyncStdoutStream::new(8192, guest_out));
|
||||
b.stderr(AsyncStdoutStream::new(8192, guest_err));
|
||||
for (k, v) in &cfg.env {
|
||||
b.env(k, v);
|
||||
}
|
||||
|
||||
let state = ProcessState {
|
||||
ctx: b.build(),
|
||||
table: ResourceTable::new(),
|
||||
http: WasiHttpCtx::new(),
|
||||
hooks: HttpHooks { allow: cfg.network },
|
||||
fs: sausage_vfs::adapter::Adapter::new(cfg.filesystem),
|
||||
};
|
||||
|
||||
let (line_tx, line_rx) = mpsc::channel(1024);
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(host_out);
|
||||
let mut bytes = Vec::new();
|
||||
while let Ok(n) = reader.read_until(b'\n', &mut bytes).await {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
// Preserve a non-blank invalid line so routing emits its warning.
|
||||
let line = String::from_utf8(std::mem::take(&mut bytes))
|
||||
.unwrap_or_else(|_| "\u{fffd}".into());
|
||||
if line_tx.send(line).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let (err_tx, err_rx) = mpsc::channel(1024);
|
||||
let trap_log = log.clone();
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(host_err);
|
||||
let mut bytes = Vec::new();
|
||||
while let Ok(n) = reader.read_until(b'\n', &mut bytes).await {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
let line = String::from_utf8_lossy(&bytes)
|
||||
.trim_end_matches(['\r', '\n'])
|
||||
.to_string();
|
||||
bytes.clear();
|
||||
if let Some((journal, pid, plugin)) = &log {
|
||||
journal.write(pid, plugin, "stderr", &line);
|
||||
} else if err_tx.send(line).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let rt = self.clone();
|
||||
cfg.store_ended.send_replace(false);
|
||||
let task = tokio::spawn(async move {
|
||||
let mut store = Store::new(&rt.engine, state);
|
||||
store.epoch_deadline_async_yield_and_update(1);
|
||||
let result = async {
|
||||
let cmd = prepared
|
||||
.map_err(wasmtime::Error::msg)?
|
||||
.instantiate_async(&mut store)
|
||||
.await?;
|
||||
cmd.wasi_cli_run().call_run(&mut store).await
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(())) => Exit::Clean,
|
||||
Ok(Err(())) => Exit::Code(1),
|
||||
Err(e) => match e.downcast_ref::<I32Exit>() {
|
||||
Some(I32Exit(0)) => Exit::Clean,
|
||||
Some(I32Exit(c)) => Exit::Code(*c),
|
||||
None => {
|
||||
let full = format!("{e:?}");
|
||||
if let Some((journal, pid, plugin)) = &trap_log {
|
||||
journal.write(pid, plugin, "core", &full);
|
||||
}
|
||||
Exit::Trap(short_trap(&full))
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Instance {
|
||||
stdin: Some(host_in),
|
||||
stdout: line_rx,
|
||||
stderr: err_rx,
|
||||
stderr_task: Some(stderr_task),
|
||||
task: Some(task),
|
||||
store_ended: cfg.store_ended,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod trap_text_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn short_trap_display_is_utf8_safe() {
|
||||
let full = format!("backtrace\nwasm trap: {}\nmore detail", "😀".repeat(1000));
|
||||
let status = short_trap(&full);
|
||||
assert!(status.len() <= 300);
|
||||
assert!(status.starts_with("wasm trap:"));
|
||||
assert_eq!(short_trap(""), "trap");
|
||||
assert_eq!(short_trap("first line\nsecond"), "first line");
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
//! Ordered boot calls and the single restart owner for each service (PROTOCOL.md section 8).
|
||||
use crate::{Host, host::State};
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::VecDeque, sync::atomic::Ordering, time::Duration};
|
||||
use tokio::time::Instant;
|
||||
|
||||
// Systemd-derived values:
|
||||
// https://www.man7.org/linux/man-pages/man5/systemd-system.conf.5.html.
|
||||
const CRASH_LIMIT: usize = 5;
|
||||
const CRASH_WINDOW: Duration = Duration::from_secs(10);
|
||||
|
||||
pub(crate) struct Service {
|
||||
pub(crate) method: String,
|
||||
pub(crate) params: Value,
|
||||
pub(crate) request: Value,
|
||||
pub(crate) owner: Option<String>,
|
||||
pub(crate) pending: bool,
|
||||
pub(crate) version: Option<String>,
|
||||
pub(crate) enabled: bool,
|
||||
crashes: VecDeque<Instant>,
|
||||
}
|
||||
impl Service {
|
||||
fn crashed(&mut self, now: Instant) -> bool {
|
||||
while self
|
||||
.crashes
|
||||
.front()
|
||||
.is_some_and(|t| now.duration_since(*t) >= CRASH_WINDOW)
|
||||
{
|
||||
self.crashes.pop_front();
|
||||
}
|
||||
self.crashes.push_back(now);
|
||||
self.crashes.len() <= CRASH_LIMIT
|
||||
}
|
||||
}
|
||||
impl Host {
|
||||
/// Starts discovery in the background, then waits for each boot service in order.
|
||||
pub(crate) async fn boot(&self) -> anyhow::Result<()> {
|
||||
self.watch_plugins()?;
|
||||
let boot: Vec<Value> = serde_yaml_ng::from_slice(
|
||||
&tokio::fs::read(self.0.world.root.join("config/boot.yaml")).await?,
|
||||
)?;
|
||||
for service in boot {
|
||||
self.start_service(
|
||||
service["method"].as_str().unwrap_or("").into(),
|
||||
service["params"].clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Register once, before admission or readiness. Restarts reuse this registration.
|
||||
pub(crate) async fn start_service(&self, method: String, params: Value) {
|
||||
let request = json!(uuid::Uuid::now_v7().to_string());
|
||||
{
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
if self.0.shutdown.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
state.services.push(Service {
|
||||
method,
|
||||
params,
|
||||
request: request.clone(),
|
||||
owner: None,
|
||||
enabled: true,
|
||||
pending: true,
|
||||
version: None,
|
||||
crashes: VecDeque::new(),
|
||||
});
|
||||
}
|
||||
self.run_service(request).await;
|
||||
}
|
||||
/// Await readiness only; process cleanup, not this response, decides whether to restart.
|
||||
async fn run_service(&self, request: Value) {
|
||||
let (method, params) = {
|
||||
let state = self.0.state.lock().unwrap();
|
||||
if self.0.shutdown.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let Some(service) = state
|
||||
.services
|
||||
.iter()
|
||||
.find(|s| s.request == request && s.enabled)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
(service.method.clone(), service.params.clone())
|
||||
};
|
||||
self.0
|
||||
.journal
|
||||
.write("core", "core", "core", &format!("service start {method}"));
|
||||
let result = self
|
||||
.route("core".into(), Some(request.clone()), method, params, true)
|
||||
.await;
|
||||
{
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
if let Some(service) = state.services.iter_mut().find(|s| s.request == request) {
|
||||
service.pending = false;
|
||||
}
|
||||
}
|
||||
if let Err(error) = result {
|
||||
// Admission failures have no process lifetime to supervise. A process crash
|
||||
// before readiness is handled by finish just like one after readiness.
|
||||
self.0
|
||||
.journal
|
||||
.write("core", "core", "core", &format!("service failed {error}"));
|
||||
}
|
||||
}
|
||||
/// Called under the state lock after the old owner and its files/locks are gone.
|
||||
pub(crate) fn service_ended(
|
||||
&self,
|
||||
state: &mut State,
|
||||
pid: &str,
|
||||
status: &str,
|
||||
stopped: bool,
|
||||
superseded: bool,
|
||||
) {
|
||||
let Some(service) = state
|
||||
.services
|
||||
.iter_mut()
|
||||
.find(|s| s.owner.as_deref() == Some(pid))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
service.owner = None;
|
||||
if self.0.shutdown.load(Ordering::SeqCst) || !service.enabled {
|
||||
return;
|
||||
}
|
||||
if !superseded {
|
||||
if stopped {
|
||||
service.enabled = false;
|
||||
return;
|
||||
}
|
||||
if status == "exit 0" {
|
||||
return;
|
||||
}
|
||||
if !service.crashed(Instant::now()) {
|
||||
service.enabled = false;
|
||||
self.0.journal.write(
|
||||
"core",
|
||||
"core",
|
||||
"core",
|
||||
&format!(
|
||||
"service gave up {}: more than {CRASH_LIMIT} crashes within {} seconds",
|
||||
service.method,
|
||||
CRASH_WINDOW.as_secs(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.queue_service(service);
|
||||
}
|
||||
/// Reserve the next attempt before spawning it; upgrades and crashes share this owner.
|
||||
pub(crate) fn queue_service(&self, service: &mut Service) {
|
||||
service.request = json!(uuid::Uuid::now_v7().to_string());
|
||||
service.pending = true;
|
||||
let request = service.request.clone();
|
||||
let host = self.clone();
|
||||
tokio::spawn(async move {
|
||||
host.run_service(request).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,356 +0,0 @@
|
||||
//! Deterministic service supervision tests: native tasks stand in for guest lifetimes.
|
||||
use super::*;
|
||||
use crate::{NativePlugin, runtime::Runtime};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
|
||||
struct Rig {
|
||||
host: Host,
|
||||
starts: mpsc::UnboundedReceiver<(String, oneshot::Sender<()>)>,
|
||||
_world: tempfile::TempDir,
|
||||
}
|
||||
impl Rig {
|
||||
async fn new() -> Self {
|
||||
let world = tempfile::tempdir().unwrap();
|
||||
let runtime = Runtime::new(world.path()).unwrap();
|
||||
let host = Host::test_world(world.path(), runtime).await.unwrap();
|
||||
let (tx, starts) = mpsc::unbounded_channel();
|
||||
let plugin: NativePlugin = Arc::new(move |ctx, method, _| {
|
||||
let tx = tx.clone();
|
||||
Box::pin(async move {
|
||||
if method != "serve" {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let (end, ended) = oneshot::channel();
|
||||
tx.send((ctx.pid, end)).unwrap();
|
||||
let _ = ended.await;
|
||||
Ok(Value::Null)
|
||||
})
|
||||
});
|
||||
host.register_native("demo", plugin.clone());
|
||||
host.register_native("other", plugin);
|
||||
Self {
|
||||
host,
|
||||
starts,
|
||||
_world: world,
|
||||
}
|
||||
}
|
||||
async fn start(&mut self, method: &str) -> (String, oneshot::Sender<()>) {
|
||||
let host = self.host.clone();
|
||||
let method = method.to_string();
|
||||
tokio::spawn(async move {
|
||||
host.start_service(method, json!({"kept":true})).await;
|
||||
});
|
||||
self.next().await
|
||||
}
|
||||
async fn next(&mut self) -> (String, oneshot::Sender<()>) {
|
||||
// Reuse the host integration call bound (activation.rs::call).
|
||||
tokio::time::timeout(Duration::from_secs(60), self.starts.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
fn ended(&self, pid: &str) -> watch::Receiver<bool> {
|
||||
self.host.0.state.lock().unwrap().processes[pid]
|
||||
.ended
|
||||
.subscribe()
|
||||
}
|
||||
async fn crash(&self, pid: &str) {
|
||||
let mut ended = self.ended(pid);
|
||||
// Abort execution without asking the supervisor for an intentional stop.
|
||||
self.host.0.state.lock().unwrap().processes[pid]
|
||||
.abort
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.abort();
|
||||
ended.wait_for(|done| *done).await.unwrap();
|
||||
}
|
||||
async fn clean(&self, pid: &str, end: oneshot::Sender<()>) {
|
||||
let mut ended = self.ended(pid);
|
||||
end.send(()).unwrap();
|
||||
ended.wait_for(|done| *done).await.unwrap();
|
||||
}
|
||||
fn assert_single(&self, pid: &str, registrations: usize) {
|
||||
let state = self.host.0.state.lock().unwrap();
|
||||
assert_eq!(state.services.len(), registrations);
|
||||
let p = &state.processes[pid];
|
||||
assert_eq!(p.info.params, json!({"kept":true}));
|
||||
assert!(p.info.detached);
|
||||
assert_eq!(p.caller, "core");
|
||||
assert_eq!(
|
||||
state
|
||||
.services
|
||||
.iter()
|
||||
.filter(|s| s.owner.as_deref() == Some(pid))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
fn no_start(&mut self) {
|
||||
assert!(self.starts.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn crash_before_and_after_readiness_observes_process_not_response() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (pid, _end) = rig.start("demo.serve").await;
|
||||
rig.assert_single(&pid, 1);
|
||||
rig.crash(&pid).await; // No ready RPC response was sent.
|
||||
let (pid, end) = rig.next().await;
|
||||
rig.assert_single(&pid, 1);
|
||||
rig.host.answer(&pid, Ok(json!({"ready":true})));
|
||||
tokio::task::yield_now().await;
|
||||
rig.no_start(); // Ready does not end the service lifetime.
|
||||
rig.crash(&pid).await;
|
||||
drop(end);
|
||||
let (pid, end) = rig.next().await;
|
||||
rig.assert_single(&pid, 1);
|
||||
rig.clean(&pid, end).await;
|
||||
rig.no_start(); // A clean, intentional return is not a crash.
|
||||
rig.host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn fifth_restarts_sixth_gives_up_and_journals_once() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (mut pid, mut end) = rig.start("demo.serve").await;
|
||||
for crash in 1..=CRASH_LIMIT {
|
||||
rig.crash(&pid).await;
|
||||
drop(end);
|
||||
(pid, end) = rig.next().await;
|
||||
rig.assert_single(&pid, 1);
|
||||
assert_eq!(
|
||||
rig.host.0.state.lock().unwrap().services[0].crashes.len(),
|
||||
crash
|
||||
);
|
||||
}
|
||||
rig.crash(&pid).await;
|
||||
drop(end);
|
||||
rig.no_start();
|
||||
let logs = rig
|
||||
.host
|
||||
.call("core.log", json!({"grep":"service gave up"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(logs.as_array().unwrap().len(), 1);
|
||||
assert!(
|
||||
logs[0]["line"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("more than 5 crashes within 10 seconds")
|
||||
);
|
||||
assert!(!rig.host.0.state.lock().unwrap().services[0].enabled);
|
||||
// A retired hook cannot bypass give-up while its supervisor has no owner.
|
||||
rig.host.register_native(
|
||||
"fallback",
|
||||
Arc::new(move |ctx, _, _| {
|
||||
Box::pin(async move {
|
||||
ctx.call(
|
||||
"core.call",
|
||||
json!({"method":"demo.serve","params":{"kept":true},"detach":true}),
|
||||
)
|
||||
.await
|
||||
})
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
rig.host
|
||||
.call("fallback.rpc.retired", json!({}))
|
||||
.await
|
||||
.unwrap(),
|
||||
Value::Null
|
||||
);
|
||||
rig.no_start();
|
||||
// Neither time passing nor an upgrade re-arms a given-up service.
|
||||
tokio::time::advance(CRASH_WINDOW).await;
|
||||
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "new");
|
||||
rig.host.begin_upgrade(upgrade);
|
||||
tokio::task::yield_now().await;
|
||||
rig.no_start();
|
||||
rig.host.stop().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sliding_window_boundary_and_progress() {
|
||||
let now = Instant::now();
|
||||
let service = || Service {
|
||||
method: "demo.serve".into(),
|
||||
params: json!({}),
|
||||
request: json!("request"),
|
||||
owner: None,
|
||||
enabled: true,
|
||||
pending: false,
|
||||
version: None,
|
||||
crashes: VecDeque::new(),
|
||||
};
|
||||
let mut before = service();
|
||||
let mut exact = service();
|
||||
let mut after = service();
|
||||
for _ in 0..CRASH_LIMIT {
|
||||
assert!(before.crashed(now));
|
||||
assert!(exact.crashed(now));
|
||||
assert!(after.crashed(now));
|
||||
}
|
||||
assert!(!before.crashed(now + CRASH_WINDOW - Duration::from_nanos(1)));
|
||||
assert!(exact.crashed(now + CRASH_WINDOW));
|
||||
assert_eq!(exact.crashes.len(), 1);
|
||||
assert!(after.crashed(now + CRASH_WINDOW + Duration::from_nanos(1)));
|
||||
// Repeated progress beyond the window never accumulates old failures.
|
||||
for step in 2..=CRASH_LIMIT as u32 + 1 {
|
||||
assert!(exact.crashed(now + CRASH_WINDOW * step));
|
||||
assert_eq!(exact.crashes.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn services_have_independent_crash_budgets() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (mut a, mut a_end) = rig.start("demo.serve").await;
|
||||
let (b, b_end) = rig.start("other.serve").await;
|
||||
for _ in 0..CRASH_LIMIT {
|
||||
rig.crash(&a).await;
|
||||
drop(a_end);
|
||||
(a, a_end) = rig.next().await;
|
||||
}
|
||||
rig.crash(&a).await;
|
||||
drop(a_end);
|
||||
rig.crash(&b).await;
|
||||
drop(b_end);
|
||||
let (b, b_end) = rig.next().await;
|
||||
rig.assert_single(&b, 2);
|
||||
rig.clean(&b, b_end).await;
|
||||
rig.no_start();
|
||||
rig.host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn upgrade_crash_race_has_one_restart_and_retired_fallback_cannot_duplicate_it() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (pid, end) = rig.start("demo.serve").await;
|
||||
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "new");
|
||||
rig.crash(&pid).await; // Exits between snapshot and begin_upgrade.
|
||||
drop(end);
|
||||
rig.host.begin_upgrade(upgrade);
|
||||
let (pid, end) = rig.next().await;
|
||||
rig.assert_single(&pid, 1);
|
||||
assert!(
|
||||
rig.host.0.state.lock().unwrap().services[0]
|
||||
.crashes
|
||||
.is_empty()
|
||||
);
|
||||
// A native retired hook exercises the same routing path as a guest fallback.
|
||||
rig.host.register_native(
|
||||
"fallback",
|
||||
Arc::new(move |ctx, _, _| {
|
||||
Box::pin(async move {
|
||||
ctx.call(
|
||||
"core.call",
|
||||
json!({"method":"demo.serve","params":{"kept":true},"detach":true}),
|
||||
)
|
||||
.await
|
||||
})
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
rig.host
|
||||
.call("fallback.rpc.retired", json!({}))
|
||||
.await
|
||||
.unwrap(),
|
||||
Value::Null
|
||||
);
|
||||
rig.no_start();
|
||||
rig.clean(&pid, end).await;
|
||||
rig.host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn crash_before_upgrade_snapshot_also_has_one_restart() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (pid, end) = rig.start("demo.serve").await;
|
||||
rig.crash(&pid).await;
|
||||
drop(end);
|
||||
// Restart may be pending admission or already admitted: snapshot handles both.
|
||||
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "native");
|
||||
rig.host.begin_upgrade(upgrade);
|
||||
let (pid, end) = rig.next().await;
|
||||
rig.assert_single(&pid, 1);
|
||||
rig.no_start();
|
||||
rig.clean(&pid, end).await;
|
||||
rig.host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn pending_admission_is_single_owned_across_upgrade() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (pid, end) = rig.start("demo.serve").await;
|
||||
let host = rig.host.clone();
|
||||
let admission = host.0.admission.write().await;
|
||||
rig.crash(&pid).await;
|
||||
drop(end);
|
||||
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "native");
|
||||
rig.host.begin_upgrade(upgrade);
|
||||
assert!(rig.host.0.state.lock().unwrap().services[0].pending);
|
||||
drop(admission);
|
||||
let (pid, end) = rig.next().await;
|
||||
rig.assert_single(&pid, 1);
|
||||
rig.no_start();
|
||||
rig.clean(&pid, end).await;
|
||||
rig.host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn shutdown_rejects_restart_already_waiting_for_admission() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (pid, end) = rig.start("demo.serve").await;
|
||||
let host = rig.host.clone();
|
||||
let admission = host.0.admission.write().await;
|
||||
rig.crash(&pid).await;
|
||||
drop(end);
|
||||
rig.host.stop().await;
|
||||
drop(admission);
|
||||
tokio::task::yield_now().await;
|
||||
rig.no_start();
|
||||
assert!(rig.host.processes().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn clean_service_stays_dormant_until_upgrade_and_explicit_stop_stays_disabled() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (pid, end) = rig.start("demo.serve").await;
|
||||
rig.clean(&pid, end).await;
|
||||
rig.no_start();
|
||||
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "new");
|
||||
rig.host.begin_upgrade(upgrade);
|
||||
let (pid, end) = rig.next().await;
|
||||
rig.assert_single(&pid, 1);
|
||||
// An explicit stop stays disabled even if the task subsequently exits cleanly.
|
||||
let mut ended = rig.host.begin_stop(&pid).unwrap();
|
||||
end.send(()).unwrap();
|
||||
ended.wait_for(|done| *done).await.unwrap();
|
||||
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "newer");
|
||||
rig.host.begin_upgrade(upgrade);
|
||||
tokio::task::yield_now().await;
|
||||
rig.no_start();
|
||||
rig.host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn intentional_stop_and_shutdown_never_restart_even_during_upgrade() {
|
||||
let mut rig = Rig::new().await;
|
||||
let (pid, end) = rig.start("demo.serve").await;
|
||||
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "new");
|
||||
// An explicit kill after supersession overrides the automatic upgrade restart.
|
||||
rig.host
|
||||
.call("core.kill", json!({"pid":pid}))
|
||||
.await
|
||||
.unwrap();
|
||||
drop(end);
|
||||
rig.host.begin_upgrade(upgrade);
|
||||
tokio::task::yield_now().await;
|
||||
rig.no_start();
|
||||
let (_pid, end) = rig.start("other.serve").await;
|
||||
rig.host.stop().await;
|
||||
drop(end);
|
||||
rig.no_start();
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
//! Cargo's WASI test runner: one compiled component, isolated instances, and a small core transport.
|
||||
use anyhow::{Result, bail};
|
||||
use sausage::{
|
||||
Host,
|
||||
runtime::{Exit, InstanceConfig, Runtime},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use wasmtime::component::Component;
|
||||
|
||||
/// A temporary world remains alive through the end of its WASI store.
|
||||
struct TestWorld(PathBuf);
|
||||
impl Drop for TestWorld {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
struct Run {
|
||||
exit: Exit,
|
||||
output: String,
|
||||
}
|
||||
/// Runs one instance and separates JSON-RPC requests from ordinary test output.
|
||||
async fn instance(
|
||||
runtime: Arc<Runtime>,
|
||||
component: Component,
|
||||
args: Vec<String>,
|
||||
package: PathBuf,
|
||||
) -> Result<Run> {
|
||||
let path = std::env::temp_dir().join(format!("sausage-test-{}", uuid::Uuid::now_v7()));
|
||||
let world = TestWorld(path);
|
||||
let host = Host::test_world(&world.0, runtime.clone()).await?;
|
||||
let tracker = sausage_vfs::Process::default();
|
||||
let mut filesystem = host.filesystem().await?;
|
||||
// Virtual ancestors keep directory entries of the absolute package mount traversable.
|
||||
for ancestor in package
|
||||
.ancestors()
|
||||
.skip(1)
|
||||
.filter(|path| *path != Path::new("/"))
|
||||
{
|
||||
filesystem.mount(
|
||||
ancestor.to_str().unwrap(),
|
||||
Arc::new(sausage_vfs::Memory::default()),
|
||||
false,
|
||||
);
|
||||
}
|
||||
filesystem.mount(
|
||||
package.to_str().unwrap(),
|
||||
Arc::new(sausage_vfs::Host::new(&package, tracker.clone())?),
|
||||
false,
|
||||
);
|
||||
let mut env: Vec<_> = std::env::vars_os()
|
||||
.filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
|
||||
.filter(|(key, _)| {
|
||||
["CARGO_", "RUST_", "SAUSAGE_"]
|
||||
.iter()
|
||||
.any(|prefix| key.starts_with(prefix))
|
||||
})
|
||||
.collect();
|
||||
env.extend([
|
||||
("SAUSAGE_PID".into(), "test".into()),
|
||||
("SAUSAGE_PLUGIN".into(), "test".into()),
|
||||
("SAUSAGE_PARENT".into(), "core".into()),
|
||||
]);
|
||||
let timeout_secs = std::env::var("SAUSAGE_TEST_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| *value >= 1)
|
||||
.unwrap_or(sausage::cargo::DEFAULT_TEST_TIMEOUT_SECS);
|
||||
let deadline = tokio::time::Instant::now()
|
||||
.checked_add(std::time::Duration::from_secs(timeout_secs))
|
||||
.ok_or_else(|| anyhow::anyhow!("test_timeout_secs is too large"))?;
|
||||
let (ended, mut ended_rx) = tokio::sync::watch::channel(true);
|
||||
let mut guest = runtime
|
||||
.start_with_args(
|
||||
&component,
|
||||
InstanceConfig {
|
||||
filesystem,
|
||||
store_ended: ended,
|
||||
network: true,
|
||||
env,
|
||||
},
|
||||
&args,
|
||||
)
|
||||
.await?;
|
||||
let mut pending = std::collections::VecDeque::new();
|
||||
let mut stderr = std::mem::replace(&mut guest.stderr, tokio::sync::mpsc::channel(1).1);
|
||||
let (errors, mut stderr_lines) = tokio::sync::mpsc::unbounded_channel();
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
while let Some(line) = stderr.recv().await {
|
||||
let _ = errors.send(line);
|
||||
}
|
||||
});
|
||||
let mut output = String::new();
|
||||
let mut stdout_open = true;
|
||||
let mut stderr_open = true;
|
||||
let mut handlers = tokio::task::JoinSet::new();
|
||||
let result = tokio::time::timeout_at(deadline, async {
|
||||
while stdout_open || stderr_open || !pending.is_empty() {
|
||||
tokio::select! {
|
||||
line=async { if let Some(line) = pending.pop_front() { Some(line) } else { guest.stdout.recv().await } }, if stdout_open || !pending.is_empty()=>match line {
|
||||
Some(line)=> {
|
||||
// Libtest can prefix its status line before SDK output. The SDK starts JSON on a fresh line.
|
||||
if let Ok(request)=serde_json::from_str::<Value>(&line) && request["jsonrpc"]=="2.0" && request["method"].is_string() {
|
||||
let method = request["method"].as_str().unwrap();
|
||||
if !matches!(method, "core.lock" | "core.log") {
|
||||
let params = request.get("params").cloned().unwrap_or(json!({}));
|
||||
let result = match method {
|
||||
"core.unlock" => host.call(method, params).await,
|
||||
"core.notify" => Ok(Value::Null),
|
||||
_ => Err(json!({"code":-32601,"message":"a test has no other plugins"})),
|
||||
};
|
||||
if let Some(id) = request.get("id") {
|
||||
let response = match result { Ok(result)=>json!({"jsonrpc":"2.0","id":id,"result":result}), Err(error)=>json!({"jsonrpc":"2.0","id":id,"error":error}) };
|
||||
if guest.send_line(&response.to_string(), |line| pending.push_back(line)).await.is_err() { guest.stdin.take(); }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let host = host.clone();
|
||||
handlers.spawn(async move {
|
||||
let method=request["method"].as_str().unwrap();
|
||||
let params=request.get("params").cloned().unwrap_or(json!({}));
|
||||
let result=match method {
|
||||
"core.lock"|"core.unlock"|"core.log"=>host.call(method,params).await,
|
||||
"core.notify"=>Ok(Value::Null),
|
||||
_=>Err(json!({"code":-32601,"message":"a test has no other plugins"})),
|
||||
};
|
||||
request.get("id").map(|id|match result {Ok(result)=>json!({"jsonrpc":"2.0","id":id,"result":result}),Err(error)=>json!({"jsonrpc":"2.0","id":id,"error":error})})
|
||||
});
|
||||
} else {output.push_str(line.trim_end_matches(['\r', '\n']));output.push('\n');}
|
||||
},None=>stdout_open=false,
|
||||
},
|
||||
line=stderr_lines.recv(), if stderr_open=>match line {Some(line)=>{output.push_str(&line);output.push('\n');},None=>stderr_open=false},
|
||||
response=handlers.join_next(), if !handlers.is_empty()=> {
|
||||
if let Some(response) = response.transpose()?.flatten()
|
||||
&& guest.send_line(&response.to_string(), |line| pending.push_back(line)).await.is_err() { guest.stdin.take(); }
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(guest.wait().await)
|
||||
}).await;
|
||||
handlers.abort_all();
|
||||
// Dropping the instance aborts even a CPU-bound WASI loop. Wait for the
|
||||
// store and outstanding filesystem work before removing its test world.
|
||||
drop(guest);
|
||||
let _ = ended_rx.wait_for(|ended| *ended).await;
|
||||
let _ = stderr_task.await;
|
||||
tracker.idle().await;
|
||||
host.stop().await;
|
||||
let exit =
|
||||
result.map_err(|_| anyhow::anyhow!("timed out after {timeout_secs} s\n{output}"))??;
|
||||
Ok(Run { exit, output })
|
||||
}
|
||||
/// Extracts libtest names from its stable list format.
|
||||
fn names(output: &str) -> Vec<String> {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_suffix(": test").map(str::to_owned))
|
||||
.collect()
|
||||
}
|
||||
/// Lists tests, selects libtest filters, and runs each selected test on the core pool.
|
||||
pub async fn run(wasm: &Path, args: Vec<String>) -> Result<bool> {
|
||||
let package = std::env::current_dir()?;
|
||||
let cache = std::env::var_os("XDG_CACHE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".cache")
|
||||
})
|
||||
.join("sausage");
|
||||
let compile_path = wasm.to_path_buf();
|
||||
let (runtime, component) = tokio::task::spawn_blocking(move || -> Result<_> {
|
||||
let runtime = Runtime::new(&cache)?;
|
||||
let component = runtime.compile(&std::fs::read(compile_path)?)?;
|
||||
Ok((runtime, component))
|
||||
})
|
||||
.await??;
|
||||
let mut listing_args = vec![wasm.display().to_string(), "--list".into()];
|
||||
listing_args.extend(args.iter().filter(|arg| arg.as_str() != "--list").cloned());
|
||||
let listing = instance(
|
||||
runtime.clone(),
|
||||
component.clone(),
|
||||
listing_args.clone(),
|
||||
package.clone(),
|
||||
)
|
||||
.await?;
|
||||
if !matches!(listing.exit, Exit::Clean) {
|
||||
bail!(
|
||||
"cannot list WASI tests: {}\n{}",
|
||||
listing.exit,
|
||||
listing.output
|
||||
);
|
||||
}
|
||||
if args.iter().any(|arg| arg == "--list") {
|
||||
print!("{}", listing.output);
|
||||
return Ok(true);
|
||||
}
|
||||
let selected = names(&listing.output);
|
||||
listing_args.push("--ignored".into());
|
||||
let ignored = if args
|
||||
.iter()
|
||||
.any(|arg| matches!(arg.as_str(), "--ignored" | "--include-ignored"))
|
||||
{
|
||||
Vec::new()
|
||||
} else {
|
||||
names(
|
||||
&instance(
|
||||
runtime.clone(),
|
||||
component.clone(),
|
||||
listing_args,
|
||||
package.clone(),
|
||||
)
|
||||
.await?
|
||||
.output,
|
||||
)
|
||||
};
|
||||
println!("running {} tests", selected.len());
|
||||
let cores = std::thread::available_parallelism()
|
||||
.map(usize::from)
|
||||
.unwrap_or(1);
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(cores));
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
let mut skipped = 0;
|
||||
for name in selected {
|
||||
if ignored.contains(&name) {
|
||||
println!("test {name} ... ignored");
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let runtime = runtime.clone();
|
||||
let component = component.clone();
|
||||
let package = package.clone();
|
||||
let semaphore = semaphore.clone();
|
||||
let arguments = vec![
|
||||
wasm.display().to_string(),
|
||||
"--exact".into(),
|
||||
name.clone(),
|
||||
"--nocapture".into(),
|
||||
"--test-threads=1".into(),
|
||||
"--include-ignored".into(),
|
||||
];
|
||||
tasks.spawn(async move {
|
||||
let _permit = semaphore.acquire_owned().await?;
|
||||
let result = instance(runtime, component, arguments, package).await;
|
||||
Ok::<_, anyhow::Error>((name, result))
|
||||
});
|
||||
}
|
||||
let mut passed = 0;
|
||||
let mut failed = 0;
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
let (name, result) = result??;
|
||||
let success = result
|
||||
.as_ref()
|
||||
.is_ok_and(|run| matches!(run.exit, Exit::Clean));
|
||||
if success {
|
||||
passed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
println!("test {name} ... {}", if success { "ok" } else { "FAILED" });
|
||||
match result {
|
||||
Ok(run) => {
|
||||
for line in run
|
||||
.output
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
!line.trim().is_empty()
|
||||
&& !line.starts_with("test result:")
|
||||
&& !matches!(*line, "running 1 test" | "running 0 tests")
|
||||
&& !matches!(*line, "ok" | "FAILED")
|
||||
})
|
||||
.map(|line| {
|
||||
line.strip_prefix(&format!("test {name} ... "))
|
||||
.unwrap_or(line)
|
||||
})
|
||||
.filter(|line| {
|
||||
!matches!(*line, "ok" | "FAILED" | "ignored") && !line.is_empty()
|
||||
})
|
||||
{
|
||||
// Indentation keeps ordinary test output separate from our libtest status and totals.
|
||||
println!(" {line}");
|
||||
}
|
||||
}
|
||||
Err(error) => println!("{error:#}"),
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"\ntest result: {}. {passed} passed; {failed} failed; {skipped} ignored; 0 measured; 0 filtered out",
|
||||
if failed == 0 { "ok" } else { "FAILED" }
|
||||
);
|
||||
Ok(failed == 0)
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
//! Grace periods, service restarts, and retirement of replaced versions (PROTOCOL.md section 3.7).
|
||||
use crate::Host;
|
||||
use serde_json::json;
|
||||
use std::{collections::BTreeMap, sync::atomic::Ordering, time::Duration};
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// Uniform host policy (PROTOCOL 3.7), independent of discovery metadata.
|
||||
const UPGRADE_GRACE: Duration = Duration::from_secs(5);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(crate) struct OldProcess {
|
||||
pid: String,
|
||||
version: String,
|
||||
ended: watch::Receiver<bool>,
|
||||
}
|
||||
pub(crate) struct Upgrade {
|
||||
plugin: String,
|
||||
previous: String,
|
||||
current: String,
|
||||
processes: Vec<OldProcess>,
|
||||
}
|
||||
impl Host {
|
||||
/// Snapshots old admissions while activation holds the admission write guard.
|
||||
pub(crate) fn snapshot_upgrade(
|
||||
&self,
|
||||
plugin: &str,
|
||||
previous: String,
|
||||
current: &str,
|
||||
) -> Upgrade {
|
||||
let mut state = self.0.state.lock().unwrap();
|
||||
let processes = state
|
||||
.processes
|
||||
.iter_mut()
|
||||
.filter(|(_, process)| {
|
||||
process.info.plugin == plugin
|
||||
&& process.info.version != current
|
||||
&& process.info.method != "rpc.discover"
|
||||
})
|
||||
.map(|(pid, process)| {
|
||||
// The service supervisor owns the restart, including an exit on
|
||||
// the superseded notice before the grace timer is installed.
|
||||
process.superseded = !process.stopped;
|
||||
OldProcess {
|
||||
pid: pid.clone(),
|
||||
version: process.info.version.clone(),
|
||||
ended: process.ended.subscribe(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// A cleanly returned service is dormant until an upgrade. A pending
|
||||
// admission will choose the current version itself. Disabled services
|
||||
// (explicit stop or crash-limit give-up) must never be re-armed here.
|
||||
for service in &mut state.services {
|
||||
if service.enabled
|
||||
&& !service.pending
|
||||
&& service.owner.is_none()
|
||||
&& service.version.as_deref() != Some(current)
|
||||
&& service
|
||||
.method
|
||||
.split_once('.')
|
||||
.is_some_and(|(name, _)| name == plugin)
|
||||
{
|
||||
self.queue_service(service);
|
||||
}
|
||||
}
|
||||
Upgrade {
|
||||
plugin: plugin.into(),
|
||||
previous,
|
||||
current: current.into(),
|
||||
processes,
|
||||
}
|
||||
}
|
||||
/// Lets old calls finish, stops them after grace, and starts each lifecycle hook once.
|
||||
pub(crate) fn begin_upgrade(&self, upgrade: Upgrade) {
|
||||
// Queue the notice before returning from activation, without waiting on guest code.
|
||||
for process in &upgrade.processes {
|
||||
self.send(
|
||||
&process.pid,
|
||||
json!({"jsonrpc":"2.0","method":"rpc.superseded","params":{"current":upgrade.current}}),
|
||||
);
|
||||
}
|
||||
let host = self.clone();
|
||||
tokio::spawn(async move {
|
||||
if host.0.shutdown.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
host.0.journal.write(
|
||||
"core",
|
||||
&upgrade.plugin,
|
||||
"core",
|
||||
&format!("upgrade grace {} seconds", UPGRADE_GRACE.as_secs()),
|
||||
);
|
||||
let mut versions: BTreeMap<String, Vec<watch::Receiver<bool>>> = BTreeMap::new();
|
||||
versions.entry(upgrade.previous).or_default();
|
||||
for process in upgrade.processes {
|
||||
versions
|
||||
.entry(process.version)
|
||||
.or_default()
|
||||
.push(process.ended.clone());
|
||||
let host = host.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut ended = process.ended;
|
||||
tokio::select! {
|
||||
_ = ended.wait_for(|ended|*ended) => (),
|
||||
_ = tokio::time::sleep(UPGRADE_GRACE) => { host.begin_retire(&process.pid); }
|
||||
}
|
||||
});
|
||||
}
|
||||
for (version, processes) in versions {
|
||||
let host = host.clone();
|
||||
let plugin = upgrade.plugin.clone();
|
||||
let retirement = (plugin.clone(), version.clone());
|
||||
if !host
|
||||
.0
|
||||
.retirements
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(retirement.clone())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
for mut ended in processes {
|
||||
let _ = ended.wait_for(|ended| *ended).await;
|
||||
}
|
||||
loop {
|
||||
if host.0.shutdown.load(Ordering::SeqCst) {
|
||||
host.0.retirements.lock().unwrap().remove(&retirement);
|
||||
return;
|
||||
}
|
||||
let live: Vec<_> = host
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.values()
|
||||
.filter(|process| {
|
||||
process.info.plugin == plugin
|
||||
&& process.info.version == version
|
||||
&& process.info.method != "rpc.discover"
|
||||
})
|
||||
.map(|process| process.ended.subscribe())
|
||||
.collect();
|
||||
if live.is_empty() {
|
||||
break;
|
||||
}
|
||||
for mut ended in live {
|
||||
let _ = ended.wait_for(|ended| *ended).await;
|
||||
}
|
||||
}
|
||||
if host
|
||||
.0
|
||||
.graph
|
||||
.lock()
|
||||
.unwrap()
|
||||
.plugins
|
||||
.get(&plugin)
|
||||
.is_some_and(|current| current.hash == version)
|
||||
{
|
||||
host.0.retirements.lock().unwrap().remove(&retirement);
|
||||
return;
|
||||
}
|
||||
host.0.journal.write(
|
||||
"core",
|
||||
&plugin,
|
||||
"core",
|
||||
&format!("retired start {version}"),
|
||||
);
|
||||
let result = host
|
||||
.route(
|
||||
"core".into(),
|
||||
Some(json!(uuid::Uuid::now_v7().to_string())),
|
||||
format!("{plugin}.rpc.retired"),
|
||||
json!({"previous":version}),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
host.0.journal.write(
|
||||
"core",
|
||||
&plugin,
|
||||
"core",
|
||||
&format!("retired result {result:?}"),
|
||||
);
|
||||
host.0.retirements.lock().unwrap().remove(&retirement);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
//! Controlled-clock coverage of both upgrade windows and discovery exclusion.
|
||||
use super::*;
|
||||
use crate::{
|
||||
process::{Process, ProcessInfo},
|
||||
runtime::Runtime,
|
||||
world::World,
|
||||
};
|
||||
use tokio::{
|
||||
sync::mpsc,
|
||||
task::JoinHandle,
|
||||
time::{Instant, advance},
|
||||
};
|
||||
|
||||
/// A process with active work that ignores both superseded and EOF.
|
||||
fn process(
|
||||
host: &Host,
|
||||
pid: &str,
|
||||
method: &str,
|
||||
) -> (mpsc::UnboundedReceiver<serde_json::Value>, JoinHandle<()>) {
|
||||
let (input, incoming) = mpsc::unbounded_channel();
|
||||
let task = tokio::spawn(std::future::pending());
|
||||
host.0.state.lock().unwrap().processes.insert(
|
||||
pid.into(),
|
||||
Process {
|
||||
info: ProcessInfo {
|
||||
plugin: "notes".into(),
|
||||
version: "old".into(),
|
||||
method: method.into(),
|
||||
params: json!({}),
|
||||
parent: None,
|
||||
detached: true,
|
||||
start_time: 0,
|
||||
state: "running".into(),
|
||||
},
|
||||
memory: sausage_vfs::Memory::default(),
|
||||
files: sausage_vfs::Process::default(),
|
||||
store_ended: watch::channel(true).0,
|
||||
input: Some(input),
|
||||
abort: Some(task.abort_handle()),
|
||||
caller: "console".into(),
|
||||
id: None,
|
||||
answer: None,
|
||||
ended: watch::channel(false).0,
|
||||
stopped: false,
|
||||
superseded: false,
|
||||
},
|
||||
);
|
||||
(incoming, task)
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.7/3.6: five seconds running, then five seconds stopping; discovery is untouched.
|
||||
#[tokio::test]
|
||||
async fn two_five_second_windows_and_discovery_exclusion() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("world");
|
||||
World::create(&root).unwrap();
|
||||
let host = Host::test_world(&root, Runtime::new(&dir.path().join("cache")).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let (mut input, work) = process(&host, "old-work", "work");
|
||||
let (mut discovery_input, discovery) = process(&host, "old-discovery", "rpc.discover");
|
||||
let upgrade = host.snapshot_upgrade("notes", "old".into(), "new");
|
||||
assert_eq!(upgrade.processes.len(), 1);
|
||||
tokio::time::pause();
|
||||
let start = Instant::now();
|
||||
host.begin_upgrade(upgrade);
|
||||
assert_eq!(
|
||||
input.recv().await.unwrap(),
|
||||
json!({"jsonrpc":"2.0","method":"rpc.superseded","params":{"current":"new"}})
|
||||
);
|
||||
// Run the upgrade dispatcher, then its per-process task, so the deadline is armed.
|
||||
tokio::task::yield_now().await;
|
||||
tokio::task::yield_now().await;
|
||||
let window = Duration::from_secs(5);
|
||||
assert_eq!(UPGRADE_GRACE, window);
|
||||
// tokio::time::sleep documents millisecond granularity; the paused driver
|
||||
// may round a deadline up by one tick. Check each window independently.
|
||||
let tick = Duration::from_millis(1);
|
||||
let before_deadline = window - tick;
|
||||
advance(before_deadline).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(host.processes()["old-work"].state, "running");
|
||||
assert!(!work.is_finished());
|
||||
advance(tick).await;
|
||||
assert!(
|
||||
input.recv().await.is_none(),
|
||||
"EOF at grace, even with active work"
|
||||
);
|
||||
let stopped = Instant::now();
|
||||
assert!((window..=window + tick).contains(&(stopped - start)));
|
||||
assert_eq!(host.processes()["old-work"].state, "stopping");
|
||||
// begin_retire schedules the independent PROTOCOL 3.6 deadline.
|
||||
tokio::task::yield_now().await;
|
||||
advance(before_deadline).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert!(
|
||||
!work.is_finished(),
|
||||
"on_stop still has its full five seconds"
|
||||
);
|
||||
advance(tick).await;
|
||||
assert!(work.await.unwrap_err().is_cancelled());
|
||||
assert!((window..=window + tick).contains(&(Instant::now() - stopped)));
|
||||
assert_eq!(host.processes()["old-discovery"].state, "running");
|
||||
assert!(matches!(
|
||||
discovery_input.try_recv(),
|
||||
Err(mpsc::error::TryRecvError::Empty)
|
||||
));
|
||||
assert!(!discovery.is_finished());
|
||||
discovery.abort();
|
||||
let _ = discovery.await;
|
||||
// Synthetic processes have no execution cleanup task; discard only this test table.
|
||||
host.0.shutdown.store(true, Ordering::SeqCst);
|
||||
host.0.state.lock().unwrap().processes.clear();
|
||||
host.stop().await;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
//! Background discovery and nonrecursive notifications for installed plugin versions.
|
||||
use crate::Host;
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
impl Host {
|
||||
/// Watches only the plugin root and its immediate folders. Discovery survives watcher failure.
|
||||
pub(crate) fn watch_plugins(&self) -> anyhow::Result<()> {
|
||||
let root = self.0.world.root.join("plugins");
|
||||
let watched_root = root.clone();
|
||||
let (events, mut incoming) = tokio::sync::mpsc::unbounded_channel();
|
||||
let setup = (|| -> anyhow::Result<_> {
|
||||
let mut watcher =
|
||||
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
|
||||
if let Ok(event) = event
|
||||
&& !matches!(event.kind, notify::EventKind::Access(_))
|
||||
&& event.paths.iter().any(|path| {
|
||||
path.parent() == Some(&watched_root)
|
||||
|| path.file_name().is_some_and(|name| name == "plugin.wasm")
|
||||
})
|
||||
{
|
||||
let _ = events.send(());
|
||||
}
|
||||
})?;
|
||||
watcher.watch(&root, RecursiveMode::NonRecursive)?;
|
||||
for entry in std::fs::read_dir(&root)? {
|
||||
let entry = entry?;
|
||||
if entry.file_type()?.is_dir() {
|
||||
watcher.watch(&entry.path(), RecursiveMode::NonRecursive)?;
|
||||
}
|
||||
}
|
||||
Ok(watcher)
|
||||
})();
|
||||
match setup {
|
||||
Ok(watcher) => *self.0.watcher.lock().unwrap() = Some(watcher),
|
||||
Err(error) => {
|
||||
self.0
|
||||
.journal
|
||||
.write("core", "core", "core", &format!("watcher failed {error}"))
|
||||
}
|
||||
}
|
||||
let weak = std::sync::Arc::downgrade(&self.0);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Some(inner) = weak.upgrade() else {
|
||||
break;
|
||||
};
|
||||
let host = Host(inner);
|
||||
// Root events also cover plugin folders created after startup.
|
||||
if let Some(watcher) = host.0.watcher.lock().unwrap().as_mut()
|
||||
&& let Ok(entries) = std::fs::read_dir(&root)
|
||||
{
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().is_ok_and(|kind| kind.is_dir())
|
||||
&& let Err(error) =
|
||||
watcher.watch(&entry.path(), RecursiveMode::NonRecursive)
|
||||
{
|
||||
host.0.journal.write(
|
||||
"core",
|
||||
"core",
|
||||
"core",
|
||||
&format!("watcher failed {error}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _activation = host.0.activations.lock().await;
|
||||
if let Err(error) = host.refresh_graph().await {
|
||||
host.0.journal.write(
|
||||
"core",
|
||||
"core",
|
||||
"core",
|
||||
&format!("discovery failed {error}"),
|
||||
);
|
||||
}
|
||||
drop(_activation);
|
||||
drop(host);
|
||||
if incoming.recv().await.is_none() {
|
||||
break;
|
||||
}
|
||||
while incoming.try_recv().is_ok() {}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
//! Armed plugin-file watches. A Notify permit coalesces changes without losing a later change.
|
||||
use crate::{Host, RpcResult, error};
|
||||
use sausage_vfs::observe::under;
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use tokio::sync::Notify;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) struct Watch {
|
||||
path: String,
|
||||
changed: Arc<Notify>,
|
||||
}
|
||||
pub(crate) type Watches = Arc<Mutex<HashMap<Uuid, Watch>>>;
|
||||
pub(crate) fn changed(watches: &Watches, path: &str) {
|
||||
let targets: Vec<_> = watches
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
// Removing/moving an ancestor also invalidates a watch inside that subtree.
|
||||
.filter(|w| under(path, &w.path) || under(&w.path, path))
|
||||
.map(|w| w.changed.clone())
|
||||
.collect();
|
||||
for changed in targets {
|
||||
changed.notify_one();
|
||||
}
|
||||
}
|
||||
struct Registration {
|
||||
watches: Watches,
|
||||
key: Uuid,
|
||||
}
|
||||
impl Drop for Registration {
|
||||
fn drop(&mut self) {
|
||||
self.watches.lock().unwrap().remove(&self.key);
|
||||
}
|
||||
}
|
||||
fn path(caller: &str, path: &str) -> String {
|
||||
let path = sausage_vfs::resolve("/", path);
|
||||
let path = path.trim_end_matches('/');
|
||||
if path.is_empty() {
|
||||
return "/".into();
|
||||
}
|
||||
if under(path, "/self") {
|
||||
format!("/proc/{caller}{}", &path[5..])
|
||||
} else {
|
||||
path.into()
|
||||
}
|
||||
}
|
||||
impl Host {
|
||||
pub(crate) async fn watch(&self, caller: &str, id: Option<Value>, target: &str) -> RpcResult {
|
||||
let id = id.ok_or_else(|| error(-32600, "watch needs a request ID"))?;
|
||||
let mut ended = self
|
||||
.0
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.processes
|
||||
.get(caller)
|
||||
.filter(|p| p.info.state == "running")
|
||||
.map(|p| p.ended.subscribe())
|
||||
.ok_or_else(|| error(-32600, "watch needs a running caller process"))?;
|
||||
let changed = Arc::new(Notify::new());
|
||||
let registration = Registration {
|
||||
watches: self.0.watches.clone(),
|
||||
key: Uuid::now_v7(),
|
||||
};
|
||||
self.0.watches.lock().unwrap().insert(
|
||||
registration.key,
|
||||
Watch {
|
||||
path: path(caller, target),
|
||||
changed: changed.clone(),
|
||||
},
|
||||
);
|
||||
self.call_notify(caller, &id, "armed", json!({}));
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = ended.wait_for(|ended| *ended) => return Ok(Value::Null),
|
||||
_ = changed.notified() => self.call_notify(caller, &id, "changed", json!({})),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// Pending changes coalesce; a change after consuming the last permit is retained.
|
||||
#[tokio::test]
|
||||
async fn coalescing_and_cleanup() {
|
||||
let watches = Watches::default();
|
||||
let n = Arc::new(Notify::new());
|
||||
let r = Registration {
|
||||
watches: watches.clone(),
|
||||
key: Uuid::now_v7(),
|
||||
};
|
||||
watches.lock().unwrap().insert(
|
||||
r.key,
|
||||
Watch {
|
||||
path: "/mailbox".into(),
|
||||
changed: n.clone(),
|
||||
},
|
||||
);
|
||||
changed(&watches, "/mailboxes/a");
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::ZERO, n.notified())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
changed(&watches, "/mailbox/a");
|
||||
changed(&watches, "/mailbox/b");
|
||||
n.notified().await;
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::ZERO, n.notified())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
changed(&watches, "/mailbox/c");
|
||||
n.notified().await;
|
||||
drop(r);
|
||||
assert!(watches.lock().unwrap().is_empty());
|
||||
changed(&watches, "/mailbox/d");
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::ZERO, n.notified())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn self_alias_and_normalization() {
|
||||
assert_eq!(path("writer", "/self/../self/a/"), "/proc/writer/a");
|
||||
assert_eq!(path("writer", "/selfish/a"), "/selfish/a");
|
||||
assert_eq!(path("writer", "/"), "/");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lifetime_tests {
|
||||
use super::*;
|
||||
/// The real caller's ended event releases the registration, without a cancellation RPC.
|
||||
#[tokio::test]
|
||||
async fn caller_death_removes_watch() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let runtime = crate::runtime::Runtime::new(&temp.path().join("cache")).unwrap();
|
||||
let host = Host::test_world(&temp.path().join("world"), runtime)
|
||||
.await
|
||||
.unwrap();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
host.register_native(
|
||||
"holder",
|
||||
Arc::new(move |ctx, _, _| {
|
||||
let tx = tx.clone();
|
||||
Box::pin(async move {
|
||||
tx.send(ctx.pid.clone()).unwrap();
|
||||
std::future::pending().await
|
||||
})
|
||||
}),
|
||||
);
|
||||
let h = host.clone();
|
||||
let task = tokio::spawn(async move { h.call("holder.hold", json!({})).await });
|
||||
let pid = rx.recv().await.unwrap();
|
||||
let mut watch = Box::pin(host.watch(&pid, Some(json!("watch")), "/mailbox"));
|
||||
assert!(
|
||||
watch
|
||||
.as_mut()
|
||||
.poll(&mut std::task::Context::from_waker(std::task::Waker::noop()))
|
||||
.is_pending()
|
||||
);
|
||||
assert_eq!(host.0.watches.lock().unwrap().len(), 1);
|
||||
host.call("core.kill", json!({"pid":pid})).await.unwrap();
|
||||
watch.await.unwrap();
|
||||
assert!(host.0.watches.lock().unwrap().is_empty());
|
||||
assert!(task.await.unwrap().is_err());
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
//! World folders and atomic build output helpers.
|
||||
use anyhow::{Result, bail};
|
||||
use std::path::{Path, PathBuf};
|
||||
#[derive(Clone)]
|
||||
/// The absolute host folder shared by all processes.
|
||||
pub struct World {
|
||||
/// The absolute path of the world on the host.
|
||||
pub root: PathBuf,
|
||||
}
|
||||
impl World {
|
||||
/// Validate reserved root names and resolve the world path.
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
for name in ["proc", "run", "self"] {
|
||||
if path.join(name).symlink_metadata().is_ok() {
|
||||
bail!("reserved world path: {name}");
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
root: path.canonicalize()?,
|
||||
})
|
||||
}
|
||||
/// Create the folders and empty boot configuration of a new world.
|
||||
pub fn create(path: &Path) -> Result<Self> {
|
||||
if path.exists() {
|
||||
bail!("world already exists: {}", path.display());
|
||||
}
|
||||
for dir in ["plugins", "state/core", "config"] {
|
||||
std::fs::create_dir_all(path.join(dir))?;
|
||||
}
|
||||
std::fs::write(path.join("config/boot.yaml"), "[]\n")?;
|
||||
Self::open(path)
|
||||
}
|
||||
/// Return the folder that contains a plugin component.
|
||||
pub fn plugin_dir(&self, name: &str) -> PathBuf {
|
||||
self.root.join("plugins").join(name)
|
||||
}
|
||||
/// Return the persistent state folder of a plugin.
|
||||
pub fn state_dir(&self, name: &str) -> PathBuf {
|
||||
self.root.join("state").join(name)
|
||||
}
|
||||
}
|
||||
/// Write a temporary sibling and rename it over the destination.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
std::fs::rename(tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
/// Replace a build copy with a source tree, without target folders.
|
||||
pub fn replace_source(from: &Path, to: &Path) -> Result<()> {
|
||||
if to.exists() {
|
||||
std::fs::remove_dir_all(to)?;
|
||||
}
|
||||
copy_tree(from, to)
|
||||
}
|
||||
fn copy_tree(from: &Path, to: &Path) -> Result<()> {
|
||||
std::fs::create_dir_all(to)?;
|
||||
for entry in std::fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
if entry.file_name() == "target" {
|
||||
continue;
|
||||
}
|
||||
if entry.file_type()?.is_dir() {
|
||||
copy_tree(&entry.path(), &to.join(entry.file_name()))?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), to.join(entry.file_name()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
[package]
|
||||
name = "cargo-c"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
sausage-plugin = "0.1"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
|
||||
[workspace]
|
||||
@@ -1,5 +0,0 @@
|
||||
// Compile C with the `cc` crate: natively for the unit test, and with the
|
||||
// WASI clang (`CC_wasm32_wasip2`) for the actor.
|
||||
fn main() {
|
||||
cc::Build::new().file("src/add.c").compile("add");
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
//! SDK plugin linked to a WASI C function.
|
||||
unsafe extern "C" {
|
||||
fn c_add(a: i32, b: i32) -> i32;
|
||||
}
|
||||
fn add(a: i32, b: i32) -> i32 {
|
||||
unsafe { c_add(a, b) }
|
||||
}
|
||||
struct Plugin;
|
||||
#[sausage_plugin::rpc]
|
||||
impl Plugin {
|
||||
/// Add two fixed numbers through C.
|
||||
fn sum(&self) -> sausage_plugin::Result<i32> {
|
||||
Ok(add(2, 3))
|
||||
}
|
||||
}
|
||||
fn main() {
|
||||
sausage_plugin::run(Plugin);
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn c_code_runs_under_wasi() {
|
||||
assert_eq!(super::add(2, 3), 5);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
missing_function();
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//! A build script descendant that stays alive until the native call ends.
|
||||
fn main() {
|
||||
std::process::Command::new("sh")
|
||||
.args(["-c", "echo $$ > child.pid; exec sleep 1000"])
|
||||
.status()
|
||||
.unwrap();
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
fn main() {}
|
||||
@@ -1,6 +0,0 @@
|
||||
[package]
|
||||
name = "cargo-test-fail"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
@@ -1,13 +0,0 @@
|
||||
//! A panic must not prevent the runner from executing another test.
|
||||
fn main() {}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_panics() {
|
||||
panic!("deliberate WASI panic");
|
||||
}
|
||||
#[test]
|
||||
fn z_survives() {
|
||||
assert_eq!(2 + 2, 4);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "cargo-valid"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
sausage_plugin = { package = "sausage-plugin", version = "0.1", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["dep:sausage_plugin"]
|
||||
|
||||
[workspace]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
@@ -1,4 +0,0 @@
|
||||
//! Produces a warning so the build response can retain diagnostics.
|
||||
fn main() {
|
||||
println!("cargo:warning=fixture build warning");
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
fixture
|
||||
@@ -1,165 +0,0 @@
|
||||
//! SDK plugin and tests for the native build pipeline.
|
||||
struct Plugin;
|
||||
#[sausage_plugin::rpc]
|
||||
impl Plugin {
|
||||
/// Return a greeting.
|
||||
fn hello(&self) -> sausage_plugin::Result<String> {
|
||||
Ok("hello".into())
|
||||
}
|
||||
}
|
||||
fn main() {
|
||||
sausage_plugin::run(Plugin);
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn arithmetic() {
|
||||
assert_eq!(2 + 2, 4);
|
||||
}
|
||||
#[test]
|
||||
fn fixture_at_manifest_path() {
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/fixture.txt")).unwrap(),
|
||||
"fixture\n"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn current_directory() {
|
||||
println!(
|
||||
"WASI_CURRENT_DIR={}",
|
||||
std::env::current_dir().unwrap().display()
|
||||
);
|
||||
}
|
||||
#[sausage_plugin::test]
|
||||
async fn sdk_lock() {
|
||||
let guard = sausage_plugin::lock("/state/lock").await.unwrap();
|
||||
assert!(guard.held());
|
||||
let locks: sausage_plugin::serde_json::Value = sausage_plugin::serde_json::from_str(
|
||||
&std::fs::read_to_string("/proc/core/locks.json").unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(locks.as_object().unwrap().len(), 1);
|
||||
assert!(locks.get("/state/lock").is_some());
|
||||
let mut guard = Some(guard);
|
||||
let mut waiting = Box::pin(std::future::IntoFuture::into_future(sausage_plugin::lock(
|
||||
"/state/lock",
|
||||
)));
|
||||
let acquired = std::future::poll_fn(|context| {
|
||||
let result = std::future::Future::poll(waiting.as_mut(), context);
|
||||
if result.is_pending() {
|
||||
drop(guard.take());
|
||||
}
|
||||
result
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(acquired.held());
|
||||
let logs = sausage_plugin::call("core.log", sausage_plugin::serde_json::json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(logs.is_array());
|
||||
sausage_plugin::call(
|
||||
"core.notify",
|
||||
sausage_plugin::serde_json::json!({"name":"progress","params":{}}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
#[sausage_plugin::test]
|
||||
async fn other_plugin_fails() {
|
||||
let error = sausage_plugin::call("missing.method", sausage_plugin::serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("a test has no other plugins"));
|
||||
}
|
||||
#[sausage_plugin::test]
|
||||
async fn unlock_order() {
|
||||
for iteration in 0..300 {
|
||||
let guard = sausage_plugin::lock("/state/order").await.unwrap();
|
||||
assert!(guard.held());
|
||||
drop(guard);
|
||||
let guard = sausage_plugin::lock("/state/order")
|
||||
.try_now()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(guard.held(), "iteration {iteration}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn empty_world() {
|
||||
for path in [
|
||||
"/plugins",
|
||||
"/config",
|
||||
"/state",
|
||||
"/state/core/journal.sqlite",
|
||||
"/logs/core/journal.sqlite",
|
||||
] {
|
||||
assert!(!std::path::Path::new(path).exists(), "{path}");
|
||||
}
|
||||
let names: Vec<_> = std::fs::read_dir("/")
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name())
|
||||
.collect();
|
||||
// The runner mounts the package folder at its host path, so the first
|
||||
// folder of that path also appears in `/` (for example `tmp` or `build`).
|
||||
let package = env!("CARGO_MANIFEST_DIR")
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap();
|
||||
assert!(
|
||||
names.iter().all(|name| {
|
||||
["proc", "self", "run", package]
|
||||
.iter()
|
||||
.any(|allowed| name == allowed)
|
||||
}),
|
||||
"{names:?}"
|
||||
);
|
||||
for path in ["/proc", "/self", "/run"] {
|
||||
assert!(std::path::Path::new(path).is_dir());
|
||||
}
|
||||
assert!(std::fs::write("/proc/forbidden", "no").is_err());
|
||||
}
|
||||
fn isolated() {
|
||||
assert!(!std::path::Path::new("/self/own").exists());
|
||||
assert!(!std::path::Path::new("/run/own").exists());
|
||||
std::fs::write("/self/own", "self").unwrap();
|
||||
std::fs::write("/run/own", "run").unwrap();
|
||||
assert_eq!(std::fs::read_to_string("/self/own").unwrap(), "self");
|
||||
}
|
||||
#[test]
|
||||
fn isolation_one() {
|
||||
isolated();
|
||||
}
|
||||
#[test]
|
||||
fn isolation_two() {
|
||||
isolated();
|
||||
}
|
||||
#[test]
|
||||
fn environment() {
|
||||
for (key, _) in std::env::vars() {
|
||||
assert!(
|
||||
["CARGO_", "RUST_", "SAUSAGE_"]
|
||||
.iter()
|
||||
.any(|prefix| key.starts_with(prefix)),
|
||||
"{key}"
|
||||
);
|
||||
}
|
||||
assert!(std::env::var("PWD").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn late_response() {
|
||||
use std::io::Write;
|
||||
for id in 0..100 {
|
||||
println!(
|
||||
"\n{{\"jsonrpc\":\"2.0\",\"id\":{id},\"method\":\"core.log\",\"params\":{{}}}}"
|
||||
);
|
||||
}
|
||||
std::io::stdout().flush().unwrap();
|
||||
}
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn ignored_case() {
|
||||
assert_eq!(1, 1);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//! Checks that cargo invokes integration test binaries through the WASI runner.
|
||||
#[test]
|
||||
fn integration_case() {
|
||||
println!("INTEGRATION_CASE");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,669 +0,0 @@
|
||||
//! Run the agent in the real core against scripted OpenAI and Anthropic HTTP servers.
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper::{Response, server::conn::http1, service::service_fn};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use sausage::{Host, world::World};
|
||||
use sausage_vfs::Layer;
|
||||
use serde_json::{Value, json};
|
||||
use std::{convert::Infallible, path::PathBuf, sync::OnceLock, time::Duration};
|
||||
use tokio::{
|
||||
net::TcpListener,
|
||||
sync::{mpsc, oneshot},
|
||||
};
|
||||
|
||||
/// The two HTTP contracts supported by the agent.
|
||||
const APIS: [&str; 3] = [
|
||||
"openai-responses",
|
||||
"anthropic-messages",
|
||||
"openai-chat-completions",
|
||||
];
|
||||
/// A captured request and the script's delayed HTTP response.
|
||||
type Exchange = (String, Value, oneshot::Sender<(u16, Value)>);
|
||||
/// The script controls each response, including concurrent requests.
|
||||
pub(super) struct Llm {
|
||||
pub(super) url: String,
|
||||
api: &'static str,
|
||||
requests: mpsc::UnboundedReceiver<Exchange>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Llm {
|
||||
pub(super) async fn new(api: &'static str) -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let (tx, requests) = mpsc::unbounded_channel::<Exchange>();
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let service =
|
||||
service_fn(move |request: hyper::Request<hyper::body::Incoming>| {
|
||||
let tx = tx.clone();
|
||||
async move {
|
||||
assert_eq!(request.method(), "POST");
|
||||
// The local Chat Completions fixture explicitly disables authentication.
|
||||
if api == APIS[2] {
|
||||
assert!(!request.headers().contains_key("authorization"));
|
||||
}
|
||||
let path = request.uri().path().to_string();
|
||||
let bytes = request.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = serde_json::from_slice(&bytes).unwrap();
|
||||
let (reply, answer) = oneshot::channel();
|
||||
tx.send((path, body, reply)).unwrap();
|
||||
let (status, body) = answer.await.unwrap();
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", "application/json")
|
||||
.body(Full::new(Bytes::from(body.to_string())))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
});
|
||||
http1::Builder::new()
|
||||
.serve_connection(TokioIo::new(stream), service)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
});
|
||||
Self {
|
||||
url,
|
||||
api,
|
||||
requests,
|
||||
task,
|
||||
}
|
||||
}
|
||||
pub(super) async fn next(&mut self) -> (Value, oneshot::Sender<(u16, Value)>) {
|
||||
let (path, body, reply) =
|
||||
tokio::time::timeout(Duration::from_secs(60), self.requests.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
path,
|
||||
if self.api == APIS[0] {
|
||||
"/v1/responses"
|
||||
} else if self.api == APIS[2] {
|
||||
"/v1/chat/completions"
|
||||
} else {
|
||||
"/v1/messages"
|
||||
}
|
||||
);
|
||||
assert_eq!(body["model"], "scripted-test");
|
||||
(body, reply)
|
||||
}
|
||||
pub(super) fn answer(&self, text: &str, calls: &[(&str, Value)]) -> Value {
|
||||
if self.api == APIS[2] {
|
||||
let calls: Vec<Value> = calls.iter().enumerate().map(|(n,(name,args))| json!({"id":format!("tool-{n}"),"type":"function","function":{"name":name,"arguments":args.to_string()}})).collect();
|
||||
json!({"choices":[{"message":{"role":"assistant","content":text,"tool_calls":calls}}]})
|
||||
} else if self.api == APIS[0] {
|
||||
let mut output =
|
||||
vec![json!({"type":"message","content":[{"type":"output_text","text":text}]})];
|
||||
for (n, (name, args)) in calls.iter().enumerate() {
|
||||
output.push(json!({"type":"function_call","call_id":format!("tool-{n}"),"name":name,"arguments":args.to_string()}));
|
||||
}
|
||||
json!({"status":"completed","output":output})
|
||||
} else {
|
||||
let mut content = vec![json!({"type":"text","text":text})];
|
||||
for (n, (name, args)) in calls.iter().enumerate() {
|
||||
content.push(
|
||||
json!({"type":"tool_use","id":format!("tool-{n}"),"name":name,"input":args}),
|
||||
);
|
||||
}
|
||||
json!({"content":content,"stop_reason":if calls.is_empty(){"end_turn"}else{"tool_use"}})
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Llm {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn fixtures() -> &'static PathBuf {
|
||||
static TARGET: OnceLock<PathBuf> = OnceLock::new();
|
||||
TARGET.get_or_init(|| {
|
||||
super::common::build("agent", "agent");
|
||||
super::common::sdk_probe().parent().unwrap().to_owned()
|
||||
})
|
||||
}
|
||||
struct TestWorld(PathBuf);
|
||||
impl TestWorld {
|
||||
fn new(server: &Llm) -> Self {
|
||||
let path = std::env::temp_dir().join(format!("sausage-agent-{}", uuid::Uuid::now_v7()));
|
||||
let world = World::create(&path).unwrap();
|
||||
for (plugin, binary) in [("agent", "agent"), ("probe", "sdk-probe")] {
|
||||
std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap();
|
||||
std::fs::copy(
|
||||
fixtures().join(format!("{binary}.wasm")),
|
||||
world.plugin_dir(plugin).join("plugin.wasm"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::write(path.join("config/agent.yaml"), serde_yaml_ng::to_string(&json!({"api":server.api,"base_url":if server.api == APIS[1] {server.url.clone()} else {format!("{}/v1",server.url)},"model":"scripted-test","token":if server.api == APIS[2] {""} else {"fake-token"},"allow_no_token":server.api == APIS[2]})).unwrap()).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
async fn start(&self) -> Host {
|
||||
Host::start_with_cache(
|
||||
&self.0,
|
||||
std::path::Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
fn history(&self) -> String {
|
||||
std::fs::read_to_string(self.0.join("state/agent/history.jsonl")).unwrap()
|
||||
}
|
||||
}
|
||||
impl Drop for TestWorld {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn chat(host: &Host, text: &str) -> tokio::task::JoinHandle<Value> {
|
||||
let host = host.clone();
|
||||
let text = text.to_owned();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(90),
|
||||
host.call("agent.chat", json!({"text":text})),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
fn input(body: &Value, api: &str) -> Value {
|
||||
body[if api == APIS[0] { "input" } else { "messages" }].clone()
|
||||
}
|
||||
fn outputs(body: &Value, api: &str) -> Vec<Value> {
|
||||
if api == APIS[2] {
|
||||
body["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|e| e["role"] == "tool")
|
||||
.map(|e| serde_json::from_str(e["content"].as_str().unwrap()).unwrap())
|
||||
.collect()
|
||||
} else if api == APIS[0] {
|
||||
body["input"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|e| e["type"] == "function_call_output")
|
||||
.map(|e| serde_json::from_str(e["output"].as_str().unwrap()).unwrap())
|
||||
.collect()
|
||||
} else {
|
||||
body["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.flat_map(|e| e["content"].as_array().unwrap())
|
||||
.filter(|e| e["type"] == "tool_result")
|
||||
.map(|e| serde_json::from_str(e["content"].as_str().unwrap()).unwrap())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalid configuration names the file and the field before any inference request.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unconfigured_chat_names_the_configuration() {
|
||||
let server = Llm::new(APIS[0]).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let path = world.0.join("config/agent.yaml");
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
let host = world.start().await;
|
||||
let error = host
|
||||
.call("agent.chat", json!({"text":"hello"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("/config/agent.yaml")
|
||||
);
|
||||
for (config, field) in [
|
||||
("", "api"),
|
||||
("api: unknown", "api"),
|
||||
("api: openai-responses", "base_url"),
|
||||
(
|
||||
"api: openai-responses\nbase_url: http://localhost/v1",
|
||||
"model",
|
||||
),
|
||||
(
|
||||
"api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: ''",
|
||||
"token",
|
||||
),
|
||||
(
|
||||
"api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: CHANGE_ME",
|
||||
"token",
|
||||
),
|
||||
(
|
||||
"api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: key\nthinking: hihg",
|
||||
"thinking",
|
||||
),
|
||||
] {
|
||||
std::fs::write(&path, config).unwrap();
|
||||
let error = host
|
||||
.call("agent.chat", json!({"text":"hello"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
let message = error["message"].as_str().unwrap();
|
||||
assert!(message.contains("/config/agent.yaml"), "{message}");
|
||||
assert!(message.contains(field), "{message}");
|
||||
}
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// All API shapes return final text and retain the complete history after a core restart.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn final_answers_and_history_survive_restart() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let turn = chat(&host, "first request alpha");
|
||||
let (body, reply) = server.next().await;
|
||||
assert!(
|
||||
input(&body, api)
|
||||
.to_string()
|
||||
.contains("first request alpha")
|
||||
);
|
||||
assert_eq!(body["tools"].as_array().unwrap().len(), 5);
|
||||
reply
|
||||
.send((200, server.answer("first answer alpha", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(turn.await.unwrap(), "first answer alpha");
|
||||
host.stop().await;
|
||||
let prefix = world.history();
|
||||
drop(host);
|
||||
let host = world.start().await;
|
||||
let turn = chat(&host, "second request beta");
|
||||
let (body, reply) = server.next().await;
|
||||
let text = input(&body, api).to_string();
|
||||
for expected in [
|
||||
"first request alpha",
|
||||
"first answer alpha",
|
||||
"second request beta",
|
||||
] {
|
||||
assert!(text.contains(expected));
|
||||
}
|
||||
reply
|
||||
.send((200, server.answer("second answer beta", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(turn.await.unwrap(), "second answer beta");
|
||||
assert!(world.history().starts_with(&prefix));
|
||||
let entries: Vec<Value> = world
|
||||
.history()
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
entries
|
||||
.iter()
|
||||
.map(|e| e["kind"].as_str().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
["user", "assistant", "user", "assistant"]
|
||||
);
|
||||
let doc = host.call("agent.rpc.discover", json!({})).await.unwrap();
|
||||
assert_eq!(doc["x-sausage-grace"], 600);
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Both API shapes execute every tool, return tool errors, and deliver interim text.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn tools_and_interim_notifications() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let mut events = host.subscribe();
|
||||
let turn = chat(&host, "exercise the tools");
|
||||
let (_, reply) = server.next().await;
|
||||
let calls = [
|
||||
(
|
||||
"call",
|
||||
json!({"method":"probe.add","params":{"a":4,"b":5},"detach":true}),
|
||||
),
|
||||
("read_file", json!({"path":"/proc/core/PROTOCOL.md"})),
|
||||
(
|
||||
"write_file",
|
||||
json!({"path":"/state/work/source/note.txt","content":"saved"}),
|
||||
),
|
||||
("list_dir", json!({"path":"/state/work/source"})),
|
||||
(
|
||||
"copy_dir",
|
||||
json!({"from":"/state/work/source","to":"/state/work/copied"}),
|
||||
),
|
||||
("read_file", json!({"path":"/state/work/missing"})),
|
||||
("call", json!({"method":"absent.run","params":{}})),
|
||||
("call", json!({"method":"probe.fail","params":{}})),
|
||||
];
|
||||
reply
|
||||
.send((200, server.answer("I will inspect the world.", &calls)))
|
||||
.unwrap();
|
||||
let (body, reply) = server.next().await;
|
||||
let results = outputs(&body, api);
|
||||
assert_eq!(results.len(), 8);
|
||||
assert_eq!(results[0], 9);
|
||||
assert!(results[1]["content"].as_str().unwrap().contains("JSON-RPC"));
|
||||
assert_eq!(results[2]["written"], "/state/work/source/note.txt");
|
||||
assert_eq!(results[3]["entries"][0]["name"], "note.txt");
|
||||
assert_eq!(results[4]["copied_files"], 1);
|
||||
assert!(results[5]["error"]["message"].is_string());
|
||||
assert!(results[6]["error"]["code"].is_number());
|
||||
assert_eq!(
|
||||
results[7]["error"],
|
||||
json!({"code":-32010,"message":"deliberate","data":{"diagnostic":"probe error details"}})
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(world.0.join("state/work/copied/note.txt")).unwrap(),
|
||||
"saved"
|
||||
);
|
||||
let event = tokio::time::timeout(Duration::from_secs(5), events.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(event["params"]["name"], "text");
|
||||
assert_eq!(
|
||||
event["params"]["params"]["text"],
|
||||
"I will inspect the world."
|
||||
);
|
||||
reply
|
||||
.send((200, server.answer("The tools finished.", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(turn.await.unwrap(), "The tools finished.");
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// The history lock holds the second chat until the complete first turn ends.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn concurrent_chats_serialize_complete_turns() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let first = chat(&host, "first concurrent request");
|
||||
let (_, reply) = server.next().await;
|
||||
let second = chat(&host, "second concurrent request");
|
||||
let view = host.filesystem().await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
let file = view
|
||||
.open("/proc/core/locks.json", sausage_vfs::Open::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let locks: Value =
|
||||
serde_json::from_slice(&file.read(0, 65536).await.unwrap()).unwrap();
|
||||
if locks["/state/agent/history.jsonl"]["waiting"]
|
||||
.as_array()
|
||||
.is_some_and(|waiting| waiting.len() == 1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(server.requests.try_recv().is_err());
|
||||
reply
|
||||
.send((200, server.answer("first concurrent answer", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(first.await.unwrap(), "first concurrent answer");
|
||||
let (body, reply) = server.next().await;
|
||||
let text = input(&body, api).to_string();
|
||||
assert!(text.contains("first concurrent answer"));
|
||||
assert!(text.contains("second concurrent request"));
|
||||
reply
|
||||
.send((200, server.answer("second concurrent answer", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(second.await.unwrap(), "second concurrent answer");
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Context-size failures discard old complete turns without changing the append-only history.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn context_window_advances_by_complete_turns() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let first = chat(&host, "old turn");
|
||||
let (_, reply) = server.next().await;
|
||||
reply.send((200, server.answer("old answer", &[]))).unwrap();
|
||||
first.await.unwrap();
|
||||
let second = chat(&host, "current turn");
|
||||
let (_, reply) = server.next().await;
|
||||
reply
|
||||
.send((400, json!({"error":"context_length_exceeded"})))
|
||||
.unwrap();
|
||||
let (body, reply) = server.next().await;
|
||||
let text = input(&body, api).to_string();
|
||||
assert!(!text.contains("old turn"));
|
||||
assert!(!text.contains("old answer"));
|
||||
assert!(text.contains("current turn"));
|
||||
reply
|
||||
.send((200, server.answer("current answer", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(second.await.unwrap(), "current answer");
|
||||
assert_eq!(world.history().lines().count(), 4);
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Oversized tool outputs are bounded, and failed retries do not commit a new history cursor.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn oversized_current_turn_preserves_the_previous_cursor() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
std::fs::write(world.0.join("large.txt"), "x".repeat(200_000)).unwrap();
|
||||
let host = world.start().await;
|
||||
let first = chat(&host, "old retained turn");
|
||||
let (_, reply) = server.next().await;
|
||||
reply
|
||||
.send((200, server.answer("old retained answer", &[])))
|
||||
.unwrap();
|
||||
first.await.unwrap();
|
||||
let skip = world.0.join("state/agent/history.skip");
|
||||
std::fs::write(&skip, "0").unwrap();
|
||||
let caller = host.clone();
|
||||
let turn = tokio::spawn(async move {
|
||||
caller
|
||||
.call("agent.chat", json!({"text":"read a large tool result"}))
|
||||
.await
|
||||
});
|
||||
let (_, reply) = server.next().await;
|
||||
reply
|
||||
.send((
|
||||
200,
|
||||
server.answer("", &[("read_file", json!({"path":"/large.txt"}))]),
|
||||
))
|
||||
.unwrap();
|
||||
let (body, reply) = server.next().await;
|
||||
let serialized = input(&body, api).to_string();
|
||||
assert!(serialized.contains("Full output: /logs/agent/"));
|
||||
let history = world.history();
|
||||
let result: Value = history
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str::<Value>(line).unwrap())
|
||||
.find(|entry| {
|
||||
entry["output"]
|
||||
.as_str()
|
||||
.is_some_and(|out| out.contains("Full output: "))
|
||||
})
|
||||
.unwrap();
|
||||
let output = result["output"].as_str().unwrap();
|
||||
let path = output
|
||||
.split("Full output: ")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split(". ")
|
||||
.next()
|
||||
.unwrap();
|
||||
let archive = std::fs::read_to_string(world.0.join(path.trim_start_matches('/'))).unwrap();
|
||||
assert!(archive.contains(&"x".repeat(200_000)));
|
||||
assert!(output.len() <= 50 * 1024);
|
||||
assert!(serialized.len() < 70_000);
|
||||
reply
|
||||
.send((413, json!({"type":"request_too_large"})))
|
||||
.unwrap();
|
||||
let (body, reply) = server.next().await;
|
||||
assert!(!input(&body, api).to_string().contains("old retained turn"));
|
||||
reply
|
||||
.send((413, json!({"type":"request_too_large"})))
|
||||
.unwrap();
|
||||
assert!(turn.await.unwrap().is_err());
|
||||
assert_eq!(std::fs::read_to_string(&skip).unwrap(), "0");
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider-private items are replayed only for their original API and model.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn private_items_are_scoped_to_the_model() {
|
||||
for api in [APIS[0], APIS[1]] {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
// Without a thinking level, newer models think by default, so their items return.
|
||||
std::fs::create_dir_all(world.0.join("state/agent")).unwrap();
|
||||
let mut entries = vec![json!({"kind":"user","text":"old turn"})];
|
||||
for (model, marker) in [
|
||||
("another-model", "old-private-marker"),
|
||||
("scripted-test", "current-private-marker"),
|
||||
] {
|
||||
let item = if api == APIS[0] {
|
||||
json!({"type":"reasoning","encrypted_content":marker})
|
||||
} else {
|
||||
json!({"type":"thinking","thinking":"private","signature":marker})
|
||||
};
|
||||
entries.push(json!({"kind":"opaque","api":api,"model":model,"item":item}));
|
||||
}
|
||||
entries.push(json!({"kind":"assistant","text":"old answer"}));
|
||||
std::fs::write(
|
||||
world.0.join("state/agent/history.jsonl"),
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| format!("{entry}\n"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
let host = world.start().await;
|
||||
let turn = chat(&host, "next turn");
|
||||
let (body, reply) = server.next().await;
|
||||
let text = input(&body, api).to_string();
|
||||
assert!(!text.contains("old-private-marker"));
|
||||
assert!(text.contains("current-private-marker"));
|
||||
reply.send((200, server.answer("done", &[]))).unwrap();
|
||||
assert_eq!(turn.await.unwrap(), "done");
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Each API gets the configured thinking level. A level in the call applies to that turn only.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn thinking_levels_reach_each_api() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let config_path = world.0.join("config/agent.yaml");
|
||||
let mut config: Value =
|
||||
serde_yaml_ng::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
|
||||
config["thinking"] = json!("high");
|
||||
std::fs::write(config_path, serde_yaml_ng::to_string(&config).unwrap()).unwrap();
|
||||
let host = world.start().await;
|
||||
// Off is `none` for OpenAI, and disabled thinking without an effort for Anthropic.
|
||||
let off = if api == APIS[1] { None } else { Some("none") };
|
||||
for (thinking, effort) in [
|
||||
(None, Some("high")),
|
||||
(Some("low"), Some("low")),
|
||||
(Some("off"), off),
|
||||
(None, Some("high")),
|
||||
] {
|
||||
let mut params = json!({"text":"hello"});
|
||||
if let Some(thinking) = thinking {
|
||||
params["thinking"] = json!(thinking);
|
||||
}
|
||||
let caller = host.clone();
|
||||
let turn =
|
||||
tokio::spawn(async move { caller.call("agent.chat", params).await.unwrap() });
|
||||
let (body, reply) = server.next().await;
|
||||
let sent = if api == APIS[0] {
|
||||
&body["reasoning"]["effort"]
|
||||
} else if api == APIS[2] {
|
||||
&body["reasoning_effort"]
|
||||
} else {
|
||||
let mode = if thinking == Some("off") {
|
||||
"disabled"
|
||||
} else {
|
||||
"adaptive"
|
||||
};
|
||||
assert_eq!(body["thinking"]["type"], mode, "{body}");
|
||||
&body["output_config"]["effort"]
|
||||
};
|
||||
assert_eq!(sent.as_str(), effort, "{api} {thinking:?}");
|
||||
reply.send((200, server.answer("done", &[]))).unwrap();
|
||||
assert_eq!(turn.await.unwrap(), "done");
|
||||
}
|
||||
let error = host
|
||||
.call("agent.chat", json!({"text":"hello","thinking":"hihg"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error["code"], -32602, "{error}");
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate assistant messages retain a separator in the final answer.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn final_texts_have_a_separator() {
|
||||
for api in [APIS[0], APIS[1]] {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let turn = chat(&host, "two paragraphs");
|
||||
let (_, reply) = server.next().await;
|
||||
let mut answer = server.answer("first", &[]);
|
||||
if api == APIS[0] {
|
||||
answer["output"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!({"type":"message","content":[{"type":"output_text","text":"second"}]}));
|
||||
} else {
|
||||
answer["content"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!({"type":"text","text":"second"}));
|
||||
}
|
||||
reply.send((200, answer)).unwrap();
|
||||
assert_eq!(turn.await.unwrap(), "first\n\nsecond");
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// An incomplete response containing reasoning but no text is an error.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn incomplete_without_text_is_an_error() {
|
||||
let mut server = Llm::new(APIS[0]).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let caller = host.clone();
|
||||
let turn =
|
||||
tokio::spawn(async move { caller.call("agent.chat", json!({"text":"answer"})).await });
|
||||
let (_, reply) = server.next().await;
|
||||
reply.send((200, json!({"status":"incomplete","output":[{"type":"reasoning","encrypted_content":"private"}]}))).unwrap();
|
||||
assert!(
|
||||
turn.await.unwrap().unwrap_err()["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("incomplete response")
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,450 +0,0 @@
|
||||
//! Binary contracts for atomic init, environment flags, signals, and the piped console.
|
||||
use sausage::{Host, world::World};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::AsyncWriteExt,
|
||||
process::{Child, Command},
|
||||
};
|
||||
|
||||
use super::common::{CACHE, probe};
|
||||
|
||||
fn binary() -> Command {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_sausage"));
|
||||
command
|
||||
.arg("--cache-dir")
|
||||
.arg(CACHE)
|
||||
.env_remove("SAUSAGE_HEADLESS")
|
||||
.env_remove("SAUSAGE_LOOP")
|
||||
.kill_on_drop(true);
|
||||
command
|
||||
}
|
||||
fn world() -> (tempfile::TempDir, PathBuf) {
|
||||
let folder = tempfile::tempdir().unwrap();
|
||||
let world = World::create(&folder.path().join("world")).unwrap();
|
||||
(folder, world.root)
|
||||
}
|
||||
async fn status(child: &mut Child) -> std::process::ExitStatus {
|
||||
tokio::time::timeout(Duration::from_secs(15), child.wait())
|
||||
.await
|
||||
.expect("CLI did not exit")
|
||||
.unwrap()
|
||||
}
|
||||
async fn journal_exists(world: &Path) {
|
||||
tokio::time::timeout(Duration::from_secs(15), async {
|
||||
while !world.join("logs/core/journal.sqlite").exists() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
fn signal(child: &Child, signal: nix::sys::signal::Signal) {
|
||||
nix::sys::signal::kill(
|
||||
nix::unistd::Pid::from_raw(child.id().unwrap() as i32),
|
||||
signal,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
fn install_probe(world: &Path) {
|
||||
std::fs::create_dir_all(world.join("plugins/probe")).unwrap();
|
||||
std::fs::copy(probe(), world.join("plugins/probe/plugin.wasm")).unwrap();
|
||||
}
|
||||
|
||||
/// Failed init leaves no world; successful init seeds the restart policy and a working agent.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn init_builds_agent() {
|
||||
tokio::time::timeout(Duration::from_secs(300), init_builds_agent_inner())
|
||||
.await
|
||||
.expect("init and seeded-agent checks exceeded 300 seconds");
|
||||
}
|
||||
|
||||
async fn init_builds_agent_inner() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let world = temporary.path().join("world");
|
||||
// Legacy configuration variables must not affect the templates.
|
||||
let configured = || {
|
||||
let mut command = binary();
|
||||
command
|
||||
.current_dir(temporary.path())
|
||||
.env("SAUSAGE_API", "not-an-api")
|
||||
.env("SAUSAGE_BASE_URL", "http://127.0.0.1:1/")
|
||||
.env("SAUSAGE_MODEL", "init-test")
|
||||
.env("SAUSAGE_API_TOKEN", "fake-token")
|
||||
.stdin(Stdio::null());
|
||||
command
|
||||
};
|
||||
let failed = configured()
|
||||
.arg("init")
|
||||
.env("SAUSAGE_HEADLESS", "not-a-boolean")
|
||||
.env("RUSTC", "/nonexistent-sausage-rustc")
|
||||
.output()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!failed.status.success());
|
||||
assert!(String::from_utf8_lossy(&failed.stderr).contains("nonexistent-sausage-rustc"));
|
||||
assert!(!world.exists());
|
||||
let output = configured().arg("init").output().await.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stdout).contains("first agent build takes a few minutes")
|
||||
);
|
||||
// Bare sausage reopens the completed world instead of trying to recreate it.
|
||||
assert!(configured().output().await.unwrap().status.success());
|
||||
let prompt = std::fs::read_to_string(world.join("plugins/agent/source/src/prompt.md")).unwrap();
|
||||
assert!(prompt.contains("HARD RULE: the mediator and all agents NEVER call `core.restart`"));
|
||||
assert!(prompt.contains("without the person's explicit approval each time"));
|
||||
assert!(prompt.contains("alias `core.exit`"));
|
||||
for text in [
|
||||
"`#loop`",
|
||||
"SAUSAGE_LOOP=1",
|
||||
"answers before",
|
||||
"exit code 75",
|
||||
] {
|
||||
assert!(prompt.contains(text), "seeded prompt missing {text}");
|
||||
}
|
||||
let boot = std::fs::read_to_string(world.join("config/boot.yaml")).unwrap();
|
||||
assert!(boot.contains("SAUSAGE_LOOP=1"));
|
||||
let mut config: Value =
|
||||
serde_yaml_ng::from_slice(&std::fs::read(world.join("config/agent.yaml")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(config["token"], "CHANGE_ME");
|
||||
assert_eq!(config["api"], "openai-responses");
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("config/agent.yaml"));
|
||||
assert_eq!(
|
||||
serde_yaml_ng::from_slice::<Value>(&std::fs::read(world.join("config/boot.yaml")).unwrap())
|
||||
.unwrap(),
|
||||
json!([])
|
||||
);
|
||||
for file in [
|
||||
"plugins/cargo/sdk/Cargo.toml",
|
||||
"plugins/cargo/sdk/src/lib.rs",
|
||||
"plugins/agent/source/Cargo.toml",
|
||||
"plugins/agent/source/src/main.rs",
|
||||
"plugins/agent/plugin.wasm",
|
||||
] {
|
||||
assert!(world.join(file).is_file(), "missing {file}");
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(world.join("plugins/agent/source/Cargo.lock")).unwrap(),
|
||||
include_bytes!("../../../Cargo.lock")
|
||||
);
|
||||
assert!(!world.join("state/cargo/target").exists());
|
||||
assert!(
|
||||
Path::new(CACHE)
|
||||
.join("cargo-target/wasm32-wasip2/release")
|
||||
.is_dir()
|
||||
);
|
||||
let host = Host::start_with_cache(&world, Path::new(CACHE))
|
||||
.await
|
||||
.unwrap();
|
||||
let doc = host.call("agent.rpc.discover", json!({})).await.unwrap();
|
||||
assert_eq!(doc["x-sausage-grace"], 600);
|
||||
assert!(
|
||||
doc["methods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|method| method["name"] == "chat")
|
||||
);
|
||||
let error = host
|
||||
.call("agent.chat", json!({"text":"hello"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("/config/agent.yaml")
|
||||
);
|
||||
assert!(error["message"].as_str().unwrap().contains("token"));
|
||||
let mut server = super::agent::Llm::new("anthropic-messages").await;
|
||||
config["api"] = json!("anthropic-messages");
|
||||
config["base_url"] = json!(server.url);
|
||||
config["model"] = json!("scripted-test");
|
||||
config["token"] = json!("fake-token");
|
||||
std::fs::write(
|
||||
world.join("config/agent.yaml"),
|
||||
serde_yaml_ng::to_string(&config).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let caller = host.clone();
|
||||
let turn =
|
||||
tokio::spawn(async move { caller.call("agent.chat", json!({"text":"hello"})).await });
|
||||
let (_, reply) = server.next().await;
|
||||
reply
|
||||
.send((200, server.answer("configured agent", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(turn.await.unwrap().unwrap(), "configured agent");
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// Both headless forms ignore EOF. False and empty values preserve the plain console.
|
||||
#[tokio::test]
|
||||
async fn headless_flag_and_environment() {
|
||||
for value in [None, Some("1"), Some("true")] {
|
||||
let (_folder, world) = world();
|
||||
let mut command = binary();
|
||||
command.arg("run").arg(&world).stdin(Stdio::null());
|
||||
if let Some(value) = value {
|
||||
command.env("SAUSAGE_HEADLESS", value);
|
||||
} else {
|
||||
command.arg("--headless");
|
||||
}
|
||||
let mut child = command.spawn().unwrap();
|
||||
journal_exists(&world).await;
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(300), child.wait())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
signal(&child, nix::sys::signal::Signal::SIGTERM);
|
||||
assert_eq!(status(&mut child).await.code(), Some(0));
|
||||
}
|
||||
for value in ["0", ""] {
|
||||
let (_folder, world) = world();
|
||||
let mut child = binary()
|
||||
.arg("run")
|
||||
.arg(world)
|
||||
.env("SAUSAGE_HEADLESS", value)
|
||||
.stdin(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
assert_eq!(status(&mut child).await.code(), Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Signals interrupt an unanswered boot service instead of waiting forever.
|
||||
#[tokio::test]
|
||||
async fn signals_work_during_boot() {
|
||||
for sig in [
|
||||
nix::sys::signal::Signal::SIGTERM,
|
||||
nix::sys::signal::Signal::SIGINT,
|
||||
] {
|
||||
let (_folder, world) = world();
|
||||
std::fs::write(
|
||||
world.join("config/boot.yaml"),
|
||||
json!([
|
||||
{"method":"core.lock","params":{"path":"boot"}},
|
||||
{"method":"core.lock","params":{"path":"boot"}}
|
||||
])
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut child = binary()
|
||||
.arg("run")
|
||||
.arg(&world)
|
||||
.arg("--headless")
|
||||
.stdin(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
journal_exists(&world).await;
|
||||
tokio::time::timeout(Duration::from_secs(15), async {
|
||||
loop {
|
||||
let db =
|
||||
rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
|
||||
if let Ok(2) = db.query_row(
|
||||
"SELECT count(*) FROM journal WHERE line='service start core.lock'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
) {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
signal(&child, sig);
|
||||
assert_eq!(status(&mut child).await.code(), Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Piped input supports commands, quit, and the restart exit code.
|
||||
#[tokio::test]
|
||||
async fn piped_console_and_exit_codes() {
|
||||
for (line, supervised, expected) in [
|
||||
("help\n", false, 0),
|
||||
("quit\n", false, 0),
|
||||
("core.restart\n", false, 0),
|
||||
("core.exit\n", false, 0),
|
||||
("core.restart\n", true, 75),
|
||||
("core.exit\n", true, 75),
|
||||
] {
|
||||
let (_folder, world) = world();
|
||||
let mut command = binary();
|
||||
command.arg("run").arg(world);
|
||||
if supervised {
|
||||
command.env("SAUSAGE_LOOP", "1");
|
||||
}
|
||||
let mut child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
// Supervised calls must finish via restart, not a racing console EOF.
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
input.write_all(line.as_bytes()).await.unwrap();
|
||||
if !supervised {
|
||||
drop(input);
|
||||
}
|
||||
let output = tokio::time::timeout(Duration::from_secs(15), child.wait_with_output())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(expected));
|
||||
if !supervised && line.starts_with("core.") {
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("SAUSAGE_LOOP not set"));
|
||||
}
|
||||
if expected == 0 {
|
||||
assert!(!output.stdout.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A console input error still stops live actors and flushes the journal.
|
||||
#[tokio::test]
|
||||
async fn console_error_stops_host() {
|
||||
let (_folder, world) = world();
|
||||
install_probe(&world);
|
||||
std::fs::write(
|
||||
world.join("config/boot.yaml"),
|
||||
json!([{"method":"probe.serve","params":{}}]).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut child = binary()
|
||||
.arg("run")
|
||||
.arg(&world)
|
||||
.stdin(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(&[255, 10])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status(&mut child).await.code(), Some(1));
|
||||
let db = rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
|
||||
let stopped: i64 = db
|
||||
.query_row(
|
||||
"SELECT count(*) FROM journal WHERE plugin='probe' AND line='stop'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(stopped > 0);
|
||||
}
|
||||
|
||||
/// SIGINT stops only the wait, so ps can still find the live call.
|
||||
#[tokio::test]
|
||||
async fn interrupt_leaves_call_running() {
|
||||
let (_folder, world) = world();
|
||||
install_probe(&world);
|
||||
let mut child = binary()
|
||||
.arg("run")
|
||||
.arg(&world)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
input.write_all(b"probe.busy\n").await.unwrap();
|
||||
let pid = tokio::time::timeout(Duration::from_secs(20), async {
|
||||
loop {
|
||||
if world.join("logs/core/journal.sqlite").exists() {
|
||||
let db =
|
||||
rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
|
||||
if let Ok(pid) = db.query_row(
|
||||
"SELECT pid FROM journal WHERE plugin='probe' AND line='start busy {}'",
|
||||
[],
|
||||
|r| r.get::<_, String>(0),
|
||||
) {
|
||||
break pid;
|
||||
}
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
signal(&child, nix::sys::signal::Signal::SIGINT);
|
||||
input
|
||||
.write_all(format!("ps\nkill {pid}\nquit\n").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
drop(input);
|
||||
let output = tokio::time::timeout(Duration::from_secs(20), child.wait_with_output())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let text = String::from_utf8(output.stdout).unwrap();
|
||||
assert_eq!(output.status.code(), Some(0), "{text}");
|
||||
assert!(
|
||||
text.contains("stopped waiting; the call continues, see ps"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(&format!("{pid} probe busy console running")),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Piped calls wait for activation, and EOF or quit preserves the last result.
|
||||
#[tokio::test]
|
||||
async fn piped_console_runs_dependent_commands_in_order() {
|
||||
for ending in ["", "quit\n"] {
|
||||
let (_folder, world) = world();
|
||||
std::fs::copy(probe(), world.join("candidate.wasm")).unwrap();
|
||||
std::fs::create_dir_all(world.join("state/probe")).unwrap();
|
||||
std::fs::write(world.join("state/probe/discover.json"), r#"{"methods":[]}"#).unwrap();
|
||||
let activate = r#"core.activate {"plugin":"notes","wasm":"candidate.wasm","force":true}"#;
|
||||
let call = r#"notes.echo {"ordered":true}"#;
|
||||
let mut child = binary()
|
||||
.arg("run")
|
||||
.arg(&world)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(format!("{activate}\n{call}\n{ending}").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
let output = tokio::time::timeout(Duration::from_secs(20), child.wait_with_output())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
assert_eq!(
|
||||
output.status.code(),
|
||||
Some(0),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(!stdout.contains("error:"), "{stdout}");
|
||||
assert!(stdout.contains(r#""ordered": true"#), "{stdout}");
|
||||
assert!(!stdout.contains("> core.activate"), "{stdout}");
|
||||
if !ending.is_empty() {
|
||||
assert!(
|
||||
stdout.find(r#""ordered": true"#).unwrap() < stdout.find("Goodbye.").unwrap(),
|
||||
"{stdout}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
//! Persistent caches and guest builds shared by the host integration modules.
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
pub const CACHE: &str = concat!(env!("CARGO_TARGET_TMPDIR"), "/cache");
|
||||
|
||||
pub fn build(package: &str, binary: &str) -> PathBuf {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let target = Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/guests"));
|
||||
assert!(
|
||||
std::process::Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--offline",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"--manifest-path"
|
||||
])
|
||||
.arg(root.join(package).join("Cargo.toml"))
|
||||
.arg("--target-dir")
|
||||
.arg(target)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
target
|
||||
.join("wasm32-wasip2/debug")
|
||||
.join(format!("{binary}.wasm"))
|
||||
}
|
||||
|
||||
pub fn probe() -> PathBuf {
|
||||
static WASM: OnceLock<PathBuf> = OnceLock::new();
|
||||
WASM.get_or_init(|| build("cli/tests/plugins/probe", "probe"))
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn sdk_probe() -> PathBuf {
|
||||
static WASM: OnceLock<PathBuf> = OnceLock::new();
|
||||
WASM.get_or_init(|| build("sdk/tests/plugins/probe", "sdk-probe"))
|
||||
.clone()
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
//! Console command contracts against a temporary world and the raw probe component.
|
||||
use super::common::probe as fixture;
|
||||
use sausage::{
|
||||
Host,
|
||||
console::{execute_line, notification_text},
|
||||
world::World,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
struct TestWorld(PathBuf);
|
||||
impl TestWorld {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!("sausage-console-{}", uuid::Uuid::now_v7()));
|
||||
let world = World::create(&root).unwrap();
|
||||
for plugin in ["probe", "agent"] {
|
||||
std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap();
|
||||
std::fs::copy(fixture(), world.plugin_dir(plugin).join("plugin.wasm")).unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(world.state_dir("probe")).unwrap();
|
||||
std::fs::write(world.state_dir("probe").join("discover.json"),json!({"methods":[{"name":"echo","description":"Echo supplied text.","params":[{"name":"text","description":"The input text.","schema":{"type":"string"}}],"result":{"schema":{"type":"string"}}}]}).to_string()).unwrap();
|
||||
let mut candidate = std::fs::read(fixture()).unwrap();
|
||||
// A custom section creates a new version without changing probe behavior.
|
||||
candidate.extend_from_slice(&[0, 2, 1, b'c']);
|
||||
std::fs::write(root.join("candidate.wasm"), candidate).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
async fn start(&self) -> Host {
|
||||
let cache = PathBuf::from(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache"));
|
||||
Host::start_with_cache(&self.0, &cache).await.unwrap()
|
||||
}
|
||||
}
|
||||
impl Drop for TestWorld {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
async fn command(host: &Host, line: &str) -> String {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(60), execute_line(host, line))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Explicit calls return indented JSON data, and RPC errors remain visible.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn rpc_call() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let result = command(&host, "probe.echo {\"answer\":42}").await;
|
||||
assert!(result.contains("\n \"answer\": 42\n"));
|
||||
assert!(command(&host, "absent.echo {}").await.contains("error:"));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&command(&host, r#"agent.chat {"text":"explicit"}"#).await)
|
||||
.unwrap(),
|
||||
json!({"text":"explicit"})
|
||||
);
|
||||
assert_eq!(
|
||||
command(&host, "probe.echo not-json").await,
|
||||
"probe.echo not-json"
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// Plain text calls agent.chat and its interim notification uses dim styling.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn chat() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let mut notifications = host.subscribe();
|
||||
assert_eq!(command(&host, "hello agent").await, "hello agent");
|
||||
let value = notifications.recv().await.unwrap();
|
||||
assert_eq!(notification_text(&value), "\x1b[2minterim\x1b[0m");
|
||||
assert_eq!(
|
||||
command(&host, "Hello. Another sentence.").await,
|
||||
"Hello. Another sentence."
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// Invalid command arguments and dotted prose remain agent chat.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn prose_is_not_a_command() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
for line in [
|
||||
"help me write a notes plugin",
|
||||
"ps is useful",
|
||||
"stop it",
|
||||
"kill that",
|
||||
"cat",
|
||||
"methods probe extra",
|
||||
"ls too many words",
|
||||
"log -n nope",
|
||||
"README.md says hello",
|
||||
"agent.chat not-json",
|
||||
"e.g. write notes",
|
||||
] {
|
||||
assert_eq!(command(&host, line).await, line);
|
||||
}
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// Folder listings include the live mounts and accept an explicit folder.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ls() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let result = command(&host, "ls").await;
|
||||
for name in ["proc/", "run/", "self/"] {
|
||||
assert!(result.contains(name));
|
||||
}
|
||||
assert!(command(&host, "ls /proc/core").await.contains("plugins/"));
|
||||
host.stop().await;
|
||||
}
|
||||
/// Cat reads multiple chunks and finite generated snapshots through the VFS.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn cat() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let text = "x".repeat(150_000);
|
||||
std::fs::write(world.0.join("large"), &text).unwrap();
|
||||
assert_eq!(command(&host, "cat /large").await, text);
|
||||
host.call("probe.write", json!({"path":"/run/shared","text":"memory"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(command(&host, "cat /run/shared").await, "memory");
|
||||
assert!(
|
||||
command(&host, "cat /proc/core/PROTOCOL.md")
|
||||
.await
|
||||
.contains("filesystem")
|
||||
);
|
||||
assert!(execute_line(&host, "cat /missing").await.is_err());
|
||||
host.stop().await;
|
||||
}
|
||||
/// Method summaries include parameter types, result types, and documentation.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn methods() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let result = command(&host, "methods probe").await;
|
||||
for text in [
|
||||
"probe.echo(text: string) -> string",
|
||||
"Echo supplied text.",
|
||||
"The input text.",
|
||||
] {
|
||||
assert!(result.contains(text), "{result}");
|
||||
}
|
||||
host.stop().await;
|
||||
}
|
||||
/// Ps includes every requested column and the live process metadata.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ps() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let pid = host.call("probe.serve", json!({})).await.unwrap();
|
||||
let text = command(&host, "ps").await;
|
||||
assert!(text.contains("PID PLUGIN METHOD PARENT STATE AGE"));
|
||||
assert!(text.contains(pid.as_str().unwrap()));
|
||||
assert!(text.contains("probe serve console"));
|
||||
host.stop().await;
|
||||
}
|
||||
/// Log formats local times and short pids, while core.log preserves JSON and filters.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn log() {
|
||||
let world = TestWorld::new();
|
||||
std::fs::create_dir_all(world.0.join("plugins/data-logger")).unwrap();
|
||||
std::fs::copy(fixture(), world.0.join("plugins/data-logger/plugin.wasm")).unwrap();
|
||||
let host = world.start().await;
|
||||
host.call("data-logger.echo", json!({})).await.unwrap();
|
||||
// Finish discovery before comparing two snapshots of the journal.
|
||||
command(&host, "methods data-logger").await;
|
||||
let pid = host.call("probe.serve", json!({})).await.unwrap();
|
||||
for (line, filters) in [
|
||||
("log".to_owned(), json!({})),
|
||||
(
|
||||
"log data-logger -n 1".to_owned(),
|
||||
json!({"plugin":"data-logger","limit":1}),
|
||||
),
|
||||
(
|
||||
"log probe -n 1".to_owned(),
|
||||
json!({"plugin":"probe","limit":1}),
|
||||
),
|
||||
(
|
||||
format!("log -n 1 {}", pid.as_str().unwrap()),
|
||||
json!({"pid":pid,"limit":1}),
|
||||
),
|
||||
] {
|
||||
let raw = command(&host, &format!("core.log {filters}")).await;
|
||||
let entries: Value = serde_json::from_str(&raw).unwrap();
|
||||
let entries = entries.as_array().unwrap();
|
||||
assert!(!entries.is_empty());
|
||||
let printed = command(&host, &line).await;
|
||||
let lines: Vec<_> = printed.lines().collect();
|
||||
assert_eq!(lines.len(), entries.len(), "{printed}");
|
||||
if line != "log" {
|
||||
assert_eq!(entries.len(), 1);
|
||||
}
|
||||
for (line, entry) in lines.into_iter().zip(entries) {
|
||||
let time = chrono::DateTime::parse_from_rfc3339(entry["time"].as_str().unwrap())
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Local)
|
||||
.format("%H:%M:%S%.3f")
|
||||
.to_string();
|
||||
let plugin = entry["plugin"].as_str().unwrap();
|
||||
let pid = entry["pid"].as_str().unwrap();
|
||||
let prefix = format!("{plugin}-");
|
||||
let short = pid
|
||||
.strip_prefix(&prefix)
|
||||
.map(|uuid| format!("{prefix}{}", &uuid[..8]))
|
||||
.unwrap_or_else(|| pid.to_owned());
|
||||
assert_eq!(
|
||||
line,
|
||||
format!(
|
||||
"{} {} {} {}",
|
||||
time,
|
||||
short,
|
||||
entry["stream"].as_str().unwrap(),
|
||||
entry["line"].as_str().unwrap()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(command(&host, "log absent-plugin").await, "");
|
||||
assert_eq!(command(&host, "log -n").await, "log -n");
|
||||
host.stop().await;
|
||||
}
|
||||
/// Activate installs a candidate through core.activate.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn activate() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let result = command(&host, "activate probe candidate.wasm").await;
|
||||
assert!(!result.starts_with("error:"), "{result}");
|
||||
assert!(world.0.join("plugins/probe/plugin.prev.wasm").exists());
|
||||
host.stop().await;
|
||||
}
|
||||
/// Revert restores the previous component through core.revert.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn revert() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
command(&host, "activate probe candidate.wasm").await;
|
||||
let result = command(&host, "revert probe").await;
|
||||
assert!(!result.starts_with("error:"), "{result}");
|
||||
host.stop().await;
|
||||
}
|
||||
/// Stop closes a live process gracefully.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stop() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let pid = host.call("probe.serve", json!({})).await.unwrap();
|
||||
command(&host, &format!("stop {}", pid.as_str().unwrap())).await;
|
||||
assert!(!host.processes().contains_key(pid.as_str().unwrap()));
|
||||
host.stop().await;
|
||||
}
|
||||
/// Kill ends a live process immediately.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kill() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let pid = host.call("probe.serve", json!({})).await.unwrap();
|
||||
command(&host, &format!("kill {}", pid.as_str().unwrap())).await;
|
||||
assert!(!host.processes().contains_key(pid.as_str().unwrap()));
|
||||
host.stop().await;
|
||||
}
|
||||
/// Help lists all console commands.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn help() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let text = command(&host, "help").await;
|
||||
for name in [
|
||||
"agent",
|
||||
"plugin.method",
|
||||
"ls",
|
||||
"cat",
|
||||
"methods",
|
||||
"ps",
|
||||
"log",
|
||||
"activate",
|
||||
"revert",
|
||||
"stop",
|
||||
"kill",
|
||||
"help",
|
||||
"quit",
|
||||
] {
|
||||
assert!(text.contains(name));
|
||||
}
|
||||
host.stop().await;
|
||||
}
|
||||
/// Quit produces a final message, and blank input does nothing.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn quit() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
assert_eq!(command(&host, "quit").await, "Goodbye.");
|
||||
assert_eq!(command(&host, " ").await, "");
|
||||
host.stop().await;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
//! Host integration contracts run in one parallel libtest pool.
|
||||
mod activation;
|
||||
mod agent;
|
||||
mod cargo;
|
||||
mod cli;
|
||||
mod common;
|
||||
mod console;
|
||||
mod core;
|
||||
mod sdk;
|
||||
mod watches;
|
||||
@@ -1,399 +0,0 @@
|
||||
//! Exercise SDK plugins through the real host, without a fake core.
|
||||
use sausage::{Host, world::World};
|
||||
use serde_json::{Value, json};
|
||||
use std::{path::PathBuf, sync::OnceLock, time::Duration};
|
||||
|
||||
fn fixtures() -> &'static PathBuf {
|
||||
static TARGET: OnceLock<PathBuf> = OnceLock::new();
|
||||
TARGET.get_or_init(|| {
|
||||
for (package, binary) in [
|
||||
("examples/notes", "notes"),
|
||||
("examples/consumer", "consumer"),
|
||||
("examples/www", "www"),
|
||||
("examples/secrets", "secrets"),
|
||||
("sdk/tests/plugins/consumer", "sdk-consumer"),
|
||||
] {
|
||||
super::common::build(package, binary);
|
||||
}
|
||||
super::common::sdk_probe().parent().unwrap().to_owned()
|
||||
})
|
||||
}
|
||||
struct TestWorld(PathBuf);
|
||||
impl TestWorld {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("sausage-sdk-{}", uuid::Uuid::now_v7()));
|
||||
let world = World::create(&path).unwrap();
|
||||
for (plugin, binary) in [
|
||||
("notes", "notes"),
|
||||
("consumer", "consumer"),
|
||||
("www", "www"),
|
||||
("secrets", "secrets"),
|
||||
("probe", "sdk-probe"),
|
||||
("typed", "sdk-consumer"),
|
||||
] {
|
||||
std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap();
|
||||
std::fs::copy(
|
||||
fixtures().join(format!("{binary}.wasm")),
|
||||
world.plugin_dir(plugin).join("plugin.wasm"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
Self(path)
|
||||
}
|
||||
async fn start(&self) -> Host {
|
||||
Host::start_with_cache(
|
||||
&self.0,
|
||||
std::path::Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
impl Drop for TestWorld {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
async fn call(host: &Host, method: &str, params: Value) -> Value {
|
||||
tokio::time::timeout(Duration::from_secs(60), host.call(method, params))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Concurrent read-modify-write calls retain every note under the SDK lock.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn notes_lock_preserves_parallel_additions() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
assert_eq!(
|
||||
call(&host, "notes.add", json!({"text":"first"})).await["count"],
|
||||
1
|
||||
);
|
||||
let calls = (0..12)
|
||||
.map(|n| {
|
||||
let h = host.clone();
|
||||
tokio::spawn(
|
||||
async move { call(&h, "notes.add", json!({"text":format!("note-{n}")})).await },
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for task in calls {
|
||||
task.await.unwrap();
|
||||
}
|
||||
let notes = call(&host, "notes.list", json!({})).await;
|
||||
assert_eq!(notes.as_array().unwrap().len(), 13);
|
||||
assert_eq!(notes[0], "first");
|
||||
for n in 0..12 {
|
||||
assert!(
|
||||
notes
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!(format!("note-{n}")))
|
||||
);
|
||||
}
|
||||
host.stop().await;
|
||||
}
|
||||
/// The example consumer invokes notes through its generated import.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn generated_notes_import() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
assert_eq!(
|
||||
call(&host, "consumer.note", json!({"text":"typed"})).await,
|
||||
1
|
||||
);
|
||||
assert_eq!(call(&host, "notes.list", json!({})).await, json!(["typed"]));
|
||||
host.stop().await;
|
||||
}
|
||||
/// Discovery retains the complete document and satisfies the consumer contract.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn discovery_is_compatible() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let actual = call(&host, "notes.rpc.discover", json!({})).await;
|
||||
let consumer = call(&host, "consumer.rpc.discover", json!({})).await;
|
||||
let expected: Value = serde_json::from_str(include_str!(
|
||||
"../../../examples/consumer/imports/notes.json"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(actual["openrpc"], "1.2.6");
|
||||
assert!(actual["components"]["schemas"]["AddParams"].is_object());
|
||||
assert!(
|
||||
actual["methods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|m| m["name"] == "list")
|
||||
.unwrap()
|
||||
.get("x-sausage-params")
|
||||
.is_none()
|
||||
);
|
||||
assert!(actual["components"]["schemas"].get("Empty").is_none());
|
||||
assert!(
|
||||
sausage_interface::compat::check(&consumer["x-sausage-imports"]["notes"], &actual)
|
||||
.is_empty()
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// Detached SDK calls receive progress and forward it through core.notify.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn detached_call_notifications() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let mut events = host.subscribe();
|
||||
assert_eq!(call(&host, "probe.relay", json!({})).await, 42);
|
||||
let event = tokio::time::timeout(Duration::from_secs(5), events.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(event["params"]["name"], "step");
|
||||
host.stop().await;
|
||||
}
|
||||
/// Detached typed calls complete and detached services survive their original caller.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn detached_service_survives_parent() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
assert_eq!(call(&host, "typed.run", json!({})).await, 5);
|
||||
let child = call(&host, "probe.launch", json!({})).await;
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while host.processes().values().any(|p| p.method == "launch") {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(host.processes().contains_key(child.as_str().unwrap()));
|
||||
host.stop().await;
|
||||
}
|
||||
/// A core.stop request closes stdin and executes the SDK stop hook.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stop_runs_hook() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let pid = call(&host, "probe.service", json!({})).await;
|
||||
call(&host, "core.stop", json!({"pid":pid})).await;
|
||||
assert_eq!(std::fs::read(world.0.join("stopped")).unwrap(), b"yes");
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 3.7: an idle SDK listener opts into drain and exits before EOF/grace.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn superseded_idle_listener_drains_before_grace() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let (notice, mut notices) = tokio::sync::mpsc::unbounded_channel();
|
||||
host.register_native(
|
||||
"other",
|
||||
std::sync::Arc::new(move |_, method, _| {
|
||||
let notice = notice.clone();
|
||||
Box::pin(async move {
|
||||
assert_eq!(method, "superseded");
|
||||
notice.send(std::time::Instant::now()).unwrap();
|
||||
Ok(json!({}))
|
||||
})
|
||||
}),
|
||||
);
|
||||
let pid = call(&host, "probe.drain_service", json!({})).await;
|
||||
// Replace with a different real component; force only skips unrelated test consumers.
|
||||
std::fs::copy(
|
||||
fixtures().join("notes.wasm"),
|
||||
world.0.join("state/next.wasm"),
|
||||
)
|
||||
.unwrap();
|
||||
call(
|
||||
&host,
|
||||
"core.activate",
|
||||
json!({"plugin":"probe","wasm":"/state/next.wasm","force":true}),
|
||||
)
|
||||
.await;
|
||||
// Activation preparation/discovery is outside upgrade grace. Time from the
|
||||
// hook's outgoing call, not from the start of candidate compilation.
|
||||
let before = tokio::time::timeout(Duration::from_secs(60), notices.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
// Waiting on its lifetime lock is an exit event, not process-liveness polling.
|
||||
call(&host, "core.lock", json!({"path":"/drain-owner"})).await;
|
||||
assert!(before.elapsed() < Duration::from_secs(5));
|
||||
assert!(!host.processes().contains_key(pid.as_str().unwrap()));
|
||||
assert!(
|
||||
!world
|
||||
.0
|
||||
.join(format!("stopped-{}", pid.as_str().unwrap()))
|
||||
.exists(),
|
||||
"drain must not run on_stop (discovery processes may run their own hooks)"
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// SDK framing carries payloads above 64 KiB in both directions.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn large_messages() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let text = "z".repeat(192 * 1024);
|
||||
assert_eq!(call(&host, "probe.echo", json!({"text":text})).await, text);
|
||||
host.stop().await;
|
||||
}
|
||||
/// Normal SDK stdout never produces the discarded-output journal warning.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn no_discarded_stdout() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
call(&host, "probe.echo", json!({"text":"framing"})).await;
|
||||
host.stop().await;
|
||||
assert_eq!(
|
||||
call(&host, "core.log", json!({"grep":"discarded stdout"})).await,
|
||||
json!([])
|
||||
);
|
||||
}
|
||||
/// Two SDK processes exchange endpoint requests and progress at a world path.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn world_endpoint() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
call(&host, "probe.service", json!({})).await;
|
||||
assert_eq!(call(&host, "typed.world_endpoint", json!({})).await, 7);
|
||||
host.stop().await;
|
||||
}
|
||||
/// The HTTP example exposes its self endpoint through the process namespace.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn www_control_through_proc() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
assert_eq!(
|
||||
call(&host, "consumer.demo", json!({"text":"web","port":0})).await,
|
||||
0
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// The vault example publishes a fixed endpoint in the shared run namespace.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn secrets_fixed_endpoint() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
call(&host, "secrets.serve", json!({})).await;
|
||||
assert_eq!(call(&host, "consumer.vault", json!({})).await, "hello");
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.6: a background task retains its SDK process until the task completes.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn background_task_retains_process() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let pid = call(&host, "probe.background_hold", json!({})).await;
|
||||
assert!(host.processes().contains_key(pid.as_str().unwrap()));
|
||||
assert_eq!(call(&host, "notes.list", json!({})).await, json!([]));
|
||||
assert!(host.processes().contains_key(pid.as_str().unwrap()));
|
||||
std::fs::write(world.0.join("release-background"), "release").unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while host.processes().contains_key(pid.as_str().unwrap()) {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(world.0.join("background-ended")).unwrap(),
|
||||
"done"
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 5: stale endpoint identities and interfaces fail through genuine SDK processes.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn endpoint_handshake_rejects_mismatch() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let pid = call(&host, "probe.service", json!({})).await;
|
||||
let path = world.0.join("state/control");
|
||||
let original = std::fs::read_to_string(&path).unwrap();
|
||||
let words: Vec<_> = original.split_whitespace().collect();
|
||||
for changed in [
|
||||
format!("{} wrong-owner {}\n", words[0], words[2]),
|
||||
format!("{} {} wrong-interface\n", words[0], words[1]),
|
||||
] {
|
||||
std::fs::write(&path, changed).unwrap();
|
||||
let error = host
|
||||
.call("typed.world_endpoint", json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error["code"], -32000);
|
||||
assert!(error["message"].as_str().unwrap().contains("mismatch"));
|
||||
assert!(host.processes().contains_key(pid.as_str().unwrap()));
|
||||
}
|
||||
std::fs::write(path, original).unwrap();
|
||||
assert_eq!(call(&host, "typed.world_endpoint", json!({})).await, 7);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 10.1 and 5: discovery through core retains params, endpoints, imports, and grace.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn discovery_extensions_through_core() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let provider = call(&host, "probe.rpc.discover", json!({})).await;
|
||||
assert_eq!(provider["openrpc"], "1.2.6");
|
||||
assert_eq!(provider["x-sausage-grace"], 12);
|
||||
assert!(
|
||||
provider["x-sausage-endpoints"]["control"]["methods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|m| m["name"] == "stats")
|
||||
);
|
||||
let add = provider["methods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|m| m["name"] == "add")
|
||||
.unwrap();
|
||||
assert_eq!(add["paramStructure"], "by-name");
|
||||
let reference = add["x-sausage-params"]["$ref"].as_str().unwrap();
|
||||
assert_eq!(
|
||||
provider.pointer(reference.trim_start_matches('#')).unwrap()["type"],
|
||||
"object"
|
||||
);
|
||||
let consumer = call(&host, "typed.rpc.discover", json!({})).await;
|
||||
assert!(
|
||||
consumer["x-sausage-imports"]["probe"]["methods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|m| m["name"] == "add")
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.2: rpc.discover is SDK control traffic and endpoint control is not a main method.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reserved_rpc_control_names() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
assert!(call(&host, "probe.rpc.discover", json!({})).await["methods"].is_array());
|
||||
for method in ["probe.rpc.endpoint", "probe.rpc.unknown"] {
|
||||
assert_eq!(
|
||||
host.call(method, json!({})).await.unwrap_err()["code"],
|
||||
-32601
|
||||
);
|
||||
}
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.1: the SDK's leading newline isolates an unfinished diagnostic from its response.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn leading_newline_protects_sdk_response() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
assert_eq!(
|
||||
call(&host, "probe.framed_output", json!({})).await,
|
||||
"intact"
|
||||
);
|
||||
host.stop().await;
|
||||
let warnings = call(&host, "core.log", json!({"grep":"discarded stdout"})).await;
|
||||
assert_eq!(warnings.as_array().unwrap().len(), 1);
|
||||
assert!(!warnings.to_string().contains("partial diagnostic"));
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
//! Real SDK watch callbacks and raw WASI filesystem mutations share the host namespace.
|
||||
use super::common::{CACHE, probe, sdk_probe};
|
||||
use sausage::{Host, world::World};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Duration;
|
||||
|
||||
// Same call bound as core.rs; no sleeps or polling.
|
||||
async fn call(h: &Host, method: &str, p: Value) -> Value {
|
||||
tokio::time::timeout(Duration::from_secs(20), h.call(method, p))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
async fn event(h: &Host, rx: &mut tokio::sync::broadcast::Receiver<Value>, name: &str) -> Value {
|
||||
let result = tokio::time::timeout(Duration::from_secs(20), async {
|
||||
loop {
|
||||
let e = rx.recv().await.unwrap();
|
||||
if e["params"]["name"] == name {
|
||||
return e["params"]["params"].clone();
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"JOURNAL {}",
|
||||
h.call("core.log", json!({"limit":100})).await.unwrap()
|
||||
);
|
||||
panic!("{e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn start() -> (tempfile::TempDir, Host) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let world = World::create(&dir.path().join("world")).unwrap();
|
||||
for (name, wasm) in [("probe", probe()), ("sdk", sdk_probe())] {
|
||||
std::fs::create_dir_all(world.plugin_dir(name)).unwrap();
|
||||
std::fs::copy(wasm, world.plugin_dir(name).join("plugin.wasm")).unwrap();
|
||||
}
|
||||
let host = Host::start_with_cache(&world.root, std::path::Path::new(CACHE))
|
||||
.await
|
||||
.unwrap();
|
||||
(dir, host)
|
||||
}
|
||||
fn watch(h: &Host, path: &str) -> tokio::task::JoinHandle<sausage::RpcResult> {
|
||||
let (h, path) = (h.clone(), path.to_owned());
|
||||
tokio::spawn(async move { h.call("sdk.watch", json!({"path":path})).await })
|
||||
}
|
||||
async fn mutate(h: &Host, op: &str, path: &str) {
|
||||
call(h, "probe.mutate", json!({"op":op,"path":path})).await;
|
||||
}
|
||||
/// PROTOCOL 4: pre-arm state is in the first scan; each post-scan mutation wakes
|
||||
/// the SDK without completing the watch. Covers descriptor/stream/truncate paths.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn armed_scan_and_later_changes() {
|
||||
let (_dir, h) = start().await;
|
||||
mutate(&h, "mkdir", "/state/mailbox").await;
|
||||
call(
|
||||
&h,
|
||||
"probe.write",
|
||||
json!({"path":"/state/mailbox/before", "text":"before"}),
|
||||
)
|
||||
.await;
|
||||
let mut rx = h.subscribe();
|
||||
let task = watch(&h, "/state/mailbox/../mailbox/");
|
||||
let armed = event(&h, &mut rx, "armed").await;
|
||||
assert_eq!(armed["entries"], json!(["before"]));
|
||||
assert!(!task.is_finished());
|
||||
for (op, expected) in [
|
||||
("descriptor", "descriptor"),
|
||||
("stream", "streamptor"),
|
||||
("append", "streamptorappend"),
|
||||
("size", ""),
|
||||
("descriptor", "descriptor"),
|
||||
("truncate", ""),
|
||||
] {
|
||||
mutate(&h, op, "/state/mailbox/before").await;
|
||||
let mut scan = event(&h, &mut rx, "changed").await;
|
||||
while scan["text"] != expected {
|
||||
scan = event(&h, &mut rx, "changed").await;
|
||||
}
|
||||
}
|
||||
mutate(&h, "burst", "/state/mailbox").await;
|
||||
let mut scanned = event(&h, &mut rx, "changed").await;
|
||||
while !scanned["entries"].as_array().unwrap().contains(&json!("4")) {
|
||||
scanned = event(&h, &mut rx, "changed").await;
|
||||
}
|
||||
while rx.try_recv().is_ok() {}
|
||||
mutate(&h, "delete", "/state/mailbox/4").await;
|
||||
let mut scanned = event(&h, &mut rx, "changed").await;
|
||||
while scanned["entries"].as_array().unwrap().contains(&json!("4")) {
|
||||
scanned = event(&h, &mut rx, "changed").await;
|
||||
}
|
||||
call(&h, "core.kill", json!({"pid":armed["pid"]})).await;
|
||||
assert!(task.await.unwrap().is_err());
|
||||
h.stop().await;
|
||||
}
|
||||
/// PROTOCOL 4: atomic publication, rename source/destination, mounted paths,
|
||||
/// absent paths, and caller-death cleanup. No external host writes are required.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn rename_delete_and_mounts() {
|
||||
let (_dir, h) = start().await;
|
||||
for base in ["/state/inbox", "/run/inbox"] {
|
||||
let mut rx = h.subscribe();
|
||||
let task = watch(&h, base);
|
||||
let armed = event(&h, &mut rx, "armed").await;
|
||||
assert_eq!(armed["entries"], json!([]));
|
||||
let staging = format!("{base}-staging");
|
||||
mutate(&h, "mkdir", &staging).await;
|
||||
call(
|
||||
&h,
|
||||
"probe.mutate",
|
||||
json!({"op":"rename","path":staging,"to":base}),
|
||||
)
|
||||
.await;
|
||||
event(&h, &mut rx, "changed").await;
|
||||
mutate(&h, "atomic", &format!("{base}/published")).await;
|
||||
let mut scan = event(&h, &mut rx, "changed").await;
|
||||
while !scan["entries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("published"))
|
||||
{
|
||||
scan = event(&h, &mut rx, "changed").await;
|
||||
}
|
||||
assert_eq!(
|
||||
call(
|
||||
&h,
|
||||
"probe.read",
|
||||
json!({"path":format!("{base}/published")})
|
||||
)
|
||||
.await,
|
||||
"complete"
|
||||
);
|
||||
while rx.try_recv().is_ok() {}
|
||||
call(
|
||||
&h,
|
||||
"probe.mutate",
|
||||
json!({"op":"rename","path":base,"to":format!("{base}-moved")}),
|
||||
)
|
||||
.await;
|
||||
let mut scan = event(&h, &mut rx, "changed").await;
|
||||
while scan["entries"] != json!([]) {
|
||||
scan = event(&h, &mut rx, "changed").await;
|
||||
}
|
||||
call(&h, "core.kill", json!({"pid":armed["pid"]})).await;
|
||||
assert!(task.await.unwrap().is_err());
|
||||
}
|
||||
// /self watches bind to this caller, and mutations are visible via /proc/<pid>.
|
||||
let mut rx = h.subscribe();
|
||||
let task = watch(&h, "/self");
|
||||
let armed = event(&h, &mut rx, "armed").await;
|
||||
assert!(
|
||||
armed["entries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("owned"))
|
||||
);
|
||||
event(&h, &mut rx, "changed").await;
|
||||
assert_eq!(
|
||||
call(
|
||||
&h,
|
||||
"probe.read",
|
||||
json!({"path":format!("/proc/{}/owned", armed["pid"].as_str().unwrap())})
|
||||
)
|
||||
.await,
|
||||
"visible via proc"
|
||||
);
|
||||
call(&h, "core.kill", json!({"pid":armed["pid"]})).await;
|
||||
assert!(task.await.unwrap().is_err());
|
||||
h.stop().await;
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
//! Raw discovery and lifecycle guest for activation protocol tests.
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{self, BufRead, Write};
|
||||
/// Emits one flushed JSON-RPC response.
|
||||
fn send(value: Value) {
|
||||
println!("{value}");
|
||||
io::stdout().flush().unwrap();
|
||||
}
|
||||
/// Calls a provider after activation releases an old consumer process.
|
||||
fn deferred(request: &Value, plugin: &str, lines: &mut impl Iterator<Item = io::Result<String>>) {
|
||||
send(
|
||||
json!({"jsonrpc":"2.0","id":request["id"],"result":std::env::var("SAUSAGE_PID").unwrap()}),
|
||||
);
|
||||
let before = std::time::Instant::now();
|
||||
while !std::path::Path::new(&format!("/state/{plugin}/release-call")).exists() {
|
||||
assert!(before.elapsed() < std::time::Duration::from_secs(30));
|
||||
}
|
||||
send(json!({"jsonrpc":"2.0","id":"deferred","method":"notes.legacy","params":{}}));
|
||||
for line in lines {
|
||||
let response: Value = serde_json::from_str(&line.unwrap()).unwrap();
|
||||
if response["id"] == "deferred" {
|
||||
std::fs::write(format!("/state/{plugin}/call-result"), response.to_string()).unwrap();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Answers a method that version two removes from its interface.
|
||||
fn legacy(request: &Value, document: &Value, version: &str) {
|
||||
if document["methods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|m| m["name"] == "legacy")
|
||||
{
|
||||
send(json!({"jsonrpc":"2.0","id":request["id"],"result":version}));
|
||||
} else {
|
||||
send(
|
||||
json!({"jsonrpc":"2.0","id":request["id"],"error":{"code":-32601,"message":"legacy removed","data":{"version":version}}}),
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Reads a version-specific discovery document without the SDK.
|
||||
fn main() {
|
||||
let version = env!("FIXTURE_VERSION");
|
||||
let plugin = std::env::var("SAUSAGE_PLUGIN").unwrap();
|
||||
let mut lines = io::stdin().lock().lines();
|
||||
let request: Value = serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap();
|
||||
let document: Value =
|
||||
serde_json::from_slice(&std::fs::read(format!("/state/{plugin}/{version}.json")).unwrap())
|
||||
.unwrap();
|
||||
let result = match request["method"].as_str().unwrap() {
|
||||
"deferred" => {
|
||||
deferred(&request, &plugin, &mut lines);
|
||||
return;
|
||||
}
|
||||
"legacy" => {
|
||||
legacy(&request, &document, version);
|
||||
return;
|
||||
}
|
||||
"rpc.discover" => {
|
||||
if document["x-test-no-answer"] == true {
|
||||
for _ in &mut lines {}
|
||||
return;
|
||||
}
|
||||
if document["x-test-wait"] == true {
|
||||
std::fs::write(format!("/state/{plugin}/discover-started"), "ready").unwrap();
|
||||
let before = std::time::Instant::now();
|
||||
while !std::path::Path::new(&format!("/state/{plugin}/discover-release")).exists() {
|
||||
assert!(before.elapsed() < std::time::Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
document
|
||||
}
|
||||
"read" => {
|
||||
let text =
|
||||
std::fs::read_to_string(request["params"]["path"].as_str().unwrap()).unwrap();
|
||||
serde_json::from_str(&text).unwrap_or(json!(text))
|
||||
}
|
||||
"rpc.retired" => {
|
||||
std::fs::write(
|
||||
format!("/state/{plugin}/retired"),
|
||||
request["params"]["previous"].as_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
json!({"retired":version})
|
||||
}
|
||||
"work" => {
|
||||
// Keep an actual request unanswered through grace; notices do not drain it.
|
||||
send(
|
||||
json!({"jsonrpc":"2.0","method":"core.notify","params":{"name":"ready","params":{}}}),
|
||||
);
|
||||
for _ in lines {}
|
||||
return;
|
||||
}
|
||||
"serve" | "hold" => {
|
||||
// One genuine process failure, followed by a healthy service, without
|
||||
// timers or liveness polling in either the fixture or supervisor.
|
||||
let crash = request["params"]["crash_once"].as_str();
|
||||
let first = crash.is_some()
|
||||
&& std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(format!("/state/{plugin}/crashed-once"))
|
||||
.is_ok();
|
||||
if first && crash == Some("before") {
|
||||
std::process::exit(1);
|
||||
}
|
||||
send(json!({"jsonrpc":"2.0","id":request["id"],"result":{"version":version}}));
|
||||
if first && crash == Some("after") {
|
||||
std::process::exit(1);
|
||||
}
|
||||
if request["method"] == "serve" {
|
||||
std::fs::write(format!("/state/{plugin}/service"), version).unwrap();
|
||||
}
|
||||
for line in lines {
|
||||
let Ok(message) = serde_json::from_str::<Value>(&line.unwrap()) else {
|
||||
continue;
|
||||
};
|
||||
if message["method"] == "rpc.superseded" {
|
||||
std::fs::write(
|
||||
format!(
|
||||
"/state/{plugin}/superseded-{}",
|
||||
std::env::var("SAUSAGE_PID").unwrap()
|
||||
),
|
||||
message.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
if request["params"]["exit_on_superseded"] == true {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::write(format!("/state/{plugin}/ended-{version}"), "ended").unwrap();
|
||||
return;
|
||||
}
|
||||
_ => json!({"version":version}),
|
||||
};
|
||||
send(json!({"jsonrpc":"2.0","id":request["id"],"result":result}));
|
||||
if request["method"] == "rpc.discover" && result["x-test-linger"] == true {
|
||||
if result["x-test-stubborn"] == true {
|
||||
let before = std::time::Instant::now();
|
||||
while !std::path::Path::new(&format!("/state/{plugin}/discovery-exit")).exists() {
|
||||
assert!(before.elapsed() < std::time::Duration::from_secs(30));
|
||||
}
|
||||
} else {
|
||||
for _ in lines {}
|
||||
}
|
||||
std::fs::write(
|
||||
format!("/state/{plugin}/discovery-ended-{version}"),
|
||||
"ended",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,589 +0,0 @@
|
||||
//! Raw JSON-RPC methods used by the host's protocol tests.
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{self, BufRead, Read, Write};
|
||||
type Lines = io::Lines<io::BufReader<io::Stdin>>;
|
||||
|
||||
fn send(value: Value) {
|
||||
let mut output = io::stdout().lock();
|
||||
writeln!(output, "\n{value}").unwrap();
|
||||
output.flush().unwrap();
|
||||
}
|
||||
fn receive(lines: &mut Lines) -> Value {
|
||||
serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap()
|
||||
}
|
||||
fn echo(params: &Value) -> Value {
|
||||
params.clone()
|
||||
}
|
||||
fn chat(params: &Value) -> Value {
|
||||
send(json!({"jsonrpc":"2.0", "method":"core.notify", "params":{
|
||||
"name":"text", "params":{"text":"interim"}
|
||||
}}));
|
||||
json!({"text":params["text"]})
|
||||
}
|
||||
fn discover(params: &Value) -> Value {
|
||||
if let Ok(bytes) = std::fs::read("/state/probe/discover.json") {
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
} else {
|
||||
params.clone()
|
||||
}
|
||||
}
|
||||
fn environment() -> Value {
|
||||
json!({
|
||||
"pid": std::env::var("SAUSAGE_PID").unwrap(),
|
||||
"plugin": std::env::var("SAUSAGE_PLUGIN").unwrap(),
|
||||
"parent": std::env::var("SAUSAGE_PARENT").unwrap(),
|
||||
})
|
||||
}
|
||||
fn multicall(lines: &mut Lines) -> Value {
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "probe.echo",
|
||||
"params": {"n": 1}
|
||||
}));
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": "1",
|
||||
"method": "probe.echo",
|
||||
"params": {"n": 2}
|
||||
}));
|
||||
for _ in 0..2 {
|
||||
let response = receive(lines);
|
||||
if response["id"] == 1 {
|
||||
assert_eq!(response["result"]["n"], 1);
|
||||
} else {
|
||||
assert_eq!(response["id"], "1");
|
||||
assert_eq!(response["result"]["n"], 2);
|
||||
}
|
||||
}
|
||||
json!({"count":2})
|
||||
}
|
||||
fn notification(params: &Value) -> Value {
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "probe.write",
|
||||
"params": params
|
||||
}));
|
||||
let before = std::time::Instant::now();
|
||||
loop {
|
||||
let contents = std::fs::read_to_string(params["path"].as_str().unwrap()).ok();
|
||||
if contents.as_deref() == params["text"].as_str() {
|
||||
break;
|
||||
}
|
||||
assert!(before.elapsed() < std::time::Duration::from_secs(10));
|
||||
}
|
||||
json!({})
|
||||
}
|
||||
fn unknown_id() {
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": "late",
|
||||
"result": 7
|
||||
}));
|
||||
}
|
||||
fn wait_eof(lines: &mut Lines) {
|
||||
std::fs::write("/state/waiting", "yes").unwrap();
|
||||
send(json!({"jsonrpc":"2.0","method":"core.notify","params":{
|
||||
"name":"waiting-eof", "params":{"pid":std::env::var("SAUSAGE_PID").unwrap()}
|
||||
}}));
|
||||
for _ in lines {}
|
||||
std::fs::write("/state/stopped", "yes").unwrap();
|
||||
}
|
||||
fn busy() -> Value {
|
||||
loop {
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
}
|
||||
fn exit_without_answer() {
|
||||
eprintln!("last line");
|
||||
}
|
||||
fn junk() -> Value {
|
||||
io::stdout().write_all(&[255, 10]).unwrap();
|
||||
println!("junk\n{{}}\n[]");
|
||||
eprintln!("stderr line");
|
||||
json!({})
|
||||
}
|
||||
fn invalid_utf8() -> Value {
|
||||
io::stdout().write_all(&[255, 10]).unwrap();
|
||||
json!({})
|
||||
}
|
||||
fn blank() -> Value {
|
||||
println!("\n \t\n");
|
||||
json!({})
|
||||
}
|
||||
fn burst(request: &Value) {
|
||||
let count = request["params"]["count"].as_u64().unwrap();
|
||||
let mut output = String::new();
|
||||
for index in 0..count {
|
||||
let notification = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "core.notify",
|
||||
"params": {"name": "burst", "params": {"index": index}}
|
||||
});
|
||||
output.push_str(¬ification.to_string());
|
||||
output.push('\n');
|
||||
}
|
||||
let response = json!({"jsonrpc":"2.0", "id":request["id"], "result":count});
|
||||
output.push_str(&response.to_string());
|
||||
output.push('\n');
|
||||
let mut stdout = io::stdout().lock();
|
||||
stdout.write_all(output.as_bytes()).unwrap();
|
||||
stdout.flush().unwrap();
|
||||
}
|
||||
fn notify() -> Value {
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "core.notify",
|
||||
"params": {
|
||||
"name": "progress",
|
||||
"params": {"n": 1}
|
||||
}
|
||||
}));
|
||||
json!({})
|
||||
}
|
||||
fn serve(request: &Value, lines: &mut Lines) {
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": request["id"],
|
||||
"result": std::env::var("SAUSAGE_PID").unwrap()
|
||||
}));
|
||||
for _ in lines {}
|
||||
}
|
||||
fn network(params: &Value) -> Value {
|
||||
let mut socket = std::net::TcpStream::connect(params["tcp"].as_str().unwrap()).unwrap();
|
||||
let mut text = String::new();
|
||||
socket.read_to_string(&mut text).unwrap();
|
||||
let udp = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
udp.send_to(b"udp", params["udp"].as_str().unwrap())
|
||||
.unwrap();
|
||||
json!(text)
|
||||
}
|
||||
fn read_file(params: &Value) -> Value {
|
||||
json!(std::fs::read_to_string(params["path"].as_str().unwrap()).unwrap())
|
||||
}
|
||||
fn write_file(params: &Value) -> Value {
|
||||
std::fs::write(
|
||||
params["path"].as_str().unwrap(),
|
||||
params["text"].as_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
json!({})
|
||||
}
|
||||
fn outgoing_call(params: &Value, lines: &mut Lines) -> Value {
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": {"nested": 7},
|
||||
"method": params["method"],
|
||||
"params": params["params"]
|
||||
}));
|
||||
loop {
|
||||
let response = receive(lines);
|
||||
if response["id"] == json!({"nested":7}) {
|
||||
return response["result"].clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn lock(params: &Value, lines: &mut Lines) -> Value {
|
||||
outgoing_call(&json!({"method":"core.lock", "params":params}), lines)
|
||||
}
|
||||
fn filesystem(params: &Value) -> Value {
|
||||
use std::fs;
|
||||
let pid = std::env::var("SAUSAGE_PID").unwrap();
|
||||
match params["topic"].as_str().unwrap() {
|
||||
"root" => {
|
||||
let preopens = wasip2::filesystem::preopens::get_directories();
|
||||
assert_eq!(preopens.len(), 1);
|
||||
assert_eq!(preopens[0].1, "/");
|
||||
let names: Vec<_> = fs::read_dir("/")
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
||||
.collect();
|
||||
for name in ["proc", "self", "run"] {
|
||||
assert!(names.iter().any(|n| n == name));
|
||||
}
|
||||
fs::write("/state/root", "world").unwrap();
|
||||
fs::write("/run/shared", "shared").unwrap();
|
||||
assert!(fs::rename("/run/shared", "/state/moved").is_err());
|
||||
assert!(fs::rename("/state/root", "/self/moved").is_err());
|
||||
assert_eq!(fs::read_to_string("/run/../state/root").unwrap(), "world");
|
||||
}
|
||||
"reserved" => {
|
||||
for name in ["info.json", "log", "fs"] {
|
||||
assert!(fs::write(format!("/self/{name}"), "bad").is_err());
|
||||
assert!(fs::create_dir(format!("/self/{name}")).is_err());
|
||||
fs::write("/self/temp", "ok").unwrap();
|
||||
assert!(fs::rename("/self/temp", format!("/self/{name}")).is_err());
|
||||
fs::remove_file("/self/temp").unwrap();
|
||||
}
|
||||
}
|
||||
"self" => {
|
||||
fs::write("/self/endpoint.tmp", "endpoint").unwrap();
|
||||
fs::rename("/self/endpoint.tmp", "/self/endpoint").unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(format!("/proc/{pid}/endpoint")).unwrap(),
|
||||
"endpoint"
|
||||
);
|
||||
assert!(fs::write(format!("/proc/{pid}/endpoint"), "bad").is_err());
|
||||
assert!(fs::remove_file(format!("/proc/{pid}/endpoint")).is_err());
|
||||
let info: Value =
|
||||
serde_json::from_slice(&fs::read("/self/info.json").unwrap()).unwrap();
|
||||
assert_eq!(info["plugin"], "probe");
|
||||
assert_eq!(info["method"], "filesystem");
|
||||
return json!(pid);
|
||||
}
|
||||
"isolated" => {
|
||||
assert!(!fs::exists("/self/endpoint").unwrap());
|
||||
}
|
||||
"snapshot" => {
|
||||
eprintln!("before snapshot");
|
||||
let mut file = fs::File::open("/self/log").unwrap();
|
||||
eprintln!("after snapshot");
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents).unwrap();
|
||||
assert!(contents.contains("start filesystem"));
|
||||
assert!(!contents.contains("after snapshot"));
|
||||
assert_eq!(file.read(&mut [0; 1]).unwrap(), 0);
|
||||
}
|
||||
"core" => {
|
||||
assert!(
|
||||
fs::read_to_string("/proc/core/PROTOCOL.md")
|
||||
.unwrap()
|
||||
.contains("## 4. The filesystem")
|
||||
);
|
||||
for name in ["graph.json", "locks.json"] {
|
||||
let _: Value =
|
||||
serde_json::from_slice(&fs::read(format!("/proc/core/{name}")).unwrap())
|
||||
.unwrap();
|
||||
}
|
||||
assert!(
|
||||
fs::read_to_string("/proc/core/graph.dot")
|
||||
.unwrap()
|
||||
.contains("digraph")
|
||||
);
|
||||
assert!(fs::read_dir("/proc/core/plugins").is_ok());
|
||||
assert!(fs::write("/proc/core/graph.json", "bad").is_err());
|
||||
}
|
||||
_ => panic!("unknown filesystem topic"),
|
||||
}
|
||||
json!({})
|
||||
}
|
||||
fn write_loop(request: &Value, lines: &mut Lines) {
|
||||
assert_eq!(
|
||||
lock(&json!({"path":"/state/loop"}), lines),
|
||||
json!({"held":true})
|
||||
);
|
||||
std::fs::write("/state/loop", "old").unwrap();
|
||||
send(
|
||||
json!({"jsonrpc":"2.0", "id":request["id"], "result":std::env::var("SAUSAGE_PID").unwrap()}),
|
||||
);
|
||||
loop {
|
||||
std::fs::write("/state/loop", "old").unwrap();
|
||||
}
|
||||
}
|
||||
fn locked_write(lines: &mut Lines) -> Value {
|
||||
assert_eq!(
|
||||
lock(&json!({"path":"/state/loop"}), lines),
|
||||
json!({"held":true})
|
||||
);
|
||||
std::fs::write("/state/loop", "new").unwrap();
|
||||
outgoing_call(&json!({"method":"probe.echo","params":{}}), lines);
|
||||
assert_eq!(std::fs::read_to_string("/state/loop").unwrap(), "new");
|
||||
json!({})
|
||||
}
|
||||
fn unlock_answer(request: &Value, lines: &mut Lines) {
|
||||
lock(&json!({"path":"ordered-unlock"}), lines);
|
||||
let unlock = json!({"jsonrpc":"2.0","method":"core.unlock","params":{"path":"ordered-unlock"}});
|
||||
let answer =
|
||||
json!({"jsonrpc":"2.0","id":request["id"],"result":std::env::var("SAUSAGE_PID").unwrap()});
|
||||
let mut stdout = io::stdout().lock();
|
||||
writeln!(stdout, "{unlock}\n{answer}").unwrap();
|
||||
stdout.flush().unwrap();
|
||||
drop(stdout);
|
||||
for _ in lines {}
|
||||
}
|
||||
fn notification_child(lines: &mut Lines) -> Value {
|
||||
send(json!({"jsonrpc":"2.0","method":"probe.orphan_notify","params":{}}));
|
||||
let before = std::time::Instant::now();
|
||||
let pid = loop {
|
||||
if let Ok(pid) = std::fs::read_to_string("/state/orphan") {
|
||||
break pid;
|
||||
}
|
||||
assert!(before.elapsed() < std::time::Duration::from_secs(10));
|
||||
};
|
||||
send(json!({"jsonrpc":"2.0","id":7,"method":"core.stop","params":{"pid":pid}}));
|
||||
let mut count = 0;
|
||||
loop {
|
||||
let response = receive(lines);
|
||||
if response["method"] == "core.notify" {
|
||||
count += 1;
|
||||
}
|
||||
if response["id"] == 7 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
json!(count)
|
||||
}
|
||||
fn batch(lines: &mut Lines) -> Value {
|
||||
send(
|
||||
json!([{"jsonrpc":"2.0","method":"probe.write","params":{"path":"/state/batch","text":"bad"}}]),
|
||||
);
|
||||
outgoing_call(
|
||||
&json!({"method":"probe.echo","params":{"barrier":true}}),
|
||||
lines,
|
||||
);
|
||||
json!(!std::path::Path::new("/state/batch").exists())
|
||||
}
|
||||
fn late_answer(request: &Value, lines: &mut Lines) {
|
||||
send(json!({"jsonrpc":"2.0","id":request["id"],"result":42}));
|
||||
send(json!({"jsonrpc":"2.0","id":request["id"],"result":99}));
|
||||
for _ in lines {}
|
||||
}
|
||||
fn nested_environment(lines: &mut Lines) -> Value {
|
||||
let child = outgoing_call(&json!({"method":"probe.env","params":{}}), lines);
|
||||
json!({"caller":std::env::var("SAUSAGE_PID").unwrap(),"child":child})
|
||||
}
|
||||
fn forgotten(request: &Value, lines: &mut Lines) {
|
||||
std::fs::write("/state/forgotten", environment().to_string()).unwrap();
|
||||
serve(request, lines);
|
||||
}
|
||||
fn forget(lines: &mut Lines) -> Value {
|
||||
send(
|
||||
json!({"jsonrpc":"2.0","method":"core.call","params":{"method":"probe.forgotten","params":{},"detach":true}}),
|
||||
);
|
||||
let before = std::time::Instant::now();
|
||||
while !std::path::Path::new("/state/forgotten").exists() {
|
||||
assert!(before.elapsed() < std::time::Duration::from_secs(10));
|
||||
}
|
||||
send(json!({"jsonrpc":"2.0","id":"barrier","method":"probe.echo","params":{}}));
|
||||
let response = receive(lines);
|
||||
assert_eq!(
|
||||
response["id"], "barrier",
|
||||
"a forgotten call must not send a result"
|
||||
);
|
||||
environment()
|
||||
}
|
||||
fn tree(request: &Value, lines: &mut Lines) {
|
||||
let depth = request["params"]["depth"].as_u64().unwrap();
|
||||
if depth > 0 {
|
||||
outgoing_call(
|
||||
&json!({"method":"probe.tree","params":{"depth":depth-1}}),
|
||||
lines,
|
||||
);
|
||||
}
|
||||
send(
|
||||
json!({"jsonrpc":"2.0","id":request["id"],"result":std::env::var("SAUSAGE_PID").unwrap()}),
|
||||
);
|
||||
busy();
|
||||
}
|
||||
fn dispatch(request: &Value, lines: &mut Lines) -> Option<Value> {
|
||||
let params = &request["params"];
|
||||
let result = match request["method"].as_str().unwrap() {
|
||||
"batch" => batch(lines),
|
||||
"trap" => std::arch::wasm32::unreachable(),
|
||||
"nested_environment" => nested_environment(lines),
|
||||
"forget" => forget(lines),
|
||||
"forgotten" => {
|
||||
forgotten(request, lines);
|
||||
return None;
|
||||
}
|
||||
"tree" => {
|
||||
tree(request, lines);
|
||||
return None;
|
||||
}
|
||||
"late_answer" => {
|
||||
late_answer(request, lines);
|
||||
return None;
|
||||
}
|
||||
"invalid_stderr" => {
|
||||
io::stderr().write_all(&[255, 10]).unwrap();
|
||||
eprintln!("after invalid UTF-8");
|
||||
json!(std::env::var("SAUSAGE_PID").unwrap())
|
||||
}
|
||||
"unlock_answer" => {
|
||||
unlock_answer(request, lines);
|
||||
return None;
|
||||
}
|
||||
"null_error" => {
|
||||
send(json!({"jsonrpc":"2.0","id":request["id"],"error":null,"result":42}));
|
||||
return None;
|
||||
}
|
||||
"notification_child" => notification_child(lines),
|
||||
"orphan_notify" => {
|
||||
std::fs::write("/state/orphan", std::env::var("SAUSAGE_PID").unwrap()).unwrap();
|
||||
notify()
|
||||
}
|
||||
"self_serve" => {
|
||||
std::fs::write("/self/endpoint", "endpoint").unwrap();
|
||||
serve(request, lines);
|
||||
return None;
|
||||
}
|
||||
"filesystem" => filesystem(params),
|
||||
"write_loop" => {
|
||||
write_loop(request, lines);
|
||||
return None;
|
||||
}
|
||||
"locked_write" => locked_write(lines),
|
||||
"echo" => echo(params),
|
||||
"chat" => chat(params),
|
||||
"rpc.discover" => discover(params),
|
||||
"env" => environment(),
|
||||
"multicall" => multicall(lines),
|
||||
"notification" => notification(params),
|
||||
"unknownid" => {
|
||||
unknown_id();
|
||||
return None;
|
||||
}
|
||||
"request_id" => request["id"].clone(),
|
||||
"wait_eof" => {
|
||||
wait_eof(lines);
|
||||
return None;
|
||||
}
|
||||
"busy" => busy(),
|
||||
"exit" => {
|
||||
exit_without_answer();
|
||||
return None;
|
||||
}
|
||||
"junk" => junk(),
|
||||
"blank" => blank(),
|
||||
"invalid_utf8" => invalid_utf8(),
|
||||
"notify" => notify(),
|
||||
"burst" => {
|
||||
burst(request);
|
||||
return None;
|
||||
}
|
||||
"restart" => {
|
||||
assert_eq!(
|
||||
lock(&json!({"path":"/state/restart-stop"}), lines),
|
||||
json!({"held":true})
|
||||
);
|
||||
assert_eq!(
|
||||
outgoing_call(&json!({"method":"core.restart", "params":{}}), lines),
|
||||
json!({})
|
||||
);
|
||||
std::fs::write("/state/restart-answer", "received").unwrap();
|
||||
serve(request, lines);
|
||||
std::fs::write("/state/restart-stop", "persisted after EOF").unwrap();
|
||||
eprintln!("restart stop hook finished");
|
||||
return None;
|
||||
}
|
||||
"serve" => {
|
||||
serve(request, lines);
|
||||
return None;
|
||||
}
|
||||
"network" => network(params),
|
||||
"mutate" => mutate(params),
|
||||
"read" => read_file(params),
|
||||
"write" => write_file(params),
|
||||
"raw_call" => {
|
||||
send(params.clone());
|
||||
receive(lines)
|
||||
}
|
||||
"call" => outgoing_call(params, lines),
|
||||
"lock" => lock(params, lines),
|
||||
_ => {
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": request["id"],
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": "method not found"
|
||||
}
|
||||
}));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(result)
|
||||
}
|
||||
fn main() {
|
||||
if let Ok(mode) = std::fs::read_to_string("/state/early") {
|
||||
if mode == "flood" {
|
||||
for index in 0..5000 {
|
||||
eprintln!("flood {index} {}", "x".repeat(128));
|
||||
}
|
||||
} else {
|
||||
eprintln!("last early log");
|
||||
if mode == "answer" {
|
||||
send(
|
||||
json!({"jsonrpc":"2.0","id":std::env::var("SAUSAGE_PID").unwrap(),"result":"early"}),
|
||||
);
|
||||
}
|
||||
std::process::exit(23);
|
||||
}
|
||||
}
|
||||
let mut lines = io::BufReader::new(io::stdin()).lines();
|
||||
let request = receive(&mut lines);
|
||||
if let Some(result) = dispatch(&request, &mut lines)
|
||||
&& let Some(id) = request.get("id")
|
||||
{
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": result
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate mutation calls let the host await a scan before each subsequent change.
|
||||
fn mutate(p: &Value) -> Value {
|
||||
use wasip2::filesystem::{
|
||||
preopens,
|
||||
types::{DescriptorFlags as D, OpenFlags as O, PathFlags as P},
|
||||
};
|
||||
let path = p["path"].as_str().unwrap();
|
||||
match p["op"].as_str().unwrap() {
|
||||
"mkdir" => std::fs::create_dir_all(path).unwrap(),
|
||||
"rename" => std::fs::rename(path, p["to"].as_str().unwrap()).unwrap(),
|
||||
"delete" => std::fs::remove_file(path).unwrap(),
|
||||
"rmdir" => std::fs::remove_dir(path).unwrap(),
|
||||
"atomic" => {
|
||||
let temp = format!("{path}.tmp");
|
||||
std::fs::write(&temp, "complete").unwrap();
|
||||
std::fs::rename(temp, path).unwrap();
|
||||
}
|
||||
"burst" => {
|
||||
for i in 0..5 {
|
||||
std::fs::write(format!("{path}/{i}"), "complete").unwrap();
|
||||
}
|
||||
}
|
||||
op => {
|
||||
let root = preopens::get_directories().remove(0).0;
|
||||
let file = root
|
||||
.open_at(
|
||||
P::empty(),
|
||||
path.trim_start_matches('/'),
|
||||
O::empty(),
|
||||
D::READ | D::WRITE,
|
||||
)
|
||||
.unwrap();
|
||||
match op {
|
||||
"descriptor" => {
|
||||
file.write(b"descriptor", 0).unwrap();
|
||||
}
|
||||
"stream" => {
|
||||
file.write_via_stream(0)
|
||||
.unwrap()
|
||||
.blocking_write_and_flush(b"stream")
|
||||
.unwrap();
|
||||
}
|
||||
"append" => {
|
||||
file.append_via_stream()
|
||||
.unwrap()
|
||||
.blocking_write_and_flush(b"append")
|
||||
.unwrap();
|
||||
}
|
||||
"size" => file.set_size(0).unwrap(),
|
||||
"truncate" => {
|
||||
root.open_at(
|
||||
P::empty(),
|
||||
path.trim_start_matches('/'),
|
||||
O::TRUNCATE,
|
||||
D::WRITE,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
_ => panic!("unknown mutation"),
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# Agents can edit provider settings. Provider API keys are visible inside the world.
|
||||
# Empty keys allow startup. Fill the matching provider key before a model request.
|
||||
# catalog selects a provider ID from pi-ai's catalog. It defaults to the provider's name.
|
||||
# Model overrides retain unspecified catalog fields, including API flags and price tiers.
|
||||
# Example: catalog: anthropic with models: [{id: claude-opus-5-5, context_window: 272000}].
|
||||
providers:
|
||||
anthropic:
|
||||
type: anthropic-messages
|
||||
base_url: https://api.anthropic.com
|
||||
api_key: ""
|
||||
models:
|
||||
- claude-opus-5-5
|
||||
openai:
|
||||
type: openai-responses
|
||||
base_url: https://api.openai.com/v1
|
||||
api_key: ""
|
||||
models:
|
||||
- gpt-6.1-sol
|
||||
- gpt-6-luna
|
||||
- gpt-6-astra
|
||||
main_agent_name: sausage
|
||||
prompt: |-
|
||||
## Sausage and the world
|
||||
|
||||
Sausage is a world of programs and durable agents. All agents see and change the same world.
|
||||
Use the programs in /bin through bash. Use explicit repository paths. Worktrees live at /worktrees/<id>.
|
||||
The platform source lives in /src/sausage. Saved extension file edits reload automatically. No Git commit is required.
|
||||
/etc/sausage/config.yaml contains this shared prompt, the main agent name, and provider settings.
|
||||
Agent definitions live in /etc/sausage/roster/<name>.md. Project definitions live in <repo>/.sausage/roster/*.md.
|
||||
You can read and edit the config and the roster files. Changes apply at the next run.
|
||||
Provider API keys in the config are intentionally visible. Other secrets stay in the host credentials manager.
|
||||
The host injects those credentials. A 403 requests approval. Never store secrets in memory or print them.
|
||||
The world web server (/bin/web.wasm, unit web.service) listens on port 8080. It serves the files in /srv/www, read fresh on each request,
|
||||
and forwards /api/<path> to http://host.sausage/<path>. The user's chat page is /srv/www/index.html.
|
||||
Agents write CLIs and servers. Do not add host imports beyond the process interface.
|
||||
|
||||
## Inspect the harness state
|
||||
|
||||
curl -s http://host.sausage/whoami returns {program, pid, conversations}. The first entry of conversations is your own conversation id.
|
||||
Read the live harness state with curl http://host.sausage/harness/...:
|
||||
- conversations: ids, titles, owners, models, thinking, tools, and running state.
|
||||
- conversations/ID/entries?after=N: conversation entries after entry N.
|
||||
- events?after=N: domain events after event N.
|
||||
- models: available models.
|
||||
- extensions: loaded extensions.
|
||||
- credential-requests: pending credential requests.
|
||||
Read /proc/processes for processes and /proc/extensions for extensions.
|
||||
Each conversation has id, title, running, and agent with model {provider, modelId}, thinkingLevel, and tools.
|
||||
Example: curl -s http://host.sausage/harness/conversations | jaq '.conversations[] | {id, title, model: .agent.model, thinking: .agent.thinkingLevel}'
|
||||
|
||||
# OpenAI-compatible chat-completions example (Ollama):
|
||||
# providers:
|
||||
# local:
|
||||
# type: openai-chat-completions
|
||||
# base_url: http://localhost:11434/v1
|
||||
# api_key: local
|
||||
# models:
|
||||
# - id: local-model
|
||||
# context_window: 32768
|
||||
# max_tokens: 4096
|
||||
# reasoning: false
|
||||
# cost: {input: 0, output: 0, cache_read: 0, cache_write: 0}
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
description: Coordinates one feature for the main agent
|
||||
model: anthropic/claude-opus-5-5
|
||||
thinking: high
|
||||
addTools: []
|
||||
---
|
||||
|
||||
# Feature coordinator
|
||||
|
||||
Own the problem and final verdict. Delegate execution, not judgment. Read relevant memory and repository instructions first.
|
||||
For a small clear change, implement it yourself and run the required checks.
|
||||
For a feature or exploration, assess first and change nothing until authorization arrives.
|
||||
An assessment states complexity, approximate code delta, reuse, recommendation, a simpler alternative, risks, and numbered decisions.
|
||||
Send it to the main agent first. Ask the user after the main agent recommends options, unless the main agent supplies an authorized decision.
|
||||
|
||||
## Execution
|
||||
|
||||
After approval, use plan with binary acceptance criteria. Delegate independent reconnaissance to scout and bounded implementation to worker or designer.
|
||||
A writing child gets a worktree. Each brief names the source repo, owned files, forbidden areas, known evidence, checks, and required report.
|
||||
Never assign overlapping edits concurrently. Do not delegate requirements or the final verdict.
|
||||
Inspect each child’s diff and actual check output. A claim or idle child is not evidence.
|
||||
Land accepted workers into your checkout. Run the required checks on the combined result.
|
||||
Give reviewer an immutable full-SHA base..head range, repo path, criteria, and validation evidence. Verify concrete findings yourself.
|
||||
Children never push. Never commit, reset, or land in the user’s checkout to test a result.
|
||||
Before workspace submit, report the outcome, exact checks, risks, and divergence from the task.
|
||||
Every repository-specific branch or workspace operation must name the explicit source repo path.
|
||||
If visual approval is required, ask the user for approval of the actual rendered result before submission.
|
||||
P3 has no browser capture tools. Request images through the coordinator or user, and name any visual checks you cannot verify.
|
||||
|
||||
## Progress
|
||||
|
||||
While children run, state which replies you await. Never declare completion before their reports arrive.
|
||||
End a turn only when a child, queued submission, or requested reply will wake you, or when you report a blocker.
|
||||
A failed submission requires a fix at its cause. Never skip a check or extend its deadline to hide a failure.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
description: Builds bounded product design for CLIs, servers, and interfaces.
|
||||
isolate: worktree
|
||||
denyTools: []
|
||||
model: anthropic/claude-opus-5-5
|
||||
thinking: high
|
||||
---
|
||||
|
||||
# Product designer
|
||||
|
||||
Solve the user’s stated problem within the assigned interface scope. Do not change backend contracts or dependencies without authorization.
|
||||
Identify the user, their goal, the first thing they must notice, and the most important action.
|
||||
Match existing patterns. Use hierarchy, spacing, readable text, and concrete action labels.
|
||||
For CLIs, review help text, stdout, stderr, exit status, and long or empty output.
|
||||
For web interfaces, cover empty, loading, error, long content, keyboard use, narrow widths, and applicable themes.
|
||||
Keep existing behavior and required checks intact. Name departures from the brief instead of silently changing its scope.
|
||||
Inspect rendered output before visual claims. P3 has no browser capture tools. Request screenshots from the coordinator or user.
|
||||
If images are unavailable, report visual verification as unverified. Automated checks do not replace visual approval.
|
||||
Do not delegate or push. Make one focused commit with --no-gpg-sign.
|
||||
Report the outcome, design decisions, divergence, image paths, exact checks, and remaining risks.
|
||||
End with Status: DONE | PARTIAL | BLOCKED and Commit: the full SHA or none.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
description: Performs one read-only learning pass and proposes evidence-based changes.
|
||||
fast: true
|
||||
tools:
|
||||
- read
|
||||
- bash
|
||||
- memory
|
||||
model: openai/gpt-6.1-sol
|
||||
thinking: medium
|
||||
---
|
||||
|
||||
# Read-only dream pass
|
||||
|
||||
Your brief names one pass: memory, failures, tools, corrections, codify, or project.
|
||||
Use its handoff, supplied conversation evidence, memory, and explicit repository files.
|
||||
Start from summaries. Inspect individual evidence only where a summary points to a concrete issue.
|
||||
Do not edit, commit, change state, message anyone, or delegate. Use bash only for inspection.
|
||||
P3 supplies no history-query tool. If the handoff lacks conversation evidence, name that limitation rather than invent a query.
|
||||
Report the overall finding, then prioritized findings with conversation ids, commits, or path:line citations and observed counts.
|
||||
Propose one change per slug with a title, size, risk, and evidence.
|
||||
Memory changes use add, update, or delete operations with reasons, but you do not apply them.
|
||||
Codify proposals name the memory they replace. Project proposals name the explicit repo and existing code they reuse.
|
||||
Never propose a storage chapter switch. Name all checks you cannot perform.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: Read-only review lead for an immutable commit range.
|
||||
tools:
|
||||
- read
|
||||
- bash
|
||||
- subagent
|
||||
model: openai/gpt-6-astra
|
||||
thinking: high
|
||||
---
|
||||
|
||||
# Review lead
|
||||
|
||||
Review the coordinator’s explicit repo, immutable full-SHA base..head range, and acceptance criteria.
|
||||
A missing or ambiguous range blocks verification. Never substitute another range.
|
||||
If reviewer-* definitions exist, start every one with the same brief. Do not share reports among reviewers.
|
||||
Wait for complete answers. A failed or idle child without an answer counts as missing.
|
||||
Merge findings without additions or omissions. Name every reviewer who raised each issue. Show disagreements explicitly.
|
||||
APPROVED requires approval from every reviewer. Otherwise report NEEDS CHANGES.
|
||||
Without a team, inspect git log, git diff, and relevant unchanged logic yourself.
|
||||
Remain read-only. Do not edit, commit, push, run project scripts, or execute mutation-capable checks.
|
||||
Read supplied validation output instead of rerunning suites. Report only issues the range introduces.
|
||||
P0 means breakage, data loss, or exposed secrets. P1 means likely functional failure.
|
||||
P2 blocks only if acceptance criteria require it. P3 means nonblocking polish.
|
||||
Report the reviewed range, reviewer verdicts, blocking and nonblocking findings, disagreements, observed verification, and residual risks.
|
||||
Each finding needs priority, path:line, impact, evidence, and a suggested fix. Never imply checks you did not run.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
description: "The main agent: starts branches, routes decisions, and maintains world memory."
|
||||
resident: true
|
||||
tools:
|
||||
- read
|
||||
- bash
|
||||
- message
|
||||
- note
|
||||
- notify
|
||||
- automation
|
||||
- branch
|
||||
- compact
|
||||
- remember
|
||||
- memory
|
||||
- todo
|
||||
- goal
|
||||
- edit
|
||||
- write
|
||||
model: anthropic/claude-opus-5-5
|
||||
thinking: high
|
||||
---
|
||||
|
||||
# Main agent
|
||||
|
||||
You are the user’s main agent in Sausage. Keep this conversation for the user and branch reports.
|
||||
Use the harness routes to answer questions about yourself and the world instead of guessing.
|
||||
Inspect your own model, the conversation tree, and what branches do through those routes.
|
||||
Read /src/sausage/AGENTS.md, the roster, skills, and relevant extension documents before answers about the platform.
|
||||
Read the how-sausage-works skill when the user asks for a walkthrough.
|
||||
|
||||
## Branches and decisions
|
||||
|
||||
Write configuration and memory yourself. Send product code to a coordinator through branch.
|
||||
Every branch brief names its repo path, goal, scope, acceptance criteria, and relevant memory slugs.
|
||||
A small clear change needs targeted checks. A feature needs an assessment before implementation unless the user explicitly waived it.
|
||||
After authorization, the coordinator plans, implements, verifies, reviews, and submits. Never declare success from an idle branch.
|
||||
Route decisions through message with numbered questions. Let coordinators ask their own decisions after you state your recommendation.
|
||||
Ask the user unless their instructions, memory, or active goal already settle the decision.
|
||||
During a hands-off goal, record each decision and its authority with goal. Credentials and approvals remain the user’s responsibility.
|
||||
Report a completed submission with its commit, checks, and divergence. Never push without authorization.
|
||||
|
||||
## Memory and open work
|
||||
|
||||
Use memory to read facts and remember to apply add, update, and delete operations.
|
||||
Record durable decisions, not state that the prompt already supplies. Never store secrets in memory.
|
||||
Use todo for open work, not unanswered questions. Use message for branch replies and notify for requested updates.
|
||||
On a failed run, inspect the supplied failure evidence before you resume the agent or request user action.
|
||||
Use compact with a self-contained handoff when context grows large.
|
||||
A dream is a read-only review of evidence and memory. Approved proposals become ordinary branches. Do not switch storage chapters.
|
||||
Use automation for schedules, event conditions, or supervised scripts. Verify its state with automation list.
|
||||
When the user requests specialization, read integrate-sausage and adopt-sausage-workflow before proposals.
|
||||
|
||||
|
||||
Act directly when the user asks you to change your setup. Edit /etc/sausage/config.yaml to change provider settings, the global prompt, or main_agent_name. Edit /etc/sausage/roster/<name>.md to change a model or role prompt. Project definitions live in <repo>/.sausage/roster/*.md. You can read and edit these files, including provider API keys in the config. Edit extensions in /src/sausage and commit to main to reload them. Edit the chat UI in /srv/www. The next request sees the edit without a build or restart. Do not only describe these changes. Make them when requested.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
description: Read-only reconnaissance of one code question with path:line evidence.
|
||||
tools:
|
||||
- read
|
||||
- bash
|
||||
model: openai/gpt-6-luna
|
||||
thinking: low
|
||||
---
|
||||
|
||||
# Read-only reconnaissance
|
||||
|
||||
Answer only the assigned question. Inspect the explicit repo with read and /bin programs through bash.
|
||||
Use rg, ls, git log, git show, and git diff for inspection. Never change files or state.
|
||||
Do not edit, commit, run project scripts, or delegate.
|
||||
Return a short conclusion, then path:line evidence and brief quotations where needed.
|
||||
Name everything you cannot find or verify.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
description: Writes and repairs one supervised automation script.
|
||||
tools:
|
||||
- read
|
||||
- write
|
||||
- edit
|
||||
- bash
|
||||
- automation
|
||||
model: openai/gpt-6-luna
|
||||
thinking: low
|
||||
---
|
||||
|
||||
# Automation script owner
|
||||
|
||||
Own the executable run file and automation id supplied in the brief. Use a WASI program or a supported shell in /bin.
|
||||
Prefer an existing blocking watch command over polling. Never poll faster than every 30 seconds.
|
||||
Store seen ids and persistent state in the supplied AUTOMATION_STATE directory.
|
||||
Print FIRE followed by the event description when the condition holds. Other stdout is progress. Never print credentials.
|
||||
A one-shot script exits after FIRE. A recurring script must not repeat seen events.
|
||||
If the recurring task explicitly permits completion, print DONE with a reason and exit zero when no work remains.
|
||||
Use the explicit repo path and automation file path from the brief. Report edits that remain uncommitted.
|
||||
Try the script briefly before automation resume with your id.
|
||||
On failure, inspect the supplied log, fix the cause, and resume. If the task cannot work, escalate with evidence.
|
||||
Every repair turn ends with automation resume or escalate. Keep reports short.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
description: Implements one bounded task and reports check evidence in a focused commit.
|
||||
fast: true
|
||||
isolate: worktree
|
||||
denyTools: []
|
||||
model: openai/gpt-6.1-sol
|
||||
thinking: medium
|
||||
---
|
||||
|
||||
# Bounded implementer
|
||||
|
||||
Implement exactly the assigned task in your worktree. Read the brief, repository instructions, and nearby code first.
|
||||
Reuse existing helpers. Do not expand scope, redesign the plan, delegate, or push. Report missing context as BLOCKED.
|
||||
Run the brief’s checks with /bin programs. Use targeted tests during iteration and required suites before the commit.
|
||||
Every behavior needs a test. Retain tests for the core path and actual bugs.
|
||||
Inspect the complete diff. Remove only your own temporary code. Stage only task files.
|
||||
Create one focused commit with --no-gpg-sign. Never commit failed checks.
|
||||
|
||||
## Report
|
||||
|
||||
Status: DONE | PARTIAL | BLOCKED
|
||||
Summary: the outcome or blocker.
|
||||
Acceptance criteria: each criterion with observed command output or path:line evidence.
|
||||
Validation: exact commands and results. Name all unverified checks.
|
||||
Commit: the full SHA and subject, or none.
|
||||
Divergence: differences from the brief, or None.
|
||||
Risks: remaining risks, or none.
|
||||
DONE requires evidence for every criterion.
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
# Run Sausage
|
||||
|
||||
Use the web UI for conversations, passkeys, approvals, and secret entry. The host serves only JSON through `/api/`.
|
||||
|
||||
**TL;DR:** Start a separate v3 world, create the SSH tunnel below, and open http://localhost:8080.
|
||||
The Nix package supplies the userland bundle, source tree, and enabled chat service.
|
||||
Keep the complete world folder between starts.
|
||||
|
||||
## Build and start
|
||||
|
||||
Run host commands from a checkout of the repository.
|
||||
Use Nix with flakes and the `pipe-operators` experimental feature.
|
||||
The package supplies the native Rust builder and its tools.
|
||||
|
||||
`nix build` builds the host and userland through separate Crane pipelines.
|
||||
The userland pipeline targets `wasm32-wasip2`.
|
||||
Separate Go derivations build awk and esbuild for WASI preview1.
|
||||
QuickJS builds from its vendored C sources with WASI clang.
|
||||
Nix assembles the final bundle without the development bundle script.
|
||||
Use `sausage run --no-builder-sandbox WORLD` only when the host must run the builder without namespace isolation.
|
||||
|
||||
The package entry point is:
|
||||
|
||||
```sh
|
||||
nix run . -- init "$HOME/sausage-v3"
|
||||
nix run . -- run "$HOME/sausage-v3" </dev/null
|
||||
```
|
||||
|
||||
The package sets `SAUSAGE_USERLAND` to its assembled bundle by default.
|
||||
For `init`, you can override it with `--userland BUNDLE` or the environment variable.
|
||||
`init` asks nothing and refuses an existing world. `run` refuses a folder without a world.
|
||||
A bundle contains `bin/`, `src/`, and the seeded `etc/` tree. It is not a world folder.
|
||||
Closing stdin only detaches the optional terminal console. The host and chat service continue.
|
||||
|
||||
The package sets `SAUSAGE_PRECOMPILED` to its read-only cache of compiled tools.
|
||||
The host checks `--cache` or `~/.cache/sausage` first, then this folder, then compiles missing tools into the writable cache.
|
||||
If Wasmtime rejects an artifact, the host logs one line and compiles the program lazily.
|
||||
For development or tests, run `sausage precompile BUNDLE OUT` and set `SAUSAGE_PRECOMPILED=OUT`.
|
||||
|
||||
The source-tree bundle builder is:
|
||||
|
||||
```sh
|
||||
nix develop -c nu userland/bundle.nu
|
||||
```
|
||||
|
||||
It builds the world commands incrementally with the configured Cargo cache.
|
||||
Pass a destination to place the bundle under your shared executor target directory.
|
||||
The script snapshots the current source files, including uncommitted edits.
|
||||
The source archive includes the complete `config/`, `skills/`, and `extensions/` directories.
|
||||
The bundle contains plain source files. `sausage init` copies them without a Git repository or first commit.
|
||||
Both bundle builders read `userland/tools.json` for the package and command names.
|
||||
Coreutils, diffutils, procps, and brush each occupy one real binary in `bin/`.
|
||||
Their command names are relative symlinks to those binaries.
|
||||
The world copier preserves these symlinks and dangling links in the source tree.
|
||||
A fresh development world needs no manual source copy.
|
||||
Both bundle builders copy `userland/web/www/` whole into `/srv/www/`, with the original filenames and the Marked license.
|
||||
The server crate, service unit, and test scripts stay outside that folder.
|
||||
The bundle includes `/bin/web.wasm` and an enabled `/etc/systemd/system/web.service`.
|
||||
World init starts that service on `0.0.0.0:8080`.
|
||||
To change its address, edit `ExecStart` to add `--listen ADDR`, then run `systemctl restart web` inside the world.
|
||||
|
||||
For a release host from the checkout:
|
||||
|
||||
```sh
|
||||
nix develop -c cargo build -p sausage --release --locked
|
||||
```
|
||||
|
||||
Cargo puts the host under `target/release/sausage`, unless `CARGO_TARGET_DIR` selects another directory.
|
||||
Use that binary with `init WORLD --userland BUNDLE`, then `run WORLD`.
|
||||
Use the repository's build slots and shared executor target directory for agent validation builds.
|
||||
|
||||
The host creates this layout:
|
||||
|
||||
| Host path | Purpose |
|
||||
| --- | --- |
|
||||
| `sausage-v3/world/` | The world root `/`, including programs and editable source. |
|
||||
| `sausage-v3/host/` | Private host state, including credentials, harness storage, undo, and builder caches. |
|
||||
|
||||
The host creates `/run` in memory. It generates `/proc` at runtime.
|
||||
It copies a bundle only when `WORLD/world` does not exist.
|
||||
A later run does not replace the world's tools, source, or config.
|
||||
Keep both `world/` and `host/` for recovery.
|
||||
Provider API keys in `/etc/sausage/config.yaml` are the explicit exception to host-only secret storage.
|
||||
|
||||
Sources: [`main.rs`](../host/src/main.rs), [`world.rs`](../host/src/world.rs),
|
||||
[`flake.nix`](../flake.nix), and [`bundle.nu`](../userland/bundle.nu).
|
||||
|
||||
## Use the chat and stop the host
|
||||
|
||||
The chat selects the configured main agent by default. Choose another conversation from the list.
|
||||
Root #1 remains only as history. A main agent rename keeps the same conversation.
|
||||
The page polls for new entries and shows text, collapsed tools, and pending credential requests.
|
||||
Use **Send** to submit a message and **Stop** to stop the selected conversation's active turn.
|
||||
The pending-requests banner opens the approval view in the page. The host verifies the passkey assertion.
|
||||
Agents edit `/srv/www/index.html`. Each request reads the file fresh without a build or restart.
|
||||
|
||||
The terminal console remains optional. Each nonempty console line submits a message to the main agent conversation.
|
||||
Assistant text appears on stdout. This is not a shell.
|
||||
Text such as `quit`, `help`, and `core.restart` has no special host meaning.
|
||||
Ask the agent to execute world commands when you need tools.
|
||||
|
||||
Press **Ctrl-C** to stop the host. SIGTERM also requests orderly shutdown.
|
||||
EOF only detaches the console input. It does not stop the host.
|
||||
The web chat remains available after console input closes.
|
||||
|
||||
After a crash, repeat the same start command with the same world folder.
|
||||
The host opens `host/harness.sqlite`, restores extensions, and resumes durable work.
|
||||
It does not replay the historical transcript into the console.
|
||||
It cannot restore ordinary process memory or reverse completed network effects.
|
||||
There is no automatic restart supervisor in this guide.
|
||||
|
||||
Sources: [`input.rs`](../host/src/harness/input.rs), [`main.ts`](../host/js/main.ts),
|
||||
and [`harness/mod.rs`](../host/src/harness/mod.rs).
|
||||
|
||||
## Reach the chat and approval pages
|
||||
|
||||
The world web server listens on port 8080. The host opens no separate port.
|
||||
If Sausage runs on another machine, forward the port with one SSH tunnel from your computer:
|
||||
|
||||
```sh
|
||||
ssh -N -L 8080:127.0.0.1:8080 YOUR-SERVER
|
||||
```
|
||||
|
||||
Open **http://localhost:8080**. The first-passkey notice opens the Passkeys view.
|
||||
The host derives the passkey RP ID and origin from the browser request Origin header.
|
||||
For direct VPN access, passkeys need HTTPS because non-localhost HTTP lacks a secure context.
|
||||
Passkeys registered for localhost do not transfer to a production hostname.
|
||||
Do not expose the server to other people before the first registration.
|
||||
|
||||
### Register, create an item, enter a secret
|
||||
|
||||
1. Select **Register your first passkey** in the chat notice and choose a label.
|
||||
2. Complete the browser's passkey ceremony.
|
||||
3. Ask the agent to file an item creation request through `POST /cred/requests`.
|
||||
4. Select the request in the pending-requests banner and approve it with your passkey.
|
||||
5. Read the item's public metadata to find its item ID and stable field ID.
|
||||
6. Select **Enter a secret**, then select the item's secret field.
|
||||
7. Enter the secret in the blank form and complete its passkey ceremony.
|
||||
|
||||
Until the first passkey exists, D53 permits unlocked decisions and first registration.
|
||||
After registration, decisions require a passkey. Additional registrations require an existing passkey.
|
||||
Secret entry requires a passkey even during bootstrap.
|
||||
The browser encrypts the secret before submission. The world never receives its plaintext.
|
||||
Do not paste secrets into chat or world files.
|
||||
|
||||
There is no item editor or credential dashboard yet.
|
||||
Item creation uses `kind: "edit"` and a stable request key.
|
||||
Its request has `item: null`, `base_version: null`, and a `change` array.
|
||||
The creation operation is `{op: "create_item", config: ITEM}`.
|
||||
`ITEM` must contain the complete public item configuration, including its fields, rules, and transformation.
|
||||
Further edits add fields and rules. Inspect the typed operations before you construct those edits.
|
||||
Public reads use `/cred/items` and `/cred/items/ITEM_ID`.
|
||||
They do not return stored secret values.
|
||||
|
||||
### Approve a request
|
||||
|
||||
The agent or world UI obtains pending requests from `/harness/credential-requests` or `/cred/requests?state=pending`.
|
||||
Follow the `/#approve/ID` link on the web origin through the tunnel.
|
||||
Review the stored summary and public details, choose limits when applicable, then approve or deny.
|
||||
The world can file a request. It cannot approve its own request.
|
||||
|
||||
For a manual Chrome check against a fresh world on port 8080, run:
|
||||
|
||||
```sh
|
||||
node userland/web/browser-check.mjs
|
||||
```
|
||||
|
||||
The script uses agent-browser and a CDP virtual authenticator. It registers a passkey and approves an item creation request.
|
||||
Set `AGENT_BROWSER_EXECUTABLE_PATH` if Chrome needs an explicit path.
|
||||
|
||||
Sources: [`host API`](../host/src/api/README.md), [`credential views`](../userland/web/www/credentials-view.js),
|
||||
[`edits.rs`](../host/src/cred/edits.rs), and [`browser cryptography`](../userland/web/www/credentials.js).
|
||||
|
||||
## Models
|
||||
|
||||
The world config is `/etc/sausage/config.yaml`, at `WORLD/world/etc/sausage/config.yaml` on the host.
|
||||
It contains `providers`, `main_agent_name`, and the global `prompt`. It contains no roster.
|
||||
Definitions live in `/etc/sausage/roster/<name>.md`.
|
||||
YAML frontmatter supplies `description`, `model`, `thinking`, and optional tool or isolation settings.
|
||||
The body supplies the role prompt. The filename supplies the name.
|
||||
Project definitions use `<repo>/.sausage/roster/*.md`.
|
||||
The eight built-ins are sausage, coordinator, worker, dream-pass, scout, watcher, reviewer, and designer.
|
||||
Every agent receives the global `prompt` before its role prompt.
|
||||
The shared prompt explains how agents inspect their own model and the live harness state.
|
||||
|
||||
Run `sausage init`. Set `providers.<name>.api_key` in the config, then run `sausage run`.
|
||||
The defaults use Anthropic Messages and OpenAI Responses, with empty keys.
|
||||
The config also shows a commented OpenAI-compatible Chat Completions endpoint.
|
||||
Each provider has `type`, `base_url`, `api_key`, and `models`. References use `<provider>/<model>`.
|
||||
An optional `catalog` selects a provider ID from pi-ai's catalog. It defaults to the configured provider's name.
|
||||
Each model retains pi-ai's complete catalog entry, including API flags, thinking mappings, input limits, and price tiers.
|
||||
The host needs no separate metadata download, cache, or snapshot.
|
||||
Model objects can override `context_window`, `max_tokens`, `reasoning`, and `cost`.
|
||||
Cost fields are `input`, `output`, `cache_read`, and `cache_write`, in USD per million tokens.
|
||||
Overrides retain unspecified catalog fields. The base URL always comes from the config.
|
||||
For example, `catalog: anthropic` with an Opus 5.5 context override retains adaptive thinking.
|
||||
An unknown model needs all metadata fields. Its error names the missing fields and config path.
|
||||
Pi-ai stores estimated call costs in assistant usage.
|
||||
|
||||
The host and chat start with empty keys. A model request with missing settings names the config file.
|
||||
Provider API keys in the config are intentionally visible to agents. Do not print or commit them.
|
||||
Other managed secrets remain outside the world and require normal credential approvals.
|
||||
|
||||
Edit `main_agent_name` to select a definition.
|
||||
To rename the main agent, rename its Markdown file and select the new filename without `.md`.
|
||||
The same conversation adopts the new name, model, and role prompt.
|
||||
The default is `sausage`, with `anthropic/claude-opus-5-5` and `high` thinking.
|
||||
Model, prompt, roster, and provider edits apply at the next run without a restart.
|
||||
|
||||
Sources: [`credentials.rs`](../host/src/harness/credentials.rs), [`main.ts`](../host/js/main.ts),
|
||||
and [`models`](../extensions/models/README.md).
|
||||
|
||||
## Change extensions
|
||||
|
||||
The platform source is `/src/sausage`. Each extension lives at `extensions/NAME/server.ts`.
|
||||
Save extension edits directly. No Git commit or restart is required.
|
||||
The VFS watcher waits for a short quiet period, then hashes the extension files.
|
||||
Unchanged contents do not reload.
|
||||
|
||||
The loader copies the files under `/run/sausage/extensions/HASH`.
|
||||
It validates the entire candidate before it replaces the live registry.
|
||||
A broken candidate leaves the previous good set active.
|
||||
An active tool call retains its implementation. The next call uses the new implementation.
|
||||
|
||||
Read `/proc/extensions` through the agent's file tools to inspect `current`, `good`, and `error`.
|
||||
Use `undo` to restore earlier files. The normal file watch reloads them.
|
||||
At startup, the loader hashes the current files, including edits made while the host was offline.
|
||||
If they fail, it loads the last good copy from `host/harness.sqlite` and shows the error.
|
||||
The fallback does not change editable files.
|
||||
|
||||
Source: [`extensions`](../extensions/README.md) and [`extensions.ts`](../host/js/extensions.ts).
|
||||
|
||||
## Everyday tools inside the world
|
||||
|
||||
These are world programs, not console commands. Ask the agent to run them.
|
||||
|
||||
| Tool | Use and limits |
|
||||
| --- | --- |
|
||||
| `cargo` | Build, run, and test Rust programs. The host copies the workspace through the VFS, fetches dependencies, then builds offline in namespaces. It publishes WASI artifacts into the world. Other targets are unsupported. |
|
||||
| `undo` | Inspect tags and restore filesystem changes. Later edits can conflict. Network effects and native builds are not reversible. Retention is seven days or 4 GiB of unique content. |
|
||||
| `systemctl` | Control world init services, not the host machine's systemd. Persistent units live under `/etc/systemd/system`. Init observes edits without daemon reload. |
|
||||
| `journalctl` | Read world service logs under `/var/log/journal`. Each unit retains a 1 MiB log and one rotated file. |
|
||||
| `git` | Manage world repositories. HTTPS credential rules can inject authorization through the host HTTP hook. SSH remotes use host signing. |
|
||||
| `ssh` | Execute a remote command without a PTY. Public identities and signatures come from the host. Private keys never enter the world. Host keys live in the world's known_hosts file. |
|
||||
|
||||
Init starts when the host starts the runtime, if `/bin/init.wasm` exists.
|
||||
Transient units under `/run/systemd/transient` disappear after restart.
|
||||
A missing credential grant causes a pending host request, not an automatic approval.
|
||||
A changed SSH host key fails even with the accept-new policy.
|
||||
|
||||
Command reference and implementation evidence:
|
||||
[`builder`](../host/src/builder/README.md),
|
||||
[`undo`](../userland/undo/README.md), [`init`](../userland/init/README.md),
|
||||
[`git`](../userland/git/README.md), and [`ssh`](../userland/ssh/README.md).
|
||||
|
||||
## Build a world UI with the host API
|
||||
|
||||
World programs call **`http://host.sausage`** through standard WASI HTTP.
|
||||
The world web server forwards `/api/PATH` to `http://host.sausage/PATH` through WASI HTTP.
|
||||
It streams response chunks instead of buffering the entire NDJSON body.
|
||||
The bootstrap page polls `entries?after=` once per second rather than using a persistent stream.
|
||||
Use the credential views in the web UI. They call only `/api/cred/...` JSON routes.
|
||||
|
||||
| Method and route | Result or request |
|
||||
| --- | --- |
|
||||
| `GET /harness/conversations` | Conversation records and `main_agent: {name, conversationId}` for the default selection. |
|
||||
| `GET /harness/conversations/ID/entries?after=0` | `{entries:[...]}` in ascending order. |
|
||||
| `GET /harness/conversations/ID/stream?after=0` | NDJSON entries, replay first, then live commits. |
|
||||
| `POST /harness/conversations/ID/submit` | `{content:"text",requestId:"stable-id"}` returns 202 and a submission ID. Reuse the request ID for retries. |
|
||||
| `POST /harness/conversations/ID/stop` | Stops active turns and owned work. Queued input remains. |
|
||||
| `GET /harness/events?after=0` | NDJSON events, replay first, then live commits. |
|
||||
| `POST /harness/extensions/NAME/actions/ACTION` | `{args:{...},conversationId?:number}` invokes an exported user action. |
|
||||
| `GET /harness/models` | Public model catalog without authentication data. |
|
||||
| `GET /harness/extensions` | Loaded extension status. |
|
||||
| `GET /harness/credential-requests` | Pending conversation requests with `/#approve/ID` links. |
|
||||
|
||||
The `after` cursor is exclusive. Reconnect with the last record ID.
|
||||
Streams close when the client disconnects or the harness stops.
|
||||
Do not render a stream as one JSON response.
|
||||
An unavailable harness returns 503. Invalid input returns 400. Missing resources return 404.
|
||||
|
||||
Source: [`host API contract`](../host/src/api/README.md),
|
||||
[`harness-api.ts`](../host/js/harness-api.ts), and [`harness/http.rs`](../host/src/harness/http.rs).
|
||||
|
||||
## Known gaps before cutover
|
||||
|
||||
- Laptop access, passkey ceremonies, secret entry, and browser approval remain **to check with Surma**.
|
||||
- Startup checks use fake provider credentials. They do not verify a real model session or external credential injection.
|
||||
- The bootstrap chat is deliberately plain. There is no item editor or credential dashboard.
|
||||
- The console has no shell commands, transcript navigation, or special quit command.
|
||||
- `sausage sh` requires `-c`. Its interactive shell is not implemented.
|
||||
|
||||
A successful local start does not verify external services or the production passkey origin.
|
||||
@@ -1,85 +0,0 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"AddParams": {
|
||||
"description": "Parameters for a new note.",
|
||||
"properties": {
|
||||
"text": {
|
||||
"description": "The complete note text, including any newlines.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"text"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Added": {
|
||||
"description": "The result after a note enters the list.",
|
||||
"properties": {
|
||||
"count": {
|
||||
"description": "The number of notes, including the new note.",
|
||||
"format": "uint",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"count"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"title": "notes",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"methods": [
|
||||
{
|
||||
"description": "Add a note and return the new count.",
|
||||
"name": "add",
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"description": "The complete note text, including any newlines.",
|
||||
"name": "text",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "The complete note text, including any newlines.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Added"
|
||||
}
|
||||
},
|
||||
"summary": "Add a note and return the new count.",
|
||||
"x-sausage-params": {
|
||||
"$ref": "#/components/schemas/AddParams"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "List all notes, oldest first.",
|
||||
"name": "list",
|
||||
"paramStructure": "by-name",
|
||||
"params": [],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"summary": "List all notes, oldest first."
|
||||
}
|
||||
],
|
||||
"openrpc": "1.2.6",
|
||||
"x-sausage-endpoints": {},
|
||||
"x-sausage-imports": {}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"GetParams": {
|
||||
"description": "The name of a secret to read.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "The unique name within this vault.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PutParams": {
|
||||
"description": "The name and value of a secret.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "The unique name within this vault.",
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"description": "The text to store until process exit.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"value"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"title": "secrets",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"methods": [
|
||||
{
|
||||
"description": "Open the in-memory vault and publish its endpoint.",
|
||||
"name": "serve",
|
||||
"paramStructure": "by-name",
|
||||
"params": [],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"summary": "Open the in-memory vault and publish its endpoint."
|
||||
}
|
||||
],
|
||||
"openrpc": "1.2.6",
|
||||
"x-sausage-endpoints": {
|
||||
"vault": {
|
||||
"methods": [
|
||||
{
|
||||
"description": "Store a secret and report its name to the caller.",
|
||||
"name": "put",
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"description": "The unique name within this vault.",
|
||||
"name": "name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "The unique name within this vault.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "The text to store until process exit.",
|
||||
"name": "value",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "The text to store until process exit.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"summary": "Store a secret and report its name to the caller.",
|
||||
"x-sausage-params": {
|
||||
"$ref": "#/components/schemas/PutParams"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Read a secret, or return null when its name is absent.",
|
||||
"name": "get",
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"description": "The unique name within this vault.",
|
||||
"name": "name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "The unique name within this vault.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"summary": "Read a secret, or return null when its name is absent.",
|
||||
"x-sausage-params": {
|
||||
"$ref": "#/components/schemas/GetParams"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"x-sausage-imports": {}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"StartParams": {
|
||||
"description": "The server configuration.",
|
||||
"properties": {
|
||||
"port": {
|
||||
"description": "The HTTP port on all network interfaces.",
|
||||
"format": "uint16",
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"port"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"title": "www",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"methods": [
|
||||
{
|
||||
"description": "Start the HTTP server and return its process ID.",
|
||||
"name": "start",
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"description": "The HTTP port on all network interfaces.",
|
||||
"name": "port",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "The HTTP port on all network interfaces.",
|
||||
"format": "uint16",
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"summary": "Start the HTTP server and return its process ID.",
|
||||
"x-sausage-params": {
|
||||
"$ref": "#/components/schemas/StartParams"
|
||||
}
|
||||
}
|
||||
],
|
||||
"openrpc": "1.2.6",
|
||||
"x-sausage-endpoints": {
|
||||
"control": {
|
||||
"methods": [
|
||||
{
|
||||
"description": "Count the HTTP requests this server answered.",
|
||||
"name": "stats",
|
||||
"paramStructure": "by-name",
|
||||
"params": [],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"format": "uint64",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"summary": "Count the HTTP requests this server answered."
|
||||
},
|
||||
{
|
||||
"description": "End this process after the response reaches the client.",
|
||||
"name": "stop",
|
||||
"paramStructure": "by-name",
|
||||
"params": [],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"summary": "End this process after the response reaches the client."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"x-sausage-grace": 5,
|
||||
"x-sausage-imports": {}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
//! Use typed calls from discovered provider documents in `imports/`.
|
||||
//! Generated modules own the wire types, so calls need no manual JSON.
|
||||
//! The detached HTTP service survives this consumer. Its endpoint refers to that service's process-local state.
|
||||
//! Copy fresh documents from `/proc/core/plugins/<provider>/discover.json` after an interface changes.
|
||||
use sausage_plugin::{Result, rpc, schema};
|
||||
sausage_plugin::import!("notes");
|
||||
sausage_plugin::import!("www");
|
||||
sausage_plugin::import!("secrets");
|
||||
/// Demonstrate typed imports without manual JSON messages.
|
||||
struct Consumer;
|
||||
/// A note for the imported notes plugin.
|
||||
#[schema]
|
||||
struct NoteParams {
|
||||
/// The text to append.
|
||||
text: String,
|
||||
}
|
||||
/// The text and HTTP port for this demonstration.
|
||||
#[schema]
|
||||
struct DemoParams {
|
||||
/// The note text to append.
|
||||
text: String,
|
||||
/// The port for the detached HTTP server.
|
||||
port: u16,
|
||||
}
|
||||
#[rpc]
|
||||
impl Consumer {
|
||||
/// Add a note through the generated import and return its count.
|
||||
async fn note(&self, p: NoteParams) -> Result<usize> {
|
||||
Ok(notes::add(notes::AddParams { text: p.text }).await?.count as usize)
|
||||
}
|
||||
/// Add a note, start a server, and inspect its control endpoint.
|
||||
async fn demo(&self, p: DemoParams) -> Result<u64> {
|
||||
let added = notes::add(notes::AddParams { text: p.text }).await?;
|
||||
eprintln!("{} notes", added.count);
|
||||
let pid = www::start(www::StartParams { port: p.port })
|
||||
.detach()
|
||||
.await?;
|
||||
let control = www::control::connect(format!("/proc/{pid}/control")).await?;
|
||||
control.stats().await
|
||||
}
|
||||
/// Store and read a value through the boot service's fixed endpoint.
|
||||
async fn vault(&self) -> Result<Option<String>> {
|
||||
let vault = secrets::vault::connect("/run/secrets").await?;
|
||||
vault
|
||||
.put(secrets::PutParams {
|
||||
name: "example".into(),
|
||||
value: "hello".into(),
|
||||
})
|
||||
.on_notify(|name, params| eprintln!("{name}: {params}"))
|
||||
.await?;
|
||||
vault
|
||||
.get(secrets::GetParams {
|
||||
name: "example".into(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
/// Start the SDK transport for this plugin.
|
||||
fn main() {
|
||||
sausage_plugin::run(Consumer)
|
||||
}
|
||||
/// Tests check generated wire types without other plugins in the test world.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// Generated params retain their wire field names.
|
||||
#[test]
|
||||
fn generated_params_use_text() {
|
||||
let value = sausage_plugin::serde_json::to_value(notes::AddParams {
|
||||
text: "milk".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(value, sausage_plugin::serde_json::json!({"text":"milk"}));
|
||||
}
|
||||
/// A generated result decodes without another plugin in the test world.
|
||||
#[test]
|
||||
fn generated_result_decodes_count() {
|
||||
let added: notes::Added = sausage_plugin::serde_json::from_str(r#"{"count":2}"#).unwrap();
|
||||
assert_eq!(added.count, 2);
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
//! Persistent notes in `/state/notes/notes.json`.
|
||||
//! Each call has its own process. The add method locks the file before its read-modify-write operation.
|
||||
//! Atomic replacement lets readers observe a complete list without a lock.
|
||||
use sausage_plugin::serde_json;
|
||||
use sausage_plugin::{Result, lock, rpc, schema, state_dir, write_atomic};
|
||||
/// The stateless notes plugin reads its file on each call.
|
||||
struct Notes;
|
||||
/// Parameters for a new note.
|
||||
#[schema]
|
||||
struct AddParams {
|
||||
/// The complete note text, including any newlines.
|
||||
text: String,
|
||||
}
|
||||
/// The result after a note enters the list.
|
||||
#[schema]
|
||||
struct Added {
|
||||
/// The number of notes, including the new note.
|
||||
count: usize,
|
||||
}
|
||||
#[rpc]
|
||||
impl Notes {
|
||||
/// Add a note and return the new count.
|
||||
async fn add(&self, AddParams { text }: AddParams) -> Result<Added> {
|
||||
let path = state_dir().join("notes.json");
|
||||
let _guard = lock(&path).await?;
|
||||
let mut notes = load()?;
|
||||
notes.push(text);
|
||||
std::fs::create_dir_all(state_dir())?;
|
||||
write_atomic(path, serde_json::to_vec(¬es)?)?;
|
||||
Ok(Added { count: notes.len() })
|
||||
}
|
||||
/// List all notes, oldest first.
|
||||
fn list(&self) -> Result<Vec<String>> {
|
||||
load()
|
||||
}
|
||||
}
|
||||
/// Read the list, or return an empty list when the file does not exist.
|
||||
fn load() -> Result<Vec<String>> {
|
||||
match std::fs::read(state_dir().join("notes.json")) {
|
||||
Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
/// Start the SDK transport for notes.
|
||||
fn main() {
|
||||
sausage_plugin::run(Notes)
|
||||
}
|
||||
|
||||
/// Tests run in fresh worlds under the WASI test runner.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// The core lock protects two sequential additions and their counts.
|
||||
#[sausage_plugin::test]
|
||||
async fn add_appends_and_counts() {
|
||||
assert_eq!(
|
||||
Notes
|
||||
.add(AddParams {
|
||||
text: "milk".into()
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.count,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
Notes
|
||||
.add(AddParams {
|
||||
text: "eggs".into()
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.count,
|
||||
2
|
||||
);
|
||||
assert_eq!(Notes.list().unwrap(), ["milk", "eggs"]);
|
||||
}
|
||||
/// A fresh test world has no notes file.
|
||||
#[test]
|
||||
fn list_is_empty_without_a_file() {
|
||||
assert!(Notes.list().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
[package]
|
||||
name = "secrets"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
[dependencies]
|
||||
sausage-plugin = { path = "../../sdk" }
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Keep a process-local vault behind a fixed endpoint at `/run/secrets`.
|
||||
//! Start `secrets.serve` as a detached boot service. Its memory lasts until process exit.
|
||||
use sausage_plugin::{Ctx, Result, listen, rpc, schema};
|
||||
use std::{cell::RefCell, collections::BTreeMap, rc::Rc};
|
||||
/// The service entry point publishes one process-local vault.
|
||||
struct Secrets;
|
||||
#[rpc(endpoints(vault=Vault))]
|
||||
impl Secrets {
|
||||
/// Open the in-memory vault and publish its endpoint.
|
||||
async fn serve(&self) -> Result<()> {
|
||||
listen("/run/secrets", Rc::new(Vault::default())).await
|
||||
}
|
||||
}
|
||||
/// The endpoint owns secrets in memory, not on disk.
|
||||
#[derive(Default)]
|
||||
struct Vault {
|
||||
/// Named values disappear when this process ends.
|
||||
entries: RefCell<BTreeMap<String, String>>,
|
||||
}
|
||||
/// The name and value of a secret.
|
||||
#[schema]
|
||||
struct PutParams {
|
||||
/// The unique name within this vault.
|
||||
name: String,
|
||||
/// The text to store until process exit.
|
||||
value: String,
|
||||
}
|
||||
/// The name of a secret to read.
|
||||
#[schema]
|
||||
struct GetParams {
|
||||
/// The unique name within this vault.
|
||||
name: String,
|
||||
}
|
||||
#[rpc]
|
||||
impl Vault {
|
||||
/// Store a secret and report its name to the caller.
|
||||
fn put(&self, ctx: &Ctx, p: PutParams) -> Result<()> {
|
||||
ctx.notify("stored", sausage_plugin::serde_json::json!({"name":p.name}))?;
|
||||
self.entries.borrow_mut().insert(p.name, p.value);
|
||||
Ok(())
|
||||
}
|
||||
/// Read a secret, or return null when its name is absent.
|
||||
fn get(&self, p: GetParams) -> Result<Option<String>> {
|
||||
Ok(self.entries.borrow().get(&p.name).cloned())
|
||||
}
|
||||
}
|
||||
/// Start the SDK transport for the vault service.
|
||||
fn main() {
|
||||
sausage_plugin::run(Secrets)
|
||||
}
|
||||
/// Tests verify absent keys and process-local storage.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// Missing names return null instead of an RPC error.
|
||||
#[test]
|
||||
fn absent_secret() {
|
||||
assert_eq!(
|
||||
Vault::default()
|
||||
.get(GetParams {
|
||||
name: "missing".into()
|
||||
})
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
/// Store a value and retrieve it through the same process-local vault.
|
||||
#[sausage_plugin::test]
|
||||
async fn stores_secret() {
|
||||
let vault = Vault::default();
|
||||
vault
|
||||
.put(
|
||||
&Ctx::default(),
|
||||
PutParams {
|
||||
name: "token".into(),
|
||||
value: "example".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
vault
|
||||
.get(GetParams {
|
||||
name: "token".into()
|
||||
})
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("example")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
[package]
|
||||
name = "www"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
[dependencies]
|
||||
sausage-plugin = { path = "../../sdk" }
|
||||
wstd = "0.6"
|
||||
@@ -1,97 +0,0 @@
|
||||
//! Serve files from `/state/www` and expose a control endpoint at `/self/control`.
|
||||
//! Start this process detached or as a service so it outlives its caller.
|
||||
//! Clients read `/proc/<pid>/control`. A fresh core call cannot inspect this process's counters.
|
||||
use sausage_plugin::{Ctx, Result, background, listen, pid, rpc, schema, state_dir};
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
use wstd::{
|
||||
io::{AsyncRead, AsyncWrite},
|
||||
iter::AsyncIterator,
|
||||
net::TcpListener,
|
||||
};
|
||||
/// The HTTP plugin starts a server and returns its process ID.
|
||||
struct Www;
|
||||
/// The server configuration.
|
||||
#[schema]
|
||||
struct StartParams {
|
||||
/// The HTTP port on all network interfaces.
|
||||
port: u16,
|
||||
}
|
||||
#[rpc(endpoints(control=Control),grace_seconds=5)]
|
||||
impl Www {
|
||||
/// Start the HTTP server and return its process ID.
|
||||
async fn start(&self, p: StartParams) -> Result<String> {
|
||||
let tcp = TcpListener::bind(&format!("0.0.0.0:{}", p.port)).await?;
|
||||
let control = Rc::new(Control { hits: Cell::new(0) });
|
||||
listen("/self/control", control.clone()).await?;
|
||||
background(serve(tcp, control));
|
||||
Ok(pid())
|
||||
}
|
||||
}
|
||||
/// Shared process-local state for the HTTP task and its endpoint.
|
||||
struct Control {
|
||||
/// The number of answered requests.
|
||||
hits: Cell<u64>,
|
||||
}
|
||||
#[rpc]
|
||||
impl Control {
|
||||
/// Count the HTTP requests this server answered.
|
||||
fn stats(&self) -> Result<u64> {
|
||||
Ok(self.hits.get())
|
||||
}
|
||||
/// End this process after the response reaches the client.
|
||||
fn stop(&self, ctx: &Ctx) -> Result<()> {
|
||||
ctx.exit_after_response();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
/// Answer HTTP requests with files and count all responses, including missing files.
|
||||
async fn serve(tcp: TcpListener, control: Rc<Control>) {
|
||||
let mut incoming = tcp.incoming();
|
||||
while let Some(Ok(mut stream)) = incoming.next().await {
|
||||
let mut request = [0; 8192];
|
||||
let Ok(size) = stream.read(&mut request).await else {
|
||||
continue;
|
||||
};
|
||||
let line = String::from_utf8_lossy(&request[..size]);
|
||||
let path = line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.unwrap_or("/")
|
||||
.trim_start_matches('/');
|
||||
let (status, body) = match std::fs::read(state_dir().join(if path.is_empty() {
|
||||
"index.html"
|
||||
} else {
|
||||
path
|
||||
})) {
|
||||
Ok(body) => ("200 OK", body),
|
||||
Err(_) => ("404 Not Found", b"Not found".to_vec()),
|
||||
};
|
||||
control.hits.set(control.hits.get() + 1);
|
||||
let header = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(header.as_bytes()).await;
|
||||
let _ = stream.write_all(&body).await;
|
||||
let _ = stream.flush().await;
|
||||
}
|
||||
}
|
||||
/// Start the SDK transport for the HTTP plugin.
|
||||
fn main() {
|
||||
sausage_plugin::run(Www)
|
||||
}
|
||||
/// Tests check local counters and the service entry point.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// Control reports its shared counter without a core round trip.
|
||||
#[test]
|
||||
fn stats_reads_counter() {
|
||||
assert_eq!(Control { hits: Cell::new(3) }.stats().unwrap(), 3);
|
||||
}
|
||||
/// The listener binds an ephemeral port and publishes its control endpoint.
|
||||
#[sausage_plugin::test]
|
||||
async fn starts_on_available_port() {
|
||||
assert_eq!(Www.start(StartParams { port: 0 }).await.unwrap(), pid());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# World extensions
|
||||
|
||||
The harness loads files under `/src/sausage/extensions/`.
|
||||
Each extension occupies `<name>/server.ts` in that directory. No Git repository or commit is required.
|
||||
The default export is an extension or a factory that receives a `Host`.
|
||||
The extension name must match its folder.
|
||||
An optional numeric `order` export controls order. Names break ties.
|
||||
|
||||
## Reload contract
|
||||
|
||||
The VFS watcher observes the extension directory recursively.
|
||||
After changes, the harness waits for 75 milliseconds of quiet before it reads the files.
|
||||
It computes a SHA-256 hash over sorted paths and their byte contents, only at startup and after file changes.
|
||||
Unchanged contents do not reload.
|
||||
The harness copies the captured bytes to `/run/sausage/extensions/<hash>/extensions/`.
|
||||
Imports read that snapshot through the world VFS, not the editable files.
|
||||
Relative imports must include their file extensions.
|
||||
TypeScript modules use the same `deno_ast` transpiler as the harness.
|
||||
|
||||
The harness imports and validates the whole file set before it changes the live registry.
|
||||
An import error, factory error, name mismatch, or registry validation error retains the previous set.
|
||||
A running tool call keeps its old implementation. The next call uses the new implementation.
|
||||
Removed extensions leave the registry after a successful reload.
|
||||
|
||||
`/proc/extensions` contains JSON with `good`, `current`, and `error`. Version values are content hashes.
|
||||
A session document persists these values in the host database, `<dir>/host/harness.sqlite`.
|
||||
After successful validation, another session document retains the complete last good file set in that database.
|
||||
Broken edits do not replace that copy.
|
||||
On startup, the harness hashes and loads the current files, including offline edits.
|
||||
If they fail, it loads the host-side copy and reports the current error before `resume()`.
|
||||
Tasks without a registered definition remain blocked.
|
||||
The snapshot directories disappear with `/run` on restart. The host-side copy survives.
|
||||
|
||||
## Host
|
||||
|
||||
The host supplies these members:
|
||||
|
||||
- `harness()`, `storage.scanSubmissions`, `models()`, and the conversation `metadata` document token.
|
||||
- `settings()`, from `/src/sausage/settings.json`. The VFS watcher detects changes to this file.
|
||||
- `fs.read`, `write`, `append`, `list`, `stat`, `lstat`, `mkdir`, `remove`, and `rename` on world paths.
|
||||
- `exec(command, options)` for shell strings or argument arrays. Its result contains `code`, `stdout`, and `stderr`.
|
||||
- Exec options: `cwd`, `env` pairs, `timeout` in seconds, `conversations`, `signal`, and `onOutput(text, stream)`.
|
||||
- `git(cwd, ...args)`, which returns trimmed stdout and throws with stderr on failure.
|
||||
- `implement(api, value)`, which stages a service until the file set installs.
|
||||
- `need(api)`, which returns a proxy. Every method call resolves the current provider, including a retained method reference.
|
||||
- `want(api)`, which returns a proxy or `undefined` when no provider exists.
|
||||
- `onOpen(callback)` and `onClose(callback)` for facet lifecycle.
|
||||
- `resource(key, create)`, which retains a value across reloads. Keys belong to their extension name.
|
||||
- `notice(message)`, which writes a console line and the latest notice to `/proc/extensions`.
|
||||
|
||||
P3-d will add `emit`, `react`, and `eventLog` at the marked seam in `host/js/extensions.ts`.
|
||||
Those APIs will supply transactional events and persistent reactor cursors.
|
||||
|
||||
The synthetic package modules reuse the bundle's instances:
|
||||
|
||||
- `@earendil-works/pi-durable` and `/tools`
|
||||
- `@earendil-works/pi-ai` and `/models`
|
||||
- `@earendil-works/pi-schema`
|
||||
- `@earendil-works/chord` and `/context`
|
||||
|
||||
New package surfaces require a namespace in `registerModules()`.
|
||||
They must not bundle a second pi-durable instance.
|
||||
The registry supplies the default extension selection for existing conversations.
|
||||
New root conversations do not pin the built-ins.
|
||||
Explicit per-conversation extension selections remain explicit.
|
||||
|
||||
## Restore files
|
||||
|
||||
Use `undo` to restore earlier files. The normal file watch reloads the restored set.
|
||||
The startup fallback does not change editable files.
|
||||
|
||||
## Tests
|
||||
|
||||
`host/tests/it/extensions.rs` runs `host/js/extensions-test-bundle.js` with a faux model and a process interface that rejects all commands.
|
||||
It tests file edits, broken edits, a multi-file burst, an old call across reload, and a retained service handle.
|
||||
It also tests unchanged hashes, offline edits, and recovery from the host-side copy across restarts.
|
||||
The init tests verify that `/src/sausage` contains no `.git` directory.
|
||||
The Nix shell preserves the user's Cargo configuration and its kache wrapper.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Automations
|
||||
|
||||
The `automation` tool admits schedules, domain events, context conditions, and shell scripts.
|
||||
The ledger stores durable deadlines and fire counts.
|
||||
A fire creates its subscriber deliveries in the same transaction.
|
||||
Each delivery uses a stable request id, so a restart does not duplicate the subscriber input.
|
||||
Prompt runs use owned subagent conversations.
|
||||
|
||||
## Definitions and scripts
|
||||
|
||||
Each automation occupies `/src/sausage/automations/A<n>/` in the world.
|
||||
The tool writes `automation.json` there as a readable copy of its definition.
|
||||
The durable ledger remains authoritative.
|
||||
The extension does not import manual changes to this copy.
|
||||
Deletion retains the files.
|
||||
|
||||
A script uses `script.sh` in that directory.
|
||||
If the file exists, the extension executes it with world `sh`.
|
||||
Otherwise, a watcher writes it from the `ask` request before execution.
|
||||
The watcher inherits the creator's agent configuration.
|
||||
The script receives a one-hour timeout.
|
||||
The host cancels execution when the task aborts.
|
||||
|
||||
Only stdout lines with `FIRE` trigger deliveries.
|
||||
An optional message follows `FIRE`.
|
||||
Stderr lines never trigger deliveries.
|
||||
`DONE` takes effect after a successful exit.
|
||||
The extension retains a bounded log in `log`.
|
||||
Script tasks deduplicate their fire sequence across restarts.
|
||||
External script effects can repeat after a crash.
|
||||
|
||||
## Credentials
|
||||
|
||||
The conversation that creates an automation remains its execution conversation.
|
||||
Every script launch uses `host.chain(creator)`.
|
||||
The host delegates to `conversationChains` in `host/js/environment.ts`.
|
||||
The host appends the branch starter's chain after the ownership or fork ancestry.
|
||||
The host removes duplicate ids without changing their order.
|
||||
Subscribe and unsubscribe change notification recipients only.
|
||||
They never change the execution conversation.
|
||||
Built-in automations without a creator use an empty chain.
|
||||
No secret value enters a definition, script environment, or watcher prompt.
|
||||
|
||||
## Triggers
|
||||
|
||||
Schedules accept `cron`, `at`, `in`, and `every` forms.
|
||||
The five imported schedule cases verify IANA zones, leap days, and daylight-saving transitions in V8.
|
||||
Events use the durable event service and its reactor cursor.
|
||||
Conditions use measured prompt tokens and the model's context window.
|
||||
A decrease of ten percentage points below the threshold re-arms a condition.
|
||||
The cooldown starts with a condition fire, not a manual fire.
|
||||
For `idleFor`, admitted conversation inputs reset the idle period.
|
||||
|
||||
## Scope
|
||||
|
||||
The initial port supports create, list, events, run, pause, resume, subscribe, unsubscribe, runs, log, and delete.
|
||||
It retains the schedule and prompt-run implementation from Main agent.
|
||||
It replaces native process groups with host Exec.
|
||||
Project-manifest discovery and automatic script repair remain outside this initial port.
|
||||
Credentials use grants, not named secret files.
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Durable automation lifecycle events. */
|
||||
import { defineEvent } from '@earendil-works/sausage-events'
|
||||
export const Fired = defineEvent('automations.fired')
|
||||
export const Started = defineEvent('automations.started')
|
||||
export const Exited = defineEvent('automations.exited')
|
||||
export const Done = defineEvent('automations.done')
|
||||
export const Escalated = defineEvent('automations.escalated')
|
||||
@@ -0,0 +1,154 @@
|
||||
/** Calendar schedules, interval deadlines, and cron time zones. */
|
||||
import type { Schedule } from './types.ts'
|
||||
export type { Schedule } from './types.ts'
|
||||
|
||||
function duration(text: string) {
|
||||
const match = /^(\d+(?:\.\d+)?)\s*(s|m|h|d)$/.exec(text)
|
||||
if (!match) throw new Error('Use a duration such as 30m, 2h, or 1d')
|
||||
return Number(match[1]) * { s: 1000, m: 60000, h: 3600000, d: 86400000 }[match[2]]!
|
||||
}
|
||||
|
||||
function field(text: string, min: number, max: number) {
|
||||
const values = new Set<number>()
|
||||
for (const item of text.split(',')) {
|
||||
const match = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(item)
|
||||
if (!match) throw new Error(`Invalid cron field: ${text}`)
|
||||
const step = Number(match[2] ?? 1)
|
||||
const [start, end] =
|
||||
match[1] === '*'
|
||||
? [min, max]
|
||||
: match[1].includes('-')
|
||||
? match[1].split('-').map(Number)
|
||||
: [Number(match[1]), match[2] ? max : Number(match[1])]
|
||||
if (step < 1 || start < min || end > max || start > end)
|
||||
throw new Error(`Invalid cron field: ${text}`)
|
||||
for (let value = start; value <= end; value += step) values.add(value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
function cron(expr: string, tz: string) {
|
||||
const parts = expr.trim().split(/\s+/)
|
||||
if (parts.length !== 5) throw new Error('Cron requires five fields')
|
||||
const fields = parts.map((part, i) => field(part, [0, 0, 1, 1, 0][i], [59, 23, 31, 12, 7][i]))
|
||||
if (fields[4].has(7)) fields[4].add(0)
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz,
|
||||
minute: 'numeric',
|
||||
hour: 'numeric',
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
hourCycle: 'h23',
|
||||
})
|
||||
const local = (time: number) => {
|
||||
const p = Object.fromEntries(
|
||||
formatter.formatToParts(time).map((part) => [part.type, part.value]),
|
||||
)
|
||||
const dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(p.weekday)
|
||||
const day = fields[2].has(Number(p.day))
|
||||
const week = fields[4].has(dow)
|
||||
return {
|
||||
wall: Date.UTC(
|
||||
Number(p.year),
|
||||
Number(p.month) - 1,
|
||||
Number(p.day),
|
||||
Number(p.hour),
|
||||
Number(p.minute),
|
||||
),
|
||||
minute: Number(p.minute),
|
||||
hour: Number(p.hour),
|
||||
dateMatches:
|
||||
fields[3].has(Number(p.month)) &&
|
||||
(parts[2].startsWith('*') || parts[4].startsWith('*') ? day && week : day || week),
|
||||
}
|
||||
}
|
||||
return { local, fields }
|
||||
}
|
||||
|
||||
export function parseSchedule(input: string | Schedule, now = Date.now()): Schedule {
|
||||
let schedule: Schedule
|
||||
if (typeof input !== 'string') schedule = { ...input }
|
||||
else if (input.startsWith('in '))
|
||||
schedule = { kind: 'at', time: new Date(now + duration(input.slice(3).trim())).toISOString() }
|
||||
else if (input.startsWith('every '))
|
||||
schedule = { kind: 'every', ms: duration(input.slice(6).trim()) }
|
||||
else if (input.startsWith('cron ')) {
|
||||
const match = /^cron\s+"([^"]+)"(?:\s+(\S+))?$/.exec(input)
|
||||
if (!match) throw new Error('Use cron "0 9 * * 1-5" Europe/Vienna')
|
||||
schedule = {
|
||||
kind: 'cron',
|
||||
expr: match[1],
|
||||
tz: match[2] ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
}
|
||||
} else schedule = { kind: 'at', time: input.replace(/^at\s+/, '') }
|
||||
if (schedule.kind === 'every') {
|
||||
if (!Number.isFinite(schedule.ms) || schedule.ms < 1000)
|
||||
throw new Error('Intervals must be at least 1s')
|
||||
} else if (schedule.kind === 'at') {
|
||||
if (!/^\d{4}-\d\d-\d\dT/.test(schedule.time) || !Number.isFinite(Date.parse(schedule.time)))
|
||||
throw new Error('Use an ISO time')
|
||||
schedule.time = new Date(schedule.time).toISOString()
|
||||
} else {
|
||||
schedule.tz ||= Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
cron(schedule.expr, schedule.tz)
|
||||
}
|
||||
return schedule
|
||||
}
|
||||
|
||||
export function nextAt(schedule: Schedule, after: number, previous = after): number {
|
||||
if (schedule.kind === 'at') return Date.parse(schedule.time)
|
||||
if (schedule.kind === 'every') return after + schedule.ms
|
||||
const { local, fields } = cron(schedule.expr, schedule.tz)
|
||||
const previousWall = local(previous).wall
|
||||
const start = Math.floor(after / 60000) * 60000 + 60000
|
||||
let time = start
|
||||
while (time <= start + 8 * 366 * 86400000) {
|
||||
const p = local(time)
|
||||
if (!p.dateMatches) {
|
||||
const minutes = 1440 - p.hour * 60 - p.minute
|
||||
const target = p.wall + minutes * 60000
|
||||
const candidate = time + minutes * 60000
|
||||
// Correct the midnight jump for an offset change, without crossing a matching day.
|
||||
time = Math.max(time + 60000, candidate + target - local(candidate).wall)
|
||||
} else if (!fields[1].has(p.hour)) {
|
||||
time += (60 - p.minute) * 60000
|
||||
} else if (fields[0].has(p.minute) && p.wall !== previousWall) return time
|
||||
else time += 60000
|
||||
}
|
||||
throw new Error('Cron has no fire within 8 years')
|
||||
}
|
||||
|
||||
export function describeSchedule(schedule: Schedule): string {
|
||||
if (schedule.kind === 'at') return `Once at ${schedule.time}`
|
||||
if (schedule.kind === 'cron') {
|
||||
const [minute, hour, day, month, weekday] = schedule.expr.trim().split(/\s+/)
|
||||
const clock =
|
||||
/^\d+$/.test(minute) && /^\d+$/.test(hour)
|
||||
? `At ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`
|
||||
: minute === '*' && hour === '*'
|
||||
? 'Every minute'
|
||||
: `At minutes ${minute} of hours ${hour}`
|
||||
const days =
|
||||
weekday === '1-5'
|
||||
? ' on weekdays'
|
||||
: weekday === '*'
|
||||
? ''
|
||||
: ` on weekdays ${weekday} (Sunday=0)`
|
||||
const dates = day === '*' ? '' : ` on month days ${day}`
|
||||
const months = month === '*' ? '' : ` in months ${month}`
|
||||
return `${clock}${dates}${dates && days ? ' or' : ''}${days}${months} (${schedule.tz})`
|
||||
}
|
||||
for (const [unit, ms] of [
|
||||
['day', 86400000],
|
||||
['hour', 3600000],
|
||||
['minute', 60000],
|
||||
['second', 1000],
|
||||
] as const)
|
||||
if (schedule.ms % ms === 0) {
|
||||
const n = schedule.ms / ms
|
||||
return `Every ${n} ${unit}${n === 1 ? '' : 's'}`
|
||||
}
|
||||
return `Every ${schedule.ms / 1000} seconds`
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/** Durable schedule deadlines and owned prompt runs. */
|
||||
import {
|
||||
AssistantEntry,
|
||||
AgentDoc,
|
||||
defineTask,
|
||||
ROOT_CONVERSATION_ID,
|
||||
} from '@earendil-works/pi-durable'
|
||||
import type { ConversationId, Cursor, Tx } from '@earendil-works/pi-durable'
|
||||
type ExtensionHost = any
|
||||
import { SubagentsApi } from '../subagents/api.ts'
|
||||
import { ModelsApi } from '../models/api.ts'
|
||||
import { nextAt } from './schedule.ts'
|
||||
import type { Automation, Run } from './types.ts'
|
||||
import { RUN_TASK } from './types.ts'
|
||||
import type { Automations as Ledger } from './server.ts'
|
||||
import { Escalated, Fired } from './refs.ts'
|
||||
import { RunLabel } from './types.ts'
|
||||
const owner = {
|
||||
ownership: { kind: 'conversation' as const },
|
||||
conversationId: ROOT_CONVERSATION_ID,
|
||||
background: true,
|
||||
}
|
||||
const terminal = {
|
||||
status: 'terminal' as const,
|
||||
outcome: { status: 'completed' as const, result: null },
|
||||
}
|
||||
async function definition(host: ExtensionHost, name: string) {
|
||||
const found = (
|
||||
await host.need(ModelsApi).discover('/src/sausage')
|
||||
).definitions.find((d) => d.name === name)
|
||||
if (!found) throw new Error(`Unknown agent: ${name}`)
|
||||
return found
|
||||
}
|
||||
export function scheduler(
|
||||
host: ExtensionHost,
|
||||
Automations: typeof Ledger,
|
||||
deliver: (
|
||||
tx: Tx,
|
||||
a: Automation,
|
||||
event: number | string,
|
||||
kind: 'fired' | 'escalated',
|
||||
text: string,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
const RunTask = defineTask<
|
||||
{ automation: Automation; firedAt: number; text?: string },
|
||||
{ phase: 'create' } | { phase: 'deliver'; conversationId: ConversationId },
|
||||
null
|
||||
>({
|
||||
name: RUN_TASK,
|
||||
version: 1,
|
||||
initial: () => ({ phase: 'create' }),
|
||||
phases: {
|
||||
create: async (task, runtime, context) => {
|
||||
try {
|
||||
const a = task.input.automation
|
||||
const parent = await runtime.agent(context)
|
||||
const profile = a.agent ? await definition(host, a.agent) : undefined
|
||||
await runtime.commit(async (tx) => {
|
||||
const child = await host.need(SubagentsApi).createChild(tx, {
|
||||
owner: { kind: 'task', taskId: task.id },
|
||||
definition: profile,
|
||||
parentAgent: parent,
|
||||
metadata: host.metadata,
|
||||
label: RunLabel,
|
||||
title: `${a.name} · ${new Date(task.input.firedAt).toLocaleString()}`,
|
||||
excludeTools: a.agent ? ['automation', 'subagent'] : ['automation'],
|
||||
excludeExtensions: ['automations'],
|
||||
cwd: (await tx.doc(AgentDoc, runtime.conversationId)).cwd ?? '/src/sausage',
|
||||
model: a.model,
|
||||
thinking: a.thinking,
|
||||
})
|
||||
const run = (await tx.doc(Automations)).automations[a.id]?.runs.find(
|
||||
(r) => r.id === String(task.id),
|
||||
)
|
||||
if (run) run.conversationId = child.id
|
||||
return { status: 'running', checkpoint: { phase: 'deliver', conversationId: child.id } }
|
||||
}, context)
|
||||
} catch (error) {
|
||||
if (runtime.signal.aborted) throw error
|
||||
await runtime.commit(async (tx) => {
|
||||
finish(
|
||||
await tx.doc(Automations),
|
||||
task.input.automation.id,
|
||||
String(task.id),
|
||||
'failed',
|
||||
String(error),
|
||||
)
|
||||
return terminal
|
||||
}, context)
|
||||
}
|
||||
},
|
||||
deliver: async (task, runtime, context) => {
|
||||
try {
|
||||
const child = (await runtime.conversation(task.state.checkpoint.conversationId, context))!
|
||||
const result = await (
|
||||
await child.submit(
|
||||
{
|
||||
type: 'input',
|
||||
content:
|
||||
task.input.automation.prompt! +
|
||||
(task.input.text ? `\n\nFired: ${task.input.text}` : ''),
|
||||
requestId: `automation-run:${task.id}`,
|
||||
},
|
||||
context,
|
||||
)
|
||||
).wait(context)
|
||||
await runtime.commit(async (tx) => {
|
||||
const entry =
|
||||
result.status === 'done' && result.type === 'input'
|
||||
? await tx.entry(AssistantEntry, result.answer)
|
||||
: undefined
|
||||
const answer = entry?.model?.[0]
|
||||
const text =
|
||||
answer?.role === 'assistant'
|
||||
? answer.content.flatMap((p) => (p.type === 'text' ? [p.text] : [])).join('')
|
||||
: undefined
|
||||
finish(
|
||||
await tx.doc(Automations),
|
||||
task.input.automation.id,
|
||||
String(task.id),
|
||||
result.status === 'done'
|
||||
? 'done'
|
||||
: result.status === 'unanswered' && result.reason === 'aborted'
|
||||
? 'aborted'
|
||||
: 'failed',
|
||||
text,
|
||||
)
|
||||
return terminal
|
||||
}, context)
|
||||
} catch (error) {
|
||||
if (runtime.signal.aborted) throw error
|
||||
await runtime.commit(async (tx) => {
|
||||
finish(
|
||||
await tx.doc(Automations),
|
||||
task.input.automation.id,
|
||||
String(task.id),
|
||||
'failed',
|
||||
String(error),
|
||||
)
|
||||
return terminal
|
||||
}, context)
|
||||
}
|
||||
},
|
||||
},
|
||||
abort: (task, runtime, context) =>
|
||||
runtime.commit(async (tx) => {
|
||||
finish(await tx.doc(Automations), task.input.automation.id, String(task.id), 'aborted')
|
||||
return { status: 'terminal', outcome: { status: 'aborted' } }
|
||||
}, context),
|
||||
})
|
||||
async function fire(tx: Tx, a: Automation, firedAt: number, text = '') {
|
||||
a.recentFires = (a.recentFires ?? []).filter((at) => at > firedAt - 3600000)
|
||||
if (a.recentFires.length >= 6) {
|
||||
const reason = 'Fired more than 6 times in an hour; paused'
|
||||
a.enabled = false
|
||||
delete a.nextAt
|
||||
if (a.script) {
|
||||
a.script.state = 'stopped'
|
||||
a.script.error = reason
|
||||
}
|
||||
const event = await host.emit(tx, Escalated, { id: a.id, reason })
|
||||
await deliver(tx, a, event.id, 'escalated', reason)
|
||||
return { id: a.id }
|
||||
}
|
||||
// Scan for overlapping runs before any task/entry writes in this transaction.
|
||||
const result = a.prompt ? await fireRun(tx, a, firedAt, text) : { id: a.id }
|
||||
a.recentFires.push(firedAt)
|
||||
a.fires++
|
||||
a.lastFireAt = firedAt
|
||||
const event = await host.emit(tx, Fired, { id: a.id, text, fire: a.fires })
|
||||
if (a.subscribers.length) await deliver(tx, a, event.id, 'fired', text)
|
||||
return result
|
||||
}
|
||||
const activeRuns = new WeakMap<Tx, Set<string>>()
|
||||
// One event can fire several automations. Read their overlaps before the first fire writes any entries/tasks.
|
||||
async function prepareFires(tx: Tx): Promise<Set<string>> {
|
||||
const cached = activeRuns.get(tx)
|
||||
if (cached) return cached
|
||||
const ids = new Set<string>()
|
||||
// A long run can outlive the bounded run history.
|
||||
for (const status of ['pending', 'running', 'waiting', 'completing'] as const) {
|
||||
let cursor: Cursor | undefined
|
||||
do {
|
||||
const page = await tx.scanTasks(
|
||||
{ conversationId: ROOT_CONVERSATION_ID, kind: RUN_TASK, status },
|
||||
100,
|
||||
cursor,
|
||||
)
|
||||
for (const task of page.items)
|
||||
ids.add((task.input as { automation: Automation }).automation.id)
|
||||
cursor = page.next
|
||||
} while (cursor)
|
||||
}
|
||||
activeRuns.set(tx, ids)
|
||||
return ids
|
||||
}
|
||||
async function fireRun(tx: Tx, a: Automation, firedAt: number, text: string) {
|
||||
const running = a.runs.some((r) => r.status === 'running') || (await prepareFires(tx)).has(a.id)
|
||||
if (running) {
|
||||
a.runs.push({ id: `skipped:${firedAt}`, firedAt, finishedAt: firedAt, status: 'skipped' })
|
||||
} else {
|
||||
const id = await tx.createTask(
|
||||
RunTask,
|
||||
{
|
||||
automation: { ...(JSON.parse(JSON.stringify(a)) as Automation), runs: [] },
|
||||
firedAt,
|
||||
text,
|
||||
},
|
||||
owner,
|
||||
)
|
||||
activeRuns.get(tx)!.add(a.id)
|
||||
a.runs.push({ id: String(id), firedAt, status: 'running' })
|
||||
}
|
||||
a.runs = a.runs.slice(-50)
|
||||
return a.runs.at(-1)!
|
||||
}
|
||||
const Scheduler = defineTask<
|
||||
{ id: string },
|
||||
{ phase: 'plan' } | { phase: 'sleep'; until: number } | { phase: 'fire'; until: number },
|
||||
null
|
||||
>({
|
||||
name: 'main-agent.automation-scheduler',
|
||||
version: 1,
|
||||
initial: () => ({ phase: 'plan' }),
|
||||
phases: {
|
||||
plan: (task, runtime, context) =>
|
||||
runtime.commit(async (tx) => {
|
||||
const a = (await tx.doc(Automations)).automations[task.input.id]
|
||||
if (!a?.enabled || a.scheduler !== task.id) return terminal
|
||||
const until =
|
||||
a.nextAt ??
|
||||
nextAt(
|
||||
a.schedule!,
|
||||
Math.max(runtime.now(), a.lastFireAt ?? a.createdAt),
|
||||
a.lastFireAt ?? a.createdAt,
|
||||
)
|
||||
a.nextAt = until
|
||||
return { status: 'running', checkpoint: { phase: 'sleep', until } }
|
||||
}, context),
|
||||
sleep: (task, runtime, context) =>
|
||||
runtime.sleepUntil(
|
||||
task.state.checkpoint.until,
|
||||
{ phase: 'fire', until: task.state.checkpoint.until },
|
||||
context,
|
||||
),
|
||||
fire: (task, runtime, context) =>
|
||||
runtime.commit(async (tx) => {
|
||||
const a = (await tx.doc(Automations)).automations[task.input.id]
|
||||
if (!a?.enabled || a.scheduler !== task.id) return terminal
|
||||
await fire(tx, a, runtime.now())
|
||||
delete a.nextAt
|
||||
if (!a.enabled || a.schedule!.kind === 'at') {
|
||||
a.enabled = false
|
||||
delete a.scheduler
|
||||
return terminal
|
||||
}
|
||||
return { status: 'running', checkpoint: { phase: 'plan' } }
|
||||
}, context),
|
||||
},
|
||||
abort: (_task, runtime, context) =>
|
||||
runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), context),
|
||||
})
|
||||
|
||||
return { RunTask, Scheduler, fire, prepareFires }
|
||||
}
|
||||
function finish(
|
||||
state: { automations: Record<string, Automation> },
|
||||
id: string,
|
||||
runId: string,
|
||||
status: Run['status'],
|
||||
answer?: string,
|
||||
) {
|
||||
const run = state.automations[id]?.runs.find((r) => r.id === runId)
|
||||
if (run) {
|
||||
run.status = status
|
||||
run.finishedAt = Date.now()
|
||||
if (answer !== undefined) run.answer = answer.trim().split('\n')[0].slice(0, 200)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/** World-file definitions, durable admission, script execution, and subscriber delivery. */
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
|
||||
import { arr, bool, int, map, obj, opt, str, union } from '@earendil-works/pi-schema'
|
||||
import { defineDoc, defineExtension, defineTask, defineTool, ROOT_CONVERSATION_ID as ROOT } from '@earendil-works/pi-durable'
|
||||
import { SubagentsApi } from '../subagents/api.ts'
|
||||
import { scheduler } from './scheduler.ts'
|
||||
import { triggers, TRIGGER_EVENTS } from './triggers.ts'
|
||||
import { parseSchedule } from './schedule.ts'
|
||||
import { fireText, doneText, trimLog } from './types.ts'
|
||||
import type { Args, Automation, Subscriber } from './types.ts'
|
||||
export type { Automation, Run } from './types.ts'
|
||||
export const AutomationsApi = {name:'automations'}
|
||||
export const Automations = defineDoc<{next:number;automations:Record<string,Automation>}>({kind:'main-agent.automations',version:1,scope:'session',checkpointWhen:()=>false,initial:()=>({next:1,automations:{}})})
|
||||
const Receipts = defineDoc<Record<string,any>>({kind:'main-agent.automation-receipts',version:1,scope:'task',checkpointWhen:()=>false,initial:()=>({})})
|
||||
const ScriptFires = defineDoc<Record<string,boolean>>({kind:'automations.script-fires',version:1,scope:'task',checkpointWhen:()=>false,initial:()=>({})})
|
||||
const owner = {ownership:{kind:'conversation'},conversationId:ROOT,background:true} as const
|
||||
const terminal = {status:'terminal',outcome:{status:'completed',result:null}} as const
|
||||
const json = (value:any) => JSON.parse(JSON.stringify(value))
|
||||
export default function automations(host:any) {
|
||||
const now = () => host.harness().options?.now?.() ?? Date.now()
|
||||
const directory = (id:string) => `/src/sausage/automations/${id}`
|
||||
const path = (id:string) => `${directory(id)}/script.sh`
|
||||
function subscriber(target:Subscriber):Subscriber {
|
||||
if(typeof target==='number' || /^@[a-zA-Z0-9_-]+$/.test(target)) return target
|
||||
if(/^#[1-9]\d*$/.test(target)) return Number(target.slice(1))
|
||||
throw new Error('Subscriber must be #id or @name')
|
||||
}
|
||||
async function resolve(target:Subscriber) {
|
||||
return typeof target === 'number' ? target : host.need(SubagentsApi).mainAgent(target.slice(1),context)
|
||||
}
|
||||
const Delivery = defineTask<any,{phase:'deliver'},null>({name:'automations.delivery',version:1,initial:()=>({phase:'deliver'}),phases:{deliver:async(task,runtime,ctx)=>{
|
||||
const id = await resolve(task.input.target)
|
||||
const conversation = await runtime.conversation(id,ctx)
|
||||
await conversation!.submit({type:'input',requestId:`automation:${task.input.event}:${String(task.input.target)}`,content:task.input.text},ctx)
|
||||
await runtime.commit(()=>terminal,ctx)
|
||||
}},abort:(_task,runtime,ctx)=>runtime.commit(()=>({status:'terminal',outcome:{status:'aborted'}}),ctx)})
|
||||
async function deliver(tx:any,a:Automation,event:number|string,kind:string,text:string) {
|
||||
for (const target of a.subscribers) await tx.createTask(Delivery,{target,event,text:`[automation ${a.id} ${kind}] ${a.name}${text ? `\n${text}` : ''}`},owner)
|
||||
}
|
||||
const clock = scheduler(host,Automations,deliver)
|
||||
const conditions = triggers(host,Automations,clock.fire,clock.prepareFires)
|
||||
const Script = defineTask<{id:string},{phase:'prepare'}|{phase:'write';watcher:number}|{phase:'execute'},null>({name:'automations.script',version:1,initial:()=>({phase:'prepare'}),phases:{
|
||||
prepare:async(task,runtime,ctx)=>{
|
||||
const a = (await host.harness().snapshot(Automations,ctx))?.automations[task.input.id]
|
||||
if (!a?.enabled || !a.script || a.scheduler!==task.id) return runtime.commit(()=>terminal,ctx)
|
||||
await persist(a)
|
||||
try { await host.fs.stat(path(a.id)); return runtime.commit(()=>({status:'running',checkpoint:{phase:'execute'}}),ctx) } catch(error) { if((error as any).code !== 'not_found') throw error }
|
||||
const parent = a.executionConversation === undefined ? await runtime.agent(ctx) : await (await runtime.conversation(a.executionConversation,ctx))!.agent(ctx)
|
||||
await runtime.commit(async(tx)=>{
|
||||
const child = await host.need(SubagentsApi).createChild(tx,{owner:{kind:'task',taskId:task.id},parentAgent:parent,label:'watcher',title:a.name,excludeTools:['automation'],excludeExtensions:['automations'],cwd:directory(a.id)})
|
||||
;(await tx.doc(Automations)).automations[a.id].script!.conversationId = child.id
|
||||
return {status:'running',checkpoint:{phase:'write',watcher:child.id}}
|
||||
},ctx)
|
||||
},
|
||||
write:async(task,runtime,ctx)=>{
|
||||
const a = (await host.harness().snapshot(Automations,ctx))!.automations[task.input.id]
|
||||
const watcher = await runtime.conversation(task.state.checkpoint.watcher,ctx)
|
||||
await (await watcher!.submit({type:'input',requestId:`automation-write:${task.id}`,content:`Write ${path(a.id)} for this request: ${a.script!.ask}\nUse stdout FIRE followed by an optional message to trigger the automation. Only stdout has a protocol. Print DONE and exit 0 when complete. Never read host secrets. Use curl normally. The host injects the creator's grants.`},ctx)).wait(ctx)
|
||||
await runtime.commit(()=>({status:'running',checkpoint:{phase:'execute'}}),ctx)
|
||||
},
|
||||
execute:async(task,runtime,ctx)=>{
|
||||
const a = (await host.harness().snapshot(Automations,ctx))!.automations[task.input.id]
|
||||
if (!a?.enabled || !a.script || a.scheduler!==task.id) return runtime.commit(()=>terminal,ctx)
|
||||
const conversations = a.executionConversation === undefined ? [] : await host.chain(a.executionConversation)
|
||||
let queue = Promise.resolve(), log='', done=false, sequence=0
|
||||
const pending:Record<string,string> = {stdout:'',stderr:''}
|
||||
function line(text:string,stream:string) {
|
||||
log=trimLog(log+text+'\n')
|
||||
if(doneText(text,stream==='stdout') !== undefined) done=true
|
||||
const fire=fireText(text,stream==='stdout')
|
||||
if(fire === undefined) return
|
||||
const key=String(++sequence)
|
||||
queue=queue.then(()=>host.harness().commit(async(tx:any)=>{
|
||||
const receipts=await tx.doc(ScriptFires,task.id)
|
||||
if(receipts[key]) return
|
||||
const current=(await tx.doc(Automations)).automations[a.id]
|
||||
if(current?.enabled && current.scheduler===task.id) await clock.fire(tx,current,runtime.now(),fire)
|
||||
receipts[key]=true
|
||||
},ctx))
|
||||
}
|
||||
const result=await host.exec(['sh',path(a.id)],{cwd:directory(a.id),conversations,timeout:3600,signal:runtime.signal,task:task.id,onOutput:(text:string,stream:string)=>{
|
||||
pending[stream]+=text
|
||||
const lines=pending[stream].split('\n'); pending[stream]=lines.pop()!
|
||||
for(const text of lines) line(text.replace(/\r$/,''),stream)
|
||||
}})
|
||||
for(const stream of ['stdout','stderr']) if(pending[stream]) line(pending[stream],stream)
|
||||
await queue
|
||||
await host.fs.write(`${directory(a.id)}/log`,log)
|
||||
await runtime.commit(async(tx)=>{
|
||||
const current=(await tx.doc(Automations)).automations[a.id]
|
||||
if(current?.script) {
|
||||
current.script.exitCode=result.code
|
||||
current.script.state=result.code===0 ? 'done' : 'failed'
|
||||
current.script.exitedAt=runtime.now()
|
||||
if(result.code!==0) current.script.error=result.stderr
|
||||
if(done || current.script.once) current.enabled=false
|
||||
}
|
||||
return terminal
|
||||
},ctx)
|
||||
},
|
||||
},abort:(_task,runtime,ctx)=>runtime.commit(()=>({status:'terminal',outcome:{status:'aborted'}}),ctx)})
|
||||
function required(value:any,name:string):string { if(typeof value!=='string'||!value.trim()) throw new Error(`${name} is required`); return value.trim() }
|
||||
async function change(tx:any,args:Args,creator?:number) {
|
||||
const ledger=await tx.doc(Automations)
|
||||
if(args.action==='events') return TRIGGER_EVENTS
|
||||
if(args.action==='list') return Object.values(ledger.automations)
|
||||
if(args.action==='create') {
|
||||
if(args.key) { const existing=Object.values(ledger.automations).find((a:Automation)=>a.key===args.key); if(existing) return existing }
|
||||
const name=required(args.name,'name')
|
||||
if([args.schedule,args.ask,args.event,args.condition].filter(Boolean).length!==1) throw new Error('Exactly one trigger is required')
|
||||
const subscribers=(args.subscribers ?? (args.prompt ? [] : creator===undefined ? [] : [creator])).map(subscriber)
|
||||
if(!args.prompt&&!subscribers.length) throw new Error('At least one of prompt or subscribers is required')
|
||||
const id=`A${ledger.next++}`, at=now()
|
||||
const a:Automation={id,name,enabled:true,createdAt:at,updatedAt:at,subscribers,runs:[],fires:0,
|
||||
...(creator===undefined?{}:{executionConversation:creator}),...(args.key?{key:args.key}:{}),
|
||||
...(args.prompt?{prompt:args.prompt}:{}),...(args.agent?{agent:args.agent}:{}),...(args.model?{model:args.model}:{}),...(args.thinking?{thinking:args.thinking}:{}),
|
||||
...(args.schedule?{schedule:parseSchedule(args.schedule,at)}:{}),
|
||||
...(args.event?{event:{...args.event,sinceEvent:0}}:{}),
|
||||
...(args.condition?{condition:{...args.condition,cooldownMs:args.condition.cooldownMs??3600000,armed:true}}:{}),
|
||||
...(args.ask?{script:{ask:args.ask,once:args.once??true,conversationId:0,state:'writing'}}:{})}
|
||||
if(a.schedule) a.scheduler=await tx.createTask(clock.Scheduler,{id},owner)
|
||||
if(a.script) a.scheduler=await tx.createTask(Script,{id},owner)
|
||||
ledger.automations[id]=a
|
||||
return json(a)
|
||||
}
|
||||
const a=ledger.automations[required(args.id,'id')]
|
||||
if(!a) throw new Error('Unknown automation')
|
||||
let abort
|
||||
if(args.action==='runs') return a.runs
|
||||
if(args.action==='subscribe'||args.action==='unsubscribe') {
|
||||
const requested=args.to ?? creator
|
||||
if(requested===undefined) throw new Error('Subscriber conversation is missing')
|
||||
const target=subscriber(requested)
|
||||
a.subscribers=args.action==='subscribe'?[...new Set([...a.subscribers,target])]:a.subscribers.filter((s:Subscriber)=>s!==target)
|
||||
if(!a.prompt&&!a.subscribers.length) throw new Error('At least one of prompt or subscribers is required')
|
||||
} else if(args.action==='pause'||args.action==='delete') {
|
||||
a.enabled=false
|
||||
abort=a.scheduler
|
||||
delete a.scheduler
|
||||
if(args.action==='delete') delete ledger.automations[a.id]
|
||||
} else if(args.action==='resume') {
|
||||
if(a.enabled && a.scheduler) return json(a)
|
||||
a.enabled=true
|
||||
if(a.schedule) a.scheduler=await tx.createTask(clock.Scheduler,{id:a.id},owner)
|
||||
if(a.script) a.scheduler=await tx.createTask(Script,{id:a.id},owner)
|
||||
} else if(args.action==='run') {
|
||||
if(a.script) {
|
||||
abort=a.scheduler
|
||||
a.enabled=true
|
||||
a.scheduler=await tx.createTask(Script,{id:a.id},owner)
|
||||
} else return clock.fire(tx,a,now())
|
||||
} else throw new Error(`Unknown action ${args.action}`)
|
||||
a.updatedAt=now()
|
||||
return {...json(a),...(abort===undefined?{}:{_abort:abort})}
|
||||
}
|
||||
async function persist(a:Automation) {
|
||||
for(const part of ['/src/sausage/automations',directory(a.id)]) {
|
||||
try { await host.fs.mkdir(part) } catch(error) { if((error as any).code !== 'already_exists') throw error }
|
||||
}
|
||||
await host.fs.write(`${directory(a.id)}/automation.json`,JSON.stringify(a,null,2))
|
||||
}
|
||||
const operate=async(args:Args,creator?:number)=>{
|
||||
if(args.action==='log') return host.fs.read(`${directory(required(args.id,'id'))}/log`)
|
||||
const result=await host.harness().commit(async(tx:any)=>json(await change(tx,args,creator)),context)
|
||||
if(result?._abort) await host.harness().abortTask(result._abort,context)
|
||||
if(result && !Array.isArray(result)) delete result._abort
|
||||
if(result?.id && args.action!=='delete') await persist(result)
|
||||
return result
|
||||
}
|
||||
host.implement(AutomationsApi,{operate,directory,script:path})
|
||||
conditions.install()
|
||||
host.onOpen(conditions.open)
|
||||
host.onClose(conditions.close)
|
||||
return defineExtension({name:'automations',tasks:[Delivery,clock.RunTask,clock.Scheduler,Script],tools:[defineTool({name:'automation',description:'Create schedule, event, context, or script automations. Scripts retain the creator identity. Actions: create, list, events, run, pause, resume, subscribe, unsubscribe, runs, log, delete.',parameters:obj({action:str(),id:opt(str()),name:opt(str()),schedule:opt(str()),ask:opt(str()),prompt:opt(str()),once:opt(bool()),key:opt(str()),subscribers:opt(arr(union([int({minimum:1}),str()]))),to:opt(str()),event:opt(obj({name:str(),where:opt(map(union([str(),int(),bool()])))})),condition:opt(obj({metric:str(),of:str(),above:int({minimum:1,maximum:99}),cooldownMs:opt(int()),idleFor:opt(int())}))}),execute:async(args,api,ctx)=>{
|
||||
if(args.action==='log') return {content:[{type:'text',text:await host.fs.read(`${directory(required(args.id,'id'))}/log`)}]}
|
||||
const result=await api.commit(async(tx:any)=>{
|
||||
const receipts=await tx.doc(Receipts,api.taskId)
|
||||
if(receipts[api.callId]===undefined) receipts[api.callId]=json(await change(tx,args,api.conversationId))
|
||||
return json(receipts[api.callId])
|
||||
},ctx)
|
||||
if(result?._abort) await host.harness().abortTask(result._abort,ctx)
|
||||
const output=json(result); delete output?._abort
|
||||
if(output?.id && args.action!=='delete') await persist(output)
|
||||
return {content:[{type:'text',text:JSON.stringify(output)}]}
|
||||
}})]})
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/** Event and context conditions use the same transactional fire path. */
|
||||
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context'
|
||||
const onCommit = (harness: any, callback: any) => {
|
||||
const observation = harness.observeCommits({advance: callback, end: () => {}})
|
||||
return () => observation.detach()
|
||||
}
|
||||
import { ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
|
||||
import type { ConversationId, Tx } from '@earendil-works/pi-durable'
|
||||
type ExtensionHost = any
|
||||
import { defineEvent } from '@earendil-works/sausage-events'
|
||||
import type { EventRecord } from '@earendil-works/sausage-events'
|
||||
import { defineDoc } from '@earendil-works/pi-durable'
|
||||
const UserInputEvent = defineEvent('conversations.user-input')
|
||||
export type ContextUsage = {percent?: number; tokens?: number; window?: number}
|
||||
export const ContextUsageDoc = defineDoc<ContextUsage>({kind:'conversations.context',version:1,scope:'conversation',history:'latest',fork:'initial',initial:()=>({}),checkpointWhen:()=>false})
|
||||
import { MainAgent, SubagentsApi } from '../subagents/api.ts'
|
||||
import { describeSchedule } from './schedule.ts'
|
||||
import type { Automations as Ledger } from './server.ts'
|
||||
import type { Automation, TriggerEvent } from './types.ts'
|
||||
import { RUN_TASK, MAIN_AGENT } from './types.ts'
|
||||
|
||||
export const TRIGGER_EVENTS: TriggerEvent[] = [
|
||||
{
|
||||
name: 'workspaces.landed',
|
||||
description: 'A submission landed',
|
||||
fields: {
|
||||
sha: 'Landed commit',
|
||||
title: 'Submission title',
|
||||
target: 'Target branch',
|
||||
conversationId: 'Submitting conversation',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'workspaces.check',
|
||||
description: 'A landing check finished',
|
||||
fields: { result: 'passed | failed | flaky | timeout' },
|
||||
},
|
||||
{
|
||||
name: 'health.changed',
|
||||
description: 'Provider health changed',
|
||||
fields: { provider: 'Provider name', state: 'ok | failing | unknown' },
|
||||
},
|
||||
{
|
||||
name: 'models.login-changed',
|
||||
description: 'Provider login changed',
|
||||
fields: { provider: 'Provider name' },
|
||||
},
|
||||
{ name: 'update.applied', description: 'An update was applied', fields: {} },
|
||||
{ name: 'update.refused', description: 'An update was refused', fields: {} },
|
||||
{
|
||||
name: 'cloud.backup',
|
||||
description: 'Cloud backup changed phase',
|
||||
fields: { phase: 'started | done | failed' },
|
||||
},
|
||||
{ name: 'lineage.branched', description: 'A branch was created', fields: {} },
|
||||
{
|
||||
name: 'conversations.compacted',
|
||||
description: 'A conversation compacted',
|
||||
fields: {
|
||||
conversation: 'Conversation id, or @name for a main agent',
|
||||
reason: 'Compaction reason',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'automations.escalated',
|
||||
description: 'An automation escalated',
|
||||
fields: { id: 'Automation id' },
|
||||
},
|
||||
]
|
||||
|
||||
export function describeTrigger(a: Automation): string {
|
||||
if (a.schedule) return describeSchedule(a.schedule)
|
||||
if (a.event)
|
||||
return `on ${a.event.name}${
|
||||
a.event.where && Object.keys(a.event.where).length
|
||||
? ` where ${Object.entries(a.event.where)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(', ')}`
|
||||
: ''
|
||||
}`
|
||||
if (a.condition)
|
||||
return `when ${a.condition.of} context ≥ ${a.condition.above}%${a.condition.idleFor ? ` after ${hours(a.condition.idleFor)} without the user's input` : ''}`
|
||||
return a.script!.state
|
||||
}
|
||||
|
||||
/** A pause as people say it: `8h`, `90m`, `1.5h`. */
|
||||
export const hours = (ms: number) =>
|
||||
ms % 3600_000 === 0 || ms >= 10 * 3600_000
|
||||
? `${Math.round((ms / 3600_000) * 10) / 10}h`
|
||||
: `${Math.round(ms / 60_000)}m`
|
||||
|
||||
/** How often idle conditions are looked at: a pause is crossed by time passing, not by a measurement. */
|
||||
export const IDLE_TICK = 60_000
|
||||
|
||||
function summary(event: EventRecord): string {
|
||||
const data = event.data as Record<string, unknown>
|
||||
if (event.name === 'workspaces.landed')
|
||||
return `${event.name} ${String(data.sha).slice(0, 7)} ${JSON.stringify(data.title)}`.slice(
|
||||
0,
|
||||
200,
|
||||
)
|
||||
return `${event.name} {${Object.entries(data)
|
||||
.map(([key, value]) => `${key}=${JSON.stringify(value)}`)
|
||||
.join(' ')}}`
|
||||
.replace(/\s+/g, ' ')
|
||||
.slice(0, 200)
|
||||
}
|
||||
|
||||
export function triggers(
|
||||
host: ExtensionHost,
|
||||
Automations: typeof Ledger,
|
||||
fire: (tx: Tx, a: Automation, at: number, text?: string) => Promise<unknown>,
|
||||
prepareFires: (tx: Tx) => Promise<unknown>,
|
||||
) {
|
||||
async function resolve(of: string): Promise<ConversationId | undefined> {
|
||||
if (MAIN_AGENT.test(of)) return host.need(SubagentsApi).mainAgent(of.slice(1), BACKGROUND_CONTEXT)
|
||||
if (/^#[1-9]\d*$/.test(of)) return Number(of.slice(1)) as ConversationId
|
||||
return undefined
|
||||
}
|
||||
/** Evaluate `a`'s condition on `usage`, the measurement a commit published, else the conversation's current one. */
|
||||
async function evaluate(tx: Tx, a: Automation, id?: number, measured?: ContextUsage) {
|
||||
if (!a.enabled || !a.condition || !id) return
|
||||
const usage = measured ?? (await tx.doc(ContextUsageDoc, id as ConversationId))
|
||||
if (usage.percent === undefined) return
|
||||
const condition = a.condition
|
||||
condition.value = usage.percent
|
||||
if (usage.percent < condition.above - 10) condition.armed = true
|
||||
const now = Date.now()
|
||||
const paused = condition.idleFor ? now - (condition.inputAt ?? a.createdAt) : undefined
|
||||
if (
|
||||
usage.percent >= condition.above &&
|
||||
condition.armed &&
|
||||
(paused === undefined || paused >= condition.idleFor!) &&
|
||||
// A manual run is not a crossing: the cooldown counts from the condition's own fire.
|
||||
(condition.firedAt === undefined || now - condition.firedAt >= condition.cooldownMs)
|
||||
) {
|
||||
const count = (n: number) =>
|
||||
Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(n)
|
||||
await fire(
|
||||
tx,
|
||||
a,
|
||||
now,
|
||||
`${condition.of} context ${Math.round(usage.percent)}%${usage.tokens !== undefined && usage.window !== undefined ? ` (${count(usage.tokens)}/${count(usage.window)})` : ''}${paused === undefined ? '' : ` after ${hours(paused)} without the user's input`}`,
|
||||
)
|
||||
condition.armed = false
|
||||
condition.firedAt = now
|
||||
}
|
||||
}
|
||||
let stop: (() => void) | undefined
|
||||
let queue = Promise.resolve()
|
||||
let closed = false
|
||||
/**
|
||||
* Evaluate every enabled context condition: on the measurements one commit published (`changed`, by conversation),
|
||||
* or on each conversation's current one; with `idle`, only those that wait for a pause. Each commit's measurements
|
||||
* are evaluated in commit order, so a drop below the re-arm line followed by a crossing is seen as both, however far
|
||||
* evaluation lags.
|
||||
*/
|
||||
async function check(changed?: Map<number, ContextUsage>, idle = false) {
|
||||
const state = await host.harness().snapshot(Automations, BACKGROUND_CONTEXT)
|
||||
for (const a of Object.values(state?.automations ?? {})) {
|
||||
if (closed || !a.enabled || !a.condition || (idle && !a.condition.idleFor)) continue
|
||||
const target = await resolve(a.condition.of)
|
||||
if (!target || (changed && !changed.has(target))) continue
|
||||
await host.harness().commit(async (tx) => {
|
||||
const current = (await tx.doc(Automations)).automations[a.id]
|
||||
if (current?.condition?.of === a.condition!.of)
|
||||
await evaluate(tx, current, target, changed?.get(target))
|
||||
}, BACKGROUND_CONTEXT)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Whether `event` came from one of `a`'s own prompt runs: its trace's conversation, or the conversation it is about
|
||||
* (`conversation`, `conversationId` in its data), is a run conversation owned by one of `a`'s run tasks. Read from
|
||||
* the conversation's owner, not the bounded run history, so an old or long run still counts.
|
||||
*/
|
||||
async function fromOwnRun(tx: Tx, a: Automation, event: EventRecord) {
|
||||
// No shortcut on `a.prompt`: a run started before the prompt was removed can still emit.
|
||||
const data = event.data as Record<string, unknown>
|
||||
const ids = new Set(
|
||||
[data.conversation, data.conversationId].filter(
|
||||
(id): id is number => typeof id === 'number',
|
||||
),
|
||||
)
|
||||
for (const id of ids) {
|
||||
const creator = (await tx.conversation(id as ConversationId))?.owner
|
||||
if (!creator) continue
|
||||
const owner = await tx.task(creator.taskId)
|
||||
if (
|
||||
owner?.kind === RUN_TASK &&
|
||||
(owner.input as { automation?: { id?: string } }).automation?.id === a.id
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
/** One minute's look at the conditions that wait for a pause: time alone may have crossed it. */
|
||||
const idleTick = () => {
|
||||
queue = queue
|
||||
.then(() => check(undefined, true))
|
||||
.catch((error: unknown) => {
|
||||
host.notice(`Automation idle trigger: ${String(error)}`)
|
||||
})
|
||||
return queue
|
||||
}
|
||||
function install() {
|
||||
// A pause counts from the user's latest input to the conversation a condition watches.
|
||||
host.react(
|
||||
'user-input',
|
||||
[UserInputEvent],
|
||||
async (tx, { data, at }) => {
|
||||
const state = await tx.doc(Automations)
|
||||
for (const a of Object.values(state.automations)) {
|
||||
if (!a.condition?.idleFor) continue
|
||||
const of = a.condition.of
|
||||
const target = MAIN_AGENT.test(of)
|
||||
? (await tx.doc(MainAgent, ROOT_CONVERSATION_ID))[of.slice(1)]
|
||||
: Number(of.slice(1))
|
||||
if (target === data.conversation) a.condition.inputAt = Date.parse(at)
|
||||
}
|
||||
},
|
||||
{ from: 'now' },
|
||||
)
|
||||
// Tokens name public events without loading other extensions' runtime refs.
|
||||
host.react(
|
||||
'events',
|
||||
TRIGGER_EVENTS.map(({ name }) => defineEvent(name)),
|
||||
async (tx, event) => {
|
||||
const state = await tx.doc(Automations)
|
||||
const matching: Automation[] = []
|
||||
// `@name` matches the main agent's conversation when the event comes: it changes with each chapter.
|
||||
const mainAgent = await tx.doc(MainAgent, ROOT_CONVERSATION_ID)
|
||||
const expected = (value: string | number | boolean) =>
|
||||
typeof value === 'string' && MAIN_AGENT.test(value)
|
||||
? Object.hasOwn(mainAgent, value.slice(1))
|
||||
? mainAgent[value.slice(1)]
|
||||
: null
|
||||
: value
|
||||
// Every read before the first fire: a transaction may not scan tasks after it wrote.
|
||||
for (const a of Object.values(state.automations)) {
|
||||
if (!a.enabled || a.event?.name !== event.name || event.id <= a.event.sinceEvent) continue
|
||||
if (
|
||||
!Object.entries(a.event.where ?? {}).every(
|
||||
([key, value]) => (event.data as Record<string, unknown>)[key] === expected(value),
|
||||
)
|
||||
)
|
||||
continue
|
||||
if (!(await fromOwnRun(tx, a, event))) matching.push(a)
|
||||
}
|
||||
if (matching.some((a) => a.prompt)) await prepareFires(tx)
|
||||
for (const a of matching) await fire(tx, a, Date.now(), summary(event))
|
||||
},
|
||||
{ from: 'now' },
|
||||
)
|
||||
}
|
||||
async function open() {
|
||||
closed = false
|
||||
stop?.()
|
||||
// Not a reactor: context usage is a document, not a domain event.
|
||||
stop = onCommit(host.harness(), (publication) => {
|
||||
const entries = publication.changes.filter((change: any) => change.type === 'entry').map((change: any) => change.value)
|
||||
const measured = entries.flatMap((entry: any) => {
|
||||
const message = entry.model?.[0]
|
||||
if(message?.role !== 'assistant' || !message.usage) return []
|
||||
const model = host.models().getModel(message.provider,message.model)
|
||||
if(!model?.contextWindow) return []
|
||||
const tokens = message.usage.input + message.usage.cacheRead + message.usage.cacheWrite
|
||||
return [{id:entry.conversationId,usage:{tokens,window:model.contextWindow,percent:tokens/model.contextWindow*100}}]
|
||||
})
|
||||
const inputs = entries.filter((entry: any) => entry.kind === 'pi.input')
|
||||
if(measured.length || inputs.length) queue = queue.then(async () => {
|
||||
if(closed) return
|
||||
await host.harness().commit(async (tx: any) => {
|
||||
const ledger = await tx.doc(Automations)
|
||||
const mainAgent = await tx.doc(MainAgent,ROOT_CONVERSATION_ID)
|
||||
for(const entry of inputs) for(const a of Object.values(ledger.automations) as Automation[]) {
|
||||
if(!a.condition?.idleFor) continue
|
||||
const target=MAIN_AGENT.test(a.condition.of) ? mainAgent[a.condition.of.slice(1)] : Number(a.condition.of.slice(1))
|
||||
if(target === entry.conversationId) a.condition.inputAt = entry.model?.[0]?.timestamp ?? Date.now()
|
||||
}
|
||||
for(const {id,usage} of measured) Object.assign(await tx.doc(ContextUsageDoc,id),usage)
|
||||
},BACKGROUND_CONTEXT)
|
||||
}).catch((error: unknown) => host.notice(`Automation context measurement: ${String(error)}`))
|
||||
const changed = new Map<number, ContextUsage>()
|
||||
for (const change of publication.changes)
|
||||
if (
|
||||
change.type === 'document' &&
|
||||
change.kind === 'change' &&
|
||||
change.record.kind === ContextUsageDoc.definition.kind &&
|
||||
change.conversationId !== undefined
|
||||
)
|
||||
changed.set(change.conversationId, structuredClone(change.value as ContextUsage))
|
||||
if (changed.size)
|
||||
queue = queue
|
||||
.then(() => check(changed))
|
||||
.catch((error: unknown) => {
|
||||
host.notice(`Automation context trigger: ${String(error)}`)
|
||||
})
|
||||
})
|
||||
await check()
|
||||
// A close while the first check ran ends here: no timer outlives it.
|
||||
if (closed) return
|
||||
clearInterval(timer)
|
||||
timer = setInterval(idleTick, IDLE_TICK)
|
||||
}
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
async function close() {
|
||||
closed = true
|
||||
stop?.()
|
||||
stop = undefined
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
await queue
|
||||
}
|
||||
return { resolve, evaluate, install, open, close, idleTick }
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/** Automation definitions, runtime records, and the stdout protocol. */
|
||||
import type { ModelThinkingLevel } from '@earendil-works/pi-ai'
|
||||
import type { TaskId, ConversationId } from '@earendil-works/pi-durable'
|
||||
|
||||
export type TriggerEvent = { name: string; description: string; fields: Record<string, string> }
|
||||
/** The labels automations gives the conversations it creates: a prompt run's, and a script's watcher. */
|
||||
export const RunLabel = 'automation'
|
||||
export const WatcherLabel = 'watcher'
|
||||
|
||||
export type Schedule =
|
||||
| { kind: 'cron'; expr: string; tz: string }
|
||||
| { kind: 'at'; time: string }
|
||||
| { kind: 'every'; ms: number }
|
||||
export type Run = {
|
||||
id: string
|
||||
firedAt: number
|
||||
conversationId?: ConversationId
|
||||
status: 'running' | 'done' | 'failed' | 'aborted' | 'skipped'
|
||||
finishedAt?: number
|
||||
answer?: string
|
||||
}
|
||||
/** A conversation an automation wakes: `#id` as a number, or a main agent's `@name`, resolved at each delivery. */
|
||||
export type Subscriber = number | string
|
||||
export type Automation = {
|
||||
id: string
|
||||
scope?: 'project'
|
||||
/** The caller's idempotency key: a create with a key that exists returns that automation instead. */
|
||||
key?: string
|
||||
/** Fixed creator identity. Subscriptions only control notifications. */
|
||||
executionConversation?: number
|
||||
name: string
|
||||
enabled: boolean
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
schedule?: Schedule
|
||||
event?: { name: string; where?: Record<string, string | number | boolean>; sinceEvent: number }
|
||||
condition?: {
|
||||
metric: 'context'
|
||||
of: string
|
||||
above: number
|
||||
cooldownMs: number
|
||||
armed: boolean
|
||||
value?: number
|
||||
/** Fire only once the user gave `of` no input for this long (ms): a pause, checked every minute. */
|
||||
idleFor?: number
|
||||
/** The user's latest input to `of` (ms), kept while `idleFor` is set; the pause counts from creation before one. */
|
||||
inputAt?: number
|
||||
/** When the condition itself last fired: the cooldown counts from it, not from a manual run. */
|
||||
firedAt?: number
|
||||
}
|
||||
recentFires?: number[]
|
||||
script?: {
|
||||
ask: string
|
||||
once: boolean
|
||||
secrets?: string[]
|
||||
/** A project script's committed `to`: the subscriber its manifest names. */
|
||||
to?: string
|
||||
conversationId: number
|
||||
state: 'writing' | 'running' | 'investigating' | 'failed' | 'stopped' | 'done'
|
||||
/** Unplanned exits in a row; the third escalates. A FIRE or an hour of running resets it. */
|
||||
failures?: number
|
||||
pid?: number
|
||||
startedAt?: number
|
||||
exitedAt?: number
|
||||
exitCode?: number
|
||||
lastOutputAt?: number
|
||||
error?: string
|
||||
}
|
||||
prompt?: string
|
||||
agent?: string
|
||||
model?: { provider: string; modelId: string }
|
||||
thinking?: ModelThinkingLevel
|
||||
subscribers: Subscriber[]
|
||||
nextAt?: number
|
||||
scheduler?: TaskId
|
||||
runs: Run[]
|
||||
fires: number
|
||||
lastFireAt?: number
|
||||
}
|
||||
export type AutomationListItem = Automation & { alive: boolean }
|
||||
export type CreateArgs = {
|
||||
name: string
|
||||
schedule?: string | Schedule
|
||||
ask?: string
|
||||
event?: { name: string; where?: Record<string, string | number | boolean> }
|
||||
condition?: {
|
||||
metric: 'context'
|
||||
of: string
|
||||
above: number
|
||||
cooldownMs?: number
|
||||
/** 0 removes it. */
|
||||
idleFor?: number
|
||||
}
|
||||
once?: boolean
|
||||
secrets?: string[]
|
||||
prompt?: string
|
||||
agent?: string
|
||||
model?: { provider: string; modelId: string }
|
||||
thinking?: ModelThinkingLevel
|
||||
subscribers?: Subscriber[]
|
||||
scope?: 'user' | 'project'
|
||||
slug?: string
|
||||
/** A project script's main agent to wake on every machine, `@name`; default `the main agent`. */
|
||||
to?: string
|
||||
key?: string
|
||||
}
|
||||
export type Args = Partial<CreateArgs> & {
|
||||
action: string
|
||||
id?: string
|
||||
runId?: string
|
||||
to?: string
|
||||
lines?: number
|
||||
reason?: string
|
||||
}
|
||||
export type Manifest = { name: string; ask: string; once: boolean; secrets?: string[]; to: string }
|
||||
/** A main agent's name as a subscriber or target: `the main agent`. */
|
||||
export const MAIN_AGENT = /^@[a-zA-Z0-9_-]+$/
|
||||
/** How many unplanned script exits in a row escalate instead of waking the watcher again. */
|
||||
export const FAILURE_LIMIT = 3
|
||||
export type Effect = { id: string; action: string; previousPid?: number }
|
||||
export const isSlug = (id: string) => /^[a-z][a-z0-9-]{0,62}$/.test(id) && !(id in Object.prototype)
|
||||
export const isId = (id: string) => /^A[1-9]\d*$/.test(id) || isSlug(id)
|
||||
|
||||
/** Only stdout has a protocol; stderr never fires an automation. */
|
||||
export function fireText(line: string, stdout: boolean): string | undefined {
|
||||
return stdout && /^FIRE(?:\s|$)/.test(line) ? line.slice(4).trim() : undefined
|
||||
}
|
||||
/** DONE takes effect only when the script exits 0. */
|
||||
export function doneText(line: string, stdout: boolean): string | undefined {
|
||||
return stdout && /^DONE(?:\s|$)/.test(line) ? line.slice(4).trim() : undefined
|
||||
}
|
||||
/** Keep both bounds even for a single very long output line. */
|
||||
export function trimLog(text: string): string {
|
||||
const lines = text.split('\n').slice(-2001).join('\n')
|
||||
const bytes = new TextEncoder().encode(lines)
|
||||
return bytes.length > 256 * 1024 ? new TextDecoder().decode(bytes.subarray(-256 * 1024)) : lines
|
||||
}
|
||||
|
||||
/** The task kind of an automation's prompt run, which owns the run's conversation. */
|
||||
export const RUN_TASK = 'main-agent.automation-run'
|
||||
@@ -0,0 +1,17 @@
|
||||
# Conversation lifecycle
|
||||
|
||||
The `conversations` service exposes metadata subjects and the concluded set.
|
||||
`concludeIn(tx, ids)` refuses unknown, owned, root, and main agent conversations.
|
||||
`setMainAgent`, `setAutomaticSummaries`, and `generatedTitle` update durable conversation state.
|
||||
`generatedTitle` only marks a matching metadata title that no earlier call marked.
|
||||
|
||||
The `beforeInput` hook resumes the recipient and its owner/fork ancestors.
|
||||
This change shares the transaction that places the input.
|
||||
A write without an input does not resume a conversation.
|
||||
Unrelated concluded conversations remain concluded.
|
||||
|
||||
Summaries do not run in this subset.
|
||||
`summaryModel()` returns undefined and `requestSummary()` does nothing.
|
||||
The durable automatic-summary preference remains available for the later summary port.
|
||||
|
||||
`host/tests/core_ext.rs` verifies the lifecycle through the real snapshot loader.
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Durable conversation lifecycle state and the summary seam for later ports. */
|
||||
import { defineDoc } from '@earendil-works/pi-durable'
|
||||
export const ConversationsApi = {name:'conversations'}
|
||||
export const State = defineDoc({kind:'conversations.state',version:1,scope:'conversation',history:'latest',fork:'initial',initial:()=>({}),checkpointWhen:()=>false})
|
||||
export const Concluded = defineDoc({kind:'conversations.concluded',version:1,scope:'session',initial:()=>({ids:[]}),checkpointWhen:()=>false})
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Conversation metadata and lifecycle. Summaries remain disabled until the summary port supplies them. */
|
||||
import { defineExtension, GenerationTask, hook, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
|
||||
import { ConversationsApi, State, Concluded } from './api.ts'
|
||||
export default function conversations(host: any) {
|
||||
const concludedIn = async (tx: any) => new Set<number>((await tx.doc(Concluded)).ids)
|
||||
host.implement(ConversationsApi, {
|
||||
concludedIn,
|
||||
concluded: async (context: any) => host.harness().commit(concludedIn,context),
|
||||
concludeIn: async (tx: any, ids: number[]) => {
|
||||
for (const id of ids) {
|
||||
const record = await tx.conversation(id)
|
||||
if (!record) throw new Error(`Unknown conversation ${id}`)
|
||||
if (record.owner) throw new Error(`Conversation ${id} is owned`)
|
||||
if (id === ROOT_CONVERSATION_ID || (await tx.doc(State,id)).mainAgent) throw new Error(`Conversation ${id} is fixed`)
|
||||
}
|
||||
const state = await tx.doc(Concluded)
|
||||
state.ids = [...new Set([...state.ids,...ids])]
|
||||
},
|
||||
setMainAgent: async (tx: any, id: number, mainAgent: boolean) => { (await tx.doc(State,id)).mainAgent = mainAgent },
|
||||
setAutomaticSummaries: async (tx: any, id: number, enabled: boolean) => { (await tx.doc(State,id)).automaticSummaries = enabled },
|
||||
generatedTitle: async (tx: any, id: number, title: string) => {
|
||||
const state = await tx.doc(State,id)
|
||||
if (state.generatedTitle === undefined && (await tx.doc(host.metadata,id)).title === title) state.generatedTitle = title
|
||||
},
|
||||
subject: async (id: number, context: any) => {
|
||||
const metadata = await host.harness().snapshot(host.metadata,id,context)
|
||||
return metadata?.title ? {title:metadata.title} : {}
|
||||
},
|
||||
// Later summaries can supply these without changing lifecycle callers.
|
||||
summaryModel: async () => undefined,
|
||||
requestSummary: async () => {},
|
||||
})
|
||||
return defineExtension({name:'conversations',hooks:[hook(GenerationTask,{beforeInput:async (_input,{tx,conversationId})=>{
|
||||
const ancestors = new Set<number>()
|
||||
for (let id = conversationId; id !== undefined && !ancestors.has(id);) {
|
||||
ancestors.add(id)
|
||||
const record = await tx.conversation(id)
|
||||
id = record?.owner?.conversationId ?? record?.parent?.conversationId
|
||||
}
|
||||
const state = await tx.doc(Concluded)
|
||||
state.ids = state.ids.filter(id=>!ancestors.has(id))
|
||||
}})]})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** Pure checks for world settings, Markdown definitions, overlays, and skill frontmatter. */
|
||||
import assert from 'node:assert/strict'
|
||||
import {readFileSync,readdirSync} from 'node:fs'
|
||||
import {parseConfig,parseDefinition,parseFrontmatter,definitionRuns,toolsFor} from './agents.ts'
|
||||
let count=0
|
||||
function test(name:string,run:()=>void){run();console.log(`ok ${++count} - ${name}`)}
|
||||
const config=parseConfig(readFileSync('config/config.yaml','utf8'))
|
||||
const definitions=readdirSync('config/roster').sort().map(name=>parseDefinition(readFileSync(`config/roster/${name}`,'utf8'),`config/roster/${name}`))
|
||||
test('exactly eight built-in Markdown definitions and no inline roster',()=>{
|
||||
assert.deepEqual(definitions.map(d=>d.name),['coordinator','designer','dream-pass','reviewer','sausage','scout','watcher','worker'])
|
||||
assert.equal(config.roster,undefined);assert.equal(config.main_agent_name,'sausage')
|
||||
for(const d of definitions){
|
||||
const model=['coordinator','designer','sausage'].includes(d.name)?'anthropic/claude-opus-5-5':d.name==='reviewer'?'openai/gpt-6-astra':['scout','watcher'].includes(d.name)?'openai/gpt-6-luna':'openai/gpt-6.1-sol'
|
||||
assert.equal(d.model,model)
|
||||
assert.equal(d.thinking,['scout','watcher'].includes(d.name)?'low':['worker','dream-pass'].includes(d.name)?'medium':'high')
|
||||
}
|
||||
})
|
||||
test('shared prompt contains state inspection and current world paths',()=>{
|
||||
for(const text of ['http://host.sausage/harness/','whoami','conversations/ID/entries?after=N','events?after=N','credential-requests','/proc/processes','/proc/extensions','jaq','/etc/sausage/config.yaml'])assert(config.prompt.includes(text),text)
|
||||
})
|
||||
test('main role describes direct self-change and state inspection',()=>{
|
||||
const role=definitions.find(d=>d.name===config.main_agent_name)!
|
||||
assert(role.instructions.includes('Act directly'));assert(role.instructions.includes('instead of guessing'))
|
||||
})
|
||||
test('filename defines the name and model IDs preserve slashes',()=>{
|
||||
const d=parseDefinition('---\ndescription: Example\nmodel: local/org/model\n---\nRole','/roster/new-name.md')
|
||||
assert.equal(d.name,'new-name');assert.deepEqual(definitionRuns(d).model,{provider:'local',modelId:'org/model'})
|
||||
})
|
||||
test('an additive project overlay retains base settings and appends instructions',()=>{
|
||||
const base=parseDefinition('---\ndescription: Base\nmodel: local/model\ntools: [read]\n---\nBase instructions','/roster/worker.md')
|
||||
const overlay=parseDefinition('---\naddTools: [bash]\ndenyTools: [read]\n---\nProject instructions','/project/.sausage/roster/worker.md',base)
|
||||
assert.equal(overlay.model,'local/model');assert.deepEqual(toolsFor(overlay,[]),['bash'])
|
||||
assert(overlay.instructions.includes('Base instructions'));assert(overlay.instructions.includes('Project instructions'))
|
||||
})
|
||||
test('a complete project definition replaces the body',()=>{
|
||||
const base=definitions.find(d=>d.name==='worker')!
|
||||
const next=parseDefinition('---\ndescription: Project worker\nmodel: local/model\n---\nReplacement','/project/.sausage/roster/worker.md',base)
|
||||
assert.equal(next.instructions,'Replacement')
|
||||
})
|
||||
test('missing settings and malformed definition fields fail with a path',()=>{
|
||||
assert.throws(()=>parseConfig('main_agent_name: sausage'),/prompt/)
|
||||
assert.throws(()=>parseConfig('prompt: text'),/main_agent_name/)
|
||||
assert.throws(()=>parseDefinition('---\nmodel: local/model\n---\nRole','/roster/example.md'),/description/)
|
||||
assert.throws(()=>parseDefinition('---\ndescription: Example\nmodel: invalid\n---\nRole','/roster/example.md'),/example.md/)
|
||||
assert.throws(()=>parseDefinition('---\ndescription: Example\nmodel: local/model\nthinking: invalid\n---\nRole','/roster/example.md'),/thinking/)
|
||||
})
|
||||
test('tool denials do not mutate inherited tools',()=>{
|
||||
const inherited=['read','bash'];assert.deepEqual(toolsFor({...definitions[0],denyTools:['bash']},inherited),['read']);assert.deepEqual(inherited,['read','bash'])
|
||||
})
|
||||
test('all built-in skills contain valid YAML frontmatter',()=>{
|
||||
for(const name of readdirSync('skills',{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>e.name)){
|
||||
const skill=parseFrontmatter(readFileSync(`skills/${name}/SKILL.md`,'utf8'))
|
||||
assert.equal(skill.frontmatter.name,name);assert.equal(typeof skill.frontmatter.description,'string');assert(skill.body.length>0)
|
||||
}
|
||||
})
|
||||
console.log(`${count} tests passed`)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user