Compare commits

..
2 Commits
44 changed files with 1101 additions and 265 deletions

No files matched your search

+5 -4
View File
@@ -29,9 +29,10 @@ cd Artemis-Eapil
常用选项: 常用选项:
```powershell ```powershell
.\start-cn.bat -SetupOnly # 仅准备 Python 环境并检查已有 MCP 配置 .\start-cn.bat -SetupOnly # 准备环境并安装/更新已检测客户端的 MCP
.\start.bat -Port 8001 # 指定端口 .\start.bat -Port 8001 # 指定端口
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查 .\start.bat -SkipUpdate # 跳过 Git 源码更新检查
.\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页
``` ```
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。 镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。
@@ -55,13 +56,13 @@ cd Artemis-Eapil
## 让 AI 使用 ## 让 AI 使用
环境与模型配置完成后,例如为 Codex 接入 MCP: Windows 启动脚本会为已检测到的 AI 客户端安装或更新 MCP。未被检测到的客户端,可手动安装,例如 Codex:
```sh ```sh
uv run artemis mcp --install codex uv run artemis mcp --install codex
``` ```
在客户端重新加载 MCP,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。其他客户端可通过 `uv run artemis mcp --help` 查看接入方式。 安装或更新后,在客户端重新加载 MCP 或重启客户端,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。其他客户端可通过 `uv run artemis mcp --help` 查看接入方式。
示例指令: 示例指令:
@@ -69,7 +70,7 @@ uv run artemis mcp --install codex
## 更新与排错 ## 更新与排错
Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建和已有 MCP 配置。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。更新后需在 AI 客户端重新加载 MCP。 Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建并同步已检测客户端的 MCP。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。MCP 配置同步失败会明确停止启动,可使用 `-SkipMcpSync` 仅启动网页。更新后需在 AI 客户端重新加载 MCP。
1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树,解压后再次运行一键脚本即可检查更新。1.1.0 及更早 ZIP 不含 Git 信息,无法自行拉取;需要下载新版源码包重新解压一次,之后即可自动更新。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。 1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树,解压后再次运行一键脚本即可检查更新。1.1.0 及更早 ZIP 不含 Git 信息,无法自行拉取;需要下载新版源码包重新解压一次,之后即可自动更新。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
+14 -6
View File
@@ -128,7 +128,7 @@ def delete_batch(batch_id: str):
except ValueError as exc: except ValueError as exc:
raise HTTPException(400, str(exc)) from exc raise HTTPException(400, str(exc)) from exc
try: try:
if not store.delete_batch(key): if not service.delete_batch(key):
raise HTTPException(404, "批次不存在") raise HTTPException(404, "批次不存在")
except ValueError as exc: except ValueError as exc:
raise HTTPException(409, str(exc)) from exc raise HTTPException(409, str(exc)) from exc
@@ -166,7 +166,8 @@ async def download_report(
try: try:
if fmt in {"screenshots", "video", "all"}: if fmt in {"screenshots", "video", "all"}:
path = await asyncio.to_thread(report_export.export_session_media, session_id, fmt) path = await asyncio.to_thread(report_export.export_session_media, session_id, fmt)
return FileResponse(path, filename=f"任务{session_id[:8]}-{fmt}.zip") label = report_export.archive_case_name(report_export.session_report(session_id))
return FileResponse(path, filename=f"{label}-{fmt}.zip")
paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt]) paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt])
return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}") return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}")
except ValueError as exc: except ValueError as exc:
@@ -184,13 +185,15 @@ def history(
): ):
# SQL filtering/pagination searches the entire persisted history, not a UI cache. # SQL filtering/pagination searches the entire persisted history, not a UI cache.
query = """WITH history AS ( query = """WITH history AS (
SELECT s.session_id, s.initial_goal, s.start_time, s.end_time, SELECT s.session_id, s.initial_goal, s.start_time,
COALESCE(json_extract(j.payload,'$.ended_at'),s.end_time) end_time,
COALESCE(j.status,s.status) status, COALESCE(json_extract(j.payload,'$.case.name'),s.initial_goal) name, COALESCE(j.status,s.status) status, COALESCE(json_extract(j.payload,'$.case.name'),s.initial_goal) name,
COALESCE(json_extract(j.payload,'$.case.tags'),'') tags, COALESCE(json_extract(j.payload,'$.case.tags'),'') tags,
COALESCE(json_extract(j.payload,'$.case.case_id'),'') case_id, COALESCE(json_extract(j.payload,'$.case.case_id'),'') case_id,
COALESCE(json_extract(j.payload,'$.case.category'),'') category, COALESCE(json_extract(j.payload,'$.case.category'),'') category,
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory, COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions,
COALESCE(json_extract(j.payload,'$.error'),'') error
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
UNION ALL UNION ALL
SELECT j.session_id,json_extract(j.payload,'$.case.goal'), SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
@@ -200,7 +203,8 @@ def history(
COALESCE(json_extract(j.payload,'$.case.case_id'),''), COALESCE(json_extract(j.payload,'$.case.case_id'),''),
COALESCE(json_extract(j.payload,'$.case.category'),''), COALESCE(json_extract(j.payload,'$.case.category'),''),
COALESCE(json_extract(j.payload,'$.case.subcategory'),''), COALESCE(json_extract(j.payload,'$.case.subcategory'),''),
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') COALESCE(json_extract(j.payload,'$.case.preconditions'),''),
COALESCE(json_extract(j.payload,'$.error'),'')
FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id) FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id)
) """ ) """
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')" where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
@@ -226,4 +230,8 @@ def history(
+ " ORDER BY start_time DESC,session_id LIMIT ? OFFSET ?", + " ORDER BY start_time DESC,session_id LIMIT ? OFFSET ?",
[*args, size, (page - 1) * size], [*args, size, (page - 1) * size],
).fetchall() ).fetchall()
return {"items": [dict(r) for r in rows], "total": total, "page": page, "size": size} items = [dict(row) for row in rows]
for item in items:
if item["case_id"]:
item["name"] = task_import.case_title(item)
return {"items": items, "total": total, "page": page, "size": size}
+14
View File
@@ -27,12 +27,16 @@ try:
from admin_console.database.repositories.trace_repository import trace_repo from admin_console.database.repositories.trace_repository import trace_repo
from admin_console.services.media_service import media_service from admin_console.services.media_service import media_service
from admin_console.services.model_service import model_service from admin_console.services.model_service import model_service
from admin_console.services.automation_store import store
from admin_console.services.task_import import case_title
except ImportError: except ImportError:
from apps.admin_console.core.state import state from apps.admin_console.core.state import state
from apps.admin_console.database.repositories.session_repository import session_repo from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.database.repositories.trace_repository import trace_repo from apps.admin_console.database.repositories.trace_repository import trace_repo
from apps.admin_console.services.media_service import media_service from apps.admin_console.services.media_service import media_service
from apps.admin_console.services.model_service import model_service from apps.admin_console.services.model_service import model_service
from apps.admin_console.services.automation_store import store
from apps.admin_console.services.task_import import case_title
router = APIRouter(tags=["sessions"]) router = APIRouter(tags=["sessions"])
@@ -52,6 +56,7 @@ async def list_sessions():
def _list_sessions_sync(): def _list_sessions_sync():
rows = session_repo.get_all_sessions() rows = session_repo.get_all_sessions()
jobs_by_id = store.jobs_for_sessions([str(row.get("session_id")) for row in rows])
video_rec_map = session_repo.get_video_recordings_map() video_rec_map = session_repo.get_video_recordings_map()
latest_recordings = session_repo.get_latest_video_recordings_map() latest_recordings = session_repo.get_latest_video_recordings_map()
agent_names_by_session = session_repo.get_agent_trace_names_map() agent_names_by_session = session_repo.get_agent_trace_names_map()
@@ -74,6 +79,15 @@ def _list_sessions_sync():
for row_dict in rows: for row_dict in rows:
s_id = str(row_dict.get("session_id")) s_id = str(row_dict.get("session_id"))
job = jobs_by_id.get(s_id)
if job:
case = job.get("case") or {}
row_dict["case_id"] = case.get("case_id") or ""
row_dict["display_name"] = case_title(case) or row_dict.get("initial_goal")
row_dict["error"] = job.get("error") or ""
if job.get("status") in {"completed", "failed", "cancelled", "interrupted", "skipped"}:
row_dict["status"] = job["status"]
row_dict["end_time"] = job.get("ended_at") or row_dict.get("end_time")
recording = latest_recordings.get(s_id) recording = latest_recordings.get(s_id)
d_info_raw = row_dict.get("device_info") d_info_raw = row_dict.get("device_info")
device_id = None device_id = None
@@ -3,11 +3,13 @@
import asyncio import asyncio
import logging import logging
import os import os
import shutil
import time import time
from uuid import UUID
from apps.admin_console.core.state import state from apps.admin_console.core.state import state
from apps.admin_console.services.automation_store import store, new_id from apps.admin_console.services.automation_store import store, new_id
from apps.admin_console.services.task_import import case_goal from apps.admin_console.services.task_import import case_goal, case_title
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"} TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"}
@@ -82,9 +84,9 @@ async def on_terminal(item: dict, status: str):
job = store.job(sid) job = store.job(sid)
if job is None: if job is None:
raise RuntimeError("任务元数据写入失败") raise RuntimeError("任务元数据写入失败")
job.update(status="finalizing", ended_at=time.time())
if job.get("timed_out"): if job.get("timed_out"):
status = "failed" status = "failed"
job.update(status="finalizing", terminal_status=status, ended_at=time.time())
store.save_job(job) store.save_job(job)
try: try:
job["status"] = status job["status"] = status
@@ -99,7 +101,9 @@ async def on_terminal(item: dict, status: str):
} }
): ):
job["status"] = "failed" job["status"] = "failed"
job["error"] = "任务执行结束,但业务检查未通过" job["error"] = job.get("error") or "任务执行结束,但业务检查未通过"
elif job["status"] == "failed" and report.get("error") and not job.get("error"):
job["error"] = report["error"]
# Persist terminal classification before export, but the coordinator waits for finalization. # Persist terminal classification before export, but the coordinator waits for finalization.
store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]}) store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]})
await asyncio.to_thread(export_session, sid, job["formats"]) await asyncio.to_thread(export_session, sid, job["formats"])
@@ -107,6 +111,7 @@ async def on_terminal(item: dict, status: str):
except Exception as exc: except Exception as exc:
logger.exception("Report finalization failed for %s", sid) logger.exception("Report finalization failed for %s", sid)
job["report_error"] = str(exc)[:500] job["report_error"] = str(exc)[:500]
job.pop("terminal_status", None)
store.save_job(job) store.save_job(job)
@@ -138,7 +143,9 @@ async def tick_batches():
and not job.get("timed_out") and not job.get("timed_out")
and time.time() - job.get("started_at", time.time()) - excluded > limit and time.time() - job.get("started_at", time.time()) - excluded > limit
): ):
job.update(error="任务达到批次配置的执行时限", timed_out=True) job.update(
error=f"任务执行超时({limit} 秒,不含暂停/排队时间)", timed_out=True
)
store.save_job(job) store.save_job(job)
task_queue_service.stop_tasks(session_id=job["session_id"]) task_queue_service.stop_tasks(session_id=job["session_id"])
batch.update(status="paused", reason="任务超时,已暂停剩余任务;请确认设备状态") batch.update(status="paused", reason="任务超时,已暂停剩余任务;请确认设备状态")
@@ -202,6 +209,7 @@ async def tick_batches():
runtime_overrides=options, runtime_overrides=options,
session_id=job["session_id"], session_id=job["session_id"],
ingress="batch", ingress="batch",
display_name=case_title(job["case"]),
) )
if response.get("status") == "rejected" or not response.get("tasks"): if response.get("status") == "rejected" or not response.get("tasks"):
raise ValueError(response.get("error") or "队列拒绝接收任务") raise ValueError(response.get("error") or "队列拒绝接收任务")
@@ -265,6 +273,35 @@ def create_batch(payload: dict) -> dict:
return store.create_batch(batch, jobs, payload) return store.create_batch(batch, jobs, payload)
def delete_batch(batch_id: str) -> bool:
"""Delete an inactive batch, its sessions and its generated reports."""
from artemis.data_engine.storage import StorageManager
from apps.admin_console.services import report_export
jobs = store.begin_delete_batch(batch_id)
if jobs is None:
return False
storage = StorageManager(store.path, report_export.TRACES_PATH)
mcp_exports = report_export.EXPORT_ROOT.parent / "mcp_exports"
with report_export.EXPORT_LOCK:
for job in jobs:
session_id = str(UUID(job["session_id"]))
storage.delete_session(UUID(session_id))
report_dir = report_export.EXPORT_ROOT / "sessions" / session_id
if report_dir.is_dir():
shutil.rmtree(report_dir)
mcp_session_dir = mcp_exports / "sessions" / session_id
if mcp_session_dir.is_dir():
shutil.rmtree(mcp_session_dir)
batch_dir = report_export.EXPORT_ROOT / "batches" / batch_id
if batch_dir.is_dir():
shutil.rmtree(batch_dir)
mcp_batch_dir = mcp_exports / "batches" / batch_id
if mcp_batch_dir.is_dir():
shutil.rmtree(mcp_batch_dir)
return store.delete_batch(batch_id)
def batch_action(batch_id: str, action: str) -> dict: def batch_action(batch_id: str, action: str) -> dict:
batch = store.get("batch", batch_id) batch = store.get("batch", batch_id)
if not batch: if not batch:
+68 -12
View File
@@ -54,6 +54,13 @@ class AutomationStore:
def put(self, kind: str, key: str, payload: dict) -> dict: def put(self, kind: str, key: str, payload: dict) -> dict:
with self.connection() as conn: with self.connection() as conn:
if kind == "batch":
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?", (key,)
).fetchone()
if row is None or json.loads(row[0]).get("status") == "deleting":
raise ValueError("批次正在删除或已删除,不能继续更新")
conn.execute( conn.execute(
"INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at", "INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
(kind, key, json.dumps(payload, ensure_ascii=False), time.time()), (kind, key, json.dumps(payload, ensure_ascii=False), time.time()),
@@ -78,6 +85,14 @@ class AutomationStore:
def save_job(self, payload: dict) -> dict: def save_job(self, payload: dict) -> dict:
with self.connection() as conn: with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
if batch_id := payload.get("batch_id"):
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
(batch_id,),
).fetchone()
if row is None or json.loads(row[0]).get("status") == "deleting":
raise ValueError("批次正在删除,不能继续派发任务")
conn.execute( conn.execute(
"INSERT INTO automation_jobs VALUES (?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET payload=excluded.payload,status=excluded.status,updated_at=excluded.updated_at", "INSERT INTO automation_jobs VALUES (?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET payload=excluded.payload,status=excluded.status,updated_at=excluded.updated_at",
( (
@@ -106,6 +121,25 @@ class AutomationStore:
) )
return [{**json.loads(row[0]), "status": row[1]} for row in rows] return [{**json.loads(row[0]), "status": row[1]} for row in rows]
def jobs_for_sessions(self, session_ids: list[str]) -> dict[str, dict]:
"""Load only metadata belonging to the sessions shown in history."""
result = {}
with self.connection() as conn:
for offset in range(0, len(session_ids), 400):
chunk = session_ids[offset : offset + 400]
markers = ",".join("?" for _ in chunk)
rows = conn.execute(
f"SELECT session_id,payload,status FROM automation_jobs WHERE session_id IN ({markers})",
chunk,
).fetchall()
result.update(
{
row["session_id"]: {**json.loads(row["payload"]), "status": row["status"]}
for row in rows
}
)
return result
def preferences(self) -> dict: def preferences(self) -> dict:
return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]} return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]}
@@ -164,8 +198,38 @@ class AutomationStore:
conn.commit() conn.commit()
return batch return batch
def begin_delete_batch(self, batch_id: str) -> list[dict] | None:
"""Reserve an inactive batch for cleanup before any more jobs can start."""
with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
(batch_id,),
).fetchone()
if row is None:
return None
batch = json.loads(row[0])
active = conn.execute(
"SELECT 1 FROM automation_jobs WHERE batch_id=? AND status IN "
"('queued','running','task_paused','resuming','finalizing') LIMIT 1",
(batch_id,),
).fetchone()
if batch["status"] == "running" or active:
raise ValueError("批次仍有任务执行中或排队中,请等待完成后删除")
batch["status"] = "deleting"
conn.execute(
"UPDATE automation_records SET payload=?,updated_at=? WHERE kind='batch' AND id=?",
(json.dumps(batch, ensure_ascii=False), time.time(), batch_id),
)
jobs = conn.execute(
"SELECT payload,status FROM automation_jobs WHERE batch_id=? ORDER BY position",
(batch_id,),
).fetchall()
conn.commit()
return [{**json.loads(job[0]), "status": job[1]} for job in jobs]
def delete_batch(self, batch_id: str) -> bool: def delete_batch(self, batch_id: str) -> bool:
"""Remove a batch record; retain executed job metadata and its reports.""" """Remove a batch record and all of its jobs after cleanup."""
with self.connection() as conn: with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE") conn.execute("BEGIN IMMEDIATE")
row = conn.execute( row = conn.execute(
@@ -175,17 +239,9 @@ class AutomationStore:
if row is None: if row is None:
return False return False
batch = json.loads(row[0]) batch = json.loads(row[0])
active = conn.execute( if batch.get("status") != "deleting":
"SELECT 1 FROM automation_jobs WHERE batch_id=? AND status IN " raise ValueError("批次尚未准备删除")
"('queued','running','task_paused','resuming','finalizing') LIMIT 1", conn.execute("DELETE FROM automation_jobs WHERE batch_id=?", (batch_id,))
(batch_id,),
).fetchone()
if batch["status"] == "running" or active:
raise ValueError("批次仍有任务执行中或排队中,请等待完成后删除")
conn.execute(
"DELETE FROM automation_jobs WHERE batch_id=? AND status='pending'",
(batch_id,),
)
conn.execute( conn.execute(
"DELETE FROM automation_records WHERE kind='batch' AND id=?", "DELETE FROM automation_records WHERE kind='batch' AND id=?",
(batch_id,), (batch_id,),
+25 -6
View File
@@ -22,6 +22,7 @@ from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
from apps.admin_console.database.repositories.session_repository import SessionRepository from apps.admin_console.database.repositories.session_repository import SessionRepository
from apps.admin_console.database.repositories.step_repository import StepRepository from apps.admin_console.database.repositories.step_repository import StepRepository
from apps.admin_console.services.automation_store import store, identifier from apps.admin_console.services.automation_store import store, identifier
from apps.admin_console.services.task_import import case_title
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports" EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
EXPORT_LOCK = threading.RLock() EXPORT_LOCK = threading.RLock()
@@ -40,6 +41,14 @@ STATUS = {
} }
def archive_case_name(report: dict) -> str:
"""Keep case identifiers readable without allowing ZIP paths or Windows-invalid names."""
fallback = str(report.get("id") or "task")[:8]
candidate = str(report.get("case_id") or fallback).strip()
candidate = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", candidate).strip(" .")
return candidate[:80] or fallback
def read_json(path: Path, fallback=None): def read_json(path: Path, fallback=None):
try: try:
return json.loads(path.read_text(encoding="utf-8")) return json.loads(path.read_text(encoding="utf-8"))
@@ -83,6 +92,9 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
return redact(recorded) return redact(recorded)
if status not in {"failed", "interrupted"}: if status not in {"failed", "interrupted"}:
return "" return ""
state = read_json(directory / "status.json", {})
if isinstance(state, dict) and state.get("error"):
return redact(state["error"])
for name in ("stdout.log", "stderr.log"): for name in ("stdout.log", "stderr.log"):
path = directory / name path = directory / name
try: try:
@@ -96,6 +108,8 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
match = re.search(r"Error running automation:\s*(.+)", line) match = re.search(r"Error running automation:\s*(.+)", line)
if match: if match:
return redact(match.group(1)) return redact(match.group(1))
if re.search(r"(?i)(?:timeout|timed out|超时|deadline exceeded)", line):
return redact("模型请求或任务执行超时:" + line.strip())
return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。" return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
@@ -142,7 +156,7 @@ def session_report(session_id: str, db_path=None) -> dict:
) )
return { return {
"id": session_id, "id": session_id,
"name": case.get("name") or session.get("initial_goal", session_id)[:120], "name": case_title(case) or session.get("initial_goal", session_id)[:120],
"goal": case.get("goal") or session.get("initial_goal", ""), "goal": case.get("goal") or session.get("initial_goal", ""),
"expected": case.get("expected", ""), "expected": case.get("expected", ""),
"status": status, "status": status,
@@ -638,15 +652,18 @@ def export_session_media(session_id: str, kind: str) -> Path:
"videos": [name for _, name in videos], "videos": [name for _, name in videos],
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。", "note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
} }
prefix = archive_case_name(report) + "/" if kind == "all" else ""
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive: with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
if kind == "all": if kind == "all":
for path in export_session(session_id, ["docx", "xlsx"]): for path in export_session(session_id, ["docx", "xlsx"]):
archive.write(path, path.name) archive.write(path, prefix + path.name)
for source, name in images: for source, name in images:
archive.write(source, f"步骤截图/{name}") archive.write(source, f"{prefix}步骤截图/{name}")
for source, name in videos: for source, name in videos:
archive.write(source, f"任务录屏/{name}") archive.write(source, f"{prefix}任务录屏/{name}")
archive.writestr("文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2)) archive.writestr(
prefix + "文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2)
)
temporary.replace(target) temporary.replace(target)
return target return target
@@ -691,6 +708,8 @@ def export_batch(batch_id: str) -> Path:
archive.write(path, path.name) archive.write(path, path.name)
for number, report in enumerate(records, 1): for number, report in enumerate(records, 1):
for path in export_session(report["id"], formats): for path in export_session(report["id"], formats):
archive.write(path, f"任务报告/{number:04d}_{report['id']}/{path.name}") archive.write(
path, f"任务报告/{number:04d}_{archive_case_name(report)}/{path.name}"
)
temporary.replace(target) temporary.replace(target)
return target return target
@@ -177,3 +177,10 @@ def parse_workbook(data: bytes) -> dict:
def case_goal(case: dict) -> str: def case_goal(case: dict) -> str:
"""Execution gets the prompt verbatim; metadata stays outside model input.""" """Execution gets the prompt verbatim; metadata stays outside model input."""
return case["goal"] return case["goal"]
def case_title(case: dict) -> str:
"""Human-facing title; never prepend metadata to the model prompt."""
case_id = str(case.get("case_id") or "").strip()
name = str(case.get("name") or "").strip()
return " ".join(part for part in (case_id, name) if part)
@@ -1133,6 +1133,7 @@ class TaskQueueService:
verification_level: str | None = None, verification_level: str | None = None,
explorer_mode: str | None = None, explorer_mode: str | None = None,
runtime_overrides: dict[str, Any] | None = None, runtime_overrides: dict[str, Any] | None = None,
display_name: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Enqueues one or more goals and wakes up the background worker. """Enqueues one or more goals and wakes up the background worker.
@@ -1189,6 +1190,8 @@ class TaskQueueService:
) )
if runtime_overrides: if runtime_overrides:
task_item["runtime_overrides"] = runtime_overrides task_item["runtime_overrides"] = runtime_overrides
if display_name:
task_item["display_name"] = display_name
state.queue_items.append(task_item) state.queue_items.append(task_item)
enqueued_tasks.append(task_item) enqueued_tasks.append(task_item)
cls._broadcast_startup_progress( cls._broadcast_startup_progress(
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.1.1", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.1.1", "version": "1.2.0",
"dependencies": { "dependencies": {
"@angular/cdk": "^22.1.5", "@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4", "@angular/common": "^22.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.1.1", "version": "1.2.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
@@ -76,6 +76,7 @@ limitations under the License.
class="dropdown-item-card" class="dropdown-item-card"
[class.selected]="agentService.currentSessionId() === session.session_id" [class.selected]="agentService.currentSessionId() === session.session_id"
[class.running]="getTaskStatus(session) === 'running'" [class.running]="getTaskStatus(session) === 'running'"
[ngClass]="getTaskStatus(session)"
(click)="selectTask(session.session_id, $event)" (click)="selectTask(session.session_id, $event)"
> >
<div class="item-header"> <div class="item-header">
@@ -96,7 +97,7 @@ limitations under the License.
<span class="item-time-divider">·</span> <span class="item-time-divider">·</span>
} }
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span> <span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
@if (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused') { @if (['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
<button <button
type="button" type="button"
class="btn-stop-card" class="btn-stop-card"
@@ -107,7 +108,7 @@ limitations under the License.
<span class="material-symbols-outlined">stop_circle</span> <span class="material-symbols-outlined">stop_circle</span>
</button> </button>
} }
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') { @if (!['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
<button <button
type="button" type="button"
class="btn-delete-card" class="btn-delete-card"
@@ -120,12 +121,15 @@ limitations under the License.
} }
</div> </div>
</div> </div>
<div class="item-goal" title="{{ session.initial_goal }}"> <div class="item-goal" [title]="session.initial_goal || ''">
{{ session.initial_goal || '未命名任务' }} {{ session.display_name || session.initial_goal || '未命名任务' }}
</div> </div>
@if (session.error && getTaskStatus(session) === 'failed') {
<div class="item-error" [title]="session.error">{{session.error}}</div>
}
<div class="item-footer"> <div class="item-footer">
<app-task-id-copy [taskId]="session.session_id" /> <app-task-id-copy [taskId]="session.session_id" />
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span> <span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }} · 已用 {{ formatTaskDuration(session.start_time, session.end_time) }}</span>
</div> </div>
</div> </div>
} }
@@ -3684,6 +3684,12 @@ $stream-row-height: 25px;
&.running { &.running {
border-left: 3.5px solid #2563eb; border-left: 3.5px solid #2563eb;
} }
&.paused, &.task_paused { border-left: 3.5px solid #d97706; background: #fffbeb; }
&.resuming, &.queued { border-left: 3.5px solid #7c3aed; }
&.failed { border-left: 3.5px solid #dc2626; background: #fff7f7; }
&.completed { border-left: 3.5px solid #16a34a; }
&.cancelled, &.interrupted { border-left: 3.5px solid #ea580c; }
.item-error { color: #b91c1c; font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }
.item-header { .item-header {
display: flex; display: flex;
@@ -3729,12 +3735,24 @@ $stream-row-height: 25px;
border-color: #fdba74; border-color: #fdba74;
&::before { background-color: #f97316; } &::before { background-color: #f97316; }
} }
&.task_paused {
background: #ffedd5;
color: #9a3412;
border-color: #fb923c;
&::before { background-color: #ea580c; }
}
&.resuming, &.queued {
background: #f3e8ff;
color: #6b21a8;
border-color: #c4b5fd;
&::before { background-color: #7c3aed; }
}
&.completed { &.completed {
background: #fafafa; background: #dcfce7;
color: #1f1f1f; color: #166534;
border-color: #e2e8f0; border-color: #86efac;
&::before { &::before {
background-color: #1f1f1f; background-color: #16a34a;
} }
} }
&.pending { &.pending {
@@ -3746,21 +3764,22 @@ $stream-row-height: 25px;
} }
} }
&.failed { &.failed {
background: #fafafa; background: #fee2e2;
color: #ef4444; color: #991b1b;
border-color: #fee2e2; border-color: #fca5a5;
&::before { &::before {
background-color: #ef4444; background-color: #ef4444;
} }
} }
&.cancelled { &.cancelled {
background: #fafafa; background: #ffedd5;
color: #d97706; color: #9a3412;
border-color: #fef3c7; border-color: #fdba74;
&::before { &::before {
background-color: #f59e0b; background-color: #ea580c;
} }
} }
&.interrupted { background: #fef3c7; color: #92400e; border-color: #fbbf24; &::before { background-color: #d97706; } }
.status-dot { .status-dot {
display: none; display: none;
@@ -388,13 +388,13 @@ export class AgentStreamComponent implements AfterViewInit {
public activeQueue = computed(() => { public activeQueue = computed(() => {
const list = this.agentService.sessions().filter((s) => { const list = this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s); const status = this.getTaskStatus(s);
return status === 'running' || status === 'paused' || status === 'pending'; return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status);
}); });
return list.sort((a, b) => { return list.sort((a, b) => {
const statusA = this.getTaskStatus(a); const statusA = this.getTaskStatus(a);
const statusB = this.getTaskStatus(b); const statusB = this.getTaskStatus(b);
const isRunA = statusA === 'running' || statusA === 'paused'; const isRunA = ['running', 'paused', 'task_paused', 'resuming'].includes(statusA);
const isRunB = statusB === 'running' || statusB === 'paused'; const isRunB = ['running', 'paused', 'task_paused', 'resuming'].includes(statusB);
if (isRunA && !isRunB) return -1; if (isRunA && !isRunB) return -1;
if (!isRunA && isRunB) return 1; if (!isRunA && isRunB) return 1;
return (a.start_time || 0) - (b.start_time || 0); return (a.start_time || 0) - (b.start_time || 0);
@@ -404,7 +404,7 @@ export class AgentStreamComponent implements AfterViewInit {
public historyTasks = computed(() => { public historyTasks = computed(() => {
return this.agentService.sessions().filter((s) => { return this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s); const status = this.getTaskStatus(s);
return status === 'completed' || status === 'failed' || status === 'cancelled'; return ['completed', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(status);
}); });
}); });
@@ -491,7 +491,7 @@ export class AgentStreamComponent implements AfterViewInit {
const terminal = ['completed', 'failed', 'cancelled', 'interrupted'] const terminal = ['completed', 'failed', 'cancelled', 'interrupted']
.includes(this.agentService.currentSession()?.status || ''); .includes(this.agentService.currentSession()?.status || '');
if (event.status === 'pending') { if (event.status === 'pending') {
return terminal ? '任务结束前未进入模型' : '已接收,等待下一步'; return terminal ? '任务结束前未进入模型' : '已接收,正在打断当前模型步骤';
} }
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录'; return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
} }
@@ -912,14 +912,14 @@ export class AgentStreamComponent implements AfterViewInit {
return tuningLabel(kind, id); return tuningLabel(kind, id);
} }
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' { public getTaskStatus(session: Session): string {
if (session.status) { if (session.status) {
const s = session.status.toLowerCase(); const s = session.status.toLowerCase();
if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') { if (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s)) {
return (s === 'success' ? 'completed' : s) as any; return s === 'success' ? 'completed' : s;
} }
if (s === 'running' || s === 'paused' || s === 'pending') { if (['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(s)) {
return s as any; return s;
} }
} }
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) { if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
@@ -1360,6 +1360,14 @@ export class AgentStreamComponent implements AfterViewInit {
return isAndroidAction(action); return isAndroidAction(action);
} }
public formatTaskDuration(start?: number, end?: number): string {
if (!start || !Number.isFinite(start)) return '—';
const seconds = Math.max(0, Math.floor((end || Date.now() / 1000) - start));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours ? `${hours}时${minutes}分` : minutes ? `${minutes}分${seconds % 60}秒` : `${seconds}秒`;
}
public hasRecordedDeviceAction(action: any): boolean { public hasRecordedDeviceAction(action: any): boolean {
const actions = Array.isArray(action) ? action : action ? [action] : []; const actions = Array.isArray(action) ? action : action ? [action] : [];
return actions.some(item => this.isAndroidAction(item) && !this.isReportStatusAction(item)); return actions.some(item => this.isAndroidAction(item) && !this.isReportStatusAction(item));
@@ -28,7 +28,7 @@
@if (liveTask() && agent.currentSessionId()) { @if (liveTask() && agent.currentSessionId()) {
<div class="task-guidance"> <div class="task-guidance">
<h3>任务指导 · {{label(taskStatus())}}</h3> <h3>任务指导 · {{label(taskStatus())}}</h3>
<p>{{ taskStatus() === 'paused' ? '模型请求已暂停;提交指导后请在执行日志中恢复模型调用。' : '运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。' }}</p> <p>{{ taskStatus() === 'paused' ? '模型请求已暂停;提交指导后请在执行日志中恢复模型调用。' : '运行中提交会立即打断模型思考并重新观察设备;正在执行的设备动作会先安全结束。' }}</p>
<label for="task-guidance-text">补充信息或调整方向</label> <label for="task-guidance-text">补充信息或调整方向</label>
<textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea> <textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea>
<div class="actions"> <div class="actions">
@@ -61,7 +61,7 @@
@for(b of batches(); track b.id){ @for(b of batches(); track b.id){
<div class="batch-record"> <div class="batch-record">
<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button> <button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>
<button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次记录(保留已执行任务与报告)'"><span class="material-symbols-outlined">delete_outline</span></button> <button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次及其任务、报告和文件'"><span class="material-symbols-outlined">delete_outline</span></button>
</div> </div>
} }
@if(!batches().length){<p>暂无批次。</p>} @if(!batches().length){<p>暂无批次。</p>}
@@ -70,10 +70,10 @@
<div class="actions"> <div class="actions">
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>} @if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
@if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>} @if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
@if(b.status !== 'running'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>} @if(b.status !== 'running' && b.status !== 'deleting'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
<a [href]="'/api/automation/batches/'+b.id+'/archive'">下载批次报告 ZIP</a> <a [href]="'/api/automation/batches/'+b.id+'/archive'">下载批次报告 ZIP</a>
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p> </div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>} @if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')">{{j.position}}. {{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>} @for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')" [title]="j.case.goal">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
</section>} </section>}
} }
@@ -38,7 +38,7 @@ export class AutomationPanelComponent {
}); });
} }
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; } formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告'} as Record<string,string>)[s] || s; } label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record<string,string>)[s] || s; }
renderReport(content: string) { return renderMarkdownToHtml(content || ''); } renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
async action(fn: () => Promise<void>) { async action(fn: () => Promise<void>) {
if (this.busy()) return; if (this.busy()) return;
@@ -73,12 +73,13 @@ export class AutomationPanelComponent {
}); } }); }
selectBatch(id: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.get(`/api/automation/batches/${id}`))); }); } selectBatch(id: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.get(`/api/automation/batches/${id}`))); }); }
deleteBatch(item: { id: string; name: string }) { deleteBatch(item: { id: string; name: string }) {
if (!window.confirm(`删除批次“${item.name}”?未执行任务会移除,已执行任务与报告仍保留。`)) return; if (!window.confirm(`删除批次“${item.name}”及其全部任务记录、报告和文件?此操作无法撤销。`)) return;
void this.action(async () => { void this.action(async () => {
await firstValueFrom(this.http.delete(`/api/automation/batches/${encodeURIComponent(item.id)}`)); await firstValueFrom(this.http.delete(`/api/automation/batches/${encodeURIComponent(item.id)}`));
if (this.batch()?.id === item.id) this.batch.set(null); if (this.batch()?.id === item.id) this.batch.set(null);
await this.refresh(); await this.refresh();
this.notice.set('批次记录已删除;已执行任务与报告仍可在历史中查看'); this.agent.fetchSessions();
this.notice.set('批次及关联任务、报告和文件已删除');
}); });
} }
batchAction(action: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.post(`/api/automation/batches/${this.batch().id}/${action}`,{}))); await this.refresh(); }); } batchAction(action: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.post(`/api/automation/batches/${this.batch().id}/${action}`,{}))); await this.refresh(); }); }
@@ -49,6 +49,21 @@ describe('历史任务检索', () => {
fixture.destroy(); fixture.destroy();
}); });
it('显示明确的结果、完成时间与总耗时,标题悬停显示完整提示词', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total: 1, items: [
{session_id:'case-1', name:'1.1 打开并核对设置', initial_goal:'请打开设置首页并核对两个真实分类', status:'failed', start_time:100, end_time:165, error:'任务执行超时(60 秒)'}
]});
fixture.detectChanges();
const row: HTMLButtonElement = fixture.nativeElement.querySelector('button.row');
expect(row.getAttribute('title')).toBe('请打开设置首页并核对两个真实分类');
expect(row.textContent).toContain('1.1 打开并核对设置');
expect(row.textContent).toContain('总耗时 1分5秒');
expect(row.textContent).toContain('任务执行超时');
expect(fixture.nativeElement.querySelector('.status-failed .history-status').textContent).toContain('失败');
fixture.destroy();
});
it('日期筛选用本地零点到次日零点,后端分页参数独立传递', () => { it('日期筛选用本地零点到次日零点,后端分页参数独立传递', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent); const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]}); http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
@@ -13,12 +13,12 @@ import { AgentService } from '../../services/agent.service';
@if(error()){<p role="alert">{{error()}}</p>} @if(error()){<p role="alert">{{error()}}</p>}
@if(loading()){<small>正在查询…</small>} @if(loading()){<small>正在查询…</small>}
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary> @for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
@for(row of group.rows; track row.session_id){<div class="history-item"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)"><strong>{{row.name || row.initial_goal}}</strong><span>{{label(row.status)}} · {{row.start_time*1000 | date:'HH:mm:ss'}} · {{row.tags}}</span></button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>} @for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
</details>} </details>}
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>} @if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div> <div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
</section>`, </section>`,
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:4px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:500}.row span{opacity:.75}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #64748b55;border-radius:6px;margin:5px 0;padding:8px}.history-item:has(.row.selected){border-color:#60a5fa;background:#3b82f615}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`] styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-block;border-radius:5px;padding:2px 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
}) })
export class TaskHistoryComponent { export class TaskHistoryComponent {
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1; agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
@@ -27,7 +27,8 @@ export class TaskHistoryComponent {
constructor(){this.load();const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});} constructor(){this.load();const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});}
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});} remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
pages(){return Math.max(1,Math.ceil(this.total()/30));} pages(){return Math.max(1,Math.ceil(this.total()/30));}
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已取消',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;} label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
duration(start:number,end?:number){if(!start||!end)return '—';const seconds=Math.max(0,Math.floor(end-start));const hours=Math.floor(seconds/3600);const minutes=Math.floor((seconds%3600)/60);return hours?`${hours}时${minutes}分`:minutes?`${minutes}分${seconds%60}秒`:`${seconds}秒`;}
toggle(key:string,event:Event){if((event.target as HTMLDetailsElement).open)this.collapsed.delete(key);else this.collapsed.add(key);} toggle(key:string,event:Event){if((event.target as HTMLDetailsElement).open)this.collapsed.delete(key);else this.collapsed.add(key);}
search(){this.page=1;this.revision++;clearTimeout(this.debounce);this.debounce=setTimeout(()=>this.load(),250);} search(){this.page=1;this.revision++;clearTimeout(this.debounce);this.debounce=setTimeout(()=>this.load(),250);}
load(){ load(){
@@ -38,6 +38,9 @@ export interface Session {
start_time: number; start_time: number;
end_time?: number; end_time?: number;
status?: string; status?: string;
display_name?: string;
case_id?: string;
error?: string;
video_url?: string; video_url?: string;
recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable'; recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable';
model_info?: ModelInfo; model_info?: ModelInfo;
@@ -34,7 +34,7 @@ limitations under the License.
</div> </div>
<label for="workspace-task-guidance">补充信息或调整方向</label> <label for="workspace-task-guidance">补充信息或调整方向</label>
<textarea id="workspace-task-guidance" [(ngModel)]="taskGuidance" rows="2" maxlength="10000" <textarea id="workspace-task-guidance" [(ngModel)]="taskGuidance" rows="2" maxlength="10000"
[disabled]="guidanceBusy()" placeholder="输入指导;运行中将在下一步执行前生效"></textarea> [disabled]="guidanceBusy()" placeholder="输入指导;将中断当前模型思考并立即重新规划"></textarea>
<div class="guidance-actions"> <div class="guidance-actions">
<span>{{ status === 'paused' ? '提交指导后请在执行日志中恢复模型调用' : '下方输入框用于开始新任务' }}</span> <span>{{ status === 'paused' ? '提交指导后请在执行日志中恢复模型调用' : '下方输入框用于开始新任务' }}</span>
@if (status === 'running') { @if (status === 'running') {
@@ -108,7 +108,7 @@ export class AgentService {
const pendingMatch = pendingById.get(s.session_id); const pendingMatch = pendingById.get(s.session_id);
const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id) const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id)
|| !!activeMatch; || !!activeMatch;
const isTerminal = s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'cancelled'; const isTerminal = ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s.status || '');
let sStatus = s.status; let sStatus = s.status;
if (!isTerminal) { if (!isTerminal) {
const isPending = !!pendingMatch && !isCurrentActive; const isPending = !!pendingMatch && !isCurrentActive;
@@ -1515,12 +1515,12 @@ export class AgentService {
return; // Paused while the tab is in the background; resumed by visibilitychange. return; // Paused while the tab is in the background; resumed by visibilitychange.
} }
this.fetchStatus(); this.fetchStatus();
const selected = this.currentSessionId();
if (selected) this.fetchGuidance(selected);
this.pollCounter++; this.pollCounter++;
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications // Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
if (this.pollCounter % 3 === 0) { if (this.pollCounter % 3 === 0) {
this.fetchSessions(); this.fetchSessions();
const selected = this.currentSessionId();
if (selected) this.fetchGuidance(selected);
} }
}, 2000); }, 2000);
}); });
@@ -1,9 +1,10 @@
import { Pipe, PipeTransform } from '@angular/core'; import { Pipe, PipeTransform } from '@angular/core';
const LABELS: Record<string, string> = { const LABELS: Record<string, string> = {
running: '执行中', queued: '排队中', pending: '等待中', completed: '已完成', running: '执行中', queued: '排队中', pending: '等待中', completed: '执行完成',
success: '成功', succeeded: '成功', failed: '失败', error: '错误', paused: '已暂停', success: '成功', succeeded: '成功', failed: '失败', error: '错误', paused: '已暂停',
cancelled: '已取消', canceled: '已取消', stopped: '已停止', unknown: '未知', cancelled: '已终止', canceled: '已终止', stopped: '已终止', interrupted: '中断',
task_paused: '等待指导', resuming: '恢复排队', skipped: '已跳过', unknown: '未知',
idle: '空闲', starting: '启动中', launching: '启动中', ready: '就绪', idle: '空闲', starting: '启动中', launching: '启动中', ready: '就绪',
pass: '通过', fail: '失败', warn: '警告', warning: '警告', blocked: '受阻', pass: '通过', fail: '失败', warn: '警告', warning: '警告', blocked: '受阻',
done: '完成', active: '当前', waiting: '等待中', processing: '处理中', done: '完成', active: '当前', waiting: '等待中', processing: '处理中',
+46 -2
View File
@@ -45,7 +45,7 @@ import re
import uuid import uuid
from artemis.config.settings import settings from artemis.config.settings import settings
from artemis.runtime.task_control import TaskControl from artemis.runtime.task_control import TaskControl, has_pending_instruction
from jinja2 import Template from jinja2 import Template
from langchain_core.messages import ( from langchain_core.messages import (
@@ -937,6 +937,9 @@ class FlashRunner:
action_names = self.executor.action_tool_names action_names = self.executor.action_tool_names
index_elements = self._turn_index_snapshot(state) index_elements = self._turn_index_snapshot(state)
for tc in tool_calls: for tc in tool_calls:
control = getattr(self.ctx, "task_control", None)
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
return None, pre_screenshot_bytes, xml_list, action_sequence
name = tc["name"].split(":")[-1] if ":" in tc["name"] else tc["name"] name = tc["name"].split(":")[-1] if ":" in tc["name"] else tc["name"]
args = tc.get("args") or {} args = tc.get("args") or {}
tc_id = tc.get("id") or str(uuid.uuid4()) tc_id = tc.get("id") or str(uuid.uuid4())
@@ -1118,7 +1121,36 @@ class FlashRunner:
turn_base = len(messages) - 1 turn_base = len(messages) - 1
current_tools = report_only_tools if is_final else tools_declaration current_tools = report_only_tools if is_final else tools_declaration
response = await self._invoke_model(llm, current_tools, messages) if isinstance(control, TaskControl):
interrupted, response = await control.run_interruptibly(
self._invoke_model(llm, current_tools, messages),
on_pause=getattr(self.ctx, "on_task_pause", None),
on_resume=getattr(self.ctx, "on_task_resume", None),
)
else:
interrupted, response = (
False,
await self._invoke_model(llm, current_tools, messages),
)
if interrupted or (
isinstance(control, TaskControl) and has_pending_instruction(control.directory)
):
# Do not dispatch a stale action or final answer from a prompt
# assembled before the instruction arrived.
(
shot_path,
current_pre_screenshot_bytes,
current_xml_list,
) = await capture_screenshot_and_parse_ui(
self.ctx, state, self.controller, skip_settling=False
)
state.latest_screenshot = shot_path
pending_notices.append(
"新指导已打断旧模型步骤。请重新观察画面并按最新指导决定下一步。"
)
turns -= 1
continue
if response is None: if response is None:
break break
@@ -1145,6 +1177,18 @@ class FlashRunner:
self._record_llm_trace(step_token_usage, raw_text) self._record_llm_trace(step_token_usage, raw_text)
tool_calls = self._resolve_tool_calls(response, raw_text) tool_calls = self._resolve_tool_calls(response, raw_text)
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
(
shot_path,
current_pre_screenshot_bytes,
current_xml_list,
) = await capture_screenshot_and_parse_ui(
self.ctx, state, self.controller, skip_settling=False
)
state.latest_screenshot = shot_path
pending_notices.append("新指导已收到;忽略旧模型动作,重新观察画面。")
turns -= 1
continue
if not tool_calls: if not tool_calls:
logger.info( logger.info(
+10 -4
View File
@@ -33,7 +33,7 @@ from artemis.graph.state import State
from artemis.graph.visibility import strict_state from artemis.graph.visibility import strict_state
from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool
from artemis.llm.usage import usage_from_message from artemis.llm.usage import usage_from_message
from artemis.runtime.task_control import TaskControl from artemis.runtime.task_control import TaskControl, has_pending_instruction
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message
from artemis.tools.command_tool import ( from artemis.tools.command_tool import (
analyze_task_output_wrapper, analyze_task_output_wrapper,
@@ -539,6 +539,9 @@ class OperatorNode:
if interrupted: if interrupted:
return None, None, None, False return None, None, None, False
assert response is not None assert response is not None
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
# The model answered a prompt that predates the user's new direction.
return None, None, None, False
usage = usage_from_message(response) usage = usage_from_message(response)
# The ledger's compaction thresholds read the operator's own # The ledger's compaction thresholds read the operator's own
@@ -690,10 +693,10 @@ class OperatorNode:
if normalize_name(tool_name) in INTERRUPTIBLE_HELPERS: if normalize_name(tool_name) in INTERRUPTIBLE_HELPERS:
interrupted, result_obj = await self._await_or_pause(tool_call) interrupted, result_obj = await self._await_or_pause(tool_call)
if interrupted: if interrupted:
span.result = "任务暂停;诊断已中断,恢复后重新观察屏幕" span.result = "任务被暂停或指导打断;重新观察屏幕后继续"
results.append((span.result, "error")) results.append((span.result, "error"))
for remaining in other_calls[len(results) :]: for remaining in other_calls[len(results) :]:
results.append(("任务暂停,未执行该工具", "error")) results.append(("任务已打断,未执行该工具", "error"))
for completed, (content, status) in zip( for completed, (content, status) in zip(
other_calls, results other_calls, results
): ):
@@ -710,7 +713,7 @@ class OperatorNode:
current_messages.append( current_messages.append(
ToolMessage( ToolMessage(
tool_call_id=action["id"], tool_call_id=action["id"],
content="任务暂停,未执行屏幕操作", content="任务已打断,未执行屏幕操作",
status="error", status="error",
) )
) )
@@ -827,6 +830,9 @@ class OperatorNode:
action_calls = [] action_calls = []
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
return None, None, None, False
if action_calls: if action_calls:
logger.info( logger.info(
f"Operator requested {len(action_calls)} action(s)." f"Operator requested {len(action_calls)} action(s)."
+40 -38
View File
@@ -1158,11 +1158,21 @@ class StorageManager:
"""Delete all data associated with a session, including files on disk.""" """Delete all data associated with a session, including files on disk."""
session_id_str = str(session_id) session_id_str = str(session_id)
# 1. Get video paths before deleting from DB # 1. Get files referenced by this session before deleting from DB.
video_paths = [] video_paths = []
video_ids: list[str] = [] video_ids: list[str] = []
image_names: set[str] = set()
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
image_names = {
str(name)
for row in conn.execute(
"SELECT pre_image_name, post_image_name FROM steps WHERE session_id = ?",
(session_id_str,),
)
for name in row
if name
}
cursor = conn.execute( cursor = conn.execute(
"SELECT video_id, local_video_path FROM video_recordings WHERE session_id = ?", "SELECT video_id, local_video_path FROM video_recordings WHERE session_id = ?",
(session_id_str,), (session_id_str,),
@@ -1173,9 +1183,11 @@ class StorageManager:
if row["local_video_path"]: if row["local_video_path"]:
video_paths.append(Path(row["local_video_path"])) video_paths.append(Path(row["local_video_path"]))
except sqlite3.OperationalError as e: except sqlite3.OperationalError as e:
logger.warning(f"Could not query video_recordings: {e}") logger.warning(f"Could not query session media: {e}")
raise
# 2. Delete from DB tables # 2. Delete from DB tables
orphaned_images: list[str] = []
with self._get_connection() as conn: with self._get_connection() as conn:
conn.execute("PRAGMA foreign_keys = OFF") conn.execute("PRAGMA foreign_keys = OFF")
board_keys = [f"session:{session_id_str}"] + [ board_keys = [f"session:{session_id_str}"] + [
@@ -1207,13 +1219,26 @@ class StorageManager:
(session_id_str,), (session_id_str,),
) )
except sqlite3.OperationalError as e: except sqlite3.OperationalError as e:
logger.warning(f"Failed to delete from table {table}: {e}") logger.error(f"Failed to delete from table {table}: {e}")
conn.commit() raise
conn.execute("VACUUM") for name in image_names:
if not conn.execute(
"SELECT 1 FROM steps WHERE pre_image_name=? OR post_image_name=? LIMIT 1",
(name, name),
).fetchone():
conn.execute("DELETE FROM images WHERE image_name=?", (name,))
orphaned_images.append(name)
conn.commit() conn.commit()
logger.info(f"Database records for session {session_id} cleared.") logger.info(f"Database records for session {session_id} cleared.")
images_root = (self.base_trace_dir / "images").resolve()
for name in orphaned_images:
if len(name) == 64 and all(c in "0123456789abcdef" for c in name):
image_path = (images_root / f"{name}.jpg").resolve()
if image_path.is_relative_to(images_root):
image_path.unlink(missing_ok=True)
# 3. Delete session directory (notes, etc.) # 3. Delete session directory (notes, etc.)
session_dir = self.base_trace_dir / session_id_str session_dir = self.base_trace_dir / session_id_str
if session_dir.exists() and session_dir.is_dir(): if session_dir.exists() and session_dir.is_dir():
@@ -1222,38 +1247,15 @@ class StorageManager:
logger.info(f"Deleted session directory: {session_dir}") logger.info(f"Deleted session directory: {session_dir}")
except Exception as e: except Exception as e:
logger.error(f"Failed to delete session directory {session_dir}: {e}") logger.error(f"Failed to delete session directory {session_dir}: {e}")
raise
# 4. Delete video files and their containing folders (which might have been renamed) # 4. Never let a persisted recording path remove a sibling session.
trace_root = self.base_trace_dir.resolve()
for video_path in video_paths: for video_path in video_paths:
# video_path is likely: /path/to/traces/task_name/recording.mp4 resolved = video_path.resolve()
# If it was renamed, it might be: /path/to/traces/task_name_PASS_timestamp/recording.mp4 if not resolved.is_relative_to(trace_root):
continue
task_name = video_path.parent.name relative = resolved.relative_to(trace_root)
if not relative.parts or relative.parts[0] != session_id_str:
if task_name and task_name != "traces" and task_name != "..": continue
# Search for directories starting with task_name in base_trace_dir resolved.unlink(missing_ok=True)
try:
for path in self.base_trace_dir.iterdir():
if path.is_dir() and path.name.startswith(task_name):
try:
shutil.rmtree(path)
logger.info(f"Deleted trace/video directory: {path}")
except Exception as e:
logger.error(f"Failed to delete directory {path}: {e}")
elif path.is_file() and path.name.startswith(task_name):
try:
path.unlink()
logger.info(f"Deleted trace/video file: {path}")
except Exception as e:
logger.error(f"Failed to delete file {path}: {e}")
except Exception as e:
logger.error(f"Error iterating base_trace_dir for cleanup: {e}")
if video_path.exists():
try:
video_path.unlink()
logger.info(f"Deleted video file directly: {video_path}")
if video_path.parent.exists() and not any(video_path.parent.iterdir()):
video_path.parent.rmdir()
except Exception as e:
logger.error(f"Failed to delete video file directly {video_path}: {e}")
+10 -1
View File
@@ -84,6 +84,11 @@ def list_instructions(session_id: str) -> list[dict]:
return sorted(records, key=lambda record: (record.get("timestamp", 0), record["id"])) return sorted(records, key=lambda record: (record.get("timestamp", 0), record["id"]))
def has_pending_instruction(base_dir: str | Path) -> bool:
directory = Path(base_dir) / "instructions"
return any(directory.glob("*.json")) or (Path(base_dir) / "injected_instruction.json").exists()
def consume_instructions(base_dir: str | Path) -> dict | None: def consume_instructions(base_dir: str | Path) -> dict | None:
directory = Path(base_dir) / "instructions" directory = Path(base_dir) / "instructions"
found = [] found = []
@@ -175,7 +180,7 @@ class TaskControl:
async def run_interruptibly[T]( async def run_interruptibly[T](
self, operation: Awaitable[T], *, on_pause=None, on_resume=None self, operation: Awaitable[T], *, on_pause=None, on_resume=None
) -> tuple[bool, T | None]: ) -> tuple[bool, T | None]:
"""Park a task during a long model/helper call, then discard its stale result. """Interrupt a stale model/helper call for pause or fresh user guidance.
Only use for operations whose cancellation cannot leave a device action Only use for operations whose cancellation cannot leave a device action
half executed. The caller must return to fresh perception after resume. half executed. The caller must return to fresh perception after resume.
@@ -189,6 +194,10 @@ class TaskControl:
await asyncio.gather(running, return_exceptions=True) await asyncio.gather(running, return_exceptions=True)
await self.checkpoint(on_pause=on_pause, on_resume=on_resume) await self.checkpoint(on_pause=on_pause, on_resume=on_resume)
return True, None return True, None
if has_pending_instruction(self.directory):
running.cancel()
await asyncio.gather(running, return_exceptions=True)
return True, None
if running.done(): if running.done():
return False, running.result() return False, running.result()
await asyncio.wait({running}, timeout=0.2) await asyncio.wait({running}, timeout=0.2)
+6 -4
View File
@@ -2,7 +2,7 @@
源码内的 `skills/artemis-automation/` 是可分发 Skill,不依赖本机 Codex 的私有能力。将整个目录复制到宿主支持的 Skill 目录即可;Codex 为 `$CODEX_HOME/skills/`(未配置时 `~/.codex/skills/`)。仓库包含 Skill,启动程序不会修改用户的全局 Skill 目录。 源码内的 `skills/artemis-automation/` 是可分发 Skill,不依赖本机 Codex 的私有能力。将整个目录复制到宿主支持的 Skill 目录即可;Codex 为 `$CODEX_HOME/skills/`(未配置时 `~/.codex/skills/`)。仓库包含 Skill,启动程序不会修改用户的全局 Skill 目录。
先完成项目环境和模型配置,然后在项目目录执行 `uv run artemis mcp --install codex` 安装/更新 MCP 配置。其他客户端将 `codex` 换成对应客户端;`uv run artemis mcp --help` 可查看支持列表。更新后重新加载宿主 MCP;已有配置若设置了工具白名单,需要重新安装配置或加入新增工具。复制 Skill 后按宿主方式重新加载技能。 Windows 一键脚本会为检测到的客户端自动安装或更新 MCP 配置。未检测到客户端时,在项目目录执行 `uv run artemis mcp --install codex`;其他客户端将 `codex` 换成对应名称,`uv run artemis mcp --help` 可查看支持列表。更新后重新加载宿主 MCP;复制 Skill 后按宿主方式重新加载技能。
可以向 AI 这样下达任务: 可以向 AI 这样下达任务:
@@ -36,12 +36,14 @@ Windows 已安装用户再次运行 `start.bat` / `start-cn.bat` 时会检查当
Python 每次按 `uv.lock` 检查并同步缺失/变化的依赖;前端依据源码、资源及包锁文件指纹决定是否重新执行 `npm ci` 和构建,成功后才记录指纹。国内镜像选项继续生效。检测到正在执行/排队的任务时,自动更新启动会停止,让原任务继续。 Python 每次按 `uv.lock` 检查并同步缺失/变化的依赖;前端依据源码、资源及包锁文件指纹决定是否重新执行 `npm ci` 和构建,成功后才记录指纹。国内镜像选项继续生效。检测到正在执行/排队的任务时,自动更新启动会停止,让原任务继续。
MCP 自动刷新只处理已存在且指向当前项目的标准 Python 模块配置,更新解释器路径和已有工具白名单。保留用户环境变量、禁用状态、禁用工具及其他服务;JSON/TOML 无法安全解析、其他项目和自定义启动包装器保持原样。首次修改保留备份,未接入的客户端不会自动安装。源码更新后需在 AI 客户端重新加载 MCP 连接以使用新实现;脚本不会强制重启你的 IDE。 MCP 同步会识别已安装客户端的配置目录,为缺少 ARTEMIS 条目的客户端安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。无法安全解析配置时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。源码和配置同步后需在 AI 客户端重新加载 MCP 连接或重启客户端,脚本无法替宿主重启已加载的 MCP 进程。
创建批次时必须提供唯一 `request_id`;请求超时后可以按编号查询或用相同内容重发,避免重复用例。创建只保存草稿,`start` 才执行。AI 无需先制造 Excel,也可直接提交 JSON;字段与示例见 [Skill 批量任务参考](../skills/artemis-automation/references/batches.md)。 创建批次时必须提供唯一 `request_id`;请求超时后可以按编号查询或用相同内容重发,避免重复用例。创建只保存草稿,`start` 才执行。AI 无需先制造 Excel,也可直接提交 JSON;字段与示例见 [Skill 批量任务参考](../skills/artemis-automation/references/batches.md)。
报告导出到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。 报告按任务或批次归档到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接;删除批次时会清理对应导出副本。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复,用 `guidance_history` 核对是否领取,或用 `resume` 单独恢复。 模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复;运行中的模型思考会中断并重新观察设备,已经开始的设备动作会安全结束。用 `guidance_history` 核对指导是否被领取、在任务流程中核对实际执行情况,或用 `resume` 单独恢复。
批次任务在队列中使用“用例编号 + 用例名称”显示,提示词仍是模型实际收到的内容。任务历史展示失败原因、完成时间和总耗时;导出全部资料时以用例编号命名目录。删除非活跃批次会同步清理其任务记录、步骤、截图、录屏及生成的报告,删除后不能通过旧 `request_id` 重复执行。
保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。 保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
+4 -2
View File
@@ -20,12 +20,14 @@
4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。 4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。
5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。 5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。
同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次只停止后续派发。单个任务可在步骤间安全暂停,释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导在下一步执行前生效,不打断当前步骤。暂停等待和重新排队时间不计入该任务执行时限。 同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次只停止后续派发。单个任务可在安全边界暂停并释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导会中断当前模型思考或只读诊断,重新观察画面;已经开始的设备动作会安全结束。网页流程会显示指导的提交与领取状态,仍应检查后续动作是否符合指导。暂停等待和重新排队时间不计入该任务执行时限。
服务重启后保留批次,等待用户继续未开始的用例。正在执行或暂停的任务标记中断,不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。 服务重启后保留批次,等待用户继续未开始的用例。正在执行或暂停的任务标记中断,不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。 每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
队列中批量任务按“用例编号 + 测试用例”显示,悬停可看完整提示词;失败原因、完成时间及总耗时在历史中可查看。单任务“下载全部资料”以用例编号命名压缩包内目录。删除已结束的批次会同步删除关联任务记录、步骤、截图、录屏、报告和 MCP 导出副本;正在执行或排队的批次不能删除。
## AI 使用 ## AI 使用
通过 MCP 可直接提交结构化批次、检索历史和导出报告;见 [AI 自动化与 Skill](ai-automation.md)。操作回放已移除,旧记录仅供历史查看,不能继续或重新执行。 通过 MCP 可直接提交结构化批次、检索历史和导出报告;见 [AI 自动化与 Skill](ai-automation.md)。操作回放已移除,旧记录仅供历史查看,不能继续或重新执行。
@@ -34,4 +36,4 @@
`.env` 可配置 `ARTEMIS_BATCH_TASK_TIMEOUT`(默认 1800 秒)、`ARTEMIS_IMPORT_MAX_ROWS`(默认 1000 行)和 `ARTEMIS_IMPORT_MAX_BYTES`(默认 10 MiB)。修改后重启服务。网页的单批次超时设置优先于环境默认值。 `.env` 可配置 `ARTEMIS_BATCH_TASK_TIMEOUT`(默认 1800 秒)、`ARTEMIS_IMPORT_MAX_ROWS`(默认 1000 行)和 `ARTEMIS_IMPORT_MAX_BYTES`(默认 10 MiB)。修改后重启服务。网页的单批次超时设置优先于环境默认值。
批次、任务元数据和报告偏好保存在现有 SQLite 数据库的 `automation_records`、`automation_jobs` 表。清空任务历史同时清除批次与执行元数据;已导出的文件保留。 批次、任务元数据和报告偏好保存在现有 SQLite 数据库的 `automation_records`、`automation_jobs` 表。全局“清空任务历史”会清除批次与执行元数据;它与上文的单批次删除不同,已导出的文件会保留。
+13
View File
@@ -0,0 +1,13 @@
# ARTEMIS-Eapil 1.1.2
## 修复
- Windows 一键脚本在依赖同步后,为已检测到的 AI 客户端安装或更新 ARTEMIS MCP。旧配置指向其他解压目录时,会迁移到当前目录,并更新 Python、启动参数、环境路径和工具清单。
- 同步保留已有密钥、禁用状态、其他 MCP 服务及自定义设置。自定义启动命令会提示跳过;配置无法安全解析时明确停止启动,原文件保持不变。仅需网页时可用 `-SkipMcpSync`。
- 源码升级后会重新载入新版启动脚本再同步 MCP。客户端需重新加载 MCP 或重启,才能让已运行的旧 MCP 进程退出。
## 安装
下载发布附件 `artemis-eapil-1.1.2-source.zip`,解压后运行 `start.bat` 或国内镜像入口 `start-cn.bat`。附件包含精简 Git 仓库,后续可通过启动脚本检查更新。Gitea 自动生成的“源代码 ZIP”没有 `.git`,不能通过脚本拉取更新。
后端默认测试 2170 项通过、6 项跳过;Ruff、质量基线、核心类型检查通过。附件同时提供 SHA-256 校验文件。
+14
View File
@@ -0,0 +1,14 @@
# ARTEMIS-Eapil 1.2.0
## 工作区改进
- 批量任务在队列和历史中显示“用例编号 + 测试用例”;悬停标题可查看完整提示词。任务状态、完成时间、总耗时和失败原因更易辨认。
- 超时失败会写明原因和批次时限。单任务资料 ZIP 以用例编号命名目录。
- 删除已结束的批次会清理关联任务、步骤、截图、录屏和报告,包括 MCP 导出副本;仍被其他任务引用的截图会保留。正在执行或排队的批次不能删除。
- 网页或 MCP 提交指导时,中断当前模型思考并重新观察设备。已开始的设备动作安全结束;流程显示指导提交、领取和后续动作。
## 安装
下载附件 `artemis-eapil-1.2.0-source.zip`,解压后运行 `start.bat`;国内网络可运行 `start-cn.bat`。附件包含精简 Git 仓库,可供后续启动脚本检查更新。Gitea 自动生成的源码 ZIP 不含 `.git`,请使用发布附件。
升级后刷新网页,并在 AI 客户端重新加载 MCP。验证:Python 默认测试 2177 项通过、6 项跳过;前端 149 项通过且生产构建成功;Ruff、质量基线和核心类型检查通过。
+24 -7
View File
@@ -67,10 +67,16 @@ async def _request(method: str, route: str, **kwargs) -> Any:
return response.json() return response.json()
async def _download(route: str, filename: str) -> dict: async def _download(
route: str,
filename: str,
*,
owner_kind: Literal["sessions", "batches"] | None = None,
owner_id: str | None = None,
) -> dict:
url = await _base_url() url = await _base_url()
EXPORT_DIR.mkdir(parents=True, exist_ok=True) directory = EXPORT_DIR / owner_kind / _id(owner_id) if owner_kind else EXPORT_DIR
path = EXPORT_DIR / f"{uuid4().hex[:12]}-{filename}" path = directory / f"{uuid4().hex[:12]}-{filename}"
partial = path.with_suffix(path.suffix + ".part") partial = path.with_suffix(path.suffix + ".part")
try: try:
async with httpx.AsyncClient(base_url=url, timeout=120, trust_env=False) as client: async with httpx.AsyncClient(base_url=url, timeout=120, trust_env=False) as client:
@@ -78,6 +84,7 @@ async def _download(route: str, filename: str) -> dict:
if response.is_error: if response.is_error:
await response.aread() await response.aread()
_check(response) _check(response)
directory.mkdir(parents=True, exist_ok=True)
with partial.open("wb") as stream: with partial.open("wb") as stream:
async for chunk in response.aiter_bytes(): async for chunk in response.aiter_bytes():
stream.write(chunk) stream.write(chunk)
@@ -198,8 +205,8 @@ async def mobile_batch(
use mobile_manage_task(stop) to stop that task. resume never repeats finished cases. use mobile_manage_task(stop) to stop that task. resume never repeats finished cases.
retry_failed creates a new draft only after an explicit rerun request; it is not retry_failed creates a new draft only after an explicit rerun request; it is not
transport-idempotent, so after an uncertain response use list instead of retrying. transport-idempotent, so after an uncertain response use list instead of retrying.
delete removes a non-active batch record after an explicit user request; it keeps delete removes a non-active batch and its task sessions, reports and files after
already executed task sessions and reports. Confirm the batch ID with get first. an explicit user request. Confirm the batch ID with get first; deletion is irreversible.
Restarted batches remain paused. No automatic failed-case retries or action replay. Restarted batches remain paused. No automatic failed-case retries or action replay.
""" """
if action == "create": if action == "create":
@@ -270,7 +277,12 @@ async def mobile_export_report(
return { return {
"batch_id": bid, "batch_id": bid,
"files": [ "files": [
await _download(f"/api/automation/batches/{bid}/archive", f"batch-{bid}.zip") await _download(
f"/api/automation/batches/{bid}/archive",
f"batch-{bid}.zip",
owner_kind="batches",
owner_id=bid,
)
], ],
} }
sid = _id(session_id) sid = _id(session_id)
@@ -286,10 +298,15 @@ async def mobile_export_report(
): ):
raise ValueError("formats 需选择 docx、xlsx、screenshots、video 或 all") raise ValueError("formats 需选择 docx、xlsx、screenshots、video 或 all")
report = await _request("GET", f"/api/automation/reports/{sid}") report = await _request("GET", f"/api/automation/reports/{sid}")
from apps.admin_console.services.report_export import archive_case_name
label = archive_case_name(report)
files = [ files = [
await _download( await _download(
f"/api/automation/reports/{sid}/{fmt}", f"/api/automation/reports/{sid}/{fmt}",
f"task-{sid}.{fmt}" if fmt in {"docx", "xlsx"} else f"task-{sid}-{fmt}.zip", f"task-{sid}.{fmt}" if fmt in {"docx", "xlsx"} else f"{label}-{fmt}.zip",
owner_kind="sessions",
owner_id=sid,
) )
for fmt in dict.fromkeys(chosen) for fmt in dict.fromkeys(chosen)
] ]
+5 -3
View File
@@ -282,8 +282,10 @@ def mobile_manage_task(
- **'resume'**: Queues a paused task for its device again; optional `instruction` - **'resume'**: Queues a paused task for its device again; optional `instruction`
provides guidance before the next action. provides guidance before the next action.
- **'inject_instruction'**: Injects real-time guidance mid-flight when the - **'inject_instruction'**: Injects real-time guidance mid-flight when the
subagent errs, stalls, or loops. Applied at the start of the next subagent errs, stalls, or loops. An in-flight model response or read-only
planning turn (Pro) or the next reactive loop (Flash). Requires helper is cancelled immediately, then the runner observes the device again
and consumes the new guidance. An already-started device action finishes
safely before the runner changes direction. Requires
`instruction` (unless `release_loop=True`). To end a continuous `instruction` (unless `release_loop=True`). To end a continuous
monitoring task ([Loop:continuous] milestone) gracefully, you MUST pass monitoring task ([Loop:continuous] milestone) gracefully, you MUST pass
`release_loop=True` — this is the only signal that unlocks the `release_loop=True` — this is the only signal that unlocks the
@@ -697,7 +699,7 @@ def mobile_manage_task(
return { return {
"trace_id": trace_id, "trace_id": trace_id,
"status": current_status, "status": current_status,
"message": f"Successfully injected instruction{suffix}: '{instruction}'", "message": f"Guidance accepted and current model step interrupted{suffix}; check guidance_history for consumption.",
} }
except Exception as e: except Exception as e:
return { return {
+1 -1
View File
@@ -14,7 +14,7 @@
[project] [project]
name = "artemis" name = "artemis"
version = "1.1.1" version = "1.2.0"
description = "Autonomous AI Assistant for Mobile Automation" description = "Autonomous AI Assistant for Mobile Automation"
authors = [ authors = [
+11 -4
View File
@@ -18,6 +18,7 @@ param(
[switch]$NoOpen = $false, [switch]$NoOpen = $false,
[switch]$SetupOnly = $false, [switch]$SetupOnly = $false,
[switch]$SkipUpdate = $false, [switch]$SkipUpdate = $false,
[switch]$SkipMcpSync = $false,
[ValidateSet('Official', 'China')][string]$Mirror [ValidateSet('Official', 'China')][string]$Mirror
) )
@@ -50,6 +51,7 @@ if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) {
$reloadArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $ScriptDir 'start.ps1'), '-Port', $Port, '-SkipUpdate') $reloadArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $ScriptDir 'start.ps1'), '-Port', $Port, '-SkipUpdate')
if ($NoOpen) { $reloadArgs += '-NoOpen' } if ($NoOpen) { $reloadArgs += '-NoOpen' }
if ($SetupOnly) { $reloadArgs += '-SetupOnly' } if ($SetupOnly) { $reloadArgs += '-SetupOnly' }
if ($SkipMcpSync) { $reloadArgs += '-SkipMcpSync' }
if ($Mirror) { $reloadArgs += @('-Mirror', $Mirror) } if ($Mirror) { $reloadArgs += @('-Mirror', $Mirror) }
& powershell.exe @reloadArgs & powershell.exe @reloadArgs
exit $LASTEXITCODE exit $LASTEXITCODE
@@ -353,10 +355,15 @@ if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) {
Write-Host (Get-LaunchText ' [OK] Created .env from .env.example. Configure your model in the web UI.' ' [完成] 已按模板创建 .env;请在网页中配置模型。') -ForegroundColor Green Write-Host (Get-LaunchText ' [OK] Created .env from .env.example. Configure your model in the web UI.' ' [完成] 已按模板创建 .env;请在网页中配置模型。') -ForegroundColor Green
} }
} }
Write-Host (Get-LaunchText ' [CHECK] Refreshing already-configured MCP clients...' ' [检查] 正在检查并更新已有的 MCP 客户端配置...') -ForegroundColor Cyan if (-not $SkipMcpSync) {
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp Write-Host (Get-LaunchText ' [CHECK] Installing/updating MCP for detected AI clients...' ' [检查] 正在为检测到的 AI 客户端安装或更新 MCP...') -ForegroundColor Cyan
if ($LASTEXITCODE -ne 0) { & $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
Write-Host (Get-LaunchText ' [WARN] MCP refresh failed. Existing client settings were not replaced; check the errors above.' ' [提示] MCP 配置更新失败,原配置仍保留;请查看上方错误。') -ForegroundColor Yellow if ($LASTEXITCODE -ne 0) {
Write-Host (Get-LaunchText ' [ERROR] MCP synchronization failed. Fix the configuration above, or use -SkipMcpSync to run the web UI without MCP.' ' [错误] MCP 同步失败。请修复上方配置;仅需启动网页时可使用 -SkipMcpSync。') -ForegroundColor Red
exit 1
}
} else {
Write-Host (Get-LaunchText ' [SKIP] MCP synchronization was skipped.' ' [跳过] 已跳过 MCP 同步。') -ForegroundColor Yellow
} }
if ($SetupOnly) { if ($SetupOnly) {
Write-Host (Get-LaunchText ' [OK] Setup complete. Run start.bat to launch Artemis.' ' [完成] 环境已准备好。运行 start-cn.bat 启动 ARTEMIS。') -ForegroundColor Green Write-Host (Get-LaunchText ' [OK] Setup complete. Run start.bat to launch Artemis.' ' [完成] 环境已准备好。运行 start-cn.bat 启动 ARTEMIS。') -ForegroundColor Green
+279 -101
View File
@@ -1,4 +1,4 @@
"""Check an existing checkout and refresh only already-configured local MCP clients.""" """Check an existing checkout and synchronize installed local MCP clients."""
from __future__ import annotations from __future__ import annotations
@@ -140,10 +140,10 @@ def update_source(root: Path, port: int = 8000) -> int:
def _atomic_write(path: Path, text: str) -> None: def _atomic_write(path: Path, text: str) -> None:
if path.read_text(encoding="utf-8") == text: if path.is_file() and path.read_text(encoding="utf-8") == text:
return return
backup = path.with_name(path.name + ".artemis-update.bak") backup = path.with_name(path.name + ".artemis-update.bak")
if not backup.exists(): if path.is_file() and not backup.exists():
shutil.copy2(path, backup) shutil.copy2(path, backup)
fd, name = tempfile.mkstemp(prefix=".artemis-update-", dir=path.parent) fd, name = tempfile.mkstemp(prefix=".artemis-update-", dir=path.parent)
try: try:
@@ -154,87 +154,120 @@ def _atomic_write(path: Path, text: str) -> None:
Path(name).unlink(missing_ok=True) Path(name).unlink(missing_ok=True)
def _belongs_to_project(config: dict, root: Path) -> bool: def _is_standard_local_artemis(config: dict) -> bool:
if config.get("url"): if config.get("url") or config.get("type") not in (None, "stdio"):
return False return False
args = config.get("args", []) args = config.get("args", [])
if args[:2] not in (["-m", "mcp_server"], ["-m", "mcp_server.server"]): # Running this launcher selects this checkout, including after a user moves
# A hand-written wrapper/uv/SSE command may need custom arguments; leave it alone. # a release ZIP to a new directory. Do not take over custom wrappers/SSE.
return False return args in (["-m", "mcp_server"], ["-m", "mcp_server.server"])
candidates = [config.get("cwd"), config.get("env", {}).get("PYTHONPATH")]
for value in candidates:
if isinstance(value, str) and Path(value).resolve() == root.resolve():
return True
command = config.get("command")
return (
isinstance(command, str)
and Path(command).resolve().parent == (root / ".venv" / "Scripts").resolve()
)
def _tool_updates(config: dict, names: tuple[str, ...]) -> dict: def _updated_entry(config: dict, template: dict, root: Path, names: tuple[str, ...]) -> dict:
updates = {} updated = {**config, "command": template["command"], "args": template["args"]}
if "enabled_tools" in config: if "cwd" in template or "cwd" in config:
updates["enabled_tools"] = list(dict.fromkeys([*config["enabled_tools"], *names])) updated["cwd"] = str(root)
if isinstance(config.get("tools"), dict): environment = config.get("env", {})
updates["tools"] = {**{name: {"eager": True} for name in names}, **config["tools"]} if not isinstance(environment, dict):
return updates raise ValueError("The existing Artemis MCP environment is not an object")
updated["env"] = {**environment, **template["env"]}
if "enabled_tools" in template or "enabled_tools" in config:
updated["enabled_tools"] = list(names)
if "tools" in template or "tools" in config:
current_tools = config.get("tools", {})
if not isinstance(current_tools, dict):
raise ValueError("The existing Artemis MCP tools setting is not an object")
updated["tools"] = {name: {"eager": True, **current_tools.get(name, {})} for name in names}
for key in ("type", "required", "startup_timeout_sec", "connectionTimeoutMs"):
if key in template and key not in updated:
updated[key] = template[key]
return updated
def refresh_json( def refresh_json(
path: Path, keys: tuple[str, ...], root: Path, python: str, names: tuple[str, ...] path: Path,
) -> bool: keys: tuple[str, ...],
if not path.is_file(): root: Path,
python: str,
names: tuple[str, ...],
template: dict | None = None,
install_missing: bool = False,
) -> bool | None:
if not path.is_file() and not install_missing:
return False return False
raw = path.read_text(encoding="utf-8") raw = path.read_text(encoding="utf-8") if path.is_file() else "{}"
data = json.loads(raw) data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError("MCP configuration must be a JSON object")
parent = data parent = data
for key in keys: for key in keys:
parent = parent.get(key, {}) child = parent.get(key)
if child is None and install_missing:
child = parent[key] = {}
if not isinstance(child, dict):
raise ValueError(f"Invalid MCP configuration section: {key}")
parent = child
config = parent.get("artemis") config = parent.get("artemis")
if not isinstance(config, dict) or not _belongs_to_project(config, root): if config is None and install_missing:
return False if template is None:
updated = {**config, "command": python, **_tool_updates(config, names)} raise ValueError("A standard MCP template is required for installation")
updated = template
elif isinstance(config, dict) and _is_standard_local_artemis(config):
if template is None:
template = {
"command": python,
"args": ["-m", "mcp_server"],
"env": {"PYTHONPATH": str(root)},
}
updated = _updated_entry(config, template, root, names)
elif config is not None and not isinstance(config, dict):
raise ValueError("The Artemis MCP configuration is not an object")
else:
return None
if updated == config: if updated == config:
return False return False
parent["artemis"] = updated parent["artemis"] = updated
path.parent.mkdir(parents=True, exist_ok=True)
_atomic_write(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n") _atomic_write(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
return True return True
def refresh_codex(path: Path, root: Path, python: str, names: tuple[str, ...]) -> bool: def _toml_value(value: object) -> str:
if not path.is_file(): if isinstance(value, bool):
return False return "true" if value else "false"
raw = path.read_text(encoding="utf-8") return json.dumps(value, ensure_ascii=False)
config = tomllib.loads(raw).get("mcp_servers", {}).get("artemis")
if not isinstance(config, dict) or not _belongs_to_project(config, root):
return False def _toml_inline_table(values: dict) -> str:
wanted = {"command": python, **_tool_updates(config, names)} return "{ " + ", ".join(f"{key} = {_toml_value(value)}" for key, value in values.items()) + " }"
wanted = {key: value for key, value in wanted.items() if config.get(key) != value}
if not wanted:
return False def _replace_toml_tables(raw: str, assignments: dict[str, dict]) -> str:
"""Update only Artemis TOML tables; preserve every unrelated section and setting."""
lines = raw.splitlines(keepends=True) lines = raw.splitlines(keepends=True)
inside = False output: list[str] = []
found = set() section = ""
output = [] found: dict[str, set[str]] = {name: set() for name in assignments}
seen: set[str] = set()
def append_missing(): def append_missing() -> None:
for key, value in wanted.items(): if section in assignments:
if key not in found: for key, value in assignments[section].items():
output.append(f"{key} = {json.dumps(value, ensure_ascii=False)}\n") if key not in found[section]:
found.add(key) rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
output.append(f"{key} = {rendered}\n")
found[section].add(key)
# Replace complete assignment spans, including multiline arrays, using TOML parsing.
index = 0 index = 0
while index < len(lines): while index < len(lines):
line = lines[index] line = lines[index]
if line.lstrip().startswith("["): if line.lstrip().startswith("["):
if inside:
append_missing() append_missing()
header = re.sub(r"[\s\"']", "", line.split("#", 1)[0]).strip() header = re.sub(r"[\s\"']", "", line.split("#", 1)[0]).strip()
inside = header == "[mcp_servers.artemis]" section = header[1:-1] if header.startswith("[") and header.endswith("]") else ""
match = re.match(r"\s*(\w+)\s*=", line) if inside else None seen.add(section)
if match and match[1] in wanted: match = re.match(r"\s*(\w+)\s*=", line) if section in assignments else None
if match and match[1] in assignments[section]:
key = match[1] key = match[1]
original = line original = line
while True: while True:
@@ -246,91 +279,236 @@ def refresh_codex(path: Path, root: Path, python: str, names: tuple[str, ...]) -
if index >= len(lines): if index >= len(lines):
raise ValueError("Cannot safely parse existing MCP setting") raise ValueError("Cannot safely parse existing MCP setting")
original += lines[index] original += lines[index]
output.append(f"{key} = {json.dumps(wanted[key], ensure_ascii=False)}\n") value = assignments[section][key]
found.add(key) rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
output.append(f"{key} = {rendered}\n")
found[section].add(key)
else: else:
output.append(line) output.append(line)
index += 1 index += 1
if inside:
append_missing() append_missing()
updated = "".join(output) for name, values in assignments.items():
parsed = tomllib.loads(updated) if name not in seen:
if any(parsed["mcp_servers"]["artemis"].get(k) != v for k, v in wanted.items()): if output and not output[-1].endswith("\n"):
raise ValueError("Could not locate the existing Artemis table safely") output.append("\n")
_atomic_write(path, updated) output.append(f"\n[{name}]\n")
return updated != raw for key, value in values.items():
rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
output.append(f"{key} = {rendered}\n")
return "".join(output)
def refresh_mcp(root: Path) -> None: def refresh_codex(
path: Path,
root: Path,
python: str,
names: tuple[str, ...],
template: dict | None = None,
install_missing: bool = False,
) -> bool | None:
if not path.is_file() and not install_missing:
return False
raw = path.read_text(encoding="utf-8") if path.is_file() else ""
parsed = tomllib.loads(raw)
config = parsed.get("mcp_servers", {}).get("artemis")
if config is None and install_missing:
if template is None:
raise ValueError("A standard MCP template is required for installation")
updated = template
elif isinstance(config, dict) and _is_standard_local_artemis(config):
if template is None:
template = {
"command": python,
"args": ["-m", "mcp_server"],
"cwd": str(root),
"env": {"PYTHONPATH": str(root)},
"enabled_tools": list(names),
}
updated = _updated_entry(config, template, root, names)
else:
return None
top = {
key: value
for key, value in updated.items()
if key != "env" and (not config or config.get(key) != value)
}
environment = updated["env"]
if config and config.get("env") == environment:
env_updates = {}
else:
env_updates = {
key: value
for key, value in environment.items()
if not config or config.get("env", {}).get(key) != value
}
env_header = "mcp_servers.artemis.env"
has_env_table = re.search(r"(?m)^\s*\[mcp_servers\.artemis\.env\]\s*(?:#.*)?$", raw) is not None
if config and "env" in config and not has_env_table and env_updates:
top["env"] = environment
env_updates = {}
assignments = {"mcp_servers.artemis": top}
if env_updates:
assignments[env_header] = env_updates
if not top and not env_updates:
return False
result = _replace_toml_tables(raw, assignments)
actual = tomllib.loads(result)["mcp_servers"]["artemis"]
if any(actual.get(key) != value for key, value in top.items()) or any(
actual.get("env", {}).get(key) != value for key, value in env_updates.items()
):
raise ValueError("Could not safely refresh the Artemis MCP table")
path.parent.mkdir(parents=True, exist_ok=True)
_atomic_write(path, result)
return result != raw
def refresh_mcp(root: Path) -> int:
sys.path.insert(0, str(root)) sys.path.insert(0, str(root))
from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_vscode_user_dir from artemis.interfaces.cli.commands.mcp import (
ARTEMIS_MCP_TOOLS,
_get_config_snippet,
_get_vscode_user_dir,
)
home = Path.home() home = Path.home()
vscode = _get_vscode_user_dir() vscode = _get_vscode_user_dir()
appdata = Path(os.environ.get("APPDATA", home / "AppData/Roaming")) appdata = Path(os.environ.get("APPDATA", home / "AppData/Roaming"))
cline_store = vscode / "globalStorage/saoudrizwan.claude-dev"
roo_store = vscode / "globalStorage/rooveterinaryinc.roo-cline"
paths = [ paths = [
(home / ".gemini/config/mcp_config.json", ("mcpServers",)), (home / ".gemini/config/mcp_config.json", ("mcpServers",), "antigravity", home / ".gemini"),
(home / ".gemini/jetski/mcp_config.json", ("mcpServers",)), (home / ".gemini/jetski/mcp_config.json", ("mcpServers",), "jetski", home / ".gemini"),
(home / ".gemini/antigravity/mcp_config.json", ("mcpServers",)),
(home / ".cursor/mcp.json", ("mcpServers",)),
(home / ".codeium/windsurf/mcp_config.json", ("mcpServers",)),
(home / ".claude.json", ("mcpServers",)),
(appdata / "Claude/claude_desktop_config.json", ("mcpServers",)),
(vscode / "mcp.json", ("servers",)),
(home / ".copilot/mcp-config.json", ("servers",)),
(home / ".cline/data/settings/cline_mcp_settings.json", ("mcpServers",)),
(home / ".cline/mcp.json", ("mcpServers",)),
( (
vscode / "globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", home / ".gemini/antigravity/mcp_config.json",
("mcpServers",), ("mcpServers",),
"antigravity",
home / ".gemini",
),
(home / ".cursor/mcp.json", ("mcpServers",), "cursor", home / ".cursor"),
(
home / ".codeium/windsurf/mcp_config.json",
("mcpServers",),
"windsurf",
home / ".codeium/windsurf",
),
(home / ".claude.json", ("mcpServers",), "claude", home / ".claude"),
(
appdata / "Claude/claude_desktop_config.json",
("mcpServers",),
"claude",
appdata / "Claude",
),
(vscode / "mcp.json", ("servers",), "vscode", vscode),
(home / ".copilot/mcp-config.json", ("servers",), "vscode", home / ".copilot"),
(
home / ".cline/data/settings/cline_mcp_settings.json",
("mcpServers",),
"cline",
home / ".cline",
),
(home / ".cline/mcp.json", ("mcpServers",), "cline", home / ".cline"),
(
cline_store / "settings/cline_mcp_settings.json",
("mcpServers",),
"cline",
cline_store,
), ),
( (
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json", roo_store / "settings/mcp_settings.json",
("mcpServers",), ("mcpServers",),
"roo",
roo_store,
), ),
( (
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json", roo_store / "settings/cline_mcp_settings.json",
("mcpServers",), ("mcpServers",),
"roo",
roo_store,
), ),
(root / ".roo/mcp.json", ("mcpServers",)), (root / ".roo/mcp.json", ("mcpServers",), "roo", root / ".roo"),
(home / ".openclaw/openclaw.json", ("mcp", "servers")), (home / ".openclaw/openclaw.json", ("mcp", "servers"), "openclaw", home / ".openclaw"),
] ]
changed = 0 codex_home = Path(os.environ.get("CODEX_HOME", home / ".codex"))
codex = Path(os.environ.get("CODEX_HOME", home / ".codex")) / "config.toml" paths.append((codex_home / "config.toml", (), "codex", codex_home))
for path, keys in [*paths, (codex, ())]: installed = updated_count = ready = skipped = errors = 0
for path, keys, client, marker in paths:
if not path.is_file() and not marker.is_dir():
continue
existed = path.is_file()
template = _get_config_snippet(client, sys.executable, str(root))
for key in keys:
template = template[key]
template = template["artemis"] if keys else template["mcp_servers"]["artemis"]
try: try:
updated = ( changed = (
refresh_json(path, keys, root, sys.executable, ARTEMIS_MCP_TOOLS) refresh_json(
if keys path,
else refresh_codex(path, root, sys.executable, ARTEMIS_MCP_TOOLS) keys,
root,
sys.executable,
ARTEMIS_MCP_TOOLS,
template=template,
install_missing=True,
) )
if updated: if keys
else refresh_codex(
path,
root,
sys.executable,
ARTEMIS_MCP_TOOLS,
template=template,
install_missing=True,
)
)
if changed:
print( print(
launch_text( launch_text(
f"[UPDATED] Existing MCP configuration: {path}", f"[{'UPDATED' if existed else 'INSTALLED'}] MCP configuration: {path}",
f"[已更新] 已有 MCP 配置:{path}", f"[{'已更新' if existed else '已安装'}] MCP 配置:{path}",
) )
) )
changed += 1 if existed:
updated_count += 1
else:
installed += 1
elif changed is None:
print(
launch_text(
f"[SKIP] Custom Artemis MCP entry in {path}; use the manual installer for this client.",
f"[跳过] {path} 使用自定义 ARTEMIS MCP 启动方式;请手动检查此客户端。",
)
)
skipped += 1
else:
ready += 1
except (OSError, ValueError, TypeError, AttributeError): except (OSError, ValueError, TypeError, AttributeError):
print( print(
launch_text( launch_text(
f"[WARN] Could not safely refresh {path}; original configuration preserved.", f"[ERROR] Could not safely synchronize {path}; original configuration preserved.",
f"[提示] 无法安全更新 {path};原配置已保留。", f"[错误] 无法安全同步 {path};原配置已保留。",
)
)
errors += 1
print(
launch_text(
f"[CHECK] MCP clients: installed {installed}, updated {updated_count}, already current {ready}, custom skipped {skipped}, errors {errors}.",
f"[检查] MCP 客户端:新装 {installed} 个、更新 {updated_count} 个、已是最新 {ready} 个、自定义跳过 {skipped} 个、失败 {errors} 个。",
) )
) )
print( print(
launch_text( launch_text(
f"[OK] MCP source is current; refreshed {changed} existing client configuration(s). Reload the MCP connection in your AI client after a source update.", "[ACTION] Reload or restart your AI client to load the current MCP process and tool schemas.",
f"[完成] MCP 源码已就绪;更新了 {changed} 个已有客户端配置。源码升级后请在 AI 客户端重新加载 MCP。", "[操作] 请在 AI 客户端重新加载 MCP 或重启客户端,使新版进程和工具签名生效。",
) )
) )
if not (installed or updated_count or ready or skipped or errors):
print( print(
launch_text( launch_text(
"[TIP] For a new client run: uv run artemis mcp --install <client>", "[TIP] No supported client detected; install one with: uv run artemis mcp --install <client>",
"[提示] 新客户端可执行:uv run artemis mcp --install <client>", "[提示] 未检测到受支持的客户端;可执行 uv run artemis mcp --install <client>。",
) )
) )
return 1 if errors else 0
def frontend_fingerprint(root: Path) -> str: def frontend_fingerprint(root: Path) -> str:
@@ -362,7 +540,7 @@ if __name__ == "__main__":
if args.action == "source": if args.action == "source":
sys.exit(update_source(args.root, args.port)) sys.exit(update_source(args.root, args.port))
elif args.action == "mcp": elif args.action == "mcp":
refresh_mcp(args.root) sys.exit(refresh_mcp(args.root))
else: else:
marker = args.root / "apps/showcase_ui/node_modules/.artemis-source-hash" marker = args.root / "apps/showcase_ui/node_modules/.artemis-source-hash"
current = frontend_fingerprint(args.root) current = frontend_fingerprint(args.root)
+2 -2
View File
@@ -14,7 +14,7 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
3. 首次使用、工具出错、任务拒绝或设备异常,先 `mobile_diagnose`。按 `next_steps` 的依赖顺序处理;`attempt_fix=true` 可尝试工具自带修复。只有凭据相关故障才使用 `verify_credentials=true`,观察异常才使用 `probe_device=true`。不要盲目重发任务。MCP 不可用时在项目目录运行 `uv run artemis doctor` 并检查宿主 MCP 日志。 3. 首次使用、工具出错、任务拒绝或设备异常,先 `mobile_diagnose`。按 `next_steps` 的依赖顺序处理;`attempt_fix=true` 可尝试工具自带修复。只有凭据相关故障才使用 `verify_credentials=true`,观察异常才使用 `probe_device=true`。不要盲目重发任务。MCP 不可用时在项目目录运行 `uv run artemis doctor` 并检查宿主 MCP 日志。
4. 选择执行模式:Flash 适合路径明确的界面操作;Pro 适合动态分支、持续计划、ADB/日志诊断或需要 Checker 明确校验的任务。**`mobile_run_task.model` 的 `flash`/`pro` 是执行模式,不是 API 模型名称**。步数长不自动等于需要 Pro;视频能力只有显式启用后才可用。 4. 选择执行模式:Flash 适合路径明确的界面操作;Pro 适合动态分支、持续计划、ADB/日志诊断或需要 Checker 明确校验的任务。**`mobile_run_task.model` 的 `flash`/`pro` 是执行模式,不是 API 模型名称**。步数长不自动等于需要 Pro;视频能力只有显式启用后才可用。
5. 单任务用 `mobile_run_task`;批量任务用 `mobile_batch`,读 [批量任务与交付](references/batches.md)。AI 可直接提交结构化用例,不必先创建 Excel。用户提供 Excel 时先 `mobile_task_file(action="preview", path=绝对路径)`,修正所有行错误后提交。提示词列原样作为目标,前置约束列只作元数据。 5. 单任务用 `mobile_run_task`;批量任务用 `mobile_batch`,读 [批量任务与交付](references/batches.md)。AI 可直接提交结构化用例,不必先创建 Excel。用户提供 Excel 时先 `mobile_task_file(action="preview", path=绝对路径)`,修正所有行错误后提交。提示词列原样作为目标,前置约束列只作元数据。
6. 单任务返回 `trace_id` 后,用 `mobile_manage_task(action="status", trace_id=...)` 跟踪;网页任务也可用其任务编号。批次用 `mobile_batch(action="get", batch_id=...)`。需要人工补充时用 `pause`,其返回的 `pausing` 只表示请求已接收;轮询到 `task_paused` 才表示设备已释放。Pro 等待模型回复或只读诊断时可中断,已开始的设备动作会先安全结束。`inject_instruction` 可在运行中指导下一步,暂停中提交则恢复;`guidance_history` 的 `pending/consumed` 区分提交和执行端领取,无新指导用 `resume`。恢复进入设备 FIFO 队尾并重新观察手机状态。尊重工具建议的等待周期,避免连续高频轮询;停滞或失败先诊断,不把同一业务任务自动重提。 6. 单任务返回 `trace_id` 后,用 `mobile_manage_task(action="status", trace_id=...)` 跟踪;网页任务也可用其任务编号。批次用 `mobile_batch(action="get", batch_id=...)`。需要人工补充时用 `pause`,其返回的 `pausing` 只表示请求已接收;轮询到 `task_paused` 才表示设备已释放。`inject_instruction` 会立即取消进行中的模型回复或只读诊断并重新观察手机,已开始的设备动作会先安全结束;暂停中提交则恢复。`guidance_history` 的 `pending/consumed` 区分提交和执行端领取,无新指导用 `resume`。恢复进入设备 FIFO 队尾并重新观察手机状态。尊重工具建议的等待周期,避免连续高频轮询;停滞或失败先诊断,不把同一业务任务自动重提。
## 结果、证据与交付 ## 结果、证据与交付
@@ -32,6 +32,6 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。 - API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
- 录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。 - 录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。
- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。 - 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。
- 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。用户明确要求删除批次记录时先 `mobile_batch(action="get")` 核对编号,再用 `delete`;执行中/排队中的批次不能删,已执行的任务历史和报告会保留。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。 - 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。用户明确要求删除批次记录时先 `mobile_batch(action="get")` 核对编号,再用 `delete`;执行中/排队中的批次不能删,删除会清除该批次的任务历史、报告和文件。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。 需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。
@@ -56,7 +56,7 @@
- 单项 `pending/queued/running/pausing/task_paused/resuming/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`pausing` 是请求已发出而设备仍被占用,`task_paused` 才表示设备已释放;`finalizing` 在生成报告,不能提前宣称报告已完成。 - 单项 `pending/queued/running/pausing/task_paused/resuming/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`pausing` 是请求已发出而设备仍被占用,`task_paused` 才表示设备已释放;`finalizing` 在生成报告,不能提前宣称报告已完成。
- 批次 `pause` 保留当前执行,只暂停后续派发;单项 `mobile_manage_task(pause)` 会在安全边界释放设备,Pro 的模型等待与只读诊断可中断。恢复后重新观察画面,并排到设备队尾,不阻塞后续项。`mobile_manage_task(guidance_history)` 可核对指导是否从 `pending` 变为 `consumed`,但仍需检查后续操作是否符合指导。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。 - 批次 `pause` 保留当前执行,只暂停后续派发;单项 `mobile_manage_task(pause)` 会在安全边界释放设备,Pro 的模型等待与只读诊断可中断。恢复后重新观察画面,并排到设备队尾,不阻塞后续项。`mobile_manage_task(guidance_history)` 可核对指导是否从 `pending` 变为 `consumed`,但仍需检查后续操作是否符合指导。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。
- `resume` 只继续未开始项;`retry_failed` 复制失败、取消和中断项为新草稿,不修改原结果。必须来自用户的明确重跑请求。该动作不提供创建去重,响应不确定时先 `list` 查找新批次,不自动重复调用。 - `resume` 只继续未开始项;`retry_failed` 复制失败、取消和中断项为新草稿,不修改原结果。必须来自用户的明确重跑请求。该动作不提供创建去重,响应不确定时先 `list` 查找新批次,不自动重复调用。
- 用户明确要求删除批次记录时,先 `get` 核对批次编号与状态,再 `delete`;正在执行、排队或等待指导的批次不能删除,已经运行过的任务历史及报告不会随批次记录一起消失。 - 用户明确要求删除批次记录时,先 `get` 核对批次编号与状态,再 `delete`;正在执行、排队或等待指导的批次不能删除。删除会同时移除该批次任务历史、报告和文件,无法撤销。
## 报告 ## 报告
+2 -1
View File
@@ -25,8 +25,9 @@ echo -SetupOnly 安装或检查环境,不启动网页
echo -Port 8001 使用指定端口,默认 8000 echo -Port 8001 使用指定端口,默认 8000
echo -NoOpen 启动服务后不自动打开浏览器 echo -NoOpen 启动服务后不自动打开浏览器
echo -SkipUpdate 跳过 Git 源码更新检查 echo -SkipUpdate 跳过 Git 源码更新检查
echo -SkipMcpSync 仅启动网页,跳过 MCP 安装和更新
echo -Help 显示本说明 echo -Help 显示本说明
echo. echo.
echo 国内镜像设置:config\downloads.json echo 国内镜像设置:config\downloads.json
echo 首次运行需联网;后续运行会检查源码、依赖和已有 MCP 配置。 echo 首次运行需联网;后续运行会检查源码、依赖,并同步已安装 AI 客户端的 MCP。
endlocal & exit /b 0 endlocal & exit /b 0
+116 -7
View File
@@ -2,8 +2,10 @@
from io import BytesIO from io import BytesIO
import json import json
import sqlite3
import time import time
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from uuid import UUID
import zipfile import zipfile
from docx import Document from docx import Document
@@ -125,19 +127,44 @@ def test_preferences_and_required_device(client):
assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422 assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422
def test_batch_delete_retains_executed_task_and_prevents_request_replay(client, isolated): def test_batch_delete_removes_tasks_and_files_and_prevents_request_replay(client, isolated):
created = client.post( created = client.post(
"/api/automation/batches", json=payload(request_id="delete-example") "/api/automation/batches", json=payload(request_id="delete-example")
).json() ).json()
first, second = isolated.jobs(created["id"]) first, second = isolated.jobs(created["id"])
isolated.save_job({**first, "status": "completed"}) isolated.save_job({**first, "status": "completed"})
trace_dir = reports.TRACES_PATH / first["session_id"]
trace_dir.mkdir(parents=True)
(trace_dir / "result.json").write_text("{}", encoding="utf-8")
report_dir = reports.EXPORT_ROOT / "sessions" / first["session_id"]
report_dir.mkdir(parents=True)
(report_dir / "任务报告.docx").write_bytes(b"report")
batch_dir = reports.EXPORT_ROOT / "batches" / created["id"]
batch_dir.mkdir(parents=True)
(batch_dir / "批次报告.zip").write_bytes(b"zip")
mcp_task_dir = reports.EXPORT_ROOT.parent / "mcp_exports" / "sessions" / first["session_id"]
mcp_task_dir.mkdir(parents=True)
(mcp_task_dir / "报告.docx").write_bytes(b"report")
mcp_batch_dir = reports.EXPORT_ROOT.parent / "mcp_exports" / "batches" / created["id"]
mcp_batch_dir.mkdir(parents=True)
(mcp_batch_dir / "批次.zip").write_bytes(b"zip")
unrelated = reports.TRACES_PATH / "unrelated-task"
unrelated.mkdir()
(unrelated / "keep.txt").write_text("keep", encoding="utf-8")
response = client.delete(f"/api/automation/batches/{created['id']}") response = client.delete(f"/api/automation/batches/{created['id']}")
assert response.status_code == 200 assert response.status_code == 200
assert client.get(f"/api/automation/batches/{created['id']}").status_code == 404 assert client.get(f"/api/automation/batches/{created['id']}").status_code == 404
assert isolated.job(first["session_id"])["case"]["name"] == "用例甲" assert isolated.job(first["session_id"]) is None
assert isolated.job(second["session_id"]) is None assert isolated.job(second["session_id"]) is None
assert reports.session_report(first["session_id"])["name"] == "用例甲" assert not trace_dir.exists()
assert not report_dir.exists()
assert not batch_dir.exists()
assert not mcp_task_dir.exists()
assert not mcp_batch_dir.exists()
assert (unrelated / "keep.txt").read_text(encoding="utf-8") == "keep"
with pytest.raises(ValueError, match="不存在"):
reports.session_report(first["session_id"])
assert ( assert (
client.post( client.post(
"/api/automation/batches", json=payload(request_id="delete-example") "/api/automation/batches", json=payload(request_id="delete-example")
@@ -158,6 +185,49 @@ def test_batch_delete_rejects_active_jobs(client, isolated, status):
assert len(isolated.jobs(batch["id"])) == 2 assert len(isolated.jobs(batch["id"])) == 2
def test_batch_deletion_reservation_blocks_new_dispatch(isolated):
batch = service.create_batch(payload())
job = isolated.jobs(batch["id"])[0]
isolated.begin_delete_batch(batch["id"])
with pytest.raises(ValueError, match="正在删除"):
isolated.save_job({**job, "status": "queued"})
with pytest.raises(ValueError, match="正在删除"):
isolated.put("batch", batch["id"], {**batch, "status": "running"})
assert isolated.jobs_for_sessions([job["session_id"]])[job["session_id"]]["status"] == "pending"
def test_batch_deletion_preserves_shared_screenshot_until_last_task(client, isolated):
first_batch = service.create_batch(payload())
second_batch = service.create_batch(payload(name="另一批次"))
first = isolated.jobs(first_batch["id"])[0]
other = isolated.jobs(second_batch["id"])[0]
shared_hash = "a" * 64
storage = StorageManager(isolated.path, reports.TRACES_PATH)
image_file = reports.TRACES_PATH / "images" / f"{shared_hash}.jpg"
image_file.parent.mkdir(parents=True)
image_file.write_bytes(b"shared image")
with sqlite3.connect(isolated.path) as conn:
conn.execute("INSERT INTO images(image_name) VALUES (?)", (shared_hash,))
for job in (first, other):
conn.execute(
"INSERT INTO sessions(session_id,initial_goal,start_time,status,device_info) "
"VALUES (?,?,?,?,?)",
(job["session_id"], "观察设置", 1.0, "completed", "{}"),
)
conn.execute(
"INSERT INTO steps(step_id,session_id,pre_image_name) VALUES (?,?,?)",
(job["session_id"], job["session_id"], shared_hash),
)
assert client.delete(f"/api/automation/batches/{first_batch['id']}").status_code == 200
assert image_file.read_bytes() == b"shared image"
assert storage.get_session(UUID(other["session_id"])) is not None
assert client.delete(f"/api/automation/batches/{second_batch['id']}").status_code == 200
assert not image_file.exists()
def test_restart_never_repeats_started_work(isolated): def test_restart_never_repeats_started_work(isolated):
batch = service.create_batch(payload()) batch = service.create_batch(payload())
service.batch_action(batch["id"], "resume") service.batch_action(batch["id"], "resume")
@@ -260,6 +330,41 @@ async def test_report_failure_does_not_strand_job(isolated, monkeypatch):
assert "disk full" in saved["report_error"] assert "disk full" in saved["report_error"]
@pytest.mark.asyncio
async def test_batch_timeout_stays_visible_in_job_report_and_history(client, isolated):
job = isolated.jobs(service.create_batch(payload())["id"])[0]
isolated.save_job(
{**job, "status": "running", "timed_out": True, "error": "任务执行超时(30 秒)"}
)
await service.on_terminal({"session_id": job["session_id"]}, "cancelled")
saved = isolated.job(job["session_id"])
assert saved["status"] == "failed"
assert "超时" in saved["error"]
assert "超时" in reports.session_report(job["session_id"])["error"]
history = client.get("/api/automation/history").json()["items"]
assert any(
item["session_id"] == job["session_id"] and "超时" in item["error"] for item in history
)
@pytest.mark.asyncio
async def test_model_timeout_from_runner_log_reaches_failed_task_and_report(isolated):
job = isolated.jobs(service.create_batch(payload())["id"])[0]
folder = reports.TRACES_PATH / job["session_id"]
folder.mkdir(parents=True)
(folder / "stderr.log").write_text(
"TimeoutError: model request timed out after 300 seconds\n", encoding="utf-8"
)
await service.on_terminal({"session_id": job["session_id"]}, "failed")
saved = isolated.job(job["session_id"])
assert saved["status"] == "failed"
assert "超时" in saved["error"]
assert reports.session_report(job["session_id"])["status"] == "failed"
assert "超时" in reports.session_report(job["session_id"])["error"]
def test_reports_do_not_execute_formulas_or_invent_pass(isolated): def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
batch = service.create_batch(payload()) batch = service.create_batch(payload())
job = isolated.jobs(batch["id"])[0] job = isolated.jobs(batch["id"])[0]
@@ -296,6 +401,7 @@ def test_failed_report_exposes_logged_reason_and_formats_chinese_markdown(isolat
assert "abc123secret" not in reports.failure_reason( assert "abc123secret" not in reports.failure_reason(
folder, "failed", "认证失败 OPENAI_API_KEY=abc123secret Authorization: Bearer abc123secret" folder, "failed", "认证失败 OPENAI_API_KEY=abc123secret Authorization: Bearer abc123secret"
) )
assert "超时" not in reports.failure_reason(folder, "failed", "LLMExhaustedError: 额度不足")
paths = reports.export_session(job["session_id"]) paths = reports.export_session(job["session_id"])
doc = Document(next(path for path in paths if path.suffix == ".docx")) doc = Document(next(path for path in paths if path.suffix == ".docx"))
assert doc.styles["Normal"].font.name == "Microsoft YaHei" assert doc.styles["Normal"].font.name == "Microsoft YaHei"
@@ -325,6 +431,7 @@ def test_task_material_zip_contains_ordered_evidence_and_reports(
client, isolated, tmp_path, monkeypatch client, isolated, tmp_path, monkeypatch
): ):
job = isolated.jobs(service.create_batch(payload())["id"])[0] job = isolated.jobs(service.create_batch(payload())["id"])[0]
isolated.save_job({**job, "case": {**job["case"], "case_id": "1.1"}})
evidence = tmp_path / "evidence" evidence = tmp_path / "evidence"
evidence.mkdir() evidence.mkdir()
first = evidence / "first.png" first = evidence / "first.png"
@@ -343,14 +450,16 @@ def test_task_material_zip_contains_ordered_evidence_and_reports(
assert response.status_code == 200 assert response.status_code == 200
with zipfile.ZipFile(BytesIO(response.content)) as archive: with zipfile.ZipFile(BytesIO(response.content)) as archive:
files = archive.namelist() files = archive.namelist()
assert all(name.startswith("1.1/") for name in files)
assert any(name.endswith(".docx") for name in files) assert any(name.endswith(".docx") for name in files)
assert any(name.endswith(".xlsx") for name in files) assert any(name.endswith(".xlsx") for name in files)
assert files.index("步骤截图/0001_步骤0001_操作前.png") < files.index( assert files.index("1.1/步骤截图/0001_步骤0001_操作前.png") < files.index(
"步骤截图/0002_步骤0002_操作后.png" "1.1/步骤截图/0002_步骤0002_操作后.png"
) )
assert archive.read("任务录屏/0001_recording.mp4") == b"recording.mp4" assert archive.read("1.1/任务录屏/0001_recording.mp4") == b"recording.mp4"
manifest = json.loads(archive.read("文件清单.json")) manifest = json.loads(archive.read("1.1/文件清单.json"))
assert manifest["session_id"] == job["session_id"] assert manifest["session_id"] == job["session_id"]
assert "1.1-all.zip" in response.headers["content-disposition"]
def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated): def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated):
@@ -5,6 +5,14 @@ import pytest
from apps.admin_console.routers import sessions as sessions_router from apps.admin_console.routers import sessions as sessions_router
@pytest.fixture(autouse=True)
def isolate_jobs(monkeypatch):
mocked = MagicMock()
mocked.jobs_for_sessions.return_value = {}
monkeypatch.setattr(sessions_router, "store", mocked)
return mocked
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_sessions_uses_batched_history_metadata(monkeypatch): async def test_list_sessions_uses_batched_history_metadata(monkeypatch):
repo = MagicMock() repo = MagicMock()
@@ -96,3 +104,42 @@ async def test_list_sessions_extracts_device_serial(monkeypatch):
assert result[0]["device_id"] == "63191FDKX00062" assert result[0]["device_id"] == "63191FDKX00062"
assert result[1]["device_serial"] == "emulator-5554" assert result[1]["device_serial"] == "emulator-5554"
assert result[1]["device_id"] == "emulator-5554" assert result[1]["device_id"] == "emulator-5554"
@pytest.mark.asyncio
async def test_list_sessions_uses_batch_title_and_timeout_error(monkeypatch, isolate_jobs):
repo = MagicMock()
repo.get_all_sessions.return_value = [
{
"session_id": "session-case",
"status": "completed",
"start_time": 1.0,
"initial_goal": "打开设置并检查页面",
"device_info": '{"profile":"flash"}',
}
]
repo.get_video_recordings_map.return_value = {}
repo.get_latest_video_recordings_map.return_value = {}
repo.get_agent_trace_names_map.return_value = {}
monkeypatch.setattr(sessions_router, "session_repo", repo)
monkeypatch.setattr(
sessions_router.media_service, "build_video_index", MagicMock(return_value={})
)
isolate_jobs.jobs_for_sessions.return_value = {
"session-case": {
"session_id": "session-case",
"status": "failed",
"ended_at": 65.0,
"error": "任务执行超时(60 秒)",
"case": {"case_id": "1.1", "name": "打开并核对设置", "goal": "打开设置并检查页面"},
}
}
result = await sessions_router.list_sessions()
isolate_jobs.jobs_for_sessions.assert_called_once_with(["session-case"])
assert result[0]["display_name"] == "1.1 打开并核对设置"
assert result[0]["initial_goal"] == "打开设置并检查页面"
assert result[0]["status"] == "failed"
assert result[0]["end_time"] == 65.0
assert "超时" in result[0]["error"]
+8 -2
View File
@@ -80,13 +80,19 @@ async def test_download_writes_actual_binary_atomically(monkeypatch, tmp_path):
monkeypatch.setattr( monkeypatch.setattr(
tools.httpx, "AsyncClient", lambda **kw: client_type(transport=transport, **kw) tools.httpx, "AsyncClient", lambda **kw: client_type(transport=transport, **kw)
) )
result = await tools._download("/api/automation/reports/test/docx", "test.docx") result = await tools._download(
"/api/automation/reports/test/docx",
"test.docx",
owner_kind="sessions",
owner_id="test",
)
from pathlib import Path from pathlib import Path
assert Path(result["path"]).read_bytes() == b"PK\x03\x04report-bytes" assert Path(result["path"]).read_bytes() == b"PK\x03\x04report-bytes"
assert Path(result["path"]).is_absolute() assert Path(result["path"]).is_absolute()
assert Path(result["path"]).parent == tmp_path / "sessions" / "test"
assert result["download_url"] == "http://test/api/automation/reports/test/docx" assert result["download_url"] == "http://test/api/automation/reports/test/docx"
assert not list(tmp_path.glob("*.part")) assert not list(tmp_path.rglob("*.part"))
@pytest.mark.asyncio @pytest.mark.asyncio
+1 -1
View File
@@ -373,7 +373,7 @@ def test_mobile_manage_task_inject_instruction(temp_trace_env):
ok_res = mobile_manage_task( ok_res = mobile_manage_task(
action="inject_instruction", trace_id=trace_id, instruction="Scroll down slowly" action="inject_instruction", trace_id=trace_id, instruction="Scroll down slowly"
) )
assert "Successfully injected" in ok_res["message"] assert "current model step interrupted" in ok_res["message"]
@pytest.mark.asyncio @pytest.mark.asyncio
+29
View File
@@ -116,6 +116,35 @@ async def test_interruptible_call_returns_normal_result_without_pause(tmp_path,
assert await control.run_interruptibly(model_call()) == (False, "current result") assert await control.run_interruptibly(model_call()) == (False, "current result")
@pytest.mark.asyncio
async def test_new_guidance_cancels_stale_model_without_releasing_device(tmp_path, monkeypatch):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
released = []
class Lock:
def release(self):
released.append(True)
control = task_control.TaskControl("task-123", Lock())
started = asyncio.Event()
cancelled = asyncio.Event()
async def stale_model():
started.set()
try:
await asyncio.Event().wait()
finally:
cancelled.set()
call = asyncio.create_task(control.run_interruptibly(stale_model()))
await asyncio.wait_for(started.wait(), 1)
task_control.add_instruction("task-123", "改为检查当前页面", source="mcp")
assert await asyncio.wait_for(call, 1) == (True, None)
assert cancelled.is_set()
assert released == []
assert task_control.consume_instructions(control.directory)["instruction"] == "改为检查当前页面"
def test_guidance_history_distinguishes_submission_from_runner_consumption(tmp_path, monkeypatch): def test_guidance_history_distinguishes_submission_from_runner_consumption(tmp_path, monkeypatch):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path) monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
first = task_control.add_instruction("task-123", "从快捷设置进入", source="web") first = task_control.add_instruction("task-123", "从快捷设置进入", source="web")
+157 -6
View File
@@ -120,7 +120,7 @@ command = "leave-alone"
assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original
def test_json_only_updates_existing_matching_project(tmp_path): def test_json_migrates_existing_standard_mcp_to_current_checkout(tmp_path):
path = tmp_path / "mcp.json" path = tmp_path / "mcp.json"
root = tmp_path / "project" root = tmp_path / "project"
config = { config = {
@@ -132,19 +132,170 @@ def test_json_only_updates_existing_matching_project(tmp_path):
"autoApprove": [], "autoApprove": [],
} }
path.write_text(json.dumps({"mcpServers": {"artemis": config, "other": {"command": "keep"}}})) path.write_text(json.dumps({"mcpServers": {"artemis": config, "other": {"command": "keep"}}}))
assert not update.refresh_json( moved_root = tmp_path / "new release"
path, ("mcpServers",), tmp_path / "different", "new", ("mobile_batch",) assert update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",))
)
assert update.refresh_json(path, ("mcpServers",), root, "new", ("mobile_batch",))
result = json.loads(path.read_text())["mcpServers"] result = json.loads(path.read_text())["mcpServers"]
assert result["artemis"]["env"] == {"CUSTOM": "preserved"} assert result["artemis"]["env"]["CUSTOM"] == "preserved"
assert result["artemis"]["env"]["PYTHONPATH"] == str(moved_root)
assert result["artemis"]["cwd"] == str(moved_root)
assert result["artemis"]["disabled"] is True assert result["artemis"]["disabled"] is True
assert result["other"] == {"command": "keep"} assert result["other"] == {"command": "keep"}
assert not update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",))
missing = tmp_path / "not-installed.json" missing = tmp_path / "not-installed.json"
assert not update.refresh_json(missing, ("mcpServers",), root, "new", ()) assert not update.refresh_json(missing, ("mcpServers",), root, "new", ())
assert not missing.exists() assert not missing.exists()
def test_mcp_sync_installs_detected_clients_and_migrates_codex(tmp_path, monkeypatch):
from artemis.interfaces.cli.commands import mcp as mcp_command
home = tmp_path / "home"
home.mkdir()
codex_home = home / ".codex"
codex_home.mkdir()
codex_config = codex_home / "config.toml"
codex_config.write_text(
'model = "my-model"\n'
"[mcp_servers.artemis]\n"
'command = "old-python"\n'
'args = ["-m", "mcp_server.server"]\n'
'cwd = "C:/old-release"\n'
'enabled_tools = ["mobile_run_task", "retired_tool"]\n'
"enabled = false\n"
"[mcp_servers.artemis.env]\n"
'PYTHONPATH = "C:/old-release"\n'
'OPENAI_API_KEY = "keep-secret"\n',
encoding="utf-8",
)
cursor_home = home / ".cursor"
cursor_home.mkdir()
(cursor_home / "mcp.json").write_text('{"mcpServers":{"other":{"command":"keep"}}}')
monkeypatch.setattr("pathlib.Path.home", lambda: home)
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
monkeypatch.setenv("CODEX_HOME", str(codex_home))
monkeypatch.setenv("APPDATA", str(home / "AppData"))
root = tmp_path / "new release"
assert update.refresh_mcp(root) == 0
codex = tomllib.loads(codex_config.read_text(encoding="utf-8"))
entry = codex["mcp_servers"]["artemis"]
assert codex["model"] == "my-model"
assert entry["command"] == update.sys.executable
assert entry["cwd"] == str(root)
assert entry["args"] == ["-m", "mcp_server"]
assert entry["enabled"] is False
assert entry["env"]["OPENAI_API_KEY"] == "keep-secret"
assert entry["env"]["PYTHONPATH"] == str(root)
assert entry["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
cursor = json.loads((cursor_home / "mcp.json").read_text(encoding="utf-8"))
assert cursor["mcpServers"]["artemis"]["env"]["PYTHONPATH"] == str(root)
assert cursor["mcpServers"]["other"] == {"command": "keep"}
first = codex_config.read_text(encoding="utf-8")
assert update.refresh_mcp(root) == 0
assert codex_config.read_text(encoding="utf-8") == first
def test_mcp_sync_fails_on_invalid_detected_config_without_overwriting(tmp_path, monkeypatch):
from artemis.interfaces.cli.commands import mcp as mcp_command
home = tmp_path / "home"
cursor_home = home / ".cursor"
cursor_home.mkdir(parents=True)
config = cursor_home / "mcp.json"
config.write_text("{invalid json", encoding="utf-8")
monkeypatch.setattr("pathlib.Path.home", lambda: home)
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
monkeypatch.delenv("CODEX_HOME", raising=False)
assert update.refresh_mcp(tmp_path / "project") == 1
assert config.read_text(encoding="utf-8") == "{invalid json"
def test_mcp_sync_installs_codex_when_client_directory_exists(tmp_path, monkeypatch):
from artemis.interfaces.cli.commands import mcp as mcp_command
home = tmp_path / "home"
codex_home = home / ".codex"
codex_home.mkdir(parents=True)
monkeypatch.setattr("pathlib.Path.home", lambda: home)
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
monkeypatch.setenv("CODEX_HOME", str(codex_home))
root = tmp_path / "project"
assert update.refresh_mcp(root) == 0
config = tomllib.loads((codex_home / "config.toml").read_text(encoding="utf-8"))
assert config["mcp_servers"]["artemis"]["cwd"] == str(root)
assert config["mcp_servers"]["artemis"]["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
def test_codex_install_preserves_existing_user_tables(tmp_path):
path = tmp_path / "config.toml"
path.write_text(
'model = "keep"\n[mcp_servers.other]\ncommand = "other-tool"\n', encoding="utf-8"
)
template = {
"command": "new-python",
"args": ["-m", "mcp_server"],
"cwd": str(tmp_path),
"env": {"PYTHONPATH": str(tmp_path)},
"enabled_tools": ["mobile_run_task"],
}
assert update.refresh_codex(
path,
tmp_path,
"new-python",
("mobile_run_task",),
template=template,
install_missing=True,
)
data = tomllib.loads(path.read_text(encoding="utf-8"))
assert data["model"] == "keep"
assert data["mcp_servers"]["other"]["command"] == "other-tool"
assert data["mcp_servers"]["artemis"]["env"]["PYTHONPATH"] == str(tmp_path)
def test_codex_inline_environment_sync_is_idempotent(tmp_path):
path = tmp_path / "config.toml"
path.write_text(
'[mcp_servers.artemis]\ncommand = "old-python"\n'
'args = ["-m", "mcp_server"]\n'
'env = { PYTHONPATH = "C:/old", OPENAI_API_KEY = "keep-secret" }\n',
encoding="utf-8",
)
template = {
"command": "new-python",
"args": ["-m", "mcp_server"],
"cwd": str(tmp_path),
"env": {"PYTHONPATH": str(tmp_path)},
"enabled_tools": ["mobile_run_task"],
}
assert update.refresh_codex(
path, tmp_path, "new-python", ("mobile_run_task",), template=template
)
data = tomllib.loads(path.read_text(encoding="utf-8"))["mcp_servers"]["artemis"]
assert data["env"]["OPENAI_API_KEY"] == "keep-secret"
assert data["env"]["PYTHONPATH"] == str(tmp_path)
assert not update.refresh_codex(
path, tmp_path, "new-python", ("mobile_run_task",), template=template
)
def test_custom_mcp_wrapper_is_left_unchanged(tmp_path):
path = tmp_path / "mcp.json"
original = '{"mcpServers":{"artemis":{"command":"uv","args":["run","custom"]}}}'
path.write_text(original, encoding="utf-8")
assert (
update.refresh_json(
path,
("mcpServers",),
tmp_path,
"new-python",
("mobile_run_task",),
install_missing=True,
template={"command": "new-python", "args": ["-m", "mcp_server"], "env": {}},
)
is None
)
assert path.read_text(encoding="utf-8") == original
def test_frontend_fingerprint_detects_lock_source_and_deleted_assets(tmp_path): def test_frontend_fingerprint_detects_lock_source_and_deleted_assets(tmp_path):
ui = tmp_path / "apps/showcase_ui" ui = tmp_path / "apps/showcase_ui"
(ui / "src").mkdir(parents=True) (ui / "src").mkdir(parents=True)
Generated
+1 -1
View File
@@ -206,7 +206,7 @@ wheels = [
[[package]] [[package]]
name = "artemis" name = "artemis"
version = "1.1.1" version = "1.2.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "adbutils" }, { name = "adbutils" },