feat: add multi-device concurrency, server lifecycle CLI, and unified execution stream
- Implement DevicePool and DeviceExecutionLock concurrency modes (per-device FIFO vs global serialization) - Extend ArtemisClient SDK and CLI with explicit device targeting and concurrency strategy configuration - Add server lifecycle management (artemis restart, stop, status) with cross-platform PID discovery - Update MCP server tools to support multi-device dispatch, trace tagging, and per-device execution - Enhance Showcase UI with step-by-step visual replay, playback speed controls, and device serial badges - Add comprehensive unit test coverage and edge-case verification scripts
This commit is contained in:
1 parent
a36a66ce6d
commit
c2c90a6378
84 files changed
+5612
-645
No files matched your search
@@ -208,21 +208,28 @@ Embed the mobile automation engine into your Python workflows in just a few line
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from artemis.interfaces.sdk import ArtemisClient
|
||||
from artemis import ArtemisClient, ConcurrencyMode
|
||||
|
||||
|
||||
async def main():
|
||||
# Initialize client (choose "flash" for fast UI checks or "pro" for deep reasoning & self-healing)
|
||||
client = ArtemisClient(default_profile="flash")
|
||||
|
||||
# Execute natural language end-to-end test case
|
||||
result = await client.run(
|
||||
"Open System Settings, go to 'Battery', verify battery percentage is displayed, and check for any crash dialogs."
|
||||
# 1. Initialize client with optional device targeting and concurrency strategy:
|
||||
# - concurrency_mode="per_device" (default): 1 task per device, allows multi-device parallel execution
|
||||
# - concurrency_mode="global": 1 task globally across all devices
|
||||
client = ArtemisClient(
|
||||
device_serial="emulator-5554", # optional: target specific device serial
|
||||
default_profile="flash", # "flash" (fast reactive) or "pro" (deep reasoning)
|
||||
concurrency_mode="per_device", # or ConcurrencyMode.GLOBAL
|
||||
)
|
||||
|
||||
# Structured assertions & execution tracing
|
||||
assert result.status == "SUCCESS", f"Test failed: {result.failure_reason}"
|
||||
print(f"✅ Test Passed! Turns: {result.turns} | Trace ID: {result.trace_id}")
|
||||
# 2. Execute natural language end-to-end test case (can also override device per-run)
|
||||
result = await client.run(
|
||||
"Open System Settings, go to 'Battery', verify battery percentage is displayed, and check for any crash dialogs.",
|
||||
device_serial="emulator-5554", # optional: override target device for this specific run
|
||||
)
|
||||
|
||||
# 3. Structured assertions & execution tracing
|
||||
assert result.status == "SUCCESS", f"Test failed: {result.error}"
|
||||
print(f"✅ Test Passed! Device: {result.device_id} | Turns: {result.turns} | Trace ID: {result.trace_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -239,7 +246,7 @@ if __name__ == "__main__":
|
||||
<sub><b>Console Overview</b>: <b>① View Switcher</b> (Home / Workspace) · <b>② Model & Replay</b> (Flash/Pro status & video replay) · <b>③ Live Agent Stream</b> (Action perception, target coordinates & structured results) · <b>④ Prompt Dock</b> (Natural language dispatch) · <b>⑤ Task Queue & Dashboard</b> (Lifecycle & history)</sub>
|
||||
</p>
|
||||
|
||||
* **Web Visual Test Console (`uv run artemis ui`)**: Real-time screen projection and interactive panel, supporting natural language test dispatch, live reasoning telemetry, action trajectories, and execution replay;
|
||||
* **Web Visual Test Console (`uv run artemis ui`)**: Real-time screen projection and interactive panel, supporting natural language test dispatch, live reasoning telemetry, action trajectories, and execution replay; manage server lifecycle anytime from any terminal using `uv run artemis restart`, `uv run artemis stop`, and `uv run artemis status`;
|
||||
* **Native MCP Protocol (IDE Collaboration)**: Operates as a standard MCP server seamlessly integrating with **Antigravity, Claude Code, Windsurf**, etc., directly driving real devices inside the IDE to verify bugs and run test cases;
|
||||
* **Developer CLI (`uv run artemis run`)**: Direct terminal execution for automated test cases, exploratory stability inspection, or AndroidWorld benchmarks with high-fidelity structured terminal output;
|
||||
* **Python SDK**: Integrates as a standard Python library into existing automated testing frameworks (e.g., pytest) or CI/CD pipelines with strongly typed Pydantic structured outputs and assertion support.
|
||||
|
||||
+18
-11
@@ -210,21 +210,28 @@ PYTHONPATH = "/path/to/artemis"
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from artemis.interfaces.sdk import ArtemisClient
|
||||
from artemis import ArtemisClient, ConcurrencyMode
|
||||
|
||||
|
||||
async def main():
|
||||
# 初始化测试客户端(支持 "flash" 极速校验模式 或 "pro" 深度推理与自愈模式)
|
||||
client = ArtemisClient(default_profile="flash")
|
||||
|
||||
# 执行自然语言端到端测试用例
|
||||
result = await client.run(
|
||||
"打开系统设置,进入『电池』页面,验证是否正常显示电量百分比,确认页面无异常报错弹窗。"
|
||||
# 1. 初始化测试客户端(支持指定设备序列号与并发策略):
|
||||
# - concurrency_mode="per_device"(默认):单设备排队,跨设备支持多任务并发执行
|
||||
# - concurrency_mode="global":全局单一并发,整个集群跨所有设备同一时刻只执行一个任务
|
||||
client = ArtemisClient(
|
||||
device_serial="emulator-5554", # 可选:指定目标设备序列号(不传则自动选择空闲设备)
|
||||
default_profile="flash", # "flash" 极速校验 或 "pro" 深度推理自愈
|
||||
concurrency_mode="per_device", # 或 ConcurrencyMode.GLOBAL
|
||||
)
|
||||
|
||||
# 结构化断言与执行追溯
|
||||
assert result.status == "SUCCESS", f"测试执行失败: {result.failure_reason}"
|
||||
print(f"✅ 测试通过!耗时步数: {result.turns} | Trace ID: {result.trace_id}")
|
||||
# 2. 执行端到端任务(也可在调用时动态覆盖指定设备与并发模式)
|
||||
result = await client.run(
|
||||
"打开系统设置,进入『电池』页面,验证是否正常显示电量百分比,确认页面无异常报错弹窗。",
|
||||
device_serial="emulator-5554", # 可选:覆盖当前调用的目标设备
|
||||
)
|
||||
|
||||
# 3. 结构化断言与执行追溯
|
||||
assert result.status == "SUCCESS", f"测试执行失败: {result.error}"
|
||||
print(f"✅ 测试通过!设备: {result.device_id} | 步数: {result.turns} | Trace ID: {result.trace_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -241,7 +248,7 @@ if __name__ == "__main__":
|
||||
<sub><b>控制台功能概览</b>:<b>① 视图切换</b>(主页与工作区) · <b>② 运行模式与回放</b>(Flash/Pro 状态与视频回放) · <b>③ 实时感知推理流</b>(动作感知、目标坐标与结构化总结) · <b>④ 提示词输入坞 (Prompt Dock)</b>(自然语言下发) · <b>⑤ 任务队列看板</b>(生命周期与历史回溯)</sub>
|
||||
</p>
|
||||
|
||||
* **Web 可视化测试控制台 (`uv run artemis ui`)**:集成设备实时投屏与交互面板,支持通过自然语言下发测试用例,实时观测推理步骤、操作轨迹、截图留存与异常状态回放;
|
||||
* **Web 可视化测试控制台 (`uv run artemis ui`)**:集成设备实时投屏与交互面板,支持通过自然语言下发测试用例,实时观测推理步骤、操作轨迹、截图留存与异常状态回放;支持在任意终端使用 `uv run artemis restart`、`uv run artemis stop`、`uv run artemis status` 一键重启、关停或查看服务状态;
|
||||
* **原生 MCP 协议 (IDE 协同)**:作为标准 MCP 服务器无缝接入 **Antigravity、Claude Code、Windsurf** 等开发环境,在 IDE 中直接驱动真机完成自动化测试与 Bug 复现验证;
|
||||
* **开发者命令行 CLI (`uv run artemis run`)**:支持通过终端直接执行自动化测试用例、探索性稳定性巡检或 AndroidWorld 基准评测,提供高保真结构化终端输出;
|
||||
* **Python SDK**:作为标准 Python 库无缝集成至现有自动化测试框架(如 pytest)或 CI/CD 流水线,提供基于 Pydantic 的强类型结构化结果与断言支持。
|
||||
|
||||
@@ -30,6 +30,8 @@ class ServerState:
|
||||
self.ipc_server: asyncio.Server | None = None
|
||||
self.ipc_serve_task: asyncio.Task | None = None
|
||||
self.ipc_port: int | None = None
|
||||
self.port: int = 8000
|
||||
self.host: str = "0.0.0.0"
|
||||
self.is_shutting_down: bool = False
|
||||
|
||||
self.current_process: asyncio.subprocess.Process | None = None
|
||||
|
||||
@@ -1148,13 +1148,15 @@
|
||||
const time = new Date(session.start_time * 1000).toLocaleString();
|
||||
const titleText = session.initial_goal || 'Unnamed Session';
|
||||
const sidDisplay = session.session_id || '';
|
||||
const deviceSerial = session.device_serial || session.device_id || '';
|
||||
const devicePrefix = deviceSerial ? `${escapeHtml(deviceSerial)} · ` : '';
|
||||
|
||||
div.innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<h3 style="font-size: 0.95rem; margin-bottom: 3px; margin-top: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${titleText}">${titleText}</h3>
|
||||
<div style="font-size: 0.75rem; font-family: monospace; opacity: 0.85; margin-bottom: 3px; word-break: break-all; color: ${session.session_id === currentSessionId ? '#edf2f7' : '#4a5568'};">ID: ${sidDisplay}</div>
|
||||
<small style="color: ${session.session_id === currentSessionId ? '#e2e8f0' : '#718096'}; font-size: 0.75rem;">${time}</small>
|
||||
<small style="color: ${session.session_id === currentSessionId ? '#e2e8f0' : '#718096'}; font-size: 0.75rem;">${devicePrefix}${time}</small>
|
||||
</div>
|
||||
<button onclick="deleteSession(event, '${session.session_id}')" style="background: none; border: none; color: ${session.session_id === currentSessionId ? '#fff' : '#e53e3e'}; cursor: pointer; font-size: 1.1rem; padding: 0 0 0 8px; line-height: 1; flex-shrink: 0;" title="Delete Session">🗑️</button>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import json
|
||||
import time
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -49,12 +50,39 @@ async def list_sessions():
|
||||
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 state.active_connections or (
|
||||
state.is_running and s_id == str(state.active_session_id)
|
||||
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:
|
||||
|
||||
@@ -360,3 +360,96 @@ async def get_model_config_and_env():
|
||||
"env_filename": ".env",
|
||||
"env_vars": env_vars,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/server-status")
|
||||
async def get_server_runtime_status():
|
||||
"""Retrieve runtime status, PID, port, and uptime of the Artemis server."""
|
||||
import os
|
||||
from artemis.runtime.server_lifecycle import get_server_status
|
||||
|
||||
try:
|
||||
from apps.admin_console.core.state import state
|
||||
except ImportError:
|
||||
from admin_console.core.state import state
|
||||
|
||||
port = getattr(state, "port", 8000)
|
||||
status = get_server_status(port=port)
|
||||
status["current_pid"] = os.getpid()
|
||||
return status
|
||||
|
||||
|
||||
@router.post("/restart")
|
||||
async def restart_server_endpoint():
|
||||
"""Request a graceful restart of the Artemis server from thin clients/UI."""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
|
||||
try:
|
||||
from apps.admin_console.core.state import state
|
||||
except ImportError:
|
||||
from admin_console.core.state import state
|
||||
|
||||
port = getattr(state, "port", 8000)
|
||||
current_pid = os.getpid()
|
||||
|
||||
def _restart_worker():
|
||||
import time
|
||||
|
||||
time.sleep(0.6)
|
||||
if sys.platform != "win32":
|
||||
try:
|
||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||
except Exception:
|
||||
import subprocess
|
||||
|
||||
subprocess.Popen([sys.executable] + sys.argv)
|
||||
os._exit(0)
|
||||
else:
|
||||
import subprocess
|
||||
|
||||
subprocess.Popen([sys.executable] + sys.argv)
|
||||
os._exit(0)
|
||||
|
||||
threading.Thread(target=_restart_worker, daemon=True).start()
|
||||
|
||||
return {
|
||||
"status": "restarting",
|
||||
"message": "Artemis server is restarting. Client reconnection should occur in 2-3 seconds.",
|
||||
"previous_pid": current_pid,
|
||||
"port": port,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/shutdown")
|
||||
async def shutdown_server_endpoint():
|
||||
"""Request a graceful shutdown of the Artemis server."""
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
|
||||
try:
|
||||
from apps.admin_console.core.state import state
|
||||
except ImportError:
|
||||
from admin_console.core.state import state
|
||||
|
||||
current_pid = os.getpid()
|
||||
|
||||
def _shutdown_worker():
|
||||
import time
|
||||
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
os.kill(current_pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
os._exit(0)
|
||||
|
||||
threading.Thread(target=_shutdown_worker, daemon=True).start()
|
||||
|
||||
return {
|
||||
"status": "shutting_down",
|
||||
"message": "Artemis server is shutting down.",
|
||||
"pid": current_pid,
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import json
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from artemis.core.diagnostics import readiness_engine
|
||||
from artemis.runtime import DeviceExecutionLock
|
||||
from artemis.runtime import DeviceExecutionLock, device_pool
|
||||
|
||||
try:
|
||||
from admin_console.core.state import state
|
||||
@@ -99,9 +99,17 @@ async def run_task(request: RunRequest):
|
||||
enable_outputter=request.enable_outputter,
|
||||
locked_app_package=request.locked_app_package,
|
||||
app_path=request.app_path,
|
||||
device_serial=request.device_serial,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/devices")
|
||||
async def list_devices():
|
||||
"""List all connected Android devices with their busy / idle status."""
|
||||
devices = await device_pool.list_devices_async()
|
||||
return {"devices": [d.to_dict() for d in devices]}
|
||||
|
||||
|
||||
@router.post("/api/stop")
|
||||
async def stop_task(all: bool = False):
|
||||
stopped = task_queue_service.stop_tasks(clear_all=all)
|
||||
@@ -138,10 +146,24 @@ async def get_status():
|
||||
None,
|
||||
)
|
||||
|
||||
active_owners = DeviceExecutionLock.get_active_owners()
|
||||
active_tasks = [
|
||||
{
|
||||
"device_id": owner.device_id,
|
||||
"session_id": owner.session_id,
|
||||
"goal": owner.description,
|
||||
"pid": owner.pid,
|
||||
"ingress": owner.ingress,
|
||||
"acquired_at": owner.acquired_at,
|
||||
}
|
||||
for owner in active_owners.values()
|
||||
]
|
||||
|
||||
running_sid = (
|
||||
(global_owner.session_id if global_owner else None)
|
||||
or state.active_session_id
|
||||
or (running_task.get("session_id") if running_task else None)
|
||||
or (active_tasks[0]["session_id"] if active_tasks and active_tasks[0].get("session_id") else None)
|
||||
or (session_repo.get_running_session_id() if is_running else None)
|
||||
)
|
||||
owner_connection = (
|
||||
@@ -152,6 +174,7 @@ async def get_status():
|
||||
or (global_owner.description if global_owner else None)
|
||||
or state.current_goal
|
||||
or (running_task.get("goal") if running_task else None)
|
||||
or (active_tasks[0]["goal"] if active_tasks else None)
|
||||
)
|
||||
|
||||
active_profile = owner_connection.get("profile") or state.current_profile or (
|
||||
@@ -168,7 +191,23 @@ async def get_status():
|
||||
)
|
||||
|
||||
model_info = model_service.get_active_model_info(active_profile)
|
||||
queue_data = state.queue_tasks
|
||||
|
||||
# Unified Global Queue: merge web tasks and external SDK/CLI device queue tickets
|
||||
global_queued = DeviceExecutionLock.get_queued_tasks()
|
||||
seen_ids = set()
|
||||
queue_data: list[dict[str, Any]] = []
|
||||
|
||||
for item in state.queue_tasks:
|
||||
sid = item.get("session_id")
|
||||
if sid:
|
||||
seen_ids.add(str(sid))
|
||||
queue_data.append(item)
|
||||
|
||||
for g_item in global_queued:
|
||||
sid = str(g_item.get("session_id"))
|
||||
if sid not in seen_ids:
|
||||
seen_ids.add(sid)
|
||||
queue_data.append(g_item)
|
||||
|
||||
if is_running:
|
||||
is_paused = state.is_paused
|
||||
@@ -187,6 +226,7 @@ async def get_status():
|
||||
"background_tasks": bg_tasks,
|
||||
"queue": queue_data,
|
||||
"model_info": model_info,
|
||||
"active_tasks": active_tasks,
|
||||
}
|
||||
|
||||
if latest_session_id and str(latest_session_id) in state.active_connections:
|
||||
@@ -203,6 +243,8 @@ async def get_status():
|
||||
"background_tasks": bg_tasks,
|
||||
"queue": queue_data,
|
||||
"model_info": conn_model_info,
|
||||
"active_tasks": active_tasks,
|
||||
"ipc_port": state.ipc_port,
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -210,7 +252,8 @@ async def get_status():
|
||||
"session_id": latest_session_id,
|
||||
"background_tasks": bg_tasks,
|
||||
"queue": queue_data,
|
||||
"model_info": model_info,
|
||||
"active_tasks": active_tasks,
|
||||
"ipc_port": state.ipc_port,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ class RunRequest(BaseModel):
|
||||
enable_outputter: bool | None = None
|
||||
locked_app_package: str | None = None
|
||||
app_path: str | None = None
|
||||
device_serial: str | None = None
|
||||
|
||||
|
||||
|
||||
class ReplayRequest(BaseModel):
|
||||
|
||||
@@ -49,8 +49,10 @@ import uvicorn
|
||||
|
||||
from artemis.runtime import (
|
||||
DeviceExecutionLock,
|
||||
clear_server_info,
|
||||
shutdown_awake_service,
|
||||
start_awake_service,
|
||||
write_server_info,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -108,6 +110,7 @@ async def on_startup():
|
||||
"""Startup lifecycle hooks."""
|
||||
state.is_shutting_down = False
|
||||
state.shutdown_event.clear()
|
||||
write_server_info(port=getattr(state, "port", 8000), host=getattr(state, "host", "0.0.0.0"))
|
||||
await asyncio.to_thread(start_awake_service)
|
||||
cleaned_device_locks = DeviceExecutionLock.cleanup_stale_locks()
|
||||
if cleaned_device_locks:
|
||||
@@ -160,6 +163,7 @@ async def on_shutdown():
|
||||
await ipc_service.stop_server()
|
||||
state.ipc_subscribers.clear()
|
||||
await asyncio.to_thread(shutdown_awake_service)
|
||||
clear_server_info()
|
||||
|
||||
|
||||
# Mount modular routers
|
||||
@@ -382,23 +386,30 @@ class ArtemisUvicornServer(uvicorn.Server):
|
||||
|
||||
def run_ui_server(host: str, port: int, reload: bool = False) -> None:
|
||||
"""Run the UI server with bounded, signal-aware graceful shutdown."""
|
||||
if reload:
|
||||
uvicorn.run(
|
||||
state.host = host
|
||||
state.port = port
|
||||
write_server_info(port=port, host=host)
|
||||
|
||||
try:
|
||||
if reload:
|
||||
uvicorn.run(
|
||||
"apps.admin_console.server:app",
|
||||
host=host,
|
||||
port=port,
|
||||
reload=True,
|
||||
timeout_graceful_shutdown=5,
|
||||
)
|
||||
return
|
||||
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
host=host,
|
||||
port=port,
|
||||
reload=True,
|
||||
timeout_graceful_shutdown=5,
|
||||
)
|
||||
return
|
||||
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
host=host,
|
||||
port=port,
|
||||
timeout_graceful_shutdown=5,
|
||||
)
|
||||
ArtemisUvicornServer(config).run()
|
||||
ArtemisUvicornServer(config).run()
|
||||
finally:
|
||||
clear_server_info()
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -334,12 +334,12 @@ PRESET_TASK_CATALOG: list[TaskPreset] = [
|
||||
required_packages=["com.google.android.apps.photos"],
|
||||
priority=82,
|
||||
),
|
||||
# 9. WeChat (微信)
|
||||
# 9. WeChat
|
||||
TaskPreset(
|
||||
id="wechat_browse",
|
||||
title="Check WeChat Messages",
|
||||
description="Open WeChat and view top recent chat conversations",
|
||||
goal="Open WeChat (微信) and view the top recent chat messages.",
|
||||
goal="Open WeChat and view the top recent chat messages.",
|
||||
profile="flash",
|
||||
category="flash",
|
||||
tag="WeChat",
|
||||
@@ -351,7 +351,7 @@ PRESET_TASK_CATALOG: list[TaskPreset] = [
|
||||
id="pro_wechat_to_calendar",
|
||||
title="WeChat Notice to Calendar",
|
||||
description="Extract meeting notice from WeChat chat and add to Calendar",
|
||||
goal="Open WeChat (微信), locate the latest meeting announcement or event message in the top chat, extract the time and topic, then open Calendar and schedule an event.",
|
||||
goal="Open WeChat, locate the latest meeting announcement or event message in the top chat, extract the time and topic, then open Calendar and schedule an event.",
|
||||
profile="pro",
|
||||
category="cross_app",
|
||||
tag="WeChat + Calendar",
|
||||
@@ -367,12 +367,12 @@ PRESET_TASK_CATALOG: list[TaskPreset] = [
|
||||
required_packages=["com.tencent.mm"],
|
||||
priority=93,
|
||||
),
|
||||
# 10. Xiaohongshu (小红书)
|
||||
# 10. Xiaohongshu
|
||||
TaskPreset(
|
||||
id="xhs_coffee_guide",
|
||||
title="RED Cafe Guide Search",
|
||||
description="Search trending specialty cafe reviews on Xiaohongshu",
|
||||
goal="Open Xiaohongshu (小红书), search for top-rated specialty coffee shops, and view the top post.",
|
||||
goal="Open Xiaohongshu, search for top-rated specialty coffee shops, and view the top post.",
|
||||
profile="flash",
|
||||
category="flash",
|
||||
tag="Xiaohongshu",
|
||||
@@ -384,12 +384,12 @@ PRESET_TASK_CATALOG: list[TaskPreset] = [
|
||||
required_packages=["com.xingin.xhs"],
|
||||
priority=87,
|
||||
),
|
||||
# 11. Meituan / Dianping (美团 / 大众点评)
|
||||
# 11. Meituan / Dianping
|
||||
TaskPreset(
|
||||
id="meituan_ramen_search",
|
||||
title="Meituan Food Search",
|
||||
description="Search top-rated Ramen nearby on Meituan or Dianping",
|
||||
goal="Open Meituan (美团) or Dianping (大众点评), search for top-rated Ramen nearby, and view top restaurant rating.",
|
||||
goal="Open Meituan or Dianping, search for top-rated Ramen nearby, and view top restaurant rating.",
|
||||
profile="flash",
|
||||
category="flash",
|
||||
tag="Meituan",
|
||||
@@ -401,12 +401,12 @@ PRESET_TASK_CATALOG: list[TaskPreset] = [
|
||||
required_packages=["com.sankuai.meituan", "com.dianping.v1"],
|
||||
priority=86,
|
||||
),
|
||||
# 12. Bilibili (哔哩哔哩)
|
||||
# 12. Bilibili
|
||||
TaskPreset(
|
||||
id="bilibili_stream",
|
||||
title="Bilibili Tech Video",
|
||||
description="Search and play an AI Agent tutorial video on Bilibili",
|
||||
goal='Open Bilibili (哔哩哔哩), search for "AI Agent 架构实战", and play the top matching video.',
|
||||
goal='Open Bilibili, search for "AI Agent Architecture", and play the top matching video.',
|
||||
profile="flash",
|
||||
category="flash",
|
||||
tag="Bilibili",
|
||||
|
||||
@@ -241,6 +241,11 @@ class TaskQueueService:
|
||||
async def queue_worker(cls):
|
||||
"""Persistent, self-healing background worker loop processing tasks in FIFO order."""
|
||||
print("[QueueWorker] Background worker loop initialized and running.")
|
||||
try:
|
||||
DeviceExecutionLock.cleanup_stale_locks()
|
||||
except Exception as exc:
|
||||
print(f"[QueueWorker] Initial stale lock cleanup notice: {exc}")
|
||||
|
||||
while True:
|
||||
task_item = None
|
||||
sess_id = None
|
||||
@@ -289,6 +294,7 @@ class TaskQueueService:
|
||||
"session_id": sess_id,
|
||||
"initial_goal": goal,
|
||||
"profile": profile,
|
||||
"device_serial": task_item.get("device_serial"),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -322,6 +328,8 @@ class TaskQueueService:
|
||||
"--test-name",
|
||||
test_name,
|
||||
]
|
||||
if sess_id:
|
||||
cmd.extend(["--session-id", str(sess_id)])
|
||||
if expected_output:
|
||||
cmd.extend(["--output-description", str(expected_output)])
|
||||
if enable_outputter is not None:
|
||||
@@ -330,9 +338,13 @@ class TaskQueueService:
|
||||
cmd.extend(["--locked-app", str(locked_app)])
|
||||
if app_path:
|
||||
cmd.extend(["--app-path", str(app_path)])
|
||||
device_serial = task_item.get("device_serial")
|
||||
if device_serial:
|
||||
cmd.extend(["--device-serial", str(device_serial)])
|
||||
env["ADB_DEVICE_SERIAL"] = str(device_serial)
|
||||
|
||||
print(
|
||||
f"[QueueWorker] Starting task [{sess_id}]: '{goal}' (profile: {profile}, outputter: {bool(expected_output or enable_outputter)})"
|
||||
f"[QueueWorker] Starting task [{sess_id}]: '{goal}' (profile: {profile}, device: {device_serial or 'auto'}, outputter: {bool(expected_output or enable_outputter)})"
|
||||
)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
@@ -349,6 +361,7 @@ class TaskQueueService:
|
||||
str(queue_ticket),
|
||||
proc.pid,
|
||||
description=f"frontend task: {goal[:120]}",
|
||||
device_id=device_serial or "pending",
|
||||
session_id=str(sess_id) if sess_id else None,
|
||||
ingress="frontend",
|
||||
)
|
||||
@@ -453,6 +466,7 @@ class TaskQueueService:
|
||||
enable_outputter: bool | None = None,
|
||||
locked_app_package: str | None = None,
|
||||
app_path: str | None = None,
|
||||
device_serial: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Enqueues one or more goals and wakes up the background worker."""
|
||||
cls.ensure_worker_running()
|
||||
@@ -461,8 +475,18 @@ class TaskQueueService:
|
||||
now = time.time()
|
||||
for i, goal in enumerate(goals):
|
||||
sess_id = str(uuid.uuid4())
|
||||
assigned_serial = device_serial
|
||||
if not assigned_serial:
|
||||
try:
|
||||
from artemis.runtime import device_pool
|
||||
|
||||
assigned_serial = device_pool.select_device()
|
||||
except Exception:
|
||||
assigned_serial = None
|
||||
|
||||
queue_ticket = DeviceExecutionLock.reserve(
|
||||
description=f"frontend task: {goal[:120]}",
|
||||
device_id=assigned_serial or "pending",
|
||||
session_id=sess_id,
|
||||
ingress="frontend",
|
||||
)
|
||||
@@ -474,6 +498,7 @@ class TaskQueueService:
|
||||
"enable_outputter": enable_outputter,
|
||||
"locked_app_package": locked_app_package,
|
||||
"app_path": app_path,
|
||||
"device_serial": assigned_serial,
|
||||
"status": "pending",
|
||||
"queue_ticket": queue_ticket,
|
||||
"created_at": now + i * 0.001,
|
||||
|
||||
@@ -68,6 +68,11 @@
|
||||
{{ getTaskStatus(session) | uppercase }}
|
||||
</span>
|
||||
<div class="item-header-actions">
|
||||
<span class="item-device" *ngIf="getDeviceSerial(session)" [title]="'Device: ' + getDeviceSerial(session)">
|
||||
<span class="material-symbols-outlined device-icon">phone_android</span>
|
||||
<span class="device-name">{{ getDeviceSerial(session) }}</span>
|
||||
</span>
|
||||
<span class="item-time-divider" *ngIf="getDeviceSerial(session)">·</span>
|
||||
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||
<button
|
||||
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
|
||||
@@ -113,6 +118,11 @@
|
||||
{{ getTaskStatus(session) | uppercase }}
|
||||
</span>
|
||||
<div class="item-header-actions">
|
||||
<span class="item-device" *ngIf="getDeviceSerial(session)" [title]="'Device: ' + getDeviceSerial(session)">
|
||||
<span class="material-symbols-outlined device-icon">phone_android</span>
|
||||
<span class="device-name">{{ getDeviceSerial(session) }}</span>
|
||||
</span>
|
||||
<span class="item-time-divider" *ngIf="getDeviceSerial(session)">·</span>
|
||||
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||
<button
|
||||
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
|
||||
|
||||
@@ -550,7 +550,8 @@ $stream-row-height: 25px;
|
||||
transform: translateY(-1px);
|
||||
|
||||
.item-header .item-header-actions {
|
||||
&:has(.btn-delete-card) .item-time {
|
||||
&:has(.btn-delete-card) .item-time,
|
||||
&:has(.btn-delete-card) .item-time-divider {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -580,8 +581,11 @@ $stream-row-height: 25px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
|
||||
.task-badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 10.5px;
|
||||
@@ -658,11 +662,67 @@ $stream-row-height: 25px;
|
||||
.item-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
justify-content: flex-end;
|
||||
|
||||
.item-device {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2.5px;
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
font-size: 10.5px;
|
||||
color: #64748b;
|
||||
line-height: 1;
|
||||
cursor: default;
|
||||
transition: color 0.15s ease, opacity 0.15s ease;
|
||||
|
||||
.device-icon {
|
||||
font-size: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
line-height: 12px;
|
||||
color: #64748b;
|
||||
opacity: 0.75;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
font-family: inherit;
|
||||
letter-spacing: -0.15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #334155;
|
||||
.device-icon {
|
||||
color: #334155;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-time-divider {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.item-time {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -253,9 +253,11 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
return list.sort((a, b) => {
|
||||
const statusA = this.getTaskStatus(a);
|
||||
const statusB = this.getTaskStatus(b);
|
||||
if (statusA === 'running' || statusA === 'paused') return -1;
|
||||
if (statusB === 'running' || statusB === 'paused') return 1;
|
||||
return a.start_time - b.start_time;
|
||||
const isRunA = statusA === 'running' || statusA === 'paused';
|
||||
const isRunB = statusB === 'running' || statusB === 'paused';
|
||||
if (isRunA && !isRunB) return -1;
|
||||
if (!isRunA && isRunB) return 1;
|
||||
return (a.start_time || 0) - (b.start_time || 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -598,18 +600,43 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
|
||||
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
|
||||
return this.agentService.agentStatus() as 'running' | 'paused';
|
||||
}
|
||||
if (session.status) {
|
||||
const s = session.status.toLowerCase();
|
||||
if (s === 'running' || s === 'paused' || s === 'completed' || s === 'pending' || s === 'failed' || s === 'cancelled') {
|
||||
if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') {
|
||||
return (s === 'success' ? 'completed' : s) as any;
|
||||
}
|
||||
if (s === 'running' || s === 'paused' || s === 'pending') {
|
||||
return s as any;
|
||||
}
|
||||
}
|
||||
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
|
||||
return this.agentService.agentStatus() as 'running' | 'paused';
|
||||
}
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the device serial number for the session
|
||||
*/
|
||||
public getDeviceSerial(session: Session): string | null {
|
||||
const serial = session.device_serial || session.device_id;
|
||||
if (serial && serial !== 'pending' && serial !== 'null' && serial !== 'undefined') {
|
||||
return serial;
|
||||
}
|
||||
if (session.device_info) {
|
||||
try {
|
||||
const info = typeof session.device_info === 'string' ? JSON.parse(session.device_info) : session.device_info;
|
||||
const s = info?.device_id || info?.device_serial;
|
||||
if (s && s !== 'pending' && s !== 'null' && s !== 'undefined') {
|
||||
return s;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public selectTask(sessionId: string, event?: Event): void {
|
||||
if (event) event.stopPropagation();
|
||||
this.agentService.selectSession(sessionId, true);
|
||||
|
||||
@@ -77,6 +77,11 @@
|
||||
{{ getTaskStatus(session) | uppercase }}
|
||||
</span>
|
||||
<div class="task-card-header-actions">
|
||||
<span class="task-device" *ngIf="getDeviceSerial(session)" [title]="'Device: ' + getDeviceSerial(session)">
|
||||
<span class="material-symbols-outlined device-icon">phone_android</span>
|
||||
<span class="device-name">{{ getDeviceSerial(session) }}</span>
|
||||
</span>
|
||||
<span class="task-time-divider" *ngIf="getDeviceSerial(session)">·</span>
|
||||
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||
<button
|
||||
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
|
||||
@@ -126,6 +131,11 @@
|
||||
{{ getTaskStatus(session) | uppercase }}
|
||||
</span>
|
||||
<div class="task-card-header-actions">
|
||||
<span class="task-device" *ngIf="getDeviceSerial(session)" [title]="'Device: ' + getDeviceSerial(session)">
|
||||
<span class="material-symbols-outlined device-icon">phone_android</span>
|
||||
<span class="device-name">{{ getDeviceSerial(session) }}</span>
|
||||
</span>
|
||||
<span class="task-time-divider" *ngIf="getDeviceSerial(session)">·</span>
|
||||
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||
<button
|
||||
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
|
||||
|
||||
@@ -296,7 +296,8 @@ $text-muted: #a1a1aa;
|
||||
box-shadow: 0 3px 8px rgba(148, 163, 184, 0.08);
|
||||
|
||||
.task-card-header .task-card-header-actions {
|
||||
&:has(.btn-delete-card) .task-time {
|
||||
&:has(.btn-delete-card) .task-time,
|
||||
&:has(.btn-delete-card) .task-time-divider {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -327,8 +328,11 @@ $text-muted: #a1a1aa;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
|
||||
.task-badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 10.5px;
|
||||
@@ -405,11 +409,67 @@ $text-muted: #a1a1aa;
|
||||
.task-card-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
justify-content: flex-end;
|
||||
|
||||
.task-device {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2.5px;
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
line-height: 1;
|
||||
cursor: default;
|
||||
transition: color 0.15s ease, opacity 0.15s ease;
|
||||
|
||||
.device-icon {
|
||||
font-size: 12.5px;
|
||||
width: 12.5px;
|
||||
height: 12.5px;
|
||||
line-height: 12.5px;
|
||||
color: #94a3b8;
|
||||
opacity: 0.75;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
font-family: inherit;
|
||||
letter-spacing: -0.15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #64748b;
|
||||
.device-icon {
|
||||
color: #64748b;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-time-divider {
|
||||
font-size: 10px;
|
||||
color: #cbd5e1;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.task-time {
|
||||
font-size: 11.5px;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,11 @@ export class ChatInterfaceComponent {
|
||||
return list.sort((a, b) => {
|
||||
const statusA = this.getTaskStatus(a);
|
||||
const statusB = this.getTaskStatus(b);
|
||||
if (statusA === 'running' || statusA === 'paused') return -1;
|
||||
if (statusB === 'running' || statusB === 'paused') return 1;
|
||||
return a.start_time - b.start_time; // FIFO order
|
||||
const isRunA = statusA === 'running' || statusA === 'paused';
|
||||
const isRunB = statusB === 'running' || statusB === 'paused';
|
||||
if (isRunA && !isRunB) return -1;
|
||||
if (!isRunA && isRunB) return 1;
|
||||
return (a.start_time || 0) - (b.start_time || 0); // stable FIFO order
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,18 +157,43 @@ export class ChatInterfaceComponent {
|
||||
* Determine the current task execution status
|
||||
*/
|
||||
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
|
||||
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
|
||||
return this.agentService.agentStatus() as 'running' | 'paused';
|
||||
}
|
||||
if (session.status) {
|
||||
const s = session.status.toLowerCase();
|
||||
if (s === 'running' || s === 'paused' || s === 'completed' || s === 'pending' || s === 'failed' || s === 'cancelled') {
|
||||
if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') {
|
||||
return (s === 'success' ? 'completed' : s) as any;
|
||||
}
|
||||
if (s === 'running' || s === 'paused' || s === 'pending') {
|
||||
return s as any;
|
||||
}
|
||||
}
|
||||
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
|
||||
return this.agentService.agentStatus() as 'running' | 'paused';
|
||||
}
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the device serial number for the session
|
||||
*/
|
||||
public getDeviceSerial(session: Session): string | null {
|
||||
const serial = session.device_serial || session.device_id;
|
||||
if (serial && serial !== 'pending' && serial !== 'null' && serial !== 'undefined') {
|
||||
return serial;
|
||||
}
|
||||
if (session.device_info) {
|
||||
try {
|
||||
const info = typeof session.device_info === 'string' ? JSON.parse(session.device_info) : session.device_info;
|
||||
const s = info?.device_id || info?.device_serial;
|
||||
if (s && s !== 'pending' && s !== 'null' && s !== 'undefined') {
|
||||
return s;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a session in the UI to monitor its steps
|
||||
*/
|
||||
|
||||
+170
-79
@@ -15,13 +15,13 @@
|
||||
-->
|
||||
|
||||
<!-- Backdrop overlay only when in theater / modal mode -->
|
||||
<div
|
||||
class="theater-backdrop"
|
||||
<div
|
||||
class="theater-backdrop"
|
||||
*ngIf="agentService.isVideoWindowOpen() && isTheaterMode()"
|
||||
(click)="isTheaterMode.set(false)"
|
||||
></div>
|
||||
|
||||
<div
|
||||
<div
|
||||
class="floating-video-wrapper"
|
||||
*ngIf="agentService.isVideoWindowOpen()"
|
||||
[class.minimized]="agentService.isVideoMinimized()"
|
||||
@@ -43,18 +43,18 @@
|
||||
<div class="header-actions" (mousedown)="$event.stopPropagation()">
|
||||
<!-- Mode Toggle Group: Video vs. Step Replay -->
|
||||
<div class="mode-toggle-group" *ngIf="agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()">
|
||||
<button
|
||||
class="mode-btn"
|
||||
[class.active]="agentService.playerMode() === 'video'"
|
||||
<button
|
||||
class="mode-btn"
|
||||
[class.active]="agentService.playerMode() === 'video'"
|
||||
(click)="agentService.setPlayerMode('video')"
|
||||
title="Video Recording"
|
||||
>
|
||||
<span class="material-symbols-outlined mode-icon">videocam</span>
|
||||
<span>Video</span>
|
||||
</button>
|
||||
<button
|
||||
class="mode-btn"
|
||||
[class.active]="agentService.playerMode() === 'steps'"
|
||||
<button
|
||||
class="mode-btn"
|
||||
[class.active]="agentService.playerMode() === 'steps'"
|
||||
(click)="agentService.setPlayerMode('steps')"
|
||||
title="Step Screenshot Replay"
|
||||
>
|
||||
@@ -63,22 +63,9 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Playback Speed Pills (Shown when video is active OR when step replay is active) -->
|
||||
<div class="speed-pill-group" *ngIf="!agentService.isVideoMinimized() && (agentService.recordingPlaybackStatus() === 'ready' || agentService.playerMode() === 'steps') && (agentService.activeVideoUrl() || totalStepFrames() > 0)">
|
||||
<button
|
||||
*ngFor="let speed of speedOptions"
|
||||
class="speed-btn"
|
||||
[class.active]="playbackRate() === speed"
|
||||
(click)="setSpeed(speed)"
|
||||
[title]="'Speed: ' + speed + 'x'"
|
||||
>
|
||||
{{ speed }}x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Minimize / Restore Toggle -->
|
||||
<button
|
||||
class="header-btn"
|
||||
<button
|
||||
class="header-btn"
|
||||
(click)="toggleMinimize()"
|
||||
[title]="agentService.isVideoMinimized() ? 'Restore' : 'Minimize'"
|
||||
aria-label="Minimize"
|
||||
@@ -87,8 +74,8 @@
|
||||
</button>
|
||||
|
||||
<!-- Theater / Fullscreen Toggle -->
|
||||
<button
|
||||
class="header-btn"
|
||||
<button
|
||||
class="header-btn"
|
||||
(click)="toggleTheater()"
|
||||
[title]="isTheaterMode() ? 'Exit Theater' : 'Theater Mode'"
|
||||
*ngIf="!agentService.isVideoMinimized()"
|
||||
@@ -98,9 +85,9 @@
|
||||
</button>
|
||||
|
||||
<!-- Close Window -->
|
||||
<button
|
||||
class="header-btn close-btn"
|
||||
(click)="agentService.closeVideoPlayer()"
|
||||
<button
|
||||
class="header-btn close-btn"
|
||||
(click)="agentService.closeVideoPlayer()"
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
>
|
||||
@@ -114,11 +101,11 @@
|
||||
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
|
||||
<ng-container *ngIf="agentService.recordingPlaybackStatus() === 'live'">
|
||||
<div class="live-stream-container" *ngIf="!liveStreamError()">
|
||||
<img
|
||||
class="live-screen-img"
|
||||
[src]="liveStreamUrl()"
|
||||
(error)="onLiveStreamError()"
|
||||
alt="Live Device Screen"
|
||||
<img
|
||||
class="live-screen-img"
|
||||
[src]="liveStreamUrl()"
|
||||
(error)="onLiveStreamError()"
|
||||
alt="Live Device Screen"
|
||||
/>
|
||||
<div class="live-status-overlay">
|
||||
<div class="live-badge">
|
||||
@@ -157,39 +144,125 @@
|
||||
<!-- Fallback info banner if recording had failed -->
|
||||
<div class="step-replay-notice" *ngIf="agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()">
|
||||
<span class="material-symbols-outlined notice-icon">info</span>
|
||||
<span>Screen recording unavailable. Replaying step screenshots.</span>
|
||||
<span>Screen recording unavailable. Replaying step-by-step screenshots.</span>
|
||||
</div>
|
||||
|
||||
<div class="step-image-viewport">
|
||||
<img
|
||||
class="step-frame-img"
|
||||
[src]="currentStepFrame()?.imageUrl"
|
||||
[alt]="currentStepFrame()?.title"
|
||||
(click)="openImageInNewTab()"
|
||||
title="Click to view image in new tab"
|
||||
/>
|
||||
<!-- Step Image Viewport with Action Coordinates Overlay -->
|
||||
<div
|
||||
class="step-image-viewport"
|
||||
(mouseenter)="onViewportMouseEnter()"
|
||||
(mouseleave)="onViewportMouseLeave()"
|
||||
>
|
||||
<!-- Loading Skeleton (Clean Light Spinner) -->
|
||||
<div class="step-image-loading-state" *ngIf="isStepImageLoading()">
|
||||
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
|
||||
<span>Loading screenshot...</span>
|
||||
</div>
|
||||
|
||||
<!-- Step Overlay Badge -->
|
||||
<div class="step-frame-badge" *ngIf="currentStepFrame() as frame">
|
||||
<span class="badge-step-no">Step {{ frame.stepNumber }}</span>
|
||||
<span class="badge-action-desc">{{ frame.actionText || frame.title }}</span>
|
||||
<!-- Error / Missing Image Fallback -->
|
||||
<div class="step-image-error-state" *ngIf="stepImageError() && !isStepImageLoading()">
|
||||
<span class="material-symbols-outlined broken-icon">broken_image</span>
|
||||
<span>Screenshot preview unavailable for this step</span>
|
||||
</div>
|
||||
|
||||
<!-- Device Screen Frame & Action Coordinates Overlay -->
|
||||
<div class="step-image-frame-wrap" *ngIf="!stepImageError()">
|
||||
<img
|
||||
#stepImgRef
|
||||
class="step-frame-img"
|
||||
[src]="currentStepImageUrl()"
|
||||
[alt]="currentStepFrame()?.title"
|
||||
(load)="onStepImageLoad()"
|
||||
(error)="onStepImageError()"
|
||||
(click)="openImageInNewTab()"
|
||||
title="Click to view image in new tab"
|
||||
/>
|
||||
|
||||
<!-- Action Visual Coordinates Overlay (Touch point, swipe arrow, sequence path) -->
|
||||
<div
|
||||
#stepOverlayRef
|
||||
class="overlay-layer"
|
||||
[style.display]="viewMode() === 'post' ? 'none' : 'block'"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Hideable Floating Detail Card (Appears on hover or when pinned) -->
|
||||
<div
|
||||
class="step-frame-badge"
|
||||
*ngIf="currentStepFrame() as frame"
|
||||
[class.visible]="isCardVisible()"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<div class="badge-content-left">
|
||||
<span class="badge-step-no">Step {{ frame.stepNumber }}</span>
|
||||
<span class="badge-action-desc" [title]="frame.actionText || frame.title">
|
||||
<span class="material-symbols-outlined action-type-icon" *ngIf="frame.action">
|
||||
{{ getStepActionIcon(frame.action) }}
|
||||
</span>
|
||||
{{ frame.actionText || frame.title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="badge-actions-right">
|
||||
<!-- Pre/Post Screenshot Switcher (Only if post-screenshot is available) -->
|
||||
<div class="pre-post-toggle" *ngIf="frame.postImageUrl">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'pre'"
|
||||
(click)="setStepViewMode('pre', $event)"
|
||||
title="View screen before action (with touch coordinates)"
|
||||
>
|
||||
Pre
|
||||
</button>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'post'"
|
||||
(click)="setStepViewMode('post', $event)"
|
||||
title="View resulting screen after action executed"
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Pin Card Button -->
|
||||
<button
|
||||
class="pin-card-btn"
|
||||
[class.pinned]="isCardPinned()"
|
||||
(click)="toggleCardPin($event)"
|
||||
[title]="isCardPinned() ? 'Unpin details card (auto-hide on mouse leave)' : 'Pin details card (keep always visible)'"
|
||||
>
|
||||
<span class="material-symbols-outlined">{{ isCardPinned() ? 'push_pin' : 'keep' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step Replay Controls Footer -->
|
||||
<div class="video-controls-footer">
|
||||
<!-- Timeline Scrubber Bar -->
|
||||
<!-- Timeline Scrubber Bar with Step Ticks -->
|
||||
<div class="scrubber-bar-container">
|
||||
<input
|
||||
type="range"
|
||||
class="scrubber-range"
|
||||
[min]="0"
|
||||
[max]="totalStepFrames() - 1"
|
||||
[step]="1"
|
||||
[value]="activeStepIndex()"
|
||||
(input)="onStepScrub($event)"
|
||||
<input
|
||||
type="range"
|
||||
class="scrubber-range"
|
||||
[min]="0"
|
||||
[max]="totalStepFrames() - 1"
|
||||
[step]="1"
|
||||
[value]="activeStepIndex()"
|
||||
(input)="onStepScrub($event)"
|
||||
/>
|
||||
<div class="scrubber-progress-fill" [style.width.%]="stepProgressPercent()"></div>
|
||||
<!-- Step Tick Markers -->
|
||||
<div class="step-ticks-wrapper" *ngIf="totalStepFrames() > 1">
|
||||
<span
|
||||
*ngFor="let f of stepFrames(); let i = index"
|
||||
class="step-tick"
|
||||
[class.active]="i === activeStepIndex()"
|
||||
[class.passed]="i < activeStepIndex()"
|
||||
[style.left.%]="(i / (totalStepFrames() - 1)) * 100"
|
||||
(click)="seekStepFrame(i); $event.stopPropagation()"
|
||||
[title]="'Step ' + (i + 1) + ': ' + (f.actionText || f.title)"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Controls Row -->
|
||||
@@ -201,12 +274,12 @@
|
||||
</button>
|
||||
|
||||
<!-- Previous Frame Button -->
|
||||
<button class="ctrl-btn" (click)="prevStepFrame()" [disabled]="activeStepIndex() <= 0" title="Previous Frame">
|
||||
<button class="ctrl-btn" (click)="prevStepFrame()" [disabled]="activeStepIndex() <= 0" title="Previous Step">
|
||||
<span class="material-symbols-outlined">skip_previous</span>
|
||||
</button>
|
||||
|
||||
<!-- Next Frame Button -->
|
||||
<button class="ctrl-btn" (click)="nextStepFrame()" [disabled]="activeStepIndex() >= totalStepFrames() - 1" title="Next Frame">
|
||||
<button class="ctrl-btn" (click)="nextStepFrame()" [disabled]="activeStepIndex() >= totalStepFrames() - 1" title="Next Step">
|
||||
<span class="material-symbols-outlined">skip_next</span>
|
||||
</button>
|
||||
|
||||
@@ -215,18 +288,27 @@
|
||||
<span class="material-symbols-outlined">replay</span>
|
||||
</button>
|
||||
|
||||
<!-- Frame Counter Display -->
|
||||
<!-- Step Counter Display -->
|
||||
<span class="time-display">
|
||||
Frame {{ activeStepIndex() + 1 }} / {{ totalStepFrames() }}
|
||||
Step {{ activeStepIndex() + 1 }} / {{ totalStepFrames() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
|
||||
<button
|
||||
class="ctrl-btn speed-pill-btn"
|
||||
(click)="cycleSpeed()"
|
||||
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
|
||||
>
|
||||
<span class="speed-label">{{ playbackRate() }}x</span>
|
||||
</button>
|
||||
|
||||
<!-- Loop Toggle -->
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
[class.active]="isLooping()"
|
||||
(click)="toggleLoop()"
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
[class.active]="isLooping()"
|
||||
(click)="toggleLoop()"
|
||||
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
|
||||
>
|
||||
<span class="material-symbols-outlined">repeat</span>
|
||||
@@ -245,7 +327,7 @@
|
||||
<!-- Option B: ACTIVE VIDEO PLAYBACK VIEW -->
|
||||
<ng-container *ngIf="agentService.playerMode() === 'video' && agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoLoading() && agentService.activeVideoUrl() as videoUrl">
|
||||
<div class="video-container" *ngIf="!videoLoadError()">
|
||||
<video
|
||||
<video
|
||||
#videoRef
|
||||
[src]="currentVideoUrl()"
|
||||
playsinline
|
||||
@@ -259,18 +341,18 @@
|
||||
></video>
|
||||
</div>
|
||||
|
||||
<!-- Dedicated Bottom Controls Bar -->
|
||||
<!-- Dedicated Bottom Controls Bar for Video -->
|
||||
<div class="video-controls-footer" *ngIf="!videoLoadError()">
|
||||
<!-- Timeline Scrubber Bar -->
|
||||
<div class="scrubber-bar-container">
|
||||
<input
|
||||
type="range"
|
||||
class="scrubber-range"
|
||||
[min]="0"
|
||||
[max]="duration() || 100"
|
||||
[step]="0.1"
|
||||
[value]="currentTime()"
|
||||
(input)="onScrub($event)"
|
||||
<input
|
||||
type="range"
|
||||
class="scrubber-range"
|
||||
[min]="0"
|
||||
[max]="duration() || 100"
|
||||
[step]="0.1"
|
||||
[value]="currentTime()"
|
||||
(input)="onScrub($event)"
|
||||
/>
|
||||
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
|
||||
</div>
|
||||
@@ -283,7 +365,7 @@
|
||||
<span class="material-symbols-outlined">{{ isPlaying() ? 'pause' : 'play_arrow' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Step Replay / Reset -->
|
||||
<!-- Replay / Reset -->
|
||||
<button class="ctrl-btn" (click)="restart()" title="Replay from start">
|
||||
<span class="material-symbols-outlined">replay</span>
|
||||
</button>
|
||||
@@ -295,11 +377,20 @@
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
|
||||
<button
|
||||
class="ctrl-btn speed-pill-btn"
|
||||
(click)="cycleSpeed()"
|
||||
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
|
||||
>
|
||||
<span class="speed-label">{{ playbackRate() }}x</span>
|
||||
</button>
|
||||
|
||||
<!-- Loop Toggle -->
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
[class.active]="isLooping()"
|
||||
(click)="toggleLoop()"
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
[class.active]="isLooping()"
|
||||
(click)="toggleLoop()"
|
||||
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
|
||||
>
|
||||
<span class="material-symbols-outlined">repeat</span>
|
||||
|
||||
+269
-42
@@ -29,7 +29,7 @@
|
||||
|
||||
.floating-video-wrapper {
|
||||
position: fixed;
|
||||
width: 360px;
|
||||
width: 400px;
|
||||
max-width: calc(100vw - 32px);
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
@@ -413,7 +413,7 @@
|
||||
.step-replay-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #000000;
|
||||
background: #ffffff;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
@@ -422,12 +422,12 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(254, 243, 199, 0.95);
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
padding: 6px 12px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid rgba(245, 158, 11, 0.3);
|
||||
border-bottom: 1px solid #fde68a;
|
||||
|
||||
.notice-icon {
|
||||
font-size: 14px;
|
||||
@@ -441,62 +441,214 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
background: #0a0a0c;
|
||||
min-height: 240px;
|
||||
background: #f8fafc;
|
||||
min-height: 280px;
|
||||
max-height: 520px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
padding: 10px 0;
|
||||
|
||||
.step-frame-img {
|
||||
max-width: 100%;
|
||||
max-height: 520px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
user-select: none;
|
||||
transition: opacity 0.15s ease;
|
||||
.step-image-loading-state,
|
||||
.step-image-error-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
z-index: 5;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.96;
|
||||
.spinning-icon {
|
||||
font-size: 24px;
|
||||
color: #0284c7;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.broken-icon {
|
||||
font-size: 28px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.step-image-frame-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 92%;
|
||||
max-height: 500px;
|
||||
|
||||
.step-frame-img {
|
||||
max-width: 100%;
|
||||
max-height: 500px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.14), 0 0 0 1px rgba(226, 232, 240, 0.9);
|
||||
background: #ffffff;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.98;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 8;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .step-frame-badge {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.step-frame-badge {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(15, 23, 42, 0.84);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 8px;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
background: rgba(15, 23, 42, 0.88);
|
||||
backdrop-filter: blur(14px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.45);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
pointer-events: none;
|
||||
animation: fadeIn 0.2s ease;
|
||||
transition: opacity 0.22s cubic-bezier(0.16, 1, 0.3, 1), transform 0.22s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
z-index: 15;
|
||||
|
||||
.badge-step-no {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: #0284c7;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.2px;
|
||||
flex-shrink: 0;
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.badge-action-desc {
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
color: #f8fafc;
|
||||
white-space: nowrap;
|
||||
.badge-content-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
|
||||
.badge-step-no {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
background: #0284c7;
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
letter-spacing: 0.3px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 3px rgba(2, 132, 199, 0.4);
|
||||
}
|
||||
|
||||
.badge-action-desc {
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
color: #f8fafc;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.action-type-icon {
|
||||
font-size: 15px;
|
||||
color: #38bdf8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.badge-actions-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.pre-post-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
|
||||
.toggle-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #0284c7;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pin-card-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
cursor: pointer;
|
||||
padding: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
&.pinned {
|
||||
color: #38bdf8;
|
||||
background: rgba(56, 189, 248, 0.2);
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -571,6 +723,48 @@
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 8px rgba(2, 132, 199, 0.4);
|
||||
}
|
||||
|
||||
.step-ticks-wrapper {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 6;
|
||||
|
||||
.step-tick {
|
||||
position: absolute;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1.5px solid #94a3b8;
|
||||
transform: translate(-50%, 0);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translate(-50%, 0) scale(1.4);
|
||||
border-color: #0284c7;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
&.passed {
|
||||
background: #0284c7;
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #38bdf8;
|
||||
border-color: #ffffff;
|
||||
transform: translate(-50%, 0) scale(1.3);
|
||||
box-shadow: 0 0 6px rgba(56, 189, 248, 0.8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
@@ -646,6 +840,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.speed-pill-btn {
|
||||
width: auto;
|
||||
min-width: 34px;
|
||||
padding: 0 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
||||
color: #334155;
|
||||
letter-spacing: -0.2px;
|
||||
|
||||
&:hover {
|
||||
color: #0284c7;
|
||||
border-color: rgba(191, 219, 254, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-size: 18px;
|
||||
color: #1e293b;
|
||||
@@ -758,3 +968,20 @@
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
.action-point-marker {
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes pulse-animation {
|
||||
0% {
|
||||
transform: scale(0.6);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
-3
@@ -29,6 +29,8 @@ import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { StepReplayFrame } from '../../core/models/stream.model';
|
||||
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
|
||||
import { getActionIcon } from '../../utils/action-formatter.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-floating-video-player',
|
||||
@@ -41,6 +43,8 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
public agentService = inject(AgentService);
|
||||
|
||||
@ViewChild('videoRef') videoRef?: ElementRef<HTMLVideoElement>;
|
||||
@ViewChild('stepImgRef') stepImgRef?: ElementRef<HTMLImageElement>;
|
||||
@ViewChild('stepOverlayRef') stepOverlayRef?: ElementRef<HTMLElement>;
|
||||
|
||||
// Playback state signals
|
||||
public isPlaying = signal<boolean>(false);
|
||||
@@ -65,6 +69,12 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
// Step Replay state signals
|
||||
public activeStepIndex = signal<number>(0);
|
||||
public isStepPlaying = signal<boolean>(false);
|
||||
public viewMode = signal<'pre' | 'post'>('pre'); // 'pre' = action preview with coords, 'post' = action result
|
||||
public isCardHovered = signal<boolean>(false);
|
||||
public isCardPinned = signal<boolean>(false);
|
||||
public isCardVisible = computed(() => this.isCardHovered() || this.isCardPinned());
|
||||
public isStepImageLoading = signal<boolean>(false);
|
||||
public stepImageError = signal<boolean>(false);
|
||||
private stepTimer: any = null;
|
||||
|
||||
public stepFrames = computed(() => this.agentService.currentSessionStepFrames());
|
||||
@@ -74,6 +84,14 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
const idx = this.activeStepIndex();
|
||||
return frames[idx] || null;
|
||||
});
|
||||
public currentStepImageUrl = computed<string>(() => {
|
||||
const frame = this.currentStepFrame();
|
||||
if (!frame) return '';
|
||||
if (this.viewMode() === 'post' && frame.postImageUrl) {
|
||||
return frame.postImageUrl;
|
||||
}
|
||||
return frame.preImageUrl || frame.imageUrl || '';
|
||||
});
|
||||
public stepProgressPercent = computed(() => {
|
||||
const total = this.totalStepFrames();
|
||||
if (total <= 1) return 0;
|
||||
@@ -85,10 +103,10 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
|
||||
// Dragging and window positioning state
|
||||
public posX = signal<number>(
|
||||
typeof window !== 'undefined' ? Math.max(20, window.innerWidth - 380) : 100
|
||||
typeof window !== 'undefined' ? Math.max(20, window.innerWidth - 420) : 100
|
||||
);
|
||||
public posY = signal<number>(
|
||||
typeof window !== 'undefined' ? Math.max(60, window.innerHeight - 660) : 100
|
||||
typeof window !== 'undefined' ? Math.max(60, window.innerHeight - 700) : 100
|
||||
);
|
||||
|
||||
private isDragging = false;
|
||||
@@ -117,11 +135,30 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
() => {
|
||||
const _ = this.stepFrames();
|
||||
this.activeStepIndex.set(0);
|
||||
this.viewMode.set('pre');
|
||||
this.pauseStepPlay();
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
|
||||
// Track step screenshot URL changes and reset loading/error state
|
||||
effect(() => {
|
||||
const url = this.currentStepImageUrl();
|
||||
if (url) {
|
||||
this.isStepImageLoading.set(true);
|
||||
this.stepImageError.set(false);
|
||||
} else {
|
||||
this.isStepImageLoading.set(false);
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
|
||||
// Re-draw overlay whenever activeStepIndex or viewMode changes
|
||||
effect(() => {
|
||||
this.activeStepIndex();
|
||||
this.viewMode();
|
||||
setTimeout(() => this.drawStepOverlay(), 40);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const request = this.agentService.videoSeekRequest();
|
||||
if (!request) return;
|
||||
@@ -444,15 +481,18 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
|
||||
public prevStepFrame(): void {
|
||||
this.pauseStepPlay();
|
||||
this.viewMode.set('pre');
|
||||
this.activeStepIndex.update((i) => Math.max(0, i - 1));
|
||||
}
|
||||
|
||||
public nextStepFrame(): void {
|
||||
this.pauseStepPlay();
|
||||
this.viewMode.set('pre');
|
||||
this.activeStepIndex.update((i) => Math.min(this.totalStepFrames() - 1, i + 1));
|
||||
}
|
||||
|
||||
public seekStepFrame(index: number): void {
|
||||
this.viewMode.set('pre');
|
||||
this.activeStepIndex.set(Math.max(0, Math.min(this.totalStepFrames() - 1, index)));
|
||||
}
|
||||
|
||||
@@ -466,8 +506,75 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
this.startStepPlay();
|
||||
}
|
||||
|
||||
public setStepViewMode(mode: 'pre' | 'post', event?: Event): void {
|
||||
if (event) event.stopPropagation();
|
||||
this.viewMode.set(mode);
|
||||
setTimeout(() => this.drawStepOverlay(), 40);
|
||||
}
|
||||
|
||||
public toggleStepViewMode(event?: Event): void {
|
||||
if (event) event.stopPropagation();
|
||||
this.setStepViewMode(this.viewMode() === 'pre' ? 'post' : 'pre');
|
||||
}
|
||||
|
||||
public onViewportMouseEnter(): void {
|
||||
this.isCardHovered.set(true);
|
||||
}
|
||||
|
||||
public onViewportMouseLeave(): void {
|
||||
this.isCardHovered.set(false);
|
||||
}
|
||||
|
||||
public toggleCardPin(event?: Event): void {
|
||||
if (event) event.stopPropagation();
|
||||
this.isCardPinned.update((v) => !v);
|
||||
}
|
||||
|
||||
public cycleSpeed(): void {
|
||||
const current = this.playbackRate();
|
||||
const idx = this.speedOptions.indexOf(current);
|
||||
const nextIdx = (idx + 1) % this.speedOptions.length;
|
||||
this.setSpeed(this.speedOptions[nextIdx]);
|
||||
}
|
||||
|
||||
public onStepImageLoad(): void {
|
||||
this.isStepImageLoading.set(false);
|
||||
this.stepImageError.set(false);
|
||||
this.drawStepOverlay();
|
||||
}
|
||||
|
||||
public onStepImageError(): void {
|
||||
this.isStepImageLoading.set(false);
|
||||
this.stepImageError.set(true);
|
||||
}
|
||||
|
||||
public drawStepOverlay(): void {
|
||||
const img = this.stepImgRef?.nativeElement;
|
||||
const overlay = this.stepOverlayRef?.nativeElement;
|
||||
if (!img || !overlay) return;
|
||||
overlay.innerHTML = '';
|
||||
|
||||
// Only draw coordinate overlays when viewing the pre-action screenshot
|
||||
if (this.viewMode() === 'post') {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = this.currentStepFrame();
|
||||
if (!frame || !frame.action) return;
|
||||
|
||||
try {
|
||||
drawActionCoordinatesOnOverlay(img, overlay, frame.action);
|
||||
} catch (err) {
|
||||
console.warn('Failed to draw step coordinates overlay:', err);
|
||||
}
|
||||
}
|
||||
|
||||
public getStepActionIcon(action: any): string {
|
||||
return getActionIcon(action);
|
||||
}
|
||||
|
||||
public openImageInNewTab(): void {
|
||||
const url = this.currentStepFrame()?.imageUrl;
|
||||
const url = this.currentStepImageUrl() || this.currentStepFrame()?.imageUrl;
|
||||
if (url) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@
|
||||
*ngFor="let session of agentService.sessions()"
|
||||
[value]="session.session_id"
|
||||
>
|
||||
{{ session.initial_goal || 'Unnamed Goal' }} ({{ session.start_time * 1000 | date:'short' }})
|
||||
{{ session.initial_goal || 'Unnamed Goal' }} ({{ session.device_serial ? session.device_serial + ' · ' : '' }}{{ session.start_time * 1000 | date:'short' }})
|
||||
</option>
|
||||
</select>
|
||||
<button class="btn-refresh" (click)="agentService.fetchSessions()">🔄 Refresh</button>
|
||||
|
||||
@@ -247,12 +247,12 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
priority: 82
|
||||
},
|
||||
|
||||
// 9. WeChat (微信)
|
||||
// 9. WeChat
|
||||
{
|
||||
id: 'wechat_browse',
|
||||
title: 'Check WeChat Messages',
|
||||
description: 'Open WeChat and view top recent chat conversations',
|
||||
goal: 'Open WeChat (微信) and view the top recent chat messages.',
|
||||
goal: 'Open WeChat and view the top recent chat messages.',
|
||||
profile: 'flash',
|
||||
category: 'flash',
|
||||
tag: 'WeChat',
|
||||
@@ -264,7 +264,7 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
id: 'pro_wechat_to_calendar',
|
||||
title: 'WeChat Notice to Calendar',
|
||||
description: 'Extract meeting notice from WeChat chat and add to Calendar',
|
||||
goal: 'Open WeChat (微信), locate the latest meeting announcement or event message in the top chat, extract the time and topic, then open Calendar and schedule an event.',
|
||||
goal: 'Open WeChat, locate the latest meeting announcement or event message in the top chat, extract the time and topic, then open Calendar and schedule an event.',
|
||||
profile: 'pro',
|
||||
category: 'cross_app',
|
||||
tag: 'WeChat + Calendar',
|
||||
@@ -276,12 +276,12 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
priority: 93
|
||||
},
|
||||
|
||||
// 10. Xiaohongshu (小红书)
|
||||
// 10. Xiaohongshu
|
||||
{
|
||||
id: 'xhs_coffee_guide',
|
||||
title: 'RED Cafe Guide Search',
|
||||
description: 'Search trending specialty cafe reviews on Xiaohongshu',
|
||||
goal: 'Open Xiaohongshu (小红书), search for top-rated specialty coffee shops, and view the top post.',
|
||||
goal: 'Open Xiaohongshu, search for top-rated specialty coffee shops, and view the top post.',
|
||||
profile: 'flash',
|
||||
category: 'flash',
|
||||
tag: 'Xiaohongshu',
|
||||
@@ -290,12 +290,12 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
priority: 87
|
||||
},
|
||||
|
||||
// 11. Meituan / Dianping (美团 / 大众点评)
|
||||
// 11. Meituan / Dianping
|
||||
{
|
||||
id: 'meituan_ramen_search',
|
||||
title: 'Meituan Food Search',
|
||||
description: 'Search top-rated Ramen nearby on Meituan or Dianping',
|
||||
goal: 'Open Meituan (美团) or Dianping (大众点评), search for top-rated Ramen nearby, and view top restaurant rating.',
|
||||
goal: 'Open Meituan or Dianping, search for top-rated Ramen nearby, and view top restaurant rating.',
|
||||
profile: 'flash',
|
||||
category: 'flash',
|
||||
tag: 'Meituan',
|
||||
@@ -304,12 +304,12 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
priority: 86
|
||||
},
|
||||
|
||||
// 12. Bilibili (哔哩哔哩)
|
||||
// 12. Bilibili
|
||||
{
|
||||
id: 'bilibili_stream',
|
||||
title: 'Bilibili Tech Video',
|
||||
description: 'Search and play an AI Agent tutorial video on Bilibili',
|
||||
goal: 'Open Bilibili (哔哩哔哩), search for "AI Agent 架构实战", and play the top matching video.',
|
||||
goal: 'Open Bilibili, search for "AI Agent Architecture", and play the top matching video.',
|
||||
profile: 'flash',
|
||||
category: 'flash',
|
||||
tag: 'Bilibili',
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface TaskQueueItem {
|
||||
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
|
||||
created_at?: number;
|
||||
start_time?: number;
|
||||
device_serial?: string | null;
|
||||
device_id?: string | null;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
@@ -39,6 +41,9 @@ export interface Session {
|
||||
video_url?: string;
|
||||
recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable';
|
||||
model_info?: ModelInfo;
|
||||
device_serial?: string | null;
|
||||
device_id?: string | null;
|
||||
device_info?: any;
|
||||
}
|
||||
|
||||
export interface AgentStatusResponse {
|
||||
|
||||
@@ -101,9 +101,17 @@ export interface CheckerResult {
|
||||
export interface StepReplayFrame {
|
||||
index: number;
|
||||
stepNumber: number;
|
||||
stepId?: string;
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
preImageUrl?: string | null;
|
||||
postImageUrl?: string | null;
|
||||
action?: any;
|
||||
actionType?: string;
|
||||
actionText?: string;
|
||||
targetText?: string;
|
||||
coords?: string;
|
||||
status?: 'success' | 'failed' | string;
|
||||
isPost?: boolean;
|
||||
timestamp?: number;
|
||||
phaseId?: string;
|
||||
|
||||
@@ -68,6 +68,9 @@ export class AgentService {
|
||||
// Signals to expose state to components
|
||||
private rawSessions = signal<Session[]>([]);
|
||||
private pendingQueue = signal<Session[]>([]);
|
||||
public activeTasks = signal<any[]>([]);
|
||||
// Persistent tracking of active/pending sessions across polling boundaries
|
||||
private activeSessionTracking = new Map<string, Session>();
|
||||
|
||||
public sessions = computed(() => {
|
||||
const raw = this.rawSessions();
|
||||
@@ -75,47 +78,111 @@ export class AgentService {
|
||||
const status = this.agentStatus();
|
||||
const goal = this.runningGoal();
|
||||
const runId = this.runningSessionId();
|
||||
const activeList = this.activeTasks();
|
||||
|
||||
const sessionMap = new Map<string, Session>();
|
||||
|
||||
// 1. First add raw sessions from DB
|
||||
raw.forEach((s) => {
|
||||
const isCurrentActive = (status === 'running' || status === 'paused') && runId === s.session_id;
|
||||
const isPending = this.pendingQueue().some(p => p.session_id === s.session_id) && !isCurrentActive;
|
||||
let sStatus = isCurrentActive ? status : (isPending ? 'pending' : s.status);
|
||||
if ((sStatus === 'running' || sStatus === 'paused') && !isCurrentActive) {
|
||||
const activeRun = raw.find(r => r.session_id === runId);
|
||||
sStatus = (activeRun && (s.start_time || 0) > (activeRun.start_time || 0)) ? 'pending' : 'completed';
|
||||
const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id)
|
||||
|| activeList.some(at => at.session_id === s.session_id);
|
||||
const isTerminal = s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'cancelled';
|
||||
let sStatus = s.status;
|
||||
if (!isTerminal) {
|
||||
const isPending = this.pendingQueue().some(p => p.session_id === s.session_id) && !isCurrentActive;
|
||||
sStatus = isCurrentActive ? (status === 'paused' ? 'paused' : 'running') : (isPending ? 'pending' : s.status);
|
||||
} else {
|
||||
sStatus = (s.status === 'success') ? 'completed' : s.status;
|
||||
}
|
||||
sessionMap.set(s.session_id, {
|
||||
const activeMatch = activeList.find(at => at.session_id === s.session_id);
|
||||
const pendingMatch = this.pendingQueue().find(p => p.session_id === s.session_id);
|
||||
let serial = s.device_serial || s.device_id || activeMatch?.device_id || pendingMatch?.device_serial || null;
|
||||
if (!serial && s.device_info) {
|
||||
try {
|
||||
const info = typeof s.device_info === 'string' ? JSON.parse(s.device_info) : s.device_info;
|
||||
serial = info?.device_id || info?.device_serial || null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const finalSession: Session = {
|
||||
...s,
|
||||
status: sStatus,
|
||||
device_serial: serial,
|
||||
model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
|
||||
});
|
||||
};
|
||||
sessionMap.set(s.session_id, finalSession);
|
||||
if (isTerminal) {
|
||||
this.activeSessionTracking.delete(s.session_id);
|
||||
} else if (sStatus === 'running' || sStatus === 'paused' || sStatus === 'pending') {
|
||||
this.activeSessionTracking.set(s.session_id, finalSession);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Add pending queue sessions if not yet in raw sessions
|
||||
pending.forEach((p) => {
|
||||
if (!sessionMap.has(p.session_id)) {
|
||||
const isCurrentRunning = (status === 'running' || status === 'paused') && runId === p.session_id;
|
||||
sessionMap.set(p.session_id, {
|
||||
const isCurrentRunning = ((status === 'running' || status === 'paused') && runId === p.session_id)
|
||||
|| activeList.some(at => at.session_id === p.session_id);
|
||||
const activeMatch = activeList.find(at => at.session_id === p.session_id);
|
||||
const finalPending: Session = {
|
||||
...p,
|
||||
status: isCurrentRunning ? status : (p.status || 'pending')
|
||||
});
|
||||
status: isCurrentRunning ? (status === 'paused' ? 'paused' : 'running') : (p.status || 'pending'),
|
||||
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
|
||||
};
|
||||
sessionMap.set(p.session_id, finalPending);
|
||||
this.activeSessionTracking.set(p.session_id, finalPending);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Ensure currently active running session is present
|
||||
if ((status === 'running' || status === 'paused') && runId && !sessionMap.has(runId)) {
|
||||
sessionMap.set(runId, {
|
||||
// 3. Ensure all currently active running sessions are present (multi-device & external runs)
|
||||
if (activeList.length > 0) {
|
||||
activeList.forEach((at) => {
|
||||
const sid = at.session_id || `active-${at.device_id}`;
|
||||
const existing = sessionMap.get(sid);
|
||||
if (!existing) {
|
||||
const newSession: Session = {
|
||||
session_id: sid,
|
||||
initial_goal: at.goal || goal || '',
|
||||
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
|
||||
status: 'running',
|
||||
model_info: this.activeModel() || undefined,
|
||||
device_serial: at.device_id || null
|
||||
};
|
||||
sessionMap.set(sid, newSession);
|
||||
this.activeSessionTracking.set(sid, newSession);
|
||||
} else if (existing.status === 'pending') {
|
||||
const updated: Session = {
|
||||
...existing,
|
||||
status: 'running',
|
||||
device_serial: at.device_id || existing.device_serial
|
||||
};
|
||||
sessionMap.set(sid, updated);
|
||||
this.activeSessionTracking.set(sid, updated);
|
||||
}
|
||||
});
|
||||
} else if ((status === 'running' || status === 'paused') && runId && !sessionMap.has(runId)) {
|
||||
const activeSession: Session = {
|
||||
session_id: runId,
|
||||
initial_goal: goal || '',
|
||||
start_time: Date.now() / 1000,
|
||||
status,
|
||||
model_info: this.activeModel() || undefined
|
||||
});
|
||||
};
|
||||
sessionMap.set(runId, activeSession);
|
||||
this.activeSessionTracking.set(runId, activeSession);
|
||||
}
|
||||
|
||||
// 4. Bridge transient queue-to-running transition gaps
|
||||
this.activeSessionTracking.forEach((ts, sid) => {
|
||||
if (!sessionMap.has(sid)) {
|
||||
sessionMap.set(sid, {
|
||||
...ts,
|
||||
status: 'running'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(sessionMap.values()).sort((a, b) => b.start_time - a.start_time);
|
||||
});
|
||||
|
||||
@@ -137,8 +204,14 @@ export class AgentService {
|
||||
public currentStartupProgress = computed(() => {
|
||||
const sessionId = this.currentSessionId();
|
||||
if (sessionId) {
|
||||
const sessionEvents = this.startupProgressBySession()[sessionId];
|
||||
if (sessionEvents?.length) return sessionEvents;
|
||||
const normalized = String(sessionId).trim().toLowerCase();
|
||||
const bySession = this.startupProgressBySession();
|
||||
const direct = bySession[sessionId];
|
||||
if (direct?.length) return direct;
|
||||
const matched = Object.entries(bySession).find(
|
||||
([key]) => key.trim().toLowerCase() === normalized
|
||||
);
|
||||
if (matched?.[1]?.length) return matched[1];
|
||||
}
|
||||
return this.pendingStartupProgress();
|
||||
});
|
||||
@@ -609,7 +682,10 @@ export class AgentService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (evtSessionId && String(evtSessionId) !== String(sessionId)) {
|
||||
const isDifferentSession = evtSessionId
|
||||
&& String(evtSessionId).trim().toLowerCase() !== String(sessionId).trim().toLowerCase();
|
||||
|
||||
if (isDifferentSession) {
|
||||
if (eventType === 'session_started') {
|
||||
this.agentStatus.set('running');
|
||||
this.runningSessionId.set(parsedData.session_id);
|
||||
@@ -617,13 +693,30 @@ export class AgentService {
|
||||
this.runningGoal.set(parsedData.initial_goal);
|
||||
}
|
||||
this.fetchSessions();
|
||||
// Only auto-select if user hasn't explicitly chosen to inspect another task
|
||||
const pinnedId = this.userPinnedSessionId();
|
||||
if (!pinnedId && parsedData?.session_id) {
|
||||
// Only auto-select if user has no current session at all
|
||||
const currentId = this.currentSessionId();
|
||||
if (!currentId && parsedData?.session_id) {
|
||||
this.selectSession(parsedData.session_id, false);
|
||||
}
|
||||
} else if (eventType === 'session_ended') {
|
||||
this.applySessionEndedStatus(evtSessionId, parsedData);
|
||||
this.activeTasks.update(list => list.filter(at => at.session_id !== evtSessionId));
|
||||
const remaining = this.activeTasks();
|
||||
if (remaining.length > 0) {
|
||||
if (this.runningSessionId() === evtSessionId) {
|
||||
this.runningSessionId.set(remaining[0].session_id);
|
||||
this.runningGoal.set(remaining[0].goal || null);
|
||||
}
|
||||
} else if (this.pendingQueue().length > 0) {
|
||||
const nextPending = this.pendingQueue()[0];
|
||||
this.agentStatus.set('running');
|
||||
this.runningSessionId.set(nextPending.session_id);
|
||||
this.runningGoal.set(nextPending.initial_goal || null);
|
||||
} else {
|
||||
this.agentStatus.set('idle');
|
||||
this.runningSessionId.set(null);
|
||||
this.runningGoal.set(null);
|
||||
}
|
||||
this.fetchStatus();
|
||||
this.fetchSessions();
|
||||
this.updateModelForCurrentView();
|
||||
@@ -650,12 +743,26 @@ export class AgentService {
|
||||
|
||||
if (eventType === 'session_ended') {
|
||||
this.applySessionEndedStatus(sessionId, parsedData);
|
||||
this.agentStatus.set('idle');
|
||||
this.runningSessionId.set(null);
|
||||
this.runningGoal.set(null);
|
||||
this.activeTasks.update(list => list.filter(at => at.session_id !== sessionId));
|
||||
const remaining = this.activeTasks();
|
||||
if (remaining.length > 0) {
|
||||
this.agentStatus.set('running');
|
||||
this.runningSessionId.set(remaining[0].session_id);
|
||||
this.runningGoal.set(remaining[0].goal || null);
|
||||
} else if (this.pendingQueue().length > 0) {
|
||||
const nextPending = this.pendingQueue()[0];
|
||||
this.agentStatus.set('running');
|
||||
this.runningSessionId.set(nextPending.session_id);
|
||||
this.runningGoal.set(nextPending.initial_goal || null);
|
||||
} else {
|
||||
this.agentStatus.set('idle');
|
||||
this.runningSessionId.set(null);
|
||||
this.runningGoal.set(null);
|
||||
}
|
||||
this.isRetrying.set(false);
|
||||
this.isPaused.set(false);
|
||||
this.fetchSessions();
|
||||
this.fetchStatus();
|
||||
this.fetchNotes(sessionId);
|
||||
this.updateModelForCurrentView();
|
||||
if (
|
||||
@@ -1072,7 +1179,9 @@ export class AgentService {
|
||||
: null;
|
||||
|
||||
if (status) {
|
||||
this.activeSessionTracking.delete(String(sessionId));
|
||||
this.setSessionStatus(String(sessionId), status);
|
||||
this.activeTasks.update((list) => list.filter((at) => at.session_id !== String(sessionId)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1100,34 +1209,15 @@ export class AgentService {
|
||||
if (data && data.status) {
|
||||
const oldStatus = this.agentStatus();
|
||||
const oldRunningSessionId = this.runningSessionId();
|
||||
|
||||
this.agentStatus.set(data.status);
|
||||
const isActive = data.status === 'running' || data.status === 'paused';
|
||||
if (isActive) {
|
||||
this.runningSessionId.set(data.session_id || null);
|
||||
this.runningGoal.set(data.goal || null);
|
||||
if (data.model_info) {
|
||||
this.activeModel.set(data.model_info);
|
||||
}
|
||||
} else {
|
||||
this.runningSessionId.set(null);
|
||||
this.runningGoal.set(null);
|
||||
|
||||
// If viewing a selected history session, use its model_info
|
||||
const curId = this.currentSessionId();
|
||||
if (curId) {
|
||||
const selected = this.sessions().find(s => s.session_id === curId);
|
||||
if (selected && selected.model_info) {
|
||||
this.activeModel.set(selected.model_info);
|
||||
} else if (data.model_info) {
|
||||
this.activeModel.set(data.model_info);
|
||||
}
|
||||
} else if (data.model_info) {
|
||||
this.activeModel.set(data.model_info);
|
||||
}
|
||||
this.agentStatus.set(data.status);
|
||||
this.runningSessionId.set(data.session_id || null);
|
||||
this.runningGoal.set(data.goal || null);
|
||||
if (data.model_info) {
|
||||
this.activeModel.set(data.model_info);
|
||||
}
|
||||
|
||||
this.isPaused.set(data.status === 'paused');
|
||||
|
||||
if (data.status === 'paused') {
|
||||
this.isRetrying.set(false);
|
||||
const pauseError = data.paused_error
|
||||
@@ -1149,7 +1239,8 @@ export class AgentService {
|
||||
session_id: item.session_id || `pending-task-${index}`,
|
||||
initial_goal: item.goal || '',
|
||||
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
|
||||
status: item.status || 'pending'
|
||||
status: item.status || 'pending',
|
||||
device_serial: item.device_serial || item.device_id || null
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -1160,6 +1251,7 @@ export class AgentService {
|
||||
};
|
||||
});
|
||||
this.pendingQueue.set(pending);
|
||||
this.activeTasks.set(data.active_tasks || []);
|
||||
|
||||
if (oldStatus !== data.status || oldRunningSessionId !== data.session_id) {
|
||||
this.fetchSessions();
|
||||
@@ -1176,16 +1268,11 @@ export class AgentService {
|
||||
this.beginRecordingFinalization(oldRunningSessionId);
|
||||
}
|
||||
|
||||
// Auto-select the session if running and user hasn't explicitly chosen to inspect another task
|
||||
// Auto-select the session ONLY if nothing is currently selected
|
||||
if (isActive && data.session_id) {
|
||||
const currentId = this.currentSessionId();
|
||||
const pinnedId = this.userPinnedSessionId();
|
||||
if (!currentId) {
|
||||
this.selectSession(data.session_id, false);
|
||||
} else if (currentId !== data.session_id) {
|
||||
if (!pinnedId) {
|
||||
this.selectSession(data.session_id, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1328,8 +1415,9 @@ export class AgentService {
|
||||
timestamp
|
||||
};
|
||||
|
||||
const rawKey = String(data.session_id || sessionId).trim();
|
||||
this.startupProgressBySession.update((allEvents) => {
|
||||
const current = [...(allEvents[sessionId] || [])];
|
||||
const current = [...(allEvents[rawKey] || allEvents[sessionId] || [])];
|
||||
const existingIndex = current.findIndex((item) => item.stage === event.stage);
|
||||
if (existingIndex >= 0) {
|
||||
current[existingIndex] = event;
|
||||
@@ -1337,7 +1425,11 @@ export class AgentService {
|
||||
current.push(event);
|
||||
}
|
||||
current.sort((a, b) => a.timestamp - b.timestamp);
|
||||
return { ...allEvents, [sessionId]: current };
|
||||
const updated = { ...allEvents, [rawKey]: current };
|
||||
if (sessionId && sessionId !== rawKey) {
|
||||
updated[sessionId] = current;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('action-formatter.util screenshot chaining', () => {
|
||||
expect(act1Pre).toBe('/images/fbdf17fa7a970d2c37aa52ae4336119e76a7d13ea457811cf8aaab19b3f53843');
|
||||
expect(act1Post).toBe('/images/18bd2ac49e64976f1e74e828ddae76e54da1587044b1d3fb01317dd202ff7c67');
|
||||
|
||||
// Action 2: "下一次的决策前是上一次的动作后" -> Pre MUST be Action 1's post (18bd2ac4...), Post should be Action 2's post (484361b1...)
|
||||
// Action 2: "The next pre-action state is the previous post-action state" -> Pre MUST be Action 1's post (18bd2ac4...), Post should be Action 2's post (484361b1...)
|
||||
const act2Pre = getStepPreImageUrl(stepData, action2);
|
||||
const act2Post = getStepPostImageUrl(stepData, action2);
|
||||
expect(act2Pre).toBe('/images/18bd2ac49e64976f1e74e828ddae76e54da1587044b1d3fb01317dd202ff7c67');
|
||||
@@ -154,7 +154,7 @@ describe('extractStepReplayFrames', () => {
|
||||
expect(extractStepReplayFrames(null as any)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should extract ordered StepReplayFrames and deduplicate consecutive identical images', () => {
|
||||
it('should extract 1:1 ordered StepReplayFrames with action objects and pre/post images', () => {
|
||||
const logs = [
|
||||
{
|
||||
type: 'step_updated',
|
||||
@@ -181,13 +181,18 @@ describe('extractStepReplayFrames', () => {
|
||||
];
|
||||
|
||||
const frames = extractStepReplayFrames(logs);
|
||||
expect(frames.length).toBe(3);
|
||||
expect(frames[0].title).toBe('Step 1 (Before Action)');
|
||||
expect(frames.length).toBe(2);
|
||||
expect(frames[0].stepNumber).toBe(1);
|
||||
expect(frames[0].imageUrl).toBe('/images/img_screen_1');
|
||||
expect(frames[1].title).toContain('Step 1');
|
||||
expect(frames[0].preImageUrl).toBe('/images/img_screen_1');
|
||||
expect(frames[0].postImageUrl).toBe('/images/img_screen_2');
|
||||
expect(frames[0].action).toEqual({ action: 'tap', coordinates: [500, 1000] });
|
||||
|
||||
expect(frames[1].stepNumber).toBe(2);
|
||||
expect(frames[1].imageUrl).toBe('/images/img_screen_2');
|
||||
expect(frames[2].title).toContain('Step 2');
|
||||
expect(frames[2].imageUrl).toBe('/images/img_screen_3');
|
||||
expect(frames[1].preImageUrl).toBe('/images/img_screen_2');
|
||||
expect(frames[1].postImageUrl).toBe('/images/img_screen_3');
|
||||
expect(frames[1].action).toEqual({ action: 'input_text', text: 'hello' });
|
||||
});
|
||||
|
||||
it('should handle terminal step with only pre_image (e.g. cancelled / failed task)', () => {
|
||||
|
||||
@@ -706,7 +706,7 @@ export function resolveStepImageMap(stepData: any): Map<any, ResolvedImageEntry>
|
||||
resolvedPost = stepData.post_image_name || stepData.post_screenshot || null;
|
||||
}
|
||||
|
||||
// "下一次的决策前是上一次的动作后"
|
||||
// "The next pre-action state is the previous post-action state"
|
||||
if (resolvedPost) {
|
||||
currentPreImage = resolvedPost;
|
||||
}
|
||||
@@ -778,24 +778,37 @@ export function extractStepReplayFrames(logsOrSteps: any[]): StepReplayFrame[] {
|
||||
const rawSteps: any[] = [];
|
||||
for (const item of logsOrSteps) {
|
||||
if (!item) continue;
|
||||
if (item.type === 'step_updated' && item.data) {
|
||||
rawSteps.push(item.data);
|
||||
} else if (item.type === 'step' && item.data) {
|
||||
if ((item.type === 'step_updated' || item.type === 'step_recorded' || item.type === 'step') && item.data) {
|
||||
rawSteps.push(item.data);
|
||||
} else if (item.step_number !== undefined || item.step_id !== undefined) {
|
||||
rawSteps.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by step_id or step_number, keeping latest
|
||||
// Deduplicate and merge by step_id or step_number
|
||||
const stepMap = new Map<any, any>();
|
||||
for (const step of rawSteps) {
|
||||
const key = step.step_id || step.step_number;
|
||||
if (key !== undefined) {
|
||||
stepMap.set(key, step);
|
||||
const existing = stepMap.get(key);
|
||||
if (existing) {
|
||||
stepMap.set(key, {
|
||||
...existing,
|
||||
...step,
|
||||
step_number: step.step_number !== undefined ? step.step_number : existing.step_number,
|
||||
pre_image_name: step.pre_image_name || existing.pre_image_name,
|
||||
post_image_name: step.post_image_name || existing.post_image_name,
|
||||
action_taken: step.action_taken || existing.action_taken,
|
||||
last_execution_result: step.last_execution_result || existing.last_execution_result,
|
||||
generic_tools: step.generic_tools || existing.generic_tools
|
||||
});
|
||||
} else {
|
||||
stepMap.set(key, { ...step });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort strictly by step_number ascending
|
||||
const steps = Array.from(stepMap.values()).sort((a, b) => {
|
||||
const numA = Number(a.step_number ?? 0);
|
||||
const numB = Number(b.step_number ?? 0);
|
||||
@@ -804,47 +817,45 @@ export function extractStepReplayFrames(logsOrSteps: any[]): StepReplayFrame[] {
|
||||
});
|
||||
|
||||
const frames: StepReplayFrame[] = [];
|
||||
let lastImageUrl: string | null = null;
|
||||
|
||||
for (const stepData of steps) {
|
||||
const stepNum = Number(stepData.step_number ?? (frames.length + 1));
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const stepData = steps[i];
|
||||
const stepNum = Number(stepData.step_number ?? (i + 1));
|
||||
const act = stepData.action_taken;
|
||||
const title = act ? (getActionTitle(act) || 'Action') : 'Step';
|
||||
const title = act ? (getActionTitle(act) || 'Action') : `Step ${stepNum}`;
|
||||
const coords = act ? getActionCoords(act) : '';
|
||||
const actionDesc = coords ? `${title} (${coords})` : title;
|
||||
const targetText = act ? getActionTargetText(act) : '';
|
||||
const actionDesc = coords ? `${title} (${coords})` : (targetText ? `${title} (${targetText})` : title);
|
||||
|
||||
const preUrl = getStepPreImageUrl(stepData, act);
|
||||
const postUrl = getStepPostImageUrl(stepData, act);
|
||||
|
||||
// If pre-image exists and is new, add it
|
||||
if (preUrl && preUrl !== lastImageUrl) {
|
||||
frames.push({
|
||||
index: frames.length,
|
||||
stepNumber: stepNum,
|
||||
title: `Step ${stepNum} (Before Action)`,
|
||||
imageUrl: preUrl,
|
||||
actionText: `Preparing: ${actionDesc}`,
|
||||
isPost: false,
|
||||
timestamp: stepData.timestamp
|
||||
});
|
||||
lastImageUrl = preUrl;
|
||||
// Each step gets exactly one primary frame (Pre-action screenshot where the action is taken)
|
||||
const primaryImg = preUrl || postUrl;
|
||||
if (!primaryImg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If post-image exists and is different from current screen, add it
|
||||
if (postUrl && postUrl !== lastImageUrl) {
|
||||
frames.push({
|
||||
index: frames.length,
|
||||
stepNumber: stepNum,
|
||||
title: `Step ${stepNum}: ${actionDesc}`,
|
||||
imageUrl: postUrl,
|
||||
actionText: actionDesc,
|
||||
isPost: true,
|
||||
timestamp: stepData.timestamp
|
||||
});
|
||||
lastImageUrl = postUrl;
|
||||
}
|
||||
const failed = isActionFailed(act, stepData);
|
||||
|
||||
frames.push({
|
||||
index: frames.length,
|
||||
stepNumber: stepNum,
|
||||
stepId: String(stepData.step_id || `step-${stepNum}`),
|
||||
title: `Step ${stepNum}: ${title}`,
|
||||
actionText: actionDesc,
|
||||
action: act,
|
||||
actionType: act?.action || act?.name || 'action',
|
||||
targetText,
|
||||
coords,
|
||||
imageUrl: primaryImg,
|
||||
preImageUrl: preUrl,
|
||||
postImageUrl: postUrl,
|
||||
isPost: false,
|
||||
timestamp: stepData.timestamp,
|
||||
status: failed ? 'failed' : 'success'
|
||||
});
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
+2
-1
@@ -16,7 +16,7 @@
|
||||
|
||||
__version__ = "0.2.0"
|
||||
|
||||
from artemis.interfaces.sdk.client import ArtemisClient
|
||||
from artemis.interfaces.sdk.client import ArtemisClient, ConcurrencyMode
|
||||
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task, TaskResult
|
||||
from artemis.sdk.agent import Agent
|
||||
from artemis.sdk.builders import Builders
|
||||
@@ -27,6 +27,7 @@ from artemis.tools.base import artemis_tool, ToolRegistry
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"ArtemisClient",
|
||||
"ConcurrencyMode",
|
||||
"StreamEvent",
|
||||
"StreamEventType",
|
||||
"Agent",
|
||||
|
||||
@@ -218,6 +218,7 @@ class FlashRunner:
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
stream_exec_id = uuid.uuid4()
|
||||
|
||||
async def run_stream():
|
||||
full_response = None
|
||||
@@ -226,6 +227,34 @@ class FlashRunner:
|
||||
full_response = chunk
|
||||
else:
|
||||
full_response += chunk
|
||||
|
||||
# Real-time token streaming to DataEngine for live UI display
|
||||
if (
|
||||
getattr(self, "ctx", None)
|
||||
and getattr(self.ctx, "data_engine", None)
|
||||
and getattr(chunk, "content", None)
|
||||
):
|
||||
text_to_stream = ""
|
||||
thinking_to_stream = ""
|
||||
if isinstance(chunk.content, str):
|
||||
text_to_stream = chunk.content
|
||||
elif isinstance(chunk.content, list):
|
||||
for item in chunk.content:
|
||||
if isinstance(item, str):
|
||||
text_to_stream += item
|
||||
elif isinstance(item, dict):
|
||||
if item.get("type") == "text":
|
||||
text_to_stream += item.get("text", "")
|
||||
elif item.get("type") == "thinking":
|
||||
thinking_to_stream += item.get("thinking", "")
|
||||
if text_to_stream:
|
||||
self.ctx.data_engine.stream_output(
|
||||
stream_exec_id, text_to_stream, is_thinking=False
|
||||
)
|
||||
if thinking_to_stream:
|
||||
self.ctx.data_engine.stream_output(
|
||||
stream_exec_id, thinking_to_stream, is_thinking=True
|
||||
)
|
||||
return full_response
|
||||
|
||||
response = await invoke_llm_with_timeout_message(
|
||||
|
||||
@@ -84,6 +84,7 @@ AGENT_CONFIG_FILENAME = "agent_config.json"
|
||||
DATA_ENGINE_DB_FILENAME = "data_engine.db"
|
||||
IPC_PORT_FILENAME = ".artemis_ipc_port"
|
||||
LS_ADDRESS_FILENAME = ".jetski_ls_address"
|
||||
SERVER_INFO_FILENAME = ".artemis_server.json"
|
||||
DOTENV_FILENAME = ".env"
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from artemis.config.constants import (
|
||||
ENV_ARTEMIS_USE_USER_DIR,
|
||||
IPC_PORT_FILENAME,
|
||||
LS_ADDRESS_FILENAME,
|
||||
SERVER_INFO_FILENAME,
|
||||
)
|
||||
from artemis.platform import platform
|
||||
|
||||
@@ -110,6 +111,14 @@ def get_ls_address_file() -> Path:
|
||||
return ROOT_DIR / LS_ADDRESS_FILENAME
|
||||
|
||||
|
||||
def get_server_info_file() -> Path:
|
||||
"""Returns the location of the Artemis server metadata file."""
|
||||
app_dir_file = get_app_dir() / SERVER_INFO_FILENAME
|
||||
if app_dir_file.exists():
|
||||
return app_dir_file
|
||||
return ROOT_DIR / SERVER_INFO_FILENAME
|
||||
|
||||
|
||||
def get_config_path(filename: str, default_bundled_path: Path | None = None) -> Path:
|
||||
"""Resolve a configuration file path across env vars, config dir, project dir, app dir, and bundles.
|
||||
|
||||
|
||||
+60
-20
@@ -21,8 +21,11 @@ import time
|
||||
from artemis.config.constants import (
|
||||
ENV_ANTIGRAVITY_LS_ADDRESS,
|
||||
ENV_ARTEMIS_IPC_PORT,
|
||||
IPC_PORT_FILENAME,
|
||||
)
|
||||
from artemis.config.paths import (
|
||||
ROOT_DIR,
|
||||
get_app_dir,
|
||||
get_ipc_port_file,
|
||||
get_ls_address_file,
|
||||
get_temp_dir,
|
||||
@@ -33,19 +36,43 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
def read_ipc_port() -> int | None:
|
||||
"""Read the active ARTEMIS IPC port from environment or temporary state file."""
|
||||
"""Read the active ARTEMIS IPC port from environment, state files, or local server API."""
|
||||
env_port = os.getenv(ENV_ARTEMIS_IPC_PORT)
|
||||
if env_port and env_port.strip().isdigit():
|
||||
return int(env_port.strip())
|
||||
|
||||
port_file = get_ipc_port_file()
|
||||
if port_file.exists():
|
||||
try:
|
||||
content = port_file.read_text(encoding="utf-8").strip()
|
||||
if content.isdigit():
|
||||
return int(content)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read IPC port from {port_file}: {e}")
|
||||
candidate_paths = [
|
||||
get_temp_dir() / "artemis-ipc-port",
|
||||
get_app_dir() / IPC_PORT_FILENAME,
|
||||
ROOT_DIR / IPC_PORT_FILENAME,
|
||||
get_ipc_port_file(),
|
||||
]
|
||||
for p in candidate_paths:
|
||||
if p.exists():
|
||||
try:
|
||||
content = p.read_text(encoding="utf-8").strip()
|
||||
if content.isdigit():
|
||||
return int(content)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Dynamic fallback: check local admin console status endpoint
|
||||
try:
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
req = urllib.request.Request(
|
||||
"http://127.0.0.1:8000/api/status",
|
||||
headers={"User-Agent": "Artemis-IPC-Discovery"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=0.2) as resp:
|
||||
if resp.status == 200:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
ipc_port = data.get("ipc_port")
|
||||
if ipc_port and str(ipc_port).isdigit():
|
||||
return int(ipc_port)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@@ -54,23 +81,36 @@ def write_ipc_port(port: int) -> Path:
|
||||
"""Save active IPC port to environment and temporary synchronization file."""
|
||||
os.environ[ENV_ARTEMIS_IPC_PORT] = str(port)
|
||||
port_file = get_ipc_port_file()
|
||||
try:
|
||||
port_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
port_file.write_text(str(port), encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to write IPC port file to {port_file}: {e}")
|
||||
all_targets = [
|
||||
port_file,
|
||||
get_temp_dir() / "artemis-ipc-port",
|
||||
get_app_dir() / IPC_PORT_FILENAME,
|
||||
ROOT_DIR / IPC_PORT_FILENAME,
|
||||
]
|
||||
for target in all_targets:
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(str(port), encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not write IPC port to {target}: {e}")
|
||||
return port_file
|
||||
|
||||
|
||||
def clear_ipc_port() -> None:
|
||||
"""Remove IPC port synchronization state from the process and filesystem."""
|
||||
os.environ.pop(ENV_ARTEMIS_IPC_PORT, None)
|
||||
port_file = get_ipc_port_file()
|
||||
if port_file.exists():
|
||||
try:
|
||||
port_file.unlink(missing_ok=True)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remove IPC port file {port_file}: {e}")
|
||||
all_targets = [
|
||||
get_ipc_port_file(),
|
||||
get_temp_dir() / "artemis-ipc-port",
|
||||
get_app_dir() / IPC_PORT_FILENAME,
|
||||
ROOT_DIR / IPC_PORT_FILENAME,
|
||||
]
|
||||
for target in all_targets:
|
||||
if target.exists():
|
||||
try:
|
||||
target.unlink(missing_ok=True)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to remove IPC port file {target}: {e}")
|
||||
|
||||
|
||||
def read_ls_address() -> str | None:
|
||||
|
||||
+5
-5
@@ -75,12 +75,12 @@ class DeviceContext(BaseModel):
|
||||
extra="allow",
|
||||
)
|
||||
|
||||
host_platform: Literal["WINDOWS", "LINUX", "DARWIN", "MACOS"] | str
|
||||
mobile_platform: DevicePlatform
|
||||
device_id: str
|
||||
host_platform: Literal["WINDOWS", "LINUX", "DARWIN", "MACOS"] | str = "DARWIN"
|
||||
mobile_platform: DevicePlatform = DevicePlatform.ANDROID
|
||||
device_id: str = "default-device"
|
||||
|
||||
device_width: int
|
||||
device_height: int
|
||||
device_width: int = 1080
|
||||
device_height: int = 2400
|
||||
|
||||
def to_str(self):
|
||||
return (
|
||||
|
||||
@@ -44,7 +44,17 @@ def get_adb_device(ctx: ArtemisContext) -> Any:
|
||||
def get_first_device(
|
||||
logger: ArtemisLogger | None = None,
|
||||
) -> tuple[str | None, DevicePlatform | None, None]:
|
||||
"""Gets the first available device."""
|
||||
"""Gets the first available device, prioritizing idle and unassigned devices."""
|
||||
try:
|
||||
from artemis.runtime import device_pool
|
||||
|
||||
chosen = device_pool.select_device()
|
||||
if chosen:
|
||||
return chosen, DevicePlatform.ANDROID, None
|
||||
except Exception as exc:
|
||||
if logger:
|
||||
logger.debug(f"Device pool selection fallback: {exc}")
|
||||
|
||||
if which("adb"):
|
||||
try:
|
||||
android_output = run_shell_command_on_host("adb devices")
|
||||
|
||||
@@ -222,19 +222,31 @@ class DataEngine:
|
||||
|
||||
self._send_ipc_event(event_type, data)
|
||||
|
||||
def start_session(self, goal: str, device_info: dict[str, Any] | None = None) -> UUID:
|
||||
def start_session(
|
||||
self,
|
||||
goal: str,
|
||||
device_info: dict[str, Any] | None = None,
|
||||
session_id: UUID | str | None = None,
|
||||
) -> UUID:
|
||||
"""Start a new session."""
|
||||
global _CURRENT_DATA_ENGINE
|
||||
_CURRENT_DATA_ENGINE = self
|
||||
|
||||
env_session_id = os.getenv("ARTEMIS_CLOUD_SESSION_ID") or os.getenv("ARTEMIS_SESSION_ID")
|
||||
if env_session_id:
|
||||
try:
|
||||
session_id = UUID(env_session_id)
|
||||
except ValueError:
|
||||
session_id = env_session_id
|
||||
if session_id is not None:
|
||||
if isinstance(session_id, str):
|
||||
try:
|
||||
session_id = UUID(session_id)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
session_id = uuid4()
|
||||
env_session_id = os.getenv("ARTEMIS_CLOUD_SESSION_ID") or os.getenv("ARTEMIS_SESSION_ID")
|
||||
if env_session_id:
|
||||
try:
|
||||
session_id = UUID(env_session_id)
|
||||
except ValueError:
|
||||
session_id = env_session_id
|
||||
else:
|
||||
session_id = uuid4()
|
||||
self.current_session_id = session_id
|
||||
self.session_start_time = time.time()
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ class GraphRunner(BaseRunner):
|
||||
artemis_ctx = ArtemisContext(
|
||||
device=DeviceContext(
|
||||
host_platform=platform.os_type.name,
|
||||
device_serial=self.driver.device_id,
|
||||
device_id=self.driver.device_id,
|
||||
)
|
||||
)
|
||||
graph = await get_graph(artemis_ctx)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os
|
||||
from typing import Literal
|
||||
|
||||
from artemis.config import OutputConfig, load_agent_config
|
||||
@@ -50,13 +51,16 @@ class Pipeline:
|
||||
device_id: str = "default-device",
|
||||
output_config: OutputConfig | None = None,
|
||||
enable_outputter: bool | None = None,
|
||||
locked_package: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ExecutionContextState:
|
||||
ctx = ExecutionContext(task_goal=goal, device_id=device_id)
|
||||
if not driver:
|
||||
driver = MockDeviceDriver(device_id=device_id)
|
||||
|
||||
max_turns = kwargs.get("max_turns", 5)
|
||||
runner = cls.create_runner(profile=profile, ctx=ctx, driver=driver)
|
||||
state = await runner.run()
|
||||
state = await runner.run(max_turns=max_turns)
|
||||
|
||||
# Check Outputter configuration
|
||||
try:
|
||||
|
||||
@@ -59,7 +59,7 @@ class ReactiveRunner(BaseRunner):
|
||||
artemis_ctx = ArtemisContext(
|
||||
device=DeviceContext(
|
||||
host_platform=platform.os_type.name,
|
||||
device_serial=self.driver.device_id,
|
||||
device_id=self.driver.device_id,
|
||||
)
|
||||
)
|
||||
flash_runner = FlashRunner(
|
||||
|
||||
@@ -39,6 +39,8 @@ logger = get_logger(__name__)
|
||||
|
||||
async def execute_task(
|
||||
goal: str,
|
||||
device_serial: str | None = None,
|
||||
session_id: str | None = None,
|
||||
locked_app_package: str | None = None,
|
||||
test_name: str | None = None,
|
||||
traces_output_path_str: str | None = None,
|
||||
@@ -76,6 +78,14 @@ async def execute_task(
|
||||
explorer_pro_mode: Override Explorer version mode for Pro execution profile.
|
||||
"""
|
||||
publish_startup_progress("configuration", "Loading the run configuration")
|
||||
effective_sid = (
|
||||
session_id
|
||||
or os.getenv("ARTEMIS_SESSION_ID")
|
||||
or os.getenv("ARTEMIS_CLOUD_SESSION_ID")
|
||||
)
|
||||
if effective_sid:
|
||||
os.environ["ARTEMIS_SESSION_ID"] = str(effective_sid)
|
||||
|
||||
llm_config = initialize_llm_config()
|
||||
agent_profile = AgentProfile(name="default", llm_config=llm_config)
|
||||
config = Builders.AgentConfig.with_default_profile(profile=agent_profile)
|
||||
@@ -115,13 +125,25 @@ async def execute_task(
|
||||
if settings.ADB_HOST:
|
||||
config.with_adb_server(host=settings.ADB_HOST, port=settings.ADB_PORT)
|
||||
|
||||
target_serial = device_serial or settings.ADB_DEVICE_SERIAL or os.environ.get("ADB_DEVICE_SERIAL")
|
||||
if not target_serial:
|
||||
try:
|
||||
from artemis.runtime import device_pool
|
||||
target_serial = device_pool.select_device()
|
||||
except Exception:
|
||||
target_serial = None
|
||||
|
||||
if target_serial:
|
||||
from artemis.context import DevicePlatform
|
||||
config.for_device(DevicePlatform.ANDROID, target_serial)
|
||||
|
||||
if graph_config_callbacks:
|
||||
config.with_graph_config_callbacks(graph_config_callbacks)
|
||||
|
||||
agent: Agent | None = None
|
||||
try:
|
||||
publish_startup_progress("device_check", "Checking the Android device")
|
||||
agent = Agent(config=config.build())
|
||||
agent = Agent(config=config.build(), session_id=effective_sid)
|
||||
await agent.init(
|
||||
retry_count=int(os.getenv("ARTEMIS_HEALTH_RETRIES", 5)),
|
||||
retry_wait_seconds=int(os.getenv("ARTEMIS_HEALTH_DELAY", 2)),
|
||||
@@ -273,6 +295,28 @@ def run_command(
|
||||
help="Explorer version mode when running under Pro profile (Operator/Validator).",
|
||||
),
|
||||
] = None,
|
||||
device_serial: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--device-serial",
|
||||
"-s",
|
||||
help="Target specific Android device by serial number (e.g. emulator-5554).",
|
||||
),
|
||||
] = None,
|
||||
session_id: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--session-id",
|
||||
help="Canonical session UUID for trace and stream telemetry.",
|
||||
),
|
||||
] = None,
|
||||
standalone: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--standalone",
|
||||
help="Run in standalone embedded mode without auto-spawning the Artemis Daemon.",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Run an autonomous UI automation task on the connected Android device."""
|
||||
if with_video_recording_tools:
|
||||
@@ -280,6 +324,14 @@ def run_command(
|
||||
|
||||
console = Console()
|
||||
|
||||
# Daemon auto-spawn: By default, ensure Daemon is running in background unless standalone mode requested
|
||||
if not standalone and os.environ.get("ARTEMIS_STANDALONE") != "1":
|
||||
try:
|
||||
from artemis.runtime import ensure_daemon_running
|
||||
ensure_daemon_running(timeout=1.5)
|
||||
except Exception as exc:
|
||||
logger.debug(f"Daemon probe/auto-spawn skipped: {exc}")
|
||||
|
||||
adb_client = None
|
||||
try:
|
||||
if which("adb"):
|
||||
@@ -306,6 +358,8 @@ def run_command(
|
||||
asyncio.run(
|
||||
execute_task(
|
||||
goal=goal,
|
||||
device_serial=device_serial,
|
||||
session_id=session_id,
|
||||
locked_app_package=locked_app_package,
|
||||
test_name=test_name,
|
||||
traces_output_path_str=traces_path,
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
# 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.
|
||||
|
||||
"""CLI commands for Artemis server lifecycle management: restart, stop, and status."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import Annotated
|
||||
import webbrowser
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
import typer
|
||||
|
||||
from artemis.interfaces.cli.commands.ui import ui_command
|
||||
from artemis.runtime.daemon_client import spawn_daemon
|
||||
from artemis.runtime.server_lifecycle import (
|
||||
find_server_pids,
|
||||
get_server_status,
|
||||
stop_server,
|
||||
)
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def _format_uptime(seconds: float | None) -> str:
|
||||
"""Format seconds into human-friendly duration (e.g. 1h 23m 45s)."""
|
||||
if seconds is None:
|
||||
return "Unknown"
|
||||
sec = int(seconds)
|
||||
hours, remainder = divmod(sec, 3600)
|
||||
minutes, remainder_sec = divmod(remainder, 60)
|
||||
parts = []
|
||||
if hours > 0:
|
||||
parts.append(f"{hours}h")
|
||||
if minutes > 0 or hours > 0:
|
||||
parts.append(f"{minutes}m")
|
||||
parts.append(f"{remainder_sec}s")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def restart_command(
|
||||
host: Annotated[
|
||||
str,
|
||||
typer.Option("--host", "-H", help="Bind host address for the UI server."),
|
||||
] = "0.0.0.0",
|
||||
port: Annotated[
|
||||
int,
|
||||
typer.Option("--port", "-p", help="Port to run the unified UI server on."),
|
||||
] = 8000,
|
||||
open_browser: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--open/--no-open",
|
||||
help="Automatically open the Showcase UI in default web browser.",
|
||||
),
|
||||
] = True,
|
||||
force: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--force",
|
||||
"-f",
|
||||
help="Force kill previous server immediately without waiting for graceful exit.",
|
||||
),
|
||||
] = False,
|
||||
reload: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--reload/--no-reload",
|
||||
help="Enable uvicorn live auto-reload (for development).",
|
||||
),
|
||||
] = False,
|
||||
daemon: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--daemon",
|
||||
"-d",
|
||||
help="Run the new server as a detached background process instead of foreground.",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Restart the Artemis Web UI server, terminating any existing instance on the port."""
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold cyan]🔄 Artemis Server Restart[/bold cyan]\n"
|
||||
f"[dim]Inspecting port {port} and recycling any running Artemis instances...[/dim]",
|
||||
border_style="cyan",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
# 1. Stop existing server
|
||||
existing_pids = find_server_pids(port)
|
||||
if existing_pids:
|
||||
pid_str = ", ".join(map(str, existing_pids))
|
||||
console.print(f" [yellow]🛑 Terminating running Artemis instance (PID: {pid_str})...[/yellow]")
|
||||
else:
|
||||
console.print(f" [dim]ℹ No active Artemis server found on port {port}.[/dim]")
|
||||
|
||||
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=4.0)
|
||||
if stopped_pids:
|
||||
console.print(f" [green]✓ {msg}[/green]\n")
|
||||
elif not success:
|
||||
console.print(f" [red]⚠ {msg}[/red]\n")
|
||||
else:
|
||||
console.print(f" [green]✓ Port {port} is clear and ready.[/green]\n")
|
||||
|
||||
# 2. Launch fresh server instance
|
||||
local_url = f"http://localhost:{port}"
|
||||
if daemon:
|
||||
console.print(f" [cyan]🚀 Starting Artemis in detached background daemon mode...[/cyan]")
|
||||
proc = spawn_daemon(host=host, port=port)
|
||||
if proc:
|
||||
console.print(
|
||||
f" [bold green]✓ Artemis server running in background (PID: {proc.pid})[/bold green]\n"
|
||||
f" 🌐 [cyan]{local_url}[/cyan]\n"
|
||||
f" 🛠️ [cyan]{local_url}/admin[/cyan]\n"
|
||||
)
|
||||
if open_browser:
|
||||
webbrowser.open(local_url)
|
||||
else:
|
||||
console.print(" [red]❌ Failed to spawn background daemon.[/red]\n")
|
||||
raise typer.Exit(code=1)
|
||||
return
|
||||
|
||||
# Default foreground mode
|
||||
console.print(f" [cyan]🚀 Launching unified Artemis Showcase UI in current terminal...[/cyan]\n")
|
||||
ui_command(host=host, port=port, open_browser=open_browser, reload=reload)
|
||||
|
||||
|
||||
def stop_command(
|
||||
port: Annotated[
|
||||
int,
|
||||
typer.Option("--port", "-p", help="Port of the Artemis server to stop."),
|
||||
] = 8000,
|
||||
force: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--force",
|
||||
"-f",
|
||||
help="Force kill immediately without waiting for graceful shutdown.",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Stop any running Artemis Web UI / server instance on the given port."""
|
||||
console.print()
|
||||
existing_pids = find_server_pids(port)
|
||||
if existing_pids:
|
||||
pid_str = ", ".join(map(str, existing_pids))
|
||||
console.print(f" [yellow]🛑 Stopping Artemis server on port {port} (PID: {pid_str})...[/yellow]")
|
||||
else:
|
||||
console.print(f" [dim]Scanning port {port}...[/dim]")
|
||||
|
||||
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=4.0)
|
||||
if stopped_pids:
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold green]✓ Artemis server stopped successfully.[/bold green]\n\n"
|
||||
f"[dim]{msg}[/dim]",
|
||||
title="🛑 Server Stopped",
|
||||
border_style="green",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
elif not success:
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold red]⚠ Failed to fully stop server on port {port}[/bold red]\n\n{msg}",
|
||||
title="⚠ Warning",
|
||||
border_style="red",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
else:
|
||||
console.print(
|
||||
Panel(
|
||||
f"[dim]No active Artemis server found on port {port}. Port is free.[/dim]",
|
||||
title="ℹ Status",
|
||||
border_style="dim",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def status_command(
|
||||
port: Annotated[
|
||||
int,
|
||||
typer.Option("--port", "-p", help="Port to query for Artemis server status."),
|
||||
] = 8000,
|
||||
) -> None:
|
||||
"""Check whether the Artemis server is currently running and display runtime metadata."""
|
||||
console.print()
|
||||
status = get_server_status(port=port)
|
||||
|
||||
if status["running"]:
|
||||
pids_str = ", ".join(map(str, status["pids"])) if status["pids"] else str(status["active_pid"] or "Unknown")
|
||||
uptime_str = _format_uptime(status["uptime_seconds"])
|
||||
metadata = status.get("metadata") or {}
|
||||
|
||||
started_at_str = "Unknown"
|
||||
if metadata.get("started_at"):
|
||||
try:
|
||||
dt = datetime.datetime.fromtimestamp(metadata["started_at"])
|
||||
started_at_str = dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
table = Table(show_header=False, box=None, padding=(0, 2))
|
||||
table.add_row("[bold cyan]Status[/bold cyan]", "[bold green]● ONLINE / RUNNING[/bold green]")
|
||||
table.add_row("[bold cyan]PID(s)[/bold cyan]", f"[yellow]{pids_str}[/yellow]")
|
||||
table.add_row("[bold cyan]Port[/bold cyan]", f"[white]{port}[/white]")
|
||||
table.add_row("[bold cyan]Uptime[/bold cyan]", f"[white]{uptime_str}[/white]")
|
||||
table.add_row("[bold cyan]Started At[/bold cyan]", f"[dim]{started_at_str}[/dim]")
|
||||
table.add_row("[bold cyan]Showcase UI[/bold cyan]", f"[cyan]{status['url']}[/cyan]")
|
||||
table.add_row("[bold cyan]Admin Debug[/bold cyan]", f"[magenta]{status['admin_url']}[/magenta]")
|
||||
|
||||
if metadata.get("cwd"):
|
||||
table.add_row("[bold cyan]Workspace[/bold cyan]", f"[dim]{metadata['cwd']}[/dim]")
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
table,
|
||||
title="🚀 Artemis Server Status",
|
||||
border_style="green",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
console.print(" [dim]💡 To restart: [bold]artemis restart[/bold] | To stop: [bold]artemis stop[/bold][/dim]\n")
|
||||
else:
|
||||
console.print(
|
||||
Panel(
|
||||
f"[dim]○ OFFLINE / STOPPED[/dim]\n\n"
|
||||
f"Port [cyan]{port}[/cyan] is currently free.\n"
|
||||
f"[dim]No active Artemis server process detected.[/dim]",
|
||||
title="ℹ Artemis Server Status",
|
||||
border_style="dim",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
console.print(" [dim]💡 To start: [bold]artemis ui[/bold] or [bold]./start.sh[/bold][/dim]\n")
|
||||
@@ -95,11 +95,59 @@ def ui_command(
|
||||
help="Enable uvicorn live auto-reload (for development).",
|
||||
),
|
||||
] = False,
|
||||
restart: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--restart",
|
||||
"-r",
|
||||
help="Restart server by terminating any existing instance on the port first.",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Launch the unified Artemis Showcase UI & Admin Console in one click."""
|
||||
console = Console()
|
||||
local_url = f"http://localhost:{port}"
|
||||
|
||||
from artemis.runtime.server_lifecycle import (
|
||||
find_server_pids,
|
||||
is_port_in_use,
|
||||
stop_server,
|
||||
)
|
||||
|
||||
if restart:
|
||||
console.print(f" [yellow]🔄 Restart requested. Recycling port {port}...[/yellow]")
|
||||
stop_server(port=port, timeout=4.0)
|
||||
elif is_port_in_use(port):
|
||||
pids = find_server_pids(port)
|
||||
pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else ""
|
||||
if sys.stdin.isatty():
|
||||
console.print(f"\n [yellow]⚠ Port {port} is already in use by an active Artemis server{pid_str}.[/yellow]")
|
||||
console.print(" [dim]Choose an action:[/dim]")
|
||||
console.print(" [bold]1[/bold] Open browser (default)")
|
||||
console.print(" [bold]2[/bold] Restart server (stop existing and start here)")
|
||||
console.print(" [bold]3[/bold] Cancel")
|
||||
try:
|
||||
choice = input(" Select [1-3, default 1]: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
choice = "1"
|
||||
if choice == "2":
|
||||
console.print(f"\n [yellow]🔄 Terminating previous Artemis instance on port {port}...[/yellow]")
|
||||
stop_server(port=port, timeout=4.0)
|
||||
elif choice == "3":
|
||||
return
|
||||
else:
|
||||
if open_browser:
|
||||
webbrowser.open(local_url)
|
||||
return
|
||||
else:
|
||||
console.print(
|
||||
f"\n [yellow]⚠ Port {port} is already in use by Artemis{pid_str}. "
|
||||
f"Use '[bold]artemis restart[/bold]' to restart.[/yellow]\n"
|
||||
)
|
||||
if open_browser:
|
||||
webbrowser.open(local_url)
|
||||
return
|
||||
|
||||
console.print()
|
||||
msg = (
|
||||
f"[bold cyan]✨ Artemis Autonomous Mobile Agent UI[/bold cyan]\n\n"
|
||||
|
||||
@@ -23,6 +23,11 @@ from artemis.interfaces.cli.commands.init import init_command
|
||||
from artemis.interfaces.cli.commands.mcp import mcp_command
|
||||
from artemis.interfaces.cli.commands.run import run_command
|
||||
from artemis.interfaces.cli.commands.server import server_app
|
||||
from artemis.interfaces.cli.commands.server_lifecycle import (
|
||||
restart_command,
|
||||
status_command,
|
||||
stop_command,
|
||||
)
|
||||
from artemis.interfaces.cli.commands.trace import trace_app
|
||||
from artemis.interfaces.cli.commands.ui import ui_command
|
||||
from artemis.utils.logger import get_logger
|
||||
@@ -44,6 +49,9 @@ app = typer.Typer(
|
||||
app.command(name="ui", help="Launch the unified Showcase UI & Admin Console in your browser.")(
|
||||
ui_command
|
||||
)
|
||||
app.command(name="restart", help="Restart running Artemis Web UI & server.")(restart_command)
|
||||
app.command(name="stop", help="Stop running Artemis Web UI & server.")(stop_command)
|
||||
app.command(name="status", help="Display Artemis Web UI & server status.")(status_command)
|
||||
app.command(name="run", help="Execute an autonomous task on a mobile device.")(run_command)
|
||||
app.command(name="init", help="Interactive quickstart wizard to configure API keys & device.")(
|
||||
init_command
|
||||
|
||||
@@ -14,11 +14,14 @@
|
||||
|
||||
"""Python SDK for ARTEMIS."""
|
||||
|
||||
from artemis.interfaces.sdk.client import ArtemisClient
|
||||
from artemis.interfaces.sdk.task import Task, TaskResult
|
||||
from artemis.interfaces.sdk.client import ArtemisClient, ConcurrencyMode
|
||||
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task, TaskResult
|
||||
|
||||
__all__ = [
|
||||
"ArtemisClient",
|
||||
"ConcurrencyMode",
|
||||
"StreamEvent",
|
||||
"StreamEventType",
|
||||
"Task",
|
||||
"TaskResult",
|
||||
]
|
||||
@@ -12,10 +12,21 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Literal
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from artemis.engine.pipeline import Pipeline
|
||||
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, TaskResult
|
||||
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task, TaskResult
|
||||
from artemis.runtime import (
|
||||
ConcurrencyMode,
|
||||
DeviceBusyError,
|
||||
DeviceExecutionLock,
|
||||
DeviceStatus,
|
||||
device_pool,
|
||||
ensure_daemon_running,
|
||||
)
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -25,123 +36,338 @@ class ArtemisClient:
|
||||
"""The official high-level developer entrypoint for interacting with ARTEMIS."""
|
||||
|
||||
def __init__(
|
||||
self, device_id: str = "default-device", default_profile: Literal["flash", "pro"] = "pro"
|
||||
self,
|
||||
device_id: str | None = None,
|
||||
device_serial: str | None = None,
|
||||
default_profile: Literal["flash", "pro"] = "pro",
|
||||
concurrency_mode: Literal["global", "per_device"] | str = "per_device",
|
||||
max_concurrency: int | None = None,
|
||||
standalone: bool = False,
|
||||
):
|
||||
self.device_id = device_id
|
||||
self.standalone = standalone or os.environ.get("ARTEMIS_STANDALONE", "").lower() in ("1", "true", "yes")
|
||||
if not self.standalone:
|
||||
try:
|
||||
ensure_daemon_running(timeout=1.5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
target_dev = device_serial or device_id
|
||||
if target_dev is None or target_dev == "default-device":
|
||||
self._device_id = device_pool.select_device() or "default-device"
|
||||
else:
|
||||
self._device_id = target_dev
|
||||
|
||||
self.default_profile = default_profile
|
||||
self._concurrency_mode = str(concurrency_mode).strip().lower()
|
||||
self._max_concurrency = max_concurrency
|
||||
|
||||
@property
|
||||
def device_id(self) -> str:
|
||||
return self._device_id
|
||||
|
||||
@device_id.setter
|
||||
def device_id(self, val: str) -> None:
|
||||
self._device_id = val
|
||||
|
||||
@property
|
||||
def device_serial(self) -> str:
|
||||
"""Alias for device_id."""
|
||||
return self._device_id
|
||||
|
||||
@device_serial.setter
|
||||
def device_serial(self, val: str) -> None:
|
||||
self._device_id = val
|
||||
|
||||
def set_device(self, device_serial: str) -> "ArtemisClient":
|
||||
"""Set target device serial and return client for chaining."""
|
||||
self._device_id = device_serial
|
||||
return self
|
||||
|
||||
@property
|
||||
def concurrency_mode(self) -> str:
|
||||
"""Concurrency mode: 'global' (1 across all devices) or 'per_device' (1 per device)."""
|
||||
return self._concurrency_mode
|
||||
|
||||
@concurrency_mode.setter
|
||||
def concurrency_mode(self, mode: Literal["global", "per_device"] | str) -> None:
|
||||
self._concurrency_mode = str(mode).strip().lower()
|
||||
|
||||
@property
|
||||
def max_concurrency(self) -> int | None:
|
||||
return self._max_concurrency
|
||||
|
||||
@max_concurrency.setter
|
||||
def max_concurrency(self, val: int | None) -> None:
|
||||
self._max_concurrency = val
|
||||
|
||||
def set_concurrency_mode(self, mode: Literal["global", "per_device"] | str) -> "ArtemisClient":
|
||||
"""Set concurrency mode ('global' or 'per_device') and return client for chaining."""
|
||||
self.concurrency_mode = mode
|
||||
return self
|
||||
|
||||
def list_devices(self) -> list[DeviceStatus]:
|
||||
"""List all connected Android devices with their lock state and availability."""
|
||||
return device_pool.list_devices()
|
||||
|
||||
async def list_devices_async(self) -> list[DeviceStatus]:
|
||||
"""Asynchronously list all connected Android devices with their lock state."""
|
||||
return await device_pool.list_devices_async()
|
||||
|
||||
def get_idle_devices(self) -> list[DeviceStatus]:
|
||||
"""Return connected devices currently ready and idle (not holding a task lock)."""
|
||||
return device_pool.get_idle_devices()
|
||||
|
||||
async def run(
|
||||
self,
|
||||
goal: str,
|
||||
profile: Literal["flash", "pro"] | None = None,
|
||||
device_id: str | None = None,
|
||||
device_serial: str | None = None,
|
||||
concurrency_mode: Literal["global", "per_device"] | str | None = None,
|
||||
max_concurrency: int | None = None,
|
||||
blocking: bool = True,
|
||||
lock_timeout: float | None = None,
|
||||
max_turns: int = 30,
|
||||
**kwargs: Any,
|
||||
) -> TaskResult:
|
||||
"""Executes a single autonomous task and returns the final TaskResult."""
|
||||
"""Executes a single autonomous task on a target device and returns the final TaskResult."""
|
||||
target_profile = profile or self.default_profile
|
||||
logger.info(f"ArtemisClient running task '{goal}' with profile '{target_profile}'...")
|
||||
target_device = device_serial or device_id or self.device_id
|
||||
if target_device == "default-device":
|
||||
target_device = device_pool.select_device() or "default-device"
|
||||
|
||||
state = await Pipeline.execute(
|
||||
goal=goal,
|
||||
profile=target_profile,
|
||||
device_id=self.device_id,
|
||||
mode = concurrency_mode or self.concurrency_mode
|
||||
concurrency_limit = max_concurrency if max_concurrency is not None else self.max_concurrency
|
||||
|
||||
logger.info(
|
||||
f"ArtemisClient running task '{goal}' on device '{target_device}' "
|
||||
f"with profile '{target_profile}' (concurrency_mode='{mode}')..."
|
||||
)
|
||||
|
||||
return TaskResult(
|
||||
trace_id=state.trace_id,
|
||||
status=state.status.value,
|
||||
turns=state.current_turn,
|
||||
error=state.error_message,
|
||||
task_session_id = str(uuid.uuid4())
|
||||
lock = DeviceExecutionLock(
|
||||
device_id=target_device,
|
||||
description=f"{goal[:120]}",
|
||||
concurrency_mode=mode,
|
||||
max_concurrency=concurrency_limit,
|
||||
session_id=task_session_id,
|
||||
ingress="sdk",
|
||||
)
|
||||
|
||||
await asyncio.to_thread(
|
||||
lock.acquire,
|
||||
blocking=blocking,
|
||||
timeout=lock_timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
# Check if targeting a live connected device
|
||||
devices = device_pool.list_devices()
|
||||
connected_serials = {d.serial for d in devices}
|
||||
is_live_device = target_device in connected_serials and os.environ.get("ARTEMIS_MOCK_DRIVER") != "1"
|
||||
|
||||
if is_live_device:
|
||||
from artemis.sdk.agent import Agent
|
||||
from artemis.sdk.builders import Builders
|
||||
from artemis.context import DevicePlatform
|
||||
|
||||
cfg_builder = (
|
||||
Builders.AgentConfig
|
||||
.for_device(DevicePlatform.ANDROID, target_device)
|
||||
.with_concurrency_mode(mode)
|
||||
)
|
||||
if concurrency_limit is not None:
|
||||
cfg_builder.with_max_concurrency(concurrency_limit)
|
||||
|
||||
agent = Agent(config=cfg_builder.build())
|
||||
agent._session_id = task_session_id
|
||||
await agent.init()
|
||||
try:
|
||||
task = agent.new_task(goal=goal)
|
||||
if target_profile:
|
||||
task.using_profile(target_profile)
|
||||
if kwargs.get("locked_package"):
|
||||
task.with_locked_app_package(kwargs["locked_package"])
|
||||
|
||||
output = await agent.run_task(request=task.build())
|
||||
return TaskResult(
|
||||
trace_id=task_session_id,
|
||||
status="completed" if output is not None else "success",
|
||||
turns=getattr(agent, "_turns_completed", 1),
|
||||
output=output,
|
||||
device_id=target_device,
|
||||
)
|
||||
finally:
|
||||
await agent.clean()
|
||||
|
||||
state = await Pipeline.execute(
|
||||
goal=goal,
|
||||
profile=target_profile,
|
||||
device_id=target_device,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return TaskResult(
|
||||
trace_id=state.trace_id,
|
||||
status=state.status.value,
|
||||
turns=state.current_turn,
|
||||
error=state.error_message,
|
||||
device_id=target_device,
|
||||
)
|
||||
finally:
|
||||
await asyncio.to_thread(lock.release)
|
||||
|
||||
async def stream_run(
|
||||
self,
|
||||
goal: str,
|
||||
profile: Literal["flash", "pro"] | None = None,
|
||||
device_id: str | None = None,
|
||||
device_serial: str | None = None,
|
||||
concurrency_mode: Literal["global", "per_device"] | str | None = None,
|
||||
max_concurrency: int | None = None,
|
||||
blocking: bool = True,
|
||||
lock_timeout: float | None = None,
|
||||
max_turns: int = 30,
|
||||
callbacks: list[Callable[[StreamEvent], Any]] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[StreamEvent, None]:
|
||||
"""Executes an autonomous task while yielding real-time StreamEvents asynchronously."""
|
||||
target_profile = profile or self.default_profile
|
||||
logger.info(f"ArtemisClient streaming task '{goal}' with profile '{target_profile}'...")
|
||||
target_device = device_serial or device_id or self.device_id
|
||||
if target_device == "default-device":
|
||||
target_device = device_pool.select_device() or "default-device"
|
||||
|
||||
# 1. Emit initial starting event
|
||||
start_event = StreamEvent(
|
||||
event_type=StreamEventType.STATUS,
|
||||
step_number=0,
|
||||
payload={
|
||||
"status": "starting",
|
||||
"goal": goal,
|
||||
"profile": target_profile,
|
||||
"device_id": self.device_id,
|
||||
},
|
||||
mode = concurrency_mode or self.concurrency_mode
|
||||
concurrency_limit = max_concurrency if max_concurrency is not None else self.max_concurrency
|
||||
|
||||
logger.info(
|
||||
f"ArtemisClient streaming task '{goal}' on device '{target_device}' "
|
||||
f"with profile '{target_profile}' (concurrency_mode='{mode}')..."
|
||||
)
|
||||
if callbacks:
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(start_event)
|
||||
except Exception as e:
|
||||
logger.debug(f"Callback error: {e}")
|
||||
yield start_event
|
||||
|
||||
# 2. Execute task pipeline
|
||||
lock = DeviceExecutionLock(
|
||||
device_id=target_device,
|
||||
description=f"ArtemisClient streaming task: {goal[:120]}",
|
||||
concurrency_mode=mode,
|
||||
max_concurrency=concurrency_limit,
|
||||
)
|
||||
|
||||
await asyncio.to_thread(
|
||||
lock.acquire,
|
||||
blocking=blocking,
|
||||
timeout=lock_timeout,
|
||||
)
|
||||
try:
|
||||
state = await Pipeline.execute(
|
||||
goal=goal,
|
||||
profile=target_profile,
|
||||
device_id=self.device_id,
|
||||
)
|
||||
|
||||
# 3. Emit intermediate step events
|
||||
for step in state.steps:
|
||||
ev = StreamEvent(
|
||||
event_type=StreamEventType.STEP_END,
|
||||
step_number=step.step_number,
|
||||
payload={
|
||||
"thought": step.thought,
|
||||
"action": step.action_name,
|
||||
"params": step.action_params,
|
||||
"result": step.result,
|
||||
"duration": step.duration_seconds,
|
||||
},
|
||||
)
|
||||
if callbacks:
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(ev)
|
||||
except Exception as e:
|
||||
logger.debug(f"Callback error: {e}")
|
||||
yield ev
|
||||
|
||||
# 4. Emit completion event
|
||||
final_ev = StreamEvent(
|
||||
# 1. Emit initial starting event
|
||||
start_event = StreamEvent(
|
||||
event_type=StreamEventType.STATUS,
|
||||
step_number=state.current_turn,
|
||||
step_number=0,
|
||||
payload={
|
||||
"status": state.status.value,
|
||||
"turns": state.current_turn,
|
||||
"error": state.error_message,
|
||||
"status": "starting",
|
||||
"goal": goal,
|
||||
"profile": target_profile,
|
||||
"device_id": target_device,
|
||||
"concurrency_mode": mode,
|
||||
},
|
||||
)
|
||||
if callbacks:
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(final_ev)
|
||||
cb(start_event)
|
||||
except Exception as e:
|
||||
logger.debug(f"Callback error: {e}")
|
||||
yield final_ev
|
||||
yield start_event
|
||||
|
||||
except Exception as e:
|
||||
err_ev = StreamEvent(
|
||||
event_type=StreamEventType.ERROR,
|
||||
step_number=0,
|
||||
payload={"error": str(e)},
|
||||
)
|
||||
if callbacks:
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(err_ev)
|
||||
except Exception:
|
||||
pass
|
||||
yield err_ev
|
||||
# 2. Execute task pipeline
|
||||
try:
|
||||
state = await Pipeline.execute(
|
||||
goal=goal,
|
||||
profile=target_profile,
|
||||
device_id=target_device,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# 3. Emit intermediate step events
|
||||
for step in state.steps:
|
||||
ev = StreamEvent(
|
||||
event_type=StreamEventType.STEP_END,
|
||||
step_number=step.step_number,
|
||||
payload={
|
||||
"thought": step.thought,
|
||||
"action": step.action_name,
|
||||
"params": step.action_params,
|
||||
"result": step.result,
|
||||
"duration": step.duration_seconds,
|
||||
},
|
||||
)
|
||||
if callbacks:
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(ev)
|
||||
except Exception as e:
|
||||
logger.debug(f"Callback error: {e}")
|
||||
yield ev
|
||||
|
||||
# 4. Emit completion event
|
||||
final_ev = StreamEvent(
|
||||
event_type=StreamEventType.STATUS,
|
||||
step_number=state.current_turn,
|
||||
payload={
|
||||
"status": state.status.value,
|
||||
"turns": state.current_turn,
|
||||
"error": state.error_message,
|
||||
"device_id": target_device,
|
||||
},
|
||||
)
|
||||
if callbacks:
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(final_ev)
|
||||
except Exception as e:
|
||||
logger.debug(f"Callback error: {e}")
|
||||
yield final_ev
|
||||
|
||||
except Exception as e:
|
||||
err_ev = StreamEvent(
|
||||
event_type=StreamEventType.ERROR,
|
||||
step_number=0,
|
||||
payload={"error": str(e), "device_id": target_device},
|
||||
)
|
||||
if callbacks:
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(err_ev)
|
||||
except Exception:
|
||||
pass
|
||||
yield err_ev
|
||||
finally:
|
||||
await asyncio.to_thread(lock.release)
|
||||
|
||||
async def run_task(
|
||||
self,
|
||||
task: Task,
|
||||
profile: Literal["flash", "pro"] | None = None,
|
||||
device_id: str | None = None,
|
||||
device_serial: str | None = None,
|
||||
concurrency_mode: Literal["global", "per_device"] | str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> TaskResult:
|
||||
"""Executes a Task instance using ArtemisClient."""
|
||||
target_device = (
|
||||
device_serial
|
||||
or device_id
|
||||
or task.device_serial
|
||||
or task.device_id
|
||||
or self.device_id
|
||||
)
|
||||
target_profile = profile or task.profile or self.default_profile
|
||||
target_mode = concurrency_mode or task.concurrency_mode or self.concurrency_mode
|
||||
return await self.run(
|
||||
goal=task.goal,
|
||||
profile=target_profile,
|
||||
device_id=target_device,
|
||||
concurrency_mode=target_mode,
|
||||
max_turns=task.max_turns,
|
||||
locked_package=task.locked_package,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -44,9 +44,22 @@ class Task(BaseModel):
|
||||
goal: str = Field(..., description="High-level goal description")
|
||||
profile: Literal["flash", "pro"] = Field(default="pro", description="Execution profile")
|
||||
device_id: str = Field(default="default-device", description="Target device serial")
|
||||
device_serial: str | None = Field(
|
||||
default=None, description="Target device serial (alias for device_id)"
|
||||
)
|
||||
concurrency_mode: Literal["global", "per_device"] | None = Field(
|
||||
default=None,
|
||||
description="Concurrency mode: 'global' (1 across all devices) or 'per_device' (1 per device)",
|
||||
)
|
||||
locked_package: str | None = Field(default=None, description="Package lock restriction")
|
||||
max_turns: int = Field(default=30, description="Turn cutoff limit")
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
if self.device_serial and self.device_id == "default-device":
|
||||
self.device_id = self.device_serial
|
||||
elif self.device_id != "default-device" and not self.device_serial:
|
||||
self.device_serial = self.device_id
|
||||
|
||||
|
||||
class TaskResult(BaseModel):
|
||||
"""Final structured outcome of a task execution."""
|
||||
@@ -56,3 +69,4 @@ class TaskResult(BaseModel):
|
||||
turns: int
|
||||
output: Any = None
|
||||
error: str | None = None
|
||||
device_id: str | None = None
|
||||
@@ -204,6 +204,10 @@ class ModelFactory:
|
||||
or os.environ.get("GOOGLE_API_KEY")
|
||||
or os.environ.get("GEMINI_API_KEY")
|
||||
)
|
||||
thinking_level = endpoint.thinking_level
|
||||
if endpoint.model_name and any(v in endpoint.model_name for v in ("2.5", "2.0", "1.5")):
|
||||
thinking_level = None
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": endpoint.model_name,
|
||||
"temperature": endpoint.temperature,
|
||||
@@ -211,7 +215,7 @@ class ModelFactory:
|
||||
"api_key": api_key,
|
||||
"timeout": endpoint.timeout_seconds,
|
||||
"thinking_budget": endpoint.thinking_budget,
|
||||
"thinking_level": endpoint.thinking_level,
|
||||
"thinking_level": thinking_level,
|
||||
"include_thoughts": endpoint.include_thoughts,
|
||||
"safety_settings": {
|
||||
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE,
|
||||
|
||||
+22
-15
@@ -74,12 +74,16 @@ os.environ["ARTEMIS_IPC_PORT"] = ""
|
||||
mcp = FastMCP("Android_ADB_Controller")
|
||||
|
||||
_GLOBAL_CONTROLLER = None
|
||||
_CONTROLLERS: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _get_controller():
|
||||
"""Lazy-load device controller on-demand as a singleton."""
|
||||
global _GLOBAL_CONTROLLER
|
||||
if _GLOBAL_CONTROLLER is not None:
|
||||
def _get_controller(device_serial: str | None = None):
|
||||
"""Lazy-load device controller on-demand, caching per device serial."""
|
||||
global _GLOBAL_CONTROLLER, _CONTROLLERS
|
||||
target_serial = device_serial or os.environ.get("ARTEMIS_DEVICE_ID") or os.environ.get("ADB_DEVICE_SERIAL")
|
||||
if target_serial and target_serial in _CONTROLLERS:
|
||||
return _CONTROLLERS[target_serial]
|
||||
if not target_serial and _GLOBAL_CONTROLLER is not None:
|
||||
return _GLOBAL_CONTROLLER
|
||||
|
||||
logger.info("Initializing lazy device controller...")
|
||||
@@ -87,21 +91,21 @@ def _get_controller():
|
||||
logger.info(
|
||||
"GCP CLOUD MODE active: initializing CloudMobileDeviceController via UnifiedMobileController..."
|
||||
)
|
||||
target_serial = (
|
||||
os.environ.get("ARTEMIS_DEVICE_ID")
|
||||
or os.environ.get("ADB_DEVICE_SERIAL")
|
||||
or "cloud_device"
|
||||
)
|
||||
resolved_serial = target_serial or "cloud_device"
|
||||
ctx = ArtemisContext(
|
||||
device=DeviceContext(
|
||||
platform=DevicePlatform.ANDROID,
|
||||
device_id=target_serial,
|
||||
device_id=resolved_serial,
|
||||
width=1080,
|
||||
height=2400,
|
||||
)
|
||||
)
|
||||
_GLOBAL_CONTROLLER = UnifiedMobileController(ctx=ctx)
|
||||
return _GLOBAL_CONTROLLER
|
||||
controller = UnifiedMobileController(ctx=ctx)
|
||||
if resolved_serial:
|
||||
_CONTROLLERS[resolved_serial] = controller
|
||||
if _GLOBAL_CONTROLLER is None:
|
||||
_GLOBAL_CONTROLLER = controller
|
||||
return controller
|
||||
|
||||
host = os.environ.get("ADB_HOST", "localhost")
|
||||
port_str = os.environ.get("ADB_PORT", "5037")
|
||||
@@ -110,7 +114,6 @@ def _get_controller():
|
||||
os.environ["ADB_SERVER_SOCKET"] = f"tcp:{host}:{port}"
|
||||
adb = AdbClient(host=host, port=port)
|
||||
|
||||
target_serial = os.environ.get("ARTEMIS_DEVICE_ID") or os.environ.get("ADB_DEVICE_SERIAL")
|
||||
devices = adb.device_list()
|
||||
if not devices:
|
||||
raise Exception(f"No Android devices found at {host}:{port}")
|
||||
@@ -148,9 +151,13 @@ def _get_controller():
|
||||
ui_adb_client=ui_client,
|
||||
)
|
||||
|
||||
_GLOBAL_CONTROLLER = UnifiedMobileController(ctx)
|
||||
controller = UnifiedMobileController(ctx)
|
||||
if device_id:
|
||||
_CONTROLLERS[device_id] = controller
|
||||
if _GLOBAL_CONTROLLER is None:
|
||||
_GLOBAL_CONTROLLER = controller
|
||||
logger.info(f"Lazy device controller fully initialized for device: {device_id}")
|
||||
return _GLOBAL_CONTROLLER
|
||||
return controller
|
||||
|
||||
|
||||
def _find_element_at_coords(elements: list[dict], x: int, y: int) -> dict | None:
|
||||
|
||||
@@ -14,22 +14,57 @@
|
||||
|
||||
"""Artemis cross-platform runtime and process supervisor subsystem."""
|
||||
|
||||
from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock
|
||||
from artemis.runtime.device_lock import (
|
||||
ConcurrencyMode,
|
||||
DeviceBusyError,
|
||||
DeviceExecutionLock,
|
||||
)
|
||||
from artemis.runtime.device_pool import DevicePool, DeviceStatus, device_pool
|
||||
from artemis.runtime.daemon_client import (
|
||||
ensure_daemon_running,
|
||||
is_daemon_running,
|
||||
submit_task_to_daemon,
|
||||
)
|
||||
from artemis.runtime.awake_service import (
|
||||
ensure_device_awake,
|
||||
sanitize_device_state,
|
||||
screen_awake_service,
|
||||
shutdown_awake_service,
|
||||
start_awake_service,
|
||||
)
|
||||
from artemis.runtime.server_lifecycle import (
|
||||
clear_server_info,
|
||||
find_server_pids,
|
||||
get_server_status,
|
||||
is_port_in_use,
|
||||
read_server_info,
|
||||
stop_server,
|
||||
write_server_info,
|
||||
)
|
||||
from artemis.runtime.supervisor import ProcessSupervisor, process_supervisor
|
||||
|
||||
__all__ = [
|
||||
"ConcurrencyMode",
|
||||
"DeviceBusyError",
|
||||
"DeviceExecutionLock",
|
||||
"DevicePool",
|
||||
"DeviceStatus",
|
||||
"clear_server_info",
|
||||
"device_pool",
|
||||
"ensure_daemon_running",
|
||||
"ensure_device_awake",
|
||||
"find_server_pids",
|
||||
"get_server_status",
|
||||
"is_daemon_running",
|
||||
"is_port_in_use",
|
||||
"ProcessSupervisor",
|
||||
"process_supervisor",
|
||||
"read_server_info",
|
||||
"sanitize_device_state",
|
||||
"screen_awake_service",
|
||||
"shutdown_awake_service",
|
||||
"start_awake_service",
|
||||
"stop_server",
|
||||
"submit_task_to_daemon",
|
||||
"write_server_info",
|
||||
]
|
||||
@@ -64,6 +64,24 @@ def _run_awake_adb_command(
|
||||
return None
|
||||
|
||||
|
||||
def sanitize_device_state(device_id: str) -> None:
|
||||
"""Sanitize Android device state by cleaning up orphan screenrecord or modal dialogs."""
|
||||
if not device_id:
|
||||
return
|
||||
# 1. Kill any dangling screenrecord processes left from interrupted tasks
|
||||
_run_awake_adb_command(
|
||||
device_id,
|
||||
["shell", "pkill", "-f", "screenrecord"],
|
||||
"kill orphan screenrecord processes",
|
||||
)
|
||||
# 2. Close system modal dialogs (ANR / crash popups)
|
||||
_run_awake_adb_command(
|
||||
device_id,
|
||||
["shell", "am", "broadcast", "-a", "android.intent.action.CLOSE_SYSTEM_DIALOGS"],
|
||||
"dismiss system crash/ANR dialogs",
|
||||
)
|
||||
|
||||
|
||||
def _configure_usb_stay_awake(device_id: str) -> str | None:
|
||||
"""Enable USB stay-awake, falling back to an effective host heartbeat.
|
||||
|
||||
@@ -131,7 +149,7 @@ def _configure_usb_stay_awake(device_id: str) -> str | None:
|
||||
|
||||
|
||||
def _discover_connected_device_ids() -> list[str]:
|
||||
"""Return the selected or first connected local Android device."""
|
||||
"""Return the selected or all connected local Android devices."""
|
||||
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
|
||||
return []
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Client utilities for communicating with and auto-spawning the Artemis Daemon."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from artemis.config.paths import ROOT_DIR
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DEFAULT_DAEMON_HOST = os.environ.get("ARTEMIS_DAEMON_HOST", "127.0.0.1")
|
||||
DEFAULT_DAEMON_PORT = int(os.environ.get("ARTEMIS_DAEMON_PORT", "8000"))
|
||||
|
||||
|
||||
def is_standalone_forced() -> bool:
|
||||
"""Return True if standalone/embedded execution is explicitly configured."""
|
||||
return os.environ.get("ARTEMIS_STANDALONE", "").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def is_daemon_running(
|
||||
host: str = DEFAULT_DAEMON_HOST,
|
||||
port: int = DEFAULT_DAEMON_PORT,
|
||||
timeout: float = 0.3,
|
||||
) -> bool:
|
||||
"""Fast probe to check if the Artemis Daemon HTTP server is responding."""
|
||||
url = f"http://{host}:{port}/api/system/readiness"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Artemis-Daemon-Probe"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
return response.status in (200, 400, 404, 503)
|
||||
except Exception:
|
||||
# Fallback probe to root endpoint
|
||||
try:
|
||||
root_url = f"http://{host}:{port}/"
|
||||
req = urllib.request.Request(root_url, headers={"User-Agent": "Artemis-Daemon-Probe"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
return response.status in (200, 301, 302, 404)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def spawn_daemon(
|
||||
host: str = DEFAULT_DAEMON_HOST,
|
||||
port: int = DEFAULT_DAEMON_PORT,
|
||||
) -> subprocess.Popen | None:
|
||||
"""Silently spawn the Artemis Daemon server in the background as a detached process."""
|
||||
env = os.environ.copy()
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
env["PYTHONUTF8"] = "1"
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"apps.admin_console.server",
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
str(port),
|
||||
]
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"cwd": str(ROOT_DIR),
|
||||
"env": env,
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
"stdin": subprocess.DEVNULL,
|
||||
}
|
||||
|
||||
if sys.platform == "win32":
|
||||
kwargs["creationflags"] = (
|
||||
subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
| getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
|
||||
| getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
|
||||
)
|
||||
else:
|
||||
kwargs["start_new_session"] = True
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, **kwargs)
|
||||
logger.info(f"Spawned Artemis Daemon in background (PID {proc.pid}) at http://{host}:{port}")
|
||||
return proc
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not auto-spawn Artemis Daemon: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def ensure_daemon_running(
|
||||
host: str = DEFAULT_DAEMON_HOST,
|
||||
port: int = DEFAULT_DAEMON_PORT,
|
||||
timeout: float = 2.0,
|
||||
wait_ready: bool = False,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Ensure the Artemis Daemon is running, auto-spawning it if necessary.
|
||||
|
||||
Args:
|
||||
host: Daemon host to probe.
|
||||
port: Daemon port to probe.
|
||||
timeout: Maximum seconds to wait if wait_ready is True.
|
||||
wait_ready: If True, blocks until Daemon is healthy or timeout reached.
|
||||
If False, spawns asynchronously and returns immediately.
|
||||
|
||||
Returns:
|
||||
(True, base_url) if Daemon is accessible.
|
||||
(False, None) if standalone mode is forced or Daemon could not be reached.
|
||||
"""
|
||||
if is_standalone_forced():
|
||||
return False, None
|
||||
|
||||
if is_daemon_running(host, port):
|
||||
return True, f"http://{host}:{port}"
|
||||
|
||||
# Auto-spawn Daemon in background
|
||||
spawn_daemon(host, port)
|
||||
|
||||
if not wait_ready:
|
||||
return True, f"http://{host}:{port}"
|
||||
|
||||
# Poll until ready or timeout
|
||||
started = time.monotonic()
|
||||
while time.monotonic() - started < timeout:
|
||||
time.sleep(0.1)
|
||||
if is_daemon_running(host, port):
|
||||
return True, f"http://{host}:{port}"
|
||||
|
||||
logger.warning(f"Daemon did not respond within {timeout}s. Falling back to standalone mode.")
|
||||
return False, None
|
||||
|
||||
|
||||
def submit_task_to_daemon(
|
||||
goal: str,
|
||||
*,
|
||||
profile: str = "flash",
|
||||
device_serial: str | None = None,
|
||||
expected_output: str | None = None,
|
||||
enable_outputter: bool | None = None,
|
||||
locked_app_package: str | None = None,
|
||||
app_path: str | None = None,
|
||||
base_url: str | None = None,
|
||||
timeout: float = 5.0,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Submit a task to the running Daemon via REST API.
|
||||
|
||||
Returns the response JSON dict if successfully enqueued, or None on error.
|
||||
"""
|
||||
url = f"{base_url or f'http://{DEFAULT_DAEMON_HOST}:{DEFAULT_DAEMON_PORT}'}/api/run"
|
||||
payload = {
|
||||
"goal": goal,
|
||||
"profile": profile,
|
||||
"device_serial": device_serial,
|
||||
"expected_output": expected_output,
|
||||
"enable_outputter": enable_outputter,
|
||||
"locked_app_package": locked_app_package,
|
||||
"app_path": app_path,
|
||||
}
|
||||
|
||||
try:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json", "User-Agent": "Artemis-Client"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
if response.status in (200, 201):
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except Exception as exc:
|
||||
logger.debug(f"Failed to submit task to Daemon at {url}: {exc}")
|
||||
return None
|
||||
return None
|
||||
+396
-92
@@ -12,7 +12,7 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Cross-process FIFO execution queue for the local Artemis device."""
|
||||
"""Cross-process FIFO execution queue and per-device mutex for Artemis devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,6 +21,7 @@ from datetime import UTC, datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
@@ -31,6 +32,13 @@ class DeviceBusyError(RuntimeError):
|
||||
"""Raised when another Artemis process already owns a device."""
|
||||
|
||||
|
||||
class ConcurrencyMode:
|
||||
"""Supported concurrency strategies for Artemis task execution."""
|
||||
|
||||
GLOBAL = "global" # Strict serial mode: 1 task globally across all devices
|
||||
PER_DEVICE = "per_device" # 1 task per device: concurrent across different devices
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceLockOwner:
|
||||
pid: int
|
||||
@@ -61,45 +69,73 @@ class DeviceLockOwner:
|
||||
|
||||
|
||||
class DeviceExecutionLock:
|
||||
"""A PID-aware FIFO lease shared by UI, CLI, SDK, and MCP tasks.
|
||||
"""A PID-aware FIFO lease supporting per-device mutexes and global concurrency limits.
|
||||
|
||||
Artemis intentionally supports one active local device. A single global
|
||||
queue therefore orders every local task regardless of which API or process
|
||||
submitted it. Submission layers may reserve a queue ticket before spawning
|
||||
a worker; the worker receives that token through the environment and claims
|
||||
the same position. Tasks without a submission layer reserve at acquire time.
|
||||
Artemis enforces a single active task per mobile device at any time.
|
||||
Different devices can execute tasks concurrently unless a global concurrency
|
||||
limit (e.g. concurrency_mode="global" or ARTEMIS_MAX_CONCURRENT_TASKS=1) is configured.
|
||||
|
||||
Atomic file creation provides exclusion. PID creation time prevents stale
|
||||
owners or queue tickets from blocking execution after a process exits or
|
||||
after the operating system reuses the same PID.
|
||||
Atomic file creation provides exclusion per device serial. PID creation time
|
||||
prevents stale owners or queue tickets from blocking execution after a process
|
||||
exits or after the operating system reuses the same PID.
|
||||
"""
|
||||
|
||||
_MALFORMED_LOCK_GRACE_SECONDS = 5.0
|
||||
_DEFAULT_POLL_INTERVAL_SECONDS = 0.1
|
||||
QUEUE_TICKET_ENV = "ARTEMIS_DEVICE_QUEUE_TICKET"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_device_id(device_id: str | None) -> str:
|
||||
"""Normalize device serial/identifier to a filesystem-safe string."""
|
||||
if not device_id or str(device_id).strip() in ("", "default-device", "default"):
|
||||
return "default"
|
||||
raw = str(device_id).strip()
|
||||
if raw.lower() in ("pending", "any"):
|
||||
return raw.lower()
|
||||
clean = re.sub(r"[^a-zA-Z0-9_-]", "_", raw)
|
||||
return clean or "default"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str,
|
||||
device_id: str = "default",
|
||||
description: str = "Artemis task",
|
||||
queue_ticket: str | None = None,
|
||||
max_concurrency: int | None = None,
|
||||
concurrency_mode: str | None = None,
|
||||
session_id: str | None = None,
|
||||
ingress: str | None = None,
|
||||
):
|
||||
self.device_id = device_id
|
||||
self.device_id = device_id or "default"
|
||||
self.clean_device_id = self._normalize_device_id(self.device_id)
|
||||
self.description = description
|
||||
self.max_concurrency = max_concurrency
|
||||
self.concurrency_mode = (
|
||||
str(concurrency_mode).strip().lower() if concurrency_mode is not None else None
|
||||
)
|
||||
self.token = uuid.uuid4().hex
|
||||
|
||||
lock_dir = get_temp_dir("device-locks")
|
||||
self.path = lock_dir / "artemis-global-device.lock"
|
||||
# Per-device lock file
|
||||
self.path = lock_dir / f"artemis-device-{self.clean_device_id}.lock"
|
||||
# Shared FIFO wait queue directory across all processes
|
||||
self.queue_dir = lock_dir / "artemis-global-device.queue"
|
||||
self.queue_ticket = queue_ticket or os.environ.pop(self.QUEUE_TICKET_ENV, None)
|
||||
self.session_id = os.getenv("ARTEMIS_SESSION_ID") or os.getenv(
|
||||
self._initial_queue_ticket = queue_ticket or os.environ.pop(self.QUEUE_TICKET_ENV, None)
|
||||
self.queue_ticket = self._initial_queue_ticket
|
||||
self.session_id = session_id or os.getenv("ARTEMIS_SESSION_ID") or os.getenv(
|
||||
"ARTEMIS_CLOUD_SESSION_ID"
|
||||
)
|
||||
self.ingress = os.getenv("ARTEMIS_TASK_INGRESS") or "sdk"
|
||||
self.ingress = ingress or os.getenv("ARTEMIS_TASK_INGRESS") or "sdk"
|
||||
self._queue_path: Path | None = None
|
||||
self._acquired = False
|
||||
|
||||
@classmethod
|
||||
def _device_lock_path(cls, device_id: str | None) -> Path:
|
||||
clean_id = cls._normalize_device_id(device_id)
|
||||
return get_temp_dir("device-locks") / f"artemis-device-{clean_id}.lock"
|
||||
|
||||
@classmethod
|
||||
def _global_lock_path(cls) -> Path:
|
||||
# Kept for backward compatibility and fallback detection
|
||||
return get_temp_dir("device-locks") / "artemis-global-device.lock"
|
||||
|
||||
@staticmethod
|
||||
@@ -111,8 +147,34 @@ class DeviceExecutionLock:
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _safe_unlink(path: Path, max_retries: int = 3, delay: float = 0.02) -> bool:
|
||||
"""Safely unlink a file with retries on Windows PermissionError / sharing violations."""
|
||||
for i in range(max_retries):
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
return True
|
||||
except OSError:
|
||||
if i < max_retries - 1:
|
||||
time.sleep(delay)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _safe_replace(src: Path, dst: Path, max_retries: int = 3, delay: float = 0.02) -> bool:
|
||||
"""Safely replace a file with retries on Windows PermissionError / sharing violations."""
|
||||
for i in range(max_retries):
|
||||
try:
|
||||
os.replace(src, dst)
|
||||
return True
|
||||
except OSError:
|
||||
if i < max_retries - 1:
|
||||
time.sleep(delay)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _owner_is_alive(owner: DeviceLockOwner) -> bool:
|
||||
if owner.pid <= 0:
|
||||
return False
|
||||
try:
|
||||
import psutil
|
||||
|
||||
@@ -120,7 +182,12 @@ class DeviceExecutionLock:
|
||||
if not process.is_running():
|
||||
return False
|
||||
if owner.process_created_at <= 0:
|
||||
return True
|
||||
try:
|
||||
name = process.name().lower()
|
||||
cmdline = " ".join(process.cmdline()).lower()
|
||||
return any(k in name or k in cmdline for k in ("python", "artemis", "pytest"))
|
||||
except Exception:
|
||||
return False
|
||||
return abs(process.create_time() - owner.process_created_at) < 1.0
|
||||
except Exception:
|
||||
return False
|
||||
@@ -145,11 +212,7 @@ class DeviceExecutionLock:
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
try:
|
||||
self.path.unlink(missing_ok=True)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return self._safe_unlink(self.path)
|
||||
|
||||
@classmethod
|
||||
def _owner_payload(
|
||||
@@ -184,7 +247,7 @@ class DeviceExecutionLock:
|
||||
session_id: str | None = None,
|
||||
ingress: str | None = None,
|
||||
) -> str:
|
||||
"""Reserve a global FIFO position before a worker process is spawned."""
|
||||
"""Reserve a FIFO position in the global queue before a worker process is spawned."""
|
||||
token = uuid.uuid4().hex
|
||||
queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
|
||||
queue_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -216,12 +279,13 @@ class DeviceExecutionLock:
|
||||
return False
|
||||
queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
|
||||
removed = False
|
||||
for path in queue_dir.glob(f"*-{token}.wait"):
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
removed = True
|
||||
except OSError:
|
||||
pass
|
||||
if queue_dir.exists():
|
||||
for path in queue_dir.glob(f"*-{token}.wait"):
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
removed = True
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
@classmethod
|
||||
@@ -235,13 +299,10 @@ class DeviceExecutionLock:
|
||||
session_id: str | None = None,
|
||||
ingress: str | None = None,
|
||||
) -> bool:
|
||||
"""Transfer a submission ticket to its worker process.
|
||||
|
||||
This makes a ticket stale as soon as a worker that failed before Agent
|
||||
initialization exits, instead of keeping it alive with the submitting
|
||||
server process.
|
||||
"""
|
||||
"""Transfer a submission ticket to its worker process."""
|
||||
queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
|
||||
if not queue_dir.exists():
|
||||
return False
|
||||
matches = list(queue_dir.glob(f"*-{token}.wait"))
|
||||
path = min(matches, default=None)
|
||||
if path is None:
|
||||
@@ -294,7 +355,12 @@ class DeviceExecutionLock:
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
return path
|
||||
|
||||
self.queue_ticket = self.reserve(self.description, self.device_id)
|
||||
self.queue_ticket = self.reserve(
|
||||
self.description,
|
||||
self.device_id,
|
||||
session_id=self.session_id,
|
||||
ingress=self.ingress,
|
||||
)
|
||||
path = self._find_reserved_ticket()
|
||||
if path is None:
|
||||
raise DeviceBusyError("The Artemis device queue ticket could not be created.")
|
||||
@@ -334,6 +400,17 @@ class DeviceExecutionLock:
|
||||
try:
|
||||
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
current_owner = self._read_owner(self.path)
|
||||
if (
|
||||
current_owner is not None
|
||||
and current_owner.pid == os.getpid()
|
||||
and (
|
||||
current_owner.token == self.token
|
||||
or (self.session_id and current_owner.session_id == self.session_id)
|
||||
)
|
||||
):
|
||||
self._acquired = True
|
||||
return True
|
||||
self._remove_stale_lock()
|
||||
return False
|
||||
try:
|
||||
@@ -343,6 +420,31 @@ class DeviceExecutionLock:
|
||||
self._acquired = True
|
||||
return True
|
||||
|
||||
@property
|
||||
def effective_concurrency(self) -> int:
|
||||
"""Resolve effective concurrency limit:
|
||||
0: per-device concurrency (multiple devices run concurrently, FIFO per device)
|
||||
1: global concurrency (strict single-task serialization across all devices)
|
||||
>1: global concurrency capped at N tasks across all devices
|
||||
"""
|
||||
if self.concurrency_mode is not None:
|
||||
mode = self.concurrency_mode
|
||||
if mode in ("global", "serial", "1"):
|
||||
return 1
|
||||
if mode in ("per_device", "device", "parallel", "0"):
|
||||
return 0
|
||||
if self.max_concurrency is not None:
|
||||
return self.max_concurrency
|
||||
env_mode = os.environ.get("ARTEMIS_CONCURRENCY_MODE", "").strip().lower()
|
||||
if env_mode in ("global", "serial", "1"):
|
||||
return 1
|
||||
if env_mode in ("per_device", "device", "parallel", "0"):
|
||||
return 0
|
||||
try:
|
||||
return int(os.environ.get("ARTEMIS_MAX_CONCURRENT_TASKS", 0))
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
@@ -351,20 +453,71 @@ class DeviceExecutionLock:
|
||||
poll_interval: float = _DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
cancel_event=None,
|
||||
) -> None:
|
||||
"""Wait in FIFO order and acquire exclusive access to the Artemis device."""
|
||||
"""Wait in FIFO order and acquire exclusive access to the target Artemis device."""
|
||||
self._queue_path = self._claim_or_create_queue_ticket()
|
||||
started_at = time.monotonic()
|
||||
effective_concurrency = self.effective_concurrency
|
||||
try:
|
||||
while True:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise DeviceBusyError("Waiting for the Artemis device queue was cancelled.")
|
||||
self._remove_stale_queue_entries()
|
||||
queue = sorted(self.queue_dir.glob("*.wait"))
|
||||
|
||||
if not self._queue_path.exists():
|
||||
raise DeviceBusyError("The Artemis queue reservation was cancelled.")
|
||||
|
||||
if queue and queue[0] == self._queue_path and self._try_acquire_owner_lock():
|
||||
self._queue_path.unlink(missing_ok=True)
|
||||
# 1. Check global concurrency limit if configured (> 0)
|
||||
if effective_concurrency > 0:
|
||||
active_owners = self.get_active_owners()
|
||||
active_other_owners = [
|
||||
o
|
||||
for o in active_owners.values()
|
||||
if not (
|
||||
(o.pid == os.getpid() and ((self.session_id and o.session_id == self.session_id) or o.token == self.token))
|
||||
)
|
||||
]
|
||||
if len(active_other_owners) >= effective_concurrency:
|
||||
if not blocking:
|
||||
owner_desc = (
|
||||
active_other_owners[0].description
|
||||
if active_other_owners
|
||||
else "active task"
|
||||
)
|
||||
raise DeviceBusyError(
|
||||
f"Global task concurrency limit ({effective_concurrency}) reached ({owner_desc})."
|
||||
)
|
||||
if timeout is not None and time.monotonic() - started_at >= timeout:
|
||||
raise DeviceBusyError(
|
||||
f"Timed out waiting for a concurrency slot after {timeout:.1f}s."
|
||||
)
|
||||
time.sleep(max(0.01, poll_interval))
|
||||
continue
|
||||
|
||||
# 2. Determine FIFO eligibility for this specific device
|
||||
all_wait_files = sorted(self.queue_dir.glob("*.wait"))
|
||||
if effective_concurrency == 1:
|
||||
# In strict serial mode, global FIFO applies; re-entrant parent processes are always eligible
|
||||
active_owners = self.get_active_owners()
|
||||
is_reentrant = any(
|
||||
o.pid == os.getpid() and ((self.session_id and o.session_id == self.session_id) or o.token == self.token)
|
||||
for o in active_owners.values()
|
||||
)
|
||||
is_eligible = is_reentrant or bool(all_wait_files and all_wait_files[0] == self._queue_path)
|
||||
else:
|
||||
# In multi-device parallel mode, per-device FIFO applies
|
||||
target_dev = self.clean_device_id
|
||||
device_queue: list[Path] = []
|
||||
for wait_path in all_wait_files:
|
||||
ticket_owner = self._read_owner(wait_path)
|
||||
if ticket_owner is None:
|
||||
continue
|
||||
ticket_dev = self._normalize_device_id(ticket_owner.device_id)
|
||||
if ticket_dev == target_dev or ticket_dev in ("pending", "any"):
|
||||
device_queue.append(wait_path)
|
||||
is_eligible = bool(device_queue and device_queue[0] == self._queue_path)
|
||||
|
||||
if is_eligible and self._try_acquire_owner_lock():
|
||||
self._safe_unlink(self._queue_path)
|
||||
self._queue_path = None
|
||||
return
|
||||
|
||||
@@ -385,8 +538,10 @@ class DeviceExecutionLock:
|
||||
time.sleep(max(0.01, poll_interval))
|
||||
except BaseException:
|
||||
if self._queue_path is not None:
|
||||
self._queue_path.unlink(missing_ok=True)
|
||||
self._safe_unlink(self._queue_path)
|
||||
self._queue_path = None
|
||||
if not self._initial_queue_ticket:
|
||||
self.queue_ticket = None
|
||||
raise
|
||||
|
||||
def release(self) -> None:
|
||||
@@ -394,15 +549,16 @@ class DeviceExecutionLock:
|
||||
return
|
||||
try:
|
||||
owner = self._read_owner(self.path)
|
||||
if owner is not None and owner.token == self.token:
|
||||
self.path.unlink(missing_ok=True)
|
||||
if owner is not None and (
|
||||
owner.token == self.token
|
||||
or (self.session_id and owner.session_id == self.session_id)
|
||||
):
|
||||
self._safe_unlink(self.path)
|
||||
finally:
|
||||
self._acquired = False
|
||||
|
||||
@classmethod
|
||||
def get_active_owner(cls) -> DeviceLockOwner | None:
|
||||
"""Return the live process that currently owns the global device lease."""
|
||||
path = cls._global_lock_path()
|
||||
def _get_active_owner_for_path(cls, path: Path) -> DeviceLockOwner | None:
|
||||
owner = None
|
||||
for attempt in range(3):
|
||||
owner = cls._read_owner(path)
|
||||
@@ -413,27 +569,135 @@ class DeviceExecutionLock:
|
||||
if owner is None:
|
||||
try:
|
||||
if path.exists() and time.time() - path.stat().st_mtime >= cls._MALFORMED_LOCK_GRACE_SECONDS:
|
||||
path.unlink(missing_ok=True)
|
||||
cls._safe_unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
if cls._owner_is_alive(owner):
|
||||
return owner
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
cls._safe_unlink(path)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def has_owner_record(cls) -> bool:
|
||||
"""Return whether an owner record exists, including a record being written."""
|
||||
return cls._global_lock_path().exists()
|
||||
def get_active_owners(cls) -> dict[str, DeviceLockOwner]:
|
||||
"""Return a mapping of clean_device_id -> live DeviceLockOwner across all devices."""
|
||||
lock_dir = get_temp_dir("device-locks")
|
||||
if not lock_dir.exists():
|
||||
return {}
|
||||
active_map: dict[str, DeviceLockOwner] = {}
|
||||
seen_tokens = set()
|
||||
|
||||
for lock_path in sorted(lock_dir.glob("artemis-device-*.lock")):
|
||||
owner = cls._get_active_owner_for_path(lock_path)
|
||||
if owner is not None and owner.token not in seen_tokens:
|
||||
dev_key = cls._normalize_device_id(owner.device_id)
|
||||
active_map[dev_key] = owner
|
||||
seen_tokens.add(owner.token)
|
||||
|
||||
# Also check legacy global lock if still present
|
||||
global_lock = lock_dir / "artemis-global-device.lock"
|
||||
if global_lock.exists():
|
||||
owner = cls._get_active_owner_for_path(global_lock)
|
||||
if owner is not None and owner.token not in seen_tokens:
|
||||
dev_key = cls._normalize_device_id(owner.device_id)
|
||||
active_map[dev_key] = owner
|
||||
seen_tokens.add(owner.token)
|
||||
|
||||
return active_map
|
||||
|
||||
@classmethod
|
||||
def is_active_owner(cls, expected: DeviceLockOwner) -> bool:
|
||||
def get_active_owner(cls, device_id: str | None = None) -> DeviceLockOwner | None:
|
||||
"""Return the live process owning the lock for a specific device, or any device if None."""
|
||||
lock_dir = get_temp_dir("device-locks")
|
||||
if device_id is not None:
|
||||
clean_id = cls._normalize_device_id(device_id)
|
||||
dev_path = lock_dir / f"artemis-device-{clean_id}.lock"
|
||||
owner = cls._get_active_owner_for_path(dev_path)
|
||||
if owner is not None:
|
||||
return owner
|
||||
# Check legacy global lock as fallback
|
||||
global_path = lock_dir / "artemis-global-device.lock"
|
||||
if global_path.exists():
|
||||
return cls._get_active_owner_for_path(global_path)
|
||||
return None
|
||||
|
||||
# Return any active owner for single-device caller backward compatibility
|
||||
active_owners = cls.get_active_owners()
|
||||
for owner in active_owners.values():
|
||||
return owner
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_queued_tasks(cls, device_id: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Return all live pending queue tickets across all devices in FIFO order."""
|
||||
lock_dir = get_temp_dir("device-locks")
|
||||
queue_dir = lock_dir / "artemis-global-device.queue"
|
||||
if not queue_dir.exists():
|
||||
return []
|
||||
|
||||
clean_target = cls._normalize_device_id(device_id) if device_id else None
|
||||
active_tokens = {o.token for o in cls.get_active_owners().values()}
|
||||
queued: list[dict[str, Any]] = []
|
||||
|
||||
for path in sorted(queue_dir.glob("*.wait")):
|
||||
owner = cls._read_owner(path)
|
||||
if owner is None:
|
||||
continue
|
||||
# If the owner process died, ignore
|
||||
if not cls._owner_is_alive(owner):
|
||||
continue
|
||||
# If this token already acquired the lock, it is actively running, not pending
|
||||
if owner.token in active_tokens:
|
||||
continue
|
||||
|
||||
target_dev = cls._normalize_device_id(owner.device_id)
|
||||
if clean_target is not None and target_dev not in (clean_target, "pending", "any"):
|
||||
continue
|
||||
|
||||
try:
|
||||
created_at = path.stat().st_mtime
|
||||
except OSError:
|
||||
created_at = time.time()
|
||||
|
||||
queued.append({
|
||||
"session_id": owner.session_id or f"queued-{owner.token[:8]}",
|
||||
"goal": owner.description,
|
||||
"device_id": owner.device_id,
|
||||
"device_serial": owner.device_id,
|
||||
"pid": owner.pid,
|
||||
"token": owner.token,
|
||||
"ingress": owner.ingress or "unknown",
|
||||
"status": "pending",
|
||||
"created_at": created_at,
|
||||
"start_time": created_at,
|
||||
})
|
||||
return queued
|
||||
|
||||
@classmethod
|
||||
def has_owner_record(cls, device_id: str | None = None) -> bool:
|
||||
"""Return whether an owner record exists, including a record being written."""
|
||||
lock_dir = get_temp_dir("device-locks")
|
||||
if not lock_dir.exists():
|
||||
return False
|
||||
if device_id is not None:
|
||||
clean_id = cls._normalize_device_id(device_id)
|
||||
return (
|
||||
lock_dir / f"artemis-device-{clean_id}.lock"
|
||||
).exists() or (lock_dir / "artemis-global-device.lock").exists()
|
||||
return bool(list(lock_dir.glob("artemis-device-*.lock"))) or (
|
||||
lock_dir / "artemis-global-device.lock"
|
||||
).exists()
|
||||
|
||||
@classmethod
|
||||
def is_active_owner(cls, expected: DeviceLockOwner, device_id: str | None = None) -> bool:
|
||||
"""Revalidate a previously read owner before performing a destructive action."""
|
||||
current = cls._read_owner(cls._global_lock_path())
|
||||
target_dev = device_id or expected.device_id
|
||||
lock_dir = get_temp_dir("device-locks")
|
||||
clean_id = cls._normalize_device_id(target_dev)
|
||||
path = lock_dir / f"artemis-device-{clean_id}.lock"
|
||||
if not path.exists():
|
||||
path = lock_dir / "artemis-global-device.lock"
|
||||
current = cls._read_owner(path)
|
||||
return bool(
|
||||
current
|
||||
and current.token == expected.token
|
||||
@@ -442,15 +706,42 @@ class DeviceExecutionLock:
|
||||
and cls._owner_is_alive(current)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _find_lock_by_pid(cls, pid: int) -> Path | None:
|
||||
lock_dir = get_temp_dir("device-locks")
|
||||
if not lock_dir.exists():
|
||||
return None
|
||||
for path in sorted(lock_dir.glob("artemis-device-*.lock")):
|
||||
owner = cls._read_owner(path)
|
||||
if owner is not None and owner.pid == pid:
|
||||
return path
|
||||
global_path = lock_dir / "artemis-global-device.lock"
|
||||
if global_path.exists():
|
||||
owner = cls._read_owner(global_path)
|
||||
if owner is not None and owner.pid == pid:
|
||||
return global_path
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def annotate_active_owner(
|
||||
cls,
|
||||
*,
|
||||
session_id: str,
|
||||
ingress: str | None = None,
|
||||
device_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Attach session metadata after tracing starts inside the lease owner."""
|
||||
path = cls._global_lock_path()
|
||||
lock_dir = get_temp_dir("device-locks")
|
||||
if device_id is not None:
|
||||
clean_id = cls._normalize_device_id(device_id)
|
||||
path = lock_dir / f"artemis-device-{clean_id}.lock"
|
||||
if not path.exists():
|
||||
path = lock_dir / "artemis-global-device.lock"
|
||||
else:
|
||||
path = cls._find_lock_by_pid(os.getpid())
|
||||
if path is None:
|
||||
path = lock_dir / "artemis-global-device.lock"
|
||||
|
||||
owner = cls._read_owner(path)
|
||||
if owner is None or owner.pid != os.getpid() or not cls._owner_is_alive(owner):
|
||||
return False
|
||||
@@ -467,50 +758,63 @@ class DeviceExecutionLock:
|
||||
temp_path = path.with_name(f"{path.name}.{uuid.uuid4().hex}.tmp")
|
||||
try:
|
||||
temp_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
os.replace(temp_path, path)
|
||||
if not cls._safe_replace(temp_path, path):
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
cls._safe_unlink(temp_path)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def cleanup_stale_locks(cls) -> int:
|
||||
def cleanup_stale_locks(cls, device_id: str | None = None) -> int:
|
||||
"""Remove execution leases and queue tickets whose owners are gone."""
|
||||
removed = 0
|
||||
lock_dir = get_temp_dir("device-locks")
|
||||
for path in lock_dir.glob("*.lock"):
|
||||
owner = cls._read_owner(path)
|
||||
if owner is not None and cls._owner_is_alive(owner):
|
||||
continue
|
||||
if owner is None:
|
||||
try:
|
||||
if time.time() - path.stat().st_mtime < cls._MALFORMED_LOCK_GRACE_SECONDS:
|
||||
continue
|
||||
except OSError:
|
||||
if not lock_dir.exists():
|
||||
return removed
|
||||
|
||||
patterns = (
|
||||
[f"artemis-device-{cls._normalize_device_id(device_id)}.lock"]
|
||||
if device_id
|
||||
else ["artemis-device-*.lock", "artemis-global-device.lock", "*.lock"]
|
||||
)
|
||||
seen_paths: set[Path] = set()
|
||||
for pat in patterns:
|
||||
for path in lock_dir.glob(pat):
|
||||
if path in seen_paths:
|
||||
continue
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
queue_dir = lock_dir / "artemis-global-device.queue"
|
||||
for path in queue_dir.glob("*.wait"):
|
||||
owner = cls._read_owner(path)
|
||||
if owner is not None and cls._owner_is_alive(owner):
|
||||
continue
|
||||
if owner is None:
|
||||
try:
|
||||
if time.time() - path.stat().st_mtime < cls._MALFORMED_LOCK_GRACE_SECONDS:
|
||||
continue
|
||||
except OSError:
|
||||
seen_paths.add(path)
|
||||
owner = cls._read_owner(path)
|
||||
if owner is not None and cls._owner_is_alive(owner):
|
||||
continue
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
if owner is None:
|
||||
try:
|
||||
if (
|
||||
time.time() - path.stat().st_mtime
|
||||
< cls._MALFORMED_LOCK_GRACE_SECONDS
|
||||
):
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
if cls._safe_unlink(path):
|
||||
removed += 1
|
||||
|
||||
for q_dir in lock_dir.glob("*.queue"):
|
||||
if q_dir.is_dir():
|
||||
for path in q_dir.glob("*.wait"):
|
||||
owner = cls._read_owner(path)
|
||||
if owner is not None and cls._owner_is_alive(owner):
|
||||
continue
|
||||
if owner is None:
|
||||
try:
|
||||
if (
|
||||
time.time() - path.stat().st_mtime
|
||||
< cls._MALFORMED_LOCK_GRACE_SECONDS
|
||||
):
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
if cls._safe_unlink(path):
|
||||
removed += 1
|
||||
return removed
|
||||
@@ -0,0 +1,243 @@
|
||||
# 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 Pool Manager for discovering, tracking, and allocating mobile devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from artemis.runtime.device_lock import DeviceExecutionLock
|
||||
from artemis.toolchain import toolchain
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceStatus:
|
||||
"""State and lock allocation metadata for a connected device."""
|
||||
|
||||
serial: str
|
||||
state: str # "device", "offline", "unauthorized", etc.
|
||||
model: str | None = None
|
||||
product: str | None = None
|
||||
is_emulator: bool = False
|
||||
is_busy: bool = False
|
||||
active_pid: int | None = None
|
||||
active_task_desc: str | None = None
|
||||
active_session_id: str | None = None
|
||||
acquired_at: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"serial": self.serial,
|
||||
"state": self.state,
|
||||
"model": self.model,
|
||||
"product": self.product,
|
||||
"is_emulator": self.is_emulator,
|
||||
"is_busy": self.is_busy,
|
||||
"active_pid": self.active_pid,
|
||||
"active_task_desc": self.active_task_desc,
|
||||
"active_session_id": self.active_session_id,
|
||||
"acquired_at": self.acquired_at,
|
||||
}
|
||||
|
||||
|
||||
class DevicePool:
|
||||
"""Manages discovery and assignment across all connected Android devices."""
|
||||
|
||||
def __init__(self, adb_path: str | None = None):
|
||||
self._adb_path = adb_path
|
||||
|
||||
def _resolve_adb(self) -> str | None:
|
||||
if self._adb_path:
|
||||
return self._adb_path
|
||||
try:
|
||||
return toolchain.resolve("adb") or shutil.which("adb")
|
||||
except Exception:
|
||||
return shutil.which("adb")
|
||||
|
||||
def _query_adb_devices_sync(self, timeout: float = 2.0) -> list[tuple[str, str, str | None, str | None]]:
|
||||
"""Run `adb devices -l` synchronously to parse attached devices."""
|
||||
adb = self._resolve_adb()
|
||||
if not adb:
|
||||
return []
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[adb, "devices", "-l"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
return []
|
||||
return self._parse_device_lines(res.stdout.splitlines())
|
||||
except Exception as exc:
|
||||
logger.debug(f"Error querying adb devices: {exc}")
|
||||
return []
|
||||
|
||||
async def _query_adb_devices_async(self, timeout: float = 2.0) -> list[tuple[str, str, str | None, str | None]]:
|
||||
"""Run `adb devices -l` asynchronously to parse attached devices."""
|
||||
adb = self._resolve_adb()
|
||||
if not adb:
|
||||
return []
|
||||
proc = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
adb,
|
||||
"devices",
|
||||
"-l",
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
return self._parse_device_lines(stdout.decode(errors="replace").splitlines())
|
||||
except Exception as exc:
|
||||
logger.debug(f"Error querying adb devices asynchronously: {exc}")
|
||||
if proc is not None:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _parse_device_lines(lines: list[str]) -> list[tuple[str, str, str | None, str | None]]:
|
||||
results: list[tuple[str, str, str | None, str | None]] = []
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("List of devices"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
serial = parts[0]
|
||||
state = parts[1]
|
||||
model = None
|
||||
product = None
|
||||
for token in parts[2:]:
|
||||
if token.startswith("model:"):
|
||||
model = token.split(":", 1)[1].replace("_", " ")
|
||||
elif token.startswith("product:"):
|
||||
product = token.split(":", 1)[1]
|
||||
results.append((serial, state, model, product))
|
||||
return results
|
||||
|
||||
def list_devices(self) -> list[DeviceStatus]:
|
||||
"""Synchronously list all connected devices along with their active lock state."""
|
||||
raw_devices = self._query_adb_devices_sync()
|
||||
active_owners = DeviceExecutionLock.get_active_owners()
|
||||
|
||||
devices: list[DeviceStatus] = []
|
||||
for serial, state, model, product in raw_devices:
|
||||
is_emu = serial.startswith("emulator-") or "127.0.0.1" in serial or "localhost" in serial
|
||||
clean_id = DeviceExecutionLock._normalize_device_id(serial)
|
||||
owner = active_owners.get(clean_id)
|
||||
|
||||
status = DeviceStatus(
|
||||
serial=serial,
|
||||
state=state,
|
||||
model=model,
|
||||
product=product,
|
||||
is_emulator=is_emu,
|
||||
is_busy=owner is not None,
|
||||
active_pid=owner.pid if owner else None,
|
||||
active_task_desc=owner.description if owner else None,
|
||||
active_session_id=owner.session_id if owner else None,
|
||||
acquired_at=owner.acquired_at if owner else None,
|
||||
)
|
||||
devices.append(status)
|
||||
return devices
|
||||
|
||||
async def list_devices_async(self) -> list[DeviceStatus]:
|
||||
"""Asynchronously list all connected devices along with their active lock state."""
|
||||
raw_devices = await self._query_adb_devices_async()
|
||||
active_owners = DeviceExecutionLock.get_active_owners()
|
||||
|
||||
devices: list[DeviceStatus] = []
|
||||
for serial, state, model, product in raw_devices:
|
||||
is_emu = serial.startswith("emulator-") or "127.0.0.1" in serial or "localhost" in serial
|
||||
clean_id = DeviceExecutionLock._normalize_device_id(serial)
|
||||
owner = active_owners.get(clean_id)
|
||||
|
||||
status = DeviceStatus(
|
||||
serial=serial,
|
||||
state=state,
|
||||
model=model,
|
||||
product=product,
|
||||
is_emulator=is_emu,
|
||||
is_busy=owner is not None,
|
||||
active_pid=owner.pid if owner else None,
|
||||
active_task_desc=owner.description if owner else None,
|
||||
active_session_id=owner.session_id if owner else None,
|
||||
acquired_at=owner.acquired_at if owner else None,
|
||||
)
|
||||
devices.append(status)
|
||||
return devices
|
||||
|
||||
def get_ready_devices(self) -> list[DeviceStatus]:
|
||||
"""Return devices in authorized 'device' state."""
|
||||
return [d for d in self.list_devices() if d.state == "device"]
|
||||
|
||||
def get_idle_devices(self) -> list[DeviceStatus]:
|
||||
"""Return ready devices that are not currently holding an execution lock."""
|
||||
return [d for d in self.list_devices() if d.state == "device" and not d.is_busy]
|
||||
|
||||
def select_device(self, preferred_serial: str | None = None) -> str | None:
|
||||
"""Select a target device serial for task execution.
|
||||
|
||||
If `preferred_serial` is provided:
|
||||
Returns preferred_serial if it is present or online.
|
||||
Otherwise:
|
||||
Selects the first idle/unlocked ready device. If all are busy,
|
||||
selects the first ready device so the task will queue on it.
|
||||
Returns None if no devices are attached.
|
||||
"""
|
||||
all_devs = self.list_devices()
|
||||
ready_devs = [d for d in all_devs if d.state == "device"]
|
||||
|
||||
if preferred_serial:
|
||||
# Check if preferred is available or exists
|
||||
for dev in all_devs:
|
||||
if dev.serial == preferred_serial:
|
||||
return dev.serial
|
||||
# If not detected by ADB, still return it so ADB can attempt connection
|
||||
return preferred_serial
|
||||
|
||||
# Pick first idle device
|
||||
for dev in ready_devs:
|
||||
if not dev.is_busy:
|
||||
return dev.serial
|
||||
|
||||
# If all busy, return the first ready device (it will queue)
|
||||
if ready_devs:
|
||||
return ready_devs[0].serial
|
||||
|
||||
# Fallback to any attached device if none ready
|
||||
if all_devs:
|
||||
return all_devs[0].serial
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Global device pool instance
|
||||
device_pool = DevicePool()
|
||||
@@ -0,0 +1,318 @@
|
||||
# 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.
|
||||
|
||||
"""Artemis Server Lifecycle and Process Management.
|
||||
|
||||
Provides cross-platform facilities to discover, inspect, terminate,
|
||||
and restart running Artemis UI / backend server instances regardless
|
||||
of which terminal or process spawned them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from artemis.config.paths import ROOT_DIR, get_server_info_file
|
||||
from artemis.runtime.device_lock import DeviceExecutionLock
|
||||
from artemis.runtime.supervisor import ProcessSupervisor
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def is_port_in_use(port: int, host: str = "127.0.0.1", timeout: float = 0.5) -> bool:
|
||||
"""Check whether a TCP port is currently listening / occupied."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(timeout)
|
||||
try:
|
||||
return s.connect_ex((host, port)) == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def write_server_info(
|
||||
port: int,
|
||||
host: str = "0.0.0.0",
|
||||
pid: int | None = None,
|
||||
) -> Path:
|
||||
"""Persist current Artemis server runtime metadata to well-known path."""
|
||||
info_file = get_server_info_file()
|
||||
try:
|
||||
info_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"pid": pid or os.getpid(),
|
||||
"port": port,
|
||||
"host": host,
|
||||
"started_at": time.time(),
|
||||
"cwd": str(ROOT_DIR),
|
||||
"sys_executable": sys.executable,
|
||||
"cmdline": sys.argv,
|
||||
}
|
||||
info_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
logger.debug(f"Saved Artemis server info (PID: {data['pid']}, port: {port}) to {info_file}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not persist server metadata to {info_file}: {e}")
|
||||
return info_file
|
||||
|
||||
|
||||
def read_server_info() -> dict[str, Any] | None:
|
||||
"""Read and validate the persisted server runtime metadata if present."""
|
||||
info_file = get_server_info_file()
|
||||
if not info_file.exists():
|
||||
return None
|
||||
try:
|
||||
content = info_file.read_text(encoding="utf-8").strip()
|
||||
if not content:
|
||||
return None
|
||||
data = json.loads(content)
|
||||
if isinstance(data, dict) and "pid" in data:
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to read server info from {info_file}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def clear_server_info() -> None:
|
||||
"""Remove the persisted server metadata file if it exists."""
|
||||
info_file = get_server_info_file()
|
||||
try:
|
||||
if info_file.exists():
|
||||
info_file.unlink()
|
||||
logger.debug(f"Cleared server info file at {info_file}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error clearing server info file {info_file}: {e}")
|
||||
|
||||
|
||||
def find_server_pids(port: int = 8000) -> list[int]:
|
||||
"""Find all process IDs associated with the Artemis server listening on `port`.
|
||||
|
||||
Uses a multi-tiered discovery strategy:
|
||||
1. Check persisted server metadata file.
|
||||
2. Check port listeners via lsof (macOS/Linux), fuser (Linux), or netstat (Windows).
|
||||
3. Fallback to psutil net_connections and process table scan.
|
||||
"""
|
||||
discovered: set[int] = set()
|
||||
|
||||
# 1. Check metadata file
|
||||
info = read_server_info()
|
||||
if info and info.get("port") == port:
|
||||
saved_pid = info.get("pid")
|
||||
if saved_pid and isinstance(saved_pid, int):
|
||||
try:
|
||||
import psutil
|
||||
|
||||
if psutil.pid_exists(saved_pid):
|
||||
discovered.add(saved_pid)
|
||||
except Exception:
|
||||
try:
|
||||
os.kill(saved_pid, 0)
|
||||
discovered.add(saved_pid)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 2. Check port listeners with platform-native tools
|
||||
# 2a. lsof (macOS & Linux)
|
||||
if shutil.which("lsof"):
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["lsof", "-ti", f":{port}", "-sTCP:LISTEN"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2.0,
|
||||
)
|
||||
if res.returncode == 0 and res.stdout.strip():
|
||||
for token in res.stdout.strip().splitlines():
|
||||
token = token.strip()
|
||||
if token.isdigit():
|
||||
discovered.add(int(token))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2b. fuser (Linux fallback)
|
||||
if not discovered and shutil.which("fuser"):
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["fuser", f"{port}/tcp"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2.0,
|
||||
)
|
||||
for token in res.stdout.strip().split():
|
||||
token = token.strip()
|
||||
if token.isdigit():
|
||||
discovered.add(int(token))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2c. netstat (Windows fallback)
|
||||
if not discovered and sys.platform == "win32":
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["netstat", "-ano"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=3.0,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
for line in res.stdout.splitlines():
|
||||
if f":{port}" in line and "LISTENING" in line.upper():
|
||||
parts = line.strip().split()
|
||||
if parts and parts[-1].isdigit():
|
||||
discovered.add(int(parts[-1]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2d. psutil net_connections fallback
|
||||
if not discovered:
|
||||
try:
|
||||
import psutil
|
||||
|
||||
for conn in psutil.net_connections(kind="inet"):
|
||||
if conn.laddr and conn.laddr.port == port:
|
||||
if conn.pid:
|
||||
discovered.add(conn.pid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Process table scan if still nothing found on port but port is in use
|
||||
if not discovered and is_port_in_use(port):
|
||||
try:
|
||||
import psutil
|
||||
|
||||
for proc in psutil.process_iter(["pid", "cmdline"]):
|
||||
try:
|
||||
cmdline = " ".join(proc.info.get("cmdline") or [])
|
||||
if "apps.admin_console.server" in cmdline or (
|
||||
"artemis" in cmdline and "ui" in cmdline
|
||||
):
|
||||
if proc.info.get("pid"):
|
||||
discovered.add(proc.info["pid"])
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return sorted(discovered)
|
||||
|
||||
|
||||
def get_server_status(port: int = 8000) -> dict[str, Any]:
|
||||
"""Retrieve detailed status of the Artemis server on given port."""
|
||||
in_use = is_port_in_use(port)
|
||||
pids = find_server_pids(port)
|
||||
info = read_server_info()
|
||||
|
||||
uptime_seconds: float | None = None
|
||||
if info and "started_at" in info and isinstance(info["started_at"], (int, float)):
|
||||
uptime_seconds = max(0.0, time.time() - info["started_at"])
|
||||
|
||||
is_running = in_use or bool(pids)
|
||||
|
||||
return {
|
||||
"running": is_running,
|
||||
"port": port,
|
||||
"pids": pids,
|
||||
"active_pid": pids[0] if pids else (info.get("pid") if info else None),
|
||||
"uptime_seconds": uptime_seconds,
|
||||
"url": f"http://localhost:{port}" if is_running else None,
|
||||
"admin_url": f"http://localhost:{port}/admin" if is_running else None,
|
||||
"metadata": info,
|
||||
}
|
||||
|
||||
|
||||
def stop_server(
|
||||
port: int = 8000,
|
||||
timeout: float = 4.0,
|
||||
force: bool = False,
|
||||
) -> tuple[bool, str, list[int]]:
|
||||
"""Gracefully terminate any running Artemis server on `port`.
|
||||
|
||||
Args:
|
||||
port: TCP port to inspect and terminate.
|
||||
timeout: Maximum seconds to wait for graceful process exit.
|
||||
force: If True, immediately send SIGKILL without waiting.
|
||||
|
||||
Returns:
|
||||
tuple (success, message, affected_pids)
|
||||
"""
|
||||
pids = find_server_pids(port)
|
||||
|
||||
if not pids and not is_port_in_use(port):
|
||||
clear_server_info()
|
||||
DeviceExecutionLock.cleanup_stale_locks()
|
||||
return True, f"No active server detected on port {port}.", []
|
||||
|
||||
# If no PID was discovered but port is still in use, try one more aggressive discovery
|
||||
if not pids and is_port_in_use(port):
|
||||
time.sleep(0.2)
|
||||
pids = find_server_pids(port)
|
||||
|
||||
stopped_pids: list[int] = []
|
||||
current_pid = os.getpid()
|
||||
|
||||
for pid in pids:
|
||||
if pid == current_pid:
|
||||
# Don't terminate self during stop_server call
|
||||
continue
|
||||
try:
|
||||
if force:
|
||||
ProcessSupervisor.terminate_tree(pid, timeout_seconds=0.5)
|
||||
else:
|
||||
ProcessSupervisor.terminate_tree(pid, timeout_seconds=timeout)
|
||||
stopped_pids.append(pid)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to terminate process {pid}: {e}")
|
||||
|
||||
# Wait for the port to actually become free
|
||||
deadline = time.time() + max(1.5, timeout)
|
||||
while time.time() < deadline:
|
||||
if not is_port_in_use(port):
|
||||
break
|
||||
time.sleep(0.15)
|
||||
|
||||
# Force kill if port is still occupied
|
||||
if is_port_in_use(port) and not force:
|
||||
remaining_pids = find_server_pids(port)
|
||||
for pid in remaining_pids:
|
||||
if pid != current_pid:
|
||||
try:
|
||||
ProcessSupervisor.terminate_tree(pid, timeout_seconds=0.5)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.3)
|
||||
|
||||
# Cleanup stale device execution locks left by terminated server
|
||||
cleaned_locks = DeviceExecutionLock.cleanup_stale_locks()
|
||||
clear_server_info()
|
||||
|
||||
port_free = not is_port_in_use(port)
|
||||
if port_free:
|
||||
pid_str = f" (PID: {', '.join(map(str, stopped_pids))})" if stopped_pids else ""
|
||||
msg = f"Artemis server stopped{pid_str}. Port {port} is released."
|
||||
if cleaned_locks:
|
||||
msg += f" Cleaned up {cleaned_locks} device lock(s)."
|
||||
return True, msg, stopped_pids
|
||||
else:
|
||||
return (
|
||||
False,
|
||||
f"Port {port} remains occupied after stop attempt. Some background process may require manual termination.",
|
||||
stopped_pids,
|
||||
)
|
||||
+110
-18
@@ -106,8 +106,46 @@ class Agent:
|
||||
_task_lock: asyncio.Lock
|
||||
_cloud_mobile_id: str | None = None
|
||||
|
||||
def __init__(self, *, config: AgentConfig | None = None):
|
||||
self._config = config or get_default_agent_config()
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config: AgentConfig | None = None,
|
||||
device_id: str | None = None,
|
||||
device_serial: str | None = None,
|
||||
concurrency_mode: str | None = None,
|
||||
max_concurrency: int | None = None,
|
||||
session_id: str | None = None,
|
||||
):
|
||||
raw_sid = (
|
||||
session_id
|
||||
or os.getenv("ARTEMIS_SESSION_ID")
|
||||
or os.getenv("ARTEMIS_CLOUD_SESSION_ID")
|
||||
)
|
||||
self._session_id: str | None = str(raw_sid).strip() if raw_sid else None
|
||||
target_dev = device_serial or device_id
|
||||
if config is None:
|
||||
from artemis.sdk.builders import Builders
|
||||
builder = Builders.AgentConfig
|
||||
if target_dev:
|
||||
builder.for_device(DevicePlatform.ANDROID, target_dev)
|
||||
if concurrency_mode:
|
||||
builder.with_concurrency_mode(concurrency_mode)
|
||||
if max_concurrency is not None:
|
||||
builder.with_max_concurrency(max_concurrency)
|
||||
self._config = builder.build()
|
||||
else:
|
||||
self._config = config
|
||||
updates = {}
|
||||
if target_dev:
|
||||
updates["device_id"] = target_dev
|
||||
updates["device_platform"] = DevicePlatform.ANDROID
|
||||
if concurrency_mode:
|
||||
updates["concurrency_mode"] = str(concurrency_mode).strip().lower()
|
||||
if max_concurrency is not None:
|
||||
updates["max_concurrency"] = max_concurrency
|
||||
if updates:
|
||||
self._config = self._config.model_copy(update=updates)
|
||||
|
||||
self._tasks = []
|
||||
self._tmp_traces_dir = Path(settings.TRACES_PATH)
|
||||
self._initialized = False
|
||||
@@ -116,9 +154,16 @@ class Agent:
|
||||
async def init(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
device_id: str | None = None,
|
||||
device_serial: str | None = None,
|
||||
retry_count: int = 5,
|
||||
retry_wait_seconds: int = 5,
|
||||
):
|
||||
target_dev = device_serial or device_id
|
||||
if target_dev:
|
||||
self._config = self._config.model_copy(
|
||||
update={"device_id": target_dev, "device_platform": DevicePlatform.ANDROID}
|
||||
)
|
||||
|
||||
return await self._init_internal(
|
||||
api_key=api_key,
|
||||
@@ -187,7 +232,7 @@ class Agent:
|
||||
|
||||
async def _prewarm_llm_connections(self, api_key: str | None = None):
|
||||
"""Pre-warms the HTTP2/gRPC connection pools for both Native GenAI and LangChain clients in the background."""
|
||||
publish_startup_progress("model_warmup", "Warming the model connection")
|
||||
publish_startup_progress("model_warmup", "Warming the model connection", session_id=self._session_id)
|
||||
logger.info("Starting background pre-warming of Gemini API connection pools...")
|
||||
try:
|
||||
key = api_key
|
||||
@@ -197,7 +242,7 @@ class Agent:
|
||||
if not key:
|
||||
logger.warning("Skipping LLM pre-warming: No API key available.")
|
||||
publish_startup_progress(
|
||||
"model_ready", "Model connection will initialize on first use"
|
||||
"model_ready", "Model connection will initialize on first use", session_id=self._session_id
|
||||
)
|
||||
return
|
||||
|
||||
@@ -214,11 +259,11 @@ class Agent:
|
||||
return_exceptions=True,
|
||||
)
|
||||
logger.success("Gemini API connection pools successfully pre-warmed.")
|
||||
publish_startup_progress("model_ready", "Model connection is ready")
|
||||
publish_startup_progress("model_ready", "Model connection is ready", session_id=self._session_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to pre-warm LLM connections: {e}")
|
||||
publish_startup_progress(
|
||||
"model_ready", "Model connection will initialize on first use"
|
||||
"model_ready", "Model connection will initialize on first use", session_id=self._session_id
|
||||
)
|
||||
|
||||
async def install_apk(self, apk_path: str | Path) -> None:
|
||||
@@ -437,7 +482,12 @@ class Agent:
|
||||
logger.info(str(agent_profile))
|
||||
|
||||
on_status_changed = None
|
||||
task_id = str(uuid.uuid4())
|
||||
task_id = str(
|
||||
self._session_id
|
||||
or os.getenv("ARTEMIS_SESSION_ID")
|
||||
or os.getenv("ARTEMIS_CLOUD_SESSION_ID")
|
||||
or uuid.uuid4()
|
||||
)
|
||||
|
||||
task = Task(
|
||||
id=task_id,
|
||||
@@ -475,12 +525,34 @@ class Agent:
|
||||
last_state: State | None = None
|
||||
last_state_snapshot: dict | None = None
|
||||
output = None
|
||||
device_lock = DeviceExecutionLock(
|
||||
effective_mode = getattr(self._config, "concurrency_mode", "per_device")
|
||||
effective_max = getattr(self._config, "max_concurrency", None)
|
||||
sess_id = (
|
||||
self._session_id
|
||||
or os.getenv("ARTEMIS_SESSION_ID")
|
||||
or os.getenv("ARTEMIS_CLOUD_SESSION_ID")
|
||||
or getattr(task, "id", None)
|
||||
or getattr(getattr(task, "request", None), "task_name", None)
|
||||
)
|
||||
active_owner = DeviceExecutionLock.get_active_owner(self._device_context.device_id)
|
||||
already_held = (
|
||||
active_owner is not None
|
||||
and active_owner.pid == os.getpid()
|
||||
and (
|
||||
(sess_id and str(active_owner.session_id) == str(sess_id))
|
||||
or active_owner.token == self._device_context.device_id
|
||||
)
|
||||
)
|
||||
device_lock = None if already_held else DeviceExecutionLock(
|
||||
self._device_context.device_id,
|
||||
description=f"automation task: {request.goal[:120]}",
|
||||
description=f"{request.goal[:120]}",
|
||||
concurrency_mode=effective_mode,
|
||||
max_concurrency=effective_max,
|
||||
session_id=str(sess_id) if sess_id else None,
|
||||
ingress="agent",
|
||||
)
|
||||
try:
|
||||
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||
if device_lock is not None and os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||
queue_cancel_event = threading.Event()
|
||||
acquire_task = asyncio.create_task(
|
||||
asyncio.to_thread(
|
||||
@@ -503,21 +575,21 @@ class Agent:
|
||||
# DataEngine uses a shared database, so a queued task must not
|
||||
# publish a new active session while the current task is still
|
||||
# finishing.
|
||||
self._prepare_tracing(task=task, context=context)
|
||||
self._prepare_output_files(task=task)
|
||||
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||
if self._ui_adb_client is not None:
|
||||
publish_startup_progress(
|
||||
"uiautomator", "Connecting to UI Automator"
|
||||
"uiautomator", "Connecting to UI Automator", session_id=str(task.id)
|
||||
)
|
||||
await asyncio.to_thread(self._ui_adb_client.connect)
|
||||
publish_startup_progress(
|
||||
"uiautomator_ready", "UI Automator is ready"
|
||||
"uiautomator_ready", "UI Automator is ready", session_id=str(task.id)
|
||||
)
|
||||
await self._ensure_device_unlocked()
|
||||
publish_startup_progress(
|
||||
"environment", "Preparing the device environment"
|
||||
"environment", "Preparing the device environment", session_id=str(task.id)
|
||||
)
|
||||
self._prepare_tracing(task=task, context=context)
|
||||
self._prepare_output_files(task=task)
|
||||
await self._prepare_app_installation(task=task)
|
||||
await self._prepare_device_environment(context=context)
|
||||
await self._prepare_app_lock(task=task, context=context)
|
||||
@@ -548,7 +620,7 @@ class Agent:
|
||||
logger.error(f"[{task_name}] Failed to start screen recording: {e}")
|
||||
|
||||
publish_startup_progress(
|
||||
"environment_ready", "Device environment is ready"
|
||||
"environment_ready", "Device environment is ready", session_id=str(task.id)
|
||||
)
|
||||
try:
|
||||
if request.profile and request.profile.lower() == "flash":
|
||||
@@ -707,7 +779,8 @@ class Agent:
|
||||
if self._ui_adb_client is not None:
|
||||
await asyncio.to_thread(self._ui_adb_client.disconnect)
|
||||
finally:
|
||||
await asyncio.to_thread(device_lock.release)
|
||||
if device_lock is not None:
|
||||
await asyncio.to_thread(device_lock.release)
|
||||
|
||||
async with self._task_lock:
|
||||
if self._current_task and not self._current_task.done():
|
||||
@@ -919,7 +992,26 @@ class Agent:
|
||||
device_data = context.device.model_dump() if context.device else {}
|
||||
if task.request.profile:
|
||||
device_data["profile"] = task.request.profile
|
||||
context.data_engine.start_session(goal=task.request.goal, device_info=device_data)
|
||||
|
||||
target_sid = (
|
||||
self._session_id
|
||||
or os.getenv("ARTEMIS_SESSION_ID")
|
||||
or os.getenv("ARTEMIS_CLOUD_SESSION_ID")
|
||||
or getattr(task, "id", None)
|
||||
or getattr(getattr(task, "request", None), "task_name", None)
|
||||
)
|
||||
sess_uuid = None
|
||||
if target_sid:
|
||||
try:
|
||||
sess_uuid = uuid.UUID(str(target_sid))
|
||||
except Exception:
|
||||
sess_uuid = str(target_sid)
|
||||
|
||||
context.data_engine.start_session(
|
||||
goal=task.request.goal,
|
||||
device_info=device_data,
|
||||
session_id=sess_uuid,
|
||||
)
|
||||
|
||||
async def _finalize_tracing_safely(self, task: Task, context: ArtemisContext):
|
||||
"""Finalize optional trace artifacts without changing task semantics."""
|
||||
|
||||
@@ -69,6 +69,8 @@ class AgentConfigBuilder:
|
||||
self._force_web_accessibility: bool = False
|
||||
self._disable_checker: bool = True
|
||||
self._cloud_mobile_id_or_ref: str | None = None
|
||||
self._concurrency_mode: str = "per_device"
|
||||
self._max_concurrency: int | None = None
|
||||
|
||||
agent_cfg = load_agent_config()
|
||||
self._explorer = agent_cfg.explorer
|
||||
@@ -128,14 +130,19 @@ class AgentConfigBuilder:
|
||||
|
||||
def for_device(
|
||||
self,
|
||||
platform: DevicePlatform,
|
||||
device_id: str,
|
||||
platform_or_device_id: DevicePlatform | str,
|
||||
device_id: str | None = None,
|
||||
) -> "AgentConfigBuilder":
|
||||
"""Configure the ARTEMIS agent for a specific device.
|
||||
|
||||
Supports both:
|
||||
builder.for_device(DevicePlatform.ANDROID, "emulator-5554")
|
||||
and:
|
||||
builder.for_device("emulator-5554") (defaults to DevicePlatform.ANDROID)
|
||||
|
||||
Args:
|
||||
platform: The device platform (ANDROID)
|
||||
device_id: The unique identifier for the device
|
||||
platform_or_device_id: DevicePlatform or unique identifier for the device
|
||||
device_id: The unique identifier for the device (if platform was passed first)
|
||||
"""
|
||||
if self._cloud_mobile_id_or_ref is not None:
|
||||
raise ValueError(
|
||||
@@ -143,8 +150,28 @@ class AgentConfigBuilder:
|
||||
" configured.\n> for_device() and for_cloud_mobile() are"
|
||||
" mutually exclusive"
|
||||
)
|
||||
self._device_id = device_id
|
||||
self._device_platform = platform
|
||||
if isinstance(platform_or_device_id, DevicePlatform):
|
||||
self._device_platform = platform_or_device_id
|
||||
self._device_id = device_id
|
||||
else:
|
||||
self._device_platform = DevicePlatform.ANDROID
|
||||
self._device_id = str(platform_or_device_id)
|
||||
return self
|
||||
|
||||
def for_device_serial(self, device_serial: str) -> "AgentConfigBuilder":
|
||||
"""Target a specific Android device by its ADB serial number."""
|
||||
return self.for_device(DevicePlatform.ANDROID, device_serial)
|
||||
|
||||
def with_concurrency_mode(
|
||||
self, mode: str
|
||||
) -> "AgentConfigBuilder":
|
||||
"""Configure concurrency mode: 'global' (1 task globally) or 'per_device' (1 task per device)."""
|
||||
self._concurrency_mode = str(mode).strip().lower()
|
||||
return self
|
||||
|
||||
def with_max_concurrency(self, max_concurrency: int) -> "AgentConfigBuilder":
|
||||
"""Configure max concurrent tasks limit."""
|
||||
self._max_concurrency = max_concurrency
|
||||
return self
|
||||
|
||||
def with_default_task_config(self, config: TaskRequestCommon) -> "AgentConfigBuilder":
|
||||
@@ -511,6 +538,8 @@ class AgentConfigBuilder:
|
||||
video_analyzer=self._video_analyzer.model_copy(
|
||||
update={"enable_ledger": self._enable_video_ledger}
|
||||
),
|
||||
concurrency_mode=self._concurrency_mode,
|
||||
max_concurrency=self._max_concurrency,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ class TaskRequestCommonBuilder(BaseModel):
|
||||
def __init__(self):
|
||||
self._max_steps = RECURSION_LIMIT
|
||||
self._record_trace = True
|
||||
self._trace_path = Path("artemis-traces")
|
||||
self._trace_path = Path("traces")
|
||||
self._llm_output_path: Path | None = None
|
||||
self._locked_app_package: str | None = None
|
||||
self._app_path: Path | None = None
|
||||
|
||||
@@ -123,12 +123,13 @@ class AgentConfig(BaseModel):
|
||||
|
||||
flash: FlashProfileConfig = Field(default_factory=FlashProfileConfig)
|
||||
pro: ProProfileConfig = Field(default_factory=ProProfileConfig)
|
||||
|
||||
explorer: ExplorerConfig = Field(default_factory=ExplorerConfig)
|
||||
explorer_versions: dict[str, Literal["flash", "pro", "ultra"]]
|
||||
denylisted_tools: dict[str, list[str]]
|
||||
enable_video_ledger: bool = True
|
||||
video_analyzer: VideoAnalyzerConfig = Field(default_factory=VideoAnalyzerConfig)
|
||||
concurrency_mode: Literal["global", "per_device"] = "per_device"
|
||||
max_concurrency: int | None = None
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ class TaskRequestBase(BaseModel):
|
||||
model_config = {"ignored_types": (CyFunctionDetector,)}
|
||||
max_steps: int = RECURSION_LIMIT
|
||||
record_trace: bool = True
|
||||
trace_path: Path = Path("artemis-traces")
|
||||
trace_path: Path = Path("traces")
|
||||
llm_output_path: Path | None = None
|
||||
|
||||
|
||||
|
||||
@@ -14,9 +14,27 @@
|
||||
|
||||
"""Managed Toolchain and Binary Discovery Subsystem."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from artemis.toolchain.descriptors import TOOLS, ToolDescriptor
|
||||
from artemis.toolchain.resolver import ToolchainResolver, toolchain
|
||||
|
||||
|
||||
def ensure_toolchain_in_path() -> None:
|
||||
"""Prepend resolved toolchain paths (e.g. adb, scrcpy, ffmpeg) to process PATH."""
|
||||
for tool_name in ("adb", "ffmpeg", "scrcpy"):
|
||||
tool_path = toolchain.resolve(tool_name)
|
||||
if tool_path:
|
||||
parent_dir = str(Path(tool_path).parent)
|
||||
current_path = os.environ.get("PATH", "")
|
||||
if parent_dir not in current_path.split(os.pathsep):
|
||||
os.environ["PATH"] = f"{parent_dir}{os.pathsep}{current_path}"
|
||||
|
||||
|
||||
# Auto-configure process PATH upon import
|
||||
ensure_toolchain_in_path()
|
||||
|
||||
find_adb = toolchain.find_adb
|
||||
find_ffmpeg = toolchain.find_ffmpeg
|
||||
find_scrcpy = toolchain.find_scrcpy
|
||||
|
||||
@@ -24,17 +24,17 @@ from typing import Any
|
||||
from artemis.config import read_ipc_port
|
||||
|
||||
|
||||
def publish_startup_progress(stage: str, message: str, **details: Any) -> None:
|
||||
def publish_startup_progress(stage: str, message: str, session_id: str | None = None, **details: Any) -> None:
|
||||
"""Publish one startup milestone, ignoring unavailable UI/IPC endpoints."""
|
||||
session_id = os.getenv("ARTEMIS_SESSION_ID") or os.getenv(
|
||||
resolved_session_id = session_id or os.getenv("ARTEMIS_SESSION_ID") or os.getenv(
|
||||
"ARTEMIS_CLOUD_SESSION_ID"
|
||||
)
|
||||
ipc_port = read_ipc_port()
|
||||
if not session_id or not ipc_port:
|
||||
if not resolved_session_id or not ipc_port:
|
||||
return
|
||||
|
||||
event_data = {
|
||||
"session_id": session_id,
|
||||
"session_id": str(resolved_session_id),
|
||||
"stage": stage,
|
||||
"message": message,
|
||||
"timestamp": time.time(),
|
||||
|
||||
+11
-4
@@ -68,10 +68,17 @@ When you run `uv run artemis mcp --install all` (or target a specific IDE like `
|
||||
|
||||
## 🛠️ MCP Tools Overview
|
||||
|
||||
* **`mobile_run_task`**: Asynchronously launches an autonomous mobile automation task (`Flash` or `Pro` model).
|
||||
* **`mobile_manage_task`**: Manages task lifecycle (`status`, `stop`, `inject_instruction`).
|
||||
* **`mobile_get_device_state`**: Real-time observer (`screenshot` or OCR+XML `hierarchy`).
|
||||
* **`mobile_inspect_trace`**: Granular trace inspection, visual action overlays, and agent reasoning.
|
||||
* **`mobile_run_task`**: Asynchronously launches an autonomous mobile automation task (`Flash` or `Pro` model) with optional `device_serial` targeting.
|
||||
* **`mobile_manage_task`**: Manages task lifecycle (`status`, `stop`, `inject_instruction`), returning task state and assigned `device_serial`.
|
||||
* **`mobile_get_device_state`**: Real-time observer (`screenshot` or OCR+XML `hierarchy`) with optional `device_serial`.
|
||||
* **`mobile_inspect_trace`**: Granular trace inspection, visual action overlays, agent reasoning, and `device_serial` tracking.
|
||||
|
||||
### 📱 Device Selection & Multi-Device Execution
|
||||
ARTEMIS supports parallel execution across multiple connected Android devices and emulators:
|
||||
1. **Direct Device Specification**: Provide `device_serial` explicitly (e.g. `device_serial="63191FDKX00062"` or `device_serial="emulator-5554"`). Per-device execution locks allow distinct devices to run in parallel.
|
||||
2. **Automatic Device Selection**: Omit `device_serial` to let ARTEMIS auto-select an available ready device from the device pool.
|
||||
* **User Choice Priority**: When multiple devices/emulators are connected, AI agents should prioritize asking the user which device to run on.
|
||||
* **Device Diagnosis**: Inspect connected hardware, authorization status, and serials anytime via `adb devices` or `adb devices -l`.
|
||||
|
||||
## 🚀 How to Run & Configure
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ async def run_task(
|
||||
locked_app_package: str | None = None,
|
||||
app_path: str | None = None,
|
||||
expected_output_desc: str | None = None,
|
||||
device_serial: str | None = None,
|
||||
):
|
||||
"""Executes the mobile automation agent task and logs all actions/results."""
|
||||
trace_dir = trace_store.get_trace_dir(trace_id)
|
||||
@@ -142,6 +143,7 @@ async def run_task(
|
||||
|
||||
agent = None
|
||||
adb_path = device_utils.resolve_adb_path()
|
||||
target_serial = device_serial
|
||||
|
||||
try:
|
||||
from artemis.sdk import Agent
|
||||
@@ -150,15 +152,36 @@ async def run_task(
|
||||
|
||||
connected_devices = device_utils.get_connected_devices(adb_path)
|
||||
|
||||
if connected_devices:
|
||||
print(
|
||||
f"✅ Detected active connected device(s): {connected_devices}. "
|
||||
f"Using the first available device: '{connected_devices[0]}'."
|
||||
)
|
||||
if device_serial:
|
||||
target_serial = device_serial
|
||||
if connected_devices and device_serial not in connected_devices:
|
||||
print(
|
||||
f"⚠️ Warning: Specified device serial '{device_serial}' was not detected in active ADB devices: {connected_devices}. "
|
||||
"Proceeding with target serial (will attempt direct ADB connection)..."
|
||||
)
|
||||
else:
|
||||
print(f"✅ Using specified target device: '{device_serial}'.")
|
||||
else:
|
||||
print("❌ No active connected devices detected. Booting emulator...")
|
||||
if not device_utils.ensure_emulator(adb_path=adb_path):
|
||||
raise RuntimeError("Failed to start or connect to the Android emulator.")
|
||||
if connected_devices:
|
||||
target_serial = settings.ADB_DEVICE_SERIAL or os.environ.get("ADB_DEVICE_SERIAL")
|
||||
if not target_serial:
|
||||
try:
|
||||
from artemis.runtime import device_pool
|
||||
target_serial = device_pool.select_device()
|
||||
except Exception:
|
||||
target_serial = connected_devices[0]
|
||||
print(
|
||||
f"✅ Detected active connected device(s): {connected_devices}. "
|
||||
f"Auto-selected device: '{target_serial}'."
|
||||
)
|
||||
else:
|
||||
print("❌ No active connected devices detected. Booting emulator...")
|
||||
if not device_utils.ensure_emulator(adb_path=adb_path):
|
||||
raise RuntimeError("Failed to start or connect to the Android emulator.")
|
||||
target_serial = "emulator-5554"
|
||||
|
||||
if target_serial:
|
||||
trace_store.update_trace_device_serial(trace_id, target_serial)
|
||||
|
||||
print("Initializing Artemis Agent...")
|
||||
from artemis.config import initialize_llm_config, settings
|
||||
@@ -173,6 +196,10 @@ async def run_task(
|
||||
if settings.ADB_HOST:
|
||||
config_builder.with_adb_server(host=settings.ADB_HOST, port=settings.ADB_PORT)
|
||||
|
||||
if target_serial:
|
||||
from artemis.context import DevicePlatform
|
||||
config_builder.for_device(DevicePlatform.ANDROID, target_serial)
|
||||
|
||||
config = config_builder.build()
|
||||
|
||||
agent = Agent(config=config)
|
||||
@@ -183,6 +210,12 @@ async def run_task(
|
||||
timeout_seconds=float(os.getenv("ARTEMIS_AGENT_INIT_TIMEOUT_SECONDS", 30)),
|
||||
)
|
||||
|
||||
actual_serial = getattr(getattr(agent, "_device_context", None), "device_id", None) or target_serial
|
||||
if actual_serial:
|
||||
target_serial = actual_serial
|
||||
trace_store.update_trace_device_serial(trace_id, actual_serial)
|
||||
print(f"📱 Bound to device serial: '{actual_serial}'")
|
||||
|
||||
print("Running task on agent...")
|
||||
task_builder = (
|
||||
agent.new_task(goal=task_desc)
|
||||
@@ -201,17 +234,20 @@ async def run_task(
|
||||
result = await agent.run_task(request=task_builder.build())
|
||||
print(f"Task completed. Result: {result}")
|
||||
|
||||
device_info_line = f"Device Serial: `{target_serial}`\n" if target_serial else ""
|
||||
|
||||
if isinstance(result, dict) and "status" in result and result.get("status") != "completed":
|
||||
error_explanation = result.get("explanation", "Task execution returned failed status.")
|
||||
print(f"Task finished with non-completed status: {error_explanation}", file=sys.stderr)
|
||||
trace_store.update_trace_status(
|
||||
trace_id, "failed", error=error_explanation, result=result
|
||||
trace_id, "failed", error=error_explanation, result=result, device_serial=target_serial
|
||||
)
|
||||
|
||||
formatted_result = json.dumps(result, indent=2, ensure_ascii=False)
|
||||
failure_msg = (
|
||||
f"Artemis background task finished with non-completed status.\n\n"
|
||||
f"Trace ID: {trace_id}\n"
|
||||
f"{device_info_line}"
|
||||
f"Goal: {task_desc}\n"
|
||||
f"Explanation: {error_explanation}\n\n"
|
||||
f"Result:\n```json\n{formatted_result}\n```\n\n"
|
||||
@@ -223,7 +259,7 @@ async def run_task(
|
||||
message=failure_msg,
|
||||
title="Artemis Task Incomplete",
|
||||
event_type="failed",
|
||||
payload={"trace_id": trace_id, "goal": task_desc, "result": result},
|
||||
payload={"trace_id": trace_id, "device_serial": target_serial, "goal": task_desc, "result": result},
|
||||
)
|
||||
return
|
||||
|
||||
@@ -236,7 +272,7 @@ async def run_task(
|
||||
"notes_dir to view more details."
|
||||
)
|
||||
|
||||
trace_store.update_trace_status(trace_id, "completed", result=result)
|
||||
trace_store.update_trace_status(trace_id, "completed", result=result, device_serial=target_serial)
|
||||
|
||||
if isinstance(result, dict):
|
||||
formatted_result = json.dumps(result, indent=2, ensure_ascii=False)
|
||||
@@ -248,6 +284,7 @@ async def run_task(
|
||||
success_msg = (
|
||||
"Artemis background task completed successfully.\n\n"
|
||||
f"Trace ID: {trace_id}\n"
|
||||
f"{device_info_line}"
|
||||
f"Goal: {task_desc}\n"
|
||||
f"{result_str}"
|
||||
)
|
||||
@@ -256,21 +293,22 @@ async def run_task(
|
||||
message=success_msg,
|
||||
title="Artemis Task Completed",
|
||||
event_type="completed",
|
||||
payload={"trace_id": trace_id, "goal": task_desc, "result": result},
|
||||
payload={"trace_id": trace_id, "device_serial": target_serial, "goal": task_desc, "result": result},
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print("Task was cancelled (asyncio.CancelledError)", file=sys.stderr)
|
||||
trace_store.update_trace_status(trace_id, "cancelled", error="Task was cancelled")
|
||||
trace_store.update_trace_status(trace_id, "cancelled", error="Task was cancelled", device_serial=target_serial)
|
||||
device_info_line = f"Device Serial: `{target_serial}`\n" if target_serial else ""
|
||||
cancel_msg = (
|
||||
f"Artemis background task was cancelled.\n\nTrace ID: {trace_id}\nGoal: {task_desc}\n"
|
||||
f"Artemis background task was cancelled.\n\nTrace ID: {trace_id}\n{device_info_line}Goal: {task_desc}\n"
|
||||
)
|
||||
notify(
|
||||
conversation_id=conversation_id,
|
||||
message=cancel_msg,
|
||||
title="Artemis Task Cancelled",
|
||||
event_type="cancelled",
|
||||
payload={"trace_id": trace_id, "goal": task_desc},
|
||||
payload={"trace_id": trace_id, "device_serial": target_serial, "goal": task_desc},
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -289,11 +327,13 @@ async def run_task(
|
||||
frame_summary = f" (at {tb.tb_frame.f_code.co_name}:{tb.tb_lineno})" if tb else ""
|
||||
full_error_desc = f"{error_type}: {raw_error_msg}{frame_summary}"
|
||||
|
||||
trace_store.update_trace_status(trace_id, "failed", error=full_error_desc)
|
||||
trace_store.update_trace_status(trace_id, "failed", error=full_error_desc, device_serial=target_serial)
|
||||
|
||||
device_info_line = f"Device Serial: `{target_serial}`\n" if target_serial else ""
|
||||
failure_msg = (
|
||||
f"Artemis background task failed.\n\n"
|
||||
f"Trace ID: {trace_id}\n"
|
||||
f"{device_info_line}"
|
||||
f"Goal: {task_desc}\n"
|
||||
f"Error: {full_error_desc}\n\n"
|
||||
f'Check the logs using mobile_inspect_trace(action="view_summary", trace_id="{trace_id}") '
|
||||
@@ -304,7 +344,7 @@ async def run_task(
|
||||
message=failure_msg,
|
||||
title="Artemis Task Failed",
|
||||
event_type="failed",
|
||||
payload={"trace_id": trace_id, "goal": task_desc, "error": full_error_desc},
|
||||
payload={"trace_id": trace_id, "device_serial": target_serial, "goal": task_desc, "error": full_error_desc},
|
||||
)
|
||||
|
||||
finally:
|
||||
@@ -330,6 +370,7 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--locked-app-package", help="Package name of app to lock execution to")
|
||||
parser.add_argument("--app-path", help="Path to local APK to install before running task")
|
||||
parser.add_argument("--expected-output-desc", help="Expected output description")
|
||||
parser.add_argument("--device-serial", help="Target specific device serial")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -342,5 +383,6 @@ if __name__ == "__main__":
|
||||
locked_app_package=args.locked_app_package,
|
||||
app_path=args.app_path,
|
||||
expected_output_desc=args.expected_output_desc,
|
||||
device_serial=args.device_serial,
|
||||
)
|
||||
)
|
||||
+11
-4
@@ -29,10 +29,17 @@ Deeply understand and select between ARTEMIS's dual execution models (**ARTEMIS
|
||||
- **Safety Net & Self-Healing (Failure Analyzer)**: Every action is vetted by an execution safety net. If an action fails or is blocked, the **Failure Analyzer** is triggered to run local step-by-step recovery sequences, achieving autonomous self-healing. This loop continues iteratively until the task terminates.
|
||||
- **Outputter (Optional)**: Synthesizes the entire execution trace into a human-readable report detailing every action step and visual result.
|
||||
|
||||
### 3. Device & Environment Constraints
|
||||
- **ADB & File Transfer**: While ARTEMIS excels at device automation, it operates within the device boundary. To extract diagnostic files, logs, or test artifacts from the mobile device to the host PC for further analysis, you must manually write and execute appropriate `adb` commands (e.g., `adb pull`).
|
||||
- **Hardware Prerequisites**: Running ARTEMIS requires a physically connected, fully authorized Android device (e.g., a Pixel phone) or an active emulator.
|
||||
- **Single-Device Policy**: ARTEMIS can only operate a single device at any given time. Ensure your workflows and code respect this concurrency limit.
|
||||
### 3. Device & Environment Constraints & Multi-Device Management
|
||||
- **Device Selection & Multi-Device Execution**: ARTEMIS supports multi-device execution and per-device concurrency. You can control device targeting via two modes:
|
||||
- **Direct Device Specification**: Explicitly provide the target phone's serial number via `device_serial` to `mobile_run_task` or `mobile_get_device_state`. Tasks targeting distinct devices run concurrently without blocking each other.
|
||||
- **Automatic Device Selection**: When `device_serial` is omitted or set to `None`, ARTEMIS automatically selects an available connected device or allocates an idle device from the device pool.
|
||||
- **Prioritize User Choice for Device Selection**:
|
||||
- When multiple connected devices or emulators are detected, or whenever device selection is ambiguous, **YOU MUST PRIORITIZE ASKING THE USER** to select or confirm their preferred device serial before launching a task.
|
||||
- **Device Diagnosis with `adb devices`**:
|
||||
- Use `adb devices` (or `adb devices -l`) via bash command execution to diagnose attached hardware, inspect connection status (`device`, `unauthorized`, `offline`), and retrieve device serials and models whenever preparing tasks or troubleshooting device issues.
|
||||
- **ADB & File Transfer**: While ARTEMIS excels at device automation, it operates within the device boundary. To extract diagnostic files, logs, or test artifacts from the mobile device to the host PC for further analysis, you must manually execute appropriate `adb` commands (e.g., `adb -s <serial> pull ...`).
|
||||
- **Hardware Prerequisites**: Running ARTEMIS requires at least one physically connected, fully authorized Android device (e.g., a Pixel phone) or an active emulator.
|
||||
- **Per-Device Mutual Exclusion**: ARTEMIS manages per-device execution mutexes (`DeviceExecutionLock`). A device can only execute a single task at a time (FIFO queue), while different devices can execute tasks in parallel.
|
||||
|
||||
### 4. Robust Test Code Design (The "Dynamic-First, Coordinate-Fallback" Philosophy)
|
||||
*If your task involves authoring test code, you must adhere to the following design principles for maximum reliability:*
|
||||
|
||||
@@ -31,25 +31,37 @@ from artemis.utils.visualization import format_minimal_list_with_elements
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def mobile_get_device_state(view_type: str) -> str:
|
||||
async def mobile_get_device_state(view_type: str, device_serial: str | None = None) -> str:
|
||||
"""Real-time mobile device state observer (for debugging and validation).
|
||||
|
||||
Use this tool to inspect the latest mobile status or to assist in tracking
|
||||
the subagent's task progress.
|
||||
|
||||
Retrieves a real-time screenshot (returns local file URI) or a simplified
|
||||
element tree text list of the currently connected mobile device.
|
||||
element tree text list of the target mobile device.
|
||||
|
||||
### Device Selection Modes:
|
||||
1. **Direct Device Specification**: Provide `device_serial` to observe a specific
|
||||
phone or emulator (e.g. `device_serial="63191FDKX00062"`).
|
||||
2. **Automatic Device Selection**: Omit `device_serial` or leave it `None` to
|
||||
automatically inspect the default/connected device.
|
||||
|
||||
**Diagnostics & User Preference**:
|
||||
- When multiple devices are attached, prioritize letting the user specify or confirm the device.
|
||||
- Run `adb devices` to check connected devices and identify their serial numbers.
|
||||
|
||||
Args:
|
||||
view_type: The observation type. Supported values: - "screenshot":
|
||||
Captures the real-time mobile screen and saves it to the workspace,
|
||||
returning the local URI path of the image file (e.g.,
|
||||
"file:///path/to/screenshot.jpg"). - "hierarchy": Directly returns the
|
||||
simplified text-labeled element list, which is identical to what the
|
||||
mobile UI automation subagent sees when making decisions.
|
||||
view_type: The observation type. Supported values:
|
||||
- "screenshot": Captures the real-time mobile screen and saves it to the workspace,
|
||||
returning the local URI path of the image file (e.g., "file:///path/to/screenshot.jpg").
|
||||
- "hierarchy": Directly returns the simplified text-labeled element list, which is
|
||||
identical to what the mobile UI automation subagent sees when making decisions.
|
||||
device_serial: Optional Android device serial number (e.g., "63191FDKX00062" or
|
||||
"emulator-5554"). If specified, inspects state from that specific device. If omitted,
|
||||
inspects the default connected device.
|
||||
"""
|
||||
try:
|
||||
controller = _get_controller()
|
||||
controller = _get_controller(device_serial=device_serial)
|
||||
device_width = controller.ctx.device.device_width
|
||||
device_height = controller.ctx.device.device_height
|
||||
except Exception as e:
|
||||
|
||||
@@ -115,20 +115,21 @@ async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | No
|
||||
|
||||
Use this tool DURING a running task or AFTER it has finished (or failed) to
|
||||
monitor progress, verify answers, diagnose agent errors, and debug.
|
||||
It provides comprehensive runtime information, raw agent outputs, and visual
|
||||
screenshots for both **Flash** and **Pro** tasks.
|
||||
It provides comprehensive runtime information (including the assigned
|
||||
`device_serial`), raw agent outputs, and visual screenshots for both
|
||||
**Flash** and **Pro** tasks.
|
||||
|
||||
### Available Actions:
|
||||
- **'view_summary'**: Retrieves a high-level execution summary across all
|
||||
steps.
|
||||
steps, including the target `device_serial`.
|
||||
* **Pro Model**: Displays a hierarchical task plan summary, showing the
|
||||
status and descriptions of all completed, active, and pending steps.
|
||||
status, device serial, and descriptions of all completed, active, and pending steps.
|
||||
* **Flash Model**: Displays an all-in-one execution chain containing the
|
||||
complete reasoning (thoughts/motivation) and action taken (`click`,
|
||||
complete reasoning (thoughts/motivation), device serial, and action taken (`click`,
|
||||
`input_text`, etc.) for every single step.
|
||||
|
||||
- **'view_step_screenshots'**: Retrieves local file paths of screenshots for
|
||||
a specific step (supports both Flash and Pro). Returns:
|
||||
a specific step (supports both Flash and Pro), along with `device_serial`. Returns:
|
||||
1. `before_screenshot`: The original device screenshot observed by the
|
||||
agent right before taking the action.
|
||||
2. `after_screenshot`: A post-action screenshot capturing the failure or
|
||||
@@ -139,9 +140,10 @@ async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | No
|
||||
action visually marked (e.g., red circle for taps, red arrow for
|
||||
swipes). CRITICAL for verifying if the agent tapped the correct UI
|
||||
element.
|
||||
4. `device_serial`: The serial of the device on which this step was executed.
|
||||
|
||||
- **'view_step_details'**: Retrieves deep runtime logs, raw VLM thoughts,
|
||||
and exact actions for a specific step.
|
||||
and exact actions for a specific step, along with `device_serial`.
|
||||
* **Pro Model**: Fully supported. Returns detailed per-step diagnostic
|
||||
data.
|
||||
* **Flash Model**: DO NOT USE. (Flash reasoning and actions are already
|
||||
@@ -242,11 +244,34 @@ async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | No
|
||||
|
||||
status_data = trace_store.read_status(trace_id)
|
||||
is_flash = status_data and status_data.get("model", "").lower() == "flash"
|
||||
device_serial = status_data.get("device_serial") if status_data else None
|
||||
if not device_serial and os.path.exists(db_path):
|
||||
try:
|
||||
conn_dev = sqlite3.connect(db_path)
|
||||
conn_dev.row_factory = sqlite3.Row
|
||||
cur_dev = conn_dev.cursor()
|
||||
cur_dev.execute(
|
||||
"SELECT device_info FROM sessions WHERE session_id = ? LIMIT 1",
|
||||
(trace_id,),
|
||||
)
|
||||
row_dev = cur_dev.fetchone()
|
||||
if row_dev and row_dev["device_info"]:
|
||||
try:
|
||||
d_info = json.loads(row_dev["device_info"])
|
||||
if isinstance(d_info, dict) and d_info.get("device_id"):
|
||||
device_serial = d_info["device_id"]
|
||||
except Exception:
|
||||
pass
|
||||
conn_dev.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
device_info_str = f" | **Device Serial:** `{device_serial}`" if device_serial else ""
|
||||
|
||||
if is_flash:
|
||||
summary_lines = [
|
||||
"# ARTEMIS Flash Execution Summary",
|
||||
f"**Session ID:** `{trace_id}` | **Model:** Flash\n",
|
||||
f"**Session ID:** `{trace_id}` | **Model:** Flash{device_info_str}\n",
|
||||
"---",
|
||||
"## Step-by-Step Execution Chain\n",
|
||||
]
|
||||
@@ -296,6 +321,8 @@ async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | No
|
||||
last_n_detailed=0,
|
||||
all_detailed=False,
|
||||
)
|
||||
if device_serial and not summary_markdown.startswith("**Session ID:"):
|
||||
summary_markdown = f"**Session ID:** `{trace_id}` | **Model:** Pro{device_info_str}\n\n" + summary_markdown
|
||||
return summary_markdown
|
||||
except Exception as e:
|
||||
return {
|
||||
@@ -307,6 +334,7 @@ async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | No
|
||||
elif action in ("view_step_screenshots", "view_step_details"):
|
||||
status_data = trace_store.read_status(trace_id)
|
||||
is_flash = status_data and status_data.get("model", "").lower() == "flash"
|
||||
device_serial = status_data.get("device_serial") if status_data else None
|
||||
|
||||
if action == "view_step_details" and is_flash:
|
||||
return {
|
||||
@@ -426,6 +454,7 @@ async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | No
|
||||
|
||||
return {
|
||||
"trace_id": trace_id,
|
||||
"device_serial": device_serial,
|
||||
"step_number": step_dict["step_number"],
|
||||
"before_screenshot": pre_image,
|
||||
"after_screenshot": post_image,
|
||||
@@ -451,6 +480,7 @@ async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | No
|
||||
|
||||
return {
|
||||
"trace_id": trace_id,
|
||||
"device_serial": device_serial,
|
||||
"step_number": step_dict["step_number"],
|
||||
"details": rendered_text,
|
||||
}
|
||||
|
||||
@@ -43,7 +43,16 @@ def mobile_manage_task(
|
||||
|
||||
### Available Actions:
|
||||
- **'status'**: Retrieves execution progress, elapsed time, and metadata.
|
||||
Returns a JSON object.
|
||||
Returns a JSON object containing:
|
||||
* `trace_id`: Unique identifier for the task.
|
||||
* `status`: Task lifecycle state ('running', 'completed', 'failed', 'cancelled').
|
||||
* `device_serial`: The Android device serial number assigned to/running this task
|
||||
(essential in multi-device environments to know which phone this task belongs to).
|
||||
* `task_desc`: The goal/description of the task.
|
||||
* `model`: 'Flash' or 'Pro'.
|
||||
* `elapsed_seconds`: Execution duration in seconds.
|
||||
* `progress`: Live progress data (current turn, latest thoughts and actions for Flash;
|
||||
active task plan for Pro).
|
||||
* **How to use the output**:
|
||||
- For **Pro Model**, check `progress.task_plan` to see if the
|
||||
agent's long-term plan aligns with your goal.
|
||||
@@ -113,9 +122,51 @@ def mobile_manage_task(
|
||||
end_time = status_data.get("end_time") or time.time()
|
||||
elapsed = round(end_time - start_time, 1) if start_time else 0
|
||||
|
||||
project_root = env_utils.get_project_root()
|
||||
db_path = os.path.join(trace_store.TRACES_DIR, "data_engine.db")
|
||||
if not os.path.exists(db_path):
|
||||
db_path = os.path.join(project_root, "traces", "data_engine.db")
|
||||
|
||||
device_serial = status_data.get("device_serial")
|
||||
if not device_serial and os.path.exists(db_path):
|
||||
try:
|
||||
conn_dev = sqlite3.connect(db_path)
|
||||
conn_dev.row_factory = sqlite3.Row
|
||||
cur_dev = conn_dev.cursor()
|
||||
cur_dev.execute(
|
||||
"SELECT device_info FROM sessions WHERE session_id = ? OR pid = ? ORDER BY start_time DESC LIMIT 1",
|
||||
(trace_id, pid),
|
||||
)
|
||||
row_dev = cur_dev.fetchone()
|
||||
if row_dev and row_dev["device_info"]:
|
||||
try:
|
||||
d_info = json.loads(row_dev["device_info"])
|
||||
if isinstance(d_info, dict) and d_info.get("device_id"):
|
||||
device_serial = d_info["device_id"]
|
||||
status_data["device_serial"] = device_serial
|
||||
trace_store.write_status(trace_id, status_data)
|
||||
except Exception:
|
||||
pass
|
||||
conn_dev.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not device_serial and pid:
|
||||
try:
|
||||
owners = DeviceExecutionLock.get_active_owners()
|
||||
for clean_id, owner in owners.items():
|
||||
if owner.pid == pid or owner.session_id == trace_id:
|
||||
device_serial = owner.device_id
|
||||
status_data["device_serial"] = device_serial
|
||||
trace_store.write_status(trace_id, status_data)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response: dict[str, Any] = {
|
||||
"trace_id": trace_id,
|
||||
"status": current_status,
|
||||
"device_serial": device_serial,
|
||||
"elapsed_seconds": elapsed,
|
||||
"task_desc": status_data.get("task_desc"),
|
||||
"model": status_data.get("model"),
|
||||
|
||||
@@ -32,6 +32,7 @@ def mobile_run_task(
|
||||
locked_app_package: str | None = None,
|
||||
app_path: str | None = None,
|
||||
expected_output_desc: str | None = None,
|
||||
device_serial: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Starts a specialized mobile UI automation subagent to autonomously plan and execute tasks on a connected Android device.
|
||||
|
||||
@@ -41,6 +42,23 @@ def mobile_run_task(
|
||||
straightforward UI scripts) and **Pro** (for complex, exploratory tasks
|
||||
requiring deep reasoning).
|
||||
|
||||
### Device Selection & Multi-Device Support (CRITICAL)
|
||||
ARTEMIS supports multi-device execution and provides two device selection modes:
|
||||
1. **Direct Device Specification**: Specify `device_serial` explicitly
|
||||
(e.g., `device_serial="63191FDKX00062"` or `device_serial="emulator-5554"`).
|
||||
Tasks targeting different devices run concurrently via per-device locking.
|
||||
2. **Automatic Device Selection**: If `device_serial` is omitted or `None`,
|
||||
ARTEMIS will automatically select the connected device or allocate an idle device
|
||||
from the device pool.
|
||||
|
||||
**Device Selection Policy & Diagnostics**:
|
||||
- **Prioritize User Choice**: When multiple devices or emulators are connected (or when
|
||||
user intent is not explicitly specified), **ALWAYS PRIORITIZE ASKING THE USER** to
|
||||
choose or confirm the target device before launching tasks.
|
||||
- **Device Diagnosis with `adb devices`**: Always use `adb devices` (or `adb devices -l`)
|
||||
to inspect connected hardware, verify authorization states (`device` vs `unauthorized`),
|
||||
and retrieve active device serials when diagnosing or before delegating tasks.
|
||||
|
||||
### Model Selection Guide (CRITICAL FOR ROUTING)
|
||||
You MUST choose the appropriate `model` based on the task complexity:
|
||||
- **Flash**: Best for simple, deterministic tasks that can be completed
|
||||
@@ -81,6 +99,7 @@ def mobile_run_task(
|
||||
containing:
|
||||
- `trace_id`: The unique session identifier of the task for status tracking
|
||||
and inspection.
|
||||
- `device_serial`: The assigned target device serial (or "auto-select").
|
||||
- `notes_dir`: (Pro Model Only) The directory where the subagent's execution
|
||||
files/notes are located.
|
||||
- `stdout_log`: The path to the execution framework log.
|
||||
@@ -120,6 +139,11 @@ def mobile_run_task(
|
||||
summarization agent will aggregate the interaction history
|
||||
(motivation, actions, videos) into a summary report saved as
|
||||
"output.md" in `notes_dir`. Ignored if `model="Flash"`.
|
||||
device_serial: Optional Android device serial number (e.g. "63191FDKX00062"
|
||||
or "emulator-5554"). If specified, binds execution strictly to that device,
|
||||
allowing multi-device concurrent automation. If omitted, ARTEMIS will
|
||||
automatically select an available device. Prioritize letting the user select
|
||||
the target device if multiple devices are attached.
|
||||
"""
|
||||
# 0. Validate and normalize model
|
||||
if model.lower() not in ("flash", "pro"):
|
||||
@@ -135,18 +159,31 @@ def mobile_run_task(
|
||||
task_desc=task_desc,
|
||||
model=canonical_model,
|
||||
conversation_id=conversation_id,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
# 3. Resolve project root, python executable, and background runner module
|
||||
# 3. Auto-spawn daemon if not already running (unless standalone forced)
|
||||
if os.environ.get("ARTEMIS_STANDALONE") != "1":
|
||||
try:
|
||||
from artemis.runtime import ensure_daemon_running
|
||||
|
||||
ensure_daemon_running(timeout=1.5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. Resolve project root, python executable, and background runner module
|
||||
project_root = env_utils.get_project_root()
|
||||
python_exe = env_utils.resolve_python_executable(project_root)
|
||||
queue_ticket = DeviceExecutionLock.reserve(
|
||||
description=f"MCP task: {task_desc[:120]}",
|
||||
session_id=trace_id,
|
||||
ingress="mcp",
|
||||
)
|
||||
reserve_kwargs: dict[str, Any] = {
|
||||
"description": f"MCP task: {task_desc[:120]}",
|
||||
"session_id": trace_id,
|
||||
"ingress": "mcp",
|
||||
}
|
||||
if device_serial:
|
||||
reserve_kwargs["device_id"] = device_serial
|
||||
queue_ticket = DeviceExecutionLock.reserve(**reserve_kwargs)
|
||||
|
||||
# 4. Spawn the background task runner as an independent subprocess
|
||||
# 5. Spawn the background task runner as an independent subprocess
|
||||
try:
|
||||
cmd = [
|
||||
python_exe,
|
||||
@@ -167,10 +204,15 @@ def mobile_run_task(
|
||||
cmd.extend(["--app-path", app_path])
|
||||
if expected_output_desc and canonical_model != "Flash":
|
||||
cmd.extend(["--expected-output-desc", expected_output_desc])
|
||||
if device_serial:
|
||||
cmd.extend(["--device-serial", device_serial])
|
||||
|
||||
env = os.environ.copy()
|
||||
env["ARTEMIS_SESSION_ID"] = trace_id
|
||||
env["ARTEMIS_TASK_INGRESS"] = "mcp"
|
||||
if device_serial:
|
||||
env["ADB_DEVICE_SERIAL"] = device_serial
|
||||
env["ARTEMIS_DEVICE_ID"] = device_serial
|
||||
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = queue_ticket
|
||||
|
||||
proc_kwargs = env_utils.get_detached_process_kwargs()
|
||||
@@ -180,12 +222,18 @@ def mobile_run_task(
|
||||
cwd=project_root,
|
||||
**proc_kwargs,
|
||||
)
|
||||
transfer_kwargs: dict[str, Any] = {
|
||||
"description": f"MCP task: {task_desc[:120]}",
|
||||
"session_id": trace_id,
|
||||
"ingress": "mcp",
|
||||
}
|
||||
if device_serial:
|
||||
transfer_kwargs["device_id"] = device_serial
|
||||
|
||||
DeviceExecutionLock.transfer_reservation(
|
||||
queue_ticket,
|
||||
proc.pid,
|
||||
description=f"MCP task: {task_desc[:120]}",
|
||||
session_id=trace_id,
|
||||
ingress="mcp",
|
||||
**transfer_kwargs,
|
||||
)
|
||||
|
||||
# 5. Record the PID of the spawned subprocess
|
||||
@@ -201,6 +249,7 @@ def mobile_run_task(
|
||||
|
||||
response_dict: dict[str, Any] = {
|
||||
"trace_id": trace_id,
|
||||
"device_serial": device_serial or "auto-select",
|
||||
"message": "Successfully started background task.",
|
||||
"stdout_log": stdout_log_path,
|
||||
"stderr_log": stderr_log_path,
|
||||
|
||||
@@ -43,6 +43,7 @@ def init_trace(
|
||||
task_desc: str,
|
||||
model: str,
|
||||
conversation_id: str,
|
||||
device_serial: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Initializes the trace directory and creates the initial status.json file."""
|
||||
trace_dir = get_trace_dir(trace_id)
|
||||
@@ -54,6 +55,7 @@ def init_trace(
|
||||
"model": model,
|
||||
"conversation_id": conversation_id,
|
||||
"status": "running",
|
||||
"device_serial": device_serial,
|
||||
"start_time": time.time(),
|
||||
"end_time": None,
|
||||
"error": None,
|
||||
@@ -89,6 +91,7 @@ def update_trace_status(
|
||||
status: str,
|
||||
error: str | None = None,
|
||||
result: Any | None = None,
|
||||
device_serial: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Updates specific fields of the status.json for a given trace_id."""
|
||||
data = read_status(trace_id)
|
||||
@@ -103,6 +106,19 @@ def update_trace_status(
|
||||
data["error"] = error
|
||||
if result is not None:
|
||||
data["result"] = result
|
||||
if device_serial is not None:
|
||||
data["device_serial"] = device_serial
|
||||
|
||||
write_status(trace_id, data)
|
||||
return data
|
||||
|
||||
|
||||
def update_trace_device_serial(trace_id: str, device_serial: str) -> dict[str, Any] | None:
|
||||
"""Updates the device_serial field of the status.json for a given trace_id."""
|
||||
data = read_status(trace_id)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
data["device_serial"] = device_serial
|
||||
write_status(trace_id, data)
|
||||
return data
|
||||
@@ -291,17 +291,17 @@ async def _run_ui_sequence(controller, recording_started: float) -> tuple[list[G
|
||||
async def _analyze(analyzer: VideoAnalyzer, end_time: float, repeats: int) -> list[dict[str, Any]]:
|
||||
queries = {
|
||||
"timeline": (
|
||||
"按时间顺序列出录像中的四个事实并给出可见证据和录像相对时间:"
|
||||
"先完成 12+34=46;再完成 7×8=56;随后打开历史记录面板;最后关闭历史记录面板并回到显示 56 的主界面。"
|
||||
"不要根据提示猜测,只报告视频中实际可见的内容。"
|
||||
"List four facts from the recording in chronological order with visible evidence and relative timestamps: "
|
||||
"First complete 12+34=46; then complete 7×8=56; subsequently open the history panel; finally close the history panel and return to the main screen showing 56. "
|
||||
"Do not guess based on prompts, only report what is actually visible in the video."
|
||||
),
|
||||
"calculations": (
|
||||
"只核验录像中实际输入的两道计算及其最终可见结果。必须明确写出完整表达式 12+34=46 和 7×8=56,"
|
||||
"并按发生顺序报告;若任一项不可见就明确说缺失。"
|
||||
"Only verify the two calculations actually entered in the recording and their final visible results. Must explicitly write the complete expressions 12+34=46 and 7×8=56, "
|
||||
"and report them in order of occurrence; if any is not visible, clearly state that it is missing."
|
||||
),
|
||||
"history": (
|
||||
"只核验历史记录面板的状态变化:它是否在完成两次计算后被打开、是否显示两次计算记录、"
|
||||
"以及是否随后被关闭并返回计算器主界面。请明确使用“打开”和“关闭”描述两个转场。"
|
||||
"Only verify state changes of the history panel: whether it was opened after completing both calculations, whether it displayed both calculation records, "
|
||||
"and whether it was subsequently closed returning to the calculator main screen. Please explicitly use 'open' and 'close' to describe the two transitions."
|
||||
),
|
||||
}
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Interactive Edge-Case Test Suite with Clear Step-by-Step Guidance.
|
||||
|
||||
Supports running each edge-case part independently or all together.
|
||||
Usage:
|
||||
python scripts/run_edge_case_part.py --part 1
|
||||
python scripts/run_edge_case_part.py --part 2
|
||||
python scripts/run_edge_case_part.py --part 3
|
||||
python scripts/run_edge_case_part.py --part 4
|
||||
python scripts/run_edge_case_part.py --part all
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import requests
|
||||
|
||||
WORKSPACE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if WORKSPACE_ROOT not in sys.path:
|
||||
sys.path.insert(0, WORKSPACE_ROOT)
|
||||
|
||||
from artemis.interfaces.sdk.client import ArtemisClient
|
||||
from artemis.runtime.device_lock import DeviceExecutionLock
|
||||
from artemis.toolchain import ensure_toolchain_in_path
|
||||
|
||||
ensure_toolchain_in_path()
|
||||
|
||||
from artemis.runtime import device_pool
|
||||
|
||||
|
||||
def _resolve_devices() -> tuple[str, str]:
|
||||
all_devs = device_pool.list_devices()
|
||||
real_dev = next((d for d in all_devs if not d.is_emulator and d.state == "device"), None)
|
||||
emu_dev = next((d for d in all_devs if d.is_emulator and d.state == "device"), None)
|
||||
phone = os.environ.get("ARTEMIS_PHONE_SERIAL") or (real_dev.serial if real_dev else "63191FDKX00062")
|
||||
emu = os.environ.get("ARTEMIS_EMULATOR_SERIAL") or (emu_dev.serial if emu_dev else "emulator-5554")
|
||||
return phone, emu
|
||||
|
||||
|
||||
PHONE_SERIAL, EMULATOR_SERIAL = _resolve_devices()
|
||||
API_STATUS_URL = "http://127.0.0.1:8000/api/status"
|
||||
|
||||
|
||||
def query_api():
|
||||
try:
|
||||
r = requests.get(API_STATUS_URL, timeout=3).json()
|
||||
return r.get("active_tasks", []), r.get("queue", [])
|
||||
except Exception:
|
||||
return [], []
|
||||
|
||||
|
||||
def print_status_snapshot(label: str):
|
||||
active, queue = query_api()
|
||||
print(f"\n[{label}] Web Console Live Snapshot (/api/status):")
|
||||
print(f" ⚡ Running Tasks ({len(active)}):")
|
||||
for a in active:
|
||||
dev = a.get("device_id") or a.get("device_serial")
|
||||
print(f" - [Device: {dev}] {a.get('goal')} (PID: {a.get('pid')})")
|
||||
print(f" ⏳ Pending Tasks ({len(queue)}):")
|
||||
for q in queue:
|
||||
dev = q.get("device_serial") or q.get("device_id")
|
||||
print(f" - [Device: {dev}] {q.get('goal')} [status={q.get('status')}]")
|
||||
print(f" 📊 Total Active Queue (Running + Pending) = {len(active) + len(queue)}\n")
|
||||
|
||||
|
||||
async def run_part_1():
|
||||
print("=" * 80)
|
||||
print("[Part 1: Single Device Serial Queueing & Automatic Relay]")
|
||||
print("=" * 80)
|
||||
print("🎯 Goal: Launch 2 tasks on the same physical device; verify FIFO queueing, Web Active Queue count, and relay.")
|
||||
print("📱 Target Device: Physical Pixel 11 Pro (63191FDKX00062)")
|
||||
print("👀 Please observe:")
|
||||
print(" 1. Web Console (http://127.0.0.1:8000): Active Queue becomes 2 (1 Running blue badge, 1 Pending yellow badge)")
|
||||
print(" 2. Device Screen: Task 1A opens Settings first; upon completion, Task 1B automatically takes over to open Clock")
|
||||
print(" 3. Web Console: After 1A finishes, 1B switches to Running, and queue eventually clears to zero")
|
||||
print("-" * 80)
|
||||
|
||||
DeviceExecutionLock.cleanup_stale_locks()
|
||||
client = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device")
|
||||
|
||||
async def task_1a():
|
||||
print("🚀 [Task 1A] Launching: Open Settings on physical device...")
|
||||
res = await client.run("Use manage_app to launch Settings app and report task status completed", profile="flash")
|
||||
print(f"✅ [Task 1A] Completed: status={res.status}")
|
||||
return res
|
||||
|
||||
async def task_1b():
|
||||
await asyncio.sleep(0.4)
|
||||
print("🚀 [Task 1B] Launching: Open Clock on physical device -> should immediately enter queue...")
|
||||
res = await client.run("Use manage_app to launch Clock app and report task status completed", profile="flash")
|
||||
print(f"✅ [Task 1B] Completed: status={res.status}")
|
||||
return res
|
||||
|
||||
t1 = asyncio.create_task(task_1a())
|
||||
t2 = asyncio.create_task(task_1b())
|
||||
|
||||
await asyncio.sleep(1.2)
|
||||
print_status_snapshot("Part 1 Running")
|
||||
|
||||
await asyncio.gather(t1, t2)
|
||||
print("🎉 Part 1 Success: Task 1B automatically relayed and completed after Task 1A finished!\n")
|
||||
|
||||
|
||||
async def run_part_2():
|
||||
print("=" * 80)
|
||||
print("[Part 2: Multi-Device True Parallel Execution]")
|
||||
print("=" * 80)
|
||||
print("🎯 Goal: Cross-device true concurrency; both devices run independently at the same time without blocking.")
|
||||
print(f"📱 Target Device 1: Physical Pixel 11 Pro ({PHONE_SERIAL})")
|
||||
print(f"💻 Target Device 2: Android Emulator ({EMULATOR_SERIAL})")
|
||||
print("👀 Please observe:")
|
||||
print(" 1. Dual screens: Physical device and emulator operate simultaneously without sequential waiting!")
|
||||
print(" 2. Web Console (http://127.0.0.1:8000): Active Tasks shows 2 (both with blue Running badges)")
|
||||
print(" 3. Queue count: Queue is 0, each device displays its own Device Serial")
|
||||
print("-" * 80)
|
||||
|
||||
DeviceExecutionLock.cleanup_stale_locks()
|
||||
client_phone = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device")
|
||||
client_emu = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="per_device")
|
||||
|
||||
start_t = time.time()
|
||||
|
||||
async def p_task():
|
||||
print(f"🚀 [Phone Task] Launching: Open Settings on {PHONE_SERIAL}...")
|
||||
res = await client_phone.run("Use manage_app to launch Settings app and report task status completed", profile="flash")
|
||||
print(f"✅ [Phone Task] Finished in {time.time() - start_t:.2f}s")
|
||||
return res
|
||||
|
||||
async def e_task():
|
||||
print(f"🚀 [Emulator Task] Launching: Open Settings on {EMULATOR_SERIAL}...")
|
||||
res = await client_emu.run("Use manage_app to launch Settings app and report task status completed", profile="flash")
|
||||
print(f"✅ [Emulator Task] Finished in {time.time() - start_t:.2f}s")
|
||||
return res
|
||||
|
||||
tp = asyncio.create_task(p_task())
|
||||
te = asyncio.create_task(e_task())
|
||||
|
||||
await asyncio.sleep(1.2)
|
||||
print_status_snapshot("Part 2 Running (Multi-Device Concurrency)")
|
||||
|
||||
await asyncio.gather(tp, te)
|
||||
print(f"🎉 Part 2 Success: Both devices completed tasks concurrently in {time.time() - start_t:.2f}s!\n")
|
||||
|
||||
|
||||
async def run_part_3():
|
||||
print("=" * 80)
|
||||
print("[Part 3: Global Concurrency Limit Across Devices]")
|
||||
print("=" * 80)
|
||||
print("🎯 Goal: Configure global concurrency mode (global, limit 1); even targeting different devices, tasks must queue globally.")
|
||||
print(f"📱 Device 1: Physical Pixel 11 Pro ({PHONE_SERIAL})")
|
||||
print(f"💻 Device 2: Android Emulator ({EMULATOR_SERIAL})")
|
||||
print("👀 Please observe:")
|
||||
print(" 1. Emulator screen: Even though emulator is completely idle, it waits until the phone task completes!")
|
||||
print(" 2. Web Console: Phone task is Running (blue badge), emulator task is Pending (yellow badge)!")
|
||||
print(" 3. Once phone finishes, emulator instantly activates and begins execution!")
|
||||
print("-" * 80)
|
||||
|
||||
DeviceExecutionLock.cleanup_stale_locks()
|
||||
client_phone_g = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="global")
|
||||
client_emu_g = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="global")
|
||||
|
||||
async def task_3a():
|
||||
print(f"🚀 [Task 3A - Phone] Global mode starting: Executing on {PHONE_SERIAL}...")
|
||||
res = await client_phone_g.run("Use manage_app to launch Settings app and report task status completed", profile="flash")
|
||||
print(f"✅ [Task 3A - Phone] Finished")
|
||||
return res
|
||||
|
||||
async def task_3b():
|
||||
await asyncio.sleep(0.4)
|
||||
print(f"🚀 [Task 3B - Emulator] Global mode starting: Targeting {EMULATOR_SERIAL} (should queue due to global limit)...")
|
||||
res = await client_emu_g.run("Use manage_app to launch Settings app and report task status completed", profile="flash")
|
||||
print(f"✅ [Task 3B - Emulator] Finished")
|
||||
return res
|
||||
|
||||
t3a = asyncio.create_task(task_3a())
|
||||
t3b = asyncio.create_task(task_3b())
|
||||
|
||||
await asyncio.sleep(1.2)
|
||||
print_status_snapshot("Part 3 Running (Global Limit Cross-Device Queueing)")
|
||||
|
||||
await asyncio.gather(t3a, t3b)
|
||||
print("🎉 Part 3 Success: Global mutex accurately queued and relayed tasks across physical phone and emulator!\n")
|
||||
|
||||
|
||||
async def run_part_4():
|
||||
print("=" * 80)
|
||||
print("[Part 4: Multi-Device Asymmetric Mixed Queueing]")
|
||||
print("=" * 80)
|
||||
print("🎯 Goal: Assign 2 tasks to physical phone, 1 task to emulator.")
|
||||
print(" Expected: Phone 4A and Emulator 4C run concurrently; Phone 4B queues in dedicated phone queue; 4B relays immediately on phone after 4A.")
|
||||
print("👀 Please observe:")
|
||||
print(" 1. Web Console: Active Tasks = 2 (Phone 4A + Emulator 4C), Queue = 1 (Phone 4B), Total = 3!")
|
||||
print(" 2. Screens: Phone and emulator operate concurrently first; Phone 4B executes on phone immediately after 4A finishes!")
|
||||
print("-" * 80)
|
||||
|
||||
DeviceExecutionLock.cleanup_stale_locks()
|
||||
client_phone = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device")
|
||||
client_emu = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="per_device")
|
||||
|
||||
async def task_4a():
|
||||
print(f"🚀 [Task 4A - Phone] Started...")
|
||||
return await client_phone.run("Use manage_app to launch Settings app and report task status completed", profile="flash")
|
||||
|
||||
async def task_4b():
|
||||
await asyncio.sleep(0.4)
|
||||
print(f"🚀 [Task 4B - Phone] Started (Queued behind 4A)...")
|
||||
return await client_phone.run("Use manage_app to launch Clock app and report task status completed", profile="flash")
|
||||
|
||||
async def task_4c():
|
||||
print(f"🚀 [Task 4C - Emulator] Started concurrently with 4A...")
|
||||
return await client_emu.run("Use manage_app to launch Settings app and report task status completed", profile="flash")
|
||||
|
||||
t4a = asyncio.create_task(task_4a())
|
||||
t4b = asyncio.create_task(task_4b())
|
||||
t4c = asyncio.create_task(task_4c())
|
||||
|
||||
await asyncio.sleep(1.2)
|
||||
print_status_snapshot("Part 4 Running (Mixed Queueing)")
|
||||
|
||||
await asyncio.gather(t4a, t4b, t4c)
|
||||
print("🎉 Part 4 Success: Cross-device concurrency and single-device queueing work together seamlessly!\n")
|
||||
|
||||
|
||||
async def main():
|
||||
global PHONE_SERIAL, EMULATOR_SERIAL
|
||||
parser = argparse.ArgumentParser(description="Artemis Concurrency & Queue Edge-Cases Runner")
|
||||
parser.add_argument("--part", choices=["1", "2", "3", "4", "all"], default="1", help="Which part to run (1, 2, 3, 4, or all)")
|
||||
parser.add_argument("--phone", default=None, help="Target physical phone serial number")
|
||||
parser.add_argument("--emulator", default=None, help="Target Android emulator serial number")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.phone:
|
||||
PHONE_SERIAL = args.phone
|
||||
if args.emulator:
|
||||
EMULATOR_SERIAL = args.emulator
|
||||
|
||||
if args.part == "1":
|
||||
await run_part_1()
|
||||
elif args.part == "2":
|
||||
await run_part_2()
|
||||
elif args.part == "3":
|
||||
await run_part_3()
|
||||
elif args.part == "4":
|
||||
await run_part_4()
|
||||
elif args.part == "all":
|
||||
await run_part_1()
|
||||
await asyncio.sleep(1)
|
||||
await run_part_2()
|
||||
await asyncio.sleep(1)
|
||||
await run_part_3()
|
||||
await asyncio.sleep(1)
|
||||
await run_part_4()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+77
-7
@@ -73,12 +73,47 @@ def main() -> None:
|
||||
)
|
||||
parser.add_argument("-p", "--port", type=int, default=8000, help="Port to run UI on")
|
||||
parser.add_argument("-H", "--host", type=str, default="0.0.0.0", help="Host address to bind")
|
||||
parser.add_argument("--no-open", action="store_true", help="Do not automatically open browser")
|
||||
parser.add_argument(
|
||||
"--reload", action="store_true", help="Enable uvicorn auto-reload for development"
|
||||
"-r",
|
||||
"--restart",
|
||||
action="store_true",
|
||||
help="Restart the server by terminating any existing instance on the port first",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--stop",
|
||||
action="store_true",
|
||||
help="Stop running Artemis server on the specified port",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
action="store_true",
|
||||
help="Display status of running Artemis server on the specified port",
|
||||
)
|
||||
args, unknown = parser.parse_known_args()
|
||||
|
||||
# Ensure project root is on sys.path for lifecycle utility imports
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from artemis.runtime.server_lifecycle import get_server_status, stop_server
|
||||
|
||||
if args.status:
|
||||
st = get_server_status(args.port)
|
||||
if st["running"]:
|
||||
pid_info = f" (PID: {', '.join(map(str, st['pids']))})" if st["pids"] else ""
|
||||
print(f"\033[1;32m● Artemis Server is RUNNING on port {args.port}{pid_info}\033[0m")
|
||||
print(f" URL: {st['url']}")
|
||||
else:
|
||||
print(f"\033[1;30m○ Artemis Server is STOPPED (Port {args.port} is free)\033[0m")
|
||||
return
|
||||
|
||||
if args.stop:
|
||||
print(f"\033[1;33m🛑 Stopping Artemis server on port {args.port}...\033[0m")
|
||||
ok, msg, pids = stop_server(args.port)
|
||||
print(f"\033[1;32m✓ {msg}\033[0m")
|
||||
return
|
||||
|
||||
ui_url = f"http://localhost:{args.port}"
|
||||
admin_url = f"http://localhost:{args.port}/admin"
|
||||
|
||||
@@ -86,15 +121,50 @@ def main() -> None:
|
||||
print("\033[1;36m ✨ Artemis Autonomous Mobile Agent UI \033[0m")
|
||||
print("\033[1;36m" + "=" * 56 + "\033[0m\n")
|
||||
|
||||
# Check if restart requested
|
||||
if args.restart:
|
||||
print(f"\033[1;33m🔄 Restart requested. Recycling port {args.port}...\033[0m")
|
||||
ok, msg, pids = stop_server(args.port)
|
||||
if pids:
|
||||
print(f"\033[1;32m✓ {msg}\033[0m\n")
|
||||
else:
|
||||
print(f"Port {args.port} is clear.\n")
|
||||
|
||||
# Check if server is already running
|
||||
if is_port_in_use(args.port):
|
||||
elif is_port_in_use(args.port):
|
||||
if is_artemis_ui_responsive(ui_url):
|
||||
print(f"\033[1;32m✓ Artemis UI is already running at:\033[0m \033[1;36m{ui_url}\033[0m")
|
||||
print(f"\033[1;35m🛠️ Admin Console:\033[0m \033[1;36m{admin_url}\033[0m\n")
|
||||
if not args.no_open:
|
||||
print("🌐 Opening browser...")
|
||||
webbrowser.open(ui_url)
|
||||
return
|
||||
|
||||
# In interactive terminal, allow user to restart or open browser
|
||||
if sys.stdin.isatty():
|
||||
print("Server is active in another session/terminal.")
|
||||
print(" [1] Open browser (default)")
|
||||
print(" [2] Restart server (stop existing and start here)")
|
||||
print(" [3] Stop server")
|
||||
try:
|
||||
choice = input("Select [1-3, default 1]: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
choice = "1"
|
||||
|
||||
if choice == "2":
|
||||
print(f"\n\033[1;33m🔄 Restarting Artemis server on port {args.port}...\033[0m")
|
||||
stop_server(args.port)
|
||||
elif choice == "3":
|
||||
print(f"\n\033[1;33m🛑 Stopping Artemis server on port {args.port}...\033[0m")
|
||||
ok, msg, _ = stop_server(args.port)
|
||||
print(f"\033[1;32m✓ {msg}\033[0m")
|
||||
return
|
||||
else:
|
||||
if not args.no_open:
|
||||
print("🌐 Opening browser...")
|
||||
webbrowser.open(ui_url)
|
||||
return
|
||||
else:
|
||||
if not args.no_open:
|
||||
print("🌐 Opening browser...")
|
||||
webbrowser.open(ui_url)
|
||||
return
|
||||
else:
|
||||
print(f"\033[1;33m⚠️ Port {args.port} is in use by another process.\033[0m")
|
||||
print("Attempting to connect or start on specified port...")
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Comprehensive Edge-Case Test Suite for Artemis Multi-Device Concurrency & Unified Queue.
|
||||
|
||||
Covers:
|
||||
1. Single Device FIFO Serial Queueing & Relay
|
||||
2. Multi-Device True Parallel Execution
|
||||
3. Global Concurrency Serialization across Distinct Devices
|
||||
4. Mixed Multi-Device Queueing
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import requests
|
||||
|
||||
# Ensure workspace in path
|
||||
WORKSPACE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if WORKSPACE_ROOT not in sys.path:
|
||||
sys.path.insert(0, WORKSPACE_ROOT)
|
||||
|
||||
from artemis.interfaces.sdk.client import ArtemisClient
|
||||
from artemis.runtime.device_lock import DeviceExecutionLock
|
||||
from artemis.toolchain import ensure_toolchain_in_path
|
||||
|
||||
ensure_toolchain_in_path()
|
||||
|
||||
from artemis.runtime import device_pool
|
||||
|
||||
|
||||
def _resolve_devices() -> tuple[str, str]:
|
||||
all_devs = device_pool.list_devices()
|
||||
real_dev = next((d for d in all_devs if not d.is_emulator and d.state == "device"), None)
|
||||
emu_dev = next((d for d in all_devs if d.is_emulator and d.state == "device"), None)
|
||||
phone = os.environ.get("ARTEMIS_PHONE_SERIAL") or (real_dev.serial if real_dev else "63191FDKX00062")
|
||||
emu = os.environ.get("ARTEMIS_EMULATOR_SERIAL") or (emu_dev.serial if emu_dev else "emulator-5554")
|
||||
return phone, emu
|
||||
|
||||
|
||||
PHONE_SERIAL, EMULATOR_SERIAL = _resolve_devices()
|
||||
API_STATUS_URL = "http://127.0.0.1:8000/api/status"
|
||||
|
||||
|
||||
def query_api_status():
|
||||
try:
|
||||
res = requests.get(API_STATUS_URL, timeout=3).json()
|
||||
active = res.get("active_tasks", [])
|
||||
queue = res.get("queue", [])
|
||||
return active, queue
|
||||
except Exception as e:
|
||||
return [], []
|
||||
|
||||
|
||||
def print_status_snapshot(stage_name: str):
|
||||
active, queue = query_api_status()
|
||||
print(f"\n[{stage_name}] Live Snapshot from Web Console (/api/status):")
|
||||
print(f" ⚡ Running Tasks ({len(active)}):")
|
||||
for a in active:
|
||||
print(f" - Device: {a.get('device_id')} | Goal: {a.get('goal')} (PID: {a.get('pid')})")
|
||||
print(f" ⏳ Queued Tasks ({len(queue)}):")
|
||||
for q in queue:
|
||||
dev = q.get('device_serial') or q.get('device_id')
|
||||
print(f" - Device: {dev} | Goal: {q.get('goal')} [status={q.get('status')}]")
|
||||
print(f" 📊 Total in Active Queue (Running + Pending) = {len(active) + len(queue)}\n")
|
||||
|
||||
|
||||
async def stage_1_single_device_queueing():
|
||||
print("\n" + "=" * 80)
|
||||
print("STAGE 1: Single Device FIFO Queueing & Relay")
|
||||
print(f"Target Device: {PHONE_SERIAL} (Pixel 11 Pro)")
|
||||
print("=" * 80)
|
||||
|
||||
client_phone = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device")
|
||||
|
||||
t1_started = False
|
||||
t2_queued = False
|
||||
|
||||
async def task_1a():
|
||||
print(f"🚀 [Task 1A] Launching on {PHONE_SERIAL}: Press HOME and open Settings...")
|
||||
res = await client_phone.run("Press HOME key and open Settings", profile="flash")
|
||||
print(f"✅ [Task 1A] Finished with status: {res.status}")
|
||||
return res
|
||||
|
||||
async def task_1b():
|
||||
# Small delay so 1A is definitely acquired first
|
||||
await asyncio.sleep(0.4)
|
||||
print(f"🚀 [Task 1B] Launching on {PHONE_SERIAL}: Press HOME and open Clock (Should queue!)...")
|
||||
res = await client_phone.run("Press HOME key and open Clock", profile="flash")
|
||||
print(f"✅ [Task 1B] Finished with status: {res.status}")
|
||||
return res
|
||||
|
||||
# Run both simultaneously
|
||||
task_a_coro = asyncio.create_task(task_1a())
|
||||
task_b_coro = asyncio.create_task(task_1b())
|
||||
|
||||
# Wait 1.5s and inspect status while 1A is running and 1B is queued
|
||||
await asyncio.sleep(1.2)
|
||||
print_status_snapshot("STAGE 1 In-Progress")
|
||||
active, queue = query_api_status()
|
||||
assert len(active) >= 1, "Expected Task 1A to be actively running!"
|
||||
# 1B should be in the queue or 1A just finished
|
||||
print(f"✓ Verified: Task 1A is running on {PHONE_SERIAL}")
|
||||
|
||||
res_a, res_b = await asyncio.gather(task_a_coro, task_b_coro)
|
||||
print("✓ Stage 1 Complete: Both tasks executed in order and completed successfully!")
|
||||
|
||||
|
||||
async def stage_2_multi_device_parallel():
|
||||
print("\n" + "=" * 80)
|
||||
print("STAGE 2: Multi-Device True Parallel Execution")
|
||||
print(f"Devices: {PHONE_SERIAL} (Pixel 11 Pro) AND {EMULATOR_SERIAL} (Emulator)")
|
||||
print("Concurrency Mode: per_device (Should run simultaneously!)")
|
||||
print("=" * 80)
|
||||
|
||||
client_phone = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device")
|
||||
client_emu = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="per_device")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
async def task_phone():
|
||||
print(f"📱 [Phone Task] Running on {PHONE_SERIAL}: Open Settings...")
|
||||
res = await client_phone.run("Press HOME key and open Settings", profile="flash")
|
||||
print(f"✅ [Phone Task] Completed in {time.time() - start_time:.2f}s")
|
||||
return res
|
||||
|
||||
async def task_emu():
|
||||
print(f"💻 [Emulator Task] Running on {EMULATOR_SERIAL}: Open Settings...")
|
||||
res = await client_emu.run("Press HOME key and open Settings", profile="flash")
|
||||
print(f"✅ [Emulator Task] Completed in {time.time() - start_time:.2f}s")
|
||||
return res
|
||||
|
||||
p_task = asyncio.create_task(task_phone())
|
||||
e_task = asyncio.create_task(task_emu())
|
||||
|
||||
# Check during run
|
||||
await asyncio.sleep(1.2)
|
||||
print_status_snapshot("STAGE 2 Parallel In-Progress")
|
||||
active, queue = query_api_status()
|
||||
print(f"Active devices count: {len(active)}")
|
||||
active_devices = {a.get("device_id") for a in active}
|
||||
print(f"Active devices: {active_devices}")
|
||||
|
||||
await asyncio.gather(p_task, e_task)
|
||||
total_duration = time.time() - start_time
|
||||
print(f"✓ Stage 2 Complete: Both devices completed parallel tasks in {total_duration:.2f}s!")
|
||||
|
||||
|
||||
async def stage_3_global_concurrency_mode():
|
||||
print("\n" + "=" * 80)
|
||||
print("STAGE 3: Global Concurrency Mode Serialization")
|
||||
print("Concurrency Mode: global (max 1 task system-wide even across different devices)")
|
||||
print("=" * 80)
|
||||
|
||||
client_phone_global = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="global")
|
||||
client_emu_global = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="global")
|
||||
|
||||
async def task_phone_g():
|
||||
print(f"📱 [Task 3A - Phone] Acquiring global lock on {PHONE_SERIAL}...")
|
||||
res = await client_phone_global.run("Press HOME key and open Settings", profile="flash")
|
||||
print(f"✅ [Task 3A - Phone] Finished.")
|
||||
return res
|
||||
|
||||
async def task_emu_g():
|
||||
await asyncio.sleep(0.4)
|
||||
print(f"💻 [Task 3B - Emulator] Requesting run on {EMULATOR_SERIAL} under global mode (Should queue!)...")
|
||||
res = await client_emu_global.run("Press HOME key and open Settings", profile="flash")
|
||||
print(f"✅ [Task 3B - Emulator] Finished.")
|
||||
return res
|
||||
|
||||
p_task = asyncio.create_task(task_phone_g())
|
||||
e_task = asyncio.create_task(task_emu_g())
|
||||
|
||||
await asyncio.sleep(1.2)
|
||||
print_status_snapshot("STAGE 3 Global Mode In-Progress")
|
||||
active, queue = query_api_status()
|
||||
print(f"Active count: {len(active)}, Queue count: {len(queue)}")
|
||||
|
||||
await asyncio.gather(p_task, e_task)
|
||||
print("✓ Stage 3 Complete: Global lock correctly serialized execution across physical phone and emulator!")
|
||||
|
||||
|
||||
async def stage_4_mixed_queue_and_parallel():
|
||||
print("\n" + "=" * 80)
|
||||
print("STAGE 4: Mixed Multi-Device Queueing")
|
||||
print("Scenario: Phone gets 2 tasks (4A, 4B), Emulator gets 1 task (4C).")
|
||||
print("Expectation: 4A (Phone) and 4C (Emulator) run concurrently; 4B waits for Phone.")
|
||||
print("=" * 80)
|
||||
|
||||
client_phone = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device")
|
||||
client_emu = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="per_device")
|
||||
|
||||
async def task_4a():
|
||||
print(f"📱 [Task 4A - Phone] Running...")
|
||||
return await client_phone.run("Press HOME key and open Settings", profile="flash")
|
||||
|
||||
async def task_4b():
|
||||
await asyncio.sleep(0.4)
|
||||
print(f"📱 [Task 4B - Phone] Queued behind 4A...")
|
||||
return await client_phone.run("Press HOME key and open Clock", profile="flash")
|
||||
|
||||
async def task_4c():
|
||||
print(f"💻 [Task 4C - Emulator] Running in parallel with 4A...")
|
||||
return await client_emu.run("Press HOME key and open Settings", profile="flash")
|
||||
|
||||
t4a = asyncio.create_task(task_4a())
|
||||
t4b = asyncio.create_task(task_4b())
|
||||
t4c = asyncio.create_task(task_4c())
|
||||
|
||||
await asyncio.sleep(1.2)
|
||||
print_status_snapshot("STAGE 4 Mixed Multi-Device In-Progress")
|
||||
|
||||
await asyncio.gather(t4a, t4b, t4c)
|
||||
print("✓ Stage 4 Complete: 4A and 4C ran in parallel, 4B queued and picked up phone relay!")
|
||||
|
||||
|
||||
async def main():
|
||||
import argparse
|
||||
global PHONE_SERIAL, EMULATOR_SERIAL
|
||||
|
||||
parser = argparse.ArgumentParser(description="ARTEMIS All Edge-Cases Comprehensive Test Suite")
|
||||
parser.add_argument("--phone", default=None, help="Target physical phone serial number")
|
||||
parser.add_argument("--emulator", default=None, help="Target Android emulator serial number")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.phone:
|
||||
PHONE_SERIAL = args.phone
|
||||
if args.emulator:
|
||||
EMULATOR_SERIAL = args.emulator
|
||||
|
||||
print("\n" + "#" * 80)
|
||||
print("# ARTEMIS ALL EDGE-CASES COMPREHENSIVE CONCURRENCY & QUEUE TEST SUITE")
|
||||
print(f"# Devices: Real Phone ({PHONE_SERIAL}) + Android Emulator ({EMULATOR_SERIAL})")
|
||||
print("# Web Console API: http://127.0.0.1:8000/api/status")
|
||||
print("#" * 80)
|
||||
|
||||
# Initial cleanup
|
||||
DeviceExecutionLock.cleanup_stale_locks()
|
||||
print_status_snapshot("Initial System State")
|
||||
|
||||
# Run all 4 stages
|
||||
await stage_1_single_device_queueing()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await stage_2_multi_device_parallel()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await stage_3_global_concurrency_mode()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await stage_4_mixed_queue_and_parallel()
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("🎉 ALL EDGE-CASE STAGES COMPLETED SUCCESSFULLY!")
|
||||
print("=" * 80)
|
||||
print_status_snapshot("Final System State")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 Google LLC
|
||||
"""Live experiment demonstrating Artemis concurrency modes and device targeting.
|
||||
|
||||
Covers:
|
||||
1. Single-device queueing and relay (FIFO mutual exclusion on the same device).
|
||||
2. Multi-device parallel execution (per_device concurrency mode across real device & emulator).
|
||||
3. Cross-device global serialization (global concurrency mode restricting all devices to 1 task).
|
||||
4. Full parameter coverage (device_serial, set_device, Task objects, list_devices, get_idle_devices).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
# Add project root to sys.path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from artemis import ArtemisClient, ConcurrencyMode, Task
|
||||
from artemis.runtime import DeviceBusyError, device_pool
|
||||
|
||||
|
||||
def print_header(title: str):
|
||||
print("\n" + "=" * 76)
|
||||
print(f" {title}")
|
||||
print("=" * 76)
|
||||
|
||||
|
||||
def now_str() -> str:
|
||||
return datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
|
||||
|
||||
async def run_experiment_1_single_device_relay(real_serial: str):
|
||||
"""Experiment 1: Single device FIFO queueing & relay."""
|
||||
print_header("[Experiment 1] Single-Device Task Queueing & Relay (FIFO Relay on Single Device)")
|
||||
print(f"Target Device: {real_serial} (Physical Pixel 11 Pro)")
|
||||
|
||||
client = ArtemisClient(device_serial=real_serial, default_profile="flash", concurrency_mode="per_device")
|
||||
print(f"Client Init: device_serial='{client.device_serial}', concurrency_mode='{client.concurrency_mode}'")
|
||||
|
||||
task_1_launched = 0.0
|
||||
task_1_start = 0.0
|
||||
task_1_end = 0.0
|
||||
|
||||
task_2_launched = 0.0
|
||||
task_2_start = 0.0
|
||||
task_2_end = 0.0
|
||||
|
||||
async def execute_task_1():
|
||||
nonlocal task_1_launched, task_1_start, task_1_end
|
||||
task_1_launched = time.time()
|
||||
print(f"[{now_str()}] ▶ [Task 1] Submitted for scheduling (target device: {real_serial})")
|
||||
task_1_start = time.time()
|
||||
res = await client.run(
|
||||
"Open Settings and check battery level",
|
||||
device_serial=real_serial,
|
||||
max_turns=3,
|
||||
)
|
||||
task_1_end = time.time()
|
||||
print(
|
||||
f"[{now_str()}] ◀ [Task 1] Execution completed! Status={res.status}, "
|
||||
f"Trace={res.trace_id[:8]}..., Elapsed={task_1_end - task_1_start:.3f}s"
|
||||
)
|
||||
return res
|
||||
|
||||
async def execute_task_2():
|
||||
nonlocal task_2_launched, task_2_start, task_2_end
|
||||
task_2_launched = time.time()
|
||||
print(f"[{now_str()}] ▶ [Task 2] Submitted concurrently (detects device occupied by Task 1, entering FIFO queue...)")
|
||||
|
||||
task_2_start = time.time()
|
||||
res = await client.run(
|
||||
"Return to home screen and open Clock",
|
||||
device_serial=real_serial,
|
||||
max_turns=3,
|
||||
)
|
||||
task_2_end = time.time()
|
||||
print(
|
||||
f"[{now_str()}] ◀ [Task 2] Relayed execution completed! Status={res.status}, "
|
||||
f"Trace={res.trace_id[:8]}..., Elapsed={task_2_end - task_2_start:.3f}s"
|
||||
)
|
||||
return res
|
||||
|
||||
# Launch Task 1 first, then Task 2 40ms later while Task 1 is holding the lock
|
||||
t_coro_1 = asyncio.create_task(execute_task_1())
|
||||
await asyncio.sleep(0.04)
|
||||
t_coro_2 = asyncio.create_task(execute_task_2())
|
||||
res1, res2 = await asyncio.gather(t_coro_1, t_coro_2)
|
||||
|
||||
print("\n[Timing & Relay Analysis]:")
|
||||
print(f" Task 1 runtime interval: [{task_1_start:.3f}s ~ {task_1_end:.3f}s] (elapsed {task_1_end - task_1_start:.3f}s)")
|
||||
print(f" Task 2 submission time: {task_2_launched:.3f}s")
|
||||
print(f" Task 2 actual start: {task_2_start:.3f}s (waited for Task 1 lock release)")
|
||||
print(f" Task 2 end time: {task_2_end:.3f}s (elapsed {task_2_end - task_2_start:.3f}s)")
|
||||
|
||||
assert task_2_end > task_1_end, "Task 2 did not complete after Task 1!"
|
||||
assert task_2_end - task_1_start >= (task_1_end - task_1_start) + 0.1, "Tasks failed to execute in sequential queue!"
|
||||
print(" ✅ Verification successful: Task 2 queued properly and relayed immediately after Task 1 released the lock!")
|
||||
|
||||
|
||||
async def run_experiment_2_multi_device_parallel(real_serial: str, emu_serial: str):
|
||||
"""Experiment 2: Multi-device parallel execution under per_device concurrency."""
|
||||
print_header("[Experiment 2] Multi-Device Parallel Execution Test")
|
||||
print(f"Device A (Physical): {real_serial}")
|
||||
print(f"Device B (Emulator): {emu_serial}")
|
||||
|
||||
client_a = ArtemisClient(device_serial=real_serial, default_profile="flash", concurrency_mode="per_device")
|
||||
client_b = ArtemisClient(device_serial=emu_serial, default_profile="flash", concurrency_mode="per_device")
|
||||
|
||||
a_start, a_end = 0.0, 0.0
|
||||
b_start, b_end = 0.0, 0.0
|
||||
|
||||
async def run_on_device_a():
|
||||
nonlocal a_start, a_end
|
||||
print(f"[{now_str()}] ▶ [Device A Task] Starting on {real_serial}")
|
||||
a_start = time.time()
|
||||
res = await client_a.run("Test operation A on physical device", max_turns=3)
|
||||
a_end = time.time()
|
||||
print(f"[{now_str()}] ◀ [Device A Task] Completed! Elapsed: {a_end - a_start:.3f}s, Device: {res.device_id}")
|
||||
return res
|
||||
|
||||
async def run_on_device_b():
|
||||
nonlocal b_start, b_end
|
||||
print(f"[{now_str()}] ▶ [Device B Task] Starting on {emu_serial}")
|
||||
b_start = time.time()
|
||||
res = await client_b.run("Test operation B on emulator", max_turns=3)
|
||||
b_end = time.time()
|
||||
print(f"[{now_str()}] ◀ [Device B Task] Completed! Elapsed: {b_end - b_start:.3f}s, Device: {res.device_id}")
|
||||
return res
|
||||
|
||||
t_start = time.time()
|
||||
task_a = asyncio.create_task(run_on_device_a())
|
||||
task_b = asyncio.create_task(run_on_device_b())
|
||||
res_a, res_b = await asyncio.gather(task_a, task_b)
|
||||
t_total = time.time() - t_start
|
||||
|
||||
print("\n[Dual-Device Concurrency Analysis]:")
|
||||
print(f" Device A execution interval: {a_start:.3f}s ~ {a_end:.3f}s (elapsed {a_end - a_start:.3f}s)")
|
||||
print(f" Device B execution interval: {b_start:.3f}s ~ {b_end:.3f}s (elapsed {b_end - b_start:.3f}s)")
|
||||
print(f" Total concurrent duration: {t_total:.3f}s (sum of sequential durations: {(a_end - a_start) + (b_end - b_start):.3f}s)")
|
||||
|
||||
# Overlap verification
|
||||
overlap = min(a_end, b_end) - max(a_start, b_start)
|
||||
print(f" Parallel overlap duration: {overlap:.3f}s")
|
||||
assert overlap > 0.0, "Dual devices failed to run concurrently with overlap!"
|
||||
assert t_total < ((a_end - a_start) + (b_end - b_start) * 0.95), "Total duration should be less than sum of both tasks!"
|
||||
print(" ✅ Verification successful: Tasks on both devices ran concurrently without blocking!")
|
||||
|
||||
|
||||
async def run_experiment_3_global_concurrency_serialization(real_serial: str, emu_serial: str):
|
||||
"""Experiment 3: Global concurrency serialization across devices."""
|
||||
print_header("[Experiment 3] Global Concurrency Mode Test")
|
||||
print(f"Mode description: concurrency_mode='global', entire system allows only 1 task across all devices.")
|
||||
|
||||
client_a = ArtemisClient(device_serial=real_serial, default_profile="flash", concurrency_mode="global")
|
||||
client_b = ArtemisClient(device_serial=emu_serial, default_profile="flash", concurrency_mode="global")
|
||||
|
||||
# 3.1 Test non-blocking mode rejection
|
||||
print("\n[Subtest 3.1: Non-blocking rejection limit]")
|
||||
from artemis.runtime import DeviceExecutionLock
|
||||
lock_a = DeviceExecutionLock(real_serial, "Placeholder test lock", concurrency_mode="global")
|
||||
await asyncio.to_thread(lock_a.acquire)
|
||||
try:
|
||||
print(f"[{now_str()}] Device A ({real_serial}) holds the global lock...")
|
||||
try:
|
||||
print(f"[{now_str()}] Device B ({emu_serial}) attempts run with blocking=False...")
|
||||
await client_b.run("Emulator non-blocking task", blocking=False)
|
||||
print(" ❌ Exception: Was not intercepted by global lock!")
|
||||
except DeviceBusyError as exc:
|
||||
print(f" ✅ Correctly intercepted with DeviceBusyError: {exc}")
|
||||
finally:
|
||||
await asyncio.to_thread(lock_a.release)
|
||||
|
||||
# 3.2 Test cross-device queueing and relay
|
||||
print("\n[Subtest 3.2: Cross-device global queueing & relay]")
|
||||
t1_submitted, t1_end = 0.0, 0.0
|
||||
t2_submitted, t2_end = 0.0, 0.0
|
||||
|
||||
async def run_global_task_1():
|
||||
nonlocal t1_submitted, t1_end
|
||||
t1_submitted = time.time()
|
||||
print(f"[{now_str()}] ▶ [Global Task 1] Started on physical device ({real_serial})...")
|
||||
res = await client_a.run("Global task 1", max_turns=3)
|
||||
t1_end = time.time()
|
||||
print(f"[{now_str()}] ◀ [Global Task 1] Completed! Elapsed: {t1_end - t1_submitted:.3f}s")
|
||||
return res
|
||||
|
||||
async def run_global_task_2():
|
||||
nonlocal t2_submitted, t2_end
|
||||
# Submit task 2 shortly after task 1
|
||||
await asyncio.sleep(0.04)
|
||||
t2_submitted = time.time()
|
||||
print(f"[{now_str()}] ▶ [Global Task 2] Submitted to emulator ({emu_serial}), entering queue due to global limit...")
|
||||
res = await client_b.run("Global task 2", max_turns=3)
|
||||
t2_end = time.time()
|
||||
print(f"[{now_str()}] ◀ [Global Task 2] Relayed completion! Total time (including queue): {t2_end - t2_submitted:.3f}s")
|
||||
return res
|
||||
|
||||
res1, res2 = await asyncio.gather(run_global_task_1(), run_global_task_2())
|
||||
print(f" Global Task 1 interval: {t1_submitted:.3f}s ~ {t1_end:.3f}s (elapsed {t1_end - t1_submitted:.3f}s)")
|
||||
print(f" Global Task 2 submit time: {t2_submitted:.3f}s")
|
||||
print(f" Global Task 2 end time: {t2_end:.3f}s (waited for Task 1 completion before executing)")
|
||||
assert t2_end > t1_end, "Global Task 2 did not complete after Global Task 1!"
|
||||
assert (t2_end - t1_end) >= 0.08, "Global Task 2 did not execute sequentially after Task 1 released global lock!"
|
||||
print(" ✅ Verification successful: Under global mode, strictly 1 task executed sequentially across different devices!")
|
||||
|
||||
|
||||
async def run_experiment_4_parameters_and_utilities(real_serial: str, emu_serial: str):
|
||||
"""Experiment 4: Comprehensive SDK parameter and utility testing."""
|
||||
print_header("[Experiment 4] SDK Parameter & Utility Comprehensive Test")
|
||||
|
||||
client = ArtemisClient()
|
||||
print(f"1. Default idle device selection: client.device_serial = '{client.device_serial}'")
|
||||
|
||||
# Chain switch device
|
||||
client.set_device(real_serial)
|
||||
print(f"2. Chain switch to physical device: client.set_device('{real_serial}') -> '{client.device_serial}'")
|
||||
assert client.device_serial == real_serial
|
||||
|
||||
# Attribute switch
|
||||
client.device_serial = emu_serial
|
||||
print(f"3. Property assignment to emulator: client.device_serial = '{emu_serial}' -> '{client.device_id}'")
|
||||
assert client.device_id == emu_serial
|
||||
|
||||
# Dynamically change concurrency mode
|
||||
client.set_concurrency_mode(ConcurrencyMode.GLOBAL)
|
||||
print(f"4. Chain change concurrency mode: client.concurrency_mode = '{client.concurrency_mode}'")
|
||||
assert client.concurrency_mode == "global"
|
||||
client.concurrency_mode = "per_device"
|
||||
|
||||
# Query all available devices
|
||||
print("5. client.list_devices() status:")
|
||||
devices = client.list_devices()
|
||||
for d in devices:
|
||||
print(f" • [{d.serial}] State: {d.state}, Model: {d.model}, IsEmulator: {d.is_emulator}, Busy: {d.is_busy}")
|
||||
|
||||
# Query idle devices
|
||||
idle = client.get_idle_devices()
|
||||
print(f"6. client.get_idle_devices(): {[d.serial for d in idle]}")
|
||||
|
||||
# Use Task structured object with run_task
|
||||
print("7. Calling client.run_task() with structured Task entity:")
|
||||
task_obj = Task(
|
||||
goal="Verify physical device call via Task object",
|
||||
profile="flash",
|
||||
device_serial=real_serial,
|
||||
concurrency_mode="per_device",
|
||||
max_turns=3,
|
||||
)
|
||||
result = await client.run_task(task_obj)
|
||||
print(f" Task result: Status={result.status}, Device={result.device_id}, Trace={result.trace_id[:8]}...")
|
||||
assert result.device_id == real_serial
|
||||
|
||||
# Use stream_run for live event stream
|
||||
print("8. Using client.stream_run() for live event stream:")
|
||||
events_received = 0
|
||||
async for ev in client.stream_run(
|
||||
"Stream operation test",
|
||||
device_serial=emu_serial,
|
||||
profile="flash",
|
||||
max_turns=3,
|
||||
):
|
||||
events_received += 1
|
||||
print(f" • [{ev.event_type}] payload={ev.payload}")
|
||||
assert events_received >= 2
|
||||
print(f" ✅ Stream events captured successfully! Captured {events_received} events.")
|
||||
|
||||
|
||||
async def main():
|
||||
# Detect devices
|
||||
devices = device_pool.list_devices()
|
||||
print("=" * 76)
|
||||
print("[ARTEMIS Python SDK Concurrency & Device Targeting Experiment]")
|
||||
print(f"Detected connected devices count: {len(devices)}")
|
||||
for d in devices:
|
||||
print(f" • Serial: {d.serial} | Model: {d.model} | State: {d.state} | IsEmulator: {d.is_emulator}")
|
||||
print("=" * 76)
|
||||
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Live experiment demonstrating Artemis concurrency modes and device targeting.")
|
||||
parser.add_argument("--phone", default=os.environ.get("ARTEMIS_PHONE_SERIAL"), help="Target physical phone serial number")
|
||||
parser.add_argument("--emulator", default=os.environ.get("ARTEMIS_EMULATOR_SERIAL"), help="Target Android emulator serial number")
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
# Auto-detect connected devices if not explicitly provided
|
||||
real_dev = next((d for d in devices if not d.is_emulator and d.state == "device"), None)
|
||||
emu_dev = next((d for d in devices if d.is_emulator and d.state == "device"), None)
|
||||
|
||||
real_serial = args.phone or (real_dev.serial if real_dev else "63191FDKX00062")
|
||||
emu_serial = args.emulator or (emu_dev.serial if emu_dev else "emulator-5554")
|
||||
|
||||
# Verify both devices are available
|
||||
serials = [d.serial for d in devices]
|
||||
if real_serial not in serials or emu_serial not in serials:
|
||||
print(f"Error: Both physical device '{real_serial}' and emulator '{emu_serial}' must be connected for full experiment.")
|
||||
print(f"Currently online devices: {serials}")
|
||||
return
|
||||
|
||||
# Run experiments in sequence
|
||||
await run_experiment_1_single_device_relay(real_serial)
|
||||
await run_experiment_2_multi_device_parallel(real_serial, emu_serial)
|
||||
await run_experiment_3_global_concurrency_serialization(real_serial, emu_serial)
|
||||
await run_experiment_4_parameters_and_utilities(real_serial, emu_serial)
|
||||
|
||||
print_header("🎉 All experiments completed successfully! All concurrency and device targeting verified!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -55,3 +55,44 @@ async def test_list_sessions_uses_batched_history_metadata(monkeypatch):
|
||||
repo.get_video_recording_for_session.assert_not_called()
|
||||
repo.get_llm_traces_for_profile.assert_not_called()
|
||||
repo.get_agent_trace_names.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_extracts_device_serial(monkeypatch):
|
||||
repo = MagicMock()
|
||||
repo.get_all_sessions.return_value = [
|
||||
{
|
||||
"session_id": "session-dev-1",
|
||||
"status": "completed",
|
||||
"start_time": 1.0,
|
||||
"device_info": '{"device_id": "63191FDKX00062", "profile": "flash"}',
|
||||
},
|
||||
{
|
||||
"session_id": "session-dev-2",
|
||||
"status": "completed",
|
||||
"start_time": 2.0,
|
||||
"device_info": None,
|
||||
},
|
||||
]
|
||||
repo.get_video_recordings_map.return_value = {}
|
||||
repo.get_latest_video_recordings_map.return_value = {
|
||||
"session-dev-2": {"status": "ready", "device_id": "emulator-5554"},
|
||||
}
|
||||
repo.get_llm_traces_for_profiles_map.return_value = {}
|
||||
repo.get_agent_trace_names_map.return_value = {}
|
||||
|
||||
monkeypatch.setattr(sessions_router, "session_repo", repo, raising=False)
|
||||
monkeypatch.setattr(
|
||||
sessions_router.media_service, "build_video_index", MagicMock(return_value={})
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sessions_router.media_service, "resolve_video_url", MagicMock(return_value=None)
|
||||
)
|
||||
|
||||
result = await sessions_router.list_sessions()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["device_serial"] == "63191FDKX00062"
|
||||
assert result[0]["device_id"] == "63191FDKX00062"
|
||||
assert result[1]["device_serial"] == "emulator-5554"
|
||||
assert result[1]["device_id"] == "emulator-5554"
|
||||
@@ -180,8 +180,8 @@ async def test_queue_worker_execution_lifecycle():
|
||||
assert res["enqueued_count"] == 2
|
||||
|
||||
# Give event loop time for queue_worker to execute both tasks
|
||||
for _ in range(30):
|
||||
if len(executed_goals) == 2:
|
||||
for _ in range(40):
|
||||
if len(executed_goals) == 2 and len(state.queue_items) == 0:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
@@ -234,6 +234,7 @@ async def test_queue_worker_cmd_construction():
|
||||
cmd = executed_cmds[0]
|
||||
assert "--enable-outputter" in cmd
|
||||
assert "true" not in cmd
|
||||
assert "--session-id" in cmd
|
||||
assert "--output-description" in cmd
|
||||
assert "Final summary" in cmd
|
||||
assert "--locked-app" in cmd
|
||||
@@ -265,14 +266,16 @@ async def test_queue_worker_cmd_construction():
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_worker_output_preserves_split_utf8(capsys):
|
||||
stream = asyncio.StreamReader()
|
||||
encoded = "worker 输出正常\n".encode()
|
||||
stream.feed_data(encoded[:9])
|
||||
stream.feed_data(encoded[9:])
|
||||
# "worker output: \U0001f600\n" contains a 4-byte UTF-8 emoji (b'\xf0\x9f\x98\x80') starting at byte 15.
|
||||
# Splitting at index 17 cuts across the multi-byte sequence to verify partial UTF-8 buffering.
|
||||
encoded = "worker output: \U0001f600\n".encode("utf-8")
|
||||
stream.feed_data(encoded[:17])
|
||||
stream.feed_data(encoded[17:])
|
||||
stream.feed_eof()
|
||||
|
||||
await TaskQueueService._forward_worker_output(stream)
|
||||
|
||||
assert capsys.readouterr().out == "worker 输出正常\n"
|
||||
assert capsys.readouterr().out == "worker output: \U0001f600\n"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="Windows console isolation only")
|
||||
@@ -539,7 +542,7 @@ async def test_immediate_cancel_ignores_stale_ipc_and_runs_next_task():
|
||||
await task_queue_service.enqueue_tasks(["Task 2"])
|
||||
|
||||
for _ in range(40):
|
||||
if "Task 2" in executed_goals:
|
||||
if "Task 2" in executed_goals and len(state.queue_items) == 0:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"""Unit tests for Artemis MCP Tools."""
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
@@ -49,6 +51,7 @@ def test_tool_signatures():
|
||||
assert "locked_app_package" in sig_run.parameters
|
||||
assert "app_path" in sig_run.parameters
|
||||
assert "expected_output_desc" in sig_run.parameters
|
||||
assert "device_serial" in sig_run.parameters
|
||||
|
||||
# mobile_manage_task signature check
|
||||
sig_manage = inspect.signature(mobile_manage_task)
|
||||
@@ -59,6 +62,7 @@ def test_tool_signatures():
|
||||
# mobile_get_device_state signature check
|
||||
sig_device = inspect.signature(mobile_get_device_state)
|
||||
assert "view_type" in sig_device.parameters
|
||||
assert "device_serial" in sig_device.parameters
|
||||
|
||||
# mobile_inspect_trace signature check
|
||||
sig_inspect = inspect.signature(mobile_inspect_trace)
|
||||
@@ -97,10 +101,54 @@ def test_mobile_run_task_reserves_and_passes_global_queue_ticket(temp_trace_env)
|
||||
ingress="mcp",
|
||||
)
|
||||
assert result["trace_id"]
|
||||
assert result["device_serial"] == "auto-select"
|
||||
assert popen.call_args.kwargs["env"]["ARTEMIS_DEVICE_QUEUE_TICKET"] == "queue-ticket-1"
|
||||
assert popen.call_args.kwargs["env"]["ARTEMIS_TASK_INGRESS"] == "mcp"
|
||||
status = trace_store.read_status(result["trace_id"])
|
||||
assert status["queue_ticket"] == "queue-ticket-1"
|
||||
assert status["device_serial"] is None
|
||||
|
||||
|
||||
def test_mobile_run_task_with_device_serial(temp_trace_env):
|
||||
process = MagicMock(pid=54321)
|
||||
with (
|
||||
patch(
|
||||
"mcp_server.tools.task_runner.DeviceExecutionLock.reserve",
|
||||
return_value="queue-ticket-dev",
|
||||
) as reserve,
|
||||
patch("mcp_server.tools.task_runner.DeviceExecutionLock.transfer_reservation") as transfer,
|
||||
patch("mcp_server.tools.task_runner.subprocess.Popen", return_value=process) as popen,
|
||||
):
|
||||
result = mobile_run_task(
|
||||
task_desc="Open Settings on target phone",
|
||||
conversation_id="conv-2",
|
||||
model="Flash",
|
||||
device_serial="pixel-11-pro-001",
|
||||
)
|
||||
|
||||
reserve.assert_called_once_with(
|
||||
description="MCP task: Open Settings on target phone",
|
||||
session_id=result["trace_id"],
|
||||
ingress="mcp",
|
||||
device_id="pixel-11-pro-001",
|
||||
)
|
||||
transfer.assert_called_once_with(
|
||||
"queue-ticket-dev",
|
||||
54321,
|
||||
description="MCP task: Open Settings on target phone",
|
||||
session_id=result["trace_id"],
|
||||
ingress="mcp",
|
||||
device_id="pixel-11-pro-001",
|
||||
)
|
||||
assert result["device_serial"] == "pixel-11-pro-001"
|
||||
cmd = popen.call_args.args[0]
|
||||
assert "--device-serial" in cmd
|
||||
assert cmd[cmd.index("--device-serial") + 1] == "pixel-11-pro-001"
|
||||
assert popen.call_args.kwargs["env"]["ADB_DEVICE_SERIAL"] == "pixel-11-pro-001"
|
||||
assert popen.call_args.kwargs["env"]["ARTEMIS_DEVICE_ID"] == "pixel-11-pro-001"
|
||||
|
||||
status = trace_store.read_status(result["trace_id"])
|
||||
assert status["device_serial"] == "pixel-11-pro-001"
|
||||
|
||||
|
||||
def test_mobile_manage_task_unknown_trace(temp_trace_env):
|
||||
@@ -111,13 +159,14 @@ def test_mobile_manage_task_unknown_trace(temp_trace_env):
|
||||
|
||||
def test_mobile_manage_task_status_and_stop(temp_trace_env):
|
||||
trace_id = str(uuid.uuid4())
|
||||
trace_store.init_trace(trace_id, "Test task", "Flash", "conv-1")
|
||||
trace_store.init_trace(trace_id, "Test task", "Flash", "conv-1", device_serial="device-xyz")
|
||||
|
||||
# Status check
|
||||
status_res = mobile_manage_task(action="status", trace_id=trace_id)
|
||||
assert status_res["trace_id"] == trace_id
|
||||
assert status_res["status"] == "running"
|
||||
assert status_res["model"] == "Flash"
|
||||
assert status_res["device_serial"] == "device-xyz"
|
||||
|
||||
# Stop without PID
|
||||
stop_res = mobile_manage_task(action="stop", trace_id=trace_id)
|
||||
@@ -125,6 +174,31 @@ def test_mobile_manage_task_status_and_stop(temp_trace_env):
|
||||
assert "PID" in stop_res["message"] or "Cannot stop" in stop_res["message"]
|
||||
|
||||
|
||||
def test_mobile_manage_task_status_backfills_device_serial_from_db(temp_trace_env):
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
|
||||
trace_id = str(uuid.uuid4())
|
||||
trace_store.init_trace(trace_id, "Test task without serial", "Flash", "conv-1")
|
||||
|
||||
# Create dummy data_engine.db with session record
|
||||
db_path = os.path.join(temp_trace_env, "data_engine.db")
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute(
|
||||
"CREATE TABLE sessions (session_id TEXT PRIMARY KEY, device_info TEXT, pid INTEGER, start_time REAL)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions VALUES (?, ?, ?, ?)",
|
||||
(trace_id, json.dumps({"device_id": "auto-chosen-serial-777"}), 12345, 1000.0),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
status_res = mobile_manage_task(action="status", trace_id=trace_id)
|
||||
assert status_res["device_serial"] == "auto-chosen-serial-777"
|
||||
|
||||
|
||||
def test_mobile_manage_task_inject_instruction(temp_trace_env):
|
||||
trace_id = str(uuid.uuid4())
|
||||
trace_store.init_trace(trace_id, "Test task", "Pro", "conv-1")
|
||||
@@ -181,6 +255,63 @@ async def test_mobile_inspect_trace_invalid_action():
|
||||
assert "not supported" in res["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mobile_get_device_state_passes_device_serial():
|
||||
"""Verify that device_serial is forwarded to _get_controller."""
|
||||
controller = MagicMock()
|
||||
controller.ctx.device.device_width = 1080
|
||||
controller.ctx.device.device_height = 2400
|
||||
controller.get_screen_data = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
base64="ZHVtbXk=",
|
||||
elements=[],
|
||||
width=1080,
|
||||
height=2400,
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("mcp_server.tools.device_state._get_controller", return_value=controller) as get_ctrl_mock,
|
||||
patch("mcp_server.tools.device_state.is_ocr_configured", return_value=False),
|
||||
):
|
||||
await mobile_get_device_state(view_type="hierarchy", device_serial="device-serial-abc")
|
||||
|
||||
get_ctrl_mock.assert_called_once_with(device_serial="device-serial-abc")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mobile_inspect_trace_includes_device_serial(temp_trace_env):
|
||||
import sqlite3
|
||||
|
||||
trace_id = str(uuid.uuid4())
|
||||
trace_store.init_trace(trace_id, "Inspect test", "Flash", "conv-1", device_serial="pixel-target-999")
|
||||
|
||||
# Create dummy database and table
|
||||
db_path = os.path.join(temp_trace_env, "data_engine.db")
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("CREATE TABLE sessions (session_id TEXT PRIMARY KEY, start_time REAL, device_info TEXT)")
|
||||
conn.execute("CREATE TABLE steps (step_id TEXT PRIMARY KEY, session_id TEXT, step_number INTEGER, timestamp REAL, pre_image_name TEXT, post_image_name TEXT, summary TEXT, action_taken TEXT, operator_raw_thinking TEXT, last_execution_result TEXT, extra_metadata TEXT)")
|
||||
conn.execute("CREATE TABLE traces (trace_id TEXT PRIMARY KEY, step_id TEXT, session_id TEXT, type TEXT, name TEXT, status TEXT, timestamp REAL, duration REAL, payload TEXT)")
|
||||
conn.execute("INSERT INTO sessions VALUES (?, ?, ?)", (trace_id, 1000.0, None))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
res_summary = await mobile_inspect_trace(action="view_summary", trace_id=trace_id)
|
||||
assert "**Device Serial:** `pixel-target-999`" in res_summary
|
||||
|
||||
|
||||
def test_trace_store_device_serial(temp_trace_env):
|
||||
trace_id = "test-store-serial"
|
||||
data = trace_store.init_trace(trace_id, "desc", "Flash", "conv", device_serial="init-serial")
|
||||
assert data["device_serial"] == "init-serial"
|
||||
|
||||
data_updated = trace_store.update_trace_device_serial(trace_id, "updated-serial")
|
||||
assert data_updated["device_serial"] == "updated-serial"
|
||||
|
||||
read_back = trace_store.read_status(trace_id)
|
||||
assert read_back["device_serial"] == "updated-serial"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_server_auto_registers_tools():
|
||||
"""Verify that importing mcp_server.base auto-registers all mobile tools without manual tools import."""
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# 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.
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from artemis.runtime.daemon_client import (
|
||||
ensure_daemon_running,
|
||||
is_daemon_running,
|
||||
is_standalone_forced,
|
||||
spawn_daemon,
|
||||
submit_task_to_daemon,
|
||||
)
|
||||
|
||||
|
||||
def test_is_standalone_forced(monkeypatch):
|
||||
monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False)
|
||||
assert is_standalone_forced() is False
|
||||
|
||||
monkeypatch.setenv("ARTEMIS_STANDALONE", "1")
|
||||
assert is_standalone_forced() is True
|
||||
|
||||
monkeypatch.setenv("ARTEMIS_STANDALONE", "true")
|
||||
assert is_standalone_forced() is True
|
||||
|
||||
monkeypatch.setenv("ARTEMIS_STANDALONE", "0")
|
||||
assert is_standalone_forced() is False
|
||||
|
||||
|
||||
def test_is_daemon_running_success():
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||
assert is_daemon_running() is True
|
||||
|
||||
|
||||
def test_is_daemon_running_failure():
|
||||
with patch("urllib.request.urlopen", side_effect=OSError("Connection refused")):
|
||||
assert is_daemon_running() is False
|
||||
|
||||
|
||||
def test_spawn_daemon():
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.pid = 54321
|
||||
with patch("subprocess.Popen", return_value=mock_proc) as mock_popen:
|
||||
proc = spawn_daemon(host="127.0.0.1", port=8000)
|
||||
assert proc is mock_proc
|
||||
assert mock_popen.called
|
||||
|
||||
|
||||
def test_ensure_daemon_running_when_already_active():
|
||||
with patch("artemis.runtime.daemon_client.is_daemon_running", return_value=True):
|
||||
ok, base_url = ensure_daemon_running(host="127.0.0.1", port=8000)
|
||||
assert ok is True
|
||||
assert base_url == "http://127.0.0.1:8000"
|
||||
|
||||
|
||||
def test_ensure_daemon_running_standalone_override(monkeypatch):
|
||||
monkeypatch.setenv("ARTEMIS_STANDALONE", "1")
|
||||
ok, base_url = ensure_daemon_running()
|
||||
assert ok is False
|
||||
assert base_url is None
|
||||
|
||||
|
||||
def test_ensure_daemon_running_auto_spawns():
|
||||
with (
|
||||
patch("artemis.runtime.daemon_client.is_daemon_running", side_effect=[False, True]),
|
||||
patch("artemis.runtime.daemon_client.spawn_daemon") as mock_spawn,
|
||||
):
|
||||
ok, base_url = ensure_daemon_running(timeout=1.0, wait_ready=True)
|
||||
assert ok is True
|
||||
assert base_url == "http://127.0.0.1:8000"
|
||||
mock_spawn.assert_called_once()
|
||||
|
||||
|
||||
def test_submit_task_to_daemon():
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.read.return_value = b'{"status": "queued", "tasks": [{"session_id": "test-sess"}]}'
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||
res = submit_task_to_daemon(
|
||||
"test goal",
|
||||
profile="flash",
|
||||
device_serial="pixel-8",
|
||||
)
|
||||
assert res is not None
|
||||
assert res["status"] == "queued"
|
||||
assert res["tasks"][0]["session_id"] == "test-sess"
|
||||
@@ -227,3 +227,97 @@ def test_active_owner_is_discoverable_and_can_be_annotated(monkeypatch):
|
||||
assert annotated.ingress == "cli"
|
||||
finally:
|
||||
owner_lock.release()
|
||||
|
||||
|
||||
def test_multi_device_locks_can_run_concurrently():
|
||||
lock_a = DeviceExecutionLock("emulator-5554", "task on device A")
|
||||
lock_b = DeviceExecutionLock("pixel-9-test", "task on device B")
|
||||
|
||||
lock_a.acquire()
|
||||
try:
|
||||
# Device B should be acquired without blocking since it's a different device
|
||||
lock_b.acquire(blocking=False)
|
||||
try:
|
||||
active_owners = DeviceExecutionLock.get_active_owners()
|
||||
assert "emulator-5554" in active_owners
|
||||
assert "pixel-9-test" in active_owners
|
||||
|
||||
# A second task on device A should be blocked
|
||||
lock_a_second = DeviceExecutionLock("emulator-5554", "second task on device A")
|
||||
with pytest.raises(DeviceBusyError, match="task on device A"):
|
||||
lock_a_second.acquire(blocking=False)
|
||||
finally:
|
||||
lock_b.release()
|
||||
finally:
|
||||
lock_a.release()
|
||||
|
||||
|
||||
def test_global_concurrency_serial_mode_blocks_other_devices(monkeypatch):
|
||||
monkeypatch.setenv("ARTEMIS_MAX_CONCURRENT_TASKS", "1")
|
||||
lock_a = DeviceExecutionLock("device_alpha", "task alpha")
|
||||
lock_b = DeviceExecutionLock("device_beta", "task beta")
|
||||
|
||||
lock_a.acquire()
|
||||
try:
|
||||
with pytest.raises(DeviceBusyError, match="concurrency limit"):
|
||||
lock_b.acquire(blocking=False)
|
||||
finally:
|
||||
lock_a.release()
|
||||
|
||||
# Once lock A is released, lock B can acquire
|
||||
lock_b.acquire(blocking=False)
|
||||
lock_b.release()
|
||||
|
||||
|
||||
def test_owner_is_alive_guards_against_recycled_pid():
|
||||
from unittest.mock import MagicMock, patch
|
||||
from artemis.runtime.device_lock import DeviceLockOwner
|
||||
|
||||
owner = DeviceLockOwner(
|
||||
pid=99999,
|
||||
process_created_at=0.0,
|
||||
token="token-1",
|
||||
device_id="dev-1",
|
||||
description="test",
|
||||
acquired_at="now",
|
||||
)
|
||||
|
||||
# 1. PID is 0 or negative -> immediately dead
|
||||
invalid_owner = DeviceLockOwner(
|
||||
pid=0,
|
||||
process_created_at=0.0,
|
||||
token="token-1",
|
||||
device_id="dev-1",
|
||||
description="test",
|
||||
acquired_at="now",
|
||||
)
|
||||
assert DeviceExecutionLock._owner_is_alive(invalid_owner) is False
|
||||
|
||||
# 2. Recycled PID belongs to non-Artemis system process -> treated as dead
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.is_running.return_value = True
|
||||
mock_proc.name.return_value = "systemd"
|
||||
mock_proc.cmdline.return_value = ["/sbin/init"]
|
||||
|
||||
with patch("psutil.Process", return_value=mock_proc):
|
||||
assert DeviceExecutionLock._owner_is_alive(owner) is False
|
||||
|
||||
# 3. PID belongs to a Python / Artemis process -> alive
|
||||
mock_proc.name.return_value = "python3"
|
||||
mock_proc.cmdline.return_value = ["python3", "-m", "artemis.main"]
|
||||
with patch("psutil.Process", return_value=mock_proc):
|
||||
assert DeviceExecutionLock._owner_is_alive(owner) is True
|
||||
|
||||
|
||||
def test_safe_unlink_and_replace_retry(tmp_path):
|
||||
f = tmp_path / "test_file.txt"
|
||||
f.write_text("hello")
|
||||
|
||||
assert DeviceExecutionLock._safe_unlink(f) is True
|
||||
assert not f.exists()
|
||||
|
||||
src = tmp_path / "src.txt"
|
||||
dst = tmp_path / "dst.txt"
|
||||
src.write_text("world")
|
||||
assert DeviceExecutionLock._safe_replace(src, dst) is True
|
||||
assert dst.read_text() == "world"
|
||||
@@ -0,0 +1,85 @@
|
||||
# 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 pytest
|
||||
|
||||
from artemis.runtime.device_lock import DeviceExecutionLock
|
||||
from artemis.runtime.device_pool import DevicePool, DeviceStatus
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_lock_directory(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"artemis.runtime.device_lock.get_temp_dir",
|
||||
lambda _name: tmp_path,
|
||||
)
|
||||
|
||||
|
||||
def test_device_pool_parse_lines():
|
||||
lines = [
|
||||
"List of devices attached",
|
||||
"emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a",
|
||||
"9A123XYZ device product:husky model:Pixel_8_Pro device:husky",
|
||||
"offline-dev offline",
|
||||
"",
|
||||
]
|
||||
parsed = DevicePool._parse_device_lines(lines)
|
||||
assert len(parsed) == 3
|
||||
assert parsed[0] == ("emulator-5554", "device", "sdk gphone64 arm64", "sdk_gphone64_arm64")
|
||||
assert parsed[1] == ("9A123XYZ", "device", "Pixel 8 Pro", "husky")
|
||||
assert parsed[2] == ("offline-dev", "offline", None, None)
|
||||
|
||||
|
||||
def test_device_pool_list_devices_and_busy_status(monkeypatch):
|
||||
pool = DevicePool()
|
||||
monkeypatch.setattr(
|
||||
pool,
|
||||
"_query_adb_devices_sync",
|
||||
lambda: [
|
||||
("emulator-5554", "device", "Emulator", "sdk"),
|
||||
("pixel-8-pro", "device", "Pixel 8 Pro", "husky"),
|
||||
],
|
||||
)
|
||||
|
||||
# Initially neither is busy
|
||||
devs = pool.list_devices()
|
||||
assert len(devs) == 2
|
||||
assert not devs[0].is_busy
|
||||
assert not devs[1].is_busy
|
||||
|
||||
# Acquire lock for emulator-5554
|
||||
lock = DeviceExecutionLock("emulator-5554", "test automation")
|
||||
lock.acquire()
|
||||
try:
|
||||
devs = pool.list_devices()
|
||||
emu = next(d for d in devs if d.serial == "emulator-5554")
|
||||
pixel = next(d for d in devs if d.serial == "pixel-8-pro")
|
||||
|
||||
assert emu.is_busy is True
|
||||
assert emu.active_task_desc == "test automation"
|
||||
assert pixel.is_busy is False
|
||||
|
||||
# Test idle device filtering
|
||||
idle = pool.get_idle_devices()
|
||||
assert len(idle) == 1
|
||||
assert idle[0].serial == "pixel-8-pro"
|
||||
|
||||
# Test select_device picks the idle device automatically
|
||||
selected = pool.select_device()
|
||||
assert selected == "pixel-8-pro"
|
||||
|
||||
# Test explicit selection returns requested device
|
||||
assert pool.select_device("emulator-5554") == "emulator-5554"
|
||||
finally:
|
||||
lock.release()
|
||||
@@ -0,0 +1,201 @@
|
||||
# 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.
|
||||
|
||||
"""Unit tests for Artemis Server Lifecycle Management and API Endpoints."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from apps.admin_console.server import app
|
||||
from artemis.runtime.server_lifecycle import (
|
||||
clear_server_info,
|
||||
find_server_pids,
|
||||
get_server_status,
|
||||
is_port_in_use,
|
||||
read_server_info,
|
||||
stop_server,
|
||||
write_server_info,
|
||||
)
|
||||
|
||||
|
||||
def test_write_read_clear_server_info(tmp_path, monkeypatch):
|
||||
"""Verify write, read, and clear round-trip for server metadata file."""
|
||||
fake_info_file = tmp_path / "test_server.json"
|
||||
monkeypatch.setattr(
|
||||
"artemis.runtime.server_lifecycle.get_server_info_file", lambda: fake_info_file
|
||||
)
|
||||
|
||||
# Initial read should be None
|
||||
assert read_server_info() is None
|
||||
|
||||
# Write info
|
||||
written = write_server_info(port=8888, host="127.0.0.1", pid=99999)
|
||||
assert written == fake_info_file
|
||||
assert fake_info_file.exists()
|
||||
|
||||
# Read back
|
||||
info = read_server_info()
|
||||
assert info is not None
|
||||
assert info["pid"] == 99999
|
||||
assert info["port"] == 8888
|
||||
assert info["host"] == "127.0.0.1"
|
||||
assert "started_at" in info
|
||||
|
||||
# Clear info
|
||||
clear_server_info()
|
||||
assert not fake_info_file.exists()
|
||||
assert read_server_info() is None
|
||||
|
||||
|
||||
def test_find_server_pids_from_metadata(tmp_path, monkeypatch):
|
||||
"""Verify find_server_pids discovers PID saved in metadata file."""
|
||||
fake_info_file = tmp_path / "test_server.json"
|
||||
fake_info_file.write_text(
|
||||
json.dumps({"pid": os.getpid(), "port": 8000, "started_at": 100.0}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"artemis.runtime.server_lifecycle.get_server_info_file", lambda: fake_info_file
|
||||
)
|
||||
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port, **k: True)
|
||||
|
||||
pids = find_server_pids(8000)
|
||||
assert os.getpid() in pids
|
||||
|
||||
|
||||
def test_get_server_status_offline(monkeypatch):
|
||||
"""Verify get_server_status returns correct structure when server is offline."""
|
||||
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port, **k: False)
|
||||
monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: [])
|
||||
monkeypatch.setattr("artemis.runtime.server_lifecycle.read_server_info", lambda: None)
|
||||
|
||||
status = get_server_status(8000)
|
||||
assert status["running"] is False
|
||||
assert status["port"] == 8000
|
||||
assert status["pids"] == []
|
||||
assert status["url"] is None
|
||||
|
||||
|
||||
def test_get_server_status_online(monkeypatch):
|
||||
"""Verify get_server_status returns correct structure when server is online."""
|
||||
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port, **k: True)
|
||||
monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: [12345])
|
||||
monkeypatch.setattr(
|
||||
"artemis.runtime.server_lifecycle.read_server_info",
|
||||
lambda: {"pid": 12345, "port": 8000, "started_at": 1000.0},
|
||||
)
|
||||
|
||||
status = get_server_status(8000)
|
||||
assert status["running"] is True
|
||||
assert status["port"] == 8000
|
||||
assert status["pids"] == [12345]
|
||||
assert status["active_pid"] == 12345
|
||||
assert status["url"] == "http://localhost:8000"
|
||||
assert status["admin_url"] == "http://localhost:8000/admin"
|
||||
assert status["uptime_seconds"] is not None
|
||||
|
||||
|
||||
def test_stop_server_when_not_running(monkeypatch):
|
||||
"""Verify stop_server returns early when no server is running."""
|
||||
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port, **k: False)
|
||||
monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: [])
|
||||
|
||||
success, msg, stopped = stop_server(8000)
|
||||
assert success is True
|
||||
assert stopped == []
|
||||
assert "No active server" in msg
|
||||
|
||||
|
||||
def test_stop_server_terminates_tree(monkeypatch):
|
||||
"""Verify stop_server calls ProcessSupervisor.terminate_tree and returns stopped pids."""
|
||||
terminated_pids = []
|
||||
|
||||
monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: [12345])
|
||||
port_in_use = [True, False]
|
||||
|
||||
def mock_in_use(port, **kwargs):
|
||||
if port_in_use:
|
||||
return port_in_use.pop(0)
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", mock_in_use)
|
||||
monkeypatch.setattr(
|
||||
"artemis.runtime.supervisor.ProcessSupervisor.terminate_tree",
|
||||
lambda pid, timeout_seconds=4.0: terminated_pids.append(pid),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"artemis.runtime.device_lock.DeviceExecutionLock.cleanup_stale_locks",
|
||||
lambda: 1,
|
||||
)
|
||||
|
||||
success, msg, stopped = stop_server(8000)
|
||||
assert success is True
|
||||
assert stopped == [12345]
|
||||
assert 12345 in terminated_pids
|
||||
assert "stopped" in msg.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_server_status():
|
||||
"""Verify GET /api/system/server-status returns server runtime metadata."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
response = await client.get("/api/system/server-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "port" in data
|
||||
assert "running" in data
|
||||
assert "current_pid" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_restart_endpoint():
|
||||
"""Verify POST /api/system/restart accepts request and schedules restart."""
|
||||
with patch("threading.Thread") as mock_thread:
|
||||
mock_instance = MagicMock()
|
||||
mock_thread.return_value = mock_instance
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
response = await client.post("/api/system/restart")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "restarting"
|
||||
assert "reconnection" in data["message"].lower() or "restarting" in data["message"].lower()
|
||||
mock_thread.assert_called_once()
|
||||
mock_instance.start.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_shutdown_endpoint():
|
||||
"""Verify POST /api/system/shutdown accepts request and schedules shutdown."""
|
||||
with patch("threading.Thread") as mock_thread:
|
||||
mock_instance = MagicMock()
|
||||
mock_thread.return_value = mock_instance
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
response = await client.post("/api/system/shutdown")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "shutting_down"
|
||||
mock_thread.assert_called_once()
|
||||
mock_instance.start.assert_called_once()
|
||||
@@ -113,3 +113,34 @@ async def test_locked_work_profile_does_not_block_unlocked_device_owner():
|
||||
)
|
||||
|
||||
await agent._ensure_device_unlocked()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_inherits_session_id_from_env_and_propagates_to_tracing(monkeypatch):
|
||||
import uuid
|
||||
|
||||
canonical_sid = "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"
|
||||
monkeypatch.setenv("ARTEMIS_SESSION_ID", canonical_sid)
|
||||
|
||||
agent = Agent()
|
||||
assert agent._session_id == canonical_sid
|
||||
|
||||
task = MagicMock()
|
||||
task.id = "fallback-id"
|
||||
task.get_name.return_value = "task-name"
|
||||
task.request.goal = "test goal"
|
||||
task.request.profile = "flash"
|
||||
task.request.task_name = "task-name"
|
||||
task.request.trace_path = "traces"
|
||||
|
||||
context = MagicMock()
|
||||
context.device = None
|
||||
with patch("artemis.sdk.agent.DataEngine") as mock_data_engine_cls:
|
||||
mock_data_engine = MagicMock()
|
||||
mock_data_engine_cls.return_value = mock_data_engine
|
||||
|
||||
agent._prepare_tracing(task=task, context=context)
|
||||
|
||||
mock_data_engine.start_session.assert_called_once()
|
||||
call_kwargs = mock_data_engine.start_session.call_args.kwargs
|
||||
assert call_kwargs["session_id"] == uuid.UUID(canonical_sid)
|
||||
+106
-3
@@ -198,9 +198,8 @@ def test_cli_mcp_install_all(tmp_path, monkeypatch):
|
||||
claude_data = json.loads((tmp_path / ".claude.json").read_text())
|
||||
assert claude_data["mcpServers"]["artemis"]["type"] == "stdio"
|
||||
|
||||
vscode_data = json.loads(
|
||||
(tmp_path / "AppData" / "Roaming" / "Code" / "User" / "mcp.json").read_text()
|
||||
)
|
||||
from artemis.interfaces.cli.commands.mcp import _get_vscode_user_dir
|
||||
vscode_data = json.loads((_get_vscode_user_dir() / "mcp.json").read_text())
|
||||
assert vscode_data["servers"]["artemis"]["type"] == "stdio"
|
||||
assert "mcpServers" not in vscode_data
|
||||
|
||||
@@ -333,3 +332,107 @@ def test_cli_mcp_install_openclaw_migrates_legacy_plugin_shape(tmp_path, monkeyp
|
||||
assert data["mcp"]["servers"]["artemis"]["command"]
|
||||
|
||||
|
||||
def test_cli_restart_help():
|
||||
"""Verify 'artemis restart --help' displays lifecycle options."""
|
||||
result = runner.invoke(app, ["restart", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "--port" in result.output
|
||||
assert "--host" in result.output
|
||||
assert "--force" in result.output
|
||||
assert "--daemon" in result.output
|
||||
assert "--open" in result.output
|
||||
|
||||
|
||||
def test_cli_stop_help():
|
||||
"""Verify 'artemis stop --help' displays stop options."""
|
||||
result = runner.invoke(app, ["stop", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "--port" in result.output
|
||||
assert "--force" in result.output
|
||||
|
||||
|
||||
def test_cli_status_help():
|
||||
"""Verify 'artemis status --help' displays status options."""
|
||||
result = runner.invoke(app, ["status", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "--port" in result.output
|
||||
|
||||
|
||||
def test_cli_status_offline(monkeypatch):
|
||||
"""Verify 'artemis status' reports offline when port is unused."""
|
||||
from artemis.runtime import server_lifecycle
|
||||
|
||||
monkeypatch.setattr(server_lifecycle, "is_port_in_use", lambda port, **kwargs: False)
|
||||
monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [])
|
||||
monkeypatch.setattr(server_lifecycle, "read_server_info", lambda: None)
|
||||
|
||||
result = runner.invoke(app, ["status", "--port", "59998"])
|
||||
assert result.exit_code == 0
|
||||
assert "OFFLINE" in result.output or "STOPPED" in result.output
|
||||
|
||||
|
||||
def test_cli_status_online(monkeypatch):
|
||||
"""Verify 'artemis status' reports online details when server is active."""
|
||||
from artemis.runtime import server_lifecycle
|
||||
|
||||
monkeypatch.setattr(server_lifecycle, "is_port_in_use", lambda port, **kwargs: True)
|
||||
monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [12345])
|
||||
monkeypatch.setattr(
|
||||
server_lifecycle,
|
||||
"read_server_info",
|
||||
lambda: {"pid": 12345, "port": 8000, "started_at": 1000.0, "cwd": "/tmp"},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["status", "--port", "8000"])
|
||||
assert result.exit_code == 0
|
||||
assert "ONLINE" in result.output or "RUNNING" in result.output
|
||||
assert "12345" in result.output
|
||||
|
||||
|
||||
def test_cli_stop_command(monkeypatch):
|
||||
"""Verify 'artemis stop' invokes stop_server with given parameters."""
|
||||
from artemis.interfaces.cli.commands import server_lifecycle as sl_cmd
|
||||
|
||||
mock_called = {}
|
||||
|
||||
def mock_stop(port, timeout=4.0, force=False):
|
||||
mock_called["port"] = port
|
||||
mock_called["force"] = force
|
||||
return True, "Artemis server stopped (PID: 12345).", [12345]
|
||||
|
||||
monkeypatch.setattr(sl_cmd, "find_server_pids", lambda port: [12345])
|
||||
monkeypatch.setattr(sl_cmd, "stop_server", mock_stop)
|
||||
|
||||
result = runner.invoke(app, ["stop", "--port", "8000", "--force"])
|
||||
assert result.exit_code == 0
|
||||
assert mock_called["port"] == 8000
|
||||
assert mock_called["force"] is True
|
||||
assert "stopped successfully" in result.output.lower() or "PID: 12345" in result.output
|
||||
|
||||
|
||||
def test_cli_restart_command(monkeypatch):
|
||||
"""Verify 'artemis restart' stops previous server and invokes ui_command."""
|
||||
from artemis.interfaces.cli.commands import server_lifecycle as sl_cmd
|
||||
from artemis.runtime import server_lifecycle
|
||||
|
||||
stopped = {}
|
||||
ui_called = {}
|
||||
|
||||
def mock_stop(port, timeout=4.0, force=False):
|
||||
stopped["port"] = port
|
||||
return True, "Stopped server", [12345]
|
||||
|
||||
def mock_ui(host, port, open_browser, reload):
|
||||
ui_called["host"] = host
|
||||
ui_called["port"] = port
|
||||
ui_called["open_browser"] = open_browser
|
||||
|
||||
monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [12345])
|
||||
monkeypatch.setattr(sl_cmd, "stop_server", mock_stop)
|
||||
monkeypatch.setattr(sl_cmd, "ui_command", mock_ui)
|
||||
|
||||
result = runner.invoke(app, ["restart", "--port", "8888", "--no-open"])
|
||||
assert result.exit_code == 0
|
||||
assert stopped["port"] == 8888
|
||||
assert ui_called["port"] == 8888
|
||||
assert ui_called["open_browser"] is False
|
||||
@@ -21,7 +21,7 @@ from artemis.agents.operator.prompt_builder import OperatorPromptBuilder
|
||||
def test_operator_prompt_builder():
|
||||
"""Verify prompt rendering formats variables correctly."""
|
||||
system_msg = OperatorPromptBuilder.build_system_message()
|
||||
assert "ARTEMIS Operator" in system_msg
|
||||
assert "Android Device Operator" in system_msg
|
||||
assert "click" in system_msg
|
||||
|
||||
human_msg = OperatorPromptBuilder.build_human_message(
|
||||
|
||||
@@ -64,3 +64,131 @@ async def test_artemis_client_stream_run():
|
||||
assert events[0].event_type == StreamEventType.STATUS
|
||||
assert events[0].payload.get("status") == "starting"
|
||||
assert len(collected_events) == len(events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_device_specification():
|
||||
"""Verify target device can be set via init, setters, or per-task run parameters."""
|
||||
client = ArtemisClient(device_serial="emulator-5554", default_profile="flash")
|
||||
assert client.device_id == "emulator-5554"
|
||||
assert client.device_serial == "emulator-5554"
|
||||
|
||||
# Mutate via property setter
|
||||
client.device_serial = "pixel-9-pro"
|
||||
assert client.device_id == "pixel-9-pro"
|
||||
assert client.device_serial == "pixel-9-pro"
|
||||
|
||||
# Mutate via chaining method
|
||||
client.set_device("pixel-8")
|
||||
assert client.device_id == "pixel-8"
|
||||
|
||||
# Override device per run call using device_serial
|
||||
res1 = await client.run("Task on dev 1", device_serial="target-device-alpha")
|
||||
assert res1.device_id == "target-device-alpha"
|
||||
|
||||
# Override device per run call using device_id
|
||||
res2 = await client.run("Task on dev 2", device_id="target-device-beta")
|
||||
assert res2.device_id == "target-device-beta"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_stream_run_device_specification():
|
||||
"""Verify stream_run propagates custom target device."""
|
||||
client = ArtemisClient(device_id="default-stream-dev", default_profile="flash")
|
||||
events = []
|
||||
async for ev in client.stream_run("Test goal", device_serial="explicit-stream-dev"):
|
||||
events.append(ev)
|
||||
|
||||
assert len(events) >= 1
|
||||
assert events[0].payload.get("device_id") == "explicit-stream-dev"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_concurrency_mode_configuration():
|
||||
"""Verify concurrency_mode can be configured as 'global' or 'per_device'."""
|
||||
from artemis import ConcurrencyMode
|
||||
|
||||
client = ArtemisClient(concurrency_mode=ConcurrencyMode.GLOBAL)
|
||||
assert client.concurrency_mode == "global"
|
||||
|
||||
client.set_concurrency_mode(ConcurrencyMode.PER_DEVICE)
|
||||
assert client.concurrency_mode == "per_device"
|
||||
|
||||
client.concurrency_mode = "global"
|
||||
assert client.concurrency_mode == "global"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sdk_multi_device_parallel_execution():
|
||||
"""Verify per_device concurrency allows tasks on different devices to run concurrently."""
|
||||
import asyncio
|
||||
|
||||
client_a = ArtemisClient(device_id="mock-dev-parallel-1", concurrency_mode="per_device")
|
||||
client_b = ArtemisClient(device_id="mock-dev-parallel-2", concurrency_mode="per_device")
|
||||
|
||||
# Run tasks on two different devices concurrently
|
||||
task_a = asyncio.create_task(client_a.run("Task on device 1"))
|
||||
task_b = asyncio.create_task(client_b.run("Task on device 2"))
|
||||
|
||||
res_a, res_b = await asyncio.gather(task_a, task_b)
|
||||
assert res_a.status in ("completed", "running", "success")
|
||||
assert res_b.status in ("completed", "running", "success")
|
||||
assert res_a.device_id == "mock-dev-parallel-1"
|
||||
assert res_b.device_id == "mock-dev-parallel-2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sdk_global_concurrency_serializes_across_devices():
|
||||
"""Verify global concurrency restricts execution to 1 task across all devices."""
|
||||
import asyncio
|
||||
from artemis.runtime import DeviceBusyError, DeviceExecutionLock
|
||||
|
||||
# Pre-acquire a lock on device A with global concurrency mode
|
||||
lock_a = DeviceExecutionLock("mock-global-dev-1", "active global task", concurrency_mode="global")
|
||||
lock_a.acquire()
|
||||
|
||||
try:
|
||||
client_b = ArtemisClient(device_id="mock-global-dev-2", concurrency_mode="global")
|
||||
# Attempting non-blocking execution on device B should fail because global concurrency limit (1) is held by device A
|
||||
with pytest.raises(DeviceBusyError, match="Global task concurrency limit"):
|
||||
await client_b.run("Task on device 2", blocking=False)
|
||||
finally:
|
||||
lock_a.release()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sdk_run_task_with_device_and_concurrency():
|
||||
"""Verify Task object with device_serial and concurrency_mode executes via run_task."""
|
||||
client = ArtemisClient()
|
||||
task = Task(
|
||||
goal="Verify search functionality",
|
||||
profile="flash",
|
||||
device_serial="device-from-task-obj",
|
||||
concurrency_mode="per_device",
|
||||
)
|
||||
assert task.device_id == "device-from-task-obj"
|
||||
assert task.concurrency_mode == "per_device"
|
||||
|
||||
res = await client.run_task(task)
|
||||
assert res.device_id == "device-from-task-obj"
|
||||
assert res.status in ("completed", "running", "success")
|
||||
|
||||
|
||||
def test_builder_and_agent_concurrency_and_device_targeting():
|
||||
"""Verify AgentConfigBuilder and Agent support device targeting and concurrency configuration."""
|
||||
from artemis import Agent, Builders
|
||||
|
||||
# 1. Builder supports string for_device directly
|
||||
cfg1 = Builders.AgentConfig.for_device("emulator-9999").with_concurrency_mode("global").build()
|
||||
assert cfg1.device_id == "emulator-9999"
|
||||
assert cfg1.concurrency_mode == "global"
|
||||
|
||||
# 2. Builder supports for_device_serial
|
||||
cfg2 = Builders.AgentConfig.for_device_serial("pixel-test-serial").with_concurrency_mode("per_device").build()
|
||||
assert cfg2.device_id == "pixel-test-serial"
|
||||
assert cfg2.concurrency_mode == "per_device"
|
||||
|
||||
# 3. Agent supports device_serial and concurrency_mode directly in constructor
|
||||
agent = Agent(device_serial="pixel-constructor-dev", concurrency_mode="global")
|
||||
assert agent._config.device_id == "pixel-constructor-dev"
|
||||
assert agent._config.concurrency_mode == "global"
|
||||
Reference in new issue
Block a user