feat: improve batch task controls and release 1.2.0

This commit is contained in:
chenmingxuan committed 2026-09-25 02:02:48 +08:00
1 parent 0e13bb5c29
commit 84e183d28d
38 files changed
+632 -147

No files matched your search

+14 -6
View File
@@ -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}
+14
View File
@@ -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:
+68 -12
View File
@@ -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,),
+25 -6
View File
@@ -22,6 +22,7 @@ from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
from apps.admin_console.database.repositories.session_repository import SessionRepository
from apps.admin_console.database.repositories.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(
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "artemis-showcase-ui",
"version": "1.1.2",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "artemis-showcase-ui",
"version": "1.1.2",
"version": "1.2.0",
"dependencies": {
"@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "artemis-showcase-ui",
"version": "1.1.2",
"version": "1.2.0",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
@@ -76,6 +76,7 @@ limitations under the License.
class="dropdown-item-card"
[class.selected]="agentService.currentSessionId() === session.session_id"
[class.running]="getTaskStatus(session) === 'running'"
[ngClass]="getTaskStatus(session)"
(click)="selectTask(session.session_id, $event)"
>
<div class="item-header">
@@ -96,7 +97,7 @@ limitations under the License.
<span class="item-time-divider">·</span>
}
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
@if (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused') {
@if (['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
<button
type="button"
class="btn-stop-card"
@@ -107,7 +108,7 @@ limitations under the License.
<span class="material-symbols-outlined">stop_circle</span>
</button>
}
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') {
@if (!['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
<button
type="button"
class="btn-delete-card"
@@ -120,12 +121,15 @@ limitations under the License.
}
</div>
</div>
<div class="item-goal" title="{{ session.initial_goal }}">
{{ session.initial_goal || '未命名任务' }}
<div class="item-goal" [title]="session.initial_goal || ''">
{{ session.display_name || session.initial_goal || '未命名任务' }}
</div>
@if (session.error && getTaskStatus(session) === 'failed') {
<div class="item-error" [title]="session.error">{{session.error}}</div>
}
<div class="item-footer">
<app-task-id-copy [taskId]="session.session_id" />
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }} · 已用 {{ formatTaskDuration(session.start_time, session.end_time) }}</span>
</div>
</div>
}
@@ -3684,6 +3684,12 @@ $stream-row-height: 25px;
&.running {
border-left: 3.5px solid #2563eb;
}
&.paused, &.task_paused { border-left: 3.5px solid #d97706; background: #fffbeb; }
&.resuming, &.queued { border-left: 3.5px solid #7c3aed; }
&.failed { border-left: 3.5px solid #dc2626; background: #fff7f7; }
&.completed { border-left: 3.5px solid #16a34a; }
&.cancelled, &.interrupted { border-left: 3.5px solid #ea580c; }
.item-error { color: #b91c1c; font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }
.item-header {
display: flex;
@@ -3729,12 +3735,24 @@ $stream-row-height: 25px;
border-color: #fdba74;
&::before { background-color: #f97316; }
}
&.task_paused {
background: #ffedd5;
color: #9a3412;
border-color: #fb923c;
&::before { background-color: #ea580c; }
}
&.resuming, &.queued {
background: #f3e8ff;
color: #6b21a8;
border-color: #c4b5fd;
&::before { background-color: #7c3aed; }
}
&.completed {
background: #fafafa;
color: #1f1f1f;
border-color: #e2e8f0;
background: #dcfce7;
color: #166534;
border-color: #86efac;
&::before {
background-color: #1f1f1f;
background-color: #16a34a;
}
}
&.pending {
@@ -3746,21 +3764,22 @@ $stream-row-height: 25px;
}
}
&.failed {
background: #fafafa;
color: #ef4444;
border-color: #fee2e2;
background: #fee2e2;
color: #991b1b;
border-color: #fca5a5;
&::before {
background-color: #ef4444;
}
}
&.cancelled {
background: #fafafa;
color: #d97706;
border-color: #fef3c7;
background: #ffedd5;
color: #9a3412;
border-color: #fdba74;
&::before {
background-color: #f59e0b;
background-color: #ea580c;
}
}
&.interrupted { background: #fef3c7; color: #92400e; border-color: #fbbf24; &::before { background-color: #d97706; } }
.status-dot {
display: none;
@@ -388,13 +388,13 @@ export class AgentStreamComponent implements AfterViewInit {
public activeQueue = computed(() => {
const list = this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s);
return status === 'running' || status === 'paused' || status === 'pending';
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status);
});
return list.sort((a, b) => {
const statusA = this.getTaskStatus(a);
const statusB = this.getTaskStatus(b);
const isRunA = statusA === 'running' || statusA === 'paused';
const isRunB = statusB === 'running' || statusB === 'paused';
const isRunA = ['running', 'paused', 'task_paused', 'resuming'].includes(statusA);
const isRunB = ['running', 'paused', 'task_paused', 'resuming'].includes(statusB);
if (isRunA && !isRunB) return -1;
if (!isRunA && isRunB) return 1;
return (a.start_time || 0) - (b.start_time || 0);
@@ -404,7 +404,7 @@ export class AgentStreamComponent implements AfterViewInit {
public historyTasks = computed(() => {
return this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s);
return status === 'completed' || status === 'failed' || status === 'cancelled';
return ['completed', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(status);
});
});
@@ -491,7 +491,7 @@ export class AgentStreamComponent implements AfterViewInit {
const terminal = ['completed', 'failed', 'cancelled', 'interrupted']
.includes(this.agentService.currentSession()?.status || '');
if (event.status === 'pending') {
return terminal ? '任务结束前未进入模型' : '已接收,等待下一步';
return terminal ? '任务结束前未进入模型' : '已接收,正在打断当前模型步骤';
}
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
}
@@ -912,14 +912,14 @@ export class AgentStreamComponent implements AfterViewInit {
return tuningLabel(kind, id);
}
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
public getTaskStatus(session: Session): string {
if (session.status) {
const s = session.status.toLowerCase();
if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') {
return (s === 'success' ? 'completed' : s) as any;
if (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s)) {
return s === 'success' ? 'completed' : s;
}
if (s === 'running' || s === 'paused' || s === 'pending') {
return s as any;
if (['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(s)) {
return s;
}
}
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
@@ -1360,6 +1360,14 @@ export class AgentStreamComponent implements AfterViewInit {
return isAndroidAction(action);
}
public formatTaskDuration(start?: number, end?: number): string {
if (!start || !Number.isFinite(start)) return '—';
const seconds = Math.max(0, Math.floor((end || Date.now() / 1000) - start));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours ? `${hours}时${minutes}分` : minutes ? `${minutes}分${seconds % 60}秒` : `${seconds}秒`;
}
public hasRecordedDeviceAction(action: any): boolean {
const actions = Array.isArray(action) ? action : action ? [action] : [];
return actions.some(item => this.isAndroidAction(item) && !this.isReportStatusAction(item));
@@ -28,7 +28,7 @@
@if (liveTask() && agent.currentSessionId()) {
<div class="task-guidance">
<h3>任务指导 · {{label(taskStatus())}}</h3>
<p>{{ taskStatus() === 'paused' ? '模型请求已暂停;提交指导后请在执行日志中恢复模型调用。' : '运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。' }}</p>
<p>{{ taskStatus() === 'paused' ? '模型请求已暂停;提交指导后请在执行日志中恢复模型调用。' : '运行中提交会立即打断模型思考并重新观察设备;正在执行的设备动作会先安全结束。' }}</p>
<label for="task-guidance-text">补充信息或调整方向</label>
<textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea>
<div class="actions">
@@ -61,7 +61,7 @@
@for(b of batches(); track b.id){
<div class="batch-record">
<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>
<button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次记录(保留已执行任务与报告)'"><span class="material-symbols-outlined">delete_outline</span></button>
<button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次及其任务、报告和文件'"><span class="material-symbols-outlined">delete_outline</span></button>
</div>
}
@if(!batches().length){<p>暂无批次。</p>}
@@ -70,10 +70,10 @@
<div class="actions">
@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 !== 'running'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
@if(b.status !== 'running' && b.status !== 'deleting'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
<a [href]="'/api/automation/batches/'+b.id+'/archive'">下载批次报告 ZIP</a>
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')">{{j.position}}. {{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')" [title]="j.case.goal">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
</section>}
}
@@ -38,7 +38,7 @@ export class AutomationPanelComponent {
});
}
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告'} as Record<string,string>)[s] || s; }
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record<string,string>)[s] || s; }
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
async action(fn: () => Promise<void>) {
if (this.busy()) return;
@@ -73,12 +73,13 @@ export class AutomationPanelComponent {
}); }
selectBatch(id: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.get(`/api/automation/batches/${id}`))); }); }
deleteBatch(item: { id: string; name: string }) {
if (!window.confirm(`删除批次“${item.name}”?未执行任务会移除,已执行任务与报告仍保留。`)) return;
if (!window.confirm(`删除批次“${item.name}”及其全部任务记录、报告和文件?此操作无法撤销。`)) return;
void this.action(async () => {
await firstValueFrom(this.http.delete(`/api/automation/batches/${encodeURIComponent(item.id)}`));
if (this.batch()?.id === item.id) this.batch.set(null);
await this.refresh();
this.notice.set('批次记录已删除;已执行任务与报告仍可在历史中查看');
this.agent.fetchSessions();
this.notice.set('批次及关联任务、报告和文件已删除');
});
}
batchAction(action: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.post(`/api/automation/batches/${this.batch().id}/${action}`,{}))); await this.refresh(); }); }
@@ -49,6 +49,21 @@ describe('历史任务检索', () => {
fixture.destroy();
});
it('显示明确的结果、完成时间与总耗时,标题悬停显示完整提示词', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total: 1, items: [
{session_id:'case-1', name:'1.1 打开并核对设置', initial_goal:'请打开设置首页并核对两个真实分类', status:'failed', start_time:100, end_time:165, error:'任务执行超时(60 秒)'}
]});
fixture.detectChanges();
const row: HTMLButtonElement = fixture.nativeElement.querySelector('button.row');
expect(row.getAttribute('title')).toBe('请打开设置首页并核对两个真实分类');
expect(row.textContent).toContain('1.1 打开并核对设置');
expect(row.textContent).toContain('总耗时 1分5秒');
expect(row.textContent).toContain('任务执行超时');
expect(fixture.nativeElement.querySelector('.status-failed .history-status').textContent).toContain('失败');
fixture.destroy();
});
it('日期筛选用本地零点到次日零点,后端分页参数独立传递', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
@@ -13,12 +13,12 @@ import { AgentService } from '../../services/agent.service';
@if(error()){<p role="alert">{{error()}}</p>}
@if(loading()){<small>正在查询…</small>}
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
@for(row of group.rows; track row.session_id){<div class="history-item"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)"><strong>{{row.name || row.initial_goal}}</strong><span>{{label(row.status)}} · {{row.start_time*1000 | date:'HH:mm:ss'}} · {{row.tags}}</span></button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
</details>}
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
</section>`,
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:4px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:500}.row span{opacity:.75}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #64748b55;border-radius:6px;margin:5px 0;padding:8px}.history-item:has(.row.selected){border-color:#60a5fa;background:#3b82f615}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-block;border-radius:5px;padding:2px 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
})
export class TaskHistoryComponent {
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
@@ -27,7 +27,8 @@ export class TaskHistoryComponent {
constructor(){this.load();const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});}
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
pages(){return Math.max(1,Math.ceil(this.total()/30));}
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已取消',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
duration(start:number,end?:number){if(!start||!end)return '—';const seconds=Math.max(0,Math.floor(end-start));const hours=Math.floor(seconds/3600);const minutes=Math.floor((seconds%3600)/60);return hours?`${hours}时${minutes}分`:minutes?`${minutes}分${seconds%60}秒`:`${seconds}秒`;}
toggle(key:string,event:Event){if((event.target as HTMLDetailsElement).open)this.collapsed.delete(key);else this.collapsed.add(key);}
search(){this.page=1;this.revision++;clearTimeout(this.debounce);this.debounce=setTimeout(()=>this.load(),250);}
load(){
@@ -38,6 +38,9 @@ export interface Session {
start_time: number;
end_time?: number;
status?: string;
display_name?: string;
case_id?: string;
error?: string;
video_url?: string;
recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable';
model_info?: ModelInfo;
@@ -34,7 +34,7 @@ limitations under the License.
</div>
<label for="workspace-task-guidance">补充信息或调整方向</label>
<textarea id="workspace-task-guidance" [(ngModel)]="taskGuidance" rows="2" maxlength="10000"
[disabled]="guidanceBusy()" placeholder="输入指导;运行中将在下一步执行前生效"></textarea>
[disabled]="guidanceBusy()" placeholder="输入指导;将中断当前模型思考并立即重新规划"></textarea>
<div class="guidance-actions">
<span>{{ status === 'paused' ? '提交指导后请在执行日志中恢复模型调用' : '下方输入框用于开始新任务' }}</span>
@if (status === 'running') {
@@ -108,7 +108,7 @@ export class AgentService {
const pendingMatch = pendingById.get(s.session_id);
const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id)
|| !!activeMatch;
const isTerminal = s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'cancelled';
const isTerminal = ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s.status || '');
let sStatus = s.status;
if (!isTerminal) {
const isPending = !!pendingMatch && !isCurrentActive;
@@ -1515,12 +1515,12 @@ export class AgentService {
return; // Paused while the tab is in the background; resumed by visibilitychange.
}
this.fetchStatus();
const selected = this.currentSessionId();
if (selected) this.fetchGuidance(selected);
this.pollCounter++;
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
if (this.pollCounter % 3 === 0) {
this.fetchSessions();
const selected = this.currentSessionId();
if (selected) this.fetchGuidance(selected);
}
}, 2000);
});
@@ -1,9 +1,10 @@
import { Pipe, PipeTransform } from '@angular/core';
const LABELS: Record<string, string> = {
running: '执行中', queued: '排队中', pending: '等待中', completed: '已完成',
running: '执行中', queued: '排队中', pending: '等待中', completed: '执行完成',
success: '成功', succeeded: '成功', failed: '失败', error: '错误', paused: '已暂停',
cancelled: '已取消', canceled: '已取消', stopped: '已停止', unknown: '未知',
cancelled: '已终止', canceled: '已终止', stopped: '已终止', interrupted: '中断',
task_paused: '等待指导', resuming: '恢复排队', skipped: '已跳过', unknown: '未知',
idle: '空闲', starting: '启动中', launching: '启动中', ready: '就绪',
pass: '通过', fail: '失败', warn: '警告', warning: '警告', blocked: '受阻',
done: '完成', active: '当前', waiting: '等待中', processing: '处理中',