This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-daemon/src/spawn_history.rs
T

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"));
}
}