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