1use chrono::{DateTime, Duration, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use thiserror::Error;
11
12pub type SessionKey = String;
14
15pub fn make_session_key(session_id: &str, tty: &str) -> SessionKey {
17 format!("{}:{}", session_id, tty)
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
22#[serde(rename_all = "snake_case")]
23pub enum HookSessionStatus {
24 Running,
26 WaitingInput,
28 Stopped,
30 #[default]
32 Unknown,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct HookSession {
38 pub session_id: String,
40 pub cwd: String,
42 pub tty: String,
44 pub status: HookSessionStatus,
46 pub created_at: DateTime<Utc>,
48 pub updated_at: DateTime<Utc>,
50 pub last_event: String,
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct LiveSessionFile {
57 pub version: u8,
59 pub sessions: HashMap<SessionKey, HookSession>,
61 pub updated_at: Option<DateTime<Utc>>,
63}
64
65#[derive(Debug, Error)]
67pub enum HookStateError {
68 #[error("IO error: {0}")]
69 Io(#[from] std::io::Error),
70 #[error("JSON parse error: {0}")]
71 Json(#[from] serde_json::Error),
72 #[error("No home directory found")]
73 NoHome,
74}
75
76impl LiveSessionFile {
77 pub fn default_path() -> Option<PathBuf> {
79 dirs::home_dir().map(|h| h.join(".ccboard").join("live-sessions.json"))
80 }
81
82 pub fn lock_path() -> Option<PathBuf> {
84 dirs::home_dir().map(|h| h.join(".ccboard").join("live-sessions.lock"))
85 }
86
87 pub fn load(path: &Path) -> Result<Self, HookStateError> {
89 if !path.exists() {
90 return Ok(Self {
91 version: 1,
92 ..Default::default()
93 });
94 }
95 let data = std::fs::read(path)?;
96 let mut file: Self = serde_json::from_slice(&data)?;
97 if file.version == 0 {
99 file.version = 1;
100 }
101 Ok(file)
102 }
103
104 pub fn save(&self, path: &Path) -> Result<(), HookStateError> {
106 let tmp_path = path.with_extension("tmp");
107 let data = serde_json::to_vec_pretty(self)?;
108 std::fs::write(&tmp_path, &data)?;
109 std::fs::rename(&tmp_path, path)?;
110 Ok(())
111 }
112
113 pub fn prune_stopped(&mut self, max_age: std::time::Duration) {
115 let cutoff =
116 Utc::now() - Duration::from_std(max_age).unwrap_or_else(|_| Duration::minutes(30));
117 self.sessions
118 .retain(|_, s| s.status != HookSessionStatus::Stopped || s.updated_at >= cutoff);
119 }
120
121 pub fn prune_stale_running(&mut self, max_age: std::time::Duration) {
126 let cutoff =
127 Utc::now() - Duration::from_std(max_age).unwrap_or_else(|_| Duration::minutes(10));
128 self.sessions.retain(|_, s| {
129 s.status == HookSessionStatus::Stopped || s.updated_at >= cutoff
131 });
132 }
133
134 pub fn upsert(
137 &mut self,
138 key: SessionKey,
139 session_id: String,
140 cwd: String,
141 tty: String,
142 new_status: HookSessionStatus,
143 event_name: String,
144 ) {
145 let now = Utc::now();
146
147 let effective_status = if new_status == HookSessionStatus::Running
149 && self
150 .sessions
151 .get(&key)
152 .map(|s| s.status == HookSessionStatus::Stopped)
153 .unwrap_or(false)
154 {
155 HookSessionStatus::Running
156 } else {
157 new_status
158 };
159
160 if let Some(existing) = self.sessions.get_mut(&key) {
161 existing.status = effective_status;
162 existing.updated_at = now;
163 existing.last_event = event_name;
164 } else {
165 self.sessions.insert(
166 key,
167 HookSession {
168 session_id,
169 cwd,
170 tty,
171 status: effective_status,
172 created_at: now,
173 updated_at: now,
174 last_event: event_name,
175 },
176 );
177 }
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use std::time::Duration;
185
186 #[test]
187 fn test_prune_stopped_removes_old() {
188 let mut file = LiveSessionFile {
189 version: 1,
190 ..Default::default()
191 };
192
193 let old_time = Utc::now() - Duration::from_secs(31 * 60); file.sessions.insert(
195 "s1:tty1".to_string(),
196 HookSession {
197 session_id: "s1".to_string(),
198 cwd: "/tmp".to_string(),
199 tty: "tty1".to_string(),
200 status: HookSessionStatus::Stopped,
201 created_at: old_time,
202 updated_at: old_time,
203 last_event: "Stop".to_string(),
204 },
205 );
206
207 file.sessions.insert(
209 "s2:tty2".to_string(),
210 HookSession {
211 session_id: "s2".to_string(),
212 cwd: "/tmp".to_string(),
213 tty: "tty2".to_string(),
214 status: HookSessionStatus::Running,
215 created_at: old_time,
216 updated_at: old_time,
217 last_event: "PreToolUse".to_string(),
218 },
219 );
220
221 file.prune_stopped(Duration::from_secs(30 * 60));
222
223 assert!(!file.sessions.contains_key("s1:tty1"));
224 assert!(file.sessions.contains_key("s2:tty2"));
225 }
226
227 #[test]
228 fn test_upsert_revives_stopped() {
229 let mut file = LiveSessionFile {
230 version: 1,
231 ..Default::default()
232 };
233
234 let key = "s1:tty1".to_string();
235 let old_time = Utc::now() - chrono::Duration::seconds(5);
236
237 file.sessions.insert(
238 key.clone(),
239 HookSession {
240 session_id: "s1".to_string(),
241 cwd: "/tmp".to_string(),
242 tty: "tty1".to_string(),
243 status: HookSessionStatus::Stopped,
244 created_at: old_time,
245 updated_at: old_time,
246 last_event: "Stop".to_string(),
247 },
248 );
249
250 file.upsert(
251 key.clone(),
252 "s1".to_string(),
253 "/tmp".to_string(),
254 "tty1".to_string(),
255 HookSessionStatus::Running,
256 "UserPromptSubmit".to_string(),
257 );
258
259 assert_eq!(file.sessions[&key].status, HookSessionStatus::Running);
260 }
261
262 #[test]
263 fn test_prune_stale_running_removes_stale() {
264 let mut file = LiveSessionFile {
265 version: 1,
266 ..Default::default()
267 };
268
269 let stale_time = Utc::now() - Duration::from_secs(11 * 60);
270 let recent_time = Utc::now() - Duration::from_secs(60);
271
272 file.sessions.insert(
274 "s1:tty1".to_string(),
275 HookSession {
276 session_id: "s1".to_string(),
277 cwd: "/tmp".to_string(),
278 tty: "tty1".to_string(),
279 status: HookSessionStatus::Running,
280 created_at: stale_time,
281 updated_at: stale_time,
282 last_event: "PreToolUse".to_string(),
283 },
284 );
285
286 file.sessions.insert(
288 "s2:tty2".to_string(),
289 HookSession {
290 session_id: "s2".to_string(),
291 cwd: "/tmp".to_string(),
292 tty: "tty2".to_string(),
293 status: HookSessionStatus::WaitingInput,
294 created_at: stale_time,
295 updated_at: stale_time,
296 last_event: "Notification".to_string(),
297 },
298 );
299
300 file.sessions.insert(
302 "s3:tty3".to_string(),
303 HookSession {
304 session_id: "s3".to_string(),
305 cwd: "/tmp".to_string(),
306 tty: "tty3".to_string(),
307 status: HookSessionStatus::Running,
308 created_at: recent_time,
309 updated_at: recent_time,
310 last_event: "PreToolUse".to_string(),
311 },
312 );
313
314 file.sessions.insert(
316 "s4:tty4".to_string(),
317 HookSession {
318 session_id: "s4".to_string(),
319 cwd: "/tmp".to_string(),
320 tty: "tty4".to_string(),
321 status: HookSessionStatus::Stopped,
322 created_at: stale_time,
323 updated_at: stale_time,
324 last_event: "Stop".to_string(),
325 },
326 );
327
328 file.prune_stale_running(Duration::from_secs(10 * 60));
329
330 assert!(
331 !file.sessions.contains_key("s1:tty1"),
332 "stale Running should be pruned"
333 );
334 assert!(
335 !file.sessions.contains_key("s2:tty2"),
336 "stale WaitingInput should be pruned"
337 );
338 assert!(
339 file.sessions.contains_key("s3:tty3"),
340 "recent Running should survive"
341 );
342 assert!(
343 file.sessions.contains_key("s4:tty4"),
344 "Stopped handled by prune_stopped, not touched here"
345 );
346 }
347
348 #[test]
349 fn test_make_session_key() {
350 assert_eq!(
351 make_session_key("abc-123", "/dev/ttys001"),
352 "abc-123:/dev/ttys001"
353 );
354 }
355}