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
270const STREAM_CHANNEL_SIZE: usize = 256;
271const MAX_SUBSCRIBERS_PER_CHANNEL: usize = 64;
272const MAX_VERSION_CACHE_ENTRIES: usize = 4096;
276
277struct Inner {
278 write_conn: Mutex<Connection>,
279 read_pool: Mutex<Vec<Connection>>,
280 streams: Mutex<HashMap<String, broadcast::Sender<ContextEventV1>>>,
281 version_cache: Mutex<HashMap<String, i64>>,
282 db_path: PathBuf,
283}
284
285impl Inner {
286 fn open_read_conn(path: &PathBuf) -> Connection {
287 Self::try_open_read_conn(path).expect("open read context-os db")
288 }
289
290 fn try_open_read_conn(path: &PathBuf) -> Result<Connection, ObservationPersistenceError> {
291 let conn = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
292 conn.busy_timeout(std::time::Duration::from_secs(5))?;
293 conn.execute_batch("PRAGMA query_only=ON;")?;
294 Ok(conn)
295 }
296
297 fn try_take_read_conn(&self) -> Result<Connection, ObservationPersistenceError> {
298 let cached = lock_observation(&self.read_pool)?.pop();
299 match cached {
300 Some(conn) => Ok(conn),
301 None => Self::try_open_read_conn(&self.db_path),
302 }
303 }
304
305 fn take_read_conn(&self) -> Connection {
306 self.read_pool
307 .lock()
308 .unwrap_or_else(std::sync::PoisonError::into_inner)
309 .pop()
310 .unwrap_or_else(|| Self::open_read_conn(&self.db_path))
311 }
312
313 fn return_read_conn(&self, conn: Connection) {
314 let mut pool = self
315 .read_pool
316 .lock()
317 .unwrap_or_else(std::sync::PoisonError::into_inner);
318 if pool.len() < MAX_READ_CONNS {
319 pool.push(conn);
320 }
321 }
322
323 fn stream_key(workspace_id: &str, channel_id: &str) -> String {
324 format!("{workspace_id}\0{channel_id}")
325 }
326
327 fn next_version(&self, workspace_id: &str, channel_id: &str) -> i64 {
328 let key = Self::stream_key(workspace_id, channel_id);
329
330 {
331 let mut cache = self
332 .version_cache
333 .lock()
334 .unwrap_or_else(std::sync::PoisonError::into_inner);
335 if let Some(v) = cache.get_mut(&key) {
336 *v += 1;
337 return *v;
338 }
339 }
340
341 let conn = self.take_read_conn();
342 let v: i64 = conn
343 .query_row(
344 "SELECT COALESCE(MAX(version), 0) FROM context_events WHERE workspace_id = ?1 AND channel_id = ?2",
345 params![workspace_id, channel_id],
346 |row| row.get(0),
347 )
348 .unwrap_or(0);
349 self.return_read_conn(conn);
350
351 let mut cache = self
352 .version_cache
353 .lock()
354 .unwrap_or_else(std::sync::PoisonError::into_inner);
355 if cache.len() > MAX_VERSION_CACHE_ENTRIES {
356 cache.clear();
358 }
359 let entry = cache.entry(key).or_insert(v);
360 *entry = (*entry).max(v) + 1;
361 *entry
362 }
363}
364
365impl Default for ContextBus {
366 fn default() -> Self {
367 Self::new()
368 }
369}
370
371impl ContextBus {
372 pub fn new() -> Self {
373 let path = default_db_path();
374 Self::open_at(path)
375 }
376
377 pub fn open_at(path: PathBuf) -> Self {
380 Self::try_open_at(path).expect("open context-os db")
381 }
382
383 pub fn try_open_at(path: PathBuf) -> Result<Self, ObservationPersistenceError> {
385 if let Some(parent) = path.parent() {
386 std::fs::create_dir_all(parent)?;
387 }
388 let conn = Connection::open(&path)?;
389 conn.busy_timeout(std::time::Duration::from_secs(5))?;
390 conn.execute_batch(
391 "PRAGMA journal_mode=WAL;
392 CREATE TABLE IF NOT EXISTS context_events (
393 id INTEGER PRIMARY KEY AUTOINCREMENT,
394 workspace_id TEXT NOT NULL,
395 channel_id TEXT NOT NULL,
396 kind TEXT NOT NULL,
397 actor TEXT,
398 timestamp TEXT NOT NULL,
399 payload_json TEXT NOT NULL,
400 version INTEGER NOT NULL DEFAULT 0,
401 parent_id INTEGER,
402 delivery_id TEXT,
403 target_agents_json TEXT
404 );
405 CREATE INDEX IF NOT EXISTS idx_context_events_stream
406 ON context_events(workspace_id, channel_id, id);
407 CREATE TABLE IF NOT EXISTS context_local_observations (
408 id INTEGER PRIMARY KEY AUTOINCREMENT,
409 schema_version INTEGER NOT NULL CHECK(schema_version = 1),
410 source_kind TEXT NOT NULL,
411 producer_instance TEXT NOT NULL,
412 source_id TEXT NOT NULL,
413 source_timestamp TEXT NOT NULL,
414 payload_json TEXT NOT NULL,
415 UNIQUE(source_kind, producer_instance, source_id)
416 );
417 CREATE INDEX IF NOT EXISTS idx_context_local_observations_source
418 ON context_local_observations(source_kind, id);",
419 )?;
420
421 let _ = conn.execute_batch(
422 "ALTER TABLE context_events ADD COLUMN version INTEGER NOT NULL DEFAULT 0;",
423 );
424 let _ = conn.execute_batch("ALTER TABLE context_events ADD COLUMN parent_id INTEGER;");
425 let _ = conn.execute_batch("ALTER TABLE context_events ADD COLUMN delivery_id TEXT;");
426 let _ =
427 conn.execute_batch("ALTER TABLE context_events ADD COLUMN target_agents_json TEXT;");
428 conn.execute_batch(
429 "CREATE UNIQUE INDEX IF NOT EXISTS idx_context_events_delivery
430 ON context_events(workspace_id, channel_id, delivery_id)
431 WHERE delivery_id IS NOT NULL;",
432 )?;
433
434 let _ = conn.execute_batch(
435 "CREATE VIRTUAL TABLE IF NOT EXISTS context_events_fts USING fts5(
436 payload_text,
437 content=context_events,
438 content_rowid=id
439 );",
440 );
441
442 let mut read_conns = Vec::with_capacity(MAX_READ_CONNS);
443 for _ in 0..MAX_READ_CONNS {
444 read_conns.push(Inner::try_open_read_conn(&path)?);
445 }
446
447 Ok(Self {
448 inner: Arc::new(Inner {
449 write_conn: Mutex::new(conn),
450 read_pool: Mutex::new(read_conns),
451 streams: Mutex::new(HashMap::new()),
452 version_cache: Mutex::new(HashMap::new()),
453 db_path: path,
454 }),
455 })
456 }
457
458 #[cfg(test)]
459 pub(crate) fn db_path(&self) -> &std::path::Path {
460 &self.inner.db_path
461 }
462
463 pub fn subscribe(
464 &self,
465 workspace_id: &str,
466 channel_id: &str,
467 ) -> Option<broadcast::Receiver<ContextEventV1>> {
468 let key = Inner::stream_key(workspace_id, channel_id);
469 let mut streams = self
470 .inner
471 .streams
472 .lock()
473 .unwrap_or_else(std::sync::PoisonError::into_inner);
474 streams.retain(|_, tx| tx.receiver_count() > 0);
479 let tx = streams
480 .entry(key)
481 .or_insert_with(|| broadcast::channel(STREAM_CHANNEL_SIZE).0);
482 if tx.receiver_count() >= MAX_SUBSCRIBERS_PER_CHANNEL {
483 tracing::warn!(
484 "SSE subscriber cap ({MAX_SUBSCRIBERS_PER_CHANNEL}) reached for {workspace_id}/{channel_id} — rejecting"
485 );
486 return None;
487 }
488 Some(tx.subscribe())
489 }
490
491 pub fn subscribe_filtered(
494 &self,
495 workspace_id: &str,
496 channel_id: &str,
497 filter: TopicFilter,
498 ) -> Option<FilteredSubscription> {
499 let rx = self.subscribe(workspace_id, channel_id)?;
500 Some(FilteredSubscription { rx, filter })
501 }
502
503 pub fn append(
504 &self,
505 workspace_id: &str,
506 channel_id: &str,
507 kind: &ContextEventKindV1,
508 actor: Option<&str>,
509 payload: Value,
510 ) -> Option<ContextEventV1> {
511 self.append_with_parent(workspace_id, channel_id, kind, actor, payload, None)
512 }
513
514 pub fn append_ocla_observation(
518 &self,
519 producer_instance: &str,
520 record: &OclaBusRecord,
521 ) -> Result<i64, ObservationPersistenceError> {
522 let payload_json = serde_json::to_string(&record.event)?;
523 let _: OclaEvent = serde_json::from_str(&payload_json)?;
525 super::observation::admit_payload(&payload_json)?;
526 self.insert_local_observation(
527 LocalObservationSource::Ocla,
528 producer_instance,
529 record.id,
530 &record.timestamp_ms.to_string(),
531 &payload_json,
532 )
533 }
534
535 pub fn append_legacy_observation(
538 &self,
539 producer_instance: &str,
540 event: &LeanCtxEvent,
541 ) -> Result<i64, ObservationPersistenceError> {
542 let payload_json = serde_json::to_string(event)?;
543 let _: LeanCtxEvent = serde_json::from_str(&payload_json)?;
544 super::observation::admit_payload(&payload_json)?;
545 self.insert_local_observation(
546 LocalObservationSource::Legacy,
547 producer_instance,
548 event.id,
549 &event.timestamp,
550 &payload_json,
551 )
552 }
553
554 fn insert_local_observation(
555 &self,
556 source: LocalObservationSource,
557 producer_instance: &str,
558 source_id: u64,
559 source_timestamp: &str,
560 payload_json: &str,
561 ) -> Result<i64, ObservationPersistenceError> {
562 use rusqlite::OptionalExtension;
563 validate_identity(producer_instance, source_id)?;
564 let mut conn = lock_observation(&self.inner.write_conn)?;
565 let transaction =
566 conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
567 let existing: Option<(i64, String, String)> = transaction
568 .query_row(
569 "SELECT id, source_timestamp, payload_json FROM context_local_observations
570 WHERE source_kind = ?1 AND producer_instance = ?2 AND source_id = ?3",
571 params![source.as_str(), producer_instance, source_id.to_string()],
572 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
573 )
574 .optional()?;
575 if let Some((cursor, timestamp, payload)) = existing {
576 if timestamp != source_timestamp || payload != payload_json {
577 return Err(ObservationPersistenceError::IdentityConflict);
578 }
579 transaction.commit()?;
580 return Ok(cursor);
581 }
582 transaction.execute(
583 "INSERT INTO context_local_observations
584 (schema_version, source_kind, producer_instance, source_id, source_timestamp, payload_json)
585 VALUES (1, ?1, ?2, ?3, ?4, ?5)",
586 params![
587 source.as_str(),
588 producer_instance,
589 source_id.to_string(),
590 source_timestamp,
591 payload_json
592 ],
593 )?;
594 let cursor_id = transaction.last_insert_rowid();
595 transaction.commit()?;
596 Ok(cursor_id)
597 }
598
599 pub fn read_local_observations(
601 &self,
602 limit: usize,
603 ) -> Result<Vec<LocalObservationV1>, ObservationPersistenceError> {
604 let limit = limit.clamp(1, 10_000) as i64;
605 let conn = self.inner.try_take_read_conn()?;
606 let result = (|| {
607 let mut stmt = conn.prepare(
608 "SELECT id, source_kind, source_id, source_timestamp, payload_json,
609 producer_instance, schema_version
610 FROM context_local_observations ORDER BY id ASC LIMIT ?1",
611 )?;
612 let mut rows = stmt.query(params![limit])?;
613 let mut observations = Vec::new();
614 while let Some(row) = rows.next()? {
615 if let Some(observation) = decode_row(row)? {
616 observations.push(observation);
617 }
618 }
619 Ok(observations)
620 })();
621 self.return_observation_read_conn(conn)?;
622 result
623 }
624
625 pub fn read_legacy_observations(
626 &self,
627 limit: usize,
628 ) -> Result<Vec<LeanCtxEvent>, ObservationPersistenceError> {
629 let limit = limit.clamp(1, 10_000) as i64;
630 let conn = self.inner.try_take_read_conn()?;
631 let result = (|| {
632 let mut stmt = conn.prepare(
633 "SELECT id, source_kind, source_id, source_timestamp, payload_json,
634 producer_instance, schema_version FROM context_local_observations
635 WHERE source_kind = 'legacy' ORDER BY id DESC LIMIT ?1",
636 )?;
637 let mut rows = stmt.query(params![limit])?;
638 let mut events = Vec::new();
639 while let Some(row) = rows.next()? {
640 let Some(observation) = decode_row(row)? else {
641 continue;
642 };
643 let LocalObservationPayload::Legacy(event) = observation.payload else {
644 return Err(ObservationPersistenceError::InvalidRow(
645 "non-legacy observation in legacy projection".into(),
646 ));
647 };
648 events.push(event);
649 }
650 events.reverse();
651 Ok(events)
652 })();
653 self.return_observation_read_conn(conn)?;
654 result
655 }
656
657 fn return_observation_read_conn(
658 &self,
659 conn: Connection,
660 ) -> Result<(), ObservationPersistenceError> {
661 let mut pool = lock_observation(&self.inner.read_pool)?;
662 if pool.len() < MAX_READ_CONNS {
663 pool.push(conn);
664 }
665 Ok(())
666 }
667
668 pub fn append_with_parent(
669 &self,
670 workspace_id: &str,
671 channel_id: &str,
672 kind: &ContextEventKindV1,
673 actor: Option<&str>,
674 payload: Value,
675 parent_id: Option<i64>,
676 ) -> Option<ContextEventV1> {
677 let ev = self.insert_event(
678 workspace_id,
679 channel_id,
680 kind,
681 actor,
682 payload,
683 parent_id,
684 None,
685 )?;
686 self.broadcast_event(&ev);
687 Some(ev)
688 }
689
690 pub fn append_directed(
693 &self,
694 workspace_id: &str,
695 channel_id: &str,
696 kind: &ContextEventKindV1,
697 actor: Option<&str>,
698 payload: Value,
699 target_agents: Vec<String>,
700 ) -> Option<ContextEventV1> {
701 let ev = self.insert_event(
702 workspace_id,
703 channel_id,
704 kind,
705 actor,
706 payload,
707 None,
708 Some(target_agents),
709 )?;
710 self.broadcast_event(&ev);
711 Some(ev)
712 }
713
714 fn insert_event(
715 &self,
716 workspace_id: &str,
717 channel_id: &str,
718 kind: &ContextEventKindV1,
719 actor: Option<&str>,
720 payload: Value,
721 parent_id: Option<i64>,
722 target_agents: Option<Vec<String>>,
723 ) -> Option<ContextEventV1> {
724 let ts = Utc::now();
725 let payload_json = payload.to_string();
726 let target_agents_json = encode_target_agents(target_agents.as_deref());
727
728 let (id, version) = {
729 let Ok(conn) = self.inner.write_conn.lock() else {
730 return None;
731 };
732 let version = self.inner.next_version(workspace_id, channel_id);
733
734 let result: Result<(i64, i64), rusqlite::Error> = conn
735 .execute_batch("BEGIN IMMEDIATE")
736 .and_then(|()| {
737 conn.execute(
738 "INSERT INTO context_events (workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id, target_agents_json)
739 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
740 params![
741 workspace_id,
742 channel_id,
743 kind.as_str(),
744 actor.map(str::to_string),
745 ts.to_rfc3339(),
746 payload_json,
747 version,
748 parent_id,
749 target_agents_json,
750 ],
751 )?;
752 let rowid = conn.last_insert_rowid();
753 if let Err(e) = conn.execute(
754 "INSERT INTO context_events_fts(rowid, payload_text) VALUES (?1, ?2)",
755 params![rowid, payload_json],
756 ) {
757 tracing::warn!("FTS insert failed for event {rowid}: {e}");
758 }
759 conn.execute_batch("COMMIT")?;
760 Ok((rowid, version))
761 });
762
763 match result {
764 Ok(pair) => pair,
765 Err(e) => {
766 tracing::warn!("context bus append failed: {e}");
767 let _ = conn.execute_batch("ROLLBACK");
768 return None;
769 }
770 }
771 };
772
773 Some(ContextEventV1 {
774 id,
775 workspace_id: workspace_id.to_string(),
776 channel_id: channel_id.to_string(),
777 consistency_level: kind.consistency_level().as_str().to_string(),
778 kind: kind.as_str().to_string(),
779 actor: actor.map(str::to_string),
780 timestamp: ts,
781 version,
782 parent_id,
783 payload,
784 target_agents,
785 })
786 }
787
788 fn broadcast_event(&self, ev: &ContextEventV1) {
789 let key = Inner::stream_key(&ev.workspace_id, &ev.channel_id);
790 let tx = self
791 .inner
792 .streams
793 .lock()
794 .unwrap_or_else(std::sync::PoisonError::into_inner)
795 .get(&key)
796 .cloned();
797 if let Some(tx) = tx {
798 let _ = tx.send(ev.clone());
799 }
800 }
801
802 pub fn read(
803 &self,
804 workspace_id: &str,
805 channel_id: &str,
806 since: i64,
807 limit: usize,
808 ) -> Vec<ContextEventV1> {
809 let limit = limit.clamp(1, 1000) as i64;
810 let conn = self.inner.take_read_conn();
811 let result = (|| {
812 let mut stmt = conn.prepare(
813 "SELECT id, workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id, target_agents_json
814 FROM context_events
815 WHERE workspace_id = ?1 AND channel_id = ?2 AND id > ?3
816 ORDER BY id ASC
817 LIMIT ?4",
818 ).ok()?;
819 let rows = stmt
820 .query_map(
821 params![workspace_id, channel_id, since, limit],
822 event_from_row,
823 )
824 .ok()?;
825 Some(rows.flatten().collect::<Vec<_>>())
826 })();
827 self.inner.return_read_conn(conn);
828 result.unwrap_or_default()
829 }
830
831 pub fn append_delivery_once(
837 &self,
838 workspace_id: &str,
839 channel_id: &str,
840 kind: &ContextEventKindV1,
841 actor: Option<&str>,
842 payload: Value,
843 delivery_id: &str,
844 target_agents: Option<Vec<String>>,
845 ) -> Result<bool, String> {
846 let ts = Utc::now();
847 let payload_json = payload.to_string();
848 let target_agents_json = encode_target_agents(target_agents.as_deref());
849 let (id, version) = {
850 let conn = self
851 .inner
852 .write_conn
853 .lock()
854 .map_err(|_| "context bus write lock poisoned".to_string())?;
855 conn.execute_batch("BEGIN IMMEDIATE")
856 .map_err(|error| error.to_string())?;
857 let version = self.inner.next_version(workspace_id, channel_id);
858 let inserted = conn
859 .execute(
860 "INSERT OR IGNORE INTO context_events (workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id, delivery_id, target_agents_json)
861 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?9)",
862 params![workspace_id, channel_id, kind.as_str(), actor, ts.to_rfc3339(), payload_json, version, delivery_id, target_agents_json],
863 )
864 .map_err(|error| error.to_string());
865 let inserted = match inserted {
866 Ok(value) => value,
867 Err(error) => {
868 let _ = conn.execute_batch("ROLLBACK");
869 return Err(error);
870 }
871 };
872 if inserted == 0 {
873 conn.execute_batch("COMMIT")
874 .map_err(|error| error.to_string())?;
875 return Ok(false);
876 }
877 let rowid = conn.last_insert_rowid();
878 if let Err(error) = conn.execute(
879 "INSERT INTO context_events_fts(rowid, payload_text) VALUES (?1, ?2)",
880 params![rowid, payload_json],
881 ) {
882 tracing::warn!("FTS insert failed for event {rowid}: {error}");
883 }
884 conn.execute_batch("COMMIT")
885 .map_err(|error| error.to_string())?;
886 (rowid, version)
887 };
888 self.broadcast_event(&ContextEventV1 {
889 id,
890 workspace_id: workspace_id.to_string(),
891 channel_id: channel_id.to_string(),
892 consistency_level: kind.consistency_level().as_str().to_string(),
893 kind: kind.as_str().to_string(),
894 actor: actor.map(str::to_string),
895 timestamp: ts,
896 version,
897 parent_id: None,
898 payload,
899 target_agents,
900 });
901 Ok(true)
902 }
903
904 pub fn recent_by_kind(
906 &self,
907 workspace_id: &str,
908 channel_id: &str,
909 kind: &str,
910 limit: usize,
911 ) -> Vec<ContextEventV1> {
912 let limit = limit.clamp(1, 100) as i64;
913 let conn = self.inner.take_read_conn();
914 let result = (|| {
915 let mut stmt = conn.prepare(
916 "SELECT id, workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id, target_agents_json
917 FROM context_events
918 WHERE workspace_id = ?1 AND channel_id = ?2 AND kind = ?3
919 ORDER BY id DESC
920 LIMIT ?4",
921 ).ok()?;
922 let rows = stmt
923 .query_map(
924 params![workspace_id, channel_id, kind, limit],
925 event_from_row,
926 )
927 .ok()?;
928 Some(rows.flatten().collect::<Vec<_>>())
929 })();
930 self.inner.return_read_conn(conn);
931 result.unwrap_or_default()
932 }
933
934 pub fn search(
936 &self,
937 workspace_id: &str,
938 channel_id: Option<&str>,
939 query: &str,
940 limit: usize,
941 ) -> Vec<ContextEventV1> {
942 let limit = limit.clamp(1, 100) as i64;
943 let conn = self.inner.take_read_conn();
944 let result =
945 if let Some(ch) = channel_id {
946 (|| {
947 let mut stmt = conn.prepare(
948 "SELECT e.id, e.workspace_id, e.channel_id, e.kind, e.actor, e.timestamp,
949 e.payload_json, e.version, e.parent_id, e.target_agents_json
950 FROM context_events e
951 JOIN context_events_fts f ON e.id = f.rowid
952 WHERE f.payload_text MATCH ?1 AND e.workspace_id = ?2 AND e.channel_id = ?3
953 ORDER BY f.rank
954 LIMIT ?4",
955 ).ok()?;
956 let rows = stmt
957 .query_map(params![query, workspace_id, ch, limit], event_from_row)
958 .ok()?;
959 Some(rows.flatten().collect::<Vec<_>>())
960 })()
961 } else {
962 (|| {
963 let mut stmt = conn.prepare(
964 "SELECT e.id, e.workspace_id, e.channel_id, e.kind, e.actor, e.timestamp,
965 e.payload_json, e.version, e.parent_id, e.target_agents_json
966 FROM context_events e
967 JOIN context_events_fts f ON e.id = f.rowid
968 WHERE f.payload_text MATCH ?1 AND e.workspace_id = ?2
969 ORDER BY f.rank
970 LIMIT ?3",
971 ).ok()?;
972 let rows = stmt
973 .query_map(params![query, workspace_id, limit], event_from_row)
974 .ok()?;
975 Some(rows.flatten().collect::<Vec<_>>())
976 })()
977 };
978 self.inner.return_read_conn(conn);
979 result.unwrap_or_default()
980 }
981
982 pub fn lineage(
985 &self,
986 event_id: i64,
987 workspace_id: &str,
988 max_depth: usize,
989 ) -> Vec<ContextEventV1> {
990 let max_depth = max_depth.clamp(1, 50);
991 let conn = self.inner.take_read_conn();
992 let mut chain = Vec::new();
993 let mut current_id = Some(event_id);
994
995 for _ in 0..max_depth {
996 let Some(id) = current_id else {
997 break;
998 };
999 let ev = conn.query_row(
1000 "SELECT id, workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id, target_agents_json
1001 FROM context_events WHERE id = ?1 AND workspace_id = ?2",
1002 params![id, workspace_id],
1003 event_from_row,
1004 );
1005 match ev {
1006 Ok(ev) => {
1007 current_id = ev.parent_id;
1008 chain.push(ev);
1009 }
1010 Err(_) => break,
1011 }
1012 }
1013 self.inner.return_read_conn(conn);
1014 chain
1015 }
1016
1017 pub fn latest_id(&self, workspace_id: &str, channel_id: &str) -> i64 {
1019 let conn = self.inner.take_read_conn();
1020 let result = conn
1021 .query_row(
1022 "SELECT COALESCE(MAX(id), 0) FROM context_events WHERE workspace_id = ?1 AND channel_id = ?2",
1023 params![workspace_id, channel_id],
1024 |row| row.get(0),
1025 )
1026 .unwrap_or(0);
1027 self.inner.return_read_conn(conn);
1028 result
1029 }
1030}
1031
1032pub struct FilteredSubscription {
1034 pub rx: broadcast::Receiver<ContextEventV1>,
1035 pub filter: TopicFilter,
1036}
1037
1038impl FilteredSubscription {
1039 pub async fn recv_filtered(&mut self) -> Result<ContextEventV1, broadcast::error::RecvError> {
1042 loop {
1043 let ev = self.rx.recv().await?;
1044 if self.filter.matches(&ev) {
1045 return Ok(ev);
1046 }
1047 }
1048 }
1049}
1050
1051fn default_db_path() -> PathBuf {
1052 let data = crate::core::data_dir::lean_ctx_data_dir().unwrap_or_else(|_| PathBuf::from("."));
1053 data.join("context-os").join("context-os.db")
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058 use super::*;
1059 use tempfile::tempdir;
1060
1061 fn test_bus() -> (ContextBus, tempfile::TempDir) {
1062 let td = tempdir().expect("tempdir");
1063 let bus = ContextBus::open_at(td.path().join("test-context-os.db"));
1064 (bus, td)
1065 }
1066
1067 #[test]
1068 fn append_and_read_roundtrip() {
1069 let (bus, _td) = test_bus();
1070 let ev = bus
1071 .append(
1072 "ws",
1073 "ch",
1074 &ContextEventKindV1::ToolCallRecorded,
1075 Some("agent"),
1076 serde_json::json!({"tool":"ctx_read"}),
1077 )
1078 .expect("append");
1079 let got = bus.read("ws", "ch", ev.id - 1, 10);
1080 assert!(got.iter().any(|e| e.id == ev.id));
1081 }
1082
1083 #[test]
1084 fn authenticated_delivery_is_unique_across_bus_instances() {
1085 let td = tempdir().expect("tempdir");
1086 let path = td.path().join("delivery-context-os.db");
1087 let buses = [ContextBus::open_at(path.clone()), ContextBus::open_at(path)];
1088 let barrier = Arc::new(std::sync::Barrier::new(2));
1089 let handles: Vec<_> = buses
1090 .into_iter()
1091 .map(|bus| {
1092 let barrier = Arc::clone(&barrier);
1093 std::thread::spawn(move || {
1094 barrier.wait();
1095 bus.append_delivery_once(
1096 "ws",
1097 "a2a",
1098 &ContextEventKindV1::SessionMutated,
1099 Some("agent"),
1100 serde_json::json!({"delivery_id":"delivery-1"}),
1101 "delivery-1",
1102 None,
1103 )
1104 .expect("delivery append")
1105 })
1106 })
1107 .collect();
1108 let inserted: Vec<bool> = handles
1109 .into_iter()
1110 .map(|handle| handle.join().expect("thread"))
1111 .collect();
1112 assert_eq!(inserted.iter().filter(|value| **value).count(), 1);
1113
1114 let bus = ContextBus::open_at(td.path().join("delivery-context-os.db"));
1115 assert_eq!(bus.read("ws", "a2a", 0, 10).len(), 1);
1116 }
1117
1118 #[test]
1119 fn directed_delivery_reaches_only_its_target_live_and_on_replay() {
1120 let td = tempdir().expect("tempdir");
1121 let path = td.path().join("directed-delivery-context-os.db");
1122 let bus = ContextBus::open_at(path.clone());
1123 let target_filter = || TopicFilter {
1124 agent_id: Some("agent-a".to_string()),
1125 ..TopicFilter::default()
1126 };
1127 let other_filter = || TopicFilter {
1128 agent_id: Some("agent-b".to_string()),
1129 ..TopicFilter::default()
1130 };
1131 let mut target_rx = bus.subscribe("ws", "a2a").expect("subscribe");
1132 let targets = vec!["agent-a".to_string()];
1133 assert!(
1134 bus.append_delivery_once(
1135 "ws",
1136 "a2a",
1137 &ContextEventKindV1::SessionMutated,
1138 Some("sender"),
1139 serde_json::json!({"delivery_id":"directed-1"}),
1140 "directed-1",
1141 Some(targets.clone()),
1142 )
1143 .expect("delivery append")
1144 );
1145 let live = target_rx.try_recv().expect("live delivery event");
1146 assert_eq!(live.target_agents, Some(targets.clone()));
1147 assert!(target_filter().matches(&live));
1148 assert!(!other_filter().matches(&live));
1149 assert!(!TopicFilter::broadcast_only().matches(&live));
1150 drop(bus);
1151
1152 let reopened = ContextBus::open_at(path);
1153 let replayed = reopened.read("ws", "a2a", 0, 10);
1154 assert_eq!(replayed.len(), 1);
1155 assert_eq!(replayed[0].target_agents, Some(targets));
1156 assert!(target_filter().matches(&replayed[0]));
1157 assert!(!other_filter().matches(&replayed[0]));
1158 assert!(!TopicFilter::broadcast_only().matches(&replayed[0]));
1159 }
1160
1161 #[test]
1162 fn multi_client_concurrent_appends_have_deterministic_ordering() {
1163 let (bus, _td) = test_bus();
1164 let bus = Arc::new(bus);
1165 let n_clients = 5;
1166 let n_events_per_client = 20;
1167 let ws = format!("ws-concurrent-{}", std::process::id());
1168 let ch = format!("ch-concurrent-{}", std::process::id());
1169
1170 let mut handles = vec![];
1171 for client_idx in 0..n_clients {
1172 let bus = Arc::clone(&bus);
1173 let ws = ws.clone();
1174 let ch = ch.clone();
1175 handles.push(std::thread::spawn(move || {
1176 let agent = format!("agent-{client_idx}");
1177 for event_idx in 0..n_events_per_client {
1178 bus.append(
1179 &ws,
1180 &ch,
1181 &ContextEventKindV1::ToolCallRecorded,
1182 Some(&agent),
1183 serde_json::json!({"client": client_idx, "seq": event_idx}),
1184 );
1185 }
1186 }));
1187 }
1188
1189 for h in handles {
1190 h.join().unwrap();
1191 }
1192
1193 let all = bus.read(&ws, &ch, 0, 1000);
1194 assert_eq!(
1195 all.len(),
1196 n_clients * n_events_per_client,
1197 "all events should be persisted"
1198 );
1199
1200 let ids: Vec<i64> = all.iter().map(|e| e.id).collect();
1201 let mut sorted = ids.clone();
1202 sorted.sort_unstable();
1203 assert_eq!(ids, sorted, "events must be in strictly ascending ID order");
1204
1205 for win in ids.windows(2) {
1206 assert!(
1207 win[1] > win[0],
1208 "IDs must be strictly monotonic (no gaps from concurrent access)"
1209 );
1210 }
1211 }
1212
1213 #[test]
1214 fn workspace_channel_isolation() {
1215 let (bus, _td) = test_bus();
1216 let pid = std::process::id();
1217 let ws_a = format!("ws-iso-a-{pid}");
1218 let ws_b = format!("ws-iso-b-{pid}");
1219 let ws_c = format!("ws-iso-c-{pid}");
1220 let ch1 = format!("ch-iso-1-{pid}");
1221 let ch2 = format!("ch-iso-2-{pid}");
1222
1223 bus.append(
1224 &ws_a,
1225 &ch1,
1226 &ContextEventKindV1::SessionMutated,
1227 Some("agent-a"),
1228 serde_json::json!({"ws":"a","ch":"1"}),
1229 );
1230 bus.append(
1231 &ws_a,
1232 &ch2,
1233 &ContextEventKindV1::KnowledgeRemembered,
1234 Some("agent-a"),
1235 serde_json::json!({"ws":"a","ch":"2"}),
1236 );
1237 bus.append(
1238 &ws_b,
1239 &ch1,
1240 &ContextEventKindV1::ArtifactStored,
1241 Some("agent-b"),
1242 serde_json::json!({"ws":"b","ch":"1"}),
1243 );
1244
1245 let ws_a_ch_1 = bus.read(&ws_a, &ch1, 0, 100);
1246 assert_eq!(ws_a_ch_1.len(), 1);
1247 assert_eq!(ws_a_ch_1[0].kind, "session_mutated");
1248
1249 let ws_a_ch_2 = bus.read(&ws_a, &ch2, 0, 100);
1250 assert_eq!(ws_a_ch_2.len(), 1);
1251 assert_eq!(ws_a_ch_2[0].kind, "knowledge_remembered");
1252
1253 let ws_b_ch_1 = bus.read(&ws_b, &ch1, 0, 100);
1254 assert_eq!(ws_b_ch_1.len(), 1);
1255 assert_eq!(ws_b_ch_1[0].kind, "artifact_stored");
1256
1257 let ws_c_ch_1 = bus.read(&ws_c, &ch1, 0, 100);
1258 assert!(ws_c_ch_1.is_empty(), "non-existent workspace returns empty");
1259 }
1260
1261 #[test]
1262 fn replay_from_cursor_returns_only_newer_events() {
1263 let (bus, _td) = test_bus();
1264 let pid = std::process::id();
1265 let ws = &format!("ws-replay-{pid}");
1266 let ch = &format!("ch-replay-{pid}");
1267
1268 let ev1 = bus
1269 .append(
1270 ws,
1271 ch,
1272 &ContextEventKindV1::ToolCallRecorded,
1273 None,
1274 serde_json::json!({"seq":1}),
1275 )
1276 .unwrap();
1277 let ev2 = bus
1278 .append(
1279 ws,
1280 ch,
1281 &ContextEventKindV1::SessionMutated,
1282 None,
1283 serde_json::json!({"seq":2}),
1284 )
1285 .unwrap();
1286 let _ev3 = bus
1287 .append(
1288 ws,
1289 ch,
1290 &ContextEventKindV1::GraphBuilt,
1291 None,
1292 serde_json::json!({"seq":3}),
1293 )
1294 .unwrap();
1295
1296 let from_cursor = bus.read(ws, ch, ev2.id, 100);
1297 assert_eq!(from_cursor.len(), 1, "only events after cursor");
1298 assert_eq!(from_cursor[0].kind, "graph_built");
1299
1300 let from_first = bus.read(ws, ch, ev1.id, 100);
1301 assert_eq!(from_first.len(), 2, "events after first");
1302
1303 let from_zero = bus.read(ws, ch, 0, 100);
1304 assert_eq!(from_zero.len(), 3, "all events from zero");
1305 }
1306
1307 #[test]
1308 fn directed_visibility_survives_restart_and_filters_other_agents() {
1309 let td = tempdir().expect("tempdir");
1310 let path = td.path().join("directed-context-os.db");
1311 let bus = ContextBus::open_at(path.clone());
1312 let targets = vec!["agent-a".to_string()];
1313 let event = bus
1314 .append_directed(
1315 "ws",
1316 "ch",
1317 &ContextEventKindV1::SessionMutated,
1318 Some("lead"),
1319 serde_json::json!({"decision":"keep"}),
1320 targets.clone(),
1321 )
1322 .expect("directed event");
1323 assert_eq!(event.target_agents, Some(targets.clone()));
1324 drop(bus);
1325
1326 let reopened = ContextBus::open_at(path);
1327 let replayed = reopened.read("ws", "ch", 0, 10);
1328 assert_eq!(replayed.len(), 1);
1329 assert_eq!(replayed[0].target_agents, Some(targets));
1330 assert!(
1331 TopicFilter {
1332 agent_id: Some("agent-a".to_string()),
1333 ..TopicFilter::default()
1334 }
1335 .matches(&replayed[0])
1336 );
1337 assert!(
1338 !TopicFilter {
1339 agent_id: Some("agent-b".to_string()),
1340 ..TopicFilter::default()
1341 }
1342 .matches(&replayed[0])
1343 );
1344 }
1345
1346 #[test]
1347 fn legacy_schema_migrates_without_turning_events_into_directed_events() {
1348 let td = tempdir().expect("tempdir");
1349 let path = td.path().join("legacy-context-os.db");
1350 {
1351 let conn = Connection::open(&path).expect("legacy db");
1352 conn.execute_batch(
1353 "CREATE TABLE context_events (
1354 id INTEGER PRIMARY KEY AUTOINCREMENT,
1355 workspace_id TEXT NOT NULL,
1356 channel_id TEXT NOT NULL,
1357 kind TEXT NOT NULL,
1358 actor TEXT,
1359 timestamp TEXT NOT NULL,
1360 payload_json TEXT NOT NULL,
1361 version INTEGER NOT NULL DEFAULT 0,
1362 parent_id INTEGER,
1363 delivery_id TEXT
1364 );
1365 INSERT INTO context_events
1366 (workspace_id, channel_id, kind, timestamp, payload_json)
1367 VALUES ('ws', 'ch', 'knowledge_remembered',
1368 '2026-01-01T00:00:00Z', '{}');",
1369 )
1370 .expect("legacy schema");
1371 }
1372
1373 let bus = ContextBus::open_at(path);
1374 let replayed = bus.read("ws", "ch", 0, 10);
1375 assert_eq!(replayed.len(), 1);
1376 assert_eq!(replayed[0].target_agents, None);
1377 }
1378
1379 #[test]
1380 fn broadcast_subscriber_receives_events() {
1381 let (bus, _td) = test_bus();
1382 let mut rx = bus.subscribe("ws", "ch").expect("subscribe should succeed");
1383
1384 let ev = bus
1385 .append(
1386 "ws",
1387 "ch",
1388 &ContextEventKindV1::ProofAdded,
1389 Some("verifier"),
1390 serde_json::json!({"proof":"hash"}),
1391 )
1392 .unwrap();
1393
1394 let received = rx.try_recv().expect("subscriber should receive event");
1395 assert_eq!(received.id, ev.id);
1396 assert_eq!(received.kind, "proof_added");
1397 assert_eq!(received.actor.as_deref(), Some("verifier"));
1398 }
1399
1400 #[test]
1401 fn event_kind_parser_roundtrips_all_v1_kinds() {
1402 for kind in [
1403 ContextEventKindV1::ToolCallRecorded,
1404 ContextEventKindV1::SessionMutated,
1405 ContextEventKindV1::KnowledgeRemembered,
1406 ContextEventKindV1::ArtifactStored,
1407 ContextEventKindV1::GraphBuilt,
1408 ContextEventKindV1::ProofAdded,
1409 ContextEventKindV1::ProvenanceCheckpoint,
1410 ContextEventKindV1::ProvenanceEdit,
1411 ContextEventKindV1::HealthChanged,
1412 ] {
1413 assert_eq!(ContextEventKindV1::parse(kind.as_str()), kind);
1414 }
1415 }
1416}
1417