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:
2026-06-20 20:54:21 +00:00
co-authored by Claude Opus 4.8
parent 7f3ac21402
commit b5fabff8e4
9 changed files with 544 additions and 2 deletions
+7
View File
@@ -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()),
+5 -1
View File
@@ -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");
+44
View File
@@ -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();
+3
View File
@@ -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 ..."
+1
View File
@@ -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;
+58
View File
@@ -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)
}
+348
View File
@@ -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"));
}
}
+24 -1
View File
@@ -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");