Files
agame/addons/godot_ai/migration_coordinator.gd
T
2026-09-12 22:46:56 +08:00

214 lines
7.9 KiB
GDScript

@tool
extends Node
## Prepare the capsule's value-only handoff to the shared activation runner.
## This coordinator finishes before the runner disables the capsule or swaps
## its source. The runner alone owns the scan and in-editor activation.
const INSTALLER_SCRIPT := "res://addons/godot_ai/utils/update_installer.gd"
const ACTIVATION_SCRIPT := "res://addons/godot_ai/utils/update_activation_runner.gd"
const PORT_RESOLVER_SCRIPT := "res://addons/godot_ai/utils/port_resolver.gd"
const STATUS_SETTING := "godot_ai/v4_migration_bridge_status"
## v3's `plugin.gd::_ensure_game_helper_autoload` wrote
## `ProjectSettings.set_setting("autoload/_mcp_game_helper", "*res://addons/godot_ai/runtime/game_helper.gd")`
## and saved project.godot. The capsule never re-registers it, so the entry
## dangles for the whole migration window and points at a file the v4 swap
## moves away (#946). Remove it before the swap, matching the exact target so
## a user-owned autoload of the same name is left alone.
const GAME_HELPER_AUTOLOAD_SETTING := "autoload/_mcp_game_helper"
const GAME_HELPER_AUTOLOAD_TARGET := "res://addons/godot_ai/runtime/game_helper.gd"
const REQUIRED_INSTALLER_METHODS := [
"acquire_lock", "release_lock", "discard_stage",
]
const PRE_DISABLE_DRAIN_FRAMES := 2
## Unknown pre-v4 versions still describe a major crossing. Manual-major
## client repinning replaces any owned pre-v4 entry, so this fallback is
## identity metadata rather than a client-selection authority.
const UNKNOWN_V3_VERSION := "3.0.0"
signal state_changed(message: String, failed: bool)
enum Phase { IDLE, DRAIN, DONE }
var _package: Dictionary = {}
var _phase := Phase.IDLE
var _frames := 0
var _installer: Script
var _lock_held := false
func start(package: Dictionary) -> void:
if _phase != Phase.IDLE:
return
if not _valid_package(package):
_fail_before_swap("The prepared v4 migration handoff is invalid.")
return
_package = package.duplicate(true)
_frames = PRE_DISABLE_DRAIN_FRAMES
_phase = Phase.DRAIN
set_process(true)
func _process(_delta: float) -> void:
match _phase:
Phase.DRAIN:
_frames -= 1
if _frames <= 0:
_prepare_swap()
_:
set_process(false)
## Everything that can still refuse without touching the live tree.
func _prepare_swap() -> void:
_installer = _load_script_with(INSTALLER_SCRIPT, "swap")
for method in REQUIRED_INSTALLER_METHODS:
if _installer == null or not _script_declares(_installer, method):
_installer = null
_fail_before_swap(
"The migration capsule is incomplete (utils/update_installer.gd is missing or invalid)."
)
return
var activation := GDScript.new()
activation.source_code = FileAccess.get_file_as_string(ACTIVATION_SCRIPT)
if activation.source_code.is_empty() or activation.reload() != OK or not _script_declares(activation, "start"):
_fail_before_swap("The migration capsule's activation runner is missing or invalid.")
return
var cleared := _remove_v3_game_helper_autoload()
if cleared != OK:
_fail_before_swap(
"Could not remove the v3 game-helper autoload from project.godot (%s)."
% error_string(cleared)
)
return
var fingerprint := _editor_fingerprint()
if fingerprint.is_empty():
_fail_before_swap("Could not fingerprint this editor process for the update lock.")
return
var locked: Variant = _installer.call("acquire_lock", OS.get_process_id(), fingerprint)
if not locked is Dictionary or not bool(locked.get("ok", false)):
_fail_before_swap(_error_of(locked, "Another editor holds the Godot AI update lock."))
return
_lock_held = true
var from_version := str(_package.get("from_version", ""))
var record := {
"from_version": from_version if not from_version.is_empty() else UNKNOWN_V3_VERSION,
"to_version": str(_package.get("to_version", "")),
"manifest_sha256": str(_package.get("manifest_sha256", "")),
"expected_tree_sha256": str(_package.get("expected_tree_sha256", "")),
"editor_nonce": Crypto.new().generate_random_bytes(16).hex_encode(),
## The v4 plugin reads this after activation: a v3-to-v4 crossing
## may replace owned client-config entries broadly, which an
## ordinary v4-to-v4 update must not do.
"replace_owned_mismatches": true,
}
var runner: Node = activation.new()
get_tree().root.add_child(runner)
var accepted: Variant = runner.call("start", {"stage_root": str(_package.stage_root), "record": record})
if not accepted is bool or not accepted:
var reason := str(runner.get("refusal_reason"))
runner.queue_free()
_fail_before_swap(reason if not reason.is_empty() else "The migration activation runner refused the prepared handoff.")
return
## No capsule Script or suspended callback crosses into the scan. The
## runner holds only its independent source and copied package values.
_lock_held = false
_installer = null
_phase = Phase.DONE
set_process(false)
print("MCP | v3 bridge handing verified tree to in-editor activation")
queue_free()
func _fail_before_swap(message: String) -> void:
_release_lock()
_discard_stage()
record_status(message)
push_error("Godot AI v4 migration: %s" % message)
_phase = Phase.DONE
set_process(false)
state_changed.emit(message, true)
queue_free()
func _release_lock() -> void:
if not _lock_held:
return
_lock_held = false
if _installer != null:
_installer.call("release_lock")
## A refused attempt leaves no staged tree behind; `stage()` would clear it
## on Retry anyway, so this is tidiness, not a contract the bridge relies on.
func _discard_stage() -> void:
if _installer != null and _script_declares(_installer, "discard_stage"):
_installer.call("discard_stage")
static func _remove_v3_game_helper_autoload() -> Error:
if not ProjectSettings.has_setting(GAME_HELPER_AUTOLOAD_SETTING):
return OK
var target := str(ProjectSettings.get_setting(GAME_HELPER_AUTOLOAD_SETTING, "")).trim_prefix("*")
if target != GAME_HELPER_AUTOLOAD_TARGET:
return OK
ProjectSettings.clear(GAME_HELPER_AUTOLOAD_SETTING)
return ProjectSettings.save()
## `McpPortResolver.process_fingerprint` from the v4 `utils/port_resolver.gd`
## the capsule carries; final v3's copy of that file predates the method, so
## the lookup is by path and method, never by class_name.
static func _editor_fingerprint() -> String:
var resolver := _load_script_with(PORT_RESOLVER_SCRIPT, "process_fingerprint")
if resolver == null:
return ""
var value: Variant = resolver.call("process_fingerprint", OS.get_process_id())
return str(value) if value is String else ""
static func _load_script_with(path: String, method: String) -> Script:
if not ResourceLoader.exists(path):
return null
## Final v3 ships older files at some of these paths (utils/port_resolver.gd),
## already compiled into the resource cache before the capsule was extracted
## over the tree. Ignore the cache, deeply, so the capsule's copies and
## their preloads are the ones that run.
var loaded: Variant = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP)
if not loaded is Script or not _script_declares(loaded, method):
return null
return loaded
static func _script_declares(script: Script, method: String) -> bool:
for entry in script.get_script_method_list():
if str(entry.get("name", "")) == method:
return true
return false
static func _error_of(result: Variant, fallback: String) -> String:
if result is Dictionary:
var error := str((result as Dictionary).get("error", "")).strip_edges()
if not error.is_empty():
return error
return fallback
static func record_status(error: String) -> void:
var settings := EditorInterface.get_editor_settings()
if settings != null:
settings.set_setting(status_setting(), JSON.stringify({"error": error}))
static func status_setting() -> String:
## A failed migration in one project must not block a different project.
return STATUS_SETTING + "_" + ProjectSettings.globalize_path("res://").sha256_text()
static func _valid_package(package: Dictionary) -> bool:
for name in ["stage_root", "expected_tree_sha256", "manifest_sha256", "to_version"]:
if str(package.get(name, "")).is_empty():
return false
return true