diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index fdafd101..71ea1ea3 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -22,7 +22,7 @@ //! effectcraft-cli bench --ops [--small] [--layers N] [--comps N] [--footage N] everyday-operation timings //! on a large generated project (open, save, auto-save, undo/redo, timeline, Project panel) //! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script -//! effectcraft-cli mcp [--bridge PORT] MCP server on stdio +//! effectcraft-cli mcp [--autosave | --bridge PORT] MCP server on stdio //! //! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty) //! Saving: --save (back to --project) | --save-as F.ecproj @@ -111,7 +111,7 @@ const USAGE: &str = "usage: effectcraft-cli : id or name, '-' = active comp; : id, '#n' or name; : JSON or bare string"; @@ -175,6 +175,7 @@ const FLAGS: &[&str] = &[ "--ops", "--small", "--adv3d", + "--autosave", ]; struct Args { @@ -323,6 +324,17 @@ fn backend(args: &Args, default_demo: bool) -> Result { /// renderer setting, Mercury GPU Acceleration by default). fn session(args: &Args) -> Result { let mut s = effectcraft_host::session(); + if args.flag("--autosave") { + let dir = effectcraft_host::config_dir().ok_or_else(|| Failure::Error("--autosave: no config directory; set EFFECTCRAFT_CONFIG_DIR".into()))?; + // Read only auto-save settings. Opting in must not change headless rendering or load + // the desktop's models, shortcuts, disk cache or recovery sentinel. + use effectcraft_engine::config::ConfigStore; + let config = effectcraft_engine::config::DirConfig::new(dir); + if let Some(text) = config.read(effectcraft_engine::prefs::PREFS_FILE) { + s.prefs.auto_save = effectcraft_engine::prefs::Prefs::from_json(&text).auto_save; + } + s.prefs.normalize(); + } if args.flag("--gpu") { let g = effectcraft_gpu::Gpu::try_headless().map_err(|e| Failure::Error(format!("--gpu: no usable GPU adapter ({e})")))?; s.accel = Some(std::sync::Arc::new(g)); @@ -404,6 +416,9 @@ fn emit(v: &Value, json_out: bool) { } fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { + if args.flag("--autosave") && cmd != "mcp" { + return usage_err("--autosave is only supported by mcp"); + } match cmd { "render" => render(args, json_out)?, "bench" => bench_cmd(args)?, @@ -563,9 +578,20 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { } } "mcp" => { + if args.flag("--autosave") && args.opt("--bridge").is_some() { + return usage_err("--autosave is for headless MCP; the bridged app owns its auto-saves"); + } let b = backend(args, false)?; + let server = McpServer::new(b); + let mut server = if args.flag("--autosave") { + let root = + effectcraft_host::config_dir().ok_or_else(|| Failure::Error("--autosave: no config directory; set EFFECTCRAFT_CONFIG_DIR".into()))?; + server.with_autosave(&root)? + } else { + server + }; // A client that closes its end of stdout has ended the session. - match McpServer::new(b).serve_stdio() { + match server.serve_stdio() { Err(e) if e.kind() != std::io::ErrorKind::BrokenPipe => return Err(e.to_string().into()), _ => {} } diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index 74d1fad7..723016a8 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -1,6 +1,6 @@ //! End-to-end tests of the agent CLI: JSON output, project round-trips, rendering and MCP over stdio. -use std::io::Write; +use std::io::{BufRead, BufReader, Write}; use std::path::PathBuf; use std::process::{Command, Stdio}; @@ -296,3 +296,161 @@ fn render_out_is_relative_to_the_working_directory() { assert!(abs.exists()); let _ = std::fs::remove_dir_all(&root); } + +/// A real stdio client: each edit's reply is read before disconnecting or killing the server. +struct McpChild { + child: std::process::Child, + output: BufReader, +} + +impl McpChild { + fn start(config: &std::path::Path, autosave: bool) -> Self { + let mut command = bin(); + command.arg("mcp").env("EFFECTCRAFT_CONFIG_DIR", config).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()); + if autosave { + command.arg("--autosave"); + } + let mut child = command.spawn().unwrap(); + let output = BufReader::new(child.stdout.take().unwrap()); + Self { child, output } + } + fn rpc(&mut self, method: &str, params: Value) -> Value { + writeln!(self.child.stdin.as_mut().unwrap(), "{}", json!({"jsonrpc":"2.0", "id":1, "method":method, "params":params})).unwrap(); + let mut line = String::new(); + assert!(self.output.read_line(&mut line).unwrap() > 0, "server exited without replying"); + let reply: Value = serde_json::from_str(&line).unwrap(); + assert!(reply.get("error").is_none(), "{reply}"); + reply["result"].clone() + } + fn tool(&mut self, name: &str, arguments: Value) -> Value { + let result = self.rpc("tools/call", json!({"name":name,"arguments":arguments})); + assert_eq!(result["isError"], false, "{result}"); + serde_json::from_str(result["content"][0]["text"].as_str().unwrap()).unwrap() + } + fn kill(&mut self) { + self.child.kill().unwrap(); + self.child.wait().unwrap(); + } + fn eof(&mut self) { + drop(self.child.stdin.take()); + assert!(self.child.wait().unwrap().success()); + } +} + +impl Drop for McpChild { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn mcp_config(name: &str) -> PathBuf { + let dir = tmp(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn mcp_autosave_survives_kill_and_restart_without_touching_desktop_settings() { + let config = mcp_config("mcp-kill"); + let prefs = r#"{"autoSave":{"enabled":false,"maxVersions":2,"intervalMinutes":20}}"#; + std::fs::write(config.join("prefs.json"), prefs).unwrap(); + std::fs::write(config.join("shortcuts.json"), "{}").unwrap(); + std::fs::write(config.join("session.lock"), "desktop sentinel").unwrap(); + let mut first = McpChild::start(&config, true); + first.rpc("initialize", json!({})); + first.tool("execute_command", json!({"command":"comp.new","params":{"name":"Survives restart","width":320,"height":180}})); + first.tool("execute_command", json!({"command":"layer.newText","params":{"text":"Unsaved title"}})); + assert_eq!(first.tool("execute_command", json!({"command":"prefs.get","params":{"key":"autoSave.enabled"}})), false); + let status = first.tool("get_project", json!({}))["autosave"].clone(); + let path = status["latestPath"].as_str().unwrap(); + first.kill(); // SIGKILL: there is no opportunity to save at EOF. + let folder = std::path::Path::new(status["folder"].as_str().unwrap()); + assert_eq!(effectcraft_engine::autosave::existing(folder, "Untitled Project").len(), 2, "loaded maxVersions is respected"); + let manifest: Value = serde_json::from_str(&std::fs::read_to_string(folder.join("session.json")).unwrap()).unwrap(); + assert_eq!(manifest["dirty"], true); + assert_eq!(manifest["ended"], false); + let mut restarted = McpChild::start(&config, true); + let init = restarted.rpc("initialize", json!({})); + assert!(init["instructions"].as_str().unwrap().contains(path)); + assert_eq!(init["_meta"]["effectcraftAutoSave"]["previousSessions"][0]["autosave"], path); + restarted.tool("open_project", json!({"path":path})); + let comp = restarted.tool("get_comp", json!({"comp":"Survives restart"})); + assert_eq!(comp["layers"].as_array().unwrap().len(), 1); + let layer = restarted.tool("get_layer", json!({"layer":"#1"})); + assert!(layer.to_string().contains("Unsaved title"), "{layer}"); + restarted.tool("save_project", json!({"path":config.join("Recovered.ecproj")})); + restarted.eof(); + assert_eq!(std::fs::read_to_string(config.join("prefs.json")).unwrap(), prefs); + assert_eq!(std::fs::read_to_string(config.join("shortcuts.json")).unwrap(), "{}"); + assert_eq!(std::fs::read_to_string(config.join("session.lock")).unwrap(), "desktop sentinel"); + std::fs::remove_dir_all(config).unwrap(); +} + +#[test] +fn mcp_autosave_eof_preserves_titled_edits_without_overwriting_project() { + let config = mcp_config("mcp-eof"); + let original = config.join("Original.ecproj"); + let mut first = McpChild::start(&config, true); + first.tool("execute_command", json!({"command":"comp.new","params":{"name":"Original"}})); + first.tool("save_project", json!({"path":original})); + let original_bytes = std::fs::read(&original).unwrap(); + first.tool("execute_command", json!({"command":"layer.newText","params":{"text":"Not manually saved"}})); + let status = first.tool("get_project", json!({}))["autosave"].clone(); + first.eof(); + assert_eq!(std::fs::read(original).unwrap(), original_bytes); + assert!(std::fs::read_to_string(status["latestPath"].as_str().unwrap()).unwrap().contains("Not manually saved")); + let folder = std::path::Path::new(status["folder"].as_str().unwrap()); + let manifest: Value = serde_json::from_str(&std::fs::read_to_string(folder.join("session.json")).unwrap()).unwrap(); + assert_eq!(manifest["ended"], true); + assert_eq!(manifest["dirty"], true); + std::fs::remove_dir_all(config).unwrap(); +} + +#[test] +fn mcp_autosave_preserves_work_when_stdout_is_closed() { + let config = mcp_config("mcp-broken-pipe"); + let mut child = bin() + .args(["mcp", "--autosave"]) + .env("EFFECTCRAFT_CONFIG_DIR", &config) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + drop(child.stdout.take()); + { + let mut input = child.stdin.take().unwrap(); + writeln!(input, "{}", json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_command","arguments":{"command":"comp.new","params":{"name":"Closed stdout"}}}})).unwrap(); + } + let out = child.wait_with_output().unwrap(); + assert_eq!(out.status.code(), Some(0), "{}", String::from_utf8_lossy(&out.stderr)); + let base = config.join(effectcraft_engine::autosave::AUTOSAVE_FOLDER).join("MCP"); + let folder = std::fs::read_dir(base).unwrap().next().unwrap().unwrap().path(); + let manifest: Value = serde_json::from_str(&std::fs::read_to_string(folder.join("session.json")).unwrap()).unwrap(); + assert!(std::fs::read_to_string(manifest["autosave"].as_str().unwrap()).unwrap().contains("Closed stdout")); + assert_eq!(manifest["ended"], true); + std::fs::remove_dir_all(config).unwrap(); +} + +#[test] +fn mcp_autosave_is_opt_in_and_rejects_invalid_combinations() { + let config = mcp_config("mcp-opt-in"); + let mut client = McpChild::start(&config, false); + client.tool("execute_command", json!({"command":"comp.new","params":{"name":"Ephemeral"}})); + client.eof(); + assert!(std::fs::read_dir(&config).unwrap().next().is_none()); + for args in [["mcp", "--autosave", "--bridge", "1"].as_slice(), ["info", "--autosave"].as_slice()] { + let out = bin().args(args).env("EFFECTCRAFT_CONFIG_DIR", &config).output().unwrap(); + assert_eq!(out.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&out.stderr).contains("--autosave")); + } + let blocked = config.join("file-not-directory"); + std::fs::write(&blocked, "file").unwrap(); + let out = bin().args(["mcp", "--autosave"]).env("EFFECTCRAFT_CONFIG_DIR", blocked).output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&out.stderr).contains("MCP auto-save")); + assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked")); + std::fs::remove_dir_all(config).unwrap(); +} diff --git a/contributors/people.toml b/contributors/people.toml index fd3d0317..5e120a33 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -26,3 +26,6 @@ sync_github_name = false real_name = "Gert Pellin" display_name = "Gert Pellin" sync_github_name = false + +[people.jonassneu] +sync_github_name = true diff --git a/crates/automation/src/autosave.rs b/crates/automation/src/autosave.rs new file mode 100644 index 00000000..b3f2e231 --- /dev/null +++ b/crates/automation/src/autosave.rs @@ -0,0 +1,136 @@ +//! 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, + error: Option, + previous: Vec, + manifest: Option, +} + +impl AutoSave { + pub fn new(root: &Path, prefs: &Prefs) -> std::io::Result { + 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 { + 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 { + // 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() +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index b4040bab..9f38516e 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -15,6 +15,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod autosave; pub mod backend; pub mod base64; pub mod bridge; diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 2a6ea687..3cb424dc 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -2,9 +2,11 @@ //! `initialize`, `ping`, `tools/list` and `tools/call`; notifications are accepted and ignored. use std::io::{BufRead, Write}; +use std::path::Path; use serde_json::{Value, json}; +use crate::autosave::AutoSave; use crate::tools::{self, Reply}; use crate::{Backend, Error, base64}; @@ -21,11 +23,34 @@ const INVALID_PARAMS: i64 = -32602; pub struct McpServer { backend: Backend, + autosave: Option, } impl McpServer { pub fn new(backend: Backend) -> Self { - Self { backend } + Self { backend, autosave: None } + } + + /// Opt in to durable headless checkpoints in a separate folder per server. + /// `root` is the platform config directory; settings are read by the caller. + pub fn with_autosave(mut self, root: &Path) -> crate::Result { + let s = self.backend.session().ok_or_else(|| crate::Error::BadArgs("--autosave is for headless MCP; the bridged app owns its auto-saves".into()))?; + s.autosave.background = false; + let autosave = AutoSave::new(root, &s.prefs).map_err(|e| crate::Error::Other(format!("MCP auto-save: {e}")))?; + s.autosave_folder_override = Some(autosave.folder().to_path_buf()); + self.autosave = Some(autosave); + Ok(self) + } + + fn autosave_info(&self) -> Value { + self.autosave.as_ref().map(AutoSave::info).unwrap_or_else(|| json!({"enabled": false})) + } + + fn checkpoint(&mut self, finish: bool) -> std::io::Result<()> { + match (&mut self.autosave, self.backend.session()) { + (Some(a), Some(s)) => a.checkpoint(s, finish), + _ => Ok(()), + } } pub fn backend(&mut self) -> &mut Backend { @@ -73,14 +98,21 @@ impl McpServer { /// Serve until EOF on `input`. pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { - for line in input.lines() { - if let Some(reply) = self.handle_line(&line?) { - output.write_all(reply.as_bytes())?; - output.write_all(b"\n")?; - output.flush()?; + let served = (|| { + for line in input.lines() { + if let Some(reply) = self.handle_line(&line?) { + output.write_all(reply.as_bytes())?; + output.write_all(b"\n")?; + output.flush()?; + } } + Ok(()) + })(); + match (served, self.checkpoint(true)) { + (r, Ok(())) => r, + (Ok(()), Err(e)) => Err(e), + (Err(transport), Err(save)) => Err(std::io::Error::other(format!("{transport}; {save}"))), } - Ok(()) } /// Serve on stdin/stdout (the MCP stdio transport). @@ -94,11 +126,17 @@ impl McpServer { let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(""); let version = PROTOCOL_VERSIONS.iter().find(|v| **v == asked).copied().unwrap_or(PROTOCOL_VERSIONS[0]); let mode = if self.backend.is_bridge() { "bridge" } else { "headless" }; + let instructions = if self.autosave.is_some() { + format!("{INSTRUCTIONS} Headless auto-save and recovery: {}", self.autosave_info()) + } else { + INSTRUCTIONS.to_string() + }; Ok(json!({ "protocolVersion": version, "capabilities": {"tools": {"listChanged": false}}, "serverInfo": {"name": "effectcraft", "title": format!("EffectCraft ({mode})"), "version": env!("CARGO_PKG_VERSION")}, - "instructions": INSTRUCTIONS, + "instructions": instructions, + "_meta": {"effectcraftAutoSave": self.autosave_info()}, })) } "ping" => Ok(json!({})), @@ -113,7 +151,30 @@ impl McpServer { return Err((INVALID_PARAMS, format!("unknown tool `{name}`{hint}"))); } let args = params.get("arguments").cloned().unwrap_or(json!({})); - Ok(call_result(tools::run(&mut self.backend, name, &args))) + let mut result = call_result(tools::run(&mut self.backend, name, &args)); + // Also checkpoint a partially successful tool that returned an error. The + // project is durable before the client receives its reply (even if it kills + // the process immediately afterwards). + if let Err(e) = self.checkpoint(false) { + let _ = writeln!(std::io::stderr(), "{e}"); + if let Some(content) = result.get_mut("content").and_then(Value::as_array_mut) { + content.push(json!({"type": "text", "text": format!("Warning: {e}. Use save_project with a writable path before disconnecting.")})); + } + } + if self.autosave.is_some() { + result["_meta"]["effectcraftAutoSave"] = self.autosave_info(); + } + if self.autosave.is_some() + && name == "get_project" + && result["isError"] == false + && let Some(text) = result.get_mut("content").and_then(Value::as_array_mut).and_then(|c| c.first_mut()).and_then(|c| c.get_mut("text")) + && let Some(summary) = text.as_str().and_then(|t| serde_json::from_str::(t).ok()) + { + let mut summary = summary; + summary["autosave"] = self.autosave_info(); + *text = Value::String(summary.to_string()); + } + Ok(result) } // Advertised as absent, but answer politely for clients that probe anyway. "resources/list" => Ok(json!({"resources": []})), diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 1364d920..7bea5004 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -218,6 +218,167 @@ fn stdio_loop() { assert_eq!(lines[1]["result"]["isError"], false); } +/// Regression for #258: a dirty headless project must survive an MCP disconnect. +#[test] +fn headless_autosave_on_eof_preserves_unsaved_project() { + let dir = std::env::temp_dir().join(format!("ec-mcp-autosave-eof-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = server().with_autosave(&dir).unwrap(); + let input = [ + json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "execute_command", "arguments": {"command": "comp.new", "params": {"name": "Keep my work", "width": 320, "height": 180}}}}), + json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "execute_command", "arguments": {"command": "layer.newText", "params": {"text": "Unsaved title"}}}}), + ].iter().map(|v| v.to_string() + "\n").collect::(); + let mut output = Vec::new(); + s.serve(input.as_bytes(), &mut output).unwrap(); + let path = s.backend().session().unwrap().autosave.last_path.clone(); + assert!(path.is_some(), "disconnect discarded the dirty project without an auto-save"); + let path = path.unwrap(); + let mut fresh = server(); + call_json(&mut fresh, "open_project", json!({"path": path})); + let comp = call_json(&mut fresh, "get_comp", json!({"comp": "Keep my work"})); + assert_eq!(comp["layers"].as_array().unwrap().len(), 1); + assert!(s.backend().session().unwrap().is_dirty(), "an auto-save must not mark the original project saved"); + std::fs::remove_dir_all(dir).unwrap(); +} + +fn autosave_test_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("ec-mcp-autosave-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn autosave_status(s: &mut McpServer) -> Value { + rpc(s, 1, "initialize", json!({}))["_meta"]["effectcraftAutoSave"].clone() +} + +#[test] +fn autosave_is_durable_before_reply_and_queries_do_not_rotate() { + let dir = autosave_test_dir("durable"); + let mut s = server().with_autosave(&dir).unwrap(); + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Durable"}})); + let first = autosave_status(&mut s); + let path = first["latestPath"].as_str().unwrap(); + assert!(std::fs::read_to_string(path).unwrap().contains("Durable")); + let summary = call_json(&mut s, "get_project", json!({})); + assert_eq!(summary["autosave"]["latestPath"], path); + assert_eq!(summary["path"], Value::Null); + assert_eq!(summary["dirty"], true); + assert_eq!(autosave_status(&mut s)["latestPath"], path); + assert!(s.backend().session().unwrap().config.is_none()); + drop(s); // Abrupt process loss has no EOF cleanup; the checkpoint already exists. + let mut restarted = server().with_autosave(&dir).unwrap(); + let init = rpc(&mut restarted, 1, "initialize", json!({})); + assert!(init["instructions"].as_str().unwrap().contains(path)); + assert_eq!(init["_meta"]["effectcraftAutoSave"]["previousSessions"][0]["autosave"], path); + call_json(&mut restarted, "open_project", json!({"path": path})); + assert_eq!(call_json(&mut restarted, "get_project", json!({}))["items"][0]["name"], "Durable"); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn autosave_errors_preserve_tool_success_retry_and_fail_shutdown() { + let dir = autosave_test_dir("write-error"); + let mut s = server().with_autosave(&dir).unwrap(); + let folder = std::path::PathBuf::from(autosave_status(&mut s)["folder"].as_str().unwrap()); + std::fs::remove_dir(&folder).unwrap(); + std::fs::write(&folder, "blocked by file").unwrap(); + let (content, failed) = call(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Still in memory"}})); + assert!(!failed, "the edit succeeded even though its checkpoint failed"); + assert!(content.iter().any(|c| c["text"].as_str().unwrap_or("").contains("Warning:"))); + assert!(s.backend().session().unwrap().is_dirty()); + assert!(autosave_status(&mut s)["error"].as_str().unwrap().contains("failed")); + let error = s.serve(&b""[..], Vec::new()).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::Other); + assert!(error.to_string().contains("MCP auto-save")); + std::fs::remove_file(&folder).unwrap(); + // Even a query retries the outstanding dirty revision. + let summary = call_json(&mut s, "get_project", json!({})); + assert_eq!(summary["autosave"]["error"], Value::Null); + let path = summary["autosave"]["latestPath"].as_str().unwrap(); + assert!(std::fs::read_to_string(path).unwrap().contains("Still in memory")); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn autosave_keeps_sessions_and_desktop_recovery_separate() { + let dir = autosave_test_dir("isolated"); + let desktop_save = dir.join(effectcraft_engine::autosave::AUTOSAVE_FOLDER).join("Untitled Project auto-save 1.ecproj"); + std::fs::create_dir_all(desktop_save.parent().unwrap()).unwrap(); + std::fs::write(&desktop_save, "desktop backup").unwrap(); + std::fs::write(dir.join("session.lock"), "desktop sentinel").unwrap(); + let mut a = server().with_autosave(&dir).unwrap(); + let mut b = server().with_autosave(&dir).unwrap(); + assert_ne!(autosave_status(&mut a)["folder"], autosave_status(&mut b)["folder"]); + for index in 0..7 { + for (s, name) in [(&mut a, "A"), (&mut b, "B")] { + call_json(s, "execute_command", json!({"command": "comp.new", "params": {"name": format!("{name}{index}")}})); + } + } + for (s, mine, other) in [(&mut a, "A6", "B6"), (&mut b, "B6", "A6")] { + let status = autosave_status(s); + let json = std::fs::read_to_string(status["latestPath"].as_str().unwrap()).unwrap(); + assert!(json.contains(mine)); + assert!(!json.contains(other)); + let folder = std::path::Path::new(status["folder"].as_str().unwrap()); + assert_eq!(effectcraft_engine::autosave::existing(folder, "Untitled Project").len(), 5); + } + assert_eq!(std::fs::read_to_string(desktop_save).unwrap(), "desktop backup"); + assert_eq!(std::fs::read_to_string(dir.join("session.lock")).unwrap(), "desktop sentinel"); + assert!(!dir.join("prefs.json").exists()); + assert!(!dir.join("shortcuts.json").exists()); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn autosave_marks_saved_or_undone_work_clean_without_losing_its_path() { + let dir = autosave_test_dir("clean"); + let mut s = server().with_autosave(&dir).unwrap(); + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Undo me"}})); + let status = autosave_status(&mut s); + let folder = std::path::Path::new(status["folder"].as_str().unwrap()); + call_json(&mut s, "undo", json!({})); + let manifest: Value = serde_json::from_str(&std::fs::read_to_string(folder.join("session.json")).unwrap()).unwrap(); + assert_eq!(manifest["dirty"], false); + call_json(&mut s, "redo", json!({})); + let path = autosave_status(&mut s)["latestPath"].clone(); + call_json(&mut s, "save_project", json!({"path": dir.join("Saved.ecproj")})); + assert_eq!(autosave_status(&mut s)["latestPath"], path, "save resets the engine bookkeeping but the server remembers the recovery file"); + let manifest: Value = serde_json::from_str(&std::fs::read_to_string(folder.join("session.json")).unwrap()).unwrap(); + assert_eq!(manifest["dirty"], false); + let mut next = server().with_autosave(&dir).unwrap(); + assert!(autosave_status(&mut next)["previousSessions"].as_array().unwrap().is_empty()); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn autosave_flushes_on_read_errors_and_broken_output() { + struct Broken; + impl Write for Broken { + fn write(&mut self, _: &[u8]) -> std::io::Result { + Err(std::io::ErrorKind::BrokenPipe.into()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let dir = autosave_test_dir("transports"); + for invalid_input in [true, false] { + let mut s = server().with_autosave(&dir).unwrap(); + // Simulate an edit whose client went away before it received a tool response. + s.backend().exec("comp.new", json!({"name": "Transport failure"})).unwrap(); + let error = if invalid_input { + s.serve(&b"\xff\n"[..], Vec::new()).unwrap_err() + } else { + s.serve(&b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n"[..], Broken).unwrap_err() + }; + assert_eq!(error.kind(), if invalid_input { std::io::ErrorKind::InvalidData } else { std::io::ErrorKind::BrokenPipe }); + let status = autosave_status(&mut s); + assert!(std::fs::read_to_string(status["latestPath"].as_str().unwrap()).unwrap().contains("Transport failure")); + } + std::fs::remove_dir_all(dir).unwrap(); +} + /// A fake desktop control channel: answers `engine.execute` from a real session, and canned /// `render.frame` / `ui.screenshot` replies. fn fake_app() -> u16 { @@ -507,3 +668,69 @@ fn agents_doc_tools_table_is_current() { } assert!(missing.is_empty(), "docs/agents.md's Tools table is missing: {missing:#?}"); } + +#[test] +fn autosave_checkpoints_batch_and_partially_failed_tools() { + let dir = autosave_test_dir("partial"); + let mut s = server().with_autosave(&dir).unwrap(); + let msgs = json!([ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_command","arguments":{"command":"comp.new","params":{"name":"Batch"}}}}, + {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"execute_command","arguments":{"command":"layer.newText","params":{"text":"Batch title"}}}} + ]); + let reply = s.handle(&msgs).unwrap(); + assert_eq!(reply.as_array().unwrap().len(), 2); + let (_, failed) = call(&mut s, "add_keyframe", json!({"layer":"#1","path":"transform/opacity","keys":[{"time":0,"value":37}],"interpolation":"invalid"})); + assert!(failed, "the key was added, then the interpolation failed"); + let status = autosave_status(&mut s); + let mut recovered = server(); + call_json(&mut recovered, "open_project", json!({"path":status["latestPath"]})); + let prop = call_json(&mut recovered, "get_property", json!({"layer":"#1","path":"transform/opacity"})); + assert_eq!(prop["keys"].as_array().unwrap().len(), 1); + assert_eq!(prop["keys"][0]["value"], 37.0); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn autosave_isolation_also_applies_to_engine_autosaves_and_preference_changes() { + let dir = autosave_test_dir("engine-save"); + let desktop = dir.join("DesktopCustom"); + std::fs::create_dir_all(&desktop).unwrap(); + std::fs::write(desktop.join("Untitled Project auto-save 1.ecproj"), "desktop backup").unwrap(); + let mut session = Session::default(); + session.prefs.auto_save.location = "custom".into(); + session.prefs.auto_save.folder = desktop.to_string_lossy().into_owned(); + let mut s = McpServer::new(Backend::headless(session)).with_autosave(&dir).unwrap(); + call_json(&mut s, "execute_command", json!({"command":"comp.new","params":{"name":"Isolated"}})); + let status = autosave_status(&mut s); + let explicit = call_json(&mut s, "execute_command", json!({"command":"file.autoSave"})); + assert!(std::path::Path::new(explicit["path"].as_str().unwrap()).starts_with(status["folder"].as_str().unwrap())); + assert_eq!(autosave_status(&mut s)["latestPath"], explicit["path"]); + // The host override survives preference edits even within a single batch tool call. + call_json( + &mut s, + "batch", + json!({"steps":[ + {"command":"prefs.set","params":{"values":{"autoSave.location":"custom","autoSave.folder":desktop.to_string_lossy()}}}, + {"command":"file.autoSave","params":{}} + ]}), + ); + call_json(&mut s, "save_project", json!({"path":dir.join("Named.ecproj")})); + call_json(&mut s, "execute_command", json!({"command":"layer.newText","params":{"text":"Latest isolated edit"}})); + let current = autosave_status(&mut s); + assert!(std::path::Path::new(current["latestPath"].as_str().unwrap()).starts_with(status["folder"].as_str().unwrap())); + assert!(std::fs::read_to_string(current["latestPath"].as_str().unwrap()).unwrap().contains("Latest isolated edit")); + let recovery = call_json(&mut s, "execute_command", json!({"command":"file.recoveryInfo"})); + assert_eq!(recovery["folder"], status["folder"]); + assert_eq!(std::fs::read_to_string(desktop.join("Untitled Project auto-save 1.ecproj")).unwrap(), "desktop backup"); + assert_eq!(std::fs::read_dir(&desktop).unwrap().count(), 2, "only the original desktop file and the MCP subfolder"); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(unix)] +#[test] +fn autosave_non_utf8_path_returns_an_error_instead_of_panicking() { + use std::os::unix::ffi::OsStringExt; + let root = std::path::PathBuf::from(std::ffi::OsString::from_vec(b"/tmp/ec-mcp-\xff".to_vec())); + let result = server().with_autosave(&root); + assert!(matches!(result, Err(crate::Error::Other(message)) if message.contains("UTF-8"))); +} diff --git a/crates/engine/src/commands/settings.rs b/crates/engine/src/commands/settings.rs index f5030d45..320a6c18 100644 --- a/crates/engine/src/commands/settings.rs +++ b/crates/engine/src/commands/settings.rs @@ -239,11 +239,12 @@ fn auto_save(s: &mut Session, _: &Value) -> Result { fn recovery_info(s: &mut Session, _: &Value) -> Result { let root = s.default_autosave_root(); - let latest = crate::autosave::latest_in(s.file_ops(), &s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string()); + let prefs = s.autosave_prefs(); + let latest = crate::autosave::latest_in(s.file_ops(), &prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string()); Ok(json!({ "lastAutoSave": s.autosave.last_path, "latestOnDisk": latest, - "folder": crate::autosave::folder(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string()), + "folder": crate::autosave::folder(&prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string()), "recentProjects": s.prefs.recent_projects, })) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 93fb4c19..f9c7bcea 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -396,6 +396,9 @@ pub struct Session { pub shortcut_table: std::sync::OnceLock, /// Auto-save bookkeeping. pub autosave: autosave::AutoSaveState, + /// Host-owned isolation for all auto-saves (e.g. one MCP session). Independent of settings + /// and project bookkeeping, so opening/saving a project or changing preferences keeps it. + pub autosave_folder_override: Option, /// The viewer snapshot (Take Snapshot / Show Snapshot). pub snapshot: Option, /// The JavaScript scripting engine (set by the host that links `effectcraft-script`): @@ -504,6 +507,7 @@ impl Default for Session { ui_commands: vec![], shortcut_table: std::sync::OnceLock::new(), autosave: autosave::AutoSaveState::default(), + autosave_folder_override: None, snapshot: None, script: None, plugin_loader: None, diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index 281c5ab4..88e1ce18 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -200,6 +200,17 @@ impl Session { } } + /// Auto-save preferences with the host's isolation folder applied, without changing or + /// persisting the user's settings. + pub fn autosave_prefs(&self) -> crate::prefs::Prefs { + let mut prefs = self.prefs.clone(); + if let Some(folder) = &self.autosave_folder_override { + prefs.auto_save.location = "custom".into(); + prefs.auto_save.folder = folder.to_string_lossy().into_owned(); + } + prefs + } + /// Write an auto-save now (whether or not the project is dirty). Returns its path. /// /// With [`autosave::AutoSaveState::background`] the project is serialised and written on a @@ -209,8 +220,9 @@ impl Session { // One write at a time: the previous one must land before its slot rotates. self.autosave_wait()?; let root = self.default_autosave_root(); + let prefs = self.autosave_prefs(); let fail = |e: std::io::Error| EngineError::Other(format!("auto-save failed: {e}")); - let plan = autosave::plan_in(self.file_ops(), &self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot).map_err(fail)?; + let plan = autosave::plan_in(self.file_ops(), &prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot).map_err(fail)?; if self.autosave.background && !cfg!(target_arch = "wasm32") { let project = self.project.clone(); let config = self.config.clone(); @@ -267,7 +279,8 @@ impl Session { if !self.is_dirty() || self.autosave.saved_revision == Some(self.revision) || self.render_job.is_some() { return None; } - if self.path.is_none() && self.prefs.auto_save.location != "custom" && self.default_autosave_root().is_none() { + if self.path.is_none() && self.prefs.auto_save.location != "custom" && self.default_autosave_root().is_none() && self.autosave_folder_override.is_none() + { return None; } let r = self.autosave_now(); diff --git a/docs/agents.md b/docs/agents.md index 52c46d3a..a34e29d1 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -46,6 +46,33 @@ Desktop, Cursor and the like) take the same `command` and `args`. The server speaks JSON-RPC 2.0 over stdio, one message per line, and supports MCP protocol versions 2025-06-18, 2025-03-26 and 2024-11-05 (`initialize`, `ping`, `tools/list`, `tools/call`). +### Keeping headless work across restarts + +Start `effectcraft-cli mcp --autosave` (MCP args `["mcp", "--autosave"]`) to preserve +unsaved headless work. Each tool call that changes a dirty project writes a checkpoint before +its reply is sent, so even killing the server after that reply leaves the work on disk. EOF +and transport errors also flush outstanding changes. Queries do not rotate the saved versions. +A failed checkpoint adds a warning to the tool reply; the edit remains in memory and the next +call retries. Use `save_project {path}` to a writable location before disconnecting if warned. + +Each server has its own folder under `/EffectCraft Auto-Save/MCP/` (or +`/MCP/`). The maximum-version setting applies within that folder. +`--autosave` enables these checkpoints regardless of the desktop's Auto-Save toggle and interval; +it reads settings without writing desktop preferences, shortcuts or crash-recovery state. +`EFFECTCRAFT_CONFIG_DIR` overrides the platform config directory. Session folders are retained +for recovery; remove old folders once their work is saved elsewhere. + +`initialize` instructions and `_meta.effectcraftAutoSave` list the current folder and up to five +previous dirty sessions; `get_project.autosave` also reports the latest checkpoint and errors. +Recover explicitly with `open_project {"path":""}`, then +`save_project {"path":"Recovered.ecproj"}`. Prior sessions may still be running and are never +opened automatically. Replacing a dirty project with `open_project` or `file.newProject` discards +it intentionally; save it first. The metadata describes the current project in each session. + +Without `--autosave`, headless work is in memory until explicitly saved. The flag is headless +only; a bridged desktop app owns its auto-saves. An edit interrupted before its reply is received +may be absent from the last checkpoint. + ### Tools | Tool | What it does | diff --git a/docs/preferences.md b/docs/preferences.md index dc57595c..2ee65553 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -189,7 +189,7 @@ from?, newName?}`, `shortcuts.export {preset?, path?}`, `shortcuts.import {path? - Every *n* minutes (Settings ▸ Project ▸ Auto-Save) a project with unsaved changes is written to an `EffectCraft Auto-Save` folder next to it (or the custom folder) as ` auto-save N.ecproj`. Slots rotate through 1…maximum versions, overwriting the oldest. - Untitled projects go to the custom folder, or to `Auto-Save` in the config directory. + Untitled projects go to the custom folder, or to `EffectCraft Auto-Save` in the config directory. - Every project and auto-save write is atomic: a temporary file is written and flushed, then renamed over the target, so a crash mid-write leaves the previous file intact. - While the app runs, `session.lock` in the config directory records the open project and its @@ -198,5 +198,12 @@ from?, newName?}`, `shortcuts.export {preset?, path?}`, `shortcuts.import {path? - File ▸ Open Recent lists recent projects (stored in the settings), File ▸ Revert reloads the saved project, File ▸ Increment and Save saves `Intro.ecproj` as `Intro 2.ecproj`. +Headless MCP clients can opt in with `effectcraft-cli mcp --autosave`. Unlike the desktop's +interval, this writes changed dirty projects before sending each tool reply, with separate +version slots per server under `EffectCraft Auto-Save/MCP/` (or the custom folder's `MCP/`). +It reads settings without sharing the desktop's writable settings store or `session.lock`. +See [headless recovery](agents.md#keeping-headless-work-across-restarts) for how to reopen a +checkpoint after a client restart. + Commands: `file.autoSave` (now), `file.recoveryInfo`, `file.openRecent {index? | path?}`, `file.clearRecent`, `file.revert`, `file.incrementAndSave`.