Skip to main content

agentmux_srv/server/service/
object_helpers.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! AgentMux-object read/update/meta primitives used by the `object` service
5//! handler, plus the per-agent zoom mirror. `update_object_meta` and
6//! `schedule_agent_zoom_mirror` are re-exported crate-wide.
7
8use crate::backend::obj::*;
9use crate::backend::storage::store::Store;
10
11/// Phase E.4 (Option A) — reverse lookup: given a `LayoutState.oid`,
12/// find the `Tab.oid` that owns it (i.e., the tab whose `layoutstate`
13/// field matches). Returns `None` when the layout is unowned (legacy
14/// or partially-migrated row) or the mstore read fails — caller treats
15/// either as "skip the reducer dispatch and fall through to the wcore
16/// write." Linear scan over all tabs; acceptable here because the
17/// layout-update path is low-frequency relative to drag-resize and
18/// the reducer mutex itself is held for sub-millisecond intervals.
19pub(super) fn find_tab_for_layout(store: &Store, layout_oid: &str) -> Option<String> {
20    let tabs = store.get_all::<Tab>().ok()?;
21    tabs.into_iter()
22        .find(|t| t.layoutstate == layout_oid)
23        .map(|t| t.oid)
24}
25
26/// Resolve an "otype:oid" string to the corresponding wave object JSON.
27pub(super) fn get_object_by_oref(store: &Store, oref_str: &str) -> Result<serde_json::Value, String> {
28    let oref = crate::backend::ORef::parse(oref_str).map_err(|e| e.to_string())?;
29
30    // Validate otype is known
31    match oref.otype.as_str() {
32        OTYPE_CLIENT | OTYPE_WINDOW | OTYPE_WORKSPACE | OTYPE_TAB | OTYPE_LAYOUT | OTYPE_BLOCK => {}
33        _ => return Err(format!("unknown otype: {}", oref.otype)),
34    }
35
36    // Use raw JSON read to avoid strict struct deserialization issues
37    // (e.g. layout leaforder with embedded BlockDef objects).
38    // This matches Go's generic map-based GetObject behavior.
39    store
40        .get_raw(&oref.otype, &oref.oid)
41        .map_err(|e| e.to_string())?
42        .ok_or_else(|| format!("not found: {}", oref_str))
43}
44
45/// Update a wave object by replacing it wholesale in the store.
46/// The incoming value must have `otype` and `oid` fields.
47/// Matches Go's ObjectService.UpdateObject behavior.
48/// Returns (otype, oid, updated_value_with_new_version) on success.
49pub(super) fn update_object(
50    store: &Store,
51    mut value: serde_json::Value,
52) -> Result<(String, String, serde_json::Value), String> {
53    let otype = value
54        .get("otype")
55        .and_then(|v| v.as_str())
56        .ok_or_else(|| "UpdateObject: missing otype field".to_string())?
57        .to_string();
58    let oid = value
59        .get("oid")
60        .and_then(|v| v.as_str())
61        .ok_or_else(|| "UpdateObject: missing oid field".to_string())?
62        .to_string();
63
64    // Validate the otype is known
65    match otype.as_str() {
66        OTYPE_CLIENT | OTYPE_WINDOW | OTYPE_WORKSPACE | OTYPE_TAB | OTYPE_LAYOUT | OTYPE_BLOCK => {}
67        _ => return Err(format!("UpdateObject: unsupported otype: {}", otype)),
68    }
69
70    // Use raw JSON storage (matching Go's generic map-based UpdateObject).
71    // The frontend sends the full replacement object; strict Rust struct deserialization
72    // can fail on dynamic fields (e.g. layout rootnode with embedded BlockDefs).
73    let new_version = store
74        .update_raw(&otype, &oid, &value)
75        .map_err(|e| format!("UpdateObject: {}", e))?;
76
77    // Update version in the value for the returned update event
78    if let Some(obj) = value.as_object_mut() {
79        obj.insert("version".to_string(), serde_json::json!(new_version));
80    }
81
82    Ok((otype, oid, value))
83}
84
85/// Update object meta by oref string. Merges meta into existing object.
86///
87/// The whole read-merge-write round trip runs inside ONE `Store::with_tx`
88/// critical section (a single acquisition of the store's connection Mutex,
89/// wrapping a real SQLite transaction) instead of two separate
90/// `must_get`/`update` calls. This closes a lost-update race: two
91/// call sites can legitimately fire on the SAME block within milliseconds
92/// of each other — e.g. `persist_session_id` (clearing a stale `--resume`
93/// id) and `clear_active_pid` both run from independent async tasks on a
94/// persistent controller's process exit. Each used to read a pre-write
95/// snapshot of the block's *entire* meta map, merge in its own single key,
96/// and write the *entire* merged map back — so whichever of the two won
97/// the race to write LAST silently reverted the other's change (every key
98/// it didn't itself touch reverts to whatever it read, which may already
99/// be stale). `with_tx` holds the one connection lock across the full
100/// read+merge+write, so no other `Store::*` call for ANY object can
101/// interleave inside it — the two writers now strictly serialize instead
102/// of racing, and each one's merge is always built from the other's
103/// already-committed result. See
104/// REPORT_LOGIN_PERSIST_FAILURE_AND_STUCK_WORKING_2026_07_27.md.
105pub(crate) fn update_object_meta(
106    store: &Store,
107    oref_str: &str,
108    meta_update: &MetaMapType,
109) -> Result<(), String> {
110    let oref = crate::backend::ORef::parse(oref_str).map_err(|e| e.to_string())?;
111
112    // Validate the otype up front — StoreTx's methods return StoreError, so
113    // the "unsupported otype" case (a plain String) has to be rejected
114    // before entering with_tx rather than from inside its closure.
115    match oref.otype.as_str() {
116        OTYPE_CLIENT | OTYPE_WINDOW | OTYPE_WORKSPACE | OTYPE_TAB | OTYPE_BLOCK => {}
117        _ => return Err(format!("cannot update meta for otype: {}", oref.otype)),
118    }
119
120    store
121        .with_tx(|tx| {
122            match oref.otype.as_str() {
123                OTYPE_CLIENT => {
124                    let mut obj = tx.must_get::<Client>(&oref.oid)?;
125                    obj.meta = merge_meta(&obj.meta, meta_update, true);
126                    tx.update(&mut obj)?;
127                }
128                OTYPE_WINDOW => {
129                    let mut obj = tx.must_get::<Window>(&oref.oid)?;
130                    obj.meta = merge_meta(&obj.meta, meta_update, true);
131                    tx.update(&mut obj)?;
132                }
133                OTYPE_WORKSPACE => {
134                    let mut obj = tx.must_get::<Workspace>(&oref.oid)?;
135                    obj.meta = merge_meta(&obj.meta, meta_update, true);
136                    tx.update(&mut obj)?;
137                }
138                OTYPE_TAB => {
139                    let mut obj = tx.must_get::<Tab>(&oref.oid)?;
140                    obj.meta = merge_meta(&obj.meta, meta_update, true);
141                    tx.update(&mut obj)?;
142                }
143                OTYPE_BLOCK => {
144                    let mut obj = tx.must_get::<Block>(&oref.oid)?;
145                    obj.meta = merge_meta(&obj.meta, meta_update, true);
146                    tx.update(&mut obj)?;
147                }
148                // Unreachable — validated above, before with_tx was entered.
149                _ => unreachable!("otype validated before with_tx"),
150            }
151            Ok(())
152        })
153        .map_err(|e| e.to_string())
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::backend::storage::store::Store;
160    use std::sync::{Arc, Barrier};
161    use std::thread;
162
163    fn insert_block(store: &Store, block_id: &str) {
164        let mut block = Block {
165            oid: block_id.to_string(),
166            parentoref: String::new(),
167            version: 0,
168            runtimeopts: None,
169            stickers: None,
170            meta: MetaMapType::new(),
171            subblockids: None,
172        };
173        store.insert(&mut block).unwrap();
174    }
175
176    /// Pins the fix for a real, reproduced lost-update race: two concurrent
177    /// `update_object_meta` calls on the SAME block, each merging in a
178    /// DIFFERENT single key, must both survive — neither may silently
179    /// revert the other's key by writing back a merged snapshot it read
180    /// before the other's write landed. This is exactly the shape of the
181    /// `persist_session_id` / `clear_active_pid` race
182    /// (REPORT_LOGIN_PERSIST_FAILURE_AND_STUCK_WORKING_2026_07_27.md): both
183    /// fire from independent async tasks on a persistent controller's
184    /// process exit, each touching one key of the same block's meta.
185    ///
186    /// A `Barrier` forces both threads to call `update_object_meta` as
187    /// close to simultaneously as real concurrent tasks would, maximizing
188    /// the odds this test actually exercises the race window rather than
189    /// happening to run the two calls back-to-back. Before the `with_tx`
190    /// fix (two separate `must_get`+`update` lock acquisitions instead of
191    /// one spanning both), this test was flaky — failing whenever the two
192    /// threads' read-then-write windows overlapped.
193    #[test]
194    fn update_object_meta_concurrent_writers_both_keys_survive() {
195        let store = Arc::new(Store::open_in_memory().unwrap());
196        // ORef::parse requires a UUID-shaped oid (see oref.rs) — a plain
197        // slug like "block-race" fails to parse, not the race itself.
198        let block_id = uuid::Uuid::new_v4().to_string();
199        insert_block(&store, &block_id);
200        let oref_str = format!("block:{block_id}");
201
202        let barrier = Arc::new(Barrier::new(2));
203
204        let store_a = Arc::clone(&store);
205        let oref_a = oref_str.clone();
206        let barrier_a = Arc::clone(&barrier);
207        let handle_a = thread::spawn(move || {
208            let mut meta = MetaMapType::new();
209            meta.insert("agent:sessionid".to_string(), serde_json::Value::String(String::new()));
210            barrier_a.wait();
211            update_object_meta(&store_a, &oref_a, &meta).unwrap();
212        });
213
214        let store_b = Arc::clone(&store);
215        let oref_b = oref_str.clone();
216        let barrier_b = Arc::clone(&barrier);
217        let handle_b = thread::spawn(move || {
218            let mut meta = MetaMapType::new();
219            meta.insert("session:active_pid".to_string(), serde_json::Value::from(4242));
220            barrier_b.wait();
221            update_object_meta(&store_b, &oref_b, &meta).unwrap();
222        });
223
224        handle_a.join().unwrap();
225        handle_b.join().unwrap();
226
227        let final_block = store.must_get::<Block>(&block_id).unwrap();
228        // Both threads' writes must have stuck — a lost-update race would
229        // have whichever writer finished LAST silently revert the other's
230        // key back to absent, since it wrote back a meta snapshot it read
231        // before the other's write landed.
232        assert_eq!(
233            final_block.meta.get("agent:sessionid").and_then(|v| v.as_str()),
234            Some(""),
235            "agent:sessionid should be the empty string thread A wrote, not missing/reverted",
236        );
237        assert_eq!(
238            final_block.meta.get("session:active_pid").and_then(|v| v.as_i64()),
239            Some(4242),
240            "session:active_pid should be the value thread B wrote, not missing/reverted",
241        );
242        // Version must reflect exactly two applied updates (1 insert + 2
243        // updates = version 3) — a lost update would also show as a
244        // version that skipped one of the two writers' increments in a
245        // way that leaves one key's write invisible (already covered by
246        // the assert above); this is a secondary, cheap corroboration.
247        assert_eq!(final_block.version, 3, "both concurrent updates must have applied, not one clobbering the other");
248    }
249}
250
251/// Mirror an agent block's `term:zoom` into the per-agent `ui:zoom` content
252/// blob immediately (no debounce) so the zoom survives pane close even if the
253/// user closes the pane right after zooming. `zoom = Some(z)` upserts;
254/// `zoom = None` (term:zoom reset to null / 1.0) deletes the row so a
255/// default agent stores nothing. Writes are off-thread so the WebSocket
256/// handler stays non-blocking; SQLite serializes concurrent writes via the
257/// store mutex. See SPEC_AGENT_ZOOM_PERSISTENCE §4.3.
258pub(crate) fn schedule_agent_zoom_mirror(
259    store: std::sync::Arc<crate::backend::storage::store::Store>,
260    agent_id: String,
261    zoom: Option<f64>,
262) {
263    tokio::spawn(async move {
264        let now = chrono::Utc::now().timestamp_millis();
265        let result = match zoom {
266            Some(z) => store.agent_content_set(
267                &crate::backend::storage::store::AgentContent {
268                    agent_id: agent_id.clone(),
269                    content_type: "ui:zoom".to_string(),
270                    content: format!("{}", z),
271                    updated_at: now,
272                },
273            ),
274            None => store.agent_content_delete(&agent_id, "ui:zoom").map(|_| ()),
275        };
276        if let Err(e) = result {
277            tracing::warn!(agent_id = %agent_id, error = %e, "[zoom] agent zoom mirror write failed");
278        }
279    });
280}
281
282#[cfg(test)]
283mod agent_zoom_mirror_tests {
284    use super::schedule_agent_zoom_mirror;
285    use crate::backend::storage::store::{AgentContent, AgentDefinition, Store};
286    use std::sync::Arc;
287
288    /// `db_agent_content.agent_id` has a FK to the agent-definitions table, so a
289    /// real mirror only ever fires for an existing agent (the def was loaded at
290    /// agent.open). Seed a minimal def so the test mirrors that invariant.
291    fn seed_agent_def(store: &Store, id: &str) {
292        let mut def: AgentDefinition = serde_json::from_value(serde_json::json!({
293            "id": id,
294            "name": id,
295            "icon": "wand-magic-sparkles",
296            "provider": "claude",
297            "description": "",
298            "created_at": 1,
299        }))
300        .expect("build minimal AgentDefinition");
301        store.agent_def_insert(&mut def).expect("insert agent def");
302    }
303
304    /// Each `term:zoom` change is written immediately; last write wins.
305    #[tokio::test]
306    async fn mirror_writes_each_zoom_immediately() {
307        let store = Arc::new(Store::open_in_memory().unwrap());
308        let agent = "agent-zoom-burst";
309        seed_agent_def(&store, agent);
310
311        schedule_agent_zoom_mirror(store.clone(), agent.into(), Some(1.1));
312        schedule_agent_zoom_mirror(store.clone(), agent.into(), Some(1.4));
313
314        // Give the spawned tasks a moment to complete.
315        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
316
317        let saved = store
318            .agent_content_get(agent, "ui:zoom")
319            .unwrap()
320            .expect("zoom persisted immediately");
321        // Last write wins.
322        assert_eq!(saved.content, "1.4", "last zoom value persists");
323    }
324
325    /// `term:zoom` reset to null/1.0 → `None` → delete the saved row so a
326    /// default agent stores nothing.
327    #[tokio::test]
328    async fn reset_to_default_deletes_saved_zoom() {
329        let store = Arc::new(Store::open_in_memory().unwrap());
330        let agent = "agent-zoom-reset";
331        seed_agent_def(&store, agent);
332        store
333            .agent_content_set(&AgentContent {
334                agent_id: agent.into(),
335                content_type: "ui:zoom".into(),
336                content: "1.5".into(),
337                updated_at: 1,
338            })
339            .unwrap();
340
341        schedule_agent_zoom_mirror(store.clone(), agent.into(), None);
342        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
343
344        assert!(
345            store.agent_content_get(agent, "ui:zoom").unwrap().is_none(),
346            "reset-to-default removes the persisted zoom"
347        );
348    }
349}