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
@@ -136,7 +136,7 @@ class ServerState:
|
||||
return {
|
||||
str(run.get("lock_key") or run["device_id"])
|
||||
for run in self.active_runs.values()
|
||||
if run.get("device_id")
|
||||
if run.get("device_id") and not run.get("parked")
|
||||
}
|
||||
|
||||
@property
|
||||
|
||||
@@ -422,7 +422,7 @@ class SessionRepository:
|
||||
return count
|
||||
|
||||
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
|
||||
forced server stop: live workers, including workers owned by another
|
||||
@@ -442,14 +442,14 @@ class SessionRepository:
|
||||
cursor.execute(
|
||||
"UPDATE sessions SET status = ?, end_time = ? "
|
||||
"WHERE session_id = ? AND status = ?",
|
||||
("failed", now, row["session_id"], "running"),
|
||||
("interrupted", now, row["session_id"], "running"),
|
||||
)
|
||||
count += cursor.rowcount
|
||||
try:
|
||||
if trace_store.read_status(str(row["session_id"])):
|
||||
trace_store.update_trace_status(
|
||||
str(row["session_id"]),
|
||||
"failed",
|
||||
"interrupted",
|
||||
error="Process terminated prior to server startup.",
|
||||
)
|
||||
except OSError as exc:
|
||||
|
||||
@@ -146,8 +146,13 @@ def report(session_id: str):
|
||||
|
||||
|
||||
@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:
|
||||
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])
|
||||
return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}")
|
||||
except ValueError as exc:
|
||||
@@ -167,21 +172,29 @@ def history(
|
||||
query = """WITH history AS (
|
||||
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(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
|
||||
UNION ALL
|
||||
SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
|
||||
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'),
|
||||
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)
|
||||
) """
|
||||
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
|
||||
args = []
|
||||
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("_", "\\_") + "%"
|
||||
args.extend([escaped] * 4)
|
||||
args.extend([escaped] * 8)
|
||||
for condition, value in [
|
||||
("status = ?", status or None),
|
||||
("start_time >= ?", start),
|
||||
|
||||
@@ -18,8 +18,16 @@ import json
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from artemis.core.diagnostics import readiness_engine
|
||||
from artemis.runtime import DeviceExecutionLock, device_pool
|
||||
from artemis.runtime.task_control import (
|
||||
add_instruction,
|
||||
control_dir,
|
||||
read_state,
|
||||
request_pause,
|
||||
request_resume,
|
||||
)
|
||||
|
||||
try:
|
||||
from admin_console.core.state import state
|
||||
@@ -42,6 +50,48 @@ except ImportError:
|
||||
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")
|
||||
async def get_task_presets(
|
||||
category: str = "recommended",
|
||||
@@ -285,6 +335,17 @@ async def get_status():
|
||||
}
|
||||
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 = (
|
||||
(global_owner.session_id if global_owner else None)
|
||||
|
||||
@@ -152,7 +152,7 @@ async def on_startup():
|
||||
|
||||
cleaned = session_repo.cleanup_orphans_on_startup()
|
||||
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
|
||||
# recordings; publish whatever raw files they left behind.
|
||||
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 = {
|
||||
str(item["session_id"])
|
||||
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
|
||||
@@ -212,7 +214,7 @@ async def on_shutdown():
|
||||
state.current_process = None
|
||||
state.queue_items.clear()
|
||||
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
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ def register_submission(item: dict, case: dict | None = None, formats=None, **ex
|
||||
def recover_batches():
|
||||
"""Recovery never silently repeats a potentially side-effecting interrupted task."""
|
||||
for job in store.jobs():
|
||||
if job["status"] in {"running", "queued", "finalizing"}:
|
||||
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
|
||||
job.update(
|
||||
status=job.get("terminal_status", "interrupted"),
|
||||
error=job.get("error")
|
||||
@@ -64,7 +64,7 @@ def recover_batches():
|
||||
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
|
||||
)
|
||||
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(
|
||||
status="interrupted", error="服务中断,需手动重跑", ended_at=time.time()
|
||||
)
|
||||
@@ -89,11 +89,15 @@ async def on_terminal(item: dict, status: str):
|
||||
try:
|
||||
job["status"] = status
|
||||
report = await asyncio.to_thread(session_report, sid)
|
||||
if report["verdict"] == "验证失败" or report["outcome"].get("task_status") in {
|
||||
"blocked",
|
||||
"partial",
|
||||
"failed",
|
||||
}:
|
||||
if status != "interrupted" and (
|
||||
report["verdict"] == "验证失败"
|
||||
or report["outcome"].get("task_status")
|
||||
in {
|
||||
"blocked",
|
||||
"partial",
|
||||
"failed",
|
||||
}
|
||||
):
|
||||
job["status"] = "failed"
|
||||
job["error"] = "任务执行结束,但业务检查未通过"
|
||||
# Persist terminal classification before export, but the coordinator waits for finalization.
|
||||
@@ -121,11 +125,18 @@ async def tick_batches():
|
||||
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
|
||||
store.put("batch", batch["id"], batch)
|
||||
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 (
|
||||
job["status"] != "finalizing"
|
||||
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)
|
||||
store.save_job(job)
|
||||
@@ -142,6 +153,11 @@ async def tick_batches():
|
||||
job.update(status="skipped", error="本批次设置为失败即停止", ended_at=time.time())
|
||||
store.save_job(job)
|
||||
pending = []
|
||||
if not pending and any(
|
||||
j["status"] in {"task_paused", "resuming", "running", "queued", "finalizing"}
|
||||
for j in jobs
|
||||
):
|
||||
continue
|
||||
if not pending:
|
||||
batch.update(status="completed", ended_at=time.time())
|
||||
try:
|
||||
@@ -156,7 +172,9 @@ async def tick_batches():
|
||||
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
|
||||
store.put("batch", batch["id"], batch)
|
||||
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()
|
||||
rejection = (
|
||||
None
|
||||
@@ -169,26 +187,28 @@ async def tick_batches():
|
||||
continue
|
||||
if (store.get("batch", batch["id"]) or {}).get("status") != "running":
|
||||
continue
|
||||
job = pending[0]
|
||||
job.update(status="queued", started_at=time.time())
|
||||
store.save_job(job)
|
||||
try:
|
||||
response = await task_queue_service.enqueue_tasks(
|
||||
[case_goal(job["case"])],
|
||||
profile=batch.get("profile", "flash"),
|
||||
expected_output="用简体中文报告实际结果与证据。明确区分执行完成、验证通过、验证失败和前置条件未满足。"
|
||||
+ job["case"].get("expected", ""),
|
||||
profile=options.get("profile") or batch.get("profile", "flash"),
|
||||
expected_output="用简体中文报告实际结果与证据。明确区分执行完成、验证通过、验证失败和前置条件未满足。",
|
||||
enable_outputter=True,
|
||||
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"],
|
||||
ingress="batch",
|
||||
)
|
||||
if response.get("status") == "rejected" or not response.get("tasks"):
|
||||
raise ValueError(response.get("error") or "队列拒绝接收任务")
|
||||
bound = response["tasks"][0].get("device_serial")
|
||||
if bound and not serial:
|
||||
batch["device_serial"] = bound
|
||||
store.put("batch", batch["id"], batch)
|
||||
if bound:
|
||||
job["device_serial"] = bound
|
||||
store.save_job(job)
|
||||
except Exception as exc:
|
||||
job.update(status="pending", error=str(exc)[:500])
|
||||
store.save_job(job)
|
||||
|
||||
@@ -129,6 +129,7 @@ def persist_environment(values: dict[str, str]) -> None:
|
||||
|
||||
class CapabilitySettings(BaseModel):
|
||||
video_enabled: bool = False
|
||||
recording_enabled: bool = False
|
||||
outputter_enabled: bool = True
|
||||
step_summarizer_enabled: bool = True
|
||||
|
||||
@@ -136,6 +137,7 @@ class CapabilitySettings(BaseModel):
|
||||
def get_capability_settings() -> CapabilitySettings:
|
||||
return CapabilitySettings(
|
||||
video_enabled=settings.ARTEMIS_VIDEO_ENABLED,
|
||||
recording_enabled=settings.ARTEMIS_RECORDING_ENABLED,
|
||||
outputter_enabled=settings.ARTEMIS_OUTPUTTER_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:
|
||||
values = {
|
||||
"ARTEMIS_VIDEO_ENABLED": update.video_enabled,
|
||||
"ARTEMIS_RECORDING_ENABLED": update.recording_enabled,
|
||||
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
|
||||
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ STATUS = {
|
||||
"pending": "待执行",
|
||||
"queued": "已排队",
|
||||
"running": "执行中",
|
||||
"task_paused": "等待指导",
|
||||
"resuming": "等待设备恢复",
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +109,11 @@ def session_report(session_id: str, db_path=None) -> dict:
|
||||
"error": meta.get("error", ""),
|
||||
"device": meta.get("device_serial") or text(session.get("device_info") or ""),
|
||||
"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]:
|
||||
return [
|
||||
("任务编号", 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["expected"] or "未指定"),
|
||||
("执行状态", 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
|
||||
|
||||
|
||||
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:
|
||||
with EXPORT_LOCK:
|
||||
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
|
||||
import json
|
||||
@@ -6,33 +6,53 @@ import os
|
||||
import zipfile
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
HEADERS = {
|
||||
"任务名称": "name",
|
||||
"任务目标": "goal",
|
||||
"预期结果": "expected",
|
||||
"目标应用": "app",
|
||||
"前置条件": "preconditions",
|
||||
"输入参数(JSON)": "parameters",
|
||||
"标签": "tags",
|
||||
}
|
||||
HEADERS = (
|
||||
"用例编号",
|
||||
"检测项目",
|
||||
"测试子项",
|
||||
"测试用例",
|
||||
"前置约束提取",
|
||||
"Artemis提示词",
|
||||
"任务配置(JSON)",
|
||||
)
|
||||
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
|
||||
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):
|
||||
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)
|
||||
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)
|
||||
parameters: dict[str, str | int | float | bool] = Field(default_factory=dict)
|
||||
tags: str = Field(default="", max_length=500)
|
||||
goal: str = Field(min_length=1, max_length=10000)
|
||||
task_config: TaskOptions = Field(default_factory=TaskOptions)
|
||||
|
||||
|
||||
def template() -> bytes:
|
||||
@@ -40,27 +60,54 @@ def template() -> bytes:
|
||||
ws = wb.active
|
||||
assert ws is not None
|
||||
ws.title = "任务"
|
||||
ws.append(list(HEADERS))
|
||||
ws.append(HEADERS)
|
||||
ws.freeze_panes = "A2"
|
||||
ws.auto_filter.ref = "A1:G1"
|
||||
for cell in ws[1]:
|
||||
cell.font = Font(bold=True, color="FFFFFF")
|
||||
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("填写说明")
|
||||
for line in [
|
||||
"模板版本:1",
|
||||
"每行一个独立任务;任务名称、任务目标必填。",
|
||||
"预期结果决定要验证的业务条件。未填写时只报告任务完成情况。",
|
||||
"前置条件描述实际用例起点;不会统一重启或清空应用数据。",
|
||||
'输入参数使用 JSON 对象,例如 {"关键词":"天气"};目标可引用 {{关键词}}。',
|
||||
"目标应用可填名称或包名;空白时由任务决定。",
|
||||
"不要填写 Excel 公式、宏或外部链接。导入仅预览,点击开始后执行。",
|
||||
]:
|
||||
help_sheet.append([line])
|
||||
help_sheet.column_dimensions["A"].width = 105
|
||||
help_sheet.append(
|
||||
["模板版本:2。任务表每行一项,Artemis提示词必填;只把这一列作为 AI 任务正文。"]
|
||||
)
|
||||
help_sheet.append(["前置约束提取及其他描述列仅作查看、搜索和报告元数据,不追加到提示词。"])
|
||||
help_sheet.append(["任务配置(JSON)可留空,空字段继承批次或全局配置;禁止密钥与未知字段。"])
|
||||
help_sheet.append(["示例仅供参考,不会导入执行。请在“任务”页填写自己的用例。"])
|
||||
help_sheet.append(HEADERS)
|
||||
help_sheet.append(
|
||||
[
|
||||
"示例-001",
|
||||
"示例模块",
|
||||
"示例检查",
|
||||
"检查示例页面",
|
||||
"设备已经打开目标应用",
|
||||
"观察当前页面并报告实际结果,不要假设检查已通过。",
|
||||
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:
|
||||
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()
|
||||
wb.save(stream)
|
||||
return stream.getvalue()
|
||||
@@ -90,7 +137,7 @@ def parse_workbook(data: bytes) -> dict:
|
||||
rows = iter(wb["任务"].iter_rows())
|
||||
headers = [str(cell.value or "").strip() for cell in next(rows, [])]
|
||||
if headers != list(HEADERS):
|
||||
raise ValueError("列名或顺序与模板不一致,请重新下载模板")
|
||||
raise ValueError("列名或顺序与新版模板不一致,请重新下载模板")
|
||||
tasks, errors = [], []
|
||||
for number, row in enumerate(rows, 2):
|
||||
if number > MAX_ROWS + 1:
|
||||
@@ -100,28 +147,33 @@ def parse_workbook(data: bytes) -> dict:
|
||||
try:
|
||||
if any(cell.data_type == "f" for cell in row):
|
||||
raise ValueError("不允许公式,请粘贴为纯文本")
|
||||
values = [str(cell.value or "").strip() for cell in row[: len(HEADERS)]]
|
||||
item = dict(zip(HEADERS.values(), values))
|
||||
item["parameters"] = json.loads(item.get("parameters") or "{}")
|
||||
case = TaskCase.model_validate(item)
|
||||
values = [
|
||||
str(cell.value if cell.value is not None else "").strip() for cell in row[:7]
|
||||
]
|
||||
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()})
|
||||
except (ValueError, TypeError) as exc:
|
||||
errors.append({"row": number, "message": str(exc)[:500]})
|
||||
if not tasks and not errors:
|
||||
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:
|
||||
wb.close()
|
||||
|
||||
|
||||
def case_goal(case: dict) -> str:
|
||||
goal = case["goal"]
|
||||
for key, value in case.get("parameters", {}).items():
|
||||
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 '仅确认任务完成情况,不宣称测试通过'}"
|
||||
)
|
||||
"""Execution gets the prompt verbatim; metadata stays outside model input."""
|
||||
return case["goal"]
|
||||
@@ -302,7 +302,7 @@ class TaskQueueService:
|
||||
normalized = current_status.lower().strip() if isinstance(current_status, str) else None
|
||||
if normalized == "success":
|
||||
return "completed", True
|
||||
if normalized in {"completed", "failed", "cancelled"}:
|
||||
if normalized in {"completed", "failed", "cancelled", "interrupted"}:
|
||||
return normalized, False
|
||||
return ("completed" if returncode == 0 else "failed"), True
|
||||
|
||||
@@ -388,10 +388,9 @@ class TaskQueueService:
|
||||
@classmethod
|
||||
def _dispatch_pending_tasks(cls) -> None:
|
||||
"""Launch every pending task admissible under the current concurrency limit."""
|
||||
cls._sync_control_states()
|
||||
limit = cls._concurrency_limit()
|
||||
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
|
||||
# "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
|
||||
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:
|
||||
return
|
||||
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
|
||||
# only launches on an otherwise idle scheduler; the device lock
|
||||
# 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
|
||||
if device is not None and target.lock_key in busy_devices:
|
||||
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.
|
||||
continue
|
||||
|
||||
@@ -454,6 +459,42 @@ class TaskQueueService:
|
||||
if capacity <= 0:
|
||||
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
|
||||
def _begin_task_run(
|
||||
cls,
|
||||
@@ -531,6 +572,22 @@ class TaskQueueService:
|
||||
env["ARTEMIS_SESSION_ID"] = str(sess_id)
|
||||
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
|
||||
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)
|
||||
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
|
||||
queue_ticket = task_item.get("queue_ticket")
|
||||
@@ -664,6 +721,12 @@ class TaskQueueService:
|
||||
returncode,
|
||||
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 session_repo.update_session_status(sess_id, new_status, time.time()):
|
||||
print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'")
|
||||
@@ -1069,6 +1132,7 @@ class TaskQueueService:
|
||||
conversation_id: str | None = None,
|
||||
verification_level: str | None = None,
|
||||
explorer_mode: str | None = None,
|
||||
runtime_overrides: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Enqueues one or more goals and wakes up the background worker.
|
||||
|
||||
@@ -1123,6 +1187,8 @@ class TaskQueueService:
|
||||
verification_level=verification_level,
|
||||
explorer_mode=explorer_mode,
|
||||
)
|
||||
if runtime_overrides:
|
||||
task_item["runtime_overrides"] = runtime_overrides
|
||||
state.queue_items.append(task_item)
|
||||
enqueued_tasks.append(task_item)
|
||||
cls._broadcast_startup_progress(
|
||||
|
||||
Reference in new issue
Block a user