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 let conn = Connection::open(path).expect("open read context-os db");
221 let _ = conn.busy_timeout(std::time::Duration::from_secs(5));
222 let _ = conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA query_only=ON;");
223 conn
224 }
225
226 fn take_read_conn(&self) -> Connection {
227 self.read_pool
228 .lock()
229 .unwrap_or_else(std::sync::PoisonError::into_inner)
230 .pop()
231 .unwrap_or_else(|| Self::open_read_conn(&self.db_path))
232 }
233
234 fn return_read_conn(&self, conn: Connection) {
235 let mut pool = self
236 .read_pool
237 .lock()
238 .unwrap_or_else(std::sync::PoisonError::into_inner);
239 if pool.len() < MAX_READ_CONNS {
240 pool.push(conn);
241 }
242 }
243
244 fn stream_key(workspace_id: &str, channel_id: &str) -> String {
245 format!("{workspace_id}\0{channel_id}")
246 }
247
248 fn next_version(&self, workspace_id: &str, channel_id: &str) -> i64 {
249 let key = Self::stream_key(workspace_id, channel_id);
250
251 {
252 let mut cache = self
253 .version_cache
254 .lock()
255 .unwrap_or_else(std::sync::PoisonError::into_inner);
256 if let Some(v) = cache.get_mut(&key) {
257 *v += 1;
258 return *v;
259 }
260 }
261
262 let conn = self.take_read_conn();
263 let v: i64 = conn
264 .query_row(
265 "SELECT COALESCE(MAX(version), 0) FROM context_events WHERE workspace_id = ?1 AND channel_id = ?2",
266 params![workspace_id, channel_id],
267 |row| row.get(0),
268 )
269 .unwrap_or(0);
270 self.return_read_conn(conn);
271
272 let mut cache = self
273 .version_cache
274 .lock()
275 .unwrap_or_else(std::sync::PoisonError::into_inner);
276 if cache.len() > MAX_VERSION_CACHE_ENTRIES {
277 cache.clear();
279 }
280 let entry = cache.entry(key).or_insert(v);
281 *entry = (*entry).max(v) + 1;
282 *entry
283 }
284}
285
286impl Default for ContextBus {
287 fn default() -> Self {
288 Self::new()
289 }
290}
291
292impl ContextBus {
293 pub fn new() -> Self {
294 let path = default_db_path();
295 Self::open_at(path)
296 }
297
298 fn open_at(path: PathBuf) -> Self {
299 if let Some(parent) = path.parent() {
300 let _ = std::fs::create_dir_all(parent);
301 }
302 let conn = Connection::open(&path).expect("open context-os db");
303 let _ = conn.busy_timeout(std::time::Duration::from_secs(5));
304 conn.execute_batch(
305 "PRAGMA journal_mode=WAL;
306 CREATE TABLE IF NOT EXISTS context_events (
307 id INTEGER PRIMARY KEY AUTOINCREMENT,
308 workspace_id TEXT NOT NULL,
309 channel_id TEXT NOT NULL,
310 kind TEXT NOT NULL,
311 actor TEXT,
312 timestamp TEXT NOT NULL,
313 payload_json TEXT NOT NULL,
314 version INTEGER NOT NULL DEFAULT 0,
315 parent_id INTEGER
316 );
317 CREATE INDEX IF NOT EXISTS idx_context_events_stream
318 ON context_events(workspace_id, channel_id, id);",
319 )
320 .expect("init context-os db");
321
322 let _ = conn.execute_batch(
323 "ALTER TABLE context_events ADD COLUMN version INTEGER NOT NULL DEFAULT 0;",
324 );
325 let _ = conn.execute_batch("ALTER TABLE context_events ADD COLUMN parent_id INTEGER;");
326
327 let _ = conn.execute_batch(
328 "CREATE VIRTUAL TABLE IF NOT EXISTS context_events_fts USING fts5(
329 payload_text,
330 content=context_events,
331 content_rowid=id
332 );",
333 );
334
335 let mut read_conns = Vec::with_capacity(MAX_READ_CONNS);
336 for _ in 0..MAX_READ_CONNS {
337 read_conns.push(Inner::open_read_conn(&path));
338 }
339
340 Self {
341 inner: Arc::new(Inner {
342 write_conn: Mutex::new(conn),
343 read_pool: Mutex::new(read_conns),
344 streams: Mutex::new(HashMap::new()),
345 version_cache: Mutex::new(HashMap::new()),
346 db_path: path,
347 }),
348 }
349 }
350
351 pub fn subscribe(
352 &self,
353 workspace_id: &str,
354 channel_id: &str,
355 ) -> Option<broadcast::Receiver<ContextEventV1>> {
356 let key = Inner::stream_key(workspace_id, channel_id);
357 let mut streams = self
358 .inner
359 .streams
360 .lock()
361 .unwrap_or_else(std::sync::PoisonError::into_inner);
362 streams.retain(|_, tx| tx.receiver_count() > 0);
367 let tx = streams
368 .entry(key)
369 .or_insert_with(|| broadcast::channel(STREAM_CHANNEL_SIZE).0);
370 if tx.receiver_count() >= MAX_SUBSCRIBERS_PER_CHANNEL {
371 tracing::warn!(
372 "SSE subscriber cap ({MAX_SUBSCRIBERS_PER_CHANNEL}) reached for {workspace_id}/{channel_id} — rejecting"
373 );
374 return None;
375 }
376 Some(tx.subscribe())
377 }
378
379 pub fn subscribe_filtered(
382 &self,
383 workspace_id: &str,
384 channel_id: &str,
385 filter: TopicFilter,
386 ) -> Option<FilteredSubscription> {
387 let rx = self.subscribe(workspace_id, channel_id)?;
388 Some(FilteredSubscription { rx, filter })
389 }
390
391 pub fn append(
392 &self,
393 workspace_id: &str,
394 channel_id: &str,
395 kind: &ContextEventKindV1,
396 actor: Option<&str>,
397 payload: Value,
398 ) -> Option<ContextEventV1> {
399 self.append_with_parent(workspace_id, channel_id, kind, actor, payload, None)
400 }
401
402 pub fn append_with_parent(
403 &self,
404 workspace_id: &str,
405 channel_id: &str,
406 kind: &ContextEventKindV1,
407 actor: Option<&str>,
408 payload: Value,
409 parent_id: Option<i64>,
410 ) -> Option<ContextEventV1> {
411 let ev = self.insert_event(
412 workspace_id,
413 channel_id,
414 kind,
415 actor,
416 payload,
417 parent_id,
418 None,
419 )?;
420 self.broadcast_event(&ev);
421 Some(ev)
422 }
423
424 pub fn append_directed(
427 &self,
428 workspace_id: &str,
429 channel_id: &str,
430 kind: &ContextEventKindV1,
431 actor: Option<&str>,
432 payload: Value,
433 target_agents: Vec<String>,
434 ) -> Option<ContextEventV1> {
435 let ev = self.insert_event(
436 workspace_id,
437 channel_id,
438 kind,
439 actor,
440 payload,
441 None,
442 Some(target_agents),
443 )?;
444 self.broadcast_event(&ev);
445 Some(ev)
446 }
447
448 fn insert_event(
449 &self,
450 workspace_id: &str,
451 channel_id: &str,
452 kind: &ContextEventKindV1,
453 actor: Option<&str>,
454 payload: Value,
455 parent_id: Option<i64>,
456 target_agents: Option<Vec<String>>,
457 ) -> Option<ContextEventV1> {
458 let ts = Utc::now();
459 let payload_json = payload.to_string();
460
461 let (id, version) = {
462 let Ok(conn) = self.inner.write_conn.lock() else {
463 return None;
464 };
465 let version = self.inner.next_version(workspace_id, channel_id);
466
467 let result: Result<(i64, i64), rusqlite::Error> = conn
468 .execute_batch("BEGIN IMMEDIATE")
469 .and_then(|()| {
470 conn.execute(
471 "INSERT INTO context_events (workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id)
472 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
473 params![
474 workspace_id,
475 channel_id,
476 kind.as_str(),
477 actor.map(str::to_string),
478 ts.to_rfc3339(),
479 payload_json,
480 version,
481 parent_id,
482 ],
483 )?;
484 let rowid = conn.last_insert_rowid();
485 if let Err(e) = conn.execute(
486 "INSERT INTO context_events_fts(rowid, payload_text) VALUES (?1, ?2)",
487 params![rowid, payload_json],
488 ) {
489 tracing::warn!("FTS insert failed for event {rowid}: {e}");
490 }
491 conn.execute_batch("COMMIT")?;
492 Ok((rowid, version))
493 });
494
495 match result {
496 Ok(pair) => pair,
497 Err(e) => {
498 tracing::warn!("context bus append failed: {e}");
499 let _ = conn.execute_batch("ROLLBACK");
500 return None;
501 }
502 }
503 };
504
505 Some(ContextEventV1 {
506 id,
507 workspace_id: workspace_id.to_string(),
508 channel_id: channel_id.to_string(),
509 consistency_level: kind.consistency_level().as_str().to_string(),
510 kind: kind.as_str().to_string(),
511 actor: actor.map(str::to_string),
512 timestamp: ts,
513 version,
514 parent_id,
515 payload,
516 target_agents,
517 })
518 }
519
520 fn broadcast_event(&self, ev: &ContextEventV1) {
521 let key = Inner::stream_key(&ev.workspace_id, &ev.channel_id);
522 let tx = self
523 .inner
524 .streams
525 .lock()
526 .unwrap_or_else(std::sync::PoisonError::into_inner)
527 .get(&key)
528 .cloned();
529 if let Some(tx) = tx {
530 let _ = tx.send(ev.clone());
531 }
532 }
533
534 pub fn read(
535 &self,
536 workspace_id: &str,
537 channel_id: &str,
538 since: i64,
539 limit: usize,
540 ) -> Vec<ContextEventV1> {
541 let limit = limit.clamp(1, 1000) as i64;
542 let conn = self.inner.take_read_conn();
543 let result = (|| {
544 let mut stmt = conn.prepare(
545 "SELECT id, workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id
546 FROM context_events
547 WHERE workspace_id = ?1 AND channel_id = ?2 AND id > ?3
548 ORDER BY id ASC
549 LIMIT ?4",
550 ).ok()?;
551 let rows = stmt
552 .query_map(
553 params![workspace_id, channel_id, since, limit],
554 event_from_row,
555 )
556 .ok()?;
557 Some(rows.flatten().collect::<Vec<_>>())
558 })();
559 self.inner.return_read_conn(conn);
560 result.unwrap_or_default()
561 }
562
563 pub fn recent_by_kind(
565 &self,
566 workspace_id: &str,
567 channel_id: &str,
568 kind: &str,
569 limit: usize,
570 ) -> Vec<ContextEventV1> {
571 let limit = limit.clamp(1, 100) as i64;
572 let conn = self.inner.take_read_conn();
573 let result = (|| {
574 let mut stmt = conn.prepare(
575 "SELECT id, workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id
576 FROM context_events
577 WHERE workspace_id = ?1 AND channel_id = ?2 AND kind = ?3
578 ORDER BY id DESC
579 LIMIT ?4",
580 ).ok()?;
581 let rows = stmt
582 .query_map(
583 params![workspace_id, channel_id, kind, limit],
584 event_from_row,
585 )
586 .ok()?;
587 Some(rows.flatten().collect::<Vec<_>>())
588 })();
589 self.inner.return_read_conn(conn);
590 result.unwrap_or_default()
591 }
592
593 pub fn search(
595 &self,
596 workspace_id: &str,
597 channel_id: Option<&str>,
598 query: &str,
599 limit: usize,
600 ) -> Vec<ContextEventV1> {
601 let limit = limit.clamp(1, 100) as i64;
602 let conn = self.inner.take_read_conn();
603 let result =
604 if let Some(ch) = channel_id {
605 (|| {
606 let mut stmt = conn.prepare(
607 "SELECT e.id, e.workspace_id, e.channel_id, e.kind, e.actor, e.timestamp,
608 e.payload_json, e.version, e.parent_id
609 FROM context_events e
610 JOIN context_events_fts f ON e.id = f.rowid
611 WHERE f.payload_text MATCH ?1 AND e.workspace_id = ?2 AND e.channel_id = ?3
612 ORDER BY f.rank
613 LIMIT ?4",
614 ).ok()?;
615 let rows = stmt
616 .query_map(params![query, workspace_id, ch, limit], event_from_row)
617 .ok()?;
618 Some(rows.flatten().collect::<Vec<_>>())
619 })()
620 } else {
621 (|| {
622 let mut stmt = conn.prepare(
623 "SELECT e.id, e.workspace_id, e.channel_id, e.kind, e.actor, e.timestamp,
624 e.payload_json, e.version, e.parent_id
625 FROM context_events e
626 JOIN context_events_fts f ON e.id = f.rowid
627 WHERE f.payload_text MATCH ?1 AND e.workspace_id = ?2
628 ORDER BY f.rank
629 LIMIT ?3",
630 ).ok()?;
631 let rows = stmt
632 .query_map(params![query, workspace_id, limit], event_from_row)
633 .ok()?;
634 Some(rows.flatten().collect::<Vec<_>>())
635 })()
636 };
637 self.inner.return_read_conn(conn);
638 result.unwrap_or_default()
639 }
640
641 pub fn lineage(
644 &self,
645 event_id: i64,
646 workspace_id: &str,
647 max_depth: usize,
648 ) -> Vec<ContextEventV1> {
649 let max_depth = max_depth.clamp(1, 50);
650 let conn = self.inner.take_read_conn();
651 let mut chain = Vec::new();
652 let mut current_id = Some(event_id);
653
654 for _ in 0..max_depth {
655 let Some(id) = current_id else {
656 break;
657 };
658 let ev = conn.query_row(
659 "SELECT id, workspace_id, channel_id, kind, actor, timestamp, payload_json, version, parent_id
660 FROM context_events WHERE id = ?1 AND workspace_id = ?2",
661 params![id, workspace_id],
662 event_from_row,
663 );
664 match ev {
665 Ok(ev) => {
666 current_id = ev.parent_id;
667 chain.push(ev);
668 }
669 Err(_) => break,
670 }
671 }
672 self.inner.return_read_conn(conn);
673 chain
674 }
675
676 pub fn latest_id(&self, workspace_id: &str, channel_id: &str) -> i64 {
678 let conn = self.inner.take_read_conn();
679 let result = conn
680 .query_row(
681 "SELECT COALESCE(MAX(id), 0) FROM context_events WHERE workspace_id = ?1 AND channel_id = ?2",
682 params![workspace_id, channel_id],
683 |row| row.get(0),
684 )
685 .unwrap_or(0);
686 self.inner.return_read_conn(conn);
687 result
688 }
689}
690
691pub struct FilteredSubscription {
693 pub rx: broadcast::Receiver<ContextEventV1>,
694 pub filter: TopicFilter,
695}
696
697impl FilteredSubscription {
698 pub async fn recv_filtered(&mut self) -> Result<ContextEventV1, broadcast::error::RecvError> {
701 loop {
702 let ev = self.rx.recv().await?;
703 if self.filter.matches(&ev) {
704 return Ok(ev);
705 }
706 }
707 }
708}
709
710fn default_db_path() -> PathBuf {
711 let data = crate::core::data_dir::lean_ctx_data_dir().unwrap_or_else(|_| PathBuf::from("."));
712 data.join("context-os").join("context-os.db")
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718 use tempfile::tempdir;
719
720 fn test_bus() -> (ContextBus, tempfile::TempDir) {
721 let td = tempdir().expect("tempdir");
722 let bus = ContextBus::open_at(td.path().join("test-context-os.db"));
723 (bus, td)
724 }
725
726 #[test]
727 fn append_and_read_roundtrip() {
728 let (bus, _td) = test_bus();
729 let ev = bus
730 .append(
731 "ws",
732 "ch",
733 &ContextEventKindV1::ToolCallRecorded,
734 Some("agent"),
735 serde_json::json!({"tool":"ctx_read"}),
736 )
737 .expect("append");
738 let got = bus.read("ws", "ch", ev.id - 1, 10);
739 assert!(got.iter().any(|e| e.id == ev.id));
740 }
741
742 #[test]
743 fn multi_client_concurrent_appends_have_deterministic_ordering() {
744 let (bus, _td) = test_bus();
745 let bus = Arc::new(bus);
746 let n_clients = 5;
747 let n_events_per_client = 20;
748 let ws = format!("ws-concurrent-{}", std::process::id());
749 let ch = format!("ch-concurrent-{}", std::process::id());
750
751 let mut handles = vec![];
752 for client_idx in 0..n_clients {
753 let bus = Arc::clone(&bus);
754 let ws = ws.clone();
755 let ch = ch.clone();
756 handles.push(std::thread::spawn(move || {
757 let agent = format!("agent-{client_idx}");
758 for event_idx in 0..n_events_per_client {
759 bus.append(
760 &ws,
761 &ch,
762 &ContextEventKindV1::ToolCallRecorded,
763 Some(&agent),
764 serde_json::json!({"client": client_idx, "seq": event_idx}),
765 );
766 }
767 }));
768 }
769
770 for h in handles {
771 h.join().unwrap();
772 }
773
774 let all = bus.read(&ws, &ch, 0, 1000);
775 assert_eq!(
776 all.len(),
777 n_clients * n_events_per_client,
778 "all events should be persisted"
779 );
780
781 let ids: Vec<i64> = all.iter().map(|e| e.id).collect();
782 let mut sorted = ids.clone();
783 sorted.sort_unstable();
784 assert_eq!(ids, sorted, "events must be in strictly ascending ID order");
785
786 for win in ids.windows(2) {
787 assert!(
788 win[1] > win[0],
789 "IDs must be strictly monotonic (no gaps from concurrent access)"
790 );
791 }
792 }
793
794 #[test]
795 fn workspace_channel_isolation() {
796 let (bus, _td) = test_bus();
797 let pid = std::process::id();
798 let ws_a = format!("ws-iso-a-{pid}");
799 let ws_b = format!("ws-iso-b-{pid}");
800 let ws_c = format!("ws-iso-c-{pid}");
801 let ch1 = format!("ch-iso-1-{pid}");
802 let ch2 = format!("ch-iso-2-{pid}");
803
804 bus.append(
805 &ws_a,
806 &ch1,
807 &ContextEventKindV1::SessionMutated,
808 Some("agent-a"),
809 serde_json::json!({"ws":"a","ch":"1"}),
810 );
811 bus.append(
812 &ws_a,
813 &ch2,
814 &ContextEventKindV1::KnowledgeRemembered,
815 Some("agent-a"),
816 serde_json::json!({"ws":"a","ch":"2"}),
817 );
818 bus.append(
819 &ws_b,
820 &ch1,
821 &ContextEventKindV1::ArtifactStored,
822 Some("agent-b"),
823 serde_json::json!({"ws":"b","ch":"1"}),
824 );
825
826 let ws_a_ch_1 = bus.read(&ws_a, &ch1, 0, 100);
827 assert_eq!(ws_a_ch_1.len(), 1);
828 assert_eq!(ws_a_ch_1[0].kind, "session_mutated");
829
830 let ws_a_ch_2 = bus.read(&ws_a, &ch2, 0, 100);
831 assert_eq!(ws_a_ch_2.len(), 1);
832 assert_eq!(ws_a_ch_2[0].kind, "knowledge_remembered");
833
834 let ws_b_ch_1 = bus.read(&ws_b, &ch1, 0, 100);
835 assert_eq!(ws_b_ch_1.len(), 1);
836 assert_eq!(ws_b_ch_1[0].kind, "artifact_stored");
837
838 let ws_c_ch_1 = bus.read(&ws_c, &ch1, 0, 100);
839 assert!(ws_c_ch_1.is_empty(), "non-existent workspace returns empty");
840 }
841
842 #[test]
843 fn replay_from_cursor_returns_only_newer_events() {
844 let (bus, _td) = test_bus();
845 let pid = std::process::id();
846 let ws = &format!("ws-replay-{pid}");
847 let ch = &format!("ch-replay-{pid}");
848
849 let ev1 = bus
850 .append(
851 ws,
852 ch,
853 &ContextEventKindV1::ToolCallRecorded,
854 None,
855 serde_json::json!({"seq":1}),
856 )
857 .unwrap();
858 let ev2 = bus
859 .append(
860 ws,
861 ch,
862 &ContextEventKindV1::SessionMutated,
863 None,
864 serde_json::json!({"seq":2}),
865 )
866 .unwrap();
867 let _ev3 = bus
868 .append(
869 ws,
870 ch,
871 &ContextEventKindV1::GraphBuilt,
872 None,
873 serde_json::json!({"seq":3}),
874 )
875 .unwrap();
876
877 let from_cursor = bus.read(ws, ch, ev2.id, 100);
878 assert_eq!(from_cursor.len(), 1, "only events after cursor");
879 assert_eq!(from_cursor[0].kind, "graph_built");
880
881 let from_first = bus.read(ws, ch, ev1.id, 100);
882 assert_eq!(from_first.len(), 2, "events after first");
883
884 let from_zero = bus.read(ws, ch, 0, 100);
885 assert_eq!(from_zero.len(), 3, "all events from zero");
886 }
887
888 #[test]
889 fn broadcast_subscriber_receives_events() {
890 let (bus, _td) = test_bus();
891 let mut rx = bus.subscribe("ws", "ch").expect("subscribe should succeed");
892
893 let ev = bus
894 .append(
895 "ws",
896 "ch",
897 &ContextEventKindV1::ProofAdded,
898 Some("verifier"),
899 serde_json::json!({"proof":"hash"}),
900 )
901 .unwrap();
902
903 let received = rx.try_recv().expect("subscriber should receive event");
904 assert_eq!(received.id, ev.id);
905 assert_eq!(received.kind, "proof_added");
906 assert_eq!(received.actor.as_deref(), Some("verifier"));
907 }
908}