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 Orchestrator,
152}
153
154impl SessionRole {
155 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
190pub enum SessionStatus {
191 Draft,
193 InProgress,
195 Review,
197 AgentReview,
199 Question,
201 Queued,
203 Rebasing,
205 Merging,
207 Merged,
209 Done,
211 Canceled,
213}
214
215impl SessionStatus {
216 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 pub fn allows_chat_composer(self) -> bool {
233 self.allows_session_actions()
234 || matches!(self, SessionStatus::InProgress | SessionStatus::Rebasing)
235 }
236
237 pub fn allows_session_actions(self) -> bool {
239 self.allows_review_actions()
240 || matches!(self, SessionStatus::Draft | SessionStatus::Question)
241 }
242
243 pub fn allows_diff_view(self) -> bool {
245 self.allows_review_actions() || self.is_read_only()
246 }
247
248 pub fn allows_rebase_action(self) -> bool {
250 self.allows_review_actions() || self == SessionStatus::InProgress
251 }
252
253 pub fn allows_review_actions(self) -> bool {
255 matches!(self, SessionStatus::Review | SessionStatus::AgentReview)
256 }
257
258 pub fn allows_terminal_continuation(self) -> bool {
260 matches!(self, SessionStatus::Done | SessionStatus::Canceled)
261 }
262
263 pub fn is_read_only(self) -> bool {
265 matches!(self, SessionStatus::Merged)
266 }
267
268 pub fn allows_stacked_child_start(self) -> bool {
270 self.allows_review_actions()
271 }
272
273 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 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#[derive(Clone, Debug, Eq, PartialEq)]
377pub struct SessionSettings {
378 pub agent: AgentSelection,
380 pub base_branch: String,
382 pub is_draft: bool,
384 pub parent_session_id: Option<SessionId>,
386 pub personality_id: Option<String>,
388 pub project_id: i64,
390 pub reasoning_level: ReasoningLevel,
392 pub role: SessionRole,
394 pub speed_mode: SpeedMode,
396}
397
398#[derive(Clone, Debug, Eq, PartialEq)]
400pub struct ReviewRequest {
401 pub last_refreshed_at: i64,
403 pub summary: ReviewRequestSummary,
405}
406
407#[derive(Clone, Debug, Eq, PartialEq)]
409pub struct Session {
410 pub created_at: i64,
412 pub draft_prompt: Option<String>,
414 pub id: SessionId,
416 pub messages: Vec<SessionMessage>,
418 pub published_upstream_ref: Option<String>,
420 pub questions: Vec<QuestionItem>,
422 pub queued_messages: Vec<String>,
424 pub review_request: Option<ReviewRequest>,
426 pub settings: SessionSettings,
428 pub status: SessionStatus,
430 pub summary: Option<String>,
432 pub title: Option<String>,
434 pub updated_at: i64,
436}
437
438pub 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 let session_id = SessionId::from("session-1");
453
454 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_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 let source = Arc::<str>::from("session-1");
469 let expected = "session-1".to_string();
470
471 let session_id = SessionId::from(source);
473 let owned = String::from(session_id.clone());
474
475 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 let statuses = SessionStatus::ALL;
485
486 let round_tripped = statuses.map(|status| {
488 status
489 .to_string()
490 .parse::<SessionStatus>()
491 .expect("status should parse")
492 });
493
494 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 let roles = [SessionRole::Worker, SessionRole::Orchestrator];
509
510 let round_tripped = roles.map(|role| {
512 role.to_string()
513 .parse::<SessionRole>()
514 .expect("role should parse")
515 });
516
517 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 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 let status = SessionStatus::Merged;
534
535 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 let statuses = SessionStatus::ALL;
545
546 let continuation_statuses = statuses
548 .into_iter()
549 .filter(|status| status.allows_terminal_continuation())
550 .collect::<Vec<_>>();
551
552 assert_eq!(
554 continuation_statuses,
555 vec![SessionStatus::Done, SessionStatus::Canceled]
556 );
557 }
558
559 #[test]
560 fn activity_day_key_applies_offsets() {
561 assert_eq!(activity_day_key_with_offset(86_399, 1), 1);
563 assert_eq!(activity_day_key_with_offset(0, -1), -1);
564 }
565}