From c44621f1b428b53bda41082250c3b777488e8d00 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Thu, 18 Jun 2026 10:59:17 -0700 Subject: [PATCH 1/2] feat(rivetkit): native actor plugin ABI + host loader + napi cut + integration tests Rebased onto main (2.3.2). Adds the native actor-plugin host (createNativePluginFactory, ABI host loader) so external cdylib plugins (e.g. agent-os) load via dlopen; removes the in-tree bundled agent-os; keeps main's engine auto-spawn + runner-config. --- .github/workflows/publish.yaml | 3 + .gitignore | 3 + .npmrc | 1 + Cargo.lock | 154 +- Cargo.toml | 6 + PLAN2.md | 276 +++ RIVETKIT_RUST_FIX.md | 226 +++ TODOLIST.md | 415 ++++ engine/artifacts/errors/actor.not_found.json | 2 +- engine/sdks/typescript/api-full/tsconfig.json | 1 + examples/agent-os-e2e/.gitignore | 1 + examples/agent-os-e2e/package.json | 7 +- .../scripts/prepare-agent-modules.mjs | 39 + examples/agent-os-e2e/src/client.ts | 9 +- examples/agent-os-e2e/src/server.ts | 18 +- examples/agent-os/package.json | 15 +- examples/agent-os/src/agent-session/server.ts | 2 +- examples/agent-os/src/cron/server.ts | 2 +- examples/agent-os/src/filesystem/server.ts | 4 +- examples/agent-os/src/git/server.ts | 2 +- examples/agent-os/src/hello-world/server.ts | 2 +- examples/agent-os/src/network/server.ts | 2 +- examples/agent-os/src/processes/server.ts | 2 +- examples/agent-os/src/sandbox/server.ts | 2 +- examples/agent-os/src/tools/server.ts | 2 +- package.json | 1 + pnpm-lock.yaml | 1062 +++++++--- rivetkit-rust/Cargo.toml | 9 - rivetkit-rust/packages/client/src/encoding.rs | 66 + rivetkit-rust/packages/client/src/lib.rs | 1 + .../packages/client/src/protocol/codec.rs | 14 +- .../packages/client/tests/encoding.rs | 85 + .../rivet-actor-plugin-abi/Cargo.toml | 16 + .../rivet-actor-plugin-abi/src/codec.rs | 991 ++++++++++ .../rivet-actor-plugin-abi/src/lib.rs | 525 +++++ .../rivet-actor-plugin-abi/src/portable.rs | 1553 +++++++++++++++ .../rivet-actor-test-plugin/Cargo.toml | 18 + .../rivet-actor-test-plugin/src/lib.rs | 714 +++++++ .../packages/rivetkit-core/Cargo.toml | 4 + .../rivetkit-core/src/actor/context.rs | 20 + .../packages/rivetkit-core/src/actor/mod.rs | 6 + .../rivetkit-core/src/actor/native_plugin.rs | 1758 +++++++++++++++++ .../src/actor/portable_native.rs | 660 +++++++ .../packages/rivetkit-core/src/lib.rs | 4 + .../rivetkit-core/tests/integration.rs | 4 + .../tests/integration/common/ctx.rs | 20 +- .../tests/integration/counter.rs | 904 ++++++++- .../tests/native_plugin_integration.rs | 1016 ++++++++++ rivetkit-rust/packages/rivetkit/Cargo.toml | 6 + .../packages/rivetkit/src/encoding.rs | 423 ++++ rivetkit-rust/packages/rivetkit/src/event.rs | 91 +- rivetkit-rust/packages/rivetkit/src/lib.rs | 1 + .../packages/rivetkit/tests/encoding.rs | 140 ++ .../rivetkit/tests/encoding_fixtures.rs | 121 ++ .../rivetkit/tests/encoding_roundtrip.rs | 144 ++ .../tests/fixtures/encoding/plain_string.json | 1 + .../encoding/struct_with_byte_field.json | 7 + .../fixtures/encoding/uint8array_1234.json | 4 + .../fixtures/encoding/uint8array_hello.json | 4 + .../tests/fixtures/encoding/virtual_stat.json | 17 + .../test/exports-files-packaging.test.ts | 117 ++ .../packages/rivetkit-napi/Cargo.toml | 3 + .../packages/rivetkit-napi/index.d.ts | 13 + .../rivetkit-napi/src/actor_factory.rs | 62 + .../fixtures/driver-test-suite/agent-os.ts | 4 - .../driver-test-suite/registry-static.ts | 18 - .../packages/rivetkit/package.json | 15 +- .../packages/rivetkit/src/actor/definition.ts | 20 + .../rivetkit/src/agent-os/actor/cron.ts | 84 - .../rivetkit/src/agent-os/actor/db.ts | 54 - .../rivetkit/src/agent-os/actor/filesystem.ts | 131 -- .../rivetkit/src/agent-os/actor/index.ts | 285 --- .../rivetkit/src/agent-os/actor/network.ts | 58 - .../rivetkit/src/agent-os/actor/preview.ts | 190 -- .../rivetkit/src/agent-os/actor/process.ts | 177 -- .../rivetkit/src/agent-os/actor/session.ts | 518 ----- .../rivetkit/src/agent-os/actor/shell.ts | 60 - .../packages/rivetkit/src/agent-os/config.ts | 79 - .../rivetkit/src/agent-os/fs/database-vfs.ts | 624 ------ .../packages/rivetkit/src/agent-os/index.ts | 65 - .../packages/rivetkit/src/agent-os/types.ts | 148 -- .../src/drivers/engine/actor-driver.ts | 28 + .../packages/rivetkit/src/mod.ts | 18 + .../rivetkit/src/registry/napi-runtime.ts | 8 + .../packages/rivetkit/src/registry/native.ts | 11 +- .../packages/rivetkit/src/registry/runtime.ts | 23 +- .../tests/byte-encoding-parity.test.ts | 132 ++ .../rivetkit/tests/cjs-no-import-meta.test.ts | 96 + .../tests/driver/actor-agent-os.test.ts | 306 --- .../rivetkit/tests/driver/shared-types.ts | 1 - .../fixtures/native-plugin-runtime-server.ts | 66 + .../tests/native-plugin-runtime.test.ts | 321 +++ .../packages/rivetkit/tsconfig.json | 3 +- .../packages/rivetkit/tsup.config.ts | 1 - scripts/publish/src/lib/version.ts | 1 + .../content/docs/agent-os/system-prompt.mdx | 2 +- 96 files changed, 12030 insertions(+), 3318 deletions(-) create mode 100644 .npmrc create mode 100644 PLAN2.md create mode 100644 RIVETKIT_RUST_FIX.md create mode 100644 TODOLIST.md create mode 100644 examples/agent-os-e2e/.gitignore create mode 100644 examples/agent-os-e2e/scripts/prepare-agent-modules.mjs delete mode 100644 rivetkit-rust/Cargo.toml create mode 100644 rivetkit-rust/packages/client/src/encoding.rs create mode 100644 rivetkit-rust/packages/client/tests/encoding.rs create mode 100644 rivetkit-rust/packages/rivet-actor-plugin-abi/Cargo.toml create mode 100644 rivetkit-rust/packages/rivet-actor-plugin-abi/src/codec.rs create mode 100644 rivetkit-rust/packages/rivet-actor-plugin-abi/src/lib.rs create mode 100644 rivetkit-rust/packages/rivet-actor-plugin-abi/src/portable.rs create mode 100644 rivetkit-rust/packages/rivet-actor-test-plugin/Cargo.toml create mode 100644 rivetkit-rust/packages/rivet-actor-test-plugin/src/lib.rs create mode 100644 rivetkit-rust/packages/rivetkit-core/src/actor/native_plugin.rs create mode 100644 rivetkit-rust/packages/rivetkit-core/src/actor/portable_native.rs create mode 100644 rivetkit-rust/packages/rivetkit-core/tests/native_plugin_integration.rs create mode 100644 rivetkit-rust/packages/rivetkit/src/encoding.rs create mode 100644 rivetkit-rust/packages/rivetkit/tests/encoding.rs create mode 100644 rivetkit-rust/packages/rivetkit/tests/encoding_fixtures.rs create mode 100644 rivetkit-rust/packages/rivetkit/tests/encoding_roundtrip.rs create mode 100644 rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/plain_string.json create mode 100644 rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/struct_with_byte_field.json create mode 100644 rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/uint8array_1234.json create mode 100644 rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/uint8array_hello.json create mode 100644 rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/virtual_stat.json create mode 100644 rivetkit-typescript/packages/engine-runner-protocol/test/exports-files-packaging.test.ts delete mode 100644 rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/agent-os.ts delete mode 100644 rivetkit-typescript/packages/rivetkit/src/agent-os/actor/cron.ts delete mode 100644 rivetkit-typescript/packages/rivetkit/src/agent-os/actor/db.ts delete mode 100644 rivetkit-typescript/packages/rivetkit/src/agent-os/actor/filesystem.ts delete mode 100644 rivetkit-typescript/packages/rivetkit/src/agent-os/actor/index.ts delete mode 100644 rivetkit-typescript/packages/rivetkit/src/agent-os/actor/network.ts delete mode 100644 rivetkit-typescript/packages/rivetkit/src/agent-os/actor/preview.ts delete mode 100644 rivetkit-typescript/packages/rivetkit/src/agent-os/actor/process.ts delete mode 100644 rivetkit-typescript/packages/rivetkit/src/agent-os/actor/session.ts delete mode 100644 rivetkit-typescript/packages/rivetkit/src/agent-os/actor/shell.ts delete mode 100644 rivetkit-typescript/packages/rivetkit/src/agent-os/config.ts delete mode 100644 rivetkit-typescript/packages/rivetkit/src/agent-os/fs/database-vfs.ts delete mode 100644 rivetkit-typescript/packages/rivetkit/src/agent-os/index.ts delete mode 100644 rivetkit-typescript/packages/rivetkit/src/agent-os/types.ts create mode 100644 rivetkit-typescript/packages/rivetkit/tests/byte-encoding-parity.test.ts create mode 100644 rivetkit-typescript/packages/rivetkit/tests/cjs-no-import-meta.test.ts delete mode 100644 rivetkit-typescript/packages/rivetkit/tests/driver/actor-agent-os.test.ts create mode 100644 rivetkit-typescript/packages/rivetkit/tests/fixtures/native-plugin-runtime-server.ts create mode 100644 rivetkit-typescript/packages/rivetkit/tests/native-plugin-runtime.test.ts diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index e28c13555e..8db8122374 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -525,6 +525,9 @@ jobs: --version-only # ---- build TypeScript packages (turbo dep graph picks up native) ---- + # The napi `.node` is built in the dedicated build job and placed above; + # types are committed. SKIP_NAPI_BUILD avoids the publish runner (no Rust + # toolchain) recompiling napi on a turbo cache miss (e.g. Cargo.lock churn). - name: Build TypeScript packages env: SKIP_WASM_BUILD: "1" diff --git a/.gitignore b/.gitignore index 8ecdc765ff..42c889c3b0 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,6 @@ scripts/ralph/codex-streams/ # Agent working files now live user-scoped in ~/.agents (or $AGENTS_DIR). # This entry is a safety net so a stray in-repo .agent/ never gets committed. .agent/ + +# Local rivet-engine binary placed for engine-cli getEnginePath resolution +rivetkit-typescript/packages/engine-cli/rivet-engine diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000000..d67f374883 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +node-linker=hoisted diff --git a/Cargo.lock b/Cargo.lock index 6e25dbf517..605c370747 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1435,12 +1435,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -1761,7 +1761,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -3044,7 +3044,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.53.2", + "windows-targets 0.52.6", ] [[package]] @@ -3385,9 +3385,9 @@ dependencies = [ [[package]] name = "napi-build" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" +checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" [[package]] name = "napi-derive" @@ -3571,9 +3571,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -4616,7 +4616,7 @@ dependencies = [ "once_cell", "socket2 0.6.0", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -4949,6 +4949,29 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rivet-actor-plugin-abi" +version = "2.3.2" +dependencies = [ + "anyhow", + "base64 0.22.1", + "ciborium", + "serde", +] + +[[package]] +name = "rivet-actor-test-plugin" +version = "2.3.2" +dependencies = [ + "anyhow", + "ciborium", + "futures", + "rivet-actor-plugin-abi", + "serde", + "serde_bytes", + "serde_json", +] + [[package]] name = "rivet-api-builder" version = "2.3.2" @@ -6014,6 +6037,7 @@ dependencies = [ "anyhow", "async-trait", "axum 0.8.4", + "base64 0.22.1", "bytes", "ciborium", "futures", @@ -6025,6 +6049,7 @@ dependencies = [ "rivetkit-client-protocol", "rivetkit-core", "serde", + "serde_bytes", "serde_json", "tokio", "tokio-util", @@ -6103,11 +6128,14 @@ dependencies = [ "http-body-util", "include_dir", "js-sys", + "libloading", "nix 0.30.1", "parking_lot", "portpicker", "rand 0.8.5", "reqwest 0.12.22", + "rivet-actor-plugin-abi", + "rivet-actor-test-plugin", "rivet-depot-client", "rivet-depot-client-types", "rivet-envoy-client", @@ -6173,6 +6201,8 @@ version = "2.3.2" dependencies = [ "anyhow", "async-trait", + "base64 0.22.1", + "ciborium", "hex", "http 1.3.1", "napi", @@ -6185,6 +6215,7 @@ dependencies = [ "rivetkit-core", "scc", "serde", + "serde_bytes", "serde_json", "tokio", "tokio-util", @@ -6298,21 +6329,21 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] name = "rustls" -version = "0.23.29" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.4", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] @@ -6362,7 +6393,7 @@ dependencies = [ "rustls", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.4", + "rustls-webpki 0.103.13", "security-framework", "security-framework-sys", "webpki-root-certs", @@ -6387,9 +6418,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.4" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -7509,9 +7540,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa 1.0.15", @@ -7519,22 +7550,22 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -8933,15 +8964,6 @@ 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.2", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -8975,29 +8997,13 @@ 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_gnullvm", "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.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" -dependencies = [ - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", -] - [[package]] name = "windows-threading" version = "0.1.0" @@ -9019,12 +9025,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -9037,12 +9037,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -9055,24 +9049,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" - [[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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -9085,12 +9067,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -9103,12 +9079,6 @@ 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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -9121,12 +9091,6 @@ 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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -9139,12 +9103,6 @@ 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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" - [[package]] name = "winnow" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index 81a1be4e96..ff2e9ae897 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,8 @@ members = [ "engine/sdks/rust/test-envoy", "engine/sdks/rust/ups-protocol", "rivetkit-rust/packages/actor-persist", + "rivetkit-rust/packages/rivet-actor-plugin-abi", + "rivetkit-rust/packages/rivet-actor-test-plugin", "rivetkit-rust/packages/client", "rivetkit-rust/packages/engine-process", "rivetkit-rust/packages/rivetkit", @@ -617,6 +619,10 @@ members = [ path = "engine/packages/depot-client-types" version = "=2.3.2" + [workspace.dependencies.rivet-actor-plugin-abi] + path = "rivetkit-rust/packages/rivet-actor-plugin-abi" + version = "=2.3.2" + [workspace.dependencies.rivetkit-core] path = "rivetkit-rust/packages/rivetkit-core" version = "=2.3.2" diff --git a/PLAN2.md b/PLAN2.md new file mode 100644 index 0000000000..7dd73a671c --- /dev/null +++ b/PLAN2.md @@ -0,0 +1,276 @@ +# Plan 2 — agent-os integration + +NAPI-only Rust-backed agent-os actor for rivetkit. Written after a first attempt overcomplicated the architecture; reviewed by 5 subagents (architecture, feasibility, test strategy, risks, adversarial) and corrected based on findings. + +--- + +## Scope + +**In:** NAPI-only agent-os actor. JS users on the native runtime call `agentOs(config)` and get a working Rust-backed actor. + +**Out:** wasm runtime (agent-os-client uses `tokio::process`, native-only). Rust-direct registration (no caller). Future actor kinds beyond agent-os. + +--- + +## Architectural principles + +1. **One NAPI class, one register method.** No separate `NapiAgentOsDefinition`. agent-os produces a `NapiActorFactory` via a static constructor and registers through the existing `register(name, &NapiActorFactory)` path. +2. **Discriminator via marker field, not class hierarchy.** `ActorDefinition` grows an optional `nativeFactoryBuilder?: (runtime: CoreRuntime) => ActorFactoryHandle` field. Two call sites (registry loop, engine driver ladder) check that one field. +3. **Validation drives ordering.** Get one test through the driver suite (the real end-to-end gate) before building anything else. No structural scaffolding before signal. +4. **No premature abstractions.** No `HostToolInvoker` trait. No `entry_fn` + `actor_config` + `build_core_factory` three-way API — just `build_core_factory(config) -> CoreActorFactory`. No event broadcast constants without feeders. Wire DTOs land in the same PR as their first feeder action, never alone. +5. **User config is load-bearing from Day 1 for the serializable subset.** `software`, `loopbackExemptPorts`, `allowedNodeBuiltins`, `moduleAccessCwd`, `additionalInstructions`, `permissions`, `rootFilesystem`, `sidecar.Shared`, plain `mounts` flow through. Non-serializable fields (`mounts[].driver` callbacks, `scheduleDriver`, `sidecar.Explicit`, `onBeforeConnect`/`onSessionEvent`/`onPermissionRequest` callbacks) **fail loud** with a clear "not yet supported" error. Never silently drop user config. +6. **TypeScript is the source of truth for the wire protocol.** Byte payloads use the rivetkit convention `["$Uint8Array", base64]` because that's what TS emits and expects. The Rust framework must mirror it on both encode and decode sides (see RIVETKIT_RUST_FIX.md — this is a framework fix, not agent-os work). + +--- + +## Architecture + +### Rust crate `rivetkit-agent-os` (native-only) + +``` +src/ + lib.rs — pub fn build_core_factory(config: AgentOsActorConfig) + -> CoreActorFactory + actor.rs — AgentOsActor marker (impl Actor) + config.rs — AgentOsActorConfig (closure factory for AgentOsConfig + so we can rebuild it across sleep/wake cycles) + run.rs — event loop: ensure_vm, shutdown_vm, dispatch + actions/ + mod.rs — pub async fn dispatch(ctx, vm, action) + filesystem.rs — read_file, write_file, ... + session.rs — create_session, send_prompt, ... + process.rs — exec, spawn, ... + shell.rs — open_shell, ... + cron.rs — schedule_cron, ... + network.rs — vm_fetch + preview.rs — create_signed_preview_url, expire_signed_preview_url + events.rs — Subscriptions: per-VM cron broadcast + per-entity + session/process/shell broadcasts wired from + dispatcher arms when entities are created + persistence.rs — MIGRATION_SQL + migrate(sql) + preview_http.rs — handle(ctx, vm, http) for /fetch/{token}/path + state.rs — RunState counters (populated by dispatcher arms) +``` + +One public function: `build_core_factory(config) -> CoreActorFactory`. No separate `AgentOsActorDefinition` struct, no `entry_fn`/`actor_config` accessors. + +### NAPI binding `rivetkit-napi` + +Add a static constructor to the existing `NapiActorFactory`: + +```rust +#[napi] +impl NapiActorFactory { + /// Existing constructor for JS-callback actors. + #[napi(constructor)] + pub fn constructor(callbacks: JsObject, config: Option) + -> napi::Result { ... } + + /// New: build a NapiActorFactory backed by rivetkit-agent-os. + /// Walks `tool_callbacks` JsObject to extract execute fns, + /// builds AgentOsActorConfig from `options`, calls + /// `rivetkit_agent_os::build_core_factory(config)`. + #[napi(factory)] + pub fn from_agent_os( + options: NapiAgentOsOptions, + tool_callbacks: Option, + ) -> napi::Result { ... } +} +``` + +Zero changes to the existing `register` method. + +### TypeScript `rivetkit` + +Three small changes: + +**Change 1: `AnyActorDefinition` and `ActorDefinition` grow an optional field.** +```ts +export interface AnyActorDefinition { + readonly config: any; + readonly nativeFactoryBuilder?: (runtime: CoreRuntime) => ActorFactoryHandle; +} +``` + +The `ActorDefinition` class gets the field as a defaultable instance property so `instanceof ActorDefinition` still works downstream. + +**Change 2: `agentOs(config)` returns a real `ActorDefinition` instance with `nativeFactoryBuilder` set.** + +Lazy — `agentOs()` runs at module load time before the runtime is selected. The builder runs at registration time when the runtime is known. Mirrors the Cloudflare Workers global-scope rule. + +```ts +// src/agent-os/actor/index.ts +export function agentOs(config): ActorDefinition<...> { + const parsed = agentOsActorConfigSchema.parse(config); + const nativeFactoryBuilder = (runtime: CoreRuntime): ActorFactoryHandle => { + if (runtime.kind !== "napi") { + throw new Error( + "agentOs() requires the NAPI runtime; the wasm runtime does not support agent-os actors", + ); + } + const options = toNapiAgentOsOptions(parsed); + const toolCallbacks = extractToolCallbacks(parsed); + return runtime.createAgentOsFactory!(options, toolCallbacks); + }; + const definition = new ActorDefinition({} as any); + (definition as any).nativeFactoryBuilder = nativeFactoryBuilder; + return definition; +} +``` + +**Change 3: Dispatch lives inside `runtime.registerActor`. The registry loop is a one-liner.** + +```ts +// registry/native.ts::buildConfiguredRegistry +for (const [name, definition] of Object.entries(config.use)) { + runtime.registerActor(registry, name, definition, config); +} +``` + +```ts +// registry/napi-runtime.ts::NapiCoreRuntime +registerActor( + registry: RegistryHandle, + name: string, + definition: AnyActorDefinition, + registryConfig: RegistryConfig, +): void { + const factory = (definition as any).nativeFactoryBuilder + ? (definition as any).nativeFactoryBuilder(this) + : buildNativeFactory(this, registryConfig, definition); + asNativeRegistry(registry).register(name, asNativeFactory(factory)); +} +``` + +`registerActor`'s signature widens to take an `AnyActorDefinition` + the `RegistryConfig`. Wasm-runtime mirrors the same dispatch with its own fallback (or throws for the agent-os case). + +**Engine driver ladder** (`drivers/engine/actor-driver.ts`) — between dynamic and static branches: +```ts +} else if ((definition as any).nativeFactoryBuilder) { + // Rust-backed actor; CoreActorFactory handles everything. + // handler.actor stays undefined. +} else if (isStaticActorDefinition(definition)) { + ... +} +``` + +`handler.actor` undefined is safe because the only blocking accessor (`#loadActorHandler`) is called from hibernating-WS code that agent-os doesn't use. + +**Keep on `CoreRuntime`:** +- `createAgentOsFactory?(options, toolCallbacks): ActorFactoryHandle` — named capability seam per `rivetkit-typescript/CLAUDE.md` "Runtime Boundary" rule. Wasm throws "not supported." + +**Delete:** +- Legacy `agentOs()` body (the `actor({...})` callback bag). +- `src/agent-os/actor/{cron,db,filesystem,network,preview,process,session,shell}.ts` (the buildXActions modules). + +--- + +## Implementation order + +Each phase has its own e2e gate. See TODOLIST.md for the concrete checklist. + +**Phase 0** — prerequisites + smoke test target. + +**RIVETKIT_RUST_FIX** — precondition. Framework byte-encoding fix (see RIVETKIT_RUST_FIX.md). + +**Phase 1a** — Rust crate skeleton + ONE action (`readFile`) + dispatcher e2e against real sidecar. No NAPI, no engine, no JS. + +**Phase 1b** — NAPI `NapiActorFactory::from_agent_os` + focused Vitest. 1a still passes. + +**Phase 1c** — JS shim + ladder branches + first driver-suite cell green (bare encoding). 1a + 1b still pass. *This is the "architecture is real" milestone.* + +**Phase 2** — cross-encoding parity (cbor + json work without per-action fixes thanks to RIVETKIT_RUST_FIX). Test one structured-object action across all three encodings. + +**Phase 3** — action surface buildout, category by category. Driver suite no-bail at each category. + +**Phase 4** — persistence + preview HTTP handler. + +**Phase 5** — toolkit callbacks (highest risk). + +**Phase 6** — cleanup + docs. + +--- + +## Tests + +| Layer | Test file | Purpose | +|---|---|---| +| Unit | `rivetkit-agent-os/tests/persistence.rs` | MIGRATION_SQL valid via rusqlite | +| Unit | `rivetkit-agent-os/tests/preview_http.rs` | path parser + token generator | +| Helper-e2e | `rivetkit-agent-os/tests/end_to_end.rs` | helpers against real sidecar (gated) | +| Dispatcher-e2e | `rivetkit-agent-os/tests/dispatcher_e2e.rs` | dispatch arms against real sidecar (gated) | +| Cutover | `rivetkit-typescript/.../tests/agent-os-cutover.test.ts` | `agentOs()` returns expected shape | +| End-to-end | `rivetkit-typescript/.../tests/driver/actor-agent-os.test.ts` | full chain via engine + sidecar | +| Sleep/wake | `rivetkit-typescript/.../tests/driver/actor-agent-os-sleep.test.ts` | VM recreates with user config on wake; catches the "default VM after wake" regression | + +**Inner loop:** `dispatcher_e2e.rs` is the per-save gate. ~2-second feedback against a real sidecar. No engine. + +**Boundary gate:** the driver suite at phase boundaries. **No `--bail`** at boundaries — the full suite must be green. `--bail=1` is for local fix-and-retry only. + +--- + +## Review checklist (every PR) + +**No premature scaffolding:** +- [ ] Every wire DTO receives a real value from a real caller in this PR. No DTOs added "for the next action." +- [ ] Every event broadcast name constant is fed by an actual `Subscriptions::spawn_*` call. +- [ ] Every `RunState` counter is incremented in the dispatcher arm that creates the entity AND decremented in the arm that destroys it. +- [ ] No abstraction (trait, generic wrapper) with one concrete impl. +- [ ] No orphan methods left after refactors (grep the workspace). + +**Full data flow, no stubs:** +- [ ] User config flows from `agentOs(config)` → `NapiAgentOsOptions` → `AgentOsActorConfig` → `AgentOsConfig` for every plain-data field, OR an explicit fail-loud error is raised for non-serializable fields. + +**Wire format hygiene:** +- [ ] Byte payloads (top-level and nested) go through the RIVETKIT_RUST_FIX `Action::ok` wrapping. Never raw `serde_bytes::ByteBuf` or `Vec` to client. + +**Cleanup discipline:** +- [ ] No legacy code paths left as "fallback" — if the new path is the path, the old path is deleted in the same PR. +- [ ] No diagnostic `console.error` / `tracing::debug!` added during debugging that survives commit. + +**Verified, not just compiled:** +- [ ] No commit message says "verified" without naming the exact test (file + name) that was run and passed. +- [ ] `cargo check` and `pnpm tsc --noEmit` are baselines, not gates. The actual gate is the e2e test from the relevant sub-phase. + +**Sub-phase regression check:** +- [ ] All prior sub-phase e2e tests still pass after each subsequent change. + +**Build hygiene:** +- [ ] If Rust touched, NAPI artifact rebuilt (`pnpm --filter @rivetkit/rivetkit-napi build:force`). +- [ ] Driver suite ran at phase boundaries without `--bail`. + +Note on `pnpm build` for `rivetkit/`: not required for src edits — `vitest.config.ts` uses `vite-tsconfig-paths` so `rivetkit/agent-os` resolves to `src/agent-os/index.ts` during test runs. `dist/` build matters only for external consumers. + +--- + +## Resolved questions + +1. **`nativeFactoryBuilder` typing:** `ActorFactoryHandle` opaque. The engine driver passes it through to `runtime.registerActor` and never inspects. +2. **Construction timing:** lazy builder. `agentOs(config)` runs at module load before runtime selection. The registration loop calls the builder once the runtime is known. +3. **Wire DTOs location:** inline in each action module. +4. **`skip.agentOs` on wasm:** `createWasmDriverTestConfig` defaults it to `true`. + +## Open question to resolve at Phase 1 start + +**Fail-loud strategy for non-serializable config fields.** Decide which fields throw vs. silently drop with a warning. The conservative call is fail-loud for everything that can't round-trip cleanly. Decide before Phase 1b's `NapiAgentOsOptions` shape lands. + +--- + +## Slip risk + +| Phase | Slip risk | +|---|---| +| 0 | Low — clean state | +| RIVETKIT_RUST_FIX | Medium — custom serializer adapter; well-defined | +| 1a | Low — `dispatcher_e2e.rs` is a focused gate | +| 1b | Low — `#[napi(factory)]` is established | +| 1c | Low if discovered; Medium if a new layer-mismatch surfaces | +| 2 | Low — Parts 1+2 of the fix handle byte encoding | +| 3 | Low — pattern repeats per arm | +| 4 | Low | +| 5 | **High** — TSF lifetime + cancellation token bridging across `napi_actor_events.rs` is a known minefield | +| 6 | Low | + +**Rollback for high-risk phases:** +- RIVETKIT_RUST_FIX: ship Phase 3 with `bare`-encoding-only and document. +- Phase 5: ship Phases 0–4 without host-tool support, document, merge. The other 50+ actions are usable without tools. diff --git a/RIVETKIT_RUST_FIX.md b/RIVETKIT_RUST_FIX.md new file mode 100644 index 0000000000..ce9436dce8 --- /dev/null +++ b/RIVETKIT_RUST_FIX.md @@ -0,0 +1,226 @@ +# Rivetkit-rust fix: `$Uint8Array` encoding parity with TS + +A narrow framework fix to bring rivetkit-rust to parity with rivetkit-typescript on **`Uint8Array` byte payloads only**. Precondition for the agent-os integration (PLAN2.md). + +**Explicitly scope-limited:** the only TS convention this implements is `JSON_COMPAT_UINT8_ARRAY`. Other JSON-compat types (`$BigInt`, `$ArrayBuffer`, `$Undefined`, `$Set`, `Date`, `RegExp`, `Error`, `Map`, etc.) are **not** in scope. They can be added when a real consumer needs them. agent-os only returns `Uint8Array`-shaped bytes (`readFile`, `vmFetch.body`, batch-read `content`); nothing else. + +**TS is the source of truth.** TS sits on at least one end of every action call. The wire convention `["$Uint8Array", base64]` is what TS emits and what TS expects. The Rust framework mirrors it on both encode and decode sides. + +--- + +## The gap + +TS rivetkit handles byte payloads transparently: + +```ts +readFile: async (c, path) => agentOs.readFile(path), // returns Uint8Array +``` + +The user returns a `Uint8Array`, the framework wraps as `["$Uint8Array", base64]`, the receiving client revives. Works across bare/cbor/json. + +Rust rivetkit has no equivalent: + +```rust +action.ok(&bytes); // Vec +``` + +Bytes round-trip cleanly on bare. On cbor and json they get mangled into a number array because the engine decodes through `serde_json::Value` (no byte variant) at `rivetkit-core/src/registry/inspector.rs::decode_cbor_json_or_null`. + +This is a framework-feature-parity miss. Every Rust-defined actor that returns bytes is silently broken on non-bare encodings. + +--- + +## TS reference + +`rivetkit-typescript/packages/rivetkit/src/common/encoding.ts:14`: + +```ts +const JSON_COMPAT_UINT8_ARRAY = "$Uint8Array"; // capital U +``` + +Encode (`encodeJsonCompatValue`): +```ts +if (input instanceof Uint8Array) { + return [JSON_COMPAT_UINT8_ARRAY, base64EncodeUint8Array(input)]; +} +``` + +Decode (`reviveJsonCompatValue`): +```ts +if (input[0] === JSON_COMPAT_UINT8_ARRAY) { + return base64DecodeToUint8Array(input[1]); +} +``` + +Applied recursively to nested byte fields. + +--- + +## Proposed fix + +Three parts. Encode + decode are essential for full parity; the engine cleanup is a follow-up. + +### Part 1 — Encode side: auto-wrap in `Action::ok` + +**Goal:** mirror TS's `Uint8Array` wrapping. When a user-returned value contains byte payloads, wrap them as `["$Uint8Array", base64]` before CBOR-encoding. Recurse into nested fields. + +```rust +// rivetkit-rust/packages/rivetkit/src/encoding.rs (new) +const JSON_COMPAT_UINT8_ARRAY: &str = "$Uint8Array"; + +pub(crate) fn encode_json_compat(value: &T, writer: &mut W) -> anyhow::Result<()> +where + T: Serialize, + W: std::io::Write, +{ + let mut adapter = JsonCompatAdapter::new(writer); + value.serialize(&mut adapter)?; + Ok(()) +} +``` + +The adapter intercepts `serialize_bytes` calls (`serde_bytes::ByteBuf`, `serde_bytes::Bytes`, `&[u8]` via `serde_bytes`) and emits the 2-element array shape. Plain `Vec` keeps default behavior (CBOR array) — users opting into `Uint8Array` semantics annotate `#[serde(with = "serde_bytes")]`. Matches TS's explicit `Uint8Array` vs other-typed-array distinction. + +All other serde calls pass through to ciborium unchanged. No `$BigInt`, `$ArrayBuffer`, `$Set`, `$Undefined` handling. Other types encode as ciborium's default — same as today. + +Swap `Action::ok` (and `WfHistory::reply`, `WfReplay::reply`) to use `encode_json_compat` instead of raw `ciborium::into_writer`. State serialization (`SerializeState::save`) and queue payloads stay raw — consumed by Rust, not JS. + +### Part 2 — Decode side: auto-unwrap in `rivetkit-client` + +**Goal:** mirror TS's `Uint8Array` revival. When a Rust client receives an action response containing `["$Uint8Array", base64]`, hand the caller bytes instead of the literal array. + +Where: `rivetkit-rust/packages/client/`. + +```rust +// rivetkit-rust/packages/client/src/encoding.rs (new) +pub(crate) fn revive_json_compat(value: serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Array(items) if is_uint8_array_tag(&items) => { + // ["$Uint8Array", base64] → bytes (whatever shape this crate uses) + } + serde_json::Value::Array(items) => serde_json::Value::Array( + items.into_iter().map(revive_json_compat).collect(), + ), + serde_json::Value::Object(_) => /* recurse */ value, + other => other, + } +} + +fn is_uint8_array_tag(items: &[serde_json::Value]) -> bool { + items.len() == 2 + && items[0].as_str() == Some("$Uint8Array") + && items[1].is_string() +} +``` + +Other tagged arrays (`["$BigInt", ...]`, `["$Set", ...]`) and non-tagged arrays pass through unchanged. + +Hook into the action-response decode site. Confirm `rivetkit-client`'s response shape during implementation. + +### Part 3 — Engine routing cleanup (follow-up) + +**Goal:** `rivetkit-core` shouldn't lossy-decode action responses through `serde_json::Value` in the first place. + +Audit callers of `decode_cbor_json_or_null` (`rivetkit-core/src/registry/inspector.rs:598-609`). Split: +- Inspector display path: keep `serde_json::Value` intermediate (browser tab shows bytes as base64 or hex). +- Action-response forward path: sibling that forwards encoded bytes opaquely. + +Can land any time. Parts 1+2's wrapping convention survives the lossy decode anyway — `["$Uint8Array", base64]` is JSON-native. + +--- + +## Tests + +### Part 1 — Rust encode (`rivetkit-rust/packages/rivetkit/tests/encoding.rs`) + +```rust +#[test] +fn byte_buf_wraps_as_json_compat_uint8_array() { ... } + +#[test] +fn nested_byte_field_in_struct_wraps() { + #[derive(Serialize)] + struct Reply { status: u16, body: serde_bytes::ByteBuf } + // assert intermediate["body"][0] == "$Uint8Array" + // assert intermediate["body"][1] == base64 +} + +#[test] +fn plain_vec_u8_stays_as_array() { ... } + +#[test] +fn non_byte_types_pass_through_unchanged() { ... } +``` + +### Part 2 — Rust decode (`rivetkit-rust/packages/client/tests/encoding.rs`) + +```rust +#[test] +fn json_compat_uint8_array_revives_to_bytes() { ... } + +#[test] +fn nested_byte_field_revives_inside_struct() { ... } + +#[test] +fn non_byte_arrays_pass_through() { ... } + +#[test] +fn unrelated_tagged_arrays_pass_through() { ... } +``` + +### Round-trip parity + +```rust +#[test] +fn encode_then_decode_round_trips_bytes() { + let original = b"round-trip data".to_vec(); + let value = serde_bytes::ByteBuf::from(original.clone()); + let encoded = encode_json_compat_to_vec(&value).unwrap(); + let intermediate: serde_json::Value = + ciborium::from_reader(&encoded[..]).unwrap(); + let revived = revive_json_compat(intermediate); + assert_eq!(revived_as_bytes(&revived).unwrap(), original); +} +``` + +### TS parity (cross-language) + +A Rust test in `rivetkit-rust/packages/rivetkit/tests/encoding_fixtures.rs` writes Rust-encoded output to a fixture file. A Vitest test in `rivetkit-typescript/packages/rivetkit/tests/byte-encoding-parity.test.ts` reads the fixture and asserts: +- TS `encodeJsonCompatValue` produces the same shape on the same input. +- TS `reviveJsonCompatValue` revives Rust-encoded bytes correctly. + +Keeps both sides honest. + +### End-to-end (after Parts 1+2 land) + +The agent-os driver suite cell `writeFile and readFile round-trip` should pass for cbor and json without any agent-os-specific changes. That's the all-the-way-through validation. + +--- + +## Scope + +### Part 1 — Encode (rivetkit-rust) + +- `rivetkit-rust/packages/rivetkit/src/encoding.rs` (new, ~150 lines). +- `rivetkit-rust/packages/rivetkit/src/event.rs` (~5 line change in `Action::ok`, `WfHistory::reply`, `WfReplay::reply`). +- `rivetkit-rust/packages/rivetkit/src/lib.rs` (add `pub mod encoding`). +- `rivetkit-rust/packages/rivetkit/tests/encoding.rs` (new). + +### Part 2 — Decode (rivetkit-client) + +- `rivetkit-rust/packages/client/src/encoding.rs` (new, ~120 lines). +- The action-response decode site (find via grep). +- `rivetkit-rust/packages/client/tests/encoding.rs` (new). + +### Part 3 — Engine cleanup (rivetkit-core, follow-up) + +- `rivetkit-core/src/registry/inspector.rs` (split function), plus every caller. + +--- + +## Ordering + +1. Land Parts 1 + 2 in `rivetkit-rust`. +2. Verify via the focused Rust unit tests, the round-trip test, and the cross-language TS-parity test. +3. Then proceed with PLAN2's Phase 1 (agent-os bones). +4. Part 3 (engine routing cleanup) can land any time, before or after agent-os ships. diff --git a/TODOLIST.md b/TODOLIST.md new file mode 100644 index 0000000000..8703772865 --- /dev/null +++ b/TODOLIST.md @@ -0,0 +1,415 @@ +# TODOLIST for PLAN2 + +Phase-by-phase task list. Each phase ends with an **e2e gate** that must pass before moving on, and a **STOP — discuss** marker. + +**E2E maintained at every STOP.** A regression in an earlier phase blocks progress in the current phase. **Feature debt is OK; e2e debt is not.** + +Reference: PLAN2.md (design), RIVETKIT_RUST_FIX.md (framework precondition). + +--- + +## Phase 0 — Prerequisites + smoke test target + +### Tasks + +- [ ] Verify prereqs all build: + - [ ] `cargo check -p rivetkit` + - [ ] `cargo check -p rivetkit-core` + - [ ] `cargo build -p agent-os-sidecar` + - [ ] `cargo build -p rivet-engine` + - [ ] `pnpm install` at root + - [ ] `pnpm -r --filter @rivetkit/workflow-engine --filter @rivetkit/virtual-websocket --filter @rivetkit/engine-envoy-protocol build` +- [ ] Confirm test fixture `rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/agent-os.ts` exists (it's just `agentOs({ options: { software: [common] } })` against legacy code). +- [ ] Confirm `createWasmDriverTestConfig` defaults `skip.agentOs = true` (sensible default; add if missing). +- [ ] Write the driver-suite smoke test target as a placeholder in `rivetkit-typescript/packages/rivetkit/tests/driver/actor-agent-os.test.ts`: + ```ts + test("writeFile and readFile round-trip", async (c) => { + const { client } = await setupDriverTest(c, { + ...driverTestConfig, + useRealTimers: true, + }); + const actor = client.agentOsTestActor.getOrCreate([crypto.randomUUID()]); + await actor.writeFile("/home/user/hello.txt", "hello world"); + const data = await actor.readFile("/home/user/hello.txt"); + expect(new TextDecoder().decode(data)).toBe("hello world"); + }, 60_000); + ``` + +### E2E gate + +The smoke test fails because no implementation exists yet — that's expected. The test framework must be operational (failure mode is "actor not registered" or "no agentOs implementation," not "test runner broken"). + +### STOP — discuss + +Confirm prereqs all build. Confirm the smoke test target is runnable (even if failing). Decide if anything in the prereq gate failed and how to handle. + +--- + +## RIVETKIT_RUST_FIX — precondition + +Land before Phase 1. Three parts; Parts 1+2 essential, Part 3 follow-up. + +### Tasks — Part 1 (encode) + +- [ ] Create `rivetkit-rust/packages/rivetkit/src/encoding.rs` with `JsonCompatAdapter` serializer that intercepts `serialize_bytes` and emits `["$Uint8Array", base64]`. Only the Uint8Array case. +- [ ] Add `JSON_COMPAT_UINT8_ARRAY = "$Uint8Array"` const (capital U). +- [ ] Add `pub mod encoding;` to `rivetkit-rust/packages/rivetkit/src/lib.rs`. +- [ ] Swap `encode_cbor` for `encode_json_compat` in `Action::ok` (`event.rs:196`). +- [ ] Audit other callers of `encode_cbor` in `event.rs` (`WfHistory::reply`, `WfReplay::reply`). Swap if they forward to JS clients. +- [ ] Add doc-comment to `lib.rs` referencing the TS source-of-truth convention. + +### Tasks — Part 2 (decode) + +- [ ] Create `rivetkit-rust/packages/client/src/encoding.rs` with `revive_json_compat` walker. Detect `["$Uint8Array", base64]` tagged arrays; recurse; pass through everything else. +- [ ] Hook into the action-response decode site. Find via grep for action result deserialization in `rivetkit-rust/packages/client/`. + +### Tasks — Tests + +- [ ] `rivetkit-rust/packages/rivetkit/tests/encoding.rs`: + - [ ] `byte_buf_wraps_as_json_compat_uint8_array` + - [ ] `nested_byte_field_in_struct_wraps` + - [ ] `plain_vec_u8_stays_as_array` + - [ ] `non_byte_types_pass_through_unchanged` +- [ ] `rivetkit-rust/packages/client/tests/encoding.rs`: + - [ ] `json_compat_uint8_array_revives_to_bytes` + - [ ] `nested_byte_field_revives_inside_struct` + - [ ] `non_byte_arrays_pass_through` + - [ ] `unrelated_tagged_arrays_pass_through` +- [ ] Round-trip test (encode then decode) asserting original bytes recovered. +- [ ] Cross-language parity test: + - [ ] Rust test writes fixture file. + - [ ] Vitest test reads fixture, asserts TS `encodeJsonCompatValue` matches and TS `reviveJsonCompatValue` revives correctly. + +### E2E gate + +- [ ] All Rust encoding tests pass: `cargo test -p rivetkit --test encoding`. +- [ ] All Rust client decoding tests pass: `cargo test -p rivetkit-client --test encoding`. +- [ ] Cross-language fixture parity test passes. + +### STOP — discuss + +Framework fix is solid. Confirm before adding agent-os on top. + +--- + +## Phase 1a — Rust crate + one action, dispatcher-level e2e + +### Tasks + +- [ ] Create new crate at `rivetkit-rust/packages/rivetkit-agent-os/`: + - [ ] `Cargo.toml` with deps on `rivetkit`, `rivetkit-core`, `agent-os-client`, `anyhow`, `tokio`, `tracing`, `serde`, `serde_bytes`, `ciborium`, `futures`. + - [ ] Add to root workspace members in `Cargo.toml`. + - [ ] May need to re-pin hickory-resolver to `0.26.0-beta.3` via `cargo update --precise` if the lockfile resolves to a newer beta. +- [ ] `src/actor.rs`: `AgentOsActor` marker struct implementing `Actor` with `Input=()`, `ConnParams=serde_json::Value`, `ConnState=()`, `Action=Raw`. +- [ ] `src/config.rs`: `AgentOsActorConfig` with `build_options: Arc AgentOsConfig + Send + Sync>` closure factory. Carries `SoftwareInput` with `command_dir: Option` field on the Rust DTO. +- [ ] `src/lib.rs`: `pub fn build_core_factory(config: AgentOsActorConfig) -> CoreActorFactory`. ONE public function. +- [ ] `src/run.rs`: event loop with `ensure_vm` (lazy bring-up, broadcasts `vmBooted`) and `shutdown_vm` (on Sleep/Destroy, broadcasts `vmShutdown`). +- [ ] `src/actions/mod.rs`: `pub async fn dispatch(ctx, vm, action)` with ONE arm: `readFile`. Uses `action.ok(&bytes)` which auto-wraps via the RIVETKIT_RUST_FIX adapter. + +### E2E gate — `rivetkit-agent-os/tests/dispatcher_e2e.rs` + +Gated on `AGENT_OS_SIDECAR_BIN` env var (skip if missing). + +- [ ] Build VM via `AgentOs::create(AgentOsConfig::default())`. +- [ ] Seed a known file via `vm.write_file(...)` directly (bypass dispatcher). +- [ ] Build synthetic `Action` event for `"readFile"`. +- [ ] Call `actions::dispatch(ctx, vm, action)`. +- [ ] Decode reply, assert bytes match what was written. + +Run with: +```sh +cargo build -p agent-os-sidecar +AGENT_OS_SIDECAR_BIN=$(pwd)/target/debug/agent-os-sidecar \ + cargo test -p rivetkit-agent-os --test dispatcher_e2e +``` + +Must pass. + +### STOP — discuss + +The Rust crate works against a real sidecar at the dispatcher layer. Confirm before adding NAPI. + +--- + +## Phase 1b — NAPI binding, NAPI-level e2e + +### Tasks + +- [ ] `NapiAgentOsOptions` `#[napi(object)]` in `rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs` (or new module) carrying plain-data fields. JSON-envelope fields for nested shapes: + - `software_json: Option` (JSON-encoded `SoftwareInput[]`) + - `loopback_exempt_ports: Option>` + - `allowed_node_builtins: Option>` + - `module_access_cwd: Option` + - `additional_instructions: Option` + - `permissions_json: Option` + - `mounts_json: Option` (plain-data subset only) + - `root_filesystem_json: Option` + - `sidecar_json: Option` +- [ ] `NapiActorFactory::from_agent_os(options, tool_callbacks)` static `#[napi(factory)]` method: + - Walk `tool_callbacks` JsObject if provided to wrap `execute` fns as TSFs (or stub for now — Phase 5 fully wires this). + - Build `AgentOsActorConfig` from options. + - Call `rivetkit_agent_os::build_core_factory(config)`. + - Store `Arc` on `inner`. +- [ ] Fail-loud detection: if `options` contains markers for non-serializable fields (`mounts[].driver`, `scheduleDriver`, `sidecar.Explicit`, the three user callbacks), throw a clear "not yet supported on the Rust path" error. +- [ ] Rebuild NAPI: `pnpm --filter @rivetkit/rivetkit-napi build:force`. Verify `from_agent_os` appears in `index.d.ts` as a static method. + +### E2E gate — `rivetkit-typescript/packages/rivetkit-napi/tests/agent-os-factory.test.ts` + +- [ ] Import `NapiActorFactory` from `@rivetkit/rivetkit-napi`. +- [ ] Construct via `NapiActorFactory.fromAgentOs({ software_json: JSON.stringify([{ package: "@rivet-dev/agent-os-common", commandDir: "/path" }]) }, undefined)`. Assert non-null handle. +- [ ] Construct with deliberately bad config (non-data driver field). Assert throws fail-loud error. + +Plus: **Phase 1a's `dispatcher_e2e.rs` must still pass.** No regressions. + +### STOP — discuss + +NAPI binding works. The Rust crate is reachable from JS. Confirm before adding JS shim layer. + +--- + +## Phase 1c — JS shim + engine driver branches, full driver-suite e2e + +### Tasks + +- [ ] Add optional `nativeFactoryBuilder?: (runtime: CoreRuntime) => ActorFactoryHandle` to `AnyActorDefinition` interface in `actor/definition.ts`, and to `ActorDefinition` class as defaultable instance property. +- [ ] Rewrite `agentOs(config)` in `src/agent-os/actor/index.ts`: + - Parse config via existing `agentOsActorConfigSchema`. + - Build `nativeFactoryBuilder` lazy closure: + - Reject if `runtime.kind !== "napi"` with clear error. + - Build `NapiAgentOsOptions` from parsed config (thread `software` with `commandDir`). + - Extract tool callbacks from `parsed.options.toolKits` (stub for Phase 1c — pass `undefined`). + - Call `runtime.createAgentOsFactory(options, toolCallbacks)`, cast to `ActorFactoryHandle`. + - Return `new ActorDefinition({} as any)` with `nativeFactoryBuilder` set. +- [ ] Delete the legacy `agentOs()` body and the `buildXActions` imports in the same file. +- [ ] Delete `src/agent-os/actor/{cron,db,filesystem,network,preview,process,session,shell}.ts`. +- [ ] Update `src/agent-os/index.ts` barrel to drop the deleted exports. +- [ ] Add `createAgentOsFactory?(options, toolCallbacks): ActorFactoryHandle` to `CoreRuntime` interface in `registry/runtime.ts`. +- [ ] `napi-runtime.ts::NapiCoreRuntime`: + - [ ] Implement `createAgentOsFactory(options, toolCallbacks)` calling `NapiActorFactory.fromAgentOs(...)` and returning the handle. + - [ ] **Widen `registerActor` signature** to take `definition: AnyActorDefinition` + `registryConfig: RegistryConfig`. Inside, do the dispatch: `if (definition.nativeFactoryBuilder) → call it; else → buildNativeFactory`. +- [ ] `wasm-runtime.ts::WasmCoreRuntime`: + - [ ] Mirror the widened `registerActor` signature. For agent-os actors (`nativeFactoryBuilder` set), throw "not supported." +- [ ] `registry/native.ts::buildConfiguredRegistry`: loop becomes `runtime.registerActor(registry, name, definition, config)`. +- [ ] `drivers/engine/actor-driver.ts`: add branch between dynamic and static: + ```ts + } else if ((definition as any).nativeFactoryBuilder) { + // Rust-backed; CoreActorFactory handles everything. + logger().debug({ msg: "engine actor started (rust-native)", actorId, name, key }); + } + ``` +- [ ] Add `writeFile` arm to `actions::dispatch` in the Rust crate (smoke test needs both). + +### E2E gate — Phase 0's smoke test passes for bare encoding + +```sh +AGENT_OS_SIDECAR_BIN=$(pwd)/target/debug/agent-os-sidecar \ +RIVET_ENGINE_BINARY=$(pwd)/target/debug/rivet-engine \ + pnpm vitest run tests/driver/actor-agent-os.test.ts \ + --bail=1 -t "writeFile and readFile round-trip" +``` + +Bare encoding cell must pass. + +Plus: **Phase 1a tests AND Phase 1b test must still pass.** + +### STOP — discuss + +End-to-end works. JS → engine → NAPI → Rust → sidecar → VM → bytes back. **This is the milestone that means the architecture is real.** Confirm before expanding. + +--- + +## Phase 2 — Cross-encoding parity + +### Tasks + +- [ ] Verify Phase 0's smoke test passes for cbor encoding (should "just work" because RIVETKIT_RUST_FIX wraps bytes at the framework layer). +- [ ] Verify the same for json encoding. +- [ ] If either fails, narrow: was RIVETKIT_RUST_FIX applied to every relevant `encode_cbor` call? Did the dispatcher arm use `action.ok(&bytes)` correctly? +- [ ] Add one structured-object action (e.g. `stat`) to validate non-byte shapes round-trip across all three encodings. + +### E2E gate + +- [ ] `writeFile and readFile round-trip` passes for bare + cbor + json. +- [ ] `stat returns file metadata` passes for bare + cbor + json. +- [ ] All Phase 1 e2e gates still pass. + +### STOP — discuss + +Cross-encoding works. Confirm before bulk action buildout. + +--- + +## Phase 3 — Action surface buildout + +Build out the remaining ~49 actions. After each category, run the full driver suite **no-bail** to catch independent failures. + +### Filesystem + +- [ ] Add arms: `mkdir`, `readdir`, `stat`, `exists`, `move`, `deleteFile`, `readFiles`, `writeFiles`, `readdirRecursive`. +- [ ] Wire DTOs land with their first feeder arm. +- [ ] **Driver-suite gate for filesystem category (no `--bail`).** + +### Process + +- [ ] Add arms: `exec`, `spawn`, `waitProcess`, `killProcess`, `stopProcess`, `listProcesses`, `allProcesses`, `processTree`, `getProcess`, `writeProcessStdin`, `closeProcessStdin`. +- [ ] Wire `processOutput` + `processExit` broadcasts inside `spawn` / `exec` arms. +- [ ] Increment `RunState.active_processes` on spawn, decrement on exit. +- [ ] **Driver-suite gate for process category.** + +### Shell + +- [ ] Add arms: `openShell`, `writeShell`, `resizeShell`, `closeShell`. +- [ ] Wire `shellData` broadcast inside `openShell` arm. +- [ ] Increment/decrement `RunState.active_shells`. +- [ ] **Driver-suite gate for shell category.** + +### Session + +- [ ] Add arms: `createSession`, `sendPrompt`, `cancelPrompt`, `respondPermission`, `closeSession`, `destroySession`, `resumeSession`, `listSessions`, `getSession`, `setMode`, `getModes`, `setModel`, `setThoughtLevel`, `getConfigOptions`, `getEvents`, `getSequencedEvents`, `rawSend`, `listAgents`. +- [ ] Wire `sessionEvent` + `permissionRequest` broadcasts inside `createSession`. +- [ ] Increment/decrement `RunState.active_sessions`. +- [ ] **Driver-suite gate for session category.** + +### Cron + +- [ ] Add arms: `scheduleCron`, `listCronJobs`, `cancelCronJob`. +- [ ] Wire `cronEvent` broadcast (always-on per VM). +- [ ] **Driver-suite gate for cron category.** + +### Network + +- [ ] Add arm: `vmFetch`. +- [ ] **Driver-suite gate for network category.** + +### Misc session bookkeeping (persistence-backed) + +- [ ] `listPersistedSessions` querying `agent_os_sessions`. +- [ ] `getSessionEvents` querying `agent_os_session_events`. +- [ ] **Driver-suite gate.** + +### E2E gate (phase boundary) + +Full driver suite no-bail. All categories green except preview (Phase 4) and tool actions (Phase 5). + +Plus all earlier e2e gates still pass. + +### STOP — discuss + +Action surface built out and validated. Confirm before preview + toolkits. + +--- + +## Phase 4 — Persistence + preview + +### Tasks + +- [ ] `src/persistence.rs`: `MIGRATION_SQL` const with 4 agent-os tables + indexes (port from TS `actor/db.ts`). `pub const MIGRATION_SQL: &str`. +- [ ] `pub async fn migrate_actor(ctx: &Ctx) -> Result<()>` — calls `sql.exec(MIGRATION_SQL)` if SQLite enabled, idempotent. +- [ ] Call `migrate_actor` at the top of `run::run` before the event loop. +- [ ] `tests/persistence.rs` rusqlite unit tests covering migration validity. +- [ ] `src/actions/preview.rs`: `generate_token` + `create_signed_preview_url` + `expire_signed_preview_url`. SQLite-backed via `ctx.sql()`. +- [ ] `src/preview_http.rs`: `parse_fetch_path` + `handle(ctx, vm, http)` for `/fetch/{token}/path` URLs. **Forward all request headers** to the VM fetch (don't drop them). +- [ ] Wire preview handler into `run::run`'s `Event::Http` arm. +- [ ] `tests/preview_http.rs` unit tests for path parser + token generator. + +### E2E gate + +- [ ] `tests/persistence.rs` passes. +- [ ] `tests/preview_http.rs` passes. +- [ ] Driver-suite preview tests pass (`createSignedPreviewUrl`, token round-trip via `/fetch/{token}/path`, `expireSignedPreviewUrl`). +- [ ] All earlier e2e gates still pass. + +### STOP — discuss + +Persistence + preview works. Confirm before toolkit callbacks. + +--- + +## Phase 5 — Toolkit callbacks + +### Tasks + +- [ ] In `NapiActorFactory::from_agent_os`, walk `tool_callbacks` JsObject. Extract `execute: JsFunction` for each `":"` key. Wrap as `ThreadsafeFunction`. +- [ ] Plug TSF callbacks into `AgentOsConfig::tool_kits` via a `ToolCallback` Arc closure that dispatches through the TSF. +- [ ] In JS `agentOs()` shim's `nativeFactoryBuilder`, walk `parsed.options.toolKits[*].tools[*]`, build the `{":": executeFn}` JsObject, pass to `runtime.createAgentOsFactory`. +- [ ] Driver-suite test: register an actor with a host tool, send a prompt that triggers the tool, assert JS `execute` ran and result reached the session. + +### Risk callout + +Highest-risk phase. TSF lifetime + cancellation token bridging across `napi_actor_events.rs` is a known minefield. If lifetime bugs surface, the rollback is to ship Phases 0–4 without host-tool support and merge. + +### E2E gate + +- [ ] Driver-suite host-tool round-trip test passes. +- [ ] All earlier e2e gates still pass. + +### STOP — discuss + +Toolkits work end-to-end. Confirm before cleanup. + +--- + +## Phase 6 — Cleanup + docs + +### Tasks — final cleanup pass + +- [ ] Grep for any orphan code (unused helpers, dead constants, unused wire DTOs from earlier phases). +- [ ] Grep for diagnostic logs: `grep -rE "console\.error|tracing::debug" rivetkit-typescript/packages/rivetkit/src/agent-os rivetkit-rust/packages/rivetkit-agent-os/src` — clean up. +- [ ] Confirm `RunState` counters are wired throughout (no unincremented counter sets). +- [ ] Confirm every wire DTO has a feeder. +- [ ] Confirm every event broadcast constant has a `Subscriptions::spawn_*` feeder. + +### Tasks — docs + +- [ ] Add `rivetkit-rust/packages/rivetkit-agent-os/CLAUDE.md`. +- [ ] Add `AGENTS.md` symlink (`ln -s CLAUDE.md AGENTS.md` from the package dir). +- [ ] Add bullet to root `CLAUDE.md` under "RivetKit Layer Architecture" describing the new crate. +- [ ] Update website docs for the new agent-os actor surface. + +### Tasks — lint + format + +- [ ] `cargo clippy -p rivetkit-agent-os -- -W warnings` clean. +- [ ] `pnpm biome check` clean on changed files. +- [ ] `pnpm tsc --noEmit` baseline clean (modulo pre-existing workspace issues). + +### Tasks — final regression + +- [ ] Full driver suite no-bail. Every cell green (modulo intentional skips). +- [ ] `cargo test -p rivetkit-agent-os` clean. +- [ ] Rebuild NAPI: `pnpm --filter @rivetkit/rivetkit-napi build:force`. +- [ ] Quickstart smoke (`ext/agent-os/examples/quickstart/`) runs against the new path. + +### E2E gate + +Final driver suite no-bail run. All cells green. Bytes round-trip on all three encodings. + +### STOP — discuss + +Ready to land. Confirm before merging. + +--- + +## Review checklist (apply at every PR within these phases) + +Pulled from PLAN2.md "Review checklist." Applies to **every PR**, not just phase boundaries: + +- [ ] No DTO added without a feeder in the same PR. +- [ ] No event broadcast constant without a `Subscriptions::spawn_*` feeder. +- [ ] No `RunState` counter introduced without dispatcher-arm increment + decrement. +- [ ] No abstraction (trait, generic wrapper) with one impl. +- [ ] No orphan method (grep the workspace before merge). +- [ ] User config flows or fails loud — never silently dropped. +- [ ] Byte payloads go through `Action::ok` wrapping (post-RIVETKIT_RUST_FIX). +- [ ] No legacy paths kept as fallback after a cutover. +- [ ] No diagnostic logs left in. +- [ ] Commit messages don't say "verified" without naming the test that ran. +- [ ] All prior sub-phase e2e tests still pass. +- [ ] NAPI artifact rebuilt if Rust touched. + +--- + +## Notes + +- **E2E maintained:** at every STOP, all prior phase e2e tests must still pass. +- **Stop and discuss:** don't move past a STOP without confirming the gate. +- **Feature debt is OK; e2e debt is not.** A missing action gets added in Phase 3. A failing driver-suite cell means the architecture has a leak that more code will only obscure. diff --git a/engine/artifacts/errors/actor.not_found.json b/engine/artifacts/errors/actor.not_found.json index 2e30c938f6..e99880acf2 100644 --- a/engine/artifacts/errors/actor.not_found.json +++ b/engine/artifacts/errors/actor.not_found.json @@ -1,5 +1,5 @@ { "code": "not_found", "group": "actor", - "message": "The actor does not exist or was destroyed." + "message": "Actor resource was not found." } \ No newline at end of file diff --git a/engine/sdks/typescript/api-full/tsconfig.json b/engine/sdks/typescript/api-full/tsconfig.json index 20e86125fd..bb30cd4303 100644 --- a/engine/sdks/typescript/api-full/tsconfig.json +++ b/engine/sdks/typescript/api-full/tsconfig.json @@ -7,6 +7,7 @@ "moduleResolution": "node", "esModuleInterop": true, "skipLibCheck": true, + "types": ["node"], "declaration": true, "emitDeclarationOnly": true, "sourceMap": true, diff --git a/examples/agent-os-e2e/.gitignore b/examples/agent-os-e2e/.gitignore new file mode 100644 index 0000000000..499a30e545 --- /dev/null +++ b/examples/agent-os-e2e/.gitignore @@ -0,0 +1 @@ +.agent-modules/ diff --git a/examples/agent-os-e2e/package.json b/examples/agent-os-e2e/package.json index 6796ebb01f..b68d732cad 100644 --- a/examples/agent-os-e2e/package.json +++ b/examples/agent-os-e2e/package.json @@ -4,14 +4,15 @@ "private": true, "type": "module", "scripts": { - "test:server": "tsx src/server.ts", + "prepare-agent-modules": "node scripts/prepare-agent-modules.mjs", + "test:server": "npm run prepare-agent-modules && tsx src/server.ts", "test": "tsx src/client.ts", "check-types": "tsc --noEmit" }, "dependencies": { "rivetkit": "*", - "@rivet-dev/agent-os-common": "*", - "@rivet-dev/agent-os-pi": "^0.1.1" + "@rivet-dev/agent-os-common": "0.0.260331072558", + "@rivet-dev/agent-os-pi": "0.0.0-main.8794200" }, "devDependencies": { "@types/node": "^22.13.9", diff --git a/examples/agent-os-e2e/scripts/prepare-agent-modules.mjs b/examples/agent-os-e2e/scripts/prepare-agent-modules.mjs new file mode 100644 index 0000000000..4b137e1a81 --- /dev/null +++ b/examples/agent-os-e2e/scripts/prepare-agent-modules.mjs @@ -0,0 +1,39 @@ +// Builds a small, self-contained dependency closure for the Pi agent at +// `.agent-modules/`, which the example mounts as the VM's `/root/node_modules` +// via `nodeModulesMount(".agent-modules/node_modules")` in src/server.ts. +// +// Why a dedicated dir instead of the example's own node_modules: in this pnpm +// monorepo the example's deps are symlinks that resolve out to the workspace +// root store, and the VM resolver (correctly) refuses symlinks that escape the +// mounted root. Mounting the workspace root itself would expose the entire +// ~4.5 GB monorepo to the VM. A flat `npm install` here gives the agent exactly +// its own closure and nothing else. +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, ".."); +const dir = join(root, ".agent-modules"); +const pi = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); +const PI_VER = pi.dependencies["@rivet-dev/agent-os-pi"]; +// Keep agent + SDK versions in sync with the example's pinned agent-os version. +const deps = { + "@rivet-dev/agent-os-pi": PI_VER, + "@rivet-dev/agent-os-core": PI_VER, + "@mariozechner/pi-coding-agent": "0.60.0", +}; + +const stamp = join(dir, ".deps.json"); +const want = JSON.stringify(deps); +if (existsSync(stamp) && readFileSync(stamp, "utf8") === want) { + console.log("[prepare-agent-modules] up to date"); + process.exit(0); +} +mkdirSync(dir, { recursive: true }); +writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "agent-modules", private: true, dependencies: deps }, null, 2)); +console.log("[prepare-agent-modules] installing agent closure into .agent-modules ..."); +execFileSync("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, stdio: "inherit" }); +writeFileSync(stamp, want); +console.log("[prepare-agent-modules] done"); diff --git a/examples/agent-os-e2e/src/client.ts b/examples/agent-os-e2e/src/client.ts index 128daf9d93..880fb97c13 100644 --- a/examples/agent-os-e2e/src/client.ts +++ b/examples/agent-os-e2e/src/client.ts @@ -105,9 +105,14 @@ const preview = (await agent.createSignedPreviewUrl(8080, 60)) as { console.log(`Preview path: ${preview.path}`); console.log(`Preview token: ${preview.token}`); -// Fetch through the preview proxy +// Fetch through the preview proxy. getGatewayUrl() returns a routing URL that +// already carries a query string (rvt-namespace/rvt-method/rvt-crash-policy/...), +// so the preview path must be inserted into the pathname before the query rather +// than naively appended (which would land inside the rvt-crash-policy value). const gatewayUrl = await agent.getGatewayUrl(); -const previewUrl = `${gatewayUrl}${preview.path}`; +const previewUrlObj = new URL(gatewayUrl); +previewUrlObj.pathname = previewUrlObj.pathname.replace(/\/$/, "") + preview.path; +const previewUrl = previewUrlObj.toString(); console.log(`Fetching preview URL: ${previewUrl}`); const previewResponse = await fetch(previewUrl); const previewBody = await previewResponse.text(); diff --git a/examples/agent-os-e2e/src/server.ts b/examples/agent-os-e2e/src/server.ts index 7522c60853..f1d5d6efd4 100644 --- a/examples/agent-os-e2e/src/server.ts +++ b/examples/agent-os-e2e/src/server.ts @@ -1,9 +1,23 @@ +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; import common from "@rivet-dev/agent-os-common"; import pi from "@rivet-dev/agent-os-pi"; import { setup } from "rivetkit"; -import { agentOs } from "rivetkit/agent-os"; +import { agentOs, nodeModulesMount } from "rivetkit/agent-os"; -const vm = agentOs({ options: { software: [common, pi] } }); +// The Pi agent closure is pre-installed (flat npm tree) into `.agent-modules/` +// by `scripts/prepare-agent-modules.mjs`. Mount its `node_modules` at +// `/root/node_modules` so the VM module resolver can read the agent SDK + its +// transitive deps through the kernel VFS. +const here = dirname(fileURLToPath(import.meta.url)); +const agentModules = join(here, "..", ".agent-modules", "node_modules"); + +const vm = agentOs({ + options: { + software: [common, pi], + mounts: [nodeModulesMount(agentModules)], + }, +}); export const registry = setup({ use: { vm } }); registry.start(); diff --git a/examples/agent-os/package.json b/examples/agent-os/package.json index 7823fd392b..67a7bf8ed9 100644 --- a/examples/agent-os/package.json +++ b/examples/agent-os/package.json @@ -25,7 +25,13 @@ "check-types": "tsc --noEmit" }, "dependencies": { + "@agent-os-pkgs/common": "0.0.0-split-runtime-preview.5d46b14", + "@rivet-dev/agent-os-core": "0.0.0-main.8794200", + "@rivet-dev/agent-os-pi": "0.0.0-main.8794200", + "@rivet-dev/agent-os-sandbox": "0.0.0-main.8794200", + "@secure-exec/s3": "0.0.0-split-runtime-preview.5d46b14", "rivetkit": "*", + "sandbox-agent": "^0.4.2", "zod": "^3.25.0" }, "devDependencies": { @@ -34,8 +40,13 @@ "typescript": "^5.5.2" }, "template": { - "technologies": ["typescript"], - "tags": ["agent-os", "vm"], + "technologies": [ + "typescript" + ], + "tags": [ + "agent-os", + "vm" + ], "noFrontend": true, "skipVercel": true }, diff --git a/examples/agent-os/src/agent-session/server.ts b/examples/agent-os/src/agent-session/server.ts index 7522c60853..35ca8b12a8 100644 --- a/examples/agent-os/src/agent-session/server.ts +++ b/examples/agent-os/src/agent-session/server.ts @@ -1,4 +1,4 @@ -import common from "@rivet-dev/agent-os-common"; +import common from "@agent-os-pkgs/common"; import pi from "@rivet-dev/agent-os-pi"; import { setup } from "rivetkit"; import { agentOs } from "rivetkit/agent-os"; diff --git a/examples/agent-os/src/cron/server.ts b/examples/agent-os/src/cron/server.ts index b4e759e13a..ab1649d41f 100644 --- a/examples/agent-os/src/cron/server.ts +++ b/examples/agent-os/src/cron/server.ts @@ -1,4 +1,4 @@ -import common from "@rivet-dev/agent-os-common"; +import common from "@agent-os-pkgs/common"; import { setup } from "rivetkit"; import { agentOs } from "rivetkit/agent-os"; diff --git a/examples/agent-os/src/filesystem/server.ts b/examples/agent-os/src/filesystem/server.ts index 84304fd150..ad6af4a21f 100644 --- a/examples/agent-os/src/filesystem/server.ts +++ b/examples/agent-os/src/filesystem/server.ts @@ -1,11 +1,11 @@ -import common from "@rivet-dev/agent-os-common"; +import common from "@agent-os-pkgs/common"; import { setup } from "rivetkit"; import { agentOs } from "rivetkit/agent-os"; // The default agentOS actor mounts an in-memory filesystem at /home/user. // You can add custom mounts for S3, host directories, or other backends: // -// import { S3BlockStore } from "@rivet-dev/agent-os-s3"; +// import { S3BlockStore } from "@secure-exec/s3"; // const vm = agentOs({ // options: { // software: [common], diff --git a/examples/agent-os/src/git/server.ts b/examples/agent-os/src/git/server.ts index 0833c35031..77c5beadac 100644 --- a/examples/agent-os/src/git/server.ts +++ b/examples/agent-os/src/git/server.ts @@ -1,4 +1,4 @@ -import common from "@rivet-dev/agent-os-common"; +import common from "@agent-os-pkgs/common"; import git from "@rivet-dev/agent-os-git"; import { setup } from "rivetkit"; import { agentOs } from "rivetkit/agent-os"; diff --git a/examples/agent-os/src/hello-world/server.ts b/examples/agent-os/src/hello-world/server.ts index b4e759e13a..ab1649d41f 100644 --- a/examples/agent-os/src/hello-world/server.ts +++ b/examples/agent-os/src/hello-world/server.ts @@ -1,4 +1,4 @@ -import common from "@rivet-dev/agent-os-common"; +import common from "@agent-os-pkgs/common"; import { setup } from "rivetkit"; import { agentOs } from "rivetkit/agent-os"; diff --git a/examples/agent-os/src/network/server.ts b/examples/agent-os/src/network/server.ts index b4e759e13a..ab1649d41f 100644 --- a/examples/agent-os/src/network/server.ts +++ b/examples/agent-os/src/network/server.ts @@ -1,4 +1,4 @@ -import common from "@rivet-dev/agent-os-common"; +import common from "@agent-os-pkgs/common"; import { setup } from "rivetkit"; import { agentOs } from "rivetkit/agent-os"; diff --git a/examples/agent-os/src/processes/server.ts b/examples/agent-os/src/processes/server.ts index b4e759e13a..ab1649d41f 100644 --- a/examples/agent-os/src/processes/server.ts +++ b/examples/agent-os/src/processes/server.ts @@ -1,4 +1,4 @@ -import common from "@rivet-dev/agent-os-common"; +import common from "@agent-os-pkgs/common"; import { setup } from "rivetkit"; import { agentOs } from "rivetkit/agent-os"; diff --git a/examples/agent-os/src/sandbox/server.ts b/examples/agent-os/src/sandbox/server.ts index b489bbd863..8ae3e33f62 100644 --- a/examples/agent-os/src/sandbox/server.ts +++ b/examples/agent-os/src/sandbox/server.ts @@ -4,7 +4,7 @@ // container lifecycle. The sandbox filesystem is mounted at /sandbox and // the toolkit exposes process management as CLI commands. -import common from "@rivet-dev/agent-os-common"; +import common from "@agent-os-pkgs/common"; import { createSandboxFs, createSandboxToolkit, diff --git a/examples/agent-os/src/tools/server.ts b/examples/agent-os/src/tools/server.ts index 761bf16dc3..0a1c919fba 100644 --- a/examples/agent-os/src/tools/server.ts +++ b/examples/agent-os/src/tools/server.ts @@ -1,4 +1,4 @@ -import common from "@rivet-dev/agent-os-common"; +import common from "@agent-os-pkgs/common"; import { hostTool, toolKit } from "@rivet-dev/agent-os-core"; import pi from "@rivet-dev/agent-os-pi"; import { setup } from "rivetkit"; diff --git a/package.json b/package.json index 1fad8b44e3..22a01332dd 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@rivet-gg/cloud": "https://pkg.pr.new/rivet-dev/engine-ee/@rivet-gg/cloud@e5c4911", "rivetkit": "workspace:*", "@rivetkit/engine-api-full": "workspace:*", + "@types/mime": "3.0.4", "@codemirror/state": "6.5.2", "@codemirror/view": "6.38.2", "@codemirror/autocomplete": "6.18.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 489446b319..b9d0d0b899 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,7 @@ overrides: react: 19.1.0 react-dom: 19.1.0 '@rivet-gg/cloud': https://pkg.pr.new/rivet-dev/engine-ee/@rivet-gg/cloud@e5c4911 + '@types/mime': 3.0.4 '@codemirror/state': 6.5.2 '@codemirror/view': 6.38.2 '@codemirror/autocomplete': 6.18.7 @@ -225,9 +226,27 @@ importers: examples/agent-os: dependencies: + '@agent-os-pkgs/common': + specifier: 0.0.0-split-runtime-preview.5d46b14 + version: 0.0.0-split-runtime-preview.5d46b14 + '@rivet-dev/agent-os-core': + specifier: 0.0.0-main.8794200 + version: 0.0.0-main.8794200 + '@rivet-dev/agent-os-pi': + specifier: 0.0.0-main.8794200 + version: 0.0.0-main.8794200(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.20.1)(zod@3.25.76) + '@rivet-dev/agent-os-sandbox': + specifier: 0.0.0-main.8794200 + version: 0.0.0-main.8794200(get-port@7.1.0) + '@secure-exec/s3': + specifier: 0.0.0-split-runtime-preview.5d46b14 + version: 0.0.0-split-runtime-preview.5d46b14 rivetkit: specifier: workspace:* version: link:../../rivetkit-typescript/packages/rivetkit + sandbox-agent: + specifier: ^0.4.2 + version: 0.4.2(get-port@7.1.0)(zod@3.25.76) zod: specifier: ^3.25.0 version: 3.25.76 @@ -245,11 +264,11 @@ importers: examples/agent-os-e2e: dependencies: '@rivet-dev/agent-os-common': - specifier: '*' + specifier: 0.0.260331072558 version: 0.0.260331072558 '@rivet-dev/agent-os-pi': - specifier: ^0.1.1 - version: 0.1.1(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.20.1)(zod@3.25.76) + specifier: 0.0.0-main.8794200 + version: 0.0.0-main.8794200(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.3.6))(ws@8.20.1)(zod@4.3.6) rivetkit: specifier: workspace:* version: link:../../rivetkit-typescript/packages/rivetkit @@ -2867,8 +2886,8 @@ importers: version: 3.25.76 devDependencies: '@types/mime': - specifier: ^4.0.0 - version: 4.0.0 + specifier: 3.0.4 + version: 3.0.4 '@types/node': specifier: ^20.19.13 version: 20.19.13 @@ -3239,9 +3258,6 @@ importers: '@hono/zod-openapi': specifier: ^1.1.5 version: 1.1.5(hono@4.11.9)(zod@4.1.13) - '@rivet-dev/agent-os-core': - specifier: ^0.1.1 - version: 0.1.1(pyodide@0.28.3) '@rivetkit/bare-ts': specifier: ^0.6.2 version: 0.6.2 @@ -3312,12 +3328,6 @@ importers: '@hono/node-ws': specifier: ^1.1.1 version: 1.3.0(@hono/node-server@1.19.9(hono@4.11.9))(hono@4.11.9) - '@rivet-dev/agent-os-common': - specifier: '*' - version: 0.0.260331072558 - '@rivet-dev/agent-os-pi': - specifier: ^0.1.1 - version: 0.1.1(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13))(pyodide@0.28.3)(ws@8.19.0)(zod@4.1.13) '@standard-schema/spec': specifier: ^1.0.0 version: 1.0.0 @@ -3864,6 +3874,33 @@ packages: '@adobe/css-tools@4.3.3': resolution: {integrity: sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==} + '@agent-os-pkgs/common@0.0.0-split-runtime-preview.5d46b14': + resolution: {integrity: sha512-6TzgaSZzgDdgQzY1Zgb4zAlzvb49NYuXK1gNDjWAfQeivz2NcWyfaN1qPRATHXOtpGUGm05Z65OwjLF05tTbNg==} + + '@agent-os-pkgs/coreutils@0.0.0-split-runtime-preview.5d46b14': + resolution: {integrity: sha512-AOOiAYcPfFvTDzwpV2U6n0/lxJXuUhbq67Tt7HRiIyG76e4zHTSzAu55RoW/24myRsKVjRw5jPA0hB413TuEQA==} + + '@agent-os-pkgs/diffutils@0.0.0-split-runtime-preview.5d46b14': + resolution: {integrity: sha512-i38lpZ24rm8nt4ImGuZ2KQBjI/ohADkponspwbHWiqR6t3jBKggg4PaSq3xcxY8O8rZ/05Igq1YBlrDh33Y4hQ==} + + '@agent-os-pkgs/findutils@0.0.0-split-runtime-preview.5d46b14': + resolution: {integrity: sha512-9IwymPoxtJveSB+HppgAUII05JBYGNhP9QSys5NoPSiaEOlIhcAVoKZHq6XFGyr33MhWmHyy3KEyrXFnkan+9Q==} + + '@agent-os-pkgs/gawk@0.0.0-split-runtime-preview.5d46b14': + resolution: {integrity: sha512-zjuyTyjXCz6LgZZqdA14vcgmyzjR5Ipeb3hlL+Tn3sCfAGp+Y7GSbTO9yBtTv8fkWNGOLq8il5zQJkJlHHMMfg==} + + '@agent-os-pkgs/grep@0.0.0-split-runtime-preview.5d46b14': + resolution: {integrity: sha512-xworE1A81YCKzXY2uW+2oK6BiB7YzEvcxU/RVxI7Psveylpfdlr/GJOVGfjyHTPi30hhDik3TEPSowS1WJqAfg==} + + '@agent-os-pkgs/gzip@0.0.0-split-runtime-preview.5d46b14': + resolution: {integrity: sha512-j8WENZbxekTBuMzLQCgqaA9vkkGGDn57mg/H4VaIIoiOWnUkKJMYbZ26twP0s6RbKDCe0vNWiAKDukhxe5jezQ==} + + '@agent-os-pkgs/sed@0.0.0-split-runtime-preview.5d46b14': + resolution: {integrity: sha512-uPEuPNIve/IOLYb9kJDFv8LDisg0jjcvdwLqdm9kacvOuc19wjhUZG2xPB8cjzKCgNtDN91jN+uo8F9no+oQBA==} + + '@agent-os-pkgs/tar@0.0.0-split-runtime-preview.5d46b14': + resolution: {integrity: sha512-mkh3mZtalQJ3n7M4769q30hvYSbI8wEIUF3LS9IzMQ9XfHW4NmIbGjDDrJxTuv8DepsN6SMS4lkNXjbY5yh4DQ==} + '@agentclientprotocol/sdk@0.16.1': resolution: {integrity: sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw==} peerDependencies: @@ -4032,6 +4069,12 @@ packages: resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} @@ -4045,46 +4088,90 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + '@aws-sdk/checksums@3.1000.7': + resolution: {integrity: sha512-qh0fG/RtrFztst4+vn1HZehAvAhr5Jlq/WMP7e5KvvfF16oNVBc9CDNVdxdm19vzOY2x0qiDMFCRjhxQAusGWQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-bedrock-runtime@3.1024.0': resolution: {integrity: sha512-nIhsn0/eYrL2fTh4kMO7Hpfmhv+AkkXl0KGNpD6+fdmotGvRBWcDv9/PmP/+sT6gvrKTYyzH3vu4efpTPzzP0Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-s3@3.1073.0': + resolution: {integrity: sha512-/Dvhrff0I4D2YUWSdm8uLKa1bfXdw9BMRDUME6ZeoTrrdQKQDeo2scLDjdpC5X2YdvTc/ZnUCR2HAvD7qXvS1w==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.973.26': resolution: {integrity: sha512-A/E6n2W42ruU+sfWk+mMUOyVXbsSgGrY3MJ9/0Az5qUdG67y8I6HYzzoAa+e/lzxxl1uCYmEL6BTMi9ZiZnplQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.974.22': + resolution: {integrity: sha512-YofH63shc6YRdXjz80BJkpJW+Bkn0Cuu2dn4Rv7s9G2Idt58tgtzQEWxrR2xVljlVfIBeUjPuULnSVYLke3sUQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.24': resolution: {integrity: sha512-FWg8uFmT6vQM7VuzELzwVo5bzExGaKHdubn0StjgrcU5FvuLExUe+k06kn/40uKv59rYzhez8eFNM4yYE/Yb/w==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.48': + resolution: {integrity: sha512-h6FEC95fbexUd6zxm4PdgS82bTcI2PRtUb2ZwMipb/Xr8bPwtf0G8rBo2jp7NA24Mbx2JA8/WingiYpA9RCCyw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.26': resolution: {integrity: sha512-CY4ppZ+qHYqcXqBVi//sdHST1QK3KzOEiLtpLsc9W2k2vfZPKExGaQIsOwcyvjpjUEolotitmd3mUNY56IwDEA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.50': + resolution: {integrity: sha512-lJO3OLpjvz5m/RSBQmsG/CEUGsvCy5ruxKwPQaOCqxqCMuyYT2BZwQUTDZVVwqQ9LrZKuK24JSa6r31hL/tvkg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.28': resolution: {integrity: sha512-wXYvq3+uQcZV7k+bE4yDXCTBdzWTU9x/nMiKBfzInmv6yYK1veMK0AKvRfRBd72nGWYKcL6AxwiPg9z/pYlgpw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.55': + resolution: {integrity: sha512-TBoF4buBGYhXjdZAryayY2TrkQj2B2KfE/msG4V53XCt+w0EhEwM2JRjx8p2grJ2C6gtH5++SAwEvGMRdi0yyw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.28': resolution: {integrity: sha512-ZSTfO6jqUTCysbdBPtEX5OUR//3rbD0lN7jO3sQeS2Gjr/Y+DT6SbIJ0oT2cemNw3UzKu97sNONd1CwNMthuZQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.54': + resolution: {integrity: sha512-hBWI3wZTdTGiuMfmPts6AWbAjFfRniOQnqx68tc2cQvRKWawFbN9wkLOVPWM1FAOyowZU73mC6Fi+rHSHNyLFw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.29': resolution: {integrity: sha512-clSzDcvndpFJAggLDnDb36sPdlZYyEs5Zm6zgZjjUhwsJgSWiWKwFIXUVBcbruidNyBdbpOv2tNDL9sX8y3/0g==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.57': + resolution: {integrity: sha512-u6dClpzNdWf1HGWz4wwhdXi1wiOofCLniM9S4BQQGlLAN9TW7VB+ld5V533GdKrYMaFeBGFqKnj0JCYvynLqwQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.24': resolution: {integrity: sha512-Q2k/XLrFXhEztPHqj4SLCNID3hEPdlhh1CDLBpNnM+1L8fq7P+yON9/9M1IGN/dA5W45v44ylERfXtDAlmMNmw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.48': + resolution: {integrity: sha512-w6VZwojPt12WnEkAUy6Nu4K6sWCbBmR7QX390b0nE6vRvkXbrYr9Lq9VySGkfjiMjpUA87op+J4EgvRmtWIDoQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.28': resolution: {integrity: sha512-IoUlmKMLEITFn1SiCTjPfR6KrE799FBo5baWyk/5Ppar2yXZoUdaRqZzJzK6TcJxx450M8m8DbpddRVYlp5R/A==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.54': + resolution: {integrity: sha512-23uZpIpF2SIFDCa1fcWa202tK4gGeyvX6GIIAjiB8WBsvsVRBMnJ/7dCxHzxf7eZT7GToJg837LDIBnZsl/VUg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.28': resolution: {integrity: sha512-d+6h0SD8GGERzKe27v5rOzNGKOl0D+l0bWJdqrxH8WSQzHzjsQFIAPgIeOTUwBHVsKKwtSxc91K/SWax6XgswQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.54': + resolution: {integrity: sha512-0Iv5QttS6wcATlodYKgvQj6B9Db51rx7NU9fqu0PoLeS4BIgdYMc/QK4smwLwpm5RFrs02V/eLyEFp3FklvlNQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/eventstream-handler-node@3.972.12': resolution: {integrity: sha512-ruyc/MNR6e+cUrGCth7fLQ12RXBZDy/bV06tgqB9Z5n/0SN/C0m6bsQEV8FF9zPI6VSAOaRd0rNgmpYVnGawrQ==} engines: {node: '>=20.0.0'} @@ -4093,6 +4180,10 @@ packages: resolution: {integrity: sha512-r+oP+tbCxgqXVC3pu3MUVePgSY0ILMjA+aEwOosS77m3/DRbtvHrHwqvMcw+cjANMeGzJ+i0ar+n77KXpRA8RQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-flexible-checksums@3.974.32': + resolution: {integrity: sha512-KhuzFMzUbb3oEj43CdPDbEJ/RG/RkErkmXk3J/LE8OPFNvkCn8PYPMpjOLgzAzvxBacsSyytdWf+R50q0alJ4w==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-host-header@3.972.8': resolution: {integrity: sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==} engines: {node: '>=20.0.0'} @@ -4105,6 +4196,10 @@ packages: resolution: {integrity: sha512-/Wt5+CT8dpTFQxEJ9iGy/UGrXr7p2wlIOEHvIr/YcHYByzoLjrqkYqXdJjd9UIgWjv7eqV2HnFJen93UTuwfTQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-sdk-s3@3.972.53': + resolution: {integrity: sha512-keWp6Z5cEIJzPwoCf/WRm0ceAeephPDDivhRsK/xXs2ZYXyypJ2/DL9G1IR0bz/s+iZC0EgzmFV4r7rlvLlxQQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-user-agent@3.972.28': resolution: {integrity: sha512-cfWZFlVh7Va9lRay4PN2A9ARFzaBYcA097InT5M2CdRS05ECF5yaz86jET8Wsl2WcyKYEvVr/QNmKtYtafUHtQ==} engines: {node: '>=20.0.0'} @@ -4117,10 +4212,18 @@ packages: resolution: {integrity: sha512-c7ZSIXrESxHKx2Mcopgd8AlzZgoXMr20fkx5ViPWPOLBvmyhw9VwJx/Govg8Ef/IhEon5R9l53Z8fdYSEmp6VA==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.22': + resolution: {integrity: sha512-4IwtcYSxEIVw5hcp8ogq0CMbFNZFw7jJUetpfFUhFFeqsa1K8j2Ihg2hnxLyOp3stMZnXda6VzOmPi1AFZQXcg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/region-config-resolver@3.972.10': resolution: {integrity: sha512-1dq9ToC6e070QvnVhhbAs3bb5r6cQ10gTVc6cyRV5uvQe7P138TV2uG2i6+Yok4bAkVAcx5AqkTEBUvWEtBlsQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.35': + resolution: {integrity: sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1021.0': resolution: {integrity: sha512-TKY6h9spUk3OLs5v1oAgW9mAeBE3LAGNBwJokLy96wwmd4W2v/tYlXseProyed9ValDj2u1jK/4Rg1T+1NXyJA==} engines: {node: '>=20.0.0'} @@ -4129,8 +4232,12 @@ packages: resolution: {integrity: sha512-eoyTMgd6OzoE1dq50um5Y53NrosEkWsjH0W6pswi7vrv1W9hY/7hR43jDcPevqqj+OQksf/5lc++FTqRlb8Y1Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.973.5': - resolution: {integrity: sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==} + '@aws-sdk/token-providers@3.1071.0': + resolution: {integrity: sha512-4LDW2Qob6LoLFuqYSYZq2AyTE9koSE9+i+n5UZcm10GpmQOK0zRD9L4uYlzItiTKksIWgC/qMFChAi3RvKYtMg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.13': + resolution: {integrity: sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==} engines: {node: '>=20.0.0'} '@aws-sdk/types@3.973.6': @@ -4165,6 +4272,10 @@ packages: resolution: {integrity: sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.30': + resolution: {integrity: sha512-StElZPEoBquWwNqw1AcfpzEyZqJvFxouG+mpDNYlcH6ZOrqd2CuIryv+8LV8gNHZUOyKyJF3Dq9vxaXEmDR9TQ==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.3': resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} engines: {node: '>=18.0.0'} @@ -4839,61 +4950,31 @@ packages: cpu: [arm64] os: [darwin] - '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': - resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} - cpu: [arm64] - os: [darwin] - '@cbor-extract/cbor-extract-darwin-x64@2.2.0': resolution: {integrity: sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==} cpu: [x64] os: [darwin] - '@cbor-extract/cbor-extract-darwin-x64@2.2.2': - resolution: {integrity: sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==} - cpu: [x64] - os: [darwin] - '@cbor-extract/cbor-extract-linux-arm64@2.2.0': resolution: {integrity: sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==} cpu: [arm64] os: [linux] - '@cbor-extract/cbor-extract-linux-arm64@2.2.2': - resolution: {integrity: sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==} - cpu: [arm64] - os: [linux] - '@cbor-extract/cbor-extract-linux-arm@2.2.0': resolution: {integrity: sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==} cpu: [arm] os: [linux] - '@cbor-extract/cbor-extract-linux-arm@2.2.2': - resolution: {integrity: sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==} - cpu: [arm] - os: [linux] - '@cbor-extract/cbor-extract-linux-x64@2.2.0': resolution: {integrity: sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw==} cpu: [x64] os: [linux] - '@cbor-extract/cbor-extract-linux-x64@2.2.2': - resolution: {integrity: sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==} - cpu: [x64] - os: [linux] - '@cbor-extract/cbor-extract-win32-x64@2.2.0': resolution: {integrity: sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==} cpu: [x64] os: [win32] - '@cbor-extract/cbor-extract-win32-x64@2.2.2': - resolution: {integrity: sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==} - cpu: [x64] - os: [win32] - '@chevrotain/cst-dts-gen@11.0.3': resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} @@ -6882,6 +6963,9 @@ packages: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} + '@nodable/entities@2.2.0': + resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -8003,8 +8087,8 @@ packages: '@rivet-dev/agent-os-common@0.0.260331072558': resolution: {integrity: sha512-bkMU6yqLxNLoXYA2/f2qRf0JrlqbIiBRKRpP3d+tbmF+VV2wrAY+iLJDHN6Jw+Q8q3CkT6jB5KTnCZkcYEF3RQ==} - '@rivet-dev/agent-os-core@0.1.1': - resolution: {integrity: sha512-Uw5jr+gUXDY7TDUFqlypjGe1BD2KL9kTHPNo/f1iNS1R+l9IWuvT+FF/MXsLOEkc3fB06OPVu2ZvUuNwp9MpLQ==} + '@rivet-dev/agent-os-core@0.0.0-main.8794200': + resolution: {integrity: sha512-Z6cedhN+UaV6+ML2+2kDQ+jeR5pZZXW5ltnL1GeLTyZF+Ni6oSKrnlUeJiufARBBDjvmaXslFx2K/WEm93xrsA==} '@rivet-dev/agent-os-coreutils@0.0.260331072558': resolution: {integrity: sha512-vI/J2MJnpsJQZ7F5DU/udGqGMtliqhTg28lE6XZ/qEGyjUvmEQ9AiV8CaAcX5HrImAUWOofBbTv6/XGKjOhT1w==} @@ -8024,24 +8108,26 @@ packages: '@rivet-dev/agent-os-gzip@0.0.260331072558': resolution: {integrity: sha512-id9iOrZFAuaXaFWIIiQEo70Ze/poI7hodSqWfo9ro0sajV0xI7kSp0BbXgS4PvGc9O2+VWcOE2qzqTUnKNlBzQ==} - '@rivet-dev/agent-os-pi@0.1.1': - resolution: {integrity: sha512-+PWE8Kubl6ZfXAPETFXhPRBoPKJbsnbXKK3gvrgGqvBa7kJvzPCYM5muUipSsSPwXurLOtIN+vnCiu8bU/Q7kA==} + '@rivet-dev/agent-os-pi@0.0.0-main.8794200': + resolution: {integrity: sha512-zskoPhFRNhTMzkhQ1PeUW7f8edHgj1CDDZ7Fosqs/zJiRK1xczKwo9frc1yykQq8jZSZqoolPMhLih+dUe3r2Q==} hasBin: true - '@rivet-dev/agent-os-posix@0.1.0': - resolution: {integrity: sha512-NIrI7cCb9x6jdmzRPPx7dAeXoTF/YCqf93ydEzYFA2zshIelLW9Rp5KtgP/2hM6fP0ly4+vVnOeavxJW0wYtcA==} - - '@rivet-dev/agent-os-python@0.1.0': - resolution: {integrity: sha512-1tH1beMf1ceSpicQKwN/a6h+NmJrmfuT4GStiRDZmvN/UWfZhkxuy7HR5VPTQpE/feUZJ01FdtBS3Em/Qoxb2Q==} - peerDependencies: - pyodide: '>=0.28.0' - peerDependenciesMeta: - pyodide: - optional: true + '@rivet-dev/agent-os-sandbox@0.0.0-main.8794200': + resolution: {integrity: sha512-AJ8rNi7PHSivNicOq96p+Rs1j02xx9I2O6NbguTR5HHMpYJ5s6UW9qE1GVnNeuBWjvYXtKv2NfcPkVkKDJ4V+A==} '@rivet-dev/agent-os-sed@0.0.260331072558': resolution: {integrity: sha512-i/6ifWCcGE2TEfPWR5ig5tMVKUc0qL0ng59htgS+sqt6zdMaPIllwVztOgXNxMEdFM1TF646e/OkMfMzmYZDCA==} + '@rivet-dev/agent-os-sidecar-linux-x64-gnu@0.0.0-main.8794200': + resolution: {integrity: sha512-DAOrUTvAH+D8diMXOPy4MTm1W90cMkdMN8pL0ClblkqQfGgWx3nDG5sWQwsbzVM1ulNPMi+xVTUdsXA/LBPRBQ==} + engines: {node: '>=20'} + cpu: [x64] + os: [linux] + + '@rivet-dev/agent-os-sidecar@0.0.0-main.8794200': + resolution: {integrity: sha512-brNKTrpQTVTgWYXF+wJkoX+tc1rb4T4cHmJX7onQBNGB2vqFjktafuoJcajVsZPU32ZZ+crd5DK+dqYYLev75Q==} + engines: {node: '>=20'} + '@rivet-dev/agent-os-tar@0.0.260331072558': resolution: {integrity: sha512-LAa8fm4jombNBQRR42O/57WxY6VA13Uea8JFChh6yPN4VtFP84KI5Kg4/Zzne3YDronGZlgJivznNUH3dtTMzA==} @@ -8252,37 +8338,59 @@ packages: '@rushstack/ts-command-line@5.1.2': resolution: {integrity: sha512-jn0EnSefYrkZDrBGd6KGuecL84LI06DgzL4hVQ46AUijNBt2nRU/ST4HhrfII/w91siCd1J/Okvxq/BS75Me/A==} - '@sec-ant/readable-stream@0.4.1': - resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - - '@secure-exec/core@0.2.1': - resolution: {integrity: sha512-HsnUv6gClpMA1BBRmX86j30TKTZtgJC/fO1tVavr7IpM2zNKbHU8LgSlBd7mv2SNy02ImTmU/GnQ3aYB4NSbEg==} - - '@secure-exec/nodejs@0.2.1': - resolution: {integrity: sha512-UJMJqVFxexlHJV0Q9nWURvrz6GElj8673DDOOFln6FHR6JS+9SaSU3eISrN158DuNC3SFi4rgjb/scKnK4YOYQ==} - - '@secure-exec/v8-darwin-arm64@0.2.1': - resolution: {integrity: sha512-gEWhMHzUpLwzuBNAD0lVkZXE8wFlWMLp4IOZ+56FYwOW/C+m07cYxuW4TjHyPqZ+vPm3IkoaMqqH5yT9VhjX/Q==} + '@sandbox-agent/cli-darwin-arm64@0.4.2': + resolution: {integrity: sha512-+L1O8SI7k/LLhyB4dG0ghmz1cJHa0WtVjuRTrEE2gw/5EbGLWopPBsCVCmQ7snrQ4fPwtaiZDhfExcEj1VI7aw==} cpu: [arm64] os: [darwin] - '@secure-exec/v8-darwin-x64@0.2.1': - resolution: {integrity: sha512-H2Z5K+Cq+fn/kxjGvhJzepnNFWG6qNdyhZybVWGr5bAAZoSz/Qkad4WnXcurWU+880tKDtnf19LHBXrg7zewNQ==} + '@sandbox-agent/cli-darwin-x64@0.4.2': + resolution: {integrity: sha512-dDg/EwWsdgVVbJiiCX1scSNRRA48u77SsC7Tuqrfzx4fIJMLuLiIcmEtXQyCBWysSyQNV2Cr+PYXXQfCb3xg8g==} cpu: [x64] os: [darwin] - '@secure-exec/v8-linux-arm64-gnu@0.2.1': - resolution: {integrity: sha512-14subGhVV/gW35mYYm7Gv1Keeex7PxIgQfoKji/JH7wYyDuarP6kgaES0nJw+JXVkxEVud52c+kbcIjIggqCEw==} + '@sandbox-agent/cli-linux-arm64@0.4.2': + resolution: {integrity: sha512-TGmTUexMoubmWQyTeaOJu0rDVl2h0Ifh1pZ0ceZy7u/6Eoqs2n46CbfQtasUxZJf10uxPgRyzEDhcdDrTYVQUA==} cpu: [arm64] os: [linux] - '@secure-exec/v8-linux-x64-gnu@0.2.1': - resolution: {integrity: sha512-Az4s+vUf+78vWtsC7rTn/jQc6WKJafAdt2YpEjB4Gnu+sX+FFTIst1hRV4gJonbRyJdy6SW+OQ6DZatmwczorQ==} + '@sandbox-agent/cli-linux-x64@0.4.2': + resolution: {integrity: sha512-H9Rbqq0DRkCHvakzefJUDrDa2y+vJjlYd5/tefzKbQ34locE13TGNygRLxdEVXpBECjK9wVdBwTVEphQNsOcjw==} + cpu: [x64] + os: [linux] + + '@sandbox-agent/cli-shared@0.4.2': + resolution: {integrity: sha512-sjZXRkKeFXCSKR6hHzF2Af8CCRO3F3WFwVQJ22+sLTXJ2xskV8lkUE4egknQU9B5BC1Zumts/YiNCFQWG85awQ==} + + '@sandbox-agent/cli-win32-x64@0.4.2': + resolution: {integrity: sha512-lZNfHWPwQe/VH51Yvrl/ATCUvBZ3a+c8mwovojhQcmZlv4QuUQPkuvxhPqHRh9AyBx78L5J/ha46es2doa34nQ==} + cpu: [x64] + os: [win32] + + '@sandbox-agent/cli@0.4.2': + resolution: {integrity: sha512-trO//ypJBSt5xkewuol9LOykvDgHwUXq8R+yQVS+0CmpN3lYUtewHkb+At9RVGRhDMmJZY2oasaXDnhfurQ33w==} + hasBin: true + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@secure-exec/core@0.0.0-split-runtime-preview.5d46b14': + resolution: {integrity: sha512-U7oWReKG4K+7o0x+nP2qx0CUYGoO2RvRbUZJXhy2iG2ce+Axe/+o5j/NaondRu1C37Tx2EjVwjP1hNhmD0DTNA==} + + '@secure-exec/s3@0.0.0-split-runtime-preview.5d46b14': + resolution: {integrity: sha512-X7stPV4f5/J+t3qBd/8YQFty9iNFNCD7ipAp0rwVDVNdhjA1xyAMKvQXfG11rbe2Y5o1P43k5cT6Z10pZtFevw==} + + '@secure-exec/sandbox@0.0.0-split-runtime-preview.5d46b14': + resolution: {integrity: sha512-/CS91LGj03r0ylUeb+5uf+dKt9w/gcBXo11dvtfs6ZNSf0TRSayvHxHEwagN7ABarOGJT3uc1DEhp63/XsGGUQ==} + + '@secure-exec/sidecar-linux-x64-gnu@0.0.0-split-runtime-preview.5d46b14': + resolution: {integrity: sha512-litPZ/HgzA1VjjOGaBuP5uLj6PXiTGJJF7q12NXM7xN+nqFrSDRrwgByPde4TxX2kbk1zP+hYM08S+qqdrAwxQ==} + engines: {node: '>=20'} cpu: [x64] os: [linux] - '@secure-exec/v8@0.2.1': - resolution: {integrity: sha512-ye/seCqzvyMGnvyP+AO7RkVMR/lE3x9m0D2PfmiAXA457R78ZmOFmZ6v+JlJG2vv3LM30KsSXTUhwpG+Teh0hw==} + '@secure-exec/sidecar@0.0.0-split-runtime-preview.5d46b14': + resolution: {integrity: sha512-Zfj6i7UNikc4SXtyBYRcMydHFEsrmUT0VnG8lt8hTz6Ux6YgBmIXmzMZntyJnDfa+ayJoYbNnfcjEiy4AvTr8Q==} + engines: {node: '>=20'} '@sentry-internal/browser-utils@10.42.0': resolution: {integrity: sha512-HCEICKvepxN4/6NYfnMMMlppcSwIEwtS66X6d1/mwaHdi2ivw0uGl52p7Nfhda/lIJArbrkWprxl0WcjZajhQA==} @@ -8608,10 +8716,18 @@ packages: resolution: {integrity: sha512-J+2TT9D6oGsUVXVEMvz8h2EmdVnkBiy2auCie4aSJMvKlzUtO5hqjEzXhoCUkIMo7gAYjbQcN0g/MMSXEhDs1Q==} engines: {node: '>=18.0.0'} + '@smithy/core@3.25.1': + resolution: {integrity: sha512-zpDbpXBCBsxfLtG2GEUyfgvHvSFrw5CwDZSNzL0v52gx/c3oPlPbm+7W7num8xs6vyiUBn+bvYPHcQDOXZynCQ==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.12': resolution: {integrity: sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.4.1': + resolution: {integrity: sha512-TSAF5NHgxEsllbErYWbK8aLnl5L601NGc5VYJlSPsKnf3YlkhdoBN+geGcaU00oiw2OK3QO5LA3QNXiiWhCidQ==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-codec@4.2.12': resolution: {integrity: sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==} engines: {node: '>=18.0.0'} @@ -8636,6 +8752,10 @@ packages: resolution: {integrity: sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.5.1': + resolution: {integrity: sha512-96JrD1q71anokymx9Iblb+zKmNQYNstlV/25A9ZYIJ2A0rp1r7/GZAIm0bDWSmVvz3DpNOCZuabzsiL+w0UHhw==} + engines: {node: '>=18.0.0'} + '@smithy/hash-node@4.2.12': resolution: {integrity: sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==} engines: {node: '>=18.0.0'} @@ -8680,6 +8800,10 @@ packages: resolution: {integrity: sha512-ejjxdAXjkPIs9lyYyVutOGNOraqUE9v/NjGMKwwFrfOM354wfSD8lmlj8hVwUzQmlLLF4+udhfCX9Exnbmvfzw==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.8.1': + resolution: {integrity: sha512-emtXvoky671puri18ETf64AFIQUGIEA093F2drXpBgB0OGnBLjcwNR3CA2mYu62IAqNsS56xa5lnTxAgPq7cjw==} + engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.2.12': resolution: {integrity: sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==} engines: {node: '>=18.0.0'} @@ -8708,18 +8832,22 @@ packages: resolution: {integrity: sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.12.8': - resolution: {integrity: sha512-aJaAX7vHe5i66smoSSID7t4rKY08PbD8EBU7DOloixvhOozfYWdcSYE4l6/tjkZ0vBZhGjheWzB2mh31sLgCMA==} + '@smithy/signature-v4@5.5.1': + resolution: {integrity: sha512-X9rVls3En0z3NtrmguTmpRM0/NqtWUxBjal6fcAkwtsub+gOdLZ6kD+V7xhUgFMGdG14bHbZ7M5QjaRI1+DatQ==} engines: {node: '>=18.0.0'} - '@smithy/types@4.13.0': - resolution: {integrity: sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==} + '@smithy/smithy-client@4.12.8': + resolution: {integrity: sha512-aJaAX7vHe5i66smoSSID7t4rKY08PbD8EBU7DOloixvhOozfYWdcSYE4l6/tjkZ0vBZhGjheWzB2mh31sLgCMA==} engines: {node: '>=18.0.0'} '@smithy/types@4.13.1': resolution: {integrity: sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==} engines: {node: '>=18.0.0'} + '@smithy/types@4.15.0': + resolution: {integrity: sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.12': resolution: {integrity: sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==} engines: {node: '>=18.0.0'} @@ -9451,9 +9579,8 @@ packages: '@types/mime-types@2.1.4': resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} - '@types/mime@4.0.0': - resolution: {integrity: sha512-5eEkJZ/BLvTE3vXGKkWlyTSUVZuzj23Wj8PoyOq2lt5I3CYbiLBOPb3XmCW6QcuOibIUE6emHXHt9E/F/rCa6w==} - deprecated: This is a stub types definition. mime provides its own type definitions, so you do not need this installed. + '@types/mime@3.0.4': + resolution: {integrity: sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==} '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -9944,6 +10071,9 @@ packages: engines: {node: '>=10.0.0'} deprecated: this version has critical issues, please update to the latest version + '@xterm/headless@6.0.0': + resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -10004,6 +10134,9 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acp-http-client@0.4.2: + resolution: {integrity: sha512-3wtPieF08YIU4vNXaoL5up/1D0if4i9IX3Ye5q/bwbcwg1BKsazIK/VNNfvN4ldbPjWul69IqIOpGRS3I0qo3Q==} + actor-core@0.6.3: resolution: {integrity: sha512-cdYf0GX3m3jvlubbdujOcnPn93r1fP9F0mEBso72ofMTI0+EeGMS34BNrmaGmk5Pb3iD45KQl3u5ZY5Mzv4DNg==} hasBin: true @@ -10138,6 +10271,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + arch@2.2.0: resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} @@ -10627,16 +10763,9 @@ packages: resolution: {integrity: sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==} hasBin: true - cbor-extract@2.2.2: - resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} - hasBin: true - cbor-x@1.6.0: resolution: {integrity: sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==} - cbor-x@1.6.4: - resolution: {integrity: sha512-UGKHjp6RHC6QuZ2yy5LCKm7MojM4716DwoSaqwQpaH4DvZvbBTGcoDNTiG9Y2lByXZYFEs9WRkS5tLl96IrF1Q==} - ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -12208,10 +12337,17 @@ packages: fast-xml-builder@1.1.4: resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + fast-xml-parser@5.5.8: resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==} hasBin: true + fast-xml-parser@5.7.3: + resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -12480,10 +12616,18 @@ packages: resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==} engines: {node: '>=10'} + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + gaxios@7.1.4: resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} engines: {node: '>=18'} + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + gcp-metadata@8.1.2: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} @@ -12622,10 +12766,26 @@ packages: resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} engines: {node: '>=18'} + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + google-logging-utils@1.1.3: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} + googleapis-common@7.2.0: + resolution: {integrity: sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==} + engines: {node: '>=14.0.0'} + + googleapis@144.0.0: + resolution: {integrity: sha512-ELcWOXtJxjPX4vsKMh+7V+jZvgPwYMlEhQFiu2sa9Qmt5veX8nwXPksOWGGN6Zk4xCiLygUyaz7xGtcMO+Onxw==} + engines: {node: '>=14.0.0'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -12637,6 +12797,10 @@ packages: resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + h3@1.15.4: resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==} @@ -14108,11 +14272,6 @@ packages: engines: {node: '>=4'} hasBin: true - mime@4.0.7: - resolution: {integrity: sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==} - engines: {node: '>=16'} - hasBin: true - mime@4.1.0: resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} engines: {node: '>=16'} @@ -14745,6 +14904,10 @@ packages: resolution: {integrity: sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==} engines: {node: '>=14.0.0'} + path-expression-matcher@1.6.0: + resolution: {integrity: sha512-e5y7RCLHKjemsgQ4eqGJtPyr10ILz25HO7flzxhTV8bgvd5yHx98DGtCAtbVW9f2TqnYI/gEVZd+vz7snrdPTw==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -15821,6 +15984,38 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sandbox-agent@0.4.2: + resolution: {integrity: sha512-fH6WDQEaIrgiu93LxZcy+4Dx+t+/cslu+hzXImDyUlsaL6jV2jIv4fdxELkALlo7uzyEDVK9lmqs9qy65RHwBQ==} + peerDependencies: + '@cloudflare/sandbox': '>=0.1.0' + '@daytonaio/sdk': '>=0.12.0' + '@e2b/code-interpreter': '>=1.0.0' + '@fly/sprites': '>=0.0.1' + '@vercel/sandbox': '>=0.1.0' + computesdk: '>=0.1.0' + dockerode: '>=4.0.0' + get-port: '>=7.0.0' + modal: '>=0.1.0' + peerDependenciesMeta: + '@cloudflare/sandbox': + optional: true + '@daytonaio/sdk': + optional: true + '@e2b/code-interpreter': + optional: true + '@fly/sprites': + optional: true + '@vercel/sandbox': + optional: true + computesdk: + optional: true + dockerode: + optional: true + get-port: + optional: true + modal: + optional: true + sass@1.93.2: resolution: {integrity: sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==} engines: {node: '>=14.0.0'} @@ -15844,9 +16039,6 @@ packages: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} - secure-exec@0.2.1: - resolution: {integrity: sha512-oaQDzTPDSCOckYC8G0PimIqzEVxY6sYEvcx0fMGsRR/Wl4wkFVHaZgQ3kc2DHWysV6WHWt5g1AXc/6seafO2XQ==} - secure-exec@https://pkg.pr.new/rivet-dev/secure-exec@7659aba: resolution: {tarball: https://pkg.pr.new/rivet-dev/secure-exec@7659aba} version: 0.1.0 @@ -16240,6 +16432,9 @@ packages: strnum@2.2.0: resolution: {integrity: sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==} + strnum@2.4.1: + resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} + strtok3@10.3.4: resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} engines: {node: '>=18'} @@ -16764,10 +16959,6 @@ packages: resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} engines: {node: '>=18.17'} - undici@7.24.7: - resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} - engines: {node: '>=20.18.1'} - undici@7.24.8: resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} engines: {node: '>=20.18.1'} @@ -16972,6 +17163,9 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + url@0.11.4: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} @@ -17039,6 +17233,11 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -17459,10 +17658,6 @@ packages: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} - web-streams-polyfill@4.2.0: - resolution: {integrity: sha512-0rYDzGOh9EZpig92umN5g5D/9A1Kff7k0/mzPSSCY8jEQeYkgRMoY7LhbXtUCWzLCMX0TUE9aoHkjFNB7D9pfA==} - engines: {node: '>= 8'} - web-vitals@4.2.4: resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} @@ -17647,6 +17842,10 @@ packages: resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} hasBin: true + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + xml2js@0.6.0: resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} engines: {node: '>=4.0.0'} @@ -17836,13 +18035,40 @@ snapshots: '@adobe/css-tools@4.3.3': optional: true + '@agent-os-pkgs/common@0.0.0-split-runtime-preview.5d46b14': + dependencies: + '@agent-os-pkgs/coreutils': 0.0.0-split-runtime-preview.5d46b14 + '@agent-os-pkgs/diffutils': 0.0.0-split-runtime-preview.5d46b14 + '@agent-os-pkgs/findutils': 0.0.0-split-runtime-preview.5d46b14 + '@agent-os-pkgs/gawk': 0.0.0-split-runtime-preview.5d46b14 + '@agent-os-pkgs/grep': 0.0.0-split-runtime-preview.5d46b14 + '@agent-os-pkgs/gzip': 0.0.0-split-runtime-preview.5d46b14 + '@agent-os-pkgs/sed': 0.0.0-split-runtime-preview.5d46b14 + '@agent-os-pkgs/tar': 0.0.0-split-runtime-preview.5d46b14 + + '@agent-os-pkgs/coreutils@0.0.0-split-runtime-preview.5d46b14': {} + + '@agent-os-pkgs/diffutils@0.0.0-split-runtime-preview.5d46b14': {} + + '@agent-os-pkgs/findutils@0.0.0-split-runtime-preview.5d46b14': {} + + '@agent-os-pkgs/gawk@0.0.0-split-runtime-preview.5d46b14': {} + + '@agent-os-pkgs/grep@0.0.0-split-runtime-preview.5d46b14': {} + + '@agent-os-pkgs/gzip@0.0.0-split-runtime-preview.5d46b14': {} + + '@agent-os-pkgs/sed@0.0.0-split-runtime-preview.5d46b14': {} + + '@agent-os-pkgs/tar@0.0.0-split-runtime-preview.5d46b14': {} + '@agentclientprotocol/sdk@0.16.1(zod@3.25.76)': dependencies: zod: 3.25.76 - '@agentclientprotocol/sdk@0.16.1(zod@4.1.13)': + '@agentclientprotocol/sdk@0.16.1(zod@4.3.6)': dependencies: - zod: 4.1.13 + zod: 4.3.6 '@ai-sdk/anthropic@1.2.12(zod@4.1.13)': dependencies: @@ -18013,11 +18239,11 @@ snapshots: optionalDependencies: zod: 3.25.76 - '@anthropic-ai/sdk@0.73.0(zod@4.1.13)': + '@anthropic-ai/sdk@0.73.0(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: - zod: 4.1.13 + zod: 4.3.6 '@arethetypeswrong/cli@0.18.3': dependencies: @@ -18152,7 +18378,22 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.5 + '@aws-sdk/types': 3.973.13 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.13 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.13 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 '@aws-crypto/sha256-browser@5.2.0': @@ -18160,7 +18401,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.5 + '@aws-sdk/types': 3.973.13 '@aws-sdk/util-locate-window': 3.965.5 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -18168,7 +18409,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.5 + '@aws-sdk/types': 3.973.13 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -18177,10 +18418,21 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.5 + '@aws-sdk/types': 3.973.13 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 + '@aws-sdk/checksums@3.1000.7': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.974.22 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.25.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@aws-sdk/client-bedrock-runtime@3.1024.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -18233,6 +18485,23 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-s3@3.1073.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.22 + '@aws-sdk/credential-provider-node': 3.972.57 + '@aws-sdk/middleware-flexible-checksums': 3.974.32 + '@aws-sdk/middleware-sdk-s3': 3.972.53 + '@aws-sdk/signature-v4-multi-region': 3.996.35 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.25.1 + '@smithy/fetch-http-handler': 5.5.1 + '@smithy/node-http-handler': 4.8.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@aws-sdk/core@3.973.26': dependencies: '@aws-sdk/types': 3.973.6 @@ -18249,6 +18518,17 @@ snapshots: '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 + '@aws-sdk/core@3.974.22': + dependencies: + '@aws-sdk/types': 3.973.13 + '@aws-sdk/xml-builder': 3.972.30 + '@aws/lambda-invoke-store': 0.2.3 + '@smithy/core': 3.25.1 + '@smithy/signature-v4': 5.5.1 + '@smithy/types': 4.15.0 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.24': dependencies: '@aws-sdk/core': 3.973.26 @@ -18257,6 +18537,14 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.48': + dependencies: + '@aws-sdk/core': 3.974.22 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.25.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.26': dependencies: '@aws-sdk/core': 3.973.26 @@ -18270,6 +18558,16 @@ snapshots: '@smithy/util-stream': 4.5.21 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.50': + dependencies: + '@aws-sdk/core': 3.974.22 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.25.1 + '@smithy/fetch-http-handler': 5.5.1 + '@smithy/node-http-handler': 4.8.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.972.28': dependencies: '@aws-sdk/core': 3.973.26 @@ -18289,6 +18587,22 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-ini@3.972.55': + dependencies: + '@aws-sdk/core': 3.974.22 + '@aws-sdk/credential-provider-env': 3.972.48 + '@aws-sdk/credential-provider-http': 3.972.50 + '@aws-sdk/credential-provider-login': 3.972.54 + '@aws-sdk/credential-provider-process': 3.972.48 + '@aws-sdk/credential-provider-sso': 3.972.54 + '@aws-sdk/credential-provider-web-identity': 3.972.54 + '@aws-sdk/nested-clients': 3.997.22 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.25.1 + '@smithy/credential-provider-imds': 4.4.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.28': dependencies: '@aws-sdk/core': 3.973.26 @@ -18302,6 +18616,15 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-login@3.972.54': + dependencies: + '@aws-sdk/core': 3.974.22 + '@aws-sdk/nested-clients': 3.997.22 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.25.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.29': dependencies: '@aws-sdk/credential-provider-env': 3.972.24 @@ -18319,6 +18642,20 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-node@3.972.57': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.48 + '@aws-sdk/credential-provider-http': 3.972.50 + '@aws-sdk/credential-provider-ini': 3.972.55 + '@aws-sdk/credential-provider-process': 3.972.48 + '@aws-sdk/credential-provider-sso': 3.972.54 + '@aws-sdk/credential-provider-web-identity': 3.972.54 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.25.1 + '@smithy/credential-provider-imds': 4.4.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.24': dependencies: '@aws-sdk/core': 3.973.26 @@ -18328,6 +18665,14 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.48': + dependencies: + '@aws-sdk/core': 3.974.22 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.25.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.28': dependencies: '@aws-sdk/core': 3.973.26 @@ -18341,6 +18686,16 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-sso@3.972.54': + dependencies: + '@aws-sdk/core': 3.974.22 + '@aws-sdk/nested-clients': 3.997.22 + '@aws-sdk/token-providers': 3.1071.0 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.25.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.28': dependencies: '@aws-sdk/core': 3.973.26 @@ -18353,6 +18708,15 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-web-identity@3.972.54': + dependencies: + '@aws-sdk/core': 3.974.22 + '@aws-sdk/nested-clients': 3.997.22 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.25.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@aws-sdk/eventstream-handler-node@3.972.12': dependencies: '@aws-sdk/types': 3.973.6 @@ -18367,6 +18731,11 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 + '@aws-sdk/middleware-flexible-checksums@3.974.32': + dependencies: + '@aws-sdk/checksums': 3.1000.7 + tslib: 2.8.1 + '@aws-sdk/middleware-host-header@3.972.8': dependencies: '@aws-sdk/types': 3.973.6 @@ -18388,6 +18757,15 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 + '@aws-sdk/middleware-sdk-s3@3.972.53': + dependencies: + '@aws-sdk/core': 3.974.22 + '@aws-sdk/signature-v4-multi-region': 3.996.35 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.25.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@aws-sdk/middleware-user-agent@3.972.28': dependencies: '@aws-sdk/core': 3.973.26 @@ -18457,6 +18835,19 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/nested-clients@3.997.22': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.22 + '@aws-sdk/signature-v4-multi-region': 3.996.35 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.25.1 + '@smithy/fetch-http-handler': 5.5.1 + '@smithy/node-http-handler': 4.8.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@aws-sdk/region-config-resolver@3.972.10': dependencies: '@aws-sdk/types': 3.973.6 @@ -18465,6 +18856,13 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.35': + dependencies: + '@aws-sdk/types': 3.973.13 + '@smithy/signature-v4': 5.5.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1021.0': dependencies: '@aws-sdk/core': 3.973.26 @@ -18489,9 +18887,18 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/types@3.973.5': + '@aws-sdk/token-providers@3.1071.0': + dependencies: + '@aws-sdk/core': 3.974.22 + '@aws-sdk/nested-clients': 3.997.22 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.25.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.13': dependencies: - '@smithy/types': 4.13.0 + '@smithy/types': 4.15.0 tslib: 2.8.1 '@aws-sdk/types@3.973.6': @@ -18540,6 +18947,12 @@ snapshots: fast-xml-parser: 5.5.8 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.30': + dependencies: + '@smithy/types': 4.15.0 + fast-xml-parser: 5.7.3 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.3': {} '@babel/code-frame@7.10.4': @@ -19267,39 +19680,21 @@ snapshots: '@cbor-extract/cbor-extract-darwin-arm64@2.2.0': optional: true - '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': - optional: true - '@cbor-extract/cbor-extract-darwin-x64@2.2.0': optional: true - '@cbor-extract/cbor-extract-darwin-x64@2.2.2': - optional: true - '@cbor-extract/cbor-extract-linux-arm64@2.2.0': optional: true - '@cbor-extract/cbor-extract-linux-arm64@2.2.2': - optional: true - '@cbor-extract/cbor-extract-linux-arm@2.2.0': optional: true - '@cbor-extract/cbor-extract-linux-arm@2.2.2': - optional: true - '@cbor-extract/cbor-extract-linux-x64@2.2.0': optional: true - '@cbor-extract/cbor-extract-linux-x64@2.2.2': - optional: true - '@cbor-extract/cbor-extract-win32-x64@2.2.0': optional: true - '@cbor-extract/cbor-extract-win32-x64@2.2.2': - optional: true - '@chevrotain/cst-dts-gen@11.0.3': dependencies: '@chevrotain/gast': 11.0.3 @@ -20393,7 +20788,7 @@ snapshots: google-auth-library: 10.6.2 p-retry: 4.6.2 protobufjs: 7.5.4 - ws: 8.19.0 + ws: 8.20.1 optionalDependencies: '@modelcontextprotocol/sdk': 1.25.3(hono@4.11.9)(zod@3.25.76) transitivePeerDependencies: @@ -20401,14 +20796,14 @@ snapshots: - supports-color - utf-8-validate - '@google/genai@1.48.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13))': + '@google/genai@1.48.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.3.6))': dependencies: google-auth-library: 10.6.2 p-retry: 4.6.2 protobufjs: 7.5.4 - ws: 8.19.0 + ws: 8.20.1 optionalDependencies: - '@modelcontextprotocol/sdk': 1.25.3(hono@4.11.9)(zod@4.1.13) + '@modelcontextprotocol/sdk': 1.25.3(hono@4.11.9)(zod@4.3.6) transitivePeerDependencies: - bufferutil - supports-color @@ -21068,9 +21463,9 @@ snapshots: - ws - zod - '@mariozechner/pi-agent-core@0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13))(ws@8.19.0)(zod@4.1.13)': + '@mariozechner/pi-agent-core@0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.3.6))(ws@8.20.1)(zod@4.3.6)': dependencies: - '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13))(ws@8.19.0)(zod@4.1.13) + '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.3.6))(ws@8.20.1)(zod@4.3.6) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -21093,7 +21488,7 @@ snapshots: openai: 6.26.0(ws@8.20.1)(zod@3.25.76) partial-json: 0.1.7 proxy-agent: 6.5.0 - undici: 7.24.7 + undici: 7.24.8 zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -21104,21 +21499,21 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13))(ws@8.19.0)(zod@4.1.13)': + '@mariozechner/pi-ai@0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.3.6))(ws@8.20.1)(zod@4.3.6)': dependencies: - '@anthropic-ai/sdk': 0.73.0(zod@4.1.13) + '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) '@aws-sdk/client-bedrock-runtime': 3.1024.0 - '@google/genai': 1.48.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13)) + '@google/genai': 1.48.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.3.6)) '@mistralai/mistralai': 1.14.1 '@sinclair/typebox': 0.34.41 ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) chalk: 5.6.2 - openai: 6.26.0(ws@8.19.0)(zod@4.1.13) + openai: 6.26.0(ws@8.20.1)(zod@4.3.6) partial-json: 0.1.7 proxy-agent: 6.5.0 - undici: 7.24.7 - zod-to-json-schema: 3.25.1(zod@4.1.13) + undici: 7.24.8 + zod-to-json-schema: 3.25.1(zod@4.3.6) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -21147,8 +21542,8 @@ snapshots: minimatch: 10.2.5 proper-lockfile: 4.1.2 strip-ansi: 7.1.2 - undici: 7.24.7 - yaml: 2.8.2 + undici: 7.24.8 + yaml: 2.9.0 optionalDependencies: '@mariozechner/clipboard': 0.3.2 transitivePeerDependencies: @@ -21160,11 +21555,11 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13))(ws@8.19.0)(zod@4.1.13)': + '@mariozechner/pi-coding-agent@0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.3.6))(ws@8.20.1)(zod@4.3.6)': dependencies: '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13))(ws@8.19.0)(zod@4.1.13) - '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13))(ws@8.19.0)(zod@4.1.13) + '@mariozechner/pi-agent-core': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.3.6))(ws@8.20.1)(zod@4.3.6) + '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.3.6))(ws@8.20.1)(zod@4.3.6) '@mariozechner/pi-tui': 0.60.0 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 @@ -21179,8 +21574,8 @@ snapshots: minimatch: 10.2.5 proper-lockfile: 4.1.2 strip-ansi: 7.1.2 - undici: 7.24.7 - yaml: 2.8.2 + undici: 7.24.8 + yaml: 2.9.0 optionalDependencies: '@mariozechner/clipboard': 0.3.2 transitivePeerDependencies: @@ -21409,7 +21804,7 @@ snapshots: '@mistralai/mistralai@1.14.1': dependencies: - ws: 8.19.0 + ws: 8.20.1 zod: 3.25.76 zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: @@ -21452,7 +21847,7 @@ snapshots: react-simple-code-editor: 0.14.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) serve-handler: 6.1.6 tailwind-merge: 2.6.0 - tailwindcss-animate: 1.0.7(tailwindcss@4.2.2) + tailwindcss-animate: 1.0.7(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.9.0)) zod: 3.25.76 transitivePeerDependencies: - '@cfworker/json-schema' @@ -21524,7 +21919,7 @@ snapshots: - hono - supports-color - '@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13)': + '@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.13(hono@4.11.9) ajv: 8.17.1 @@ -21540,8 +21935,8 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.1.13 - zod-to-json-schema: 3.25.1(zod@4.1.13) + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) transitivePeerDependencies: - hono - supports-color @@ -21619,6 +22014,8 @@ snapshots: '@noble/hashes@2.0.1': {} + '@nodable/entities@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -22913,18 +23310,22 @@ snapshots: '@rivet-dev/agent-os-sed': 0.0.260331072558 '@rivet-dev/agent-os-tar': 0.0.260331072558 - '@rivet-dev/agent-os-core@0.1.1(pyodide@0.28.3)': + '@rivet-dev/agent-os-core@0.0.0-main.8794200': dependencies: - '@rivet-dev/agent-os-posix': 0.1.0 - '@rivet-dev/agent-os-python': 0.1.0(pyodide@0.28.3) - '@secure-exec/core': 0.2.1 - '@secure-exec/nodejs': 0.2.1 - '@secure-exec/v8': 0.2.1 + '@aws-sdk/client-s3': 3.1073.0 + '@rivet-dev/agent-os-sidecar': 0.0.0-main.8794200 + '@rivetkit/bare-ts': 0.6.2 + '@secure-exec/core': 0.0.0-split-runtime-preview.5d46b14 + '@xterm/headless': 6.0.0 + better-sqlite3: 12.8.0 croner: 10.0.1 + googleapis: 144.0.0 + isolated-vm: 6.1.2 long-timeout: 0.1.1 - secure-exec: 0.2.1 + minimatch: 10.2.5 transitivePeerDependencies: - - pyodide + - encoding + - supports-color '@rivet-dev/agent-os-coreutils@0.0.260331072558': {} @@ -22938,50 +23339,66 @@ snapshots: '@rivet-dev/agent-os-gzip@0.0.260331072558': {} - '@rivet-dev/agent-os-pi@0.1.1(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.20.1)(zod@3.25.76)': + '@rivet-dev/agent-os-pi@0.0.0-main.8794200(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.20.1)(zod@3.25.76)': dependencies: '@agentclientprotocol/sdk': 0.16.1(zod@3.25.76) '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.20.1)(zod@3.25.76) '@mariozechner/pi-coding-agent': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.20.1)(zod@3.25.76) - '@rivet-dev/agent-os-core': 0.1.1(pyodide@0.28.3) + '@rivet-dev/agent-os-core': 0.0.0-main.8794200 transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt - bufferutil - - pyodide + - encoding - supports-color - utf-8-validate - ws - zod - '@rivet-dev/agent-os-pi@0.1.1(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13))(pyodide@0.28.3)(ws@8.19.0)(zod@4.1.13)': + '@rivet-dev/agent-os-pi@0.0.0-main.8794200(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.3.6))(ws@8.20.1)(zod@4.3.6)': dependencies: - '@agentclientprotocol/sdk': 0.16.1(zod@4.1.13) - '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13))(ws@8.19.0)(zod@4.1.13) - '@mariozechner/pi-coding-agent': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.1.13))(ws@8.19.0)(zod@4.1.13) - '@rivet-dev/agent-os-core': 0.1.1(pyodide@0.28.3) + '@agentclientprotocol/sdk': 0.16.1(zod@4.3.6) + '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.3.6))(ws@8.20.1)(zod@4.3.6) + '@mariozechner/pi-coding-agent': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@4.3.6))(ws@8.20.1)(zod@4.3.6) + '@rivet-dev/agent-os-core': 0.0.0-main.8794200 transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt - bufferutil - - pyodide + - encoding - supports-color - utf-8-validate - ws - zod - '@rivet-dev/agent-os-posix@0.1.0': + '@rivet-dev/agent-os-sandbox@0.0.0-main.8794200(get-port@7.1.0)': dependencies: - '@secure-exec/core': 0.2.1 - - '@rivet-dev/agent-os-python@0.1.0(pyodide@0.28.3)': - dependencies: - '@secure-exec/core': 0.2.1 - optionalDependencies: - pyodide: 0.28.3 + '@rivet-dev/agent-os-core': 0.0.0-main.8794200 + '@secure-exec/sandbox': 0.0.0-split-runtime-preview.5d46b14(get-port@7.1.0)(zod@4.3.6) + sandbox-agent: 0.4.2(get-port@7.1.0)(zod@4.3.6) + zod: 4.3.6 + transitivePeerDependencies: + - '@cloudflare/sandbox' + - '@daytonaio/sdk' + - '@e2b/code-interpreter' + - '@fly/sprites' + - '@vercel/sandbox' + - computesdk + - dockerode + - encoding + - get-port + - modal + - supports-color '@rivet-dev/agent-os-sed@0.0.260331072558': {} + '@rivet-dev/agent-os-sidecar-linux-x64-gnu@0.0.0-main.8794200': + optional: true + + '@rivet-dev/agent-os-sidecar@0.0.0-main.8794200': + optionalDependencies: + '@rivet-dev/agent-os-sidecar-linux-x64-gnu': 0.0.0-main.8794200 + '@rivet-dev/agent-os-tar@0.0.260331072558': {} '@rivet-gg/api@25.5.3': @@ -23289,43 +23706,67 @@ snapshots: - '@types/node' optional: true - '@sec-ant/readable-stream@0.4.1': {} - - '@secure-exec/core@0.2.1': - dependencies: - better-sqlite3: 12.8.0 + '@sandbox-agent/cli-darwin-arm64@0.4.2': + optional: true - '@secure-exec/nodejs@0.2.1': - dependencies: - '@secure-exec/core': 0.2.1 - '@secure-exec/v8': 0.2.1 - cbor-x: 1.6.4 - cjs-module-lexer: 2.2.0 - es-module-lexer: 1.7.0 - esbuild: 0.27.3 - node-stdlib-browser: 1.3.1 - web-streams-polyfill: 4.2.0 + '@sandbox-agent/cli-darwin-x64@0.4.2': + optional: true - '@secure-exec/v8-darwin-arm64@0.2.1': + '@sandbox-agent/cli-linux-arm64@0.4.2': optional: true - '@secure-exec/v8-darwin-x64@0.2.1': + '@sandbox-agent/cli-linux-x64@0.4.2': optional: true - '@secure-exec/v8-linux-arm64-gnu@0.2.1': + '@sandbox-agent/cli-shared@0.4.2': {} + + '@sandbox-agent/cli-win32-x64@0.4.2': optional: true - '@secure-exec/v8-linux-x64-gnu@0.2.1': + '@sandbox-agent/cli@0.4.2': + dependencies: + '@sandbox-agent/cli-shared': 0.4.2 + optionalDependencies: + '@sandbox-agent/cli-darwin-arm64': 0.4.2 + '@sandbox-agent/cli-darwin-x64': 0.4.2 + '@sandbox-agent/cli-linux-arm64': 0.4.2 + '@sandbox-agent/cli-linux-x64': 0.4.2 + '@sandbox-agent/cli-win32-x64': 0.4.2 optional: true - '@secure-exec/v8@0.2.1': + '@sec-ant/readable-stream@0.4.1': {} + + '@secure-exec/core@0.0.0-split-runtime-preview.5d46b14': + dependencies: + '@rivetkit/bare-ts': 0.6.2 + '@secure-exec/sidecar': 0.0.0-split-runtime-preview.5d46b14 + + '@secure-exec/s3@0.0.0-split-runtime-preview.5d46b14': + dependencies: + '@secure-exec/core': 0.0.0-split-runtime-preview.5d46b14 + + '@secure-exec/sandbox@0.0.0-split-runtime-preview.5d46b14(get-port@7.1.0)(zod@4.3.6)': dependencies: - cbor-x: 1.6.4 + '@secure-exec/core': 0.0.0-split-runtime-preview.5d46b14 + sandbox-agent: 0.4.2(get-port@7.1.0)(zod@4.3.6) + transitivePeerDependencies: + - '@cloudflare/sandbox' + - '@daytonaio/sdk' + - '@e2b/code-interpreter' + - '@fly/sprites' + - '@vercel/sandbox' + - computesdk + - dockerode + - get-port + - modal + - zod + + '@secure-exec/sidecar-linux-x64-gnu@0.0.0-split-runtime-preview.5d46b14': + optional: true + + '@secure-exec/sidecar@0.0.0-split-runtime-preview.5d46b14': optionalDependencies: - '@secure-exec/v8-darwin-arm64': 0.2.1 - '@secure-exec/v8-darwin-x64': 0.2.1 - '@secure-exec/v8-linux-arm64-gnu': 0.2.1 - '@secure-exec/v8-linux-x64-gnu': 0.2.1 + '@secure-exec/sidecar-linux-x64-gnu': 0.0.0-split-runtime-preview.5d46b14 '@sentry-internal/browser-utils@10.42.0': dependencies: @@ -23740,6 +24181,12 @@ snapshots: '@smithy/uuid': 1.1.2 tslib: 2.8.1 + '@smithy/core@3.25.1': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.12': dependencies: '@smithy/node-config-provider': 4.3.12 @@ -23748,6 +24195,12 @@ snapshots: '@smithy/url-parser': 4.2.12 tslib: 2.8.1 + '@smithy/credential-provider-imds@4.4.1': + dependencies: + '@smithy/core': 3.25.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@smithy/eventstream-codec@4.2.12': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -23786,6 +24239,12 @@ snapshots: '@smithy/util-base64': 4.3.2 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.5.1': + dependencies: + '@smithy/core': 3.25.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@smithy/hash-node@4.2.12': dependencies: '@smithy/types': 4.13.1 @@ -23861,6 +24320,12 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 + '@smithy/node-http-handler@4.8.1': + dependencies: + '@smithy/core': 3.25.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@smithy/property-provider@4.2.12': dependencies: '@smithy/types': 4.13.1 @@ -23902,6 +24367,12 @@ snapshots: '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 + '@smithy/signature-v4@5.5.1': + dependencies: + '@smithy/core': 3.25.1 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@smithy/smithy-client@4.12.8': dependencies: '@smithy/core': 3.23.13 @@ -23912,11 +24383,11 @@ snapshots: '@smithy/util-stream': 4.5.21 tslib: 2.8.1 - '@smithy/types@4.13.0': + '@smithy/types@4.13.1': dependencies: tslib: 2.8.1 - '@smithy/types@4.13.1': + '@smithy/types@4.15.0': dependencies: tslib: 2.8.1 @@ -24689,9 +25160,7 @@ snapshots: '@types/mime-types@2.1.4': {} - '@types/mime@4.0.0': - dependencies: - mime: 4.0.7 + '@types/mime@3.0.4': {} '@types/ms@2.1.0': {} @@ -25498,6 +25967,8 @@ snapshots: '@xmldom/xmldom@0.8.11': {} + '@xterm/headless@6.0.0': {} + '@xtuc/ieee754@1.2.0': optional: true @@ -25564,6 +26035,18 @@ snapshots: acorn@8.16.0: {} + acp-http-client@0.4.2(zod@3.25.76): + dependencies: + '@agentclientprotocol/sdk': 0.16.1(zod@3.25.76) + transitivePeerDependencies: + - zod + + acp-http-client@0.4.2(zod@4.3.6): + dependencies: + '@agentclientprotocol/sdk': 0.16.1(zod@4.3.6) + transitivePeerDependencies: + - zod + actor-core@0.6.3(eventsource@3.0.7)(ws@8.20.1): dependencies: cbor-x: 1.6.0 @@ -25724,6 +26207,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + anynum@1.0.1: {} + arch@2.2.0: {} arg@4.1.3: {} @@ -26390,26 +26875,10 @@ snapshots: '@cbor-extract/cbor-extract-win32-x64': 2.2.0 optional: true - cbor-extract@2.2.2: - dependencies: - node-gyp-build-optional-packages: 5.1.1 - optionalDependencies: - '@cbor-extract/cbor-extract-darwin-arm64': 2.2.2 - '@cbor-extract/cbor-extract-darwin-x64': 2.2.2 - '@cbor-extract/cbor-extract-linux-arm': 2.2.2 - '@cbor-extract/cbor-extract-linux-arm64': 2.2.2 - '@cbor-extract/cbor-extract-linux-x64': 2.2.2 - '@cbor-extract/cbor-extract-win32-x64': 2.2.2 - optional: true - cbor-x@1.6.0: optionalDependencies: cbor-extract: 2.2.0 - cbor-x@1.6.4: - optionalDependencies: - cbor-extract: 2.2.2 - ccount@2.0.1: {} chai@4.5.0: @@ -28066,12 +28535,24 @@ snapshots: dependencies: path-expression-matcher: 1.2.1 + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.6.0 + xml-naming: 0.1.0 + fast-xml-parser@5.5.8: dependencies: fast-xml-builder: 1.1.4 path-expression-matcher: 1.2.1 strnum: 2.2.0 + fast-xml-parser@5.7.3: + dependencies: + '@nodable/entities': 2.2.0 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.6.0 + strnum: 2.4.1 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -28362,6 +28843,17 @@ snapshots: fuse.js@7.1.0: {} + gaxios@6.7.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + gaxios@7.1.4: dependencies: extend: 3.0.2 @@ -28370,6 +28862,15 @@ snapshots: transitivePeerDependencies: - supports-color + gcp-metadata@6.1.1: + dependencies: + gaxios: 6.7.1 + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + gcp-metadata@8.1.2: dependencies: gaxios: 7.1.4 @@ -28526,14 +29027,56 @@ snapshots: transitivePeerDependencies: - supports-color + google-auth-library@9.15.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1 + gcp-metadata: 6.1.1 + gtoken: 7.1.0 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + google-logging-utils@0.0.2: {} + google-logging-utils@1.1.3: {} + googleapis-common@7.2.0: + dependencies: + extend: 3.0.2 + gaxios: 6.7.1 + google-auth-library: 9.15.1 + qs: 6.14.1 + url-template: 2.0.8 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + googleapis@144.0.0: + dependencies: + google-auth-library: 9.15.1 + googleapis-common: 7.2.0 + transitivePeerDependencies: + - encoding + - supports-color + gopd@1.2.0: {} graceful-fs@4.2.11: {} graphql@16.14.0: {} + gtoken@7.1.0: + dependencies: + gaxios: 6.7.1 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + h3@1.15.4: dependencies: cookie-es: 1.2.2 @@ -30634,8 +31177,6 @@ snapshots: mime@1.6.0: {} - mime@4.0.7: {} - mime@4.1.0: {} mimic-fn@1.2.0: {} @@ -31155,16 +31696,16 @@ snapshots: transitivePeerDependencies: - encoding - openai@6.26.0(ws@8.19.0)(zod@4.1.13): - optionalDependencies: - ws: 8.19.0 - zod: 4.1.13 - openai@6.26.0(ws@8.20.1)(zod@3.25.76): optionalDependencies: ws: 8.20.1 zod: 3.25.76 + openai@6.26.0(ws@8.20.1)(zod@4.3.6): + optionalDependencies: + ws: 8.20.1 + zod: 4.3.6 + openapi-types@12.1.3: {} openapi3-ts@4.5.0: @@ -31362,6 +31903,8 @@ snapshots: path-expression-matcher@1.2.1: {} + path-expression-matcher@1.6.0: {} + path-is-absolute@1.0.1: {} path-is-inside@1.0.2: {} @@ -32603,6 +33146,26 @@ snapshots: safer-buffer@2.1.2: {} + sandbox-agent@0.4.2(get-port@7.1.0)(zod@3.25.76): + dependencies: + '@sandbox-agent/cli-shared': 0.4.2 + acp-http-client: 0.4.2(zod@3.25.76) + optionalDependencies: + '@sandbox-agent/cli': 0.4.2 + get-port: 7.1.0 + transitivePeerDependencies: + - zod + + sandbox-agent@0.4.2(get-port@7.1.0)(zod@4.3.6): + dependencies: + '@sandbox-agent/cli-shared': 0.4.2 + acp-http-client: 0.4.2(zod@4.3.6) + optionalDependencies: + '@sandbox-agent/cli': 0.4.2 + get-port: 7.1.0 + transitivePeerDependencies: + - zod + sass@1.93.2: dependencies: chokidar: 4.0.3 @@ -32627,11 +33190,6 @@ snapshots: ajv-formats: 2.1.1(ajv@8.17.1) ajv-keywords: 5.1.0(ajv@8.17.1) - secure-exec@0.2.1: - dependencies: - '@secure-exec/core': 0.2.1 - '@secure-exec/nodejs': 0.2.1 - secure-exec@https://pkg.pr.new/rivet-dev/secure-exec@7659aba: dependencies: buffer: 6.0.3 @@ -33119,6 +33677,10 @@ snapshots: strnum@2.2.0: {} + strnum@2.4.1: + dependencies: + anynum: 1.0.1 + strtok3@10.3.4: dependencies: '@tokenizer/token': 0.3.0 @@ -33805,8 +34367,6 @@ snapshots: undici@6.24.1: {} - undici@7.24.7: {} - undici@7.24.8: {} undici@8.3.0: {} @@ -34013,6 +34573,8 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 + url-template@2.0.8: {} + url@0.11.4: dependencies: punycode: 1.4.1 @@ -34066,6 +34628,8 @@ snapshots: uuid@7.0.3: {} + uuid@9.0.1: {} + v8-compile-cache-lib@3.0.1: {} validate-npm-package-name@5.0.1: {} @@ -34854,8 +35418,6 @@ snapshots: web-streams-polyfill@4.0.0-beta.3: {} - web-streams-polyfill@4.2.0: {} - web-vitals@4.2.4: {} webidl-conversions@3.0.1: {} @@ -35037,6 +35599,8 @@ snapshots: dependencies: sax: 1.4.4 + xml-naming@0.1.0: {} + xml2js@0.6.0: dependencies: sax: 1.5.0 diff --git a/rivetkit-rust/Cargo.toml b/rivetkit-rust/Cargo.toml deleted file mode 100644 index f30335dd72..0000000000 --- a/rivetkit-rust/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[workspace] -resolver = "2" -members = [ - "packages/client", -] - -[workspace.package] -edition = "2021" -license = "Apache-2.0" diff --git a/rivetkit-rust/packages/client/src/encoding.rs b/rivetkit-rust/packages/client/src/encoding.rs new file mode 100644 index 0000000000..9c50e51e2c --- /dev/null +++ b/rivetkit-rust/packages/client/src/encoding.rs @@ -0,0 +1,66 @@ +//! Byte-payload decoding parity with the rivetkit TypeScript framework. +//! +//! TS sits on the server end of every action call (when invoked through +//! `rivetkit-typescript`). Action responses that contain `Uint8Array` +//! payloads arrive wrapped as `["$Uint8Array", base64]` per the +//! convention defined at +//! `rivetkit-typescript/packages/rivetkit/src/common/encoding.ts:14`. +//! +//! This module strips that wrapper before handing the result to the +//! caller. +//! +//! **Scope-limited:** only `JSON_COMPAT_UINT8_ARRAY` is recognized. +//! Other JSON-compat tags from the TS side (`$BigInt`, `$ArrayBuffer`, +//! `$Set`, `$Undefined`, etc.) are not revived — add them when a real +//! consumer needs them. +//! +//! ## Caveat — `serde_json::Value` has no byte variant +//! +//! TS's `reviveJsonCompatValue` returns a real `Uint8Array`. The Rust +//! client surface uses `serde_json::Value`, which has no native byte +//! representation. The revival strips the `["$Uint8Array", ...]` tag +//! and leaves the **base64-encoded string** as the field's value. The +//! caller knows from action-shape context which fields are bytes and +//! can decode the base64 if raw bytes are needed. +//! +//! This is a known limitation. A future revision could change the +//! action-result type to one that carries bytes natively, but that's a +//! larger API change. + +use serde_json::Value; + +/// Tag string for the `Uint8Array` JSON-compat envelope. Matches the +/// TypeScript constant. +pub const JSON_COMPAT_UINT8_ARRAY: &str = "$Uint8Array"; + +/// Walk a `serde_json::Value` and strip `["$Uint8Array", base64]` +/// wrappers, leaving the base64 string in place. +/// +/// Recurses into arrays and objects so nested byte fields get unwrapped +/// too. Non-wrapper arrays and other types pass through unchanged. +pub fn revive_json_compat(value: Value) -> Value { + match value { + Value::Array(items) if is_uint8_array_tag(&items) => { + // ["$Uint8Array", ""] → "" + // Safe: is_uint8_array_tag guarantees items[1] is a string. + items.into_iter().nth(1).expect("tagged array has 2 items") + } + Value::Array(items) => { + Value::Array(items.into_iter().map(revive_json_compat).collect()) + } + Value::Object(map) => { + let mut revived = serde_json::Map::with_capacity(map.len()); + for (k, v) in map { + revived.insert(k, revive_json_compat(v)); + } + Value::Object(revived) + } + other => other, + } +} + +fn is_uint8_array_tag(items: &[Value]) -> bool { + items.len() == 2 + && items[0].as_str() == Some(JSON_COMPAT_UINT8_ARRAY) + && items[1].is_string() +} diff --git a/rivetkit-rust/packages/client/src/lib.rs b/rivetkit-rust/packages/client/src/lib.rs index adf5ece4a3..4de3028f45 100644 --- a/rivetkit-rust/packages/client/src/lib.rs +++ b/rivetkit-rust/packages/client/src/lib.rs @@ -10,6 +10,7 @@ pub mod client; mod common; pub mod connection; pub mod drivers; +pub mod encoding; pub mod handle; pub mod protocol; mod remote_manager; diff --git a/rivetkit-rust/packages/client/src/protocol/codec.rs b/rivetkit-rust/packages/client/src/protocol/codec.rs index 71f3fd44f1..14a6b08372 100644 --- a/rivetkit-rust/packages/client/src/protocol/codec.rs +++ b/rivetkit-rust/packages/client/src/protocol/codec.rs @@ -46,20 +46,20 @@ pub fn encode_http_action_request(encoding: EncodingKind, args: &[JsonValue]) -> } pub fn decode_http_action_response(encoding: EncodingKind, payload: &[u8]) -> Result { - match encoding { + let raw = match encoding { EncodingKind::Json => { let value: JsonValue = serde_json::from_slice(payload)?; value .get("output") .cloned() - .ok_or_else(|| anyhow!("action response missing output")) + .ok_or_else(|| anyhow!("action response missing output"))? } EncodingKind::Cbor => { let value: JsonValue = serde_cbor::from_slice(payload)?; value .get("output") .cloned() - .ok_or_else(|| anyhow!("action response missing output")) + .ok_or_else(|| anyhow!("action response missing output"))? } EncodingKind::Bare => { let response = @@ -67,9 +67,13 @@ pub fn decode_http_action_response(encoding: EncodingKind, payload: &[u8]) -> Re payload, ) .context("decode bare action response")?; - Ok(serde_cbor::from_slice(&response.output)?) + serde_cbor::from_slice(&response.output)? } - } + }; + // Strip rivetkit's `["$Uint8Array", base64]` byte-payload wrappers + // (mirrors TS `reviveJsonCompatValue`). See `crate::encoding` for + // the caveat about how byte payloads are represented in JsonValue. + Ok(crate::encoding::revive_json_compat(raw)) } pub fn encode_http_queue_request( diff --git a/rivetkit-rust/packages/client/tests/encoding.rs b/rivetkit-rust/packages/client/tests/encoding.rs new file mode 100644 index 0000000000..15f06c8237 --- /dev/null +++ b/rivetkit-rust/packages/client/tests/encoding.rs @@ -0,0 +1,85 @@ +//! Decode-side tests for the `JSON_COMPAT_UINT8_ARRAY` revival. +//! +//! Mirrors `rivetkit-typescript/.../common/encoding.ts::reviveJsonCompatValue` +//! for the `Uint8Array` case. + +use rivetkit_client::encoding::revive_json_compat; +use serde_json::json; + +#[test] +fn json_compat_uint8_array_revives_to_base64_string() { + // Note: with `serde_json::Value`, we can't represent raw bytes + // natively. The tag is stripped; the base64 string remains. + // Callers know the field shape and decode as needed. + let wrapped = json!(["$Uint8Array", "aGVsbG8="]); + let revived = revive_json_compat(wrapped); + assert_eq!(revived, json!("aGVsbG8=")); +} + +#[test] +fn nested_byte_field_revives_inside_struct() { + let wrapped = json!({ + "status": 200, + "body": ["$Uint8Array", "b2s="], + }); + let revived = revive_json_compat(wrapped); + assert_eq!(revived["status"], 200); + assert_eq!(revived["body"], json!("b2s=")); +} + +#[test] +fn deeply_nested_byte_field_revives() { + let wrapped = json!({ + "outer": { + "middle": { + "inner_bytes": ["$Uint8Array", "ZGVlcA=="] + } + } + }); + let revived = revive_json_compat(wrapped); + assert_eq!(revived["outer"]["middle"]["inner_bytes"], json!("ZGVlcA==")); +} + +#[test] +fn non_byte_arrays_pass_through() { + let value = json!([1, 2, 3]); + assert_eq!(revive_json_compat(value.clone()), value); +} + +#[test] +fn unrelated_tagged_arrays_pass_through() { + // `["$BigInt", "12345"]` is a different tag; we only handle Uint8Array. + let value = json!(["$BigInt", "12345"]); + assert_eq!(revive_json_compat(value.clone()), value); + + // Random 2-element arrays where the first element isn't a recognized + // tag should pass through unchanged. + let value = json!(["hello", "world"]); + assert_eq!(revive_json_compat(value.clone()), value); +} + +#[test] +fn three_element_arrays_starting_with_tag_pass_through() { + // Only 2-element arrays with the exact tag are recognized. + let value = json!(["$Uint8Array", "data", "extra"]); + assert_eq!(revive_json_compat(value.clone()), value); +} + +#[test] +fn array_of_byte_payloads_each_revives() { + let wrapped = json!([ + ["$Uint8Array", "YQ=="], + ["$Uint8Array", "YmM="], + ]); + let revived = revive_json_compat(wrapped); + assert_eq!(revived, json!(["YQ==", "YmM="])); +} + +#[test] +fn primitives_pass_through() { + assert_eq!(revive_json_compat(json!(null)), json!(null)); + assert_eq!(revive_json_compat(json!(true)), json!(true)); + assert_eq!(revive_json_compat(json!(42)), json!(42)); + assert_eq!(revive_json_compat(json!("string")), json!("string")); + assert_eq!(revive_json_compat(json!(3.14)), json!(3.14)); +} diff --git a/rivetkit-rust/packages/rivet-actor-plugin-abi/Cargo.toml b/rivetkit-rust/packages/rivet-actor-plugin-abi/Cargo.toml new file mode 100644 index 0000000000..9df003df6c --- /dev/null +++ b/rivetkit-rust/packages/rivet-actor-plugin-abi/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "rivet-actor-plugin-abi" +version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +edition.workspace = true +workspace = "../../../" +description = "Stable C-ABI contract + generic actor wire codec for RivetKit native actor plugins loaded via dlopen." + +[dependencies] +anyhow.workspace = true +base64.workspace = true +ciborium.workspace = true +serde = { workspace = true, features = ["derive"] } diff --git a/rivetkit-rust/packages/rivet-actor-plugin-abi/src/codec.rs b/rivetkit-rust/packages/rivet-actor-plugin-abi/src/codec.rs new file mode 100644 index 0000000000..72c0a157da --- /dev/null +++ b/rivetkit-rust/packages/rivet-actor-plugin-abi/src/codec.rs @@ -0,0 +1,991 @@ +//! Generic actor wire codec (spec §4.1) — shared, in lockstep, by the RivetKit +//! host and actor plugins so action args/replies round-trip identically. +//! +//! This is rivetkit's *generic* actor encoding: the +//! `["$Uint8Array", base64]` JSON-compat byte wrapping for replies. The arg +//! decoder (`decode_positional` + the CBOR `Value` deserializer) is ported in a +//! follow-up; both live here so a change forces an ABI version bump. +//! +//! Ported verbatim from `rivetkit/src/encoding.rs`. + +use std::io::Write; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use serde::Serialize; + +/// Tag string for the `Uint8Array` JSON-compat envelope (note capital `U`). +pub const JSON_COMPAT_UINT8_ARRAY: &str = "$Uint8Array"; + +/// Encode `value` as CBOR with byte payloads wrapped per the TS convention. +pub fn encode_json_compat(value: &T, writer: W) -> anyhow::Result<()> +where + T: Serialize, + W: Write, +{ + let wrapped = JsonCompatWrap(value); + ciborium::into_writer(&wrapped, writer)?; + Ok(()) +} + +/// Convenience wrapper that encodes to a `Vec`. +pub fn encode_json_compat_to_vec(value: &T) -> anyhow::Result> { + let mut buf = Vec::new(); + encode_json_compat(value, &mut buf)?; + Ok(buf) +} + +/// Newtype that re-serializes any embedded `serialize_bytes` call as the +/// `["$Uint8Array", base64]` shape, recursively. +struct JsonCompatWrap<'a, T: ?Sized>(&'a T); + +impl Serialize for JsonCompatWrap<'_, T> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(JsonCompatSerializer { inner: serializer }) + } +} + +struct JsonCompatSerializer { + inner: S, +} + +impl serde::Serializer for JsonCompatSerializer { + type Ok = S::Ok; + type Error = S::Error; + + type SerializeSeq = JsonCompatSerializeSeq; + type SerializeTuple = JsonCompatSerializeTuple; + type SerializeTupleStruct = JsonCompatSerializeTupleStruct; + type SerializeTupleVariant = JsonCompatSerializeTupleVariant; + type SerializeMap = JsonCompatSerializeMap; + type SerializeStruct = JsonCompatSerializeStruct; + type SerializeStructVariant = JsonCompatSerializeStructVariant; + + fn serialize_bool(self, v: bool) -> Result { + self.inner.serialize_bool(v) + } + fn serialize_i8(self, v: i8) -> Result { + self.inner.serialize_i8(v) + } + fn serialize_i16(self, v: i16) -> Result { + self.inner.serialize_i16(v) + } + fn serialize_i32(self, v: i32) -> Result { + self.inner.serialize_i32(v) + } + fn serialize_i64(self, v: i64) -> Result { + self.inner.serialize_i64(v) + } + fn serialize_i128(self, v: i128) -> Result { + self.inner.serialize_i128(v) + } + fn serialize_u8(self, v: u8) -> Result { + self.inner.serialize_u8(v) + } + fn serialize_u16(self, v: u16) -> Result { + self.inner.serialize_u16(v) + } + fn serialize_u32(self, v: u32) -> Result { + self.inner.serialize_u32(v) + } + fn serialize_u64(self, v: u64) -> Result { + self.inner.serialize_u64(v) + } + fn serialize_u128(self, v: u128) -> Result { + self.inner.serialize_u128(v) + } + fn serialize_f32(self, v: f32) -> Result { + self.inner.serialize_f32(v) + } + fn serialize_f64(self, v: f64) -> Result { + self.inner.serialize_f64(v) + } + fn serialize_char(self, v: char) -> Result { + self.inner.serialize_char(v) + } + fn serialize_str(self, v: &str) -> Result { + self.inner.serialize_str(v) + } + + /// The load-bearing override: byte payloads emit the 2-element tagged array. + fn serialize_bytes(self, v: &[u8]) -> Result { + use serde::ser::SerializeTuple as _; + let base64 = BASE64_STANDARD.encode(v); + let mut tuple = self.inner.serialize_tuple(2)?; + tuple.serialize_element(JSON_COMPAT_UINT8_ARRAY)?; + tuple.serialize_element(&base64)?; + tuple.end() + } + + fn serialize_none(self) -> Result { + self.inner.serialize_none() + } + fn serialize_some(self, value: &T) -> Result { + self.inner.serialize_some(&JsonCompatWrap(value)) + } + fn serialize_unit(self) -> Result { + self.inner.serialize_unit() + } + fn serialize_unit_struct(self, name: &'static str) -> Result { + self.inner.serialize_unit_struct(name) + } + fn serialize_unit_variant( + self, + name: &'static str, + variant_index: u32, + variant: &'static str, + ) -> Result { + self.inner + .serialize_unit_variant(name, variant_index, variant) + } + fn serialize_newtype_struct( + self, + name: &'static str, + value: &T, + ) -> Result { + self.inner + .serialize_newtype_struct(name, &JsonCompatWrap(value)) + } + fn serialize_newtype_variant( + self, + name: &'static str, + variant_index: u32, + variant: &'static str, + value: &T, + ) -> Result { + self.inner + .serialize_newtype_variant(name, variant_index, variant, &JsonCompatWrap(value)) + } + fn serialize_seq(self, len: Option) -> Result { + Ok(JsonCompatSerializeSeq { + inner: self.inner.serialize_seq(len)?, + }) + } + fn serialize_tuple(self, len: usize) -> Result { + Ok(JsonCompatSerializeTuple { + inner: self.inner.serialize_tuple(len)?, + }) + } + fn serialize_tuple_struct( + self, + name: &'static str, + len: usize, + ) -> Result { + Ok(JsonCompatSerializeTupleStruct { + inner: self.inner.serialize_tuple_struct(name, len)?, + }) + } + fn serialize_tuple_variant( + self, + name: &'static str, + variant_index: u32, + variant: &'static str, + len: usize, + ) -> Result { + Ok(JsonCompatSerializeTupleVariant { + inner: self + .inner + .serialize_tuple_variant(name, variant_index, variant, len)?, + }) + } + fn serialize_map(self, len: Option) -> Result { + Ok(JsonCompatSerializeMap { + inner: self.inner.serialize_map(len)?, + }) + } + fn serialize_struct( + self, + name: &'static str, + len: usize, + ) -> Result { + Ok(JsonCompatSerializeStruct { + inner: self.inner.serialize_struct(name, len)?, + }) + } + fn serialize_struct_variant( + self, + name: &'static str, + variant_index: u32, + variant: &'static str, + len: usize, + ) -> Result { + Ok(JsonCompatSerializeStructVariant { + inner: self + .inner + .serialize_struct_variant(name, variant_index, variant, len)?, + }) + } +} + +macro_rules! compat_seq { + ($name:ident, $trait:path, $method:ident) => { + struct $name { + inner: S, + } + impl $trait for $name { + type Ok = S::Ok; + type Error = S::Error; + fn $method(&mut self, value: &T) -> Result<(), Self::Error> { + self.inner.$method(&JsonCompatWrap(value)) + } + fn end(self) -> Result { + self.inner.end() + } + } + }; +} + +compat_seq!( + JsonCompatSerializeSeq, + serde::ser::SerializeSeq, + serialize_element +); +compat_seq!( + JsonCompatSerializeTuple, + serde::ser::SerializeTuple, + serialize_element +); +compat_seq!( + JsonCompatSerializeTupleStruct, + serde::ser::SerializeTupleStruct, + serialize_field +); +compat_seq!( + JsonCompatSerializeTupleVariant, + serde::ser::SerializeTupleVariant, + serialize_field +); + +struct JsonCompatSerializeMap { + inner: S, +} +impl serde::ser::SerializeMap for JsonCompatSerializeMap { + type Ok = S::Ok; + type Error = S::Error; + fn serialize_key(&mut self, key: &T) -> Result<(), Self::Error> { + self.inner.serialize_key(&JsonCompatWrap(key)) + } + fn serialize_value(&mut self, value: &T) -> Result<(), Self::Error> { + self.inner.serialize_value(&JsonCompatWrap(value)) + } + fn end(self) -> Result { + self.inner.end() + } +} + +struct JsonCompatSerializeStruct { + inner: S, +} +impl serde::ser::SerializeStruct for JsonCompatSerializeStruct { + type Ok = S::Ok; + type Error = S::Error; + fn serialize_field( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), Self::Error> { + self.inner.serialize_field(key, &JsonCompatWrap(value)) + } + fn end(self) -> Result { + self.inner.end() + } +} + +struct JsonCompatSerializeStructVariant { + inner: S, +} +impl serde::ser::SerializeStructVariant + for JsonCompatSerializeStructVariant +{ + type Ok = S::Ok; + type Error = S::Error; + fn serialize_field( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), Self::Error> { + self.inner.serialize_field(key, &JsonCompatWrap(value)) + } + fn end(self) -> Result { + self.inner.end() + } +} + +// =========================================================================== +// Arg decode codec — `decode_positional`/`encode_positional` + the CBOR +// `Value` deserializer. Ported verbatim from rivetkit `action.rs` + `event.rs`. +// =========================================================================== + +use std::fmt; +use std::io::Cursor; + +use anyhow::Context as _; +use ciborium::Value; +use serde::Deserialize; +use serde::de::{ + self, DeserializeOwned, DeserializeSeed, EnumAccess, MapAccess, SeqAccess, VariantAccess, + Visitor, +}; + +pub const TUPLE_ARITY_MAX: usize = 16; + +/// Encode `value` as the positional CBOR array action args use. +pub fn encode_positional(value: &T) -> anyhow::Result> { + let mut encoded = Vec::new(); + ciborium::into_writer(value, &mut encoded).context("encode action args as cbor")?; + let value: Value = ciborium::from_reader(Cursor::new(&encoded)) + .context("decode action args into cbor value")?; + let value = positional_value(value); + encode_value(&value) +} + +/// Decode positional CBOR action args into `T`, normalizing map/null/newtype. +pub fn decode_positional(args: &[u8]) -> anyhow::Result { + let value = if args.is_empty() { + Value::Array(Vec::new()) + } else { + ciborium::from_reader(Cursor::new(args)).context("decode action args from cbor")? + }; + let value = match value { + Value::Null => Value::Array(Vec::new()), + value => value, + }; + match decode_value::(&value) { + Ok(value) => Ok(value), + Err(first_error) => match &value { + Value::Array(values) if values.is_empty() => decode_value(&Value::Null) + .or_else(|_| Err(first_error).context("decode positional action args as unit")), + Value::Array(values) if values.len() == 1 => decode_value(&values[0]) + .or_else(|_| Err(first_error).context("decode positional action args as newtype")), + _ => Err(first_error).context("decode positional action args"), + }, + } +} + +fn positional_value(value: Value) -> Value { + match value { + Value::Map(entries) => Value::Array(entries.into_iter().map(|(_, value)| value).collect()), + Value::Array(values) => Value::Array(values), + Value::Null => Value::Array(Vec::new()), + value => Value::Array(vec![value]), + } +} + +fn encode_value(value: &Value) -> anyhow::Result> { + let mut encoded = Vec::new(); + ciborium::into_writer(value, &mut encoded).context("encode positional action args as cbor")?; + Ok(encoded) +} + +fn decode_value(value: &Value) -> anyhow::Result { + deserialize_cbor_value(value.clone()) + .map_err(|error| anyhow::anyhow!(error.to_string())) + .context("decode positional action args from cbor") +} + +pub fn deserialize_cbor_value(value: Value) -> Result { + T::deserialize(ValueDeserializer::new(value)) +} + +struct ValueDeserializer { + value: Value, +} + +impl ValueDeserializer { + fn new(value: Value) -> Self { + Self { value } + } +} + +impl<'de> de::Deserializer<'de> for ValueDeserializer { + type Error = de::value::Error; + + fn deserialize_any(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + match self.value { + Value::Bool(value) => visitor.visit_bool(value), + Value::Integer(value) => { + let value = i128::from(value); + if value < 0 { + if let Ok(value) = i64::try_from(value) { + visitor.visit_i64(value) + } else { + visitor.visit_i128(value) + } + } else if let Ok(value) = u64::try_from(value) { + visitor.visit_u64(value) + } else { + visitor.visit_u128(value as u128) + } + } + Value::Float(value) => visitor.visit_f64(value), + Value::Bytes(value) => visitor.visit_byte_buf(value), + Value::Text(value) => visitor.visit_string(value), + Value::Null => visitor.visit_unit(), + Value::Array(values) => visitor.visit_seq(ValueSeqAccess { + values: values.into_iter(), + }), + Value::Map(entries) => visitor.visit_map(ValueMapAccess { + entries: entries.into_iter(), + value: None, + }), + Value::Tag(_, _) => Err(de::Error::custom( + "tagged action payloads are not supported", + )), + _ => Err(de::Error::custom("unsupported action payload value")), + } + } + + fn deserialize_bool(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + match self.value { + Value::Bool(value) => visitor.visit_bool(value), + other => Err(invalid_type(&other, "a bool")), + } + } + + fn deserialize_i8(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_i8(expect_signed(self.value, "an i8")?) + } + fn deserialize_i16(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_i16(expect_signed(self.value, "an i16")?) + } + fn deserialize_i32(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_i32(expect_signed(self.value, "an i32")?) + } + fn deserialize_i64(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_i64(expect_signed(self.value, "an i64")?) + } + fn deserialize_i128(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_i128(expect_signed(self.value, "an i128")?) + } + fn deserialize_u8(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_u8(expect_unsigned(self.value, "a u8")?) + } + fn deserialize_u16(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_u16(expect_unsigned(self.value, "a u16")?) + } + fn deserialize_u32(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_u32(expect_unsigned(self.value, "a u32")?) + } + fn deserialize_u64(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_u64(expect_unsigned(self.value, "a u64")?) + } + fn deserialize_u128(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_u128(expect_unsigned(self.value, "a u128")?) + } + + fn deserialize_f32(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + match self.value { + Value::Float(value) => visitor.visit_f32(value as f32), + other => Err(invalid_type(&other, "an f32")), + } + } + fn deserialize_f64(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + match self.value { + Value::Float(value) => visitor.visit_f64(value), + other => Err(invalid_type(&other, "an f64")), + } + } + + fn deserialize_char(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + match self.value { + Value::Text(value) => { + let mut chars = value.chars(); + match (chars.next(), chars.next()) { + (Some(ch), None) => visitor.visit_char(ch), + _ => Err(de::Error::custom("expected a single-character string")), + } + } + other => Err(invalid_type(&other, "a char")), + } + } + + fn deserialize_str(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + match self.value { + Value::Text(value) => visitor.visit_string(value), + other => Err(invalid_type(&other, "a string")), + } + } + fn deserialize_string(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + self.deserialize_str(visitor) + } + + fn deserialize_bytes(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + match self.value { + Value::Bytes(value) => visitor.visit_byte_buf(value), + other => Err(invalid_type(&other, "bytes")), + } + } + fn deserialize_byte_buf(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + self.deserialize_bytes(visitor) + } + + fn deserialize_option(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + match self.value { + Value::Null => visitor.visit_none(), + other => visitor.visit_some(ValueDeserializer::new(other)), + } + } + + fn deserialize_unit(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + match self.value { + Value::Null => visitor.visit_unit(), + other => Err(invalid_type(&other, "null")), + } + } + fn deserialize_unit_struct( + self, + _name: &'static str, + visitor: V, + ) -> Result + where + V: Visitor<'de>, + { + self.deserialize_unit(visitor) + } + fn deserialize_newtype_struct( + self, + _name: &'static str, + visitor: V, + ) -> Result + where + V: Visitor<'de>, + { + visitor.visit_newtype_struct(self) + } + + fn deserialize_seq(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + match self.value { + Value::Array(values) => visitor.visit_seq(ValueSeqAccess { + values: values.into_iter(), + }), + other => Err(invalid_type(&other, "an array")), + } + } + + fn deserialize_tuple(self, len: usize, visitor: V) -> Result + where + V: Visitor<'de>, + { + match self.value { + Value::Array(values) => { + if values.len() != len { + return Err(de::Error::custom(format!( + "expected tuple action payload with {len} elements, got {}", + values.len() + ))); + } + visitor.visit_seq(ValueSeqAccess { + values: values.into_iter(), + }) + } + other => Err(invalid_type(&other, "an array")), + } + } + fn deserialize_tuple_struct( + self, + _name: &'static str, + len: usize, + visitor: V, + ) -> Result + where + V: Visitor<'de>, + { + self.deserialize_tuple(len, visitor) + } + + fn deserialize_map(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + match self.value { + Value::Map(entries) => visitor.visit_map(ValueMapAccess { + entries: entries.into_iter(), + value: None, + }), + other => Err(invalid_type(&other, "a map")), + } + } + + fn deserialize_struct( + self, + _name: &'static str, + _fields: &'static [&'static str], + visitor: V, + ) -> Result + where + V: Visitor<'de>, + { + match self.value { + Value::Map(entries) => visitor.visit_map(ValueMapAccess { + entries: entries.into_iter(), + value: None, + }), + Value::Array(values) => visitor.visit_seq(ValueSeqAccess { + values: values.into_iter(), + }), + other => Err(invalid_type(&other, "a map or array")), + } + } + + fn deserialize_enum( + self, + _name: &'static str, + _variants: &'static [&'static str], + visitor: V, + ) -> Result + where + V: Visitor<'de>, + { + match self.value { + Value::Text(variant) => visitor.visit_enum(ValueEnumAccess { + variant, + value: None, + }), + Value::Map(mut entries) if entries.len() == 1 => { + let Some((key, value)) = entries.pop() else { + return Err(de::Error::custom( + "expected externally tagged enum map to contain one entry", + )); + }; + match key { + Value::Text(variant) => visitor.visit_enum(ValueEnumAccess { + variant, + value: Some(value), + }), + other => Err(invalid_type(&other, "a string enum variant")), + } + } + other => Err(invalid_type(&other, "an externally tagged enum")), + } + } + + fn deserialize_identifier(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + self.deserialize_str(visitor) + } + fn deserialize_ignored_any(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_unit() + } +} + +struct ValueSeqAccess { + values: std::vec::IntoIter, +} +impl<'de> SeqAccess<'de> for ValueSeqAccess { + type Error = de::value::Error; + fn next_element_seed(&mut self, seed: T) -> Result, Self::Error> + where + T: DeserializeSeed<'de>, + { + self.values + .next() + .map(|value| seed.deserialize(ValueDeserializer::new(value))) + .transpose() + } + fn size_hint(&self) -> Option { + Some(self.values.len()) + } +} + +struct ValueMapAccess { + entries: std::vec::IntoIter<(Value, Value)>, + value: Option, +} +impl<'de> MapAccess<'de> for ValueMapAccess { + type Error = de::value::Error; + fn next_key_seed(&mut self, seed: K) -> Result, Self::Error> + where + K: DeserializeSeed<'de>, + { + match self.entries.next() { + Some((key, value)) => { + self.value = Some(value); + seed.deserialize(ValueDeserializer::new(key)).map(Some) + } + None => Ok(None), + } + } + fn next_value_seed(&mut self, seed: V) -> Result + where + V: DeserializeSeed<'de>, + { + let value = self + .value + .take() + .ok_or_else(|| de::Error::custom("value requested before key"))?; + seed.deserialize(ValueDeserializer::new(value)) + } + fn size_hint(&self) -> Option { + Some(self.entries.len()) + } +} + +struct ValueEnumAccess { + variant: String, + value: Option, +} +impl<'de> EnumAccess<'de> for ValueEnumAccess { + type Error = de::value::Error; + type Variant = ValueVariantAccess; + fn variant_seed(self, seed: V) -> Result<(V::Value, Self::Variant), Self::Error> + where + V: DeserializeSeed<'de>, + { + let variant = seed.deserialize( + serde::de::value::StringDeserializer::::new(self.variant), + )?; + Ok((variant, ValueVariantAccess { value: self.value })) + } +} + +struct ValueVariantAccess { + value: Option, +} +impl<'de> VariantAccess<'de> for ValueVariantAccess { + type Error = de::value::Error; + fn unit_variant(self) -> Result<(), Self::Error> { + match self.value { + None | Some(Value::Null) => Ok(()), + Some(other) => Err(invalid_type(&other, "null")), + } + } + fn newtype_variant_seed(self, seed: T) -> Result + where + T: DeserializeSeed<'de>, + { + seed.deserialize(ValueDeserializer::new(self.value.unwrap_or(Value::Null))) + } + fn tuple_variant(self, len: usize, visitor: V) -> Result + where + V: Visitor<'de>, + { + de::Deserializer::deserialize_tuple( + ValueDeserializer::new(self.value.unwrap_or(Value::Null)), + len, + visitor, + ) + } + fn struct_variant( + self, + fields: &'static [&'static str], + visitor: V, + ) -> Result + where + V: Visitor<'de>, + { + de::Deserializer::deserialize_struct( + ValueDeserializer::new(self.value.unwrap_or(Value::Null)), + "enum", + fields, + visitor, + ) + } +} + +fn expect_signed(value: Value, expected: &'static str) -> Result +where + T: TryFrom, +{ + match value { + Value::Integer(value) => T::try_from(i128::from(value)) + .map_err(|_| de::Error::custom(format!("expected {expected}"))), + other => Err(invalid_type(&other, expected)), + } +} + +fn expect_unsigned(value: Value, expected: &'static str) -> Result +where + T: TryFrom, +{ + match value { + Value::Integer(value) => T::try_from( + u128::try_from(value).map_err(|_| de::Error::custom(format!("expected {expected}")))?, + ) + .map_err(|_| de::Error::custom(format!("expected {expected}"))), + other => Err(invalid_type(&other, expected)), + } +} + +fn invalid_type(value: &Value, expected: &'static str) -> de::value::Error { + de::Error::invalid_type(unexpected(value), &Expected(expected)) +} + +fn unexpected(value: &Value) -> de::Unexpected<'_> { + match value { + Value::Bool(value) => de::Unexpected::Bool(*value), + Value::Integer(value) => { + let signed = i128::from(*value); + if signed < 0 { + if let Ok(value) = i64::try_from(signed) { + de::Unexpected::Signed(value) + } else { + de::Unexpected::Other("integer") + } + } else if let Ok(value) = u64::try_from(signed) { + de::Unexpected::Unsigned(value) + } else { + de::Unexpected::Other("integer") + } + } + Value::Float(value) => de::Unexpected::Float(*value), + Value::Bytes(value) => de::Unexpected::Bytes(value), + Value::Text(value) => de::Unexpected::Str(value), + Value::Null => de::Unexpected::Other("null"), + Value::Tag(_, _) => de::Unexpected::Other("tag"), + Value::Array(_) => de::Unexpected::Seq, + Value::Map(_) => de::Unexpected::Map, + _ => de::Unexpected::Other("value"), + } +} + +struct Expected(&'static str); +impl de::Expected for Expected { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.0) + } +} + +/// `Raw` action marker: refuses serde decoding (use `decode_positional`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Raw; +impl<'de> Deserialize<'de> for Raw { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let _ = de::IgnoredAny::deserialize(deserializer)?; + Err(de::Error::custom( + "Raw cannot be deserialized; use decode_positional instead", + )) + } +} + +#[cfg(test)] +mod decode_tests { + use super::{decode_positional, encode_positional}; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] + struct NamedArgs { + first: String, + second: String, + } + #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] + struct TupleArgs(String, String); + #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] + struct NewtypeArg(u32); + #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] + struct UnitArg; + + #[test] + fn positional_encode_has_ts_byte_parity() { + assert_eq!( + encode_positional(&NamedArgs { + first: "a".into(), + second: "b".into() + }) + .unwrap(), + vec![0x82, 0x61, b'a', 0x61, b'b'] + ); + assert_eq!(encode_positional(&NewtypeArg(5)).unwrap(), vec![0x81, 0x05]); + assert_eq!(encode_positional(&UnitArg).unwrap(), vec![0x80]); + } + + #[test] + fn positional_round_trips_arg_shapes() { + let named = NamedArgs { + first: "a".into(), + second: "b".into(), + }; + assert_eq!( + decode_positional::(&encode_positional(&named).unwrap()).unwrap(), + named + ); + let tuple = TupleArgs("a".into(), "b".into()); + assert_eq!( + decode_positional::(&encode_positional(&tuple).unwrap()).unwrap(), + tuple + ); + assert_eq!( + decode_positional::(&encode_positional(&NewtypeArg(5)).unwrap()).unwrap(), + NewtypeArg(5) + ); + assert_eq!( + decode_positional::(&encode_positional(&UnitArg).unwrap()).unwrap(), + UnitArg + ); + } +} diff --git a/rivetkit-rust/packages/rivet-actor-plugin-abi/src/lib.rs b/rivetkit-rust/packages/rivet-actor-plugin-abi/src/lib.rs new file mode 100644 index 0000000000..da89ed1d27 --- /dev/null +++ b/rivetkit-rust/packages/rivet-actor-plugin-abi/src/lib.rs @@ -0,0 +1,525 @@ +//! Stable C-ABI contract for RivetKit native actor plugins loaded via `dlopen`. +//! +//! This crate is the **generic** contract shared, in lockstep, by the RivetKit +//! host (which `dlopen`s a plugin) and an actor plugin `cdylib`. +//! It contains **no** product-specific or business logic — only: +//! +//! 1. the ABI version + magic constants (§4.1 of the dylib-actor-plugin spec), +//! 2. the `#[repr(C)]` boundary types (buffers, results, events, the host +//! vtable), +//! 3. the exported plugin symbol names the host resolves after `dlopen`, +//! 4. (added in a follow-up module) the generic actor wire codec. +//! +//! ## Memory & safety model (normative) +//! +//! - Only `#[repr(C)]` scalars, opaque pointers, and length-prefixed byte +//! buffers cross the boundary. No `String`/`Vec`/`Box`/`Future`/ +//! `serde_json::Value` is ever passed by value. +//! - **One free path:** every [`OwnedBuf`] is freed exactly once by calling its +//! own non-optional `free` pointer. This is allocator-correct regardless of +//! which side produced the buffer (the two binaries link independent +//! allocators). +//! - Inputs to an **async submit** transfer ownership via [`OwnedBuf`] (the +//! callee frees them when done). [`BorrowedBuf`] is only valid for the +//! duration of a **synchronous** call. +//! - Every `extern "C"` boundary (both directions, incl. completion callbacks) +//! must wrap its body in `catch_unwind`; a panic across `extern "C"` is UB. + +#![allow(clippy::missing_safety_doc)] +// FFI glue: these `unsafe fn`s are unsafe in their entirety by design; an +// inner-block-per-op convention adds noise without adding safety here. +#![allow(unsafe_op_in_unsafe_fn)] + +pub mod codec; +pub mod portable; + +use std::ffi::c_void; + +pub use portable::{ + ActorIdentity, Backend, ConnDisconnectRequest, ConnInfo, ConnListResponse, ConnSendRequest, + DylibBackend, Event, KeepAwakeToken, KvEntriesRequest, KvEntry, KvGetResponse, KvKeyRequest, + KvKeysRequest, KvListOpts, KvListPrefixRequest, KvListRangeRequest, KvListResponse, + KvRangeRequest, KvValuesResponse, PortableActorBackend, PortableActorCtx, PortableBoxFuture, + QueueSendResponse, ReplyToken, RequestSaveOpts, ScheduleActionRequest, ScheduleAlarmRequest, + ScheduledEvent, ScheduledEventsResponse, WsOpenPayload, decode_action_payload, + decode_event_frame, encode_action_payload, encode_conn_closed_payload, + encode_conn_open_payload, encode_conn_preflight_payload, encode_event_frame, + encode_queue_send_payload, encode_queue_send_response, encode_subscribe_payload, + encode_ws_open_payload, +}; + +/// Bumped on ANY change to the structs, symbol signatures, the wire codec, or +/// the event-tag enum in this crate. The host refuses to load a plugin whose +/// reported version != this. No negotiation, no fallback (same-version +/// lockstep, matching the project's wire-protocol rule). +pub const RIVET_ACTOR_ABI_VERSION: u64 = 13; + +/// Magic returned by [`SYM_ABI_MAGIC`] to detect "this `.so` is not a rivet +/// actor plugin at all" before any other symbol is called. ASCII "RVTABI\0\1". +pub const RIVET_ACTOR_ABI_MAGIC: u64 = 0x52_56_54_41_42_49_00_01; + +/// Exported plugin symbol names the host resolves with `dlsym` after `dlopen`. +/// Kept as constants so host and plugin can never disagree on spelling. +pub mod symbols { + /// `extern "C" fn() -> u64` — cheap, no allocation, no fallible work. + /// Host calls this FIRST and aborts the load if it != [`super::RIVET_ACTOR_ABI_MAGIC`]. + pub const ABI_MAGIC: &[u8] = b"rivet_actor_abi_magic\0"; + /// `extern "C" fn() -> u64` — must equal [`super::RIVET_ACTOR_ABI_VERSION`]. + pub const ABI_VERSION: &[u8] = b"rivet_actor_abi_version\0"; + /// `extern "C" fn(out_err: *mut OwnedBuf) -> *mut c_void` — once per load. + pub const PLUGIN_INIT: &[u8] = b"rivet_actor_plugin_init\0"; + /// `extern "C" fn(plugin, config_json: BorrowedBuf, sidecar_path: BorrowedBuf, out_err: *mut OwnedBuf) -> *mut c_void`. + pub const FACTORY_NEW: &[u8] = b"rivet_actor_factory_new\0"; + /// `extern "C" fn(factory, host: *const HostVtable, done: CompletionFn, user_data: *mut c_void) -> *mut c_void`. + pub const RUN: &[u8] = b"rivet_actor_run\0"; + /// `extern "C" fn(instance: *mut c_void)` — closes the event stream. + pub const CANCEL: &[u8] = b"rivet_actor_cancel\0"; + /// `extern "C" fn(instance: *mut c_void)` — force VM teardown on grace deadline. + pub const GRACE_DEADLINE: &[u8] = b"rivet_actor_grace_deadline\0"; + /// `extern "C" fn(instance: *mut c_void)` — only after `run`'s completion fired. + pub const INSTANCE_FREE: &[u8] = b"rivet_actor_instance_free\0"; + /// `extern "C" fn(factory: *mut c_void)`. + pub const FACTORY_FREE: &[u8] = b"rivet_actor_factory_free\0"; + /// `extern "C" fn(plugin: *mut c_void)` — drains the plugin runtime. + pub const PLUGIN_SHUTDOWN: &[u8] = b"rivet_actor_plugin_shutdown\0"; +} + +/// Borrowed, immutable bytes. Valid ONLY for the duration of a **synchronous** +/// call. MUST NOT be used as input to an async submit (the bytes would be read +/// after the submit returns — use-after-free). Use [`OwnedBuf`] there. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct BorrowedBuf { + pub ptr: *const u8, + pub len: usize, +} + +impl BorrowedBuf { + /// Borrow a slice for the duration of a synchronous call. The slice MUST + /// outlive the call. + pub fn from_slice(s: &[u8]) -> Self { + Self { + ptr: s.as_ptr(), + len: s.len(), + } + } + + /// # Safety + /// `ptr`/`len` must describe a valid, initialized region that outlives the + /// returned slice's use. + pub unsafe fn as_slice<'a>(&self) -> &'a [u8] { + if self.len == 0 { + &[] + } else { + std::slice::from_raw_parts(self.ptr, self.len) + } + } +} + +/// Owned bytes. Freed EXACTLY ONCE by calling its own `free` (non-optional). +/// Empty buffers use [`noop_free`], never a null `free`. +#[repr(C)] +pub struct OwnedBuf { + pub ptr: *mut u8, + pub len: usize, + pub cap: usize, + /// Frees this buffer with the allocator of the side that produced it. + pub free: extern "C" fn(ptr: *mut u8, len: usize, cap: usize), +} + +/// `free` impl for an [`OwnedBuf`] backed by a Rust `Vec` allocated on +/// *this* side. Each side uses its own `from_vec`/`free` pair, so frees are +/// always allocator-correct. +pub extern "C" fn free_rust_vec(ptr: *mut u8, len: usize, cap: usize) { + if !ptr.is_null() && cap != 0 { + // SAFETY: ptr/len/cap came from `Vec::into_raw_parts` on this side. + unsafe { + drop(Vec::from_raw_parts(ptr, len, cap)); + } + } +} + +/// `free` impl for empty/borrowed-dangling buffers; does nothing. +pub extern "C" fn noop_free(_ptr: *mut u8, _len: usize, _cap: usize) {} + +impl OwnedBuf { + /// Transfer ownership of a `Vec` across the boundary. The receiver must + /// call [`OwnedBuf::free_self`] (or consume via [`OwnedBuf::into_vec`]) + /// exactly once. + pub fn from_vec(mut v: Vec) -> Self { + if v.capacity() == 0 { + return Self::empty(); + } + let ptr = v.as_mut_ptr(); + let len = v.len(); + let cap = v.capacity(); + std::mem::forget(v); + Self { + ptr, + len, + cap, + free: free_rust_vec, + } + } + + /// An empty buffer with a no-op free. + pub fn empty() -> Self { + Self { + ptr: std::ptr::NonNull::dangling().as_ptr(), + len: 0, + cap: 0, + free: noop_free, + } + } + + /// View the bytes without taking ownership. + /// + /// # Safety + /// `self` must be a valid buffer that outlives the returned slice. + pub unsafe fn as_slice(&self) -> &[u8] { + if self.len == 0 { + &[] + } else { + std::slice::from_raw_parts(self.ptr, self.len) + } + } + + /// Take ownership of the bytes IF this buffer was produced on this side via + /// [`OwnedBuf::from_vec`] (i.e. `free == free_rust_vec`). Otherwise copies + /// the bytes and frees the original with its own `free`. + /// + /// # Safety + /// `self` must be a valid, not-yet-freed buffer. Consumes it (do not free + /// again). + pub unsafe fn into_vec(self) -> Vec { + if self.cap == 0 { + return Vec::new(); + } + if self.free as *const () as usize == free_rust_vec as *const () as usize { + Vec::from_raw_parts(self.ptr, self.len, self.cap) + } else { + // Cross-side buffer: copy out, then free with the producer's free. + let copy = std::slice::from_raw_parts(self.ptr, self.len).to_vec(); + (self.free)(self.ptr, self.len, self.cap); + copy + } + } + + /// Free this buffer via its own allocator. Call exactly once. + /// + /// # Safety + /// `self` must be a valid, not-yet-freed buffer; do not use it afterward. + pub unsafe fn free_self(self) { + (self.free)(self.ptr, self.len, self.cap); + } +} + +/// Status of a completed cross-boundary operation. +#[repr(i32)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AbiStatus { + Ok = 0, + /// Application error; `payload` carries a structured error (see codec). + Err = 1, + /// A panic was caught at a boundary; the operation did not complete. + Panic = 2, + /// The operation was cancelled. + Cancelled = 3, + /// The event stream is closed (terminal for `next_event`). + ChannelClosed = 4, +} + +/// Result of an async submit: status + payload. The completion callback ALWAYS +/// takes ownership of `payload` and frees it, on every path. +#[repr(C)] +pub struct AbiResult { + pub status: AbiStatus, + pub payload: OwnedBuf, +} + +impl AbiResult { + pub fn ok(payload: OwnedBuf) -> Self { + Self { + status: AbiStatus::Ok, + payload, + } + } + pub fn err(payload: OwnedBuf) -> Self { + Self { + status: AbiStatus::Err, + payload, + } + } + pub fn status_only(status: AbiStatus) -> Self { + Self { + status, + payload: OwnedBuf::empty(), + } + } + pub fn channel_closed() -> Self { + Self::status_only(AbiStatus::ChannelClosed) + } +} + +/// Called EXACTLY once, from any thread, when an async op completes. Reclaims +/// `user_data`, takes ownership of `result.payload` (frees it), and MUST be +/// wrapped in `catch_unwind` by the implementer. +pub type CompletionFn = extern "C" fn(user_data: *mut c_void, result: AbiResult); + +/// A lifecycle event delivered to the plugin via `next_event`. Encoded into a +/// [`CompletionFn`] `AbiResult` payload as `(tag, reply_token, event_bytes)`. +/// `reply_token == 0` means the event needs no reply. +#[repr(C)] +pub struct AbiEvent { + pub tag: u32, + pub reply_token: u64, + pub payload: OwnedBuf, +} + +/// Generic actor lifecycle event tags, generated to match RivetKit's +/// `RuntimeEvent`. These are generic and product-agnostic. Payloads are opaque +/// bytes; reply semantics are documented per tag in the spec (§4.6). +#[repr(u32)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AbiEventTag { + Action = 0, + Http = 1, + Subscribe = 2, + ConnOpen = 3, + ConnClosed = 4, + QueueSend = 5, + WsOpen = 6, + SerializeState = 7, + Sleep = 8, + Destroy = 9, + ConnPreflight = 10, +} + +impl AbiEventTag { + pub fn from_u32(v: u32) -> Option { + Some(match v { + 0 => Self::Action, + 1 => Self::Http, + 2 => Self::Subscribe, + 3 => Self::ConnOpen, + 4 => Self::ConnClosed, + 5 => Self::QueueSend, + 6 => Self::WsOpen, + 7 => Self::SerializeState, + 8 => Self::Sleep, + 9 => Self::Destroy, + 10 => Self::ConnPreflight, + _ => return None, + }) + } + + /// Whether the host expects a `reply_ok`/`reply_err` for this event. + pub fn needs_reply(self) -> bool { + !matches!(self, Self::ConnClosed) + } +} + +// --- Host vtable fn-pointer aliases (plugin -> host) --- + +/// Async submit (`db_exec`): `(ctx, sql, done, user_data)`. `sql` ownership is +/// transferred (host frees it when the spawned task is done reading). +pub type DbExecFn = + extern "C" fn(ctx: *const c_void, sql: OwnedBuf, done: CompletionFn, user_data: *mut c_void); +/// Async submit (`db_query`/`db_run`): `(ctx, sql, params, done, user_data)`. +pub type DbSqlFn = extern "C" fn( + ctx: *const c_void, + sql: OwnedBuf, + params: OwnedBuf, + done: CompletionFn, + user_data: *mut c_void, +); +/// Sync actor-state snapshot read: `(ctx) -> state_bytes`. +pub type StateGetFn = extern "C" fn(ctx: *const c_void) -> OwnedBuf; +/// Sync actor-state mutation: `(ctx, state_bytes) -> AbiStatus`. +pub type StateSetFn = extern "C" fn(ctx: *const c_void, state: OwnedBuf) -> AbiStatus; +/// Sync actor identity snapshot read: `(ctx) -> cbor(ActorIdentity)`. +pub type ActorIdentityFn = extern "C" fn(ctx: *const c_void) -> OwnedBuf; +/// Async actor-state save: `(ctx, state_bytes, done, user_data)`. +pub type StateSaveFn = + extern "C" fn(ctx: *const c_void, state: OwnedBuf, done: CompletionFn, user_data: *mut c_void); +/// Sync save request: `(ctx, immediate, has_max_wait, max_wait_ms) -> AbiStatus`. +pub type RequestSaveFn = extern "C" fn( + ctx: *const c_void, + immediate: u8, + has_max_wait: u8, + max_wait_ms: u32, +) -> AbiStatus; +/// Async save request with completion: `(ctx, immediate, has_max_wait, max_wait_ms, done, user_data)`. +pub type RequestSaveAndWaitFn = extern "C" fn( + ctx: *const c_void, + immediate: u8, + has_max_wait: u8, + max_wait_ms: u32, + done: CompletionFn, + user_data: *mut c_void, +); +/// Sync actor sleep request: `(ctx) -> AbiResult`. +pub type SleepFn = extern "C" fn(ctx: *const c_void) -> AbiResult; +/// Sync actor-abort snapshot: `(ctx) -> 0/1`. +pub type ActorAbortedFn = extern "C" fn(ctx: *const c_void) -> u8; +/// Async actor-abort wait: `(ctx, done, user_data)`. +pub type WaitActorAbortFn = + extern "C" fn(ctx: *const c_void, done: CompletionFn, user_data: *mut c_void); +/// Sync keep-awake enter: `(ctx) -> cbor(KeepAwakeToken)`. +pub type KeepAwakeEnterFn = extern "C" fn(ctx: *const c_void) -> AbiResult; +/// Sync keep-awake exit: `(ctx, token) -> AbiStatus`. +pub type KeepAwakeExitFn = extern "C" fn(ctx: *const c_void, token: u64) -> AbiStatus; +/// Sync keep-awake count: `(ctx) -> active_count`. +pub type KeepAwakeCountFn = extern "C" fn(ctx: *const c_void) -> u64; +/// Async KV operation: `(ctx, cbor_request, done, user_data)`. +pub type KvOpFn = extern "C" fn( + ctx: *const c_void, + request: OwnedBuf, + done: CompletionFn, + user_data: *mut c_void, +); +/// Async scheduling operation: `(ctx, cbor_request, done, user_data)`. +pub type ScheduleOpFn = extern "C" fn( + ctx: *const c_void, + request: OwnedBuf, + done: CompletionFn, + user_data: *mut c_void, +); +/// Async connection operation: `(ctx, cbor_request, done, user_data)`. +pub type ConnOpFn = extern "C" fn( + ctx: *const c_void, + request: OwnedBuf, + done: CompletionFn, + user_data: *mut c_void, +); +/// Sync hibernatable websocket ack: `(ctx, gateway_id, request_id, server_message_index)`. +pub type HibernatableAckFn = extern "C" fn( + ctx: *const c_void, + gateway_id: OwnedBuf, + request_id: OwnedBuf, + server_message_index: u16, +) -> AbiResult; +/// Sync connection send: `(ctx, cbor_request) -> AbiResult`. +pub type ConnSendFn = extern "C" fn(ctx: *const c_void, request: OwnedBuf) -> AbiResult; +/// `(ctx) -> 0|1`. +pub type SqlEnabledFn = extern "C" fn(ctx: *const c_void) -> u8; +/// Async pull of the next event: `(ctx, done, user_data)`. Completes with an +/// `AbiResult` whose payload encodes an [`AbiEvent`], or `ChannelClosed`. +pub type NextEventFn = + extern "C" fn(ctx: *const c_void, done: CompletionFn, user_data: *mut c_void); +/// Sync event reply: `(ctx, reply_token, payload) -> AbiStatus`. +pub type ReplyFn = + extern "C" fn(ctx: *const c_void, reply_token: u64, payload: OwnedBuf) -> AbiStatus; +/// Sync, runtime-free broadcast: `(ctx, name, payload) -> AbiStatus`. +pub type BroadcastFn = + extern "C" fn(ctx: *const c_void, name: OwnedBuf, payload: OwnedBuf) -> AbiStatus; +/// Sync structured log: `(ctx, level, msg)`. +pub type LogFn = extern "C" fn(ctx: *const c_void, level: i32, msg: BorrowedBuf); +/// Refcount the opaque host ctx handle (clone/release). +pub type CtxRefFn = extern "C" fn(ctx: *const c_void) -> *const c_void; +pub type CtxReleaseFn = extern "C" fn(ctx: *const c_void); +/// Signal the plugin that startup is ready (manual-startup mode). +pub type StartupReadyFn = extern "C" fn(ctx: *const c_void, ok: u8, err_msg: BorrowedBuf); + +/// The capabilities the host exposes to a running plugin actor. Built by the +/// host over a live `ActorContext`; the `ctx` handle is OWNED + refcounted +/// (the plugin may `ctx_clone` it into detached `'static` tasks, and the host +/// keeps the underlying `ActorContext` alive until the last `ctx_release`). +#[repr(C)] +#[derive(Clone, Copy)] +pub struct HostVtable { + pub abi_version: u64, + + /// Opaque, refcounted handle to the host `ActorContext`. + pub ctx: *const c_void, + pub ctx_clone: CtxRefFn, + pub ctx_release: CtxReleaseFn, + + pub db_exec: DbExecFn, + pub db_query: DbSqlFn, + pub db_run: DbSqlFn, + pub sql_is_enabled: SqlEnabledFn, + + pub state_get: StateGetFn, + pub state_set: StateSetFn, + pub actor_identity: ActorIdentityFn, + pub state_save: StateSaveFn, + pub request_save: RequestSaveFn, + pub request_save_and_wait: RequestSaveAndWaitFn, + pub sleep: SleepFn, + pub actor_aborted: ActorAbortedFn, + pub wait_actor_abort: WaitActorAbortFn, + pub keep_awake_enter: KeepAwakeEnterFn, + pub keep_awake_exit: KeepAwakeExitFn, + pub keep_awake_count: KeepAwakeCountFn, + + pub kv_get: KvOpFn, + pub kv_put: KvOpFn, + pub kv_delete: KvOpFn, + pub kv_batch_get: KvOpFn, + pub kv_batch_put: KvOpFn, + pub kv_batch_delete: KvOpFn, + pub kv_delete_range: KvOpFn, + pub kv_list_prefix: KvOpFn, + pub kv_list_range: KvOpFn, + + pub schedule_after: ScheduleOpFn, + pub schedule_at: ScheduleOpFn, + pub set_alarm: ScheduleOpFn, + pub scheduled_events: ScheduleOpFn, + + pub conn_list: ConnOpFn, + pub conn_disconnect: ConnOpFn, + pub hibernatable_ws_ack: HibernatableAckFn, + pub conn_send: ConnSendFn, + + pub next_event: NextEventFn, + pub reply_ok: ReplyFn, + pub reply_err: ReplyFn, + pub startup_ready: StartupReadyFn, + + pub broadcast: BroadcastFn, + pub log: LogFn, +} + +unsafe impl Send for HostVtable {} +unsafe impl Sync for HostVtable {} + +/// Host-side actor knobs that the plugin's factory reports back so the host can +/// apply them (these live host-side today in `build_core_factory`). Carried in +/// the config envelope / descriptor rather than across the C ABI by value. +#[derive(Clone, Copy, Debug)] +pub struct ActorConfigKnobs { + pub has_database: bool, + pub sleep_grace_period_ms: u64, + pub action_timeout_ms: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn owned_buf_roundtrips_rust_vec() { + let buf = OwnedBuf::from_vec(vec![1u8, 2, 3, 4]); + let v = unsafe { buf.into_vec() }; + assert_eq!(v, vec![1, 2, 3, 4]); + } + + #[test] + fn owned_buf_empty_is_safe_to_free() { + let buf = OwnedBuf::empty(); + unsafe { buf.free_self() }; + } + + #[test] + fn event_tag_roundtrip_and_reply_semantics() { + for v in 0u32..=10 { + let tag = AbiEventTag::from_u32(v).expect("known tag"); + assert_eq!(tag as u32, v); + } + assert!(AbiEventTag::from_u32(11).is_none()); + assert!(!AbiEventTag::ConnClosed.needs_reply()); + assert!(AbiEventTag::Action.needs_reply()); + } +} diff --git a/rivetkit-rust/packages/rivet-actor-plugin-abi/src/portable.rs b/rivetkit-rust/packages/rivet-actor-plugin-abi/src/portable.rs new file mode 100644 index 0000000000..8ed49dca8e --- /dev/null +++ b/rivetkit-rust/packages/rivet-actor-plugin-abi/src/portable.rs @@ -0,0 +1,1553 @@ +use std::ffi::c_void; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::mpsc; + +use anyhow::{Result, anyhow, bail}; +use serde::de::DeserializeOwned; + +use crate::{AbiResult, AbiStatus, BorrowedBuf, HostVtable, OwnedBuf}; + +pub type PortableBoxFuture<'a, T> = Pin + Send + 'a>>; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ReplyToken(pub u64); + +#[derive(Clone, Copy, Debug, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct RequestSaveOpts { + pub immediate: bool, + pub max_wait_ms: Option, +} + +#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct KeepAwakeToken { + pub token: u64, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct ActorIdentity { + pub actor_id: String, + pub name: String, + pub key: String, + pub region: String, + pub input: Option>, + pub has_state: bool, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct ConnInfo { + pub id: String, + pub params: Vec, + pub state: Vec, + pub is_hibernatable: bool, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct ConnListResponse { + pub conns: Vec, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct ConnDisconnectRequest { + pub conn_ids: Vec, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct ConnSendRequest { + pub conn_id: String, + pub name: String, + pub payload: Vec, +} + +#[derive(Clone, Copy, Debug, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct KvListOpts { + pub reverse: bool, + pub limit: Option, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct KvEntry { + pub key: Vec, + pub value: Vec, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct KvKeyRequest { + pub key: Vec, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct KvKeysRequest { + pub keys: Vec>, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct KvEntriesRequest { + pub entries: Vec, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct KvRangeRequest { + pub start: Vec, + pub end: Vec, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct KvListPrefixRequest { + pub prefix: Vec, + pub opts: KvListOpts, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct KvListRangeRequest { + pub start: Vec, + pub end: Vec, + pub opts: KvListOpts, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct KvGetResponse { + pub value: Option>, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct KvValuesResponse { + pub values: Vec>>, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct KvListResponse { + pub entries: Vec, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct ScheduleActionRequest { + pub delay_ms: Option, + pub timestamp_ms: Option, + pub action_name: String, + pub args: Vec, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct ScheduleAlarmRequest { + pub timestamp_ms: Option, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct ScheduledEvent { + pub event_id: String, + pub timestamp_ms: i64, + pub action_name: String, + pub args: Option>, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct ScheduledEventsResponse { + pub events: Vec, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +struct ConnPreflightWire { + conn: ConnInfo, + params: Vec, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +struct ConnOpenWire { + conn: ConnInfo, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +struct ConnClosedWire { + conn: ConnInfo, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +struct SubscribeWire { + conn: ConnInfo, + event_name: String, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +struct QueueSendWire { + name: String, + body: Vec, + conn: ConnInfo, + request: Vec, + wait: bool, + timeout_ms: Option, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct WsOpenPayload { + pub conn: ConnInfo, + pub request: Option>, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct QueueSendResponse { + pub status: String, + pub response: Option>, +} + +#[derive(Debug)] +pub enum Event { + Action { + name: String, + args: Vec, + reply: ReplyToken, + }, + Http { + request: Vec, + reply: ReplyToken, + }, + Subscribe { + conn: ConnInfo, + event_name: String, + reply: ReplyToken, + }, + QueueSend { + name: String, + body: Vec, + conn: ConnInfo, + request: Vec, + wait: bool, + timeout_ms: Option, + reply: ReplyToken, + }, + WebSocketOpen { + conn: ConnInfo, + request: Option>, + reply: ReplyToken, + }, + ConnPreflight { + conn: ConnInfo, + params: Vec, + reply: ReplyToken, + }, + ConnOpen { + conn: ConnInfo, + reply: ReplyToken, + }, + ConnClosed { + conn: ConnInfo, + }, + SerializeState { + reply: ReplyToken, + }, + Sleep { + reply: ReplyToken, + }, + Destroy { + reply: ReplyToken, + }, +} + +impl Event { + pub fn reply_token(&self) -> Option { + match self { + Event::Action { reply, .. } + | Event::Http { reply, .. } + | Event::Subscribe { reply, .. } + | Event::QueueSend { reply, .. } + | Event::WebSocketOpen { reply, .. } + | Event::ConnPreflight { reply, .. } + | Event::ConnOpen { reply, .. } + | Event::SerializeState { reply } + | Event::Sleep { reply } + | Event::Destroy { reply } => Some(*reply), + Event::ConnClosed { .. } => None, + } + } +} + +pub trait PortableActorBackend: Send + Sync { + fn next_event(&self) -> PortableBoxFuture<'_, Result>>; + fn reply_ok(&self, token: ReplyToken, payload: Vec) -> Result<()>; + fn reply_err(&self, token: ReplyToken, message: String) -> Result<()>; + fn startup_ready(&self, result: Result<()>) -> Result<()>; + fn broadcast(&self, name: String, payload: Vec) -> Result<()>; + fn actor_id(&self) -> Result; + fn name(&self) -> Result; + fn key(&self) -> Result; + fn region(&self) -> Result; + fn input(&self) -> Result>>; + fn has_state(&self) -> Result; + fn state(&self) -> Result>; + fn set_state(&self, state: Vec) -> Result<()>; + fn save_state(&self, state: Vec) -> PortableBoxFuture<'_, Result<()>>; + fn request_save(&self, opts: RequestSaveOpts) -> Result<()>; + fn request_save_and_wait(&self, opts: RequestSaveOpts) -> PortableBoxFuture<'_, Result<()>>; + fn sleep(&self) -> Result<()>; + fn actor_aborted(&self) -> Result; + fn wait_for_actor_abort(&self) -> PortableBoxFuture<'_, Result<()>>; + fn keep_awake_enter(&self) -> Result; + fn keep_awake_exit(&self, token: KeepAwakeToken) -> Result<()>; + fn keep_awake_count(&self) -> Result; + fn kv_get(&self, key: Vec) -> PortableBoxFuture<'_, Result>>>; + fn kv_put(&self, key: Vec, value: Vec) -> PortableBoxFuture<'_, Result<()>>; + fn kv_delete(&self, key: Vec) -> PortableBoxFuture<'_, Result<()>>; + fn kv_batch_get( + &self, + keys: Vec>, + ) -> PortableBoxFuture<'_, Result>>>>; + fn kv_batch_put(&self, entries: Vec) -> PortableBoxFuture<'_, Result<()>>; + fn kv_batch_delete(&self, keys: Vec>) -> PortableBoxFuture<'_, Result<()>>; + fn kv_delete_range(&self, start: Vec, end: Vec) -> PortableBoxFuture<'_, Result<()>>; + fn kv_list_prefix( + &self, + prefix: Vec, + opts: KvListOpts, + ) -> PortableBoxFuture<'_, Result>>; + fn kv_list_range( + &self, + start: Vec, + end: Vec, + opts: KvListOpts, + ) -> PortableBoxFuture<'_, Result>>; + fn schedule_after_ms( + &self, + delay_ms: u64, + action_name: String, + args: Vec, + ) -> PortableBoxFuture<'_, Result<()>>; + fn schedule_at_ms( + &self, + timestamp_ms: i64, + action_name: String, + args: Vec, + ) -> PortableBoxFuture<'_, Result<()>>; + fn set_alarm(&self, timestamp_ms: Option) -> PortableBoxFuture<'_, Result<()>>; + fn scheduled_events(&self) -> PortableBoxFuture<'_, Result>>; + fn conn_list(&self) -> PortableBoxFuture<'_, Result>>; + fn disconnect_conn(&self, conn_id: String) -> PortableBoxFuture<'_, Result<()>>; + fn disconnect_conns(&self, conn_ids: Vec) -> PortableBoxFuture<'_, Result<()>>; + fn send(&self, conn_id: String, name: String, payload: Vec) -> Result<()>; + fn ack_hibernatable_websocket_message( + &self, + gateway_id: Vec, + request_id: Vec, + server_message_index: u16, + ) -> Result<()>; + fn sql_is_enabled(&self) -> bool; + fn db_exec<'a>(&'a self, sql: &'a str) -> PortableBoxFuture<'a, Result>>; + fn db_query<'a>( + &'a self, + sql: &'a str, + params: Option>, + ) -> PortableBoxFuture<'a, Result>>; + fn db_run<'a>( + &'a self, + sql: &'a str, + params: Option>, + ) -> PortableBoxFuture<'a, Result<()>>; +} + +/// Backend-portable actor context. +/// +/// This type is deliberately separate from RivetKit's engine-side +/// `ActorContext`: a dylib actor cannot link `rivetkit-core`, so the actor-facing +/// API must be neutral bytes, ids, and enums instead of engine handles. Use it +/// when one actor source must run both in-process and as a `cdylib`. The tradeoff +/// is a small dispatch/marshalling cost and a lower-level API than the native +/// engine context. TypeScript app contexts are unaffected. +#[derive(Clone)] +pub enum Backend { + /// In-process backend supplied by rivetkit-core. + /// + /// `rivet-actor-plugin-abi` cannot name rivetkit-core's concrete + /// `NativeBackend` without creating a dependency cycle, so the enum variant + /// stores the neutral backend trait behind an `Arc`. + Native(Arc), + /// FFI backend used by actors loaded from a `cdylib`. + Dylib(DylibBackend), +} + +impl Backend { + fn as_portable(&self) -> &dyn PortableActorBackend { + match self { + Backend::Native(backend) => backend.as_ref(), + Backend::Dylib(backend) => backend, + } + } +} + +#[derive(Clone)] +pub struct PortableActorCtx { + backend: Backend, +} + +impl PortableActorCtx { + pub fn new(backend: impl PortableActorBackend + 'static) -> Self { + Self { + backend: Backend::Native(Arc::new(backend)), + } + } + + pub fn from_backend(backend: Backend) -> Self { + Self { backend } + } + + pub fn new_dylib(backend: DylibBackend) -> Self { + Self { + backend: Backend::Dylib(backend), + } + } + + fn backend(&self) -> &dyn PortableActorBackend { + self.backend.as_portable() + } + + pub async fn next_event(&self) -> Result> { + self.backend().next_event().await + } + + pub fn reply_ok(&self, token: ReplyToken, payload: Vec) -> Result<()> { + self.backend().reply_ok(token, payload) + } + + pub fn reply_err(&self, token: ReplyToken, message: impl Into) -> Result<()> { + self.backend().reply_err(token, message.into()) + } + + pub fn startup_ready(&self, result: Result<()>) -> Result<()> { + self.backend().startup_ready(result) + } + + pub fn broadcast(&self, name: impl Into, payload: Vec) -> Result<()> { + self.backend().broadcast(name.into(), payload) + } + + pub fn actor_id(&self) -> Result { + self.backend().actor_id() + } + + pub fn name(&self) -> Result { + self.backend().name() + } + + pub fn key(&self) -> Result { + self.backend().key() + } + + pub fn region(&self) -> Result { + self.backend().region() + } + + pub fn input(&self) -> Result>> { + self.backend().input() + } + + pub fn has_state(&self) -> Result { + self.backend().has_state() + } + + pub fn state(&self) -> Result> { + self.backend().state() + } + + pub fn set_state(&self, state: Vec) -> Result<()> { + self.backend().set_state(state) + } + + pub async fn save_state(&self, state: Vec) -> Result<()> { + self.backend().save_state(state).await + } + + pub fn request_save(&self, opts: RequestSaveOpts) -> Result<()> { + self.backend().request_save(opts) + } + + pub async fn request_save_and_wait(&self, opts: RequestSaveOpts) -> Result<()> { + self.backend().request_save_and_wait(opts).await + } + + pub fn sleep(&self) -> Result<()> { + self.backend().sleep() + } + + pub fn actor_aborted(&self) -> Result { + self.backend().actor_aborted() + } + + pub async fn wait_for_actor_abort(&self) -> Result<()> { + self.backend().wait_for_actor_abort().await + } + + pub fn keep_awake_region(&self) -> Result { + let token = self.backend().keep_awake_enter()?; + Ok(PortableKeepAwakeRegion { + backend: self.backend.clone(), + token: Some(token), + }) + } + + pub async fn keep_awake(&self, future: F) -> Result + where + F: Future, + { + let _guard = self.keep_awake_region()?; + Ok(future.await) + } + + pub fn keep_awake_count(&self) -> Result { + self.backend().keep_awake_count() + } + + pub async fn kv_get(&self, key: Vec) -> Result>> { + self.backend().kv_get(key).await + } + + pub async fn kv_put(&self, key: Vec, value: Vec) -> Result<()> { + self.backend().kv_put(key, value).await + } + + pub async fn kv_delete(&self, key: Vec) -> Result<()> { + self.backend().kv_delete(key).await + } + + pub async fn kv_batch_get(&self, keys: Vec>) -> Result>>> { + self.backend().kv_batch_get(keys).await + } + + pub async fn kv_batch_put(&self, entries: Vec) -> Result<()> { + self.backend().kv_batch_put(entries).await + } + + pub async fn kv_batch_delete(&self, keys: Vec>) -> Result<()> { + self.backend().kv_batch_delete(keys).await + } + + pub async fn kv_delete_range(&self, start: Vec, end: Vec) -> Result<()> { + self.backend().kv_delete_range(start, end).await + } + + pub async fn kv_list_prefix(&self, prefix: Vec, opts: KvListOpts) -> Result> { + self.backend().kv_list_prefix(prefix, opts).await + } + + pub async fn kv_list_range( + &self, + start: Vec, + end: Vec, + opts: KvListOpts, + ) -> Result> { + self.backend().kv_list_range(start, end, opts).await + } + + pub async fn schedule_after_ms( + &self, + delay_ms: u64, + action_name: impl Into, + args: Vec, + ) -> Result<()> { + self.backend() + .schedule_after_ms(delay_ms, action_name.into(), args) + .await + } + + pub async fn after( + &self, + delay_ms: u64, + action_name: impl Into, + args: Vec, + ) -> Result<()> { + self.schedule_after_ms(delay_ms, action_name, args).await + } + + pub async fn schedule_at_ms( + &self, + timestamp_ms: i64, + action_name: impl Into, + args: Vec, + ) -> Result<()> { + self.backend() + .schedule_at_ms(timestamp_ms, action_name.into(), args) + .await + } + + pub async fn at( + &self, + timestamp_ms: i64, + action_name: impl Into, + args: Vec, + ) -> Result<()> { + self.schedule_at_ms(timestamp_ms, action_name, args).await + } + + pub async fn set_alarm(&self, timestamp_ms: Option) -> Result<()> { + self.backend().set_alarm(timestamp_ms).await + } + + pub async fn scheduled_events(&self) -> Result> { + self.backend().scheduled_events().await + } + + pub async fn conn_list(&self) -> Result> { + self.backend().conn_list().await + } + + pub async fn disconnect_conn(&self, conn_id: impl Into) -> Result<()> { + self.backend().disconnect_conn(conn_id.into()).await + } + + pub async fn disconnect_conns(&self, conn_ids: Vec) -> Result<()> { + self.backend().disconnect_conns(conn_ids).await + } + + pub fn send( + &self, + conn_id: impl Into, + name: impl Into, + payload: Vec, + ) -> Result<()> { + self.backend().send(conn_id.into(), name.into(), payload) + } + + pub fn ack_hibernatable_websocket_message( + &self, + gateway_id: impl Into>, + request_id: impl Into>, + server_message_index: u16, + ) -> Result<()> { + self.backend().ack_hibernatable_websocket_message( + gateway_id.into(), + request_id.into(), + server_message_index, + ) + } + + pub fn sql_is_enabled(&self) -> bool { + self.backend().sql_is_enabled() + } + + pub async fn db_exec(&self, sql: &str) -> Result> { + self.backend().db_exec(sql).await + } + + pub async fn db_query(&self, sql: &str, params: Option>) -> Result> { + self.backend().db_query(sql, params).await + } + + pub async fn db_run(&self, sql: &str, params: Option>) -> Result<()> { + self.backend().db_run(sql, params).await + } +} + +pub struct PortableKeepAwakeRegion { + backend: Backend, + token: Option, +} + +impl Drop for PortableKeepAwakeRegion { + fn drop(&mut self) { + if let Some(token) = self.token.take() { + let _ = self.backend.as_portable().keep_awake_exit(token); + } + } +} + +struct SendResult(AbiResult); +unsafe impl Send for SendResult {} + +extern "C" fn complete_to_channel(user_data: *mut c_void, result: AbiResult) { + let _ = std::panic::catch_unwind(|| unsafe { + let tx = Box::from_raw(user_data as *mut mpsc::Sender); + let _ = tx.send(SendResult(result)); + }); +} + +#[derive(Clone, Copy)] +struct SendUserData(*mut c_void); +unsafe impl Send for SendUserData {} + +fn abi_result_to_bytes(result: AbiResult) -> Result> { + let status = result.status; + let payload = unsafe { result.payload.into_vec() }; + match status { + AbiStatus::Ok => Ok(payload), + AbiStatus::Err => Err(anyhow!("{}", String::from_utf8_lossy(&payload))), + AbiStatus::Panic => bail!("host operation panicked"), + AbiStatus::Cancelled => bail!("host operation cancelled"), + AbiStatus::ChannelClosed => bail!("host event stream closed"), + } +} + +fn encode_cbor(value: &T) -> Result> { + let mut out = Vec::new(); + ciborium::into_writer(value, &mut out)?; + Ok(out) +} + +fn decode_cbor(bytes: &[u8]) -> Result { + Ok(ciborium::from_reader(std::io::Cursor::new(bytes))?) +} + +fn call_async(submit: F) -> PortableBoxFuture<'static, Result> +where + F: FnOnce(crate::CompletionFn, *mut c_void) + Send + 'static, +{ + Box::pin(async move { + let (tx, rx) = mpsc::channel::(); + let user_data = SendUserData(Box::into_raw(Box::new(tx)) as *mut c_void); + submit(complete_to_channel, user_data.0); + rx.recv() + .map(|r| r.0) + .map_err(|_| anyhow!("host completion channel closed")) + }) +} + +#[derive(Clone, Copy)] +struct SendVtable(HostVtable); +unsafe impl Send for SendVtable {} +unsafe impl Sync for SendVtable {} + +impl SendVtable { + fn next_event(&self, done: crate::CompletionFn, user_data: *mut c_void) { + (self.0.next_event)(self.0.ctx, done, user_data); + } +} + +pub struct DylibBackend { + host: HostVtable, +} + +unsafe impl Send for DylibBackend {} +unsafe impl Sync for DylibBackend {} + +impl DylibBackend { + /// Build a dylib backend from the host vtable received by `rivet_actor_run`. + /// + /// The backend clones the opaque host context and releases it on drop, so it + /// may outlive the synchronous `run` call that provided the vtable. + /// + /// # Safety + /// `host` must point to a valid same-version [`HostVtable`]. + pub unsafe fn from_host_vtable(host: &HostVtable) -> Self { + let host = *host; + (host.ctx_clone)(host.ctx); + Self { host } + } + + fn complete_bytes(&self, submit: F) -> PortableBoxFuture<'static, Result>> + where + F: FnOnce(HostVtable, crate::CompletionFn, *mut c_void) + Send + 'static, + { + let host = SendVtable(self.host); + Box::pin(async move { + let result = call_async(move |done, user_data| submit(host.0, done, user_data)).await?; + abi_result_to_bytes(result) + }) + } + + fn identity(&self) -> Result { + let buf = (self.host.actor_identity)(self.host.ctx); + decode_cbor(&unsafe { buf.into_vec() }) + } +} + +impl Clone for DylibBackend { + fn clone(&self) -> Self { + (self.host.ctx_clone)(self.host.ctx); + Self { host: self.host } + } +} + +impl Drop for DylibBackend { + fn drop(&mut self) { + (self.host.ctx_release)(self.host.ctx); + } +} + +impl PortableActorBackend for DylibBackend { + fn next_event(&self) -> PortableBoxFuture<'_, Result>> { + let host = SendVtable(self.host); + Box::pin(async move { + let result = + call_async(move |done, user_data| host.next_event(done, user_data)).await?; + if result.status == AbiStatus::ChannelClosed { + unsafe { result.payload.free_self() }; + return Ok(None); + } + decode_event_frame(&abi_result_to_bytes(result)?).map(Some) + }) + } + + fn reply_ok(&self, token: ReplyToken, payload: Vec) -> Result<()> { + match (self.host.reply_ok)(self.host.ctx, token.0, OwnedBuf::from_vec(payload)) { + AbiStatus::Ok => Ok(()), + other => Err(anyhow!("reply_ok failed with {other:?}")), + } + } + + fn reply_err(&self, token: ReplyToken, message: String) -> Result<()> { + match (self.host.reply_err)( + self.host.ctx, + token.0, + OwnedBuf::from_vec(message.into_bytes()), + ) { + AbiStatus::Ok => Ok(()), + other => Err(anyhow!("reply_err failed with {other:?}")), + } + } + + fn startup_ready(&self, result: Result<()>) -> Result<()> { + match result { + Ok(()) => (self.host.startup_ready)(self.host.ctx, 1, BorrowedBuf::from_slice(&[])), + Err(err) => { + let msg = err.to_string(); + (self.host.startup_ready)( + self.host.ctx, + 0, + BorrowedBuf::from_slice(msg.as_bytes()), + ); + } + } + Ok(()) + } + + fn broadcast(&self, name: String, payload: Vec) -> Result<()> { + match (self.host.broadcast)( + self.host.ctx, + OwnedBuf::from_vec(name.into_bytes()), + OwnedBuf::from_vec(payload), + ) { + AbiStatus::Ok => Ok(()), + other => Err(anyhow!("broadcast failed with {other:?}")), + } + } + + fn actor_id(&self) -> Result { + Ok(self.identity()?.actor_id) + } + + fn name(&self) -> Result { + Ok(self.identity()?.name) + } + + fn key(&self) -> Result { + Ok(self.identity()?.key) + } + + fn region(&self) -> Result { + Ok(self.identity()?.region) + } + + fn input(&self) -> Result>> { + Ok(self.identity()?.input) + } + + fn has_state(&self) -> Result { + Ok(self.identity()?.has_state) + } + + fn state(&self) -> Result> { + let buf = (self.host.state_get)(self.host.ctx); + Ok(unsafe { buf.into_vec() }) + } + + fn set_state(&self, state: Vec) -> Result<()> { + match (self.host.state_set)(self.host.ctx, OwnedBuf::from_vec(state)) { + AbiStatus::Ok => Ok(()), + other => Err(anyhow!("set_state failed with {other:?}")), + } + } + + fn save_state(&self, state: Vec) -> PortableBoxFuture<'_, Result<()>> { + let fut = self.complete_bytes(move |host, done, user_data| { + (host.state_save)(host.ctx, OwnedBuf::from_vec(state), done, user_data); + }); + Box::pin(async move { + fut.await?; + Ok(()) + }) + } + + fn request_save(&self, opts: RequestSaveOpts) -> Result<()> { + let (has_max_wait, max_wait_ms) = match opts.max_wait_ms { + Some(max_wait_ms) => (1, max_wait_ms), + None => (0, 0), + }; + match (self.host.request_save)( + self.host.ctx, + u8::from(opts.immediate), + has_max_wait, + max_wait_ms, + ) { + AbiStatus::Ok => Ok(()), + other => Err(anyhow!("request_save failed with {other:?}")), + } + } + + fn request_save_and_wait(&self, opts: RequestSaveOpts) -> PortableBoxFuture<'_, Result<()>> { + let (has_max_wait, max_wait_ms) = match opts.max_wait_ms { + Some(max_wait_ms) => (1, max_wait_ms), + None => (0, 0), + }; + let fut = self.complete_bytes(move |host, done, user_data| { + (host.request_save_and_wait)( + host.ctx, + u8::from(opts.immediate), + has_max_wait, + max_wait_ms, + done, + user_data, + ); + }); + Box::pin(async move { + fut.await?; + Ok(()) + }) + } + + fn sleep(&self) -> Result<()> { + abi_result_to_bytes((self.host.sleep)(self.host.ctx)).map(|_| ()) + } + + fn actor_aborted(&self) -> Result { + Ok((self.host.actor_aborted)(self.host.ctx) != 0) + } + + fn wait_for_actor_abort(&self) -> PortableBoxFuture<'_, Result<()>> { + let fut = self.complete_bytes(move |host, done, user_data| { + (host.wait_actor_abort)(host.ctx, done, user_data); + }); + Box::pin(async move { + fut.await?; + Ok(()) + }) + } + + fn keep_awake_enter(&self) -> Result { + let bytes = abi_result_to_bytes((self.host.keep_awake_enter)(self.host.ctx))?; + decode_cbor(&bytes) + } + + fn keep_awake_exit(&self, token: KeepAwakeToken) -> Result<()> { + match (self.host.keep_awake_exit)(self.host.ctx, token.token) { + AbiStatus::Ok => Ok(()), + other => Err(anyhow!("keep_awake_exit failed with {other:?}")), + } + } + + fn keep_awake_count(&self) -> Result { + Ok((self.host.keep_awake_count)(self.host.ctx) as usize) + } + + fn kv_get(&self, key: Vec) -> PortableBoxFuture<'_, Result>>> { + let request = encode_cbor(&KvKeyRequest { key }); + let fut = self.complete_bytes(move |host, done, user_data| { + let request = match request { + Ok(request) => request, + Err(error) => { + done( + user_data, + AbiResult::err(OwnedBuf::from_vec(format!("{error:#}").into_bytes())), + ); + return; + } + }; + (host.kv_get)(host.ctx, OwnedBuf::from_vec(request), done, user_data); + }); + Box::pin(async move { + let response: KvGetResponse = decode_cbor(&fut.await?)?; + Ok(response.value) + }) + } + + fn kv_put(&self, key: Vec, value: Vec) -> PortableBoxFuture<'_, Result<()>> { + let request = encode_cbor(&KvEntriesRequest { + entries: vec![KvEntry { key, value }], + }); + let fut = self.complete_bytes(move |host, done, user_data| { + let request = match request { + Ok(request) => request, + Err(error) => { + done( + user_data, + AbiResult::err(OwnedBuf::from_vec(format!("{error:#}").into_bytes())), + ); + return; + } + }; + (host.kv_put)(host.ctx, OwnedBuf::from_vec(request), done, user_data); + }); + Box::pin(async move { + fut.await?; + Ok(()) + }) + } + + fn kv_delete(&self, key: Vec) -> PortableBoxFuture<'_, Result<()>> { + let request = encode_cbor(&KvKeysRequest { keys: vec![key] }); + let fut = self.complete_bytes(move |host, done, user_data| { + let request = match request { + Ok(request) => request, + Err(error) => { + done( + user_data, + AbiResult::err(OwnedBuf::from_vec(format!("{error:#}").into_bytes())), + ); + return; + } + }; + (host.kv_delete)(host.ctx, OwnedBuf::from_vec(request), done, user_data); + }); + Box::pin(async move { + fut.await?; + Ok(()) + }) + } + + fn kv_batch_get( + &self, + keys: Vec>, + ) -> PortableBoxFuture<'_, Result>>>> { + let request = encode_cbor(&KvKeysRequest { keys }); + let fut = self.complete_bytes(move |host, done, user_data| { + let request = match request { + Ok(request) => request, + Err(error) => { + done( + user_data, + AbiResult::err(OwnedBuf::from_vec(format!("{error:#}").into_bytes())), + ); + return; + } + }; + (host.kv_batch_get)(host.ctx, OwnedBuf::from_vec(request), done, user_data); + }); + Box::pin(async move { + let response: KvValuesResponse = decode_cbor(&fut.await?)?; + Ok(response.values) + }) + } + + fn kv_batch_put(&self, entries: Vec) -> PortableBoxFuture<'_, Result<()>> { + let request = encode_cbor(&KvEntriesRequest { entries }); + let fut = self.complete_bytes(move |host, done, user_data| { + let request = match request { + Ok(request) => request, + Err(error) => { + done( + user_data, + AbiResult::err(OwnedBuf::from_vec(format!("{error:#}").into_bytes())), + ); + return; + } + }; + (host.kv_batch_put)(host.ctx, OwnedBuf::from_vec(request), done, user_data); + }); + Box::pin(async move { + fut.await?; + Ok(()) + }) + } + + fn kv_batch_delete(&self, keys: Vec>) -> PortableBoxFuture<'_, Result<()>> { + let request = encode_cbor(&KvKeysRequest { keys }); + let fut = self.complete_bytes(move |host, done, user_data| { + let request = match request { + Ok(request) => request, + Err(error) => { + done( + user_data, + AbiResult::err(OwnedBuf::from_vec(format!("{error:#}").into_bytes())), + ); + return; + } + }; + (host.kv_batch_delete)(host.ctx, OwnedBuf::from_vec(request), done, user_data); + }); + Box::pin(async move { + fut.await?; + Ok(()) + }) + } + + fn kv_delete_range(&self, start: Vec, end: Vec) -> PortableBoxFuture<'_, Result<()>> { + let request = encode_cbor(&KvRangeRequest { start, end }); + let fut = self.complete_bytes(move |host, done, user_data| { + let request = match request { + Ok(request) => request, + Err(error) => { + done( + user_data, + AbiResult::err(OwnedBuf::from_vec(format!("{error:#}").into_bytes())), + ); + return; + } + }; + (host.kv_delete_range)(host.ctx, OwnedBuf::from_vec(request), done, user_data); + }); + Box::pin(async move { + fut.await?; + Ok(()) + }) + } + + fn kv_list_prefix( + &self, + prefix: Vec, + opts: KvListOpts, + ) -> PortableBoxFuture<'_, Result>> { + let request = encode_cbor(&KvListPrefixRequest { prefix, opts }); + let fut = self.complete_bytes(move |host, done, user_data| { + let request = match request { + Ok(request) => request, + Err(error) => { + done( + user_data, + AbiResult::err(OwnedBuf::from_vec(format!("{error:#}").into_bytes())), + ); + return; + } + }; + (host.kv_list_prefix)(host.ctx, OwnedBuf::from_vec(request), done, user_data); + }); + Box::pin(async move { + let response: KvListResponse = decode_cbor(&fut.await?)?; + Ok(response.entries) + }) + } + + fn kv_list_range( + &self, + start: Vec, + end: Vec, + opts: KvListOpts, + ) -> PortableBoxFuture<'_, Result>> { + let request = encode_cbor(&KvListRangeRequest { start, end, opts }); + let fut = self.complete_bytes(move |host, done, user_data| { + let request = match request { + Ok(request) => request, + Err(error) => { + done( + user_data, + AbiResult::err(OwnedBuf::from_vec(format!("{error:#}").into_bytes())), + ); + return; + } + }; + (host.kv_list_range)(host.ctx, OwnedBuf::from_vec(request), done, user_data); + }); + Box::pin(async move { + let response: KvListResponse = decode_cbor(&fut.await?)?; + Ok(response.entries) + }) + } + + fn schedule_after_ms( + &self, + delay_ms: u64, + action_name: String, + args: Vec, + ) -> PortableBoxFuture<'_, Result<()>> { + let request = encode_cbor(&ScheduleActionRequest { + delay_ms: Some(delay_ms), + timestamp_ms: None, + action_name, + args, + }); + let fut = self.complete_bytes(move |host, done, user_data| { + let request = match request { + Ok(request) => request, + Err(error) => { + done( + user_data, + AbiResult::err(OwnedBuf::from_vec(format!("{error:#}").into_bytes())), + ); + return; + } + }; + (host.schedule_after)(host.ctx, OwnedBuf::from_vec(request), done, user_data); + }); + Box::pin(async move { + fut.await?; + Ok(()) + }) + } + + fn schedule_at_ms( + &self, + timestamp_ms: i64, + action_name: String, + args: Vec, + ) -> PortableBoxFuture<'_, Result<()>> { + let request = encode_cbor(&ScheduleActionRequest { + delay_ms: None, + timestamp_ms: Some(timestamp_ms), + action_name, + args, + }); + let fut = self.complete_bytes(move |host, done, user_data| { + let request = match request { + Ok(request) => request, + Err(error) => { + done( + user_data, + AbiResult::err(OwnedBuf::from_vec(format!("{error:#}").into_bytes())), + ); + return; + } + }; + (host.schedule_at)(host.ctx, OwnedBuf::from_vec(request), done, user_data); + }); + Box::pin(async move { + fut.await?; + Ok(()) + }) + } + + fn set_alarm(&self, timestamp_ms: Option) -> PortableBoxFuture<'_, Result<()>> { + let request = encode_cbor(&ScheduleAlarmRequest { timestamp_ms }); + let fut = self.complete_bytes(move |host, done, user_data| { + let request = match request { + Ok(request) => request, + Err(error) => { + done( + user_data, + AbiResult::err(OwnedBuf::from_vec(format!("{error:#}").into_bytes())), + ); + return; + } + }; + (host.set_alarm)(host.ctx, OwnedBuf::from_vec(request), done, user_data); + }); + Box::pin(async move { + fut.await?; + Ok(()) + }) + } + + fn scheduled_events(&self) -> PortableBoxFuture<'_, Result>> { + let request = encode_cbor(&()); + let fut = self.complete_bytes(move |host, done, user_data| { + let request = match request { + Ok(request) => request, + Err(error) => { + done( + user_data, + AbiResult::err(OwnedBuf::from_vec(format!("{error:#}").into_bytes())), + ); + return; + } + }; + (host.scheduled_events)(host.ctx, OwnedBuf::from_vec(request), done, user_data); + }); + Box::pin(async move { + let response: ScheduledEventsResponse = decode_cbor(&fut.await?)?; + Ok(response.events) + }) + } + + fn conn_list(&self) -> PortableBoxFuture<'_, Result>> { + let request = encode_cbor(&()); + let fut = self.complete_bytes(move |host, done, user_data| { + let request = match request { + Ok(request) => request, + Err(error) => { + done( + user_data, + AbiResult::err(OwnedBuf::from_vec(format!("{error:#}").into_bytes())), + ); + return; + } + }; + (host.conn_list)(host.ctx, OwnedBuf::from_vec(request), done, user_data); + }); + Box::pin(async move { + let response: ConnListResponse = decode_cbor(&fut.await?)?; + Ok(response.conns) + }) + } + + fn disconnect_conn(&self, conn_id: String) -> PortableBoxFuture<'_, Result<()>> { + self.disconnect_conns(vec![conn_id]) + } + + fn disconnect_conns(&self, conn_ids: Vec) -> PortableBoxFuture<'_, Result<()>> { + let request = encode_cbor(&ConnDisconnectRequest { conn_ids }); + let fut = self.complete_bytes(move |host, done, user_data| { + let request = match request { + Ok(request) => request, + Err(error) => { + done( + user_data, + AbiResult::err(OwnedBuf::from_vec(format!("{error:#}").into_bytes())), + ); + return; + } + }; + (host.conn_disconnect)(host.ctx, OwnedBuf::from_vec(request), done, user_data); + }); + Box::pin(async move { + fut.await?; + Ok(()) + }) + } + + fn send(&self, conn_id: String, name: String, payload: Vec) -> Result<()> { + let request = encode_cbor(&ConnSendRequest { + conn_id, + name, + payload, + })?; + abi_result_to_bytes((self.host.conn_send)( + self.host.ctx, + OwnedBuf::from_vec(request), + ))?; + Ok(()) + } + + fn ack_hibernatable_websocket_message( + &self, + gateway_id: Vec, + request_id: Vec, + server_message_index: u16, + ) -> Result<()> { + abi_result_to_bytes((self.host.hibernatable_ws_ack)( + self.host.ctx, + OwnedBuf::from_vec(gateway_id), + OwnedBuf::from_vec(request_id), + server_message_index, + ))?; + Ok(()) + } + + fn sql_is_enabled(&self) -> bool { + (self.host.sql_is_enabled)(self.host.ctx) != 0 + } + + fn db_exec<'a>(&'a self, sql: &'a str) -> PortableBoxFuture<'a, Result>> { + let sql = sql.as_bytes().to_vec(); + self.complete_bytes(move |host, done, user_data| { + (host.db_exec)(host.ctx, OwnedBuf::from_vec(sql), done, user_data); + }) + } + + fn db_query<'a>( + &'a self, + sql: &'a str, + params: Option>, + ) -> PortableBoxFuture<'a, Result>> { + let sql = sql.as_bytes().to_vec(); + let params = params.unwrap_or_default(); + self.complete_bytes(move |host, done, user_data| { + (host.db_query)( + host.ctx, + OwnedBuf::from_vec(sql), + OwnedBuf::from_vec(params), + done, + user_data, + ); + }) + } + + fn db_run<'a>( + &'a self, + sql: &'a str, + params: Option>, + ) -> PortableBoxFuture<'a, Result<()>> { + let sql = sql.as_bytes().to_vec(); + let params = params.unwrap_or_default(); + let fut = self.complete_bytes(move |host, done, user_data| { + (host.db_run)( + host.ctx, + OwnedBuf::from_vec(sql), + OwnedBuf::from_vec(params), + done, + user_data, + ); + }); + Box::pin(async move { + fut.await?; + Ok(()) + }) + } +} + +pub fn encode_action_payload(name: &str, args: &[u8]) -> Vec { + let mut out = Vec::with_capacity(4 + name.len() + args.len()); + out.extend_from_slice(&(name.len() as u32).to_le_bytes()); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(args); + out +} + +pub fn decode_action_payload(payload: &[u8]) -> Result<(String, Vec)> { + let name_len = payload + .get(0..4) + .ok_or_else(|| anyhow!("action payload missing name length")) + .and_then(|b| { + b.try_into() + .map(u32::from_le_bytes) + .map_err(|_| anyhow!("invalid action name length")) + })? as usize; + let rest = payload + .get(4..) + .ok_or_else(|| anyhow!("action payload missing body"))?; + let name = rest + .get(..name_len) + .ok_or_else(|| anyhow!("action payload name out of bounds"))?; + let args = rest + .get(name_len..) + .ok_or_else(|| anyhow!("action payload args out of bounds"))?; + Ok((String::from_utf8(name.to_vec())?, args.to_vec())) +} + +pub fn encode_event_frame(tag: u32, token: ReplyToken, payload: &[u8]) -> Vec { + let mut out = Vec::with_capacity(12 + payload.len()); + out.extend_from_slice(&tag.to_le_bytes()); + out.extend_from_slice(&token.0.to_le_bytes()); + out.extend_from_slice(payload); + out +} + +pub fn encode_conn_preflight_payload(conn: &ConnInfo, params: &[u8]) -> Result> { + let wire = ConnPreflightWire { + conn: conn.clone(), + params: params.to_vec(), + }; + let mut out = Vec::new(); + ciborium::into_writer(&wire, &mut out)?; + Ok(out) +} + +pub fn encode_conn_open_payload(conn: &ConnInfo) -> Result> { + let wire = ConnOpenWire { conn: conn.clone() }; + let mut out = Vec::new(); + ciborium::into_writer(&wire, &mut out)?; + Ok(out) +} + +pub fn encode_conn_closed_payload(conn: &ConnInfo) -> Result> { + let wire = ConnClosedWire { conn: conn.clone() }; + let mut out = Vec::new(); + ciborium::into_writer(&wire, &mut out)?; + Ok(out) +} + +pub fn encode_subscribe_payload(conn: &ConnInfo, event_name: &str) -> Result> { + let wire = SubscribeWire { + conn: conn.clone(), + event_name: event_name.to_owned(), + }; + let mut out = Vec::new(); + ciborium::into_writer(&wire, &mut out)?; + Ok(out) +} + +pub fn encode_queue_send_payload( + name: &str, + body: &[u8], + conn: &ConnInfo, + request: &[u8], + wait: bool, + timeout_ms: Option, +) -> Result> { + let wire = QueueSendWire { + name: name.to_owned(), + body: body.to_vec(), + conn: conn.clone(), + request: request.to_vec(), + wait, + timeout_ms, + }; + let mut out = Vec::new(); + ciborium::into_writer(&wire, &mut out)?; + Ok(out) +} + +pub fn encode_ws_open_payload(conn: &ConnInfo, request: Option<&[u8]>) -> Result> { + let wire = WsOpenPayload { + conn: conn.clone(), + request: request.map(<[u8]>::to_vec), + }; + let mut out = Vec::new(); + ciborium::into_writer(&wire, &mut out)?; + Ok(out) +} + +pub fn encode_queue_send_response(status: &str, response: Option>) -> Result> { + encode_cbor(&QueueSendResponse { + status: status.to_owned(), + response, + }) +} + +pub fn decode_event_frame(bytes: &[u8]) -> Result { + if bytes.len() < 12 { + bail!("event frame shorter than header"); + } + let tag = u32::from_le_bytes(bytes[0..4].try_into()?); + let token = ReplyToken(u64::from_le_bytes(bytes[4..12].try_into()?)); + let payload = bytes[12..].to_vec(); + let tag = + crate::AbiEventTag::from_u32(tag).ok_or_else(|| anyhow!("unknown event tag {tag}"))?; + Ok(match tag { + crate::AbiEventTag::Action => { + let (name, args) = decode_action_payload(&payload)?; + Event::Action { + name, + args, + reply: token, + } + } + crate::AbiEventTag::Http => Event::Http { + request: payload, + reply: token, + }, + crate::AbiEventTag::Subscribe => { + let wire: SubscribeWire = ciborium::from_reader(std::io::Cursor::new(payload))?; + Event::Subscribe { + conn: wire.conn, + event_name: wire.event_name, + reply: token, + } + } + crate::AbiEventTag::QueueSend => { + let wire: QueueSendWire = ciborium::from_reader(std::io::Cursor::new(payload))?; + Event::QueueSend { + name: wire.name, + body: wire.body, + conn: wire.conn, + request: wire.request, + wait: wire.wait, + timeout_ms: wire.timeout_ms, + reply: token, + } + } + crate::AbiEventTag::WsOpen => { + let wire: WsOpenPayload = ciborium::from_reader(std::io::Cursor::new(payload))?; + Event::WebSocketOpen { + conn: wire.conn, + request: wire.request, + reply: token, + } + } + crate::AbiEventTag::ConnOpen => { + let wire: ConnOpenWire = ciborium::from_reader(std::io::Cursor::new(payload))?; + Event::ConnOpen { + conn: wire.conn, + reply: token, + } + } + crate::AbiEventTag::ConnPreflight => { + let wire: ConnPreflightWire = ciborium::from_reader(std::io::Cursor::new(payload))?; + Event::ConnPreflight { + conn: wire.conn, + params: wire.params, + reply: token, + } + } + crate::AbiEventTag::ConnClosed => { + let wire: ConnClosedWire = ciborium::from_reader(std::io::Cursor::new(payload))?; + Event::ConnClosed { conn: wire.conn } + } + crate::AbiEventTag::SerializeState => Event::SerializeState { reply: token }, + crate::AbiEventTag::Sleep => Event::Sleep { reply: token }, + crate::AbiEventTag::Destroy => Event::Destroy { reply: token }, + }) +} diff --git a/rivetkit-rust/packages/rivet-actor-test-plugin/Cargo.toml b/rivetkit-rust/packages/rivet-actor-test-plugin/Cargo.toml new file mode 100644 index 0000000000..1718036e5c --- /dev/null +++ b/rivetkit-rust/packages/rivet-actor-test-plugin/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "rivet-actor-test-plugin" +version.workspace = true +edition.workspace = true +publish = false +description = "PortableActorCtx counter fixture compiled as both rlib and cdylib for RivetKit parity tests. Test-only." + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +anyhow.workspace = true +ciborium.workspace = true +futures.workspace = true +rivet-actor-plugin-abi.workspace = true +serde.workspace = true +serde_bytes.workspace = true +serde_json.workspace = true diff --git a/rivetkit-rust/packages/rivet-actor-test-plugin/src/lib.rs b/rivetkit-rust/packages/rivet-actor-test-plugin/src/lib.rs new file mode 100644 index 0000000000..e7c7e71a5d --- /dev/null +++ b/rivetkit-rust/packages/rivet-actor-test-plugin/src/lib.rs @@ -0,0 +1,714 @@ +//! Portable counter actor fixture. +//! +//! The public `counter_actor` function is the single actor source used by +//! native tests. The `extern "C"` exports wrap the same function in a +//! `DylibBackend` so the fixture also builds as a cdylib. + +use std::collections::HashMap; +use std::ffi::c_void; +use std::io::Cursor; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::Result; +use rivet_actor_plugin_abi::{ + self as abi, ConnInfo, DylibBackend, Event, KvEntry, KvListOpts, PortableActorCtx, + RequestSaveOpts, +}; +use serde_json::{Value as JsonValue, json}; + +pub async fn counter_actor(ctx: PortableActorCtx) -> Result<()> { + counter_actor_with_factory(ctx, None).await +} + +async fn counter_actor_with_factory( + ctx: PortableActorCtx, + factory_info: Option, +) -> Result<()> { + ctx.startup_ready(Ok(()))?; + + let mut count = read_count(&ctx.state()?); + let mut conn_stats = ConnStats::default(); + let mut scheduled_count = 0i64; + let mut double_reply_status = JsonValue::Null; + let save_wait_status = Arc::new(Mutex::new(None::)); + while let Some(event) = ctx.next_event().await? { + match event { + Event::Action { name, reply, .. } => match name.as_str() { + "increment" => { + count += 1; + ctx.request_save(RequestSaveOpts::default())?; + ctx.reply_ok(reply, encode_json(&json!(count)))?; + } + "save_direct" => { + count += 1; + ctx.save_state(encode_json(&json!({ "count": count }))) + .await?; + ctx.reply_ok(reply, encode_json(&json!(count)))?; + } + "set_state_report" => { + ctx.set_state(encode_json(&json!({ "count": 41 })))?; + count = read_count(&ctx.state()?); + ctx.reply_ok(reply, encode_json(&json!({ "count": count })))?; + } + "abort_snapshot" => { + ctx.reply_ok( + reply, + encode_json(&json!({ + "aborted": ctx.actor_aborted()?, + })), + )?; + } + "wait_abort" => { + ctx.wait_for_actor_abort().await?; + ctx.reply_ok( + reply, + encode_json(&json!({ + "aborted": ctx.actor_aborted()?, + })), + )?; + } + "save_wait" => { + count += 1; + *save_wait_status.lock().expect("save_wait status lock") = None; + let wait_ctx = ctx.clone(); + let status = save_wait_status.clone(); + thread::spawn(move || { + let result = futures::executor::block_on(wait_ctx.request_save_and_wait( + RequestSaveOpts { + immediate: true, + max_wait_ms: Some(1_000), + }, + )); + *status.lock().expect("save_wait status lock") = Some(match result { + Ok(()) => json!({ "done": true, "ok": true }), + Err(error) => { + json!({ "done": true, "ok": false, "error": format!("{error:#}") }) + } + }); + }); + ctx.reply_ok(reply, encode_json(&json!(count)))?; + } + "save_wait_status" => { + let status = save_wait_status + .lock() + .expect("save_wait status lock") + .clone() + .unwrap_or_else(|| json!({ "done": false })); + ctx.reply_ok(reply, encode_json(&status))?; + } + "double_reply_probe" => { + ctx.reply_ok(reply, encode_json(&json!({ "first": true })))?; + let second = ctx.reply_ok(reply, encode_json(&json!({ "second": true }))); + double_reply_status = json!({ + "secondOk": second.is_ok(), + "secondError": second.err().map(|error| format!("{error:#}")), + }); + } + "double_reply_status" => { + ctx.reply_ok(reply, encode_json(&double_reply_status))?; + } + "drop_reply_probe" => { + let _ = reply; + } + "reply_err_probe" => { + ctx.reply_err(reply, "portable reply error")?; + } + "sleep_now" => { + ctx.sleep()?; + ctx.reply_ok(reply, encode_json(&json!({ "requested": true })))?; + } + "keep_awake_report" => { + let before = ctx.keep_awake_count()?; + let count_ctx = ctx.clone(); + let during = ctx + .keep_awake(async move { count_ctx.keep_awake_count() }) + .await??; + let after = ctx.keep_awake_count()?; + ctx.reply_ok( + reply, + encode_json(&json!({ + "before": before, + "during": during, + "after": after, + })), + )?; + } + "fanout_alarm_report" => { + let timestamp_ms = now_ms().saturating_add(250); + ctx.broadcast( + "portable-broadcast", + encode_json(&json!({ "source": "portable-parity" })), + )?; + ctx.set_alarm(Some(timestamp_ms)).await?; + ctx.set_alarm(None).await?; + ctx.reply_ok( + reply, + encode_json(&json!({ + "broadcasted": true, + "alarmTimestampFuture": timestamp_ms >= now_ms(), + "alarmCleared": true, + })), + )?; + } + "sleep_marker" => { + let marker = ctx.kv_get(b"portable-lifecycle/sleep".to_vec()).await?; + ctx.reply_ok( + reply, + encode_json(&json!({ + "sleepCleanupObserved": marker.as_deref() == Some(b"done".as_slice()), + })), + )?; + } + "get" => { + ctx.reply_ok(reply, encode_json(&json!(count)))?; + } + "factory_config_report" => { + let info = factory_info.as_ref(); + ctx.reply_ok( + reply, + encode_json(&json!({ + "configJson": info.map(|info| info.config_json.as_str()).unwrap_or(""), + "sidecarPath": info.map(|info| info.sidecar_path.as_str()).unwrap_or(""), + })), + )?; + } + "identity_report" => { + ctx.reply_ok( + reply, + encode_json(&json!({ + "actorId": ctx.actor_id()?, + "name": ctx.name()?, + "key": ctx.key()?, + "region": ctx.region()?, + "input": ctx.input()?.map(|input| decode_json(&input)), + "hasState": ctx.has_state()?, + })), + )?; + } + "conn_report" => { + let conns = ctx.conn_list().await?; + ctx.disconnect_conn("missing-conn-for-portable-parity") + .await?; + let send_result = conn_stats.last_open.as_ref().map(|conn| { + ctx.send(conn.id.clone(), "portable-send", b"payload".to_vec()) + }); + ctx.reply_ok(reply, encode_json(&conn_stats.report(conns, send_result)))?; + } + "ack_invalid" => { + let result = ctx.ack_hibernatable_websocket_message( + b"bad".to_vec(), + b"req1".to_vec(), + 7, + ); + ctx.reply_ok( + reply, + encode_json(&json!({ + "ok": result.is_ok(), + "error": result.err().map(|error| format!("{error:#}")), + })), + )?; + } + "kv_roundtrip" => { + let report = kv_roundtrip(&ctx).await?; + ctx.reply_ok(reply, encode_json(&report))?; + } + "sqlite_roundtrip" => { + let report = sqlite_roundtrip(&ctx).await?; + ctx.reply_ok(reply, encode_json(&report))?; + } + "schedule_once" => { + ctx.after(250, "scheduled_increment", Vec::new()).await?; + let pending = ctx.scheduled_events().await?; + ctx.reply_ok( + reply, + encode_json(&json!({ + "pendingCount": pending.len(), + "firstAction": pending.first().map(|event| event.action_name.as_str()), + })), + )?; + } + "schedule_at_once" => { + let timestamp_ms = now_ms().saturating_add(250); + ctx.at(timestamp_ms, "scheduled_increment", Vec::new()) + .await?; + let pending = ctx.scheduled_events().await?; + ctx.reply_ok( + reply, + encode_json(&json!({ + "pendingCount": pending.len(), + "firstAction": pending.first().map(|event| event.action_name.as_str()), + "firstTimestampAtOrAfter": pending + .first() + .map(|event| event.timestamp_ms >= timestamp_ms) + .unwrap_or(false), + })), + )?; + } + "scheduled_increment" => { + scheduled_count += 1; + ctx.reply_ok(reply, encode_json(&json!(scheduled_count)))?; + } + "schedule_report" => { + ctx.reply_ok( + reply, + encode_json(&json!({ "scheduledCount": scheduled_count })), + )?; + } + _ => { + ctx.reply_err(reply, format!("unknown action `{name}`"))?; + } + }, + Event::SerializeState { reply } => { + ctx.reply_ok(reply, encode_json(&json!({ "count": count })))?; + } + Event::Sleep { reply } => { + ctx.kv_put(b"portable-lifecycle/sleep".to_vec(), b"done".to_vec()) + .await?; + ctx.reply_ok(reply, Vec::new())?; + } + Event::Destroy { reply } => { + ctx.reply_ok(reply, Vec::new())?; + } + Event::ConnPreflight { + conn, + params, + reply, + } => { + conn_stats.preflight_count += 1; + conn_stats.last_preflight = Some(conn); + conn_stats.last_preflight_params = Some(params); + ctx.reply_ok(reply, Vec::new())?; + } + Event::ConnOpen { conn, reply } => { + conn_stats.open_count += 1; + conn_stats.last_open = Some(conn); + ctx.reply_ok(reply, Vec::new())?; + } + Event::QueueSend { + name, + body, + conn, + request, + wait, + timeout_ms, + reply, + } => { + let response = encode_json(&json!({ + "name": name, + "body": decode_json(&body), + "conn": conn_info_json(&conn), + "request": decode_http_request(&request).ok().map(|request| http_request_json(&request)), + "wait": wait, + "timeoutMs": timeout_ms, + })); + ctx.reply_ok( + reply, + abi::encode_queue_send_response("completed", Some(response))?, + )?; + } + Event::WebSocketOpen { + conn, + request, + reply, + } => { + conn_stats.ws_open_count += 1; + conn_stats.last_ws_open = Some(conn); + conn_stats.last_ws_request = request + .as_deref() + .and_then(|request| decode_http_request(request).ok()) + .map(|request| http_request_json(&request)); + ctx.reply_ok(reply, Vec::new())?; + } + Event::Http { request, reply } => { + let request = decode_http_request(&request)?; + let body = decode_json(&request.body); + ctx.reply_ok( + reply, + encode_http_response(&HttpResponseWire { + status: 207, + headers: HashMap::from([( + "x-portable-fixture".to_owned(), + "http".to_owned(), + )]), + body: encode_json(&json!({ + "method": request.method, + "uri": request.uri, + "body": body, + "header": request.headers.get("x-portable-test").cloned(), + })), + })?, + )?; + } + Event::Subscribe { + conn, + event_name, + reply, + } => { + conn_stats.subscribe_count += 1; + conn_stats.last_subscribe = Some(conn); + conn_stats.last_subscribe_event_name = Some(event_name); + ctx.reply_ok(reply, Vec::new())?; + } + Event::ConnClosed { conn } => { + conn_stats.closed_count += 1; + conn_stats.last_closed = Some(conn); + } + } + } + + Ok(()) +} + +#[derive(Clone)] +struct FactoryInfo { + config_json: String, + sidecar_path: String, +} + +#[derive(Default)] +struct ConnStats { + preflight_count: u64, + open_count: u64, + closed_count: u64, + subscribe_count: u64, + ws_open_count: u64, + last_preflight: Option, + last_preflight_params: Option>, + last_open: Option, + last_closed: Option, + last_subscribe: Option, + last_subscribe_event_name: Option, + last_ws_open: Option, + last_ws_request: Option, +} + +impl ConnStats { + fn report(&self, conns: Vec, send_result: Option>) -> JsonValue { + json!({ + "preflightCount": self.preflight_count, + "openCount": self.open_count, + "closedCount": self.closed_count, + "subscribeCount": self.subscribe_count, + "wsOpenCount": self.ws_open_count, + "lastPreflight": self.last_preflight.as_ref().map(conn_info_json), + "lastPreflightParams": self.last_preflight_params.as_ref().map(|params| decode_json(params)), + "lastOpen": self.last_open.as_ref().map(conn_info_json), + "lastClosed": self.last_closed.as_ref().map(conn_info_json), + "lastSubscribe": self.last_subscribe.as_ref().map(conn_info_json), + "lastSubscribeEventName": self.last_subscribe_event_name.as_deref(), + "lastWsOpen": self.last_ws_open.as_ref().map(conn_info_json), + "lastWsRequest": self.last_ws_request.as_ref(), + "connList": conns.iter().map(conn_info_json).collect::>(), + "disconnectMissingOk": true, + "sendOk": send_result.as_ref().is_some_and(Result::is_ok), + "sendError": send_result.and_then(Result::err).map(|error| format!("{error:#}")), + }) + } +} + +fn conn_info_json(conn: &ConnInfo) -> JsonValue { + json!({ + "id": conn.id, + "params": decode_json(&conn.params), + "state": conn.state, + "isHibernatable": conn.is_hibernatable, + }) +} + +fn http_request_json(request: &HttpRequestWire) -> JsonValue { + json!({ + "method": request.method.as_str(), + "uri": request.uri.as_str(), + "headers": &request.headers, + "body": decode_json(&request.body), + }) +} + +async fn kv_roundtrip(ctx: &PortableActorCtx) -> Result { + let prefix = b"portable-kv/".to_vec(); + let end = b"portable-kv0".to_vec(); + ctx.kv_delete_range(prefix.clone(), end.clone()).await?; + + ctx.kv_put(b"portable-kv/a".to_vec(), b"one".to_vec()) + .await?; + ctx.kv_batch_put(vec![ + KvEntry { + key: b"portable-kv/b".to_vec(), + value: b"two".to_vec(), + }, + KvEntry { + key: b"portable-kv/c".to_vec(), + value: b"three".to_vec(), + }, + KvEntry { + key: b"portable-kv/other".to_vec(), + value: b"other".to_vec(), + }, + ]) + .await?; + + ctx.kv_delete(b"portable-kv/c".to_vec()).await?; + ctx.kv_batch_delete(vec![b"portable-kv/other".to_vec()]) + .await?; + + let got = ctx.kv_get(b"portable-kv/a".to_vec()).await?; + let batch = ctx + .kv_batch_get(vec![ + b"portable-kv/a".to_vec(), + b"portable-kv/b".to_vec(), + b"portable-kv/c".to_vec(), + ]) + .await?; + let prefix_entries = ctx + .kv_list_prefix(prefix.clone(), KvListOpts::default()) + .await?; + let range_entries = ctx + .kv_list_range( + b"portable-kv/a".to_vec(), + b"portable-kv/c".to_vec(), + KvListOpts { + reverse: true, + limit: Some(1), + }, + ) + .await?; + + ctx.kv_delete_range(prefix.clone(), end).await?; + let after_delete = ctx.kv_list_prefix(prefix, KvListOpts::default()).await?; + + Ok(json!({ + "got": bytes_option_json(got), + "batch": batch.into_iter().map(bytes_option_json).collect::>(), + "prefix": kv_entries_json(prefix_entries), + "range": kv_entries_json(range_entries), + "afterDelete": kv_entries_json(after_delete), + })) +} + +async fn sqlite_roundtrip(ctx: &PortableActorCtx) -> Result { + if !ctx.sql_is_enabled() { + return Ok(json!({ "enabled": false })); + } + + ctx.db_exec( + "CREATE TABLE IF NOT EXISTS portable_sqlite_parity ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + )", + ) + .await?; + ctx.db_run("DELETE FROM portable_sqlite_parity", None) + .await?; + ctx.db_run( + "INSERT INTO portable_sqlite_parity (id, value) VALUES (?, ?)", + Some(encode_json(&json!([1, "native-dylib-parity"]))), + ) + .await?; + ctx.db_run( + "INSERT INTO portable_sqlite_parity (id, value) VALUES (?, ?)", + Some(encode_json(&json!([2, "filtered-out"]))), + ) + .await?; + + let rows = ctx + .db_query( + "SELECT id, value FROM portable_sqlite_parity WHERE id = ?", + Some(encode_json(&json!([1]))), + ) + .await?; + let rows = decode_json(&rows); + + Ok(json!({ + "enabled": true, + "rows": rows, + })) +} + +fn kv_entries_json(entries: Vec) -> JsonValue { + JsonValue::Array( + entries + .into_iter() + .map(|entry| { + json!({ + "key": bytes_json(entry.key), + "value": bytes_json(entry.value), + }) + }) + .collect(), + ) +} + +fn bytes_option_json(bytes: Option>) -> JsonValue { + bytes.map(bytes_json).unwrap_or(JsonValue::Null) +} + +fn bytes_json(bytes: Vec) -> JsonValue { + JsonValue::String(String::from_utf8_lossy(&bytes).into_owned()) +} + +fn encode_json(value: &JsonValue) -> Vec { + let mut out = Vec::new(); + ciborium::into_writer(value, &mut out).expect("encode cbor json"); + out +} + +fn decode_json(bytes: &[u8]) -> JsonValue { + if bytes.is_empty() { + return JsonValue::Null; + } + ciborium::from_reader(Cursor::new(bytes)).unwrap_or(JsonValue::Null) +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct HttpRequestWire { + method: String, + uri: String, + headers: HashMap, + #[serde(with = "serde_bytes")] + body: Vec, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct HttpResponseWire { + status: u16, + headers: HashMap, + #[serde(with = "serde_bytes")] + body: Vec, +} + +fn decode_http_request(bytes: &[u8]) -> Result { + Ok(ciborium::from_reader(Cursor::new(bytes))?) +} + +fn encode_http_response(response: &HttpResponseWire) -> Result> { + let mut out = Vec::new(); + ciborium::into_writer(response, &mut out)?; + Ok(out) +} + +fn read_count(state: &[u8]) -> i64 { + if state.is_empty() { + return 0; + } + + let value = decode_json(state); + value + .get("count") + .and_then(JsonValue::as_i64) + .unwrap_or_default() +} + +fn now_ms() -> i64 { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + i64::try_from(millis).unwrap_or(i64::MAX) +} + +#[unsafe(no_mangle)] +pub extern "C" fn rivet_actor_abi_magic() -> u64 { + abi::RIVET_ACTOR_ABI_MAGIC +} + +#[unsafe(no_mangle)] +pub extern "C" fn rivet_actor_abi_version() -> u64 { + abi::RIVET_ACTOR_ABI_VERSION +} + +struct Plugin; +struct Factory { + info: FactoryInfo, +} +struct Instance { + join: Option>, +} + +#[unsafe(no_mangle)] +pub extern "C" fn rivet_actor_plugin_init(_out_err: *mut abi::OwnedBuf) -> *mut c_void { + Box::into_raw(Box::new(Plugin)) as *mut c_void +} + +#[unsafe(no_mangle)] +pub extern "C" fn rivet_actor_factory_new( + _plugin: *mut c_void, + config_json: abi::BorrowedBuf, + sidecar_path: abi::BorrowedBuf, + _out_err: *mut abi::OwnedBuf, +) -> *mut c_void { + let info = FactoryInfo { + config_json: String::from_utf8_lossy(unsafe { config_json.as_slice() }).into_owned(), + sidecar_path: String::from_utf8_lossy(unsafe { sidecar_path.as_slice() }).into_owned(), + }; + Box::into_raw(Box::new(Factory { info })) as *mut c_void +} + +struct SendVtable(abi::HostVtable); +unsafe impl Send for SendVtable {} + +struct SendPtr(*mut c_void); +unsafe impl Send for SendPtr {} + +impl SendPtr { + fn complete(self, done: abi::CompletionFn, result: abi::AbiResult) { + done(self.0, result); + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn rivet_actor_run( + factory: *mut c_void, + host: *const abi::HostVtable, + done: abi::CompletionFn, + user_data: *mut c_void, +) -> *mut c_void { + let host = SendVtable(unsafe { *host }); + let user_data = SendPtr(user_data); + let factory_info = unsafe { (*(factory as *const Factory)).info.clone() }; + let join = thread::spawn(move || { + let ctx = unsafe { PortableActorCtx::new_dylib(DylibBackend::from_host_vtable(&host.0)) }; + let result = + futures::executor::block_on(counter_actor_with_factory(ctx, Some(factory_info))); + let abi_result = match result { + Ok(()) => abi::AbiResult::ok(abi::OwnedBuf::empty()), + Err(err) => { + abi::AbiResult::err(abi::OwnedBuf::from_vec(format!("{err:#}").into_bytes())) + } + }; + user_data.complete(done, abi_result); + }); + + Box::into_raw(Box::new(Instance { join: Some(join) })) as *mut c_void +} + +#[unsafe(no_mangle)] +pub extern "C" fn rivet_actor_cancel(_instance: *mut c_void) {} + +#[unsafe(no_mangle)] +pub extern "C" fn rivet_actor_grace_deadline(_instance: *mut c_void) {} + +#[unsafe(no_mangle)] +pub extern "C" fn rivet_actor_instance_free(instance: *mut c_void) { + if !instance.is_null() { + let mut instance = unsafe { Box::from_raw(instance as *mut Instance) }; + if let Some(join) = instance.join.take() { + let _ = join.join(); + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn rivet_actor_factory_free(factory: *mut c_void) { + if !factory.is_null() { + unsafe { drop(Box::from_raw(factory as *mut Factory)) }; + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn rivet_actor_plugin_shutdown(plugin: *mut c_void) { + if !plugin.is_null() { + unsafe { drop(Box::from_raw(plugin as *mut Plugin)) }; + } +} diff --git a/rivetkit-rust/packages/rivetkit-core/Cargo.toml b/rivetkit-rust/packages/rivetkit-core/Cargo.toml index e4a1dc8702..9282b88b56 100644 --- a/rivetkit-rust/packages/rivetkit-core/Cargo.toml +++ b/rivetkit-rust/packages/rivetkit-core/Cargo.toml @@ -21,6 +21,7 @@ native-runtime = [ "dep:http-body-util", "dep:tokio-stream", "dep:tower-http", + "dep:libloading", "rivet-envoy-client/native-transport", ] wasm-runtime = ["rivet-envoy-client/wasm-transport"] @@ -43,7 +44,9 @@ futures.workspace = true http.workspace = true http-body-util = { workspace = true, optional = true } include_dir = { workspace = true } +libloading = { version = "0.8", optional = true } nix = { workspace = true, optional = true } +rivet-actor-plugin-abi.workspace = true parking_lot.workspace = true rand.workspace = true reqwest = { workspace = true, optional = true } @@ -86,6 +89,7 @@ web-time = "1.1" [dev-dependencies] portpicker.workspace = true +rivet-actor-test-plugin = { path = "../rivet-actor-test-plugin" } tempfile.workspace = true tokio = { workspace = true, features = ["test-util"] } tracing-subscriber.workspace = true diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs index 89e245de11..d86284af0f 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs @@ -204,6 +204,26 @@ impl ActorContext { ) } + pub fn new_with_sqlite( + actor_id: impl Into, + name: impl Into, + key: ActorKey, + region: impl Into, + sql: SqliteDb, + ) -> Self { + Self::build( + actor_id.into(), + name.into(), + key, + region.into(), + None, + String::new(), + ActorConfig::default(), + Kv::default(), + sql, + ) + } + #[cfg(test)] pub(crate) fn new_for_state_tests(kv: Kv, config: ActorConfig) -> Self { Self::build( diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs index 04855d127a..d3b0cff52b 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs @@ -9,7 +9,11 @@ pub mod kv; pub mod lifecycle_hooks; pub mod messages; pub mod metrics; +#[cfg(feature = "native-runtime")] +pub mod native_plugin; pub mod persist; +#[cfg(feature = "native-runtime")] +pub mod portable_native; pub(crate) mod preload; pub mod queue; pub mod schedule; @@ -28,6 +32,8 @@ pub use factory::{ActorEntryFn, ActorFactory}; pub use kv::Kv; pub use lifecycle_hooks::{ActorEvents, ActorStart, Reply}; pub use messages::{ActorEvent, QueueSendResult, QueueSendStatus, Request, Response, StateDelta}; +#[cfg(feature = "native-runtime")] +pub use portable_native::{NativeBackend, build_portable_native_actor_factory}; pub use queue::{ CompletableQueueMessage, EnqueueAndWaitOpts, QueueMessage, QueueNextBatchOpts, QueueNextOpts, QueueTryNextBatchOpts, QueueTryNextOpts, QueueWaitOpts, diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/native_plugin.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/native_plugin.rs new file mode 100644 index 0000000000..b6436bb5d2 --- /dev/null +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/native_plugin.rs @@ -0,0 +1,1758 @@ +//! Generic loader for native actor plugins (`dlopen` of a `cdylib`), per the +//! dylib-actor-plugin spec §6.1. RivetKit knows only this generic ABI, not any +//! product-specific symbols. The plugin is resolved by path, its ABI magic/version is +//! verified (refuse-on-mismatch, no fallback), and it is adapted into the +//! existing [`ActorFactory`] boxed-closure entry point. +//! +//! This module is the load/ABI/symbol layer. The host vtable construction and +//! the event adapter (reply slab, grace bridge) build on top of it. +//! +//! ## Event adapter mapping +//! +//! The adapter consumes the core-level [`crate::actor::messages::ActorEvent`] +//! from [`ActorStart::events`] and maps to [`abi::AbiEventTag`] — no dependency +//! on the higher-level `rivetkit` crate's `RuntimeEvent` is needed: +//! +//! | `ActorEvent` | `AbiEventTag` / handling | +//! |-------------------------|------------------------------------------------| +//! | `Action` | `Action` (reply: ok/err) | +//! | `HttpRequest` | `Http` (reply) | +//! | `SubscribeRequest` | `Subscribe` (reply: allow) | +//! | `ConnectionOpen` | `ConnOpen` (reply: accept) | +//! | `ConnectionClosed` | `ConnClosed` (no reply) | +//! | `QueueSend` | `QueueSend` (reply) | +//! | `WebSocketOpen` | `WsOpen` (reply) | +//! | `SerializeState` | `SerializeState` (reply: actor-state bytes) | +//! | `RunGracefulCleanup` | split → `Sleep`/`Destroy` by reason (reply) | +//! | `FinalizeSleep`/`Destroy` | lifecycle reply, drives VM teardown | +//! | `DisconnectConn` | consumed internally (host calls disconnect) | +//! | `ConnectionPreflight` | consumed internally | +//! | `WorkflowHistory/Replay`| not applicable to native plugins (reply empty) | +//! +//! Reply-bearing events allocate a `reply_token` into a slab that OWNS the +//! `Reply`; draining the slab on exit/cancel drops each `Reply`, firing +//! `Err(DroppedReply)` so callers never hang (spec §6.3). + +// FFI glue: `unsafe fn`s here are unsafe in their entirety by design. +#![allow(unsafe_op_in_unsafe_fn)] + +use std::collections::HashMap; +use std::ffi::c_void; +use std::panic::AssertUnwindSafe; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow, bail}; +use futures::future::FutureExt as _; +use libloading::{Library, Symbol}; +use parking_lot::Mutex; +use rivet_actor_plugin_abi as abi; +use tokio::runtime::Handle; + +use crate::ActorConfig; +use crate::actor::connection::ConnHandle; +use crate::actor::context::{ActorContext, KeepAwakeRegion}; +use crate::actor::factory::ActorFactory; +use crate::actor::lifecycle_hooks::ActorEvents; +use crate::actor::messages::{ActorEvent, Request, Response, StateDelta}; +use crate::actor::messages::{QueueSendResult, QueueSendStatus}; +use crate::actor::state::RequestSaveOpts; +use crate::actor::task_types::ShutdownKind; +use crate::types::{ListOpts, format_actor_key}; + +// --- Plugin export signatures (must match `abi::symbols` / spec §4.5) --- + +type AbiU64Fn = unsafe extern "C" fn() -> u64; +type PluginInitFn = unsafe extern "C" fn(out_err: *mut abi::OwnedBuf) -> *mut c_void; +type FactoryNewFn = unsafe extern "C" fn( + plugin: *mut c_void, + config_json: abi::BorrowedBuf, + sidecar_path: abi::BorrowedBuf, + out_err: *mut abi::OwnedBuf, +) -> *mut c_void; +type RunFn = unsafe extern "C" fn( + factory: *mut c_void, + host: *const abi::HostVtable, + done: abi::CompletionFn, + user_data: *mut c_void, +) -> *mut c_void; +type HandleFn = unsafe extern "C" fn(handle: *mut c_void); + +/// Opaque plugin-owned handle (plugin/factory/instance). Send+Sync because the +/// plugin owns the pointed-to state and the host only passes it back opaquely. +#[derive(Clone, Copy)] +pub(crate) struct OpaqueHandle(pub(crate) *mut c_void); +unsafe impl Send for OpaqueHandle {} +unsafe impl Sync for OpaqueHandle {} + +/// A `dlopen`ed, ABI-verified, initialized plugin. Kept alive for the process +/// lifetime (never unloaded — unloading a dylib with live runtime/threads is +/// unsound). One per unique dylib path. +// `grace_deadline`/`factory_free`/`plugin_shutdown` are retained for lifecycle +// paths not yet wired (host grace-deadline trigger, explicit teardown). +#[allow(dead_code)] +pub(crate) struct LoadedPlugin { + // Field order matters for drop: handle/symbols before `_lib`. We never drop + // these in practice (cached for process lifetime), but keep `_lib` last. + plugin: OpaqueHandle, + factory_new: FactoryNewFn, + run: RunFn, + cancel: HandleFn, + grace_deadline: HandleFn, + instance_free: HandleFn, + factory_free: HandleFn, + plugin_shutdown: HandleFn, + _lib: Library, +} + +unsafe impl Send for LoadedPlugin {} +unsafe impl Sync for LoadedPlugin {} + +#[allow(dead_code)] +impl LoadedPlugin { + pub(crate) fn factory_new(&self) -> FactoryNewFn { + self.factory_new + } + pub(crate) fn run(&self) -> RunFn { + self.run + } + pub(crate) fn cancel(&self) -> HandleFn { + self.cancel + } + pub(crate) fn grace_deadline(&self) -> HandleFn { + self.grace_deadline + } + pub(crate) fn instance_free(&self) -> HandleFn { + self.instance_free + } + pub(crate) fn factory_free(&self) -> HandleFn { + self.factory_free + } + pub(crate) fn plugin_shutdown(&self) -> HandleFn { + self.plugin_shutdown + } +} + +fn cache() -> &'static Mutex>> { + static CACHE: OnceLock>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Read + free an out-error `OwnedBuf` produced by the plugin, returning its +/// UTF-8 message (lossy). Consumes the buffer. +unsafe fn take_out_err(out: abi::OwnedBuf) -> String { + if out.len == 0 { + return String::new(); + } + let msg = String::from_utf8_lossy(out.as_slice()).into_owned(); + out.free_self(); + msg +} + +/// Load (or fetch from cache) the plugin at `path`, verifying its ABI and +/// running `plugin_init` exactly once per path. +pub(crate) fn load_plugin(path: &Path) -> Result> { + let key = path.to_path_buf(); + if let Some(existing) = cache().lock().get(&key).cloned() { + return Ok(existing); + } + + // SAFETY: loading an arbitrary dylib runs its initializers; we only load + // from trusted, host-resolved paths (spec §13). Symbol signatures are + // fixed by the shared `rivet-actor-plugin-abi` contract. + let loaded = unsafe { load_uncached(path) } + .with_context(|| format!("load native actor plugin at {}", path.display()))?; + let arc = Arc::new(loaded); + cache().lock().insert(key, arc.clone()); + Ok(arc) +} + +unsafe fn sym(lib: &Library, name: &[u8]) -> Result +where + T: Copy, +{ + let symbol: Symbol = lib + .get(name) + .with_context(|| format!("resolve symbol {}", String::from_utf8_lossy(name)))?; + Ok(*symbol) +} + +unsafe fn load_uncached(path: &Path) -> Result { + let lib = Library::new(path).context("dlopen")?; + + // ABI magic + version are checked FIRST, before any other call. + let abi_magic: AbiU64Fn = sym(&lib, abi::symbols::ABI_MAGIC)?; + let abi_version: AbiU64Fn = sym(&lib, abi::symbols::ABI_VERSION)?; + let magic = abi_magic(); + if magic != abi::RIVET_ACTOR_ABI_MAGIC { + bail!( + "not a rivet actor plugin (magic {magic:#x} != {:#x})", + abi::RIVET_ACTOR_ABI_MAGIC + ); + } + let version = abi_version(); + if version != abi::RIVET_ACTOR_ABI_VERSION { + bail!( + "actor plugin ABI v{version}, host expects v{} (same-version lockstep; no fallback)", + abi::RIVET_ACTOR_ABI_VERSION + ); + } + + let plugin_init: PluginInitFn = sym(&lib, abi::symbols::PLUGIN_INIT)?; + let factory_new: FactoryNewFn = sym(&lib, abi::symbols::FACTORY_NEW)?; + let run: RunFn = sym(&lib, abi::symbols::RUN)?; + let cancel: HandleFn = sym(&lib, abi::symbols::CANCEL)?; + let grace_deadline: HandleFn = sym(&lib, abi::symbols::GRACE_DEADLINE)?; + let instance_free: HandleFn = sym(&lib, abi::symbols::INSTANCE_FREE)?; + let factory_free: HandleFn = sym(&lib, abi::symbols::FACTORY_FREE)?; + let plugin_shutdown: HandleFn = sym(&lib, abi::symbols::PLUGIN_SHUTDOWN)?; + + let mut out_err = abi::OwnedBuf::empty(); + let plugin = plugin_init(&mut out_err as *mut _); + if plugin.is_null() { + let msg = take_out_err(out_err); + bail!("rivet_actor_plugin_init failed: {msg}"); + } + + Ok(LoadedPlugin { + plugin: OpaqueHandle(plugin), + factory_new, + run, + cancel, + grace_deadline, + instance_free, + factory_free, + plugin_shutdown, + _lib: lib, + }) +} + +/// Create a per-actor-type plugin factory: load the plugin, call `factory_new` +/// with the opaque config envelope + sidecar path, and adapt the result into a +/// RivetKit [`ActorFactory`]. +/// +/// NOTE: the entry closure (host vtable + event adapter) is implemented in a +/// follow-up step; this establishes the verified load + factory-construction +/// path and the factory handle the entry will `run`. +pub fn build_native_plugin_factory( + plugin_path: &Path, + config_json: &str, + sidecar_path: &str, + config: ActorConfig, +) -> Result { + let plugin = load_plugin(plugin_path)?; + + // Build the factory handle from the opaque config envelope. The plugin + // parses the JSON itself (config is opaque to the host). + let mut out_err = abi::OwnedBuf::empty(); + // SAFETY: borrowed buffers are valid for the duration of this synchronous + // call only; `factory_new` must copy anything it retains. + let factory_ptr = unsafe { + (plugin.factory_new())( + plugin.plugin.0, + abi::BorrowedBuf::from_slice(config_json.as_bytes()), + abi::BorrowedBuf::from_slice(sidecar_path.as_bytes()), + &mut out_err as *mut _, + ) + }; + if factory_ptr.is_null() { + let msg = unsafe { take_out_err(out_err) }; + return Err(anyhow!("rivet_actor_factory_new failed: {msg}")); + } + let factory = OpaqueHandle(factory_ptr); + + let plugin_for_entry = plugin.clone(); + let entry = move |start: crate::actor::lifecycle_hooks::ActorStart| { + let plugin = plugin_for_entry.clone(); + Box::pin(run_native_actor(plugin, factory, start)) + as crate::runtime::RuntimeBoxFuture> + }; + + Ok(ActorFactory::new_with_manual_startup_ready(config, entry)) +} + +/// Completion callback the plugin invokes when the actor loop exits. Reclaims +/// the boxed oneshot sender, frees the result payload, and signals the host. +struct RunDone { + status: abi::AbiStatus, + payload: Vec, +} + +extern "C" fn run_done(user_data: *mut c_void, result: abi::AbiResult) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let tx = Box::from_raw(user_data as *mut tokio::sync::oneshot::Sender); + let status = result.status; + let payload = result.payload.into_vec(); + let _ = tx.send(RunDone { status, payload }); + })); +} + +/// Tells the plugin to cancel (close its event stream) if the host drops the +/// actor future before it completes. `instance` is taken on the happy path so +/// the guard becomes a no-op once the instance is freed. +struct CancelGuard { + plugin: Arc, + instance: Option<*mut c_void>, +} +unsafe impl Send for CancelGuard {} +impl Drop for CancelGuard { + fn drop(&mut self) { + if let Some(instance) = self.instance.take() { + // Host aborted the actor future (e.g. the sleep/destroy grace + // deadline elapsed): force VM teardown, then close the event stream. + // Both are idempotent and the instance is not yet freed. + unsafe { + (self.plugin.grace_deadline())(instance); + (self.plugin.cancel())(instance); + } + } + } +} + +/// Drive one native-plugin actor instance: build the host vtable over the +/// actor context, spawn the event adapter, `run` the plugin, await completion. +async fn run_native_actor( + plugin: Arc, + factory: OpaqueHandle, + start: crate::actor::lifecycle_hooks::ActorStart, +) -> Result<()> { + let runtime = Handle::current(); + let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel::(); + let state = Arc::new(HostCtxState { + ctx: start.ctx.clone(), + runtime: runtime.clone(), + slab: ReplySlab::new(), + events: tokio::sync::Mutex::new(event_rx), + startup: Mutex::new(start.startup_ready), + keep_awake: KeepAwakeStore::new(), + }); + + // Event adapter: drains core ActorEvents -> forwarded stream / inline replies. + let loop_handle = runtime.spawn(adapter_loop(start.events, event_tx, state.clone())); + + // Host vtable, wrapped `Send` so it can live across the completion await. + // The plugin copies it synchronously in `run`. `ctx` holds ONE owned Arc ref. + let vtable = SendVtable(abi::HostVtable { + abi_version: abi::RIVET_ACTOR_ABI_VERSION, + ctx: state.clone().into_ctx_ptr(), + ctx_clone: host_ctx_clone, + ctx_release: host_ctx_release, + db_exec: host_db_exec, + db_query: host_db_query, + db_run: host_db_run, + sql_is_enabled: host_sql_is_enabled, + state_get: host_state_get, + state_set: host_state_set, + actor_identity: host_actor_identity, + state_save: host_state_save, + request_save: host_request_save, + request_save_and_wait: host_request_save_and_wait, + sleep: host_sleep, + actor_aborted: host_actor_aborted, + wait_actor_abort: host_wait_actor_abort, + keep_awake_enter: host_keep_awake_enter, + keep_awake_exit: host_keep_awake_exit, + keep_awake_count: host_keep_awake_count, + kv_get: host_kv_get, + kv_put: host_kv_put, + kv_delete: host_kv_delete, + kv_batch_get: host_kv_batch_get, + kv_batch_put: host_kv_batch_put, + kv_batch_delete: host_kv_batch_delete, + kv_delete_range: host_kv_delete_range, + kv_list_prefix: host_kv_list_prefix, + kv_list_range: host_kv_list_range, + schedule_after: host_schedule_after, + schedule_at: host_schedule_at, + set_alarm: host_set_alarm, + scheduled_events: host_scheduled_events, + conn_list: host_conn_list, + conn_disconnect: host_conn_disconnect, + hibernatable_ws_ack: host_hibernatable_ws_ack, + conn_send: host_conn_send, + next_event: host_next_event, + reply_ok: host_reply_ok, + reply_err: host_reply_err, + startup_ready: host_startup_ready, + broadcast: host_broadcast, + log: host_log, + }); + + // Completion bridge: plugin -> host on actor exit. + let (done_tx, done_rx) = tokio::sync::oneshot::channel::(); + let user_data = Box::into_raw(Box::new(done_tx)) as *mut c_void; + + // Submit. The plugin copies the vtable synchronously in `run`. + let instance = unsafe { + (plugin.run())( + factory.0, + &vtable.0 as *const abi::HostVtable, + run_done, + user_data, + ) + }; + let mut cancel = CancelGuard { + plugin: plugin.clone(), + instance: Some(instance), + }; + + // Await actor exit. + let status = done_rx.await; + + // Cleanup: stop the adapter loop, drain outstanding replies (DroppedReply), + // release the original ctx ref, free the instance (taken so the guard is a + // no-op on the freed handle). + let instance = cancel.instance.take().expect("instance present after run"); + loop_handle.abort(); + state.slab.drain(); + // SAFETY: balanced with `into_ctx_ptr`; in-flight callbacks hold their own + // ctx clones, so the underlying ActorContext stays alive until they drop. + unsafe { + host_ctx_release(vtable.0.ctx); + (plugin.instance_free())(instance); + } + + match status { + Ok(RunDone { + status: abi::AbiStatus::Ok, + .. + }) => Ok(()), + Ok(done) => { + let message = String::from_utf8_lossy(&done.payload); + if message.is_empty() { + Err(anyhow!("native actor exited with status {:?}", done.status)) + } else { + Err(anyhow!( + "native actor exited with status {:?}: {message}", + done.status + )) + } + } + Err(_) => Err(anyhow!("native actor completion channel dropped")), + } +} + +/// `Send` wrapper so the `#[repr(C)]` vtable (which holds a `*const c_void`) +/// can be kept alive across the completion await. The pointed-to state is +/// `Send + Sync` (`Arc`). +struct SendVtable(abi::HostVtable); +unsafe impl Send for SendVtable {} + +// --------------------------------------------------------------------------- +// Host vtable — the plugin -> host capabilities (spec §4.4 / §6.2). +// +// The opaque `ctx` handle the plugin receives is `Arc::into_raw(Arc)`. +// It is refcounted: `ctx_clone`/`ctx_release` manage the Arc so the underlying +// `ActorContext` outlives any in-flight callback (spec §6.4). The async `db_*` +// fns are called ON THE PLUGIN'S THREAD, so they must spawn on the captured +// HOST runtime `Handle` (ambient spawn would hit the plugin's tokio). +// --------------------------------------------------------------------------- + +/// One forwarded lifecycle event: `(tag, reply_token, event_bytes)`. +pub(crate) type ForwardedEvent = (u32, u64, Vec); + +/// State behind the opaque `HostVtable.ctx` pointer. Shared by every vtable fn +/// and the adapter loop; refcounted so it outlives in-flight callbacks. +pub(crate) struct HostCtxState { + ctx: ActorContext, + runtime: Handle, + /// Outstanding replies awaiting plugin answers (spec §6.3). + slab: ReplySlab, + /// Receiver side of the adapter loop's forwarded-event stream. The plugin + /// pulls via `next_event`; `None` (closed) => `ChannelClosed`. + events: tokio::sync::Mutex>, + /// Manual startup-ready signal (the entry uses `new_with_manual_startup_ready`). + startup: Mutex>>>, + /// User keep-awake regions held by a plugin actor. + keep_awake: KeepAwakeStore, +} + +impl HostCtxState { + pub(crate) fn into_ctx_ptr(self: Arc) -> *const c_void { + Arc::into_raw(self) as *const c_void + } +} + +extern "C" fn host_next_event(ctx: *const c_void, done: abi::CompletionFn, user_data: *mut c_void) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let ud = SendUserData(user_data); + let handle = state.runtime.clone(); + handle.spawn(async move { + let ud = ud; + let mut rx = state.events.lock().await; + let result = match rx.recv().await { + Some((tag, token, payload)) => abi::AbiResult::ok(abi::OwnedBuf::from_vec( + abi::encode_event_frame(tag, abi::ReplyToken(token), &payload), + )), + None => abi::AbiResult::channel_closed(), + }; + drop(rx); + done(ud.0, result); + }); + })); +} + +extern "C" fn host_reply_ok( + ctx: *const c_void, + reply_token: u64, + payload: abi::OwnedBuf, +) -> abi::AbiStatus { + std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let bytes = payload.into_vec(); + if state.slab.fulfill_ok(reply_token, bytes) { + abi::AbiStatus::Ok + } else { + abi::AbiStatus::Err + } + })) + .unwrap_or(abi::AbiStatus::Panic) +} + +extern "C" fn host_reply_err( + ctx: *const c_void, + reply_token: u64, + err: abi::OwnedBuf, +) -> abi::AbiStatus { + std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let msg = String::from_utf8_lossy(&err.into_vec()).into_owned(); + if state.slab.fulfill_err(reply_token, msg) { + abi::AbiStatus::Ok + } else { + abi::AbiStatus::Err + } + })) + .unwrap_or(abi::AbiStatus::Panic) +} + +extern "C" fn host_startup_ready(ctx: *const c_void, ok: u8, err_msg: abi::BorrowedBuf) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + if let Some(tx) = state.startup.lock().take() { + let result = if ok != 0 { + Ok(()) + } else { + Err(anyhow!("{}", String::from_utf8_lossy(err_msg.as_slice()))) + }; + let _ = tx.send(result); + } + })); +} + +/// Send wrapper for the plugin's `user_data` pointer so it can move into a +/// spawned task. The plugin owns the pointee; the host only round-trips it. +struct SendUserData(*mut c_void); +unsafe impl Send for SendUserData {} + +/// Reconstitute an owned `Arc` from the opaque pointer WITHOUT +/// dropping the caller's reference (bumps the strong count by one). The +/// returned Arc must be dropped to release that bump. +unsafe fn ctx_arc(ptr: *const c_void) -> Arc { + let p = ptr as *const HostCtxState; + Arc::increment_strong_count(p); + Arc::from_raw(p) +} + +extern "C" fn host_ctx_clone(ptr: *const c_void) -> *const c_void { + let _ = std::panic::catch_unwind(|| unsafe { + Arc::increment_strong_count(ptr as *const HostCtxState); + }); + ptr +} + +extern "C" fn host_ctx_release(ptr: *const c_void) { + let _ = std::panic::catch_unwind(|| unsafe { + Arc::decrement_strong_count(ptr as *const HostCtxState); + }); +} + +/// Encode a host-side error for transport. TODO(§4.7): structured +/// `{group, code, message, fatal}` CBOR; for now the UTF-8 message. +fn encode_db_error(err: &anyhow::Error) -> abi::OwnedBuf { + abi::OwnedBuf::from_vec(format!("{err:#}").into_bytes()) +} + +fn ok_bytes(bytes: Vec) -> abi::AbiResult { + abi::AbiResult::ok(abi::OwnedBuf::from_vec(bytes)) +} + +fn ok_cbor(value: &T) -> abi::AbiResult { + match encode_cbor(value) { + Ok(bytes) => ok_bytes(bytes), + Err(error) => abi::AbiResult::err(encode_db_error(&error)), + } +} + +fn encode_cbor(value: &T) -> Result> { + let mut out = Vec::new(); + ciborium::into_writer(value, &mut out)?; + Ok(out) +} + +fn decode_cbor(bytes: &[u8]) -> Result { + Ok(ciborium::from_reader(std::io::Cursor::new(bytes))?) +} + +fn list_opts(opts: abi::KvListOpts) -> ListOpts { + ListOpts { + reverse: opts.reverse, + limit: opts.limit, + } +} + +struct CompletionGuard { + done: abi::CompletionFn, + ud: SendUserData, + fired: bool, +} + +impl CompletionGuard { + fn fire(&mut self, result: abi::AbiResult) { + if !self.fired { + self.fired = true; + (self.done)(self.ud.0, result); + } + } +} + +impl Drop for CompletionGuard { + fn drop(&mut self) { + self.fire(abi::AbiResult::status_only(abi::AbiStatus::Cancelled)); + } +} + +/// Spawn `fut` on the host runtime, then deliver its `AbiResult` to the plugin +/// completion callback exactly once. Keeps `state` alive across the call +/// (refcount), and wakes the plugin if the task is cancelled or panics. +fn spawn_completion( + state: Arc, + done: abi::CompletionFn, + user_data: *mut c_void, + fut: F, +) where + F: std::future::Future + Send + 'static, +{ + let ud = SendUserData(user_data); + let handle = state.runtime.clone(); + handle.spawn(async move { + // Keep ctx alive for the whole op. + let _state = state; + let mut guard = CompletionGuard { + done, + ud, + fired: false, + }; + let result = match AssertUnwindSafe(fut).catch_unwind().await { + Ok(result) => result, + Err(_) => abi::AbiResult::status_only(abi::AbiStatus::Panic), + }; + guard.fire(result); + }); +} + +extern "C" fn host_db_exec( + ctx: *const c_void, + sql: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let sql_vec = sql.into_vec(); + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + let r = async { + let sql_str = std::str::from_utf8(&sql_vec).context("sql utf8")?; + st.ctx.db_exec(sql_str).await + } + .await; + match r { + Ok(bytes) => ok_bytes(bytes), + Err(e) => abi::AbiResult::err(encode_db_error(&e)), + } + }); + })); +} + +extern "C" fn host_db_query( + ctx: *const c_void, + sql: abi::OwnedBuf, + params: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let sql_vec = sql.into_vec(); + let params_vec = params.into_vec(); + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + let r = async { + let sql_str = std::str::from_utf8(&sql_vec).context("sql utf8")?; + let params = (!params_vec.is_empty()).then_some(params_vec.as_slice()); + st.ctx.db_query(sql_str, params).await + } + .await; + match r { + Ok(bytes) => ok_bytes(bytes), + Err(e) => abi::AbiResult::err(encode_db_error(&e)), + } + }); + })); +} + +extern "C" fn host_db_run( + ctx: *const c_void, + sql: abi::OwnedBuf, + params: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let sql_vec = sql.into_vec(); + let params_vec = params.into_vec(); + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + let r = async { + let sql_str = std::str::from_utf8(&sql_vec).context("sql utf8")?; + let params = (!params_vec.is_empty()).then_some(params_vec.as_slice()); + st.ctx.db_run(sql_str, params).await + } + .await; + match r { + Ok(()) => abi::AbiResult::ok(abi::OwnedBuf::empty()), + Err(e) => abi::AbiResult::err(encode_db_error(&e)), + } + }); + })); +} + +extern "C" fn host_sql_is_enabled(ctx: *const c_void) -> u8 { + std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + u8::from(state.ctx.sql().is_enabled()) + })) + .unwrap_or(0) +} + +extern "C" fn host_state_get(ctx: *const c_void) -> abi::OwnedBuf { + std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + abi::OwnedBuf::from_vec(state.ctx.state()) + })) + .unwrap_or_else(|_| abi::OwnedBuf::empty()) +} + +extern "C" fn host_state_set(ctx: *const c_void, state_bytes: abi::OwnedBuf) -> abi::AbiStatus { + std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + state.ctx.set_initial_state(state_bytes.into_vec()); + abi::AbiStatus::Ok + })) + .unwrap_or(abi::AbiStatus::Panic) +} + +extern "C" fn host_actor_identity(ctx: *const c_void) -> abi::OwnedBuf { + std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let identity = abi::ActorIdentity { + actor_id: state.ctx.actor_id().to_owned(), + name: state.ctx.name().to_owned(), + key: format_actor_key(state.ctx.key()), + region: state.ctx.region().to_owned(), + input: state.ctx.input(), + has_state: state.ctx.has_state(), + }; + match encode_cbor(&identity) { + Ok(bytes) => abi::OwnedBuf::from_vec(bytes), + Err(error) => encode_db_error(&error), + } + })) + .unwrap_or_else(|_| abi::OwnedBuf::empty()) +} + +extern "C" fn host_state_save( + ctx: *const c_void, + state_bytes: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let bytes = state_bytes.into_vec(); + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + match st.ctx.save_state(vec![StateDelta::ActorState(bytes)]).await { + Ok(()) => abi::AbiResult::ok(abi::OwnedBuf::empty()), + Err(e) => abi::AbiResult::err(encode_db_error(&e)), + } + }); + })); +} + +extern "C" fn host_request_save( + ctx: *const c_void, + immediate: u8, + has_max_wait: u8, + max_wait_ms: u32, +) -> abi::AbiStatus { + std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + state.ctx.request_save(RequestSaveOpts { + immediate: immediate != 0, + max_wait_ms: (has_max_wait != 0).then_some(max_wait_ms), + }); + abi::AbiStatus::Ok + })) + .unwrap_or(abi::AbiStatus::Panic) +} + +extern "C" fn host_request_save_and_wait( + ctx: *const c_void, + immediate: u8, + has_max_wait: u8, + max_wait_ms: u32, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let opts = RequestSaveOpts { + immediate: immediate != 0, + max_wait_ms: (has_max_wait != 0).then_some(max_wait_ms), + }; + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + match st.ctx.request_save_and_wait(opts).await { + Ok(()) => abi::AbiResult::ok(abi::OwnedBuf::empty()), + Err(e) => abi::AbiResult::err(encode_db_error(&e)), + } + }); + })); +} + +extern "C" fn host_sleep(ctx: *const c_void) -> abi::AbiResult { + std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + match state.ctx.sleep() { + Ok(()) => ok_bytes(Vec::new()), + Err(error) => abi::AbiResult::err(encode_db_error(&error)), + } + })) + .unwrap_or_else(|_| abi::AbiResult::status_only(abi::AbiStatus::Panic)) +} + +extern "C" fn host_actor_aborted(ctx: *const c_void) -> u8 { + std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + u8::from(state.ctx.actor_aborted()) + })) + .unwrap_or(1) +} + +extern "C" fn host_wait_actor_abort( + ctx: *const c_void, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let token = state.ctx.actor_abort_signal(); + spawn_completion(state, done, user_data, async move { + token.cancelled().await; + abi::AbiResult::ok(abi::OwnedBuf::empty()) + }); + })); +} + +extern "C" fn host_keep_awake_enter(ctx: *const c_void) -> abi::AbiResult { + std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + ok_cbor(&state.keep_awake.insert(state.ctx.keep_awake_region())) + })) + .unwrap_or_else(|_| abi::AbiResult::status_only(abi::AbiStatus::Panic)) +} + +extern "C" fn host_keep_awake_exit(ctx: *const c_void, token: u64) -> abi::AbiStatus { + std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + match state.keep_awake.remove(abi::KeepAwakeToken { token }) { + Ok(()) => abi::AbiStatus::Ok, + Err(error) => { + tracing::warn!(?error, "native plugin released an unknown keep-awake token"); + abi::AbiStatus::Err + } + } + })) + .unwrap_or(abi::AbiStatus::Panic) +} + +extern "C" fn host_keep_awake_count(ctx: *const c_void) -> u64 { + std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + state.ctx.keep_awake_count() as u64 + })) + .unwrap_or(0) +} + +extern "C" fn host_kv_get( + ctx: *const c_void, + request: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let request_bytes = request.into_vec(); + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + let r = async { + let request: abi::KvKeyRequest = decode_cbor(&request_bytes)?; + let mut values = st.ctx.kv_batch_get(&[request.key.as_slice()]).await?; + Ok::<_, anyhow::Error>(abi::KvGetResponse { + value: values.pop().flatten(), + }) + } + .await; + match r { + Ok(response) => ok_cbor(&response), + Err(e) => abi::AbiResult::err(encode_db_error(&e)), + } + }); + })); +} + +extern "C" fn host_kv_put( + ctx: *const c_void, + request: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let request_bytes = request.into_vec(); + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + let r = async { + let request: abi::KvEntriesRequest = decode_cbor(&request_bytes)?; + let refs: Vec<(&[u8], &[u8])> = request + .entries + .iter() + .map(|entry| (entry.key.as_slice(), entry.value.as_slice())) + .collect(); + st.ctx.kv_batch_put(&refs).await + } + .await; + match r { + Ok(()) => abi::AbiResult::ok(abi::OwnedBuf::empty()), + Err(e) => abi::AbiResult::err(encode_db_error(&e)), + } + }); + })); +} + +extern "C" fn host_kv_delete( + ctx: *const c_void, + request: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let request_bytes = request.into_vec(); + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + let r = async { + let request: abi::KvKeysRequest = decode_cbor(&request_bytes)?; + let refs: Vec<&[u8]> = request.keys.iter().map(Vec::as_slice).collect(); + st.ctx.kv_batch_delete(&refs).await + } + .await; + match r { + Ok(()) => abi::AbiResult::ok(abi::OwnedBuf::empty()), + Err(e) => abi::AbiResult::err(encode_db_error(&e)), + } + }); + })); +} + +extern "C" fn host_kv_batch_get( + ctx: *const c_void, + request: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let request_bytes = request.into_vec(); + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + let r = async { + let request: abi::KvKeysRequest = decode_cbor(&request_bytes)?; + let refs: Vec<&[u8]> = request.keys.iter().map(Vec::as_slice).collect(); + Ok::<_, anyhow::Error>(abi::KvValuesResponse { + values: st.ctx.kv_batch_get(&refs).await?, + }) + } + .await; + match r { + Ok(response) => ok_cbor(&response), + Err(e) => abi::AbiResult::err(encode_db_error(&e)), + } + }); + })); +} + +extern "C" fn host_kv_batch_put( + ctx: *const c_void, + request: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + host_kv_put(ctx, request, done, user_data); +} + +extern "C" fn host_kv_batch_delete( + ctx: *const c_void, + request: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + host_kv_delete(ctx, request, done, user_data); +} + +extern "C" fn host_kv_delete_range( + ctx: *const c_void, + request: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let request_bytes = request.into_vec(); + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + let r = async { + let request: abi::KvRangeRequest = decode_cbor(&request_bytes)?; + st.ctx.kv_delete_range(&request.start, &request.end).await + } + .await; + match r { + Ok(()) => abi::AbiResult::ok(abi::OwnedBuf::empty()), + Err(e) => abi::AbiResult::err(encode_db_error(&e)), + } + }); + })); +} + +extern "C" fn host_kv_list_prefix( + ctx: *const c_void, + request: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let request_bytes = request.into_vec(); + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + let r = async { + let request: abi::KvListPrefixRequest = decode_cbor(&request_bytes)?; + let entries = st + .ctx + .kv_list_prefix(&request.prefix, list_opts(request.opts)) + .await? + .into_iter() + .map(|(key, value)| abi::KvEntry { key, value }) + .collect(); + Ok::<_, anyhow::Error>(abi::KvListResponse { entries }) + } + .await; + match r { + Ok(response) => ok_cbor(&response), + Err(e) => abi::AbiResult::err(encode_db_error(&e)), + } + }); + })); +} + +extern "C" fn host_kv_list_range( + ctx: *const c_void, + request: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let request_bytes = request.into_vec(); + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + let r = async { + let request: abi::KvListRangeRequest = decode_cbor(&request_bytes)?; + let entries = st + .ctx + .kv_list_range(&request.start, &request.end, list_opts(request.opts)) + .await? + .into_iter() + .map(|(key, value)| abi::KvEntry { key, value }) + .collect(); + Ok::<_, anyhow::Error>(abi::KvListResponse { entries }) + } + .await; + match r { + Ok(response) => ok_cbor(&response), + Err(e) => abi::AbiResult::err(encode_db_error(&e)), + } + }); + })); +} + +extern "C" fn host_schedule_after( + ctx: *const c_void, + request: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let request_bytes = request.into_vec(); + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + let r = async { + let request: abi::ScheduleActionRequest = decode_cbor(&request_bytes)?; + let delay_ms = request + .delay_ms + .ok_or_else(|| anyhow!("schedule_after missing delay_ms"))?; + st.ctx.after( + Duration::from_millis(delay_ms), + &request.action_name, + &request.args, + ); + Ok::<_, anyhow::Error>(()) + } + .await; + match r { + Ok(()) => abi::AbiResult::ok(abi::OwnedBuf::empty()), + Err(e) => abi::AbiResult::err(encode_db_error(&e)), + } + }); + })); +} + +extern "C" fn host_schedule_at( + ctx: *const c_void, + request: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let request_bytes = request.into_vec(); + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + let r = async { + let request: abi::ScheduleActionRequest = decode_cbor(&request_bytes)?; + let timestamp_ms = request + .timestamp_ms + .ok_or_else(|| anyhow!("schedule_at missing timestamp_ms"))?; + st.ctx.at(timestamp_ms, &request.action_name, &request.args); + Ok::<_, anyhow::Error>(()) + } + .await; + match r { + Ok(()) => abi::AbiResult::ok(abi::OwnedBuf::empty()), + Err(e) => abi::AbiResult::err(encode_db_error(&e)), + } + }); + })); +} + +extern "C" fn host_set_alarm( + ctx: *const c_void, + request: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let request_bytes = request.into_vec(); + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + let r = async { + let request: abi::ScheduleAlarmRequest = decode_cbor(&request_bytes)?; + st.ctx.set_alarm(request.timestamp_ms) + } + .await; + match r { + Ok(()) => abi::AbiResult::ok(abi::OwnedBuf::empty()), + Err(e) => abi::AbiResult::err(encode_db_error(&e)), + } + }); + })); +} + +extern "C" fn host_scheduled_events( + ctx: *const c_void, + request: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + request.free_self(); + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + let events = st + .ctx + .scheduled_events() + .into_iter() + .map(|event| abi::ScheduledEvent { + event_id: event.event_id, + timestamp_ms: event.timestamp, + action_name: event.action, + args: event.args, + }) + .collect(); + ok_cbor(&abi::ScheduledEventsResponse { events }) + }); + })); +} + +extern "C" fn host_conn_list( + ctx: *const c_void, + request: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + request.free_self(); + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + let conns = st.ctx.conns().map(|conn| conn_info(&conn)).collect(); + ok_cbor(&abi::ConnListResponse { conns }) + }); + })); +} + +extern "C" fn host_conn_disconnect( + ctx: *const c_void, + request: abi::OwnedBuf, + done: abi::CompletionFn, + user_data: *mut c_void, +) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let request_bytes = request.into_vec(); + let st = state.clone(); + spawn_completion(state, done, user_data, async move { + let r = async { + let request: abi::ConnDisconnectRequest = decode_cbor(&request_bytes)?; + st.ctx + .disconnect_conns(|conn| request.conn_ids.iter().any(|id| id == conn.id())) + .await + } + .await; + match r { + Ok(()) => abi::AbiResult::ok(abi::OwnedBuf::empty()), + Err(e) => abi::AbiResult::err(encode_db_error(&e)), + } + }); + })); +} + +extern "C" fn host_hibernatable_ws_ack( + ctx: *const c_void, + gateway_id: abi::OwnedBuf, + request_id: abi::OwnedBuf, + server_message_index: u16, +) -> abi::AbiResult { + std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let gateway_id = gateway_id.into_vec(); + let request_id = request_id.into_vec(); + match state.ctx.ack_hibernatable_websocket_message( + &gateway_id, + &request_id, + server_message_index, + ) { + Ok(()) => ok_bytes(Vec::new()), + Err(error) => abi::AbiResult::err(encode_db_error(&error)), + } + })) + .unwrap_or_else(|_| abi::AbiResult::status_only(abi::AbiStatus::Panic)) +} + +extern "C" fn host_conn_send(ctx: *const c_void, request: abi::OwnedBuf) -> abi::AbiResult { + std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let request: abi::ConnSendRequest = match decode_cbor(&request.into_vec()) { + Ok(request) => request, + Err(error) => return abi::AbiResult::err(encode_db_error(&error)), + }; + let Some(conn) = state.ctx.conns().find(|conn| conn.id() == request.conn_id) else { + return abi::AbiResult::err(encode_db_error(&anyhow!( + "connection `{}` not found", + request.conn_id + ))); + }; + match conn.try_send(&request.name, &request.payload) { + Ok(()) => ok_bytes(Vec::new()), + Err(error) => abi::AbiResult::err(encode_db_error(&error)), + } + })) + .unwrap_or_else(|_| abi::AbiResult::status_only(abi::AbiStatus::Panic)) +} + +extern "C" fn host_broadcast( + ctx: *const c_void, + name: abi::OwnedBuf, + payload: abi::OwnedBuf, +) -> abi::AbiStatus { + std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let state = ctx_arc(ctx); + let name_vec = name.into_vec(); + let payload_vec = payload.into_vec(); + match std::str::from_utf8(&name_vec) { + Ok(name_str) => { + state.ctx.broadcast(name_str, &payload_vec); + abi::AbiStatus::Ok + } + Err(_) => abi::AbiStatus::Err, + } + })) + .unwrap_or(abi::AbiStatus::Panic) +} + +extern "C" fn host_log(ctx: *const c_void, level: i32, msg: abi::BorrowedBuf) { + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { + let _state = ctx_arc(ctx); + let bytes = msg.as_slice(); + let text = String::from_utf8_lossy(bytes); + match level { + 0 => tracing::trace!(target: "native_plugin", "{text}"), + 1 => tracing::debug!(target: "native_plugin", "{text}"), + 2 => tracing::info!(target: "native_plugin", "{text}"), + 3 => tracing::warn!(target: "native_plugin", "{text}"), + _ => tracing::error!(target: "native_plugin", "{text}"), + } + })); +} + +/// HTTP request forwarded to the plugin (`Http` tag payload), CBOR-encoded. +#[derive(serde::Serialize, serde::Deserialize)] +struct HttpReqWire { + method: String, + uri: String, + headers: HashMap, + #[serde(with = "serde_bytes")] + body: Vec, +} + +/// HTTP response the plugin returns in its reply, CBOR-encoded. +#[derive(serde::Serialize, serde::Deserialize)] +struct HttpRespWire { + status: u16, + headers: HashMap, + #[serde(with = "serde_bytes")] + body: Vec, +} + +fn encode_http_request(req: &Request) -> Vec { + let (method, uri, headers, body) = req.to_parts(); + let wire = HttpReqWire { + method, + uri, + headers, + body, + }; + let mut out = Vec::new(); + let _ = ciborium::into_writer(&wire, &mut out); + out +} + +fn decode_http_response(bytes: &[u8]) -> Result { + let wire: HttpRespWire = ciborium::from_reader(std::io::Cursor::new(bytes)) + .context("decode plugin http response")?; + Response::from_parts(wire.status, wire.headers, wire.body) +} + +fn conn_info(conn: &ConnHandle) -> abi::ConnInfo { + abi::ConnInfo { + id: conn.id().to_owned(), + params: conn.params(), + state: conn.state(), + is_hibernatable: conn.is_hibernatable(), + } +} + +/// The event adapter (spec §6.3): drains the core `ActorEvents`, forwards +/// reply-bearing actor events to the plugin (storing their `Reply` in the slab), +/// and answers reject/skip/internal events inline. On stream end (actor exit) +/// it drains the slab so no awaiting caller hangs. +async fn adapter_loop( + mut events: ActorEvents, + tx: tokio::sync::mpsc::UnboundedSender, + state: Arc, +) { + use abi::AbiEventTag as Tag; + while let Some(ev) = events.recv().await { + match ev { + // --- forwarded (reply stored in slab) --- + ActorEvent::Action { + name, args, reply, .. + } => { + let token = state.slab.insert(PendingReply::Bytes(reply)); + if tx + .send(( + Tag::Action as u32, + token, + abi::encode_action_payload(&name, &args), + )) + .is_err() + { + break; + } + } + ActorEvent::ConnectionPreflight { + conn, + params, + reply, + .. + } => { + let token = state.slab.insert(PendingReply::Unit(reply)); + let payload = match abi::encode_conn_preflight_payload(&conn_info(&conn), ¶ms) { + Ok(payload) => payload, + Err(error) => { + tracing::error!(?error, "failed to encode connection preflight event"); + state.slab.fulfill_err(token, format!("{error:#}")); + continue; + } + }; + if tx + .send((Tag::ConnPreflight as u32, token, payload)) + .is_err() + { + break; + } + } + ActorEvent::ConnectionOpen { conn, reply, .. } => { + let token = state.slab.insert(PendingReply::Unit(reply)); + let payload = match abi::encode_conn_open_payload(&conn_info(&conn)) { + Ok(payload) => payload, + Err(error) => { + tracing::error!(?error, "failed to encode connection open event"); + state.slab.fulfill_err(token, format!("{error:#}")); + continue; + } + }; + if tx.send((Tag::ConnOpen as u32, token, payload)).is_err() { + break; + } + } + ActorEvent::SubscribeRequest { + conn, + reply, + event_name, + } => { + let token = state.slab.insert(PendingReply::Unit(reply)); + let payload = match abi::encode_subscribe_payload(&conn_info(&conn), &event_name) { + Ok(payload) => payload, + Err(error) => { + tracing::error!(?error, "failed to encode subscribe event"); + state.slab.fulfill_err(token, format!("{error:#}")); + continue; + } + }; + if tx.send((Tag::Subscribe as u32, token, payload)).is_err() { + break; + } + } + ActorEvent::QueueSend { + name, + body, + conn, + request, + wait, + timeout_ms, + reply, + } => { + let token = state.slab.insert(PendingReply::Queue(reply)); + let payload = match abi::encode_queue_send_payload( + &name, + &body, + &conn_info(&conn), + &encode_http_request(&request), + wait, + timeout_ms, + ) { + Ok(payload) => payload, + Err(error) => { + tracing::error!(?error, "failed to encode queue send event"); + state.slab.fulfill_err(token, format!("{error:#}")); + continue; + } + }; + if tx.send((Tag::QueueSend as u32, token, payload)).is_err() { + break; + } + } + ActorEvent::WebSocketOpen { + conn, + request, + reply, + .. + } => { + let token = state.slab.insert(PendingReply::Unit(reply)); + let request = request.as_ref().map(encode_http_request); + let payload = + match abi::encode_ws_open_payload(&conn_info(&conn), request.as_deref()) { + Ok(payload) => payload, + Err(error) => { + tracing::error!(?error, "failed to encode websocket open event"); + state.slab.fulfill_err(token, format!("{error:#}")); + continue; + } + }; + if tx.send((Tag::WsOpen as u32, token, payload)).is_err() { + break; + } + } + ActorEvent::RunGracefulCleanup { reason, reply } => { + let tag = match reason { + ShutdownKind::Sleep => Tag::Sleep, + ShutdownKind::Destroy => Tag::Destroy, + }; + let token = state.slab.insert(PendingReply::Unit(reply)); + if tx.send((tag as u32, token, Vec::new())).is_err() { + break; + } + } + ActorEvent::ConnectionClosed { conn } => { + // No reply; reply_token 0. + let payload = match abi::encode_conn_closed_payload(&conn_info(&conn)) { + Ok(payload) => payload, + Err(error) => { + tracing::error!(?error, "failed to encode connection closed event"); + continue; + } + }; + if tx.send((Tag::ConnClosed as u32, 0, payload)).is_err() { + break; + } + } + + ActorEvent::HttpRequest { request, reply } => { + let token = state.slab.insert(PendingReply::Http(reply)); + if tx + .send((Tag::Http as u32, token, encode_http_request(&request))) + .is_err() + { + break; + } + } + ActorEvent::SerializeState { reply, .. } => { + let token = state.slab.insert(PendingReply::State(reply)); + if tx + .send((Tag::SerializeState as u32, token, Vec::new())) + .is_err() + { + break; + } + } + + // --- answered inline (not forwarded) --- + ActorEvent::DisconnectConn { reply, .. } => reply.send(Ok(())), + ActorEvent::WorkflowHistoryRequested { reply } => reply.send(Ok(None)), + ActorEvent::WorkflowReplayRequested { reply, .. } => reply.send(Ok(None)), + + #[cfg(test)] + ActorEvent::BeginSleep => {} + #[cfg(test)] + ActorEvent::FinalizeSleep { reply } => reply.send(Ok(())), + #[cfg(test)] + ActorEvent::Destroy { reply } => reply.send(Ok(())), + } + } + // Actor exiting: fail any outstanding replies (DroppedReply) so no caller hangs. + state.slab.drain(); +} + +// --------------------------------------------------------------------------- +// Reply slab (spec §6.3) — the safety-critical reply lifecycle. +// +// Forwarded reply-bearing events hand their `Reply` to this slab keyed by a +// `reply_token`. The plugin answers via `reply_ok`/`reply_err(token, ..)`. On +// actor exit/cancel the slab is DRAINED, dropping every outstanding `Reply` +// — whose `Drop` sends `Err(DroppedReply)` so callers never hang. +// --------------------------------------------------------------------------- + +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::actor::lifecycle_hooks::Reply; + +/// A reply handle awaiting the plugin's answer. Only the variants the adapter +/// forwards are represented (Action → bytes; lifecycle/conn/subscribe → unit); +/// other events are answered inline by the adapter and never reach the slab. +pub(crate) enum PendingReply { + Bytes(Reply>), + Unit(Reply<()>), + State(Reply>), + Http(Reply), + Queue(Reply), +} + +impl PendingReply { + fn fulfill_ok(self, payload: Vec) { + match self { + PendingReply::Bytes(r) => r.send(Ok(payload)), + PendingReply::Unit(r) => r.send(Ok(())), + PendingReply::State(r) => { + let deltas = if payload.is_empty() { + Vec::new() + } else { + vec![StateDelta::ActorState(payload)] + }; + r.send(Ok(deltas)); + } + PendingReply::Http(r) => r.send(decode_http_response(&payload)), + PendingReply::Queue(r) => r.send(decode_queue_send_response(&payload)), + } + } + + fn fulfill_err(self, msg: String) { + match self { + PendingReply::Bytes(r) => r.send(Err(anyhow!("{msg}"))), + PendingReply::Unit(r) => r.send(Err(anyhow!("{msg}"))), + PendingReply::State(r) => r.send(Err(anyhow!("{msg}"))), + PendingReply::Http(r) => r.send(Err(anyhow!("{msg}"))), + PendingReply::Queue(r) => r.send(Err(anyhow!("{msg}"))), + } + } +} + +fn decode_queue_send_response(bytes: &[u8]) -> Result { + let wire: abi::QueueSendResponse = ciborium::from_reader(std::io::Cursor::new(bytes)) + .context("decode plugin queue send response")?; + let status = match wire.status.as_str() { + "completed" => QueueSendStatus::Completed, + "timedOut" => QueueSendStatus::TimedOut, + other => return Err(anyhow!("unknown queue send status `{other}`")), + }; + Ok(QueueSendResult { + status, + response: wire.response, + }) +} + +/// Token-keyed store of outstanding replies. Draining drops every `Reply`, +/// firing `Err(DroppedReply)` to unblock any awaiting caller. +pub(crate) struct ReplySlab { + next: AtomicU64, + map: Mutex>, +} + +impl ReplySlab { + pub(crate) fn new() -> Self { + Self { + next: AtomicU64::new(1), + map: Mutex::new(HashMap::new()), + } + } + + /// Store a reply, returning its non-zero token. + pub(crate) fn insert(&self, reply: PendingReply) -> u64 { + let token = self.next.fetch_add(1, Ordering::Relaxed); + self.map.lock().insert(token, reply); + token + } + + /// Fulfill a pending reply with the plugin's payload. Returns false if the + /// token is unknown, already taken, or drained. + pub(crate) fn fulfill_ok(&self, token: u64, payload: Vec) -> bool { + if let Some(reply) = self.map.lock().remove(&token) { + reply.fulfill_ok(payload); + true + } else { + false + } + } + + pub(crate) fn fulfill_err(&self, token: u64, msg: String) -> bool { + if let Some(reply) = self.map.lock().remove(&token) { + reply.fulfill_err(msg); + true + } else { + false + } + } + + /// Drop every outstanding reply (on actor exit/cancel). Each `Reply::drop` + /// sends `Err(DroppedReply)`. + pub(crate) fn drain(&self) { + self.map.lock().clear(); + } +} + +pub(crate) struct KeepAwakeStore { + next: AtomicU64, + regions: Mutex>, +} + +impl KeepAwakeStore { + fn new() -> Self { + Self { + next: AtomicU64::new(1), + regions: Mutex::new(HashMap::new()), + } + } + + fn insert(&self, region: KeepAwakeRegion) -> abi::KeepAwakeToken { + let token = self.next.fetch_add(1, Ordering::Relaxed); + self.regions.lock().insert(token, region); + abi::KeepAwakeToken { token } + } + + fn remove(&self, token: abi::KeepAwakeToken) -> Result<()> { + self.regions + .lock() + .remove(&token.token) + .ok_or_else(|| anyhow!("keep-awake token {} is unknown", token.token))?; + Ok(()) + } +} + +#[cfg(test)] +mod slab_tests { + use super::*; + + #[test] + fn drain_drops_replies_with_error() { + let slab = ReplySlab::new(); + let (tx, mut rx) = tokio::sync::oneshot::channel::>(); + let token = slab.insert(PendingReply::Unit(Reply::from(tx))); + assert_eq!(token, 1); + slab.drain(); + // The dropped Reply must have sent an error (DroppedReply), not hang. + match rx.try_recv() { + Ok(result) => assert!(result.is_err(), "drained reply should be Err"), + other => panic!("expected a sent error, got {other:?}"), + } + } + + #[test] + fn fulfill_ok_delivers_payload() { + let slab = ReplySlab::new(); + let (tx, mut rx) = tokio::sync::oneshot::channel::>>(); + let token = slab.insert(PendingReply::Bytes(Reply::from(tx))); + assert!(slab.fulfill_ok(token, vec![1, 2, 3])); + let got = rx.try_recv().expect("sent").expect("ok"); + assert_eq!(got, vec![1, 2, 3]); + // Token is consumed; a second fulfill is rejected. + assert!(!slab.fulfill_ok(token, vec![9])); + } +} diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/portable_native.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/portable_native.rs new file mode 100644 index 0000000000..bca19e94a1 --- /dev/null +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/portable_native.rs @@ -0,0 +1,660 @@ +use std::collections::HashMap; +use std::future::Future; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use anyhow::{Result, anyhow}; +use parking_lot::Mutex; +use rivet_actor_plugin_abi::{ + ConnInfo, Event, KeepAwakeToken, KvEntry, KvListOpts, PortableActorBackend, PortableActorCtx, + PortableBoxFuture, ReplyToken, RequestSaveOpts as PortableRequestSaveOpts, ScheduledEvent, +}; +use tokio::sync::{Mutex as AsyncMutex, oneshot}; + +use crate::actor::connection::ConnHandle; +use crate::actor::lifecycle_hooks::{ActorEvents, ActorStart, Reply}; +use crate::actor::messages::{ActorEvent, Response, StateDelta}; +use crate::actor::messages::{QueueSendResult, QueueSendStatus}; +use crate::actor::state::RequestSaveOpts; +use crate::actor::task_types::ShutdownKind; +use crate::types::{ListOpts, format_actor_key}; +use crate::{ActorConfig, ActorContext, ActorFactory}; + +pub struct NativeBackend { + ctx: ActorContext, + events: AsyncMutex, + startup: Mutex>>>, + slab: ReplySlab, + keep_awake: KeepAwakeStore, +} + +impl NativeBackend { + pub fn new(start: ActorStart) -> Self { + Self { + ctx: start.ctx, + events: AsyncMutex::new(start.events), + startup: Mutex::new(start.startup_ready), + slab: ReplySlab::new(), + keep_awake: KeepAwakeStore::new(), + } + } +} + +impl PortableActorBackend for NativeBackend { + fn next_event(&self) -> PortableBoxFuture<'_, Result>> { + Box::pin(async move { + loop { + let event = { + let mut events = self.events.lock().await; + events.recv().await + }; + let Some(event) = event else { + self.slab.drain(); + return Ok(None); + }; + + match event { + ActorEvent::Action { + name, args, reply, .. + } => { + let token = self.slab.insert(PendingReply::Bytes(reply)); + return Ok(Some(Event::Action { + name, + args, + reply: token, + })); + } + ActorEvent::HttpRequest { request, reply } => { + let token = self.slab.insert(PendingReply::Http(reply)); + return Ok(Some(Event::Http { + request: encode_http_request(&request), + reply: token, + })); + } + ActorEvent::SubscribeRequest { + conn, + event_name, + reply, + } => { + let token = self.slab.insert(PendingReply::Unit(reply)); + return Ok(Some(Event::Subscribe { + conn: conn_info(&conn), + event_name, + reply: token, + })); + } + ActorEvent::QueueSend { + name, + body, + conn, + request, + wait, + timeout_ms, + reply, + } => { + let token = self.slab.insert(PendingReply::Queue(reply)); + return Ok(Some(Event::QueueSend { + name, + body, + conn: conn_info(&conn), + request: encode_http_request(&request), + wait, + timeout_ms, + reply: token, + })); + } + ActorEvent::WebSocketOpen { + conn, + request, + reply, + .. + } => { + let token = self.slab.insert(PendingReply::Unit(reply)); + return Ok(Some(Event::WebSocketOpen { + conn: conn_info(&conn), + request: request.as_ref().map(encode_http_request), + reply: token, + })); + } + ActorEvent::ConnectionPreflight { + conn, + params, + reply, + .. + } => { + let token = self.slab.insert(PendingReply::Unit(reply)); + return Ok(Some(Event::ConnPreflight { + conn: conn_info(&conn), + params, + reply: token, + })); + } + ActorEvent::ConnectionOpen { conn, reply, .. } => { + let token = self.slab.insert(PendingReply::Unit(reply)); + return Ok(Some(Event::ConnOpen { + conn: conn_info(&conn), + reply: token, + })); + } + ActorEvent::ConnectionClosed { conn } => { + return Ok(Some(Event::ConnClosed { + conn: conn_info(&conn), + })); + } + ActorEvent::SerializeState { reply, .. } => { + let token = self.slab.insert(PendingReply::State(reply)); + return Ok(Some(Event::SerializeState { reply: token })); + } + ActorEvent::RunGracefulCleanup { reason, reply } => { + let token = self.slab.insert(PendingReply::Unit(reply)); + return Ok(Some(match reason { + ShutdownKind::Sleep => Event::Sleep { reply: token }, + ShutdownKind::Destroy => Event::Destroy { reply: token }, + })); + } + ActorEvent::DisconnectConn { reply, .. } => reply.send(Ok(())), + ActorEvent::WorkflowHistoryRequested { reply } => reply.send(Ok(None)), + ActorEvent::WorkflowReplayRequested { reply, .. } => reply.send(Ok(None)), + + #[cfg(test)] + ActorEvent::BeginSleep => {} + #[cfg(test)] + ActorEvent::FinalizeSleep { reply } => reply.send(Ok(())), + #[cfg(test)] + ActorEvent::Destroy { reply } => reply.send(Ok(())), + } + } + }) + } + + fn reply_ok(&self, token: ReplyToken, payload: Vec) -> Result<()> { + self.slab.fulfill_ok(token, payload) + } + + fn reply_err(&self, token: ReplyToken, message: String) -> Result<()> { + self.slab.fulfill_err(token, message) + } + + fn startup_ready(&self, result: Result<()>) -> Result<()> { + let is_ok = result.is_ok(); + if let Some(tx) = self.startup.lock().take() { + let _ = tx.send(result); + } + if is_ok { + Ok(()) + } else { + Err(anyhow!("startup_ready signaled failure")) + } + } + + fn broadcast(&self, name: String, payload: Vec) -> Result<()> { + self.ctx.broadcast(&name, &payload); + Ok(()) + } + + fn actor_id(&self) -> Result { + Ok(self.ctx.actor_id().to_owned()) + } + + fn name(&self) -> Result { + Ok(self.ctx.name().to_owned()) + } + + fn key(&self) -> Result { + Ok(format_actor_key(self.ctx.key())) + } + + fn region(&self) -> Result { + Ok(self.ctx.region().to_owned()) + } + + fn input(&self) -> Result>> { + Ok(self.ctx.input()) + } + + fn has_state(&self) -> Result { + Ok(self.ctx.has_state()) + } + + fn state(&self) -> Result> { + Ok(self.ctx.state()) + } + + fn set_state(&self, state: Vec) -> Result<()> { + self.ctx.set_initial_state(state); + Ok(()) + } + + fn save_state(&self, state: Vec) -> PortableBoxFuture<'_, Result<()>> { + Box::pin(async move { + self.ctx + .save_state(vec![StateDelta::ActorState(state)]) + .await + }) + } + + fn request_save(&self, opts: PortableRequestSaveOpts) -> Result<()> { + self.ctx.request_save(RequestSaveOpts { + immediate: opts.immediate, + max_wait_ms: opts.max_wait_ms, + }); + Ok(()) + } + + fn request_save_and_wait( + &self, + opts: PortableRequestSaveOpts, + ) -> PortableBoxFuture<'_, Result<()>> { + Box::pin(async move { + self.ctx + .request_save_and_wait(RequestSaveOpts { + immediate: opts.immediate, + max_wait_ms: opts.max_wait_ms, + }) + .await + }) + } + + fn sleep(&self) -> Result<()> { + self.ctx.sleep() + } + + fn actor_aborted(&self) -> Result { + Ok(self.ctx.actor_aborted()) + } + + fn wait_for_actor_abort(&self) -> PortableBoxFuture<'_, Result<()>> { + Box::pin(async move { + self.ctx.actor_abort_signal().cancelled().await; + Ok(()) + }) + } + + fn keep_awake_enter(&self) -> Result { + Ok(self.keep_awake.insert(self.ctx.keep_awake_region())) + } + + fn keep_awake_exit(&self, token: KeepAwakeToken) -> Result<()> { + self.keep_awake.remove(token) + } + + fn keep_awake_count(&self) -> Result { + Ok(self.ctx.keep_awake_count()) + } + + fn kv_get(&self, key: Vec) -> PortableBoxFuture<'_, Result>>> { + Box::pin(async move { + let mut values = self.ctx.kv_batch_get(&[key.as_slice()]).await?; + Ok(values.pop().flatten()) + }) + } + + fn kv_put(&self, key: Vec, value: Vec) -> PortableBoxFuture<'_, Result<()>> { + Box::pin(async move { + self.ctx + .kv_batch_put(&[(key.as_slice(), value.as_slice())]) + .await + }) + } + + fn kv_delete(&self, key: Vec) -> PortableBoxFuture<'_, Result<()>> { + Box::pin(async move { self.ctx.kv_batch_delete(&[key.as_slice()]).await }) + } + + fn kv_batch_get( + &self, + keys: Vec>, + ) -> PortableBoxFuture<'_, Result>>>> { + Box::pin(async move { + let key_refs: Vec<&[u8]> = keys.iter().map(Vec::as_slice).collect(); + self.ctx.kv_batch_get(&key_refs).await + }) + } + + fn kv_batch_put(&self, entries: Vec) -> PortableBoxFuture<'_, Result<()>> { + Box::pin(async move { + let entry_refs: Vec<(&[u8], &[u8])> = entries + .iter() + .map(|entry| (entry.key.as_slice(), entry.value.as_slice())) + .collect(); + self.ctx.kv_batch_put(&entry_refs).await + }) + } + + fn kv_batch_delete(&self, keys: Vec>) -> PortableBoxFuture<'_, Result<()>> { + Box::pin(async move { + let key_refs: Vec<&[u8]> = keys.iter().map(Vec::as_slice).collect(); + self.ctx.kv_batch_delete(&key_refs).await + }) + } + + fn kv_delete_range(&self, start: Vec, end: Vec) -> PortableBoxFuture<'_, Result<()>> { + Box::pin(async move { self.ctx.kv_delete_range(&start, &end).await }) + } + + fn kv_list_prefix( + &self, + prefix: Vec, + opts: KvListOpts, + ) -> PortableBoxFuture<'_, Result>> { + Box::pin(async move { + Ok(self + .ctx + .kv_list_prefix(&prefix, list_opts(opts)) + .await? + .into_iter() + .map(|(key, value)| KvEntry { key, value }) + .collect()) + }) + } + + fn kv_list_range( + &self, + start: Vec, + end: Vec, + opts: KvListOpts, + ) -> PortableBoxFuture<'_, Result>> { + Box::pin(async move { + Ok(self + .ctx + .kv_list_range(&start, &end, list_opts(opts)) + .await? + .into_iter() + .map(|(key, value)| KvEntry { key, value }) + .collect()) + }) + } + + fn schedule_after_ms( + &self, + delay_ms: u64, + action_name: String, + args: Vec, + ) -> PortableBoxFuture<'_, Result<()>> { + Box::pin(async move { + self.ctx + .after(Duration::from_millis(delay_ms), &action_name, &args); + Ok(()) + }) + } + + fn schedule_at_ms( + &self, + timestamp_ms: i64, + action_name: String, + args: Vec, + ) -> PortableBoxFuture<'_, Result<()>> { + Box::pin(async move { + self.ctx.at(timestamp_ms, &action_name, &args); + Ok(()) + }) + } + + fn set_alarm(&self, timestamp_ms: Option) -> PortableBoxFuture<'_, Result<()>> { + Box::pin(async move { self.ctx.set_alarm(timestamp_ms) }) + } + + fn scheduled_events(&self) -> PortableBoxFuture<'_, Result>> { + Box::pin(async move { + Ok(self + .ctx + .scheduled_events() + .into_iter() + .map(|event| ScheduledEvent { + event_id: event.event_id, + timestamp_ms: event.timestamp, + action_name: event.action, + args: event.args, + }) + .collect()) + }) + } + + fn conn_list(&self) -> PortableBoxFuture<'_, Result>> { + Box::pin(async move { Ok(self.ctx.conns().map(|conn| conn_info(&conn)).collect()) }) + } + + fn disconnect_conn(&self, conn_id: String) -> PortableBoxFuture<'_, Result<()>> { + Box::pin(async move { self.ctx.disconnect_conn(conn_id).await }) + } + + fn disconnect_conns(&self, conn_ids: Vec) -> PortableBoxFuture<'_, Result<()>> { + Box::pin(async move { + self.ctx + .disconnect_conns(|conn| conn_ids.iter().any(|id| id == conn.id())) + .await + }) + } + + fn send(&self, conn_id: String, name: String, payload: Vec) -> Result<()> { + let conn = self + .ctx + .conns() + .find(|conn| conn.id() == conn_id) + .ok_or_else(|| anyhow!("connection `{conn_id}` not found"))?; + conn.try_send(&name, &payload) + } + + fn ack_hibernatable_websocket_message( + &self, + gateway_id: Vec, + request_id: Vec, + server_message_index: u16, + ) -> Result<()> { + self.ctx + .ack_hibernatable_websocket_message(&gateway_id, &request_id, server_message_index) + } + + fn sql_is_enabled(&self) -> bool { + self.ctx.sql().is_enabled() + } + + fn db_exec<'a>(&'a self, sql: &'a str) -> PortableBoxFuture<'a, Result>> { + Box::pin(async move { self.ctx.db_exec(sql).await }) + } + + fn db_query<'a>( + &'a self, + sql: &'a str, + params: Option>, + ) -> PortableBoxFuture<'a, Result>> { + Box::pin(async move { self.ctx.db_query(sql, params.as_deref()).await }) + } + + fn db_run<'a>( + &'a self, + sql: &'a str, + params: Option>, + ) -> PortableBoxFuture<'a, Result<()>> { + Box::pin(async move { self.ctx.db_run(sql, params.as_deref()).await }) + } +} + +struct KeepAwakeStore { + next: AtomicU64, + regions: Mutex>, +} + +impl KeepAwakeStore { + fn new() -> Self { + Self { + next: AtomicU64::new(1), + regions: Mutex::new(HashMap::new()), + } + } + + fn insert(&self, region: crate::actor::context::KeepAwakeRegion) -> KeepAwakeToken { + let token = self.next.fetch_add(1, Ordering::Relaxed); + self.regions.lock().insert(token, region); + KeepAwakeToken { token } + } + + fn remove(&self, token: KeepAwakeToken) -> Result<()> { + self.regions + .lock() + .remove(&token.token) + .ok_or_else(|| anyhow!("keep-awake token {} is unknown", token.token))?; + Ok(()) + } +} + +fn conn_info(conn: &ConnHandle) -> ConnInfo { + ConnInfo { + id: conn.id().to_owned(), + params: conn.params(), + state: conn.state(), + is_hibernatable: conn.is_hibernatable(), + } +} + +fn list_opts(opts: KvListOpts) -> ListOpts { + ListOpts { + reverse: opts.reverse, + limit: opts.limit, + } +} + +pub fn build_portable_native_actor_factory(config: ActorConfig, actor: F) -> ActorFactory +where + F: Fn(PortableActorCtx) -> Fut + Send + Sync + Clone + 'static, + Fut: Future> + Send + 'static, +{ + ActorFactory::new_with_manual_startup_ready(config, move |start| { + let actor = actor.clone(); + Box::pin(async move { + let ctx = PortableActorCtx::new(NativeBackend::new(start)); + actor(ctx).await + }) as crate::runtime::RuntimeBoxFuture> + }) +} + +enum PendingReply { + Bytes(Reply>), + Unit(Reply<()>), + State(Reply>), + Http(Reply), + Queue(Reply), +} + +impl PendingReply { + fn fulfill_ok(self, payload: Vec) -> Result<()> { + match self { + PendingReply::Bytes(reply) => reply.send(Ok(payload)), + PendingReply::Unit(reply) => reply.send(Ok(())), + PendingReply::State(reply) => { + let deltas = if payload.is_empty() { + Vec::new() + } else { + vec![StateDelta::ActorState(payload)] + }; + reply.send(Ok(deltas)); + } + PendingReply::Http(reply) => reply.send(decode_http_response(&payload)), + PendingReply::Queue(reply) => reply.send(decode_queue_send_response(&payload)), + } + Ok(()) + } + + fn fulfill_err(self, message: String) { + match self { + PendingReply::Bytes(reply) => reply.send(Err(anyhow!("{message}"))), + PendingReply::Unit(reply) => reply.send(Err(anyhow!("{message}"))), + PendingReply::State(reply) => reply.send(Err(anyhow!("{message}"))), + PendingReply::Http(reply) => reply.send(Err(anyhow!("{message}"))), + PendingReply::Queue(reply) => reply.send(Err(anyhow!("{message}"))), + } + } +} + +fn decode_queue_send_response(bytes: &[u8]) -> Result { + let wire: rivet_actor_plugin_abi::QueueSendResponse = + ciborium::from_reader(std::io::Cursor::new(bytes))?; + let status = match wire.status.as_str() { + "completed" => QueueSendStatus::Completed, + "timedOut" => QueueSendStatus::TimedOut, + other => return Err(anyhow!("unknown queue send status `{other}`")), + }; + Ok(QueueSendResult { + status, + response: wire.response, + }) +} + +struct ReplySlab { + next: AtomicU64, + map: Mutex>, +} + +impl ReplySlab { + fn new() -> Self { + Self { + next: AtomicU64::new(1), + map: Mutex::new(HashMap::new()), + } + } + + fn insert(&self, reply: PendingReply) -> ReplyToken { + let token = self.next.fetch_add(1, Ordering::Relaxed); + self.map.lock().insert(token, reply); + ReplyToken(token) + } + + fn fulfill_ok(&self, token: ReplyToken, payload: Vec) -> Result<()> { + let reply = self + .map + .lock() + .remove(&token.0) + .ok_or_else(|| anyhow!("reply token {} is unknown or already answered", token.0))?; + reply.fulfill_ok(payload) + } + + fn fulfill_err(&self, token: ReplyToken, message: String) -> Result<()> { + let reply = self + .map + .lock() + .remove(&token.0) + .ok_or_else(|| anyhow!("reply token {} is unknown or already answered", token.0))?; + reply.fulfill_err(message); + Ok(()) + } + + fn drain(&self) { + self.map.lock().clear(); + } +} + +fn encode_http_request(req: &crate::actor::messages::Request) -> Vec { + let (method, uri, headers, body) = req.to_parts(); + let wire = HttpReqWire { + method, + uri, + headers, + body, + }; + let mut out = Vec::new(); + let _ = ciborium::into_writer(&wire, &mut out); + out +} + +fn decode_http_response(bytes: &[u8]) -> Result { + let wire: HttpRespWire = + ciborium::from_reader(std::io::Cursor::new(bytes)).map_err(|e| anyhow!("{e}"))?; + Response::from_parts(wire.status, wire.headers, wire.body) +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct HttpReqWire { + method: String, + uri: String, + headers: HashMap, + #[serde(with = "serde_bytes")] + body: Vec, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct HttpRespWire { + status: u16, + headers: HashMap, + #[serde(with = "serde_bytes")] + body: Vec, +} diff --git a/rivetkit-rust/packages/rivetkit-core/src/lib.rs b/rivetkit-rust/packages/rivetkit-core/src/lib.rs index 63e5be3cde..f180105882 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/lib.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/lib.rs @@ -127,6 +127,10 @@ pub use actor::messages::{ ActorEvent, QueueSendResult, QueueSendStatus, Request, Response, SerializeStateReason, StateDelta, }; +#[cfg(feature = "native-runtime")] +pub use actor::native_plugin::build_native_plugin_factory; +#[cfg(feature = "native-runtime")] +pub use actor::portable_native::{NativeBackend, build_portable_native_actor_factory}; pub use actor::queue::{ CompletableQueueMessage, EnqueueAndWaitOpts, QueueMessage, QueueNextBatchOpts, QueueNextOpts, QueueTryNextBatchOpts, QueueTryNextOpts, QueueWaitOpts, diff --git a/rivetkit-rust/packages/rivetkit-core/tests/integration.rs b/rivetkit-rust/packages/rivetkit-core/tests/integration.rs index b06b5b402e..9ae6df5270 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/integration.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/integration.rs @@ -12,3 +12,7 @@ mod sqlite_corruption_fuzz; #[path = "migration/v2_2_1/mod.rs"] mod migration_v2_2_1; + +#[cfg(feature = "native-runtime")] +#[path = "native_plugin_integration.rs"] +mod native_plugin_integration; diff --git a/rivetkit-rust/packages/rivetkit-core/tests/integration/common/ctx.rs b/rivetkit-rust/packages/rivetkit-core/tests/integration/common/ctx.rs index fb90ca5fa7..281c8bfff1 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/integration/common/ctx.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/integration/common/ctx.rs @@ -28,12 +28,14 @@ pub struct IntegrationCtx { guard_port: u16, api_peer_port: u16, metrics_port: u16, + sqlite_enabled: bool, stdout_path: PathBuf, stderr_path: PathBuf, } pub struct IntegrationCtxBuilder { snapshot_dir: Option, + sqlite_enabled: bool, } pub struct RegistryTask { @@ -83,7 +85,10 @@ pub struct ApiActor { impl IntegrationCtx { pub fn builder() -> IntegrationCtxBuilder { - IntegrationCtxBuilder { snapshot_dir: None } + IntegrationCtxBuilder { + snapshot_dir: None, + sqlite_enabled: false, + } } pub fn serve_registry(&self, registry: CoreRegistry) -> RegistryTask { @@ -400,6 +405,7 @@ impl IntegrationCtx { self.guard_port, self.api_peer_port, self.metrics_port, + self.sqlite_enabled, &self.stdout_path, &self.stderr_path, ) @@ -426,6 +432,11 @@ impl IntegrationCtxBuilder { self } + pub fn enable_sqlite(mut self) -> Self { + self.sqlite_enabled = true; + self + } + pub async fn start(self) -> Result { let _ = tracing_subscriber::fmt() .with_env_filter("info") @@ -463,6 +474,7 @@ impl IntegrationCtxBuilder { guard_port, api_peer_port, metrics_port, + self.sqlite_enabled, &stdout_path, &stderr_path, ) @@ -482,6 +494,7 @@ impl IntegrationCtxBuilder { guard_port, api_peer_port, metrics_port, + sqlite_enabled: self.sqlite_enabled, stdout_path, stderr_path, }) @@ -571,6 +584,7 @@ async fn spawn_engine_child( guard_port: u16, api_peer_port: u16, metrics_port: u16, + sqlite_enabled: bool, stdout_path: &Path, stderr_path: &Path, ) -> Result { @@ -599,6 +613,10 @@ async fn spawn_engine_child( .stdout(Stdio::from(stdout)) .stderr(Stdio::from(stderr)); + if sqlite_enabled { + command.env("RIVET__SQLITE__UNSTABLE_DISABLE_COMMIT_SIZE_CAP", "false"); + } + match database { EngineDatabase::FileSystem => { command.env("RIVET__FILE_SYSTEM__PATH", db_path); diff --git a/rivetkit-rust/packages/rivetkit-core/tests/integration/counter.rs b/rivetkit-rust/packages/rivetkit-core/tests/integration/counter.rs index 6ad17be37f..bd1c4cbb20 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/integration/counter.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/integration/counter.rs @@ -1,9 +1,12 @@ -use std::io::Cursor; +use std::path::PathBuf; +use std::process::Command; +use std::time::{Duration, Instant}; use anyhow::{Context, Result}; +use rivet_actor_test_plugin::counter_actor; use rivetkit_core::{ - ActorConfig, ActorEvent, ActorFactory, CoreRegistry, RequestSaveOpts, SerializeStateReason, - StateDelta, + ActorConfig, ActorFactory, CoreRegistry, build_native_plugin_factory, + build_portable_native_actor_factory, }; use serde_json::{Value as JsonValue, json}; @@ -11,12 +14,186 @@ use crate::common::ctx::IntegrationCtx; const ACTOR_NAME: &str = "counter"; +#[derive(Clone, Copy, Debug)] +enum Backend { + Native, + Dylib, +} + +#[tokio::test(flavor = "multi_thread")] +async fn counter_actor_has_backend_parity_through_engine() -> Result<()> { + let dylib = build_fixture(); + + for backend in [Backend::Native, Backend::Dylib] { + scenario_counter(backend, dylib.clone()) + .await + .with_context(|| format!("counter parity for {backend:?}"))?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn counter_actor_persists_state_with_backend_parity() -> Result<()> { + let dylib = build_fixture(); + + for backend in [Backend::Native, Backend::Dylib] { + scenario_counter_state_persistence(backend, dylib.clone()) + .await + .with_context(|| format!("counter state parity for {backend:?}"))?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn counter_actor_has_set_state_abort_snapshot_backend_parity() -> Result<()> { + let dylib = build_fixture(); + + for backend in [Backend::Native, Backend::Dylib] { + scenario_counter_set_state_abort_snapshot(backend, dylib.clone()) + .await + .with_context(|| format!("counter set-state/abort parity for {backend:?}"))?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn counter_actor_has_identity_backend_parity() -> Result<()> { + let dylib = build_fixture(); + + for backend in [Backend::Native, Backend::Dylib] { + scenario_counter_identity(backend, dylib.clone()) + .await + .with_context(|| format!("counter identity parity for {backend:?}"))?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn counter_actor_observes_connections_with_backend_parity() -> Result<()> { + let dylib = build_fixture(); + + for backend in [Backend::Native, Backend::Dylib] { + scenario_counter_connections(backend, dylib.clone()) + .await + .with_context(|| format!("counter connection parity for {backend:?}"))?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn counter_actor_has_kv_backend_parity() -> Result<()> { + let dylib = build_fixture(); + + for backend in [Backend::Native, Backend::Dylib] { + scenario_counter_kv(backend, dylib.clone()) + .await + .with_context(|| format!("counter kv parity for {backend:?}"))?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn counter_actor_has_sqlite_backend_parity() -> Result<()> { + let dylib = build_fixture(); + + for backend in [Backend::Native, Backend::Dylib] { + scenario_counter_sqlite(backend, dylib.clone()) + .await + .with_context(|| format!("counter sqlite parity for {backend:?}"))?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn counter_actor_has_scheduling_backend_parity() -> Result<()> { + let dylib = build_fixture(); + + for backend in [Backend::Native, Backend::Dylib] { + scenario_counter_scheduling(backend, dylib.clone()) + .await + .with_context(|| format!("counter scheduling parity for {backend:?}"))?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn counter_actor_can_request_sleep_with_backend_parity() -> Result<()> { + let dylib = build_fixture(); + + for backend in [Backend::Native, Backend::Dylib] { + scenario_counter_sleep(backend, dylib.clone()) + .await + .with_context(|| format!("counter sleep parity for {backend:?}"))?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn counter_actor_has_keep_awake_backend_parity() -> Result<()> { + let dylib = build_fixture(); + + for backend in [Backend::Native, Backend::Dylib] { + scenario_counter_keep_awake(backend, dylib.clone()) + .await + .with_context(|| format!("counter keep-awake parity for {backend:?}"))?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn counter_actor_has_fanout_alarm_backend_parity() -> Result<()> { + let dylib = build_fixture(); + + for backend in [Backend::Native, Backend::Dylib] { + scenario_counter_fanout_alarm(backend, dylib.clone()) + .await + .with_context(|| format!("counter fanout/alarm parity for {backend:?}"))?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn counter_actor_has_queue_send_backend_parity() -> Result<()> { + let dylib = build_fixture(); + + for backend in [Backend::Native, Backend::Dylib] { + scenario_counter_queue_send(backend, dylib.clone()) + .await + .with_context(|| format!("counter queue send parity for {backend:?}"))?; + } + + Ok(()) +} + #[tokio::test(flavor = "multi_thread")] -async fn counter_actor_handles_actions_through_engine() -> Result<()> { +async fn counter_actor_has_hibernation_ack_backend_parity() -> Result<()> { + let dylib = build_fixture(); + + for backend in [Backend::Native, Backend::Dylib] { + scenario_counter_hibernation_ack(backend, dylib.clone()) + .await + .with_context(|| format!("counter hibernation ack parity for {backend:?}"))?; + } + + Ok(()) +} + +async fn scenario_counter(backend: Backend, dylib: PathBuf) -> Result<()> { let ctx = IntegrationCtx::builder().start().await?; ctx.create_default_namespace().await?; let mut registry = CoreRegistry::new(); - registry.register(ACTOR_NAME, counter_factory()); + registry.register(ACTOR_NAME, factory_for(backend, dylib)?); let registry_task = ctx.serve_registry(registry); ctx.wait_for_envoy_ready().await?; @@ -45,119 +222,632 @@ async fn counter_actor_handles_actions_through_engine() -> Result<()> { Ok(()) } -fn counter_factory() -> ActorFactory { - ActorFactory::new(ActorConfig::default(), |start| { - Box::pin(async move { - let ctx = start.ctx; - let mut count = read_count(&ctx.state()); - let mut events = start.events; - while let Some(event) = events.recv().await { - match event { - ActorEvent::Action { - name, - args: _, - conn: _, - reply, - } => match name.as_str() { - "increment" => { - count += 1; - ctx.request_save(RequestSaveOpts::default()); - reply.send(Ok(encode_json(&json!(count)))); - } - "get" => { - reply.send(Ok(encode_json(&json!(count)))); - } - name => { - reply.send(Err(anyhow::anyhow!("unknown action `{name}`"))); - } - }, - ActorEvent::SerializeState { reason, reply } => match reason { - SerializeStateReason::Save | SerializeStateReason::Inspector => { - reply.send(Ok(vec![StateDelta::ActorState(encode_json(&json!({ - "count": count, - })))])); - } - }, - ActorEvent::RunGracefulCleanup { reason: _, reply } => { - reply.send(Ok(())); - } - ActorEvent::HttpRequest { request: _, reply } => { - reply.send(Err(anyhow::anyhow!("http requests are not handled"))); - } - ActorEvent::QueueSend { - name: _, - body: _, - conn: _, - request: _, - wait: _, - timeout_ms: _, - reply, - } => { - reply.send(Err(anyhow::anyhow!("queue sends are not handled"))); - } - ActorEvent::WebSocketOpen { - ws: _, - conn: _, - request: _, - reply, - } => { - reply.send(Err(anyhow::anyhow!("websockets are not handled"))); - } - ActorEvent::ConnectionPreflight { - conn: _, - params: _, - request: _, - reply, - } => { - reply.send(Ok(())); - } - ActorEvent::ConnectionOpen { reply, .. } => { - reply.send(Ok(())); - } - ActorEvent::ConnectionClosed { conn: _ } => {} - ActorEvent::SubscribeRequest { - conn: _, - event_name: _, - reply, - } => { - reply.send(Err(anyhow::anyhow!("subscriptions are not handled"))); - } - ActorEvent::DisconnectConn { conn_id: _, reply } => { - reply.send(Ok(())); - } - ActorEvent::WorkflowHistoryRequested { reply } => { - reply.send(Ok(None)); - } - ActorEvent::WorkflowReplayRequested { entry_id: _, reply } => { - reply.send(Ok(None)); - } - } +async fn scenario_counter_identity(backend: Backend, dylib: PathBuf) -> Result<()> { + let ctx = IntegrationCtx::builder().start().await?; + ctx.create_default_namespace().await?; + let mut registry = CoreRegistry::new(); + registry.register(ACTOR_NAME, factory_for(backend, dylib)?); + let registry_task = ctx.serve_registry(registry); + + ctx.wait_for_envoy_ready().await?; + let actor = ctx + .create_actor_with_key(ACTOR_NAME, Some("portable-identity")) + .await?; + + let report = ctx + .wait_for_json_action(&actor.actor_id, "identity_report") + .await + .context("identity report")?; + let report = action_output(&report)?; + assert_eq!(report["actorId"], json!(actor.actor_id)); + assert_eq!(report["name"], json!(ACTOR_NAME)); + assert_eq!(report["key"], json!("portable-identity")); + assert!(report["region"].is_string()); + assert_eq!(report["input"], JsonValue::Null); + assert_eq!(report["hasState"], json!(false)); + + registry_task.shutdown().await?; + ctx.shutdown().await?; + + Ok(()) +} + +async fn scenario_counter_hibernation_ack(backend: Backend, dylib: PathBuf) -> Result<()> { + let ctx = IntegrationCtx::builder().start().await?; + ctx.create_default_namespace().await?; + let mut registry = CoreRegistry::new(); + registry.register(ACTOR_NAME, factory_for(backend, dylib)?); + let registry_task = ctx.serve_registry(registry); + + ctx.wait_for_envoy_ready().await?; + let actor = ctx.create_actor(ACTOR_NAME).await?; + + let report = ctx + .wait_for_json_action(&actor.actor_id, "ack_invalid") + .await + .context("invalid hibernation ack")?; + let report = action_output(&report)?; + assert_eq!(report["ok"], json!(false)); + assert!( + report["error"] + .as_str() + .unwrap_or_default() + .contains("gateway_id must be exactly 4 bytes"), + "unexpected ack error for {backend:?}: {report}" + ); + + registry_task.shutdown().await?; + ctx.shutdown().await?; + + Ok(()) +} + +async fn scenario_counter_set_state_abort_snapshot(backend: Backend, dylib: PathBuf) -> Result<()> { + let ctx = IntegrationCtx::builder().start().await?; + ctx.create_default_namespace().await?; + let mut registry = CoreRegistry::new(); + registry.register(ACTOR_NAME, factory_for(backend, dylib)?); + let registry_task = ctx.serve_registry(registry); + + ctx.wait_for_envoy_ready().await?; + let actor = ctx.create_actor(ACTOR_NAME).await?; + + let state_report = ctx + .wait_for_json_action(&actor.actor_id, "set_state_report") + .await + .context("set-state report")?; + let state_report = action_output(&state_report)?; + assert_eq!(state_report["count"], json!(41)); + + let abort_report = ctx + .wait_for_json_action(&actor.actor_id, "abort_snapshot") + .await + .context("abort snapshot")?; + let abort_report = action_output(&abort_report)?; + assert_eq!(abort_report["aborted"], json!(false)); + + registry_task.shutdown().await?; + ctx.shutdown().await?; + + Ok(()) +} + +async fn scenario_counter_queue_send(backend: Backend, dylib: PathBuf) -> Result<()> { + let ctx = IntegrationCtx::builder().start().await?; + ctx.create_default_namespace().await?; + let mut registry = CoreRegistry::new(); + registry.register(ACTOR_NAME, factory_for(backend, dylib)?); + let registry_task = ctx.serve_registry(registry); + + ctx.wait_for_envoy_ready().await?; + let actor = ctx.create_actor(ACTOR_NAME).await?; + + let body = send_json_queue( + &ctx, + &actor.actor_id, + "portable-queue", + json!({ "kind": "portable", "backend": format!("{backend:?}") }), + ) + .await + .context("queue send")?; + let body: JsonValue = serde_json::from_str(&body).context("decode queue response")?; + + assert_eq!(body["status"], json!("completed")); + assert_eq!(body["response"]["name"], json!("portable-queue")); + assert_eq!(body["response"]["body"]["kind"], json!("portable")); + assert_eq!(body["response"]["wait"], json!(true)); + assert_eq!(body["response"]["timeoutMs"], json!(2_000)); + assert_eq!(body["response"]["conn"]["isHibernatable"], json!(false)); + assert!(body["response"]["conn"]["id"].is_string()); + + registry_task.shutdown().await?; + ctx.shutdown().await?; + + Ok(()) +} + +async fn scenario_counter_scheduling(backend: Backend, dylib: PathBuf) -> Result<()> { + let ctx = IntegrationCtx::builder().start().await?; + ctx.create_default_namespace().await?; + let mut registry = CoreRegistry::new(); + registry.register(ACTOR_NAME, factory_for(backend, dylib)?); + let registry_task = ctx.serve_registry(registry); + + ctx.wait_for_envoy_ready().await?; + let actor = ctx.create_actor(ACTOR_NAME).await?; + + let scheduled = ctx + .wait_for_json_action(&actor.actor_id, "schedule_once") + .await + .context("schedule once")?; + let scheduled = action_output(&scheduled)?; + assert_eq!(scheduled["pendingCount"], json!(1)); + assert_eq!(scheduled["firstAction"], json!("scheduled_increment")); + wait_for_scheduled_count(&ctx, &actor.actor_id, 1) + .await + .context("scheduled after action")?; + + let scheduled_at = ctx + .wait_for_json_action(&actor.actor_id, "schedule_at_once") + .await + .context("schedule at once")?; + let scheduled_at = action_output(&scheduled_at)?; + assert_eq!(scheduled_at["pendingCount"], json!(1)); + assert_eq!(scheduled_at["firstAction"], json!("scheduled_increment")); + assert_eq!(scheduled_at["firstTimestampAtOrAfter"], json!(true)); + wait_for_scheduled_count(&ctx, &actor.actor_id, 2) + .await + .context("scheduled at action")?; + + registry_task.shutdown().await?; + ctx.shutdown().await?; + + Ok(()) +} + +async fn scenario_counter_sleep(backend: Backend, dylib: PathBuf) -> Result<()> { + let ctx = IntegrationCtx::builder().start().await?; + ctx.create_default_namespace().await?; + let mut registry = CoreRegistry::new(); + registry.register(ACTOR_NAME, factory_for(backend, dylib)?); + let registry_task = ctx.serve_registry(registry); + + ctx.wait_for_envoy_ready().await?; + let actor = ctx.create_actor(ACTOR_NAME).await?; + + let before = ctx + .wait_for_json_action(&actor.actor_id, "sleep_marker") + .await + .context("sleep marker before request")?; + let before = action_output(&before)?; + assert_eq!(before["sleepCleanupObserved"], json!(false)); + + let requested = ctx + .wait_for_json_action(&actor.actor_id, "sleep_now") + .await + .context("request sleep")?; + let requested = action_output(&requested)?; + assert_eq!(requested["requested"], json!(true)); + + wait_for_sleep_marker(&ctx, &actor.actor_id).await?; + + registry_task.shutdown().await?; + ctx.shutdown().await?; + + Ok(()) +} + +async fn scenario_counter_keep_awake(backend: Backend, dylib: PathBuf) -> Result<()> { + let ctx = IntegrationCtx::builder().start().await?; + ctx.create_default_namespace().await?; + let mut registry = CoreRegistry::new(); + registry.register(ACTOR_NAME, factory_for(backend, dylib)?); + let registry_task = ctx.serve_registry(registry); + + ctx.wait_for_envoy_ready().await?; + let actor = ctx.create_actor(ACTOR_NAME).await?; + + let report = ctx + .wait_for_json_action(&actor.actor_id, "keep_awake_report") + .await + .context("keep-awake report")?; + let report = action_output(&report)?; + + assert_eq!(report["before"], json!(0)); + assert_eq!(report["during"], json!(1)); + assert_eq!(report["after"], json!(0)); + + registry_task.shutdown().await?; + ctx.shutdown().await?; + + Ok(()) +} + +async fn scenario_counter_fanout_alarm(backend: Backend, dylib: PathBuf) -> Result<()> { + let ctx = IntegrationCtx::builder().start().await?; + ctx.create_default_namespace().await?; + let mut registry = CoreRegistry::new(); + registry.register(ACTOR_NAME, factory_for(backend, dylib)?); + let registry_task = ctx.serve_registry(registry); + + ctx.wait_for_envoy_ready().await?; + let actor = ctx.create_actor(ACTOR_NAME).await?; + + let report = ctx + .wait_for_json_action(&actor.actor_id, "fanout_alarm_report") + .await + .context("fanout/alarm report")?; + let report = action_output(&report)?; + + assert_eq!(report["broadcasted"], json!(true)); + assert_eq!(report["alarmTimestampFuture"], json!(true)); + assert_eq!(report["alarmCleared"], json!(true)); + + registry_task.shutdown().await?; + ctx.shutdown().await?; + + Ok(()) +} + +async fn scenario_counter_kv(backend: Backend, dylib: PathBuf) -> Result<()> { + let ctx = IntegrationCtx::builder().start().await?; + ctx.create_default_namespace().await?; + let mut registry = CoreRegistry::new(); + registry.register(ACTOR_NAME, factory_for(backend, dylib)?); + let registry_task = ctx.serve_registry(registry); + + ctx.wait_for_envoy_ready().await?; + let actor = ctx.create_actor(ACTOR_NAME).await?; + + let report = ctx + .wait_for_json_action(&actor.actor_id, "kv_roundtrip") + .await + .context("kv roundtrip")?; + let report = action_output(&report)?; + + assert_eq!(report["got"], json!("one")); + assert_eq!(report["batch"], json!(["one", "two", null])); + assert_eq!( + report["prefix"], + json!([ + { "key": "portable-kv/a", "value": "one" }, + { "key": "portable-kv/b", "value": "two" } + ]) + ); + assert_eq!( + report["range"], + json!([{ "key": "portable-kv/b", "value": "two" }]) + ); + assert_eq!(report["afterDelete"], json!([])); + + registry_task.shutdown().await?; + ctx.shutdown().await?; + + Ok(()) +} + +async fn scenario_counter_sqlite(backend: Backend, dylib: PathBuf) -> Result<()> { + let ctx = IntegrationCtx::builder().enable_sqlite().start().await?; + ctx.create_default_namespace().await?; + let mut registry = CoreRegistry::new(); + registry.register(ACTOR_NAME, factory_for_with_database(backend, dylib, true)?); + let registry_task = ctx.serve_registry(registry); + + ctx.wait_for_envoy_ready().await?; + let actor = ctx.create_actor(ACTOR_NAME).await?; + + let report = ctx + .wait_for_json_action(&actor.actor_id, "sqlite_roundtrip") + .await + .context("sqlite roundtrip")?; + let report = action_output(&report)?; + + assert_eq!(report["enabled"], json!(true)); + assert_eq!( + report["rows"], + json!([{ "id": 1, "value": "native-dylib-parity" }]) + ); + + registry_task.shutdown().await?; + ctx.shutdown().await?; + + Ok(()) +} + +async fn scenario_counter_connections(backend: Backend, dylib: PathBuf) -> Result<()> { + let ctx = IntegrationCtx::builder().start().await?; + ctx.create_default_namespace().await?; + let mut registry = CoreRegistry::new(); + registry.register(ACTOR_NAME, factory_for(backend, dylib)?); + let registry_task = ctx.serve_registry(registry); + + ctx.wait_for_envoy_ready().await?; + let actor = ctx.create_actor(ACTOR_NAME).await?; + + let first = send_json_action_with_conn_params( + &ctx, + &actor.actor_id, + "conn_report", + json!({ "source": "parity", "backend": format!("{backend:?}") }), + ) + .await + .context("first connection report")?; + let first = action_output(&first)?; + assert_eq!(first["preflightCount"], json!(1)); + assert_eq!(first["openCount"], json!(1)); + assert_eq!(first["lastPreflightParams"]["source"], json!("parity")); + assert_eq!(first["lastOpen"]["params"]["source"], json!("parity")); + assert_eq!( + first["lastPreflight"]["id"], first["lastOpen"]["id"], + "preflight/open should describe the same connection" + ); + assert_eq!(first["lastOpen"]["isHibernatable"], json!(false)); + assert_eq!(first["disconnectMissingOk"], json!(true)); + assert_eq!(first["sendOk"], json!(false)); + assert!( + first["sendError"] + .as_str() + .unwrap_or_default() + .contains("Connection event sender is not configured"), + "unexpected send error: {first}" + ); + assert!( + conn_list_contains(&first, first["lastOpen"]["id"].as_str().unwrap_or_default()), + "conn_list should include the current action connection" + ); + + let second = send_json_action_with_conn_params( + &ctx, + &actor.actor_id, + "conn_report", + json!({ "source": "parity-2" }), + ) + .await + .context("second connection report")?; + let second = action_output(&second)?; + assert_eq!(second["preflightCount"], json!(2)); + assert_eq!(second["openCount"], json!(2)); + assert_eq!(second["lastPreflightParams"]["source"], json!("parity-2")); + assert!( + second["closedCount"].as_u64().unwrap_or_default() >= 1, + "second report should observe the first HTTP action connection closing" + ); + assert!(second["lastClosed"]["id"].is_string()); + assert_eq!(second["disconnectMissingOk"], json!(true)); + assert_eq!(second["sendOk"], json!(false)); + assert!( + second["sendError"] + .as_str() + .unwrap_or_default() + .contains("Connection event sender is not configured"), + "unexpected send error: {second}" + ); + assert!( + conn_list_contains( + &second, + second["lastOpen"]["id"].as_str().unwrap_or_default() + ), + "conn_list should include the current action connection" + ); + + registry_task.shutdown().await?; + ctx.shutdown().await?; + + Ok(()) +} + +async fn scenario_counter_state_persistence(backend: Backend, dylib: PathBuf) -> Result<()> { + let ctx = IntegrationCtx::builder().start().await?; + ctx.create_default_namespace().await?; + + let mut registry = CoreRegistry::new(); + registry.register(ACTOR_NAME, factory_for(backend, dylib)?); + let registry_task = ctx.serve_registry(registry); + + ctx.wait_for_envoy_ready().await?; + let actor = ctx + .create_or_get_actor_with_key(ACTOR_NAME, "portable-state") + .await?; + + let saved = ctx + .wait_for_json_action(&actor.actor_id, "save_wait") + .await + .context("request save and wait")?; + assert_eq!(action_output(&saved)?, json!(1)); + wait_for_save_wait_status(&ctx, &actor.actor_id) + .await + .context("save wait completion")?; + + let sleep = ctx + .wait_for_json_action(&actor.actor_id, "sleep_now") + .await + .context("request sleep before state restore")?; + assert_eq!(action_output(&sleep)?["requested"], json!(true)); + wait_for_sleep_marker(&ctx, &actor.actor_id) + .await + .context("sleep/wake before state restore")?; + + let restored = ctx + .wait_for_json_action(&actor.actor_id, "get") + .await + .context("restored count")?; + assert_eq!(action_output(&restored)?, json!(1)); + + registry_task.shutdown().await?; + ctx.shutdown().await?; + + Ok(()) +} + +async fn wait_for_save_wait_status(ctx: &IntegrationCtx, actor_id: &str) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(10); + let mut last_status = JsonValue::Null; + while Instant::now() < deadline { + let status = ctx + .wait_for_json_action(actor_id, "save_wait_status") + .await + .context("save wait status")?; + last_status = action_output(&status)?; + if last_status["done"] == json!(true) { + if last_status["ok"] == json!(true) { + return Ok(()); } + anyhow::bail!("request_save_and_wait failed: {last_status}"); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } - Ok(()) - }) + anyhow::bail!("request_save_and_wait did not complete; last status: {last_status}"); +} + +async fn wait_for_sleep_marker(ctx: &IntegrationCtx, actor_id: &str) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(10); + let mut last_report = JsonValue::Null; + while Instant::now() < deadline { + let report = ctx + .wait_for_json_action(actor_id, "sleep_marker") + .await + .context("sleep marker")?; + last_report = action_output(&report)?; + if last_report["sleepCleanupObserved"] == json!(true) { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + anyhow::bail!("sleep cleanup marker was not observed; last report: {last_report}"); +} + +async fn wait_for_scheduled_count( + ctx: &IntegrationCtx, + actor_id: &str, + expected: i64, +) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(10); + let mut last_report = JsonValue::Null; + while Instant::now() < deadline { + let report = ctx + .wait_for_json_action(actor_id, "schedule_report") + .await + .context("schedule report")?; + last_report = action_output(&report)?; + if last_report["scheduledCount"] == json!(expected) { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + anyhow::bail!("scheduled count did not reach {expected}; last report: {last_report}"); +} + +fn factory_for(backend: Backend, dylib: PathBuf) -> Result { + factory_for_with_database(backend, dylib, false) +} + +fn factory_for_with_database( + backend: Backend, + dylib: PathBuf, + has_database: bool, +) -> Result { + let mut config = ActorConfig::default(); + config.has_database = has_database; + config.remote_sqlite = has_database; + Ok(match backend { + Backend::Native => build_portable_native_actor_factory(config, counter_actor), + Backend::Dylib => build_native_plugin_factory(&dylib, "{}", "", config) + .context("load counter dylib fixture")?, }) } +fn build_fixture() -> PathBuf { + let status = Command::new(env!("CARGO")) + .args(["build", "-p", "rivet-actor-test-plugin"]) + .status() + .expect("spawn cargo build for fixture plugin"); + assert!(status.success(), "fixture plugin build failed"); + + let target = std::env::var("CARGO_TARGET_DIR") + .unwrap_or_else(|_| format!("{}/../../../target", env!("CARGO_MANIFEST_DIR"))); + let lib = if cfg!(target_os = "macos") { + "librivet_actor_test_plugin.dylib" + } else if cfg!(target_os = "windows") { + "rivet_actor_test_plugin.dll" + } else { + "librivet_actor_test_plugin.so" + }; + let path = PathBuf::from(format!("{target}/debug/{lib}")); + assert!( + path.exists(), + "built fixture not found at {}", + path.display() + ); + path +} + fn action_output(body: &str) -> Result { let value: JsonValue = serde_json::from_str(body).context("decode action response")?; Ok(value.get("output").cloned().unwrap_or(JsonValue::Null)) } -fn read_count(state: &[u8]) -> i64 { - if state.is_empty() { - return 0; - } +fn conn_list_contains(report: &JsonValue, conn_id: &str) -> bool { + report["connList"] + .as_array() + .map(|conns| { + conns + .iter() + .any(|conn| conn.get("id").and_then(JsonValue::as_str) == Some(conn_id)) + }) + .unwrap_or(false) +} - let value: JsonValue = ciborium::from_reader(Cursor::new(state)).unwrap_or(JsonValue::Null); - value - .get("count") - .and_then(JsonValue::as_i64) - .unwrap_or_default() +async fn send_json_action_with_conn_params( + ctx: &IntegrationCtx, + actor_id: &str, + action: &str, + conn_params: JsonValue, +) -> Result { + let response = ctx + .client() + .post(format!( + "{}/gateway/{}/action/{}", + ctx.endpoint(), + actor_id, + action + )) + .header("x-rivet-encoding", "json") + .header("content-type", "application/json") + .header("x-rivet-conn-params", serde_json::to_string(&conn_params)?) + .body(r#"{"args":[]}"#) + .send() + .await + .context("send actor action with connection params")?; + let status = response.status(); + let body = response + .text() + .await + .context("read actor action response")?; + if !status.is_success() { + anyhow::bail!( + "actor action failed with {status}: {body}\n\nengine stdout:\n{}\n\nengine stderr:\n{}", + ctx.engine_stdout_tail(), + ctx.engine_stderr_tail() + ); + } + Ok(body) } -fn encode_json(value: &JsonValue) -> Vec { - let mut out = Vec::new(); - ciborium::into_writer(value, &mut out).expect("encode cbor json"); - out +async fn send_json_queue( + ctx: &IntegrationCtx, + actor_id: &str, + queue: &str, + body: JsonValue, +) -> Result { + let response = ctx + .client() + .post(format!( + "{}/gateway/{}/queue/{}", + ctx.endpoint(), + actor_id, + queue + )) + .header("x-rivet-encoding", "json") + .header("content-type", "application/json") + .body(serde_json::to_string(&json!({ + "body": body, + "wait": true, + "timeout": 2_000, + }))?) + .send() + .await + .context("send actor queue request")?; + let status = response.status(); + let body = response.text().await.context("read actor queue response")?; + if !status.is_success() { + anyhow::bail!( + "actor queue failed with {status}: {body}\n\nengine stdout:\n{}\n\nengine stderr:\n{}", + ctx.engine_stdout_tail(), + ctx.engine_stderr_tail() + ); + } + Ok(body) } diff --git a/rivetkit-rust/packages/rivetkit-core/tests/native_plugin_integration.rs b/rivetkit-rust/packages/rivetkit-core/tests/native_plugin_integration.rs new file mode 100644 index 0000000000..16acae2131 --- /dev/null +++ b/rivetkit-rust/packages/rivetkit-core/tests/native_plugin_integration.rs @@ -0,0 +1,1016 @@ +//! End-to-end integration test for the native-actor-plugin host loader. +//! +//! Builds the `rivet-actor-test-plugin` cdylib FIXTURE, loads it through the +//! real `build_native_plugin_factory` (dlopen + ABI magic/version check + +//! symbol cache), drives a full actor lifecycle (startup-ready signal → Action +//! dispatch → reply slab → reply), and asserts the portable counter actor +//! replies correctly. Exercises the generic ABI + host adapter path with no +//! product-specific plugin package and no sidecar. +#![cfg(feature = "native-runtime")] + +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Command; +use std::time::Duration; + +use anyhow::{Context, Result}; +use rivet_actor_test_plugin::counter_actor; +use rivetkit_core::{ + ActorConfig, ActorContext, ActorEvent, ActorFactory, ActorStart, ConnHandle, CoreRegistry, + QueueSendStatus, Reply, Request, SerializeStateReason, ShutdownKind, StateDelta, WebSocket, + build_native_plugin_factory, build_portable_native_actor_factory, +}; +use serde_json::{Value as JsonValue, json}; +use tokio::sync::{mpsc, oneshot}; + +use crate::common::ctx::IntegrationCtx; + +/// Build the fixture cdylib and return the path to the built `.so`. +fn build_fixture() -> PathBuf { + let status = Command::new(env!("CARGO")) + .args(["build", "-p", "rivet-actor-test-plugin"]) + .status() + .expect("spawn cargo build for fixture plugin"); + assert!(status.success(), "fixture plugin build failed"); + + let target = std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| { + // /../../../target (manifest = .../packages/rivetkit-core) + format!("{}/../../../target", env!("CARGO_MANIFEST_DIR")) + }); + let lib = if cfg!(target_os = "macos") { + "librivet_actor_test_plugin.dylib" + } else if cfg!(target_os = "windows") { + "rivet_actor_test_plugin.dll" + } else { + "librivet_actor_test_plugin.so" + }; + let path = PathBuf::from(format!("{target}/debug/{lib}")); + assert!( + path.exists(), + "built fixture not found at {}", + path.display() + ); + path +} + +#[tokio::test] +async fn native_plugin_counter_round_trips_action_through_host_loader() { + let so = build_fixture(); + + let mut config = ActorConfig::default(); + config.has_database = false; + let factory = build_native_plugin_factory(&so, "{}", "", config) + .expect("load + construct native plugin factory"); + + // Queue one Action; its reply channel receives the portable counter output. + let args = Vec::new(); + let (reply_tx, reply_rx) = oneshot::channel(); + let (event_tx, event_rx) = mpsc::unbounded_channel::(); + event_tx + .send(ActorEvent::Action { + name: "get".to_owned(), + args: args.clone(), + conn: None, + reply: Reply::from(reply_tx), + }) + .expect("queue action"); + + let (startup_tx, startup_rx) = oneshot::channel(); + let start = ActorStart { + ctx: ActorContext::new("native-plugin-e2e", "test", Vec::new(), "local"), + is_new: true, + input: None, + snapshot: None, + hibernated: Vec::new(), + events: event_rx.into(), + startup_ready: Some(startup_tx), + }; + + let join = tokio::spawn(async move { factory.start(start).await }); + + // The fixture must signal startup (manual startup-ready) before the host + // resolves this — proves the startup handshake works. + tokio::time::timeout(Duration::from_secs(10), startup_rx) + .await + .expect("startup signal within 10s") + .expect("startup channel open") + .expect("startup ok"); + + // The fixture returns the counter value through the reply slab. + let reply = tokio::time::timeout(Duration::from_secs(10), reply_rx) + .await + .expect("reply within 10s") + .expect("reply channel open") + .expect("action reply ok"); + assert_eq!(decode_cbor_json(&reply), json!(0)); + + // Closing the event stream ends the actor; the run future must join cleanly. + drop(event_tx); + tokio::time::timeout(Duration::from_secs(10), join) + .await + .expect("actor task joins within 10s") + .expect("actor task not panicked") + .expect("actor run ok"); +} + +#[tokio::test] +async fn native_plugin_websocket_open_round_trips_through_host_loader() { + let so = build_fixture(); + + let mut config = ActorConfig::default(); + config.has_database = false; + let factory = build_native_plugin_factory(&so, "{}", "", config) + .expect("load + construct native plugin factory"); + + let (reply_tx, reply_rx) = oneshot::channel(); + let (event_tx, event_rx) = mpsc::unbounded_channel::(); + event_tx + .send(ActorEvent::WebSocketOpen { + conn: ConnHandle::new("ws-conn", Vec::new(), Vec::new(), false), + ws: WebSocket::new(), + request: None, + reply: Reply::from(reply_tx), + }) + .expect("queue websocket open"); + + let (startup_tx, startup_rx) = oneshot::channel(); + let start = ActorStart { + ctx: ActorContext::new("native-plugin-ws-e2e", "test", Vec::new(), "local"), + is_new: true, + input: None, + snapshot: None, + hibernated: Vec::new(), + events: event_rx.into(), + startup_ready: Some(startup_tx), + }; + + let join = tokio::spawn(async move { factory.start(start).await }); + + tokio::time::timeout(Duration::from_secs(10), startup_rx) + .await + .expect("startup signal within 10s") + .expect("startup channel open") + .expect("startup ok"); + + tokio::time::timeout(Duration::from_secs(10), reply_rx) + .await + .expect("reply within 10s") + .expect("reply channel open") + .expect("websocket open reply ok"); + + drop(event_tx); + tokio::time::timeout(Duration::from_secs(10), join) + .await + .expect("actor task joins within 10s") + .expect("actor task not panicked") + .expect("actor run ok"); +} + +#[tokio::test] +async fn native_plugin_forwards_opaque_factory_config() { + let so = build_fixture(); + + let mut config = ActorConfig::default(); + config.has_database = false; + let config_json = r#"{"package":"native-plugin-test","nested":{"ok":true}}"#; + let sidecar_path = "/tmp/native-plugin-sidecar-for-forwarding-test"; + let factory = build_native_plugin_factory(&so, config_json, sidecar_path, config) + .expect("load + construct native plugin factory"); + + let (reply_tx, reply_rx) = oneshot::channel(); + let (event_tx, event_rx) = mpsc::unbounded_channel::(); + event_tx + .send(ActorEvent::Action { + name: "factory_config_report".to_owned(), + args: Vec::new(), + conn: None, + reply: Reply::from(reply_tx), + }) + .expect("queue action"); + + let (startup_tx, startup_rx) = oneshot::channel(); + let start = ActorStart { + ctx: ActorContext::new("native-plugin-config-e2e", "test", Vec::new(), "local"), + is_new: true, + input: None, + snapshot: None, + hibernated: Vec::new(), + events: event_rx.into(), + startup_ready: Some(startup_tx), + }; + + let join = tokio::spawn(async move { factory.start(start).await }); + + tokio::time::timeout(Duration::from_secs(10), startup_rx) + .await + .expect("startup signal within 10s") + .expect("startup channel open") + .expect("startup ok"); + + let reply = tokio::time::timeout(Duration::from_secs(10), reply_rx) + .await + .expect("reply within 10s") + .expect("reply channel open") + .expect("action reply ok"); + assert_eq!( + decode_cbor_json(&reply), + json!({ + "configJson": config_json, + "sidecarPath": sidecar_path, + }) + ); + + drop(event_tx); + tokio::time::timeout(Duration::from_secs(10), join) + .await + .expect("actor task joins within 10s") + .expect("actor task not panicked") + .expect("actor run ok"); +} + +#[derive(Clone, Copy, Debug)] +enum PortableBackend { + Native, + Dylib, +} + +#[tokio::test] +async fn portable_actor_waits_for_abort_on_both_backends() -> Result<()> { + let so = build_fixture(); + + for backend in [PortableBackend::Native, PortableBackend::Dylib] { + let factory = portable_factory(backend, &so)?; + assert_abort_wait(factory, backend).await?; + } + + Ok(()) +} + +#[tokio::test] +async fn portable_actor_forwards_http_and_subscribe_on_both_backends() -> Result<()> { + let so = build_fixture(); + + for backend in [PortableBackend::Native, PortableBackend::Dylib] { + let factory = portable_factory(backend, &so)?; + assert_http_and_subscribe(factory, backend).await?; + } + + Ok(()) +} + +#[tokio::test] +async fn portable_actor_forwards_serialize_state_and_destroy_on_both_backends() -> Result<()> { + let so = build_fixture(); + + for backend in [PortableBackend::Native, PortableBackend::Dylib] { + let factory = portable_factory(backend, &so)?; + assert_serialize_state_and_destroy(factory, backend).await?; + } + + Ok(()) +} + +#[tokio::test] +async fn portable_actor_forwards_conn_queue_ws_on_both_backends() -> Result<()> { + let so = build_fixture(); + + for backend in [PortableBackend::Native, PortableBackend::Dylib] { + let factory = portable_factory(backend, &so)?; + assert_conn_queue_ws(factory, backend).await?; + } + + Ok(()) +} + +#[tokio::test] +async fn portable_actor_rejects_double_reply_on_both_backends() -> Result<()> { + let so = build_fixture(); + + for backend in [PortableBackend::Native, PortableBackend::Dylib] { + let factory = portable_factory(backend, &so)?; + assert_double_reply_rejected(factory, backend).await?; + } + + Ok(()) +} + +#[tokio::test] +async fn portable_actor_drops_unanswered_replies_on_both_backends() -> Result<()> { + let so = build_fixture(); + + for backend in [PortableBackend::Native, PortableBackend::Dylib] { + let factory = portable_factory(backend, &so)?; + assert_unanswered_reply_dropped(factory, backend).await?; + } + + Ok(()) +} + +#[tokio::test] +async fn portable_actor_propagates_reply_err_on_both_backends() -> Result<()> { + let so = build_fixture(); + + for backend in [PortableBackend::Native, PortableBackend::Dylib] { + let factory = portable_factory(backend, &so)?; + assert_reply_err_propagated(factory, backend).await?; + } + + Ok(()) +} + +fn portable_factory(backend: PortableBackend, so: &PathBuf) -> Result { + let mut config = ActorConfig::default(); + config.has_database = false; + Ok(match backend { + PortableBackend::Native => build_portable_native_actor_factory(config, counter_actor), + PortableBackend::Dylib => build_native_plugin_factory(so, "{}", "", config) + .context("load counter dylib fixture")?, + }) +} + +async fn assert_abort_wait(factory: ActorFactory, backend: PortableBackend) -> Result<()> { + let (reply_tx, reply_rx) = oneshot::channel(); + let (event_tx, event_rx) = mpsc::unbounded_channel::(); + event_tx + .send(ActorEvent::Action { + name: "wait_abort".to_owned(), + args: Vec::new(), + conn: None, + reply: Reply::from(reply_tx), + }) + .expect("queue wait_abort action"); + + let (startup_tx, startup_rx) = oneshot::channel(); + let actor_ctx = ActorContext::new("portable-abort-e2e", "test", Vec::new(), "local"); + let start = ActorStart { + ctx: actor_ctx.clone(), + is_new: true, + input: None, + snapshot: None, + hibernated: Vec::new(), + events: event_rx.into(), + startup_ready: Some(startup_tx), + }; + + let join = tokio::spawn(async move { factory.start(start).await }); + tokio::time::timeout(Duration::from_secs(10), startup_rx) + .await + .with_context(|| format!("startup signal within 10s for {backend:?}"))? + .context("startup channel open")? + .context("startup ok")?; + + actor_ctx.cancel_actor_abort_signal(); + let reply = tokio::time::timeout(Duration::from_secs(10), reply_rx) + .await + .with_context(|| format!("abort reply within 10s for {backend:?}"))? + .context("reply channel open")? + .context("action reply ok")?; + assert_eq!(decode_cbor_json(&reply), json!({ "aborted": true })); + + drop(event_tx); + tokio::time::timeout(Duration::from_secs(10), join) + .await + .with_context(|| format!("actor task joins within 10s for {backend:?}"))? + .context("actor task not panicked")? + .context("actor run ok")?; + + Ok(()) +} + +async fn assert_double_reply_rejected( + factory: ActorFactory, + backend: PortableBackend, +) -> Result<()> { + let (probe_reply_tx, probe_reply_rx) = oneshot::channel(); + let (status_reply_tx, status_reply_rx) = oneshot::channel(); + let (event_tx, event_rx) = mpsc::unbounded_channel::(); + event_tx + .send(ActorEvent::Action { + name: "double_reply_probe".to_owned(), + args: Vec::new(), + conn: None, + reply: Reply::from(probe_reply_tx), + }) + .expect("queue double-reply probe"); + event_tx + .send(ActorEvent::Action { + name: "double_reply_status".to_owned(), + args: Vec::new(), + conn: None, + reply: Reply::from(status_reply_tx), + }) + .expect("queue double-reply status"); + + let (startup_tx, startup_rx) = oneshot::channel(); + let start = ActorStart { + ctx: ActorContext::new("portable-double-reply-e2e", "test", Vec::new(), "local"), + is_new: true, + input: None, + snapshot: None, + hibernated: Vec::new(), + events: event_rx.into(), + startup_ready: Some(startup_tx), + }; + + let join = tokio::spawn(async move { factory.start(start).await }); + tokio::time::timeout(Duration::from_secs(10), startup_rx) + .await + .with_context(|| format!("startup signal within 10s for {backend:?}"))? + .context("startup channel open")? + .context("startup ok")?; + + let first = tokio::time::timeout(Duration::from_secs(10), probe_reply_rx) + .await + .with_context(|| format!("double-reply first response within 10s for {backend:?}"))? + .context("double-reply first channel open")? + .context("double-reply first ok")?; + assert_eq!(decode_cbor_json(&first), json!({ "first": true })); + + let status = tokio::time::timeout(Duration::from_secs(10), status_reply_rx) + .await + .with_context(|| format!("double-reply status within 10s for {backend:?}"))? + .context("double-reply status channel open")? + .context("double-reply status ok")?; + let status = decode_cbor_json(&status); + assert_eq!(status["secondOk"], json!(false)); + assert!( + status["secondError"].as_str().is_some_and(|error| { + error.contains("already answered") || error.contains("reply_ok failed") + }), + "unexpected double-reply error for {backend:?}: {status}" + ); + + drop(event_tx); + tokio::time::timeout(Duration::from_secs(10), join) + .await + .with_context(|| format!("actor task joins within 10s for {backend:?}"))? + .context("actor task not panicked")? + .context("actor run ok")?; + + Ok(()) +} + +async fn assert_unanswered_reply_dropped( + factory: ActorFactory, + backend: PortableBackend, +) -> Result<()> { + let (reply_tx, reply_rx) = oneshot::channel(); + let (event_tx, event_rx) = mpsc::unbounded_channel::(); + event_tx + .send(ActorEvent::Action { + name: "drop_reply_probe".to_owned(), + args: Vec::new(), + conn: None, + reply: Reply::from(reply_tx), + }) + .expect("queue drop-reply probe"); + + let (startup_tx, startup_rx) = oneshot::channel(); + let start = ActorStart { + ctx: ActorContext::new("portable-drop-reply-e2e", "test", Vec::new(), "local"), + is_new: true, + input: None, + snapshot: None, + hibernated: Vec::new(), + events: event_rx.into(), + startup_ready: Some(startup_tx), + }; + + let join = tokio::spawn(async move { factory.start(start).await }); + tokio::time::timeout(Duration::from_secs(10), startup_rx) + .await + .with_context(|| format!("startup signal within 10s for {backend:?}"))? + .context("startup channel open")? + .context("startup ok")?; + + drop(event_tx); + + let result = tokio::time::timeout(Duration::from_secs(10), reply_rx) + .await + .with_context(|| format!("dropped reply within 10s for {backend:?}"))? + .context("drop-reply channel open")?; + let error = result.expect_err("drop-reply probe should return an error"); + let error = format!("{error:#}"); + assert!( + error.contains("dropped_reply") + || error.contains("dropped without a response") + || error.contains("DroppedReply"), + "unexpected dropped-reply error for {backend:?}: {error}" + ); + + tokio::time::timeout(Duration::from_secs(10), join) + .await + .with_context(|| format!("actor task joins within 10s for {backend:?}"))? + .context("actor task not panicked")? + .context("actor run ok")?; + + Ok(()) +} + +async fn assert_reply_err_propagated( + factory: ActorFactory, + backend: PortableBackend, +) -> Result<()> { + let (reply_tx, reply_rx) = oneshot::channel(); + let (event_tx, event_rx) = mpsc::unbounded_channel::(); + event_tx + .send(ActorEvent::Action { + name: "reply_err_probe".to_owned(), + args: Vec::new(), + conn: None, + reply: Reply::from(reply_tx), + }) + .expect("queue reply_err probe"); + + let (startup_tx, startup_rx) = oneshot::channel(); + let start = ActorStart { + ctx: ActorContext::new("portable-reply-err-e2e", "test", Vec::new(), "local"), + is_new: true, + input: None, + snapshot: None, + hibernated: Vec::new(), + events: event_rx.into(), + startup_ready: Some(startup_tx), + }; + + let join = tokio::spawn(async move { factory.start(start).await }); + tokio::time::timeout(Duration::from_secs(10), startup_rx) + .await + .with_context(|| format!("startup signal within 10s for {backend:?}"))? + .context("startup channel open")? + .context("startup ok")?; + + let result = tokio::time::timeout(Duration::from_secs(10), reply_rx) + .await + .with_context(|| format!("reply_err response within 10s for {backend:?}"))? + .context("reply_err channel open")?; + let error = result.expect_err("reply_err probe should return an error"); + let error = format!("{error:#}"); + assert!( + error.contains("portable reply error"), + "unexpected reply_err error for {backend:?}: {error}" + ); + + drop(event_tx); + tokio::time::timeout(Duration::from_secs(10), join) + .await + .with_context(|| format!("actor task joins within 10s for {backend:?}"))? + .context("actor task not panicked")? + .context("actor run ok")?; + + Ok(()) +} + +async fn assert_serialize_state_and_destroy( + factory: ActorFactory, + backend: PortableBackend, +) -> Result<()> { + let (increment_reply_tx, increment_reply_rx) = oneshot::channel(); + let (serialize_reply_tx, serialize_reply_rx) = oneshot::channel(); + let (destroy_reply_tx, destroy_reply_rx) = oneshot::channel(); + let (event_tx, event_rx) = mpsc::unbounded_channel::(); + event_tx + .send(ActorEvent::Action { + name: "increment".to_owned(), + args: Vec::new(), + conn: None, + reply: Reply::from(increment_reply_tx), + }) + .expect("queue increment action"); + event_tx + .send(ActorEvent::SerializeState { + reason: SerializeStateReason::Save, + reply: Reply::from(serialize_reply_tx), + }) + .expect("queue serialize state"); + event_tx + .send(ActorEvent::RunGracefulCleanup { + reason: ShutdownKind::Destroy, + reply: Reply::from(destroy_reply_tx), + }) + .expect("queue destroy cleanup"); + + let (startup_tx, startup_rx) = oneshot::channel(); + let start = ActorStart { + ctx: ActorContext::new( + "portable-serialize-destroy-e2e", + "test", + Vec::new(), + "local", + ), + is_new: true, + input: None, + snapshot: None, + hibernated: Vec::new(), + events: event_rx.into(), + startup_ready: Some(startup_tx), + }; + + let join = tokio::spawn(async move { factory.start(start).await }); + tokio::time::timeout(Duration::from_secs(10), startup_rx) + .await + .with_context(|| format!("startup signal within 10s for {backend:?}"))? + .context("startup channel open")? + .context("startup ok")?; + + let increment_reply = tokio::time::timeout(Duration::from_secs(10), increment_reply_rx) + .await + .with_context(|| format!("increment reply within 10s for {backend:?}"))? + .context("increment reply channel open")? + .context("increment reply ok")?; + assert_eq!(decode_cbor_json(&increment_reply), json!(1)); + + let deltas = tokio::time::timeout(Duration::from_secs(10), serialize_reply_rx) + .await + .with_context(|| format!("serialize-state reply within 10s for {backend:?}"))? + .context("serialize-state reply channel open")? + .context("serialize-state reply ok")?; + assert_eq!(deltas.len(), 1); + match &deltas[0] { + StateDelta::ActorState(bytes) => { + assert_eq!(decode_cbor_json(bytes), json!({ "count": 1 })); + } + other => anyhow::bail!("unexpected state delta for {backend:?}: {other:?}"), + } + + tokio::time::timeout(Duration::from_secs(10), destroy_reply_rx) + .await + .with_context(|| format!("destroy reply within 10s for {backend:?}"))? + .context("destroy reply channel open")? + .context("destroy reply ok")?; + + drop(event_tx); + tokio::time::timeout(Duration::from_secs(10), join) + .await + .with_context(|| format!("actor task joins within 10s for {backend:?}"))? + .context("actor task not panicked")? + .context("actor run ok")?; + + Ok(()) +} + +async fn assert_conn_queue_ws(factory: ActorFactory, backend: PortableBackend) -> Result<()> { + let conn = ConnHandle::new( + format!("rich-conn-{backend:?}"), + encode_cbor_json(&json!({ "backend": format!("{backend:?}") })), + vec![1, 2, 3], + true, + ); + let (preflight_reply_tx, preflight_reply_rx) = oneshot::channel(); + let (open_reply_tx, open_reply_rx) = oneshot::channel(); + let (queue_reply_tx, queue_reply_rx) = oneshot::channel(); + let (ws_reply_tx, ws_reply_rx) = oneshot::channel(); + let (report_reply_tx, report_reply_rx) = oneshot::channel(); + let (event_tx, event_rx) = mpsc::unbounded_channel::(); + + event_tx + .send(ActorEvent::ConnectionPreflight { + conn: conn.clone(), + params: encode_cbor_json(&json!({ + "phase": "preflight", + "backend": format!("{backend:?}"), + })), + request: Some( + Request::from_parts( + "GET", + "/portable-preflight", + HashMap::from([("x-portable-test".to_owned(), format!("{backend:?}"))]), + Vec::new(), + ) + .context("build preflight request")?, + ), + reply: Reply::from(preflight_reply_tx), + }) + .expect("queue connection preflight"); + event_tx + .send(ActorEvent::ConnectionOpen { + conn: conn.clone(), + request: Some( + Request::from_parts( + "GET", + "/portable-open", + HashMap::from([("x-portable-test".to_owned(), format!("{backend:?}"))]), + Vec::new(), + ) + .context("build connection-open request")?, + ), + reply: Reply::from(open_reply_tx), + }) + .expect("queue connection open"); + event_tx + .send(ActorEvent::QueueSend { + name: "portable-queue-direct".to_owned(), + body: encode_cbor_json(&json!({ + "kind": "direct", + "backend": format!("{backend:?}"), + })), + conn: conn.clone(), + request: Request::from_parts( + "POST", + "/portable-queue", + HashMap::from([("x-portable-test".to_owned(), format!("{backend:?}"))]), + encode_cbor_json(&json!({ "request": "queue" })), + ) + .context("build queue request")?, + wait: true, + timeout_ms: Some(3_456), + reply: Reply::from(queue_reply_tx), + }) + .expect("queue queue-send event"); + event_tx + .send(ActorEvent::WebSocketOpen { + conn: conn.clone(), + ws: WebSocket::new(), + request: Some( + Request::from_parts( + "GET", + "/portable-ws", + HashMap::from([("x-portable-test".to_owned(), format!("{backend:?}"))]), + Vec::new(), + ) + .context("build websocket request")?, + ), + reply: Reply::from(ws_reply_tx), + }) + .expect("queue websocket open"); + event_tx + .send(ActorEvent::ConnectionClosed { conn: conn.clone() }) + .expect("queue connection closed"); + event_tx + .send(ActorEvent::Action { + name: "conn_report".to_owned(), + args: Vec::new(), + conn: None, + reply: Reply::from(report_reply_tx), + }) + .expect("queue connection report"); + + let (startup_tx, startup_rx) = oneshot::channel(); + let start = ActorStart { + ctx: ActorContext::new("portable-rich-events-e2e", "test", Vec::new(), "local"), + is_new: true, + input: None, + snapshot: None, + hibernated: Vec::new(), + events: event_rx.into(), + startup_ready: Some(startup_tx), + }; + + let join = tokio::spawn(async move { factory.start(start).await }); + tokio::time::timeout(Duration::from_secs(10), startup_rx) + .await + .with_context(|| format!("startup signal within 10s for {backend:?}"))? + .context("startup channel open")? + .context("startup ok")?; + + tokio::time::timeout(Duration::from_secs(10), preflight_reply_rx) + .await + .with_context(|| format!("preflight reply within 10s for {backend:?}"))? + .context("preflight reply channel open")? + .context("preflight reply ok")?; + tokio::time::timeout(Duration::from_secs(10), open_reply_rx) + .await + .with_context(|| format!("open reply within 10s for {backend:?}"))? + .context("open reply channel open")? + .context("open reply ok")?; + + let queue = tokio::time::timeout(Duration::from_secs(10), queue_reply_rx) + .await + .with_context(|| format!("queue reply within 10s for {backend:?}"))? + .context("queue reply channel open")? + .context("queue reply ok")?; + assert_eq!(queue.status, QueueSendStatus::Completed); + let queue_response = decode_cbor_json(queue.response.as_deref().context("queue response")?); + assert_eq!(queue_response["name"], json!("portable-queue-direct")); + assert_eq!(queue_response["body"]["kind"], json!("direct")); + assert_eq!( + queue_response["body"]["backend"], + json!(format!("{backend:?}")) + ); + assert_eq!(queue_response["conn"]["id"], json!(conn.id())); + assert_eq!( + queue_response["conn"]["params"]["backend"], + json!(format!("{backend:?}")) + ); + assert_eq!(queue_response["conn"]["state"], json!([1, 2, 3])); + assert_eq!(queue_response["conn"]["isHibernatable"], json!(true)); + assert_eq!(queue_response["request"]["method"], json!("POST")); + assert_eq!(queue_response["request"]["uri"], json!("/portable-queue")); + assert_eq!( + queue_response["request"]["headers"]["x-portable-test"], + json!(format!("{backend:?}")) + ); + assert_eq!(queue_response["request"]["body"]["request"], json!("queue")); + assert_eq!(queue_response["wait"], json!(true)); + assert_eq!(queue_response["timeoutMs"], json!(3_456)); + + tokio::time::timeout(Duration::from_secs(10), ws_reply_rx) + .await + .with_context(|| format!("websocket reply within 10s for {backend:?}"))? + .context("websocket reply channel open")? + .context("websocket reply ok")?; + + let report = tokio::time::timeout(Duration::from_secs(10), report_reply_rx) + .await + .with_context(|| format!("connection report within 10s for {backend:?}"))? + .context("connection report channel open")? + .context("connection report ok")?; + let report = decode_cbor_json(&report); + assert_eq!(report["preflightCount"], json!(1)); + assert_eq!(report["openCount"], json!(1)); + assert_eq!(report["closedCount"], json!(1)); + assert_eq!(report["lastPreflight"]["id"], json!(conn.id())); + assert_eq!( + report["lastPreflight"]["params"]["backend"], + json!(format!("{backend:?}")) + ); + assert_eq!(report["lastPreflight"]["state"], json!([1, 2, 3])); + assert_eq!(report["lastPreflight"]["isHibernatable"], json!(true)); + assert_eq!(report["lastPreflightParams"]["phase"], json!("preflight")); + assert_eq!( + report["lastPreflightParams"]["backend"], + json!(format!("{backend:?}")) + ); + assert_eq!(report["lastOpen"]["id"], json!(conn.id())); + assert_eq!(report["lastClosed"]["id"], json!(conn.id())); + assert_eq!(report["wsOpenCount"], json!(1)); + assert_eq!(report["lastWsOpen"]["id"], json!(conn.id())); + assert_eq!(report["lastWsRequest"]["method"], json!("GET")); + assert_eq!(report["lastWsRequest"]["uri"], json!("/portable-ws")); + assert_eq!( + report["lastWsRequest"]["headers"]["x-portable-test"], + json!(format!("{backend:?}")) + ); + + drop(event_tx); + tokio::time::timeout(Duration::from_secs(10), join) + .await + .with_context(|| format!("actor task joins within 10s for {backend:?}"))? + .context("actor task not panicked")? + .context("actor run ok")?; + + Ok(()) +} + +async fn assert_http_and_subscribe(factory: ActorFactory, backend: PortableBackend) -> Result<()> { + let subscribe_conn = ConnHandle::new( + format!("subscribe-conn-{backend:?}"), + encode_cbor_json(&json!({ "backend": format!("{backend:?}") })), + vec![4, 5, 6], + false, + ); + let (http_reply_tx, http_reply_rx) = oneshot::channel(); + let (subscribe_reply_tx, subscribe_reply_rx) = oneshot::channel(); + let (report_reply_tx, report_reply_rx) = oneshot::channel(); + let (event_tx, event_rx) = mpsc::unbounded_channel::(); + event_tx + .send(ActorEvent::HttpRequest { + request: Request::from_parts( + "POST", + "/portable-http", + HashMap::from([("x-portable-test".to_owned(), format!("{backend:?}"))]), + encode_cbor_json(&json!({ "backend": format!("{backend:?}") })), + ) + .context("build portable http request")?, + reply: Reply::from(http_reply_tx), + }) + .expect("queue http request"); + event_tx + .send(ActorEvent::SubscribeRequest { + conn: subscribe_conn.clone(), + event_name: "portable.event".to_owned(), + reply: Reply::from(subscribe_reply_tx), + }) + .expect("queue subscribe request"); + event_tx + .send(ActorEvent::Action { + name: "conn_report".to_owned(), + args: Vec::new(), + conn: None, + reply: Reply::from(report_reply_tx), + }) + .expect("queue connection report"); + + let (startup_tx, startup_rx) = oneshot::channel(); + let start = ActorStart { + ctx: ActorContext::new("portable-http-subscribe-e2e", "test", Vec::new(), "local"), + is_new: true, + input: None, + snapshot: None, + hibernated: Vec::new(), + events: event_rx.into(), + startup_ready: Some(startup_tx), + }; + + let join = tokio::spawn(async move { factory.start(start).await }); + tokio::time::timeout(Duration::from_secs(10), startup_rx) + .await + .with_context(|| format!("startup signal within 10s for {backend:?}"))? + .context("startup channel open")? + .context("startup ok")?; + + let http_response = tokio::time::timeout(Duration::from_secs(10), http_reply_rx) + .await + .with_context(|| format!("http reply within 10s for {backend:?}"))? + .context("http reply channel open")? + .context("http reply ok")?; + assert_eq!(http_response.status().as_u16(), 207); + assert_eq!( + http_response + .headers() + .get("x-portable-fixture") + .and_then(|value| value.to_str().ok()), + Some("http") + ); + let body = decode_cbor_json(http_response.body()); + assert_eq!(body["method"], json!("POST")); + assert_eq!(body["uri"], json!("/portable-http")); + assert_eq!(body["header"], json!(format!("{backend:?}"))); + assert_eq!(body["body"]["backend"], json!(format!("{backend:?}"))); + + tokio::time::timeout(Duration::from_secs(10), subscribe_reply_rx) + .await + .with_context(|| format!("subscribe reply within 10s for {backend:?}"))? + .context("subscribe reply channel open")? + .context("subscribe reply ok")?; + + let report = tokio::time::timeout(Duration::from_secs(10), report_reply_rx) + .await + .with_context(|| format!("subscribe report within 10s for {backend:?}"))? + .context("subscribe report channel open")? + .context("subscribe report ok")?; + let report = decode_cbor_json(&report); + assert_eq!(report["subscribeCount"], json!(1)); + assert_eq!(report["lastSubscribe"]["id"], json!(subscribe_conn.id())); + assert_eq!( + report["lastSubscribe"]["params"]["backend"], + json!(format!("{backend:?}")) + ); + assert_eq!(report["lastSubscribe"]["state"], json!([4, 5, 6])); + assert_eq!(report["lastSubscribeEventName"], json!("portable.event")); + + drop(event_tx); + tokio::time::timeout(Duration::from_secs(10), join) + .await + .with_context(|| format!("actor task joins within 10s for {backend:?}"))? + .context("actor task not panicked")? + .context("actor run ok")?; + + Ok(()) +} + +fn action_output(body: &str) -> Result { + let value: JsonValue = serde_json::from_str(body).context("decode action response")?; + Ok(value.get("output").cloned().unwrap_or(JsonValue::Null)) +} + +/// Full-stack: the SAME harness `counter` uses (IntegrationCtx + CoreRegistry + +/// the real `rivet-engine` binary), but the registered actor is backed by a +/// native plugin loaded via `build_native_plugin_factory`. Proves a native +/// plugin actor runs through the real engine -> runtime -> actor -> reply path +/// identically to an in-process Rust actor — not just the host loader in +/// isolation. Requires the engine binary (target/debug/rivet-engine or +/// RIVET_ENGINE_BINARY_PATH), exactly like `counter`. +#[tokio::test(flavor = "multi_thread")] +async fn native_plugin_counter_actor_runs_through_engine() -> Result<()> { + const ACTOR_NAME: &str = "native-plugin-counter"; + let so = build_fixture(); + + let ctx = IntegrationCtx::builder().start().await?; + ctx.create_default_namespace().await?; + + let mut registry = CoreRegistry::new(); + let mut config = ActorConfig::default(); + config.has_database = false; + let factory = + build_native_plugin_factory(&so, "{}", "", config).expect("load native plugin factory"); + registry.register(ACTOR_NAME, factory); + let registry_task = ctx.serve_registry(registry); + + ctx.wait_for_envoy_ready().await?; + let actor = ctx.create_actor(ACTOR_NAME).await?; + + let body = ctx + .wait_for_json_action(&actor.actor_id, "increment") + .await + .context("increment action through engine")?; + assert_eq!( + action_output(&body)?, + json!(1), + "native plugin reply via engine" + ); + + registry_task.shutdown().await?; + ctx.shutdown().await?; + Ok(()) +} + +fn decode_cbor_json(bytes: &[u8]) -> JsonValue { + ciborium::from_reader(std::io::Cursor::new(bytes)).expect("decode cbor json") +} + +fn encode_cbor_json(value: &JsonValue) -> Vec { + let mut out = Vec::new(); + ciborium::into_writer(value, &mut out).expect("encode cbor json"); + out +} diff --git a/rivetkit-rust/packages/rivetkit/Cargo.toml b/rivetkit-rust/packages/rivetkit/Cargo.toml index 584432442c..0587338ad4 100644 --- a/rivetkit-rust/packages/rivetkit/Cargo.toml +++ b/rivetkit-rust/packages/rivetkit/Cargo.toml @@ -17,6 +17,7 @@ sqlite-local = ["rivetkit-core/sqlite-local"] [dependencies] anyhow.workspace = true async-trait.workspace = true +base64.workspace = true ciborium.workspace = true futures.workspace = true http.workspace = true @@ -26,15 +27,20 @@ rivetkit-client.workspace = true parking_lot.workspace = true serde.workspace = true serde_json.workspace = true +serde_bytes.workspace = true tokio.workspace = true tokio-util.workspace = true tracing.workspace = true [dev-dependencies] axum = { workspace = true, features = ["ws"] } +base64.workspace = true bytes.workspace = true +ciborium.workspace = true rivet-envoy-client = { workspace = true, features = ["native-transport"] } +rivetkit-client = { path = "../client" } rivetkit-client-protocol.workspace = true +serde_bytes.workspace = true serde_json.workspace = true tracing-subscriber.workspace = true trybuild = "1.0.116" diff --git a/rivetkit-rust/packages/rivetkit/src/encoding.rs b/rivetkit-rust/packages/rivetkit/src/encoding.rs new file mode 100644 index 0000000000..2b03f2a053 --- /dev/null +++ b/rivetkit-rust/packages/rivetkit/src/encoding.rs @@ -0,0 +1,423 @@ +//! Byte-payload encoding parity with the rivetkit TypeScript framework. +//! +//! TS sits on at least one end of every action call (usually the client end). +//! The wire convention `["$Uint8Array", base64]` is what TS emits and what +//! TS expects. This module mirrors it for action-response encoding so Rust +//! actors can return byte payloads that round-trip correctly across all +//! three wire encodings (bare, cbor, json). +//! +//! **Scope-limited:** only `JSON_COMPAT_UINT8_ARRAY` is implemented. Other +//! JSON-compat tags from the TS side (`$BigInt`, `$ArrayBuffer`, `$Set`, +//! `$Undefined`, etc.) are not mirrored — add them when a real consumer +//! needs them. +//! +//! Reference: `rivetkit-typescript/packages/rivetkit/src/common/encoding.ts` +//! (`JSON_COMPAT_UINT8_ARRAY`, `encodeJsonCompatValue`). +//! +//! ## Convention +//! +//! Byte payloads (anything that goes through `serialize_bytes`, including +//! `serde_bytes::ByteBuf`, `serde_bytes::Bytes`, and `&[u8]` annotated +//! `#[serde(with = "serde_bytes")]`) are wrapped as a 2-element tagged +//! array: +//! +//! ```ignore +//! ["$Uint8Array", ""] +//! ``` +//! +//! Plain `Vec` without `#[serde(with = "serde_bytes")]` is treated as +//! a CBOR array of integers — matching TS's distinction between +//! `Uint8Array` (wrapped) and other typed arrays (passed through). +//! +//! All other serde calls pass through to `ciborium` unchanged. + +use std::io::Write; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use serde::Serialize; + +/// Tag string for the `Uint8Array` JSON-compat envelope. Matches the +/// TypeScript constant in `rivetkit-typescript/.../common/encoding.ts:14`. +/// Note the capital `U`. +pub const JSON_COMPAT_UINT8_ARRAY: &str = "$Uint8Array"; + +/// Encode `value` as CBOR with byte payloads wrapped per the TS convention. +/// +/// Use this in place of `ciborium::into_writer` at every site that +/// forwards a user value to a JS client (action replies, workflow +/// history, workflow replay). +pub fn encode_json_compat(value: &T, writer: W) -> anyhow::Result<()> +where + T: Serialize, + W: Write, +{ + let wrapped = JsonCompatWrap(value); + ciborium::into_writer(&wrapped, writer)?; + Ok(()) +} + +/// Convenience wrapper that encodes to a `Vec`. +pub fn encode_json_compat_to_vec(value: &T) -> anyhow::Result> { + let mut buf = Vec::new(); + encode_json_compat(value, &mut buf)?; + Ok(buf) +} + +/// Newtype that re-serializes any embedded `serialize_bytes` call as the +/// `["$Uint8Array", base64]` shape. Wraps any `Serialize` value so the +/// transformation applies recursively to nested fields. +struct JsonCompatWrap<'a, T: ?Sized>(&'a T); + +impl Serialize for JsonCompatWrap<'_, T> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(JsonCompatSerializer { inner: serializer }) + } +} + +/// Serializer adapter that intercepts `serialize_bytes` to emit the +/// rivetkit `Uint8Array` envelope. Every other method forwards to the +/// underlying serializer with the same `JsonCompatWrap` recursion so +/// nested byte fields get the same treatment. +struct JsonCompatSerializer { + inner: S, +} + +impl serde::Serializer for JsonCompatSerializer { + type Ok = S::Ok; + type Error = S::Error; + + type SerializeSeq = JsonCompatSerializeSeq; + type SerializeTuple = JsonCompatSerializeTuple; + type SerializeTupleStruct = JsonCompatSerializeTupleStruct; + type SerializeTupleVariant = JsonCompatSerializeTupleVariant; + type SerializeMap = JsonCompatSerializeMap; + type SerializeStruct = JsonCompatSerializeStruct; + type SerializeStructVariant = JsonCompatSerializeStructVariant; + + fn serialize_bool(self, v: bool) -> Result { + self.inner.serialize_bool(v) + } + + fn serialize_i8(self, v: i8) -> Result { + self.inner.serialize_i8(v) + } + + fn serialize_i16(self, v: i16) -> Result { + self.inner.serialize_i16(v) + } + + fn serialize_i32(self, v: i32) -> Result { + self.inner.serialize_i32(v) + } + + fn serialize_i64(self, v: i64) -> Result { + self.inner.serialize_i64(v) + } + + fn serialize_i128(self, v: i128) -> Result { + self.inner.serialize_i128(v) + } + + fn serialize_u8(self, v: u8) -> Result { + self.inner.serialize_u8(v) + } + + fn serialize_u16(self, v: u16) -> Result { + self.inner.serialize_u16(v) + } + + fn serialize_u32(self, v: u32) -> Result { + self.inner.serialize_u32(v) + } + + fn serialize_u64(self, v: u64) -> Result { + self.inner.serialize_u64(v) + } + + fn serialize_u128(self, v: u128) -> Result { + self.inner.serialize_u128(v) + } + + fn serialize_f32(self, v: f32) -> Result { + self.inner.serialize_f32(v) + } + + fn serialize_f64(self, v: f64) -> Result { + self.inner.serialize_f64(v) + } + + fn serialize_char(self, v: char) -> Result { + self.inner.serialize_char(v) + } + + fn serialize_str(self, v: &str) -> Result { + self.inner.serialize_str(v) + } + + /// The load-bearing override. Byte payloads (`serde_bytes::ByteBuf`, + /// `serde_bytes::Bytes`, `&[u8]` with `#[serde(with = "serde_bytes")]`) + /// all funnel through here. Emit the 2-element tagged array shape. + fn serialize_bytes(self, v: &[u8]) -> Result { + use serde::ser::SerializeTuple as _; + let base64 = BASE64_STANDARD.encode(v); + let mut tuple = self.inner.serialize_tuple(2)?; + tuple.serialize_element(JSON_COMPAT_UINT8_ARRAY)?; + tuple.serialize_element(&base64)?; + tuple.end() + } + + fn serialize_none(self) -> Result { + self.inner.serialize_none() + } + + fn serialize_some(self, value: &T) -> Result { + self.inner.serialize_some(&JsonCompatWrap(value)) + } + + fn serialize_unit(self) -> Result { + self.inner.serialize_unit() + } + + fn serialize_unit_struct(self, name: &'static str) -> Result { + self.inner.serialize_unit_struct(name) + } + + fn serialize_unit_variant( + self, + name: &'static str, + variant_index: u32, + variant: &'static str, + ) -> Result { + self.inner + .serialize_unit_variant(name, variant_index, variant) + } + + fn serialize_newtype_struct( + self, + name: &'static str, + value: &T, + ) -> Result { + self.inner + .serialize_newtype_struct(name, &JsonCompatWrap(value)) + } + + fn serialize_newtype_variant( + self, + name: &'static str, + variant_index: u32, + variant: &'static str, + value: &T, + ) -> Result { + self.inner + .serialize_newtype_variant(name, variant_index, variant, &JsonCompatWrap(value)) + } + + fn serialize_seq(self, len: Option) -> Result { + Ok(JsonCompatSerializeSeq { + inner: self.inner.serialize_seq(len)?, + }) + } + + fn serialize_tuple(self, len: usize) -> Result { + Ok(JsonCompatSerializeTuple { + inner: self.inner.serialize_tuple(len)?, + }) + } + + fn serialize_tuple_struct( + self, + name: &'static str, + len: usize, + ) -> Result { + Ok(JsonCompatSerializeTupleStruct { + inner: self.inner.serialize_tuple_struct(name, len)?, + }) + } + + fn serialize_tuple_variant( + self, + name: &'static str, + variant_index: u32, + variant: &'static str, + len: usize, + ) -> Result { + Ok(JsonCompatSerializeTupleVariant { + inner: self + .inner + .serialize_tuple_variant(name, variant_index, variant, len)?, + }) + } + + fn serialize_map(self, len: Option) -> Result { + Ok(JsonCompatSerializeMap { + inner: self.inner.serialize_map(len)?, + }) + } + + fn serialize_struct( + self, + name: &'static str, + len: usize, + ) -> Result { + Ok(JsonCompatSerializeStruct { + inner: self.inner.serialize_struct(name, len)?, + }) + } + + fn serialize_struct_variant( + self, + name: &'static str, + variant_index: u32, + variant: &'static str, + len: usize, + ) -> Result { + Ok(JsonCompatSerializeStructVariant { + inner: self + .inner + .serialize_struct_variant(name, variant_index, variant, len)?, + }) + } +} + +// --- Compound serializer wrappers (each delegates element serialization +// through `JsonCompatWrap` so nested byte fields get wrapped too) --- + +struct JsonCompatSerializeSeq { + inner: S, +} + +impl serde::ser::SerializeSeq for JsonCompatSerializeSeq { + type Ok = S::Ok; + type Error = S::Error; + + fn serialize_element(&mut self, value: &T) -> Result<(), Self::Error> { + self.inner.serialize_element(&JsonCompatWrap(value)) + } + + fn end(self) -> Result { + self.inner.end() + } +} + +struct JsonCompatSerializeTuple { + inner: S, +} + +impl serde::ser::SerializeTuple for JsonCompatSerializeTuple { + type Ok = S::Ok; + type Error = S::Error; + + fn serialize_element(&mut self, value: &T) -> Result<(), Self::Error> { + self.inner.serialize_element(&JsonCompatWrap(value)) + } + + fn end(self) -> Result { + self.inner.end() + } +} + +struct JsonCompatSerializeTupleStruct { + inner: S, +} + +impl serde::ser::SerializeTupleStruct + for JsonCompatSerializeTupleStruct +{ + type Ok = S::Ok; + type Error = S::Error; + + fn serialize_field(&mut self, value: &T) -> Result<(), Self::Error> { + self.inner.serialize_field(&JsonCompatWrap(value)) + } + + fn end(self) -> Result { + self.inner.end() + } +} + +struct JsonCompatSerializeTupleVariant { + inner: S, +} + +impl serde::ser::SerializeTupleVariant + for JsonCompatSerializeTupleVariant +{ + type Ok = S::Ok; + type Error = S::Error; + + fn serialize_field(&mut self, value: &T) -> Result<(), Self::Error> { + self.inner.serialize_field(&JsonCompatWrap(value)) + } + + fn end(self) -> Result { + self.inner.end() + } +} + +struct JsonCompatSerializeMap { + inner: S, +} + +impl serde::ser::SerializeMap for JsonCompatSerializeMap { + type Ok = S::Ok; + type Error = S::Error; + + fn serialize_key(&mut self, key: &T) -> Result<(), Self::Error> { + self.inner.serialize_key(&JsonCompatWrap(key)) + } + + fn serialize_value(&mut self, value: &T) -> Result<(), Self::Error> { + self.inner.serialize_value(&JsonCompatWrap(value)) + } + + fn end(self) -> Result { + self.inner.end() + } +} + +struct JsonCompatSerializeStruct { + inner: S, +} + +impl serde::ser::SerializeStruct for JsonCompatSerializeStruct { + type Ok = S::Ok; + type Error = S::Error; + + fn serialize_field( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), Self::Error> { + self.inner.serialize_field(key, &JsonCompatWrap(value)) + } + + fn end(self) -> Result { + self.inner.end() + } +} + +struct JsonCompatSerializeStructVariant { + inner: S, +} + +impl serde::ser::SerializeStructVariant + for JsonCompatSerializeStructVariant +{ + type Ok = S::Ok; + type Error = S::Error; + + fn serialize_field( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), Self::Error> { + self.inner.serialize_field(key, &JsonCompatWrap(value)) + } + + fn end(self) -> Result { + self.inner.end() + } +} diff --git a/rivetkit-rust/packages/rivetkit/src/event.rs b/rivetkit-rust/packages/rivetkit/src/event.rs index 1c41d4852f..66970ec6b8 100644 --- a/rivetkit-rust/packages/rivetkit/src/event.rs +++ b/rivetkit-rust/packages/rivetkit/src/event.rs @@ -242,7 +242,8 @@ impl ActionCall { } pub fn ok(mut self, value: &T) { - let result = encode_cbor(value, "encode action response as cbor"); + let result = + crate::encoding::encode_json_compat_to_vec(value).context("encode action response"); if let Some(reply) = self.reply.take() { reply.send(result); } @@ -887,7 +888,6 @@ fn encode_cbor(value: &T, context: &'static str) -> AnyhowResult(value: Value, expected: &'static str) -> Result where T: TryFrom, @@ -1392,6 +1392,93 @@ impl Destroy { } } +#[derive(Debug)] +#[must_use = "reply to workflow history or dropping it sends actor/dropped_reply"] +pub struct WfHistory { + pub(crate) reply: Option>>>, +} + +impl Drop for WfHistory { + fn drop(&mut self) { + if self.reply.is_some() { + warn_dropped_event("WorkflowHistory", "history"); + } + } +} + +impl WfHistory { + pub fn reply(self, history: Option<&T>) { + match history { + Some(history) => match crate::encoding::encode_json_compat_to_vec(history) + .context("encode workflow history") + { + Ok(bytes) => self.reply_raw(Some(bytes)), + Err(error) => self.reply_err(error), + }, + None => self.reply_raw(None), + } + } + + pub fn reply_raw(mut self, bytes: Option>) { + if let Some(reply) = self.reply.take() { + reply.send(Ok(bytes)); + } + } + + pub fn reply_err(mut self, err: anyhow::Error) { + if let Some(reply) = self.reply.take() { + reply.send(Err(err)); + } + } +} + +#[derive(Debug)] +#[must_use = "reply to workflow replay or dropping it sends actor/dropped_reply"] +pub struct WfReplay { + pub(crate) entry_id: Option, + pub(crate) reply: Option>>>, +} + +impl Drop for WfReplay { + fn drop(&mut self) { + if self.reply.is_some() { + warn_dropped_event( + "WorkflowReplay", + self.entry_id.as_deref().unwrap_or(""), + ); + } + } +} + +impl WfReplay { + pub fn entry_id(&self) -> Option<&str> { + self.entry_id.as_deref() + } + + pub fn reply(self, value: Option<&T>) { + match value { + Some(value) => match crate::encoding::encode_json_compat_to_vec(value) + .context("encode workflow replay") + { + Ok(bytes) => self.reply_raw(Some(bytes)), + Err(error) => self.reply_err(error), + }, + None => self.reply_raw(None), + } + } + + pub fn reply_raw(mut self, bytes: Option>) { + if let Some(reply) = self.reply.take() { + reply.send(Ok(bytes)); + } + } + + pub fn reply_err(mut self, err: anyhow::Error) { + if let Some(reply) = self.reply.take() { + reply.send(Err(err)); + } + } +} fn warn_dropped_event(variant: &'static str, identifying: impl fmt::Display) { tracing::warn!( variant, diff --git a/rivetkit-rust/packages/rivetkit/src/lib.rs b/rivetkit-rust/packages/rivetkit/src/lib.rs index 792bfe1295..7455350941 100644 --- a/rivetkit-rust/packages/rivetkit/src/lib.rs +++ b/rivetkit-rust/packages/rivetkit/src/lib.rs @@ -1,6 +1,7 @@ pub mod action; pub mod actor; pub mod context; +pub mod encoding; pub mod event; pub mod persist; pub mod prelude; diff --git a/rivetkit-rust/packages/rivetkit/tests/encoding.rs b/rivetkit-rust/packages/rivetkit/tests/encoding.rs new file mode 100644 index 0000000000..b548e9de6c --- /dev/null +++ b/rivetkit-rust/packages/rivetkit/tests/encoding.rs @@ -0,0 +1,140 @@ +//! Encode-side tests for the `JSON_COMPAT_UINT8_ARRAY` byte-payload +//! wrapping convention. Mirrors what +//! `rivetkit-typescript/.../common/encoding.ts::encodeJsonCompatValue` +//! does for `Uint8Array` inputs. + +use std::io::Cursor; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use rivetkit::encoding::encode_json_compat_to_vec; +use serde::Serialize; + +fn decode_intermediate(encoded: &[u8]) -> serde_json::Value { + ciborium::from_reader(Cursor::new(encoded)).expect("decode CBOR as JSON value") +} + +#[test] +fn byte_buf_wraps_as_json_compat_uint8_array() { + let bytes = serde_bytes::ByteBuf::from(b"hello".to_vec()); + let encoded = encode_json_compat_to_vec(&bytes).expect("encode"); + let intermediate = decode_intermediate(&encoded); + + assert!( + intermediate.is_array(), + "expected array, got {intermediate:?}" + ); + let arr = intermediate.as_array().unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0], "$Uint8Array"); + assert_eq!(arr[1], BASE64_STANDARD.encode(b"hello")); +} + +#[test] +fn nested_byte_field_in_struct_wraps() { + #[derive(Serialize)] + struct Reply { + status: u16, + body: serde_bytes::ByteBuf, + } + let value = Reply { + status: 200, + body: serde_bytes::ByteBuf::from(b"ok".to_vec()), + }; + let encoded = encode_json_compat_to_vec(&value).expect("encode"); + let intermediate = decode_intermediate(&encoded); + + assert_eq!(intermediate["status"], 200); + let body = &intermediate["body"]; + assert!( + body.is_array(), + "expected body to be wrapped array, got {body:?}" + ); + assert_eq!(body[0], "$Uint8Array"); + assert_eq!(body[1], BASE64_STANDARD.encode(b"ok")); +} + +#[test] +fn plain_vec_u8_stays_as_array() { + // Without #[serde(with = "serde_bytes")], Vec serializes via + // `serialize_seq` (one integer per element), not `serialize_bytes`. + // Matches TS's distinction between Uint8Array and other typed arrays. + let value: Vec = vec![1, 2, 3]; + let encoded = encode_json_compat_to_vec(&value).expect("encode"); + let intermediate = decode_intermediate(&encoded); + + assert!(intermediate.is_array()); + let arr = intermediate.as_array().unwrap(); + assert_eq!(arr.len(), 3); + assert_eq!(arr[0], 1); + assert_eq!(arr[1], 2); + assert_eq!(arr[2], 3); +} + +#[test] +fn non_byte_types_pass_through_unchanged() { + #[derive(Serialize)] + struct Reply { + msg: String, + count: u32, + enabled: bool, + ratio: f64, + } + let value = Reply { + msg: "hi".into(), + count: 7, + enabled: true, + ratio: 1.5, + }; + let encoded_compat = encode_json_compat_to_vec(&value).expect("encode via compat"); + let encoded_raw = { + let mut buf = Vec::new(); + ciborium::into_writer(&value, &mut buf).expect("encode via ciborium"); + buf + }; + // Compat path should be identical to raw ciborium when there are no + // byte payloads to wrap. + assert_eq!( + encoded_compat, encoded_raw, + "non-byte types should round-trip identically" + ); +} + +#[test] +fn nested_byte_field_inside_optional_wraps() { + #[derive(Serialize)] + struct Reply { + maybe_body: Option, + } + let value = Reply { + maybe_body: Some(serde_bytes::ByteBuf::from(b"present".to_vec())), + }; + let encoded = encode_json_compat_to_vec(&value).expect("encode"); + let intermediate = decode_intermediate(&encoded); + + let body = &intermediate["maybe_body"]; + assert!( + body.is_array(), + "expected Some(byte_buf) to wrap, got {body:?}" + ); + assert_eq!(body[0], "$Uint8Array"); + assert_eq!(body[1], BASE64_STANDARD.encode(b"present")); +} + +#[test] +fn byte_field_inside_seq_wraps_each_element() { + let values: Vec = vec![ + serde_bytes::ByteBuf::from(b"a".to_vec()), + serde_bytes::ByteBuf::from(b"bc".to_vec()), + ]; + let encoded = encode_json_compat_to_vec(&values).expect("encode"); + let intermediate = decode_intermediate(&encoded); + + let arr = intermediate.as_array().expect("outer should be array"); + assert_eq!(arr.len(), 2); + for (i, expected) in [b"a".as_ref(), b"bc".as_ref()].into_iter().enumerate() { + let item = &arr[i]; + assert!(item.is_array(), "item {i} should be wrapped array"); + assert_eq!(item[0], "$Uint8Array"); + assert_eq!(item[1], BASE64_STANDARD.encode(expected)); + } +} diff --git a/rivetkit-rust/packages/rivetkit/tests/encoding_fixtures.rs b/rivetkit-rust/packages/rivetkit/tests/encoding_fixtures.rs new file mode 100644 index 0000000000..05aec6439f --- /dev/null +++ b/rivetkit-rust/packages/rivetkit/tests/encoding_fixtures.rs @@ -0,0 +1,121 @@ +//! Generate cross-language parity fixtures. Writes Rust-encoded outputs +//! to JSON files that the TypeScript side reads to assert wire-format +//! parity (`tests/byte-encoding-parity.test.ts`). +//! +//! Run via `cargo test -p rivetkit --test encoding_fixtures`. The +//! fixtures land in `tests/fixtures/encoding/`. + +use std::io::Cursor; +use std::path::PathBuf; + +use rivetkit::encoding::encode_json_compat_to_vec; +use serde::Serialize; + +fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("encoding") +} + +fn write_fixture(name: &str, intermediate: &serde_json::Value) { + let dir = fixture_dir(); + std::fs::create_dir_all(&dir).expect("mkdir fixtures"); + let path = dir.join(format!("{name}.json")); + let serialized = serde_json::to_string_pretty(intermediate).expect("serialize fixture as JSON"); + std::fs::write(&path, serialized).expect("write fixture"); + println!("wrote fixture: {}", path.display()); +} + +fn encode_and_cbor_decode(value: &T) -> serde_json::Value { + let encoded = encode_json_compat_to_vec(value).expect("encode"); + ciborium::from_reader(Cursor::new(encoded)).expect("decode") +} + +#[test] +fn fixture_uint8array_hello() { + let bytes = serde_bytes::ByteBuf::from(b"hello".to_vec()); + let intermediate = encode_and_cbor_decode(&bytes); + write_fixture("uint8array_hello", &intermediate); +} + +#[test] +fn fixture_uint8array_1234() { + let bytes = serde_bytes::ByteBuf::from(vec![1u8, 2, 3, 4]); + let intermediate = encode_and_cbor_decode(&bytes); + write_fixture("uint8array_1234", &intermediate); +} + +#[test] +fn fixture_struct_with_byte_field() { + #[derive(Serialize)] + struct Reply { + status: u16, + body: serde_bytes::ByteBuf, + } + let value = Reply { + status: 200, + body: serde_bytes::ByteBuf::from(b"ok".to_vec()), + }; + let intermediate = encode_and_cbor_decode(&value); + write_fixture("struct_with_byte_field", &intermediate); +} + +/// Structured non-byte payload modeled after a virtual filesystem stat result. +/// Exercises bool, u32, u64, f64, and camelCase `#[serde(rename)]` fields +/// to catch encoder bugs that the byte-only fixtures would miss. Phase 2 +/// gate: this struct must round-trip losslessly across bare/cbor/json. +#[derive(Serialize)] +struct VirtualStatFixture { + mode: u32, + size: u64, + blocks: u64, + dev: u64, + rdev: u64, + #[serde(rename = "isDirectory")] + is_directory: bool, + #[serde(rename = "isSymbolicLink")] + is_symbolic_link: bool, + #[serde(rename = "atimeMs")] + atime_ms: f64, + #[serde(rename = "mtimeMs")] + mtime_ms: f64, + #[serde(rename = "ctimeMs")] + ctime_ms: f64, + #[serde(rename = "birthtimeMs")] + birthtime_ms: f64, + ino: u64, + nlink: u64, + uid: u32, + gid: u32, +} + +#[test] +fn fixture_virtual_stat_struct() { + let value = VirtualStatFixture { + mode: 0o100_644, + size: 7, + blocks: 1, + dev: 42, + rdev: 0, + is_directory: false, + is_symbolic_link: false, + atime_ms: 1_780_000_000_000.5, + mtime_ms: 1_780_000_001_000.25, + ctime_ms: 1_780_000_002_000.125, + birthtime_ms: 1_780_000_003_000.0625, + ino: 9_876_543_210, + nlink: 1, + uid: 1000, + gid: 1000, + }; + let intermediate = encode_and_cbor_decode(&value); + write_fixture("virtual_stat", &intermediate); +} + +#[test] +fn fixture_plain_string() { + let value = "hello world".to_string(); + let intermediate = encode_and_cbor_decode(&value); + write_fixture("plain_string", &intermediate); +} diff --git a/rivetkit-rust/packages/rivetkit/tests/encoding_roundtrip.rs b/rivetkit-rust/packages/rivetkit/tests/encoding_roundtrip.rs new file mode 100644 index 0000000000..b72f78e14c --- /dev/null +++ b/rivetkit-rust/packages/rivetkit/tests/encoding_roundtrip.rs @@ -0,0 +1,144 @@ +//! Round-trip test: encode via `rivetkit::encoding`, decode via +//! `rivetkit_client::encoding::revive_json_compat` (through a +//! `serde_json::Value` intermediate to simulate the engine's lossy +//! decode path). + +use std::io::Cursor; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use rivetkit::encoding::encode_json_compat_to_vec; +use rivetkit_client::encoding::revive_json_compat; +use serde::Serialize; + +fn ciborium_to_json(encoded: &[u8]) -> serde_json::Value { + ciborium::from_reader(Cursor::new(encoded)).expect("ciborium decode as JSON value") +} + +#[test] +fn encode_then_decode_round_trips_bytes() { + let original = b"round-trip data".to_vec(); + let value = serde_bytes::ByteBuf::from(original.clone()); + + let encoded = encode_json_compat_to_vec(&value).expect("encode"); + let intermediate = ciborium_to_json(&encoded); + let revived = revive_json_compat(intermediate); + + // After revival, the base64 string is what remains. + let base64 = revived.as_str().expect("revived to base64 string"); + let decoded = BASE64_STANDARD.decode(base64).expect("decode base64"); + assert_eq!(decoded, original); +} + +#[test] +fn encode_then_decode_round_trips_nested_struct() { + #[derive(Serialize)] + struct Reply { + status: u16, + body: serde_bytes::ByteBuf, + } + let value = Reply { + status: 200, + body: serde_bytes::ByteBuf::from(b"hello".to_vec()), + }; + + let encoded = encode_json_compat_to_vec(&value).expect("encode"); + let intermediate = ciborium_to_json(&encoded); + let revived = revive_json_compat(intermediate); + + assert_eq!(revived["status"], 200); + let base64 = revived["body"].as_str().expect("body revived to base64"); + let decoded = BASE64_STANDARD.decode(base64).expect("decode base64"); + assert_eq!(decoded, b"hello"); +} + +/// Encode/decode round-trip for a structured virtual filesystem stat payload. +/// Phase 2 gate: every field +/// (bool, u32, u64, f64, camelCase rename) survives the framework's +/// encode -> ciborium decode -> revive_json_compat path losslessly. +#[test] +fn encode_then_decode_round_trips_virtual_stat() { + #[derive(Serialize)] + struct VirtualStat { + mode: u32, + size: u64, + blocks: u64, + dev: u64, + rdev: u64, + #[serde(rename = "isDirectory")] + is_directory: bool, + #[serde(rename = "isSymbolicLink")] + is_symbolic_link: bool, + #[serde(rename = "atimeMs")] + atime_ms: f64, + #[serde(rename = "mtimeMs")] + mtime_ms: f64, + #[serde(rename = "ctimeMs")] + ctime_ms: f64, + #[serde(rename = "birthtimeMs")] + birthtime_ms: f64, + ino: u64, + nlink: u64, + uid: u32, + gid: u32, + } + let value = VirtualStat { + mode: 0o100_644, + size: 7, + blocks: 1, + dev: 42, + rdev: 0, + is_directory: false, + is_symbolic_link: true, + atime_ms: 1_780_000_000_000.5, + mtime_ms: 1_780_000_001_000.25, + ctime_ms: 1_780_000_002_000.125, + birthtime_ms: 1_780_000_003_000.0625, + ino: 9_876_543_210, + nlink: 1, + uid: 1000, + gid: 1000, + }; + + let encoded = encode_json_compat_to_vec(&value).expect("encode"); + let intermediate = ciborium_to_json(&encoded); + let revived = revive_json_compat(intermediate); + + // u32 / u16 / small integers come back as JSON numbers. + assert_eq!(revived["mode"], serde_json::json!(0o100_644u32)); + assert_eq!(revived["size"], serde_json::json!(7)); + assert_eq!(revived["blocks"], serde_json::json!(1)); + assert_eq!(revived["dev"], serde_json::json!(42)); + assert_eq!(revived["rdev"], serde_json::json!(0)); + + // Booleans. + assert_eq!(revived["isDirectory"], serde_json::json!(false)); + assert_eq!(revived["isSymbolicLink"], serde_json::json!(true)); + + // f64 timestamps must preserve fractional precision. + assert_eq!( + revived["atimeMs"].as_f64().expect("atimeMs f64"), + 1_780_000_000_000.5, + ); + assert_eq!( + revived["mtimeMs"].as_f64().expect("mtimeMs f64"), + 1_780_000_001_000.25, + ); + assert_eq!( + revived["ctimeMs"].as_f64().expect("ctimeMs f64"), + 1_780_000_002_000.125, + ); + assert_eq!( + revived["birthtimeMs"].as_f64().expect("birthtimeMs f64"), + 1_780_000_003_000.0625, + ); + + // Large u64 — must not silently downcast through f64. + assert_eq!(revived["ino"].as_u64().expect("ino u64"), 9_876_543_210u64,); + assert_eq!(revived["nlink"], serde_json::json!(1)); + assert_eq!(revived["uid"], serde_json::json!(1000)); + assert_eq!(revived["gid"], serde_json::json!(1000)); + + // camelCase renames must not leak the snake_case Rust names. + assert!(revived.get("is_directory").is_none()); + assert!(revived.get("atime_ms").is_none()); +} diff --git a/rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/plain_string.json b/rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/plain_string.json new file mode 100644 index 0000000000..cfcb15cf66 --- /dev/null +++ b/rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/plain_string.json @@ -0,0 +1 @@ +"hello world" \ No newline at end of file diff --git a/rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/struct_with_byte_field.json b/rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/struct_with_byte_field.json new file mode 100644 index 0000000000..a33c808934 --- /dev/null +++ b/rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/struct_with_byte_field.json @@ -0,0 +1,7 @@ +{ + "body": [ + "$Uint8Array", + "b2s=" + ], + "status": 200 +} \ No newline at end of file diff --git a/rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/uint8array_1234.json b/rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/uint8array_1234.json new file mode 100644 index 0000000000..0b09f4e30b --- /dev/null +++ b/rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/uint8array_1234.json @@ -0,0 +1,4 @@ +[ + "$Uint8Array", + "AQIDBA==" +] \ No newline at end of file diff --git a/rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/uint8array_hello.json b/rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/uint8array_hello.json new file mode 100644 index 0000000000..f872ed793f --- /dev/null +++ b/rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/uint8array_hello.json @@ -0,0 +1,4 @@ +[ + "$Uint8Array", + "aGVsbG8=" +] \ No newline at end of file diff --git a/rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/virtual_stat.json b/rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/virtual_stat.json new file mode 100644 index 0000000000..7620eeb9d8 --- /dev/null +++ b/rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/virtual_stat.json @@ -0,0 +1,17 @@ +{ + "atimeMs": 1780000000000.5, + "birthtimeMs": 1780000003000.0625, + "blocks": 1, + "ctimeMs": 1780000002000.125, + "dev": 42, + "gid": 1000, + "ino": 9876543210, + "isDirectory": false, + "isSymbolicLink": false, + "mode": 33188, + "mtimeMs": 1780000001000.25, + "nlink": 1, + "rdev": 0, + "size": 7, + "uid": 1000 +} \ No newline at end of file diff --git a/rivetkit-typescript/packages/engine-runner-protocol/test/exports-files-packaging.test.ts b/rivetkit-typescript/packages/engine-runner-protocol/test/exports-files-packaging.test.ts new file mode 100644 index 0000000000..190f77a835 --- /dev/null +++ b/rivetkit-typescript/packages/engine-runner-protocol/test/exports-files-packaging.test.ts @@ -0,0 +1,117 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { minimatch } from "minimatch"; +import { describe, expect, test } from "vitest"; + +/** + * Regression guard for rivetkit issue #3: "Missing CJS wrappers for declared exports". + * + * Root cause that remains: @rivetkit/engine-runner-protocol declares CJS export + * targets (require.default -> ./dist/index.cjs, require.types -> ./dist/index.d.cts) + * but its package.json `files` field is the restrictive glob + * ["dist/**\/*.js", "dist/**\/*.d.ts"], which excludes .cjs and .d.cts. As a result + * the published tarball does NOT contain the declared require target, so a CJS + * consumer doing `require("@rivetkit/engine-runner-protocol")` crashes with + * ERR_MODULE_NOT_FOUND / cannot find module ./dist/index.cjs. + * + * This is a purely STATIC check (no build / no network): it reads package.json, + * enumerates every leaf target path in `exports`, and asserts each one is included + * by the package's `files` field using npm's packing glob semantics. It encodes + * the CORRECT expected behavior, so it FAILS while the bug is present and will + * PASS once `files` is widened to ship the declared CJS artifacts. + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const pkgDir = resolve(here, ".."); +const pkgJsonPath = join(pkgDir, "package.json"); + +interface PkgJson { + name: string; + exports?: unknown; + files?: string[]; +} + +const pkg: PkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8")); + +/** Recursively collect every leaf string value from an exports map. */ +function collectExportTargets(node: unknown, acc: Set): void { + if (typeof node === "string") { + // Only consider relative file targets (skip e.g. bare specifiers). + if (node.startsWith("./") || node.startsWith("dist/")) { + acc.add(node.replace(/^\.\//, "")); + } + return; + } + if (Array.isArray(node)) { + for (const child of node) collectExportTargets(child, acc); + return; + } + if (node && typeof node === "object") { + for (const value of Object.values(node as Record)) { + collectExportTargets(value, acc); + } + } +} + +/** + * Does the npm `files` field include `targetPath`? + * + * npm semantics: a bare path that names a directory (e.g. "dist") behaves like + * "dist/**" (the whole subtree is included). Otherwise the entry is treated as a + * glob and matched against the package-relative path. + */ +function filesIncludes(files: string[], targetPath: string): boolean { + for (const raw of files) { + const pattern = raw.replace(/^\.\//, "").replace(/\/$/, ""); + if (minimatch(targetPath, pattern)) return true; + // Bare directory name -> recursive include. + if (!pattern.includes("*")) { + if ( + targetPath === pattern || + targetPath.startsWith(`${pattern}/`) + ) { + return true; + } + } + } + return false; +} + +describe("engine-runner-protocol exports are shippable via files field", () => { + const targets = new Set(); + collectExportTargets(pkg.exports, targets); + const files = pkg.files ?? []; + + test("package declares exports and a files field", () => { + expect(targets.size).toBeGreaterThan(0); + expect(files.length).toBeGreaterThan(0); + }); + + for (const target of targets) { + test(`exports target "${target}" is included by the files field (so it is published)`, () => { + expect( + filesIncludes(files, target), + `package.json "files" (${JSON.stringify(files)}) does not include exports target "${target}". ` + + `A CJS/ESM consumer that resolves to "${target}" would crash with ERR_MODULE_NOT_FOUND ` + + `because the file is excluded from the published tarball (issue #3).`, + ).toBe(true); + }); + } + + test("declared exports targets exist on disk after build (when dist is present)", () => { + // Soft check: only assert disk presence if the package has been built. + const anyBuilt = [...targets].some((t) => existsSync(join(pkgDir, t))); + if (!anyBuilt) { + // Not built locally; the files-vs-exports static checks above are the + // authoritative guard for issue #3. + return; + } + for (const target of targets) { + expect( + existsSync(join(pkgDir, target)), + `exports target "${target}" is missing from dist after build`, + ).toBe(true); + } + }); +}); diff --git a/rivetkit-typescript/packages/rivetkit-napi/Cargo.toml b/rivetkit-typescript/packages/rivetkit-napi/Cargo.toml index 0dc83afd1a..e4709914a1 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/Cargo.toml +++ b/rivetkit-typescript/packages/rivetkit-napi/Cargo.toml @@ -37,3 +37,6 @@ napi-build = "2" [dev-dependencies] rivetkit-actor-persist.workspace = true vbare.workspace = true +base64.workspace = true +ciborium.workspace = true +serde_bytes.workspace = true diff --git a/rivetkit-typescript/packages/rivetkit-napi/index.d.ts b/rivetkit-typescript/packages/rivetkit-napi/index.d.ts index 6d99414903..560071ebdb 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/index.d.ts +++ b/rivetkit-typescript/packages/rivetkit-napi/index.d.ts @@ -100,6 +100,12 @@ export interface JsActorConfig { actions?: Array inspectorTabs?: Array } +/** Options for loading a native actor plugin (`cdylib`) by path. */ +export interface NapiNativePluginOptions { + pluginPath: string + configJson?: string + sidecarPath?: string +} export interface JsBindParam { kind: string intValue?: number @@ -282,6 +288,13 @@ export declare class ActorContext { } export declare class NapiActorFactory { constructor(callbacks: object, config?: JsActorConfig | undefined | null) + /** + * Static constructor that loads a native actor plugin (`cdylib`) by path and + * adapts it through the generic `rivet-actor-plugin-abi`. RivetKit holds no + * plugin-specific knowledge: `config_json` is an opaque envelope the plugin + * parses itself, and `sidecar_path` is forwarded verbatim. + */ + static fromNativePlugin(options: NapiNativePluginOptions): NapiActorFactory } export declare class CancellationToken { constructor() diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs b/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs index c25e8c92ce..cebbb341b8 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs @@ -293,6 +293,14 @@ impl std::fmt::Display for BridgeRivetErrorContext { impl std::error::Error for BridgeRivetErrorContext {} +/// Options for loading a native actor plugin (`cdylib`) by path. +#[napi(object)] +pub struct NapiNativePluginOptions { + pub plugin_path: String, + pub config_json: Option, + pub sidecar_path: Option, +} + #[napi] pub struct NapiActorFactory { #[allow(dead_code)] @@ -342,6 +350,31 @@ impl NapiActorFactory { inner, }) } + + /// Static constructor that loads a native actor plugin (`cdylib`) by path and + /// adapts it through the generic `rivet-actor-plugin-abi`. RivetKit holds no + /// plugin-specific knowledge: `config_json` is an opaque envelope the plugin + /// parses itself, and `sidecar_path` is forwarded verbatim. + #[napi(factory)] + pub fn from_native_plugin(options: NapiNativePluginOptions) -> napi::Result { + crate::init_tracing(None); + let mut config = ActorConfig::default(); + config.has_database = true; + let factory = rivetkit_core::build_native_plugin_factory( + std::path::Path::new(&options.plugin_path), + options.config_json.as_deref().unwrap_or("{}"), + options.sidecar_path.as_deref().unwrap_or(""), + config, + ) + .map_err(napi_anyhow_error)?; + let inner = Arc::new(factory); + let bindings = Arc::new(CallbackBindings::empty()); + tracing::debug!(class = "NapiActorFactory", "constructed via from_native_plugin"); + Ok(Self { + _bindings: bindings, + inner, + }) + } } impl Drop for NapiActorFactory { @@ -375,6 +408,35 @@ impl AdapterConfig { } impl CallbackBindings { + /// Construct an empty `CallbackBindings` (no JS callbacks registered). + /// Used by native-plugin factories whose actor event loop lives outside JS. + pub(crate) fn empty() -> Self { + Self { + create_state: None, + on_create: None, + create_conn_state: None, + create_vars: None, + on_migrate: None, + on_wake: None, + on_before_actor_start: None, + on_sleep: None, + on_destroy: None, + on_before_connect: None, + on_connect: None, + on_disconnect_final: None, + on_before_subscribe: None, + actions: HashMap::new(), + on_before_action_response: None, + on_request: None, + on_queue_send: None, + on_websocket: None, + run: None, + get_workflow_history: None, + replay_workflow: None, + serialize_state: None, + } + } + fn from_js(callbacks: JsObject) -> napi::Result { let actions = if let Some(actions) = callbacks.get::<_, JsObject>("actions")? { let mut mapped = HashMap::new(); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/agent-os.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/agent-os.ts deleted file mode 100644 index 883965a50c..0000000000 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/agent-os.ts +++ /dev/null @@ -1,4 +0,0 @@ -import common from "@rivet-dev/agent-os-common"; -import { agentOs } from "rivetkit/agent-os"; - -export const agentOsTestActor = agentOs({ options: { software: [common] } }); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-static.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-static.ts index b0250faac1..04215ab0d8 100644 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-static.ts +++ b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry-static.ts @@ -171,18 +171,6 @@ import { workflowTryActor, } from "./workflow"; -let agentOsTestActor: - | Awaited["agentOsTestActor"] - | undefined; - -try { - ({ agentOsTestActor } = await import("./agent-os")); -} catch (error) { - if (!(error instanceof Error) || !error.message.includes("agent-os")) { - throw error; - } -} - // Consolidated setup with all actors export const registry = setup({ use: { @@ -370,11 +358,5 @@ export const registry = setup({ beforeConnectGenericErrorActor, stateChangeRecursionActor, stateChangeReentrantMutationActor, - ...(agentOsTestActor - ? { - // From agent-os.ts - agentOsTestActor, - } - : {}), }, }); diff --git a/rivetkit-typescript/packages/rivetkit/package.json b/rivetkit-typescript/packages/rivetkit/package.json index 5ff09e3a9d..b2f17b89f9 100644 --- a/rivetkit-typescript/packages/rivetkit/package.json +++ b/rivetkit-typescript/packages/rivetkit/package.json @@ -151,16 +151,6 @@ "types": "./dist/tsup/utils.d.cts", "default": "./dist/tsup/utils.cjs" } - }, - "./agent-os": { - "import": { - "types": "./dist/tsup/agent-os/index.d.ts", - "default": "./dist/tsup/agent-os/index.js" - }, - "require": { - "types": "./dist/tsup/agent-os/index.d.cts", - "default": "./dist/tsup/agent-os/index.cjs" - } } }, "engines": { @@ -171,7 +161,7 @@ "./dist/tsup/chunk-*.cjs" ], "scripts": { - "build": "tsup src/mod.ts src/client/mod.ts src/common/log.ts src/common/websocket.ts src/actor/errors.ts src/utils.ts src/workflow/mod.ts src/test/mod.ts src/inspector/mod.ts src/inspector-tab/mod.ts src/db/mod.ts src/db/drizzle.ts src/dynamic/mod.ts && tsup src/agent-os/index.ts --no-clean --out-dir dist/tsup/agent-os", + "build": "tsup src/mod.ts src/client/mod.ts src/common/log.ts src/common/websocket.ts src/actor/errors.ts src/utils.ts src/workflow/mod.ts src/test/mod.ts src/inspector/mod.ts src/inspector-tab/mod.ts src/db/mod.ts src/db/drizzle.ts src/dynamic/mod.ts", "build:browser": "tsup --config tsup.browser.config.ts", "check-types": "tsc --noEmit", "lint": "biome check . && pnpm run check:test-skips && pnpm run check:wait-for-comments", @@ -189,7 +179,6 @@ }, "dependencies": { "@hono/zod-openapi": "^1.1.5", - "@rivet-dev/agent-os-core": "^0.1.1", "@rivetkit/bare-ts": "^0.6.2", "@rivetkit/engine-cli": "workspace:*", "@rivetkit/engine-envoy-protocol": "workspace:*", @@ -214,8 +203,6 @@ "@copilotkit/llmock": "^1.6.0", "@hono/node-server": "^1.18.2", "@hono/node-ws": "^1.1.1", - "@rivet-dev/agent-os-common": "*", - "@rivet-dev/agent-os-pi": "^0.1.1", "@standard-schema/spec": "^1.0.0", "@types/invariant": "^2", "@types/node": "^22.13.1", diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/definition.ts b/rivetkit-typescript/packages/rivetkit/src/actor/definition.ts index b741b13dca..4fd6765f38 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/definition.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/definition.ts @@ -1,5 +1,9 @@ import type { AnyDatabaseProvider } from "@/common/database/config"; import type { RegistryConfig } from "@/registry/config"; +import type { + ActorFactoryHandle, + CoreRuntime, +} from "@/registry/runtime"; import { type Actions, type ActorConfig, @@ -49,6 +53,16 @@ export interface BaseActorDefinition< export interface AnyActorDefinition { readonly config: any; + /** + * Marker for foreign-runtime factories. When set, + * the registry-build ladder calls this closure with the active + * `CoreRuntime` to obtain an `ActorFactoryHandle` directly, bypassing + * the normal JS-callbacks factory built from `actor(...)`. + * + * Out-of-tree native-plugin packages set this; `CoreRuntime::registerActor` + * and the engine actor-driver consume it. + */ + nativeFactoryBuilder?: (runtime: CoreRuntime) => ActorFactoryHandle; } export type AnyStaticActorDefinition = ActorDefinition< @@ -85,6 +99,12 @@ export class ActorDefinition< > implements BaseActorDefinition { #config: ActorConfig; + /** + * Foreign-runtime factory marker. See [`AnyActorDefinition.nativeFactoryBuilder`]. + * Defaults to `undefined`; out-of-tree native-plugin packages set it via + * direct property assignment after construction. + */ + nativeFactoryBuilder?: (runtime: CoreRuntime) => ActorFactoryHandle; constructor(config: ActorConfig) { this.#config = config; diff --git a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/cron.ts b/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/cron.ts deleted file mode 100644 index 053b850e40..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/cron.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { CronAction, CronJobInfo } from "@rivet-dev/agent-os-core"; -import type { AgentOsActorConfig } from "../config"; -import type { - AgentOsActionContext, - SerializableCronAction, - SerializableCronJobInfo, - SerializableCronJobOptions, -} from "../types"; -import { ensureVm } from "./index"; - -function serializeCronAction(action: CronAction): SerializableCronAction { - switch (action.type) { - case "session": - return { - type: "session", - agentType: action.agentType, - prompt: action.prompt, - cwd: action.options?.cwd, - }; - case "exec": - return { - type: "exec", - command: action.command, - args: action.args, - }; - case "callback": - throw new TypeError("callback cron actions are not serializable"); - } -} - -function serializeCronJob(job: CronJobInfo): SerializableCronJobInfo { - return { - id: job.id, - schedule: job.schedule, - action: serializeCronAction(job.action), - overlap: job.overlap, - lastRun: job.lastRun?.toISOString(), - nextRun: job.nextRun?.toISOString(), - runCount: job.runCount, - running: job.running, - }; -} - -// Build cron scheduling actions for the actor factory. -export function buildCronActions( - config: AgentOsActorConfig, -) { - return { - scheduleCron: async ( - c: AgentOsActionContext, - options: SerializableCronJobOptions, - ): Promise<{ id: string }> => { - const agentOs = await ensureVm(c, config); - const job = agentOs.scheduleCron({ - id: options.id, - schedule: options.schedule, - action: options.action as CronAction, - overlap: options.overlap, - }); - c.log.info({ - msg: "agent-os cron job scheduled", - jobId: job.id, - schedule: options.schedule, - }); - return { id: job.id }; - }, - - listCronJobs: async ( - c: AgentOsActionContext, - ): Promise => { - const agentOs = await ensureVm(c, config); - return agentOs.listCronJobs().map(serializeCronJob); - }, - - cancelCronJob: async ( - c: AgentOsActionContext, - id: string, - ): Promise => { - const agentOs = await ensureVm(c, config); - agentOs.cancelCronJob(id); - c.log.info({ msg: "agent-os cron job cancelled", jobId: id }); - }, - }; -} diff --git a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/db.ts b/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/db.ts deleted file mode 100644 index e1b1374f59..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/db.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { RawAccess } from "@/common/database/config"; - -export async function migrateAgentOsTables(db: RawAccess): Promise { - await db.execute(` - CREATE TABLE IF NOT EXISTS agent_os_preview_tokens ( - token TEXT PRIMARY KEY, - port INTEGER NOT NULL, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_preview_tokens_expires_at - ON agent_os_preview_tokens(expires_at); - - CREATE TABLE IF NOT EXISTS agent_os_fs_entries ( - path TEXT PRIMARY KEY, - is_directory INTEGER NOT NULL DEFAULT 0, - content BLOB, - mode INTEGER NOT NULL DEFAULT 33188, - uid INTEGER NOT NULL DEFAULT 0, - gid INTEGER NOT NULL DEFAULT 0, - size INTEGER NOT NULL DEFAULT 0, - atime_ms INTEGER NOT NULL, - mtime_ms INTEGER NOT NULL, - ctime_ms INTEGER NOT NULL, - birthtime_ms INTEGER NOT NULL, - symlink_target TEXT, - nlink INTEGER NOT NULL DEFAULT 1 - ); - - CREATE INDEX IF NOT EXISTS idx_fs_entries_parent - ON agent_os_fs_entries(path); - - CREATE TABLE IF NOT EXISTS agent_os_sessions ( - session_id TEXT PRIMARY KEY, - agent_type TEXT NOT NULL, - capabilities TEXT NOT NULL, - agent_info TEXT, - created_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS agent_os_session_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - seq INTEGER NOT NULL, - event TEXT NOT NULL, - created_at INTEGER NOT NULL, - FOREIGN KEY (session_id) REFERENCES agent_os_sessions(session_id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_session_events_session_seq - ON agent_os_session_events(session_id, seq); - `); -} diff --git a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/filesystem.ts b/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/filesystem.ts deleted file mode 100644 index 2f17d14388..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/filesystem.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { - AgentRegistryEntry, - BatchReadResult, - BatchWriteEntry, - BatchWriteResult, - DirEntry, - ReaddirRecursiveOptions, -} from "@rivet-dev/agent-os-core"; -import type { AgentOsActorConfig } from "../config"; -import type { AgentOsActionContext } from "../types"; -import { ensureVm } from "./index"; - -// Infer types from AgentOs methods since @secure-exec/core is not a direct dep. -type VirtualStat = Awaited< - ReturnType ->; -type DeleteOptions = Parameters< - import("@rivet-dev/agent-os-core").AgentOs["delete"] ->[1]; - -// Build filesystem and agent registry actions for the actor factory. -export function buildFilesystemActions( - config: AgentOsActorConfig, -) { - return { - readFile: async ( - c: AgentOsActionContext, - path: string, - ): Promise => { - const agentOs = await ensureVm(c, config); - return agentOs.readFile(path); - }, - - writeFile: async ( - c: AgentOsActionContext, - path: string, - content: string | Uint8Array, - ): Promise => { - const agentOs = await ensureVm(c, config); - await agentOs.writeFile(path, content); - }, - - readFiles: async ( - c: AgentOsActionContext, - paths: string[], - ): Promise => { - const agentOs = await ensureVm(c, config); - return agentOs.readFiles(paths); - }, - - writeFiles: async ( - c: AgentOsActionContext, - entries: BatchWriteEntry[], - ): Promise => { - const agentOs = await ensureVm(c, config); - return agentOs.writeFiles(entries); - }, - - mkdir: async ( - c: AgentOsActionContext, - path: string, - options?: { recursive?: boolean }, - ): Promise => { - const agentOs = await ensureVm(c, config); - await agentOs.mkdir(path, options); - }, - - readdir: async ( - c: AgentOsActionContext, - path: string, - ): Promise => { - const agentOs = await ensureVm(c, config); - return agentOs.readdir(path); - }, - - readdirRecursive: async ( - c: AgentOsActionContext, - path: string, - options?: ReaddirRecursiveOptions, - ): Promise => { - const agentOs = await ensureVm(c, config); - return agentOs.readdirRecursive(path, options); - }, - - stat: async ( - c: AgentOsActionContext, - path: string, - ): Promise => { - const agentOs = await ensureVm(c, config); - return agentOs.stat(path); - }, - - exists: async ( - c: AgentOsActionContext, - path: string, - ): Promise => { - const agentOs = await ensureVm(c, config); - return agentOs.exists(path); - }, - - move: async ( - c: AgentOsActionContext, - from: string, - to: string, - ): Promise => { - const agentOs = await ensureVm(c, config); - await agentOs.move(from, to); - }, - - deleteFile: async ( - c: AgentOsActionContext, - path: string, - options?: DeleteOptions, - ): Promise => { - const agentOs = await ensureVm(c, config); - await agentOs.delete(path, options); - }, - - // TODO: mountFs and unmountFs are not exposed as actor actions because - // filesystem drivers (VirtualFileSystem) are not serializable over the - // network. Mount filesystems via the `options.mounts` config in agentOs() - // instead. See: https://github.com/rivet-dev/rivet/issues/XXXX - - listAgents: async ( - c: AgentOsActionContext, - ): Promise => { - const agentOs = await ensureVm(c, config); - return agentOs.listAgents(); - }, - }; -} diff --git a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/index.ts b/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/index.ts deleted file mode 100644 index 9facc5fd39..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/index.ts +++ /dev/null @@ -1,285 +0,0 @@ -import type { AgentOsOptions, MountConfig } from "@rivet-dev/agent-os-core"; -import { AgentOs, createInMemoryFileSystem } from "@rivet-dev/agent-os-core"; -import { actor, event, type ActorDefinition } from "@/actor/mod"; -import type { DatabaseProvider, RawAccess } from "@/common/database/config"; -import { db } from "@/common/database/mod"; -import { - type AgentOsActorConfig, - type AgentOsActorConfigInput, - agentOsActorConfigSchema, -} from "../config"; -import type { - AgentOsActionContext, - AgentOsActorState, - AgentOsActorVars, - CronEventPayload, - PermissionRequestPayload, - ProcessExitPayload, - ProcessOutputPayload, - SessionEventPayload, - ShellDataPayload, - VmBootedPayload, - VmShutdownPayload, -} from "../types"; -import { buildCronActions } from "./cron"; -import { migrateAgentOsTables } from "./db"; -import { buildFilesystemActions } from "./filesystem"; -import { buildNetworkActions } from "./network"; -import { buildOnRequestHandler, buildPreviewActions } from "./preview"; -import { buildProcessActions } from "./process"; -import { - buildConfigActions, - buildPromptActions, - buildSessionActions, - buildSessionPersistenceActions, -} from "./session"; -import { buildShellActions } from "./shell"; - -// --- VM lifecycle helpers --- - -async function ensureVm( - c: AgentOsActionContext, - config: AgentOsActorConfig, -): Promise { - if (c.vars.agentOs) { - return c.vars.agentOs; - } - - const start = Date.now(); - - // Build options with in-memory VFS as default working directory mount. - const options = buildVmOptions(config.options); - - const agentOs = await AgentOs.create(options); - c.vars.agentOs = agentOs; - - // Wire cron events to actor events. - agentOs.onCronEvent((cronEvent) => { - c.broadcast("cronEvent", { event: cronEvent }); - }); - - c.broadcast("vmBooted", {}); - c.log.info({ - msg: "agent-os vm booted", - bootDurationMs: Date.now() - start, - }); - - return agentOs; -} - -function buildVmOptions(userOptions?: AgentOsOptions): AgentOsOptions { - const userMounts = userOptions?.mounts ?? []; - - // Check if the user already provided a mount at /home/user. If so, respect - // their override and skip the default in-memory VFS mount. - const hasWorkdirMount = userMounts.some( - (m: MountConfig) => m.path === "/home/user", - ); - - if (hasWorkdirMount) { - return userOptions ?? {}; - } - - // TODO: Reimplement with persistent backend (actor KV-backed metadata + - // actor storage-backed blocks) so VM filesystem state survives sleep/wake. - const memMount: MountConfig = { - path: "/home/user", - driver: createInMemoryFileSystem(), - }; - - return { - ...userOptions, - mounts: [memMount, ...userMounts], - }; -} - -// --- Prevent-sleep coordination --- - -function syncPreventSleep( - c: AgentOsActionContext, -): void { - const shouldPrevent = - c.vars.activeSessionIds.size > 0 || - c.vars.activeProcesses.size > 0 || - c.vars.activeHooks.size > 0 || - c.vars.activeShells.size > 0; - - c.setPreventSleep(shouldPrevent); - - c.log.info({ - msg: "agent-os prevent sleep sync", - preventSleep: shouldPrevent, - activeSessions: c.vars.activeSessionIds.size, - activeProcesses: c.vars.activeProcesses.size, - activeHooks: c.vars.activeHooks.size, - activeShells: c.vars.activeShells.size, - }); -} - -// --- Hook tracking --- - -function runHook( - c: AgentOsActionContext, - name: string, - callback: () => void | Promise, -): void { - const promise = Promise.resolve(callback()) - .catch((error) => - c.log.error({ msg: "agent-os hook failed", hookName: name, error }), - ) - .finally(() => { - c.vars.activeHooks.delete(promise); - syncPreventSleep(c); - }); - c.vars.activeHooks.add(promise); - syncPreventSleep(c); - c.waitUntil(promise); -} - -// --- Public API --- - -export function agentOs( - config: AgentOsActorConfigInput, -): ActorDefinition< - AgentOsActorState, - TConnParams, - undefined, - AgentOsActorVars, - undefined, - DatabaseProvider, - { - sessionEvent: typeof sessionEventToken; - permissionRequest: typeof permissionRequestToken; - vmBooted: typeof vmBootedToken; - vmShutdown: typeof vmShutdownToken; - processOutput: typeof processOutputToken; - processExit: typeof processExitToken; - shellData: typeof shellDataToken; - cronEvent: typeof cronEventToken; - }, - Record, - any -> { - const parsedConfig = agentOsActorConfigSchema.parse( - config, - ) as AgentOsActorConfig; - const actions = { - ...buildSessionActions(parsedConfig), - ...buildPromptActions(parsedConfig), - ...buildConfigActions(parsedConfig), - ...buildSessionPersistenceActions(parsedConfig), - ...buildProcessActions(parsedConfig), - ...buildFilesystemActions(parsedConfig), - ...buildPreviewActions(parsedConfig), - ...buildShellActions(parsedConfig), - ...buildCronActions(parsedConfig), - ...buildNetworkActions(parsedConfig), - }; - - return actor< - AgentOsActorState, - TConnParams, - undefined, - AgentOsActorVars, - undefined, - DatabaseProvider, - { - sessionEvent: typeof sessionEventToken; - permissionRequest: typeof permissionRequestToken; - vmBooted: typeof vmBootedToken; - vmShutdown: typeof vmShutdownToken; - processOutput: typeof processOutputToken; - processExit: typeof processExitToken; - shellData: typeof shellDataToken; - cronEvent: typeof cronEventToken; - }, - Record, - typeof actions - >({ - options: { - sleepGracePeriod: 900_000, - actionTimeout: 900_000, - }, - createState: async () => ({}), - createVars: () => ({ - agentOs: null, - activeSessionIds: new Set(), - activeProcesses: new Set(), - activeHooks: new Set>(), - activeShells: new Set(), - sessions: new Set(), - }), - db: db({ - onMigrate: migrateAgentOsTables, - }), - events: { - sessionEvent: sessionEventToken, - permissionRequest: permissionRequestToken, - vmBooted: vmBootedToken, - vmShutdown: vmShutdownToken, - processOutput: processOutputToken, - processExit: processExitToken, - shellData: shellDataToken, - cronEvent: cronEventToken, - }, - onBeforeConnect: parsedConfig.onBeforeConnect - ? async (ctx, params) => { - // Skip user auth for preview URL requests. The signed token - // in onRequest is the credential; browsers navigating preview - // URLs cannot supply actor connection params. - if (ctx.request) { - const url = new URL(ctx.request.url); - if (url.pathname.startsWith("/fetch/")) { - return; - } - } - await parsedConfig.onBeforeConnect?.(ctx, params); - } - : undefined, - onRequest: buildOnRequestHandler(parsedConfig), - onSleep: async (c) => { - c.log.info({ - msg: "agent-os vm shutdown for sleep", - activeSessions: c.vars.sessions.size, - activeProcesses: c.vars.activeProcesses.size, - activeShells: c.vars.activeShells.size, - }); - - if (c.vars.agentOs) { - await c.vars.agentOs.dispose(); - c.vars.agentOs = null; - } - - c.broadcast("vmShutdown", { reason: "sleep" as const }); - }, - onDestroy: async (c) => { - c.log.info({ - msg: "agent-os vm shutdown for destroy", - activeSessions: c.vars.sessions.size, - activeProcesses: c.vars.activeProcesses.size, - activeShells: c.vars.activeShells.size, - }); - - if (c.vars.agentOs) { - await c.vars.agentOs.dispose(); - c.vars.agentOs = null; - } - - c.broadcast("vmShutdown", { reason: "destroy" as const }); - }, - actions, - }); -} - -// Event type tokens. Declared at module level so they can be referenced in -// the actor generic type parameters. -const sessionEventToken = event(); -const permissionRequestToken = event(); -const vmBootedToken = event(); -const vmShutdownToken = event(); -const processOutputToken = event(); -const processExitToken = event(); -const shellDataToken = event(); -const cronEventToken = event(); - -export { ensureVm, syncPreventSleep, runHook }; diff --git a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/network.ts b/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/network.ts deleted file mode 100644 index 8dbf418d1a..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/network.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { AgentOsActorConfig } from "../config"; -import type { AgentOsActionContext } from "../types"; -import { ensureVm } from "./index"; - -// Serializable fetch options for the actor action boundary. -export interface VmFetchOptions { - method?: string; - headers?: Record; - body?: string | Uint8Array; -} - -// Serializable fetch result returned by the actor action. -export interface VmFetchResult { - status: number; - statusText: string; - headers: Record; - body: Uint8Array; -} - -// Build network actions for the actor factory. -export function buildNetworkActions( - config: AgentOsActorConfig, -) { - return { - vmFetch: async ( - c: AgentOsActionContext, - port: number, - url: string, - options?: VmFetchOptions, - ): Promise => { - const agentOs = await ensureVm(c, config); - - const headers = new Headers(options?.headers); - const request = new Request(url, { - method: options?.method ?? "GET", - headers, - body: options?.body ?? null, - }); - - const response = await agentOs.fetch(port, request); - - // Serialize response headers to a plain object. - const responseHeaders: Record = {}; - response.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); - - const body = new Uint8Array(await response.arrayBuffer()); - - return { - status: response.status, - statusText: response.statusText, - headers: responseHeaders, - body, - }; - }, - }; -} diff --git a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/preview.ts b/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/preview.ts deleted file mode 100644 index 3e5f9f7e96..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/preview.ts +++ /dev/null @@ -1,190 +0,0 @@ -import crypto from "node:crypto"; -import type { RequestContext } from "@/actor/config"; -import type { DatabaseProvider, RawAccess } from "@/common/database/config"; -import type { AgentOsActorConfig } from "../config"; -import type { - AgentOsActionContext, - AgentOsActorState, - AgentOsActorVars, -} from "../types"; -import { ensureVm } from "./index"; - -// Generate a 32-character lowercase alphanumeric token (a-z0-9). -// 36^32 ~= 1.6e49 possible tokens, brute-force infeasible. -export function generateToken(): string { - const bytes = crypto.randomBytes(32); - const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; - let token = ""; - for (let i = 0; i < 32; i++) { - token += alphabet[bytes[i]! % alphabet.length]; - } - return token; -} - -// CORS headers added to all preview proxy responses. -const CORS_HEADERS: Record = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "*", -}; - -function addCorsHeaders(response: Response): Response { - const headers = new Headers(response.headers); - for (const [key, value] of Object.entries(CORS_HEADERS)) { - headers.set(key, value); - } - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); -} - -type AgentOsRequestContext = RequestContext< - AgentOsActorState, - TConnParams, - undefined, - AgentOsActorVars, - undefined, - DatabaseProvider ->; - -export function buildOnRequestHandler( - config: AgentOsActorConfig, -) { - return async ( - c: AgentOsRequestContext, - request: Request, - ): Promise => { - const url = new URL(request.url); - const pathname = url.pathname; - - // Expect paths like /fetch/{token} or /fetch/{token}/remaining/path. - const match = pathname.match(/^\/fetch\/([a-z0-9]+)(\/.*)?$/); - if (!match) { - return new Response("Not Found", { status: 404 }); - } - - // Handle OPTIONS preflight before token validation. - if (request.method === "OPTIONS") { - return new Response(null, { status: 204, headers: CORS_HEADERS }); - } - - const token = match[1]!; - const remainingPath = match[2] ?? "/"; - - // Validate token from SQLite. - const now = Date.now(); - const rows: { port: number }[] = await c.db.execute( - `SELECT port FROM agent_os_preview_tokens WHERE token = ? AND expires_at > ?`, - token, - now, - ); - - if (rows.length === 0) { - c.log.warn({ msg: "agent-os preview auth failed", token }); - return addCorsHeaders(new Response("Forbidden", { status: 403 })); - } - - const port = rows[0]?.port; - - // Boot the VM if needed. - const agentOs = await ensureVm( - c as AgentOsActionContext, - config, - ); - - // Build the request to proxy through the VM's virtual network. - const vmUrl = `http://localhost:${port}${remainingPath}${url.search}`; - const vmRequest = new Request(vmUrl, { - method: request.method, - headers: request.headers, - body: request.body, - duplex: "half", - } as RequestInit); - - const vmResponse = await agentOs.fetch(port, vmRequest); - - c.log.info({ - msg: "agent-os preview request proxied", - port, - method: request.method, - path: remainingPath, - status: vmResponse.status, - }); - - return addCorsHeaders(vmResponse); - }; -} - -export function buildPreviewActions( - config: AgentOsActorConfig, -) { - return { - createSignedPreviewUrl: async ( - c: AgentOsActionContext, - port: number, - expiresInSeconds?: number, - ): Promise<{ - path: string; - token: string; - port: number; - expiresAt: number; - }> => { - await ensureVm(c, config); - - const effectiveExpires = - expiresInSeconds ?? config.preview.defaultExpiresInSeconds; - const maxExpires = config.preview.maxExpiresInSeconds; - - if (effectiveExpires < 1 || effectiveExpires > maxExpires) { - throw new Error( - `expiresInSeconds must be between 1 and ${maxExpires}`, - ); - } - - const token = generateToken(); - const now = Date.now(); - const expiresAt = now + effectiveExpires * 1000; - - // Insert token and lazy-delete expired tokens. - await c.db.execute( - `INSERT INTO agent_os_preview_tokens (token, port, created_at, expires_at) - VALUES (?, ?, ?, ?)`, - token, - port, - now, - expiresAt, - ); - await c.db.execute( - `DELETE FROM agent_os_preview_tokens WHERE expires_at <= ?`, - now, - ); - - // Path relative to the actor's gateway URL. Full URL is - // `${gatewayUrl}/request/fetch/${token}` where gatewayUrl - // comes from the client's getGatewayUrl(). - const path = `/request/fetch/${token}`; - - c.log.info({ - msg: "agent-os preview token created", - port, - expiresInSeconds: effectiveExpires, - }); - - return { path, token, port, expiresAt }; - }, - - expireSignedPreviewUrl: async ( - c: AgentOsActionContext, - token: string, - ): Promise => { - await c.db.execute( - `DELETE FROM agent_os_preview_tokens WHERE token = ?`, - token, - ); - - c.log.info({ msg: "agent-os preview token expired", token }); - }, - }; -} diff --git a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/process.ts b/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/process.ts deleted file mode 100644 index a64f135b26..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/process.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { - ProcessInfo, - ProcessTreeNode, - SpawnedProcessInfo, -} from "@rivet-dev/agent-os-core"; -import { isRivetErrorCode } from "@/actor/errors"; -import type { AgentOsActorConfig } from "../config"; -import type { AgentOsActionContext } from "../types"; -import { ensureVm, syncPreventSleep } from "./index"; - -// Infer types from AgentOs methods since @secure-exec/core is not a direct dep. -type ExecResult = Awaited< - ReturnType ->; -type ExecOptions = Parameters< - import("@rivet-dev/agent-os-core").AgentOs["exec"] ->[1]; -type SpawnOptions = Parameters< - import("@rivet-dev/agent-os-core").AgentOs["spawn"] ->[2]; - -function broadcastProcessEvent( - c: AgentOsActionContext, - name: "processOutput" | "processExit", - payload: unknown, -) { - try { - c.broadcast(name, payload); - } catch (error) { - if (isRivetErrorCode(error, "actor", "stopping")) { - return; - } - throw error; - } -} - -// Build process execution actions for the actor factory. -export function buildProcessActions( - config: AgentOsActorConfig, -) { - return { - exec: async ( - c: AgentOsActionContext, - command: string, - options?: ExecOptions, - ): Promise => { - const agentOs = await ensureVm(c, config); - return agentOs.exec(command, options); - }, - - spawn: async ( - c: AgentOsActionContext, - command: string, - args: string[], - options?: SpawnOptions, - ): Promise<{ pid: number }> => { - const agentOs = await ensureVm(c, config); - const { pid } = agentOs.spawn(command, args, { - ...options, - onStdout: (data: Uint8Array) => { - broadcastProcessEvent(c, "processOutput", { - pid, - stream: "stdout" as const, - data, - }); - options?.onStdout?.(data); - }, - onStderr: (data: Uint8Array) => { - broadcastProcessEvent(c, "processOutput", { - pid, - stream: "stderr" as const, - data, - }); - options?.onStderr?.(data); - }, - }); - - c.vars.activeProcesses.add(pid); - syncPreventSleep(c); - c.log.info({ - msg: "agent-os process spawned", - pid, - command, - }); - - agentOs - .waitProcess(pid) - .then((exitCode) => { - broadcastProcessEvent(c, "processExit", { pid, exitCode }); - c.log.info({ - msg: "agent-os process exited", - pid, - exitCode, - }); - }) - .catch(() => { - // Process killed during dispose. Silently clean up. - }) - .finally(() => { - c.vars.activeProcesses.delete(pid); - syncPreventSleep(c); - }); - - return { pid }; - }, - - writeProcessStdin: async ( - c: AgentOsActionContext, - pid: number, - data: string | Uint8Array, - ): Promise => { - const agentOs = await ensureVm(c, config); - agentOs.writeProcessStdin(pid, data); - }, - - closeProcessStdin: async ( - c: AgentOsActionContext, - pid: number, - ): Promise => { - const agentOs = await ensureVm(c, config); - agentOs.closeProcessStdin(pid); - }, - - waitProcess: async ( - c: AgentOsActionContext, - pid: number, - ): Promise => { - const agentOs = await ensureVm(c, config); - return agentOs.waitProcess(pid); - }, - - listProcesses: async ( - c: AgentOsActionContext, - ): Promise => { - const agentOs = await ensureVm(c, config); - return agentOs.listProcesses(); - }, - - allProcesses: async ( - c: AgentOsActionContext, - ): Promise => { - const agentOs = await ensureVm(c, config); - return agentOs.allProcesses(); - }, - - processTree: async ( - c: AgentOsActionContext, - ): Promise => { - const agentOs = await ensureVm(c, config); - return agentOs.processTree(); - }, - - getProcess: async ( - c: AgentOsActionContext, - pid: number, - ): Promise => { - const agentOs = await ensureVm(c, config); - return agentOs.getProcess(pid); - }, - - stopProcess: async ( - c: AgentOsActionContext, - pid: number, - ): Promise => { - const agentOs = await ensureVm(c, config); - agentOs.stopProcess(pid); - }, - - killProcess: async ( - c: AgentOsActionContext, - pid: number, - ): Promise => { - const agentOs = await ensureVm(c, config); - agentOs.killProcess(pid); - }, - }; -} diff --git a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/session.ts b/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/session.ts deleted file mode 100644 index 136c33fbb8..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/session.ts +++ /dev/null @@ -1,518 +0,0 @@ -import type { - AgentOs, - AgentType, - CreateSessionOptions, - GetEventsOptions, - JsonRpcNotification, - JsonRpcResponse, - PermissionReply, - SequencedEvent, - SessionConfigOption, - SessionInfo, - SessionModeState, -} from "@rivet-dev/agent-os-core"; -import type { AgentOsActorConfig } from "../config"; -import type { - AgentOsActionContext, - PersistedSessionEvent, - PersistedSessionRecord, - PromptResult, - SessionRecord, -} from "../types"; -import { ensureVm, runHook, syncPreventSleep } from "./index"; - -// Strip non-serializable values (functions) from agent-os-core responses so -// CBOR/BARE encoding doesn't fail. The JsonRpcResponse objects from -// secure-exec can contain function properties. -function stripFunctions(value: unknown): unknown { - if (value === null || value === undefined) return value; - if (typeof value === "function") return undefined; - if (typeof value !== "object") return value; - if (Array.isArray(value)) return value.map(stripFunctions); - const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) { - if (typeof v !== "function") { - out[k] = stripFunctions(v); - } - } - return out; -} - -// Helper to verify a session exists in the VM. Throws via AgentOs if not found. -function assertSessionExists( - c: AgentOsActionContext, - sessionId: string, -): void { - if (!c.vars.sessions.has(sessionId)) { - throw new Error(`session not found: ${sessionId}`); - } -} - -// Build a SessionRecord from AgentOs flat API. -function toSessionRecord( - agentOs: AgentOs, - sessionId: string, - agentType: string, -): SessionRecord { - return { - sessionId, - agentType, - capabilities: agentOs.getSessionCapabilities(sessionId) ?? {}, - agentInfo: agentOs.getSessionAgentInfo(sessionId), - }; -} - -// --- Session persistence helpers --- - -// Persist a session record to SQLite when it is created. -async function persistSession( - c: AgentOsActionContext, - agentOs: AgentOs, - sessionId: string, - agentType: string, -): Promise { - const now = Date.now(); - const capabilities = agentOs.getSessionCapabilities(sessionId) ?? {}; - const agentInfo = agentOs.getSessionAgentInfo(sessionId); - await c.db.execute( - `INSERT OR REPLACE INTO agent_os_sessions (session_id, agent_type, capabilities, agent_info, created_at) - VALUES (?, ?, ?, ?, ?)`, - sessionId, - agentType, - JSON.stringify(capabilities), - agentInfo ? JSON.stringify(agentInfo) : null, - now, - ); -} - -// Persist a session event to SQLite with an auto-incrementing sequence number. -async function persistSessionEvent( - c: AgentOsActionContext, - sessionId: string, - event: JsonRpcNotification, -): Promise { - const now = Date.now(); - - // Compute next sequence number for this session. - const rows: { max_seq: number | null }[] = await c.db.execute( - `SELECT MAX(seq) as max_seq FROM agent_os_session_events WHERE session_id = ?`, - sessionId, - ); - const nextSeq = (rows[0]?.max_seq ?? -1) + 1; - - await c.db.execute( - `INSERT INTO agent_os_session_events (session_id, seq, event, created_at) - VALUES (?, ?, ?, ?)`, - sessionId, - nextSeq, - JSON.stringify(event), - now, - ); -} - -// Remove a session and its events from SQLite. -async function deletePersistedSession( - c: AgentOsActionContext, - sessionId: string, -): Promise { - await c.db.execute( - `DELETE FROM agent_os_session_events WHERE session_id = ?`, - sessionId, - ); - await c.db.execute( - `DELETE FROM agent_os_sessions WHERE session_id = ?`, - sessionId, - ); -} - -// Subscribe to a session's event and permission streams via the flat AgentOs API, -// broadcasting events and running user-provided hooks. -export function subscribeToSession( - c: AgentOsActionContext, - agentOs: AgentOs, - sessionId: string, - parsedConfig: AgentOsActorConfig, -): void { - agentOs.onSessionEvent(sessionId, (event) => { - c.broadcast( - "sessionEvent", - JSON.parse(JSON.stringify({ sessionId, event })), - ); - - // Persist event to SQLite for sleep/wake recovery. - persistSessionEvent(c, sessionId, event).catch((error) => - c.log.error({ - msg: "agent-os failed to persist session event", - sessionId, - error, - }), - ); - - if (parsedConfig.onSessionEvent) { - runHook(c, "onSessionEvent", () => - parsedConfig.onSessionEvent?.(c, sessionId, event), - ); - } - }); - - agentOs.onPermissionRequest(sessionId, (request) => { - c.broadcast( - "permissionRequest", - JSON.parse(JSON.stringify({ sessionId, request })), - ); - - if (parsedConfig.onPermissionRequest) { - runHook(c, "onPermissionRequest", () => - parsedConfig.onPermissionRequest?.(c, sessionId, request), - ); - } - }); - - c.vars.sessions.add(sessionId); -} - -// Build session management actions for the actor factory. -export function buildSessionActions( - config: AgentOsActorConfig, -) { - return { - createSession: async ( - c: AgentOsActionContext, - agentType: AgentType, - options?: CreateSessionOptions, - ): Promise => { - const agentOs = await ensureVm(c, config); - const { sessionId } = await agentOs.createSession( - agentType, - options, - ); - subscribeToSession(c, agentOs, sessionId, config); - - // Persist session metadata to SQLite for sleep/wake recovery. - await persistSession(c, agentOs, sessionId, agentType); - - c.log.info({ - msg: "agent-os session created", - sessionId, - agentType, - }); - return toSessionRecord(agentOs, sessionId, agentType); - }, - - listSessions: async ( - c: AgentOsActionContext, - ): Promise => { - const agentOs = await ensureVm(c, config); - return agentOs.listSessions(); - }, - - getSession: async ( - c: AgentOsActionContext, - sessionId: string, - ): Promise => { - assertSessionExists(c, sessionId); - const agentOs = await ensureVm(c, config); - const info = agentOs - .listSessions() - .find((s) => s.sessionId === sessionId); - if (!info) { - throw new Error(`session not found: ${sessionId}`); - } - return toSessionRecord(agentOs, sessionId, info.agentType); - }, - - destroySession: async ( - c: AgentOsActionContext, - sessionId: string, - ): Promise => { - const agentOs = await ensureVm(c, config); - await agentOs.destroySession(sessionId); - c.vars.sessions.delete(sessionId); - c.vars.activeSessionIds.delete(sessionId); - syncPreventSleep(c); - - // Clean up persisted session and events from SQLite. - await deletePersistedSession(c, sessionId); - - c.log.info({ msg: "agent-os session destroyed", sessionId }); - }, - - resumeSession: async ( - c: AgentOsActionContext, - sessionId: string, - ): Promise<{ sessionId: string }> => { - const agentOs = await ensureVm(c, config); - return agentOs.resumeSession(sessionId); - }, - - closeSession: async ( - c: AgentOsActionContext, - sessionId: string, - ): Promise => { - const agentOs = await ensureVm(c, config); - agentOs.closeSession(sessionId); - c.vars.sessions.delete(sessionId); - c.vars.activeSessionIds.delete(sessionId); - syncPreventSleep(c); - - // Clean up persisted session and events from SQLite. - await deletePersistedSession(c, sessionId); - - c.log.info({ msg: "agent-os session closed", sessionId }); - }, - }; -} - -// Build prompt, cancel, and permission actions for the actor factory. -export function buildPromptActions( - _config: AgentOsActorConfig, -) { - return { - sendPrompt: async ( - c: AgentOsActionContext, - sessionId: string, - text: string, - ): Promise => { - if (c.aborted) { - throw new Error( - "actor is shutting down, cannot start new prompt", - ); - } - - assertSessionExists(c, sessionId); - const agentOs = c.vars.agentOs; - if (!agentOs) { - throw new Error("VM not initialized"); - } - - c.vars.activeSessionIds.add(sessionId); - syncPreventSleep(c); - c.log.info({ msg: "agent-os prompt turn started", sessionId }); - - const start = Date.now(); - try { - const result = await agentOs.prompt(sessionId, text); - return { - response: JSON.parse(JSON.stringify(result.response)), - text: result.text, - }; - } finally { - c.vars.activeSessionIds.delete(sessionId); - syncPreventSleep(c); - c.log.info({ - msg: "agent-os prompt turn ended", - sessionId, - durationMs: Date.now() - start, - }); - } - }, - - cancelPrompt: async ( - c: AgentOsActionContext, - sessionId: string, - ): Promise => { - assertSessionExists(c, sessionId); - const agentOs = c.vars.agentOs; - if (!agentOs) { - throw new Error("VM not initialized"); - } - return stripFunctions( - agentOs.cancelSession(sessionId), - ) as JsonRpcResponse; - }, - - respondPermission: async ( - c: AgentOsActionContext, - sessionId: string, - permissionId: string, - reply: PermissionReply, - ): Promise => { - assertSessionExists(c, sessionId); - const agentOs = c.vars.agentOs; - if (!agentOs) { - throw new Error("VM not initialized"); - } - return stripFunctions( - agentOs.respondPermission(sessionId, permissionId, reply), - ) as JsonRpcResponse; - }, - }; -} - -// Build session configuration proxy actions for the actor factory. -export function buildConfigActions( - _config: AgentOsActorConfig, -) { - return { - setMode: async ( - c: AgentOsActionContext, - sessionId: string, - modeId: string, - ): Promise => { - assertSessionExists(c, sessionId); - const agentOs = c.vars.agentOs; - if (!agentOs) { - throw new Error("VM not initialized"); - } - return stripFunctions( - agentOs.setSessionMode(sessionId, modeId), - ) as JsonRpcResponse; - }, - - getModes: async ( - c: AgentOsActionContext, - sessionId: string, - ): Promise => { - assertSessionExists(c, sessionId); - const agentOs = c.vars.agentOs; - if (!agentOs) { - throw new Error("VM not initialized"); - } - return agentOs.getSessionModes(sessionId); - }, - - setModel: async ( - c: AgentOsActionContext, - sessionId: string, - model: string, - ): Promise => { - assertSessionExists(c, sessionId); - const agentOs = c.vars.agentOs; - if (!agentOs) { - throw new Error("VM not initialized"); - } - return stripFunctions( - agentOs.setSessionModel(sessionId, model), - ) as JsonRpcResponse; - }, - - setThoughtLevel: async ( - c: AgentOsActionContext, - sessionId: string, - level: string, - ): Promise => { - assertSessionExists(c, sessionId); - const agentOs = c.vars.agentOs; - if (!agentOs) { - throw new Error("VM not initialized"); - } - return stripFunctions( - agentOs.setSessionThoughtLevel(sessionId, level), - ) as JsonRpcResponse; - }, - - getConfigOptions: async ( - c: AgentOsActionContext, - sessionId: string, - ): Promise => { - assertSessionExists(c, sessionId); - const agentOs = c.vars.agentOs; - if (!agentOs) { - throw new Error("VM not initialized"); - } - return agentOs.getSessionConfigOptions(sessionId); - }, - - getEvents: async ( - c: AgentOsActionContext, - sessionId: string, - options?: GetEventsOptions, - ): Promise => { - assertSessionExists(c, sessionId); - const agentOs = c.vars.agentOs; - if (!agentOs) { - throw new Error("VM not initialized"); - } - return agentOs - .getSessionEvents(sessionId, options) - .map((e) => e.notification); - }, - - getSequencedEvents: async ( - c: AgentOsActionContext, - sessionId: string, - options?: GetEventsOptions, - ): Promise => { - assertSessionExists(c, sessionId); - const agentOs = c.vars.agentOs; - if (!agentOs) { - throw new Error("VM not initialized"); - } - return agentOs.getSessionEvents(sessionId, options); - }, - - rawSend: async ( - c: AgentOsActionContext, - sessionId: string, - method: string, - params?: Record, - ): Promise => { - assertSessionExists(c, sessionId); - const agentOs = c.vars.agentOs; - if (!agentOs) { - throw new Error("VM not initialized"); - } - return stripFunctions( - agentOs.rawSessionSend(sessionId, method, params), - ) as JsonRpcResponse; - }, - }; -} - -// Build actions for querying persisted session data from SQLite. -// These work without a running VM and return data from prior sessions -// that survived sleep/wake cycles. -export function buildSessionPersistenceActions( - _config: AgentOsActorConfig, -) { - return { - listPersistedSessions: async ( - c: AgentOsActionContext, - ): Promise => { - const rows: { - session_id: string; - agent_type: string; - capabilities: string; - agent_info: string | null; - created_at: number; - }[] = await c.db.execute( - `SELECT session_id, agent_type, capabilities, agent_info, created_at - FROM agent_os_sessions - ORDER BY created_at ASC`, - ); - - return rows.map((row) => ({ - sessionId: row.session_id, - agentType: row.agent_type, - capabilities: JSON.parse(row.capabilities), - agentInfo: row.agent_info ? JSON.parse(row.agent_info) : null, - createdAt: row.created_at, - })); - }, - - getSessionEvents: async ( - c: AgentOsActionContext, - sessionId: string, - ): Promise => { - const rows: { - session_id: string; - seq: number; - event: string; - created_at: number; - }[] = await c.db.execute( - `SELECT session_id, seq, event, created_at - FROM agent_os_session_events - WHERE session_id = ? - ORDER BY seq ASC`, - sessionId, - ); - - return rows.map((row) => ({ - sessionId: row.session_id, - seq: row.seq, - event: JSON.parse(row.event), - createdAt: row.created_at, - })); - }, - }; -} diff --git a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/shell.ts b/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/shell.ts deleted file mode 100644 index c44513ee45..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/shell.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { OpenShellOptions } from "@rivet-dev/agent-os-core"; -import type { AgentOsActorConfig } from "../config"; -import type { AgentOsActionContext } from "../types"; -import { ensureVm, syncPreventSleep } from "./index"; - -// Build shell actions for the actor factory. -export function buildShellActions( - config: AgentOsActorConfig, -) { - return { - openShell: async ( - c: AgentOsActionContext, - options?: OpenShellOptions, - ): Promise<{ shellId: string }> => { - const agentOs = await ensureVm(c, config); - const { shellId } = agentOs.openShell(options); - - // Wire shell data to actor events. - agentOs.onShellData(shellId, (data: Uint8Array) => { - c.broadcast("shellData", { shellId, data }); - }); - - c.vars.activeShells.add(shellId); - syncPreventSleep(c); - c.log.info({ msg: "agent-os shell opened", shellId }); - - return { shellId }; - }, - - writeShell: async ( - c: AgentOsActionContext, - shellId: string, - data: string | Uint8Array, - ): Promise => { - const agentOs = await ensureVm(c, config); - agentOs.writeShell(shellId, data); - }, - - resizeShell: async ( - c: AgentOsActionContext, - shellId: string, - cols: number, - rows: number, - ): Promise => { - const agentOs = await ensureVm(c, config); - agentOs.resizeShell(shellId, cols, rows); - }, - - closeShell: async ( - c: AgentOsActionContext, - shellId: string, - ): Promise => { - const agentOs = await ensureVm(c, config); - agentOs.closeShell(shellId); - c.vars.activeShells.delete(shellId); - syncPreventSleep(c); - c.log.info({ msg: "agent-os shell closed", shellId }); - }, - }; -} diff --git a/rivetkit-typescript/packages/rivetkit/src/agent-os/config.ts b/rivetkit-typescript/packages/rivetkit/src/agent-os/config.ts deleted file mode 100644 index a0ed59b30b..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/agent-os/config.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { - AgentOsOptions, - JsonRpcNotification, - PermissionRequest, -} from "@rivet-dev/agent-os-core"; -import { z } from "zod/v4"; -import type { ActorContext, BeforeConnectContext } from "@/actor/config"; -import type { AgentOsActorState, AgentOsActorVars } from "./types"; - -const zFunction = < - T extends (...args: any[]) => any = (...args: unknown[]) => unknown, ->() => z.custom((val) => typeof val === "function"); - -const AgentOsOptionsSchema = z.custom( - (val) => typeof val === "object" && val !== null, -); - -export const agentOsActorConfigSchema = z - .object({ - options: AgentOsOptionsSchema.optional(), - preview: z - .object({ - defaultExpiresInSeconds: z.number().positive().default(3600), - maxExpiresInSeconds: z.number().positive().default(86400), - }) - .strict() - .prefault(() => ({})), - onBeforeConnect: zFunction().optional(), - onSessionEvent: zFunction().optional(), - onPermissionRequest: zFunction().optional(), - }) - .strict(); - -// --- Typed config types (generic callbacks overlaid on the Zod schema) --- - -type AgentOsActorContext = ActorContext< - AgentOsActorState, - TConnParams, - undefined, - AgentOsActorVars, - undefined, - any ->; - -interface AgentOsActorConfigCallbacks { - onBeforeConnect?: ( - c: BeforeConnectContext< - AgentOsActorState, - AgentOsActorVars, - undefined, - any - >, - params: TConnParams, - ) => void | Promise; - onSessionEvent?: ( - c: AgentOsActorContext, - sessionId: string, - event: JsonRpcNotification, - ) => void | Promise; - onPermissionRequest?: ( - c: AgentOsActorContext, - sessionId: string, - request: PermissionRequest, - ) => void | Promise; -} - -// Parsed config (after Zod defaults/transforms applied). -export type AgentOsActorConfig = Omit< - z.infer, - "onBeforeConnect" | "onSessionEvent" | "onPermissionRequest" -> & - AgentOsActorConfigCallbacks; - -// Input config (what users pass in before Zod transforms). -export type AgentOsActorConfigInput = Omit< - z.input, - "onBeforeConnect" | "onSessionEvent" | "onPermissionRequest" -> & - AgentOsActorConfigCallbacks; diff --git a/rivetkit-typescript/packages/rivetkit/src/agent-os/fs/database-vfs.ts b/rivetkit-typescript/packages/rivetkit/src/agent-os/fs/database-vfs.ts deleted file mode 100644 index 4ad96621fb..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/agent-os/fs/database-vfs.ts +++ /dev/null @@ -1,624 +0,0 @@ -/** - * SQLite-backed VirtualFileSystem implementation. - * - * Stores file content, metadata (mode, timestamps), and directory structure - * in SQLite tables managed by the RivetKit actor's database. This allows VM - * filesystem state to persist across sleep/wake cycles. - * - * We use SQLite instead of actor KV because SQLite handles bulk write - * optimizations (transactions, WAL mode, page caching) under the hood. With KV - * we would need to manually chunk writes to stay under batch size limits, - * implement our own indexing for directory listing queries, and handle - * consistency across multiple KV operations. SQLite gives us all of this for - * free. - * - * All paths are normalized to POSIX form (forward slashes, rooted at "/"). - */ - -import * as posixPath from "node:path/posix"; -import type { RawAccess } from "@/common/database/config"; - -// Infer VirtualFileSystem from PlainMountConfig.driver since -// @secure-exec/core is not a direct dependency of this package. -type VirtualFileSystem = - import("@rivet-dev/agent-os-core").PlainMountConfig["driver"]; - -// Infer VirtualStat from AgentOs.stat() return type. -type VirtualStat = Awaited< - ReturnType ->; - -// Infer VirtualDirEntry from readDirWithTypes. -// VirtualDirEntry has: name, isDirectory, isSymbolicLink?, ino? -interface VirtualDirEntry { - name: string; - isDirectory: boolean; - isSymbolicLink?: boolean; - ino?: number; -} - -// POSIX mode constants. -const S_IFDIR = 0o040000; -const S_IFREG = 0o100000; -const S_IFLNK = 0o120000; -const DEFAULT_FILE_MODE = S_IFREG | 0o644; -const DEFAULT_DIR_MODE = S_IFDIR | 0o755; - -interface FsRow extends Record { - path: string; - is_directory: number; - content: Uint8Array | null; - mode: number; - uid: number; - gid: number; - size: number; - atime_ms: number; - mtime_ms: number; - ctime_ms: number; - birthtime_ms: number; - symlink_target: string | null; - nlink: number; -} - -function normPath(p: string): string { - const normalized = posixPath.normalize(`/${p}`); - // Remove trailing slash unless it's the root. - if (normalized.length > 1 && normalized.endsWith("/")) { - return normalized.slice(0, -1); - } - return normalized; -} - -function parentPath(p: string): string { - const parent = posixPath.dirname(p); - return parent; -} - -function throwENOENT(path: string): never { - const err = new Error(`ENOENT: no such file or directory: ${path}`); - err.name = "ENOENT"; - throw err; -} - -function throwEEXIST(path: string): never { - const err = new Error(`EEXIST: file already exists: ${path}`); - err.name = "EEXIST"; - throw err; -} - -function throwENOTDIR(path: string): never { - const err = new Error(`ENOTDIR: not a directory: ${path}`); - err.name = "ENOTDIR"; - throw err; -} - -function throwEISDIR(path: string): never { - const err = new Error(`EISDIR: illegal operation on a directory: ${path}`); - err.name = "EISDIR"; - throw err; -} - -function throwENOTEMPTY(path: string): never { - const err = new Error(`ENOTEMPTY: directory not empty: ${path}`); - err.name = "ENOTEMPTY"; - throw err; -} - -function throwENOSYS(op: string): never { - const err = new Error(`ENOSYS: function not implemented: ${op}`); - err.name = "ENOSYS"; - throw err; -} - -function rowToStat(row: FsRow): VirtualStat { - return { - mode: row.mode, - size: row.size, - isDirectory: row.is_directory === 1, - isSymbolicLink: row.symlink_target !== null, - atimeMs: row.atime_ms, - mtimeMs: row.mtime_ms, - ctimeMs: row.ctime_ms, - birthtimeMs: row.birthtime_ms, - ino: 0, - nlink: row.nlink, - uid: row.uid, - gid: row.gid, - }; -} - -export interface DatabaseVfsOptions { - /** The RawAccess database handle from the actor's db provider. */ - db: RawAccess; -} - -/** - * Create a VirtualFileSystem backed by SQLite. - * - * The returned filesystem stores all content and metadata in the - * `agent_os_fs_entries` table. The table must be created beforehand - * via `migrateAgentOsTables()`. - */ -export function createDatabaseVfs( - options: DatabaseVfsOptions, -): VirtualFileSystem { - const { db } = options; - - async function getEntry(path: string): Promise { - const rows = await db.execute( - "SELECT * FROM agent_os_fs_entries WHERE path = ?", - path, - ); - return rows[0]; - } - - async function getEntryOrThrow(path: string): Promise { - const entry = await getEntry(path); - if (!entry) { - throwENOENT(path); - } - return entry; - } - - async function ensureParentExists(path: string): Promise { - const parent = parentPath(path); - if (parent === path) return; // root - const entry = await getEntry(parent); - if (!entry) { - throwENOENT(parent); - } - if (entry.is_directory !== 1) { - throwENOTDIR(parent); - } - } - - async function getChildEntries(dirPath: string): Promise { - // Find direct children by matching paths that are one level deeper. - // A direct child of "/foo" has path like "/foo/bar" but NOT "/foo/bar/baz". - const prefix = dirPath === "/" ? "/" : `${dirPath}/`; - const rows = await db.execute( - "SELECT * FROM agent_os_fs_entries WHERE path LIKE ? AND path != ?", - `${prefix}%`, - dirPath, - ); - // Filter to direct children only. - return rows.filter((row) => { - const relative = row.path.slice(prefix.length); - return relative.length > 0 && !relative.includes("/"); - }); - } - - // Ensure root directory exists. - const rootInit = (async () => { - const root = await getEntry("/"); - if (!root) { - const now = Date.now(); - await db.execute( - `INSERT OR IGNORE INTO agent_os_fs_entries (path, is_directory, content, mode, uid, gid, size, atime_ms, mtime_ms, ctime_ms, birthtime_ms, symlink_target, nlink) VALUES (?, 1, NULL, ?, 0, 0, 0, ?, ?, ?, ?, NULL, 2)`, - "/", - DEFAULT_DIR_MODE, - now, - now, - now, - now, - ); - } - })(); - - const backend: VirtualFileSystem = { - async readFile(p: string): Promise { - await rootInit; - const path = normPath(p); - const entry = await getEntryOrThrow(path); - if (entry.is_directory === 1) { - throwEISDIR(path); - } - return entry.content ?? new Uint8Array(0); - }, - - async readTextFile(p: string): Promise { - const data = await backend.readFile(p); - return new TextDecoder().decode(data); - }, - - async readDir(p: string): Promise { - await rootInit; - const path = normPath(p); - const entry = await getEntryOrThrow(path); - if (entry.is_directory !== 1) { - throwENOTDIR(path); - } - const children = await getChildEntries(path); - return children.map((child) => posixPath.basename(child.path)); - }, - - async readDirWithTypes(p: string): Promise { - await rootInit; - const path = normPath(p); - const entry = await getEntryOrThrow(path); - if (entry.is_directory !== 1) { - throwENOTDIR(path); - } - const children = await getChildEntries(path); - return children.map((child) => ({ - name: posixPath.basename(child.path), - isDirectory: child.is_directory === 1, - isSymbolicLink: child.symlink_target !== null, - ino: 0, - })); - }, - - async writeFile( - p: string, - content: string | Uint8Array, - ): Promise { - await rootInit; - const path = normPath(p); - await ensureParentExists(path); - - const existing = await getEntry(path); - if (existing && existing.is_directory === 1) { - throwEISDIR(path); - } - - const data = - typeof content === "string" - ? new TextEncoder().encode(content) - : content; - const now = Date.now(); - - if (existing) { - await db.execute( - `UPDATE agent_os_fs_entries SET content = ?, size = ?, mtime_ms = ?, ctime_ms = ?, atime_ms = ? WHERE path = ?`, - data, - data.byteLength, - now, - now, - now, - path, - ); - } else { - await db.execute( - `INSERT INTO agent_os_fs_entries (path, is_directory, content, mode, uid, gid, size, atime_ms, mtime_ms, ctime_ms, birthtime_ms, symlink_target, nlink) VALUES (?, 0, ?, ?, 0, 0, ?, ?, ?, ?, ?, NULL, 1)`, - path, - data, - DEFAULT_FILE_MODE, - data.byteLength, - now, - now, - now, - now, - ); - } - }, - - async createDir(p: string): Promise { - await rootInit; - const path = normPath(p); - await ensureParentExists(path); - - const existing = await getEntry(path); - if (existing) { - throwEEXIST(path); - } - - const now = Date.now(); - await db.execute( - `INSERT INTO agent_os_fs_entries (path, is_directory, content, mode, uid, gid, size, atime_ms, mtime_ms, ctime_ms, birthtime_ms, symlink_target, nlink) VALUES (?, 1, NULL, ?, 0, 0, 0, ?, ?, ?, ?, NULL, 2)`, - path, - DEFAULT_DIR_MODE, - now, - now, - now, - now, - ); - }, - - async mkdir( - p: string, - options?: { recursive?: boolean }, - ): Promise { - await rootInit; - const path = normPath(p); - - if (options?.recursive) { - const parts = path.split("/").filter(Boolean); - let current = ""; - for (const part of parts) { - current += `/${part}`; - const existing = await getEntry(current); - if (!existing) { - const now = Date.now(); - await db.execute( - `INSERT INTO agent_os_fs_entries (path, is_directory, content, mode, uid, gid, size, atime_ms, mtime_ms, ctime_ms, birthtime_ms, symlink_target, nlink) VALUES (?, 1, NULL, ?, 0, 0, 0, ?, ?, ?, ?, NULL, 2)`, - current, - DEFAULT_DIR_MODE, - now, - now, - now, - now, - ); - } else if (existing.is_directory !== 1) { - throwENOTDIR(current); - } - } - } else { - await backend.createDir(p); - } - }, - - async exists(p: string): Promise { - await rootInit; - const path = normPath(p); - const entry = await getEntry(path); - return entry !== undefined; - }, - - async stat(p: string): Promise { - await rootInit; - const path = normPath(p); - const entry = await getEntryOrThrow(path); - return rowToStat(entry); - }, - - async removeFile(p: string): Promise { - await rootInit; - const path = normPath(p); - const entry = await getEntryOrThrow(path); - if (entry.is_directory === 1) { - throwEISDIR(path); - } - await db.execute( - "DELETE FROM agent_os_fs_entries WHERE path = ?", - path, - ); - }, - - async removeDir(p: string): Promise { - await rootInit; - const path = normPath(p); - const entry = await getEntryOrThrow(path); - if (entry.is_directory !== 1) { - throwENOTDIR(path); - } - const children = await getChildEntries(path); - if (children.length > 0) { - throwENOTEMPTY(path); - } - await db.execute( - "DELETE FROM agent_os_fs_entries WHERE path = ?", - path, - ); - }, - - async rename(oldPath: string, newPath: string): Promise { - await rootInit; - const from = normPath(oldPath); - const to = normPath(newPath); - - const entry = await getEntryOrThrow(from); - await ensureParentExists(to); - - // Remove destination if it exists (overwrite semantics). - const destEntry = await getEntry(to); - if (destEntry) { - if (destEntry.is_directory === 1) { - const children = await getChildEntries(to); - if (children.length > 0) { - throwENOTEMPTY(to); - } - } - await db.execute( - "DELETE FROM agent_os_fs_entries WHERE path = ?", - to, - ); - } - - if (entry.is_directory === 1) { - // Move all descendants by updating path prefixes. - const prefix = from === "/" ? "/" : `${from}/`; - const newPrefix = to === "/" ? "/" : `${to}/`; - - // Get all descendants first, then update them. - const descendants = await db.execute( - "SELECT path FROM agent_os_fs_entries WHERE path LIKE ?", - `${prefix}%`, - ); - - for (const desc of descendants) { - const newDescPath = - newPrefix + desc.path.slice(prefix.length); - await db.execute( - "UPDATE agent_os_fs_entries SET path = ? WHERE path = ?", - newDescPath, - desc.path, - ); - } - } - - // Update the entry itself. - const now = Date.now(); - await db.execute( - "UPDATE agent_os_fs_entries SET path = ?, ctime_ms = ? WHERE path = ?", - to, - now, - from, - ); - }, - - async realpath(p: string): Promise { - await rootInit; - const path = normPath(p); - const entry = await getEntryOrThrow(path); - if (entry.symlink_target !== null) { - return normPath(entry.symlink_target); - } - return path; - }, - - async symlink(target: string, linkPath: string): Promise { - await rootInit; - const link = normPath(linkPath); - await ensureParentExists(link); - - const existing = await getEntry(link); - if (existing) { - throwEEXIST(link); - } - - const now = Date.now(); - await db.execute( - `INSERT INTO agent_os_fs_entries (path, is_directory, content, mode, uid, gid, size, atime_ms, mtime_ms, ctime_ms, birthtime_ms, symlink_target, nlink) VALUES (?, 0, NULL, ?, 0, 0, ?, ?, ?, ?, ?, ?, 1)`, - link, - S_IFLNK | 0o777, - target.length, - now, - now, - now, - now, - target, - ); - }, - - async readlink(p: string): Promise { - await rootInit; - const path = normPath(p); - const entry = await getEntryOrThrow(path); - if (entry.symlink_target === null) { - const err = new Error(`EINVAL: not a symlink: ${path}`); - err.name = "EINVAL"; - throw err; - } - return entry.symlink_target; - }, - - async lstat(p: string): Promise { - // lstat does not follow symlinks; same as stat for our storage model. - return backend.stat(p); - }, - - async link(_oldPath: string, _newPath: string): Promise { - throwENOSYS("link"); - }, - - async chmod(p: string, mode: number): Promise { - await rootInit; - const path = normPath(p); - await getEntryOrThrow(path); - const now = Date.now(); - await db.execute( - "UPDATE agent_os_fs_entries SET mode = ?, ctime_ms = ? WHERE path = ?", - mode, - now, - path, - ); - }, - - async chown(p: string, uid: number, gid: number): Promise { - await rootInit; - const path = normPath(p); - await getEntryOrThrow(path); - const now = Date.now(); - await db.execute( - "UPDATE agent_os_fs_entries SET uid = ?, gid = ?, ctime_ms = ? WHERE path = ?", - uid, - gid, - now, - path, - ); - }, - - async utimes(p: string, atime: number, mtime: number): Promise { - await rootInit; - const path = normPath(p); - await getEntryOrThrow(path); - const now = Date.now(); - await db.execute( - "UPDATE agent_os_fs_entries SET atime_ms = ?, mtime_ms = ?, ctime_ms = ? WHERE path = ?", - atime, - mtime, - now, - path, - ); - }, - - async truncate(p: string, length: number): Promise { - await rootInit; - const path = normPath(p); - const entry = await getEntryOrThrow(path); - if (entry.is_directory === 1) { - throwEISDIR(path); - } - - const existing = entry.content ?? new Uint8Array(0); - let newContent: Uint8Array; - if (length >= existing.byteLength) { - // Extend with zeros. - newContent = new Uint8Array(length); - newContent.set(existing); - } else { - // Truncate. - newContent = existing.slice(0, length); - } - - const now = Date.now(); - await db.execute( - "UPDATE agent_os_fs_entries SET content = ?, size = ?, mtime_ms = ?, ctime_ms = ? WHERE path = ?", - newContent, - length, - now, - now, - path, - ); - }, - - async pread( - p: string, - offset: number, - length: number, - ): Promise { - await rootInit; - const path = normPath(p); - const entry = await getEntryOrThrow(path); - if (entry.is_directory === 1) { - throwEISDIR(path); - } - const content = entry.content ?? new Uint8Array(0); - const end = Math.min(offset + length, content.byteLength); - if (offset >= content.byteLength) { - return new Uint8Array(0); - } - return content.slice(offset, end); - }, - - async pwrite( - p: string, - offset: number, - data: Uint8Array, - ): Promise { - await rootInit; - const path = normPath(p); - const entry = await getEntryOrThrow(path); - if (entry.is_directory === 1) { - throwEISDIR(path); - } - const content = entry.content ?? new Uint8Array(0); - const end = offset + data.byteLength; - const newSize = Math.max(content.byteLength, end); - const buf = new Uint8Array(newSize); - buf.set(content); - buf.set(data, offset); - const now = Date.now(); - await db.execute( - `UPDATE agent_os_fs_entries SET content = ?, size = ?, mtime_ms = ?, ctime_ms = ? WHERE path = ?`, - buf, - newSize, - now, - now, - path, - ); - }, - }; - - return backend; -} diff --git a/rivetkit-typescript/packages/rivetkit/src/agent-os/index.ts b/rivetkit-typescript/packages/rivetkit/src/agent-os/index.ts deleted file mode 100644 index 6e2023359b..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/agent-os/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Database migration - -// Cron actions -export { buildCronActions } from "./actor/cron"; -export { migrateAgentOsTables } from "./actor/db"; -// Filesystem actions -export { buildFilesystemActions } from "./actor/filesystem"; -// Actor factory and VM lifecycle helpers -export { agentOs, ensureVm, runHook, syncPreventSleep } from "./actor/index"; -// Network actions -export { - buildNetworkActions, - type VmFetchOptions, - type VmFetchResult, -} from "./actor/network"; -// Preview actions -export { - buildOnRequestHandler, - buildPreviewActions, - generateToken, -} from "./actor/preview"; -// Process actions -export { buildProcessActions } from "./actor/process"; -// Session actions -export { - buildConfigActions, - buildPromptActions, - buildSessionActions, - buildSessionPersistenceActions, - subscribeToSession, -} from "./actor/session"; -// Shell actions -export { buildShellActions } from "./actor/shell"; -// Config schema and types -export { - type AgentOsActorConfig, - type AgentOsActorConfigInput, - agentOsActorConfigSchema, -} from "./config"; -// Database-backed VFS -export { - createDatabaseVfs, - type DatabaseVfsOptions, -} from "./fs/database-vfs"; -// Domain types and event payloads -export type { - AgentOsActionContext, - AgentOsActorState, - AgentOsActorVars, - AgentOsEvents, - CronEventPayload, - PermissionRequestPayload, - PersistedSessionEvent, - PersistedSessionRecord, - ProcessExitPayload, - ProcessOutputPayload, - PromptResult, - SerializableCronAction, - SerializableCronJobOptions, - SessionEventPayload, - SessionRecord, - ShellDataPayload, - VmBootedPayload, - VmShutdownPayload, -} from "./types"; diff --git a/rivetkit-typescript/packages/rivetkit/src/agent-os/types.ts b/rivetkit-typescript/packages/rivetkit/src/agent-os/types.ts deleted file mode 100644 index 5b5b943865..0000000000 --- a/rivetkit-typescript/packages/rivetkit/src/agent-os/types.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { - AgentCapabilities, - AgentInfo, - AgentOs, - CronEvent, - JsonRpcNotification, - JsonRpcResponse, - PermissionRequest, -} from "@rivet-dev/agent-os-core"; -import type { ActionContext } from "@/actor/config"; - -// --- Actor state (persisted across sleep/wake) --- - -// biome-ignore lint/complexity/noBannedTypes: empty state placeholder, consumers extend via generics -export type AgentOsActorState = {}; - -// --- Actor vars (ephemeral, recreated on wake) --- - -export interface AgentOsActorVars { - agentOs: AgentOs | null; - activeSessionIds: Set; - activeProcesses: Set; - activeHooks: Set>; - activeShells: Set; - sessions: Set; -} - -// --- Event payloads --- - -export interface SessionEventPayload { - sessionId: string; - event: JsonRpcNotification; -} - -export interface PermissionRequestPayload { - sessionId: string; - request: PermissionRequest; -} - -export type VmBootedPayload = Record; - -export interface VmShutdownPayload { - reason: "sleep" | "destroy" | "error"; -} - -export interface ProcessOutputPayload { - pid: number; - stream: "stdout" | "stderr"; - data: Uint8Array; -} - -export interface ProcessExitPayload { - pid: number; - exitCode: number; -} - -export interface ShellDataPayload { - shellId: string; - data: Uint8Array; -} - -export interface CronEventPayload { - event: CronEvent; -} - -// --- Event schema map (used by actor() events config) --- - -export interface AgentOsEvents { - sessionEvent: SessionEventPayload; - permissionRequest: PermissionRequestPayload; - vmBooted: VmBootedPayload; - vmShutdown: VmShutdownPayload; - processOutput: ProcessOutputPayload; - processExit: ProcessExitPayload; - shellData: ShellDataPayload; - cronEvent: CronEventPayload; -} - -// --- Prompt result --- - -/** Result from sendPrompt. */ -export interface PromptResult { - /** Raw JSON-RPC response from the ACP adapter. */ - response: JsonRpcResponse; - /** Accumulated agent text output from streamed message chunks. */ - text: string; -} - -// --- Session serialization --- - -export interface SessionRecord { - sessionId: string; - agentType: string; - capabilities: AgentCapabilities; - agentInfo: AgentInfo | null; -} - -// --- Persisted session types --- - -export interface PersistedSessionRecord { - sessionId: string; - agentType: string; - capabilities: AgentCapabilities; - agentInfo: AgentInfo | null; - createdAt: number; -} - -export interface PersistedSessionEvent { - sessionId: string; - seq: number; - event: JsonRpcNotification; - createdAt: number; -} - -// --- Serializable cron action (excludes callback type) --- - -export type SerializableCronAction = - | { type: "session"; agentType: string; prompt: string; cwd?: string } - | { type: "exec"; command: string; args?: string[] }; - -export interface SerializableCronJobOptions { - id?: string; - schedule: string; - action: SerializableCronAction; - overlap?: "allow" | "skip" | "queue"; -} - -export interface SerializableCronJobInfo { - id: string; - schedule: string; - action: SerializableCronAction; - overlap: "allow" | "skip" | "queue"; - lastRun?: string; - nextRun?: string; - runCount: number; - running: boolean; -} - -// --- Action context alias --- - -export type AgentOsActionContext = ActionContext< - AgentOsActorState, - TConnParams, - undefined, - AgentOsActorVars, - undefined, - any ->; diff --git a/rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts b/rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts index 298358ea73..0114ce428e 100644 --- a/rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts +++ b/rivetkit-typescript/packages/rivetkit/src/drivers/engine/actor-driver.ts @@ -142,6 +142,18 @@ interface HibernatableRunnerWebSocketBinding { export type DriverContext = {}; +/** + * Placeholder `handler.actor` value used for Rust-backed actor definitions + * (those with `nativeFactoryBuilder`). The Rust `CoreActorFactory` owns + * lifecycle and request dispatch, so the JS-side actor instance is a stub + * that no-ops on the handful of methods the engine driver may invoke during + * stop/teardown paths. + */ +const NATIVE_NAPI_ACTOR_STUB = { + onStop: async (_reason: string) => {}, + debugForceCrash: async () => {}, +} as unknown as AnyActorInstance; + export class EngineActorDriver implements ActorDriver { #config: RegistryConfig; #inlineClient: Client; @@ -1640,6 +1652,22 @@ export class EngineActorDriver implements ActorDriver { error: stringifyError(error), }); } + } else if (definition.nativeFactoryBuilder) { + // Foreign native-plugin actor. The + // `CoreActorFactory` registered for this name owns the + // actor's lifecycle, state, and request dispatch. The + // engine driver only needs to keep the handler alive so + // stop/load paths don't blow up. + handler.actor = NATIVE_NAPI_ACTOR_STUB; + handler.actorStartError = undefined; + handler.actorStartPromise?.resolve(); + handler.actorStartPromise = undefined; + logger().debug({ + msg: "engine actor started (rust-native)", + actorId, + name, + key, + }); } else if (isStaticActorDefinition(definition)) { const instantiateStart = performance.now(); const staticActor = diff --git a/rivetkit-typescript/packages/rivetkit/src/mod.ts b/rivetkit-typescript/packages/rivetkit/src/mod.ts index 29c385a348..e509677c79 100644 --- a/rivetkit-typescript/packages/rivetkit/src/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/mod.ts @@ -1,4 +1,11 @@ export * from "@/actor/mod"; +// Actor context types needed by out-of-tree native-plugin forwarders. +// Re-exported explicitly so the bundled `.d.ts` keeps +// them even when no in-tree public consumer references them. +export type { + ActionContext, + BeforeConnectContext, +} from "@/actor/config"; export { type AnyClient, type Client, @@ -7,8 +14,19 @@ export { export type { ActorQuery } from "@/client/query"; export { InlineWebSocketAdapter } from "@/common/inline-websocket-adapter"; export { noopNext } from "@/common/utils"; +export type { + DatabaseProvider, + RawAccess, +} from "@/common/database/config"; export * from "@/registry"; export * from "@/registry/config"; +// Native-actor-plugin runtime contract, public so out-of-tree plugin +// forwarders can build a native-plugin descriptor. +export type { + ActorFactoryHandle, + CoreRuntime, + NapiNativePluginOptions, +} from "@/registry/runtime"; export { toUint8Array } from "@/utils"; export type { WorkflowBranchContextOf, diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts b/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts index 50bb1dec0b..bc1066b79a 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts @@ -12,6 +12,7 @@ import type { CancellationTokenHandle, ConnHandle, CoreRuntime, + NapiNativePluginOptions, RegistryHandle, RuntimeActorConfig, RuntimeBytes, @@ -192,6 +193,13 @@ export class NapiCoreRuntime implements CoreRuntime { asNativeRegistry(registry).register(name, asNativeFactory(factory)); } + createNativePluginFactory( + options: NapiNativePluginOptions, + ): ActorFactoryHandle { + const factory = this.#bindings.NapiActorFactory.fromNativePlugin(options); + return asActorFactoryHandle(factory); + } + async serveRegistry( registry: RegistryHandle, config: RuntimeServeConfig, diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts index 3c94653463..5a40e4b83f 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts @@ -4854,11 +4854,12 @@ export async function buildRegistryWithRuntime( const registry = runtime.createRegistry(); for (const [name, definition] of Object.entries(config.use)) { - runtime.registerActor( - registry, - name, - buildNativeFactory(runtime, config, definition), - ); + // Dispatch: foreign-runtime factories bypass `buildNativeFactory` and + // own their entry loop directly. + const factory = definition.nativeFactoryBuilder + ? definition.nativeFactoryBuilder(runtime) + : buildNativeFactory(runtime, config, definition); + runtime.registerActor(registry, name, factory); } return { diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts b/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts index b8dca80bb8..533b578611 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts @@ -3,6 +3,17 @@ import type { SqliteNativeMetrics } from "@/common/database/config"; import type { RegistryConfig } from "./config"; import { logger } from "./log"; +/** + * Options for loading a native actor plugin (cdylib) by path. `configJson` is + * an opaque envelope the plugin parses itself; `sidecarPath` is forwarded. + */ +export interface NapiNativePluginOptions { + pluginPath: string; + configJson?: string; + sidecarPath?: string; +} + + declare const handleBrand: unique symbol; type OpaqueHandle = { @@ -331,6 +342,13 @@ export interface CoreRuntime { name: string, factory: ActorFactoryHandle, ): void; + /** + * Build a factory from a native actor plugin (cdylib) loaded by path. + * Optional: only the native NAPI runtime implements this; wasm throws. + */ + createNativePluginFactory?( + options: NapiNativePluginOptions, + ): ActorFactoryHandle; serveRegistry( registry: RegistryHandle, config: RuntimeServeConfig, @@ -613,8 +631,9 @@ export async function buildServeConfig( }; // Always best-effort resolve the engine binary path and hand it to the core. - // The core alone decides whether to actually spawn a local engine, so JS must - // not duplicate that decision here. `loadEnginePath` throws when no binary is + // The core alone decides whether to actually spawn a local engine (its + // `should_manage_engine`, based on the endpoint + spawn mode), so JS must not + // duplicate that decision here. `loadEnginePath` throws when no binary is // available (remote-only install, unsupported platform, optional deps // skipped); in that case leave it unset and let the core report // `engine.binary_unavailable` only if it actually needs one. diff --git a/rivetkit-typescript/packages/rivetkit/tests/byte-encoding-parity.test.ts b/rivetkit-typescript/packages/rivetkit/tests/byte-encoding-parity.test.ts new file mode 100644 index 0000000000..e217585e27 --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/tests/byte-encoding-parity.test.ts @@ -0,0 +1,132 @@ +// Cross-language parity tests for byte-payload encoding/decoding. +// +// Reads fixtures generated by Rust at +// `rivetkit-rust/packages/rivetkit/tests/fixtures/encoding/*.json` (run +// `cargo test -p rivetkit --test encoding_fixtures` to regenerate) and +// asserts that: +// 1. TS `encodeJsonCompatValue` produces the same wire shape on the same +// input (encode parity). +// 2. TS `reviveJsonCompatValue` revives Rust-encoded bytes to a real +// `Uint8Array` with the original contents (decode parity). +// +// If this test fails, the Rust framework and the TS framework have +// drifted apart on the wire convention — fix whichever side disagrees. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { + encodeJsonCompatValue, + reviveJsonCompatValue, +} from "@/common/encoding"; + +const FIXTURE_DIR = join( + __dirname, + "../../../../rivetkit-rust/packages/rivetkit/tests/fixtures/encoding", +); + +function loadFixture(name: string): unknown { + const raw = readFileSync(join(FIXTURE_DIR, `${name}.json`), "utf8"); + return JSON.parse(raw); +} + +describe("byte-encoding parity (Rust ⇄ TS)", () => { + test("uint8array_hello fixture matches TS encode output", () => { + const rustEncoded = loadFixture("uint8array_hello"); + const tsEncoded = encodeJsonCompatValue( + new Uint8Array([0x68, 0x65, 0x6c, 0x6c, 0x6f]), // "hello" + ); + expect(rustEncoded).toEqual(tsEncoded); + }); + + test("uint8array_1234 fixture matches TS encode output", () => { + const rustEncoded = loadFixture("uint8array_1234"); + const tsEncoded = encodeJsonCompatValue(new Uint8Array([1, 2, 3, 4])); + expect(rustEncoded).toEqual(tsEncoded); + }); + + test("struct_with_byte_field fixture matches TS encode output", () => { + const rustEncoded = loadFixture("struct_with_byte_field"); + const tsEncoded = encodeJsonCompatValue({ + status: 200, + body: new Uint8Array([0x6f, 0x6b]), // "ok" + }); + expect(rustEncoded).toEqual(tsEncoded); + }); + + test("plain string is unchanged across languages", () => { + const rustEncoded = loadFixture("plain_string"); + const tsEncoded = encodeJsonCompatValue("hello world"); + expect(rustEncoded).toEqual(tsEncoded); + }); + + test("TS reviveJsonCompatValue handles Rust-encoded uint8array_hello", () => { + const rustEncoded = loadFixture("uint8array_hello"); + const revived = reviveJsonCompatValue(rustEncoded); + expect(revived).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(revived as Uint8Array)).toBe("hello"); + }); + + test("TS reviveJsonCompatValue handles Rust-encoded uint8array_1234", () => { + const rustEncoded = loadFixture("uint8array_1234"); + const revived = reviveJsonCompatValue(rustEncoded); + expect(revived).toBeInstanceOf(Uint8Array); + expect([...revived]).toEqual([1, 2, 3, 4]); + }); + + test("TS reviveJsonCompatValue handles Rust-encoded nested byte field", () => { + const rustEncoded = loadFixture("struct_with_byte_field"); + const revived = reviveJsonCompatValue(rustEncoded) as { + status: number; + body: Uint8Array; + }; + expect(revived.status).toBe(200); + expect(revived.body).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(revived.body)).toBe("ok"); + }); + + // Structured non-byte payload (VirtualStat-shaped). Phase 2 gate: + // every field — bool, u32, u64, f64 with fractional precision, + // camelCase renames — must come through the Rust encode -> TS decode + // path losslessly. + test("virtual_stat fixture round-trips all field types from Rust to TS", () => { + const rustEncoded = loadFixture("virtual_stat") as Record< + string, + unknown + >; + + // Integer fields. + expect(rustEncoded.mode).toBe(0o100_644); + expect(rustEncoded.size).toBe(7); + expect(rustEncoded.blocks).toBe(1); + expect(rustEncoded.dev).toBe(42); + expect(rustEncoded.rdev).toBe(0); + expect(rustEncoded.nlink).toBe(1); + expect(rustEncoded.uid).toBe(1000); + expect(rustEncoded.gid).toBe(1000); + + // Booleans. + expect(rustEncoded.isDirectory).toBe(false); + expect(rustEncoded.isSymbolicLink).toBe(false); + + // f64 with sub-integer precision must survive intact. + expect(rustEncoded.atimeMs).toBe(1_780_000_000_000.5); + expect(rustEncoded.mtimeMs).toBe(1_780_000_001_000.25); + expect(rustEncoded.ctimeMs).toBe(1_780_000_002_000.125); + expect(rustEncoded.birthtimeMs).toBe(1_780_000_003_000.0625); + + // Large u64 — must not silently truncate to f53. + expect(rustEncoded.ino).toBe(9_876_543_210); + + // camelCase renames: snake_case Rust field names must not leak. + expect(rustEncoded).not.toHaveProperty("is_directory"); + expect(rustEncoded).not.toHaveProperty("is_symbolic_link"); + expect(rustEncoded).not.toHaveProperty("atime_ms"); + expect(rustEncoded).not.toHaveProperty("birthtime_ms"); + + // TS reviveJsonCompatValue on a structured (non-byte) payload + // must be a no-op: the object passes through unchanged. + const revived = reviveJsonCompatValue(rustEncoded); + expect(revived).toEqual(rustEncoded); + }); +}); diff --git a/rivetkit-typescript/packages/rivetkit/tests/cjs-no-import-meta.test.ts b/rivetkit-typescript/packages/rivetkit/tests/cjs-no-import-meta.test.ts new file mode 100644 index 0000000000..e6a49bf106 --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/tests/cjs-no-import-meta.test.ts @@ -0,0 +1,96 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; + +// Regression guard for rivetkit issue #2: +// "import.meta.url in CJS chunks crashes ts-node/CJS loaders" +// +// Background: tsup emits CommonJS (.cjs) bundles for the `require` conditions +// of every package export. `import.meta` is an ESM-only syntactic form; when a +// raw `import.meta.url` leaks into a .cjs file, loading that file under a +// CommonJS loader (ts-node, plain `require()`, older bundlers) throws a +// SyntaxError ("Cannot use 'import.meta' outside a module"). The fix is +// `shims: true` in the shared tsup config (tsup.base.ts), which rewrites +// `import.meta.url` into a CJS-safe shim (e.g. `new URL(\`file:${__filename}\`).href`). +// +// This test statically scans every produced .cjs file in the built dist/tsup +// output and asserts that none of them contains the literal `import.meta.url`. +// It directly encodes the original failure mode: if `shims` regresses (or a new +// reachable usage slips past tree-shaking), the offending bundle will contain +// the raw token and this test will fail before it ever crashes a CJS consumer. +// +// Approach chosen: static build-output assertion (the lightest reliable check). +// It requires the package to be built. If dist/tsup has not been built yet we +// throw with an actionable message rather than silently passing, so a missing +// build is never mistaken for "no occurrences found". + +const TEST_DIR = dirname(fileURLToPath(import.meta.url)); +const PACKAGE_DIR = resolve(TEST_DIR, ".."); +const DIST_TSUP_DIR = resolve(PACKAGE_DIR, "dist", "tsup"); + +const FORBIDDEN_TOKEN = "import.meta.url"; + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function collectCjsFiles(root: string): string[] { + const results: string[] = []; + const stack: string[] = [root]; + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) continue; + for (const entry of readdirSync(current, { withFileTypes: true })) { + const fullPath = resolve(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile() && entry.name.endsWith(".cjs")) { + results.push(fullPath); + } + } + } + return results; +} + +describe("rivetkit CJS bundles are free of raw import.meta (issue #2)", () => { + test("no built .cjs file contains a raw import.meta.url token", () => { + if (!isDirectory(DIST_TSUP_DIR)) { + throw new Error( + `Expected built CJS output at ${DIST_TSUP_DIR} but it does not exist. ` + + `Run \`pnpm run build\` in ${PACKAGE_DIR} before running this regression guard.`, + ); + } + + const cjsFiles = collectCjsFiles(DIST_TSUP_DIR); + + // Sanity: the build must have produced at least one CJS bundle. If it + // produced none, the scan below would be vacuously green, which would + // silently mask a regression. + expect( + cjsFiles.length, + `Expected at least one .cjs bundle under ${DIST_TSUP_DIR}; found none. ` + + `The build may be incomplete.`, + ).toBeGreaterThan(0); + + const offenders: string[] = []; + for (const file of cjsFiles) { + const contents = readFileSync(file, "utf8"); + if (contents.includes(FORBIDDEN_TOKEN)) { + offenders.push(file.slice(PACKAGE_DIR.length + 1)); + } + } + + expect( + offenders, + `Found raw \`${FORBIDDEN_TOKEN}\` in ${offenders.length} built CJS file(s). ` + + `import.meta is ESM-only and crashes CommonJS/ts-node loaders. ` + + `Ensure \`shims: true\` remains set in tsup.base.ts. Offending files:\n` + + offenders.map((f) => ` - ${f}`).join("\n"), + ).toEqual([]); + }); +}); diff --git a/rivetkit-typescript/packages/rivetkit/tests/driver/actor-agent-os.test.ts b/rivetkit-typescript/packages/rivetkit/tests/driver/actor-agent-os.test.ts deleted file mode 100644 index 68d81e9d1e..0000000000 --- a/rivetkit-typescript/packages/rivetkit/tests/driver/actor-agent-os.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { createRequire } from "node:module"; -import { describe, expect, test } from "vitest"; -import { describeDriverMatrix } from "./shared-matrix"; -import { setupDriverTest } from "./shared-utils"; - -const require = createRequire(import.meta.url); -const hasAgentOsCore = (() => { - try { - require.resolve("@rivet-dev/agent-os-core"); - return true; - } catch { - return false; - } -})(); - -describeDriverMatrix("Actor Agent Os", (driverTestConfig) => { - describe.skipIf(driverTestConfig.skip?.agentOs || !hasAgentOsCore)( - "Actor agentOS Tests", - () => { - // --- Filesystem --- - - test("writeFile and readFile round-trip", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `fs-${crypto.randomUUID()}`, - ]); - - await actor.writeFile("/home/user/hello.txt", "hello world"); - const data = await actor.readFile("/home/user/hello.txt"); - expect(new TextDecoder().decode(data)).toBe("hello world"); - }, 60_000); - - test("mkdir and readdir", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `dir-${crypto.randomUUID()}`, - ]); - - await actor.mkdir("/home/user/subdir"); - await actor.writeFile("/home/user/subdir/a.txt", "a"); - await actor.writeFile("/home/user/subdir/b.txt", "b"); - const entries = await actor.readdir("/home/user/subdir"); - const filtered = entries.filter( - (e: string) => e !== "." && e !== "..", - ); - expect(filtered.sort()).toEqual(["a.txt", "b.txt"]); - }, 60_000); - - test("stat returns file metadata", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `stat-${crypto.randomUUID()}`, - ]); - - await actor.writeFile("/home/user/stat-test.txt", "content"); - const s = await actor.stat("/home/user/stat-test.txt"); - expect(s.isDirectory).toBe(false); - expect(s.size).toBe(7); - }, 60_000); - - test("exists returns true for existing file", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `exists-${crypto.randomUUID()}`, - ]); - - await actor.writeFile("/home/user/exists.txt", "x"); - expect(await actor.exists("/home/user/exists.txt")).toBe(true); - expect(await actor.exists("/home/user/nope.txt")).toBe(false); - }, 60_000); - - test("move renames a file", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `move-${crypto.randomUUID()}`, - ]); - - await actor.writeFile("/home/user/old.txt", "data"); - await actor.move("/home/user/old.txt", "/home/user/new.txt"); - expect(await actor.exists("/home/user/old.txt")).toBe(false); - expect(await actor.exists("/home/user/new.txt")).toBe(true); - }, 60_000); - - test("deleteFile removes a file", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `del-${crypto.randomUUID()}`, - ]); - - await actor.writeFile("/home/user/todelete.txt", "gone"); - await actor.deleteFile("/home/user/todelete.txt"); - expect(await actor.exists("/home/user/todelete.txt")).toBe( - false, - ); - }, 60_000); - - test("writeFiles and readFiles batch operations", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `batch-${crypto.randomUUID()}`, - ]); - - const writeResults = await actor.writeFiles([ - { path: "/home/user/batch-a.txt", content: "aaa" }, - { path: "/home/user/batch-b.txt", content: "bbb" }, - ]); - expect(writeResults.every((r: any) => r.success)).toBe(true); - - const readResults = await actor.readFiles([ - "/home/user/batch-a.txt", - "/home/user/batch-b.txt", - ]); - expect(new TextDecoder().decode(readResults[0].content)).toBe( - "aaa", - ); - expect(new TextDecoder().decode(readResults[1].content)).toBe( - "bbb", - ); - }, 60_000); - - test("readdirRecursive lists nested files", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `recursive-${crypto.randomUUID()}`, - ]); - - await actor.mkdir("/home/user/rdir"); - await actor.mkdir("/home/user/rdir/sub"); - await actor.writeFile("/home/user/rdir/top.txt", "t"); - await actor.writeFile("/home/user/rdir/sub/deep.txt", "d"); - const entries = await actor.readdirRecursive("/home/user/rdir"); - const paths = entries.map((e: any) => e.path); - expect(paths).toContain("/home/user/rdir/top.txt"); - expect(paths).toContain("/home/user/rdir/sub"); - expect(paths).toContain("/home/user/rdir/sub/deep.txt"); - }, 60_000); - - // --- Process execution --- - - test("exec runs a command and returns output", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `exec-${crypto.randomUUID()}`, - ]); - - const result = await actor.exec("echo hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("hello"); - }, 60_000); - - test("spawn and waitProcess", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `spawn-${crypto.randomUUID()}`, - ]); - - // Write a script that exits with code 42. - await actor.writeFile("/tmp/exit42.js", "process.exit(42);"); - - const { pid } = await actor.spawn("node", ["/tmp/exit42.js"]); - expect(typeof pid).toBe("number"); - - const exitCode = await actor.waitProcess(pid); - expect(exitCode).toBe(42); - }, 60_000); - - test("listProcesses returns spawned processes", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `list-proc-${crypto.randomUUID()}`, - ]); - - // Write a long-running script. - await actor.writeFile( - "/tmp/long.js", - "setTimeout(() => {}, 30000);", - ); - - const { pid } = await actor.spawn("node", ["/tmp/long.js"]); - const procs = await actor.listProcesses(); - expect(procs.some((p: any) => p.pid === pid)).toBe(true); - - await actor.killProcess(pid); - }, 60_000); - - test("killProcess terminates a running process", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `kill-${crypto.randomUUID()}`, - ]); - - await actor.writeFile( - "/tmp/hang.js", - "setTimeout(() => {}, 60000);", - ); - - const { pid } = await actor.spawn("node", ["/tmp/hang.js"]); - await actor.killProcess(pid); - const exitCode = await actor.waitProcess(pid); - // SIGKILL results in non-zero exit code. - expect(exitCode).not.toBe(0); - }, 60_000); - - // --- Network --- - - test("vmFetch proxies request to VM service", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `fetch-${crypto.randomUUID()}`, - ]); - - // Write and spawn a simple HTTP server inside the VM. - await actor.writeFile( - "/tmp/server.js", - ` -const http = require("http"); -const server = http.createServer((req, res) => { - res.writeHead(200, { "Content-Type": "text/plain" }); - res.end("vm-response"); -}); -server.listen(9876, "127.0.0.1", () => { - console.log("listening"); -}); -`, - ); - await actor.spawn("node", ["/tmp/server.js"]); - - // Wait for server to start. - await new Promise((r) => setTimeout(r, 2000)); - - const result = await actor.vmFetch( - 9876, - "http://127.0.0.1:9876/test", - ); - expect(result.status).toBe(200); - expect(new TextDecoder().decode(result.body)).toBe( - "vm-response", - ); - }, 60_000); - - // --- Cron --- - - test("scheduleCron and listCronJobs", async (c) => { - const { client } = await setupDriverTest(c, { - ...driverTestConfig, - useRealTimers: true, - }); - const actor = client.agentOsTestActor.getOrCreate([ - `cron-${crypto.randomUUID()}`, - ]); - - const { id } = await actor.scheduleCron({ - schedule: "* * * * *", - action: { type: "exec", command: "echo cron-tick" }, - }); - expect(typeof id).toBe("string"); - - const jobs = await actor.listCronJobs(); - expect(jobs.some((j: any) => j.id === id)).toBe(true); - - await actor.cancelCronJob(id); - const jobsAfter = await actor.listCronJobs(); - expect(jobsAfter.some((j: any) => j.id === id)).toBe(false); - }, 60_000); - }, - ); -}); diff --git a/rivetkit-typescript/packages/rivetkit/tests/driver/shared-types.ts b/rivetkit-typescript/packages/rivetkit/tests/driver/shared-types.ts index ef32df54a3..a31d8019c3 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/driver/shared-types.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/driver/shared-types.ts @@ -7,7 +7,6 @@ export interface SkipTests { schedule?: boolean; sleep?: boolean; hibernation?: boolean; - agentOs?: boolean; } export interface DriverTestFeatures { diff --git a/rivetkit-typescript/packages/rivetkit/tests/fixtures/native-plugin-runtime-server.ts b/rivetkit-typescript/packages/rivetkit/tests/fixtures/native-plugin-runtime-server.ts new file mode 100644 index 0000000000..7a6efcc2e7 --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/tests/fixtures/native-plugin-runtime-server.ts @@ -0,0 +1,66 @@ +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { getEnginePath } from "@rivetkit/engine-cli"; +import { + type ActorFactoryHandle, + actor, + type CoreRuntime, + setup, +} from "../../src/mod"; +import { buildNativeRegistry } from "../../src/registry/native"; + +const fixtureDir = dirname(fileURLToPath(import.meta.url)); +const repoEngineBinary = resolve( + fixtureDir, + "../../../../../target/debug/rivet-engine", +); +const pluginPath = process.env.RIVETKIT_TEST_NATIVE_PLUGIN_PATH; + +if (!pluginPath) { + throw new Error("RIVETKIT_TEST_NATIVE_PLUGIN_PATH is required"); +} + +function resolveEngineBinaryPath(): string { + if (existsSync(repoEngineBinary)) { + return repoEngineBinary; + } + + return getEnginePath(); +} + +const nativePluginActor = actor({ + actions: {}, +}); +nativePluginActor.nativeFactoryBuilder = ( + runtime: CoreRuntime, +): ActorFactoryHandle => { + if (!runtime.createNativePluginFactory) { + throw new Error("native plugin factories require the NAPI runtime"); + } + + return runtime.createNativePluginFactory({ + pluginPath, + configJson: process.env.RIVETKIT_TEST_NATIVE_PLUGIN_CONFIG_JSON ?? "{}", + sidecarPath: process.env.RIVETKIT_TEST_NATIVE_PLUGIN_SIDECAR_PATH ?? "", + }); +}; + +const registry = setup({ + use: { + nativePluginActor, + }, + endpoint: process.env.RIVETKIT_TEST_ENDPOINT ?? "http://127.0.0.1:6642", + namespace: process.env.RIVET_NAMESPACE ?? "default", + token: process.env.RIVET_TOKEN ?? "dev", + envoy: { + poolName: process.env.RIVETKIT_TEST_POOL_NAME ?? "default", + }, +}); + +const { registry: nativeRegistry, serveConfig } = await buildNativeRegistry( + registry.parseConfig(), +); +serveConfig.engineBinaryPath = resolveEngineBinaryPath(); + +await nativeRegistry.serve(serveConfig); diff --git a/rivetkit-typescript/packages/rivetkit/tests/native-plugin-runtime.test.ts b/rivetkit-typescript/packages/rivetkit/tests/native-plugin-runtime.test.ts new file mode 100644 index 0000000000..59b96e3420 --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/tests/native-plugin-runtime.test.ts @@ -0,0 +1,321 @@ +import { type ChildProcess, execFile, spawn } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import getPort from "get-port"; +import { afterEach, describe, expect, test } from "vitest"; +import { createClient } from "../src/client/mod"; + +const TEST_DIR = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_PATH = join( + TEST_DIR, + "fixtures", + "native-plugin-runtime-server.ts", +); +const R6_ROOT = resolve(TEST_DIR, "../../../.."); +const RUST_WORKSPACE = join(R6_ROOT, "rivetkit-rust"); +const TEST_PLUGIN_PATH = join( + R6_ROOT, + "target", + "debug", + process.platform === "darwin" + ? "librivet_actor_test_plugin.dylib" + : process.platform === "win32" + ? "rivet_actor_test_plugin.dll" + : "librivet_actor_test_plugin.so", +); +const NAMESPACE = "default"; +const TOKEN = "dev"; +const execFileAsync = promisify(execFile); +let runtimeLogs = { + stdout: "", + stderr: "", +}; + +function childOutput(): string { + return [runtimeLogs.stdout, runtimeLogs.stderr].filter(Boolean).join("\n"); +} + +async function waitForHealth( + child: ChildProcess, + endpoint: string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error( + `native plugin runtime exited before health check passed:\n${childOutput()}`, + ); + } + + try { + const response = await fetch(`${endpoint}/health`); + if (response.ok) { + return; + } + } catch {} + + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + throw new Error( + `timed out waiting for native plugin runtime health:\n${childOutput()}`, + ); +} + +async function waitForEnvoy( + child: ChildProcess, + endpoint: string, + poolName: string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error( + `native plugin runtime exited before envoy registration:\n${childOutput()}`, + ); + } + + const response = await fetch( + `${endpoint}/envoys?namespace=${encodeURIComponent(NAMESPACE)}&name=${encodeURIComponent(poolName)}`, + { + headers: { + Authorization: `Bearer ${TOKEN}`, + }, + }, + ); + + if (response.ok) { + const body = (await response.json()) as { + envoys: Array<{ envoy_key: string }>; + }; + + if (body.envoys.length > 0) { + return; + } + } + + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + throw new Error( + `timed out waiting for envoy registration in pool ${poolName}\n${childOutput()}`, + ); +} + +async function upsertNormalRunnerConfig( + endpoint: string, + poolName: string, +): Promise { + const datacentersResponse = await fetch( + `${endpoint}/datacenters?namespace=${encodeURIComponent(NAMESPACE)}`, + { + headers: { + Authorization: `Bearer ${TOKEN}`, + }, + }, + ); + + if (!datacentersResponse.ok) { + throw new Error( + `failed to list datacenters: ${datacentersResponse.status} ${await datacentersResponse.text()}\n${childOutput()}`, + ); + } + + const datacentersBody = (await datacentersResponse.json()) as { + datacenters: Array<{ name: string }>; + }; + const datacenter = datacentersBody.datacenters[0]?.name; + + if (!datacenter) { + throw new Error(`engine returned no datacenters\n${childOutput()}`); + } + + const response = await fetch( + `${endpoint}/runner-configs/${encodeURIComponent(poolName)}?namespace=${encodeURIComponent(NAMESPACE)}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + datacenters: { + [datacenter]: { + normal: {}, + }, + }, + }), + }, + ); + + if (response.ok) { + return; + } + + throw new Error( + `failed to upsert runner config ${poolName}: ${response.status} ${await response.text()}\n${childOutput()}`, + ); +} + +async function waitForActorReady( + callback: () => Promise, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + return await callback(); + } catch (error) { + lastError = error; + const errorCode = + typeof error === "object" && + error !== null && + "code" in error && + typeof error.code === "string" + ? error.code + : undefined; + if ( + !( + (errorCode && + /^(no_envoys|actor_ready_timeout|actor_wake_retries_exceeded|service_unavailable)$/.test( + errorCode, + )) || + (error instanceof Error && + /(no_envoys|actor_ready_timeout|actor_wake_retries_exceeded|service_unavailable)/.test( + error.message, + )) + ) + ) { + throw error; + } + } + + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + throw lastError instanceof Error + ? lastError + : new Error("timed out waiting for actor to become ready"); +} + +async function stopRuntime(child: ChildProcess): Promise { + if (child.exitCode !== null) { + return; + } + + child.kill("SIGINT"); + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (child.exitCode === null) { + child.kill("SIGKILL"); + } + }, 5_000); + + child.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); +} + +async function buildNativePluginFixture(): Promise { + await execFileAsync("cargo", ["build", "-p", "rivet-actor-test-plugin"], { + cwd: RUST_WORKSPACE, + env: process.env, + maxBuffer: 1024 * 1024 * 20, + }); + return TEST_PLUGIN_PATH; +} + +describe.sequential("native plugin runtime integration", () => { + let runtime: ChildProcess | undefined; + + afterEach(async () => { + if (runtime) { + await stopRuntime(runtime); + runtime = undefined; + } + }, 30_000); + + test("registers a native plugin actor through the TS runtime", async () => { + const pluginPath = await buildNativePluginFixture(); + const poolName = "native-plugin"; + const port = await getPort({ host: "127.0.0.1" }); + const endpoint = `http://127.0.0.1:${port}`; + const configJson = JSON.stringify({ + package: "@rivetkit/native-plugin-test-shape", + sidecar: true, + }); + const sidecarPath = "/tmp/rivetkit-native-plugin-sidecar"; + runtimeLogs = { stdout: "", stderr: "" }; + runtime = spawn(process.execPath, ["--import", "tsx", FIXTURE_PATH], { + cwd: dirname(TEST_DIR), + env: { + ...process.env, + RIVET_TOKEN: TOKEN, + RIVET_NAMESPACE: NAMESPACE, + RIVETKIT_TEST_ENDPOINT: endpoint, + RIVETKIT_TEST_POOL_NAME: poolName, + RIVETKIT_TEST_NATIVE_PLUGIN_PATH: pluginPath, + RIVETKIT_TEST_NATIVE_PLUGIN_CONFIG_JSON: configJson, + RIVETKIT_TEST_NATIVE_PLUGIN_SIDECAR_PATH: sidecarPath, + RIVETKIT_STORAGE_PATH: mkdtempSync( + join(tmpdir(), "rivetkit-native-plugin-test-"), + ), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + runtime.stdout?.on("data", (chunk) => { + runtimeLogs.stdout += chunk.toString(); + }); + runtime.stderr?.on("data", (chunk) => { + runtimeLogs.stderr += chunk.toString(); + }); + + await waitForHealth(runtime, endpoint, 90_000); + await upsertNormalRunnerConfig(endpoint, poolName); + await waitForEnvoy(runtime, endpoint, poolName, 30_000); + + const client = createClient({ + endpoint, + token: TOKEN, + namespace: NAMESPACE, + poolName, + disableMetadataLookup: true, + }) as any; + + const handle = await waitForActorReady( + () => + client.nativePluginActor.create([ + `native-plugin-${crypto.randomUUID()}`, + ]), + 30_000, + ); + + expect( + await waitForActorReady( + () => handle.factory_config_report(), + 30_000, + ), + ).toEqual({ + configJson, + sidecarPath, + }); + expect(await waitForActorReady(() => handle.increment(), 30_000)).toBe( + 1, + ); + expect(await waitForActorReady(() => handle.get(), 30_000)).toBe(1); + + await client.dispose(); + }, 120_000); +}); diff --git a/rivetkit-typescript/packages/rivetkit/tsconfig.json b/rivetkit-typescript/packages/rivetkit/tsconfig.json index 4ba9ab3b75..f32f5b673f 100644 --- a/rivetkit-typescript/packages/rivetkit/tsconfig.json +++ b/rivetkit-typescript/packages/rivetkit/tsconfig.json @@ -11,8 +11,7 @@ "rivetkit/db/drizzle": ["./src/db/drizzle.ts"], "rivetkit/dynamic": ["./src/dynamic/mod.ts"], "rivetkit/errors": ["./src/actor/errors.ts"], - "rivetkit/utils": ["./src/utils.ts"], - "rivetkit/agent-os": ["./src/agent-os/index.ts"] + "rivetkit/utils": ["./src/utils.ts"] } }, "include": [ diff --git a/rivetkit-typescript/packages/rivetkit/tsup.config.ts b/rivetkit-typescript/packages/rivetkit/tsup.config.ts index 2e22dd42ab..bc2a55f257 100644 --- a/rivetkit-typescript/packages/rivetkit/tsup.config.ts +++ b/rivetkit-typescript/packages/rivetkit/tsup.config.ts @@ -19,7 +19,6 @@ export default defineConfig({ "@rivetkit/traces/encoding", "@rivetkit/traces/otlp", "@rivetkit/workflow-engine", - "@rivet-dev/agent-os-core", ]; }, define: { diff --git a/scripts/publish/src/lib/version.ts b/scripts/publish/src/lib/version.ts index fd58d289b6..d3752cd15b 100644 --- a/scripts/publish/src/lib/version.ts +++ b/scripts/publish/src/lib/version.ts @@ -61,6 +61,7 @@ const PUBLISHED_RUST_WORKSPACE_DEPS = new Set([ "rivetkit-client", "rivetkit-core", "rivetkit-engine-process", + "rivet-actor-plugin-abi", ]); export interface BumpOptions { diff --git a/website/src/content/docs/agent-os/system-prompt.mdx b/website/src/content/docs/agent-os/system-prompt.mdx index 68d8adc49e..dd91d54b0f 100644 --- a/website/src/content/docs/agent-os/system-prompt.mdx +++ b/website/src/content/docs/agent-os/system-prompt.mdx @@ -6,7 +6,7 @@ skill: true agentOS automatically injects a system prompt into every agent session that describes the VM environment and available tools. The prompt is additive and never replaces the agent's own instructions (CLAUDE.md, AGENTS.md, etc.). -The base prompt lives at `/etc/agentos/instructions.md` inside the VM. +The prompt is assembled and injected at the start of each session rather than baked into the VM image, so it always reflects the tools currently exposed to that session. ## Customization From 8633327f4ec62e49de87e877eff3811c547434cd Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 26 Jun 2026 10:17:17 -0700 Subject: [PATCH 2/2] fix(rivetkit): lengthen native plugin actor timeouts --- .../rivetkit-napi/src/actor_factory.rs | 22 ++++++++++++++++--- .../rivetkit-napi/tests/actor_factory.rs | 15 +++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs b/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs index cebbb341b8..832f588d9c 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs @@ -301,6 +301,24 @@ pub struct NapiNativePluginOptions { pub sidecar_path: Option, } +fn native_plugin_actor_config() -> ActorConfig { + let mut config = ActorConfig::default(); + config.has_database = true; + // Native-plugin (agent-os) actors run multi-second VM boots, long agent + // turns, and keep live event streams open. The stock defaults (2.5s + // connection liveness, 30s sleep, 60s action, 5s connect) drop connections + // mid-session and race live `sessionEvent` subscriptions. + let long = Duration::from_secs(3600); + config.connection_liveness_timeout = long; + config.sleep_timeout = long; + config.action_timeout = long; + config.on_connect_timeout = long; + config.on_before_connect_timeout = long; + config.create_conn_state_timeout = long; + config.create_vars_timeout = long; + config +} + #[napi] pub struct NapiActorFactory { #[allow(dead_code)] @@ -358,13 +376,11 @@ impl NapiActorFactory { #[napi(factory)] pub fn from_native_plugin(options: NapiNativePluginOptions) -> napi::Result { crate::init_tracing(None); - let mut config = ActorConfig::default(); - config.has_database = true; let factory = rivetkit_core::build_native_plugin_factory( std::path::Path::new(&options.plugin_path), options.config_json.as_deref().unwrap_or("{}"), options.sidecar_path.as_deref().unwrap_or(""), - config, + native_plugin_actor_config(), ) .map_err(napi_anyhow_error)?; let inner = Arc::new(factory); diff --git a/rivetkit-typescript/packages/rivetkit-napi/tests/actor_factory.rs b/rivetkit-typescript/packages/rivetkit-napi/tests/actor_factory.rs index ad8d18aea4..c045f0c8cc 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/tests/actor_factory.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/tests/actor_factory.rs @@ -125,3 +125,18 @@ mod moved_tests { assert!(logs.contains("parse_err")); } } + +#[test] +fn native_plugin_actor_config_uses_long_finite_runtime_timeouts() { + let config = native_plugin_actor_config(); + let long = Duration::from_secs(3600); + + assert!(config.has_database); + assert_eq!(config.connection_liveness_timeout, long); + assert_eq!(config.sleep_timeout, long); + assert_eq!(config.action_timeout, long); + assert_eq!(config.on_connect_timeout, long); + assert_eq!(config.on_before_connect_timeout, long); + assert_eq!(config.create_conn_state_timeout, long); + assert_eq!(config.create_vars_timeout, long); +}