From ce712638274f7e1438a21048f0d3d944e4568abb Mon Sep 17 00:00:00 2001 From: Mohamed Saleh Zaied Date: Wed, 3 Jun 2026 06:43:53 +0300 Subject: [PATCH 1/2] feat(web-sdk): WASM core scaffolding (Web SDK Phase 8.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds core/web-sdk — the browser sibling of core/mobile-sdk: the shared core (policy, realtime, skills) exposed to JS via wasm-bindgen. Mirrors the mobile-sdk binding pattern and ADDS composePrompt (web widget composes the teaching prompt client-side). Design: Web* serde mirror types + pure *_impl functions are always compiled and host-testable; wasm-bindgen glue + deps are gated to target_arch=wasm32 so cargo test --workspace stays green on macOS with no wasm toolchain. Validated (host): cargo test -p skilly-core-web-sdk = 4 passed, incl. compose_prompt_web_matches_core_skills_fixture (byte-identical to core/skills). cargo check --workspace green. The wasm32 compile runs via wasm-pack/CI (scripts/build-web-sdk.sh) — local Homebrew+rustup toolchain conflict prevented a local wasm build; not a code issue. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 12 ++ Cargo.lock | 136 +++++++++++++++++++ Cargo.toml | 1 + core/web-sdk/Cargo.toml | 27 ++++ core/web-sdk/README.md | 49 +++++++ core/web-sdk/src/lib.rs | 286 +++++++++++++++++++++++++++++++++++++++ scripts/build-web-sdk.sh | 26 ++++ sdk/web/README.md | 22 +++ 8 files changed, 559 insertions(+) create mode 100644 core/web-sdk/Cargo.toml create mode 100644 core/web-sdk/README.md create mode 100644 core/web-sdk/src/lib.rs create mode 100755 scripts/build-web-sdk.sh create mode 100644 sdk/web/README.md diff --git a/AGENTS.md b/AGENTS.md index 606d69fb..aab3c692 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,6 +201,18 @@ Platform shell bootstrap binaries that run the shared-core turn-start flow throu > Validated on macOS: `cargo check --workspace` + `cargo run -p skilly-{windows,linux}-shell -- --smoke` both pass (turn-start `allowed=true`, `phase=completed`). The Tauri GUI builds in CI on Windows. +### Web SDK WASM core (`core/web-sdk`) — Web SDK Phase 8.0 + +Browser sibling of `core/mobile-sdk`: the shared core (`policy`, `realtime`, `skills`) exposed to JavaScript via `wasm-bindgen`. Adds `composePrompt` (not in the mobile surface) since the browser widget composes the host site's teaching prompt client-side. See `docs/architecture/web-sdk-prd.md`. + +| File | Purpose | +|------|---------| +| `core/web-sdk/src/lib.rs` | `Web*` serde mirror types + pure `*_impl` (host-testable) + `wasm-bindgen` glue gated to `target_arch = "wasm32"`. Exposes `canStartTurn`, `trialIsExhausted`, `usageIsOverCap`, `composePrompt`, `replayRealtimeEvents`. | +| `scripts/build-web-sdk.sh` | `wasm-pack build` → `sdk/web/generated/`. | +| `sdk/web/` | Browser SDK artifacts (sibling of `sdk/ios`, `sdk/android`). | + +> The `wasm-bindgen`/`serde-wasm-bindgen` deps are `wasm32`-only, so `cargo test --workspace` stays green on macOS with no wasm toolchain. Host-validated: `cargo test -p skilly-core-web-sdk` (4 tests, incl. `compose_prompt` parity vs the shared `core/skills` fixture). The actual wasm compile runs via `wasm-pack`/CI. The `@skilly/web` widget (Shadow-DOM overlay, DOM digest, selector pointing, voice) is Phase 8.1+. + ### Skill Files The repo ships 5 bundled skills under `skills/`, also copied into the app bundle under `Resources/skills/` so new users get them without downloading anything. diff --git a/Cargo.lock b/Cargo.lock index ebabc85f..ca8f7150 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,6 +123,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.11.1" @@ -161,6 +167,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.6.1" @@ -216,6 +228,30 @@ dependencies = [ "autocfg", ] +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "glob" version = "0.3.3" @@ -251,6 +287,18 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "log" version = "0.4.29" @@ -313,6 +361,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "plain" version = "0.2.3" @@ -337,6 +391,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "scroll" version = "0.12.0" @@ -377,6 +437,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -465,6 +536,20 @@ dependencies = [ "serde_json", ] +[[package]] +name = "skilly-core-web-sdk" +version = "0.1.0" +dependencies = [ + "serde", + "serde-wasm-bindgen", + "serde_json", + "skilly-core-domain", + "skilly-core-policy", + "skilly-core-realtime", + "skilly-core-skills", + "wasm-bindgen", +] + [[package]] name = "skilly-linux-shell" version = "0.1.0" @@ -483,6 +568,12 @@ dependencies = [ "skilly-core-realtime", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smawk" version = "0.3.2" @@ -686,6 +777,51 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + [[package]] name = "weedle2" version = "5.0.0" diff --git a/Cargo.toml b/Cargo.toml index 61840b5f..91f56305 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "core/skills", "core/realtime", "core/mobile-sdk", + "core/web-sdk", "apps/windows-shell", "apps/linux-shell", ] diff --git a/core/web-sdk/Cargo.toml b/core/web-sdk/Cargo.toml new file mode 100644 index 00000000..430566ad --- /dev/null +++ b/core/web-sdk/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "skilly-core-web-sdk" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "WebAssembly (wasm-bindgen) surface for selected Skilly core APIs. Browser sibling of core/mobile-sdk. See docs/architecture/web-sdk-prd.md." + +[lib] +name = "skilly_core_web_sdk" +# rlib so the pure *_impl functions + native tests build on the host; +# cdylib is the artifact wasm-pack compiles for the browser. +crate-type = ["cdylib", "rlib"] + +[dependencies] +skilly-core-domain = { path = "../domain" } +skilly-core-policy = { path = "../policy" } +skilly-core-realtime = { path = "../realtime" } +skilly-core-skills = { path = "../skills" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# wasm-only: the JS bindings layer. Gated to wasm32 so host +# `cargo check/test --workspace` needs no wasm toolchain (matches the +# windows-shell-gui exclusion pattern, but here via target-specific deps). +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" +serde-wasm-bindgen = "0.6" diff --git a/core/web-sdk/README.md b/core/web-sdk/README.md new file mode 100644 index 00000000..85f8e002 --- /dev/null +++ b/core/web-sdk/README.md @@ -0,0 +1,49 @@ +# skilly-core-web-sdk + +WebAssembly surface for selected Skilly core APIs — the **browser sibling of +`core/mobile-sdk`** (UniFFI). The same shared core (`policy`, `realtime`, +`skills`) exposed to JavaScript via `wasm-bindgen`. This is **Phase 8.0** of the +Web SDK plan (`docs/architecture/web-sdk-prd.md`). + +## Exposed API (JS names) + +| JS function | Input | Output | Core source | +|-------------|-------|--------|-------------| +| `canStartTurn(input)` | `WebPolicyInput` | `WebPolicyDecision` | `core/policy` | +| `trialIsExhausted(input)` | `WebPolicyInput` | `boolean` | `core/policy` | +| `usageIsOverCap(input)` | `WebPolicyInput` | `boolean` | `core/policy` | +| `composePrompt(input)` | `{ base_prompt, skill, progress }` | `string` | `core/skills` | +| `replayRealtimeEvents(eventsJson)` | JSON string | `WebRealtimeReplaySummary \| null` | `core/realtime` | + +`composePrompt` is web-specific (not in the mobile surface): the browser widget +composes the host site's teaching prompt client-side. + +## Build layout + +- Pure `*_impl` functions + `Web*` serde types are **always compiled** and + host-testable — no wasm toolchain required. +- The `wasm-bindgen` glue is gated to `target_arch = "wasm32"` (and the + `wasm-bindgen`/`serde-wasm-bindgen` deps are `wasm32`-only), so + `cargo check --workspace` and `cargo test --workspace` stay green on macOS + without any wasm tooling. + +## Validate (host — no wasm toolchain) + +```bash +cargo test -p skilly-core-web-sdk +``` + +Includes `compose_prompt_web_matches_core_skills_fixture`, which reuses the +shared `core/skills` fixture to prove the web prompt output is byte-identical to +the core (and therefore to desktop/mobile). + +## Build the browser package (wasm) + +```bash +./scripts/build-web-sdk.sh # wasm-pack → sdk/web/generated/ +``` + +Requires a clean rustup toolchain with `wasm32-unknown-unknown` + `wasm-pack`. +NOTE: a host with both Homebrew rust and rustup at the same version can fail the +wasm32 build with "can't find crate for `core`"; prefer a single rustup +toolchain or run in CI. diff --git a/core/web-sdk/src/lib.rs b/core/web-sdk/src/lib.rs new file mode 100644 index 00000000..c3525fc8 --- /dev/null +++ b/core/web-sdk/src/lib.rs @@ -0,0 +1,286 @@ +//! WebAssembly surface for selected Skilly core APIs. +//! +//! Browser sibling of `core/mobile-sdk` (UniFFI): the SAME shared core +//! (`policy`, `realtime`, `skills`) exposed to JavaScript via `wasm-bindgen`. +//! +//! Structure: +//! - `Web*` serde mirror types + pure `*_impl` functions are always compiled +//! and host-testable (no wasm toolchain needed), mirroring `Mobile*`. +//! - The `wasm-bindgen` glue lives in the `wasm` module, compiled only for +//! `wasm32`, and converts JS values via `serde-wasm-bindgen`. +//! +//! Unlike mobile, the web surface also exposes `compose_prompt` (skills), +//! because the browser widget composes the host site's teaching prompt +//! client-side. See `docs/architecture/web-sdk-prd.md` (§8a/§10). + +use serde::{Deserialize, Serialize}; +use skilly_core_domain::{BlockReason, EntitlementState, PolicyConfig, PolicyInput}; +use skilly_core_policy::{can_start_turn, trial_is_exhausted, usage_is_over_cap}; +use skilly_core_realtime::{replay_events, RealtimeEvent}; +use skilly_core_skills::{compose_prompt, SkillDefinition, SkillProgress}; + +// --------------------------------------------------------------------------- +// Web mirror types (serde) — JS passes/receives these as plain objects. +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum WebEntitlementState { + None, + Trial, + Active, + CanceledValid, + CanceledExpired, + Expired, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum WebBlockReason { + TrialExhausted, + CapReached, + SubscriptionInactive, + Expired, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WebPolicyInput { + #[serde(default)] + pub user_id: Option, + pub entitlement_state: WebEntitlementState, + pub trial_seconds_used: u64, + pub usage_seconds_used: u64, + #[serde(default)] + pub admin_workos_user_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WebPolicyDecision { + pub allowed: bool, + pub reason: Option, + pub is_admin_user: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WebRealtimeReplaySummary { + pub phase_name: String, + pub turns_completed: u64, +} + +/// Input for `compose_prompt`. `skill` and `progress` reuse the core/skills +/// serde types directly (no mirror needed — they already derive Serialize). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WebComposePromptInput { + pub base_prompt: String, + pub skill: SkillDefinition, + pub progress: SkillProgress, +} + +// --------------------------------------------------------------------------- +// Pure implementations (host-testable; the wasm glue is a thin wrapper). +// --------------------------------------------------------------------------- + +fn web_policy_config(input: &WebPolicyInput) -> PolicyConfig { + PolicyConfig { + admin_workos_user_ids: input.admin_workos_user_ids.clone(), + ..PolicyConfig::default() + } +} + +pub fn can_start_turn_impl(input: WebPolicyInput) -> WebPolicyDecision { + let policy_config = web_policy_config(&input); + let policy_input = PolicyInput { + user_id: input.user_id, + entitlement_state: map_web_entitlement_state(input.entitlement_state), + trial_seconds_used: input.trial_seconds_used, + usage_seconds_used: input.usage_seconds_used, + }; + + let decision = can_start_turn(&policy_config, &policy_input); + WebPolicyDecision { + allowed: decision.allowed, + reason: map_web_block_reason(decision.reason), + is_admin_user: decision.is_admin_user, + } +} + +pub fn trial_is_exhausted_impl(input: &WebPolicyInput) -> bool { + let policy_config = web_policy_config(input); + let policy_input = PolicyInput { + user_id: input.user_id.clone(), + entitlement_state: EntitlementState::Trial, + trial_seconds_used: input.trial_seconds_used, + usage_seconds_used: 0, + }; + + trial_is_exhausted(&policy_config, &policy_input) +} + +pub fn usage_is_over_cap_impl(input: &WebPolicyInput) -> bool { + let policy_config = web_policy_config(input); + let policy_input = PolicyInput { + user_id: input.user_id.clone(), + entitlement_state: EntitlementState::Active, + trial_seconds_used: 0, + usage_seconds_used: input.usage_seconds_used, + }; + + usage_is_over_cap(&policy_config, &policy_input) +} + +pub fn compose_prompt_impl(input: &WebComposePromptInput) -> String { + compose_prompt(&input.base_prompt, &input.skill, &input.progress) +} + +pub fn replay_realtime_events_from_json_impl(events_json: &str) -> Option { + let parsed_events: Vec = serde_json::from_str(events_json).ok()?; + let final_state = replay_events(&parsed_events).ok()?; + Some(WebRealtimeReplaySummary { + phase_name: final_state.phase_name().to_string(), + turns_completed: final_state.turns_completed, + }) +} + +fn map_web_entitlement_state(entitlement_state: WebEntitlementState) -> EntitlementState { + match entitlement_state { + WebEntitlementState::None => EntitlementState::None, + WebEntitlementState::Trial => EntitlementState::Trial, + WebEntitlementState::Active => EntitlementState::Active, + WebEntitlementState::CanceledValid => EntitlementState::Canceled { + access_still_valid: true, + }, + WebEntitlementState::CanceledExpired => EntitlementState::Canceled { + access_still_valid: false, + }, + WebEntitlementState::Expired => EntitlementState::Expired, + } +} + +fn map_web_block_reason(block_reason: Option) -> Option { + match block_reason { + Some(BlockReason::TrialExhausted) => Some(WebBlockReason::TrialExhausted), + Some(BlockReason::CapReached) => Some(WebBlockReason::CapReached), + Some(BlockReason::SubscriptionInactive) => Some(WebBlockReason::SubscriptionInactive), + Some(BlockReason::Expired) => Some(WebBlockReason::Expired), + None => None, + } +} + +// --------------------------------------------------------------------------- +// wasm-bindgen glue — compiled only for the browser (wasm32). +// JS calls these with plain objects; serde-wasm-bindgen does the conversion. +// --------------------------------------------------------------------------- + +#[cfg(target_arch = "wasm32")] +mod wasm { + use super::*; + use wasm_bindgen::prelude::*; + + #[wasm_bindgen(js_name = canStartTurn)] + pub fn can_start_turn(input: JsValue) -> Result { + let parsed: WebPolicyInput = serde_wasm_bindgen::from_value(input)?; + let decision = can_start_turn_impl(parsed); + Ok(serde_wasm_bindgen::to_value(&decision)?) + } + + #[wasm_bindgen(js_name = trialIsExhausted)] + pub fn trial_is_exhausted(input: JsValue) -> Result { + let parsed: WebPolicyInput = serde_wasm_bindgen::from_value(input)?; + Ok(trial_is_exhausted_impl(&parsed)) + } + + #[wasm_bindgen(js_name = usageIsOverCap)] + pub fn usage_is_over_cap(input: JsValue) -> Result { + let parsed: WebPolicyInput = serde_wasm_bindgen::from_value(input)?; + Ok(usage_is_over_cap_impl(&parsed)) + } + + #[wasm_bindgen(js_name = composePrompt)] + pub fn compose_prompt(input: JsValue) -> Result { + let parsed: WebComposePromptInput = serde_wasm_bindgen::from_value(input)?; + Ok(compose_prompt_impl(&parsed)) + } + + #[wasm_bindgen(js_name = replayRealtimeEvents)] + pub fn replay_realtime_events(events_json: String) -> Result { + let summary = replay_realtime_events_from_json_impl(&events_json); + Ok(serde_wasm_bindgen::to_value(&summary)?) + } +} + +// --------------------------------------------------------------------------- +// Native parity tests — prove the web surface matches the shared core. +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_start_turn_web_blocks_exhausted_trial() { + let decision = can_start_turn_impl(WebPolicyInput { + user_id: Some("user-1".to_string()), + entitlement_state: WebEntitlementState::Trial, + trial_seconds_used: 901, + usage_seconds_used: 0, + admin_workos_user_ids: Vec::new(), + }); + + assert!(!decision.allowed); + assert_eq!(decision.reason, Some(WebBlockReason::TrialExhausted)); + assert!(!decision.is_admin_user); + } + + #[test] + fn can_start_turn_web_allows_admin_regardless() { + let decision = can_start_turn_impl(WebPolicyInput { + user_id: Some("admin-1".to_string()), + entitlement_state: WebEntitlementState::Expired, + trial_seconds_used: 99_999, + usage_seconds_used: 99_999, + admin_workos_user_ids: vec!["admin-1".to_string()], + }); + + assert!(decision.allowed); + assert!(decision.is_admin_user); + } + + /// Reuses the shared `core/skills` fixture to prove the web `compose_prompt` + /// produces byte-identical output to the core (and therefore to desktop). + #[test] + fn compose_prompt_web_matches_core_skills_fixture() { + let fixture_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../skills/fixtures/compose_prompt_fixture.json" + ); + let fixture_json = + std::fs::read_to_string(fixture_path).expect("skills fixture should be readable"); + let fixture: serde_json::Value = + serde_json::from_str(&fixture_json).expect("fixture should parse"); + + let input = WebComposePromptInput { + base_prompt: fixture["base_prompt"].as_str().unwrap().to_string(), + skill: serde_json::from_value(fixture["skill"].clone()).expect("skill should parse"), + progress: serde_json::from_value(fixture["progress"].clone()) + .expect("progress should parse"), + }; + + let composed = compose_prompt_impl(&input); + assert_eq!(composed, fixture["expected_prompt"].as_str().unwrap()); + } + + #[test] + fn replay_realtime_events_web_returns_summary() { + let events_json = r#"[ + {"type":"turn_started","turn_id":"turn-1"}, + {"type":"audio_capture_committed","turn_id":"turn-1"}, + {"type":"response_started","turn_id":"turn-1"}, + {"type":"response_completed","turn_id":"turn-1"} + ]"#; + + let summary = replay_realtime_events_from_json_impl(events_json) + .expect("valid event sequence should produce a summary"); + assert_eq!(summary.phase_name, "completed"); + assert_eq!(summary.turns_completed, 1); + } +} diff --git a/scripts/build-web-sdk.sh b/scripts/build-web-sdk.sh new file mode 100755 index 00000000..79df339b --- /dev/null +++ b/scripts/build-web-sdk.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Builds the WebAssembly package for the Skilly web SDK (core/web-sdk). +# Browser sibling of scripts/generate-mobile-sdk-bindings.sh. +# +# Requirements (clean toolchain — see note below): +# rustup target add wasm32-unknown-unknown +# cargo install wasm-pack +# +# NOTE: a host with BOTH Homebrew rust and rustup at the same version can fail +# the wasm32 build with "can't find crate for `core`". Prefer a single rustup +# toolchain (or run this in CI). wasm-pack drives the wasm32 build itself. + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT_DIR="$REPO_ROOT/sdk/web/generated" + +cd "$REPO_ROOT/core/web-sdk" + +wasm-pack build \ + --target web \ + --release \ + --out-dir "$OUT_DIR" \ + --out-name skilly_core_web_sdk + +echo "Generated web SDK (wasm + JS bindings) in $OUT_DIR" diff --git a/sdk/web/README.md b/sdk/web/README.md new file mode 100644 index 00000000..1e7f460e --- /dev/null +++ b/sdk/web/README.md @@ -0,0 +1,22 @@ +# Skilly Web SDK (`sdk/web`) + +Browser SDK artifacts — sibling of `sdk/ios` and `sdk/android`. The WASM + JS +bindings generated from `core/web-sdk` land in `sdk/web/generated/` (built by +`scripts/build-web-sdk.sh` via `wasm-pack`, mirroring how `sdk/{ios,android}` +are generated by the mobile-sdk bindgen script). + +This is **Phase 8.0** (WASM core) of the Web SDK plan — the shared Rust core +compiled for the browser. The end-user widget package (`@skilly/web`: Shadow-DOM +overlay, DOM digest, selector pointing, voice pipeline) is **Phase 8.1+** and is +not part of this slice. See `docs/architecture/web-sdk-prd.md`. + +## Layout (target) + +``` +sdk/web/ +├── generated/ # wasm-pack output (skilly_core_web_sdk.js + .wasm + .d.ts) +└── sample/ # (8.1+) minimal browser usage example +``` + +`generated/` is machine-produced — regenerate via `scripts/build-web-sdk.sh`, +never hand-edit. From edeeb2f0bed0bfda4ac607422a435c31d07928b0 Mon Sep 17 00:00:00 2001 From: Mohamed Saleh Zaied Date: Wed, 3 Jun 2026 06:53:43 +0300 Subject: [PATCH 2/2] fix(web-sdk): make wasm build robust to Homebrew/rustup toolchain conflict Root cause: on a host with both Homebrew rust and rustup, the toolchain cargo resolves rustc from PATH (Homebrew's, no wasm32 std) -> 'can't find crate for core'. Fix: build-web-sdk.sh now pins RUSTC/PATH to the rustup toolchain. Verified end-to-end: wasm-pack produces a ~195KB optimized wasm + JS + .d.ts exposing canStartTurn/trialIsExhausted/usageIsOverCap/composePrompt/replayRealtimeEvents. sdk/web/generated/ is gitignored (regenerated by the script / CI). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 +++ core/web-sdk/README.md | 14 +++++++++----- scripts/build-web-sdk.sh | 9 +++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index b48876d0..720e19fb 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ apps/windows-shell-gui/gen/ # Agent run-continuation tooling state .sisyphus/ + +# wasm-pack output for core/web-sdk (regenerate via scripts/build-web-sdk.sh / CI) +sdk/web/generated/ diff --git a/core/web-sdk/README.md b/core/web-sdk/README.md index 85f8e002..ff66bcb3 100644 --- a/core/web-sdk/README.md +++ b/core/web-sdk/README.md @@ -40,10 +40,14 @@ the core (and therefore to desktop/mobile). ## Build the browser package (wasm) ```bash -./scripts/build-web-sdk.sh # wasm-pack → sdk/web/generated/ +./scripts/build-web-sdk.sh # wasm-pack → sdk/web/generated/ (gitignored build output) ``` -Requires a clean rustup toolchain with `wasm32-unknown-unknown` + `wasm-pack`. -NOTE: a host with both Homebrew rust and rustup at the same version can fail the -wasm32 build with "can't find crate for `core`"; prefer a single rustup -toolchain or run in CI. +Requires `rustup target add wasm32-unknown-unknown` + `wasm-pack`. Verified +output: a ~195KB optimized `.wasm` + JS glue + `.d.ts` exposing `canStartTurn`, +`trialIsExhausted`, `usageIsOverCap`, `composePrompt`, `replayRealtimeEvents`. + +NOTE: on a host with BOTH Homebrew rust and rustup, the toolchain `cargo` +resolves `rustc` from PATH (Homebrew's, which lacks wasm32 std) and fails with +"can't find crate for `core`". `scripts/build-web-sdk.sh` handles this +automatically by pinning `RUSTC`/`PATH` to the rustup toolchain. diff --git a/scripts/build-web-sdk.sh b/scripts/build-web-sdk.sh index 79df339b..a023ff6f 100755 --- a/scripts/build-web-sdk.sh +++ b/scripts/build-web-sdk.sh @@ -15,6 +15,15 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" OUT_DIR="$REPO_ROOT/sdk/web/generated" +# Pin rustc to the rustup toolchain. On hosts that also have a Homebrew rust on +# PATH, the toolchain cargo otherwise resolves `rustc` from PATH (Homebrew's, +# which has no wasm32 std) and fails with "can't find crate for `core`". +if command -v rustup >/dev/null 2>&1; then + TOOLCHAIN_BIN="$(dirname "$(rustup which rustc)")" + export PATH="$TOOLCHAIN_BIN:$PATH" + export RUSTC="$TOOLCHAIN_BIN/rustc" +fi + cd "$REPO_ROOT/core/web-sdk" wasm-pack build \