1use crate::agent::{
2 AgentId, Budget, ConversationId, CorrelationId, LogPosition, SdkInfo, SessionStatus, TokenUsage,
3};
4use crate::graph::SourceRef;
5use crate::schema::Digest32;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum LinkSurface {
13 Memory,
14 Kv,
15 GraphNode,
16 GraphEdge,
17 Projection,
18 Child,
19}
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum LinkRelation {
25 Wrote,
26 Recalled,
27 Touched,
28}
29
30pub mod request {
32 use super::*;
33
34 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
35 pub struct SessionGet {
36 pub stream: String,
37 pub id: ConversationId,
38 }
39
40 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
41 pub struct SessionList {
42 pub stream: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub status: Option<SessionStatus>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub root: Option<ConversationId>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub label_prefix: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub agent: Option<AgentId>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub text: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub cursor: Option<String>,
57 pub limit: u32,
59 pub want_total: bool,
60 }
61
62 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
63 pub struct SessionEvents {
64 pub stream: String,
65 pub id: ConversationId,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub cursor: Option<String>,
68 pub limit: u32,
70 pub fixed_frontier: bool,
71 }
72
73 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
74 pub struct SessionState {
75 pub stream: String,
76 pub id: ConversationId,
77 pub history_limit: u32,
79 }
80
81 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
82 pub struct SessionLinks {
83 pub stream: String,
84 pub id: ConversationId,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub surface: Option<LinkSurface>,
87 }
88
89 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
90 pub struct SessionSources {
91 pub stream: String,
92 pub id: ConversationId,
93 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
95 pub lane_only: bool,
96 }
97
98 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
99 pub struct SessionChanges {
100 pub stream: String,
101 pub after: u64,
102 pub limit: u32,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub series: Option<u64>,
109 }
110}
111
112#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
116pub struct SourceFrontier {
117 pub topic_id: u32,
118 pub topic_generation: u64,
119 pub partition_id: u32,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub folded: Option<u64>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub head: Option<u64>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub retained_from: Option<u64>,
129}
130
131#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134pub enum GapReason {
135 ExpiredBeforeFold,
137 Pruned,
139 Truncated,
141 Rebuilding,
143 #[serde(other)]
144 Unrecognized,
145}
146
147#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
149pub struct SourceGap {
150 pub topic_id: u32,
151 pub topic_generation: u64,
152 pub partition_id: u32,
153 pub from: u64,
154 pub to: u64,
155 pub reason: GapReason,
156}
157
158#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
162pub struct PayloadRange {
163 pub topic_id: u32,
164 pub topic_generation: u64,
165 pub partition_id: u32,
166 pub first: u64,
167 pub last: u64,
168}
169
170#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "snake_case")]
173pub enum StateOutcome {
174 Applied,
176 Rejected,
178 Stale,
180 Duplicate,
182 #[serde(other)]
183 Unrecognized,
184}
185
186#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
188pub struct StateChange {
189 pub revision: u64,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub op_id: Option<String>,
193 pub outcome: StateOutcome,
194 pub at: LogPosition,
195 pub broker_ts: u64,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub old_digest: Option<Digest32>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub new_digest: Option<Digest32>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub reason: Option<String>,
202}
203
204#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
206pub struct SessionFlags {
207 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
208 pub label_truncated: bool,
209 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
210 pub overflow: bool,
211 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
212 pub events_truncated: bool,
213 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
214 pub lane_conflict: bool,
215 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
218 pub control_conflict: bool,
219 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
220 pub rebuilding: bool,
221 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
224 pub liveness_unknown: bool,
225}
226
227#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
229pub struct SessionInfo {
230 pub stream: String,
231 pub id: ConversationId,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub label: Option<String>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub namespace: Option<String>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub agent: Option<AgentId>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub parent: Option<ConversationId>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub root: Option<ConversationId>,
242 pub status: SessionStatus,
243 pub idle: bool,
244 pub over_budget: bool,
245 pub pause_requested: bool,
246 pub cancel_requested: bool,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub started_at: Option<u64>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub ended_at: Option<u64>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub first_event_at: Option<u64>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub last_event_at: Option<u64>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub last_heartbeat_at: Option<u64>,
257 pub events: u64,
258 pub model_calls: u64,
259 pub tool_calls: u64,
260 pub tokens_in: u64,
261 pub tokens_out: u64,
262 pub cost_micros: u64,
263 pub errors: u64,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub budget: Option<Budget>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub sdk: Option<SdkInfo>,
268 #[serde(default)]
269 pub flags: SessionFlags,
270 #[serde(default, skip_serializing_if = "is_zero")]
272 pub held: u64,
273 #[serde(default, skip_serializing_if = "Vec::is_empty")]
274 pub frontier: Vec<SourceFrontier>,
275}
276
277fn is_zero(value: &u64) -> bool {
278 *value == 0
279}
280
281#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
283pub struct SessionPage {
284 pub items: Vec<SessionInfo>,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub cursor: Option<String>,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub total: Option<u64>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub searched: Option<u64>,
291 pub truncated: bool,
292}
293
294#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
297pub struct SessionEvent {
298 pub at: SourceRef,
299 pub session: ConversationId,
300 pub broker_ts: u64,
301 pub kind: String,
302 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub operation: Option<String>,
304 pub display: String,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub agent: Option<AgentId>,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub addressee: Option<String>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub correlation: Option<CorrelationId>,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub cause: Option<LogPosition>,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub tool: Option<String>,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub usage: Option<TokenUsage>,
317 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
318 pub after_end: bool,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub verified_actor: Option<String>,
322 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
323 pub summary: BTreeMap<String, serde_json::Value>,
324}
325
326#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
328pub struct SessionEventsPage {
329 pub items: Vec<SessionEvent>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub cursor: Option<String>,
332 #[serde(default, skip_serializing_if = "Vec::is_empty")]
333 pub ranges: Vec<PayloadRange>,
334 #[serde(default, skip_serializing_if = "Vec::is_empty")]
335 pub frontier: Vec<SourceFrontier>,
336 pub fixed_frontier: bool,
337 #[serde(default, skip_serializing_if = "Vec::is_empty")]
338 pub gaps: Vec<SourceGap>,
339}
340
341#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
343pub struct SessionStateView {
344 pub revision: u64,
345 pub document: serde_json::Value,
346 #[serde(default, skip_serializing_if = "Vec::is_empty")]
347 pub history: Vec<StateChange>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub frontier: Option<SourceFrontier>,
350 pub complete: bool,
351}
352
353#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
355pub struct SessionLink {
356 pub surface: LinkSurface,
357 pub resource: String,
358 pub item: String,
359 pub relation: LinkRelation,
360 pub first: LogPosition,
361 pub last: LogPosition,
362}
363
364#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
366pub struct SessionLinksView {
367 pub links: Vec<SessionLink>,
368 #[serde(default, skip_serializing_if = "Vec::is_empty")]
369 pub frontier: Vec<SourceFrontier>,
370 pub truncated: bool,
371}
372
373#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
375pub struct SessionSources {
376 pub sources: Vec<SourceFrontier>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub lane: Option<(u32, u64, u32)>,
380}
381
382#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
384pub struct SessionChangeRow {
385 pub seq: u64,
386 pub sessions: Vec<ConversationId>,
387 #[serde(default, skip_serializing_if = "Vec::is_empty")]
392 pub positions: Vec<LogPosition>,
393 pub truncated: bool,
394 #[serde(default, skip_serializing_if = "SessionChangeOrigin::is_records")]
395 pub origin: SessionChangeOrigin,
396}
397
398#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(rename_all = "snake_case")]
401pub enum SessionChangeOrigin {
402 #[default]
404 Records,
405 Links,
407 Maintenance,
410 Expiry,
412}
413
414impl SessionChangeOrigin {
415 pub fn is_records(&self) -> bool {
416 *self == Self::Records
417 }
418}
419
420#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
423pub struct SessionChanges {
424 pub rows: Vec<SessionChangeRow>,
425 pub floor: u64,
426 pub resync: bool,
427 #[serde(default, skip_serializing_if = "is_zero_series")]
430 pub series: u64,
431}
432
433#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
436#[non_exhaustive]
437pub enum SessionReply {
438 Ok(SessionOutcome),
439 Err(SessionError),
440}
441
442#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
444#[non_exhaustive]
445pub enum SessionOutcome {
446 Info(Box<SessionInfo>),
447 Page(SessionPage),
448 Events(SessionEventsPage),
449 State(SessionStateView),
450 Links(SessionLinksView),
451 Sources(SessionSources),
452 Changes(SessionChanges),
453}
454
455#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
457#[non_exhaustive]
458pub enum SessionError {
459 #[error("sessions not supported: {0}")]
460 Unsupported(String),
461 #[error("session not found: {0}")]
462 NotFound(String),
463 #[error("stream is not registered for sessions: {0}")]
464 NotRegistered(String),
465 #[error("invalid session request: {0}")]
466 Invalid(String),
467 #[error("session read not authorized: {0}")]
468 Unauthorized(String),
469 #[error("stale session generation: {0}")]
471 Stale(String),
472 #[error("session backend error: {0}")]
473 Backend(String),
474 #[error("temporarily unavailable: {0}")]
476 Unavailable(String),
477}
478
479#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
483pub struct SessionHeartbeat {
484 pub process: String,
485 pub stream: String,
486 pub sessions: Vec<ConversationId>,
487}
488
489impl crate::validate::Validate for SessionHeartbeat {
490 fn validate(&self) -> Result<(), crate::error::InvalidError> {
491 if self.sessions.len() > crate::limits::MAX_HEARTBEAT_SESSIONS {
492 return Err(crate::error::InvalidError::new(
493 "heartbeat lists too many sessions",
494 ));
495 }
496 Ok(())
497 }
498}
499
500#[expect(clippy::trivially_copy_pass_by_ref, reason = "serde skip predicate")]
501fn is_zero_series(series: &u64) -> bool {
502 *series == 0
503}