Files
Artemis-Eapil/artemis/runtime/daemon_client.py
T
somew1nd 5f1ee18bc1 feat(core): consolidate config hub, unify daemon scheduling across all entrypoints, and fix step replay
- 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
2026-08-27 10:21:40 -07:00

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"}