feat: introduce MCP server, cross-platform runtime, and Showcase UI reorganization

- Showcase UI Reorganization:
  * Move web UI dashboard from root directory into apps/showcase_ui/ for cleaner project structure
  * Add 'artemis ui' CLI command (artemis/interfaces/cli/commands/ui.py) and launcher scripts for the interactive showcase console
- Standalone MCP Server & Notifiers (mcp_server/):
  * Implement dedicated MCP server package with mobile automation tools (device_state, inspect_trace, task_manager, task_runner)
  * Add multi-channel notifiers (AgentAPI, Desktop notifications, Webhooks, File output, and Composite routing)
  * Introduce background task runner and trace store for autonomous mobile execution monitoring
- Cross-Platform Runtime & Toolchain Support:
  * Add artemis/platform/ with OS-specific implementations for Linux, macOS (Darwin), and Windows
  * Add artemis/runtime/supervisor.py for resilient process and agent runtime supervision
  * Add artemis/toolchain/ with dependency resolution and SDK descriptors
  * Add cross-platform launcher scripts (start.sh, start.bat) and installer scripts (scripts/install_deps.*)
- Core Enhancements & Diagnostics:
  * Enhance coordinate normalization, OCR API fallback, and perception pipeline
  * Add credential validation (credentials_validator.py) and system diagnostics
  * Update bilingual documentation (README.md, README_CN.md) and architecture diagrams
- Test Suite Expansion:
  * Add integration tests for cloud gateway, remote ADB, and Artemis login
  * Add unit tests covering core diagnostics, platform detection, toolchain, credentials validator, optional OCR, and MCP tools
