Skip to main content

tapes_capture/
session.rs

1//! What the envelope needs to know about a harness's session.
2//!
3//! The envelope's job is to turn a resolved session identity into the
4//! `X-Tapes-*` header set. To do that it needs six things — a harness id, a
5//! session id, and four optional fields — and it needs them from *every*
6//! harness, present and future.
7//!
8//! Before this trait it got them by naming one: the producer imported Claude's
9//! session-file struct and read its fields directly. That is what made the
10//! envelope un-shareable. It sat in the crate that declares harnesses, so
11//! adding a harness could change it, and the harness registry could not take
12//! its ids from the envelope without the two crates depending on each other.
13//!
14//! [`HarnessSession`] states the requirement instead of importing a supplier of
15//! it. A harness crate implements it for whatever shape it already parses — a
16//! foreign trait on a local type, which is always allowed — and the envelope
17//! constructs from `&impl HarnessSession`, naming nobody. The next harness
18//! implements the same trait without a line changing here.
19//!
20//! # Absence is a first-class answer
21//!
22//! Every field but the two required ones defaults to "this harness has no such
23//! thing". A harness that never names a session, or ships no version string,
24//! implements nothing extra and the corresponding header is simply omitted —
25//! which is the envelope's existing meaning for an absent optional (see
26//! `X-Tapes-*` field docs: absent and empty stay distinguishable downstream).
27//! Nothing is ever filled with a placeholder to satisfy the shape.
28
29/// A harness session, as the envelope producer sees it.
30///
31/// Implement this on the type a harness crate already parses out of whatever
32/// the harness publishes — a session file, a rollout record, a lifecycle
33/// report. The methods are a projection, not a parser: they hand back what the
34/// implementor already holds.
35///
36/// Only [`harness_id`](Self::harness_id) and [`session_id`](Self::session_id)
37/// are required, because an envelope without them is not an identity at all —
38/// the producer's completeness rule rejects exactly that pair being absent.
39/// Everything else defaults to absent.
40pub trait HarnessSession {
41    /// The harness this session belongs to — the `X-Tapes-Harness-Id` value.
42    ///
43    /// Returned by the implementor rather than passed in by the caller so one
44    /// session type cannot be stamped under two different harness ids by two
45    /// call sites. The `HARNESS_ID_*` constants are the vocabulary; a harness
46    /// crate takes its id from there and reports it here.
47    fn harness_id(&self) -> &str;
48
49    /// The harness's own session identifier — `X-Tapes-Harness-Session-Id`.
50    ///
51    /// Required, and deliberately not `Option`: a harness that cannot name its
52    /// session has no envelope to produce, and its capture client should emit
53    /// the `unknown` sentinel rather than an identity with a hole in it.
54    fn session_id(&self) -> &str;
55
56    /// Harness version string — `X-Tapes-Harness-Version`.
57    fn version(&self) -> Option<&str> {
58        None
59    }
60
61    /// Working directory the harness is running in — `X-Tapes-Cwd`.
62    fn cwd(&self) -> Option<&str> {
63        None
64    }
65
66    /// User-given session name — `X-Tapes-Session-Name`.
67    fn name(&self) -> Option<&str> {
68        None
69    }
70
71    /// Everything else this harness wants carried in
72    /// `X-Tapes-Harness-Metadata`, as the JSON object it is encoded from.
73    ///
74    /// Returned by value rather than borrowed because the map is *assembled*,
75    /// not stored: a harness typically has a few modelled fields plus a
76    /// verbatim passthrough of whatever its session file carried that the
77    /// crate does not model, and the two are one object on the wire. Which
78    /// keys those are, and how they are spelled, is harness knowledge and
79    /// belongs on the implementor's side of this boundary — the producer only
80    /// caps, encodes, and drops.
81    fn metadata(&self) -> serde_json::Map<String, serde_json::Value> {
82        serde_json::Map::new()
83    }
84}
85
86#[cfg(test)]
87#[allow(clippy::unwrap_used, clippy::expect_used)]
88mod tests {
89    use super::*;
90
91    /// The minimum a harness must state. Everything else takes the default,
92    /// which is the shape a harness with no analogue for a field gets for
93    /// free — no placeholder, no dummy string.
94    struct Minimal;
95
96    impl HarnessSession for Minimal {
97        fn harness_id(&self) -> &str {
98            "minimal"
99        }
100        fn session_id(&self) -> &str {
101            "sid-1"
102        }
103    }
104
105    #[test]
106    fn a_harness_with_no_optional_fields_states_only_the_two_required_ones() {
107        let session = Minimal;
108        assert_eq!(session.harness_id(), "minimal");
109        assert_eq!(session.session_id(), "sid-1");
110        assert_eq!(session.version(), None);
111        assert_eq!(session.cwd(), None);
112        assert_eq!(session.name(), None);
113        assert!(session.metadata().is_empty());
114    }
115}