Skip to main content

ag_session/
model.rs

1//! Shared session identity, lifecycle, settings, and aggregate models.
2
3use std::borrow::Borrow;
4use std::fmt;
5use std::ops::Deref;
6use std::path::Path;
7use std::str::FromStr;
8use std::sync::Arc;
9
10use ag_agent::{AgentSelection, ReasoningLevel};
11pub use ag_forge::{ForgeKind, ReviewRequestState, ReviewRequestSummary};
12use ag_protocol::QuestionItem;
13use serde::de::{self, Deserializer};
14use serde::ser::Serializer;
15use serde::{Deserialize, Serialize};
16
17use crate::SessionMessage;
18
19/// Number of seconds in one activity-calendar day.
20const SECONDS_PER_DAY: i64 = 86_400;
21
22/// Shared stable identifier for one session.
23///
24/// Session identifiers are cloned heavily across maps, events, and worker
25/// tasks. Wrapping the identifier in `Arc<str>` keeps those clones cheap while
26/// retaining borrowed `&str` lookup support.
27#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
28pub struct SessionId(Arc<str>);
29
30impl SessionId {
31    /// Returns the session identifier as a string slice.
32    pub fn as_str(&self) -> &str {
33        self.0.as_ref()
34    }
35}
36
37impl AsRef<str> for SessionId {
38    fn as_ref(&self) -> &str {
39        self.as_str()
40    }
41}
42
43impl AsRef<Path> for SessionId {
44    fn as_ref(&self) -> &Path {
45        Path::new(self.as_str())
46    }
47}
48
49impl Borrow<str> for SessionId {
50    fn borrow(&self) -> &str {
51        self.as_str()
52    }
53}
54
55impl Deref for SessionId {
56    type Target = str;
57
58    fn deref(&self) -> &Self::Target {
59        self.as_str()
60    }
61}
62
63impl fmt::Display for SessionId {
64    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65        formatter.write_str(self.as_str())
66    }
67}
68
69impl From<&str> for SessionId {
70    fn from(value: &str) -> Self {
71        Self(Arc::<str>::from(value))
72    }
73}
74
75impl From<String> for SessionId {
76    fn from(value: String) -> Self {
77        Self(Arc::<str>::from(value))
78    }
79}
80
81impl From<Arc<str>> for SessionId {
82    fn from(value: Arc<str>) -> Self {
83        Self(value)
84    }
85}
86
87impl From<SessionId> for String {
88    fn from(value: SessionId) -> Self {
89        value.as_str().to_string()
90    }
91}
92
93impl PartialEq<str> for SessionId {
94    fn eq(&self, other: &str) -> bool {
95        self.as_str() == other
96    }
97}
98
99impl PartialEq<&str> for SessionId {
100    fn eq(&self, other: &&str) -> bool {
101        self.as_str() == *other
102    }
103}
104
105impl PartialEq<String> for SessionId {
106    fn eq(&self, other: &String) -> bool {
107        self.as_str() == other
108    }
109}
110
111impl PartialEq<&String> for SessionId {
112    fn eq(&self, other: &&String) -> bool {
113        self.as_str() == other.as_str()
114    }
115}
116
117impl Serialize for SessionId {
118    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
119    where
120        S: Serializer,
121    {
122        serializer.serialize_str(self.as_str())
123    }
124}
125
126impl<'de> Deserialize<'de> for SessionId {
127    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
128    where
129        D: Deserializer<'de>,
130    {
131        String::deserialize(deserializer)
132            .map(Self::from)
133            .map_err(de::Error::custom)
134    }
135}
136
137/// High-level lifecycle state for one session.
138#[derive(Clone, Copy, Debug, Eq, PartialEq)]
139pub enum SessionStatus {
140    /// Session has been created but has not started its first agent turn yet.
141    Draft,
142    /// An agent turn or its post-processing workflow is running.
143    InProgress,
144    /// The session is ready for user review and follow-up work.
145    Review,
146    /// The session is generating focused-review output.
147    AgentReview,
148    /// The session is waiting for model clarification responses.
149    Question,
150    /// The session is waiting in the merge queue.
151    Queued,
152    /// The session branch is being rebased.
153    Rebasing,
154    /// The session branch is being merged.
155    Merging,
156    /// A remote merge awaits local target-branch synchronization.
157    Merged,
158    /// The session completed successfully.
159    Done,
160    /// The session was canceled before completion.
161    Canceled,
162}
163
164impl SessionStatus {
165    /// Ordered list of all session statuses.
166    pub const ALL: [SessionStatus; 11] = [
167        SessionStatus::Draft,
168        SessionStatus::InProgress,
169        SessionStatus::Review,
170        SessionStatus::AgentReview,
171        SessionStatus::Question,
172        SessionStatus::Queued,
173        SessionStatus::Rebasing,
174        SessionStatus::Merging,
175        SessionStatus::Merged,
176        SessionStatus::Done,
177        SessionStatus::Canceled,
178    ];
179
180    /// Returns whether this status permits opening the chat composer.
181    pub fn allows_chat_composer(self) -> bool {
182        self.allows_session_actions()
183            || matches!(self, SessionStatus::InProgress | SessionStatus::Rebasing)
184    }
185
186    /// Returns whether this status permits idle session actions.
187    pub fn allows_session_actions(self) -> bool {
188        self.allows_review_actions()
189            || matches!(self, SessionStatus::Draft | SessionStatus::Question)
190    }
191
192    /// Returns whether this status permits opening the session diff.
193    pub fn allows_diff_view(self) -> bool {
194        self.allows_review_actions() || self.is_read_only()
195    }
196
197    /// Returns whether this status permits starting or queueing session sync.
198    pub fn allows_rebase_action(self) -> bool {
199        self.allows_review_actions() || self == SessionStatus::InProgress
200    }
201
202    /// Returns whether this status keeps review actions enabled.
203    pub fn allows_review_actions(self) -> bool {
204        matches!(self, SessionStatus::Review | SessionStatus::AgentReview)
205    }
206
207    /// Returns whether this status can seed a follow-on continuation session.
208    pub fn allows_terminal_continuation(self) -> bool {
209        matches!(self, SessionStatus::Done)
210    }
211
212    /// Returns whether this lifecycle state permits only local inspection.
213    pub fn is_read_only(self) -> bool {
214        matches!(self, SessionStatus::Merged)
215    }
216
217    /// Returns whether this status is stable enough to start a stacked child.
218    pub fn allows_stacked_child_start(self) -> bool {
219        self.allows_review_actions()
220    }
221
222    /// Returns whether this status represents branch-mutating stack work.
223    pub fn is_stack_branch_mutating(self) -> bool {
224        matches!(
225            self,
226            SessionStatus::InProgress
227                | SessionStatus::Question
228                | SessionStatus::Queued
229                | SessionStatus::Rebasing
230                | SessionStatus::Merging
231                | SessionStatus::Merged
232        )
233    }
234
235    /// Returns whether a transition to `next` is valid.
236    pub fn can_transition_to(self, next: SessionStatus) -> bool {
237        if self == next {
238            return true;
239        }
240        if self == SessionStatus::Merged {
241            return next == SessionStatus::Done;
242        }
243
244        matches!(
245            (self, next),
246            (
247                SessionStatus::Draft,
248                SessionStatus::InProgress | SessionStatus::Canceled
249            ) | (
250                SessionStatus::Draft | SessionStatus::InProgress,
251                SessionStatus::Rebasing
252            ) | (SessionStatus::InProgress, SessionStatus::Canceled)
253                | (SessionStatus::Review, SessionStatus::AgentReview)
254                | (SessionStatus::AgentReview, SessionStatus::Review)
255                | (
256                    SessionStatus::Review | SessionStatus::AgentReview,
257                    SessionStatus::Merged | SessionStatus::Done
258                )
259                | (
260                    SessionStatus::Review | SessionStatus::AgentReview | SessionStatus::Question,
261                    SessionStatus::InProgress
262                        | SessionStatus::Queued
263                        | SessionStatus::Rebasing
264                        | SessionStatus::Merging
265                        | SessionStatus::Canceled
266                )
267                | (
268                    SessionStatus::Queued,
269                    SessionStatus::Merging | SessionStatus::Review | SessionStatus::AgentReview
270                )
271                | (
272                    SessionStatus::InProgress | SessionStatus::Rebasing,
273                    SessionStatus::Review | SessionStatus::AgentReview | SessionStatus::Question
274                )
275                | (
276                    SessionStatus::Merging,
277                    SessionStatus::Done | SessionStatus::Review | SessionStatus::AgentReview
278                )
279        )
280    }
281}
282
283impl fmt::Display for SessionStatus {
284    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
285        let value = match self {
286            SessionStatus::Draft => "Draft",
287            SessionStatus::InProgress => "InProgress",
288            SessionStatus::Review => "Review",
289            SessionStatus::AgentReview => "AgentReview",
290            SessionStatus::Question => "Question",
291            SessionStatus::Queued => "Queued",
292            SessionStatus::Rebasing => "Rebasing",
293            SessionStatus::Merging => "Merging",
294            SessionStatus::Merged => "Merged",
295            SessionStatus::Done => "Done",
296            SessionStatus::Canceled => "Canceled",
297        };
298
299        formatter.write_str(value)
300    }
301}
302
303impl FromStr for SessionStatus {
304    type Err = String;
305
306    fn from_str(value: &str) -> Result<Self, Self::Err> {
307        match value {
308            "Draft" => Ok(SessionStatus::Draft),
309            "InProgress" | "Committing" => Ok(SessionStatus::InProgress),
310            "Review" => Ok(SessionStatus::Review),
311            "AgentReview" => Ok(SessionStatus::AgentReview),
312            "Question" => Ok(SessionStatus::Question),
313            "Queued" => Ok(SessionStatus::Queued),
314            "Rebasing" => Ok(SessionStatus::Rebasing),
315            "Merging" => Ok(SessionStatus::Merging),
316            "Merged" => Ok(SessionStatus::Merged),
317            "Done" => Ok(SessionStatus::Done),
318            "Canceled" => Ok(SessionStatus::Canceled),
319            _ => Err(format!("Unknown status: {value}")),
320        }
321    }
322}
323
324/// Complete session-scoped execution settings returned by the API.
325#[derive(Clone, Debug, Eq, PartialEq)]
326pub struct SessionSettings {
327    /// Agent provider and model selected for this session.
328    pub agent: AgentSelection,
329    /// Base branch used to create the session.
330    pub base_branch: String,
331    /// Whether the session uses deferred draft materialization.
332    pub is_draft: bool,
333    /// Parent session for a one-level stacked session.
334    pub parent_session_id: Option<SessionId>,
335    /// Workspace personality selected for future turns, when present.
336    pub personality_id: Option<String>,
337    /// Owning project identifier.
338    pub project_id: i64,
339    /// Session-scoped reasoning level.
340    pub reasoning_level: ReasoningLevel,
341}
342
343/// Persisted forge linkage for one session.
344#[derive(Clone, Debug, Eq, PartialEq)]
345pub struct ReviewRequest {
346    /// Unix timestamp of the most recent successful refresh.
347    pub last_refreshed_at: i64,
348    /// Normalized remote summary captured at `last_refreshed_at`.
349    pub summary: ReviewRequestSummary,
350}
351
352/// Complete persisted session aggregate returned by the programmatic API.
353#[derive(Clone, Debug, Eq, PartialEq)]
354pub struct Session {
355    /// Session creation timestamp in Unix seconds.
356    pub created_at: i64,
357    /// Staged draft prompt that has not entered the durable transcript.
358    pub draft_prompt: Option<String>,
359    /// Stable session identifier.
360    pub id: SessionId,
361    /// Ordered durable transcript messages.
362    pub messages: Vec<SessionMessage>,
363    /// Published upstream branch reference, when present.
364    pub published_upstream_ref: Option<String>,
365    /// Structured clarification questions waiting for answers.
366    pub questions: Vec<QuestionItem>,
367    /// Chat messages waiting behind the active turn or rebase.
368    pub queued_messages: Vec<String>,
369    /// Persisted review-request linkage, when present.
370    pub review_request: Option<ReviewRequest>,
371    /// Complete session-scoped execution settings.
372    pub settings: SessionSettings,
373    /// Current lifecycle status.
374    pub status: SessionStatus,
375    /// Latest persisted structured summary.
376    pub summary: Option<String>,
377    /// Optional user-visible session title.
378    pub title: Option<String>,
379    /// Last update timestamp in Unix seconds.
380    pub updated_at: i64,
381}
382
383/// Converts Unix timestamp seconds to a day key after applying a UTC offset.
384pub fn activity_day_key_with_offset(timestamp_seconds: i64, utc_offset_seconds: i64) -> i64 {
385    timestamp_seconds
386        .saturating_add(utc_offset_seconds)
387        .div_euclid(SECONDS_PER_DAY)
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn session_id_round_trips_json() {
396        // Arrange
397        let session_id = SessionId::from("session-1");
398
399        // Act
400        let serialized = serde_json::to_string(&session_id).expect("id should serialize");
401        let deserialized =
402            serde_json::from_str::<SessionId>(&serialized).expect("id should deserialize");
403
404        // Assert
405        assert_eq!(deserialized, session_id);
406        assert_eq!(deserialized.as_str(), "session-1");
407        assert_eq!(AsRef::<Path>::as_ref(&deserialized), Path::new("session-1"));
408    }
409
410    #[test]
411    fn session_id_supports_owned_conversions_and_comparisons() {
412        // Arrange
413        let source = Arc::<str>::from("session-1");
414        let expected = "session-1".to_string();
415
416        // Act
417        let session_id = SessionId::from(source);
418        let owned = String::from(session_id.clone());
419
420        // Assert
421        assert_eq!(session_id, expected);
422        assert_eq!(session_id, &expected);
423        assert_eq!(owned, expected);
424    }
425
426    #[test]
427    fn session_status_round_trips_persisted_values() {
428        // Arrange
429        let statuses = SessionStatus::ALL;
430
431        // Act
432        let round_tripped = statuses.map(|status| {
433            status
434                .to_string()
435                .parse::<SessionStatus>()
436                .expect("status should parse")
437        });
438
439        // Assert
440        assert_eq!(round_tripped, statuses);
441        assert_eq!(
442            "Committing"
443                .parse::<SessionStatus>()
444                .expect("legacy status should parse"),
445            SessionStatus::InProgress
446        );
447        assert!("Unknown".parse::<SessionStatus>().is_err());
448    }
449
450    #[test]
451    fn merged_status_only_transitions_to_done() {
452        // Arrange
453        let status = SessionStatus::Merged;
454
455        // Act / Assert
456        assert!(status.can_transition_to(SessionStatus::Merged));
457        assert!(status.can_transition_to(SessionStatus::Done));
458        assert!(!status.can_transition_to(SessionStatus::Review));
459    }
460
461    #[test]
462    fn activity_day_key_applies_offsets() {
463        // Arrange / Act / Assert
464        assert_eq!(activity_day_key_with_offset(86_399, 1), 1);
465        assert_eq!(activity_day_key_with_offset(0, -1), -1);
466    }
467}