278 lines
12 KiB
GDScript
278 lines
12 KiB
GDScript
@tool
|
|
class_name McpResourceIO
|
|
extends RefCounted
|
|
|
|
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
|
|
|
## Shared helpers for "save a Resource to .tres" and the mutually-exclusive
|
|
## path-vs-resource_path param validation that every resource-authoring
|
|
## handler needs. Extracted to remove 4-way duplication across
|
|
## resource_handler, environment_handler, texture_handler, and curve_handler.
|
|
## Also home to the shared write-a-text-file path + deferred import-settle
|
|
## completion used by both script_handler.create_script and
|
|
## filesystem_handler.write_file (#714).
|
|
|
|
# Bounded settle window for `ResourceLoader.exists(path)` after a fresh text
|
|
# write registers with the filesystem, so an agent calling
|
|
# create_script/write_file -> attach_script back-to-back doesn't race the
|
|
# editor's import pipeline (#261, extended to write_file by #714). Polled once
|
|
# per frame, with an elapsed-time cap below the dispatcher's deferred timeouts
|
|
# for both commands. If import is still not visible at the cap, we still
|
|
# return committed data instead of letting the already-written file surface
|
|
# as DEFERRED_TIMEOUT.
|
|
const IMPORT_SETTLE_MAX_FRAMES := 300
|
|
const IMPORT_SETTLE_MAX_MSEC := 3500
|
|
|
|
|
|
## Validate that exactly one of {path, resource_path} is provided.
|
|
##
|
|
## When `require_property` is true (default), also requires a non-empty
|
|
## `property` param when `path` is given — this matches the semantics of
|
|
## "assign a resource to node.property" (resource_create, texture tools,
|
|
## curve_set_points). Pass false for tools where the path itself IS the
|
|
## target (environment_create assigning to WorldEnvironment.environment).
|
|
##
|
|
## Returns null on success or an error dict on failure.
|
|
static func validate_home(params: Dictionary, require_property: bool = true) -> Variant:
|
|
var node_path: String = params.get("path", "")
|
|
var property: String = params.get("property", "")
|
|
var resource_path: String = params.get("resource_path", "")
|
|
var has_node_target := not node_path.is_empty()
|
|
var has_file_target := not resource_path.is_empty()
|
|
|
|
if has_node_target and has_file_target:
|
|
var both_msg := "Provide either path+property or resource_path, not both" if require_property else "Provide either path or resource_path, not both"
|
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, both_msg)
|
|
if not has_node_target and not has_file_target:
|
|
var none_msg := "Must provide either path+property (assign inline) or resource_path (save .tres)" if require_property else "Must provide either path or resource_path"
|
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, none_msg)
|
|
if require_property and has_node_target and property.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Missing required param: property (required when path is given)")
|
|
return null
|
|
|
|
|
|
## Save `res` to `resource_path` as a .tres/.res file.
|
|
##
|
|
## Handles: res:// prefix validation, overwrite check, parent-directory
|
|
## creation, ResourceSaver.save error reporting, and the post-save
|
|
## EditorFileSystem.update_file() so the dock picks up the change.
|
|
##
|
|
## `label` is the human-readable resource-kind for error messages (e.g.
|
|
## "Environment", "Gradient texture", "Curve"). `extra_fields` is merged
|
|
## into the success response alongside the standard fields
|
|
## (`resource_path`, `overwritten`, `undoable: false`, `reason`). Passing
|
|
## a `reason` key in `extra_fields` overrides the default — useful for
|
|
## tools that edit existing files rather than creating fresh ones.
|
|
##
|
|
## `pause_target` should be the handler's `McpConnection`. When supplied,
|
|
## `pause_processing` is flipped on around `ResourceSaver.save()` so the
|
|
## dispatcher's WebSocket pump can't re-enter while Godot pumps
|
|
## `Main::iteration()` for the resource-save's progress UI / script-class
|
|
## update task. Without this guard a queued command landing during the
|
|
## save can trigger another `save_to_disk` that tries to add the same
|
|
## `update_scripts_classes` editor task — "Task already exists" → null
|
|
## deref → SIGSEGV. Same family of bug as godotengine/godot#118545 and
|
|
## the same mitigation as `SceneHandler`'s `save_scene*` wraps. See
|
|
## issue #288.
|
|
##
|
|
## Returns either an error dict or a {"data": {...}} success dict — ready
|
|
## for the handler to return directly.
|
|
static func save_to_disk(
|
|
res: Resource,
|
|
resource_path: String,
|
|
overwrite: bool,
|
|
label: String,
|
|
extra_fields: Dictionary = {},
|
|
pause_target: McpConnection = null,
|
|
) -> Dictionary:
|
|
var path_err = McpPathValidator.path_error(resource_path, "resource_path", true)
|
|
if path_err != null:
|
|
return path_err
|
|
|
|
var existed_before := FileAccess.file_exists(resource_path)
|
|
if existed_before and not overwrite:
|
|
return ErrorCodes.make(
|
|
ErrorCodes.INVALID_PARAMS,
|
|
"%s already exists at %s (pass overwrite=true to replace)" % [label, resource_path]
|
|
)
|
|
# Captured BEFORE the overwrite below so a resave of an already-uid'd file
|
|
# (overwrite=true) can restore its own uid instead of losing it — see
|
|
# ensure_uid's doc comment.
|
|
var prior_uid := ResourceLoader.get_resource_uid(resource_path) if existed_before else ResourceUID.INVALID_ID
|
|
|
|
var dir_path := resource_path.get_base_dir()
|
|
var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
|
|
if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
|
|
return ErrorCodes.make(
|
|
ErrorCodes.INTERNAL_ERROR,
|
|
"Failed to create directory %s: %s" % [dir_path, error_string(mkdir_err)]
|
|
)
|
|
|
|
if pause_target != null:
|
|
pause_target.pause_processing = true
|
|
var save_err := ResourceSaver.save(res, resource_path)
|
|
if pause_target != null:
|
|
pause_target.pause_processing = false
|
|
if save_err != OK:
|
|
return ErrorCodes.make(
|
|
ErrorCodes.INTERNAL_ERROR,
|
|
"Failed to save %s to %s: %s" % [label, resource_path, error_string(save_err)]
|
|
)
|
|
var uid_err := ensure_uid(resource_path, prior_uid)
|
|
if uid_err != OK:
|
|
return ErrorCodes.make(
|
|
ErrorCodes.INTERNAL_ERROR,
|
|
"%s saved to %s but failed to write its uid: %s" % [label, resource_path, error_string(uid_err)]
|
|
)
|
|
|
|
var efs := EditorInterface.get_resource_filesystem()
|
|
if efs != null:
|
|
efs.update_file(resource_path)
|
|
|
|
var data := {
|
|
"resource_path": resource_path,
|
|
"overwritten": existed_before,
|
|
"undoable": false,
|
|
"reason": "File creation is persistent; delete the file manually to revert",
|
|
}
|
|
attach_cleanup_hint(data, existed_before, [resource_path])
|
|
# merge with overwrite=true so callers (e.g. curve_set_points editing an
|
|
# existing .tres) can supply a domain-specific `reason`.
|
|
data.merge(extra_fields, true)
|
|
return {"data": data}
|
|
|
|
|
|
## Save `res` to `resource_path` with the same `pause_processing` re-entrancy
|
|
## guard as `save_to_disk` (see its doc for the #288 SIGSEGV background), for
|
|
## call sites that need to pick their own error handling / overwrite policy
|
|
## instead of `save_to_disk`'s full validate+mkdir+overwrite-guard bundle
|
|
## (undo/redo callables reloading-mutating-resaving an existing resource,
|
|
## `apply_to_node`'s inline-then-save branch). Returns the raw
|
|
## `ResourceSaver.save` error code.
|
|
static func guarded_save(res: Resource, resource_path: String, pause_target: McpConnection) -> int:
|
|
var prior_uid := ResourceLoader.get_resource_uid(resource_path) if FileAccess.file_exists(resource_path) else ResourceUID.INVALID_ID
|
|
if pause_target != null:
|
|
pause_target.pause_processing = true
|
|
var save_err := ResourceSaver.save(res, resource_path)
|
|
if pause_target != null:
|
|
pause_target.pause_processing = false
|
|
if save_err != OK:
|
|
return save_err
|
|
return ensure_uid(resource_path, prior_uid)
|
|
|
|
|
|
## Make `resource_path` carry a stable uid after a successful
|
|
## `ResourceSaver.save()`, matching what Godot's own "New Scene"/"New
|
|
## Resource" editor flows always embed. A bare `ResourceSaver.save()` call
|
|
## does neither on its own: a brand-new file gets no `uid=` at all, and
|
|
## resaving a file that already had one silently drops it (#737). Call this
|
|
## immediately after every successful save.
|
|
##
|
|
## `prior_uid` is whatever `ResourceLoader.get_resource_uid(resource_path)`
|
|
## returned BEFORE this save overwrote the file (pass `ResourceUID.INVALID_ID`
|
|
## for a brand-new path). Reusing the prior id — instead of always minting a
|
|
## fresh one — keeps any `uid://...` references elsewhere in the project
|
|
## resolving to the same file.
|
|
##
|
|
## Returns the `Error` from `ResourceSaver.set_uid()` so callers can surface a
|
|
## uid-write failure instead of silently reporting success on a file that
|
|
## didn't end up with the uid it was supposed to get.
|
|
static func ensure_uid(resource_path: String, prior_uid: int) -> Error:
|
|
var id := prior_uid
|
|
if id == ResourceUID.INVALID_ID:
|
|
id = ResourceUID.create_id()
|
|
return ResourceSaver.set_uid(resource_path, id)
|
|
|
|
|
|
## Attach a `cleanup.rm` hint listing `paths` to `data` — only when the call
|
|
## just created a new file (`existed_before == false`). On overwrite the field
|
|
## is omitted because the caller already had the file on disk, and handing
|
|
## them a cleanup list would invite dropping user content instead of just
|
|
## scratch artifacts. Used by write-and-return handlers (create_script,
|
|
## filesystem_write_text, resource_create/save_to_disk) so callers running
|
|
## transient smoke tests can rm artifacts without tracking paths. See #82.
|
|
static func attach_cleanup_hint(data: Dictionary, existed_before: bool, paths: Array) -> void:
|
|
if existed_before:
|
|
return
|
|
data["cleanup"] = {"rm": paths}
|
|
|
|
|
|
## Shared write-a-text-file path (#714): parent-directory mkdir, write +
|
|
## flush with an explicit error check so a truncated write (disk full,
|
|
## permission flip mid-write) surfaces as an error instead of plain success.
|
|
## Deliberately does NOT call `EditorFileSystem.update_file()` — callers
|
|
## register the file themselves after assembling their response fields, so
|
|
## the registration comment (the dsarno/godot#6 scan-stacking rationale)
|
|
## stays next to the call. Returns null on success or an error dict ready
|
|
## to return from the handler.
|
|
static func write_text_to_disk(path: String, content: String) -> Variant:
|
|
var dir_path := path.get_base_dir()
|
|
if not DirAccess.dir_exists_absolute(dir_path):
|
|
var err := DirAccess.make_dir_recursive_absolute(dir_path)
|
|
if err != OK:
|
|
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
|
|
|
|
var file := FileAccess.open(path, FileAccess.WRITE)
|
|
if file == null:
|
|
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file for writing: %s" % path)
|
|
|
|
file.store_string(content)
|
|
file.flush()
|
|
var write_err := file.get_error()
|
|
file.close()
|
|
if write_err != OK:
|
|
return ErrorCodes.make(
|
|
ErrorCodes.INTERNAL_ERROR,
|
|
"Write failed for %s (%s); file may be truncated" % [path, error_string(write_err)]
|
|
)
|
|
return null
|
|
|
|
|
|
# `static` is load-bearing: the deferred completion captures no `self`, so the
|
|
# coroutine survives even if the calling handler RefCounted is freed mid-await.
|
|
# Under concurrent create storms with editor_reload_plugin fired during the
|
|
# burst, an instance-method coroutine is otherwise GC'd between `await` and
|
|
# resume, producing "Resumed function ... after await, but class instance is
|
|
# gone" errors and dropping the response. Keep this function static and
|
|
# parameterise everything it needs explicitly — do not reference instance
|
|
# state. Shared by create_script and write_file's fresh-`.gd` path (#714).
|
|
static func finish_text_write_deferred(
|
|
connection: McpConnection,
|
|
request_id: String,
|
|
path: String,
|
|
data: Dictionary,
|
|
) -> void:
|
|
if not is_instance_valid(connection):
|
|
return
|
|
var tree := connection.get_tree()
|
|
if tree == null:
|
|
return
|
|
var deadline_ms := Time.get_ticks_msec() + IMPORT_SETTLE_MAX_MSEC
|
|
# Let _dispatch() return DEFERRED_RESPONSE and register the request before
|
|
# this coroutine can send a committed result. ResourceLoader.exists(path)
|
|
# may already be true on fast imports; without this handoff the connection
|
|
# treats the response as late/unregistered and drops it, then the dispatcher
|
|
# times out a file that was already written (#324). The deadline starts
|
|
# before this await so a slow handoff frame is counted against the bounded
|
|
# settle window.
|
|
await tree.process_frame
|
|
var frames := 0
|
|
while (
|
|
frames < IMPORT_SETTLE_MAX_FRAMES
|
|
and Time.get_ticks_msec() < deadline_ms
|
|
and not ResourceLoader.exists(path)
|
|
):
|
|
await tree.process_frame
|
|
frames += 1
|
|
# If the plugin tears down (_exit_tree frees the connection) during the
|
|
# await, is_instance_valid() goes false and we drop the response silently —
|
|
# the server's request timeout will surface the failure to the caller.
|
|
if not is_instance_valid(connection):
|
|
return
|
|
var payload := data.duplicate()
|
|
var settled := ResourceLoader.exists(path)
|
|
payload["import_settled"] = settled
|
|
payload["import_settle"] = "settled" if settled else "timeout"
|
|
payload["import_pending"] = not settled
|
|
connection.send_deferred_response(request_id, {"data": payload})
|