1use crate::agent::{Session, Sessions};
2use crate::context::ContextMessage;
3use crate::error::LaserError;
4use crate::laser::Laser;
5use crate::types::ConversationId;
6use iggy::prelude::{Consumer, Identifier, MessageClient, PollingStrategy, TopicClient};
7use laser_wire::codes::{
8 AGDX_SESSION_CHANGES_CODE, AGDX_SESSION_EVENTS_CODE, AGDX_SESSION_GET_CODE,
9 AGDX_SESSION_LINKS_CODE, AGDX_SESSION_LIST_CODE, AGDX_SESSION_SOURCES_CODE,
10 AGDX_SESSION_STATE_CODE,
11};
12use laser_wire::graph::SourceRef;
13use laser_wire::session::request;
14use laser_wire::session::{
15 LinkSurface, SessionChanges, SessionEventsPage, SessionInfo, SessionLinksView, SessionOutcome,
16 SessionPage, SessionReply, SessionSources, SessionStateView,
17};
18use std::collections::{BTreeSet, VecDeque};
19use std::time::Duration;
20
21impl Sessions {
22 pub async fn get(&self, id: ConversationId) -> Result<SessionInfo, LaserError> {
24 let request = request::SessionGet {
25 stream: self.stream()?.to_owned(),
26 id: id.into(),
27 };
28 match self.read(AGDX_SESSION_GET_CODE, &request).await? {
29 SessionOutcome::Info(info) => Ok(*info),
30 _ => Err(unexpected("get")),
31 }
32 }
33
34 pub fn list(&self) -> SessionListRequest {
37 SessionListRequest {
38 sessions: self.clone(),
39 status: None,
40 root: None,
41 label_prefix: None,
42 agent: None,
43 text: None,
44 cursor: None,
45 limit: 0,
46 total: false,
47 }
48 }
49
50 pub fn events(&self, id: ConversationId) -> SessionEventsRequest {
53 SessionEventsRequest {
54 sessions: self.clone(),
55 id,
56 cursor: None,
57 limit: 0,
58 fixed_frontier: false,
59 }
60 }
61
62 pub async fn state(
65 &self,
66 id: ConversationId,
67 history_limit: u32,
68 ) -> Result<SessionStateView, LaserError> {
69 let request = request::SessionState {
70 stream: self.stream()?.to_owned(),
71 id: id.into(),
72 history_limit,
73 };
74 match self.read(AGDX_SESSION_STATE_CODE, &request).await? {
75 SessionOutcome::State(view) => Ok(view),
76 _ => Err(unexpected("state")),
77 }
78 }
79
80 pub async fn links(
83 &self,
84 id: ConversationId,
85 surface: Option<LinkSurface>,
86 ) -> Result<SessionLinksView, LaserError> {
87 let request = request::SessionLinks {
88 stream: self.stream()?.to_owned(),
89 id: id.into(),
90 surface,
91 };
92 match self.read(AGDX_SESSION_LINKS_CODE, &request).await? {
93 SessionOutcome::Links(view) => Ok(view),
94 _ => Err(unexpected("links")),
95 }
96 }
97
98 pub async fn sources(&self, id: ConversationId) -> Result<SessionSources, LaserError> {
100 let request = request::SessionSources {
101 stream: self.stream()?.to_owned(),
102 id: id.into(),
103 lane_only: false,
104 };
105 match self.read(AGDX_SESSION_SOURCES_CODE, &request).await? {
106 SessionOutcome::Sources(sources) => Ok(sources),
107 _ => Err(unexpected("sources")),
108 }
109 }
110
111 pub(crate) async fn registered_lane(
112 &self,
113 id: ConversationId,
114 ) -> Result<Option<(u32, u64, u32)>, LaserError> {
115 let request = request::SessionSources {
116 stream: self.stream()?.to_owned(),
117 id: id.into(),
118 lane_only: true,
119 };
120 match self.read(AGDX_SESSION_SOURCES_CODE, &request).await? {
121 SessionOutcome::Sources(view) => Ok(view.lane),
122 _ => Err(unexpected("sources")),
123 }
124 }
125
126 pub async fn changes(&self, after: u64, limit: u32) -> Result<SessionChanges, LaserError> {
129 self.changes_in(after, limit, None).await
130 }
131
132 async fn changes_in(
135 &self,
136 after: u64,
137 limit: u32,
138 series: Option<u64>,
139 ) -> Result<SessionChanges, LaserError> {
140 let request = request::SessionChanges {
141 stream: self.stream()?.to_owned(),
142 after,
143 limit,
144 series,
145 };
146 match self.read(AGDX_SESSION_CHANGES_CODE, &request).await? {
147 SessionOutcome::Changes(changes) => Ok(changes),
148 _ => Err(unexpected("changes")),
149 }
150 }
151
152 pub async fn watch(&self, poll_every: Duration) -> Result<SessionWatch, LaserError> {
157 let mut after = 0;
158 let mut series = None;
159 loop {
160 let page = self.changes_in(after, 0, series).await?;
161 if page.series != 0 {
162 series = Some(page.series);
163 }
164 if page.resync {
165 after = page.floor;
166 continue;
167 }
168 match page.rows.iter().map(|row| row.seq).max() {
169 Some(seq) => after = seq,
170 None => break,
171 }
172 }
173 Ok(SessionWatch {
174 sessions: self.clone(),
175 poll_every,
176 after,
177 series,
178 ready: VecDeque::new(),
179 })
180 }
181
182 async fn read<T: serde::Serialize>(
183 &self,
184 code: u32,
185 request: &T,
186 ) -> Result<SessionOutcome, LaserError> {
187 let laser = self.laser();
188 if !laser.capabilities().await.sessions {
189 return Err(LaserError::unsupported_feature(
190 "sessions",
191 "sessions",
192 "session reads need a deployment that indexes sessions",
193 ));
194 }
195 let payload = laser_wire::framing::encode_named(request)?;
196 let reply = laser.send_raw_with_response(code, payload).await?;
197 match crate::error::decode_managed_reply::<SessionReply>(&reply)? {
198 SessionReply::Ok(outcome) => Ok(outcome),
199 SessionReply::Err(error) => Err(error.into()),
200 _ => Err(unexpected("reply")),
201 }
202 }
203}
204
205impl Session {
206 pub async fn status(&self) -> Result<SessionInfo, LaserError> {
209 self.laser.sessions().get(self.conversation()).await
210 }
211}
212
213#[must_use = "a session list does nothing until fetch().await"]
215pub struct SessionListRequest {
216 sessions: Sessions,
217 status: Option<laser_wire::agent::SessionStatus>,
218 root: Option<ConversationId>,
219 label_prefix: Option<String>,
220 agent: Option<laser_wire::agent::AgentId>,
221 text: Option<String>,
222 cursor: Option<String>,
223 limit: u32,
224 total: bool,
225}
226
227impl SessionListRequest {
228 pub fn status(mut self, status: laser_wire::agent::SessionStatus) -> Self {
230 self.status = Some(status);
231 self
232 }
233
234 pub fn root(mut self, root: ConversationId) -> Self {
236 self.root = Some(root);
237 self
238 }
239
240 pub fn label_prefix(mut self, prefix: impl Into<String>) -> Self {
242 self.label_prefix = Some(prefix.into());
243 self
244 }
245
246 pub fn agent(mut self, agent: impl Into<laser_wire::agent::AgentId>) -> Self {
248 let agent = agent.into();
249 self.agent = Some(agent);
250 self
251 }
252
253 pub fn text(mut self, text: impl Into<String>) -> Self {
255 self.text = Some(text.into());
256 self
257 }
258
259 pub fn cursor(mut self, cursor: impl Into<String>) -> Self {
261 self.cursor = Some(cursor.into());
262 self
263 }
264
265 pub fn limit(mut self, limit: u32) -> Self {
267 self.limit = limit;
268 self
269 }
270
271 pub fn total(mut self) -> Self {
273 self.total = true;
274 self
275 }
276
277 pub async fn fetch(self) -> Result<SessionPage, LaserError> {
279 let request = request::SessionList {
280 stream: self.sessions.stream()?.to_owned(),
281 status: self.status,
282 root: self.root.map(Into::into),
283 label_prefix: self.label_prefix,
284 agent: self.agent,
285 text: self.text,
286 cursor: self.cursor,
287 limit: self.limit,
288 want_total: self.total,
289 };
290 match self.sessions.read(AGDX_SESSION_LIST_CODE, &request).await? {
291 SessionOutcome::Page(page) => Ok(page),
292 _ => Err(unexpected("list")),
293 }
294 }
295}
296
297#[must_use = "a session events read does nothing until fetch().await"]
299pub struct SessionEventsRequest {
300 sessions: Sessions,
301 id: ConversationId,
302 cursor: Option<String>,
303 limit: u32,
304 fixed_frontier: bool,
305}
306
307impl SessionEventsRequest {
308 pub fn cursor(mut self, cursor: impl Into<String>) -> Self {
310 self.cursor = Some(cursor.into());
311 self
312 }
313
314 pub fn limit(mut self, limit: u32) -> Self {
316 self.limit = limit;
317 self
318 }
319
320 pub fn fixed_frontier(mut self) -> Self {
322 self.fixed_frontier = true;
323 self
324 }
325
326 pub async fn fetch(self) -> Result<SessionEventsPage, LaserError> {
328 let request = request::SessionEvents {
329 stream: self.sessions.stream()?.to_owned(),
330 id: self.id.into(),
331 cursor: self.cursor,
332 limit: self.limit,
333 fixed_frontier: self.fixed_frontier,
334 };
335 match self
336 .sessions
337 .read(AGDX_SESSION_EVENTS_CODE, &request)
338 .await?
339 {
340 SessionOutcome::Events(page) => Ok(page),
341 _ => Err(unexpected("events")),
342 }
343 }
344}
345
346#[derive(Debug, Clone, PartialEq, Eq)]
348pub enum SessionChange {
349 Changed(Vec<laser_wire::agent::ConversationId>),
351 Resync,
354}
355
356pub struct SessionWatch {
359 sessions: Sessions,
360 poll_every: Duration,
361 after: u64,
362 series: Option<u64>,
365 ready: VecDeque<SessionChange>,
366}
367
368impl SessionWatch {
369 pub async fn next(&mut self) -> Result<SessionChange, LaserError> {
371 loop {
372 if let Some(change) = self.ready.pop_front() {
373 return Ok(change);
374 }
375 let page = match self.sessions.changes_in(self.after, 0, self.series).await {
376 Ok(page) => page,
377 Err(error) if error.code() == crate::query::ResultCode::Stale => {
381 tokio::time::sleep(self.poll_every).await;
382 continue;
383 }
384 Err(error) => return Err(error),
385 };
386 if page.series != 0 {
387 self.series = Some(page.series);
388 }
389 if page.resync {
390 self.after = page.floor;
391 return Ok(SessionChange::Resync);
392 }
393 if page.rows.is_empty() {
394 tokio::time::sleep(self.poll_every).await;
395 continue;
396 }
397 if page.rows.iter().any(|row| row.truncated) {
401 if let Some(seq) = page.rows.iter().map(|row| row.seq).max() {
402 self.after = self.after.max(seq);
403 }
404 return Ok(SessionChange::Resync);
405 }
406 let mut seen = BTreeSet::new();
407 let mut changed = Vec::new();
408 for row in &page.rows {
409 self.after = self.after.max(row.seq);
410 for session in &row.sessions {
411 if seen.insert(*session) {
412 changed.push(*session);
413 }
414 }
415 }
416 self.ready.push_back(SessionChange::Changed(changed));
417 }
418 }
419}
420
421impl Laser {
422 pub async fn read_at(&self, at: &SourceRef) -> Result<Option<ContextMessage>, LaserError> {
427 let SourceRef::Message {
428 stream,
429 topic,
430 partition,
431 offset,
432 generation,
433 ..
434 } = at
435 else {
436 return Err(LaserError::Invalid(
437 "only a message reference names a log record".to_owned(),
438 ));
439 };
440 let client = self.client();
441 let stream_id = Identifier::numeric(*stream)?;
442 let topic_id = Identifier::numeric(*topic)?;
443 let consumer = Consumer::new(Identifier::named("laser-read-at")?);
444 let Some((source, message)) = read_at_checked(
445 *topic,
446 *generation,
447 || async {
448 Ok(client
449 .get_topic(&stream_id, &topic_id)
450 .await?
451 .map(|details| ReadAtSource {
452 id: details.id,
453 generation: details.created_at.as_micros(),
454 name: details.name,
455 }))
456 },
457 || async {
458 let polled = client
459 .poll_messages(
460 &stream_id,
461 &topic_id,
462 Some(*partition),
463 &consumer,
464 &PollingStrategy::offset(*offset),
465 1,
466 false,
467 )
468 .await?;
469 Ok(polled
470 .messages
471 .into_iter()
472 .find(|message| message.header.offset == *offset))
473 },
474 )
475 .await?
476 else {
477 return Ok(None);
478 };
479 let (provenance, envelope) = crate::agent::provenance_and_envelope(&message)?;
480 Ok(Some(ContextMessage {
481 id: crate::types::MessageId::new(*partition, *offset),
482 provenance,
483 payload: message.payload.to_vec(),
484 envelope,
485 topic: source.name,
486 timestamp_micros: message.header.timestamp,
487 stream_id: *stream,
488 topic_id: *topic,
489 }))
490 }
491}
492
493#[derive(Debug, PartialEq, Eq)]
494struct ReadAtSource {
495 id: u32,
496 generation: u64,
497 name: String,
498}
499
500async fn read_at_checked<T, M, MF, P, PF>(
501 topic: u32,
502 generation: Option<u64>,
503 mut metadata: M,
504 poll: P,
505) -> Result<Option<(ReadAtSource, T)>, LaserError>
506where
507 M: FnMut() -> MF,
508 MF: std::future::Future<Output = Result<Option<ReadAtSource>, LaserError>>,
509 P: FnOnce() -> PF,
510 PF: std::future::Future<Output = Result<Option<T>, LaserError>>,
511{
512 let Some(source) = metadata().await? else {
513 return Ok(None);
514 };
515 if source.id != topic || generation.is_some_and(|generation| generation != source.generation) {
516 return Ok(None);
517 }
518 let Some(message) = poll().await? else {
519 return Ok(None);
520 };
521 if metadata().await?.as_ref() != Some(&source) {
522 return Ok(None);
523 }
524 Ok(Some((source, message)))
525}
526
527fn unexpected(read: &str) -> LaserError {
528 LaserError::Protocol(format!("session {read}: unexpected reply shape"))
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534 use std::sync::Arc;
535 use std::sync::atomic::{AtomicU64, Ordering};
536
537 #[tokio::test]
538 async fn given_a_source_recreated_during_read_at_when_polled_then_should_discard_replacement_bytes()
539 {
540 for expected in [None, Some(10)] {
541 let generation = Arc::new(AtomicU64::new(10));
542 let result = read_at_checked(
543 2,
544 expected,
545 || {
546 let generation = generation.load(Ordering::SeqCst);
547 async move {
548 Ok(Some(ReadAtSource {
549 id: 2,
550 generation,
551 name: "agent.sessions".to_owned(),
552 }))
553 }
554 },
555 || async {
556 generation.store(11, Ordering::SeqCst);
557 Ok(Some(b"replacement"))
558 },
559 )
560 .await
561 .expect("read finishes");
562 assert!(
563 result.is_none(),
564 "a reused offset is not the original record"
565 );
566 }
567 }
568
569 #[tokio::test]
570 async fn given_a_source_deleted_during_read_at_when_polled_then_should_discard_the_record() {
571 let generation = AtomicU64::new(10);
572 let result = read_at_checked(
573 2,
574 Some(10),
575 || {
576 let exists = generation.load(Ordering::SeqCst) != 0;
577 async move {
578 Ok(exists.then(|| ReadAtSource {
579 id: 2,
580 generation: 10,
581 name: "agent.sessions".to_owned(),
582 }))
583 }
584 },
585 || async {
586 generation.store(0, Ordering::SeqCst);
587 Ok(Some(b"removed"))
588 },
589 )
590 .await
591 .expect("read finishes");
592 assert!(result.is_none(), "a removed source has no retained record");
593 }
594
595 #[tokio::test]
596 async fn given_a_live_source_when_read_at_then_should_return_the_original_record() {
597 for expected in [None, Some(10)] {
598 let result = read_at_checked(
599 2,
600 expected,
601 || async {
602 Ok(Some(ReadAtSource {
603 id: 2,
604 generation: 10,
605 name: "agent.sessions".to_owned(),
606 }))
607 },
608 || async { Ok(Some(b"original")) },
609 )
610 .await
611 .expect("read finishes")
612 .expect("the source still exists");
613 assert_eq!(result.1, b"original");
614 }
615 }
616
617 #[tokio::test]
618 async fn given_a_stale_source_when_read_at_then_should_refuse_before_polling() {
619 let result = read_at_checked(
620 2,
621 Some(9),
622 || async {
623 Ok(Some(ReadAtSource {
624 id: 2,
625 generation: 10,
626 name: "agent.sessions".to_owned(),
627 }))
628 },
629 || async { Err::<Option<()>, _>(LaserError::Invalid("unexpected poll".to_owned())) },
630 )
631 .await
632 .expect("read finishes");
633 assert!(result.is_none());
634 }
635}