1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
5use serde_json::Value;
6use sha2::{Digest, Sha256};
7use starweaver_context::ResumableState;
8use starweaver_core::{
9 CheckpointId, ConversationId, Metadata, RunId, RunLifecycle, SessionId, TaskId, TraceContext,
10 VersionedRecordError,
11};
12use starweaver_stream::{ReplayCursor, ReplayCursorFamily, ReplayScope};
13
14use crate::{input::InputPart, management::SessionDeletionFence};
15
16#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
18#[serde(rename_all = "snake_case")]
19pub enum SessionStatus {
20 #[default]
22 Active,
23 Archived,
25 Failed,
27 Deleted,
29}
30
31#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
33pub struct DurableRunStatus(Option<RunLifecycle>);
34
35pub type RunStatus = DurableRunStatus;
37
38#[allow(non_upper_case_globals)]
39impl DurableRunStatus {
40 pub const Queued: Self = Self(None);
42 pub const Starting: Self = Self(Some(RunLifecycle::Starting));
44 pub const Running: Self = Self(Some(RunLifecycle::Running));
46 pub const Waiting: Self = Self(Some(RunLifecycle::Waiting));
48 pub const Completed: Self = Self(Some(RunLifecycle::Completed));
50 pub const Failed: Self = Self(Some(RunLifecycle::Failed));
52 pub const Cancelled: Self = Self(Some(RunLifecycle::Cancelled));
54
55 #[must_use]
57 pub const fn lifecycle(self) -> Option<RunLifecycle> {
58 self.0
59 }
60
61 #[must_use]
63 pub const fn as_str(self) -> &'static str {
64 match self.0 {
65 None => "queued",
66 Some(lifecycle) => lifecycle.as_str(),
67 }
68 }
69
70 #[must_use]
72 pub const fn is_active(self) -> bool {
73 matches!(
74 self.0,
75 None | Some(RunLifecycle::Starting | RunLifecycle::Running | RunLifecycle::Waiting)
76 )
77 }
78
79 #[must_use]
81 pub const fn is_terminal(self) -> bool {
82 match self.0 {
83 Some(lifecycle) => lifecycle.is_terminal(),
84 None => false,
85 }
86 }
87}
88
89impl From<RunLifecycle> for DurableRunStatus {
90 fn from(value: RunLifecycle) -> Self {
91 Self(Some(value))
92 }
93}
94
95impl TryFrom<DurableRunStatus> for RunLifecycle {
96 type Error = QueuedRunStatus;
97
98 fn try_from(value: DurableRunStatus) -> Result<Self, Self::Error> {
99 value.0.ok_or(QueuedRunStatus)
100 }
101}
102
103impl Serialize for DurableRunStatus {
104 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
105 where
106 S: Serializer,
107 {
108 serializer.serialize_str(self.as_str())
109 }
110}
111
112impl<'de> Deserialize<'de> for DurableRunStatus {
113 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
114 where
115 D: Deserializer<'de>,
116 {
117 match String::deserialize(deserializer)?.as_str() {
118 "queued" => Ok(Self::Queued),
119 "starting" => Ok(Self::Starting),
120 "running" => Ok(Self::Running),
121 "waiting" => Ok(Self::Waiting),
122 "completed" => Ok(Self::Completed),
123 "failed" => Ok(Self::Failed),
124 "cancelled" => Ok(Self::Cancelled),
125 other => Err(D::Error::custom(format!("unknown run status: {other}"))),
126 }
127 }
128}
129
130#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
132pub struct RunTerminalError {
133 pub code: String,
135 pub message: String,
137}
138
139impl RunTerminalError {
140 #[must_use]
142 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
143 Self {
144 code: code.into(),
145 message: message.into(),
146 }
147 }
148}
149
150#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
152pub struct RunTerminalProjection {
153 pub status: RunStatus,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub output_preview: Option<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub error: Option<RunTerminalError>,
161}
162
163impl RunTerminalProjection {
164 pub fn try_new(
171 status: RunStatus,
172 output_preview: Option<String>,
173 error: Option<RunTerminalError>,
174 ) -> Result<Self, RunTerminalProjectionError> {
175 let projection = Self {
176 status,
177 output_preview,
178 error,
179 };
180 projection.validate()?;
181 Ok(projection)
182 }
183
184 #[must_use]
186 pub const fn completed(output_preview: Option<String>) -> Self {
187 Self {
188 status: RunStatus::Completed,
189 output_preview,
190 error: None,
191 }
192 }
193
194 #[must_use]
196 pub const fn failed(error: RunTerminalError) -> Self {
197 Self {
198 status: RunStatus::Failed,
199 output_preview: None,
200 error: Some(error),
201 }
202 }
203
204 #[must_use]
206 pub const fn cancelled(error: Option<RunTerminalError>) -> Self {
207 Self {
208 status: RunStatus::Cancelled,
209 output_preview: None,
210 error,
211 }
212 }
213
214 pub fn validate(&self) -> Result<(), RunTerminalProjectionError> {
223 if !self.status.is_terminal() {
224 return Err(RunTerminalProjectionError::NonTerminalStatus(self.status));
225 }
226 if self.status == RunStatus::Failed && self.error.is_none() {
227 return Err(RunTerminalProjectionError::MissingFailureDiagnostic);
228 }
229 if self.status == RunStatus::Completed && self.error.is_some() {
230 return Err(RunTerminalProjectionError::UnexpectedSuccessDiagnostic);
231 }
232 if let Some(error) = self.error.as_ref() {
233 if error.code.is_empty() {
234 return Err(RunTerminalProjectionError::EmptyDiagnosticCode);
235 }
236 if error.message.is_empty() {
237 return Err(RunTerminalProjectionError::EmptyDiagnosticMessage);
238 }
239 }
240 Ok(())
241 }
242
243 #[must_use]
245 pub fn matches(&self, run: &RunRecord) -> bool {
246 (run.status, &run.output_preview, &run.terminal_error)
247 == (self.status, &self.output_preview, &self.error)
248 }
249
250 pub fn apply_to(&self, run: &mut RunRecord) {
252 run.status = self.status;
253 run.output_preview.clone_from(&self.output_preview);
254 run.terminal_error.clone_from(&self.error);
255 }
256}
257
258#[derive(Clone, Debug, Eq, PartialEq)]
260pub enum RunTerminalProjectionError {
261 NonTerminalStatus(RunStatus),
263 MissingFailureDiagnostic,
265 UnexpectedSuccessDiagnostic,
267 UnexpectedNonTerminalDiagnostic(RunStatus),
269 EmptyDiagnosticCode,
271 EmptyDiagnosticMessage,
273}
274
275impl std::fmt::Display for RunTerminalProjectionError {
276 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277 match self {
278 Self::NonTerminalStatus(status) => {
279 write!(formatter, "run status {} is not terminal", status.as_str())
280 }
281 Self::MissingFailureDiagnostic => {
282 formatter.write_str("failed run requires a terminal diagnostic")
283 }
284 Self::UnexpectedSuccessDiagnostic => {
285 formatter.write_str("completed run cannot carry a terminal diagnostic")
286 }
287 Self::UnexpectedNonTerminalDiagnostic(status) => write!(
288 formatter,
289 "non-terminal run status {} cannot carry a terminal diagnostic",
290 status.as_str()
291 ),
292 Self::EmptyDiagnosticCode => formatter.write_str("terminal diagnostic code is empty"),
293 Self::EmptyDiagnosticMessage => {
294 formatter.write_str("terminal diagnostic message is empty")
295 }
296 }
297 }
298}
299
300impl std::error::Error for RunTerminalProjectionError {}
301
302#[derive(Clone, Copy, Debug, Eq, PartialEq)]
304pub struct QueuedRunStatus;
305
306impl std::fmt::Display for QueuedRunStatus {
307 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
308 formatter.write_str("queued run has no runtime lifecycle")
309 }
310}
311
312impl std::error::Error for QueuedRunStatus {}
313
314#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
316#[serde(rename_all = "snake_case")]
317pub enum ExecutionStatus {
318 Pending,
320 Running,
322 Waiting,
324 Completed,
326 Failed,
328 Cancelled,
330}
331
332#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
334pub struct EnvironmentStateRef {
335 pub provider: String,
337 pub reference: String,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub revision: Option<String>,
342 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
344 pub metadata: Metadata,
345}
346
347#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
349pub struct CheckpointRef {
350 pub checkpoint_id: CheckpointId,
352 pub run_id: RunId,
354 pub sequence: usize,
356 pub node: String,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub storage_ref: Option<String>,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub stream_cursor: Option<usize>,
364 pub created_at: DateTime<Utc>,
366 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
368 pub metadata: Metadata,
369}
370
371#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
373pub struct StreamCursorRef {
374 pub position: ReplayCursor,
376 pub created_at: DateTime<Utc>,
378 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
380 pub metadata: Metadata,
381}
382
383impl StreamCursorRef {
384 #[must_use]
386 pub fn new(position: ReplayCursor) -> Self {
387 Self {
388 position,
389 created_at: Utc::now(),
390 metadata: Metadata::default(),
391 }
392 }
393
394 #[must_use]
396 pub const fn family(&self) -> ReplayCursorFamily {
397 self.position.family
398 }
399
400 #[must_use]
402 pub const fn scope(&self) -> &ReplayScope {
403 &self.position.scope
404 }
405
406 #[must_use]
408 pub const fn sequence(&self) -> usize {
409 self.position.sequence
410 }
411
412 #[must_use]
414 pub fn same_stream(&self, other: &Self) -> bool {
415 self.family() == other.family() && self.scope() == other.scope()
416 }
417
418 pub fn validate_for_run(&self, run_id: &RunId) -> Result<(), StreamCursorRefError> {
424 let expected = ReplayScope::run(run_id.as_str());
425 if self.scope() != &expected {
426 return Err(StreamCursorRefError::WrongScope {
427 expected: expected.as_str().to_string(),
428 actual: self.scope().as_str().to_string(),
429 });
430 }
431 Ok(())
432 }
433
434 pub fn validate_progression(&self, current: &Self) -> Result<(), StreamCursorRefError> {
440 if self.same_stream(current) && self.sequence() < current.sequence() {
441 return Err(StreamCursorRefError::SequenceRegression {
442 family: self.family(),
443 scope: self.scope().as_str().to_string(),
444 current: current.sequence(),
445 proposed: self.sequence(),
446 });
447 }
448 Ok(())
449 }
450}
451
452#[derive(Clone, Debug, Eq, PartialEq)]
454pub enum StreamCursorRefError {
455 WrongScope {
457 expected: String,
459 actual: String,
461 },
462 SequenceRegression {
464 family: ReplayCursorFamily,
466 scope: String,
468 current: usize,
470 proposed: usize,
472 },
473}
474
475impl std::fmt::Display for StreamCursorRefError {
476 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
477 match self {
478 Self::WrongScope { expected, actual } => {
479 write!(
480 formatter,
481 "expected cursor scope {expected}, received {actual}"
482 )
483 }
484 Self::SequenceRegression {
485 family,
486 scope,
487 current,
488 proposed,
489 } => write!(
490 formatter,
491 "{} cursor for {scope} regressed from {current} to {proposed}",
492 family.as_str()
493 ),
494 }
495 }
496}
497
498impl std::error::Error for StreamCursorRefError {}
499
500#[derive(Deserialize)]
501#[serde(deny_unknown_fields)]
502struct CurrentStreamCursorRefWire {
503 position: ReplayCursor,
504 created_at: DateTime<Utc>,
505 #[serde(default)]
506 metadata: Metadata,
507}
508
509#[derive(Deserialize)]
510#[serde(deny_unknown_fields)]
511struct LegacyStreamCursorRefWire {
512 family: String,
513 scope: String,
514 sequence: usize,
515 #[serde(default)]
516 cursor: Option<String>,
517 created_at: DateTime<Utc>,
518 #[serde(default)]
519 metadata: Metadata,
520}
521
522#[derive(Deserialize)]
523#[serde(untagged)]
524enum StreamCursorRefWire {
525 Current(CurrentStreamCursorRefWire),
526 Legacy(LegacyStreamCursorRefWire),
527}
528
529impl<'de> Deserialize<'de> for StreamCursorRef {
530 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
531 where
532 D: Deserializer<'de>,
533 {
534 match StreamCursorRefWire::deserialize(deserializer)? {
535 StreamCursorRefWire::Current(current) => Ok(Self {
536 position: current.position,
537 created_at: current.created_at,
538 metadata: current.metadata,
539 }),
540 StreamCursorRefWire::Legacy(legacy) => {
541 let LegacyStreamCursorRefWire {
542 family,
543 scope,
544 sequence,
545 cursor,
546 created_at,
547 metadata,
548 } = legacy;
549 let family = match family.as_str() {
550 "raw_runtime" => ReplayCursorFamily::RawRuntime,
551 "display" => ReplayCursorFamily::Display,
552 "replay_event" => ReplayCursorFamily::ReplayEvent,
553 other => {
554 return Err(D::Error::custom(format!(
555 "unknown stream cursor family: {other}"
556 )));
557 }
558 };
559 let mut position =
560 ReplayCursor::for_family(family, ReplayScope::from_string(scope), sequence);
561 position.backend_cursor = cursor;
562 Ok(Self {
563 position,
564 created_at,
565 metadata,
566 })
567 }
568 }
569 }
570}
571
572#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
574#[serde(deny_unknown_fields)]
575pub struct WorkspaceProvenanceRef {
576 pub workspace_id: String,
578 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub display_label: Option<String>,
581 pub provenance_digest: String,
583}
584
585impl WorkspaceProvenanceRef {
586 #[must_use]
588 pub fn new(
589 workspace_id: impl Into<String>,
590 display_label: Option<String>,
591 provenance_digest: impl Into<String>,
592 ) -> Self {
593 Self {
594 workspace_id: workspace_id.into(),
595 display_label,
596 provenance_digest: provenance_digest.into(),
597 }
598 }
599
600 #[must_use]
605 pub fn for_execution_domain_root(
606 execution_domain_id: &str,
607 canonical_root: &str,
608 display_label: Option<String>,
609 ) -> Self {
610 let identity = workspace_identity_digest(
611 b"starweaver.workspace.id.v1\0",
612 execution_domain_id,
613 canonical_root,
614 );
615 let provenance = workspace_identity_digest(
616 b"starweaver.workspace.provenance.v1\0",
617 execution_domain_id,
618 canonical_root,
619 );
620 Self {
621 workspace_id: format!("workspace_{}", &identity[..32]),
622 display_label,
623 provenance_digest: format!("sha256:{provenance}"),
624 }
625 }
626
627 #[must_use]
632 pub fn from_legacy_display(display: impl Into<String>) -> Self {
633 let display = display.into();
634 let mut digest = Sha256::new();
635 digest.update(b"starweaver.session.workspace_provenance.legacy.v1\0");
636 digest.update(display.as_bytes());
637 let digest = format!("sha256:{:x}", digest.finalize());
638 Self {
639 workspace_id: format!("legacy:{digest}"),
640 display_label: Some(display),
641 provenance_digest: digest,
642 }
643 }
644
645 #[must_use]
647 pub fn is_legacy_unbound(&self) -> bool {
648 self.workspace_id.starts_with("legacy:")
649 }
650
651 #[must_use]
653 pub fn display_value(&self) -> &str {
654 self.display_label.as_deref().unwrap_or(&self.workspace_id)
655 }
656
657 #[must_use]
659 pub fn matches_filter(&self, workspace: &str) -> bool {
660 self.workspace_id == workspace || self.display_label.as_deref() == Some(workspace)
661 }
662
663 pub fn validate(&self) -> Result<(), &'static str> {
670 if self.workspace_id.trim().is_empty() || self.provenance_digest.trim().is_empty() {
671 return Err("workspace provenance identity is empty");
672 }
673 if self.is_legacy_unbound()
674 && (self.workspace_id != format!("legacy:{}", self.provenance_digest)
675 || self.display_label.as_deref().is_none_or(str::is_empty))
676 {
677 return Err("legacy workspace provenance is inconsistent");
678 }
679 Ok(())
680 }
681}
682
683fn workspace_identity_digest(
684 domain: &[u8],
685 execution_domain_id: &str,
686 canonical_root: &str,
687) -> String {
688 let mut digest = Sha256::new();
689 digest.update(domain);
690 digest.update(
691 u64::try_from(execution_domain_id.len())
692 .unwrap_or(u64::MAX)
693 .to_be_bytes(),
694 );
695 digest.update(execution_domain_id.as_bytes());
696 digest.update(
697 u64::try_from(canonical_root.len())
698 .unwrap_or(u64::MAX)
699 .to_be_bytes(),
700 );
701 digest.update(canonical_root.as_bytes());
702 format!("{:x}", digest.finalize())
703}
704
705#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
707#[serde(deny_unknown_fields)]
708pub struct RuntimeConfigSnapshotRef {
709 pub generation: u64,
711 pub etag: String,
713 pub materialization_digest: String,
715}
716
717impl RuntimeConfigSnapshotRef {
718 #[must_use]
720 pub fn new(
721 generation: u64,
722 etag: impl Into<String>,
723 materialization_digest: impl Into<String>,
724 ) -> Self {
725 Self {
726 generation,
727 etag: etag.into(),
728 materialization_digest: materialization_digest.into(),
729 }
730 }
731
732 pub fn validate(&self) -> Result<(), &'static str> {
738 if self.generation == 0 {
739 return Err("runtime config generation must be positive");
740 }
741 if self.etag.trim().is_empty() || self.materialization_digest.trim().is_empty() {
742 return Err("runtime config snapshot identity is empty");
743 }
744 Ok(())
745 }
746}
747
748#[derive(Deserialize)]
749struct SessionRecordV1 {
750 #[serde(default)]
751 workspace: Option<String>,
752 #[serde(flatten)]
753 fields: serde_json::Map<String, Value>,
754}
755
756fn decode_session_record_v1(payload: Value) -> Result<SessionRecord, VersionedRecordError> {
757 let legacy =
758 serde_json::from_value::<SessionRecordV1>(payload).map_err(VersionedRecordError::Json)?;
759 let mut current = serde_json::from_value::<SessionRecord>(Value::Object(legacy.fields))
760 .map_err(VersionedRecordError::Json)?;
761 current.workspace = legacy
762 .workspace
763 .map(WorkspaceProvenanceRef::from_legacy_display);
764 Ok(current)
765}
766
767#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
769pub struct SessionRecord {
770 pub session_id: SessionId,
772 #[serde(default = "default_session_namespace")]
774 pub namespace_id: String,
775 #[serde(default, skip_serializing_if = "Option::is_none")]
777 pub owner_id: Option<String>,
778 #[serde(default = "initial_session_revision")]
780 pub revision: u64,
781 #[serde(default)]
783 pub deletion_fence: SessionDeletionFence,
784 #[serde(default, skip_serializing_if = "Option::is_none")]
786 pub title: Option<String>,
787 #[serde(default, skip_serializing_if = "Option::is_none")]
789 pub workspace: Option<WorkspaceProvenanceRef>,
790 #[serde(default, skip_serializing_if = "Option::is_none")]
792 pub profile: Option<String>,
793 #[serde(default)]
795 pub status: SessionStatus,
796 #[serde(default)]
798 pub state: ResumableState,
799 #[serde(default, skip_serializing_if = "Option::is_none")]
801 pub environment_state: Option<EnvironmentStateRef>,
802 #[serde(default, skip_serializing_if = "Vec::is_empty")]
804 pub stream_cursors: Vec<StreamCursorRef>,
805 #[serde(default, skip_serializing_if = "TraceContext::is_empty")]
807 pub trace_context: TraceContext,
808 #[serde(default, skip_serializing_if = "Option::is_none")]
810 pub parent_session_id: Option<SessionId>,
811 #[serde(default, skip_serializing_if = "Option::is_none")]
813 pub head_run_id: Option<RunId>,
814 #[serde(default, skip_serializing_if = "Option::is_none")]
816 pub head_success_run_id: Option<RunId>,
817 #[serde(default, skip_serializing_if = "Option::is_none")]
819 pub active_run_id: Option<RunId>,
820 pub created_at: DateTime<Utc>,
822 pub updated_at: DateTime<Utc>,
824 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
826 pub metadata: Metadata,
827}
828
829fn default_session_namespace() -> String {
830 crate::LOCAL_SESSION_NAMESPACE.to_string()
831}
832
833const fn initial_session_revision() -> u64 {
834 1
835}
836
837const fn initial_run_revision() -> u64 {
838 1
839}
840
841impl starweaver_core::VersionedRecord for SessionRecord {
842 const SCHEMA: &'static str = "starweaver.session.session_record";
843 const VERSION: u32 = 2;
844 const ALLOW_BARE_V0: bool = true;
845
846 fn decode_version(version: u32, payload: Value) -> Result<Self, VersionedRecordError> {
847 match version {
848 1 => decode_session_record_v1(payload),
849 Self::VERSION => serde_json::from_value(payload).map_err(VersionedRecordError::Json),
850 actual => Err(VersionedRecordError::UnsupportedVersion {
851 schema: Self::SCHEMA,
852 supported: Self::VERSION,
853 actual,
854 }),
855 }
856 }
857
858 fn decode_bare_v0(payload: Value) -> Result<Self, VersionedRecordError> {
859 decode_session_record_v1(payload)
860 }
861}
862
863impl SessionRecord {
864 pub fn validate_provenance(&self) -> Result<(), &'static str> {
870 self.workspace
871 .as_ref()
872 .map_or(Ok(()), WorkspaceProvenanceRef::validate)
873 }
874
875 #[must_use]
877 pub fn new(session_id: SessionId) -> Self {
878 let now = Utc::now();
879 Self {
880 session_id,
881 namespace_id: default_session_namespace(),
882 owner_id: None,
883 revision: initial_session_revision(),
884 deletion_fence: SessionDeletionFence::Stable,
885 title: None,
886 workspace: None,
887 profile: None,
888 status: SessionStatus::Active,
889 state: ResumableState::default(),
890 environment_state: None,
891 stream_cursors: Vec::new(),
892 trace_context: TraceContext::default(),
893 parent_session_id: None,
894 head_run_id: None,
895 head_success_run_id: None,
896 active_run_id: None,
897 created_at: now,
898 updated_at: now,
899 metadata: Metadata::default(),
900 }
901 }
902}
903
904#[derive(Deserialize)]
905struct RunRecordV1 {
906 #[serde(flatten)]
907 fields: serde_json::Map<String, Value>,
908}
909
910fn decode_run_record_v1(payload: Value) -> Result<RunRecord, VersionedRecordError> {
911 let legacy =
912 serde_json::from_value::<RunRecordV1>(payload).map_err(VersionedRecordError::Json)?;
913 serde_json::from_value::<RunRecord>(Value::Object(legacy.fields))
914 .map_err(VersionedRecordError::Json)
915}
916
917#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
919pub struct RunRecord {
920 pub session_id: SessionId,
922 pub run_id: RunId,
924 #[serde(default = "initial_run_revision")]
926 pub revision: u64,
927 pub conversation_id: ConversationId,
929 #[serde(default, skip_serializing_if = "Vec::is_empty")]
931 pub input: Vec<InputPart>,
932 #[serde(default)]
934 pub status: RunStatus,
935 #[serde(default, skip_serializing_if = "Option::is_none")]
937 pub output_preview: Option<String>,
938 #[serde(default, skip_serializing_if = "Option::is_none")]
940 pub terminal_error: Option<RunTerminalError>,
941 #[serde(default, skip_serializing_if = "Value::is_null")]
943 pub structured_output: Value,
944 #[serde(default, skip_serializing_if = "Option::is_none")]
946 pub latest_checkpoint: Option<CheckpointRef>,
947 #[serde(default, skip_serializing_if = "Option::is_none")]
949 pub environment_state: Option<EnvironmentStateRef>,
950 #[serde(default, skip_serializing_if = "Vec::is_empty")]
952 pub stream_cursors: Vec<StreamCursorRef>,
953 #[serde(default, skip_serializing_if = "TraceContext::is_empty")]
955 pub trace_context: TraceContext,
956 #[serde(default)]
958 pub sequence_no: usize,
959 #[serde(default, skip_serializing_if = "Option::is_none")]
961 pub restore_from_run_id: Option<RunId>,
962 #[serde(default, skip_serializing_if = "Option::is_none")]
964 pub parent_run_id: Option<RunId>,
965 #[serde(default, skip_serializing_if = "Option::is_none")]
967 pub parent_task_id: Option<TaskId>,
968 #[serde(default, skip_serializing_if = "Option::is_none")]
970 pub trigger_type: Option<String>,
971 #[serde(default, skip_serializing_if = "Option::is_none")]
973 pub profile: Option<String>,
974 #[serde(default, skip_serializing_if = "Option::is_none")]
976 pub config_snapshot: Option<RuntimeConfigSnapshotRef>,
977 pub created_at: DateTime<Utc>,
979 pub updated_at: DateTime<Utc>,
981 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
983 pub metadata: Metadata,
984}
985
986impl starweaver_core::VersionedRecord for RunRecord {
987 const SCHEMA: &'static str = "starweaver.session.run_record";
988 const VERSION: u32 = 2;
989 const ALLOW_BARE_V0: bool = true;
990
991 fn decode_version(version: u32, payload: Value) -> Result<Self, VersionedRecordError> {
992 match version {
993 1 => decode_run_record_v1(payload),
994 Self::VERSION => serde_json::from_value(payload).map_err(VersionedRecordError::Json),
995 actual => Err(VersionedRecordError::UnsupportedVersion {
996 schema: Self::SCHEMA,
997 supported: Self::VERSION,
998 actual,
999 }),
1000 }
1001 }
1002
1003 fn decode_bare_v0(payload: Value) -> Result<Self, VersionedRecordError> {
1004 decode_run_record_v1(payload)
1005 }
1006}
1007
1008impl RunRecord {
1009 pub fn validate_provenance(&self) -> Result<(), &'static str> {
1015 self.config_snapshot
1016 .as_ref()
1017 .map_or(Ok(()), RuntimeConfigSnapshotRef::validate)
1018 }
1019
1020 #[must_use]
1024 pub fn terminal_projection(&self) -> Option<RunTerminalProjection> {
1025 self.status.is_terminal().then(|| RunTerminalProjection {
1026 status: self.status,
1027 output_preview: self.output_preview.clone(),
1028 error: self.terminal_error.clone(),
1029 })
1030 }
1031
1032 pub fn validate_new_write(&self) -> Result<(), RunTerminalProjectionError> {
1041 self.terminal_projection().map_or_else(
1042 || {
1043 if self.terminal_error.is_some() {
1044 Err(RunTerminalProjectionError::UnexpectedNonTerminalDiagnostic(
1045 self.status,
1046 ))
1047 } else {
1048 Ok(())
1049 }
1050 },
1051 |terminal| terminal.validate(),
1052 )
1053 }
1054
1055 pub fn normalize_for_admission(&mut self) {
1060 self.status = RunStatus::Queued;
1061 self.output_preview = None;
1062 self.terminal_error = None;
1063 }
1064
1065 pub fn apply_legacy_status_update(
1070 &mut self,
1071 status: RunStatus,
1072 output_preview: Option<String>,
1073 ) {
1074 self.status = status;
1075 match status {
1076 RunStatus::Failed => {
1077 self.output_preview = None;
1078 self.terminal_error = Some(RunTerminalError::new(
1079 "legacy_status_update_failed",
1080 "run failed",
1081 ));
1082 }
1083 RunStatus::Cancelled => {
1084 self.output_preview = None;
1085 self.terminal_error = Some(RunTerminalError::new(
1086 "legacy_status_update_cancelled",
1087 "run cancelled",
1088 ));
1089 }
1090 _ => {
1091 self.output_preview = output_preview;
1092 self.terminal_error = None;
1093 }
1094 }
1095 }
1096
1097 #[must_use]
1099 pub fn new(session_id: SessionId, run_id: RunId, conversation_id: ConversationId) -> Self {
1100 let now = Utc::now();
1101 Self {
1102 session_id,
1103 run_id,
1104 revision: initial_run_revision(),
1105 conversation_id,
1106 input: Vec::new(),
1107 status: RunStatus::Queued,
1108 output_preview: None,
1109 terminal_error: None,
1110 structured_output: Value::Null,
1111 latest_checkpoint: None,
1112 environment_state: None,
1113 stream_cursors: Vec::new(),
1114 trace_context: TraceContext::default(),
1115 sequence_no: 0,
1116 restore_from_run_id: None,
1117 parent_run_id: None,
1118 parent_task_id: None,
1119 trigger_type: None,
1120 profile: None,
1121 config_snapshot: None,
1122 created_at: now,
1123 updated_at: now,
1124 metadata: Metadata::default(),
1125 }
1126 }
1127}