Compare commits

...
5 Commits
Author SHA1 Message Date
chenmingxuan e1e26772d9 fix: prevent false timeout failures and release 1.2.2 2026-09-28 19:11:16 +08:00
chenmingxuan 1dc2b0c686 fix: prevent timeout stalls and release 1.2.1 2026-09-28 15:20:22 +08:00
chenmingxuan 84e183d28d feat: improve batch task controls and release 1.2.0 2026-09-25 02:02:48 +08:00
chenmingxuan 0e13bb5c29 fix: synchronize MCP clients on launch 2026-09-24 12:07:42 +08:00
chenmingxuan 8522fa3ca3 fix: ship updatable 1.1.1 source archives
Package a clean shallow main checkout with credential-free Gitea remote, localize the China launcher, and set the new-install Responses endpoint.
2026-09-24 10:49:57 +08:00
69 changed files with 1821 additions and 395 deletions

No files matched your search

+5 -1
View File
@@ -2,10 +2,12 @@
# 所有模型调用仅使用 OpenAI Responses API;环境变量优先于 .env。 # 所有模型调用仅使用 OpenAI Responses API;环境变量优先于 .env。
OPENAI_API_KEY= OPENAI_API_KEY=
# 接口根地址:通常以 /v1 结尾,不要添加 /responses 或 /chat/completions。 # 接口根地址:通常以 /v1 结尾,不要添加 /responses 或 /chat/completions。
OPENAI_BASE_URL=https://api.openai.com/v1 OPENAI_BASE_URL=https://api-n-cd.playones.com/v1
OPENAI_MODEL=mimo-v2.6-flash OPENAI_MODEL=mimo-v2.6-flash
# 单次模型 HTTP 请求超时(秒) # 单次模型 HTTP 请求超时(秒)
OPENAI_TIMEOUT_SECONDS=300 OPENAI_TIMEOUT_SECONDS=300
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=25
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。 # 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
OPENAI_MAX_RETRIES=5 OPENAI_MAX_RETRIES=5
OPENAI_RETRY_INTERVAL_SECONDS=10 OPENAI_RETRY_INTERVAL_SECONDS=10
@@ -19,6 +21,8 @@ ARTEMIS_OUTPUTTER_ENABLED=true
ARTEMIS_STEP_SUMMARIZER_ENABLED=true ARTEMIS_STEP_SUMMARIZER_ENABLED=true
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。 # Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3 ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3
# Flash 连续无工具调用的最大次数;无效回复不会重复送回模型。
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS=3
# 视频分段、重试与单次分析超时(秒) # 视频分段、重试与单次分析超时(秒)
ARTEMIS_VIDEO_CHUNK_SECONDS=60 ARTEMIS_VIDEO_CHUNK_SECONDS=60
+9 -8
View File
@@ -12,7 +12,7 @@
## 安装与启动 ## 安装与启动
准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器。首次安装需要联网,建议使用 Git 克隆以支持后续更新。 准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器。首次安装需要联网。可使用 Git 克隆或下载 1.1.1 及更新版本的发布源码包;新版 ZIP 解压后也保留 Git 更新能力。
### Windows ### Windows
@@ -24,14 +24,15 @@ cd Artemis-Eapil
.\start.bat .\start.bat
``` ```
国内网络使用 `.\start-cn.bat`。启动脚本会检测并安装缺少的 uv、Python 和所需工具,创建或复用 `.venv`,同步依赖并构建网页。启动后访问 **http://127.0.0.1:8000**。 国内网络使用 `.\start-cn.bat`,支持 `-Help` 查看中文选项。启动脚本会检测并安装缺少的 uv、Python 和所需工具,创建或复用 `.venv`,同步依赖并构建网页。启动后访问 **http://127.0.0.1:8000**。
常用选项: 常用选项:
```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 下载地址。
@@ -49,19 +50,19 @@ cd Artemis-Eapil
预设模型为 `gpt-5.6-luna`、`mimo-v2.6-flash`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**;视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。 预设模型为 `gpt-5.6-luna`、`mimo-v2.6-flash`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**;视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。 也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。 报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
## 让 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,9 +70,9 @@ 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。
ZIP 安装可继续运行;需要自动拉取源码时,请改用 Git 克隆安装。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。 1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树,解压后再次运行一键脚本即可检查更新。1.1.0 及更早 ZIP 不含 Git 信息,无法自行拉取;需要下载新版源码包重新解压一次,之后即可自动更新。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
更多说明:[批量任务与报告](docs/automation-workspace.md) · [AI 接入](docs/ai-automation.md) · [开发协作指南](AGENTS.md) 更多说明:[批量任务与报告](docs/automation-workspace.md) · [AI 接入](docs/ai-automation.md) · [开发协作指南](AGENTS.md)
+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)
@@ -125,6 +130,11 @@ async def tick_batches():
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次") batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
store.put("batch", batch["id"], batch) store.put("batch", batch["id"], batch)
for job in active: for job in active:
# The queue records started_at at submission, then replaces it
# when the worker actually starts. Waiting for a device must
# not consume this case's execution budget.
if job["status"] != "running":
continue
limit = int( limit = int(
(job.get("case", {}).get("task_config") or {}).get("timeout_seconds") (job.get("case", {}).get("task_config") or {}).get("timeout_seconds")
or batch.get("timeout_seconds", 1800) or batch.get("timeout_seconds", 1800)
@@ -134,15 +144,16 @@ async def tick_batches():
if task_state.get("non_execution_started_at"): if task_state.get("non_execution_started_at"):
excluded += time.time() - float(task_state["non_execution_started_at"]) excluded += time.time() - float(task_state["non_execution_started_at"])
if ( if (
job["status"] != "finalizing" 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="任务超时,已暂停剩余任务;请确认设备状态") # on_terminal records the failed case. The next pending
store.put("batch", batch["id"], batch) # case then runs normally unless stop_on_failure is set.
continue continue
if batch["status"] != "running": if batch["status"] != "running":
continue continue
@@ -202,6 +213,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 +277,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,),
@@ -16,7 +16,7 @@ _write_lock = threading.Lock()
class ModelSettingsUpdate(BaseModel): class ModelSettingsUpdate(BaseModel):
base_url: str = "https://api.openai.com/v1" base_url: str = "https://api-n-cd.playones.com/v1"
model: str = Field(min_length=1, max_length=200) model: str = Field(min_length=1, max_length=200)
api_key: SecretStr | None = None # Omitted/blank keeps the existing key. api_key: SecretStr | None = None # Omitted/blank keeps the existing key.
timeout_seconds: float = Field(default=300, gt=0, le=3600) timeout_seconds: float = Field(default=300, gt=0, le=3600)
@@ -41,7 +41,7 @@ class ModelSettingsUpdate(BaseModel):
): ):
raise ValueError("请输入有效的 HTTP(S) 接口根地址,不要包含密钥、查询参数或片段") raise ValueError("请输入有效的 HTTP(S) 接口根地址,不要包含密钥、查询参数或片段")
if value.endswith(("/responses", "/chat/completions")): if value.endswith(("/responses", "/chat/completions")):
raise ValueError("请输入接口根地址,例如 https://api.openai.com/v1") raise ValueError("请输入接口根地址,例如 https://api-n-cd.playones.com/v1")
return value return value
@field_validator("model") @field_validator("model")
@@ -57,7 +57,7 @@ def get_model_settings() -> dict:
return { return {
"provider": "openai", "provider": "openai",
"api_mode": "responses", "api_mode": "responses",
"base_url": settings.OPENAI_BASE_URL or "https://api.openai.com/v1", "base_url": settings.OPENAI_BASE_URL or "https://api-n-cd.playones.com/v1",
"model": settings.OPENAI_MODEL, "model": settings.OPENAI_MODEL,
"api_key_configured": bool(settings.OPENAI_API_KEY), "api_key_configured": bool(settings.OPENAI_API_KEY),
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS, "timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
+52 -8
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,19 +92,49 @@ 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"])
log_lines: list[str] = []
for name in ("stdout.log", "stderr.log"): for name in ("stdout.log", "stderr.log"):
path = directory / name path = directory / name
try: try:
with path.open("rb") as stream: with path.open("rb") as stream:
stream.seek(0, 2) stream.seek(0, 2)
stream.seek(max(0, stream.tell() - 131072)) stream.seek(max(0, stream.tell() - 131072))
lines = stream.read().decode("utf-8", errors="replace").splitlines() log_lines.extend(stream.read().decode("utf-8", errors="replace").splitlines())
except OSError: except OSError:
continue continue
for line in reversed(lines): for line in reversed(log_lines):
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))
for line in reversed(log_lines):
stripped = line.strip()
if stripped.startswith(("⚠", "ℹ", "WARNING", "INFO", "DEBUG")):
continue
if any(
name in line
for name in (
"VisualStepSummarizer:",
"StepMemoryService:",
"Could not clean unowned screen wake locks",
)
):
continue
exception_line = re.fullmatch(
r"(?:[A-Za-z_]\w*\.)*[A-Za-z_]\w*(?:Error|Exception|Timeout):\s*.*",
stripped,
)
if exception_line and not re.search(r"(?i)(?:timeout|timed out|超时)", stripped):
return redact(stripped)
if re.search(
r"(?i)(?:\b(?:TimeoutError|APITimeoutError|ReadTimeout|ConnectTimeout)\s*:|"
r"(?:模型请求|任务执行|model request|task execution).{0,100}"
r"(?:超时|timed out|deadline exceeded))",
stripped,
):
return redact("模型请求或任务执行超时:" + stripped)
return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。" return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
@@ -142,7 +181,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 +677,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 +733,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.0", "version": "1.2.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.1.0", "version": "1.2.2",
"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.0", "version": "1.2.2",
"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(); }); }
@@ -24,7 +24,7 @@ interface ModelSettings {
<p>统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。</p> <p>统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。</p>
<label for="model-base-url">接口地址</label> <label for="model-base-url">接口地址</label>
<input id="model-base-url" name="baseUrl" type="url" [(ngModel)]="baseUrl" required <input id="model-base-url" name="baseUrl" type="url" [(ngModel)]="baseUrl" required
placeholder="https://api.openai.com/v1" [disabled]="busy() || loading()" /> placeholder="https://api-n-cd.playones.com/v1" [disabled]="busy() || loading()" />
<small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small> <small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small>
<label for="model-name">模型名称</label> <label for="model-name">模型名称</label>
<div class="model-presets" role="group" aria-label="预设模型"> <div class="model-presets" role="group" aria-label="预设模型">
@@ -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: '处理中',
+63 -3
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 (
@@ -167,6 +167,7 @@ class FlashRunner:
ctx, ctx,
model_name=self.step_summarizer_cfg.model, model_name=self.step_summarizer_cfg.model,
retry_limit=self.memory_runtime_cfg.retry_limit, retry_limit=self.memory_runtime_cfg.retry_limit,
timeout_seconds=self.step_summarizer_cfg.timeout_seconds,
max_concurrency=self.memory_runtime_cfg.max_concurrency, max_concurrency=self.memory_runtime_cfg.max_concurrency,
flush_timeout_s=self.memory_runtime_cfg.flush_timeout_s, flush_timeout_s=self.memory_runtime_cfg.flush_timeout_s,
) )
@@ -937,6 +938,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())
@@ -1060,6 +1064,8 @@ class FlashRunner:
current_xml_list = xml_list current_xml_list = xml_list
previous_turn: _TurnRecord | None = None previous_turn: _TurnRecord | None = None
pending_notices: list[str] = [] pending_notices: list[str] = []
no_tool_attempts = 0
no_tool_limit = settings.ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS
while limit is None or turns < limit: while limit is None or turns < limit:
control = getattr(self.ctx, "task_control", None) control = getattr(self.ctx, "task_control", None)
@@ -1118,7 +1124,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 +1180,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(
@@ -1154,11 +1201,24 @@ class FlashRunner:
if is_final: if is_final:
final_report = {"status": "failed", "explanation": raw_text} final_report = {"status": "failed", "explanation": raw_text}
break break
no_tool_attempts += 1
if no_tool_attempts >= no_tool_limit:
final_report = {
"status": "failed",
"explanation": (
f"模型连续 {no_tool_attempts} 次未调用工具,"
"已停止任务以避免空转和重复计费;未执行新的设备动作。"
),
}
break
pending_notices.append(_NO_TOOL_CALL_NOTICE) pending_notices.append(_NO_TOOL_CALL_NOTICE)
ledger.stage_turn(messages[turn_base:]) # Keep the observation and any injected user instruction, but
# do not feed the model its own unproductive prose on retry.
ledger.stage_turn(messages[turn_base:-1])
previous_turn = _TurnRecord() previous_turn = _TurnRecord()
continue continue
no_tool_attempts = 0
# Process tool calls # Process tool calls
turn = _TurnRecord() turn = _TurnRecord()
( (
+12 -2
View File
@@ -151,6 +151,7 @@ class VisualStepSummarizer(StepMemoryService):
ctx: ArtemisContext, ctx: ArtemisContext,
model_name: str | None = None, model_name: str | None = None,
retry_limit: int = 3, retry_limit: int = 3,
timeout_seconds: float = 25.0,
*, *,
max_concurrency: int = 1, max_concurrency: int = 1,
flush_timeout_s: float = 30.0, flush_timeout_s: float = 30.0,
@@ -161,6 +162,7 @@ class VisualStepSummarizer(StepMemoryService):
retry_limit=retry_limit, retry_limit=retry_limit,
flush_timeout_s=flush_timeout_s, flush_timeout_s=flush_timeout_s,
) )
self._timeout_seconds = timeout_seconds
# Initialize lightweight VLM: prioritize explicit model_name # Initialize lightweight VLM: prioritize explicit model_name
target_model = model_name or settings.OPENAI_MODEL target_model = model_name or settings.OPENAI_MODEL
@@ -459,7 +461,9 @@ class VisualStepSummarizer(StepMemoryService):
HumanMessage(content=content_blocks), HumanMessage(content=content_blocks),
] ]
response = await asyncio.wait_for(self._llm.ainvoke(messages), timeout=25.0) response = await asyncio.wait_for(
self._llm.ainvoke(messages), timeout=self._timeout_seconds
)
self._meter_lens_call(response) self._meter_lens_call(response)
summary_raw = response.content if isinstance(response.content, str) else "" summary_raw = response.content if isinstance(response.content, str) else ""
if isinstance(response.content, list): if isinstance(response.content, list):
@@ -505,8 +509,14 @@ class VisualStepSummarizer(StepMemoryService):
logger.debug(f"DataEngine step summary update skipped: {de_err}") logger.debug(f"DataEngine step summary update skipped: {de_err}")
return True return True
except TimeoutError:
logger.warning(
f"VisualStepSummarizer: Step {step_number} summary request timed out "
f"(limit {self._timeout_seconds:.1f}s)"
)
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"VisualStepSummarizer: Error generating summary for step {step_number}: {e}" f"VisualStepSummarizer: Error generating summary for step {step_number}: "
f"{type(e).__name__}: {e}"
) )
return False return False
+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)."
+5
View File
@@ -370,6 +370,11 @@ class StepSummarizerConfig(BaseModel):
default_factory=lambda: settings.OPENAI_MODEL, default_factory=lambda: settings.OPENAI_MODEL,
description="Lightweight model used for background step state summarization.", description="Lightweight model used for background step state summarization.",
) )
timeout_seconds: float = Field(
default=25.0,
gt=0,
description="Timeout for one optional background visual-summary model request.",
)
prune_history_xml: bool = Field( prune_history_xml: bool = Field(
default=True, default=True,
description="Whether to prune outdated heavy UI XML trees from historical turns.", description="Whether to prune outdated heavy UI XML trees from historical turns.",
+3 -1
View File
@@ -95,7 +95,7 @@ class Settings(BaseSettings):
API_KEY: SecretStr | None = None API_KEY: SecretStr | None = None
# Custom Provider Endpoints # Custom Provider Endpoints
OPENAI_BASE_URL: str | None = None OPENAI_BASE_URL: str | None = "https://api-n-cd.playones.com/v1"
OPENAI_MODEL: str = Field(default="mimo-v2.6-flash", min_length=1) OPENAI_MODEL: str = Field(default="mimo-v2.6-flash", min_length=1)
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0) OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10) OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
@@ -114,6 +114,8 @@ class Settings(BaseSettings):
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
# Consecutive Pro responses without a valid tool call before failing the task. # Consecutive Pro responses without a valid tool call before failing the task.
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20) ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
# Consecutive Flash responses without any tool call before failing the task.
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
# Android ADB Connectivity # Android ADB Connectivity
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST) ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
+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}")
+1
View File
@@ -33,6 +33,7 @@
"step_summarizer": { "step_summarizer": {
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}", "enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}", "model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-25}",
"prune_history_xml": true "prune_history_xml": true
} }
}, },
+15 -4
View File
@@ -113,22 +113,28 @@ class ScreenAwakeLease:
} }
@contextmanager @contextmanager
def _mutex(self): def _mutex(self, *, wait_timeout_seconds: float | None = None):
self.mutex_path.parent.mkdir(parents=True, exist_ok=True) self.mutex_path.parent.mkdir(parents=True, exist_ok=True)
started_at = time.monotonic() started_at = time.monotonic()
timeout = (
self._MUTEX_TIMEOUT_SECONDS
if wait_timeout_seconds is None
else max(0.0, wait_timeout_seconds)
)
acquired = False acquired = False
while not acquired: while not acquired:
try: try:
fd = os.open(self.mutex_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) fd = os.open(self.mutex_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError: except FileExistsError:
if time.monotonic() - started_at >= timeout:
raise TimeoutError("Timed out acquiring the Artemis awake-lease mutex.")
if self._path_is_stale(self.mutex_path): if self._path_is_stale(self.mutex_path):
try: try:
self.mutex_path.unlink(missing_ok=True) self.mutex_path.unlink(missing_ok=True)
except OSError: except OSError:
pass pass
else:
continue continue
if time.monotonic() - started_at >= self._MUTEX_TIMEOUT_SECONDS:
raise TimeoutError("Timed out acquiring the Artemis awake-lease mutex.")
time.sleep(self._MUTEX_POLL_SECONDS) time.sleep(self._MUTEX_POLL_SECONDS)
else: else:
try: try:
@@ -274,10 +280,15 @@ class ScreenAwakeLease:
def cleanup_unowned_references(self) -> bool: def cleanup_unowned_references(self) -> bool:
"""Drain legacy shell wake locks only when no live old host owns them.""" """Drain legacy shell wake locks only when no live old host owns them."""
try: try:
with self._mutex(): # Migration cleanup is optional. A busy owner must not delay every
# queued task by the normal 10-second lease-acquisition deadline.
with self._mutex(wait_timeout_seconds=1.0):
if self._live_leases(): if self._live_leases():
return False return False
return self._drain_references() return self._drain_references()
except TimeoutError:
logger.debug(f"Skipped optional wake-lock cleanup on {self.device_id}: mutex busy")
return False
except Exception as exc: except Exception as exc:
logger.warning(f"Could not clean unowned screen wake locks on {self.device_id}: {exc}") logger.warning(f"Could not clean unowned screen wake locks on {self.device_id}: {exc}")
return False return False
+3 -2
View File
@@ -95,8 +95,9 @@ def _configure_usb_stay_awake(device_id: str) -> str | None:
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1": if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
return None return None
# Migration cleanup: previous Artemis versions used a persistent shell # Migration cleanup belongs to the long-lived host. A queued task worker
# wake lock. Drain it only after every live legacy owner has exited. # must not wait on another process's optional legacy wake-lock cleanup.
if os.environ.get("ARTEMIS_TASK_WORKER") != "1":
ScreenAwakeLease(device_id).cleanup_unowned_references() ScreenAwakeLease(device_id).cleanup_unowned_references()
commands = ( commands = (
+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)
+3 -3
View File
@@ -115,9 +115,9 @@ async def validate_api_key(
return False, f"Vision OCR API verification failed ({resp.status_code}): {err_msg}" return False, f"Vision OCR API verification failed ({resp.status_code}): {err_msg}"
elif clean_provider == "openai": elif clean_provider == "openai":
base = (base_url or settings.OPENAI_BASE_URL or "https://api.openai.com/v1").rstrip( base = (
"/" base_url or settings.OPENAI_BASE_URL or "https://api-n-cd.playones.com/v1"
) ).rstrip("/")
resp = await client.post( resp = await client.post(
f"{base}/responses", f"{base}/responses",
headers={"Authorization": f"Bearer {clean_key}"}, headers={"Authorization": f"Bearer {clean_key}"},
+1
View File
@@ -33,6 +33,7 @@
"step_summarizer": { "step_summarizer": {
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}", "enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}", "model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-25}",
"prune_history_xml": true "prune_history_xml": true
} }
}, },
+7 -5
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 这样下达任务:
@@ -32,16 +32,18 @@
新增工具通过统一后台服务运行,与网页共享配置、队列、历史和报告;不另外启动独立调度器。`ARTEMIS_STANDALONE` 模式不支持这些工作区工具。 新增工具通过统一后台服务运行,与网页共享配置、队列、历史和报告;不另外启动独立调度器。`ARTEMIS_STANDALONE` 模式不支持这些工作区工具。
Windows 已安装用户再次运行 `start.bat` / `start-cn.bat` 时会检查当前分支的 Git 上游,仅做快进更新;拉取后重新加载新版启动脚本。没有 Git、ZIP 解压安装、网络/认证失败、有本地修改或分支分叉时保留现有源码并给出提示,不重置或覆盖。ZIP 用户可继续运行本地版本;需要自动源码升级时请改用 Git 克隆安装。`-SkipUpdate` 可跳过源码更新检查。 Windows 已安装用户再次运行 `start.bat` / `start-cn.bat` 时会检查当前分支的 Git 上游,仅做快进更新;拉取后重新加载新版启动脚本。1.1.1 及更新版本的发布 ZIP 是精简的 Git 工作树,解压后也能在后续运行时检查更新。1.1.0 及更早 ZIP 缺少 `.git`,需手动下载新版源码包一次。没有 Git、网络/认证失败、有本地修改或分支分叉时保留现有源码并给出提示,不重置或覆盖。`-SkipUpdate` 可跳过源码更新检查。
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.1
## 修复与改进
- 发布源码包现在包含干净的浅层 Git 工作树,跟踪 Gitea 的 `main`。解压后再次运行 Windows 启动脚本可快进拉取更新;构建输出、虚拟环境、个人配置和本机 Git 凭据不随包发布。
- `start-cn.bat` 提供中文帮助、启动说明和安装/更新关键提示,继续使用国内镜像。可运行 `start-cn.bat -Help` 查看参数。
- 新安装默认 Responses 接口地址改为 `https://api-n-cd.playones.com/v1`。已经保存到 `.env` 的地址和密钥保持原样。
## 安装与升级
下载本版源码 ZIP 并解压,运行 `start.bat` 或 `start-cn.bat`。1.1.0 及更早的 ZIP 本身没有 `.git`,无法自动升级;请下载本版源码包一次,之后可用启动脚本检查更新。首次运行会创建 `.venv`、安装依赖并构建前端,重新加载 AI 客户端的 MCP 后使用新版实现。
更新采用 Git 快进;本地修改、分支分叉或任务运行中均不会覆盖。附件提供 `SHA256SUMS.txt` 用于校验源码包。
+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、质量基线和核心类型检查通过。
+14
View File
@@ -0,0 +1,14 @@
# ARTEMIS-Eapil 1.2.1
## 修复
- 批量任务达到执行超时后,该用例记录为失败,不再自动暂停整批。当前用例结算后继续执行下一项;开启“失败即停止”的批次仍会跳过剩余项。排队等待不计入执行超时。
- Flash 连续收到无工具调用的回复时,最多尝试 3 次并给出明确失败原因,避免持续空转和重复计费。重试会保留设备观察与用户指导,但不把模型的无效回复送回上下文。可通过 `ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS` 调整上限。
模型请求中的相同缓存 token 数量通常是提示词前缀缓存命中,不能据此判断缓存造成循环。本版针对无工具调用的执行循环建立退出条件。
## 安装与验证
请下载附件 `artemis-eapil-1.2.1-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件包含精简 Git 仓库,可供后续启动脚本检查更新。Gitea 自动生成的源码 ZIP 不含 `.git`,请使用发布附件。升级后重启 ARTEMIS 服务、刷新网页并重新加载 AI 客户端的 MCP。
验证:Python 默认测试 2182 项通过、6 项跳过;Ruff、质量基线和核心类型检查通过。
+13
View File
@@ -0,0 +1,13 @@
# ARTEMIS-Eapil 1.2.2
## 修复
- 任务报告区分实际执行失败与可恢复的诊断警告。屏幕唤醒锁清理超时、步骤视觉摘要重试不再被误写为任务或模型请求超时;真实终止异常仍会显示。
- 旧屏幕唤醒锁清理遇到设备锁繁忙时快速跳过,避免队列任务因清理动作等待或连续失败。长驻服务负责后续清理。
- Flash 步骤视觉摘要使用独立超时,默认 25 秒,可通过 `ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS` 设置,不再占用主模型请求的 300 秒超时额度。
## 安装与验证
下载附件 `artemis-eapil-1.2.2-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件含精简 Git 仓库,后续可由启动脚本检查更新。升级后重启 ARTEMIS 服务、刷新网页并重新加载 AI 客户端的 MCP。
验证:Python 默认测试 2191 项通过、2 项跳过、8 项按标记排除;Ruff、质量基线和核心类型检查通过。两项真实模拟器 Flash 批量任务均完成;干净环境未复现原先的锁争用,锁繁忙与报告归因通过回归测试覆盖。
+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.0" version = "1.2.2"
description = "Autonomous AI Assistant for Mobile Automation" description = "Autonomous AI Assistant for Mobile Automation"
authors = [ authors = [
+27 -21
View File
@@ -1,42 +1,48 @@
# Python bootstrap shared by the Windows launcher and its isolated checks. # Python bootstrap shared by the Windows launcher and its isolated checks.
# Keep this file ASCII for Windows PowerShell 5.1. # Save as UTF-8 with BOM so Windows PowerShell 5.1 reads Chinese messages correctly.
function Get-BootstrapText {
param([string]$English, [string]$Chinese)
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') { return $Chinese }
return $English
}
function Initialize-ArtemisPython { function Initialize-ArtemisPython {
param([Parameter(Mandatory=$true)][string]$ProjectRoot) param([Parameter(Mandatory=$true)][string]$ProjectRoot)
$ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot -ErrorAction Stop).Path $ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot -ErrorAction Stop).Path
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'pyproject.toml'))) { if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'pyproject.toml'))) {
throw 'pyproject.toml is missing. Extract the complete source archive first.' throw (Get-BootstrapText 'pyproject.toml is missing. Extract the complete source archive first.' '缺少 pyproject.toml;请重新解压完整源码包。')
} }
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'uv.lock'))) { if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'uv.lock'))) {
throw 'uv.lock is missing. Extract the complete source archive first.' throw (Get-BootstrapText 'uv.lock is missing. Extract the complete source archive first.' '缺少 uv.lock;请重新解压完整源码包。')
} }
if (-not (Test-CommandExists 'uv')) { if (-not (Test-CommandExists 'uv')) {
if ($ArtemisDownloads -and $ArtemisDownloads.python_index) { if ($ArtemisDownloads -and $ArtemisDownloads.python_index) {
Write-Host ' [INSTALL] Installing uv from the selected Python mirror...' -ForegroundColor Cyan Write-Host (Get-BootstrapText ' [INSTALL] Installing uv from the selected Python mirror...' ' [安装] 正在从选定的 Python 镜像安装 uv...') -ForegroundColor Cyan
Install-ArtemisUvFromIndex -IndexUrl $ArtemisDownloads.python_index Install-ArtemisUvFromIndex -IndexUrl $ArtemisDownloads.python_index
} else { } else {
Write-Host ' [INSTALL] uv was not found. Installing from the configured installer...' -ForegroundColor Cyan Write-Host (Get-BootstrapText ' [INSTALL] uv was not found. Installing from the configured installer...' ' [安装] 未找到 uv,正在下载安装...') -ForegroundColor Cyan
$installer = Join-Path ([IO.Path]::GetTempPath()) ('artemis-uv-' + [guid]::NewGuid().ToString('N') + '.ps1') $installer = Join-Path ([IO.Path]::GetTempPath()) ('artemis-uv-' + [guid]::NewGuid().ToString('N') + '.ps1')
try { try {
$installerUrl = if ($ArtemisDownloads) { $ArtemisDownloads.uv_installer_url } else { 'https://astral.sh/uv/install.ps1' } $installerUrl = if ($ArtemisDownloads) { $ArtemisDownloads.uv_installer_url } else { 'https://astral.sh/uv/install.ps1' }
if (-not (Invoke-DownloadFile -Uri $installerUrl -OutFile $installer)) { if (-not (Invoke-DownloadFile -Uri $installerUrl -OutFile $installer)) {
throw 'Could not download uv. Check your network/proxy and retry.' throw (Get-BootstrapText 'Could not download uv. Check your network/proxy and retry.' 'uv 下载失败;请检查网络、代理或镜像后重试。')
} }
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installer | Out-Host & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installer | Out-Host
if ($LASTEXITCODE -ne 0) { throw "uv installation failed (exit $LASTEXITCODE)." } if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapText "uv installation failed (exit $LASTEXITCODE)." "uv 安装失败(退出码 $LASTEXITCODE)。") }
} finally { } finally {
Remove-Item -LiteralPath $installer -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $installer -Force -ErrorAction SilentlyContinue
} }
} }
Update-EnvironmentPath Update-EnvironmentPath
} else { } else {
Write-Host ' [SKIP] uv is already installed.' -ForegroundColor Green Write-Host (Get-BootstrapText ' [SKIP] uv is already installed.' ' [复用] uv 已安装,跳过下载。') -ForegroundColor Green
} }
if (-not (Test-CommandExists 'uv')) { throw 'uv is still unavailable. Install it from https://astral.sh/uv/ and retry.' } if (-not (Test-CommandExists 'uv')) { throw (Get-BootstrapText 'uv is still unavailable. Install it from https://astral.sh/uv/ and retry.' '仍无法运行 uv;请检查安装并重试。') }
& uv --version | Out-Host & uv --version | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'The installed uv could not run. Repair its installation and retry.' } if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapText 'The installed uv could not run. Repair its installation and retry.' '已安装的 uv 无法运行;请修复安装后重试。') }
$venvDir = Join-Path $ProjectRoot '.venv' $venvDir = Join-Path $ProjectRoot '.venv'
$pythonExe = Join-Path $venvDir 'Scripts\python.exe' $pythonExe = Join-Path $venvDir 'Scripts\python.exe'
@@ -45,18 +51,18 @@ function Initialize-ArtemisPython {
Push-Location -LiteralPath $ProjectRoot Push-Location -LiteralPath $ProjectRoot
try { try {
if (-not (Test-Path -LiteralPath $venvDir)) { if (-not (Test-Path -LiteralPath $venvDir)) {
Write-Host ' [INSTALL] Creating .venv with Python 3.12 (downloaded automatically if needed)...' -ForegroundColor Cyan Write-Host (Get-BootstrapText ' [INSTALL] Creating .venv with Python 3.12 (downloaded automatically if needed)...' ' [安装] 正在创建 Python 3.12 虚拟环境 .venv;缺少 Python 时自动下载...') -ForegroundColor Cyan
& uv venv --python 3.12 $venvDir | Out-Host & uv venv --python 3.12 $venvDir | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'Could not create .venv. Check the download errors above and retry.' } if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapText 'Could not create .venv. Check the download errors above and retry.' '.venv 创建失败;请查看上方下载错误并重试。') }
} else { } else {
Write-Host ' [SKIP] Reusing the existing project .venv.' -ForegroundColor Green Write-Host (Get-BootstrapText ' [SKIP] Reusing the existing project .venv.' ' [复用] 已有项目 .venv,跳过创建。') -ForegroundColor Green
} }
if (-not (Test-Path -LiteralPath $pythonExe)) { if (-not (Test-Path -LiteralPath $pythonExe)) {
throw 'The existing .venv is incomplete. Rename it as a backup, then rerun start.bat.' throw (Get-BootstrapText 'The existing .venv is incomplete. Rename it as a backup, then rerun start.bat.' '已有 .venv 不完整。先重命名备份,再运行 start-cn.bat。')
} }
& $pythonExe -c 'import sys; sys.exit(0 if sys.version_info >= (3, 12) and sys.prefix != sys.base_prefix else 1)' | Out-Host & $pythonExe -c 'import sys; sys.exit(0 if sys.version_info >= (3, 12) and sys.prefix != sys.base_prefix else 1)' | Out-Host
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw 'The existing .venv is unusable or older than Python 3.12. Rename it as a backup, then rerun start.bat.' throw (Get-BootstrapText 'The existing .venv is unusable or older than Python 3.12. Rename it as a backup, then rerun start.bat.' '已有 .venv 无法使用或 Python 低于 3.12。先重命名备份,再运行 start-cn.bat。')
} }
# A cheap check skips installation when the lock and installed packages agree. # A cheap check skips installation when the lock and installed packages agree.
@@ -64,19 +70,19 @@ function Initialize-ArtemisPython {
$lockOption = if ($ArtemisDownloads -and $ArtemisDownloads.python_index) { '--frozen' } else { '--locked' } $lockOption = if ($ArtemisDownloads -and $ArtemisDownloads.python_index) { '--frozen' } else { '--locked' }
& uv sync --check $lockOption --no-dev --inexact --quiet 2>$null | Out-Host & uv sync --check $lockOption --no-dev --inexact --quiet 2>$null | Out-Host
if ($LASTEXITCODE -eq 0) { if ($LASTEXITCODE -eq 0) {
Write-Host ' [SKIP] Python dependencies are already ready.' -ForegroundColor Green Write-Host (Get-BootstrapText ' [SKIP] Python dependencies are already ready.' ' [复用] Python 依赖已就绪。') -ForegroundColor Green
} else { } else {
Write-Host ' [INSTALL] Synchronizing dependencies from uv.lock...' -ForegroundColor Cyan Write-Host (Get-BootstrapText ' [INSTALL] Synchronizing dependencies from uv.lock...' ' [安装] 正在按 uv.lock 同步 Python 依赖...') -ForegroundColor Cyan
if ($ArtemisDownloads -and $ArtemisDownloads.python_index) { if ($ArtemisDownloads -and $ArtemisDownloads.python_index) {
Install-ArtemisLockedDependencies -PythonExe $pythonExe -IndexUrl $ArtemisDownloads.python_index Install-ArtemisLockedDependencies -PythonExe $pythonExe -IndexUrl $ArtemisDownloads.python_index
} else { } else {
& uv sync --locked --no-dev --inexact | Out-Host & uv sync --locked --no-dev --inexact | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed. Fix the error above and rerun start.bat.' } if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapText 'Dependency installation failed. Fix the error above and rerun start.bat.' '依赖安装失败;请处理上方错误,再运行 start-cn.bat。') }
} }
} }
& $pythonExe -c 'import artemis, uvicorn, fastapi, langchain_openai' | Out-Host & $pythonExe -c 'import artemis, uvicorn, fastapi, langchain_openai' | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'Python dependency verification failed. The UI will not be started.' } if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapText 'Python dependency verification failed. The UI will not be started.' 'Python 依赖验证失败,网页不会启动。') }
Write-Host ' [OK] Python environment is ready.' -ForegroundColor Green Write-Host (Get-BootstrapText ' [OK] Python environment is ready.' ' [完成] Python 环境已就绪。') -ForegroundColor Green
return $pythonExe return $pythonExe
} finally { } finally {
Pop-Location Pop-Location
+6 -2
View File
@@ -1,5 +1,5 @@
# Download settings are process-local; never edit global pip/npm/uv settings. # Download settings are process-local; never edit global pip/npm/uv settings.
# Keep this file ASCII for Windows PowerShell 5.1. # Save as UTF-8 with BOM so Windows PowerShell 5.1 reads Chinese messages correctly.
function Initialize-ArtemisDownloads { function Initialize-ArtemisDownloads {
param([string]$ProjectRoot, [string]$Mode) param([string]$ProjectRoot, [string]$Mode)
@@ -42,8 +42,12 @@ function Initialize-ArtemisDownloads {
# Official scrcpy v4.1 win64 release digest. Verify every mirror/override equally. # Official scrcpy v4.1 win64 release digest. Verify every mirror/override equally.
$values.scrcpy_sha256 = '5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db' $values.scrcpy_sha256 = '5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db'
$script:ArtemisDownloads = [pscustomobject]$values $script:ArtemisDownloads = [pscustomobject]$values
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') {
Write-Host " [镜像] 使用国内下载源(config/downloads.json)。" -ForegroundColor Cyan
} else {
Write-Host " [INFO] Download mode: $Mode (config/downloads.json)." -ForegroundColor Cyan Write-Host " [INFO] Download mode: $Mode (config/downloads.json)." -ForegroundColor Cyan
} }
}
function Install-ArtemisUvFromIndex { function Install-ArtemisUvFromIndex {
param([string]$IndexUrl, [string]$InstallDir = (Join-Path $env:USERPROFILE '.local\bin')) param([string]$IndexUrl, [string]$InstallDir = (Join-Path $env:USERPROFILE '.local\bin'))
+107 -7
View File
@@ -1,10 +1,13 @@
"""Create a portable source ZIP without environments, builds, or local credentials.""" """Create a clean, shallow Git checkout ZIP that can fast-forward on launch."""
import argparse import argparse
from datetime import datetime from datetime import datetime
import hashlib import hashlib
import os import os
from pathlib import Path from pathlib import Path
import subprocess
import tempfile
from urllib.parse import urlsplit
import zipfile import zipfile
@@ -83,6 +86,9 @@ EXCLUDED_SUFFIXES = {
".swo", ".swo",
} }
GIT_METADATA_FILES = {"HEAD", "config", "index", "shallow", "packed-refs"}
GIT_METADATA_DIRS = {"objects", "refs"}
def excluded(path: Path) -> bool: def excluded(path: Path) -> bool:
parts = tuple(part.lower() for part in path.parts) parts = tuple(part.lower() for part in path.parts)
@@ -116,22 +122,116 @@ def source_files(root: Path):
yield path yield path
def git(root: Path, *args: str) -> str:
result = subprocess.run(
["git", "-C", str(root), *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=90,
)
if result.returncode:
raise RuntimeError(f"git {args[0]} failed (exit {result.returncode})")
return result.stdout.strip()
def git_metadata_files(root: Path):
metadata = root / ".git"
for name in sorted(GIT_METADATA_FILES):
path = metadata / name
if path.is_file():
yield path
for name in sorted(GIT_METADATA_DIRS):
directory = metadata / name
if directory.exists():
yield from source_files_without_filter(directory)
for name in ("exclude", "sparse-checkout"):
path = metadata / "info" / name
if path.is_file():
yield path
def source_files_without_filter(root: Path):
for directory, dirs, files in os.walk(root, followlinks=False):
current = Path(directory)
dirs[:] = sorted(name for name in dirs if not (current / name).is_symlink())
for name in sorted(files):
path = current / name
if not path.is_symlink():
yield path
def release_remote(root: Path) -> tuple[str, str]:
if not (root / ".git").exists():
raise ValueError("Source packaging requires a Git checkout")
if git(root, "status", "--porcelain", "--untracked-files=no"):
raise ValueError("Commit tracked changes before packaging a release")
branch = git(root, "symbolic-ref", "--quiet", "--short", "HEAD")
remote = git(root, "config", "--get", f"branch.{branch}.remote")
merge_ref = git(root, "config", "--get", f"branch.{branch}.merge")
if remote == "." or merge_ref != f"refs/heads/{branch}":
raise ValueError("The current branch must track the same branch on a remote")
url = git(root, "remote", "get-url", remote)
parsed = urlsplit(url)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("The release upstream must be a credential-free HTTPS URL")
if parsed.query or parsed.fragment:
raise ValueError("The release upstream must not contain a query or fragment")
return branch, url
def package_source(root: Path, output: Path) -> tuple[int, str]: def package_source(root: Path, output: Path) -> tuple[int, str]:
root = root.resolve() root = root.resolve()
output = output.resolve() output = output.resolve()
branch, remote_url = release_remote(root)
revision = git(root, "rev-parse", "HEAD")
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
temporary = output.with_suffix(".zip.tmp") temporary = output.with_suffix(".zip.tmp")
files = [p for p in source_files(root) if p.resolve() not in {output, temporary}]
try: try:
with tempfile.TemporaryDirectory(prefix="artemis-release-") as work:
checkout = Path(work) / "artemis"
git(
root,
"clone",
"--depth",
"1",
"--single-branch",
"--branch",
branch,
root.as_uri(),
str(checkout),
)
git(checkout, "remote", "set-url", "origin", remote_url)
# Generated assets are tracked upstream but must not appear in the
# unpacked source. Sparse checkout keeps the Git index clean.
omitted = [
name for name in git(checkout, "ls-files").splitlines() if excluded(Path(name))
]
patterns = ["/*", *(f"!/{name}" for name in omitted)]
git(checkout, "sparse-checkout", "set", "--no-cone", *patterns)
# Rebuilt assets in sparse paths must not look like user edits.
info_exclude = checkout / ".git" / "info" / "exclude"
with info_exclude.open("a", encoding="utf-8") as stream:
stream.write("\n".join(["", *(f"/{name}" for name in omitted), ""]))
if git(checkout, "rev-parse", "HEAD") != revision or git(
checkout, "status", "--porcelain"
):
raise RuntimeError("The packaged checkout is not clean at the requested revision")
files = [*source_files(checkout), *git_metadata_files(checkout)]
names = {
str(Path("artemis") / path.relative_to(checkout)).replace("\\", "/")
for path in files
}
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive: with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
for path in files: for path in files:
archive.write(path, Path("artemis") / path.relative_to(root)) archive.write(path, Path("artemis") / path.relative_to(checkout))
with zipfile.ZipFile(temporary) as archive: with zipfile.ZipFile(temporary) as archive:
bad_file = archive.testzip() bad_file = archive.testzip()
if bad_file: if bad_file or set(archive.namelist()) != names:
raise RuntimeError(f"ZIP integrity check failed: {bad_file}") raise RuntimeError(
if any(excluded(Path(name).relative_to("artemis")) for name in archive.namelist()): f"ZIP integrity check failed: {bad_file or 'file list mismatch'}"
raise RuntimeError("An excluded file was found in the source archive") )
temporary.replace(output) temporary.replace(output)
finally: finally:
temporary.unlink(missing_ok=True) temporary.unlink(missing_ok=True)
+51 -38
View File
@@ -1,4 +1,4 @@
# Copyright 2026 Google LLC # Copyright 2026 Google LLC
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -18,12 +18,19 @@ 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
) )
$ErrorActionPreference = "Continue" $ErrorActionPreference = "Continue"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
function Get-LaunchText {
param([string]$English, [string]$Chinese)
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') { return $Chinese }
return $English
}
# Ensure modern TLS protocols are enabled for downloads (GitHub releases, nodejs.org, Google repositories) # Ensure modern TLS protocols are enabled for downloads (GitHub releases, nodejs.org, Google repositories)
try { try {
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
@@ -44,12 +51,13 @@ 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
} }
if ($updateExit -ne 0) { if ($updateExit -ne 0) {
Write-Host ' [ERROR] Source update check failed. Resolve the error above, or use -SkipUpdate for local startup.' -ForegroundColor Red Write-Host (Get-LaunchText ' [ERROR] Source update check failed. Resolve the error above, or use -SkipUpdate for local startup.' ' [错误] 源码更新检查失败。请处理上方错误;要运行当前源码可传入 -SkipUpdate。') -ForegroundColor Red
exit 1 exit 1
} }
} }
@@ -58,12 +66,12 @@ if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) {
try { try {
Initialize-ArtemisDownloads -ProjectRoot $RootDir -Mode $Mirror Initialize-ArtemisDownloads -ProjectRoot $RootDir -Mode $Mirror
} catch { } catch {
Write-Host " [ERROR] Invalid download settings: $_" -ForegroundColor Red Write-Host (Get-LaunchText " [ERROR] Invalid download settings: $_" " [错误] 下载配置无效:$_") -ForegroundColor Red
exit 1 exit 1
} }
Write-Host "======================================================" -ForegroundColor Cyan Write-Host "======================================================" -ForegroundColor Cyan
Write-Host " Artemis Autonomous Mobile Agent UI " -ForegroundColor Cyan Write-Host (Get-LaunchText " Artemis Autonomous Mobile Agent UI " " ARTEMIS 手机自动化测试平台") -ForegroundColor Cyan
Write-Host "======================================================" -ForegroundColor Cyan Write-Host "======================================================" -ForegroundColor Cyan
Write-Host "" Write-Host ""
@@ -181,7 +189,7 @@ function Install-PortablePlatformTools {
$env:PATH = "$ptDir;$env:PATH" $env:PATH = "$ptDir;$env:PATH"
return $true return $true
} }
Write-Host " [INFO] Installing Android platform-tools (adb) in user space..." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Installing Android platform-tools (adb) in user space..." " [安装] 正在安装 Android 调试工具 adb(当前用户)...") -ForegroundColor Cyan
try { try {
$zipPath = "$env:TEMP\platform-tools-windows.zip" $zipPath = "$env:TEMP\platform-tools-windows.zip"
if (Invoke-DownloadFile -Uri $ArtemisDownloads.adb_url -OutFile $zipPath) { if (Invoke-DownloadFile -Uri $ArtemisDownloads.adb_url -OutFile $zipPath) {
@@ -190,12 +198,12 @@ function Install-PortablePlatformTools {
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
if (Test-Path "$ptDir\adb.exe") { if (Test-Path "$ptDir\adb.exe") {
$env:PATH = "$ptDir;$env:PATH" $env:PATH = "$ptDir;$env:PATH"
Write-Host " [OK] adb installed in user space." -ForegroundColor Green Write-Host (Get-LaunchText " [OK] adb installed in user space." " [完成] adb 已安装。") -ForegroundColor Green
return $true return $true
} }
} }
} catch { } catch {
Write-Host " [WARN] Failed to install portable platform-tools: $_" -ForegroundColor DarkYellow Write-Host (Get-LaunchText " [WARN] Failed to install portable platform-tools: $_" " [提示] adb 安装失败:$_") -ForegroundColor DarkYellow
} }
return $false return $false
} }
@@ -217,9 +225,9 @@ function Install-PortableScrcpy {
} }
} }
Write-Host " [INFO] Installing portable scrcpy in user space..." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Installing portable scrcpy in user space..." " [安装] 正在安装 scrcpy(当前用户)...") -ForegroundColor Cyan
if ($ArtemisDownloads.mode -eq 'China' -and $ArtemisDownloads.scrcpy_url.StartsWith('https://github.com/')) { if ($ArtemisDownloads.mode -eq 'China' -and $ArtemisDownloads.scrcpy_url.StartsWith('https://github.com/')) {
Write-Host ' [WARN] China mode has no non-GitHub scrcpy source. Configure overrides.scrcpy_url or install scrcpy manually.' -ForegroundColor Yellow Write-Host (Get-LaunchText ' [WARN] China mode has no non-GitHub scrcpy source. Configure overrides.scrcpy_url or install scrcpy manually.' ' [提示] 国内镜像中没有可用的 scrcpy 下载地址;请设置 config/downloads.json 的 overrides.scrcpy_url,或自行安装。') -ForegroundColor Yellow
return $false return $false
} }
try { try {
@@ -244,12 +252,12 @@ function Install-PortableScrcpy {
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path "$scrcpyDir\scrcpy.exe") { if (Test-Path "$scrcpyDir\scrcpy.exe") {
$env:PATH = "$scrcpyDir;$env:PATH" $env:PATH = "$scrcpyDir;$env:PATH"
Write-Host " [OK] Portable scrcpy installed in user space." -ForegroundColor Green Write-Host (Get-LaunchText " [OK] Portable scrcpy installed in user space." " [完成] scrcpy 已安装。") -ForegroundColor Green
return $true return $true
} }
} }
} catch { } catch {
Write-Host " [WARN] Failed to install portable scrcpy: $_" -ForegroundColor DarkYellow Write-Host (Get-LaunchText " [WARN] Failed to install portable scrcpy: $_" " [提示] scrcpy 安装失败:$_") -ForegroundColor DarkYellow
} }
return $false return $false
} }
@@ -295,7 +303,7 @@ function Install-PortableNode {
} }
} }
Write-Host " [INFO] Installing portable Node.js LTS (v22.23.2) in user space..." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Installing portable Node.js LTS (v22.23.2) in user space..." " [安装] 正在安装 Node.js LTS v22.23.2(当前用户)...") -ForegroundColor Cyan
try { try {
$nodeVer = "v22.23.2" $nodeVer = "v22.23.2"
$arch = if ([System.Environment]::Is64BitOperatingSystem) { $arch = if ([System.Environment]::Is64BitOperatingSystem) {
@@ -319,12 +327,12 @@ function Install-PortableNode {
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path "$nodeDir\node.exe") { if (Test-Path "$nodeDir\node.exe") {
$env:PATH = "$nodeDir;$env:PATH" $env:PATH = "$nodeDir;$env:PATH"
Write-Host " [OK] Portable Node.js $nodeVer installed in user space." -ForegroundColor Green Write-Host (Get-LaunchText " [OK] Portable Node.js $nodeVer installed in user space." " [完成] Node.js $nodeVer 已安装。") -ForegroundColor Green
return $true return $true
} }
} }
} catch { } catch {
Write-Host " [WARN] Failed to install portable Node.js: $_" -ForegroundColor DarkYellow Write-Host (Get-LaunchText " [WARN] Failed to install portable Node.js: $_" " [提示] Node.js 安装失败:$_") -ForegroundColor DarkYellow
} }
return $false return $false
} }
@@ -344,16 +352,21 @@ try {
if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) { if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) {
if (Test-Path -LiteralPath (Join-Path $RootDir '.env.example')) { if (Test-Path -LiteralPath (Join-Path $RootDir '.env.example')) {
Copy-Item -LiteralPath (Join-Path $RootDir '.env.example') -Destination (Join-Path $RootDir '.env') -ErrorAction Stop Copy-Item -LiteralPath (Join-Path $RootDir '.env.example') -Destination (Join-Path $RootDir '.env') -ErrorAction Stop
Write-Host ' [OK] Created .env from .env.example. Configure your model in the web UI.' -ForegroundColor Green Write-Host (Get-LaunchText ' [OK] Created .env from .env.example. Configure your model in the web UI.' ' [完成] 已按模板创建 .env;请在网页中配置模型。') -ForegroundColor Green
} }
} }
Write-Host ' [CHECK] Refreshing already-configured MCP clients...' -ForegroundColor Cyan if (-not $SkipMcpSync) {
Write-Host (Get-LaunchText ' [CHECK] Installing/updating MCP for detected AI clients...' ' [检查] 正在为检测到的 AI 客户端安装或更新 MCP...') -ForegroundColor Cyan
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp & $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
Write-Host ' [WARN] MCP refresh failed. Existing client settings were not replaced; check the errors above.' -ForegroundColor Yellow 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 ' [OK] Setup complete. Run start.bat to launch Artemis.' -ForegroundColor Green Write-Host (Get-LaunchText ' [OK] Setup complete. Run start.bat to launch Artemis.' ' [完成] 环境已准备好。运行 start-cn.bat 启动 ARTEMIS。') -ForegroundColor Green
exit 0 exit 0
} }
@@ -371,8 +384,8 @@ if ($missingCore.Count -gt 0) {
$useWinGet = $false $useWinGet = $false
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) { if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) {
if ([Console]::IsInputRedirected -eq $false) { if ([Console]::IsInputRedirected -eq $false) {
Write-Host " [INFO] Missing components detected: $($missingCore -join ', ')." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Missing components detected: $($missingCore -join ', ')." " [检查] 缺少组件:$($missingCore -join ', ')。") -ForegroundColor Cyan
$ans = Read-Host " Install system packages via WinGet? [Y/n]" $ans = Read-Host (Get-LaunchText " Install system packages via WinGet? [Y/n]" " 通过 WinGet 安装?[Y/n]")
if ($ans -eq "" -or $ans -match "^[Yy]") { if ($ans -eq "" -or $ans -match "^[Yy]") {
$useWinGet = $true $useWinGet = $true
} }
@@ -382,7 +395,7 @@ if ($missingCore.Count -gt 0) {
} }
if ($useWinGet) { if ($useWinGet) {
Write-Host " [INFO] Auto-installing missing components ($($missingCore -join ', '))..." -ForegroundColor DarkYellow Write-Host (Get-LaunchText " [INFO] Auto-installing missing components ($($missingCore -join ', '))..." " [安装] 正在安装缺少的组件:$($missingCore -join ', ')...") -ForegroundColor DarkYellow
if ($missingCore -contains "adb") { if ($missingCore -contains "adb") {
winget install --id Google.PlatformTools -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null winget install --id Google.PlatformTools -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null
} }
@@ -401,7 +414,7 @@ if ($missingCore.Count -gt 0) {
} }
if (-not (Test-CommandExists "scrcpy")) { if (-not (Test-CommandExists "scrcpy")) {
if (-not (Install-PortableScrcpy)) { if (-not (Install-PortableScrcpy)) {
Write-Host ' [WARN] scrcpy download failed. Set overrides.scrcpy_url in config/downloads.json to a reachable release ZIP, then retry.' -ForegroundColor Yellow Write-Host (Get-LaunchText ' [WARN] scrcpy download failed. Set overrides.scrcpy_url in config/downloads.json to a reachable release ZIP, then retry.' ' [提示] scrcpy 下载失败。请在 config/downloads.json 中设置可访问的 overrides.scrcpy_url,然后重试。') -ForegroundColor Yellow
} }
} }
} }
@@ -415,19 +428,19 @@ $ShowcaseIndexAlt1 = "$RootDir\apps\showcase_ui\dist\browser\index.html"
$ShowcaseIndexAlt2 = "$RootDir\apps\showcase_ui\dist\index.html" $ShowcaseIndexAlt2 = "$RootDir\apps\showcase_ui\dist\index.html"
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') frontend-check & $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') frontend-check
$frontendChanged = $LASTEXITCODE -eq 10 $frontendChanged = $LASTEXITCODE -eq 10
if ($LASTEXITCODE -notin @(0, 10)) { Write-Host ' [ERROR] Could not check frontend sources.' -ForegroundColor Red; exit 1 } if ($LASTEXITCODE -notin @(0, 10)) { Write-Host (Get-LaunchText ' [ERROR] Could not check frontend sources.' ' [错误] 无法检查前端源码。') -ForegroundColor Red; exit 1 }
if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Path $ShowcaseIndexAlt1)) -and (-not (Test-Path $ShowcaseIndexAlt2)))) { if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Path $ShowcaseIndexAlt1)) -and (-not (Test-Path $ShowcaseIndexAlt2)))) {
if (-not (Test-NodeCompatible)) { if (-not (Test-NodeCompatible)) {
if (Test-CommandExists "node") { if (Test-CommandExists "node") {
$curVer = (& node -v 2>$null) $curVer = (& node -v 2>$null)
Write-Host " [INFO] Detected Node.js $curVer, but Angular CLI requires Node.js >= v22.22.0." -ForegroundColor Yellow Write-Host (Get-LaunchText " [INFO] Detected Node.js $curVer, but Angular CLI requires Node.js >= v22.22.0." " [检查] 检测到 Node.js $curVer;前端需要 v22.22.0 或更新版本。") -ForegroundColor Yellow
} else { } else {
Write-Host " [INFO] Node.js/npm not found (required for Showcase UI)." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Node.js/npm not found (required for Showcase UI)." " [检查] 未找到 Node.js/npm,网页构建需要安装。") -ForegroundColor Cyan
} }
# 1. Try nvm if available on Windows # 1. Try nvm if available on Windows
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "nvm")) { if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "nvm")) {
Write-Host " [INFO] Installing Node.js 22 LTS via nvm..." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Installing Node.js 22 LTS via nvm..." " [安装] 正在通过 nvm 安装 Node.js 22 LTS...") -ForegroundColor Cyan
& nvm install 22.23.2 | Out-Null & nvm install 22.23.2 | Out-Null
& nvm use 22.23.2 | Out-Null & nvm use 22.23.2 | Out-Null
Update-EnvironmentPath Update-EnvironmentPath
@@ -438,8 +451,8 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
$useWinGetNode = $false $useWinGetNode = $false
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) { if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) {
if ([Console]::IsInputRedirected -eq $false) { if ([Console]::IsInputRedirected -eq $false) {
Write-Host " [INFO] Node.js >= v22.22.0 required for Showcase UI." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Node.js >= v22.22.0 required for Showcase UI." " [检查] 网页需要 Node.js v22.22.0 或更新版本。") -ForegroundColor Cyan
$ans = Read-Host " Install/Upgrade Node.js via WinGet (may require Administrator)? [Y/n]" $ans = Read-Host (Get-LaunchText " Install/Upgrade Node.js via WinGet (may require Administrator)? [Y/n]" " 通过 WinGet 安装/升级 Node.js(可能需要管理员权限)?[Y/n]")
if ($ans -eq "" -or $ans -match "^[Yy]") { if ($ans -eq "" -or $ans -match "^[Yy]") {
$useWinGetNode = $true $useWinGetNode = $true
} }
@@ -449,7 +462,7 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
} }
if ($useWinGetNode) { if ($useWinGetNode) {
Write-Host " [INFO] Installing/Upgrading Node.js LTS via WinGet..." -ForegroundColor Yellow Write-Host (Get-LaunchText " [INFO] Installing/Upgrading Node.js LTS via WinGet..." " [安装] 正在通过 WinGet 安装/升级 Node.js LTS...") -ForegroundColor Yellow
winget install --id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null winget install --id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null
Update-EnvironmentPath Update-EnvironmentPath
} }
@@ -463,36 +476,36 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
if (Test-NodeCompatible) { if (Test-NodeCompatible) {
$nodeVer = (& node -v 2>$null) $nodeVer = (& node -v 2>$null)
Write-Host " [OK] Node.js $nodeVer is ready." -ForegroundColor Green Write-Host (Get-LaunchText " [OK] Node.js $nodeVer is ready." " [完成] Node.js $nodeVer 已就绪。") -ForegroundColor Green
Write-Host " [INFO] Showcase UI sources/dependencies changed or build missing. Compiling..." -ForegroundColor Yellow Write-Host (Get-LaunchText " [INFO] Showcase UI sources/dependencies changed or build missing. Compiling..." " [构建] 前端源码/依赖有变化或缺少构建结果,准备重新构建...") -ForegroundColor Yellow
Push-Location "$RootDir\apps\showcase_ui" Push-Location "$RootDir\apps\showcase_ui"
try { try {
$npmExec = if (Test-CommandExists "npm.cmd") { "npm.cmd" } else { "npm" } $npmExec = if (Test-CommandExists "npm.cmd") { "npm.cmd" } else { "npm" }
Write-Host " [INFO] Installing frontend npm dependencies..." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Installing frontend npm dependencies..." " [安装] 正在安装前端 npm 依赖...") -ForegroundColor Cyan
& $npmExec ci --no-audit --no-fund --loglevel=error & $npmExec ci --no-audit --no-fund --loglevel=error
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "npm ci failed (exit $LASTEXITCODE). Check the network and retry." throw "npm ci failed (exit $LASTEXITCODE). Check the network and retry."
} }
Write-Host " [INFO] Building Angular frontend application..." -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Building Angular frontend application..." " [构建] 正在构建 Angular 网页...") -ForegroundColor Cyan
& $npmExec run build & $npmExec run build
if ($LASTEXITCODE -ne 0) { throw "Frontend build failed (exit $LASTEXITCODE)." } if ($LASTEXITCODE -ne 0) { throw "Frontend build failed (exit $LASTEXITCODE)." }
if (Test-Path $ShowcaseIndex) { if (Test-Path $ShowcaseIndex) {
Write-Host " [OK] Showcase UI compiled successfully." -ForegroundColor Green Write-Host (Get-LaunchText " [OK] Showcase UI compiled successfully." " [完成] 网页构建成功。") -ForegroundColor Green
} elseif ((Test-Path $ShowcaseIndexAlt1) -or (Test-Path $ShowcaseIndexAlt2)) { } elseif ((Test-Path $ShowcaseIndexAlt1) -or (Test-Path $ShowcaseIndexAlt2)) {
Write-Host " [OK] Showcase UI compiled successfully." -ForegroundColor Green Write-Host (Get-LaunchText " [OK] Showcase UI compiled successfully." " [完成] 网页构建成功。") -ForegroundColor Green
} else { } else {
throw 'Frontend build did not produce index.html.' throw 'Frontend build did not produce index.html.'
} }
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') frontend-mark & $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') frontend-mark
if ($LASTEXITCODE -ne 0) { throw 'Could not save frontend build fingerprint.' } if ($LASTEXITCODE -ne 0) { throw 'Could not save frontend build fingerprint.' }
} catch { } catch {
Write-Host " [ERROR] Failed to build Showcase UI: $_" -ForegroundColor Red Write-Host (Get-LaunchText " [ERROR] Failed to build Showcase UI: $_" " [错误] 网页构建失败:$_") -ForegroundColor Red
exit 1 exit 1
} finally { } finally {
Pop-Location Pop-Location
} }
} else { } else {
Write-Host " [ERROR] A compatible Node.js/npm could not be installed. Install Node.js LTS and retry." -ForegroundColor Red Write-Host (Get-LaunchText " [ERROR] A compatible Node.js/npm could not be installed. Install Node.js LTS and retry." " [错误] 无法安装兼容的 Node.js/npm。请安装 Node.js LTS 后重试。") -ForegroundColor Red
exit 1 exit 1
} }
} }
@@ -505,7 +518,7 @@ Start-LocalAdbServer
# Launch via `python -m artemis` (not the `artemis.exe` console-script shim) so the # Launch via `python -m artemis` (not the `artemis.exe` console-script shim) so the
# long-running server never locks .venv\Scripts\artemis.exe against `uv sync` reinstalls. # long-running server never locks .venv\Scripts\artemis.exe against `uv sync` reinstalls.
Write-Host " [INFO] Launching Artemis Showcase UI & Admin Console..." -ForegroundColor Green Write-Host (Get-LaunchText " [INFO] Launching Artemis Showcase UI & Admin Console..." " [启动] 正在启动 ARTEMIS 网页与管理服务...") -ForegroundColor Green
if ($NoOpen) { if ($NoOpen) {
& $ArtemisPython -m artemis ui --port $Port --no-open & $ArtemisPython -m artemis ui --port $Port --no-open
} else { } else {
+332 -106
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
@@ -19,6 +19,10 @@ from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
def launch_text(english: str, chinese: str) -> str:
return chinese if os.environ.get("ARTEMIS_LAUNCH_LANG") == "zh-CN" else english
def git(root: Path, *args: str, timeout: int = 15) -> str: def git(root: Path, *args: str, timeout: int = 15) -> str:
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "Never"} env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "Never"}
result = subprocess.run( result = subprocess.run(
@@ -50,19 +54,28 @@ def update_source(root: Path, port: int = 8000) -> int:
or (status.get("status") == "running" and status.get("pid")) or (status.get("status") == "running" and status.get("pid"))
): ):
print( print(
"[WAIT] Tasks are active. Finish/stop them before updating dependencies or restarting." launch_text(
"[WAIT] Tasks are active. Finish/stop them before updating dependencies or restarting.",
"[等待] 有任务正在执行或排队。请先处理任务,再更新依赖或重启。",
)
) )
return 20 return 20
except (OSError, ValueError): except (OSError, ValueError):
pass pass
if not shutil.which("git"): if not shutil.which("git"):
print( print(
"[SKIP] Git is not installed. Continuing with local source; install Git for automatic source updates." launch_text(
"[SKIP] Git is not installed. Continuing with local source; install Git for automatic source updates.",
"[跳过] 未安装 Git,继续使用当前源码;安装 Git 后可自动检查更新。",
)
) )
return 0 return 0
if not (root / ".git").exists(): if not (root / ".git").exists():
print( print(
"[SKIP] This source ZIP has no Git metadata. Use a Git clone for automatic source updates; local files are preserved." launch_text(
"[SKIP] This older source ZIP has no Git metadata. Use a new release ZIP or Git clone for automatic updates; local files are preserved.",
"[跳过] 旧版源码包不含 Git 信息。请使用新版源码包或 Git 克隆;当前文件保持不变。",
)
) )
return 0 return 0
try: try:
@@ -79,41 +92,58 @@ def update_source(root: Path, port: int = 8000) -> int:
raise RuntimeError( raise RuntimeError(
"Use a clean Git remote URL and a credential manager, not embedded credentials." "Use a clean Git remote URL and a credential manager, not embedded credentials."
) )
print("[CHECK] Checking the current branch's configured upstream...") print(
launch_text(
"[CHECK] Checking the current branch's configured upstream...",
"[检查] 正在检查当前分支的远程更新...",
)
)
git(root, "fetch", "--no-tags", remote, merge_ref, timeout=45) git(root, "fetch", "--no-tags", remote, merge_ref, timeout=45)
target = git(root, "rev-parse", "FETCH_HEAD") target = git(root, "rev-parse", "FETCH_HEAD")
current = git(root, "rev-parse", "HEAD") current = git(root, "rev-parse", "HEAD")
if current == target: if current == target:
print("[OK] Source is up to date.") print(launch_text("[OK] Source is up to date.", "[完成] 源码已是最新版。"))
return 0 return 0
if git(root, "status", "--porcelain"): if git(root, "status", "--porcelain"):
print( print(
"[SKIP] Upstream differs, but local changes exist. Commit/back up your work before updating; continuing locally." launch_text(
"[SKIP] Upstream differs, but local changes exist. Commit/back up your work before updating; continuing locally.",
"[跳过] 发现本地修改;请先提交或备份。此次继续使用本地源码。",
)
) )
return 0 return 0
ancestor = git(root, "merge-base", "HEAD", target) ancestor = git(root, "merge-base", "HEAD", target)
if ancestor != current: if ancestor != current:
print( print(
"[SKIP] Local branch is ahead or diverged. No automatic merge/rebase; continuing locally." launch_text(
"[SKIP] Local branch is ahead or diverged. No automatic merge/rebase; continuing locally.",
"[跳过] 本地分支领先或分叉,不自动合并;继续使用本地源码。",
)
) )
return 0 return 0
git(root, "merge", "--ff-only", target, timeout=30) git(root, "merge", "--ff-only", target, timeout=30)
print( print(
f"[UPDATED] Source {current[:12]} -> {target[:12]}. Reloading launcher and checking dependencies." launch_text(
f"[UPDATED] Source {current[:12]} -> {target[:12]}. Reloading launcher and checking dependencies.",
f"[已更新] 源码 {current[:12]} -> {target[:12]},重新载入启动脚本并检查依赖。",
)
) )
return 10 return 10
except (RuntimeError, subprocess.TimeoutExpired) as exc: except (RuntimeError, subprocess.TimeoutExpired) as exc:
print( print(
f"[WARN] {exc if isinstance(exc, RuntimeError) else 'Git update timed out.'} Continuing with local source." launch_text(
f"[WARN] {exc if isinstance(exc, RuntimeError) else 'Git update timed out.'} Continuing with local source.",
"[提示] Git 更新失败或超时;请检查网络、凭据和仓库状态。继续使用当前源码。",
)
) )
return 0 return 0
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:
@@ -124,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:
@@ -216,73 +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(
path,
keys,
root,
sys.executable,
ARTEMIS_MCP_TOOLS,
template=template,
install_missing=True,
)
if keys if keys
else refresh_codex(path, root, sys.executable, ARTEMIS_MCP_TOOLS) else refresh_codex(
path,
root,
sys.executable,
ARTEMIS_MCP_TOOLS,
template=template,
install_missing=True,
) )
if updated: )
print(f"[UPDATED] Existing MCP configuration: {path}") if changed:
changed += 1
except (OSError, ValueError, TypeError, AttributeError):
print(f"[WARN] Could not safely refresh {path}; original configuration preserved.")
print( print(
f"[OK] MCP source is current; refreshed {changed} existing client configuration(s). Reload the MCP connection in your AI client after a source update." launch_text(
f"[{'UPDATED' if existed else 'INSTALLED'}] MCP configuration: {path}",
f"[{'已更新' if existed else '已安装'}] MCP 配置:{path}",
) )
print("[TIP] For a new client run: uv run artemis mcp --install <client>") )
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):
print(
launch_text(
f"[ERROR] Could not safely synchronize {path}; original configuration preserved.",
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(
launch_text(
"[ACTION] Reload or restart your AI client to load the current MCP process and tool schemas.",
"[操作] 请在 AI 客户端重新加载 MCP 或重启客户端,使新版进程和工具签名生效。",
)
)
if not (installed or updated_count or ready or skipped or errors):
print(
launch_text(
"[TIP] No supported client detected; install one with: 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:
@@ -314,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`;正在执行、排队或等待指导的批次不能删除。删除会同时移除该批次任务历史、报告和文件,无法撤销。
## 报告 ## 报告
+30 -3
View File
@@ -1,6 +1,33 @@
@echo off @echo off
rem Use China mirrors for first setup and subsequent launches. chcp 65001 >nul 2>&1
rem All start.bat arguments, including -SetupOnly, are supported.
setlocal DisableDelayedExpansion setlocal DisableDelayedExpansion
set "ARTEMIS_LAUNCH_LANG=zh-CN"
title ARTEMIS 国内镜像启动
if /i "%~1"=="-Help" goto help
if /i "%~1"=="--help" goto help
echo ======================================================
echo ARTEMIS 中文启动 - 使用国内镜像
echo ======================================================
echo 正在检查已有环境、项目更新及依赖;首次启动可能需要下载组件。
echo 镜像地址和下载超时可在 config\downloads.json 中调整。
echo.
call "%~dp0start.bat" -Mirror China %* call "%~dp0start.bat" -Mirror China %*
exit /b %ERRORLEVEL% set "ARTEMIS_CN_EXIT_CODE=%ERRORLEVEL%"
if not "%ARTEMIS_CN_EXIT_CODE%"=="0" echo 启动未完成;请查看上方错误信息,并检查网络或镜像设置。
endlocal & exit /b %ARTEMIS_CN_EXIT_CODE%
:help
echo 用法:start-cn.bat [选项]
echo.
echo -SetupOnly 安装或检查环境,不启动网页
echo -Port 8001 使用指定端口,默认 8000
echo -NoOpen 启动服务后不自动打开浏览器
echo -SkipUpdate 跳过 Git 源码更新检查
echo -SkipMcpSync 仅启动网页,跳过 MCP 安装和更新
echo -Help 显示本说明
echo.
echo 国内镜像设置:config\downloads.json
echo 首次运行需联网;后续运行会检查源码、依赖,并同步已安装 AI 客户端的 MCP。
endlocal & exit /b 0
+3 -3
View File
@@ -15,12 +15,12 @@ rem limitations under the License.
chcp 65001 >nul 2>&1 chcp 65001 >nul 2>&1
setlocal DisableDelayedExpansion setlocal DisableDelayedExpansion
title Artemis Mobile Agent UI if /i "%ARTEMIS_LAUNCH_LANG%"=="zh-CN" (title ARTEMIS 手机自动化测试) else (title Artemis Mobile Agent UI)
pushd "%~dp0" || exit /b 1 pushd "%~dp0" || exit /b 1
echo ====================================================== echo ======================================================
echo Artemis Autonomous Mobile Agent UI if /i "%ARTEMIS_LAUNCH_LANG%"=="zh-CN" (echo ARTEMIS 手机自动化测试) else (echo Artemis Autonomous Mobile Agent UI)
echo ====================================================== echo ======================================================
echo. echo.
@@ -33,7 +33,7 @@ set "ARTEMIS_EXIT_CODE=%ERRORLEVEL%"
if not "%ARTEMIS_EXIT_CODE%"=="0" ( if not "%ARTEMIS_EXIT_CODE%"=="0" (
echo. echo.
echo An error occurred while starting Artemis. if /i "%ARTEMIS_LAUNCH_LANG%"=="zh-CN" (echo ARTEMIS 启动失败,请查看上方详细信息。) else (echo An error occurred while starting Artemis.)
if not defined ARTEMIS_NO_PAUSE pause if not defined ARTEMIS_NO_PAUSE pause
) )
popd popd
+199 -8
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, Mock
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,123 @@ 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
@pytest.mark.parametrize("stop_on_failure", [False, True])
async def test_timed_out_case_releases_next_batch_case(isolated, monkeypatch, stop_on_failure):
from apps.admin_console import server
from artemis.runtime.device_pool import DeviceStatus
batch = service.create_batch(payload(timeout_seconds=1, stop_on_failure=stop_on_failure))
service.batch_action(batch["id"], "resume")
first = isolated.jobs(batch["id"])[0]
isolated.save_job({**first, "status": "running", "started_at": time.time() - 2})
monkeypatch.setattr(
service, "state", type("State", (), {"is_paused": False, "active_runs": {}})()
)
stop = Mock(return_value=True)
enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]})
monkeypatch.setattr(server.task_queue_service, "stop_tasks", stop)
monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue)
monkeypatch.setattr(
"artemis.runtime.device_pool.try_list_devices_async",
AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]),
)
monkeypatch.setattr(reports, "export_batch", Mock())
await service.tick_batches()
assert isolated.get("batch", batch["id"])["status"] == "running"
assert "超时" in isolated.job(first["session_id"])["error"]
stop.assert_called_once_with(session_id=first["session_id"])
await service.tick_batches()
stop.assert_called_once()
await service.on_terminal({"session_id": first["session_id"]}, "cancelled")
await service.tick_batches()
statuses = [job["status"] for job in isolated.jobs(batch["id"])]
assert statuses == (["failed", "skipped"] if stop_on_failure else ["failed", "queued"])
assert enqueue.await_count == (0 if stop_on_failure else 1)
@pytest.mark.asyncio
async def test_queued_case_does_not_consume_execution_timeout(isolated, monkeypatch):
from apps.admin_console import server
batch = service.create_batch(payload(timeout_seconds=1))
service.batch_action(batch["id"], "resume")
first = isolated.jobs(batch["id"])[0]
isolated.save_job({**first, "status": "queued", "started_at": time.time() - 30})
monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})())
stop = Mock()
monkeypatch.setattr(server.task_queue_service, "stop_tasks", stop)
await service.tick_batches()
stop.assert_not_called()
assert isolated.job(first["session_id"])["status"] == "queued"
assert isolated.get("batch", batch["id"])["status"] == "running"
@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_optional_timeout_warnings_are_not_reported_as_task_failure(tmp_path):
(tmp_path / "stdout.log").write_text(
"⚠ Could not clean unowned screen wake locks on emulator-5554: "
"Timed out acquiring the Artemis awake-lease mutex.\n"
"⚠ VisualStepSummarizer: Error generating summary for step 3: \n"
"ℹ StepMemoryService: Retrying Step 3 (attempt 4) after 1.0s\n",
encoding="utf-8",
)
assert reports.failure_reason(tmp_path, "failed", None) == (
"任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
)
(tmp_path / "stderr.log").write_text(
"TimeoutError: model request timed out after 300 seconds\n", encoding="utf-8"
)
reason = reports.failure_reason(tmp_path, "failed", None)
assert "model request timed out" in reason
assert "awake-lease" not in reason
(tmp_path / "stderr.log").write_text(
"DeviceBusyError: device queue reservation was cancelled\n", encoding="utf-8"
)
assert reports.failure_reason(tmp_path, "failed", None) == (
"DeviceBusyError: device queue reservation was cancelled"
)
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 +483,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 +513,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 +532,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):
@@ -59,6 +59,17 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret" assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret"
def test_new_install_uses_playones_endpoint_without_overriding_saved_url(monkeypatch):
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
expected = "https://api-n-cd.playones.com/v1"
assert Settings(_env_file=None).OPENAI_BASE_URL == expected
assert service.ModelSettingsUpdate(model="test").base_url == expected
monkeypatch.setattr(settings, "OPENAI_BASE_URL", None)
assert service.get_model_settings()["base_url"] == expected
monkeypatch.setattr(settings, "OPENAI_BASE_URL", "https://saved.example/v1")
assert service.get_model_settings()["base_url"] == "https://saved.example/v1"
@pytest.mark.parametrize( @pytest.mark.parametrize(
"url", "url",
[ [
@@ -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"]
@@ -636,6 +636,32 @@ async def test_run_no_tool_call_turn_is_nudged_not_terminated(mock_context):
and any(EXECUTION_RESULT_MARKER in b.get("text", "") for b in m.content) and any(EXECUTION_RESULT_MARKER in b.get("text", "") for b in m.content)
for m in turn2_messages for m in turn2_messages
) )
assert not any(
isinstance(m, AIMessage) and m.content == "thinking only" for m in turn2_messages
)
@pytest.mark.asyncio
@pytest.mark.parametrize("attempts", [1, 3])
async def test_flash_stops_repeated_no_tool_responses(mock_context, monkeypatch, attempts):
from artemis.config.settings import settings
monkeypatch.setattr(settings, "ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS", attempts)
responses = [AIMessage(content="I will click now.", tool_calls=[]) for _ in range(attempts)]
with (
patch("artemis.controllers.unified_controller.get_driver"),
patch(
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
AsyncMock(return_value=_INITIAL_OBSERVATION),
),
):
runner = _make_runner(mock_context, responses, max_turns=0)
result = await runner.run(State(initial_goal="Open Settings"))
assert result["status"] == "failed"
assert f"连续 {attempts} 次未调用工具" in result["explanation"]
assert runner._invoke_model.await_count == attempts
runner.executor.execute.assert_not_awaited()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -14,6 +14,7 @@
"""Unit tests for VisualStepSummarizer and Context Compressor in Flash profile.""" """Unit tests for VisualStepSummarizer and Context Compressor in Flash profile."""
import asyncio
from unittest.mock import ANY, AsyncMock, Mock, call from unittest.mock import ANY, AsyncMock, Mock, call
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -106,6 +107,29 @@ async def test_summarizer_retries_independently(mock_context):
assert attempt_count == 2 assert attempt_count == 2
@pytest.mark.asyncio
async def test_visual_summary_uses_its_own_configured_timeout(mock_context):
summarizer = VisualStepSummarizer(mock_context, retry_limit=0, timeout_seconds=0.01)
async def slow_response(_messages):
await asyncio.sleep(1)
summarizer._llm = Mock()
summarizer._llm.ainvoke = AsyncMock(side_effect=slow_response)
summarizer.dispatch(
step_number=1,
action_name="click",
action_args={"target": [100, 100]},
pre_img_bytes=b"pre",
post_img_bytes=b"post",
exec_outcome="Clicked",
)
await summarizer.flush()
assert summarizer.has_failed(1)
assert summarizer._llm.ainvoke.await_count == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_summarizer_bounded_retry_marks_failed(mock_context): async def test_summarizer_bounded_retry_marks_failed(mock_context):
"""Persistent failures exit after 1 + retry_limit attempts with an explicit failed state.""" """Persistent failures exit after 1 + retry_limit attempts with an explicit failed state."""
+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
@@ -13,6 +13,8 @@
# limitations under the License. # limitations under the License.
import json import json
from pathlib import Path
import time
import pytest import pytest
@@ -126,3 +128,30 @@ def test_migration_cleanup_never_drains_a_live_legacy_owner(monkeypatch):
cleanup._drain_references = lambda: pytest.fail("must not drain a live owner") cleanup._drain_references = lambda: pytest.fail("must not drain a live owner")
assert cleanup.cleanup_unowned_references() is False assert cleanup.cleanup_unowned_references() is False
def test_optional_cleanup_skips_a_busy_mutex_without_delaying_task_start(monkeypatch):
owner = ScreenAwakeLease("device-123")
owner.mutex_path.write_text(json.dumps(owner._payload()), encoding="utf-8")
cleanup = ScreenAwakeLease("device-123")
monkeypatch.setattr(cleanup, "_drain_references", lambda: pytest.fail("must not drain"))
started_at = time.monotonic()
assert cleanup.cleanup_unowned_references() is False
assert time.monotonic() - started_at < 2.0
def test_unremovable_stale_mutex_still_obeys_deadline(monkeypatch):
lease = ScreenAwakeLease("device-123")
lease.mutex_path.write_text(json.dumps({"pid": 999999}), encoding="utf-8")
original_unlink = Path.unlink
def refuse_mutex_unlink(path, *args, **kwargs):
if path == lease.mutex_path:
raise PermissionError("file is in use")
return original_unlink(path, *args, **kwargs)
monkeypatch.setattr(Path, "unlink", refuse_mutex_unlink)
with pytest.raises(TimeoutError, match="awake-lease mutex"):
with lease._mutex(wait_timeout_seconds=0.1):
pytest.fail("stale mutex must not be acquired")
+17
View File
@@ -1,3 +1,4 @@
import os
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from artemis.runtime.awake_service import ( from artemis.runtime.awake_service import (
@@ -37,6 +38,22 @@ def test_usb_policy_is_primary_when_android_reports_it_active(mock_run, lease_ty
assert not any("KEYCODE_UNKNOWN" in command for command in commands) assert not any("KEYCODE_UNKNOWN" in command for command in commands)
@patch.dict(os.environ, {"ARTEMIS_TASK_WORKER": "1"})
@patch("artemis.runtime.awake_service.ScreenAwakeLease")
@patch("artemis.runtime.awake_service.subprocess.run")
def test_queued_worker_does_not_run_optional_legacy_cleanup(mock_run, lease_type):
mock_run.side_effect = [
completed(),
completed(),
completed(),
completed("2\n"),
completed(" mStayOn=true\n"),
]
assert _configure_usb_stay_awake("device-123") == AWAKE_STRATEGY_USB
lease_type.assert_not_called()
@patch("artemis.runtime.awake_service.ScreenAwakeLease") @patch("artemis.runtime.awake_service.ScreenAwakeLease")
@patch("artemis.runtime.awake_service.subprocess.run") @patch("artemis.runtime.awake_service.subprocess.run")
def test_inactive_usb_policy_falls_back_to_effective_host_heartbeat(mock_run, _lease_type): def test_inactive_usb_policy_falls_back_to_effective_host_heartbeat(mock_run, _lease_type):
+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")
+7 -14
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""Keep bootstrap scripts ASCII for Windows PowerShell 5.1 compatibility. """Require ASCII or UTF-8 with BOM for Windows PowerShell 5.1 scripts.
BOM-less scripts are decoded using the system ANSI code page. UTF-8 symbols BOM-less scripts are decoded using the system ANSI code page. UTF-8 symbols
can become quote delimiters under cp1252 and cause parse errors. can become quote delimiters under cp1252 and cause parse errors.
@@ -31,18 +31,11 @@ def test_powershell_scripts_are_discovered():
@pytest.mark.parametrize("script", POWERSHELL_SCRIPTS, ids=lambda p: p.name) @pytest.mark.parametrize("script", POWERSHELL_SCRIPTS, ids=lambda p: p.name)
def test_powershell_script_is_pure_ascii(script): def test_powershell_script_has_windows_compatible_encoding(script):
raw = script.read_bytes() raw = script.read_bytes()
offending = [] if any(byte > 0x7F for byte in raw):
line_no = 1 assert raw.startswith(b"\xef\xbb\xbf"), (
for index, byte in enumerate(raw): f"{script.name} contains non-ASCII bytes without a UTF-8 BOM; "
if byte == 0x0A: "Windows PowerShell 5.1 would use the system ANSI code page."
line_no += 1
elif byte > 0x7F:
offending.append((line_no, index, hex(byte)))
assert not offending, (
f"{script.name} contains non-ASCII bytes at (line, offset, byte) "
f"{offending[:10]}; Windows PowerShell 5.1 decodes BOM-less .ps1 files "
"with the system ANSI code page. Use ASCII status labels."
) )
raw.decode("utf-8-sig")
+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)
+110 -12
View File
@@ -1,36 +1,134 @@
import importlib.util import importlib.util
from pathlib import Path from pathlib import Path
import shutil
import subprocess
import zipfile import zipfile
import pytest
def test_archive_keeps_source_and_excludes_local_builds_and_secrets(tmp_path):
script = Path(__file__).resolve().parents[2] / "scripts/package_source.py" SCRIPT = Path(__file__).resolve().parents[2] / "scripts/package_source.py"
spec = importlib.util.spec_from_file_location("package_source", script) spec = importlib.util.spec_from_file_location("package_source", SCRIPT)
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) spec.loader.exec_module(module)
def git(root, *args):
result = subprocess.run(
["git", "-C", str(root), *args], capture_output=True, text=True, timeout=30
)
assert result.returncode == 0, result.stderr
return result.stdout.strip()
@pytest.fixture
def repository(tmp_path):
if not shutil.which("git"):
pytest.skip("Git required")
root = tmp_path / "source"
root.mkdir()
git(root, "init", "-b", "main")
git(root, "config", "user.name", "Fixture")
git(root, "config", "user.email", "fixture@example.test")
keep = ["start.bat", ".env.example", "uv.lock", "apps/showcase_ui/src/main.ts"] keep = ["start.bat", ".env.example", "uv.lock", "apps/showcase_ui/src/main.ts"]
tracked_builds = [
"artemis/resources/showcase_ui/main.js",
"packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk",
"packages/artemis-accessibility-helper/debug.keystore",
]
omit = [ omit = [
".env", ".env",
".env.local", ".env.local",
".venv/pyvenv.cfg", ".venv/pyvenv.cfg",
"nested/node_modules/foo.js", "nested/node_modules/foo.js",
"apps/showcase_ui/dist/main.js", "apps/showcase_ui/dist/main.js",
"artemis/resources/showcase_ui/main.js",
"__pycache__/a.pyc", "__pycache__/a.pyc",
"traces/data.db", "traces/data.db",
"helper.apk",
"debug.keystore",
".git/config",
".roo/mcp.json", ".roo/mcp.json",
] ]
for name in keep + omit: for name in keep + tracked_builds + omit:
target = tmp_path / name target = root / name
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("fixture") target.write_text("fixture")
(root / ".gitignore").write_text(
".env*\n!.env.example\n.venv/\nnode_modules/\ndist/\ntraces/\n.roo/\n__pycache__/\n"
)
git(root, "add", "--", ".gitignore", *keep, *tracked_builds)
git(root, "commit", "-m", "initial")
git(root, "remote", "add", "eapil", "https://git.example.invalid/team/project.git")
git(root, "config", "branch.main.remote", "eapil")
git(root, "config", "branch.main.merge", "refs/heads/main")
return root, keep, tracked_builds, omit
def test_archive_is_updatable_clean_checkout_without_local_data(repository, tmp_path):
root, keep, tracked_builds, omit = repository
output = tmp_path / "artifacts/source.zip" output = tmp_path / "artifacts/source.zip"
count, digest = module.package_source(tmp_path, output) count, digest = module.package_source(root, output)
assert count == len(keep) assert count > len(keep)
assert len(digest) == 64 assert len(digest) == 64
with zipfile.ZipFile(output) as archive: with zipfile.ZipFile(output) as archive:
assert archive.testzip() is None assert archive.testzip() is None
assert set(archive.namelist()) == {f"artemis/{name}" for name in keep} names = set(archive.namelist())
assert {f"artemis/{name}" for name in keep}.issubset(names)
assert not {f"artemis/{name}" for name in tracked_builds + omit} & names
assert "artemis/.git/config" in names
assert "artemis/.git/index" in names
assert not any(name.startswith("artemis/.git/logs/") for name in names)
assert not any(name.startswith("artemis/.git/hooks/") for name in names)
archive.extractall(tmp_path / "unpacked")
extracted = tmp_path / "unpacked/artemis"
assert git(extracted, "symbolic-ref", "--short", "HEAD") == "main"
assert git(extracted, "rev-parse", "HEAD") == git(root, "rev-parse", "HEAD")
assert git(extracted, "status", "--porcelain") == ""
assert git(extracted, "remote", "get-url", "origin") == (
"https://git.example.invalid/team/project.git"
)
assert git(extracted, "config", "--get", "branch.main.remote") == "origin"
(extracted / tracked_builds[0]).parent.mkdir(parents=True)
(extracted / tracked_builds[0]).write_text("new bundle")
assert git(extracted, "status", "--porcelain") == ""
def test_unpacked_archive_fast_forwards(repository, tmp_path):
root, _, tracked_builds, _ = repository
bare = tmp_path / "upstream.git"
bare.mkdir()
git(bare, "init", "--bare")
git(root, "remote", "add", "test-upstream", str(bare))
git(root, "push", "test-upstream", "main")
archive = tmp_path / "source.zip"
module.package_source(root, archive)
with zipfile.ZipFile(archive) as contents:
contents.extractall(tmp_path / "unpacked")
extracted = tmp_path / "unpacked/artemis"
git(extracted, "remote", "set-url", "origin", str(bare))
built_asset = extracted / tracked_builds[0]
built_asset.parent.mkdir(parents=True)
built_asset.write_text("generated after setup")
(root / "start.bat").write_text("updated")
git(root, "add", "--", "start.bat")
git(root, "commit", "-m", "update launcher")
git(root, "push", "test-upstream", "main")
from scripts.update_installation import update_source
assert update_source(extracted, port=1) == 10
assert (extracted / "start.bat").read_text() == "updated"
assert git(extracted, "status", "--porcelain") == ""
def test_packaging_rejects_uncommitted_changes_and_credentials(repository, tmp_path):
root, _, _, _ = repository
(root / "start.bat").write_text("local change")
with pytest.raises(ValueError, match="Commit tracked changes"):
module.package_source(root, tmp_path / "source.zip")
(root / "start.bat").write_text("fixture")
git(
root,
"remote",
"set-url",
"eapil",
"https://user:secret@git.example.invalid/team/project.git",
)
with pytest.raises(ValueError, match="credential-free HTTPS"):
module.package_source(root, tmp_path / "source.zip")
Generated
+1 -1
View File
@@ -206,7 +206,7 @@ wheels = [
[[package]] [[package]]
name = "artemis" name = "artemis"
version = "1.1.0" version = "1.2.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "adbutils" }, { name = "adbutils" },