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 /// v3.0 #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}