From 4a434f78555d63336006939068bcff10d37da9c4 Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:11:26 +0300 Subject: [PATCH] Autosave named scenes with revision and disk conflict safety --- CMakeLists.txt | 5 +- include/faset/authoring/service.hpp | 3 +- include/faset/editor/autosave.hpp | 35 ++++++++ include/faset/editor/session.hpp | 3 + src/authoring/service.cpp | 5 +- src/editor/autosave.cpp | 88 ++++++++++++++++++++ src/editor/commands.cpp | 8 +- src/editor/session.cpp | 12 ++- tests/editor_autosave_tests.cpp | 119 ++++++++++++++++++++++++++++ tests/editor_session_tests.cpp | 18 +++++ 10 files changed, 290 insertions(+), 6 deletions(-) create mode 100644 include/faset/editor/autosave.hpp create mode 100644 src/editor/autosave.cpp create mode 100644 tests/editor_autosave_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 9152957..082e26a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,13 +74,16 @@ if(TARGET faset_assets AND TARGET faset_authoring AND EXISTS "${PROJECT_SOURCE_D endif() if(TARGET faset_editor_commands AND TARGET faset_build_service) include(cmake/Plugins.cmake) - add_library(faset_editor_session STATIC src/editor/session.cpp) + add_library(faset_editor_session STATIC src/editor/session.cpp src/editor/autosave.cpp) target_link_libraries(faset_editor_session PUBLIC faset_editor_commands faset_build_service faset_assets faset_editor_plugins) if(BUILD_TESTING) add_executable(faset_editor_session_tests tests/editor_session_tests.cpp) target_link_libraries(faset_editor_session_tests PRIVATE faset_editor_session) target_compile_definitions(faset_editor_session_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}") add_test(NAME editor_session_settings COMMAND faset_editor_session_tests) + add_executable(faset_editor_autosave_tests tests/editor_autosave_tests.cpp) + target_link_libraries(faset_editor_autosave_tests PRIVATE faset_editor_session) + add_test(NAME editor_autosave COMMAND faset_editor_autosave_tests) endif() endif() foreach(module UI EditorUI Editor Applications) diff --git a/include/faset/authoring/service.hpp b/include/faset/authoring/service.hpp index cd0f27f..993f74b 100644 --- a/include/faset/authoring/service.hpp +++ b/include/faset/authoring/service.hpp @@ -25,7 +25,8 @@ class AuthoringService { const Json& operations, const std::string& idempotency_key = ""); Json undo(const std::string& document, std::uint64_t expected_revision); Json redo(const std::string& document, std::uint64_t expected_revision); - Json save(const std::string& document, const std::filesystem::path& relative = {}); + Json save(const std::string& document, const std::filesystem::path& relative = {}, + std::optional expected_revision = std::nullopt); Json recovery_documents() const; Json recover(const std::string& document, std::optional expected_revision = std::nullopt); diff --git a/include/faset/editor/autosave.hpp b/include/faset/editor/autosave.hpp new file mode 100644 index 0000000..92e0cea --- /dev/null +++ b/include/faset/editor/autosave.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace faset::editor { + +class AutosaveController { + public: + using Clock = std::chrono::steady_clock; + using SaveFn = std::function; + explicit AutosaveController(SaveFn save); + + // Called from the Editor event loop with an injected monotonic clock for + // deterministic tests. Only a named dirty scene becomes eligible to save. + void observe(const Json& documents, Clock::time_point now, bool enabled); + Json status() const; + + private: + struct Entry { + std::uint64_t revision{}; + std::string path, state = "saved", error; + Clock::time_point deadline{}; + }; + SaveFn save_; + mutable std::mutex mutex_; + std::map entries_; + bool enabled_ = true; +}; + +} // namespace faset::editor diff --git a/include/faset/editor/session.hpp b/include/faset/editor/session.hpp index 3790084..b53173f 100644 --- a/include/faset/editor/session.hpp +++ b/include/faset/editor/session.hpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -62,6 +63,8 @@ class Session { Commands commands_; assets::AssetPipeline assets_; BuildService builds_; + AutosaveController autosave_; + bool autosave_enabled_ = true; std::map> imports_; std::vector workers_; std::vector logs_; diff --git a/src/authoring/service.cpp b/src/authoring/service.cpp index e099f23..f5b1d43 100644 --- a/src/authoring/service.cpp +++ b/src/authoring/service.cpp @@ -573,9 +573,12 @@ Json AuthoringService::undo(const std::string& id, std::uint64_t revision) { Json AuthoringService::redo(const std::string& id, std::uint64_t revision) { return history(id, revision, true); } -Json AuthoringService::save(const std::string& id, const std::filesystem::path& relative) { +Json AuthoringService::save(const std::string& id, const std::filesystem::path& relative, + std::optional expected_revision) { std::lock_guard lock(mutex_); auto& current = state(id); + if (expected_revision) + check_revision(current.revision, *expected_revision); const auto selected = relative.empty() ? current.path : relative.lexically_normal(); require(!selected.empty(), "save.path", "Choose a scene path before saving"); const auto path = project_path(root_, selected); diff --git a/src/editor/autosave.cpp b/src/editor/autosave.cpp new file mode 100644 index 0000000..e3e17c3 --- /dev/null +++ b/src/editor/autosave.cpp @@ -0,0 +1,88 @@ +#include + +#include +#include +#include + +namespace faset::editor { + +AutosaveController::AutosaveController(SaveFn save) : save_(std::move(save)) { + if (!save_) + throw std::invalid_argument("Autosave requires a save callback"); +} + +void AutosaveController::observe(const Json& documents, Clock::time_point now, bool enabled) { + std::lock_guard lock(mutex_); + enabled_ = enabled; + std::set present; + for (const auto& document : documents) { + const auto id = document.at("id").get(); + const auto revision = document.at("revision").get(); + const auto path = document.value("path", std::string()); + const auto dirty = document.value("dirty", false); + present.insert(id); + auto [found, inserted] = entries_.try_emplace(id); + auto& entry = found->second; + const auto changed = inserted || entry.revision != revision || entry.path != path; + const auto prior_state = entry.state; + entry.revision = revision; + entry.path = path; + if (!dirty) { + entry.state = "saved"; + entry.error.clear(); + continue; + } + if (path.empty()) { + entry.state = "save_as_required"; + entry.error.clear(); + continue; + } + if (!enabled) { + entry.state = "disabled"; + entry.error.clear(); + continue; + } + if (changed || prior_state == "disabled" || prior_state == "saved" || + prior_state == "save_as_required") { + entry.state = "pending"; + entry.error.clear(); + entry.deadline = now + std::chrono::seconds(2); + } + if (entry.state != "pending" || now < entry.deadline) + continue; + entry.state = "saving"; + try { + const auto saved = save_(id, revision); + if (saved.at("revision").get() != revision || + saved.at("dirty").get()) + throw std::runtime_error("Save returned an unexpected document revision"); + entry.state = "saved"; + entry.error.clear(); + } catch (const Error& error) { + entry.state = error.code() == "save.disk_conflict" || + error.code() == "revision.conflict" + ? "conflict" + : "failed"; + entry.error = error.what(); + } catch (const std::exception& error) { + entry.state = "failed"; + entry.error = error.what(); + } + } + for (auto it = entries_.begin(); it != entries_.end();) + it = present.contains(it->first) ? std::next(it) : entries_.erase(it); +} + +Json AutosaveController::status() const { + std::lock_guard lock(mutex_); + Json documents = Json::array(); + for (const auto& [id, entry] : entries_) + documents.push_back({{"id", id}, + {"state", entry.state}, + {"revision", entry.revision}, + {"path", entry.path}, + {"error", entry.error}}); + return {{"enabled", enabled_}, {"documents", documents}}; +} + +} // namespace faset::editor diff --git a/src/editor/commands.cpp b/src/editor/commands.cpp index b7fcf0b..91ab5d9 100644 --- a/src/editor/commands.cpp +++ b/src/editor/commands.cpp @@ -103,9 +103,13 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin [&](const Json& args) { return authoring_.query(args.at("document")); }, true); add("faset_document_save", "Atomically save an authoring document. Refuses to overwrite an externally modified file.", - object_schema({{"document", text}, {"path", text}}, {"document"}), [&](const Json& args) { + object_schema({{"document", text}, {"path", text}, + {"expected_revision", integer}}, {"document"}), [&](const Json& args) { + std::optional expected; + if (args.contains("expected_revision")) + expected = args.at("expected_revision").get(); return authoring_.save(args.at("document"), - path_from_utf8(args.value("path", std::string()))); + path_from_utf8(args.value("path", std::string())), expected); }); add( "faset_schema", diff --git a/src/editor/session.cpp b/src/editor/session.cpp index 75eb3e3..0c6d160 100644 --- a/src/editor/session.cpp +++ b/src/editor/session.cpp @@ -52,7 +52,10 @@ struct Session::ImportTask { }; Session::Session(SessionConfig config) : config_(std::move(config)), authoring_(config_.project_root), commands_(authoring_), - assets_(config_.project_root / ".faset/cache"), builds_(build_config(config_)) { + assets_(config_.project_root / ".faset/cache"), builds_(build_config(config_)), + autosave_([this](const std::string& document, std::uint64_t revision) { + return authoring_.save(document, {}, revision); + }) { register_commands(); plugins_ = std::make_unique( commands_, [this](std::string message) { log(std::move(message)); }); @@ -297,12 +300,19 @@ void Session::poll() { if (status == "failed" || status == "conflict") log("Asset import " + status + ": " + value.value("error", std::string())); } + autosave_.observe(authoring_.documents(), AutosaveController::Clock::now(), + autosave_enabled_); } void Session::register_commands() { const Json text = {{"type", "string"}}, boolean = {{"type", "boolean"}}; auto schema = [](Json properties, Json required = Json::array()) { return Commands::object_schema(std::move(properties), std::move(required)); }; + commands_.add("faset_autosave_status", + "Read revision-aware scene autosave state. Unnamed scenes remain in recovery " + "until explicitly saved with a path.", + schema(Json::object()), + [this](const Json&) { return autosave_.status(); }, true); commands_.add( "faset_capabilities", "Inspect available Editor services and rendering capabilities.", schema(Json::object()), diff --git a/tests/editor_autosave_tests.cpp b/tests/editor_autosave_tests.cpp new file mode 100644 index 0000000..3e1b290 --- /dev/null +++ b/tests/editor_autosave_tests.cpp @@ -0,0 +1,119 @@ +#include +#include +#include +#include +#include + +using namespace faset; +using namespace std::chrono_literals; +namespace fs = std::filesystem; + +namespace { +void check(bool value, std::string_view message) { + if (!value) + throw std::runtime_error(std::string(message)); +} +void contracts(const fs::path& root) { + authoring::AuthoringService service(root); + const auto created = service.create("Before", 2); + const auto id = created.at("id").get(); + const auto path = root / "Scenes/main.scene.json"; + service.save(id, "Scenes/main.scene.json"); + int saves{}; + editor::AutosaveController autosave([&](const std::string& document, std::uint64_t revision) { + ++saves; + return service.save(document, {}, revision); + }); + using Clock = editor::AutosaveController::Clock; + const auto start = Clock::time_point{}; + autosave.observe(service.documents(), start, true); + for (int index = 1; index <= 3; ++index) { + const auto revision = service.query(id).at("revision").get(); + service.transact(id, revision, + Json::array({{{"op", "scene.rename"}, + {"name", "Edit " + std::to_string(index)}}})); + autosave.observe(service.documents(), start + index * 100ms, true); + } + const auto play_snapshot = service.query(id).at("scene"); + autosave.observe(service.documents(), start + 2299ms, true); + check(saves == 0 && read_json(path).at("name") == "Before", + "Rapid edits coalesce until the last revision is idle for two seconds"); + autosave.observe(service.documents(), start + 2301ms, true); + check(saves == 1 && read_json(path).at("name") == "Edit 3" && + service.query(id).at("scene") == play_snapshot && + autosave.status().at("documents")[0].at("state") == "saved", + "Named scene saves once without changing the Play snapshot"); + const auto current_revision = service.query(id).at("revision").get(); + check(service.undo(id, current_revision).at("scene").at("name") == "Edit 2", + "Undo remains available after autosave"); + bool stale_rejected{}; + try { + service.save(id, {}, current_revision); + } catch (const Error& error) { + stale_rejected = error.code() == "revision.conflict"; + } + check(stale_rejected, "A stale autosave revision cannot save a newer document"); + + const auto external = read_text(path) + " \n"; + atomic_write(path, external); + const auto revision = service.query(id).at("revision").get(); + service.transact(id, revision, + Json::array({{{"op", "scene.rename"}, {"name", "After external edit"}}})); + autosave.observe(service.documents(), start + 3000ms, true); + autosave.observe(service.documents(), start + 5100ms, true); + check(saves == 2 && read_text(path) == external && + autosave.status().at("documents")[0].at("state") == "conflict", + "Disk conflict does not overwrite external changes"); + autosave.observe(service.documents(), start + 10000ms, true); + check(saves == 2, "A failed autosave does not retry every polling frame"); + + const auto unnamed = service.create("Unnamed", 2); + autosave.observe(service.documents(), start + 10100ms, true); + bool found_unnamed{}; + const auto unnamed_status = autosave.status(); + for (const auto& row : unnamed_status.at("documents")) + if (row.at("id") == unnamed.at("id")) + found_unnamed = row.at("state") == "save_as_required"; + check(found_unnamed && + fs::is_regular_file(root / ".faset/recovery" / + (unnamed.at("id").get() + ".json")), + "Unnamed scene remains recoverable without receiving an implicit save path"); + autosave.observe(service.documents(), start + 10200ms, false); + check(!autosave.status().at("enabled").get() && saves == 2, + "Disabled autosave leaves recovery journaling active"); + + int failed_attempts{}; + editor::AutosaveController failing([&](const std::string&, std::uint64_t) -> Json { + ++failed_attempts; + throw Error("io.write", "Synthetic read-only destination"); + }); + Json fake_documents = Json::array( + {{{"id", "failure-fixture"}, {"revision", 1}, + {"path", "Scenes/fixture.scene.json"}, {"dirty", true}}}); + failing.observe(fake_documents, start, true); + failing.observe(fake_documents, start + 2100ms, true); + check(failed_attempts == 1 && + failing.status().at("documents")[0].at("state") == "failed", + "Write failures remain visible"); + failing.observe(fake_documents, start + 10000ms, true); + check(failed_attempts == 1, "A failed write does not retry on every frame"); + fake_documents[0]["revision"] = 2; + failing.observe(fake_documents, start + 10100ms, true); + failing.observe(fake_documents, start + 12200ms, true); + check(failed_attempts == 2, "A newer revision receives a fresh autosave deadline"); +} +} // namespace + +int main() { + const auto root = fs::temp_directory_path() / ("faset-autosave-" + new_id()); + try { + contracts(root); + fs::remove_all(root); + std::cout << "Revision-aware autosave contracts passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + fs::remove_all(root); + return 1; + } +} diff --git a/tests/editor_session_tests.cpp b/tests/editor_session_tests.cpp index 071b817..8522101 100644 --- a/tests/editor_session_tests.cpp +++ b/tests/editor_session_tests.cpp @@ -157,6 +157,24 @@ int main(int argc, char** argv) { require(invalid_lua_status.at("stale") == true && !invalid_lua_status.at("error").get().empty(), "test", "Invalid Lua manifest did not mark schema stale with diagnostics"); + const auto autosave_id = document.at("id").get(); + const auto autosave_revision = + session.authoring().query(autosave_id).at("revision").get(); + session.authoring().transact( + autosave_id, autosave_revision, + Json::array({{{"op", "scene.rename"}, {"name", "Saved by MCP polling"}}})); + reject_command("faset_document_save", + {{"document", autosave_id}, + {"expected_revision", autosave_revision}}, + "revision.conflict"); + session.poll(); + require(commands.call("faset_autosave_status", Json::object()).at("enabled") == true, + "test", "Autosave status is available through Editor commands"); + std::this_thread::sleep_for(std::chrono::milliseconds(2100)); + session.poll(); + require(read_json(root / path_from_utf8("Scenes/Начало 世界.scene.json")).at("name") == + "Saved by MCP polling", + "test", "A long-lived headless Editor poll saves a named scene after idle"); external["version"] = 999; atomic_write_json(root / "project.faset.json", external); bool rejected = false;