This commit is contained in:
somew1nd committed 2026-08-18 13:59:27 -07:00
1 parent 78e478f502
commit 9a5aaf99b7
249 files changed
+37226 -9339

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
# 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.
"""Artemis Diagnostics & System Readiness Package."""
from artemis.core.diagnostics.emulator_manager import (
EmulatorLaunchStage,
EmulatorLaunchState,
emulator_manager,
)
from artemis.core.diagnostics.engine import ReadinessEngine, readiness_engine
from artemis.core.diagnostics.probes.adb_probe import AdbDeviceProbe
from artemis.core.diagnostics.probes.base import BaseProbe
from artemis.core.diagnostics.probes.credentials_probe import (
LLMCredentialsProbe,
VisionOCRProbe,
)
from artemis.core.diagnostics.probes.runtime_probe import (
PythonRuntimeProbe,
SystemConfigProbe,
)
from artemis.core.diagnostics.probes.toolchain_probe import ToolchainProbe
from artemis.core.diagnostics.schema import (
DeviceInfo,
ProbeAction,
ProbeCategory,
ProbeResult,
ProbeStatus,
SystemReadinessReport,
)
__all__ = [
"ReadinessEngine",
"readiness_engine",
"emulator_manager",
"EmulatorLaunchStage",
"EmulatorLaunchState",
"BaseProbe",
"PythonRuntimeProbe",
"SystemConfigProbe",
"AdbDeviceProbe",
"LLMCredentialsProbe",
"VisionOCRProbe",
"ToolchainProbe",
"DeviceInfo",
"ProbeAction",
"ProbeCategory",
"ProbeResult",
"ProbeStatus",
"SystemReadinessReport",
]
@@ -0,0 +1,480 @@
# 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.
"""Android Emulator Launch & Boot Progress Lifecycle Manager."""
import asyncio
from collections import deque
from enum import Enum
import os
from pathlib import Path
import shutil
import subprocess
import threading
import time
from typing import Any
from pydantic import BaseModel, Field
from artemis.platform import OSType, platform
from artemis.toolchain import toolchain
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
class EmulatorLaunchStage(str, Enum):
"""Lifecycle stages of launching an Android Virtual Device."""
IDLE = "idle"
STARTING = "starting" # Process spawned, verifying PID
WAITING_FOR_ADB = "waiting_for_adb" # Process alive, waiting for emulator to appear in ADB
BOOTING = "booting" # Visible in ADB, waiting for sys.boot_completed=1
READY = "ready" # Boot complete and ready for automation
FAILED = "failed" # Process crashed, lock error, or timeout
STOPPED = "stopped" # Stopped by user
class EmulatorLaunchState(BaseModel):
"""State schema for real-time emulator launch tracking."""
avd_name: str | None = None
status: EmulatorLaunchStage = EmulatorLaunchStage.IDLE
pid: int | None = None
serial: str | None = None
stage_message: str = "Ready to launch"
progress_percent: int = 0
started_at: float | None = None
elapsed_seconds: int = 0
error: str | None = None
logs: list[str] = Field(default_factory=list)
can_retry: bool = True
class EmulatorManager:
"""Manages background emulator execution, stdout/stderr stream capture, and boot completion polling."""
def __init__(self):
self._current_state = EmulatorLaunchState()
self._proc: subprocess.Popen | None = None
self._log_buffer: deque[str] = deque(maxlen=100)
self._track_task: asyncio.Task | None = None
self._lock = asyncio.Lock()
self._reader_thread: threading.Thread | None = None
def _locate_emulator(self) -> str | None:
"""Find the emulator binary path from PATH or standard SDK environments."""
resolved = toolchain.resolve("emulator")
if resolved:
return resolved
emu_path = shutil.which("emulator")
if emu_path:
return emu_path
sdk_candidates = [
os.environ.get("ANDROID_HOME"),
os.environ.get("ANDROID_SDK_ROOT"),
str(Path.home() / "Android" / "Sdk"),
str(Path.home() / "Android" / "sdk"),
str(Path.home() / "Library" / "Android" / "sdk"),
os.getenv("LOCALAPPDATA", "") + "/Android/Sdk" if os.getenv("LOCALAPPDATA") else None,
"/usr/lib/android-sdk",
"/opt/android-sdk",
]
for base in sdk_candidates:
if base:
cand = (
Path(base)
/ "emulator"
/ ("emulator.exe" if platform.os_type == OSType.WINDOWS else "emulator")
)
if cand.is_file():
return str(cand)
return None
def _locate_adb(self) -> str:
"""Find the adb binary path."""
resolved = toolchain.resolve("adb")
if resolved:
return resolved
return shutil.which("adb") or "adb"
def get_status(self) -> EmulatorLaunchState:
"""Return current launch progress state snapshot."""
state = self._current_state.model_copy()
if state.started_at and state.status in (
EmulatorLaunchStage.STARTING,
EmulatorLaunchStage.WAITING_FOR_ADB,
EmulatorLaunchStage.BOOTING,
):
state.elapsed_seconds = int(time.time() - state.started_at)
state.logs = list(self._log_buffer)
return state
def _stream_logs(self, proc: subprocess.Popen):
"""Continuously read process stdout/stderr in a background thread."""
try:
if proc.stdout:
for line in iter(proc.stdout.readline, ""):
if not line:
break
clean_line = line.strip()
if clean_line:
self._log_buffer.append(clean_line)
except Exception as e:
logger.debug(f"Log streaming finished: {e}")
finally:
if proc.stdout:
proc.stdout.close()
async def launch(self, avd_name: str) -> EmulatorLaunchState:
"""Initiate background emulator launch and start asynchronous boot monitoring."""
clean_avd = avd_name.strip()
if not clean_avd:
self._current_state = EmulatorLaunchState(
status=EmulatorLaunchStage.FAILED,
error="AVD name cannot be empty.",
stage_message="Launch failed: AVD name cannot be empty.",
)
return self.get_status()
async with self._lock:
# If already launching the same AVD and process is alive
if (
self._current_state.status
in (
EmulatorLaunchStage.STARTING,
EmulatorLaunchStage.WAITING_FOR_ADB,
EmulatorLaunchStage.BOOTING,
)
and self._current_state.avd_name == clean_avd
and self._proc is not None
and self._proc.poll() is None
):
logger.info(f"AVD '{clean_avd}' is already in progress of launching.")
return self.get_status()
emu_path = self._locate_emulator()
if not emu_path:
self._current_state = EmulatorLaunchState(
avd_name=clean_avd,
status=EmulatorLaunchStage.FAILED,
error="Android emulator executable not found in PATH or Android SDK. Please install Android SDK platform tools and emulator.",
stage_message="Emulator binary not found.",
)
return self.get_status()
self._log_buffer.clear()
self._log_buffer.append(f"Starting emulator '{clean_avd}' using binary: {emu_path}")
try:
# Cancel previous tracker if any
if self._track_task and not self._track_task.done():
self._track_task.cancel()
# Spawn emulator process capturing stdout & stderr
proc = subprocess.Popen(
[emu_path, "-avd", clean_avd],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
start_new_session=True,
)
self._proc = proc
now = time.time()
# Start reader thread for stdout/stderr
self._reader_thread = threading.Thread(
target=self._stream_logs, args=(proc,), daemon=True
)
self._reader_thread.start()
self._current_state = EmulatorLaunchState(
avd_name=clean_avd,
status=EmulatorLaunchStage.STARTING,
pid=proc.pid,
stage_message=f"Process spawned (PID: {proc.pid}). Initializing QEMU virtualization...",
progress_percent=15,
started_at=now,
elapsed_seconds=0,
error=None,
logs=list(self._log_buffer),
)
# Launch async boot tracker task
self._track_task = asyncio.create_task(
self._track_boot_lifecycle(clean_avd, proc, now)
)
return self.get_status()
except Exception as e:
logger.error(f"Failed to spawn emulator process: {e}")
self._current_state = EmulatorLaunchState(
avd_name=clean_avd,
status=EmulatorLaunchStage.FAILED,
error=f"Failed to spawn emulator process: {e}",
stage_message="Failed to spawn emulator process.",
logs=list(self._log_buffer),
)
return self.get_status()
async def _track_boot_lifecycle(self, avd_name: str, proc: subprocess.Popen, started_at: float):
"""Monitor emulator lifecycle from process execution to ADB connection and OS boot completion."""
adb_path = self._locate_adb()
detected_serial: str | None = None
max_wait_seconds = 180 # 3 minutes maximum boot timeout
try:
# Phase 1: Early crash detection (first 5 seconds)
for _ in range(5):
await asyncio.sleep(1)
poll_res = proc.poll()
if poll_res is not None:
logs_str = "\n".join(list(self._log_buffer)[-10:])
error_msg = f"Emulator process exited immediately with code {poll_res}."
if "already running" in logs_str.lower() or "lock" in logs_str.lower():
error_msg = f"AVD '{avd_name}' is locked by another running emulator instance. Try stopping existing instances or restart ADB."
elif "panic" in logs_str.lower():
error_msg = f"Emulator panic: {logs_str}"
logger.error(f"[EmulatorManager] Early crash: {error_msg}")
self._current_state = EmulatorLaunchState(
avd_name=avd_name,
status=EmulatorLaunchStage.FAILED,
pid=proc.pid,
error=error_msg,
stage_message=f"Process exited prematurely (Exit code {poll_res})",
started_at=started_at,
elapsed_seconds=int(time.time() - started_at),
logs=list(self._log_buffer),
)
return
# Phase 2: Waiting for ADB handshake
self._current_state.status = EmulatorLaunchStage.WAITING_FOR_ADB
self._current_state.progress_percent = 35
self._current_state.stage_message = (
"QEMU hypervisor started. Waiting for ADB connection..."
)
while time.time() - started_at < max_wait_seconds:
await asyncio.sleep(1.5)
# Check if process died
poll_res = proc.poll()
if poll_res is not None:
logs_str = "\n".join(list(self._log_buffer)[-10:])
self._current_state = EmulatorLaunchState(
avd_name=avd_name,
status=EmulatorLaunchStage.FAILED,
pid=proc.pid,
error=f"Emulator process terminated unexpectedly (exit code {poll_res}): {logs_str}",
stage_message="Process terminated unexpectedly",
started_at=started_at,
elapsed_seconds=int(time.time() - started_at),
logs=list(self._log_buffer),
)
return
# Check adb devices for emulator serial
try:
p = await asyncio.create_subprocess_exec(
adb_path,
"devices",
"-l",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, _ = await p.communicate()
out_text = stdout.decode(errors="replace")
# Scan for emulator-*
for line in out_text.splitlines():
line = line.strip()
if line.startswith("emulator-"):
parts = line.split()
if parts:
detected_serial = parts[0]
break
except Exception as e:
logger.debug(f"ADB query error during boot tracking: {e}")
if detected_serial:
break
if not detected_serial:
self._current_state = EmulatorLaunchState(
avd_name=avd_name,
status=EmulatorLaunchStage.FAILED,
pid=proc.pid,
error="Timeout waiting for emulator to connect to ADB.",
stage_message="ADB handshake timed out.",
started_at=started_at,
elapsed_seconds=int(time.time() - started_at),
logs=list(self._log_buffer),
)
return
# Phase 3: Android OS Booting (Polling sys.boot_completed)
self._current_state.status = EmulatorLaunchStage.BOOTING
self._current_state.serial = detected_serial
self._current_state.progress_percent = 65
self._current_state.stage_message = (
f"Connected to ADB ({detected_serial}). Android OS is booting up..."
)
while time.time() - started_at < max_wait_seconds:
await asyncio.sleep(2)
# Check if process died
poll_res = proc.poll()
if poll_res is not None:
self._current_state = EmulatorLaunchState(
avd_name=avd_name,
status=EmulatorLaunchStage.FAILED,
pid=proc.pid,
error=f"Emulator process crashed during OS boot (exit code {poll_res}).",
stage_message="Process crashed during OS boot",
started_at=started_at,
elapsed_seconds=int(time.time() - started_at),
logs=list(self._log_buffer),
)
return
# Check sys.boot_completed
try:
p = await asyncio.create_subprocess_exec(
adb_path,
"-s",
detected_serial,
"shell",
"getprop sys.boot_completed",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, _ = await p.communicate()
boot_completed = stdout.decode(errors="replace").strip()
if boot_completed == "1":
logger.info(
f"[EmulatorManager] Android system boot completed for {detected_serial} ({avd_name})"
)
self._current_state = EmulatorLaunchState(
avd_name=avd_name,
status=EmulatorLaunchStage.READY,
pid=proc.pid,
serial=detected_serial,
stage_message=f"Android emulator '{avd_name}' is fully booted and ready!",
progress_percent=100,
started_at=started_at,
elapsed_seconds=int(time.time() - started_at),
error=None,
logs=list(self._log_buffer),
)
try:
from artemis.core.diagnostics.engine import readiness_engine
readiness_engine.set_active_device_serial(detected_serial)
except Exception as e:
logger.debug(f"Failed to auto-select device serial: {e}")
return
else:
# Gradual progress visual indicator
elapsed = time.time() - started_at
calc_progress = min(95, int(65 + (elapsed / 60) * 30))
self._current_state.progress_percent = calc_progress
self._current_state.stage_message = f"Android OS booting ({int(elapsed)}s)... Initializing system services..."
except Exception as e:
logger.debug(f"Error querying boot_completed: {e}")
# Timeout after max_wait_seconds
self._current_state = EmulatorLaunchState(
avd_name=avd_name,
status=EmulatorLaunchStage.FAILED,
pid=proc.pid,
serial=detected_serial,
error=f"Android OS boot did not finish within {max_wait_seconds}s timeout.",
stage_message="Boot process timed out.",
started_at=started_at,
elapsed_seconds=int(time.time() - started_at),
logs=list(self._log_buffer),
)
except asyncio.CancelledError:
logger.info(f"[EmulatorManager] Boot tracker cancelled for '{avd_name}'")
except Exception as exc:
logger.error(f"[EmulatorManager] Unexpected exception in boot tracker: {exc}")
self._current_state = EmulatorLaunchState(
avd_name=avd_name,
status=EmulatorLaunchStage.FAILED,
pid=proc.pid if proc else None,
error=f"Unexpected error: {exc}",
stage_message="Unexpected error during boot tracking.",
started_at=started_at,
elapsed_seconds=int(time.time() - started_at),
logs=list(self._log_buffer),
)
async def stop(self) -> dict[str, Any]:
"""Terminate the active emulator process."""
async with self._lock:
if self._track_task and not self._track_task.done():
self._track_task.cancel()
serial = self._current_state.serial
pid = self._current_state.pid
avd = self._current_state.avd_name
if serial:
try:
adb_path = self._locate_adb()
subprocess.run(
[adb_path, "-s", serial, "emu", "kill"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
)
except Exception:
pass
if self._proc and self._proc.poll() is None:
try:
self._proc.terminate()
try:
self._proc.wait(timeout=3)
except subprocess.TimeoutExpired:
self._proc.kill()
except Exception as e:
logger.error(f"Error terminating emulator process {pid}: {e}")
self._current_state = EmulatorLaunchState(
avd_name=avd,
status=EmulatorLaunchStage.STOPPED,
stage_message="Emulator process stopped by user.",
logs=list(self._log_buffer),
)
return {"success": True, "message": f"Emulator '{avd}' stopped"}
def dismiss(self) -> dict[str, Any]:
"""Reset emulator launch state back to idle."""
self._current_state = EmulatorLaunchState()
self._log_buffer.clear()
return {"success": True, "message": "State reset"}
# Global singleton instance
emulator_manager = EmulatorManager()
+228
View File
@@ -0,0 +1,228 @@
# 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.
"""System Readiness & Diagnostic Orchestration Engine."""
import asyncio
import shutil
import subprocess
import time
from typing import Any
from artemis.core.diagnostics.probes.adb_probe import AdbDeviceProbe
from artemis.core.diagnostics.probes.base import BaseProbe
from artemis.core.diagnostics.probes.credentials_probe import (
LLMCredentialsProbe,
VisionOCRProbe,
)
from artemis.core.diagnostics.probes.runtime_probe import (
PythonRuntimeProbe,
SystemConfigProbe,
)
from artemis.core.diagnostics.probes.toolchain_probe import ToolchainProbe
from artemis.core.diagnostics.schema import (
DeviceInfo,
ProbeCategory,
ProbeResult,
ProbeStatus,
SystemReadinessReport,
)
from artemis.platform import platform
from artemis.toolchain import toolchain
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
class ReadinessEngine:
"""Central orchestration engine executing modular readiness probes."""
def __init__(self):
self._probes: dict[str, BaseProbe] = {}
self._active_device_serial: str | None = None
self._python_probe = PythonRuntimeProbe()
self._config_probe = SystemConfigProbe()
self._toolchain_probe = ToolchainProbe()
self._credentials_probe = LLMCredentialsProbe()
self._ocr_probe = VisionOCRProbe()
self._adb_probe = AdbDeviceProbe()
# Register default core probes in logical lifecycle order
self.register_probe(self._python_probe)
self.register_probe(self._config_probe)
self.register_probe(self._toolchain_probe)
self.register_probe(self._credentials_probe)
self.register_probe(self._ocr_probe)
self.register_probe(self._adb_probe)
def register_probe(self, probe: BaseProbe) -> None:
"""Register a new diagnostic probe."""
self._probes[probe.probe_id] = probe
def unregister_probe(self, probe_id: str) -> None:
"""Remove a diagnostic probe."""
self._probes.pop(probe_id, None)
def set_active_device_serial(self, serial: str | None) -> None:
"""Set user-selected active target device serial."""
self._active_device_serial = serial
if hasattr(self._adb_probe, "set_target_serial"):
self._adb_probe.set_target_serial(serial)
def get_active_device_serial(self) -> str | None:
"""Get currently selected active device serial."""
return self._active_device_serial
async def run_probe(self, probe_id: str) -> ProbeResult | None:
"""Execute a single specific probe by ID."""
probe = self._probes.get(probe_id)
if not probe:
return None
return await probe.probe()
async def run_all(self, categories: list[ProbeCategory] | None = None) -> SystemReadinessReport:
"""Concurrently run all registered diagnostic probes and compile report."""
toolchain.clear_cache()
target_probes = [
probe
for probe in self._probes.values()
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,
)
blockers = [r for r in results if r.is_blocker]
passed_blockers = [r for r in blockers if r.status == ProbeStatus.PASS]
overall_ready = len(blockers) > 0 and len(blockers) == len(passed_blockers)
# Extract active device info from ADB probe metadata if available
active_device: DeviceInfo | None = None
adb_result = next((r for r in results if r.id == "android_adb"), None)
if adb_result and adb_result.metadata.get("active_device"):
try:
active_device = DeviceInfo(**adb_result.metadata["active_device"])
except Exception:
pass
return SystemReadinessReport(
overall_ready=overall_ready,
blocker_count=len(blockers),
passed_blocker_count=len(passed_blockers),
probes=results,
active_device=active_device,
os_type=platform.os_type.value,
timestamp=time.time(),
)
async def restart_adb_server(self) -> dict[str, Any]:
"""Execute adb kill-server && adb start-server to recover connectivity."""
adb_path = shutil.which("adb") or "adb"
def _restart_sync():
try:
subprocess.run(
[adb_path, "kill-server"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
)
res = subprocess.run(
[adb_path, "start-server"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
)
success = res.returncode == 0
return {
"success": success,
"message": "ADB server restarted successfully"
if success
else "Failed to restart ADB",
"output": "",
}
except Exception as exc:
return {
"success": False,
"message": f"Error executing ADB restart: {exc}",
}
logger.info("[ReadinessEngine] Restarting ADB server...")
return await asyncio.to_thread(_restart_sync)
async def launch_emulator(self, avd_name: str) -> dict[str, Any]:
"""Launch an Android emulator by AVD name in the background and track its lifecycle."""
from artemis.core.diagnostics.emulator_manager import emulator_manager
state = await emulator_manager.launch(avd_name)
return state.model_dump()
def get_emulator_status(self) -> dict[str, Any]:
"""Query current status of background emulator launch."""
from artemis.core.diagnostics.emulator_manager import emulator_manager
return emulator_manager.get_status().model_dump()
async def stop_emulator(self) -> dict[str, Any]:
"""Stop current running emulator."""
from artemis.core.diagnostics.emulator_manager import emulator_manager
return await emulator_manager.stop()
def dismiss_emulator(self) -> dict[str, Any]:
"""Dismiss current emulator launch state."""
from artemis.core.diagnostics.emulator_manager import emulator_manager
return emulator_manager.dismiss()
async def connect_wireless_adb(self, host: str, port: int = 5555) -> dict[str, Any]:
"""Connect to an Android device over Wi-Fi via adb connect."""
clean_host = host.strip()
if not clean_host:
return {"success": False, "message": "Host IP address cannot be empty"}
target = f"{clean_host}:{port}"
adb_path = shutil.which("adb") or "adb"
def _connect_sync():
try:
res = subprocess.run(
[adb_path, "connect", target],
capture_output=True,
text=True,
timeout=8,
)
output = (res.stdout + "\n" + res.stderr).strip()
success = "connected to" in output.lower() and "failed" not in output.lower()
return {
"success": success,
"target": target,
"output": output,
"message": f"Connected to {target}" if success else output,
}
except Exception as exc:
return {
"success": False,
"target": target,
"message": f"Connection error: {exc}",
}
logger.info(f"[ReadinessEngine] Connecting to wireless ADB target '{target}'...")
return await asyncio.to_thread(_connect_sync)
# Global singleton instance
readiness_engine = ReadinessEngine()
@@ -0,0 +1,449 @@
# 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.
"""Android ADB & Device Connectivity Probe with AVD Detection."""
import asyncio
import os
from pathlib import Path
import shutil
import subprocess
from typing import Any
from artemis.core.diagnostics.probes.base import BaseProbe
from artemis.core.diagnostics.schema import (
DeviceInfo,
ProbeAction,
ProbeCategory,
ProbeResult,
ProbeStatus,
)
from artemis.platform import OSType, platform
from artemis.toolchain import toolchain
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
class AdbDeviceProbe(BaseProbe):
"""Deep inspection probe for Android Debug Bridge, connected mobile devices, and local AVD emulators."""
def __init__(self, target_serial: str | None = None):
self._target_serial = target_serial
@property
def probe_id(self) -> str:
return "android_adb"
@property
def category(self) -> ProbeCategory:
return ProbeCategory.DEVICE
@property
def is_blocker(self) -> bool:
return True
def set_target_serial(self, serial: str | None) -> None:
"""Set or update preferred active device serial."""
self._target_serial = serial
def _locate_adb(self) -> str | None:
"""Find the adb binary path from ToolchainResolver."""
return toolchain.resolve("adb", force_refresh=True)
def _locate_emulator(self) -> str | None:
"""Find the emulator binary path from PATH or standard SDK environments."""
emu_path = shutil.which("emulator")
if emu_path:
return emu_path
sdk_candidates = [
os.environ.get("ANDROID_HOME"),
os.environ.get("ANDROID_SDK_ROOT"),
str(Path.home() / "Android" / "Sdk"),
str(Path.home() / "Android" / "sdk"),
str(Path.home() / "Library" / "Android" / "sdk"),
os.getenv("LOCALAPPDATA", "") + "/Android/Sdk" if os.getenv("LOCALAPPDATA") else None,
"/usr/lib/android-sdk",
"/opt/android-sdk",
]
for base in sdk_candidates:
if base:
cand = (
Path(base)
/ "emulator"
/ ("emulator.exe" if platform.os_type == OSType.WINDOWS else "emulator")
)
if cand.is_file():
return str(cand)
return None
def _list_installed_avds(self, emulator_path: str | None = None) -> list[str]:
"""Discover all locally created Android Virtual Devices (AVDs)."""
avd_names: list[str] = []
# Method 1: Scan ~/.android/avd/*.ini
avd_dir = Path.home() / ".android" / "avd"
if avd_dir.is_dir():
for ini_file in avd_dir.glob("*.ini"):
name = ini_file.stem
if name and name not in avd_names:
avd_names.append(name)
return avd_names
async def _get_adb_version(self, adb_path: str) -> str:
"""Query ADB version string."""
try:
proc = await asyncio.create_subprocess_exec(
adb_path,
"version",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, _ = await proc.communicate()
if stdout:
first_line = stdout.decode(errors="replace").strip().splitlines()[0]
return first_line
except Exception as e:
logger.debug(f"Failed to read adb version: {e}")
return "Installed"
async def _parse_adb_devices(self, adb_path: str) -> list[DeviceInfo]:
"""Execute `adb devices -l` and extract structured device metadata."""
devices: list[DeviceInfo] = []
try:
proc = await asyncio.create_subprocess_exec(
adb_path,
"devices",
"-l",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, _ = await proc.communicate()
if proc.returncode != 0 or not stdout:
return devices
lines = [
line.strip()
for line in stdout.decode(errors="replace").splitlines()
if line.strip()
]
for line in lines[1:]: # Skip "List of devices attached"
parts = line.split()
if len(parts) < 2:
continue
serial = parts[0]
state = parts[1]
extra_tokens = parts[2:]
model = None
product = None
for token in extra_tokens:
if token.startswith("model:"):
model = token.split(":", 1)[1].replace("_", " ")
elif token.startswith("product:"):
product = token.split(":", 1)[1]
is_emulator = (
serial.startswith("emulator-") or "127.0.0.1" in serial or "localhost" in serial
)
if is_emulator and not model:
model = "Android Emulator"
device_info = DeviceInfo(
serial=serial,
state=state,
model=model,
product=product,
is_emulator=is_emulator,
)
devices.append(device_info)
except Exception as e:
logger.error(f"Error querying adb devices: {e}")
# For authorized devices, attempt non-blocking quick property enrichment
for dev in devices:
if dev.state == "device":
try:
p_prop = await asyncio.create_subprocess_exec(
adb_path,
"-s",
dev.serial,
"shell",
"getprop ro.build.version.release",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
p_size = await asyncio.create_subprocess_exec(
adb_path,
"-s",
dev.serial,
"shell",
"wm size",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out_prop, _ = await p_prop.communicate()
out_size, _ = await p_size.communicate()
if out_prop:
dev.android_version = out_prop.decode(errors="replace").strip()
if out_size:
size_str = out_size.decode(errors="replace").strip()
if "Physical size:" in size_str:
dev.screen_resolution = size_str.split("Physical size:")[-1].strip()
# Query installed application packages with timeout protection
try:
p_pkg = await asyncio.create_subprocess_exec(
adb_path,
"-s",
dev.serial,
"shell",
"pm list packages",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out_pkg, _ = await asyncio.wait_for(p_pkg.communicate(), timeout=2.5)
if out_pkg:
pkgs = [
line.split("package:", 1)[1].strip()
for line in out_pkg.decode(errors="replace").splitlines()
if line.startswith("package:")
and line.split("package:", 1)[1].strip()
]
dev.installed_packages = pkgs
except Exception as e_pkg:
logger.debug(f"Failed to query packages for {dev.serial}: {e_pkg}")
except Exception:
pass
return devices
async def probe(self) -> ProbeResult:
"""Run complete ADB, device, and local AVD connectivity probe."""
adb_path = self._locate_adb()
emulator_path = self._locate_emulator()
installed_avds = self._list_installed_avds(emulator_path)
# 1. ADB binary check
if not adb_path:
actions = []
if platform.os_type == OSType.WINDOWS:
actions.append(
ProbeAction(
action_type="command",
label="Install via WinGet",
payload="winget install Google.PlatformTools",
)
)
actions.append(
ProbeAction(
action_type="command",
label="Run PowerShell Setup",
payload="powershell -ExecutionPolicy Bypass -File scripts/install_deps.ps1",
)
)
elif platform.os_type == OSType.MACOS:
actions.append(
ProbeAction(
action_type="command",
label="Install via Homebrew",
payload="brew install android-platform-tools",
)
)
else:
actions.append(
ProbeAction(
action_type="command",
label="Linux Quick Install",
payload="sudo apt-get install -y adb",
)
)
actions.append(
ProbeAction(
action_type="command",
label="One-Click Install All",
payload="bash scripts/install_deps.sh",
)
)
actions.append(
ProbeAction(
action_type="link",
label="Download Android Platform Tools",
payload="https://developer.android.com/tools/releases/platform-tools",
)
)
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.FAIL,
is_blocker=self.is_blocker,
summary="ADB Not Found",
description="Android Debug Bridge (adb) is not installed or not in PATH.",
metadata={"installed": False},
actions=actions,
)
adb_ver = await self._get_adb_version(adb_path)
devices = await self._parse_adb_devices(adb_path)
# Normalize emulator command display
is_emu_in_path = shutil.which("emulator") is not None
emu_display_cmd = "emulator" if is_emu_in_path else (emulator_path or "emulator")
metadata: dict[str, Any] = {
"installed": True,
"adb_path": adb_path,
"adb_version": adb_ver,
"emulator_path": emulator_path,
"is_emulator_in_path": is_emu_in_path,
"installed_avds": installed_avds,
"device_count": len(devices),
"devices": [d.model_dump() for d in devices],
}
# 2. Case: No devices found
if not devices:
actions: list[ProbeAction] = []
# If user has installed AVDs, generate exact launch commands for each
if installed_avds:
for avd in installed_avds:
actions.append(
ProbeAction(
action_type="command",
label=f"Launch {avd}",
payload=f"{emu_display_cmd} -avd {avd}",
)
)
else:
actions.append(
ProbeAction(
action_type="command",
label="Start Default Emulator",
payload=f"{emu_display_cmd} -avd Pixel_8_API_34",
)
)
actions.append(
ProbeAction(
action_type="hint",
label="Connect via USB",
payload="Connect your Android phone via USB cable and enable Developer Options -> USB Debugging.",
)
)
desc = "ADB is ready, but no active Android device or emulator was detected."
if installed_avds:
desc += f" Found {len(installed_avds)} installed emulator(s): {', '.join(installed_avds)}."
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.WARN,
is_blocker=self.is_blocker,
summary="No Device Found",
description=desc,
metadata=metadata,
actions=actions,
)
# 3. Check for authorized and ready devices
ready_devices = [d for d in devices if d.state == "device"]
unauthorized_devices = [d for d in devices if d.state == "unauthorized"]
if not ready_devices:
if unauthorized_devices:
unauth_serial = unauthorized_devices[0].serial
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.WARN,
is_blocker=self.is_blocker,
summary="Device Unauthorized",
description=f"Device detected ({unauth_serial}), but USB debugging is not yet authorized.",
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Authorize USB Debugging",
payload="Please unlock your Android phone and tap 'Allow' on the USB Debugging permission prompt.",
),
ProbeAction(
action_type="command",
label="Restart ADB Server",
payload=f"{adb_path} kill-server && {adb_path} start-server",
),
],
)
else:
first_serial = devices[0].serial
first_state = devices[0].state
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.WARN,
is_blocker=self.is_blocker,
summary="Device Booting",
description=f"Device detected ({first_serial}, state: {first_state}). It is currently booting up or offline. Please wait a few seconds...",
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Device Booting",
payload=f"Device {first_serial} is initializing. It will be ready automatically in a few seconds.",
)
],
)
# 4. Ready device available
active_dev = ready_devices[0]
if self._target_serial:
matched = next((d for d in ready_devices if d.serial == self._target_serial), None)
if matched:
active_dev = matched
display_name = active_dev.model or active_dev.serial
if active_dev.screen_resolution:
display_name = f"{display_name} ({active_dev.screen_resolution})"
metadata["active_device"] = active_dev.model_dump()
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.PASS,
is_blocker=self.is_blocker,
summary="Connected",
description=f"ADB connected to {display_name}. Ready for perception and touch automation.",
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Device Ready",
payload=f"Serial: {active_dev.serial} | Android OS: {active_dev.android_version or 'Unknown'}",
)
],
)
+45
View File
@@ -0,0 +1,45 @@
# 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.
"""Abstract Base Probe for Artemis System Diagnostics."""
from abc import ABC, abstractmethod
from artemis.core.diagnostics.schema import ProbeCategory, ProbeResult
class BaseProbe(ABC):
"""Abstract interface representing a system readiness probe."""
@property
@abstractmethod
def probe_id(self) -> str:
"""Unique identifier for this probe."""
pass
@property
@abstractmethod
def category(self) -> ProbeCategory:
"""Category to which this probe belongs."""
pass
@property
@abstractmethod
def is_blocker(self) -> bool:
"""Whether a failure in this probe should block autonomous task execution."""
pass
@abstractmethod
async def probe(self) -> ProbeResult:
"""Execute the probe inspection and return a structured ProbeResult."""
pass
@@ -0,0 +1,296 @@
# 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.
"""LLM & Multimodal Vision Credentials Readiness Probe."""
from typing import Any
from artemis.config import settings
from artemis.core.diagnostics.probes.base import BaseProbe
from artemis.core.diagnostics.schema import (
ProbeAction,
ProbeCategory,
ProbeResult,
ProbeStatus,
)
class LLMCredentialsProbe(BaseProbe):
"""Probe verifying Gemini and multi-provider multimodal LLM credentials."""
@property
def probe_id(self) -> str:
return "gemini_api_key"
@property
def category(self) -> ProbeCategory:
return ProbeCategory.CREDENTIALS
@property
def is_blocker(self) -> bool:
return True
def _mask_key(self, key_str: str) -> str:
"""Helper to safely mask an API credential for display."""
if len(key_str) > 10:
return f"{key_str[:6]}...{key_str[-4:]}"
return "***"
async def probe(self) -> ProbeResult:
import os
from artemis.config.settings import is_placeholder_key
gemini_key = settings.get_api_key("google")
openai_key = settings.get_api_key("openai")
claude_key = settings.get_api_key("anthropic")
openrouter_key = settings.get_api_key("openrouter")
xai_key = settings.get_api_key("xai")
ocr_key = settings.get_api_key("ocr")
configured_providers: list[dict[str, Any]] = []
api_keys_map: dict[str, str] = {}
if gemini_key and not is_placeholder_key(gemini_key):
g_val = gemini_key.get_secret_value()
configured_providers.append(
{
"provider": "google",
"label": "Gemini",
"masked": self._mask_key(g_val),
"raw_key": g_val,
"key": g_val,
}
)
api_keys_map["google"] = g_val
api_keys_map["gemini"] = g_val
if openai_key and not is_placeholder_key(openai_key):
o_val = openai_key.get_secret_value()
configured_providers.append(
{
"provider": "openai",
"label": "ChatGPT",
"masked": self._mask_key(o_val),
"raw_key": o_val,
"key": o_val,
}
)
api_keys_map["openai"] = o_val
if claude_key and not is_placeholder_key(claude_key):
c_val = claude_key.get_secret_value()
configured_providers.append(
{
"provider": "anthropic",
"label": "Claude",
"masked": self._mask_key(c_val),
"raw_key": c_val,
"key": c_val,
}
)
api_keys_map["anthropic"] = c_val
if openrouter_key and not is_placeholder_key(openrouter_key):
or_val = openrouter_key.get_secret_value()
configured_providers.append(
{
"provider": "openrouter",
"label": "OpenRouter",
"masked": self._mask_key(or_val),
"raw_key": or_val,
"key": or_val,
}
)
api_keys_map["openrouter"] = or_val
if xai_key and not is_placeholder_key(xai_key):
x_val = xai_key.get_secret_value()
configured_providers.append(
{
"provider": "xai",
"label": "xAI (Grok)",
"masked": self._mask_key(x_val),
"raw_key": x_val,
"key": x_val,
}
)
api_keys_map["xai"] = x_val
# Detect any custom model endpoints or environment variables defined in files
for env_var, label, prov_id in [
("DEEPSEEK_API_KEY", "DeepSeek", "deepseek"),
("GROQ_API_KEY", "Groq", "groq"),
("OPENAI_BASE_URL", "Custom OpenAI Endpoint", "custom"),
("OLLAMA_BASE_URL", "Local Ollama", "ollama"),
("VLLM_BASE_URL", "vLLM Endpoint", "vllm"),
("VERTEX_AI_PROJECT", "Google Cloud Vertex AI", "vertexai"),
]:
val = os.environ.get(env_var)
if val and val.strip() and not is_placeholder_key(val.strip()):
configured_providers.append(
{
"provider": prov_id,
"label": label,
"masked": self._mask_key(val.strip()),
"raw_key": val.strip(),
"key": val.strip(),
}
)
api_keys_map[prov_id] = val.strip()
if ocr_key and not is_placeholder_key(ocr_key):
api_keys_map["ocr"] = ocr_key.get_secret_value()
current_active_key = (
gemini_key.get_secret_value()
if gemini_key
else (configured_providers[0]["key"] if configured_providers else "")
)
metadata = {
"configured_count": len(configured_providers),
"providers": configured_providers,
"has_ocr_key": ocr_key is not None,
"current_key": current_active_key,
"current_gemini_key": gemini_key.get_secret_value() if gemini_key else "",
"api_keys": api_keys_map,
}
# Case 1: Gemini API Key configured (Standard / Recommended)
if gemini_key:
masked = self._mask_key(gemini_key.get_secret_value())
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Multimodal LLM API Key",
status=ProbeStatus.PASS,
is_blocker=self.is_blocker,
summary="Active (Gemini)",
description=f"Gemini multimodal API credential is active ({masked}) and ready for vision and reasoning.",
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Provider Active",
payload="Gemini 2.5 Flash / Pro Multimodal Engine is enabled.",
)
],
)
# Case 2: Other LLM provider configured
if configured_providers:
first_p = configured_providers[0]
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Multimodal LLM API Key",
status=ProbeStatus.PASS,
is_blocker=self.is_blocker,
summary=f"Active ({first_p['label']})",
description=f"{first_p['label']} multimodal API credential is active ({first_p['masked']}).",
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Provider Active",
payload=f"{first_p['label']} multimodal vision endpoint is active.",
)
],
)
# Case 3: No LLM key configured
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Multimodal LLM API Key",
status=ProbeStatus.FAIL,
is_blocker=self.is_blocker,
summary="Key Missing",
description="No Multimodal LLM credential (e.g. GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY) found in environment or .env file.",
metadata=metadata,
actions=[
ProbeAction(
action_type="command",
label="Run Artemis Init",
payload="artemis init",
),
ProbeAction(
action_type="link",
label="Get Google AI Studio Key",
payload="https://aistudio.google.com/app/apikey",
),
],
)
class VisionOCRProbe(BaseProbe):
"""Probe verifying optional Google Cloud Vision OCR credentials."""
@property
def probe_id(self) -> str:
return "vision_ocr_key"
@property
def category(self) -> ProbeCategory:
return ProbeCategory.CREDENTIALS
@property
def is_blocker(self) -> bool:
return False
def _mask_key(self, key_str: str) -> str:
"""Helper to safely mask an API credential for display."""
if len(key_str) > 10:
return f"{key_str[:6]}...{key_str[-4:]}"
return "***"
async def probe(self) -> ProbeResult:
from artemis.utils.ocr_api import is_ocr_configured
ocr_key = settings.get_api_key("ocr")
is_configured = is_ocr_configured() and ocr_key is not None
if is_configured and ocr_key:
val = ocr_key.get_secret_value()
masked = self._mask_key(val)
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Vision OCR API Key (Optional)",
status=ProbeStatus.PASS,
is_blocker=False,
summary="Active & Configured",
description=f"Google Cloud Vision OCR ({masked}) is active for image text recognition.",
metadata={"configured": True, "masked_key": masked, "key": val, "raw_key": val},
actions=[
ProbeAction(
action_type="hint",
label="OCR Enabled",
payload="OCR is active and will fuse text with UI XML hierarchy.",
)
],
)
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Vision OCR API Key (Optional)",
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.",
metadata={"configured": False},
actions=[
ProbeAction(
action_type="hint",
label="Standard XML Perception",
payload="Artemis uses pure UI layout parsing and Set-of-Marks visual grounding.",
)
],
)
@@ -0,0 +1,169 @@
# 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.
"""Python Runtime and System Configuration Diagnostic Probes."""
import platform
import sys
from artemis.config import parse_llm_config
from artemis.core.diagnostics.probes.base import BaseProbe
from artemis.core.diagnostics.schema import (
ProbeAction,
ProbeCategory,
ProbeResult,
ProbeStatus,
)
class PythonRuntimeProbe(BaseProbe):
"""Probe inspecting Python runtime environment, version, and architecture."""
@property
def probe_id(self) -> str:
return "python_runtime"
@property
def category(self) -> ProbeCategory:
return ProbeCategory.RUNTIME
@property
def is_blocker(self) -> bool:
return True
async def probe(self) -> ProbeResult:
py_ver_tuple = sys.version_info
py_ver_str = f"{py_ver_tuple.major}.{py_ver_tuple.minor}.{py_ver_tuple.micro}"
is_supported = py_ver_tuple >= (3, 12)
arch = platform.machine()
executable = sys.executable
metadata = {
"version": py_ver_str,
"major": py_ver_tuple.major,
"minor": py_ver_tuple.minor,
"micro": py_ver_tuple.micro,
"executable": executable,
"architecture": arch,
"platform": sys.platform,
"is_supported": is_supported,
}
if is_supported:
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Python Runtime Environment",
status=ProbeStatus.PASS,
is_blocker=self.is_blocker,
summary=f"Python {py_ver_str} Ready",
description=f"Python {py_ver_str} ({arch}, 64-bit) detected at {executable}. Satisfies requirement (>= 3.12).",
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Python Runtime OK",
payload=f"Python {py_ver_str} ({arch})",
)
],
)
else:
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Python Runtime Environment",
status=ProbeStatus.FAIL,
is_blocker=self.is_blocker,
summary=f"Python {py_ver_str} Unsupported",
description=f"Python {py_ver_str} detected. Artemis requires Python >= 3.12.",
metadata=metadata,
actions=[
ProbeAction(
action_type="link",
label="Download Python 3.12+",
payload="https://www.python.org/downloads/",
),
ProbeAction(
action_type="command",
label="Install with uv",
payload="uv python install 3.12",
),
],
)
class SystemConfigProbe(BaseProbe):
"""Probe verifying config/artemis.jsonc and settings integrity."""
@property
def probe_id(self) -> str:
return "system_config"
@property
def category(self) -> ProbeCategory:
return ProbeCategory.RUNTIME
@property
def is_blocker(self) -> bool:
return True
async def probe(self) -> ProbeResult:
try:
cfg = parse_llm_config()
planner_model = getattr(getattr(cfg, "planner", None), "model", "Gemini 2.5 Flash")
metadata = {
"valid": True,
"config_file": "config/artemis.jsonc",
"planner_model": planner_model,
}
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Artemis System Configuration",
status=ProbeStatus.PASS,
is_blocker=self.is_blocker,
summary="Config Valid",
description=f"System configuration loaded and validated (Planner model: {planner_model}).",
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Config Healthy",
payload="config/artemis.jsonc is loaded and valid.",
)
],
)
except Exception as e:
metadata = {
"valid": False,
"config_file": "config/artemis.jsonc",
"error": str(e),
}
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Artemis System Configuration",
status=ProbeStatus.FAIL,
is_blocker=self.is_blocker,
summary="Config Error",
description=f"Failed to parse config/artemis.jsonc: {e}",
metadata=metadata,
actions=[
ProbeAction(
action_type="command",
label="Run Artemis Init",
payload="artemis init",
)
],
)
@@ -0,0 +1,139 @@
# 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.
"""Toolchain and Video Recording Auxiliary Probe."""
from artemis.core.diagnostics.probes.base import BaseProbe
from artemis.core.diagnostics.schema import (
ProbeAction,
ProbeCategory,
ProbeResult,
ProbeStatus,
)
from artemis.platform import OSType, platform
from artemis.toolchain import toolchain
class ToolchainProbe(BaseProbe):
"""Probe checking required multimedia and video recording toolchains (ffmpeg, scrcpy)."""
@property
def probe_id(self) -> str:
return "toolchain"
@property
def category(self) -> ProbeCategory:
return ProbeCategory.TOOLCHAIN
@property
def is_blocker(self) -> bool:
return False
async def probe(self) -> ProbeResult:
ffmpeg_path = toolchain.resolve("ffmpeg", force_refresh=True)
scrcpy_path = toolchain.resolve("scrcpy", force_refresh=True)
tools_installed = []
tools_missing = []
if ffmpeg_path:
tools_installed.append("FFmpeg")
else:
tools_missing.append("FFmpeg")
if scrcpy_path:
tools_installed.append("scrcpy")
else:
tools_missing.append("scrcpy")
metadata = {
"ffmpeg": ffmpeg_path is not None,
"scrcpy": scrcpy_path is not None,
"ffmpeg_path": ffmpeg_path,
"scrcpy_path": scrcpy_path,
"tools_installed": tools_installed,
"tools_missing": tools_missing,
}
if ffmpeg_path and scrcpy_path:
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Video Recording Toolchain",
status=ProbeStatus.PASS,
is_blocker=self.is_blocker,
summary="Ready (FFmpeg + scrcpy)",
description=f"FFmpeg ({ffmpeg_path}) and scrcpy ({scrcpy_path}) are available. Live stream and high-speed video replays are active.",
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Toolchain Active",
payload="FFmpeg and scrcpy are installed and active.",
)
],
)
missing_str = " & ".join(tools_missing)
actions = []
if platform.os_type == OSType.WINDOWS:
actions.append(
ProbeAction(
action_type="command",
label="Install via WinGet",
payload="winget install Gyan.FFmpeg Genymobile.scrcpy",
)
)
actions.append(
ProbeAction(
action_type="command",
label="Run PowerShell Setup",
payload="powershell -ExecutionPolicy Bypass -File scripts/install_deps.ps1",
)
)
elif platform.os_type == OSType.MACOS:
actions.append(
ProbeAction(
action_type="command",
label="Install via Homebrew",
payload="brew install ffmpeg scrcpy",
)
)
else:
actions.append(
ProbeAction(
action_type="command",
label="Install on Linux",
payload="sudo apt-get install -y ffmpeg scrcpy",
)
)
actions.append(
ProbeAction(
action_type="command",
label="One-Click Install Script",
payload="bash scripts/install_deps.sh",
)
)
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Video Recording Toolchain",
status=ProbeStatus.FAIL,
is_blocker=self.is_blocker,
summary=f"Missing {missing_str}",
description=f"Video toolchain is required for screen streaming and test replay recording. Missing: {missing_str}.",
metadata=metadata,
actions=actions,
)
+114
View File
@@ -0,0 +1,114 @@
# 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.
"""Schema definitions for Artemis System Diagnostics & Readiness Engine."""
from enum import Enum
from typing import Any, Literal
from pydantic import BaseModel, Field
class ProbeStatus(str, Enum):
"""Execution status of a readiness probe."""
PASS = "pass" # 🟢 Ready for execution
WARN = "warn" # 🟡 Warning / Non-blocking prerequisite missing (e.g. no devices attached)
FAIL = "fail" # 🔴 Blocking prerequisite failure (e.g. ADB missing, no API key)
SKIPPED = "skipped" # ⚪ Skipped probe
class ProbeCategory(str, Enum):
"""Category of system readiness check."""
DEVICE = "device" # Hardware & Device Driver (ADB, Emulator, Cloud device)
CREDENTIALS = "auth" # LLM API Keys & OCR Credentials
TOOLCHAIN = "toolchain" # External tools (FFmpeg, scrcpy, etc.)
RUNTIME = "runtime" # Python runtime & Config health
class ProbeAction(BaseModel):
"""Actionable guidance payload for quick remediation or user guidance."""
action_type: Literal["command", "hint", "link"] = Field(
description="Type of remediation action ('command' to copy/run, 'hint' for guidance, 'link' for docs)"
)
label: str = Field(description="Display label for the action button or badge")
payload: str = Field(description="Command string to execute/copy or explanation hint")
class DeviceInfo(BaseModel):
"""Structured Android device / emulator metadata."""
serial: str = Field(description="Device serial number or emulator port ID (e.g. emulator-5554)")
state: str = Field(
default="device", description="Connection state ('device', 'unauthorized', 'offline')"
)
model: str | None = Field(
default=None, description="Device model name (e.g. Pixel 8, sdk_gphone64_arm64)"
)
product: str | None = Field(default=None, description="Product code name")
android_version: str | None = Field(
default=None, description="Android OS release version (e.g. 14)"
)
screen_resolution: str | None = Field(
default=None, description="Screen width x height (e.g. 1080x2400)"
)
is_emulator: bool = Field(default=False, description="Whether the device is an emulator")
installed_packages: list[str] = Field(
default_factory=list, description="List of recognized installed package names"
)
class ProbeResult(BaseModel):
"""Outcome of a single readiness probe check."""
id: str = Field(
description="Unique identifier for the probe (e.g. 'android_adb', 'gemini_api_key')"
)
category: ProbeCategory = Field(description="Category of the probe")
title: str = Field(description="Human-readable title (e.g. 'Device / Emulator Connected')")
status: ProbeStatus = Field(description="Current status (pass, warn, fail)")
is_blocker: bool = Field(
default=True, description="Whether passing this probe is strictly required to run tasks"
)
summary: str = Field(
description="Short badge summary text (e.g. 'Connected', 'No Device', 'Active & Ready')"
)
description: str = Field(description="Detailed explanation of the status")
metadata: dict[str, Any] = Field(
default_factory=dict, description="Arbitrary extra metadata (e.g. device list)"
)
actions: list[ProbeAction] = Field(
default_factory=list, description="List of actionable remediation steps"
)
class SystemReadinessReport(BaseModel):
"""Aggregated system readiness report across all probes."""
overall_ready: bool = Field(description="True if all blocker probes are passing")
blocker_count: int = Field(description="Total count of required blocker probes")
passed_blocker_count: int = Field(
description="Count of required blocker probes currently passing"
)
probes: list[ProbeResult] = Field(
default_factory=list, description="Ordered list of probe results"
)
active_device: DeviceInfo | None = Field(
default=None, description="Currently selected/active target device"
)
os_type: str = Field(
default="linux", description="Operating system type ('linux', 'darwin', 'windows')"
)
timestamp: float = Field(description="Unix timestamp of when the report was generated")