use std::collections::BTreeSet;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use starweaver_core::{RunId, SessionId};
use crate::{RunRecord, RunStatus, SessionStoreError, SessionStoreResult};
pub const MAX_HOST_EVENT_PAGE_SIZE: usize = 500;
pub const MAX_HOST_EVENT_POSITION: u64 = 9_223_372_036_854_775_807;
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DurableHostEventClass {
SessionChanged,
RunChanged,
OutputAvailable,
ApprovalChanged,
DeferredChanged,
ClarificationChanged,
EnvironmentChanged,
Diagnostic,
}
impl DurableHostEventClass {
pub const ALL: [Self; 8] = [
Self::SessionChanged,
Self::RunChanged,
Self::OutputAvailable,
Self::ApprovalChanged,
Self::DeferredChanged,
Self::ClarificationChanged,
Self::EnvironmentChanged,
Self::Diagnostic,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::SessionChanged => "session_changed",
Self::RunChanged => "run_changed",
Self::OutputAvailable => "output_available",
Self::ApprovalChanged => "approval_changed",
Self::DeferredChanged => "deferred_changed",
Self::ClarificationChanged => "clarification_changed",
Self::EnvironmentChanged => "environment_changed",
Self::Diagnostic => "diagnostic",
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DurableHostEventScope {
Global,
Session {
session_id: SessionId,
},
Run {
session_id: SessionId,
run_id: RunId,
},
}
impl DurableHostEventScope {
#[must_use]
pub const fn session(session_id: SessionId) -> Self {
Self::Session { session_id }
}
#[must_use]
pub const fn run(session_id: SessionId, run_id: RunId) -> Self {
Self::Run { session_id, run_id }
}
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::Global => "global",
Self::Session { .. } => "session",
Self::Run { .. } => "run",
}
}
#[must_use]
pub const fn session_id(&self) -> Option<&SessionId> {
match self {
Self::Global => None,
Self::Session { session_id } | Self::Run { session_id, .. } => Some(session_id),
}
}
#[must_use]
pub const fn run_id(&self) -> Option<&RunId> {
match self {
Self::Run { run_id, .. } => Some(run_id),
Self::Global | Self::Session { .. } => None,
}
}
#[must_use]
pub fn contains(&self, record_scope: &Self) -> bool {
match (self, record_scope) {
(Self::Global, _) => true,
(
Self::Session {
session_id: expected,
},
Self::Session { session_id } | Self::Run { session_id, .. },
) => expected == session_id,
(
Self::Run {
session_id: expected_session,
run_id: expected_run,
},
Self::Run { session_id, run_id },
) => expected_session == session_id && expected_run == run_id,
(Self::Session { .. } | Self::Run { .. }, Self::Global | Self::Session { .. }) => false,
}
}
fn identity_components(&self) -> Vec<&str> {
match self {
Self::Global => vec!["global"],
Self::Session { session_id } => vec!["session", session_id.as_str()],
Self::Run { session_id, run_id } => {
vec!["run", session_id.as_str(), run_id.as_str()]
}
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct EventPublicationKey(String);
impl EventPublicationKey {
pub fn derive(
transition_identity: &str,
ordinal: u32,
scope: &DurableHostEventScope,
event_class: DurableHostEventClass,
) -> SessionStoreResult<Self> {
if transition_identity.is_empty() {
return Err(SessionStoreError::Failed(
"host event transition identity cannot be empty".to_string(),
));
}
let ordinal = ordinal.to_string();
let mut components = vec![transition_identity, event_class.as_str(), ordinal.as_str()];
components.extend(scope.identity_components());
Ok(Self(format!(
"host-event-publication-sha256:{:x}",
framed_digest(components)
)))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct PendingHostEventPublication {
pub publication_key: EventPublicationKey,
pub event_id: String,
pub scope: DurableHostEventScope,
pub event_class: DurableHostEventClass,
pub projection: Value,
pub occurred_at: DateTime<Utc>,
}
impl starweaver_core::VersionedRecord for PendingHostEventPublication {
const SCHEMA: &'static str = "starweaver.session.pending_host_event_publication";
}
impl PendingHostEventPublication {
pub fn new(
transition_identity: &str,
ordinal: u32,
scope: DurableHostEventScope,
event_class: DurableHostEventClass,
projection: Value,
occurred_at: DateTime<Utc>,
) -> SessionStoreResult<Self> {
if !projection.is_object() {
return Err(SessionStoreError::Failed(
"durable host event projection must be a JSON object".to_string(),
));
}
let publication_key =
EventPublicationKey::derive(transition_identity, ordinal, &scope, event_class)?;
let event_id = derived_event_id(&publication_key);
Ok(Self {
publication_key,
event_id,
scope,
event_class,
projection,
occurred_at,
})
}
pub fn validate(&self) -> SessionStoreResult<()> {
if self.publication_key.as_str().is_empty() || self.event_id.is_empty() {
return Err(SessionStoreError::Failed(
"durable host event identity cannot be empty".to_string(),
));
}
if self.event_id != derived_event_id(&self.publication_key) {
return Err(SessionStoreError::Conflict(format!(
"host event identity does not match publication {}",
self.publication_key.as_str()
)));
}
if !self.projection.is_object() {
return Err(SessionStoreError::Failed(
"durable host event projection must be a JSON object".to_string(),
));
}
Ok(())
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RunChangedSummary {
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diagnostic_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_preview: Option<String>,
pub revision: String,
pub run_id: RunId,
pub session_id: SessionId,
pub status: RunStatus,
pub updated_at: DateTime<Utc>,
}
impl From<&RunRecord> for RunChangedSummary {
fn from(run: &RunRecord) -> Self {
Self {
created_at: run.created_at,
diagnostic_ref: run.terminal_error.as_ref().map(|error| error.code.clone()),
output_preview: run.output_preview.clone(),
revision: run.revision.to_string(),
run_id: run.run_id.clone(),
session_id: run.session_id.clone(),
status: run.status,
updated_at: run.updated_at,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RunChangedProjection {
pub kind: String,
pub run: RunChangedSummary,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OutputAvailableProjection {
pub kind: String,
pub output_ref: String,
pub preview: String,
pub run_id: RunId,
pub session_id: SessionId,
}
pub fn run_changed_publication(
transition_identity: &str,
ordinal: u32,
run: &RunRecord,
) -> SessionStoreResult<PendingHostEventPublication> {
let projection = serde_json::to_value(RunChangedProjection {
kind: "run_changed".to_string(),
run: RunChangedSummary::from(run),
})
.map_err(|error| SessionStoreError::Failed(error.to_string()))?;
PendingHostEventPublication::new(
transition_identity,
ordinal,
DurableHostEventScope::run(run.session_id.clone(), run.run_id.clone()),
DurableHostEventClass::RunChanged,
projection,
run.updated_at,
)
}
pub fn output_available_publication(
transition_identity: &str,
ordinal: u32,
run: &RunRecord,
) -> SessionStoreResult<Option<PendingHostEventPublication>> {
let Some(preview) = run.output_preview.clone() else {
return Ok(None);
};
let projection = serde_json::to_value(OutputAvailableProjection {
kind: "output_available".to_string(),
output_ref: format!(
"run-output:{}:{}:{}",
run.session_id.as_str(),
run.run_id.as_str(),
run.revision
),
preview,
run_id: run.run_id.clone(),
session_id: run.session_id.clone(),
})
.map_err(|error| SessionStoreError::Failed(error.to_string()))?;
PendingHostEventPublication::new(
transition_identity,
ordinal,
DurableHostEventScope::run(run.session_id.clone(), run.run_id.clone()),
DurableHostEventClass::OutputAvailable,
projection,
run.updated_at,
)
.map(Some)
}
pub fn append_authoritative_run_publications<'a>(
publications: &mut Vec<PendingHostEventPublication>,
transition_identity: &str,
runs: impl IntoIterator<Item = &'a RunRecord>,
) -> SessionStoreResult<()> {
for (index, run) in runs.into_iter().enumerate() {
let scope = DurableHostEventScope::run(run.session_id.clone(), run.run_id.clone());
let ordinal = u32::try_from(index).map_err(|error| {
SessionStoreError::Failed(format!("too many authoritative run publications: {error}"))
})?;
if !publications.iter().any(|publication| {
publication.scope == scope
&& publication.event_class == DurableHostEventClass::RunChanged
}) {
publications.push(run_changed_publication(transition_identity, ordinal, run)?);
}
if run.output_preview.is_some()
&& !publications.iter().any(|publication| {
publication.scope == scope
&& publication.event_class == DurableHostEventClass::OutputAvailable
})
&& let Some(publication) =
output_available_publication(transition_identity, ordinal, run)?
{
publications.push(publication);
}
if run.status.is_terminal() && run.output_preview.is_some() {
let run_changed_index = publications.iter().position(|publication| {
publication.scope == scope
&& publication.event_class == DurableHostEventClass::RunChanged
});
let output_index = publications.iter().position(|publication| {
publication.scope == scope
&& publication.event_class == DurableHostEventClass::OutputAvailable
});
if let (Some(run_changed_index), Some(output_index)) = (run_changed_index, output_index)
&& output_index > run_changed_index
{
let output = publications.remove(output_index);
publications.insert(run_changed_index, output);
}
}
}
Ok(())
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct DurableHostEventRecord {
pub position: u64,
pub publication_key: EventPublicationKey,
pub event_id: String,
pub scope: DurableHostEventScope,
pub event_class: DurableHostEventClass,
pub projection: Value,
pub occurred_at: DateTime<Utc>,
}
impl starweaver_core::VersionedRecord for DurableHostEventRecord {
const SCHEMA: &'static str = "starweaver.session.durable_host_event";
}
impl DurableHostEventRecord {
#[must_use]
pub fn from_pending(position: u64, pending: PendingHostEventPublication) -> Self {
Self {
position,
publication_key: pending.publication_key,
event_id: pending.event_id,
scope: pending.scope,
event_class: pending.event_class,
projection: pending.projection,
occurred_at: pending.occurred_at,
}
}
#[must_use]
pub fn pending_projection(&self) -> PendingHostEventPublication {
PendingHostEventPublication {
publication_key: self.publication_key.clone(),
event_id: self.event_id.clone(),
scope: self.scope.clone(),
event_class: self.event_class,
projection: self.projection.clone(),
occurred_at: self.occurred_at,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DurableHostEventQuery {
pub scope: DurableHostEventScope,
pub event_classes: BTreeSet<DurableHostEventClass>,
pub after_position: Option<u64>,
pub limit: usize,
}
impl DurableHostEventQuery {
pub fn new(
scope: DurableHostEventScope,
event_classes: impl IntoIterator<Item = DurableHostEventClass>,
after_position: Option<u64>,
limit: usize,
) -> SessionStoreResult<Self> {
let event_classes = event_classes.into_iter().collect::<BTreeSet<_>>();
if event_classes.is_empty() {
return Err(SessionStoreError::Failed(
"durable host event query requires at least one event class".to_string(),
));
}
if !(1..=MAX_HOST_EVENT_PAGE_SIZE).contains(&limit) {
return Err(SessionStoreError::Failed(format!(
"durable host event query limit must be between 1 and {MAX_HOST_EVENT_PAGE_SIZE}"
)));
}
if after_position.is_some_and(|position| position > MAX_HOST_EVENT_POSITION) {
return Err(SessionStoreError::Failed(format!(
"durable host event position exceeds {MAX_HOST_EVENT_POSITION}"
)));
}
Ok(Self {
scope,
event_classes,
after_position,
limit,
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DurableHostEventPage {
pub records: Vec<DurableHostEventRecord>,
pub next_position: Option<u64>,
pub has_more: bool,
}
fn derived_event_id(publication_key: &EventPublicationKey) -> String {
format!(
"event-sha256:{:x}",
framed_digest([publication_key.as_str()])
)
}
fn framed_digest<'a>(components: impl IntoIterator<Item = &'a str>) -> impl std::fmt::LowerHex {
let mut digest = Sha256::new();
for component in components {
digest.update(component.len().to_string().as_bytes());
digest.update(b":");
digest.update(component.as_bytes());
digest.update(b";");
}
digest.finalize()
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use chrono::Utc;
use serde_json::json;
use starweaver_core::{ConversationId, RunId, SessionId};
use crate::{RunRecord, RunStatus};
use super::{
DurableHostEventClass, DurableHostEventQuery, DurableHostEventScope,
PendingHostEventPublication, append_authoritative_run_publications,
output_available_publication, run_changed_publication,
};
#[test]
fn publication_identity_is_stable_and_framed() {
let occurred_at = Utc::now();
let first = PendingHostEventPublication::new(
"a:b",
0,
DurableHostEventScope::session(SessionId::from_string("c")),
DurableHostEventClass::SessionChanged,
json!({"revision": "1"}),
occurred_at,
)
.expect("first publication");
let retry = PendingHostEventPublication::new(
"a:b",
0,
DurableHostEventScope::session(SessionId::from_string("c")),
DurableHostEventClass::SessionChanged,
json!({"revision": "1"}),
occurred_at,
)
.expect("retry publication");
let ambiguous_without_framing = PendingHostEventPublication::new(
"a",
0,
DurableHostEventScope::session(SessionId::from_string("b:c")),
DurableHostEventClass::SessionChanged,
json!({"revision": "1"}),
occurred_at,
)
.expect("second publication");
assert_eq!(first, retry);
assert_ne!(
first.publication_key,
ambiguous_without_framing.publication_key
);
assert_ne!(first.event_id, ambiguous_without_framing.event_id);
}
#[test]
fn authoritative_run_projections_are_product_neutral_wire_shapes() {
let mut run = RunRecord::new(
SessionId::from_string("session-event"),
RunId::from_string("run-event"),
ConversationId::from_string("conversation-event"),
);
run.status = RunStatus::Completed;
run.output_preview = Some("ready".to_string());
run.revision = 7;
let changed =
run_changed_publication("run-transition", 0, &run).expect("run changed publication");
assert_eq!(changed.projection["kind"], json!("run_changed"));
assert_eq!(changed.projection["run"]["revision"], json!("7"));
assert_eq!(changed.projection["run"]["runId"], json!("run-event"));
assert_eq!(changed.projection["run"]["status"], json!("completed"));
let output = output_available_publication("run-transition", 0, &run)
.expect("output publication")
.expect("preview produces output event");
assert_eq!(output.projection["kind"], json!("output_available"));
assert_eq!(output.projection["preview"], json!("ready"));
assert_eq!(output.projection["runId"], json!("run-event"));
assert_eq!(output.projection["sessionId"], json!("session-event"));
}
#[test]
fn terminal_output_is_published_before_the_terminal_run_event() {
let mut run = RunRecord::new(
SessionId::from_string("session-terminal"),
RunId::from_string("run-terminal"),
ConversationId::from_string("conversation-terminal"),
);
run.status = RunStatus::Completed;
run.output_preview = Some("complete".to_string());
let preseeded =
run_changed_publication("terminal-transition", 0, &run).expect("preseeded run event");
for mut publications in [Vec::new(), vec![preseeded]] {
append_authoritative_run_publications(&mut publications, "terminal-transition", [&run])
.expect("authoritative terminal publications");
assert_eq!(
publications
.iter()
.map(|publication| publication.event_class)
.collect::<Vec<_>>(),
vec![
DurableHostEventClass::OutputAvailable,
DurableHostEventClass::RunChanged,
]
);
}
}
#[test]
fn scope_containment_is_hierarchical() {
let session_id = SessionId::from_string("session-1");
let run = DurableHostEventScope::run(session_id.clone(), RunId::from_string("run-1"));
assert!(DurableHostEventScope::Global.contains(&run));
assert!(DurableHostEventScope::session(session_id).contains(&run));
assert!(run.contains(&run));
assert!(!DurableHostEventScope::session(SessionId::from_string("other")).contains(&run));
}
#[test]
fn query_rejects_empty_classes_and_unbounded_limits() {
assert!(DurableHostEventQuery::new(DurableHostEventScope::Global, [], None, 10).is_err());
assert!(
DurableHostEventQuery::new(
DurableHostEventScope::Global,
[DurableHostEventClass::Diagnostic],
None,
501,
)
.is_err()
);
assert!(
DurableHostEventQuery::new(
DurableHostEventScope::Global,
[DurableHostEventClass::Diagnostic],
Some(super::MAX_HOST_EVENT_POSITION + 1),
1,
)
.is_err()
);
}
#[test]
fn publication_validation_rejects_forged_event_identity() {
let mut publication = PendingHostEventPublication::new(
"transition",
0,
DurableHostEventScope::Global,
DurableHostEventClass::Diagnostic,
json!({"code": "test"}),
Utc::now(),
)
.expect("publication");
publication.event_id = "caller-selected".to_string();
assert!(publication.validate().is_err());
}
}