Skip to main content

sim_web_shell/
live.rs

1//! The live browser session bridge (VIEW4.05).
2//!
3//! This module turns the embedded browser shell into a live edit surface over
4//! the blocking HTTP server: the browser posts an Intent, the server submits it
5//! through a server-held [`Session`], pumps the resulting Scene diff, and
6//! responds with the patch(es). The browser applies each patch and repaints. It
7//! is a submit/response bridge -- each Intent is one request -- not a streaming
8//! channel.
9//!
10//! # Wire format
11//!
12//! The browser already speaks plain, untagged JSON: `intent.js` builds untagged
13//! Intent objects and `diff.js`/`scene.js` consume untagged Scene patches and
14//! Scenes. So the bridge uses `sim-codec-json`'s untagged interop projection in
15//! both directions. The cookbook route hand-rolls its JSON and never decodes an
16//! `Expr` from a request body, so there was no existing body codec to reuse;
17//! this is the bridge's own decode/encode surface.
18//!
19//! The untagged projection is intentionally lossy (it cannot tell a symbol from
20//! a string), so [`decode_intent_body`] lifts the well-known Intent envelope
21//! back to faithful `Expr`s: the `kind` tag and `origin.operator` become
22//! symbols, and each `path` segment tag (`k`/`i`) becomes a symbol so the
23//! universal editor's path parser accepts it. Every other field passes through
24//! as decoded. The universal default editor edits at the root path, which is the
25//! only shape the shipped browser shell emits, so this lift is sufficient for
26//! the live surface today.
27//!
28//! # Future work
29//!
30//! This is a request/response bridge. A WebSocket (or SSE) channel would let the
31//! server push patches without a client Intent -- needed for agent peers and
32//! collaborative edits -- but that requires an async server and is out of scope
33//! for the blocking HTTP shell. When that lands, the same [`Session::pump`]
34//! output should be streamed rather than returned per request.
35
36use std::sync::Arc;
37
38use sim_codec_json::{JsonProjectionMode, project_expr_to_json, project_json_to_expr};
39use sim_kernel::{Cx, DefaultFactory, EagerPolicy, Expr, Result as SimResult, Symbol};
40use sim_lib_view::{
41    LensRegistry, UNIVERSAL_EDITOR_ID, UNIVERSAL_VIEW_ID, register_universal_default,
42};
43use sim_lib_web_bridge::{FixtureTransport, SceneUpdate, Session};
44
45/// The namespace every Intent `kind` symbol lives in (mirrors `sim-lib-intent`).
46const INTENT_NAMESPACE: &str = "intent";
47
48/// The default pane the shell opens the demo resource into. The shipped
49/// `app.js` posts Intents for this pane.
50pub const DEFAULT_PANE: &str = "pane-main";
51
52/// The default resource seeded into the live session for the demo shell.
53pub const DEFAULT_RESOURCE: &str = "demo";
54
55/// A server-held live session: a [`Session`] over a deterministic in-memory
56/// [`FixtureTransport`], its [`LensRegistry`] (with the universal default lens
57/// registered), and the runtime [`Cx`] used to render Scenes.
58///
59/// The blocking HTTP server is single-threaded, so the shell owns one of these
60/// directly and serves every request against it in turn; no lock is needed. A
61/// multi-threaded server would hold this behind a `Mutex`.
62pub struct LiveSession {
63    session: Session<FixtureTransport>,
64    registry: LensRegistry,
65    cx: Cx,
66}
67
68impl LiveSession {
69    /// Build a live session, seed the demo resource, and open it into the
70    /// default pane so Intents can be submitted immediately.
71    pub fn new() -> SimResult<Self> {
72        let mut transport = FixtureTransport::new();
73        transport.set(Symbol::new(DEFAULT_RESOURCE), demo_value());
74        let mut registry = LensRegistry::new();
75        register_universal_default(&mut registry, false);
76        // bin-boot-exempt: the LiveSession is the realize/EvalFabric Intent/Scene
77        // bridge -- a distinct eval surface with its own transport, not the binary's
78        // boot runtime (that goes through sim_run_core::Bootloader). It owns its cx.
79        let mut cx = Cx::new(Arc::new(EagerPolicy), Arc::new(DefaultFactory)); // bin-boot-exempt
80        let mut session = Session::new(transport);
81        session.open(
82            &mut cx,
83            &registry,
84            Symbol::new(DEFAULT_PANE),
85            Symbol::new(DEFAULT_RESOURCE),
86            Symbol::new(UNIVERSAL_VIEW_ID),
87            Symbol::new(UNIVERSAL_EDITOR_ID),
88        )?;
89        Ok(Self {
90            session,
91            registry,
92            cx,
93        })
94    }
95
96    /// Open `resource` into `pane` through the universal default lenses and
97    /// return its initial Scene.
98    pub fn open(&mut self, resource: &str, pane: &str) -> SimResult<Expr> {
99        self.session.open(
100            &mut self.cx,
101            &self.registry,
102            Symbol::new(pane),
103            Symbol::new(resource),
104            Symbol::new(UNIVERSAL_VIEW_ID),
105            Symbol::new(UNIVERSAL_EDITOR_ID),
106        )
107    }
108
109    /// Submit a decoded Intent against `pane`, then pump and return the Scene
110    /// update(s) (each carrying the diff that reconstructs its new Scene).
111    pub fn submit(&mut self, pane: &str, intent: &Expr) -> SimResult<Vec<SceneUpdate>> {
112        self.session
113            .submit_intent(&mut self.cx, &self.registry, &Symbol::new(pane), intent)?;
114        self.session.pump(&mut self.cx, &self.registry)
115    }
116}
117
118/// The demo resource value rendered by the live shell on boot.
119fn demo_value() -> Expr {
120    Expr::Map(vec![
121        (
122            Expr::Symbol(Symbol::new("title")),
123            Expr::String("SIM live session".to_owned()),
124        ),
125        (
126            Expr::Symbol(Symbol::new("note")),
127            Expr::String("edit me".to_owned()),
128        ),
129    ])
130}
131
132/// Decode an Intent from an untagged-JSON request body and lift its envelope
133/// back to faithful `Expr`s. Returns a structured error string on malformed
134/// JSON or a non-object body; it never panics.
135pub fn decode_intent_body(body: &str) -> Result<Expr, String> {
136    let value: serde_json::Value =
137        serde_json::from_str(body).map_err(|err| format!("invalid JSON intent body: {err}"))?;
138    let expr = project_json_to_expr(&value, JsonProjectionMode::UntaggedInterop);
139    lift_intent(expr)
140}
141
142/// Encode a batch of Scene updates as the untagged-JSON `{ "patches": [...] }`
143/// response the browser's patch listener consumes.
144pub fn encode_patches(updates: &[SceneUpdate]) -> String {
145    let patches: Vec<serde_json::Value> = updates
146        .iter()
147        .map(|update| project_expr_to_json(&update.diff, JsonProjectionMode::UntaggedInterop))
148        .collect();
149    serde_json::json!({ "patches": patches }).to_string()
150}
151
152/// Encode a Scene as the untagged-JSON `{ "scene": ... }` response the open
153/// route returns.
154pub fn encode_scene(scene: &Expr) -> String {
155    serde_json::json!({ "scene": project_expr_to_json(scene, JsonProjectionMode::UntaggedInterop) })
156        .to_string()
157}
158
159/// Encode a structured `{ "error": message }` JSON body.
160pub fn error_json(message: &str) -> String {
161    serde_json::json!({ "error": message }).to_string()
162}
163
164/// Lift an untagged-decoded Intent map back to a faithful Intent `Expr`.
165fn lift_intent(expr: Expr) -> Result<Expr, String> {
166    let Expr::Map(entries) = expr else {
167        return Err("intent body must be a JSON object".to_owned());
168    };
169    let mut lifted = Vec::with_capacity(entries.len());
170    for (key, value) in entries {
171        let name = key_name(&key)?;
172        let value = match name.as_str() {
173            "kind" => lift_kind(value)?,
174            "origin" => lift_origin(value),
175            "path" => lift_path(value),
176            _ => value,
177        };
178        lifted.push((Expr::Symbol(Symbol::new(name)), value));
179    }
180    Ok(Expr::Map(lifted))
181}
182
183/// The local name of a map key (a symbol or string key).
184fn key_name(key: &Expr) -> Result<String, String> {
185    match key {
186        Expr::Symbol(symbol) => Ok(symbol.name.to_string()),
187        Expr::String(text) => Ok(text.clone()),
188        other => Err(format!("intent key must be a string, found {other:?}")),
189    }
190}
191
192/// Lift a `kind` field to its `intent/<name>` symbol, stripping a redundant
193/// `intent/` prefix the browser may include.
194fn lift_kind(value: Expr) -> Result<Expr, String> {
195    match value {
196        Expr::Symbol(symbol) => Ok(Expr::Symbol(symbol)),
197        Expr::String(text) => {
198            let local = text.strip_prefix("intent/").unwrap_or(&text);
199            Ok(Expr::Symbol(Symbol::qualified(INTENT_NAMESPACE, local)))
200        }
201        other => Err(format!("intent 'kind' must be a string, found {other:?}")),
202    }
203}
204
205/// Lift the `origin.operator` field to a symbol, leaving the tick untouched.
206fn lift_origin(value: Expr) -> Expr {
207    let Expr::Map(entries) = value else {
208        return value;
209    };
210    let lifted = entries
211        .into_iter()
212        .map(|(key, value)| {
213            let is_operator = matches!(&key, Expr::Symbol(symbol) if &*symbol.name == "operator")
214                || matches!(&key, Expr::String(text) if text == "operator");
215            let value = match value {
216                Expr::String(text) if is_operator => Expr::Symbol(Symbol::new(text)),
217                other => other,
218            };
219            (key, value)
220        })
221        .collect();
222    Expr::Map(lifted)
223}
224
225/// Lift each `path` segment to the `Vector([sym(tag), key])` wire form the
226/// universal editor's path parser expects. The segment tag (`k`/`i`) becomes a
227/// symbol; the key passes through. An empty path (the only shape the shipped
228/// shell emits) round-trips unchanged.
229fn lift_path(value: Expr) -> Expr {
230    let segments = match value {
231        Expr::List(segments) | Expr::Vector(segments) => segments,
232        other => return other,
233    };
234    Expr::List(segments.into_iter().map(lift_segment).collect())
235}
236
237/// Lift a single path segment `[tag, key]` to `Vector([sym(tag), key])`.
238fn lift_segment(segment: Expr) -> Expr {
239    let items = match segment {
240        Expr::List(items) | Expr::Vector(items) => items,
241        other => return other,
242    };
243    let lifted = items
244        .into_iter()
245        .enumerate()
246        .map(|(index, item)| match item {
247            Expr::String(text) if index == 0 => Expr::Symbol(Symbol::new(text)),
248            other => other,
249        })
250        .collect();
251    Expr::Vector(lifted)
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use sim_lib_intent::{Origin, intent};
258
259    fn key_path(key: &str) -> Expr {
260        Expr::List(vec![Expr::Vector(vec![
261            Expr::Symbol(Symbol::new("k")),
262            Expr::Symbol(Symbol::new(key)),
263        ])])
264    }
265
266    fn edit_intent(key: &str, value: &str) -> Expr {
267        intent(
268            "edit-field",
269            Origin::human(1),
270            vec![
271                ("target", demo_value()),
272                ("path", key_path(key)),
273                ("value", Expr::String(value.to_owned())),
274            ],
275        )
276    }
277
278    #[test]
279    fn submit_edit_returns_a_patch_that_reconstructs_the_scene() {
280        let mut live = LiveSession::new().unwrap();
281        let before = live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
282        sim_lib_scene::validate_scene(&before).expect("initial scene is valid");
283
284        let updates = live
285            .submit(DEFAULT_PANE, &edit_intent("title", "changed"))
286            .unwrap();
287        assert_eq!(updates.len(), 1, "the subscribed pane updates exactly once");
288        let update = &updates[0];
289        assert_ne!(update.scene, before, "the Scene changed");
290        let rebuilt = sim_lib_scene::apply(&before, &update.diff).unwrap();
291        assert_eq!(
292            rebuilt, update.scene,
293            "the diff reconstructs the new Scene from the old one"
294        );
295    }
296
297    #[test]
298    fn open_returns_a_valid_scene() {
299        let mut live = LiveSession::new().unwrap();
300        let scene = live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
301        sim_lib_scene::validate_scene(&scene).expect("open returns a valid Scene");
302    }
303
304    #[test]
305    fn a_browser_json_intent_decodes_and_drives_a_root_edit() {
306        // The browser posts untagged JSON with a string `kind` and a root path;
307        // the bridge must lift it into an Intent the universal editor accepts.
308        let body = r#"{"kind":"intent/edit-field","origin":{"operator":"human","at-tick":2},"target":{},"path":[],"value":"hello"}"#;
309        let intent = decode_intent_body(body).unwrap();
310        let kind = match &intent {
311            Expr::Map(entries) => entries.iter().find_map(|(key, value)| {
312                matches!(key, Expr::Symbol(symbol) if &*symbol.name == "kind").then_some(value)
313            }),
314            _ => None,
315        };
316        assert!(
317            matches!(kind, Some(Expr::Symbol(_))),
318            "the kind tag is lifted to a symbol"
319        );
320
321        let mut live = LiveSession::new().unwrap();
322        live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
323        let updates = live.submit(DEFAULT_PANE, &intent).unwrap();
324        assert_eq!(updates.len(), 1);
325    }
326
327    #[test]
328    fn a_malformed_body_is_an_error_not_a_panic() {
329        assert!(decode_intent_body("this is not json").is_err());
330        assert!(
331            decode_intent_body("[1, 2, 3]").is_err(),
332            "a non-object intent body is rejected"
333        );
334    }
335
336    #[test]
337    fn an_intent_without_a_kind_fails_closed_on_submit() {
338        let intent = decode_intent_body(r#"{"origin":{"operator":"human","at-tick":1}}"#).unwrap();
339        let mut live = LiveSession::new().unwrap();
340        assert!(
341            live.submit(DEFAULT_PANE, &intent).is_err(),
342            "an intent without a kind is rejected, not executed"
343        );
344    }
345
346    #[test]
347    fn patches_scenes_and_errors_encode_as_untagged_json() {
348        let mut live = LiveSession::new().unwrap();
349        live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
350        let updates = live
351            .submit(DEFAULT_PANE, &edit_intent("title", "x"))
352            .unwrap();
353
354        let patches = encode_patches(&updates);
355        assert!(patches.contains("\"patches\""), "carries a patches array");
356        assert!(patches.contains("scene/patch"), "patches are scene patches");
357
358        let scene = encode_scene(&live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap());
359        assert!(scene.contains("\"scene\""), "carries a scene field");
360
361        assert!(
362            error_json("boom").contains("boom"),
363            "errors carry a message"
364        );
365    }
366}