feat(maestro): durable per-host spawn history + recent-dirs query (#24)
Add a `spawn_history` daemon module backing the spawn form's "recent dirs" picker. Sidecars are deleted on kill, so they can't be the source — use an append-only JSONL log at `<state_dir>/spawn-history.jsonl`, one record per successful spawn (cwd, command, name, worktree_branch, at). - Write: `SessionManager::create` appends after a successful spawn, best-effort — a log-write failure only warns, never fails the spawn. - Read: `recent_dirs()` folds newest-first, dedups by cwd, drops entries past the recency cap, caps the result count. - Prune: opportunistic `compact()` from `append` once the file passes a size threshold; atomic tmp+rename rewrite. - Config: `[daemon].spawn_history_days` (default 30, `0` disables). History is wired into the manager at daemon startup. Time-dependent logic is factored into `*_as_of(now)` helpers for deterministic unit tests. Adds module unit tests (append/dedup/recency prune/compaction/disabled/malformed/cap), a config resolution test, and real-tmux integration tests proving create→append→read end to end. Closes #24 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -172,6 +172,11 @@ pub struct DaemonSection {
|
||||
/// enable `loginctl enable-linger`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tmux_tmpdir: Option<PathBuf>,
|
||||
/// Recency cap in days for the durable spawn history that backs the spawn
|
||||
/// form's "recent dirs" picker (#24). `None` ⇒ daemon default (30 days);
|
||||
/// `Some(0)` disables history entirely (no log written, no recents read).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub spawn_history_days: Option<u32>,
|
||||
}
|
||||
|
||||
impl DaemonSection {
|
||||
@@ -186,6 +191,7 @@ impl DaemonSection {
|
||||
&& self.private_addr.is_none()
|
||||
&& self.pty_replay_bytes.is_none()
|
||||
&& self.tmux_tmpdir.is_none()
|
||||
&& self.spawn_history_days.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,6 +587,7 @@ mod tests {
|
||||
private_addr: Some("10.0.0.7:7070".into()),
|
||||
pty_replay_bytes: Some(131_072),
|
||||
tmux_tmpdir: Some(PathBuf::from("/run/user/1000")),
|
||||
spawn_history_days: Some(14),
|
||||
},
|
||||
registry: RegistrySection {
|
||||
bind_addr: Some("0.0.0.0:8080".parse().unwrap()),
|
||||
|
||||
@@ -106,7 +106,11 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
worktree_mgr,
|
||||
config.fifo_dir(),
|
||||
config.pty_replay_bytes,
|
||||
);
|
||||
)
|
||||
.with_spawn_history(crate::spawn_history::SpawnHistory::new(
|
||||
config.spawn_history_path(),
|
||||
config.spawn_history_days,
|
||||
));
|
||||
|
||||
let recovered = manager.recover().await?;
|
||||
tracing::info!(recovered, "adopted existing tmux sessions");
|
||||
|
||||
@@ -26,6 +26,10 @@ pub use maestro_config::{
|
||||
/// Default bind address when neither env nor file specify one.
|
||||
pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070";
|
||||
|
||||
/// Default recency cap (days) for the durable spawn history (#24) when
|
||||
/// `[daemon].spawn_history_days` is unset. `0` in config disables history.
|
||||
pub const DEFAULT_SPAWN_HISTORY_DAYS: u32 = 30;
|
||||
|
||||
/// Env var that overrides `[shared].registry_url`.
|
||||
pub const ENV_REGISTRY_URL: &str = "MAESTRO_REGISTRY_URL";
|
||||
/// Env var that overrides `[shared].token`.
|
||||
@@ -93,6 +97,10 @@ pub struct DaemonConfig {
|
||||
/// forces sockets under `p` so bare `tmux ls` in a matching user shell
|
||||
/// sees maestro sessions.
|
||||
pub tmux_tmpdir: Option<PathBuf>,
|
||||
/// Recency cap (days) for the durable spawn history that backs the spawn
|
||||
/// form's "recent dirs" picker (#24). `0` disables history. Default
|
||||
/// [`DEFAULT_SPAWN_HISTORY_DAYS`].
|
||||
pub spawn_history_days: u32,
|
||||
}
|
||||
|
||||
impl DaemonConfig {
|
||||
@@ -104,6 +112,11 @@ impl DaemonConfig {
|
||||
self.state_dir.join("fifos")
|
||||
}
|
||||
|
||||
/// JSONL spawn-history log path (#24): `<state_dir>/spawn-history.jsonl`.
|
||||
pub fn spawn_history_path(&self) -> PathBuf {
|
||||
self.state_dir.join("spawn-history.jsonl")
|
||||
}
|
||||
|
||||
/// Combine the two perimeter config keys into the typed advertisement the
|
||||
/// daemon broadcasts. A perimeter is only advertised when BOTH the id and
|
||||
/// the private address are set — this is the single place the "both or
|
||||
@@ -237,6 +250,9 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
||||
.map(PathBuf::from)
|
||||
.or(daemon.tmux_tmpdir)
|
||||
.or_else(dirs::runtime_dir);
|
||||
let spawn_history_days = daemon
|
||||
.spawn_history_days
|
||||
.unwrap_or(DEFAULT_SPAWN_HISTORY_DAYS);
|
||||
|
||||
Ok(DaemonConfig {
|
||||
daemon_id,
|
||||
@@ -252,6 +268,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
||||
private_addr,
|
||||
pty_replay_bytes,
|
||||
tmux_tmpdir,
|
||||
spawn_history_days,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -504,6 +521,33 @@ private_addr = "10.0.0.7:7070"
|
||||
assert!(cfg.private_addr.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_history_days_defaults_and_overrides() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
|
||||
std::fs::write(&path, minimal_file()).unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert_eq!(cfg.spawn_history_days, DEFAULT_SPAWN_HISTORY_DAYS);
|
||||
assert!(cfg.spawn_history_path().ends_with("spawn-history.jsonl"));
|
||||
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[shared]
|
||||
registry_url = "http://r"
|
||||
token = "t"
|
||||
|
||||
[daemon]
|
||||
daemon_url = "ws://x"
|
||||
spawn_history_days = 0
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert_eq!(cfg.spawn_history_days, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_hostname_is_nonempty() {
|
||||
let h = system_hostname();
|
||||
|
||||
@@ -131,6 +131,9 @@ pub fn run(
|
||||
// None ⇒ daemon resolves to $XDG_RUNTIME_DIR at startup. Override in
|
||||
// config.toml as `[daemon] tmux_tmpdir = "/tmp"` (NAP-234).
|
||||
tmux_tmpdir: None,
|
||||
// None ⇒ daemon defaults to DEFAULT_SPAWN_HISTORY_DAYS (30). Override
|
||||
// in config.toml as `[daemon] spawn_history_days = 0` to disable (#24).
|
||||
spawn_history_days: None,
|
||||
};
|
||||
file.save(path).map_err(ConfigError::from)?;
|
||||
let _ = handle; // stdout handle only used during prompts; binary prints "wrote ..."
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod pty;
|
||||
pub mod registry_client;
|
||||
pub mod session;
|
||||
pub mod sidecar;
|
||||
pub mod spawn_history;
|
||||
pub mod tmux;
|
||||
pub mod worktree;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::pty::{pipe_pane_command, PtyBridge};
|
||||
use crate::sidecar::{SidecarError, SidecarStore};
|
||||
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
|
||||
use crate::tmux::{Tmux, TmuxError};
|
||||
use crate::worktree::{WorktreeError, WorktreeManager};
|
||||
|
||||
@@ -101,6 +102,10 @@ struct Inner {
|
||||
/// NAP-229: per-session PTY replay buffer cap in bytes. Each new attach
|
||||
/// receives the last `replay_bytes` of output before live frames.
|
||||
replay_bytes: usize,
|
||||
/// Durable spawn-history log backing the "recent dirs" picker (#24).
|
||||
/// `None` when not wired (tests, or before `with_spawn_history`); a present
|
||||
/// but disabled (`0`-day) history no-ops internally.
|
||||
spawn_history: Option<SpawnHistory>,
|
||||
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
||||
events: broadcast::Sender<SessionEvent>,
|
||||
}
|
||||
@@ -142,16 +147,41 @@ impl SessionManager {
|
||||
worktree_mgr,
|
||||
fifo_dir,
|
||||
replay_bytes,
|
||||
spawn_history: None,
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
events,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a durable spawn-history log (#24). Builder, called once right
|
||||
/// after construction while the `Arc<Inner>` is uniquely owned — so
|
||||
/// [`Arc::get_mut`] succeeds. A no-op (logged) on the off chance the handle
|
||||
/// was already cloned.
|
||||
pub fn with_spawn_history(mut self, history: SpawnHistory) -> Self {
|
||||
match Arc::get_mut(&mut self.inner) {
|
||||
Some(inner) => inner.spawn_history = Some(history),
|
||||
None => tracing::error!(
|
||||
"with_spawn_history called after the SessionManager was cloned; ignoring"
|
||||
),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn daemon_id(&self) -> &str {
|
||||
&self.inner.daemon_id
|
||||
}
|
||||
|
||||
/// Recent working dirs for the spawn form's picker (#24), newest-first and
|
||||
/// deduped by dir. Empty when no history is wired or it's disabled.
|
||||
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
||||
self.inner
|
||||
.spawn_history
|
||||
.as_ref()
|
||||
.map(|h| h.recent_dirs())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn events(&self) -> broadcast::Receiver<SessionEvent> {
|
||||
self.inner.events.subscribe()
|
||||
}
|
||||
@@ -244,6 +274,13 @@ impl SessionManager {
|
||||
return Err(SessionError::Tmux(e));
|
||||
}
|
||||
|
||||
// Capture spawn-history inputs before `req` is partially moved into
|
||||
// `session`. `history_cwd` is the *chosen* working dir (the request
|
||||
// `cwd`), not a generated worktree checkout path — that's what the
|
||||
// recents picker offers to reuse.
|
||||
let history_cwd = req.cwd.clone();
|
||||
let worktree_branch = req.worktree.as_ref().map(|w| w.branch.clone());
|
||||
|
||||
let session = Session {
|
||||
id,
|
||||
daemon_id: self.inner.daemon_id.clone(),
|
||||
@@ -280,6 +317,27 @@ impl SessionManager {
|
||||
);
|
||||
}
|
||||
let _ = self.inner.events.send(SessionEvent::Added(session.clone()));
|
||||
|
||||
// Best-effort: log the spawn for the recents picker (#24). A log-write
|
||||
// failure must never fail an otherwise-successful spawn, so the error
|
||||
// is only warned. No-op when history is unwired or disabled.
|
||||
if let Some(history) = &self.inner.spawn_history {
|
||||
let record = SpawnHistoryRecord {
|
||||
cwd: history_cwd,
|
||||
command: session.command.clone(),
|
||||
name: session
|
||||
.metadata
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string),
|
||||
worktree_branch,
|
||||
at: session.created_at,
|
||||
};
|
||||
if let Err(e) = history.append(&record) {
|
||||
tracing::warn!(error = %e, "spawn-history append failed (non-fatal)");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! 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>,
|
||||
}
|
||||
|
||||
/// 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_as_of(Utc::now())
|
||||
}
|
||||
|
||||
/// [`recent_dirs`](Self::recent_dirs) with an explicit clock for testing.
|
||||
fn recent_dirs_as_of(&self, now: DateTime<Utc>) -> 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() >= self.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"));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use maestro_daemon::sidecar::SidecarStore;
|
||||
use maestro_daemon::spawn_history::SpawnHistory;
|
||||
use maestro_daemon::tmux::Tmux;
|
||||
use maestro_daemon::worktree::WorktreeManager;
|
||||
use maestro_daemon::SessionManager;
|
||||
@@ -118,18 +119,32 @@ pub struct DaemonFixture {
|
||||
|
||||
impl DaemonFixture {
|
||||
pub fn new(daemon_id: &str) -> Self {
|
||||
Self::build(daemon_id, None)
|
||||
}
|
||||
|
||||
/// Variant whose manager has a durable spawn history (#24) attached, with
|
||||
/// `retention_days` recency cap, logging to `<state_dir>/spawn-history.jsonl`.
|
||||
pub fn new_with_history(daemon_id: &str, retention_days: u32) -> Self {
|
||||
Self::build(daemon_id, Some(retention_days))
|
||||
}
|
||||
|
||||
fn build(daemon_id: &str, history_days: Option<u32>) -> Self {
|
||||
let tmux = TmuxFixture::new();
|
||||
let state_dir = TempDir::new().unwrap();
|
||||
let worktree_root = TempDir::new().unwrap();
|
||||
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
||||
let worktree_mgr = WorktreeManager::new(worktree_root.path().to_path_buf());
|
||||
let manager = SessionManager::new(
|
||||
let mut manager = SessionManager::new(
|
||||
daemon_id.to_string(),
|
||||
tmux.tmux.clone(),
|
||||
sidecar,
|
||||
worktree_mgr,
|
||||
state_dir.path().join("fifos"),
|
||||
);
|
||||
if let Some(days) = history_days {
|
||||
manager =
|
||||
manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days));
|
||||
}
|
||||
Self {
|
||||
tmux,
|
||||
manager,
|
||||
@@ -138,6 +153,14 @@ impl DaemonFixture {
|
||||
}
|
||||
}
|
||||
|
||||
fn history_path(state_dir: &TempDir) -> PathBuf {
|
||||
state_dir.path().join("spawn-history.jsonl")
|
||||
}
|
||||
|
||||
pub fn spawn_history_path(&self) -> PathBuf {
|
||||
Self::history_path(&self.state_dir)
|
||||
}
|
||||
|
||||
pub fn sidecar_dir(&self) -> PathBuf {
|
||||
self.state_dir.path().join("sessions")
|
||||
}
|
||||
|
||||
@@ -50,6 +50,60 @@ async fn create_then_kill_round_trip() {
|
||||
.exists());
|
||||
}
|
||||
|
||||
// #24: a successful spawn appends a record to the durable history, which
|
||||
// `recent_dirs()` then surfaces — proving the create→append→read path end to
|
||||
// end against a real tmux + on-disk JSONL log.
|
||||
#[tokio::test]
|
||||
async fn create_appends_to_spawn_history() {
|
||||
let fx = DaemonFixture::new_with_history("laptop", 30);
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let session = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
daemon_id: "laptop".into(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
// The JSONL log exists and the recents query surfaces the chosen dir.
|
||||
assert!(fx.spawn_history_path().exists());
|
||||
let recents = fx.manager.recent_dirs();
|
||||
assert_eq!(recents.len(), 1);
|
||||
assert_eq!(recents[0].cwd, cwd.path());
|
||||
assert_eq!(recents[0].last_command, "sleep 30");
|
||||
|
||||
fx.manager.kill(session.id).await.expect("kill");
|
||||
// History is durable — it survives the kill that deletes the sidecar.
|
||||
assert_eq!(fx.manager.recent_dirs().len(), 1);
|
||||
}
|
||||
|
||||
// With history disabled (`0` days) or unwired, a spawn logs nothing and the
|
||||
// recents query is empty — the spawn itself still succeeds.
|
||||
#[tokio::test]
|
||||
async fn create_without_history_logs_nothing() {
|
||||
let fx = DaemonFixture::new("laptop");
|
||||
let cwd = TempDir::new().unwrap();
|
||||
fx.manager
|
||||
.create(CreateSessionRequest {
|
||||
daemon_id: "laptop".into(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
assert!(fx.manager.recent_dirs().is_empty());
|
||||
assert!(!fx.spawn_history_path().exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_wrong_daemon_in_request() {
|
||||
let fx = DaemonFixture::new("laptop");
|
||||
|
||||
Reference in New Issue
Block a user