Compare commits

..
333 Commits
Author SHA1 Message Date
surma 079ede7abb Merge v3-t105: host/tests/e2e-real.nu, real end-to-end check with the package and real models (T105, D71)
ci / check (push) In progress
2026-11-12 20:39:49 +00:00
surma 80500b2294 Use normalized model references in the real check 2026-11-12 20:37:29 +00:00
surma 426e6c7f13 Merge branch 'v3' into v3-t105 2026-11-12 20:16:56 +00:00
surma a536da3afd Merge v3-t103: seed /srv/www as one folder userland/web/www, whole-folder copy, Nix check for GET / (T103, D70) 2026-11-12 20:16:27 +00:00
surma d59c94a0af Match real-check replies to their submitted inputs 2026-11-12 19:55:51 +00:00
surma 3ba35d3ce6 Merge branch 'v3' into v3-t105 2026-11-12 19:47:31 +00:00
surma e61358e7aa Add a packaged real-model end-to-end check 2026-11-12 19:47:31 +00:00
surma aa5f25b64c Merge branch 'v3' into v3-t103 2026-11-12 19:45:59 +00:00
surma 8e4f32b543 Seed web assets as one folder and check the packaged web service 2026-11-12 19:45:59 +00:00
surma b304fdb1c5 Merge v3-t104: models start from pi-ai's catalog with all API flags, config overrides on top, no models.dev fetch (T104, D69) 2026-11-12 19:44:11 +00:00
surma 12c85f5807 Preserve pi-ai catalog metadata for configured models 2026-11-12 19:42:09 +00:00
surma 196d92e305 Merge v3-t98: precompiled seed tools from Nix as a read-only cache layer, lazy otherwise (T98) 2026-11-12 15:25:48 +00:00
surma 3b91cec3ee Precompile seeded tools during the Nix build 2026-11-12 15:23:54 +00:00
surma aa8c8a2818 Merge branch 'v3' into v3-t98 2026-11-12 14:47:38 +00:00
surma 80791f76a6 Merge v3-t93: idiomatic flake (separate crane pipelines, buildGoModule, QuickJS from source, multi-call binaries with symlinks, bundle assembled in Nix, full test check with printed skips) (T93) 2026-11-12 14:47:06 +00:00
surma cc3ff75a67 Read QuickJS from the built bundle and print the unavailable esbuild fixture skip 2026-11-12 14:13:34 +00:00
surma e7b7cf113c Run sandbox tests serially to avoid cold compilation deadline contention 2026-11-12 13:46:25 +00:00
surma 7de4bc9746 Select the WASI target explicitly in the remaining guest build helpers 2026-11-12 13:23:54 +00:00
surma ce2631da96 Merge v3 through T102 and remove extensions-revert from the shared bundle names 2026-11-12 12:39:32 +00:00
surma d679b37ba5 Merge branch 'v3' into v3-t98 2026-11-12 12:38:57 +00:00
surma 2dd57cc59c Merge v3-t102: extensions reload by file watching (hash at startup and on change), last good in host storage, no git in loader or init (T102) 2026-11-12 12:37:54 +00:00
surma e7674f89d7 Use extension content hashes in dependent test fixtures 2026-11-12 12:28:52 +00:00
surma b4b2fbe33e Reload extension file snapshots without Git 2026-11-12 12:22:01 +00:00
surma 257959a574 Merge branch 'v3' into v3-t93 2026-11-12 12:13:39 +00:00
surma 2edc47958b Print skips for unavailable sandbox fixtures and use multicall undo guests 2026-11-12 12:13:39 +00:00
surma ac75939b6c Merge branch 'v3' into v3-t98 2026-11-12 11:40:38 +00:00
surma a46d67e4ff tests: keep the shell corpus and esbuild app fixtures under host/tests/fixtures after removing spikes/ 2026-11-12 11:37:31 +00:00
surma dd79b573f1 Print sandbox test skips and update the multicall shutdown helper
Keep the full test suite selected without new fixture derivations.

