refactor: unify agent history and harden runtime diagnostics
Preserve user guidance through history compression, stabilize Flash index targets, and retire withdrawn checks without applying stale verdicts. Improve runtime diagnostics, tool responses, and history rendering. Simplify documentation and comments. Validation: 2076 tests passed in the full suite; 381 affected tests and 26 frontend tests passed after cleanup. Ruff and the quality ratchet passed.
This commit is contained in:
1 parent
e983180b5b
commit
5d92939d91
127 files changed
+13738
-1988
No files matched your search
@@ -20,6 +20,7 @@ from artemis.core.diagnostics.adb_keys import (
|
||||
heal_adb_keys,
|
||||
inspect_adb_keys,
|
||||
)
|
||||
from artemis.core.diagnostics.device_smoke import smoke_test_device
|
||||
from artemis.core.diagnostics.emulator_manager import (
|
||||
EmulatorLaunchStage,
|
||||
EmulatorLaunchState,
|
||||
@@ -32,11 +33,20 @@ from artemis.core.diagnostics.probes.credentials_probe import (
|
||||
LLMCredentialsProbe,
|
||||
VisionOCRProbe,
|
||||
)
|
||||
from artemis.core.diagnostics.probes.host_probe import IntegrationHostProbe
|
||||
from artemis.core.diagnostics.probes.runtime_probe import (
|
||||
PythonRuntimeProbe,
|
||||
SystemConfigProbe,
|
||||
)
|
||||
from artemis.core.diagnostics.probes.toolchain_probe import ToolchainProbe
|
||||
from artemis.core.diagnostics.readiness import (
|
||||
CHECK_ORDER,
|
||||
Verdict,
|
||||
adb_keys_corrupted,
|
||||
base_verdict,
|
||||
collect_readiness,
|
||||
sort_by_fix_order,
|
||||
)
|
||||
from artemis.core.diagnostics.schema import (
|
||||
DeviceInfo,
|
||||
ProbeAction,
|
||||
@@ -53,6 +63,7 @@ __all__ = [
|
||||
"EmulatorLaunchStage",
|
||||
"EmulatorLaunchState",
|
||||
"BaseProbe",
|
||||
"IntegrationHostProbe",
|
||||
"PythonRuntimeProbe",
|
||||
"SystemConfigProbe",
|
||||
"AdbDeviceProbe",
|
||||
@@ -69,4 +80,11 @@ __all__ = [
|
||||
"get_adb_key_paths",
|
||||
"inspect_adb_keys",
|
||||
"heal_adb_keys",
|
||||
"smoke_test_device",
|
||||
"CHECK_ORDER",
|
||||
"Verdict",
|
||||
"collect_readiness",
|
||||
"base_verdict",
|
||||
"adb_keys_corrupted",
|
||||
"sort_by_fix_order",
|
||||
]
|
||||
@@ -0,0 +1,359 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""End-to-end device observation smoke test.
|
||||
|
||||
The readiness ADB probe only proves that ``adb devices`` and ``dumpsys`` answer.
|
||||
The most common "device shows Connected but ARTEMIS cannot observe it" failure
|
||||
is UIAutomator not answering (screenshot / hierarchy dump hangs), which today
|
||||
only surfaces as a failed task. :func:`smoke_test_device` performs exactly the
|
||||
observation the ``mobile_get_device_state`` MCP tool performs -- build (or reuse)
|
||||
the cached device controller through ``artemis.mcp.adb_server._get_controller``
|
||||
and call ``controller.get_screen_data()`` -- and reports a structured verdict
|
||||
with concrete repair guidance instead of raising.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
* ``_get_controller`` is synchronous, caches one controller per serial for the
|
||||
life of the process and never touches ``DeviceExecutionLock``. Its first call
|
||||
for a serial blocks on ``UIAutomatorClient.get_screen_data()`` (three
|
||||
``u2.connect`` attempts plus a screenshot and hierarchy dump), so it runs on a
|
||||
worker thread here.
|
||||
* ``AdbDriver.get_screen_data`` invokes the synchronous UIAutomator client on
|
||||
the event-loop thread. A plain ``asyncio.wait_for`` around the coroutine can
|
||||
therefore never fire while UIAutomator hangs. The coroutine is executed on a
|
||||
dedicated daemon thread with its own event loop, and the deadline is enforced
|
||||
on the join. A thread abandoned by a timeout finishes on its own once the
|
||||
underlying adb / HTTP timeouts elapse; being a daemon it never delays
|
||||
interpreter shutdown.
|
||||
* ``u2.connect`` restarts the on-device UIAutomator server when it believes the
|
||||
server is unhealthy. Running that against a device another ARTEMIS process is
|
||||
driving would break the running task's session, so a device with a live
|
||||
``DeviceExecutionLock`` owner is reported as busy instead of probed.
|
||||
* Nothing is left behind: no screenshot is written to disk, no lock is taken,
|
||||
and -- mirroring ``mobile_get_device_state`` -- the cached controller is kept
|
||||
for reuse rather than closed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from collections.abc import Callable
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from artemis.runtime.device_lock import DeviceExecutionLock
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
#: On-device packages installed by uiautomator2 (``python -m uiautomator2 init``).
|
||||
UIAUTOMATOR_PACKAGES: tuple[str, ...] = (
|
||||
"com.github.uiautomator",
|
||||
"com.github.uiautomator.test",
|
||||
)
|
||||
|
||||
#: A real JPEG screenshot of any phone screen is tens of kilobytes; the Android
|
||||
#: driver substitutes a 1x1 PNG (~70 bytes) when every capture path failed.
|
||||
_MIN_PLAUSIBLE_SCREENSHOT_BYTES = 512
|
||||
|
||||
_SERIAL_PLACEHOLDER = "<serial>"
|
||||
|
||||
|
||||
def _serial_or_placeholder(serial: str | None) -> str:
|
||||
return serial or _SERIAL_PLACEHOLDER
|
||||
|
||||
|
||||
def _uiautomator_fix(serial: str | None) -> list[str]:
|
||||
s = _serial_or_placeholder(serial)
|
||||
force_stops = ", then ".join(
|
||||
f"adb -s {s} shell am force-stop {pkg}" for pkg in UIAUTOMATOR_PACKAGES
|
||||
)
|
||||
return [
|
||||
"Unlock the phone and keep the screen on (UIAutomator cannot dump a locked or sleeping screen).",
|
||||
f"Restart the on-device UIAutomator server, then retry: {force_stops}",
|
||||
(
|
||||
"If it keeps failing, reinstall the UIAutomator server APKs: "
|
||||
f"python -m uiautomator2 purge --serial {s}, then python -m uiautomator2 init --serial {s} "
|
||||
"(run with the ARTEMIS virtualenv Python), or reboot the device."
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def fix_for_error(error: str | None, serial: str | None) -> list[str]:
|
||||
"""Map an error string to concrete repair steps (substring match, case-insensitive)."""
|
||||
if not error:
|
||||
return []
|
||||
text = error.lower()
|
||||
s = _serial_or_placeholder(serial)
|
||||
|
||||
if "busy" in text or "another task" in text:
|
||||
return [
|
||||
"Wait for the running task to finish, or stop it with mobile_manage_task(action='stop', ...).",
|
||||
"Then rerun the smoke test.",
|
||||
]
|
||||
if "unauthorized" in text:
|
||||
return [
|
||||
"Accept the 'Allow USB debugging' prompt on the phone (tick 'Always allow from this computer').",
|
||||
f"If no prompt appears, re-plug the USB cable and run: adb -s {s} devices",
|
||||
]
|
||||
if "offline" in text:
|
||||
return [
|
||||
"Re-plug the USB cable (try another port or cable).",
|
||||
"Run: adb reconnect (or adb kill-server, then adb start-server) and check adb devices -l.",
|
||||
]
|
||||
if "no android devices" in text or "no device" in text or "not found" in text:
|
||||
return [
|
||||
"Connect an Android device with USB debugging enabled, or start an emulator.",
|
||||
"mobile_diagnose(launch_avd='<avd name>') boots a named AVD; adb devices -l lists what is attached.",
|
||||
]
|
||||
if (
|
||||
"timeout" in text
|
||||
or "timed out" in text
|
||||
or "uiautomator" in text
|
||||
or "not responding" in text
|
||||
or "did not respond" in text
|
||||
or "hierarchy" in text
|
||||
or "screenshot" in text
|
||||
or "http" in text
|
||||
or "deadobject" in text
|
||||
):
|
||||
return _uiautomator_fix(serial)
|
||||
return [
|
||||
f"Check the raw hierarchy dump manually: adb -s {s} shell uiautomator dump, then adb -s {s} shell cat /sdcard/window_dump.xml",
|
||||
f"Check the raw screenshot path: adb -s {s} exec-out screencap -p > screen.png",
|
||||
]
|
||||
|
||||
|
||||
#: Exceptions the smoke test must never swallow.
|
||||
_PASSTHROUGH_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
||||
KeyboardInterrupt,
|
||||
SystemExit,
|
||||
asyncio.CancelledError,
|
||||
)
|
||||
|
||||
|
||||
def _describe_failure(exc: BaseException) -> str:
|
||||
"""uiautomator2 errors derive from ``BaseException`` and often carry an empty message."""
|
||||
text = str(exc).strip()
|
||||
name = type(exc).__name__
|
||||
if not text:
|
||||
return name
|
||||
return text if name in ("Exception", "RuntimeError") else f"{name}: {text}"
|
||||
|
||||
|
||||
def _resolve_requested_serial(device_serial: str | None) -> str | None:
|
||||
"""Mirror ``_get_controller``'s serial resolution (explicit arg, then env)."""
|
||||
return (
|
||||
device_serial or os.environ.get("ARTEMIS_DEVICE_ID") or os.environ.get("ADB_DEVICE_SERIAL")
|
||||
)
|
||||
|
||||
|
||||
def _find_busy_owner(target_serial: str | None) -> tuple[str, Any] | None:
|
||||
"""Return ``(device_key, owner)`` when a live ARTEMIS process owns the target device.
|
||||
|
||||
With no target serial ``_get_controller`` would pick the first attached device,
|
||||
which cannot be matched against lock records cheaply; any live owner is then
|
||||
treated as busy to stay on the safe side.
|
||||
"""
|
||||
try:
|
||||
owners = DeviceExecutionLock.get_active_owners()
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
logger.debug(f"Device lock inspection failed, skipping busy guard: {exc}")
|
||||
return None
|
||||
if not owners:
|
||||
return None
|
||||
if target_serial is None:
|
||||
key, owner = next(iter(owners.items()))
|
||||
return key, owner
|
||||
clean = DeviceExecutionLock._normalize_device_id(target_serial)
|
||||
for key, owner in owners.items():
|
||||
if key == clean or key.endswith(f"__{clean}"):
|
||||
return key, owner
|
||||
return None
|
||||
|
||||
|
||||
async def _run_in_daemon_thread(fn: Callable[[], T], timeout: float, what: str) -> T:
|
||||
"""Run ``fn`` on a daemon thread and wait at most ``timeout`` seconds.
|
||||
|
||||
Raises :class:`TimeoutError` on deadline. The thread keeps running until
|
||||
``fn`` returns on its own, but the caller is released immediately.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
future: asyncio.Future[T] = loop.create_future()
|
||||
|
||||
def _deliver(setter: Callable[[], None]) -> None:
|
||||
try:
|
||||
loop.call_soon_threadsafe(setter)
|
||||
except RuntimeError:
|
||||
# Event loop already closed: the caller gave up on us.
|
||||
pass
|
||||
|
||||
def _worker() -> None:
|
||||
try:
|
||||
value = fn()
|
||||
except BaseException as exc: # pylint: disable=broad-exception-caught
|
||||
error: BaseException = exc
|
||||
|
||||
def _fail() -> None:
|
||||
if not future.done():
|
||||
future.set_exception(error)
|
||||
|
||||
_deliver(_fail)
|
||||
return
|
||||
|
||||
def _succeed() -> None:
|
||||
if not future.done():
|
||||
future.set_result(value)
|
||||
|
||||
_deliver(_succeed)
|
||||
|
||||
threading.Thread(target=_worker, name=f"artemis-smoke-{what}", daemon=True).start()
|
||||
try:
|
||||
return await asyncio.wait_for(future, timeout=timeout)
|
||||
except TimeoutError:
|
||||
logger.warning(f"Device smoke test: {what} did not finish within {timeout:.1f}s")
|
||||
raise
|
||||
|
||||
|
||||
def _run_coroutine_blocking(coro_factory: Callable[[], Any]) -> Any:
|
||||
"""Execute a coroutine on a fresh event loop owned by the current thread."""
|
||||
return asyncio.run(coro_factory())
|
||||
|
||||
|
||||
def _count_elements(elements: Any) -> int | None:
|
||||
if elements is None:
|
||||
return None
|
||||
if isinstance(elements, str):
|
||||
return elements.count("<node")
|
||||
if isinstance(elements, dict):
|
||||
return 1
|
||||
try:
|
||||
return len(elements)
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
|
||||
def _decoded_length(b64: Any) -> int | None:
|
||||
if not isinstance(b64, str) or not b64:
|
||||
return None
|
||||
try:
|
||||
return len(base64.b64decode(b64))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
async def smoke_test_device(
|
||||
device_serial: str | None = None, timeout_seconds: float = 20.0
|
||||
) -> dict[str, Any]:
|
||||
"""Observe the device exactly like ``mobile_get_device_state`` and report a verdict.
|
||||
|
||||
Never raises. Returns::
|
||||
|
||||
{"ok": bool, "serial": str | None, "elapsed_seconds": float,
|
||||
"screenshot_bytes": int | None, "element_count": int | None,
|
||||
"error": str | None, "fix": list[str]}
|
||||
"""
|
||||
started = time.monotonic()
|
||||
requested_serial = _resolve_requested_serial(device_serial)
|
||||
result: dict[str, Any] = {
|
||||
"ok": False,
|
||||
"serial": requested_serial,
|
||||
"elapsed_seconds": 0.0,
|
||||
"screenshot_bytes": None,
|
||||
"element_count": None,
|
||||
"error": None,
|
||||
"fix": [],
|
||||
}
|
||||
|
||||
def _finish(error: str | None = None, cause: str | None = None) -> dict[str, Any]:
|
||||
"""Record the verdict; ``cause`` is the raw failure text used to pick the fix."""
|
||||
result["elapsed_seconds"] = round(time.monotonic() - started, 3)
|
||||
result["error"] = error
|
||||
result["ok"] = error is None
|
||||
result["fix"] = fix_for_error(cause if cause is not None else error, result["serial"])
|
||||
return result
|
||||
|
||||
busy = _find_busy_owner(requested_serial)
|
||||
if busy is not None:
|
||||
key, owner = busy
|
||||
description = getattr(owner, "description", "") or "unknown task"
|
||||
pid = getattr(owner, "pid", "?")
|
||||
result["serial"] = requested_serial or getattr(owner, "device_id", None)
|
||||
return _finish(
|
||||
f"Device {key} is busy: another task is running on it ({description}, pid {pid});"
|
||||
" skipping the screen capture so the running task is not disturbed."
|
||||
)
|
||||
|
||||
try:
|
||||
from artemis.mcp import adb_server
|
||||
|
||||
controller = await _run_in_daemon_thread(
|
||||
lambda: adb_server._get_controller(device_serial=device_serial),
|
||||
timeout_seconds,
|
||||
"controller-init",
|
||||
)
|
||||
except TimeoutError:
|
||||
return _finish(
|
||||
f"UIAutomator/controller initialization did not respond within {timeout_seconds:g}s"
|
||||
)
|
||||
except _PASSTHROUGH_EXCEPTIONS:
|
||||
raise
|
||||
except BaseException as exc: # pylint: disable=broad-exception-caught
|
||||
cause = _describe_failure(exc)
|
||||
return _finish(f"Failed to initialize Android device controller: {cause}", cause)
|
||||
|
||||
controller_ctx = getattr(controller, "ctx", None)
|
||||
device = getattr(controller_ctx, "device", None)
|
||||
device_id = getattr(device, "device_id", None)
|
||||
if isinstance(device_id, str) and device_id:
|
||||
result["serial"] = device_id
|
||||
|
||||
remaining = max(0.5, timeout_seconds - (time.monotonic() - started))
|
||||
try:
|
||||
device_data = await _run_in_daemon_thread(
|
||||
lambda: _run_coroutine_blocking(controller.get_screen_data),
|
||||
remaining,
|
||||
"screen-data",
|
||||
)
|
||||
except TimeoutError:
|
||||
return _finish(f"UIAutomator/screen capture did not respond within {timeout_seconds:g}s")
|
||||
except _PASSTHROUGH_EXCEPTIONS:
|
||||
raise
|
||||
except BaseException as exc: # pylint: disable=broad-exception-caught
|
||||
cause = _describe_failure(exc)
|
||||
return _finish(f"Screen capture failed: {cause}", cause)
|
||||
|
||||
result["screenshot_bytes"] = _decoded_length(getattr(device_data, "base64", None))
|
||||
result["element_count"] = _count_elements(getattr(device_data, "elements", None))
|
||||
|
||||
if result["screenshot_bytes"] is None:
|
||||
return _finish("Screen capture returned no screenshot data")
|
||||
if result["screenshot_bytes"] < _MIN_PLAUSIBLE_SCREENSHOT_BYTES:
|
||||
return _finish(
|
||||
f"Screenshot capture failed (driver returned a {result['screenshot_bytes']}-byte placeholder image)"
|
||||
)
|
||||
if not result["element_count"]:
|
||||
return _finish(
|
||||
"UIAutomator hierarchy dump returned no UI elements (screenshot worked, hierarchy did not)"
|
||||
)
|
||||
return _finish(None)
|
||||
|
||||
|
||||
__all__ = ["UIAUTOMATOR_PACKAGES", "fix_for_error", "smoke_test_device"]
|
||||
@@ -15,6 +15,7 @@
|
||||
"""System Readiness & Diagnostic Orchestration Engine."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
@@ -33,6 +34,7 @@ from artemis.core.diagnostics.probes.runtime_probe import (
|
||||
from artemis.core.diagnostics.probes.toolchain_probe import ToolchainProbe
|
||||
from artemis.core.diagnostics.schema import (
|
||||
DeviceInfo,
|
||||
ProbeAction,
|
||||
ProbeCategory,
|
||||
ProbeResult,
|
||||
ProbeStatus,
|
||||
@@ -50,6 +52,13 @@ class ReadinessEngine:
|
||||
|
||||
_REPORT_CACHE_TTL_SECONDS = 2.0
|
||||
|
||||
#: Deadline for one probe inside a report. The ADB probe shells out to
|
||||
#: ``adb`` without a deadline of its own, and a wedged ADB server would
|
||||
#: otherwise hang every surface that runs the report (``artemis doctor``,
|
||||
#: the console wizard, ``mobile_diagnose``). A probe that overruns is
|
||||
#: reported as a FAIL with the recovery steps instead.
|
||||
PROBE_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
def __init__(self):
|
||||
self._probes: dict[str, BaseProbe] = {}
|
||||
self._python_probe = PythonRuntimeProbe()
|
||||
@@ -178,11 +187,22 @@ class ReadinessEngine:
|
||||
if categories is None or probe.category in categories
|
||||
]
|
||||
|
||||
# Concurrently execute probes
|
||||
results: list[ProbeResult] = await asyncio.gather(
|
||||
*[probe.probe() for probe in target_probes],
|
||||
return_exceptions=False,
|
||||
# Concurrently execute probes. A probe that raises must not take the
|
||||
# whole report down with it: it becomes a structured FAIL so the
|
||||
# remaining probes still reach the user and the verdict stays honest.
|
||||
outcomes = await asyncio.gather(
|
||||
*[
|
||||
asyncio.wait_for(probe.probe(), timeout=self.PROBE_TIMEOUT_SECONDS)
|
||||
for probe in target_probes
|
||||
],
|
||||
return_exceptions=True,
|
||||
)
|
||||
results: list[ProbeResult] = [
|
||||
outcome
|
||||
if isinstance(outcome, ProbeResult)
|
||||
else self._crashed_probe_result(probe, outcome)
|
||||
for probe, outcome in zip(target_probes, outcomes, strict=True)
|
||||
]
|
||||
|
||||
blockers = [r for r in results if r.is_blocker]
|
||||
passed_blockers = [r for r in blockers if r.status == ProbeStatus.PASS]
|
||||
@@ -211,6 +231,62 @@ class ReadinessEngine:
|
||||
timestamp=time.time(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _crashed_probe_result(probe: BaseProbe, exc: object) -> ProbeResult:
|
||||
"""Turn an exception escaping ``probe.probe()`` into a FAIL result.
|
||||
|
||||
Cancellation and other non-``Exception`` errors are re-raised: they are
|
||||
not a diagnosis of the host, they are the event loop shutting us down.
|
||||
"""
|
||||
if isinstance(exc, BaseException) and not isinstance(exc, Exception):
|
||||
raise exc
|
||||
if isinstance(exc, TimeoutError):
|
||||
logger.warning(
|
||||
f"[ReadinessEngine] Probe '{probe.probe_id}' did not finish within"
|
||||
f" {ReadinessEngine.PROBE_TIMEOUT_SECONDS:.0f}s; reporting it as FAIL."
|
||||
)
|
||||
return ProbeResult(
|
||||
id=probe.probe_id,
|
||||
category=probe.category,
|
||||
title=probe.probe_id.replace("_", " ").title(),
|
||||
status=ProbeStatus.FAIL,
|
||||
is_blocker=probe.is_blocker,
|
||||
summary="Probe timed out",
|
||||
description=(
|
||||
f"The '{probe.probe_id}' check did not finish within"
|
||||
f" {ReadinessEngine.PROBE_TIMEOUT_SECONDS:.0f}s. A hung ADB server is the"
|
||||
" usual cause: restart it and run the diagnosis again."
|
||||
),
|
||||
actions=[
|
||||
ProbeAction(
|
||||
action_type="command",
|
||||
label="Restart ADB",
|
||||
payload="adb kill-server && adb start-server",
|
||||
),
|
||||
],
|
||||
metadata={
|
||||
"exception_type": "TimeoutError",
|
||||
"timeout_seconds": ReadinessEngine.PROBE_TIMEOUT_SECONDS,
|
||||
},
|
||||
)
|
||||
logger.warning(
|
||||
f"[ReadinessEngine] Probe '{probe.probe_id}' crashed; reporting it as FAIL: "
|
||||
f"{type(exc).__name__}: {exc}"
|
||||
)
|
||||
return ProbeResult(
|
||||
id=probe.probe_id,
|
||||
category=probe.category,
|
||||
title=probe.probe_id.replace("_", " ").title(),
|
||||
status=ProbeStatus.FAIL,
|
||||
is_blocker=probe.is_blocker,
|
||||
summary="Probe crashed",
|
||||
description=(
|
||||
f"The '{probe.probe_id}' check raised {type(exc).__name__}: {exc}. "
|
||||
"This is a diagnostics bug or a host permission problem, not a device fault."
|
||||
),
|
||||
metadata={"exception_type": type(exc).__name__, "exception": str(exc)},
|
||||
)
|
||||
|
||||
async def heal_adb_keys(self, force: bool = False) -> dict[str, Any]:
|
||||
"""Auto-heal corrupted or 0-byte ADB authentication keys."""
|
||||
from artemis.core.diagnostics.adb_keys import heal_adb_keys
|
||||
|
||||
@@ -284,7 +284,7 @@ class VisionOCRProbe(BaseProbe):
|
||||
status=ProbeStatus.PASS,
|
||||
is_blocker=False,
|
||||
summary="Not Configured (Optional)",
|
||||
description="OCR_API_KEY is not set. Perception operates seamlessly in pure UI XML hierarchy mode.",
|
||||
description="OCR_API_KEY is not set. Perception uses the UI XML hierarchy without OCR.",
|
||||
metadata={"configured": False},
|
||||
actions=[
|
||||
ProbeAction(
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Integration-host probe: the process environment an IDE / MCP client launched us in.
|
||||
|
||||
The standard readiness probes answer "can Artemis drive a phone on this
|
||||
machine?". This probe answers the question that precedes it when Artemis is
|
||||
embedded in an AI coding assistant (Claude Code, Cursor, Codex, Antigravity,
|
||||
...): "was the server launched the way the installer expects, and can it
|
||||
spawn a background runner?". The failure modes it covers are the ones users
|
||||
hit mostly through MCP, rarely from the CLI or the web console:
|
||||
|
||||
* the MCP client started the server with a different interpreter than the
|
||||
project virtualenv, so the spawned runner imports a different package set;
|
||||
* the ``.env`` file the settings loader reads is not where the user put the key;
|
||||
* the traces directory is not writable from the client's sandbox;
|
||||
* the daemon port is held by another process (a very common collision with
|
||||
dev servers that also default to port 8000).
|
||||
|
||||
The probe is not registered in the default :class:`ReadinessEngine` probe set:
|
||||
the web console has its own device wizard and would only render noise. The
|
||||
``mobile_diagnose`` MCP tool and ``artemis doctor`` run it explicitly.
|
||||
|
||||
Only the unwritable-traces case blocks task execution; the result carries
|
||||
``is_blocker`` accordingly, so the WARN findings degrade the verdict without
|
||||
blocking it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
from artemis.core.diagnostics.probes.base import BaseProbe
|
||||
from artemis.core.diagnostics.schema import (
|
||||
ProbeAction,
|
||||
ProbeCategory,
|
||||
ProbeResult,
|
||||
ProbeStatus,
|
||||
)
|
||||
|
||||
|
||||
def _same_file(a: str | os.PathLike[str], b: str | os.PathLike[str]) -> bool:
|
||||
"""Compare two interpreter paths tolerantly (symlinks, case on Windows)."""
|
||||
try:
|
||||
return os.path.samefile(a, b)
|
||||
except OSError:
|
||||
return os.path.normcase(os.path.abspath(a)) == os.path.normcase(os.path.abspath(b))
|
||||
|
||||
|
||||
#: Client names accepted by ``artemis mcp --install`` (see interfaces/cli/commands/mcp.py).
|
||||
MCP_CLIENT_NAMES: tuple[str, ...] = (
|
||||
"antigravity",
|
||||
"claude",
|
||||
"cursor",
|
||||
"windsurf",
|
||||
"vscode",
|
||||
"cline",
|
||||
"roo",
|
||||
"openclaw",
|
||||
"codex",
|
||||
)
|
||||
|
||||
|
||||
def _env_has_prefix(prefix: str, *, exclude: tuple[str, ...] = ()) -> bool:
|
||||
return any(key.startswith(prefix) and key not in exclude for key in os.environ)
|
||||
|
||||
|
||||
def detect_mcp_client() -> str | None:
|
||||
"""Best-effort guess of which MCP client launched this process, from ``os.environ``.
|
||||
|
||||
Returns one of :data:`MCP_CLIENT_NAMES` or ``None`` when unsure. MCP hosts
|
||||
spawn the server with their own process environment, so the variables the
|
||||
IDE exports to its child processes leak through. Specific hosts are checked
|
||||
before the generic VS Code markers because Cursor, Windsurf, Cline and Roo
|
||||
are (or run inside) VS Code and inherit ``VSCODE_*`` / ``TERM_PROGRAM``.
|
||||
"""
|
||||
# Antigravity injects its language-server endpoint and CSRF token
|
||||
# (ANTIGRAVITY_LS_ADDRESS / ANTIGRAVITY_CSRF_TOKEN) so child processes can
|
||||
# call `agentapi`; mcp_server/notifiers/agentapi.py relies on the same pair.
|
||||
if _env_has_prefix("ANTIGRAVITY_"):
|
||||
return "antigravity"
|
||||
# Claude Code marks every child with CLAUDECODE=1 plus CLAUDE_CODE_* details
|
||||
# (entrypoint, session id, ...). Claude Desktop shares the same installer
|
||||
# target, so both map to "claude".
|
||||
if os.environ.get("CLAUDECODE") == "1" or _env_has_prefix("CLAUDE_CODE_"):
|
||||
return "claude"
|
||||
# Cursor tags its integrated terminal and spawned processes with
|
||||
# CURSOR_TRACE_ID (and CURSOR_AGENT in agent mode).
|
||||
if "CURSOR_TRACE_ID" in os.environ or _env_has_prefix("CURSOR_"):
|
||||
return "cursor"
|
||||
# Windsurf (Codeium) exports WINDSURF_* to its child processes.
|
||||
if _env_has_prefix("WINDSURF_"):
|
||||
return "windsurf"
|
||||
# Codex CLI sets CODEX_SANDBOX* / CODEX_* for the processes it runs.
|
||||
# CODEX_HOME alone is excluded: users set it by hand to relocate the Codex
|
||||
# config directory, so on its own it does not prove Codex launched us.
|
||||
if _env_has_prefix("CODEX_", exclude=("CODEX_HOME",)):
|
||||
return "codex"
|
||||
# OPENCLAW_WEBHOOK_URL is a notifier setting users put in any host's env
|
||||
# block (see mcp_server/notifiers/webhook.py); only other OPENCLAW_* vars
|
||||
# indicate the OpenClaw runtime itself.
|
||||
if _env_has_prefix("OPENCLAW_", exclude=("OPENCLAW_WEBHOOK_URL",)):
|
||||
return "openclaw"
|
||||
# Cline / Roo Code run as VS Code extensions; their own prefixes are rare
|
||||
# but unambiguous when present, and must win over the generic VS Code check.
|
||||
if _env_has_prefix("CLINE_"):
|
||||
return "cline"
|
||||
if _env_has_prefix("ROO_"):
|
||||
return "roo"
|
||||
# Plain VS Code (Copilot agent mode) exports VSCODE_PID / VSCODE_IPC_HOOK
|
||||
# and sets TERM_PROGRAM=vscode. Weakest signal, so it is checked last.
|
||||
if (
|
||||
"VSCODE_PID" in os.environ
|
||||
or "VSCODE_IPC_HOOK" in os.environ
|
||||
or os.environ.get("TERM_PROGRAM") == "vscode"
|
||||
):
|
||||
return "vscode"
|
||||
return None
|
||||
|
||||
|
||||
def mcp_install_actions(client: str | None) -> list[ProbeAction]:
|
||||
"""Actions that regenerate the MCP client config for ``client`` (or ask which one)."""
|
||||
if client:
|
||||
return [
|
||||
ProbeAction(
|
||||
action_type="command",
|
||||
label="Regenerate MCP Config",
|
||||
payload=f"uv run artemis mcp --install {client}",
|
||||
)
|
||||
]
|
||||
return [
|
||||
ProbeAction(
|
||||
action_type="hint",
|
||||
label="Regenerate MCP Config",
|
||||
payload=(
|
||||
"Could not tell which MCP client launched this server. Ask the user which IDE"
|
||||
" they are using, then run `uv run artemis mcp --install <client>` with"
|
||||
" <client> being one of: " + ", ".join(MCP_CLIENT_NAMES) + "."
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def project_venv_python(project_root: Path) -> Path:
|
||||
"""Return the interpreter path the installer writes into MCP client configs."""
|
||||
if sys.platform == "win32":
|
||||
return project_root / ".venv" / "Scripts" / "python.exe"
|
||||
return project_root / ".venv" / "bin" / "python"
|
||||
|
||||
|
||||
def directory_is_writable(path: Path) -> tuple[bool, str | None]:
|
||||
"""Try to create and delete a scratch file; return (ok, error)."""
|
||||
try:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=path, prefix=".artemis-diag-", delete=True):
|
||||
pass
|
||||
return True, None
|
||||
except OSError as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def intended_traces_path() -> Path:
|
||||
"""Return the traces directory the settings loader *would* pick, without creating it.
|
||||
|
||||
Mirrors the selection order of :func:`artemis.config.paths.get_default_traces_path`
|
||||
(``ARTEMIS_TRACES_DIR`` env, else the user app dir when the install mode
|
||||
keeps mutable state there, else ``ROOT_DIR/traces``) minus its ``mkdir``.
|
||||
Used only when that function raised, so the probe can still name the
|
||||
offending directory in its FAIL result instead of crashing.
|
||||
"""
|
||||
from artemis.config import paths as artemis_paths
|
||||
|
||||
env_traces = os.getenv(artemis_paths.ENV_ARTEMIS_TRACES_DIR)
|
||||
if env_traces:
|
||||
return Path(env_traces)
|
||||
if artemis_paths._use_user_app_dir():
|
||||
return artemis_paths.get_app_dir() / "traces"
|
||||
return Path(artemis_paths.ROOT_DIR) / "traces"
|
||||
|
||||
|
||||
def resolve_traces_dir() -> tuple[Path, str | None]:
|
||||
"""Return ``(traces_dir, error)``; ``error`` is set when the directory could not be created.
|
||||
|
||||
``get_default_traces_path`` creates the directory as a side effect, so on a
|
||||
read-only parent it raises before :func:`directory_is_writable` ever runs.
|
||||
Surface that as data rather than letting the probe itself crash.
|
||||
"""
|
||||
from artemis.config.paths import get_default_traces_path
|
||||
|
||||
try:
|
||||
return Path(get_default_traces_path()), None
|
||||
except OSError as exc:
|
||||
return intended_traces_path(), str(exc)
|
||||
|
||||
|
||||
class IntegrationHostProbe(BaseProbe):
|
||||
"""Probe verifying the MCP / IDE host process can actually run Artemis tasks."""
|
||||
|
||||
@property
|
||||
def probe_id(self) -> str:
|
||||
return "integration_host"
|
||||
|
||||
@property
|
||||
def category(self) -> ProbeCategory:
|
||||
return ProbeCategory.RUNTIME
|
||||
|
||||
@property
|
||||
def is_blocker(self) -> bool:
|
||||
return True
|
||||
|
||||
async def probe(self) -> ProbeResult:
|
||||
# Lazy imports keep this module importable in stripped test environments
|
||||
# and avoid a core -> runtime import at package import time.
|
||||
from artemis.config.paths import ROOT_DIR, get_env_file, is_source_checkout
|
||||
from artemis.runtime.daemon_client import (
|
||||
DEFAULT_DAEMON_HOST,
|
||||
DEFAULT_DAEMON_PORT,
|
||||
daemon_log_path,
|
||||
is_artemis_daemon,
|
||||
is_standalone_forced,
|
||||
)
|
||||
from artemis.runtime.server_lifecycle import is_port_in_use
|
||||
|
||||
project_root = Path(ROOT_DIR)
|
||||
source_checkout = is_source_checkout()
|
||||
server_python = Path(sys.executable)
|
||||
# A project virtualenv is only meaningful for a source checkout. In a
|
||||
# wheel install ROOT_DIR is site-packages, which never carries a .venv;
|
||||
# the current interpreter also runs background tasks.
|
||||
venv_python: Path | None = project_venv_python(project_root) if source_checkout else None
|
||||
venv_exists = venv_python is not None and venv_python.is_file()
|
||||
runner_python = venv_python if venv_exists else server_python
|
||||
interpreter_matches_venv = venv_exists and _same_file(server_python, venv_python)
|
||||
mcp_client = detect_mcp_client()
|
||||
|
||||
env_file = Path(get_env_file())
|
||||
traces_dir, traces_error = resolve_traces_dir()
|
||||
if traces_error is None:
|
||||
traces_writable, traces_error = directory_is_writable(traces_dir)
|
||||
else:
|
||||
traces_writable = False
|
||||
|
||||
standalone = is_standalone_forced()
|
||||
# Identity, not liveness: a dev server squatting on the port answers
|
||||
# HTTP too, and must show up as a hijacked port, not as the daemon.
|
||||
daemon_reachable = False if standalone else is_artemis_daemon()
|
||||
port_in_use = is_port_in_use(DEFAULT_DAEMON_PORT, DEFAULT_DAEMON_HOST)
|
||||
port_hijacked = port_in_use and not daemon_reachable and not standalone
|
||||
|
||||
metadata: dict[str, Any] = {
|
||||
"os": sys.platform,
|
||||
"mcp_client": mcp_client,
|
||||
"project_root": str(project_root),
|
||||
"is_source_checkout": source_checkout,
|
||||
"server_python": str(server_python),
|
||||
"venv_python": str(venv_python) if venv_python is not None else None,
|
||||
"venv_exists": venv_exists,
|
||||
"interpreter_matches_venv": interpreter_matches_venv,
|
||||
"runner_python": str(runner_python),
|
||||
"env_file": str(env_file),
|
||||
"env_file_exists": env_file.is_file(),
|
||||
"traces_dir": str(traces_dir),
|
||||
"traces_dir_writable": traces_writable,
|
||||
"traces_dir_error": traces_error,
|
||||
"daemon": {
|
||||
"host": DEFAULT_DAEMON_HOST,
|
||||
"port": DEFAULT_DAEMON_PORT,
|
||||
"standalone_forced": standalone,
|
||||
"reachable": daemon_reachable,
|
||||
"port_in_use": port_in_use,
|
||||
"port_held_by_other_process": port_hijacked,
|
||||
"log_path": str(daemon_log_path()),
|
||||
},
|
||||
}
|
||||
|
||||
problems: list[str] = []
|
||||
actions: list[ProbeAction] = []
|
||||
status = ProbeStatus.PASS
|
||||
|
||||
if not traces_writable:
|
||||
status = ProbeStatus.FAIL
|
||||
problems.append(f"traces directory {traces_dir} is not writable ({traces_error})")
|
||||
actions.append(
|
||||
ProbeAction(
|
||||
action_type="hint",
|
||||
label="Fix Traces Directory",
|
||||
payload=(
|
||||
f"Make {traces_dir} writable for the user running the MCP server, or point "
|
||||
"ARTEMIS_TRACES_DIR at a writable directory in the MCP server's env block, "
|
||||
"then restart the MCP server."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if port_hijacked:
|
||||
status = ProbeStatus.WARN if status is ProbeStatus.PASS else status
|
||||
problems.append(
|
||||
f"port {DEFAULT_DAEMON_PORT} is held by a process that is not the Artemis daemon"
|
||||
)
|
||||
actions.append(
|
||||
ProbeAction(
|
||||
action_type="hint",
|
||||
label="Free the Daemon Port",
|
||||
payload=(
|
||||
f"Another program is listening on {DEFAULT_DAEMON_HOST}:{DEFAULT_DAEMON_PORT}"
|
||||
" (often a dev server). Stop it, or set ARTEMIS_DAEMON_PORT to a free port in"
|
||||
" the MCP server's env block and restart the MCP server. Tasks still run in"
|
||||
" standalone mode meanwhile, without the shared scheduler."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if not source_checkout:
|
||||
# Wheel / frozen install: no virtualenv expectation, nothing to warn about.
|
||||
pass
|
||||
elif venv_exists and not interpreter_matches_venv:
|
||||
status = ProbeStatus.WARN if status is ProbeStatus.PASS else status
|
||||
problems.append(
|
||||
f"MCP server runs on {server_python}, not the project virtualenv {venv_python}"
|
||||
)
|
||||
actions.extend(mcp_install_actions(mcp_client))
|
||||
actions.append(
|
||||
ProbeAction(
|
||||
action_type="hint",
|
||||
label="Interpreter Mismatch",
|
||||
payload=(
|
||||
"The MCP client config should point `command` at the .venv interpreter so the"
|
||||
" server and the background runner share one package set. Restart the MCP"
|
||||
" server after changing the config."
|
||||
),
|
||||
)
|
||||
)
|
||||
elif not venv_exists:
|
||||
status = ProbeStatus.WARN if status is ProbeStatus.PASS else status
|
||||
problems.append(
|
||||
"no project virtualenv (.venv) found; runner falls back to the server interpreter"
|
||||
)
|
||||
actions.append(
|
||||
ProbeAction(
|
||||
action_type="command",
|
||||
label="Create Virtualenv",
|
||||
payload="uv sync",
|
||||
)
|
||||
)
|
||||
|
||||
if not env_file.is_file():
|
||||
actions.append(
|
||||
ProbeAction(
|
||||
action_type="hint",
|
||||
label="Env File Location",
|
||||
payload=(
|
||||
f"No .env file at {env_file}. API keys are read from that file or from the MCP"
|
||||
" server's env block; the file is created by `artemis init`."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if not daemon_reachable and not port_hijacked:
|
||||
actions.append(
|
||||
ProbeAction(
|
||||
action_type="hint",
|
||||
label="Daemon Auto-Start",
|
||||
payload=(
|
||||
"Standalone mode is forced (ARTEMIS_STANDALONE=1); tasks run as detached"
|
||||
" runner processes."
|
||||
if standalone
|
||||
else "The Artemis daemon is not running; mobile_run_task starts it on the"
|
||||
f" first task. Its log is {daemon_log_path()}."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if status is ProbeStatus.PASS:
|
||||
summary = "Host Ready"
|
||||
description = (
|
||||
f"MCP server runs on {server_python}; runner uses {runner_python}. "
|
||||
f"Traces at {traces_dir}. "
|
||||
+ (
|
||||
"Daemon reachable."
|
||||
if daemon_reachable
|
||||
else "Daemon will auto-start on the first task."
|
||||
)
|
||||
)
|
||||
actions.insert(
|
||||
0,
|
||||
ProbeAction(
|
||||
action_type="hint",
|
||||
label="Host OK",
|
||||
payload="Interpreter, traces directory and daemon port look consistent.",
|
||||
),
|
||||
)
|
||||
else:
|
||||
summary = "Host Misconfigured" if status is ProbeStatus.FAIL else "Host Warning"
|
||||
joined = "; ".join(problems)
|
||||
description = joined[:1].upper() + joined[1:] + "."
|
||||
|
||||
# Only an unwritable traces directory stops tasks from running. The
|
||||
# WARN findings (interpreter mismatch, missing venv, squatted port) are
|
||||
# degradations the runner works around, so they must not turn the
|
||||
# verdict into "blocked" — a pip/conda checkout without .venv would
|
||||
# otherwise never leave that state.
|
||||
return ProbeResult(
|
||||
id=self.probe_id,
|
||||
category=self.category,
|
||||
title="MCP / IDE Integration Host",
|
||||
status=status,
|
||||
is_blocker=status is not ProbeStatus.WARN,
|
||||
summary=summary,
|
||||
description=description,
|
||||
metadata=metadata,
|
||||
actions=actions,
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Readiness collection and verdict shared by every diagnosis entry point.
|
||||
|
||||
``artemis doctor`` and the ``mobile_diagnose`` MCP tool must agree on which
|
||||
probes run, in which order they are fixed, and what "ready" means. This module
|
||||
holds exactly that common core; each entry point layers its own extra
|
||||
conditions, fix policy and rendering on top.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal
|
||||
|
||||
from artemis.core.diagnostics.engine import readiness_engine
|
||||
from artemis.core.diagnostics.probes.host_probe import IntegrationHostProbe
|
||||
from artemis.core.diagnostics.schema import ProbeResult, ProbeStatus, SystemReadinessReport
|
||||
|
||||
Verdict = Literal["ready", "degraded", "blocked"]
|
||||
|
||||
#: Fix order. Runtime problems mask everything after them, credentials mask
|
||||
#: device problems (a task cannot start without a model), and the toolchain is
|
||||
#: optional.
|
||||
CHECK_ORDER: dict[str, int] = {
|
||||
"python_runtime": 0,
|
||||
"system_config": 1,
|
||||
"integration_host": 2,
|
||||
"gemini_api_key": 3,
|
||||
"android_adb": 4,
|
||||
"toolchain": 5,
|
||||
"vision_ocr_key": 6,
|
||||
}
|
||||
|
||||
|
||||
def sort_by_fix_order(results: Sequence[ProbeResult]) -> list[ProbeResult]:
|
||||
"""Order probe results so the first problem listed is the one to fix first."""
|
||||
return sorted(results, key=lambda r: CHECK_ORDER.get(r.id, len(CHECK_ORDER)))
|
||||
|
||||
|
||||
async def collect_readiness() -> tuple[SystemReadinessReport, ProbeResult]:
|
||||
"""Run the engine probes and the integration-host probe concurrently."""
|
||||
report, host = await asyncio.gather(
|
||||
readiness_engine.run_all(force_refresh=True),
|
||||
IntegrationHostProbe().probe(),
|
||||
)
|
||||
return report, host
|
||||
|
||||
|
||||
def base_verdict(results: Sequence[ProbeResult]) -> Verdict:
|
||||
"""Verdict from probe results alone.
|
||||
|
||||
Blocked when there is no blocker at all (nothing was checked) or any
|
||||
blocker is not PASS; degraded when only optional checks fail; ready
|
||||
otherwise.
|
||||
"""
|
||||
blockers = [r for r in results if r.is_blocker]
|
||||
if not blockers or any(r.status is not ProbeStatus.PASS for r in blockers):
|
||||
return "blocked"
|
||||
if any(r.status is not ProbeStatus.PASS for r in results):
|
||||
return "degraded"
|
||||
return "ready"
|
||||
|
||||
|
||||
def adb_keys_corrupted(results: Sequence[ProbeResult]) -> bool:
|
||||
"""Whether the ADB probe reported corrupted RSA keys (the one self-heal both entry points share)."""
|
||||
adb = next((r for r in results if r.id == "android_adb"), None)
|
||||
if adb is None:
|
||||
return False
|
||||
return bool((adb.metadata.get("adb_keys") or {}).get("is_corrupted"))
|
||||
Reference in new issue
Block a user