feat: improve batch task controls and release 1.2.0
This commit is contained in:
1 parent
0e13bb5c29
commit
84e183d28d
38 files changed
+632
-147
No files matched your search
@@ -128,7 +128,7 @@ def delete_batch(batch_id: str):
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
try:
|
||||
if not store.delete_batch(key):
|
||||
if not service.delete_batch(key):
|
||||
raise HTTPException(404, "批次不存在")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
@@ -166,7 +166,8 @@ async def download_report(
|
||||
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")
|
||||
label = report_export.archive_case_name(report_export.session_report(session_id))
|
||||
return FileResponse(path, filename=f"{label}-{fmt}.zip")
|
||||
paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt])
|
||||
return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}")
|
||||
except ValueError as exc:
|
||||
@@ -184,13 +185,15 @@ def history(
|
||||
):
|
||||
# SQL filtering/pagination searches the entire persisted history, not a UI cache.
|
||||
query = """WITH history AS (
|
||||
SELECT s.session_id, s.initial_goal, s.start_time, s.end_time,
|
||||
SELECT s.session_id, s.initial_goal, s.start_time,
|
||||
COALESCE(json_extract(j.payload,'$.ended_at'),s.end_time) end_time,
|
||||
COALESCE(j.status,s.status) status, COALESCE(json_extract(j.payload,'$.case.name'),s.initial_goal) name,
|
||||
COALESCE(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
|
||||
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions,
|
||||
COALESCE(json_extract(j.payload,'$.error'),'') error
|
||||
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
|
||||
UNION ALL
|
||||
SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
|
||||
@@ -200,7 +203,8 @@ def history(
|
||||
COALESCE(json_extract(j.payload,'$.case.case_id'),''),
|
||||
COALESCE(json_extract(j.payload,'$.case.category'),''),
|
||||
COALESCE(json_extract(j.payload,'$.case.subcategory'),''),
|
||||
COALESCE(json_extract(j.payload,'$.case.preconditions'),'')
|
||||
COALESCE(json_extract(j.payload,'$.case.preconditions'),''),
|
||||
COALESCE(json_extract(j.payload,'$.error'),'')
|
||||
FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id)
|
||||
) """
|
||||
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
|
||||
@@ -226,4 +230,8 @@ def history(
|
||||
+ " ORDER BY start_time DESC,session_id LIMIT ? OFFSET ?",
|
||||
[*args, size, (page - 1) * size],
|
||||
).fetchall()
|
||||
return {"items": [dict(r) for r in rows], "total": total, "page": page, "size": size}
|
||||
items = [dict(row) for row in rows]
|
||||
for item in items:
|
||||
if item["case_id"]:
|
||||
item["name"] = task_import.case_title(item)
|
||||
return {"items": items, "total": total, "page": page, "size": size}
|
||||
@@ -27,12 +27,16 @@ try:
|
||||
from admin_console.database.repositories.trace_repository import trace_repo
|
||||
from admin_console.services.media_service import media_service
|
||||
from admin_console.services.model_service import model_service
|
||||
from admin_console.services.automation_store import store
|
||||
from admin_console.services.task_import import case_title
|
||||
except ImportError:
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
||||
from apps.admin_console.services.media_service import media_service
|
||||
from apps.admin_console.services.model_service import model_service
|
||||
from apps.admin_console.services.automation_store import store
|
||||
from apps.admin_console.services.task_import import case_title
|
||||
|
||||
|
||||
router = APIRouter(tags=["sessions"])
|
||||
@@ -52,6 +56,7 @@ async def list_sessions():
|
||||
|
||||
def _list_sessions_sync():
|
||||
rows = session_repo.get_all_sessions()
|
||||
jobs_by_id = store.jobs_for_sessions([str(row.get("session_id")) for row in rows])
|
||||
video_rec_map = session_repo.get_video_recordings_map()
|
||||
latest_recordings = session_repo.get_latest_video_recordings_map()
|
||||
agent_names_by_session = session_repo.get_agent_trace_names_map()
|
||||
@@ -74,6 +79,15 @@ def _list_sessions_sync():
|
||||
|
||||
for row_dict in rows:
|
||||
s_id = str(row_dict.get("session_id"))
|
||||
job = jobs_by_id.get(s_id)
|
||||
if job:
|
||||
case = job.get("case") or {}
|
||||
row_dict["case_id"] = case.get("case_id") or ""
|
||||
row_dict["display_name"] = case_title(case) or row_dict.get("initial_goal")
|
||||
row_dict["error"] = job.get("error") or ""
|
||||
if job.get("status") in {"completed", "failed", "cancelled", "interrupted", "skipped"}:
|
||||
row_dict["status"] = job["status"]
|
||||
row_dict["end_time"] = job.get("ended_at") or row_dict.get("end_time")
|
||||
recording = latest_recordings.get(s_id)
|
||||
d_info_raw = row_dict.get("device_info")
|
||||
device_id = None
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from uuid import UUID
|
||||
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.services.automation_store import store, new_id
|
||||
from apps.admin_console.services.task_import import case_goal
|
||||
from apps.admin_console.services.task_import import case_goal, case_title
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"}
|
||||
@@ -82,9 +84,9 @@ async def on_terminal(item: dict, status: str):
|
||||
job = store.job(sid)
|
||||
if job is None:
|
||||
raise RuntimeError("任务元数据写入失败")
|
||||
job.update(status="finalizing", ended_at=time.time())
|
||||
if job.get("timed_out"):
|
||||
status = "failed"
|
||||
job.update(status="finalizing", terminal_status=status, ended_at=time.time())
|
||||
store.save_job(job)
|
||||
try:
|
||||
job["status"] = status
|
||||
@@ -99,7 +101,9 @@ async def on_terminal(item: dict, status: str):
|
||||
}
|
||||
):
|
||||
job["status"] = "failed"
|
||||
job["error"] = "任务执行结束,但业务检查未通过"
|
||||
job["error"] = job.get("error") or "任务执行结束,但业务检查未通过"
|
||||
elif job["status"] == "failed" and report.get("error") and not job.get("error"):
|
||||
job["error"] = report["error"]
|
||||
# Persist terminal classification before export, but the coordinator waits for finalization.
|
||||
store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]})
|
||||
await asyncio.to_thread(export_session, sid, job["formats"])
|
||||
@@ -107,6 +111,7 @@ async def on_terminal(item: dict, status: str):
|
||||
except Exception as exc:
|
||||
logger.exception("Report finalization failed for %s", sid)
|
||||
job["report_error"] = str(exc)[:500]
|
||||
job.pop("terminal_status", None)
|
||||
store.save_job(job)
|
||||
|
||||
|
||||
@@ -138,7 +143,9 @@ async def tick_batches():
|
||||
and not job.get("timed_out")
|
||||
and time.time() - job.get("started_at", time.time()) - excluded > limit
|
||||
):
|
||||
job.update(error="任务达到批次配置的执行时限", timed_out=True)
|
||||
job.update(
|
||||
error=f"任务执行超时({limit} 秒,不含暂停/排队时间)", timed_out=True
|
||||
)
|
||||
store.save_job(job)
|
||||
task_queue_service.stop_tasks(session_id=job["session_id"])
|
||||
batch.update(status="paused", reason="任务超时,已暂停剩余任务;请确认设备状态")
|
||||
@@ -202,6 +209,7 @@ async def tick_batches():
|
||||
runtime_overrides=options,
|
||||
session_id=job["session_id"],
|
||||
ingress="batch",
|
||||
display_name=case_title(job["case"]),
|
||||
)
|
||||
if response.get("status") == "rejected" or not response.get("tasks"):
|
||||
raise ValueError(response.get("error") or "队列拒绝接收任务")
|
||||
@@ -265,6 +273,35 @@ def create_batch(payload: dict) -> dict:
|
||||
return store.create_batch(batch, jobs, payload)
|
||||
|
||||
|
||||
def delete_batch(batch_id: str) -> bool:
|
||||
"""Delete an inactive batch, its sessions and its generated reports."""
|
||||
from artemis.data_engine.storage import StorageManager
|
||||
from apps.admin_console.services import report_export
|
||||
|
||||
jobs = store.begin_delete_batch(batch_id)
|
||||
if jobs is None:
|
||||
return False
|
||||
storage = StorageManager(store.path, report_export.TRACES_PATH)
|
||||
mcp_exports = report_export.EXPORT_ROOT.parent / "mcp_exports"
|
||||
with report_export.EXPORT_LOCK:
|
||||
for job in jobs:
|
||||
session_id = str(UUID(job["session_id"]))
|
||||
storage.delete_session(UUID(session_id))
|
||||
report_dir = report_export.EXPORT_ROOT / "sessions" / session_id
|
||||
if report_dir.is_dir():
|
||||
shutil.rmtree(report_dir)
|
||||
mcp_session_dir = mcp_exports / "sessions" / session_id
|
||||
if mcp_session_dir.is_dir():
|
||||
shutil.rmtree(mcp_session_dir)
|
||||
batch_dir = report_export.EXPORT_ROOT / "batches" / batch_id
|
||||
if batch_dir.is_dir():
|
||||
shutil.rmtree(batch_dir)
|
||||
mcp_batch_dir = mcp_exports / "batches" / batch_id
|
||||
if mcp_batch_dir.is_dir():
|
||||
shutil.rmtree(mcp_batch_dir)
|
||||
return store.delete_batch(batch_id)
|
||||
|
||||
|
||||
def batch_action(batch_id: str, action: str) -> dict:
|
||||
batch = store.get("batch", batch_id)
|
||||
if not batch:
|
||||
|
||||
@@ -54,6 +54,13 @@ class AutomationStore:
|
||||
|
||||
def put(self, kind: str, key: str, payload: dict) -> dict:
|
||||
with self.connection() as conn:
|
||||
if kind == "batch":
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?", (key,)
|
||||
).fetchone()
|
||||
if row is None or json.loads(row[0]).get("status") == "deleting":
|
||||
raise ValueError("批次正在删除或已删除,不能继续更新")
|
||||
conn.execute(
|
||||
"INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
|
||||
(kind, key, json.dumps(payload, ensure_ascii=False), time.time()),
|
||||
@@ -78,6 +85,14 @@ class AutomationStore:
|
||||
|
||||
def save_job(self, payload: dict) -> dict:
|
||||
with self.connection() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
if batch_id := payload.get("batch_id"):
|
||||
row = conn.execute(
|
||||
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
|
||||
(batch_id,),
|
||||
).fetchone()
|
||||
if row is None or json.loads(row[0]).get("status") == "deleting":
|
||||
raise ValueError("批次正在删除,不能继续派发任务")
|
||||
conn.execute(
|
||||
"INSERT INTO automation_jobs VALUES (?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET payload=excluded.payload,status=excluded.status,updated_at=excluded.updated_at",
|
||||
(
|
||||
@@ -106,6 +121,25 @@ class AutomationStore:
|
||||
)
|
||||
return [{**json.loads(row[0]), "status": row[1]} for row in rows]
|
||||
|
||||
def jobs_for_sessions(self, session_ids: list[str]) -> dict[str, dict]:
|
||||
"""Load only metadata belonging to the sessions shown in history."""
|
||||
result = {}
|
||||
with self.connection() as conn:
|
||||
for offset in range(0, len(session_ids), 400):
|
||||
chunk = session_ids[offset : offset + 400]
|
||||
markers = ",".join("?" for _ in chunk)
|
||||
rows = conn.execute(
|
||||
f"SELECT session_id,payload,status FROM automation_jobs WHERE session_id IN ({markers})",
|
||||
chunk,
|
||||
).fetchall()
|
||||
result.update(
|
||||
{
|
||||
row["session_id"]: {**json.loads(row["payload"]), "status": row["status"]}
|
||||
for row in rows
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def preferences(self) -> dict:
|
||||
return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]}
|
||||
|
||||
@@ -164,8 +198,38 @@ class AutomationStore:
|
||||
conn.commit()
|
||||
return batch
|
||||
|
||||
def begin_delete_batch(self, batch_id: str) -> list[dict] | None:
|
||||
"""Reserve an inactive batch for cleanup before any more jobs can start."""
|
||||
with self.connection() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
|
||||
(batch_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
batch = json.loads(row[0])
|
||||
active = conn.execute(
|
||||
"SELECT 1 FROM automation_jobs WHERE batch_id=? AND status IN "
|
||||
"('queued','running','task_paused','resuming','finalizing') LIMIT 1",
|
||||
(batch_id,),
|
||||
).fetchone()
|
||||
if batch["status"] == "running" or active:
|
||||
raise ValueError("批次仍有任务执行中或排队中,请等待完成后删除")
|
||||
batch["status"] = "deleting"
|
||||
conn.execute(
|
||||
"UPDATE automation_records SET payload=?,updated_at=? WHERE kind='batch' AND id=?",
|
||||
(json.dumps(batch, ensure_ascii=False), time.time(), batch_id),
|
||||
)
|
||||
jobs = conn.execute(
|
||||
"SELECT payload,status FROM automation_jobs WHERE batch_id=? ORDER BY position",
|
||||
(batch_id,),
|
||||
).fetchall()
|
||||
conn.commit()
|
||||
return [{**json.loads(job[0]), "status": job[1]} for job in jobs]
|
||||
|
||||
def delete_batch(self, batch_id: str) -> bool:
|
||||
"""Remove a batch record; retain executed job metadata and its reports."""
|
||||
"""Remove a batch record and all of its jobs after cleanup."""
|
||||
with self.connection() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
@@ -175,17 +239,9 @@ class AutomationStore:
|
||||
if row is None:
|
||||
return False
|
||||
batch = json.loads(row[0])
|
||||
active = conn.execute(
|
||||
"SELECT 1 FROM automation_jobs WHERE batch_id=? AND status IN "
|
||||
"('queued','running','task_paused','resuming','finalizing') LIMIT 1",
|
||||
(batch_id,),
|
||||
).fetchone()
|
||||
if batch["status"] == "running" or active:
|
||||
raise ValueError("批次仍有任务执行中或排队中,请等待完成后删除")
|
||||
conn.execute(
|
||||
"DELETE FROM automation_jobs WHERE batch_id=? AND status='pending'",
|
||||
(batch_id,),
|
||||
)
|
||||
if batch.get("status") != "deleting":
|
||||
raise ValueError("批次尚未准备删除")
|
||||
conn.execute("DELETE FROM automation_jobs WHERE batch_id=?", (batch_id,))
|
||||
conn.execute(
|
||||
"DELETE FROM automation_records WHERE kind='batch' AND id=?",
|
||||
(batch_id,),
|
||||
|
||||
@@ -22,6 +22,7 @@ from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
|
||||
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||
from apps.admin_console.database.repositories.step_repository import StepRepository
|
||||
from apps.admin_console.services.automation_store import store, identifier
|
||||
from apps.admin_console.services.task_import import case_title
|
||||
|
||||
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
||||
EXPORT_LOCK = threading.RLock()
|
||||
@@ -40,6 +41,14 @@ STATUS = {
|
||||
}
|
||||
|
||||
|
||||
def archive_case_name(report: dict) -> str:
|
||||
"""Keep case identifiers readable without allowing ZIP paths or Windows-invalid names."""
|
||||
fallback = str(report.get("id") or "task")[:8]
|
||||
candidate = str(report.get("case_id") or fallback).strip()
|
||||
candidate = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", candidate).strip(" .")
|
||||
return candidate[:80] or fallback
|
||||
|
||||
|
||||
def read_json(path: Path, fallback=None):
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
@@ -83,6 +92,9 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
|
||||
return redact(recorded)
|
||||
if status not in {"failed", "interrupted"}:
|
||||
return ""
|
||||
state = read_json(directory / "status.json", {})
|
||||
if isinstance(state, dict) and state.get("error"):
|
||||
return redact(state["error"])
|
||||
for name in ("stdout.log", "stderr.log"):
|
||||
path = directory / name
|
||||
try:
|
||||
@@ -96,6 +108,8 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
|
||||
match = re.search(r"Error running automation:\s*(.+)", line)
|
||||
if match:
|
||||
return redact(match.group(1))
|
||||
if re.search(r"(?i)(?:timeout|timed out|超时|deadline exceeded)", line):
|
||||
return redact("模型请求或任务执行超时:" + line.strip())
|
||||
return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
|
||||
|
||||
|
||||
@@ -142,7 +156,7 @@ def session_report(session_id: str, db_path=None) -> dict:
|
||||
)
|
||||
return {
|
||||
"id": session_id,
|
||||
"name": case.get("name") or session.get("initial_goal", session_id)[:120],
|
||||
"name": case_title(case) or session.get("initial_goal", session_id)[:120],
|
||||
"goal": case.get("goal") or session.get("initial_goal", ""),
|
||||
"expected": case.get("expected", ""),
|
||||
"status": status,
|
||||
@@ -638,15 +652,18 @@ def export_session_media(session_id: str, kind: str) -> Path:
|
||||
"videos": [name for _, name in videos],
|
||||
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
|
||||
}
|
||||
prefix = archive_case_name(report) + "/" if kind == "all" else ""
|
||||
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)
|
||||
archive.write(path, prefix + path.name)
|
||||
for source, name in images:
|
||||
archive.write(source, f"步骤截图/{name}")
|
||||
archive.write(source, f"{prefix}步骤截图/{name}")
|
||||
for source, name in videos:
|
||||
archive.write(source, f"任务录屏/{name}")
|
||||
archive.writestr("文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
archive.write(source, f"{prefix}任务录屏/{name}")
|
||||
archive.writestr(
|
||||
prefix + "文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2)
|
||||
)
|
||||
temporary.replace(target)
|
||||
return target
|
||||
|
||||
@@ -691,6 +708,8 @@ def export_batch(batch_id: str) -> Path:
|
||||
archive.write(path, path.name)
|
||||
for number, report in enumerate(records, 1):
|
||||
for path in export_session(report["id"], formats):
|
||||
archive.write(path, f"任务报告/{number:04d}_{report['id']}/{path.name}")
|
||||
archive.write(
|
||||
path, f"任务报告/{number:04d}_{archive_case_name(report)}/{path.name}"
|
||||
)
|
||||
temporary.replace(target)
|
||||
return target
|
||||
@@ -177,3 +177,10 @@ def parse_workbook(data: bytes) -> dict:
|
||||
def case_goal(case: dict) -> str:
|
||||
"""Execution gets the prompt verbatim; metadata stays outside model input."""
|
||||
return case["goal"]
|
||||
|
||||
|
||||
def case_title(case: dict) -> str:
|
||||
"""Human-facing title; never prepend metadata to the model prompt."""
|
||||
case_id = str(case.get("case_id") or "").strip()
|
||||
name = str(case.get("name") or "").strip()
|
||||
return " ".join(part for part in (case_id, name) if part)
|
||||
@@ -1133,6 +1133,7 @@ class TaskQueueService:
|
||||
verification_level: str | None = None,
|
||||
explorer_mode: str | None = None,
|
||||
runtime_overrides: dict[str, Any] | None = None,
|
||||
display_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Enqueues one or more goals and wakes up the background worker.
|
||||
|
||||
@@ -1189,6 +1190,8 @@ class TaskQueueService:
|
||||
)
|
||||
if runtime_overrides:
|
||||
task_item["runtime_overrides"] = runtime_overrides
|
||||
if display_name:
|
||||
task_item["display_name"] = display_name
|
||||
state.queue_items.append(task_item)
|
||||
enqueued_tasks.append(task_item)
|
||||
cls._broadcast_startup_progress(
|
||||
|
||||
Reference in new issue
Block a user