Skip to main content

lattice_agent/
state_cache.rs

1//! AG-2a: the protocol-neutral editor read-state cache.
2//!
3//! Per mode-ownership ([[feedback_mode_owns_its_surface]]), the read state
4//! the agent read tools answer from belongs to **this crate**, not the host
5//! and not any one agent-protocol adapter. Adapters subscribe to the generic
6//! event bus (`DocumentOpened` / `DocumentClosed` / `SelectionsChanged`) and
7//! fold those events into an `EditorStateCache`. A dedicated updater task
8//! owns the writes; agent tasks read the cache off the editor thread. The
9//! editor thread pays nothing new — it already `publish`es these events.
10//!
11//! On-demand text / path / dirty come from the generic `BufferStore`
12//! service at read time, not this cache — the cache only tracks the
13//! open-editor *set* and the active selection, which aren't otherwise
14//! queryable off-thread.
15
16use std::collections::HashMap;
17use std::path::PathBuf;
18use std::sync::{Arc, Mutex};
19
20use lattice_protocol::ids::DocumentId;
21use lattice_protocol::{Event, EventKind, SelectionSet};
22use lattice_runtime::{EventBus, EventFilter, SubscriptionTarget};
23
24/// One open editor buffer, tracked from `DocumentOpened`.
25#[derive(Debug, Clone, Default)]
26pub struct OpenBuffer {
27    /// Filesystem path, if the buffer is a real file editor (`None` for
28    /// scratch / unsaved buffers).
29    pub path: Option<PathBuf>,
30    /// Latest known text version.
31    pub version: u64,
32}
33
34/// The active buffer's selection, tracked from `SelectionsChanged`.
35#[derive(Debug, Clone)]
36pub struct ActiveSelection {
37    /// The buffer whose selection this is (the most recently active one).
38    pub buffer: DocumentId,
39    /// Text version the selection was reported against.
40    pub version: u64,
41    /// The selection set (usually one cursor / range).
42    pub selections: SelectionSet,
43}
44
45/// Crate-owned snapshot of the editor state the read tools answer from.
46/// Holds **no host types** — only protocol-level ids / selections / paths.
47/// Mutated solely by the updater task (draining generic events); read by
48/// consumers under the [`EditorStateHandle`] mutex.
49#[derive(Debug, Default)]
50pub struct EditorStateCache {
51    /// Open editor buffers keyed by id.
52    pub open_buffers: HashMap<DocumentId, OpenBuffer>,
53    /// The active buffer + selection, if any buffer is active.
54    pub active: Option<ActiveSelection>,
55}
56
57impl EditorStateCache {
58    /// Fold one generic editor event into the cache. The subscription
59    /// filters to the three relevant kinds; the catch-all keeps this a
60    /// total, defensive match.
61    pub fn apply_event(&mut self, event: &Event) {
62        match event {
63            Event::DocumentOpened {
64                id, path, version, ..
65            } => {
66                self.open_buffers.insert(
67                    *id,
68                    OpenBuffer {
69                        path: path.clone(),
70                        version: *version,
71                    },
72                );
73            }
74            Event::DocumentClosed { id } => {
75                self.open_buffers.remove(id);
76                if self.active.as_ref().is_some_and(|a| a.buffer == *id) {
77                    self.active = None;
78                }
79            }
80            Event::SelectionsChanged {
81                id,
82                version,
83                selections,
84            } => {
85                if let Some(b) = self.open_buffers.get_mut(id) {
86                    b.version = *version;
87                }
88                self.active = Some(ActiveSelection {
89                    buffer: *id,
90                    version: *version,
91                    selections: selections.clone(),
92                });
93            }
94            _ => {}
95        }
96    }
97}
98
99/// Thread-safe handle to the read cache, shared between the updater task
100/// (writer) and agent tasks (readers). A plain `Mutex` — updates are O(1)
101/// in-place (no per-event clone) and reads are rare (agent-initiated), so
102/// there's no contention worth a wait-free structure.
103pub type EditorStateHandle = Arc<Mutex<EditorStateCache>>;
104
105/// Subscribe to the generic event bus and spawn the cache-updater task.
106/// Returns the shared cache handle. Fully crate-owned: the host only
107/// publishes the (generic) events; this task + cache live here.
108pub fn spawn_read_cache(bus: &Arc<EventBus>, rt: &tokio::runtime::Handle) -> EditorStateHandle {
109    let cache: EditorStateHandle = Arc::new(Mutex::new(EditorStateCache::default()));
110    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
111    bus.subscribe(
112        EventFilter::kinds(vec![
113            EventKind::DocumentOpened,
114            EventKind::DocumentClosed,
115            EventKind::SelectionsChanged,
116        ]),
117        SubscriptionTarget::Channel(tx),
118    );
119    let cache_for_task = Arc::clone(&cache);
120    rt.spawn(async move {
121        while let Some(event) = rx.recv().await {
122            cache_for_task
123                .lock()
124                .unwrap_or_else(|e| e.into_inner())
125                .apply_event(&event);
126        }
127    });
128    cache
129}
130
131#[cfg(test)]
132mod tests {
133    #![allow(clippy::unwrap_used)]
134    use super::*;
135
136    fn doc(id: u64) -> DocumentId {
137        DocumentId::new(id)
138    }
139
140    fn opened(id: u64, path: &str) -> Event {
141        Event::DocumentOpened {
142            id: doc(id),
143            path: Some(PathBuf::from(path)),
144            version: 1,
145            text: String::new(),
146        }
147    }
148
149    #[test]
150    fn document_opened_adds_to_open_set_with_path() {
151        let mut s = EditorStateCache::default();
152        s.apply_event(&opened(1, "/work/a.rs"));
153        assert_eq!(s.open_buffers.len(), 1);
154        assert_eq!(
155            s.open_buffers[&doc(1)].path.as_deref(),
156            Some(std::path::Path::new("/work/a.rs"))
157        );
158    }
159
160    #[test]
161    fn selections_changed_sets_active_buffer_and_version() {
162        let mut s = EditorStateCache::default();
163        s.apply_event(&opened(1, "/work/a.rs"));
164        s.apply_event(&Event::SelectionsChanged {
165            id: doc(1),
166            version: 7,
167            selections: SelectionSet::default(),
168        });
169        let active = s.active.as_ref().expect("active set");
170        assert_eq!(active.buffer, doc(1));
171        assert_eq!(active.version, 7);
172    }
173
174    #[test]
175    fn document_closed_removes_from_set_and_clears_active() {
176        let mut s = EditorStateCache::default();
177        s.apply_event(&opened(1, "/work/a.rs"));
178        s.apply_event(&Event::SelectionsChanged {
179            id: doc(1),
180            version: 2,
181            selections: SelectionSet::default(),
182        });
183        assert!(s.active.is_some());
184        s.apply_event(&Event::DocumentClosed { id: doc(1) });
185        assert!(s.open_buffers.is_empty());
186        assert!(
187            s.active.is_none(),
188            "closing the active buffer clears active"
189        );
190    }
191
192    #[test]
193    fn closing_a_non_active_buffer_keeps_active() {
194        let mut s = EditorStateCache::default();
195        s.apply_event(&opened(1, "/work/a.rs"));
196        s.apply_event(&opened(2, "/work/b.rs"));
197        s.apply_event(&Event::SelectionsChanged {
198            id: doc(2),
199            version: 3,
200            selections: SelectionSet::default(),
201        });
202        s.apply_event(&Event::DocumentClosed { id: doc(1) });
203        assert_eq!(s.open_buffers.len(), 1);
204        assert!(
205            s.active.is_some(),
206            "closing a non-active buffer leaves active"
207        );
208    }
209
210    #[test]
211    fn unrelated_event_is_a_noop() {
212        let mut s = EditorStateCache::default();
213        s.apply_event(&Event::DocumentSaved {
214            id: doc(1),
215            path: PathBuf::from("/work/a.rs"),
216        });
217        assert!(s.open_buffers.is_empty());
218        assert!(s.active.is_none());
219    }
220}