402 lines
15 KiB
Rust
402 lines
15 KiB
Rust
//! Durable per-host spawn history for the spawn form's "recent dirs" picker (#24).
|
|
//!
|
|
//! Sidecars are deleted on kill, so they can't back a "recently used working
|
|
//! directories" list. This module keeps an **append-only JSONL log** at
|
|
//! `<state_dir>/spawn-history.jsonl`, one [`SpawnHistoryRecord`] per successful
|
|
//! spawn, that survives kills and daemon restarts.
|
|
//!
|
|
//! - **Write:** [`SpawnHistory::append`] adds a line. Best-effort — callers
|
|
//! ignore the error so a log-write failure never fails a spawn.
|
|
//! - **Read:** [`SpawnHistory::recent_dirs`] folds the log newest-first, dedups
|
|
//! by `cwd`, drops entries past the recency cap, and caps the result count.
|
|
//! - **Prune:** [`SpawnHistory::compact`] rewrites the file dropping records
|
|
//! past the recency cap; it's called opportunistically from `append` once the
|
|
//! file grows past [`COMPACT_THRESHOLD_BYTES`].
|
|
//!
|
|
//! The recency cap comes from `[daemon].spawn_history_days` (default 30); `0`
|
|
//! disables history entirely — `append` and `recent_dirs` both become no-ops.
|
|
//!
|
|
//! Like [`crate::dirlist`] / [`crate::gitroot`], the time-dependent logic is
|
|
//! factored into `*_as_of(now)` helpers so the fold/prune behaviour is
|
|
//! unit-tested deterministically without touching the wall clock.
|
|
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use chrono::{DateTime, Duration, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Max [`RecentDir`] entries [`SpawnHistory::recent_dirs`] returns. The picker
|
|
/// shows a handful; this bounds the wire payload regardless of log size.
|
|
pub const DEFAULT_MAX_RESULTS: usize = 50;
|
|
|
|
/// File-size threshold past which [`SpawnHistory::append`] triggers an
|
|
/// opportunistic [`SpawnHistory::compact`]. 256 KiB is thousands of records —
|
|
/// large enough that compaction is rare, small enough the file never bloats.
|
|
pub const COMPACT_THRESHOLD_BYTES: u64 = 256 * 1024;
|
|
|
|
/// One logged spawn. Serialized as a single JSON line in the JSONL log.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct SpawnHistoryRecord {
|
|
/// The working directory the user chose (the original request `cwd`, not a
|
|
/// generated worktree checkout path) — what the picker offers to reuse.
|
|
pub cwd: PathBuf,
|
|
pub command: String,
|
|
/// Generated session name, when one was assigned.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub name: Option<String>,
|
|
/// Worktree branch, when the spawn requested one.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub worktree_branch: Option<String>,
|
|
pub at: DateTime<Utc>,
|
|
}
|
|
|
|
/// A deduplicated recent working directory, newest use first. The query result
|
|
/// of [`SpawnHistory::recent_dirs`]; #26 maps this to the wire `RecentDir`.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct RecentDir {
|
|
pub cwd: PathBuf,
|
|
/// The command of the most recent spawn in this dir.
|
|
pub last_command: String,
|
|
pub last_used: DateTime<Utc>,
|
|
}
|
|
|
|
/// Map daemon-local recents to the wire payload (#26). `recent_dirs` already
|
|
/// caps and dedups, so `truncated` is always false here — the field exists for
|
|
/// wire symmetry with [`maestro_protocol::DirListing`].
|
|
pub fn to_wire(dirs: Vec<RecentDir>) -> maestro_protocol::RecentDirs {
|
|
maestro_protocol::RecentDirs {
|
|
dirs: dirs
|
|
.into_iter()
|
|
.map(|d| maestro_protocol::RecentDir {
|
|
cwd: d.cwd,
|
|
last_command: d.last_command,
|
|
last_used: d.last_used,
|
|
})
|
|
.collect(),
|
|
truncated: false,
|
|
}
|
|
}
|
|
|
|
/// Append-only JSONL spawn log rooted at a file path, with a recency cap.
|
|
/// Cheap to clone — just the path + caps.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SpawnHistory {
|
|
path: PathBuf,
|
|
/// Recency cap in days. `0` disables history (every operation is a no-op).
|
|
retention_days: u32,
|
|
max_results: usize,
|
|
compact_threshold_bytes: u64,
|
|
}
|
|
|
|
impl SpawnHistory {
|
|
/// History logged to `path`, keeping records for `retention_days` (`0`
|
|
/// disables). Uses [`DEFAULT_MAX_RESULTS`] and [`COMPACT_THRESHOLD_BYTES`].
|
|
pub fn new(path: impl Into<PathBuf>, retention_days: u32) -> Self {
|
|
Self {
|
|
path: path.into(),
|
|
retention_days,
|
|
max_results: DEFAULT_MAX_RESULTS,
|
|
compact_threshold_bytes: COMPACT_THRESHOLD_BYTES,
|
|
}
|
|
}
|
|
|
|
pub fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
|
|
/// Whether history is on. `false` when `retention_days == 0`.
|
|
pub fn is_enabled(&self) -> bool {
|
|
self.retention_days > 0
|
|
}
|
|
|
|
/// Append one record. No-op when disabled. Best-effort: the caller ignores
|
|
/// the error so a log-write failure never fails the spawn. Creates the
|
|
/// parent directory on demand and triggers opportunistic compaction once
|
|
/// the file grows past [`COMPACT_THRESHOLD_BYTES`].
|
|
pub fn append(&self, record: &SpawnHistoryRecord) -> std::io::Result<()> {
|
|
if !self.is_enabled() {
|
|
return Ok(());
|
|
}
|
|
if let Some(parent) = self.path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
let mut line = serde_json::to_string(record).map_err(std::io::Error::other)?;
|
|
line.push('\n');
|
|
let mut f = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&self.path)?;
|
|
f.write_all(line.as_bytes())?;
|
|
|
|
// Opportunistic prune: rewrite past the size threshold so the log can't
|
|
// grow without bound. Best-effort — a compaction failure leaves the
|
|
// (valid) appended log in place.
|
|
if f.metadata().map(|m| m.len()).unwrap_or(0) > self.compact_threshold_bytes {
|
|
if let Err(e) = self.compact() {
|
|
tracing::warn!(error = %e, "spawn-history compaction failed (non-fatal)");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Recent working dirs, newest use first, deduped by `cwd`, within the
|
|
/// recency cap, capped at `max_results`. Empty when disabled or unreadable.
|
|
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
|
self.recent_dirs_capped(Utc::now(), self.max_results)
|
|
}
|
|
|
|
/// Every deduped recent dir, newest-first, without the picker's result
|
|
/// cap (#54): project enrichment folds the whole history, not its first
|
|
/// page, so a project spawned in long ago still reads as fresh.
|
|
pub fn all_recent_dirs(&self) -> Vec<RecentDir> {
|
|
self.recent_dirs_capped(Utc::now(), usize::MAX)
|
|
}
|
|
|
|
/// [`recent_dirs`](Self::recent_dirs) with an explicit clock for testing.
|
|
#[cfg(test)]
|
|
fn recent_dirs_as_of(&self, now: DateTime<Utc>) -> Vec<RecentDir> {
|
|
self.recent_dirs_capped(now, self.max_results)
|
|
}
|
|
|
|
fn recent_dirs_capped(&self, now: DateTime<Utc>, max_results: usize) -> Vec<RecentDir> {
|
|
if !self.is_enabled() {
|
|
return Vec::new();
|
|
}
|
|
let cutoff = now - Duration::days(self.retention_days as i64);
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut out = Vec::new();
|
|
// Records are appended oldest→newest; reverse to fold newest-first so
|
|
// the first time we see a `cwd` is its most-recent use.
|
|
for rec in self.read_records().into_iter().rev() {
|
|
if rec.at < cutoff {
|
|
continue;
|
|
}
|
|
if !seen.insert(rec.cwd.clone()) {
|
|
continue;
|
|
}
|
|
out.push(RecentDir {
|
|
cwd: rec.cwd,
|
|
last_command: rec.command,
|
|
last_used: rec.at,
|
|
});
|
|
if out.len() >= max_results {
|
|
break;
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Rewrite the log keeping only records within the recency cap, preserving
|
|
/// chronological (append) order. No-op when disabled.
|
|
pub fn compact(&self) -> std::io::Result<()> {
|
|
self.compact_as_of(Utc::now())
|
|
}
|
|
|
|
/// [`compact`](Self::compact) with an explicit clock for testing.
|
|
fn compact_as_of(&self, now: DateTime<Utc>) -> std::io::Result<()> {
|
|
if !self.is_enabled() {
|
|
return Ok(());
|
|
}
|
|
let cutoff = now - Duration::days(self.retention_days as i64);
|
|
let kept: Vec<SpawnHistoryRecord> = self
|
|
.read_records()
|
|
.into_iter()
|
|
.filter(|r| r.at >= cutoff)
|
|
.collect();
|
|
let mut body = String::new();
|
|
for rec in &kept {
|
|
body.push_str(&serde_json::to_string(rec).map_err(std::io::Error::other)?);
|
|
body.push('\n');
|
|
}
|
|
// Atomic replace (tmp + rename) so a crash mid-rewrite can't truncate
|
|
// the log, mirroring [`crate::sidecar`]'s write discipline.
|
|
let tmp = self.path.with_extension("jsonl.tmp");
|
|
std::fs::write(&tmp, body)?;
|
|
std::fs::rename(&tmp, &self.path)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Parse every line of the log into a record, skipping unreadable/malformed
|
|
/// lines (a partial final write, a hand-edit) rather than failing the read.
|
|
/// Returns empty when the file is absent.
|
|
fn read_records(&self) -> Vec<SpawnHistoryRecord> {
|
|
let contents = match std::fs::read_to_string(&self.path) {
|
|
Ok(s) => s,
|
|
Err(_) => return Vec::new(),
|
|
};
|
|
contents
|
|
.lines()
|
|
.filter(|l| !l.trim().is_empty())
|
|
.filter_map(|l| serde_json::from_str::<SpawnHistoryRecord>(l).ok())
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn rec(cwd: &str, command: &str, at: DateTime<Utc>) -> SpawnHistoryRecord {
|
|
SpawnHistoryRecord {
|
|
cwd: PathBuf::from(cwd),
|
|
command: command.into(),
|
|
name: None,
|
|
worktree_branch: None,
|
|
at,
|
|
}
|
|
}
|
|
|
|
fn t(s: &str) -> DateTime<Utc> {
|
|
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
|
|
}
|
|
|
|
/// `(history, _tempdir)` over a temp state dir. Keep the dir alive.
|
|
fn fixture(retention_days: u32) -> (SpawnHistory, tempfile::TempDir) {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let path = tmp.path().join("spawn-history.jsonl");
|
|
(SpawnHistory::new(path, retention_days), tmp)
|
|
}
|
|
|
|
#[test]
|
|
fn append_then_read_round_trips_record() {
|
|
let (h, _tmp) = fixture(30);
|
|
let r = SpawnHistoryRecord {
|
|
cwd: PathBuf::from("/home/u/git/maestro"),
|
|
command: "claude".into(),
|
|
name: Some("neon-drifter".into()),
|
|
worktree_branch: Some("feat/x".into()),
|
|
at: t("2026-06-20T10:00:00Z"),
|
|
};
|
|
h.append(&r).unwrap();
|
|
assert_eq!(h.read_records(), vec![r]);
|
|
}
|
|
|
|
#[test]
|
|
fn recent_dirs_dedups_by_cwd_newest_first() {
|
|
let (h, _tmp) = fixture(30);
|
|
// Same dir spawned twice (older claude, newer zsh) plus a second dir.
|
|
h.append(&rec("/a", "claude", t("2026-06-20T08:00:00Z")))
|
|
.unwrap();
|
|
h.append(&rec("/b", "opencode", t("2026-06-20T09:00:00Z")))
|
|
.unwrap();
|
|
h.append(&rec("/a", "zsh", t("2026-06-20T10:00:00Z")))
|
|
.unwrap();
|
|
|
|
let recents = h.recent_dirs_as_of(t("2026-06-20T11:00:00Z"));
|
|
// /a is most recent (10:00), then /b (09:00); /a appears once with the
|
|
// newest command.
|
|
assert_eq!(recents.len(), 2);
|
|
assert_eq!(recents[0].cwd, PathBuf::from("/a"));
|
|
assert_eq!(recents[0].last_command, "zsh");
|
|
assert_eq!(recents[0].last_used, t("2026-06-20T10:00:00Z"));
|
|
assert_eq!(recents[1].cwd, PathBuf::from("/b"));
|
|
}
|
|
|
|
#[test]
|
|
fn recent_dirs_drops_entries_past_recency_cap() {
|
|
let (h, _tmp) = fixture(30);
|
|
h.append(&rec("/old", "claude", t("2026-01-01T00:00:00Z")))
|
|
.unwrap();
|
|
h.append(&rec("/new", "claude", t("2026-06-19T00:00:00Z")))
|
|
.unwrap();
|
|
|
|
// As of 2026-06-20, /old (>30d) is pruned, /new survives.
|
|
let recents = h.recent_dirs_as_of(t("2026-06-20T00:00:00Z"));
|
|
let dirs: Vec<&Path> = recents.iter().map(|r| r.cwd.as_path()).collect();
|
|
assert_eq!(dirs, [Path::new("/new")]);
|
|
}
|
|
|
|
#[test]
|
|
fn compaction_rewrites_dropping_stale_records() {
|
|
let (h, _tmp) = fixture(30);
|
|
h.append(&rec("/old", "claude", t("2026-01-01T00:00:00Z")))
|
|
.unwrap();
|
|
h.append(&rec("/new", "zsh", t("2026-06-19T00:00:00Z")))
|
|
.unwrap();
|
|
assert_eq!(h.read_records().len(), 2);
|
|
|
|
h.compact_as_of(t("2026-06-20T00:00:00Z")).unwrap();
|
|
|
|
let after = h.read_records();
|
|
assert_eq!(after.len(), 1);
|
|
assert_eq!(after[0].cwd, PathBuf::from("/new"));
|
|
// Order is preserved (chronological), file ends with a newline.
|
|
let raw = std::fs::read_to_string(h.path()).unwrap();
|
|
assert!(raw.ends_with('\n'));
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_history_is_a_no_op() {
|
|
let (h, _tmp) = fixture(0);
|
|
assert!(!h.is_enabled());
|
|
h.append(&rec("/a", "claude", t("2026-06-20T10:00:00Z")))
|
|
.unwrap();
|
|
// Nothing written, nothing read.
|
|
assert!(!h.path().exists());
|
|
assert!(h.recent_dirs_as_of(t("2026-06-20T11:00:00Z")).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn read_skips_malformed_lines() {
|
|
let (h, _tmp) = fixture(30);
|
|
h.append(&rec("/a", "claude", t("2026-06-20T10:00:00Z")))
|
|
.unwrap();
|
|
// Append a junk line + a blank line by hand.
|
|
std::fs::write(
|
|
h.path(),
|
|
format!(
|
|
"{{\"not\":\"a record\"}}\n\n{}\n",
|
|
serde_json::to_string(&rec("/a", "claude", t("2026-06-20T10:00:00Z"))).unwrap()
|
|
),
|
|
)
|
|
.unwrap();
|
|
// Only the one valid record is parsed.
|
|
assert_eq!(h.read_records().len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn recent_dirs_caps_result_count() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut h = SpawnHistory::new(tmp.path().join("h.jsonl"), 30);
|
|
h.max_results = 2;
|
|
for i in 0..5 {
|
|
h.append(&rec(
|
|
&format!("/d{i}"),
|
|
"claude",
|
|
t("2026-06-20T10:00:00Z") + Duration::minutes(i),
|
|
))
|
|
.unwrap();
|
|
}
|
|
let recents = h.recent_dirs_as_of(t("2026-06-20T11:00:00Z"));
|
|
assert_eq!(recents.len(), 2);
|
|
// Newest two dirs: /d4 then /d3.
|
|
assert_eq!(recents[0].cwd, PathBuf::from("/d4"));
|
|
assert_eq!(recents[1].cwd, PathBuf::from("/d3"));
|
|
}
|
|
|
|
/// Project enrichment (#54) folds the whole log, not just the picker's
|
|
/// first page.
|
|
#[test]
|
|
fn all_recent_dirs_skips_the_result_cap() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut h = SpawnHistory::new(tmp.path().join("h.jsonl"), 30);
|
|
h.max_results = 2;
|
|
// Recent enough to survive the 30-day cap against the real clock.
|
|
let base = Utc::now() - Duration::hours(1);
|
|
for i in 0..5 {
|
|
h.append(&rec(
|
|
&format!("/d{i}"),
|
|
"claude",
|
|
base + Duration::minutes(i),
|
|
))
|
|
.unwrap();
|
|
}
|
|
assert_eq!(h.recent_dirs().len(), 2);
|
|
let all = h.all_recent_dirs();
|
|
assert_eq!(all.len(), 5);
|
|
assert_eq!(all[0].cwd, PathBuf::from("/d4"));
|
|
assert_eq!(all[4].cwd, PathBuf::from("/d0"));
|
|
}
|
|
}
|