diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 144b044..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,8 +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 = & $exe --headless --no-header -- --version - if ($LASTEXITCODE -ne 0 -or -not $version) { throw 'The packaged app did not start.' } + $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 @@ -347,8 +349,11 @@ 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." + 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" 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..e48ca2c 100644 --- a/src/app/main.gd +++ b/src/app/main.gd @@ -43,7 +43,7 @@ func _ready() -> void: 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")) + print(Studio.version()) get_tree().quit() return if options.has("check-piece") or options.has("check-source"): diff --git a/src/mcp/server.gd b/src/mcp/server.gd index 9609e56..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}) @@ -225,4 +227,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..54bfd64 100644 --- a/src/studio/studio.gd +++ b/src/studio/studio.gd @@ -11,10 +11,14 @@ 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" +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("/") @@ -323,29 +327,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 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]) + 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 == "": + 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()) + if part != "": + 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..59b94b2 100644 --- a/src/studio/studio_files.gd +++ b/src/studio/studio_files.gd @@ -14,3 +14,53 @@ static func remove_tree(path: String) -> void: for name in dir.get_files(): 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 := 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) + DirAccess.make_dir_recursive_absolute(folder) + 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/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/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 0cced2b..9e20cfb 100644 --- a/tests/mcp_test.gd +++ b/tests/mcp_test.gd @@ -53,12 +53,17 @@ 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) 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 +95,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/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/studio_test.gd b/tests/studio_test.gd index 34f1cac..7827b14 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,64 @@ 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 beside := folder.get_base_dir() + var taken := [".replaced.part", ".replaced.previous"] + var staged := func(): + 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") + 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", + "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() == 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()) FileAccess.open(path, FileAccess.WRITE).store_buffer(bytes) diff --git a/tests/window_test.gd b/tests/window_test.gd index ac7ff70..352b7aa 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") @@ -87,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() @@ -119,12 +130,38 @@ 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) + 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") - 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) + 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()) func _folder(name: String) -> String: var dir := ProjectSettings.globalize_path(OUT + "folders/" + name) 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