use std::fmt;
use std::str::FromStr;
use std::time::SystemTime;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use everruns_core::Message;
use everruns_host::{
EventCursor, EventHistory, EventHistoryReadLimit, EventHistoryReadRequest, EventLogError,
MAX_EVENT_HISTORY_PAGE_SIZE,
};
use crate::{Agent, ContentPart, MessageRole, SessionId};
const MAX_CURSOR_TOKEN_LEN: usize = 4096;
const CURSOR_PREFIX: &str = "eh1.";
const DEFAULT_HISTORY_PAGE_SIZE: usize = 100;
#[derive(Clone)]
pub struct HistoryQuery {
agent: Agent,
session_id: SessionId,
limit: usize,
cursor: Option<EventCursor>,
}
impl HistoryQuery {
pub(crate) fn new(agent: Agent, session_id: SessionId) -> Self {
Self {
agent,
session_id,
limit: DEFAULT_HISTORY_PAGE_SIZE,
cursor: None,
}
}
pub fn limit(mut self, limit: usize) -> Result<Self, HistoryError> {
if limit == 0 || limit > MAX_EVENT_HISTORY_PAGE_SIZE {
return Err(HistoryError::InvalidLimit {
requested: limit,
maximum: MAX_EVENT_HISTORY_PAGE_SIZE,
});
}
self.limit = limit;
Ok(self)
}
pub fn after(mut self, cursor: HistoryCursor) -> Result<Self, HistoryError> {
let event_cursor = cursor.decode()?;
if event_cursor.session_id() != self.session_id {
return Err(HistoryError::CrossSessionCursor);
}
self.cursor = Some(event_cursor);
Ok(self)
}
pub async fn page(&self) -> Result<HistoryPage, HistoryError> {
self.agent.ensure_session_cataloged(self.session_id).await?;
let backends = self
.agent
.shared_backends()
.await
.map_err(|error| error.history_error())?;
let limit = EventHistoryReadLimit::new(self.limit).map_err(map_event_error)?;
let mut request = EventHistoryReadRequest::new(self.session_id, limit);
if let Some(cursor) = &self.cursor {
request = request.with_cursor(cursor.clone());
}
let page = EventHistory::new(backends.event_log.clone())
.read_page(request)
.await
.map_err(map_event_error)?;
Ok(HistoryPage {
session_id: self.session_id,
messages: page
.messages
.into_iter()
.map(SessionMessage::from)
.collect(),
next_cursor: page.next_cursor.map(HistoryCursor::encode).transpose()?,
})
}
pub fn pages(self) -> HistoryPages {
HistoryPages {
query: Some(self),
finished: false,
}
}
}
impl fmt::Debug for HistoryQuery {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HistoryQuery")
.field("session_id", &self.session_id)
.field("limit", &self.limit)
.field("has_cursor", &self.cursor.is_some())
.finish()
}
}
#[derive(Debug)]
pub struct HistoryPages {
query: Option<HistoryQuery>,
finished: bool,
}
impl HistoryPages {
pub async fn next_page(&mut self) -> Result<Option<HistoryPage>, HistoryError> {
if self.finished {
return Ok(None);
}
let query = self
.query
.take()
.expect("unfinished page reader has a query");
let page = match query.page().await {
Ok(page) => page,
Err(error) => {
self.query = Some(query);
return Err(error);
}
};
match page.next_cursor.clone() {
Some(cursor) => self.query = Some(query.after(cursor)?),
None => self.finished = true,
}
Ok(Some(page))
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct HistoryPage {
pub session_id: SessionId,
pub messages: Vec<SessionMessage>,
pub next_cursor: Option<HistoryCursor>,
}
impl HistoryPage {
pub fn len(&self) -> usize {
self.messages.len()
}
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
pub fn iter(&self) -> impl ExactSizeIterator<Item = &SessionMessage> {
self.messages.iter()
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct SessionMessage {
pub id: String,
pub role: MessageRole,
pub content: Vec<ContentPart>,
pub created_at: SystemTime,
}
impl SessionMessage {
pub fn text(&self) -> String {
self.content
.iter()
.filter_map(ContentPart::as_text)
.collect()
}
}
impl From<Message> for SessionMessage {
fn from(message: Message) -> Self {
Self {
id: message.id.to_string(),
role: message.role,
content: message.content,
created_at: message.created_at.into(),
}
}
}
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct HistoryCursor(String);
impl HistoryCursor {
pub(crate) fn from_token(token: String) -> Result<Self, HistoryCursorParseError> {
validate_cursor_token(&token)?;
Ok(Self(token))
}
fn encode(cursor: EventCursor) -> Result<Self, HistoryError> {
let encoded = serde_json::to_vec(&cursor).map_err(|_| HistoryError::IncompatibleCursor)?;
Self::from_token(format!(
"{CURSOR_PREFIX}{}",
URL_SAFE_NO_PAD.encode(encoded)
))
.map_err(|_| HistoryError::IncompatibleCursor)
}
fn decode(&self) -> Result<EventCursor, HistoryError> {
let encoded = self
.0
.strip_prefix(CURSOR_PREFIX)
.ok_or(HistoryError::IncompatibleCursor)?;
if encoded.is_empty() {
return Err(HistoryError::InvalidCursor);
}
let bytes = URL_SAFE_NO_PAD
.decode(encoded)
.map_err(|_| HistoryError::InvalidCursor)?;
serde_json::from_slice(&bytes).map_err(|_| HistoryError::InvalidCursor)
}
}
impl fmt::Debug for HistoryCursor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HistoryCursor")
.field("token", &"<redacted>")
.field("len", &self.0.len())
.finish()
}
}
impl fmt::Display for HistoryCursor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for HistoryCursor {
type Err = HistoryCursorParseError;
fn from_str(token: &str) -> Result<Self, Self::Err> {
Self::from_token(token.to_owned())
}
}
fn validate_cursor_token(token: &str) -> Result<(), HistoryCursorParseError> {
if token.is_empty()
|| token.len() > MAX_CURSOR_TOKEN_LEN
|| !token
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~'))
{
return Err(HistoryCursorParseError(()));
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HistoryCursorParseError(());
impl fmt::Display for HistoryCursorParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("invalid history cursor")
}
}
impl std::error::Error for HistoryCursorParseError {}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum HistoryError {
InvalidLimit {
requested: usize,
maximum: usize,
},
InvalidCursor,
CrossSessionCursor,
ExpiredCursor,
IncompatibleCursor,
HistoryTooLarge,
SessionNotFound {
session_id: SessionId,
},
Unavailable,
Corrupt,
}
impl fmt::Display for HistoryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidLimit { requested, maximum } => {
write!(
f,
"history page limit must be between 1 and {maximum}; got {requested}"
)
}
Self::InvalidCursor => f.write_str("invalid history cursor"),
Self::CrossSessionCursor => f.write_str("history cursor belongs to another session"),
Self::ExpiredCursor => f.write_str("history cursor snapshot has expired"),
Self::IncompatibleCursor => {
f.write_str("history cursor is incompatible with this query")
}
Self::HistoryTooLarge => {
f.write_str("session history exceeds the bounded replay limit")
}
Self::SessionNotFound { session_id } => {
write!(f, "session {session_id} was not found")
}
Self::Unavailable => f.write_str("session history is unavailable"),
Self::Corrupt => f.write_str("session history is corrupt"),
}
}
}
impl std::error::Error for HistoryError {}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ResumeError {
SessionNotFound {
session_id: SessionId,
},
Unavailable,
Corrupt,
}
impl fmt::Display for ResumeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::SessionNotFound { session_id } => {
write!(f, "session {session_id} was not found")
}
Self::Unavailable => f.write_str("session history is unavailable"),
Self::Corrupt => f.write_str("session history is corrupt"),
}
}
}
impl std::error::Error for ResumeError {}
fn map_event_error(error: EventLogError) -> HistoryError {
match error {
EventLogError::CrossSessionCursor { .. } => HistoryError::CrossSessionCursor,
EventLogError::IncompatibleCursor { .. } => HistoryError::IncompatibleCursor,
EventLogError::ExpiredCursor { .. } => HistoryError::ExpiredCursor,
EventLogError::Corruption { .. } => HistoryError::Corrupt,
EventLogError::InvalidRead { .. } => HistoryError::HistoryTooLarge,
EventLogError::Backend { .. } | EventLogError::InvalidAppend { .. } => {
HistoryError::Unavailable
}
_ => HistoryError::Unavailable,
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use everruns_host::EventLogError;
use super::{HistoryCursor, HistoryError, ResumeError, map_event_error};
#[test]
fn cursor_round_trips_as_an_opaque_string() {
let cursor = HistoryCursor::from_str("eh1.c2Vzc2lvbg.c25hcHNob3Q").unwrap();
assert_eq!(cursor.to_string(), "eh1.c2Vzc2lvbg.c25hcHNob3Q");
assert_eq!(
HistoryCursor::from_str(&cursor.to_string()).unwrap(),
cursor
);
assert!(!format!("{cursor:?}").contains("c2Vzc2lvbg"));
}
#[test]
fn cursor_rejects_empty_whitespace_control_and_oversized_tokens() {
assert!(HistoryCursor::from_str("").is_err());
assert!(HistoryCursor::from_str("token with spaces").is_err());
assert!(HistoryCursor::from_str("token\n").is_err());
assert!(HistoryCursor::from_str("token/with/path-delimiters").is_err());
assert!(HistoryCursor::from_str(&"x".repeat(4097)).is_err());
}
#[test]
fn invalid_limit_reports_the_allowed_maximum() {
let error = HistoryError::InvalidLimit {
requested: 1001,
maximum: 1000,
};
assert_eq!(
error.to_string(),
"history page limit must be between 1 and 1000; got 1001"
);
}
#[test]
fn resume_not_found_keeps_the_framework_session_id() {
let session_id = crate::SessionId::new();
let error = ResumeError::SessionNotFound { session_id };
assert_eq!(
error.to_string(),
format!("session {session_id} was not found")
);
}
#[test]
fn host_cursor_and_replay_failures_keep_distinct_framework_errors() {
assert_eq!(
map_event_error(EventLogError::ExpiredCursor {
detail: "expired".into(),
}),
HistoryError::ExpiredCursor
);
assert_eq!(
map_event_error(EventLogError::IncompatibleCursor {
detail: "incompatible".into(),
}),
HistoryError::IncompatibleCursor
);
assert_eq!(
map_event_error(EventLogError::InvalidRead {
detail: "bounded replay".into(),
}),
HistoryError::HistoryTooLarge
);
}
}