mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 05:58:19 -06:00
137 lines
5.8 KiB
Rust
137 lines
5.8 KiB
Rust
//! Durable MCP checkpoints, isolated from the desktop and other headless sessions.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use effectcraft_engine::config::{FileOps, StdFiles};
|
|
use effectcraft_engine::{Session, autosave, prefs::Prefs};
|
|
use serde_json::{Value, json};
|
|
|
|
pub(crate) struct AutoSave {
|
|
folder: PathBuf,
|
|
latest: Option<String>,
|
|
error: Option<String>,
|
|
previous: Vec<Value>,
|
|
manifest: Option<Value>,
|
|
}
|
|
|
|
impl AutoSave {
|
|
pub fn new(root: &Path, prefs: &Prefs) -> std::io::Result<Self> {
|
|
let base = if prefs.auto_save.location == "custom" && !prefs.auto_save.folder.trim().is_empty() {
|
|
PathBuf::from(prefs.auto_save.folder.trim()).join("MCP")
|
|
} else {
|
|
root.join(autosave::AUTOSAVE_FOLDER).join("MCP")
|
|
};
|
|
if base.to_str().is_none() {
|
|
return Err(std::io::Error::other("the MCP auto-save folder must be valid UTF-8; set EFFECTCRAFT_CONFIG_DIR to a UTF-8 path"));
|
|
}
|
|
std::fs::create_dir_all(&base)?;
|
|
let previous = previous_sessions(&base);
|
|
let folder = unique_folder(&base)?;
|
|
Ok(Self { folder, latest: None, error: None, previous, manifest: None })
|
|
}
|
|
|
|
pub fn folder(&self) -> &Path {
|
|
&self.folder
|
|
}
|
|
|
|
pub fn info(&self) -> Value {
|
|
json!({
|
|
"enabled": true, "folder": self.folder.to_string_lossy(), "latestPath": self.latest,
|
|
"error": self.error, "previousSessions": self.previous,
|
|
"recovery": "Recover with open_project {path: latestPath}, then save_project {path: a normal project file}. Previous sessions may still be running; they are never opened automatically.",
|
|
})
|
|
}
|
|
|
|
pub fn checkpoint(&mut self, s: &mut Session, ended: bool) -> std::io::Result<()> {
|
|
let result = self.write(s, ended);
|
|
if let Err(e) = &result {
|
|
self.error = Some(format!("MCP auto-save in {} failed: {e}", self.folder.display()));
|
|
}
|
|
result.map_err(|e| std::io::Error::other(format!("MCP auto-save in {} failed: {e}", self.folder.display())))
|
|
}
|
|
|
|
fn write(&mut self, s: &mut Session, ended: bool) -> std::io::Result<()> {
|
|
if s.is_dirty() && s.autosave.saved_revision != Some(s.revision) {
|
|
s.autosave.background = false;
|
|
s.autosave_now().map_err(std::io::Error::other)?;
|
|
}
|
|
s.autosave_wait().map_err(std::io::Error::other)?;
|
|
// Explicit file.autoSave and save-on-render-start use the same override. Keep their
|
|
// successful latest path too, even when the revision was already checkpointed.
|
|
if let Some(path) = &s.autosave.last_path {
|
|
self.latest = Some(path.clone());
|
|
}
|
|
let record = json!({
|
|
"pid": autosave::pid(), "project": s.path, "autosave": self.latest,
|
|
"dirty": s.is_dirty(), "revision": s.revision, "ended": ended,
|
|
});
|
|
// Queries don't rewrite the same manifest or rotate project slots.
|
|
if self.manifest.as_ref() != Some(&record) && self.latest.is_some() {
|
|
StdFiles.write(&self.folder.join("session.json"), record.to_string().as_bytes())?;
|
|
self.manifest = Some(record);
|
|
}
|
|
self.error = None;
|
|
if ended && self.latest.is_none() {
|
|
let _ = std::fs::remove_dir(&self.folder);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn unique_folder(base: &Path) -> std::io::Result<PathBuf> {
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static NEXT: AtomicU64 = AtomicU64::new(0);
|
|
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
|
|
for _ in 0..100 {
|
|
let folder = base.join(format!("{}-{stamp}-{}", autosave::pid(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
|
match std::fs::create_dir(&folder) {
|
|
Ok(()) => return Ok(folder),
|
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
|
Err(e) => return Err(e),
|
|
}
|
|
}
|
|
Err(std::io::Error::other("cannot reserve a unique MCP auto-save folder"))
|
|
}
|
|
|
|
fn previous_sessions(base: &Path) -> Vec<Value> {
|
|
// Scan directory metadata with bounded memory, then read only the newest 200 small
|
|
// manifests. Limiting read_dir before sorting could hide a newly killed session once
|
|
// older folders accumulated. Recovery is explicit, so running sessions are safe to list.
|
|
let Ok(entries) = std::fs::read_dir(base) else { return vec![] };
|
|
let mut records = std::collections::BTreeSet::new();
|
|
for entry in entries.flatten() {
|
|
let path = entry.path().join("session.json");
|
|
let Ok(meta) = path.metadata() else { continue };
|
|
if !meta.is_file() || meta.len() > 64 * 1024 {
|
|
continue;
|
|
}
|
|
records.insert((meta.modified().ok(), path));
|
|
if records.len() > 200 {
|
|
records.pop_first();
|
|
}
|
|
}
|
|
records
|
|
.into_iter()
|
|
.rev()
|
|
.filter_map(|(_, path)| {
|
|
use std::io::Read;
|
|
let mut bytes = Vec::new();
|
|
std::fs::File::open(path).ok()?.take(64 * 1024 + 1).read_to_end(&mut bytes).ok()?;
|
|
if bytes.len() > 64 * 1024 {
|
|
return None;
|
|
}
|
|
let record: Value = serde_json::from_slice(&bytes).ok()?;
|
|
let saved = record.get("autosave")?.as_str()?;
|
|
if saved.len() > 4096 || record.get("dirty") != Some(&Value::Bool(true)) || !Path::new(saved).is_file() {
|
|
return None;
|
|
}
|
|
let project = record.get("project").and_then(Value::as_str).filter(|p| p.len() <= 4096);
|
|
Some(json!({"autosave": saved, "project": project, "dirty": true,
|
|
"ended": record.get("ended").and_then(Value::as_bool).unwrap_or(false),
|
|
"pid": record.get("pid").and_then(Value::as_u64),
|
|
}))
|
|
})
|
|
.take(5)
|
|
.collect()
|
|
}
|