1use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14
15use crate::intent::{AgentId, IntentId, SessionId};
16use crate::namespace::Namespace;
17use crate::object::ObjectId;
18
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
21pub enum SessionStatus {
22 Active,
24 Completed,
26 Abandoned,
28}
29
30impl SessionStatus {
31 pub fn as_str(&self) -> &'static str {
33 match self {
34 Self::Active => "Active",
35 Self::Completed => "Completed",
36 Self::Abandoned => "Abandoned",
37 }
38 }
39
40 pub fn from_wire(s: &str) -> Self {
43 match s {
44 "Active" => Self::Active,
45 "Completed" => Self::Completed,
46 _ => Self::Abandoned,
47 }
48 }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Session {
59 pub id: SessionId,
60 pub agent_id: AgentId,
61 pub working_branch: String,
62 pub head: ObjectId,
63 pub parent_session: Option<SessionId>,
65 pub delegated_intent: Option<IntentId>,
67 pub report_to: Option<String>,
69 pub path_scope: Option<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub scope_tenant: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub scope_namespace: Option<Namespace>,
85 #[serde(default = "default_status")]
87 pub status: SessionStatus,
88 pub created_at: DateTime<Utc>,
90 #[serde(default)]
92 pub ended_at: Option<DateTime<Utc>>,
93}
94
95fn default_status() -> SessionStatus {
96 SessionStatus::Active
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
106 fn as_str_active() {
107 assert_eq!(SessionStatus::Active.as_str(), "Active");
108 }
109
110 #[test]
111 fn as_str_completed() {
112 assert_eq!(SessionStatus::Completed.as_str(), "Completed");
113 }
114
115 #[test]
116 fn as_str_abandoned() {
117 assert_eq!(SessionStatus::Abandoned.as_str(), "Abandoned");
118 }
119
120 #[test]
123 fn from_wire_known_values() {
124 assert_eq!(SessionStatus::from_wire("Active"), SessionStatus::Active);
125 assert_eq!(
126 SessionStatus::from_wire("Completed"),
127 SessionStatus::Completed
128 );
129 assert_eq!(
130 SessionStatus::from_wire("Abandoned"),
131 SessionStatus::Abandoned
132 );
133 }
134
135 #[test]
136 fn from_wire_unknown_maps_to_abandoned() {
137 assert_eq!(
138 SessionStatus::from_wire("unknown-future-status"),
139 SessionStatus::Abandoned
140 );
141 assert_eq!(SessionStatus::from_wire(""), SessionStatus::Abandoned);
142 assert_eq!(
143 SessionStatus::from_wire("active"), SessionStatus::Abandoned
145 );
146 }
147
148 #[test]
151 fn as_str_from_wire_roundtrip() {
152 for s in [
153 SessionStatus::Active,
154 SessionStatus::Completed,
155 SessionStatus::Abandoned,
156 ] {
157 assert_eq!(SessionStatus::from_wire(s.as_str()), s);
158 }
159 }
160
161 #[test]
164 fn session_status_serializes_and_deserializes() {
165 for s in [
166 SessionStatus::Active,
167 SessionStatus::Completed,
168 SessionStatus::Abandoned,
169 ] {
170 let j = serde_json::to_string(&s).unwrap();
171 let back: SessionStatus = serde_json::from_str(&j).unwrap();
172 assert_eq!(back, s);
173 }
174 }
175
176 #[test]
179 fn session_optional_fields_skip_when_none() {
180 let s = Session {
181 id: "s1".into(),
182 agent_id: "agent/test".into(),
183 working_branch: "main".into(),
184 head: ObjectId::hash(b"head"),
185 parent_session: None,
186 delegated_intent: None,
187 report_to: None,
188 path_scope: None,
189 scope_tenant: None,
190 scope_namespace: None,
191 status: SessionStatus::Active,
192 created_at: Utc::now(),
193 ended_at: None,
194 };
195
196 let json = serde_json::to_string(&s).unwrap();
197 assert!(
198 !json.contains("scope_tenant"),
199 "scope_tenant should be omitted when None (has skip_serializing_if)"
200 );
201 assert!(
203 json.contains("ended_at"),
204 "ended_at is always present in JSON (serializes as null when None)"
205 );
206 }
207
208 #[test]
209 fn session_round_trip_with_all_fields() {
210 let s = Session {
211 id: "sess-42".into(),
212 agent_id: "agent/planner".into(),
213 working_branch: "feature/planning".into(),
214 head: ObjectId::hash(b"some-commit"),
215 parent_session: Some("sess-1".into()),
216 delegated_intent: Some("intent-7".into()),
217 report_to: Some("lead/coordinator".into()),
218 path_scope: Some("/cluster/nodes".into()),
219 scope_tenant: Some("tenant-A".into()),
220 scope_namespace: Some(Namespace::new("project-x").unwrap()),
221 status: SessionStatus::Completed,
222 created_at: Utc::now(),
223 ended_at: Some(Utc::now()),
224 };
225
226 let json = serde_json::to_string(&s).unwrap();
227 let restored: Session = serde_json::from_str(&json).unwrap();
228
229 assert_eq!(restored.id, s.id);
230 assert_eq!(restored.agent_id, s.agent_id);
231 assert_eq!(restored.working_branch, s.working_branch);
232 assert_eq!(restored.status, SessionStatus::Completed);
233 assert_eq!(restored.parent_session, s.parent_session);
234 assert_eq!(restored.scope_tenant.as_deref(), Some("tenant-A"));
235 assert_eq!(
236 restored.scope_namespace.as_ref().map(|n| n.as_str()),
237 Some("project-x")
238 );
239 assert!(restored.ended_at.is_some());
240 }
241}