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 upsert(
124 &mut self,
125 key: SessionKey,
126 session_id: String,
127 cwd: String,
128 tty: String,
129 new_status: HookSessionStatus,
130 event_name: String,
131 ) {
132 let now = Utc::now();
133
134 let effective_status = if new_status == HookSessionStatus::Running
136 && self
137 .sessions
138 .get(&key)
139 .map(|s| s.status == HookSessionStatus::Stopped)
140 .unwrap_or(false)
141 {
142 HookSessionStatus::Running
143 } else {
144 new_status
145 };
146
147 if let Some(existing) = self.sessions.get_mut(&key) {
148 existing.status = effective_status;
149 existing.updated_at = now;
150 existing.last_event = event_name;
151 } else {
152 self.sessions.insert(
153 key,
154 HookSession {
155 session_id,
156 cwd,
157 tty,
158 status: effective_status,
159 created_at: now,
160 updated_at: now,
161 last_event: event_name,
162 },
163 );
164 }
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use std::time::Duration;
172
173 #[test]
174 fn test_prune_stopped_removes_old() {
175 let mut file = LiveSessionFile {
176 version: 1,
177 ..Default::default()
178 };
179
180 let old_time = Utc::now() - Duration::from_secs(31 * 60); file.sessions.insert(
182 "s1:tty1".to_string(),
183 HookSession {
184 session_id: "s1".to_string(),
185 cwd: "/tmp".to_string(),
186 tty: "tty1".to_string(),
187 status: HookSessionStatus::Stopped,
188 created_at: old_time,
189 updated_at: old_time,
190 last_event: "Stop".to_string(),
191 },
192 );
193
194 file.sessions.insert(
196 "s2:tty2".to_string(),
197 HookSession {
198 session_id: "s2".to_string(),
199 cwd: "/tmp".to_string(),
200 tty: "tty2".to_string(),
201 status: HookSessionStatus::Running,
202 created_at: old_time,
203 updated_at: old_time,
204 last_event: "PreToolUse".to_string(),
205 },
206 );
207
208 file.prune_stopped(Duration::from_secs(30 * 60));
209
210 assert!(!file.sessions.contains_key("s1:tty1"));
211 assert!(file.sessions.contains_key("s2:tty2"));
212 }
213
214 #[test]
215 fn test_upsert_revives_stopped() {
216 let mut file = LiveSessionFile {
217 version: 1,
218 ..Default::default()
219 };
220
221 let key = "s1:tty1".to_string();
222 let old_time = Utc::now() - chrono::Duration::seconds(5);
223
224 file.sessions.insert(
225 key.clone(),
226 HookSession {
227 session_id: "s1".to_string(),
228 cwd: "/tmp".to_string(),
229 tty: "tty1".to_string(),
230 status: HookSessionStatus::Stopped,
231 created_at: old_time,
232 updated_at: old_time,
233 last_event: "Stop".to_string(),
234 },
235 );
236
237 file.upsert(
238 key.clone(),
239 "s1".to_string(),
240 "/tmp".to_string(),
241 "tty1".to_string(),
242 HookSessionStatus::Running,
243 "UserPromptSubmit".to_string(),
244 );
245
246 assert_eq!(file.sessions[&key].status, HookSessionStatus::Running);
247 }
248
249 #[test]
250 fn test_make_session_key() {
251 assert_eq!(
252 make_session_key("abc-123", "/dev/ttys001"),
253 "abc-123:/dev/ttys001"
254 );
255 }
256}