1use 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
19const SECONDS_PER_DAY: i64 = 86_400;
21
22#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
28pub struct SessionId(Arc<str>);
29
30impl SessionId {
31 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
139pub enum SessionStatus {
140 Draft,
142 InProgress,
144 Review,
146 AgentReview,
148 Question,
150 Queued,
152 Rebasing,
154 Merging,
156 Merged,
158 Done,
160 Canceled,
162}
163
164impl SessionStatus {
165 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 pub fn allows_chat_composer(self) -> bool {
182 self.allows_session_actions()
183 || matches!(self, SessionStatus::InProgress | SessionStatus::Rebasing)
184 }
185
186 pub fn allows_session_actions(self) -> bool {
188 self.allows_review_actions()
189 || matches!(self, SessionStatus::Draft | SessionStatus::Question)
190 }
191
192 pub fn allows_diff_view(self) -> bool {
194 self.allows_review_actions() || self.is_read_only()
195 }
196
197 pub fn allows_rebase_action(self) -> bool {
199 self.allows_review_actions() || self == SessionStatus::InProgress
200 }
201
202 pub fn allows_review_actions(self) -> bool {
204 matches!(self, SessionStatus::Review | SessionStatus::AgentReview)
205 }
206
207 pub fn allows_terminal_continuation(self) -> bool {
209 matches!(self, SessionStatus::Done)
210 }
211
212 pub fn is_read_only(self) -> bool {
214 matches!(self, SessionStatus::Merged)
215 }
216
217 pub fn allows_stacked_child_start(self) -> bool {
219 self.allows_review_actions()
220 }
221
222 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 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#[derive(Clone, Debug, Eq, PartialEq)]
326pub struct SessionSettings {
327 pub agent: AgentSelection,
329 pub base_branch: String,
331 pub is_draft: bool,
333 pub parent_session_id: Option<SessionId>,
335 pub personality_id: Option<String>,
337 pub project_id: i64,
339 pub reasoning_level: ReasoningLevel,
341}
342
343#[derive(Clone, Debug, Eq, PartialEq)]
345pub struct ReviewRequest {
346 pub last_refreshed_at: i64,
348 pub summary: ReviewRequestSummary,
350}
351
352#[derive(Clone, Debug, Eq, PartialEq)]
354pub struct Session {
355 pub created_at: i64,
357 pub draft_prompt: Option<String>,
359 pub id: SessionId,
361 pub messages: Vec<SessionMessage>,
363 pub published_upstream_ref: Option<String>,
365 pub questions: Vec<QuestionItem>,
367 pub queued_messages: Vec<String>,
369 pub review_request: Option<ReviewRequest>,
371 pub settings: SessionSettings,
373 pub status: SessionStatus,
375 pub summary: Option<String>,
377 pub title: Option<String>,
379 pub updated_at: i64,
381}
382
383pub 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 let session_id = SessionId::from("session-1");
398
399 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_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 let source = Arc::<str>::from("session-1");
414 let expected = "session-1".to_string();
415
416 let session_id = SessionId::from(source);
418 let owned = String::from(session_id.clone());
419
420 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 let statuses = SessionStatus::ALL;
430
431 let round_tripped = statuses.map(|status| {
433 status
434 .to_string()
435 .parse::<SessionStatus>()
436 .expect("status should parse")
437 });
438
439 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 let status = SessionStatus::Merged;
454
455 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 assert_eq!(activity_day_key_with_offset(86_399, 1), 1);
465 assert_eq!(activity_day_key_with_offset(0, -1), -1);
466 }
467}