Skip to main content

fno_agents/
subscribe.rs

1//! `fno-agents subscribe` -- stream registry state transitions + pane exits as
2//! newline-delimited JSON.
3//!
4//! Client-side and daemon-free by design: the daemon already writes every
5//! transition to its own append-only `events.jsonl` (the `inside_leg_report`,
6//! `inside_leg_completed`, and `screen_state_change` kinds it emits at the badge
7//! transition edges). `subscribe` follows that file from EOF and reshapes those
8//! kinds into a stable transition schema, rather than threading a broadcast
9//! channel through the hot registry-write path. The append-only log is also
10//! strictly better substrate for a work-queue consumer than a bounded
11//! drop-oldest broadcast: a slow reader never blocks the daemon (the file is the
12//! buffer) and never drops the "agent went idle" event it needs -- it just reads
13//! it later.
14//!
15//! ponytail: no per-subscriber bounded queue / lagged marker / rate coalescing.
16//! The file-follow transport does not have the "slow consumer stalls the daemon"
17//! problem those solve, and the daemon's own emit cadence bounds the rate. Add
18//! them only if a socket-push transport is ever actually required.
19
20use crate::paths::AgentsHome;
21use crate::state::{self, Registry};
22use serde_json::{json, Value};
23use std::collections::HashMap;
24use std::io::{Read, Seek, SeekFrom, Write};
25use std::time::Duration;
26
27const POLL_INTERVAL: Duration = Duration::from_millis(250);
28
29/// One normalized transition, reshaped from an `events.jsonl` line. `agent` is
30/// `None` for `inside_leg_report` (that event carries only `session_id`); the
31/// follow loop resolves it against the registry before emitting.
32#[derive(Debug, Clone, PartialEq)]
33pub struct Transition {
34    /// Original event kind (passed through for provenance).
35    pub kind: String,
36    /// `"state"` (a working/blocked/idle transition) or `"exit"` (pane/turn end).
37    pub category: &'static str,
38    /// Which authority decided the verdict: `"hook"` or `"screen"`.
39    pub authority: &'static str,
40    /// Agent name when the source event carries it, else `None` (resolve by sid).
41    pub agent: Option<String>,
42    /// The report's session id, present only on `inside_leg_report`.
43    pub session_id: Option<String>,
44    /// New state label.
45    pub state: String,
46    /// Per-source monotonic sequence, when the event carries one.
47    pub seq: Option<u64>,
48}
49
50/// Reshape one `events.jsonl` line into a [`Transition`], or `None` for any
51/// non-transition kind (spawn/stop/reconcile/daemon-lifecycle/... are ignored).
52pub fn classify(v: &Value) -> Option<Transition> {
53    // Unified envelope (x-2901): `type` + payload under `data`. The `kind`/flat
54    // fallback covers the mixed-binary window (an old daemon binary keeps writing
55    // the retired shape until `fno restart`) and rotated events.jsonl.1 history.
56    // Removal criterion: drop the `.or_else(kind)`/flat fallback once the daemon
57    // fleet has restarted on the post-x-2901 binary and no rotated file carries a
58    // `kind` line.
59    let kind = v
60        .get("type")
61        .or_else(|| v.get("kind"))
62        .and_then(|x| x.as_str())?;
63    let payload = v.get("data").unwrap_or(v);
64    let str_field = |k: &str| payload.get(k).and_then(|x| x.as_str()).map(str::to_string);
65    let seq = payload.get("seq").and_then(|x| x.as_u64());
66    match kind {
67        // Hook report: {session_id, seq, state} -- no name, resolved later.
68        "inside_leg_report" => Some(Transition {
69            kind: kind.to_string(),
70            category: "state",
71            authority: "hook",
72            agent: None,
73            session_id: str_field("session_id"),
74            state: str_field("state").unwrap_or_default(),
75            seq,
76        }),
77        // Early-push flush: a report buffered before its row existed, applied at
78        // row creation. Carries {name, session_id, state, seq} and is the ONLY
79        // event for that transition, so a subscriber must surface it too.
80        "inside_leg_buffer_flushed" => Some(Transition {
81            kind: kind.to_string(),
82            category: "state",
83            authority: "hook",
84            agent: str_field("name"),
85            session_id: str_field("session_id"),
86            state: str_field("state").unwrap_or_default(),
87            seq,
88        }),
89        // Ordered exit / turn-done teardown: {name, session_id, final_state, seq}.
90        "inside_leg_completed" => Some(Transition {
91            kind: kind.to_string(),
92            category: "exit",
93            authority: "hook",
94            agent: str_field("name"),
95            session_id: str_field("session_id"),
96            state: str_field("final_state").unwrap_or_else(|| "done".to_string()),
97            seq,
98        }),
99        // Scrape verdict change: {name, state, rule, seq, cleared}. A cleared
100        // verdict (badge dropped) reads as idle. The scrape sweep ALSO emits this
101        // kind for a manifest PARSE ERROR ({provider, error}, no name/state) --
102        // that is not a row transition, so require `name` and skip otherwise.
103        "screen_state_change" => {
104            let name = payload.get("name").and_then(|x| x.as_str())?;
105            let cleared = payload
106                .get("cleared")
107                .and_then(|c| c.as_bool())
108                .unwrap_or(false);
109            let state = if cleared {
110                "idle".to_string()
111            } else {
112                str_field("state").unwrap_or_else(|| "idle".to_string())
113            };
114            Some(Transition {
115                kind: kind.to_string(),
116                category: "state",
117                authority: "screen",
118                agent: Some(name.to_string()),
119                session_id: None,
120                state,
121                seq,
122            })
123        }
124        _ => None,
125    }
126}
127
128/// Resolve a hook report's `session_id` to a row name using the daemon's own
129/// matcher (any provider id field), so the mapping never drifts from spawn.
130fn resolve_name(reg: &Registry, session_id: &str) -> Option<String> {
131    reg.entries
132        .iter()
133        .find(|e| crate::daemon::entry_holds_session(e, session_id))
134        .map(|e| e.name.clone())
135}
136
137/// Runtime filters for one subscribe stream.
138struct Filters {
139    agent: Option<String>,
140    want_state: bool,
141    want_exit: bool,
142}
143
144fn ino_of(m: std::fs::Metadata) -> u64 {
145    use std::os::unix::fs::MetadataExt;
146    m.ino()
147}
148
149/// Classify one raw `events.jsonl` line, resolve its agent name, apply the
150/// filters, and emit one NDJSON transition. `reg` caches the registry for
151/// session_id->name resolution (refreshed on a miss); `last_state` tracks the
152/// prior state per agent so each emission carries `old_state`.
153fn process_line(
154    line: &str,
155    home: &AgentsHome,
156    filters: &Filters,
157    reg: &mut Option<Registry>,
158    last_state: &mut HashMap<String, String>,
159) {
160    let Ok(v) = serde_json::from_str::<Value>(line) else {
161        return;
162    };
163    let Some(t) = classify(&v) else { return };
164    match t.category {
165        "state" if !filters.want_state => return,
166        "exit" if !filters.want_exit => return,
167        _ => {}
168    }
169    // Resolve the name (enrich a name-less hook report by session id).
170    let agent = match (t.agent.clone(), &t.session_id) {
171        (Some(name), _) => Some(name),
172        (None, Some(sid)) => {
173            let mut found = reg.as_ref().and_then(|r| resolve_name(r, sid));
174            if found.is_none() {
175                *reg = state::load_registry(&home.registry_json()).ok();
176                found = reg.as_ref().and_then(|r| resolve_name(r, sid));
177            }
178            found
179        }
180        (None, None) => None,
181    };
182    // --agent filter: an unresolved agent can't match.
183    if let Some(want) = &filters.agent {
184        if agent.as_deref() != Some(want.as_str()) {
185            return;
186        }
187    }
188    let old = agent.as_ref().and_then(|a| last_state.get(a).cloned());
189    let out_line = json!({
190        "agent": agent,
191        "event": t.category,
192        "state": t.state,
193        "old_state": old,
194        "authority": t.authority,
195        "seq": t.seq,
196        "kind": t.kind,
197    })
198    .to_string();
199    let mut out = std::io::stdout().lock();
200    let _ = writeln!(out, "{out_line}");
201    let _ = out.flush();
202    if let Some(a) = agent {
203        last_state.insert(a, t.state);
204    }
205}
206
207/// Drain every complete line currently readable from `file` (the fd we follow),
208/// feeding each to [`process_line`]. A trailing partial line is kept in `carry`.
209fn drain_fd(
210    file: &mut std::fs::File,
211    carry: &mut String,
212    home: &AgentsHome,
213    filters: &Filters,
214    reg: &mut Option<Registry>,
215    last_state: &mut HashMap<String, String>,
216) {
217    let mut buf = String::new();
218    if file.read_to_string(&mut buf).is_err() || buf.is_empty() {
219        return;
220    }
221    carry.push_str(&buf);
222    while let Some(nl) = carry.find('\n') {
223        let line: String = carry.drain(..=nl).collect();
224        let line = line.trim_end();
225        if !line.is_empty() {
226            process_line(line, home, filters, reg, last_state);
227        }
228    }
229}
230
231/// `fno-agents subscribe [--agent <name>] [--kinds state,exit] [--json]`
232pub async fn run_subscribe(rest: &[String], home: &AgentsHome) -> i32 {
233    let mut agent_filter: Option<String> = None;
234    let mut want_state = true;
235    let mut want_exit = true;
236    let mut kinds_set = false;
237
238    let mut it = rest.iter();
239    while let Some(a) = it.next() {
240        match a.as_str() {
241            "--agent" => match it.next() {
242                Some(v) => agent_filter = Some(v.clone()),
243                None => {
244                    eprintln!("fno-agents: --agent needs a value");
245                    return 2;
246                }
247            },
248            "--kinds" => match it.next() {
249                Some(v) => {
250                    // First --kinds resets to the named subset; unknown names error.
251                    want_state = false;
252                    want_exit = false;
253                    kinds_set = true;
254                    for k in v.split(',').map(str::trim).filter(|k| !k.is_empty()) {
255                        match k {
256                            "state" => want_state = true,
257                            "exit" => want_exit = true,
258                            other => {
259                                eprintln!("fno-agents: subscribe --kinds must be state|exit (got {other})");
260                                return 2;
261                            }
262                        }
263                    }
264                }
265                None => {
266                    eprintln!("fno-agents: --kinds needs a value");
267                    return 2;
268                }
269            },
270            // --json is the only output shape (a JSON stream), accepted for parity.
271            "--json" | "-J" => {}
272            other if other.starts_with("--") => {
273                eprintln!("fno-agents: subscribe: unknown flag: {other}");
274                return 2;
275            }
276            other => {
277                eprintln!("fno-agents: subscribe: unexpected argument: {other}");
278                return 2;
279            }
280        }
281    }
282    if kinds_set && !want_state && !want_exit {
283        eprintln!("fno-agents: subscribe --kinds selected nothing (use state and/or exit)");
284        return 2;
285    }
286
287    let filters = Filters {
288        agent: agent_filter,
289        want_state,
290        want_exit,
291    };
292    let path = home.events_jsonl();
293    let mut carry = String::new();
294    let mut last_state: HashMap<String, String> = HashMap::new();
295    // Cache the registry for session_id->name resolution; refresh on a miss.
296    let mut reg: Option<Registry> = state::load_registry(&home.registry_json()).ok();
297
298    // Follow by holding the fd open (tail -f semantics): reads continue on the
299    // CURRENT inode even after events.jsonl rotates to events.jsonl.1, so the
300    // rotated file's tail drains naturally and we only reopen when the active
301    // path resolves to a NEW inode. Start at EOF -- subscribe is a push stream of
302    // transitions after connect, not a history dump.
303    let mut file: Option<std::fs::File> = match std::fs::File::open(&path) {
304        Ok(mut f) => {
305            let _ = f.seek(SeekFrom::End(0));
306            Some(f)
307        }
308        Err(_) => None,
309    };
310    let mut fd_ino: Option<u64> = file.as_ref().and_then(|f| f.metadata().ok()).map(ino_of);
311
312    loop {
313        // The file may not exist yet at startup; open it (from its start) once
314        // it appears -- there is no history to skip on a freshly created file.
315        if file.is_none() {
316            if let Ok(f) = std::fs::File::open(&path) {
317                fd_ino = f.metadata().ok().map(ino_of);
318                file = Some(f);
319            }
320        }
321        // Drain everything currently available on the fd we follow.
322        if let Some(f) = &mut file {
323            drain_fd(f, &mut carry, home, &filters, &mut reg, &mut last_state);
324        }
325        // Rotation: the active path now resolves to a different inode than our
326        // fd. We just drained our fd to the old inode's true EOF, so reopen and
327        // follow the new active file from its start (no event lost at the seam).
328        let path_ino = std::fs::metadata(&path).ok().map(ino_of);
329        if path_ino.is_some() && path_ino != fd_ino {
330            carry.clear();
331            match std::fs::File::open(&path) {
332                Ok(f) => {
333                    fd_ino = path_ino;
334                    file = Some(f);
335                }
336                Err(_) => file = None,
337            }
338        }
339        tokio::time::sleep(POLL_INTERVAL).await;
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn classifies_hook_report_without_name() {
349        let t = classify(&json!({
350            "type": "inside_leg_report",
351            "data": {"session_id": "sid-1", "seq": 3, "state": "blocked"}
352        }))
353        .unwrap();
354        assert_eq!(t.category, "state");
355        assert_eq!(t.authority, "hook");
356        assert_eq!(t.agent, None);
357        assert_eq!(t.session_id.as_deref(), Some("sid-1"));
358        assert_eq!(t.state, "blocked");
359        assert_eq!(t.seq, Some(3));
360    }
361
362    #[test]
363    fn classifies_completion_as_exit() {
364        let t = classify(&json!({
365            "type": "inside_leg_completed",
366            "data": {"name": "wkA", "session_id": "sid-1", "final_state": "done", "seq": 9}
367        }))
368        .unwrap();
369        assert_eq!(t.category, "exit");
370        assert_eq!(t.agent.as_deref(), Some("wkA"));
371        assert_eq!(t.state, "done");
372    }
373
374    #[test]
375    fn cleared_screen_state_reads_idle() {
376        let t = classify(&json!({
377            "type": "screen_state_change",
378            "data": {"name": "wkA", "state": Value::Null, "rule": Value::Null, "seq": 2, "cleared": true}
379        }))
380        .unwrap();
381        assert_eq!(t.category, "state");
382        assert_eq!(t.authority, "screen");
383        assert_eq!(t.agent.as_deref(), Some("wkA"));
384        assert_eq!(t.state, "idle");
385    }
386
387    #[test]
388    fn live_screen_state_keeps_verdict() {
389        let t = classify(&json!({
390            "type": "screen_state_change",
391            "data": {"name": "wkA", "state": "blocked", "rule": "menu", "seq": 4, "cleared": false}
392        }))
393        .unwrap();
394        assert_eq!(t.state, "blocked");
395    }
396
397    #[test]
398    fn ignores_non_transition_kinds() {
399        assert!(classify(&json!({"type": "agent_spawned", "data": {"name": "wkA"}})).is_none());
400        assert!(classify(&json!({"type": "daemon_started", "data": {"pid": 1}})).is_none());
401        assert!(classify(&json!({"no_type": true})).is_none());
402    }
403
404    #[test]
405    fn classifies_buffer_flush_as_hook_state() {
406        // The early-push flush is the ONLY event for that transition; it carries
407        // {name, session_id, state, seq}.
408        let t = classify(&json!({
409            "type": "inside_leg_buffer_flushed",
410            "data": {"name": "wkA", "session_id": "sid-1", "state": "working", "seq": 4}
411        }))
412        .unwrap();
413        assert_eq!(t.category, "state");
414        assert_eq!(t.authority, "hook");
415        assert_eq!(t.agent.as_deref(), Some("wkA"));
416        assert_eq!(t.state, "working");
417        assert_eq!(t.seq, Some(4));
418    }
419
420    #[test]
421    fn screen_state_parse_error_variant_is_ignored() {
422        // The scrape sweep emits screen_state_change for a manifest parse error
423        // with {provider, error} and no name -- not a row transition, must skip.
424        assert!(classify(&json!({
425            "type": "screen_state_change",
426            "data": {"provider": "codex", "error": "bad manifest"}
427        }))
428        .is_none());
429    }
430
431    #[test]
432    fn legacy_kind_flat_line_still_classifies_via_fallback() {
433        // Mixed-binary/rotated-history window: an old daemon binary emits the
434        // retired {kind, <flat fields>} shape. The fallback must still classify
435        // it until the fleet has restarted. Delete with the fallback in classify.
436        let t = classify(&json!({
437            "kind": "inside_leg_report", "session_id": "sid-1", "seq": 3, "state": "blocked"
438        }))
439        .unwrap();
440        assert_eq!(t.category, "state");
441        assert_eq!(t.session_id.as_deref(), Some("sid-1"));
442        assert_eq!(t.state, "blocked");
443        assert_eq!(t.seq, Some(3));
444    }
445}