commit 1f49d0f7bb1e5b662ce7a72434cf8dd4fb39c8d3 Author: Surma Date: Thu Oct 15 10:45:23 2026 +0100 Sausage: host, broker, commit-on-close filesystem, cargo actor, console, agent Imported from the spike branch, with: - a library crate (Host API) for the CLI and the tests - JSON test scenarios for candidates - commit on close only for new or truncated files - a Nix flake: nix run -- init, nix run -- run diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..192c6c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +/.cargo-home +/world +/result diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..47263c0 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3021 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli", +] + +[[package]] +name = "agent" +version = "0.1.0" +dependencies = [ + "async-channel", + "serde", + "serde_json", + "wstd", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpp_demangle" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "cranelift-assembler-x64" +version = "0.136.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d504c8c7ba3f3accaf8e98b7096beb98e1a271404357744c7e9e730c6865ff" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.136.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c065757905596bc06e82969ebd7b92d691b7fd710a7e9e8181ae0c30dab588d0" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.136.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca7162cd4d4fc5f9f2f88d1459d939882203d93a8c5a19ef2c405b2e82ef13f" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.136.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec2ba2d4f3d959ec4023a10266c3dcfb28381d1938d507a9a5e1abb78bbcc88" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.136.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a64cb821d5fed56bdd6053acd36fa928cd82b70bf6b19b8b14b8b27d047b9a" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.17.1", + "libm", + "log", + "postcard", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "serde_derive", + "sha2", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.136.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142be1e267a7497ba3c2b5a829eaf5387b92b9bcdba6e0a6020f1ffccae7f2b8" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.136.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e41f75410da6cce8601330137d73718f679db81cb790a33de3871b5c90a0de3" + +[[package]] +name = "cranelift-control" +version = "0.136.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2616c6d01c14921ac9289ef65eb7702d9c760dfdc4537a8ee5bf38ea2832c6" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.136.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a03e8b44c4877cf2fb21e5788d514af6fd291a066ed9ae37adf114a25fecb4aa" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.136.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ff7cb014bd08752210eba55a650969f8db9b52670d69d2ea0b23e1e9e3edf" +dependencies = [ + "cranelift-codegen", + "hashbrown 0.17.1", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.136.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea6526ebf9064fff8ce3b1e26afad7027f9e6bb0e45d2b9200e4cbab8343345" + +[[package]] +name = "cranelift-native" +version = "0.136.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b44d323f3e5108a8a5bc61f3acd227285ece066e19afe9aece211d66b34a64ea" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.136.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc173168f291ff3b95b5d0769d6a05b1eaef5e6d74c2600affab8396b4e7b420" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxprof-processed-profile" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" +dependencies = [ + "bitflags", + "debugid", + "rustc-hash", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memfd" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90" +dependencies = [ + "rustix", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "object" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" +dependencies = [ + "crc32fast", + "hashbrown 0.17.1", + "indexmap", + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulley-interpreter" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def59f28ccaf2cb4f8dfaac83197fd7d56a4470a70e4f2589454c731370101c5" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1834d3b3ef994f266a0c9ed29a8214f2c2faaa9b1c44babfa57566bb05af83" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regalloc2" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.1", + "log", + "rustc-hash", + "serde", + "smallvec", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "sausage" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "clap", + "http", + "http-body-util", + "include_dir", + "serde", + "serde_json", + "tokio", + "wasmtime", + "wasmtime-wasi", + "wasmtime-wasi-http", +] + +[[package]] +name = "sausage-actor" +version = "0.1.0" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand 2.5.0", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-compose" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27dc71f96d21a8b9153da9005c603f4f20d5781b7e6d2675ea0ca5def8713b75" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "log", + "petgraph", + "smallvec", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", + "wat", +] + +[[package]] +name = "wasm-encoder" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64765e43c000fae008a48db8b288e175e237fc9ce20a26586836878c1bee89f" +dependencies = [ + "leb128fmt", + "wasmparser 0.258.3", +] + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser 0.262.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e10808eb27efde692f1b1706089fe19296728bf872291f5b4e0d50fba88f2f" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", +] + +[[package]] +name = "wasmparser" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe9269dd817e0e7021bf534ccd9761bc2d64778b6708e41228b8439d75eaa0b1" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "indexmap", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14a457ba8670a3db3d357c1aeac3ed4fe378a76054a221789f0df0ea507e292b" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.258.3", +] + +[[package]] +name = "wasmtime" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fbdd39f5c66f63ba6b5b31391b324b7ade7237d29c866cdb561330118ef436e" +dependencies = [ + "addr2line", + "async-trait", + "bitflags", + "bumpalo", + "bytes", + "cc", + "encoding_rs", + "futures", + "fxprof-processed-profile", + "gimli", + "ittapi", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rayon", + "rustix", + "semver", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "target-lexicon", + "tempfile", + "wasm-compose", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", + "wasmtime-environ", + "wasmtime-internal-cache", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wat", + "windows-sys 0.61.2", + "wit-parser", +] + +[[package]] +name = "wasmtime-environ" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e915c27826de4f7b4411b61fd6db32ca448657a5136b8c8e8874a15e90cecbf4" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.17.1", + "indexmap", + "log", + "object", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "sha2", + "smallvec", + "target-lexicon", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-cache" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "952086551d2041430a159fe4bda3471cfea3d52846b93c825829cea9dd01954d" +dependencies = [ + "base64", + "directories-next", + "log", + "postcard", + "rustix", + "serde", + "serde_derive", + "sha2", + "toml", + "wasmtime-environ", + "windows-sys 0.61.2", + "zstd", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95f531a0b809fd9fd158be6133c97bd2fc628ac47f5812aff3e3b5624eb02c2" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e754a3ff5690c34eb4713bd7c65f60dd607ead4e39042cbf52ac8011de14455b" + +[[package]] +name = "wasmtime-internal-core" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba00c5db77adb0749ee059068e5a417bfac3d8969f5a3479d9bd1ca8c1ba248d" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ceeb4325712a253f463e50a69b5a547fbbecd52e45612360c543eaa25a03ff" +dependencies = [ + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.21", + "wasmparser 0.258.3", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb3f21bc32b5382ea95f83ce9a5f2f62b68f1b25a37d0802a41a65236eae5ae" +dependencies = [ + "cc", + "libc", + "rustix", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e786f9925126e0d2e466c5308e572e8b6226f4047596228a3d63d99528a78949" +dependencies = [ + "cc", + "object", + "rustix", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92cb1dd5da78f5b21d65e91063bdd8c2eb343c1f077a1dcbc29003d4a794abb0" +dependencies = [ + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0243d244f6dd07ccd114e6bb099e8dd1197b47de5c054576adc9483f80f65d8" +dependencies = [ + "cranelift-codegen", + "log", + "object", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fa5616cd875ec78f5b92020fa47ced7c8bb923079185c8475751098b8c4a69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad5e8cef56790d4741b62f7b6c971af53b2ad4c2d1603f8ffe86872763147aa" +dependencies = [ + "anyhow", + "bitflags", + "heck", + "indexmap", + "wit-component", + "wit-parser", +] + +[[package]] +name = "wasmtime-wasi" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284555904d42017e049fd57c05810b7975075c322481c045f99b07385fb7a5ba" +dependencies = [ + "async-trait", + "bitflags", + "bytes", + "cap-primitives", + "futures", + "rand", + "rustix", + "rustix-linux-procfs", + "thiserror 2.0.21", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "wiggle", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "wasmtime-wasi-http" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9248a9ab044a9a4f414afdd14ffb38d7c58d4e2cc0f119ace9464d698f6f3bde" +dependencies = [ + "async-trait", + "bytes", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "pin-project-lite", + "rustls", + "tokio", + "tokio-rustls", + "tokio-util", + "tracing", + "wasmtime", + "wasmtime-wasi", + "wasmtime-wasi-io", + "webpki-roots", +] + +[[package]] +name = "wasmtime-wasi-io" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebaf573aae85636595bdb976b6c76e9277e1f81db067aff6764cb256e0c17dff" +dependencies = [ + "async-trait", + "bytes", + "futures", + "tracing", + "wasmtime", +] + +[[package]] +name = "wast" +version = "35.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" +dependencies = [ + "leb128", +] + +[[package]] +name = "wast" +version = "262.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.262.0", +] + +[[package]] +name = "wat" +version = "1.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1" +dependencies = [ + "wast 262.0.0", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wiggle" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad3dcdc2980b91d0e7db6975caee4f553c6e37fd8e1464a38b09baceed3fe568" +dependencies = [ + "bitflags", + "thiserror 2.0.21", + "tracing", + "wasmtime", + "wasmtime-environ", + "wiggle-macro", +] + +[[package]] +name = "wiggle-generate" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4765433ad668e225812b956e0f598011e5cdab4d1a57b2d295ab6863e36ff231" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasmtime-environ", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d94c6623099d11a3fee39986492e803dd0db6676c9d40ab9553a7edd29f317e5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "wiggle-generate", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags", +] + +[[package]] +name = "wit-component" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fca88950af0c1f42bc77e17315e48798b8d68340171b6d204f62bc19a878a6" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.258.3", + "wasm-metadata", + "wasmparser 0.258.3", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2127663dcf3ee16614fb0e3c33d5c6c66fec9dad317a8b0fdd3869487a533" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser 0.258.3", +] + +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast 35.0.2", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wstd" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b52936db10a79bb724dadd1c2c3aac958e8229dcb1f1c7f2b7044ca9fc6a3a" +dependencies = [ + "anyhow", + "async-task", + "bytes", + "futures-lite", + "http", + "http-body", + "http-body-util", + "itoa", + "pin-project-lite", + "serde", + "serde_json", + "slab", + "wasip2", + "wstd-macro", +] + +[[package]] +name = "wstd-macro" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153db9b65508bf6c2efe26617169840c03671ba5719a35868db7682a5261f7d4" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.1+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ca87358 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +resolver = "3" +members = ["cli", "agent", "sdk"] +default-members = ["cli"] +exclude = ["examples"] diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 0000000..cea509d --- /dev/null +++ b/DECISIONS.md @@ -0,0 +1,154 @@ +# Decisions + +This file lists the design decisions of the spike. **(you)** marks a decision +that Surma made. **(spike)** marks a decision that Scout made alone during the +night and that needs review. + +## Architecture + +- **(you)** Actors are WASI commands (`wasm32-wasip2`). They use stdin, stdout, + files, and HTTP. No WIT interfaces, no component composition. +- **(you)** Actors exchange newline-delimited JSON through the host, which is + the broker. No built-in request/response. A reply is a message with + `reply_to`. Delivery is at most once. +- **(you)** The broker accepts only `.wasm` files. It knows no source language. +- **(you)** `cargo` is a built-in Rust compiler actor. **(spike)** It is native + (host code), because cargo cannot run inside WASM. +- **(you)** The agent is a normal WASM actor with the fixed ID `agent`. It can + read and upgrade its own source. +- **(spike)** Three native actors: `broker`, `console`, `cargo`. +- **(you)** One Cargo workspace with two crates: `cli` (binary `sausage`) and + `agent`. **(spike)** Example actors are in `examples/`, outside the workspace. +- **(you)** `sausage init` builds the agent and asks for the API shape, the + endpoint, the model, and the token. **(spike)** The token can also come from + `SAUSAGE_API_TOKEN`. The source contains no endpoint and no token. + +## Messages + +- **(spike)** Stdin line: `{"id", "from", "reply_to"?, "body"}`. + Stdout line: `{"to"?, "id"?, "reply_to"?, "body"}`. +- **(spike)** The broker sets `from`. An actor can choose its own `id`. The + broker then uses `"/"`, so IDs cannot collide or be faked. + Without an `id`, the broker assigns `m-`. +- **(you)** Without `to`, a message goes to the subscribers of the sender. + **(spike)** `subscribe` / `unsubscribe` messages to the broker. The registry + stores subscriptions, so they survive restarts and upgrades. `init` + subscribes `console` to `agent`. +- **(spike)** Errors from routing come back as + `{"type": "undeliverable", "reason", "to"}` from `broker`. +- **(spike)** Lifecycle: `{"type": "shutdown"}` from `broker`, then EOF on stdin. + After 5 seconds the broker kills the instance. +- **(spike)** Lines on stdout that are not JSON objects with `body` go to the + actor's `stderr.log` with a note. + +## Filesystem + +- **(you)** `/data` private, `/public` own public files, + `/actor//public` and `/actor//source` read-only for everyone. +- **(spike)** The broker also writes `/actor//status.json` and + `/actor//stderr.log`. `ls /actor` is the registry. +- **(you)** Commit on close: a write becomes visible only when the writer + closes the file. **(spike)** This also applies to `/data`. Implementation: a + wrapper around the `wasmtime-wasi` filesystem. An open for write goes to a + hidden `.sausage-tmp-*` file next to the target. The descriptor drop renames + it into place. Details: + - A normal exit commits every open file (process exit closes files). + - A trap or kill discards uncommitted writes. + - An append or an edit without truncate copies the old content first. + - Other descriptors see the old content until the close. + - Directory operations take effect immediately. + - The layer hides `.sausage-tmp-*` names from listings, `stat`, `open`, + `rename`, and `unlink`. + - Symlinks are disabled (they would break the path tracking). + - No `fsync`. A power loss can lose committed data. +- **(spike)** Raw sockets are not available (no network permission), but the + `wasi:sockets` interfaces are linked, so programs that import them still + start. + +## Broker requests + +- **(spike)** `spawn`, `upgrade`, `rollback`, `stop`, `start`, `remove`, + `subscribe`, `unsubscribe`, `list`. +- **(you)** `spawn` and `upgrade` take `wasm`, plus optional `source` and + `built_by` metadata. **(spike)** The broker copies the `.wasm` file and the + source folder into `broker/versions///`, with `meta.json` (paths, + builder, requester, time). +- **(spike)** Path resolution: `/actor/...` is global, and `/public/...` and + `/data/...` belong to the sender. `..` is rejected. Native senders (the + console) can give absolute host paths. +- **(you)** Anyone can upgrade anyone. No permissions. +- **(spike)** Actor IDs: `a-z`, `0-9`, `-`, `_`, at most 64 characters. +- **(spike)** `remove` archives every folder of the actor to `archive/`. Nothing + is deleted. + +## Upgrades, tests, rollback + +- **(spike)** The tests are message transcripts in `source/tests/*.jsonl`: + `{"send": body}` and `{"expect": subset-of-body, "to"?}`. Each file runs in + a fresh candidate on a copy of the actor's `/data` and `/public`. The + candidate has no HTTP access. An `expect` waits up to 10 s. Without tests, + the candidate must stay up for 2 s (smoke test). +- **(spike)** Swap: hold the mailbox, stop the old instance gracefully, + snapshot `/data` + `/public` into the old version's folder, install the new + source in `/actor//source`, start the new instance, release the mailbox. +- **(spike)** Restart after a crash: backoff 0.5 s × 2ⁿ, at most 30 s. The + counter resets after 60 s of uptime. +- **(spike)** Parked message: when an instance traps, the broker keeps the + last delivered message. It delivers that message again after the next + upgrade, not after a plain restart (that would loop). +- **(spike)** Crash loop: 3 crashes within 30 s after an upgrade → automatic + rollback to the previous version, with its data snapshot. The broker + informs the actor that requested the upgrade. +- **(spike)** `rollback` without `version` goes to the highest older version. + It restores that version's snapshot, and it first snapshots the current + state, so a roll-forward is possible. +- **(spike)** Versions are per actor, not global. + +## Limits + +- **(spike)** Mailbox: 1,000 messages. When it is full, the sender gets + `undeliverable`. +- **(spike)** The broker waits 30 s when it writes to an actor's stdin. Then it + drops the message and logs it. +- **(spike)** Time slices: the epoch advances every 10 ms, and each guest + yields at each epoch. +- **(spike)** One `sausage run` per world (file lock on `world/.lock`). +- **(spike)** HTTP for all running actors, with no allow-list yet. + +## Agent + +- **(you)** API shapes: OpenAI Responses and Anthropic Messages, chosen by + `"api"` in `/data/config.json`. +- **(spike)** Config fields: `api`, `base_url`, `model`, `token`, optional + `reasoning_effort` and `max_tokens` (16,000). OpenAI: `POST + {base_url}/v1/responses` with `store: false` and encrypted reasoning passed + back. Anthropic: `POST {base_url}/v1/messages` with `x-api-key`. +- **(spike)** One shared conversation in `/data/history.json`, in a neutral + format, so the API shape can change. The history is limited to 600 entries, + with the cut at a user message. There is no compaction yet. +- **(spike)** A turn starts only for `{"type": "text"}` messages *without* + `reply_to`. Replies never start turns, so two actors cannot keep the agent + in an endless loop. Other messages are collected and shown at the next + turn. +- **(spike)** Tools: `send_message` (with optional wait for the reply), + `wait_for_messages`, `read_file`, `list_dir`, `write_file`, `copy_dir`, + `remove_path`. At most 40 steps per turn. Tool output is cut at 30 KB. +- **(spike)** The final text of a turn goes to the sender, as a reply to the + message that started the turn. +- **(spike)** A shutdown during a turn saves the turn to `/data/turn.json`. The + next start (for example after a self-upgrade) resumes the turn and answers + the original message. + +## Console + +- **(spike)** `@ `, plain text to `agent`, `/deploy`, + `/list`, `/sleep`, `/await`, `/help`, `/quit`. `/sleep` and `/await` exist + for scripted tests. + +## Known risks (accepted for now) + +- Build scripts and proc macros run as native code in the host environment. +- Any actor can upgrade the agent with code that reads the token. +- Any actor can message, stop, upgrade, or remove any actor. +- A full copy of `/data` and `/public` at every upgrade is slow for large data. +- Mailboxes are in memory. A host crash loses queued messages. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e164134 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# Sausage + +You can see how the sausage is made. + +Sausage is a system of actors that you grow by talking to it. Every actor is a +WASI program (a `.wasm` file) with stdin and stdout. Actors send each other +newline-delimited JSON messages through a broker. The agent is an actor too: +it does inference over HTTP, writes new actors, and can change its own code. + +Inspired by [Jiti](https://ghuntley.com/lisp/), but with WASM instead of a +Lisp image. + +## Quick start + +```sh +nix develop +cargo build --release +export SAUSAGE_API_TOKEN=... # or type it at the prompt +./target/release/sausage init world --api openai-responses \ + --base-url https://api.example.com --model some-model +./target/release/sausage run world +``` + +`init` builds the agent (the first build takes a few minutes) and writes its +configuration to `world/private/agent/data/config.json`. The source contains +no endpoint and no token. + +In the console: + +```text +hello → text to the agent +@notes {"type":"add","text":"milk"} → a JSON message to an actor +@agent what actors exist? → text to an actor +/deploy notes examples/notes → build with cargo, then spawn or upgrade +/list → list actors +/await agent 300 → wait for the next message (scripts) +/quit +``` + +## Concepts + +- **Actor**: a `wasi:cli/command` program. It reads messages on stdin and + writes messages on stdout. Logs go to stderr. +- **Message**: `{"id", "from", "reply_to"?, "body"}` on stdin; + `{"to"?, "id"?, "reply_to"?, "body"}` on stdout. No `to` = publish to + subscribers. There is no request/response: a reply is a new message with + `reply_to`. Delivery is at most once. +- **Broker**: routes messages, owns mailboxes, and runs actors. It accepts + only `.wasm` files: `spawn`, `upgrade`, `rollback`, `stop`, `start`, + `remove`, `subscribe`, `unsubscribe`, `list`. +- **cargo**: the built-in Rust compiler actor. `build` a source folder, get a + `.wasm` path back. Other actors can add other languages. +- **Filesystem** of each actor: + - `/data`: private state + - `/public`: its public files + - `/actor//{public,source,status.json,stderr.log}`: every actor, read-only + + A write becomes visible when the writer closes the file (commit on close). +- **Upgrade**: the broker tests the candidate on a copy of `/data` and + `/public` (transcripts in `source/tests/*.jsonl`). Then it holds new + messages, stops the old version, starts the new one, and delivers the held + messages. A new version that crashes 3 times in 30 seconds is rolled back. + +See [DECISIONS.md](DECISIONS.md) for every design decision and +[SPIKE.md](SPIKE.md) for the spike results. diff --git a/agent/Cargo.toml b/agent/Cargo.toml new file mode 100644 index 0000000..a720c9c --- /dev/null +++ b/agent/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "agent" +version = "0.1.0" +edition = "2024" +description = "The Sausage agent actor." + +[dependencies] +async-channel = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +wstd = "0.6" diff --git a/agent/src/llm.rs b/agent/src/llm.rs new file mode 100644 index 0000000..53d80d2 --- /dev/null +++ b/agent/src/llm.rs @@ -0,0 +1,200 @@ +//! Inference through two API shapes: OpenAI Responses and Anthropic Messages. +//! +//! The conversation is stored in a neutral format, so the agent can switch +//! between the two shapes. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use wstd::http::{Body, Client, Request}; +use wstd::time::Duration; + +#[derive(Deserialize, Clone)] +pub struct Config { + /// `openai-responses` or `anthropic-messages`. + pub api: String, + pub base_url: String, + pub model: String, + pub token: String, + /// Optional reasoning effort for OpenAI models ("low", "medium", ...). + #[serde(default)] + pub reasoning_effort: Option, + #[serde(default = "default_max_tokens")] + pub max_tokens: u32, +} + +fn default_max_tokens() -> u32 { + 16000 +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Entry { + User { text: String }, + Assistant { text: String }, + ToolCall { id: String, name: String, arguments: String }, + ToolResult { id: String, output: String }, + /// A provider item that only this API shape understands (OpenAI reasoning). + Opaque { api: String, item: Value }, +} + +pub struct Tool { + pub name: &'static str, + pub description: &'static str, + pub parameters: Value, +} + +pub async fn complete(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result, String> { + match cfg.api.as_str() { + "openai-responses" => openai(cfg, system, tools, history).await, + "anthropic-messages" => anthropic(cfg, system, tools, history).await, + other => Err(format!("unknown API shape {other:?}")), + } +} + +async fn post(url: &str, headers: &[(&str, String)], body: &Value) -> Result { + 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 mut client = Client::new(); + client.set_first_byte_timeout(Duration::from_secs(600)); + client.set_between_bytes_timeout(Duration::from_secs(600)); + let resp = client.send(req).await.map_err(|e| format!("HTTP request failed: {e:?}"))?; + let 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() { + let mut t = text; + t.truncate(2000); + return Err(format!("HTTP {status}: {t}")); + } + serde_json::from_str(&text).map_err(|e| format!("invalid JSON in response: {e}")) +} + +async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result, 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 } => input.push(json!({"role": "assistant", "content": text})), + 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, item } if api == "openai-responses" => input.push(item.clone()), + Entry::Opaque { .. } => {} + } + } + let tools: Vec = 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"], + "max_output_tokens": cfg.max_tokens, + }); + if let Some(effort) = &cfg.reasoning_effort { + body["reasoning"] = json!({"effort": effort}); + } + let url = format!("{}/v1/responses", cfg.base_url); + let resp = post(&url, &[("authorization", format!("Bearer {}", cfg.token))], &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(); + if !text.is_empty() { + out.push(Entry::Assistant { text }); + } + } + 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(), item }), + _ => {} + } + } + if resp["status"] == "incomplete" && out.is_empty() { + return Err(format!("incomplete response: {}", resp["incomplete_details"])); + } + Ok(out) +} + +async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result, String> { + // Merge consecutive blocks of the same role into one message. + let mut messages: Vec<(String, Vec)> = 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})) + } + Entry::Opaque { .. } => {} + } + } + let messages: Vec = messages.into_iter().map(|(role, content)| json!({"role": role, "content": content})).collect(); + let tools: Vec = tools + .iter() + .map(|t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters})) + .collect(); + let body = json!({ + "model": cfg.model, + "system": system, + "messages": messages, + "tools": tools, + "max_tokens": cfg.max_tokens, + }); + let url = format!("{}/v1/messages", cfg.base_url); + let resp = post( + &url, + &[("x-api-key", cfg.token.clone()), ("anthropic-version", "2023-06-01".to_string())], + &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 }); + } + } + 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(), + }), + _ => {} + } + } + Ok(out) +} diff --git a/agent/src/main.rs b/agent/src/main.rs new file mode 100644 index 0000000..0b0a333 --- /dev/null +++ b/agent/src/main.rs @@ -0,0 +1,520 @@ +//! The Sausage agent: a normal WASM actor that does inference over HTTP and +//! grows the world through messages to `cargo` and `broker`. +//! +//! Configuration: `/data/config.json` (see `llm::Config`). +//! Conversation: `/data/history.json` (one shared conversation). + +mod llm; + +use std::collections::VecDeque; + +use async_channel::{Receiver, Sender}; +use llm::{Config, Entry, Tool}; +use serde_json::{Value, json}; +use wstd::future::FutureExt; +use wstd::io::{AsyncRead, AsyncWrite}; +use wstd::time::{Duration, Instant}; + +const SYSTEM: &str = include_str!("prompt.md"); +const CONFIG: &str = "/data/config.json"; +const HISTORY: &str = "/data/history.json"; +/// An interrupted turn (sender and message ID), resumed at the next start. +const TURN: &str = "/data/turn.json"; +const MAX_STEPS: usize = 40; +const MAX_HISTORY: usize = 600; +const MAX_TOOL_OUTPUT: usize = 30_000; +const MAX_NOTES: usize = 50; + +fn main() { + wstd::runtime::block_on(async { + let (tx, rx) = async_channel::unbounded(); + wstd::runtime::spawn(read_stdin(tx)).detach(); + Agent::new(rx).run().await; + }); +} + +/// Read NDJSON messages from stdin into the channel. +async fn read_stdin(tx: Sender) { + let mut stdin = wstd::io::stdin(); + let mut buf = Vec::new(); + let mut chunk = vec![0u8; 16 * 1024]; + loop { + match stdin.read(&mut chunk).await { + Ok(0) | Err(_) => break, + Ok(n) => { + buf.extend_from_slice(&chunk[..n]); + while let Some(pos) = buf.iter().position(|b| *b == b'\n') { + let line: Vec = buf.drain(..=pos).collect(); + if let Ok(v) = serde_json::from_slice::(&line) + && tx.send(v).await.is_err() + { + return; + } + } + } + } + } + tx.close(); +} + +fn truncate(mut s: String, max: usize) -> String { + if s.len() > max { + let mut end = max; + while !s.is_char_boundary(end) { + end -= 1; + } + let total = s.len(); + s.truncate(end); + s.push_str(&format!("\n… (truncated, {total} bytes in total)")); + } + s +} + +fn tools() -> Vec { + vec![ + Tool { + name: "send_message", + description: "Send a message to an actor. With wait_seconds > 0, wait for the reply (a message whose reply_to is the ID of this message). Returns the message ID, the reply (or null), and other messages that arrived meanwhile.", + parameters: json!({ + "type": "object", + "properties": { + "to": {"type": "string", "description": "Actor ID"}, + "body": {"type": "object", "description": "Message body. Must have a \"type\" field."}, + "wait_seconds": {"type": "number", "description": "Seconds to wait for the reply. 0 = do not wait."} + }, + "required": ["to", "body"] + }), + }, + Tool { + name: "wait_for_messages", + description: "Wait up to `seconds` for messages to arrive and return them.", + parameters: json!({ + "type": "object", + "properties": {"seconds": {"type": "number"}}, + "required": ["seconds"] + }), + }, + Tool { + name: "read_file", + description: "Read a text file. Paths: /data/..., /public/..., /actor//...", + parameters: json!({ + "type": "object", + "properties": { + "path": {"type": "string"}, + "offset": {"type": "integer", "description": "Byte offset (default 0)"}, + "max_bytes": {"type": "integer", "description": "Default 30000"} + }, + "required": ["path"] + }), + }, + Tool { + name: "list_dir", + description: "List a folder.", + parameters: json!({ + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"] + }), + }, + Tool { + name: "write_file", + description: "Write a text file (only under /data or /public). Creates parent folders.", + parameters: json!({ + "type": "object", + "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, + "required": ["path", "content"] + }), + }, + Tool { + name: "copy_dir", + description: "Copy a folder tree, for example /actor//source to /public/drafts/. Skips target/ folders.", + parameters: json!({ + "type": "object", + "properties": {"from": {"type": "string"}, "to": {"type": "string"}}, + "required": ["from", "to"] + }), + }, + Tool { + name: "remove_path", + description: "Remove a file or folder under /data or /public.", + parameters: json!({ + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"] + }), + }, + ] +} + +fn copy_tree(from: &str, to: &str) -> std::io::Result { + std::fs::create_dir_all(to)?; + let mut n = 0; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + let src = format!("{from}/{name}"); + let dst = format!("{to}/{name}"); + if entry.file_type()?.is_dir() { + if name != "target" { + n += copy_tree(&src, &dst)?; + } + } else { + std::fs::copy(&src, &dst)?; + n += 1; + } + } + Ok(n) +} + +struct Agent { + rx: Receiver, + stdout: wstd::io::Stdout, + history: Vec, + /// Text messages that start a new turn later. + queue: VecDeque, + /// Other messages that arrived while the agent was idle. + notes: Vec, + seq: u64, + shutdown: bool, +} + +impl Agent { + fn new(rx: Receiver) -> Self { + let history = std::fs::read_to_string(HISTORY) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + Self { + rx, + stdout: wstd::io::stdout(), + history, + queue: VecDeque::new(), + notes: Vec::new(), + seq: 0, + shutdown: false, + } + } + + async fn run(mut self) { + eprintln!("agent: started with {} history entries", self.history.len()); + self.resume().await; + while !self.shutdown { + let msg = match self.queue.pop_front() { + Some(m) => m, + None => match self.rx.recv().await { + Ok(m) => m, + Err(_) => break, + }, + }; + if msg["body"]["type"] == "shutdown" { + break; + } + if starts_turn(&msg) { + self.turn(msg).await; + } else { + self.notes.push(msg); + if self.notes.len() > MAX_NOTES { + self.notes.remove(0); + } + } + } + self.save(); + eprintln!("agent: stopped"); + } + + /// Continue a turn that a restart (usually an upgrade of the agent itself) + /// interrupted. + async fn resume(&mut self) { + let Some(turn) = std::fs::read_to_string(TURN).ok().and_then(|s| serde_json::from_str::(&s).ok()) else { + return; + }; + let _ = std::fs::remove_file(TURN); + let Ok(cfg) = std::fs::read_to_string(CONFIG).map_err(|_| ()).and_then(|s| serde_json::from_str::(&s).map_err(|_| ())) else { + return; + }; + // Every tool call needs a result, or the APIs reject the history. + let answered: std::collections::HashSet = self + .history + .iter() + .filter_map(|e| match e { + Entry::ToolResult { id, .. } => Some(id.clone()), + _ => None, + }) + .collect(); + let dangling: Vec = self + .history + .iter() + .filter_map(|e| match e { + Entry::ToolCall { id, .. } if !answered.contains(id) => Some(id.clone()), + _ => None, + }) + .collect(); + for id in dangling { + self.history.push(Entry::ToolResult { + id, + output: json!({"error": "interrupted: the agent restarted before this tool finished"}).to_string(), + }); + } + let version = std::env::var("SAUSAGE_VERSION").unwrap_or_default(); + self.history.push(Entry::User { + text: format!( + "[note from your runtime] You restarted and now run version {version}. The restart interrupted your last turn (probably your own upgrade). Check the state you need, then finish the turn and answer the original message." + ), + }); + eprintln!("agent: resuming an interrupted turn"); + self.steps(&cfg, turn["from"].clone(), turn["id"].clone()).await; + } + + fn save(&mut self) { + if self.history.len() > MAX_HISTORY { + let start = self.history.len() - MAX_HISTORY; + let cut = (start..self.history.len()) + .find(|i| matches!(self.history[*i], Entry::User { .. })) + .unwrap_or(start); + self.history.drain(..cut); + } + if let Ok(s) = serde_json::to_string(&self.history) + && let Err(e) = std::fs::write(HISTORY, s) + { + eprintln!("agent: cannot save history: {e}"); + } + } + + async fn emit(&mut self, msg: Value) { + let mut line = msg.to_string(); + line.push('\n'); + if self.stdout.write_all(line.as_bytes()).await.is_err() || self.stdout.flush().await.is_err() { + eprintln!("agent: cannot write to stdout"); + } + } + + async fn reply(&mut self, to: &Value, reply_to: &Value, text: String) { + self.emit(json!({"to": to, "reply_to": reply_to, "body": {"type": "text", "text": text}})).await; + } + + /// Sort a message that arrived during a turn. + fn stash(&mut self, m: Value, others: &mut Vec) { + if m["body"]["type"] == "shutdown" { + self.shutdown = true; + } else if starts_turn(&m) { + others.push(json!({"queued_for_a_later_turn": true, "message": m})); + self.queue.push_back(m); + } else { + others.push(m); + } + } + + async fn turn(&mut self, msg: Value) { + let from = msg["from"].clone(); + let id = msg["id"].clone(); + let cfg: Config = match std::fs::read_to_string(CONFIG).map_err(|e| e.to_string()).and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string())) { + Ok(c) => c, + Err(e) => { + self.reply(&from, &id, format!("I am not configured: cannot read {CONFIG}: {e}")).await; + return; + } + }; + let mut text = format!( + "Message {} from {}:\n{}", + id.as_str().unwrap_or("?"), + from.as_str().unwrap_or("?"), + msg["body"]["text"].as_str().unwrap_or("") + ); + if !self.notes.is_empty() { + let notes: Vec = self.notes.drain(..).map(|n| n.to_string()).collect(); + text = format!("Messages that arrived while you were idle:\n{}\n\n{text}", notes.join("\n")); + } + self.history.push(Entry::User { text }); + self.steps(&cfg, from, id).await; + } + + /// Run inference and tools until the model answers. If a shutdown + /// interrupts the turn, remember it in TURN so the next start resumes it. + async fn steps(&mut self, cfg: &Config, from: Value, id: Value) { + let tools = tools(); + let cfg = cfg.clone(); + for _ in 0..MAX_STEPS { + let started = Instant::now(); + let result = llm::complete(&cfg, SYSTEM, &tools, &self.history).await; + let entries = match result { + Ok(e) => e, + Err(e) => { + eprintln!("agent: inference failed: {e}"); + self.history.push(Entry::Assistant { text: format!("(inference failed: {e})") }); + self.save(); + self.reply(&from, &id, format!("Inference failed: {e}")).await; + return; + } + }; + eprintln!("agent: inference took {:?}", started.elapsed()); + let calls: Vec<(String, String, String)> = entries + .iter() + .filter_map(|e| match e { + Entry::ToolCall { id, name, arguments } => Some((id.clone(), name.clone(), arguments.clone())), + _ => None, + }) + .collect(); + let answer: Vec = entries + .iter() + .filter_map(|e| match e { + Entry::Assistant { text } => Some(text.clone()), + _ => None, + }) + .collect(); + self.history.extend(entries); + if calls.is_empty() { + self.save(); + let answer = if answer.is_empty() { "(no answer)".to_string() } else { answer.join("\n\n") }; + self.reply(&from, &id, answer).await; + return; + } + for (call_id, name, args) in calls { + let output = self.tool(&name, &args).await; + self.history.push(Entry::ToolResult { id: call_id, output: truncate(output.to_string(), MAX_TOOL_OUTPUT) }); + } + self.save(); + if self.shutdown { + let _ = std::fs::write(TURN, json!({"from": from, "id": id}).to_string()); + eprintln!("agent: turn interrupted by shutdown; it resumes after the restart"); + return; + } + } + self.reply(&from, &id, format!("I stopped after {MAX_STEPS} steps. Tell me to continue if you want.")).await; + } + + async fn tool(&mut self, name: &str, args: &str) -> Value { + let a: Value = match serde_json::from_str(args) { + Ok(v) => v, + Err(e) => return json!({"error": format!("arguments are not valid JSON: {e}")}), + }; + let path = |k: &str| a[k].as_str().unwrap_or_default().to_string(); + let err = |e: std::io::Error| json!({"error": e.to_string()}); + match name { + "send_message" => self.send_message(&a).await, + "wait_for_messages" => { + let secs = a["seconds"].as_f64().unwrap_or(10.0).clamp(0.0, 900.0); + let mut others = Vec::new(); + match self.rx.recv().timeout(Duration::from_secs_f64(secs)).await { + Ok(Ok(m)) => { + self.stash(m, &mut others); + while let Ok(m) = self.rx.try_recv() { + self.stash(m, &mut others); + } + } + Ok(Err(_)) => self.shutdown = true, + Err(_) => {} + } + json!({"messages": others}) + } + "read_file" => { + let p = path("path"); + match std::fs::read(&p) { + Ok(bytes) => { + let offset = (a["offset"].as_u64().unwrap_or(0) as usize).min(bytes.len()); + let max = a["max_bytes"].as_u64().unwrap_or(30_000) as usize; + let end = (offset + max).min(bytes.len()); + json!({ + "path": p, "size": bytes.len(), "offset": offset, + "content": String::from_utf8_lossy(&bytes[offset..end]), + "truncated": end < bytes.len(), + }) + } + Err(e) => err(e), + } + } + "list_dir" => match std::fs::read_dir(path("path")) { + Ok(rd) => { + let mut entries: Vec = rd + .filter_map(|e| e.ok()) + .take(500) + .map(|e| { + let meta = e.metadata().ok(); + json!({ + "name": e.file_name().to_string_lossy(), + "type": if meta.as_ref().is_some_and(|m| m.is_dir()) { "dir" } else { "file" }, + "size": meta.map(|m| m.len()), + }) + }) + .collect(); + entries.sort_by(|x, y| x["name"].as_str().cmp(&y["name"].as_str())); + json!({"entries": entries}) + } + Err(e) => err(e), + }, + "write_file" => { + let p = path("path"); + if let Some(parent) = std::path::Path::new(&p).parent() { + let _ = std::fs::create_dir_all(parent); + } + match std::fs::write(&p, a["content"].as_str().unwrap_or_default()) { + Ok(()) => json!({"written": p}), + Err(e) => err(e), + } + } + "copy_dir" => match copy_tree(&path("from"), &path("to")) { + Ok(n) => json!({"copied_files": n}), + Err(e) => err(e), + }, + "remove_path" => { + let p = path("path"); + let r = if std::path::Path::new(&p).is_dir() { std::fs::remove_dir_all(&p) } else { std::fs::remove_file(&p) }; + match r { + Ok(()) => json!({"removed": p}), + Err(e) => err(e), + } + } + other => json!({"error": format!("unknown tool {other}")}), + } + } + + async fn send_message(&mut self, a: &Value) -> Value { + let Some(to) = a["to"].as_str() else { return json!({"error": "missing \"to\""}) }; + if !a["body"].is_object() { + return json!({"error": "\"body\" must be a JSON object"}); + } + self.seq += 1; + let own = format!("a{}-{}", wstd_now_ms(), self.seq); + let full = format!("agent/{own}"); + self.emit(json!({"to": to, "id": own, "body": a["body"]})).await; + let wait = a["wait_seconds"].as_f64().unwrap_or(0.0).clamp(0.0, 900.0); + let mut reply = Value::Null; + let mut others = Vec::new(); + if wait > 0.0 { + let deadline = Instant::now() + Duration::from_secs_f64(wait); + loop { + match self.rx.recv().timeout(deadline).await { + Ok(Ok(m)) => { + if m["reply_to"] == full.as_str() { + reply = m; + break; + } + self.stash(m, &mut others); + if self.shutdown { + break; + } + } + Ok(Err(_)) => { + // stdin closed: the broker stops us. + self.shutdown = true; + break; + } + Err(_) => break, + } + } + } + json!({"id": full, "reply": reply, "waited": wait > 0.0, "other_messages": others}) + } +} + +/// A text message that is not a reply starts a turn. Replies never do, so +/// two actors cannot keep the agent in an endless conversation. +fn starts_turn(m: &Value) -> bool { + m["body"]["type"] == "text" && m.get("reply_to").is_none_or(Value::is_null) +} + +fn wstd_now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} diff --git a/agent/src/prompt.md b/agent/src/prompt.md new file mode 100644 index 0000000..b934235 --- /dev/null +++ b/agent/src/prompt.md @@ -0,0 +1,80 @@ +You are the agent of a Sausage world. Your actor ID is `agent`. + +Sausage is a system of actors. Each actor is a WASI program (a `.wasm` file). Actors talk only through messages. People talk to you through other actors, for example `console` (a terminal) or later `matrix`. You help them by using actors, and by writing, testing, and upgrading actors. You can also change your own code. + +# Messages + +Every message has a sender (`from`), an ID, an optional `reply_to` (the ID of the message it answers), and a JSON `body`. A body is an object with a `"type"` field. There is no request/response: a reply is a new message with `reply_to` set. Delivery is at most once. + +A WASM actor reads one JSON object per line on stdin: +`{"id":"m-17","from":"console","body":{...}}` +and writes one JSON object per line on stdout: +`{"to":"console","reply_to":"m-17","body":{...}}` +Without `"to"`, the broker publishes the message to the subscribers of the sender. An actor may set its own `"id"`. The broker then uses `"/"`. stdout is only for messages. Logs go to stderr. The broker sends `{"type":"shutdown"}` before it stops an actor. EOF on stdin means "stop now". + +# Filesystem + +Each actor sees: +- `/data`: private state, read-write. Only the actor itself sees it. +- `/public`: its public files, read-write. +- `/actor//public`: the public files of every actor, read-only. +- `/actor//source`: the source of the running version of every actor, read-only. +- `/actor//status.json` and `/actor//stderr.log`: state and stderr log of every actor. + +`ls /actor` lists all actors. A write becomes visible to other actors only when the writer closes the file. Send large or binary data as a file path, not in a message. + +# Built-in actors + +`broker` (only accepts `.wasm` files, knows no source language): +- `{"type":"spawn","actor":"","wasm":"","source":"","built_by":"cargo"}` +- `{"type":"upgrade","actor":"","wasm":"","source":"","built_by":"cargo"}` +- `{"type":"rollback","actor":""}` (optional `"version"`) +- `{"type":"stop","actor":""}`, `{"type":"start","actor":""}`, `{"type":"remove","actor":""}` (archives, never deletes) +- `{"type":"subscribe","actor":""}`, `{"type":"unsubscribe","actor":""}`, `{"type":"list"}` + +Before an upgrade or spawn, the broker tests the candidate on a copy of the actor's `/data` and `/public`, with no HTTP access. The tests are message transcripts in `/tests/*.jsonl`. Each line is `{"send": }` or `{"expect": }` (optional `"to"`). Without test files, the candidate must start and stay up for 2 seconds. If a new version crashes 3 times within 30 seconds, the broker rolls it back automatically. The broker keeps the message that crashed an actor and delivers it again after the next upgrade. + +`cargo` (built-in Rust compiler): +- `{"type":"build","source":""}` → `{"type":"built","wasm":"/actor/cargo/public/builds//.wasm",...}` or `{"type":"build_failed","errors":"..."}`. A build can take a minute. Wait up to 600 seconds for the reply. + +# Writing an actor in Rust + +1. Write the crate to `/public/drafts//`: `Cargo.toml`, `src/main.rs`, and `tests/*.jsonl`. Use `edition = "2024"`. Use `serde_json` for messages. +2. Send `build` to `cargo` with the folder path `/actor/agent/public/drafts/` and wait for the reply. Fix compiler errors and build again. +3. Send `spawn` (new actor) or `upgrade` (existing actor) to `broker` with the `wasm` path from the build reply and the same `source` folder. Wait for the reply. +4. Send a message to the new actor to check it. + +A simple actor that only reacts to messages can block on stdin: + +```rust +use serde_json::{json, Value}; +use std::io::{BufRead, Write}; + +fn main() { + let mut out = std::io::stdout().lock(); + for line in std::io::stdin().lock().lines() { + let Ok(line) = line else { break }; + let Ok(msg) = serde_json::from_str::(&line) else { continue }; + let body = &msg["body"]; + let reply = match body["type"].as_str() { + Some("shutdown") => break, + Some("ping") => json!({"type": "pong"}), + _ => json!({"type": "error", "reason": "unknown message"}), + }; + writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap(); + out.flush().unwrap(); + } +} +``` + +An actor that needs HTTP or timers and must also read messages uses the async `wstd` crate (`wstd = "0.6"`): `#[wstd::main] async fn main()`, `wstd::io::stdin()` (`AsyncRead`), `wstd::io::stdout()` (`AsyncWrite`, `write_all`, `flush`), `wstd::http::{Client, Request, Body}` (`Client::new().send(Request::get(url).body(Body::empty())?).await?`, then `response.into_body().contents().await?`), `wstd::task::sleep(wstd::time::Duration::from_secs(n)).await`, and `wstd::runtime::spawn(future)`. Actors have outgoing HTTP and HTTPS only, no raw sockets. Threads are not available. Store state in `/data` so it survives restarts and upgrades. A new version must read the data format of the old version. + +# Changing yourself + +Your own source is at `/actor/agent/source`. To change yourself, copy it to `/public/drafts/agent`, edit the copy, build it, and upgrade `agent`. The upgrade restarts you, and the conversation continues from `/data`. Be careful: if the new version cannot answer, the person must roll you back through the console. Change yourself only when the person asks for it. + +# How to work + +- Use tools to look at the real state before you claim anything. Read files instead of guessing. +- Keep answers short and concrete. Say what you changed and whether it worked. +- Your final text in a turn goes back to the person who wrote to you. diff --git a/agent/tests/answers.json b/agent/tests/answers.json new file mode 100644 index 0000000..9c2e85f --- /dev/null +++ b/agent/tests/answers.json @@ -0,0 +1,6 @@ +{ + "description": "the agent answers text with text, even without config or network", + "steps": [ + {"send": {"type": "text", "text": "Hello"}, "expect": {"type": "text"}} + ] +} diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..6b24ed7 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "sausage" +version = "0.1.0" +edition = "2024" +description = "Grow a system of WASI actors by talking to it." + +[[bin]] +name = "sausage" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +bytes = "1" +clap = { version = "4", features = ["derive"] } +http = "1" +http-body-util = "0.1" +include_dir = "0.7" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +wasmtime = "49" +wasmtime-wasi = "49" +wasmtime-wasi-http = "49" diff --git a/cli/src/broker.rs b/cli/src/broker.rs new file mode 100644 index 0000000..f185e68 --- /dev/null +++ b/cli/src/broker.rs @@ -0,0 +1,856 @@ +//! The broker: message routing, mailboxes, subscriptions, and the lifecycle of +//! WASM actors (spawn, upgrade, rollback, stop, start, remove). +//! +//! The broker accepts only `.wasm` files. It does not know any source language. + +use std::collections::{BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde_json::{Value, json}; +use tokio::sync::{mpsc, oneshot}; +use wasmtime::component::Component; + +use crate::candidate; +use crate::fs::Mount; +use crate::runtime::{Exit, Instance, InstanceConfig, Runtime}; +use crate::world::{self, ActorRecord, NATIVE, Registry, VersionMeta, World}; + +pub const MAILBOX_SIZE: usize = 1000; +pub const SHUTDOWN_DEADLINE: Duration = Duration::from_secs(5); +/// A new version that crashes this often within `CRASH_WINDOW` after a swap +/// is rolled back automatically. +const CRASH_LIMIT: usize = 3; +const CRASH_WINDOW: Duration = Duration::from_secs(30); +const STDIN_WRITE_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone, Debug)] +pub struct Envelope { + pub id: String, + pub from: String, + pub to: String, + pub reply_to: Option, + pub body: Value, +} + +impl Envelope { + /// The NDJSON line that the recipient reads on stdin. + pub fn to_line(&self) -> String { + let mut v = json!({"id": self.id, "from": self.from, "body": self.body}); + if let Some(r) = &self.reply_to { + v["reply_to"] = json!(r); + } + v.to_string() + } +} + +pub fn shutdown_line() -> String { + json!({"id": "broker/shutdown", "from": "broker", "body": {"type": "shutdown"}}).to_string() +} + +enum Control { + Upgrade { version: u64, requester: String, reply: oneshot::Sender> }, + Rollback { target: Option, reply: oneshot::Sender> }, + Stop { reply: oneshot::Sender> }, + Start { reply: oneshot::Sender> }, + Remove { reply: oneshot::Sender> }, + Shutdown { reply: oneshot::Sender> }, +} + +pub struct Broker { + pub world: World, + pub runtime: Arc, + mailboxes: Mutex>>, + controls: Mutex>>, + registry: Mutex, + op_locks: Mutex>>>, + seq: AtomicU64, +} + +impl Broker { + pub fn new(world: World, runtime: Arc) -> Result> { + let registry = world.load_registry()?; + Ok(Arc::new(Self { + world, + runtime, + mailboxes: Mutex::new(HashMap::new()), + controls: Mutex::new(HashMap::new()), + registry: Mutex::new(registry), + op_locks: Mutex::new(HashMap::new()), + seq: AtomicU64::new(1), + })) + } + + pub fn registry(&self) -> Registry { + self.registry.lock().unwrap().clone() + } + + fn update_registry(&self, f: impl FnOnce(&mut Registry)) -> Result<()> { + let mut reg = self.registry.lock().unwrap(); + f(&mut reg); + self.world.save_registry(®) + } + + /// Create the mailbox of an actor. Messages wait here while the actor is + /// down, during a swap, or while it restarts. + pub fn open_mailbox(&self, id: &str) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(MAILBOX_SIZE); + self.mailboxes.lock().unwrap().insert(id.to_string(), tx); + rx + } + + fn next_id(&self) -> String { + format!("m-{}", self.seq.fetch_add(1, Ordering::Relaxed)) + } + + /// Send a message from a native actor. `to: None` publishes to the + /// subscribers of `from`. Returns the message ID. + pub fn send(&self, from: &str, to: Option<&str>, id: Option<&str>, reply_to: Option<&str>, body: Value) -> String { + let id = match id { + Some(own) => format!("{from}/{own}"), + None => self.next_id(), + }; + let recipients: Vec = match to { + Some(to) => vec![to.to_string()], + None => self + .registry + .lock() + .unwrap() + .subscriptions + .get(from) + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default(), + }; + for to in recipients { + self.deliver(Envelope { + id: id.clone(), + from: from.to_string(), + to, + reply_to: reply_to.map(str::to_string), + body: body.clone(), + }); + } + id + } + + /// Route one stdout line of a WASM actor. + pub fn route_line(&self, from: &str, line: &str) { + let line = line.trim(); + if line.is_empty() { + return; + } + let parsed: Option = serde_json::from_str(line).ok(); + let Some(Value::Object(obj)) = parsed else { + self.log(from, &format!("stdout line is not a JSON object, ignored: {}", truncate(line, 500))); + return; + }; + let Some(body) = obj.get("body").cloned() else { + self.log(from, &format!("message without \"body\", ignored: {}", truncate(line, 500))); + return; + }; + let to = obj.get("to").and_then(Value::as_str); + let own_id = obj.get("id").and_then(|v| match v { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + _ => None, + }); + let reply_to = obj.get("reply_to").and_then(Value::as_str); + self.send(from, to, own_id.as_deref(), reply_to, body); + } + + fn deliver(&self, env: Envelope) { + let tx = self.mailboxes.lock().unwrap().get(&env.to).cloned(); + let error = match tx { + None => Some(format!("no actor with ID {}", env.to)), + Some(tx) => match tx.try_send(env.clone()) { + Ok(()) => None, + Err(mpsc::error::TrySendError::Full(_)) => Some(format!("mailbox of {} is full", env.to)), + Err(mpsc::error::TrySendError::Closed(_)) => Some(format!("actor {} is gone", env.to)), + }, + }; + if let Some(reason) = error + && env.from != "broker" + && env.from != "test" + { + self.send( + "broker", + Some(&env.from), + None, + Some(&env.id), + json!({"type": "undeliverable", "reason": reason, "to": env.to}), + ); + } + } + + /// Append a host note to the stderr log of an actor. + pub fn log(&self, id: &str, text: &str) { + use std::io::Write; + let path = self.world.actor_dir(id).join("stderr.log"); + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(f, "[sausage {}] {text}", world::now()); + } + } + + fn op_lock(&self, id: &str) -> Arc> { + self.op_locks.lock().unwrap().entry(id.to_string()).or_default().clone() + } + + /// Mounts of a running actor. + pub fn mounts(&self, id: &str) -> Vec { + vec![ + Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: false }, + Mount { guest: "/public".into(), host: self.world.public_dir(id), writable: true }, + Mount { guest: "/data".into(), host: self.world.data_dir(id), writable: true }, + ] + } + + // --------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------- + + /// Start the supervisors of all registered WASM actors. + pub fn start_all(self: &Arc) { + let reg = self.registry(); + for (id, rec) in reg.actors { + self.start_supervisor(&id, rec.version, rec.running); + } + } + + fn start_supervisor(self: &Arc, id: &str, version: u64, running: bool) { + let mailbox = self.open_mailbox(id); + let (ctl_tx, ctl_rx) = mpsc::channel(16); + self.controls.lock().unwrap().insert(id.to_string(), ctl_tx); + let sv = Supervisor::new(self.clone(), id.to_string(), version, running, mailbox, ctl_rx); + tokio::spawn(sv.run()); + } + + async fn control(&self, id: &str, make: impl FnOnce(oneshot::Sender>) -> Control) -> Result { + let tx = self.controls.lock().unwrap().get(id).cloned().ok_or_else(|| anyhow!("no WASM actor with ID {id}"))?; + let (reply, rx) = oneshot::channel(); + tx.send(make(reply)).await.map_err(|_| anyhow!("supervisor of {id} is gone"))?; + rx.await.map_err(|_| anyhow!("supervisor of {id} did not answer"))? + } + + /// Stop every actor gracefully (host shutdown). The registry keeps their + /// `running` flags, so the next `sausage run` starts them again. + pub async fn shutdown_all(&self) { + let ids: Vec = self.controls.lock().unwrap().keys().cloned().collect(); + let mut waits = Vec::new(); + for id in ids { + waits.push(async move { + let _ = self.control(&id, |reply| Control::Shutdown { reply }).await; + }); + } + futures_join_all(waits).await; + } + + /// Store a new version: copy the `.wasm` file and the source folder into + /// the version store. Returns the version directory and the component. + fn store_version(&self, id: &str, version: u64, wasm: &Path, source: Option<&Path>, meta: &VersionMeta) -> Result<(PathBuf, Component)> { + let dir = self.world.version_dir(id, version); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir)?; + std::fs::copy(wasm, dir.join("actor.wasm")).with_context(|| format!("copy {}", wasm.display()))?; + match source { + Some(src) => world::copy_tree(src, &dir.join("source"))?, + None => std::fs::create_dir_all(dir.join("source"))?, + } + world::write_atomic(&dir.join("meta.json"), serde_json::to_string_pretty(meta)?.as_bytes())?; + let component = self.runtime.load(&dir.join("actor.wasm"))?; + Ok((dir, component)) + } + + /// Create a new actor from a `.wasm` file. With `start`, also start its + /// supervisor (in a running host). Without `start` (during `init`), only + /// register it. + pub async fn create_actor( + self: &Arc, + id: &str, + wasm: &Path, + source: Option<&Path>, + meta: VersionMeta, + start: bool, + ) -> Result { + if !world::valid_id(id) { + bail!("invalid actor ID {id:?}: use a-z, 0-9, '-' and '_'"); + } + if NATIVE.contains(&id) || self.registry().actors.contains_key(id) { + bail!("an actor with ID {id} already exists"); + } + let lock = self.op_lock(id); + let _guard = lock.lock().await; + let (dir, component) = self.store_version(id, 1, wasm, source, &meta)?; + let report = candidate::test(self, id, &component, &dir.join("source"), None, None).await; + let report = match report { + Ok(r) => r, + Err(e) => { + let _ = std::fs::remove_dir_all(self.world.versions_dir(id)); + return Err(e); + } + }; + self.world.ensure_actor_dirs(id)?; + world::replace_tree(&dir.join("source"), &self.world.source_dir(id))?; + self.update_registry(|r| { + r.actors.insert(id.to_string(), ActorRecord { version: 1, next_version: 2, running: true }); + })?; + write_status(&self.world, id, &json!({"id": id, "version": 1, "state": "created"})); + if start { + self.start_supervisor(id, 1, true); + } + Ok(json!({"type": "spawned", "actor": id, "version": 1, "tests": report})) + } + + pub async fn upgrade_actor(self: &Arc, id: &str, wasm: &Path, source: Option<&Path>, mut meta: VersionMeta) -> Result { + let lock = self.op_lock(id); + let _guard = lock.lock().await; + let rec = self.registry().actors.get(id).cloned().ok_or_else(|| anyhow!("no WASM actor with ID {id}"))?; + let version = rec.next_version; + self.update_registry(|r| { + if let Some(a) = r.actors.get_mut(id) { + a.next_version = version + 1; + } + })?; + meta.version = version; + let (dir, component) = self.store_version(id, version, wasm, source, &meta)?; + let report = candidate::test( + self, + id, + &component, + &dir.join("source"), + Some(&self.world.data_dir(id)), + Some(&self.world.public_dir(id)), + ) + .await; + if let Err(e) = report { + let _ = std::fs::remove_dir_all(&dir); + return Err(e); + } + let mut result = self + .control(id, |reply| Control::Upgrade { version, requester: meta.requested_by.clone(), reply }) + .await?; + result["tests"] = report.unwrap(); + Ok(result) + } + + /// Handle one message to `broker`. + async fn handle(self: Arc, env: Envelope) { + let result = self.clone().command(&env).await; + let body = match result { + Ok(v) => v, + Err(e) => json!({"type": "error", "request": env.body.get("type"), "reason": format!("{e:#}")}), + }; + self.send("broker", Some(&env.from), None, Some(&env.id), body); + } + + async fn command(self: Arc, env: &Envelope) -> Result { + let b = &env.body; + let ty = b.get("type").and_then(Value::as_str).unwrap_or(""); + let actor = || -> Result { + b.get("actor").and_then(Value::as_str).map(str::to_string).ok_or_else(|| anyhow!("missing \"actor\"")) + }; + let meta = |version: u64| -> Result<(PathBuf, Option, VersionMeta)> { + let wasm_s = b.get("wasm").and_then(Value::as_str).ok_or_else(|| anyhow!("missing \"wasm\""))?; + let wasm = self.world.resolve(&env.from, wasm_s)?; + if !wasm.is_file() { + bail!("{wasm_s} is not a file"); + } + let source_s = b.get("source").and_then(Value::as_str); + let source = source_s.map(|s| self.world.resolve(&env.from, s)).transpose()?; + if let Some(s) = &source + && !s.is_dir() + { + bail!("{} is not a folder", source_s.unwrap()); + } + let m = VersionMeta { + version, + wasm: wasm_s.to_string(), + source: source_s.map(str::to_string), + built_by: b.get("built_by").and_then(Value::as_str).map(str::to_string), + requested_by: env.from.clone(), + created_at: world::now(), + }; + Ok((wasm, source, m)) + }; + match ty { + "spawn" => { + let id = actor()?; + let (wasm, source, m) = meta(1)?; + self.create_actor(&id, &wasm, source.as_deref(), m, true).await + } + "upgrade" => { + let id = actor()?; + let (wasm, source, m) = meta(0)?; + self.upgrade_actor(&id, &wasm, source.as_deref(), m).await + } + "rollback" => { + let id = actor()?; + let target = b.get("version").and_then(Value::as_u64); + let lock = self.op_lock(&id); + let _g = lock.lock().await; + self.control(&id, |reply| Control::Rollback { target, reply }).await + } + "stop" => self.control(&actor()?, |reply| Control::Stop { reply }).await, + "start" => self.control(&actor()?, |reply| Control::Start { reply }).await, + "remove" => { + let id = actor()?; + let lock = self.op_lock(&id); + let _g = lock.lock().await; + let r = self.control(&id, |reply| Control::Remove { reply }).await?; + self.mailboxes.lock().unwrap().remove(&id); + self.controls.lock().unwrap().remove(&id); + self.update_registry(|reg| { + reg.actors.remove(&id); + reg.subscriptions.remove(&id); + for subs in reg.subscriptions.values_mut() { + subs.remove(&id); + } + })?; + Ok(r) + } + "subscribe" | "unsubscribe" => { + let publisher = actor()?; + let sub = env.from.clone(); + let on = ty == "subscribe"; + self.update_registry(|reg| { + let set = reg.subscriptions.entry(publisher.clone()).or_insert_with(BTreeSet::new); + if on { + set.insert(sub.clone()); + } else { + set.remove(&sub); + } + })?; + Ok(json!({"type": format!("{ty}d"), "actor": publisher})) + } + "list" => { + let reg = self.registry(); + let mut actors: Vec = NATIVE.iter().map(|id| json!({"id": id, "kind": "native"})).collect(); + for (id, rec) in reg.actors { + let status = std::fs::read_to_string(self.world.actor_dir(&id).join("status.json")) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()); + actors.push(json!({ + "id": id, "kind": "wasm", "version": rec.version, "running": rec.running, + "state": status.as_ref().and_then(|s| s.get("state")).cloned(), + })); + } + Ok(json!({"type": "actors", "actors": actors, "subscriptions": reg.subscriptions})) + } + "shutdown" => Ok(json!({"type": "ignored"})), + other => bail!("unknown broker request {other:?}. Known: spawn, upgrade, rollback, stop, start, remove, subscribe, unsubscribe, list"), + } + } + + /// The native `broker` actor. + pub async fn run_actor(self: Arc, mut mailbox: mpsc::Receiver) { + while let Some(env) = mailbox.recv().await { + tokio::spawn(self.clone().handle(env)); + } + } +} + +async fn futures_join_all>(futs: Vec) { + let mut set = Vec::new(); + for f in futs { + set.push(Box::pin(f)); + } + for f in set { + f.await; + } +} + +pub fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut end = max; + while !s.is_char_boundary(end) { + end -= 1; + } + format!("{}… ({} bytes)", &s[..end], s.len()) +} + +fn write_status(world: &World, id: &str, status: &Value) { + let mut s = status.clone(); + s["updated_at"] = json!(world::now()); + let _ = world::write_atomic( + &world.actor_dir(id).join("status.json"), + serde_json::to_string_pretty(&s).unwrap_or_default().as_bytes(), + ); +} + +// ------------------------------------------------------------------------- +// Supervisor: owns one WASM actor and its instance. +// ------------------------------------------------------------------------- + +struct Supervisor { + broker: Arc, + id: String, + version: u64, + desired_running: bool, + mailbox: mpsc::Receiver, + control: mpsc::Receiver, + component: Option, + instance: Option, + state: &'static str, + started_at: Instant, + restarts: u32, + restart_at: Option, + last_exit: Option, + last_delivered: Option, + /// The message that the actor got just before it crashed. The broker + /// delivers it again after the next upgrade. + parked: Option, + redeliver: Option, + previous: Option, + swapped_at: Option, + crashes_since_swap: usize, + upgrade_requester: Option, +} + +async fn recv_line(instance: &mut Option) -> Option { + match instance { + Some(i) => i.stdout.recv().await, + None => std::future::pending().await, + } +} + +async fn sleep_until_opt(t: Option) { + match t { + Some(t) => tokio::time::sleep_until(t.into()).await, + None => std::future::pending().await, + } +} + +impl Supervisor { + fn new( + broker: Arc, + id: String, + version: u64, + running: bool, + mailbox: mpsc::Receiver, + control: mpsc::Receiver, + ) -> Self { + Self { + broker, + id, + version, + desired_running: running, + mailbox, + control, + component: None, + instance: None, + state: if running { "starting" } else { "stopped" }, + started_at: Instant::now(), + restarts: 0, + restart_at: None, + last_exit: None, + last_delivered: None, + parked: None, + redeliver: None, + previous: None, + swapped_at: None, + crashes_since_swap: 0, + upgrade_requester: None, + } + } + + fn world(&self) -> &World { + &self.broker.world + } + + fn status(&self) { + write_status( + self.world(), + &self.id, + &json!({ + "id": self.id, + "version": self.version, + "state": self.state, + "restarts": self.restarts, + "last_exit": self.last_exit, + "parked_message": self.parked.as_ref().map(|m| json!({"id": m.id, "from": m.from, "body": m.body})), + "previous_version": self.previous, + }), + ); + } + + async fn run(mut self) { + self.status(); + loop { + if self.desired_running && self.instance.is_none() && self.restart_at.is_none_or(|t| Instant::now() >= t) { + self.restart_at = None; + self.start_instance().await; + } + let has_instance = self.instance.is_some(); + let waiting = !has_instance && self.desired_running; + tokio::select! { + ctl = self.control.recv() => { + let Some(ctl) = ctl else { break }; + if self.handle_control(ctl).await { + break; + } + } + msg = self.mailbox.recv(), if has_instance => { + let Some(msg) = msg else { break }; + self.deliver(msg).await; + } + line = recv_line(&mut self.instance) => { + match line { + Some(line) => self.broker.route_line(&self.id, &line), + None => { + let exit = self.instance.as_mut().unwrap().wait().await; + self.instance = None; + self.on_exit(exit).await; + } + } + } + _ = sleep_until_opt(self.restart_at), if waiting => {} + } + } + } + + async fn deliver(&mut self, msg: Envelope) { + let Some(inst) = self.instance.as_mut() else { return }; + let line = msg.to_line(); + match tokio::time::timeout(STDIN_WRITE_TIMEOUT, inst.send_line(&line)).await { + Ok(Ok(())) => self.last_delivered = Some(msg), + Ok(Err(_)) => {} // The instance is ending. The exit path handles it. + Err(_) => { + let note = format!("actor did not read stdin for {STDIN_WRITE_TIMEOUT:?}; message {} dropped", msg.id); + self.broker.log(&self.id, ¬e); + } + } + } + + async fn start_instance(&mut self) { + if self.component.is_none() { + let path = self.world().version_dir(&self.id, self.version).join("actor.wasm"); + let t = Instant::now(); + match self.broker.runtime.load(&path) { + Ok(c) => { + self.broker.log(&self.id, &format!("compiled version {} in {} ms", self.version, t.elapsed().as_millis())); + self.component = Some(c); + } + Err(e) => { + self.broker.log(&self.id, &format!("cannot load version {}: {e:#}", self.version)); + self.state = "broken"; + self.desired_running = false; + self.status(); + return; + } + } + } + let cfg = InstanceConfig { + mounts: self.broker.mounts(&self.id), + allow_http: true, + stderr_log: self.world().actor_dir(&self.id).join("stderr.log"), + env: vec![ + ("SAUSAGE_ACTOR".into(), self.id.clone()), + ("SAUSAGE_VERSION".into(), self.version.to_string()), + ], + }; + match self.broker.runtime.start(self.component.as_ref().unwrap(), cfg) { + Ok(inst) => { + self.instance = Some(inst); + self.state = "running"; + self.started_at = Instant::now(); + self.status(); + if let Some(msg) = self.redeliver.take() { + self.broker.log(&self.id, &format!("delivering parked message {} again", msg.id)); + self.deliver(msg).await; + } + } + Err(e) => { + self.broker.log(&self.id, &format!("cannot start: {e:#}")); + self.state = "broken"; + self.desired_running = false; + self.status(); + } + } + } + + async fn stop_instance(&mut self) -> Option { + let inst = self.instance.take()?; + let exit = inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE).await; + self.broker.log(&self.id, &format!("stopped: {exit}")); + self.last_exit = Some(exit.to_string()); + Some(exit) + } + + async fn on_exit(&mut self, exit: Exit) { + self.broker.log(&self.id, &format!("instance ended unexpectedly: {exit}")); + self.last_exit = Some(exit.to_string()); + if !exit.is_clean() + && let Some(m) = self.last_delivered.take() + { + self.parked = Some(m); + } + if self.started_at.elapsed() > Duration::from_secs(60) { + self.restarts = 0; + } + if let (Some(prev), Some(at)) = (self.previous, self.swapped_at) + && at.elapsed() < CRASH_WINDOW + { + self.crashes_since_swap += 1; + if self.crashes_since_swap >= CRASH_LIMIT { + let from = self.version; + self.broker.log(&self.id, &format!("version {from} crashed {CRASH_LIMIT} times after the upgrade; rolling back to {prev}")); + let result = self.rollback(Some(prev)).await; + let body = match result { + Ok(_) => json!({"type": "rolled_back", "actor": self.id, "from": from, "to": prev, "reason": "crash loop after upgrade", "last_exit": exit.to_string()}), + Err(e) => json!({"type": "error", "actor": self.id, "reason": format!("automatic rollback failed: {e:#}")}), + }; + if let Some(r) = self.upgrade_requester.clone() { + self.broker.send("broker", Some(&r), None, None, body); + } + return; + } + } + let delay = Duration::from_millis(500 * 2u64.pow(self.restarts.min(6))).min(Duration::from_secs(30)); + self.restarts += 1; + self.restart_at = Some(Instant::now() + delay); + self.state = "restarting"; + self.status(); + } + + fn snapshot_current(&self) -> Result<()> { + let snap = self.world().version_dir(&self.id, self.version).join("snapshot"); + world::replace_tree(&self.world().data_dir(&self.id), &snap.join("data"))?; + world::replace_tree(&self.world().public_dir(&self.id), &snap.join("public"))?; + Ok(()) + } + + fn install(&mut self, version: u64) -> Result<()> { + let src = self.world().version_dir(&self.id, version).join("source"); + world::replace_tree(&src, &self.world().source_dir(&self.id))?; + self.version = version; + self.component = None; + let id = self.id.clone(); + self.broker.update_registry(|r| { + if let Some(a) = r.actors.get_mut(&id) { + a.version = version; + a.running = true; + } + }) + } + + async fn upgrade(&mut self, version: u64, requester: String) -> Result { + self.stop_instance().await; + self.snapshot_current()?; + let previous = self.version; + self.install(version)?; + self.previous = Some(previous); + self.swapped_at = Some(Instant::now()); + self.crashes_since_swap = 0; + self.restarts = 0; + self.restart_at = None; + self.upgrade_requester = Some(requester); + self.redeliver = self.parked.take(); + self.desired_running = true; + self.start_instance().await; + Ok(json!({"type": "upgraded", "actor": self.id, "version": version, "previous": previous})) + } + + async fn rollback(&mut self, target: Option) -> Result { + let target = match target { + Some(t) => t, + None => (1..self.version) + .rev() + .find(|v| self.world().version_dir(&self.id, *v).join("actor.wasm").is_file()) + .ok_or_else(|| anyhow!("{} has no version before {}", self.id, self.version))?, + }; + let dir = self.world().version_dir(&self.id, target); + if !dir.join("actor.wasm").is_file() { + bail!("{} has no version {target}", self.id); + } + self.stop_instance().await; + self.snapshot_current()?; + let from = self.version; + let snap = dir.join("snapshot"); + if snap.is_dir() { + world::replace_tree(&snap.join("data"), &self.world().data_dir(&self.id))?; + world::replace_tree(&snap.join("public"), &self.world().public_dir(&self.id))?; + } + self.install(target)?; + self.previous = None; + self.swapped_at = None; + self.restarts = 0; + self.restart_at = None; + self.desired_running = true; + self.start_instance().await; + Ok(json!({"type": "rolled_back", "actor": self.id, "from": from, "to": target, "restored_state": snap.is_dir()})) + } + + /// Returns `true` when the supervisor must end. + async fn handle_control(&mut self, ctl: Control) -> bool { + match ctl { + Control::Upgrade { version, requester, reply } => { + let r = self.upgrade(version, requester).await; + let _ = reply.send(r); + } + Control::Rollback { target, reply } => { + let r = self.rollback(target).await; + let _ = reply.send(r); + } + Control::Stop { reply } => { + self.desired_running = false; + self.restart_at = None; + self.stop_instance().await; + self.state = "stopped"; + let id = self.id.clone(); + let r = self.broker.update_registry(|r| { + if let Some(a) = r.actors.get_mut(&id) { + a.running = false; + } + }); + let _ = reply.send(r.map(|_| json!({"type": "stopped", "actor": self.id}))); + } + Control::Start { reply } => { + self.desired_running = true; + self.restart_at = None; + let id = self.id.clone(); + let r = self.broker.update_registry(|r| { + if let Some(a) = r.actors.get_mut(&id) { + a.running = true; + } + }); + let _ = reply.send(r.map(|_| json!({"type": "started", "actor": self.id}))); + } + Control::Remove { reply } => { + self.desired_running = false; + self.stop_instance().await; + let r = self.archive(); + let _ = reply.send(r); + return true; + } + Control::Shutdown { reply } => { + self.stop_instance().await; + self.state = "host stopped"; + self.status(); + let _ = reply.send(Ok(json!({"type": "shut_down"}))); + return true; + } + } + self.status(); + false + } + + /// Move every folder of the actor into `archive/`. Nothing is deleted. + fn archive(&self) -> Result { + let dest = self.world().root.join("archive").join(format!("{}-{}", self.id, world::now())); + std::fs::create_dir_all(&dest)?; + for (from, name) in [ + (self.world().actor_dir(&self.id), "actor"), + (self.world().root.join("private").join(&self.id), "private"), + (self.world().versions_dir(&self.id), "versions"), + ] { + if from.exists() { + std::fs::rename(&from, dest.join(name))?; + } + } + Ok(json!({"type": "removed", "actor": self.id, "archive": dest.display().to_string()})) + } +} diff --git a/cli/src/candidate.rs b/cli/src/candidate.rs new file mode 100644 index 0000000..00a2e79 --- /dev/null +++ b/cli/src/candidate.rs @@ -0,0 +1,192 @@ +//! Tests for a candidate version, before the broker installs it. +//! +//! A test is a JSON file in `source/tests/*.json`, one scenario per file: +//! +//! ```json +//! { +//! "description": "add, then list", +//! "steps": [ +//! {"send": {"type": "add", "text": "milk"}, "expect": {"type": "added"}}, +//! {"send": {"type": "list"}, "from": "someone", "expect": [{"type": "notes"}], "to": "someone"} +//! ] +//! } +//! ``` +//! +//! Each step sends `send` (optional, from `from` or "test"), then reads one +//! message for each entry of `expect` (an object or a list). An expected body +//! matches if it is a subset of the actual body. With `to`, each message must +//! also go to that recipient. +//! +//! Each file runs in a fresh instance on a copy of the actor's `/data` and +//! `/public`. The candidate has no HTTP access. Without test files, the +//! candidate must start and stay up for `SMOKE_TIME`. + +use std::path::Path; +use std::time::Duration; + +use anyhow::{Result, anyhow, bail}; +use serde_json::{Value, json}; +use wasmtime::component::Component; + +use crate::broker::{Broker, SHUTDOWN_DEADLINE, shutdown_line, truncate}; +use crate::fs::Mount; +use crate::runtime::{Instance, InstanceConfig}; +use crate::world; + +const SMOKE_TIME: Duration = Duration::from_secs(2); +const EXPECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// `expected` matches `actual` if every key of an expected object exists in +/// the actual object with a matching value. Other values must be equal. +pub fn subset_match(expected: &Value, actual: &Value) -> bool { + match (expected, actual) { + (Value::Object(e), Value::Object(a)) => e.iter().all(|(k, v)| a.get(k).is_some_and(|av| subset_match(v, av))), + (Value::Array(e), Value::Array(a)) => e.len() == a.len() && e.iter().zip(a).all(|(x, y)| subset_match(x, y)), + _ => expected == actual, + } +} + +struct Scratch { + dir: std::path::PathBuf, +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +fn stderr_tail(path: &Path) -> String { + let text = std::fs::read_to_string(path).unwrap_or_default(); + let start = text.len().saturating_sub(2000); + let mut s = start; + while !text.is_char_boundary(s) { + s += 1; + } + text[s..].to_string() +} + +pub async fn test( + broker: &Broker, + id: &str, + component: &Component, + source: &Path, + data: Option<&Path>, + public: Option<&Path>, +) -> Result { + let mut files: Vec<_> = std::fs::read_dir(source.join("tests")) + .map(|rd| { + rd.filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x == "json")) + .collect() + }) + .unwrap_or_default(); + files.sort(); + + if files.is_empty() { + let (mut inst, scratch) = start(broker, id, component, data, public)?; + tokio::time::sleep(SMOKE_TIME).await; + if inst.is_finished() { + let exit = inst.wait().await; + bail!( + "candidate ended during the smoke test ({exit}). stderr:\n{}", + stderr_tail(&scratch.dir.join("stderr.log")) + ); + } + inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE).await; + return Ok(json!({"smoke_test": "passed", "files": 0})); + } + + let mut passed = Vec::new(); + for file in &files { + let name = file.file_name().unwrap().to_string_lossy().to_string(); + run_file(broker, id, component, data, public, file) + .await + .map_err(|e| anyhow!("test {name} failed: {e:#}"))?; + passed.push(name); + } + Ok(json!({"passed": passed})) +} + +fn start(broker: &Broker, id: &str, component: &Component, data: Option<&Path>, public: Option<&Path>) -> Result<(Instance, Scratch)> { + let dir = broker.world.scratch_dir().join(format!("{id}-{}-{}", world::now(), rand_suffix())); + let scratch = Scratch { dir: dir.clone() }; + std::fs::create_dir_all(dir.join("data"))?; + std::fs::create_dir_all(dir.join("public"))?; + if let Some(d) = data.filter(|d| d.is_dir()) { + world::copy_tree(d, &dir.join("data"))?; + } + if let Some(p) = public.filter(|p| p.is_dir()) { + world::copy_tree(p, &dir.join("public"))?; + } + let cfg = InstanceConfig { + mounts: vec![ + Mount { guest: "/actor".into(), host: broker.world.actors_root(), writable: false }, + Mount { guest: "/public".into(), host: dir.join("public"), writable: true }, + Mount { guest: "/data".into(), host: dir.join("data"), writable: true }, + ], + allow_http: false, + stderr_log: dir.join("stderr.log"), + env: vec![("SAUSAGE_ACTOR".into(), id.to_string()), ("SAUSAGE_CANDIDATE".into(), "1".into())], + }; + Ok((broker.runtime.start(component, cfg)?, scratch)) +} + +fn rand_suffix() -> u32 { + use std::hash::{BuildHasher, Hasher}; + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_u64(std::process::id() as u64); + h.finish() as u32 +} + +async fn run_file( + broker: &Broker, + id: &str, + component: &Component, + data: Option<&Path>, + public: Option<&Path>, + file: &Path, +) -> Result<()> { + let test: Value = serde_json::from_str(&std::fs::read_to_string(file)?).map_err(|e| anyhow!("invalid JSON: {e}"))?; + let steps = test["steps"].as_array().ok_or_else(|| anyhow!("missing \"steps\" list"))?; + let (mut inst, scratch) = start(broker, id, component, data, public)?; + let stderr = scratch.dir.join("stderr.log"); + for (n, step) in steps.iter().enumerate() { + let n = n + 1; + if let Some(body) = step.get("send") { + let from = step["from"].as_str().unwrap_or("test"); + let env = json!({"id": format!("test/{n}"), "from": from, "body": body}); + inst.send_line(&env.to_string()) + .await + .map_err(|e| anyhow!("step {n}: candidate does not read stdin: {e}"))?; + } + let expected = match step.get("expect") { + None => vec![], + Some(Value::Array(list)) => list.clone(), + Some(one) => vec![one.clone()], + }; + for expected in expected { + let line = match tokio::time::timeout(EXPECT_TIMEOUT, inst.stdout.recv()).await { + Err(_) => bail!("step {n}: no message within {EXPECT_TIMEOUT:?}; expected {expected}"), + Ok(None) => { + let exit = inst.wait().await; + bail!("step {n}: candidate ended ({exit}); expected {expected}. stderr:\n{}", stderr_tail(&stderr)) + } + Ok(Some(l)) => l, + }; + let msg: Value = serde_json::from_str(&line) + .map_err(|_| anyhow!("step {n}: candidate wrote a non-JSON line: {}", truncate(&line, 300)))?; + if !subset_match(&expected, &msg["body"]) { + bail!("step {n}: expected body {expected}, got {}", truncate(&msg["body"].to_string(), 1000)); + } + if let Some(to) = step.get("to") + && msg.get("to") != Some(to) + { + bail!("step {n}: expected recipient {to}, got {}", msg.get("to").unwrap_or(&Value::Null)); + } + } + } + inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE).await; + Ok(()) +} diff --git a/cli/src/cargo_actor.rs b/cli/src/cargo_actor.rs new file mode 100644 index 0000000..c05075f --- /dev/null +++ b/cli/src/cargo_actor.rs @@ -0,0 +1,169 @@ +//! The native `cargo` actor: builds Rust source folders into `.wasm` files. +//! +//! Request: `{"type": "build", "source": "/actor//public/"}` +//! Replies: `{"type": "built", "wasm": "/actor/cargo/public/builds//.wasm", ...}` +//! `{"type": "build_failed", "errors": "..."}` +//! +//! The broker knows nothing about Rust. This actor is one way to get a +//! `.wasm` file. Other actors can add other languages. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde_json::{Value, json}; +use tokio::process::Command; +use tokio::sync::{Mutex, mpsc}; + +use crate::broker::{Broker, Envelope, truncate}; +use crate::world::{self, World}; + +pub const ID: &str = "cargo"; +pub const TARGET: &str = "wasm32-wasip2"; +const BUILD_TIMEOUT: Duration = Duration::from_secs(900); + +pub struct BuildOutput { + /// Host path of the published `.wasm` file. + pub wasm: PathBuf, + pub seconds: f64, + pub warnings: String, +} + +pub struct Cargo { + world: World, + /// One build at a time: all builds share one target folder. + lock: Mutex<()>, +} + +impl Cargo { + pub fn new(world: World) -> Self { + Self { world, lock: Mutex::new(()) } + } + + /// Build the crate in `source` (a host path) and publish the `.wasm` file + /// under `/actor/cargo/public/builds//`. + pub async fn build(&self, source: &Path) -> Result { + if !source.join("Cargo.toml").is_file() { + bail!("{} has no Cargo.toml", source.display()); + } + let _guard = self.lock.lock().await; + let started = Instant::now(); + let data = self.world.data_dir(ID); + let key = format!("{:016x}", fxhash(source.to_string_lossy().as_bytes())); + let build_dir = data.join("work").join(&key); + world::replace_tree(source, &build_dir)?; + // The crate must not join a parent workspace by accident. + let manifest = build_dir.join("Cargo.toml"); + let text = std::fs::read_to_string(&manifest)?; + if !text.contains("[workspace]") { + std::fs::write(&manifest, format!("{text}\n[workspace]\n"))?; + } + + let mut cmd = Command::new("cargo"); + cmd.arg("build") + .arg("--release") + .arg("--target") + .arg(TARGET) + .arg("--message-format=json-render-diagnostics") + .arg("--target-dir") + .arg(data.join("target")) + .current_dir(&build_dir) + .kill_on_drop(true); + let out = tokio::time::timeout(BUILD_TIMEOUT, cmd.output()) + .await + .map_err(|_| anyhow!("cargo build took longer than {BUILD_TIMEOUT:?}"))? + .context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?; + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + if !out.status.success() { + bail!("{}", truncate(&strip_progress(&stderr), 20_000)); + } + let mut artifact = None; + for line in String::from_utf8_lossy(&out.stdout).lines() { + if let Ok(v) = serde_json::from_str::(line) + && v["reason"] == "compiler-artifact" + && let Some(exe) = v["executable"].as_str() + { + artifact = Some(PathBuf::from(exe)); + } + } + let artifact = artifact.ok_or_else(|| anyhow!("cargo built no binary. Does the crate have a src/main.rs?"))?; + + let n = next_build_number(&self.world.public_dir(ID).join("builds"))?; + let dest_dir = self.world.public_dir(ID).join("builds").join(n.to_string()); + let name = artifact.file_name().context("artifact has no file name")?; + let dest = dest_dir.join(name); + world::write_atomic(&dest, &std::fs::read(&artifact)?)?; + Ok(BuildOutput { wasm: dest, seconds: started.elapsed().as_secs_f64(), warnings: strip_progress(&stderr) }) + } + + pub async fn run_actor(self: Arc, broker: Arc, mut mailbox: mpsc::Receiver) { + while let Some(env) = mailbox.recv().await { + let this = self.clone(); + let broker = broker.clone(); + tokio::spawn(async move { + let body = this.handle(&broker, &env).await.unwrap_or_else(|e| { + json!({"type": "build_failed", "source": env.body.get("source"), "errors": format!("{e:#}")}) + }); + broker.send(ID, Some(&env.from), None, Some(&env.id), body); + }); + } + } + + async fn handle(&self, broker: &Broker, env: &Envelope) -> Result { + match env.body.get("type").and_then(Value::as_str) { + Some("build") => { + let source_s = env.body.get("source").and_then(Value::as_str).ok_or_else(|| anyhow!("missing \"source\""))?; + let source = broker.world.resolve(&env.from, source_s)?; + let out = self.build(&source).await?; + let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string()); + Ok(json!({ + "type": "built", + "wasm": wasm, + "source": source_s, + "built_by": ID, + "seconds": (out.seconds * 10.0).round() / 10.0, + "warnings": truncate(&out.warnings, 4000), + })) + } + Some("shutdown") => Ok(json!({"type": "ignored"})), + other => bail!("unknown request {other:?}. Known: build"), + } + } +} + +fn next_build_number(dir: &Path) -> Result { + std::fs::create_dir_all(dir)?; + let max = std::fs::read_dir(dir)? + .filter_map(|e| e.ok()?.file_name().to_str()?.parse::().ok()) + .max() + .unwrap_or(0); + Ok(max + 1) +} + +/// Remove cargo's "Compiling ..." progress lines. Keep diagnostics. +fn strip_progress(stderr: &str) -> String { + stderr + .lines() + .filter(|l| { + let t = l.trim_start(); + !(t.starts_with("Compiling ") + || t.starts_with("Downloaded ") + || t.starts_with("Downloading ") + || t.starts_with("Updating ") + || t.starts_with("Locking ") + || t.starts_with("Adding ") + || t.starts_with("Blocking ")) + }) + .collect::>() + .join("\n") +} + +fn fxhash(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf29ce484222325; + for b in bytes { + h ^= *b as u64; + h = h.wrapping_mul(0x100000001b3); + } + h +} diff --git a/cli/src/console.rs b/cli/src/console.rs new file mode 100644 index 0000000..c739c56 --- /dev/null +++ b/cli/src/console.rs @@ -0,0 +1,207 @@ +//! The native `console` actor: your terminal. +//! +//! - `@ ` sends a JSON body. `@ ` sends +//! `{"type": "text", "text": ...}`. +//! - A line without `@` goes to `agent` as text. +//! - `/deploy ` builds a Rust folder with `cargo`, then spawns +//! or upgrades the actor. +//! - `/list`, `/sleep `, `/help`, `/quit`. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::{Result, anyhow, bail}; +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::sync::{mpsc, oneshot}; + +use crate::broker::{Broker, Envelope}; + +pub const ID: &str = "console"; + +const HELP: &str = "\ +@ send a JSON body to an actor +@ send {\"type\":\"text\",\"text\":...} + send text to the agent +/deploy build a Rust folder with cargo, then spawn or upgrade +/list list actors +/sleep wait (for scripts) +/await wait for the next message from an actor (for scripts) +/quit stop all actors and exit"; + +pub struct Console { + broker: Arc, + waiters: Mutex>>, + seq: AtomicU64, + /// Number of messages received per sender, for `/await`. + seen: Mutex>, + arrived: tokio::sync::Notify, +} + +fn show(env: &Envelope) { + let reply = if env.reply_to.is_some() { " ↩" } else { "" }; + let b = &env.body; + if b["type"] == "text" + && let Some(t) = b["text"].as_str() + { + println!("[{}{reply}] {t}", env.from); + return; + } + let mut compact = b.clone(); + let mut extra = Vec::new(); + if let Some(obj) = compact.as_object_mut() { + for key in ["errors", "warnings", "reason"] { + if let Some(Value::String(s)) = obj.get(key) + && s.contains('\n') + { + extra.push((key, s.clone())); + obj.remove(key); + } + } + } + println!("[{}{reply}] {compact}", env.from); + for (k, s) in extra { + println!(" {k}:\n{}", s.lines().map(|l| format!(" {l}")).collect::>().join("\n")); + } +} + +impl Console { + pub fn new(broker: Arc) -> Arc { + Arc::new(Self { + broker, + waiters: Mutex::new(HashMap::new()), + seq: AtomicU64::new(1), + seen: Mutex::new(HashMap::new()), + arrived: tokio::sync::Notify::new(), + }) + } + + pub async fn run_mailbox(self: Arc, mut mailbox: mpsc::Receiver) { + while let Some(env) = mailbox.recv().await { + show(&env); + *self.seen.lock().unwrap().entry(env.from.clone()).or_default() += 1; + self.arrived.notify_waiters(); + if let Some(r) = &env.reply_to + && let Some(tx) = self.waiters.lock().unwrap().remove(r) + { + let _ = tx.send(env); + } + } + } + + fn send(&self, to: &str, body: Value) -> String { + let own = format!("c{}", self.seq.fetch_add(1, Ordering::Relaxed)); + self.broker.send(ID, Some(to), Some(&own), None, body) + } + + async fn request(&self, to: &str, body: Value, timeout: Duration) -> Result { + let own = format!("c{}", self.seq.fetch_add(1, Ordering::Relaxed)); + let (tx, rx) = oneshot::channel(); + self.waiters.lock().unwrap().insert(format!("{ID}/{own}"), tx); + self.broker.send(ID, Some(to), Some(&own), None, body); + tokio::time::timeout(timeout, rx) + .await + .map_err(|_| anyhow!("no reply from {to} within {timeout:?}"))? + .map_err(|_| anyhow!("reply channel closed")) + } + + async fn deploy(&self, actor: &str, dir: &str) -> Result<()> { + let path = std::path::absolute(dir)?; + let source = path.display().to_string(); + println!("· building {source} …"); + let built = self.request(crate::cargo_actor::ID, json!({"type": "build", "source": source}), Duration::from_secs(900)).await?; + if built.body["type"] != "built" { + bail!("build failed"); + } + let exists = self.broker.registry().actors.contains_key(actor); + let ty = if exists { "upgrade" } else { "spawn" }; + println!("· {ty} {actor} …"); + let body = json!({ + "type": ty, + "actor": actor, + "wasm": built.body["wasm"], + "source": source, + "built_by": built.body["built_by"], + }); + let r = self.request("broker", body, Duration::from_secs(300)).await?; + if r.body["type"] == "error" { + bail!("{ty} failed"); + } + Ok(()) + } + + /// Read commands until EOF or `/quit`. + pub async fn run_input(self: Arc) { + println!("Sausage console. Type /help for commands."); + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Err(e) = self.command(line).await { + if e.to_string() == "quit" { + break; + } + println!("! {e:#}"); + } + } + } + + async fn command(&self, line: &str) -> Result<()> { + if let Some(rest) = line.strip_prefix('@') { + let (to, text) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + let text = text.trim(); + let body = match serde_json::from_str::(text) { + Ok(v @ Value::Object(_)) => v, + _ => json!({"type": "text", "text": text}), + }; + self.send(to, body); + return Ok(()); + } + if let Some(cmd) = line.strip_prefix('/') { + let mut parts = cmd.split_whitespace(); + match parts.next() { + Some("help") => println!("{HELP}"), + Some("quit") => bail!("quit"), + Some("list") => { + self.send("broker", json!({"type": "list"})); + } + Some("sleep") => { + let secs: f64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(1.0); + tokio::time::sleep(Duration::from_secs_f64(secs)).await; + } + Some("await") => { + let from = parts.next().unwrap_or("agent").to_string(); + let secs: f64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(60.0); + let count = |c: &Self| c.seen.lock().unwrap().get(&from).copied().unwrap_or(0); + let before = count(self); + let deadline = tokio::time::Instant::now() + Duration::from_secs_f64(secs); + loop { + let notified = self.arrived.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if count(self) != before { + break; + } + if tokio::time::timeout_at(deadline, notified).await.is_err() { + bail!("no message from {from} within {secs} s"); + } + } + } + Some("deploy") => { + let (Some(actor), Some(dir)) = (parts.next(), parts.next()) else { + bail!("usage: /deploy "); + }; + self.deploy(actor, dir).await?; + } + _ => bail!("unknown command. Type /help."), + } + return Ok(()); + } + self.send("agent", json!({"type": "text", "text": line})); + Ok(()) + } +} diff --git a/cli/src/fs.rs b/cli/src/fs.rs new file mode 100644 index 0000000..2afe1de --- /dev/null +++ b/cli/src/fs.rs @@ -0,0 +1,414 @@ +//! Filesystem layer with commit-on-close semantics. +//! +//! The layer wraps the wasmtime-wasi `wasi:filesystem` implementation. In a +//! writable mount, an open that creates or truncates a file goes to a hidden +//! temporary file next to the target. When the guest drops the descriptor +//! (close), the layer renames the temporary file into place. Until then, no +//! other reader sees the change. If the instance traps or is killed, the +//! changes are lost. +//! +//! An open of an existing file without truncate (append, in-place edit) +//! writes in place, so that appending to a log does not copy the log. + +use std::collections::HashMap; +use std::path::{Component, Path, PathBuf}; + +use wasmtime::component::{HasData, Resource}; +use wasmtime_wasi::filesystem::{Descriptor, WasiFilesystemCtxView}; +use wasmtime_wasi::p2::bindings::filesystem::types::{ + self, ErrorCode, HostDescriptor, HostDirectoryEntryStream, +}; +use wasmtime_wasi::p2::bindings::filesystem::preopens; +use wasmtime_wasi::p2::{DynInputStream, DynOutputStream, FsError, FsResult}; + +/// Name prefix of uncommitted files. The layer hides these names from guests. +pub const TMP_PREFIX: &str = ".sausage-tmp-"; + +static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +#[derive(Clone, Debug)] +pub struct Mount { + pub guest: String, + pub host: PathBuf, + pub writable: bool, +} + +#[derive(Clone)] +struct DirInfo { + host: PathBuf, + root: PathBuf, + commit: bool, +} + +struct Pending { + tmp: PathBuf, + target: PathBuf, +} + +/// Per-instance state of the layer. +pub struct FsExt { + mounts: Vec, + dirs: HashMap, + pending: HashMap, +} + +impl FsExt { + pub fn new(mounts: Vec) -> Self { + Self { mounts, dirs: HashMap::new(), pending: HashMap::new() } + } + + /// Commit every open write. Use this when the guest exits normally, + /// because process exit closes all files. + pub fn commit_all(&mut self) { + for (_, p) in self.pending.drain() { + commit(p); + } + } +} + +impl Drop for FsExt { + /// Discard writes that were not committed (trap, kill). + fn drop(&mut self) { + for (_, p) in self.pending.drain() { + let _ = std::fs::remove_file(&p.tmp); + } + } +} + +fn commit(p: Pending) { + if let Err(e) = std::fs::rename(&p.tmp, &p.target) { + eprintln!("sausage: commit of {} failed: {e}", p.target.display()); + let _ = std::fs::remove_file(&p.tmp); + } +} + +fn is_tmp_name(path: &str) -> bool { + path.rsplit('/').next().is_some_and(|n| n.starts_with(TMP_PREFIX)) +} + +/// Join `rel` to the directory `dir` lexically. Return `None` if the result +/// leaves the mount root. +fn resolve(dir: &DirInfo, rel: &str) -> Option { + let mut out = dir.host.clone(); + for c in Path::new(rel).components() { + match c { + Component::Normal(n) => out.push(n), + Component::CurDir => {} + Component::ParentDir => { + if !out.pop() { + return None; + } + } + Component::RootDir | Component::Prefix(_) => return None, + } + } + out.starts_with(&dir.root).then_some(out) +} + +pub struct ActorFs<'a> { + pub inner: WasiFilesystemCtxView<'a>, + pub ext: &'a mut FsExt, +} + +pub struct ActorFsData; + +impl HasData for ActorFsData { + type Data<'a> = ActorFs<'a>; +} + +impl preopens::Host for ActorFs<'_> { + fn get_directories(&mut self) -> wasmtime::Result, String)>> { + let dirs = preopens::Host::get_directories(&mut self.inner)?; + for (r, name) in &dirs { + if let Some(m) = self.ext.mounts.iter().find(|m| &m.guest == name) { + self.ext.dirs.insert( + r.rep(), + DirInfo { host: m.host.clone(), root: m.host.clone(), commit: m.writable }, + ); + } + } + Ok(dirs) + } +} + +impl types::Host for ActorFs<'_> { + fn convert_error_code(&mut self, err: FsError) -> wasmtime::Result { + types::Host::convert_error_code(&mut self.inner, err) + } + + fn filesystem_error_code( + &mut self, + err: Resource, + ) -> wasmtime::Result> { + types::Host::filesystem_error_code(&mut self.inner, err) + } +} + +impl HostDescriptor for ActorFs<'_> { + async fn advise( + &mut self, + fd: Resource, + offset: types::Filesize, + len: types::Filesize, + advice: types::Advice, + ) -> FsResult<()> { + HostDescriptor::advise(&mut self.inner, fd, offset, len, advice).await + } + + async fn sync_data(&mut self, fd: Resource) -> FsResult<()> { + HostDescriptor::sync_data(&mut self.inner, fd).await + } + + async fn get_flags(&mut self, fd: Resource) -> FsResult { + HostDescriptor::get_flags(&mut self.inner, fd).await + } + + async fn get_type(&mut self, fd: Resource) -> FsResult { + HostDescriptor::get_type(&mut self.inner, fd).await + } + + async fn set_size(&mut self, fd: Resource, size: types::Filesize) -> FsResult<()> { + HostDescriptor::set_size(&mut self.inner, fd, size).await + } + + async fn set_times( + &mut self, + fd: Resource, + atim: types::NewTimestamp, + mtim: types::NewTimestamp, + ) -> FsResult<()> { + HostDescriptor::set_times(&mut self.inner, fd, atim, mtim).await + } + + async fn read( + &mut self, + fd: Resource, + len: types::Filesize, + offset: types::Filesize, + ) -> FsResult<(Vec, bool)> { + HostDescriptor::read(&mut self.inner, fd, len, offset).await + } + + async fn write( + &mut self, + fd: Resource, + buf: Vec, + offset: types::Filesize, + ) -> FsResult { + HostDescriptor::write(&mut self.inner, fd, buf, offset).await + } + + async fn read_directory( + &mut self, + fd: Resource, + ) -> FsResult> { + HostDescriptor::read_directory(&mut self.inner, fd).await + } + + async fn sync(&mut self, fd: Resource) -> FsResult<()> { + HostDescriptor::sync(&mut self.inner, fd).await + } + + async fn create_directory_at(&mut self, fd: Resource, path: String) -> FsResult<()> { + HostDescriptor::create_directory_at(&mut self.inner, fd, path).await + } + + async fn stat(&mut self, fd: Resource) -> FsResult { + HostDescriptor::stat(&mut self.inner, fd).await + } + + async fn stat_at( + &mut self, + fd: Resource, + path_flags: types::PathFlags, + path: String, + ) -> FsResult { + if is_tmp_name(&path) { + return Err(ErrorCode::NoEntry.into()); + } + HostDescriptor::stat_at(&mut self.inner, fd, path_flags, path).await + } + + async fn set_times_at( + &mut self, + fd: Resource, + path_flags: types::PathFlags, + path: String, + atim: types::NewTimestamp, + mtim: types::NewTimestamp, + ) -> FsResult<()> { + HostDescriptor::set_times_at(&mut self.inner, fd, path_flags, path, atim, mtim).await + } + + async fn link_at( + &mut self, + fd: Resource, + old_path_flags: types::PathFlags, + old_path: String, + new_descriptor: Resource, + new_path: String, + ) -> FsResult<()> { + HostDescriptor::link_at(&mut self.inner, fd, old_path_flags, old_path, new_descriptor, new_path).await + } + + async fn open_at( + &mut self, + fd: Resource, + path_flags: types::PathFlags, + path: String, + oflags: types::OpenFlags, + flags: types::DescriptorFlags, + ) -> FsResult> { + if is_tmp_name(&path) { + return Err(ErrorCode::NoEntry.into()); + } + let parent = self.ext.dirs.get(&fd.rep()).cloned(); + + // Commit-on-close: a new or truncated file goes to a hidden temporary file. + if let Some(dir) = parent.as_ref().filter(|d| d.commit) + && flags.contains(types::DescriptorFlags::WRITE) + && !oflags.contains(types::OpenFlags::DIRECTORY) + && let Some(target) = resolve(dir, &path) + { + let meta = std::fs::metadata(&target).ok(); + let new_file = meta.is_none() && oflags.contains(types::OpenFlags::CREATE); + let truncated = meta.as_ref().is_some_and(|m| m.is_file()) + && oflags.contains(types::OpenFlags::TRUNCATE) + && !oflags.contains(types::OpenFlags::EXCLUSIVE); + if new_file || truncated { + let n = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp_name = format!("{TMP_PREFIX}{}-{n}", std::process::id()); + let tmp_rel = match path.rsplit_once('/') { + Some((d, _)) => format!("{d}/{tmp_name}"), + None => tmp_name.clone(), + }; + let tmp = target.with_file_name(&tmp_name); + let r = HostDescriptor::open_at(&mut self.inner, fd, path_flags, tmp_rel, oflags, flags).await?; + self.ext.pending.insert(r.rep(), Pending { tmp, target }); + return Ok(r); + } + } + + let r = HostDescriptor::open_at(&mut self.inner, fd, path_flags, path.clone(), oflags, flags).await?; + if let Some(dir) = parent + && matches!(self.inner.table.get(&r)?, Descriptor::Dir(_)) + && let Some(host) = resolve(&dir, &path) + { + self.ext.dirs.insert(r.rep(), DirInfo { host, root: dir.root, commit: dir.commit }); + } + Ok(r) + } + + fn drop(&mut self, fd: Resource) -> wasmtime::Result<()> { + let rep = fd.rep(); + HostDescriptor::drop(&mut self.inner, fd)?; + self.ext.dirs.remove(&rep); + if let Some(p) = self.ext.pending.remove(&rep) { + commit(p); + } + Ok(()) + } + + async fn readlink_at(&mut self, fd: Resource, path: String) -> FsResult { + HostDescriptor::readlink_at(&mut self.inner, fd, path).await + } + + async fn remove_directory_at(&mut self, fd: Resource, path: String) -> FsResult<()> { + HostDescriptor::remove_directory_at(&mut self.inner, fd, path).await + } + + async fn rename_at( + &mut self, + fd: Resource, + old_path: String, + new_fd: Resource, + new_path: String, + ) -> FsResult<()> { + if is_tmp_name(&old_path) || is_tmp_name(&new_path) { + return Err(ErrorCode::NoEntry.into()); + } + HostDescriptor::rename_at(&mut self.inner, fd, old_path, new_fd, new_path).await + } + + async fn symlink_at( + &mut self, + _fd: Resource, + _src_path: String, + _dest_path: String, + ) -> FsResult<()> { + // Symlinks would break the lexical path tracking of commit-on-close. + Err(ErrorCode::NotPermitted.into()) + } + + async fn unlink_file_at(&mut self, fd: Resource, path: String) -> FsResult<()> { + if is_tmp_name(&path) { + return Err(ErrorCode::NoEntry.into()); + } + HostDescriptor::unlink_file_at(&mut self.inner, fd, path).await + } + + fn read_via_stream( + &mut self, + fd: Resource, + offset: types::Filesize, + ) -> FsResult> { + HostDescriptor::read_via_stream(&mut self.inner, fd, offset) + } + + fn write_via_stream( + &mut self, + fd: Resource, + offset: types::Filesize, + ) -> FsResult> { + HostDescriptor::write_via_stream(&mut self.inner, fd, offset) + } + + fn append_via_stream(&mut self, fd: Resource) -> FsResult> { + HostDescriptor::append_via_stream(&mut self.inner, fd) + } + + async fn is_same_object( + &mut self, + a: Resource, + b: Resource, + ) -> wasmtime::Result { + HostDescriptor::is_same_object(&mut self.inner, a, b).await + } + + async fn metadata_hash(&mut self, fd: Resource) -> FsResult { + HostDescriptor::metadata_hash(&mut self.inner, fd).await + } + + async fn metadata_hash_at( + &mut self, + fd: Resource, + path_flags: types::PathFlags, + path: String, + ) -> FsResult { + HostDescriptor::metadata_hash_at(&mut self.inner, fd, path_flags, path).await + } +} + +impl HostDirectoryEntryStream for ActorFs<'_> { + async fn read_directory_entry( + &mut self, + stream: Resource, + ) -> FsResult> { + let rep = stream.rep(); + loop { + let entry = HostDirectoryEntryStream::read_directory_entry( + &mut self.inner, + Resource::new_borrow(rep), + ) + .await?; + match entry { + Some(e) if e.name.starts_with(TMP_PREFIX) => continue, + other => return Ok(other), + } + } + } + + fn drop(&mut self, stream: Resource) -> wasmtime::Result<()> { + HostDirectoryEntryStream::drop(&mut self.inner, stream) + } +} diff --git a/cli/src/lib.rs b/cli/src/lib.rs new file mode 100644 index 0000000..d176c1a --- /dev/null +++ b/cli/src/lib.rs @@ -0,0 +1,62 @@ +//! Sausage: grow a system of WASI actors by talking to it. +//! +//! The host runs the broker, the native actors (`broker`, `cargo`, and the +//! console), and one supervisor per WASM actor. See `PROTOCOL.md`. + +pub mod broker; +pub mod candidate; +pub mod cargo_actor; +pub mod console; +pub mod fs; +pub mod runtime; +pub mod world; + +use std::path::Path; +use std::sync::Arc; + +use anyhow::{Result, bail}; +use tokio::sync::mpsc; + +pub use broker::{Broker, Envelope}; +pub use world::World; + +/// A running world. +pub struct Host { + pub world: World, + pub broker: Arc, + _lock: std::fs::File, +} + +impl Host { + /// Start the broker, `cargo`, and every registered actor of the world at + /// `root`. Only one host can run a world at a time. + pub async fn start(root: &Path) -> Result { + let world = World::new(std::path::absolute(root)?); + if !world.exists() { + bail!("{} is not a Sausage world. Create one with: sausage init", root.display()); + } + let lock = std::fs::File::create(world.root.join(".lock"))?; + if lock.try_lock().is_err() { + bail!("another sausage process runs {}", root.display()); + } + let broker = Broker::new(world.clone(), runtime::Runtime::new()?)?; + + let rx = broker.open_mailbox("broker"); + tokio::spawn(broker.clone().run_actor(rx)); + let rx = broker.open_mailbox(cargo_actor::ID); + tokio::spawn(Arc::new(cargo_actor::Cargo::new(world.clone())).run_actor(broker.clone(), rx)); + + broker.start_all(); + Ok(Host { world, broker, _lock: lock }) + } + + /// Register a native actor with the ID `id` and return its mailbox. + pub fn mailbox(&self, id: &str) -> mpsc::Receiver { + self.broker.open_mailbox(id) + } + + /// Stop every actor gracefully. + pub async fn stop(self) { + self.broker.shutdown_all().await; + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 0000000..8a84d7b --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,125 @@ +use std::io::Write; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use clap::{Parser, Subcommand}; +use include_dir::{Dir, include_dir}; +use serde_json::json; + +use sausage::world::{self, VersionMeta, World}; +use sausage::{Broker, Host, cargo_actor, console, runtime}; + +/// The source of the agent actor. `init` builds it like any other actor. +static AGENT_SOURCE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent"); + +#[derive(Parser)] +#[command(version, about)] +struct Cli { + #[command(subcommand)] + command: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Create a new world and build the agent actor. + /// + /// Reads SAUSAGE_API, SAUSAGE_BASE_URL, SAUSAGE_MODEL and + /// SAUSAGE_API_TOKEN, and asks for every value that is missing. + Init { + /// Folder of the new world. + #[arg(default_value = "world")] + dir: PathBuf, + }, + /// Start the broker, all actors, and the console. + Run { + #[arg(default_value = "world")] + dir: PathBuf, + }, +} + +/// The value of `var`, or the answer to `question`. +fn setting(var: &str, question: &str, default: Option<&str>) -> Result { + if let Ok(v) = std::env::var(var) + && !v.is_empty() + { + return Ok(v); + } + match default { + Some(d) => print!("{question} [{d}]: "), + None => print!("{question}: "), + } + std::io::stdout().flush()?; + let mut line = String::new(); + std::io::stdin().read_line(&mut line)?; + match (line.trim(), default) { + ("", Some(d)) => Ok(d.to_string()), + ("", None) => bail!("{question} is required (or set {var})"), + (v, _) => Ok(v.to_string()), + } +} + +async fn init(dir: PathBuf) -> Result<()> { + let api = setting("SAUSAGE_API", "API shape (openai-responses or anthropic-messages)", Some("openai-responses"))?; + if api != "openai-responses" && api != "anthropic-messages" { + bail!("unknown API shape {api:?}"); + } + let base_url = setting("SAUSAGE_BASE_URL", "API base URL", None)?; + let model = setting("SAUSAGE_MODEL", "Model", None)?; + let token = setting("SAUSAGE_API_TOKEN", "API token", None)?; + + let world = World::create(&dir)?; + let mut reg = world.load_registry()?; + reg.subscriptions.entry("agent".into()).or_default().insert("console".into()); + world.save_registry(®)?; + + // The agent's private configuration. Only the agent can read /data. + world.ensure_actor_dirs("agent")?; + let config = json!({"api": api, "base_url": base_url.trim_end_matches('/'), "model": model, "token": token}); + let config_path = world.data_dir("agent").join("config.json"); + std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600))?; + } + + let source = world.root.join("broker").join("seed").join("agent"); + std::fs::create_dir_all(&source)?; + AGENT_SOURCE.extract(&source).context("extract agent source")?; + println!("Building the agent actor (the first build takes a few minutes) …"); + let out = cargo_actor::Cargo::new(world.clone()).build(&source).await.context("build agent")?; + println!("Built in {:.1} s.", out.seconds); + + let broker = Broker::new(world.clone(), runtime::Runtime::new()?)?; + let meta = VersionMeta { + version: 1, + wasm: world.global_path(&out.wasm).unwrap_or_default(), + source: Some(source.display().to_string()), + built_by: Some(cargo_actor::ID.into()), + requested_by: "init".into(), + created_at: world::now(), + }; + broker.create_actor("agent", &out.wasm, Some(&source), meta, false).await?; + println!("World ready. Start it with: sausage run {}", dir.display()); + Ok(()) +} + +async fn run(dir: PathBuf) -> Result<()> { + let host = Host::start(&dir).await?; + let rx = host.mailbox(console::ID); + let console = console::Console::new(host.broker.clone()); + tokio::spawn(Arc::clone(&console).run_mailbox(rx)); + console.run_input().await; + println!("Stopping actors …"); + host.stop().await; + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<()> { + match Cli::parse().command { + Cmd::Init { dir } => init(dir).await, + Cmd::Run { dir } => run(dir).await, + } +} diff --git a/cli/src/runtime.rs b/cli/src/runtime.rs new file mode 100644 index 0000000..fb050ea --- /dev/null +++ b/cli/src/runtime.rs @@ -0,0 +1,304 @@ +//! Wasmtime engine and actor 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, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, 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::Command; +use wasmtime_wasi::p2::bindings::filesystem::{preopens, types}; +use wasmtime_wasi::{FsPerms, I32Exit, WasiCtx, WasiCtxView, WasiView}; +use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView}; + +use crate::fs::{ActorFs, ActorFsData, FsExt, Mount}; + +pub struct WasiPart { + ctx: WasiCtx, + table: ResourceTable, +} + +impl WasiView for WasiPart { + fn ctx(&mut self) -> WasiCtxView<'_> { + WasiCtxView { ctx: &mut self.ctx, table: &mut self.table } + } +} + +pub struct HttpHooks { + allow: bool, +} + +impl WasiHttpHooks for HttpHooks { + fn send_request( + &mut self, + request: http::Request, + options: Option, + fut: Box> + Send>, + ) -> Box< + dyn Future< + Output = wasmtime_wasi_http::Result<( + http::Response, + Box> + 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 + Send>, + )) + }) + } +} + +pub struct ActorState { + wasi: WasiPart, + fsx: FsExt, + http: WasiHttpCtx, + hooks: HttpHooks, +} + +impl WasiView for ActorState { + fn ctx(&mut self) -> WasiCtxView<'_> { + self.wasi.ctx() + } +} + +impl WasiHttpView for ActorState { + fn http(&mut self) -> WasiHttpCtxView<'_> { + WasiHttpCtxView { ctx: &mut self.http, table: &mut self.wasi.table, hooks: &mut self.hooks } + } +} + +fn actor_fs(s: &mut ActorState) -> ActorFs<'_> { + ActorFs { + inner: wasmtime_wasi::filesystem::WasiFilesystemView::filesystem(&mut s.wasi), + ext: &mut s.fsx, + } +} + +pub struct Runtime { + engine: Engine, + linker: Linker, +} + +/// 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 Exit { + pub fn is_clean(&self) -> bool { + matches!(self, Exit::Clean) + } +} + +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}"), + } + } +} + +pub struct InstanceConfig { + pub mounts: Vec, + pub allow_http: bool, + pub stderr_log: PathBuf, + pub env: Vec<(String, String)>, +} + +pub struct Instance { + stdin: DuplexStream, + /// Lines that the guest writes to stdout. Closed when the guest ends. + pub stdout: mpsc::Receiver, + task: JoinHandle, +} + +impl Instance { + /// Write one line to the guest's stdin. + pub async fn send_line(&mut self, line: &str) -> std::io::Result<()> { + self.stdin.write_all(line.as_bytes()).await?; + self.stdin.write_all(b"\n").await?; + self.stdin.flush().await + } + + /// Wait until the guest ends. + pub async fn wait(&mut self) -> Exit { + match (&mut self.task).await { + Ok(exit) => exit, + Err(e) if e.is_cancelled() => Exit::Trap("killed".into()), + Err(e) => Exit::Trap(format!("host task failed: {e}")), + } + } + + pub fn is_finished(&self) -> bool { + self.task.is_finished() + } + + /// Send `shutdown`, close stdin, and wait. Kill the guest after `deadline`. + pub async fn stop(mut self, shutdown_line: &str, deadline: Duration) -> Exit { + let _ = self.send_line(shutdown_line).await; + let _ = self.stdin.shutdown().await; + drop(self.stdin); + match tokio::time::timeout(deadline, &mut self.task).await { + Ok(Ok(exit)) => exit, + Ok(Err(e)) => Exit::Trap(format!("host task failed: {e}")), + Err(_) => { + self.task.abort(); + let _ = (&mut self.task).await; + Exit::Trap("killed after shutdown deadline".into()) + } + } + } +} + +fn log_note(path: &Path, text: &str) { + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(f, "[sausage] {text}"); + } +} + +/// 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(); + line.truncate(300); + line +} + +impl Runtime { + pub fn new() -> Result> { + let mut config = Config::new(); + config.epoch_interruption(true); + let engine = Engine::new(&config)?; + + let mut linker: Linker = Linker::new(&engine); + wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; + wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; + // Replace the filesystem with the commit-on-close layer. + linker.allow_shadowing(true); + types::add_to_linker::(&mut linker, actor_fs)?; + preopens::add_to_linker::(&mut linker, actor_fs)?; + linker.allow_shadowing(false); + + // Time slices: every 10 ms each running guest yields to the scheduler. + let ticker = engine.clone(); + std::thread::spawn(move || { + loop { + std::thread::sleep(Duration::from_millis(10)); + ticker.increment_epoch(); + } + }); + + Ok(Arc::new(Self { engine, linker })) + } + + pub fn load(&self, wasm: &Path) -> Result { + Component::from_file(&self.engine, wasm) + .map_err(anyhow::Error::from) + .with_context(|| format!("load {}", wasm.display())) + } + + pub fn start(self: &Arc, component: &Component, cfg: InstanceConfig) -> Result { + let (host_in, guest_in) = tokio::io::duplex(64 * 1024); + let (guest_out, host_out) = tokio::io::duplex(64 * 1024); + + let stderr = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&cfg.stderr_log) + .with_context(|| format!("open {}", cfg.stderr_log.display()))?; + + let mut b = WasiCtx::builder(); + b.stdin(AsyncStdinStream::new(guest_in)); + b.stdout(AsyncStdoutStream::new(8192, guest_out)); + b.stderr(AsyncStdoutStream::new(8192, tokio::fs::File::from_std(stderr))); + for (k, v) in &cfg.env { + b.env(k, v); + } + for m in &cfg.mounts { + let perms = if m.writable { FsPerms::ReadWrite } else { FsPerms::ReadOnly }; + b.preopened_dir(&m.host, &m.guest, perms) + .map_err(anyhow::Error::from) + .with_context(|| format!("mount {} at {}", m.host.display(), m.guest))?; + } + let state = ActorState { + wasi: WasiPart { ctx: b.build(), table: ResourceTable::new() }, + fsx: FsExt::new(cfg.mounts), + http: WasiHttpCtx::new(), + hooks: HttpHooks { allow: cfg.allow_http }, + }; + + let (line_tx, line_rx) = mpsc::channel(1024); + tokio::spawn(async move { + let mut lines = BufReader::new(host_out).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line_tx.send(line).await.is_err() { + break; + } + } + }); + + let rt = self.clone(); + let component = component.clone(); + let stderr_log = cfg.stderr_log.clone(); + 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 = Command::instantiate_async(&mut store, &component, &rt.linker).await?; + cmd.wasi_cli_run().call_run(&mut store).await + } + .await; + let exit = match result { + Ok(Ok(())) => Exit::Clean, + Ok(Err(())) => Exit::Code(1), + Err(e) => match e.downcast_ref::() { + Some(I32Exit(0)) => Exit::Clean, + Some(I32Exit(c)) => Exit::Code(*c), + None => { + // Keep the full backtrace in the log, and a short reason in the status. + let full = format!("{e:?}"); + log_note(&stderr_log, &format!("trap details:\n{full}")); + Exit::Trap(short_trap(&full)) + } + }, + }; + // Process exit closes every file, so a clean exit commits. + if exit.is_clean() { + store.data_mut().fsx.commit_all(); + } + exit + }); + + Ok(Instance { stdin: host_in, stdout: line_rx, task }) + } +} diff --git a/cli/src/world.rs b/cli/src/world.rs new file mode 100644 index 0000000..13e9deb --- /dev/null +++ b/cli/src/world.rs @@ -0,0 +1,239 @@ +//! On-disk layout of a Sausage workspace ("world"). +//! +//! ```text +//! / +//! sausage.json registry: actors, versions, subscriptions +//! actor//public/ written by the actor, read-only for others +//! actor//source/ source of the running version (broker) +//! actor//status.json state of the actor (broker) +//! actor//stderr.log stderr of the actor (broker) +//! private//data/ private state of the actor +//! broker/versions/// actor.wasm, source/, meta.json, snapshot/ +//! archive/ removed actors +//! ``` + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +use crate::fs::TMP_PREFIX; + +/// IDs of the actors that the host implements natively. +pub const NATIVE: &[&str] = &["broker", "console", "cargo"]; + +#[derive(Clone)] +pub struct World { + pub root: PathBuf, +} + +#[derive(Default, Serialize, Deserialize, Clone)] +pub struct Registry { + pub actors: BTreeMap, + /// Publisher ID → subscriber IDs. + pub subscriptions: BTreeMap>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ActorRecord { + pub version: u64, + pub next_version: u64, + pub running: bool, +} + +#[derive(Serialize, Deserialize, Clone, Debug, Default)] +pub struct VersionMeta { + pub version: u64, + /// Path of the `.wasm` file, as the requester gave it. + pub wasm: String, + /// Path of the source folder, as the requester gave it. + pub source: Option, + /// The actor that built the `.wasm` file. + pub built_by: Option, + /// The actor that requested the spawn or upgrade. + pub requested_by: String, + pub created_at: u64, +} + +pub fn valid_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 64 + && id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') +} + +pub fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +impl World { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn registry_path(&self) -> PathBuf { + self.root.join("sausage.json") + } + pub fn actor_dir(&self, id: &str) -> PathBuf { + self.root.join("actor").join(id) + } + pub fn actors_root(&self) -> PathBuf { + self.root.join("actor") + } + pub fn public_dir(&self, id: &str) -> PathBuf { + self.actor_dir(id).join("public") + } + pub fn source_dir(&self, id: &str) -> PathBuf { + self.actor_dir(id).join("source") + } + pub fn data_dir(&self, id: &str) -> PathBuf { + self.root.join("private").join(id).join("data") + } + pub fn versions_dir(&self, id: &str) -> PathBuf { + self.root.join("broker").join("versions").join(id) + } + pub fn version_dir(&self, id: &str, v: u64) -> PathBuf { + self.versions_dir(id).join(v.to_string()) + } + pub fn scratch_dir(&self) -> PathBuf { + self.root.join("broker").join("scratch") + } + + pub fn exists(&self) -> bool { + self.registry_path().is_file() + } + + /// Create an empty world with the folders of the native actors. + pub fn create(root: &Path) -> Result { + let world = World::new(std::path::absolute(root)?); + if world.exists() { + bail!("{} is already a Sausage world", root.display()); + } + std::fs::create_dir_all(&world.root)?; + world.save_registry(&Registry::default())?; + for id in NATIVE { + world.ensure_actor_dirs(id)?; + let status = serde_json::json!({"id": id, "kind": "native", "state": "built-in"}); + write_atomic(&world.actor_dir(id).join("status.json"), serde_json::to_string_pretty(&status)?.as_bytes())?; + } + Ok(world) + } + + /// Create the directories of one actor. + pub fn ensure_actor_dirs(&self, id: &str) -> Result<()> { + std::fs::create_dir_all(self.public_dir(id))?; + std::fs::create_dir_all(self.data_dir(id))?; + Ok(()) + } + + pub fn load_registry(&self) -> Result { + let text = std::fs::read_to_string(self.registry_path()) + .with_context(|| format!("no Sausage world at {}", self.root.display()))?; + Ok(serde_json::from_str(&text)?) + } + + pub fn save_registry(&self, reg: &Registry) -> Result<()> { + write_atomic(&self.registry_path(), serde_json::to_string_pretty(reg)?.as_bytes()) + } + + /// Map a path in an actor's namespace to a host path. + /// + /// - `/actor//...` is the global filesystem. + /// - `/public/...` and `/data/...` belong to `sender`. + /// - Native senders (the console) can also give absolute host paths. + pub fn resolve(&self, sender: &str, path: &str) -> Result { + let clean = |base: PathBuf, rest: &str| -> Result { + let mut out = base.clone(); + for c in Path::new(rest).components() { + match c { + std::path::Component::Normal(n) => out.push(n), + std::path::Component::CurDir | std::path::Component::RootDir => {} + _ => bail!("path must not contain '..': {path}"), + } + } + Ok(out) + }; + if let Some(rest) = path.strip_prefix("/actor/") { + return clean(self.actors_root(), rest); + } + if let Some(rest) = path.strip_prefix("/public/").or(path.strip_prefix("/public").filter(|r| r.is_empty())) { + return clean(self.public_dir(sender), rest); + } + if let Some(rest) = path.strip_prefix("/data/").or(path.strip_prefix("/data").filter(|r| r.is_empty())) { + return clean(self.data_dir(sender), rest); + } + if NATIVE.contains(&sender) && Path::new(path).is_absolute() { + return Ok(PathBuf::from(path)); + } + bail!("cannot resolve path {path} for {sender}: use /actor//..., /public/... or /data/...") + } + + /// The path of a host file in the global filesystem, if it has one. + pub fn global_path(&self, host: &Path) -> Option { + let rel = host.strip_prefix(self.actors_root()).ok()?; + Some(format!("/actor/{}", rel.display())) + } +} + +/// Write a file so that readers never see a partial version. +pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { + let dir = path.parent().context("path has no parent")?; + std::fs::create_dir_all(dir)?; + let tmp = dir.join(format!( + "{TMP_PREFIX}host-{}-{}", + std::process::id(), + path.file_name().and_then(|n| n.to_str()).unwrap_or("file") + )); + std::fs::write(&tmp, bytes)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + +/// Copy a directory tree. Skips `target/` folders and uncommitted files. +pub fn copy_tree(from: &Path, to: &Path) -> Result<()> { + std::fs::create_dir_all(to)?; + for entry in std::fs::read_dir(from).with_context(|| format!("read {}", from.display()))? { + let entry = entry?; + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with(TMP_PREFIX) { + continue; + } + let ty = entry.file_type()?; + let dst = to.join(&name); + if ty.is_dir() { + if name_str == "target" || name_str == ".git" { + continue; + } + copy_tree(&entry.path(), &dst)?; + } else if ty.is_file() { + std::fs::copy(entry.path(), &dst)?; + } + } + Ok(()) +} + +/// Replace the directory `dst` with a copy of `src`, as close to atomic as a +/// directory can be: copy to a sibling, then swap with two renames. +pub fn replace_tree(src: &Path, dst: &Path) -> Result<()> { + let parent = dst.parent().context("path has no parent")?; + let name = dst.file_name().and_then(|n| n.to_str()).unwrap_or("dir"); + let fresh = parent.join(format!("{TMP_PREFIX}new-{name}")); + let old = parent.join(format!("{TMP_PREFIX}old-{name}")); + let _ = std::fs::remove_dir_all(&fresh); + let _ = std::fs::remove_dir_all(&old); + if src.is_dir() { + copy_tree(src, &fresh)?; + } else { + std::fs::create_dir_all(&fresh)?; + } + if dst.exists() { + std::fs::rename(dst, &old)?; + } + std::fs::rename(&fresh, dst)?; + let _ = std::fs::remove_dir_all(&old); + Ok(()) +} diff --git a/examples/bench/Cargo.toml b/examples/bench/Cargo.toml new file mode 100644 index 0000000..16ab922 --- /dev/null +++ b/examples/bench/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "bench" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde_json = "1" diff --git a/examples/bench/src/main.rs b/examples/bench/src/main.rs new file mode 100644 index 0000000..61c1bc3 --- /dev/null +++ b/examples/bench/src/main.rs @@ -0,0 +1,45 @@ +//! {"type":"bench","target":"echo","n":1000}: ping-pong with the target, one +//! message at a time, and report the round-trip times. +use serde_json::{Value, json}; +use std::io::{BufRead, Write}; +use std::time::Instant; + +fn main() { + let mut out = std::io::stdout().lock(); + let mut lines = std::io::stdin().lock().lines(); + while let Some(Ok(line)) = lines.next() { + let Ok(msg) = serde_json::from_str::(&line) else { continue }; + match msg["body"]["type"].as_str() { + Some("shutdown") => break, + Some("bench") => { + let target = msg["body"]["target"].as_str().unwrap_or("echo").to_string(); + let n = msg["body"]["n"].as_u64().unwrap_or(1000); + let mut times = Vec::new(); + let started = Instant::now(); + for i in 0..n { + let t = Instant::now(); + writeln!(out, "{}", json!({"to": target, "id": i, "body": {"type": "ping", "i": i}})).unwrap(); + out.flush().unwrap(); + // Wait for the reply. + loop { + let Some(Ok(l)) = lines.next() else { return }; + let Ok(r) = serde_json::from_str::(&l) else { continue }; + if r["reply_to"].as_str().is_some_and(|s| s.ends_with(&format!("/{i}"))) { + break; + } + } + times.push(t.elapsed().as_micros() as u64); + } + let total = started.elapsed().as_secs_f64(); + times.sort(); + let p = |q: f64| times[((times.len() as f64 - 1.0) * q) as usize]; + let reply = json!({"type": "bench_result", "n": n, "total_s": total, + "msgs_per_s": (n as f64 / total).round(), + "p50_us": p(0.5), "p90_us": p(0.9), "p99_us": p(0.99), "max_us": p(1.0)}); + writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap(); + out.flush().unwrap(); + } + _ => {} + } + } +} diff --git a/examples/crasher/Cargo.toml b/examples/crasher/Cargo.toml new file mode 100644 index 0000000..af6eb0b --- /dev/null +++ b/examples/crasher/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "crasher" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/examples/crasher/src/main.rs b/examples/crasher/src/main.rs new file mode 100644 index 0000000..f581eb1 --- /dev/null +++ b/examples/crasher/src/main.rs @@ -0,0 +1,6 @@ +//! A bad version: it starts, waits 3 seconds (long enough for the smoke +//! test), then panics. Used to test the automatic rollback. +fn main() { + std::thread::sleep(std::time::Duration::from_secs(3)); + panic!("this version is broken"); +} diff --git a/examples/echo/Cargo.toml b/examples/echo/Cargo.toml new file mode 100644 index 0000000..46c5a87 --- /dev/null +++ b/examples/echo/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "echo" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde_json = "1" diff --git a/examples/echo/src/main.rs b/examples/echo/src/main.rs new file mode 100644 index 0000000..8e54d0f --- /dev/null +++ b/examples/echo/src/main.rs @@ -0,0 +1,15 @@ +use serde_json::{json, Value}; +use std::io::{BufRead, Write}; + +fn main() { + let mut out = std::io::stdout().lock(); + for line in std::io::stdin().lock().lines() { + let Ok(msg) = serde_json::from_str::(&line.unwrap()) else { continue }; + if msg["body"]["type"] == "shutdown" { + break; + } + let env = json!({"to": msg["from"], "reply_to": msg["id"], "body": {"type": "echo", "got": msg["body"]}}); + writeln!(out, "{env}").unwrap(); + out.flush().unwrap(); + } +} diff --git a/examples/fetch/Cargo.toml b/examples/fetch/Cargo.toml new file mode 100644 index 0000000..895db74 --- /dev/null +++ b/examples/fetch/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "fetch" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde_json = "1" +wstd = "0.6" diff --git a/examples/fetch/src/main.rs b/examples/fetch/src/main.rs new file mode 100644 index 0000000..f94d44c --- /dev/null +++ b/examples/fetch/src/main.rs @@ -0,0 +1,78 @@ +//! Fetches URLs over HTTP(S) and writes the body to /public/fetch/. +//! Reads stdin and runs requests at the same time: while a fetch runs, +//! `ping` still answers at once. +use serde_json::{Value, json}; +use std::cell::RefCell; +use std::rc::Rc; +use wstd::http::{Body, Client, Request}; +use wstd::io::{AsyncRead, AsyncWrite}; + +type Out = Rc>>; + +async fn flush(out: &Out) { + let lines: Vec = out.borrow_mut().drain(..).collect(); + let mut stdout = wstd::io::stdout(); + for l in lines { + let _ = stdout.write_all(format!("{l}\n").as_bytes()).await; + } + let _ = stdout.flush().await; +} + +async fn fetch(n: u64, msg: Value, out: Out) { + let url = msg["body"]["url"].as_str().unwrap_or_default().to_string(); + let started = wstd::time::Instant::now(); + let result = async { + let req = Request::get(&url).body(Body::empty()).map_err(|e| e.to_string())?; + let resp = Client::new().send(req).await.map_err(|e| format!("{e:?}"))?; + let status = resp.status().as_u16(); + let bytes = resp.into_body().contents().await.map_err(|e| format!("{e:?}"))?.to_vec(); + std::fs::create_dir_all("/public/fetch").map_err(|e| e.to_string())?; + let path = format!("/public/fetch/{n}"); + std::fs::write(&path, &bytes).map_err(|e| e.to_string())?; + Ok::<_, String>((status, bytes.len(), path)) + } + .await; + let ms = started.elapsed().as_millis(); + let body = match result { + Ok((status, size, path)) => { + let id = std::env::var("SAUSAGE_ACTOR").unwrap_or("fetch".into()); + json!({"type": "fetched", "status": status, "bytes": size, "path": path.replacen("/public", &format!("/actor/{id}/public"), 1), "ms": ms}) + } + Err(e) => json!({"type": "error", "reason": e, "ms": ms}), + }; + out.borrow_mut().push(json!({"to": msg["from"], "reply_to": msg["id"], "body": body}).to_string()); + flush(&out).await; +} + +fn main() { + wstd::runtime::block_on(async { + let out: Out = Rc::new(RefCell::new(Vec::new())); + let mut stdin = wstd::io::stdin(); + let mut buf = Vec::new(); + let mut chunk = vec![0u8; 8192]; + let mut n = 0u64; + loop { + let Ok(len) = stdin.read(&mut chunk).await else { break }; + if len == 0 { + break; + } + buf.extend_from_slice(&chunk[..len]); + while let Some(pos) = buf.iter().position(|b| *b == b'\n') { + let line: Vec = buf.drain(..=pos).collect(); + let Ok(msg) = serde_json::from_slice::(&line) else { continue }; + match msg["body"]["type"].as_str() { + Some("shutdown") => return, + Some("fetch") => { + n += 1; + wstd::runtime::spawn(fetch(n, msg, out.clone())).detach(); + } + Some("ping") => { + out.borrow_mut().push(json!({"to": msg["from"], "reply_to": msg["id"], "body": {"type": "pong"}}).to_string()); + flush(&out).await; + } + _ => {} + } + } + } + }); +} diff --git a/examples/fstest/Cargo.toml b/examples/fstest/Cargo.toml new file mode 100644 index 0000000..0b6b178 --- /dev/null +++ b/examples/fstest/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "fstest" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde_json = "1" diff --git a/examples/fstest/src/main.rs b/examples/fstest/src/main.rs new file mode 100644 index 0000000..1ac1d70 --- /dev/null +++ b/examples/fstest/src/main.rs @@ -0,0 +1,47 @@ +//! Filesystem probe for the spike. Keeps files open across messages. +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::io::{BufRead, Write}; + +fn main() { + let mut open: HashMap = HashMap::new(); + let mut out = std::io::stdout().lock(); + for line in std::io::stdin().lock().lines() { + let Ok(line) = line else { break }; + let Ok(msg) = serde_json::from_str::(&line) else { continue }; + let b = &msg["body"]; + let path = b["path"].as_str().unwrap_or_default().to_string(); + let r = |res: std::io::Result| match res { + Ok(v) => v, + Err(e) => json!({"type": "error", "error": e.to_string()}), + }; + let reply = match b["type"].as_str() { + Some("shutdown") => break, + Some("write") => r(std::fs::write(&path, b["text"].as_str().unwrap_or_default()).map(|_| json!({"type": "ok"}))), + Some("open") => r((|| { + let mut f = std::fs::File::create(&path)?; + f.write_all(b["text"].as_str().unwrap_or_default().as_bytes())?; + open.insert(path.clone(), f); + Ok(json!({"type": "ok", "open": true})) + })()), + Some("append") => r((|| { + let mut f = std::fs::OpenOptions::new().append(true).open(&path)?; + f.write_all(b["text"].as_str().unwrap_or_default().as_bytes())?; + open.insert(path.clone(), f); + Ok(json!({"type": "ok", "open": true})) + })()), + Some("close") => json!({"type": "ok", "closed": open.remove(&path).is_some()}), + Some("read") => r(std::fs::read_to_string(&path).map(|t| json!({"type": "content", "text": t}))), + Some("list") => r(std::fs::read_dir(&path).map(|rd| { + let mut names: Vec = rd.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect(); + names.sort(); + json!({"type": "entries", "names": names}) + })), + Some("remove") => r(std::fs::remove_file(&path).map(|_| json!({"type": "ok"}))), + Some("crash") => panic!("asked to crash"), + _ => json!({"type": "error", "error": "unknown message"}), + }; + writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap(); + out.flush().unwrap(); + } +} diff --git a/examples/notes-v2/Cargo.toml b/examples/notes-v2/Cargo.toml new file mode 100644 index 0000000..2b0aa5f --- /dev/null +++ b/examples/notes-v2/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "notes" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde_json = "1" diff --git a/examples/notes-v2/src/main.rs b/examples/notes-v2/src/main.rs new file mode 100644 index 0000000..4d539d5 --- /dev/null +++ b/examples/notes-v2/src/main.rs @@ -0,0 +1,39 @@ +//! Keeps a list of notes in /data/notes.json and publishes them as +//! /public/notes.txt. Version 2: adds `count` and no longer crashes. +use serde_json::{Value, json}; +use std::io::{BufRead, Write}; + +const DATA: &str = "/data/notes.json"; + +fn save(notes: &[String]) { + std::fs::write(DATA, serde_json::to_string(notes).unwrap()).unwrap(); + std::fs::write("/public/notes.txt", notes.join("\n") + "\n").unwrap(); +} + +fn main() { + let mut notes: Vec = std::fs::read_to_string(DATA) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + let mut out = std::io::stdout().lock(); + for line in std::io::stdin().lock().lines() { + let Ok(line) = line else { break }; + let Ok(msg) = serde_json::from_str::(&line) else { continue }; + let body = &msg["body"]; + let reply = match body["type"].as_str() { + Some("shutdown") => break, + Some("add") => { + notes.push(body["text"].as_str().unwrap_or_default().to_string()); + save(¬es); + json!({"type": "added", "count": notes.len()}) + } + Some("list") => json!({"type": "notes", "items": notes}), + Some("count") => json!({"type": "count", "count": notes.len(), "version": 2}), + // Version 2 fixes the crash. + Some("crash") => json!({"type": "not_crashing", "version": 2}), + _ => json!({"type": "error", "reason": "unknown message"}), + }; + writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap(); + out.flush().unwrap(); + } +} diff --git a/examples/notes-v2/tests/basic.json b/examples/notes-v2/tests/basic.json new file mode 100644 index 0000000..6900f67 --- /dev/null +++ b/examples/notes-v2/tests/basic.json @@ -0,0 +1,9 @@ +{ + "description": "version 2 counts notes and no longer crashes", + "steps": [ + {"send": {"type": "add", "text": "test note"}, "expect": {"type": "added"}}, + {"send": {"type": "list"}, "expect": {"type": "notes"}}, + {"send": {"type": "count"}, "expect": {"type": "count", "version": 2}}, + {"send": {"type": "crash"}, "expect": {"type": "not_crashing"}} + ] +} diff --git a/examples/notes/Cargo.toml b/examples/notes/Cargo.toml new file mode 100644 index 0000000..2b0aa5f --- /dev/null +++ b/examples/notes/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "notes" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde_json = "1" diff --git a/examples/notes/src/main.rs b/examples/notes/src/main.rs new file mode 100644 index 0000000..e63137d --- /dev/null +++ b/examples/notes/src/main.rs @@ -0,0 +1,37 @@ +//! Keeps a list of notes in /data/notes.json and publishes them as +//! /public/notes.txt. +use serde_json::{Value, json}; +use std::io::{BufRead, Write}; + +const DATA: &str = "/data/notes.json"; + +fn save(notes: &[String]) { + std::fs::write(DATA, serde_json::to_string(notes).unwrap()).unwrap(); + std::fs::write("/public/notes.txt", notes.join("\n") + "\n").unwrap(); +} + +fn main() { + let mut notes: Vec = std::fs::read_to_string(DATA) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + let mut out = std::io::stdout().lock(); + for line in std::io::stdin().lock().lines() { + let Ok(line) = line else { break }; + let Ok(msg) = serde_json::from_str::(&line) else { continue }; + let body = &msg["body"]; + let reply = match body["type"].as_str() { + Some("shutdown") => break, + Some("add") => { + notes.push(body["text"].as_str().unwrap_or_default().to_string()); + save(¬es); + json!({"type": "added", "count": notes.len()}) + } + Some("list") => json!({"type": "notes", "items": notes}), + Some("crash") => panic!("asked to crash"), + _ => json!({"type": "error", "reason": "unknown message"}), + }; + writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap(); + out.flush().unwrap(); + } +} diff --git a/examples/notes/tests/basic.json b/examples/notes/tests/basic.json new file mode 100644 index 0000000..1f7a84b --- /dev/null +++ b/examples/notes/tests/basic.json @@ -0,0 +1,7 @@ +{ + "description": "add a note, then list the notes", + "steps": [ + {"send": {"type": "add", "text": "test note"}, "expect": {"type": "added"}}, + {"send": {"type": "list"}, "expect": {"type": "notes"}} + ] +} diff --git a/examples/ticker/Cargo.toml b/examples/ticker/Cargo.toml new file mode 100644 index 0000000..dea727b --- /dev/null +++ b/examples/ticker/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "ticker" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde_json = "1" diff --git a/examples/ticker/src/main.rs b/examples/ticker/src/main.rs new file mode 100644 index 0000000..2aeef21 --- /dev/null +++ b/examples/ticker/src/main.rs @@ -0,0 +1,16 @@ +//! Sends {"type":"add","text":"tick N"} to `notes` every 50 ms, N = 1..=200. +//! Does not read stdin, so the broker kills it at shutdown; that is fine. +use std::io::Write; +fn main() { + let mut out = std::io::stdout().lock(); + for n in 1..=200 { + let msg = serde_json::json!({"to": "notes", "body": {"type": "add", "text": format!("tick {n}")}}); + writeln!(out, "{msg}").unwrap(); + out.flush().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + // Stay alive without doing anything. + loop { + std::thread::sleep(std::time::Duration::from_secs(3600)); + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..2866b85 --- /dev/null +++ b/flake.lock @@ -0,0 +1,48 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1791637130, + "narHash": "sha256-TuvwvSjJrpqyIYdjhQ7Wu+ADVfeOpO2Um21/V7TOubw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ca1659429c950a11a565b5764e37a1b704e17ec8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1791962566, + "narHash": "sha256-rEB0dvSZw1niBVJvt0AiWygNS/OMdA81lquTzLIWuvo=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "8111d2b45adcbaeaff037f996643f1ac43f944e6", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..27303c6 --- /dev/null +++ b/flake.nix @@ -0,0 +1,58 @@ +{ + description = "Sausage: grow a system of WASI actors by talking to it"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + rust-overlay.url = "github:oxalica/rust-overlay"; + rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = { nixpkgs, rust-overlay, ... }: + let + systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forAll = f: nixpkgs.lib.genAttrs systems (system: f (import nixpkgs { + inherit system; + overlays = [ rust-overlay.overlays.default ]; + })); + # The toolchain that builds the host and, at runtime, the actors. + rustFor = pkgs: pkgs.rust-bin.stable.latest.default.override { + targets = [ "wasm32-wasip2" ]; + extensions = [ "rust-src" ]; + }; + sausageFor = pkgs: + let + rust = rustFor pkgs; + rustPlatform = pkgs.makeRustPlatform { cargo = rust; rustc = rust; }; + in + rustPlatform.buildRustPackage { + pname = "sausage"; + version = "0.1.0"; + src = ./.; + cargoLock.lockFile = ./Cargo.lock; + cargoBuildFlags = [ "-p" "sausage" ]; + doCheck = false; + nativeBuildInputs = [ pkgs.makeWrapper ]; + # The `cargo` actor runs cargo at runtime. Build scripts need a C linker. + postInstall = '' + wrapProgram $out/bin/sausage --prefix PATH : ${pkgs.lib.makeBinPath [ rust pkgs.stdenv.cc ]} + ''; + meta.mainProgram = "sausage"; + }; + in + { + packages = forAll (pkgs: { default = sausageFor pkgs; }); + + apps = forAll (pkgs: { + default = { type = "app"; program = "${sausageFor pkgs}/bin/sausage"; }; + }); + + devShells = forAll (pkgs: { + default = pkgs.mkShell { + packages = [ (rustFor pkgs) pkgs.wasmtime pkgs.wasm-tools ]; + shellHook = '' + export CARGO_HOME="$PWD/.cargo-home" + ''; + }; + }); + }; +} diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml new file mode 100644 index 0000000..7664bf0 --- /dev/null +++ b/sdk/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "sausage-actor" +version = "0.1.0" +edition = "2024" +description = "SDK for Sausage actors." + +[dependencies] diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs new file mode 100644 index 0000000..78ef62c --- /dev/null +++ b/sdk/src/lib.rs @@ -0,0 +1 @@ +//! SDK for Sausage actors (placeholder).