diff --git a/AGENTS.md b/AGENTS.md index 01b0438..07bcfd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,8 @@ operational rules. ### Lifecycle and setup -- **Pre-setup:** `setup` and `status` use only the bundled stdlib CLI. +- **Pre-setup:** `setup`, `status`, `doctor`, and `wait-ready` use only the + bundled stdlib CLI. - **Post-setup:** the full CLI is available after `com.zh1zh1.csharpconsole` is installed and Unity resolves it. @@ -68,6 +69,8 @@ through because their contracts are project-specific. |---------|-------|---------| | `cs setup` | pre | Install/version-check the Unity package | | `cs status` | pre | Package, connection, and version status | +| `cs doctor [--operation UUID]` | pre | Unity/project-read-only diagnosis; probes the machine-local outbox | +| `cs wait-ready [--timeout TIMEOUT]` | pre | Wait without Unity/project mutation; optionally bind a refresh op/generation | | `cs exec --file FILE` | post | Execute raw C# as a fallback | | `cs command --input FILE --json` | post | Run one framework command | | `cs batch --input FILE --json` | post | Run multiple commands in one request | @@ -95,10 +98,16 @@ package so client and service versions stay aligned: 2. `Library/PackageCache/com.zh1zh1.csharpconsole@*/Editor/ExternalTool~/console-client/`. `ConsoleSession` wires the core client, command protocol, configuration, output, -response parser, and HTTP transport into the CLI operations. Clearly refused -connections are retried once after one second to tolerate Unity domain reloads. -Timeouts, HTTP errors, and connection resets are not retried because a mutation may -already have executed. +response parser, and HTTP transport into the CLI operations. `reliability.py` +owns project identity, readiness reduction, diagnostics, and the machine-local +invocation outbox. Protocol-v2 operations use a stable invocation id and exact +request bytes for one bounded retry; the Unity-side durable journal either +replays the original response or prevents a second dispatch within its advertised +dedupe window. `outcome_unknown` and `operation_in_progress` exit 4 and must be +inspected with `cs doctor --operation UUID`; never replace that UUID with a new +one while the outcome is unresolved. Once the outbox records an id as sent, a +later CLI run never dispatches it again, including after the server window +expires; use a new id only for a new intent. ### Version and machine-local state @@ -108,7 +117,7 @@ already have executed. source to the newest tag on the CLI's `major.minor` line when setup writes the manifest; `file:` sources, explicit fragments, and failed tag queries are left unpinned. -- Package-path cache and snippet usage statistics live in a per-project user cache +- Package-path cache, invocation audit records, and snippet usage statistics live in a per-project user cache (`%LOCALAPPDATA%\unity-cli\\` on Windows or `$XDG_CACHE_HOME/unity-cli//` elsewhere), never in the project tree. - The committed custom-command catalog and snippet audit remain project state; see @@ -123,6 +132,7 @@ skills/unity-cli/scripts/cli/cs.py CLI dispatcher skills/unity-cli/scripts/cli/command_index.py skills/unity-cli/scripts/cli/command_manifest.json skills/unity-cli/scripts/cli/core_bridge.py +skills/unity-cli/scripts/cli/reliability.py skills/unity-cli/scripts/cli/paths.py Per-project cache paths skills/unity-cli/scripts/cli/VERSION Release version ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d59b12..107f81a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,26 @@ the section matching the pushed tag (without the leading `v`) as release notes. ## [Unreleased] +### Added + +- **Unity/project-read-only reliability workflow** — `cs doctor` reports project/package, + target identity, protocol, journal, compile, and readiness findings; + `cs wait-ready` follows the matching Unity 2022 service across reload and + local port changes without triggering a mutation, and can resume a specific + refresh by operation id and generation. `cs doctor --operation ` + reconciles an uncertain HTTP invocation with local and server receipts. These + diagnostics may create and delete a probe in the machine-local outbox cache. + +### Changed + +- **Protocol-v2 operations are at-most-once** — command, batch, exec, compile, + and refresh requests are bound to a durable invocation id and exact bytes. + One bounded retry reuses both; within the advertised dedupe window Unity + replays a persisted result or blocks a second dispatch. Unresolved or + in-progress work exits 4 instead of being blindly repeated. Once the outbox + records an id as sent, later CLI runs diagnose it rather than redispatching it, + including after the server retention window expires. + ## [2.0.8] - 2026-07-23 ### Removed @@ -65,13 +85,13 @@ the section matching the pushed tag (without the leading `v`) as release notes. JSON-escaped) is demoted to a stdin-piping edge case. Scratch `.cs` / `req.json` files now have a **mandatory location**: the absolute path `/Temp/CSharpConsole/AgentScratch/` — inside Unity's own `Temp/` - (never imported, auto-cleaned on editor close, write-sandbox-friendly for - workspace-bound agents, and colocated with the service's existing - `Temp/CSharpConsole/` state). Semantic file names, overwritten per task; one-shot + (never imported, auto-cleaned on editor close, and write-sandbox-friendly for + workspace-bound agents). Semantic file names, overwritten per task; one-shot suffix only under known same-project concurrency. Never under `Assets/` (a typical REPL snippet fails project compilation there — blocking refresh workflows and, after an editor restart, potentially preventing the service from starting), and - never delete `Temp/CSharpConsole/` itself. Decided in an adversarial CC↔Codex + never delete `Temp/CSharpConsole/` itself because older package versions may + retain compatibility state there. Decided in an adversarial CC↔Codex review (local audit transcript: `cc-codex-discussion-history/20260722-213810-scratch-file-conventions.md`, kept untracked by repo policy), which replaced the earlier diff --git a/README.md b/README.md index 5d72109..a73375c 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ operation, and the agent triggers it automatically (in any skills-compatible age | ---------- | ----------- | | `cs setup` | Install the package into the manifest (version-check if present) | | `cs status` / `cs health` | Package and service status | +| `cs doctor` / `cs wait-ready` | Diagnose or wait for the matching Unity 2022 service | | `cs command --input` | Structured Unity Editor commands | | `cs exec` | Run raw C# in the Editor (fallback) | | `cs refresh` | Trigger asset refresh / recompile | @@ -88,6 +89,14 @@ operation, and the agent triggers it automatically (in any skills-compatible age | `cs snippets …` | Reusable C# snippet library | | `cs snippets doctor` | Snippet library health audit | +Protocol-v2 operations are protected by a durable, windowed at-most-once journal. +A lost response is retried only with the same operation id and exact request +bytes; Unity replays the original result or blocks a second dispatch within the +advertised dedupe window. Unknown and in-progress results exit 4 and point to +`cs doctor --operation `. Once the local outbox records that an id was +sent, later CLI runs diagnose that id instead of dispatching it again; use a new +id only for a genuinely new intent. + ### 📦 Commands @@ -294,7 +303,7 @@ Auto-detects project root and service port. No manual configuration. | ---------------------- | ------------------------------------------------------------------------------------------ | | `service: UNREACHABLE` | Make sure Unity Editor is open with the project loaded | | `package: NOT FOUND` | Run `cs setup` to add the package, then open Unity to let it resolve | -| Port conflict | Service auto-advances to the next free port. Check `Temp/CSharpConsole/refresh_state.json` | +| Port conflict | Service auto-advances to the next free port. Check `Library/CSharpConsole/RefreshState/v1/refresh_state.json` | | Commands not found | Ensure the package compiled successfully (no errors in Unity Console) | | Version mismatch | Run `cs status` to see versions; align the Unity package with the CLI `major.minor` | diff --git a/README_zh.md b/README_zh.md index 2045550..abf58aa 100644 --- a/README_zh.md +++ b/README_zh.md @@ -80,6 +80,7 @@ Claude 会自动选择合适的命令,或在需要时编写 C# 代码。 | ----- | ---- | | `cs setup` | 安装包到 manifest(已安装则做版本校验) | | `cs status` / `cs health` | 包与服务状态 | +| `cs doctor` / `cs wait-ready` | 诊断或等待属于当前项目的 Unity 2022 服务 | | `cs command --input` | 结构化 Unity 编辑器命令 | | `cs exec` | 在编辑器中执行原始 C#(兜底) | | `cs refresh` | 触发资产刷新 / 重编译 | @@ -87,6 +88,13 @@ Claude 会自动选择合适的命令,或在需要时编写 C# 代码。 | `cs snippets …` | 可复用 C# 片段库 | | `cs snippets doctor` | 片段库健康审计 | +协议 v2 的操作由持久化、带去重窗口的 at-most-once 日志保护。响应丢失时, +CLI 只会用同一个 operation id 和完全相同的请求字节重试;在服务声明的窗口内, +Unity 会重放原结果或阻止二次执行。结果未知或仍在执行时,CLI 以退出码 4 +返回,并提示运行 `cs doctor --operation `。本地 outbox 一旦记录某个 id +已经发送,后续 CLI 进程只会诊断该 id,不会再次派发;只有新的操作意图才使用新 +id。 + ### 📦 命令 @@ -292,7 +300,7 @@ AI Agent Unity Editor | ---------------------- | ---------------------------------------------------------- | | `service: UNREACHABLE` | 确保 Unity 编辑器已打开并加载了项目 | | `package: NOT FOUND` | 运行 `cs setup` 添加包,再打开 Unity 解析它 | -| 端口冲突 | 服务会自动切换到下一个可用端口,查看 `Temp/CSharpConsole/refresh_state.json` | +| 端口冲突 | 服务会自动切换到下一个可用端口,查看 `Library/CSharpConsole/RefreshState/v1/refresh_state.json` | | 找不到命令 | 确保包编译成功(Unity Console 中无报错) | | 版本不匹配 | 运行 `cs status` 查看版本;把 Unity 包对齐到 CLI 的 `major.minor` | diff --git a/skills/unity-cli/SKILL.md b/skills/unity-cli/SKILL.md index bcddd20..a7e3545 100644 --- a/skills/unity-cli/SKILL.md +++ b/skills/unity-cli/SKILL.md @@ -5,8 +5,9 @@ description: > or change Unity scenes, GameObjects, components, transforms, prefabs, materials, project assets, play mode, screenshots, profiler recording, or execute C# inside Unity; also use for Unity console maintenance and unity-cli setup, status, - refresh, snippets, or custom commands. Do not use for source-only Unity coding - that does not require interaction with the live Editor or Player. + readiness diagnosis, uncertain-operation recovery, refresh, snippets, or + custom commands. Do not use for source-only Unity coding that does not require + interaction with the live Editor or Player. --- # Unity CLI @@ -57,7 +58,8 @@ overwrite the same file when revising it serially; use a random suffix only when another agent is known to work on the same Unity project concurrently. Scratch payloads are one-shot. Put reusable C# in the snippet library. Clean only -`AgentScratch/`; never delete `Temp/CSharpConsole/`, which contains service state. +`AgentScratch/`; never delete the parent `Temp/CSharpConsole/`, which older package +versions may still use for compatibility state. Never write REPL payloads under `Assets/`: importing them can break project compilation and prevent the service from restarting. @@ -123,7 +125,9 @@ Do not load or print the unfiltered 59-command registry during routine work. | Snippet audit | `cs snippets doctor` / `stats` | `references/snippets-audit.md` | | Refresh and compile | `cs refresh` | `references/refresh.md` | | Custom-command catalog | `cs catalog sync` / `list` | `references/catalog.md` | -| Package / connection state | `cs status` / `cs health` | `references/status.md` | +| Diagnose readiness / uncertain operation | `cs doctor` / `cs doctor --operation UUID` | `references/status.md` | +| Wait without Unity/project mutation | `cs wait-ready --timeout N` (bind refresh op/generation when resuming) | `references/status.md` | +| Package / raw service state | `cs status` / `cs health` | `references/status.md` | | Package setup | `cs setup` | `references/setup.md` | ## Output conventions @@ -135,6 +139,13 @@ Do not load or print the unfiltered 59-command registry during routine work. - A version-mismatch warning means the installed package and CLI use different `major.minor` lines. Follow `references/setup.md`; the warning does not itself block execution. +- `outcome_unknown` or `operation_in_progress` (exit 4) means the operation is + unresolved and the CLI deliberately did not create a second dispatch. Run + `cs doctor --operation --json` and verify the affected state; never + replace that UUID with a new one while it is unresolved. +- Once the local outbox records an operation id as sent, later CLI runs diagnose + it instead of dispatching it again, even after server retention expires. A new + UUID represents a new intent, not a retry. - Expanded CLI commands and JSON payloads are agent-internal. When Unity must be opened or focused, tell the user what action is needed in plain language, then run `cs status` yourself to verify the result. diff --git a/skills/unity-cli/references/commands.md b/skills/unity-cli/references/commands.md index 7d8290d..e472a9f 100644 --- a/skills/unity-cli/references/commands.md +++ b/skills/unity-cli/references/commands.md @@ -158,9 +158,13 @@ state was reached. screenshot. 3. After asset or C# file changes that require compilation, follow `references/refresh.md`; a domain reload clears REPL sessions. -4. If transport fails after a mutation may have been accepted, read back before - retrying. Never blindly repeat a create, duplicate, destroy, import, or other - mutation whose execution state is unknown. +4. Protocol-v2 operations are durably claimed before dispatch. The CLI may retry + once with the same operation id and exact bytes; Unity replays the original + response or refuses a second dispatch within the advertised dedupe window. If + the result is `outcome_unknown` or `operation_in_progress` (exit 4), run + `cs doctor --operation --json`, read back the affected state, and never + replace the unresolved UUID with a new one. After the outbox records an id as + sent, later CLI runs will diagnose rather than dispatch it again. 5. Report completion only when read-back matches the requested state. Otherwise report the observed state and the next recoverable action. diff --git a/skills/unity-cli/references/exec-code.md b/skills/unity-cli/references/exec-code.md index 4491e08..31b8ff6 100644 --- a/skills/unity-cli/references/exec-code.md +++ b/skills/unity-cli/references/exec-code.md @@ -149,4 +149,6 @@ durable storage. - `exec` output is text; the process exit code carries success/failure. Use `--json` only when the structured envelope is needed (then check `ok` / `exitCode`) -- Port is auto-detected from `Temp/CSharpConsole/refresh_state.json` +- Port is auto-detected from + `Library/CSharpConsole/RefreshState/v1/refresh_state.json`, with the old + `Temp/CSharpConsole/refresh_state.json` retained as a compatibility fallback. diff --git a/skills/unity-cli/references/refresh.md b/skills/unity-cli/references/refresh.md index b30c4c0..b5008f7 100644 --- a/skills/unity-cli/references/refresh.md +++ b/skills/unity-cli/references/refresh.md @@ -10,10 +10,27 @@ cs refresh --exit-playmode --wait 120 ``` - `--exit-playmode` automatically exits play mode before refreshing if needed -- `--wait TIMEOUT` blocks until the refresh + compile + domain-reload cycle completes (default 120s, max 600s) -- Domain reload restarts the HTTP service and clears REPL sessions; `--wait` handles reconnection +- `--wait TIMEOUT` blocks until the refresh + compile + domain-reload cycle completes (default 60s, max 600s) +- Domain reload restarts the HTTP service and clears REPL sessions; `--wait` + follows the matching project and requires this refresh's operation id / + generation before accepting `ready` -After completion, verify with `cs status` if needed. +After completion, use `cs doctor` for a full readiness check if needed. To wait +without triggering another refresh, use `cs wait-ready --timeout 120`. + +If `refresh --wait` exits 4, do not issue another refresh. Its JSON contains +`expectedRefreshOperationId` and `expectedGeneration`; resume that exact wait: + +```bash +cs wait-ready --refresh-operation "" --generation --timeout 120 +``` + +`operation_in_progress` means the matching refresh is still active. +`outcome_unknown` means the matching target/state could not be confirmed. In +either case inspect `cs doctor`; if the result also includes an HTTP +`invocation.invocationId`, use that UUID with `cs doctor --operation ` to +diagnose the acceptance request. The refresh operation id and HTTP invocation id +are different identifiers. **Manual control (when you need fine-grained steps):** diff --git a/skills/unity-cli/references/status.md b/skills/unity-cli/references/status.md index 5e9d9a5..4f6c4d3 100644 --- a/skills/unity-cli/references/status.md +++ b/skills/unity-cli/references/status.md @@ -1,25 +1,77 @@ # Unity CLI Status -Check the current state of the Unity C# Console service: package installation and -service connectivity. +Use the smallest Unity/project-read-only check that matches the question. +`doctor` and `wait-ready` may create and delete a probe in the machine-local +outbox cache to verify that future protected invocations can be recorded: -Run: +- `cs status` — concise project, package, connection, protocol, and Unity 2022 + compatibility status. +- `cs health` — raw live service snapshot. +- `cs doctor` — actionable project identity, protocol, journal, compile, and + readiness diagnosis. +- `cs wait-ready --timeout 120` — wait without installing, refreshing, exiting + Play Mode, or otherwise mutating Unity. Add `--refresh-operation + --generation ` when resuming a timed-out refresh wait. +- `cs doctor --operation --json` — reconcile an uncertain operation with + the local outbox and Unity's durable receipt. ```bash cs status +cs doctor +cs wait-ready --timeout 120 +cs wait-ready --refresh-operation "" --generation 7 --timeout 120 ``` Reports: - **project**: Unity project root path - **package**: whether `com.zh1zh1.csharpconsole` is installed and resolvable - **service**: whether the Unity HTTP service is reachable at the configured port -- **version**: package/protocol/Unity versions from the live service, or the +- **version**: package/protocol versions and Unity 2022 compatibility, or the on-disk package version when the service is down Exit code 0 means **fully operational** (service reachable and healthy); any degraded state — no project, package missing, service unreachable — exits 1. Read the text to see which layer is down. +`doctor` and `wait-ready` additionally verify that the reachable service belongs +to this project and supports the protocol-v2 at-most-once journal. They describe +the compatible editor as **Unity 2022**; exact patch information appears only in +verbose raw evidence. + +`wait-ready` follows a matching service across domain reload and local port +changes. Compile failure, wrong-project identity, incompatible protocol, or an +unwritable journal fail immediately. A normal reload or temporarily refused +connection remains waitable until the monotonic deadline. A bound refresh timeout +returns exit 4: `operation_in_progress` when the same op is visibly active, or +`outcome_unknown` when the target/op cannot be confirmed. Do not start a new +refresh; resume the same op/generation. + +## Uncertain operations + +Every protocol-v2 HTTP invocation is bound to one UUID and exact request bytes. Unity +durably claims it before dispatch and persists the response before writing the +socket. Within the advertised dedupe window, a retry with the same UUID therefore +replays or reports the existing state instead of dispatching twice. The CLI uses +that retry only inside the original bounded request. Once its local outbox records +the id as sent, a later CLI process will not dispatch it again—even after Unity's +retention window expires—and directs recovery through `doctor`. + +If an HTTP invocation result is `outcome_unknown` or +`operation_in_progress` (exit 4), do not replace it with a new invocation id: + +```bash +cs doctor --operation "" --json +``` + +Then perform the smallest independent read-back. `completed` means the handler +returned and its response is replayable; it does not replace verification that +the requested Unity state was reached. `operation_in_progress` means to keep +observing the same UUID. `outcome_unknown` means it may have run and will not be +dispatched again. `protection_expired` means a local completed receipt remains but +Unity no longer retains the server record; this CLI still refuses to reuse the id. +Refresh lifecycle `opId` / `generation` values are separate from that HTTP +invocation UUID; recover them with the bound `wait-ready` form above. + **Version mismatch handling:** if the output contains `⚠` indicating CLI/package version misalignment, do NOT just report the mismatch. Explain that the installed Unity package and the bundled CLI are on different `major.minor` lines, and ask the user to diff --git a/skills/unity-cli/scripts/cli/core_bridge.py b/skills/unity-cli/scripts/cli/core_bridge.py index 8633f9b..a1c9948 100644 --- a/skills/unity-cli/scripts/cli/core_bridge.py +++ b/skills/unity-cli/scripts/cli/core_bridge.py @@ -1,16 +1,28 @@ """Dynamic bridge to csharpconsole_core from an installed Unity package.""" import errno +import hashlib import json import os import sys import time +import uuid from pathlib import Path from cli import PACKAGE_NAME, DEFAULT_EDITOR_PORT, load_pkg_path, save_pkg_path CORE_RELATIVE = Path("Editor/ExternalTool~/console-client") _RETRY_DELAY_S = 1 +_RELIABLE_ENDPOINTS = frozenset({ + "editor", + "compile", + "editor-compile", + "runtime-compile", + "refresh", + "command", + "batch", + "execute", +}) def _is_connection_refused(error): @@ -113,28 +125,496 @@ def _ensure_path(core_path): sys.path.insert(0, sp) -def _make_post_with_retry(transport_http, state, default_timeout): - """Create a POST function that retries one refused connection.""" - # The urllib-based core raises TransportError for every transport failure - # (connection refused, timeout, non-2xx). Older requests-based cores raised - # OSError subclasses instead. Catch both so the domain-reload retry survives - # whichever core version is resolved; fall back to OSError only against a - # core that predates TransportError. - transport_error = getattr(transport_http, "TransportError", None) - transient = (OSError, transport_error) if transport_error is not None else (OSError,) +def _decode_envelope(raw): + """Return ``(envelope, data)`` for a service response, or ``({}, {})``.""" + try: + envelope = json.loads(raw) if isinstance(raw, str) else raw + except (TypeError, ValueError): + return {}, {} + if not isinstance(envelope, dict): + return {}, {} + data = envelope.get("dataJson", {}) + if isinstance(data, str): + try: + data = json.loads(data) + except (TypeError, ValueError): + data = {} + return envelope, data if isinstance(data, dict) else {} + + +def _error_envelope(result_type, summary, invocation=None): + envelope = { + "ok": False, + "stage": "bootstrap", + "type": result_type, + "summary": summary, + "sessionId": "", + "dataJson": "{}", + } + if invocation: + envelope["invocation"] = invocation + return json.dumps(envelope, ensure_ascii=False, separators=(",", ":")) + + +class _ReliablePost: + """Operation-aware HTTP adapter used by all ConsoleSession calls. + + It performs a health/capability handshake before a protected endpoint, + writes a machine-local outbox record before network dispatch, and reuses + the exact bytes + invocation id for one bounded retry. The Unity service + remains the authority for deduplication and result replay. + """ + + def __init__( + self, + transport_http, + state, + default_timeout, + project_root, + explicit_operation_id=None, + ): + self._transport = transport_http + self._state = state + self._default_timeout = default_timeout + self._project_root = Path(project_root).resolve() + self._explicit_operation_id = explicit_operation_id + self._explicit_consumed = False + self._health = None + self._last_invocation = None + self._last_transport_unknown = None + + from cli.reliability import InvocationOutbox, expected_editor_target_id + self._expected_target_id = expected_editor_target_id(self._project_root) + self._outbox = InvocationOutbox(self._project_root) + + # The urllib-based core raises TransportError for every transport + # failure. Older cores raised OSError subclasses instead. + transport_error = getattr(transport_http, "TransportError", None) + self._transient = ( + (OSError, transport_error) + if transport_error is not None + else (OSError,) + ) - def _post(endpoint, payload, timeout=None): - t = timeout if timeout is not None else default_timeout - url_base = state.current_server_base_url() + @property + def last_invocation(self): + return self._last_invocation + + def _next_invocation_id(self): + if self._explicit_operation_id and not self._explicit_consumed: + self._explicit_consumed = True + try: + return str(uuid.UUID(str(self._explicit_operation_id))) + except (ValueError, AttributeError, TypeError): + return None + return str(uuid.uuid4()) + + def _post_json(self, endpoint, payload, timeout, *, headers=None, body=None): + """Call the protocol-v2 transport without re-serializing *body*.""" + return self._transport.post_json( + self._state.current_server_base_url(), + endpoint, + payload, + timeout, + headers=headers, + body=body, + ) + + def _probe_reliability(self, timeout): + if self._health is not None: + return self._health + raw = None + for attempt in range(2): + try: + raw = self._transport.post_json( + self._state.current_server_base_url(), + "health", + {}, + min(timeout, 2), + ) + break + except self._transient: + if attempt != 0: + raise + time.sleep(_RETRY_DELAY_S) + envelope, data = _decode_envelope(raw) + if not envelope.get("ok") or not data: + raise RuntimeError("Unity health response is invalid") + + from cli.reliability import inspect_reliability_health + reliability = inspect_reliability_health( + data, + self._expected_target_id, + ) + if not reliability["targetVerified"] or not reliability["targetMatches"]: + raise RuntimeError( + "Unity target mismatch: the reachable service belongs to a " + "different project" + ) + if not reliability["protocolSupported"]: + raise RuntimeError( + "Unity package does not provide protocol-v2 reliable invocations" + ) + if reliability["missingCapabilities"]: + raise RuntimeError( + "Unity package does not provide the required at-most-once " + "capabilities: " + + ", ".join(reliability["missingCapabilities"]) + ) + if not reliability["unitySupported"]: + raise RuntimeError("The connected Editor is not Unity 2022") + if not reliability["dedupeWindowValid"]: + raise RuntimeError( + "Unity package did not advertise a valid invocation dedupe window" + ) + if not reliability["journalWritable"]: + raise RuntimeError( + "Unity invocation journal is not confirmed writable; refusing to execute" + ) + self._health = data + return data + + def _encode_body(self, payload): + encoder = getattr(self._transport, "encode_json_body", None) + if encoder is None: + raise RuntimeError( + "Installed Unity package core does not support reliable request bytes" + ) + return encoder(payload) + + @staticmethod + def _matching_receipt( + receipt, + *, + invocation_id, + target_id, + endpoint, + body, + ): + if not isinstance(receipt, dict): + return None + try: + receipt_id = str(uuid.UUID(str(receipt.get("invocationId")))) + except (ValueError, AttributeError, TypeError): + return None + expected_digest = hashlib.sha256(body).hexdigest() + if ( + receipt_id != invocation_id + or receipt.get("targetId") != target_id + or receipt.get("guarantee") != "at-most-once" + or not receipt.get("state") + ): + return None + if receipt.get("state") == "conflict": + # A conflict receipt describes the original binding already stored + # under this id, not the rejected request's endpoint/body. Matching + # id + target is enough to prove this new request was not executed. + return dict(receipt) + if ( + receipt.get("endpoint") != endpoint + or receipt.get("requestDigest") != expected_digest + ): + return None + return dict(receipt) + + def _record_response(self, invocation_id, target_id, endpoint, body, raw): + envelope, _ = _decode_envelope(raw) + receipt = self._matching_receipt( + envelope.get("invocation"), + invocation_id=invocation_id, + target_id=target_id, + endpoint=endpoint, + body=body, + ) + if receipt is None: + request_digest = hashlib.sha256(body).hexdigest() + receipt = { + "invocationId": invocation_id, + "targetId": target_id, + "endpoint": endpoint, + "requestDigest": request_digest, + "state": "outcome_unknown", + "guarantee": "unverified", + "replayed": False, + } + self._last_invocation = receipt + self._last_transport_unknown = ( + "Unity returned no matching durable invocation receipt; the " + "operation may have executed and was not repeated. " + f"Inspect it with `cs doctor --operation {invocation_id} --json`." + ) + try: + self._outbox.mark_unknown( + invocation_id, + "missing or inconsistent durable invocation receipt", + ) + except OSError: + pass + return + self._last_invocation = receipt try: - return transport_http.post_json(url_base, endpoint, payload, t) - except transient as error: - if not _is_connection_refused(error): - raise - time.sleep(_RETRY_DELAY_S) - return transport_http.post_json(url_base, endpoint, payload, t) + self._outbox.mark_received(invocation_id, receipt) + except OSError: + # The authoritative receipt is already durable on the Unity side. + # Do not turn a known server result into an unknown result merely + # because the local audit update failed after the response arrived. + receipt = dict(receipt) + receipt["localAuditWarning"] = "failed to update local invocation outbox" + self._last_invocation = receipt + + def __call__(self, endpoint, payload, timeout=None): + self._last_invocation = None + self._last_transport_unknown = None + request_timeout = timeout if timeout is not None else self._default_timeout + if endpoint not in _RELIABLE_ENDPOINTS: + url_base = self._state.current_server_base_url() + try: + return self._transport.post_json( + url_base, endpoint, payload, request_timeout, + ) + except self._transient as error: + if not _is_connection_refused(error): + raise + time.sleep(_RETRY_DELAY_S) + return self._transport.post_json( + url_base, endpoint, payload, request_timeout, + ) - return _post + try: + health = self._probe_reliability(request_timeout) + except self._transient as error: + return _error_envelope( + "system_error", + f"Unity reliability preflight could not reach the service: {error}", + ) + except Exception as error: + return _error_envelope("capability_missing", str(error)) + + invocation_id = self._next_invocation_id() + if not invocation_id: + return _error_envelope( + "validation_error", + "--operation-id must be a UUID", + ) + if self._explicit_operation_id: + existing = self._outbox.load(invocation_id) + if existing is None: + return _error_envelope( + "validation_error", + "Explicit operation ids are recovery-only and must already " + "exist in the local invocation outbox. Omit --operation-id " + "for a new intent.", + { + "invocationId": invocation_id, + "state": "not_executed", + "guarantee": "local-no-dispatch", + "replayed": False, + }, + ) + + try: + body = self._encode_body(payload) + except Exception as error: + return _error_envelope("capability_missing", str(error)) + + target_id = health.get("targetId") or self._expected_target_id + request_hash = hashlib.sha256( + endpoint.encode("utf-8") + b"\n" + body + ).hexdigest() + request_digest = hashlib.sha256(body).hexdigest() + headers = { + "X-CSharpConsole-Invocation-Id": invocation_id, + "X-CSharpConsole-Target-Id": target_id, + } + try: + self._outbox.prepare( + invocation_id, + target_id=target_id, + endpoint=endpoint, + request_hash=request_hash, + request_digest=request_digest, + ) + self._outbox.mark_sending(invocation_id) + except OSError as error: + error_text = str(error) + conflict = "already bound to a different request" in error_text + already_sent = "has already been sent" in error_text + if already_sent: + existing = self._outbox.load(invocation_id) or {} + existing_state = existing.get("state") or "unknown" + completed = existing_state in { + "completed", + "succeeded", + "failed", + "replayed", + } + receipt = { + "invocationId": invocation_id, + "state": existing_state, + "guarantee": "local-no-redispatch", + "replayed": False, + } + self._last_invocation = receipt + return _error_envelope( + ( + "operation_already_completed" + if completed + else "outcome_unknown" + ), + ( + f"Operation {invocation_id} is already {existing_state}; " + "it was not dispatched again. Use a new id only for a " + "new intent." + if completed + else + f"Operation {invocation_id} was already sent and was not " + "dispatched again. Inspect it with " + f"`cs doctor --operation {invocation_id} --json`." + ), + receipt, + ) + return _error_envelope( + "invocation_conflict" if conflict else "invocation_store_unavailable", + ( + f"Operation id conflicts with its local request binding: {error}" + if conflict + else f"Local invocation outbox is not writable: {error}" + ), + { + "invocationId": invocation_id, + "state": "conflict" if conflict else "not_executed", + "guarantee": "at-most-once", + "replayed": False, + }, + ) + + first_error = None + last_error = None + for attempt in range(2): + try: + raw = self._post_json( + endpoint, + payload, + request_timeout, + headers=headers, + body=body, + ) + self._record_response( + invocation_id, + target_id, + endpoint, + body, + raw, + ) + return raw + except self._transient as error: + first_error = first_error or error + last_error = error + if attempt == 0: + time.sleep(_RETRY_DELAY_S) + continue + + both_refused = ( + _is_connection_refused(first_error) + and _is_connection_refused(last_error) + ) + state = "not_executed" if both_refused else "outcome_unknown" + receipt = { + "invocationId": invocation_id, + "state": state, + "guarantee": "at-most-once", + "replayed": False, + } + self._last_invocation = receipt + if state == "outcome_unknown": + self._last_transport_unknown = ( + "Unity may have applied this operation; it was not repeated. " + f"Inspect it with `cs doctor --operation {invocation_id} --json`." + ) + try: + if state == "outcome_unknown": + self._outbox.mark_unknown(invocation_id, str(last_error)) + else: + self._outbox.mark_not_executed(invocation_id, str(last_error)) + except OSError: + pass + return _error_envelope( + "outcome_unknown" if state == "outcome_unknown" else "system_error", + self._last_transport_unknown + or f"Unity service is unreachable: {last_error}", + receipt, + ) + + def annotate_result(self, result): + """Attach the operation receipt and preserve unknown-outcome semantics.""" + if not isinstance(result, dict) or not self._last_invocation: + return result + result = dict(result) + result["invocation"] = dict(self._last_invocation) + if self._last_transport_unknown: + result.update({ + "ok": False, + "type": "outcome_unknown", + "exitCode": 4, + "summary": self._last_transport_unknown, + }) + elif result.get("type") in {"outcome_unknown", "operation_in_progress"}: + invocation_id = ( + self._last_invocation.get("invocationId") + or self._last_invocation.get("id") + ) + if invocation_id and invocation_id not in (result.get("summary") or ""): + result["summary"] = ( + f"{result.get('summary') or 'Operation is unresolved'} " + f"Inspect the same id with `cs doctor --operation " + f"{invocation_id} --json`; do not replace it with a new id." + ) + return result + + +def _make_post_with_retry( + transport_http, + state, + default_timeout, + project_root=None, + operation_id=None, +): + """Create the operation-aware POST adapter for a ConsoleSession.""" + if project_root is None: + # Compatibility seam for older in-process callers. Product + # ConsoleSession construction always supplies a project root. + transport_error = getattr(transport_http, "TransportError", None) + transient = ( + (OSError, transport_error) + if transport_error is not None + else (OSError,) + ) + + def _legacy_post(endpoint, payload, timeout=None): + request_timeout = ( + timeout if timeout is not None else default_timeout + ) + url_base = state.current_server_base_url() + try: + return transport_http.post_json( + url_base, endpoint, payload, request_timeout, + ) + except transient as error: + if not _is_connection_refused(error): + raise + time.sleep(_RETRY_DELAY_S) + return transport_http.post_json( + url_base, endpoint, payload, request_timeout, + ) + + return _legacy_post + return _ReliablePost( + transport_http, + state, + default_timeout, + project_root, + explicit_operation_id=operation_id, + ) def _coerce_args_json(cmd): @@ -153,7 +633,8 @@ class ConsoleSession: def __init__(self, project_root, ip="127.0.0.1", port=DEFAULT_EDITOR_PORT, mode="editor", timeout=30, agent_root=None, pkg_dir=None, - compile_ip=None, compile_port=None, session_id=None): + compile_ip=None, compile_port=None, session_id=None, + operation_id=None): core_path = (pkg_dir / CORE_RELATIVE) if pkg_dir else resolve(project_root, agent_root) _ensure_path(core_path) @@ -181,7 +662,13 @@ def __init__(self, project_root, ip="127.0.0.1", port=DEFAULT_EDITOR_PORT, mode= self._session_id = client_base.generate_session_id(session_id) self._timeout = timeout - self._post = _make_post_with_retry(transport_http, state, timeout) + self._post = _make_post_with_retry( + transport_http, + state, + timeout, + project_root, + operation_id=operation_id, + ) self._mode_name = lambda: state.current_mode_name() # Placeholders required by csharpconsole_core API for persistent # using/define directives. Empty for CLI usage; the interactive REPL @@ -189,30 +676,37 @@ def __init__(self, project_root, ip="127.0.0.1", port=DEFAULT_EDITOR_PORT, mode= self._define = lambda: "" self._using = lambda: "" + def _annotate_result(self, result): + annotate = getattr(self._post, "annotate_result", None) + return annotate(result) if annotate else result + def exec(self, code, reset=False): # In runtime mode, the snippet must be compiled by the editor and # forwarded to the player — execute_runtime_request POSTs to the # "compile" endpoint with targetIP/targetPort. Without this branch # we'd POST to "editor" and silently run in the local editor. if self._state.runtime_mode: - return self._client.execute_runtime_request( + result = self._client.execute_runtime_request( self._post, self._parser.parse_text_http_response, self._define, self._using, self._state.runtime_ip, self._state.runtime_port, self._state.runtime_dll_path, code, self._session_id, reset, ) - return self._client.execute_editor_request( - self._post, self._parser.parse_text_http_response, - self._define, self._using, code, self._session_id, reset, - ) + else: + result = self._client.execute_editor_request( + self._post, self._parser.parse_text_http_response, + self._define, self._using, code, self._session_id, reset, + ) + return self._annotate_result(result) def command(self, namespace, action, args=None): - return self._cmd.request_command( + result = self._cmd.request_command( self._post, self._parser.parse_command_http_response, self._mode_name, namespace, action, self._session_id, args, timeout_seconds=self._timeout, ) + return self._annotate_result(result) def health(self): return self._client.request_health( @@ -227,24 +721,27 @@ def refresh(self, exit_playmode=False, changed_files=None): payload["changedFiles"] = changed_files if not payload: - return self._client.request_refresh( + result = self._client.request_refresh( self._post, self._parser.parse_refresh_http_response, self._mode_name, ) + return self._annotate_result(result) from csharpconsole_core.models import make_result, new_run_id start = time.time() run_id = new_run_id() try: raw = self._post("refresh", payload) - return self._parser.parse_refresh_http_response( + result = self._parser.parse_refresh_http_response( raw, self._mode_name(), run_id, (time.time() - start) * 1000, ) + return self._annotate_result(result) except Exception as e: - return make_result( + result = make_result( False, "bootstrap", "system_error", 3, f"Refresh request failed: {e}", "", self._mode_name(), run_id, (time.time() - start) * 1000, ) + return self._annotate_result(result) def wait_ready(self, timeout=60): return self._client.wait_for_service_recovery( @@ -321,7 +818,7 @@ def batch(self, commands_json, stop_on_error=False): results_list = results_raw ok = bool(envelope.get("ok")) - return make_result( + result = make_result( ok, "command", "" if ok else "system_error", 0 if ok else 3, envelope.get("summary") or f"Batch: {data.get('succeeded', 0)}/{data.get('total', 0)} succeeded", @@ -333,15 +830,77 @@ def batch(self, commands_json, stop_on_error=False): "results": results_list, }, ) + result_type = envelope.get("type") or result.get("type") + if not ok: + result["type"] = result_type + if result_type in {"outcome_unknown", "operation_in_progress"}: + result["exitCode"] = 4 + if isinstance(envelope.get("invocation"), dict): + result["invocation"] = envelope["invocation"] + return self._annotate_result(result) except Exception as e: - return make_result( + result = make_result( False, "command", "system_error", 3, f"Batch request failed: {e}", "", self._mode_name(), run_id, (time.time() - start) * 1000, ) + return self._annotate_result(result) + + def invocation_status(self, invocation_id): + """Inspect one server-side invocation without creating a new operation.""" + from csharpconsole_core.models import make_result, new_run_id + start = time.time() + run_id = new_run_id() + payload = { + "invocationId": invocation_id, + "targetId": self._post._expected_target_id, + } + try: + raw = self._post("invocation-status", payload, min(self._timeout, 5)) + envelope, data = _decode_envelope(raw) + ok = bool(envelope.get("ok")) + result_type = envelope.get("type") or ("" if ok else "system_error") + return make_result( + ok, + "bootstrap", + result_type, + 0 if ok else ( + 4 + if result_type in {"outcome_unknown", "operation_in_progress"} + else 3 + ), + envelope.get("summary") or "Invocation status", + "", + self._mode_name(), + run_id, + (time.time() - start) * 1000, + data, + ) + except Exception as error: + return make_result( + False, + "bootstrap", + "system_error", + 3, + f"Invocation status failed: {error}", + "", + self._mode_name(), + run_id, + (time.time() - start) * 1000, + ) def _print_text(self, result): - text = result.get("data", {}).get("text") or result.get("summary", "") + if result.get("type") in {"outcome_unknown", "operation_in_progress"}: + # annotate_result puts the stable invocation id and recovery + # command in summary. Never let a server-provided text payload hide + # that information in non-JSON output. + text = ( + result.get("summary", "") + or result.get("data", {}).get("text") + or "" + ) + else: + text = result.get("data", {}).get("text") or result.get("summary", "") text = text.replace("\\n", "\n").replace("\\t", "\t") if result.get("ok"): print(text) if text else None diff --git a/skills/unity-cli/scripts/cli/cs.py b/skills/unity-cli/scripts/cli/cs.py index 8389918..9004172 100644 --- a/skills/unity-cli/scripts/cli/cs.py +++ b/skills/unity-cli/scripts/cli/cs.py @@ -7,6 +7,7 @@ import re import subprocess import sys +import uuid from pathlib import Path # Ensure the cli package is importable when run as a standalone script @@ -77,15 +78,32 @@ def find_project_root(hint=None): def detect_port(project_root): - """Read the effective port from Temp/CSharpConsole/refresh_state.json.""" - try: - state_file = Path(project_root) / "Temp" / "CSharpConsole" / "refresh_state.json" - data = json.loads(state_file.read_text("utf-8")) - port = data.get("effectivePort") - if port: - return int(port) - except (OSError, json.JSONDecodeError, ValueError): - pass + """Read the effective Editor port from current or legacy refresh state.""" + state_files = ( + Path(project_root) + / "Library" + / "CSharpConsole" + / "RefreshState" + / "v1" + / "refresh_state.json", + # Compatibility with package versions that stored discovery state in + # Unity's rebuildable Temp directory. + Path(project_root) + / "Temp" + / "CSharpConsole" + / "refresh_state.json", + ) + for state_file in state_files: + try: + data = json.loads(state_file.read_text("utf-8")) + port = data.get("effectivePort") + if isinstance(port, bool): + continue + port = int(port) + if 1 <= port <= 65535: + return port + except (OSError, AttributeError, json.JSONDecodeError, TypeError, ValueError): + continue return None @@ -121,8 +139,55 @@ def _read_input_json(src): _SLIM_DROP = {"stage", "type", "exitCode", "sessionId", "runId", "mode", "durationMs"} _HEALTH_DROP = {"ok", "initialized", "isEditor", "port", "refreshing", "editorState", - "packageVersion", "protocolVersion", "unityVersion", "operation", - "accepted", "sessionsCleared", "exitPlayModeRequested", "message"} + "packageVersion", "protocolVersion", "unityVersion", "operation", + "accepted", "sessionsCleared", "exitPlayModeRequested", "message", + "targetId", "serviceEpoch", "capabilities", "journalWritable", + "dedupeWindowSeconds", "isUpdating", "isPlaying", + "mainThreadHeartbeatAgeMs"} + + +def _compact_diagnostic_data(source_data, *, include_operation=False): + source_data = source_data if isinstance(source_data, dict) else {} + compact = { + "ready": bool(source_data.get("ready")), + "unity": source_data.get("unity") or "Unity 2022", + "port": source_data.get("port"), + } + findings = [ + item + for item in source_data.get("findings") or [] + if item.get("severity") != "info" + ] + if findings: + compact["findings"] = findings + if source_data.get("waitedSeconds") is not None: + compact["waitedSeconds"] = source_data["waitedSeconds"] + if source_data.get("timedOut"): + compact["timedOut"] = True + for key in ("expectedRefreshOperationId", "expectedGeneration"): + if source_data.get(key) is not None: + compact[key] = source_data[key] + + operation = source_data.get("operation") + if include_operation and isinstance(operation, dict): + local = operation.get("local") or {} + server = operation.get("server") or {} + receipt = local.get("receipt") or {} + compact_operation = { + "id": operation.get("id"), + "localState": local.get("state"), + "serverState": server.get("state"), + } + if receipt.get("replayed") is not None: + compact_operation["replayed"] = bool(receipt.get("replayed")) + if operation.get("serverError"): + compact_operation["serverError"] = operation["serverError"] + compact["operation"] = { + key: value + for key, value in compact_operation.items() + if value is not None + } + return compact def _slim_result(result): @@ -144,6 +209,10 @@ def _slim_result(result): # command echo removal elif "command" in data and len(data) == 1: out.pop("data", None) + # doctor/wait-ready/refresh --wait: keep only actionable findings in + # compact mode. Full evidence remains available with --verbose. + elif "ready" in data and "findings" in data: + out["data"] = _compact_diagnostic_data(data) # health/refresh: strip diagnostic fields elif "initialized" in data or "accepted" in data: trimmed = {k: v for k, v in data.items() if k not in _HEALTH_DROP} @@ -153,6 +222,22 @@ def _slim_result(result): out.pop("summary", None) if not out.get("data"): out.pop("data", None) + # Successful receipts are verbose-only diagnostics. For non-success + # results retain only the operation id/state needed for safe recovery. + invocation = out.get("invocation") + if isinstance(invocation, dict): + if out.get("ok"): + out.pop("invocation", None) + else: + compact_invocation = { + key: invocation.get(key) + for key in ("invocationId", "id", "state") + if invocation.get(key) is not None + } + if compact_invocation: + out["invocation"] = compact_invocation + else: + out.pop("invocation", None) return out @@ -193,7 +278,80 @@ def _new_session(root, args, pkg_dir): return ConsoleSession(root, args.ip, args.port, args.mode, args.timeout, pkg_dir=pkg_dir, compile_ip=args.compile_ip, compile_port=args.compile_port, - session_id=getattr(args, "session", None)) + session_id=getattr(args, "session", None), + operation_id=getattr(args, "operation_id", None)) + + +def _new_reliability_coordinator(root, args): + from cli.reliability import ReliabilityCoordinator + return ReliabilityCoordinator( + root, + ip=args.ip, + port=args.port, + mode=args.mode, + compile_ip=args.compile_ip, + compile_port=args.compile_port, + ) + + +def _emit_diagnostic_result(result, args): + if args.as_json: + rendered = result + if not args.verbose: + rendered = dict(result) + source_data = result.get("data") or {} + rendered["data"] = _compact_diagnostic_data( + source_data, + include_operation=True, + ) + if args.verbose: + json.dump(rendered, sys.stdout, ensure_ascii=False, indent=2) + else: + json.dump( + rendered, + sys.stdout, + ensure_ascii=False, + separators=(",", ":"), + ) + print() + return + print(result.get("summary") or ("OK" if result.get("ok") else "Not ready")) + findings = (result.get("data") or {}).get("findings") or [] + for finding in findings: + if finding.get("severity") == "info" and not args.verbose: + continue + marker = { + "error": "ERROR", + "warning": "WARN", + "info": "OK", + }.get(finding.get("severity"), "INFO") + print(f"[{marker}] {finding.get('code')}: {finding.get('summary')}") + remediation = finding.get("remediation") + if remediation: + print(f" Next: {remediation}") + + +def cmd_doctor(root, args): + coordinator = _new_reliability_coordinator(root, args) + result = coordinator.doctor( + operation_id=getattr(args, "doctor_operation", None), + verbose=args.verbose, + timeout=args.timeout, + ) + _emit_diagnostic_result(result, args) + return result.get("exitCode", 3) + + +def cmd_wait_ready(root, args): + coordinator = _new_reliability_coordinator(root, args) + result = coordinator.wait_ready( + args.timeout, + expected_operation_id=getattr(args, "refresh_operation", None), + minimum_generation=getattr(args, "refresh_generation", None), + verbose=args.verbose, + ) + _emit_diagnostic_result(result, args) + return result.get("exitCode", 3) def _pin_source_tag(source): @@ -339,14 +497,26 @@ def _cmd_status_json(root, args, agent_root=None): "compileFailed": hdata.get("compileFailed", False), } # Build summary from live data - unity_ver = hdata.get("unityVersion", "") + unity_ver = hdata.get("unityVersion") editor_state = hdata.get("editorState", "") - if unity_ver: - result["summary"] = f"Connected to Unity {unity_ver} ({editor_state})" if editor_state else f"Connected to Unity {unity_ver}" + unity_supported = ( + isinstance(unity_ver, str) + and unity_ver.startswith("2022.") + ) + if unity_supported: + result["summary"] = ( + f"Connected to Unity 2022 ({editor_state})" + if editor_state + else "Connected to Unity 2022" + ) + result["ok"] = True + result["exitCode"] = 0 else: - result["summary"] = f"Connected ({editor_state})" if editor_state else "Connected" - result["ok"] = True - result["exitCode"] = 0 + result["summary"] = "Connected Editor is not Unity 2022" + if args.verbose: + data["runtimeEvidence"] = { + "unityVersion": unity_ver, + } else: data["service"] = {"reachable": False, "port": args.port, "mode": args.mode} data["editor"] = None @@ -430,19 +600,28 @@ def cmd_status(root, args, agent_root=None): s = _new_session(root, args, pkg_dir) r = s.health() if r.get("ok"): - rc = 0 data = r.get("data", {}) - print(f"service: OK (port {args.port}, {args.mode})") + unity_ver = data.get("unityVersion") + unity_supported = ( + isinstance(unity_ver, str) + and unity_ver.startswith("2022.") + ) + if unity_supported: + rc = 0 + print(f"service: OK (port {args.port}, {args.mode})") + else: + print("service: ERROR (connected Editor is not Unity 2022)") pkg_ver = data.get("packageVersion") proto_ver = data.get("protocolVersion") - unity_ver = data.get("unityVersion") if pkg_ver: ver_parts = [pkg_ver] if proto_ver is not None: ver_parts.append(f"protocol v{proto_ver}") - if unity_ver: - ver_parts.append(f"Unity {unity_ver}") + if unity_supported: + ver_parts.append("Unity 2022") print(f"version: {', '.join(ver_parts)}") + if args.verbose and unity_ver: + print(f"runtime_evidence: unityVersion={unity_ver}") else: print("service: UNREACHABLE") except Exception as e: @@ -1664,9 +1843,10 @@ def _apply_conn_opts(parser, args, payload): so an explicit flag always wins over the JSON. A value that can't be cast fails the command (like an invalid CLI flag would) instead of silently falling back.""" for key, attr, cast in (("ip", "ip", str), ("port", "port", int), - ("mode", "mode", str), ("timeout", "timeout", int), - ("compileIp", "compile_ip", str), - ("compilePort", "compile_port", int)): + ("mode", "mode", str), ("timeout", "timeout", int), + ("compileIp", "compile_ip", str), + ("compilePort", "compile_port", int), + ("operationId", "operation_id", str)): if key in payload and not hasattr(args, attr): try: setattr(args, attr, cast(payload[key])) @@ -1761,6 +1941,12 @@ def main(): shared.add_argument("--timeout", type=int, default=SUPPRESS, help="HTTP timeout in seconds (default: 30)") shared.add_argument("--session", default=SUPPRESS, help="Reuse a named REPL session (default: fresh session)") + shared.add_argument( + "--operation-id", + dest="operation_id", + default=SUPPRESS, + help=argparse.SUPPRESS, + ) shared.add_argument("--json", dest="as_json", action="store_true", default=SUPPRESS, help="JSON output (compact by default, use --verbose for full)") shared.add_argument("--verbose", action="store_true", default=SUPPRESS, @@ -1778,6 +1964,40 @@ def main(): sub.add_parser("status", parents=[shared], help="Package + connection status") + sp_doctor = sub.add_parser( + "doctor", + parents=[shared], + help="Read-only project, package, readiness, and operation diagnosis", + ) + sp_doctor.add_argument( + "--operation", + dest="doctor_operation", + default=None, + metavar="UUID", + help="Inspect one uncertain operation without re-running it", + ) + + sp_wait_ready = sub.add_parser( + "wait-ready", + parents=[shared], + help="Wait without mutation until the matching Unity 2022 service is ready", + ) + sp_wait_ready.add_argument( + "--refresh-operation", + dest="refresh_operation", + default=None, + metavar="OP_ID", + help="Resume waiting for one refresh operation id", + ) + sp_wait_ready.add_argument( + "--generation", + dest="refresh_generation", + default=None, + type=int, + metavar="N", + help="Minimum generation paired with --refresh-operation", + ) + sp_exec = sub.add_parser("exec", parents=[shared], help="Execute C# code") sp_exec.add_argument("--file", "-f", dest="file", help="Read raw C# code from a file") @@ -1929,10 +2149,28 @@ def main(): # overwrites of values given at the top level. for k, v in (("project", None), ("ip", "127.0.0.1"), ("port", None), ("mode", "editor"), ("compile_ip", None), ("compile_port", None), - ("timeout", 30), ("as_json", False), ("verbose", False)): + ("timeout", 30), ("as_json", False), ("verbose", False), + ("operation_id", None)): if not hasattr(args, k): setattr(args, k, v) + if args.cmd == "wait-ready": + refresh_operation = getattr(args, "refresh_operation", None) + refresh_generation = getattr(args, "refresh_generation", None) + if (refresh_operation is None) != (refresh_generation is None): + p.error( + "--refresh-operation and --generation must be supplied together" + ) + if refresh_operation is not None: + try: + args.refresh_operation = uuid.UUID( + str(refresh_operation) + ).hex + except (ValueError, AttributeError, TypeError): + p.error("--refresh-operation must be a UUID") + if refresh_generation <= 0: + p.error("--generation must be positive") + # Resolve C# for exec: --file (raw code) or --input (JSON {"code":..}) — exactly one. if args.cmd == "exec": file = getattr(args, "file", None) @@ -1956,7 +2194,7 @@ def main(): # resolved project root (never raw cwd) so it stays stable across agents/cwd. agent_root = str(root) if root else (args.project or str(Path.cwd())) - # Auto-detect editor port from refresh_state.json when needed for + # Auto-detect the Editor port from durable refresh state when needed for # --port (editor mode) or --compile-port fallback (runtime mode). detected_editor_port = None needs_detect = args.port is None or (args.mode == "runtime" and args.compile_port is None) @@ -1989,6 +2227,10 @@ def main(): sys.exit(cmd_setup(root, args)) if args.cmd == "status": sys.exit(cmd_status(root, args, agent_root)) + if args.cmd == "doctor": + sys.exit(cmd_doctor(root, args)) + if args.cmd == "wait-ready": + sys.exit(cmd_wait_ready(root, args)) if args.cmd == "catalog": if root is None: print("Error: no Unity project found.", file=sys.stderr) @@ -2055,13 +2297,56 @@ def main(): s = _new_session(root, args, pkg_dir) def _refresh(): - r = s.refresh( + refresh_result = s.refresh( exit_playmode=getattr(args, "exit_playmode", False), changed_files=getattr(args, "files", None), ) + r = refresh_result if args.wait is not None: if r.get("ok"): - r = s.wait_ready(timeout=args.wait) + data = r.get("data") or {} + operation = data.get("operation") or {} + expected_operation_id = operation.get("opId") + expected_generation = data.get("generation") + if ( + not isinstance(expected_operation_id, str) + or not expected_operation_id + or not isinstance(expected_generation, int) + or isinstance(expected_generation, bool) + or expected_generation <= 0 + ): + r = dict(refresh_result) + r.update({ + "ok": False, + "type": "outcome_unknown", + "exitCode": 4, + "summary": ( + "Refresh was accepted without a valid operation id " + "and generation; inspect its invocation before " + "starting another refresh." + ), + }) + else: + coordinator = _new_reliability_coordinator(root, args) + wait_result = coordinator.wait_ready( + args.wait, + expected_operation_id=expected_operation_id, + minimum_generation=expected_generation, + verbose=args.verbose, + ) + if not wait_result.get("ok"): + wait_result = dict(wait_result) + wait_data = dict(wait_result.get("data") or {}) + wait_data.update({ + "expectedRefreshOperationId": expected_operation_id, + "expectedGeneration": expected_generation, + }) + wait_result["data"] = wait_data + if isinstance(refresh_result.get("invocation"), dict): + wait_result["invocation"] = dict( + refresh_result["invocation"] + ) + r = wait_result else: print("Warning: refresh returned ok=false; --wait skipped", file=sys.stderr) return r @@ -2087,6 +2372,7 @@ def _list_commands_filtered(session, a): } result = dispatch[args.cmd]() + exit_code = result.get("exitCode", 0) if args.as_json: if not args.verbose: @@ -2096,7 +2382,7 @@ def _list_commands_filtered(session, a): else: s.emit(result) - sys.exit(result.get("exitCode", 0)) + sys.exit(exit_code) if __name__ == "__main__": diff --git a/skills/unity-cli/scripts/cli/reliability.py b/skills/unity-cli/scripts/cli/reliability.py new file mode 100644 index 0000000..57ded4b --- /dev/null +++ b/skills/unity-cli/scripts/cli/reliability.py @@ -0,0 +1,1364 @@ +"""Reliability coordination for Unity service discovery and invocations. + +This module owns three concerns that otherwise leak into every CLI handler: + +* stable project identity and ready-state reduction; +* Unity/project-read-only doctor and wait-ready diagnostics, including a + machine-local outbox writability probe; +* a strict machine-local invocation outbox. + +The Unity package remains authoritative for execution and deduplication. The +outbox is an audit/recovery aid and never substitutes for the server journal. +""" + +import hashlib +import json +import os +import time +import urllib.error +import urllib.request +import uuid +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +from cli import PACKAGE_NAME, DEFAULT_EDITOR_PORT +from cli.paths import state_dir + + +PROTOCOL_VERSION = 2 +REQUIRED_CAPABILITIES = frozenset({ + "at_most_once", + "invocation_headers", + "invocation_receipts", + "invocation_status", +}) +EDITOR_PORT_SCAN_COUNT = 10 +DEFAULT_POLL_INTERVAL = 0.5 +HEARTBEAT_STALE_MS = 5000 + + +def inspect_reliability_health(health, expected_target_id=None): + """Reduce one health payload to the shared reliability policy facts.""" + health = health if isinstance(health, dict) else {} + target_id = health.get("targetId") or "" + unity_version = health.get("unityVersion") + protocol = health.get("protocolVersion") + capabilities = set(health.get("capabilities") or []) + dedupe_window = health.get("dedupeWindowSeconds") + return { + "targetId": target_id, + "targetVerified": bool(target_id), + "targetMatches": ( + not expected_target_id or target_id == expected_target_id + ), + "unitySupported": ( + isinstance(unity_version, str) + and unity_version.startswith("2022.") + ), + "protocolVersion": protocol, + "protocolSupported": ( + isinstance(protocol, int) + and not isinstance(protocol, bool) + and protocol >= PROTOCOL_VERSION + ), + "missingCapabilities": sorted( + REQUIRED_CAPABILITIES - capabilities + ), + "dedupeWindowSeconds": dedupe_window, + "dedupeWindowValid": ( + isinstance(dedupe_window, int) + and not isinstance(dedupe_window, bool) + and dedupe_window > 0 + ), + "journalWritable": health.get( + "invocationJournalWritable", + health.get("journalWritable"), + ) is True, + } + + +def _utc_now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _normalize_project_root(project_root): + path = str(Path(project_root).resolve()).rstrip("\\/") + if os.name == "nt": + path = path.lower() + return path.replace("\\", "/") + + +def expected_editor_target_id(project_root): + """Return the protocol-v2 target id for an Editor project. + + Prefer the package's project-local identity once Unity has initialized it. + This keeps junction/symlink launches aligned without trusting a network + response. Fall back to the package-side path algorithm before first launch. + The raw path is never sent or persisted in the service health response. + """ + identity_path = ( + Path(project_root) + / "Library" + / "CSharpConsole" + / "InvocationLedger" + / "v1" + / "identity.json" + ) + try: + identity = json.loads(identity_path.read_text("utf-8")) + persisted = identity.get("targetId") + persisted_root = identity.get("projectRoot") + except (OSError, ValueError, AttributeError): + persisted = None + persisted_root = None + persisted_root_matches = False + if isinstance(persisted_root, str) and persisted_root: + try: + persisted_root_matches = ( + _normalize_project_root(persisted_root) + == _normalize_project_root(project_root) + ) + except OSError: + persisted_root_matches = False + if ( + persisted_root_matches + and isinstance(persisted, str) + and persisted.startswith("editor-") + and len(persisted) == len("editor-") + 24 + and all( + character in "0123456789abcdef" + for character in persisted[len("editor-"):] + ) + ): + return persisted + + normalized = _normalize_project_root(project_root) + digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest() + return f"editor-{digest[:24]}" + + +def _strict_atomic_write(path, value): + """Durably replace a JSON file or raise; never silently lose an outbox write.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temp = path.with_name(f"{path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}") + text = json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n" + try: + with temp.open("w", encoding="utf-8", newline="\n") as stream: + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temp, path) + except Exception: + try: + temp.unlink() + except OSError: + pass + raise + + +class InvocationOutbox: + """Strict per-project operation audit stored outside the Unity project.""" + + def __init__(self, project_root): + self._root = state_dir(project_root) / "invocations" / "v1" + self._lock_path = self._root / ".lock" + + @contextmanager + def _exclusive_lock(self): + self._root.mkdir(parents=True, exist_ok=True) + with self._lock_path.open("a+b") as stream: + stream.seek(0, os.SEEK_END) + if stream.tell() == 0: + stream.write(b"\0") + stream.flush() + os.fsync(stream.fileno()) + stream.seek(0) + if os.name == "nt": + import msvcrt + msvcrt.locking(stream.fileno(), msvcrt.LK_LOCK, 1) + else: + import fcntl + fcntl.flock(stream.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + stream.seek(0) + if os.name == "nt": + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + + @staticmethod + def _normalize_id(invocation_id): + try: + return str(uuid.UUID(str(invocation_id))) + except (ValueError, AttributeError, TypeError) as error: + raise OSError(f"invalid invocation id: {invocation_id}") from error + + def _path(self, invocation_id): + normalized = self._normalize_id(invocation_id) + return self._root / f"{normalized}.json" + + def load(self, invocation_id): + path = self._path(invocation_id) + try: + value = json.loads(path.read_text("utf-8")) + except FileNotFoundError: + return None + except (OSError, ValueError) as error: + return { + "id": self._normalize_id(invocation_id), + "state": "local_record_unreadable", + "error": str(error), + } + if not isinstance(value, dict): + return { + "id": self._normalize_id(invocation_id), + "state": "local_record_unreadable", + "error": "local invocation record is not a JSON object", + } + return value + + def probe_writable(self): + """Return ``None`` when a strict outbox write/delete probe succeeds.""" + probe = self._root / f".doctor-probe.{os.getpid()}.{uuid.uuid4().hex}" + try: + with self._exclusive_lock(): + with probe.open("x", encoding="utf-8", newline="\n") as stream: + stream.write("ok\n") + stream.flush() + os.fsync(stream.fileno()) + probe.unlink() + return None + except OSError as error: + try: + probe.unlink() + except OSError: + pass + return str(error) + + def _update(self, invocation_id, state, **fields): + with self._exclusive_lock(): + record = self.load(invocation_id) or { + "id": self._normalize_id(invocation_id), + "createdAtUtc": _utc_now(), + } + record.update(fields) + record["state"] = state + record["updatedAtUtc"] = _utc_now() + _strict_atomic_write(self._path(invocation_id), record) + return record + + def prepare( + self, + invocation_id, + *, + target_id, + endpoint, + request_hash, + request_digest=None, + ): + normalized = self._normalize_id(invocation_id) + with self._exclusive_lock(): + existing = self.load(normalized) + if existing: + expected = ( + existing.get("targetId"), + existing.get("endpoint"), + existing.get("requestHash"), + ) + actual = (target_id, endpoint, request_hash) + if expected != actual: + raise OSError( + "operation id is already bound to a different request" + ) + existing_digest = existing.get("requestDigest") + if ( + request_digest is not None + and existing_digest not in (None, request_digest) + ): + raise OSError( + "operation id is already bound to a different request" + ) + if request_digest is not None and existing_digest is None: + existing["requestDigest"] = request_digest + existing["updatedAtUtc"] = _utc_now() + _strict_atomic_write(self._path(normalized), existing) + if existing.get("state") not in { + "prepared", + "not_executed", + "rejected", + }: + raise OSError( + "operation id has already been sent " + f"(state={existing.get('state') or 'unknown'}); " + "inspect it with doctor instead of dispatching it again" + ) + return existing + record = { + "id": normalized, + "state": "prepared", + "targetId": target_id, + "endpoint": endpoint, + "requestHash": request_hash, + "requestDigest": request_digest, + "createdAtUtc": _utc_now(), + "updatedAtUtc": _utc_now(), + } + _strict_atomic_write(self._path(normalized), record) + return record + + def mark_sending(self, invocation_id): + return self._update(invocation_id, "sending", sentAtUtc=_utc_now()) + + def mark_received(self, invocation_id, receipt): + return self._update( + invocation_id, + (receipt or {}).get("state") or "completed", + receivedAtUtc=_utc_now(), + receipt=receipt or {}, + ) + + def mark_unknown(self, invocation_id, error): + return self._update( + invocation_id, + "outcome_unknown", + lastError=error, + doNotRetryAsNewOperation=True, + ) + + def mark_not_executed(self, invocation_id, error): + return self._update( + invocation_id, + "not_executed", + lastError=error, + ) + + +def _json_body(payload): + return json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def _decode_envelope(raw): + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + envelope = json.loads(raw) + if not isinstance(envelope, dict): + raise ValueError("response is not a JSON object") + data = envelope.get("dataJson", {}) + if isinstance(data, str): + data = json.loads(data or "{}") + if not isinstance(data, dict): + data = {} + return envelope, data + + +class HttpConsoleAdapter: + """Small remote-owned seam for the Unity HTTP service.""" + + def __init__(self, ip): + self.ip = ip + + def post(self, port, endpoint, payload, timeout=2, headers=None): + url = f"http://{self.ip}:{port}/CSharpConsole/{endpoint}" + request_headers = {"Content-Type": "application/json"} + request_headers.update(headers or {}) + request = urllib.request.Request( + url, + data=_json_body(payload), + headers=request_headers, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + charset = response.headers.get_content_charset() or "utf-8" + return response.read().decode(charset) + except urllib.error.HTTPError as error: + # Preserve a structured body if a future service uses non-2xx. + body = error.read().decode("utf-8", errors="replace") + if body: + return body + raise OSError(f"HTTP {error.code} {error.reason}") from error + except OSError: + raise + + +def _finding(code, severity, summary, remediation=None, evidence=None): + item = { + "code": code, + "severity": severity, + "summary": summary, + } + if remediation: + item["remediation"] = remediation + if evidence is not None: + item["evidence"] = evidence + return item + + +def _read_package_version(package_dir): + if not package_dir: + return None + try: + data = json.loads((Path(package_dir) / "package.json").read_text("utf-8")) + return data.get("version") + except (OSError, ValueError, AttributeError): + return None + + +def _parse_major_minor(version): + if not isinstance(version, str): + return None + parts = version.lstrip("v").split(".") + if len(parts) < 2: + return None + try: + return int(parts[0]), int(parts[1]) + except ValueError: + return None + + +def _find_package(project_root): + if not project_root: + return None + root = Path(project_root) + manifest = root / "Packages" / "manifest.json" + try: + dependencies = json.loads(manifest.read_text("utf-8")).get( + "dependencies", {} + ) + except (OSError, ValueError, AttributeError): + dependencies = {} + source = dependencies.get(PACKAGE_NAME, "") + if isinstance(source, str) and source.startswith("file:"): + path = (root / "Packages" / source[len("file:"):]).resolve() + if (path / "package.json").is_file(): + return path + + cache = root / "Library" / "PackageCache" + try: + candidates = sorted(cache.iterdir()) + except OSError: + candidates = [] + for candidate in candidates: + if ( + candidate.name == PACKAGE_NAME + or candidate.name.startswith(PACKAGE_NAME + "@") + ) and (candidate / "package.json").is_file(): + return candidate + return None + + +def _read_plugin_version(): + try: + return (Path(__file__).with_name("VERSION")).read_text("utf-8").strip() + except OSError: + return None + + +def _health_is_ready(data): + operation = data.get("operation") or {} + phase = operation.get("phase") or "" + heartbeat_age = data.get("mainThreadHeartbeatAgeMs") + heartbeat_ready = ( + isinstance(heartbeat_age, (int, float)) + and not isinstance(heartbeat_age, bool) + and 0 <= heartbeat_age <= HEARTBEAT_STALE_MS + ) + return ( + bool(data.get("initialized")) + and data.get("editorState") == "ready" + and not data.get("refreshing") + and not data.get("isCompiling") + and not data.get("isUpdating") + and not data.get("compileFailed") + and phase in ("", "ready") + and heartbeat_ready + ) + + +class ReliabilityCoordinator: + """Deep coordinator behind doctor, wait-ready, and invocation recovery.""" + + def __init__( + self, + project_root, + *, + ip="127.0.0.1", + port=DEFAULT_EDITOR_PORT, + mode="editor", + compile_ip=None, + compile_port=None, + adapter=None, + clock=None, + sleeper=None, + ): + self.project_root = ( + Path(project_root).resolve() if project_root is not None else None + ) + self.ip = ( + compile_ip or "127.0.0.1" + if mode == "runtime" + else ip + ) + self.port = ( + compile_port or DEFAULT_EDITOR_PORT + if mode == "runtime" + else port + ) + self.mode = mode + self.adapter = adapter or HttpConsoleAdapter(self.ip) + self.clock = clock or time.monotonic + self.sleeper = sleeper or time.sleep + self.expected_target = ( + expected_editor_target_id(self.project_root) + if self.project_root is not None + else None + ) + self.outbox = ( + InvocationOutbox(self.project_root) + if self.project_root is not None + else None + ) + self._outbox_probe_checked = False + self._outbox_probe_error = None + + def _refresh_expected_target(self): + if self.project_root is None: + return + self.expected_target = expected_editor_target_id(self.project_root) + + def _candidate_ports(self): + values = [int(self.port or DEFAULT_EDITOR_PORT)] + if self.ip in {"127.0.0.1", "localhost", "::1"}: + values.extend( + range(DEFAULT_EDITOR_PORT, DEFAULT_EDITOR_PORT + EDITOR_PORT_SCAN_COUNT) + ) + return list(dict.fromkeys(values)) + + def _probe( + self, + *, + deadline=None, + expected_operation_id=None, + minimum_generation=None, + ): + # Unity creates the project-local identity during service startup. A + # long-running wait may begin before that file exists (notably when the + # project was opened through a junction), so do not freeze the fallback + # path-derived target for the whole coordinator lifetime. + self._refresh_expected_target() + errors = [] + mismatches = [] + unverified = [] + stale_matches = [] + for port in self._candidate_ports(): + request_timeout = 2.0 + if deadline is not None: + remaining = deadline - self.clock() + if remaining <= 0: + break + request_timeout = min(request_timeout, max(0.001, remaining)) + try: + raw = self.adapter.post( + port, + "health", + {}, + timeout=request_timeout, + ) + envelope, data = _decode_envelope(raw) + except Exception as error: + errors.append({"port": port, "error": str(error)}) + continue + if not envelope.get("ok"): + errors.append({ + "port": port, + "error": envelope.get("summary") or "health returned ok=false", + }) + continue + target = data.get("targetId") + if self.expected_target: + if not target: + unverified.append({ + "reachable": True, + "port": port, + "envelope": envelope, + "health": data, + }) + continue + if target != self.expected_target: + # The identity may have appeared while this port scan was + # in flight. Only expected_editor_target_id can adopt it, + # and that helper verifies its persisted projectRoot first. + self._refresh_expected_target() + if target != self.expected_target: + mismatches.append({"port": port, "targetId": target}) + continue + if expected_operation_id or minimum_generation is not None: + operation = data.get("operation") or {} + operation_matches = ( + not expected_operation_id + or operation.get("opId") == expected_operation_id + ) + generation_matches = ( + minimum_generation is None + or int(data.get("generation") or 0) + >= int(minimum_generation) + ) + if not operation_matches or not generation_matches: + stale_matches.append({ + "reachable": True, + "port": port, + "envelope": envelope, + "health": data, + }) + continue + return { + "reachable": True, + "port": port, + "envelope": envelope, + "health": data, + "errors": errors, + "mismatches": mismatches, + } + if stale_matches: + fallback = dict(stale_matches[0]) + fallback["errors"] = errors + fallback["mismatches"] = mismatches + fallback["staleMatches"] = [ + {"port": item["port"]} + for item in stale_matches + ] + return fallback + if unverified: + fallback = dict(unverified[0]) + fallback["errors"] = errors + fallback["mismatches"] = mismatches + fallback["unverified"] = [ + {"port": item["port"]} + for item in unverified + ] + return fallback + return { + "reachable": False, + "port": None, + "health": {}, + "errors": errors, + "mismatches": mismatches, + } + + def _inspect_operation( + self, + invocation_id, + probe, + *, + verbose=False, + deadline=None, + ): + local = self.outbox.load(invocation_id) if self.outbox else None + local_receipt_trusted = False + if isinstance(local, dict): + local_receipt = local.get("receipt") + if isinstance(local_receipt, dict): + try: + receipt_id = str(uuid.UUID( + str(local_receipt.get("invocationId")) + )) + except (ValueError, AttributeError, TypeError): + receipt_id = None + digest = local_receipt.get("requestDigest") + local_receipt_trusted = ( + receipt_id == invocation_id + and local.get("id") == invocation_id + and local_receipt.get("targetId") == local.get("targetId") + and local_receipt.get("targetId") == self.expected_target + and local_receipt.get("endpoint") == local.get("endpoint") + and local_receipt.get("requestDigest") + == local.get("requestDigest") + and local_receipt.get("guarantee") == "at-most-once" + and isinstance(digest, str) + and len(digest) == 64 + and all(character in "0123456789abcdefABCDEF" for character in digest) + ) + server = None + server_error = None + if probe.get("reachable"): + payload = { + "invocationId": invocation_id, + "targetId": self.expected_target or "", + } + headers = {} + if self.expected_target: + headers["X-CSharpConsole-Target-Id"] = self.expected_target + try: + request_timeout = 3.0 + if deadline is not None: + remaining = deadline - self.clock() + if remaining <= 0: + raise TimeoutError("diagnostic timeout expired") + request_timeout = min(request_timeout, max(0.001, remaining)) + raw = self.adapter.post( + probe["port"], + "invocation-status", + payload, + timeout=request_timeout, + headers=headers, + ) + envelope, data = _decode_envelope(raw) + if not envelope.get("ok"): + server_error = envelope.get("summary") + elif ( + data.get("invocationId") != invocation_id + or data.get("targetId") != self.expected_target + ): + server_error = ( + "Invocation status response identity does not match the " + "requested operation." + ) + elif ( + ( + data.get("found") + or data.get("protectionExpired") + or data.get("state") == "protection_expired" + ) + and isinstance(local, dict) + and local.get("state") != "conflict" + and not ( + isinstance(local.get("receipt"), dict) + and local["receipt"].get("state") == "conflict" + ) + and ( + not local.get("endpoint") + or not local.get("requestDigest") + or data.get("endpoint") != local.get("endpoint") + or str(data.get("requestDigest") or "").lower() + != str(local.get("requestDigest") or "").lower() + ) + ): + server_error = ( + "Invocation status response does not match the local " + "endpoint and request-digest binding." + ) + else: + server = data + except Exception as error: + server_error = str(error) + if not verbose: + if isinstance(local, dict): + local_receipt = local.get("receipt") + local = { + key: local.get(key) + for key in ("id", "state", "endpoint", "updatedAtUtc") + if local.get(key) is not None + } + if isinstance(local_receipt, dict): + local["receipt"] = { + key: local_receipt.get(key) + for key in ( + "invocationId", + "state", + "guarantee", + "replayed", + ) + if local_receipt.get(key) is not None + } + if isinstance(server, dict): + server = { + key: server.get(key) + for key in ( + "found", + "invocationId", + "targetId", + "serviceEpoch", + "endpoint", + "state", + "protectionExpired", + "previousState", + "createdAtUtc", + "updatedAtUtc", + ) + if server.get(key) is not None + } + return { + "id": invocation_id, + "local": local, + "localReceiptTrusted": local_receipt_trusted, + "server": server, + "serverError": server_error, + } + + def _report_from_probe( + self, + probe, + *, + operation_id=None, + verbose=False, + deadline=None, + ): + findings = [] + package_dir = _find_package(self.project_root) + plugin_version = _read_plugin_version() + package_version = _read_package_version(package_dir) + + if self.project_root is None: + findings.append(_finding( + "project.not_found", + "error", + "No Unity project was found.", + "Run from a Unity project or pass --project.", + )) + else: + findings.append(_finding( + "project.found", + "info", + "Unity project detected.", + evidence={"path": str(self.project_root)} if verbose else None, + )) + + if self.outbox is not None: + if not self._outbox_probe_checked: + self._outbox_probe_error = self.outbox.probe_writable() + self._outbox_probe_checked = True + if self._outbox_probe_error: + findings.append(_finding( + "outbox.unwritable", + "error", + "The machine-local invocation outbox is not writable.", + "Restore write access to the unity-cli user cache.", + ( + {"error": self._outbox_probe_error} + if verbose + else None + ), + )) + else: + findings.append(_finding( + "outbox.writable", + "info", + "The machine-local invocation outbox is writable.", + )) + + if package_dir is None: + findings.append(_finding( + "package.not_installed", + "error", + "The C# Console package is not resolved.", + "Run `cs setup` only after approving the exact package source.", + {"requiresApproval": True}, + )) + else: + findings.append(_finding( + "package.installed", + "info", + "The C# Console package is resolved.", + evidence={ + "path": str(package_dir), + "version": package_version, + } if verbose else {"version": package_version}, + )) + if ( + _parse_major_minor(plugin_version) + and _parse_major_minor(package_version) + and _parse_major_minor(plugin_version) + != _parse_major_minor(package_version) + ): + findings.append(_finding( + "version.mismatch", + "error", + "unity-cli and the package are on different compatibility lines.", + "Align their major.minor versions, then re-run `cs setup`.", + { + "plugin": plugin_version, + "package": package_version, + }, + )) + + health = probe.get("health") or {} + if not probe.get("reachable"): + if probe.get("mismatches"): + findings.append(_finding( + "target.mismatch", + "error", + "Reachable Unity services belong to other projects.", + "Start this project with UnityStart.cmd or pass the correct port.", + probe["mismatches"], + )) + else: + findings.append(_finding( + "service.unreachable", + "error", + "The Unity service is not reachable.", + "Start Unity 2022 with UnityStart.cmd, then run `cs wait-ready`.", + probe.get("errors") if verbose else None, + )) + else: + findings.append(_finding( + "service.reachable", + "info", + "The Unity 2022 service is reachable.", + evidence={"port": probe["port"]}, + )) + reliability = inspect_reliability_health( + health, + self.expected_target, + ) + if not reliability["targetVerified"]: + findings.append(_finding( + "target.unverified", + "error", + "The service cannot prove which Unity project it belongs to.", + "Use a package with protocol v2 target identity.", + )) + elif not reliability["targetMatches"]: + findings.append(_finding( + "target.mismatch", + "error", + "The service belongs to a different Unity project.", + "Use the port reported by this project's service.", + )) + + if not reliability["unitySupported"]: + findings.append(_finding( + "unity.unsupported", + "error", + "The connected Editor is not Unity 2022.", + "Open this project with Unity 2022.", + )) + + if ( + not reliability["protocolSupported"] + or reliability["missingCapabilities"] + or not reliability["dedupeWindowValid"] + ): + findings.append(_finding( + "protocol.reliability_missing", + "error", + "The installed service does not support reliable invocations.", + "Update the C# Console package to the protocol-v2 release.", + { + "protocolVersion": reliability["protocolVersion"], + "missing": reliability["missingCapabilities"], + "dedupeWindowSeconds": reliability[ + "dedupeWindowSeconds" + ], + }, + )) + elif not reliability["journalWritable"]: + findings.append(_finding( + "journal.unwritable", + "error", + "The Unity invocation journal is not writable.", + "Restore write access to the project's Library directory.", + )) + else: + findings.append(_finding( + "protocol.at_most_once", + "info", + "At-most-once invocation protection is available.", + evidence={ + "dedupeWindowSeconds": health.get("dedupeWindowSeconds"), + }, + )) + + operation = health.get("operation") or {} + if health.get("compileFailed"): + findings.append(_finding( + "editor.compile_failed", + "error", + "Unity compilation failed.", + "Fix Console compilation errors, then run `cs wait-ready`.", + )) + elif operation.get("phase") == "failed": + findings.append(_finding( + "editor.refresh_failed", + "error", + operation.get("message") or "Unity refresh failed.", + "Inspect the failure, then run a new `cs refresh --wait`.", + )) + elif _health_is_ready(health): + findings.append(_finding( + "editor.ready", + "info", + "Unity 2022 is ready.", + )) + else: + state = ( + operation.get("message") + or health.get("editorState") + or "not ready" + ) + findings.append(_finding( + "editor.not_ready", + "error", + f"Unity 2022 is not ready: {state}.", + "Run `cs wait-ready` to wait without triggering a mutation.", + )) + + operation_report = None + operation_uncertain = False + operation_result_type = None + operation_summary = None + if operation_id: + try: + normalized = str(uuid.UUID(str(operation_id))) + except (ValueError, TypeError, AttributeError): + normalized = None + findings.append(_finding( + "operation.invalid_id", + "error", + "--operation must be a UUID.", + )) + if normalized: + operation_report = self._inspect_operation( + normalized, + probe, + verbose=verbose, + deadline=deadline, + ) + server = operation_report.get("server") or {} + local = operation_report.get("local") or {} + local_receipt = local.get("receipt") or {} + state = server.get("state") + local_state = local.get("state") + local_receipt_state = ( + local_receipt.get("state") + if isinstance(local_receipt, dict) + else None + ) + local_receipt_trusted = bool( + operation_report.get("localReceiptTrusted") + ) + completed_states = {"completed", "succeeded", "failed", "replayed"} + unknown_states = { + "outcome_unknown", + "unknown", + "expired_unknown", + } + in_progress_states = {"started", "sending", "in_progress"} + + if state in completed_states: + severity = "info" + summary = f"Operation {normalized} is {state} and will not be repeated." + elif state in unknown_states: + severity = "error" + summary = ( + f"Operation {normalized} may have executed; do not retry it " + "with a new id." + ) + operation_uncertain = True + operation_result_type = "outcome_unknown" + elif state in in_progress_states: + severity = "warning" + summary = f"Operation {normalized} is still in progress." + operation_uncertain = True + operation_result_type = "operation_in_progress" + elif state == "protection_expired": + previous_state = server.get("previousState") + if previous_state == "completed": + severity = "warning" + summary = ( + f"Operation {normalized} completed, but its server " + "at-most-once protection window has expired. This " + "CLI will not dispatch that id again." + ) + else: + severity = "error" + summary = ( + f"Operation {normalized} protection expired after " + f"state {previous_state or 'unknown'}; its outcome " + "must be treated as unresolved and the id must not " + "be dispatched again." + ) + operation_uncertain = True + operation_result_type = "outcome_unknown" + elif ( + state == "not_found" + and local_receipt_trusted + and local_receipt_state in completed_states + ): + severity = "warning" + state = "protection_expired" + summary = ( + f"Operation {normalized} has a local {local_receipt_state} " + "receipt, but the server no longer retains its protection " + "record. This CLI will not dispatch that id again." + ) + elif local_receipt_trusted and local_receipt_state in completed_states: + severity = "info" + state = local_receipt_state + summary = ( + f"Operation {normalized} has a durable local {state} " + "receipt. This CLI will not dispatch that id again." + ) + elif local_state in unknown_states or local_state in in_progress_states: + severity = "error" + state = local_state + summary = ( + f"Operation {normalized} may have been sent, but no durable " + "server result is available." + ) + operation_uncertain = True + operation_result_type = "outcome_unknown" + elif local_state in { + "prepared", + "not_executed", + "rejected", + }: + severity = "info" + state = local_state + summary = f"Operation {normalized} was definitely not executed." + elif local_state == "conflict": + severity = "error" + state = local_state + summary = ( + f"Operation id {normalized} belongs to a different request; " + "the original operation outcome is not confirmed." + ) + operation_uncertain = True + operation_result_type = "outcome_unknown" + elif operation_report.get("local"): + severity = "warning" + summary = ( + f"Operation {normalized} exists locally but has no confirmed " + "server result." + ) + operation_uncertain = True + operation_result_type = "outcome_unknown" + else: + severity = "warning" + summary = f"Operation {normalized} was not found." + operation_uncertain = True + operation_result_type = "outcome_unknown" + operation_summary = summary + findings.append(_finding( + f"operation.{state or 'unconfirmed'}", + severity, + summary, + ( + "Read back the affected Unity state before deciding on a " + "new operation." + if severity != "info" + else None + ), + )) + + errors = [item for item in findings if item["severity"] == "error"] + ready = ( + bool(probe.get("reachable")) + and _health_is_ready(health) + and not errors + and not operation_uncertain + ) + data = { + "ready": ready, + "unity": "Unity 2022", + "port": probe.get("port"), + "targetId": health.get("targetId"), + "serviceEpoch": health.get("serviceEpoch"), + "findings": findings, + } + if operation_report is not None: + data["operation"] = operation_report + if verbose and health: + data["rawHealth"] = health + + if operation_uncertain: + summary = operation_summary or ( + "The operation outcome is not confirmed; do not repeat it with a new id." + ) + result_type = operation_result_type or "outcome_unknown" + exit_code = 4 + elif ready: + summary = "Unity 2022 is ready." + result_type = "ready" + exit_code = 0 + else: + summary = errors[0]["summary"] if errors else "Unity 2022 is not ready." + result_type = "not_ready" + exit_code = 3 + return { + "ok": ready, + "stage": "bootstrap", + "type": result_type, + "exitCode": exit_code, + "summary": summary, + "data": data, + } + + def doctor(self, *, operation_id=None, verbose=False, timeout=None): + deadline = ( + self.clock() + max(0.0, float(timeout)) + if timeout is not None + else None + ) + return self._report_from_probe( + self._probe(deadline=deadline), + operation_id=operation_id, + verbose=verbose, + deadline=deadline, + ) + + def wait_ready( + self, + timeout, + *, + expected_operation_id=None, + minimum_generation=None, + verbose=False, + poll_interval=DEFAULT_POLL_INTERVAL, + ): + timeout = max(0.0, float(timeout)) + deadline = self.clock() + timeout + last_probe = None + while True: + probe = self._probe( + deadline=deadline, + expected_operation_id=expected_operation_id, + minimum_generation=minimum_generation, + ) + last_probe = probe + health = probe.get("health") or {} + operation = health.get("operation") or {} + + report = self._report_from_probe(probe, verbose=verbose) + terminal_codes = { + item["code"] + for item in report["data"]["findings"] + if item["severity"] == "error" + } + if ( + self.ip in {"127.0.0.1", "localhost", "::1"} + and len(self._candidate_ports()) > 1 + ): + # Other local Unity projects are expected to occupy adjacent + # ports while the target Editor is reloading. They are skipped + # candidates, not proof that the requested target changed. + terminal_codes.discard("target.mismatch") + terminal_codes.discard("target.unverified") + if probe.get("unverified") or ( + not probe.get("reachable") and probe.get("mismatches") + ): + for candidate_only_code in { + "unity.unsupported", + "protocol.reliability_missing", + "journal.unwritable", + "editor.compile_failed", + "editor.refresh_failed", + }: + terminal_codes.discard(candidate_only_code) + expectation_met = True + if minimum_generation is not None: + expectation_met = ( + int(health.get("generation") or 0) >= int(minimum_generation) + ) + operation_matches = True + if expected_operation_id: + operation_matches = operation.get("opId") == expected_operation_id + expectation_met = ( + expectation_met + and operation_matches + and operation.get("phase") == "ready" + ) + if ( + not operation_matches + or operation.get("phase") + in {"requested", "refreshing_assets", "compiling", "reloading"} + ): + # A stale compile-failed flag from the previous generation + # must not terminate a refresh that has not reached its own + # terminal phase yet. + terminal_codes.discard("editor.compile_failed") + terminal_codes.discard("editor.refresh_failed") + terminal = bool(terminal_codes & { + "project.not_found", + "package.not_installed", + "version.mismatch", + "target.mismatch", + "target.unverified", + "unity.unsupported", + "protocol.reliability_missing", + "journal.unwritable", + "outbox.unwritable", + "editor.compile_failed", + "editor.refresh_failed", + }) + + if report["ok"] and expectation_met: + report["summary"] = "Unity 2022 is ready." + report["data"]["waitedSeconds"] = round( + max(0.0, timeout - max(0.0, deadline - self.clock())), + 3, + ) + return report + confirmed_in_progress = bool( + expected_operation_id + and probe.get("reachable") + and operation_matches + and operation.get("phase") + in {"requested", "refreshing_assets", "compiling", "reloading"} + ) + if terminal: + if ( + expected_operation_id + and not ( + operation_matches + and operation.get("phase") == "failed" + ) + ): + report["ok"] = False + report["type"] = ( + "operation_in_progress" + if confirmed_in_progress + else "outcome_unknown" + ) + report["exitCode"] = 4 + report["summary"] = ( + ( + f"Refresh operation {expected_operation_id} is still " + f"{operation.get('phase')}." + ) + if confirmed_in_progress + else + f"Refresh operation {expected_operation_id} has not " + "reached a confirmed terminal state. Inspect that same " + "operation before starting another refresh." + ) + return report + if self.clock() >= deadline: + report["ok"] = False + if expected_operation_id: + report["type"] = ( + "operation_in_progress" + if confirmed_in_progress + else "outcome_unknown" + ) + report["exitCode"] = 4 + report["summary"] = ( + ( + f"Timed out after {timeout:g}s while refresh operation " + f"{expected_operation_id} remained " + f"{operation.get('phase')}; do not start another " + "refresh while it is active." + ) + if confirmed_in_progress + else + f"Timed out after {timeout:g}s before refresh operation " + f"{expected_operation_id} reached confirmed ready. " + "Inspect that same operation before starting another refresh." + ) + else: + report["type"] = "wait_timeout" + report["exitCode"] = 3 + report["summary"] = ( + f"Timed out after {timeout:g}s waiting for Unity 2022 to be ready." + ) + report["data"]["timedOut"] = True + return report + self.sleeper(min(poll_interval, max(0.0, deadline - self.clock())))