438 lines
15 KiB
Python
438 lines
15 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
|
|
from http.client import HTTPException
|
|
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.config.settings import settings
|
|
from artemis.runtime.process_probe import pid_is_alive
|
|
from artemis.runtime.server_lifecycle import is_port_in_use, read_server_info
|
|
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 | None = None,
|
|
) -> bool:
|
|
"""Fast probe to check if the Artemis Daemon HTTP server is responding."""
|
|
timeout = timeout if timeout is not None else settings.ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS
|
|
url = f"http://{host}:{port}/api/system/server-status"
|
|
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 is_artemis_daemon(
|
|
host: str = DEFAULT_DAEMON_HOST,
|
|
port: int = DEFAULT_DAEMON_PORT,
|
|
timeout: float = 2.0,
|
|
) -> bool:
|
|
"""Identity probe: is the process on ``host:port`` the Artemis daemon?
|
|
|
|
:func:`is_daemon_running` is a liveness probe that accepts any HTTP
|
|
listener (a dev server on port 8000 answers ``200`` on ``/``). Diagnostics
|
|
need to tell the daemon apart from a port squatter, so this asks the
|
|
daemon's lightweight ``/api/system/server-status`` route and requires the
|
|
JSON shape only Artemis serves.
|
|
"""
|
|
url = f"http://{host}:{port}/api/system/server-status"
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": "Artemis-Daemon-Probe"})
|
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
if response.status != 200:
|
|
return False
|
|
payload = json.loads(response.read().decode("utf-8", errors="replace") or "null")
|
|
except (OSError, ValueError, HTTPException):
|
|
return False
|
|
return isinstance(payload, dict) and "current_pid" in payload and "port" in payload
|
|
|
|
|
|
def daemon_log_path(port: int = DEFAULT_DAEMON_PORT) -> Path:
|
|
"""Return the log file path used by a background-spawned Daemon on the given port."""
|
|
from artemis.config.paths import get_app_dir
|
|
|
|
return get_app_dir() / "logs" / f"daemon-{port}.log"
|
|
|
|
|
|
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.
|
|
|
|
The server is always launched via ``sys.executable -m`` (never through the
|
|
``artemis``/``artemis-admin`` console-script shims) so the long-lived process
|
|
chain never holds a lock on ``Scripts/artemis.exe`` — on Windows, a resident
|
|
shim blocks ``uv sync``/reinstalls with "os error 32" until it exits.
|
|
"""
|
|
env = os.environ.copy()
|
|
env["PYTHONUNBUFFERED"] = "1"
|
|
env["PYTHONUTF8"] = "1"
|
|
|
|
cmd = [
|
|
sys.executable,
|
|
"-m",
|
|
"apps.admin_console.server",
|
|
"--host",
|
|
host,
|
|
"--port",
|
|
str(port),
|
|
]
|
|
|
|
# Persist daemon output for post-mortem debugging (daemon mode is the
|
|
# default for `artemis restart`, so silent DEVNULL would hide crashes).
|
|
log_handle: Any = subprocess.DEVNULL
|
|
try:
|
|
log_file = daemon_log_path(port)
|
|
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
log_handle = open(log_file, "ab")
|
|
except Exception:
|
|
log_handle = subprocess.DEVNULL
|
|
|
|
kwargs: dict[str, Any] = {
|
|
"cwd": str(ROOT_DIR),
|
|
"env": env,
|
|
"stdout": log_handle,
|
|
"stderr": subprocess.STDOUT if log_handle is not subprocess.DEVNULL else 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
|
|
finally:
|
|
# The child inherited the handle; the parent's copy is no longer needed.
|
|
if log_handle is not subprocess.DEVNULL:
|
|
try:
|
|
log_handle.close()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
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}"
|
|
|
|
if is_port_in_use(port, host):
|
|
# A slow/busy HTTP loop is not a stopped daemon. Only trust persisted
|
|
# metadata belonging to a live process on the requested endpoint.
|
|
info = read_server_info() or {}
|
|
if info.get("port") == port and info.get("host") == host and pid_is_alive(info.get("pid")):
|
|
return True, f"http://{host}:{port}"
|
|
logger.warning(f"Port {host}:{port} is occupied; refusing to spawn another daemon")
|
|
return False, None
|
|
|
|
# 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,
|
|
verification_level: str | None = None,
|
|
explorer_mode: str | None = None,
|
|
runtime_overrides: dict[str, Any] | 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.
|
|
|
|
``verification_level`` ('off' | 'final' | 'checkpoints' | 'strict') and
|
|
``explorer_mode`` ('flash' | 'pro' | 'ultra') are the Pro-profile tuning
|
|
knobs of ``/api/run``; they are forwarded verbatim and ignored by Flash.
|
|
|
|
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,
|
|
"verification_level": verification_level,
|
|
"explorer_mode": explorer_mode,
|
|
"locked_app_package": locked_app_package,
|
|
"app_path": app_path,
|
|
"session_id": session_id,
|
|
"ingress": ingress,
|
|
"conversation_id": conversation_id,
|
|
"runtime_overrides": runtime_overrides,
|
|
}
|
|
|
|
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",
|
|
verification_level: str | None = None,
|
|
explorer_mode: str | None = None,
|
|
base_url: str | None = None,
|
|
timeout: float = 15.0,
|
|
) -> dict[str, Any] | None:
|
|
"""Submit a batch of goals to the running Daemon.
|
|
|
|
``verification_level`` / ``explorer_mode`` apply to every goal of the batch
|
|
(see :func:`submit_task_to_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,
|
|
"verification_level": verification_level,
|
|
"explorer_mode": explorer_mode,
|
|
}
|
|
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"}
|