light_fw_cli_core/
session.rs1use std::fs::{self, File, OpenOptions};
16use std::io::Write;
17use std::path::{Path, PathBuf};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use serde_json::Value;
21
22const CURRENT_MARKER: &str = "current";
24const SCREENSHOTS_DIR: &str = "screenshots";
26const STATE_DIR: &str = "state";
27const ACTIONS_FILE: &str = "actions.jsonl";
29const SUMMARY_FILE: &str = "summary.md";
31
32#[derive(Debug, thiserror::Error)]
34pub enum SessionError {
35 #[error("io error: {0}")]
37 Io(#[from] std::io::Error),
38 #[error("no active session")]
40 NoActiveSession,
41 #[error("json: {0}")]
43 Json(#[from] serde_json::Error),
44 #[error("base64: {0}")]
46 Decode(String),
47}
48
49pub type Result<T> = std::result::Result<T, SessionError>;
51
52fn sessions_dir(cwd: &Path) -> PathBuf {
53 cwd.join(".light").join("agent-sessions")
54}
55
56fn current_marker_path(cwd: &Path) -> PathBuf {
57 sessions_dir(cwd).join(CURRENT_MARKER)
58}
59
60pub fn read_session_meta(cwd: &Path, id: &str) -> Option<Value> {
66 let p = sessions_dir(cwd)
67 .join(id)
68 .join(STATE_DIR)
69 .join("session.json");
70 let s = fs::read_to_string(&p).ok()?;
71 serde_json::from_str(&s).ok()
72}
73
74fn write_session_meta(cwd: &Path, id: &str, meta: &Value) {
75 let dir = sessions_dir(cwd).join(id).join(STATE_DIR);
76 let _ = fs::create_dir_all(&dir);
77 let _ = fs::write(
78 dir.join("session.json"),
79 serde_json::to_string(meta).unwrap_or_default(),
80 );
81}
82
83pub fn active_session_id(cwd: &Path) -> Option<String> {
85 fs::read_to_string(current_marker_path(cwd))
86 .ok()
87 .map(|s| s.trim().to_owned())
88 .filter(|s| !s.is_empty())
89}
90
91fn rand_suffix() -> String {
92 let now = SystemTime::now()
95 .duration_since(UNIX_EPOCH)
96 .map(|d| d.as_nanos() as u64)
97 .unwrap_or(0);
98 let pid = std::process::id() as u64;
99 let x = (now ^ pid.wrapping_mul(0x9E37_79B9_7F4A_7C15)) & 0xFFFFF;
100 format!("{x:05x}")
101}
102
103pub fn start_session(cwd: &Path) -> Result<String> {
108 let root = sessions_dir(cwd);
109 fs::create_dir_all(&root)?;
110 let now = SystemTime::now();
112 let secs = now
113 .duration_since(UNIX_EPOCH)
114 .map(|d| d.as_secs())
115 .unwrap_or(0);
116 let ts = format_unix(secs);
118 let id = format!("{ts}_{}", rand_suffix());
119 let session_dir = root.join(&id);
120 fs::create_dir_all(&session_dir)?;
121 fs::create_dir_all(session_dir.join(SCREENSHOTS_DIR))?;
122 fs::create_dir_all(session_dir.join(STATE_DIR))?;
123 let mut actions = File::create(session_dir.join(ACTIONS_FILE))?;
124 actions.flush()?;
125 fs::write(current_marker_path(cwd), &id)?;
126 let started_at = now_rfc3339();
130 let meta = serde_json::json!({ "started_at": started_at, "ended_at": Value::Null });
131 fs::write(
132 session_dir.join("state").join("session.json"),
133 serde_json::to_string(&meta)?,
134 )?;
135 Ok(id)
136}
137
138fn actions_path(cwd: &Path, id: &str) -> PathBuf {
140 sessions_dir(cwd).join(id).join(ACTIONS_FILE)
141}
142
143pub fn stop_session(cwd: &Path, id: &str) -> Result<PathBuf> {
146 let marker = current_marker_path(cwd);
147 if let Ok(cur) = fs::read_to_string(&marker)
148 && cur.trim() == id
149 {
150 let _ = fs::remove_file(&marker);
151 }
152 let session_dir = sessions_dir(cwd).join(id);
153 let actions = actions_path(cwd, id);
154 let lines = fs::read_to_string(&actions).unwrap_or_default();
155 let mut entries: Vec<serde_json::Value> = Vec::new();
156 for line in lines.lines() {
157 let line = line.trim();
158 if line.is_empty() {
159 continue;
160 }
161 if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
162 entries.push(v);
163 }
164 }
165 let now = now_rfc3339();
171 let meta = read_session_meta(cwd, id).unwrap_or(Value::Null);
172 let started = meta
173 .get("started_at")
174 .and_then(|t| t.as_str())
175 .filter(|s| !s.is_empty())
176 .unwrap_or("?")
177 .to_owned();
178 let ended = now.clone();
179 let mut new_meta = meta.clone();
181 if let Value::Object(m) = &mut new_meta {
182 m.insert("ended_at".into(), Value::String(ended.clone()));
183 } else {
184 new_meta = serde_json::json!({ "started_at": started.clone(), "ended_at": ended.clone() });
185 }
186 write_session_meta(cwd, id, &new_meta);
187 let screenshots = entries
188 .iter()
189 .filter_map(|e| e.get("screenshot").and_then(|s| s.as_str()))
190 .count();
191 let mut md = String::new();
192 md.push_str("# Agent Session Summary\n\n");
193 md.push_str(&format!("- **Started:** {started}\n"));
194 md.push_str(&format!("- **Ended:** {ended}\n"));
195 md.push_str(&format!("- **Actions:** {}\n", entries.len()));
196 md.push_str(&format!("- **Screenshots:** {screenshots}\n\n"));
197 md.push_str("## Sequence\n\n");
198 for (i, e) in entries.iter().enumerate() {
199 let ts = e.get("ts").and_then(|t| t.as_str()).unwrap_or("?");
200 let cmd = e.get("cmd").and_then(|c| c.as_str()).unwrap_or("?");
201 let shot = e
202 .get("screenshot")
203 .and_then(|s| s.as_str())
204 .map(|s| format!(" (screenshot: `{s}`)").to_owned())
205 .unwrap_or_default();
206 md.push_str(&format!("{}) `{ts}` — **{cmd}**{shot}\n", i + 1));
207 }
208 let summary_path = session_dir.join(SUMMARY_FILE);
209 fs::write(&summary_path, md)?;
210 Ok(summary_path)
211}
212
213pub fn record_action(
219 cwd: &Path,
220 cmd: &str,
221 args: &serde_json::Value,
222 result: &serde_json::Value,
223 screenshot_rel: Option<&str>,
224) -> Result<Option<String>> {
225 let Some(id) = active_session_id(cwd) else {
226 return Ok(None);
227 };
228 let path = actions_path(cwd, &id);
229 let ts = now_rfc3339();
230 let mut obj = serde_json::Map::new();
231 obj.insert("ts".into(), serde_json::Value::String(ts));
232 obj.insert("cmd".into(), serde_json::Value::String(cmd.to_owned()));
233 obj.insert("args".into(), args.clone());
234 obj.insert("result".into(), result.clone());
235 if let Some(s) = screenshot_rel {
236 obj.insert("screenshot".into(), serde_json::Value::String(s.to_owned()));
237 }
238 append_line(&path, &serde_json::to_string(&obj)?)?;
239 Ok(screenshot_rel.map(String::from))
240}
241
242pub fn save_screenshot(cwd: &Path, base64_payload: &str, cmd: &str) -> Result<Option<String>> {
247 use base64::Engine;
248 let Some(id) = active_session_id(cwd) else {
249 return Ok(None);
250 };
251 let dir = sessions_dir(cwd).join(&id).join(SCREENSHOTS_DIR);
252 fs::create_dir_all(&dir)?;
253 let counter = fs::read_dir(&dir).map(|rd| rd.count()).unwrap_or(0) + 1;
255 let fname = format!("{counter:03}_{cmd}.png");
256 let target = dir.join(&fname);
257 let b64 = base64_payload
258 .strip_prefix("base64:")
259 .unwrap_or(base64_payload);
260 let bytes = base64::engine::general_purpose::STANDARD
261 .decode(b64)
262 .map_err(|e| SessionError::Decode(e.to_string()))?;
263 fs::write(&target, &bytes)?;
264 Ok(Some(format!("{SCREENSHOTS_DIR}/{fname}")))
265}
266
267pub fn read_actions(cwd: &Path, id: &str) -> Result<Vec<serde_json::Value>> {
269 let path = actions_path(cwd, id);
270 let text = fs::read_to_string(&path).unwrap_or_default();
271 Ok(text
272 .lines()
273 .filter(|l| !l.trim().is_empty())
274 .filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
275 .collect())
276}
277
278fn append_line(path: &Path, line: &str) -> std::io::Result<()> {
279 let mut f = OpenOptions::new().create(true).append(true).open(path)?;
280 writeln!(f, "{line}")?;
281 Ok(())
282}
283
284fn now_rfc3339() -> String {
285 let d = SystemTime::now()
286 .duration_since(UNIX_EPOCH)
287 .unwrap_or_default();
288 format_unix(d.as_secs()).replace('_', "T") + ":00Z"
289}
290
291fn format_unix(secs: u64) -> String {
292 let days = (secs / 86400) as i64;
294 let sod = (secs % 86400) as u32;
295 let h = sod / 3600;
296 let m = (sod % 3600) / 60;
297 let s = sod % 60;
298 let (y, mo, day) = civil_from_days(days);
299 format!("{y:04}-{mo:02}-{day:02}_{h:02}-{m:02}-{s:02}")
300}
301
302fn civil_from_days(z: i64) -> (i64, u32, u32) {
304 let z = z + 719468;
305 let era = if z >= 0 { z } else { z - 146096 } / 146097;
306 let doe = (z - era * 146097) as u64;
307 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
308 let y = yoe as i64 + era * 400;
309 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
310 let mp = (5 * doy + 2) / 153;
311 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
312 let mo = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
313 let y = if mo <= 2 { y + 1 } else { y };
314 (y, mo, d)
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 fn scratch_dir() -> PathBuf {
324 let base = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
325 base.join(format!("_session_test_{}", std::process::id()))
326 }
327
328 #[test]
331 fn empty_session_has_timestamps_in_summary() {
332 let dir = scratch_dir();
333 let _ = std::fs::remove_dir_all(&dir);
334 let id = start_session(&dir).expect("start");
335 let meta_before = read_session_meta(&dir, &id).expect("meta");
337 assert!(
338 meta_before
339 .get("started_at")
340 .and_then(|v| v.as_str())
341 .is_some(),
342 "started_at задан"
343 );
344 assert!(meta_before.get("ended_at").is_none() || meta_before["ended_at"].is_null());
345 let _summary = stop_session(&dir, &id).expect("stop");
346 let summary = std::fs::read_to_string(sessions_dir(&dir).join(&id).join(SUMMARY_FILE))
347 .expect("summary");
348 assert!(!summary.contains("Started:** ?"), "Started: {summary}");
350 assert!(!summary.contains("Ended:** ?"), "Ended: {summary}");
351 let meta_after = read_session_meta(&dir, &id).expect("meta after");
353 assert!(
354 meta_after
355 .get("ended_at")
356 .and_then(|v| v.as_str())
357 .is_some(),
358 "ended_at задан после stop"
359 );
360 let _ = std::fs::remove_dir_all(&dir);
361 }
362}