- Unified configuration and paths under artemis.config as single source of truth - Consolidated execution across CLI, SDK, MCP, and Admin Console to default to Artemis Daemon - Fixed frontend step replay to preserve failure steps with proper sequential numbering - Implemented task deduplication, debouncing, and multi-device lock isolation - Added comprehensive end-to-end multi-port integration tests for live phone and emulator
350 lines
12 KiB
Python
350 lines
12 KiB
Python
# 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.
|
|
|
|
"""Client utilities for communicating with and auto-spawning the Artemis Daemon."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from typing import Any
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
from artemis.config.paths import ROOT_DIR
|
|
from artemis.utils.logger import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
DEFAULT_DAEMON_HOST = os.environ.get("ARTEMIS_DAEMON_HOST", "127.0.0.1")
|
|
DEFAULT_DAEMON_PORT = int(os.environ.get("ARTEMIS_DAEMON_PORT", "8000"))
|
|
|
|
|
|
def is_standalone_forced() -> bool:
|
|
"""Return True if standalone/embedded execution is explicitly configured."""
|
|
return os.environ.get("ARTEMIS_STANDALONE", "").lower() in ("1", "true", "yes")
|
|
|
|
|
|
def is_daemon_running(
|
|
host: str = DEFAULT_DAEMON_HOST,
|
|
port: int = DEFAULT_DAEMON_PORT,
|
|
timeout: float = 0.3,
|
|
) -> bool:
|
|
"""Fast probe to check if the Artemis Daemon HTTP server is responding."""
|
|
url = f"http://{host}:{port}/api/system/readiness"
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": "Artemis-Daemon-Probe"})
|
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
return response.status in (200, 400, 404, 503)
|
|
except Exception:
|
|
# Fallback probe to root endpoint
|
|
try:
|
|
root_url = f"http://{host}:{port}/"
|
|
req = urllib.request.Request(root_url, headers={"User-Agent": "Artemis-Daemon-Probe"})
|
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
return response.status in (200, 301, 302, 404)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def spawn_daemon(
|
|
host: str = DEFAULT_DAEMON_HOST,
|
|
port: int = DEFAULT_DAEMON_PORT,
|
|
) -> subprocess.Popen | None:
|
|
"""Silently spawn the Artemis Daemon server in the background as a detached process."""
|
|
env = os.environ.copy()
|
|
env["PYTHONUNBUFFERED"] = "1"
|
|
env["PYTHONUTF8"] = "1"
|
|
|
|
cmd = [
|
|
sys.executable,
|
|
"-m",
|
|
"apps.admin_console.server",
|
|
"--host",
|
|
host,
|
|
"--port",
|
|
str(port),
|
|
]
|
|
|
|
kwargs: dict[str, Any] = {
|
|
"cwd": str(ROOT_DIR),
|
|
"env": env,
|
|
"stdout": subprocess.DEVNULL,
|
|
"stderr": subprocess.DEVNULL,
|
|
"stdin": subprocess.DEVNULL,
|
|
}
|
|
|
|
if sys.platform == "win32":
|
|
kwargs["creationflags"] = (
|
|
subprocess.CREATE_NEW_PROCESS_GROUP
|
|
| getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
|
|
| getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
|
|
)
|
|
else:
|
|
kwargs["start_new_session"] = True
|
|
|
|
try:
|
|
proc = subprocess.Popen(cmd, **kwargs)
|
|
logger.info(f"Spawned Artemis Daemon in background (PID {proc.pid}) at http://{host}:{port}")
|
|
return proc
|
|
except Exception as exc:
|
|
logger.warning(f"Could not auto-spawn Artemis Daemon: {exc}")
|
|
return None
|
|
|
|
|
|
def ensure_daemon_running(
|
|
host: str = DEFAULT_DAEMON_HOST,
|
|
port: int = DEFAULT_DAEMON_PORT,
|
|
timeout: float = 2.0,
|
|
wait_ready: bool = False,
|
|
) -> tuple[bool, str | None]:
|
|
"""Ensure the Artemis Daemon is running, auto-spawning it if necessary.
|
|
|
|
Args:
|
|
host: Daemon host to probe.
|
|
port: Daemon port to probe.
|
|
timeout: Maximum seconds to wait if wait_ready is True.
|
|
wait_ready: If True, blocks until Daemon is healthy or timeout reached.
|
|
If False, spawns asynchronously and returns immediately.
|
|
|
|
Returns:
|
|
(True, base_url) if Daemon is accessible.
|
|
(False, None) if standalone mode is forced or Daemon could not be reached.
|
|
"""
|
|
if is_standalone_forced():
|
|
return False, None
|
|
|
|
if is_daemon_running(host, port):
|
|
return True, f"http://{host}:{port}"
|
|
|
|
# Auto-spawn Daemon in background
|
|
spawn_daemon(host, port)
|
|
|
|
if not wait_ready:
|
|
return True, f"http://{host}:{port}"
|
|
|
|
# Poll until ready or timeout
|
|
started = time.monotonic()
|
|
while time.monotonic() - started < timeout:
|
|
time.sleep(0.1)
|
|
if is_daemon_running(host, port):
|
|
return True, f"http://{host}:{port}"
|
|
|
|
logger.warning(f"Daemon did not respond within {timeout}s. Falling back to standalone mode.")
|
|
return False, None
|
|
|
|
|
|
def submit_task_to_daemon(
|
|
goal: str,
|
|
*,
|
|
profile: str = "flash",
|
|
device_serial: str | None = None,
|
|
expected_output: str | None = None,
|
|
enable_outputter: bool | None = None,
|
|
locked_app_package: str | None = None,
|
|
app_path: str | None = None,
|
|
session_id: str | None = None,
|
|
ingress: str = "client",
|
|
conversation_id: str | None = None,
|
|
base_url: str | None = None,
|
|
timeout: float = 15.0,
|
|
) -> dict[str, Any] | None:
|
|
"""Submit a task to the running Daemon via REST API.
|
|
|
|
Returns the response JSON dict if successfully enqueued, or None on error.
|
|
"""
|
|
url = f"{base_url or f'http://{DEFAULT_DAEMON_HOST}:{DEFAULT_DAEMON_PORT}'}/api/run"
|
|
payload = {
|
|
"goal": goal,
|
|
"profile": profile,
|
|
"device_serial": device_serial,
|
|
"expected_output": expected_output,
|
|
"enable_outputter": enable_outputter,
|
|
"locked_app_package": locked_app_package,
|
|
"app_path": app_path,
|
|
"session_id": session_id,
|
|
"ingress": ingress,
|
|
"conversation_id": conversation_id,
|
|
}
|
|
|
|
try:
|
|
data = json.dumps(payload).encode("utf-8")
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=data,
|
|
headers={"Content-Type": "application/json", "User-Agent": "Artemis-Client"},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
if response.status in (200, 201):
|
|
return json.loads(response.read().decode("utf-8"))
|
|
except Exception as exc:
|
|
logger.debug(f"Failed to submit task to Daemon at {url}: {exc}")
|
|
return None
|
|
return None
|
|
|
|
|
|
def stop_task_on_daemon(
|
|
session_id: str,
|
|
*,
|
|
base_url: str | None = None,
|
|
timeout: float = 5.0,
|
|
) -> bool:
|
|
"""Request the running Artemis Daemon to stop a task by session ID."""
|
|
url = f"{base_url or f'http://{DEFAULT_DAEMON_HOST}:{DEFAULT_DAEMON_PORT}'}/api/stop"
|
|
payload = {"session_id": session_id}
|
|
try:
|
|
data = json.dumps(payload).encode("utf-8")
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=data,
|
|
headers={"Content-Type": "application/json", "User-Agent": "Artemis-Client"},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
if response.status == 200:
|
|
resp_data = json.loads(response.read().decode("utf-8"))
|
|
return resp_data.get("status") == "stopped"
|
|
except Exception as exc:
|
|
logger.debug(f"Failed to request stop from Daemon at {url}: {exc}")
|
|
return False
|
|
return False
|
|
|
|
|
|
def get_daemon_session(
|
|
session_id: str,
|
|
*,
|
|
base_url: str | None = None,
|
|
timeout: float = 5.0,
|
|
) -> dict[str, Any] | None:
|
|
"""Fetch details of a single session from the Artemis Daemon."""
|
|
url = f"{base_url or f'http://{DEFAULT_DAEMON_HOST}:{DEFAULT_DAEMON_PORT}'}/api/sessions/{session_id}"
|
|
try:
|
|
req = urllib.request.Request(
|
|
url,
|
|
headers={"User-Agent": "Artemis-Client"},
|
|
method="GET",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
if response.status == 200:
|
|
return json.loads(response.read().decode("utf-8"))
|
|
except Exception as exc:
|
|
logger.debug(f"Failed to get session {session_id} from Daemon at {url}: {exc}")
|
|
return None
|
|
return None
|
|
|
|
|
|
def get_daemon_status(
|
|
*,
|
|
base_url: str | None = None,
|
|
timeout: float = 5.0,
|
|
) -> dict[str, Any] | None:
|
|
"""Fetch global scheduler and device status from the Artemis Daemon."""
|
|
url = f"{base_url or f'http://{DEFAULT_DAEMON_HOST}:{DEFAULT_DAEMON_PORT}'}/api/status"
|
|
try:
|
|
req = urllib.request.Request(
|
|
url,
|
|
headers={"User-Agent": "Artemis-Client"},
|
|
method="GET",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
if response.status == 200:
|
|
return json.loads(response.read().decode("utf-8"))
|
|
except Exception as exc:
|
|
logger.debug(f"Failed to get status from Daemon at {url}: {exc}")
|
|
return None
|
|
return None
|
|
|
|
|
|
def submit_batch_to_daemon(
|
|
goals: list[str],
|
|
*,
|
|
profile: str = "flash",
|
|
device_serial: str | None = None,
|
|
ingress: str = "cli",
|
|
base_url: str | None = None,
|
|
timeout: float = 15.0,
|
|
) -> dict[str, Any] | None:
|
|
"""Submit a batch of goals to the running Daemon."""
|
|
url = f"{base_url or f'http://{DEFAULT_DAEMON_HOST}:{DEFAULT_DAEMON_PORT}'}/api/run"
|
|
payload = {
|
|
"goals": goals,
|
|
"profile": profile,
|
|
"device_serial": device_serial,
|
|
"ingress": ingress,
|
|
}
|
|
try:
|
|
data = json.dumps(payload).encode("utf-8")
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=data,
|
|
headers={"Content-Type": "application/json", "User-Agent": "Artemis-Client"},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
if response.status in (200, 201):
|
|
return json.loads(response.read().decode("utf-8"))
|
|
except Exception as exc:
|
|
logger.debug(f"Failed to submit batch to Daemon at {url}: {exc}")
|
|
return None
|
|
return None
|
|
|
|
|
|
def wait_for_daemon_task(
|
|
session_id: str,
|
|
*,
|
|
base_url: str | None = None,
|
|
timeout: float = 600.0,
|
|
poll_interval: float = 1.0,
|
|
on_status_update: Any = None,
|
|
) -> dict[str, Any]:
|
|
"""Poll the Daemon until the task reaches a terminal status (completed, failed, cancelled)."""
|
|
started = time.monotonic()
|
|
last_status = None
|
|
|
|
while time.monotonic() - started < timeout:
|
|
sess = get_daemon_session(session_id, base_url=base_url)
|
|
if sess:
|
|
status = sess.get("status")
|
|
if status != last_status:
|
|
last_status = status
|
|
if callable(on_status_update):
|
|
on_status_update(sess)
|
|
|
|
if status in ("completed", "success", "failed", "cancelled"):
|
|
return sess
|
|
else:
|
|
# Check if task is queued in daemon status
|
|
st = get_daemon_status(base_url=base_url)
|
|
if st:
|
|
queue = st.get("queue") or []
|
|
is_queued = any(
|
|
isinstance(item, dict) and str(item.get("session_id")) == str(session_id)
|
|
for item in queue
|
|
)
|
|
current_st = "queued" if is_queued else "launching"
|
|
if current_st != last_status:
|
|
last_status = current_st
|
|
if callable(on_status_update):
|
|
on_status_update({"status": current_st, "session_id": session_id})
|
|
|
|
time.sleep(poll_interval)
|
|
|
|
return {"session_id": session_id, "status": "timeout", "error": f"Timed out after {timeout}s"}
|
|
|