Skip to main content

lean_ctx/core/context_os/
mod.rs

1use std::path::PathBuf;
2use std::sync::{Arc, Mutex, OnceLock};
3
4mod shared_sessions;
5pub use shared_sessions::{SharedSessionKey, SharedSessionStore};
6
7mod context_bus;
8pub mod observation;
9pub(crate) use context_bus::AdmittedLegacyObservation;
10pub use context_bus::{
11    ConsistencyLevel, ContextBus, ContextEventKindV1, ContextEventV1, FilteredSubscription,
12    TopicFilter,
13};
14pub use observation::{
15    LocalObservationPayload, LocalObservationSource, LocalObservationV1,
16    ObservationPersistenceError,
17};
18
19pub mod redaction;
20pub use redaction::{RedactionLevel, redact_event_payload, redact_payload_value};
21
22/// Wraps either a plain `broadcast::Receiver` or a `FilteredSubscription`
23/// so the SSE route can handle both with the same code path.
24pub enum SubscriptionKind {
25    Unfiltered(tokio::sync::broadcast::Receiver<ContextEventV1>),
26    Filtered(FilteredSubscription),
27}
28
29impl SubscriptionKind {
30    pub async fn recv(
31        &mut self,
32    ) -> Result<ContextEventV1, tokio::sync::broadcast::error::RecvError> {
33        match self {
34            Self::Unfiltered(rx) => rx.recv().await,
35            Self::Filtered(fs) => fs.recv_filtered().await,
36        }
37    }
38}
39
40mod metrics;
41pub use metrics::{ContextOsMetrics, MetricsSnapshot};
42
43/// Shared runtime backing Context OS features (shared sessions + event bus).
44///
45/// This is intentionally process-local: it enables multi-client coordination
46/// for HTTP/daemon/team-server deployments (one process handling many clients).
47#[derive(Clone)]
48pub struct ContextOsRuntime {
49    pub shared_sessions: Arc<SharedSessionStore>,
50    pub bus: Arc<ContextBus>,
51    pub metrics: Arc<ContextOsMetrics>,
52}
53
54impl Default for ContextOsRuntime {
55    fn default() -> Self {
56        Self::try_new().expect("open context-os runtime")
57    }
58}
59
60impl ContextOsRuntime {
61    fn try_new() -> Result<Self, ObservationPersistenceError> {
62        let base = if crate::core::data_dir::sandboxed() {
63            crate::core::data_dir::test_sandbox_dir()
64        } else {
65            crate::core::data_dir::lean_ctx_data_dir().map_err(std::io::Error::other)?
66        };
67        let path = base.join("context-os").join("context-os.db");
68        let bus = ContextBus::try_open_at(path)?;
69
70        Ok(Self {
71            shared_sessions: Arc::new(SharedSessionStore::new()),
72            bus: Arc::new(bus),
73            metrics: Arc::new(ContextOsMetrics::default()),
74        })
75    }
76
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    pub fn data_dir() -> Option<PathBuf> {
82        crate::core::data_dir::lean_ctx_data_dir().ok()
83    }
84}
85
86static RUNTIME: OnceLock<Arc<ContextOsRuntime>> = OnceLock::new();
87static RUNTIME_INIT: Mutex<()> = Mutex::new(());
88
89pub fn runtime() -> Arc<ContextOsRuntime> {
90    try_runtime().expect("open context-os runtime")
91}
92
93/// Fallible access to the same runtime authority. Failed initialization is not
94/// cached, so restoring the configured storage permits a subsequent retry.
95pub fn try_runtime() -> Result<Arc<ContextOsRuntime>, ObservationPersistenceError> {
96    if let Some(runtime) = RUNTIME.get() {
97        return Ok(Arc::clone(runtime));
98    }
99    // Build only one SQLite-backed runtime at a time. Competing first callers
100    // used to open and migrate the same database concurrently, which can make
101    // a valid Windows startup fail with a transient sharing/lock error.
102    let _initialization = RUNTIME_INIT
103        .lock()
104        .unwrap_or_else(std::sync::PoisonError::into_inner);
105    if let Some(runtime) = RUNTIME.get() {
106        return Ok(Arc::clone(runtime));
107    }
108    let candidate = Arc::new(ContextOsRuntime::try_new()?);
109    let _ = RUNTIME.set(candidate);
110    RUNTIME.get().cloned().ok_or_else(|| {
111        ObservationPersistenceError::InvalidRow("runtime initialization did not complete".into())
112    })
113}
114
115/// Convenience: append an event to the global bus with metrics tracking.
116pub fn emit_event(
117    workspace_id: &str,
118    channel_id: &str,
119    kind: &ContextEventKindV1,
120    actor: Option<&str>,
121    payload: serde_json::Value,
122) {
123    let rt = runtime();
124    if rt
125        .bus
126        .append(workspace_id, channel_id, kind, actor, payload)
127        .is_some()
128    {
129        rt.metrics.record_event_appended();
130        rt.metrics.record_event_broadcast();
131        rt.metrics.record_workspace_active(workspace_id);
132    }
133}
134
135/// Emit an event directed at specific agents only.
136pub fn emit_directed_event(
137    workspace_id: &str,
138    channel_id: &str,
139    kind: &ContextEventKindV1,
140    actor: Option<&str>,
141    payload: serde_json::Value,
142    target_agents: Vec<String>,
143) {
144    let rt = runtime();
145    if rt
146        .bus
147        .append_directed(
148            workspace_id,
149            channel_id,
150            kind,
151            actor,
152            payload,
153            target_agents,
154        )
155        .is_some()
156    {
157        rt.metrics.record_event_appended();
158        rt.metrics.record_event_broadcast();
159        rt.metrics.record_workspace_active(workspace_id);
160    }
161}
162
163/// Classify a tool name into a secondary event kind (beyond ToolCallRecorded).
164pub fn secondary_event_kind(tool: &str, action: Option<&str>) -> Option<ContextEventKindV1> {
165    match tool {
166        "ctx_session" => {
167            let a = action.unwrap_or("");
168            if matches!(
169                a,
170                "save"
171                    | "set_task"
172                    | "task"
173                    | "checkpoint"
174                    | "finding"
175                    | "decision"
176                    | "reset"
177                    | "import"
178                    | "export"
179            ) {
180                Some(ContextEventKindV1::SessionMutated)
181            } else {
182                None
183            }
184        }
185        "ctx_handoff" | "ctx_workflow" | "ctx_share" => Some(ContextEventKindV1::SessionMutated),
186        "ctx_knowledge" | "ctx_knowledge_relations" => {
187            let a = action.unwrap_or("");
188            if matches!(
189                a,
190                "remember"
191                    | "relate"
192                    | "unrelate"
193                    | "feedback"
194                    | "remove"
195                    | "consolidate"
196                    | "import"
197            ) {
198                Some(ContextEventKindV1::KnowledgeRemembered)
199            } else {
200                None
201            }
202        }
203        "ctx_artifacts" => {
204            let a = action.unwrap_or("");
205            if matches!(a, "reindex" | "remove") {
206                Some(ContextEventKindV1::ArtifactStored)
207            } else {
208                None
209            }
210        }
211        "ctx_graph" => {
212            let a = action.unwrap_or("");
213            if matches!(
214                a,
215                "index-build"
216                    | "index-build-full"
217                    | "index-build-background"
218                    | "index-build-full-background"
219            ) {
220                Some(ContextEventKindV1::GraphBuilt)
221            } else {
222                None
223            }
224        }
225        "ctx_proof" | "ctx_verify" => {
226            let a = action.unwrap_or("");
227            if matches!(a, "generate" | "export" | "verify") {
228                Some(ContextEventKindV1::ProofAdded)
229            } else {
230                None
231            }
232        }
233        _ => None,
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn test_runtime_bus_uses_stable_process_sandbox() {
243        let _isolated = crate::core::data_dir::isolated_data_dir();
244        // The shared process runtime, not a second bus: another connection to
245        // the same sandbox database makes every parallel writer wait on
246        // SQLite's file lock and trips the bounded observation lock (Windows).
247        let runtime = runtime();
248        let expected = crate::core::data_dir::test_sandbox_dir()
249            .join("context-os")
250            .join("context-os.db");
251
252        assert_eq!(runtime.bus.db_path(), expected);
253    }
254}