feat: enable remote SDK and resilient device orchestration
This commit is contained in:
1 parent
3cc711df51
commit
ddfa420945
110 files changed
+14450
-12954
No files matched your search
@@ -18,6 +18,7 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Any
|
||||
import urllib.parse
|
||||
from fastapi import HTTPException
|
||||
@@ -28,6 +29,12 @@ from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
|
||||
class MediaService:
|
||||
"""Service handling media indexing, local file security, payload unwrapping and notes/plans."""
|
||||
|
||||
# Conversion results keyed by source path -> (mtime, size, resolved path).
|
||||
# Failed conversions are cached too (resolved path == source path) so a broken
|
||||
# file doesn't re-run ffmpeg (up to 45s) on every /api/sessions poll.
|
||||
_playable_cache: dict[str, tuple[float, int, str]] = {}
|
||||
_playable_cache_lock = threading.Lock()
|
||||
|
||||
@classmethod
|
||||
def ensure_browser_playable_video(cls, p: Path) -> Path:
|
||||
"""Ensure the video file is in a browser-supported container (MP4).
|
||||
@@ -41,59 +48,88 @@ class MediaService:
|
||||
if suffix == ".mp4":
|
||||
return p
|
||||
if suffix in (".mkv", ".webm"):
|
||||
src_stat = p.stat()
|
||||
cache_key = str(p)
|
||||
cached = cls._playable_cache.get(cache_key)
|
||||
if cached and cached[0] == src_stat.st_mtime and cached[1] == src_stat.st_size:
|
||||
return Path(cached[2])
|
||||
mp4_cand = p.with_suffix(".mp4")
|
||||
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
|
||||
return mp4_cand
|
||||
from artemis.utils.video import get_ffmpeg_path
|
||||
ffmpeg = get_ffmpeg_path()
|
||||
# 1. Attempt fast copy remux
|
||||
subprocess.run(
|
||||
[
|
||||
ffmpeg,
|
||||
"-y",
|
||||
"-fflags",
|
||||
"+genpts",
|
||||
"-i",
|
||||
str(p),
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
cls._playable_cache[cache_key] = (
|
||||
src_stat.st_mtime,
|
||||
src_stat.st_size,
|
||||
str(mp4_cand),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=15,
|
||||
)
|
||||
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
|
||||
return mp4_cand
|
||||
# 2. If copy remux failed, fallback to ultrafast re-encode
|
||||
subprocess.run(
|
||||
[
|
||||
ffmpeg,
|
||||
"-y",
|
||||
"-fflags",
|
||||
"+genpts+discardcorrupt",
|
||||
"-i",
|
||||
str(p),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(mp4_cand),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
|
||||
)
|
||||
return mp4_cand
|
||||
with cls._playable_cache_lock:
|
||||
# Another thread may have converted this file while we waited.
|
||||
cached = cls._playable_cache.get(cache_key)
|
||||
if cached and cached[0] == src_stat.st_mtime and cached[1] == src_stat.st_size:
|
||||
return Path(cached[2])
|
||||
try:
|
||||
result = cls._convert_to_mp4(p, mp4_cand)
|
||||
except Exception:
|
||||
result = p
|
||||
cls._playable_cache[cache_key] = (
|
||||
src_stat.st_mtime,
|
||||
src_stat.st_size,
|
||||
str(result),
|
||||
)
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
return p
|
||||
|
||||
@staticmethod
|
||||
def _convert_to_mp4(p: Path, mp4_cand: Path) -> Path:
|
||||
from artemis.utils.video import get_ffmpeg_path
|
||||
ffmpeg = get_ffmpeg_path()
|
||||
# 1. Attempt fast copy remux
|
||||
subprocess.run(
|
||||
[
|
||||
ffmpeg,
|
||||
"-y",
|
||||
"-fflags",
|
||||
"+genpts",
|
||||
"-i",
|
||||
str(p),
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(mp4_cand),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=15,
|
||||
)
|
||||
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
|
||||
return mp4_cand
|
||||
# 2. If copy remux failed, fallback to ultrafast re-encode
|
||||
subprocess.run(
|
||||
[
|
||||
ffmpeg,
|
||||
"-y",
|
||||
"-fflags",
|
||||
"+genpts+discardcorrupt",
|
||||
"-i",
|
||||
str(p),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(mp4_cand),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
|
||||
return mp4_cand
|
||||
return p
|
||||
|
||||
@staticmethod
|
||||
def path_to_video_url(p: Path) -> str:
|
||||
resolved = p.resolve()
|
||||
@@ -287,30 +323,51 @@ class MediaService:
|
||||
return [MediaService.unwrap_payload(x) for x in obj]
|
||||
return obj
|
||||
|
||||
@staticmethod
|
||||
def get_safe_local_file(path_str: str) -> tuple[Path, str]:
|
||||
_LOCAL_FILE_MEDIA_TYPES = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mkv": "video/x-matroska",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_safe_local_file(cls, path_str: str) -> tuple[Path, str]:
|
||||
"""Serve trace media only: resolved path must sit inside the workspace
|
||||
or traces tree AND carry a media extension, so this endpoint can never
|
||||
hand out .env, databases, or source files."""
|
||||
decoded_path = urllib.parse.unquote(path_str)
|
||||
if decoded_path.startswith("file://"):
|
||||
decoded_path = decoded_path[len("file://") :]
|
||||
p = Path(decoded_path).resolve()
|
||||
try:
|
||||
p = Path(decoded_path).resolve(strict=True)
|
||||
except (OSError, ValueError):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
if not p.is_file():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
if not str(p).startswith(str(WORKSPACE_ROOT)):
|
||||
allowed_roots = []
|
||||
for root in (WORKSPACE_ROOT, TRACES_PATH):
|
||||
try:
|
||||
allowed_roots.append(Path(root).resolve())
|
||||
except OSError:
|
||||
continue
|
||||
if not any(p.is_relative_to(root) for root in allowed_roots):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Access denied. Path is outside workspace root.",
|
||||
)
|
||||
|
||||
ext = p.suffix.lower()
|
||||
media_type = "application/octet-stream"
|
||||
if ext in (".jpg", ".jpeg"):
|
||||
media_type = "image/jpeg"
|
||||
elif ext == ".png":
|
||||
media_type = "image/png"
|
||||
elif ext == ".mp4":
|
||||
media_type = "video/mp4"
|
||||
media_type = cls._LOCAL_FILE_MEDIA_TYPES.get(p.suffix.lower())
|
||||
if media_type is None:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Access denied. Only media files are served.",
|
||||
)
|
||||
|
||||
return p, media_type
|
||||
|
||||
|
||||
@@ -13,18 +13,27 @@
|
||||
# limitations under the License.
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ModelService:
|
||||
"""Service handling agent architecture profile detection and metadata formatting."""
|
||||
|
||||
@staticmethod
|
||||
def get_active_model_info(profile: str | None = None) -> dict[str, str]:
|
||||
"""Return active architecture and underlying LLM model configuration."""
|
||||
# 1. Determine underlying LLM model and provider from config
|
||||
model_id = "gemini-3.7-flash"
|
||||
# parse_llm_config re-reads and re-validates the config file on every call;
|
||||
# /api/sessions calls this once per session row, so cache (provider, model_id)
|
||||
# for a short TTL. Config edits take effect within _LLM_INFO_TTL seconds.
|
||||
_LLM_INFO_TTL = 10.0
|
||||
_llm_info_cache: tuple[float, str, str] | None = None
|
||||
|
||||
@classmethod
|
||||
def _get_llm_provider_and_model(cls) -> tuple[str, str]:
|
||||
cached = cls._llm_info_cache
|
||||
now = time.monotonic()
|
||||
if cached and now - cached[0] < cls._LLM_INFO_TTL:
|
||||
return cached[1], cached[2]
|
||||
provider = "google"
|
||||
model_id = "gemini-3.7-flash"
|
||||
try:
|
||||
from artemis.config import parse_llm_config
|
||||
|
||||
@@ -37,6 +46,14 @@ class ModelService:
|
||||
model_id = str(llm_cfg.default.model)
|
||||
except Exception:
|
||||
pass
|
||||
cls._llm_info_cache = (now, provider, model_id)
|
||||
return provider, model_id
|
||||
|
||||
@classmethod
|
||||
def get_active_model_info(cls, profile: str | None = None) -> dict[str, str]:
|
||||
"""Return active architecture and underlying LLM model configuration."""
|
||||
# 1. Determine underlying LLM model and provider from config (cached)
|
||||
provider, model_id = cls._get_llm_provider_and_model()
|
||||
|
||||
# 2. Determine agent architecture name (Flash vs Pro)
|
||||
arch_name = "Flash"
|
||||
|
||||
@@ -16,6 +16,7 @@ import asyncio
|
||||
import codecs
|
||||
from datetime import datetime
|
||||
import os
|
||||
import psutil
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -38,7 +39,13 @@ from artemis.config import (
|
||||
TEST_OUTPUTS_DIR,
|
||||
WORKSPACE_ROOT,
|
||||
)
|
||||
from artemis.runtime import DeviceExecutionLock, process_supervisor
|
||||
from artemis.runtime import (
|
||||
AdbEndpoint,
|
||||
AdbTarget,
|
||||
DeviceExecutionLock,
|
||||
current_adb_endpoint,
|
||||
process_supervisor,
|
||||
)
|
||||
|
||||
|
||||
class TaskQueueService:
|
||||
@@ -50,6 +57,17 @@ class TaskQueueService:
|
||||
# keeps weak references to running tasks).
|
||||
_run_tasks: set[asyncio.Task] = set()
|
||||
|
||||
@staticmethod
|
||||
def _task_target(task_item: dict[str, Any]) -> AdbTarget:
|
||||
endpoint_data = task_item.get("adb_endpoint")
|
||||
endpoint = (
|
||||
AdbEndpoint.from_mapping(endpoint_data)
|
||||
if isinstance(endpoint_data, dict)
|
||||
else current_adb_endpoint()
|
||||
)
|
||||
serial = task_item.get("device_serial")
|
||||
return AdbTarget(endpoint=endpoint, serial=str(serial) if serial else None)
|
||||
|
||||
@classmethod
|
||||
def _broadcast_event(cls, event_type: str, data: Any):
|
||||
"""Broadcasts an event safely to all registered subscribers."""
|
||||
@@ -241,8 +259,6 @@ class TaskQueueService:
|
||||
pid = getattr(proc, "pid", None)
|
||||
if pid:
|
||||
try:
|
||||
import psutil
|
||||
|
||||
p = psutil.Process(pid)
|
||||
if not p.is_running() or p.status() == psutil.STATUS_ZOMBIE:
|
||||
return proc.returncode if proc.returncode is not None else -15
|
||||
@@ -345,7 +361,7 @@ class TaskQueueService:
|
||||
if isinstance(i, dict) and i.get("status") == "running"
|
||||
]
|
||||
busy_devices = state.busy_device_ids | {
|
||||
str(i.get("device_serial")) for i in in_flight if i.get("device_serial")
|
||||
cls._task_target(i).lock_key for i in in_flight if i.get("device_serial")
|
||||
}
|
||||
dispatched_any = False
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -358,19 +374,20 @@ class TaskQueueService:
|
||||
continue
|
||||
|
||||
device = item.get("device_serial")
|
||||
target = cls._task_target(item)
|
||||
if mode == "per_device":
|
||||
# A task without a resolved device may bind to any serial, so it
|
||||
# only launches on an otherwise idle scheduler; the device lock
|
||||
# then allocates freely without contending against active runs.
|
||||
if device is None and (state.active_runs or in_flight or dispatched_any):
|
||||
continue
|
||||
if device is not None and str(device) in busy_devices:
|
||||
if device is not None and target.lock_key in busy_devices:
|
||||
continue
|
||||
|
||||
item["status"] = "running"
|
||||
dispatched_any = True
|
||||
if device is not None:
|
||||
busy_devices.add(str(device))
|
||||
busy_devices.add(target.lock_key)
|
||||
run_task = loop.create_task(cls._execute_task_item(item))
|
||||
# Hold a strong reference: asyncio keeps only weak refs to running
|
||||
# tasks, and a collected run would strand its queue item forever.
|
||||
@@ -389,6 +406,8 @@ class TaskQueueService:
|
||||
proc: asyncio.subprocess.Process | None = None
|
||||
output_task: asyncio.Task[None] | None = None
|
||||
try:
|
||||
if not isinstance(goal, str) or not goal.strip():
|
||||
raise ValueError("Queued task must contain a non-empty string goal.")
|
||||
expected_output = task_item.get("expected_output")
|
||||
enable_outputter = task_item.get("enable_outputter")
|
||||
locked_app = task_item.get("locked_app_package") or task_item.get("locked_app")
|
||||
@@ -436,6 +455,9 @@ class TaskQueueService:
|
||||
env["ARTEMIS_SESSION_ID"] = str(sess_id)
|
||||
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
|
||||
env["ARTEMIS_TASK_WORKER"] = "1"
|
||||
target = cls._task_target(task_item)
|
||||
target.endpoint.apply_to_environment(env)
|
||||
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
|
||||
queue_ticket = task_item.get("queue_ticket")
|
||||
if queue_ticket:
|
||||
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = str(queue_ticket)
|
||||
@@ -479,6 +501,8 @@ class TaskQueueService:
|
||||
state.active_runs[run_key] = {
|
||||
"process": proc,
|
||||
"device_id": str(device_serial) if device_serial else None,
|
||||
"lock_key": target.lock_key if device_serial else None,
|
||||
"adb_endpoint": target.endpoint.to_dict(),
|
||||
"goal": goal,
|
||||
"profile": profile,
|
||||
}
|
||||
@@ -503,6 +527,7 @@ class TaskQueueService:
|
||||
device_id=device_serial or "pending",
|
||||
session_id=str(sess_id) if sess_id else None,
|
||||
ingress=ingress_type,
|
||||
lock_scope=target.lock_scope,
|
||||
)
|
||||
# Forward the worker's combined output and tee it into the trace's
|
||||
# stdout.log so the log paths advertised by the MCP API exist.
|
||||
@@ -667,6 +692,7 @@ class TaskQueueService:
|
||||
|
||||
enqueued_tasks = []
|
||||
now = time.time()
|
||||
endpoint = current_adb_endpoint()
|
||||
|
||||
# 1. Deduplication by session_id: if session_id is already running or queued, do not re-enqueue
|
||||
if session_id and len(goals) == 1:
|
||||
@@ -705,6 +731,7 @@ class TaskQueueService:
|
||||
and item.get("status") == "pending"
|
||||
and item.get("goal") == first_goal
|
||||
and (not device_serial or item.get("device_serial") == device_serial)
|
||||
and item.get("adb_endpoint", {}).get("identity") == endpoint.identity
|
||||
and (now - float(item.get("created_at", 0))) < 1.0
|
||||
),
|
||||
None,
|
||||
@@ -719,31 +746,19 @@ class TaskQueueService:
|
||||
|
||||
# Strict device binding: reject an explicitly requested serial that is not
|
||||
# attached and authorized, instead of silently running on another device.
|
||||
# The shared validator fails open on an indeterminate/empty enumeration so
|
||||
# the task can proceed and fail downstream with a clear no-device error.
|
||||
if device_serial:
|
||||
try:
|
||||
from artemis.runtime import device_pool
|
||||
|
||||
norm = DeviceExecutionLock._normalize_device_id
|
||||
attached = {
|
||||
norm(d.serial): d.state
|
||||
for d in await device_pool.list_devices_async()
|
||||
}
|
||||
rejection = await device_pool.validate_explicit_serial_async(device_serial)
|
||||
except Exception:
|
||||
attached = None
|
||||
# Empty enumeration (no adb / transient failure) skips validation so the
|
||||
# task can proceed and fail downstream with a clear no-device error,
|
||||
# mirroring mcp_server.tools.task_runner._validate_device_serial.
|
||||
if attached and attached.get(norm(device_serial)) != "device":
|
||||
dev_state = attached.get(norm(device_serial))
|
||||
error_detail = (
|
||||
f"Device '{device_serial}' is not connected. "
|
||||
f"Attached devices: {sorted(attached) if attached else 'none'}."
|
||||
if dev_state is None
|
||||
else f"Device '{device_serial}' is attached but not ready (state: '{dev_state}')."
|
||||
)
|
||||
rejection = None
|
||||
if rejection:
|
||||
return {
|
||||
"status": "rejected",
|
||||
"error": error_detail,
|
||||
"error": rejection,
|
||||
"tasks": [],
|
||||
"enqueued_count": 0,
|
||||
"total_queued": len(state.queue_tasks),
|
||||
@@ -772,6 +787,7 @@ class TaskQueueService:
|
||||
device_id=assigned_serial or "pending",
|
||||
session_id=sess_id,
|
||||
ingress=ingress,
|
||||
lock_scope=endpoint.identity,
|
||||
)
|
||||
task_item = {
|
||||
"session_id": sess_id,
|
||||
@@ -782,6 +798,7 @@ class TaskQueueService:
|
||||
"locked_app_package": locked_app_package,
|
||||
"app_path": app_path,
|
||||
"device_serial": assigned_serial,
|
||||
"adb_endpoint": endpoint.to_dict(),
|
||||
"ingress": ingress,
|
||||
"conversation_id": conversation_id,
|
||||
"status": "pending",
|
||||
|
||||
Reference in new issue
Block a user