feat: add plugin runtime and cross-device task orchestration
This commit is contained in:
1 parent
3ee4a5f7a8
commit
6663233ab8
82 files changed
+4147
-124
No files matched your search
@@ -270,10 +270,12 @@ async def tick_batches():
|
||||
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()
|
||||
computer = options.get("execution_target") == "computer"
|
||||
devices = await device_pool.try_list_devices_async() if not computer else []
|
||||
rejection = (
|
||||
None
|
||||
if serial and any(d.serial == serial and d.state == "device" for d in devices or [])
|
||||
if computer
|
||||
or (serial and any(d.serial == serial and d.state == "device" for d in devices or []))
|
||||
else "无法确认所选设备在线且已授权,请恢复设备连接后继续批次"
|
||||
)
|
||||
if rejection:
|
||||
|
||||
@@ -450,6 +450,47 @@ def add_markdown_content(doc, content: str):
|
||||
)
|
||||
|
||||
|
||||
def plugin_operations(steps: list[dict]) -> list[dict]:
|
||||
operations = []
|
||||
for step in steps:
|
||||
for trace in step.get("generic_tools", []):
|
||||
payload = trace.get("payload") or {}
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = json.loads(payload)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(payload, dict) or not payload.get("plugin_id"):
|
||||
continue
|
||||
result = payload.get("result") or {}
|
||||
if isinstance(result, str):
|
||||
try:
|
||||
result = json.loads(result)
|
||||
except ValueError:
|
||||
result = {}
|
||||
if not isinstance(result, dict):
|
||||
result = {}
|
||||
content = "\n".join(
|
||||
str(block.get("text", ""))
|
||||
for block in result.get("content", [])
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
)
|
||||
operations.append(
|
||||
{
|
||||
"step": step.get("step_number"),
|
||||
"plugin": payload["plugin_id"],
|
||||
"version": payload.get("plugin_version", ""),
|
||||
"tool": result.get("tool") or trace.get("name"),
|
||||
"resources": ", ".join(payload.get("resource_ids") or []),
|
||||
"status": result.get("status") or trace.get("status", "unknown"),
|
||||
"content": (
|
||||
content or payload.get("error") or result.get("message") or "未返回文字证据"
|
||||
)[:6000],
|
||||
}
|
||||
)
|
||||
return operations
|
||||
|
||||
|
||||
def word_report(report: dict, path: Path):
|
||||
doc = word_styles(Document())
|
||||
doc.add_paragraph(report["name"], style="Title")
|
||||
@@ -490,6 +531,15 @@ def word_report(report: dict, path: Path):
|
||||
f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))}\n"
|
||||
).bold = True
|
||||
paragraph.add_run(step.get("summary") or "已记录操作")
|
||||
operations = plugin_operations(report["steps"])
|
||||
if operations:
|
||||
doc.add_heading("扩展插件操作", 2)
|
||||
for operation in operations:
|
||||
doc.add_paragraph(
|
||||
f"步骤 {operation['step']} · {operation['plugin']} v{operation['version']} · {operation['tool']} · {operation['status']}"
|
||||
)
|
||||
doc.add_paragraph("资源:" + operation["resources"])
|
||||
doc.add_paragraph(operation["content"])
|
||||
evidence = report["steps"][:1] + report["steps"][-1:]
|
||||
seen: set[str] = set()
|
||||
images: list[tuple[Path, int]] = []
|
||||
@@ -630,6 +680,27 @@ def excel_report(report: dict, path: Path):
|
||||
],
|
||||
(10, 22, 68, 26, 24),
|
||||
)
|
||||
operations = plugin_operations(report["steps"])
|
||||
if operations:
|
||||
sheet_rows(
|
||||
wb.create_sheet("插件操作"),
|
||||
[
|
||||
("步骤", "插件", "版本", "工具", "资源", "调用状态", "观察与结果"),
|
||||
*[
|
||||
(
|
||||
op["step"],
|
||||
op["plugin"],
|
||||
op["version"],
|
||||
op["tool"],
|
||||
op["resources"],
|
||||
op["status"],
|
||||
op["content"],
|
||||
)
|
||||
for op in operations
|
||||
],
|
||||
],
|
||||
(10, 24, 14, 26, 24, 16, 80),
|
||||
)
|
||||
wb.save(path)
|
||||
|
||||
|
||||
@@ -663,6 +734,30 @@ def session_screenshots(report: dict) -> list[tuple[Path, str]]:
|
||||
f"{index:04d}_步骤{int(step.get('step_number') or index):04d}_{phase}{source.suffix.lower()}",
|
||||
)
|
||||
)
|
||||
# Plugin observations are attached to their own tool traces rather than
|
||||
# replacing a phone's step image in a mixed task.
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for tool_index, trace in enumerate(step.get("generic_tools", []), 1):
|
||||
payload = trace.get("payload") or {}
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = json.loads(payload)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(payload, dict) or not payload.get("plugin_id"):
|
||||
continue
|
||||
plugin_id = re.sub(r"[^a-zA-Z0-9_-]", "_", str(payload["plugin_id"]))[:48]
|
||||
names = payload.get("image_names") or [payload.get("post_image_name")]
|
||||
for image_index, name in enumerate(names, 1):
|
||||
source = image_path(name)
|
||||
if source and (plugin_id, str(name)) not in seen:
|
||||
seen.add((plugin_id, str(name)))
|
||||
screenshots.append(
|
||||
(
|
||||
source,
|
||||
f"{index:04d}_插件_{plugin_id}_{tool_index:03d}_{image_index:02d}{source.suffix.lower()}",
|
||||
)
|
||||
)
|
||||
return screenshots
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from artemis.plugins.models import ExecutionTarget, PluginSelection
|
||||
|
||||
HEADERS = (
|
||||
"用例编号",
|
||||
@@ -47,6 +48,8 @@ class TaskOptions(BaseModel):
|
||||
device_command_timeout_seconds: float | None = Field(default=None, gt=0, le=60)
|
||||
action_failure_attempts: int | None = Field(default=None, ge=1, le=10)
|
||||
worker_cleanup_timeout_seconds: float | None = Field(default=None, gt=0, le=300)
|
||||
plugins: list[PluginSelection] | None = None
|
||||
execution_target: ExecutionTarget | None = None
|
||||
|
||||
|
||||
class TaskCase(BaseModel):
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
# limitations under the License.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import os
|
||||
@@ -275,6 +276,11 @@ class TaskQueueService:
|
||||
]
|
||||
for item in removed_items:
|
||||
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
|
||||
from artemis.plugins.store import PluginStore
|
||||
|
||||
# A stop request may already have removed this row. The worker finalizer
|
||||
# must still release the server-owned version reservation after exit.
|
||||
PluginStore().release(str(session_id))
|
||||
state.queue_items = [
|
||||
t
|
||||
for t in state.queue_items
|
||||
@@ -417,6 +423,9 @@ class TaskQueueService:
|
||||
busy_devices = state.busy_device_ids | {
|
||||
cls._task_target(i).lock_key for i in in_flight if i.get("device_serial")
|
||||
}
|
||||
busy_resources = {r for item in in_flight for r in item.get("plugin_resources", [])}
|
||||
for owner in DeviceExecutionLock.get_active_owners().values():
|
||||
busy_resources.update(owner.resource_ids)
|
||||
dispatched_any = False
|
||||
loop = asyncio.get_running_loop()
|
||||
for item in list(state.queue_items):
|
||||
@@ -429,6 +438,9 @@ class TaskQueueService:
|
||||
|
||||
device = item.get("device_serial")
|
||||
target = cls._task_target(item)
|
||||
resources = set(item.get("plugin_resources", []))
|
||||
if resources & busy_resources:
|
||||
continue
|
||||
if limit == 0:
|
||||
# A task without a resolved device may bind to any serial, so it
|
||||
# only launches on an otherwise idle scheduler; the device lock
|
||||
@@ -446,6 +458,7 @@ class TaskQueueService:
|
||||
continue
|
||||
|
||||
item["status"] = "running"
|
||||
busy_resources.update(resources)
|
||||
dispatched_any = True
|
||||
if device is not None:
|
||||
busy_devices.add(target.lock_key)
|
||||
@@ -593,6 +606,11 @@ class TaskQueueService:
|
||||
else str(overrides[option])
|
||||
)
|
||||
target.endpoint.apply_to_environment(env)
|
||||
if "plugins" in overrides:
|
||||
env["ARTEMIS_TASK_PLUGINS"] = json.dumps(overrides["plugins"])
|
||||
else:
|
||||
env.pop("ARTEMIS_TASK_PLUGINS", None)
|
||||
env["ARTEMIS_EXECUTION_TARGET"] = overrides.get("execution_target") or "android"
|
||||
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
|
||||
queue_ticket = task_item.get("queue_ticket")
|
||||
if queue_ticket:
|
||||
@@ -623,7 +641,7 @@ class TaskQueueService:
|
||||
if app_path:
|
||||
cmd.extend(["--app-path", str(app_path)])
|
||||
device_serial = task_item.get("device_serial")
|
||||
if device_serial:
|
||||
if device_serial and env["ARTEMIS_EXECUTION_TARGET"] != "computer":
|
||||
cmd.extend(["--device-serial", str(device_serial)])
|
||||
env["ADB_DEVICE_SERIAL"] = str(device_serial)
|
||||
from apps.admin_console.services.automation_store import store
|
||||
@@ -1237,11 +1255,40 @@ class TaskQueueService:
|
||||
if duplicate_response is not None:
|
||||
return duplicate_response
|
||||
|
||||
rejection_response = await cls._reject_unavailable_device(device_serial)
|
||||
execution_target = (runtime_overrides or {}).get("execution_target") or "android"
|
||||
from pydantic import TypeAdapter
|
||||
from artemis.plugins.models import PluginSelection
|
||||
from artemis.plugins.store import PluginStore
|
||||
|
||||
choices = (runtime_overrides or {}).get("plugins")
|
||||
try:
|
||||
pinned = PluginStore().pin(
|
||||
TypeAdapter(list[PluginSelection]).validate_python(choices)
|
||||
if choices is not None
|
||||
else None,
|
||||
execution_target,
|
||||
)
|
||||
except ValueError:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "插件未就绪、未授权或不支持所选任务目标,请检查插件配置",
|
||||
}
|
||||
runtime_overrides = {
|
||||
**(runtime_overrides or {}),
|
||||
"execution_target": execution_target,
|
||||
"plugins": [p.model_dump() for p in pinned],
|
||||
}
|
||||
rejection_response = (
|
||||
await cls._reject_unavailable_device(device_serial)
|
||||
if execution_target != "computer"
|
||||
else None
|
||||
)
|
||||
if rejection_response is not None:
|
||||
return rejection_response
|
||||
|
||||
single_session_id = session_id if (session_id and len(goals) == 1) else None
|
||||
if execution_target == "computer":
|
||||
device_serial = "computer:local"
|
||||
if not device_serial:
|
||||
# Device enumeration may block on ADB.
|
||||
from artemis.runtime import device_pool
|
||||
@@ -1270,6 +1317,19 @@ class TaskQueueService:
|
||||
)
|
||||
if runtime_overrides:
|
||||
task_item["runtime_overrides"] = runtime_overrides
|
||||
if pinned:
|
||||
try:
|
||||
task_item["plugin_resources"] = PluginStore().reserve(
|
||||
task_item["session_id"], pinned, execution_target
|
||||
)
|
||||
except (ValueError, OSError):
|
||||
DeviceExecutionLock.cancel_reservation(task_item.get("queue_ticket"))
|
||||
for previous in enqueued_tasks:
|
||||
cls._remove_task(previous["session_id"])
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "插件配置在入队前发生变化,请检查后重新提交",
|
||||
}
|
||||
if display_name:
|
||||
task_item["display_name"] = display_name
|
||||
state.queue_items.append(task_item)
|
||||
@@ -1380,6 +1440,7 @@ class TaskQueueService:
|
||||
"""Terminate all active device owners and clear pending queue submissions."""
|
||||
from apps.admin_console.services.automation_store import store
|
||||
from apps.admin_console.services.automation_service import on_terminal, spawn
|
||||
from artemis.plugins.store import PluginStore
|
||||
|
||||
for batch in store.records("batch"):
|
||||
if batch["status"] == "running":
|
||||
@@ -1389,6 +1450,7 @@ class TaskQueueService:
|
||||
for item in state.queue_items:
|
||||
if isinstance(item, dict) and item.get("status") != "running":
|
||||
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
|
||||
PluginStore().release(str(item.get("session_id")))
|
||||
if store.job(str(item.get("session_id"))):
|
||||
spawn(on_terminal(item, "cancelled"))
|
||||
state.clear_queue()
|
||||
@@ -1712,7 +1774,9 @@ class TaskQueueService:
|
||||
if stopped_item and stopped_item.get("status") != "running":
|
||||
from apps.admin_console.services.automation_store import store
|
||||
from apps.admin_console.services.automation_service import on_terminal, spawn
|
||||
from artemis.plugins.store import PluginStore
|
||||
|
||||
PluginStore().release(str(stopped_session_id))
|
||||
if store.job(str(stopped_session_id)):
|
||||
spawn(on_terminal(stopped_item, "cancelled"))
|
||||
if stopped_item and not reservation_cancelled:
|
||||
|
||||
Reference in new issue
Block a user