2026-10-09 12:49:07 +02:00

195 lines
7.9 KiB
GDScript

@tool
extends RefCounted
## Builds the projection / read-model projects.json from the per-instance entry
## files. DDD: this is a PROJECTION, not a transactional aggregate root — the
## per-instance entry files (<hash>.json, <hash>.runtime.json) are the write-side
## aggregates (one writer each); projects.json is the derived, fanned-in read
## model the TypeScript bridge reads. It is owned by no one and rebuilt
## idempotently (same entry files → same output).
##
## The rebuild scans all entry files, port-conflict-prunes stale editor entries,
## overlays the runtime entries onto the editor base by _key, and atomically
## writes projects.json. The caller holds the lock (or accepts benign
## last-writer-wins) — see RegistryClient's lifecycle methods.
##
## All methods are static — no instance state. Editor-clean: this file is in the
## runtime autoload's preload closure (via registry_client.gd), so it names no
## editor-only class (godot#91713).
# Direct preload (NOT via core/modules.gd): this file is in the runtime autoload's
# preload closure (registry_client.gd → mcp_runtime_server.gd), so it must stay
# editor-clean — core/modules.gd names EditorInterface and would taint the autoload in
# exports (godot#91713). RegistryPaths is editor-clean and owns the on-disk layout.
const _RegistryPaths := preload("res://addons/godot_mcp_toolkit/registry/store/registry_paths.gd")
# -- Registry I/O (used by rebuild) -------------------------------------------
static func write_atomic(data: Dictionary) -> void:
var path := _RegistryPaths.registry_path()
var tmp_path := path + ".tmp"
var bak_path := path + ".bak"
var f := FileAccess.open(tmp_path, FileAccess.WRITE)
if f == null:
push_warning("[MCPRegistry] cannot write %s (err %d)" % [tmp_path, FileAccess.get_open_error()])
return
f.store_string(JSON.stringify(data, "\t"))
f.close()
# Two-phase rename: .tmp → target, with .bak safety net.
# On Windows DirAccess.rename fails if the target exists, so we
# rename existing → .bak first, then .tmp → target. If the second
# rename fails we restore from .bak.
if FileAccess.file_exists(path):
# Phase 1: existing → .bak
if FileAccess.file_exists(bak_path):
DirAccess.remove_absolute(bak_path)
var bak_err := DirAccess.rename_absolute(path, bak_path)
if bak_err != OK:
push_warning("[MCPRegistry] rename %s -> %s failed (err %d); aborting write" % [path, bak_path, bak_err])
DirAccess.remove_absolute(tmp_path)
return
# Phase 2: .tmp → target
var err := DirAccess.rename_absolute(tmp_path, path)
if err != OK:
push_warning("[MCPRegistry] rename %s -> %s failed (err %d); restoring from backup" % [tmp_path, path, err])
# Restore .bak → target if it exists.
if FileAccess.file_exists(bak_path):
DirAccess.rename_absolute(bak_path, path)
DirAccess.remove_absolute(tmp_path)
return
# Cleanup .bak on success.
if FileAccess.file_exists(bak_path):
DirAccess.remove_absolute(bak_path)
# -- Rebuild projects.json from entry files ------------------------------------
# Fields the runtime child owns. When an editor base entry exists for a _key,
# only these overlay from <hash>.runtime.json — every other field stays the
# editor's. A runtime-only entry (no editor base) is schema-complete on its own.
const _RUNTIME_OWNED_FIELDS := ["runtime_port", "runtime_pid"]
## Scans entries/*.json (+ *.runtime.json) and writes the aggregated
## projects.json. OS.is_process_running() is unreliable on Windows (returns
## false for live sibling editors), so PID-based GC is not used. Instead, port-
## conflict pruning removes stale editor entries: when two entries claim the
## same port, the one with the older started_at is pruned (its entry
## file is deleted so it doesn't reappear on next rebuild).
## Fresh dead entries are cleaned up by deregister() on normal exit or
## overwritten when the same project reopens (same hash).
## Concurrent rebuilds are idempotent (same files → same output).
## Caller must hold the lock (or accept benign last-writer-wins).
static func rebuild() -> void:
var dir_path := _RegistryPaths.entry_dir()
var dir := DirAccess.open(dir_path)
if dir == null:
write_atomic({"by_path": {}})
return
# Pass 1: scan editor (<hash>.json) and runtime (<hash>.runtime.json) files
# into separate buckets. Each editor item: { data, fpath, port, started_at }.
var editor_items: Array[Dictionary] = []
var runtime_entries: Array[Dictionary] = []
dir.list_dir_begin()
var fname := dir.get_next()
while fname != "":
if not fname.ends_with(".json") or fname.ends_with(".tmp"):
fname = dir.get_next()
continue
var fpath := dir_path.path_join(fname)
var f := FileAccess.open(fpath, FileAccess.READ)
if f == null:
fname = dir.get_next()
continue
var text := f.get_as_text()
f.close()
var parsed = JSON.parse_string(text)
if parsed == null or not parsed is Dictionary or not parsed.has("_key"):
fname = dir.get_next()
continue
var entry: Dictionary = parsed
if fname.ends_with(".runtime.json"):
runtime_entries.append(entry)
else:
editor_items.append({
"data": entry,
"fpath": fpath,
"port": int(entry.get("port", 0)),
"started_at": int(entry.get("started_at", 0)),
})
fname = dir.get_next()
dir.list_dir_end()
# Pass 2: for each port, keep only the newest editor entry (highest
# started_at). Runtime entries carry port -1, so they never participate.
var best_by_port: Dictionary = {} # int → index into editor_items
var stale_files: Array[String] = []
var editor_entries: Array[Dictionary] = []
for i in editor_items.size():
var port: int = editor_items[i]["port"]
if port <= 0:
continue # No port — keep unconditionally.
if not best_by_port.has(port):
best_by_port[port] = i
else:
var prev_idx: int = best_by_port[port]
if editor_items[i]["started_at"] > editor_items[prev_idx]["started_at"]:
# New entry is newer — prune the old one.
stale_files.append(editor_items[prev_idx]["fpath"])
editor_items[prev_idx]["_pruned"] = true
best_by_port[port] = i
else:
# Old entry is newer — prune this one.
stale_files.append(editor_items[i]["fpath"])
editor_items[i]["_pruned"] = true
for item in editor_items:
if item.get("_pruned", false):
continue
editor_entries.append(item["data"])
# Pass 3: merge editor base + runtime overlay by _key (pure).
var by_path := merge_by_path(editor_entries, runtime_entries)
# Delete stale entry files so they don't reappear on next rebuild.
for stale_path in stale_files:
DirAccess.remove_absolute(stale_path)
write_atomic({"by_path": by_path})
## Pure: group editor + runtime entries by _key and produce the by_path map.
## For each _key the row is the editor base (minus _key) with the runtime-owned
## fields overlaid from the matching runtime entry. A runtime entry with no
## editor base contributes its full (schema-complete) shape; an editor entry
## with no runtime overlay keeps its own runtime_port/runtime_pid (null). No
## filesystem access — directly unit-testable.
static func merge_by_path(editor_entries: Array, runtime_entries: Array) -> Dictionary:
# Index runtime entries by _key for overlay lookup.
var runtime_by_key: Dictionary = {}
for re in runtime_entries:
var re_dict: Dictionary = re
runtime_by_key[str(re_dict.get("_key", ""))] = re_dict
var by_path := {}
# Editor bases first — overlay runtime-owned fields where a runtime entry exists.
for ee in editor_entries:
var ee_dict: Dictionary = ee
var key := str(ee_dict.get("_key", ""))
var row: Dictionary = ee_dict.duplicate()
row.erase("_key")
if runtime_by_key.has(key):
var rt: Dictionary = runtime_by_key[key]
for field in _RUNTIME_OWNED_FIELDS:
row[field] = rt.get(field, null)
by_path[key] = row
# Runtime-only entries (no editor base) — use the full runtime shape.
for rkey in runtime_by_key:
if by_path.has(rkey):
continue
var rt_only: Dictionary = runtime_by_key[rkey]
var rt_row: Dictionary = rt_only.duplicate()
rt_row.erase("_key")
by_path[rkey] = rt_row
return by_path