Files
agame/addons/godot_ai/utils/update_activation_runner.gd
T
2026-09-21 16:16:04 +08:00

392 lines
16 KiB
GDScript

@tool
extends Node
## Compile this source into a GDScript with no resource_path before creating
## the node. Its named callbacks must survive replacement of the file itself.
## The caller verifies/stages/quiesces and owns the update lock. No plugin,
## dock, handler, or suspended caller frame crosses the deferred handoff.
const LIVE_ROOT := "res://addons/godot_ai"
const PLUGIN_CFG := LIVE_ROOT + "/plugin.cfg"
const STAGE_ROOT := "res://addons/.godot_ai_update/stage/addons/godot_ai"
const INSTALLER_PATH := LIVE_ROOT + "/utils/update_installer.gd"
const VERIFIER_PATH := LIVE_ROOT + "/utils/release_verifier.gd"
const RECEIPT_PATH := "res://addons/.godot_ai_update/activation.json"
const SCAN_TIMEOUT_MS := 60000
enum Phase { IDLE, DRAIN, REQUEST_OLD_SCAN, WAIT_OLD_SCAN, SWAPPING, REQUEST_NEW_SCAN, WAIT_NEW_SCAN, ENABLING, DONE }
var _phase := Phase.IDLE
var _package: Dictionary = {}
var _installer: Script
var _verifier: Script
var _frames := 0
var _deadline := 0
var _filesystem: EditorFileSystem
var _failure := ""
var _outcome := ""
var _backup_root := ""
var _retired_scripts: Array[Script] = []
var _inspected_objects := {}
var _inspection_budget := 100000
## Copied by a caller when start() refuses, before the runner is freed.
var refusal_reason := "The independent activation handoff was refused."
func start(package: Dictionary) -> bool:
if _phase != Phase.IDLE:
refusal_reason = "The activation runner has already started."
return false
if not is_inside_tree():
refusal_reason = "The activation runner must belong to the editor scene tree."
return false
if not str(get_script().resource_path).is_empty():
refusal_reason = "The activation runner must be source-free before replacing its files."
return false # A file-backed runner can be replaced by the scan it starts.
if package.get("stage_root") != STAGE_ROOT:
refusal_reason = "The update stage root does not match the verified activation location."
return false
if not package.get("record") is Dictionary:
refusal_reason = "The update handoff requires an installer record Dictionary."
return false
var record: Dictionary = package.record
for key in ["from_version", "to_version", "manifest_sha256", "expected_tree_sha256", "editor_nonce"]:
if not record.get(key) is String or str(record[key]).is_empty():
refusal_reason = "The update record requires a nonempty String field: %s." % key
return false
if not record.get("replace_owned_mismatches") is bool:
refusal_reason = "The update record requires a boolean replace_owned_mismatches field."
return false
# Copy only values that belong to the existing installer's swap record.
_package = {"stage_root": STAGE_ROOT, "record": {}}
for key in ["from_version", "to_version", "manifest_sha256", "expected_tree_sha256", "editor_nonce", "replace_owned_mismatches"]:
_package.record[key] = record[key]
_installer = ResourceLoader.load(INSTALLER_PATH, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) as Script
_verifier = ResourceLoader.load(VERIFIER_PATH, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) as Script
if _installer == null or _verifier == null:
refusal_reason = "The update installer or release verifier script could not be loaded."
return false
for method in ["swap", "verify_after_restart", "release_lock", "read_lock", "discard_stage"]:
if not _declares(_installer, method):
refusal_reason = "The update installer is missing its required %s method." % method
return false
if not _declares(_verifier, "hash_tree"):
refusal_reason = "The release verifier is missing its required hash_tree method."
return false
var lock: Dictionary = _installer.call("read_lock")
if int(lock.get("pid", 0)) != OS.get_process_id() or str(lock.get("fingerprint", "")).is_empty():
refusal_reason = "The update lock does not identify this editor with a process fingerprint."
return false
_inspected_objects.clear()
_inspection_budget = 100000
if not _user_resources_can_keep_paths():
return false
refusal_reason = ""
process_mode = Node.PROCESS_MODE_ALWAYS
_phase = Phase.DRAIN
set_process(false)
_begin.call_deferred()
return true
func _declares(script: Script, method: String) -> bool:
for entry in script.get_script_method_list():
if str(entry.get("name", "")) == method:
return true
return false
func _begin() -> void:
_filesystem = EditorInterface.get_resource_filesystem()
if _filesystem == null:
_stop("The editor filesystem is unavailable; no update swap was attempted.")
return
_filesystem.sources_changed.connect(_scan_completed)
EditorInterface.set_plugin_enabled(PLUGIN_CFG, false)
if EditorInterface.is_plugin_enabled(PLUGIN_CFG):
_stop("Godot could not disable the plugin; the verified stage was retained.")
return
_frames = 2
set_process(true)
func _process(_delta: float) -> void:
if _phase == Phase.DRAIN:
_frames -= 1
if _frames <= 0:
_phase = Phase.REQUEST_OLD_SCAN
_deadline = Time.get_ticks_msec() + SCAN_TIMEOUT_MS
elif _phase in [Phase.REQUEST_OLD_SCAN, Phase.WAIT_OLD_SCAN, Phase.REQUEST_NEW_SCAN, Phase.WAIT_NEW_SCAN]:
if Time.get_ticks_msec() >= _deadline:
_stop("The filesystem scan did not complete. The plugin remains disabled; the staged update and any backup were retained.")
elif _phase in [Phase.REQUEST_OLD_SCAN, Phase.REQUEST_NEW_SCAN] and not _filesystem.is_scanning():
_phase = Phase.WAIT_OLD_SCAN if _phase == Phase.REQUEST_OLD_SCAN else Phase.WAIT_NEW_SCAN
# scan() may decline while a finished worker still awaits GUI
# completion. Its sources_changed signal remains a valid barrier.
_filesystem.scan()
func _swap() -> void:
set_process(false)
# A cache lookup may resolve dependencies even when the requested script
# is cached. Retain all old references while their complete tree exists;
# never load a canonical path after the swap to recover an old script.
if not _collect_cached_scripts(LIVE_ROOT):
_stop("The previous script cache could not be captured safely; no update swap was attempted.")
return
var before: Dictionary = _verifier.call("hash_tree", LIVE_ROOT)
if not bool(before.get("ok", false)):
_stop("Could not hash the previous tree before activation; no swap was attempted.")
return
var swapped: Dictionary = _installer.call("swap", STAGE_ROOT, LIVE_ROOT, _package.record)
if not bool(swapped.get("ok", false)):
_failure = "Update swap failed: %s" % str(swapped.get("error", "unknown error"))
var restored: Dictionary = _verifier.call("hash_tree", LIVE_ROOT)
if not bool(restored.get("ok", false)) or restored.get("tree_sha256") != before.get("tree_sha256"):
_stop(_failure + "; the original tree could not be proven restored. Explicit repair is required.")
return
_installer.call("discard_stage")
_outcome = "previous_tree_retained"
else:
print("MCP | update to %s swapped in; reloading the plugin in this editor" % str(_package.record.to_version))
var verified: Dictionary = _installer.call("verify_after_restart", LIVE_ROOT)
_outcome = str(verified.get("status", ""))
if _outcome != "success" and _outcome != "rolled_back":
_stop("The installed tree could not be verified: %s" % str(verified.get("error", "unknown outcome")))
return
if _outcome == "rolled_back":
_failure = "Update verification failed; the previous tree was restored: %s" % str(verified.get("error", ""))
else:
_backup_root = str(verified.get("backup_root", ""))
var expected_backup := "res://addons/.godot_ai_update/backup/" + str(_package.record.from_version)
if _backup_root != expected_backup:
_stop("The update marker names an unexpected backup. The plugin remains disabled.")
return
var backup: Dictionary = _verifier.call("hash_tree", _backup_root)
if not bool(backup.get("ok", false)) or backup.get("tree_sha256") != before.get("tree_sha256"):
_stop("The retained backup could not be proven to match the previous tree. The plugin remains disabled.")
return
if _outcome == "success" and not _retire_cached_scripts(_backup_root):
_stop("The previous script cache could not be retained safely. The plugin remains disabled.")
return
_release_scripts()
_phase = Phase.REQUEST_NEW_SCAN
_deadline = Time.get_ticks_msec() + SCAN_TIMEOUT_MS
set_process(true)
func _scan_completed(_sources_changed: bool) -> void:
if _phase not in [Phase.WAIT_OLD_SCAN, Phase.WAIT_NEW_SCAN]:
return
if _filesystem.is_scanning():
return # Another completion listener already started a newer scan.
if Time.get_ticks_msec() >= _deadline:
_stop("The filesystem scan completion arrived after the activation deadline. The plugin remains disabled.")
return
set_process(false)
# sources_changed follows scan actions, documentation, and GUI updates.
# filesystem_changed can occur earlier; a deferred completion can become
# stale during a newer GUI scan. Consume this boundary synchronously.
if _phase == Phase.WAIT_OLD_SCAN:
_phase = Phase.SWAPPING
_swap()
else:
_phase = Phase.ENABLING
_filesystem.sources_changed.disconnect(_scan_completed)
_enable()
func _enable() -> void:
if not _failure.is_empty() and str(_package.record.from_version).get_slice(".", 0).to_int() < 4:
# A restored capsule must show Retry instead of automatically repeating
# the failed update. This is its existing project-scoped status key.
var settings := EditorInterface.get_editor_settings()
if settings != null:
var key := "godot_ai/v4_migration_bridge_status_" + ProjectSettings.globalize_path("res://").sha256_text()
settings.set_setting(key, JSON.stringify({"error": _failure}))
EditorInterface.set_plugin_enabled(PLUGIN_CFG, true)
if not EditorInterface.is_plugin_enabled(PLUGIN_CFG):
_stop("The scanned plugin could not be enabled. The installed tree and backup were retained.")
return
if _outcome == "success":
var plugin := _find_plugin(get_tree().root)
if plugin == null or str(plugin.get("_loaded_plugin_version")) != str(_package.record.to_version):
EditorInterface.set_plugin_enabled(PLUGIN_CFG, false)
_stop("The enabled plugin did not report the installed version. The update evidence was retained.")
return
var saved := ProjectSettings.save()
if saved != OK:
_stop("The plugin is enabled, but saving its enabled state failed: %s" % error_string(saved))
return
_phase = Phase.DONE
_record(_outcome, _failure)
if not _failure.is_empty():
_show_failure(_failure)
return
print("MCP | update activation completed in editor PID %d" % OS.get_process_id())
queue_free()
func _user_resources_can_keep_paths() -> bool:
# Retiring a Script changes resource_path for all its existing users.
# Refuse serializable scene/resource references instead of silently making
# a user's next save point into the updater's retained backup directory.
var roots: Array = []
if EditorInterface.has_method("get_open_scene_roots"):
roots = EditorInterface.call("get_open_scene_roots")
else:
if EditorInterface.get_open_scenes().size() > 1:
refusal_reason = "This Godot version cannot inspect every open scene safely. Close the other scene tabs before retrying the update."
return false
var current := EditorInterface.get_edited_scene_root()
if current != null:
roots.append(current)
for root in roots:
if not _inspect_user_node(root):
return false
var inspected := EditorInterface.get_inspector().get_edited_object()
if inspected is Resource and not _inspect_user_value(inspected, 0):
return false
return true
func _inspect_user_node(node: Node) -> bool:
if not _inspect_user_value(node, 0):
return false
for child in node.get_children():
if not _inspect_user_node(child):
return false
return true
func _inspect_user_value(value: Variant, depth: int) -> bool:
_inspection_budget -= 1
if _inspection_budget < 0 or depth > 64:
refusal_reason = "Open scene or resource data exceeded the safe inspection limit. Close the affected scenes or resources before retrying the update."
return false
if value is Script:
if value.resource_path.begins_with(LIVE_ROOT + "/"):
refusal_reason = "An open scene or resource references addon script %s. In-editor activation would change its saved script path. Close the scene or resource before retrying; the update was not installed." % value.resource_path
return false
return true
if value is Resource or value is Node:
var identity: int = value.get_instance_id()
if _inspected_objects.has(identity):
return true
_inspected_objects[identity] = true
for property in value.get_property_list():
if int(property.usage) & PROPERTY_USAGE_STORAGE:
if not _inspect_user_value(value.get(property.name), depth + 1):
return false
elif value is Array:
for entry in value:
if not _inspect_user_value(entry, depth + 1):
return false
elif value is Dictionary:
for key in value:
if not _inspect_user_value(key, depth + 1) or not _inspect_user_value(value[key], depth + 1):
return false
return true
func _retire_cached_scripts(backup_root: String) -> bool:
# All references were captured before replacement. Godot moves
# both its resource and GDScript cache entries through take_over_path().
# Existing UndoRedo callbacks keep the old compiled code and typed state;
# canonical loads create a fresh graph, including fresh static variables.
for script in _retired_scripts:
var previous_path := script.resource_path
var retained_path := backup_root + previous_path.trim_prefix(LIVE_ROOT)
if not previous_path.begins_with(LIVE_ROOT + "/") or not FileAccess.file_exists(retained_path):
return false
if ResourceLoader.has_cached(retained_path):
return false # Never displace a different retained generation.
# A later update in this same editor must not prune source files still
# referenced by an earlier generation's undo callbacks.
get_tree().root.set_meta("godot_ai_retained_update_scripts", true)
for script in _retired_scripts:
var retained_path := backup_root + script.resource_path.trim_prefix(LIVE_ROOT)
script.take_over_path(retained_path)
if script.resource_path != retained_path:
return false
return true
func _collect_cached_scripts(folder: String) -> bool:
var directory := DirAccess.open(folder)
if directory == null:
return false
for filename in directory.get_files():
if not filename.ends_with(".gd"):
continue
var canonical := folder.path_join(filename)
if ResourceLoader.has_cached(canonical):
var script := ResourceLoader.load(canonical) as Script
if script == null or script.resource_path != canonical:
return false
_retired_scripts.append(script)
for child in directory.get_directories():
if not _collect_cached_scripts(folder.path_join(child)):
return false
return true
func _find_plugin(node: Node) -> Node:
var script := node.get_script() as Script
if script != null and script.resource_path == LIVE_ROOT + "/plugin.gd":
return node
for child in node.get_children():
var found := _find_plugin(child)
if found != null:
return found
return null
func _release_scripts() -> void:
if _installer != null:
_installer.call("release_lock")
_installer = null
_verifier = null
func _stop(message: String) -> void:
_phase = Phase.DONE
set_process(false)
if _filesystem != null and _filesystem.sources_changed.is_connected(_scan_completed):
_filesystem.sources_changed.disconnect(_scan_completed)
_release_scripts()
_record("activation_failed", message)
_show_failure(message)
func _record(status: String, message: String) -> void:
var file := FileAccess.open(RECEIPT_PATH, FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify({"status": status, "error": message,
"editor_pid": OS.get_process_id(), "from_version": _package.record.from_version,
"to_version": _package.record.to_version, "unix": Time.get_unix_time_from_system()}))
else:
push_error("MCP | could not write update activation evidence: %s" % error_string(FileAccess.get_open_error()))
func _show_failure(message: String) -> void:
push_error("MCP | %s" % message)
if DisplayServer.get_name() == "headless":
queue_free()
return
var dialog := AcceptDialog.new()
dialog.title = "Godot AI update needs attention"
dialog.dialog_text = message + "\nUpdate evidence: res://addons/.godot_ai_update/"
add_child(dialog)
dialog.confirmed.connect(_dismiss_failure)
dialog.canceled.connect(_dismiss_failure)
dialog.popup_centered(Vector2i(620, 220))
func _dismiss_failure() -> void:
## Godot may have left its shared progress dialog under the failure window.
## Return it before freeing our subtree and leaving the editor's pointer dangling.
if is_inside_tree():
for dialog in find_children("*", "ProgressDialog", true, false):
dialog.reparent(get_tree().root)
queue_free()