crates/signaltty-server/src/persist.rs
//! Snapshot persistence: atomic writes, tolerant loading, honest
//! restore states. See docs/09.
use std::collections::{HashMap, HashSet};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use signaltty_term::HeadlessBackend;
use signaltty_core::model::{LiveState, Notification, Pane, RestoreState, Tab, Task, Workspace};
use signaltty_term::TerminalBackend;
use crate::config::Config;
use crate::store::SharedStore;
pub const SNAPSHOT_VERSION: u32 = 1;
#[derive(Debug, Serialize, Deserialize)]
pub struct Snapshot {
pub version: u32,
pub saved_at: DateTime<Utc>,
pub workspaces: Vec<Workspace>,
pub tabs: Vec<Tab>,
pub panes: Vec<Pane>,
pub notifications: Vec<Notification>,
#[serde(default)]
pub tasks: Vec<Task>,
}
/// Save snapshot + scrollback tails atomically. Quick enough to call
/// directly on the shutdown path; the flusher calls it periodically.
pub fn save(
store: &SharedStore,
terms: &Mutex<HeadlessBackend>,
config: &Config,
) -> Result<(), String> {
ensure_dir(&config.state_dir)?;
let history = config.state_dir.join("history");
ensure_dir(&history)?;
let snap = {
let s = store.read().unwrap();
Snapshot {
version: SNAPSHOT_VERSION,
saved_at: Utc::now(),
workspaces: s.workspaces.values().cloned().collect(),
tabs: s.tabs.values().cloned().collect(),
panes: s.panes.values().cloned().collect(),
notifications: s.notifications.iter().cloned().collect(),
tasks: s.tasks.values().cloned().collect(),
}
};
// Tails first (best-effort context, raw lines, byte-capped).
let live_ids: HashSet<String> = snap.panes.iter().map(|p| p.id.clone()).collect();
{
let terms = terms.lock().unwrap();
for pane in &snap.panes {
let path = tail_path(&history, &pane.id);
if let Some(lines) = terms.tail(&pane.id, usize::MAX, false) {
let mut bytes = 0usize;
let mut kept: Vec<&str> = Vec::new();
for line in lines.iter().rev() {
bytes += line.len() + 1;
if bytes > config.history_tail_bytes {
break;
}
kept.push(line);
}
kept.reverse();
let content = kept.join("\n");
atomic_write(&path, content.as_bytes())?;
}
}
}
// Prune tails for panes that no longer exist.
if let Ok(entries) = fs::read_dir(&history) {
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if let Some(id) = name.strip_suffix(".tail") {
if !live_ids.contains(id) {
let _ = fs::remove_file(e.path());
}
}
}
}
let data = serde_json::to_vec_pretty(&snap).map_err(|e| e.to_string())?;
atomic_write(&config.state_dir.join("snapshot.json"), &data)?;
Ok(())
}
pub struct LoadedSnapshot {
pub snapshot: Snapshot,
pub tails: HashMap<String, Vec<String>>,
}
pub fn load(config: &Config) -> Option<LoadedSnapshot> {
let path = config.state_dir.join("snapshot.json");
let data = fs::read(&path).ok()?;
let snapshot: Snapshot = match serde_json::from_slice(&data) {
Ok(s) => s,
Err(e) => {
// Back up corrupt snapshot, start empty, log loudly.
let backup = config.state_dir.join(format!(
"snapshot.corrupt-{}",
Utc::now().format("%Y%m%dT%H%M%S")
));
let _ = fs::rename(&path, &backup);
tracing::warn!(
"corrupt snapshot ({}), backed up to {:?}; starting empty",
e,
backup
);
return None;
}
};
if snapshot.version != SNAPSHOT_VERSION {
tracing::error!(
"snapshot version {} != {}; refusing to load",
snapshot.version,
SNAPSHOT_VERSION
);
return None;
}
let history = config.state_dir.join("history");
let mut tails = HashMap::new();
for pane in &snapshot.panes {
let path = tail_path(&history, &pane.id);
if let Ok(content) = fs::read_to_string(&path) {
tails.insert(
pane.id.clone(),
content.lines().map(|l| l.to_string()).collect(),
);
}
}
Some(LoadedSnapshot { snapshot, tails })
}
/// Rebuild store + headless surfaces from a snapshot. No processes are
/// started here — ever. Panes become RESTORED/RESUMABLE/EXITED.
pub fn apply(store: &SharedStore, terms: &Mutex<HeadlessBackend>, mut loaded: LoadedSnapshot) {
let mut s = store.write().unwrap();
// Legacy snapshots predate handles: backfill unique ones. Sorted by id
// so the `-2` suffixes land deterministically; each insert keeps the
// collision check honest for the next legacy workspace.
loaded.snapshot.workspaces.sort_by(|a, b| a.id.cmp(&b.id));
for ws in loaded.snapshot.workspaces {
let mut ws = ws;
if ws.handle.is_empty() {
ws.handle = crate::router::unique_handle(&s, &signaltty_core::model::slugify(&ws.name));
}
s.workspaces.insert(ws.id.clone(), ws);
}
for tab in loaded.snapshot.tabs {
s.tabs.insert(tab.id.clone(), tab);
}
let mut terms = terms.lock().unwrap();
for mut pane in loaded.snapshot.panes {
if let Some(decision) = &mut pane.pending_decision {
if crate::approvals::Approvals::is_native(&decision.id) {
decision.answerable = false;
}
}
let was_live = matches!(pane.live, LiveState::Live);
if was_live {
// The process is gone with the old server; keep the code
// unknown rather than inventing one.
pane.live = LiveState::Exited { code: None };
}
pane.restore_state = if !was_live {
RestoreState::Exited
} else if pane.agent.resume_argv.is_some() {
RestoreState::Resumable
} else {
RestoreState::Restored
};
if let Some(lines) = loaded.tails.remove(&pane.id) {
terms.restore_surface(&pane.id, pane.pty_size.cols, pane.pty_size.rows, lines);
} else {
terms.create_surface(&pane.id, pane.pty_size.cols, pane.pty_size.rows);
}
s.panes.insert(pane.id.clone(), pane);
}
for n in loaded.snapshot.notifications {
s.push_notification(n);
}
// Read cursors are runtime-only: `restore_surface` already reset them
// so pre-restart cursors read as dropped (headless `dropped_floor`).
for task in loaded.snapshot.tasks {
s.tasks.insert(task.id.clone(), task);
}
}
/// Recovery: no process survives a restart (docs/09), so every non-terminal
/// task's worker is dead. Fail them with evidence via the `task_fail`
/// transition (pairs mutate + emit per the AGENTS.md rule); completed work,
/// results, dispositions, and checkouts stay intact. Call after
/// `Store::configure_events` so the transitions are journaled and broadcast.
pub fn recover_tasks(store: &SharedStore) {
let mut s = store.write().unwrap();
let stale: Vec<String> = s
.tasks
.values()
.filter(|t| !t.state.is_terminal())
.map(|t| t.id.clone())
.collect();
for id in stale {
s.task_fail(&id, Some(serde_json::json!({"stage": "restart"})));
}
}
fn tail_path(history: &Path, pane_id: &str) -> PathBuf {
let safe: String = pane_id
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect();
history.join(format!("{safe}.tail"))
}
fn ensure_dir(dir: &Path) -> Result<(), String> {
if !dir.exists() {
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
fs::set_permissions(dir, fs::Permissions::from_mode(0o700)).map_err(|e| e.to_string())?;
}
Ok(())
}
fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
let tmp = path.with_extension("tmp");
fs::write(&tmp, data).map_err(|e| format!("{}: {e}", tmp.display()))?;
fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())?;
fs::rename(&tmp, path).map_err(|e| e.to_string())?;
Ok(())
}