lean_ctx/core/context_os/
mod.rs1use 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
21pub 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#[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
92pub fn try_runtime() -> Result<Arc<ContextOsRuntime>, ObservationPersistenceError> {
95 if let Some(runtime) = RUNTIME.get() {
96 return Ok(Arc::clone(runtime));
97 }
98 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
114pub 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
134pub 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
162pub 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 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}