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:
Weilin Liu committed 2026-08-13 12:11:10 -07:00
1 parent 2fd70465e6
commit c1803fcb86
29 files changed
+8889

No files matched your search

+1
View File
@@ -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()
+204
View File
@@ -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()