tapes_client/core/models/session.rs
1//! Session shapes: the capture identity and the deriver's projection.
2//!
3//! The split the wire draws is kept here rather than flattened: identity is
4//! ingest-written and lives at the top of [`SessionItem`], while everything
5//! folded from the span layer at derive time lives under `rollup`. Flattening
6//! them would blur which layer owns a field, and "why is this empty?" has two
7//! very different answers depending on the side it fell on.
8
9use std::collections::BTreeMap;
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use super::ContractModel;
15use super::span::SpanLinkItem;
16use super::trace::TraceDetail;
17
18/// The per-session shape: capture identity at the top level, the deriver-
19/// owned projection nested under `rollup`.
20///
21/// Models the contract's `SessionItem` schema.
22#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
23#[serde(default)]
24#[non_exhaustive]
25pub struct SessionItem {
26 /// The gateway-stamped JWT subject (WorkOS user id) captured at ingest;
27 /// empty for rows captured before the edge began stamping it.
28 pub auth_subject: String,
29
30 /// The contract's `cwd`.
31 pub cwd: String,
32
33 /// The user's Console rename (sessions.display_name), empty unless a user
34 /// set one.
35 pub display_name: String,
36
37 /// The server-resolved label clients should render: DisplayName ->
38 /// rollup.title (generated) -> preview -> Name -> id slice.
39 pub display_title: String,
40
41 /// The contract's `ended_at`, an RFC 3339 timestamp.
42 pub ended_at: String,
43
44 /// The contract's `harness_id`.
45 pub harness_id: String,
46
47 /// The contract's `harness_metadata`.
48 #[serde(deserialize_with = "super::null_default")]
49 pub harness_metadata: BTreeMap<String, Value>,
50
51 /// The contract's `harness_session_id`.
52 pub harness_session_id: String,
53
54 /// The contract's `harness_version`.
55 pub harness_version: String,
56
57 /// Identity — capture-side facts, ingest-written.
58 pub id: String,
59
60 /// The contract's `last_seen_at`, an RFC 3339 timestamp.
61 pub last_seen_at: String,
62
63 /// A runtime presence signal, not a projection fact: true when the
64 /// session has no recorded end and was seen within the liveness window.
65 pub live: bool,
66
67 /// The harness identity-row label — the harness-supplied session name (a
68 /// plan slug), or the folded title (rollup.title) as a fallback when no
69 /// name was captured.
70 pub name: String,
71
72 /// The contract's `parent_session_id`.
73 pub parent_session_id: String,
74
75 /// The contract's `rollup`.
76 #[serde(deserialize_with = "super::null_default")]
77 pub rollup: SessionRollup,
78
79 /// The contract's `started_at`, an RFC 3339 timestamp.
80 pub started_at: String,
81}
82
83impl ContractModel for SessionItem {
84 const SCHEMA: &'static str = "SessionItem";
85}
86
87/// The deriver-owned session projection — status, title, counts, and spend,
88/// all folded from the span layer at derive time.
89///
90/// Models the contract's `SessionRollup` schema.
91#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
92#[serde(default)]
93#[non_exhaustive]
94pub struct SessionRollup {
95 /// KindCounts (spans per call_kind) and Tasks (TaskCreate/TaskUpdate
96 /// folds) are pinned so the rollup shape is uniform across sessions.
97 #[serde(deserialize_with = "super::null_default")]
98 pub kind_counts: BTreeMap<String, i32>,
99
100 /// The dominant conversation-spine model; ModelUsage is the per- model
101 /// spend breakdown across every thread (subagent models included), cost-
102 /// ordered so the UI can show "dominant model + share" without a cheap-
103 /// subagent fan-out skewing it.
104 pub model: String,
105
106 /// The contract's `model_usage`.
107 #[serde(deserialize_with = "super::null_default")]
108 pub model_usage: Vec<ModelUsage>,
109
110 /// The contract's `preview`.
111 pub preview: String,
112
113 /// The contract's `status`.
114 pub status: String,
115
116 /// The contract's `tasks`.
117 #[serde(deserialize_with = "super::null_default")]
118 pub tasks: Vec<TreeTask>,
119
120 /// The deriver's folded session title (derived_title), generated from the
121 /// conversation.
122 pub title: String,
123
124 /// The contract's `turn_count`.
125 pub turn_count: i32,
126
127 /// The contract's `usage`.
128 #[serde(deserialize_with = "super::null_default")]
129 pub usage: SessionUsage,
130}
131
132impl ContractModel for SessionRollup {
133 const SCHEMA: &'static str = "SessionRollup";
134}
135
136/// The session's total token/cost spend, folded from the span layer.
137///
138/// Models the contract's `SessionUsage` schema.
139#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
140#[serde(default)]
141#[non_exhaustive]
142pub struct SessionUsage {
143 /// The contract's `cost_usd`.
144 pub cost_usd: f64,
145
146 /// The contract's `input_tokens`.
147 pub input_tokens: i64,
148
149 /// The contract's `output_tokens`.
150 pub output_tokens: i64,
151}
152
153impl ContractModel for SessionUsage {
154 const SCHEMA: &'static str = "SessionUsage";
155}
156
157/// One model's contribution to a session in the API: how many llm calls ran
158/// on it and what they spent.
159///
160/// Models the contract's `ModelUsage` schema.
161#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
162#[serde(default)]
163#[non_exhaustive]
164pub struct ModelUsage {
165 /// The contract's `calls`.
166 pub calls: i64,
167
168 /// The contract's `cost_usd`.
169 pub cost_usd: f64,
170
171 /// The contract's `input_tokens`.
172 pub input_tokens: i64,
173
174 /// The contract's `model`.
175 pub model: String,
176
177 /// The contract's `output_tokens`.
178 pub output_tokens: i64,
179}
180
181impl ContractModel for ModelUsage {
182 const SCHEMA: &'static str = "ModelUsage";
183}
184
185/// One task folded from the session's TaskCreate/TaskUpdate calls.
186///
187/// Models the contract's `TreeTask` schema.
188#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
189#[serde(default)]
190#[non_exhaustive]
191pub struct TreeTask {
192 /// The contract's `description`.
193 pub description: String,
194
195 /// The contract's `id`.
196 pub id: String,
197
198 /// The contract's `status`.
199 pub status: String,
200
201 /// The contract's `subject`.
202 pub subject: String,
203
204 /// The contract's `updates`.
205 pub updates: i32,
206}
207
208impl ContractModel for TreeTask {
209 const SCHEMA: &'static str = "TreeTask";
210}
211
212/// The response envelope for GET /v1/sessions.
213///
214/// Models the contract's `SessionListResponse` schema.
215#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
216#[serde(default)]
217#[non_exhaustive]
218pub struct SessionListResponse {
219 /// The contract's `items`.
220 #[serde(deserialize_with = "super::null_default")]
221 pub items: Vec<SessionItem>,
222
223 /// The contract's `next_cursor`.
224 pub next_cursor: String,
225}
226
227impl ContractModel for SessionListResponse {
228 const SCHEMA: &'static str = "SessionListResponse";
229}
230
231/// The response for GET /v1/sessions/:id: the session record alone.
232///
233/// Models the contract's `SessionDetailResponse` schema.
234#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
235#[serde(default)]
236#[non_exhaustive]
237pub struct SessionDetailResponse {
238 /// The contract's `session`.
239 #[serde(deserialize_with = "super::null_default")]
240 pub session: SessionItem,
241}
242
243impl ContractModel for SessionDetailResponse {
244 const SCHEMA: &'static str = "SessionDetailResponse";
245}
246
247/// The composite session view on the span model — one page of it. The page
248/// is bounded in traces (`limit`) and in bytes; `session` and `links` are
249/// whole on every page.
250///
251/// Models the contract's `SessionTracesResponse` schema.
252#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
253#[serde(default)]
254#[non_exhaustive]
255pub struct SessionTracesResponse {
256 /// The contract's `links`.
257 #[serde(deserialize_with = "super::null_default")]
258 pub links: Vec<SpanLinkItem>,
259
260 /// Continues the walk from the last trace of this page (pass it as
261 /// `cursor`). Empty once the page reached the session's last trace. A
262 /// page may close short of `limit` on its byte budget, so its absence —
263 /// not the page's length — is what means "no more".
264 #[serde(deserialize_with = "super::null_default")]
265 pub next_cursor: String,
266
267 /// The contract's `schema`.
268 pub schema: String,
269
270 /// The contract's `session`.
271 #[serde(deserialize_with = "super::null_default")]
272 pub session: SessionItem,
273
274 /// The contract's `traces`.
275 #[serde(deserialize_with = "super::null_default")]
276 pub traces: Vec<TraceDetail>,
277}
278
279impl ContractModel for SessionTracesResponse {
280 const SCHEMA: &'static str = "SessionTracesResponse";
281}
282
283/// The `PATCH /v1/sessions/{id}` body.
284///
285/// `display_name` distinguishes three states the server acts on differently:
286/// absent is nothing to update (a 400), while an explicit null or an empty
287/// string clears the rename back to the auto-derived title.
288///
289/// So the field is an [`Option`] that is omitted when unset, or the type could
290/// not express the distinction it documents: a `None` that still serialized
291/// would arrive as the empty string and clear a user's rename, which is the
292/// one thing this body must not do by accident.
293///
294/// Models the contract's `sessionUpdateRequest` schema.
295#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
296#[serde(default)]
297pub struct SessionUpdateRequest {
298 /// The contract's `display_name`. `None` sends nothing;
299 /// `Some(String::new())` clears the rename.
300 #[serde(skip_serializing_if = "Option::is_none")]
301 pub display_name: Option<String>,
302}
303
304impl ContractModel for SessionUpdateRequest {
305 const SCHEMA: &'static str = "sessionUpdateRequest";
306}
307
308impl SessionListResponse {
309 /// This listing as one page of the crate's pagination convention.
310 ///
311 /// The envelope is `items` plus `next_cursor`, which is exactly
312 /// [`crate::page::Page`] — so a caller walking sessions reaches the same
313 /// loop, the same three spellings of "no more pages", and the same guard
314 /// against a server that repeats a cursor as every other listing.
315 #[must_use]
316 pub fn into_page(self) -> crate::page::Page<SessionItem> {
317 crate::page::Page {
318 items: self.items,
319 next_cursor: Some(self.next_cursor),
320 }
321 }
322}
323
324impl SessionTracesResponse {
325 /// Take this page's paged part out, leaving the envelope behind.
326 ///
327 /// Only `traces` is paged; `session`, `links`, and `schema` repeat on
328 /// every page and stay put. What comes out is exactly
329 /// [`crate::page::Page`], so a walk over a session's traces reaches the
330 /// same loop, the same three spellings of "no more pages", and the same
331 /// guard against a repeated cursor as every listing. `next_cursor` goes
332 /// with the page — it belongs to the walk, not to the session — so the
333 /// envelope left behind is what the whole composite looks like once the
334 /// walk is done.
335 ///
336 /// Not `into_page`: the envelope carries facts a page cannot, and a
337 /// conversion that discarded them would have to be undone by every walk.
338 #[must_use]
339 pub fn take_page(&mut self) -> crate::page::Page<TraceDetail> {
340 crate::page::Page {
341 items: std::mem::take(&mut self.traces),
342 next_cursor: Some(std::mem::take(&mut self.next_cursor)),
343 }
344 }
345}