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}