Skip to main content

meerkat_mobkit/console_aggregator/
store.rs

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    /// Database file path; `:memory:` for in-memory stores (where the
455    /// per-operation fence guard degrades to a no-op).
456    db_path: PathBuf,
457}
458
459/// The console aggregator's schema domain in the per-file migration
460/// ledger. Migration 0001 is the historical two-table + two-index DDL.
461const 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    /// Per-operation maintenance-fence guard: the connection is held for
558    /// the store's lifetime, so the fence cannot ride the open — every
559    /// database-touching operation takes its own shared guard.
560    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    /// M3: the console store — previously opened with zero PRAGMAs — now
1109    /// runs under the `Primary` profile (WAL + synchronous=FULL + shared
1110    /// busy timeout) and stamps the `mobkit-console` ledger domain, and the
1111    /// hot paths (append_if_absent, watermarks) still round-trip.
1112    #[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    /// A pre-ledger console database (historical DDL, no meerkat_schema) is
1154    /// refused typed at open with its rows left untouched and no ledger
1155    /// stamped: pre-ledger corpora are below the mobkit 0.8.8 floor, and the
1156    /// 0.8.11 reset retired silent pre-floor convergence (this test pinned
1157    /// that convergence until then).
1158    #[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}