oxedyne/daimond/src/wasm/mod.rs
8.6 KiB, 7 runs
created by r2519314175:987, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Browser (wasm32) runtime surface for Daimond. |
| 2 | //! |
| 3 | //! This module tree is the bridge between JavaScript and Daimond's |
| 4 | //! target-agnostic core. It is compiled only for `wasm32` and never |
| 5 | //! links into the native build. |
| 6 | //! |
| 7 | //! - [`entry`] — the `#[wasm_bindgen]` API exposed to JS: a core-init |
| 8 | //! probe, an OPFS read/write pair, and an LLM transport probe. |
| 9 | //! - [`app`] — the [`DaimondApp`](app::DaimondApp) agent surface: runs a real |
| 10 | //! [`Agent`](crate::agent::Agent) turn and streams |
| 11 | //! [`AgentEvent`](crate::protocol::AgentEvent)s to a JS callback, and |
| 12 | //! hosts the Diamond / crystal / fold surface. |
| 13 | //! - [`diamond`] — the Diamond / crystal / fold substrate: the OPFS layout and |
| 14 | //! store operations behind the durable crystal and the advisory fold. |
| 15 | //! - [`cloud`] — the cloud storage edge: the workspace files that are not |
| 16 | //! on this device, and the deliberate fetch that brings one down. |
| 17 | //! - [`opfs`] — an async filesystem edge over the Origin Private File |
| 18 | //! System (OPFS), reached through `navigator.storage.getDirectory()`. |
| 19 | //! - [`pty`] — the terminal edge: bindings to `window.DaimondPty`, which |
| 20 | //! carries a real terminal session rather than one whole command, and |
| 21 | //! [`pty_request`](pty::pty_request), which composes the `open` request a |
| 22 | //! session travels on — fence included, by the same path `Tool::Run` takes. |
| 23 | //! - [`hand`] — the machine hand's edge: bindings to the `window.DaimondHand` |
| 24 | //! relay behind `run`, which reaches a process outside the page. |
| 25 | //! - [`web`] — the Web panel edge: bindings to the `window.DaimondWeb` |
| 26 | //! driver behind the agent's web tools. |
| 27 | //! - [`ask`] — the question card's edge: bindings to `window.DaimondAsk` |
| 28 | //! behind `ask`, which is how a model puts ONE decision to the user with |
| 29 | //! options they answer by tapping rather than by typing. |
| 30 | //! - [`doc`] — the document panel's edge: bindings to `window.DaimondDoc` |
| 31 | //! behind `file_show`, which is how a model puts a file in front of the |
| 32 | //! user rather than reading its bytes. |
| 33 | //! - [`office`] — the Office document edge: a `.docx` read into the prose the |
| 34 | //! Doc panel draws and `file_read` returns, and Markdown written back out as |
| 35 | //! one. The model never emits document XML; the conversion is code. |
| 36 | //! - [`typst`] — the Typst compiler edge: bindings to the `window.DaimondTypst` |
| 37 | //! driver behind `typst_compile`, which exchanges bytes only. |
| 38 | //! - [`mail`] — the Mail panel's edge: bindings to `window.DaimondMail` behind the model's |
| 39 | //! `mail_list`, `mail_search`, `mail_read` and `mail_draft` tools. It reads the mailbox |
| 40 | //! the human panel reads and files a draft where the human's Send button reads it; there is |
| 41 | //! no send here, and none the model can reach. |
| 42 | //! - [`mailtls`] — the browser end of the blind mail tunnel: a TLS client that runs |
| 43 | //! in the page, so the gateway relays ciphertext and holds no keys. Sans-io — |
| 44 | //! JavaScript owns the socket and pumps bytes through it. |
| 45 | //! |
| 46 | //! The synchronous single-writer OPFS path (`createSyncAccessHandle` in |
| 47 | //! a dedicated Worker, needed for the append-only `.daimond` log) is |
| 48 | //! deferred; the main-thread async path here is sufficient for the |
| 49 | //! first browser vertical. |
| 50 | |
| 51 | pub mod app; |
| 52 | pub mod ask; |
| 53 | pub mod cloud; |
| 54 | pub mod doc; |
| 55 | pub mod entry; |
| 56 | pub mod hand; |
| 57 | pub mod diamond; |
| 58 | pub mod mail; |
| 59 | pub mod mailtls; |
| 60 | pub mod ocr; |
| 61 | pub mod office; |
| 62 | pub mod opfs; |
| 63 | pub mod pty; |
| 64 | pub mod social; |
| 65 | pub mod typst; |
| 66 | pub mod web; |
| 67 | |
| 68 | use oxedyne_fe2o3_core::prelude::*; |
| 69 | |
| 70 | use wasm_bindgen::JsValue; |
| 71 | |
| 72 | /// Render a JS error value as a human-readable string. |
| 73 | pub(crate) fn js_str(v: &JsValue) -> String { |
| 74 | v.as_string().unwrap_or_else(|| fmt!("{:?}", v)) |
| 75 | } |
| 76 | |
| 77 | /// Read a string property from a JS object, or `None` when it is absent |
| 78 | /// or not a string. Used to lift plain `{ role, content }` objects |
| 79 | /// across the boundary without a JSON round trip. |
| 80 | pub(crate) fn js_prop(obj: &JsValue, key: &str) -> Option<String> { |
| 81 | match js_sys::Reflect::get(obj, &JsValue::from_str(key)) { |
| 82 | Ok(v) => v.as_string(), |
| 83 | Err(_) => None, |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | /// Map a Daimond [`Error`] into a `JsValue` suitable for rejecting a |
| 88 | /// `Promise`, stringifying the full error (message plus tags) so the |
| 89 | /// browser console and the harness DOM see the real cause. |
| 90 | pub(crate) fn to_js_err(e: Error<ErrTag>) -> JsValue { |
| 91 | JsValue::from_str(&fmt!("{}", e)) |
| 92 | } |
| 93 | |
| 94 | /// Is the page on screen, so a request sent now has somewhere to arrive? |
| 95 | /// |
| 96 | /// `document.visibilityState`, and nothing cleverer. A frozen tab reports `hidden`; there is no |
| 97 | /// API that says "you are about to be frozen", which is the whole difficulty. |
| 98 | /// |
| 99 | /// Read through `Reflect` rather than through `web_sys::Document`, which would need two more |
| 100 | /// `web-sys` features turned on in `Cargo.toml` for one string. The same route |
| 101 | /// [`web::driver`](crate::wasm::web) takes to `window.DaimondWeb`. |
| 102 | /// |
| 103 | /// **Anything it cannot read counts as visible.** A worker has no document at all, and waiting |
| 104 | /// there for a page that does not exist would hang the turn for the whole restore budget; an |
| 105 | /// unreadable property is the same case with a different cause. So the fallback is to proceed, |
| 106 | /// which is what the code did before this existed. |
| 107 | pub(crate) fn page_is_visible() -> bool { |
| 108 | let win = match web_sys::window() { |
| 109 | Some(w) => w, |
| 110 | None => return true, |
| 111 | }; |
| 112 | let doc = match js_sys::Reflect::get(&win, &JsValue::from_str("document")) { |
| 113 | Ok(d) if !d.is_undefined() && !d.is_null() => d, |
| 114 | _ => return true, |
| 115 | }; |
| 116 | match js_sys::Reflect::get(&doc, &JsValue::from_str("visibilityState")) { |
| 117 | Ok(v) => v.as_string().map(|s| s == "visible").unwrap_or(true), |
| 118 | Err(_) => true, |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | /// The most the tool ladder will wait for a backgrounded page to come back. |
| 123 | /// |
| 124 | /// Beyond this it gives up waiting and tries anyway. A bound rather than a promise: the page may |
| 125 | /// never come back at all -- the user may have put the phone down -- and a turn that waits for |
| 126 | /// ever is a turn nobody can stop. |
| 127 | const RESTORE_WAIT_MS: u64 = 30_000; |
| 128 | |
| 129 | /// How long after a restore to leave it before sending, in milliseconds. |
| 130 | /// |
| 131 | /// **A cited number, not a guess.** Apple Developer Forums 771127 (reported 2024-12, confirmed |
| 132 | /// by a second developer 2025-03, no Apple response as of 2026-08-28) documents a live WebKit |
| 133 | /// fault of exactly this shape: a `fetch` started immediately after `visibilitychange` fails |
| 134 | /// after 20-40 seconds with `TypeError: Load failed`, while the same fetch started after a short |
| 135 | /// delay succeeds. Firing the instant the page is visible is therefore firing into the one |
| 136 | /// window that is known to be broken. |
| 137 | const SETTLE_AFTER_RESTORE_MS: u64 = 500; |
| 138 | |
| 139 | /// Park until the page is back on screen and has settled, or until the wait is spent. |
| 140 | /// |
| 141 | /// **THIS IS AS CLOSE TO SUSPENDING A TURN AS THE PLATFORM ALLOWS, and the limit is worth |
| 142 | /// stating plainly.** A request already in flight cannot be parked: the `Promise` belongs to the |
| 143 | /// browser, the page's JavaScript is not running while the page is frozen, so nothing of ours can |
| 144 | /// observe the freeze while it is happening, and by the time anything of ours runs again the |
| 145 | /// request has already been rejected -- or the connection torn down with the process. There is |
| 146 | /// no event that arrives in time and no handle to hold. |
| 147 | /// |
| 148 | /// What CAN be parked is the moment BEFORE a request. That turns "fire into a frozen page and |
| 149 | /// collect a corpse" into "wait for the page, then fire", which is what makes the retry ladder |
| 150 | /// above this worth having at all: without it, all eight attempts are spent into a frozen page |
| 151 | /// while the user is in another app, and the ladder is an expensive way to arrive at the same |
| 152 | /// failure. |
| 153 | /// |
| 154 | /// # Arguments |
| 155 | /// * `waited` - Milliseconds this call has already slept, so the park is charged to the same |
| 156 | /// budget as the backoff and a turn cannot be extended indefinitely by being backgrounded. |
| 157 | pub(crate) async fn await_restored(waited: u64) -> u64 { |
| 158 | if page_is_visible() { |
| 159 | return 0; |
| 160 | } |
| 161 | let mut spent = 0u64; |
| 162 | let budget = RESTORE_WAIT_MS.saturating_sub(waited.min(RESTORE_WAIT_MS)); |
| 163 | // Polled rather than driven by `visibilitychange`, because a listener registered while the |
| 164 | // page is frozen is a listener that has to survive the freeze to be any use, and polling is |
| 165 | // the one thing that cannot be missed: the loop simply does not run while the page is frozen, |
| 166 | // and resumes on the tick after it wakes. |
| 167 | while spent < budget { |
| 168 | crate::llm::sleep_ms(250).await; |
| 169 | spent += 250; |
| 170 | if page_is_visible() { |
| 171 | crate::llm::sleep_ms(SETTLE_AFTER_RESTORE_MS).await; |
| 172 | return spent + SETTLE_AFTER_RESTORE_MS; |
| 173 | } |
| 174 | } |
| 175 | spent |
| 176 | } |