feat: enable remote SDK and resilient device orchestration

This commit is contained in:
somew1nd committed 2026-08-30 00:41:07 -07:00
1 parent 3cc711df51
commit ddfa420945
110 files changed
+14450 -12954

No files matched your search

+58 -27
View File
@@ -12,11 +12,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, HTMLResponse
from artemis.config import IMAGES_DIR, WORKSPACE_ROOT
from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
try:
from admin_console.database.repositories.session_repository import session_repo
@@ -27,6 +28,48 @@ except ImportError:
router = APIRouter(tags=["media"])
_VIDEO_MEDIA_TYPES = {
".mp4": "video/mp4",
".webm": "video/webm",
".mkv": "video/x-matroska",
}
def _allowed_media_roots() -> list[Path]:
"""Directories the media endpoints are permitted to serve from."""
roots = []
for root in (WORKSPACE_ROOT, TRACES_PATH):
try:
roots.append(Path(root).resolve())
except OSError:
continue
return roots
def _resolve_media_path(raw_path: str, allowed_suffixes: set[str]) -> Path:
"""Resolve a client-supplied path strictly inside the allowed media roots.
Every candidate is fully resolved (symlinks, `..`, mixed separators) and
then re-checked against the allowed roots and an extension allowlist, so a
crafted URL can never address source code, databases, or dotfiles.
"""
roots = _allowed_media_roots()
candidate = Path(raw_path)
attempts = [candidate] if candidate.is_absolute() else [root / candidate for root in roots]
for attempt in attempts:
try:
resolved = attempt.resolve(strict=True)
except (OSError, ValueError):
continue
if not resolved.is_file():
continue
if not any(resolved.is_relative_to(root) for root in roots):
continue
if resolved.suffix.lower() not in allowed_suffixes:
raise HTTPException(status_code=403, detail="File type is not served by the media API.")
return resolved
raise HTTPException(status_code=404, detail="Media file not found")
@router.get("/admin", response_class=HTMLResponse)
@router.get("/debug", response_class=HTMLResponse)
@@ -47,8 +90,12 @@ async def get_admin_index():
async def get_image(image_name: str):
if not image_name.endswith(".jpg"):
image_name += ".jpg"
image_path = IMAGES_DIR / image_name
if not image_path.exists():
try:
images_root = IMAGES_DIR.resolve()
image_path = (images_root / image_name).resolve(strict=True)
except (OSError, ValueError):
raise HTTPException(status_code=404, detail="Image not found")
if not image_path.is_file() or not image_path.is_relative_to(images_root):
raise HTTPException(status_code=404, detail="Image not found")
return FileResponse(image_path, media_type="image/jpeg")
@@ -56,34 +103,18 @@ async def get_image(image_name: str):
@router.get("/videos/{video_path:path}")
async def get_video(video_path: str):
path = Path(video_path)
if not path.exists():
if not path.is_absolute():
candidate = WORKSPACE_ROOT / path
if candidate.exists():
path = candidate
else:
candidate_root = Path("/" + video_path.lstrip("/"))
if candidate_root.exists():
path = candidate_root
else:
path = candidate
if not path.exists():
raise HTTPException(status_code=404, detail=f"Video not found at {path}")
suffix = path.suffix.lower()
media_type = "video/mp4"
if suffix == ".webm":
media_type = "video/webm"
elif suffix == ".mkv":
media_type = "video/x-matroska"
return FileResponse(path, media_type=media_type)
path = _resolve_media_path(video_path, set(_VIDEO_MEDIA_TYPES))
return FileResponse(path, media_type=_VIDEO_MEDIA_TYPES[path.suffix.lower()])
@router.get("/api/sessions/{session_id}/video")
async def get_session_video(session_id: str):
# Blocking work (sqlite, filesystem scan, possible ffmpeg conversion) runs
# off the event loop.
return await asyncio.to_thread(_get_session_video_sync, session_id)
def _get_session_video_sync(session_id: str):
video_rec_map = session_repo.get_video_recordings_map()
video_idx = media_service.build_video_index()
row = session_repo.get_session_by_id(session_id)
+123 -114
View File
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import json
import time
from uuid import UUID
@@ -38,125 +39,133 @@ router = APIRouter(tags=["sessions"])
@router.get("/api/sessions")
async def list_sessions():
# The body does blocking work (sqlite queries, filesystem scans, ffmpeg
# conversion on first sight of a new recording), so run it off the event
# loop — the frontend polls this endpoint and it must not stall other
# requests.
try:
rows = session_repo.get_all_sessions()
video_rec_map = session_repo.get_video_recordings_map()
latest_recordings = session_repo.get_latest_video_recordings_map()
agent_names_by_session = session_repo.get_agent_trace_names_map()
video_idx = media_service.build_video_index()
default_model_info = model_service.get_active_model_info()
orphaned_ids = []
unresolved_profiles = []
result = []
try:
from artemis.runtime import DeviceExecutionLock
active_owners = DeviceExecutionLock.get_active_owners()
active_owner_sids = {
str(owner.session_id)
for owner in active_owners.values()
if owner.session_id
}
except Exception:
active_owner_sids = set()
for row_dict in rows:
s_id = str(row_dict.get("session_id"))
recording = latest_recordings.get(s_id)
d_info_raw = row_dict.get("device_info")
device_id = None
if d_info_raw:
try:
d_info = json.loads(d_info_raw) if isinstance(d_info_raw, str) else d_info_raw
if isinstance(d_info, dict):
device_id = d_info.get("device_id") or d_info.get("device_serial")
except Exception:
pass
if not device_id and recording:
device_id = recording.get("device_id")
row_dict["device_id"] = device_id
row_dict["device_serial"] = device_id
if row_dict.get("status") == "running":
is_active = (
s_id in active_owner_sids
or s_id in state.active_connections
or (state.is_running and s_id == str(state.active_session_id))
)
worker_is_alive = session_repo.process_is_alive(row_dict.get("pid"))
if not is_active and not worker_is_alive:
row_dict["status"] = "failed"
row_dict["end_time"] = time.time()
orphaned_ids.append(s_id)
recording_status = str((recording or {}).get("status") or "unavailable")
resolved_v_url = (
media_service.resolve_video_url(row_dict, video_rec_map, video_idx)
if recording_status != "recording"
else None
)
if resolved_v_url:
recording_status = "ready"
row_dict["video_url"] = resolved_v_url
else:
row_dict["video_url"] = None
row_dict["recording_status"] = recording_status
agent_names = agent_names_by_session.get(s_id, [])
sess_profile = model_service.resolve_session_profile(
row_dict, None, state.current_profile, agent_names=agent_names
)
row_dict["model_info"] = (
model_service.get_active_model_info(sess_profile)
if sess_profile
else default_model_info
)
if not sess_profile:
unresolved_profiles.append(row_dict)
result.append(row_dict)
# Most sessions carry a profile in device_info or an agent trace name.
# Only inspect the much larger LLM payload for legacy rows that remain
# ambiguous after those cheap checks.
if unresolved_profiles:
unresolved_ids = [str(row.get("session_id")) for row in unresolved_profiles]
llm_traces_by_session = session_repo.get_llm_traces_for_profiles_map(unresolved_ids)
for row_dict in unresolved_profiles:
s_id = str(row_dict.get("session_id"))
llm_traces = llm_traces_by_session.get(s_id)
if not llm_traces:
continue
sess_profile = model_service.resolve_session_profile(
row_dict, llm_traces, state.current_profile
)
if sess_profile:
row_dict["model_info"] = model_service.get_active_model_info(sess_profile)
if orphaned_ids:
try:
harvested = session_repo.harvest_orphaned_sessions(orphaned_ids)
print(f"[list_sessions] Auto-harvested {harvested} orphaned running session(s).")
try:
from mcp_server.utils import trace_store
for o_id in orphaned_ids:
if trace_store.read_status(str(o_id)):
trace_store.update_trace_status(
str(o_id),
"failed",
error="Process terminated unexpectedly (auto-harvested).",
)
except Exception:
pass
except Exception as e:
print(f"[list_sessions] Failed to update orphaned sessions: {e}")
return result
return await asyncio.to_thread(_list_sessions_sync)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
def _list_sessions_sync():
rows = session_repo.get_all_sessions()
video_rec_map = session_repo.get_video_recordings_map()
latest_recordings = session_repo.get_latest_video_recordings_map()
agent_names_by_session = session_repo.get_agent_trace_names_map()
video_idx = media_service.build_video_index()
default_model_info = model_service.get_active_model_info()
orphaned_ids = []
unresolved_profiles = []
result = []
try:
from artemis.runtime import DeviceExecutionLock
active_owners = DeviceExecutionLock.get_active_owners()
active_owner_sids = {
str(owner.session_id)
for owner in active_owners.values()
if owner.session_id
}
except Exception:
active_owner_sids = set()
for row_dict in rows:
s_id = str(row_dict.get("session_id"))
recording = latest_recordings.get(s_id)
d_info_raw = row_dict.get("device_info")
device_id = None
if d_info_raw:
try:
d_info = json.loads(d_info_raw) if isinstance(d_info_raw, str) else d_info_raw
if isinstance(d_info, dict):
device_id = d_info.get("device_id") or d_info.get("device_serial")
except Exception:
pass
if not device_id and recording:
device_id = recording.get("device_id")
row_dict["device_id"] = device_id
row_dict["device_serial"] = device_id
if row_dict.get("status") == "running":
is_active = (
s_id in active_owner_sids
or s_id in state.active_connections
or (state.is_running and s_id == str(state.active_session_id))
)
worker_is_alive = session_repo.process_is_alive(row_dict.get("pid"))
if not is_active and not worker_is_alive:
row_dict["status"] = "failed"
row_dict["end_time"] = time.time()
orphaned_ids.append(s_id)
recording_status = str((recording or {}).get("status") or "unavailable")
resolved_v_url = (
media_service.resolve_video_url(row_dict, video_rec_map, video_idx)
if recording_status != "recording"
else None
)
if resolved_v_url:
recording_status = "ready"
row_dict["video_url"] = resolved_v_url
else:
row_dict["video_url"] = None
row_dict["recording_status"] = recording_status
agent_names = agent_names_by_session.get(s_id, [])
sess_profile = model_service.resolve_session_profile(
row_dict, None, state.current_profile, agent_names=agent_names
)
row_dict["model_info"] = (
model_service.get_active_model_info(sess_profile)
if sess_profile
else default_model_info
)
if not sess_profile:
unresolved_profiles.append(row_dict)
result.append(row_dict)
# Most sessions carry a profile in device_info or an agent trace name.
# Only inspect the much larger LLM payload for legacy rows that remain
# ambiguous after those cheap checks.
if unresolved_profiles:
unresolved_ids = [str(row.get("session_id")) for row in unresolved_profiles]
llm_traces_by_session = session_repo.get_llm_traces_for_profiles_map(unresolved_ids)
for row_dict in unresolved_profiles:
s_id = str(row_dict.get("session_id"))
llm_traces = llm_traces_by_session.get(s_id)
if not llm_traces:
continue
sess_profile = model_service.resolve_session_profile(
row_dict, llm_traces, state.current_profile
)
if sess_profile:
row_dict["model_info"] = model_service.get_active_model_info(sess_profile)
if orphaned_ids:
try:
harvested = session_repo.harvest_orphaned_sessions(orphaned_ids)
print(f"[list_sessions] Auto-harvested {harvested} orphaned running session(s).")
try:
from mcp_server.utils import trace_store
for o_id in orphaned_ids:
if trace_store.read_status(str(o_id)):
trace_store.update_trace_status(
str(o_id),
"failed",
error="Process terminated unexpectedly (auto-harvested).",
)
except Exception:
pass
except Exception as e:
print(f"[list_sessions] Failed to update orphaned sessions: {e}")
return result
@router.get("/api/sessions/{session_id}")
async def get_session_details(session_id: str):
"""Retrieve details for a single automation session."""
+174 -35
View File
@@ -14,15 +14,84 @@
"""System Readiness & Diagnostics Router for Artemis Admin Console."""
from fastapi import APIRouter, HTTPException
import ipaddress
import os
import secrets
from urllib.parse import urlsplit
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from artemis.core.diagnostics import readiness_engine
from artemis.core.diagnostics.adb_server_connection import (
InvalidAdbServerEndpoint,
adb_server_connection,
)
from artemis.core.diagnostics.schema import SystemReadinessReport
router = APIRouter(prefix="/api/system", tags=["system"])
def _require_local_admin_request(request: Request) -> None:
"""Keep endpoint probing and mutation on the local administration boundary."""
client_host = request.client.host if request.client else None
allow_remote = os.getenv("ARTEMIS_ALLOW_REMOTE_ADB_CONFIGURATION", "").lower() in {
"1",
"true",
"yes",
}
if client_host and not allow_remote:
try:
is_loopback = ipaddress.ip_address(client_host).is_loopback
except ValueError:
is_loopback = client_host.lower() == "localhost"
if not is_loopback:
raise HTTPException(
status_code=403,
detail=(
"ADB server settings are local-only. Set "
"ARTEMIS_ALLOW_REMOTE_ADB_CONFIGURATION=true to manage them from another "
"computer."
),
)
origin = request.headers.get("origin")
host = request.headers.get("host")
if not origin or not host:
return
origin_host = urlsplit(origin).netloc.lower()
if origin_host != host.lower():
raise HTTPException(
status_code=403,
detail="ADB server settings can only be changed from the Artemis console.",
)
def _require_loopback_request(request: Request, detail: str) -> None:
"""Reject requests whose TCP peer is not the local machine."""
client_host = request.client.host if request.client else None
try:
is_loopback = bool(client_host and ipaddress.ip_address(client_host).is_loopback)
except ValueError:
is_loopback = bool(client_host and client_host.lower() == "localhost")
if not is_loopback:
raise HTTPException(status_code=403, detail=detail)
def _require_local_lifecycle_request(request: Request) -> None:
"""Authorize a process-lifecycle request from the local CLI only."""
_require_loopback_request(request, "Server lifecycle controls are local-only.")
expected = getattr(request.app.state, "lifecycle_token", None)
supplied = request.headers.get("x-artemis-lifecycle-token")
if not (
isinstance(expected, str)
and isinstance(supplied, str)
and secrets.compare_digest(expected, supplied)
):
raise HTTPException(status_code=403, detail="Invalid server lifecycle token.")
class SelectDeviceRequest(BaseModel):
"""Payload to select an active target Android device."""
@@ -94,6 +163,66 @@ async def connect_wireless_adb(request: ConnectAdbRequest):
}
class ConnectAdbServerRequest(BaseModel):
"""Payload to select an ADB server endpoint accessible from this computer."""
host: str = Field(description="Host name or IP address of the ADB server")
port: int = Field(default=5037, ge=1, le=65535, description="ADB server port")
persist: bool = Field(default=True, description="Persist the endpoint for future launches")
@router.get("/adb/server")
async def get_adb_server_status():
"""Return the process-wide ADB server endpoint currently used by Artemis."""
return adb_server_connection.status()
@router.post("/adb/server/connect")
async def connect_adb_server(payload: ConnectAdbServerRequest, request: Request):
"""Validate and activate an ADB server endpoint."""
_require_local_admin_request(request)
try:
connection_result = await adb_server_connection.connect(
payload.host,
payload.port,
persist=payload.persist,
)
except InvalidAdbServerEndpoint as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
response: dict[str, object] = {"connection_result": connection_result}
if connection_result["success"]:
readiness_engine.set_active_device_serial(None)
readiness_engine.invalidate_cache()
response["report"] = await readiness_engine.run_all(force_refresh=True)
return response
@router.post("/adb/server/probe")
async def probe_adb_server(payload: ConnectAdbServerRequest, request: Request):
"""Test an ADB server endpoint without changing the active endpoint."""
_require_local_admin_request(request)
try:
connection_result = await adb_server_connection.probe(payload.host, payload.port)
except InvalidAdbServerEndpoint as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"connection_result": connection_result}
@router.post("/adb/server/local")
async def use_local_adb_server(request: Request, persist: bool = True):
"""Restore the standard local ADB server without touching a remote daemon."""
_require_local_admin_request(request)
connection_result = await adb_server_connection.use_local_server(persist=persist)
readiness_engine.set_active_device_serial(None)
readiness_engine.invalidate_cache()
updated_report = await readiness_engine.run_all(force_refresh=True)
return {
"connection_result": connection_result,
"report": updated_report,
}
class LaunchEmulatorRequest(BaseModel):
"""Payload to launch a local Android Virtual Device (AVD)."""
@@ -157,22 +286,18 @@ class ValidateCredentialsRequest(BaseModel):
@router.get("/credentials")
async def get_credentials():
"""Retrieve current configured API keys for management."""
"""Report which providers have an API key configured.
Secret values never leave the process: this endpoint intentionally returns
presence booleans only. Keys are written via POST /credentials and used
server-side.
"""
from artemis.config import settings
gemini_k = settings.get_api_key("google")
openai_k = settings.get_api_key("openai")
anthropic_k = settings.get_api_key("anthropic")
openrouter_k = settings.get_api_key("openrouter")
ocr_k = settings.get_api_key("ocr")
return {
"google": gemini_k.get_secret_value() if gemini_k else None,
"gemini": gemini_k.get_secret_value() if gemini_k else None,
"openai": openai_k.get_secret_value() if openai_k else None,
"anthropic": anthropic_k.get_secret_value() if anthropic_k else None,
"openrouter": openrouter_k.get_secret_value() if openrouter_k else None,
"ocr": ocr_k.get_secret_value() if ocr_k else None,
}
providers = ("google", "openai", "anthropic", "openrouter", "ocr")
status = {name: bool(settings.get_api_key(name)) for name in providers}
status["gemini"] = status["google"]
return {"providers": [{"name": name, "configured": configured} for name, configured in status.items()]}
@router.post("/credentials/test")
@@ -278,11 +403,13 @@ async def get_model_config_and_env():
return None
def mask_key(k: str | None) -> str | None:
# Expose only enough to recognize which key is active, never a usable
# fragment of the secret itself.
if not k:
return None
if len(k) <= 8:
return "****"
return f"{k[:4]}...{k[-4:]}"
return f"****{k[-4:]}"
gemini_real = get_real_env_value("GEMINI_API_KEY", "google") or get_real_env_value(
"GOOGLE_API_KEY", "google"
@@ -375,18 +502,30 @@ async def get_server_runtime_status():
port = getattr(state, "port", 8000)
status = get_server_status(port=port)
status["current_pid"] = os.getpid()
return status
# Explicit DTO: the raw metadata file additionally holds the lifecycle
# token, cmdline, and filesystem paths, none of which belong on the wire.
return {
"running": status["running"],
"port": status["port"],
"pids": status["pids"],
"active_pid": status["active_pid"],
"uptime_seconds": status["uptime_seconds"],
"url": status["url"],
"admin_url": status["admin_url"],
"current_pid": os.getpid(),
}
@router.post("/restart")
async def restart_server_endpoint():
async def restart_server_endpoint(request: Request):
"""Request a graceful restart of the Artemis server from thin clients/UI."""
import asyncio
import os
import sys
import threading
_require_loopback_request(request, "Server lifecycle controls are local-only.")
try:
from apps.admin_console.core.state import state
except ImportError:
@@ -423,33 +562,33 @@ async def restart_server_endpoint():
}
@router.post("/shutdown")
async def shutdown_server_endpoint():
@router.post("/shutdown", status_code=202)
async def shutdown_server_endpoint(request: Request):
"""Request a graceful shutdown of the Artemis server."""
import os
import signal
import threading
import asyncio
try:
from apps.admin_console.core.state import state
except ImportError:
from admin_console.core.state import state
current_pid = os.getpid()
_require_local_lifecycle_request(request)
server = getattr(request.app.state, "uvicorn_server", None)
if server is None:
raise HTTPException(status_code=503, detail="Server lifecycle controller is unavailable.")
def _shutdown_worker():
import time
async def _shutdown_after_response() -> None:
# Let Starlette flush the accepted response before Uvicorn leaves its
# main loop and invokes the FastAPI shutdown lifecycle.
await asyncio.sleep(0.05)
state.is_shutting_down = True
state.shutdown_event.set()
server.should_exit = True
time.sleep(0.5)
try:
os.kill(current_pid, signal.SIGTERM)
except Exception:
os._exit(0)
threading.Thread(target=_shutdown_worker, daemon=True).start()
asyncio.create_task(_shutdown_after_response())
return {
"status": "shutting_down",
"message": "Artemis server is shutting down.",
"pid": current_pid,
"pid": os.getpid(),
}
+56
View File
@@ -80,6 +80,62 @@ async def run_task(request: RunRequest):
detail="Either 'goal' or 'goals' list must be provided.",
)
# Idempotent SDK retries must never re-run device readiness checks. A task
# can hold the device while its admission response is lost in transit; in
# that state, probing the same device again may fail or block even though
# the original task was accepted successfully.
if request.session_id and len(incoming_goals) == 1:
requested_sid = str(request.session_id)
existing_item = next(
(
item
for item in state.queue_items
if isinstance(item, dict)
and str(item.get("session_id")) == requested_sid
),
None,
)
persisted_session = session_repo.get_session_by_id(requested_sid)
is_active = (
str(state.active_session_id) == requested_sid
or requested_sid in state.active_connections
)
if existing_item or persisted_session or is_active:
task_payload = dict(existing_item or persisted_session or {})
task_payload.setdefault("session_id", requested_sid)
task_payload.setdefault("goal", incoming_goals[0])
task_payload.setdefault("profile", request.profile or "flash")
task_payload.setdefault("device_serial", request.device_serial)
task_payload.setdefault("status", "running" if is_active else "queued")
return {
"status": task_payload["status"],
"tasks": [task_payload],
"enqueued_count": 0,
"total_queued": len(state.queue_tasks),
}
# Reject an explicit unknown/offline target before running the more
# expensive readiness probe. Besides producing a stable SDK response,
# this avoids probing the currently active device for a serial that can
# never be selected. Only a successful, non-empty enumeration may reject:
# an indeterminate one (adb blip, startup) lets the submission queue and
# fail downstream with a clear error instead.
if request.device_serial:
try:
rejection = await device_pool.validate_explicit_serial_async(
request.device_serial
)
except Exception:
rejection = None
if rejection:
return {
"status": "rejected",
"error": rejection,
"tasks": [],
"enqueued_count": 0,
"total_queued": len(state.queue_tasks),
}
# Re-check immediately before enqueueing so a device locked between UI
# polling intervals cannot start through a stale Ready state. Use the
# bounded submission probe: the full diagnostics path also scans packages,