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}