Skip to main content

light_fw_cli_core/
session.rs

1//! ТЗ 0.16.0: Session Recorder — журнал действий агента.
2//!
3//! Когда активна сессия, любая команда `light devtools <cmd>` (кроме
4//! `session start`/`stop`) записывается в
5//! `.light/agent-sessions/<session-id>/actions.jsonl`. Каждая запись —
6//! одна строка JSON:
7//!
8//! ```json
9//! {"ts":"...Z","cmd":"find","args":{...},"result":{...},"screenshot":"screenshots/001.png"}
10//! ```
11//!
12//! Команда `shot` в активной сессии сохраняет PNG в `screenshots/<n>_<cmd>.png`
13//! и автоматически дописывает ссылку на него в actions.jsonl.
14
15use 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
22/// Имя маркера активной сессии.
23const CURRENT_MARKER: &str = "current";
24/// Подкаталоги внутри каждой сессии.
25const SCREENSHOTS_DIR: &str = "screenshots";
26const STATE_DIR: &str = "state";
27/// Имя журнала действий.
28const ACTIONS_FILE: &str = "actions.jsonl";
29/// Имя итогового отчёта.
30const SUMMARY_FILE: &str = "summary.md";
31
32/// Ошибка работы с session recorder.
33#[derive(Debug, thiserror::Error)]
34pub enum SessionError {
35    /// Файловая операция не удалась.
36    #[error("io error: {0}")]
37    Io(#[from] std::io::Error),
38    /// Нет активной сессии.
39    #[error("no active session")]
40    NoActiveSession,
41    /// JSON-ошибка (например, при записи строки).
42    #[error("json: {0}")]
43    Json(#[from] serde_json::Error),
44    /// Ошибка декодирования base64 (скриншот).
45    #[error("base64: {0}")]
46    Decode(String),
47}
48
49/// Результат операций session recorder.
50pub 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
60/// Метаданные сессии (`state/session.json`): время начала/окончания.
61///
62/// ТЗ 0.16.1, задача 3: `started_at` фиксируется при `Session::start`,
63/// `ended_at` — при `stop()`. Это позволяет `summary.md` показывать даты даже
64/// для пустых/аварийно завершённых сессий (раньше обе строки были `?`).
65pub 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
83/// ID активной сессии (содержимое файла `current`), если сессия запущена.
84pub 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    // Достаём энтропию из времени + PID; для имени каталога более чем
93    // достаточно. Формат: 6 hex-символов.
94    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
103/// Создаёт новую сессию, возвращает её id (имя каталога).
104///
105/// Структура: `<id>/actions.jsonl`, `<id>/screenshots/`, `<id>/state/`, а id
106/// записывается в `.light/agent-sessions/current`.
107pub fn start_session(cwd: &Path) -> Result<String> {
108    let root = sessions_dir(cwd);
109    fs::create_dir_all(&root)?;
110    // id: `<timestamp>_<rand>`.
111    let now = SystemTime::now();
112    let secs = now
113        .duration_since(UNIX_EPOCH)
114        .map(|d| d.as_secs())
115        .unwrap_or(0);
116    // Локальное время в формате YYYY-MM-DDTHH-MM-SS (достаточно для имени).
117    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    // ТЗ 0.16.1, задача 3: сохраняем точное время начала сессии в
127    // `state/session.json`, чтобы `summary.md` имел `Started:` даже при
128    // пустых/аварийных сессиях (раньше писалось `?`).
129    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
138/// Глубоко-существующий путь к файлу действий текущей сессии.
139fn actions_path(cwd: &Path, id: &str) -> PathBuf {
140    sessions_dir(cwd).join(id).join(ACTIONS_FILE)
141}
142
143/// Закрывает активную сессию: снимает маркер `current`, генерирует
144/// `summary.md` (хронологию) и возвращает путь к нему.
145pub 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    // ТЗ 0.16.1, задача 3: `Started:`/`Ended:` — из метаданных сессии
166    // (`state/session.json`), а не из первого/последнего действия. Так даты
167    // есть и у пустых сессий. `Started:` берётся из `started_at`; `Ended:` —
168    // текущее время (только здесь вызывается `stop()`). Если метаданные
169    // отсутствуют (например, сессия создана старой версией) — `?`.
170    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    // Фиксируем `ended_at` в метаданных на случай повторного чтения.
180    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
213/// Записывает одно действие в actions.jsonl активной сессии (если она есть).
214///
215/// `cmd` — имя команды; `args` — аргументы (JSON-объект или null); `result`
216/// — результат (JSON-объект или null, либо `true`/строка). Возвращает относительный
217/// путь к скриншоту (если был сохранён), иначе `None`.
218pub 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
242/// Сохраняет base64-PNG (`"base64:<data>"`) в `screenshots/<counter>_<cmd>.png`
243/// активной сессии и возвращает относительный путь (для записи в actions.jsonl).
244///
245/// Если активной сессии нет — `Ok(None)` (скриншот не сохраняется).
246pub 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    // Номер счётчика = количество файлов png в каталоге (просто и устойчиво).
254    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
267/// Читает actions.jsonl активной сессии в вектор объектов (для тестов/отчётов).
268pub 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    // Гражданская дата из unix-секунд (просто, без зависимостей).
293    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
302// Алгоритм «civil from days» (Howard Hinnant), без зависимостей.
303fn 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    /// Каталог под сессию рядом с исполняемым файлом (обходим `C:\Temp`, на
322    /// который в этой среде нет прав записи — os error 5).
323    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    /// ТЗ 0.16.1, задача 3: у пустой сессии `Started:`/`Ended:` имеют даты
329    /// (из метаданных), а не `?`; после `stop()` `ended_at` фиксируется.
330    #[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        // До stop(): в метаданных `started_at`, `ended_at: null`.
336        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        // Обе строки содержат дату (RFC3339), а не "?".
349        assert!(!summary.contains("Started:** ?"), "Started: {summary}");
350        assert!(!summary.contains("Ended:** ?"), "Ended: {summary}");
351        // После stop(): `ended_at` — строка с датой.
352        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}