mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Fix #258: preserve unsaved headless MCP work with opt-in checkpoints
This commit is contained in:
1 parent
52ab38fcff
commit
9d9f8ecbb4
12 files changed
+682
-18
No files matched your search
@@ -21,7 +21,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
|
||||
@@ -109,7 +109,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";
|
||||
|
||||
@@ -173,6 +173,7 @@ const FLAGS: &[&str] = &[
|
||||
"--ops",
|
||||
"--small",
|
||||
"--adv3d",
|
||||
"--autosave",
|
||||
];
|
||||
|
||||
struct Args {
|
||||
@@ -321,6 +322,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));
|
||||
@@ -402,6 +414,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)?,
|
||||
@@ -561,9 +576,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};
|
||||
|
||||
@@ -247,3 +247,161 @@ fn script_file_and_eval() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains(".jsxbin scripts are not supported: run the .jsx source") && !err.contains("SyntaxError"), "{err}");
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
Reference in new issue
Block a user