Skip to main content

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}