liminal_server/services/
context_watch.rs1use super::ServiceEffectError;
19use super::lys_http::{LysHttp, segment};
20use serde::Deserialize;
21use std::sync::Arc;
22use std::sync::mpsc;
23
24#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct ContextReading {
28 pub agent: String,
30 pub session: String,
32 pub context_percent: u64,
34 pub at_ms: i64,
36}
37
38pub struct ContextReporter {
40 http: Arc<dyn LysHttp>,
41 bearer: String,
42}
43
44impl std::fmt::Debug for ContextReporter {
45 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 formatter
47 .debug_struct("ContextReporter")
48 .field("http", &self.http)
49 .finish_non_exhaustive()
50 }
51}
52
53impl ContextReporter {
54 #[must_use]
57 pub const fn new(http: Arc<dyn LysHttp>, bearer: String) -> Self {
58 Self { http, bearer }
59 }
60
61 pub fn report(&self, payload: &[u8], event: &str) -> Result<(), ServiceEffectError> {
67 if event.trim().is_empty() {
68 return Err(ServiceEffectError(
69 "a context reading needs an event id".to_owned(),
70 ));
71 }
72 let reading: ContextReading = serde_json::from_slice(payload)
73 .map_err(|error| ServiceEffectError(format!("not a context reading: {error}")))?;
74 let route = format!("agents/{}/usage", segment(&reading.agent));
75 let body = serde_json::json!({
76 "event": event,
77 "at_ms": reading.at_ms,
78 "session": reading.session,
79 "context_percent": reading.context_percent,
80 });
81 let answer = self.http.post(&self.bearer, &route, &body)?;
82 if answer.succeeded() {
83 Ok(())
84 } else {
85 Err(answer.refused(&route))
86 }
87 }
88}
89
90pub trait ContextFeed: Send {
92 fn next_reading(&mut self) -> Option<(String, Vec<u8>)>;
95}
96
97enum Signal {
98 Ready,
99 Stop,
100}
101
102#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
104pub struct Drained {
105 pub reported: u64,
107 pub refused: u64,
109}
110
111pub fn drain(feed: &mut dyn ContextFeed, reporter: &ContextReporter) -> Drained {
113 let mut drained = Drained::default();
114 while let Some((event, payload)) = feed.next_reading() {
115 match reporter.report(&payload, &event) {
116 Ok(()) => drained.reported = drained.reported.saturating_add(1),
117 Err(error) => {
118 drained.refused = drained.refused.saturating_add(1);
119 tracing::warn!(event = %event, error = %error, "context reading not reported to Lys");
120 }
121 }
122 }
123 drained
124}
125
126#[derive(Debug)]
129pub struct ContextWake {
130 sender: mpsc::Sender<Signal>,
131 receiver: mpsc::Receiver<Signal>,
132}
133
134impl std::fmt::Debug for Signal {
135 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 formatter.write_str(match self {
137 Self::Ready => "Ready",
138 Self::Stop => "Stop",
139 })
140 }
141}
142
143impl Default for ContextWake {
144 fn default() -> Self {
145 Self::new()
146 }
147}
148
149impl ContextWake {
150 #[must_use]
152 pub fn new() -> Self {
153 let (sender, receiver) = mpsc::channel();
154 Self { sender, receiver }
155 }
156
157 #[must_use]
160 pub fn notifier(&self) -> liminal::channel::InboxNotifier {
161 let sender = self.sender.clone();
162 Arc::new(move || {
163 if sender.send(Signal::Ready).is_err() {
165 tracing::trace!("context watch closed; a wake was dropped");
166 }
167 })
168 }
169}
170
171#[derive(Debug)]
173pub struct ContextWatch {
174 stop: mpsc::Sender<Signal>,
175 thread: Option<std::thread::JoinHandle<()>>,
176}
177
178impl ContextWatch {
179 pub fn start<F: ContextFeed + 'static>(
185 mut feed: F,
186 wake: ContextWake,
187 reporter: Arc<ContextReporter>,
188 ) -> Result<Self, ServiceEffectError> {
189 let ContextWake { sender, receiver } = wake;
190 let thread = std::thread::Builder::new()
191 .name("liminal-context-watch".to_owned())
192 .spawn(move || {
193 drain(&mut feed, &reporter);
194 while matches!(receiver.recv(), Ok(Signal::Ready)) {
195 drain(&mut feed, &reporter);
196 }
197 })
198 .map_err(|error| {
199 ServiceEffectError(format!("the context watch did not start: {error}"))
200 })?;
201 Ok(Self {
202 stop: sender,
203 thread: Some(thread),
204 })
205 }
206
207 pub fn stop(&mut self) {
209 if self.stop.send(Signal::Stop).is_err() {
210 tracing::debug!("context watch thread already stopped");
211 }
212 if let Some(thread) = self.thread.take()
213 && thread.join().is_err()
214 {
215 tracing::warn!("the context watch thread panicked");
216 }
217 }
218}
219
220impl Drop for ContextWatch {
221 fn drop(&mut self) {
222 self.stop();
223 }
224}