1use 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
20const SECONDS_PER_DAY: i64 = 86_400;
22
23#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
29pub struct SessionId(Arc<str>);
30
31impl SessionId {
32 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
146pub enum SessionRole {
147 #[default]
149 Worker,
150 OrchestrationWorker,
153 OrchestrationResearcher,
156 Orchestrator,
158}
159
160impl SessionRole {
161 pub fn owns_branch_changes(self) -> bool {
167 matches!(self, SessionRole::Worker | SessionRole::OrchestrationWorker)
168 }
169
170 pub fn tracks_worktree_changes(self) -> bool {
175 !matches!(self, SessionRole::Orchestrator)
176 }
177
178 pub fn accepts_user_turns(self) -> bool {
181 !self.is_managed()
182 }
183
184 pub fn is_managed(self) -> bool {
186 matches!(
187 self,
188 SessionRole::OrchestrationWorker | SessionRole::OrchestrationResearcher
189 )
190 }
191}
192
193impl fmt::Display for SessionRole {
194 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
195 let value = match self {
196 SessionRole::Worker => "Worker",
197 SessionRole::OrchestrationWorker => "OrchestrationWorker",
198 SessionRole::OrchestrationResearcher => "OrchestrationResearcher",
199 SessionRole::Orchestrator => "Orchestrator",
200 };
201
202 formatter.write_str(value)
203 }
204}
205
206impl FromStr for SessionRole {
207 type Err = String;
208
209 fn from_str(value: &str) -> Result<Self, Self::Err> {
210 match value {
211 "Worker" => Ok(SessionRole::Worker),
212 "OrchestrationWorker" => Ok(SessionRole::OrchestrationWorker),
213 "OrchestrationResearcher" => Ok(SessionRole::OrchestrationResearcher),
214 "Orchestrator" => Ok(SessionRole::Orchestrator),
215 _ => Err(format!("Unknown role: {value}")),
216 }
217 }
218}
219
220#[derive(Clone, Copy, Debug, Eq, PartialEq)]
222pub enum SessionStatus {
223 Draft,
225 InProgress,
227 Review,
229 AgentReview,
231 Question,
233 Queued,
235 Rebasing,
237 Merging,
239 Merged,
241 Done,
243 Canceled,
245}
246
247impl SessionStatus {
248 pub const ALL: [SessionStatus; 11] = [
250 SessionStatus::Draft,
251 SessionStatus::InProgress,
252 SessionStatus::Review,
253 SessionStatus::AgentReview,
254 SessionStatus::Question,
255 SessionStatus::Queued,
256 SessionStatus::Rebasing,
257 SessionStatus::Merging,
258 SessionStatus::Merged,
259 SessionStatus::Done,
260 SessionStatus::Canceled,
261 ];
262
263 pub fn allows_chat_composer(self) -> bool {
265 self.allows_session_actions()
266 || matches!(self, SessionStatus::InProgress | SessionStatus::Rebasing)
267 }
268
269 pub fn allows_session_actions(self) -> bool {
271 self.allows_review_actions()
272 || matches!(self, SessionStatus::Draft | SessionStatus::Question)
273 }
274
275 pub fn allows_diff_view(self) -> bool {
277 self.allows_review_actions() || self.is_read_only()
278 }
279
280 pub fn allows_rebase_action(self) -> bool {
282 self.allows_review_actions() || self == SessionStatus::InProgress
283 }
284
285 pub fn allows_review_actions(self) -> bool {
287 matches!(self, SessionStatus::Review | SessionStatus::AgentReview)
288 }
289
290 pub fn allows_terminal_continuation(self) -> bool {
292 matches!(self, SessionStatus::Done | SessionStatus::Canceled)
293 }
294
295 pub fn is_read_only(self) -> bool {
297 matches!(self, SessionStatus::Merged)
298 }
299
300 pub fn allows_stacked_child_start(self) -> bool {
302 self.allows_review_actions()
303 }
304
305 pub fn is_stack_branch_mutating(self) -> bool {
307 matches!(
308 self,
309 SessionStatus::InProgress
310 | SessionStatus::Question
311 | SessionStatus::Queued
312 | SessionStatus::Rebasing
313 | SessionStatus::Merging
314 | SessionStatus::Merged
315 )
316 }
317
318 pub fn can_transition_to(self, next: SessionStatus) -> bool {
320 if self == next {
321 return true;
322 }
323 if self == SessionStatus::Merged {
324 return next == SessionStatus::Done;
325 }
326
327 matches!(
328 (self, next),
329 (
330 SessionStatus::Draft,
331 SessionStatus::InProgress | SessionStatus::Canceled
332 ) | (
333 SessionStatus::Draft | SessionStatus::InProgress,
334 SessionStatus::Rebasing
335 ) | (SessionStatus::InProgress, SessionStatus::Canceled)
336 | (SessionStatus::Review, SessionStatus::AgentReview)
337 | (SessionStatus::AgentReview, SessionStatus::Review)
338 | (
339 SessionStatus::Review | SessionStatus::AgentReview,
340 SessionStatus::Merged | SessionStatus::Done
341 )
342 | (
343 SessionStatus::Review | SessionStatus::AgentReview | SessionStatus::Question,
344 SessionStatus::InProgress
345 | SessionStatus::Queued
346 | SessionStatus::Rebasing
347 | SessionStatus::Merging
348 | SessionStatus::Canceled
349 )
350 | (
351 SessionStatus::Queued,
352 SessionStatus::Merging | SessionStatus::Review | SessionStatus::AgentReview
353 )
354 | (
355 SessionStatus::InProgress | SessionStatus::Rebasing,
356 SessionStatus::Review | SessionStatus::AgentReview | SessionStatus::Question
357 )
358 | (
359 SessionStatus::Merging,
360 SessionStatus::Done | SessionStatus::Review | SessionStatus::AgentReview
361 )
362 )
363 }
364}
365
366impl fmt::Display for SessionStatus {
367 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
368 let value = match self {
369 SessionStatus::Draft => "Draft",
370 SessionStatus::InProgress => "InProgress",
371 SessionStatus::Review => "Review",
372 SessionStatus::AgentReview => "AgentReview",
373 SessionStatus::Question => "Question",
374 SessionStatus::Queued => "Queued",
375 SessionStatus::Rebasing => "Rebasing",
376 SessionStatus::Merging => "Merging",
377 SessionStatus::Merged => "Merged",
378 SessionStatus::Done => "Done",
379 SessionStatus::Canceled => "Canceled",
380 };
381
382 formatter.write_str(value)
383 }
384}
385
386impl FromStr for SessionStatus {
387 type Err = String;
388
389 fn from_str(value: &str) -> Result<Self, Self::Err> {
390 match value {
391 "Draft" => Ok(SessionStatus::Draft),
392 "InProgress" | "Committing" => Ok(SessionStatus::InProgress),
393 "Review" => Ok(SessionStatus::Review),
394 "AgentReview" => Ok(SessionStatus::AgentReview),
395 "Question" => Ok(SessionStatus::Question),
396 "Queued" => Ok(SessionStatus::Queued),
397 "Rebasing" => Ok(SessionStatus::Rebasing),
398 "Merging" => Ok(SessionStatus::Merging),
399 "Merged" => Ok(SessionStatus::Merged),
400 "Done" => Ok(SessionStatus::Done),
401 "Canceled" => Ok(SessionStatus::Canceled),
402 _ => Err(format!("Unknown status: {value}")),
403 }
404 }
405}
406
407#[derive(Clone, Debug, Eq, PartialEq)]
409pub struct SessionSettings {
410 pub agent: AgentSelection,
412 pub base_branch: String,
414 pub is_draft: bool,
416 pub parent_session_id: Option<SessionId>,
418 pub personality_id: Option<String>,
420 pub project_id: i64,
422 pub reasoning_level: ReasoningLevel,
424 pub role: SessionRole,
426 pub speed_mode: SpeedMode,
428}
429
430#[derive(Clone, Debug, Eq, PartialEq)]
432pub struct ReviewRequest {
433 pub last_refreshed_at: i64,
435 pub summary: ReviewRequestSummary,
437}
438
439#[derive(Clone, Debug, Eq, PartialEq)]
441pub struct Session {
442 pub created_at: i64,
444 pub draft_prompt: Option<String>,
446 pub id: SessionId,
448 pub messages: Vec<SessionMessage>,
450 pub published_upstream_ref: Option<String>,
452 pub questions: Vec<QuestionItem>,
454 pub queued_messages: Vec<String>,
456 pub review_request: Option<ReviewRequest>,
458 pub settings: SessionSettings,
460 pub status: SessionStatus,
462 pub summary: Option<String>,
464 pub title: Option<String>,
466 pub updated_at: i64,
468}
469
470pub fn activity_day_key_with_offset(timestamp_seconds: i64, utc_offset_seconds: i64) -> i64 {
472 timestamp_seconds
473 .saturating_add(utc_offset_seconds)
474 .div_euclid(SECONDS_PER_DAY)
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480
481 #[test]
482 fn session_id_round_trips_json() {
483 let session_id = SessionId::from("session-1");
485
486 let serialized = serde_json::to_string(&session_id).expect("id should serialize");
488 let deserialized =
489 serde_json::from_str::<SessionId>(&serialized).expect("id should deserialize");
490
491 assert_eq!(deserialized, session_id);
493 assert_eq!(deserialized.as_str(), "session-1");
494 assert_eq!(AsRef::<Path>::as_ref(&deserialized), Path::new("session-1"));
495 }
496
497 #[test]
498 fn session_id_supports_owned_conversions_and_comparisons() {
499 let source = Arc::<str>::from("session-1");
501 let expected = "session-1".to_string();
502
503 let session_id = SessionId::from(source);
505 let owned = String::from(session_id.clone());
506
507 assert_eq!(session_id, expected);
509 assert_eq!(session_id, &expected);
510 assert_eq!(owned, expected);
511 }
512
513 #[test]
514 fn session_status_round_trips_persisted_values() {
515 let statuses = SessionStatus::ALL;
517
518 let round_tripped = statuses.map(|status| {
520 status
521 .to_string()
522 .parse::<SessionStatus>()
523 .expect("status should parse")
524 });
525
526 assert_eq!(round_tripped, statuses);
528 assert_eq!(
529 "Committing"
530 .parse::<SessionStatus>()
531 .expect("legacy status should parse"),
532 SessionStatus::InProgress
533 );
534 assert!("Unknown".parse::<SessionStatus>().is_err());
535 }
536
537 #[test]
538 fn session_role_round_trips_persisted_values() {
539 let roles = [
541 SessionRole::Worker,
542 SessionRole::OrchestrationWorker,
543 SessionRole::OrchestrationResearcher,
544 SessionRole::Orchestrator,
545 ];
546
547 let round_tripped = roles.map(|role| {
549 role.to_string()
550 .parse::<SessionRole>()
551 .expect("role should parse")
552 });
553
554 assert_eq!(round_tripped, roles);
556 assert_eq!(SessionRole::default(), SessionRole::Worker);
557 assert!("Unknown".parse::<SessionRole>().is_err());
558 }
559
560 #[test]
561 fn branch_ownership_and_tracking_follow_session_role() {
562 assert!(SessionRole::Worker.owns_branch_changes());
564 assert!(SessionRole::OrchestrationWorker.owns_branch_changes());
565 assert!(!SessionRole::OrchestrationResearcher.owns_branch_changes());
566 assert!(!SessionRole::Orchestrator.owns_branch_changes());
567 assert!(SessionRole::Worker.tracks_worktree_changes());
568 assert!(SessionRole::OrchestrationResearcher.tracks_worktree_changes());
569 assert!(!SessionRole::Orchestrator.tracks_worktree_changes());
570 assert!(SessionRole::Worker.accepts_user_turns());
571 assert!(!SessionRole::OrchestrationWorker.accepts_user_turns());
572 assert!(!SessionRole::OrchestrationResearcher.accepts_user_turns());
573 assert!(SessionRole::OrchestrationWorker.is_managed());
574 assert!(SessionRole::OrchestrationResearcher.is_managed());
575 }
576
577 #[test]
578 fn merged_status_only_transitions_to_done() {
579 let status = SessionStatus::Merged;
581
582 assert!(status.can_transition_to(SessionStatus::Merged));
584 assert!(status.can_transition_to(SessionStatus::Done));
585 assert!(!status.can_transition_to(SessionStatus::Review));
586 }
587
588 #[test]
589 fn terminal_continuation_is_available_for_done_and_canceled_sessions() {
590 let statuses = SessionStatus::ALL;
592
593 let continuation_statuses = statuses
595 .into_iter()
596 .filter(|status| status.allows_terminal_continuation())
597 .collect::<Vec<_>>();
598
599 assert_eq!(
601 continuation_statuses,
602 vec![SessionStatus::Done, SessionStatus::Canceled]
603 );
604 }
605
606 #[test]
607 fn activity_day_key_applies_offsets() {
608 assert_eq!(activity_day_key_with_offset(86_399, 1), 1);
610 assert_eq!(activity_day_key_with_offset(0, -1), -1);
611 }
612}