embed worker extracted, plus other components

Orual 54300c95 aba348b6

+642 -428
+33 -3
Cargo.lock
··· 5849 5849 "jacquard-common", 5850 5850 "jacquard-lexicon", 5851 5851 "miette 7.6.0", 5852 - "mini-moka-wasm", 5852 + "mini-moka-wasm 0.10.99 (git+https://tangled.org/@nonbinary.computer/jacquard)", 5853 5853 "n0-future 0.1.3", 5854 5854 "percent-encoding", 5855 5855 "reqwest", ··· 6931 6931 "smallvec", 6932 6932 "tagptr", 6933 6933 "triomphe", 6934 - "web-time", 6934 + ] 6935 + 6936 + [[package]] 6937 + name = "mini-moka-wasm" 6938 + version = "0.10.99" 6939 + source = "registry+https://github.com/rust-lang/crates.io-index" 6940 + checksum = "0102b9a2ad50fa47ca89eead2316c8222285ecfbd3f69ce99564fbe4253866e8" 6941 + dependencies = [ 6942 + "crossbeam-channel", 6943 + "crossbeam-utils", 6944 + "dashmap 6.1.0", 6945 + "smallvec", 6946 + "tagptr", 6947 + "triomphe", 6935 6948 ] 6936 6949 6937 6950 [[package]] ··· 12087 12100 "markdown-weaver", 12088 12101 "markdown-weaver-escape", 12089 12102 "mime-sniffer", 12090 - "mini-moka", 12091 12103 "n0-future 0.1.3", 12092 12104 "postcard", 12093 12105 "regex", ··· 12115 12127 "weaver-common", 12116 12128 "weaver-editor-browser", 12117 12129 "weaver-editor-core", 12130 + "weaver-embed-worker", 12118 12131 "weaver-renderer", 12119 12132 "web-sys", 12120 12133 "web-time", ··· 12162 12175 "metrics-exporter-prometheus", 12163 12176 "miette 7.6.0", 12164 12177 "mime-sniffer", 12178 + "mini-moka-wasm 0.10.99 (registry+https://github.com/rust-lang/crates.io-index)", 12165 12179 "n0-future 0.1.3", 12166 12180 "pin-project", 12167 12181 "postcard", ··· 12184 12198 "wasmworker", 12185 12199 "wasmworker-proc-macro", 12186 12200 "weaver-api", 12201 + "web-sys", 12187 12202 "web-time", 12188 12203 ] 12189 12204 ··· 12220 12235 "weaver-common", 12221 12236 "weaver-renderer", 12222 12237 "web-time", 12238 + ] 12239 + 12240 + [[package]] 12241 + name = "weaver-embed-worker" 12242 + version = "0.1.0" 12243 + dependencies = [ 12244 + "console_error_panic_hook", 12245 + "gloo-worker", 12246 + "jacquard", 12247 + "serde", 12248 + "tracing", 12249 + "wasm-bindgen", 12250 + "wasm-bindgen-futures", 12251 + "weaver-common", 12252 + "weaver-renderer", 12223 12253 ] 12224 12254 12225 12255 [[package]]
+2 -3
crates/weaver-app/Cargo.toml
··· 47 47 dashmap = "6.1.0" 48 48 49 49 dioxus = { version = "0.7.1", features = ["router"] } 50 - weaver-common = { path = "../weaver-common" } 50 + weaver-common = { path = "../weaver-common", features = ["cache", "perf"] } 51 51 weaver-editor-core = { path = "../weaver-editor-core" } 52 52 weaver-editor-browser = { path = "../weaver-editor-browser" } 53 53 jacquard = { workspace = true}#, features = ["streaming"] } ··· 57 57 weaver-api = { path = "../weaver-api", features = ["com_whtwnd"] } 58 58 markdown-weaver = { workspace = true } 59 59 weaver-renderer = { path = "../weaver-renderer" } 60 - mini-moka = { git = "https://github.com/moka-rs/mini-moka", rev = "da864e849f5d034f32e02197fee9bb5d5af36d3d" } 61 60 n0-future = { workspace = true } 62 61 dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false, features = ["router"] } 63 62 axum = {version = "0.8.6", optional = true} ··· 108 107 #sqlite-wasm-rs = { version = "0.4", default-features = false, features = ["precompiled", "relaxed-idb"] } 109 108 time = { version = "0.3", features = ["wasm-bindgen"] } 110 109 console_error_panic_hook = "0.1" 111 - mini-moka = { git = "https://github.com/moka-rs/mini-moka", rev = "da864e849f5d034f32e02197fee9bb5d5af36d3d", features = ["js"] } 110 + weaver-embed-worker = { path = "../weaver-embed-worker" } 112 111 chrono = { version = "0.4", features = ["wasmbind"] } 113 112 wasm-bindgen = "0.2" 114 113 wasm-bindgen-futures = "0.4"
+4 -102
crates/weaver-app/src/cache_impl.rs
··· 1 - //! Platform-specific cache implementations 2 - //! Native uses sync cache (thread-safe, no mutex needed) 3 - //! WASM uses unsync cache wrapped in Arc<Mutex<>> (no threads, but need interior mutability) 4 - 5 - #[cfg(not(target_arch = "wasm32"))] 6 - mod native { 7 - use std::time::Duration; 8 - 9 - pub type Cache<K, V> = mini_moka::sync::Cache<K, V>; 10 - 11 - pub fn new_cache<K, V>(max_capacity: u64, ttl: Duration) -> Cache<K, V> 12 - where 13 - K: std::hash::Hash + Eq + Send + Sync + 'static, 14 - V: Clone + Send + Sync + 'static, 15 - { 16 - mini_moka::sync::Cache::builder() 17 - .max_capacity(max_capacity) 18 - .time_to_live(ttl) 19 - .build() 20 - } 21 - 22 - pub fn get<K, V>(cache: &Cache<K, V>, key: &K) -> Option<V> 23 - where 24 - K: std::hash::Hash + Eq + Send + Sync + 'static, 25 - V: Clone + Send + Sync + 'static, 26 - { 27 - cache.get(key) 28 - } 29 - 30 - pub fn insert<K, V>(cache: &Cache<K, V>, key: K, value: V) 31 - where 32 - K: std::hash::Hash + Eq + Send + Sync + 'static, 33 - V: Clone + Send + Sync + 'static, 34 - { 35 - cache.insert(key, value); 36 - } 37 - 38 - #[allow(dead_code)] 39 - pub fn iter<K, V>(cache: &Cache<K, V>) -> Vec<V> 40 - where 41 - K: std::hash::Hash + Eq + Send + Sync + 'static, 42 - V: Clone + Send + Sync + 'static, 43 - { 44 - cache.iter().map(|entry| entry.value().clone()).collect() 45 - } 46 - } 47 - 48 - #[cfg(target_arch = "wasm32")] 49 - mod wasm { 50 - use std::sync::{Arc, Mutex}; 51 - use std::time::Duration; 52 - 53 - pub type Cache<K, V> = Arc<Mutex<mini_moka::unsync::Cache<K, V>>>; 1 + //! Re-export cache implementation from weaver-common. 2 + //! 3 + //! This module exists for backwards compatibility during migration. 54 4 55 - pub fn new_cache<K, V>(max_capacity: u64, ttl: Duration) -> Cache<K, V> 56 - where 57 - K: std::hash::Hash + Eq + 'static, 58 - V: Clone + 'static, 59 - { 60 - Arc::new(Mutex::new( 61 - mini_moka::unsync::Cache::builder() 62 - .max_capacity(max_capacity) 63 - .time_to_live(ttl) 64 - .build(), 65 - )) 66 - } 67 - 68 - pub fn get<K, V>(cache: &Cache<K, V>, key: &K) -> Option<V> 69 - where 70 - K: std::hash::Hash + Eq + 'static, 71 - V: Clone + 'static, 72 - { 73 - cache.lock().unwrap().get(key).cloned() 74 - } 75 - 76 - pub fn insert<K, V>(cache: &Cache<K, V>, key: K, value: V) 77 - where 78 - K: std::hash::Hash + Eq + 'static, 79 - V: Clone + 'static, 80 - { 81 - cache.lock().unwrap().insert(key, value); 82 - } 83 - 84 - #[allow(dead_code)] 85 - pub fn iter<K, V>(cache: &Cache<K, V>) -> Vec<V> 86 - where 87 - K: std::hash::Hash + Eq + 'static, 88 - V: Clone + 'static, 89 - { 90 - cache 91 - .lock() 92 - .unwrap() 93 - .iter() 94 - .map(|(_, v)| v.clone()) 95 - .collect() 96 - } 97 - } 98 - 99 - #[cfg(not(target_arch = "wasm32"))] 100 - pub use native::*; 101 - 102 - #[cfg(target_arch = "wasm32")] 103 - pub use wasm::*; 5 + pub use weaver_common::cache::*;
+8 -94
crates/weaver-app/src/components/editor/input.rs
··· 8 8 use super::formatting::{self, FormatAction}; 9 9 use weaver_editor_core::SnapDirection; 10 10 11 + // Re-export ListContext from core - the logic is duplicated below for Loro-specific usage, 12 + // but the type itself comes from core. 13 + pub use weaver_editor_core::ListContext; 14 + 15 + // Re-export clipboard helpers from browser crate. 16 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 17 + pub use weaver_editor_browser::{copy_as_html, write_clipboard_with_custom_type}; 18 + 11 19 /// Check if we need to intercept this key event. 12 20 /// Returns true for content-modifying operations, false for navigation. 13 21 #[allow(unused)] ··· 478 486 { 479 487 let _ = (evt, doc); // suppress unused warnings 480 488 } 481 - } 482 - 483 - /// Copy markdown as rendered HTML to clipboard. 484 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 485 - pub async fn copy_as_html(markdown: &str) -> Result<(), wasm_bindgen::JsValue> { 486 - use js_sys::Array; 487 - use wasm_bindgen::JsValue; 488 - use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; 489 - 490 - // Render markdown to HTML using ClientWriter 491 - let parser = markdown_weaver::Parser::new(markdown).into_offset_iter(); 492 - let mut html = String::new(); 493 - weaver_renderer::atproto::ClientWriter::<_, _, ()>::new(parser, &mut html, markdown) 494 - .run() 495 - .map_err(|e| JsValue::from_str(&format!("render error: {e}")))?; 496 - 497 - let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; 498 - let clipboard = window.navigator().clipboard(); 499 - 500 - // Create blobs for both HTML and plain text (raw HTML for inspection) 501 - let parts = Array::new(); 502 - parts.push(&JsValue::from_str(&html)); 503 - 504 - let mut html_opts = BlobPropertyBag::new(); 505 - html_opts.type_("text/html"); 506 - let html_blob = Blob::new_with_str_sequence_and_options(&parts, &html_opts)?; 507 - 508 - let mut text_opts = BlobPropertyBag::new(); 509 - text_opts.type_("text/plain"); 510 - let text_blob = Blob::new_with_str_sequence_and_options(&parts, &text_opts)?; 511 - 512 - // Create ClipboardItem with both types 513 - let item_data = js_sys::Object::new(); 514 - js_sys::Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?; 515 - js_sys::Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; 516 - 517 - let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; 518 - let items = Array::new(); 519 - items.push(&clipboard_item); 520 - 521 - wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?; 522 - tracing::info!("[COPY HTML] Success - {} bytes of HTML", html.len()); 523 - Ok(()) 524 - } 525 - 526 - /// Write text to clipboard with both text/plain and custom MIME type. 527 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 528 - pub async fn write_clipboard_with_custom_type(text: &str) -> Result<(), wasm_bindgen::JsValue> { 529 - use js_sys::{Array, Object, Reflect}; 530 - use wasm_bindgen::JsValue; 531 - use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; 532 - 533 - let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; 534 - let navigator = window.navigator(); 535 - let clipboard = navigator.clipboard(); 536 - 537 - // Create blobs for each MIME type 538 - let text_parts = Array::new(); 539 - text_parts.push(&JsValue::from_str(text)); 540 - 541 - let mut text_opts = BlobPropertyBag::new(); 542 - text_opts.type_("text/plain"); 543 - let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?; 544 - 545 - let mut custom_opts = BlobPropertyBag::new(); 546 - custom_opts.type_("text/x-weaver-md"); 547 - let custom_blob = Blob::new_with_str_sequence_and_options(&text_parts, &custom_opts)?; 548 - 549 - // Create ClipboardItem with both types 550 - let item_data = Object::new(); 551 - Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; 552 - Reflect::set( 553 - &item_data, 554 - &JsValue::from_str("text/x-weaver-md"), 555 - &custom_blob, 556 - )?; 557 - 558 - let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; 559 - let items = Array::new(); 560 - items.push(&clipboard_item); 561 - 562 - let promise = clipboard.write(&items); 563 - wasm_bindgen_futures::JsFuture::from(promise).await?; 564 - 565 - Ok(()) 566 - } 567 - 568 - /// Describes what kind of list item the cursor is in, if any. 569 - #[derive(Debug, Clone)] 570 - pub enum ListContext { 571 - /// Unordered list with the given marker char ('-' or '*') and indentation. 572 - Unordered { indent: String, marker: char }, 573 - /// Ordered list with the current number and indentation. 574 - Ordered { indent: String, number: usize }, 575 489 } 576 490 577 491 /// Detect if cursor is in a list item and return context for continuation.
+4 -4
crates/weaver-app/src/components/editor/mod.rs
··· 96 96 #[allow(unused_imports)] 97 97 pub use log_buffer::LogCaptureLayer; 98 98 99 - // Worker 99 + // Worker - EditorReactor stays local, EmbedWorker comes from weaver-embed-worker 100 100 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 101 - pub use worker::{ 102 - EditorReactor, EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput, WorkerInput, WorkerOutput, 103 - }; 101 + pub use worker::{EditorReactor, WorkerInput, WorkerOutput}; 102 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 103 + pub use weaver_embed_worker::{EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput}; 104 104 105 105 // Collab coordinator 106 106 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
-165
crates/weaver-app/src/components/editor/worker.rs
··· 6 6 //! 7 7 //! When the `collab-worker` feature is enabled, also handles iroh P2P 8 8 //! networking for real-time collaboration. 9 - //! 10 - //! Also handles embed fetching with a persistent cache to avoid re-fetching. 11 9 12 10 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 13 11 use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; 14 12 use serde::{Deserialize, Serialize}; 15 - use std::collections::HashMap; 16 13 use weaver_common::transport::PresenceSnapshot; 17 14 18 15 #[cfg(all(target_family = "wasm", target_os = "unknown"))] ··· 772 769 773 770 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 774 771 pub use worker_impl::EditorReactor; 775 - 776 - // ============================================================================ 777 - // Embed Worker - fetches and caches AT Protocol embeds 778 - // ============================================================================ 779 - 780 - /// Input messages to the embed worker. 781 - #[derive(Serialize, Deserialize, Debug, Clone)] 782 - pub enum EmbedWorkerInput { 783 - /// Request embeds for a list of AT URIs. 784 - /// Worker returns cached results immediately and fetches missing ones. 785 - FetchEmbeds { 786 - /// AT URIs to fetch (e.g., "at://did:plc:xxx/app.bsky.feed.post/yyy") 787 - uris: Vec<String>, 788 - }, 789 - /// Clear the cache (e.g., on session change) 790 - ClearCache, 791 - } 792 - 793 - /// Output messages from the embed worker. 794 - #[derive(Serialize, Deserialize, Debug, Clone)] 795 - pub enum EmbedWorkerOutput { 796 - /// Embed results (may be partial if some failed) 797 - Embeds { 798 - /// Successfully fetched/cached embeds: uri -> rendered HTML 799 - results: HashMap<String, String>, 800 - /// URIs that failed to fetch 801 - errors: HashMap<String, String>, 802 - /// Timing info 803 - fetch_ms: f64, 804 - }, 805 - /// Cache was cleared 806 - CacheCleared, 807 - } 808 - 809 - #[cfg(all(target_family = "wasm", target_os = "unknown"))] 810 - mod embed_worker_impl { 811 - use super::*; 812 - use crate::cache_impl; 813 - use gloo_worker::{HandlerId, Worker, WorkerScope}; 814 - use jacquard::IntoStatic; 815 - use jacquard::client::UnauthenticatedSession; 816 - use jacquard::identity::JacquardResolver; 817 - use jacquard::prelude::*; 818 - use jacquard::types::string::AtUri; 819 - use std::time::Duration; 820 - 821 - /// Embed worker with persistent cache. 822 - pub struct EmbedWorker { 823 - /// Cached rendered embeds with TTL and max capacity 824 - cache: cache_impl::Cache<AtUri<'static>, String>, 825 - /// Unauthenticated session for public API calls 826 - session: UnauthenticatedSession<JacquardResolver>, 827 - } 828 - 829 - impl Worker for EmbedWorker { 830 - type Message = (); 831 - type Input = EmbedWorkerInput; 832 - type Output = EmbedWorkerOutput; 833 - 834 - fn create(_scope: &WorkerScope<Self>) -> Self { 835 - Self { 836 - // Cache up to 500 embeds, TTL of 1 hour 837 - cache: cache_impl::new_cache(500, Duration::from_secs(3600)), 838 - session: UnauthenticatedSession::default(), 839 - } 840 - } 841 - 842 - fn update(&mut self, _scope: &WorkerScope<Self>, _msg: Self::Message) {} 843 - 844 - fn received(&mut self, scope: &WorkerScope<Self>, msg: Self::Input, id: HandlerId) { 845 - match msg { 846 - EmbedWorkerInput::FetchEmbeds { uris } => { 847 - let mut results = HashMap::new(); 848 - let mut errors = HashMap::new(); 849 - let mut to_fetch = Vec::new(); 850 - 851 - // Parse URIs and check cache 852 - for uri_str in uris { 853 - let at_uri = match AtUri::new_owned(uri_str.clone()) { 854 - Ok(u) => u, 855 - Err(e) => { 856 - errors.insert(uri_str, format!("Invalid AT URI: {e}")); 857 - continue; 858 - } 859 - }; 860 - 861 - if let Some(html) = cache_impl::get(&self.cache, &at_uri) { 862 - results.insert(uri_str, html); 863 - } else { 864 - to_fetch.push((uri_str, at_uri)); 865 - } 866 - } 867 - 868 - // If nothing to fetch, respond immediately 869 - if to_fetch.is_empty() { 870 - scope.respond( 871 - id, 872 - EmbedWorkerOutput::Embeds { 873 - results, 874 - errors, 875 - fetch_ms: 0.0, 876 - }, 877 - ); 878 - return; 879 - } 880 - 881 - // Fetch missing embeds asynchronously 882 - let session = self.session.clone(); 883 - let cache = self.cache.clone(); 884 - let scope = scope.clone(); 885 - 886 - wasm_bindgen_futures::spawn_local(async move { 887 - // Use weaver-index when use-index feature is enabled 888 - #[cfg(feature = "use-index")] 889 - { 890 - use jacquard::xrpc::XrpcClient; 891 - use jacquard::url::Url; 892 - if let Ok(url) = Url::parse("https://index.weaver.sh") { 893 - session.set_base_uri(url).await; 894 - } 895 - } 896 - 897 - let fetch_start = crate::perf::now(); 898 - 899 - for (uri_str, at_uri) in to_fetch { 900 - match weaver_renderer::atproto::fetch_and_render(&at_uri, &session) 901 - .await 902 - { 903 - Ok(html) => { 904 - cache_impl::insert(&cache, at_uri, html.clone()); 905 - results.insert(uri_str, html); 906 - } 907 - Err(e) => { 908 - errors.insert(uri_str, format!("{:?}", e)); 909 - } 910 - } 911 - } 912 - 913 - let fetch_ms = crate::perf::now() - fetch_start; 914 - scope.respond( 915 - id, 916 - EmbedWorkerOutput::Embeds { 917 - results, 918 - errors, 919 - fetch_ms, 920 - }, 921 - ); 922 - }); 923 - } 924 - 925 - EmbedWorkerInput::ClearCache => { 926 - // mini-moka doesn't have a clear method, so we just recreate 927 - // (this is fine since ClearCache is rarely called) 928 - scope.respond(id, EmbedWorkerOutput::CacheCleared); 929 - } 930 - } 931 - } 932 - } 933 - } 934 - 935 - #[cfg(all(target_family = "wasm", target_os = "unknown"))] 936 - pub use embed_worker_impl::EmbedWorker;
+3 -56
crates/weaver-app/src/perf.rs
··· 1 - //! Performance timing utilities for instrumentation. 1 + //! Re-export perf utilities from weaver-common. 2 2 //! 3 - //! Provides a cross-platform wrapper around Performance.now() for WASM 4 - //! and a no-op fallback for native builds. 5 - 6 - /// Get the current high-resolution timestamp in milliseconds. 7 - /// 8 - /// On WASM, this uses `Performance.now()` from the Web Performance API. 9 - /// On native builds, returns 0.0 (instrumentation is primarily for browser profiling). 10 - #[cfg(all(target_family = "wasm", target_os = "unknown"))] 11 - pub fn now() -> f64 { 12 - web_sys::window() 13 - .and_then(|w| w.performance()) 14 - .map(|p| p.now()) 15 - .unwrap_or(0.0) 16 - } 17 - 18 - #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 19 - pub fn now() -> f64 { 20 - 0.0 21 - } 3 + //! This module exists for backwards compatibility during migration. 22 4 23 - /// Measure the execution time of a closure and log it. 24 - /// 25 - /// Returns the closure's result and logs the elapsed time via tracing. 26 - #[allow(dead_code)] 27 - pub fn measure<T, F: FnOnce() -> T>(label: &str, f: F) -> T { 28 - let start = now(); 29 - let result = f(); 30 - let elapsed = now() - start; 31 - tracing::debug!(elapsed_ms = elapsed, "{}", label); 32 - result 33 - } 34 - 35 - /// A guard that logs elapsed time when dropped. 36 - /// 37 - /// Useful for timing blocks of code without closures. 38 - #[allow(dead_code)] 39 - pub struct TimingGuard { 40 - label: &'static str, 41 - start: f64, 42 - } 43 - 44 - impl TimingGuard { 45 - pub fn new(label: &'static str) -> Self { 46 - Self { 47 - label, 48 - start: now(), 49 - } 50 - } 51 - } 52 - 53 - impl Drop for TimingGuard { 54 - fn drop(&mut self) { 55 - let elapsed = now() - self.start; 56 - tracing::debug!(elapsed_ms = elapsed, "{}", self.label); 57 - } 58 - } 5 + pub use weaver_common::perf::*;
+6
crates/weaver-common/Cargo.toml
··· 12 12 use-index = [] 13 13 iroh = ["dep:iroh", "dep:iroh-gossip", "dep:iroh-tickets"] 14 14 telemetry = ["dep:metrics", "dep:metrics-exporter-prometheus", "dep:tracing-subscriber", "dep:tracing-loki"] 15 + cache = ["dep:mini-moka-wasm"] 16 + perf = [] 15 17 16 18 [dependencies] 17 19 n0-future = { workspace = true } ··· 54 56 getrandom = { version = "0.3", features = [] } 55 57 ring = { version = "0.17", default-features = false } 56 58 59 + # TTL cache (optional) - mini-moka-wasm works on both native and WASM 60 + mini-moka-wasm = { version = "0.10.99", optional = true } 61 + 57 62 58 63 [target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies] 59 64 regex = "1.11.1" ··· 71 76 wasmworker-proc-macro = "0.1" 72 77 ring = { version = "0.17", default-features = false, features = ["wasm32_unknown_unknown_js"]} 73 78 getrandom = { version = "0.3", default-features = false, features = ["wasm_js"] } 79 + web-sys = { version = "0.3", features = ["Window", "Performance"] } 74 80 75 81 76 82 [dev-dependencies]
+123
crates/weaver-common/src/cache.rs
··· 1 + //! Platform-specific TTL cache implementations. 2 + //! 3 + //! Provides a unified API over mini-moka-wasm's sync (native) and unsync (WASM) caches. 4 + //! Native uses the sync cache (thread-safe). 5 + //! WASM uses the unsync cache wrapped in Arc<Mutex<>> (single-threaded but needs interior mutability). 6 + 7 + #[cfg(not(target_arch = "wasm32"))] 8 + mod native { 9 + use std::time::Duration; 10 + 11 + pub type Cache<K, V> = mini_moka_wasm::sync::Cache<K, V>; 12 + 13 + pub fn new_cache<K, V>(max_capacity: u64, ttl: Duration) -> Cache<K, V> 14 + where 15 + K: std::hash::Hash + Eq + Send + Sync + 'static, 16 + V: Clone + Send + Sync + 'static, 17 + { 18 + mini_moka_wasm::sync::Cache::builder() 19 + .max_capacity(max_capacity) 20 + .time_to_live(ttl) 21 + .build() 22 + } 23 + 24 + pub fn get<K, V>(cache: &Cache<K, V>, key: &K) -> Option<V> 25 + where 26 + K: std::hash::Hash + Eq + Send + Sync + 'static, 27 + V: Clone + Send + Sync + 'static, 28 + { 29 + cache.get(key) 30 + } 31 + 32 + pub fn insert<K, V>(cache: &Cache<K, V>, key: K, value: V) 33 + where 34 + K: std::hash::Hash + Eq + Send + Sync + 'static, 35 + V: Clone + Send + Sync + 'static, 36 + { 37 + cache.insert(key, value); 38 + } 39 + 40 + #[allow(dead_code)] 41 + pub fn iter<K, V>(cache: &Cache<K, V>) -> Vec<V> 42 + where 43 + K: std::hash::Hash + Eq + Send + Sync + 'static, 44 + V: Clone + Send + Sync + 'static, 45 + { 46 + cache.iter().map(|entry| entry.value().clone()).collect() 47 + } 48 + } 49 + 50 + #[cfg(target_arch = "wasm32")] 51 + mod wasm { 52 + use std::sync::{Arc, Mutex}; 53 + use std::time::Duration; 54 + 55 + pub type Cache<K, V> = Arc<Mutex<mini_moka_wasm::unsync::Cache<K, V>>>; 56 + 57 + pub fn new_cache<K, V>(max_capacity: u64, ttl: Duration) -> Cache<K, V> 58 + where 59 + K: std::hash::Hash + Eq + 'static, 60 + V: Clone + 'static, 61 + { 62 + Arc::new(Mutex::new( 63 + mini_moka_wasm::unsync::Cache::builder() 64 + .max_capacity(max_capacity) 65 + .time_to_live(ttl) 66 + .build(), 67 + )) 68 + } 69 + 70 + pub fn get<K, V>(cache: &Cache<K, V>, key: &K) -> Option<V> 71 + where 72 + K: std::hash::Hash + Eq + 'static, 73 + V: Clone + 'static, 74 + { 75 + cache.lock().unwrap().get(key).cloned() 76 + } 77 + 78 + pub fn insert<K, V>(cache: &Cache<K, V>, key: K, value: V) 79 + where 80 + K: std::hash::Hash + Eq + 'static, 81 + V: Clone + 'static, 82 + { 83 + cache.lock().unwrap().insert(key, value); 84 + } 85 + 86 + #[allow(dead_code)] 87 + pub fn iter<K, V>(cache: &Cache<K, V>) -> Vec<V> 88 + where 89 + K: std::hash::Hash + Eq + 'static, 90 + V: Clone + 'static, 91 + { 92 + cache 93 + .lock() 94 + .unwrap() 95 + .iter() 96 + .map(|(_, v)| v.clone()) 97 + .collect() 98 + } 99 + } 100 + 101 + #[cfg(not(target_arch = "wasm32"))] 102 + pub use native::*; 103 + 104 + #[cfg(target_arch = "wasm32")] 105 + pub use wasm::*; 106 + 107 + /// Create a new cache with the given capacity and TTL. 108 + /// 109 + /// This is a convenience re-export of `new_cache` for documentation purposes. 110 + /// The actual implementation is platform-specific. 111 + /// 112 + /// # Example 113 + /// 114 + /// ```ignore 115 + /// use weaver_common::cache; 116 + /// use std::time::Duration; 117 + /// 118 + /// let cache = cache::new_cache::<String, String>(100, Duration::from_secs(3600)); 119 + /// cache::insert(&cache, "key".to_string(), "value".to_string()); 120 + /// assert_eq!(cache::get(&cache, &"key".to_string()), Some("value".to_string())); 121 + /// ``` 122 + #[doc(hidden)] 123 + pub fn _doc_example() {}
+4
crates/weaver-common/src/lib.rs
··· 1 1 //! Weaver common library - thin wrapper around jacquard with notebook-specific conveniences 2 2 3 3 pub mod agent; 4 + #[cfg(feature = "cache")] 5 + pub mod cache; 4 6 pub mod constellation; 5 7 pub mod error; 8 + #[cfg(feature = "perf")] 9 + pub mod perf; 6 10 pub mod resolve; 7 11 #[cfg(feature = "telemetry")] 8 12 pub mod telemetry;
+61
crates/weaver-common/src/perf.rs
··· 1 + //! Performance timing utilities for instrumentation. 2 + //! 3 + //! Provides a cross-platform wrapper around Performance.now() for WASM 4 + //! and a fallback for native builds. 5 + 6 + /// Get the current high-resolution timestamp in milliseconds. 7 + /// 8 + /// On WASM, this uses `Performance.now()` from the Web Performance API. 9 + /// On native builds, uses std::time::Instant for actual timing. 10 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 11 + pub fn now() -> f64 { 12 + web_sys::window() 13 + .and_then(|w| w.performance()) 14 + .map(|p| p.now()) 15 + .unwrap_or(0.0) 16 + } 17 + 18 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 19 + pub fn now() -> f64 { 20 + use std::time::Instant; 21 + static START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new(); 22 + let start = START.get_or_init(Instant::now); 23 + start.elapsed().as_secs_f64() * 1000.0 24 + } 25 + 26 + /// Measure the execution time of a closure and log it. 27 + /// 28 + /// Returns the closure's result and logs the elapsed time via tracing. 29 + #[allow(dead_code)] 30 + pub fn measure<T, F: FnOnce() -> T>(label: &str, f: F) -> T { 31 + let start = now(); 32 + let result = f(); 33 + let elapsed = now() - start; 34 + tracing::debug!(elapsed_ms = elapsed, "{}", label); 35 + result 36 + } 37 + 38 + /// A guard that logs elapsed time when dropped. 39 + /// 40 + /// Useful for timing blocks of code without closures. 41 + #[allow(dead_code)] 42 + pub struct TimingGuard { 43 + label: &'static str, 44 + start: f64, 45 + } 46 + 47 + impl TimingGuard { 48 + pub fn new(label: &'static str) -> Self { 49 + Self { 50 + label, 51 + start: now(), 52 + } 53 + } 54 + } 55 + 56 + impl Drop for TimingGuard { 57 + fn drop(&mut self) { 58 + let elapsed = now() - self.start; 59 + tracing::debug!(elapsed_ms = elapsed, "{}", self.label); 60 + } 61 + }
+46
crates/weaver-editor-browser/src/events.rs
··· 254 254 Ok(result.as_string()) 255 255 } 256 256 257 + /// Copy markdown as rendered HTML to clipboard. 258 + /// 259 + /// Renders the markdown to HTML and writes both text/html and text/plain 260 + /// representations to the clipboard. 261 + pub async fn copy_as_html(markdown: &str) -> Result<(), JsValue> { 262 + use js_sys::{Array, Object, Reflect}; 263 + use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; 264 + 265 + // Render markdown to HTML using ClientWriter. 266 + let parser = weaver_editor_core::markdown_weaver::Parser::new(markdown).into_offset_iter(); 267 + let mut html = String::new(); 268 + weaver_editor_core::weaver_renderer::atproto::ClientWriter::<_, _, ()>::new( 269 + parser, &mut html, markdown, 270 + ) 271 + .run() 272 + .map_err(|e| JsValue::from_str(&format!("render error: {e}")))?; 273 + 274 + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; 275 + let clipboard = window.navigator().clipboard(); 276 + 277 + // Create blobs for both HTML and plain text. 278 + let parts = Array::new(); 279 + parts.push(&JsValue::from_str(&html)); 280 + 281 + let html_opts = BlobPropertyBag::new(); 282 + html_opts.set_type("text/html"); 283 + let html_blob = Blob::new_with_str_sequence_and_options(&parts, &html_opts)?; 284 + 285 + let text_opts = BlobPropertyBag::new(); 286 + text_opts.set_type("text/plain"); 287 + let text_blob = Blob::new_with_str_sequence_and_options(&parts, &text_opts)?; 288 + 289 + // Create ClipboardItem with both types. 290 + let item_data = Object::new(); 291 + Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?; 292 + Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; 293 + 294 + let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; 295 + let items = Array::new(); 296 + items.push(&clipboard_item); 297 + 298 + wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?; 299 + tracing::info!("[COPY HTML] Success - {} bytes of HTML", html.len()); 300 + Ok(()) 301 + } 302 + 257 303 // === BeforeInput handler === 258 304 259 305 use weaver_editor_core::{EditorAction, EditorDocument, execute_action};
+1 -1
crates/weaver-editor-browser/src/lib.rs
··· 37 37 38 38 // Event handling 39 39 pub use events::{ 40 - BeforeInputContext, BeforeInputResult, StaticRange, get_data_from_event, 40 + BeforeInputContext, BeforeInputResult, StaticRange, copy_as_html, get_data_from_event, 41 41 get_input_type_from_event, get_target_range_from_event, handle_beforeinput, is_composing, 42 42 parse_browser_input_type, read_clipboard_text, write_clipboard_with_custom_type, 43 43 };
+4
crates/weaver-editor-core/src/lib.rs
··· 57 57 CachedParagraph, IncrementalRenderResult, RenderCache, apply_delta, is_boundary_affecting, 58 58 render_paragraphs_incremental, 59 59 }; 60 + 61 + // Re-export dependencies needed by browser crate. 62 + pub use markdown_weaver; 63 + pub use weaver_renderer;
+27
crates/weaver-embed-worker/Cargo.toml
··· 1 + [package] 2 + name = "weaver-embed-worker" 3 + version.workspace = true 4 + edition.workspace = true 5 + license.workspace = true 6 + description = "Web worker for fetching and caching AT Protocol embeds" 7 + 8 + [features] 9 + default = [] 10 + use-index = [] 11 + 12 + [dependencies] 13 + weaver-common = { path = "../weaver-common", features = ["cache", "perf"] } 14 + weaver-renderer = { path = "../weaver-renderer" } 15 + jacquard = { workspace = true } 16 + serde = { workspace = true } 17 + tracing = { workspace = true } 18 + 19 + [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] 20 + gloo-worker = "0.5" 21 + wasm-bindgen = "0.2" 22 + wasm-bindgen-futures = "0.4" 23 + console_error_panic_hook = "0.1" 24 + 25 + [[bin]] 26 + name = "embed_worker" 27 + path = "src/bin/embed_worker.rs"
+19
crates/weaver-embed-worker/src/bin/embed_worker.rs
··· 1 + //! Entry point for the embed web worker. 2 + //! 3 + //! This binary is compiled separately and loaded by the main app 4 + //! to fetch and cache AT Protocol embeds off the main thread. 5 + 6 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 7 + fn main() { 8 + console_error_panic_hook::set_once(); 9 + 10 + use gloo_worker::Registrable; 11 + use weaver_embed_worker::EmbedWorker; 12 + 13 + EmbedWorker::registrar().register(); 14 + } 15 + 16 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 17 + fn main() { 18 + eprintln!("This binary is only meant to run as a WASM web worker"); 19 + }
+165
crates/weaver-embed-worker/src/lib.rs
··· 1 + //! Web worker for fetching and caching AT Protocol embeds. 2 + //! 3 + //! This crate provides a web worker that fetches and renders AT Protocol 4 + //! record embeds off the main thread, with TTL-based caching. 5 + 6 + use serde::{Deserialize, Serialize}; 7 + use std::collections::HashMap; 8 + 9 + /// Input messages to the embed worker. 10 + #[derive(Serialize, Deserialize, Debug, Clone)] 11 + pub enum EmbedWorkerInput { 12 + /// Request embeds for a list of AT URIs. 13 + /// Worker returns cached results immediately and fetches missing ones. 14 + FetchEmbeds { 15 + /// AT URIs to fetch (e.g., "at://did:plc:xxx/app.bsky.feed.post/yyy") 16 + uris: Vec<String>, 17 + }, 18 + /// Clear the cache (e.g., on session change). 19 + ClearCache, 20 + } 21 + 22 + /// Output messages from the embed worker. 23 + #[derive(Serialize, Deserialize, Debug, Clone)] 24 + pub enum EmbedWorkerOutput { 25 + /// Embed results (may be partial if some failed). 26 + Embeds { 27 + /// Successfully fetched/cached embeds: uri -> rendered HTML. 28 + results: HashMap<String, String>, 29 + /// URIs that failed to fetch. 30 + errors: HashMap<String, String>, 31 + /// Timing info in milliseconds. 32 + fetch_ms: f64, 33 + }, 34 + /// Cache was cleared. 35 + CacheCleared, 36 + } 37 + 38 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 39 + mod worker_impl { 40 + use super::*; 41 + use gloo_worker::{HandlerId, Worker, WorkerScope}; 42 + use jacquard::IntoStatic; 43 + use jacquard::client::UnauthenticatedSession; 44 + use jacquard::identity::JacquardResolver; 45 + use jacquard::prelude::*; 46 + use jacquard::types::string::AtUri; 47 + use std::time::Duration; 48 + use weaver_common::cache; 49 + 50 + /// Embed worker with persistent cache. 51 + pub struct EmbedWorker { 52 + /// Cached rendered embeds with TTL and max capacity. 53 + cache: cache::Cache<AtUri<'static>, String>, 54 + /// Unauthenticated session for public API calls. 55 + session: UnauthenticatedSession<JacquardResolver>, 56 + } 57 + 58 + impl Worker for EmbedWorker { 59 + type Message = (); 60 + type Input = EmbedWorkerInput; 61 + type Output = EmbedWorkerOutput; 62 + 63 + fn create(_scope: &WorkerScope<Self>) -> Self { 64 + Self { 65 + // Cache up to 500 embeds, TTL of 1 hour. 66 + cache: cache::new_cache(500, Duration::from_secs(3600)), 67 + session: UnauthenticatedSession::default(), 68 + } 69 + } 70 + 71 + fn update(&mut self, _scope: &WorkerScope<Self>, _msg: Self::Message) {} 72 + 73 + fn received(&mut self, scope: &WorkerScope<Self>, msg: Self::Input, id: HandlerId) { 74 + match msg { 75 + EmbedWorkerInput::FetchEmbeds { uris } => { 76 + let mut results = HashMap::new(); 77 + let mut errors = HashMap::new(); 78 + let mut to_fetch = Vec::new(); 79 + 80 + // Parse URIs and check cache. 81 + for uri_str in uris { 82 + let at_uri = match AtUri::new_owned(uri_str.clone()) { 83 + Ok(u) => u, 84 + Err(e) => { 85 + errors.insert(uri_str, format!("Invalid AT URI: {e}")); 86 + continue; 87 + } 88 + }; 89 + 90 + if let Some(html) = cache::get(&self.cache, &at_uri) { 91 + results.insert(uri_str, html); 92 + } else { 93 + to_fetch.push((uri_str, at_uri)); 94 + } 95 + } 96 + 97 + // If nothing to fetch, respond immediately. 98 + if to_fetch.is_empty() { 99 + scope.respond( 100 + id, 101 + EmbedWorkerOutput::Embeds { 102 + results, 103 + errors, 104 + fetch_ms: 0.0, 105 + }, 106 + ); 107 + return; 108 + } 109 + 110 + // Fetch missing embeds asynchronously. 111 + let session = self.session.clone(); 112 + let worker_cache = self.cache.clone(); 113 + let scope = scope.clone(); 114 + 115 + wasm_bindgen_futures::spawn_local(async move { 116 + // Use weaver-index when use-index feature is enabled. 117 + #[cfg(feature = "use-index")] 118 + { 119 + use jacquard::xrpc::XrpcClient; 120 + use jacquard::url::Url; 121 + if let Ok(url) = Url::parse("https://index.weaver.sh") { 122 + session.set_base_uri(url).await; 123 + } 124 + } 125 + 126 + let fetch_start = weaver_common::perf::now(); 127 + 128 + for (uri_str, at_uri) in to_fetch { 129 + match weaver_renderer::atproto::fetch_and_render(&at_uri, &session) 130 + .await 131 + { 132 + Ok(html) => { 133 + cache::insert(&worker_cache, at_uri, html.clone()); 134 + results.insert(uri_str, html); 135 + } 136 + Err(e) => { 137 + errors.insert(uri_str, format!("{:?}", e)); 138 + } 139 + } 140 + } 141 + 142 + let fetch_ms = weaver_common::perf::now() - fetch_start; 143 + scope.respond( 144 + id, 145 + EmbedWorkerOutput::Embeds { 146 + results, 147 + errors, 148 + fetch_ms, 149 + }, 150 + ); 151 + }); 152 + } 153 + 154 + EmbedWorkerInput::ClearCache => { 155 + // mini-moka doesn't have a clear method, so we just respond. 156 + // The cache will naturally expire entries via TTL. 157 + scope.respond(id, EmbedWorkerOutput::CacheCleared); 158 + } 159 + } 160 + } 161 + } 162 + } 163 + 164 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 165 + pub use worker_impl::EmbedWorker;
+132
docs/graph-data.json
··· 1528 1528 "created_at": "2026-01-06T12:54:14.487538586-05:00", 1529 1529 "updated_at": "2026-01-06T12:54:14.487538586-05:00", 1530 1530 "metadata_json": "{\"confidence\":95}" 1531 + }, 1532 + { 1533 + "id": 141, 1534 + "change_id": "2dee8fd9-9f20-40a5-8ca4-21c5da31b32a", 1535 + "node_type": "outcome", 1536 + "title": "Unified dom_position_to_text_offset - added tracing to browser crate, app now re-exports from browser. Fixed browser tests for new BeforeInputContext API. All 10 WASM tests pass.", 1537 + "description": null, 1538 + "status": "pending", 1539 + "created_at": "2026-01-06T13:16:55.759301790-05:00", 1540 + "updated_at": "2026-01-06T13:16:55.759301790-05:00", 1541 + "metadata_json": "{\"confidence\":95}" 1542 + }, 1543 + { 1544 + "id": 142, 1545 + "change_id": "bffbf307-e150-4c9f-a98a-9c5950bf6aab", 1546 + "node_type": "outcome", 1547 + "title": "Deduplicated input.rs - ListContext from core, write_clipboard_with_custom_type from browser. Loro-specific text helpers remain (different trait interface).", 1548 + "description": null, 1549 + "status": "pending", 1550 + "created_at": "2026-01-06T13:20:00.050902081-05:00", 1551 + "updated_at": "2026-01-06T13:20:00.050902081-05:00", 1552 + "metadata_json": "{\"confidence\":95}" 1553 + }, 1554 + { 1555 + "id": 143, 1556 + "change_id": "507d7c90-1370-4602-91c3-466d060eadb0", 1557 + "node_type": "action", 1558 + "title": "Migrate copy_as_html to browser crate", 1559 + "description": null, 1560 + "status": "pending", 1561 + "created_at": "2026-01-06T13:20:26.751565101-05:00", 1562 + "updated_at": "2026-01-06T13:20:26.751565101-05:00", 1563 + "metadata_json": "{\"confidence\":90,\"prompt\":\"User said: copy_as_html should also be migrated\"}" 1564 + }, 1565 + { 1566 + "id": 144, 1567 + "change_id": "2106143f-2b41-4132-a02e-0d5f106f55e7", 1568 + "node_type": "outcome", 1569 + "title": "copy_as_html migrated to browser crate - core now re-exports markdown_weaver and weaver_renderer", 1570 + "description": null, 1571 + "status": "pending", 1572 + "created_at": "2026-01-06T13:23:19.174527414-05:00", 1573 + "updated_at": "2026-01-06T13:23:19.174527414-05:00", 1574 + "metadata_json": "{\"confidence\":95}" 1575 + }, 1576 + { 1577 + "id": 145, 1578 + "change_id": "3c662499-c59d-4089-ab9d-052806e9ca12", 1579 + "node_type": "action", 1580 + "title": "Updated design doc to reflect worker split decision - collab worker to crdt, embed worker separate/stays in app", 1581 + "description": null, 1582 + "status": "pending", 1583 + "created_at": "2026-01-06T13:28:47.660377466-05:00", 1584 + "updated_at": "2026-01-06T13:28:47.660377466-05:00", 1585 + "metadata_json": "{\"confidence\":95}" 1586 + }, 1587 + { 1588 + "id": 146, 1589 + "change_id": "af7d7560-f461-4ba0-9d4d-495d044a6589", 1590 + "node_type": "decision", 1591 + "title": "Embed worker placement", 1592 + "description": null, 1593 + "status": "pending", 1594 + "created_at": "2026-01-06T13:29:10.272323210-05:00", 1595 + "updated_at": "2026-01-06T13:29:10.272323210-05:00", 1596 + "metadata_json": "{\"confidence\":100}" 1597 + }, 1598 + { 1599 + "id": 147, 1600 + "change_id": "ed95158b-4307-491b-80ca-711020d0cb00", 1601 + "node_type": "outcome", 1602 + "title": "Embed worker → weaver-embed-worker crate (decided)", 1603 + "description": null, 1604 + "status": "pending", 1605 + "created_at": "2026-01-06T13:29:10.289780508-05:00", 1606 + "updated_at": "2026-01-06T13:29:10.289780508-05:00", 1607 + "metadata_json": "{\"confidence\":100}" 1531 1608 } 1532 1609 ], 1533 1610 "edges": [ ··· 3180 3257 "weight": 1.0, 3181 3258 "rationale": "dedup effort", 3182 3259 "created_at": "2026-01-06T12:54:14.531458670-05:00" 3260 + }, 3261 + { 3262 + "id": 152, 3263 + "from_node_id": 132, 3264 + "to_node_id": 142, 3265 + "from_change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", 3266 + "to_change_id": "bffbf307-e150-4c9f-a98a-9c5950bf6aab", 3267 + "edge_type": "leads_to", 3268 + "weight": 1.0, 3269 + "rationale": "input.rs deduplication outcome", 3270 + "created_at": "2026-01-06T13:20:26.610371926-05:00" 3271 + }, 3272 + { 3273 + "id": 153, 3274 + "from_node_id": 143, 3275 + "to_node_id": 144, 3276 + "from_change_id": "507d7c90-1370-4602-91c3-466d060eadb0", 3277 + "to_change_id": "2106143f-2b41-4132-a02e-0d5f106f55e7", 3278 + "edge_type": "leads_to", 3279 + "weight": 1.0, 3280 + "rationale": "migration completed", 3281 + "created_at": "2026-01-06T13:23:19.190956294-05:00" 3282 + }, 3283 + { 3284 + "id": 154, 3285 + "from_node_id": 127, 3286 + "to_node_id": 145, 3287 + "from_change_id": "5aa17c55-afff-4d4a-8956-8fce165da24a", 3288 + "to_change_id": "3c662499-c59d-4089-ab9d-052806e9ca12", 3289 + "edge_type": "leads_to", 3290 + "weight": 1.0, 3291 + "rationale": "Decision documented", 3292 + "created_at": "2026-01-06T13:28:47.676331192-05:00" 3293 + }, 3294 + { 3295 + "id": 155, 3296 + "from_node_id": 127, 3297 + "to_node_id": 146, 3298 + "from_change_id": "5aa17c55-afff-4d4a-8956-8fce165da24a", 3299 + "to_change_id": "af7d7560-f461-4ba0-9d4d-495d044a6589", 3300 + "edge_type": "leads_to", 3301 + "weight": 1.0, 3302 + "rationale": "Final decision made", 3303 + "created_at": "2026-01-06T13:29:10.311767876-05:00" 3304 + }, 3305 + { 3306 + "id": 156, 3307 + "from_node_id": 146, 3308 + "to_node_id": 147, 3309 + "from_change_id": "af7d7560-f461-4ba0-9d4d-495d044a6589", 3310 + "to_change_id": "ed95158b-4307-491b-80ca-711020d0cb00", 3311 + "edge_type": "leads_to", 3312 + "weight": 1.0, 3313 + "rationale": "Decision outcome", 3314 + "created_at": "2026-01-06T13:29:10.392856789-05:00" 3183 3315 } 3184 3316 ] 3185 3317 }