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 Orchestrator,
155}
156
157impl SessionRole {
158 pub fn owns_branch_changes(self) -> bool {
164 matches!(self, SessionRole::Worker | SessionRole::OrchestrationWorker)
165 }
166
167 pub fn accepts_user_turns(self) -> bool {
170 !matches!(self, SessionRole::OrchestrationWorker)
171 }
172
173 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
206pub enum SessionStatus {
207 Draft,
209 InProgress,
211 Review,
213 AgentReview,
215 Question,
217 Queued,
219 Rebasing,
221 Merging,
223 Merged,
225 Done,
227 Canceled,
229}
230
231impl SessionStatus {
232 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 pub fn allows_chat_composer(self) -> bool {
249 self.allows_session_actions()
250 || matches!(self, SessionStatus::InProgress | SessionStatus::Rebasing)
251 }
252
253 pub fn allows_session_actions(self) -> bool {
255 self.allows_review_actions()
256 || matches!(self, SessionStatus::Draft | SessionStatus::Question)
257 }
258
259 pub fn allows_diff_view(self) -> bool {
261 self.allows_review_actions() || self.is_read_only()
262 }
263
264 pub fn allows_rebase_action(self) -> bool {
266 self.allows_review_actions() || self == SessionStatus::InProgress
267 }
268
269 pub fn allows_review_actions(self) -> bool {
271 matches!(self, SessionStatus::Review | SessionStatus::AgentReview)
272 }
273
274 pub fn allows_terminal_continuation(self) -> bool {
276 matches!(self, SessionStatus::Done | SessionStatus::Canceled)
277 }
278
279 pub fn is_read_only(self) -> bool {
281 matches!(self, SessionStatus::Merged)
282 }
283
284 pub fn allows_stacked_child_start(self) -> bool {
286 self.allows_review_actions()
287 }
288
289 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 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#[derive(Clone, Debug, Eq, PartialEq)]
393pub struct SessionSettings {
394 pub agent: AgentSelection,
396 pub base_branch: String,
398 pub is_draft: bool,
400 pub parent_session_id: Option<SessionId>,
402 pub personality_id: Option<String>,
404 pub project_id: i64,
406 pub reasoning_level: ReasoningLevel,
408 pub role: SessionRole,
410 pub speed_mode: SpeedMode,
412}
413
414#[derive(Clone, Debug, Eq, PartialEq)]
416pub struct ReviewRequest {
417 pub last_refreshed_at: i64,
419 pub summary: ReviewRequestSummary,
421}
422
423#[derive(Clone, Debug, Eq, PartialEq)]
425pub struct Session {
426 pub created_at: i64,
428 pub draft_prompt: Option<String>,
430 pub id: SessionId,
432 pub messages: Vec<SessionMessage>,
434 pub published_upstream_ref: Option<String>,
436 pub questions: Vec<QuestionItem>,
438 pub queued_messages: Vec<String>,
440 pub review_request: Option<ReviewRequest>,
442 pub settings: SessionSettings,
444 pub status: SessionStatus,
446 pub summary: Option<String>,
448 pub title: Option<String>,
450 pub updated_at: i64,
452}
453
454pub 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 let session_id = SessionId::from("session-1");
469
470 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_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 let source = Arc::<str>::from("session-1");
485 let expected = "session-1".to_string();
486
487 let session_id = SessionId::from(source);
489 let owned = String::from(session_id.clone());
490
491 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 let statuses = SessionStatus::ALL;
501
502 let round_tripped = statuses.map(|status| {
504 status
505 .to_string()
506 .parse::<SessionStatus>()
507 .expect("status should parse")
508 });
509
510 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 let roles = [
525 SessionRole::Worker,
526 SessionRole::OrchestrationWorker,
527 SessionRole::Orchestrator,
528 ];
529
530 let round_tripped = roles.map(|role| {
532 role.to_string()
533 .parse::<SessionRole>()
534 .expect("role should parse")
535 });
536
537 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 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 let status = SessionStatus::Merged;
558
559 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 let statuses = SessionStatus::ALL;
569
570 let continuation_statuses = statuses
572 .into_iter()
573 .filter(|status| status.allows_terminal_continuation())
574 .collect::<Vec<_>>();
575
576 assert_eq!(
578 continuation_statuses,
579 vec![SessionStatus::Done, SessionStatus::Canceled]
580 );
581 }
582
583 #[test]
584 fn activity_day_key_applies_offsets() {
585 assert_eq!(activity_day_key_with_offset(86_399, 1), 1);
587 assert_eq!(activity_day_key_with_offset(0, -1), -1);
588 }
589}