Skip to main content

starweaver_session/
records.rs

1//! Durable session and run records.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
5use serde_json::Value;
6use starweaver_context::ResumableState;
7use starweaver_core::{
8    CheckpointId, ConversationId, Metadata, RunId, RunLifecycle, SessionId, TaskId, TraceContext,
9};
10use starweaver_stream::{ReplayCursor, ReplayCursorFamily, ReplayScope};
11
12use crate::{input::InputPart, management::SessionDeletionFence};
13
14/// Current durable session status.
15#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
16#[serde(rename_all = "snake_case")]
17pub enum SessionStatus {
18    /// Session can accept work.
19    #[default]
20    Active,
21    /// Session is archived.
22    Archived,
23    /// Session reached a failed terminal state.
24    Failed,
25    /// Session is tombstoned. Retained evidence is not model-visible.
26    Deleted,
27}
28
29/// Durable run status composed from admission state and the shared runtime lifecycle.
30#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
31pub struct DurableRunStatus(Option<RunLifecycle>);
32
33/// Backward-compatible public name for the durable run status.
34pub type RunStatus = DurableRunStatus;
35
36#[allow(non_upper_case_globals)]
37impl DurableRunStatus {
38    /// Run is accepted and awaiting runtime admission.
39    pub const Queued: Self = Self(None);
40    /// Runtime initialization is in progress.
41    pub const Starting: Self = Self(Some(RunLifecycle::Starting));
42    /// Runtime is actively executing.
43    pub const Running: Self = Self(Some(RunLifecycle::Running));
44    /// Runtime is waiting for external work.
45    pub const Waiting: Self = Self(Some(RunLifecycle::Waiting));
46    /// Runtime completed successfully.
47    pub const Completed: Self = Self(Some(RunLifecycle::Completed));
48    /// Runtime failed.
49    pub const Failed: Self = Self(Some(RunLifecycle::Failed));
50    /// Runtime was cancelled or interrupted.
51    pub const Cancelled: Self = Self(Some(RunLifecycle::Cancelled));
52
53    /// Return the admitted runtime lifecycle, or `None` while queued.
54    #[must_use]
55    pub const fn lifecycle(self) -> Option<RunLifecycle> {
56        self.0
57    }
58
59    /// Return the stable flat wire name.
60    #[must_use]
61    pub const fn as_str(self) -> &'static str {
62        match self.0 {
63            None => "queued",
64            Some(lifecycle) => lifecycle.as_str(),
65        }
66    }
67
68    /// Return whether the run owns the active session slot.
69    #[must_use]
70    pub const fn is_active(self) -> bool {
71        matches!(
72            self.0,
73            None | Some(RunLifecycle::Starting | RunLifecycle::Running | RunLifecycle::Waiting)
74        )
75    }
76
77    /// Return whether the run reached a terminal lifecycle.
78    #[must_use]
79    pub const fn is_terminal(self) -> bool {
80        match self.0 {
81            Some(lifecycle) => lifecycle.is_terminal(),
82            None => false,
83        }
84    }
85}
86
87impl From<RunLifecycle> for DurableRunStatus {
88    fn from(value: RunLifecycle) -> Self {
89        Self(Some(value))
90    }
91}
92
93impl TryFrom<DurableRunStatus> for RunLifecycle {
94    type Error = QueuedRunStatus;
95
96    fn try_from(value: DurableRunStatus) -> Result<Self, Self::Error> {
97        value.0.ok_or(QueuedRunStatus)
98    }
99}
100
101impl Serialize for DurableRunStatus {
102    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
103    where
104        S: Serializer,
105    {
106        serializer.serialize_str(self.as_str())
107    }
108}
109
110impl<'de> Deserialize<'de> for DurableRunStatus {
111    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
112    where
113        D: Deserializer<'de>,
114    {
115        match String::deserialize(deserializer)?.as_str() {
116            "queued" => Ok(Self::Queued),
117            "starting" => Ok(Self::Starting),
118            "running" => Ok(Self::Running),
119            "waiting" => Ok(Self::Waiting),
120            "completed" => Ok(Self::Completed),
121            "failed" => Ok(Self::Failed),
122            "cancelled" => Ok(Self::Cancelled),
123            other => Err(D::Error::custom(format!("unknown run status: {other}"))),
124        }
125    }
126}
127
128/// Product-neutral diagnostic retained with a terminal durable run.
129#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
130pub struct RunTerminalError {
131    /// Stable `snake_case` category chosen by the producing boundary.
132    pub code: String,
133    /// Sanitized diagnostic suitable for durable retrieval.
134    pub message: String,
135}
136
137impl RunTerminalError {
138    /// Build a terminal diagnostic from a stable code and safe message.
139    #[must_use]
140    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
141        Self {
142            code: code.into(),
143            message: message.into(),
144        }
145    }
146}
147
148/// Atomic terminal status, output, and diagnostic projection.
149#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
150pub struct RunTerminalProjection {
151    /// Terminal durable status.
152    pub status: RunStatus,
153    /// User-visible final output preview, not an error transport.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub output_preview: Option<String>,
156    /// Safe terminal diagnostic.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub error: Option<RunTerminalError>,
159}
160
161impl RunTerminalProjection {
162    /// Build and validate a terminal projection.
163    ///
164    /// # Errors
165    ///
166    /// Returns an error for a non-terminal status, a failed status without a diagnostic,
167    /// a completed status with a diagnostic, or an empty diagnostic field.
168    pub fn try_new(
169        status: RunStatus,
170        output_preview: Option<String>,
171        error: Option<RunTerminalError>,
172    ) -> Result<Self, RunTerminalProjectionError> {
173        let projection = Self {
174            status,
175            output_preview,
176            error,
177        };
178        projection.validate()?;
179        Ok(projection)
180    }
181
182    /// Build a successful terminal projection.
183    #[must_use]
184    pub const fn completed(output_preview: Option<String>) -> Self {
185        Self {
186            status: RunStatus::Completed,
187            output_preview,
188            error: None,
189        }
190    }
191
192    /// Build a failed terminal projection with no output preview.
193    #[must_use]
194    pub const fn failed(error: RunTerminalError) -> Self {
195        Self {
196            status: RunStatus::Failed,
197            output_preview: None,
198            error: Some(error),
199        }
200    }
201
202    /// Build a cancelled terminal projection with an optional safe reason.
203    #[must_use]
204    pub const fn cancelled(error: Option<RunTerminalError>) -> Self {
205        Self {
206            status: RunStatus::Cancelled,
207            output_preview: None,
208            error,
209        }
210    }
211
212    /// Validate the projection as a new terminal write.
213    ///
214    /// Historical records may lack a diagnostic. Stores validate this invariant only when
215    /// terminalizing a non-terminal record, while preserving already-committed legacy evidence.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error when the projection violates terminal status or diagnostic invariants.
220    pub fn validate(&self) -> Result<(), RunTerminalProjectionError> {
221        if !self.status.is_terminal() {
222            return Err(RunTerminalProjectionError::NonTerminalStatus(self.status));
223        }
224        if self.status == RunStatus::Failed && self.error.is_none() {
225            return Err(RunTerminalProjectionError::MissingFailureDiagnostic);
226        }
227        if self.status == RunStatus::Completed && self.error.is_some() {
228            return Err(RunTerminalProjectionError::UnexpectedSuccessDiagnostic);
229        }
230        if let Some(error) = self.error.as_ref() {
231            if error.code.is_empty() {
232                return Err(RunTerminalProjectionError::EmptyDiagnosticCode);
233            }
234            if error.message.is_empty() {
235                return Err(RunTerminalProjectionError::EmptyDiagnosticMessage);
236            }
237        }
238        Ok(())
239    }
240
241    /// Return whether this projection exactly matches a durable run record.
242    #[must_use]
243    pub fn matches(&self, run: &RunRecord) -> bool {
244        (run.status, &run.output_preview, &run.terminal_error)
245            == (self.status, &self.output_preview, &self.error)
246    }
247
248    /// Apply this complete projection to a run record.
249    pub fn apply_to(&self, run: &mut RunRecord) {
250        run.status = self.status;
251        run.output_preview.clone_from(&self.output_preview);
252        run.terminal_error.clone_from(&self.error);
253    }
254}
255
256/// Invalid new terminal run projection.
257#[derive(Clone, Debug, Eq, PartialEq)]
258pub enum RunTerminalProjectionError {
259    /// The supplied status is not terminal.
260    NonTerminalStatus(RunStatus),
261    /// A failed run omitted its diagnostic.
262    MissingFailureDiagnostic,
263    /// A completed run carried an error diagnostic.
264    UnexpectedSuccessDiagnostic,
265    /// A non-terminal run carried a stale terminal diagnostic.
266    UnexpectedNonTerminalDiagnostic(RunStatus),
267    /// The stable diagnostic category is empty.
268    EmptyDiagnosticCode,
269    /// The diagnostic message is empty.
270    EmptyDiagnosticMessage,
271}
272
273impl std::fmt::Display for RunTerminalProjectionError {
274    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        match self {
276            Self::NonTerminalStatus(status) => {
277                write!(formatter, "run status {} is not terminal", status.as_str())
278            }
279            Self::MissingFailureDiagnostic => {
280                formatter.write_str("failed run requires a terminal diagnostic")
281            }
282            Self::UnexpectedSuccessDiagnostic => {
283                formatter.write_str("completed run cannot carry a terminal diagnostic")
284            }
285            Self::UnexpectedNonTerminalDiagnostic(status) => write!(
286                formatter,
287                "non-terminal run status {} cannot carry a terminal diagnostic",
288                status.as_str()
289            ),
290            Self::EmptyDiagnosticCode => formatter.write_str("terminal diagnostic code is empty"),
291            Self::EmptyDiagnosticMessage => {
292                formatter.write_str("terminal diagnostic message is empty")
293            }
294        }
295    }
296}
297
298impl std::error::Error for RunTerminalProjectionError {}
299
300/// A queued durable run has not entered an executable runtime lifecycle.
301#[derive(Clone, Copy, Debug, Eq, PartialEq)]
302pub struct QueuedRunStatus;
303
304impl std::fmt::Display for QueuedRunStatus {
305    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306        formatter.write_str("queued run has no runtime lifecycle")
307    }
308}
309
310impl std::error::Error for QueuedRunStatus {}
311
312/// Generic execution status for approval, deferred, checkpoint, and archive workflows.
313#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
314#[serde(rename_all = "snake_case")]
315pub enum ExecutionStatus {
316    /// Item has been created.
317    Pending,
318    /// Item is currently processing.
319    Running,
320    /// Item is waiting on an external decision or worker.
321    Waiting,
322    /// Item completed successfully.
323    Completed,
324    /// Item failed.
325    Failed,
326    /// Item was cancelled.
327    Cancelled,
328}
329
330/// Stable reference to an exported environment provider state.
331#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
332pub struct EnvironmentStateRef {
333    /// Environment provider name.
334    pub provider: String,
335    /// Stable provider state reference.
336    pub reference: String,
337    /// Provider state revision, hash, or generation.
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub revision: Option<String>,
340    /// Provider-specific metadata.
341    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
342    pub metadata: Metadata,
343}
344
345/// Stable reference to a persisted checkpoint.
346#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
347pub struct CheckpointRef {
348    /// Checkpoint id.
349    pub checkpoint_id: CheckpointId,
350    /// Run id.
351    pub run_id: RunId,
352    /// Checkpoint sequence within the run.
353    pub sequence: usize,
354    /// Runtime node name.
355    pub node: String,
356    /// Optional storage URI.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub storage_ref: Option<String>,
359    /// Stream cursor captured with this checkpoint.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub stream_cursor: Option<usize>,
362    /// Creation time.
363    pub created_at: DateTime<Utc>,
364    /// Checkpoint metadata.
365    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
366    pub metadata: Metadata,
367}
368
369/// Stable durable reference to a family-aware stream replay position.
370#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
371pub struct StreamCursorRef {
372    /// Canonical stream cursor. Family, scope, sequence, and backend position live here once.
373    pub position: ReplayCursor,
374    /// Creation time.
375    pub created_at: DateTime<Utc>,
376    /// Cursor metadata.
377    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
378    pub metadata: Metadata,
379}
380
381impl StreamCursorRef {
382    /// Build a durable reference from a canonical cursor.
383    #[must_use]
384    pub fn new(position: ReplayCursor) -> Self {
385        Self {
386            position,
387            created_at: Utc::now(),
388            metadata: Metadata::default(),
389        }
390    }
391
392    /// Return the cursor family.
393    #[must_use]
394    pub const fn family(&self) -> ReplayCursorFamily {
395        self.position.family
396    }
397
398    /// Return the cursor scope.
399    #[must_use]
400    pub const fn scope(&self) -> &ReplayScope {
401        &self.position.scope
402    }
403
404    /// Return the last observed sequence.
405    #[must_use]
406    pub const fn sequence(&self) -> usize {
407        self.position.sequence
408    }
409
410    /// Return whether two references address the same stream family and scope.
411    #[must_use]
412    pub fn same_stream(&self, other: &Self) -> bool {
413        self.family() == other.family() && self.scope() == other.scope()
414    }
415
416    /// Validate that this cursor belongs to the supplied run scope.
417    ///
418    /// # Errors
419    ///
420    /// Returns an error when the cursor addresses another run or a non-run scope.
421    pub fn validate_for_run(&self, run_id: &RunId) -> Result<(), StreamCursorRefError> {
422        let expected = ReplayScope::run(run_id.as_str());
423        if self.scope() != &expected {
424            return Err(StreamCursorRefError::WrongScope {
425                expected: expected.as_str().to_string(),
426                actual: self.scope().as_str().to_string(),
427            });
428        }
429        Ok(())
430    }
431
432    /// Validate that replacing an existing same-stream cursor does not regress.
433    ///
434    /// # Errors
435    ///
436    /// Returns an error when the proposed sequence is behind the current sequence.
437    pub fn validate_progression(&self, current: &Self) -> Result<(), StreamCursorRefError> {
438        if self.same_stream(current) && self.sequence() < current.sequence() {
439            return Err(StreamCursorRefError::SequenceRegression {
440                family: self.family(),
441                scope: self.scope().as_str().to_string(),
442                current: current.sequence(),
443                proposed: self.sequence(),
444            });
445        }
446        Ok(())
447    }
448}
449
450/// Invalid durable stream-cursor update.
451#[derive(Clone, Debug, Eq, PartialEq)]
452pub enum StreamCursorRefError {
453    /// Cursor scope does not identify the run being updated.
454    WrongScope {
455        /// Expected run scope.
456        expected: String,
457        /// Supplied scope.
458        actual: String,
459    },
460    /// Cursor sequence would move durable replay progress backwards.
461    SequenceRegression {
462        /// Cursor family.
463        family: ReplayCursorFamily,
464        /// Cursor scope.
465        scope: String,
466        /// Current sequence.
467        current: usize,
468        /// Proposed older sequence.
469        proposed: usize,
470    },
471}
472
473impl std::fmt::Display for StreamCursorRefError {
474    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
475        match self {
476            Self::WrongScope { expected, actual } => {
477                write!(
478                    formatter,
479                    "expected cursor scope {expected}, received {actual}"
480                )
481            }
482            Self::SequenceRegression {
483                family,
484                scope,
485                current,
486                proposed,
487            } => write!(
488                formatter,
489                "{} cursor for {scope} regressed from {current} to {proposed}",
490                family.as_str()
491            ),
492        }
493    }
494}
495
496impl std::error::Error for StreamCursorRefError {}
497
498#[derive(Deserialize)]
499#[serde(deny_unknown_fields)]
500struct CurrentStreamCursorRefWire {
501    position: ReplayCursor,
502    created_at: DateTime<Utc>,
503    #[serde(default)]
504    metadata: Metadata,
505}
506
507#[derive(Deserialize)]
508#[serde(deny_unknown_fields)]
509struct LegacyStreamCursorRefWire {
510    family: String,
511    scope: String,
512    sequence: usize,
513    #[serde(default)]
514    cursor: Option<String>,
515    created_at: DateTime<Utc>,
516    #[serde(default)]
517    metadata: Metadata,
518}
519
520#[derive(Deserialize)]
521#[serde(untagged)]
522enum StreamCursorRefWire {
523    Current(CurrentStreamCursorRefWire),
524    Legacy(LegacyStreamCursorRefWire),
525}
526
527impl<'de> Deserialize<'de> for StreamCursorRef {
528    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
529    where
530        D: Deserializer<'de>,
531    {
532        match StreamCursorRefWire::deserialize(deserializer)? {
533            StreamCursorRefWire::Current(current) => Ok(Self {
534                position: current.position,
535                created_at: current.created_at,
536                metadata: current.metadata,
537            }),
538            StreamCursorRefWire::Legacy(legacy) => {
539                let LegacyStreamCursorRefWire {
540                    family,
541                    scope,
542                    sequence,
543                    cursor,
544                    created_at,
545                    metadata,
546                } = legacy;
547                let family = match family.as_str() {
548                    "raw_runtime" => ReplayCursorFamily::RawRuntime,
549                    "display" => ReplayCursorFamily::Display,
550                    "replay_event" => ReplayCursorFamily::ReplayEvent,
551                    other => {
552                        return Err(D::Error::custom(format!(
553                            "unknown stream cursor family: {other}"
554                        )));
555                    }
556                };
557                let mut position =
558                    ReplayCursor::for_family(family, ReplayScope::from_string(scope), sequence);
559                position.backend_cursor = cursor;
560                Ok(Self {
561                    position,
562                    created_at,
563                    metadata,
564                })
565            }
566        }
567    }
568}
569
570/// Durable session record.
571#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
572pub struct SessionRecord {
573    /// Session id.
574    pub session_id: SessionId,
575    /// Host-derived store/tenant namespace. Legacy records default to `local`.
576    #[serde(default = "default_session_namespace")]
577    pub namespace_id: String,
578    /// Host-derived owner/principal. Lineage and metadata never assign authority.
579    #[serde(default, skip_serializing_if = "Option::is_none")]
580    pub owner_id: Option<String>,
581    /// Monotonic optimistic concurrency revision.
582    #[serde(default = "initial_session_revision")]
583    pub revision: u64,
584    /// Deletion/continuation fence.
585    #[serde(default)]
586    pub deletion_fence: SessionDeletionFence,
587    /// User-facing title.
588    #[serde(default, skip_serializing_if = "Option::is_none")]
589    pub title: Option<String>,
590    /// Workspace identifier or path.
591    #[serde(default, skip_serializing_if = "Option::is_none")]
592    pub workspace: Option<String>,
593    /// Runtime profile or model profile name.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub profile: Option<String>,
596    /// Session status.
597    #[serde(default)]
598    pub status: SessionStatus,
599    /// Last exported context state.
600    #[serde(default)]
601    pub state: ResumableState,
602    /// Latest exported environment state reference.
603    #[serde(default, skip_serializing_if = "Option::is_none")]
604    pub environment_state: Option<EnvironmentStateRef>,
605    /// Latest stream cursor refs by family.
606    #[serde(default, skip_serializing_if = "Vec::is_empty")]
607    pub stream_cursors: Vec<StreamCursorRef>,
608    /// Session trace context.
609    #[serde(default, skip_serializing_if = "TraceContext::is_empty")]
610    pub trace_context: TraceContext,
611    /// Parent session id for forks or delegated flows.
612    #[serde(default, skip_serializing_if = "Option::is_none")]
613    pub parent_session_id: Option<SessionId>,
614    /// Latest run in the session sequence.
615    #[serde(default, skip_serializing_if = "Option::is_none")]
616    pub head_run_id: Option<RunId>,
617    /// Latest completed run usable as continuation source.
618    #[serde(default, skip_serializing_if = "Option::is_none")]
619    pub head_success_run_id: Option<RunId>,
620    /// Currently queued, running, or waiting run.
621    #[serde(default, skip_serializing_if = "Option::is_none")]
622    pub active_run_id: Option<RunId>,
623    /// Creation time.
624    pub created_at: DateTime<Utc>,
625    /// Last update time.
626    pub updated_at: DateTime<Utc>,
627    /// Metadata.
628    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
629    pub metadata: Metadata,
630}
631
632fn default_session_namespace() -> String {
633    crate::LOCAL_SESSION_NAMESPACE.to_string()
634}
635
636const fn initial_session_revision() -> u64 {
637    1
638}
639
640const fn initial_run_revision() -> u64 {
641    1
642}
643
644impl starweaver_core::VersionedRecord for SessionRecord {
645    const SCHEMA: &'static str = "starweaver.session.session_record";
646    const ALLOW_BARE_V0: bool = true;
647}
648
649impl SessionRecord {
650    /// Build a session record with default state.
651    #[must_use]
652    pub fn new(session_id: SessionId) -> Self {
653        let now = Utc::now();
654        Self {
655            session_id,
656            namespace_id: default_session_namespace(),
657            owner_id: None,
658            revision: initial_session_revision(),
659            deletion_fence: SessionDeletionFence::Stable,
660            title: None,
661            workspace: None,
662            profile: None,
663            status: SessionStatus::Active,
664            state: ResumableState::default(),
665            environment_state: None,
666            stream_cursors: Vec::new(),
667            trace_context: TraceContext::default(),
668            parent_session_id: None,
669            head_run_id: None,
670            head_success_run_id: None,
671            active_run_id: None,
672            created_at: now,
673            updated_at: now,
674            metadata: Metadata::default(),
675        }
676    }
677}
678
679/// Durable run record.
680#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
681pub struct RunRecord {
682    /// Session id.
683    pub session_id: SessionId,
684    /// Run id.
685    pub run_id: RunId,
686    /// Monotonic optimistic-concurrency revision.
687    #[serde(default = "initial_run_revision")]
688    pub revision: u64,
689    /// Conversation id.
690    pub conversation_id: ConversationId,
691    /// User, API, or service input parts.
692    #[serde(default, skip_serializing_if = "Vec::is_empty")]
693    pub input: Vec<InputPart>,
694    /// Durable run status.
695    #[serde(default)]
696    pub status: RunStatus,
697    /// Final output preview.
698    #[serde(default, skip_serializing_if = "Option::is_none")]
699    pub output_preview: Option<String>,
700    /// Safe diagnostic for a failed or cancelled terminal run.
701    #[serde(default, skip_serializing_if = "Option::is_none")]
702    pub terminal_error: Option<RunTerminalError>,
703    /// Final structured output preview or summary.
704    #[serde(default, skip_serializing_if = "Value::is_null")]
705    pub structured_output: Value,
706    /// Latest checkpoint reference.
707    #[serde(default, skip_serializing_if = "Option::is_none")]
708    pub latest_checkpoint: Option<CheckpointRef>,
709    /// Latest environment state reference for this run.
710    #[serde(default, skip_serializing_if = "Option::is_none")]
711    pub environment_state: Option<EnvironmentStateRef>,
712    /// Latest stream cursor refs by family.
713    #[serde(default, skip_serializing_if = "Vec::is_empty")]
714    pub stream_cursors: Vec<StreamCursorRef>,
715    /// Trace context.
716    #[serde(default, skip_serializing_if = "TraceContext::is_empty")]
717    pub trace_context: TraceContext,
718    /// Monotonic order inside the session.
719    #[serde(default)]
720    pub sequence_no: usize,
721    /// Run snapshot used as continuation source.
722    #[serde(default, skip_serializing_if = "Option::is_none")]
723    pub restore_from_run_id: Option<RunId>,
724    /// Parent run identifier when this run is delegated from another run.
725    #[serde(default, skip_serializing_if = "Option::is_none")]
726    pub parent_run_id: Option<RunId>,
727    /// Parent-scoped delegated task identifier when this run executes a lightweight task.
728    #[serde(default, skip_serializing_if = "Option::is_none")]
729    pub parent_task_id: Option<TaskId>,
730    /// Trigger source such as cli, service, schedule, or delegated.
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    pub trigger_type: Option<String>,
733    /// Profile resolved for this run.
734    #[serde(default, skip_serializing_if = "Option::is_none")]
735    pub profile: Option<String>,
736    /// Creation time.
737    pub created_at: DateTime<Utc>,
738    /// Last update time.
739    pub updated_at: DateTime<Utc>,
740    /// Metadata.
741    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
742    pub metadata: Metadata,
743}
744
745impl starweaver_core::VersionedRecord for RunRecord {
746    const SCHEMA: &'static str = "starweaver.session.run_record";
747    const ALLOW_BARE_V0: bool = true;
748}
749
750impl RunRecord {
751    /// Return the complete terminal projection when this record is terminal.
752    ///
753    /// Historical failed records may legitimately return a projection with no diagnostic.
754    #[must_use]
755    pub fn terminal_projection(&self) -> Option<RunTerminalProjection> {
756        self.status.is_terminal().then(|| RunTerminalProjection {
757            status: self.status,
758            output_preview: self.output_preview.clone(),
759            error: self.terminal_error.clone(),
760        })
761    }
762
763    /// Validate a newly persisted run state.
764    ///
765    /// Historical records are accepted when read, but every new write must carry a complete
766    /// terminal projection and every non-terminal write must omit stale terminal diagnostics.
767    ///
768    /// # Errors
769    ///
770    /// Returns an error when status, output, and terminal diagnostic are inconsistent.
771    pub fn validate_new_write(&self) -> Result<(), RunTerminalProjectionError> {
772        self.terminal_projection().map_or_else(
773            || {
774                if self.terminal_error.is_some() {
775                    Err(RunTerminalProjectionError::UnexpectedNonTerminalDiagnostic(
776                        self.status,
777                    ))
778                } else {
779                    Ok(())
780                }
781            },
782            |terminal| terminal.validate(),
783        )
784    }
785
786    /// Normalize caller-provided state before a new admission is persisted.
787    ///
788    /// Admission always creates a queued execution. Any stale terminal projection from a reused
789    /// record is discarded rather than becoming active-run state or client-visible output.
790    pub fn normalize_for_admission(&mut self) {
791        self.status = RunStatus::Queued;
792        self.output_preview = None;
793        self.terminal_error = None;
794    }
795
796    /// Apply the legacy low-level status update contract.
797    ///
798    /// The legacy API cannot distinguish safe diagnostics from arbitrary caller text, so failed
799    /// and cancelled updates discard their preview and persist a fixed generic diagnostic.
800    pub fn apply_legacy_status_update(
801        &mut self,
802        status: RunStatus,
803        output_preview: Option<String>,
804    ) {
805        self.status = status;
806        match status {
807            RunStatus::Failed => {
808                self.output_preview = None;
809                self.terminal_error = Some(RunTerminalError::new(
810                    "legacy_status_update_failed",
811                    "run failed",
812                ));
813            }
814            RunStatus::Cancelled => {
815                self.output_preview = None;
816                self.terminal_error = Some(RunTerminalError::new(
817                    "legacy_status_update_cancelled",
818                    "run cancelled",
819                ));
820            }
821            _ => {
822                self.output_preview = output_preview;
823                self.terminal_error = None;
824            }
825        }
826    }
827
828    /// Build a run record for a session.
829    #[must_use]
830    pub fn new(session_id: SessionId, run_id: RunId, conversation_id: ConversationId) -> Self {
831        let now = Utc::now();
832        Self {
833            session_id,
834            run_id,
835            revision: initial_run_revision(),
836            conversation_id,
837            input: Vec::new(),
838            status: RunStatus::Queued,
839            output_preview: None,
840            terminal_error: None,
841            structured_output: Value::Null,
842            latest_checkpoint: None,
843            environment_state: None,
844            stream_cursors: Vec::new(),
845            trace_context: TraceContext::default(),
846            sequence_no: 0,
847            restore_from_run_id: None,
848            parent_run_id: None,
849            parent_task_id: None,
850            trigger_type: None,
851            profile: None,
852            created_at: now,
853            updated_at: now,
854            metadata: Metadata::default(),
855        }
856    }
857}