From 99f5c4d9d1ee5a074d7274f55e57260e003b476a Mon Sep 17 00:00:00 2001 From: odd-bot Date: Thu, 12 Nov 2026 12:00:29 -0500 Subject: [PATCH 1/5] Write strict JSON, replace pieces safely, and stop crash dialogs in tests - MCP messages and a tool result's text are written with StrictJson. - A piece is written to a staging folder and checked there; only then are the piece files it replaces set aside, and they come back if the move fails. A folder holding anything a piece may not is refused. - make_* reports success only when the piece is checked and listed. - The checker's crash fault kills its process instead of crashing it, so no system crash dialog appears. - The window test runs its script for real and reads its output. - check_piece says it can go online; companions are named for their first model; the app no longer reports a version of its own, as in Builders. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 ------ project.godot | 1 - src/app/main.gd | 5 ----- src/mcp/server.gd | 2 +- src/mcp/tools.gd | 18 +++++++++------- src/pieces/piece_check.gd | 2 +- src/pieces/piece_validator.gd | 1 - src/studio/studio.gd | 39 +++++++++++++++++++++++------------ src/studio/studio_export.gd | 17 +++++++++++++++ src/studio/studio_files.gd | 37 +++++++++++++++++++++++++++++++++ tests/mcp_test.gd | 16 ++++++++++++++ tests/studio_test.gd | 22 ++++++++++++++++++++ tests/window_test.gd | 10 ++++++--- 13 files changed, 138 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 144b044..86c56f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -284,8 +284,6 @@ jobs: if (-not [IO.Path]::GetExtension($iconFile)) { $iconFile += '.icns' } if ((Get-FileHash ".smoke/AgentGuildStudio.app/Contents/Resources/$iconFile").Hash -ne (Get-FileHash brand/icon.icns).Hash) { throw 'The packaged app does not use brand/icon.icns.' } $exe = '.smoke/AgentGuildStudio.app/Contents/MacOS/Agent Guild Studio' - $version = & $exe --headless --no-header -- --version - if ($LASTEXITCODE -ne 0 -or -not $version) { throw 'The packaged app did not start.' } node tests/mcp_smoke.mjs $exe if ($LASTEXITCODE -ne 0) { throw 'The packaged app did not serve MCP.' } - name: Signing checks @@ -347,10 +345,6 @@ jobs: run: | ditto -x -k "$RUNNER_TEMP/signed/AgentGuildStudio-macos.zip" "$RUNNER_TEMP/smoke" exe="$RUNNER_TEMP/smoke/AgentGuildStudio.app/Contents/MacOS/Agent Guild Studio" - if [ -z "$("$exe" --headless --no-header -- --version)" ]; then - echo "::error::The signed app did not start." - exit 1 - fi node tests/mcp_smoke.mjs "$exe" - name: Check the signature run: | diff --git a/project.godot b/project.godot index a6d15d5..46c56e8 100644 --- a/project.godot +++ b/project.godot @@ -7,7 +7,6 @@ config/name="Agent Guild Studio" config/use_custom_user_dir=true config/custom_user_dir_name="AgentGuildStudio" config/description="Make characters and companions for Agent Guild." -config/version="0.0.0" run/main_scene="res://main.tscn" config/features=PackedStringArray("4.7", "Forward Plus") boot_splash/bg_color=Color(0.043, 0.039, 0.059, 1) diff --git a/src/app/main.gd b/src/app/main.gd index dc6ddbb..5b9e223 100644 --- a/src/app/main.gd +++ b/src/app/main.gd @@ -8,7 +8,6 @@ extends Node ## --mcp serve MCP on stdin and stdout, so an agent can make pieces (with Godot's --headless: no window) ## --world=guild --theme=light|dark the world and time of day the window opens on ## --shot=FILE.png save a picture of the window, then quit -## --version print this build's version, then quit const TOOLS := { "anthropic": "Claude Code", "openai": "Codex CLI", "google": "Antigravity CLI", "xai": "Grok Build", "docker": "Docker Agent", "shell": "Shell" } const GAP := 16.0 @@ -42,10 +41,6 @@ func _ready() -> void: continue var parts := arg.trim_prefix("--").split("=", true, 1) options[parts[0]] = parts[1] if parts.size() > 1 else "1" - if options.has("version"): - print(ProjectSettings.get_setting("application/config/version")) - get_tree().quit() - return if options.has("check-piece") or options.has("check-source"): get_tree().quit(PieceCheck.run(options)) return diff --git a/src/mcp/server.gd b/src/mcp/server.gd index 9609e56..2472f1e 100644 --- a/src/mcp/server.gd +++ b/src/mcp/server.gd @@ -225,4 +225,4 @@ func _send_error(id: Variant, code: int, message: String) -> void: _send({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}) func _send(msg: Dictionary) -> void: - write_line.call(JSON.stringify(msg)) + write_line.call(StrictJson.stringify(msg)) diff --git a/src/mcp/tools.gd b/src/mcp/tools.gd index 4560193..0843720 100644 --- a/src/mcp/tools.gd +++ b/src/mcp/tools.gd @@ -35,7 +35,7 @@ func failure(text: String, data: Variant = null) -> Dictionary: return out func _done(text: String, data: Variant = null) -> Dictionary: - var out := {"content": [{"type": "text", "text": text if data == null else text + "\n" + JSON.stringify(data)}], "isError": false} + var out := {"content": [{"type": "text", "text": text if data == null else text + "\n" + StrictJson.stringify(data)}], "isError": false} if data != null: out["structuredContent"] = data return out @@ -148,7 +148,7 @@ func _definitions() -> Array: "hue": hue, "tint": tint, "change_color": change_color, "spread": spread, "replace": replace}, ["slot", "look", "to"]), "annotations": writes}, {"name": "check_piece", "title": "Check a piece", "description": "Checks a piece folder (piece.json and its models) as Studio checks the ones it makes, for the world and slot its piece.json names.", - "inputSchema": _object({"folder": _path("The piece's folder, as a full path.")}, ["folder"]), "annotations": read}, + "inputSchema": _object({"folder": _path("The piece's folder, as a full path.")}, ["folder"]), "annotations": {"readOnlyHint": true, "openWorldHint": true}}, {"name": "list_pieces", "title": "List pieces", "description": "The pieces made with Studio on this computer, by the person in its window or by an agent: each one's `piece` value, title, slot and the folder it was saved to.", "inputSchema": _object({}, []), "annotations": read}, {"name": "remove_piece", "title": "Remove a piece", "description": "Takes a piece off Studio's list. The folder it was saved to is left as it is.", @@ -225,6 +225,9 @@ func _make(ctx: Dictionary, args: Dictionary, options: Dictionary, recipe: Dicti return failure("%s is a file, not a folder." % to) if DirAccess.dir_exists_absolute(to) and not args.get("replace", false) and not (DirAccess.get_files_at(to).is_empty() and DirAccess.get_directories_at(to).is_empty()): return failure("%s already holds files. Choose another folder, or set `replace`." % to) + var other := StudioExport.other(to) + if other != "": + return failure("%s holds %s, which a piece folder may not. Choose another folder." % [to, other]) if args.has("title"): options.title = args.title var where := Studio.slot(options.slot) @@ -243,11 +246,12 @@ func _make(ctx: Dictionary, args: Dictionary, options: Dictionary, recipe: Dicti var library: PieceLibrary = server.host.pieces library.refresh() var id := await library.add_local(folder, "", recipe) - var listed: bool = library.pieces.get(id, {}).get("error", "x") == "" - if listed: - library.pieces[id].saved = folder - library.keep() - return _done("Made and checked. The piece is in %s." % folder, {"folder": folder, "files": files, "slot": options.slot, "notes": notes, "piece": id if listed else ""}) + var problem: String = library.pieces.get(id, {}).get("error", "it was not listed") + if problem != "": + return failure("The piece in %s was made, but Studio couldn't list it: %s." % [folder, problem], {"folder": folder, "files": files, "slot": options.slot, "notes": notes, "problem": problem}) + library.pieces[id].saved = folder + library.keep() + return _done("Made and checked. The piece is in %s." % folder, {"folder": folder, "files": files, "slot": options.slot, "notes": notes, "piece": id}) ## The world's art, downloaded if it isn't here, then loaded; "" or why not. func _art(ctx: Dictionary, world: String) -> String: diff --git a/src/pieces/piece_check.gd b/src/pieces/piece_check.gd index 180a5b8..8d26430 100644 --- a/src/pieces/piece_check.gd +++ b/src/pieces/piece_check.gd @@ -347,7 +347,7 @@ static func run(options: Dictionary) -> int: var watchdog := Thread.new() watchdog.start(_watch.bind(Time.get_ticks_msec() + int(float(options.get("check-timeout", TIMEOUT)) * 1000) + 5000)) if options.get("check-fault", "") == "crash": - OS.crash("A check fault, for the tests.") + OS.kill(OS.get_process_id()) var verdict: Dictionary if options.has("check-source"): verdict = StudioSource.check(options["check-source"], options.get("check-dir", "")) diff --git a/src/pieces/piece_validator.gd b/src/pieces/piece_validator.gd index 15a6344..a265ace 100644 --- a/src/pieces/piece_validator.gd +++ b/src/pieces/piece_validator.gd @@ -9,7 +9,6 @@ var pid := -1 var _count := 0 var _out := "" -# stage "load" (no header checks) and fault "crash" are for tests/pieces_test.gd only. func check(path: String, kind: String, clips: Dictionary, stage := "", fault := "") -> Dictionary: var args := ["--check-piece=" + ProjectSettings.globalize_path(path), "--check-kind=" + kind, "--check-clips=" + PieceCheck.clips_arg(clips)] diff --git a/src/studio/studio.gd b/src/studio/studio.gd index 5b1a194..0c89c1c 100644 --- a/src/studio/studio.gd +++ b/src/studio/studio.gd @@ -11,7 +11,7 @@ class_name Studio ## AgentGuildStudio -- --build=ghost.glb[,b.glb,c.glb,d.glb] --slot=grove/companion --out=DIR [--move=rigid|soft] [--variants=tint|turn] [--turn=DEGREES] ## AgentGuildStudio -- --recolour=look0|look1|look2|agent|shell --slot=... --out=DIR [--pick=#RRGGBB|U,V] [--spread=0.15] [--hue=DEGREES] [--tint=#RRGGBB] -## Each world's Studio clip set (tools/premium.py studio): its motions with +## Each world's Studio clip set: its motions with ## both arms free, on a skeleton of its own, for character pieces. const CLIP_SETS := "res://assets/premium/studio/%s.glb" @@ -323,29 +323,42 @@ static func run(host: Node, options: Dictionary) -> int: if made.has("reason"): return _fail(made.reason) var models: Array = made.pieces.map(func(p): return StudioExport.scene(p, where.kind)) - return await _save(host, where, models, clips, options.get("title", options.build.get_file().get_basename()), made.notes, options) + return await _save(host, where, models, clips, options.get("title", options.build.get_slice(",", 0).get_file().get_basename()), made.notes, options) ## Writes the models as a piece folder, frees them, and checks each as Looks ## would. static func _save(host: Node, where: Dictionary, models: Array, clips: Dictionary, title: String, notes: Array, options: Dictionary) -> int: - var folder := ProjectSettings.globalize_path(options.get("out", "user://studio/build")) + var folder := ProjectSettings.globalize_path(options.get("out", "user://studio/build")).rstrip("/\\") + var part := folder.get_base_dir().path_join(".%s.part" % folder.get_file()) var manifest := { "format": 1, "kind": where.kind, "world": where.world, "title": title } if where.kind == "character": manifest.crew = where.crew - var reason := StudioExport.save(models, folder, manifest) + var other := StudioExport.other(folder) + if other != "": + for model in models: + model.free() + return _fail("%s holds %s, which a piece may not" % [folder, other]) + StudioFiles.remove_tree(part) + var reason := StudioExport.save(models, part, manifest) for model in models: model.free() + if reason == "": + var validator := PieceValidator.new() + host.add_child(validator) + for k in models.size(): + var file: String = PieceLibrary.FILES[where.kind][k] + var verdict: Dictionary = await validator.check(part.path_join(file), where.kind, clips) + if verdict.reason != "": + reason = "%s did not pass its check: %s" % [file, verdict.reason] + break + validator.queue_free() + if reason == "": + reason = PieceLibrary.check_folder(part).get("reason", "") + if reason == "": + reason = StudioFiles.move_in(part, folder, StudioExport.names()) + StudioFiles.remove_tree(part) if reason != "": return _fail(reason) - var validator := PieceValidator.new() - host.add_child(validator) - for k in models.size(): - var file: String = PieceLibrary.FILES[where.kind][k] - var verdict: Dictionary = await validator.check(folder.path_join(file), where.kind, clips) - if verdict.reason != "": - validator.queue_free() - return _fail("%s did not pass its check: %s" % [file, verdict.reason]) - validator.queue_free() said = { "folder": folder, "notes": notes } if not quiet: for note in notes: diff --git a/src/studio/studio_export.gd b/src/studio/studio_export.gd index ae68340..46575cc 100644 --- a/src/studio/studio_export.gd +++ b/src/studio/studio_export.gd @@ -78,6 +78,23 @@ static func write_glb(root: Node, path: String) -> Error: var err := doc.append_from_scene(root, state) return doc.write_to_filesystem(state, path) if err == OK else err +static func names() -> Array: + var out: Array = ["piece.json"] + PieceLibrary.PREVIEWS + for kind in PieceLibrary.FILES: + out += PieceLibrary.FILES[kind] + return out + +static func other(folder: String) -> String: + if not DirAccess.dir_exists_absolute(folder): + return "" + var dirs := DirAccess.get_directories_at(folder) + if not dirs.is_empty(): + return dirs[0] + for file in DirAccess.get_files_at(folder): + if not file in names(): + return file + return "" + ## A piece folder Looks takes in: its model (a character's one, or a ## companion's four, in order) and piece.json. static func save(models: Array, folder: String, manifest: Dictionary) -> String: diff --git a/src/studio/studio_files.gd b/src/studio/studio_files.gd index 09df14e..b9efa80 100644 --- a/src/studio/studio_files.gd +++ b/src/studio/studio_files.gd @@ -14,3 +14,40 @@ static func remove_tree(path: String) -> void: for name in dir.get_files(): dir.remove(name) DirAccess.remove_absolute(path) + +## Moves the files in `part` into `folder`, first setting aside the files +## `folder` holds of `names`, which go back if any move fails; "" or why not. +static func move_in(part: String, folder: String, names: Array) -> String: + var previous := folder.get_base_dir().path_join(".%s.previous" % folder.get_file()) + var made := not DirAccess.dir_exists_absolute(folder) + remove_tree(previous) + DirAccess.make_dir_recursive_absolute(folder) + DirAccess.make_dir_recursive_absolute(previous) + var aside := [] + var placed := [] + var reason := "" + for name in names: + if FileAccess.file_exists(folder.path_join(name)): + if DirAccess.rename_absolute(folder.path_join(name), previous.path_join(name)) != OK: + reason = "%s could not be set aside" % folder.path_join(name) + break + aside.append(name) + if reason == "": + for name in DirAccess.get_files_at(part): + if DirAccess.rename_absolute(part.path_join(name), folder.path_join(name)) != OK: + reason = "it could not be moved into %s" % folder + break + placed.append(name) + if reason == "": + remove_tree(previous) + return "" + for name in placed: + DirAccess.remove_absolute(folder.path_join(name)) + var kept := true + for name in aside: + kept = DirAccess.rename_absolute(previous.path_join(name), folder.path_join(name)) == OK and kept + if kept: + remove_tree(previous) + if made: + DirAccess.remove_absolute(folder) + return reason if kept else "%s; the files it held are in %s" % [reason, previous] diff --git a/tests/mcp_test.gd b/tests/mcp_test.gd index 0cced2b..40626f2 100644 --- a/tests/mcp_test.gd +++ b/tests/mcp_test.gd @@ -59,6 +59,7 @@ func run(tree: SceneTree) -> Array: var names: Array = listed.get("tools", []).map(func(t): return t.name) check(names == ["list_slots", "make_character", "make_companions", "recolor_look", "check_piece", "list_pieces", "remove_piece", "show_window"], "the tools are %s" % [names]) check(listed.get("tools", []).all(func(t): return t.inputSchema.type == "object" and t.annotations.has("readOnlyHint") and t.has("description")), "a tool lacks a schema, hints or a description") + check(listed.tools.filter(func(t): return t.name == "check_piece")[0].annotations.openWorldHint == true, "check_piece, which can download a world's art, says it stays offline") for name in ["make_character", "make_companions", "recolor_look"]: var props: Dictionary = listed.tools.filter(func(t): return t.name == name)[0].inputSchema.properties check(props.has("hue") and props.has("tint") and props.has("change_color") and props.has("spread"), "%s takes no color choices" % name) @@ -90,9 +91,24 @@ func run(tree: SceneTree) -> Array: check(color.get("isError") == true and "color" in String(color.content[0].text) and not DirAccess.dir_exists_absolute(to), "a tint that is no color was not refused before any work") var nothing := await _call(tree, "recolor_look", { "slot": "guild/anthropic", "look": "look1", "to": to }) check(nothing.get("isError") == true and "hue" in String(nothing.content[0].text), "a recolor with no color choice was not refused") + var theirs := ProjectSettings.globalize_path(OUT + "theirs") + DirAccess.make_dir_recursive_absolute(theirs) + FileAccess.open(theirs.path_join("notes.txt"), FileAccess.WRITE).store_string("mine") + var kept := await _call(tree, "make_character", { "model": "C:/x.glb", "slot": "guild/anthropic", "to": theirs, "replace": true }) + check(kept.get("isError") == true and "notes.txt" in String(kept.content[0].text) and FileAccess.get_file_as_string(theirs.path_join("notes.txt")) == "mine", + "a folder holding someone's own file was not refused before any work: %s" % kept) var four := await _call(tree, "make_companions", { "models": ["a.glb", "b.glb"], "slot": "guild/companion", "to": to }) check(four.get("isError") == true and "one model or four" in String(four.content[0].text), "two models for companions were not refused") check(host.opened == 1, "a tool other than show_window opened a window") + + # Text from a person's files (a bone, a title) can hold control characters; + # the agent's client reads each line with a strict JSON parser. + var raw := [] + _server.write_line = func(line: String): raw.append(line) + _server.receive(StrictJson.stringify({ "jsonrpc": "2.0", "id": "odd", "method": "tools/call", "params": { "name": "bone" + String.chr(1) + String.chr(11), "arguments": {} } })) + _server.write_line = func(line: String): _lines.append(JSON.parse_string(line)) + check(raw.size() == 1 and raw[0].contains("bone\\u0001\\u000b") and RegEx.create_from_string("[\\x00-\\x1f]|\\\\v").search(raw[0]) == null, + "a reply holds raw control characters or \\v: %s" % [raw]) await _lifecycle(tree, host) other.free() diff --git a/tests/studio_test.gd b/tests/studio_test.gd index 34f1cac..4c3c144 100644 --- a/tests/studio_test.gd +++ b/tests/studio_test.gd @@ -80,6 +80,7 @@ func run(tree: SceneTree) -> Array: var verdict: Dictionary = await validator.check(folder.path_join("character.glb"), "character", lengths) check(verdict.reason == "", "the piece did not pass the game's check: %s" % verdict.reason) validator.free() + await _replaces(tree, piece, lengths) _names() await _rigs(tree, default, lengths) await _companions(tree) @@ -893,6 +894,27 @@ func _gltf(folder: String, name: String, images: Array, buffer: String) -> Strin _put(folder.path_join(name), JSON.stringify(gltf).to_utf8_buffer()) return folder.path_join(name) +func _replaces(tree: SceneTree, piece: Dictionary, lengths: Dictionary) -> void: + var where := { "world": "guild", "kind": "character", "crew": "anthropic" } + var folder := ProjectSettings.globalize_path(OUT.path_join("replaced")) + var staged := func(): return Array(DirAccess.get_directories_at(folder.get_base_dir())).filter(func(d): return d.begins_with(".replaced.")) + _put(folder.path_join("character.glb"), "old".to_utf8_buffer()) + _put(folder.path_join("piece.json"), "old".to_utf8_buffer()) + var failed := await Studio._save(tree.root, where, [StudioExport.scene(piece, "character")], { "walk": 99.0 }, "Fails", [], { "out": folder }) + check(failed == 1 and _files(folder) == ["character.glb", "piece.json"] and FileAccess.get_file_as_string(folder.path_join("character.glb")) == "old", + "a piece that failed its check changed the files it would have replaced: %s" % [_files(folder)]) + _put(folder.path_join("notes.txt"), "mine".to_utf8_buffer()) + var theirs := await Studio._save(tree.root, where, [StudioExport.scene(piece, "character")], lengths, "Theirs", [], { "out": folder }) + check(theirs == 1 and "notes.txt" in String(Studio.said.reason) and _files(folder) == ["character.glb", "notes.txt", "piece.json"] and FileAccess.get_file_as_string(folder.path_join("character.glb")) == "old", + "a folder holding someone's own file was written into: %s" % [_files(folder)]) + DirAccess.remove_absolute(folder.path_join("notes.txt")) + _put(folder.path_join("companion_0.glb"), "stale".to_utf8_buffer()) + _put(folder.path_join("preview.jpg"), "stale".to_utf8_buffer()) + var saved := await Studio._save(tree.root, where, [StudioExport.scene(piece, "character")], lengths, "Replaced", [], { "out": folder }) + check(saved == 0 and _files(folder) == ["character.glb", "piece.json"] and not PieceLibrary.check_folder(folder).has("reason") and FileAccess.get_file_as_string(folder.path_join("character.glb")) != "old", + "a piece did not cleanly replace the one in its folder: %s %s" % [Studio.said.get("reason", ""), _files(folder)]) + check(staged.call().is_empty(), "a save left its staging behind: %s" % [staged.call()]) + func _put(path: String, bytes: PackedByteArray) -> void: DirAccess.make_dir_recursive_absolute(path.get_base_dir()) FileAccess.open(path, FileAccess.WRITE).store_buffer(bytes) diff --git a/tests/window_test.gd b/tests/window_test.gd index ac7ff70..29de140 100644 --- a/tests/window_test.gd +++ b/tests/window_test.gd @@ -121,10 +121,14 @@ func _window_processes(tree: SceneTree, main: Node, out: String) -> void: check(pids.all(func(pid): return OS.get_process_exit_code(pid) == 0), "a window process exit code was lost") if OS.get_name() != "Windows": var writer := out.path_join("Bill's $cash `tools`.gd") - FileAccess.open(writer, FileAccess.WRITE).store_string("extends SceneTree\nfunc _initialize():\n\tprint('window stdout')\n\tprinterr('window stderr')\n\tquit(0)\n") + var ran := out.path_join("ran") + FileAccess.open(writer, FileAccess.WRITE).store_string("extends SceneTree\nfunc _initialize():\n\tFileAccess.open(%s, FileAccess.WRITE).store_string('ran')\n\tprint('window stdout')\n\tprinterr('window stderr')\n\tquit(0)\n" % var_to_str(ran)) command = main.window_command(OS.get_executable_path(), ["--headless", "--no-header", "--script", writer]) - var output := [] - check(OS.execute(command[0], command[1], output, true) == 0 and "".join(output) == "", "the standalone window wrote to MCP's stdout or stderr") + var child := OS.execute_with_pipe(command[0], command[1], false) + check(not child.is_empty() and await TestRunner.until(tree, func(): return not OS.is_process_running(child.pid)), "the standalone window did not end") + if not child.is_empty(): + var said: String = child.stdio.get_buffer(4096).get_string_from_utf8() + child.stderr.get_buffer(4096).get_string_from_utf8() + check(OS.get_process_exit_code(child.pid) == 0 and FileAccess.file_exists(ran) and said == "", "the standalone window did not run its script, or wrote to MCP's stdout or stderr: %s" % said) func _folder(name: String) -> String: var dir := ProjectSettings.globalize_path(OUT + "folders/" + name) From 61b17ef1c626e7fa0fef92c37ff574c933b25655 Mon Sep 17 00:00:00 2001 From: odd-bot Date: Thu, 12 Nov 2026 12:16:41 -0500 Subject: [PATCH 2/5] Report each build's version: 0.1.N for release build-N package.ps1 -Release N stamps config/version and the Windows and macOS version fields just before export and restores both files after. Studio reports it in --version, MCP serverInfo and on its first screen ("Build N", or "Development build" when unstamped), and CI checks each packaged and signed app says exactly 0.1.N. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 19 +++++++++++++++---- src/app/main.gd | 5 +++++ src/mcp/server.gd | 8 +++++--- src/studio/studio.gd | 4 ++++ src/ui/home_panel.gd | 2 ++ tests/mcp_test.gd | 6 +++++- tests/package_smoke.ps1 | 8 +++++++- tests/window_test.gd | 14 ++++++++++++++ tools/package.ps1 | 26 +++++++++++++++++++++++++- 9 files changed, 82 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86c56f7..eb56723 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,9 +110,9 @@ jobs: & $env:GODOT --headless --path . --script res://tests/site_commands.gd if ($LASTEXITCODE -ne 0) { throw 'The page and the Connect dialog differ.' } - name: Package - run: ./tools/package.ps1 -Godot $env:GODOT + run: ./tools/package.ps1 -Godot $env:GODOT -Release ${{ github.run_number }} - name: Run the packaged app - run: ./tests/package_smoke.ps1 -Zip build/AgentGuildStudio-windows.zip -Dir .smoke + run: ./tests/package_smoke.ps1 -Zip build/AgentGuildStudio-windows.zip -Dir .smoke -Release ${{ github.run_number }} - id: zip if: github.event_name == 'push' run: Add-Content $env:GITHUB_OUTPUT "sha256=$((Get-FileHash build/AgentGuildStudio-windows.zip).Hash.ToLower())" @@ -184,7 +184,7 @@ jobs: Compress-Archive -LiteralPath "$env:RUNNER_TEMP/stage/AgentGuildStudio" -DestinationPath $zip Add-Content $env:GITHUB_OUTPUT "sha256=$((Get-FileHash $zip).Hash.ToLower())" - name: Run the signed app - run: ./tests/package_smoke.ps1 -Zip "$env:RUNNER_TEMP/signed/AgentGuildStudio-windows.zip" -Dir "$env:RUNNER_TEMP/smoke" + run: ./tests/package_smoke.ps1 -Zip "$env:RUNNER_TEMP/signed/AgentGuildStudio-windows.zip" -Dir "$env:RUNNER_TEMP/smoke" -Release ${{ github.run_number }} - name: Check the signature env: PUBLISHER: ${{ vars.WINDOWS_PUBLISHER }} @@ -274,7 +274,7 @@ jobs: & $env:GODOT --headless --path . --script res://tests/site_commands.gd if ($LASTEXITCODE -ne 0) { throw 'The page and the Connect dialog differ.' } - name: Package - run: ./tools/package.ps1 -Godot $env:GODOT -Platform macOS + run: ./tools/package.ps1 -Godot $env:GODOT -Platform macOS -Release ${{ github.run_number }} - name: Run the packaged app run: | ditto -x -k build/AgentGuildStudio-macos.zip .smoke @@ -284,6 +284,10 @@ jobs: if (-not [IO.Path]::GetExtension($iconFile)) { $iconFile += '.icns' } if ((Get-FileHash ".smoke/AgentGuildStudio.app/Contents/Resources/$iconFile").Hash -ne (Get-FileHash brand/icon.icns).Hash) { throw 'The packaged app does not use brand/icon.icns.' } $exe = '.smoke/AgentGuildStudio.app/Contents/MacOS/Agent Guild Studio' + $version = '0.1.${{ github.run_number }}' + $said = & $exe --headless --no-header -- --version + $short = & /usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' .smoke/AgentGuildStudio.app/Contents/Info.plist + if ($LASTEXITCODE -ne 0 -or "$said" -ne $version -or $short -ne $version) { throw "The packaged app says it is $said ($short), not $version." } node tests/mcp_smoke.mjs $exe if ($LASTEXITCODE -ne 0) { throw 'The packaged app did not serve MCP.' } - name: Signing checks @@ -345,6 +349,13 @@ jobs: run: | ditto -x -k "$RUNNER_TEMP/signed/AgentGuildStudio-macos.zip" "$RUNNER_TEMP/smoke" exe="$RUNNER_TEMP/smoke/AgentGuildStudio.app/Contents/MacOS/Agent Guild Studio" + version="0.1.${{ github.run_number }}" + said="$("$exe" --headless --no-header -- --version)" + short="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$RUNNER_TEMP/smoke/AgentGuildStudio.app/Contents/Info.plist")" + if [ "$said" != "$version" ] || [ "$short" != "$version" ]; then + echo "::error::The signed app says it is $said ($short), not $version." + exit 1 + fi node tests/mcp_smoke.mjs "$exe" - name: Check the signature run: | diff --git a/src/app/main.gd b/src/app/main.gd index 5b9e223..e48ca2c 100644 --- a/src/app/main.gd +++ b/src/app/main.gd @@ -8,6 +8,7 @@ extends Node ## --mcp serve MCP on stdin and stdout, so an agent can make pieces (with Godot's --headless: no window) ## --world=guild --theme=light|dark the world and time of day the window opens on ## --shot=FILE.png save a picture of the window, then quit +## --version print this build's version, then quit const TOOLS := { "anthropic": "Claude Code", "openai": "Codex CLI", "google": "Antigravity CLI", "xai": "Grok Build", "docker": "Docker Agent", "shell": "Shell" } const GAP := 16.0 @@ -41,6 +42,10 @@ func _ready() -> void: continue var parts := arg.trim_prefix("--").split("=", true, 1) options[parts[0]] = parts[1] if parts.size() > 1 else "1" + if options.has("version"): + print(Studio.version()) + get_tree().quit() + return if options.has("check-piece") or options.has("check-source"): get_tree().quit(PieceCheck.run(options)) return diff --git a/src/mcp/server.gd b/src/mcp/server.gd index 2472f1e..8307ddf 100644 --- a/src/mcp/server.gd +++ b/src/mcp/server.gd @@ -7,7 +7,6 @@ const SUPPORTED := ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "202 const META_VERSION := "io.modelcontextprotocol/protocolVersion" const META_CAPABILITIES := "io.modelcontextprotocol/clientCapabilities" const META_SERVER := "io.modelcontextprotocol/serverInfo" -const SERVER_INFO := {"name": "agent-guild-studio", "title": "Agent Guild: Studio", "version": "1"} const PARSE_ERROR := -32700 const INVALID_REQUEST := -32600 const METHOD_NOT_FOUND := -32601 @@ -30,6 +29,9 @@ var _active: Dictionary = {} var _closed := false var _left := false +static func server_info() -> Dictionary: + return {"name": "agent-guild-studio", "title": "Agent Guild: Studio", "version": Studio.version()} + func _ready() -> void: tools = McpTools.new(self) if read_stdin: @@ -168,7 +170,7 @@ func _dispatch(msg: Dictionary) -> void: return var asked = params.get("protocolVersion") _legacy_version = asked if asked in LEGACY else LEGACY[0] - respond(ctx, {"protocolVersion": _legacy_version, "capabilities": {"tools": {}}, "serverInfo": SERVER_INFO, "instructions": INSTRUCTIONS}) + respond(ctx, {"protocolVersion": _legacy_version, "capabilities": {"tools": {}}, "serverInfo": server_info(), "instructions": INSTRUCTIONS}) "server/discover": if not ctx.modern: _send_error(ctx.id, INVALID_PARAMS, "server/discover needs %s and %s in _meta." % [META_VERSION, META_CAPABILITIES]) @@ -210,7 +212,7 @@ func respond(ctx: Dictionary, result: Dictionary) -> void: if ctx.modern: result["resultType"] = "complete" var meta: Dictionary = result.get("_meta", {}) - meta[META_SERVER] = SERVER_INFO + meta[META_SERVER] = server_info() result["_meta"] = meta _send({"jsonrpc": "2.0", "id": ctx.id, "result": result}) diff --git a/src/studio/studio.gd b/src/studio/studio.gd index 0c89c1c..de2a11d 100644 --- a/src/studio/studio.gd +++ b/src/studio/studio.gd @@ -15,6 +15,10 @@ class_name Studio ## both arms free, on a skeleton of its own, for character pieces. const CLIP_SETS := "res://assets/premium/studio/%s.glb" +static func version() -> String: + var stamped := String(ProjectSettings.get_setting("application/config/version", "")) + return stamped if stamped != "" else "dev" + ## { world, kind, crew } for "world/crew" or "world/companion", or { reason }. static func slot(text: String) -> Dictionary: var parts := text.split("/") diff --git a/src/ui/home_panel.gd b/src/ui/home_panel.gd index cea6d96..5fd001f 100644 --- a/src/ui/home_panel.gd +++ b/src/ui/home_panel.gd @@ -112,6 +112,8 @@ func _build() -> void: _heading("Agents", false) _note("An agent can make pieces with Studio for you.") _button("Connect an agent…", func(): connect_requested.emit()) + var version := Studio.version() + _note("Development build" if version == "dev" else "Build " + version.get_slice(".", 2)) func _heading(text: String, first: bool) -> void: var label := Label.new() diff --git a/tests/mcp_test.gd b/tests/mcp_test.gd index 40626f2..9e20cfb 100644 --- a/tests/mcp_test.gd +++ b/tests/mcp_test.gd @@ -53,7 +53,11 @@ func run(tree: SceneTree) -> Array: _server.read_stdin = false _server.write_line = func(line: String): _lines.append(JSON.parse_string(line)) host.add_child(_server) - await _ask(tree, "initialize", { "protocolVersion": "2025-11-25" }) + var info: Dictionary = (await _ask(tree, "initialize", { "protocolVersion": "2025-11-25" })).get("serverInfo", {}) + ProjectSettings.set_setting("application/config/version", "0.1.7") + var stamped: Dictionary = (await _ask(tree, "initialize", { "protocolVersion": "2025-11-25" })).get("serverInfo", {}) + ProjectSettings.set_setting("application/config/version", null) + check(info.get("version") == "dev" and stamped.get("version") == "0.1.7", "the server does not report its build's version: %s, %s" % [info, stamped]) var listed: Dictionary = await _ask(tree, "tools/list", {}) var names: Array = listed.get("tools", []).map(func(t): return t.name) diff --git a/tests/package_smoke.ps1 b/tests/package_smoke.ps1 index 98e52fd..422a9c8 100644 --- a/tests/package_smoke.ps1 +++ b/tests/package_smoke.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory)][string]$Zip, [Parameter(Mandatory)][string]$Dir) +param([Parameter(Mandatory)][string]$Zip, [Parameter(Mandatory)][string]$Dir, [Parameter(Mandatory)][int]$Release) $ErrorActionPreference = 'Stop' if (Test-Path -LiteralPath $Dir) { throw "$Dir already exists." } @@ -21,6 +21,12 @@ foreach ($size in 16, 32, 48) { throw "The packaged app's $size px icon is not brand/icon.ico." } } +$version = "0.1.$Release" +$info = (Get-Item -LiteralPath $exe).VersionInfo +if ($info.FileVersion -ne "$version.0" -or $info.ProductVersion -ne "$version.0") { throw "The packaged app's file is version $($info.FileVersion) ($($info.ProductVersion)), not $version.0." } +$said = Join-Path $Dir 'version.txt' +$app = Start-Process -FilePath $exe -ArgumentList '--headless', '--no-header', '--', '--version' -RedirectStandardOutput $said -Wait -PassThru +if ($app.ExitCode -ne 0 -or (Get-Content -LiteralPath $said -Raw).Trim() -ne $version) { throw "The packaged app does not say it is $version." } # A piece the packaged app refuses for its own reason shows its checker runs. $piece = Join-Path $Dir 'not-a-piece' New-Item -ItemType Directory -Path $piece | Out-Null diff --git a/tests/window_test.gd b/tests/window_test.gd index 29de140..43386cb 100644 --- a/tests/window_test.gd +++ b/tests/window_test.gd @@ -45,6 +45,12 @@ func run(tree: SceneTree) -> Array: var home: HomePanel = main.home check(home != null and home.visible and not main.panel.visible, "the window did not open on its first screen") check(home.world == "guild" and home.art == "missing" and home.art_bytes == 300000, "the first screen does not say guild's art is a 300000 byte download: %s %d" % [home.art, home.art_bytes]) + check(_shows(home, "Development build"), "the first screen does not say it is a development build") + ProjectSettings.set_setting("application/config/version", "0.1.7") + main._show_home() + check(_shows(home, "Build 7"), "the first screen does not show its build") + ProjectSettings.set_setting("application/config/version", null) + main._show_home() home.download_requested.emit("guild") check(home.art == "downloading", "a download did not show as under way") @@ -119,6 +125,11 @@ func _window_processes(tree: SceneTree, main: Node, out: String) -> void: main._window_pids.append(pid) check(await TestRunner.until(tree, func(): return main._window_pids.is_empty()), "closed window processes were not collected") check(pids.all(func(pid): return OS.get_process_exit_code(pid) == 0), "a window process exit code was lost") + var asked := OS.execute_with_pipe(OS.get_executable_path(), main.window_args() + ["--headless", "--no-header", "--", "--version"], false) + check(not asked.is_empty() and await TestRunner.until(tree, func(): return not OS.is_process_running(asked.pid)), "Studio did not answer --version") + if not asked.is_empty(): + var version: String = asked.stdio.get_buffer(4096).get_string_from_utf8().strip_edges() + check(OS.get_process_exit_code(asked.pid) == 0 and version == "dev", "--version says %s, not dev" % version) if OS.get_name() != "Windows": var writer := out.path_join("Bill's $cash `tools`.gd") var ran := out.path_join("ran") @@ -130,6 +141,9 @@ func _window_processes(tree: SceneTree, main: Node, out: String) -> void: var said: String = child.stdio.get_buffer(4096).get_string_from_utf8() + child.stderr.get_buffer(4096).get_string_from_utf8() check(OS.get_process_exit_code(child.pid) == 0 and FileAccess.file_exists(ran) and said == "", "the standalone window did not run its script, or wrote to MCP's stdout or stderr: %s" % said) +func _shows(home: HomePanel, text: String) -> bool: + return home.rows.get_children().any(func(c): return c is Label and c.text == text and not c.is_queued_for_deletion()) + func _folder(name: String) -> String: var dir := ProjectSettings.globalize_path(OUT + "folders/" + name) DirAccess.make_dir_recursive_absolute(dir) diff --git a/tools/package.ps1 b/tools/package.ps1 index 1f1b392..281ff8e 100644 --- a/tools/package.ps1 +++ b/tools/package.ps1 @@ -1,4 +1,4 @@ -param([string]$Godot = 'godot', [ValidateSet('Windows', 'macOS')][string]$Platform = 'Windows') +param([string]$Godot = 'godot', [ValidateSet('Windows', 'macOS')][string]$Platform = 'Windows', [ValidateRange(0, 65535)][int]$Release = 0) $ErrorActionPreference = 'Stop' $project = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path @@ -16,7 +16,29 @@ $mac = $Platform -eq 'macOS' $app = Join-Path $stage 'AgentGuildStudio.app' $bundle = if ($mac) { Join-Path $app 'Contents/Resources' } else { Join-Path $stage 'AgentGuildStudio' } New-Item -ItemType Directory -Path $(if ($mac) { $stage } else { $bundle }) -Force | Out-Null +$settings = Join-Path $project 'project.godot' +$exportPresets = Join-Path $project 'export_presets.cfg' +$keptSettings = [IO.File]::ReadAllBytes($settings) +$keptPresets = [IO.File]::ReadAllBytes($exportPresets) try { + if ($Release -gt 0) { + $version = "0.1.$Release" + $text = [IO.File]::ReadAllText($settings) + $nl = if ($text.Contains("`r`n")) { "`r`n" } else { "`n" } + $section = "[application]$nl" + if ($text -match '(?m)^config/version=' -or -not $text.Contains($section)) { throw 'project.godot cannot take a version.' } + [IO.File]::WriteAllText($settings, $text.Replace($section, "$section" + "config/version=`"$version`"$nl")) + $cfg = [IO.File]::ReadAllText($exportPresets) + $fields = [ordered]@{ 'Windows Desktop' = [ordered]@{ file_version = "$version.0"; product_version = "$version.0" }; 'macOS' = [ordered]@{ short_version = $version; version = $version } } + foreach ($target in $fields.Keys) { + $found = [regex]::Match($cfg, "(?m)^\[preset\.(\d+)\]\r?\n(?:[^\[\r\n].*\r?\n|\r?\n)*?platform=`"$target`"") + $header = "[preset.$($found.Groups[1].Value).options]$nl" + if (-not $found.Success -or -not $cfg.Contains($header)) { throw "export_presets.cfg has no $target options." } + $lines = ($fields[$target].GetEnumerator() | ForEach-Object { "application/$($_.Key)=`"$($_.Value)`"$nl" }) -join '' + $cfg = $cfg.Replace($header, $header + $lines) + } + [IO.File]::WriteAllText($exportPresets, $cfg) + } $importLog = & $Godot --headless --path $project --editor --import --quit 2>&1 $importExit = $LASTEXITCODE $importLog | Write-Output @@ -44,6 +66,8 @@ try { Move-Item -LiteralPath $zip -Destination $output -Force Write-Output "Packaged $output" } finally { + [IO.File]::WriteAllBytes($settings, $keptSettings) + [IO.File]::WriteAllBytes($exportPresets, $keptPresets) $resolvedStage = [IO.Path]::GetFullPath($stage) if (-not $resolvedStage.StartsWith([IO.Path]::GetFullPath($build) + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { throw 'Staging path escaped the build directory.' } Remove-Item -LiteralPath $resolvedStage -Recurse -Force From 8d039552fa6dfa28fc2cb3a9c2cda0ece772968b Mon Sep 17 00:00:00 2001 From: oddessentials Date: Thu, 12 Nov 2026 12:37:46 -0500 Subject: [PATCH 3/5] Read Studio's --version answer while waiting for it to end On Windows the child does not end until its output has been read, so the window suite waited out its 20 seconds and failed. Co-Authored-By: Claude Fable 5.2 --- tests/window_test.gd | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/window_test.gd b/tests/window_test.gd index 43386cb..1f6ad5c 100644 --- a/tests/window_test.gd +++ b/tests/window_test.gd @@ -126,9 +126,14 @@ func _window_processes(tree: SceneTree, main: Node, out: String) -> void: check(await TestRunner.until(tree, func(): return main._window_pids.is_empty()), "closed window processes were not collected") check(pids.all(func(pid): return OS.get_process_exit_code(pid) == 0), "a window process exit code was lost") var asked := OS.execute_with_pipe(OS.get_executable_path(), main.window_args() + ["--headless", "--no-header", "--", "--version"], false) - check(not asked.is_empty() and await TestRunner.until(tree, func(): return not OS.is_process_running(asked.pid)), "Studio did not answer --version") + # On Windows the child does not end until its output has been read. + var said_version := [""] + var answered := func(): + said_version[0] += asked.stdio.get_buffer(4096).get_string_from_utf8() + return not OS.is_process_running(asked.pid) + check(not asked.is_empty() and await TestRunner.until(tree, answered), "Studio did not answer --version") if not asked.is_empty(): - var version: String = asked.stdio.get_buffer(4096).get_string_from_utf8().strip_edges() + var version: String = (said_version[0] + asked.stdio.get_buffer(4096).get_string_from_utf8()).strip_edges() check(OS.get_process_exit_code(asked.pid) == 0 and version == "dev", "--version says %s, not dev" % version) if OS.get_name() != "Windows": var writer := out.path_join("Bill's $cash `tools`.gd") From dc00f0bf2462fa871cf3aaee9620e9eeec984798 Mon Sep 17 00:00:00 2001 From: oddessentials Date: Thu, 12 Nov 2026 13:48:16 -0500 Subject: [PATCH 4/5] Stage saves in folders Studio makes itself, and read both pipes of a child A save cleared .name.part and .name.previous beside its folder before using them, so a folder of someone else's with that name, or the backup kept when files could not be put back, was deleted. Staging and backup folders are now made new, under the first free name, and only those are removed. On Windows a Godot child stops after writing to stdout or stderr until that pipe is read, so the window suite reads both while it waits. Co-Authored-By: Claude Fable 5.2 --- src/studio/studio.gd | 8 ++++---- src/studio/studio_files.gd | 19 +++++++++++++++--- src/studio/studio_flow.gd | 9 +++++---- tests/studio_test.gd | 37 +++++++++++++++++++++++++++++++++-- tests/window_test.gd | 40 +++++++++++++++++++++++++------------- 5 files changed, 87 insertions(+), 26 deletions(-) diff --git a/src/studio/studio.gd b/src/studio/studio.gd index de2a11d..54bfd64 100644 --- a/src/studio/studio.gd +++ b/src/studio/studio.gd @@ -333,7 +333,6 @@ static func run(host: Node, options: Dictionary) -> int: ## would. static func _save(host: Node, where: Dictionary, models: Array, clips: Dictionary, title: String, notes: Array, options: Dictionary) -> int: var folder := ProjectSettings.globalize_path(options.get("out", "user://studio/build")).rstrip("/\\") - var part := folder.get_base_dir().path_join(".%s.part" % folder.get_file()) var manifest := { "format": 1, "kind": where.kind, "world": where.world, "title": title } if where.kind == "character": manifest.crew = where.crew @@ -342,8 +341,8 @@ static func _save(host: Node, where: Dictionary, models: Array, clips: Dictionar for model in models: model.free() return _fail("%s holds %s, which a piece may not" % [folder, other]) - StudioFiles.remove_tree(part) - var reason := StudioExport.save(models, part, manifest) + var part := StudioFiles.make_beside(folder, "part") + var reason := StudioExport.save(models, part, manifest) if part != "" else "a folder to work in could not be made in %s" % folder.get_base_dir() for model in models: model.free() if reason == "": @@ -360,7 +359,8 @@ static func _save(host: Node, where: Dictionary, models: Array, clips: Dictionar reason = PieceLibrary.check_folder(part).get("reason", "") if reason == "": reason = StudioFiles.move_in(part, folder, StudioExport.names()) - StudioFiles.remove_tree(part) + if part != "": + StudioFiles.remove_tree(part) if reason != "": return _fail(reason) said = { "folder": folder, "notes": notes } diff --git a/src/studio/studio_files.gd b/src/studio/studio_files.gd index b9efa80..59b94b2 100644 --- a/src/studio/studio_files.gd +++ b/src/studio/studio_files.gd @@ -15,14 +15,27 @@ static func remove_tree(path: String) -> void: dir.remove(name) DirAccess.remove_absolute(path) +## A new, empty folder beside `folder` that only the caller holds, or "". +static func make_beside(folder: String, kind: String) -> String: + var parent := folder.get_base_dir() + DirAccess.make_dir_recursive_absolute(parent) + for n in range(1, 1000): + var path := parent.path_join(".%s.%s%s" % [folder.get_file(), "" if n == 1 else "%d." % n, kind]) + var err := DirAccess.make_dir_absolute(path) + if err == OK: + return path + if err != ERR_ALREADY_EXISTS: + break + return "" + ## Moves the files in `part` into `folder`, first setting aside the files ## `folder` holds of `names`, which go back if any move fails; "" or why not. static func move_in(part: String, folder: String, names: Array) -> String: - var previous := folder.get_base_dir().path_join(".%s.previous" % folder.get_file()) + var previous := make_beside(folder, "previous") + if previous == "": + return "a folder to work in could not be made in %s" % folder.get_base_dir() var made := not DirAccess.dir_exists_absolute(folder) - remove_tree(previous) DirAccess.make_dir_recursive_absolute(folder) - DirAccess.make_dir_recursive_absolute(previous) var aside := [] var placed := [] var reason := "" diff --git a/src/studio/studio_flow.gd b/src/studio/studio_flow.gd index 96aa9e8..953ac4e 100644 --- a/src/studio/studio_flow.gd +++ b/src/studio/studio_flow.gd @@ -385,12 +385,12 @@ func _save(parent: String, title: String) -> void: panel.working("Saving it in %s…" % parent) await _shown() var reason := await _exact() - var part := parent.path_join(".%s.part" % name) + var part := "" var folder := "" if reason == "": _working("Saving it in %s…" % parent) - StudioFiles.remove_tree(part) - reason = _write(part, title) + part = StudioFiles.make_beside(parent.path_join(name), "part") + reason = _write(part, title) if part != "" else "a folder to work in could not be made" var n := 1 while reason == "" and folder == "": var free := parent.path_join(name if n == 1 else "%s (%d)" % [name, n]) @@ -402,7 +402,8 @@ func _save(parent: String, title: String) -> void: elif not DirAccess.dir_exists_absolute(free): reason = "it could not be moved into %s" % free if reason != "": - StudioFiles.remove_tree(part) + if part != "": + StudioFiles.remove_tree(part) _kept("It could not be saved: %s. Nothing was left in %s." % [reason, parent], true) return var id := editing if editing != "" else PieceLibrary.local_id() diff --git a/tests/studio_test.gd b/tests/studio_test.gd index 4c3c144..0dc8ab9 100644 --- a/tests/studio_test.gd +++ b/tests/studio_test.gd @@ -897,12 +897,24 @@ func _gltf(folder: String, name: String, images: Array, buffer: String) -> Strin func _replaces(tree: SceneTree, piece: Dictionary, lengths: Dictionary) -> void: var where := { "world": "guild", "kind": "character", "crew": "anthropic" } var folder := ProjectSettings.globalize_path(OUT.path_join("replaced")) - var staged := func(): return Array(DirAccess.get_directories_at(folder.get_base_dir())).filter(func(d): return d.begins_with(".replaced.")) + var beside := folder.get_base_dir() + var taken := [".replaced.part", ".replaced.previous"] + var staged := func(): + var dirs := Array(DirAccess.get_directories_at(beside)).filter(func(d): return d.begins_with(".replaced.")) + dirs.sort() + return dirs + var theirs_kept := func(): return taken.all(func(d): return _files(beside.path_join(d)) == ["keep.txt"] and FileAccess.get_file_as_string(beside.path_join(d).path_join("keep.txt")) == "theirs") + for d in staged.call(): + StudioFiles.remove_tree(beside.path_join(d)) + StudioFiles.remove_tree(folder) + for d in taken: + _put(beside.path_join(d).path_join("keep.txt"), "theirs".to_utf8_buffer()) _put(folder.path_join("character.glb"), "old".to_utf8_buffer()) _put(folder.path_join("piece.json"), "old".to_utf8_buffer()) var failed := await Studio._save(tree.root, where, [StudioExport.scene(piece, "character")], { "walk": 99.0 }, "Fails", [], { "out": folder }) check(failed == 1 and _files(folder) == ["character.glb", "piece.json"] and FileAccess.get_file_as_string(folder.path_join("character.glb")) == "old", "a piece that failed its check changed the files it would have replaced: %s" % [_files(folder)]) + check(staged.call() == taken and theirs_kept.call(), "a failed save changed folders beside it that it did not make: %s" % [staged.call()]) _put(folder.path_join("notes.txt"), "mine".to_utf8_buffer()) var theirs := await Studio._save(tree.root, where, [StudioExport.scene(piece, "character")], lengths, "Theirs", [], { "out": folder }) check(theirs == 1 and "notes.txt" in String(Studio.said.reason) and _files(folder) == ["character.glb", "notes.txt", "piece.json"] and FileAccess.get_file_as_string(folder.path_join("character.glb")) == "old", @@ -913,7 +925,28 @@ func _replaces(tree: SceneTree, piece: Dictionary, lengths: Dictionary) -> void: var saved := await Studio._save(tree.root, where, [StudioExport.scene(piece, "character")], lengths, "Replaced", [], { "out": folder }) check(saved == 0 and _files(folder) == ["character.glb", "piece.json"] and not PieceLibrary.check_folder(folder).has("reason") and FileAccess.get_file_as_string(folder.path_join("character.glb")) != "old", "a piece did not cleanly replace the one in its folder: %s %s" % [Studio.said.get("reason", ""), _files(folder)]) - check(staged.call().is_empty(), "a save left its staging behind: %s" % [staged.call()]) + check(staged.call() == taken and theirs_kept.call(), "a save changed folders beside it that it did not make, or left its own: %s" % [staged.call()]) + + for d in taken: + StudioFiles.remove_tree(beside.path_join(d)) + + var moved := beside.path_join("moved") + var incoming := beside.path_join("incoming") + var backup := beside.path_join(".moved.previous") + for d in [moved, incoming, backup, beside.path_join(".moved.2.previous")]: + StudioFiles.remove_tree(d) + _put(backup.path_join("keep.txt"), "theirs".to_utf8_buffer()) + _put(incoming.path_join("character.glb"), "new".to_utf8_buffer()) + _put(incoming.path_join("piece.json"), "new".to_utf8_buffer()) + DirAccess.make_dir_recursive_absolute(moved.path_join("character.glb")) + _put(moved.path_join("piece.json"), "old".to_utf8_buffer()) + var blocked := StudioFiles.move_in(incoming, moved, StudioExport.names()) + check(blocked != "" and _files(moved) == ["piece.json"] and DirAccess.dir_exists_absolute(moved.path_join("character.glb")) and FileAccess.get_file_as_string(moved.path_join("piece.json")) == "old", + "a move that failed did not put back what the folder held: %s %s" % [blocked, _files(moved)]) + check(_files(backup) == ["keep.txt"] and FileAccess.get_file_as_string(backup.path_join("keep.txt")) == "theirs" and not DirAccess.dir_exists_absolute(beside.path_join(".moved.2.previous")), + "a move that failed changed a folder beside it that it did not make, or left its own") + for d in [moved, incoming, backup]: + StudioFiles.remove_tree(d) func _put(path: String, bytes: PackedByteArray) -> void: DirAccess.make_dir_recursive_absolute(path.get_base_dir()) diff --git a/tests/window_test.gd b/tests/window_test.gd index 1f6ad5c..352b7aa 100644 --- a/tests/window_test.gd +++ b/tests/window_test.gd @@ -93,10 +93,15 @@ func run(tree: SceneTree) -> Array: main.add_child(saving) saving.start(main.panel, main.stage, main.pieces, main.TOOLS, "guild", "light") saving._made = { "where": { "kind": "character", "world": "guild", "crew": "anthropic" } } + var theirs := out.path_join(".Failed save.part") + DirAccess.make_dir_recursive_absolute(theirs) + FileAccess.open(theirs.path_join("keep.txt"), FileAccess.WRITE).store_string("theirs") saving._save(out, "Failed save") main.close() check(quits[0] == 0, "closing interrupted an in-progress save") check(await TestRunner.until(tree, func(): return saving._keeping == ""), "a failed save never finished") + check(FileAccess.get_file_as_string(theirs.path_join("keep.txt")) == "theirs" and not DirAccess.dir_exists_absolute(out.path_join(".Failed save.2.part")), + "a failed save changed a folder beside it that it did not make, or left its own") check(quits[0] == 0 and saving.active and not main._closing, "a failed save did not cancel closing and leave the window usable") main.close() main.close() @@ -126,25 +131,34 @@ func _window_processes(tree: SceneTree, main: Node, out: String) -> void: check(await TestRunner.until(tree, func(): return main._window_pids.is_empty()), "closed window processes were not collected") check(pids.all(func(pid): return OS.get_process_exit_code(pid) == 0), "a window process exit code was lost") var asked := OS.execute_with_pipe(OS.get_executable_path(), main.window_args() + ["--headless", "--no-header", "--", "--version"], false) - # On Windows the child does not end until its output has been read. - var said_version := [""] - var answered := func(): - said_version[0] += asked.stdio.get_buffer(4096).get_string_from_utf8() - return not OS.is_process_running(asked.pid) - check(not asked.is_empty() and await TestRunner.until(tree, answered), "Studio did not answer --version") - if not asked.is_empty(): - var version: String = (said_version[0] + asked.stdio.get_buffer(4096).get_string_from_utf8()).strip_edges() - check(OS.get_process_exit_code(asked.pid) == 0 and version == "dev", "--version says %s, not dev" % version) + var answer := await _piped(tree, asked) + check(answer.ended, "Studio did not answer --version") + check(answer.code == 0 and answer.out.strip_edges() == "dev", "--version says %s, not dev" % answer.out) if OS.get_name() != "Windows": var writer := out.path_join("Bill's $cash `tools`.gd") var ran := out.path_join("ran") FileAccess.open(writer, FileAccess.WRITE).store_string("extends SceneTree\nfunc _initialize():\n\tFileAccess.open(%s, FileAccess.WRITE).store_string('ran')\n\tprint('window stdout')\n\tprinterr('window stderr')\n\tquit(0)\n" % var_to_str(ran)) command = main.window_command(OS.get_executable_path(), ["--headless", "--no-header", "--script", writer]) var child := OS.execute_with_pipe(command[0], command[1], false) - check(not child.is_empty() and await TestRunner.until(tree, func(): return not OS.is_process_running(child.pid)), "the standalone window did not end") - if not child.is_empty(): - var said: String = child.stdio.get_buffer(4096).get_string_from_utf8() + child.stderr.get_buffer(4096).get_string_from_utf8() - check(OS.get_process_exit_code(child.pid) == 0 and FileAccess.file_exists(ran) and said == "", "the standalone window did not run its script, or wrote to MCP's stdout or stderr: %s" % said) + var window := await _piped(tree, child) + check(window.ended, "the standalone window did not end") + check(window.code == 0 and FileAccess.file_exists(ran) and window.out + window.err == "", "the standalone window did not run its script, or wrote to MCP's stdout or stderr: %s" % (window.out + window.err)) + +func _piped(tree: SceneTree, child: Dictionary) -> Dictionary: + var said := { "ended": false, "code": -1, "out": "", "err": "" } + if child.is_empty(): + return said + var read := func(): + said.out += child.stdio.get_buffer(4096).get_string_from_utf8() + said.err += child.stderr.get_buffer(4096).get_string_from_utf8() + said.ended = await TestRunner.until(tree, func(): + read.call() + return not OS.is_process_running(child.pid)) + if not said.ended: + OS.kill(child.pid) + read.call() + said.code = OS.get_process_exit_code(child.pid) + return said func _shows(home: HomePanel, text: String) -> bool: return home.rows.get_children().any(func(c): return c is Label and c.text == text and not c.is_queued_for_deletion()) From 31c36540294672b907f2cd5dac92a7f096f58536 Mon Sep 17 00:00:00 2001 From: odd-bot Date: Thu, 12 Nov 2026 16:22:14 -0500 Subject: [PATCH 5/5] List hidden staging folders on macOS in the replace test Co-Authored-By: Claude Opus 5.5 --- tests/studio_test.gd | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/studio_test.gd b/tests/studio_test.gd index 0dc8ab9..7827b14 100644 --- a/tests/studio_test.gd +++ b/tests/studio_test.gd @@ -900,7 +900,11 @@ func _replaces(tree: SceneTree, piece: Dictionary, lengths: Dictionary) -> void: var beside := folder.get_base_dir() var taken := [".replaced.part", ".replaced.previous"] var staged := func(): - var dirs := Array(DirAccess.get_directories_at(beside)).filter(func(d): return d.begins_with(".replaced.")) + var dir := DirAccess.open(beside) + if dir == null: + return [] + dir.include_hidden = true + var dirs := Array(dir.get_directories()).filter(func(d): return d.begins_with(".replaced.")) dirs.sort() return dirs var theirs_kept := func(): return taken.all(func(d): return _files(beside.path_join(d)) == ["keep.txt"] and FileAccess.get_file_as_string(beside.path_join(d).path_join("keep.txt")) == "theirs")