feat(console): add Artemis Admin & Trace Console backend and management plane
Introduces the comprehensive developer/administrator control plane and trace
inspection console backend for the ARTEMIS mobile automation platform.
Key Features & Backend Architecture:
- Core Server & Modular Routing:
* FastAPI modular architecture with dedicated routers for sessions, steps, tasks, media, stream, and replay.
* Multi-layer repository pattern with SQLite persistence for sessions, traces, and step data.
- Task Control & Queue Management:
* FIFO asynchronous task queue service with support for single/batch goal execution and profile selection (Flash / Pro).
* Session lifecycle controls (run, pause, resume, stop, clear queue).
* Startup orphan cleanup and automatic older replay archiving.
- Live Telemetry & IPC Communication:
* High-performance IPC server for streaming real-time execution logs, agent thoughts, and tool invocations.
* Server-Sent Events (SSE) /api/stream/{session_id} endpoint for live UI updates.
- Real-Time Device Streaming:
* Live ADB screen capture and MJPEG frame generator (/api/stream/device-live).
* Device connectivity and state inspection endpoint (/api/stream/device-state).
- Trace Inspection & Step Replay Engine:
* Hierarchical call tree inspection, pre/post step screenshots, and action overlay metadata.
* Isolated single-step replay manager (/api/replay) for interactive debugging and validation.
This commit is contained in:
1 parent
2fd70465e6
commit
c1803fcb86
29 files changed
+8889
No files matched your search
@@ -0,0 +1 @@
|
||||
# Copyright 2026 Google LLC
|
||||
@@ -0,0 +1,133 @@
|
||||
# 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.
|
||||
|
||||
"""Device Live Screen Streaming Service.
|
||||
|
||||
Provides real-time, low-latency device screen frames over HTTP MJPEG and WebSocket.
|
||||
Operates concurrently with ADB agent actions with zero interference.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
logger = logging.getLogger("artemis.stream_service")
|
||||
|
||||
|
||||
class DeviceStreamService:
|
||||
"""Manages real-time screen capture and distribution to web clients."""
|
||||
|
||||
def __init__(self):
|
||||
self._active_listeners = 0
|
||||
self._lock = asyncio.Lock()
|
||||
self._latest_frame: bytes | None = None
|
||||
self._last_frame_time: float = 0.0
|
||||
self._is_capturing = False
|
||||
self._capture_task: asyncio.Task | None = None
|
||||
|
||||
async def get_device_serial(self) -> str | None:
|
||||
"""Find the currently connected active ADB device serial."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"adb",
|
||||
"devices",
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
lines = stdout.decode().strip().splitlines()
|
||||
for line in lines[1:]:
|
||||
parts = line.strip().split()
|
||||
if len(parts) >= 2 and parts[1] == "device":
|
||||
return parts[0]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking adb devices: {e}")
|
||||
return None
|
||||
|
||||
async def _capture_loop(self):
|
||||
"""Background frame capture loop that runs while listeners > 0."""
|
||||
logger.info("[StreamService] Starting live screen capture loop...")
|
||||
while self._active_listeners > 0:
|
||||
try:
|
||||
start_t = time.time()
|
||||
serial = await self.get_device_serial()
|
||||
cmd = (
|
||||
["adb", "-s", serial, "exec-out", "screencap", "-p"]
|
||||
if serial
|
||||
else ["adb", "exec-out", "screencap", "-p"]
|
||||
)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
if proc.returncode == 0 and len(stdout) > 1000:
|
||||
self._latest_frame = stdout
|
||||
self._last_frame_time = time.time()
|
||||
|
||||
elapsed = time.time() - start_t
|
||||
delay = max(0.03, 0.08 - elapsed)
|
||||
await asyncio.sleep(delay)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"[StreamService] Capture error: {e}")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
logger.info("[StreamService] Stopping live screen capture loop (0 listeners).")
|
||||
self._is_capturing = False
|
||||
|
||||
async def start_capturing(self):
|
||||
"""Register a new listener and start background capture if needed."""
|
||||
async with self._lock:
|
||||
self._active_listeners += 1
|
||||
if not self._is_capturing or self._capture_task is None or self._capture_task.done():
|
||||
self._is_capturing = True
|
||||
self._capture_task = asyncio.create_task(self._capture_loop())
|
||||
|
||||
async def stop_capturing(self):
|
||||
"""Deregister a listener and stop capture loop when count reaches 0."""
|
||||
async with self._lock:
|
||||
self._active_listeners = max(0, self._active_listeners - 1)
|
||||
if self._active_listeners == 0 and self._capture_task and not self._capture_task.done():
|
||||
self._capture_task.cancel()
|
||||
self._is_capturing = False
|
||||
|
||||
async def mjpeg_frame_generator(self) -> AsyncGenerator[bytes, None]:
|
||||
"""Async generator streaming MJPEG multipart bytes to HTTP response."""
|
||||
await self.start_capturing()
|
||||
try:
|
||||
last_sent_time = 0.0
|
||||
while True:
|
||||
if self._latest_frame and self._last_frame_time > last_sent_time:
|
||||
last_sent_time = self._last_frame_time
|
||||
frame_bytes = self._latest_frame
|
||||
yield (
|
||||
b"--frame\r\n"
|
||||
b"Content-Type: image/png\r\n"
|
||||
b"Content-Length: "
|
||||
+ str(len(frame_bytes)).encode()
|
||||
+ b"\r\n\r\n"
|
||||
+ frame_bytes
|
||||
+ b"\r\n"
|
||||
)
|
||||
await asyncio.sleep(0.04)
|
||||
finally:
|
||||
await self.stop_capturing()
|
||||
|
||||
|
||||
device_stream_service = DeviceStreamService()
|
||||
@@ -0,0 +1,204 @@
|
||||
# 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.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from admin_console.core.state import state
|
||||
except ImportError:
|
||||
from apps.admin_console.core.state import state
|
||||
|
||||
|
||||
class IPCService:
|
||||
"""Service managing internal IPC socket server and event distribution."""
|
||||
|
||||
@staticmethod
|
||||
def _clean_value(val: Any) -> Any:
|
||||
"""Recursively cleans values to remove non-serializable and internal
|
||||
Python runtime representations.
|
||||
"""
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, (bool, int, float)):
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
if (
|
||||
"object at 0x" in val
|
||||
or val.startswith("<artemis.")
|
||||
or val.startswith("<controller")
|
||||
):
|
||||
return None
|
||||
return val
|
||||
if isinstance(val, dict):
|
||||
cleaned = {}
|
||||
for k, v in val.items():
|
||||
cv = IPCService._clean_value(v)
|
||||
if cv is not None:
|
||||
cleaned[k] = cv
|
||||
return cleaned
|
||||
if isinstance(val, (list, tuple)):
|
||||
cleaned_list = []
|
||||
for item in val:
|
||||
cv = IPCService._clean_value(item)
|
||||
if cv is not None:
|
||||
cleaned_list.append(cv)
|
||||
return cleaned_list
|
||||
val_str = str(val)
|
||||
if "object at 0x" in val_str or val_str.startswith("<"):
|
||||
return None
|
||||
return val_str
|
||||
|
||||
@classmethod
|
||||
def sanitize_event_data(cls, event_type: str, data: Any) -> Any:
|
||||
"""Sanitizes stream event payloads for all clients, stripping bulky
|
||||
raw bytes and internal memory references.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
if event_type == "trace_recorded":
|
||||
tr_type = data.get("type")
|
||||
payload = data.get("payload")
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = json.loads(payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if tr_type == "tool" and isinstance(payload, dict):
|
||||
args = payload.get("args", payload)
|
||||
data["payload"] = {"args": cls._clean_value(args)}
|
||||
elif tr_type == "llm_call" and isinstance(payload, dict):
|
||||
data["payload"] = {"error": cls._clean_value(payload.get("error"))}
|
||||
elif payload is not None:
|
||||
data["payload"] = cls._clean_value(payload)
|
||||
|
||||
elif event_type in ("step_recorded", "step_updated"):
|
||||
# Strip heavy raw image bytes from SSE broadcast (clients fetch via image URLs)
|
||||
data["pre_screenshot_bytes"] = None
|
||||
data["post_screenshot_bytes"] = None
|
||||
|
||||
if "generic_tools" in data and isinstance(data["generic_tools"], list):
|
||||
sanitized_tools = []
|
||||
for tr in data["generic_tools"]:
|
||||
if not isinstance(tr, dict):
|
||||
continue
|
||||
tr_dict = dict(tr)
|
||||
pl = tr_dict.get("payload")
|
||||
if isinstance(pl, str):
|
||||
try:
|
||||
pl = json.loads(pl)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if tr_dict.get("type") == "llm_call":
|
||||
tr_dict["payload"] = (
|
||||
{"error": cls._clean_value(pl.get("error"))}
|
||||
if isinstance(pl, dict)
|
||||
else None
|
||||
)
|
||||
elif isinstance(pl, dict):
|
||||
args = pl.get("args", pl)
|
||||
tr_dict["payload"] = {"args": cls._clean_value(args)}
|
||||
else:
|
||||
tr_dict["payload"] = cls._clean_value(pl)
|
||||
sanitized_tools.append(tr_dict)
|
||||
data["generic_tools"] = sanitized_tools
|
||||
|
||||
try:
|
||||
from artemis.utils.coordinates import normalize_step_actions
|
||||
|
||||
data = normalize_step_actions(data)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return data
|
||||
|
||||
# Backward compatibility alias
|
||||
filter_event_for_angular = sanitize_event_data
|
||||
|
||||
@classmethod
|
||||
async def start_server(cls):
|
||||
async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
current_session_id = None
|
||||
while True:
|
||||
line = await reader.readline()
|
||||
if not line:
|
||||
break
|
||||
try:
|
||||
payload = json.loads(line.decode("utf-8"))
|
||||
event_type = payload.get("event_type")
|
||||
data = payload.get("data")
|
||||
|
||||
if event_type == "session_started" and data:
|
||||
state.active_session_id = data.get("session_id")
|
||||
current_session_id = data.get("session_id")
|
||||
pid = data.get("pid")
|
||||
goal = data.get("initial_goal")
|
||||
device_info = data.get("device_info")
|
||||
if isinstance(device_info, dict) and device_info.get("profile"):
|
||||
state.current_profile = device_info.get("profile")
|
||||
if goal:
|
||||
state.current_goal = goal
|
||||
print(
|
||||
f"[IPC] Session started: {state.active_session_id} (profile: {state.current_profile})"
|
||||
)
|
||||
if current_session_id:
|
||||
state.active_connections[str(current_session_id)] = {
|
||||
"writer": writer,
|
||||
"pid": pid,
|
||||
"goal": goal,
|
||||
"profile": state.current_profile,
|
||||
}
|
||||
elif event_type == "session_ended" and data:
|
||||
ended_sid = data.get("session_id")
|
||||
if state.active_session_id and str(state.active_session_id) == str(
|
||||
ended_sid
|
||||
):
|
||||
state.active_session_id = None
|
||||
state.current_goal = None
|
||||
state.current_profile = None
|
||||
|
||||
for cb in list(state.ipc_subscribers):
|
||||
try:
|
||||
cb(event_type, data)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"IPC parse error: {e}")
|
||||
|
||||
if current_session_id and str(current_session_id) in state.active_connections:
|
||||
try:
|
||||
del state.active_connections[str(current_session_id)]
|
||||
except KeyError:
|
||||
pass
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
server = await asyncio.start_server(handle_client, "127.0.0.1", 0, limit=1024 * 1024 * 100)
|
||||
state.ipc_port = server.sockets[0].getsockname()[1]
|
||||
state.ipc_server = server
|
||||
asyncio.create_task(server.serve_forever())
|
||||
print(f"Internal IPC server started on port {state.ipc_port}")
|
||||
try:
|
||||
from artemis.config import write_ipc_port
|
||||
|
||||
write_ipc_port(state.ipc_port)
|
||||
except Exception as e:
|
||||
print(f"Failed to write IPC port file: {e}")
|
||||
|
||||
|
||||
ipc_service = IPCService()
|
||||
@@ -0,0 +1,261 @@
|
||||
# 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.
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import urllib.parse
|
||||
from fastapi import HTTPException
|
||||
|
||||
try:
|
||||
from admin_console.core.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
|
||||
except ImportError:
|
||||
from apps.admin_console.core.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
|
||||
|
||||
|
||||
class MediaService:
|
||||
"""Service handling media indexing, local file security, payload unwrapping and notes/plans."""
|
||||
|
||||
@staticmethod
|
||||
def path_to_video_url(p: Path) -> str:
|
||||
resolved = p.resolve()
|
||||
try:
|
||||
rel = resolved.relative_to(WORKSPACE_ROOT)
|
||||
return f"/videos/{rel}"
|
||||
except ValueError:
|
||||
return f"/videos/{str(resolved).lstrip('/')}"
|
||||
|
||||
@classmethod
|
||||
def build_video_index(cls) -> dict[str, str]:
|
||||
idx = {}
|
||||
dirs = [
|
||||
TRACES_PATH,
|
||||
WORKSPACE_ROOT / "artemis-traces",
|
||||
WORKSPACE_ROOT / ".benchmarks" / "diagnoser" / "outputs" / "artifacts",
|
||||
]
|
||||
for d in dirs:
|
||||
if not d.exists():
|
||||
continue
|
||||
try:
|
||||
for item in d.iterdir():
|
||||
if item.is_dir():
|
||||
for ext in [".mp4", ".mkv", ".webm"]:
|
||||
vfile = item / f"recording{ext}"
|
||||
if vfile.exists():
|
||||
url = cls.path_to_video_url(vfile)
|
||||
idx[item.name] = url
|
||||
prefix = item.name.split("_PASS_")[0].split("_FAIL_")[0]
|
||||
idx[prefix] = url
|
||||
break
|
||||
elif item.suffix in [".mp4", ".mkv", ".webm"]:
|
||||
url = cls.path_to_video_url(item)
|
||||
idx[item.stem] = url
|
||||
idx[item.name] = url
|
||||
except Exception:
|
||||
pass
|
||||
return idx
|
||||
|
||||
@classmethod
|
||||
def resolve_video_url(
|
||||
cls,
|
||||
row_dict: dict[str, Any],
|
||||
video_rec_map: dict[str, str],
|
||||
video_idx: dict[str, str],
|
||||
) -> str | None:
|
||||
raw_sid = row_dict.get("session_id")
|
||||
s_id = (
|
||||
str(raw_sid).strip()
|
||||
if raw_sid is not None and str(raw_sid).strip().lower() != "none"
|
||||
else ""
|
||||
)
|
||||
v_url = None
|
||||
v_fp = row_dict.get("video_filepath")
|
||||
if v_fp and os.path.exists(v_fp):
|
||||
v_url = cls.path_to_video_url(Path(v_fp))
|
||||
|
||||
orig_rec = video_rec_map.get(s_id)
|
||||
if not v_url and orig_rec:
|
||||
p = Path(orig_rec)
|
||||
if p.exists():
|
||||
v_url = cls.path_to_video_url(p)
|
||||
else:
|
||||
folder_name = p.parent.name
|
||||
v_url = video_idx.get(folder_name) or video_idx.get(folder_name.split("_")[0])
|
||||
|
||||
if not v_url:
|
||||
v_url = video_idx.get(s_id)
|
||||
|
||||
if not v_url and s_id:
|
||||
sess_dir = TRACES_PATH / s_id
|
||||
if sess_dir.is_dir():
|
||||
for ext in [".mp4", ".webm", ".mkv"]:
|
||||
vfile = sess_dir / f"recording{ext}"
|
||||
if vfile.exists():
|
||||
v_url = cls.path_to_video_url(vfile)
|
||||
break
|
||||
if not v_url:
|
||||
try:
|
||||
for item in sess_dir.iterdir():
|
||||
if item.is_file() and item.suffix.lower() in [".mp4", ".webm", ".mkv"]:
|
||||
v_url = cls.path_to_video_url(item)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not v_url and row_dict.get("initial_goal"):
|
||||
goal_str = row_dict["initial_goal"]
|
||||
task_hint = (
|
||||
goal_str.split("Task: ")[1].split()[0].strip()
|
||||
if "Task: " in goal_str
|
||||
else goal_str.split("\n")[0].strip()
|
||||
)
|
||||
v_url = video_idx.get(task_hint)
|
||||
|
||||
return v_url
|
||||
|
||||
@staticmethod
|
||||
def unwrap_payload(obj: Any) -> Any:
|
||||
"""Unwraps trace payloads, storing base64 images as disk files and
|
||||
truncating large dumps.
|
||||
"""
|
||||
if isinstance(obj, str):
|
||||
try:
|
||||
if (obj.startswith("{") and obj.endswith("}")) or (
|
||||
obj.startswith("[") and obj.endswith("]")
|
||||
):
|
||||
return MediaService.unwrap_payload(json.loads(obj))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check if the string is a Base64 image
|
||||
is_base64_img = False
|
||||
base64_data = None
|
||||
if obj.startswith("data:image/") and ";base64," in obj:
|
||||
try:
|
||||
_, base64_data = obj.split(";base64,", 1)
|
||||
is_base64_img = True
|
||||
except Exception:
|
||||
pass
|
||||
elif obj.startswith("iVBORw0KGgo") or obj.startswith("/9j/"):
|
||||
is_base64_img = True
|
||||
base64_data = obj
|
||||
|
||||
if is_base64_img and base64_data:
|
||||
try:
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
image_hash = hashlib.sha256(image_bytes).hexdigest()
|
||||
|
||||
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
image_path = IMAGES_DIR / f"{image_hash}.jpg"
|
||||
if not image_path.exists():
|
||||
image_path.write_bytes(image_bytes)
|
||||
|
||||
return f"image://{image_hash}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if len(obj) > 2000:
|
||||
preview_start = obj[:100].replace("\n", " ")
|
||||
preview_end = obj[-100:].replace("\n", " ")
|
||||
return (
|
||||
f"<Massive String: {preview_start}...[truncated"
|
||||
f" {len(obj) - 200} characters for UI performance]...{preview_end}"
|
||||
f" (length={len(obj)})>"
|
||||
)
|
||||
return obj
|
||||
elif isinstance(obj, dict):
|
||||
return {k: MediaService.unwrap_payload(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
if len(obj) > 20 and any(
|
||||
isinstance(x, dict) and ("bounds" in x or "resource-id" in x) for x in obj
|
||||
):
|
||||
return [f"<XML UI List with {len(obj)} elements truncated for UI performance>"]
|
||||
return [MediaService.unwrap_payload(x) for x in obj]
|
||||
return obj
|
||||
|
||||
@staticmethod
|
||||
def get_safe_local_file(path_str: str) -> tuple[Path, str]:
|
||||
decoded_path = urllib.parse.unquote(path_str)
|
||||
if decoded_path.startswith("file://"):
|
||||
decoded_path = decoded_path[len("file://") :]
|
||||
p = Path(decoded_path).resolve()
|
||||
|
||||
if not p.is_file():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
if not str(p).startswith(str(WORKSPACE_ROOT)):
|
||||
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"
|
||||
|
||||
return p, media_type
|
||||
|
||||
@staticmethod
|
||||
def get_task_plan_content(session_id: str) -> str:
|
||||
plan_path = TRACES_PATH / session_id / "notes" / "task_plan.md"
|
||||
if not plan_path.exists():
|
||||
plan_path = TRACES_PATH / "notes" / "task_plan.md"
|
||||
|
||||
if plan_path.exists():
|
||||
try:
|
||||
return plan_path.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
return f"Error reading task plan: {e}"
|
||||
return "No task plan created yet."
|
||||
|
||||
@staticmethod
|
||||
def get_session_notes_content(session_id: str) -> dict[str, str]:
|
||||
notes_dir = TRACES_PATH / session_id / "notes"
|
||||
notes_content = {}
|
||||
|
||||
if notes_dir.exists() and notes_dir.is_dir():
|
||||
for file in notes_dir.iterdir():
|
||||
if file.is_file() and file.suffix in [".md", ".txt", ".json", ".yaml"]:
|
||||
try:
|
||||
notes_content[file.name] = file.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
notes_content[file.name] = f"Error reading file: {e}"
|
||||
|
||||
if not notes_content:
|
||||
global_notes_dir = TRACES_PATH / "notes"
|
||||
if global_notes_dir.exists() and global_notes_dir.is_dir():
|
||||
for file in global_notes_dir.iterdir():
|
||||
if file.is_file() and file.suffix in [
|
||||
".md",
|
||||
".txt",
|
||||
".json",
|
||||
".yaml",
|
||||
]:
|
||||
try:
|
||||
notes_content[file.name] = file.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
notes_content[file.name] = f"Error reading file: {e}"
|
||||
|
||||
return notes_content
|
||||
|
||||
|
||||
media_service = MediaService()
|
||||
@@ -0,0 +1,133 @@
|
||||
# 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.
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ModelService:
|
||||
"""Service handling model profile detection and metadata formatting."""
|
||||
|
||||
@staticmethod
|
||||
def get_active_model_info(profile: str | None = None) -> dict[str, str]:
|
||||
if profile:
|
||||
p_lower = str(profile).lower()
|
||||
if "pro" in p_lower:
|
||||
return {
|
||||
"name": "Pro",
|
||||
"id": "gemini-3.6-pro",
|
||||
"provider": "google",
|
||||
}
|
||||
elif "flash" in p_lower:
|
||||
return {
|
||||
"name": "Flash",
|
||||
"id": "gemini-3.6-flash",
|
||||
"provider": "google",
|
||||
}
|
||||
|
||||
model_name = "Flash"
|
||||
model_id = "gemini-3.6-flash"
|
||||
provider = "google"
|
||||
try:
|
||||
from artemis.config import parse_llm_config
|
||||
|
||||
llm_cfg = parse_llm_config()
|
||||
if llm_cfg and llm_cfg.operator:
|
||||
op_model = str(llm_cfg.operator.model).lower()
|
||||
provider = str(llm_cfg.operator.provider)
|
||||
model_id = llm_cfg.operator.model
|
||||
if "pro" in op_model:
|
||||
model_name = "Pro"
|
||||
elif "flash" in op_model:
|
||||
model_name = "Flash"
|
||||
else:
|
||||
model_name = llm_cfg.operator.model.capitalize()
|
||||
except Exception:
|
||||
pass
|
||||
return {"name": model_name, "id": model_id, "provider": provider}
|
||||
|
||||
@staticmethod
|
||||
def resolve_session_profile(
|
||||
row_dict: dict[str, Any],
|
||||
llm_trace_payloads: list[str] | None = None,
|
||||
running_profile: str | None = None,
|
||||
agent_names: list[str] | None = None,
|
||||
) -> str | None:
|
||||
sess_profile = None
|
||||
d_info_raw = row_dict.get("device_info")
|
||||
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):
|
||||
p = d_info.get("profile")
|
||||
if p:
|
||||
p_str = str(p).lower()
|
||||
if "pro" in p_str:
|
||||
return "pro"
|
||||
elif "flash" in p_str:
|
||||
return "flash"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if (row_dict.get("status") == "running") and running_profile:
|
||||
p_str = str(running_profile).lower()
|
||||
if "pro" in p_str:
|
||||
return "pro"
|
||||
elif "flash" in p_str:
|
||||
return "flash"
|
||||
|
||||
# Check Agent/Trace names - the most definitive indicator of architecture
|
||||
if agent_names:
|
||||
agent_names_lower = [str(a).lower() for a in agent_names]
|
||||
if any(
|
||||
name in ("planner", "validator", "summarizer", "operator", "checker", "diagnoser")
|
||||
for name in agent_names_lower
|
||||
):
|
||||
return "pro"
|
||||
if any("flashrunner" in name for name in agent_names_lower):
|
||||
return "flash"
|
||||
|
||||
# Check LLM traces for agent name or model overrides
|
||||
if llm_trace_payloads:
|
||||
for tr_payload in llm_trace_payloads:
|
||||
if tr_payload:
|
||||
try:
|
||||
p_obj = (
|
||||
json.loads(tr_payload) if isinstance(tr_payload, str) else tr_payload
|
||||
)
|
||||
if isinstance(p_obj, dict):
|
||||
agent_name = str(p_obj.get("agent") or p_obj.get("name") or "").lower()
|
||||
if "flashrunner" in agent_name:
|
||||
return "flash"
|
||||
if any(
|
||||
x in agent_name
|
||||
for x in ("planner", "validator", "operator", "checker")
|
||||
):
|
||||
return "pro"
|
||||
|
||||
m_str = str(p_obj.get("model") or p_obj.get("model_name") or "").lower()
|
||||
if (
|
||||
"gemini-3.6-pro" in m_str
|
||||
or "gemini-1.5-pro" in m_str
|
||||
or "gpt-4" in m_str
|
||||
or "claude" in m_str
|
||||
):
|
||||
return "pro"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
model_service = ModelService()
|
||||
@@ -0,0 +1,256 @@
|
||||
# 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.
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
try:
|
||||
from admin_console.core.config import (
|
||||
PAUSE_FILE,
|
||||
TEST_DATA_DIR,
|
||||
TEST_OUTPUTS_DIR,
|
||||
WORKSPACE_ROOT,
|
||||
)
|
||||
from admin_console.core.state import state
|
||||
from admin_console.database.repositories.session_repository import session_repo
|
||||
except ImportError:
|
||||
from apps.admin_console.core.config import (
|
||||
PAUSE_FILE,
|
||||
TEST_DATA_DIR,
|
||||
TEST_OUTPUTS_DIR,
|
||||
WORKSPACE_ROOT,
|
||||
)
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
|
||||
|
||||
class TaskQueueService:
|
||||
"""Service managing FIFO task execution, background worker, subprocess lifecycle,
|
||||
and startup tasks.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
async def queue_worker(cls):
|
||||
while True:
|
||||
goal = None
|
||||
sess_id = None
|
||||
try:
|
||||
item = await state.task_queue.get()
|
||||
if isinstance(item, dict):
|
||||
goal = item.get("goal")
|
||||
profile = item.get("profile", "flash")
|
||||
sess_id = item.get("session_id")
|
||||
else:
|
||||
goal = item
|
||||
profile = "flash"
|
||||
sess_id = None
|
||||
|
||||
state.current_goal = goal
|
||||
state.current_profile = profile
|
||||
if sess_id:
|
||||
state.active_session_id = sess_id
|
||||
|
||||
# Remove from state.queue_tasks
|
||||
state.queue_tasks = [
|
||||
t
|
||||
for t in state.queue_tasks
|
||||
if not (
|
||||
isinstance(t, dict)
|
||||
and (t.get("session_id") == sess_id or t.get("goal") == goal)
|
||||
)
|
||||
]
|
||||
|
||||
test_name = f"web_{int(time.time())}"
|
||||
env = os.environ.copy()
|
||||
pythonpath_parts = [
|
||||
str(WORKSPACE_ROOT),
|
||||
str(WORKSPACE_ROOT / "apps/admin_console"),
|
||||
str(WORKSPACE_ROOT / "apps/cloud_service"),
|
||||
env.get("PYTHONPATH", ""),
|
||||
]
|
||||
if state.ipc_port is not None:
|
||||
env["ARTEMIS_IPC_PORT"] = str(state.ipc_port)
|
||||
if sess_id:
|
||||
env["ARTEMIS_SESSION_ID"] = str(sess_id)
|
||||
|
||||
print(f"[QueueWorker] Starting task: {goal} with profile: {profile}")
|
||||
state.current_process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"artemis.main",
|
||||
goal,
|
||||
"--profile",
|
||||
profile,
|
||||
"--test-name",
|
||||
test_name,
|
||||
cwd=str(WORKSPACE_ROOT),
|
||||
env=env,
|
||||
)
|
||||
await state.current_process.wait()
|
||||
print(
|
||||
f"[QueueWorker] Task finished with code "
|
||||
f"{state.current_process.returncode}: {goal}"
|
||||
)
|
||||
|
||||
# Fallback status update
|
||||
if state.active_session_id:
|
||||
curr_status = session_repo.get_session_status(state.active_session_id)
|
||||
if curr_status == "running":
|
||||
if state.was_stopped_manually:
|
||||
new_status = "cancelled"
|
||||
else:
|
||||
new_status = (
|
||||
"completed" if state.current_process.returncode == 0 else "failed"
|
||||
)
|
||||
session_repo.update_session_status(
|
||||
state.active_session_id, new_status, time.time()
|
||||
)
|
||||
print(
|
||||
f"[QueueWorker] Fallback: Updated session "
|
||||
f"{state.active_session_id} status to {new_status}"
|
||||
)
|
||||
state.active_session_id = None
|
||||
|
||||
state.current_profile = None
|
||||
state.was_stopped_manually = False
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print("[QueueWorker] Background worker task was cancelled.")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[QueueWorker] Error executing task: {e}")
|
||||
finally:
|
||||
if goal is not None:
|
||||
state.task_queue.task_done()
|
||||
|
||||
@classmethod
|
||||
async def enqueue_tasks(cls, goals: list[str], profile: str = "flash") -> dict[str, Any]:
|
||||
enqueued_tasks = []
|
||||
now = time.time()
|
||||
for i, goal in enumerate(goals):
|
||||
sess_id = str(uuid.uuid4())
|
||||
task_item = {
|
||||
"session_id": sess_id,
|
||||
"goal": goal,
|
||||
"profile": profile or "flash",
|
||||
"status": "pending",
|
||||
"created_at": now + i * 0.001,
|
||||
"start_time": now + i * 0.001,
|
||||
}
|
||||
await state.task_queue.put(task_item)
|
||||
state.queue_tasks.append(task_item)
|
||||
enqueued_tasks.append(task_item)
|
||||
|
||||
return {
|
||||
"status": "queued" if state.is_running else "started",
|
||||
"tasks": enqueued_tasks,
|
||||
"enqueued_count": len(goals),
|
||||
"total_queued": len(state.queue_tasks),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def stop_tasks(cls, clear_all: bool = False) -> bool:
|
||||
stopped = False
|
||||
if clear_all:
|
||||
state.clear_queue()
|
||||
|
||||
state.was_stopped_manually = True
|
||||
if state.is_running and state.current_process:
|
||||
try:
|
||||
state.current_process.kill()
|
||||
stopped = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for _, conn_info in list(state.active_connections.items()):
|
||||
pid = conn_info.get("pid")
|
||||
if pid:
|
||||
try:
|
||||
os.kill(pid, signal.SIGINT)
|
||||
stopped = True
|
||||
except Exception as e:
|
||||
print(f"Failed to kill external process {pid}: {e}")
|
||||
|
||||
session_repo.mark_all_running_cancelled()
|
||||
|
||||
if PAUSE_FILE.exists():
|
||||
try:
|
||||
PAUSE_FILE.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return stopped
|
||||
|
||||
@classmethod
|
||||
def resume_task(cls) -> bool:
|
||||
if PAUSE_FILE.exists():
|
||||
PAUSE_FILE.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def archive_older_replays_on_launch():
|
||||
"""Archives all existing step replay output folders on server launch."""
|
||||
if TEST_OUTPUTS_DIR.exists():
|
||||
archive_dir = TEST_DATA_DIR / "older"
|
||||
for item in TEST_OUTPUTS_DIR.iterdir():
|
||||
if item.is_dir():
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
target_name = f"{item.name}_{timestamp}"
|
||||
target_path = archive_dir / target_name
|
||||
|
||||
counter = 1
|
||||
while target_path.exists():
|
||||
target_name = f"{item.name}_{timestamp}_{counter}"
|
||||
target_path = archive_dir / target_name
|
||||
counter += 1
|
||||
|
||||
print(
|
||||
f"Archiving older replay record on server launch: "
|
||||
f"{item.name} -> {target_path}"
|
||||
)
|
||||
try:
|
||||
shutil.move(str(item), str(target_path))
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Warning: Failed to archive {item.name} during server launch: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def verify_chunks_exist_on_launch(replay_manager):
|
||||
"""Verifies that chunked directories exist for all sessions in the database."""
|
||||
print("Verifying session chunks on launch...")
|
||||
try:
|
||||
sessions = session_repo.get_all_sessions()
|
||||
session_ids = [s.get("session_id") for s in sessions if s.get("session_id")]
|
||||
print(f"Found {len(session_ids)} sessions in database to verify.")
|
||||
for session_id in session_ids:
|
||||
replay_manager._ensure_session_chunked(str(session_id))
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Warning: Failed to verify chunks during server launch: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
task_queue_service = TaskQueueService()
|
||||
Reference in new issue
Block a user