Assisted-by: SurmAgent:unknown [read] [edit] [bash]
2026-11-12 11:22:09 +00:00
surma 93968ac6db Merge branch 'v3' into v3-t93
# Conflicts:
#	docs/RUNNING.md
2026-11-12 11:16:25 +00:00
surma 4e17bb381c Merge branch 'v3' into v3-t98 2026-11-12 11:13:54 +00:00
surma 7c0256a8a4 Add a read-only cache layer for precompiled tools 2026-11-12 11:13:14 +00:00
surma 21e154381b Remove box (Surma's private container service client) and spikes; drop personal hosts and paths from docs and code 2026-11-12 11:11:25 +00:00
surma 6a22f374e2 Supply test tools and verify PATH through the native init supervisor
Assisted-by: SurmAgent:unknown [read] [edit] [bash]
2026-11-12 10:59:32 +00:00
surma 03452c1aef Merge branch 'v3' into v3-t93 2026-11-12 10:45:35 +00:00
surma e4b59c032d tests: remove the P4 phase acceptance test (Surma: not a test at all) 2026-11-12 10:45:13 +00:00
surma f6149aa0e0 Preserve roster seeding and extend packaged multicall checks
Verify bare unit commands through init PATH lookup and cargo run.

Assisted-by: SurmAgent:unknown [read] [edit] [bash]
2026-11-12 10:25:46 +00:00
surma 5713b9be20 Merge branch 'v3' into v3-t93 2026-11-12 10:20:49 +00:00
surma e46f208c72 config: the prompt names both the config and the roster files as editable 2026-11-12 10:20:34 +00:00
surma f681b90753 Merge v3-t90: generic providers (3 API types, models.dev + overrides), roster as /etc/sausage/roster/*.md (Ultimate's 8), main_agent_name, no LLM proxy (T90) 2026-11-12 10:18:17 +00:00
surma b36c6a2e7f Use only the main agent API field in the web fixture (T90) 2026-11-12 10:13:53 +00:00
surma 18967eefe2 Merge branch 'v3' into v3-t93 2026-11-12 10:08:51 +00:00
surma cf1cce7f41 Merge branch 'v3' into v3-t90
# Conflicts:
#	userland/web/index.html
2026-11-12 10:08:32 +00:00
surma 359ecef28c Merge v3-t95: lazy WASI compilation, no precompile at startup or init (T95) 2026-11-12 10:08:02 +00:00
surma f21099013d Merge branch 'v3' into v3-t95 2026-11-12 09:57:26 +00:00
surma b9b64fb616 Merge branch 'v3' into v3-t93 2026-11-12 09:45:19 +00:00
surma bd0097f8a8 Merge v3-t91: readable approval view, banner refresh, hidden event conversations, header and narrow layout, browser QA pass (T91) 2026-11-12 09:42:18 +00:00
surma cf48ff5b3a Merge branch 'v3' into v3-t95
# Conflicts:
#	host/src/process/cache.rs
2026-11-12 09:37:24 +00:00
surma 237fc3268d Merge branch 'v3' into v3-t93
# Conflicts:
#	docs/RUNNING.md
2026-11-12 09:37:05 +00:00
surma d44f1d9677 Build separate native and WASI pipelines with shared multicall names
Checkpoint before integration and package validation.

Assisted-by: SurmAgent:unknown [read] [edit] [bash]
2026-11-12 09:36:27 +00:00
surma 03ad478596 Compile world programs only on first start 2026-11-12 09:35:51 +00:00
surma b5df7530a0 Merge branch 'v3' into v3-t91 2026-11-12 09:35:20 +00:00
surma a37e02b374 Complete browser QA with staged passkey enrollment and immediate selection 2026-11-12 09:35:20 +00:00
surma f998570fae Merge branch 'v3' into v3-t90 2026-11-12 09:34:42 +00:00
surma c2a970581d Merge v3-t96: --no-builder-sandbox host switch (builder without bubblewrap, for the Nix sandbox) (T96) 2026-11-12 09:34:16 +00:00
surma a0bb71b175 Allow host runtimes to bypass the native builder sandbox 2026-11-12 09:32:30 +00:00
surma 9df5c034a9 Seed Markdown definitions in the core extension fixture (T90) 2026-11-12 09:11:49 +00:00
surma b45a3595fb Clarify the provider key exception in the workflow skill (T90) 2026-11-12 09:01:43 +00:00
surma 85080bb74e Render credential reviews and improve responsive web views 2026-11-12 08:57:30 +00:00
surma 7d22f03b63 Update roster fixtures and redact live provider keys (T90) 2026-11-12 08:56:03 +00:00
surma d6e3158b80 Merge branch 'v3' into v3-t90 2026-11-12 08:46:33 +00:00
surma 49e88246c7 Configure generic model providers and Markdown agent definitions (T90) 2026-11-12 08:46:32 +00:00
surma 0b79bb4227 Merge v3-t92: sausage init prints its steps, precompile progress and next steps (T92) 2026-11-12 08:35:32 +00:00
surma 2e80085451 Show init steps and tool precompile progress 2026-11-12 08:33:55 +00:00
surma 2934e59fda web: browser check waits for the credential views before it fills them 2026-11-12 07:11:23 +00:00
surma cf6f71039f Merge v3-t89: proxy only via config; credential UI (passkeys, approvals, secret entry) in the web UI, host serves no HTML (T89) 2026-11-12 06:59:02 +00:00
surma a74ebd11ba Move credential ceremonies into the world web UI and ignore proxy environment 2026-11-12 06:57:34 +00:00
surma e0c2d8bc8b config: document the whoami and conversation fields agents read 2026-11-12 01:47:04 +00:00
surma 4d3b25d6aa Merge v3-t85: one Cargo workspace (host, vfs, userland, vendored brush), idiomatic flake (nix-gitignore, one crane artifact), no git in derivations (T85) 2026-11-12 01:33:06 +00:00
surma 4fc3bbdcad Merge branch 'v3' into v3-t85 2026-11-12 01:32:57 +00:00
surma 76ba7b9630 config: describe the world web server, drop stale listener text; agent test follows T88 and the whoami route 2026-11-12 01:32:37 +00:00
surma 3628159cd0 Merge v3 with the generic web server in the shared workspace 2026-11-12 00:36:50 +00:00
surma 01eb37bd9a fix: normalize bundle paths and finish fixture manifest migration 2026-11-12 00:35:02 +00:00
surma ce5d236a76 Merge v3-t88: generic world web server (/srv/www, /api -> host.sausage), host pages through it, no separate host listener (T88) 2026-11-12 00:34:14 +00:00
surma 6576005014 Verify streamed replay and release disconnected web clients 2026-11-12 00:33:00 +00:00
surma eb8ba02546 Merge branch 'v3' into v3-t88 2026-11-12 00:11:21 +00:00
surma fc07894d52 Serve editable world web files and mount the complete host API (T88) 2026-11-12 00:11:20 +00:00
surma 5a99fcfa25 Merge branch 'v3' into v3-t85 2026-11-12 00:04:57 +00:00
surma 464c9c67f9 Merge v3-t87: builder preserves symlinks, tolerates incomplete caches, shows world paths (T87) 2026-11-12 00:04:28 +00:00
surma 43a75b7c3a Preserve builder symlinks, tolerate incomplete caches, and display world paths 2026-11-11 23:56:54 +00:00
surma c3fd40768f test: scaffold the packaged probe in its own directory 2026-11-11 23:46:18 +00:00
surma 6fbb74d46a test: use packaged fixtures without host Git history 2026-11-11 23:40:12 +00:00
surma ea155ef70b config: whoami is http://host.sausage/whoami, not under /harness 2026-11-11 23:37:25 +00:00
surma 14cee48a35 build: retain the userland WASI binding version 2026-11-11 23:04:22 +00:00
surma 429f7265fb build: resolve WASI-compatible textwrap dependencies 2026-11-11 22:58:59 +00:00
surma 7c4f078f38 build: archive plain bundle sources without git 2026-11-11 22:52:53 +00:00
surma 68a4e5ff8e Merge branch 'v3' into v3-t85 2026-11-11 22:51:01 +00:00
surma 8581ded6a8 test: exercise packaged cargo new through the world builder 2026-11-11 22:51:01 +00:00
surma 9b67b60395 Merge v3-t83: one world config (proxy, standing name, global prompt, roster), sausage init, standing agent default, no ultimate (T83) 2026-11-11 22:50:26 +00:00
surma 8f47412330 Retain proxy-key redaction coverage with world config 2026-11-11 22:44:50 +00:00
surma e5e631d903 Use one editable world config and the standing conversation (T83) 2026-11-11 22:33:53 +00:00
surma 5007b18374 Merge branch 'v3' into v3-t85 2026-11-11 22:04:16 +00:00
surma 586bae953d build: unify production crates and vendor the patched shell
Assisted-by: SurmAgent:OpenAI
2026-11-11 22:04:16 +00:00
surma 0935b75a80 Merge v3-t86: world cargo new/init, no builder JSON on stdout, find -not/! (T86) 2026-11-11 21:58:42 +00:00
surma 652ea7960f Support Cargo scaffolding and human output, and find boolean expressions 2026-11-11 21:57:37 +00:00
surma a8716f85e1 Merge v3-t84: chat Ctrl/Cmd+Enter, markdown, tool cards, thinking italics (T84) 2026-11-11 21:45:44 +00:00
surma ae79f1f283 Merge branch 'v3' into v3-t84 2026-11-11 21:42:10 +00:00
surma ea58ca0514 Render Markdown, paired tools, and thinking in web chat (T84) 2026-11-11 21:42:10 +00:00
surma c7606b8ae9 Merge v3-t82: chat exits on stdin EOF, host shutdown in ms (T82) 2026-11-11 21:38:37 +00:00
surma f5e1d7aed5 flake: wrap the host in a separate symlinkJoin, so crane's toolchain reference removal keeps cargo on the wrapper PATH 2026-11-11 21:38:03 +00:00
surma b82a1ff70a Stop bootstrap chat on stdin EOF during socket and HTTP waits 2026-11-11 12:59:26 +00:00
surma d72e40356c Seed a minimal WASI web chat and move host approvals to port 8090 2026-11-11 11:42:08 +00:00
surma feab7055ad Merge v3-t80: extension snapshot of extensions/ only (git archive + tar), atomic last-good marker (T80) 2026-11-08 07:02:08 +00:00
surma 8afab591fe Snapshot only committed extensions and publish the last-good marker atomically 2026-11-08 07:00:39 +00:00
surma fcfaff6018 Merge v3-t79: extension loader deadline 120 s with one retry on timeout (T79) 2026-11-08 05:56:15 +00:00
surma 420066952c Give loader Git commands room under load and retry timeouts once 2026-11-08 05:55:10 +00:00
surma 3339e18485 Merge v3-t17: Nix package with the complete userland, builder closure and seeded world tree; v2 removed (T17)
# Conflicts:
#	README.md
2026-11-08 05:24:15 +00:00
surma e6c13f2518 Provide independent source tree references for the sandbox P1 checks 2026-11-08 04:34:45 +00:00
surma bddc5a17d7 Merge v3-t78: the test bundle cache is keyed on its source inputs (T78) 2026-11-08 04:16:21 +00:00
surma 6091d61ee8 tests: invalidate the bundle cache when source inputs change 2026-11-08 04:02:08 +00:00
surma 276295bd7b Merge v3-t76: git show/log formats and decorations, find -prune and predicates, tail from a pipe (T76) 2026-11-08 03:58:30 +00:00
surma 71da18bfe8 Remove temporary P1 diagnostics after bounded regression checks 2026-11-08 03:54:09 +00:00
surma 2a69d6350e Merge branch 'v3' into v3-t76 2026-11-08 03:45:36 +00:00
surma 3930c6de1a Add Git inspection forms, find pruning, and WASI tail stdin (T76) 2026-11-08 03:45:36 +00:00
surma a41139a70a Merge v3-t75: commit trailer from the conversation chain (D64), /tmp /run /var/tmp in every world (T75) 2026-11-08 03:15:55 +00:00
surma 62283e7733 Attribute world commits to conversations and restore temporary directories 2026-11-08 03:15:15 +00:00
surma 82ee68a4fb Merge v3-t63: docs/RUNNING.md, the cutover guide (T63) 2026-11-08 03:09:36 +00:00
surma 63ac29d026 Update v3 guide after fresh development bundle checks 2026-11-08 03:08:38 +00:00
surma 05c21f0eaf Merge T77 and match the complete development source snapshot 2026-11-08 02:59:38 +00:00
surma 80f345e70b Merge branch 'v3' into v3-t63 2026-11-08 02:59:03 +00:00
surma d6fad1300a Merge v3-t77: the development bundle seeds agents/, skills/, extensions/ (T77) 2026-11-08 02:58:45 +00:00
surma d797857c94 Run the selected test commands through a custom Crane check phase 2026-11-08 02:52:37 +00:00
surma 1b6cac325c bundle: seed agent definitions and skills in the source snapshot 2026-11-08 02:51:38 +00:00
surma 67b99c50a9 Retain compile-time fixtures for the shared integration executable 2026-11-08 02:33:15 +00:00
surma e939978e48 Select sandbox-safe modules from the shared host test binary 2026-11-08 02:32:55 +00:00
surma 3a9798d7c1 Merge branch 'v3' into v3-t17 2026-11-08 02:32:25 +00:00
surma de351919dd Merge v3-t59: one host test binary (host/tests/it), fixtures without /tmp (T59) 2026-11-08 02:31:45 +00:00
surma e02a4783f1 tests: include git compatibility in the shared executable 2026-11-08 02:30:58 +00:00
surma 8421800ab9 Merge branch 'v3' into v3-t59 2026-11-08 02:29:55 +00:00
surma 33d799d0a6 Merge v3-t73: reflog writes without identity, world default identity, show/cat-file/ls-tree, which (T73) 2026-11-08 02:29:35 +00:00
surma fdda49394f Provide world Git identity, reflog fallback, object inspection, and which (T73) 2026-11-08 02:28:34 +00:00
surma b38da84b31 Retain the vendored Deno sources that runtime initialization loads 2026-11-08 01:58:49 +00:00
surma 8c958aa351 tests: include git runtime and prepare preview1 fixtures from the bundle 2026-11-08 01:15:41 +00:00
surma 1892642d8e Merge v3-t70: git log -N/diff --check, xargs -I/-0/-n/-r, /dev devices, /bin aliases (T70)
# Conflicts:
#	userland/git/README.md
2026-11-08 01:15:01 +00:00
surma acf761372e Wait for the HTTP fixture port before Git comparisons 2026-11-08 01:13:56 +00:00
surma 3c8502f3af Restore the P1 sandbox check after the shell stream fix 2026-11-08 01:08:30 +00:00
surma 441d62f99f Merge branch 'v3' into v3-t17 2026-11-08 01:08:21 +00:00
surma ad795d8528 Merge branch 'v3' into v3-t59 2026-11-08 01:08:16 +00:00
surma 1aaaf91f57 Merge v3-t69: shell stream pump no longer busy-yields (slow git, p1 stall); git cancellation removes index.lock (T69) 2026-11-08 01:07:50 +00:00
surma 4f11096c88 Fix shell host starvation and release cancelled Git index locks 2026-11-08 01:06:36 +00:00
surma ffee43b33c Add world CLI compatibility for Git, xargs, devices and tool aliases 2026-11-08 00:58:30 +00:00
surma 994ff9da55 tests: show CLI output when the shell corpus exits incorrectly 2026-11-08 00:55:29 +00:00
surma c8ff644870 Temporarily skip the looping P1 test per T17 Note 3 2026-11-08 00:22:01 +00:00
surma 6ba35b0683 tests: give loader fixtures the complete extension tree 2026-11-07 23:12:42 +00:00
surma b003a799f9 tests: include harness shutdown in the shared executable 2026-11-07 23:08:25 +00:00
surma ab5d2417cb Merge branch 'v3' into v3-t59 2026-11-07 23:03:18 +00:00
surma 9d46ff8d09 tests: include extension identifier and proxy transport modules 2026-11-07 23:03:18 +00:00
surma 9f706afdb0 Merge v3-t71: finish notice waits for background workers; SIGTERM shuts down under load (T71) 2026-11-07 23:02:23 +00:00
surma 15f26c55bb Wait for background workers before finish notices and cancel tools on shutdown 2026-11-07 23:01:42 +00:00
surma 2f45b357ae Merge branch 'v3' into v3-t59 2026-11-07 21:41:40 +00:00
surma 3134be3ebf Merge v3-t66: route import, branch start model, static check for undefined names in extensions (T66) 2026-11-07 21:40:50 +00:00
surma a366350789 Merge v3-t67: component compilation does not consume command deadlines; common tools precompile first (T67) 2026-11-07 21:40:28 +00:00
surma 4ae1ded5b6 Fix final-answer routing and branch model events 2026-11-07 21:39:58 +00:00
surma 0ed5e7befe Exclude component compilation from command deadlines 2026-11-07 21:39:23 +00:00
surma afbafe1d0c Merge v3-t65: provider-specific proxy transports and catalog prices (T65) 2026-11-07 21:24:36 +00:00
surma 88f1078d9d Use provider-specific proxy transports and catalog prices 2026-11-07 21:23:38 +00:00
surma 78dbe78bb1 Preserve the full upstream snapshot with symlink-aware world copies 2026-11-07 21:22:24 +00:00
surma 217ac3fe03 Merge branch 'v3' into v3-t17 2026-11-07 21:22:08 +00:00
surma cab9ceb6f6 host: preserve bundle symlinks and finish integration test isolation 2026-11-07 20:58:33 +00:00
surma 361cfffb32 world: keep symlinks when copying the bundle (broken esbuild link; from T59, its test follows with T59) 2026-11-07 20:58:10 +00:00
surma 84266611ba Include agent definitions and skills in the development bundle 2026-11-07 20:56:17 +00:00
surma 38ce64fc2f Merge branch 'v3' into v3-t17 2026-11-07 20:55:49 +00:00
surma f8d04192c9 Document v3 console operation and cutover blockers 2026-11-07 20:54:26 +00:00
surma 092eb4772e Point sandbox integration tests at the packaged bundle cache 2026-11-07 19:58:33 +00:00
surma 7bd8bfe4d2 Merge branch 'v3' into v3-t59 2026-11-07 19:41:16 +00:00
surma 8319228e7c host: consolidate integration tests into one executable 2026-11-07 19:41:16 +00:00
surma bdbec68197 Merge v3-t60: P3 exit test complete, three kill points, all assertions (T60, P3-p) 2026-11-07 19:40:17 +00:00
surma 0e81e16f5c Complete the P3 landing exit test across three SIGKILL boundaries 2026-11-07 19:39:16 +00:00
surma 69d6040924 host/js: regenerate bundles after the T61 merge 2026-11-07 19:23:31 +00:00
surma ba6304eb91 Merge v3-t61: host API for the harness state (T61, P6) 2026-11-07 19:23:05 +00:00
surma 5dc4833e28 host: expose harness state and actions over the world API 2026-11-07 19:22:06 +00:00
surma 2d5449b3dc Handle world Cargo help locally before the host request 2026-11-07 18:23:01 +00:00
surma c3c5b36d09 host/js: regenerate all bundles after merging T52, T56, T57, T58 2026-11-07 18:21:34 +00:00
surma dae8dfe179 Merge v3-t56 (T56)
# Conflicts:
#	host/js/package.json
2026-11-07 18:20:16 +00:00
surma 01c609404c Merge v3-t58 (T58)
# Conflicts:
#	host/js/package.json
2026-11-07 18:20:15 +00:00
surma 57f528c2bc Merge v3-t57 (T57)
# Conflicts:
#	host/js/package.json
2026-11-07 18:20:14 +00:00
surma a440a2bcdf Merge v3-t52 (T52)
# Conflicts:
#	host/js/package.json
2026-11-07 18:20:14 +00:00
surma ad31954890 Merge branch 'v3' into v3-t17 2026-11-07 18:20:01 +00:00
surma 15bf342e10 Cargo: dev profile with line tables only (smaller test binaries, less linker memory) 2026-11-07 18:19:29 +00:00
surma 983fd0a234 Exclude the browser fixture from the initial snapshot index 2026-11-07 16:23:42 +00:00
surma 1d06547936 Omit the dangling browser fixture from the world source snapshot 2026-11-07 16:23:08 +00:00
surma 4ac1024e1c Preserve upstream source symlinks in the packaged snapshot 2026-11-07 16:03:34 +00:00
surma 2458f4d036 p4: test agent Rust and Node builds through the real console 2026-11-07 15:40:58 +00:00
surma c42d4c03fb Port goal and question-card server extensions 2026-11-07 15:24:06 +00:00
surma 6ebf4211c5 Port dream passes and built-in triggers without chapters 2026-11-07 15:21:36 +00:00
surma fcab84012d Port workspaces and the durable lander into the world 2026-11-07 15:15:53 +00:00
surma c8bf5e559e Build undo harness guests in the shared target 2026-11-07 15:15:52 +00:00
surma 5cba2d36aa Merge v3 and package the builder closure and complete userland 2026-11-07 15:02:25 +00:00
surma f90637a765 Merge v3-t54: watchdog as a world extension (T54, P3-l)
# Conflicts:
#	host/js/package.json
2026-11-07 14:59:32 +00:00
surma 29abe8347c Port durable watchdog retries and reports into the world 2026-11-07 14:58:11 +00:00
surma 3f5b6082e6 host/js: regenerate the p3 test bundle after the T51 merge 2026-11-07 14:52:54 +00:00
surma b29ae6bbce Merge v3-t51: automations and pause as world extensions, host.chain (T51, P3-n) 2026-11-07 14:51:59 +00:00
surma 4f3133da9e tests: hook_git borrows the target path after the T39 merge 2026-11-07 14:50:12 +00:00
surma 02f6341bf7 Expose canonical host chains and validate automation ancestry 2026-11-07 14:50:06 +00:00
surma 841e4ba6b6 Merge v3-t39: Rust builder behind host.sausage/cargo and the world cargo CLI (T39, P4)
# Conflicts:
#	flake.nix
#	host/src/process/mod.rs
#	host/tests/hook_git.rs
#	host/tests/hook_guest.rs
#	host/tests/process.rs
#	userland/Cargo.toml
2026-11-07 14:48:35 +00:00
surma 74c80acdf8 Merge T55 item 1: @ultimate branch round trip with D63 ancestry (T55, P3-p part) 2026-11-07 14:46:40 +00:00
surma d63482f02e builder: register the owned pidfd with the current Tokio API 2026-11-07 14:46:04 +00:00
surma d014222ac4 Test ultimate branch replies and D63 ancestry across reopen 2026-11-07 14:45:57 +00:00
surma d349d20524 Merge v3 messages compatibility fix into v3-t51 2026-11-07 14:43:07 +00:00
surma 66d5c7eea4 Use canonical host chains for automation scripts 2026-11-07 14:42:09 +00:00
surma ca9bd813db Merge T55 step 1: messages branch uses the models service; test that loads every extension (fixes the T49/T50 conflict) 2026-11-07 14:41:46 +00:00
surma a66a933dc4 Fix branch model configuration and load all world extensions in a test 2026-11-07 14:41:02 +00:00
surma c9d2dd0b7c Merge v3 into v3-t51 for T51 rework 2026-11-07 14:24:35 +00:00
surma fe9306c38b Merge v3-t53: P3 exit test scaffolding, scripted model, worker kill point (T53, P3-p part)
# Conflicts:
#	host/js/package.json
2026-11-07 14:24:11 +00:00
surma 7ee4059414 builder: retain Cargo config for run and correct acceptance checks 2026-11-07 14:20:37 +00:00
surma 88e8f7a454 Port durable automations and global pause as world extensions 2026-11-07 14:16:05 +00:00
surma 6c58e73319 Scaffold P3 exit test with scripted roles and worker SIGKILL recovery 2026-11-07 14:15:11 +00:00
surma a9a7f0961b Merge v3-t49: messages and lineage core as world extensions (T49, P3-i)
# Conflicts:
#	host/js/bundle.js
#	host/js/package.json
2026-11-07 14:15:08 +00:00
surma ee015fce70 Port durable messages and lineage into world extensions 2026-11-07 14:13:17 +00:00
surma edeb681fb4 Merge v3-t50: subagents use the models service, every test module in the bundle script (T50)
# Conflicts:
#	host/js/package.json
#	host/js/subagents-test-bundle.js
2026-11-07 13:51:34 +00:00
surma d5de1ad7c9 Use shared models service for subagents and bundle every JS test 2026-11-07 13:50:09 +00:00
surma 5849b855c6 Merge v3-t48: harness tool call ids in the undo journal (T48, P5 exit test)
# Conflicts:
#	host/js/package.json
2026-11-07 13:50:06 +00:00
surma 5254e26ca1 Tag harness tools and extension execution for reversible undo 2026-11-07 13:48:07 +00:00
surma 58894c1315 Merge v3-t46: plan and todos as world extensions (T46, P3-m part)
# Conflicts:
#	host/js/package.json
2026-11-07 13:31:57 +00:00
surma 6200a2e7c8 Merge v3-t45: core world extensions models, prompt, conversations, definitions (T45, P3-g, P3-e)
# Conflicts:
#	host/js/package.json
2026-11-07 13:31:47 +00:00
surma e53211eff1 builder: delegate run selection and preserve test working directories 2026-11-07 13:24:53 +00:00
surma 0442ab2dce Merge v3-t47: subagents as a world extension (T47, P3-h) 2026-11-07 13:15:19 +00:00
surma 118f54a55b Port durable subagent coordination to world extensions (T47) 2026-11-07 13:13:48 +00:00
surma 912e229f72 harness: set Options.tool_call (None until T48) after the T41 merge 2026-11-07 13:08:09 +00:00
surma 4b3f256375 Merge v3-t41: undo journal in the VFS, host API and undo CLI (T41, P5)
# Conflicts:
#	userland/Cargo.toml
2026-11-07 13:05:34 +00:00
surma f3fd6b76ca Port plan and todos as world extensions 2026-11-07 13:01:57 +00:00
surma 7ce839e908 Add tagged VFS undo journal, host API, and world CLI 2026-11-07 13:01:43 +00:00
surma 0be19f3f9c builder: exclude namespace mounts from Cargo clean 2026-11-07 12:55:28 +00:00
surma 4cc8707a20 Port core conversation, prompt, and model extensions 2026-11-07 12:53:24 +00:00
surma 250b5ab9f7 Merge v3-t43: durable events, emit/react/eventLog for extensions (T43, P3-d) 2026-11-07 12:47:38 +00:00
surma 2f18f0fa4c builder: enforce target, source sync, and timeout contracts 2026-11-07 12:32:45 +00:00
surma 62feb38d44 Regenerate event bundles after conversation chain integration 2026-11-07 12:30:42 +00:00
surma f419231ac4 Merge branch 'v3' into v3-t43 2026-11-07 12:30:10 +00:00
surma 08068886a3 Merge v3-t44: conversation chain to Exec, grant inheritance (T44, P3-f) 2026-11-07 12:29:38 +00:00
surma e003fd6b40 Pass cached conversation ancestry to bash execution 2026-11-07 12:28:46 +00:00
surma a27d579264 builder: preserve forwarded arguments and clean world artifacts 2026-11-07 12:14:17 +00:00
surma 90a4e2c1ce Merge v3-t42: awk, tar, gzip, du, stat, id, nohup, esbuild, qjs in the world (T42, without the Nix part) 2026-11-07 12:08:36 +00:00
surma b8b3166943 Port durable events and scoped extension reactors 2026-11-07 12:06:25 +00:00
surma c96e59417a builder: add native Cargo service and world CLI 2026-11-07 11:59:28 +00:00
surma ad6aa97ee1 Merge v3-t38: extension loader and host, live reload from /src/sausage main (T38, P3-c) 2026-11-07 11:31:44 +00:00
surma 72a4c2d539 Supply the extension state checkpoint policy 2026-11-07 11:30:48 +00:00
surma 576658a0c7 tests: verify the new tools through the world shell 2026-11-07 11:27:22 +00:00
surma dc6d90b0d3 userland: add portable du, stat, root id, and nohup 2026-11-07 11:16:55 +00:00
surma d79b2bc2e1 userland: add archive tools and pinned GoAWK, esbuild, and QuickJS 2026-11-07 10:56:29 +00:00
surma 44797f1302 Reuse the workspace target for fixture builds 2026-11-07 10:50:30 +00:00
surma 7e7e49bfa2 Merge v3-t40: world box CLI, port of v2's box plugin (T40, P4) 2026-11-07 10:46:30 +00:00
surma d07db09d80 box: reopen completed tree spools for upload 2026-11-07 10:41:23 +00:00
surma 85735ccf19 box: document startup-aware builds and verify archive preservation 2026-11-07 10:40:00 +00:00
surma d26d6a788a Merge v3 and retain v3 packaging with the kache-compatible shell 2026-11-07 10:38:30 +00:00
surma 2fc1fe48ba Merge branch 'v3' into v3-t40 2026-11-07 10:38:21 +00:00
surma 8e7cb5061a box: port disposable containers CLI and output retention 2026-11-07 10:38:21 +00:00
surma b605717b4d Merge branch 'v3' into v3-t38 2026-11-07 10:38:20 +00:00
surma bcb3e4d8e5 Load world extensions from commit snapshots with last-good recovery 2026-11-07 10:38:20 +00:00
surma a26721194e flake: stop overriding CARGO_HOME in the dev shell, so kache from ~/.cargo/config.toml applies 2026-11-07 10:38:03 +00:00
surma f74b16dea9 builder: add runtime tools and preserve the host Cargo configuration 2026-11-07 10:36:25 +00:00
surma 94f8d6d30c Preserve the configured kache wrapper in the development shell 2026-11-07 10:35:26 +00:00
surma c387561f40 Merge v3-t35: P2 exit test rehearsal, conversation id fix (T35) 2026-11-07 07:03:54 +00:00
surma 9d40a3fd73 test: regenerate P2 bundle with patched pi-durable 2026-11-07 07:03:07 +00:00
surma 7503f093d0 Merge branch 'v3' into v3-t35 2026-11-07 06:57:00 +00:00
surma 534fa50615 Merge v3-t32: pi-durable patch (P3-a), headless harness with channel input (P3-b) (T32)
# Conflicts:
#	host/src/harness/mod.rs
2026-11-07 06:56:42 +00:00
surma b807d5f31e Format the read-only bundle regression test 2026-11-07 06:55:17 +00:00
surma ff542f4946 Keep world copies writable and include embedded host assets 2026-11-07 06:54:40 +00:00
surma f46e5a8478 Merge branch 'v3' into v3-t17 2026-11-07 06:52:22 +00:00
surma 5d63dcfb18 Merge v3-t36: git status refreshes the index stat cache (T36) 2026-11-07 06:51:25 +00:00
surma e1cfaa93bf git: refresh index stat cache after CLI status 2026-11-07 02:39:08 +00:00
surma 2d38101870 Give sandbox checks a writable compilation cache 2026-11-07 02:38:33 +00:00
surma ce9418f2e4 test: rehearse P2 passkey approvals, injected fetch, and SSH push 2026-11-07 01:34:45 +00:00
surma c8525c3acb Carry durable input hooks and add a headless harness channel 2026-11-07 01:03:17 +00:00
surma 9d11ab381e Merge v3-t37: agent definitions, skills, model families and the definition parser (T37, P3-e part 1) 2026-11-07 00:59:10 +00:00
surma 0dc97c7682 feat: port world agents, skills, and pure definition parser
Rewrite Ultimate roles for the WASI world and explicit repositories. Add approved proxy model families, ordered overlays, and executable parser and asset checks.
2026-11-07 00:57:27 +00:00
surma 9cae803323 Merge v3-t34: git forms for workspaces and the lander, conversation trailer (T34) 2026-11-07 00:49:35 +00:00
surma 54229adad2 git: add workspace coordination forms and conversation trailers 2026-11-07 00:44:49 +00:00
surma 4dd6161091 Merge v3-t29: wasi:http hook, credential injection, guest TCP/DNS with the admin rule, one shared host API (T29) 2026-11-07 00:27:30 +00:00
surma c91ecbf7a4 Share owned host API state between HTTP hooks and native pages 2026-11-07 00:23:57 +00:00
surma aaf6d9b54f Merge v3-t22: init request race and shared-compiler lifetime fixes (T22 Rework 2) 2026-11-07 00:01:50 +00:00
surma a2bc0545a4 Match the V8 archive to deno_core's simdutf feature 2026-11-07 00:01:03 +00:00
surma 17d67aadcc Refresh unit requests and preserve shared compilation across runtime shutdown 2026-11-06 23:53:54 +00:00
surma 45d099c3e4 Merge branch 'v3' into v3-t29
# Conflicts:
#	host/Cargo.toml
#	host/src/harness/mod.rs
2026-11-06 23:46:27 +00:00
surma 9517515efe api: shut the init supervisor down gracefully after the console (merge fix for T22 + T25) 2026-11-06 23:43:49 +00:00
surma bb162abd4d T29: use QuickJS and verify the HTTP hook and guest networking 2026-11-06 23:38:21 +00:00
surma 904415063a Merge v3-t25: host API routes and host-served approval, passkey and secret-entry pages (T25) 2026-11-06 23:24:26 +00:00
surma c500ecd281 api: merge v3 proofs and add host approval pages and listener 2026-11-06 23:18:24 +00:00
surma 65464069d3 Merge v3-t22: init as a WASI systemd subset, file watchers, exit pollables (T22) 2026-11-06 23:15:39 +00:00
surma 7c7656c4a8 Merge branch 'v3' into v3-t29
# Conflicts:
#	host/Cargo.toml
2026-11-06 22:50:21 +00:00
surma fb83e0c067 WIP T29: credential hook, pinned HTTP transport and guest networking 2026-11-06 22:49:27 +00:00
surma 5a00cf3feb Merge v3-p2-fix: security review fixes for the credentials manager (T27, T28)
Typed passkey proofs checked inside mutation transactions, ports and normalized
paths, overrides on GrantCreate, payload digests, key file checks, retention
(D57), scripts in QuickJS inside Wasmtime (D58), RSA on aws-lc-rs, OAuth2
token URL checks. 85 credential tests plus durable and harness tests pass.
2026-11-06 22:47:16 +00:00
surma e332e76807 cred: reject the reserved host and migrate durable fixtures 2026-11-06 22:44:17 +00:00
surma aeb14a0b70 Merge v3-t30: curl for the world on wasi:http (T30) 2026-11-06 22:44:03 +00:00
surma 47948d7047 Frame curl requests and scope the test fixture lifecycle 2026-11-06 22:41:47 +00:00
surma 6c9eccaa21 Add WASI curl CLI and native HTTP comparisons 2026-11-06 22:31:22 +00:00
surma 86c5d8a4e4 cred: isolate scripts in WASI and move RSA signing to AWS-LC 2026-11-06 22:22:49 +00:00
surma e7be031238 Package the complete v3 bundle and retain WITX sources 2026-11-06 22:21:03 +00:00
surma f234b2d1e2 Merge v3 with T15 into v3-t22 2026-11-06 22:20:05 +00:00
surma 4568b8fe54 Merge branch 'v3' into v3-t17 2026-11-06 22:19:53 +00:00
surma 944504117d Remove v2 code and document the v3 world 2026-11-06 22:19:53 +00:00
surma 5b3e1393f5 cred: complete core proof, policy, storage and injection fixes 2026-11-06 22:19:35 +00:00
surma e33d3fac3b Merge v3-t15: shared compile cache, precompile, /bin/*.wasm and /src/sausage copies (T15) 2026-11-06 22:18:53 +00:00
surma b3d91d37e3 process: keep Cranelift's default optimization level
Compiles are cached per content and guests run many times; OptLevel::None
saved 12.7% compile time but slows every run.
2026-11-06 22:18:33 +00:00
surma e8732ede15 Limit background compilation and reduce cold compile cost 2026-11-06 21:39:22 +00:00
surma a75094054e Merge branch 'v3' into v3-p2-fix 2026-11-06 21:31:50 +00:00
surma b3d3558b31 cred: stage T27 core fixes and security regressions before T26 merge (WIP) 2026-11-06 21:31:50 +00:00
surma 940ae61af6 cred: blind RSA signatures and stage T28 security regressions (WIP) 2026-11-06 21:17:52 +00:00
surma b0a651868f Merge v3-t26: credential requests as durable tasks, llm-proxy token as an item (T26) 2026-11-06 21:16:56 +00:00
surma abe420f0d4 Make credential requests durable and store proxy authentication 2026-11-06 21:13:40 +00:00
surma c70948efe2 api: add public credential routes and SSH signing oracle 2026-11-06 21:05:11 +00:00
surma 16a9a9eb6c Merge v3-p2-cred: credentials manager library (T19, T20, T21)
Items, rules, grants for conversation subtrees and programs, requests, edit
requests, audit, the injection pipeline, V8 scripts, signing, OAuth2 refresh,
WebAuthn registration and bound assertions, and encrypted secret entry.
50 tests pass. Security review T24 runs on this code.
2026-11-06 20:24:15 +00:00
surma 30dcd4c2ca cred: keep typed secret entry configuration valid 2026-11-06 20:16:24 +00:00
surma 091f679905 cred: port native items grants requests and injection core 2026-11-06 20:05:54 +00:00
surma cd324678fe Preserve indexed executable modes in WASI git 2026-11-06 20:04:06 +00:00
surma 457a966068 Isolate watcher queues and selectively reload resolved service binaries 2026-11-06 20:00:29 +00:00
surma f2df0ce362 Prepare v3 Nix packaging and OpenSSL build inputs 2026-11-06 19:51:44 +00:00
surma 3e33a85a6c Merge branch 'v3' into v3-t15 2026-11-06 19:50:23 +00:00
surma 4f9919d8dd Share compilation and copy independent commands and source into new worlds 2026-11-06 19:50:23 +00:00
surma b39bbd4f18 Run world service policy in WASI init with pollable process observation 2026-11-06 19:49:19 +00:00
surma 1d2eb894a2 Merge v3-t23: ssh CLI and git SSH transport, signing via the host API (T23) 2026-11-06 19:48:25 +00:00
surma 11569119e6 cred: initialize V8 inside its Tokio executor and verify concurrent limits 2026-11-06 19:47:15 +00:00
surma 99a8e3a8be Add WASI SSH CLI and in-process Git SSH transport 2026-11-06 19:42:00 +00:00
surma a7e99d52f5 cred: reject non-32-byte raw ES256 scalars 2026-11-06 19:29:35 +00:00
surma 608d0c3fc1 Merge v3-t16: git matches native agent workflows (T16) 2026-11-06 19:17:17 +00:00
surma 6daa53cca4 git: match native agent workflows 2026-11-06 19:07:28 +00:00
surma 9e7e5d7b2a cred: add pure scripts, signing, and OAuth refresh; validation blocked 2026-11-06 19:05:07 +00:00
surma 1dc09bc418 cred: add passkey challenges and encrypted secret entry
Validation remains blocked by rusqlite u64 conversions in cred/grants.rs, which T19 owns.
2026-11-06 19:04:42 +00:00
surma b5462cc366 cred: script runtime contract for P2 2026-11-06 18:17:10 +00:00
surma ec6e20a9d7 host: sausage run with the userland bundle, real shell and services (T13)
- userland/bundle.nu builds the userland and assembles target/userland/bin
  (bin names, tools.txt aliases, brush as sh and bash).
- run and sh take --userland (default SAUSAGE_USERLAND); /bin is read-only.
- The console's bash tool runs through the process table with the
  conversation id; large output spills to the world's /tmp.
- sausage run starts the services supervisor.

P1 exit session (openai/gpt-6.1-sol, fresh world): write, read, edit, git
init/add/commit/diff/log, grep -rn, rg, find and a sed|sort|uniq|wc pipeline.
2026-11-06 17:34:11 +00:00
surma b9e3e3677d userland: search and exec tools, spawn library (T14)
rg (ripgrep 15.2), grep (ours, on ripgrep's regex crates), find and xargs
(ours), sed (uutils 0.3 wrapper), diff and cmp (uutils diffutils 0.6 with -r),
fd 10.5, jaq 2.3, env and timeout. userland/spawn is a small Command-like
client of the process interface. search-test.nu: 42 cases match native tools.
2026-11-06 17:21:51 +00:00
surma 665b8a2a03 host: wasip1 processes, wasi:http, identity, stdio options, services (T12)
- Core modules run on the host-side preview1 over the shared VFS (D41).
- wasi:http default handler in the linker; anonymous HTTPS git clone works.
  Sockets stay denied until P2.
- Each process carries the conversation (from Exec, inherited by children,
  shown in /proc/processes) and a host-assigned SAUSAGE_PID.
- Spawn requests choose pipe, inherit or null per stream.
- /config/services.yaml supervisor: live edits, restarts, crash limit 5/10 s,
  graceful stop (stdin EOF, then kill after 5 s) (D44).
- Compile caches keyed by content; PATH=/usr/local/bin:/bin.

world.rs also carries T13's optional read-only /bin mount (D43).
2026-11-06 17:21:51 +00:00
surma afff3ccda5 userland/git: --version and commit --no-gpg-sign (no-op) 2026-11-06 16:57:38 +00:00
surma 4df2c2e837 exec: Options carries the calling conversation 2026-11-06 16:38:00 +00:00
surma 57a47b069e host: process table, shell, coreutils and harness (T8, T9)
T8: sausage:process WIT (spawn, wait, kill), one store per process over the
shared root, /proc/processes, /run, pipeline and cancellation groups, Exec.
brush and 78 uutils commands; the shared cwd-init object sets the cwd from PWD
and keeps numeric exit codes. The corpus matches native bash.

T9: V8 (deno_core) runs pi-durable, pi-ai and chord; SQLite storage in
<dir>/host/harness.sqlite (WAL, synchronous=FULL); file tools on the VFS, bash
through Exec; resumes after SIGKILL.
2026-11-06 16:34:34 +00:00
surma 5ce2a2f2bd vfs: host-side WASI preview1 over sausage-vfs (T11)
Preview1 state lives in the host, so Go modules (esbuild) work. esbuild bundles
byte-identical to native; uutils and QuickJS-ng file tests pass. The failed
guest-side adapter attempt (T7) moves to spikes/s7-preview1-adapter/.
2026-11-06 16:34:33 +00:00
surma 4b27e9afd5 userland/git: git-compatible CLI on v2's gix code (T10)
Local workflow, smart-HTTP clone/fetch/push and public HTTPS clone/fetch match
native git in stdout and exit codes. Only WASI imports (wasi:http, exit-with-code).
2026-11-06 16:07:57 +00:00
surma 0fae0f9b10 exec: higher-ranked lifetime for output chunks 2026-11-06 16:01:07 +00:00
surma 964ef978c2 v3: world directory layout (world/ is the VFS root, host/ is host state) 2026-11-06 15:16:18 +00:00
surma 409da85124 v3: host crate skeleton, userland workspace, AGENTS.md for v3
The workspace is now host/ and vfs/. v2 crates leave the workspace as reference.
host/src/exec.rs is the contract between the process table and the harness.
2026-11-06 15:14:49 +00:00
surma 9e9cde5241 spikes: six v3 spikes (V8 harness, WASI shell, git, builder, http hook, esbuild)
Throwaway prototypes for Sausage v3. Plan and decisions:
~/research/sausage-v3/PLAN.md. Results and evidence: the spike READMEs.
2026-11-06 14:03:20 +00:00
Sausage ee88adf6c6 Remove private-world pointer from service timing comment
ci / check (push) In progress
2026-11-01 22:17:04 +00:00
Sausage 6dc21cd8dc Remove card labels from reusable cargo deadline tests 2026-11-01 22:17:04 +00:00
Sausage 5be5b3b397 Clean up dropped SDK calls and make drain assertions order-independent 2026-11-01 22:17:04 +00:00
Sausage a95b5287cf Add armed file watches without serializing unrelated IO 2026-11-01 22:17:04 +00:00
4058 changed files with 1943935 additions and 22777 deletions
+7
View File
@@ -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
View File
@@ -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/
+83 -37
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+161 -6
View File
@@ -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
View File
@@ -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.
-624
View File
@@ -1,624 +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
discover.json core.rpc.discover document
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.rpc.discover` | none | OpenRPC core API document (also `/proc/core/discover.json`) |
| `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.
+39 -115
View File
@@ -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.
-14
View File
@@ -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"
-88
View File
@@ -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);
}
}
-657
View File
@@ -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))
}
-211
View File
@@ -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();
}
}
-258
View File
@@ -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), "");
}
}
-10
View File
@@ -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.
-268
View File
@@ -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);
}
}
-42
View File
@@ -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" }
-302
View File
@@ -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, &params, &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(&copy, &bytes).await.map_err(io_error)?;
self.0
.journal
.write("core", plugin, "core", "activation copied candidate");
let discovery = self.discover_copy(plugin, &copy, 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(&copy, bytes).await.is_ok()
&& let Ok(interface) =
self.discover_copy(&candidate.plugin, &copy, 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(&current).await.map_err(io_error)? {
let backup = candidate.copy.with_extension("previous");
tokio::fs::hard_link(&current, &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, &current)
.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))
}
}
-52
View File
@@ -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
-12
View File
@@ -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
[]
-836
View File
@@ -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(&params, "timeout_secs", DEFAULT_TIMEOUT_SECS)?,
test_timeout_secs: seconds(&params, "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(&copy)
.with_context(|| format!("remove {}", copy.display()))?;
}
copy_source(&source, &copy, 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"));
}
}
-217
View File
@@ -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)
}
}
-314
View File
@@ -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 = &params["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(());
}
}
}
-19
View File
@@ -1,19 +0,0 @@
{
"openrpc": "1.2.6",
"info": {"title":"core", "version":"0.1.0"},
"methods": [
{"name": "rpc.discover", "summary": "Describe the native core API.", "description": "Describe the native core API.", "paramStructure": "by-name", "params": [], "result": {"name": "result", "schema": {"type": "object"}}},
{"name": "watch", "summary": "Keep this call open until the caller process ends. First core.notify is armed, then changed after successful plugin-originated file mutations. Scan only after armed; rescan after changed. Events may coalesce; files are the truth. No polling or cancellation API. See PROTOCOL section 4.", "description": "Keep this call open until the caller process ends. First core.notify is armed, then changed after successful plugin-originated file mutations. Scan only after armed; rescan after changed. Events may coalesce; files are the truth. No polling or cancellation API. See PROTOCOL section 4.", "paramStructure": "by-name", "params": [{"name": "path", "required": true, "description": "Virtual file or directory path, including descendants; need not exist. /self is relative to the caller.", "schema": {"type": "string"}}], "result": {"name": "result", "schema": {"type": "null"}}},
{"name": "call", "summary": "Make a call and return its result; detach lets the new process outlive its caller.", "description": "Make a call and return its result; detach lets the new process outlive its caller.", "paramStructure": "by-name", "params": [{"name": "method", "required": true, "description": "Qualified method.", "schema": {"type": "string"}}, {"name": "params", "required": true, "description": "Named parameters.", "schema": {"type": "object"}}, {"name": "detach", "required": false, "description": "Detach the new process.", "schema": {"type": "boolean"}}], "result": {"name": "result", "schema": {}}},
{"name": "notify", "summary": "Notify this process's caller while its call is open.", "description": "Notify this process's caller while its call is open.", "paramStructure": "by-name", "params": [{"name": "name", "required": true, "description": "Event name.", "schema": {"type": "string"}}, {"name": "params", "required": false, "description": "Event payload.", "schema": {}}], "result": {"name": "result", "schema": {"type": "null"}}},
{"name": "stop", "summary": "End a process and await cleanup.", "description": "End a process and await cleanup.", "paramStructure": "by-name", "params": [{"name": "pid", "required": true, "description": "Process ID.", "schema": {"type": "string"}}], "result": {"name": "result", "schema": {"type": "object"}}},
{"name": "kill", "summary": "End a process and await cleanup.", "description": "End a process and await cleanup.", "paramStructure": "by-name", "params": [{"name": "pid", "required": true, "description": "Process ID.", "schema": {"type": "string"}}], "result": {"name": "result", "schema": {"type": "object"}}},
{"name": "lock", "summary": "Acquire a FIFO process-lifetime lock.", "description": "Acquire a FIFO process-lifetime lock.", "paramStructure": "by-name", "params": [{"name": "path", "required": true, "description": "Lock key.", "schema": {"type": "string"}}, {"name": "wait", "required": false, "description": "Wait for acquisition (default true).", "schema": {"type": "boolean"}}], "result": {"name": "result", "schema": {"type": "object", "properties": {"held": {"type": "boolean"}}, "required": ["held"]}}},
{"name": "unlock", "summary": "Release an owned lock.", "description": "Release an owned lock.", "paramStructure": "by-name", "params": [{"name": "path", "required": true, "description": "Lock key.", "schema": {"type": "string"}}], "result": {"name": "result", "schema": {"type": "null"}}},
{"name": "activate", "summary": "Install one or more checked plugin versions. See PROTOCOL section 10.", "description": "Install one or more checked plugin versions. See PROTOCOL section 10.", "paramStructure": "by-name", "params": [{"name": "plugin", "required": false, "description": "Single plugin name.", "schema": {"type": "string"}}, {"name": "wasm", "required": false, "description": "Candidate path.", "schema": {"type": "string"}}, {"name": "plugins", "required": false, "description": "Batch candidates.", "schema": {"type": "array", "items": {"type": "object", "properties": {"plugin": {"type": "string"}, "wasm": {"type": "string"}}, "required": ["plugin", "wasm"]}}}, {"name": "force", "required": false, "description": "Skip compatibility rejection.", "schema": {"type": "boolean"}}], "result": {"name": "result", "schema": {"type": "object"}}},
{"name": "revert", "summary": "Restore the previous plugin version.", "description": "Restore the previous plugin version.", "paramStructure": "by-name", "params": [{"name": "plugin", "required": true, "description": "Plugin name.", "schema": {"type": "string"}}, {"name": "force", "required": false, "description": "Skip compatibility rejection.", "schema": {"type": "boolean"}}], "result": {"name": "result", "schema": {"type": "object"}}},
{"name": "log", "summary": "Read the central journal (PROTOCOL section 7).", "description": "Read the central journal (PROTOCOL section 7).", "paramStructure": "by-name", "params": [{"name": "pid", "required": false, "description": "Process ID.", "schema": {"type": "string"}}, {"name": "plugin", "required": false, "description": "Plugin name.", "schema": {"type": "string"}}, {"name": "since", "required": false, "description": "ISO 8601 start time.", "schema": {"type": "string"}}, {"name": "until", "required": false, "description": "ISO 8601 end time.", "schema": {"type": "string"}}, {"name": "grep", "required": false, "description": "Line substring.", "schema": {"type": "string"}}, {"name": "limit", "required": false, "description": "Maximum entries (default 100).", "schema": {"type": "integer"}}], "result": {"name": "result", "schema": {"type": "array", "items": {"type": "object"}}}},
{"name": "restart", "summary": "Request clean host restart through the loop supervisor; needs explicit operator approval.", "description": "Request clean host restart through the loop supervisor; needs explicit operator approval.", "paramStructure": "by-name", "params": [], "result": {"name": "result", "schema": {"type": "object"}}},
{"name": "exit", "summary": "Request clean host restart through the loop supervisor; needs explicit operator approval.", "description": "Request clean host restart through the loop supervisor; needs explicit operator approval.", "paramStructure": "by-name", "params": [], "result": {"name": "result", "schema": {"type": "object"}}}
]
}
-76
View File
@@ -1,76 +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 {
"rpc.discover" => Ok(serde_json::from_str(include_str!("core.json")).unwrap()),
"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")))
}
-129
View File
@@ -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
}
}
}
-328
View File
@@ -1,328 +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();
files.insert(
"/discover.json",
|| Ok(include_bytes!("core.json").to_vec()),
);
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
}
}
}
-288
View File
@@ -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(&copy, &bytes).await {
Ok(()) => self.discover_copy(&name, &copy, hash.clone()).await,
Err(error) => Err(io_error(error)),
};
let _ = tokio::fs::remove_file(&copy).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(), &current.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
View File
@@ -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()
}
}
-411
View File
@@ -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);
}
}
-31
View File
@@ -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})
}
-90
View File
@@ -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
View File
@@ -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)
}
-35
View File
@@ -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);
}
}
-178
View File
@@ -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;
}
}
-129
View File
@@ -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();
}
}
-397
View File
@@ -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(&params));
}
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(&params))
} 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}));
}
}
-440
View File
@@ -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");
}
}
-166
View File
@@ -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;
-356
View File
@@ -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();
}
-293
View File
@@ -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)
}
-192
View File
@@ -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);
});
}
});
}
}
-115
View File
@@ -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;
}
-84
View File
@@ -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(())
}
}
-178
View File
@@ -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;
}
}
-71
View File
@@ -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(())
}
-12
View File
@@ -1,12 +0,0 @@
[package]
name = "cargo-c"
version = "0.1.0"
edition = "2024"
[dependencies]
sausage-plugin = "0.1"
[build-dependencies]
cc = "1"
[workspace]
-5
View File
@@ -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");
}
-25
View File
@@ -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);
}
}
-3
View File
@@ -1,3 +0,0 @@
fn main() {
missing_function();
}
-7
View File
@@ -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
View File
@@ -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);
}
}
-15
View File
@@ -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"
-4
View File
@@ -1,4 +0,0 @@
//! Produces a warning so the build response can retain diagnostics.
fn main() {
println!("cargo:warning=fixture build warning");
}
-1
View File
@@ -1 +0,0 @@
fixture
-165
View File
@@ -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
-669
View File
@@ -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
-450
View File
@@ -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}"
);
}
}
}
-43
View File
@@ -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()
}
-298
View File
@@ -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
-10
View File
@@ -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;
-399
View File
@@ -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"));
}
-195
View File
@@ -1,195 +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;
}
/// PROTOCOL 9: core discovery includes the armed watch and matches its proc projection.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn discovery() {
let (_dir, h) = start().await;
let doc = call(&h, "core.rpc.discover", json!({})).await;
let projected = call(&h, "probe.read", json!({"path":"/proc/core/discover.json"})).await;
assert_eq!(
doc,
serde_json::from_str::<Value>(projected.as_str().unwrap()).unwrap()
);
let watch = doc["methods"]
.as_array()
.unwrap()
.iter()
.find(|m| m["name"] == "watch")
.unwrap();
assert_eq!(watch["params"][0]["name"], "path");
assert!(watch["description"].as_str().unwrap().contains("armed"));
h.stop().await;
}
-154
View File
@@ -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();
}
}
-589
View File
@@ -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(&notification.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!({})
}
+62
View File
@@ -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}
+34
View File
@@ -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.
+21
View File
@@ -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.
+23
View File
@@ -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.
+25
View File
@@ -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.
+54
View File
@@ -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.
+16
View File
@@ -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.
+24
View File
@@ -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.
+28
View File
@@ -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
View File
@@ -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.
-85
View File
@@ -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": {}
}
-128
View File
@@ -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": {}
}
-94
View File
@@ -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": {}
}
-81
View File
@@ -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);
}
}
-84
View File
@@ -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(&notes)?)?;
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());
}
}
-7
View File
@@ -1,7 +0,0 @@
[package]
name = "secrets"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
sausage-plugin = { path = "../../sdk" }
-90
View File
@@ -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")
);
}
}
-8
View File
@@ -1,8 +0,0 @@
[package]
name = "www"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
sausage-plugin = { path = "../../sdk" }
wstd = "0.6"
-97
View File
@@ -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());
}
}
+78
View File
@@ -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.
+60
View File
@@ -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.
+7
View File
@@ -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')
+154
View File
@@ -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`
}
+280
View File
@@ -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)
}
}
+186
View File
@@ -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)}]}
}})]})
}
+315
View File
@@ -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 }
}
+140
View File
@@ -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'
+17
View File
@@ -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.
+5
View File
@@ -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})
+43
View File
@@ -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))
}})]})
}

Some files were not shown because too many files have changed in this diff Show More