Merge pull request #280 from jonassneu/fix/mcp-autosave
MCP: preserve unsaved work across server restarts (#258) # Conflicts: # apps/effectcraft-cli/tests/cli.rs # contributors/people.toml
This commit is contained in:
commit
6c838325fa
|
|
@ -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 <info|commands|exec|run|props|get|se
|
|||
script FILE.jsx [F.ecproj] | --eval CODE run JavaScript with the After Effects-style object model
|
||||
(app.project, comps, layers, properties…); prints writeLn
|
||||
output and the result; errors exit 1 with file:line:col
|
||||
mcp [--bridge PORT] MCP server (JSON-RPC over stdio)
|
||||
mcp [--autosave | --bridge PORT] MCP server (JSON-RPC over stdio); --autosave preserves unsaved headless work
|
||||
options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge PORT --json --gpu
|
||||
<comp>: id or name, '-' = active comp; <layer>: id, '#n' or name; <value>: 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<Backend, Failure> {
|
|||
/// renderer setting, Mercury GPU Acceleration by default).
|
||||
fn session(args: &Args) -> Result<effectcraft_engine::Session, Failure> {
|
||||
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()),
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<std::process::ChildStdout>,
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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()
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<AutoSave>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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::<Value>(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": []})),
|
||||
|
|
|
|||
|
|
@ -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::<String>();
|
||||
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<usize> {
|
||||
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")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,11 +239,12 @@ fn auto_save(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
|
||||
fn recovery_info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -396,6 +396,9 @@ pub struct Session {
|
|||
pub shortcut_table: std::sync::OnceLock<shortcuts::ShortcutTable>,
|
||||
/// 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<std::path::PathBuf>,
|
||||
/// The viewer snapshot (Take Snapshot / Show Snapshot).
|
||||
pub snapshot: Option<viewer::Snapshot>,
|
||||
/// 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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 `<config directory>/EffectCraft Auto-Save/MCP/` (or
|
||||
`<custom Auto-Save folder>/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":"<checkpoint .ecproj>"}`, 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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`<name> 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`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue