Skip to main content

liminal_server/services/
context_watch.rs

1//! The context watch: a watcher over the `context-watch` subject that
2//! reports each context reading to Lys, which decides what a crossed budget
3//! does (ACCESS-003 R1).
4//!
5//! A runner publishes its session's context reading on `context-watch` (a
6//! hot `publish` decided from its pass like any other); a granted `call` on
7//! the service carries a reading the same way. Each reading is reported to
8//! Lys's `POST /agents/{agent}/usage` as its `context_percent`, under a
9//! stable event id (the message's own id, or the call's operation id), so a
10//! reading reported again charges nothing. Lys keeps each crossing and asks
11//! the session's runner to compact, give notice or stop; liminal decides
12//! none of it and keeps no threshold.
13//!
14//! The watcher is told, never polls: the subscription's inbox notifier wakes
15//! one watcher thread, which drains every ready reading and sleeps on its
16//! channel again. Stopping the watch (or dropping it) ends the thread.
17
18use super::ServiceEffectError;
19use super::lys_http::{LysHttp, segment};
20use serde::Deserialize;
21use std::sync::Arc;
22use std::sync::mpsc;
23
24/// One context reading, as a runner publishes it.
25#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct ContextReading {
28    /// The agent (seat) whose session it is, as Lys names it.
29    pub agent: String,
30    /// The session the reading is of.
31    pub session: String,
32    /// The session's context, in percent of its window.
33    pub context_percent: u64,
34    /// When it was measured, in milliseconds since the Unix epoch.
35    pub at_ms: i64,
36}
37
38/// Reports context readings to Lys over [`LysHttp`].
39pub 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    /// Reports through `http` with `bearer`, this liminal's own Lys pass,
55    /// never shown by `Debug`.
56    #[must_use]
57    pub const fn new(http: Arc<dyn LysHttp>, bearer: String) -> Self {
58        Self { http, bearer }
59    }
60
61    /// Reports one reading's bytes to Lys under `event`.
62    ///
63    /// # Errors
64    /// The bytes are not a reading, the event id is empty, or Lys refused or
65    /// could not be asked, each by name.
66    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
90/// The ready readings on the watched subject, each with its message id.
91pub trait ContextFeed: Send {
92    /// The next ready reading without blocking, or `None` when none is
93    /// ready.
94    fn next_reading(&mut self) -> Option<(String, Vec<u8>)>;
95}
96
97enum Signal {
98    Ready,
99    Stop,
100}
101
102/// What a drain came to.
103#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
104pub struct Drained {
105    /// Readings Lys accepted.
106    pub reported: u64,
107    /// Readings refused or not reported, each logged by name.
108    pub refused: u64,
109}
110
111/// Drains every ready reading from `feed` into `reporter`.
112pub 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/// The wake the watched subscription's inbox fires on every admitted
127/// envelope, and the receiving end the watcher sleeps on.
128#[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    /// A fresh wake.
151    #[must_use]
152    pub fn new() -> Self {
153        let (sender, receiver) = mpsc::channel();
154        Self { sender, receiver }
155    }
156
157    /// The notifier to install on the subscription's inbox before it is
158    /// registered, so no reading arrives without a wake.
159    #[must_use]
160    pub fn notifier(&self) -> liminal::channel::InboxNotifier {
161        let sender = self.sender.clone();
162        Arc::new(move || {
163            // A closed watcher has nothing left to wake.
164            if sender.send(Signal::Ready).is_err() {
165                tracing::trace!("context watch closed; a wake was dropped");
166            }
167        })
168    }
169}
170
171/// The running watcher; stopping or dropping it ends the thread.
172#[derive(Debug)]
173pub struct ContextWatch {
174    stop: mpsc::Sender<Signal>,
175    thread: Option<std::thread::JoinHandle<()>>,
176}
177
178impl ContextWatch {
179    /// Starts the watcher over `feed`, woken by `wake`, reporting through
180    /// `reporter`. It drains once at start, then once per wake.
181    ///
182    /// # Errors
183    /// The watcher thread could not be spawned.
184    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    /// Stops the watcher and waits for its thread.
208    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}