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}