lean_ctx/core/context_kernel/
stream_controller.rs1use std::collections::HashMap;
4use std::hash::{Hash, Hasher};
5use std::time::{Duration, Instant};
6
7const PREFIX_LINES: usize = 10;
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub struct StreamRef {
12 pub source_id: String,
13 pub stream_type: StreamType,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum StreamType {
19 Terminal,
20 BuildLog,
21 FileWatch,
22 Custom,
23}
24
25#[derive(Debug, Clone)]
27pub struct StreamState {
28 pub generation: u64,
29 pub line_cursor: usize,
30 pub byte_cursor: usize,
31 pub prefix_hash: u64,
32 pub last_seen: Instant,
33 pub total_lines: usize,
34}
35
36#[derive(Debug, Clone)]
38pub enum StreamDelta {
39 Unchanged,
41 Append {
43 new_lines: Vec<String>,
44 from_line: usize,
45 },
46 Rotation {
48 full_content: Vec<String>,
49 reason: String,
50 },
51 Expired,
53}
54
55pub struct StreamController {
57 streams: HashMap<StreamRef, StreamState>,
58 max_tracked: usize,
59 expiry: Duration,
60}
61
62impl StreamController {
63 pub fn new(max_tracked: usize, expiry_secs: u64) -> Self {
65 Self {
66 streams: HashMap::new(),
67 max_tracked,
68 expiry: Duration::from_secs(expiry_secs),
69 }
70 }
71
72 pub fn compute_delta(
74 &mut self,
75 stream_ref: &StreamRef,
76 current_content: &[String],
77 ) -> StreamDelta {
78 if current_content.is_empty() {
79 return StreamDelta::Unchanged;
80 }
81
82 let now = Instant::now();
83 let Some(state) = self.streams.get_mut(stream_ref) else {
84 let state = make_state(1, current_content, now);
85 self.ensure_capacity();
86 if self.max_tracked > 0 {
87 self.streams.insert(stream_ref.clone(), state);
88 }
89 return rotation(current_content, "first_seen");
90 };
91
92 state.last_seen = now;
93 if current_content.len() < state.total_lines {
94 replace_state(state, current_content, now);
95 return rotation(current_content, "truncated");
96 }
97
98 let previous_prefix_lines = PREFIX_LINES.min(state.total_lines);
99 let current_prefix_hash = compute_prefix_hash(current_content, previous_prefix_lines);
100 if current_prefix_hash != state.prefix_hash {
101 replace_state(state, current_content, now);
102 return rotation(current_content, "prefix_changed");
103 }
104
105 match current_content.len().cmp(&state.total_lines) {
106 std::cmp::Ordering::Equal => StreamDelta::Unchanged,
107 std::cmp::Ordering::Greater => {
108 let from_line = state.line_cursor;
109 let new_lines = current_content[from_line..].to_vec();
110 update_cursors(state, current_content);
111 StreamDelta::Append {
112 new_lines,
113 from_line,
114 }
115 }
116 std::cmp::Ordering::Less => {
117 replace_state(state, current_content, now);
118 rotation(current_content, "truncated")
119 }
120 }
121 }
122
123 pub fn gc(&mut self) -> usize {
125 let before = self.streams.len();
126 let expiry = self.expiry;
127 self.streams
128 .retain(|_, state| state.last_seen.elapsed() < expiry);
129 before - self.streams.len()
130 }
131
132 pub fn tracked_count(&self) -> usize {
134 self.streams.len()
135 }
136
137 fn ensure_capacity(&mut self) {
138 if self.max_tracked == 0 || self.streams.len() < self.max_tracked {
139 return;
140 }
141 if let Some(oldest) = self
142 .streams
143 .iter()
144 .min_by_key(|(_, state)| state.last_seen)
145 .map(|(stream_ref, _)| stream_ref.clone())
146 {
147 self.streams.remove(&oldest);
148 }
149 }
150}
151
152fn compute_prefix_hash(lines: &[String], max_lines: usize) -> u64 {
153 let mut hasher = std::collections::hash_map::DefaultHasher::new();
154 for line in lines.iter().take(max_lines) {
155 line.hash(&mut hasher);
156 }
157 hasher.finish()
158}
159
160fn content_bytes(lines: &[String]) -> usize {
161 lines.iter().map(String::len).sum()
162}
163
164fn make_state(generation: u64, content: &[String], last_seen: Instant) -> StreamState {
165 StreamState {
166 generation,
167 line_cursor: content.len(),
168 byte_cursor: content_bytes(content),
169 prefix_hash: compute_prefix_hash(content, PREFIX_LINES),
170 last_seen,
171 total_lines: content.len(),
172 }
173}
174
175fn replace_state(state: &mut StreamState, content: &[String], last_seen: Instant) {
176 *state = make_state(state.generation.saturating_add(1), content, last_seen);
177}
178
179fn update_cursors(state: &mut StreamState, content: &[String]) {
180 state.line_cursor = content.len();
181 state.byte_cursor = content_bytes(content);
182 state.prefix_hash = compute_prefix_hash(content, PREFIX_LINES);
183 state.total_lines = content.len();
184}
185
186fn rotation(content: &[String], reason: &str) -> StreamDelta {
187 StreamDelta::Rotation {
188 full_content: content.to_vec(),
189 reason: reason.to_owned(),
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 fn stream_ref() -> StreamRef {
198 StreamRef {
199 source_id: "test-stream".into(),
200 stream_type: StreamType::Terminal,
201 }
202 }
203
204 fn lines(values: &[&str]) -> Vec<String> {
205 values.iter().map(|value| (*value).into()).collect()
206 }
207
208 fn controller_with(content: &[&str]) -> StreamController {
209 let mut controller = StreamController::new(8, 60);
210 controller.compute_delta(&stream_ref(), &lines(content));
211 controller
212 }
213
214 fn rotation(delta: StreamDelta) -> (Vec<String>, String) {
215 match delta {
216 StreamDelta::Rotation {
217 full_content,
218 reason,
219 } => (full_content, reason),
220 other => panic!("expected rotation, got {other:?}"),
221 }
222 }
223
224 #[test]
225 fn test_first_seen_returns_rotation() {
226 let content = lines(&["one", "two"]);
227 let mut controller = StreamController::new(8, 60);
228 let delta = controller.compute_delta(&stream_ref(), &content);
229 assert_eq!(rotation(delta), (content, "first_seen".into()));
230 }
231
232 #[test]
233 fn test_unchanged_content_returns_unchanged() {
234 let mut controller = controller_with(&["one", "two"]);
235 let delta = controller.compute_delta(&stream_ref(), &lines(&["one", "two"]));
236 assert!(matches!(delta, StreamDelta::Unchanged));
237 }
238
239 #[test]
240 fn test_append_detection() {
241 let mut controller = controller_with(&["one", "two"]);
242 let delta = controller.compute_delta(&stream_ref(), &lines(&["one", "two", "three"]));
243 match delta {
244 StreamDelta::Append {
245 new_lines,
246 from_line,
247 } => assert_eq!((new_lines, from_line), (lines(&["three"]), 2)),
248 other => panic!("expected append, got {other:?}"),
249 }
250 }
251
252 #[test]
253 fn test_prefix_change_returns_rotation() {
254 let mut controller = controller_with(&["one", "two"]);
255 let delta = controller.compute_delta(&stream_ref(), &lines(&["changed", "two"]));
256 assert_eq!(rotation(delta).1, "prefix_changed");
257 }
258
259 #[test]
260 fn test_truncation_returns_rotation() {
261 let mut controller = controller_with(&["one", "two", "three"]);
262 let delta = controller.compute_delta(&stream_ref(), &lines(&["one", "two"]));
263 assert_eq!(rotation(delta).1, "truncated");
264 }
265
266 #[test]
267 fn test_gc_removes_expired_streams() {
268 let mut controller = StreamController::new(8, 0);
269 controller.compute_delta(&stream_ref(), &lines(&["one"]));
270 assert_eq!((controller.gc(), controller.tracked_count()), (1, 0));
271 }
272
273 #[test]
274 fn test_empty_content_unchanged() {
275 let mut controller = StreamController::new(8, 60);
276 let delta = controller.compute_delta(&stream_ref(), &[]);
277 assert!(matches!(delta, StreamDelta::Unchanged));
278 assert_eq!(controller.tracked_count(), 0);
279 }
280}