//! Persistencia del estado del WorkspaceManager. //! //! v1: sólo `WorkspaceSpec`s vivos. Los comandos (PIDs) NO se persisten — //! el kernel los mata al cerrar el daemon. Sólo la *intención declarada* //! (Workspaces creados con su spec) sobrevive a un reboot del daemon. use crate::WorkspaceManager; use serde::{Deserialize, Serialize}; use shipote_card::{PipelineSpec, WorkspaceId, WorkspaceSpec}; use std::path::{Path, PathBuf}; use tracing::{info, warn}; /// v2 agregó `saved_pipelines`. v1 lee con campo ausente como vacío. pub const SNAPSHOT_VERSION: u16 = 2; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ShipoteSnapshot { pub version: u16, pub timestamp_ms: u64, pub workspaces: Vec, #[serde(default)] pub saved_pipelines: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkspaceEntry { pub id: WorkspaceId, pub spec: WorkspaceSpec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PipelineEntry { pub name: String, pub spec: PipelineSpec, } impl ShipoteSnapshot { pub fn write(&self, path: &Path) -> anyhow::Result<()> { let bytes = serde_json::to_vec_pretty(self)?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } let tmp = path.with_extension("tmp"); std::fs::write(&tmp, &bytes)?; std::fs::rename(&tmp, path)?; Ok(()) } pub fn read(path: &Path) -> anyhow::Result { let bytes = std::fs::read(path)?; let snap: ShipoteSnapshot = serde_json::from_slice(&bytes)?; // v1 y v2 son compatibles forward (v1 sin saved_pipelines lee como vec vacío). if snap.version > SNAPSHOT_VERSION { anyhow::bail!( "snapshot version {} no soportada (esperada ≤ {})", snap.version, SNAPSHOT_VERSION ); } Ok(snap) } } /// Path canónico del snapshot: `$XDG_STATE_HOME/shipote/state.json`, /// fallback `$HOME/.local/state/shipote/state.json`, /// fallback `/tmp/shipote-state-$UID.json`. pub fn default_snapshot_path() -> PathBuf { if let Ok(state) = std::env::var("XDG_STATE_HOME") { return PathBuf::from(state).join("shipote/state.json"); } if let Ok(home) = std::env::var("HOME") { return PathBuf::from(home).join(".local/state/shipote/state.json"); } let uid = nix::unistd::getuid().as_raw(); PathBuf::from(format!("/tmp/shipote-state-{uid}.json")) } fn now_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0) } impl WorkspaceManager { /// Toma snapshot del estado actual. pub async fn snapshot(&self) -> ShipoteSnapshot { let g = self.inner.lock().await; let workspaces = g .workspaces .iter() .map(|(id, ws)| WorkspaceEntry { id: *id, spec: ws.spec.clone(), }) .collect(); let saved_pipelines = g .saved_pipelines .iter() .map(|(name, spec)| PipelineEntry { name: name.clone(), spec: spec.clone(), }) .collect(); ShipoteSnapshot { version: SNAPSHOT_VERSION, timestamp_ms: now_ms(), workspaces, saved_pipelines, } } /// Escribe snapshot a disco. pub async fn save_snapshot(&self, path: &Path) -> anyhow::Result<()> { let snap = self.snapshot().await; snap.write(path)?; info!(path = %path.display(), workspaces = snap.workspaces.len(), "snapshot saved"); Ok(()) } /// Carga snapshot desde disco y restaura los Workspaces. /// Errores no-fatales (workspaces inválidos) se loguean y se saltan. pub async fn restore_snapshot(self: &std::sync::Arc, path: &Path) -> anyhow::Result { let snap = match ShipoteSnapshot::read(path) { Ok(s) => s, Err(e) => { warn!(?e, path = %path.display(), "no snapshot — start fresh"); return Ok(0); } }; let mut restored = 0usize; for entry in snap.workspaces { // v2+: reusamos el id original así clients que tracking // workspace_id no se rompen al restart. let label = entry.spec.label.clone(); match self.create_with_id(entry.id, entry.spec).await { Ok(_) => restored += 1, Err(e) => warn!(?e, %label, "skipped workspace en restore"), } } for entry in snap.saved_pipelines { self.save_pipeline(entry.name, entry.spec).await; } info!(restored, "snapshot restored"); Ok(restored) } } #[cfg(test)] mod tests { use super::*; use crate::WorkspaceManager; use ente_incarnate::IncarnatorConfig; use shipote_card::{ExitPolicy, WorkspaceSpec}; use std::sync::Arc; fn sample_ws(label: &str) -> WorkspaceSpec { WorkspaceSpec { label: label.into(), soma: Default::default(), permissions: Default::default(), ttl: None, flow_dirs: vec![], on_exit: ExitPolicy::Reap, } } #[tokio::test] async fn roundtrip_snapshot_preserves_ulids() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("state.json"); let mgr1 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default())); let (id1, _) = mgr1.create(sample_ws("a")).await.unwrap(); let (id2, _) = mgr1.create(sample_ws("b")).await.unwrap(); mgr1.save_snapshot(&path).await.unwrap(); let mgr2 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default())); let n = mgr2.restore_snapshot(&path).await.unwrap(); assert_eq!(n, 2); let listed = mgr2.list().await; let restored_ids: std::collections::HashSet<_> = listed.iter().map(|s| s.id).collect(); assert!(restored_ids.contains(&id1)); assert!(restored_ids.contains(&id2)); } #[tokio::test] async fn snapshot_includes_saved_pipelines() { use shipote_card::{CommandRef, DiscernPolicy, PipelineSpec}; let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("state.json"); let mgr1 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default())); let (ws_id, _) = mgr1.create(sample_ws("ws")).await.unwrap(); let spec = PipelineSpec { label: "echo-cat".into(), workspace: ws_id, nodes: vec![CommandRef { label: "n1".into(), payload: brahman_card::Payload::Native { exec: "/bin/echo".into(), argv: vec!["hi".into()], envp: vec![], }, soma: Default::default(), flows: Default::default(), supervision: brahman_card::Supervision::OneShot, }], edges: vec![], discern: DiscernPolicy::default(), }; mgr1.save_pipeline("daily".into(), spec).await; mgr1.save_snapshot(&path).await.unwrap(); let mgr2 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default())); mgr2.restore_snapshot(&path).await.unwrap(); let saved = mgr2.list_saved_pipelines().await; assert_eq!(saved, vec!["daily".to_string()]); let got = mgr2.get_saved_pipeline("daily").await.expect("saved"); assert_eq!(got.label, "echo-cat"); } #[test] fn default_path_ends_with_state_json() { let p = default_snapshot_path(); assert!(p.to_string_lossy().ends_with("state.json")); } }