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    /// Worker whose write capabilities are owned exclusively by an
151    /// orchestration coordinator.
152    OrchestrationWorker,
153    /// Controller session that plans and supervises child worker sessions.
154    Orchestrator,
155}
156
157impl SessionRole {
158    /// Returns whether this role produces commits on its own session branch.
159    ///
160    /// Orchestrators read the repository to plan work but delegate every edit
161    /// to child sessions, so their branch stays empty for the session's whole
162    /// lifetime.
163    pub fn owns_branch_changes(self) -> bool {
164        matches!(self, SessionRole::Worker | SessionRole::OrchestrationWorker)
165    }
166
167    /// Returns whether an end user may submit turns or branch mutations
168    /// directly to this session.
169    pub fn accepts_user_turns(self) -> bool {
170        !matches!(self, SessionRole::OrchestrationWorker)
171    }
172
173    /// Returns whether this worker is owned by an orchestration coordinator.
174    pub fn is_managed(self) -> bool {
175        matches!(self, SessionRole::OrchestrationWorker)
176    }
177}
178
179impl fmt::Display for SessionRole {
180    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
181        let value = match self {
182            SessionRole::Worker => "Worker",
183            SessionRole::OrchestrationWorker => "OrchestrationWorker",
184            SessionRole::Orchestrator => "Orchestrator",
185        };
186
187        formatter.write_str(value)
188    }
189}
190
191impl FromStr for SessionRole {
192    type Err = String;
193
194    fn from_str(value: &str) -> Result<Self, Self::Err> {
195        match value {
196            "Worker" => Ok(SessionRole::Worker),
197            "OrchestrationWorker" => Ok(SessionRole::OrchestrationWorker),
198            "Orchestrator" => Ok(SessionRole::Orchestrator),
199            _ => Err(format!("Unknown role: {value}")),
200        }
201    }
202}
203
204/// High-level lifecycle state for one session.
205#[derive(Clone, Copy, Debug, Eq, PartialEq)]
206pub enum SessionStatus {
207    /// Session has been created but has not started its first agent turn yet.
208    Draft,
209    /// An agent turn or its post-processing workflow is running.
210    InProgress,
211    /// The session is ready for user review and follow-up work.
212    Review,
213    /// The session is generating focused-review output.
214    AgentReview,
215    /// The session is waiting for model clarification responses.
216    Question,
217    /// The session is waiting in the merge queue.
218    Queued,
219    /// The session branch is being rebased.
220    Rebasing,
221    /// The session branch is being merged.
222    Merging,
223    /// A remote merge awaits local target-branch synchronization.
224    Merged,
225    /// The session completed successfully.
226    Done,
227    /// The session was canceled before completion.
228    Canceled,
229}
230
231impl SessionStatus {
232    /// Ordered list of all session statuses.
233    pub const ALL: [SessionStatus; 11] = [
234        SessionStatus::Draft,
235        SessionStatus::InProgress,
236        SessionStatus::Review,
237        SessionStatus::AgentReview,
238        SessionStatus::Question,
239        SessionStatus::Queued,
240        SessionStatus::Rebasing,
241        SessionStatus::Merging,
242        SessionStatus::Merged,
243        SessionStatus::Done,
244        SessionStatus::Canceled,
245    ];
246
247    /// Returns whether this status permits opening the chat composer.
248    pub fn allows_chat_composer(self) -> bool {
249        self.allows_session_actions()
250            || matches!(self, SessionStatus::InProgress | SessionStatus::Rebasing)
251    }
252
253    /// Returns whether this status permits idle session actions.
254    pub fn allows_session_actions(self) -> bool {
255        self.allows_review_actions()
256            || matches!(self, SessionStatus::Draft | SessionStatus::Question)
257    }
258
259    /// Returns whether this status permits opening the session diff.
260    pub fn allows_diff_view(self) -> bool {
261        self.allows_review_actions() || self.is_read_only()
262    }
263
264    /// Returns whether this status permits starting or queueing session sync.
265    pub fn allows_rebase_action(self) -> bool {
266        self.allows_review_actions() || self == SessionStatus::InProgress
267    }
268
269    /// Returns whether this status keeps review actions enabled.
270    pub fn allows_review_actions(self) -> bool {
271        matches!(self, SessionStatus::Review | SessionStatus::AgentReview)
272    }
273
274    /// Returns whether this status can seed a follow-on continuation session.
275    pub fn allows_terminal_continuation(self) -> bool {
276        matches!(self, SessionStatus::Done | SessionStatus::Canceled)
277    }
278
279    /// Returns whether this lifecycle state permits only local inspection.
280    pub fn is_read_only(self) -> bool {
281        matches!(self, SessionStatus::Merged)
282    }
283
284    /// Returns whether this status is stable enough to start a stacked child.
285    pub fn allows_stacked_child_start(self) -> bool {
286        self.allows_review_actions()
287    }
288
289    /// Returns whether this status represents branch-mutating stack work.
290    pub fn is_stack_branch_mutating(self) -> bool {
291        matches!(
292            self,
293            SessionStatus::InProgress
294                | SessionStatus::Question
295                | SessionStatus::Queued
296                | SessionStatus::Rebasing
297                | SessionStatus::Merging
298                | SessionStatus::Merged
299        )
300    }
301
302    /// Returns whether a transition to `next` is valid.
303    pub fn can_transition_to(self, next: SessionStatus) -> bool {
304        if self == next {
305            return true;
306        }
307        if self == SessionStatus::Merged {
308            return next == SessionStatus::Done;
309        }
310
311        matches!(
312            (self, next),
313            (
314                SessionStatus::Draft,
315                SessionStatus::InProgress | SessionStatus::Canceled
316            ) | (
317                SessionStatus::Draft | SessionStatus::InProgress,
318                SessionStatus::Rebasing
319            ) | (SessionStatus::InProgress, SessionStatus::Canceled)
320                | (SessionStatus::Review, SessionStatus::AgentReview)
321                | (SessionStatus::AgentReview, SessionStatus::Review)
322                | (
323                    SessionStatus::Review | SessionStatus::AgentReview,
324                    SessionStatus::Merged | SessionStatus::Done
325                )
326                | (
327                    SessionStatus::Review | SessionStatus::AgentReview | SessionStatus::Question,
328                    SessionStatus::InProgress
329                        | SessionStatus::Queued
330                        | SessionStatus::Rebasing
331                        | SessionStatus::Merging
332                        | SessionStatus::Canceled
333                )
334                | (
335                    SessionStatus::Queued,
336                    SessionStatus::Merging | SessionStatus::Review | SessionStatus::AgentReview
337                )
338                | (
339                    SessionStatus::InProgress | SessionStatus::Rebasing,
340                    SessionStatus::Review | SessionStatus::AgentReview | SessionStatus::Question
341                )
342                | (
343                    SessionStatus::Merging,
344                    SessionStatus::Done | SessionStatus::Review | SessionStatus::AgentReview
345                )
346        )
347    }
348}
349
350impl fmt::Display for SessionStatus {
351    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
352        let value = match self {
353            SessionStatus::Draft => "Draft",
354            SessionStatus::InProgress => "InProgress",
355            SessionStatus::Review => "Review",
356            SessionStatus::AgentReview => "AgentReview",
357            SessionStatus::Question => "Question",
358            SessionStatus::Queued => "Queued",
359            SessionStatus::Rebasing => "Rebasing",
360            SessionStatus::Merging => "Merging",
361            SessionStatus::Merged => "Merged",
362            SessionStatus::Done => "Done",
363            SessionStatus::Canceled => "Canceled",
364        };
365
366        formatter.write_str(value)
367    }
368}
369
370impl FromStr for SessionStatus {
371    type Err = String;
372
373    fn from_str(value: &str) -> Result<Self, Self::Err> {
374        match value {
375            "Draft" => Ok(SessionStatus::Draft),
376            "InProgress" | "Committing" => Ok(SessionStatus::InProgress),
377            "Review" => Ok(SessionStatus::Review),
378            "AgentReview" => Ok(SessionStatus::AgentReview),
379            "Question" => Ok(SessionStatus::Question),
380            "Queued" => Ok(SessionStatus::Queued),
381            "Rebasing" => Ok(SessionStatus::Rebasing),
382            "Merging" => Ok(SessionStatus::Merging),
383            "Merged" => Ok(SessionStatus::Merged),
384            "Done" => Ok(SessionStatus::Done),
385            "Canceled" => Ok(SessionStatus::Canceled),
386            _ => Err(format!("Unknown status: {value}")),
387        }
388    }
389}
390
391/// Complete session-scoped execution settings returned by the API.
392#[derive(Clone, Debug, Eq, PartialEq)]
393pub struct SessionSettings {
394    /// Agent provider and model selected for this session.
395    pub agent: AgentSelection,
396    /// Base branch used to create the session.
397    pub base_branch: String,
398    /// Whether the session uses deferred draft materialization.
399    pub is_draft: bool,
400    /// Parent session for a one-level stacked session.
401    pub parent_session_id: Option<SessionId>,
402    /// Workspace personality selected for future turns, when present.
403    pub personality_id: Option<String>,
404    /// Owning project identifier.
405    pub project_id: i64,
406    /// Session-scoped reasoning level.
407    pub reasoning_level: ReasoningLevel,
408    /// Role this session plays in a multi-session workflow.
409    pub role: SessionRole,
410    /// Session-scoped response-speed preference.
411    pub speed_mode: SpeedMode,
412}
413
414/// Persisted forge linkage for one session.
415#[derive(Clone, Debug, Eq, PartialEq)]
416pub struct ReviewRequest {
417    /// Unix timestamp of the most recent successful refresh.
418    pub last_refreshed_at: i64,
419    /// Normalized remote summary captured at `last_refreshed_at`.
420    pub summary: ReviewRequestSummary,
421}
422
423/// Complete persisted session aggregate returned by the programmatic API.
424#[derive(Clone, Debug, Eq, PartialEq)]
425pub struct Session {
426    /// Session creation timestamp in Unix seconds.
427    pub created_at: i64,
428    /// Staged draft prompt that has not entered the durable transcript.
429    pub draft_prompt: Option<String>,
430    /// Stable session identifier.
431    pub id: SessionId,
432    /// Ordered durable transcript messages.
433    pub messages: Vec<SessionMessage>,
434    /// Published upstream branch reference, when present.
435    pub published_upstream_ref: Option<String>,
436    /// Structured clarification questions waiting for answers.
437    pub questions: Vec<QuestionItem>,
438    /// Chat messages waiting behind the active turn or rebase.
439    pub queued_messages: Vec<String>,
440    /// Persisted review-request linkage, when present.
441    pub review_request: Option<ReviewRequest>,
442    /// Complete session-scoped execution settings.
443    pub settings: SessionSettings,
444    /// Current lifecycle status.
445    pub status: SessionStatus,
446    /// Latest persisted structured summary.
447    pub summary: Option<String>,
448    /// Optional user-visible session title.
449    pub title: Option<String>,
450    /// Last update timestamp in Unix seconds.
451    pub updated_at: i64,
452}
453
454/// Converts Unix timestamp seconds to a day key after applying a UTC offset.
455pub fn activity_day_key_with_offset(timestamp_seconds: i64, utc_offset_seconds: i64) -> i64 {
456    timestamp_seconds
457        .saturating_add(utc_offset_seconds)
458        .div_euclid(SECONDS_PER_DAY)
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn session_id_round_trips_json() {
467        // Arrange
468        let session_id = SessionId::from("session-1");
469
470        // Act
471        let serialized = serde_json::to_string(&session_id).expect("id should serialize");
472        let deserialized =
473            serde_json::from_str::<SessionId>(&serialized).expect("id should deserialize");
474
475        // Assert
476        assert_eq!(deserialized, session_id);
477        assert_eq!(deserialized.as_str(), "session-1");
478        assert_eq!(AsRef::<Path>::as_ref(&deserialized), Path::new("session-1"));
479    }
480
481    #[test]
482    fn session_id_supports_owned_conversions_and_comparisons() {
483        // Arrange
484        let source = Arc::<str>::from("session-1");
485        let expected = "session-1".to_string();
486
487        // Act
488        let session_id = SessionId::from(source);
489        let owned = String::from(session_id.clone());
490
491        // Assert
492        assert_eq!(session_id, expected);
493        assert_eq!(session_id, &expected);
494        assert_eq!(owned, expected);
495    }
496
497    #[test]
498    fn session_status_round_trips_persisted_values() {
499        // Arrange
500        let statuses = SessionStatus::ALL;
501
502        // Act
503        let round_tripped = statuses.map(|status| {
504            status
505                .to_string()
506                .parse::<SessionStatus>()
507                .expect("status should parse")
508        });
509
510        // Assert
511        assert_eq!(round_tripped, statuses);
512        assert_eq!(
513            "Committing"
514                .parse::<SessionStatus>()
515                .expect("legacy status should parse"),
516            SessionStatus::InProgress
517        );
518        assert!("Unknown".parse::<SessionStatus>().is_err());
519    }
520
521    #[test]
522    fn session_role_round_trips_persisted_values() {
523        // Arrange
524        let roles = [
525            SessionRole::Worker,
526            SessionRole::OrchestrationWorker,
527            SessionRole::Orchestrator,
528        ];
529
530        // Act
531        let round_tripped = roles.map(|role| {
532            role.to_string()
533                .parse::<SessionRole>()
534                .expect("role should parse")
535        });
536
537        // Assert
538        assert_eq!(round_tripped, roles);
539        assert_eq!(SessionRole::default(), SessionRole::Worker);
540        assert!("Unknown".parse::<SessionRole>().is_err());
541    }
542
543    #[test]
544    fn only_worker_sessions_own_branch_changes() {
545        // Arrange / Act / Assert
546        assert!(SessionRole::Worker.owns_branch_changes());
547        assert!(SessionRole::OrchestrationWorker.owns_branch_changes());
548        assert!(!SessionRole::Orchestrator.owns_branch_changes());
549        assert!(SessionRole::Worker.accepts_user_turns());
550        assert!(!SessionRole::OrchestrationWorker.accepts_user_turns());
551        assert!(SessionRole::OrchestrationWorker.is_managed());
552    }
553
554    #[test]
555    fn merged_status_only_transitions_to_done() {
556        // Arrange
557        let status = SessionStatus::Merged;
558
559        // Act / Assert
560        assert!(status.can_transition_to(SessionStatus::Merged));
561        assert!(status.can_transition_to(SessionStatus::Done));
562        assert!(!status.can_transition_to(SessionStatus::Review));
563    }
564
565    #[test]
566    fn terminal_continuation_is_available_for_done_and_canceled_sessions() {
567        // Arrange
568        let statuses = SessionStatus::ALL;
569
570        // Act
571        let continuation_statuses = statuses
572            .into_iter()
573            .filter(|status| status.allows_terminal_continuation())
574            .collect::<Vec<_>>();
575
576        // Assert
577        assert_eq!(
578            continuation_statuses,
579            vec![SessionStatus::Done, SessionStatus::Canceled]
580        );
581    }
582
583    #[test]
584    fn activity_day_key_applies_offsets() {
585        // Arrange / Act / Assert
586        assert_eq!(activity_day_key_with_offset(86_399, 1), 1);
587        assert_eq!(activity_day_key_with_offset(0, -1), -1);
588    }
589}