Skip to main content

lonca_daemon/
hooked.rs

1//! What the daemon heard from a session's hooks, to prove they run (#190).
2//!
3//! A harness whose before-tool event fires for MCP tools sends one for each
4//! call to `lonca mcp` before the call is made. `lonca mcp` says which tool
5//! it runs (`mcp.call`), and the daemon takes one event of that tool, in
6//! order. A call with none means the hooks are not running. Kept in memory,
7//! not in the log: only a miss is written, as an `unhooked` observation.
8
9use std::collections::VecDeque;
10
11/// How long an event waits for its call. Lonca's tools are allowed in the
12/// harness's settings, so the call follows at once; an event whose call
13/// never came (refused, or the harness gave up) must not stand in for a
14/// later call.
15pub const PENDING_MS: i64 = 10 * 60_000;
16
17/// Most events one session keeps waiting: a harness calls a few tools at
18/// once, not hundreds.
19pub const MAX_PENDING: usize = 64;
20
21/// After a start the daemon has heard nothing, so a call of a session older
22/// than the daemon, whose event may have gone to the one before, is not taken
23/// as a miss for this long. Short: the
24/// hook blocks the call until it is answered, so only a restart between the
25/// two loses an event, and `lonca mcp` reconnects within seconds.
26pub const START_GRACE_MS: i64 = 10_000;
27
28/// The longest tool name a call may give: it goes in the log on a miss.
29pub const MAX_TOOL: usize = 64;
30
31#[derive(Clone, Debug, Default)]
32pub struct Hooked {
33    /// Before-tool events of `lonca mcp`'s tools with no call yet: the tool,
34    /// without the harness's prefix, and when.
35    pending: VecDeque<(String, i64)>,
36    /// When the last hook event of any kind came.
37    pub last_ms: Option<i64>,
38}
39
40impl Hooked {
41    /// Any hook event: the session's hooks are alive.
42    pub fn heard(&mut self, now: i64) {
43        self.last_ms = Some(now);
44    }
45
46    /// A before-tool event for one of `lonca mcp`'s tools.
47    pub fn before(&mut self, tool: &str, now: i64) {
48        self.heard(now);
49        self.expire(now);
50        if self.pending.len() >= MAX_PENDING {
51            self.pending.pop_front();
52        }
53        self.pending.push_back((tool.to_string(), now));
54    }
55
56    /// Takes the oldest event waiting for `tool`; false when there is none.
57    pub fn take(&mut self, tool: &str, now: i64) -> bool {
58        self.expire(now);
59        match self.pending.iter().position(|(t, _)| t == tool) {
60            Some(i) => {
61                self.pending.remove(i);
62                true
63            }
64            None => false,
65        }
66    }
67
68    fn expire(&mut self, now: i64) {
69        while self
70            .pending
71            .front()
72            .is_some_and(|(_, at)| now.saturating_sub(*at) > PENDING_MS)
73        {
74            self.pending.pop_front();
75        }
76    }
77
78    #[cfg(test)]
79    fn waiting(&self) -> Vec<&str> {
80        self.pending.iter().map(|(t, _)| t.as_str()).collect()
81    }
82}
83
84/// A name `lonca mcp` could give: what a tool is called, nothing else.
85pub fn valid_tool(tool: &str) -> bool {
86    !tool.is_empty()
87        && tool.len() <= MAX_TOOL
88        && tool
89            .bytes()
90            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn a_call_takes_one_event_of_its_own_tool_in_order() {
99        let mut h = Hooked::default();
100        assert!(!h.take("propose", 0), "nothing heard");
101        h.before("lease", 1);
102        h.before("propose", 2);
103        h.before("lease", 3);
104        assert!(!h.take("status", 4), "another tool's event is no proof");
105        assert!(h.take("lease", 5));
106        assert_eq!(h.waiting(), ["propose", "lease"], "the oldest went");
107        assert!(h.take("lease", 5) && h.take("propose", 5));
108        assert!(!h.take("propose", 5), "one event, one call");
109        assert_eq!(h.last_ms, Some(3));
110    }
111
112    #[test]
113    fn an_event_waits_for_its_call_only_so_long() {
114        let mut h = Hooked::default();
115        h.before("propose", 1000);
116        assert!(h.take("propose", 1000 + PENDING_MS), "at the limit");
117        h.before("propose", 1000);
118        h.before("lease", 2000);
119        assert!(!h.take("propose", 1001 + PENDING_MS), "past it");
120        assert!(
121            h.take("lease", 1001 + PENDING_MS),
122            "a later one still waits"
123        );
124        h.before("a", 0);
125        h.before("b", PENDING_MS + 1);
126        assert_eq!(h.waiting(), ["b"], "an event clears what expired");
127    }
128
129    #[test]
130    fn a_session_keeps_only_so_many_events() {
131        let mut h = Hooked::default();
132        h.before("first", 0);
133        for _ in 1..MAX_PENDING {
134            h.before("x", 0);
135        }
136        assert_eq!(h.waiting().len(), MAX_PENDING);
137        h.before("last", 0);
138        assert_eq!(h.waiting().len(), MAX_PENDING);
139        assert!(!h.take("first", 0), "the oldest made room");
140        assert!(h.take("last", 0));
141    }
142
143    #[test]
144    fn hearing_anything_counts_as_alive() {
145        let mut h = Hooked::default();
146        assert_eq!(h.last_ms, None);
147        h.heard(7);
148        assert_eq!(h.last_ms, Some(7));
149        assert!(h.waiting().is_empty());
150    }
151
152    #[test]
153    fn a_tool_is_a_short_lowercase_name() {
154        assert!(valid_tool("propose") && valid_tool("change_status") && valid_tool("a1"));
155        let long = "a".repeat(MAX_TOOL);
156        assert!(valid_tool(&long));
157        assert!(!valid_tool(&format!("{long}a")));
158        for bad in ["", "Propose", "mcp__lonca__x-y", "a b", "a\n", "รค"] {
159            assert!(!valid_tool(bad), "{bad:?}");
160        }
161    }
162}