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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum ConsistencyLevel {
111 Local = 0,
113 Eventual = 1,
115 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#[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 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 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#[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;
280const 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 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 pub fn open_at(path: PathBuf) -> Self {
388 Self::try_open_at(path).expect("open context-os db")
389 }
390
391 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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
1075pub struct FilteredSubscription {
1077 pub rx: broadcast::Receiver<ContextEventV1>,
1078 pub filter: TopicFilter,
1079}
1080
1081impl FilteredSubscription {
1082 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