1use std::collections::BTreeSet;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9use starweaver_core::{RunId, SessionId};
10
11use crate::{RunRecord, RunStatus, SessionStoreError, SessionStoreResult};
12
13pub const MAX_HOST_EVENT_PAGE_SIZE: usize = 500;
15
16pub const MAX_HOST_EVENT_POSITION: u64 = 9_223_372_036_854_775_807;
18
19#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum DurableHostEventClass {
26 SessionChanged,
28 RunChanged,
30 OutputAvailable,
32 TranscriptChanged,
34 ApprovalChanged,
36 DeferredChanged,
38 ClarificationChanged,
40 EnvironmentChanged,
42 Diagnostic,
44}
45
46impl DurableHostEventClass {
47 pub const ALL: [Self; 9] = [
49 Self::SessionChanged,
50 Self::RunChanged,
51 Self::OutputAvailable,
52 Self::TranscriptChanged,
53 Self::ApprovalChanged,
54 Self::DeferredChanged,
55 Self::ClarificationChanged,
56 Self::EnvironmentChanged,
57 Self::Diagnostic,
58 ];
59
60 #[must_use]
62 pub const fn as_str(self) -> &'static str {
63 match self {
64 Self::SessionChanged => "session_changed",
65 Self::RunChanged => "run_changed",
66 Self::OutputAvailable => "output_available",
67 Self::TranscriptChanged => "transcript_changed",
68 Self::ApprovalChanged => "approval_changed",
69 Self::DeferredChanged => "deferred_changed",
70 Self::ClarificationChanged => "clarification_changed",
71 Self::EnvironmentChanged => "environment_changed",
72 Self::Diagnostic => "diagnostic",
73 }
74 }
75}
76
77#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
79#[serde(tag = "kind", rename_all = "snake_case")]
80pub enum DurableHostEventScope {
81 Global,
83 Session {
85 session_id: SessionId,
87 },
88 Run {
90 session_id: SessionId,
92 run_id: RunId,
94 },
95}
96
97impl DurableHostEventScope {
98 #[must_use]
100 pub const fn session(session_id: SessionId) -> Self {
101 Self::Session { session_id }
102 }
103
104 #[must_use]
106 pub const fn run(session_id: SessionId, run_id: RunId) -> Self {
107 Self::Run { session_id, run_id }
108 }
109
110 #[must_use]
112 pub const fn kind(&self) -> &'static str {
113 match self {
114 Self::Global => "global",
115 Self::Session { .. } => "session",
116 Self::Run { .. } => "run",
117 }
118 }
119
120 #[must_use]
122 pub const fn session_id(&self) -> Option<&SessionId> {
123 match self {
124 Self::Global => None,
125 Self::Session { session_id } | Self::Run { session_id, .. } => Some(session_id),
126 }
127 }
128
129 #[must_use]
131 pub const fn run_id(&self) -> Option<&RunId> {
132 match self {
133 Self::Run { run_id, .. } => Some(run_id),
134 Self::Global | Self::Session { .. } => None,
135 }
136 }
137
138 #[must_use]
143 pub fn contains(&self, record_scope: &Self) -> bool {
144 match (self, record_scope) {
145 (Self::Global, _) => true,
146 (
147 Self::Session {
148 session_id: expected,
149 },
150 Self::Session { session_id } | Self::Run { session_id, .. },
151 ) => expected == session_id,
152 (
153 Self::Run {
154 session_id: expected_session,
155 run_id: expected_run,
156 },
157 Self::Run { session_id, run_id },
158 ) => expected_session == session_id && expected_run == run_id,
159 (Self::Session { .. } | Self::Run { .. }, Self::Global | Self::Session { .. }) => false,
160 }
161 }
162
163 fn identity_components(&self) -> Vec<&str> {
164 match self {
165 Self::Global => vec!["global"],
166 Self::Session { session_id } => vec!["session", session_id.as_str()],
167 Self::Run { session_id, run_id } => {
168 vec!["run", session_id.as_str(), run_id.as_str()]
169 }
170 }
171 }
172}
173
174#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
176#[serde(transparent)]
177pub struct EventPublicationKey(String);
178
179impl EventPublicationKey {
180 pub fn derive(
189 transition_identity: &str,
190 ordinal: u32,
191 scope: &DurableHostEventScope,
192 event_class: DurableHostEventClass,
193 ) -> SessionStoreResult<Self> {
194 if transition_identity.is_empty() {
195 return Err(SessionStoreError::Failed(
196 "host event transition identity cannot be empty".to_string(),
197 ));
198 }
199 let ordinal = ordinal.to_string();
200 let mut components = vec![transition_identity, event_class.as_str(), ordinal.as_str()];
201 components.extend(scope.identity_components());
202 Ok(Self(format!(
203 "host-event-publication-sha256:{:x}",
204 framed_digest(components)
205 )))
206 }
207
208 #[must_use]
210 pub fn as_str(&self) -> &str {
211 &self.0
212 }
213}
214
215#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
217pub struct PendingHostEventPublication {
218 pub publication_key: EventPublicationKey,
220 pub event_id: String,
222 pub scope: DurableHostEventScope,
224 pub event_class: DurableHostEventClass,
226 pub projection: Value,
229 pub occurred_at: DateTime<Utc>,
231}
232
233impl starweaver_core::VersionedRecord for PendingHostEventPublication {
234 const SCHEMA: &'static str = "starweaver.session.pending_host_event_publication";
235}
236
237impl PendingHostEventPublication {
238 pub fn new(
244 transition_identity: &str,
245 ordinal: u32,
246 scope: DurableHostEventScope,
247 event_class: DurableHostEventClass,
248 projection: Value,
249 occurred_at: DateTime<Utc>,
250 ) -> SessionStoreResult<Self> {
251 if !projection.is_object() {
252 return Err(SessionStoreError::Failed(
253 "durable host event projection must be a JSON object".to_string(),
254 ));
255 }
256 let publication_key =
257 EventPublicationKey::derive(transition_identity, ordinal, &scope, event_class)?;
258 let event_id = derived_event_id(&publication_key);
259 Ok(Self {
260 publication_key,
261 event_id,
262 scope,
263 event_class,
264 projection,
265 occurred_at,
266 })
267 }
268
269 pub fn validate(&self) -> SessionStoreResult<()> {
275 if self.publication_key.as_str().is_empty() || self.event_id.is_empty() {
276 return Err(SessionStoreError::Failed(
277 "durable host event identity cannot be empty".to_string(),
278 ));
279 }
280 if self.event_id != derived_event_id(&self.publication_key) {
281 return Err(SessionStoreError::Conflict(format!(
282 "host event identity does not match publication {}",
283 self.publication_key.as_str()
284 )));
285 }
286 if !self.projection.is_object() {
287 return Err(SessionStoreError::Failed(
288 "durable host event projection must be a JSON object".to_string(),
289 ));
290 }
291 Ok(())
292 }
293}
294
295#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
297#[serde(rename_all = "camelCase")]
298pub struct RunChangedSummary {
299 pub created_at: DateTime<Utc>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub diagnostic_ref: Option<String>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub output_preview: Option<String>,
307 pub revision: String,
309 pub run_id: RunId,
311 pub session_id: SessionId,
313 pub status: RunStatus,
315 pub updated_at: DateTime<Utc>,
317}
318
319impl From<&RunRecord> for RunChangedSummary {
320 fn from(run: &RunRecord) -> Self {
321 Self {
322 created_at: run.created_at,
323 diagnostic_ref: run.terminal_error.as_ref().map(|error| error.code.clone()),
324 output_preview: run.output_preview.clone(),
325 revision: run.revision.to_string(),
326 run_id: run.run_id.clone(),
327 session_id: run.session_id.clone(),
328 status: run.status,
329 updated_at: run.updated_at,
330 }
331 }
332}
333
334#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
336#[serde(rename_all = "camelCase")]
337pub struct RunChangedProjection {
338 pub kind: String,
340 pub run: RunChangedSummary,
342}
343
344#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
346#[serde(rename_all = "camelCase")]
347pub struct OutputAvailableProjection {
348 pub kind: String,
350 pub output_ref: String,
352 pub preview: String,
354 pub run_id: RunId,
356 pub session_id: SessionId,
358}
359
360#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
362#[serde(
363 tag = "kind",
364 rename_all = "snake_case",
365 rename_all_fields = "camelCase"
366)]
367pub enum TranscriptUpdateProjection {
368 MessageStarted {
370 message_id: String,
372 role: String,
374 },
375 TextAppended {
377 message_id: String,
379 delta: String,
381 },
382 MessageFinished {
384 message_id: String,
386 },
387}
388
389#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
391#[serde(rename_all = "camelCase")]
392pub struct TranscriptChangedProjection {
393 pub kind: String,
395 pub run_id: RunId,
397 pub session_id: SessionId,
399 pub transcript_sequence: String,
401 pub update: TranscriptUpdateProjection,
403}
404
405pub fn transcript_changed_publication(
412 transition_identity: &str,
413 ordinal: u32,
414 session_id: SessionId,
415 run_id: RunId,
416 transcript_sequence: u64,
417 update: TranscriptUpdateProjection,
418 occurred_at: DateTime<Utc>,
419) -> SessionStoreResult<PendingHostEventPublication> {
420 let (message_id, delta) = match &update {
421 TranscriptUpdateProjection::MessageStarted { message_id, role } => {
422 if role != "assistant" {
423 return Err(SessionStoreError::Failed(
424 "transcript role must be assistant".to_string(),
425 ));
426 }
427 (message_id, None)
428 }
429 TranscriptUpdateProjection::TextAppended { message_id, delta } => (message_id, Some(delta)),
430 TranscriptUpdateProjection::MessageFinished { message_id } => (message_id, None),
431 };
432 if message_id.is_empty() || message_id.chars().count() > 256 {
433 return Err(SessionStoreError::Failed(
434 "transcript message identity is outside host contract bounds".to_string(),
435 ));
436 }
437 if delta.is_some_and(|delta| delta.is_empty() || delta.chars().count() > 16_384) {
438 return Err(SessionStoreError::Failed(
439 "transcript text delta is outside host contract bounds".to_string(),
440 ));
441 }
442 let projection = serde_json::to_value(TranscriptChangedProjection {
443 kind: "transcript_changed".to_string(),
444 run_id: run_id.clone(),
445 session_id: session_id.clone(),
446 transcript_sequence: transcript_sequence.to_string(),
447 update,
448 })
449 .map_err(|error| SessionStoreError::Failed(error.to_string()))?;
450 PendingHostEventPublication::new(
451 transition_identity,
452 ordinal,
453 DurableHostEventScope::run(session_id, run_id),
454 DurableHostEventClass::TranscriptChanged,
455 projection,
456 occurred_at,
457 )
458}
459
460pub fn run_changed_publication(
466 transition_identity: &str,
467 ordinal: u32,
468 run: &RunRecord,
469) -> SessionStoreResult<PendingHostEventPublication> {
470 let projection = serde_json::to_value(RunChangedProjection {
471 kind: "run_changed".to_string(),
472 run: RunChangedSummary::from(run),
473 })
474 .map_err(|error| SessionStoreError::Failed(error.to_string()))?;
475 PendingHostEventPublication::new(
476 transition_identity,
477 ordinal,
478 DurableHostEventScope::run(run.session_id.clone(), run.run_id.clone()),
479 DurableHostEventClass::RunChanged,
480 projection,
481 run.updated_at,
482 )
483}
484
485pub fn output_available_publication(
491 transition_identity: &str,
492 ordinal: u32,
493 run: &RunRecord,
494) -> SessionStoreResult<Option<PendingHostEventPublication>> {
495 let Some(preview) = run.output_preview.clone() else {
496 return Ok(None);
497 };
498 let projection = serde_json::to_value(OutputAvailableProjection {
499 kind: "output_available".to_string(),
500 output_ref: format!(
501 "run-output:{}:{}:{}",
502 run.session_id.as_str(),
503 run.run_id.as_str(),
504 run.revision
505 ),
506 preview,
507 run_id: run.run_id.clone(),
508 session_id: run.session_id.clone(),
509 })
510 .map_err(|error| SessionStoreError::Failed(error.to_string()))?;
511 PendingHostEventPublication::new(
512 transition_identity,
513 ordinal,
514 DurableHostEventScope::run(run.session_id.clone(), run.run_id.clone()),
515 DurableHostEventClass::OutputAvailable,
516 projection,
517 run.updated_at,
518 )
519 .map(Some)
520}
521
522pub fn append_authoritative_run_publications<'a>(
531 publications: &mut Vec<PendingHostEventPublication>,
532 transition_identity: &str,
533 runs: impl IntoIterator<Item = &'a RunRecord>,
534) -> SessionStoreResult<()> {
535 for (index, run) in runs.into_iter().enumerate() {
536 let scope = DurableHostEventScope::run(run.session_id.clone(), run.run_id.clone());
537 let ordinal = u32::try_from(index).map_err(|error| {
538 SessionStoreError::Failed(format!("too many authoritative run publications: {error}"))
539 })?;
540 if !publications.iter().any(|publication| {
541 publication.scope == scope
542 && publication.event_class == DurableHostEventClass::RunChanged
543 }) {
544 publications.push(run_changed_publication(transition_identity, ordinal, run)?);
545 }
546 if run.output_preview.is_some()
547 && !publications.iter().any(|publication| {
548 publication.scope == scope
549 && publication.event_class == DurableHostEventClass::OutputAvailable
550 })
551 && let Some(publication) =
552 output_available_publication(transition_identity, ordinal, run)?
553 {
554 publications.push(publication);
555 }
556 if run.status.is_terminal() && run.output_preview.is_some() {
557 let run_changed_index = publications.iter().position(|publication| {
558 publication.scope == scope
559 && publication.event_class == DurableHostEventClass::RunChanged
560 });
561 let output_index = publications.iter().position(|publication| {
562 publication.scope == scope
563 && publication.event_class == DurableHostEventClass::OutputAvailable
564 });
565 if let (Some(run_changed_index), Some(output_index)) = (run_changed_index, output_index)
566 && output_index > run_changed_index
567 {
568 let output = publications.remove(output_index);
569 publications.insert(run_changed_index, output);
570 }
571 }
572 }
573 Ok(())
574}
575
576#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
578pub struct DurableHostEventRecord {
579 pub position: u64,
581 pub publication_key: EventPublicationKey,
583 pub event_id: String,
585 pub scope: DurableHostEventScope,
587 pub event_class: DurableHostEventClass,
589 pub projection: Value,
591 pub occurred_at: DateTime<Utc>,
593}
594
595impl starweaver_core::VersionedRecord for DurableHostEventRecord {
596 const SCHEMA: &'static str = "starweaver.session.durable_host_event";
597}
598
599impl DurableHostEventRecord {
600 #[must_use]
602 pub fn from_pending(position: u64, pending: PendingHostEventPublication) -> Self {
603 Self {
604 position,
605 publication_key: pending.publication_key,
606 event_id: pending.event_id,
607 scope: pending.scope,
608 event_class: pending.event_class,
609 projection: pending.projection,
610 occurred_at: pending.occurred_at,
611 }
612 }
613
614 #[must_use]
616 pub fn pending_projection(&self) -> PendingHostEventPublication {
617 PendingHostEventPublication {
618 publication_key: self.publication_key.clone(),
619 event_id: self.event_id.clone(),
620 scope: self.scope.clone(),
621 event_class: self.event_class,
622 projection: self.projection.clone(),
623 occurred_at: self.occurred_at,
624 }
625 }
626}
627
628#[derive(Clone, Debug, Eq, PartialEq)]
630pub struct DurableHostEventQuery {
631 pub scope: DurableHostEventScope,
633 pub event_classes: BTreeSet<DurableHostEventClass>,
635 pub after_position: Option<u64>,
637 pub limit: usize,
639}
640
641impl DurableHostEventQuery {
642 pub fn new(
648 scope: DurableHostEventScope,
649 event_classes: impl IntoIterator<Item = DurableHostEventClass>,
650 after_position: Option<u64>,
651 limit: usize,
652 ) -> SessionStoreResult<Self> {
653 let event_classes = event_classes.into_iter().collect::<BTreeSet<_>>();
654 if event_classes.is_empty() {
655 return Err(SessionStoreError::Failed(
656 "durable host event query requires at least one event class".to_string(),
657 ));
658 }
659 if !(1..=MAX_HOST_EVENT_PAGE_SIZE).contains(&limit) {
660 return Err(SessionStoreError::Failed(format!(
661 "durable host event query limit must be between 1 and {MAX_HOST_EVENT_PAGE_SIZE}"
662 )));
663 }
664 if after_position.is_some_and(|position| position > MAX_HOST_EVENT_POSITION) {
665 return Err(SessionStoreError::Failed(format!(
666 "durable host event position exceeds {MAX_HOST_EVENT_POSITION}"
667 )));
668 }
669 Ok(Self {
670 scope,
671 event_classes,
672 after_position,
673 limit,
674 })
675 }
676}
677
678#[derive(Clone, Debug, Eq, PartialEq)]
680pub struct DurableHostEventPage {
681 pub records: Vec<DurableHostEventRecord>,
683 pub next_position: Option<u64>,
685 pub has_more: bool,
687}
688
689fn derived_event_id(publication_key: &EventPublicationKey) -> String {
690 format!(
691 "event-sha256:{:x}",
692 framed_digest([publication_key.as_str()])
693 )
694}
695
696fn framed_digest<'a>(components: impl IntoIterator<Item = &'a str>) -> impl std::fmt::LowerHex {
697 let mut digest = Sha256::new();
698 for component in components {
699 digest.update(component.len().to_string().as_bytes());
700 digest.update(b":");
701 digest.update(component.as_bytes());
702 digest.update(b";");
703 }
704 digest.finalize()
705}
706
707#[cfg(test)]
708#[allow(clippy::expect_used)]
709mod tests {
710 use chrono::Utc;
711 use serde_json::json;
712 use starweaver_core::{ConversationId, RunId, SessionId};
713
714 use crate::{RunRecord, RunStatus};
715
716 use super::{
717 DurableHostEventClass, DurableHostEventQuery, DurableHostEventScope,
718 PendingHostEventPublication, append_authoritative_run_publications,
719 output_available_publication, run_changed_publication,
720 };
721
722 #[test]
723 fn publication_identity_is_stable_and_framed() {
724 let occurred_at = Utc::now();
725 let first = PendingHostEventPublication::new(
726 "a:b",
727 0,
728 DurableHostEventScope::session(SessionId::from_string("c")),
729 DurableHostEventClass::SessionChanged,
730 json!({"revision": "1"}),
731 occurred_at,
732 )
733 .expect("first publication");
734 let retry = PendingHostEventPublication::new(
735 "a:b",
736 0,
737 DurableHostEventScope::session(SessionId::from_string("c")),
738 DurableHostEventClass::SessionChanged,
739 json!({"revision": "1"}),
740 occurred_at,
741 )
742 .expect("retry publication");
743 let ambiguous_without_framing = PendingHostEventPublication::new(
744 "a",
745 0,
746 DurableHostEventScope::session(SessionId::from_string("b:c")),
747 DurableHostEventClass::SessionChanged,
748 json!({"revision": "1"}),
749 occurred_at,
750 )
751 .expect("second publication");
752
753 assert_eq!(first, retry);
754 assert_ne!(
755 first.publication_key,
756 ambiguous_without_framing.publication_key
757 );
758 assert_ne!(first.event_id, ambiguous_without_framing.event_id);
759 }
760
761 #[test]
762 fn authoritative_run_projections_are_product_neutral_wire_shapes() {
763 let mut run = RunRecord::new(
764 SessionId::from_string("session-event"),
765 RunId::from_string("run-event"),
766 ConversationId::from_string("conversation-event"),
767 );
768 run.status = RunStatus::Completed;
769 run.output_preview = Some("ready".to_string());
770 run.revision = 7;
771
772 let changed =
773 run_changed_publication("run-transition", 0, &run).expect("run changed publication");
774 assert_eq!(changed.projection["kind"], json!("run_changed"));
775 assert_eq!(changed.projection["run"]["revision"], json!("7"));
776 assert_eq!(changed.projection["run"]["runId"], json!("run-event"));
777 assert_eq!(changed.projection["run"]["status"], json!("completed"));
778
779 let output = output_available_publication("run-transition", 0, &run)
780 .expect("output publication")
781 .expect("preview produces output event");
782 assert_eq!(output.projection["kind"], json!("output_available"));
783 assert_eq!(output.projection["preview"], json!("ready"));
784 assert_eq!(output.projection["runId"], json!("run-event"));
785 assert_eq!(output.projection["sessionId"], json!("session-event"));
786 }
787
788 #[test]
789 fn terminal_output_is_published_before_the_terminal_run_event() {
790 let mut run = RunRecord::new(
791 SessionId::from_string("session-terminal"),
792 RunId::from_string("run-terminal"),
793 ConversationId::from_string("conversation-terminal"),
794 );
795 run.status = RunStatus::Completed;
796 run.output_preview = Some("complete".to_string());
797
798 let preseeded =
799 run_changed_publication("terminal-transition", 0, &run).expect("preseeded run event");
800 for mut publications in [Vec::new(), vec![preseeded]] {
801 append_authoritative_run_publications(&mut publications, "terminal-transition", [&run])
802 .expect("authoritative terminal publications");
803 assert_eq!(
804 publications
805 .iter()
806 .map(|publication| publication.event_class)
807 .collect::<Vec<_>>(),
808 vec![
809 DurableHostEventClass::OutputAvailable,
810 DurableHostEventClass::RunChanged,
811 ]
812 );
813 }
814 }
815
816 #[test]
817 fn scope_containment_is_hierarchical() {
818 let session_id = SessionId::from_string("session-1");
819 let run = DurableHostEventScope::run(session_id.clone(), RunId::from_string("run-1"));
820 assert!(DurableHostEventScope::Global.contains(&run));
821 assert!(DurableHostEventScope::session(session_id).contains(&run));
822 assert!(run.contains(&run));
823 assert!(!DurableHostEventScope::session(SessionId::from_string("other")).contains(&run));
824 }
825
826 #[test]
827 fn query_rejects_empty_classes_and_unbounded_limits() {
828 assert!(DurableHostEventQuery::new(DurableHostEventScope::Global, [], None, 10).is_err());
829 assert!(
830 DurableHostEventQuery::new(
831 DurableHostEventScope::Global,
832 [DurableHostEventClass::Diagnostic],
833 None,
834 501,
835 )
836 .is_err()
837 );
838 assert!(
839 DurableHostEventQuery::new(
840 DurableHostEventScope::Global,
841 [DurableHostEventClass::Diagnostic],
842 Some(super::MAX_HOST_EVENT_POSITION + 1),
843 1,
844 )
845 .is_err()
846 );
847 }
848
849 #[test]
850 fn publication_validation_rejects_forged_event_identity() {
851 let mut publication = PendingHostEventPublication::new(
852 "transition",
853 0,
854 DurableHostEventScope::Global,
855 DurableHostEventClass::Diagnostic,
856 json!({"code": "test"}),
857 Utc::now(),
858 )
859 .expect("publication");
860 publication.event_id = "caller-selected".to_string();
861 assert!(publication.validate().is_err());
862 }
863}