history : historique structure par session + am history (issue #7) - migration one-shot de history.txt, filtres kind/cwd/search/failed/session, enregistrement REPL avec duree et exit
This commit is contained in:
+274
@@ -0,0 +1,274 @@
|
||||
//! Structured command history: one JSONL file per REPL session under
|
||||
//! history/<session>.jsonl. Replaces the flat history.txt (imported once).
|
||||
|
||||
use crate::app::App;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HistoryEntry {
|
||||
/// RFC 3339 timestamp.
|
||||
pub ts: String,
|
||||
pub cmd: String,
|
||||
/// "am" (agent-manager command) or "shell" (system gateway command).
|
||||
#[serde(default = "kind_am")]
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub cwd: Option<String>,
|
||||
#[serde(default)]
|
||||
pub exit_code: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub duration_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub agent: Option<String>,
|
||||
}
|
||||
|
||||
fn kind_am() -> String {
|
||||
"am".to_string()
|
||||
}
|
||||
|
||||
impl HistoryEntry {
|
||||
pub fn now(cmd: impl Into<String>) -> Self {
|
||||
HistoryEntry {
|
||||
ts: crate::installers::now_rfc3339(),
|
||||
cmd: cmd.into(),
|
||||
kind: "am".to_string(),
|
||||
cwd: None,
|
||||
exit_code: None,
|
||||
duration_ms: None,
|
||||
agent: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_kind(mut self, kind: impl Into<String>) -> Self {
|
||||
self.kind = kind.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
|
||||
self.cwd = cwd;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_exit_code(mut self, code: Option<i32>) -> Self {
|
||||
self.exit_code = code;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_duration_ms(mut self, ms: u64) -> Self {
|
||||
self.duration_ms = Some(ms);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_agent(mut self, agent: Option<String>) -> Self {
|
||||
self.agent = agent;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Directory holding the per-session history files.
|
||||
pub fn dir(app: &App) -> PathBuf {
|
||||
app.events_dir().join("history")
|
||||
}
|
||||
|
||||
/// Append one entry to the session file of a REPL session.
|
||||
pub fn record(app: &App, session: &str, entry: &HistoryEntry) -> Result<()> {
|
||||
let d = dir(app);
|
||||
std::fs::create_dir_all(&d).ok();
|
||||
let path = d.join(format!("{session}.jsonl"));
|
||||
let line = serde_json::to_string(entry)?;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.with_context(|| format!("cannot open history file {}", path.display()))?;
|
||||
use std::io::Write;
|
||||
f.write_all(line.as_bytes())?;
|
||||
f.write_all(b"\n")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One-shot migration: import the flat history.txt into the structured
|
||||
/// directory. Returns the number of imported entries.
|
||||
pub fn migrate(app: &App) -> Result<usize> {
|
||||
let txt = app.paths.state_file.with_file_name("history.txt");
|
||||
if !txt.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let d = dir(app);
|
||||
let already = d.join("migrated.jsonl").exists();
|
||||
if already {
|
||||
return Ok(0);
|
||||
}
|
||||
let text = std::fs::read_to_string(&txt)
|
||||
.with_context(|| format!("cannot read {}", txt.display()))?;
|
||||
let mut count = 0;
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let entry = HistoryEntry::now(trimmed.to_string())
|
||||
.with_kind("am".to_string())
|
||||
.with_cwd(None);
|
||||
record(app, "migrated", &entry)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// History files of a directory, oldest first.
|
||||
pub fn files(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = std::fs::read_dir(dir)
|
||||
.map(|rd| {
|
||||
rd.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file())
|
||||
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("jsonl"))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Read all entries across sessions, most recent first. Corrupt lines are
|
||||
/// skipped silently.
|
||||
pub fn read_all(app: &App, limit: usize) -> Vec<HistoryEntry> {
|
||||
let d = dir(app);
|
||||
let mut out: Vec<HistoryEntry> = Vec::new();
|
||||
for f in files(&d).iter().rev() {
|
||||
if let Ok(text) = std::fs::read_to_string(f) {
|
||||
let mut entries: Vec<HistoryEntry> = text
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.filter_map(|l| serde_json::from_str::<HistoryEntry>(l).ok())
|
||||
.collect();
|
||||
entries.reverse(); // newest first within one file
|
||||
out.extend(entries);
|
||||
if limit > 0 && out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if limit > 0 {
|
||||
out.truncate(limit);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse the session id from a history file name ("20260815_143926.jsonl").
|
||||
pub fn session_of_file(path: &Path) -> Option<String> {
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.trim_end_matches(".jsonl").to_string())
|
||||
}
|
||||
|
||||
/// Corrupt-history guard: a session file that cannot be parsed is reported,
|
||||
/// never fatal.
|
||||
pub fn validate_file(path: &Path) -> Result<()> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("cannot read history file {}", path.display()))?;
|
||||
for (i, line) in text.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
serde_json::from_str::<HistoryEntry>(line).map_err(|e| {
|
||||
anyhow!("history file {} line {} is corrupt: {e}", path.display(), i + 1)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
fn test_app(tag: &str) -> App {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
concat!(
|
||||
"version: \"1.0\"\n",
|
||||
"settings:\n",
|
||||
" auto_install_deps: false\n",
|
||||
"agents: []\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = App::from_cli(cli).unwrap();
|
||||
let mut p = app.paths.clone();
|
||||
p.install_dir = dir.join("agents");
|
||||
p.bin_dir = dir.join("agents").join("bin");
|
||||
p.log_dir = dir.join("logs");
|
||||
p.state_file = dir.join("state.json");
|
||||
p.probe_cache_file = dir.join("probe-cache.json");
|
||||
p.config_dir = Some(dir.join("config"));
|
||||
app.paths = p;
|
||||
let _ = tag;
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_and_read_back() {
|
||||
let app = test_app("hist");
|
||||
record(&app, "s1", &HistoryEntry::now("install jcode")).unwrap();
|
||||
record(
|
||||
&app,
|
||||
"s1",
|
||||
&HistoryEntry::now("ls").with_kind("shell".to_string()).with_duration_ms(12),
|
||||
)
|
||||
.unwrap();
|
||||
let all = read_all(&app, 0);
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[0].cmd, "ls", "most recent first");
|
||||
assert_eq!(all[0].kind, "shell");
|
||||
assert_eq!(all[0].duration_ms, Some(12));
|
||||
assert_eq!(all[1].cmd, "install jcode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_imports_flat_file_once() {
|
||||
let app = test_app("mig");
|
||||
let txt = app.paths.state_file.with_file_name("history.txt");
|
||||
std::fs::write(&txt, "install jcode
|
||||
start group:dev
|
||||
|
||||
").unwrap();
|
||||
assert_eq!(migrate(&app).unwrap(), 2);
|
||||
assert_eq!(migrate(&app).unwrap(), 0, "second run is a no-op");
|
||||
let all = read_all(&app, 0);
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[0].cmd, "start group:dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_lines_are_skipped() {
|
||||
let app = test_app("corrupt");
|
||||
record(&app, "s1", &HistoryEntry::now("list")).unwrap();
|
||||
// Append garbage to the session file.
|
||||
let d = dir(&app);
|
||||
let f = d.join("s1.jsonl");
|
||||
let mut text = std::fs::read_to_string(&f).unwrap();
|
||||
text.push_str("not json at all
|
||||
");
|
||||
std::fs::write(&f, text).unwrap();
|
||||
let all = read_all(&app, 0);
|
||||
assert_eq!(all.len(), 1);
|
||||
assert!(validate_file(&f).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_of_file_extracts_id() {
|
||||
assert_eq!(
|
||||
session_of_file(Path::new("20260815_143926.jsonl")),
|
||||
Some("20260815_143926".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user