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:
1 parent
78e478f502
commit
9a5aaf99b7
249 files changed
+37226
-9339
No files matched your search
@@ -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()
|
||||
@@ -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'}",
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user