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