1use crate::agent::{
2 AgentId, Budget, ConversationId, CorrelationId, LogPosition, SdkInfo, SessionStatus, TokenUsage,
3};
4use crate::error::InvalidError;
5use crate::graph::SourceRef;
6use crate::limits::{
7 MAX_FILTER_SOURCE_NAME_BYTES, MAX_HEARTBEAT_SESSIONS, MAX_SESSION_CURSOR_BYTES,
8 MAX_SESSION_LABEL_BYTES, MAX_SESSION_TEXT_QUERY_BYTES,
9};
10use crate::schema::Digest32;
11use crate::validate::Validate;
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum LinkSurface {
19 Memory,
20 Kv,
21 GraphNode,
22 GraphEdge,
23 Projection,
24 Child,
25}
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum LinkRelation {
31 Wrote,
32 Recalled,
33 Touched,
34}
35
36pub mod request {
38 use super::*;
39
40 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
41 pub struct SessionGet {
42 pub stream: String,
43 pub id: ConversationId,
44 }
45
46 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
47 pub struct SessionList {
48 pub stream: String,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub status: Option<SessionStatus>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub root: Option<ConversationId>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub label_prefix: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub agent: Option<AgentId>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub text: Option<String>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub cursor: Option<String>,
63 pub limit: u32,
65 pub want_total: bool,
66 }
67
68 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69 pub struct SessionEvents {
70 pub stream: String,
71 pub id: ConversationId,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub cursor: Option<String>,
74 pub limit: u32,
76 pub fixed_frontier: bool,
77 }
78
79 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
80 pub struct SessionState {
81 pub stream: String,
82 pub id: ConversationId,
83 pub history_limit: u32,
85 }
86
87 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
88 pub struct SessionLinks {
89 pub stream: String,
90 pub id: ConversationId,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub surface: Option<LinkSurface>,
93 }
94
95 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
96 pub struct SessionSources {
97 pub stream: String,
98 pub id: ConversationId,
99 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
101 pub lane_only: bool,
102 }
103
104 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
105 pub struct SessionChanges {
106 pub stream: String,
107 pub after: u64,
108 pub limit: u32,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub series: Option<u64>,
116 }
117}
118
119#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
123pub struct SourceFrontier {
124 pub topic_id: u32,
125 pub topic_generation: u64,
126 pub partition_id: u32,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub folded: Option<u64>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub head: Option<u64>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub retained_from: Option<u64>,
136}
137
138#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum GapReason {
142 ExpiredBeforeFold,
144 Pruned,
146 Truncated,
148 Rebuilding,
150 #[serde(other)]
151 Unrecognized,
152}
153
154#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
156pub struct SourceGap {
157 pub topic_id: u32,
158 pub topic_generation: u64,
159 pub partition_id: u32,
160 pub from: u64,
161 pub to: u64,
162 pub reason: GapReason,
163}
164
165#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
169pub struct PayloadRange {
170 pub topic_id: u32,
171 pub topic_generation: u64,
172 pub partition_id: u32,
173 pub first: u64,
174 pub last: u64,
175}
176
177#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180pub enum StateOutcome {
181 Applied,
183 Rejected,
185 Stale,
187 Duplicate,
189 #[serde(other)]
190 Unrecognized,
191}
192
193#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
195pub struct StateChange {
196 pub revision: u64,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub op_id: Option<String>,
200 pub outcome: StateOutcome,
201 pub at: LogPosition,
202 pub broker_ts: u64,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub old_digest: Option<Digest32>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub new_digest: Option<Digest32>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub reason: Option<String>,
209}
210
211#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
213pub struct SessionFlags {
214 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
215 pub label_truncated: bool,
216 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
217 pub overflow: bool,
218 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
219 pub events_truncated: bool,
220 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
221 pub lane_conflict: bool,
222 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
225 pub control_conflict: bool,
226 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
227 pub rebuilding: bool,
228 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
231 pub liveness_unknown: bool,
232}
233
234#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
236pub struct SessionInfo {
237 pub stream: String,
238 pub id: ConversationId,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub label: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub namespace: Option<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub agent: Option<AgentId>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub parent: Option<ConversationId>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub root: Option<ConversationId>,
249 pub status: SessionStatus,
250 pub idle: bool,
251 pub over_budget: bool,
252 pub pause_requested: bool,
253 pub cancel_requested: bool,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub started_at: Option<u64>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub ended_at: Option<u64>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub first_event_at: Option<u64>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub last_event_at: Option<u64>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub last_heartbeat_at: Option<u64>,
264 pub events: u64,
265 pub model_calls: u64,
266 pub tool_calls: u64,
267 pub tokens_in: u64,
268 pub tokens_out: u64,
269 pub cost_micros: u64,
270 pub errors: u64,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub budget: Option<Budget>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub sdk: Option<SdkInfo>,
275 #[serde(default)]
276 pub flags: SessionFlags,
277 #[serde(default, skip_serializing_if = "is_zero")]
279 pub held: u64,
280 #[serde(default, skip_serializing_if = "Vec::is_empty")]
281 pub frontier: Vec<SourceFrontier>,
282}
283
284fn is_zero(value: &u64) -> bool {
285 *value == 0
286}
287
288#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
290pub struct SessionPage {
291 pub items: Vec<SessionInfo>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub cursor: Option<String>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub total: Option<u64>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub searched: Option<u64>,
298 pub truncated: bool,
299 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
301 pub rebuilding: bool,
302}
303
304#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
307pub struct SessionEvent {
308 pub at: SourceRef,
309 pub session: ConversationId,
310 pub broker_ts: u64,
311 pub kind: String,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub operation: Option<String>,
314 pub display: String,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub agent: Option<AgentId>,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub addressee: Option<String>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub correlation: Option<CorrelationId>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub cause: Option<LogPosition>,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub tool: Option<String>,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub usage: Option<TokenUsage>,
327 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
328 pub after_end: bool,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub verified_actor: Option<String>,
332 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
333 pub summary: BTreeMap<String, serde_json::Value>,
334}
335
336#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
338pub struct SessionEventsPage {
339 pub items: Vec<SessionEvent>,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub cursor: Option<String>,
342 #[serde(default, skip_serializing_if = "Vec::is_empty")]
343 pub ranges: Vec<PayloadRange>,
344 #[serde(default, skip_serializing_if = "Vec::is_empty")]
345 pub frontier: Vec<SourceFrontier>,
346 pub fixed_frontier: bool,
347 #[serde(default, skip_serializing_if = "Vec::is_empty")]
348 pub gaps: Vec<SourceGap>,
349}
350
351#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
353pub struct SessionStateView {
354 pub revision: u64,
355 pub document: serde_json::Value,
356 #[serde(default, skip_serializing_if = "Vec::is_empty")]
357 pub history: Vec<StateChange>,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub frontier: Option<SourceFrontier>,
360 pub complete: bool,
361}
362
363#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
365pub struct SessionLink {
366 pub surface: LinkSurface,
367 pub resource: String,
368 pub item: String,
369 pub relation: LinkRelation,
370 pub first: LogPosition,
371 pub last: LogPosition,
372}
373
374#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
376pub struct SessionLinksView {
377 pub links: Vec<SessionLink>,
378 #[serde(default, skip_serializing_if = "Vec::is_empty")]
379 pub frontier: Vec<SourceFrontier>,
380 pub truncated: bool,
381}
382
383#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
385pub struct SessionSources {
386 pub sources: Vec<SourceFrontier>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub lane: Option<(u32, u64, u32)>,
390}
391
392#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
394pub struct SessionChangeRow {
395 pub seq: u64,
396 pub sessions: Vec<ConversationId>,
397 #[serde(default, skip_serializing_if = "Vec::is_empty")]
402 pub positions: Vec<LogPosition>,
403 pub truncated: bool,
404 #[serde(default, skip_serializing_if = "SessionChangeOrigin::is_records")]
405 pub origin: SessionChangeOrigin,
406}
407
408#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "snake_case")]
411pub enum SessionChangeOrigin {
412 #[default]
414 Records,
415 Links,
417 Maintenance,
420 Expiry,
422}
423
424impl SessionChangeOrigin {
425 pub fn is_records(&self) -> bool {
426 *self == Self::Records
427 }
428}
429
430#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
436pub struct SessionChanges {
437 pub rows: Vec<SessionChangeRow>,
438 pub floor: u64,
439 pub resync: bool,
440 #[serde(default, skip_serializing_if = "is_zero_series")]
443 pub series: u64,
444}
445
446#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
449#[non_exhaustive]
450pub enum SessionReply {
451 Ok(SessionOutcome),
452 Err(SessionError),
453}
454
455#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
457#[non_exhaustive]
458pub enum SessionOutcome {
459 Info(Box<SessionInfo>),
460 Page(SessionPage),
461 Events(SessionEventsPage),
462 State(SessionStateView),
463 Links(SessionLinksView),
464 Sources(SessionSources),
465 Changes(SessionChanges),
466}
467
468#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
470#[non_exhaustive]
471pub enum SessionError {
472 #[error("sessions not supported: {0}")]
473 Unsupported(String),
474 #[error("session not found: {0}")]
475 NotFound(String),
476 #[error("stream is not registered for sessions: {0}")]
477 NotRegistered(String),
478 #[error("invalid session request: {0}")]
479 Invalid(String),
480 #[error("session read not authorized: {0}")]
481 Unauthorized(String),
482 #[error("stale session generation: {0}")]
484 Stale(String),
485 #[error("session backend error: {0}")]
486 Backend(String),
487 #[error("temporarily unavailable: {0}")]
489 Unavailable(String),
490}
491
492#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
496pub struct SessionHeartbeat {
497 pub process: String,
498 pub stream: String,
499 pub sessions: Vec<ConversationId>,
500}
501
502impl Validate for request::SessionList {
503 fn validate(&self) -> Result<(), InvalidError> {
504 if self.stream.is_empty() || self.stream.len() > MAX_FILTER_SOURCE_NAME_BYTES {
505 return Err(InvalidError::new(format!(
506 "session list stream must be 1..={MAX_FILTER_SOURCE_NAME_BYTES} bytes"
507 )));
508 }
509 let bounded = [
510 ("label prefix", &self.label_prefix, MAX_SESSION_LABEL_BYTES),
511 ("text", &self.text, MAX_SESSION_TEXT_QUERY_BYTES),
512 ("cursor", &self.cursor, MAX_SESSION_CURSOR_BYTES),
513 ];
514 for (label, value, cap) in bounded {
515 if let Some(value) = value
516 && value.len() > cap
517 {
518 return Err(InvalidError::new(format!(
519 "session list {label} is {}B, exceeds cap {cap}B",
520 value.len()
521 )));
522 }
523 }
524 Ok(())
525 }
526}
527
528impl Validate for SessionHeartbeat {
529 fn validate(&self) -> Result<(), InvalidError> {
530 if self.sessions.len() > MAX_HEARTBEAT_SESSIONS {
531 return Err(InvalidError::new("heartbeat lists too many sessions"));
532 }
533 Ok(())
534 }
535}
536
537#[expect(clippy::trivially_copy_pass_by_ref, reason = "serde skip predicate")]
538fn is_zero_series(series: &u64) -> bool {
539 *series == 0
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 #[test]
547 fn given_a_session_list_over_its_caps_when_validated_then_should_refuse_it() {
548 let list = request::SessionList {
549 stream: "agents".to_owned(),
550 status: None,
551 root: None,
552 label_prefix: None,
553 agent: None,
554 text: None,
555 cursor: None,
556 limit: 0,
557 want_total: false,
558 };
559 list.validate().expect("a plain session list is valid");
560 request::SessionList {
561 stream: "s".repeat(MAX_FILTER_SOURCE_NAME_BYTES),
562 label_prefix: Some("l".repeat(MAX_SESSION_LABEL_BYTES)),
563 text: Some("t".repeat(MAX_SESSION_TEXT_QUERY_BYTES)),
564 cursor: Some("c".repeat(MAX_SESSION_CURSOR_BYTES)),
565 ..list.clone()
566 }
567 .validate()
568 .expect("every filter at its cap is valid");
569 request::SessionList {
570 text: Some(String::new()),
571 cursor: Some(String::new()),
572 ..list.clone()
573 }
574 .validate()
575 .expect("an empty text and cursor are within their caps");
576 for invalid in [
577 request::SessionList {
578 stream: String::new(),
579 ..list.clone()
580 },
581 request::SessionList {
582 stream: "s".repeat(MAX_FILTER_SOURCE_NAME_BYTES + 1),
583 ..list.clone()
584 },
585 request::SessionList {
586 label_prefix: Some("l".repeat(MAX_SESSION_LABEL_BYTES + 1)),
587 ..list.clone()
588 },
589 request::SessionList {
590 text: Some("t".repeat(MAX_SESSION_TEXT_QUERY_BYTES + 1)),
591 ..list.clone()
592 },
593 request::SessionList {
594 cursor: Some("c".repeat(MAX_SESSION_CURSOR_BYTES + 1)),
595 ..list.clone()
596 },
597 ] {
598 assert!(invalid.validate().is_err(), "{invalid:?}");
599 }
600 }
601}