|
| 1 | +@tool |
| 2 | +extends RefCounted |
| 3 | + |
| 4 | +## Scanner that detects whether `addons/godot_ai/` is in a half-installed |
| 5 | +## state left behind by a self-update whose rollback couldn't restore the |
| 6 | +## previous addon contents (`UpdateReloadRunner.InstallStatus.FAILED_MIXED`). |
| 7 | +## |
| 8 | +## Without this surface the user sees "plugin won't start" with no actionable |
| 9 | +## context, re-runs the update, and compounds the mismatch (issue #354 / |
| 10 | +## audit-v2 #10). The dock paints a banner from `diagnose()` and |
| 11 | +## `editor_handler.gd::get_editor_state` includes the same Dictionary so an |
| 12 | +## MCP agent can see and report the state. |
| 13 | + |
| 14 | +const UpdateReloadRunner := preload("res://addons/godot_ai/update_reload_runner.gd") |
| 15 | + |
| 16 | +const ADDON_DIR := "res://addons/godot_ai/" |
| 17 | +## Single source of truth for the suffix lives on the producer |
| 18 | +## (`UpdateReloadRunner._install_zip_file`); aliasing here so the scanner |
| 19 | +## can never drift from what the runner actually writes. |
| 20 | +const BACKUP_SUFFIX := UpdateReloadRunner.INSTALL_BACKUP_SUFFIX |
| 21 | +## Cap so a runaway addons tree (someone parented the wrong dir, an old |
| 22 | +## crashed install left thousands of artifacts) can't blow the |
| 23 | +## `editor_state` payload size or freeze the editor on first paint. |
| 24 | +const MAX_BACKUP_RESULTS := 200 |
| 25 | +## TTL for the `diagnose()` cache. `editor_state` is one of the highest- |
| 26 | +## traffic MCP tools (agents poll it constantly) and a recursive |
| 27 | +## `DirAccess` walk on every call would put I/O on the 4ms `_process()` |
| 28 | +## budget. Mixed-state is rare and persistent across editor restarts, so |
| 29 | +## a few seconds of staleness is acceptable; the dock's Re-scan button |
| 30 | +## bypasses the cache via `force=true` for immediate feedback. |
| 31 | +const CACHE_TTL_MSEC := 5000 |
| 32 | + |
| 33 | +static var _cache_value: Dictionary = {} |
| 34 | +static var _cache_timestamp_msec: int = -1 |
| 35 | + |
| 36 | + |
| 37 | +## Walk `dir` recursively and return every `res://`-relative path that ends |
| 38 | +## in `.update_backup`, sorted ascending. Truncates at `MAX_BACKUP_RESULTS` |
| 39 | +## — the truncation flag is exposed via `diagnose()`. |
| 40 | +## |
| 41 | +## Walk order is deterministic: entries within each directory are sorted |
| 42 | +## alphabetically, subdirs pushed reverse-sorted so DFS pops them in |
| 43 | +## ascending order. Without this two scans of the same mixed tree could |
| 44 | +## return different 200-file slices when truncation kicks in (Godot's |
| 45 | +## `list_dir` order isn't guaranteed stable across filesystems). |
| 46 | +static func find_backups(dir: String = ADDON_DIR) -> Array: |
| 47 | + var results: Array = [] |
| 48 | + var stack: Array = [dir] |
| 49 | + while not stack.is_empty(): |
| 50 | + if results.size() >= MAX_BACKUP_RESULTS: |
| 51 | + break |
| 52 | + var current: String = stack.pop_back() |
| 53 | + var d := DirAccess.open(current) |
| 54 | + ## Missing dir, permission error, or unreadable junction — skip |
| 55 | + ## silently. A missing addons dir is the bare-clone case; mid-walk |
| 56 | + ## errors stay quiet so a single permission glitch can't block the |
| 57 | + ## diagnostic the rest of the scan would have produced. |
| 58 | + if d == null: |
| 59 | + continue |
| 60 | + var entries: Array = [] |
| 61 | + d.list_dir_begin() |
| 62 | + while true: |
| 63 | + var entry := d.get_next() |
| 64 | + if entry.is_empty(): |
| 65 | + break |
| 66 | + if entry == "." or entry == "..": |
| 67 | + continue |
| 68 | + entries.append({"name": entry, "is_dir": d.current_is_dir()}) |
| 69 | + d.list_dir_end() |
| 70 | + entries.sort_custom(func(a, b): return a["name"] < b["name"]) |
| 71 | + ## Push subdirs reverse-sorted so the next outer iteration pops |
| 72 | + ## them in ascending order — see method docstring for why this |
| 73 | + ## determinism matters for the truncated case. |
| 74 | + for i in range(entries.size() - 1, -1, -1): |
| 75 | + var entry: Dictionary = entries[i] |
| 76 | + if entry["is_dir"]: |
| 77 | + stack.append(current.path_join(entry["name"])) |
| 78 | + for entry in entries: |
| 79 | + if entry["is_dir"]: |
| 80 | + continue |
| 81 | + if not String(entry["name"]).ends_with(BACKUP_SUFFIX): |
| 82 | + continue |
| 83 | + results.append(current.path_join(entry["name"])) |
| 84 | + if results.size() >= MAX_BACKUP_RESULTS: |
| 85 | + break |
| 86 | + results.sort() |
| 87 | + return results |
| 88 | + |
| 89 | + |
| 90 | +## Build the structured diagnostic Dictionary surfaced via `editor_state` |
| 91 | +## and the dock banner. Empty when the addons tree is clean — callers |
| 92 | +## gate banner visibility / response field on `is_empty()`. |
| 93 | +## |
| 94 | +## Cached for `CACHE_TTL_MSEC` when scanning the default `ADDON_DIR` so |
| 95 | +## per-`editor_state` polls don't re-walk the addons tree every frame. |
| 96 | +## Tests passing a custom `dir` always see a fresh scan (cache only |
| 97 | +## tracks the production path). `force=true` bypasses the cache — used |
| 98 | +## by the dock's Re-scan button so a manual fix is reflected immediately. |
| 99 | +static func diagnose(dir: String = ADDON_DIR, force: bool = false) -> Dictionary: |
| 100 | + var use_cache := dir == ADDON_DIR and not force |
| 101 | + if use_cache and _cache_timestamp_msec >= 0: |
| 102 | + if Time.get_ticks_msec() - _cache_timestamp_msec < CACHE_TTL_MSEC: |
| 103 | + return _cache_value.duplicate(true) |
| 104 | + |
| 105 | + var backups := find_backups(dir) |
| 106 | + var result: Dictionary = {} |
| 107 | + if not backups.is_empty(): |
| 108 | + ## Most commonly produced by `_rollback_paths_written` returning |
| 109 | + ## FAILED_MIXED, but `_finalize_install_success` removes backups on |
| 110 | + ## a best-effort basis so a successful install can also leave them |
| 111 | + ## behind if the cleanup `remove_absolute` hit a permission error. |
| 112 | + ## The recovery action — delete the *.update_backup files — is the |
| 113 | + ## same in both cases, so the message acknowledges both |
| 114 | + ## possibilities rather than asserting the alarming one. |
| 115 | + result = { |
| 116 | + "addon_dir": dir, |
| 117 | + "backup_files": backups, |
| 118 | + "backup_count": backups.size(), |
| 119 | + "truncated": backups.size() >= MAX_BACKUP_RESULTS, |
| 120 | + "message": ( |
| 121 | + "Found .update_backup files in addons/godot_ai/. This usually" |
| 122 | + + " means a self-update rollback couldn't restore the previous" |
| 123 | + + " addon contents (FAILED_MIXED) — the plugin may load a mix" |
| 124 | + + " of old and new files. Restore the addon from your VCS or a" |
| 125 | + + " fresh release ZIP, then delete the listed *.update_backup" |
| 126 | + + " files. If the plugin runs without issues these are likely" |
| 127 | + + " stale from a successful install and safe to delete." |
| 128 | + ), |
| 129 | + } |
| 130 | + if use_cache: |
| 131 | + _cache_value = result.duplicate(true) |
| 132 | + _cache_timestamp_msec = Time.get_ticks_msec() |
| 133 | + return result |
| 134 | + |
| 135 | + |
| 136 | +## Reset the `diagnose()` cache. Tests that flip the addons-tree state |
| 137 | +## between calls use this to avoid TTL-bound flakiness; the dock's |
| 138 | +## Re-scan button uses `force=true` instead. |
| 139 | +static func clear_cache() -> void: |
| 140 | + _cache_value = {} |
| 141 | + _cache_timestamp_msec = -1 |
0 commit comments