diff --git a/Cargo.lock b/Cargo.lock
index c8706702..eeea0f35 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -24,7 +24,16 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [
- "gimli",
+ "gimli 0.32.3",
+]
+
+[[package]]
+name = "addr2line"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a"
+dependencies = [
+ "gimli 0.33.0",
]
[[package]]
@@ -716,11 +725,11 @@ version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
- "addr2line",
+ "addr2line 0.25.1",
"cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)",
"libc",
"miniz_oxide 0.8.9",
- "object",
+ "object 0.37.3",
"rustc-demangle",
"windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
]
@@ -935,6 +944,9 @@ name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+dependencies = [
+ "allocator-api2",
+]
[[package]]
name = "bytecount"
@@ -1333,6 +1345,15 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
+[[package]]
+name = "cobs"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
+dependencies = [
+ "thiserror 2.0.20",
+]
+
[[package]]
name = "color-eyre"
version = "0.6.5"
@@ -1521,6 +1542,15 @@ dependencies = [
"libm",
]
+[[package]]
+name = "cpp_demangle"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def"
+dependencies = [
+ "cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
[[package]]
name = "cpu-time"
version = "1.0.0"
@@ -1555,6 +1585,152 @@ dependencies = [
"libc",
]
+[[package]]
+name = "cranelift-assembler-x64"
+version = "0.136.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "658e8623bea025d142fb11c299b8c6572ffe9fd663b52887c4dc124735cc130b"
+dependencies = [
+ "cranelift-assembler-x64-meta",
+]
+
+[[package]]
+name = "cranelift-assembler-x64-meta"
+version = "0.136.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4b12b8c55c31c3d8598c2de9a657c3b978f905f6886c43e02216e0b15fe45a3"
+dependencies = [
+ "cranelift-srcgen",
+]
+
+[[package]]
+name = "cranelift-bforest"
+version = "0.136.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "894b984f82c5a36f05fdd4973fbf4d37d69cd5cdf8c16e8ffe3af4a9cdfe193c"
+dependencies = [
+ "cranelift-entity",
+ "wasmtime-internal-core",
+]
+
+[[package]]
+name = "cranelift-bitset"
+version = "0.136.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a1e4c34abe94193ada09f821f474be87ac8a1fbbacd87058571ef04c1bf4009"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "wasmtime-internal-core",
+]
+
+[[package]]
+name = "cranelift-codegen"
+version = "0.136.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63826cef0bf70e61d56cc930aa4ee423638f66dc3cfc10d683e73138a6d177b8"
+dependencies = [
+ "bumpalo",
+ "cranelift-assembler-x64",
+ "cranelift-bforest",
+ "cranelift-bitset",
+ "cranelift-codegen-meta",
+ "cranelift-codegen-shared",
+ "cranelift-control",
+ "cranelift-entity",
+ "cranelift-isle",
+ "gimli 0.33.0",
+ "hashbrown 0.17.1",
+ "libm",
+ "log",
+ "postcard",
+ "pulley-interpreter",
+ "regalloc2",
+ "rustc-hash 2.1.3",
+ "serde",
+ "serde_derive",
+ "sha2 0.10.9",
+ "smallvec 1.16.1",
+ "target-lexicon",
+ "wasmtime-internal-core",
+]
+
+[[package]]
+name = "cranelift-codegen-meta"
+version = "0.136.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26de7849112293681a19f7525c689e1d73dc6eb2687355170fb7458c4da8d353"
+dependencies = [
+ "cranelift-assembler-x64-meta",
+ "cranelift-codegen-shared",
+ "cranelift-srcgen",
+ "heck",
+ "pulley-interpreter",
+]
+
+[[package]]
+name = "cranelift-codegen-shared"
+version = "0.136.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df315a8b1cfd6e620971e3d4249bd2c4528167717e8f48a6317a87c55891bcb8"
+
+[[package]]
+name = "cranelift-control"
+version = "0.136.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c3fff7610fb17f228f16598e1a044db79a12806ece7496ff2c69cf1a27a79a5"
+dependencies = [
+ "arbitrary",
+]
+
+[[package]]
+name = "cranelift-entity"
+version = "0.136.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6158b1ac381792ad11e670abbcd15db8b1737e84bfa8a7f2bd7eb41388a92530"
+dependencies = [
+ "cranelift-bitset",
+ "serde",
+ "serde_derive",
+ "wasmtime-internal-core",
+]
+
+[[package]]
+name = "cranelift-frontend"
+version = "0.136.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "099361a0919067086dcde624a281ce719834cebf8544d6c24646f5e924107234"
+dependencies = [
+ "cranelift-codegen",
+ "hashbrown 0.17.1",
+ "log",
+ "smallvec 1.16.1",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cranelift-isle"
+version = "0.136.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a2f30ea3fd5a11f7d3473976d3b62b5b3f2b62fa943c85e0331d88bf505082c"
+
+[[package]]
+name = "cranelift-native"
+version = "0.136.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "397508a8d817dc74019603ac5a4c4e40b4df627e5aed4f2f8088345d4c01d401"
+dependencies = [
+ "cranelift-codegen",
+ "libc",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cranelift-srcgen"
+version = "0.136.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4944ef05513e1c1a85a945cf8f29d95ddea8bcbb19e0bfd4e71063450967420"
+
[[package]]
name = "crc"
version = "3.4.0"
@@ -2288,6 +2464,18 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
+[[package]]
+name = "embedded-io"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
+
+[[package]]
+name = "embedded-io"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
+
[[package]]
name = "emojis"
version = "0.9.0"
@@ -2939,7 +3127,7 @@ dependencies = [
"libc",
"log",
"rustversion",
- "windows-link 0.1.3",
+ "windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
"windows-result 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)",
]
@@ -3029,6 +3217,18 @@ version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+[[package]]
+name = "gimli"
+version = "0.33.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c"
+dependencies = [
+ "fnv",
+ "hashbrown 0.16.1 (registry+https://github.com/rust-lang/crates.io-index)",
+ "indexmap 2.14.2",
+ "stable_deref_trait",
+]
+
[[package]]
name = "gix"
version = "0.79.0"
@@ -3897,6 +4097,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)",
+ "serde",
+ "serde_core",
]
[[package]]
@@ -4885,6 +5087,12 @@ dependencies = [
"goblin",
]
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
[[package]]
name = "lettre"
version = "0.11.23"
@@ -4914,9 +5122,9 @@ dependencies = [
[[package]]
name = "libc"
-version = "0.2.189"
+version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libdbus-sys"
@@ -5118,6 +5326,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "mach2"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
+
[[package]]
name = "macroific"
version = "2.0.0"
@@ -6398,6 +6612,15 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+[[package]]
+name = "memfd"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90"
+dependencies = [
+ "rustix",
+]
+
[[package]]
name = "memmap2"
version = "0.9.11"
@@ -6542,7 +6765,7 @@ dependencies = [
"bitflags 2.13.2",
"combine",
"libc",
- "mach2",
+ "mach2 0.4.3",
"nix 0.30.1",
"sysctl",
"thiserror 2.0.20",
@@ -7034,6 +7257,18 @@ dependencies = [
"memchr 2.8.3",
]
+[[package]]
+name = "object"
+version = "0.40.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c"
+dependencies = [
+ "crc32fast",
+ "hashbrown 0.17.1",
+ "indexmap 2.14.2",
+ "memchr 2.8.3",
+]
+
[[package]]
name = "octos-agent"
version = "2.0.3-rc.13"
@@ -7636,9 +7871,9 @@ dependencies = [
[[package]]
name = "octosense-app-contract"
-version = "1.6.0"
+version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec46b54da6c411fce07e863af8ad36bb8394319deea861219e2815ad94e04f91"
+checksum = "5c3e8bb885e67a1290abdf6d3a71bf59b2707fa7e67c9b833eae6843355e86e5"
dependencies = [
"blake3",
"serde",
@@ -7649,7 +7884,7 @@ dependencies = [
[[package]]
name = "octosense-app-hub"
version = "0.1.0"
-source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=b1109a397ae45349546d50889a1706f6c58fb923#b1109a397ae45349546d50889a1706f6c58fb923"
+source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=2e5768964f15916cdb66cc6824f7671dafc6445a#2e5768964f15916cdb66cc6824f7671dafc6445a"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -7668,7 +7903,7 @@ dependencies = [
[[package]]
name = "octosense-app-hub-app"
version = "0.1.0"
-source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=b1109a397ae45349546d50889a1706f6c58fb923#b1109a397ae45349546d50889a1706f6c58fb923"
+source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=2e5768964f15916cdb66cc6824f7671dafc6445a#2e5768964f15916cdb66cc6824f7671dafc6445a"
dependencies = [
"makepad-app-module",
"makepad-widgets",
@@ -7696,7 +7931,7 @@ dependencies = [
[[package]]
name = "octosense-app-policy"
version = "0.1.0"
-source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=b1109a397ae45349546d50889a1706f6c58fb923#b1109a397ae45349546d50889a1706f6c58fb923"
+source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=2e5768964f15916cdb66cc6824f7671dafc6445a#2e5768964f15916cdb66cc6824f7671dafc6445a"
dependencies = [
"makepad-widgets",
"octosense-app-contract",
@@ -7718,7 +7953,7 @@ dependencies = [
[[package]]
name = "octosense-appstore"
version = "0.1.0"
-source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=b1109a397ae45349546d50889a1706f6c58fb923#b1109a397ae45349546d50889a1706f6c58fb923"
+source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=2e5768964f15916cdb66cc6824f7671dafc6445a#2e5768964f15916cdb66cc6824f7671dafc6445a"
dependencies = [
"makepad-app-module",
"makepad-widgets",
@@ -7740,6 +7975,13 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "octosense-guest"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
[[package]]
name = "octosense-home"
version = "0.1.0"
@@ -7949,6 +8191,7 @@ dependencies = [
"octosense-news-service",
"octosense-oauth-service",
"octosense-reference",
+ "octosense-wasm-host",
"rinx",
"serde",
"serde_json",
@@ -7979,6 +8222,18 @@ dependencies = [
"url",
]
+[[package]]
+name = "octosense-wasm-host"
+version = "0.1.0"
+dependencies = [
+ "libc",
+ "serde_json",
+ "sha2 0.10.9",
+ "wasmlab-functions",
+ "wasmtime",
+ "wat",
+]
+
[[package]]
name = "ohos-sys"
version = "0.2.2"
@@ -8475,6 +8730,18 @@ dependencies = [
"portable-atomic",
]
+[[package]]
+name = "postcard"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
+dependencies = [
+ "cobs",
+ "embedded-io 0.4.0",
+ "embedded-io 0.6.1",
+ "serde",
+]
+
[[package]]
name = "postscript"
version = "0.14.1"
@@ -8603,6 +8870,29 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
+[[package]]
+name = "pulley-interpreter"
+version = "49.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b66b7448cb92d709f2924de98b5ba5b5ce4ea08e6d6b9c3b403fcd88d122ca2"
+dependencies = [
+ "cranelift-bitset",
+ "log",
+ "pulley-macros",
+ "wasmtime-internal-core",
+]
+
+[[package]]
+name = "pulley-macros"
+version = "49.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "185b9b4cd91f85110f48f8c0314757dfb2bb48c40f5d91055ea666cbbcbf317b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "pxfm"
version = "0.1.30"
@@ -8967,6 +9257,21 @@ dependencies = [
"syn 3.0.5",
]
+[[package]]
+name = "regalloc2"
+version = "0.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85"
+dependencies = [
+ "allocator-api2",
+ "bumpalo",
+ "hashbrown 0.17.1",
+ "log",
+ "rustc-hash 2.1.3",
+ "serde",
+ "smallvec 1.16.1",
+]
+
[[package]]
name = "regex"
version = "1.13.1"
@@ -10224,6 +10529,10 @@ name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
[[package]]
name = "serde"
@@ -10920,6 +11229,12 @@ dependencies = [
"xattr",
]
+[[package]]
+name = "target-lexicon"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
+
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -10953,6 +11268,15 @@ dependencies = [
"new_debug_unreachable",
]
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -12084,6 +12408,26 @@ dependencies = [
"unicode-ident 1.0.24",
]
+[[package]]
+name = "wasm-encoder"
+version = "0.258.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f64765e43c000fae008a48db8b288e175e237fc9ce20a26586836878c1bee89f"
+dependencies = [
+ "leb128fmt",
+ "wasmparser 0.258.3",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.261.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22"
+dependencies = [
+ "leb128fmt",
+ "wasmparser 0.261.0",
+]
+
[[package]]
name = "wasm-streams"
version = "0.4.2"
@@ -12128,6 +12472,244 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "wasmlab-functions"
+version = "0.1.0"
+dependencies = [
+ "octosense-guest",
+ "pulldown-cmark 0.13.4",
+ "serde",
+ "similar",
+ "strsim",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.258.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe9269dd817e0e7021bf534ccd9761bc2d64778b6708e41228b8439d75eaa0b1"
+dependencies = [
+ "bitflags 2.13.2",
+ "hashbrown 0.17.1",
+ "indexmap 2.14.2",
+ "semver",
+ "serde",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.261.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8"
+dependencies = [
+ "bitflags 2.13.2",
+ "indexmap 2.14.2",
+ "semver",
+]
+
+[[package]]
+name = "wasmprinter"
+version = "0.258.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14a457ba8670a3db3d357c1aeac3ed4fe378a76054a221789f0df0ea507e292b"
+dependencies = [
+ "anyhow",
+ "termcolor",
+ "wasmparser 0.258.3",
+]
+
+[[package]]
+name = "wasmtime"
+version = "49.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1801b9c9f91e16e352ed77656824e7c4556a7538dbfc1a50b37243f0e12ea045"
+dependencies = [
+ "addr2line 0.26.1",
+ "async-trait",
+ "bitflags 2.13.2",
+ "bumpalo",
+ "cc",
+ "futures",
+ "libc",
+ "log",
+ "mach2 0.6.0",
+ "memfd",
+ "object 0.40.0",
+ "once_cell 1.21.4",
+ "postcard",
+ "pulley-interpreter",
+ "rayon",
+ "rustix",
+ "serde",
+ "serde_derive",
+ "smallvec 1.16.1",
+ "target-lexicon",
+ "wasmparser 0.258.3",
+ "wasmtime-environ",
+ "wasmtime-internal-core",
+ "wasmtime-internal-cranelift",
+ "wasmtime-internal-fiber",
+ "wasmtime-internal-jit-debug",
+ "wasmtime-internal-jit-icache-coherence",
+ "wasmtime-internal-unwinder",
+ "wasmtime-internal-versioned-export-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "wasmtime-environ"
+version = "49.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f397969b8daf208e45120036be74956e99364ce916d5b5d67f9e9f0fe40e5477"
+dependencies = [
+ "anyhow",
+ "cpp_demangle",
+ "cranelift-bforest",
+ "cranelift-bitset",
+ "cranelift-entity",
+ "gimli 0.33.0",
+ "hashbrown 0.17.1",
+ "indexmap 2.14.2",
+ "log",
+ "object 0.40.0",
+ "postcard",
+ "rustc-demangle",
+ "semver",
+ "serde",
+ "serde_derive",
+ "sha2 0.10.9",
+ "smallvec 1.16.1",
+ "target-lexicon",
+ "wasm-encoder 0.258.3",
+ "wasmparser 0.258.3",
+ "wasmprinter",
+ "wasmtime-internal-component-util",
+ "wasmtime-internal-core",
+]
+
+[[package]]
+name = "wasmtime-internal-component-util"
+version = "49.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fe14bd5ea6444a09f6baa3865559c93c91158930e7fe6d127429a9372f6165"
+
+[[package]]
+name = "wasmtime-internal-core"
+version = "49.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2053fd1d90080e502ca4ea39d86c3528587a40220b9572a874c8b742c219a00c"
+dependencies = [
+ "hashbrown 0.17.1",
+ "libm",
+ "serde",
+]
+
+[[package]]
+name = "wasmtime-internal-cranelift"
+version = "49.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15e96d6c55401e68e40e3f74078ec96f92fe9b8a7e90e885fb87265e69eea559"
+dependencies = [
+ "cranelift-codegen",
+ "cranelift-control",
+ "cranelift-entity",
+ "cranelift-frontend",
+ "cranelift-native",
+ "gimli 0.33.0",
+ "itertools 0.14.0",
+ "log",
+ "object 0.40.0",
+ "pulley-interpreter",
+ "smallvec 1.16.1",
+ "target-lexicon",
+ "thiserror 2.0.20",
+ "wasmparser 0.258.3",
+ "wasmtime-environ",
+ "wasmtime-internal-core",
+ "wasmtime-internal-unwinder",
+ "wasmtime-internal-versioned-export-macros",
+]
+
+[[package]]
+name = "wasmtime-internal-fiber"
+version = "49.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b913875bab1b78fada489e55d5e0a10b53286fb4bb09f5657212f549c9af36c"
+dependencies = [
+ "cc",
+ "libc",
+ "rustix",
+ "wasmtime-environ",
+ "wasmtime-internal-versioned-export-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "wasmtime-internal-jit-debug"
+version = "49.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0356a1a09dee1239904e09e962456d9f6edfa5da61c5e3e1b0e4669710f48fba"
+dependencies = [
+ "cc",
+ "wasmtime-internal-versioned-export-macros",
+]
+
+[[package]]
+name = "wasmtime-internal-jit-icache-coherence"
+version = "49.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dead9a2d7b5d835698a2e1a32006e7d0b5711f4697b24bab1564bb2488e3d7a"
+dependencies = [
+ "libc",
+ "wasmtime-internal-core",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "wasmtime-internal-unwinder"
+version = "49.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69ffac8fc893de2958b263d9d7274cf4c531ba9ff582558348d4e28d97b8a0f2"
+dependencies = [
+ "cranelift-codegen",
+ "log",
+ "object 0.40.0",
+ "wasmtime-environ",
+]
+
+[[package]]
+name = "wasmtime-internal-versioned-export-macros"
+version = "49.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a20f0dcb6c6a006363d3f85b5c633675730487b0dc23726b2d40495872407fb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "wast"
+version = "261.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a"
+dependencies = [
+ "bumpalo",
+ "leb128fmt",
+ "memchr 2.8.3",
+ "unicode-width",
+ "wasm-encoder 0.261.0",
+]
+
+[[package]]
+name = "wat"
+version = "1.261.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f"
+dependencies = [
+ "wast",
+]
+
[[package]]
name = "wayland-backend"
version = "0.3.12"
diff --git a/Cargo.toml b/Cargo.toml
index 1bec3169..51d51847 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,6 +30,7 @@ members = [
"crates/l0-chat",
"crates/oauth-service",
"crates/markdown-editor",
+ "crates/wasm-host",
"apps/reference",
"apps/news/host-service",
"apps/mail/host-service",
@@ -49,6 +50,7 @@ exclude = [
"apps/appcard/tools",
"apps/appcard/vendor",
"apps/appcard/app/splash-native",
+ "apps/wasmlab/guest",
]
default-members = ["desktop"]
@@ -71,7 +73,7 @@ octosense-reference = { path = "apps/reference" }
makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false }
makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2" }
octosense-appcard = { path = "apps/appcard/module" }
-octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "b1109a397ae45349546d50889a1706f6c58fb923" }
+octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "2e5768964f15916cdb66cc6824f7671dafc6445a" }
makepad-calculator = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false }
makepad-clock = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false }
makepad-notes = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false }
@@ -106,17 +108,17 @@ octoscript-makepad = { git = "https://github.com/OctoSense-org/Octoscript-Makepa
octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "33dea2f1f3ad3f1346a219aa8cf6e91b31361e23" }
# --- App Hub's host service registry the Mail and llm services register
# into; the same revision as App Hub's app crate (a native app, above).
-octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "b1109a397ae45349546d50889a1706f6c58fb923" }
+octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "2e5768964f15916cdb66cc6824f7671dafc6445a" }
# The app policy (resolution, isolate settings, the Splash adapter) and the
# Hub client the Card runner resolves an app's policy with; the shell's
# glance tiles run cards under it (crates/shell/src/glance_card.rs).
-octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "b1109a397ae45349546d50889a1706f6c58fb923" }
-octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "b1109a397ae45349546d50889a1706f6c58fb923" }
+octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "2e5768964f15916cdb66cc6824f7671dafc6445a" }
+octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "2e5768964f15916cdb66cc6824f7671dafc6445a" }
# The app contract (ADR 0005), from crates.io by version: App Hub, the shell
# and Rinx all depend on `octosense-app-contract = "1"`, so Cargo links ONE
# 1.x for the whole build (Cargo.lock pins the exact version OctoSense ships)
# and App Hub and Rinx no longer need the same App Hub commit.
-octosense-app-contract = "1.6"
+octosense-app-contract = "1.7"
# --- octos: ONE source for every octos crate (reviewed octos#2715 at
# b0759a57: bounded ledger session-directory names, serialized peer purge
# against session writes, and provider transport diagnostics that omit
@@ -165,6 +167,7 @@ octosense-toolbox = { path = "crates/toolbox" }
octosense-l0-chat = { path = "crates/l0-chat" }
octosense-oauth-service = { path = "crates/oauth-service" }
octosense-markdown-editor = { path = "crates/markdown-editor" }
+octosense-wasm-host = { path = "crates/wasm-host" }
article-core = { git = "https://github.com/hagency-org/Rinx.git", tag = "v1.1.0" }
article-makepad = { git = "https://github.com/hagency-org/Rinx.git", tag = "v1.1.0" }
octosense-llm-config = { path = "apps/ai-providers/config" }
@@ -194,6 +197,10 @@ keyring = "3"
# process with them (OctoSense hosting Rinx).
rusqlite = { version = "0.40.1", features = ["bundled"] }
log = "0.4"
+# Developers' own functions compiled to WebAssembly, run for their app with
+# a deadline and a memory cap (crates/wasm-host, ADR 0011). Cranelift compiles;
+# no component model, async, GC or threads.
+wasmtime = { version = "49.0.2", default-features = false, features = ["cranelift", "runtime", "std", "parallel-compilation"] }
# Workspace-wide clippy policy for the crates that opt in (`[lints]
# workspace = true`, AppCard's): lints that are real but tracked elsewhere.
diff --git a/apps/wasmlab/bundle/AGENT.md b/apps/wasmlab/bundle/AGENT.md
new file mode 100644
index 00000000..3be12100
--- /dev/null
+++ b/apps/wasmlab/bundle/AGENT.md
@@ -0,0 +1,15 @@
+# Wasm Lab
+
+Wasm Lab's tools are its own Rust functions, run on this device in a sandbox:
+they compute an answer from what you give them and reach nothing else (no
+files, network, calendar or other app).
+
+- wasmlab.find_slots: free slots of a given length in a day, around busy
+ intervals you pass ("HH:MM" pairs). It does not read anyone's calendar;
+ get the busy times first, from the person or from a tool that has them.
+- wasmlab.rank: rank names against a query, best first, tolerating typos.
+- wasmlab.diff: a unified line diff of two texts, with added and removed
+ counts.
+
+Report what a tool returned. When a tool says the input is wrong (a bad time,
+a day that ends before it starts), fix the input or ask; do not guess.
diff --git a/apps/wasmlab/bundle/fns/wasmlab.wasm b/apps/wasmlab/bundle/fns/wasmlab.wasm
new file mode 100755
index 00000000..cd1faeaa
Binary files /dev/null and b/apps/wasmlab/bundle/fns/wasmlab.wasm differ
diff --git a/apps/wasmlab/bundle/icon.svg b/apps/wasmlab/bundle/icon.svg
new file mode 100644
index 00000000..ca5ea759
--- /dev/null
+++ b/apps/wasmlab/bundle/icon.svg
@@ -0,0 +1 @@
+
diff --git a/apps/wasmlab/bundle/main.splash b/apps/wasmlab/bundle/main.splash
new file mode 100644
index 00000000..7b91309b
--- /dev/null
+++ b/apps/wasmlab/bundle/main.splash
@@ -0,0 +1,257 @@
+// BEGIN shared app interface
+// Shared system-app interface. Run tools/sync-app-interface.py after editing.
+// Bundles carry this prelude so they work in the contained standalone host too.
+// Resolve the current theme after a host restyle, not a retained prelude alias.
+let theme = mod.theme
+let ui_ink = theme.color_text
+let ui_page = theme.color_bg_app
+let ui_light = dot(ui_ink, #x36b61200) < 0.5
+let ui_surface = if ui_light { #xffffff } else { mix(ui_page, ui_ink, 0.06) }
+let ui_field = mix(ui_page, ui_ink, 0.075)
+let ui_hover = mix(ui_page, ui_ink, 0.12)
+let ui_border = mix(ui_page, ui_ink, 0.22)
+let ui_muted = mix(ui_ink, ui_page, 0.24)
+let ui_link = mix(ui_ink, #x356bce, 0.25)
+let ui_primary = #x285cd6
+let ui_success = mix(ui_ink, #x28a55b, 0.20)
+let ui_danger = mix(ui_ink, #xe13b53, 0.20)
+let UiButton = ButtonFlat{height: 44 margin: 0 padding: Inset{left: 14 right: 14}
+ draw_bg +: {border_radius: 12.0 border_size: 0.0 color: #x00000000 color_hover: ui_hover color_down: ui_field color_focus: ui_hover}
+ draw_text +: {color: ui_link color_hover: ui_link color_down: ui_link color_focus: ui_link text_style: theme.font_bold{font_size: 14}}
+}
+let UiPrimary = UiButton{
+ draw_bg +: {color: ui_primary color_hover: #x3468df color_down: #x204cb8 color_focus: #x204cb8}
+ draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff color_focus: #xffffff}
+}
+let UiPill = UiButton{draw_bg +: {color: ui_surface border_radius: 22.0} draw_text +: {color: ui_ink color_hover: ui_ink color_down: ui_ink color_focus: ui_ink}}
+let UiField = TextInput{width: Fill height: 48 margin: 0 padding: Inset{left: 14 right: 14 top: 12 bottom: 12}
+ draw_bg +: {color: ui_field color_hover: ui_field color_focus: ui_field color_empty: ui_field
+ border_color: ui_border border_color_hover: ui_link border_color_focus: ui_link border_color_empty: ui_border border_radius: 12.0}
+ draw_text +: {color: ui_ink color_hover: ui_ink color_focus: ui_ink color_empty: ui_muted color_empty_hover: ui_muted color_empty_focus: ui_muted text_style: theme.font_regular{font_size: 15}}
+ draw_selection.color: #x557dcc55
+}
+let UiCard = RoundedView{width: Fill height: Fit flow: Down spacing: 12 padding: 20
+ show_bg: true draw_bg +: {color: ui_surface border_radius: 16.0}}
+let UiCaption = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_muted text_style: theme.font_regular{font_size: 13 line_spacing: 1.4}}}
+let UiTitle = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_ink text_style: theme.font_bold{font_size: 28 line_spacing: 1.2}}}
+// END shared app interface
+// Wasm Lab: Rust functions that ship in this bundle as
+// fns/wasmlab.wasm (built from apps/wasmlab/guest), run by the shell's
+// `wasm` service in a sandbox with a deadline and a memory cap. Each card
+// calls one function through host.request and shows what came back and the
+// round trip, as this script sees it.
+let ground = ui_page
+let ink = ui_ink
+let secondary = ui_muted
+
+let APPS = ["Calendar" "Calculator" "Camera" "Clock" "Contacts" "Files" "Mail" "Maps" "Messages" "Music"
+ "News" "Notes" "Phone" "Photos" "Reminders" "Settings" "Weather" "YouTube" "Terminal" "App Hub"
+ "Sheets" "Wallet" "Health" "Translate" "Podcasts" "Books" "Voice Memos" "Compass" "Measure" "Stocks"
+ "Home" "Find My" "Shortcuts" "Magnifier" "Tips" "Fitness" "Recorder" "Gallery" "Browser" "Assistant"]
+// Commas between the pairs: `] [` would read as indexing.
+let BUSY = [["09:00" "09:30"], ["10:00" "11:00"], ["12:00" "13:00"], ["14:30" "15:00"], ["16:00" "17:30"]]
+let OLD = "milk\neggs\nbread\nbutter\napples\ncoffee\n"
+let NEW = "milk\neggs\nsourdough bread\nbutter\napples\npears\ncoffee\n"
+let SAMPLE = "# Weekly notes\n\nSome *emphasis*, `code` and a [link](https://example.org).\n\n- [x] ship the MVP\n- [ ] measure on the phone\n\n| function | input |\n|---|---|\n| find_slots | busy times |\n| fuzzy_rank | names |\n\n> Quoted, with **bold** inside.\n\n"
+
+let ranked = []
+let rank_note = ""
+let rank_seq = 0
+let duration = 30
+let slots = []
+let slots_note = ""
+let diff = nil
+let diff_note = ""
+let md_note = ""
+let md_head = ""
+let rogue_note = ""
+let runtime = nil
+let runtime_note = ""
+
+// Milliseconds to one decimal.
+fn ms(t0){ "" + floor((time_now() - t0) * 10000.0 + 0.5) / 10 + " ms" }
+
+fn rank(text){
+ rank_seq = rank_seq + 1
+ let seq = rank_seq
+ let t0 = time_now()
+ host.request("wasm.fuzzy_rank", {query: text items: APPS limit: 8}, fn(r){
+ if seq != rank_seq { return }
+ if r.is_ok {
+ ranked = r.data.ranked
+ let noun = if ranked.len() == 1 { " match" } else { " matches" }
+ rank_note = ranked.len() + noun + " in " + ms(t0)
+ }
+ else { ranked = [] rank_note = r.error }
+ ui.rank_list.render()
+ })
+}
+
+fn find_slots(minutes){
+ duration = minutes
+ let t0 = time_now()
+ host.request("wasm.find_slots", {day_start: "08:30" day_end: "18:00" duration: minutes busy: BUSY max: 12}, fn(r){
+ if r.is_ok { slots = r.data.slots slots_note = slots.len() + " free slots of " + minutes + " min in " + ms(t0) }
+ else { slots = [] slots_note = r.error }
+ ui.durations.render()
+ ui.slot_list.render()
+ })
+}
+
+fn compare(){
+ let t0 = time_now()
+ host.request("wasm.text_diff", {old: OLD new: NEW context: 1}, fn(r){
+ if r.is_ok { diff = r.data diff_note = "+" + diff.added + " −" + diff.removed + " in " + ms(t0) }
+ else { diff = nil diff_note = r.error }
+ ui.diff_view.render()
+ })
+}
+
+fn render_md(copies){
+ let text = ""
+ for i in copies { text = text + SAMPLE }
+ let t0 = time_now()
+ host.request("wasm.md_to_html", text, fn(r){
+ if r.is_ok {
+ md_note = floor(text.len() / 100) / 10 + " KB of Markdown → " + floor(r.data.text.len() / 100) / 10 + " KB of HTML in " + ms(t0)
+ md_head = r.data.text.split("\n")[0]
+ } else { md_note = r.error md_head = "" }
+ ui.md_view.render()
+ })
+}
+
+fn misbehave(mode){
+ rogue_note = "Running “" + mode + "”…"
+ ui.rogue_view.render()
+ let t0 = time_now()
+ host.request("wasm.rogue", {mode: mode}, fn(r){
+ if r.is_ok { rogue_note = mode + ": returned (unexpected)" }
+ else { rogue_note = mode + ": " + r.error + " (after " + ms(t0) + ")" }
+ ui.rogue_view.render()
+ // The next call gets a fresh instance.
+ rank(ui.query.text())
+ load_runtime()
+ })
+}
+
+fn load_runtime(){
+ let t0 = time_now()
+ host.request("wasm.functions", {}, fn(r){
+ if r.is_ok { runtime = r.data runtime_note = "Answered in " + ms(t0) }
+ else { runtime = nil runtime_note = r.error }
+ ui.runtime_view.render()
+ })
+}
+
+fn module_line(m){
+ let how = if m.from_cache { "loaded compiled code from the cache" } else { "compiled" }
+ m.file + ": " + floor(m.bytes / 1024) + " KiB, " + how + " in " + m.load_ms + " ms; memory " + floor(m.memory_bytes / 1048576) + " MiB; renewed " + m.renewed + "×"
+}
+
+// A missing key reads as `fallback` (as News and Photos do it).
+fn optional(it, key, fallback){
+ let value = try { it[key] } catch { nil }
+ if value == nil { return fallback }
+ value
+}
+
+fn stats_line(name){
+ let s = optional(runtime.stats, name, nil)
+ if s == nil { return name + ": not called yet" }
+ name + ": " + s.calls + " calls, mean " + s.mean_us + " µs, max " + s.max_us + " µs, " + s.errors + " errors"
+}
+
+fn boot(){
+ load_runtime()
+ rank("cal")
+ find_slots(30)
+ compare()
+ render_md(1)
+}
+start_timeout(0.05, || boot())
+
+let Section = Label{width: Fill text: "" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 16}}
+let Note = UiCaption{text: ""}
+let Line = Label{width: Fill text: "" draw_text.color: ink draw_text.text_style.font_size: 14}
+let Code = Label{width: Fill text: "" flow: Flow.Right{wrap: true} draw_text.color: ink draw_text.text_style: theme.font_code{font_size: 12}}
+let Choice = UiPill{}
+let ChoiceOn = UiPill{
+ draw_bg +: {color: ink color_hover: ink color_down: ink}
+ draw_text +: {color: ground color_hover: ground color_down: ground}
+}
+
+SolidView{width: Fill height: Fill flow: Overlay align: Align{x: 0.5} show_bg: true draw_bg.color: ground
+ ScrollYView{width: Fill max_width: 760 height: Fill flow: Down spacing: 14 padding: Inset{left: 14 right: 14 top: 16 bottom: 32}
+ UiTitle{text: "Wasm Lab"}
+ UiCaption{text: "Rust functions shipped in this app, run by the shell in a sandbox: no files, network or other apps, a 2 s deadline and a 256 MiB memory cap."}
+
+ UiCard{
+ Section{text: "Find an app"}
+ query := UiField{empty_text: "Type part of a name" on_change: |text| rank(text)}
+ rank_list := View{width: Fill height: Fit flow: Down spacing: 6 on_render: || {
+ Note{text: rank_note}
+ for it in ranked { Line{text: it.item + " " + it.score} }
+ }}
+ }
+
+ UiCard{
+ Section{text: "Free time today"}
+ UiCaption{text: "Busy 09:00–09:30, 10:00–11:00, 12:00–13:00, 14:30–15:00, 16:00–17:30; the day runs 08:30–18:00."}
+ durations := View{width: Fill height: Fit flow: Flow.Right{wrap: true} spacing: 8 on_render: || {
+ for m in [15 30 60 90] {
+ if m == duration { ChoiceOn{text: m + " min" on_click: || find_slots(m)} }
+ else { Choice{text: m + " min" on_click: || find_slots(m)} }
+ }
+ }}
+ slot_list := View{width: Fill height: Fit flow: Down spacing: 6 on_render: || {
+ Note{text: slots_note}
+ for s in slots { Line{text: s[0] + " – " + s[1]} }
+ }}
+ }
+
+ UiCard{
+ Section{text: "What changed in the list"}
+ diff_view := View{width: Fill height: Fit flow: Down spacing: 6 on_render: || {
+ Note{text: diff_note}
+ if diff != nil { Code{text: diff.unified} }
+ }}
+ UiButton{text: "Compare again" on_click: || compare()}
+ }
+
+ UiCard{
+ Section{text: "Markdown to HTML"}
+ md_view := View{width: Fill height: Fit flow: Down spacing: 6 on_render: || {
+ Note{text: md_note}
+ Code{text: md_head}
+ }}
+ View{width: Fill height: Fit flow: Right spacing: 8
+ UiButton{text: "Render the sample" on_click: || render_md(1)}
+ UiButton{text: "Render it ×40" on_click: || render_md(40)}
+ }
+ }
+
+ UiCard{
+ Section{text: "Misbehave"}
+ UiCaption{text: "Each ends in an error, never a crash; the next call gets a fresh instance."}
+ View{width: Fill height: Fit flow: Flow.Right{wrap: true} spacing: 8
+ UiButton{text: "Loop forever" on_click: || misbehave("loop")}
+ UiButton{text: "Allocate endlessly" on_click: || misbehave("alloc")}
+ UiButton{text: "Panic" on_click: || misbehave("panic")}
+ UiButton{text: "Recurse" on_click: || misbehave("recurse")}
+ }
+ rogue_view := View{width: Fill height: Fit flow: Down on_render: || { Note{text: rogue_note} }}
+ }
+
+ UiCard{
+ Section{text: "Runtime"}
+ runtime_view := View{width: Fill height: Fit flow: Down spacing: 6 on_render: || {
+ Note{text: runtime_note}
+ if runtime != nil {
+ for m in runtime.modules { Line{text: module_line(m)} }
+ for name in runtime.functions { Line{text: stats_line(name)} }
+ }
+ }}
+ UiButton{text: "Refresh" on_click: || load_runtime()}
+ }
+ }
+}
diff --git a/apps/wasmlab/bundle/manifest.json b/apps/wasmlab/bundle/manifest.json
new file mode 100644
index 00000000..86c693de
--- /dev/null
+++ b/apps/wasmlab/bundle/manifest.json
@@ -0,0 +1,14 @@
+{
+ "schema": 1,
+ "id": "os.wasmlab",
+ "version": "0.1.0",
+ "name": "Wasm Lab",
+ "integrity": { "bundle_blake3": "" },
+ "capabilities": ["wasm"],
+ "agent": {
+ "profile": "read-only",
+ "tools": ["ask_user_question"],
+ "model": { "needs": ["tool_calling"] },
+ "instructions": "AGENT.md"
+ }
+}
diff --git a/apps/wasmlab/bundle/tools.json b/apps/wasmlab/bundle/tools.json
new file mode 100644
index 00000000..1817b5ce
--- /dev/null
+++ b/apps/wasmlab/bundle/tools.json
@@ -0,0 +1,199 @@
+{
+ "schema": 1,
+ "tools": [
+ {
+ "name": "wasmlab.find_slots",
+ "description": "Find free slots of `duration` minutes between day_start and day_end, around the busy intervals given, every `step` minutes. Computes only: it reads no calendar.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "day_start": {
+ "type": "string",
+ "description": "Start of the day, HH:MM",
+ "pattern": "^[0-2][0-9]:[0-5][0-9]$"
+ },
+ "day_end": {
+ "type": "string",
+ "description": "End of the day, HH:MM",
+ "pattern": "^[0-2][0-9]:[0-5][0-9]$"
+ },
+ "duration": {
+ "type": "integer",
+ "description": "Minutes each slot lasts",
+ "minimum": 5,
+ "maximum": 720
+ },
+ "step": {
+ "type": "integer",
+ "description": "Minutes between slot starts (default 15)",
+ "minimum": 5,
+ "maximum": 120
+ },
+ "busy": {
+ "type": "array",
+ "description": "Busy intervals as [start, end] pairs of HH:MM",
+ "maxItems": 200,
+ "items": {
+ "type": "array",
+ "minItems": 2,
+ "maxItems": 2,
+ "items": {
+ "type": "string",
+ "pattern": "^[0-2][0-9]:[0-5][0-9]$"
+ }
+ }
+ },
+ "max": {
+ "type": "integer",
+ "description": "Most slots to return (default 10)",
+ "minimum": 1,
+ "maximum": 50
+ }
+ },
+ "required": [
+ "day_start",
+ "day_end",
+ "duration",
+ "busy"
+ ],
+ "additionalProperties": false
+ },
+ "output_schema": {
+ "type": "object",
+ "properties": {
+ "slots": {
+ "type": "array",
+ "items": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "required": [
+ "slots"
+ ]
+ },
+ "risk": "read",
+ "background": true,
+ "shareable": true,
+ "implemented_by": "host-service",
+ "host_method": "wasm.find_slots"
+ },
+ {
+ "name": "wasmlab.rank",
+ "description": "Rank names against a query, best first: a prefix beats a word start beats a substring beats letters in order; a near miss (a typo) still ranks.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "maxLength": 200
+ },
+ "items": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "maxLength": 500
+ },
+ "maxItems": 5000
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100
+ }
+ },
+ "required": [
+ "query",
+ "items"
+ ],
+ "additionalProperties": false
+ },
+ "output_schema": {
+ "type": "object",
+ "properties": {
+ "ranked": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "item": {
+ "type": "string"
+ },
+ "score": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "item",
+ "score"
+ ]
+ }
+ }
+ },
+ "required": [
+ "ranked"
+ ]
+ },
+ "risk": "read",
+ "background": true,
+ "shareable": true,
+ "implemented_by": "host-service",
+ "host_method": "wasm.fuzzy_rank"
+ },
+ {
+ "name": "wasmlab.diff",
+ "description": "A unified line diff of two texts, with the number of lines added and removed.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "old": {
+ "type": "string",
+ "maxLength": 200000
+ },
+ "new": {
+ "type": "string",
+ "maxLength": 200000
+ },
+ "context": {
+ "type": "integer",
+ "description": "Unchanged lines around each change (default 3)",
+ "minimum": 0,
+ "maximum": 20
+ }
+ },
+ "required": [
+ "old",
+ "new"
+ ],
+ "additionalProperties": false
+ },
+ "output_schema": {
+ "type": "object",
+ "properties": {
+ "unified": {
+ "type": "string"
+ },
+ "added": {
+ "type": "integer"
+ },
+ "removed": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "unified",
+ "added",
+ "removed"
+ ]
+ },
+ "risk": "read",
+ "background": true,
+ "shareable": true,
+ "implemented_by": "host-service",
+ "host_method": "wasm.text_diff"
+ }
+ ]
+}
diff --git a/apps/wasmlab/guest/Cargo.lock b/apps/wasmlab/guest/Cargo.lock
new file mode 100644
index 00000000..cdc6fbf8
--- /dev/null
+++ b/apps/wasmlab/guest/Cargo.lock
@@ -0,0 +1,159 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "octosense-guest"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "pulldown-cmark"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
+dependencies = [
+ "bitflags",
+ "memchr",
+ "pulldown-cmark-escape",
+ "unicase",
+]
+
+[[package]]
+name = "pulldown-cmark-escape"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "similar"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicase"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "wasmlab-functions"
+version = "0.1.0"
+dependencies = [
+ "octosense-guest",
+ "pulldown-cmark",
+ "serde",
+ "similar",
+ "strsim",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/apps/wasmlab/guest/Cargo.toml b/apps/wasmlab/guest/Cargo.toml
new file mode 100644
index 00000000..f71d64ce
--- /dev/null
+++ b/apps/wasmlab/guest/Cargo.toml
@@ -0,0 +1,12 @@
+# Wasm Lab's functions, built for wasm32-unknown-unknown (./build.sh), and the
+# guest SDK they use. Its own workspace: the root workspace builds natively.
+[workspace]
+resolver = "2"
+members = ["octosense-guest", "functions"]
+
+[profile.release]
+opt-level = 3
+lto = true
+codegen-units = 1
+panic = "abort"
+strip = true
diff --git a/apps/wasmlab/guest/build.sh b/apps/wasmlab/guest/build.sh
new file mode 100755
index 00000000..3536d074
--- /dev/null
+++ b/apps/wasmlab/guest/build.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# Builds Wasm Lab's functions into the app bundle (bundle/fns/wasmlab.wasm).
+# Needs the target once: rustup target add wasm32-unknown-unknown
+set -euo pipefail
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cargo build --manifest-path "$here/Cargo.toml" --release --target wasm32-unknown-unknown -p wasmlab-functions
+mkdir -p "$here/../bundle/fns"
+cp "$here/target/wasm32-unknown-unknown/release/wasmlab_functions.wasm" "$here/../bundle/fns/wasmlab.wasm"
+echo "bundle/fns/wasmlab.wasm: $(wc -c < "$here/../bundle/fns/wasmlab.wasm") bytes"
diff --git a/apps/wasmlab/guest/functions/Cargo.toml b/apps/wasmlab/guest/functions/Cargo.toml
new file mode 100644
index 00000000..1ae61ce1
--- /dev/null
+++ b/apps/wasmlab/guest/functions/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "wasmlab-functions"
+version = "0.1.0"
+edition = "2021"
+license = "Apache-2.0"
+publish = false
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+octosense-guest = { path = "../octosense-guest" }
+serde = { version = "1", features = ["derive"] }
+pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
+similar = "2"
+strsim = "0.11"
diff --git a/apps/wasmlab/guest/functions/src/lib.rs b/apps/wasmlab/guest/functions/src/lib.rs
new file mode 100644
index 00000000..3d2301aa
--- /dev/null
+++ b/apps/wasmlab/guest/functions/src/lib.rs
@@ -0,0 +1,358 @@
+//! Wasm Lab's functions: a stand-in for a developer's own Rust crate. Light
+//! computing and algorithms an app calls from its script or its agent, built
+//! for wasm32 by `../build.sh` and run by the shell's runtime.
+
+use serde::{Deserialize, Serialize};
+
+octosense_guest::abi!();
+
+// ---- Markdown to HTML --------------------------------------------------------
+
+/// CommonMark to HTML, with tables, task lists, strikethrough and footnotes.
+pub fn md_to_html(input: &[u8]) -> Result, String> {
+ use pulldown_cmark::{html, Options, Parser};
+ let text = std::str::from_utf8(input).map_err(|_| "the text is not UTF-8".to_string())?;
+ let options = Options::ENABLE_TABLES
+ | Options::ENABLE_TASKLISTS
+ | Options::ENABLE_STRIKETHROUGH
+ | Options::ENABLE_FOOTNOTES;
+ let mut out = String::with_capacity(text.len() * 3 / 2);
+ html::push_html(&mut out, Parser::new_ext(text, options));
+ Ok(out.into_bytes())
+}
+octosense_guest::export!(md_to_html);
+
+// ---- Free meeting slots ------------------------------------------------------
+
+/// A day's free slots of `duration` minutes between `day_start` and
+/// `day_end`, every `step` minutes, around the `busy` intervals ("HH:MM").
+#[derive(Deserialize)]
+pub struct SlotRequest {
+ pub day_start: String,
+ pub day_end: String,
+ pub duration: u32,
+ #[serde(default = "fifteen")]
+ pub step: u32,
+ #[serde(default)]
+ pub busy: Vec<[String; 2]>,
+ #[serde(default = "ten")]
+ pub max: usize,
+}
+
+#[derive(Serialize, Debug, PartialEq)]
+pub struct Slots {
+ pub slots: Vec<[String; 2]>,
+}
+
+fn fifteen() -> u32 {
+ 15
+}
+
+fn ten() -> usize {
+ 10
+}
+
+fn minutes(hhmm: &str) -> Result {
+ let (h, m) = hhmm
+ .split_once(':')
+ .ok_or_else(|| format!("{hhmm} is not HH:MM"))?;
+ let (h, m): (u32, u32) = (
+ h.parse().map_err(|_| format!("{hhmm} is not HH:MM"))?,
+ m.parse().map_err(|_| format!("{hhmm} is not HH:MM"))?,
+ );
+ if h > 24 || m > 59 || (h == 24 && m > 0) {
+ return Err(format!("{hhmm} is not a time of day"));
+ }
+ Ok(h * 60 + m)
+}
+
+fn hhmm(minutes: u32) -> String {
+ format!("{:02}:{:02}", minutes / 60, minutes % 60)
+}
+
+pub fn find_slots(req: SlotRequest) -> Result {
+ let (start, end) = (minutes(&req.day_start)?, minutes(&req.day_end)?);
+ if start >= end {
+ return Err("the day ends before it starts".into());
+ }
+ if req.duration == 0 || req.step == 0 {
+ return Err("the duration and the step must be at least a minute".into());
+ }
+ let mut busy = Vec::with_capacity(req.busy.len());
+ for [from, to] in &req.busy {
+ let (from, to) = (minutes(from)?, minutes(to)?);
+ if from < to {
+ busy.push((from.max(start), to.min(end)));
+ }
+ }
+ busy.sort_unstable();
+ // Merge overlapping and touching busy intervals.
+ let mut merged: Vec<(u32, u32)> = Vec::with_capacity(busy.len());
+ for (from, to) in busy {
+ match merged.last_mut() {
+ Some(last) if from <= last.1 => last.1 = last.1.max(to),
+ _ => merged.push((from, to)),
+ }
+ }
+ let mut slots = Vec::new();
+ let mut free_from = start;
+ for (from, to) in merged.into_iter().chain([(end, end)]) {
+ // Candidate starts sit on the step grid from the start of the day.
+ let mut at = start + (free_from - start).div_ceil(req.step) * req.step;
+ while at + req.duration <= from && slots.len() < req.max {
+ slots.push([hhmm(at), hhmm(at + req.duration)]);
+ at += req.step;
+ }
+ free_from = free_from.max(to);
+ }
+ Ok(Slots { slots })
+}
+octosense_guest::export_json!(find_slots);
+
+// ---- Fuzzy ranking -----------------------------------------------------------
+
+/// The items that match `query`, best first: a prefix beats a substring, a
+/// substring beats letters in order, and a near miss still ranks (typos).
+#[derive(Deserialize)]
+pub struct RankRequest {
+ pub query: String,
+ pub items: Vec,
+ #[serde(default = "ten")]
+ pub limit: usize,
+}
+
+#[derive(Serialize, Debug, PartialEq)]
+pub struct Ranked {
+ pub item: String,
+ pub score: f64,
+}
+
+/// An object, not a bare list, so an agent tool can return it.
+#[derive(Serialize, Debug, PartialEq)]
+pub struct Ranking {
+ pub ranked: Vec,
+}
+
+fn score(query: &str, item: &str) -> f64 {
+ let (q, it) = (query.to_lowercase(), item.to_lowercase());
+ if q.is_empty() {
+ return 0.0;
+ }
+ if it.starts_with(&q) {
+ return 1.0 - (it.chars().count() - q.chars().count()) as f64 / 200.0;
+ }
+ if let Some(at) = it.find(&q) {
+ // A match at a word start is nearly as good as a prefix.
+ let word_start = it[..at].ends_with([' ', '-', '_', '.', '/']);
+ return if word_start { 0.95 } else { 0.85 } - at as f64 / 200.0;
+ }
+ // Letters in order: tighter spans score higher.
+ let mut chars = it.char_indices();
+ let mut first = None;
+ let mut last = 0;
+ let mut in_order = true;
+ for c in q.chars() {
+ match chars.find(|(_, x)| *x == c) {
+ Some((i, _)) => {
+ first.get_or_insert(i);
+ last = i;
+ }
+ None => {
+ in_order = false;
+ break;
+ }
+ }
+ }
+ if in_order {
+ let span = (last - first.unwrap_or(0) + 1) as f64;
+ return 0.5 + 0.3 * (q.len() as f64 / span).min(1.0);
+ }
+ strsim::jaro_winkler(&q, &it) * 0.6
+}
+
+pub fn fuzzy_rank(req: RankRequest) -> Result {
+ let mut ranked: Vec = req
+ .items
+ .iter()
+ .map(|item| Ranked {
+ score: (score(&req.query, item) * 1000.0).round() / 1000.0,
+ item: item.clone(),
+ })
+ .filter(|r| r.score >= 0.45)
+ .collect();
+ ranked.sort_by(|a, b| {
+ b.score
+ .total_cmp(&a.score)
+ .then_with(|| a.item.cmp(&b.item))
+ });
+ ranked.truncate(req.limit);
+ Ok(Ranking { ranked })
+}
+octosense_guest::export_json!(fuzzy_rank);
+
+// ---- Line diff ---------------------------------------------------------------
+
+/// A unified diff of two texts, line by line, with `context` lines around
+/// each change.
+#[derive(Deserialize)]
+pub struct DiffRequest {
+ pub old: String,
+ pub new: String,
+ #[serde(default = "three")]
+ pub context: usize,
+}
+
+#[derive(Serialize, Debug, PartialEq)]
+pub struct Diff {
+ pub unified: String,
+ pub added: usize,
+ pub removed: usize,
+}
+
+fn three() -> usize {
+ 3
+}
+
+pub fn text_diff(req: DiffRequest) -> Result {
+ use similar::{ChangeTag, TextDiff};
+ let diff = TextDiff::from_lines(&req.old, &req.new);
+ let (mut added, mut removed) = (0, 0);
+ for change in diff.iter_all_changes() {
+ match change.tag() {
+ ChangeTag::Insert => added += 1,
+ ChangeTag::Delete => removed += 1,
+ ChangeTag::Equal => {}
+ }
+ }
+ let unified = diff
+ .unified_diff()
+ .context_radius(req.context)
+ .header("old", "new")
+ .to_string();
+ Ok(Diff {
+ unified,
+ added,
+ removed,
+ })
+}
+octosense_guest::export_json!(text_diff);
+
+// ---- Misbehaviour, for the containment checks --------------------------------
+
+#[derive(Deserialize)]
+pub struct RogueRequest {
+ pub mode: String,
+}
+
+/// Misbehaves on purpose: `loop` never returns, `alloc` takes memory until
+/// it cannot, `panic` panics, `recurse` overflows the stack.
+pub fn rogue(req: RogueRequest) -> Result {
+ match req.mode.as_str() {
+ "loop" => {
+ let mut n = 0u64;
+ loop {
+ n = std::hint::black_box(n.wrapping_add(1));
+ }
+ }
+ "alloc" => {
+ let mut hoard: Vec> = Vec::new();
+ loop {
+ hoard.push(vec![1; 16 << 20]);
+ std::hint::black_box(&hoard);
+ }
+ }
+ "panic" => panic!("rogue: a deliberate panic"),
+ "recurse" => Ok(depth(0).to_string()),
+ other => Err(format!("rogue has no mode {other}")),
+ }
+}
+octosense_guest::export_json!(rogue);
+
+#[allow(unconditional_recursion)]
+fn depth(n: u64) -> u64 {
+ std::hint::black_box(depth(std::hint::black_box(n + 1))) + 1
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn slots(busy: &[[&str; 2]], duration: u32) -> Vec<[String; 2]> {
+ let req = SlotRequest {
+ day_start: "09:00".into(),
+ day_end: "12:00".into(),
+ duration,
+ step: 30,
+ busy: busy
+ .iter()
+ .map(|[a, b]| [a.to_string(), b.to_string()])
+ .collect(),
+ max: 10,
+ };
+ find_slots(req).unwrap().slots
+ }
+
+ #[test]
+ fn free_slots_go_around_busy_time_on_the_step_grid() {
+ let found = slots(
+ &[["09:30", "10:15"], ["10:00", "10:40"], ["11:30", "12:30"]],
+ 30,
+ );
+ let found: Vec = found.iter().map(|[a, b]| format!("{a}-{b}")).collect();
+ assert_eq!(found, ["09:00-09:30", "11:00-11:30"]);
+ assert_eq!(slots(&[], 60).len(), 5);
+ }
+
+ #[test]
+ fn bad_times_are_refused() {
+ let req = SlotRequest {
+ day_start: "9am".into(),
+ day_end: "12:00".into(),
+ duration: 30,
+ step: 15,
+ busy: vec![],
+ max: 10,
+ };
+ assert!(find_slots(req).is_err());
+ }
+
+ #[test]
+ fn a_prefix_beats_a_substring_beats_letters_in_order() {
+ let items = ["Calculator", "Calendar", "Mail", "Local calls", "Clock"]
+ .map(String::from)
+ .to_vec();
+ let ranked = fuzzy_rank(RankRequest {
+ query: "cal".into(),
+ items,
+ limit: 10,
+ })
+ .unwrap()
+ .ranked;
+ let names: Vec<&str> = ranked.iter().map(|r| r.item.as_str()).collect();
+ assert_eq!(&names[..2], ["Calendar", "Calculator"]);
+ assert!(names.contains(&"Local calls"));
+ assert!(!names.contains(&"Mail"));
+ }
+
+ #[test]
+ fn a_diff_counts_its_lines() {
+ let diff = text_diff(DiffRequest {
+ old: "a\nb\nc\n".into(),
+ new: "a\nB\nc\nd\n".into(),
+ context: 1,
+ })
+ .unwrap();
+ assert_eq!((diff.added, diff.removed), (2, 1));
+ assert!(diff.unified.contains("-b\n+B\n"));
+ }
+
+ #[test]
+ fn markdown_becomes_html() {
+ let html =
+ String::from_utf8(md_to_html(b"# Hi\n\n- [x] done\n\n| a |\n|---|\n| 1 |\n").unwrap())
+ .unwrap();
+ assert!(
+ html.contains("Hi ") && html.contains("checkbox") && html.contains("")
+ );
+ }
+}
diff --git a/apps/wasmlab/guest/octosense-guest/Cargo.toml b/apps/wasmlab/guest/octosense-guest/Cargo.toml
new file mode 100644
index 00000000..6ea63a4f
--- /dev/null
+++ b/apps/wasmlab/guest/octosense-guest/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "octosense-guest"
+version = "0.1.0"
+edition = "2021"
+license = "Apache-2.0"
+publish = false
+
+[dependencies]
+serde_json = "1"
diff --git a/apps/wasmlab/guest/octosense-guest/src/lib.rs b/apps/wasmlab/guest/octosense-guest/src/lib.rs
new file mode 100644
index 00000000..b97c86e5
--- /dev/null
+++ b/apps/wasmlab/guest/octosense-guest/src/lib.rs
@@ -0,0 +1,164 @@
+//! The guest side of OctoSense's WebAssembly functions: what a Rust crate
+//! needs so an app can call its functions through the shell's runtime
+//! (`crates/wasm-host`, whose docs define the ABI).
+//!
+//! ```ignore
+//! octosense_guest::abi!();
+//!
+//! fn shout(input: &[u8]) -> Result, String> {
+//! Ok(input.to_ascii_uppercase())
+//! }
+//! octosense_guest::export!(shout);
+//!
+//! #[derive(serde::Deserialize)]
+//! struct Order { items: Vec }
+//! fn total(order: Order) -> Result {
+//! Ok(order.items.iter().sum())
+//! }
+//! octosense_guest::export_json!(total);
+//! ```
+//!
+//! Build the crate as a `cdylib` for `wasm32-unknown-unknown`. The module
+//! exports `memory`, `octo_alloc`, `octo_free` and each exported function,
+//! and imports only `octo.log`.
+
+use std::alloc::{alloc, dealloc, Layout};
+
+#[doc(hidden)]
+pub use serde_json;
+
+#[cfg(target_arch = "wasm32")]
+#[link(wasm_import_module = "octo")]
+extern "C" {
+ #[link_name = "log"]
+ fn octo_log(ptr: *const u8, len: usize);
+}
+
+/// Writes a line to the app's log. The host keeps a bounded number of
+/// lines, each cut to a bounded length.
+pub fn log(line: &str) {
+ #[cfg(target_arch = "wasm32")]
+ unsafe {
+ octo_log(line.as_ptr(), line.len())
+ }
+ #[cfg(not(target_arch = "wasm32"))]
+ eprintln!("{line}");
+}
+
+/// Exports `octo_alloc` and `octo_free`, which the host uses to pass input
+/// in and take output out. Invoke it once in the crate.
+#[macro_export]
+macro_rules! abi {
+ () => {
+ const _: () = {
+ #[export_name = "octo_alloc"]
+ pub extern "C" fn __octo_alloc(len: usize) -> *mut u8 {
+ $crate::__alloc(len)
+ }
+
+ #[export_name = "octo_free"]
+ pub unsafe extern "C" fn __octo_free(ptr: *mut u8, len: usize) {
+ $crate::__free(ptr, len)
+ }
+ };
+ };
+}
+
+/// Exports `fn(&[u8]) -> Result, String>` under its own name.
+#[macro_export]
+macro_rules! export {
+ ($name:ident) => {
+ const _: () = {
+ #[export_name = stringify!($name)]
+ pub extern "C" fn __octo_export(ptr: *const u8, len: usize) -> i64 {
+ $crate::__init();
+ let input = unsafe { $crate::__input(ptr, len) };
+ $crate::__output($name(input))
+ }
+ };
+ };
+}
+
+/// Exports `fn(In) -> Result` under its own name, where `In`
+/// is deserialized from the input as JSON and `Out` serialized to it.
+#[macro_export]
+macro_rules! export_json {
+ ($name:ident) => {
+ const _: () = {
+ #[export_name = stringify!($name)]
+ pub extern "C" fn __octo_export(ptr: *const u8, len: usize) -> i64 {
+ $crate::__init();
+ let input = unsafe { $crate::__input(ptr, len) };
+ $crate::__output(
+ $crate::serde_json::from_slice(input)
+ .map_err(|e| {
+ ::std::format!("the input is not what {} takes: {e}", stringify!($name))
+ })
+ .and_then($name)
+ .and_then(|out| {
+ $crate::serde_json::to_vec(&out).map_err(|e| e.to_string())
+ }),
+ )
+ }
+ };
+ };
+}
+
+#[doc(hidden)]
+pub fn __alloc(len: usize) -> *mut u8 {
+ if len == 0 {
+ return std::ptr::NonNull::dangling().as_ptr();
+ }
+ // SAFETY: the size is non-zero and the alignment is 1.
+ unsafe { alloc(Layout::from_size_align_unchecked(len, 1)) }
+}
+
+/// # Safety
+/// `ptr` and `len` are a buffer from [`__alloc`] or [`__output`].
+#[doc(hidden)]
+pub unsafe fn __free(ptr: *mut u8, len: usize) {
+ if len != 0 {
+ dealloc(ptr, Layout::from_size_align_unchecked(len, 1));
+ }
+}
+
+/// # Safety
+/// `ptr` and `len` are the input buffer the host wrote.
+#[doc(hidden)]
+pub unsafe fn __input<'a>(ptr: *const u8, len: usize) -> &'a [u8] {
+ if len == 0 {
+ &[]
+ } else {
+ std::slice::from_raw_parts(ptr, len)
+ }
+}
+
+/// Packs a result for the host: a status byte (`0` ok, `1` error) and the
+/// output or the error text, as `(ptr << 32) | len`.
+#[doc(hidden)]
+pub fn __output(result: Result, String>) -> i64 {
+ let mut buf = Vec::new();
+ match result {
+ Ok(bytes) => {
+ buf.reserve_exact(bytes.len() + 1);
+ buf.push(0);
+ buf.extend_from_slice(&bytes);
+ }
+ Err(text) => {
+ buf.reserve_exact(text.len() + 1);
+ buf.push(1);
+ buf.extend_from_slice(text.as_bytes());
+ }
+ }
+ let boxed = buf.into_boxed_slice();
+ let len = boxed.len();
+ let ptr = Box::into_raw(boxed) as *mut u8 as usize;
+ ((ptr as u64) << 32 | len as u64) as i64
+}
+
+/// Logs a panic's message before the module traps, so the app can tell why.
+#[doc(hidden)]
+pub fn __init() {
+ static ONCE: std::sync::Once = std::sync::Once::new();
+ ONCE.call_once(|| std::panic::set_hook(Box::new(|info| log(&format!("panic: {info}")))));
+}
diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml
index de35baf7..8284f602 100644
--- a/crates/shell/Cargo.toml
+++ b/crates/shell/Cargo.toml
@@ -72,6 +72,8 @@ octosense-ai-host = { workspace = true }
octosense-mail-service = { workspace = true, optional = true }
octosense-oauth-service = { workspace = true, optional = true, features = ["host"] }
octosense-markdown-editor = { workspace = true, optional = true }
+# Apps' own WebAssembly functions (the `wasm` service, ADR 0011).
+octosense-wasm-host = { workspace = true, optional = true }
octosense-calendar-service = { workspace = true, optional = true }
octosense-news-service = { workspace = true, optional = true }
# App Hub's host-service registry: the `glance` service registers into it.
@@ -159,6 +161,9 @@ app-aichat = ["dep:makepad-aichat"]
# development builds without it (build.rs); a release build without it
# honours only the `--dev-grant-all` launch flag.
dev-mode = []
+# The `wasm` host service (ADR 0011), which runs an app's own
+# WebAssembly functions (crates/wasm-host). Off by default.
+wasm-lab = ["app-hub", "dep:octosense-wasm-host"]
# The system toolbox offered to app agents as host-routed tools (ADR 0002
# §6, ADR 0004 §12; src/host_tools/toolbox.rs): the `toolbox` owner in the
# host-tool relay. Off by default.
diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs
index 750fa38f..52205359 100644
--- a/crates/shell/src/apps.rs
+++ b/crates/shell/src/apps.rs
@@ -252,6 +252,9 @@ pub fn register_host_services() {
// published the same way.
register_calendar_services();
register_news();
+ // Apps' own WebAssembly functions (ADR 0011).
+ #[cfg(feature = "wasm-lab")]
+ crate::wasm_service::register();
// After every service of the shell's own: the notice service never
// stands in for one.
let served = crate::glance_notice::serve_system_apps();
diff --git a/crates/shell/src/host_tools/script_apps.rs b/crates/shell/src/host_tools/script_apps.rs
index 915663c5..9c7edaf8 100644
--- a/crates/shell/src/host_tools/script_apps.rs
+++ b/crates/shell/src/host_tools/script_apps.rs
@@ -192,7 +192,7 @@ pub fn load(app: &str) -> Result<(), String> {
/// `app`'s admitted bundle: a system app's packed bundle, or an installed
/// one, with App Hub's apps root.
-fn admitted_bundle(app: &str) -> Result<(PathBuf, PathBuf), String> {
+pub(crate) fn admitted_bundle(app: &str) -> Result<(PathBuf, PathBuf), String> {
// Never a native app's tools, executor or grants (ADR 0004 §3, §7).
crate::apps::check_script_app_id(app)?;
let root = octosense_appstore::data_root_if_set().ok_or("App Hub has no apps root yet")?;
diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs
index 2381e546..3b39c225 100644
--- a/crates/shell/src/lib.rs
+++ b/crates/shell/src/lib.rs
@@ -100,6 +100,8 @@ pub mod connected_backends;
pub mod glance_digest;
#[cfg(any(feature = "app-hub", native_mobile))]
pub mod glance_notice;
+#[cfg(feature = "wasm-lab")]
+pub mod wasm_service;
pub mod glance_panel;
pub mod glance_sheet;
mod card_presentation;
diff --git a/crates/shell/src/wasm_service.rs b/crates/shell/src/wasm_service.rs
new file mode 100644
index 00000000..3b2952b9
--- /dev/null
+++ b/crates/shell/src/wasm_service.rs
@@ -0,0 +1,425 @@
+//! The `wasm` service (ADR 0011, feature `wasm-lab`): an app's own
+//! functions, written in Rust and shipped in its bundle as `fns/*.wasm`,
+//! run for that app by `octosense-wasm-host` (Wasmtime, compiled by
+//! Cranelift, with a deadline and a memory cap). They are for light
+//! computing and algorithms beside the app's script: parsing, ranking,
+//! scheduling, diffing. Splash's own compute handles kernels.
+//!
+//! - `wasm.functions` answers what the app's modules export, how they
+//! loaded and how each function has run so far.
+//! - `wasm.` calls one. Its input is the request's arguments: a
+//! string as its text, anything else as JSON. Its output comes back as
+//! JSON, or as `{"text": …}` when it is not JSON. Its own error, a trap or
+//! its deadline is the request's error.
+//!
+//! **Whose code.** A module comes only from the calling app's own admitted
+//! bundle (digest-checked, [`script_apps::admitted_bundle`]): no argument
+//! names a file, and no app reaches another's functions. The Card runner's
+//! gate and the tool executor both require the app's `wasm` capability, and
+//! the service checks the admitted manifest again before it loads anything.
+//!
+//! **Where it runs.** Requests arrive on the UI thread. Each app gets one
+//! worker thread holding its instances, so a slow function holds up only
+//! its own app. Compiled code is cached in the host directory
+//! (`wasm-cache`), keyed by the module's digest. An instance that trapped or
+//! ran past its deadline is replaced before the next call.
+//!
+//! Not done yet: an app updated while the shell runs keeps its old
+//! functions until the shell restarts, and a worker lives as long as the
+//! shell.
+
+use std::collections::{BTreeMap, HashMap};
+use std::path::{Path, PathBuf};
+use std::sync::mpsc::{channel, Receiver, SendError, Sender};
+use std::sync::{Mutex, OnceLock};
+use std::time::Instant;
+
+use octosense_appstore::services::{HostService, Replier, ServiceCall, ServiceHost};
+use octosense_wasm_host::{Instance, Limits, Program, Runtime};
+use serde_json::{json, Value};
+
+use crate::host_tools::script_apps;
+
+/// The most modules one app's bundle may carry.
+const MAX_MODULES: usize = 8;
+
+/// One engine, one epoch ticker and one code cache for the process.
+static RUNTIME: OnceLock> = OnceLock::new();
+
+/// Each app's worker, by app id.
+static WORKERS: Mutex>>> = Mutex::new(None);
+
+struct Job {
+ method: String,
+ args: Value,
+ host_dir: PathBuf,
+ reply: Replier,
+}
+
+/// The `wasm` family for the Card runner and the tool executor.
+pub struct WasmService;
+
+impl HostService for WasmService {
+ fn family(&self) -> &'static str {
+ "wasm"
+ }
+
+ fn call(&mut self, call: ServiceCall, reply: Replier, _host: &mut dyn ServiceHost) {
+ if call.from_sheet {
+ reply.send(Err("the wasm service has no sheet".into()));
+ return;
+ }
+ let app = call.app_id.clone();
+ let job = Job { method: call.method().to_string(), host_dir: call.host_dir, args: call.args, reply };
+ let mut workers = WORKERS.lock().unwrap_or_else(|e| e.into_inner());
+ let workers = workers.get_or_insert_with(HashMap::new);
+ // A worker that ended (only a panic ends one) is replaced.
+ let job = match workers.get(&app) {
+ Some(jobs) => match jobs.send(job) {
+ Ok(()) => return,
+ Err(SendError(job)) => job,
+ },
+ None => job,
+ };
+ let (jobs, queue) = channel();
+ let name = app.clone();
+ let spawned = std::thread::Builder::new().name(format!("wasm {app}")).spawn(move || work(name, queue));
+ if let Err(error) = spawned {
+ job.reply.send(Err(format!("cannot start {app}'s functions: {error}")));
+ return;
+ }
+ let _ = jobs.send(job);
+ workers.insert(app, jobs);
+ }
+}
+
+pub fn register() {
+ octosense_appstore::services::register_host_service(Box::new(WasmService));
+}
+
+/// One app's worker: loads its modules on the first request (again on the
+/// next one, if that failed), then answers each request in turn.
+fn work(app: String, jobs: Receiver) {
+ let mut lab: Option = None;
+ for job in jobs {
+ if lab.is_none() {
+ match Lab::load(&app, &job.host_dir) {
+ Ok(loaded) => lab = Some(loaded),
+ Err(error) => {
+ makepad_widgets::log!("wasm {app}: {error}");
+ job.reply.send(Err(error));
+ continue;
+ }
+ }
+ }
+ if let Some(lab) = lab.as_mut() {
+ job.reply.send(lab.answer(&job.method, &job.args));
+ }
+ }
+}
+
+fn runtime(host_dir: &Path) -> Result<&'static Runtime, String> {
+ RUNTIME
+ .get_or_init(|| Runtime::new(Limits::default(), Some(host_dir.join("wasm-cache"))))
+ .as_ref()
+ .map_err(Clone::clone)
+}
+
+/// One app's loaded modules.
+struct Lab {
+ app: String,
+ runtime: &'static Runtime,
+ modules: Vec,
+ /// Which module exports each function.
+ owner: BTreeMap,
+ stats: BTreeMap,
+}
+
+struct Module {
+ file: String,
+ bytes: usize,
+ load_ms: f64,
+ from_cache: bool,
+ program: Program,
+ instance: Instance,
+ /// Fresh instances made after a trap or a deadline.
+ renewed: u32,
+}
+
+/// How one function has run: in the worker, from input to output.
+#[derive(Default)]
+struct Stats {
+ calls: u64,
+ errors: u64,
+ total_us: f64,
+ max_us: f64,
+}
+
+impl Lab {
+ fn load(app: &str, host_dir: &Path) -> Result {
+ let (_, bundle) = script_apps::admitted_bundle(app)?;
+ if !script_apps::from_bundle(&bundle)?.families.contains("wasm") {
+ return Err(format!("{app} was not granted the wasm service"));
+ }
+ let runtime = runtime(host_dir)?;
+ let mut files: Vec = std::fs::read_dir(bundle.join("fns"))
+ .map_err(|_| format!("{app}'s bundle has no fns directory"))?
+ .flatten()
+ .map(|entry| entry.path())
+ .filter(|path| {
+ path.extension().is_some_and(|ext| ext == "wasm")
+ && std::fs::symlink_metadata(path).is_ok_and(|m| m.is_file())
+ })
+ .collect();
+ files.sort();
+ if files.is_empty() || files.len() > MAX_MODULES {
+ return Err(format!("{app}'s bundle must carry 1 to {MAX_MODULES} fns/*.wasm modules"));
+ }
+ let mut lab = Lab { app: app.to_string(), runtime, modules: Vec::new(), owner: BTreeMap::new(), stats: BTreeMap::new() };
+ for path in files {
+ let file = path.file_name().unwrap_or_default().to_string_lossy().into_owned();
+ let bytes = std::fs::read(&path).map_err(|e| format!("{file}: {e}"))?;
+ let started = Instant::now();
+ let program = runtime.load(&bytes).map_err(|e| format!("{file}: {e}"))?;
+ let instance = runtime.instantiate(&program).map_err(|e| format!("{file}: {e}"))?;
+ let load_ms = started.elapsed().as_secs_f64() * 1e3;
+ for function in program.functions() {
+ if lab.owner.insert(function.clone(), lab.modules.len()).is_some() {
+ return Err(format!("{file}: {function} is exported by another module too"));
+ }
+ }
+ let from_cache = program.from_cache();
+ makepad_widgets::log!(
+ "wasm {app}: {file} ({} KiB) {} in {load_ms:.1} ms: {}",
+ bytes.len() / 1024,
+ if from_cache { "loaded from the cache" } else { "compiled" },
+ program.functions().join(", ")
+ );
+ lab.modules.push(Module { file, bytes: bytes.len(), load_ms, from_cache, program, instance, renewed: 0 });
+ }
+ Ok(lab)
+ }
+
+ fn answer(&mut self, method: &str, args: &Value) -> Result {
+ if method == "functions" {
+ // Also in the log: where there is no remote instrument (an
+ // Android build), the numbers are read from logcat.
+ let described = self.describe();
+ makepad_widgets::log!("wasm {}: {described}", self.app);
+ return Ok(described);
+ }
+ let Some(&index) = self.owner.get(method) else {
+ return Err(format!("{} has no function {method:?}", self.app));
+ };
+ let module = &mut self.modules[index];
+ if module.instance.spent() {
+ module.instance = self.runtime.instantiate(&module.program).map_err(|e| e.to_string())?;
+ module.renewed += 1;
+ }
+ let input = match args {
+ Value::String(text) => text.clone().into_bytes(),
+ other => serde_json::to_vec(other).map_err(|e| e.to_string())?,
+ };
+ let started = Instant::now();
+ let result = module.instance.call(method, &input);
+ let us = started.elapsed().as_secs_f64() * 1e6;
+ for line in module.instance.take_logs() {
+ makepad_widgets::log!("wasm {}: {line}", self.app);
+ }
+ let stats = self.stats.entry(method.to_string()).or_default();
+ stats.calls += 1;
+ stats.total_us += us;
+ stats.max_us = stats.max_us.max(us);
+ let output = result.map_err(|error| {
+ stats.errors += 1;
+ error.to_string()
+ })?;
+ Ok(serde_json::from_slice(&output).unwrap_or_else(|_| json!({"text": String::from_utf8_lossy(&output)})))
+ }
+
+ fn describe(&self) -> Value {
+ let modules: Vec = self
+ .modules
+ .iter()
+ .map(|m| {
+ json!({"file": m.file, "bytes": m.bytes, "load_ms": round(m.load_ms),
+ "from_cache": m.from_cache, "memory_bytes": m.instance.memory_bytes(), "renewed": m.renewed})
+ })
+ .collect();
+ let stats: serde_json::Map = self
+ .stats
+ .iter()
+ .map(|(name, s)| {
+ let mean = if s.calls == 0 { 0.0 } else { s.total_us / s.calls as f64 };
+ (name.clone(), json!({"calls": s.calls, "errors": s.errors, "mean_us": round(mean), "max_us": round(s.max_us)}))
+ })
+ .collect();
+ json!({"functions": self.owner.keys().collect::>(), "modules": modules, "stats": stats})
+ }
+}
+
+fn round(value: f64) -> f64 {
+ (value * 10.0).round() / 10.0
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::ai_host::app_peers::host_tools::{HostToolCall, ToolExecutor, ToolReply};
+ use crate::host_tools::script_apps::{self, HostServiceExecutor};
+ use std::sync::Arc;
+ use std::time::Duration;
+
+ /// Wasm Lab's agent gets exactly its three function tools, each routed
+ /// to the app's own function on the `wasm` service.
+ #[test]
+ fn wasm_lab_offers_its_functions_as_tools() {
+ let dir = script_apps::tests::stamped_bundle("wasmlab", "tools", |_, _| {});
+ let loaded = script_apps::from_bundle(&dir).unwrap();
+ let _ = std::fs::remove_dir_all(&dir);
+ let names: Vec<&str> = loaded.tools.iter().filter_map(|t| t["name"].as_str()).collect();
+ assert_eq!(names, ["wasmlab.find_slots", "wasmlab.rank", "wasmlab.diff"]);
+ assert_eq!(loaded.host_methods["wasmlab.find_slots"], "wasm.find_slots");
+ assert_eq!(loaded.host_methods["wasmlab.rank"], "wasm.fuzzy_rank");
+ assert_eq!(loaded.host_methods["wasmlab.diff"], "wasm.text_diff");
+ assert!(loaded.tools.iter().all(|t| t["risk"] == "read" && t["output_schema"]["type"] == "object"));
+ assert_eq!(loaded.families.iter().collect::>(), ["wasm"]);
+ assert_eq!(loaded.generic, ["ask_user_question"]);
+ }
+
+ struct NoSheet;
+ impl ServiceHost for NoSheet {
+ fn open_sheet(&mut self, _body: String) {}
+ fn close_sheet(&mut self) {}
+ }
+
+ static NEXT_HEAP: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1 << 40);
+
+ /// What the app's script would get from `host.request(service, args)`.
+ fn request(app: &str, service: &str, args: Value, host_dir: &Path) -> Result {
+ let heap = NEXT_HEAP.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ let call = ServiceCall { app_id: app.into(), service: service.into(), args, from_sheet: false, may_prompt: true, host_dir: host_dir.into() };
+ octosense_appstore::services::dispatch(call, heap, 1, &mut NoSheet);
+ for _ in 0..1000 {
+ if let Some((_, _, answer)) = octosense_appstore::services::take_replies_for(&[heap]).pop() {
+ return answer.map(|text| serde_json::from_str(&text).unwrap());
+ }
+ std::thread::sleep(Duration::from_millis(10));
+ }
+ panic!("{service} was never answered");
+ }
+
+ /// Register a stamped copy of `apps/wasmlab/bundle` as the system app
+ /// `id`, as a build that selects it does.
+ fn ship(id: &'static str, tag: &str, edit: impl FnOnce(&Path, &mut Value)) {
+ let dir = script_apps::tests::stamped_bundle("wasmlab", tag, |dir, manifest| {
+ manifest["id"] = json!(id);
+ edit(dir, manifest);
+ });
+ let packed = octosense_app_hub::pack::pack_system_app(&dir).unwrap();
+ let _ = std::fs::remove_dir_all(&dir);
+ octosense_appstore::system::register_system_app(octosense_appstore::system::SystemApp {
+ id,
+ name: "Wasm Lab",
+ pack: Box::leak(packed.pack_json.into_boxed_str()),
+ assets: &[],
+ });
+ }
+
+ /// The whole path, in a process of its own (the apps root and the
+ /// registries are process-wide): Wasm Lab's tools reach its functions
+ /// through the executor and the service, its script's requests do too,
+ /// rogue code ends in an error and a fresh instance, and an app without
+ /// the grant reaches nothing.
+ #[test]
+ fn the_apps_own_functions_answer_its_tools_and_its_script() {
+ const CHILD: &str = "OCTOSENSE_TEST_WASM_SERVICE";
+ if std::env::var_os(CHILD).is_none() {
+ let output = std::process::Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", "wasm_service::tests::the_apps_own_functions_answer_its_tools_and_its_script", "--nocapture"])
+ .env(CHILD, "1")
+ .output()
+ .unwrap();
+ assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
+ return;
+ }
+ let root = std::env::temp_dir().join(format!("octosense-wasm-service-{}", std::process::id()));
+ let host_dir = root.join(".host");
+ octosense_appstore::set_data_root(root.clone());
+ ship("os.wasmlab", "service", |_, _| {});
+ // The same bundle without the capability (and so without its tools).
+ ship("os.wasmplain", "plain", |dir, manifest| {
+ manifest["capabilities"] = json!([]);
+ manifest.as_object_mut().unwrap().remove("agent");
+ std::fs::remove_file(dir.join("tools.json")).unwrap();
+ std::fs::remove_file(dir.join("AGENT.md")).unwrap();
+ });
+ register();
+
+ // An agent's tool call, as the relay hands it to the app's executor.
+ let (_, bundle) = script_apps::admitted_bundle("os.wasmlab").unwrap();
+ let loaded = script_apps::from_bundle(&bundle).unwrap();
+ let executor = HostServiceExecutor {
+ app: "os.wasmlab".into(),
+ tools: loaded.host_service_tools,
+ methods: loaded.host_methods,
+ families: loaded.families,
+ host_dir: host_dir.clone(),
+ };
+ let sent: Arc>> = Arc::default();
+ let into = sent.clone();
+ let call = HostToolCall::parse(&json!({"peer": "p", "session_id": "s", "turn_id": "t", "call_id": "c1",
+ "name": "wasmlab.find_slots",
+ "args": {"day_start": "09:00", "day_end": "12:00", "duration": 30, "busy": [["09:30", "11:00"]]}}))
+ .unwrap();
+ executor.execute(call, ToolReply::new("c1", move |v| into.lock().unwrap().push(v)));
+ for _ in 0..1000 {
+ script_apps::poll();
+ if !sent.lock().unwrap().is_empty() {
+ break;
+ }
+ std::thread::sleep(Duration::from_millis(10));
+ }
+ let answer = sent.lock().unwrap().pop().expect("the tool answered");
+ assert_eq!(answer["ok"], true, "{answer}");
+ // Every 15 minutes, the default step.
+ assert_eq!(answer["data"]["slots"], json!([["09:00", "09:30"], ["11:00", "11:30"], ["11:15", "11:45"], ["11:30", "12:00"]]), "{answer}");
+
+ // The script's own requests: a string goes in as its text, and an
+ // output that is not JSON comes back as text.
+ let html = request("os.wasmlab", "wasm.md_to_html", json!("# Hi"), &host_dir).unwrap();
+ assert_eq!(html["text"], "Hi \n");
+ let ranked = request("os.wasmlab", "wasm.fuzzy_rank", json!({"query": "cal", "items": ["Mail", "Calendar"]}), &host_dir).unwrap();
+ assert_eq!(ranked["ranked"][0]["item"], "Calendar");
+ let error = request("os.wasmlab", "wasm.find_slots", json!({"day_start": "12:00", "day_end": "09:00", "duration": 30, "busy": []}), &host_dir).unwrap_err();
+ assert!(error.contains("ends before it starts"), "{error}");
+ let error = request("os.wasmlab", "wasm.nothing", json!({}), &host_dir).unwrap_err();
+ assert!(error.contains("no function"), "{error}");
+
+ // Rogue code: an error each time, and the next call still answers.
+ for mode in ["loop", "alloc", "panic", "recurse"] {
+ let error = request("os.wasmlab", "wasm.rogue", json!({"mode": mode}), &host_dir).unwrap_err();
+ assert!(error.contains("deadline") || error.contains("trapped"), "{mode}: {error}");
+ let ranked = request("os.wasmlab", "wasm.fuzzy_rank", json!({"query": "m", "items": ["Mail"]}), &host_dir).unwrap();
+ assert_eq!(ranked["ranked"][0]["item"], "Mail", "{mode}");
+ }
+ let described = request("os.wasmlab", "wasm.functions", json!({}), &host_dir).unwrap();
+ assert_eq!(described["functions"], json!(["find_slots", "fuzzy_rank", "md_to_html", "rogue", "text_diff"]));
+ assert_eq!(described["modules"][0]["file"], "wasmlab.wasm");
+ assert_eq!(described["modules"][0]["renewed"], 4);
+ assert_eq!(described["stats"]["rogue"]["errors"], 4);
+ assert!(host_dir.join("wasm-cache").read_dir().unwrap().next().is_some(), "compiled code is cached");
+
+ // No grant, no functions: the service checks the admitted manifest
+ // itself, whoever dispatched the request.
+ let error = request("os.wasmplain", "wasm.fuzzy_rank", json!({"query": "m", "items": []}), &host_dir).unwrap_err();
+ assert!(error.contains("was not granted the wasm service"), "{error}");
+ // And a sheet never reaches it.
+ let heap = NEXT_HEAP.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ let call = ServiceCall { app_id: "os.wasmlab".into(), service: "wasm.functions".into(), args: json!({}), from_sheet: true, may_prompt: true, host_dir: host_dir.clone() };
+ octosense_appstore::services::dispatch(call, heap, 1, &mut NoSheet);
+ let (_, _, answer) = octosense_appstore::services::take_replies_for(&[heap]).pop().unwrap();
+ assert!(answer.unwrap_err().contains("no sheet"));
+ let _ = std::fs::remove_dir_all(root);
+ }
+}
diff --git a/crates/wasm-host/Cargo.toml b/crates/wasm-host/Cargo.toml
new file mode 100644
index 00000000..3117e85b
--- /dev/null
+++ b/crates/wasm-host/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "octosense-wasm-host"
+version = "0.1.0"
+edition = "2021"
+license = "Apache-2.0"
+publish = false
+
+[dependencies]
+wasmtime = { workspace = true }
+sha2 = "0.10"
+# Not used directly: Wasmtime reads a trap's registers through libc's
+# ucontext_t, and before 0.2.190 Android aarch64's lacked the kernel's
+# padding after the signal mask, so the faulting pc was misread, no trap was
+# recognised as wasm and every trap ended the app.
+libc = "0.2.190"
+
+[dev-dependencies]
+wat = "1"
+serde_json = { workspace = true }
+# The sample functions natively, to check the module against them.
+wasmlab-functions = { path = "../../apps/wasmlab/guest/functions" }
diff --git a/crates/wasm-host/examples/measure.rs b/crates/wasm-host/examples/measure.rs
new file mode 100644
index 00000000..2813830c
--- /dev/null
+++ b/crates/wasm-host/examples/measure.rs
@@ -0,0 +1,113 @@
+//! Startup and per-call latency of Wasm Lab's functions in the runtime,
+//! against the same functions called natively.
+//!
+//! cargo run --release -p octosense-wasm-host --example measure
+
+use std::time::{Duration, Instant};
+
+use octosense_wasm_host::{Limits, Runtime};
+use serde_json::{json, Value};
+
+const MODULE: &[u8] = include_bytes!("../../../apps/wasmlab/bundle/fns/wasmlab.wasm");
+
+/// Median of `runs` timings of `f`, in microseconds.
+fn median_us(runs: usize, mut f: impl FnMut()) -> f64 {
+ let mut times: Vec = (0..runs)
+ .map(|_| {
+ let t = Instant::now();
+ f();
+ t.elapsed()
+ })
+ .collect();
+ times.sort();
+ times[runs / 2].as_secs_f64() * 1e6
+}
+
+fn markdown() -> String {
+ let mut text = String::new();
+ for i in 0..200 {
+ text.push_str(&format!(
+ "## Section {i}\n\nSome *emphasis*, **strong** text, `code` and a [link](https://example.org/{i}).\n\n- [x] done {i}\n- [ ] todo {i}\n\n| key | value |\n|---|---|\n| a{i} | {i} |\n\n"
+ ));
+ }
+ text
+}
+
+fn day() -> Value {
+ let busy: Vec<[String; 2]> = (0..30)
+ .map(|i| {
+ let start = 8 * 60 + i * 19;
+ [format!("{:02}:{:02}", start / 60, start % 60), format!("{:02}:{:02}", (start + 11) / 60, (start + 11) % 60)]
+ })
+ .collect();
+ json!({"day_start": "08:00", "day_end": "19:00", "duration": 30, "step": 5, "busy": busy, "max": 50})
+}
+
+fn items() -> Value {
+ let words = ["calendar", "calculator", "camera", "clock", "mail", "maps", "notes", "news", "photos", "weather"];
+ let items: Vec = (0..500).map(|i| format!("{} {} {i}", words[i % 10], words[(i * 7) % 10])).collect();
+ json!({"query": "cal not", "items": items, "limit": 20})
+}
+
+fn texts() -> Value {
+ let old: Vec = (0..1000).map(|i| format!("line {i}: the quick brown fox")).collect();
+ let mut new = old.clone();
+ for i in (0..1000).step_by(50) {
+ new[i] = format!("line {i}: the quick red fox");
+ }
+ new.insert(500, "an inserted line".into());
+ json!({"old": old.join("\n"), "new": new.join("\n"), "context": 3})
+}
+
+fn main() {
+ println!("module: {} KiB", MODULE.len() / 1024);
+ let cache = std::env::temp_dir().join(format!("octosense-wasm-measure-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&cache);
+
+ let t = Instant::now();
+ let runtime = Runtime::new(Limits::default(), Some(cache.clone())).unwrap();
+ let program = runtime.load(MODULE).unwrap();
+ println!("compile (Cranelift, first load): {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
+ let again = Runtime::new(Limits::default(), Some(cache.clone())).unwrap();
+ let t = Instant::now();
+ let cached = again.load(MODULE).unwrap();
+ println!("load from the cache: {:.2} ms (from cache: {})", t.elapsed().as_secs_f64() * 1e3, cached.from_cache());
+ let t = Instant::now();
+ let mut guest = runtime.instantiate(&program).unwrap();
+ println!("instantiate: {:.3} ms", t.elapsed().as_secs_f64() * 1e3);
+ let _ = std::fs::remove_dir_all(&cache);
+
+ let md = markdown();
+ let (day, items, texts) = (day(), items(), texts());
+ let json_in = |v: &Value| serde_json::to_vec(v).unwrap();
+
+ println!("\n{:<44} {:>10} {:>10} {:>7}", "per call (median of 200)", "wasm us", "native us", "ratio");
+ let rows: Vec<(String, f64, f64)> = vec![
+ (
+ format!("md_to_html ({} KiB of Markdown)", md.len() / 1024),
+ median_us(200, || drop(guest.call("md_to_html", md.as_bytes()).unwrap())),
+ median_us(200, || drop(wasmlab_functions::md_to_html(md.as_bytes()).unwrap())),
+ ),
+ (
+ "find_slots (30 busy intervals, 5-min grid)".into(),
+ median_us(200, || drop(guest.call("find_slots", &json_in(&day)).unwrap())),
+ median_us(200, || drop(serde_json::to_vec(&wasmlab_functions::find_slots(serde_json::from_slice(&json_in(&day)).unwrap()).unwrap()).unwrap())),
+ ),
+ (
+ "fuzzy_rank (500 items)".into(),
+ median_us(200, || drop(guest.call("fuzzy_rank", &json_in(&items)).unwrap())),
+ median_us(200, || drop(serde_json::to_vec(&wasmlab_functions::fuzzy_rank(serde_json::from_slice(&json_in(&items)).unwrap()).unwrap()).unwrap())),
+ ),
+ (
+ "text_diff (1000 lines, 21 changes)".into(),
+ median_us(200, || drop(guest.call("text_diff", &json_in(&texts)).unwrap())),
+ median_us(200, || drop(serde_json::to_vec(&wasmlab_functions::text_diff(serde_json::from_slice(&json_in(&texts)).unwrap()).unwrap()).unwrap())),
+ ),
+ ];
+ for (name, wasm, native) in rows {
+ println!("{name:<44} {wasm:>10.1} {native:>10.1} {:>6.1}x", wasm / native);
+ }
+ let empty = json!({"query": "x", "items": [], "limit": 1});
+ let call = median_us(2000, || drop(guest.call("fuzzy_rank", &json_in(&empty)).unwrap()));
+ println!("\nsmallest call (fuzzy_rank over no items, JSON both ways): {call:.1} us");
+}
diff --git a/crates/wasm-host/src/lib.rs b/crates/wasm-host/src/lib.rs
new file mode 100644
index 00000000..364fa47b
--- /dev/null
+++ b/crates/wasm-host/src/lib.rs
@@ -0,0 +1,570 @@
+//! Developers' own functions, compiled to WebAssembly, run for their app in
+//! Wasmtime (Cranelift): light computing and algorithms an app ships beside
+//! its script (ADR 0011).
+//!
+//! # The guest ABI
+//!
+//! A module is core WebAssembly (the guest crate `octosense-guest` writes all
+//! of this for a Rust function):
+//! - it exports `memory`, `octo_alloc(len: i32) -> i32` and
+//! `octo_free(ptr: i32, len: i32)`;
+//! - every other export of type `(i32, i32) -> i64` is a function the app may
+//! call: it takes its input bytes at `(ptr, len)` and returns
+//! `(out_ptr << 32) | out_len` of a buffer it allocated, which holds a
+//! status byte (`0` ok, `1` error) and then the output, or the error text;
+//! - its only import may be `octo.log(ptr: i32, len: i32)`.
+//!
+//! # Containment
+//!
+//! A module reaches nothing but its own memory and `octo.log`: no file,
+//! network, clock or other import exists, and a module that asks for one is
+//! refused when it loads. Every call has a deadline (Wasmtime's epoch
+//! interruption), the memory and the wasm stack have caps, and input and
+//! output have a size limit. A trap, a deadline or a limit ends the call
+//! with an error; it never ends the process. The instance is spent then
+//! (its module's state may be half-updated) and refuses further calls; a
+//! fresh instance of the same program costs a fraction of a millisecond.
+
+use std::collections::HashMap;
+use std::fmt;
+use std::hash::{Hash, Hasher};
+use std::path::PathBuf;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use sha2::{Digest, Sha256};
+use wasmtime::{
+ Caller, Config, Engine, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, Trap,
+ TypedFunc, ValType,
+};
+
+/// How often the epoch advances: the granularity of a deadline.
+const TICK: Duration = Duration::from_millis(10);
+
+/// The most one `octo.log` line may hold; longer ones are cut.
+const LOG_LINE: usize = 1024;
+
+/// The most log lines an instance keeps until they are read.
+const LOG_LINES: usize = 64;
+
+/// What every instance of a [`Runtime`] may use.
+#[derive(Clone, Debug)]
+pub struct Limits {
+ /// How long one call may run.
+ pub deadline: Duration,
+ /// The most linear memory an instance may have.
+ pub memory_bytes: usize,
+ /// The largest module the runtime loads.
+ pub module_bytes: usize,
+ /// The largest input, and the largest output, of a call.
+ pub io_bytes: usize,
+ /// The wasm stack of a call.
+ pub stack_bytes: usize,
+}
+
+impl Default for Limits {
+ fn default() -> Self {
+ Limits {
+ deadline: Duration::from_secs(2),
+ memory_bytes: 256 << 20,
+ module_bytes: 8 << 20,
+ io_bytes: 16 << 20,
+ stack_bytes: 512 << 10,
+ }
+ }
+}
+
+/// Why a module cannot load.
+#[derive(Debug, Clone, PartialEq)]
+pub enum LoadError {
+ /// It is bigger than [`Limits::module_bytes`].
+ TooLarge(usize),
+ /// It is not valid WebAssembly for this engine.
+ Invalid(String),
+ /// It imports something other than `octo.log`.
+ Import(String),
+ /// It lacks `memory`, `octo_alloc` or `octo_free`, or one has the wrong type.
+ Export(String),
+}
+
+impl fmt::Display for LoadError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ LoadError::TooLarge(n) => write!(f, "the module is {n} bytes, over the limit"),
+ LoadError::Invalid(why) => write!(f, "not a valid module: {why}"),
+ LoadError::Import(what) => {
+ write!(f, "the module imports {what}; only octo.log is provided")
+ }
+ LoadError::Export(what) => write!(f, "the module does not export {what}"),
+ }
+ }
+}
+
+impl std::error::Error for LoadError {}
+
+/// Why a call failed.
+#[derive(Debug, Clone, PartialEq)]
+pub enum CallError {
+ /// The module has no such function.
+ NoSuchFunction(String),
+ /// The call ran past [`Limits::deadline`].
+ Deadline,
+ /// Its input or output is bigger than [`Limits::io_bytes`].
+ TooLarge(usize),
+ /// The function trapped: a panic, an out-of-bounds access, a stack
+ /// overflow, memory over its cap. The text says which.
+ Trap(String),
+ /// The function returned an error of its own.
+ Guest(String),
+ /// An earlier call on this instance trapped or ran past its deadline, so
+ /// it takes no more calls: instantiate the program again.
+ Spent,
+}
+
+impl fmt::Display for CallError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ CallError::NoSuchFunction(name) => write!(f, "no function {name}"),
+ CallError::Deadline => write!(f, "the call ran past its deadline"),
+ CallError::TooLarge(n) => write!(f, "{n} bytes is over the input/output limit"),
+ CallError::Trap(why) => write!(f, "the function trapped: {why}"),
+ CallError::Guest(why) => write!(f, "{why}"),
+ CallError::Spent => write!(f, "the instance stopped after an earlier trap"),
+ }
+ }
+}
+
+impl std::error::Error for CallError {}
+
+/// The engine and the limits every instance shares. Its epoch ticker runs
+/// for as long as the runtime or any of its instances lives: an instance
+/// never outlives its deadline.
+pub struct Runtime {
+ engine: Engine,
+ limits: Limits,
+ cache_dir: Option,
+ ticker: Arc,
+}
+
+/// A loaded module: checked, compiled (or taken from the cache).
+#[derive(Clone)]
+pub struct Program {
+ module: Module,
+ functions: Vec,
+ from_cache: bool,
+}
+
+impl Program {
+ /// The functions an app may call, sorted.
+ pub fn functions(&self) -> &[String] {
+ &self.functions
+ }
+
+ /// Whether the compiled code came from the cache.
+ pub fn from_cache(&self) -> bool {
+ self.from_cache
+ }
+}
+
+struct State {
+ limits: StoreLimits,
+ logs: Vec,
+}
+
+/// One app's instance of a [`Program`]: its own memory and its own state.
+/// It may move to another thread.
+pub struct Instance {
+ store: Store,
+ memory: Memory,
+ alloc: TypedFunc,
+ free: TypedFunc<(i32, i32), ()>,
+ functions: HashMap>,
+ deadline_ticks: u64,
+ io_bytes: usize,
+ /// A call trapped or ran past its deadline. The module's own state may
+ /// be half-updated then (a Rust guest's stack pointer and allocator live
+ /// in its memory and globals), so the instance takes no more calls.
+ spent: bool,
+ _ticker: Arc,
+}
+
+impl Runtime {
+ /// A runtime with these limits. Compiled code is cached in `cache_dir`
+ /// when one is given (keyed by the module's digest and the engine).
+ pub fn new(limits: Limits, cache_dir: Option) -> Result {
+ let mut config = Config::new();
+ config.epoch_interruption(true);
+ config.max_wasm_stack(limits.stack_bytes);
+ let engine = Engine::new(&config).map_err(|e| format!("{e:#}"))?;
+ let ticker = Arc::new(Ticker::start(engine.clone()));
+ Ok(Runtime {
+ engine,
+ limits,
+ cache_dir,
+ ticker,
+ })
+ }
+
+ pub fn limits(&self) -> &Limits {
+ &self.limits
+ }
+
+ /// Checks and compiles `bytes`, or takes its compiled code from the cache.
+ pub fn load(&self, bytes: &[u8]) -> Result {
+ if bytes.len() > self.limits.module_bytes {
+ return Err(LoadError::TooLarge(bytes.len()));
+ }
+ let cached = self.cache_path(bytes);
+ let mut from_cache = false;
+ let module = match cached.as_ref().and_then(|path| std::fs::read(path).ok()) {
+ // SAFETY: the cache holds only what `Module::serialize` wrote for
+ // this engine (the key includes its compatibility hash), in a
+ // directory that belongs to the shell.
+ Some(code) => match unsafe { Module::deserialize(&self.engine, &code) } {
+ Ok(module) => {
+ from_cache = true;
+ module
+ }
+ Err(_) => self.compile(bytes, cached.as_ref())?,
+ },
+ None => self.compile(bytes, cached.as_ref())?,
+ };
+ let functions = check(&module)?;
+ Ok(Program {
+ module,
+ functions,
+ from_cache,
+ })
+ }
+
+ fn compile(&self, bytes: &[u8], cache: Option<&PathBuf>) -> Result {
+ let module =
+ Module::new(&self.engine, bytes).map_err(|e| LoadError::Invalid(format!("{e:#}")))?;
+ if let Some(path) = cache {
+ // Check before caching: a refused module is never stored.
+ check(&module)?;
+ if let Ok(code) = module.serialize() {
+ let tmp = path.with_extension("tmp");
+ if std::fs::write(&tmp, &code).is_ok() && std::fs::rename(&tmp, path).is_ok() {
+ // Read it back off this thread: an on-access scanner
+ // (Microsoft Defender's, on a managed Mac) holds the
+ // first open of a new file for about a second, so let
+ // that happen now rather than at the next load.
+ let path = path.clone();
+ let _ = std::thread::Builder::new()
+ .name("wasm-cache".into())
+ .spawn(move || drop(std::fs::read(path)));
+ }
+ }
+ }
+ Ok(module)
+ }
+
+ fn cache_path(&self, bytes: &[u8]) -> Option {
+ let dir = self.cache_dir.as_ref()?;
+ std::fs::create_dir_all(dir).ok()?;
+ let mut engine = std::collections::hash_map::DefaultHasher::new();
+ self.engine
+ .precompile_compatibility_hash()
+ .hash(&mut engine);
+ let digest = Sha256::digest(bytes);
+ let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
+ Some(dir.join(format!("{hex}-{:016x}.cwasm", engine.finish())))
+ }
+
+ /// A fresh instance of `program`, with its own memory.
+ pub fn instantiate(&self, program: &Program) -> Result {
+ let limits = StoreLimitsBuilder::new()
+ .memory_size(self.limits.memory_bytes)
+ .instances(1)
+ .memories(1)
+ .tables(1)
+ .trap_on_grow_failure(true)
+ .build();
+ let mut store = Store::new(
+ &self.engine,
+ State {
+ limits,
+ logs: Vec::new(),
+ },
+ );
+ store.limiter(|state| &mut state.limits);
+ store.epoch_deadline_trap();
+ // Instantiation runs the start function and data initialisers: a
+ // deadline covers it too.
+ let deadline_ticks = self
+ .limits
+ .deadline
+ .as_millis()
+ .div_ceil(TICK.as_millis())
+ .max(1) as u64;
+ store.set_epoch_deadline(deadline_ticks);
+ let mut linker = Linker::new(&self.engine);
+ linker
+ .func_wrap(
+ "octo",
+ "log",
+ |mut caller: Caller<'_, State>, ptr: i32, len: i32| {
+ let Some(memory) = caller.get_export("memory").and_then(|e| e.into_memory())
+ else {
+ return;
+ };
+ let len = (len.max(0) as usize).min(LOG_LINE);
+ let mut buf = vec![0u8; len];
+ if memory.read(&caller, ptr as u32 as usize, &mut buf).is_ok()
+ && caller.data().logs.len() < LOG_LINES
+ {
+ let line = String::from_utf8_lossy(&buf).into_owned();
+ caller.data_mut().logs.push(line);
+ }
+ },
+ )
+ .map_err(|e| LoadError::Invalid(format!("{e:#}")))?;
+ let instance = linker
+ .instantiate(&mut store, &program.module)
+ .map_err(|e| LoadError::Invalid(format!("{e:#}")))?;
+ let memory = instance
+ .get_memory(&mut store, "memory")
+ .ok_or_else(|| LoadError::Export("memory".into()))?;
+ let alloc = instance
+ .get_typed_func::(&mut store, "octo_alloc")
+ .map_err(|_| LoadError::Export("octo_alloc(i32) -> i32".into()))?;
+ let free = instance
+ .get_typed_func::<(i32, i32), ()>(&mut store, "octo_free")
+ .map_err(|_| LoadError::Export("octo_free(i32, i32)".into()))?;
+ let mut functions = HashMap::new();
+ for name in &program.functions {
+ let f = instance
+ .get_typed_func::<(i32, i32), i64>(&mut store, name)
+ .map_err(|_| LoadError::Export(format!("{name}(i32, i32) -> i64")))?;
+ functions.insert(name.clone(), f);
+ }
+ Ok(Instance {
+ store,
+ memory,
+ alloc,
+ free,
+ functions,
+ deadline_ticks,
+ io_bytes: self.limits.io_bytes,
+ spent: false,
+ _ticker: self.ticker.clone(),
+ })
+ }
+}
+
+/// The imports and exports a module must have; returns its functions.
+fn check(module: &Module) -> Result, LoadError> {
+ for import in module.imports() {
+ let ok = import.module() == "octo"
+ && import.name() == "log"
+ && import.ty().func().is_some_and(|f| {
+ f.params().map(|t| t.is_i32()).eq([true, true]) && f.results().len() == 0
+ });
+ if !ok {
+ return Err(LoadError::Import(format!(
+ "{}.{}",
+ import.module(),
+ import.name()
+ )));
+ }
+ }
+ let mut functions = Vec::new();
+ let mut memory = false;
+ let mut alloc = false;
+ let mut free = false;
+ for export in module.exports() {
+ let ty = export.ty();
+ match export.name() {
+ "memory" => memory = ty.memory().is_some(),
+ "octo_alloc" => {
+ alloc = ty
+ .func()
+ .is_some_and(|f| sig(f.params(), f.results(), &[ValType::I32], &[ValType::I32]))
+ }
+ "octo_free" => {
+ free = ty.func().is_some_and(|f| {
+ sig(f.params(), f.results(), &[ValType::I32, ValType::I32], &[])
+ })
+ }
+ name => {
+ if ty.func().is_some_and(|f| {
+ sig(
+ f.params(),
+ f.results(),
+ &[ValType::I32, ValType::I32],
+ &[ValType::I64],
+ )
+ }) {
+ functions.push(name.to_string());
+ }
+ }
+ }
+ }
+ for (have, what) in [
+ (memory, "memory"),
+ (alloc, "octo_alloc(i32) -> i32"),
+ (free, "octo_free(i32, i32)"),
+ ] {
+ if !have {
+ return Err(LoadError::Export(what.into()));
+ }
+ }
+ functions.sort();
+ Ok(functions)
+}
+
+fn sig(
+ params: impl ExactSizeIterator- ,
+ results: impl ExactSizeIterator
- ,
+ want_params: &[ValType],
+ want_results: &[ValType],
+) -> bool {
+ let same = |a: ValType, b: &ValType| {
+ matches!(
+ (a, b),
+ (ValType::I32, ValType::I32) | (ValType::I64, ValType::I64)
+ )
+ };
+ params.len() == want_params.len()
+ && results.len() == want_results.len()
+ && params.zip(want_params).all(|(a, b)| same(a, b))
+ && results.zip(want_results).all(|(a, b)| same(a, b))
+}
+
+impl Instance {
+ /// The functions this instance has, sorted.
+ pub fn functions(&self) -> Vec
{
+ let mut names: Vec = self.functions.keys().cloned().collect();
+ names.sort();
+ names
+ }
+
+ /// Calls `name` with `input` and returns its output. After a trap or a
+ /// deadline the instance is spent ([`CallError::Spent`]): instantiate
+ /// the program again, which is cheap. A function's own error leaves the
+ /// instance as it was.
+ pub fn call(&mut self, name: &str, input: &[u8]) -> Result, CallError> {
+ if self.spent {
+ return Err(CallError::Spent);
+ }
+ let f = self
+ .functions
+ .get(name)
+ .cloned()
+ .ok_or_else(|| CallError::NoSuchFunction(name.into()))?;
+ if input.len() > self.io_bytes {
+ return Err(CallError::TooLarge(input.len()));
+ }
+ let result = self.run(f, input);
+ if matches!(result, Err(CallError::Deadline | CallError::Trap(_))) {
+ self.spent = true;
+ }
+ result
+ }
+
+ /// Whether a call trapped or ran past its deadline ([`Instance::call`]).
+ pub fn spent(&self) -> bool {
+ self.spent
+ }
+
+ fn run(&mut self, f: TypedFunc<(i32, i32), i64>, input: &[u8]) -> Result, CallError> {
+ self.store.set_epoch_deadline(self.deadline_ticks);
+ let len = input.len() as i32;
+ let ptr = self.alloc.call(&mut self.store, len).map_err(trap)?;
+ self.memory
+ .write(&mut self.store, ptr as u32 as usize, input)
+ .map_err(|_| CallError::Trap("octo_alloc returned memory out of bounds".into()))?;
+ let packed = f.call(&mut self.store, (ptr, len)).map_err(trap)?;
+ self.free.call(&mut self.store, (ptr, len)).map_err(trap)?;
+ let out_ptr = (packed as u64 >> 32) as usize;
+ let out_len = (packed as u64 & 0xffff_ffff) as usize;
+ if out_len > self.io_bytes + 1 {
+ self.free
+ .call(&mut self.store, (out_ptr as i32, out_len as i32))
+ .map_err(trap)?;
+ return Err(CallError::TooLarge(out_len));
+ }
+ let mut out = vec![0u8; out_len];
+ self.memory
+ .read(&self.store, out_ptr, &mut out)
+ .map_err(|_| CallError::Trap("the output is out of bounds".into()))?;
+ self.free
+ .call(&mut self.store, (out_ptr as i32, out_len as i32))
+ .map_err(trap)?;
+ match out.first() {
+ Some(0) => {
+ out.remove(0);
+ Ok(out)
+ }
+ Some(1) => Err(CallError::Guest(
+ String::from_utf8_lossy(&out[1..]).into_owned(),
+ )),
+ _ => Err(CallError::Trap("the output has no status byte".into())),
+ }
+ }
+
+ /// The lines the module logged since the last read.
+ pub fn take_logs(&mut self) -> Vec {
+ std::mem::take(&mut self.store.data_mut().logs)
+ }
+
+ /// The instance's linear memory, in bytes.
+ pub fn memory_bytes(&self) -> usize {
+ self.memory.data_size(&self.store)
+ }
+}
+
+fn trap(error: wasmtime::Error) -> CallError {
+ match error.downcast_ref::() {
+ Some(Trap::Interrupt) => CallError::Deadline,
+ Some(trap) => CallError::Trap(trap.to_string()),
+ // A limit the store enforces: say which, without the wasm backtrace
+ // wrapped around it.
+ None => {
+ let cause = error.root_cause().to_string();
+ if cause.contains("growing memory") {
+ CallError::Trap(format!("memory over its cap ({cause})"))
+ } else {
+ CallError::Trap(cause)
+ }
+ }
+ }
+}
+
+/// Advances the engine's epoch every [`TICK`] until dropped.
+struct Ticker {
+ stop: Arc,
+}
+
+impl Ticker {
+ fn start(engine: Engine) -> Ticker {
+ let stop = Arc::new(AtomicBool::new(false));
+ let flag = stop.clone();
+ std::thread::Builder::new()
+ .name("wasm-epoch".into())
+ .spawn(move || {
+ // Advance by the time that has passed, not by the sleeps: a
+ // sleep can overrun, and a deadline is wall-clock time.
+ let started = Instant::now();
+ let mut ticks = 0u128;
+ while !flag.load(Ordering::Relaxed) {
+ std::thread::sleep(TICK);
+ let due = started.elapsed().as_nanos() / TICK.as_nanos();
+ while ticks < due {
+ engine.increment_epoch();
+ ticks += 1;
+ }
+ }
+ })
+ .expect("the wasm epoch thread");
+ Ticker { stop }
+ }
+}
+
+impl Drop for Ticker {
+ fn drop(&mut self) {
+ self.stop.store(true, Ordering::Relaxed);
+ }
+}
diff --git a/crates/wasm-host/tests/guest.rs b/crates/wasm-host/tests/guest.rs
new file mode 100644
index 00000000..aaf5cd2a
--- /dev/null
+++ b/crates/wasm-host/tests/guest.rs
@@ -0,0 +1,170 @@
+//! Wasm Lab's real Rust functions (apps/wasmlab/guest, built into
+//! apps/wasmlab/bundle/fns/wasmlab.wasm by its build.sh) in the runtime:
+//! each gives exactly what the same code gives natively, and each way the
+//! rogue function misbehaves ends in an error.
+
+use std::time::{Duration, Instant};
+
+use octosense_wasm_host::{CallError, Instance, Limits, Program, Runtime};
+use serde_json::{json, Value};
+
+const MODULE: &[u8] = include_bytes!("../../../apps/wasmlab/bundle/fns/wasmlab.wasm");
+
+fn lab() -> (Runtime, Program) {
+ let runtime = Runtime::new(
+ Limits {
+ deadline: Duration::from_millis(500),
+ memory_bytes: 64 << 20,
+ ..Limits::default()
+ },
+ None,
+ )
+ .unwrap();
+ let program = runtime.load(MODULE).unwrap();
+ (runtime, program)
+}
+
+fn guest() -> Instance {
+ let (runtime, program) = lab();
+ // The instance outlives the runtime, and keeps its deadline.
+ runtime.instantiate(&program).unwrap()
+}
+
+fn call_json(guest: &mut Instance, name: &str, input: Value) -> Value {
+ let out = guest
+ .call(name, &serde_json::to_vec(&input).unwrap())
+ .unwrap();
+ serde_json::from_slice(&out).unwrap()
+}
+
+#[test]
+fn the_module_offers_its_functions_and_imports_only_log() {
+ assert_eq!(
+ guest().functions(),
+ [
+ "find_slots",
+ "fuzzy_rank",
+ "md_to_html",
+ "rogue",
+ "text_diff"
+ ]
+ );
+}
+
+#[test]
+fn markdown_matches_native() {
+ let text = "# Notes\n\nSome *emphasis*, `code` and a [link](https://example.org).\n\n- [x] done\n- [ ] todo\n\n| a | b |\n|---|---|\n| 1 | 2 |\n";
+ let wasm = guest().call("md_to_html", text.as_bytes()).unwrap();
+ assert_eq!(
+ wasm,
+ wasmlab_functions::md_to_html(text.as_bytes()).unwrap()
+ );
+}
+
+#[test]
+fn slots_match_native() {
+ let input = json!({"day_start": "08:30", "day_end": "18:00", "duration": 45, "step": 15,
+ "busy": [["09:00", "10:00"], ["09:45", "11:15"], ["13:00", "14:00"], ["16:50", "17:05"]], "max": 20});
+ let wasm = call_json(&mut guest(), "find_slots", input.clone());
+ let native = wasmlab_functions::find_slots(serde_json::from_value(input).unwrap()).unwrap();
+ assert_eq!(wasm, serde_json::to_value(native).unwrap());
+ assert_eq!(wasm["slots"][0], json!(["11:15", "12:00"]));
+}
+
+#[test]
+fn ranking_matches_native() {
+ let items = [
+ "Calendar",
+ "Calculator",
+ "Camera",
+ "Local calls",
+ "Mail",
+ "Clock",
+ "Weather",
+ "Cal Newport notes",
+ ];
+ let input = json!({"query": "cal", "items": items, "limit": 5});
+ let wasm = call_json(&mut guest(), "fuzzy_rank", input.clone());
+ let native = wasmlab_functions::fuzzy_rank(serde_json::from_value(input).unwrap()).unwrap();
+ assert_eq!(wasm, serde_json::to_value(native).unwrap());
+}
+
+#[test]
+fn diff_matches_native() {
+ let input = json!({"old": "one\ntwo\nthree\nfour\n", "new": "one\n2\nthree\nfour\nfive\n", "context": 1});
+ let wasm = call_json(&mut guest(), "text_diff", input.clone());
+ let native = wasmlab_functions::text_diff(serde_json::from_value(input).unwrap()).unwrap();
+ assert_eq!(wasm, serde_json::to_value(native).unwrap());
+}
+
+#[test]
+fn bad_input_is_the_functions_own_error() {
+ let error = guest()
+ .call("find_slots", b"{\"day_start\": 9}")
+ .unwrap_err();
+ assert!(
+ matches!(&error, CallError::Guest(why) if why.contains("find_slots")),
+ "{error:?}"
+ );
+ let error = guest()
+ .call(
+ "find_slots",
+ br#"{"day_start": "18:00", "day_end": "09:00", "duration": 30}"#,
+ )
+ .unwrap_err();
+ assert_eq!(
+ error,
+ CallError::Guest("the day ends before it starts".into())
+ );
+}
+
+#[test]
+fn rogue_code_ends_in_an_error_and_spends_only_its_own_instance() {
+ let (runtime, program) = lab();
+ let rogue = |mode: &str| {
+ let mut guest = runtime.instantiate(&program).unwrap();
+ let error = guest
+ .call("rogue", json!({ "mode": mode }).to_string().as_bytes())
+ .unwrap_err();
+ assert!(guest.spent(), "{mode}");
+ assert_eq!(
+ guest.call("fuzzy_rank", br#"{"query": "m", "items": []}"#),
+ Err(CallError::Spent)
+ );
+ (error, guest)
+ };
+
+ let started = Instant::now();
+ assert_eq!(rogue("loop").0, CallError::Deadline);
+ assert!(started.elapsed() < Duration::from_secs(2));
+
+ let (error, guest) = rogue("alloc");
+ assert!(
+ matches!(&error, CallError::Trap(why) if why.starts_with("memory over its cap") && !why.contains("backtrace")),
+ "{error:?}"
+ );
+ assert!(guest.memory_bytes() <= 64 << 20);
+
+ let (error, mut guest) = rogue("panic");
+ assert!(matches!(&error, CallError::Trap(_)), "{error:?}");
+ let logs = guest.take_logs();
+ assert!(
+ logs.iter().any(|l| l.contains("rogue: a deliberate panic")),
+ "{logs:?}"
+ );
+
+ let (error, _) = rogue("recurse");
+ assert!(
+ matches!(&error, CallError::Trap(why) if why.contains("stack")),
+ "{error:?}"
+ );
+
+ // A fresh instance of the same program answers after all of it.
+ let mut fresh = runtime.instantiate(&program).unwrap();
+ let out = call_json(
+ &mut fresh,
+ "fuzzy_rank",
+ json!({"query": "mail", "items": ["Mail", "Maps"]}),
+ );
+ assert_eq!(out["ranked"][0]["item"], "Mail");
+}
diff --git a/crates/wasm-host/tests/runtime.rs b/crates/wasm-host/tests/runtime.rs
new file mode 100644
index 00000000..0bf449b7
--- /dev/null
+++ b/crates/wasm-host/tests/runtime.rs
@@ -0,0 +1,255 @@
+//! The runtime against a hand-written guest that follows the ABI: a bump
+//! allocator, a function that works, and functions that misbehave in each
+//! way the containment has to stop.
+
+use std::time::{Duration, Instant};
+
+use octosense_wasm_host::{CallError, Instance, Limits, LoadError, Runtime};
+
+const GUEST: &str = r#"
+(module
+ (import "octo" "log" (func $log (param i32 i32)))
+ (memory (export "memory") 1)
+ (global $heap (mut i32) (i32.const 1024))
+ (global $count (mut i32) (i32.const 0))
+ (data (i32.const 16) "\01nope")
+ (data (i32.const 32) "\00")
+
+ (func $alloc (export "octo_alloc") (param $len i32) (result i32)
+ (local $p i32)
+ (local.set $p (global.get $heap))
+ (block $fits
+ (br_if $fits (i32.le_u (i32.add (local.get $p) (local.get $len))
+ (i32.mul (memory.size) (i32.const 65536))))
+ (drop (memory.grow (i32.add (i32.shr_u (local.get $len) (i32.const 16)) (i32.const 1)))))
+ (global.set $heap (i32.and (i32.add (i32.add (local.get $p) (local.get $len)) (i32.const 7))
+ (i32.const -8)))
+ (local.get $p))
+
+ (func (export "octo_free") (param i32 i32))
+
+ (func $pack (param $p i32) (param $n i32) (result i64)
+ (i64.or (i64.shl (i64.extend_i32_u (local.get $p)) (i64.const 32))
+ (i64.extend_i32_u (local.get $n))))
+
+ ;; The input in upper case.
+ (func (export "upper") (param $ptr i32) (param $len i32) (result i64)
+ (local $out i32) (local $i i32) (local $c i32)
+ (local.set $out (call $alloc (i32.add (local.get $len) (i32.const 1))))
+ (i32.store8 (local.get $out) (i32.const 0))
+ (block $done
+ (loop $next
+ (br_if $done (i32.ge_u (local.get $i) (local.get $len)))
+ (local.set $c (i32.load8_u (i32.add (local.get $ptr) (local.get $i))))
+ (if (i32.and (i32.ge_u (local.get $c) (i32.const 97)) (i32.le_u (local.get $c) (i32.const 122)))
+ (then (local.set $c (i32.sub (local.get $c) (i32.const 32)))))
+ (i32.store8 (i32.add (i32.add (local.get $out) (i32.const 1)) (local.get $i)) (local.get $c))
+ (local.set $i (i32.add (local.get $i) (i32.const 1)))
+ (br $next)))
+ (call $pack (local.get $out) (i32.add (local.get $len) (i32.const 1))))
+
+ ;; An error of the guest's own.
+ (func (export "fail") (param i32 i32) (result i64)
+ (call $pack (i32.const 16) (i32.const 5)))
+
+ ;; An endless loop.
+ (func (export "spin") (param i32 i32) (result i64)
+ (loop $forever (br $forever))
+ (unreachable))
+
+ ;; A panic.
+ (func (export "boom") (param i32 i32) (result i64)
+ (unreachable))
+
+ ;; Runaway recursion.
+ (func $deep (export "deep") (param i32 i32) (result i64)
+ (call $deep (local.get 0) (local.get 1)))
+
+ ;; A gigabyte more memory.
+ (func (export "hog") (param i32 i32) (result i64)
+ (drop (memory.grow (i32.const 16384)))
+ (call $pack (i32.const 32) (i32.const 1)))
+
+ ;; Logs its input.
+ (func (export "say") (param $ptr i32) (param $len i32) (result i64)
+ (call $log (local.get $ptr) (local.get $len))
+ (call $pack (i32.const 32) (i32.const 1)))
+
+ ;; How many times it was called, as one digit.
+ (func (export "count") (param i32 i32) (result i64)
+ (global.set $count (i32.add (global.get $count) (i32.const 1)))
+ (i32.store8 (i32.const 40) (i32.const 0))
+ (i32.store8 (i32.const 41) (i32.add (i32.const 48) (global.get $count)))
+ (call $pack (i32.const 40) (i32.const 2))))
+"#;
+
+fn limits() -> Limits {
+ Limits {
+ deadline: Duration::from_millis(200),
+ memory_bytes: 64 << 20,
+ ..Limits::default()
+ }
+}
+
+fn instance(runtime: &Runtime) -> Instance {
+ let program = runtime.load(&wat::parse_str(GUEST).unwrap()).unwrap();
+ runtime.instantiate(&program).unwrap()
+}
+
+#[test]
+fn a_function_gets_its_input_and_returns_its_output() {
+ let runtime = Runtime::new(limits(), None).unwrap();
+ let mut guest = instance(&runtime);
+ assert_eq!(
+ guest.functions(),
+ ["boom", "count", "deep", "fail", "hog", "say", "spin", "upper"]
+ );
+ assert_eq!(guest.call("upper", b"hello, Octo").unwrap(), b"HELLO, OCTO");
+ assert_eq!(guest.call("upper", b"").unwrap(), b"");
+ assert_eq!(
+ guest.call("nope", b""),
+ Err(CallError::NoSuchFunction("nope".into()))
+ );
+}
+
+#[test]
+fn a_guest_error_comes_back_as_its_text_and_the_instance_goes_on() {
+ let runtime = Runtime::new(limits(), None).unwrap();
+ let mut guest = instance(&runtime);
+ assert_eq!(guest.call("fail", b""), Err(CallError::Guest("nope".into())));
+ assert!(!guest.spent());
+ assert_eq!(guest.call("upper", b"still here").unwrap(), b"STILL HERE");
+}
+
+#[test]
+fn an_endless_loop_stops_at_its_deadline_and_spends_the_instance() {
+ let runtime = Runtime::new(limits(), None).unwrap();
+ let program = runtime.load(&wat::parse_str(GUEST).unwrap()).unwrap();
+ let mut guest = runtime.instantiate(&program).unwrap();
+ let started = Instant::now();
+ assert_eq!(guest.call("spin", b""), Err(CallError::Deadline));
+ let took = started.elapsed();
+ // The deadline is 200 ms of wall-clock time, give or take a tick.
+ assert!(
+ took >= Duration::from_millis(190) && took < Duration::from_millis(400),
+ "stopped after {took:?}"
+ );
+ // Its state may be half-updated: it takes no more calls, and a fresh
+ // instance of the same program does.
+ assert!(guest.spent());
+ assert_eq!(guest.call("upper", b"x"), Err(CallError::Spent));
+ let mut fresh = runtime.instantiate(&program).unwrap();
+ assert_eq!(fresh.call("upper", b"still here").unwrap(), b"STILL HERE");
+}
+
+#[test]
+fn memory_beyond_the_cap_ends_the_call() {
+ let runtime = Runtime::new(limits(), None).unwrap();
+ let mut guest = instance(&runtime);
+ assert!(matches!(guest.call("hog", b""), Err(CallError::Trap(_))));
+ assert!(guest.memory_bytes() <= 64 << 20);
+}
+
+#[test]
+fn a_trap_is_an_error_not_a_crash() {
+ let runtime = Runtime::new(limits(), None).unwrap();
+ let mut guest = instance(&runtime);
+ let error = guest.call("boom", b"").unwrap_err();
+ assert!(
+ matches!(&error, CallError::Trap(why) if why.contains("unreachable")),
+ "{error:?}"
+ );
+ assert_eq!(guest.call("upper", b"x"), Err(CallError::Spent));
+}
+
+#[test]
+fn runaway_recursion_traps_at_the_stack_cap() {
+ let runtime = Runtime::new(limits(), None).unwrap();
+ let error = instance(&runtime).call("deep", b"").unwrap_err();
+ assert!(
+ matches!(&error, CallError::Trap(why) if why.contains("stack")),
+ "{error:?}"
+ );
+}
+
+#[test]
+fn log_is_the_only_import() {
+ let runtime = Runtime::new(limits(), None).unwrap();
+ let mut guest = instance(&runtime);
+ guest.call("say", b"hi from the guest").unwrap();
+ assert_eq!(guest.take_logs(), ["hi from the guest"]);
+ let wasi = r#"(module
+ (import "wasi_snapshot_preview1" "fd_write" (func (param i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 1)
+ (func (export "octo_alloc") (param i32) (result i32) (i32.const 0))
+ (func (export "octo_free") (param i32 i32)))"#;
+ assert_eq!(
+ runtime.load(&wat::parse_str(wasi).unwrap()).err(),
+ Some(LoadError::Import("wasi_snapshot_preview1.fd_write".into()))
+ );
+ let no_alloc =
+ r#"(module (memory (export "memory") 1) (func (export "octo_free") (param i32 i32)))"#;
+ assert!(matches!(
+ runtime.load(&wat::parse_str(no_alloc).unwrap()),
+ Err(LoadError::Export(_))
+ ));
+}
+
+#[test]
+fn instances_keep_their_own_state() {
+ let runtime = Runtime::new(limits(), None).unwrap();
+ let (mut a, mut b) = (instance(&runtime), instance(&runtime));
+ for _ in 0..3 {
+ a.call("count", b"").unwrap();
+ }
+ assert_eq!(a.call("count", b"").unwrap(), b"4");
+ assert_eq!(b.call("count", b"").unwrap(), b"1");
+}
+
+#[test]
+fn an_instance_can_run_on_another_thread() {
+ let runtime = Runtime::new(limits(), None).unwrap();
+ let mut guest = instance(&runtime);
+ let out = std::thread::spawn(move || guest.call("upper", b"worker").unwrap())
+ .join()
+ .unwrap();
+ assert_eq!(out, b"WORKER");
+}
+
+#[test]
+fn input_over_the_limit_is_refused() {
+ let runtime = Runtime::new(
+ Limits {
+ io_bytes: 16,
+ ..limits()
+ },
+ None,
+ )
+ .unwrap();
+ assert_eq!(
+ instance(&runtime).call("upper", &[b'a'; 17]),
+ Err(CallError::TooLarge(17))
+ );
+}
+
+#[test]
+fn compiled_code_is_cached_by_digest() {
+ let dir =
+ std::env::temp_dir().join(format!("octosense-wasm-host-cache-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ let bytes = wat::parse_str(GUEST).unwrap();
+ let first = Runtime::new(limits(), Some(dir.clone())).unwrap();
+ assert!(!first.load(&bytes).unwrap().from_cache());
+ let second = Runtime::new(limits(), Some(dir.clone())).unwrap();
+ let program = second.load(&bytes).unwrap();
+ assert!(program.from_cache());
+ assert_eq!(
+ second
+ .instantiate(&program)
+ .unwrap()
+ .call("upper", b"cached")
+ .unwrap(),
+ b"CACHED"
+ );
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml
index bd9430bd..a470e04e 100644
--- a/desktop/Cargo.toml
+++ b/desktop/Cargo.toml
@@ -71,5 +71,7 @@ app-aichat = ["octosense-shell/app-aichat"]
# Developer mode from Settings and OCTOSENSE_DEV_MODE in a release build
# (debug builds have it already; ADR 0004 §13).
dev-mode = ["octosense-shell/dev-mode"]
+# Apps' own WebAssembly functions (ADR 0011; off by default).
+wasm-lab = ["octosense-shell/wasm-lab"]
# The system toolbox offered to app agents (off by default; ADR 0002 §6).
toolbox-peers = ["octosense-shell/toolbox-peers"]
diff --git a/desktop/system-apps-wasm-lab.json b/desktop/system-apps-wasm-lab.json
new file mode 100644
index 00000000..c0158fdd
--- /dev/null
+++ b/desktop/system-apps-wasm-lab.json
@@ -0,0 +1,15 @@
+{
+ "schema": 1,
+ "source": "../apps",
+ "apps": [
+ "news",
+ "photos",
+ "maps",
+ "mail",
+ "calendar",
+ "ai-providers",
+ "youtube",
+ "wasmlab"
+ ],
+ "assets": {}
+}
diff --git a/docs/adr/0011-apps-own-functions-in-webassembly.md b/docs/adr/0011-apps-own-functions-in-webassembly.md
new file mode 100644
index 00000000..241d69f6
--- /dev/null
+++ b/docs/adr/0011-apps-own-functions-in-webassembly.md
@@ -0,0 +1,161 @@
+# ADR 0011: An app's own functions, in WebAssembly
+
+English | [简体中文](0011-apps-own-functions-in-webassembly.zh-CN.md)
+
+Status: Proposed. Implemented behind the shell's `wasm-lab` feature (off by
+default), with the contract side in App Hub #140 (app-contract 1.7.0).
+Validated on macOS in a hidden desktop shell and on an Android
+phone (Redmi Note 12) in a separately packaged Home; iOS and OpenHarmony not
+tried.
+
+## Context
+
+A script app is Splash: contained, interpreted, and limited to what its
+runtime offers. Some of an app's logic is better written in Rust: a parser, a
+ranking or scheduling algorithm, a diff, a format conversion, or an existing
+crate that does exactly the job. Today the only ways to run Rust for an app
+are a native app (linked into the shell, fully trusted: not for third
+parties) or a host service (the shell's own code, written by us). Splash's
+compute kernels cover numeric work, not general Rust.
+
+We want a developer to ship their own Rust functions inside their bundle, and
+the shell to run them for that app only, with no new trust: nothing the
+functions can reach that the app could not already reach, and nothing at all
+outside their own input and output.
+
+## Decision
+
+1. **Modules.** A bundle may carry core WebAssembly modules as `fns/*.wasm`
+ (at most 8). A module exports `memory`, `octo_alloc(len) -> ptr`,
+ `octo_free(ptr, len)` and its functions, each `(ptr, len) -> i64` returning
+ `(out_ptr << 32) | out_len` of an output that starts with a status byte (0
+ ok, 1 error). Its only import may be `octo.log(ptr, len)`. A small guest
+ crate (`apps/wasmlab/guest/octosense-guest`) writes all of this for
+ `fn(&[u8]) -> Result, String>` and for serde types. No component
+ model and no WASI in this version.
+2. **Runtime.** `crates/wasm-host` runs modules in Wasmtime 49, compiled by
+ Cranelift when they load. Compiled code is cached in the host directory,
+ keyed by the module's digest and the engine's compatibility hash. Every
+ call has a deadline (2 s, epoch interruption against wall-clock time);
+ memory (256 MiB), the wasm stack (512 KiB), the module (8 MiB) and input
+ and output (16 MiB) have caps. A trap or a deadline ends the call with an
+ error, never the process, and spends the instance: the next call gets a
+ fresh one, because a Rust guest's stack pointer and allocator may be
+ half-updated.
+3. **The `wasm` host service** (shell feature `wasm-lab`). `wasm.`
+ calls one of the calling app's functions; `wasm.functions` lists them, how
+ they loaded and how they have run. A string argument goes in as its text,
+ anything else as JSON; JSON output comes back as data, anything else as
+ `{"text": …}`. Modules come only from the calling app's own admitted,
+ digest-checked bundle, never from an argument, and the service checks the
+ admitted manifest's grant itself. Each app has one worker thread, so no
+ call runs on the UI thread and a slow function holds up only its own app.
+4. **The `wasm` capability** joins App Hub's closed capability list. A store
+ says: "Runs its own functions in a sandbox on this device; they reach no
+ files, network or other apps."
+5. **Agent tools.** A tool may name `host_method: "wasm."`: the
+ app's agent (and, if shared, other callers) runs the app's own function.
+ It needs the `wasm` capability, and unlike shared host methods it has no
+ risk floor and no `private_data` requirement: the function sees only the
+ arguments it is given.
+
+## Alternatives measured
+
+CoreMark on the M5 Max (higher is faster; native Rust is about 45,000):
+
+| Runtime | CoreMark | Notes |
+| --- | --- | --- |
+| Wasmtime + Cranelift (chosen) | 44,534 | 7.4 MiB in the binary; compiles when a module loads |
+| WasmEdge AOT | 30,432 | 153 MiB library; 0.2–0.36 s to compile |
+| stitch (interpreter) | 2,942 | makepad-friendly, no JIT; needs a host-call fix (makepad #106) |
+| Pulley (Wasmtime's interpreter) | 2,587 | no JIT: the iOS option |
+| WasmEdge interpreter | 374 | |
+
+- **Native dynamic libraries** give no isolation; Theseus OS's model (a
+ trusted compiler, one address space) does not apply to a phone OS.
+- **Upstream Makepad's JIT** compiles Splash, not Rust.
+- **Splash compute kernels** stay the answer for numeric kernels.
+
+## Measurements (Wasm Lab, `apps/wasmlab`)
+
+Light algorithms only: Markdown to HTML (pulldown-cmark), free slots around
+busy times, fuzzy ranking (strsim), a line diff (similar).
+
+Medians of 200 calls, release builds, three runs on the desktop and three on
+the phone (`cargo run --release -p octosense-wasm-host --example measure`;
+on the phone the same example, cross-compiled and run over adb):
+
+| | Desktop (M5 Max) | Phone (Redmi Note 12, Snapdragon 685) |
+| --- | --- | --- |
+| Module | 433 KiB | 433 KiB |
+| Compile (first load) | 27–33 ms | 378–421 ms |
+| Load from the cache | 1.0–1.4 ms in a new process; 4.8–6.4 ms right after writing | 5.0–7.0 ms |
+| Instantiate | 0.07 ms | 0.19–0.27 ms |
+| md_to_html, 32 KiB | 405–412 µs (2.1–2.2× native) | 2,325–2,328 µs (1.7×) |
+| find_slots, 30 busy intervals | 6.7 µs (1.7×) | 40 µs (1.5×) |
+| fuzzy_rank, 500 items | 138–146 µs (1.6–1.7×) | 841–845 µs (1.4×) |
+| text_diff, 1000 lines | 231–254 µs (1.6–1.7×) | 1,434 µs (1.9×) |
+| Smallest call (JSON both ways) | 0.3 µs | 2.5–2.6 µs |
+
+Building the guest with wasm SIMD (`+simd128`) did not narrow the Markdown
+gap (2.2–2.3×): it is Cranelift's code generation against LLVM's, not
+vectorisation. Wasmtime and Cranelift add about 7.5 MiB of code to the
+Android library (symbol sizes; 7.4 MiB measured on the desktop).
+
+In the shell, a script's `host.request` to its function and back takes about
+2 ms on the desktop and 1.8–2 ms on the phone, almost all of it the UI event
+loop; the function itself takes 18–72 µs on the desktop and 47–190 µs on the
+phone. In Home on the phone the module compiled in 670 ms on the first launch
+(the app was still starting) and loaded from the cache in 7.4–11 ms after.
+The four misbehaving modes (an endless loop, endless allocation, a panic,
+runaway recursion) each ended in an error after 2,000 ms, 140 ms, 2.2 ms and
+3 ms, and Home kept running.
+
+Two platform findings:
+
+- **Android traps needed libc 0.2.190.** With libc 0.2.189 every trap that
+ Wasmtime catches by signal (a panic's `unreachable`, an out-of-bounds load,
+ a stack overflow) killed Home, and a plain binary too. Wasmtime's handler
+ ran but read the faulting pc through libc's `ucontext_t`, whose aarch64
+ Android layout lacked the kernel's padding after the signal mask, so no
+ trap looked like wasm. 0.2.190 fixed the layout; `crates/wasm-host` now
+ requires it. Explicit traps (`signals_based_traps(false)`) also worked
+ but cost 19–38% per call and doubled the compile time.
+- **A desktop scanner can make the cache slower than compiling.** On a
+ managed Mac, Microsoft Defender holds the first open of a freshly written
+ cache file for about a second, and again the first time a new build of the
+ shell opens it (1,086 ms; 2.3 ms on the next launch). The writer reads the
+ file back in the background, which covers the first case only. Compiling
+ takes 30–60 ms there, so a desktop could skip the cache or race the two;
+ on the phone the cache saves about 0.4 s.
+
+## Consequences
+
+- Third-party Rust runs in OctoSense apps at 1.4–2.2× native speed for these
+ workloads, with no new trust and no change to the default build.
+- The Android APK grows by 3.5 MiB (209.1 to 212.8 MB, with Wasm Lab's own
+ bundle); the runtime is about 7.5 MiB of code.
+- Compiling costs about 0.4 s per module on a mid-range phone (0.67 s while
+ Home is starting). The cache makes later loads 5–11 ms; compiling at
+ install time (store apps) or at build time (system apps) would remove the
+ first one.
+- A store bundle may carry `fns/*.wasm` under App Hub #140's gate (a core
+ module, at most 8, only with the `wasm` capability). So far only a system
+ app has run functions on a device.
+- iOS allows no JIT for apps and no downloaded native code: there, Pulley
+ (about 17× slower than Cranelift). A shipped system app's functions could
+ be translated to native code when the app is built; a store app's could
+ not. OpenHarmony's JIT policy is unknown (**unverified**).
+- The limits are per call and per instance. Nothing yet stops an app from
+ keeping one core busy with back-to-back calls, or from using 256 MiB in
+ each of its eight modules; a per-app CPU and memory budget is follow-up
+ work. A request that App Hub has timed out still runs in the worker.
+- An app updated while the shell runs keeps its old functions until the
+ shell restarts; workers live as long as the shell.
+
+## Open questions
+
+- Typed interfaces (the component model and WIT) instead of JSON in and out.
+- Any host import beyond `octo.log` (a clock, randomness) would be a new
+ capability, not a default.
+- Epoch deadlines are cheap but not deterministic; fuel would be, at a cost.
diff --git a/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md b/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md
new file mode 100644
index 00000000..0c1bec3d
--- /dev/null
+++ b/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md
@@ -0,0 +1,127 @@
+# ADR 0011:应用自带的 WebAssembly 函数
+
+[English](0011-apps-own-functions-in-webassembly.md) | 简体中文
+
+状态:提议中。在外壳的 `wasm-lab` 特性(默认关闭)之后实现,契约部分在 App Hub #140
+(app-contract 1.7.0)。已在 macOS 的隐藏桌面外壳中,以及一部
+Android 手机(Redmi Note 12)上单独打包的 Home 中验证;iOS 和 OpenHarmony 尚未尝试。
+
+## 背景
+
+脚本应用使用 Splash:受隔离、解释执行,能力以运行时提供的为限。应用里有些逻辑更适合用
+Rust 写:解析器、排序或排程算法、差异比较、格式转换,或者现成的、正好能完成任务的 crate。
+目前为应用运行 Rust 只有两种方式:原生应用(链接进外壳,完全受信任,不适合第三方)或宿主
+服务(外壳自己的代码,由我们编写)。Splash 的计算内核适合数值计算,不适合通用 Rust。
+
+我们希望开发者能把自己的 Rust 函数放进应用包里,由外壳只为这个应用运行,而且不引入新的
+信任:函数能接触的不超过应用本来能接触的,除了自己的输入和输出,什么都接触不到。
+
+## 决定
+
+1. **模块。** 应用包可以在 `fns/*.wasm` 中携带核心 WebAssembly 模块(最多 8 个)。
+ 模块导出 `memory`、`octo_alloc(len) -> ptr`、`octo_free(ptr, len)` 和它的函数;
+ 每个函数的签名是 `(ptr, len) -> i64`,返回 `(out_ptr << 32) | out_len`,输出的第一个
+ 字节是状态(0 成功,1 出错)。唯一允许的导入是 `octo.log(ptr, len)`。一个小的客体 crate
+ (`apps/wasmlab/guest/octosense-guest`)为 `fn(&[u8]) -> Result, String>` 和
+ serde 类型生成这一切。这一版不用组件模型,也不用 WASI。
+2. **运行时。** `crates/wasm-host` 在 Wasmtime 49 中运行模块,模块加载时由 Cranelift
+ 编译。编译结果缓存在宿主目录中,以模块摘要和引擎兼容性哈希为键。每次调用都有截止时间
+ (2 秒,按实际经过的时间做 epoch 中断);内存(256 MiB)、wasm 栈(512 KiB)、模块
+ (8 MiB)以及输入输出(16 MiB)都有上限。陷阱或超时只让这次调用出错,绝不结束进程,
+ 并作废这个实例:下次调用会得到新实例,因为 Rust 客体的栈指针和分配器可能只更新了一半。
+3. **`wasm` 宿主服务**(外壳特性 `wasm-lab`)。`wasm.` 调用发起请求的应用自己的
+ 函数;`wasm.functions` 列出这些函数、它们的加载情况和运行统计。字符串参数按文本传入,
+ 其他参数按 JSON 传入;JSON 输出作为数据返回,其他输出返回为 `{"text": …}`。模块只来自
+ 发起请求的应用自己那个已准入、经过摘要校验的应用包,绝不来自参数;服务还会自己检查已准入
+ 清单中的授权。每个应用有一个工作线程,所以没有调用在 UI 线程上运行,一个慢函数只会拖慢
+ 它自己的应用。
+4. **`wasm` 能力**加入 App Hub 封闭的能力列表。商店的说明是:“在此设备上的沙盒中运行它
+ 自带的函数;这些函数接触不到文件、网络或其他应用。”
+5. **Agent 工具。** 工具可以写 `host_method: "wasm."`:应用的 Agent(以及在
+ 共享时的其他调用者)运行这个应用自己的函数。它需要 `wasm` 能力;与共享宿主方法不同,它
+ 没有风险下限,也不要求 `private_data`:函数只能看到传给它的参数。
+
+## 实测过的替代方案
+
+M5 Max 上的 CoreMark(越高越快;原生 Rust 约 45,000):
+
+| 运行时 | CoreMark | 说明 |
+| --- | --- | --- |
+| Wasmtime + Cranelift(选定) | 44,534 | 二进制增加 7.4 MiB;模块加载时编译 |
+| WasmEdge AOT | 30,432 | 库 153 MiB;编译 0.2–0.36 秒 |
+| stitch(解释器) | 2,942 | 与 Makepad 契合,无 JIT;需要一个宿主调用修复(makepad #106) |
+| Pulley(Wasmtime 的解释器) | 2,587 | 无 JIT:iOS 的选项 |
+| WasmEdge 解释器 | 374 | |
+
+- **原生动态库**没有隔离;Theseus OS 的模型(受信任的编译器、单一地址空间)不适用于手机
+ 操作系统。
+- **上游 Makepad 的 JIT** 编译的是 Splash,不是 Rust。
+- **Splash 计算内核**仍然是数值内核的答案。
+
+## 测量(Wasm Lab,`apps/wasmlab`)
+
+只有轻量算法:Markdown 转 HTML(pulldown-cmark)、在忙碌时段之间找空闲时段、模糊排序
+(strsim)、按行差异比较(similar)。
+
+200 次调用的中位数,release 构建,桌面运行三次、手机运行三次
+(`cargo run --release -p octosense-wasm-host --example measure`;手机上是同一个示例,
+交叉编译后通过 adb 运行):
+
+| | 桌面(M5 Max) | 手机(Redmi Note 12,骁龙 685) |
+| --- | --- | --- |
+| 模块 | 433 KiB | 433 KiB |
+| 编译(首次加载) | 27–33 毫秒 | 378–421 毫秒 |
+| 从缓存加载 | 新进程中 1.0–1.4 毫秒;刚写入后 4.8–6.4 毫秒 | 5.0–7.0 毫秒 |
+| 实例化 | 0.07 毫秒 | 0.19–0.27 毫秒 |
+| md_to_html,32 KiB | 405–412 微秒(原生的 2.1–2.2 倍) | 2,325–2,328 微秒(1.7 倍) |
+| find_slots,30 个忙碌时段 | 6.7 微秒(1.7 倍) | 40 微秒(1.5 倍) |
+| fuzzy_rank,500 项 | 138–146 微秒(1.6–1.7 倍) | 841–845 微秒(1.4 倍) |
+| text_diff,1000 行 | 231–254 微秒(1.6–1.7 倍) | 1,434 微秒(1.9 倍) |
+| 最小调用(双向 JSON) | 0.3 微秒 | 2.5–2.6 微秒 |
+
+用 wasm SIMD(`+simd128`)构建客体没有缩小 Markdown 的差距(2.2–2.3 倍):差距来自
+Cranelift 与 LLVM 的代码生成,而不是向量化。Wasmtime 和 Cranelift 给 Android 库增加约
+7.5 MiB 代码(按符号大小;桌面上测得 7.4 MiB)。
+
+在外壳中,脚本用 `host.request` 调用自己的函数并收到结果,桌面上约 2 毫秒,手机上
+1.8–2 毫秒,几乎全部花在 UI 事件循环上;函数本身在桌面上用 18–72 微秒,在手机上用
+47–190 微秒。在手机的 Home 中,模块首次启动时编译用了 670 毫秒(应用当时还在启动),之后
+从缓存加载用 7.4–11 毫秒。四种失控模式(死循环、无限分配、panic、无限递归)分别在
+2,000 毫秒、140 毫秒、2.2 毫秒和 3 毫秒后以错误结束,Home 一直在运行。
+
+两个平台发现:
+
+- **Android 上的陷阱需要 libc 0.2.190。** 使用 libc 0.2.189 时,Wasmtime 通过信号捕获的
+ 每一种陷阱(panic 的 `unreachable`、越界读取、栈溢出)都会让 Home 退出,普通的命令行
+ 程序也一样。Wasmtime 的信号处理函数确实运行了,但它通过 libc 的 `ucontext_t` 读取出错
+ 位置的 pc,而 aarch64 Android 的这个结构缺少内核在信号掩码后面加的填充,所以没有一个
+ 陷阱被认出是 wasm。0.2.190 修正了这个布局;`crates/wasm-host` 现在要求这个版本。显式陷阱
+ (`signals_based_traps(false)`)也能工作,但每次调用慢 19–38%,编译时间翻倍。
+- **桌面上的扫描程序会让缓存比编译还慢。** 在受管理的 Mac 上,Microsoft Defender 会把新写入
+ 的缓存文件的第一次打开挡住约一秒;外壳的新构建第一次打开它时又会被挡一次(1,086 毫秒;
+ 下次启动 2.3 毫秒)。写入方会在后台把文件读一遍,但只能覆盖第一种情况。那里编译只要
+ 30–60 毫秒,所以桌面可以不用缓存,或让两者竞速;在手机上,缓存能省下约 0.4 秒。
+
+## 影响
+
+- 对这些工作负载,第三方 Rust 在 OctoSense 应用中的运行时间是原生的 1.4–2.2 倍,不引入
+ 新的信任,默认构建不变。
+- Android APK 增加 3.5 MiB(从 209.1 MB 到 212.8 MB,含 Wasm Lab 自己的应用包);运行时
+ 约为 7.5 MiB 代码。
+- 在中端手机上,每个模块编译约 0.4 秒(Home 启动期间 0.67 秒)。有了缓存,之后的加载只要
+ 5–11 毫秒;在安装时编译(商店应用)或在构建时编译(系统应用)可以省掉第一次。
+- 在 App Hub #140 的商店检查下,商店应用包可以携带 `fns/*.wasm`(核心模块,最多 8 个,
+ 只能配合 `wasm` 能力)。到目前为止,只有系统应用在设备上运行过函数。
+- iOS 不允许应用使用 JIT,也不允许下载原生代码:那里只能用 Pulley(比 Cranelift 慢约
+ 17 倍)。随系统发布的应用,其函数可以在构建应用时转成原生代码;商店应用不行。
+ OpenHarmony 的 JIT 策略未知(**未验证**)。
+- 限制是按调用、按实例计算的。目前还没有办法阻止一个应用用连续调用占满一个核心,或在
+ 它的八个模块里各用 256 MiB;按应用的 CPU 和内存预算是后续工作。App Hub 已判定超时的
+ 请求,仍会在工作线程里运行。
+- 外壳运行期间更新的应用,在外壳重启前仍使用旧函数;工作线程与外壳同寿命。
+
+## 未决问题
+
+- 用带类型的接口(组件模型和 WIT)代替 JSON 输入输出。
+- `octo.log` 之外的任何宿主导入(时钟、随机数)都应是新的能力,而不是默认提供。
+- epoch 截止时间开销小但不确定;fuel 是确定的,但有代价。
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 2d1e20aa..d72d6b9b 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -17,6 +17,7 @@ How these decisions fit together in the code on `main`, and which parts are stil
| [0007](0007-composable-mail-action-cards.md) | Composable Mail cards with editing, chat and approved actions | Implementation in progress; phone acceptance pending |
| [0008](0008-quiet-android-mail-jobs.md) | Quiet Android Mail jobs and native card notifications | Implemented in this change; device acceptance in progress |
| [0010](0010-shared-oauth-and-connected-apps.md) | Shared OAuth and independently installed connected apps | Implementation in progress; live sign-in passed on macOS; GitHub writes, Gmail sends and device acceptance pending |
+| [0011](0011-apps-own-functions-in-webassembly.md) | An app's own functions, in WebAssembly | Proposed; behind the `wasm-lab` feature, validated on macOS and an Android phone |
| [0012](0012-app-host-api-discovery.md) | Discoverable host APIs, signed backend operations and live script tools | Implemented in source; contract 1.6.0 published; host release and phone acceptance pending |
## Home (phone shell) decisions, 2026-09-16 to 2026-09-25
diff --git a/docs/adr/README.zh-CN.md b/docs/adr/README.zh-CN.md
index c5c79790..34478caf 100644
--- a/docs/adr/README.zh-CN.md
+++ b/docs/adr/README.zh-CN.md
@@ -17,6 +17,7 @@ OctoSense 仓库的决策:Shell、Shell 服务、系统应用,以及桌面
| [0007](0007-composable-mail-action-cards.zh-CN.md) | 可组合的 Mail 卡片:编辑、聊天与经批准的操作 | 实现中;手机验收待完成 |
| [0008](0008-quiet-android-mail-jobs.zh-CN.md) | Android 静默邮件后台任务与原生卡片通知 | 本次变更已实现;设备验收进行中 |
| [0010](0010-shared-oauth-and-connected-apps.zh-CN.md) | 共享 OAuth 与独立安装的连接账户应用 | 实现中;真实账户登录已在 macOS 上通过;GitHub 写入、Gmail 发信和设备验收待完成 |
+| [0011](0011-apps-own-functions-in-webassembly.zh-CN.md) | 应用自带的 WebAssembly 函数 | 提议中;在 `wasm-lab` 特性之后,已在 macOS 和一部 Android 手机上验证 |
| [0012](0012-app-host-api-discovery.zh-CN.md) | 可发现宿主 API、签名后端操作与活动脚本工具 | 源码已实现;契约 1.6.0 已发布;宿主发布和手机验收待完成 |
## Home(手机 Shell)的决策,2026-09-16 至 2026-09-25
diff --git a/native-apps.json b/native-apps.json
index 28e18406..bebf9363 100644
--- a/native-apps.json
+++ b/native-apps.json
@@ -100,7 +100,7 @@
"id": "apphub",
"feature": "app-hub",
"crate": "octosense-app-hub-app",
- "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "b1109a397ae45349546d50889a1706f6c58fb923", "local": null },
+ "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "2e5768964f15916cdb66cc6824f7671dafc6445a", "local": null },
"module": ["octosense_app_hub_app::APP_HUB_MODULE", "octosense_app_hub_app::CARD_MODULE"],
"bin": null,
"default_features": true,
diff --git a/phone/Cargo.toml b/phone/Cargo.toml
index 7710c3c2..cff97330 100644
--- a/phone/Cargo.toml
+++ b/phone/Cargo.toml
@@ -98,5 +98,7 @@ app-weather = ["octosense-shell/app-weather"]
# END native-apps: features
app-aichat = ["dep:makepad-aichat", "octosense-shell/app-aichat"]
dev-mode = ["octosense-shell/dev-mode"]
+# Apps' own WebAssembly functions (ADR 0011; off by default).
+wasm-lab = ["octosense-shell/wasm-lab"]
# The system toolbox offered to app agents (off by default; ADR 0002 §6).
toolbox-peers = ["octosense-shell/toolbox-peers"]
diff --git a/phone/system-apps-wasm-lab.json b/phone/system-apps-wasm-lab.json
new file mode 100644
index 00000000..6ba0c6bb
--- /dev/null
+++ b/phone/system-apps-wasm-lab.json
@@ -0,0 +1,20 @@
+{
+ "schema": 1,
+ "source": "../apps",
+ "apps": [
+ "news",
+ "photos",
+ "maps",
+ "camera",
+ "mail",
+ "calendar",
+ "ai-providers",
+ "youtube",
+ "wasmlab"
+ ],
+ "assets": {
+ "photos": {
+ "photos": "../apps/photos/resources/photos"
+ }
+ }
+}
diff --git a/tools/sync-app-interface.py b/tools/sync-app-interface.py
index dfab7707..c2b72c81 100644
--- a/tools/sync-app-interface.py
+++ b/tools/sync-app-interface.py
@@ -14,7 +14,7 @@ def main():
args = parser.parse_args()
prelude = START + (ROOT / "apps/interface.splash").read_text() + END
stale = []
- for name in ("news", "photos", "mail", "calendar", "maps", "ai-providers", "youtube"):
+ for name in ("news", "photos", "mail", "calendar", "maps", "ai-providers", "youtube", "wasmlab"):
path = ROOT / "apps" / name / "bundle/main.splash"
text = path.read_text()
body = text.split(END, 1)[1] if text.startswith(START) else text