Skip to main content

basis_tasks/
watch.rs

1//! Tailing a task's event journal from outside its execution.
2//!
3//! [`EventCursor`] is a pull-based iterator over `events.jsonl`: replay from
4//! the start by default, one `poll` per batch of whole lines appended since
5//! the last one. It does not attach, drive, or wait — a watcher only ever
6//! observes (ADR-0019) — so a host composes its own cadence and its own
7//! terminal check around it, exactly as `basis watch` does.
8
9use serde::Deserialize;
10use serde_json::Value;
11
12use crate::{Error, events::EventTail};
13
14/// One journal record: the flat `EventLine` shape exactly as written —
15/// `{"seq":N,"type":...}`, whichever vintage of journal it came from
16/// (`events::EventTail` already normalizes the pre-0.6 nested wrapper) — with
17/// basis's typed [`basis::Event`] alongside it, when this build recognizes
18/// the `type` it names.
19///
20/// `raw` is never re-derived from `event`: `basis::Event`'s own fields do not
21/// include `seq` (the journal writer splices it in), so reserializing `event`
22/// would drop it. `raw` is the wire contract (ADR-0015) — what `--json`
23/// output reproduces verbatim — and `event` is read-side convenience for a
24/// host that would rather match on a type.
25#[derive(Debug, Clone)]
26pub struct WatchRecord {
27    /// This record's sequence number, monotonic within one task — `None`
28    /// only for a line whose `seq` is missing or not a number, which no
29    /// writer this crate ever produces but a hand-edited or foreign-written
30    /// journal could. Never silently `0`: that is a real sequence number
31    /// (the journal's first line), and reporting it for a line that carried
32    /// none would be indistinguishable from that line.
33    pub seq: Option<u64>,
34    /// The exact JSON on disk.
35    pub raw: Value,
36    /// `raw`, typed — `None` for a record a newer basis wrote with a `type`
37    /// this build does not know. The enum is `#[non_exhaustive]` for exactly
38    /// this: a host can still show or forward `raw` for a record it cannot
39    /// fully type.
40    pub event: Option<Box<basis::Event>>,
41}
42
43/// A cursor over one task's event journal.
44pub struct EventCursor {
45    tail: EventTail,
46}
47
48impl EventCursor {
49    pub(crate) fn new(tail: EventTail) -> Self {
50        Self { tail }
51    }
52
53    /// Every whole record appended since the last call, oldest first.
54    ///
55    /// Empty rather than blocking when nothing is new — a host polls this at
56    /// its own pace, sleeping between calls exactly as `basis watch` does.
57    pub fn poll(&mut self) -> Result<Vec<WatchRecord>, Error> {
58        let records = self
59            .tail
60            .poll()
61            .map_err(|error| Error::new(format!("read task events: {error}")))?;
62        Ok(records.into_iter().map(build_record).collect())
63    }
64}
65
66/// A raw journal line becomes a [`WatchRecord`]. `events::EventTail` already
67/// refuses to yield a line whose `seq` does not parse — this crate's own
68/// `WatchRecord::seq` still reads it back independently rather than trusting
69/// that filter to hold forever, because a public type's contract should not
70/// depend on an internal module's current strictness.
71fn build_record(raw: Value) -> WatchRecord {
72    let seq = raw.get("seq").and_then(Value::as_u64);
73    let event = basis::Event::deserialize(&raw).ok().map(Box::new);
74    WatchRecord { seq, raw, event }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn a_record_with_a_numeric_seq_carries_it() {
83        let record = build_record(serde_json::json!({"seq": 3, "type": "notice", "message": "hi"}));
84        assert_eq!(record.seq, Some(3));
85    }
86
87    /// Never silently `0`: that is a real sequence number, the journal's
88    /// first line, and reporting it for a line that carried none would be
89    /// indistinguishable from that line.
90    #[test]
91    fn a_record_with_no_seq_is_none_not_zero() {
92        let record = build_record(serde_json::json!({"type": "notice", "message": "hi"}));
93        assert_eq!(record.seq, None);
94
95        let not_a_number = build_record(serde_json::json!({"seq": "not-a-number"}));
96        assert_eq!(not_a_number.seq, None);
97    }
98}