Skip to main content

tapes_harnesses/transcript/
payload.rs

1//! The transcript-lane wire payload.
2//!
3//! Extracted from a daemon client's transcript uploader, minus the HTTP call.
4//! The shape is a cross-language contract with tapes' Go server
5//! (`ingest.TranscriptPayload` / `pkg/sessions.IngestEnvelope`) and the Go
6//! reference client (`pkg/backfill/transcript_upload.go`):
7//!
8//! ```json
9//! {
10//!   "session": {
11//!     "org_id": "",
12//!     "auth_subject": "",
13//!     "harness_id": "claude",
14//!     "harness_session_id": "<sid>",
15//!     "harness_version": "2.1.161",
16//!     "cwd": "/Users/me/src/repo"
17//!   },
18//!   "agent_id": "<id>",          // subagent files only
19//!   "agent_type": "...",         // from agent-<id>.meta.json
20//!   "description": "...",
21//!   "tool_use_id": "toolu_...",  // the fork edge
22//!   "kind": "interacted",        // anchor re-entry rows only
23//!   "records": [ ...verbatim JSONL lines... ]
24//! }
25//! ```
26//!
27//! `org_id` and `auth_subject` serialize as empty strings rather than being
28//! omitted, matching Go's non-`omitempty` fields — so they are non-optional
29//! `&str` here and a caller with nothing to say passes `""`.
30//!
31//! The endpoint is idempotent: the server keys raw rows on
32//! `transcript:<sid>:<agent|main>:<sha256(records)[..8]>`, so re-pushing
33//! unchanged files answers `{"deduped": true}` and grown files append a new
34//! version. That is what makes the eager trigger in [`super::trigger`] and
35//! sweep-on-start in [`super::sweep`](mod@super::sweep) safe.
36//!
37//! # What stays with the client
38//!
39//! Delivery: the HTTP client, the request timeout, the response and dedup-flag
40//! parsing, and above all **auth**. A client fronted by its own cloud edge rides
41//! a bespoke auth header of its own so that edge admits the request; a
42//! standalone client authenticates differently or not at all. None of that is
43//! harness knowledge, and no such consumer-private header is part of the tapes
44//! contract.
45
46use serde::Serialize;
47use serde_json::value::RawValue;
48
49use super::files::TranscriptFile;
50
51/// Path of the transcript-ingest endpoint, joined onto a client's base URL.
52pub const INGEST_PATH: &str = "/v1/ingest/transcript";
53
54/// The session a transcript belongs to, as the ingest lane needs it.
55///
56/// A client's own session registry will carry more than this (a pid to watch,
57/// bookkeeping for backoff); this is the subset that reaches the wire. Kept
58/// owned rather than borrowed so a client can build one from a swept transcript
59/// (see [`super::sweep`](mod@super::sweep)) as easily as from a live registry
60/// entry.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct TranscriptSession {
63    /// Which harness produced it — `claude` today. Should match the
64    /// `X-Tapes-Harness-Id` the same session's wire traffic carries, so the
65    /// deriver fuses both sources under one session row.
66    pub harness_id: String,
67    /// The harness session id. **Required** by the server for transcripts; it is
68    /// what names `<sid>.jsonl` and keys the upload.
69    pub harness_session_id: String,
70    /// Harness version, when known.
71    pub harness_version: Option<String>,
72    /// Working directory the harness ran in, when known.
73    pub cwd: Option<String>,
74    /// Organization id, or empty.
75    ///
76    /// The server's envelope validation requires a UUID here, so a client whose
77    /// own organization identifier is some other shape leaves this empty
78    /// deliberately rather than sending a value that would be rejected. That is
79    /// not a loss: on a deployment with an authenticating edge, the wire-capture
80    /// path derives org identity from the validated credential rather than from
81    /// this envelope.
82    pub org_id: String,
83    /// Acting subject, or empty. A standalone client conventionally sets
84    /// `local:<os-username>`; on the platform the cloud edge stamps it from
85    /// validated JWT claims. Nothing parses the prefix — see
86    /// [`crate::attribution::Attribution::auth_subject`].
87    pub auth_subject: String,
88}
89
90impl TranscriptSession {
91    /// A session envelope with only the required fields set.
92    pub fn new(harness_id: impl Into<String>, harness_session_id: impl Into<String>) -> Self {
93        Self {
94            harness_id: harness_id.into(),
95            harness_session_id: harness_session_id.into(),
96            harness_version: None,
97            cwd: None,
98            org_id: String::new(),
99            auth_subject: String::new(),
100        }
101    }
102
103    /// Set the harness version.
104    #[must_use]
105    pub fn with_harness_version(mut self, version: Option<String>) -> Self {
106        self.harness_version = version;
107        self
108    }
109
110    /// Set the working directory.
111    #[must_use]
112    pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
113        self.cwd = cwd;
114        self
115    }
116
117    /// Set the acting subject.
118    #[must_use]
119    pub fn with_auth_subject(mut self, auth_subject: impl Into<String>) -> Self {
120        self.auth_subject = auth_subject.into();
121        self
122    }
123}
124
125/// Session envelope, field-for-field with tapes' `pkg/sessions.IngestEnvelope`
126/// (the subset the transcript lane populates).
127#[derive(Debug, Serialize)]
128pub struct IngestEnvelope<'a> {
129    /// Empty string when unknown — never omitted; see the module docs.
130    pub org_id: &'a str,
131    /// Empty string when unknown — never omitted; see the module docs.
132    pub auth_subject: &'a str,
133    /// Which harness produced the transcript.
134    pub harness_id: &'a str,
135    /// The harness session id; REQUIRED by the server for transcripts.
136    pub harness_session_id: &'a str,
137    /// Harness version, omitted when unknown.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub harness_version: Option<&'a str>,
140    /// Working directory, omitted when unknown.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub cwd: Option<&'a str>,
143}
144
145/// Ingest body for one transcript file, mirroring tapes'
146/// `ingest.TranscriptPayload`.
147#[derive(Debug, Serialize)]
148pub struct TranscriptPayload<'a> {
149    /// Session the transcript belongs to.
150    pub session: IngestEnvelope<'a>,
151    /// Absent for the main transcript; the subagent id otherwise. Matches Go's
152    /// `omitempty` by omitting `None`.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub agent_id: Option<&'a str>,
155    /// Subagent type from `agent-<id>.meta.json`, omitted when empty.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub agent_type: Option<&'a str>,
158    /// Task description from `agent-<id>.meta.json`, omitted when empty.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub description: Option<&'a str>,
161    /// The `Task` tool_use that forked this subagent — the causal edge the tapes
162    /// deriver attaches. Omitted when empty.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub tool_use_id: Option<&'a str>,
165    /// Lifecycle qualifier for a Codex `sub_agent_activity` anchor row.
166    ///
167    /// `Some("interacted")` marks a re-entry record — a `send_message` or
168    /// `followup_task` aimed at an already-spawned thread, with `agent_id` the
169    /// target thread and `tool_use_id` the triggering call. `None` means spawn
170    /// evidence, which is the legacy default and the only thing a transcript
171    /// file ever is: [`build_payload`] therefore always leaves it unset, and an
172    /// anchor builder sets it through this field directly.
173    ///
174    /// The server keys a raw row's dedup on the payload bytes and reads the
175    /// latest version per (session, agent, lifecycle kind), so an `interacted`
176    /// row versions separately from the `started` anchor it shares an
177    /// `agent_id` with rather than superseding it. That is also why the field
178    /// is `Option` with `skip_serializing_if` rather than an empty-string
179    /// sentinel, and why it sits *after* `tool_use_id`: a spawn row's bytes
180    /// must stay identical to what earlier builds already ingested.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub kind: Option<&'a str>,
183    /// The transcript's JSONL content as a JSON array, verbatim.
184    pub records: &'a RawValue,
185}
186
187/// The [`TranscriptPayload::kind`] value marking a re-entry anchor row.
188pub const KIND_INTERACTED: &str = "interacted";
189
190/// Assemble the payload for one transcript file.
191///
192/// `records` is the output of [`super::files::jsonl_to_records`] wrapped in a
193/// [`RawValue`] so the bytes embed verbatim — re-serializing them would change
194/// the server's dedup hash and register identical content as a new version.
195#[must_use]
196pub fn build_payload<'a>(
197    session: &'a TranscriptSession,
198    file: &'a TranscriptFile,
199    records: &'a RawValue,
200) -> TranscriptPayload<'a> {
201    // Empty meta fields are omitted, matching Go's `omitempty`.
202    let some_nonempty = |s: &'a str| (!s.is_empty()).then_some(s);
203    TranscriptPayload {
204        session: IngestEnvelope {
205            org_id: &session.org_id,
206            auth_subject: &session.auth_subject,
207            harness_id: &session.harness_id,
208            harness_session_id: &session.harness_session_id,
209            harness_version: session.harness_version.as_deref(),
210            cwd: session.cwd.as_deref(),
211        },
212        agent_id: file.agent_id.as_deref(),
213        agent_type: some_nonempty(&file.meta.agent_type),
214        description: some_nonempty(&file.meta.description),
215        tool_use_id: some_nonempty(&file.meta.tool_use_id),
216        // A transcript file is always spawn evidence; only an anchor row
217        // qualifies its lifecycle. See [`TranscriptPayload::kind`].
218        kind: None,
219        records,
220    }
221}
222
223#[cfg(test)]
224#[allow(clippy::unwrap_used, clippy::expect_used)]
225mod tests {
226    use super::*;
227    use crate::transcript::files::SubagentMeta;
228
229    fn session() -> TranscriptSession {
230        TranscriptSession::new(
231            tapes_capture::envelope::HARNESS_ID_CLAUDE,
232            "0ea3c2cc-fe9d-41ff-aab1-4134ad00c350",
233        )
234        .with_harness_version(Some("2.1.161".to_owned()))
235        .with_cwd(Some("/Users/me/src/repo".to_owned()))
236    }
237
238    fn main_file() -> TranscriptFile {
239        TranscriptFile {
240            path: "/tmp/x.jsonl".into(),
241            agent_id: None,
242            meta: SubagentMeta::default(),
243        }
244    }
245
246    fn subagent_file() -> TranscriptFile {
247        TranscriptFile {
248            path: "/tmp/agent-abc.jsonl".into(),
249            agent_id: Some("abc".to_owned()),
250            meta: SubagentMeta {
251                tool_use_id: "toolu_01".to_owned(),
252                agent_type: "general-purpose".to_owned(),
253                description: "dig".to_owned(),
254            },
255        }
256    }
257
258    /// Golden-pin the exact JSON the Go reference client would produce for the
259    /// same inputs: empty-string identity fields present, agent fields absent,
260    /// records verbatim.
261    ///
262    /// Carried over from the extracted uploader's shape test — same expected
263    /// bytes.
264    #[test]
265    fn main_payload_matches_go_client_shape() {
266        let session = session();
267        let file = main_file();
268        let records = RawValue::from_string(r#"[{"b":1,"a":2}]"#.to_owned()).unwrap();
269        let payload = build_payload(&session, &file, &records);
270        let got = serde_json::to_string(&payload).unwrap();
271        assert_eq!(
272            got,
273            r#"{"session":{"org_id":"","auth_subject":"","harness_id":"claude","harness_session_id":"0ea3c2cc-fe9d-41ff-aab1-4134ad00c350","harness_version":"2.1.161","cwd":"/Users/me/src/repo"},"records":[{"b":1,"a":2}]}"#,
274        );
275    }
276
277    /// Carried over from the extracted uploader's fork-metadata test.
278    #[test]
279    fn subagent_payload_carries_fork_metadata() {
280        let session = session();
281        let file = subagent_file();
282        let records = RawValue::from_string("[]".to_owned()).unwrap();
283        let payload = build_payload(&session, &file, &records);
284        let got: serde_json::Value =
285            serde_json::from_str(&serde_json::to_string(&payload).unwrap()).unwrap();
286        assert_eq!(got["agent_id"], "abc");
287        assert_eq!(got["agent_type"], "general-purpose");
288        assert_eq!(got["description"], "dig");
289        assert_eq!(got["tool_use_id"], "toolu_01");
290    }
291
292    /// Missing `meta.json` degrades to empty strings; Go's `omitempty` drops
293    /// them and so must we.
294    ///
295    /// Carried over from the extracted uploader's empty-meta test.
296    #[test]
297    fn subagent_payload_omits_empty_meta_fields() {
298        let session = session();
299        let mut file = subagent_file();
300        file.meta = SubagentMeta::default();
301        let records = RawValue::from_string("[]".to_owned()).unwrap();
302        let payload = build_payload(&session, &file, &records);
303        let got = serde_json::to_string(&payload).unwrap();
304        assert!(!got.contains("agent_type"), "got: {got}");
305        assert!(!got.contains("tool_use_id"), "got: {got}");
306        assert!(!got.contains("description"), "got: {got}");
307        assert!(got.contains(r#""agent_id":"abc""#), "got: {got}");
308    }
309
310    /// A caller that *does* have identity to declare gets it on the wire — the
311    /// standalone-client case the extracted uploader never exercised. The fields
312    /// stay present either way, which is what the Go server's non-`omitempty`
313    /// decode expects.
314    #[test]
315    fn identity_fields_are_always_present_and_carry_a_subject_when_set() {
316        let session = TranscriptSession::new("claude", "sid").with_auth_subject("local:alice");
317        let file = main_file();
318        let records = RawValue::from_string("[]".to_owned()).unwrap();
319        let got = serde_json::to_string(&build_payload(&session, &file, &records)).unwrap();
320        assert!(
321            got.contains(r#""auth_subject":"local:alice""#),
322            "got: {got}"
323        );
324        assert!(got.contains(r#""org_id":"""#), "got: {got}");
325        // Unknown optionals stay omitted rather than becoming null.
326        assert!(!got.contains("harness_version"), "got: {got}");
327        assert!(!got.contains("cwd"), "got: {got}");
328    }
329
330    /// An unset `kind` must not appear on the wire *at all*. This is the
331    /// stability constraint the whole field is shaped around: spawn-evidence
332    /// rows were ingested by builds that predate `kind`, and the server's raw
333    /// dedup keys on the payload bytes, so a `"kind":null` — or any reordering
334    /// that moved an existing field — would re-ingest every already-stored row
335    /// as a new version.
336    #[test]
337    fn an_unset_kind_leaves_the_payload_bytes_unchanged() {
338        let session = session();
339        let file = subagent_file();
340        let records = RawValue::from_string("[]".to_owned()).unwrap();
341        let payload = build_payload(&session, &file, &records);
342        assert!(payload.kind.is_none(), "build_payload never sets kind");
343        let got = serde_json::to_string(&payload).unwrap();
344        assert!(!got.contains("kind"), "got: {got}");
345        assert_eq!(
346            got,
347            r#"{"session":{"org_id":"","auth_subject":"","harness_id":"claude","harness_session_id":"0ea3c2cc-fe9d-41ff-aab1-4134ad00c350","harness_version":"2.1.161","cwd":"/Users/me/src/repo"},"agent_id":"abc","agent_type":"general-purpose","description":"dig","tool_use_id":"toolu_01","records":[]}"#,
348        );
349    }
350
351    /// A caller that *does* qualify the row — the Codex anchor lane — gets
352    /// `kind` between `tool_use_id` and `records`. Field order is part of the
353    /// contract, not an accident: it keeps a spawn row's bytes a strict prefix
354    /// of the shape an interacted row extends.
355    #[test]
356    fn an_anchor_kind_serializes_after_tool_use_id() {
357        let session = session();
358        let file = subagent_file();
359        let records = RawValue::from_string("[]".to_owned()).unwrap();
360        let payload = TranscriptPayload {
361            kind: Some(KIND_INTERACTED),
362            ..build_payload(&session, &file, &records)
363        };
364        let got = serde_json::to_string(&payload).unwrap();
365        assert!(
366            got.contains(r#""tool_use_id":"toolu_01","kind":"interacted","records":[]"#),
367            "got: {got}",
368        );
369    }
370
371    /// The records bytes embed verbatim, including key order and interior
372    /// spacing — the server's dedup hash is computed over exactly these bytes.
373    #[test]
374    fn records_embed_verbatim() {
375        let session = session();
376        let file = main_file();
377        let raw = r#"[{"z":1,"a": 2},{"b":[3,  4]}]"#;
378        let records = RawValue::from_string(raw.to_owned()).unwrap();
379        let got = serde_json::to_string(&build_payload(&session, &file, &records)).unwrap();
380        assert!(
381            got.ends_with(&format!(r#","records":{raw}}}"#)),
382            "got: {got}",
383        );
384    }
385}