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}