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}