Skip to main content

kimetsu_core/
event.rs

1use std::cell::RefCell;
2use std::sync::OnceLock;
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use time::OffsetDateTime;
7
8use crate::EVENT_SCHEMA_VERSION;
9use crate::ids::{EventId, RunId};
10
11/// Process-global write origin, set once at startup (CLI / MCP server) and
12/// stamped onto every locally-created [`Event`]. `None` until configured.
13/// Format is `<machine_id>/<agent>` (e.g. `laptop-01/claude-code`), so a shared
14/// or replicated brain can attribute each event to the device + agent that wrote
15/// it. Imported events keep their REMOTE origin (set explicitly), never this one.
16static PROCESS_ORIGIN: OnceLock<Option<String>> = OnceLock::new();
17
18thread_local! {
19    /// Per-thread write origin override. Takes precedence over [`PROCESS_ORIGIN`]
20    /// when set. The multi-user remote server (kimetsu-remote) runs each request
21    /// on one `spawn_blocking` thread and uses [`OriginScope`] to attribute that
22    /// request's writes to the authenticated USER — something the process-global
23    /// (a write-once `OnceLock`) cannot do. Unset for normal CLI/agent processes.
24    static THREAD_ORIGIN: RefCell<Option<String>> = const { RefCell::new(None) };
25}
26
27/// Set the process write origin. First call wins (idempotent thereafter), so
28/// call it once during startup before any brain write. A blank/empty value is
29/// normalized to `None` (unconfigured).
30pub fn set_process_origin(origin: impl Into<String>) {
31    let s = origin.into();
32    let value = if s.trim().is_empty() { None } else { Some(s) };
33    let _ = PROCESS_ORIGIN.set(value);
34}
35
36/// The effective write origin for the current thread: the thread-local override
37/// ([`OriginScope`]) if set, else the process-global, else `None`.
38pub fn process_origin() -> Option<String> {
39    if let Some(o) = THREAD_ORIGIN.with(|c| c.borrow().clone()) {
40        return Some(o);
41    }
42    PROCESS_ORIGIN.get().cloned().flatten()
43}
44
45/// RAII guard that overrides the write origin for the current thread for its
46/// lifetime, restoring the previous value on drop. Required for the remote
47/// server: tokio reuses blocking threads, so a bare set would leak one request's
48/// user into the next request on the same thread. Empty input is treated as "no
49/// override" (the guard still restores the prior value on drop).
50#[must_use]
51pub struct OriginScope {
52    prev: Option<String>,
53}
54
55impl OriginScope {
56    pub fn new(origin: impl Into<String>) -> Self {
57        let s = origin.into();
58        let value = if s.trim().is_empty() { None } else { Some(s) };
59        let prev = THREAD_ORIGIN.with(|c| c.replace(value));
60        OriginScope { prev }
61    }
62}
63
64impl Drop for OriginScope {
65    fn drop(&mut self) {
66        let prev = self.prev.take();
67        THREAD_ORIGIN.with(|c| *c.borrow_mut() = prev);
68    }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct Event {
73    pub event_id: EventId,
74    pub run_id: RunId,
75    #[serde(with = "time::serde::rfc3339")]
76    pub ts: OffsetDateTime,
77    pub parent_event_id: Option<EventId>,
78    pub kind: String,
79    pub schema_version: u32,
80    pub payload: Value,
81    /// Who/where wrote this event: `<machine_id>/<agent>`, or `None` for events
82    /// created before origin tracking (schema < v8) or when unconfigured.
83    /// Auto-stamped from [`process_origin`] by [`Event::new`]; preserved verbatim
84    /// across rebuild and sync replication.
85    #[serde(default)]
86    pub origin: Option<String>,
87    /// v2.6 #3 Slice B: Hybrid Logical Clock timestamp (canonical string) giving
88    /// a globally-deterministic, causal total order for convergent team sync.
89    /// `None` for events created before HLC tracking (schema < v9); the v9
90    /// migration backfills those from `(ts, rowid)`. Auto-stamped by
91    /// [`Event::new`]; preserved verbatim across rebuild and replication.
92    #[serde(default)]
93    pub hlc: Option<String>,
94}
95
96impl Event {
97    pub fn new(run_id: RunId, kind: impl Into<String>, payload: Value) -> Self {
98        Self {
99            event_id: EventId::new(),
100            run_id,
101            ts: OffsetDateTime::now_utc(),
102            parent_event_id: None,
103            kind: kind.into(),
104            schema_version: EVENT_SCHEMA_VERSION,
105            payload,
106            origin: process_origin(),
107            hlc: Some(crate::clock::now().to_canonical()),
108        }
109    }
110
111    pub fn with_parent(mut self, parent_event_id: EventId) -> Self {
112        self.parent_event_id = Some(parent_event_id);
113        self
114    }
115
116    /// Override the origin (used by the sync import path to preserve a remote
117    /// event's origin instead of stamping the local process origin).
118    pub fn with_origin(mut self, origin: Option<String>) -> Self {
119        self.origin = origin;
120        self
121    }
122
123    /// Override the HLC (used by the sync import path to preserve a remote
124    /// event's HLC instead of stamping the local clock).
125    pub fn with_hlc(mut self, hlc: Option<String>) -> Self {
126        self.hlc = hlc;
127        self
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn origin_scope_overrides_and_restores() {
137        // No override → falls through to the process-global (None here in tests).
138        assert_eq!(process_origin(), None);
139
140        {
141            let _s = OriginScope::new("srv1/user:alice");
142            assert_eq!(process_origin().as_deref(), Some("srv1/user:alice"));
143            // A fresh event picks up the thread origin.
144            let e = Event::new(RunId::new(), "memory.cited", serde_json::json!({}));
145            assert_eq!(e.origin.as_deref(), Some("srv1/user:alice"));
146
147            // Nesting restores the previous override on inner drop.
148            {
149                let _inner = OriginScope::new("srv1/user:bob");
150                assert_eq!(process_origin().as_deref(), Some("srv1/user:bob"));
151            }
152            assert_eq!(process_origin().as_deref(), Some("srv1/user:alice"));
153        }
154
155        // Outer guard dropped → cleared (no leak to the next request on this thread).
156        assert_eq!(process_origin(), None);
157    }
158
159    #[test]
160    fn origin_scope_empty_is_no_override() {
161        let _s = OriginScope::new("");
162        assert_eq!(process_origin(), None);
163    }
164}