ci / rust (push) Failing after 3s
Local primitives for `maestrod`, no network code yet: - `tmux`: subprocess wrapper around new-session, kill-session, list-sessions (filtered to maestro-*), send-keys -l, pipe-pane -o, resize-window, kill-server. - `sidecar`: atomic JSON metadata store keyed by session UUID, with load_all for daemon restart recovery. - `worktree`: `git worktree add -b` with collision-proof generated paths and `git worktree remove --force` for cleanup_on_kill. Integration tests use real tmux (isolated via unique socket + temp TMUX_TMPDIR) and real git (tmpdir repo). Closes NAP-212.
98 lines
3.3 KiB
Rust
98 lines
3.3 KiB
Rust
//! Per-session JSON metadata store.
|
|
//!
|
|
//! On `CreateSession`, the daemon writes a `<id>.json` sidecar so that on
|
|
//! restart it can pair each surviving `maestro-<id>` tmux session with its
|
|
//! original `cwd`, command, env, metadata, etc. Files are deleted on
|
|
//! `KillSession`. Sessions whose sidecar is missing at recovery time are still
|
|
//! advertised but with empty metadata (handled by the caller, not here).
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use maestro_protocol::Session;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum SidecarError {
|
|
#[error("io: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("json: {0}")]
|
|
Json(#[from] serde_json::Error),
|
|
}
|
|
|
|
/// Filesystem-backed session metadata store, rooted at a directory.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SidecarStore {
|
|
dir: PathBuf,
|
|
}
|
|
|
|
impl SidecarStore {
|
|
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
|
Self { dir: dir.into() }
|
|
}
|
|
|
|
pub fn dir(&self) -> &Path {
|
|
&self.dir
|
|
}
|
|
|
|
pub fn path_for(&self, id: Uuid) -> PathBuf {
|
|
self.dir.join(format!("{id}.json"))
|
|
}
|
|
|
|
/// Atomically write `session` to `<dir>/<id>.json`. Creates the directory
|
|
/// if it does not exist.
|
|
pub fn write(&self, session: &Session) -> Result<(), SidecarError> {
|
|
std::fs::create_dir_all(&self.dir)?;
|
|
let final_path = self.path_for(session.id);
|
|
let tmp = self.dir.join(format!("{}.json.tmp", session.id));
|
|
let json = serde_json::to_vec_pretty(session)?;
|
|
std::fs::write(&tmp, json)?;
|
|
std::fs::rename(&tmp, &final_path)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Read the sidecar for `id`. `Ok(None)` if the file is missing.
|
|
pub fn read(&self, id: Uuid) -> Result<Option<Session>, SidecarError> {
|
|
match std::fs::read(self.path_for(id)) {
|
|
Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
|
|
/// Delete the sidecar for `id`. No-op if it does not exist.
|
|
pub fn delete(&self, id: Uuid) -> Result<(), SidecarError> {
|
|
match std::fs::remove_file(self.path_for(id)) {
|
|
Ok(()) => Ok(()),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
|
|
/// Read every sidecar JSON in the directory. Files that fail to parse are
|
|
/// skipped silently — the daemon will still surface the live tmux session
|
|
/// with empty metadata, which matches the spec's recovery behavior.
|
|
pub fn load_all(&self) -> Result<Vec<Session>, SidecarError> {
|
|
let mut out = Vec::new();
|
|
let entries = match std::fs::read_dir(&self.dir) {
|
|
Ok(e) => e,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
|
|
Err(e) => return Err(e.into()),
|
|
};
|
|
for entry in entries {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
|
continue;
|
|
}
|
|
let Ok(bytes) = std::fs::read(&path) else {
|
|
continue;
|
|
};
|
|
if let Ok(session) = serde_json::from_slice::<Session>(&bytes) {
|
|
out.push(session);
|
|
}
|
|
}
|
|
out.sort_by_key(|s| s.id);
|
|
Ok(out)
|
|
}
|
|
}
|