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.
248///
249/// Models the contract's `SessionTracesResponse` schema.
250#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
251#[serde(default)]
252#[non_exhaustive]
253pub struct SessionTracesResponse {
254    /// The contract's `links`.
255    #[serde(deserialize_with = "super::null_default")]
256    pub links: Vec<SpanLinkItem>,
257
258    /// The contract's `schema`.
259    pub schema: String,
260
261    /// The contract's `session`.
262    #[serde(deserialize_with = "super::null_default")]
263    pub session: SessionItem,
264
265    /// The contract's `traces`.
266    #[serde(deserialize_with = "super::null_default")]
267    pub traces: Vec<TraceDetail>,
268}
269
270impl ContractModel for SessionTracesResponse {
271    const SCHEMA: &'static str = "SessionTracesResponse";
272}
273
274/// The `PATCH /v1/sessions/{id}` body.
275///
276/// `display_name` distinguishes three states the server acts on differently:
277/// absent is nothing to update (a 400), while an explicit null or an empty
278/// string clears the rename back to the auto-derived title.
279///
280/// So the field is an [`Option`] that is omitted when unset, or the type could
281/// not express the distinction it documents: a `None` that still serialized
282/// would arrive as the empty string and clear a user's rename, which is the
283/// one thing this body must not do by accident.
284///
285/// Models the contract's `sessionUpdateRequest` schema.
286#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
287#[serde(default)]
288pub struct SessionUpdateRequest {
289    /// The contract's `display_name`. `None` sends nothing;
290    /// `Some(String::new())` clears the rename.
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub display_name: Option<String>,
293}
294
295impl ContractModel for SessionUpdateRequest {
296    const SCHEMA: &'static str = "sessionUpdateRequest";
297}
298
299impl SessionListResponse {
300    /// This listing as one page of the crate's pagination convention.
301    ///
302    /// The envelope is `items` plus `next_cursor`, which is exactly
303    /// [`crate::page::Page`] — so a caller walking sessions reaches the same
304    /// loop, the same three spellings of "no more pages", and the same guard
305    /// against a server that repeats a cursor as every other listing.
306    #[must_use]
307    pub fn into_page(self) -> crate::page::Page<SessionItem> {
308        crate::page::Page {
309            items: self.items,
310            next_cursor: Some(self.next_cursor),
311        }
312    }
313}