Files
tekton/addons/godot_ai/utils/update_mixed_state.gd
T

141 lines
6.1 KiB
GDScript

@tool
extends RefCounted
## Scanner that detects whether `addons/godot_ai/` is in a half-installed
## state left behind by a self-update whose rollback couldn't restore the
## previous addon contents (`UpdateReloadRunner.InstallStatus.FAILED_MIXED`).
##
## Without this surface the user sees "plugin won't start" with no actionable
## context, re-runs the update, and compounds the mismatch (issue #354 /
## audit-v2 #10). The dock paints a banner from `diagnose()` and
## `editor_handler.gd::get_editor_state` includes the same Dictionary so an
## MCP agent can see and report the state.
const ADDON_DIR := "res://addons/godot_ai/"
## Producer is `update_reload_runner.gd::INSTALL_BACKUP_SUFFIX`. Inlined as a
## literal because old two-phase runners can parse this diagnostic script
## against stale runner Script-object content during their mixed-snapshot
## scan. `test_update_backup_suffix_stays_in_sync` guards against drift.
const BACKUP_SUFFIX := ".update_backup"
## Cap so a runaway addons tree (someone parented the wrong dir, an old
## crashed install left thousands of artifacts) can't blow the
## `editor_state` payload size or freeze the editor on first paint.
const MAX_BACKUP_RESULTS := 200
## TTL for the `diagnose()` cache. `editor_state` is one of the highest-
## traffic MCP tools (agents poll it constantly) and a recursive
## `DirAccess` walk on every call would put I/O on the 4ms `_process()`
## budget. Mixed-state is rare and persistent across editor restarts, so
## a few seconds of staleness is acceptable; the dock's Re-scan button
## bypasses the cache via `force=true` for immediate feedback.
const CACHE_TTL_MSEC := 5000
static var _cache_value: Dictionary = {}
static var _cache_timestamp_msec: int = -1
## Walk `dir` recursively and return every `res://`-relative path that ends
## in `.update_backup`, sorted ascending. Truncates at `MAX_BACKUP_RESULTS`
## — the truncation flag is exposed via `diagnose()`.
##
## Walk order is deterministic: entries within each directory are sorted
## alphabetically, subdirs pushed reverse-sorted so DFS pops them in
## ascending order. Without this two scans of the same mixed tree could
## return different 200-file slices when truncation kicks in (Godot's
## `list_dir` order isn't guaranteed stable across filesystems).
static func find_backups(dir: String = ADDON_DIR) -> Array:
var results: Array = []
var stack: Array = [dir]
while not stack.is_empty():
if results.size() >= MAX_BACKUP_RESULTS:
break
var current: String = stack.pop_back()
var d := DirAccess.open(current)
## Missing dir, permission error, or unreadable junction — skip
## silently. A missing addons dir is the bare-clone case; mid-walk
## errors stay quiet so a single permission glitch can't block the
## diagnostic the rest of the scan would have produced.
if d == null:
continue
var entries: Array = []
d.list_dir_begin()
while true:
var entry := d.get_next()
if entry.is_empty():
break
if entry == "." or entry == "..":
continue
entries.append({"name": entry, "is_dir": d.current_is_dir()})
d.list_dir_end()
entries.sort_custom(func(a, b): return a["name"] < b["name"])
## Push subdirs reverse-sorted so the next outer iteration pops
## them in ascending order — see method docstring for why this
## determinism matters for the truncated case.
for i in range(entries.size() - 1, -1, -1):
var entry: Dictionary = entries[i]
if entry["is_dir"]:
stack.append(current.path_join(entry["name"]))
for entry in entries:
if entry["is_dir"]:
continue
if not String(entry["name"]).ends_with(BACKUP_SUFFIX):
continue
results.append(current.path_join(entry["name"]))
if results.size() >= MAX_BACKUP_RESULTS:
break
results.sort()
return results
## Build the structured diagnostic Dictionary surfaced via `editor_state`
## and the dock banner. Empty when the addons tree is clean — callers
## gate banner visibility / response field on `is_empty()`.
##
## Cached for `CACHE_TTL_MSEC` when scanning the default `ADDON_DIR` so
## per-`editor_state` polls don't re-walk the addons tree every frame.
## Tests passing a custom `dir` always see a fresh scan (cache only
## tracks the production path). `force=true` bypasses the cache — used
## by the dock's Re-scan button so a manual fix is reflected immediately.
static func diagnose(dir: String = ADDON_DIR, force: bool = false) -> Dictionary:
var use_cache := dir == ADDON_DIR and not force
if use_cache and _cache_timestamp_msec >= 0:
if Time.get_ticks_msec() - _cache_timestamp_msec < CACHE_TTL_MSEC:
return _cache_value.duplicate(true)
var backups := find_backups(dir)
var result: Dictionary = {}
if not backups.is_empty():
## Most commonly produced by `_rollback_paths_written` returning
## FAILED_MIXED, but `_finalize_install_success` removes backups on
## a best-effort basis so a successful install can also leave them
## behind if the cleanup `remove_absolute` hit a permission error.
## The recovery action — delete the *.update_backup files — is the
## same in both cases, so the message acknowledges both
## possibilities rather than asserting the alarming one.
result = {
"addon_dir": dir,
"backup_files": backups,
"backup_count": backups.size(),
"truncated": backups.size() >= MAX_BACKUP_RESULTS,
"message": (
"Found .update_backup files in addons/godot_ai/. This usually"
+ " means a self-update rollback couldn't restore the previous"
+ " addon contents (FAILED_MIXED) — the plugin may load a mix"
+ " of old and new files. Restore the addon from your VCS or a"
+ " fresh release ZIP, then delete the listed *.update_backup"
+ " files. If the plugin runs without issues these are likely"
+ " stale from a successful install and safe to delete."
),
}
if use_cache:
_cache_value = result.duplicate(true)
_cache_timestamp_msec = Time.get_ticks_msec()
return result
## Reset the `diagnose()` cache. Tests that flip the addons-tree state
## between calls use this to avoid TTL-bound flakiness; the dock's
## Re-scan button uses `force=true` instead.
static func clear_cache() -> void:
_cache_value = {}
_cache_timestamp_msec = -1