Skip to main content

agentmux_srv/reducer/
block.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use agentmux_common::ipc::{ErrorCode, Event};
5
6use crate::state::State;
7
8
9use crate::state::BlockRecord;
10
11/// Phase E.3 — create a block inside a tab. Validates parent tab
12/// exists; otherwise emits `Event::Error` (non-fatal). On success:
13/// assigns a UUID, appends to the tab's `block_ids`, inserts into
14/// `state.blocks`, emits `Event::BlockCreated`.
15///
16/// NOT idempotent on retry (UUID assignment per call); saga-side
17/// dedup is responsible for at-most-once delivery in E.5+.
18pub(super) fn handle_create_block(
19    state: &mut State,
20    tab_id: String,
21    meta: serde_json::Value,
22) -> Vec<Event> {
23    if !state.tabs.contains_key(&tab_id) {
24        let v = state.bump_version();
25        return vec![Event::Error {
26            code: ErrorCode::InvalidCommand,
27            message: format!("CreateBlock: tab not found: {}", tab_id),
28            fatal: false,
29            version: v,
30        }];
31    }
32    let block_id = uuid::Uuid::new_v4().to_string();
33    state.blocks.insert(
34        block_id.clone(),
35        BlockRecord {
36            block_id: block_id.clone(),
37            tab_id: tab_id.clone(),
38        },
39    );
40    let tab = state.tabs.get_mut(&tab_id).expect("checked");
41    tab.block_ids.push(block_id.clone());
42    let v = state.bump_version();
43    vec![Event::BlockCreated {
44        tab_id,
45        block_id,
46        meta,
47        version: v,
48    }]
49}
50
51/// Phase E.3 — delete a block from a tab. Idempotent: deleting a
52/// missing tab or missing block is a silent no-op.
53pub(super) fn handle_delete_block(state: &mut State, tab_id: String, block_id: String) -> Vec<Event> {
54    let Some(tab) = state.tabs.get_mut(&tab_id) else {
55        return Vec::new();
56    };
57    let Some(pos) = tab.block_ids.iter().position(|b| b == &block_id) else {
58        return Vec::new();
59    };
60    tab.block_ids.remove(pos);
61    state.blocks.remove(&block_id);
62    let v = state.bump_version();
63    vec![Event::BlockDeleted {
64        tab_id,
65        block_id,
66        version: v,
67    }]
68}
69
70/// Phase E.5.5 — move a block from `src_tab_id` to `dst_tab_id` at
71/// `dst_index` (clamped). Updates `block.tab_id`. Cross-tab moves
72/// AND intra-tab repositioning both go through this command (the
73/// caller specifies the destination index regardless).
74///
75/// Errors when source / dest tab missing, block missing, or
76/// `block.tab_id != src_tab_id`.
77pub(super) fn handle_move_block(
78    state: &mut State,
79    block_id: String,
80    src_tab_id: String,
81    dst_tab_id: String,
82    dst_index: u32,
83) -> Vec<Event> {
84    let validation_error: Option<String> = {
85        if !state.tabs.contains_key(&src_tab_id) {
86            Some(format!("MoveBlock: src tab not found: {}", src_tab_id))
87        } else if !state.tabs.contains_key(&dst_tab_id) {
88            Some(format!("MoveBlock: dst tab not found: {}", dst_tab_id))
89        } else {
90            match state.blocks.get(&block_id) {
91                None => Some(format!("MoveBlock: block not found: {}", block_id)),
92                Some(block) if block.tab_id != src_tab_id => Some(format!(
93                    "MoveBlock: block {} belongs to tab {}, not {}",
94                    block_id, block.tab_id, src_tab_id
95                )),
96                _ => None,
97            }
98        }
99    };
100    if let Some(message) = validation_error {
101        let v = state.bump_version();
102        return vec![Event::Error {
103            code: ErrorCode::InvalidCommand,
104            message,
105            fatal: false,
106            version: v,
107        }];
108    }
109
110    // Special-case intra-tab move: remove and re-insert in the same
111    // tab. The clamp is computed AFTER the removal so dst_index
112    // refers to the post-removal list (matches the spec's "position
113    // in dst.tab_ids AFTER insertion" semantics for cross-tab moves).
114    let final_dst_index: u32 = if src_tab_id == dst_tab_id {
115        let tab = state.tabs.get_mut(&src_tab_id).expect("checked");
116        tab.block_ids.retain(|id| id != &block_id);
117        let clamped = (dst_index as usize).min(tab.block_ids.len());
118        tab.block_ids.insert(clamped, block_id.clone());
119        clamped as u32
120    } else {
121        // Remove from src.
122        state
123            .tabs
124            .get_mut(&src_tab_id)
125            .expect("checked")
126            .block_ids
127            .retain(|id| id != &block_id);
128        // Insert into dst.
129        let dst = state.tabs.get_mut(&dst_tab_id).expect("checked");
130        let clamped = (dst_index as usize).min(dst.block_ids.len());
131        dst.block_ids.insert(clamped, block_id.clone());
132        // Update parent.
133        state.blocks.get_mut(&block_id).expect("checked").tab_id = dst_tab_id.clone();
134        clamped as u32
135    };
136
137    let v = state.bump_version();
138    vec![Event::BlockMoved {
139        block_id,
140        src_tab_id,
141        dst_tab_id,
142        dst_index: final_dst_index,
143        version: v,
144    }]
145}
146
147/// Phase E.5.3 — pass-through for block meta updates. Same shape.
148pub(super) fn handle_update_block_meta(
149    state: &mut State,
150    block_id: String,
151    meta_patch: serde_json::Value,
152) -> Vec<Event> {
153    if !state.blocks.contains_key(&block_id) {
154        let v = state.bump_version();
155        return vec![Event::Error {
156            code: ErrorCode::InvalidCommand,
157            message: format!("UpdateBlockMeta: block not found: {}", block_id),
158            fatal: false,
159            version: v,
160        }];
161    }
162    let v = state.bump_version();
163    vec![Event::BlockMetaUpdated {
164        block_id,
165        meta_patch,
166        version: v,
167    }]
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::reducer::test_support::*;
174    use crate::reducer::update;
175    use agentmux_common::ipc::Command;
176
177    #[test]
178    fn create_block_validates_tab_exists() {
179        let mut state = State::default();
180        let events = update(
181            &mut state,
182            Command::CreateBlock { tab_id: "no-such-tab".into(), meta: serde_json::Value::Null },
183            &ctx(1),
184        );
185        assert!(matches!(&events[0], Event::Error { .. }));
186        assert!(state.blocks.is_empty());
187    }
188
189    #[test]
190    fn create_block_appends_to_tab_block_ids() {
191        let mut state = State::default();
192        let ws_id = create_workspace(&mut state, "w");
193        let tab_id = create_tab(&mut state, &ws_id, "t");
194        let events = update(
195            &mut state,
196            Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
197            &ctx(2),
198        );
199        assert!(matches!(&events[0], Event::BlockCreated { .. }));
200        let block_id = match &events[0] {
201            Event::BlockCreated { block_id, .. } => block_id.clone(),
202            _ => panic!(),
203        };
204        assert_eq!(state.tabs[&tab_id].block_ids, vec![block_id.clone()]);
205        assert_eq!(state.blocks[&block_id].tab_id, tab_id);
206    }
207
208    #[test]
209    fn delete_block_removes_from_state_and_tab() {
210        let mut state = State::default();
211        let ws_id = create_workspace(&mut state, "w");
212        let tab_id = create_tab(&mut state, &ws_id, "t");
213        let _ = update(
214            &mut state,
215            Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
216            &ctx(2),
217        );
218        let block_id = state.tabs[&tab_id].block_ids[0].clone();
219        let events = update(
220            &mut state,
221            Command::DeleteBlock {
222                tab_id: tab_id.clone(),
223                block_id: block_id.clone(),
224            },
225            &ctx(3),
226        );
227        assert!(matches!(&events[0], Event::BlockDeleted { .. }));
228        assert!(!state.blocks.contains_key(&block_id));
229        assert!(state.tabs[&tab_id].block_ids.is_empty());
230    }
231
232    #[test]
233    fn delete_block_unknown_silent_no_op() {
234        let mut state = State::default();
235        let ws_id = create_workspace(&mut state, "w");
236        let tab_id = create_tab(&mut state, &ws_id, "t");
237        let events = update(
238            &mut state,
239            Command::DeleteBlock {
240                tab_id,
241                block_id: "ghost".into(),
242            },
243            &ctx(2),
244        );
245        assert!(events.is_empty());
246    }
247
248    // ---- Phase E.5.5 — MoveBlock tests ----
249
250    #[test]
251    fn move_block_cross_tab_updates_lists_and_parent() {
252        let mut state = State::default();
253        let ws = create_workspace(&mut state, "w");
254        let src_tab = create_tab(&mut state, &ws, "src");
255        let dst_tab = create_tab(&mut state, &ws, "dst");
256        let block = create_block(&mut state, &src_tab);
257        let dst_existing = create_block(&mut state, &dst_tab);
258        let events = update(
259            &mut state,
260            Command::MoveBlock {
261                block_id: block.clone(),
262                src_tab_id: src_tab.clone(),
263                dst_tab_id: dst_tab.clone(),
264                dst_index: 0,
265            },
266            &ctx(2),
267        );
268        assert!(matches!(&events[0], Event::BlockMoved { .. }));
269        assert_eq!(state.tabs[&src_tab].block_ids, Vec::<String>::new());
270        assert_eq!(state.tabs[&dst_tab].block_ids, vec![block.clone(), dst_existing]);
271        assert_eq!(state.blocks[&block].tab_id, dst_tab);
272    }
273
274    #[test]
275    fn move_block_intra_tab_repositions() {
276        let mut state = State::default();
277        let ws = create_workspace(&mut state, "w");
278        let tab = create_tab(&mut state, &ws, "t");
279        let b1 = create_block(&mut state, &tab);
280        let b2 = create_block(&mut state, &tab);
281        let b3 = create_block(&mut state, &tab);
282        // Move b1 to position 2 (end after removal).
283        let events = update(
284            &mut state,
285            Command::MoveBlock {
286                block_id: b1.clone(),
287                src_tab_id: tab.clone(),
288                dst_tab_id: tab.clone(),
289                dst_index: 2,
290            },
291            &ctx(2),
292        );
293        match &events[0] {
294            Event::BlockMoved { dst_index, .. } => assert_eq!(*dst_index, 2),
295            other => panic!("expected BlockMoved, got {:?}", other),
296        }
297        assert_eq!(state.tabs[&tab].block_ids, vec![b2, b3, b1]);
298    }
299
300    #[test]
301    fn move_block_rejects_unknown_src_or_dst_or_block() {
302        let mut state = State::default();
303        let ws = create_workspace(&mut state, "w");
304        let tab = create_tab(&mut state, &ws, "t");
305        let other_tab = create_tab(&mut state, &ws, "other");
306        let block = create_block(&mut state, &tab);
307
308        let events = update(
309            &mut state,
310            Command::MoveBlock {
311                block_id: block.clone(),
312                src_tab_id: "ghost-src".into(),
313                dst_tab_id: other_tab.clone(),
314                dst_index: 0,
315            },
316            &ctx(2),
317        );
318        assert!(matches!(&events[0], Event::Error { .. }));
319
320        let events = update(
321            &mut state,
322            Command::MoveBlock {
323                block_id: block.clone(),
324                src_tab_id: tab.clone(),
325                dst_tab_id: "ghost-dst".into(),
326                dst_index: 0,
327            },
328            &ctx(3),
329        );
330        assert!(matches!(&events[0], Event::Error { .. }));
331
332        let events = update(
333            &mut state,
334            Command::MoveBlock {
335                block_id: "ghost-block".into(),
336                src_tab_id: tab,
337                dst_tab_id: other_tab,
338                dst_index: 0,
339            },
340            &ctx(4),
341        );
342        assert!(matches!(&events[0], Event::Error { .. }));
343    }
344
345    #[test]
346    fn move_block_rejects_when_block_belongs_to_different_tab() {
347        let mut state = State::default();
348        let ws = create_workspace(&mut state, "w");
349        let real_src = create_tab(&mut state, &ws, "real");
350        let other = create_tab(&mut state, &ws, "other");
351        let dst = create_tab(&mut state, &ws, "dst");
352        let block = create_block(&mut state, &real_src);
353        let events = update(
354            &mut state,
355            Command::MoveBlock {
356                block_id: block,
357                src_tab_id: other,
358                dst_tab_id: dst,
359                dst_index: 0,
360            },
361            &ctx(2),
362        );
363        match &events[0] {
364            Event::Error { message, .. } => {
365                assert!(message.contains("belongs to tab"), "got: {}", message);
366            }
367            other => panic!("expected Error, got {:?}", other),
368        }
369    }
370}