1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex};
4
5use rusqlite::{Connection, OptionalExtension, params};
6use sha2::{Digest, Sha256};
7
8use super::types::{
9 AppendDisposition, AppendOutcome, ConsoleCursor, ConsoleFrame, ConsoleFrameSource,
10 ConsoleFrameSourceKind, ConsoleFrameStatus, ConsoleTimelineMode, ConsoleTimelinePage,
11 ConsoleTimelineQuery, ConsoleTimelineWindowPage, ConsoleTimelineWindowQuery, NewConsoleFrame,
12};
13
14pub type ConsoleLogResult<T> = Result<T, ConsoleLogError>;
15
16pub type ConsoleLogError = Box<dyn std::error::Error + Send + Sync>;
17
18#[async_trait::async_trait]
19pub trait ConsoleLogStore: Send + Sync {
20 async fn append_if_absent(&self, frame: NewConsoleFrame) -> ConsoleLogResult<AppendOutcome>;
21
22 async fn update_frame_status(
23 &self,
24 frame_id: &str,
25 status: ConsoleFrameStatus,
26 ) -> ConsoleLogResult<Option<ConsoleFrame>>;
27
28 async fn query_frames(
29 &self,
30 query: ConsoleTimelineQuery,
31 ) -> ConsoleLogResult<ConsoleTimelinePage>;
32
33 async fn query_windowed_frames(
34 &self,
35 query: ConsoleTimelineWindowQuery,
36 ) -> ConsoleLogResult<ConsoleTimelineWindowPage> {
37 if query.mode != ConsoleTimelineMode::Since || query.before.is_some() {
38 return Err(std::io::Error::other(
39 "console log store must implement query_windowed_frames for v0.4 timeline windows",
40 )
41 .into());
42 }
43 let page = self
44 .query_frames(ConsoleTimelineQuery {
45 identity: query.identity,
46 conversation_id: query.conversation_id,
47 after: query.after,
48 limit: query.limit,
49 })
50 .await?;
51 Ok(ConsoleTimelineWindowPage {
52 latest_cursor: page.next_cursor.clone(),
53 exhausted: false,
54 frames: page.frames,
55 next_cursor: page.next_cursor,
56 })
57 }
58
59 async fn frame_by_dedupe_key(&self, dedupe_key: &str)
60 -> ConsoleLogResult<Option<ConsoleFrame>>;
61
62 async fn latest_cursor(&self) -> ConsoleLogResult<Option<ConsoleCursor>>;
63
64 async fn clear_frames(&self) -> ConsoleLogResult<()>;
65
66 async fn record_source_watermark(
67 &self,
68 runtime_key: &str,
69 source_kind: ConsoleFrameSourceKind,
70 source_cursor: &str,
71 ) -> ConsoleLogResult<()>;
72
73 async fn source_watermark(
74 &self,
75 runtime_key: &str,
76 source_kind: ConsoleFrameSourceKind,
77 ) -> ConsoleLogResult<Option<String>>;
78}
79
80#[derive(Default)]
81pub struct InMemoryConsoleLogStore {
82 state: Mutex<InMemoryState>,
83}
84
85#[derive(Default)]
86struct InMemoryState {
87 next_seq: u64,
88 frames: BTreeMap<u64, ConsoleFrame>,
89 dedupe_to_seq: HashMap<String, u64>,
90 id_to_seq: HashMap<String, u64>,
91 identity_to_seqs: HashMap<String, BTreeSet<u64>>,
92 conversation_to_seqs: HashMap<String, BTreeSet<u64>>,
93 watermarks: HashMap<(String, String), String>,
94}
95
96impl InMemoryConsoleLogStore {
97 pub fn new() -> Self {
98 Self {
99 state: Mutex::new(InMemoryState {
100 next_seq: 1,
101 frames: BTreeMap::new(),
102 dedupe_to_seq: HashMap::new(),
103 id_to_seq: HashMap::new(),
104 identity_to_seqs: HashMap::new(),
105 conversation_to_seqs: HashMap::new(),
106 watermarks: HashMap::new(),
107 }),
108 }
109 }
110}
111
112#[async_trait::async_trait]
113impl ConsoleLogStore for InMemoryConsoleLogStore {
114 async fn append_if_absent(&self, frame: NewConsoleFrame) -> ConsoleLogResult<AppendOutcome> {
115 let mut state = self
116 .state
117 .lock()
118 .map_err(|_| boxed_error("console log lock poisoned"))?;
119 if let Some(seq) = state.dedupe_to_seq.get(&frame.dedupe_key).copied()
120 && let Some(existing) = state.frames.get(&seq)
121 {
122 return Ok(AppendOutcome {
123 disposition: AppendDisposition::Existing,
124 frame: existing.clone(),
125 });
126 }
127
128 let seq = state.next_seq;
129 state.next_seq = state.next_seq.saturating_add(1);
130 let id = frame
131 .id
132 .unwrap_or_else(|| stable_frame_id(&frame.dedupe_key));
133 let frame = ConsoleFrame {
134 id: id.clone(),
135 cursor: ConsoleCursor::from_seq(seq),
136 dedupe_key: frame.dedupe_key,
137 timestamp_ms: frame.timestamp_ms,
138 runtime_key: frame.runtime_key,
139 identity: frame.identity,
140 conversation_id: frame.conversation_id,
141 session_id: frame.session_id,
142 kind: frame.kind,
143 status: frame.status,
144 frame_version: 1,
145 updated_at_ms: None,
146 payload: frame.payload,
147 source: frame.source,
148 source_event_id: frame.source_event_id,
149 interaction_id: frame.interaction_id,
150 turn_id: frame.turn_id,
151 run_id: frame.run_id,
152 parent_frame_id: frame.parent_frame_id,
153 caused_by_frame_id: frame.caused_by_frame_id,
154 };
155 state.dedupe_to_seq.insert(frame.dedupe_key.clone(), seq);
156 state.id_to_seq.insert(id, seq);
157 state
158 .identity_to_seqs
159 .entry(frame.identity.clone())
160 .or_default()
161 .insert(seq);
162 if let Some(conversation_id) = frame.conversation_id.as_ref() {
163 state
164 .conversation_to_seqs
165 .entry(conversation_id.clone())
166 .or_default()
167 .insert(seq);
168 }
169 state.frames.insert(seq, frame.clone());
170 Ok(AppendOutcome {
171 disposition: AppendDisposition::Inserted,
172 frame,
173 })
174 }
175
176 async fn update_frame_status(
177 &self,
178 frame_id: &str,
179 status: ConsoleFrameStatus,
180 ) -> ConsoleLogResult<Option<ConsoleFrame>> {
181 let mut state = self
182 .state
183 .lock()
184 .map_err(|_| boxed_error("console log lock poisoned"))?;
185 let Some(seq) = state.id_to_seq.get(frame_id).copied() else {
186 return Ok(None);
187 };
188 let Some(frame) = state.frames.get_mut(&seq) else {
189 return Ok(None);
190 };
191 frame.status = status;
192 frame.frame_version = frame.frame_version.saturating_add(1);
193 frame.updated_at_ms = Some(current_time_ms());
194 Ok(Some(frame.clone()))
195 }
196
197 async fn query_frames(
198 &self,
199 query: ConsoleTimelineQuery,
200 ) -> ConsoleLogResult<ConsoleTimelinePage> {
201 let page = self.query_windowed_frames(query.into()).await?;
202 Ok(ConsoleTimelinePage {
203 frames: page.frames,
204 next_cursor: page.next_cursor,
205 })
206 }
207
208 async fn query_windowed_frames(
209 &self,
210 query: ConsoleTimelineWindowQuery,
211 ) -> ConsoleLogResult<ConsoleTimelineWindowPage> {
212 let after_seq = query.after.as_ref().map(cursor_seq).transpose()?;
213 let before_seq = query.before.as_ref().map(cursor_seq).transpose()?;
214 let limit = normalize_limit(query.limit);
215 let scan_limit = limit.saturating_add(1);
216 let state = self
217 .state
218 .lock()
219 .map_err(|_| boxed_error("console log lock poisoned"))?;
220 let frame_matches = |seq: u64, frame: &ConsoleFrame| -> bool {
221 if after_seq.is_some_and(|after| seq <= after) {
222 return false;
223 }
224 if before_seq.is_some_and(|before| seq >= before) {
225 return false;
226 }
227 if let Some(identity) = query.identity.as_deref()
228 && frame.identity != identity
229 {
230 return false;
231 }
232 if let Some(conversation_id) = query.conversation_id.as_deref()
233 && frame.conversation_id.as_deref() != Some(conversation_id)
234 {
235 return false;
236 }
237 true
238 };
239 if let (Some(identity), Some(conversation_id)) =
240 (query.identity.as_deref(), query.conversation_id.as_deref())
241 {
242 let identity_seqs = state.identity_to_seqs.get(identity);
243 let conversation_seqs = state.conversation_to_seqs.get(conversation_id);
244 return match (identity_seqs, conversation_seqs) {
245 (Some(left), Some(right)) if left.len() <= right.len() => {
246 Ok(in_memory_window_from_seq_iters(
247 &state,
248 query.mode,
249 (limit, scan_limit),
250 left.iter().copied().filter(|seq| right.contains(seq)),
251 left.iter().rev().copied().filter(|seq| right.contains(seq)),
252 left.iter().rev().copied().filter(|seq| right.contains(seq)),
253 &frame_matches,
254 ))
255 }
256 (Some(left), Some(right)) => Ok(in_memory_window_from_seq_iters(
257 &state,
258 query.mode,
259 (limit, scan_limit),
260 right.iter().copied().filter(|seq| left.contains(seq)),
261 right.iter().rev().copied().filter(|seq| left.contains(seq)),
262 right.iter().rev().copied().filter(|seq| left.contains(seq)),
263 &frame_matches,
264 )),
265 _ => Ok(empty_window()),
266 };
267 }
268 if let Some(identity) = query.identity.as_deref() {
269 let Some(seqs) = state.identity_to_seqs.get(identity) else {
270 return Ok(empty_window());
271 };
272 return Ok(in_memory_window_from_seq_iters(
273 &state,
274 query.mode,
275 (limit, scan_limit),
276 seqs.iter().copied(),
277 seqs.iter().rev().copied(),
278 seqs.iter().rev().copied(),
279 &frame_matches,
280 ));
281 }
282 if let Some(conversation_id) = query.conversation_id.as_deref() {
283 let Some(seqs) = state.conversation_to_seqs.get(conversation_id) else {
284 return Ok(empty_window());
285 };
286 return Ok(in_memory_window_from_seq_iters(
287 &state,
288 query.mode,
289 (limit, scan_limit),
290 seqs.iter().copied(),
291 seqs.iter().rev().copied(),
292 seqs.iter().rev().copied(),
293 &frame_matches,
294 ));
295 }
296 Ok(in_memory_window_from_seq_iters(
297 &state,
298 query.mode,
299 (limit, scan_limit),
300 state.frames.keys().copied(),
301 state.frames.keys().rev().copied(),
302 state.frames.keys().rev().copied(),
303 &frame_matches,
304 ))
305 }
306
307 async fn frame_by_dedupe_key(
308 &self,
309 dedupe_key: &str,
310 ) -> ConsoleLogResult<Option<ConsoleFrame>> {
311 let state = self
312 .state
313 .lock()
314 .map_err(|_| boxed_error("console log lock poisoned"))?;
315 let Some(seq) = state.dedupe_to_seq.get(dedupe_key).copied() else {
316 return Ok(None);
317 };
318 Ok(state.frames.get(&seq).cloned())
319 }
320
321 async fn latest_cursor(&self) -> ConsoleLogResult<Option<ConsoleCursor>> {
322 let state = self
323 .state
324 .lock()
325 .map_err(|_| boxed_error("console log lock poisoned"))?;
326 Ok(state
327 .frames
328 .keys()
329 .next_back()
330 .copied()
331 .map(ConsoleCursor::from_seq))
332 }
333
334 async fn clear_frames(&self) -> ConsoleLogResult<()> {
335 let mut state = self
336 .state
337 .lock()
338 .map_err(|_| boxed_error("console log lock poisoned"))?;
339 state.frames.clear();
340 state.dedupe_to_seq.clear();
341 state.id_to_seq.clear();
342 state.identity_to_seqs.clear();
343 state.conversation_to_seqs.clear();
344 state.next_seq = 1;
345 Ok(())
346 }
347
348 async fn record_source_watermark(
349 &self,
350 runtime_key: &str,
351 source_kind: ConsoleFrameSourceKind,
352 source_cursor: &str,
353 ) -> ConsoleLogResult<()> {
354 let mut state = self
355 .state
356 .lock()
357 .map_err(|_| boxed_error("console log lock poisoned"))?;
358 state.watermarks.insert(
359 (runtime_key.to_string(), source_kind.as_str().to_string()),
360 source_cursor.to_string(),
361 );
362 Ok(())
363 }
364
365 async fn source_watermark(
366 &self,
367 runtime_key: &str,
368 source_kind: ConsoleFrameSourceKind,
369 ) -> ConsoleLogResult<Option<String>> {
370 let state = self
371 .state
372 .lock()
373 .map_err(|_| boxed_error("console log lock poisoned"))?;
374 Ok(state
375 .watermarks
376 .get(&(runtime_key.to_string(), source_kind.as_str().to_string()))
377 .cloned())
378 }
379}
380
381fn empty_window() -> ConsoleTimelineWindowPage {
382 ConsoleTimelineWindowPage {
383 frames: Vec::new(),
384 next_cursor: None,
385 latest_cursor: None,
386 exhausted: true,
387 }
388}
389
390fn in_memory_window_from_seq_iters<IForward, IReverse, ILatest, F>(
391 state: &InMemoryState,
392 mode: ConsoleTimelineMode,
393 limits: (usize, usize),
394 forward_iter: IForward,
395 reverse_iter: IReverse,
396 mut latest_iter: ILatest,
397 frame_matches: &F,
398) -> ConsoleTimelineWindowPage
399where
400 IForward: Iterator<Item = u64>,
401 IReverse: Iterator<Item = u64>,
402 ILatest: Iterator<Item = u64>,
403 F: Fn(u64, &ConsoleFrame) -> bool,
404{
405 let (limit, scan_limit) = limits;
406 let mut frames = match mode {
407 ConsoleTimelineMode::Since => forward_iter
408 .filter_map(|seq| {
409 let frame = state.frames.get(&seq)?;
410 frame_matches(seq, frame).then(|| frame.clone())
411 })
412 .take(scan_limit)
413 .collect::<Vec<_>>(),
414 ConsoleTimelineMode::Recent => {
415 let mut frames = reverse_iter
416 .filter_map(|seq| {
417 let frame = state.frames.get(&seq)?;
418 frame_matches(seq, frame).then(|| frame.clone())
419 })
420 .take(scan_limit)
421 .collect::<Vec<_>>();
422 frames.reverse();
423 frames
424 }
425 };
426 let exhausted = frames.len() <= limit;
427 if frames.len() > limit {
428 match mode {
429 ConsoleTimelineMode::Since => frames.truncate(limit),
430 ConsoleTimelineMode::Recent => {
431 frames.remove(0);
432 }
433 }
434 }
435 let next_cursor = frames.last().map(|frame| frame.cursor.clone());
436 let latest_cursor = match mode {
437 ConsoleTimelineMode::Since => latest_iter.find_map(|seq| {
438 let frame = state.frames.get(&seq)?;
439 frame_matches(seq, frame).then(|| frame.cursor.clone())
440 }),
441 ConsoleTimelineMode::Recent => next_cursor.clone(),
442 };
443 ConsoleTimelineWindowPage {
444 frames,
445 next_cursor,
446 latest_cursor,
447 exhausted,
448 }
449}
450
451pub struct SqliteConsoleLogStore {
452 conn: Arc<Mutex<Connection>>,
453 watermarks: Arc<Mutex<HashMap<(String, String), String>>>,
454 db_path: PathBuf,
457}
458
459const MOBKIT_CONSOLE_DOMAIN: meerkat_sqlite::SchemaDomain = meerkat_sqlite::SchemaDomain {
462 name: "mobkit-console",
463 migrations: &[meerkat_sqlite::Migration {
464 version: 1,
465 name: "base-schema",
466 apply: migration_0001_console_schema,
467 }],
468 initialize_current: migration_0001_console_schema,
469 allowed_existing_versions: &[1],
470 released_predecessors: &[],
471 owned_objects: &[
472 meerkat_sqlite::SchemaObject {
473 kind: meerkat_sqlite::SchemaObjectKind::Table,
474 name: "console_frames",
475 },
476 meerkat_sqlite::SchemaObject {
477 kind: meerkat_sqlite::SchemaObjectKind::Table,
478 name: "console_source_watermarks",
479 },
480 meerkat_sqlite::SchemaObject {
481 kind: meerkat_sqlite::SchemaObjectKind::Index,
482 name: "idx_console_frames_identity_cursor",
483 },
484 meerkat_sqlite::SchemaObject {
485 kind: meerkat_sqlite::SchemaObjectKind::Index,
486 name: "idx_console_frames_conversation_cursor",
487 },
488 ],
489 retired_objects: &[],
490};
491
492fn migration_0001_console_schema(tx: &rusqlite::Transaction<'_>) -> Result<(), rusqlite::Error> {
493 tx.execute_batch(
494 "CREATE TABLE IF NOT EXISTS console_frames (
495 cursor_seq INTEGER PRIMARY KEY AUTOINCREMENT,
496 id TEXT NOT NULL UNIQUE,
497 dedupe_key TEXT NOT NULL UNIQUE,
498 timestamp_ms INTEGER NOT NULL,
499 runtime_key TEXT NOT NULL,
500 identity TEXT NOT NULL,
501 conversation_id TEXT,
502 session_id TEXT,
503 kind TEXT NOT NULL,
504 status TEXT NOT NULL,
505 frame_version INTEGER NOT NULL DEFAULT 1,
506 updated_at_ms INTEGER,
507 payload_json TEXT NOT NULL,
508 source_kind TEXT NOT NULL,
509 source_cursor TEXT,
510 source_event_id TEXT,
511 interaction_id TEXT,
512 parent_frame_id TEXT,
513 caused_by_frame_id TEXT,
514 turn_id TEXT,
515 run_id TEXT
516 );
517 CREATE TABLE IF NOT EXISTS console_source_watermarks (
518 runtime_key TEXT NOT NULL,
519 source_kind TEXT NOT NULL,
520 source_cursor TEXT NOT NULL,
521 last_ingested_at_ms INTEGER NOT NULL,
522 PRIMARY KEY(runtime_key, source_kind)
523 );
524 CREATE INDEX IF NOT EXISTS idx_console_frames_identity_cursor
525 ON console_frames(identity, cursor_seq);
526 CREATE INDEX IF NOT EXISTS idx_console_frames_conversation_cursor
527 ON console_frames(conversation_id, cursor_seq);",
528 )
529}
530
531impl SqliteConsoleLogStore {
532 pub fn open(path: impl AsRef<Path>) -> ConsoleLogResult<Self> {
533 let path = path.as_ref().to_path_buf();
534 let mut conn = meerkat_sqlite::open(&path, meerkat_sqlite::ConnectionProfile::PRIMARY)
535 .map_err(into_boxed)?;
536 meerkat_sqlite::apply_domain_migrations(&mut conn, &MOBKIT_CONSOLE_DOMAIN)
537 .map_err(into_boxed)?;
538 Self::from_connection(conn, path)
539 }
540
541 pub fn in_memory() -> ConsoleLogResult<Self> {
542 let mut conn = Connection::open_in_memory().map_err(into_boxed)?;
543 meerkat_sqlite::apply_domain_migrations(&mut conn, &MOBKIT_CONSOLE_DOMAIN)
544 .map_err(into_boxed)?;
545 Self::from_connection(conn, PathBuf::from(":memory:"))
546 }
547
548 fn from_connection(conn: Connection, db_path: PathBuf) -> ConsoleLogResult<Self> {
549 let watermarks = load_source_watermarks(&conn)?;
550 Ok(Self {
551 conn: Arc::new(Mutex::new(conn)),
552 watermarks: Arc::new(Mutex::new(watermarks)),
553 db_path,
554 })
555 }
556
557 fn operation_fence(&self) -> ConsoleLogResult<meerkat_sqlite::OperationGuard> {
561 meerkat_sqlite::OperationGuard::for_database(&self.db_path).map_err(into_boxed)
562 }
563}
564
565#[async_trait::async_trait]
566impl ConsoleLogStore for SqliteConsoleLogStore {
567 async fn append_if_absent(&self, frame: NewConsoleFrame) -> ConsoleLogResult<AppendOutcome> {
568 let _fence = self.operation_fence()?;
569 let conn = self
570 .conn
571 .lock()
572 .map_err(|_| boxed_error("console log lock poisoned"))?;
573 if let Some(existing) = select_frame_by_dedupe(&conn, &frame.dedupe_key)? {
574 return Ok(AppendOutcome {
575 disposition: AppendDisposition::Existing,
576 frame: existing,
577 });
578 }
579
580 let id = frame
581 .id
582 .clone()
583 .unwrap_or_else(|| stable_frame_id(&frame.dedupe_key));
584 let payload_json = serde_json::to_string(&frame.payload).map_err(into_boxed)?;
585 conn.execute(
586 "INSERT INTO console_frames (
587 id, dedupe_key, timestamp_ms, runtime_key, identity,
588 conversation_id, session_id, kind, status, frame_version, updated_at_ms, payload_json,
589 source_kind, source_cursor, source_event_id, interaction_id,
590 parent_frame_id, caused_by_frame_id, turn_id, run_id
591 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1, NULL, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
592 params![
593 id,
594 frame.dedupe_key,
595 frame.timestamp_ms as i64,
596 frame.runtime_key,
597 frame.identity,
598 frame.conversation_id,
599 frame.session_id,
600 frame.kind,
601 frame.status.as_str(),
602 payload_json,
603 frame.source.kind.as_str(),
604 frame.source.source_cursor,
605 frame.source_event_id,
606 frame.interaction_id,
607 frame.parent_frame_id,
608 frame.caused_by_frame_id,
609 frame.turn_id,
610 frame.run_id,
611 ],
612 )
613 .map_err(into_boxed)?;
614 let inserted = select_frame_by_dedupe(&conn, &frame.dedupe_key)?
615 .ok_or_else(|| boxed_error("inserted console frame was not readable"))?;
616 Ok(AppendOutcome {
617 disposition: AppendDisposition::Inserted,
618 frame: inserted,
619 })
620 }
621
622 async fn update_frame_status(
623 &self,
624 frame_id: &str,
625 status: ConsoleFrameStatus,
626 ) -> ConsoleLogResult<Option<ConsoleFrame>> {
627 let _fence = self.operation_fence()?;
628 let conn = self
629 .conn
630 .lock()
631 .map_err(|_| boxed_error("console log lock poisoned"))?;
632 conn.execute(
633 "UPDATE console_frames SET status = ?1, frame_version = frame_version + 1, updated_at_ms = ?2 WHERE id = ?3",
634 params![status.as_str(), current_time_ms() as i64, frame_id],
635 )
636 .map_err(into_boxed)?;
637 select_frame_by_id(&conn, frame_id)
638 }
639
640 async fn query_frames(
641 &self,
642 query: ConsoleTimelineQuery,
643 ) -> ConsoleLogResult<ConsoleTimelinePage> {
644 let page = self.query_windowed_frames(query.into()).await?;
645 Ok(ConsoleTimelinePage {
646 frames: page.frames,
647 next_cursor: page.next_cursor,
648 })
649 }
650
651 async fn query_windowed_frames(
652 &self,
653 query: ConsoleTimelineWindowQuery,
654 ) -> ConsoleLogResult<ConsoleTimelineWindowPage> {
655 let after_seq = query.after.as_ref().map(cursor_seq_i64).transpose()?;
656 let before_seq = query.before.as_ref().map(cursor_seq_i64).transpose()?;
657 let limit = normalize_limit(query.limit);
658 let scan_limit = limit.saturating_add(1);
659 let _fence = self.operation_fence()?;
660 let conn = self
661 .conn
662 .lock()
663 .map_err(|_| boxed_error("console log lock poisoned"))?;
664 let mut sql = String::from(
665 "SELECT cursor_seq, id, dedupe_key, timestamp_ms, runtime_key, identity,
666 conversation_id, session_id, kind, status, frame_version, updated_at_ms, payload_json,
667 source_kind, source_cursor, source_event_id, interaction_id,
668 parent_frame_id, caused_by_frame_id, turn_id, run_id
669 FROM console_frames WHERE cursor_seq > ?1 AND cursor_seq < ?2",
670 );
671 let mut next_param = 3usize;
672 if query.identity.is_some() {
673 sql.push_str(" AND identity = ?");
674 sql.push_str(&next_param.to_string());
675 next_param += 1;
676 }
677 if query.conversation_id.is_some() {
678 sql.push_str(" AND conversation_id = ?");
679 sql.push_str(&next_param.to_string());
680 next_param += 1;
681 }
682 match query.mode {
683 ConsoleTimelineMode::Since => sql.push_str(" ORDER BY cursor_seq ASC LIMIT ?"),
684 ConsoleTimelineMode::Recent => sql.push_str(" ORDER BY cursor_seq DESC LIMIT ?"),
685 }
686 sql.push_str(&next_param.to_string());
687
688 let after = after_seq.unwrap_or(0);
689 let before = before_seq.unwrap_or(i64::MAX);
690 let mut values = vec![
691 rusqlite::types::Value::Integer(after),
692 rusqlite::types::Value::Integer(before),
693 ];
694 if let Some(identity) = query.identity.as_ref() {
695 values.push(rusqlite::types::Value::Text(identity.clone()));
696 }
697 if let Some(conversation_id) = query.conversation_id.as_ref() {
698 values.push(rusqlite::types::Value::Text(conversation_id.clone()));
699 }
700 values.push(rusqlite::types::Value::Integer(scan_limit as i64));
701 let mut frames = query_sql_frames(&conn, &sql, rusqlite::params_from_iter(values))?;
702 if query.mode == ConsoleTimelineMode::Recent {
703 frames.reverse();
704 }
705 let exhausted = frames.len() <= limit;
706 if frames.len() > limit {
707 match query.mode {
708 ConsoleTimelineMode::Since => frames.truncate(limit),
709 ConsoleTimelineMode::Recent => {
710 frames.remove(0);
711 }
712 }
713 }
714 let latest_cursor = latest_matching_cursor(
715 &conn,
716 after,
717 before,
718 query.identity.as_deref(),
719 query.conversation_id.as_deref(),
720 )?;
721 let next_cursor = frames.last().map(|frame| frame.cursor.clone());
722 Ok(ConsoleTimelineWindowPage {
723 frames,
724 next_cursor,
725 latest_cursor,
726 exhausted,
727 })
728 }
729
730 async fn frame_by_dedupe_key(
731 &self,
732 dedupe_key: &str,
733 ) -> ConsoleLogResult<Option<ConsoleFrame>> {
734 let _fence = self.operation_fence()?;
735 let conn = self
736 .conn
737 .lock()
738 .map_err(|_| boxed_error("console log lock poisoned"))?;
739 select_frame_by_dedupe(&conn, dedupe_key)
740 }
741
742 async fn latest_cursor(&self) -> ConsoleLogResult<Option<ConsoleCursor>> {
743 let _fence = self.operation_fence()?;
744 let conn = self
745 .conn
746 .lock()
747 .map_err(|_| boxed_error("console log lock poisoned"))?;
748 let seq: Option<i64> = conn
749 .query_row(
750 "SELECT cursor_seq FROM console_frames ORDER BY cursor_seq DESC LIMIT 1",
751 [],
752 |row| row.get(0),
753 )
754 .optional()
755 .map_err(into_boxed)?;
756 Ok(seq.map(|value| ConsoleCursor::from_seq(value as u64)))
757 }
758
759 async fn clear_frames(&self) -> ConsoleLogResult<()> {
760 let _fence = self.operation_fence()?;
761 let conn = self
762 .conn
763 .lock()
764 .map_err(|_| boxed_error("console log lock poisoned"))?;
765 conn.execute("DELETE FROM console_frames", [])
766 .map_err(into_boxed)?;
767 conn.execute(
768 "DELETE FROM sqlite_sequence WHERE name = 'console_frames'",
769 [],
770 )
771 .ok();
772 Ok(())
773 }
774
775 async fn record_source_watermark(
776 &self,
777 runtime_key: &str,
778 source_kind: ConsoleFrameSourceKind,
779 source_cursor: &str,
780 ) -> ConsoleLogResult<()> {
781 let _fence = self.operation_fence()?;
782 let conn = self
783 .conn
784 .lock()
785 .map_err(|_| boxed_error("console log lock poisoned"))?;
786 conn.execute(
787 "INSERT INTO console_source_watermarks (
788 runtime_key, source_kind, source_cursor, last_ingested_at_ms
789 ) VALUES (?1, ?2, ?3, ?4)
790 ON CONFLICT(runtime_key, source_kind) DO UPDATE SET
791 source_cursor = excluded.source_cursor,
792 last_ingested_at_ms = excluded.last_ingested_at_ms",
793 params![
794 runtime_key,
795 source_kind.as_str(),
796 source_cursor,
797 current_time_ms() as i64,
798 ],
799 )
800 .map_err(into_boxed)?;
801 self.watermarks
802 .lock()
803 .map_err(|_| boxed_error("console watermark lock poisoned"))?
804 .insert(
805 (runtime_key.to_string(), source_kind.as_str().to_string()),
806 source_cursor.to_string(),
807 );
808 Ok(())
809 }
810
811 async fn source_watermark(
812 &self,
813 runtime_key: &str,
814 source_kind: ConsoleFrameSourceKind,
815 ) -> ConsoleLogResult<Option<String>> {
816 let watermarks = self
817 .watermarks
818 .lock()
819 .map_err(|_| boxed_error("console watermark lock poisoned"))?;
820 Ok(watermarks
821 .get(&(runtime_key.to_string(), source_kind.as_str().to_string()))
822 .cloned())
823 }
824}
825
826fn load_source_watermarks(
827 conn: &Connection,
828) -> ConsoleLogResult<HashMap<(String, String), String>> {
829 let mut stmt = conn
830 .prepare(
831 "SELECT runtime_key, source_kind, source_cursor
832 FROM console_source_watermarks",
833 )
834 .map_err(into_boxed)?;
835 let rows = stmt
836 .query_map([], |row| {
837 Ok((
838 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
839 row.get::<_, String>(2)?,
840 ))
841 })
842 .map_err(into_boxed)?;
843 let mut watermarks = HashMap::new();
844 for row in rows {
845 let (key, cursor) = row.map_err(into_boxed)?;
846 watermarks.insert(key, cursor);
847 }
848 Ok(watermarks)
849}
850
851fn query_sql_frames<P: rusqlite::Params>(
852 conn: &Connection,
853 sql: &str,
854 params: P,
855) -> ConsoleLogResult<Vec<ConsoleFrame>> {
856 let mut stmt = conn.prepare(sql).map_err(into_boxed)?;
857 let rows = stmt.query_map(params, row_to_frame).map_err(into_boxed)?;
858 let mut frames = Vec::new();
859 for row in rows {
860 frames.push(row.map_err(into_boxed)?);
861 }
862 Ok(frames)
863}
864
865fn select_frame_by_dedupe(
866 conn: &Connection,
867 dedupe_key: &str,
868) -> ConsoleLogResult<Option<ConsoleFrame>> {
869 conn.query_row(
870 "SELECT cursor_seq, id, dedupe_key, timestamp_ms, runtime_key, identity,
871 conversation_id, session_id, kind, status, frame_version, updated_at_ms, payload_json,
872 source_kind, source_cursor, source_event_id, interaction_id,
873 parent_frame_id, caused_by_frame_id, turn_id, run_id
874 FROM console_frames WHERE dedupe_key = ?1",
875 params![dedupe_key],
876 row_to_frame,
877 )
878 .optional()
879 .map_err(into_boxed)
880}
881
882fn select_frame_by_id(conn: &Connection, id: &str) -> ConsoleLogResult<Option<ConsoleFrame>> {
883 conn.query_row(
884 "SELECT cursor_seq, id, dedupe_key, timestamp_ms, runtime_key, identity,
885 conversation_id, session_id, kind, status, frame_version, updated_at_ms, payload_json,
886 source_kind, source_cursor, source_event_id, interaction_id,
887 parent_frame_id, caused_by_frame_id, turn_id, run_id
888 FROM console_frames WHERE id = ?1",
889 params![id],
890 row_to_frame,
891 )
892 .optional()
893 .map_err(into_boxed)
894}
895
896fn latest_matching_cursor(
897 conn: &Connection,
898 after: i64,
899 before: i64,
900 identity: Option<&str>,
901 conversation_id: Option<&str>,
902) -> ConsoleLogResult<Option<ConsoleCursor>> {
903 let mut sql = String::from(
904 "SELECT cursor_seq FROM console_frames WHERE cursor_seq > ?1 AND cursor_seq < ?2",
905 );
906 if identity.is_some() {
907 sql.push_str(" AND identity = ?3");
908 }
909 if conversation_id.is_some() {
910 sql.push_str(" AND conversation_id = ?");
911 let next_param = 3 + usize::from(identity.is_some());
912 sql.push_str(&next_param.to_string());
913 }
914 sql.push_str(" ORDER BY cursor_seq DESC LIMIT 1");
915
916 let mut values = vec![
917 rusqlite::types::Value::Integer(after),
918 rusqlite::types::Value::Integer(before),
919 ];
920 if let Some(identity) = identity {
921 values.push(rusqlite::types::Value::Text(identity.to_string()));
922 }
923 if let Some(conversation_id) = conversation_id {
924 values.push(rusqlite::types::Value::Text(conversation_id.to_string()));
925 }
926 let seq: Option<i64> = conn
927 .query_row(&sql, rusqlite::params_from_iter(values), |row| row.get(0))
928 .optional()
929 .map_err(into_boxed)?;
930 Ok(seq.map(|value| ConsoleCursor::from_seq(value as u64)))
931}
932
933fn row_to_frame(row: &rusqlite::Row<'_>) -> rusqlite::Result<ConsoleFrame> {
934 let seq: i64 = row.get(0)?;
935 let payload_json: String = row.get(12)?;
936 let payload = serde_json::from_str(&payload_json).unwrap_or(serde_json::Value::Null);
937 let source_kind: String = row.get(13)?;
938 Ok(ConsoleFrame {
939 cursor: ConsoleCursor::from_seq(seq as u64),
940 id: row.get(1)?,
941 dedupe_key: row.get(2)?,
942 timestamp_ms: row.get::<_, i64>(3)? as u64,
943 runtime_key: row.get(4)?,
944 identity: row.get(5)?,
945 conversation_id: row.get(6)?,
946 session_id: row.get(7)?,
947 kind: row.get(8)?,
948 status: ConsoleFrameStatus::from_str(row.get::<_, String>(9)?.as_str()),
949 frame_version: row.get::<_, i64>(10)? as u64,
950 updated_at_ms: row.get::<_, Option<i64>>(11)?.map(|value| value as u64),
951 payload,
952 source: ConsoleFrameSource {
953 kind: ConsoleFrameSourceKind::from_str(&source_kind),
954 source_cursor: row.get(14)?,
955 },
956 source_event_id: row.get(15)?,
957 interaction_id: row.get(16)?,
958 parent_frame_id: row.get(17)?,
959 caused_by_frame_id: row.get(18)?,
960 turn_id: row.get(19)?,
961 run_id: row.get(20)?,
962 })
963}
964
965fn normalize_limit(limit: usize) -> usize {
966 limit.clamp(1, 1000)
967}
968
969fn cursor_seq(cursor: &ConsoleCursor) -> ConsoleLogResult<u64> {
970 cursor
971 .seq()
972 .ok_or_else(|| boxed_error(format!("invalid console cursor: {cursor}")))
973}
974
975fn cursor_seq_i64(cursor: &ConsoleCursor) -> ConsoleLogResult<i64> {
976 let seq = cursor_seq(cursor)?;
977 i64::try_from(seq).map_err(|_| boxed_error(format!("console cursor out of range: {cursor}")))
978}
979
980pub(crate) fn stable_frame_id(dedupe_key: &str) -> String {
981 let mut hasher = Sha256::new();
982 hasher.update(dedupe_key.as_bytes());
983 format!("console-frame-{}", to_hex(&hasher.finalize()))
984}
985
986fn to_hex(bytes: &[u8]) -> String {
987 const HEX: &[u8; 16] = b"0123456789abcdef";
988 let mut out = String::with_capacity(bytes.len() * 2);
989 for byte in bytes {
990 out.push(HEX[(byte >> 4) as usize] as char);
991 out.push(HEX[(byte & 0x0f) as usize] as char);
992 }
993 out
994}
995
996fn boxed_error(message: impl Into<String>) -> ConsoleLogError {
997 Box::new(std::io::Error::other(message.into()))
998}
999
1000fn into_boxed<E>(error: E) -> ConsoleLogError
1001where
1002 E: std::error::Error + Send + Sync + 'static,
1003{
1004 Box::new(error)
1005}
1006
1007fn current_time_ms() -> u64 {
1008 match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
1009 Ok(duration) => duration.as_millis() as u64,
1010 Err(_) => 0,
1011 }
1012}
1013
1014#[cfg(test)]
1015#[allow(clippy::expect_used)]
1016mod tests {
1017 use serde_json::json;
1018
1019 use super::*;
1020
1021 struct LegacyQueryOnlyStore;
1022
1023 #[async_trait::async_trait]
1024 impl ConsoleLogStore for LegacyQueryOnlyStore {
1025 async fn append_if_absent(
1026 &self,
1027 _frame: NewConsoleFrame,
1028 ) -> ConsoleLogResult<AppendOutcome> {
1029 Err(boxed_error("not implemented for test"))
1030 }
1031
1032 async fn update_frame_status(
1033 &self,
1034 _frame_id: &str,
1035 _status: ConsoleFrameStatus,
1036 ) -> ConsoleLogResult<Option<ConsoleFrame>> {
1037 Err(boxed_error("not implemented for test"))
1038 }
1039
1040 async fn query_frames(
1041 &self,
1042 _query: ConsoleTimelineQuery,
1043 ) -> ConsoleLogResult<ConsoleTimelinePage> {
1044 Ok(ConsoleTimelinePage {
1045 frames: Vec::new(),
1046 next_cursor: None,
1047 })
1048 }
1049
1050 async fn frame_by_dedupe_key(
1051 &self,
1052 _dedupe_key: &str,
1053 ) -> ConsoleLogResult<Option<ConsoleFrame>> {
1054 Err(boxed_error("not implemented for test"))
1055 }
1056
1057 async fn latest_cursor(&self) -> ConsoleLogResult<Option<ConsoleCursor>> {
1058 Ok(None)
1059 }
1060
1061 async fn clear_frames(&self) -> ConsoleLogResult<()> {
1062 Ok(())
1063 }
1064
1065 async fn record_source_watermark(
1066 &self,
1067 _runtime_key: &str,
1068 _source_kind: ConsoleFrameSourceKind,
1069 _source_cursor: &str,
1070 ) -> ConsoleLogResult<()> {
1071 Ok(())
1072 }
1073
1074 async fn source_watermark(
1075 &self,
1076 _runtime_key: &str,
1077 _source_kind: ConsoleFrameSourceKind,
1078 ) -> ConsoleLogResult<Option<String>> {
1079 Ok(None)
1080 }
1081 }
1082
1083 fn sample_frame(dedupe_key: &str, identity: &str) -> NewConsoleFrame {
1084 NewConsoleFrame {
1085 id: None,
1086 dedupe_key: dedupe_key.to_string(),
1087 timestamp_ms: 10,
1088 runtime_key: "runtime-a".to_string(),
1089 identity: identity.to_string(),
1090 conversation_id: Some(identity.to_string()),
1091 session_id: Some("session-1".to_string()),
1092 kind: "text_delta".to_string(),
1093 status: ConsoleFrameStatus::Delivered,
1094 payload: json!({ "delta": "hello" }),
1095 source: ConsoleFrameSource {
1096 kind: ConsoleFrameSourceKind::ConsoleEvent,
1097 source_cursor: None,
1098 },
1099 source_event_id: Some(dedupe_key.to_string()),
1100 interaction_id: None,
1101 turn_id: None,
1102 run_id: None,
1103 parent_frame_id: None,
1104 caused_by_frame_id: None,
1105 }
1106 }
1107
1108 #[tokio::test]
1113 async fn fresh_console_store_stamps_domain_gains_wal_and_round_trips() {
1114 let dir = tempfile::tempdir().expect("tempdir");
1115 let path = dir.path().join("mobkit_console.sqlite3");
1116 let store = SqliteConsoleLogStore::open(&path).expect("open");
1117
1118 let appended = store
1119 .append_if_absent(sample_frame("dedupe-1", "identity:a"))
1120 .await
1121 .expect("append");
1122 assert_eq!(appended.disposition, AppendDisposition::Inserted);
1123 let replay = store
1124 .append_if_absent(sample_frame("dedupe-1", "identity:a"))
1125 .await
1126 .expect("replay");
1127 assert_eq!(replay.disposition, AppendDisposition::Existing);
1128 assert_eq!(replay.frame.id, appended.frame.id);
1129
1130 store
1131 .record_source_watermark("runtime-a", ConsoleFrameSourceKind::ConsoleEvent, "42")
1132 .await
1133 .expect("watermark write");
1134 assert_eq!(
1135 store
1136 .source_watermark("runtime-a", ConsoleFrameSourceKind::ConsoleEvent)
1137 .await
1138 .expect("watermark read"),
1139 Some("42".to_string())
1140 );
1141
1142 let probe = Connection::open(&path).expect("probe");
1143 assert_eq!(
1144 meerkat_sqlite::domain_version(&probe, "mobkit-console").expect("ledger"),
1145 Some(1)
1146 );
1147 let journal: String = probe
1148 .pragma_query_value(None, "journal_mode", |row| row.get(0))
1149 .expect("journal_mode");
1150 assert_eq!(journal, "wal", "console store gains WAL for the first time");
1151 }
1152
1153 #[tokio::test]
1159 async fn legacy_console_file_is_refused_with_rows_preserved() {
1160 let dir = tempfile::tempdir().expect("tempdir");
1161 let path = dir.path().join("mobkit_console.sqlite3");
1162 {
1163 let conn = Connection::open(&path).expect("legacy create");
1164 conn.execute_batch(
1165 "CREATE TABLE console_frames (
1166 cursor_seq INTEGER PRIMARY KEY AUTOINCREMENT,
1167 id TEXT NOT NULL UNIQUE,
1168 dedupe_key TEXT NOT NULL UNIQUE,
1169 timestamp_ms INTEGER NOT NULL,
1170 runtime_key TEXT NOT NULL,
1171 identity TEXT NOT NULL,
1172 conversation_id TEXT,
1173 session_id TEXT,
1174 kind TEXT NOT NULL,
1175 status TEXT NOT NULL,
1176 frame_version INTEGER NOT NULL DEFAULT 1,
1177 updated_at_ms INTEGER,
1178 payload_json TEXT NOT NULL,
1179 source_kind TEXT NOT NULL,
1180 source_cursor TEXT,
1181 source_event_id TEXT,
1182 interaction_id TEXT,
1183 parent_frame_id TEXT,
1184 caused_by_frame_id TEXT,
1185 turn_id TEXT,
1186 run_id TEXT
1187 );
1188 CREATE TABLE console_source_watermarks (
1189 runtime_key TEXT NOT NULL,
1190 source_kind TEXT NOT NULL,
1191 source_cursor TEXT NOT NULL,
1192 last_ingested_at_ms INTEGER NOT NULL,
1193 PRIMARY KEY(runtime_key, source_kind)
1194 );
1195 INSERT INTO console_frames (id, dedupe_key, timestamp_ms, runtime_key, \
1196 identity, kind, status, payload_json, source_kind)
1197 VALUES ('frame-legacy', 'dedupe-legacy', 5, 'runtime-a', 'identity:a', \
1198 'text_delta', 'delivered', '{}', 'console_event');
1199 INSERT INTO console_source_watermarks (runtime_key, source_kind, \
1200 source_cursor, last_ingested_at_ms)
1201 VALUES ('runtime-a', 'console_event', '7', 5);",
1202 )
1203 .expect("legacy rows");
1204 }
1205
1206 assert!(
1207 SqliteConsoleLogStore::open(&path).is_err(),
1208 "opening a pre-ledger console database must refuse typed: unledgered owned \
1209 tables are below the mobkit 0.8.8 floor and must never be silently converged"
1210 );
1211
1212 let probe = Connection::open(&path).expect("probe");
1213 let (id, dedupe): (String, String) = probe
1214 .query_row(
1215 "SELECT id, dedupe_key FROM console_frames WHERE id = 'frame-legacy'",
1216 [],
1217 |row| Ok((row.get(0)?, row.get(1)?)),
1218 )
1219 .expect("legacy frame preserved");
1220 assert_eq!(
1221 (id.as_str(), dedupe.as_str()),
1222 ("frame-legacy", "dedupe-legacy")
1223 );
1224 let watermark: String = probe
1225 .query_row(
1226 "SELECT source_cursor FROM console_source_watermarks WHERE runtime_key = 'runtime-a'",
1227 [],
1228 |row| row.get(0),
1229 )
1230 .expect("legacy watermark preserved");
1231 assert_eq!(watermark, "7");
1232 assert_eq!(
1233 meerkat_sqlite::domain_version(&probe, "mobkit-console").expect("ledger"),
1234 None,
1235 "the refusal must not stamp a ledger onto a file it refused to own"
1236 );
1237 }
1238
1239 #[tokio::test]
1240 async fn legacy_store_default_rejects_v04_window_queries_loudly() {
1241 let store = LegacyQueryOnlyStore;
1242
1243 let err = store
1244 .query_windowed_frames(ConsoleTimelineWindowQuery {
1245 mode: ConsoleTimelineMode::Recent,
1246 limit: 10,
1247 ..ConsoleTimelineWindowQuery::default()
1248 })
1249 .await
1250 .expect_err("legacy stores must implement recent windows explicitly");
1251 assert!(
1252 err.to_string()
1253 .contains("must implement query_windowed_frames")
1254 );
1255
1256 let err = store
1257 .query_windowed_frames(ConsoleTimelineWindowQuery {
1258 mode: ConsoleTimelineMode::Since,
1259 before: Some(ConsoleCursor::from_seq(10)),
1260 limit: 10,
1261 ..ConsoleTimelineWindowQuery::default()
1262 })
1263 .await
1264 .expect_err("legacy stores must implement before windows explicitly");
1265 assert!(
1266 err.to_string()
1267 .contains("must implement query_windowed_frames")
1268 );
1269
1270 let page = store
1271 .query_windowed_frames(ConsoleTimelineWindowQuery {
1272 mode: ConsoleTimelineMode::Since,
1273 limit: 10,
1274 ..ConsoleTimelineWindowQuery::default()
1275 })
1276 .await
1277 .expect("legacy since-only fallback remains source-compatible");
1278 assert!(page.frames.is_empty());
1279 }
1280
1281 #[tokio::test]
1282 async fn in_memory_log_assigns_monotonic_cursors_and_dedupes() {
1283 let store = InMemoryConsoleLogStore::new();
1284 let first = store
1285 .append_if_absent(sample_frame("event-1", "agent-a"))
1286 .await
1287 .expect("append first");
1288 let duplicate = store
1289 .append_if_absent(sample_frame("event-1", "agent-a"))
1290 .await
1291 .expect("append duplicate");
1292 let second = store
1293 .append_if_absent(sample_frame("event-2", "agent-a"))
1294 .await
1295 .expect("append second");
1296
1297 assert_eq!(first.disposition, AppendDisposition::Inserted);
1298 assert_eq!(duplicate.disposition, AppendDisposition::Existing);
1299 assert_eq!(first.frame.cursor.seq(), Some(1));
1300 assert_eq!(second.frame.cursor.seq(), Some(2));
1301 }
1302
1303 #[tokio::test]
1304 async fn sqlite_log_queries_by_identity_and_cursor() {
1305 let store = SqliteConsoleLogStore::in_memory().expect("sqlite store");
1306 let first = store
1307 .append_if_absent(sample_frame("event-1", "agent-a"))
1308 .await
1309 .expect("append first");
1310 store
1311 .append_if_absent(sample_frame("event-2", "agent-b"))
1312 .await
1313 .expect("append second");
1314 store
1315 .append_if_absent(sample_frame("event-3", "agent-a"))
1316 .await
1317 .expect("append third");
1318
1319 let page = store
1320 .query_windowed_frames(ConsoleTimelineWindowQuery {
1321 identity: Some("agent-a".to_string()),
1322 after: Some(first.frame.cursor),
1323 limit: 10,
1324 ..ConsoleTimelineWindowQuery::default()
1325 })
1326 .await
1327 .expect("query");
1328 assert_eq!(page.frames.len(), 1);
1329 assert_eq!(page.frames[0].dedupe_key, "event-3");
1330 }
1331
1332 #[tokio::test]
1333 async fn in_memory_log_queries_recent_window_in_display_order() {
1334 let store = InMemoryConsoleLogStore::new();
1335 for index in 1..=6 {
1336 store
1337 .append_if_absent(sample_frame(&format!("event-{index}"), "agent-a"))
1338 .await
1339 .expect("append frame");
1340 }
1341
1342 let page = store
1343 .query_windowed_frames(ConsoleTimelineWindowQuery {
1344 identity: Some("agent-a".to_string()),
1345 mode: ConsoleTimelineMode::Recent,
1346 limit: 3,
1347 ..ConsoleTimelineWindowQuery::default()
1348 })
1349 .await
1350 .expect("query recent");
1351 assert_eq!(
1352 page.frames
1353 .iter()
1354 .map(|frame| frame.dedupe_key.as_str())
1355 .collect::<Vec<_>>(),
1356 vec!["event-4", "event-5", "event-6"]
1357 );
1358 assert_eq!(
1359 page.next_cursor.as_ref().and_then(ConsoleCursor::seq),
1360 Some(6)
1361 );
1362 assert_eq!(
1363 page.latest_cursor.as_ref().and_then(ConsoleCursor::seq),
1364 Some(6)
1365 );
1366 assert!(!page.exhausted);
1367
1368 let older = store
1369 .query_windowed_frames(ConsoleTimelineWindowQuery {
1370 identity: Some("agent-a".to_string()),
1371 mode: ConsoleTimelineMode::Recent,
1372 before: page.frames.first().map(|frame| frame.cursor.clone()),
1373 limit: 3,
1374 ..ConsoleTimelineWindowQuery::default()
1375 })
1376 .await
1377 .expect("query older");
1378 assert_eq!(
1379 older
1380 .frames
1381 .iter()
1382 .map(|frame| frame.dedupe_key.as_str())
1383 .collect::<Vec<_>>(),
1384 vec!["event-1", "event-2", "event-3"]
1385 );
1386 assert_eq!(
1387 older.latest_cursor.as_ref().and_then(ConsoleCursor::seq),
1388 Some(3)
1389 );
1390 }
1391
1392 #[tokio::test]
1393 async fn in_memory_log_queries_sparse_identity_recent_window_without_global_tail_scan() {
1394 let store = InMemoryConsoleLogStore::new();
1395 store
1396 .append_if_absent(sample_frame("sparse-event", "sparse-agent"))
1397 .await
1398 .expect("append sparse frame");
1399 for index in 1..=25_000 {
1400 store
1401 .append_if_absent(sample_frame(&format!("busy-event-{index}"), "busy-agent"))
1402 .await
1403 .expect("append busy frame");
1404 }
1405
1406 let page = store
1407 .query_windowed_frames(ConsoleTimelineWindowQuery {
1408 identity: Some("sparse-agent".to_string()),
1409 mode: ConsoleTimelineMode::Recent,
1410 limit: 10,
1411 ..ConsoleTimelineWindowQuery::default()
1412 })
1413 .await
1414 .expect("query sparse recent");
1415
1416 assert_eq!(page.frames.len(), 1);
1417 assert_eq!(page.frames[0].dedupe_key, "sparse-event");
1418 assert_eq!(
1419 page.latest_cursor.as_ref().and_then(ConsoleCursor::seq),
1420 Some(1)
1421 );
1422 }
1423
1424 #[tokio::test]
1425 async fn sqlite_log_queries_250k_sparse_identity_recent_window_with_index() {
1426 let store = SqliteConsoleLogStore::in_memory().expect("sqlite store");
1427 {
1428 let mut conn = store.conn.lock().expect("sqlite lock");
1429 let tx = conn.transaction().expect("begin transaction");
1430 {
1431 let mut insert = tx
1432 .prepare(
1433 "INSERT INTO console_frames (
1434 id, dedupe_key, timestamp_ms, runtime_key, identity,
1435 conversation_id, session_id, kind, status, frame_version, updated_at_ms,
1436 payload_json, source_kind, source_cursor, source_event_id,
1437 interaction_id, parent_frame_id, caused_by_frame_id, turn_id, run_id
1438 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1, NULL, ?10, ?11, NULL, ?12, NULL, NULL, NULL, NULL, NULL)",
1439 )
1440 .expect("prepare insert");
1441 insert
1442 .execute(rusqlite::params![
1443 "sparse-frame",
1444 "sparse-event",
1445 1_i64,
1446 "runtime-a",
1447 "sparse-agent",
1448 "sparse-agent",
1449 "session-sparse",
1450 "text_complete",
1451 ConsoleFrameStatus::Completed.as_str(),
1452 r#"{"text":"still visible"}"#,
1453 ConsoleFrameSourceKind::ConsoleEvent.as_str(),
1454 "sparse-event",
1455 ])
1456 .expect("insert sparse frame");
1457 for index in 2..=250_000_i64 {
1458 insert
1459 .execute(rusqlite::params![
1460 format!("busy-frame-{index}"),
1461 format!("busy-event-{index}"),
1462 index,
1463 "runtime-a",
1464 "busy-agent",
1465 "busy-agent",
1466 "session-busy",
1467 "text_delta",
1468 ConsoleFrameStatus::Completed.as_str(),
1469 format!(r#"{{"delta":{index}}}"#),
1470 ConsoleFrameSourceKind::ConsoleEvent.as_str(),
1471 format!("busy-event-{index}"),
1472 ])
1473 .expect("insert busy frame");
1474 }
1475 }
1476 let plan = tx
1477 .prepare(
1478 "EXPLAIN QUERY PLAN
1479 SELECT cursor_seq FROM console_frames
1480 WHERE cursor_seq > ?1 AND cursor_seq < ?2 AND identity = ?3
1481 ORDER BY cursor_seq DESC LIMIT ?4",
1482 )
1483 .expect("prepare query plan")
1484 .query_map(
1485 rusqlite::params![0_i64, i64::MAX, "sparse-agent", 11_i64],
1486 |row| row.get::<_, String>(3),
1487 )
1488 .expect("run query plan")
1489 .collect::<Result<Vec<_>, _>>()
1490 .expect("collect query plan")
1491 .join("\n")
1492 .to_lowercase();
1493 assert!(
1494 plan.contains("idx_console_frames_identity_cursor"),
1495 "sparse identity recent query should use identity/cursor index; plan was: {plan}"
1496 );
1497 tx.commit().expect("commit transaction");
1498 }
1499
1500 let page = store
1501 .query_windowed_frames(ConsoleTimelineWindowQuery {
1502 identity: Some("sparse-agent".to_string()),
1503 mode: ConsoleTimelineMode::Recent,
1504 limit: 10,
1505 ..ConsoleTimelineWindowQuery::default()
1506 })
1507 .await
1508 .expect("query sparse recent");
1509
1510 assert_eq!(page.frames.len(), 1);
1511 assert_eq!(page.frames[0].dedupe_key, "sparse-event");
1512 assert_eq!(
1513 page.latest_cursor.as_ref().and_then(ConsoleCursor::seq),
1514 Some(1)
1515 );
1516 }
1517
1518 #[tokio::test]
1519 async fn sqlite_log_queries_recent_window_with_before_cursor() {
1520 let store = SqliteConsoleLogStore::in_memory().expect("sqlite store");
1521 for index in 1..=6 {
1522 store
1523 .append_if_absent(sample_frame(&format!("event-{index}"), "agent-a"))
1524 .await
1525 .expect("append frame");
1526 }
1527
1528 let page = store
1529 .query_windowed_frames(ConsoleTimelineWindowQuery {
1530 identity: Some("agent-a".to_string()),
1531 mode: ConsoleTimelineMode::Recent,
1532 limit: 2,
1533 ..ConsoleTimelineWindowQuery::default()
1534 })
1535 .await
1536 .expect("query recent");
1537 assert_eq!(
1538 page.frames
1539 .iter()
1540 .map(|frame| frame.dedupe_key.as_str())
1541 .collect::<Vec<_>>(),
1542 vec!["event-5", "event-6"]
1543 );
1544
1545 let older = store
1546 .query_windowed_frames(ConsoleTimelineWindowQuery {
1547 identity: Some("agent-a".to_string()),
1548 mode: ConsoleTimelineMode::Recent,
1549 before: page.frames.first().map(|frame| frame.cursor.clone()),
1550 limit: 2,
1551 ..ConsoleTimelineWindowQuery::default()
1552 })
1553 .await
1554 .expect("query older");
1555 assert_eq!(
1556 older
1557 .frames
1558 .iter()
1559 .map(|frame| frame.dedupe_key.as_str())
1560 .collect::<Vec<_>>(),
1561 vec!["event-3", "event-4"]
1562 );
1563 assert_eq!(
1564 older.latest_cursor.as_ref().and_then(ConsoleCursor::seq),
1565 Some(4)
1566 );
1567 }
1568
1569 #[tokio::test]
1570 async fn sqlite_log_reports_exhausted_on_exact_size_recent_final_page() {
1571 let store = SqliteConsoleLogStore::in_memory().expect("sqlite store");
1572 for index in 1..=400 {
1573 store
1574 .append_if_absent(sample_frame(&format!("event-{index}"), "agent-a"))
1575 .await
1576 .expect("append frame");
1577 }
1578
1579 let first = store
1580 .query_windowed_frames(ConsoleTimelineWindowQuery {
1581 identity: Some("agent-a".to_string()),
1582 mode: ConsoleTimelineMode::Recent,
1583 limit: 200,
1584 ..ConsoleTimelineWindowQuery::default()
1585 })
1586 .await
1587 .expect("query recent");
1588 assert!(!first.exhausted);
1589 assert_eq!(first.frames[0].dedupe_key, "event-201");
1590
1591 let older = store
1592 .query_windowed_frames(ConsoleTimelineWindowQuery {
1593 identity: Some("agent-a".to_string()),
1594 mode: ConsoleTimelineMode::Recent,
1595 before: first.frames.first().map(|frame| frame.cursor.clone()),
1596 limit: 200,
1597 ..ConsoleTimelineWindowQuery::default()
1598 })
1599 .await
1600 .expect("query older");
1601 assert!(older.exhausted);
1602 assert_eq!(older.frames.len(), 200);
1603 assert_eq!(older.frames[0].dedupe_key, "event-1");
1604 }
1605
1606 #[tokio::test]
1607 async fn sqlite_log_rejects_out_of_range_console_cursors() {
1608 let store = SqliteConsoleLogStore::in_memory().expect("sqlite store");
1609 store
1610 .append_if_absent(sample_frame("event-1", "agent-a"))
1611 .await
1612 .expect("append frame");
1613
1614 let err = store
1615 .query_windowed_frames(ConsoleTimelineWindowQuery {
1616 after: Some(ConsoleCursor::from("console:9223372036854775808")),
1617 limit: 10,
1618 ..ConsoleTimelineWindowQuery::default()
1619 })
1620 .await
1621 .expect_err("oversized after cursor should be rejected");
1622 assert!(err.to_string().contains("out of range"));
1623
1624 let err = store
1625 .query_windowed_frames(ConsoleTimelineWindowQuery {
1626 before: Some(ConsoleCursor::from("console:9223372036854775808")),
1627 limit: 10,
1628 ..ConsoleTimelineWindowQuery::default()
1629 })
1630 .await
1631 .expect_err("oversized before cursor should be rejected");
1632 assert!(err.to_string().contains("out of range"));
1633 }
1634
1635 #[tokio::test]
1636 async fn sqlite_log_updates_status() {
1637 let store = SqliteConsoleLogStore::in_memory().expect("sqlite store");
1638 let first = store
1639 .append_if_absent(sample_frame("event-1", "agent-a"))
1640 .await
1641 .expect("append first");
1642 let updated = store
1643 .update_frame_status(&first.frame.id, ConsoleFrameStatus::DeliveryFailed)
1644 .await
1645 .expect("update")
1646 .expect("updated frame");
1647 assert_eq!(updated.status, ConsoleFrameStatus::DeliveryFailed);
1648 assert_eq!(updated.frame_version, 2);
1649 assert!(updated.updated_at_ms.is_some());
1650 }
1651
1652 #[tokio::test]
1653 async fn sqlite_log_records_source_watermarks() {
1654 let store = SqliteConsoleLogStore::in_memory().expect("sqlite store");
1655 store
1656 .record_source_watermark("runtime-a", ConsoleFrameSourceKind::ConsoleEvent, "evt-99")
1657 .await
1658 .expect("record watermark");
1659 let watermark = store
1660 .source_watermark("runtime-a", ConsoleFrameSourceKind::ConsoleEvent)
1661 .await
1662 .expect("read watermark");
1663 assert_eq!(watermark.as_deref(), Some("evt-99"));
1664 }
1665
1666 #[tokio::test]
1667 async fn sqlite_log_persists_frames_across_handles() {
1668 let temp_dir = tempfile::tempdir().expect("temp dir");
1669 let path = temp_dir.path().join("console.sqlite");
1670 let store = SqliteConsoleLogStore::open(&path).expect("open first handle");
1671 store
1672 .append_if_absent(sample_frame("event-1", "agent-a"))
1673 .await
1674 .expect("append frame");
1675 drop(store);
1676
1677 let reopened = SqliteConsoleLogStore::open(&path).expect("open second handle");
1678 let page = reopened
1679 .query_windowed_frames(ConsoleTimelineWindowQuery {
1680 identity: Some("agent-a".to_string()),
1681 limit: 10,
1682 ..ConsoleTimelineWindowQuery::default()
1683 })
1684 .await
1685 .expect("query frames");
1686 assert_eq!(page.frames.len(), 1);
1687 assert_eq!(page.frames[0].dedupe_key, "event-1");
1688 }
1689}