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
11const MAX_READ_CONNS: usize = 4;
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "snake_case")]
15pub enum ContextEventKindV1 {
16 ToolCallRecorded,
17 SessionMutated,
18 KnowledgeRemembered,
19 ArtifactStored,
20 GraphBuilt,
21 ProofAdded,
22}
23
24impl ContextEventKindV1 {
25 pub fn as_str(&self) -> &'static str {
26 match self {
27 Self::ToolCallRecorded => "tool_call_recorded",
28 Self::SessionMutated => "session_mutated",
29 Self::KnowledgeRemembered => "knowledge_remembered",
30 Self::ArtifactStored => "artifact_stored",
31 Self::GraphBuilt => "graph_built",
32 Self::ProofAdded => "proof_added",
33 }
34 }
35
36 pub fn parse(s: &str) -> Self {
37 match s.trim().to_lowercase().as_str() {
38 "tool_call_recorded" => Self::ToolCallRecorded,
39 "session_mutated" => Self::SessionMutated,
40 "knowledge_remembered" => Self::KnowledgeRemembered,
41 "artifact_stored" => Self::ArtifactStored,
42 "graph_built" => Self::GraphBuilt,
43 "proof_added" => Self::ProofAdded,
44 other => {
45 tracing::warn!(
46 "unknown ContextEventKind '{other}', defaulting to ToolCallRecorded"
47 );
48 Self::ToolCallRecorded
49 }
50 }
51 }
52
53 pub fn consistency_level(&self) -> ConsistencyLevel {
59 match self {
60 Self::ToolCallRecorded | Self::GraphBuilt => ConsistencyLevel::Local,
61 Self::KnowledgeRemembered | Self::ArtifactStored => ConsistencyLevel::Eventual,
62 Self::SessionMutated | Self::ProofAdded => ConsistencyLevel::Strong,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum ConsistencyLevel {
72 Local = 0,
74 Eventual = 1,
76 Strong = 2,
78}
79
80impl ConsistencyLevel {
81 pub fn as_str(&self) -> &'static str {
82 match self {
83 Self::Local => "local",
84 Self::Eventual => "eventual",
85 Self::Strong => "strong",
86 }
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub struct ContextEventV1 {
93 pub id: i64,
94 pub workspace_id: String,
95 pub channel_id: String,
96 pub kind: String,
97 pub actor: Option<String>,
98 pub timestamp: DateTime<Utc>,
99 pub version: i64,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub parent_id: Option<i64>,
102 pub consistency_level: String,
103 pub payload: Value,
104 #[serde(skip_serializing_if = "Option::is_none", default)]
105 pub target_agents: Option<Vec<String>>,
106}
107
108impl ContextEventV1 {
109 pub fn consistency(&self) -> ConsistencyLevel {
110 ContextEventKindV1::parse(&self.kind).consistency_level()
111 }
112
113 pub fn is_visible_to_agent(&self, agent_id: &str) -> bool {
114 match &self.target_agents {
115 None => true,
116 Some(targets) => targets.iter().any(|t| t == agent_id),
117 }
118 }
119}
120
121#[derive(Debug, Clone, Default)]
124pub struct TopicFilter {
125 pub kinds: Option<Vec<ContextEventKindV1>>,
126 pub actors: Option<Vec<String>>,
127 pub min_consistency: Option<ConsistencyLevel>,
128 pub agent_id: Option<String>,
129}
130
131impl TopicFilter {
132 pub fn kinds(kind_strs: &[&str]) -> Self {
134 Self {
135 kinds: Some(
136 kind_strs
137 .iter()
138 .map(|s| ContextEventKindV1::parse(s))
139 .collect(),
140 ),
141 ..Self::default()
142 }
143 }
144
145 pub fn matches(&self, event: &ContextEventV1) -> bool {
146 if let Some(ref kinds) = self.kinds {
147 let parsed = ContextEventKindV1::parse(&event.kind);
148 if !kinds.contains(&parsed) {
149 return false;
150 }
151 }
152 if let Some(ref actors) = self.actors {
153 match &event.actor {
154 Some(actor) if actors.iter().any(|a| a == actor) => {}
155 Some(_) | None => return false,
156 }
157 }
158 if let Some(min) = self.min_consistency
159 && event.consistency() < min
160 {
161 return false;
162 }
163 if let Some(ref aid) = self.agent_id
164 && !event.is_visible_to_agent(aid)
165 {
166 return false;
167 }
168 true
169 }
170}
171
172fn event_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ContextEventV1> {
173 let ts_str: String = row.get(5)?;
174 let ts = DateTime::parse_from_rfc3339(&ts_str)
175 .map_or_else(|_| Utc::now(), |d| d.with_timezone(&Utc));
176 let payload_str: String = row.get(6)?;
177 let payload: Value = serde_json::from_str(&payload_str).unwrap_or(Value::Null);
178 let kind_str: String = row.get(3)?;
179 let cl = ContextEventKindV1::parse(&kind_str)
180 .consistency_level()
181 .as_str()
182 .to_string();
183 Ok(ContextEventV1 {
184 id: row.get(0)?,
185 workspace_id: row.get(1)?,
186 channel_id: row.get(2)?,
187 kind: kind_str,
188 actor: row.get::<_, Option<String>>(4)?,
189 timestamp: ts,
190 version: row.get::<_, i64>(7).unwrap_or(0),
191 parent_id: row.get::<_, Option<i64>>(8).ok().flatten(),
192 consistency_level: cl,
193 payload,
194 target_agents: None,
195 })
196}
197
198#[derive(Clone)]
199pub struct ContextBus {
200 inner: Arc<Inner>,
201}
202
203const STREAM_CHANNEL_SIZE: usize = 256;
204const MAX_SUBSCRIBERS_PER_CHANNEL: usize = 64;
205const MAX_VERSION_CACHE_ENTRIES: usize = 4096;
209
210struct Inner {
211 write_conn: Mutex<Connection>,
212 read_pool: Mutex<Vec<Connection>>,
213 streams: Mutex<HashMap<String, broadcast::Sender<ContextEventV1>>>,
214 version_cache: Mutex<HashMap<String, i64>>,
215 db_path: PathBuf,
216}
217
218impl Inner {
219 fn open_read_conn(path: &PathBuf) -> Connection {
220 if let Some(parent) = path.parent() {
226 let _ = std::fs::create_dir_all(parent);
227 }
228 let conn = Connection::open(path).expect("open read context-os db");
229 let _ = conn.busy_timeout(std::time::Duration::from_secs(5));
230 let _ = conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA query_only=ON;");
231 conn
232 }
233
234 fn take_read_conn(&self) -> Connection {
235 self.read_pool
236 .lock()
237 .unwrap_or_else(std::sync::PoisonError::into_inner)
238 .pop()
239 .unwrap_or_else(|| Self::open_read_conn(&self.db_path))
240 }
241
242 fn return_read_conn(&self, conn: Connection) {
243 let mut pool = self
244 .read_pool
245 .lock()
246 .unwrap_or_else(std::sync::PoisonError::into_inner);
247 if pool.len() < MAX_READ_CONNS {
248 pool.push(conn);
249 }
250 }
251
252 fn stream_key(workspace_id: &str, channel_id: &str) -> String {
253 format!("{workspace_id}\0{channel_id}")
254 }
255
256 fn next_version(&self, workspace_id: &str, channel_id: &str) -> i64 {
257 let key = Self::stream_key(workspace_id, channel_id);
258
259 {
260 let mut cache = self
261 .version_cache
262 .lock()
263 .unwrap_or_else(std::sync::PoisonError::into_inner);
264 if let Some(v) = cache.get_mut(&key) {
265 *v += 1;
266 return *v;
267 }
268 }
269
270 let conn = self.take_read_conn();
271 let v: i64 = conn
272 .query_row(
273 "SELECT COALESCE(MAX(version), 0) FROM context_events WHERE workspace_id = ?1 AND channel_id = ?2",
274 params![workspace_id, channel_id],
275 |row| row.get(0),
276 )
277 .unwrap_or(0);
278 self.return_read_conn(conn);
279
280 let mut cache = self
281 .version_cache
282 .lock()
283 .unwrap_or_else(std::sync::PoisonError::into_inner);
284 if cache.len() > MAX_VERSION_CACHE_ENTRIES {
285 cache.clear();
287 }
288 let entry = cache.entry(key).or_insert(v);
289 *entry = (*entry).max(v) + 1;
290 *entry
291 }
292}
293
294impl Default for ContextBus {
295 fn default() -> Self {
296 Self::new()
297 }
298}
299
300impl ContextBus {
301 pub fn new() -> Self {
302 let path = default_db_path();
303 Self::open_at(path)
304 }
305
306 fn open_at(path: PathBuf) -> Self {
307 if let Some(parent) = path.parent() {
308 let _ = std::fs::create_dir_all(parent);
309 }
310 let conn = Connection::open(&path).expect("open context-os db");
311 let _ = conn.busy_timeout(std::time::Duration::from_secs(5));
312 conn.execute_batch(
313 "PRAGMA journal_mode=WAL;
314 CREATE TABLE IF NOT EXISTS context_events (
315 id INTEGER PRIMARY KEY AUTOINCREMENT,
316 workspace_id TEXT NOT NULL,
317 channel_id TEXT NOT NULL,
318 kind TEXT NOT NULL,
319 actor TEXT,
320 timestamp TEXT NOT NULL,
321 payload_json TEXT NOT NULL,
322 version INTEGER NOT NULL DEFAULT 0,
323 parent_id INTEGER
324 );
325 CREATE INDEX IF NOT EXISTS idx_context_events_stream
326 ON context_events(workspace_id, channel_id, id);",
327 )
328 .expect("init context-os db");
329
330 let _ = conn.execute_batch(
331 "ALTER TABLE context_events ADD COLUMN version INTEGER NOT NULL DEFAULT 0;",
332 );
333 let _ = conn.execute_batch("ALTER TABLE context_events ADD COLUMN parent_id INTEGER;");
334
335 let _ = conn.execute_batch(
336 "CREATE VIRTUAL TABLE IF NOT EXISTS context_events_fts USING fts5(
337 payload_text,
338 content=context_events,
339 content_rowid=id
340 );",
341 );
342
343 let mut read_conns = Vec::with_capacity(MAX_READ_CONNS);
344 for _ in 0..MAX_READ_CONNS {
345 read_conns.push(Inner::open_read_conn(&path));
346 }
347
348 Self {
349 inner: Arc::new(Inner {
350 write_conn: Mutex::new(conn),
351 read_pool: Mutex::new(read_conns),
352 streams: Mutex::new(HashMap::new()),
353 version_cache: Mutex::new(HashMap::new()),
354 db_path: path,
355 }),
356 }
357 }
358
359 pub fn subscribe(
360 &self,
361 workspace_id: &str,
362 channel_id: &str,
363 ) -> Option<broadcast::Receiver<ContextEventV1>> {
364 let key = Inner::stream_key(workspace_id, channel_id);
365 let mut streams = self
366 .inner
367 .streams
368 .lock()
369 .unwrap_or_else(std::sync::PoisonError::into_inner);
370 streams.retain(|_, tx| tx.receiver_count() > 0);
375 let tx = streams
376 .entry(key)
377 .or_insert_with(|| broadcast::channel(STREAM_CHANNEL_SIZE).0);
378 if tx.receiver_count() >= MAX_SUBSCRIBERS_PER_CHANNEL {
379 tracing::warn!(
380 "SSE subscriber cap ({MAX_SUBSCRIBERS_PER_CHANNEL}) reached for {workspace_id}/{channel_id} — rejecting"
381 );
382 return None;
383 }
384 Some(tx.subscribe())
385 }
386
387 pub fn subscribe_filtered(
390 &self,
391 workspace_id: &str,
392 channel_id: &str,
393 filter: TopicFilter,
394 ) -> Option<FilteredSubscription> {
395 let rx = self.subscribe(workspace_id, channel_id)?;
396 Some(FilteredSubscription { rx, filter })
397 }
398
399 pub fn append(
400 &self,
401 workspace_id: &str,
402 channel_id: &str,
403 kind: &ContextEventKindV1,
404 actor: Option<&str>,
405 payload: Value,
406 ) -> Option<ContextEventV1> {
407 self.append_with_parent(workspace_id, channel_id, kind, actor, payload, None)
408 }
409
410 pub fn append_with_parent(
411 &self,
412 workspace_id: &str,
413 channel_id: &str,
414 kind: &ContextEventKindV1,
415 actor: Option<&str>,
416 payload: Value,
417 parent_id: Option<i64>,
418 ) -> Option<ContextEventV1> {
419 let ev = self.insert_event(
420 workspace_id,
421 channel_id,
422 kind,
423 actor,
424 payload,
425 parent_id,
426 None,
427 )?;
428 self.broadcast_event(&ev);
429 Some(ev)
430 }
431
432 pub fn append_directed(
435 &self,
436 workspace_id: &str,
437 channel_id: &str,
438 kind: &ContextEventKindV1,
439 actor: Option<&str>,
440 payload: Value,
441 target_agents: Vec<String>,
442 ) -> Option<ContextEventV1> {
443 let ev = self.insert_event(
444 workspace_id,
445 channel_id,
446 kind,
447 actor,
448 payload,
449 None,
450 Some(target_agents),
451 )?;
452 self.broadcast_event(&ev);
453 Some(ev)
454 }
455
456 fn insert_event(
457 &self,
458 workspace_id: &str,
459 channel_id: &str,
460 kind: &ContextEventKindV1,
461 actor: Option<&str>,
462 payload: Value,
463 parent_id: Option<i64>,
464 target_agents: Option<Vec<String>>,
465 ) -> Option<ContextEventV1> {
466 let ts = Utc::now();
467 let payload_json = payload.to_string();
468
469 let (id, version) = {
470 let Ok(conn) = self.inner.write_conn.lock() else {
471 return None;
472 };
473 let version = self.inner.next_version(workspace_id, channel_id);
474
475 let result: Result<(i64, i64), rusqlite::Error> = conn
476 .execute_batch("BEGIN IMMEDIATE")
477 .and_then(|()| {
478 conn.execute(
479 "INSERT INTO context_events (workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id)
480 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
481 params![
482 workspace_id,
483 channel_id,
484 kind.as_str(),
485 actor.map(str::to_string),
486 ts.to_rfc3339(),
487 payload_json,
488 version,
489 parent_id,
490 ],
491 )?;
492 let rowid = conn.last_insert_rowid();
493 if let Err(e) = conn.execute(
494 "INSERT INTO context_events_fts(rowid, payload_text) VALUES (?1, ?2)",
495 params![rowid, payload_json],
496 ) {
497 tracing::warn!("FTS insert failed for event {rowid}: {e}");
498 }
499 conn.execute_batch("COMMIT")?;
500 Ok((rowid, version))
501 });
502
503 match result {
504 Ok(pair) => pair,
505 Err(e) => {
506 tracing::warn!("context bus append failed: {e}");
507 let _ = conn.execute_batch("ROLLBACK");
508 return None;
509 }
510 }
511 };
512
513 Some(ContextEventV1 {
514 id,
515 workspace_id: workspace_id.to_string(),
516 channel_id: channel_id.to_string(),
517 consistency_level: kind.consistency_level().as_str().to_string(),
518 kind: kind.as_str().to_string(),
519 actor: actor.map(str::to_string),
520 timestamp: ts,
521 version,
522 parent_id,
523 payload,
524 target_agents,
525 })
526 }
527
528 fn broadcast_event(&self, ev: &ContextEventV1) {
529 let key = Inner::stream_key(&ev.workspace_id, &ev.channel_id);
530 let tx = self
531 .inner
532 .streams
533 .lock()
534 .unwrap_or_else(std::sync::PoisonError::into_inner)
535 .get(&key)
536 .cloned();
537 if let Some(tx) = tx {
538 let _ = tx.send(ev.clone());
539 }
540 }
541
542 pub fn read(
543 &self,
544 workspace_id: &str,
545 channel_id: &str,
546 since: i64,
547 limit: usize,
548 ) -> Vec<ContextEventV1> {
549 let limit = limit.clamp(1, 1000) as i64;
550 let conn = self.inner.take_read_conn();
551 let result = (|| {
552 let mut stmt = conn.prepare(
553 "SELECT id, workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id
554 FROM context_events
555 WHERE workspace_id = ?1 AND channel_id = ?2 AND id > ?3
556 ORDER BY id ASC
557 LIMIT ?4",
558 ).ok()?;
559 let rows = stmt
560 .query_map(
561 params![workspace_id, channel_id, since, limit],
562 event_from_row,
563 )
564 .ok()?;
565 Some(rows.flatten().collect::<Vec<_>>())
566 })();
567 self.inner.return_read_conn(conn);
568 result.unwrap_or_default()
569 }
570
571 pub fn recent_by_kind(
573 &self,
574 workspace_id: &str,
575 channel_id: &str,
576 kind: &str,
577 limit: usize,
578 ) -> Vec<ContextEventV1> {
579 let limit = limit.clamp(1, 100) as i64;
580 let conn = self.inner.take_read_conn();
581 let result = (|| {
582 let mut stmt = conn.prepare(
583 "SELECT id, workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id
584 FROM context_events
585 WHERE workspace_id = ?1 AND channel_id = ?2 AND kind = ?3
586 ORDER BY id DESC
587 LIMIT ?4",
588 ).ok()?;
589 let rows = stmt
590 .query_map(
591 params![workspace_id, channel_id, kind, limit],
592 event_from_row,
593 )
594 .ok()?;
595 Some(rows.flatten().collect::<Vec<_>>())
596 })();
597 self.inner.return_read_conn(conn);
598 result.unwrap_or_default()
599 }
600
601 pub fn search(
603 &self,
604 workspace_id: &str,
605 channel_id: Option<&str>,
606 query: &str,
607 limit: usize,
608 ) -> Vec<ContextEventV1> {
609 let limit = limit.clamp(1, 100) as i64;
610 let conn = self.inner.take_read_conn();
611 let result =
612 if let Some(ch) = channel_id {
613 (|| {
614 let mut stmt = conn.prepare(
615 "SELECT e.id, e.workspace_id, e.channel_id, e.kind, e.actor, e.timestamp,
616 e.payload_json, e.version, e.parent_id
617 FROM context_events e
618 JOIN context_events_fts f ON e.id = f.rowid
619 WHERE f.payload_text MATCH ?1 AND e.workspace_id = ?2 AND e.channel_id = ?3
620 ORDER BY f.rank
621 LIMIT ?4",
622 ).ok()?;
623 let rows = stmt
624 .query_map(params![query, workspace_id, ch, limit], event_from_row)
625 .ok()?;
626 Some(rows.flatten().collect::<Vec<_>>())
627 })()
628 } else {
629 (|| {
630 let mut stmt = conn.prepare(
631 "SELECT e.id, e.workspace_id, e.channel_id, e.kind, e.actor, e.timestamp,
632 e.payload_json, e.version, e.parent_id
633 FROM context_events e
634 JOIN context_events_fts f ON e.id = f.rowid
635 WHERE f.payload_text MATCH ?1 AND e.workspace_id = ?2
636 ORDER BY f.rank
637 LIMIT ?3",
638 ).ok()?;
639 let rows = stmt
640 .query_map(params![query, workspace_id, limit], event_from_row)
641 .ok()?;
642 Some(rows.flatten().collect::<Vec<_>>())
643 })()
644 };
645 self.inner.return_read_conn(conn);
646 result.unwrap_or_default()
647 }
648
649 pub fn lineage(
652 &self,
653 event_id: i64,
654 workspace_id: &str,
655 max_depth: usize,
656 ) -> Vec<ContextEventV1> {
657 let max_depth = max_depth.clamp(1, 50);
658 let conn = self.inner.take_read_conn();
659 let mut chain = Vec::new();
660 let mut current_id = Some(event_id);
661
662 for _ in 0..max_depth {
663 let Some(id) = current_id else {
664 break;
665 };
666 let ev = conn.query_row(
667 "SELECT id, workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id
668 FROM context_events WHERE id = ?1 AND workspace_id = ?2",
669 params![id, workspace_id],
670 event_from_row,
671 );
672 match ev {
673 Ok(ev) => {
674 current_id = ev.parent_id;
675 chain.push(ev);
676 }
677 Err(_) => break,
678 }
679 }
680 self.inner.return_read_conn(conn);
681 chain
682 }
683
684 pub fn latest_id(&self, workspace_id: &str, channel_id: &str) -> i64 {
686 let conn = self.inner.take_read_conn();
687 let result = conn
688 .query_row(
689 "SELECT COALESCE(MAX(id), 0) FROM context_events WHERE workspace_id = ?1 AND channel_id = ?2",
690 params![workspace_id, channel_id],
691 |row| row.get(0),
692 )
693 .unwrap_or(0);
694 self.inner.return_read_conn(conn);
695 result
696 }
697}
698
699pub struct FilteredSubscription {
701 pub rx: broadcast::Receiver<ContextEventV1>,
702 pub filter: TopicFilter,
703}
704
705impl FilteredSubscription {
706 pub async fn recv_filtered(&mut self) -> Result<ContextEventV1, broadcast::error::RecvError> {
709 loop {
710 let ev = self.rx.recv().await?;
711 if self.filter.matches(&ev) {
712 return Ok(ev);
713 }
714 }
715 }
716}
717
718fn default_db_path() -> PathBuf {
719 let data = crate::core::data_dir::lean_ctx_data_dir().unwrap_or_else(|_| PathBuf::from("."));
720 data.join("context-os").join("context-os.db")
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726 use tempfile::tempdir;
727
728 fn test_bus() -> (ContextBus, tempfile::TempDir) {
729 let td = tempdir().expect("tempdir");
730 let bus = ContextBus::open_at(td.path().join("test-context-os.db"));
731 (bus, td)
732 }
733
734 #[test]
735 fn append_and_read_roundtrip() {
736 let (bus, _td) = test_bus();
737 let ev = bus
738 .append(
739 "ws",
740 "ch",
741 &ContextEventKindV1::ToolCallRecorded,
742 Some("agent"),
743 serde_json::json!({"tool":"ctx_read"}),
744 )
745 .expect("append");
746 let got = bus.read("ws", "ch", ev.id - 1, 10);
747 assert!(got.iter().any(|e| e.id == ev.id));
748 }
749
750 #[test]
751 fn multi_client_concurrent_appends_have_deterministic_ordering() {
752 let (bus, _td) = test_bus();
753 let bus = Arc::new(bus);
754 let n_clients = 5;
755 let n_events_per_client = 20;
756 let ws = format!("ws-concurrent-{}", std::process::id());
757 let ch = format!("ch-concurrent-{}", std::process::id());
758
759 let mut handles = vec![];
760 for client_idx in 0..n_clients {
761 let bus = Arc::clone(&bus);
762 let ws = ws.clone();
763 let ch = ch.clone();
764 handles.push(std::thread::spawn(move || {
765 let agent = format!("agent-{client_idx}");
766 for event_idx in 0..n_events_per_client {
767 bus.append(
768 &ws,
769 &ch,
770 &ContextEventKindV1::ToolCallRecorded,
771 Some(&agent),
772 serde_json::json!({"client": client_idx, "seq": event_idx}),
773 );
774 }
775 }));
776 }
777
778 for h in handles {
779 h.join().unwrap();
780 }
781
782 let all = bus.read(&ws, &ch, 0, 1000);
783 assert_eq!(
784 all.len(),
785 n_clients * n_events_per_client,
786 "all events should be persisted"
787 );
788
789 let ids: Vec<i64> = all.iter().map(|e| e.id).collect();
790 let mut sorted = ids.clone();
791 sorted.sort_unstable();
792 assert_eq!(ids, sorted, "events must be in strictly ascending ID order");
793
794 for win in ids.windows(2) {
795 assert!(
796 win[1] > win[0],
797 "IDs must be strictly monotonic (no gaps from concurrent access)"
798 );
799 }
800 }
801
802 #[test]
803 fn workspace_channel_isolation() {
804 let (bus, _td) = test_bus();
805 let pid = std::process::id();
806 let ws_a = format!("ws-iso-a-{pid}");
807 let ws_b = format!("ws-iso-b-{pid}");
808 let ws_c = format!("ws-iso-c-{pid}");
809 let ch1 = format!("ch-iso-1-{pid}");
810 let ch2 = format!("ch-iso-2-{pid}");
811
812 bus.append(
813 &ws_a,
814 &ch1,
815 &ContextEventKindV1::SessionMutated,
816 Some("agent-a"),
817 serde_json::json!({"ws":"a","ch":"1"}),
818 );
819 bus.append(
820 &ws_a,
821 &ch2,
822 &ContextEventKindV1::KnowledgeRemembered,
823 Some("agent-a"),
824 serde_json::json!({"ws":"a","ch":"2"}),
825 );
826 bus.append(
827 &ws_b,
828 &ch1,
829 &ContextEventKindV1::ArtifactStored,
830 Some("agent-b"),
831 serde_json::json!({"ws":"b","ch":"1"}),
832 );
833
834 let ws_a_ch_1 = bus.read(&ws_a, &ch1, 0, 100);
835 assert_eq!(ws_a_ch_1.len(), 1);
836 assert_eq!(ws_a_ch_1[0].kind, "session_mutated");
837
838 let ws_a_ch_2 = bus.read(&ws_a, &ch2, 0, 100);
839 assert_eq!(ws_a_ch_2.len(), 1);
840 assert_eq!(ws_a_ch_2[0].kind, "knowledge_remembered");
841
842 let ws_b_ch_1 = bus.read(&ws_b, &ch1, 0, 100);
843 assert_eq!(ws_b_ch_1.len(), 1);
844 assert_eq!(ws_b_ch_1[0].kind, "artifact_stored");
845
846 let ws_c_ch_1 = bus.read(&ws_c, &ch1, 0, 100);
847 assert!(ws_c_ch_1.is_empty(), "non-existent workspace returns empty");
848 }
849
850 #[test]
851 fn replay_from_cursor_returns_only_newer_events() {
852 let (bus, _td) = test_bus();
853 let pid = std::process::id();
854 let ws = &format!("ws-replay-{pid}");
855 let ch = &format!("ch-replay-{pid}");
856
857 let ev1 = bus
858 .append(
859 ws,
860 ch,
861 &ContextEventKindV1::ToolCallRecorded,
862 None,
863 serde_json::json!({"seq":1}),
864 )
865 .unwrap();
866 let ev2 = bus
867 .append(
868 ws,
869 ch,
870 &ContextEventKindV1::SessionMutated,
871 None,
872 serde_json::json!({"seq":2}),
873 )
874 .unwrap();
875 let _ev3 = bus
876 .append(
877 ws,
878 ch,
879 &ContextEventKindV1::GraphBuilt,
880 None,
881 serde_json::json!({"seq":3}),
882 )
883 .unwrap();
884
885 let from_cursor = bus.read(ws, ch, ev2.id, 100);
886 assert_eq!(from_cursor.len(), 1, "only events after cursor");
887 assert_eq!(from_cursor[0].kind, "graph_built");
888
889 let from_first = bus.read(ws, ch, ev1.id, 100);
890 assert_eq!(from_first.len(), 2, "events after first");
891
892 let from_zero = bus.read(ws, ch, 0, 100);
893 assert_eq!(from_zero.len(), 3, "all events from zero");
894 }
895
896 #[test]
897 fn broadcast_subscriber_receives_events() {
898 let (bus, _td) = test_bus();
899 let mut rx = bus.subscribe("ws", "ch").expect("subscribe should succeed");
900
901 let ev = bus
902 .append(
903 "ws",
904 "ch",
905 &ContextEventKindV1::ProofAdded,
906 Some("verifier"),
907 serde_json::json!({"proof":"hash"}),
908 )
909 .unwrap();
910
911 let received = rx.try_recv().expect("subscriber should receive event");
912 assert_eq!(received.id, ev.id);
913 assert_eq!(received.kind, "proof_added");
914 assert_eq!(received.actor.as_deref(), Some("verifier"));
915 }
916}