Skip to main content

lean_ctx/core/context_os/
context_bus.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::{Arc, Mutex};
4
5use chrono::{DateTime, Utc};
6use rusqlite::{Connection, params};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use tokio::sync::broadcast;
10
11use super::observation::{
12    LocalObservationPayload, LocalObservationSource, LocalObservationV1,
13    ObservationPersistenceError, decode_row, lock_observation, validate_identity,
14};
15use crate::core::events::LeanCtxEvent;
16use crate::core::ocla_bus::{OclaBusRecord, OclaEvent};
17
18const MAX_READ_CONNS: usize = 4;
19
20fn encode_target_agents(target_agents: Option<&[String]>) -> Option<String> {
21    target_agents.map(|targets| serde_json::to_string(targets).unwrap_or_else(|_| "[]".to_string()))
22}
23
24fn decode_target_agents(raw: Option<String>) -> Option<Vec<String>> {
25    match raw {
26        None => None,
27        Some(value) => match serde_json::from_str::<Vec<String>>(&value) {
28            Ok(targets) => Some(targets),
29            Err(error) => {
30                // A malformed directed-list must never turn into a broadcast.
31                tracing::warn!("invalid directed-agent list in context event: {error}");
32                Some(Vec::new())
33            }
34        },
35    }
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "snake_case")]
40pub enum ContextEventKindV1 {
41    ToolCallRecorded,
42    SessionMutated,
43    KnowledgeRemembered,
44    ArtifactStored,
45    GraphBuilt,
46    ProofAdded,
47    ProvenanceCheckpoint,
48    ProvenanceEdit,
49    HealthChanged,
50}
51
52impl ContextEventKindV1 {
53    pub fn as_str(&self) -> &'static str {
54        match self {
55            Self::ToolCallRecorded => "tool_call_recorded",
56            Self::SessionMutated => "session_mutated",
57            Self::KnowledgeRemembered => "knowledge_remembered",
58            Self::ArtifactStored => "artifact_stored",
59            Self::GraphBuilt => "graph_built",
60            Self::ProofAdded => "proof_added",
61            Self::ProvenanceCheckpoint => "provenance_checkpoint",
62            Self::ProvenanceEdit => "provenance_edit",
63            Self::HealthChanged => "health_changed",
64        }
65    }
66
67    pub fn parse(s: &str) -> Self {
68        match s.trim().to_lowercase().as_str() {
69            "tool_call_recorded" => Self::ToolCallRecorded,
70            "session_mutated" => Self::SessionMutated,
71            "knowledge_remembered" => Self::KnowledgeRemembered,
72            "artifact_stored" => Self::ArtifactStored,
73            "graph_built" => Self::GraphBuilt,
74            "proof_added" => Self::ProofAdded,
75            "provenance_checkpoint" => Self::ProvenanceCheckpoint,
76            "provenance_edit" => Self::ProvenanceEdit,
77            "health_changed" => Self::HealthChanged,
78            other => {
79                tracing::warn!(
80                    "unknown ContextEventKind '{other}', defaulting to ToolCallRecorded"
81                );
82                Self::ToolCallRecorded
83            }
84        }
85    }
86
87    /// Classifies the consistency requirement for this event kind.
88    ///
89    /// - `Local`: Agent-local, never shared (tool reads, cache hits).
90    /// - `Eventual`: Broadcast via bus, other agents see it "soon" (knowledge, artifacts).
91    /// - `Strong`: Critical decisions that require acknowledgment before proceeding.
92    pub fn consistency_level(&self) -> ConsistencyLevel {
93        match self {
94            Self::ToolCallRecorded | Self::GraphBuilt => ConsistencyLevel::Local,
95            Self::KnowledgeRemembered
96            | Self::ArtifactStored
97            | Self::ProvenanceEdit
98            | Self::HealthChanged => ConsistencyLevel::Eventual,
99            Self::SessionMutated | Self::ProofAdded | Self::ProvenanceCheckpoint => {
100                ConsistencyLevel::Strong
101            }
102        }
103    }
104}
105
106/// Consistency requirement for shared context events.
107/// Ordered from least to most strict for filtering comparisons.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum ConsistencyLevel {
111    /// Agent-local, authoritative: session task, local cache, current file set.
112    Local = 0,
113    /// Shared, eventually consistent: knowledge facts, gotchas, artifact refs.
114    Eventual = 1,
115    /// Shared, strongly consistent: workspace config, critical decisions.
116    Strong = 2,
117}
118
119impl ConsistencyLevel {
120    pub fn as_str(&self) -> &'static str {
121        match self {
122            Self::Local => "local",
123            Self::Eventual => "eventual",
124            Self::Strong => "strong",
125        }
126    }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub struct ContextEventV1 {
132    pub id: i64,
133    pub workspace_id: String,
134    pub channel_id: String,
135    pub kind: String,
136    pub actor: Option<String>,
137    pub timestamp: DateTime<Utc>,
138    pub version: i64,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub parent_id: Option<i64>,
141    pub consistency_level: String,
142    pub payload: Value,
143    #[serde(skip_serializing_if = "Option::is_none", default)]
144    pub target_agents: Option<Vec<String>>,
145}
146
147impl ContextEventV1 {
148    pub fn consistency(&self) -> ConsistencyLevel {
149        ContextEventKindV1::parse(&self.kind).consistency_level()
150    }
151
152    pub fn is_visible_to_agent(&self, agent_id: &str) -> bool {
153        match &self.target_agents {
154            None => true,
155            Some(targets) => targets.iter().any(|t| t == agent_id),
156        }
157    }
158}
159
160/// Filter for selective event subscriptions.
161/// All fields are optional; `None` means "accept all". Directed events are
162/// included by default; callers without an agent identity can opt into the
163/// broadcast-only view with [`TopicFilter::broadcast_only`].
164#[derive(Debug, Clone)]
165pub struct TopicFilter {
166    pub kinds: Option<Vec<ContextEventKindV1>>,
167    pub actors: Option<Vec<String>>,
168    pub min_consistency: Option<ConsistencyLevel>,
169    pub agent_id: Option<String>,
170    pub include_directed: bool,
171}
172
173impl Default for TopicFilter {
174    fn default() -> Self {
175        Self {
176            kinds: None,
177            actors: None,
178            min_consistency: None,
179            agent_id: None,
180            include_directed: true,
181        }
182    }
183}
184
185impl TopicFilter {
186    /// Convenience constructor: filter by event kind strings.
187    pub fn kinds(kind_strs: &[&str]) -> Self {
188        Self {
189            kinds: Some(
190                kind_strs
191                    .iter()
192                    .map(|s| ContextEventKindV1::parse(s))
193                    .collect(),
194            ),
195            ..Self::default()
196        }
197    }
198
199    /// Filter that accepts broadcast events while excluding agent-directed
200    /// events for callers that have no authenticated agent identity.
201    pub fn broadcast_only() -> Self {
202        Self {
203            include_directed: false,
204            ..Self::default()
205        }
206    }
207
208    pub fn matches(&self, event: &ContextEventV1) -> bool {
209        if !self.include_directed && event.target_agents.is_some() {
210            return false;
211        }
212        if let Some(ref kinds) = self.kinds {
213            let parsed = ContextEventKindV1::parse(&event.kind);
214            if !kinds.contains(&parsed) {
215                return false;
216            }
217        }
218        if let Some(ref actors) = self.actors {
219            match &event.actor {
220                Some(actor) if actors.iter().any(|a| a == actor) => {}
221                Some(_) | None => return false,
222            }
223        }
224        if let Some(min) = self.min_consistency
225            && event.consistency() < min
226        {
227            return false;
228        }
229        if let Some(ref aid) = self.agent_id
230            && !event.is_visible_to_agent(aid)
231        {
232            return false;
233        }
234        true
235    }
236}
237
238fn event_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ContextEventV1> {
239    let ts_str: String = row.get(5)?;
240    let ts = DateTime::parse_from_rfc3339(&ts_str)
241        .map_or_else(|_| Utc::now(), |d| d.with_timezone(&Utc));
242    let payload_str: String = row.get(6)?;
243    let payload: Value = serde_json::from_str(&payload_str).unwrap_or(Value::Null);
244    let kind_str: String = row.get(3)?;
245    let target_agents_json: Option<String> = row.get(9)?;
246    let cl = ContextEventKindV1::parse(&kind_str)
247        .consistency_level()
248        .as_str()
249        .to_string();
250    Ok(ContextEventV1 {
251        id: row.get(0)?,
252        workspace_id: row.get(1)?,
253        channel_id: row.get(2)?,
254        kind: kind_str,
255        actor: row.get::<_, Option<String>>(4)?,
256        timestamp: ts,
257        version: row.get::<_, i64>(7).unwrap_or(0),
258        parent_id: row.get::<_, Option<i64>>(8).ok().flatten(),
259        consistency_level: cl,
260        payload,
261        target_agents: decode_target_agents(target_agents_json),
262    })
263}
264
265#[derive(Clone)]
266pub struct ContextBus {
267    inner: Arc<Inner>,
268}
269
270/// A legacy event that passed storage admission, with the exact payload it is
271/// stored as. Built by [`ContextBus::admit_legacy_observation`].
272#[derive(Clone, Debug)]
273pub(crate) struct AdmittedLegacyObservation {
274    pub(crate) event: LeanCtxEvent,
275    pub(crate) payload_json: String,
276}
277
278const STREAM_CHANNEL_SIZE: usize = 256;
279const MAX_SUBSCRIBERS_PER_CHANNEL: usize = 64;
280/// Bound the write-side version cache. Each entry is re-derivable from the DB via
281/// `MAX(version)`, so when the map exceeds this (e.g. a client cycling workspace/channel
282/// ids) it is simply cleared — costing at most one extra `MAX()` query per active channel.
283const MAX_VERSION_CACHE_ENTRIES: usize = 4096;
284
285struct Inner {
286    write_conn: Mutex<Connection>,
287    read_pool: Mutex<Vec<Connection>>,
288    streams: Mutex<HashMap<String, broadcast::Sender<ContextEventV1>>>,
289    version_cache: Mutex<HashMap<String, i64>>,
290    db_path: PathBuf,
291}
292
293impl Inner {
294    fn open_read_conn(path: &PathBuf) -> Connection {
295        Self::try_open_read_conn(path).expect("open read context-os db")
296    }
297
298    fn try_open_read_conn(path: &PathBuf) -> Result<Connection, ObservationPersistenceError> {
299        let conn = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
300        conn.busy_timeout(std::time::Duration::from_secs(5))?;
301        conn.execute_batch("PRAGMA query_only=ON;")?;
302        Ok(conn)
303    }
304
305    fn try_take_read_conn(&self) -> Result<Connection, ObservationPersistenceError> {
306        let cached = lock_observation(&self.read_pool)?.pop();
307        match cached {
308            Some(conn) => Ok(conn),
309            None => Self::try_open_read_conn(&self.db_path),
310        }
311    }
312
313    fn take_read_conn(&self) -> Connection {
314        self.read_pool
315            .lock()
316            .unwrap_or_else(std::sync::PoisonError::into_inner)
317            .pop()
318            .unwrap_or_else(|| Self::open_read_conn(&self.db_path))
319    }
320
321    fn return_read_conn(&self, conn: Connection) {
322        let mut pool = self
323            .read_pool
324            .lock()
325            .unwrap_or_else(std::sync::PoisonError::into_inner);
326        if pool.len() < MAX_READ_CONNS {
327            pool.push(conn);
328        }
329    }
330
331    fn stream_key(workspace_id: &str, channel_id: &str) -> String {
332        format!("{workspace_id}\0{channel_id}")
333    }
334
335    fn next_version(&self, workspace_id: &str, channel_id: &str) -> i64 {
336        let key = Self::stream_key(workspace_id, channel_id);
337
338        {
339            let mut cache = self
340                .version_cache
341                .lock()
342                .unwrap_or_else(std::sync::PoisonError::into_inner);
343            if let Some(v) = cache.get_mut(&key) {
344                *v += 1;
345                return *v;
346            }
347        }
348
349        let conn = self.take_read_conn();
350        let v: i64 = conn
351            .query_row(
352                "SELECT COALESCE(MAX(version), 0) FROM context_events WHERE workspace_id = ?1 AND channel_id = ?2",
353                params![workspace_id, channel_id],
354                |row| row.get(0),
355            )
356            .unwrap_or(0);
357        self.return_read_conn(conn);
358
359        let mut cache = self
360            .version_cache
361            .lock()
362            .unwrap_or_else(std::sync::PoisonError::into_inner);
363        if cache.len() > MAX_VERSION_CACHE_ENTRIES {
364            // Re-derivable from the DB; drop the whole cache rather than grow unbounded.
365            cache.clear();
366        }
367        let entry = cache.entry(key).or_insert(v);
368        *entry = (*entry).max(v) + 1;
369        *entry
370    }
371}
372
373impl Default for ContextBus {
374    fn default() -> Self {
375        Self::new()
376    }
377}
378
379impl ContextBus {
380    pub fn new() -> Self {
381        let path = default_db_path();
382        Self::open_at(path)
383    }
384
385    /// Open a ContextBus backed by the given SQLite path. Useful for tests
386    /// that need an isolated database to avoid contention on Windows CI.
387    pub fn open_at(path: PathBuf) -> Self {
388        Self::try_open_at(path).expect("open context-os db")
389    }
390
391    /// Open without panicking or silently substituting a different data directory.
392    pub fn try_open_at(path: PathBuf) -> Result<Self, ObservationPersistenceError> {
393        if let Some(parent) = path.parent() {
394            std::fs::create_dir_all(parent)?;
395        }
396        let conn = Connection::open(&path)?;
397        conn.busy_timeout(std::time::Duration::from_secs(5))?;
398        conn.execute_batch(
399            "PRAGMA journal_mode=WAL;
400             CREATE TABLE IF NOT EXISTS context_events (
401               id INTEGER PRIMARY KEY AUTOINCREMENT,
402               workspace_id TEXT NOT NULL,
403               channel_id TEXT NOT NULL,
404               kind TEXT NOT NULL,
405               actor TEXT,
406               timestamp TEXT NOT NULL,
407               payload_json TEXT NOT NULL,
408               version INTEGER NOT NULL DEFAULT 0,
409               parent_id INTEGER,
410               delivery_id TEXT,
411               target_agents_json TEXT
412             );
413             CREATE INDEX IF NOT EXISTS idx_context_events_stream
414               ON context_events(workspace_id, channel_id, id);
415             CREATE TABLE IF NOT EXISTS context_local_observations (
416               id INTEGER PRIMARY KEY AUTOINCREMENT,
417               schema_version INTEGER NOT NULL CHECK(schema_version = 1),
418               source_kind TEXT NOT NULL,
419               producer_instance TEXT NOT NULL,
420               source_id TEXT NOT NULL,
421               source_timestamp TEXT NOT NULL,
422               payload_json TEXT NOT NULL,
423               UNIQUE(source_kind, producer_instance, source_id)
424             );
425             CREATE INDEX IF NOT EXISTS idx_context_local_observations_source
426               ON context_local_observations(source_kind, id);",
427        )?;
428
429        let _ = conn.execute_batch(
430            "ALTER TABLE context_events ADD COLUMN version INTEGER NOT NULL DEFAULT 0;",
431        );
432        let _ = conn.execute_batch("ALTER TABLE context_events ADD COLUMN parent_id INTEGER;");
433        let _ = conn.execute_batch("ALTER TABLE context_events ADD COLUMN delivery_id TEXT;");
434        let _ =
435            conn.execute_batch("ALTER TABLE context_events ADD COLUMN target_agents_json TEXT;");
436        conn.execute_batch(
437            "CREATE UNIQUE INDEX IF NOT EXISTS idx_context_events_delivery
438               ON context_events(workspace_id, channel_id, delivery_id)
439               WHERE delivery_id IS NOT NULL;",
440        )?;
441
442        let _ = conn.execute_batch(
443            "CREATE VIRTUAL TABLE IF NOT EXISTS context_events_fts USING fts5(
444               payload_text,
445               content=context_events,
446               content_rowid=id
447             );",
448        );
449
450        let mut read_conns = Vec::with_capacity(MAX_READ_CONNS);
451        for _ in 0..MAX_READ_CONNS {
452            read_conns.push(Inner::try_open_read_conn(&path)?);
453        }
454
455        Ok(Self {
456            inner: Arc::new(Inner {
457                write_conn: Mutex::new(conn),
458                read_pool: Mutex::new(read_conns),
459                streams: Mutex::new(HashMap::new()),
460                version_cache: Mutex::new(HashMap::new()),
461                db_path: path,
462            }),
463        })
464    }
465
466    #[cfg(test)]
467    pub(crate) fn db_path(&self) -> &std::path::Path {
468        &self.inner.db_path
469    }
470
471    pub fn subscribe(
472        &self,
473        workspace_id: &str,
474        channel_id: &str,
475    ) -> Option<broadcast::Receiver<ContextEventV1>> {
476        let key = Inner::stream_key(workspace_id, channel_id);
477        let mut streams = self
478            .inner
479            .streams
480            .lock()
481            .unwrap_or_else(std::sync::PoisonError::into_inner);
482        // Reap senders left behind by departed clients (receiver_count() == 0). A removed
483        // key is transparently recreated below; the DB is the durability source so this
484        // loses no deliverable events. Bounds `streams` to ~live connection count even
485        // under client-cycled workspace/channel ids.
486        streams.retain(|_, tx| tx.receiver_count() > 0);
487        let tx = streams
488            .entry(key)
489            .or_insert_with(|| broadcast::channel(STREAM_CHANNEL_SIZE).0);
490        if tx.receiver_count() >= MAX_SUBSCRIBERS_PER_CHANNEL {
491            tracing::warn!(
492                "SSE subscriber cap ({MAX_SUBSCRIBERS_PER_CHANNEL}) reached for {workspace_id}/{channel_id} — rejecting"
493            );
494            return None;
495        }
496        Some(tx.subscribe())
497    }
498
499    /// Subscribe with a filter — only events matching the filter are delivered.
500    /// Returns `(Receiver, TopicFilter)` for use in filtered receive loops.
501    pub fn subscribe_filtered(
502        &self,
503        workspace_id: &str,
504        channel_id: &str,
505        filter: TopicFilter,
506    ) -> Option<FilteredSubscription> {
507        let rx = self.subscribe(workspace_id, channel_id)?;
508        Some(FilteredSubscription { rx, filter })
509    }
510
511    pub fn append(
512        &self,
513        workspace_id: &str,
514        channel_id: &str,
515        kind: &ContextEventKindV1,
516        actor: Option<&str>,
517        payload: Value,
518    ) -> Option<ContextEventV1> {
519        self.append_with_parent(workspace_id, channel_id, kind, actor, payload, None)
520    }
521
522    /// Persist an OCLA record in the ContextBus-owned private observation table.
523    /// The table is deliberately outside `context_events`, so normal replay,
524    /// search, lineage, and subscriptions cannot expose local observations.
525    pub fn append_ocla_observation(
526        &self,
527        producer_instance: &str,
528        record: &OclaBusRecord,
529    ) -> Result<i64, ObservationPersistenceError> {
530        let payload_json = serde_json::to_string(&record.event)?;
531        // JSON maps non-finite numbers to null; require a readable typed payload.
532        let _: OclaEvent = serde_json::from_str(&payload_json)?;
533        super::observation::admit_payload(&payload_json)?;
534        self.insert_local_observation(
535            LocalObservationSource::Ocla,
536            producer_instance,
537            record.id,
538            &record.timestamp_ms.to_string(),
539            &payload_json,
540        )
541    }
542
543    /// Persist a legacy event as a compatibility observation without appending
544    /// to the immutable historical JSONL journal.
545    pub fn append_legacy_observation(
546        &self,
547        producer_instance: &str,
548        event: &LeanCtxEvent,
549    ) -> Result<i64, ObservationPersistenceError> {
550        let payload = Self::admit_legacy_observation(event)?;
551        self.append_admitted_legacy_observation(producer_instance, &payload)
552    }
553
554    /// The storage admission half of [`Self::append_legacy_observation`]: the
555    /// payload a legacy event is stored as, once admission keeps it verbatim.
556    /// Runs on the producer's thread, under the producer's policy view, so a
557    /// detached writer (`events::emit_compatibility` in a serving process)
558    /// persists exactly what the producer admitted.
559    pub(crate) fn admit_legacy_observation(
560        event: &LeanCtxEvent,
561    ) -> Result<AdmittedLegacyObservation, ObservationPersistenceError> {
562        let payload_json = serde_json::to_string(event)?;
563        let _: LeanCtxEvent = serde_json::from_str(&payload_json)?;
564        super::observation::admit_payload(&payload_json)?;
565        Ok(AdmittedLegacyObservation {
566            event: event.clone(),
567            payload_json,
568        })
569    }
570
571    /// The write half of [`Self::append_legacy_observation`] for a payload
572    /// [`Self::admit_legacy_observation`] already admitted.
573    pub(crate) fn append_admitted_legacy_observation(
574        &self,
575        producer_instance: &str,
576        admitted: &AdmittedLegacyObservation,
577    ) -> Result<i64, ObservationPersistenceError> {
578        self.insert_local_observation(
579            LocalObservationSource::Legacy,
580            producer_instance,
581            admitted.event.id,
582            &admitted.event.timestamp,
583            &admitted.payload_json,
584        )
585    }
586
587    /// Holds the observation writer as a long transaction would, so a test can
588    /// show that a producer does not wait for it.
589    #[cfg(test)]
590    pub(crate) fn hold_observation_writer(&self) -> std::sync::MutexGuard<'_, Connection> {
591        self.inner
592            .write_conn
593            .lock()
594            .unwrap_or_else(std::sync::PoisonError::into_inner)
595    }
596
597    fn insert_local_observation(
598        &self,
599        source: LocalObservationSource,
600        producer_instance: &str,
601        source_id: u64,
602        source_timestamp: &str,
603        payload_json: &str,
604    ) -> Result<i64, ObservationPersistenceError> {
605        use rusqlite::OptionalExtension;
606        validate_identity(producer_instance, source_id)?;
607        let mut conn = lock_observation(&self.inner.write_conn)?;
608        let transaction =
609            conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
610        let existing: Option<(i64, String, String)> = transaction
611            .query_row(
612                "SELECT id, source_timestamp, payload_json FROM context_local_observations
613             WHERE source_kind = ?1 AND producer_instance = ?2 AND source_id = ?3",
614                params![source.as_str(), producer_instance, source_id.to_string()],
615                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
616            )
617            .optional()?;
618        if let Some((cursor, timestamp, payload)) = existing {
619            if timestamp != source_timestamp || payload != payload_json {
620                return Err(ObservationPersistenceError::IdentityConflict);
621            }
622            transaction.commit()?;
623            return Ok(cursor);
624        }
625        transaction.execute(
626            "INSERT INTO context_local_observations
627             (schema_version, source_kind, producer_instance, source_id, source_timestamp, payload_json)
628             VALUES (1, ?1, ?2, ?3, ?4, ?5)",
629            params![
630                source.as_str(),
631                producer_instance,
632                source_id.to_string(),
633                source_timestamp,
634                payload_json
635            ],
636        )?;
637        let cursor_id = transaction.last_insert_rowid();
638        transaction.commit()?;
639        Ok(cursor_id)
640    }
641
642    /// Read private observations for regression and compatibility readers.
643    pub fn read_local_observations(
644        &self,
645        limit: usize,
646    ) -> Result<Vec<LocalObservationV1>, ObservationPersistenceError> {
647        let limit = limit.clamp(1, 10_000) as i64;
648        let conn = self.inner.try_take_read_conn()?;
649        let result = (|| {
650            let mut stmt = conn.prepare(
651                "SELECT id, source_kind, source_id, source_timestamp, payload_json,
652                        producer_instance, schema_version
653                 FROM context_local_observations ORDER BY id ASC LIMIT ?1",
654            )?;
655            let mut rows = stmt.query(params![limit])?;
656            let mut observations = Vec::new();
657            while let Some(row) = rows.next()? {
658                if let Some(observation) = decode_row(row)? {
659                    observations.push(observation);
660                }
661            }
662            Ok(observations)
663        })();
664        self.return_observation_read_conn(conn)?;
665        result
666    }
667
668    pub fn read_legacy_observations(
669        &self,
670        limit: usize,
671    ) -> Result<Vec<LeanCtxEvent>, ObservationPersistenceError> {
672        let limit = limit.clamp(1, 10_000) as i64;
673        let conn = self.inner.try_take_read_conn()?;
674        let result = (|| {
675            let mut stmt = conn.prepare(
676                "SELECT id, source_kind, source_id, source_timestamp, payload_json,
677                            producer_instance, schema_version FROM context_local_observations
678                 WHERE source_kind = 'legacy' ORDER BY id DESC LIMIT ?1",
679            )?;
680            let mut rows = stmt.query(params![limit])?;
681            let mut events = Vec::new();
682            while let Some(row) = rows.next()? {
683                let Some(observation) = decode_row(row)? else {
684                    continue;
685                };
686                let LocalObservationPayload::Legacy(event) = observation.payload else {
687                    return Err(ObservationPersistenceError::InvalidRow(
688                        "non-legacy observation in legacy projection".into(),
689                    ));
690                };
691                events.push(event);
692            }
693            events.reverse();
694            Ok(events)
695        })();
696        self.return_observation_read_conn(conn)?;
697        result
698    }
699
700    fn return_observation_read_conn(
701        &self,
702        conn: Connection,
703    ) -> Result<(), ObservationPersistenceError> {
704        let mut pool = lock_observation(&self.inner.read_pool)?;
705        if pool.len() < MAX_READ_CONNS {
706            pool.push(conn);
707        }
708        Ok(())
709    }
710
711    pub fn append_with_parent(
712        &self,
713        workspace_id: &str,
714        channel_id: &str,
715        kind: &ContextEventKindV1,
716        actor: Option<&str>,
717        payload: Value,
718        parent_id: Option<i64>,
719    ) -> Option<ContextEventV1> {
720        let ev = self.insert_event(
721            workspace_id,
722            channel_id,
723            kind,
724            actor,
725            payload,
726            parent_id,
727            None,
728        )?;
729        self.broadcast_event(&ev);
730        Some(ev)
731    }
732
733    /// Append an event directed at specific agents only.
734    /// Only subscribers whose `TopicFilter.agent_id` matches a target will see it.
735    pub fn append_directed(
736        &self,
737        workspace_id: &str,
738        channel_id: &str,
739        kind: &ContextEventKindV1,
740        actor: Option<&str>,
741        payload: Value,
742        target_agents: Vec<String>,
743    ) -> Option<ContextEventV1> {
744        let ev = self.insert_event(
745            workspace_id,
746            channel_id,
747            kind,
748            actor,
749            payload,
750            None,
751            Some(target_agents),
752        )?;
753        self.broadcast_event(&ev);
754        Some(ev)
755    }
756
757    fn insert_event(
758        &self,
759        workspace_id: &str,
760        channel_id: &str,
761        kind: &ContextEventKindV1,
762        actor: Option<&str>,
763        payload: Value,
764        parent_id: Option<i64>,
765        target_agents: Option<Vec<String>>,
766    ) -> Option<ContextEventV1> {
767        let ts = Utc::now();
768        let payload_json = payload.to_string();
769        let target_agents_json = encode_target_agents(target_agents.as_deref());
770
771        let (id, version) = {
772            let Ok(conn) = self.inner.write_conn.lock() else {
773                return None;
774            };
775            let version = self.inner.next_version(workspace_id, channel_id);
776
777            let result: Result<(i64, i64), rusqlite::Error> = conn
778                .execute_batch("BEGIN IMMEDIATE")
779                .and_then(|()| {
780                    conn.execute(
781                        "INSERT INTO context_events (workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id, target_agents_json)
782                         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
783                        params![
784                            workspace_id,
785                            channel_id,
786                            kind.as_str(),
787                            actor.map(str::to_string),
788                            ts.to_rfc3339(),
789                            payload_json,
790                            version,
791                            parent_id,
792                            target_agents_json,
793                        ],
794                    )?;
795                    let rowid = conn.last_insert_rowid();
796                    if let Err(e) = conn.execute(
797                        "INSERT INTO context_events_fts(rowid, payload_text) VALUES (?1, ?2)",
798                        params![rowid, payload_json],
799                    ) {
800                        tracing::warn!("FTS insert failed for event {rowid}: {e}");
801                    }
802                    conn.execute_batch("COMMIT")?;
803                    Ok((rowid, version))
804                });
805
806            match result {
807                Ok(pair) => pair,
808                Err(e) => {
809                    tracing::warn!("context bus append failed: {e}");
810                    let _ = conn.execute_batch("ROLLBACK");
811                    return None;
812                }
813            }
814        };
815
816        Some(ContextEventV1 {
817            id,
818            workspace_id: workspace_id.to_string(),
819            channel_id: channel_id.to_string(),
820            consistency_level: kind.consistency_level().as_str().to_string(),
821            kind: kind.as_str().to_string(),
822            actor: actor.map(str::to_string),
823            timestamp: ts,
824            version,
825            parent_id,
826            payload,
827            target_agents,
828        })
829    }
830
831    fn broadcast_event(&self, ev: &ContextEventV1) {
832        let key = Inner::stream_key(&ev.workspace_id, &ev.channel_id);
833        let tx = self
834            .inner
835            .streams
836            .lock()
837            .unwrap_or_else(std::sync::PoisonError::into_inner)
838            .get(&key)
839            .cloned();
840        if let Some(tx) = tx {
841            let _ = tx.send(ev.clone());
842        }
843    }
844
845    pub fn read(
846        &self,
847        workspace_id: &str,
848        channel_id: &str,
849        since: i64,
850        limit: usize,
851    ) -> Vec<ContextEventV1> {
852        let limit = limit.clamp(1, 1000) as i64;
853        let conn = self.inner.take_read_conn();
854        let result = (|| {
855            let mut stmt = conn.prepare(
856                "SELECT id, workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id, target_agents_json
857                 FROM context_events
858                  WHERE workspace_id = ?1 AND channel_id = ?2 AND id > ?3
859                 ORDER BY id ASC
860                 LIMIT ?4",
861            ).ok()?;
862            let rows = stmt
863                .query_map(
864                    params![workspace_id, channel_id, since, limit],
865                    event_from_row,
866                )
867                .ok()?;
868            Some(rows.flatten().collect::<Vec<_>>())
869        })();
870        self.inner.return_read_conn(conn);
871        result.unwrap_or_default()
872    }
873
874    /// Atomically append at most one event for an authenticated delivery ID.
875    ///
876    /// `target_agents` scopes the event like [`Self::append_directed`]: when
877    /// set, only subscribers and readers identifying as one of the targets see
878    /// it, live and on replay.
879    pub fn append_delivery_once(
880        &self,
881        workspace_id: &str,
882        channel_id: &str,
883        kind: &ContextEventKindV1,
884        actor: Option<&str>,
885        payload: Value,
886        delivery_id: &str,
887        target_agents: Option<Vec<String>>,
888    ) -> Result<bool, String> {
889        let ts = Utc::now();
890        let payload_json = payload.to_string();
891        let target_agents_json = encode_target_agents(target_agents.as_deref());
892        let (id, version) = {
893            let conn = self
894                .inner
895                .write_conn
896                .lock()
897                .map_err(|_| "context bus write lock poisoned".to_string())?;
898            conn.execute_batch("BEGIN IMMEDIATE")
899                .map_err(|error| error.to_string())?;
900            let version = self.inner.next_version(workspace_id, channel_id);
901            let inserted = conn
902                .execute(
903                    "INSERT OR IGNORE INTO context_events (workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id, delivery_id, target_agents_json)
904                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?9)",
905                    params![workspace_id, channel_id, kind.as_str(), actor, ts.to_rfc3339(), payload_json, version, delivery_id, target_agents_json],
906                )
907                .map_err(|error| error.to_string());
908            let inserted = match inserted {
909                Ok(value) => value,
910                Err(error) => {
911                    let _ = conn.execute_batch("ROLLBACK");
912                    return Err(error);
913                }
914            };
915            if inserted == 0 {
916                conn.execute_batch("COMMIT")
917                    .map_err(|error| error.to_string())?;
918                return Ok(false);
919            }
920            let rowid = conn.last_insert_rowid();
921            if let Err(error) = conn.execute(
922                "INSERT INTO context_events_fts(rowid, payload_text) VALUES (?1, ?2)",
923                params![rowid, payload_json],
924            ) {
925                tracing::warn!("FTS insert failed for event {rowid}: {error}");
926            }
927            conn.execute_batch("COMMIT")
928                .map_err(|error| error.to_string())?;
929            (rowid, version)
930        };
931        self.broadcast_event(&ContextEventV1 {
932            id,
933            workspace_id: workspace_id.to_string(),
934            channel_id: channel_id.to_string(),
935            consistency_level: kind.consistency_level().as_str().to_string(),
936            kind: kind.as_str().to_string(),
937            actor: actor.map(str::to_string),
938            timestamp: ts,
939            version,
940            parent_id: None,
941            payload,
942            target_agents,
943        });
944        Ok(true)
945    }
946
947    /// Query recent events of a specific kind (for conflict detection).
948    pub fn recent_by_kind(
949        &self,
950        workspace_id: &str,
951        channel_id: &str,
952        kind: &str,
953        limit: usize,
954    ) -> Vec<ContextEventV1> {
955        let limit = limit.clamp(1, 100) as i64;
956        let conn = self.inner.take_read_conn();
957        let result = (|| {
958            let mut stmt = conn.prepare(
959                 "SELECT id, workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id, target_agents_json
960                  FROM context_events
961                 WHERE workspace_id = ?1 AND channel_id = ?2 AND kind = ?3
962                 ORDER BY id DESC
963                 LIMIT ?4",
964            ).ok()?;
965            let rows = stmt
966                .query_map(
967                    params![workspace_id, channel_id, kind, limit],
968                    event_from_row,
969                )
970                .ok()?;
971            Some(rows.flatten().collect::<Vec<_>>())
972        })();
973        self.inner.return_read_conn(conn);
974        result.unwrap_or_default()
975    }
976
977    /// Full-text search over event payloads via FTS5.
978    pub fn search(
979        &self,
980        workspace_id: &str,
981        channel_id: Option<&str>,
982        query: &str,
983        limit: usize,
984    ) -> Vec<ContextEventV1> {
985        let limit = limit.clamp(1, 100) as i64;
986        let conn = self.inner.take_read_conn();
987        let result =
988            if let Some(ch) = channel_id {
989                (|| {
990                    let mut stmt = conn.prepare(
991                    "SELECT e.id, e.workspace_id, e.channel_id, e.kind, e.actor, e.timestamp,
992                            e.payload_json, e.version, e.parent_id, e.target_agents_json
993                     FROM context_events e
994                     JOIN context_events_fts f ON e.id = f.rowid
995                     WHERE f.payload_text MATCH ?1 AND e.workspace_id = ?2 AND e.channel_id = ?3
996                     ORDER BY f.rank
997                     LIMIT ?4",
998                ).ok()?;
999                    let rows = stmt
1000                        .query_map(params![query, workspace_id, ch, limit], event_from_row)
1001                        .ok()?;
1002                    Some(rows.flatten().collect::<Vec<_>>())
1003                })()
1004            } else {
1005                (|| {
1006                    let mut stmt = conn.prepare(
1007                    "SELECT e.id, e.workspace_id, e.channel_id, e.kind, e.actor, e.timestamp,
1008                            e.payload_json, e.version, e.parent_id, e.target_agents_json
1009                     FROM context_events e
1010                     JOIN context_events_fts f ON e.id = f.rowid
1011                     WHERE f.payload_text MATCH ?1 AND e.workspace_id = ?2
1012                     ORDER BY f.rank
1013                     LIMIT ?3",
1014                ).ok()?;
1015                    let rows = stmt
1016                        .query_map(params![query, workspace_id, limit], event_from_row)
1017                        .ok()?;
1018                    Some(rows.flatten().collect::<Vec<_>>())
1019                })()
1020            };
1021        self.inner.return_read_conn(conn);
1022        result.unwrap_or_default()
1023    }
1024
1025    /// Trace the causal lineage of an event by following parent_id chains.
1026    /// Only returns events belonging to the given workspace (tenant isolation).
1027    pub fn lineage(
1028        &self,
1029        event_id: i64,
1030        workspace_id: &str,
1031        max_depth: usize,
1032    ) -> Vec<ContextEventV1> {
1033        let max_depth = max_depth.clamp(1, 50);
1034        let conn = self.inner.take_read_conn();
1035        let mut chain = Vec::new();
1036        let mut current_id = Some(event_id);
1037
1038        for _ in 0..max_depth {
1039            let Some(id) = current_id else {
1040                break;
1041            };
1042            let ev = conn.query_row(
1043                "SELECT id, workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id, target_agents_json
1044                 FROM context_events WHERE id = ?1 AND workspace_id = ?2",
1045                params![id, workspace_id],
1046                event_from_row,
1047            );
1048            match ev {
1049                Ok(ev) => {
1050                    current_id = ev.parent_id;
1051                    chain.push(ev);
1052                }
1053                Err(_) => break,
1054            }
1055        }
1056        self.inner.return_read_conn(conn);
1057        chain
1058    }
1059
1060    /// Returns the highest event id for a workspace/channel pair, or 0 if none.
1061    pub fn latest_id(&self, workspace_id: &str, channel_id: &str) -> i64 {
1062        let conn = self.inner.take_read_conn();
1063        let result = conn
1064            .query_row(
1065                "SELECT COALESCE(MAX(id), 0) FROM context_events WHERE workspace_id = ?1 AND channel_id = ?2",
1066                params![workspace_id, channel_id],
1067                |row| row.get(0),
1068            )
1069            .unwrap_or(0);
1070        self.inner.return_read_conn(conn);
1071        result
1072    }
1073}
1074
1075/// A subscription wrapper that applies a [`TopicFilter`] to received events.
1076pub struct FilteredSubscription {
1077    pub rx: broadcast::Receiver<ContextEventV1>,
1078    pub filter: TopicFilter,
1079}
1080
1081impl FilteredSubscription {
1082    /// Receive the next event that matches the filter.
1083    /// Skips non-matching events silently.
1084    pub async fn recv_filtered(&mut self) -> Result<ContextEventV1, broadcast::error::RecvError> {
1085        loop {
1086            let ev = self.rx.recv().await?;
1087            if self.filter.matches(&ev) {
1088                return Ok(ev);
1089            }
1090        }
1091    }
1092}
1093
1094fn default_db_path() -> PathBuf {
1095    let data = crate::core::data_dir::lean_ctx_data_dir().unwrap_or_else(|_| PathBuf::from("."));
1096    data.join("context-os").join("context-os.db")
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101    use super::*;
1102    use tempfile::tempdir;
1103
1104    fn test_bus() -> (ContextBus, tempfile::TempDir) {
1105        let td = tempdir().expect("tempdir");
1106        let bus = ContextBus::open_at(td.path().join("test-context-os.db"));
1107        (bus, td)
1108    }
1109
1110    #[test]
1111    fn append_and_read_roundtrip() {
1112        let (bus, _td) = test_bus();
1113        let ev = bus
1114            .append(
1115                "ws",
1116                "ch",
1117                &ContextEventKindV1::ToolCallRecorded,
1118                Some("agent"),
1119                serde_json::json!({"tool":"ctx_read"}),
1120            )
1121            .expect("append");
1122        let got = bus.read("ws", "ch", ev.id - 1, 10);
1123        assert!(got.iter().any(|e| e.id == ev.id));
1124    }
1125
1126    #[test]
1127    fn authenticated_delivery_is_unique_across_bus_instances() {
1128        let td = tempdir().expect("tempdir");
1129        let path = td.path().join("delivery-context-os.db");
1130        let buses = [ContextBus::open_at(path.clone()), ContextBus::open_at(path)];
1131        let barrier = Arc::new(std::sync::Barrier::new(2));
1132        let handles: Vec<_> = buses
1133            .into_iter()
1134            .map(|bus| {
1135                let barrier = Arc::clone(&barrier);
1136                std::thread::spawn(move || {
1137                    barrier.wait();
1138                    bus.append_delivery_once(
1139                        "ws",
1140                        "a2a",
1141                        &ContextEventKindV1::SessionMutated,
1142                        Some("agent"),
1143                        serde_json::json!({"delivery_id":"delivery-1"}),
1144                        "delivery-1",
1145                        None,
1146                    )
1147                    .expect("delivery append")
1148                })
1149            })
1150            .collect();
1151        let inserted: Vec<bool> = handles
1152            .into_iter()
1153            .map(|handle| handle.join().expect("thread"))
1154            .collect();
1155        assert_eq!(inserted.iter().filter(|value| **value).count(), 1);
1156
1157        let bus = ContextBus::open_at(td.path().join("delivery-context-os.db"));
1158        assert_eq!(bus.read("ws", "a2a", 0, 10).len(), 1);
1159    }
1160
1161    #[test]
1162    fn directed_delivery_reaches_only_its_target_live_and_on_replay() {
1163        let td = tempdir().expect("tempdir");
1164        let path = td.path().join("directed-delivery-context-os.db");
1165        let bus = ContextBus::open_at(path.clone());
1166        let target_filter = || TopicFilter {
1167            agent_id: Some("agent-a".to_string()),
1168            ..TopicFilter::default()
1169        };
1170        let other_filter = || TopicFilter {
1171            agent_id: Some("agent-b".to_string()),
1172            ..TopicFilter::default()
1173        };
1174        let mut target_rx = bus.subscribe("ws", "a2a").expect("subscribe");
1175        let targets = vec!["agent-a".to_string()];
1176        assert!(
1177            bus.append_delivery_once(
1178                "ws",
1179                "a2a",
1180                &ContextEventKindV1::SessionMutated,
1181                Some("sender"),
1182                serde_json::json!({"delivery_id":"directed-1"}),
1183                "directed-1",
1184                Some(targets.clone()),
1185            )
1186            .expect("delivery append")
1187        );
1188        let live = target_rx.try_recv().expect("live delivery event");
1189        assert_eq!(live.target_agents, Some(targets.clone()));
1190        assert!(target_filter().matches(&live));
1191        assert!(!other_filter().matches(&live));
1192        assert!(!TopicFilter::broadcast_only().matches(&live));
1193        drop(bus);
1194
1195        let reopened = ContextBus::open_at(path);
1196        let replayed = reopened.read("ws", "a2a", 0, 10);
1197        assert_eq!(replayed.len(), 1);
1198        assert_eq!(replayed[0].target_agents, Some(targets));
1199        assert!(target_filter().matches(&replayed[0]));
1200        assert!(!other_filter().matches(&replayed[0]));
1201        assert!(!TopicFilter::broadcast_only().matches(&replayed[0]));
1202    }
1203
1204    #[test]
1205    fn multi_client_concurrent_appends_have_deterministic_ordering() {
1206        let (bus, _td) = test_bus();
1207        let bus = Arc::new(bus);
1208        let n_clients = 5;
1209        let n_events_per_client = 20;
1210        let ws = format!("ws-concurrent-{}", std::process::id());
1211        let ch = format!("ch-concurrent-{}", std::process::id());
1212
1213        let mut handles = vec![];
1214        for client_idx in 0..n_clients {
1215            let bus = Arc::clone(&bus);
1216            let ws = ws.clone();
1217            let ch = ch.clone();
1218            handles.push(std::thread::spawn(move || {
1219                let agent = format!("agent-{client_idx}");
1220                for event_idx in 0..n_events_per_client {
1221                    bus.append(
1222                        &ws,
1223                        &ch,
1224                        &ContextEventKindV1::ToolCallRecorded,
1225                        Some(&agent),
1226                        serde_json::json!({"client": client_idx, "seq": event_idx}),
1227                    );
1228                }
1229            }));
1230        }
1231
1232        for h in handles {
1233            h.join().unwrap();
1234        }
1235
1236        let all = bus.read(&ws, &ch, 0, 1000);
1237        assert_eq!(
1238            all.len(),
1239            n_clients * n_events_per_client,
1240            "all events should be persisted"
1241        );
1242
1243        let ids: Vec<i64> = all.iter().map(|e| e.id).collect();
1244        let mut sorted = ids.clone();
1245        sorted.sort_unstable();
1246        assert_eq!(ids, sorted, "events must be in strictly ascending ID order");
1247
1248        for win in ids.windows(2) {
1249            assert!(
1250                win[1] > win[0],
1251                "IDs must be strictly monotonic (no gaps from concurrent access)"
1252            );
1253        }
1254    }
1255
1256    #[test]
1257    fn workspace_channel_isolation() {
1258        let (bus, _td) = test_bus();
1259        let pid = std::process::id();
1260        let ws_a = format!("ws-iso-a-{pid}");
1261        let ws_b = format!("ws-iso-b-{pid}");
1262        let ws_c = format!("ws-iso-c-{pid}");
1263        let ch1 = format!("ch-iso-1-{pid}");
1264        let ch2 = format!("ch-iso-2-{pid}");
1265
1266        bus.append(
1267            &ws_a,
1268            &ch1,
1269            &ContextEventKindV1::SessionMutated,
1270            Some("agent-a"),
1271            serde_json::json!({"ws":"a","ch":"1"}),
1272        );
1273        bus.append(
1274            &ws_a,
1275            &ch2,
1276            &ContextEventKindV1::KnowledgeRemembered,
1277            Some("agent-a"),
1278            serde_json::json!({"ws":"a","ch":"2"}),
1279        );
1280        bus.append(
1281            &ws_b,
1282            &ch1,
1283            &ContextEventKindV1::ArtifactStored,
1284            Some("agent-b"),
1285            serde_json::json!({"ws":"b","ch":"1"}),
1286        );
1287
1288        let ws_a_ch_1 = bus.read(&ws_a, &ch1, 0, 100);
1289        assert_eq!(ws_a_ch_1.len(), 1);
1290        assert_eq!(ws_a_ch_1[0].kind, "session_mutated");
1291
1292        let ws_a_ch_2 = bus.read(&ws_a, &ch2, 0, 100);
1293        assert_eq!(ws_a_ch_2.len(), 1);
1294        assert_eq!(ws_a_ch_2[0].kind, "knowledge_remembered");
1295
1296        let ws_b_ch_1 = bus.read(&ws_b, &ch1, 0, 100);
1297        assert_eq!(ws_b_ch_1.len(), 1);
1298        assert_eq!(ws_b_ch_1[0].kind, "artifact_stored");
1299
1300        let ws_c_ch_1 = bus.read(&ws_c, &ch1, 0, 100);
1301        assert!(ws_c_ch_1.is_empty(), "non-existent workspace returns empty");
1302    }
1303
1304    #[test]
1305    fn replay_from_cursor_returns_only_newer_events() {
1306        let (bus, _td) = test_bus();
1307        let pid = std::process::id();
1308        let ws = &format!("ws-replay-{pid}");
1309        let ch = &format!("ch-replay-{pid}");
1310
1311        let ev1 = bus
1312            .append(
1313                ws,
1314                ch,
1315                &ContextEventKindV1::ToolCallRecorded,
1316                None,
1317                serde_json::json!({"seq":1}),
1318            )
1319            .unwrap();
1320        let ev2 = bus
1321            .append(
1322                ws,
1323                ch,
1324                &ContextEventKindV1::SessionMutated,
1325                None,
1326                serde_json::json!({"seq":2}),
1327            )
1328            .unwrap();
1329        let _ev3 = bus
1330            .append(
1331                ws,
1332                ch,
1333                &ContextEventKindV1::GraphBuilt,
1334                None,
1335                serde_json::json!({"seq":3}),
1336            )
1337            .unwrap();
1338
1339        let from_cursor = bus.read(ws, ch, ev2.id, 100);
1340        assert_eq!(from_cursor.len(), 1, "only events after cursor");
1341        assert_eq!(from_cursor[0].kind, "graph_built");
1342
1343        let from_first = bus.read(ws, ch, ev1.id, 100);
1344        assert_eq!(from_first.len(), 2, "events after first");
1345
1346        let from_zero = bus.read(ws, ch, 0, 100);
1347        assert_eq!(from_zero.len(), 3, "all events from zero");
1348    }
1349
1350    #[test]
1351    fn directed_visibility_survives_restart_and_filters_other_agents() {
1352        let td = tempdir().expect("tempdir");
1353        let path = td.path().join("directed-context-os.db");
1354        let bus = ContextBus::open_at(path.clone());
1355        let targets = vec!["agent-a".to_string()];
1356        let event = bus
1357            .append_directed(
1358                "ws",
1359                "ch",
1360                &ContextEventKindV1::SessionMutated,
1361                Some("lead"),
1362                serde_json::json!({"decision":"keep"}),
1363                targets.clone(),
1364            )
1365            .expect("directed event");
1366        assert_eq!(event.target_agents, Some(targets.clone()));
1367        drop(bus);
1368
1369        let reopened = ContextBus::open_at(path);
1370        let replayed = reopened.read("ws", "ch", 0, 10);
1371        assert_eq!(replayed.len(), 1);
1372        assert_eq!(replayed[0].target_agents, Some(targets));
1373        assert!(
1374            TopicFilter {
1375                agent_id: Some("agent-a".to_string()),
1376                ..TopicFilter::default()
1377            }
1378            .matches(&replayed[0])
1379        );
1380        assert!(
1381            !TopicFilter {
1382                agent_id: Some("agent-b".to_string()),
1383                ..TopicFilter::default()
1384            }
1385            .matches(&replayed[0])
1386        );
1387    }
1388
1389    #[test]
1390    fn legacy_schema_migrates_without_turning_events_into_directed_events() {
1391        let td = tempdir().expect("tempdir");
1392        let path = td.path().join("legacy-context-os.db");
1393        {
1394            let conn = Connection::open(&path).expect("legacy db");
1395            conn.execute_batch(
1396                "CREATE TABLE context_events (
1397                   id INTEGER PRIMARY KEY AUTOINCREMENT,
1398                   workspace_id TEXT NOT NULL,
1399                   channel_id TEXT NOT NULL,
1400                   kind TEXT NOT NULL,
1401                   actor TEXT,
1402                   timestamp TEXT NOT NULL,
1403                   payload_json TEXT NOT NULL,
1404                   version INTEGER NOT NULL DEFAULT 0,
1405                   parent_id INTEGER,
1406                   delivery_id TEXT
1407                 );
1408                 INSERT INTO context_events
1409                   (workspace_id, channel_id, kind, timestamp, payload_json)
1410                 VALUES ('ws', 'ch', 'knowledge_remembered',
1411                         '2026-01-01T00:00:00Z', '{}');",
1412            )
1413            .expect("legacy schema");
1414        }
1415
1416        let bus = ContextBus::open_at(path);
1417        let replayed = bus.read("ws", "ch", 0, 10);
1418        assert_eq!(replayed.len(), 1);
1419        assert_eq!(replayed[0].target_agents, None);
1420    }
1421
1422    #[test]
1423    fn broadcast_subscriber_receives_events() {
1424        let (bus, _td) = test_bus();
1425        let mut rx = bus.subscribe("ws", "ch").expect("subscribe should succeed");
1426
1427        let ev = bus
1428            .append(
1429                "ws",
1430                "ch",
1431                &ContextEventKindV1::ProofAdded,
1432                Some("verifier"),
1433                serde_json::json!({"proof":"hash"}),
1434            )
1435            .unwrap();
1436
1437        let received = rx.try_recv().expect("subscriber should receive event");
1438        assert_eq!(received.id, ev.id);
1439        assert_eq!(received.kind, "proof_added");
1440        assert_eq!(received.actor.as_deref(), Some("verifier"));
1441    }
1442
1443    #[test]
1444    fn event_kind_parser_roundtrips_all_v1_kinds() {
1445        for kind in [
1446            ContextEventKindV1::ToolCallRecorded,
1447            ContextEventKindV1::SessionMutated,
1448            ContextEventKindV1::KnowledgeRemembered,
1449            ContextEventKindV1::ArtifactStored,
1450            ContextEventKindV1::GraphBuilt,
1451            ContextEventKindV1::ProofAdded,
1452            ContextEventKindV1::ProvenanceCheckpoint,
1453            ContextEventKindV1::ProvenanceEdit,
1454            ContextEventKindV1::HealthChanged,
1455        ] {
1456            assert_eq!(ContextEventKindV1::parse(kind.as_str()), kind);
1457        }
1458    }
1459}
1460// SPDX-License-Identifier: Apache-2.0