feat: add prompt-first batches and pausable task guidance
This commit is contained in:
1 parent
cec9898e4c
commit
057f0c3050
44 files changed
+1080
-191
No files matched your search
@@ -14,6 +14,7 @@ OPENAI_REASONING_EFFORT=high
|
|||||||
|
|
||||||
# 功能开关:保存后对新任务生效
|
# 功能开关:保存后对新任务生效
|
||||||
ARTEMIS_VIDEO_ENABLED=false
|
ARTEMIS_VIDEO_ENABLED=false
|
||||||
|
ARTEMIS_RECORDING_ENABLED=false
|
||||||
ARTEMIS_OUTPUTTER_ENABLED=true
|
ARTEMIS_OUTPUTTER_ENABLED=true
|
||||||
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
||||||
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
|
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
- **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。
|
- **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。
|
||||||
- **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。
|
- **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。
|
||||||
- **批量测试**:下载 Excel 模板、校验导入、顺序执行,支持暂停、继续和复制失败项。
|
- **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。
|
||||||
- **报告与历史**:导出 Word、Excel 和批次 ZIP;历史按日期分组,支持搜索和筛选。
|
- **报告与历史**:导出 Word、Excel、步骤截图、录屏和任务资料 ZIP;历史按日期分组,支持搜索和筛选。
|
||||||
- **AI 接入**:随源码提供 MCP 与 Skill,让 AI 完成设备诊断、任务执行、历史分析和报告交付。
|
- **AI 接入**:随源码提供 MCP 与 Skill,让 AI 完成设备诊断、任务执行、历史分析和报告交付。
|
||||||
|
|
||||||
## 安装与启动
|
## 安装与启动
|
||||||
@@ -34,7 +34,7 @@ cd Artemis-Eapil
|
|||||||
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查
|
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查
|
||||||
```
|
```
|
||||||
|
|
||||||
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。scrcpy 默认从 GitHub 下载,无法访问时可配置可信的 scrcpy_url 或预先安装。
|
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。
|
||||||
|
|
||||||
### macOS / Linux
|
### macOS / Linux
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ uv run artemis mcp --install codex
|
|||||||
|
|
||||||
## 更新与排错
|
## 更新与排错
|
||||||
|
|
||||||
Windows 用户再次运行一键脚本时,会检查 Git 上游、项目依赖、前端构建和已有 MCP 配置,仅对源码做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。更新后需在 AI 客户端重新加载 MCP。
|
Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建和已有 MCP 配置。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。更新后需在 AI 客户端重新加载 MCP。
|
||||||
|
|
||||||
ZIP 安装可继续运行;需要自动拉取源码时,请改用 Git 克隆安装。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
|
ZIP 安装可继续运行;需要自动拉取源码时,请改用 Git 克隆安装。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ class ServerState:
|
|||||||
return {
|
return {
|
||||||
str(run.get("lock_key") or run["device_id"])
|
str(run.get("lock_key") or run["device_id"])
|
||||||
for run in self.active_runs.values()
|
for run in self.active_runs.values()
|
||||||
if run.get("device_id")
|
if run.get("device_id") and not run.get("parked")
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -422,7 +422,7 @@ class SessionRepository:
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
def reconcile_orphaned_sessions(self) -> int:
|
def reconcile_orphaned_sessions(self) -> int:
|
||||||
"""Mark running sessions with no live worker process as failed.
|
"""Mark running sessions with no live worker process as interrupted.
|
||||||
|
|
||||||
This is safe to call both during startup and immediately after a
|
This is safe to call both during startup and immediately after a
|
||||||
forced server stop: live workers, including workers owned by another
|
forced server stop: live workers, including workers owned by another
|
||||||
@@ -442,14 +442,14 @@ class SessionRepository:
|
|||||||
cursor.execute(
|
cursor.execute(
|
||||||
"UPDATE sessions SET status = ?, end_time = ? "
|
"UPDATE sessions SET status = ?, end_time = ? "
|
||||||
"WHERE session_id = ? AND status = ?",
|
"WHERE session_id = ? AND status = ?",
|
||||||
("failed", now, row["session_id"], "running"),
|
("interrupted", now, row["session_id"], "running"),
|
||||||
)
|
)
|
||||||
count += cursor.rowcount
|
count += cursor.rowcount
|
||||||
try:
|
try:
|
||||||
if trace_store.read_status(str(row["session_id"])):
|
if trace_store.read_status(str(row["session_id"])):
|
||||||
trace_store.update_trace_status(
|
trace_store.update_trace_status(
|
||||||
str(row["session_id"]),
|
str(row["session_id"]),
|
||||||
"failed",
|
"interrupted",
|
||||||
error="Process terminated prior to server startup.",
|
error="Process terminated prior to server startup.",
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
|
|||||||
@@ -146,8 +146,13 @@ def report(session_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/reports/{session_id}/{fmt}")
|
@router.get("/reports/{session_id}/{fmt}")
|
||||||
async def download_report(session_id: str, fmt: Literal["docx", "xlsx"]):
|
async def download_report(
|
||||||
|
session_id: str, fmt: Literal["docx", "xlsx", "screenshots", "video", "all"]
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
|
if fmt in {"screenshots", "video", "all"}:
|
||||||
|
path = await asyncio.to_thread(report_export.export_session_media, session_id, fmt)
|
||||||
|
return FileResponse(path, filename=f"任务{session_id[:8]}-{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:
|
||||||
@@ -167,21 +172,29 @@ def history(
|
|||||||
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, s.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.category'),'') category,
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions
|
||||||
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'),
|
||||||
COALESCE(json_extract(j.payload,'$.started_at'),json_extract(j.payload,'$.created_at')),
|
COALESCE(json_extract(j.payload,'$.started_at'),json_extract(j.payload,'$.created_at')),
|
||||||
json_extract(j.payload,'$.ended_at'),j.status,json_extract(j.payload,'$.case.name'),
|
json_extract(j.payload,'$.ended_at'),j.status,json_extract(j.payload,'$.case.name'),
|
||||||
COALESCE(json_extract(j.payload,'$.case.tags'),'')
|
COALESCE(json_extract(j.payload,'$.case.tags'),''),
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.case_id'),''),
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.category'),''),
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.subcategory'),''),
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.preconditions'),'')
|
||||||
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')"
|
||||||
args = []
|
args = []
|
||||||
if q:
|
if q:
|
||||||
where += " AND (name LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')"
|
where += " AND (name LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR case_id LIKE ? ESCAPE '\\' OR category LIKE ? ESCAPE '\\' OR subcategory LIKE ? ESCAPE '\\' OR preconditions LIKE ? ESCAPE '\\')"
|
||||||
escaped = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
escaped = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||||
args.extend([escaped] * 4)
|
args.extend([escaped] * 8)
|
||||||
for condition, value in [
|
for condition, value in [
|
||||||
("status = ?", status or None),
|
("status = ?", status or None),
|
||||||
("start_time >= ?", start),
|
("start_time >= ?", start),
|
||||||
|
|||||||
@@ -18,8 +18,16 @@ import json
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from artemis.core.diagnostics import readiness_engine
|
from artemis.core.diagnostics import readiness_engine
|
||||||
from artemis.runtime import DeviceExecutionLock, device_pool
|
from artemis.runtime import DeviceExecutionLock, device_pool
|
||||||
|
from artemis.runtime.task_control import (
|
||||||
|
add_instruction,
|
||||||
|
control_dir,
|
||||||
|
read_state,
|
||||||
|
request_pause,
|
||||||
|
request_resume,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from admin_console.core.state import state
|
from admin_console.core.state import state
|
||||||
@@ -42,6 +50,48 @@ except ImportError:
|
|||||||
router = APIRouter(tags=["tasks"])
|
router = APIRouter(tags=["tasks"])
|
||||||
|
|
||||||
|
|
||||||
|
class GuidanceRequest(BaseModel):
|
||||||
|
instruction: str = Field(min_length=1, max_length=10000)
|
||||||
|
|
||||||
|
|
||||||
|
def _live_task(session_id: str) -> dict:
|
||||||
|
from apps.admin_console.services.automation_store import identifier
|
||||||
|
|
||||||
|
identifier(session_id)
|
||||||
|
run = state.active_runs.get(session_id)
|
||||||
|
if not run or run["process"].returncode is not None:
|
||||||
|
raise HTTPException(409, "任务未在执行,无法暂停或指导")
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/tasks/{session_id}/pause")
|
||||||
|
def pause_running_task(session_id: str):
|
||||||
|
_live_task(session_id)
|
||||||
|
request_pause(session_id)
|
||||||
|
return {"session_id": session_id, "status": "pausing"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/tasks/{session_id}/guide")
|
||||||
|
def guide_running_task(session_id: str, value: GuidanceRequest):
|
||||||
|
_live_task(session_id)
|
||||||
|
add_instruction(session_id, value.instruction, source="web")
|
||||||
|
if (
|
||||||
|
read_state(session_id).get("status") == "task_paused"
|
||||||
|
or (control_dir(session_id) / "pause.request").exists()
|
||||||
|
):
|
||||||
|
request_resume(session_id)
|
||||||
|
return {"session_id": session_id, "status": "guidance_submitted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/tasks/{session_id}/continue")
|
||||||
|
def continue_running_task(session_id: str):
|
||||||
|
_live_task(session_id)
|
||||||
|
if read_state(session_id).get("status") != "task_paused":
|
||||||
|
raise HTTPException(409, "任务尚未暂停")
|
||||||
|
request_resume(session_id)
|
||||||
|
return {"session_id": session_id, "status": "resuming"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/tasks/presets")
|
@router.get("/api/tasks/presets")
|
||||||
async def get_task_presets(
|
async def get_task_presets(
|
||||||
category: str = "recommended",
|
category: str = "recommended",
|
||||||
@@ -285,6 +335,17 @@ async def get_status():
|
|||||||
}
|
}
|
||||||
for owner in active_owners.values()
|
for owner in active_owners.values()
|
||||||
]
|
]
|
||||||
|
for sid, run in state.active_runs.items():
|
||||||
|
if run.get("parked"):
|
||||||
|
active_tasks.append(
|
||||||
|
{
|
||||||
|
"session_id": sid,
|
||||||
|
"device_id": run.get("device_id"),
|
||||||
|
"goal": run.get("goal"),
|
||||||
|
"pid": getattr(run.get("process"), "pid", None),
|
||||||
|
"status": read_state(sid).get("status") or "task_paused",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
running_sid = (
|
running_sid = (
|
||||||
(global_owner.session_id if global_owner else None)
|
(global_owner.session_id if global_owner else None)
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ async def on_startup():
|
|||||||
|
|
||||||
cleaned = session_repo.cleanup_orphans_on_startup()
|
cleaned = session_repo.cleanup_orphans_on_startup()
|
||||||
if cleaned > 0:
|
if cleaned > 0:
|
||||||
print(f"[ServerStartup] Marked {cleaned} orphaned running session(s) as failed.")
|
print(f"[ServerStartup] Marked {cleaned} orphaned running session(s) as interrupted.")
|
||||||
# Workers killed together with a previous daemon never remuxed their
|
# Workers killed together with a previous daemon never remuxed their
|
||||||
# recordings; publish whatever raw files they left behind.
|
# recordings; publish whatever raw files they left behind.
|
||||||
asyncio.create_task(asyncio.to_thread(task_queue_service.recover_orphaned_recordings_on_launch))
|
asyncio.create_task(asyncio.to_thread(task_queue_service.recover_orphaned_recordings_on_launch))
|
||||||
@@ -176,7 +176,9 @@ async def on_shutdown():
|
|||||||
owned_session_ids = {
|
owned_session_ids = {
|
||||||
str(item["session_id"])
|
str(item["session_id"])
|
||||||
for item in state.queue_items
|
for item in state.queue_items
|
||||||
if isinstance(item, dict) and item.get("status") == "running" and item.get("session_id")
|
if isinstance(item, dict)
|
||||||
|
and item.get("status") in {"running", "task_paused", "resuming"}
|
||||||
|
and item.get("session_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
worker = state.worker_task
|
worker = state.worker_task
|
||||||
@@ -212,7 +214,7 @@ async def on_shutdown():
|
|||||||
state.current_process = None
|
state.current_process = None
|
||||||
state.queue_items.clear()
|
state.queue_items.clear()
|
||||||
for session_id in owned_session_ids:
|
for session_id in owned_session_ids:
|
||||||
session_repo.update_session_status(session_id, "cancelled")
|
session_repo.update_session_status(session_id, "interrupted")
|
||||||
|
|
||||||
from apps.admin_console.services.automation_service import background_tasks
|
from apps.admin_console.services.automation_service import background_tasks
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def register_submission(item: dict, case: dict | None = None, formats=None, **ex
|
|||||||
def recover_batches():
|
def recover_batches():
|
||||||
"""Recovery never silently repeats a potentially side-effecting interrupted task."""
|
"""Recovery never silently repeats a potentially side-effecting interrupted task."""
|
||||||
for job in store.jobs():
|
for job in store.jobs():
|
||||||
if job["status"] in {"running", "queued", "finalizing"}:
|
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
|
||||||
job.update(
|
job.update(
|
||||||
status=job.get("terminal_status", "interrupted"),
|
status=job.get("terminal_status", "interrupted"),
|
||||||
error=job.get("error")
|
error=job.get("error")
|
||||||
@@ -64,7 +64,7 @@ def recover_batches():
|
|||||||
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
|
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
|
||||||
)
|
)
|
||||||
for job in store.jobs(batch["id"]):
|
for job in store.jobs(batch["id"]):
|
||||||
if job["status"] in {"running", "queued", "finalizing"}:
|
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
|
||||||
job.update(
|
job.update(
|
||||||
status="interrupted", error="服务中断,需手动重跑", ended_at=time.time()
|
status="interrupted", error="服务中断,需手动重跑", ended_at=time.time()
|
||||||
)
|
)
|
||||||
@@ -89,11 +89,15 @@ async def on_terminal(item: dict, status: str):
|
|||||||
try:
|
try:
|
||||||
job["status"] = status
|
job["status"] = status
|
||||||
report = await asyncio.to_thread(session_report, sid)
|
report = await asyncio.to_thread(session_report, sid)
|
||||||
if report["verdict"] == "验证失败" or report["outcome"].get("task_status") in {
|
if status != "interrupted" and (
|
||||||
"blocked",
|
report["verdict"] == "验证失败"
|
||||||
"partial",
|
or report["outcome"].get("task_status")
|
||||||
"failed",
|
in {
|
||||||
}:
|
"blocked",
|
||||||
|
"partial",
|
||||||
|
"failed",
|
||||||
|
}
|
||||||
|
):
|
||||||
job["status"] = "failed"
|
job["status"] = "failed"
|
||||||
job["error"] = "任务执行结束,但业务检查未通过"
|
job["error"] = "任务执行结束,但业务检查未通过"
|
||||||
# Persist terminal classification before export, but the coordinator waits for finalization.
|
# Persist terminal classification before export, but the coordinator waits for finalization.
|
||||||
@@ -121,11 +125,18 @@ 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:
|
||||||
limit = int(batch.get("timeout_seconds", 1800))
|
limit = int(
|
||||||
|
(job.get("case", {}).get("task_config") or {}).get("timeout_seconds")
|
||||||
|
or batch.get("timeout_seconds", 1800)
|
||||||
|
)
|
||||||
|
task_state = getattr(state, "active_runs", {}).get(str(job["session_id"])) or {}
|
||||||
|
excluded = float(task_state.get("non_execution_seconds") or 0)
|
||||||
|
if task_state.get("non_execution_started_at"):
|
||||||
|
excluded += time.time() - float(task_state["non_execution_started_at"])
|
||||||
if (
|
if (
|
||||||
job["status"] != "finalizing"
|
job["status"] != "finalizing"
|
||||||
and not job.get("timed_out")
|
and not job.get("timed_out")
|
||||||
and time.time() - job.get("started_at", time.time()) > limit
|
and time.time() - job.get("started_at", time.time()) - excluded > limit
|
||||||
):
|
):
|
||||||
job.update(error="任务达到批次配置的执行时限", timed_out=True)
|
job.update(error="任务达到批次配置的执行时限", timed_out=True)
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
@@ -142,6 +153,11 @@ async def tick_batches():
|
|||||||
job.update(status="skipped", error="本批次设置为失败即停止", ended_at=time.time())
|
job.update(status="skipped", error="本批次设置为失败即停止", ended_at=time.time())
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
pending = []
|
pending = []
|
||||||
|
if not pending and any(
|
||||||
|
j["status"] in {"task_paused", "resuming", "running", "queued", "finalizing"}
|
||||||
|
for j in jobs
|
||||||
|
):
|
||||||
|
continue
|
||||||
if not pending:
|
if not pending:
|
||||||
batch.update(status="completed", ended_at=time.time())
|
batch.update(status="completed", ended_at=time.time())
|
||||||
try:
|
try:
|
||||||
@@ -156,7 +172,9 @@ 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)
|
||||||
continue
|
continue
|
||||||
serial = batch.get("device_serial")
|
job = pending[0]
|
||||||
|
options = job["case"].get("task_config") or {}
|
||||||
|
serial = options.get("device_serial") or batch.get("device_serial")
|
||||||
devices = await device_pool.try_list_devices_async()
|
devices = await device_pool.try_list_devices_async()
|
||||||
rejection = (
|
rejection = (
|
||||||
None
|
None
|
||||||
@@ -169,26 +187,28 @@ async def tick_batches():
|
|||||||
continue
|
continue
|
||||||
if (store.get("batch", batch["id"]) or {}).get("status") != "running":
|
if (store.get("batch", batch["id"]) or {}).get("status") != "running":
|
||||||
continue
|
continue
|
||||||
job = pending[0]
|
|
||||||
job.update(status="queued", started_at=time.time())
|
job.update(status="queued", started_at=time.time())
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
try:
|
try:
|
||||||
response = await task_queue_service.enqueue_tasks(
|
response = await task_queue_service.enqueue_tasks(
|
||||||
[case_goal(job["case"])],
|
[case_goal(job["case"])],
|
||||||
profile=batch.get("profile", "flash"),
|
profile=options.get("profile") or batch.get("profile", "flash"),
|
||||||
expected_output="用简体中文报告实际结果与证据。明确区分执行完成、验证通过、验证失败和前置条件未满足。"
|
expected_output="用简体中文报告实际结果与证据。明确区分执行完成、验证通过、验证失败和前置条件未满足。",
|
||||||
+ job["case"].get("expected", ""),
|
|
||||||
enable_outputter=True,
|
enable_outputter=True,
|
||||||
device_serial=serial,
|
device_serial=serial,
|
||||||
|
verification_level=options.get("verification_level"),
|
||||||
|
explorer_mode=options.get("explorer_mode"),
|
||||||
|
locked_app_package=options.get("locked_app_package"),
|
||||||
|
runtime_overrides=options,
|
||||||
session_id=job["session_id"],
|
session_id=job["session_id"],
|
||||||
ingress="batch",
|
ingress="batch",
|
||||||
)
|
)
|
||||||
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 "队列拒绝接收任务")
|
||||||
bound = response["tasks"][0].get("device_serial")
|
bound = response["tasks"][0].get("device_serial")
|
||||||
if bound and not serial:
|
if bound:
|
||||||
batch["device_serial"] = bound
|
job["device_serial"] = bound
|
||||||
store.put("batch", batch["id"], batch)
|
store.save_job(job)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
job.update(status="pending", error=str(exc)[:500])
|
job.update(status="pending", error=str(exc)[:500])
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ def persist_environment(values: dict[str, str]) -> None:
|
|||||||
|
|
||||||
class CapabilitySettings(BaseModel):
|
class CapabilitySettings(BaseModel):
|
||||||
video_enabled: bool = False
|
video_enabled: bool = False
|
||||||
|
recording_enabled: bool = False
|
||||||
outputter_enabled: bool = True
|
outputter_enabled: bool = True
|
||||||
step_summarizer_enabled: bool = True
|
step_summarizer_enabled: bool = True
|
||||||
|
|
||||||
@@ -136,6 +137,7 @@ class CapabilitySettings(BaseModel):
|
|||||||
def get_capability_settings() -> CapabilitySettings:
|
def get_capability_settings() -> CapabilitySettings:
|
||||||
return CapabilitySettings(
|
return CapabilitySettings(
|
||||||
video_enabled=settings.ARTEMIS_VIDEO_ENABLED,
|
video_enabled=settings.ARTEMIS_VIDEO_ENABLED,
|
||||||
|
recording_enabled=settings.ARTEMIS_RECORDING_ENABLED,
|
||||||
outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED,
|
outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED,
|
||||||
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
|
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
|
||||||
)
|
)
|
||||||
@@ -144,6 +146,7 @@ def get_capability_settings() -> CapabilitySettings:
|
|||||||
def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
|
def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
|
||||||
values = {
|
values = {
|
||||||
"ARTEMIS_VIDEO_ENABLED": update.video_enabled,
|
"ARTEMIS_VIDEO_ENABLED": update.video_enabled,
|
||||||
|
"ARTEMIS_RECORDING_ENABLED": update.recording_enabled,
|
||||||
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
|
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
|
||||||
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
|
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ STATUS = {
|
|||||||
"pending": "待执行",
|
"pending": "待执行",
|
||||||
"queued": "已排队",
|
"queued": "已排队",
|
||||||
"running": "执行中",
|
"running": "执行中",
|
||||||
|
"task_paused": "等待指导",
|
||||||
|
"resuming": "等待设备恢复",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -107,6 +109,11 @@ def session_report(session_id: str, db_path=None) -> dict:
|
|||||||
"error": meta.get("error", ""),
|
"error": meta.get("error", ""),
|
||||||
"device": meta.get("device_serial") or text(session.get("device_info") or ""),
|
"device": meta.get("device_serial") or text(session.get("device_info") or ""),
|
||||||
"tags": case.get("tags", ""),
|
"tags": case.get("tags", ""),
|
||||||
|
"case_id": case.get("case_id", ""),
|
||||||
|
"category": case.get("category", ""),
|
||||||
|
"subcategory": case.get("subcategory", ""),
|
||||||
|
"preconditions": case.get("preconditions", ""),
|
||||||
|
"task_config": case.get("task_config") or {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -121,6 +128,11 @@ def time_text(value) -> str:
|
|||||||
def metadata(report: dict) -> list[tuple]:
|
def metadata(report: dict) -> list[tuple]:
|
||||||
return [
|
return [
|
||||||
("任务编号", report["id"]),
|
("任务编号", report["id"]),
|
||||||
|
("用例编号", report.get("case_id") or "—"),
|
||||||
|
("检测项目", report.get("category") or "—"),
|
||||||
|
("测试子项", report.get("subcategory") or "—"),
|
||||||
|
("前置约束提取", report.get("preconditions") or "—"),
|
||||||
|
("任务配置", text(report.get("task_config") or {})),
|
||||||
("任务目标", report["goal"]),
|
("任务目标", report["goal"]),
|
||||||
("预期结果", report["expected"] or "未指定"),
|
("预期结果", report["expected"] or "未指定"),
|
||||||
("执行状态", STATUS.get(report["status"], report["status"])),
|
("执行状态", STATUS.get(report["status"], report["status"])),
|
||||||
@@ -279,6 +291,88 @@ def export_session(session_id: str, formats: list[str] | None = None) -> list[Pa
|
|||||||
return outputs
|
return outputs
|
||||||
|
|
||||||
|
|
||||||
|
def session_screenshots(report: dict) -> list[tuple[Path, str]]:
|
||||||
|
"""Ordered, path-checked pre/post observations for each recorded step."""
|
||||||
|
screenshots: list[tuple[Path, str]] = []
|
||||||
|
for index, step in enumerate(report["steps"], 1):
|
||||||
|
for phase, key in (("操作前", "pre_image_name"), ("操作后", "post_image_name")):
|
||||||
|
source = image_path(step.get(key))
|
||||||
|
if source:
|
||||||
|
screenshots.append(
|
||||||
|
(
|
||||||
|
source,
|
||||||
|
f"{index:04d}_步骤{int(step.get('step_number') or index):04d}_{phase}{source.suffix.lower()}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return screenshots
|
||||||
|
|
||||||
|
|
||||||
|
def session_videos(report: dict) -> list[tuple[Path, str]]:
|
||||||
|
"""Find only verified files attached to this session's recording directory."""
|
||||||
|
session = SessionRepository().get_session_by_id(report["id"]) or {}
|
||||||
|
recording = SessionRepository().get_video_recording_for_session(report["id"]) or {}
|
||||||
|
candidate = recording.get("local_video_path") or session.get("video_filepath")
|
||||||
|
if not candidate:
|
||||||
|
return []
|
||||||
|
source = Path(str(candidate)).resolve()
|
||||||
|
session_root = (TRACES_PATH / identifier(report["id"])).resolve()
|
||||||
|
if not source.is_relative_to(session_root):
|
||||||
|
return []
|
||||||
|
# A resumed recording lives under resume_NNNN; the initial recording is
|
||||||
|
# in its parent, with any later segments in sibling resume folders.
|
||||||
|
directory = source.parent
|
||||||
|
if directory.name.startswith("resume_"):
|
||||||
|
directory = directory.parent
|
||||||
|
if not directory.is_dir() or not directory.is_relative_to(session_root):
|
||||||
|
return []
|
||||||
|
paths = [
|
||||||
|
p
|
||||||
|
for p in directory.rglob("*.mp4")
|
||||||
|
if p.is_file()
|
||||||
|
and p.resolve().is_relative_to(directory.resolve())
|
||||||
|
and (p.parent == directory or p.parent.name.startswith("resume_"))
|
||||||
|
]
|
||||||
|
paths.sort(key=lambda p: (0 if p.parent == directory else 1, p.parent.name, p.name))
|
||||||
|
return [(path, f"{i:04d}_{path.parent.name}_{path.name}") for i, path in enumerate(paths, 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def export_session_media(session_id: str, kind: str) -> Path:
|
||||||
|
if kind not in {"screenshots", "video", "all"}:
|
||||||
|
raise ValueError("不支持的下载类型")
|
||||||
|
with EXPORT_LOCK:
|
||||||
|
report = session_report(session_id)
|
||||||
|
target_dir = EXPORT_ROOT / "sessions" / identifier(session_id)
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
images = session_screenshots(report) if kind in {"screenshots", "all"} else []
|
||||||
|
videos = session_videos(report) if kind in {"video", "all"} else []
|
||||||
|
if kind == "video" and not videos:
|
||||||
|
raise ValueError("此任务没有已保存的录屏")
|
||||||
|
if kind == "screenshots" and not images:
|
||||||
|
raise ValueError("此任务没有可下载的步骤截图")
|
||||||
|
target = target_dir / f"{kind}.zip"
|
||||||
|
temporary = target_dir / f".{kind}.tmp"
|
||||||
|
manifest = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"name": report["name"],
|
||||||
|
"status": report["status"],
|
||||||
|
"verdict": report["verdict"],
|
||||||
|
"screenshots": [name for _, name in images],
|
||||||
|
"videos": [name for _, name in videos],
|
||||||
|
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
|
||||||
|
}
|
||||||
|
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
if kind == "all":
|
||||||
|
for path in export_session(session_id, ["docx", "xlsx"]):
|
||||||
|
archive.write(path, path.name)
|
||||||
|
for source, name in images:
|
||||||
|
archive.write(source, f"步骤截图/{name}")
|
||||||
|
for source, name in videos:
|
||||||
|
archive.write(source, f"任务录屏/{name}")
|
||||||
|
archive.writestr("文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||||
|
temporary.replace(target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
def export_batch(batch_id: str) -> Path:
|
def export_batch(batch_id: str) -> Path:
|
||||||
with EXPORT_LOCK:
|
with EXPORT_LOCK:
|
||||||
batch = store.get("batch", identifier(batch_id))
|
batch = store.get("batch", identifier(batch_id))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Versioned Excel task template and bounded, non-executing import validation."""
|
"""Validate the prompt-first batch workbook without executing its contents."""
|
||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import json
|
import json
|
||||||
@@ -6,33 +6,53 @@ import os
|
|||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
from openpyxl import Workbook, load_workbook
|
from openpyxl import Workbook, load_workbook
|
||||||
|
from openpyxl.styles import Alignment, Font, PatternFill
|
||||||
from openpyxl.utils import get_column_letter
|
from openpyxl.utils import get_column_letter
|
||||||
from openpyxl.styles import Font, PatternFill, Alignment
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from pydantic import BaseModel, Field, ConfigDict
|
|
||||||
|
|
||||||
|
HEADERS = (
|
||||||
HEADERS = {
|
"用例编号",
|
||||||
"任务名称": "name",
|
"检测项目",
|
||||||
"任务目标": "goal",
|
"测试子项",
|
||||||
"预期结果": "expected",
|
"测试用例",
|
||||||
"目标应用": "app",
|
"前置约束提取",
|
||||||
"前置条件": "preconditions",
|
"Artemis提示词",
|
||||||
"输入参数(JSON)": "parameters",
|
"任务配置(JSON)",
|
||||||
"标签": "tags",
|
)
|
||||||
}
|
|
||||||
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
|
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
|
||||||
MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760"))
|
MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760"))
|
||||||
|
|
||||||
|
|
||||||
|
class TaskOptions(BaseModel):
|
||||||
|
"""Allowlisted per-case overrides; credentials are never accepted in a workbook."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
profile: str | None = Field(default=None, pattern="^(flash|pro)$")
|
||||||
|
model: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
reasoning_effort: str | None = Field(
|
||||||
|
default=None, pattern="^(default|none|minimal|low|medium|high|xhigh|max)$"
|
||||||
|
)
|
||||||
|
device_serial: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
verification_level: str | None = Field(default=None, pattern="^(off|final|checkpoints|strict)$")
|
||||||
|
explorer_mode: str | None = Field(default=None, pattern="^(flash|pro|ultra)$")
|
||||||
|
locked_app_package: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
record_video: bool | None = None
|
||||||
|
analyze_video: bool | None = None
|
||||||
|
request_timeout_seconds: float | None = Field(default=None, gt=0, le=3600)
|
||||||
|
max_retries: int | None = Field(default=None, ge=0, le=10)
|
||||||
|
retry_interval_seconds: float | None = Field(default=None, ge=0, le=300)
|
||||||
|
timeout_seconds: int | None = Field(default=None, ge=30, le=86400)
|
||||||
|
|
||||||
|
|
||||||
class TaskCase(BaseModel):
|
class TaskCase(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
case_id: str = Field(default="", max_length=200)
|
||||||
|
category: str = Field(default="", max_length=200)
|
||||||
|
subcategory: str = Field(default="", max_length=200)
|
||||||
name: str = Field(min_length=1, max_length=200)
|
name: str = Field(min_length=1, max_length=200)
|
||||||
goal: str = Field(min_length=1, max_length=10000)
|
|
||||||
expected: str = Field(default="", max_length=10000)
|
|
||||||
app: str = Field(default="", max_length=200)
|
|
||||||
preconditions: str = Field(default="", max_length=10000)
|
preconditions: str = Field(default="", max_length=10000)
|
||||||
parameters: dict[str, str | int | float | bool] = Field(default_factory=dict)
|
goal: str = Field(min_length=1, max_length=10000)
|
||||||
tags: str = Field(default="", max_length=500)
|
task_config: TaskOptions = Field(default_factory=TaskOptions)
|
||||||
|
|
||||||
|
|
||||||
def template() -> bytes:
|
def template() -> bytes:
|
||||||
@@ -40,27 +60,54 @@ def template() -> bytes:
|
|||||||
ws = wb.active
|
ws = wb.active
|
||||||
assert ws is not None
|
assert ws is not None
|
||||||
ws.title = "任务"
|
ws.title = "任务"
|
||||||
ws.append(list(HEADERS))
|
ws.append(HEADERS)
|
||||||
ws.freeze_panes = "A2"
|
ws.freeze_panes = "A2"
|
||||||
ws.auto_filter.ref = "A1:G1"
|
ws.auto_filter.ref = "A1:G1"
|
||||||
for cell in ws[1]:
|
for cell in ws[1]:
|
||||||
cell.font = Font(bold=True, color="FFFFFF")
|
cell.font = Font(bold=True, color="FFFFFF")
|
||||||
cell.fill = PatternFill("solid", fgColor="245C78")
|
cell.fill = PatternFill("solid", fgColor="245C78")
|
||||||
ws.column_dimensions[get_column_letter(int(cell.column or 1))].width = 32
|
ws.column_dimensions[get_column_letter(int(cell.column or 1))].width = (
|
||||||
|
65 if cell.column in {6, 7} else 28
|
||||||
|
)
|
||||||
help_sheet = wb.create_sheet("填写说明")
|
help_sheet = wb.create_sheet("填写说明")
|
||||||
for line in [
|
help_sheet.append(
|
||||||
"模板版本:1",
|
["模板版本:2。任务表每行一项,Artemis提示词必填;只把这一列作为 AI 任务正文。"]
|
||||||
"每行一个独立任务;任务名称、任务目标必填。",
|
)
|
||||||
"预期结果决定要验证的业务条件。未填写时只报告任务完成情况。",
|
help_sheet.append(["前置约束提取及其他描述列仅作查看、搜索和报告元数据,不追加到提示词。"])
|
||||||
"前置条件描述实际用例起点;不会统一重启或清空应用数据。",
|
help_sheet.append(["任务配置(JSON)可留空,空字段继承批次或全局配置;禁止密钥与未知字段。"])
|
||||||
'输入参数使用 JSON 对象,例如 {"关键词":"天气"};目标可引用 {{关键词}}。',
|
help_sheet.append(["示例仅供参考,不会导入执行。请在“任务”页填写自己的用例。"])
|
||||||
"目标应用可填名称或包名;空白时由任务决定。",
|
help_sheet.append(HEADERS)
|
||||||
"不要填写 Excel 公式、宏或外部链接。导入仅预览,点击开始后执行。",
|
help_sheet.append(
|
||||||
]:
|
[
|
||||||
help_sheet.append([line])
|
"示例-001",
|
||||||
help_sheet.column_dimensions["A"].width = 105
|
"示例模块",
|
||||||
|
"示例检查",
|
||||||
|
"检查示例页面",
|
||||||
|
"设备已经打开目标应用",
|
||||||
|
"观察当前页面并报告实际结果,不要假设检查已通过。",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"profile": "pro",
|
||||||
|
"model": "mimo-v2.6-flash",
|
||||||
|
"reasoning_effort": "high",
|
||||||
|
"verification_level": "final",
|
||||||
|
"record_video": True,
|
||||||
|
"analyze_video": False,
|
||||||
|
"request_timeout_seconds": 300,
|
||||||
|
"max_retries": 5,
|
||||||
|
"retry_interval_seconds": 10,
|
||||||
|
"timeout_seconds": 1800,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for column in range(1, 8):
|
||||||
|
help_sheet.column_dimensions[get_column_letter(column)].width = 70 if column > 5 else 26
|
||||||
for row in help_sheet:
|
for row in help_sheet:
|
||||||
row[0].alignment = Alignment(wrap_text=True)
|
for cell in row:
|
||||||
|
cell.alignment = Alignment(wrap_text=True, vertical="top")
|
||||||
|
help_sheet.row_dimensions[6].height = 110
|
||||||
stream = BytesIO()
|
stream = BytesIO()
|
||||||
wb.save(stream)
|
wb.save(stream)
|
||||||
return stream.getvalue()
|
return stream.getvalue()
|
||||||
@@ -90,7 +137,7 @@ def parse_workbook(data: bytes) -> dict:
|
|||||||
rows = iter(wb["任务"].iter_rows())
|
rows = iter(wb["任务"].iter_rows())
|
||||||
headers = [str(cell.value or "").strip() for cell in next(rows, [])]
|
headers = [str(cell.value or "").strip() for cell in next(rows, [])]
|
||||||
if headers != list(HEADERS):
|
if headers != list(HEADERS):
|
||||||
raise ValueError("列名或顺序与模板不一致,请重新下载模板")
|
raise ValueError("列名或顺序与新版模板不一致,请重新下载模板")
|
||||||
tasks, errors = [], []
|
tasks, errors = [], []
|
||||||
for number, row in enumerate(rows, 2):
|
for number, row in enumerate(rows, 2):
|
||||||
if number > MAX_ROWS + 1:
|
if number > MAX_ROWS + 1:
|
||||||
@@ -100,28 +147,33 @@ def parse_workbook(data: bytes) -> dict:
|
|||||||
try:
|
try:
|
||||||
if any(cell.data_type == "f" for cell in row):
|
if any(cell.data_type == "f" for cell in row):
|
||||||
raise ValueError("不允许公式,请粘贴为纯文本")
|
raise ValueError("不允许公式,请粘贴为纯文本")
|
||||||
values = [str(cell.value or "").strip() for cell in row[: len(HEADERS)]]
|
values = [
|
||||||
item = dict(zip(HEADERS.values(), values))
|
str(cell.value if cell.value is not None else "").strip() for cell in row[:7]
|
||||||
item["parameters"] = json.loads(item.get("parameters") or "{}")
|
]
|
||||||
case = TaskCase.model_validate(item)
|
config = json.loads(values[6] or "{}")
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
raise ValueError("任务配置必须为 JSON 对象")
|
||||||
|
case = TaskCase.model_validate(
|
||||||
|
{
|
||||||
|
"case_id": values[0],
|
||||||
|
"category": values[1],
|
||||||
|
"subcategory": values[2],
|
||||||
|
"name": values[3] or values[2] or values[0] or values[5][:120],
|
||||||
|
"preconditions": values[4],
|
||||||
|
"goal": values[5],
|
||||||
|
"task_config": config,
|
||||||
|
}
|
||||||
|
)
|
||||||
tasks.append({"row": number, **case.model_dump()})
|
tasks.append({"row": number, **case.model_dump()})
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
errors.append({"row": number, "message": str(exc)[:500]})
|
errors.append({"row": number, "message": str(exc)[:500]})
|
||||||
if not tasks and not errors:
|
if not tasks and not errors:
|
||||||
errors.append({"row": 2, "message": "没有任务,请填写模板后重新导入"})
|
errors.append({"row": 2, "message": "没有任务,请填写模板后重新导入"})
|
||||||
return {"version": 1, "tasks": tasks, "errors": errors, "valid": bool(tasks) and not errors}
|
return {"version": 2, "tasks": tasks, "errors": errors, "valid": bool(tasks) and not errors}
|
||||||
finally:
|
finally:
|
||||||
wb.close()
|
wb.close()
|
||||||
|
|
||||||
|
|
||||||
def case_goal(case: dict) -> str:
|
def case_goal(case: dict) -> str:
|
||||||
goal = case["goal"]
|
"""Execution gets the prompt verbatim; metadata stays outside model input."""
|
||||||
for key, value in case.get("parameters", {}).items():
|
return case["goal"]
|
||||||
goal = goal.replace("{{" + key + "}}", str(value))
|
|
||||||
return (
|
|
||||||
f"任务名称:{case['name']}\n任务目标:{goal}\n目标应用:{case.get('app') or '按目标判断'}\n"
|
|
||||||
f"前置条件:{case.get('preconditions') or '根据实际用例判断合适起点'}\n"
|
|
||||||
"先观察手机并恢复到本用例需要的起始位置,记录准备过程。不要固定重启或清空数据。"
|
|
||||||
"如果无法建立前置条件,明确报告前置条件未满足,不继续正文操作。\n"
|
|
||||||
f"预期结果:{case.get('expected') or '仅确认任务完成情况,不宣称测试通过'}"
|
|
||||||
)
|
|
||||||
@@ -302,7 +302,7 @@ class TaskQueueService:
|
|||||||
normalized = current_status.lower().strip() if isinstance(current_status, str) else None
|
normalized = current_status.lower().strip() if isinstance(current_status, str) else None
|
||||||
if normalized == "success":
|
if normalized == "success":
|
||||||
return "completed", True
|
return "completed", True
|
||||||
if normalized in {"completed", "failed", "cancelled"}:
|
if normalized in {"completed", "failed", "cancelled", "interrupted"}:
|
||||||
return normalized, False
|
return normalized, False
|
||||||
return ("completed" if returncode == 0 else "failed"), True
|
return ("completed" if returncode == 0 else "failed"), True
|
||||||
|
|
||||||
@@ -388,10 +388,9 @@ class TaskQueueService:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _dispatch_pending_tasks(cls) -> None:
|
def _dispatch_pending_tasks(cls) -> None:
|
||||||
"""Launch every pending task admissible under the current concurrency limit."""
|
"""Launch every pending task admissible under the current concurrency limit."""
|
||||||
|
cls._sync_control_states()
|
||||||
limit = cls._concurrency_limit()
|
limit = cls._concurrency_limit()
|
||||||
state.prune_finished_runs()
|
state.prune_finished_runs()
|
||||||
if limit == 1 and state.is_running:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Dispatched-but-not-yet-spawned runs are only visible as queue items in
|
# Dispatched-but-not-yet-spawned runs are only visible as queue items in
|
||||||
# "running" state, so admission must count those too -- active_runs alone
|
# "running" state, so admission must count those too -- active_runs alone
|
||||||
@@ -410,7 +409,9 @@ class TaskQueueService:
|
|||||||
and item.get("pid") not in active_pids
|
and item.get("pid") not in active_pids
|
||||||
for item in in_flight
|
for item in in_flight
|
||||||
)
|
)
|
||||||
capacity = limit - len(state.active_runs) - starting
|
capacity = (
|
||||||
|
limit - sum(not r.get("parked") for r in state.active_runs.values()) - starting
|
||||||
|
)
|
||||||
if capacity <= 0:
|
if capacity <= 0:
|
||||||
return
|
return
|
||||||
busy_devices = state.busy_device_ids | {
|
busy_devices = state.busy_device_ids | {
|
||||||
@@ -432,11 +433,15 @@ class TaskQueueService:
|
|||||||
# A task without a resolved device may bind to any serial, so it
|
# A task without a resolved device may bind to any serial, so it
|
||||||
# only launches on an otherwise idle scheduler; the device lock
|
# only launches on an otherwise idle scheduler; the device lock
|
||||||
# then allocates freely without contending against active runs.
|
# then allocates freely without contending against active runs.
|
||||||
if device is None and (state.active_runs or in_flight or dispatched_any):
|
if device is None and (
|
||||||
|
any(not r.get("parked") for r in state.active_runs.values())
|
||||||
|
or in_flight
|
||||||
|
or dispatched_any
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
if device is not None and target.lock_key in busy_devices:
|
if device is not None and target.lock_key in busy_devices:
|
||||||
continue
|
continue
|
||||||
elif limit > 1 and device is not None and target.lock_key in busy_devices:
|
elif limit >= 1 and device is not None and target.lock_key in busy_devices:
|
||||||
# A second worker for this device would wait on its lock.
|
# A second worker for this device would wait on its lock.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -454,6 +459,42 @@ class TaskQueueService:
|
|||||||
if capacity <= 0:
|
if capacity <= 0:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _sync_control_states(cls) -> None:
|
||||||
|
from artemis.runtime.task_control import read_state
|
||||||
|
from apps.admin_console.services.automation_store import store
|
||||||
|
|
||||||
|
for item in state.queue_items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
sid = str(item.get("session_id") or "")
|
||||||
|
run = state.active_runs.get(sid)
|
||||||
|
if not run:
|
||||||
|
continue
|
||||||
|
status = read_state(sid).get("status")
|
||||||
|
if status not in {"running", "task_paused", "resuming"}:
|
||||||
|
continue
|
||||||
|
old = item.get("status")
|
||||||
|
if status == old:
|
||||||
|
continue
|
||||||
|
parked = status in {"task_paused", "resuming"}
|
||||||
|
now = time.time()
|
||||||
|
if parked and not run.get("parked"):
|
||||||
|
run["non_execution_started_at"] = now
|
||||||
|
elif not parked and run.get("parked"):
|
||||||
|
run["non_execution_seconds"] = (
|
||||||
|
float(run.get("non_execution_seconds") or 0)
|
||||||
|
+ now
|
||||||
|
- float(run.pop("non_execution_started_at", now))
|
||||||
|
)
|
||||||
|
run["parked"] = parked
|
||||||
|
item["status"] = status
|
||||||
|
job = store.job(sid)
|
||||||
|
if job and job.get("status") != "finalizing":
|
||||||
|
job["status"] = status
|
||||||
|
store.save_job(job)
|
||||||
|
cls._broadcast_event("task_status", {"session_id": sid, "status": status})
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _begin_task_run(
|
def _begin_task_run(
|
||||||
cls,
|
cls,
|
||||||
@@ -531,6 +572,22 @@ class TaskQueueService:
|
|||||||
env["ARTEMIS_SESSION_ID"] = str(sess_id)
|
env["ARTEMIS_SESSION_ID"] = str(sess_id)
|
||||||
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
|
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
|
||||||
env["ARTEMIS_TASK_WORKER"] = "1"
|
env["ARTEMIS_TASK_WORKER"] = "1"
|
||||||
|
overrides = task_item.get("runtime_overrides") or {}
|
||||||
|
for option, variable in {
|
||||||
|
"model": "OPENAI_MODEL",
|
||||||
|
"reasoning_effort": "OPENAI_REASONING_EFFORT",
|
||||||
|
"request_timeout_seconds": "OPENAI_TIMEOUT_SECONDS",
|
||||||
|
"max_retries": "OPENAI_MAX_RETRIES",
|
||||||
|
"retry_interval_seconds": "OPENAI_RETRY_INTERVAL_SECONDS",
|
||||||
|
"record_video": "ARTEMIS_RECORDING_ENABLED",
|
||||||
|
"analyze_video": "ARTEMIS_VIDEO_ENABLED",
|
||||||
|
}.items():
|
||||||
|
if overrides.get(option) is not None:
|
||||||
|
env[variable] = (
|
||||||
|
str(overrides[option]).lower()
|
||||||
|
if isinstance(overrides[option], bool)
|
||||||
|
else str(overrides[option])
|
||||||
|
)
|
||||||
target.endpoint.apply_to_environment(env)
|
target.endpoint.apply_to_environment(env)
|
||||||
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
|
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
|
||||||
queue_ticket = task_item.get("queue_ticket")
|
queue_ticket = task_item.get("queue_ticket")
|
||||||
@@ -664,6 +721,12 @@ class TaskQueueService:
|
|||||||
returncode,
|
returncode,
|
||||||
manual_stop,
|
manual_stop,
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
state.is_shutting_down
|
||||||
|
and not manual_stop
|
||||||
|
and current_status not in {"completed", "success", "failed", "interrupted"}
|
||||||
|
):
|
||||||
|
new_status, should_persist = "interrupted", True
|
||||||
if should_persist:
|
if should_persist:
|
||||||
if session_repo.update_session_status(sess_id, new_status, time.time()):
|
if session_repo.update_session_status(sess_id, new_status, time.time()):
|
||||||
print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'")
|
print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'")
|
||||||
@@ -1069,6 +1132,7 @@ class TaskQueueService:
|
|||||||
conversation_id: str | None = None,
|
conversation_id: str | None = None,
|
||||||
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,
|
||||||
) -> 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.
|
||||||
|
|
||||||
@@ -1123,6 +1187,8 @@ class TaskQueueService:
|
|||||||
verification_level=verification_level,
|
verification_level=verification_level,
|
||||||
explorer_mode=explorer_mode,
|
explorer_mode=explorer_mode,
|
||||||
)
|
)
|
||||||
|
if runtime_overrides:
|
||||||
|
task_item["runtime_overrides"] = runtime_overrides
|
||||||
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(
|
||||||
|
|||||||
+22
-6
@@ -13,7 +13,23 @@
|
|||||||
<section><h3>当前任务报告</h3>
|
<section><h3>当前任务报告</h3>
|
||||||
@if (report(); as r) {
|
@if (report(); as r) {
|
||||||
<strong>{{r.name}}</strong><p>{{label(r.status)}} · {{r.verdict}}</p>
|
<strong>{{r.name}}</strong><p>{{label(r.status)}} · {{r.verdict}}</p>
|
||||||
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a></div>
|
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a>
|
||||||
|
<a [href]="'/api/automation/reports/'+r.id+'/screenshots'">步骤截图 ZIP</a>
|
||||||
|
<a [href]="'/api/automation/reports/'+r.id+'/video'">分段录屏 ZIP</a>
|
||||||
|
<a [href]="'/api/automation/reports/'+r.id+'/all'">下载全部资料</a></div>
|
||||||
|
@if (liveTask()) {
|
||||||
|
<div class="task-guidance">
|
||||||
|
<h3>任务指导 · {{label(taskStatus())}}</h3>
|
||||||
|
<p>运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。</p>
|
||||||
|
<label for="task-guidance-text">补充信息或调整方向</label>
|
||||||
|
<textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea>
|
||||||
|
<div class="actions">
|
||||||
|
@if(taskStatus() === 'running'){<button [disabled]="busy()" (click)="pauseTask(r.id)">暂停当前任务</button>}
|
||||||
|
<button [disabled]="busy() || !guidance.trim()" (click)="guideTask(r.id)">提交指导{{taskStatus() === 'task_paused' ? '并恢复' : ''}}</button>
|
||||||
|
@if(taskStatus() === 'task_paused'){<button [disabled]="busy()" (click)="continueTask(r.id)">直接继续</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<p class="muted">报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。</p>
|
<p class="muted">报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。</p>
|
||||||
<pre class="preview">{{r.content}}</pre>
|
<pre class="preview">{{r.content}}</pre>
|
||||||
} @else { <p>从任务队列或历史记录中选择任务,即可下载报告。</p> }
|
} @else { <p>从任务队列或历史记录中选择任务,即可下载报告。</p> }
|
||||||
@@ -23,12 +39,12 @@
|
|||||||
<section class="device"><label>执行设备<select [(ngModel)]="device"><option value="">请选择设备</option>@for(d of system.connectedDevices(); track d.serial){<option [value]="d.serial">{{d.serial}}</option>}</select></label></section>
|
<section class="device"><label>执行设备<select [(ngModel)]="device"><option value="">请选择设备</option>@for(d of system.connectedDevices(); track d.serial){<option [value]="d.serial">{{d.serial}}</option>}</select></label></section>
|
||||||
}
|
}
|
||||||
@if (tab() === 'batches') {
|
@if (tab() === 'batches') {
|
||||||
<section><h3>导入任务</h3><p>每行一个用例,填写名称与目标。预期结果、应用、前置条件、参数和标签可选。</p>
|
<section><h3>导入任务</h3><p>每行一个用例,仅「Artemis提示词」作为任务正文;「任务配置(JSON)」可设置每项模型、模式、超时和录屏。「前置约束提取」仅供查看与搜索。示例在模板的「填写说明」页。</p>
|
||||||
<div class="actions"><a href="/api/automation/template">下载 Excel 模板</a><label class="upload">导入 Excel<input type="file" accept=".xlsx" [disabled]="busy()" (change)="upload($event)"></label></div>
|
<div class="actions"><a href="/api/automation/template">下载 Excel 模板</a><label class="upload">导入 Excel<input type="file" accept=".xlsx" [disabled]="busy()" (change)="upload($event)"></label></div>
|
||||||
@if(preview(); as p) {
|
@if(preview(); as p) {
|
||||||
<p>有效任务 {{p.tasks.length}} 条 · 错误 {{p.errors.length}} 条</p>
|
<p>有效任务 {{p.tasks.length}} 条 · 错误 {{p.errors.length}} 条</p>
|
||||||
@for(e of p.errors; track e.row){<p class="error">第 {{e.row}} 行:{{e.message}}</p>}
|
@for(e of p.errors; track e.row){<p class="error">第 {{e.row}} 行:{{e.message}}</p>}
|
||||||
<div class="table-wrap"><table><thead><tr><th>行</th><th>名称</th><th>目标</th></tr></thead><tbody>@for(t of p.tasks; track t.row){<tr><td>{{t.row}}</td><td>{{t.name}}</td><td>{{t.goal}}</td></tr>}</tbody></table></div>
|
<div class="table-wrap"><table><thead><tr><th>行</th><th>用例</th><th>提示词</th><th>任务配置</th></tr></thead><tbody>@for(t of p.tasks; track t.row){<tr><td>{{t.row}}</td><td>{{t.name}}</td><td>{{t.goal}}</td><td>{{t.task_config | json}}</td></tr>}</tbody></table></div>
|
||||||
<label>批次名称<input [(ngModel)]="name" maxlength="200"></label>
|
<label>批次名称<input [(ngModel)]="name" maxlength="200"></label>
|
||||||
<div class="fields"><label>执行模式<select [(ngModel)]="profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label><label>单任务时限(秒)<input type="number" min="30" max="86400" [(ngModel)]="timeout"></label></div>
|
<div class="fields"><label>执行模式<select [(ngModel)]="profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label><label>单任务时限(秒)<input type="number" min="30" max="86400" [(ngModel)]="timeout"></label></div>
|
||||||
<label><input type="checkbox" [(ngModel)]="stopOnFailure"> 失败即停止后续任务(默认继续)</label>
|
<label><input type="checkbox" [(ngModel)]="stopOnFailure"> 失败即停止后续任务(默认继续)</label>
|
||||||
@@ -41,13 +57,13 @@
|
|||||||
@if(!batches().length){<p>暂无批次。</p>}
|
@if(!batches().length){<p>暂无批次。</p>}
|
||||||
</section>
|
</section>
|
||||||
@if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p>
|
@if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p>
|
||||||
<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'){<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)">{{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')">{{j.position}}. {{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
|
||||||
</section>}
|
</section>}
|
||||||
}
|
}
|
||||||
@@ -20,3 +20,5 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:
|
|||||||
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:#f8fafc;}.table-wrap{max-height:260px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid #e2e8f0;text-align:left;overflow-wrap:anywhere;}th{background:#f8fafc;color:#475569;font-weight:500;}
|
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:#f8fafc;}.table-wrap{max-height:260px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid #e2e8f0;text-align:left;overflow-wrap:anywhere;}th{background:#f8fafc;color:#475569;font-weight:500;}
|
||||||
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:#334155;background:#f8fafc;border-radius:8px;padding:12px;border:1px solid #e2e8f0;}
|
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:#334155;background:#f8fafc;border-radius:8px;padding:12px;border:1px solid #e2e8f0;}
|
||||||
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid #f1f5f9;}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:#f1f5f9;color:#475569;border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:#475569;cursor:pointer;padding:8px 0;}
|
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid #f1f5f9;}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:#f1f5f9;color:#475569;border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:#475569;cursor:pointer;padding:8px 0;}
|
||||||
|
.task-guidance{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;}
|
||||||
|
.task-guidance label{margin:8px 0 4px;}
|
||||||
@@ -15,6 +15,9 @@ export class AutomationPanelComponent {
|
|||||||
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
|
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
|
||||||
batches = signal<any[]>([]); preview = signal<any>(null);
|
batches = signal<any[]>([]); preview = signal<any>(null);
|
||||||
batch = signal<any>(null); report = signal<any>(null);
|
batch = signal<any>(null); report = signal<any>(null);
|
||||||
|
guidance = '';
|
||||||
|
taskStatus = signal('');
|
||||||
|
liveTask = signal(false);
|
||||||
word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800;
|
word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800;
|
||||||
private destroyed = false;
|
private destroyed = false;
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -24,12 +27,12 @@ export class AutomationPanelComponent {
|
|||||||
const timer = setInterval(() => void this.refresh(), 5000);
|
const timer = setInterval(() => void this.refresh(), 5000);
|
||||||
destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); });
|
destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); });
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const sid = this.agent.currentSessionId(); this.report.set(null);
|
const sid = this.agent.currentSessionId(); this.report.set(null); this.guidance = '';
|
||||||
if (sid) this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`).subscribe({ next: r => { if (this.agent.currentSessionId() === sid) this.report.set(r); }, error: () => {} });
|
if (sid) this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`).subscribe({ next: r => { if (this.agent.currentSessionId() === sid) this.report.set(r); }, error: () => {} });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
|
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
|
||||||
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',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:'正在生成报告'} as Record<string,string>)[s] || s; }
|
||||||
async action(fn: () => Promise<void>) {
|
async action(fn: () => Promise<void>) {
|
||||||
if (this.busy()) return;
|
if (this.busy()) return;
|
||||||
this.busy.set(true); this.error.set(''); this.notice.set('');
|
this.busy.set(true); this.error.set(''); this.notice.set('');
|
||||||
@@ -43,6 +46,11 @@ export class AutomationPanelComponent {
|
|||||||
this.batches.set(b);
|
this.batches.set(b);
|
||||||
const sid = this.agent.currentSessionId();
|
const sid = this.agent.currentSessionId();
|
||||||
if (sid && this.tab() === 'reports') { const r = await firstValueFrom(this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`)); if (this.agent.currentSessionId() === sid) this.report.set(r); }
|
if (sid && this.tab() === 'reports') { const r = await firstValueFrom(this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`)); if (this.agent.currentSessionId() === sid) this.report.set(r); }
|
||||||
|
if (sid) {
|
||||||
|
const current = this.agent.sessions().find(s => s.session_id === sid);
|
||||||
|
this.taskStatus.set(current?.status || this.report()?.status || '');
|
||||||
|
this.liveTask.set(['running','task_paused','resuming'].includes(current?.status || ''));
|
||||||
|
} else { this.taskStatus.set(''); this.liveTask.set(false); }
|
||||||
const id = this.batch()?.id;
|
const id = this.batch()?.id;
|
||||||
if (id) { const item = await firstValueFrom(this.http.get<any>(`/api/automation/batches/${id}`)); if (this.batch()?.id === id) this.batch.set(item); }
|
if (id) { const item = await firstValueFrom(this.http.get<any>(`/api/automation/batches/${id}`)); if (this.batch()?.id === id) this.batch.set(item); }
|
||||||
} catch { /* User initiated actions expose errors; polling retries on the next interval. */ }
|
} catch { /* User initiated actions expose errors; polling retries on the next interval. */ }
|
||||||
@@ -63,4 +71,14 @@ 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}`))); }); }
|
||||||
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(); }); }
|
||||||
|
pauseTask(id: string) { void this.action(async () => { await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`,{})); this.notice.set('已请求暂停:当前步骤结束后释放设备'); await this.refresh(); }); }
|
||||||
|
guideTask(id: string) { void this.action(async () => {
|
||||||
|
if (!this.guidance.trim()) throw Error('请先填写指导内容');
|
||||||
|
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`,{instruction:this.guidance.trim()}));
|
||||||
|
this.guidance = ''; this.notice.set('指导已提交;运行中将在下一步生效,已暂停的任务会重新排队恢复'); await this.refresh();
|
||||||
|
}); }
|
||||||
|
continueTask(id: string) { void this.action(async () => {
|
||||||
|
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`,{}));
|
||||||
|
this.notice.set('任务已申请恢复;设备忙时等待排队'); await this.refresh();
|
||||||
|
}); }
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { finalize } from 'rxjs';
|
|||||||
|
|
||||||
interface Capabilities {
|
interface Capabilities {
|
||||||
video_enabled: boolean;
|
video_enabled: boolean;
|
||||||
|
recording_enabled: boolean;
|
||||||
outputter_enabled: boolean;
|
outputter_enabled: boolean;
|
||||||
step_summarizer_enabled: boolean;
|
step_summarizer_enabled: boolean;
|
||||||
}
|
}
|
||||||
@@ -16,7 +17,9 @@ interface Capabilities {
|
|||||||
<h3>功能开关</h3>
|
<h3>功能开关</h3>
|
||||||
<p>按需启用,保存后对新任务生效。</p>
|
<p>按需启用,保存后对新任务生效。</p>
|
||||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.video_enabled" [disabled]="busy() || loading() || !loaded()" />
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.video_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
<span>视频分析与录屏<small>录制设备画面,并允许智能体分析录像;关闭可减少资源占用。</small></span></label>
|
<span>视频分析<small>允许智能体分析任务画面。运行期间会临时录制,完成后不保留录像(除非开启录屏)。</small></span></label>
|
||||||
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.recording_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
|
<span>保存录屏<small>保留任务录像以便复盘,可单独开启,不要求启用视频分析。</small></span></label>
|
||||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.outputter_enabled" [disabled]="busy() || loading() || !loaded()" />
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.outputter_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
|
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
|
||||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
@@ -36,7 +39,7 @@ interface Capabilities {
|
|||||||
export class CapabilitySettingsComponent implements OnInit {
|
export class CapabilitySettingsComponent implements OnInit {
|
||||||
changed = output<Capabilities>();
|
changed = output<Capabilities>();
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
config: Capabilities = { video_enabled: false, outputter_enabled: true, step_summarizer_enabled: true };
|
config: Capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true };
|
||||||
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
|
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
|
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
|
||||||
|
|||||||
@@ -98,21 +98,24 @@ describe('持久化模型与功能设置', () => {
|
|||||||
http.expectNone('/api/system/capability-settings');
|
http.expectNone('/api/system/capability-settings');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('保存关闭视频分析的配置,重新打开时恢复关闭状态', () => {
|
it('录屏与视频分析独立保存,重新打开后保留各自状态', () => {
|
||||||
const capabilities = { video_enabled: true, outputter_enabled: true, step_summarizer_enabled: false };
|
const capabilities = { video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false };
|
||||||
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
||||||
fixture.componentInstance.config.video_enabled = false;
|
fixture.componentInstance.config.video_enabled = false;
|
||||||
|
fixture.componentInstance.config.recording_enabled = true;
|
||||||
fixture.componentInstance.save();
|
fixture.componentInstance.save();
|
||||||
const request = http.expectOne('/api/system/capability-settings');
|
const request = http.expectOne('/api/system/capability-settings');
|
||||||
expect(request.request.body.video_enabled).toBeFalse();
|
expect(request.request.body.video_enabled).toBeFalse();
|
||||||
const saved = { ...capabilities, video_enabled: false };
|
expect(request.request.body.recording_enabled).toBeTrue();
|
||||||
|
const saved = { ...capabilities, video_enabled: false, recording_enabled: true };
|
||||||
request.flush(saved);
|
request.flush(saved);
|
||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
|
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
reopened.detectChanges();
|
reopened.detectChanges();
|
||||||
http.expectOne('/api/system/capability-settings').flush(saved);
|
http.expectOne('/api/system/capability-settings').flush(saved);
|
||||||
expect(reopened.componentInstance.config.video_enabled).toBeFalse();
|
expect(reopened.componentInstance.config.video_enabled).toBeFalse();
|
||||||
|
expect(reopened.componentInstance.config.recording_enabled).toBeTrue();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -103,7 +103,9 @@ export class AgentService {
|
|||||||
let sStatus = s.status;
|
let sStatus = s.status;
|
||||||
if (!isTerminal) {
|
if (!isTerminal) {
|
||||||
const isPending = !!pendingMatch && !isCurrentActive;
|
const isPending = !!pendingMatch && !isCurrentActive;
|
||||||
sStatus = isCurrentActive ? (status === 'paused' ? 'paused' : 'running') : (isPending ? 'pending' : s.status);
|
sStatus = isCurrentActive
|
||||||
|
? (activeMatch?.status || (status === 'paused' ? 'paused' : 'running'))
|
||||||
|
: (isPending ? 'pending' : s.status);
|
||||||
} else {
|
} else {
|
||||||
sStatus = (s.status === 'success') ? 'completed' : s.status;
|
sStatus = (s.status === 'success') ? 'completed' : s.status;
|
||||||
}
|
}
|
||||||
@@ -138,7 +140,7 @@ export class AgentService {
|
|||||||
|| !!activeMatch;
|
|| !!activeMatch;
|
||||||
const finalPending: Session = {
|
const finalPending: Session = {
|
||||||
...p,
|
...p,
|
||||||
status: isCurrentRunning ? (status === 'paused' ? 'paused' : 'running') : (p.status || 'pending'),
|
status: isCurrentRunning ? (activeMatch?.status || (status === 'paused' ? 'paused' : 'running')) : (p.status || 'pending'),
|
||||||
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
|
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
|
||||||
};
|
};
|
||||||
sessionMap.set(p.session_id, finalPending);
|
sessionMap.set(p.session_id, finalPending);
|
||||||
@@ -156,7 +158,7 @@ export class AgentService {
|
|||||||
session_id: sid,
|
session_id: sid,
|
||||||
initial_goal: at.goal || goal || '',
|
initial_goal: at.goal || goal || '',
|
||||||
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
|
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
|
||||||
status: 'running',
|
status: at.status || 'running',
|
||||||
model_info: this.activeModel() || undefined,
|
model_info: this.activeModel() || undefined,
|
||||||
device_serial: at.device_id || null
|
device_serial: at.device_id || null
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from artemis.agents.explorer.tiers import SUBMIT_TOOL
|
|||||||
from artemis.agents.explorer.tool_declarations import UNIVERSAL_EXPLORER_TOOLS
|
from artemis.agents.explorer.tool_declarations import UNIVERSAL_EXPLORER_TOOLS
|
||||||
from artemis.graph.state import State
|
from artemis.graph.state import State
|
||||||
from artemis.services.llm import get_llm
|
from artemis.services.llm import get_llm
|
||||||
|
from artemis.config.settings import settings
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -120,7 +121,9 @@ class UniversalRunnerMixin:
|
|||||||
else:
|
else:
|
||||||
current_llm = bound_llm
|
current_llm = bound_llm
|
||||||
|
|
||||||
response = await asyncio.wait_for(current_llm.ainvoke(messages), timeout=180)
|
response = await asyncio.wait_for(
|
||||||
|
current_llm.ainvoke(messages), timeout=settings.OPENAI_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
messages.append(response)
|
messages.append(response)
|
||||||
|
|
||||||
tool_calls = getattr(response, "tool_calls", None) or []
|
tool_calls = getattr(response, "tool_calls", None) or []
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ active window until its turn is chunked, whose user lines then carry it on.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from artemis.config.settings import settings
|
|
||||||
import base64
|
import base64
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
import json
|
import json
|
||||||
@@ -45,6 +44,9 @@ from pathlib import Path
|
|||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
from artemis.runtime.task_control import TaskControl
|
||||||
|
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from langchain_core.messages import (
|
from langchain_core.messages import (
|
||||||
BaseMessage,
|
BaseMessage,
|
||||||
@@ -1060,6 +1062,25 @@ class FlashRunner:
|
|||||||
pending_notices: list[str] = []
|
pending_notices: list[str] = []
|
||||||
|
|
||||||
while limit is None or turns < limit:
|
while limit is None or turns < limit:
|
||||||
|
control = getattr(self.ctx, "task_control", None)
|
||||||
|
if isinstance(control, TaskControl):
|
||||||
|
resumed = await control.checkpoint(
|
||||||
|
on_pause=getattr(self.ctx, "on_task_pause", None),
|
||||||
|
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||||
|
)
|
||||||
|
if resumed:
|
||||||
|
(
|
||||||
|
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
|
turns += 1
|
||||||
logger.info(f"--- Artemis Flash Turn {turns}{f'/{limit}' if limit else ''} ---")
|
logger.info(f"--- Artemis Flash Turn {turns}{f'/{limit}' if limit else ''} ---")
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ class Settings(BaseSettings):
|
|||||||
ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS: float = Field(default=180.0, gt=0)
|
ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS: float = Field(default=180.0, gt=0)
|
||||||
ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
|
ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
|
||||||
ARTEMIS_VIDEO_ENABLED: bool = False
|
ARTEMIS_VIDEO_ENABLED: bool = False
|
||||||
|
ARTEMIS_RECORDING_ENABLED: bool = False
|
||||||
ARTEMIS_OUTPUTTER_ENABLED: bool = True
|
ARTEMIS_OUTPUTTER_ENABLED: bool = True
|
||||||
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.
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ except ImportError:
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
||||||
from artemis.utils.video import detect_video_tools_enabled
|
from artemis.utils.video import detect_video_tools_enabled
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
from artemis.config import ExplorerConfig, LLMConfig, OutputterConfig
|
from artemis.config import ExplorerConfig, LLMConfig, OutputterConfig
|
||||||
@@ -108,6 +109,7 @@ class ExecutionSetup(BaseModel):
|
|||||||
enable_remote_tracing: bool = False
|
enable_remote_tracing: bool = False
|
||||||
app_lock_status: AppLaunchResult | None = None
|
app_lock_status: AppLaunchResult | None = None
|
||||||
video_recording_tools_enabled: bool = Field(default_factory=detect_video_tools_enabled)
|
video_recording_tools_enabled: bool = Field(default_factory=detect_video_tools_enabled)
|
||||||
|
record_video_enabled: bool = Field(default_factory=lambda: settings.ARTEMIS_RECORDING_ENABLED)
|
||||||
disable_checker: bool = False
|
disable_checker: bool = False
|
||||||
"""Legacy master switch (compat alias): ``True`` disables BOTH the midway
|
"""Legacy master switch (compat alias): ``True`` disables BOTH the midway
|
||||||
checkpoints and the final check, regardless of the individual gates below.
|
checkpoints and the final check, regardless of the individual gates below.
|
||||||
|
|||||||
@@ -372,6 +372,20 @@ class StorageManager:
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
def discard_session_recordings(self, session_id: str) -> None:
|
||||||
|
"""Remove references to temporary analysis-only captures after the run."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE video_recordings SET local_video_path = NULL, status = 'unavailable' "
|
||||||
|
"WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET video_filepath = NULL WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
def update_session_video_path(self, session_id: UUID, video_path: str):
|
def update_session_video_path(self, session_id: UUID, video_path: str):
|
||||||
"""Update video filepath across session and video_recordings tables."""
|
"""Update video filepath across session and video_recordings tables."""
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
|
|||||||
+10
-12
@@ -23,6 +23,7 @@ from artemis.controllers.unified_controller import UnifiedMobileController
|
|||||||
from artemis.data_engine.trace import trace
|
from artemis.data_engine.trace import trace
|
||||||
from artemis.graph.state import State
|
from artemis.graph.state import State
|
||||||
from artemis.graph.visibility import strict_state
|
from artemis.graph.visibility import strict_state
|
||||||
|
from artemis.runtime.task_control import TaskControl
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
from artemis.utils.ocr_api import is_ocr_configured, perform_ocr
|
from artemis.utils.ocr_api import is_ocr_configured, perform_ocr
|
||||||
from artemis.utils.ocr_xml_fusion import (
|
from artemis.utils.ocr_xml_fusion import (
|
||||||
@@ -78,18 +79,9 @@ def _check_injected_instruction_file(base_dir: str) -> dict | None:
|
|||||||
Returns the payload dict: `instruction` (str | None) plus the explicit
|
Returns the payload dict: `instruction` (str | None) plus the explicit
|
||||||
`release_loop` stop signal (bool) declared by the injecting caller.
|
`release_loop` stop signal (bool) declared by the injecting caller.
|
||||||
"""
|
"""
|
||||||
instruction_path = Path(base_dir) / "injected_instruction.json"
|
from artemis.runtime.task_control import consume_instructions
|
||||||
if instruction_path.exists():
|
|
||||||
with open(instruction_path, encoding="utf-8") as f:
|
return consume_instructions(base_dir)
|
||||||
data = json.load(f)
|
|
||||||
instruction_path.unlink()
|
|
||||||
payload = {
|
|
||||||
"instruction": data.get("instruction"),
|
|
||||||
"release_loop": bool(data.get("release_loop")),
|
|
||||||
}
|
|
||||||
logger.info(f"Read and deleted injected instruction payload: {payload}")
|
|
||||||
return payload
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _unprotect_current_check_lines(ctx: ArtemisContext) -> None:
|
def _unprotect_current_check_lines(ctx: ArtemisContext) -> None:
|
||||||
@@ -122,6 +114,12 @@ async def perception_node(state: State, ctx: ArtemisContext) -> dict:
|
|||||||
"""
|
"""
|
||||||
state = strict_state(state, "perception")
|
state = strict_state(state, "perception")
|
||||||
logger.info("Entering Perception Node...")
|
logger.info("Entering Perception Node...")
|
||||||
|
control = getattr(ctx, "task_control", None)
|
||||||
|
if isinstance(control, TaskControl):
|
||||||
|
await control.checkpoint(
|
||||||
|
on_pause=getattr(ctx, "on_task_pause", None),
|
||||||
|
on_resume=getattr(ctx, "on_task_resume", None),
|
||||||
|
)
|
||||||
|
|
||||||
if not ctx.data_engine.current_step_id:
|
if not ctx.data_engine.current_step_id:
|
||||||
ctx.data_engine.allocate_step_id()
|
ctx.data_engine.allocate_step_id()
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Cooperative, session-scoped task guidance and device lease parking."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from artemis.config import TRACES_PATH
|
||||||
|
from artemis.runtime.device_lock import DeviceExecutionLock
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def control_dir(session_id: str) -> Path:
|
||||||
|
if not session_id or not all(c.isalnum() or c in "-_" for c in session_id):
|
||||||
|
raise ValueError("无效的任务编号")
|
||||||
|
return TRACES_PATH / session_id
|
||||||
|
|
||||||
|
|
||||||
|
def _write_atomic(path: Path, data: dict) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
||||||
|
temporary.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
|
||||||
|
def read_state(session_id: str) -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads(
|
||||||
|
(control_dir(session_id) / "task_control_state.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def request_pause(session_id: str) -> None:
|
||||||
|
(control_dir(session_id) / "resume.request").unlink(missing_ok=True)
|
||||||
|
_write_atomic(control_dir(session_id) / "pause.request", {"created_at": time.time()})
|
||||||
|
|
||||||
|
|
||||||
|
def request_resume(session_id: str) -> None:
|
||||||
|
_write_atomic(control_dir(session_id) / "resume.request", {"created_at": time.time()})
|
||||||
|
|
||||||
|
|
||||||
|
def add_instruction(
|
||||||
|
session_id: str, instruction: str, *, release_loop: bool = False, source: str = "user"
|
||||||
|
) -> None:
|
||||||
|
if not instruction.strip() or len(instruction) > 10000:
|
||||||
|
raise ValueError("指导内容必须在 1–10000 字之间")
|
||||||
|
directory = control_dir(session_id) / "instructions"
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Unique names make simultaneous web and MCP submissions additive, never last-write-wins.
|
||||||
|
path = directory / f"{time.time_ns():020d}-{uuid.uuid4().hex}.json"
|
||||||
|
_write_atomic(
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
"instruction": instruction.strip(),
|
||||||
|
"release_loop": bool(release_loop),
|
||||||
|
"source": source,
|
||||||
|
"timestamp": time.time(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def consume_instructions(base_dir: str | Path) -> dict | None:
|
||||||
|
directory = Path(base_dir) / "instructions"
|
||||||
|
found = []
|
||||||
|
for path in sorted(directory.glob("*.json")) if directory.exists() else []:
|
||||||
|
try:
|
||||||
|
claimed = path.with_suffix(".reading")
|
||||||
|
path.rename(claimed)
|
||||||
|
found.append(json.loads(claimed.read_text(encoding="utf-8")))
|
||||||
|
claimed.unlink()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
legacy = Path(base_dir) / "injected_instruction.json"
|
||||||
|
if legacy.exists():
|
||||||
|
try:
|
||||||
|
claimed = legacy.with_suffix(".reading")
|
||||||
|
legacy.rename(claimed)
|
||||||
|
found.append(json.loads(claimed.read_text(encoding="utf-8")))
|
||||||
|
claimed.unlink()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
if not found:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"instruction": "\n\n".join(str(item.get("instruction") or "") for item in found).strip(),
|
||||||
|
"release_loop": any(bool(item.get("release_loop")) for item in found),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TaskControl:
|
||||||
|
"""Yield a device lease at the next safe turn and reacquire at the FIFO tail."""
|
||||||
|
|
||||||
|
def __init__(self, session_id: str, device_lock: DeviceExecutionLock):
|
||||||
|
self.session_id = session_id
|
||||||
|
self.device_lock = device_lock
|
||||||
|
self.directory = control_dir(session_id)
|
||||||
|
|
||||||
|
def _state(self, status: str) -> None:
|
||||||
|
_write_atomic(
|
||||||
|
self.directory / "task_control_state.json",
|
||||||
|
{"status": status, "updated_at": time.time(), "pid": os.getpid()},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def checkpoint(self, *, on_pause=None, on_resume=None) -> bool:
|
||||||
|
pause = self.directory / "pause.request"
|
||||||
|
if not pause.exists():
|
||||||
|
return False
|
||||||
|
pause.unlink(missing_ok=True)
|
||||||
|
if on_pause is not None:
|
||||||
|
await on_pause()
|
||||||
|
self.device_lock.release()
|
||||||
|
self._state("task_paused")
|
||||||
|
resume = self.directory / "resume.request"
|
||||||
|
while not resume.exists():
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
resume.unlink(missing_ok=True)
|
||||||
|
self._state("resuming")
|
||||||
|
previous = self.device_lock
|
||||||
|
self.device_lock = DeviceExecutionLock(
|
||||||
|
previous.device_id,
|
||||||
|
description=previous.description,
|
||||||
|
max_concurrency=previous.max_concurrency,
|
||||||
|
concurrency_mode=previous.concurrency_mode,
|
||||||
|
session_id=self.session_id,
|
||||||
|
ingress=previous.ingress,
|
||||||
|
lock_scope=previous.lock_scope,
|
||||||
|
)
|
||||||
|
cancelled = threading.Event()
|
||||||
|
acquiring = asyncio.create_task(
|
||||||
|
asyncio.to_thread(self.device_lock.acquire, cancel_event=cancelled)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.shield(acquiring)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
cancelled.set()
|
||||||
|
try:
|
||||||
|
await asyncio.shield(acquiring)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Resume acquisition ended during cancellation", exc_info=True)
|
||||||
|
self.device_lock.release()
|
||||||
|
raise
|
||||||
|
if on_resume is not None:
|
||||||
|
await on_resume()
|
||||||
|
self._state("running")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
self.device_lock.release()
|
||||||
|
(self.directory / "task_control_state.json").unlink(missing_ok=True)
|
||||||
+68
-10
@@ -540,6 +540,7 @@ class Agent:
|
|||||||
last_state: State | None = None
|
last_state: State | None = None
|
||||||
last_state_snapshot: dict | None = None
|
last_state_snapshot: dict | None = None
|
||||||
output = None
|
output = None
|
||||||
|
task_control = None
|
||||||
effective_mode = getattr(self._config, "concurrency_mode", "per_device")
|
effective_mode = getattr(self._config, "concurrency_mode", "per_device")
|
||||||
effective_max = getattr(self._config, "max_concurrency", None)
|
effective_max = getattr(self._config, "max_concurrency", None)
|
||||||
sess_id = (
|
sess_id = (
|
||||||
@@ -599,6 +600,11 @@ class Agent:
|
|||||||
# publish a new active session while the current task is still
|
# publish a new active session while the current task is still
|
||||||
# finishing.
|
# finishing.
|
||||||
self._prepare_tracing(task=task, context=context)
|
self._prepare_tracing(task=task, context=context)
|
||||||
|
if device_lock is not None and os.environ.get("ARTEMIS_TASK_WORKER") == "1":
|
||||||
|
from artemis.runtime.task_control import TaskControl
|
||||||
|
|
||||||
|
task_control = TaskControl(str(sess_id), device_lock)
|
||||||
|
context.task_control = task_control
|
||||||
self._prepare_output_files(task=task)
|
self._prepare_output_files(task=task)
|
||||||
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||||
if self._ui_adb_client is not None:
|
if self._ui_adb_client is not None:
|
||||||
@@ -612,16 +618,46 @@ class Agent:
|
|||||||
await self._prepare_app_lock(task=task, context=context)
|
await self._prepare_app_lock(task=task, context=context)
|
||||||
async with context:
|
async with context:
|
||||||
recording_started = False
|
recording_started = False
|
||||||
if self._config.video_recording_tools_enabled:
|
recording_parts = 1
|
||||||
|
controller = get_controller(context)
|
||||||
|
output_dir = None
|
||||||
|
if context.execution_setup and context.execution_setup.traces_path:
|
||||||
|
output_dir = (
|
||||||
|
self._tmp_traces_dir / context.execution_setup.trace_name
|
||||||
|
).resolve()
|
||||||
|
|
||||||
|
async def pause_capture():
|
||||||
|
nonlocal recording_started
|
||||||
|
if recording_started:
|
||||||
|
try:
|
||||||
|
await controller.stop_video_recording()
|
||||||
|
finally:
|
||||||
|
recording_started = False
|
||||||
|
from artemis.tools.command_tool import shutdown_adb_background_tasks
|
||||||
|
|
||||||
|
await shutdown_adb_background_tasks(context)
|
||||||
|
|
||||||
|
async def resume_capture():
|
||||||
|
nonlocal recording_started, recording_parts
|
||||||
|
if not (
|
||||||
|
self._config.video_recording_tools_enabled
|
||||||
|
or self._config.record_video_enabled
|
||||||
|
):
|
||||||
|
return
|
||||||
|
recording_parts += 1
|
||||||
|
target = (
|
||||||
|
output_dir / f"resume_{recording_parts:04d}" if output_dir else None
|
||||||
|
)
|
||||||
|
result = await controller.start_video_recording(output_dir=target)
|
||||||
|
recording_started = bool(result and result.success)
|
||||||
|
|
||||||
|
context.on_task_pause = pause_capture
|
||||||
|
context.on_task_resume = resume_capture
|
||||||
|
if (
|
||||||
|
self._config.video_recording_tools_enabled
|
||||||
|
or self._config.record_video_enabled
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
controller = get_controller(context)
|
|
||||||
|
|
||||||
output_dir = None
|
|
||||||
if context.execution_setup and context.execution_setup.traces_path:
|
|
||||||
output_dir = (
|
|
||||||
self._tmp_traces_dir / context.execution_setup.trace_name
|
|
||||||
).resolve()
|
|
||||||
|
|
||||||
logger.info(f"[{task_name}] Starting automated screen recording...")
|
logger.info(f"[{task_name}] Starting automated screen recording...")
|
||||||
start_res = await controller.start_video_recording(
|
start_res = await controller.start_video_recording(
|
||||||
output_dir=output_dir
|
output_dir=output_dir
|
||||||
@@ -840,6 +876,25 @@ class Agent:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[{task_name}] Failed to stop background ADB tasks: {e}")
|
logger.warning(f"[{task_name}] Failed to stop background ADB tasks: {e}")
|
||||||
try:
|
try:
|
||||||
|
if (
|
||||||
|
self._config.video_recording_tools_enabled
|
||||||
|
and not self._config.record_video_enabled
|
||||||
|
):
|
||||||
|
# Video analysis used a temporary capture during execution.
|
||||||
|
# Keep no recording after the final model/report step.
|
||||||
|
trace_dir = (self._tmp_traces_dir / task_name).resolve()
|
||||||
|
root = self._tmp_traces_dir.resolve()
|
||||||
|
if trace_dir.is_relative_to(root) and trace_dir.is_dir():
|
||||||
|
for recording in trace_dir.rglob("*"):
|
||||||
|
if recording.is_file() and (
|
||||||
|
recording.suffix.lower() in {".mkv", ".mp4", ".webm"}
|
||||||
|
or recording.name == "recording.json"
|
||||||
|
):
|
||||||
|
recording.unlink(missing_ok=True)
|
||||||
|
if context.data_engine and context.data_engine.current_session_id:
|
||||||
|
context.data_engine.storage.discard_session_recordings(
|
||||||
|
str(context.data_engine.current_session_id)
|
||||||
|
)
|
||||||
await self._finalize_tracing_safely(task=task, context=context)
|
await self._finalize_tracing_safely(task=task, context=context)
|
||||||
finally:
|
finally:
|
||||||
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||||
@@ -847,7 +902,9 @@ class Agent:
|
|||||||
if self._ui_adb_client is not None:
|
if self._ui_adb_client is not None:
|
||||||
await asyncio.to_thread(self._ui_adb_client.disconnect)
|
await asyncio.to_thread(self._ui_adb_client.disconnect)
|
||||||
finally:
|
finally:
|
||||||
if device_lock is not None:
|
if task_control is not None:
|
||||||
|
await asyncio.to_thread(task_control.release)
|
||||||
|
elif device_lock is not None:
|
||||||
await asyncio.to_thread(device_lock.release)
|
await asyncio.to_thread(device_lock.release)
|
||||||
|
|
||||||
async with self._task_lock:
|
async with self._task_lock:
|
||||||
@@ -1074,6 +1131,7 @@ class Agent:
|
|||||||
if task.request.record_trace
|
if task.request.record_trace
|
||||||
else False,
|
else False,
|
||||||
video_recording_tools_enabled=self._config.video_recording_tools_enabled,
|
video_recording_tools_enabled=self._config.video_recording_tools_enabled,
|
||||||
|
record_video_enabled=self._config.record_video_enabled,
|
||||||
disable_checker=self._config.disable_checker,
|
disable_checker=self._config.disable_checker,
|
||||||
disable_midway_checks=self._config.disable_midway_checks,
|
disable_midway_checks=self._config.disable_midway_checks,
|
||||||
disable_final_check=self._config.disable_final_check,
|
disable_final_check=self._config.disable_final_check,
|
||||||
|
|||||||
@@ -609,6 +609,7 @@ class AgentConfigBuilder:
|
|||||||
servers=self._servers,
|
servers=self._servers,
|
||||||
graph_config_callbacks=self._graph_config_callbacks,
|
graph_config_callbacks=self._graph_config_callbacks,
|
||||||
video_recording_tools_enabled=self._video_recording_tools_enabled,
|
video_recording_tools_enabled=self._video_recording_tools_enabled,
|
||||||
|
record_video_enabled=settings.ARTEMIS_RECORDING_ENABLED,
|
||||||
force_web_accessibility=self._force_web_accessibility,
|
force_web_accessibility=self._force_web_accessibility,
|
||||||
disable_checker=self._disable_checker,
|
disable_checker=self._disable_checker,
|
||||||
disable_midway_checks=self._disable_midway_checks,
|
disable_midway_checks=self._disable_midway_checks,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from artemis.config import (
|
|||||||
from artemis.context import DevicePlatform
|
from artemis.context import DevicePlatform
|
||||||
from artemis.sdk.types.task import AgentProfile, TaskRequestCommon
|
from artemis.sdk.types.task import AgentProfile, TaskRequestCommon
|
||||||
from artemis.utils.video import detect_video_tools_enabled
|
from artemis.utils.video import detect_video_tools_enabled
|
||||||
|
from artemis.config.settings import settings
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
@@ -113,6 +114,7 @@ class AgentConfig(BaseModel):
|
|||||||
servers: ServerConfig
|
servers: ServerConfig
|
||||||
graph_config_callbacks: Callbacks = None
|
graph_config_callbacks: Callbacks = None
|
||||||
video_recording_tools_enabled: bool = Field(default_factory=detect_video_tools_enabled)
|
video_recording_tools_enabled: bool = Field(default_factory=detect_video_tools_enabled)
|
||||||
|
record_video_enabled: bool = Field(default_factory=lambda: settings.ARTEMIS_RECORDING_ENABLED)
|
||||||
force_web_accessibility: bool = False
|
force_web_accessibility: bool = False
|
||||||
disable_checker: bool = False
|
disable_checker: bool = False
|
||||||
disable_midway_checks: bool = True
|
disable_midway_checks: bool = True
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"python_download_mirror": "https://mirrors.nju.edu.cn/github-release/astral-sh/python-build-standalone",
|
"python_download_mirror": "https://mirrors.nju.edu.cn/github-release/astral-sh/python-build-standalone",
|
||||||
"npm_registry": "https://registry.npmmirror.com",
|
"npm_registry": "https://registry.npmmirror.com",
|
||||||
"node_mirror": "https://registry.npmmirror.com/-/binary/node",
|
"node_mirror": "https://registry.npmmirror.com/-/binary/node",
|
||||||
"adb_url": "https://mirrors.cloud.tencent.com/AndroidSDK/platform-tools-latest-windows.zip"
|
"adb_url": "https://mirrors.cloud.tencent.com/AndroidSDK/platform-tools-latest-windows.zip",
|
||||||
|
"scrcpy_url": "https://mirrors.sdu.edu.cn/github-release/Genymobile_scrcpy/v4.1/scrcpy-win64-v4.1.zip"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"python_index": "",
|
"python_index": "",
|
||||||
|
|||||||
@@ -22,13 +22,13 @@
|
|||||||
| `mobile_workspace` | 设备列表与统一队列状态 |
|
| `mobile_workspace` | 设备列表与统一队列状态 |
|
||||||
| `mobile_config` | 读取或明确保存非敏感模型、功能、报告默认设置 |
|
| `mobile_config` | 读取或明确保存非敏感模型、功能、报告默认设置 |
|
||||||
| `mobile_run_task` | 单任务执行;`model=flash/pro` 表示执行模式 |
|
| `mobile_run_task` | 单任务执行;`model=flash/pro` 表示执行模式 |
|
||||||
| `mobile_manage_task` | 状态、指导和停止单任务 |
|
| `mobile_manage_task` | 单任务状态、安全暂停、排队恢复、下一步指导和停止 |
|
||||||
| `mobile_get_device_state` | 设备截图、界面层级 |
|
| `mobile_get_device_state` | 设备截图、界面层级 |
|
||||||
| `mobile_task_file` | XLSX 模板与导入校验 |
|
| `mobile_task_file` | XLSX 模板与导入校验 |
|
||||||
| `mobile_batch` | JSON 用例建批次、开始、暂停、继续、复制失败项 |
|
| `mobile_batch` | JSON 用例建批次、开始、暂停、继续、复制失败项 |
|
||||||
| `mobile_search_history` | 全库搜索、日期/状态筛选和分页 |
|
| `mobile_search_history` | 全库搜索、日期/状态筛选和分页 |
|
||||||
| `mobile_inspect_trace` | 只读步骤、截图和执行证据 |
|
| `mobile_inspect_trace` | 只读步骤、截图和执行证据 |
|
||||||
| `mobile_export_report` | Word、Excel 和批次 ZIP,返回本地绝对路径 |
|
| `mobile_export_report` | Word、Excel、步骤截图、录屏片段、单任务资料及批次 ZIP,返回本地绝对路径 |
|
||||||
|
|
||||||
新增工具通过统一后台服务运行,与网页共享配置、队列、历史和报告;不另外启动独立调度器。`ARTEMIS_STANDALONE` 模式不支持这些工作区工具。
|
新增工具通过统一后台服务运行,与网页共享配置、队列、历史和报告;不另外启动独立调度器。`ARTEMIS_STANDALONE` 模式不支持这些工作区工具。
|
||||||
|
|
||||||
@@ -42,6 +42,6 @@ MCP 自动刷新只处理已存在且指向当前项目的标准 Python 模块
|
|||||||
|
|
||||||
报告导出到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
|
报告导出到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
|
||||||
|
|
||||||
模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏默认关闭。
|
模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停任务释放设备,AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复,或用 `resume` 单独恢复。
|
||||||
|
|
||||||
保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
|
保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 导出结论报告
|
## 导出结论报告
|
||||||
|
|
||||||
选择已有任务,在“结论报告”中下载 Word 或 Excel。默认同时生成两种格式;勾选所需格式并保存,后续提交使用新的默认值。
|
选择已有任务,在“报告与质量”中下载 Word、Excel、按步骤排序的截图 ZIP、录屏分段 ZIP,或一键下载包含上述文件和清单的资料 ZIP。只有启用并成功保存录屏的任务才有录屏可下载。默认报告格式为 Word 和 Excel;勾选所需格式并保存,后续提交使用新的默认值。
|
||||||
|
|
||||||
报告包含目标、预期结果、执行状态、验证结论、实际输出和步骤证据。Word 内嵌关键截图,Excel 包含结果与证据工作表;任务输出中的 Markdown 表格会转换为真正的文档表格和 Excel 工作表。
|
报告包含目标、预期结果、执行状态、验证结论、实际输出和步骤证据。Word 内嵌关键截图,Excel 包含结果与证据工作表;任务输出中的 Markdown 表格会转换为真正的文档表格和 Excel 工作表。
|
||||||
|
|
||||||
@@ -15,16 +15,16 @@
|
|||||||
## Excel 批量导入
|
## Excel 批量导入
|
||||||
|
|
||||||
1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。
|
1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。
|
||||||
2. 每行填写一个独立用例。必填“任务名称”“任务目标”;其余列是预期结果、目标应用、前置条件、输入参数(JSON)、标签。
|
2. 标准列按顺序为“用例编号”“检测项目”“测试子项”“测试用例”“前置约束提取”“Artemis提示词”“任务配置(JSON)”。任务表留空,示例行在“填写说明”页。
|
||||||
3. 参数示例为 `{"关键词":"天气"}`,在任务目标中使用 `{{关键词}}`。不要填写公式、宏或外部链接。
|
3. 每行的“Artemis提示词”必填,且是唯一提交给 AI 的任务正文;其他文本列用于显示、搜索和报告,不自动拼入提示词。任务配置可留空,或填写 JSON 对象,如 `{"profile":"pro","model":"mimo-v2.6-flash","reasoning_effort":"high","record_video":true,"analyze_video":false,"request_timeout_seconds":300}`。支持单项覆盖设备、验证级别、观察模式、目标应用包名、重试及单任务时限;未设置字段继承批次或全局设置。不接受密钥和未知字段。不要填写公式、宏或外部链接。
|
||||||
4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。
|
4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。
|
||||||
5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。
|
5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。
|
||||||
|
|
||||||
同一批次按顺序执行。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次让当前任务继续;全局停止会暂停批次并停止当前执行。
|
同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次只停止后续派发。单个任务可在步骤间安全暂停,释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导在下一步执行前生效,不打断当前步骤。暂停等待和重新排队时间不计入该任务执行时限。
|
||||||
|
|
||||||
服务重启后保留批次,等待用户继续未开始的用例。中断任务不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
|
服务重启后保留批次,等待用户继续未开始的用例。正在执行或暂停的任务标记中断,不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
|
||||||
|
|
||||||
每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。
|
每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
|
||||||
|
|
||||||
## AI 使用
|
## AI 使用
|
||||||
|
|
||||||
|
|||||||
@@ -184,7 +184,10 @@ async def mobile_batch(
|
|||||||
"""Create, execute and manage durable batches on the unified device queue.
|
"""Create, execute and manage durable batches on the unified device queue.
|
||||||
|
|
||||||
create requires a unique request_id and batch={name,device_serial,cases:[{name,goal,
|
create requires a unique request_id and batch={name,device_serial,cases:[{name,goal,
|
||||||
expected?,app?,preconditions?,parameters?:{key:primitive},tags?}],profile?:flash|pro,
|
case_id?,category?,subcategory?,preconditions?,task_config?:{profile?,model?,
|
||||||
|
reasoning_effort?,device_serial?,verification_level?,explorer_mode?,
|
||||||
|
locked_app_package?,record_video?,analyze_video?,request_timeout_seconds?,
|
||||||
|
max_retries?,retry_interval_seconds?,timeout_seconds?}}],profile?:flash|pro,
|
||||||
formats?:[docx,xlsx],stop_on_failure?:false,timeout_seconds?:1800}. Required device
|
formats?:[docx,xlsx],stop_on_failure?:false,timeout_seconds?:1800}. Required device
|
||||||
must be user-selected. Creation validates and saves a DRAFT; start executes it.
|
must be user-selected. Creation validates and saves a DRAFT; start executes it.
|
||||||
Reuse the same request_id and exact payload after a connection failure; never invent
|
Reuse the same request_id and exact payload after a connection failure; never invent
|
||||||
@@ -225,7 +228,7 @@ async def mobile_search_history(
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
size: int = 30,
|
size: int = 30,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Search ALL persisted terminal tasks by name, goal, ID or tags, with pagination.
|
"""Search ALL persisted terminal tasks by name, prompt, ID or case metadata, with pagination.
|
||||||
|
|
||||||
start/end are Unix seconds, inclusive/exclusive; convert the user's local dates
|
start/end are Unix seconds, inclusive/exclusive; convert the user's local dates
|
||||||
before calling. Status: completed/success/failed/cancelled/interrupted/skipped.
|
before calling. Status: completed/success/failed/cancelled/interrupted/skipped.
|
||||||
@@ -243,11 +246,12 @@ async def mobile_search_history(
|
|||||||
async def mobile_export_report(
|
async def mobile_export_report(
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
batch_id: str | None = None,
|
batch_id: str | None = None,
|
||||||
formats: list[Literal["docx", "xlsx"]] | None = None,
|
formats: list[Literal["docx", "xlsx", "screenshots", "video", "all"]] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Export a task's Word/Excel reports or a batch ZIP to local files on the MCP host.
|
"""Export Word/Excel, ordered screenshots, saved recording segments or all task material.
|
||||||
|
|
||||||
Provide exactly one of session_id/batch_id. Task formats omitted uses saved defaults;
|
Provide exactly one of session_id/batch_id. Task formats omitted uses saved defaults;
|
||||||
|
screenshots/video/all produce ZIPs; video requires recording to have been enabled.
|
||||||
batch ZIP uses its frozen formats and includes summary + individual case reports.
|
batch ZIP uses its frozen formats and includes summary + individual case reports.
|
||||||
Returns absolute paths, download URLs and sizes; task results also include structured
|
Returns absolute paths, download URLs and sizes; task results also include structured
|
||||||
verdict/evidence. Running exports are snapshots. 'completed' is not proof of PASS;
|
verdict/evidence. Running exports are snapshots. 'completed' is not proof of PASS;
|
||||||
@@ -269,11 +273,18 @@ async def mobile_export_report(
|
|||||||
if formats is not None
|
if formats is not None
|
||||||
else (await _request("GET", "/api/automation/preferences"))["formats"]
|
else (await _request("GET", "/api/automation/preferences"))["formats"]
|
||||||
)
|
)
|
||||||
if not chosen or len(chosen) > 2 or any(fmt not in {"docx", "xlsx"} for fmt in chosen):
|
if (
|
||||||
raise ValueError("formats 至少选择 docx 或 xlsx,最多两项")
|
not chosen
|
||||||
|
or len(chosen) > 5
|
||||||
|
or any(fmt not in {"docx", "xlsx", "screenshots", "video", "all"} for fmt in chosen)
|
||||||
|
):
|
||||||
|
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}")
|
||||||
files = [
|
files = [
|
||||||
await _download(f"/api/automation/reports/{sid}/{fmt}", f"task-{sid}.{fmt}")
|
await _download(
|
||||||
|
f"/api/automation/reports/{sid}/{fmt}",
|
||||||
|
f"task-{sid}.{fmt}" if fmt in {"docx", "xlsx"} else f"task-{sid}-{fmt}.zip",
|
||||||
|
)
|
||||||
for fmt in dict.fromkeys(chosen)
|
for fmt in dict.fromkeys(chosen)
|
||||||
]
|
]
|
||||||
return {"report": report, "files": files}
|
return {"report": report, "files": files}
|
||||||
@@ -34,6 +34,13 @@ from artemis.runtime import (
|
|||||||
trace_store,
|
trace_store,
|
||||||
)
|
)
|
||||||
from artemis.runtime.process_probe import pid_is_alive
|
from artemis.runtime.process_probe import pid_is_alive
|
||||||
|
from artemis.runtime.task_control import (
|
||||||
|
add_instruction,
|
||||||
|
control_dir,
|
||||||
|
read_state,
|
||||||
|
request_pause,
|
||||||
|
request_resume,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -51,6 +58,34 @@ def _find_data_engine_db() -> str | None:
|
|||||||
return db_path if os.path.exists(db_path) else None
|
return db_path if os.path.exists(db_path) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _web_session_status(trace_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Web submissions have a session row but no MCP launcher status.json."""
|
||||||
|
db_path = _find_data_engine_db()
|
||||||
|
if not db_path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT status, pid, start_time, end_time, device_info "
|
||||||
|
"FROM sessions WHERE session_id = ? ORDER BY start_time DESC LIMIT 1",
|
||||||
|
(trace_id,),
|
||||||
|
).fetchone()
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
logger.warning("Could not read web session %s: %s", trace_id, exc)
|
||||||
|
return None
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
data = dict(row)
|
||||||
|
if data.get("device_info"):
|
||||||
|
try:
|
||||||
|
device = json.loads(data["device_info"])
|
||||||
|
data["device_serial"] = device.get("device_id") if isinstance(device, dict) else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def _pid_alive(pid: int | None) -> bool:
|
def _pid_alive(pid: int | None) -> bool:
|
||||||
if not pid:
|
if not pid:
|
||||||
return False
|
return False
|
||||||
@@ -143,8 +178,8 @@ def _reconcile_task_state(
|
|||||||
liveness_failed = (
|
liveness_failed = (
|
||||||
current_status == "failed" and status_data.get("error") == _LIVENESS_FAILURE_ERROR
|
current_status == "failed" and status_data.get("error") == _LIVENESS_FAILURE_ERROR
|
||||||
)
|
)
|
||||||
if db_status in ("completed", "success", "failed", "cancelled") and (
|
if db_status in ("completed", "success", "failed", "cancelled", "interrupted") and (
|
||||||
current_status in ("running", "pending") or liveness_failed
|
current_status in ("running", "pending", "task_paused", "resuming") or liveness_failed
|
||||||
):
|
):
|
||||||
canonical = "completed" if db_status in ("completed", "success") else db_status
|
canonical = "completed" if db_status in ("completed", "success") else db_status
|
||||||
if current_status != canonical:
|
if current_status != canonical:
|
||||||
@@ -156,7 +191,7 @@ def _reconcile_task_state(
|
|||||||
status_data["end_time"] = time.time()
|
status_data["end_time"] = time.time()
|
||||||
dirty = True
|
dirty = True
|
||||||
is_alive = False
|
is_alive = False
|
||||||
elif current_status in ("running", "pending"):
|
elif current_status in ("running", "pending", "task_paused", "resuming"):
|
||||||
if not pid:
|
if not pid:
|
||||||
# No pid recorded (daemon dispatch never writes one): liveness cannot be
|
# No pid recorded (daemon dispatch never writes one): liveness cannot be
|
||||||
# inferred, so the task is assumed alive -- matching the historical
|
# inferred, so the task is assumed alive -- matching the historical
|
||||||
@@ -225,13 +260,13 @@ def mobile_manage_task(
|
|||||||
"""Manages the lifecycle and retrieves the status of a background mobile automation task.
|
"""Manages the lifecycle and retrieves the status of a background mobile automation task.
|
||||||
|
|
||||||
This is your primary diagnostic and control tool for tasks started by
|
This is your primary diagnostic and control tool for tasks started by
|
||||||
`mobile_run_task`. You MUST use it to poll the task status when your
|
`mobile_run_task` or the web workspace. You MUST use it to poll the task status when your
|
||||||
1-minute fallback timer triggers (as required by `mobile_run_task`). You
|
1-minute fallback timer triggers (as required by `mobile_run_task`). You
|
||||||
can also steer a subagent that is stuck or off-track, or abort a task.
|
can also steer a subagent that is stuck or off-track, or abort a task.
|
||||||
|
|
||||||
### Actions
|
### Actions
|
||||||
- **'status'**: Returns `trace_id`, `status` ('running'/'completed'/'failed'/
|
- **'status'**: Returns `trace_id`, `status` ('running'/'task_paused'/
|
||||||
'cancelled'), `device_serial` (which phone owns this task in multi-device
|
'resuming'/'completed'/'failed'/'cancelled'/'interrupted'), `device_serial` (which phone owns this task in multi-device
|
||||||
setups), `task_desc`, `model`, `elapsed_seconds`, a `test_summary` (when
|
setups), `task_desc`, `model`, `elapsed_seconds`, a `test_summary` (when
|
||||||
the run declared verification check items: machine-readable
|
the run declared verification check items: machine-readable
|
||||||
passed/failed/inconclusive/unchecked counts plus failed-item details —
|
passed/failed/inconclusive/unchecked counts plus failed-item details —
|
||||||
@@ -239,6 +274,9 @@ def mobile_manage_task(
|
|||||||
current turn, latest thought/action — intervene if turns climb without
|
current turn, latest thought/action — intervene if turns climb without
|
||||||
progress or the thought indicates it is stuck; Pro: the active task plan
|
progress or the thought indicates it is stuck; Pro: the active task plan
|
||||||
— check it still aligns with your goal).
|
— check it still aligns with your goal).
|
||||||
|
- **'pause'**: Requests a pause after the current action, releasing the device.
|
||||||
|
- **'resume'**: Queues a paused task for its device again; optional `instruction`
|
||||||
|
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. Applied at the start of the next
|
||||||
planning turn (Pro) or the next reactive loop (Flash). Requires
|
planning turn (Pro) or the next reactive loop (Flash). Requires
|
||||||
@@ -254,15 +292,15 @@ def mobile_manage_task(
|
|||||||
should wind down cleanly instead of being killed.
|
should wind down cleanly instead of being killed.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
action: `"status"`, `"inject_instruction"`, or `"stop"`.
|
action: `"status"`, `"pause"`, `"resume"`, `"inject_instruction"`, or `"stop"`.
|
||||||
trace_id: The task's session identifier from `mobile_run_task`.
|
trace_id: The task's session identifier from `mobile_run_task` or the web UI.
|
||||||
instruction: Guidance string; required for `inject_instruction`
|
instruction: Guidance string; required for `inject_instruction`
|
||||||
(optional when `release_loop=True`), omit otherwise.
|
(optional when `release_loop=True`), omit otherwise.
|
||||||
release_loop: With `inject_instruction`: explicit user stop signal
|
release_loop: With `inject_instruction`: explicit user stop signal
|
||||||
that authorizes the subagent to complete continuous monitoring
|
that authorizes the subagent to complete continuous monitoring
|
||||||
loops and finish the task gracefully.
|
loops and finish the task gracefully.
|
||||||
"""
|
"""
|
||||||
status_data = trace_store.read_status(trace_id)
|
status_data = trace_store.read_status(trace_id) or _web_session_status(trace_id)
|
||||||
if not status_data:
|
if not status_data:
|
||||||
return {
|
return {
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
@@ -271,6 +309,9 @@ def mobile_manage_task(
|
|||||||
}
|
}
|
||||||
|
|
||||||
current_status, pid, is_alive = _reconcile_task_state(trace_id, status_data)
|
current_status, pid, is_alive = _reconcile_task_state(trace_id, status_data)
|
||||||
|
control_status = read_state(trace_id).get("status") if is_alive else None
|
||||||
|
if control_status in ("task_paused", "resuming") and current_status in ("running", "pending"):
|
||||||
|
current_status = control_status
|
||||||
|
|
||||||
trace_dir = trace_store.get_trace_dir(trace_id)
|
trace_dir = trace_store.get_trace_dir(trace_id)
|
||||||
|
|
||||||
@@ -368,7 +409,7 @@ def mobile_manage_task(
|
|||||||
pass
|
pass
|
||||||
break
|
break
|
||||||
|
|
||||||
if current_status in ("running", "pending") and is_alive:
|
if current_status in ("running", "pending", "task_paused", "resuming") and is_alive:
|
||||||
progress: dict[str, Any] = {}
|
progress: dict[str, Any] = {}
|
||||||
|
|
||||||
project_root = env_utils.get_project_root()
|
project_root = env_utils.get_project_root()
|
||||||
@@ -483,8 +524,38 @@ def mobile_manage_task(
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
elif action == "pause":
|
||||||
|
if current_status not in ("running", "pending", "resuming") or not is_alive:
|
||||||
|
return {
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"status": current_status,
|
||||||
|
"message": "Task is not running.",
|
||||||
|
}
|
||||||
|
request_pause(trace_id)
|
||||||
|
return {
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"status": "pausing",
|
||||||
|
"message": "Pauses after the current action and releases the device.",
|
||||||
|
}
|
||||||
|
|
||||||
|
elif action == "resume":
|
||||||
|
if current_status != "task_paused" or not is_alive:
|
||||||
|
return {
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"status": current_status,
|
||||||
|
"message": "Task is not paused.",
|
||||||
|
}
|
||||||
|
if instruction:
|
||||||
|
add_instruction(trace_id, instruction, source="mcp")
|
||||||
|
request_resume(trace_id)
|
||||||
|
return {
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"status": "resuming",
|
||||||
|
"message": "Queued for the device again.",
|
||||||
|
}
|
||||||
|
|
||||||
elif action == "stop":
|
elif action == "stop":
|
||||||
if current_status not in ("running", "pending"):
|
if current_status not in ("running", "pending", "task_paused", "resuming"):
|
||||||
return {
|
return {
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
"status": current_status,
|
"status": current_status,
|
||||||
@@ -579,7 +650,7 @@ def mobile_manage_task(
|
|||||||
"message": "Missing required argument 'instruction' for action 'inject_instruction'.",
|
"message": "Missing required argument 'instruction' for action 'inject_instruction'.",
|
||||||
}
|
}
|
||||||
|
|
||||||
if current_status not in ("running", "pending"):
|
if current_status not in ("running", "pending", "task_paused", "resuming"):
|
||||||
return {
|
return {
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
"status": current_status,
|
"status": current_status,
|
||||||
@@ -593,19 +664,13 @@ def mobile_manage_task(
|
|||||||
"message": f"Trace directory not found: {trace_dir}",
|
"message": f"Trace directory not found: {trace_dir}",
|
||||||
}
|
}
|
||||||
|
|
||||||
instruction_path = os.path.join(trace_dir, "injected_instruction.json")
|
|
||||||
try:
|
try:
|
||||||
with open(instruction_path, "w", encoding="utf-8") as f:
|
add_instruction(trace_id, instruction, release_loop=release_loop, source="mcp")
|
||||||
json.dump(
|
if (
|
||||||
{
|
current_status == "task_paused"
|
||||||
"instruction": instruction,
|
or (control_dir(trace_id) / "pause.request").exists()
|
||||||
"release_loop": bool(release_loop),
|
):
|
||||||
"timestamp": time.time(),
|
request_resume(trace_id)
|
||||||
"status": "pending",
|
|
||||||
},
|
|
||||||
f,
|
|
||||||
indent=2,
|
|
||||||
)
|
|
||||||
suffix = " (with release_loop stop signal)" if release_loop else ""
|
suffix = " (with release_loop stop signal)" if release_loop else ""
|
||||||
return {
|
return {
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
@@ -623,5 +688,5 @@ def mobile_manage_task(
|
|||||||
return {
|
return {
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
"status": current_status,
|
"status": current_status,
|
||||||
"message": f"Invalid action '{action}'. Supported actions are 'status', 'stop', and 'inject_instruction'.",
|
"message": f"Invalid action '{action}'. Supported actions are 'status', 'pause', 'resume', 'stop', and 'inject_instruction'.",
|
||||||
}
|
}
|
||||||
@@ -39,6 +39,8 @@ function Initialize-ArtemisDownloads {
|
|||||||
$env:npm_config_fetch_timeout = [string]($timeout * 1000)
|
$env:npm_config_fetch_timeout = [string]($timeout * 1000)
|
||||||
$values.mode = $Mode
|
$values.mode = $Mode
|
||||||
$values.timeout_seconds = $timeout
|
$values.timeout_seconds = $timeout
|
||||||
|
# Official scrcpy v4.1 win64 release digest. Verify every mirror/override equally.
|
||||||
|
$values.scrcpy_sha256 = '5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db'
|
||||||
$script:ArtemisDownloads = [pscustomobject]$values
|
$script:ArtemisDownloads = [pscustomobject]$values
|
||||||
Write-Host " [INFO] Download mode: $Mode (config/downloads.json)." -ForegroundColor Cyan
|
Write-Host " [INFO] Download mode: $Mode (config/downloads.json)." -ForegroundColor Cyan
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -219,11 +219,16 @@ function Install-PortableScrcpy {
|
|||||||
|
|
||||||
Write-Host " [INFO] Installing portable scrcpy in user space..." -ForegroundColor Cyan
|
Write-Host " [INFO] Installing portable scrcpy in user space..." -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 ' [INFO] scrcpy uses GitHub. If unreachable, set overrides.scrcpy_url in config/downloads.json.' -ForegroundColor Yellow
|
Write-Host ' [WARN] China mode has no non-GitHub scrcpy source. Configure overrides.scrcpy_url or install scrcpy manually.' -ForegroundColor Yellow
|
||||||
|
return $false
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$zipPath = "$env:TEMP\scrcpy-win64.zip"
|
$zipPath = "$env:TEMP\scrcpy-win64.zip"
|
||||||
if (Invoke-DownloadFile -Uri $ArtemisDownloads.scrcpy_url -OutFile $zipPath) {
|
if (Invoke-DownloadFile -Uri $ArtemisDownloads.scrcpy_url -OutFile $zipPath) {
|
||||||
|
$actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
if ($actualHash -ne $ArtemisDownloads.scrcpy_sha256) {
|
||||||
|
throw 'Downloaded scrcpy ZIP failed official SHA256 verification.'
|
||||||
|
}
|
||||||
$extractDir = "$env:TEMP\scrcpy_extract"
|
$extractDir = "$env:TEMP\scrcpy_extract"
|
||||||
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue }
|
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue }
|
||||||
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
|||||||
2. 用 `mobile_workspace` 查看设备和队列。沿用用户已指定的设备;多台可用设备且没有选择时先询问,不擅自挑选实体手机。任务参数显式传入 `device_serial`。同设备串行,不同设备可并行;本 Skill 不要求创建其他 AI 子代理。
|
2. 用 `mobile_workspace` 查看设备和队列。沿用用户已指定的设备;多台可用设备且没有选择时先询问,不擅自挑选实体手机。任务参数显式传入 `device_serial`。同设备串行,不同设备可并行;本 Skill 不要求创建其他 AI 子代理。
|
||||||
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=...)`。尊重工具建议的等待周期,等待期间可以整理已得证据,避免连续高频轮询。停滞或失败先诊断;不把同一业务任务自动重新提交来解决模型错误。
|
6. 单任务返回 `trace_id` 后,用 `mobile_manage_task(action="status", trace_id=...)` 跟踪;网页任务也可用其任务编号。批次用 `mobile_batch(action="get", batch_id=...)`。需要人工补充时用 `pause` 在当前步骤后释放设备;`inject_instruction` 可运行中指导下一步,暂停中提交则恢复。无新指导用 `resume`。恢复进入设备 FIFO 队尾,并重新观察手机状态。尊重工具建议的等待周期,避免连续高频轮询;停滞或失败先诊断,不把同一业务任务自动重提。
|
||||||
|
|
||||||
## 结果、证据与交付
|
## 结果、证据与交付
|
||||||
|
|
||||||
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。
|
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。
|
||||||
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;批次传 `batch_id` 获取汇总和每项任务报告的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。
|
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;`formats=["screenshots"]` 获取排序截图,`["video"]` 获取保存的录屏分段,`["all"]` 获取 Word、Excel 和所有可用资料的 ZIP。批次传 `batch_id` 获取汇总和各项任务报告的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。
|
||||||
- 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
|
- 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
|
||||||
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
|
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
|
||||||
- `mobile_inspect_trace` 是只读步骤证据查看;其中历史上下文的 replay 术语不表示重新执行设备动作。
|
- `mobile_inspect_trace` 是只读步骤证据查看;其中历史上下文的 replay 术语不表示重新执行设备动作。
|
||||||
@@ -30,8 +30,8 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
|||||||
- 模型名称支持预设 `gpt-5.6-luna`、`mimo-v2.6-flash` 和自定义名称,取决于用户配置的 Responses 网关。思考强度最高支持 `max`,默认 `high`、单次请求 300 秒、失败后最多重试 5 次、固定间隔 10 秒;默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
|
- 模型名称支持预设 `gpt-5.6-luna`、`mimo-v2.6-flash` 和自定义名称,取决于用户配置的 Responses 网关。思考强度最高支持 `max`,默认 `high`、单次请求 300 秒、失败后最多重试 5 次、固定间隔 10 秒;默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
|
||||||
- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。
|
- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。
|
||||||
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
|
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
|
||||||
- 当前视频开关仅提供保存的全局设置,没有单任务覆盖或独立视频文件分析接口。用户只要求临时分析且默认关闭时,明确说明这个限制;不能用“先开全局、执行后再关”影响同期任务。只有用户改为要求保存启用后,才修改配置并提交新任务。
|
- 录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。
|
||||||
- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。
|
- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。
|
||||||
- 批次暂停只停止后续派发;要停止当前任务使用 `mobile_manage_task(action="stop", trace_id=...)`。停止可能保留已经产生的业务效果,不声称自动回滚。
|
- 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
|
||||||
|
|
||||||
需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。
|
需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 直接提交用例
|
## 直接提交用例
|
||||||
|
|
||||||
用户要求执行明确的测试集合时,将每个独立目标转成一个 case。`parameters` 是简单值字典,`tags` 是字符串,不能填列表;目标中的 `{{参数名}}` 会替换成参数值。前置条件用自然语言描述真实起点。
|
用户要求执行明确的测试集合时,将每个独立提示词转成一个 case。`goal` 是唯一发送给 ARTEMIS 的任务正文;`case_id`、`category`、`subcategory`、`name`、`preconditions` 只作元数据。`task_config` 可覆盖该项执行配置,不影响其他项。
|
||||||
|
|
||||||
先生成唯一 `request_id`(例如 UUID),保存在当前任务记录中,然后调用:
|
先生成唯一 `request_id`(例如 UUID),保存在当前任务记录中,然后调用:
|
||||||
|
|
||||||
@@ -19,13 +19,21 @@
|
|||||||
"timeout_seconds": 1800,
|
"timeout_seconds": 1800,
|
||||||
"cases": [
|
"cases": [
|
||||||
{
|
{
|
||||||
|
"case_id": "DEMO-001",
|
||||||
|
"category": "系统设置",
|
||||||
|
"subcategory": "首页",
|
||||||
"name": "设置首页可达",
|
"name": "设置首页可达",
|
||||||
"goal": "打开系统设置,确认设置首页可见,不修改任何设置。",
|
|
||||||
"expected": "能看到设置首页及至少一个设置分类,记录实际文字与截图证据。",
|
|
||||||
"app": "com.android.settings",
|
|
||||||
"preconditions": "设备已解锁。",
|
"preconditions": "设备已解锁。",
|
||||||
"parameters": {},
|
"goal": "打开系统设置,确认设置首页可见,不修改任何设置;记录实际文字与截图证据。",
|
||||||
"tags": "冒烟,只读"
|
"task_config": {
|
||||||
|
"profile": "pro",
|
||||||
|
"model": "mimo-v2.6-flash",
|
||||||
|
"reasoning_effort": "high",
|
||||||
|
"locked_app_package": "com.android.settings",
|
||||||
|
"record_video": true,
|
||||||
|
"analyze_video": false,
|
||||||
|
"request_timeout_seconds": 300
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -38,19 +46,19 @@
|
|||||||
|
|
||||||
## Excel 导入
|
## Excel 导入
|
||||||
|
|
||||||
`mobile_task_file(action="template")` 返回空白模板的本地路径。标准列为任务名称、任务目标、预期结果、目标应用、前置条件、输入参数(JSON)、标签。每行独立,不支持公式、宏、外部链接。
|
`mobile_task_file(action="template")` 返回空白模板的本地路径。“任务”页七列依次为:用例编号、检测项目、测试子项、测试用例、前置约束提取、Artemis提示词、任务配置(JSON)。仅提示词必填;说明页有一条示例,不会导入执行。JSON 支持 `profile`、`model`、`reasoning_effort`、`device_serial`、`verification_level`、`explorer_mode`、`locked_app_package`、`record_video`、`analyze_video`、`request_timeout_seconds`、`max_retries`、`retry_interval_seconds`、`timeout_seconds`。未知字段或密钥被拒绝。每行独立,不支持公式、宏、外部链接。
|
||||||
|
|
||||||
`preview` 返回 `valid/tasks/errors`,不会执行。所有错误处理完后,将 `tasks` 中每项的预览字段 `row` 去掉,作为 `batch.cases` 提交。不要跳过错误行却声称全部导入成功。
|
`preview` 返回 `valid/tasks/errors`,不会执行。所有错误处理完后,将 `tasks` 中每项的预览字段 `row` 去掉,作为 `batch.cases` 提交。不要跳过错误行却声称全部导入成功。
|
||||||
|
|
||||||
## 状态与异常
|
## 状态与异常
|
||||||
|
|
||||||
- 批次 `draft` 等待开始;`running` 调度中;`paused` 等待处理;`completed` 表示所有项进入终态,不代表全部通过。
|
- 批次 `draft` 等待开始;`running` 调度中;`paused` 等待处理;`completed` 表示所有项进入终态,不代表全部通过。
|
||||||
- 单项 `pending/queued/running/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`finalizing` 在生成报告,不能提前宣称报告已完成。
|
- 单项 `pending/queued/running/task_paused/resuming/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`finalizing` 在生成报告,不能提前宣称报告已完成。
|
||||||
- `pause` 保留当前执行。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。
|
- 批次 `pause` 保留当前执行,只暂停后续派发;单项 `mobile_manage_task(pause)` 在步骤后释放设备,指导后排队恢复,不阻塞后续项。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。
|
||||||
- `resume` 只继续未开始项;`retry_failed` 复制失败、取消和中断项为新草稿,不修改原结果。必须来自用户的明确重跑请求。该动作不提供创建去重,响应不确定时先 `list` 查找新批次,不自动重复调用。
|
- `resume` 只继续未开始项;`retry_failed` 复制失败、取消和中断项为新草稿,不修改原结果。必须来自用户的明确重跑请求。该动作不提供创建去重,响应不确定时先 `list` 查找新批次,不自动重复调用。
|
||||||
|
|
||||||
## 报告
|
## 报告
|
||||||
|
|
||||||
`mobile_export_report(batch_id=...)` 下载批次 ZIP,包含汇总和各项报告,格式由创建批次时的设置决定。单任务传 `session_id`(单任务 MCP 的 `trace_id`)以及可选 `formats=["docx"]`。默认格式通过 `mobile_config(section="reports")` 查看。
|
`mobile_export_report(batch_id=...)` 下载批次 ZIP,包含汇总和各项报告,格式由创建批次时的设置决定。单任务传 `session_id`(单任务 MCP 的 `trace_id`)以及可选 `formats=["docx"]`、`["screenshots"]`、`["video"]` 或 `["all"]`。默认格式通过 `mobile_config(section="reports")` 查看。录屏与 AI 视频分析独立,只有保存了录屏才可下载片段。
|
||||||
|
|
||||||
结果文件在 MCP 主机的 `artifacts/mcp_exports/`。不要把 Office 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
|
结果文件在 MCP 主机的 `artifacts/mcp_exports/`。不要把 Office 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
|
||||||
@@ -64,9 +64,9 @@ def workbook_bytes(rows):
|
|||||||
def test_template_preview_rejects_formula_and_bad_json():
|
def test_template_preview_rejects_formula_and_bad_json():
|
||||||
data = workbook_bytes(
|
data = workbook_bytes(
|
||||||
[
|
[
|
||||||
["正常", "打开设置", "", "", "", '{"name":"测试"}', "冒烟"],
|
["正常", "设置", "冒烟", "打开设置", "", "观察设置", '{"profile":"flash"}'],
|
||||||
["公式", "=SUM(1,2)"],
|
["公式", "", "", "", "", "=SUM(1,2)"],
|
||||||
["参数错误", "测试", "", "", "", "{"],
|
["参数错误", "", "", "", "", "测试", "{"],
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
result = task_import.parse_workbook(data)
|
result = task_import.parse_workbook(data)
|
||||||
@@ -75,6 +75,30 @@ def test_template_preview_rejects_formula_and_bad_json():
|
|||||||
assert not result["valid"]
|
assert not result["valid"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_only_and_per_row_config_validation():
|
||||||
|
goal = "先查看屏幕,再按实际画面判断。"
|
||||||
|
data = workbook_bytes(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"CASE-7",
|
||||||
|
"检查项目",
|
||||||
|
"子项",
|
||||||
|
"用例名称",
|
||||||
|
"仅作索引的约束",
|
||||||
|
goal,
|
||||||
|
'{"profile":"pro","model":"mimo-v2.6-flash","record_video":true}',
|
||||||
|
],
|
||||||
|
["CASE-8", "", "", "", "", "观察屏幕", '{"OPENAI_API_KEY":"never"}'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
preview = task_import.parse_workbook(data)
|
||||||
|
assert [e["row"] for e in preview["errors"]] == [3]
|
||||||
|
case = preview["tasks"][0]
|
||||||
|
assert task_import.case_goal(case) == goal
|
||||||
|
assert case["preconditions"] == "仅作索引的约束"
|
||||||
|
assert case["task_config"]["record_video"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_archive_and_empty_template():
|
def test_invalid_archive_and_empty_template():
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
task_import.parse_workbook(b"not a zip")
|
task_import.parse_workbook(b"not a zip")
|
||||||
@@ -84,7 +108,9 @@ def test_invalid_archive_and_empty_template():
|
|||||||
def test_preview_does_not_enqueue(client, isolated):
|
def test_preview_does_not_enqueue(client, isolated):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/automation/import/preview",
|
"/api/automation/import/preview",
|
||||||
files={"file": ("tasks.xlsx", workbook_bytes([["案例", "观察页面"]]))},
|
files={
|
||||||
|
"file": ("tasks.xlsx", workbook_bytes([["案例", "", "", "观察页面", "", "观察页面"]]))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["valid"]
|
assert response.json()["valid"]
|
||||||
@@ -137,6 +163,30 @@ async def test_batch_admission_idempotent_and_bound_device(isolated, monkeypatch
|
|||||||
assert isolated.jobs(batch["id"])[1]["status"] == "pending"
|
assert isolated.jobs(batch["id"])[1]["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_paused_case_yields_batch_slot_to_next_case(isolated, monkeypatch):
|
||||||
|
from apps.admin_console import server
|
||||||
|
from artemis.runtime.device_pool import DeviceStatus
|
||||||
|
|
||||||
|
batch = service.create_batch(payload())
|
||||||
|
service.batch_action(batch["id"], "resume")
|
||||||
|
first = isolated.jobs(batch["id"])[0]
|
||||||
|
isolated.save_job({**first, "status": "task_paused", "started_at": time.time()})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service, "state", type("State", (), {"is_paused": False, "active_runs": {}})()
|
||||||
|
)
|
||||||
|
enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]})
|
||||||
|
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")]),
|
||||||
|
)
|
||||||
|
await service.tick_batches()
|
||||||
|
assert enqueue.await_count == 1
|
||||||
|
assert enqueue.call_args.args[0] == ["观察首页"]
|
||||||
|
assert [job["status"] for job in isolated.jobs(batch["id"])] == ["task_paused", "queued"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_device_outage_pauses_without_consuming_pending_case(isolated, monkeypatch):
|
async def test_device_outage_pauses_without_consuming_pending_case(isolated, monkeypatch):
|
||||||
batch = service.create_batch(payload())
|
batch = service.create_batch(payload())
|
||||||
@@ -193,6 +243,38 @@ def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
|
|||||||
assert "未验证".encode() in doc.read("word/document.xml")
|
assert "未验证".encode() in doc.read("word/document.xml")
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_material_zip_contains_ordered_evidence_and_reports(
|
||||||
|
client, isolated, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||||
|
evidence = tmp_path / "evidence"
|
||||||
|
evidence.mkdir()
|
||||||
|
first = evidence / "first.png"
|
||||||
|
second = evidence / "second.png"
|
||||||
|
video = evidence / "recording.mp4"
|
||||||
|
for path in (first, second, video):
|
||||||
|
path.write_bytes(path.name.encode())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reports,
|
||||||
|
"session_screenshots",
|
||||||
|
lambda report: [(first, "0001_步骤0001_操作前.png"), (second, "0002_步骤0002_操作后.png")],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(reports, "session_videos", lambda report: [(video, "0001_recording.mp4")])
|
||||||
|
|
||||||
|
response = client.get(f"/api/automation/reports/{job['session_id']}/all")
|
||||||
|
assert response.status_code == 200
|
||||||
|
with zipfile.ZipFile(BytesIO(response.content)) as archive:
|
||||||
|
files = archive.namelist()
|
||||||
|
assert any(name.endswith(".docx") for name in files)
|
||||||
|
assert any(name.endswith(".xlsx") for name in files)
|
||||||
|
assert files.index("步骤截图/0001_步骤0001_操作前.png") < files.index(
|
||||||
|
"步骤截图/0002_步骤0002_操作后.png"
|
||||||
|
)
|
||||||
|
assert archive.read("任务录屏/0001_recording.mp4") == b"recording.mp4"
|
||||||
|
manifest = json.loads(archive.read("文件清单.json"))
|
||||||
|
assert manifest["session_id"] == job["session_id"]
|
||||||
|
|
||||||
|
|
||||||
def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated):
|
def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated):
|
||||||
batch = service.create_batch(payload())
|
batch = service.create_batch(payload())
|
||||||
first, second = isolated.jobs(batch["id"])
|
first, second = isolated.jobs(batch["id"])
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ async def test_shutdown_marks_only_ui_owned_running_sessions():
|
|||||||
):
|
):
|
||||||
await on_shutdown()
|
await on_shutdown()
|
||||||
|
|
||||||
update_status.assert_called_once_with("ui-running", "cancelled")
|
update_status.assert_called_once_with("ui-running", "interrupted")
|
||||||
assert state.queue_items == []
|
assert state.queue_items == []
|
||||||
state.active_session_id = None
|
state.active_session_id = None
|
||||||
state.is_shutting_down = False
|
state.is_shutting_down = False
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def test_startup_cleanup_keeps_live_cross_process_session(tmp_path):
|
|||||||
assert repository.cleanup_orphans_on_startup() == 1
|
assert repository.cleanup_orphans_on_startup() == 1
|
||||||
|
|
||||||
assert repository.get_session_status("live") == "running"
|
assert repository.get_session_status("live") == "running"
|
||||||
assert repository.get_session_status("dead") == "failed"
|
assert repository.get_session_status("dead") == "interrupted"
|
||||||
|
|
||||||
|
|
||||||
def test_session_list_harvest_rechecks_worker_liveness(tmp_path):
|
def test_session_list_harvest_rechecks_worker_liveness(tmp_path):
|
||||||
|
|||||||
@@ -987,6 +987,7 @@ async def test_manual_stop_of_one_run_does_not_pollute_concurrent_run():
|
|||||||
on different devices: device A's task is stopped manually, device B's task
|
on different devices: device A's task is stopped manually, device B's task
|
||||||
then finishes normally and must still be reported as completed.
|
then finishes normally and must still be reported as completed.
|
||||||
"""
|
"""
|
||||||
|
state.is_shutting_down = False
|
||||||
ended_payloads: dict[str, dict] = {}
|
ended_payloads: dict[str, dict] = {}
|
||||||
|
|
||||||
def capture(event_type, data):
|
def capture(event_type, data):
|
||||||
|
|||||||
@@ -279,6 +279,45 @@ def test_mobile_manage_task_unknown_trace(temp_trace_env):
|
|||||||
assert "not found" in res["message"]
|
assert "not found" in res["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_task_can_be_paused_and_guided_from_mcp(temp_trace_env, monkeypatch):
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import artemis.runtime.task_control as control
|
||||||
|
|
||||||
|
monkeypatch.setattr(control, "TRACES_PATH", Path(temp_trace_env))
|
||||||
|
monkeypatch.setattr("mcp_server.tools.task_manager._pid_alive", lambda pid: True)
|
||||||
|
trace_id = str(uuid.uuid4())
|
||||||
|
db = Path(temp_trace_env) / "data_engine.db"
|
||||||
|
with sqlite3.connect(db) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE sessions (session_id TEXT, status TEXT, pid INTEGER, "
|
||||||
|
"start_time REAL, end_time REAL, device_info TEXT)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions VALUES (?, 'running', 17, 1, NULL, ?)",
|
||||||
|
(trace_id, '{"device_id":"test-device"}'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
mobile_manage_task(action="status", trace_id=trace_id)["device_serial"] == "test-device"
|
||||||
|
)
|
||||||
|
assert mobile_manage_task(action="pause", trace_id=trace_id)["status"] == "pausing"
|
||||||
|
assert (Path(temp_trace_env) / trace_id / "pause.request").exists()
|
||||||
|
control._write_atomic(
|
||||||
|
control.control_dir(trace_id) / "task_control_state.json", {"status": "task_paused"}
|
||||||
|
)
|
||||||
|
guided = mobile_manage_task(
|
||||||
|
action="inject_instruction", trace_id=trace_id, instruction="先确认当前画面"
|
||||||
|
)
|
||||||
|
assert guided["status"] == "task_paused"
|
||||||
|
assert (Path(temp_trace_env) / trace_id / "resume.request").exists()
|
||||||
|
assert (
|
||||||
|
control.consume_instructions(control.control_dir(trace_id))["instruction"]
|
||||||
|
== "先确认当前画面"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_manage_task_status_and_stop(temp_trace_env):
|
def test_mobile_manage_task_status_and_stop(temp_trace_env):
|
||||||
trace_id = str(uuid.uuid4())
|
trace_id = str(uuid.uuid4())
|
||||||
trace_store.init_trace(trace_id, "Test task", "Flash", "conv-1", device_serial="device-xyz")
|
trace_store.init_trace(trace_id, "Test task", "Flash", "conv-1", device_serial="device-xyz")
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Paused tasks yield their device and resume with a fresh lease."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import artemis.runtime.task_control as task_control
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pause_releases_device_and_resume_waits_for_new_acquisition(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||||
|
events = []
|
||||||
|
|
||||||
|
class Lock:
|
||||||
|
def __init__(self, device_id, **kwargs):
|
||||||
|
self.device_id = device_id
|
||||||
|
self.description = kwargs.get("description", "test")
|
||||||
|
self.max_concurrency = kwargs.get("max_concurrency")
|
||||||
|
self.concurrency_mode = kwargs.get("concurrency_mode", "per_device")
|
||||||
|
self.ingress = kwargs.get("ingress", "web")
|
||||||
|
self.lock_scope = kwargs.get("lock_scope", "test")
|
||||||
|
|
||||||
|
def release(self):
|
||||||
|
events.append("released")
|
||||||
|
|
||||||
|
def acquire(self, cancel_event=None):
|
||||||
|
events.append("acquired")
|
||||||
|
|
||||||
|
monkeypatch.setattr(task_control, "DeviceExecutionLock", Lock)
|
||||||
|
original = Lock("test-device")
|
||||||
|
control = task_control.TaskControl("task-123", original)
|
||||||
|
task_control.request_pause("task-123")
|
||||||
|
waiter = asyncio.create_task(
|
||||||
|
control.checkpoint(
|
||||||
|
on_pause=lambda: _event("capture_stopped", events),
|
||||||
|
on_resume=lambda: _event("capture_started", events),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for _ in range(30):
|
||||||
|
if task_control.read_state("task-123").get("status") == "task_paused":
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
assert task_control.read_state("task-123")["status"] == "task_paused"
|
||||||
|
assert events == ["capture_stopped", "released"]
|
||||||
|
task_control.request_resume("task-123")
|
||||||
|
assert await asyncio.wait_for(waiter, 2) is True
|
||||||
|
assert events == ["capture_stopped", "released", "acquired", "capture_started"]
|
||||||
|
control.release()
|
||||||
|
assert task_control.read_state("task-123") == {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _event(name, events):
|
||||||
|
events.append(name)
|
||||||
@@ -58,6 +58,8 @@ if ($ArtemisDownloads.node_mirror -ne 'https://intranet.example/node') { throw '
|
|||||||
if ($ArtemisDownloads.python_index -ne 'https://intranet.example/simple') { throw 'Environment ignored' }
|
if ($ArtemisDownloads.python_index -ne 'https://intranet.example/simple') { throw 'Environment ignored' }
|
||||||
if ($env:npm_config_registry -ne 'https://registry.npmmirror.com') { throw 'npm mirror missing' }
|
if ($env:npm_config_registry -ne 'https://registry.npmmirror.com') { throw 'npm mirror missing' }
|
||||||
if ($env:UV_HTTP_TIMEOUT -ne '180') { throw 'Timeout missing' }
|
if ($env:UV_HTTP_TIMEOUT -ne '180') { throw 'Timeout missing' }
|
||||||
|
if ($ArtemisDownloads.scrcpy_url -match 'github[.]com') { throw 'China scrcpy still uses GitHub' }
|
||||||
|
if ($ArtemisDownloads.scrcpy_sha256 -notmatch '^[a-f0-9]{64}$') { throw 'scrcpy digest missing' }
|
||||||
Remove-Item Env:npm_config_registry
|
Remove-Item Env:npm_config_registry
|
||||||
Remove-Item Env:UV_PYTHON_INSTALL_MIRROR
|
Remove-Item Env:UV_PYTHON_INSTALL_MIRROR
|
||||||
Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT -Mode Official
|
Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT -Mode Official
|
||||||
@@ -69,6 +71,13 @@ if ($env:npm_config_registry) { throw 'Official mode selected a China registry'
|
|||||||
assert result.returncode == 0, result.stdout + result.stderr
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrcpy_archive_is_checked_before_extraction():
|
||||||
|
launcher = (ROOT / "scripts/start.ps1").read_text(encoding="utf-8-sig")
|
||||||
|
assert launcher.index("Get-FileHash -LiteralPath $zipPath") < launcher.index(
|
||||||
|
"Remove-Item $extractDir -Recurse"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("corrupt", [False, True])
|
@pytest.mark.parametrize("corrupt", [False, True])
|
||||||
def test_uv_mirror_bootstrap_verifies_wheel_without_python(tmp_path, corrupt):
|
def test_uv_mirror_bootstrap_verifies_wheel_without_python(tmp_path, corrupt):
|
||||||
with zipfile.ZipFile(tmp_path / "fixture.zip", "w") as wheel:
|
with zipfile.ZipFile(tmp_path / "fixture.zip", "w") as wheel:
|
||||||
|
|||||||
Reference in new issue
Block a user