Autosave named scenes with revision and disk conflict safety
This commit is contained in:
+4
-1
@@ -74,13 +74,16 @@ if(TARGET faset_assets AND TARGET faset_authoring AND EXISTS "${PROJECT_SOURCE_D
|
|||||||
endif()
|
endif()
|
||||||
if(TARGET faset_editor_commands AND TARGET faset_build_service)
|
if(TARGET faset_editor_commands AND TARGET faset_build_service)
|
||||||
include(cmake/Plugins.cmake)
|
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)
|
target_link_libraries(faset_editor_session PUBLIC faset_editor_commands faset_build_service faset_assets faset_editor_plugins)
|
||||||
if(BUILD_TESTING)
|
if(BUILD_TESTING)
|
||||||
add_executable(faset_editor_session_tests tests/editor_session_tests.cpp)
|
add_executable(faset_editor_session_tests tests/editor_session_tests.cpp)
|
||||||
target_link_libraries(faset_editor_session_tests PRIVATE faset_editor_session)
|
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}")
|
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_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()
|
||||||
endif()
|
endif()
|
||||||
foreach(module UI EditorUI Editor Applications)
|
foreach(module UI EditorUI Editor Applications)
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ class AuthoringService {
|
|||||||
const Json& operations, const std::string& idempotency_key = "");
|
const Json& operations, const std::string& idempotency_key = "");
|
||||||
Json undo(const std::string& document, std::uint64_t expected_revision);
|
Json undo(const std::string& document, std::uint64_t expected_revision);
|
||||||
Json redo(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<std::uint64_t> expected_revision = std::nullopt);
|
||||||
Json recovery_documents() const;
|
Json recovery_documents() const;
|
||||||
Json recover(const std::string& document,
|
Json recover(const std::string& document,
|
||||||
std::optional<std::uint64_t> expected_revision = std::nullopt);
|
std::optional<std::uint64_t> expected_revision = std::nullopt);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <faset/core/json.hpp>
|
||||||
|
#include <chrono>
|
||||||
|
#include <functional>
|
||||||
|
#include <map>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace faset::editor {
|
||||||
|
|
||||||
|
class AutosaveController {
|
||||||
|
public:
|
||||||
|
using Clock = std::chrono::steady_clock;
|
||||||
|
using SaveFn = std::function<Json(const std::string&, std::uint64_t)>;
|
||||||
|
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<std::string, Entry> entries_;
|
||||||
|
bool enabled_ = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace faset::editor
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
#include <faset/assets/asset_pipeline.hpp>
|
#include <faset/assets/asset_pipeline.hpp>
|
||||||
#include <faset/core/process.hpp>
|
#include <faset/core/process.hpp>
|
||||||
#include <faset/editor/build_service.hpp>
|
#include <faset/editor/build_service.hpp>
|
||||||
|
#include <faset/editor/autosave.hpp>
|
||||||
#include <faset/editor/commands.hpp>
|
#include <faset/editor/commands.hpp>
|
||||||
#include <faset/editor/plugins.hpp>
|
#include <faset/editor/plugins.hpp>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
@@ -62,6 +63,8 @@ class Session {
|
|||||||
Commands commands_;
|
Commands commands_;
|
||||||
assets::AssetPipeline assets_;
|
assets::AssetPipeline assets_;
|
||||||
BuildService builds_;
|
BuildService builds_;
|
||||||
|
AutosaveController autosave_;
|
||||||
|
bool autosave_enabled_ = true;
|
||||||
std::map<std::string, std::shared_ptr<ImportTask>> imports_;
|
std::map<std::string, std::shared_ptr<ImportTask>> imports_;
|
||||||
std::vector<std::jthread> workers_;
|
std::vector<std::jthread> workers_;
|
||||||
std::vector<std::string> logs_;
|
std::vector<std::string> logs_;
|
||||||
|
|||||||
@@ -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) {
|
Json AuthoringService::redo(const std::string& id, std::uint64_t revision) {
|
||||||
return history(id, revision, true);
|
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<std::uint64_t> expected_revision) {
|
||||||
std::lock_guard lock(mutex_);
|
std::lock_guard lock(mutex_);
|
||||||
auto& current = state(id);
|
auto& current = state(id);
|
||||||
|
if (expected_revision)
|
||||||
|
check_revision(current.revision, *expected_revision);
|
||||||
const auto selected = relative.empty() ? current.path : relative.lexically_normal();
|
const auto selected = relative.empty() ? current.path : relative.lexically_normal();
|
||||||
require(!selected.empty(), "save.path", "Choose a scene path before saving");
|
require(!selected.empty(), "save.path", "Choose a scene path before saving");
|
||||||
const auto path = project_path(root_, selected);
|
const auto path = project_path(root_, selected);
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#include <faset/editor/autosave.hpp>
|
||||||
|
|
||||||
|
#include <faset/core/error.hpp>
|
||||||
|
#include <set>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
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<std::string> present;
|
||||||
|
for (const auto& document : documents) {
|
||||||
|
const auto id = document.at("id").get<std::string>();
|
||||||
|
const auto revision = document.at("revision").get<std::uint64_t>();
|
||||||
|
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<std::uint64_t>() != revision ||
|
||||||
|
saved.at("dirty").get<bool>())
|
||||||
|
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
|
||||||
@@ -103,9 +103,13 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin
|
|||||||
[&](const Json& args) { return authoring_.query(args.at("document")); }, true);
|
[&](const Json& args) { return authoring_.query(args.at("document")); }, true);
|
||||||
add("faset_document_save",
|
add("faset_document_save",
|
||||||
"Atomically save an authoring document. Refuses to overwrite an externally modified file.",
|
"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<std::uint64_t> expected;
|
||||||
|
if (args.contains("expected_revision"))
|
||||||
|
expected = args.at("expected_revision").get<std::uint64_t>();
|
||||||
return authoring_.save(args.at("document"),
|
return authoring_.save(args.at("document"),
|
||||||
path_from_utf8(args.value("path", std::string())));
|
path_from_utf8(args.value("path", std::string())), expected);
|
||||||
});
|
});
|
||||||
add(
|
add(
|
||||||
"faset_schema",
|
"faset_schema",
|
||||||
|
|||||||
+11
-1
@@ -52,7 +52,10 @@ struct Session::ImportTask {
|
|||||||
};
|
};
|
||||||
Session::Session(SessionConfig config)
|
Session::Session(SessionConfig config)
|
||||||
: config_(std::move(config)), authoring_(config_.project_root), commands_(authoring_),
|
: 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();
|
register_commands();
|
||||||
plugins_ = std::make_unique<PluginManager>(
|
plugins_ = std::make_unique<PluginManager>(
|
||||||
commands_, [this](std::string message) { log(std::move(message)); });
|
commands_, [this](std::string message) { log(std::move(message)); });
|
||||||
@@ -297,12 +300,19 @@ void Session::poll() {
|
|||||||
if (status == "failed" || status == "conflict")
|
if (status == "failed" || status == "conflict")
|
||||||
log("Asset import " + status + ": " + value.value("error", std::string()));
|
log("Asset import " + status + ": " + value.value("error", std::string()));
|
||||||
}
|
}
|
||||||
|
autosave_.observe(authoring_.documents(), AutosaveController::Clock::now(),
|
||||||
|
autosave_enabled_);
|
||||||
}
|
}
|
||||||
void Session::register_commands() {
|
void Session::register_commands() {
|
||||||
const Json text = {{"type", "string"}}, boolean = {{"type", "boolean"}};
|
const Json text = {{"type", "string"}}, boolean = {{"type", "boolean"}};
|
||||||
auto schema = [](Json properties, Json required = Json::array()) {
|
auto schema = [](Json properties, Json required = Json::array()) {
|
||||||
return Commands::object_schema(std::move(properties), std::move(required));
|
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(
|
commands_.add(
|
||||||
"faset_capabilities", "Inspect available Editor services and rendering capabilities.",
|
"faset_capabilities", "Inspect available Editor services and rendering capabilities.",
|
||||||
schema(Json::object()),
|
schema(Json::object()),
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
#include <faset/authoring/service.hpp>
|
||||||
|
#include <faset/core/io.hpp>
|
||||||
|
#include <faset/editor/autosave.hpp>
|
||||||
|
#include <chrono>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
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<std::string>();
|
||||||
|
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<std::uint64_t>();
|
||||||
|
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<std::uint64_t>();
|
||||||
|
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<std::uint64_t>();
|
||||||
|
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<std::string>() + ".json")),
|
||||||
|
"Unnamed scene remains recoverable without receiving an implicit save path");
|
||||||
|
autosave.observe(service.documents(), start + 10200ms, false);
|
||||||
|
check(!autosave.status().at("enabled").get<bool>() && 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,6 +157,24 @@ int main(int argc, char** argv) {
|
|||||||
require(invalid_lua_status.at("stale") == true &&
|
require(invalid_lua_status.at("stale") == true &&
|
||||||
!invalid_lua_status.at("error").get<std::string>().empty(),
|
!invalid_lua_status.at("error").get<std::string>().empty(),
|
||||||
"test", "Invalid Lua manifest did not mark schema stale with diagnostics");
|
"test", "Invalid Lua manifest did not mark schema stale with diagnostics");
|
||||||
|
const auto autosave_id = document.at("id").get<std::string>();
|
||||||
|
const auto autosave_revision =
|
||||||
|
session.authoring().query(autosave_id).at("revision").get<std::uint64_t>();
|
||||||
|
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;
|
external["version"] = 999;
|
||||||
atomic_write_json(root / "project.faset.json", external);
|
atomic_write_json(root / "project.faset.json", external);
|
||||||
bool rejected = false;
|
bool rejected = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user