commit 53f523f18767f3418a063ec85e7596bbc1e6ffea Author: Codex Date: Mon Jun 22 10:37:20 2026 +0800 Add SystemTest Agent and Controller diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc5f8e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Python caches and virtual environments +__pycache__/ +*.py[cod] +*.pyo +.venv/ +venv/ +env/ + +# Local environment and credentials +.env +.env.* +!.env.example +!.env.template +.agent_bridge_token.json +**/.agent_bridge_token.json + +# Runtime state, logs, and generated evidence +logs/ +**/logs/ +SystemTest-Agent/data/ +SystemTest-Controller/data/ +Agent-S-main/Agent-S-main/task_result_and_debug_logs/ +*.db +*.sqlite +*.sqlite3 +*.log +*.tmp +*.bak +*.pid + +# JavaScript build outputs and dependencies +node_modules/ +dist/ +build/ + +# Python build/test outputs +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +htmlcov/ +.coverage + +# Editor and OS files +.idea/ +.vscode/ +.DS_Store +Thumbs.db + +# Local packaging scratch space +.push_worktree/ +.tmp_git_nested_test/ diff --git a/SystemTest-Agent/README.md b/SystemTest-Agent/README.md new file mode 100644 index 0000000..1a0d982 --- /dev/null +++ b/SystemTest-Agent/README.md @@ -0,0 +1,69 @@ +# SystemTest-Agent + +P0-02 被控端最小可用实现,首次启动向主控注册并保存 token,后续启动使用本地 token 发送 Bearer 心跳并轮询任务。 + +## 一键启动 + +PowerShell: + +```powershell +.\start_agent.ps1 +``` + +CMD: + +```bat +start_agent.bat +``` + +默认会在局域网内自动发现 `8001/8000` 端口上的主控。也可以手工指定主控地址: + +```powershell +.\start_agent.ps1 -ControllerUrl http://:8001 +``` + +```bat +start_agent.bat http://:8001 +``` + +如果在另一台局域网设备上双击后窗口闪退,常见原因是: + +- 该设备访问不到主控机的 `8001` 端口,需放行主控机防火墙或手工传入 `http://<主控机局域网IP>:8001` +- 该设备没有安装 Python 3,或 `python/py` 不在 PATH +- 被控本地服务端口 `18081` 已被占用 + +新版 `start_agent.bat` 遇到这些错误会暂停并显示原因。 + +默认被控本地服务端口与 `Agent-S-main/bridge_config.yaml` 的 `log_web_port` 保持一致: + +- Local health: `http://127.0.0.1:18081/health` +- 环境变量:`AGENT_LOCAL_SERVICE_PORT=18081` + +## 状态持久化 + +首次注册成功后写入 `data/agent_state.json`: + +- `agent_id` +- `public_id` +- `token` +- `registered_at` +- `controller_url` + +重启时如果该文件存在且机器标识一致,被控端不会重复注册,会直接用 Bearer token 发送心跳和领任务请求。 + +## 部署形态 + +`AGENT_DEPLOYMENT_TYPE=auto` 时自动判断物理机/虚拟机;也可显式设置: + +- `physical_machine` +- `virtual_machine` +- `box_physical` +- `box_virtual` + +## 已完成的 P0-02 验收点 + +- 首次启动自动注册 +- token 保存到本地状态文件 +- 后续启动不重复注册 +- 心跳和领任务请求使用 `Authorization: Bearer ` +- 注册与心跳上报物理机/虚拟机部署形态 diff --git a/SystemTest-Agent/app/__init__.py b/SystemTest-Agent/app/__init__.py new file mode 100644 index 0000000..ce4f821 --- /dev/null +++ b/SystemTest-Agent/app/__init__.py @@ -0,0 +1,3 @@ +"""System test controlled agent package.""" + +__version__ = "0.1.0" diff --git a/SystemTest-Agent/app/__main__.py b/SystemTest-Agent/app/__main__.py new file mode 100644 index 0000000..2e7690d --- /dev/null +++ b/SystemTest-Agent/app/__main__.py @@ -0,0 +1,5 @@ +from .runner import main + + +if __name__ == "__main__": + main() diff --git a/SystemTest-Agent/app/agent_core/__init__.py b/SystemTest-Agent/app/agent_core/__init__.py new file mode 100644 index 0000000..d9be150 --- /dev/null +++ b/SystemTest-Agent/app/agent_core/__init__.py @@ -0,0 +1,5 @@ +from .memory import Blackboard, MemoryItem +from .session import AgentSession +from .tools import AgentToolRegistry + +__all__ = ["AgentSession", "AgentToolRegistry", "Blackboard", "MemoryItem"] diff --git a/SystemTest-Agent/app/agent_core/memory.py b/SystemTest-Agent/app/agent_core/memory.py new file mode 100644 index 0000000..0ffe029 --- /dev/null +++ b/SystemTest-Agent/app/agent_core/memory.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +@dataclass +class MemoryItem: + kind: str + content: dict[str, Any] + created_at: str = field(default_factory=utc_now) + task_id: str | None = None + step: int | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "kind": self.kind, + "content": self.content, + "created_at": self.created_at, + "task_id": self.task_id, + "step": self.step, + "metadata": self.metadata, + } + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "MemoryItem": + return cls( + kind=str(payload.get("kind") or "event"), + content=payload.get("content") if isinstance(payload.get("content"), dict) else {}, + created_at=str(payload.get("created_at") or utc_now()), + task_id=str(payload.get("task_id")) if payload.get("task_id") is not None else None, + step=int(payload["step"]) if isinstance(payload.get("step"), int) else None, + metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}, + ) + + +class Blackboard: + """ + Lightweight file-backed memory shared by the local Host/App agent layers. + + It intentionally mirrors UFO's blackboard shape without pulling in the full + dependency stack: requests, trajectories, observations, screenshots and facts. + """ + + DEFAULT_KINDS = ("requests", "trajectories", "observations", "screenshots", "facts") + + def __init__(self, path: Path, max_items_per_kind: int = 200): + self.path = path + self.max_items_per_kind = max_items_per_kind + self._content: dict[str, list[MemoryItem]] = {kind: [] for kind in self.DEFAULT_KINDS} + self.load() + + def load(self) -> None: + if not self.path.exists(): + return + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return + if not isinstance(raw, dict): + return + for kind in self.DEFAULT_KINDS: + entries = raw.get(kind) + if isinstance(entries, list): + self._content[kind] = [ + MemoryItem.from_dict(item) + for item in entries + if isinstance(item, dict) + ][-self.max_items_per_kind :] + + def save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + payload = { + kind: [item.to_dict() for item in items[-self.max_items_per_kind :]] + for kind, items in self._content.items() + } + self.path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True), + encoding="utf-8", + ) + + def append( + self, + kind: str, + content: dict[str, Any], + *, + task_id: str | None = None, + step: int | None = None, + metadata: dict[str, Any] | None = None, + ) -> MemoryItem: + if kind not in self._content: + self._content[kind] = [] + item = MemoryItem( + kind=kind, + content=content, + task_id=task_id, + step=step, + metadata=metadata or {}, + ) + self._content[kind].append(item) + self._content[kind] = self._content[kind][-self.max_items_per_kind :] + self.save() + return item + + def recent(self, kind: str, limit: int = 10) -> list[dict[str, Any]]: + return [item.to_dict() for item in self._content.get(kind, [])[-limit:]] + + def snapshot(self, limit: int = 5) -> dict[str, list[dict[str, Any]]]: + return {kind: self.recent(kind, limit) for kind in self._content} + + def summary(self) -> dict[str, Any]: + return { + "path": str(self.path), + "counts": {kind: len(items) for kind, items in self._content.items()}, + "latest_trajectory": self.recent("trajectories", 1), + } diff --git a/SystemTest-Agent/app/agent_core/model_client.py b/SystemTest-Agent/app/agent_core/model_client.py new file mode 100644 index 0000000..4d626fc --- /dev/null +++ b/SystemTest-Agent/app/agent_core/model_client.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import json +import re +import time +from dataclasses import dataclass +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +@dataclass +class ModelResponse: + ok: bool + content: str = "" + parsed: dict[str, Any] | None = None + duration_seconds: float = 0.0 + error: str = "" + + +def _chat_completions_url(base_url: str) -> str: + base = base_url.rstrip("/") + if base.endswith("/chat/completions"): + return base + if base.endswith("/v1"): + return f"{base}/chat/completions" + return f"{base}/v1/chat/completions" + + +def parse_json_object(text: str) -> dict[str, Any] | None: + raw = text.strip() + fence = re.search(r"```(?:json)?\s*(.*?)```", raw, re.IGNORECASE | re.DOTALL) + if fence: + raw = fence.group(1).strip() + start = raw.find("{") + end = raw.rfind("}") + if start >= 0 and end > start: + raw = raw[start : end + 1] + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return None + return parsed if isinstance(parsed, dict) else None + + +def _network_error_message(label: str, exc: BaseException, timeout: float) -> str: + reason = getattr(exc, "reason", exc) + if isinstance(exc, TimeoutError) or isinstance(reason, TimeoutError): + detail = f"timed out after {timeout:g}s" + else: + detail = str(reason) or type(reason).__name__ + return f"{label} -> {detail}" + + +class ModelClient: + def __init__(self, model_config: dict[str, Any], timeout_seconds: float = 30): + self.model_config = model_config + self.timeout_seconds = timeout_seconds + + @property + def available(self) -> bool: + return bool( + self.model_config.get("controller_model_proxy_url") + or (self.model_config.get("vllm_base_url") and self.model_config.get("vllm_model")) + ) + + def chat_json(self, messages: list[dict[str, str]], temperature: float | None = None) -> ModelResponse: + if not self.available: + return ModelResponse(ok=False, error="Model config is incomplete.") + + proxy_url = str(self.model_config.get("controller_model_proxy_url") or "").strip() + if proxy_url: + return self._chat_json_via_controller(proxy_url, messages, temperature) + + url = _chat_completions_url(str(self.model_config.get("vllm_base_url") or "")) + payload = { + "model": self.model_config.get("vllm_model"), + "messages": messages, + "temperature": temperature if temperature is not None else self.model_config.get("temperature", 0), + } + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + headers = { + "Accept": "application/json", + "Content-Type": "application/json; charset=utf-8", + } + api_key = str(self.model_config.get("api_key") or "") + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + started = time.monotonic() + request = Request(url, data=body, headers=headers, method="POST") + try: + with urlopen(request, timeout=self.timeout_seconds) as response: + raw = response.read() + except HTTPError as exc: + error_body = exc.read().decode("utf-8", errors="replace") + return ModelResponse( + ok=False, + duration_seconds=round(time.monotonic() - started, 3), + error=f"{url} -> HTTP {exc.code}: {error_body or exc.reason}", + ) + except (URLError, TimeoutError, OSError) as exc: + return ModelResponse( + ok=False, + duration_seconds=round(time.monotonic() - started, 3), + error=_network_error_message(url, exc, self.timeout_seconds), + ) + + duration = round(time.monotonic() - started, 3) + try: + data = json.loads(raw.decode("utf-8")) + choice = data["choices"][0] + message = choice.get("message") or {} + content = ( + message.get("content") + or message.get("reasoning_content") + or message.get("reasoning") + or choice.get("text") + or "" + ) + except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc: + return ModelResponse(ok=False, duration_seconds=duration, error=f"Invalid model response: {exc}") + if not isinstance(content, str): + content = json.dumps(content, ensure_ascii=False) + + return ModelResponse( + ok=True, + content=str(content), + parsed=parse_json_object(str(content)), + duration_seconds=duration, + ) + + def _chat_json_via_controller( + self, + url: str, + messages: list[dict[str, str]], + temperature: float | None = None, + ) -> ModelResponse: + payload = { + "messages": messages, + "temperature": temperature if temperature is not None else self.model_config.get("temperature", 0), + } + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + headers = { + "Accept": "application/json", + "Content-Type": "application/json; charset=utf-8", + } + token = str(self.model_config.get("controller_model_proxy_token") or "") + if token: + headers["Authorization"] = f"Bearer {token}" + + started = time.monotonic() + request = Request(url, data=body, headers=headers, method="POST") + try: + with urlopen(request, timeout=self.timeout_seconds) as response: + raw = response.read() + except HTTPError as exc: + error_body = exc.read().decode("utf-8", errors="replace") + return ModelResponse( + ok=False, + duration_seconds=round(time.monotonic() - started, 3), + error=f"controller model proxy -> HTTP {exc.code}: {error_body or exc.reason}", + ) + except (URLError, TimeoutError, OSError) as exc: + return ModelResponse( + ok=False, + duration_seconds=round(time.monotonic() - started, 3), + error=_network_error_message("controller model proxy", exc, self.timeout_seconds), + ) + + duration = round(time.monotonic() - started, 3) + try: + data = json.loads(raw.decode("utf-8")) if raw else {} + except json.JSONDecodeError as exc: + return ModelResponse(ok=False, duration_seconds=duration, error=f"Invalid controller proxy response: {exc}") + + content = str(data.get("content") or "") + parsed = data.get("parsed") if isinstance(data.get("parsed"), dict) else parse_json_object(content) + proxy_duration = data.get("duration_seconds") + try: + duration = float(proxy_duration) if proxy_duration is not None else duration + except (TypeError, ValueError): + pass + return ModelResponse( + ok=bool(data.get("ok")), + content=content, + parsed=parsed, + duration_seconds=round(duration, 3), + error=str(data.get("error") or ""), + ) diff --git a/SystemTest-Agent/app/agent_core/prompts.py b/SystemTest-Agent/app/agent_core/prompts.py new file mode 100644 index 0000000..b4be794 --- /dev/null +++ b/SystemTest-Agent/app/agent_core/prompts.py @@ -0,0 +1,103 @@ +from __future__ import annotations + + +HOST_AGENT_SYSTEM_PROMPT = """ +你是系统测试 Agent 的 HostAgent,负责把一个待测系统拆解成可执行、可追踪、可恢复的测试任务。 + +职责: +1. 阅读当前任务、任务层级、历史轨迹、页面观察结果和可用工具。 +2. 判断当前任务是否需要继续拆分为子任务。 +3. 将当前可执行范围交给 AppAgent,并说明目标、边界和风险。 +4. 遇到账号、密码、验证码、权限、网络异常或破坏性操作时,不要硬做,标记为 PENDING 或 CONFIRM,并说明需要用户补充的信息。 +5. 所有任务都必须保留层级关系,便于最终汇总成测试报告。 + +P0 阶段约束: +- 允许打开网页、观察页面、读取标题/状态、识别链接/表单/按钮、保存截图、生成子任务。 +- 不允许自动提交真实表单、删除、支付、保存、发送消息或修改业务数据。 +- 如果必须继续执行高风险动作,返回 CONFIRM。 + +输出必须是 JSON 对象: +{ + "observation": "当前任务状态", + "thought": "调度判断", + "current_subtask": "分配给 AppAgent 的子任务", + "message": ["给 AppAgent 的约束或提示"], + "status": "FINISH | CONTINUE | PENDING | ASSIGN | FAIL", + "plan": ["后续子任务或测试方向"], + "function": "assign_app_agent | finish | ask_user | fail", + "arguments": {}, + "questions": [], + "result": "调度结论" +} +""".strip() + + +APP_AGENT_SYSTEM_PROMPT = """ +你是系统测试 Agent 的 AppAgent,负责在单个网页或应用范围内执行 HostAgent 分配的测试子任务。 + +职责: +1. 打开待测页面并观察当前状态。 +2. 识别页面中的功能入口:导航链接、按钮、表单、搜索框、登录入口、上传入口等。 +3. 记录测试证据:URL、标题、HTTP 状态、可交互元素、截图路径、失败原因。 +4. 如果页面需要账号密码、验证码、权限或网络不可达,标记 PENDING 并说明缺少什么。 + +P0 阶段约束: +- 只做安全观察和无副作用动作。 +- 可以打开页面、读取结构、保存截图、枚举窗口和控件。 +- 不要自动提交真实表单、删除、支付、保存、发送消息或修改业务数据。 +- 高风险入口只记录并生成待确认任务,不要点击执行。 + +输出必须是 JSON 对象,不要输出 Markdown: +{ + "observation": "当前页面或应用状态", + "thought": "为什么选择这一步", + "action": { + "function": "open_in_browser | observe_web_page | discover_web_subtasks | list_windows | inspect_window_controls | capture_desktop | capture_window | no_action", + "arguments": {}, + "status": "CONTINUE | FINISH | FAIL | CONFIRM | PENDING" + }, + "plan": ["后续动作或子任务方向"], + "comment": "给用户或上层 Agent 的简短说明", + "save_screenshot": {"save": false, "reason": ""}, + "result": "本轮执行结论" +} +""".strip() + + +SYSTEM_TEST_ANALYST_PROMPT = """ +你是系统功能测试分析 Agent。你会收到一个页面观察结果、当前任务层级、可用链接/表单/按钮和历史记忆。 + +你的目标不是复述 HTML,而是像测试工程师一样判断“这个页面上有哪些业务功能值得测试”,并把它们拆成层级化测试子任务。 + +要求: +1. 根据页面标题、URL、链接文本、按钮文本、表单字段等推断功能模块。 +2. 优先生成用户可理解的功能测试任务,例如“登录表单校验”“系统测试启动流程”“模型配置保存”“任务详情查看”。 +3. 每个子任务必须给出测试目标 objective,而不是只给元素名称。 +4. 如果需要账号、密码、验证码、权限或测试数据,写入 preconditions。 +5. 识别风险:low 表示只读/导航/查看;medium 表示保存/提交/上传/新增/修改;high 表示删除/支付/清空/注销/不可逆。 +6. P0 阶段不要求执行破坏性操作,只生成待确认任务。 +7. 不要生成重复任务;最多生成 8 个高价值子任务。 +8. 如果当前页面信息不足,也要说明原因,并可以返回空数组。 + +输出必须是严格 JSON 对象,不要输出 Markdown: +{ + "summary": "对当前页面业务能力的简短判断", + "coverage_notes": ["本轮能够覆盖或不能覆盖的点"], + "needs_user_input": false, + "user_input_reason": "", + "functional_tasks": [ + { + "title": "用户可读的测试任务标题", + "objective": "具体测试目标和通过标准", + "module": "所属模块", + "target_url": "可访问 URL,无法判断则留空", + "level": "module | feature | case", + "risk_level": "low | medium | high", + "priority": 10, + "preconditions": [], + "source": "llm_analysis", + "reason": "为什么生成这个任务" + } + ] +} +""".strip() diff --git a/SystemTest-Agent/app/agent_core/session.py b/SystemTest-Agent/app/agent_core/session.py new file mode 100644 index 0000000..d57c3f0 --- /dev/null +++ b/SystemTest-Agent/app/agent_core/session.py @@ -0,0 +1,848 @@ +from __future__ import annotations + +import hashlib +import json +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable +from urllib.parse import urljoin, urlparse + +from .memory import Blackboard +from .model_client import ModelClient +from .prompts import APP_AGENT_SYSTEM_PROMPT, HOST_AGENT_SYSTEM_PROMPT, SYSTEM_TEST_ANALYST_PROMPT +from .tools import AgentToolRegistry, ToolResult, normalize_url + + +SubmitTask = Callable[[dict[str, Any]], dict[str, Any]] + + +@dataclass +class AgentSessionResult: + payload: dict[str, Any] + + +class AgentSession: + """ + Minimal UFO-inspired execution loop for one claimed task. + + The implementation keeps the same conceptual boundaries as UFO: + HostAgent decides the subtask, AppAgent performs one safe step, Blackboard + persists requests/observations/trajectories for later rounds. + """ + + def __init__( + self, + *, + task: dict[str, Any], + model_config_state: dict[str, Any], + memory_path: Path, + request_timeout_seconds: float, + submit_task: SubmitTask | None = None, + ) -> None: + self.task = task + self.model_config_state = model_config_state + self.model_config = model_config_state.get("value") if isinstance(model_config_state.get("value"), dict) else {} + self.request_timeout_seconds = request_timeout_seconds + self.submit_task = submit_task + self.blackboard = Blackboard(memory_path) + self.tools = AgentToolRegistry() + self.model = ModelClient(self.model_config, timeout_seconds=self._model_timeout_seconds()) + self.logs: list[str] = [] + self.screenshot_dir = memory_path.parent / "screenshots" + + def _model_timeout_seconds(self) -> float: + try: + timeout = float(self.model_config.get("request_timeout_seconds") or self.request_timeout_seconds) + except (TypeError, ValueError): + timeout = self.request_timeout_seconds + return max(1.0, min(timeout, 300.0)) + + @property + def payload(self) -> dict[str, Any]: + value = self.task.get("payload") + return value if isinstance(value, dict) else {} + + @property + def task_id(self) -> str: + return str(self.task.get("external_id") or self.task.get("id") or "") + + def target_url(self) -> str: + return normalize_url( + self.payload.get("target_url") + or self.payload.get("url") + or self.payload.get("site_url") + or self.task.get("target_url") + or self.model_config.get("target_system_url") + ) + + def run(self) -> dict[str, Any]: + started = time.monotonic() + self.blackboard.append( + "requests", + { + "task": self._task_brief(), + "model_config_version": self.model_config_state.get("version"), + }, + task_id=self.task_id, + ) + + host_decision = self._host_decision() + if self.execution_mode() == "gui": + return self._run_gui_task(started, host_decision) + + target_url = self.target_url() + if not target_url: + return self._finish_without_target(started, host_decision) + + open_browser_result: ToolResult | None = None + if self.payload.get("open_in_browser", True): + open_browser_result = self.tools.web.open_in_browser( + target_url, + wait_seconds=float(self.payload.get("browser_wait_seconds") or 1.5), + ) + self.blackboard.append( + "actions", + { + "tool": "open_in_browser", + "ok": open_browser_result.ok, + "error": open_browser_result.error, + "result": open_browser_result.result, + }, + task_id=self.task_id, + ) + if open_browser_result.ok: + self.logs.append(f"Opened target URL in browser: {target_url}") + else: + self.logs.append(f"Browser open failed: {open_browser_result.error}") + + observation_result = self.tools.web.observe_page( + target_url, + timeout_seconds=self.request_timeout_seconds, + ) + observation = observation_result.result + desktop_screenshot_result: ToolResult | None = None + if self.payload.get("capture_screenshot", True): + desktop_screenshot_result = self.tools.gui.capture_desktop( + self.screenshot_dir, + prefix=f"task_{self.task.get('id') or self.task_id}_web", + ) + if desktop_screenshot_result.ok: + self.blackboard.append( + "screenshots", + { + "image_path": desktop_screenshot_result.result.get("image_path"), + "scope": "web_desktop", + "target_url": target_url, + }, + task_id=self.task_id, + ) + elif desktop_screenshot_result.error: + self.logs.append(f"Desktop screenshot failed: {desktop_screenshot_result.error}") + + self.blackboard.append( + "observations", + { + "tool": "observe_web_page", + "ok": observation_result.ok, + "error": observation_result.error, + "browser_open": open_browser_result.result if open_browser_result else None, + "screenshot": desktop_screenshot_result.result if desktop_screenshot_result and desktop_screenshot_result.ok else None, + "summary": self._observation_summary(observation), + }, + task_id=self.task_id, + ) + + app_decision = self._app_decision(host_decision, observation_result) + discovered_children: list[dict[str, Any]] = [] + model_generated_children: list[dict[str, Any]] = [] + rule_discovered_children: list[dict[str, Any]] = [] + submitted_children: list[dict[str, Any]] = [] + model_analysis: dict[str, Any] = { + "model_used": False, + "model_available": self.model.available, + } + discovery_source = "none" + if self._allow_child_discovery(observation_result): + model_analysis, model_generated_children = self._model_discover_child_tasks(observation_result) + if model_generated_children: + discovered_children = model_generated_children + discovery_source = "llm" + else: + rule_discovered_children = self.tools.web.discover_child_tasks( + self.task, + observation, + max_depth=int(self.payload.get("discovery_max_depth") or 2), + ) + discovered_children = rule_discovered_children + discovery_source = "rule" + if model_analysis.get("model_available") and model_analysis.get("model_used"): + self.logs.append("Model returned no usable functional tasks; used rule-based fallback.") + submitted_children = self._submit_child_tasks(discovered_children) + + success, message, needs_user_input = self._status_from_observation(observation_result, app_decision) + if model_analysis.get("needs_user_input"): + needs_user_input = True + success = False + message = str(model_analysis.get("user_input_reason") or "Model determined that user input is required.") + duration = round(time.monotonic() - started, 3) + result = { + "success": success, + "message": message, + "task_type": self.payload.get("task_type") or "manual", + "agent_framework": "ufo-inspired-lite", + "p0_mode": True, + "needs_user_input": needs_user_input, + "target_url": target_url, + "final_url": observation.get("final_url"), + "http_status": observation.get("http_status"), + "content_type": observation.get("content_type"), + "page_title": observation.get("page_title"), + "duration_seconds": duration, + "action_duration_seconds": round( + observation_result.duration_seconds + + (open_browser_result.duration_seconds if open_browser_result else 0) + + (desktop_screenshot_result.duration_seconds if desktop_screenshot_result else 0), + 3, + ), + "model_duration_seconds": round( + self._model_duration(host_decision, app_decision) + + float(model_analysis.get("model_duration_seconds") or 0), + 3, + ), + "hierarchy": self.payload.get("hierarchy") or self._task_hierarchy(), + "host_decision": host_decision, + "app_decision": app_decision, + "model_available": self.model.available, + "model_analysis": model_analysis, + "model_errors": self._model_errors(host_decision, app_decision, model_analysis), + "observation": self._observation_summary(observation), + "browser_open": open_browser_result.result if open_browser_result else None, + "screenshots": [ + desktop_screenshot_result.result + ] if desktop_screenshot_result and desktop_screenshot_result.ok else [], + "available_tools": self.tools.descriptions(), + "discovered_child_tasks": self._child_task_briefs(discovered_children), + "model_generated_child_tasks": self._child_task_briefs(model_generated_children), + "rule_discovered_child_tasks": self._child_task_briefs(rule_discovered_children), + "discovery_source": discovery_source, + "submitted_child_tasks": submitted_children, + "memory": self.blackboard.summary(), + "logs": [*self.logs, *(observation_result.result.get("logs") or [])], + } + self.blackboard.append( + "trajectories", + { + "host_decision": host_decision, + "app_decision": app_decision, + "result": { + "success": success, + "message": message, + "submitted_child_count": len(submitted_children), + "duration_seconds": duration, + }, + }, + task_id=self.task_id, + ) + result["memory"] = self.blackboard.summary() + return result + + def execution_mode(self) -> str: + explicit = str(self.payload.get("execution_mode") or self.payload.get("agent_mode") or "").strip().lower() + if explicit in {"web", "gui"}: + return explicit + task_type = str(self.payload.get("task_type") or "").lower() + if task_type.startswith("gui") or self.payload.get("window_id") or self.payload.get("window_title"): + return "gui" + return "web" + + def _run_gui_task(self, started: float, host_decision: dict[str, Any]) -> dict[str, Any]: + windows_result = self.tools.gui.list_windows() + screenshot_result = self.tools.gui.capture_desktop(self.screenshot_dir, prefix=f"task_{self.task.get('id') or self.task_id}_desktop") + selected_window = self._select_window(windows_result.result.get("windows") or []) + inspect_result: ToolResult | None = None + window_screenshot_result: ToolResult | None = None + if selected_window: + inspect_result = self.tools.gui.inspect_window(str(selected_window["id"])) + window_screenshot_result = self.tools.gui.capture_window( + str(selected_window["id"]), + self.screenshot_dir, + prefix=f"task_{self.task.get('id') or self.task_id}_window", + ) + + observation = self._gui_observation_summary( + windows_result, + screenshot_result, + selected_window, + inspect_result, + window_screenshot_result, + ) + self.blackboard.append( + "observations", + { + "tool": "gui_observe", + "ok": windows_result.ok, + "error": windows_result.error, + "summary": observation, + }, + task_id=self.task_id, + ) + if screenshot_result.ok: + self.blackboard.append( + "screenshots", + {"image_path": screenshot_result.result.get("image_path"), "scope": "desktop"}, + task_id=self.task_id, + ) + if window_screenshot_result and window_screenshot_result.ok: + self.blackboard.append( + "screenshots", + {"image_path": window_screenshot_result.result.get("image_path"), "scope": "window", "window": selected_window}, + task_id=self.task_id, + ) + + app_decision = { + "observation": json.dumps(observation, ensure_ascii=False), + "thought": "GUI P0 阶段只枚举窗口、控件并保存证据截图,不执行点击或输入。", + "action": { + "function": "list_windows" if not selected_window else "inspect_window_controls", + "arguments": { + "window_id": selected_window.get("id") if selected_window else None, + "window_title": self.payload.get("window_title"), + }, + "status": "FINISH" if windows_result.ok and (not self.payload.get("window_title") or selected_window) else "FAIL", + }, + "plan": [], + "comment": "GUI 观察完成。" if windows_result.ok else windows_result.error, + "save_screenshot": { + "save": bool(screenshot_result.ok or (window_screenshot_result and window_screenshot_result.ok)), + "reason": "GUI P0 观察需要截图作为证据。" if screenshot_result.ok else screenshot_result.error, + }, + "result": "GUI 窗口和控件信息已记录。" if windows_result.ok else windows_result.error, + "model_used": False, + } + + success = windows_result.ok and (not self.payload.get("window_title") or bool(selected_window)) + message = "Agent GUI P0 observation completed." if success else "Requested GUI window was not found or could not be inspected." + duration = round(time.monotonic() - started, 3) + result = { + "success": success, + "message": message, + "task_type": self.payload.get("task_type") or "gui_p0", + "agent_framework": "ufo-inspired-lite", + "p0_mode": True, + "execution_mode": "gui", + "needs_user_input": False, + "duration_seconds": duration, + "action_duration_seconds": round( + windows_result.duration_seconds + + screenshot_result.duration_seconds + + (inspect_result.duration_seconds if inspect_result else 0) + + (window_screenshot_result.duration_seconds if window_screenshot_result else 0), + 3, + ), + "model_duration_seconds": self._model_duration(host_decision, app_decision), + "hierarchy": self.payload.get("hierarchy") or self._task_hierarchy(), + "host_decision": host_decision, + "app_decision": app_decision, + "observation": observation, + "available_tools": self.tools.descriptions(), + "memory": self.blackboard.summary(), + "logs": [ + *self.logs, + *(windows_result.error and [windows_result.error] or []), + *(screenshot_result.error and [screenshot_result.error] or []), + *(inspect_result and inspect_result.error and [inspect_result.error] or []), + ], + } + self.blackboard.append( + "trajectories", + { + "host_decision": host_decision, + "app_decision": app_decision, + "result": { + "success": success, + "message": message, + "duration_seconds": duration, + }, + }, + task_id=self.task_id, + ) + result["memory"] = self.blackboard.summary() + return result + + def _host_decision(self) -> dict[str, Any]: + fallback = { + "observation": "收到一个主控端分配的测试任务。", + "thought": "P0 阶段先把任务限定到单个 Web/AppAgent,执行无副作用观察,并按页面结构生成后续子任务。", + "current_subtask": self.payload.get("objective") or self.payload.get("instruction") or self.task.get("title") or "", + "message": ["只执行无副作用观察动作;遇到登录、验证码、权限或破坏性入口时暂停并回传。"], + "status": "ASSIGN", + "plan": ["观察目标页面", "记录页面元信息", "识别可继续拆分的链接、表单和入口"], + "function": "assign_app_agent", + "arguments": {"agent": "web_app_agent", "target_url": self.target_url()}, + "questions": [], + "result": "已分配给 Web AppAgent 执行 P0 观察。", + "model_used": False, + } + if not self.model.available: + return fallback + + context = { + "task": self._task_brief(), + "blackboard": self.blackboard.snapshot(limit=5), + "tools": self.tools.descriptions(), + } + response = self.model.chat_json( + [ + {"role": "system", "content": HOST_AGENT_SYSTEM_PROMPT}, + {"role": "user", "content": json.dumps(context, ensure_ascii=False)}, + ] + ) + if response.ok and isinstance(response.parsed, dict): + parsed = {**fallback, **response.parsed, "model_used": True, "model_duration_seconds": response.duration_seconds} + return parsed + self.logs.append(f"HostAgent model fallback: {response.error or 'invalid JSON response'}") + return {**fallback, "model_error": response.error, "model_duration_seconds": response.duration_seconds} + + def _app_decision(self, host_decision: dict[str, Any], observation_result: ToolResult) -> dict[str, Any]: + observation = self._observation_summary(observation_result.result) + fallback_status = "FINISH" if observation_result.ok else "FAIL" + if observation.get("requires_auth"): + fallback_status = "PENDING" + fallback = { + "observation": json.dumps(observation, ensure_ascii=False), + "thought": "P0 阶段采用 observe_web_page 工具完成可达性和页面结构观察,不执行提交、保存、删除等有副作用动作。", + "action": { + "function": "observe_web_page", + "arguments": {"target_url": self.target_url()}, + "status": fallback_status, + }, + "plan": [], + "comment": "页面观察完成。" if observation_result.ok else observation_result.error, + "save_screenshot": {"save": False, "reason": "P0 Web 观察暂不保存截图。"}, + "result": "页面可达性和结构信息已记录。" if observation_result.ok else observation_result.error, + "model_used": False, + } + if not self.model.available: + return fallback + + context = { + "host_decision": host_decision, + "task": self._task_brief(), + "observation": observation, + "available_tools": self.tools.descriptions(), + "recent_trajectories": self.blackboard.recent("trajectories", 5), + } + response = self.model.chat_json( + [ + {"role": "system", "content": APP_AGENT_SYSTEM_PROMPT}, + {"role": "user", "content": json.dumps(context, ensure_ascii=False)}, + ] + ) + if response.ok and isinstance(response.parsed, dict): + parsed = {**fallback, **response.parsed, "model_used": True, "model_duration_seconds": response.duration_seconds} + return parsed + self.logs.append(f"AppAgent model fallback: {response.error or 'invalid JSON response'}") + return {**fallback, "model_error": response.error, "model_duration_seconds": response.duration_seconds} + + def _model_discover_child_tasks(self, observation_result: ToolResult) -> tuple[dict[str, Any], list[dict[str, Any]]]: + base_analysis: dict[str, Any] = { + "model_available": self.model.available, + "model_called": False, + "model_call_succeeded": False, + "model_used": False, + "model_duration_seconds": 0, + "summary": "", + "coverage_notes": [], + "needs_user_input": False, + "user_input_reason": "", + "functional_task_count": 0, + } + if not observation_result.ok: + return {**base_analysis, "error": observation_result.error or "Page observation failed."}, [] + if not self.model.available: + self.logs.append("Model config is incomplete; used rule-based discovery fallback.") + return {**base_analysis, "error": "Model config is incomplete."}, [] + + observation = observation_result.result + context = { + "task": self._task_brief(), + "hierarchy": self.payload.get("hierarchy") or self._task_hierarchy(), + "page": self._observation_for_model(observation), + "recent_memory": self.blackboard.snapshot(limit=4), + "constraints": { + "p0_no_side_effects": True, + "max_functional_tasks": 8, + "discovery_max_depth": int(self.payload.get("discovery_max_depth") or 2), + }, + } + response = self.model.chat_json( + [ + {"role": "system", "content": SYSTEM_TEST_ANALYST_PROMPT}, + {"role": "user", "content": json.dumps(context, ensure_ascii=False)}, + ] + ) + if not response.ok or not isinstance(response.parsed, dict): + self.logs.append(f"SystemTestAnalyst model fallback: {response.error or 'invalid JSON response'}") + return { + **base_analysis, + "model_called": True, + "model_call_succeeded": False, + "model_used": False, + "model_duration_seconds": response.duration_seconds, + "error": response.error or "Model did not return a valid JSON object.", + }, [] + + parsed = response.parsed + children = self._children_from_model_analysis(parsed, observation) + analysis = { + **base_analysis, + "model_called": True, + "model_call_succeeded": True, + "model_used": True, + "model_duration_seconds": response.duration_seconds, + "summary": str(parsed.get("summary") or ""), + "coverage_notes": parsed.get("coverage_notes") if isinstance(parsed.get("coverage_notes"), list) else [], + "needs_user_input": bool(parsed.get("needs_user_input")), + "user_input_reason": str(parsed.get("user_input_reason") or ""), + "functional_task_count": len(children), + } + self.logs.append( + "Model analyzed page and generated " + f"{len(children)} functional task(s): {analysis.get('summary') or 'no summary'}" + ) + return analysis, children + + def _observation_for_model(self, observation: dict[str, Any]) -> dict[str, Any]: + def clean_items(items: Any, limit: int) -> list[dict[str, Any]]: + if not isinstance(items, list): + return [] + cleaned: list[dict[str, Any]] = [] + for item in items[:limit]: + if not isinstance(item, dict): + continue + cleaned.append( + { + key: value + for key, value in item.items() + if key in {"href", "text", "action", "method", "id", "name", "class", "type", "fields", "placeholder", "aria_label", "in_form"} + } + ) + return cleaned + + return { + "url": observation.get("url"), + "final_url": observation.get("final_url"), + "http_status": observation.get("http_status"), + "content_type": observation.get("content_type"), + "page_title": observation.get("page_title"), + "requires_auth": observation.get("requires_auth"), + "has_login_form": observation.get("has_login_form"), + "links": clean_items(observation.get("links"), 20), + "forms": clean_items(observation.get("forms"), 12), + "buttons": clean_items(observation.get("buttons"), 20), + } + + def _children_from_model_analysis(self, analysis: dict[str, Any], observation: dict[str, Any]) -> list[dict[str, Any]]: + raw_items = analysis.get("functional_tasks") + if not isinstance(raw_items, list): + return [] + max_children = 8 + children: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for index, item in enumerate(raw_items[:max_children], start=1): + if not isinstance(item, dict): + continue + child = self._child_from_model_item(item, observation, index) + key = ( + str(child.get("title") or "").strip().lower(), + str((child.get("payload") or {}).get("target_url") or "").strip().lower(), + ) + if key in seen: + continue + seen.add(key) + children.append(child) + return children + + def _child_from_model_item(self, item: dict[str, Any], observation: dict[str, Any], index: int) -> dict[str, Any]: + parent_external_id = str(self.task.get("external_id") or self.task.get("id") or "") + root_external_id = str(self.task.get("root_external_id") or self.payload.get("root_external_id") or parent_external_id) + parent_depth = int(self.task.get("depth") or self.payload.get("depth") or 0) + depth = parent_depth + 1 + discovery_max_depth = int(self.payload.get("discovery_max_depth") or 2) + base_url = observation.get("final_url") or observation.get("url") or self.target_url() + raw_target_url = str(item.get("target_url") or "").strip() + target_url = urljoin(base_url, raw_target_url) if raw_target_url else base_url + title = str(item.get("title") or f"模型生成测试任务 {index}").strip() + objective = str(item.get("objective") or title).strip() + module = str(item.get("module") or title).strip() + level = str(item.get("level") or "feature").strip().lower() + if level not in {"module", "feature", "case"}: + level = "feature" + risk = str(item.get("risk_level") or "low").strip().lower() + if risk not in {"low", "medium", "high"}: + risk = "low" + try: + priority = int(item.get("priority") or 30 + depth * 10 + index) + except (TypeError, ValueError): + priority = 30 + depth * 10 + index + preconditions = item.get("preconditions") if isinstance(item.get("preconditions"), list) else [] + external_id = self._stable_child_id(root_external_id, parent_external_id, title, target_url, objective) + payload = { + "task_type": "smart_discovery_p0", + "instruction": objective, + "objective": objective, + "target_url": target_url, + "source": "llm_analysis", + "llm_reason": item.get("reason") or "", + "level": level, + "depth": depth, + "risk_level": risk, + "allow_child_discovery": level in {"module", "feature"} and depth < discovery_max_depth, + "discovery_max_depth": discovery_max_depth, + "open_in_browser": True, + "capture_screenshot": True, + "preconditions": preconditions, + "hierarchy": { + "id": external_id, + "parent_id": parent_external_id, + "root_id": root_external_id, + "depth": depth, + "level": level, + }, + } + return { + "external_id": external_id, + "title": title, + "module": module, + "parent_external_id": parent_external_id, + "root_external_id": root_external_id, + "depth": depth, + "task_level": level, + "risk_level": risk, + "priority": priority, + "failure_policy": "pause" if risk in {"medium", "high"} or preconditions else "skip", + "max_retries": 2, + "timeout_seconds": 90, + "tags": ["llm-generated", "system-test", "p0"], + "payload": payload, + } + + def _stable_child_id(self, *parts: Any) -> str: + raw = "|".join(str(part or "") for part in parts) + return f"llm-{hashlib.sha1(raw.encode('utf-8')).hexdigest()[:12]}" + + def _allow_child_discovery(self, observation_result: ToolResult) -> bool: + if not observation_result.ok: + return False + allow = self.payload.get("allow_child_discovery") + if allow is not None: + return bool(allow) + if self.payload.get("task_type") != "smart_discovery_p0": + return False + level = str(self.payload.get("level") or self.task.get("task_level") or "") + depth = int(self.payload.get("depth") or self.task.get("depth") or 0) + return level in {"site", "module", ""} and depth < int(self.payload.get("discovery_max_depth") or 2) + + def _submit_child_tasks(self, children: list[dict[str, Any]]) -> list[dict[str, Any]]: + if not children or self.submit_task is None: + return [] + submitted: list[dict[str, Any]] = [] + plan_name = self.task.get("plan_name") or self.payload.get("plan_name") or "智能探索 P0" + for child in children: + payload = {**child, "plan_name": plan_name} + try: + created = self.submit_task(payload) + except Exception as exc: + self.logs.append(f"Submit child task failed: {child.get('external_id')} error={exc}") + continue + submitted.append( + { + "external_id": created.get("external_id") or child.get("external_id"), + "title": created.get("title") or child.get("title"), + "depth": created.get("depth") if created.get("depth") is not None else child.get("depth"), + "task_level": created.get("task_level") or child.get("task_level"), + } + ) + return submitted + + def _status_from_observation(self, observation_result: ToolResult, app_decision: dict[str, Any]) -> tuple[bool, str, bool]: + action = app_decision.get("action") if isinstance(app_decision.get("action"), dict) else {} + status = str(action.get("status") or "").upper() + if status == "PENDING": + return False, "Task needs user input before it can continue.", True + if status in {"FAIL", "CONFIRM"}: + return False, app_decision.get("result") or app_decision.get("comment") or "Agent action did not complete.", status == "CONFIRM" + if not observation_result.ok: + return False, observation_result.error or "Target observation failed.", False + if observation_result.result.get("http_status") in {401, 403}: + return False, "Target page requires authentication or permission.", True + return True, "Agent P0 observation completed.", False + + def _finish_without_target(self, started: float, host_decision: dict[str, Any]) -> dict[str, Any]: + task_type = self.payload.get("task_type") or "manual" + success = task_type != "smart_discovery_p0" + duration = round(time.monotonic() - started, 3) + result = { + "success": success, + "message": "No executable target URL was provided." if not success else "Task accepted; no executable URL was provided for P0 mode.", + "task_type": task_type, + "agent_framework": "ufo-inspired-lite", + "p0_mode": True, + "needs_user_input": not success, + "duration_seconds": duration, + "hierarchy": self.payload.get("hierarchy") or self._task_hierarchy(), + "host_decision": host_decision, + "app_decision": { + "action": {"function": "no_action", "arguments": {}, "status": "FAIL" if not success else "FINISH"}, + "result": "Missing target URL.", + }, + "available_tools": self.tools.descriptions(), + "memory": self.blackboard.summary(), + "logs": ["AgentSession requires target_url for Web P0 execution."], + } + self.blackboard.append("trajectories", {"host_decision": host_decision, "result": result}, task_id=self.task_id) + return result + + def _select_window(self, windows: list[dict[str, Any]]) -> dict[str, Any] | None: + window_id = self.payload.get("window_id") + if window_id: + for window in windows: + if str(window.get("id")) == str(window_id) or str(window.get("hex_id")).lower() == str(window_id).lower(): + return window + return None + + title = str(self.payload.get("window_title") or self.payload.get("application") or "").strip().lower() + if not title: + return None + for window in windows: + if title in str(window.get("title") or "").lower(): + return window + for window in windows: + if title in str(window.get("class_name") or "").lower(): + return window + return None + + def _gui_observation_summary( + self, + windows_result: ToolResult, + screenshot_result: ToolResult, + selected_window: dict[str, Any] | None, + inspect_result: ToolResult | None, + window_screenshot_result: ToolResult | None, + ) -> dict[str, Any]: + windows = windows_result.result.get("windows") if isinstance(windows_result.result, dict) else [] + controls = [] + if inspect_result and isinstance(inspect_result.result, dict): + controls = inspect_result.result.get("controls") or [] + return { + "backend": (windows_result.result or {}).get("backend") or self.tools.gui.backend_status(), + "window_count": len(windows or []), + "windows": [ + { + "id": window.get("id"), + "title": window.get("title"), + "class_name": window.get("class_name"), + "process_id": window.get("process_id"), + "rect": window.get("rect"), + "minimized": window.get("minimized"), + } + for window in (windows or [])[:20] + ], + "selected_window": selected_window, + "control_count": len(controls), + "controls": [ + { + "id": control.get("id"), + "text": control.get("text"), + "class_name": control.get("class_name"), + "rect": control.get("rect"), + "visible": control.get("visible"), + "enabled": control.get("enabled"), + } + for control in controls[:80] + ], + "desktop_screenshot": screenshot_result.result if screenshot_result.ok else None, + "window_screenshot": window_screenshot_result.result if window_screenshot_result and window_screenshot_result.ok else None, + "errors": [ + message + for message in [ + windows_result.error, + screenshot_result.error, + inspect_result.error if inspect_result else "", + window_screenshot_result.error if window_screenshot_result else "", + ] + if message + ], + } + + def _task_brief(self) -> dict[str, Any]: + return { + "id": self.task.get("id"), + "external_id": self.task.get("external_id"), + "title": self.task.get("title"), + "plan_name": self.task.get("plan_name"), + "module": self.task.get("module"), + "status": self.task.get("status"), + "depth": self.task.get("depth"), + "task_level": self.task.get("task_level"), + "risk_level": self.task.get("risk_level"), + "payload": self.payload, + } + + def _task_hierarchy(self) -> dict[str, Any]: + return { + "id": self.task.get("external_id") or self.task.get("id"), + "parent_id": self.task.get("parent_external_id"), + "root_id": self.task.get("root_external_id") or self.task.get("external_id") or self.task.get("id"), + "depth": self.task.get("depth") or 0, + "level": self.task.get("task_level") or self.payload.get("level") or "", + } + + def _observation_summary(self, observation: dict[str, Any]) -> dict[str, Any]: + return { + "url": observation.get("url"), + "final_url": observation.get("final_url"), + "http_status": observation.get("http_status"), + "content_type": observation.get("content_type"), + "page_title": observation.get("page_title"), + "requires_auth": observation.get("requires_auth"), + "auth_challenge": observation.get("auth_challenge"), + "has_login_form": observation.get("has_login_form"), + "link_count": len(observation.get("links") or []), + "form_count": len(observation.get("forms") or []), + "button_count": len(observation.get("buttons") or []), + } + + def _child_task_briefs(self, children: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + { + "external_id": child.get("external_id"), + "title": child.get("title"), + "parent_external_id": child.get("parent_external_id"), + "root_external_id": child.get("root_external_id"), + "depth": child.get("depth"), + "task_level": child.get("task_level"), + "risk_level": child.get("risk_level"), + "target_url": (child.get("payload") or {}).get("target_url"), + "source": (child.get("payload") or {}).get("source"), + "preconditions": (child.get("payload") or {}).get("preconditions") or [], + "llm_reason": (child.get("payload") or {}).get("llm_reason") or "", + } + for child in children + ] + + def _model_duration(self, host_decision: dict[str, Any], app_decision: dict[str, Any]) -> float: + return round( + float(host_decision.get("model_duration_seconds") or 0) + + float(app_decision.get("model_duration_seconds") or 0), + 3, + ) + + def _model_errors(self, *parts: dict[str, Any]) -> list[str]: + errors: list[str] = [] + for part in parts: + error = str(part.get("model_error") or part.get("error") or "").strip() + if error and error not in errors: + errors.append(error) + return errors diff --git a/SystemTest-Agent/app/agent_core/tools.py b/SystemTest-Agent/app/agent_core/tools.py new file mode 100644 index 0000000..b796c57 --- /dev/null +++ b/SystemTest-Agent/app/agent_core/tools.py @@ -0,0 +1,790 @@ +from __future__ import annotations + +import hashlib +import html +import platform +import re +import ssl +import time +import webbrowser +import ctypes +import ctypes.wintypes +from dataclasses import dataclass, field +from html.parser import HTMLParser +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urldefrag, urljoin, urlparse +from urllib.request import Request, urlopen + + +TITLE_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) +UNVERIFIED_HTTPS_CONTEXT = ssl._create_unverified_context() +MAX_HTML_BYTES = 1_500_000 +MAX_CHILD_TASKS = 8 + + +def network_error_message(exc: BaseException, timeout_seconds: float) -> str: + reason = getattr(exc, "reason", exc) + if isinstance(exc, TimeoutError) or isinstance(reason, TimeoutError): + return f"timed out after {timeout_seconds:g}s" + return str(reason) or type(reason).__name__ + + +@dataclass +class ToolResult: + ok: bool + result: dict[str, Any] = field(default_factory=dict) + error: str = "" + duration_seconds: float = 0.0 + + +def clean_text(value: Any, max_length: int = 160) -> str: + text = html.unescape(re.sub(r"\s+", " ", str(value or ""))).strip() + return text[:max_length].strip() + + +def decode_body(raw: bytes, content_type: str) -> str: + charset = "utf-8" + match = re.search(r"charset=([^;\s]+)", content_type or "", re.IGNORECASE) + if match: + charset = match.group(1).strip("\"'") + for candidate in [charset, "utf-8", "gbk", "latin-1"]: + try: + return raw.decode(candidate, errors="replace") + except LookupError: + continue + return raw.decode("utf-8", errors="replace") + + +def extract_title(raw: bytes, content_type: str) -> str: + text = decode_body(raw, content_type) + match = TITLE_RE.search(text) + if not match: + return "" + return clean_text(match.group(1), 120) + + +def normalize_url(value: Any) -> str: + text = str(value or "").strip() + if text and "://" not in text: + text = f"http://{text}" + return text + + +def origin_tuple(url: str) -> tuple[str, str, int]: + parsed = urlparse(url) + port = parsed.port or (443 if parsed.scheme == "https" else 80) + return parsed.scheme, (parsed.hostname or "").lower(), port + + +def same_origin(base_url: str, candidate_url: str) -> bool: + try: + return origin_tuple(base_url) == origin_tuple(candidate_url) + except ValueError: + return False + + +def safe_join_url(base_url: str, href: str) -> str | None: + text = str(href or "").strip() + if not text or text.startswith(("#", "javascript:", "mailto:", "tel:", "data:")): + return None + joined = urljoin(base_url, text) + joined, _fragment = urldefrag(joined) + parsed = urlparse(joined) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + return None + if not same_origin(base_url, joined): + return None + return joined + + +def stable_id(*parts: Any, prefix: str = "agent") -> str: + raw = "|".join(str(part or "") for part in parts) + return f"{prefix}-{hashlib.sha1(raw.encode('utf-8')).hexdigest()[:12]}" + + +def risk_for_text(text: str) -> str: + lowered = text.lower() + high = ["delete", "remove", "drop", "pay", "refund", "logout", "删除", "清空", "支付", "付款", "退款", "注销"] + medium = ["save", "submit", "create", "update", "edit", "upload", "保存", "提交", "新增", "修改", "上传"] + if any(word in lowered for word in high): + return "high" + if any(word in lowered for word in medium): + return "medium" + return "low" + + +class WebPageParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.title_parts: list[str] = [] + self.links: list[dict[str, Any]] = [] + self.forms: list[dict[str, Any]] = [] + self.buttons: list[dict[str, Any]] = [] + self._in_title = False + self._anchors: list[dict[str, Any]] = [] + self._form: dict[str, Any] | None = None + self._button: dict[str, Any] | None = None + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + tag = tag.lower() + attr = {name.lower(): value or "" for name, value in attrs} + if tag == "title": + self._in_title = True + elif tag == "a": + self._anchors.append({"href": attr.get("href", ""), "text_parts": []}) + elif tag == "form": + self._form = { + "action": attr.get("action", ""), + "method": (attr.get("method") or "get").upper(), + "id": attr.get("id", ""), + "name": attr.get("name", ""), + "class": attr.get("class", ""), + "text_parts": [], + "fields": [], + } + elif self._form is not None and tag in {"input", "select", "textarea"}: + self._form["fields"].append( + { + "tag": tag, + "type": (attr.get("type") or tag).lower(), + "name": attr.get("name", ""), + "id": attr.get("id", ""), + "placeholder": attr.get("placeholder", ""), + "aria_label": attr.get("aria-label", ""), + } + ) + elif tag == "button": + self._button = { + "type": attr.get("type", ""), + "id": attr.get("id", ""), + "name": attr.get("name", ""), + "class": attr.get("class", ""), + "text_parts": [], + "in_form": self._form is not None, + } + + def handle_data(self, data: str) -> None: + if self._in_title: + self.title_parts.append(data) + if self._anchors: + self._anchors[-1]["text_parts"].append(data) + if self._form is not None: + self._form["text_parts"].append(data) + if self._button is not None: + self._button["text_parts"].append(data) + + def handle_endtag(self, tag: str) -> None: + tag = tag.lower() + if tag == "title": + self._in_title = False + elif tag == "a" and self._anchors: + anchor = self._anchors.pop() + self.links.append({"href": anchor.get("href", ""), "text": clean_text(" ".join(anchor.get("text_parts") or []), 120)}) + elif tag == "button" and self._button is not None: + button = self._button + button["text"] = clean_text(" ".join(button.get("text_parts") or []), 120) + self.buttons.append(button) + self._button = None + elif tag == "form" and self._form is not None: + form = self._form + form["text"] = clean_text(" ".join(form.get("text_parts") or []), 160) + self.forms.append(form) + self._form = None + + +def looks_like_login_form(form: dict[str, Any]) -> bool: + fields = form.get("fields") or [] + text = " ".join( + [ + str(form.get("id") or ""), + str(form.get("name") or ""), + str(form.get("class") or ""), + str(form.get("text") or ""), + *[ + " ".join( + [ + str(field.get("type") or ""), + str(field.get("name") or ""), + str(field.get("id") or ""), + str(field.get("placeholder") or ""), + str(field.get("aria_label") or ""), + ] + ) + for field in fields + ], + ] + ).lower() + return "password" in text or any(keyword in text for keyword in ["login", "signin", "sign in", "登录", "账号", "密码"]) + + +class WebTools: + def open_in_browser(self, url: str, wait_seconds: float = 1.5) -> ToolResult: + target_url = normalize_url(url) + if not target_url: + return ToolResult(ok=False, error="Missing target URL.") + + started = time.monotonic() + try: + opened = bool(webbrowser.open(target_url, new=2, autoraise=True)) + if wait_seconds > 0: + time.sleep(min(max(wait_seconds, 0.0), 5.0)) + return ToolResult( + ok=opened, + result={"url": target_url, "opened": opened}, + error="" if opened else "Default browser did not accept the URL.", + duration_seconds=round(time.monotonic() - started, 3), + ) + except Exception as exc: + return ToolResult( + ok=False, + error=str(exc), + duration_seconds=round(time.monotonic() - started, 3), + ) + + def observe_page(self, url: str, timeout_seconds: float = 8) -> ToolResult: + target_url = normalize_url(url) + if not target_url: + return ToolResult(ok=False, error="Missing target URL.") + + request = Request( + target_url, + headers={ + "Accept": "text/html,application/xhtml+xml,*/*", + "User-Agent": "SystemTest-Agent/0.1 WebTools", + }, + ) + started = time.monotonic() + try: + with urlopen(request, timeout=timeout_seconds, context=UNVERIFIED_HTTPS_CONTEXT) as response: + raw = response.read(MAX_HTML_BYTES) + status = response.status + final_url = response.geturl() + content_type = response.headers.get("Content-Type", "") + except HTTPError as exc: + raw = exc.read(MAX_HTML_BYTES) + status = exc.code + final_url = exc.geturl() + content_type = exc.headers.get("Content-Type", "") + except (URLError, TimeoutError, OSError) as exc: + return ToolResult( + ok=False, + error=f"Target URL is not reachable: {network_error_message(exc, timeout_seconds)}", + duration_seconds=round(time.monotonic() - started, 3), + ) + + text = decode_body(raw, content_type) + parser = WebPageParser() + try: + parser.feed(text) + except Exception: + pass + title = clean_text(" ".join(parser.title_parts), 120) or extract_title(raw, content_type) + duration = round(time.monotonic() - started, 3) + has_login_form = any(looks_like_login_form(form) for form in parser.forms) + auth_challenge = status in {401, 403} + return ToolResult( + ok=status < 500, + duration_seconds=duration, + result={ + "url": target_url, + "final_url": final_url, + "http_status": status, + "content_type": content_type, + "page_title": title, + "elapsed_seconds": duration, + "links": parser.links[:50], + "forms": parser.forms[:20], + "buttons": parser.buttons[:50], + "requires_auth": auth_challenge, + "auth_challenge": auth_challenge, + "has_login_form": has_login_form, + }, + ) + + def discover_child_tasks( + self, + parent_task: dict[str, Any], + observation: dict[str, Any], + *, + max_children: int = MAX_CHILD_TASKS, + max_depth: int = 2, + ) -> list[dict[str, Any]]: + payload = parent_task.get("payload") if isinstance(parent_task.get("payload"), dict) else {} + parent_external_id = str(parent_task.get("external_id") or parent_task.get("id") or "") + root_external_id = str(parent_task.get("root_external_id") or payload.get("root_external_id") or parent_external_id) + parent_depth = int(parent_task.get("depth") or payload.get("depth") or 0) + discovery_max_depth = int(payload.get("discovery_max_depth") or max_depth) + child_depth = parent_depth + 1 + if child_depth > discovery_max_depth: + return [] + + page_url = observation.get("final_url") or observation.get("url") or payload.get("target_url") or "" + children: list[dict[str, Any]] = [] + seen_urls: set[str] = set() + + for link in observation.get("links") or []: + if len(children) >= max_children: + break + href = safe_join_url(page_url, str(link.get("href") or "")) + if not href or href in seen_urls: + continue + seen_urls.add(href) + label = clean_text(link.get("text"), 80) or urlparse(href).path.strip("/") or href + risk = risk_for_text(label) + children.append( + self._child_task( + parent_external_id, + root_external_id, + child_depth, + label, + f"探索页面:{label}", + f"访问并检查页面“{label}”是否可达,记录标题、状态码和可交互入口。", + href, + "module", + risk, + "html_link", + discovery_max_depth, + ) + ) + + for index, form in enumerate(observation.get("forms") or [], start=1): + if len(children) >= max_children: + break + action_url = safe_join_url(page_url, str(form.get("action") or "")) or page_url + is_login = looks_like_login_form(form) + label = "登录表单" if is_login else clean_text(form.get("name") or form.get("id") or form.get("text"), 60) or f"表单 {index}" + risk = "medium" if is_login else risk_for_text(label) + child = self._child_task( + parent_external_id, + root_external_id, + child_depth, + label, + f"识别功能:{label}", + f"分析{label}的输入项与提交入口;P0 只确认元素存在,不执行真实提交。", + action_url, + "feature", + risk, + "html_form", + discovery_max_depth, + ) + child["payload"]["preconditions"] = ["需要测试账号/密码"] if is_login else [] + children.append(child) + + for button in observation.get("buttons") or []: + if len(children) >= max_children: + break + label = clean_text(button.get("text") or button.get("name") or button.get("id"), 60) + if not label: + continue + risk = risk_for_text(label) + child = self._child_task( + parent_external_id, + root_external_id, + child_depth, + label, + f"识别入口:{label}", + f"确认入口“{label}”存在;高风险入口只记录,不执行破坏性动作。", + page_url, + "feature", + risk, + "html_button", + discovery_max_depth, + ) + if risk == "high": + child["failure_policy"] = "pause" + children.append(child) + + unique: dict[str, dict[str, Any]] = {} + for child in children: + unique.setdefault(str(child["external_id"]), child) + return list(unique.values())[:max_children] + + def _child_task( + self, + parent_external_id: str, + root_external_id: str, + depth: int, + module: str, + title: str, + objective: str, + target_url: str, + level: str, + risk: str, + source: str, + discovery_max_depth: int, + ) -> dict[str, Any]: + external_id = stable_id(root_external_id, parent_external_id, source, target_url, module) + payload = { + "task_type": "smart_discovery_p0", + "instruction": objective, + "objective": objective, + "target_url": target_url, + "source": source, + "level": level, + "depth": depth, + "risk_level": risk, + "allow_child_discovery": level == "module" and depth < discovery_max_depth, + "discovery_max_depth": discovery_max_depth, + "open_in_browser": True, + "capture_screenshot": True, + "hierarchy": { + "id": external_id, + "parent_id": parent_external_id, + "root_id": root_external_id, + "depth": depth, + "level": level, + }, + } + return { + "external_id": external_id, + "title": title, + "module": module, + "parent_external_id": parent_external_id, + "root_external_id": root_external_id, + "depth": depth, + "task_level": level, + "risk_level": risk, + "priority": 40 + depth * 10 + (40 if risk == "high" else 20 if risk == "medium" else 0), + "failure_policy": "pause" if risk == "high" else "skip", + "max_retries": 2, + "timeout_seconds": 60, + "tags": ["agent-discovered", "p0"], + "payload": payload, + } + + +class GuiTools: + def __init__(self) -> None: + self._user32 = ctypes.windll.user32 if self.available() else None + self._enum_proc_type = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM) if self.available() else None + if self._user32 and self._enum_proc_type: + self._user32.EnumWindows.argtypes = [ctypes.c_void_p, ctypes.wintypes.LPARAM] + self._user32.EnumWindows.restype = ctypes.c_bool + self._user32.EnumChildWindows.argtypes = [ctypes.wintypes.HWND, ctypes.c_void_p, ctypes.wintypes.LPARAM] + self._user32.EnumChildWindows.restype = ctypes.c_bool + + def available(self) -> bool: + return platform.system() == "Windows" and hasattr(ctypes, "windll") + + def backend_status(self) -> dict[str, Any]: + if not self.available(): + return { + "available": False, + "backend": "ctypes-win32", + "reason": "Windows GUI APIs are unavailable on this platform.", + } + return { + "available": True, + "backend": "ctypes-win32", + "capabilities": [ + "list_windows", + "inspect_window_controls", + "capture_desktop", + "capture_window", + "focus_window", + "click_window_point", + ], + } + + def list_windows(self, limit: int = 80) -> ToolResult: + started = time.monotonic() + if not self.available(): + return ToolResult(ok=False, error=self.backend_status()["reason"]) + try: + windows = self._enum_windows(limit=limit) + return ToolResult( + ok=True, + result={"windows": windows, "backend": self.backend_status()}, + duration_seconds=round(time.monotonic() - started, 3), + ) + except Exception as exc: + return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3)) + + def inspect_window(self, window_id: str, limit: int = 220) -> ToolResult: + started = time.monotonic() + if not self.available(): + return ToolResult(ok=False, error=self.backend_status()["reason"]) + try: + hwnd = self._parse_hwnd(window_id) + if not self._user32.IsWindow(hwnd): + return ToolResult(ok=False, error=f"Window not found: {window_id}") + return ToolResult( + ok=True, + result={ + "window": self._window_info(hwnd), + "controls": self._enum_child_windows(hwnd, limit=limit), + "backend": self.backend_status(), + }, + duration_seconds=round(time.monotonic() - started, 3), + ) + except Exception as exc: + return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3)) + + def capture_desktop(self, save_dir: Path, prefix: str = "desktop") -> ToolResult: + started = time.monotonic() + try: + from PIL import ImageGrab + + save_dir.mkdir(parents=True, exist_ok=True) + image = ImageGrab.grab(all_screens=True) + path = save_dir / f"{prefix}_{int(time.time() * 1000)}.png" + image.save(path) + return ToolResult( + ok=True, + result={"image_path": str(path), "width": image.width, "height": image.height}, + duration_seconds=round(time.monotonic() - started, 3), + ) + except Exception as exc: + return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3)) + + def capture_window(self, window_id: str, save_dir: Path, prefix: str = "window") -> ToolResult: + started = time.monotonic() + if not self.available(): + return ToolResult(ok=False, error=self.backend_status()["reason"]) + try: + from PIL import ImageGrab + + hwnd = self._parse_hwnd(window_id) + if not self._user32.IsWindow(hwnd): + return ToolResult(ok=False, error=f"Window not found: {window_id}") + rect = self._rect(hwnd) + if rect["width"] <= 0 or rect["height"] <= 0: + return ToolResult(ok=False, error=f"Window has invalid bounds: {window_id}") + save_dir.mkdir(parents=True, exist_ok=True) + image = ImageGrab.grab(bbox=(rect["left"], rect["top"], rect["right"], rect["bottom"])) + path = save_dir / f"{prefix}_{window_id}_{int(time.time() * 1000)}.png" + image.save(path) + return ToolResult( + ok=True, + result={ + "image_path": str(path), + "window": self._window_info(hwnd), + "width": image.width, + "height": image.height, + }, + duration_seconds=round(time.monotonic() - started, 3), + ) + except Exception as exc: + return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3)) + + def focus_window(self, window_id: str) -> ToolResult: + started = time.monotonic() + if not self.available(): + return ToolResult(ok=False, error=self.backend_status()["reason"]) + try: + hwnd = self._parse_hwnd(window_id) + if not self._user32.IsWindow(hwnd): + return ToolResult(ok=False, error=f"Window not found: {window_id}") + self._user32.ShowWindow(hwnd, 9) # SW_RESTORE + ok = bool(self._user32.SetForegroundWindow(hwnd)) + return ToolResult( + ok=ok, + result={"window": self._window_info(hwnd), "focused": ok}, + error="" if ok else "SetForegroundWindow returned false.", + duration_seconds=round(time.monotonic() - started, 3), + ) + except Exception as exc: + return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3)) + + def click_window_point(self, window_id: str, x: float, y: float, normalized: bool = True) -> ToolResult: + started = time.monotonic() + if not self.available(): + return ToolResult(ok=False, error=self.backend_status()["reason"]) + try: + hwnd = self._parse_hwnd(window_id) + if not self._user32.IsWindow(hwnd): + return ToolResult(ok=False, error=f"Window not found: {window_id}") + rect = self._rect(hwnd) + if normalized: + target_x = int(rect["left"] + max(0.0, min(1.0, float(x))) * rect["width"]) + target_y = int(rect["top"] + max(0.0, min(1.0, float(y))) * rect["height"]) + else: + target_x = int(x) + target_y = int(y) + self.focus_window(window_id) + self._user32.SetCursorPos(target_x, target_y) + self._user32.mouse_event(0x0002, 0, 0, 0, 0) # MOUSEEVENTF_LEFTDOWN + self._user32.mouse_event(0x0004, 0, 0, 0, 0) # MOUSEEVENTF_LEFTUP + return ToolResult( + ok=True, + result={"window_id": str(window_id), "x": target_x, "y": target_y, "normalized": normalized}, + duration_seconds=round(time.monotonic() - started, 3), + ) + except Exception as exc: + return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3)) + + def _parse_hwnd(self, window_id: str) -> int: + text = str(window_id).strip() + if text.lower().startswith("0x"): + return int(text, 16) + return int(text) + + def _window_text(self, hwnd: int, max_length: int = 512) -> str: + length = min(max(self._user32.GetWindowTextLengthW(hwnd), 0) + 1, max_length) + buffer = ctypes.create_unicode_buffer(length) + self._user32.GetWindowTextW(hwnd, buffer, length) + return clean_text(buffer.value, 200) + + def _class_name(self, hwnd: int) -> str: + buffer = ctypes.create_unicode_buffer(256) + self._user32.GetClassNameW(hwnd, buffer, 256) + return clean_text(buffer.value, 120) + + def _rect(self, hwnd: int) -> dict[str, int]: + rect = ctypes.wintypes.RECT() + self._user32.GetWindowRect(hwnd, ctypes.byref(rect)) + return { + "left": int(rect.left), + "top": int(rect.top), + "right": int(rect.right), + "bottom": int(rect.bottom), + "width": max(0, int(rect.right - rect.left)), + "height": max(0, int(rect.bottom - rect.top)), + } + + def _process_id(self, hwnd: int) -> int: + pid = ctypes.wintypes.DWORD() + self._user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + return int(pid.value) + + def _window_info(self, hwnd: int) -> dict[str, Any]: + return { + "id": str(int(hwnd)), + "hex_id": hex(int(hwnd)), + "title": self._window_text(hwnd), + "class_name": self._class_name(hwnd), + "process_id": self._process_id(hwnd), + "visible": bool(self._user32.IsWindowVisible(hwnd)), + "enabled": bool(self._user32.IsWindowEnabled(hwnd)), + "minimized": bool(self._user32.IsIconic(hwnd)), + "rect": self._rect(hwnd), + } + + def _enum_windows(self, limit: int = 80) -> list[dict[str, Any]]: + windows: list[dict[str, Any]] = [] + enum_proc = self._enum_proc_type + + @enum_proc + def callback(hwnd: int, _lparam: int) -> bool: + if len(windows) >= limit: + return False + if not self._user32.IsWindowVisible(hwnd): + return True + info = self._window_info(hwnd) + rect = info["rect"] + if rect["width"] < 40 or rect["height"] < 40: + return True + if not info["title"]: + return True + if info["title"] or info["class_name"]: + info["index"] = len(windows) + 1 + windows.append(info) + return True + + self._user32.EnumWindows(ctypes.cast(callback, ctypes.c_void_p), ctypes.wintypes.LPARAM(0)) + return windows + + def _enum_child_windows(self, hwnd: int, limit: int = 220) -> list[dict[str, Any]]: + controls: list[dict[str, Any]] = [] + enum_proc = self._enum_proc_type + + @enum_proc + def callback(child_hwnd: int, _lparam: int) -> bool: + if len(controls) >= limit: + return False + rect = self._rect(child_hwnd) + if rect["width"] <= 0 or rect["height"] <= 0: + return True + text = self._window_text(child_hwnd) + class_name = self._class_name(child_hwnd) + if not text and not class_name: + return True + controls.append( + { + "id": str(int(child_hwnd)), + "hex_id": hex(int(child_hwnd)), + "index": len(controls) + 1, + "text": text, + "class_name": class_name, + "visible": bool(self._user32.IsWindowVisible(child_hwnd)), + "enabled": bool(self._user32.IsWindowEnabled(child_hwnd)), + "rect": rect, + } + ) + return True + + self._user32.EnumChildWindows(hwnd, ctypes.cast(callback, ctypes.c_void_p), ctypes.wintypes.LPARAM(0)) + return controls + + +class AgentToolRegistry: + def __init__(self) -> None: + self.web = WebTools() + self.gui = GuiTools() + + def descriptions(self) -> list[dict[str, Any]]: + gui_status = self.gui.backend_status() + return [ + { + "name": "open_in_browser", + "scope": "web", + "available": True, + "description": "Open the target URL in the controlled device default browser so the operator can see the test start.", + }, + { + "name": "observe_web_page", + "scope": "web", + "available": True, + "description": "Open a web page, read status/title, and list links/forms/buttons without side effects.", + }, + { + "name": "discover_web_subtasks", + "scope": "web", + "available": True, + "description": "Create hierarchy-aware P0 child tasks from observed links/forms/buttons.", + }, + { + "name": "list_windows", + "scope": "gui", + "available": gui_status["available"], + "description": "List visible Windows desktop application windows using a dependency-free ctypes Win32 backend.", + }, + { + "name": "inspect_window_controls", + "scope": "gui", + "available": gui_status["available"], + "description": "Enumerate Win32 child controls for a selected window.", + }, + { + "name": "capture_desktop", + "scope": "gui", + "available": True, + "description": "Capture the desktop screenshot with PIL ImageGrab and save it as local evidence.", + }, + { + "name": "capture_window", + "scope": "gui", + "available": gui_status["available"], + "description": "Capture a selected window region and save it as local evidence.", + }, + { + "name": "focus_window", + "scope": "gui", + "available": gui_status["available"], + "description": "Bring a window to foreground. This is an action tool and should be used only when the task allows it.", + }, + { + "name": "click_window_point", + "scope": "gui", + "available": gui_status["available"], + "description": "Click a normalized or absolute point in a selected window. This is guarded and not used by default in P0 observation.", + }, + { + "name": "no_action", + "scope": "agent", + "available": True, + "description": "Finish, fail, or wait for user input without executing a UI action.", + }, + ] diff --git a/SystemTest-Agent/app/config.py b/SystemTest-Agent/app/config.py new file mode 100644 index 0000000..bd1bc75 --- /dev/null +++ b/SystemTest-Agent/app/config.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +ROOT_DIR = Path(__file__).resolve().parents[1] + + +def _load_env_file(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + if not path.exists(): + return values + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key: + values[key] = value + return values + + +def _get_env(name: str, default: str, file_values: dict[str, str]) -> str: + return os.environ.get(name, file_values.get(name, default)) + + +def _as_float(value: str, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _as_int(value: str, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _as_bool(value: str, default: bool) -> bool: + normalized = str(value).strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + return default + + +def _as_int_list(value: str, default: list[int]) -> list[int]: + ports: list[int] = [] + for part in str(value).replace(";", ",").split(","): + item = part.strip() + if not item: + continue + try: + port = int(item) + except ValueError: + continue + if 1 <= port <= 65535: + ports.append(port) + return ports or default + + +def _resolve_path(value: str) -> Path: + path = Path(value) + if path.is_absolute(): + return path + return ROOT_DIR / path + + +@dataclass(frozen=True) +class AgentConfig: + root_dir: Path + controller_url: str + controller_discovery_enabled: bool + controller_discovery_ports: list[int] + controller_discovery_timeout_seconds: float + state_path: Path + model_config_path: Path + runtime_state_path: Path + memory_path: Path + display_name: str + deployment_type: str + heartbeat_interval_seconds: float + task_poll_interval_seconds: float + controller_retry_interval_seconds: float + request_timeout_seconds: float + local_service_enabled: bool + local_service_host: str + local_service_port: int + runtime_command: str + + @classmethod + def from_env(cls) -> "AgentConfig": + file_values = _load_env_file(ROOT_DIR / ".env") + return cls( + root_dir=ROOT_DIR, + controller_url=_get_env("CONTROLLER_URL", "auto", file_values).rstrip("/"), + controller_discovery_enabled=_as_bool( + _get_env("AGENT_CONTROLLER_DISCOVERY_ENABLED", "true", file_values), + True, + ), + controller_discovery_ports=_as_int_list( + _get_env("AGENT_CONTROLLER_DISCOVERY_PORTS", "8001,8000", file_values), + [8001, 8000], + ), + controller_discovery_timeout_seconds=_as_float( + _get_env("AGENT_CONTROLLER_DISCOVERY_TIMEOUT_SECONDS", "0.35", file_values), + 0.35, + ), + state_path=_resolve_path(_get_env("AGENT_STATE_PATH", "data/agent_state.json", file_values)), + model_config_path=_resolve_path( + _get_env("AGENT_MODEL_CONFIG_PATH", "data/model_config.json", file_values) + ), + runtime_state_path=_resolve_path( + _get_env("AGENT_RUNTIME_STATE_PATH", "data/runtime_state.json", file_values) + ), + memory_path=_resolve_path( + _get_env("AGENT_MEMORY_PATH", "data/agent_memory.json", file_values) + ), + display_name=_get_env("AGENT_DISPLAY_NAME", "", file_values), + deployment_type=_get_env("AGENT_DEPLOYMENT_TYPE", "auto", file_values), + heartbeat_interval_seconds=_as_float( + _get_env("AGENT_HEARTBEAT_INTERVAL_SECONDS", "1", file_values), + 1.0, + ), + task_poll_interval_seconds=_as_float( + _get_env("AGENT_TASK_POLL_INTERVAL_SECONDS", "5", file_values), + 5.0, + ), + controller_retry_interval_seconds=_as_float( + _get_env("AGENT_CONTROLLER_RETRY_INTERVAL_SECONDS", "3", file_values), + 3.0, + ), + request_timeout_seconds=_as_float( + _get_env("AGENT_REQUEST_TIMEOUT_SECONDS", "5", file_values), + 5.0, + ), + local_service_enabled=_as_bool( + _get_env("AGENT_LOCAL_SERVICE_ENABLED", "true", file_values), + True, + ), + local_service_host=_get_env( + "AGENT_LOCAL_SERVICE_HOST", + _get_env("LOG_WEB_HOST", "0.0.0.0", file_values), + file_values, + ), + local_service_port=_as_int( + _get_env( + "AGENT_LOCAL_SERVICE_PORT", + _get_env("LOG_WEB_PORT", "18081", file_values), + file_values, + ), + 18081, + ), + runtime_command=_get_env("AGENT_RUNTIME_COMMAND", "", file_values), + ) diff --git a/SystemTest-Agent/app/controller_discovery.py b/SystemTest-Agent/app/controller_discovery.py new file mode 100644 index 0000000..c7f2cd5 --- /dev/null +++ b/SystemTest-Agent/app/controller_discovery.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import concurrent.futures +import ipaddress +import json +import socket +from urllib.error import URLError +from urllib.request import Request, urlopen + + +DISCOVERY_PATH = "/api/v1/discovery" +AUTO_VALUES = {"", "auto", "discover", "lan"} + + +def _is_private_ipv4(value: str) -> bool: + try: + ip = ipaddress.ip_address(value) + except ValueError: + return False + return ip.version == 4 and ip.is_private and not ip.is_loopback + + +def _local_private_ipv4s() -> list[str]: + addresses: set[str] = set() + try: + for value in socket.gethostbyname_ex(socket.gethostname())[2]: + if _is_private_ipv4(value): + addresses.add(value) + except OSError: + pass + + # UDP connect does not send packets; it reveals the chosen outbound address. + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect(("8.8.8.8", 80)) + value = sock.getsockname()[0] + if _is_private_ipv4(value): + addresses.add(value) + except OSError: + pass + + return sorted(addresses) + + +def _candidate_hosts() -> list[str]: + hosts: set[str] = {"127.0.0.1"} + for address in _local_private_ipv4s(): + hosts.add(address) + network = ipaddress.ip_network(f"{address}/24", strict=False) + for host in network.hosts(): + hosts.add(str(host)) + return sorted(hosts, key=lambda value: tuple(int(part) for part in value.split("."))) + + +def _probe(base_url: str, timeout_seconds: float) -> str | None: + url = f"{base_url.rstrip('/')}{DISCOVERY_PATH}" + request = Request(url, headers={"Accept": "application/json"}) + try: + with urlopen(request, timeout=timeout_seconds) as response: + body = response.read() + except (OSError, URLError): + return None + + try: + payload = json.loads(body.decode("utf-8")) + except json.JSONDecodeError: + return None + + if payload.get("service") != "Agent-S-Controller": + return None + discovered = str(payload.get("base_url") or base_url).rstrip("/") + return discovered or None + + +def discover_controller_base_url( + ports: list[int], + timeout_seconds: float, + workers: int = 96, + log=print, +) -> str | None: + hosts = _candidate_hosts() + attempts = [(host, port) for port in ports for host in hosts] + if not attempts: + return None + + log( + "[agent] discovering controller on " + + f"{len(hosts)} hosts x ports {', '.join(str(p) for p in ports)} ..." + ) + with concurrent.futures.ThreadPoolExecutor(max_workers=min(workers, len(attempts))) as executor: + future_map = { + executor.submit(_probe, f"http://{host}:{port}", timeout_seconds): (host, port) + for host, port in attempts + } + for future in concurrent.futures.as_completed(future_map): + found = future.result() + if found: + host, port = future_map[future] + log(f"[agent] discovered controller at {found} ({host}:{port})") + for pending in future_map: + pending.cancel() + return found + return None + + +def resolve_controller_base_url( + configured_base_url: str, + discovery_enabled: bool, + discovery_ports: list[int], + timeout_seconds: float, + log=print, +) -> str: + configured = (configured_base_url or "auto").strip().rstrip("/") + if configured.lower() not in AUTO_VALUES: + found = _probe(configured, timeout_seconds) + if found: + return found + if not discovery_enabled: + return configured + log(f"[agent] configured controller is not reachable yet: {configured}") + + if discovery_enabled: + found = discover_controller_base_url( + ports=discovery_ports, + timeout_seconds=timeout_seconds, + log=log, + ) + if found: + return found + + ports = ", ".join(str(port) for port in discovery_ports) + raise RuntimeError( + "Controller was not found. Start the controller first, allow TCP " + f"{ports} through the firewall, or start the agent with " + "start_agent.bat http://:8001" + ) diff --git a/SystemTest-Agent/app/http_client.py b/SystemTest-Agent/app/http_client.py new file mode 100644 index 0000000..520ba7f --- /dev/null +++ b/SystemTest-Agent/app/http_client.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +@dataclass +class HttpClientError(RuntimeError): + status: int + message: str + payload: dict[str, Any] | None = None + + def __str__(self) -> str: + return f"HTTP {self.status}: {self.message}" + + +def _network_error_message(exc: BaseException, timeout: float) -> str: + reason = getattr(exc, "reason", exc) + if isinstance(exc, TimeoutError) or isinstance(reason, TimeoutError): + return f"timed out after {timeout:g}s" + return str(reason) or type(reason).__name__ + + +def request_json( + method: str, + url: str, + payload: dict[str, Any] | None = None, + token: str | None = None, + timeout: float = 5.0, +) -> dict[str, Any]: + body = None + headers = {"Accept": "application/json"} + if payload is not None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json; charset=utf-8" + if token: + headers["Authorization"] = f"Bearer {token}" + + request = Request(url, data=body, headers=headers, method=method.upper()) + try: + with urlopen(request, timeout=timeout) as response: + response_body = response.read() + if not response_body: + return {} + return json.loads(response_body.decode("utf-8")) + except HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + payload_obj = None + message = raw or exc.reason + try: + payload_obj = json.loads(raw) + message = payload_obj.get("error") or message + except json.JSONDecodeError: + pass + raise HttpClientError(exc.code, message, payload_obj) from exc + except (URLError, TimeoutError, OSError) as exc: + raise HttpClientError(0, _network_error_message(exc, timeout), None) from exc diff --git a/SystemTest-Agent/app/identity.py b/SystemTest-Agent/app/identity.py new file mode 100644 index 0000000..34535cb --- /dev/null +++ b/SystemTest-Agent/app/identity.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import hashlib +import json +import platform +import socket +import subprocess +import uuid +from typing import Any + + +VM_MARKERS = ( + "virtual", + "vmware", + "hyper-v", + "hyperv", + "kvm", + "qemu", + "xen", + "virtualbox", + "parallels", + "bochs", +) + + +def host_name() -> str: + return socket.gethostname() + + +def machine_fingerprint() -> str: + material = { + "host": host_name(), + "platform": platform.platform(), + "machine": platform.machine(), + "mac": f"{uuid.getnode():012x}", + } + encoded = json.dumps(material, sort_keys=True).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def public_id() -> str: + return f"agent-{machine_fingerprint()[:16]}" + + +def _read_windows_computer_system() -> dict[str, Any]: + command = [ + "powershell", + "-NoProfile", + "-Command", + "Get-CimInstance Win32_ComputerSystem | " + "Select-Object Manufacturer,Model,HypervisorPresent | ConvertTo-Json -Compress", + ] + try: + completed = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + timeout=3, + ) + except (OSError, subprocess.SubprocessError): + return {} + try: + data = json.loads(completed.stdout.strip() or "{}") + except json.JSONDecodeError: + return {} + return data if isinstance(data, dict) else {} + + +def deployment_info(override: str) -> dict[str, Any]: + normalized = (override or "auto").strip().lower() + aliases = { + "physical": "physical_machine", + "physical_machine": "physical_machine", + "box_physical": "physical_machine", + "virtual": "virtual_machine", + "vm": "virtual_machine", + "virtual_machine": "virtual_machine", + "box_virtual": "virtual_machine", + } + if normalized in aliases: + return { + "deployment_type": aliases[normalized], + "detection_reason": f"env:{override}", + "system": {}, + } + + system_data = _read_windows_computer_system() if platform.system().lower() == "windows" else {} + manufacturer = str(system_data.get("Manufacturer") or "") + model = str(system_data.get("Model") or "") + hypervisor_present = bool(system_data.get("HypervisorPresent")) + probe_text = f"{manufacturer} {model} {platform.platform()}".lower() + is_virtual = hypervisor_present or any(marker in probe_text for marker in VM_MARKERS) + return { + "deployment_type": "virtual_machine" if is_virtual else "physical_machine", + "detection_reason": "auto:virtualization-marker" if is_virtual else "auto:no-virtualization-marker", + "system": { + "manufacturer": manufacturer, + "model": model, + "hypervisor_present": hypervisor_present, + "platform": platform.platform(), + "machine": platform.machine(), + }, + } diff --git a/SystemTest-Agent/app/local_service.py b/SystemTest-Agent/app/local_service.py new file mode 100644 index 0000000..d5e686c --- /dev/null +++ b/SystemTest-Agent/app/local_service.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import json +import mimetypes +import sys +import threading +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, Callable +from urllib.parse import parse_qs, urlparse + + +StatusProvider = Callable[[], dict[str, Any]] + + +def _json_bytes(payload: Any) -> bytes: + return json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") + + +def make_handler(status_provider: StatusProvider): + class AgentLocalServiceHandler(BaseHTTPRequestHandler): + server_version = "SystemTestAgentLocalService/0.1" + + def log_message(self, fmt: str, *args: Any) -> None: + sys.stdout.write( + "%s - - [%s] %s\n" + % (self.client_address[0], self.log_date_time_string(), fmt % args) + ) + + def _send_json(self, status: int, payload: Any) -> None: + body = _json_bytes(payload) + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") + self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") + self.end_headers() + self.wfile.write(body) + + def _send_file(self, file_path: Path) -> None: + body = file_path.read_bytes() + content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream" + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + + def _send_screenshot(self, raw_path: str) -> None: + status = status_provider() + memory_path = ((status.get("agent_core") or {}).get("memory_path") or "") + if not memory_path: + self._send_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "Memory path is not available"}) + return + screenshot_root = (Path(memory_path).parent / "screenshots").resolve() + requested = Path(raw_path).resolve() + try: + requested.relative_to(screenshot_root) + except ValueError: + self._send_json(HTTPStatus.FORBIDDEN, {"ok": False, "error": "Screenshot path is outside evidence directory"}) + return + if not requested.exists() or not requested.is_file(): + self._send_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "Screenshot not found"}) + return + self._send_file(requested) + + def do_OPTIONS(self) -> None: + self._send_json(HTTPStatus.OK, {"ok": True}) + + def do_GET(self) -> None: + parsed = urlparse(self.path) + path = parsed.path + if path in {"/", "/health"}: + self._send_json(HTTPStatus.OK, status_provider()) + return + if path == "/agent/tools": + status = status_provider() + self._send_json( + HTTPStatus.OK, + { + "ok": True, + "tools": ((status.get("agent_core") or {}).get("tools") or []), + }, + ) + return + if path == "/agent/memory": + status = status_provider() + agent_core = status.get("agent_core") or {} + self._send_json( + HTTPStatus.OK, + { + "ok": True, + "memory_path": agent_core.get("memory_path"), + "memory": agent_core.get("memory"), + }, + ) + return + if path == "/agent/screenshot": + query = parse_qs(parsed.query) + raw_path = (query.get("path") or [""])[0] + if not raw_path: + self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "Missing path"}) + return + self._send_screenshot(raw_path) + return + self._send_json( + HTTPStatus.NOT_FOUND, + {"ok": False, "error": "Route not found"}, + ) + + return AgentLocalServiceHandler + + +class LocalService: + def __init__(self, host: str, port: int, status_provider: StatusProvider): + self.host = host + self.port = port + self._server = ThreadingHTTPServer((host, port), make_handler(status_provider)) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + + def start(self) -> None: + self._thread.start() + + def stop(self) -> None: + self._server.shutdown() + self._server.server_close() + self._thread.join(timeout=2) diff --git a/SystemTest-Agent/app/model_config.py b/SystemTest-Agent/app/model_config.py new file mode 100644 index 0000000..e0c5cd2 --- /dev/null +++ b/SystemTest-Agent/app/model_config.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import json +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def _load_json(path: Path, default: dict[str, Any]) -> dict[str, Any]: + if not path.exists(): + return default + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return default + return data if isinstance(data, dict) else default + + +def _save_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") + + +def load_applied_model_config(path: Path) -> dict[str, Any]: + return _load_json(path, {"version": 0, "value": {}, "updated_at": None, "applied_at": None}) + + +def save_applied_model_config(path: Path, payload: dict[str, Any]) -> None: + _save_json(path, payload) + + +def public_model_config_summary(config: dict[str, Any]) -> dict[str, Any]: + value = dict(config.get("value") or {}) + value["api_key"] = "" + value["api_key_set"] = bool(value.get("api_key_set") or (config.get("value") or {}).get("api_key")) + return { + "version": int(config.get("version") or 0), + "updated_at": config.get("updated_at"), + "applied_at": config.get("applied_at"), + "value": value, + } + + +class RuntimeRestartManager: + def __init__(self, path: Path, command: str = ""): + self.path = path + self.command = command.strip() + self.process: subprocess.Popen | None = None + self.state = _load_json( + path, + { + "generation": 0, + "last_restart_at": None, + "reason": "", + "model_config_version": 0, + "mode": "placeholder", + }, + ) + + @property + def generation(self) -> int: + try: + return int(self.state.get("generation") or 0) + except (TypeError, ValueError): + return 0 + + def _start_process(self) -> int | None: + if not self.command: + return None + if self.process and self.process.poll() is None: + return self.process.pid + self.process = subprocess.Popen( + self.command, + cwd=str(self.path.parent.parent), + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return self.process.pid + + def stop(self) -> None: + if not self.process or self.process.poll() is not None: + return + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=5) + + def ensure_started(self, version: int) -> dict[str, Any] | None: + if not self.command: + return None + pid = self._start_process() + now = utc_now() + self.state = { + "generation": self.generation, + "last_restart_at": self.state.get("last_restart_at"), + "reason": self.state.get("reason") or "startup", + "model_config_version": version, + "mode": "subprocess", + "pid": pid, + "command_configured": True, + "checked_at": now, + } + _save_json(self.path, self.state) + return self.state + + def restart_for_model_config(self, version: int) -> dict[str, Any]: + now = utc_now() + self.stop() + pid = self._start_process() + self.state = { + "generation": self.generation + 1, + "last_restart_at": now, + "reason": f"model_config_v{version}", + "model_config_version": version, + "mode": "subprocess" if self.command else "placeholder", + "pid": pid, + "command_configured": bool(self.command), + } + _save_json(self.path, self.state) + return self.state diff --git a/SystemTest-Agent/app/runner.py b/SystemTest-Agent/app/runner.py new file mode 100644 index 0000000..839b6fd --- /dev/null +++ b/SystemTest-Agent/app/runner.py @@ -0,0 +1,509 @@ +from __future__ import annotations + +import argparse +import json +import socket +import sys +import time +import traceback +from datetime import datetime, timezone +from typing import Any + +from . import __version__ +from .agent_core import AgentSession, AgentToolRegistry +from .config import AgentConfig +from .controller_discovery import resolve_controller_base_url +from .http_client import HttpClientError, request_json +from .identity import deployment_info, host_name, machine_fingerprint, public_id +from .local_service import LocalService +from .model_config import ( + RuntimeRestartManager, + load_applied_model_config, + public_model_config_summary, + save_applied_model_config, +) +from .state import load_state, save_state + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def local_ip() -> str: + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect(("8.8.8.8", 80)) + return sock.getsockname()[0] + except OSError: + return "127.0.0.1" + + +class ControlledAgent: + def __init__(self, config: AgentConfig): + self.config = AgentConfig( + root_dir=config.root_dir, + controller_url=config.controller_url, + controller_discovery_enabled=config.controller_discovery_enabled, + controller_discovery_ports=config.controller_discovery_ports, + controller_discovery_timeout_seconds=config.controller_discovery_timeout_seconds, + state_path=config.state_path, + model_config_path=config.model_config_path, + runtime_state_path=config.runtime_state_path, + memory_path=config.memory_path, + display_name=config.display_name, + deployment_type=config.deployment_type, + heartbeat_interval_seconds=config.heartbeat_interval_seconds, + task_poll_interval_seconds=config.task_poll_interval_seconds, + controller_retry_interval_seconds=config.controller_retry_interval_seconds, + request_timeout_seconds=config.request_timeout_seconds, + local_service_enabled=config.local_service_enabled, + local_service_host=config.local_service_host, + local_service_port=config.local_service_port, + runtime_command=config.runtime_command, + ) + self.controller_url: str | None = None + self.last_connection_error: str | None = None + self.controller_retry_count = 0 + self.runtime_started = False + self.registration_confirmed = False + self.fingerprint = machine_fingerprint() + self.public_id = public_id() + self.host = host_name() + self.ip = local_ip() + self.started_at = utc_now() + self.last_heartbeat_at: str | None = None + self.last_task_claim_at: str | None = None + self.current_status = "idle" + self.local_service: LocalService | None = None + self.deployment = deployment_info(config.deployment_type) + self.display_name = config.display_name or self.host + self.state = load_state(config.state_path) + self.model_config_state = load_applied_model_config(config.model_config_path) + self.runtime_manager = RuntimeRestartManager(config.runtime_state_path, config.runtime_command) + self.tool_registry = AgentToolRegistry() + print( + "[agent] startup config: " + f"controller={self.config.controller_url} " + f"local_service={self.config.local_service_host}:{self.config.local_service_port} " + f"state={self.config.state_path} " + f"model_config={self.config.model_config_path}" + ) + + def _url(self, path: str) -> str: + if not self.controller_url: + raise RuntimeError("Controller is not connected yet") + return f"{self.controller_url}{path}" + + def resolve_controller(self) -> str: + resolved = resolve_controller_base_url( + configured_base_url=self.config.controller_url, + discovery_enabled=self.config.controller_discovery_enabled, + discovery_ports=self.config.controller_discovery_ports, + timeout_seconds=self.config.controller_discovery_timeout_seconds, + ) + if self.controller_url != resolved: + print(f"[agent] controller connected: {resolved}") + self.controller_url = resolved + self.last_connection_error = None + self.controller_retry_count = 0 + return resolved + + def ensure_registered(self) -> None: + current_public_id = self.state.get("public_id") + has_credentials = self.state.get("agent_id") and self.state.get("token") + if has_credentials and current_public_id == self.public_id: + if not self.registration_confirmed: + print( + f"Using saved registration: agent_id={self.state['agent_id']} " + f"public_id={self.public_id}" + ) + self.registration_confirmed = True + return + + payload = { + "public_id": self.public_id, + "display_name": self.display_name, + "deployment_type": self.deployment["deployment_type"], + "machine_fingerprint": self.fingerprint, + "reported_host": self.host, + "reported_ip": self.ip, + "reported_log_web_port": self.config.local_service_port + if self.config.local_service_enabled + else None, + "agent_version": __version__, + "metadata": { + "detection_reason": self.deployment["detection_reason"], + "system": self.deployment["system"], + }, + } + print(f"Registering controlled agent at {self.controller_url} ...") + response = request_json( + "POST", + self._url("/api/agents/register"), + payload, + timeout=self.config.request_timeout_seconds, + ) + self.state = { + "agent_id": response["agent_id"], + "public_id": response["public_id"], + "token": response["token"], + "registered_at": response["registered_at"], + "controller_url": self.controller_url, + } + save_state(self.config.state_path, self.state) + self.registration_confirmed = True + if response.get("token_rotated"): + print( + "Recovered registration with a new token: " + f"agent_id={response['agent_id']} state={self.config.state_path}" + ) + else: + print(f"Registered: agent_id={response['agent_id']} state={self.config.state_path}") + + def heartbeat_payload(self) -> dict[str, Any]: + return { + "status": self.current_status, + "display_name": self.display_name, + "deployment_type": self.deployment["deployment_type"], + "machine_fingerprint": self.fingerprint, + "reported_host": self.host, + "reported_ip": self.ip, + "reported_log_web_port": self.config.local_service_port + if self.config.local_service_enabled + else None, + "agent_version": __version__, + "config_revision": int(self.model_config_state.get("version") or 0), + "runtime_generation": self.runtime_manager.generation, + "started_at": self.started_at, + "sent_at": utc_now(), + "metadata": { + "detection_reason": self.deployment["detection_reason"], + "system": self.deployment["system"], + }, + } + + def send_heartbeat(self) -> None: + agent_id = int(self.state["agent_id"]) + response = request_json( + "POST", + self._url(f"/api/agents/{agent_id}/heartbeat"), + self.heartbeat_payload(), + token=str(self.state["token"]), + timeout=self.config.request_timeout_seconds, + ) + self.last_heartbeat_at = response.get("received_at") + self.apply_model_config_update(response.get("model_config")) + print(f"Heartbeat OK: {response.get('received_at')}") + + def apply_model_config_update(self, model_config: Any) -> None: + if not isinstance(model_config, dict): + return + version = int(model_config.get("version") or 0) + current_version = int(self.model_config_state.get("version") or 0) + if version <= current_version: + return + payload = { + "version": version, + "updated_at": model_config.get("updated_at"), + "applied_at": utc_now(), + "value": model_config.get("value") or {}, + } + save_applied_model_config(self.config.model_config_path, payload) + self.model_config_state = payload + restart_state = self.runtime_manager.restart_for_model_config(version) + print( + "[agent] applied model config " + f"v{version}; runtime restart generation={restart_state.get('generation')}" + ) + + def ensure_runtime_started(self) -> None: + state = self.runtime_manager.ensure_started(int(self.model_config_state.get("version") or 0)) + if state: + print( + "[agent] runtime started " + f"generation={state.get('generation')} mode={state.get('mode')}" + ) + + def submit_child_task(self, payload: dict[str, Any]) -> dict[str, Any]: + response = request_json( + "POST", + self._url("/api/tasks"), + payload, + timeout=self.config.request_timeout_seconds, + ) + return response.get("task") or {} + + def task_model_config_state(self) -> dict[str, Any]: + state = dict(self.model_config_state) + value = dict(state.get("value") or {}) + if self.state.get("agent_id") and self.state.get("token"): + value["controller_model_proxy_url"] = self._url( + f"/api/agents/{int(self.state['agent_id'])}/model/chat-json" + ) + value["controller_model_proxy_token"] = str(self.state["token"]) + value["controller_model_proxy_enabled"] = True + state["value"] = value + return state + + def execute_task(self, task: dict[str, Any]) -> dict[str, Any]: + session = AgentSession( + task=task, + model_config_state=self.task_model_config_state(), + memory_path=self.config.memory_path, + request_timeout_seconds=self.config.request_timeout_seconds, + submit_task=self.submit_child_task, + ) + return session.run() + + def task_execution_error_result(self, task: dict[str, Any], exc: Exception, started: float) -> dict[str, Any]: + payload = task.get("payload") if isinstance(task.get("payload"), dict) else {} + model_state = self.task_model_config_state() + model_value = model_state.get("value") if isinstance(model_state.get("value"), dict) else {} + trace_lines = traceback.format_exc().splitlines() + message = f"Controlled agent task execution failed: {exc}" + return { + "success": False, + "message": message, + "task_type": payload.get("task_type") or "manual", + "agent_framework": "ufo-inspired-lite", + "p0_mode": True, + "needs_user_input": True, + "target_url": payload.get("target_url") or payload.get("url") or "", + "duration_seconds": round(time.monotonic() - started, 3), + "action_duration_seconds": 0, + "model_duration_seconds": 0, + "model_available": bool( + model_value.get("controller_model_proxy_url") + or (model_value.get("vllm_base_url") and model_value.get("vllm_model")) + ), + "model_errors": [message], + "exception_type": type(exc).__name__, + "logs": [ + "Task execution raised an exception before completion.", + *trace_lines[-25:], + ], + } + + def report_task_result(self, task_id: int, result: dict[str, Any]) -> dict[str, Any]: + agent_id = int(self.state["agent_id"]) + return request_json( + "POST", + self._url(f"/api/agents/{agent_id}/tasks/{task_id}/result"), + result, + token=str(self.state["token"]), + timeout=self.config.request_timeout_seconds, + ) + + def claim_task(self) -> dict[str, Any] | None: + agent_id = int(self.state["agent_id"]) + response = request_json( + "POST", + self._url(f"/api/agents/{agent_id}/tasks/claim"), + {}, + token=str(self.state["token"]), + timeout=self.config.request_timeout_seconds, + ) + task = response.get("task") + if task: + self.last_task_claim_at = utc_now() + print(f"Claimed task: id={task.get('id')} external_id={task.get('external_id')}") + self.current_status = "running" + task_started = time.monotonic() + try: + try: + self.send_heartbeat() + except Exception as exc: + print(f"[agent] running heartbeat failed: {exc}") + try: + result = self.execute_task(task) + except Exception as exc: + result = self.task_execution_error_result(task, exc, task_started) + print(result["message"], file=sys.stderr) + self.report_task_result(int(task["id"]), result) + print( + "Reported task result: " + f"id={task.get('id')} success={result.get('success')} message={result.get('message')}" + ) + finally: + self.current_status = "idle" + else: + print("No ready task.") + return task + + def local_status(self) -> dict[str, Any]: + return { + "ok": True, + "service": "controlled-agent", + "version": __version__, + "configured_controller_url": self.config.controller_url, + "controller_url": self.controller_url, + "controller_connected": bool(self.controller_url), + "last_connection_error": self.last_connection_error, + "public_id": self.public_id, + "agent_id": self.state.get("agent_id"), + "deployment_type": self.deployment["deployment_type"], + "display_name": self.display_name, + "reported_host": self.host, + "reported_ip": self.ip, + "started_at": self.started_at, + "last_heartbeat_at": self.last_heartbeat_at, + "last_task_claim_at": self.last_task_claim_at, + "model_config": public_model_config_summary(self.model_config_state), + "runtime": self.runtime_manager.state, + "agent_core": { + "framework": "ufo-inspired-lite", + "memory_path": str(self.config.memory_path), + "memory": self.agent_memory_status(), + "tools": self.tool_registry.descriptions(), + }, + "local_service": { + "enabled": self.config.local_service_enabled, + "host": self.config.local_service_host, + "port": self.config.local_service_port, + }, + } + + def agent_memory_status(self) -> dict[str, Any]: + if not self.config.memory_path.exists(): + return {"exists": False, "counts": {}} + try: + data = json.loads(self.config.memory_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return {"exists": True, "error": str(exc), "counts": {}} + if not isinstance(data, dict): + return {"exists": True, "error": "Memory file is not an object.", "counts": {}} + return { + "exists": True, + "counts": { + key: len(value) if isinstance(value, list) else 0 + for key, value in data.items() + }, + } + + def start_local_service(self) -> None: + if not self.config.local_service_enabled: + return + self.local_service = LocalService( + self.config.local_service_host, + self.config.local_service_port, + self.local_status, + ) + self.local_service.start() + print( + "Controlled agent local service started at " + f"http://127.0.0.1:{self.config.local_service_port}/health" + ) + + def run(self, once: bool = False) -> None: + self.start_local_service() + try: + while True: + try: + if not self.controller_url: + self.resolve_controller() + self.ensure_registered() + if not self.runtime_started: + self.ensure_runtime_started() + self.runtime_started = True + self.send_heartbeat() + self.claim_task() + if once: + return + time.sleep(min(self.config.heartbeat_interval_seconds, self.config.task_poll_interval_seconds)) + except (RuntimeError, HttpClientError, OSError) as exc: + if once: + raise + self.handle_controller_error(exc) + time.sleep(max(1.0, self.config.controller_retry_interval_seconds)) + finally: + self.runtime_manager.stop() + if once and self.local_service: + self.local_service.stop() + + def handle_controller_error(self, exc: Exception) -> None: + message = str(exc) + self.last_connection_error = message + self.controller_retry_count += 1 + + if isinstance(exc, HttpClientError) and exc.status in {401, 404}: + print( + "[agent] saved controller credentials are no longer valid; " + "clearing local registration and retrying." + ) + self.state = {} + self.registration_confirmed = False + save_state(self.config.state_path, self.state) + + if not isinstance(exc, HttpClientError) or exc.status in {0, 401, 404, 409} or exc.status >= 500: + self.controller_url = None + + if self.controller_retry_count == 1 or self.controller_retry_count % 10 == 0: + print( + "[agent] controller unavailable; agent stays running and will keep scanning. " + f"retry={self.controller_retry_count} error={message}" + ) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="System test controlled agent") + parser.add_argument("--once", action="store_true", help="register, heartbeat, claim once, then exit") + parser.add_argument("--controller-url", help="override CONTROLLER_URL") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> None: + args = parse_args(argv or sys.argv[1:]) + config = AgentConfig.from_env() + if args.controller_url: + config = AgentConfig( + root_dir=config.root_dir, + controller_url=args.controller_url.rstrip("/"), + controller_discovery_enabled=config.controller_discovery_enabled, + controller_discovery_ports=config.controller_discovery_ports, + controller_discovery_timeout_seconds=config.controller_discovery_timeout_seconds, + state_path=config.state_path, + model_config_path=config.model_config_path, + runtime_state_path=config.runtime_state_path, + memory_path=config.memory_path, + display_name=config.display_name, + deployment_type=config.deployment_type, + heartbeat_interval_seconds=config.heartbeat_interval_seconds, + task_poll_interval_seconds=config.task_poll_interval_seconds, + controller_retry_interval_seconds=config.controller_retry_interval_seconds, + request_timeout_seconds=config.request_timeout_seconds, + local_service_enabled=config.local_service_enabled, + local_service_host=config.local_service_host, + local_service_port=config.local_service_port, + runtime_command=config.runtime_command, + ) + + try: + agent = ControlledAgent(config) + agent.run(once=args.once) + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + raise SystemExit(2) from exc + except OSError as exc: + print( + "Local controlled-agent service failed to start. " + f"Check whether port {config.local_service_port} is already in use. " + f"Original error: {exc}", + file=sys.stderr, + ) + raise SystemExit(2) from exc + except HttpClientError as exc: + if exc.status == 409: + print( + "Registration conflict: this machine is already registered on the controller, " + "but the local token was not found. Restore data/agent_state.json or remove the " + "existing agent record from the controller database.", + file=sys.stderr, + ) + print(str(exc), file=sys.stderr) + raise SystemExit(2) from exc + except KeyboardInterrupt: + print("\nControlled agent stopping...") + except Exception as exc: + print("Unexpected SystemTest-Agent error:", file=sys.stderr) + traceback.print_exc() + raise SystemExit(2) from exc diff --git a/SystemTest-Agent/app/state.py b/SystemTest-Agent/app/state.py new file mode 100644 index 0000000..f618ccc --- /dev/null +++ b/SystemTest-Agent/app/state.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def load_state(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + return data if isinstance(data, dict) else {} + + +def save_state(path: Path, state: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") diff --git a/SystemTest-Agent/start_agent.bat b/SystemTest-Agent/start_agent.bat new file mode 100644 index 0000000..b8b48dd --- /dev/null +++ b/SystemTest-Agent/start_agent.bat @@ -0,0 +1,80 @@ +@echo off +setlocal EnableExtensions +cd /d "%~dp0" +if not "%~1"=="" set "CONTROLLER_URL=%~1" +set "EXIT_CODE=0" + +if not exist "logs" mkdir "logs" >nul 2>nul +set "LOG_FILE=%~dp0logs\agent_start_latest.log" +if not defined PYTHONIOENCODING set "PYTHONIOENCODING=utf-8" +call :set_stamp +> "%LOG_FILE%" echo [%STAMP%] SystemTest-Agent startup +>> "%LOG_FILE%" echo Working directory: %CD% +if defined CONTROLLER_URL >> "%LOG_FILE%" echo CONTROLLER_URL=%CONTROLLER_URL% +echo Startup log: "%LOG_FILE%" + +if defined PYTHON_EXE ( + if exist "%PYTHON_EXE%" ( + call :run_python "%PYTHON_EXE%" -u -m app + goto :after_run + ) else ( + >> "%LOG_FILE%" echo PYTHON_EXE is set but was not found: %PYTHON_EXE% + ) +) + +set "CODEX_PYTHON=%USERPROFILE%\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" +if exist "%CODEX_PYTHON%" ( + call :run_python "%CODEX_PYTHON%" -u -m app + goto :after_run +) + +call :run_python python -u -m app +if not "%EXIT_CODE%"=="0" ( + call :run_python py -3 -u -m app +) + +:after_run +if not "%EXIT_CODE%"=="0" ( + echo. + echo SystemTest-Agent exited with code %EXIT_CODE%. + echo Full log: "%LOG_FILE%" + echo. + echo Last log lines: + call :show_log_tail + echo. + echo Common causes: + echo 1. Controller is not reachable from this device. + echo Try: start_agent.bat http://^:8001 + echo 2. Python 3 is not installed or not in PATH. + echo 3. Local service port 18081 is already in use. + echo. +) + +if not "%AGENT_NO_PAUSE%"=="1" ( + echo. + echo SystemTest-Agent stopped. Full log: "%LOG_FILE%" + echo Press any key to close this window. + pause >nul +) + +:end +endlocal & exit /b %EXIT_CODE% + +:run_python +echo. >> "%LOG_FILE%" +call :set_stamp +echo [%STAMP%] Running: %* >> "%LOG_FILE%" +%* >> "%LOG_FILE%" 2>&1 +set "EXIT_CODE=%ERRORLEVEL%" +call :set_stamp +echo [%STAMP%] Exit code: %EXIT_CODE% >> "%LOG_FILE%" +exit /b 0 + +:set_stamp +for /f "delims=" %%I in ('powershell -NoProfile -Command "Get-Date -Format yyyy-MM-ddTHH:mm:ss"') do set "STAMP=%%I" +if not defined STAMP set "STAMP=%TIME%" +exit /b 0 + +:show_log_tail +powershell -NoProfile -Command "if (Test-Path -LiteralPath '%LOG_FILE%') { Get-Content -LiteralPath '%LOG_FILE%' -Tail 80 -Encoding UTF8 }" +exit /b 0 diff --git a/SystemTest-Agent/start_agent.ps1 b/SystemTest-Agent/start_agent.ps1 new file mode 100644 index 0000000..4330b49 --- /dev/null +++ b/SystemTest-Agent/start_agent.ps1 @@ -0,0 +1,132 @@ +param( + [string]$ControllerUrl +) + +$ErrorActionPreference = "Stop" +Set-Location -LiteralPath $PSScriptRoot + +if ($ControllerUrl) { + $env:CONTROLLER_URL = $ControllerUrl +} +if (-not $env:PYTHONIOENCODING) { + $env:PYTHONIOENCODING = "utf-8" +} + +$logDir = Join-Path $PSScriptRoot "logs" +New-Item -ItemType Directory -Force -Path $logDir | Out-Null +$logFile = Join-Path $logDir "agent_start_latest.log" +@( + "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] SystemTest-Agent startup" + "Working directory: $PWD" + "CONTROLLER_URL=$env:CONTROLLER_URL" +) | Set-Content -LiteralPath $logFile -Encoding UTF8 +Write-Host "Startup log: $logFile" + +function Write-ExitHints { + param([int]$ExitCode) + Write-Host "" + Write-Host "SystemTest-Agent exited with code $ExitCode." + Write-Host "Full log: $logFile" + Write-Host "" + Write-Host "Last log lines:" + if (Test-Path -LiteralPath $logFile) { + Get-Content -LiteralPath $logFile -Tail 80 + } + Write-Host "" + Write-Host "Common causes:" + Write-Host " 1. Controller is not reachable from this device." + Write-Host " Try: .\start_agent.ps1 -ControllerUrl http://:8001" + Write-Host " 2. Python 3 is not installed or not in PATH." + Write-Host " 3. Local service port 18081 is already in use." +} + +function Pause-IfNeeded { + if ($env:AGENT_NO_PAUSE -ne "1") { + Write-Host "" + Write-Host "SystemTest-Agent stopped. Full log: $logFile" + Read-Host "Press Enter to close this window" | Out-Null + } +} + +function ConvertTo-CommandArgumentString { + param([string[]]$Arguments) + $quoted = foreach ($argument in $Arguments) { + if ($argument -match '[\s"]') { + '"' + ($argument -replace '"', '\"') + '"' + } + else { + $argument + } + } + return ($quoted -join ' ') +} + +function Quote-CmdArgument { + param([string]$Value) + return '"' + ($Value -replace '"', '\"') + '"' +} + +function Invoke-LoggedProcess { + param( + [string]$FilePath, + [string[]]$Arguments, + [string]$LogFilePath + ) + + $commandLine = "$(Quote-CmdArgument -Value $FilePath) $(ConvertTo-CommandArgumentString -Arguments $Arguments) >> $(Quote-CmdArgument -Value $LogFilePath) 2>&1" + & $env:ComSpec /d /s /c $commandLine + return $LASTEXITCODE +} + +$pythonExe = $null +$pythonArgs = @() + +try { + if ($env:PYTHON_EXE -and (Test-Path -LiteralPath $env:PYTHON_EXE)) { + $pythonExe = $env:PYTHON_EXE + } + + if (-not $pythonExe) { + $bundledPython = Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' + if (Test-Path -LiteralPath $bundledPython) { + $pythonExe = $bundledPython + } + } + + if (-not $pythonExe) { + $pythonCommand = Get-Command python -ErrorAction SilentlyContinue + if ($pythonCommand) { + $pythonExe = $pythonCommand.Source + } + } + + if (-not $pythonExe) { + $pythonCommand = Get-Command py -ErrorAction SilentlyContinue + if ($pythonCommand) { + $pythonExe = $pythonCommand.Source + $pythonArgs = @('-3') + } + } + + if (-not $pythonExe) { + throw "Python 3 was not found. Install Python 3, set PYTHON_EXE, or add Python to PATH." + } + + $runArgs = @($pythonArgs) + @("-u", "-m", "app") + $runLine = "Running: $pythonExe $(ConvertTo-CommandArgumentString -Arguments $runArgs)" + Write-Host $runLine + Add-Content -LiteralPath $logFile -Encoding UTF8 -Value $runLine + $exitCode = Invoke-LoggedProcess -FilePath $pythonExe -Arguments $runArgs -LogFilePath $logFile +} +catch { + $exitCode = 2 + Write-Host ($_ | Out-String) + Add-Content -LiteralPath $logFile -Encoding UTF8 -Value ($_ | Out-String) +} + +if ($exitCode -ne 0) { + Write-ExitHints -ExitCode $exitCode +} + +Pause-IfNeeded +exit $exitCode diff --git a/SystemTest-Controller/README.md b/SystemTest-Controller/README.md new file mode 100644 index 0000000..98bc845 --- /dev/null +++ b/SystemTest-Controller/README.md @@ -0,0 +1,44 @@ +# SystemTest-Controller + +P0-01 主控端最小可用实现,使用 Python 标准库提供 HTTP API、SQLite 状态库和静态面板。 + +## 一键启动 + +PowerShell: + +```powershell +.\start_controller.ps1 +``` + +CMD: + +```bat +start_controller.bat +``` + +默认地址与 `Agent-S-Controller/start_agent_controller.bat` 保持一致: + +- Health: `http://127.0.0.1:8001/health` +- Panel: `http://127.0.0.1:8001/` + +启动成功后会自动打开 Web 面板;如需关闭自动弹出,设置 `CONTROLLER_NO_BROWSER=1`。 + +## 配置 + +复制 `.env.example` 为 `.env` 后可调整: + +- `CONTROLLER_HOST`: 默认 `0.0.0.0` +- `CONTROLLER_PORT`: 默认 `8001` +- `CONTROLLER_PUBLIC_BASE_URL`: 被控端访问主控的 URL +- `CONTROLLER_DEPLOYMENT_MODE`: 主控部署形态标识,默认 `script` +- `CONTROLLER_DATABASE`: SQLite 数据库路径 +- `AGENT_STALE_AFTER_SECONDS`: 被控端超过该秒数无心跳后显示为 `offline`,默认 `5` +- `CONTROLLER_NO_BROWSER`: 设为 `1` 时不自动打开浏览器 + +## 已完成的 P0-01 验收点 + +- 启动时读取 `.env` +- 启动前检查数据库、端口、静态面板文件 +- `/health` 返回健康状态和部署形态 +- `/` 可访问静态面板 +- 提供被控端注册、心跳、领任务 API diff --git a/SystemTest-Controller/app/__init__.py b/SystemTest-Controller/app/__init__.py new file mode 100644 index 0000000..39223c9 --- /dev/null +++ b/SystemTest-Controller/app/__init__.py @@ -0,0 +1,3 @@ +"""System test controller package.""" + +__version__ = "0.1.0" diff --git a/SystemTest-Controller/app/__main__.py b/SystemTest-Controller/app/__main__.py new file mode 100644 index 0000000..5b9eec7 --- /dev/null +++ b/SystemTest-Controller/app/__main__.py @@ -0,0 +1,5 @@ +from .server import main + + +if __name__ == "__main__": + main() diff --git a/SystemTest-Controller/app/config.py b/SystemTest-Controller/app/config.py new file mode 100644 index 0000000..b1bb5b7 --- /dev/null +++ b/SystemTest-Controller/app/config.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +ROOT_DIR = Path(__file__).resolve().parents[1] + + +def _load_env_file(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + if not path.exists(): + return values + + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key: + values[key] = value + return values + + +def _get_env(name: str, default: str, file_values: dict[str, str]) -> str: + return os.environ.get(name, file_values.get(name, default)) + + +def _as_int(value: str, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _resolve_path(value: str) -> Path: + path = Path(value) + if path.is_absolute(): + return path + return ROOT_DIR / path + + +@dataclass(frozen=True) +class ControllerConfig: + root_dir: Path + host: str + port: int + database_path: Path + static_dir: Path + controller_name: str + deployment_mode: str + public_base_url: str + agent_stale_after_seconds: int + + @classmethod + def from_env(cls) -> "ControllerConfig": + file_values = _load_env_file(ROOT_DIR / ".env") + port = _as_int(_get_env("CONTROLLER_PORT", "8001", file_values), 8001) + host = _get_env("CONTROLLER_HOST", "0.0.0.0", file_values) + public_base_url = _get_env( + "CONTROLLER_PUBLIC_BASE_URL", + f"http://127.0.0.1:{port}", + file_values, + ).rstrip("/") + stale_after = _as_int( + _get_env("AGENT_STALE_AFTER_SECONDS", "5", file_values), + 5, + ) + + return cls( + root_dir=ROOT_DIR, + host=host, + port=port, + database_path=_resolve_path( + _get_env("CONTROLLER_DATABASE", "data/controller.db", file_values) + ), + static_dir=_resolve_path(_get_env("CONTROLLER_STATIC_DIR", "web", file_values)), + controller_name=_get_env( + "CONTROLLER_NAME", + "System Test Controller", + file_values, + ), + deployment_mode=_get_env("CONTROLLER_DEPLOYMENT_MODE", "script", file_values), + public_base_url=public_base_url, + agent_stale_after_seconds=stale_after, + ) diff --git a/SystemTest-Controller/app/server.py b/SystemTest-Controller/app/server.py new file mode 100644 index 0000000..425c43f --- /dev/null +++ b/SystemTest-Controller/app/server.py @@ -0,0 +1,1533 @@ +from __future__ import annotations + +import hashlib +import json +import html +import ipaddress +import mimetypes +import os +import re +import socket +import ssl +import sys +import threading +import webbrowser +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timezone +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from html.parser import HTMLParser +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qs, urldefrag, urljoin, urlparse +from urllib.request import Request, urlopen + +from . import __version__ +from .config import ControllerConfig +from .storage import AlreadyRegisteredError, Database + + +STARTED_AT = datetime.now(timezone.utc).isoformat(timespec="seconds") +DEFAULT_SITE_SCAN_PORTS = [ + 80, + 443, + 3000, + 4173, + 5173, + 5174, + 8000, + 8001, + 8080, + 8081, + 8088, + 8765, + 9000, + 9090, + 10000, +] +TLS_SITE_PORTS = {443, 8443, 9443} +MAX_SITE_SCAN_PROBES = 512 +MAX_SITE_SCAN_PORTS = 256 +DISCOVERY_MAX_LINKS = 24 +DISCOVERY_MAX_FORMS = 12 +DISCOVERY_MAX_BUTTONS = 24 +DISCOVERY_MAX_HTML_BYTES = 1_500_000 +TITLE_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) +UNVERIFIED_HTTPS_CONTEXT = ssl._create_unverified_context() + + +def _browser_autostart_enabled() -> bool: + disabled_values = {"1", "true", "yes", "on"} + return ( + os.environ.get("CONTROLLER_NO_BROWSER", "").strip().lower() not in disabled_values + and os.environ.get("AGENT_CONTROLLER_NO_BROWSER", "").strip().lower() not in disabled_values + ) + + +def _open_panel_later(url: str) -> None: + def open_panel() -> None: + try: + webbrowser.open(url, new=2) + except Exception as exc: # Browser launch should not bring the service down. + print(f"Could not open browser automatically: {exc}", file=sys.stderr) + + threading.Timer(0.8, open_panel).start() + + +def _json_bytes(payload: Any) -> bytes: + return json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") + + +def _chat_completions_url(base_url: str) -> str: + base = base_url.rstrip("/") + if base.endswith("/chat/completions"): + return base + if base.endswith("/v1"): + return f"{base}/chat/completions" + return f"{base}/v1/chat/completions" + + +def _parse_json_object(text: str) -> dict[str, Any] | None: + raw = text.strip() + fence = re.search(r"```(?:json)?\s*(.*?)```", raw, re.IGNORECASE | re.DOTALL) + if fence: + raw = fence.group(1).strip() + start = raw.find("{") + end = raw.rfind("}") + if start >= 0 and end > start: + raw = raw[start : end + 1] + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return None + return parsed if isinstance(parsed, dict) else None + + +def _completion_content(data: dict[str, Any]) -> str: + choices = data.get("choices") + if not isinstance(choices, list) or not choices: + return "" + choice = choices[0] if isinstance(choices[0], dict) else {} + message = choice.get("message") if isinstance(choice.get("message"), dict) else {} + content = ( + message.get("content") + or message.get("reasoning_content") + or message.get("reasoning") + or choice.get("text") + or "" + ) + if not isinstance(content, str): + return json.dumps(content, ensure_ascii=False) + return content + + +def _port_is_available(host: str, port: int) -> bool: + probe_host = "127.0.0.1" if host in {"0.0.0.0", "::", ""} else host + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(0.5) + return sock.connect_ex((probe_host, port)) != 0 + + +def _is_blank(value: Any) -> bool: + return value is None or value == [] or (isinstance(value, str) and not value.strip()) + + +def _parse_site_scan_ports(value: Any) -> list[int]: + if _is_blank(value): + return list(DEFAULT_SITE_SCAN_PORTS) + + raw_parts = value if isinstance(value, list) else str(value).replace(";", ",").replace(",", ",").split(",") + ports: set[int] = set() + for raw_part in raw_parts: + text = str(raw_part).strip() + if not text: + continue + if "-" in text: + start_text, end_text = [part.strip() for part in text.split("-", 1)] + if not start_text.isdigit() or not end_text.isdigit(): + raise ValueError(f"Invalid port range: {text}") + start, end = int(start_text), int(end_text) + if end < start: + raise ValueError(f"Invalid port range: {text}") + if end - start + 1 > MAX_SITE_SCAN_PORTS: + raise ValueError("Port range is too large; narrow it before scanning.") + candidates = range(start, end + 1) + else: + if not text.isdigit(): + raise ValueError(f"Invalid port: {text}") + candidates = [int(text)] + for port in candidates: + if port < 1 or port > 65535: + raise ValueError(f"Port out of range: {port}") + ports.add(port) + + if not ports: + raise ValueError("No valid ports to scan.") + if len(ports) > MAX_SITE_SCAN_PORTS: + raise ValueError("Too many ports to scan at once; please narrow the port list.") + return sorted(ports) + + +def _resolve_ipv4(host: str) -> str | None: + try: + infos = socket.getaddrinfo(host, None, socket.AF_INET, socket.SOCK_STREAM) + except socket.gaierror: + return None + for info in infos: + address = info[4][0] + if address: + return address + return None + + +def _add_local_ipv4(candidates: list[str], address: str | None) -> None: + if not address or address in candidates: + return + try: + parsed = ipaddress.ip_address(address) + except ValueError: + return + if parsed.version != 4 or parsed.is_unspecified or parsed.is_multicast: + return + candidates.append(address) + + +def _local_site_scan_hosts() -> list[dict[str, str]]: + addresses = ["127.0.0.1"] + try: + for address in socket.gethostbyname_ex(socket.gethostname())[2]: + _add_local_ipv4(addresses, address) + except OSError: + pass + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect(("8.8.8.8", 80)) + _add_local_ipv4(addresses, sock.getsockname()[0]) + except OSError: + pass + return [{"host": address, "ip": address} for address in addresses] + + +def _site_scan_hosts(target: str) -> list[dict[str, str]]: + normalized = target.strip() + if normalized.lower() in {"", "local", "localhost", "127.0.0.1", "::1"}: + return _local_site_scan_hosts() + if any(marker in normalized for marker in ["/", "\\", ",", ";", " "]): + raise ValueError("Please enter one IP address or host name, not a network range.") + ip = _resolve_ipv4(normalized) + if ip is None: + raise ValueError(f"Unable to resolve host: {normalized}") + return [{"host": normalized, "ip": ip}] + + +def _extract_site_scan_target(payload: dict[str, Any]) -> tuple[str, Any]: + target = str(payload.get("host") or "").strip() + ports = payload.get("ports") + if "://" in target: + parsed = urlparse(target) + if parsed.hostname: + target = parsed.hostname + if _is_blank(ports) and parsed.port: + ports = parsed.port + elif target.count(":") == 1: + host_part, port_part = target.rsplit(":", 1) + if host_part.strip() and port_part.isdigit(): + target = host_part.strip() + if _is_blank(ports): + ports = port_part + return target, ports + + +def _scheme_candidates(port: int) -> list[str]: + return ["https", "http"] if port in TLS_SITE_PORTS else ["http", "https"] + + +def _decode_body(raw: bytes, content_type: str) -> str: + charset = "utf-8" + match = re.search(r"charset=([^;\s]+)", content_type or "", re.IGNORECASE) + if match: + charset = match.group(1).strip("\"'") + for candidate in [charset, "utf-8", "gbk", "latin-1"]: + try: + return raw.decode(candidate, errors="replace") + except LookupError: + continue + return raw.decode("utf-8", errors="replace") + + +def _extract_page_title(raw: bytes, content_type: str) -> str: + text = _decode_body(raw, content_type) + match = TITLE_RE.search(text) + if not match: + return "" + return html.unescape(re.sub(r"\s+", " ", match.group(1))).strip() + + +def _clean_discovery_text(value: Any, max_length: int = 160) -> str: + text = html.unescape(re.sub(r"\s+", " ", str(value or ""))).strip() + return text[:max_length].strip() + + +def _normalize_discovery_url(value: Any) -> str: + text = str(value or "").strip() + if not text: + raise ValueError("Missing target system URL.") + if "://" not in text: + text = f"http://{text}" + parsed = urlparse(text) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("Target system URL must be an http(s) URL.") + return text + + +def _origin_tuple(value: str) -> tuple[str, str, int]: + parsed = urlparse(value) + port = parsed.port or (443 if parsed.scheme == "https" else 80) + return parsed.scheme, (parsed.hostname or "").lower(), port + + +def _same_origin(base_url: str, candidate_url: str) -> bool: + try: + return _origin_tuple(base_url) == _origin_tuple(candidate_url) + except ValueError: + return False + + +def _safe_join_page_url(base_url: str, href: str) -> str | None: + text = str(href or "").strip() + if not text or text.startswith(("#", "javascript:", "mailto:", "tel:", "data:")): + return None + joined = urljoin(base_url, text) + joined, _fragment = urldefrag(joined) + parsed = urlparse(joined) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + return None + if not _same_origin(base_url, joined): + return None + return joined + + +class DiscoveryHtmlParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.title_parts: list[str] = [] + self.links: list[dict[str, Any]] = [] + self.forms: list[dict[str, Any]] = [] + self.buttons: list[dict[str, Any]] = [] + self._in_title = False + self._anchor_stack: list[dict[str, Any]] = [] + self._current_form: dict[str, Any] | None = None + self._current_button: dict[str, Any] | None = None + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + tag = tag.lower() + attr = {name.lower(): value or "" for name, value in attrs} + if tag == "title": + self._in_title = True + return + if tag == "a": + self._anchor_stack.append({"href": attr.get("href", ""), "text_parts": []}) + return + if tag == "form": + self._current_form = { + "action": attr.get("action", ""), + "method": (attr.get("method") or "get").upper(), + "id": attr.get("id", ""), + "name": attr.get("name", ""), + "class": attr.get("class", ""), + "text_parts": [], + "fields": [], + } + return + if self._current_form is not None and tag in {"input", "select", "textarea"}: + self._current_form["fields"].append( + { + "tag": tag, + "type": (attr.get("type") or tag).lower(), + "name": attr.get("name", ""), + "id": attr.get("id", ""), + "placeholder": attr.get("placeholder", ""), + "aria_label": attr.get("aria-label", ""), + } + ) + if tag == "button": + self._current_button = { + "type": attr.get("type", ""), + "id": attr.get("id", ""), + "name": attr.get("name", ""), + "class": attr.get("class", ""), + "text_parts": [], + "in_form": self._current_form is not None, + } + + def handle_data(self, data: str) -> None: + if self._in_title: + self.title_parts.append(data) + if self._anchor_stack: + self._anchor_stack[-1]["text_parts"].append(data) + if self._current_form is not None: + self._current_form["text_parts"].append(data) + if self._current_button is not None: + self._current_button["text_parts"].append(data) + + def handle_endtag(self, tag: str) -> None: + tag = tag.lower() + if tag == "title": + self._in_title = False + elif tag == "a" and self._anchor_stack: + anchor = self._anchor_stack.pop() + self.links.append( + { + "href": anchor.get("href", ""), + "text": _clean_discovery_text(" ".join(anchor.get("text_parts") or []), 120), + } + ) + elif tag == "button" and self._current_button is not None: + button = self._current_button + button["text"] = _clean_discovery_text(" ".join(button.get("text_parts") or []), 120) + self.buttons.append(button) + self._current_button = None + elif tag == "form" and self._current_form is not None: + form = self._current_form + form["text"] = _clean_discovery_text(" ".join(form.get("text_parts") or []), 160) + self.forms.append(form) + self._current_form = None + + +def _fetch_discovery_page(url: str, timeout: float) -> dict[str, Any]: + request = Request( + url, + headers={ + "Accept": "text/html,application/xhtml+xml,*/*", + "User-Agent": f"SystemTestController/{__version__} discovery", + }, + ) + started = datetime.now(timezone.utc) + try: + with urlopen(request, timeout=timeout, context=UNVERIFIED_HTTPS_CONTEXT) as response: + raw = response.read(DISCOVERY_MAX_HTML_BYTES) + content_type = response.headers.get("Content-Type", "") + final_url = response.geturl() + status = response.status + except HTTPError as exc: + raw = exc.read(DISCOVERY_MAX_HTML_BYTES) + content_type = exc.headers.get("Content-Type", "") + final_url = exc.geturl() + status = exc.code + except (URLError, TimeoutError, OSError) as exc: + reason = getattr(exc, "reason", exc) + if isinstance(exc, TimeoutError) or isinstance(reason, TimeoutError): + reason = f"timed out after {timeout:g}s" + raise ValueError(f"Target system is not reachable: {reason}") from exc + + elapsed = (datetime.now(timezone.utc) - started).total_seconds() + return { + "url": url, + "final_url": final_url, + "status": status, + "content_type": content_type, + "html": _decode_body(raw, content_type), + "elapsed_seconds": round(elapsed, 3), + } + + +def _candidate_id(seed: str, *parts: Any) -> str: + raw = "|".join([seed, *[str(part or "") for part in parts]]) + digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:12] + return f"auto-{digest}" + + +def _risk_for_text(text: str) -> str: + lowered = text.lower() + high = ["delete", "remove", "drop", "支付", "付款", "删除", "注销", "清空", "退款"] + medium = ["save", "submit", "create", "update", "edit", "upload", "保存", "提交", "新增", "修改", "上传"] + if any(word in lowered for word in high): + return "high" + if any(word in lowered for word in medium): + return "medium" + return "low" + + +def _looks_like_login_form(form: dict[str, Any]) -> bool: + fields = form.get("fields") or [] + text = " ".join( + [ + str(form.get("id") or ""), + str(form.get("name") or ""), + str(form.get("class") or ""), + str(form.get("text") or ""), + *[ + " ".join( + [ + str(field.get("type") or ""), + str(field.get("name") or ""), + str(field.get("id") or ""), + str(field.get("placeholder") or ""), + str(field.get("aria_label") or ""), + ] + ) + for field in fields + ], + ] + ).lower() + return "password" in text or any(keyword in text for keyword in ["login", "signin", "sign in", "登录", "账号", "密码"]) + + +def _build_discovery_candidates(target_url: str, page: dict[str, Any]) -> dict[str, Any]: + parser = DiscoveryHtmlParser() + try: + parser.feed(page.get("html") or "") + except Exception: + pass + + page_url = page.get("final_url") or target_url + page_title = _clean_discovery_text(" ".join(parser.title_parts), 120) or _extract_page_title( + str(page.get("html") or "").encode("utf-8", errors="ignore"), + "text/html; charset=utf-8", + ) + display_name = page_title or urlparse(page_url).netloc or page_url + root_id = _candidate_id(page_url, "root", display_name) + candidates: list[dict[str, Any]] = [ + { + "id": root_id, + "parent_id": None, + "root_id": root_id, + "depth": 0, + "level": "site", + "title": f"站点冒烟检查:{display_name}", + "objective": "确认待测站点可以访问,并记录页面标题、状态码和基础元信息。", + "target_url": page_url, + "module": "站点入口", + "risk_level": "low", + "priority": 20, + "max_retries": 2, + "failure_policy": "pause", + "preconditions": [], + "source": "page", + "tags": ["smart-discovery", "p0", "site"], + "selected": True, + } + ] + + seen_links: set[str] = set() + for link in parser.links: + if len(seen_links) >= DISCOVERY_MAX_LINKS: + break + href = _safe_join_page_url(page_url, str(link.get("href") or "")) + if not href or href in seen_links: + continue + seen_links.add(href) + label = _clean_discovery_text(link.get("text"), 80) or urlparse(href).path.strip("/") or href + risk = _risk_for_text(label) + candidate_id = _candidate_id(page_url, "link", href, label) + candidates.append( + { + "id": candidate_id, + "parent_id": root_id, + "root_id": root_id, + "depth": 1, + "level": "module", + "title": f"探索页面:{label}", + "objective": f"访问并检查页面“{label}”是否可达,记录页面标题和返回状态。", + "target_url": href, + "module": label, + "risk_level": risk, + "priority": 40 if risk == "low" else 70, + "max_retries": 2, + "failure_policy": "pause" if risk == "high" else "skip", + "preconditions": [], + "source": "html_link", + "tags": ["smart-discovery", "p0", "navigation"], + "selected": risk != "high", + } + ) + + for index, form in enumerate(parser.forms[:DISCOVERY_MAX_FORMS], start=1): + action_url = _safe_join_page_url(page_url, str(form.get("action") or "")) or page_url + is_login = _looks_like_login_form(form) + field_names = [ + _clean_discovery_text( + field.get("placeholder") or field.get("aria_label") or field.get("name") or field.get("id") or field.get("type"), + 40, + ) + for field in form.get("fields") or [] + ] + field_names = [name for name in field_names if name][:8] + label = "登录表单" if is_login else (_clean_discovery_text(form.get("name") or form.get("id") or form.get("text"), 60) or f"表单 {index}") + risk = "medium" if is_login else _risk_for_text(label) + candidate_id = _candidate_id(page_url, "form", index, action_url, label) + preconditions = ["需要测试账号/密码"] if is_login else [] + candidates.append( + { + "id": candidate_id, + "parent_id": root_id, + "root_id": root_id, + "depth": 1, + "level": "feature", + "title": f"识别功能:{label}", + "objective": f"分析{label}的输入项与提交入口,P0 阶段先确认页面元素存在,不执行真实提交。", + "target_url": action_url, + "module": label, + "risk_level": risk, + "priority": 35 if is_login else 55, + "max_retries": 2, + "failure_policy": "pause" if is_login else "skip", + "preconditions": preconditions, + "source": "html_form", + "method": form.get("method") or "GET", + "fields": field_names, + "tags": ["smart-discovery", "p0", "form"], + "selected": True, + } + ) + + seen_buttons: set[str] = set() + for button in parser.buttons: + if len(seen_buttons) >= DISCOVERY_MAX_BUTTONS: + break + label = _clean_discovery_text(button.get("text") or button.get("name") or button.get("id"), 60) + if not label or label in seen_buttons: + continue + seen_buttons.add(label) + risk = _risk_for_text(label) + candidate_id = _candidate_id(page_url, "button", label) + candidates.append( + { + "id": candidate_id, + "parent_id": root_id, + "root_id": root_id, + "depth": 1, + "level": "feature", + "title": f"识别入口:{label}", + "objective": f"确认入口“{label}”在页面中存在;高风险入口仅记录,不自动执行破坏性动作。", + "target_url": page_url, + "module": "页面入口", + "risk_level": risk, + "priority": 60 if risk == "low" else 85, + "max_retries": 1, + "failure_policy": "pause" if risk == "high" else "skip", + "preconditions": [], + "source": "html_button", + "tags": ["smart-discovery", "p0", "entry"], + "selected": risk != "high", + } + ) + + if len(candidates) == 1: + for label, objective in [ + ("单页应用基础冒烟测试", "确认单页应用根页面可达,并为后续 GUI 探索提供初始页面。"), + ("页面导航与菜单发现", "后续由 GUI Agent 在页面内识别菜单、按钮和可交互区域。"), + ]: + candidate_id = _candidate_id(page_url, "fallback", label) + candidates.append( + { + "id": candidate_id, + "parent_id": root_id, + "root_id": root_id, + "depth": 1, + "level": "module", + "title": label, + "objective": objective, + "target_url": page_url, + "module": "页面探索", + "risk_level": "low", + "priority": 50, + "max_retries": 2, + "failure_policy": "skip", + "preconditions": [], + "source": "fallback", + "tags": ["smart-discovery", "p0", "spa"], + "selected": True, + } + ) + + return { + "summary": { + "target_url": target_url, + "final_url": page_url, + "page_title": page_title, + "http_status": page.get("status"), + "elapsed_seconds": page.get("elapsed_seconds"), + "link_count": len(seen_links), + "form_count": min(len(parser.forms), DISCOVERY_MAX_FORMS), + "button_count": min(len(seen_buttons), DISCOVERY_MAX_BUTTONS), + "candidate_count": len(candidates), + }, + "candidates": candidates, + } + + +def _probe_site_url(host_entry: dict[str, str], port: int, scheme: str, timeout: float) -> dict[str, Any] | None: + host = host_entry["host"] + url = f"{scheme}://{host}:{port}/" + try: + with socket.create_connection((host, port), timeout=timeout): + pass + except OSError: + return None + + started = datetime.now(timezone.utc) + request = Request( + url, + headers={ + "Accept": "text/html,application/xhtml+xml,application/json;q=0.8,*/*;q=0.5", + "User-Agent": f"SystemTestController/{__version__} SiteScanner", + }, + ) + try: + with urlopen( + request, + timeout=timeout, + context=UNVERIFIED_HTTPS_CONTEXT if scheme == "https" else None, + ) as response: + raw = response.read(256_000) + headers = response.headers + status = getattr(response, "status", 200) + final_url = response.geturl() + except HTTPError as exc: + raw = exc.read(256_000) + headers = exc.headers + status = exc.code + final_url = url + except (URLError, TimeoutError, OSError, ssl.SSLError): + return None + + elapsed = (datetime.now(timezone.utc) - started).total_seconds() + content_type = headers.get("Content-Type", "") + title = _extract_page_title(raw, content_type) + server_name = headers.get("Server", "") + name = title or server_name or f"{host_entry['ip']}:{port}" + return { + "name": name, + "title": title, + "url": url, + "final_url": final_url, + "ip": host_entry["ip"], + "host": host, + "port": port, + "scheme": scheme, + "status": status, + "content_type": content_type, + "elapsed_seconds": round(elapsed, 3), + "address": f"{host_entry['ip']}:{port}", + } + + +def _probe_site(host_entry: dict[str, str], port: int, timeout: float) -> dict[str, Any] | None: + for scheme in _scheme_candidates(port): + result = _probe_site_url(host_entry, port, scheme, timeout) + if result is not None: + return result + return None + + +def run_preflight(config: ControllerConfig, db: Database) -> list[dict[str, Any]]: + checks: list[dict[str, Any]] = [] + + db.init() + database_health = db.health() + checks.append( + { + "name": "database", + "ok": database_health["ok"], + "detail": database_health, + } + ) + + index_path = config.static_dir / "index.html" + checks.append( + { + "name": "static_panel", + "ok": index_path.exists(), + "detail": {"path": str(index_path)}, + } + ) + + checks.append( + { + "name": "port", + "ok": _port_is_available(config.host, config.port), + "detail": {"host": config.host, "port": config.port}, + } + ) + + return checks + + +def make_handler(config: ControllerConfig, db: Database): + class ControllerRequestHandler(BaseHTTPRequestHandler): + server_version = f"SystemTestController/{__version__}" + + def log_message(self, fmt: str, *args: Any) -> None: + sys.stdout.write( + "%s - - [%s] %s\n" + % (self.client_address[0], self.log_date_time_string(), fmt % args) + ) + + def _send_json(self, status: int, payload: Any) -> None: + body = _json_bytes(payload) + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.end_headers() + self.wfile.write(body) + + def _send_error_json(self, status: int, message: str, **extra: Any) -> None: + payload = {"ok": False, "error": message} + payload.update(extra) + self._send_json(status, payload) + + def _read_json_body(self) -> dict[str, Any] | None: + length = int(self.headers.get("Content-Length") or "0") + if length <= 0: + return {} + raw_body = self.rfile.read(length) + try: + payload = json.loads(raw_body.decode("utf-8")) + except json.JSONDecodeError: + self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body") + return None + if not isinstance(payload, dict): + self._send_error_json(HTTPStatus.BAD_REQUEST, "JSON body must be an object") + return None + return payload + + def _auth_agent(self, agent_id: int) -> dict[str, Any] | None: + auth = self.headers.get("Authorization") or "" + prefix = "Bearer " + if not auth.startswith(prefix): + self._send_error_json(HTTPStatus.UNAUTHORIZED, "Missing Bearer token") + return None + token = auth[len(prefix) :].strip() + agent = db.validate_agent(agent_id, token) + if agent is None: + self._send_error_json(HTTPStatus.UNAUTHORIZED, "Invalid Bearer token") + return None + return agent + + def _health_payload(self) -> dict[str, Any]: + db_health = db.health() + panel_path = config.static_dir / "index.html" + return { + "ok": db_health["ok"] and panel_path.exists(), + "service": "controller", + "version": __version__, + "name": config.controller_name, + "deployment_mode": config.deployment_mode, + "started_at": STARTED_AT, + "public_base_url": config.public_base_url, + "checks": { + "database": db_health, + "port": {"host": config.host, "port": config.port, "ok": True}, + "static_panel": {"ok": panel_path.exists(), "path": str(panel_path)}, + }, + } + + def _discovery_payload(self) -> dict[str, Any]: + host = self.headers.get("Host") + base_url = f"http://{host}" if host else config.public_base_url + return { + "service": "Agent-S-Controller", + "version": 1, + "base_url": base_url.rstrip("/"), + "time": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "deployment_mode": config.deployment_mode, + } + + def do_OPTIONS(self) -> None: + self.send_response(HTTPStatus.NO_CONTENT) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.end_headers() + + def do_GET(self) -> None: + parsed = urlparse(self.path) + path = parsed.path + query = parse_qs(parsed.query) + if path == "/health": + self._send_json(HTTPStatus.OK, self._health_payload()) + return + if path == "/api/v1/discovery": + self._send_json(HTTPStatus.OK, self._discovery_payload()) + return + if path == "/api/dashboard": + self._send_json(HTTPStatus.OK, {"ok": True, **db.dashboard()}) + return + if path == "/api/agents": + self._send_json(HTTPStatus.OK, {"ok": True, "agents": db.list_agents()}) + return + if path in {"/api/tasks", "/api/v1/tasks"}: + self._send_json( + HTTPStatus.OK, + { + "ok": True, + "tasks": db.list_tasks( + status=self._first_query(query, "status"), + plan_name=self._first_query(query, "plan_name"), + ), + }, + ) + return + if path == "/api/config/model": + self._send_json(HTTPStatus.OK, {"ok": True, **db.get_model_config()}) + return + if path == "/api/tools": + self._send_json(HTTPStatus.OK, {"ok": True, **db.get_tool_config()}) + return + if path == "/api/metrics": + self._send_json(HTTPStatus.OK, {"ok": True, "metrics": db.metrics()}) + return + if path == "/api/reports/summary": + self._send_json(HTTPStatus.OK, {"ok": True, "report": db.report_summary()}) + return + if path in {"/", "/index.html"}: + self._send_static_file(config.static_dir / "index.html") + return + if path.startswith("/static/"): + requested = path.removeprefix("/static/").replace("/", "\\") + file_path = (config.static_dir / requested).resolve() + try: + file_path.relative_to(config.static_dir.resolve()) + except ValueError: + self._send_error_json(HTTPStatus.NOT_FOUND, "Static file not found") + return + self._send_static_file(file_path) + return + self._send_error_json(HTTPStatus.NOT_FOUND, "Route not found") + + def do_POST(self) -> None: + parsed = urlparse(self.path) + path = parsed.path + payload = self._read_json_body() + if payload is None: + return + + if path == "/api/agents/register": + self._handle_register(payload) + return + if path in {"/api/tasks", "/api/v1/tasks"}: + created = db.create_task(payload) + self._send_json(HTTPStatus.CREATED, {"ok": True, "task": created}) + return + if path == "/api/tasks/import": + self._handle_import_tasks(payload) + return + if path == "/api/config/model": + try: + saved = db.save_model_config(payload) + except ValueError as exc: + self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) + return + self._send_json(HTTPStatus.OK, {"ok": True, **saved}) + return + if path == "/api/models/test": + self._send_json(HTTPStatus.OK, {"ok": True, "test": self._test_model(payload)}) + return + if path == "/api/sites/scan": + try: + result = self._scan_sites(payload) + except ValueError as exc: + self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) + return + self._send_json(HTTPStatus.OK, {"ok": True, **result}) + return + if path == "/api/system-tests/start": + try: + result = self._start_system_test(payload) + except ValueError as exc: + self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) + return + self._send_json(HTTPStatus.CREATED, {"ok": True, **result}) + return + if path == "/api/discovery/preview": + try: + result = self._preview_discovery(payload) + except ValueError as exc: + self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) + return + self._send_json(HTTPStatus.OK, {"ok": True, **result}) + return + if path == "/api/discovery/import": + try: + result = self._import_discovery_tasks(payload) + except ValueError as exc: + self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) + return + self._send_json(HTTPStatus.CREATED, {"ok": True, **result}) + return + if path == "/api/tools": + saved = db.save_tool_config(payload) + self._send_json(HTTPStatus.OK, {"ok": True, **saved}) + return + + parts = [part for part in path.split("/") if part] + if len(parts) == 4 and parts[:2] == ["api", "agents"] and parts[3] == "heartbeat": + self._handle_heartbeat(parts[2], payload) + return + if len(parts) == 5 and parts[:2] == ["api", "agents"] and parts[3:] == ["tasks", "claim"]: + self._handle_claim(parts[2]) + return + if len(parts) == 5 and parts[:2] == ["api", "agents"] and parts[3:] == ["model", "chat-json"]: + self._handle_agent_model_chat(parts[2], payload) + return + if len(parts) == 6 and parts[:2] == ["api", "agents"] and parts[3] == "tasks" and parts[5] == "result": + self._handle_agent_task_result(parts[2], parts[4], payload) + return + if len(parts) == 4 and parts[:2] == ["api", "tasks"]: + self._handle_task_action(parts[2], parts[3], payload) + return + + self._send_error_json(HTTPStatus.NOT_FOUND, "Route not found") + + def _first_query(self, query: dict[str, list[str]], name: str) -> str | None: + values = query.get(name) or [] + return values[0] if values else None + + def _handle_register(self, payload: dict[str, Any]) -> None: + required = ["public_id", "machine_fingerprint"] + missing = [key for key in required if not payload.get(key)] + if missing: + self._send_error_json( + HTTPStatus.BAD_REQUEST, + "Missing required registration fields", + missing=missing, + ) + return + try: + registration = db.register_agent(payload) + except AlreadyRegisteredError as exc: + self._send_error_json( + HTTPStatus.CONFLICT, + str(exc) + or "Agent public_id is already registered by another machine.", + public_id=payload.get("public_id"), + ) + return + status = HTTPStatus.CREATED if registration.get("registered") else HTTPStatus.OK + self._send_json(status, {"ok": True, **registration}) + + def _handle_import_tasks(self, payload: dict[str, Any]) -> None: + try: + result = db.import_tasks(payload) + except (ValueError, json.JSONDecodeError) as exc: + self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) + return + self._send_json(HTTPStatus.CREATED, {"ok": True, **result}) + + def _handle_heartbeat(self, agent_id_text: str, payload: dict[str, Any]) -> None: + try: + agent_id = int(agent_id_text) + except ValueError: + self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid agent id") + return + if self._auth_agent(agent_id) is None: + return + receipt = db.record_heartbeat(agent_id, payload) + self._send_json(HTTPStatus.OK, receipt) + + def _handle_claim(self, agent_id_text: str) -> None: + try: + agent_id = int(agent_id_text) + except ValueError: + self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid agent id") + return + if self._auth_agent(agent_id) is None: + return + task = db.claim_task(agent_id) + self._send_json(HTTPStatus.OK, {"ok": True, "task": task}) + + def _handle_agent_model_chat(self, agent_id_text: str, payload: dict[str, Any]) -> None: + try: + agent_id = int(agent_id_text) + except ValueError: + self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid agent id") + return + if self._auth_agent(agent_id) is None: + return + messages = payload.get("messages") + if not isinstance(messages, list) or not messages: + self._send_error_json(HTTPStatus.BAD_REQUEST, "Missing model messages") + return + if len(messages) > 20: + self._send_error_json(HTTPStatus.BAD_REQUEST, "Too many model messages") + return + normalized_messages: list[dict[str, str]] = [] + for message in messages: + if not isinstance(message, dict): + self._send_error_json(HTTPStatus.BAD_REQUEST, "Each model message must be an object") + return + role = str(message.get("role") or "").strip() + content = str(message.get("content") or "") + if role not in {"system", "user", "assistant"}: + self._send_error_json(HTTPStatus.BAD_REQUEST, f"Invalid model message role: {role}") + return + normalized_messages.append({"role": role, "content": content}) + result = self._proxy_model_chat_json( + { + "messages": normalized_messages, + "temperature": payload.get("temperature"), + } + ) + self._send_json(HTTPStatus.OK, result) + + def _handle_agent_task_result( + self, + agent_id_text: str, + task_id_text: str, + payload: dict[str, Any], + ) -> None: + try: + agent_id = int(agent_id_text) + task_id = int(task_id_text) + except ValueError: + self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid id") + return + if self._auth_agent(agent_id) is None: + return + task = db.record_task_result(task_id, payload) + self._send_json(HTTPStatus.OK, {"ok": True, "task": task}) + + def _handle_task_action( + self, + task_id_text: str, + action: str, + payload: dict[str, Any], + ) -> None: + try: + task_id = int(task_id_text) + except ValueError: + self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid task id") + return + + if action == "assign": + agent_id = payload.get("agent_id") + task = db.assign_task( + task_id, + int(agent_id) if agent_id not in {None, ""} else None, + reason=payload.get("reason"), + ) + self._send_json(HTTPStatus.OK, {"ok": True, "task": task}) + return + if action == "abort": + task = db.update_task_status(task_id, "cancelled", reason=payload.get("reason") or "aborted by operator") + self._send_json(HTTPStatus.OK, {"ok": True, "task": task}) + return + if action == "retry": + task = db.retry_task(task_id, reason=payload.get("reason")) + self._send_json(HTTPStatus.OK, {"ok": True, "task": task}) + return + if action == "status": + status = str(payload.get("status") or "") + if not status: + self._send_error_json(HTTPStatus.BAD_REQUEST, "Missing status") + return + task = db.update_task_status(task_id, status, reason=payload.get("reason"), payload=payload.get("result")) + self._send_json(HTTPStatus.OK, {"ok": True, "task": task}) + return + if action == "result": + task = db.record_task_result(task_id, payload) + self._send_json(HTTPStatus.OK, {"ok": True, "task": task}) + return + self._send_error_json(HTTPStatus.NOT_FOUND, "Task action not found") + + def _scan_sites(self, payload: dict[str, Any]) -> dict[str, Any]: + target, port_payload = _extract_site_scan_target(payload) + ports = _parse_site_scan_ports(port_payload) + timeout = float(payload.get("timeout_seconds") or 0.8) + timeout = max(0.2, min(timeout, 5.0)) + hosts = _site_scan_hosts(target) + probe_count = len(hosts) * len(ports) + if probe_count > MAX_SITE_SCAN_PROBES: + raise ValueError("Scan range is too large; reduce hosts or ports and try again.") + + sites: list[dict[str, Any]] = [] + worker_count = min(32, max(1, probe_count)) + with ThreadPoolExecutor(max_workers=worker_count) as executor: + futures = [ + executor.submit(_probe_site, host_entry, port, timeout) + for host_entry in hosts + for port in ports + ] + for future in as_completed(futures): + result = future.result() + if result is not None: + sites.append(result) + + sites.sort(key=lambda site: (site["ip"], site["port"], site["scheme"])) + return { + "target": target or "local", + "hosts": hosts, + "ports": ports, + "scanned": probe_count, + "count": len(sites), + "sites": sites, + } + + def _start_system_test(self, payload: dict[str, Any]) -> dict[str, Any]: + stored = db.get_model_config(public=False)["value"] + target_url = _normalize_discovery_url( + payload.get("target_url") + or payload.get("url") + or stored.get("target_system_url") + ) + parsed = urlparse(target_url) + display_name = str(payload.get("site_name") or parsed.netloc or target_url).strip() + plan_name = str(payload.get("plan_name") or f"系统测试 - {display_name}").strip() + if not plan_name: + plan_name = "系统测试" + + try: + max_depth = int(payload.get("max_depth") or payload.get("discovery_max_depth") or 2) + except (TypeError, ValueError): + max_depth = 2 + max_depth = max(1, min(max_depth, 5)) + + assigned_agent_id: int | None = None + raw_agent_id = payload.get("assigned_agent_id") or payload.get("agent_id") + if raw_agent_id not in {None, ""}: + try: + assigned_agent_id = int(raw_agent_id) + except (TypeError, ValueError) as exc: + raise ValueError("Invalid controlled-agent id.") from exc + + root_id = str( + payload.get("external_id") + or _candidate_id(target_url, "system-test-root", datetime.now(timezone.utc).isoformat()) + ) + objective = ( + "进入待测系统首页,观察页面可访问性、标题、链接、表单和主要入口;" + "发现可拆分的功能后自动创建层级化子任务并加入主控端任务队列。" + ) + created = db.create_task( + { + "external_id": root_id, + "title": f"启动系统测试:{display_name}", + "plan_name": plan_name, + "module": "系统入口", + "parent_external_id": None, + "root_external_id": root_id, + "depth": 0, + "task_level": "site", + "risk_level": "low", + "priority": 10, + "failure_policy": "pause", + "max_retries": 2, + "timeout_seconds": int(payload.get("timeout_seconds") or 90), + "assigned_agent_id": assigned_agent_id, + "tags": ["system-test", "agent-discovery", "p0"], + "payload": { + "task_type": "smart_discovery_p0", + "execution_mode": "web", + "instruction": objective, + "objective": objective, + "target_url": target_url, + "source": "system_test_start", + "level": "site", + "depth": 0, + "risk_level": "low", + "allow_child_discovery": True, + "discovery_max_depth": max_depth, + "open_in_browser": True, + "capture_screenshot": True, + "hierarchy": { + "id": root_id, + "parent_id": None, + "root_id": root_id, + "depth": 0, + "level": "site", + }, + }, + } + ) + + try: + db.save_model_config({**stored, "target_system_url": target_url}) + except ValueError: + pass + + active_agents = [ + agent + for agent in db.list_agents() + if agent.get("status") in {"online", "idle", "running", "registered"} + ] + return { + "task": created, + "target_url": target_url, + "plan_name": plan_name, + "assigned_agent_id": assigned_agent_id, + "active_agent_count": len(active_agents), + "message": "System test root task created. A controlled agent will claim it from the queue.", + } + + def _preview_discovery(self, payload: dict[str, Any]) -> dict[str, Any]: + stored = db.get_model_config(public=False)["value"] + target_url = _normalize_discovery_url( + payload.get("target_url") + or payload.get("url") + or stored.get("target_system_url") + ) + timeout = float(payload.get("timeout_seconds") or stored.get("request_timeout_seconds") or 6) + timeout = max(1.0, min(timeout, 20.0)) + page = _fetch_discovery_page(target_url, timeout) + return _build_discovery_candidates(target_url, page) + + def _import_discovery_tasks(self, payload: dict[str, Any]) -> dict[str, Any]: + candidates = payload.get("tasks") or payload.get("candidates") or [] + if not isinstance(candidates, list) or not candidates: + raise ValueError("No discovery tasks were selected.") + + plan_name = str(payload.get("plan_name") or "智能探索 P0").strip() or "智能探索 P0" + target_url = str(payload.get("target_url") or "").strip() + created: list[dict[str, Any]] = [] + id_map: dict[str, str] = {} + selected_parent_ids = { + str(item.get("parent_id")) + for item in candidates + if isinstance(item, dict) and item.get("parent_id") + } + for index, candidate in enumerate(candidates, start=1): + if not isinstance(candidate, dict): + continue + source_id = str(candidate.get("id") or _candidate_id(target_url, "selected", index)) + parent_id = candidate.get("parent_id") + root_id = candidate.get("root_id") or source_id + level = str(candidate.get("level") or "case") + try: + depth = int(candidate.get("depth") or 0) + except (TypeError, ValueError): + depth = 0 + allow_child_discovery = ( + source_id not in selected_parent_ids + and level in {"site", "module"} + and depth < 2 + ) + mapped_parent_id = id_map.get(str(parent_id), parent_id) if parent_id else None + mapped_root_id = id_map.get(str(root_id), root_id) + task_payload = { + "task_type": "smart_discovery_p0", + "instruction": candidate.get("objective") or candidate.get("title"), + "objective": candidate.get("objective") or "", + "target_url": candidate.get("target_url") or target_url, + "source": candidate.get("source") or "discovery", + "level": level, + "depth": depth, + "risk_level": candidate.get("risk_level") or "low", + "allow_child_discovery": allow_child_discovery, + "discovery_max_depth": 2, + "preconditions": candidate.get("preconditions") or [], + "discovery_candidate": candidate, + "hierarchy": { + "id": source_id, + "parent_id": parent_id, + "root_id": root_id, + "depth": depth, + "level": level, + }, + } + if candidate.get("fields"): + task_payload["fields"] = candidate.get("fields") + if candidate.get("method"): + task_payload["method"] = candidate.get("method") + + created_task = db.create_task( + { + "external_id": source_id, + "title": candidate.get("title") or f"智能探索任务 {index}", + "plan_name": plan_name, + "module": candidate.get("module") or "", + "parent_external_id": mapped_parent_id, + "root_external_id": mapped_root_id, + "depth": depth, + "task_level": level, + "risk_level": candidate.get("risk_level") or "low", + "priority": candidate.get("priority") or 100, + "failure_policy": candidate.get("failure_policy") or "skip", + "max_retries": candidate.get("max_retries") or 2, + "timeout_seconds": candidate.get("timeout_seconds") or 60, + "tags": candidate.get("tags") or ["smart-discovery", "p0"], + "payload": task_payload, + } + ) + id_map[source_id] = str(created_task.get("external_id") or source_id) + created.append(created_task) + + if not created: + raise ValueError("No valid discovery tasks were selected.") + return {"count": len(created), "tasks": created} + + def _proxy_model_chat_json(self, payload: dict[str, Any]) -> dict[str, Any]: + stored = db.get_model_config(public=False)["value"] + base_url = str(stored.get("vllm_base_url") or "").strip() + model = str(stored.get("vllm_model") or "").strip() + api_key = str(stored.get("api_key") or "") + if not base_url or not model: + return { + "ok": False, + "source": "controller_proxy", + "error": "Model config is incomplete on controller.", + "duration_seconds": 0, + } + + try: + timeout = float(stored.get("request_timeout_seconds") or 30) + except (TypeError, ValueError): + timeout = 30 + timeout = max(1.0, min(timeout, 300.0)) + temperature = payload.get("temperature") + try: + temperature_value = float(temperature) if temperature is not None else float(stored.get("temperature") or 0) + except (TypeError, ValueError): + temperature_value = 0 + + url = _chat_completions_url(base_url) + request_payload = { + "model": model, + "messages": payload.get("messages") or [], + "temperature": temperature_value, + } + body = json.dumps(request_payload, ensure_ascii=False).encode("utf-8") + headers = { + "Accept": "application/json", + "Content-Type": "application/json; charset=utf-8", + } + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + request = Request(url, data=body, headers=headers, method="POST") + started = datetime.now(timezone.utc) + try: + with urlopen(request, timeout=timeout) as response: + raw = response.read(4_000_000) + except HTTPError as exc: + elapsed = (datetime.now(timezone.utc) - started).total_seconds() + error_body = exc.read().decode("utf-8", errors="replace") + return { + "ok": False, + "source": "controller_proxy", + "url": url, + "duration_seconds": round(elapsed, 3), + "error": f"{url} -> HTTP {exc.code}: {error_body or exc.reason}", + } + except (URLError, TimeoutError, OSError) as exc: + elapsed = (datetime.now(timezone.utc) - started).total_seconds() + return { + "ok": False, + "source": "controller_proxy", + "url": url, + "duration_seconds": round(elapsed, 3), + "error": f"{url} -> {exc}", + } + + elapsed = (datetime.now(timezone.utc) - started).total_seconds() + try: + data = json.loads(raw.decode("utf-8")) if raw else {} + except json.JSONDecodeError as exc: + return { + "ok": False, + "source": "controller_proxy", + "url": url, + "duration_seconds": round(elapsed, 3), + "error": f"Invalid model response: {exc}", + } + if not isinstance(data, dict): + return { + "ok": False, + "source": "controller_proxy", + "url": url, + "duration_seconds": round(elapsed, 3), + "error": "Invalid model response: root JSON is not an object.", + } + + content = _completion_content(data) + return { + "ok": True, + "source": "controller_proxy", + "url": url, + "duration_seconds": round(elapsed, 3), + "content": content, + "parsed": _parse_json_object(content), + } + + def _test_model(self, payload: dict[str, Any]) -> dict[str, Any]: + stored = db.get_model_config(public=False)["value"] + base_url = str(payload.get("vllm_base_url") or stored.get("vllm_base_url") or "").rstrip("/") + api_key = str(payload.get("api_key") or stored.get("api_key") or "") + timeout = float(payload.get("request_timeout_seconds") or stored.get("request_timeout_seconds") or 5) + if not base_url: + return {"ok": False, "message": "Missing vLLM/OpenAI-compatible base URL"} + url = base_url + if url.endswith("/v1"): + url = f"{url}/models" + elif not url.endswith("/models"): + url = f"{url}/v1/models" + headers = {"Accept": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + request = Request(url, headers=headers) + started = datetime.now(timezone.utc) + try: + with urlopen(request, timeout=timeout) as response: + raw = response.read(512_000) + elapsed = (datetime.now(timezone.utc) - started).total_seconds() + data = json.loads(raw.decode("utf-8")) if raw else {} + models = data.get("data") if isinstance(data, dict) else None + return { + "ok": True, + "url": url, + "elapsed_seconds": round(elapsed, 3), + "models": models[:20] if isinstance(models, list) else data, + } + except HTTPError as exc: + return {"ok": False, "url": url, "message": f"HTTP {exc.code}: {exc.reason}"} + except (URLError, TimeoutError, OSError, json.JSONDecodeError) as exc: + return {"ok": False, "url": url, "message": str(exc)} + + def _send_static_file(self, file_path: Path) -> None: + if not file_path.exists() or not file_path.is_file(): + self._send_error_json(HTTPStatus.NOT_FOUND, "Static file not found") + return + body = file_path.read_bytes() + content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream" + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + return ControllerRequestHandler + + +def main() -> None: + config = ControllerConfig.from_env() + db = Database(config.database_path, config.agent_stale_after_seconds) + checks = run_preflight(config, db) + + print("System Test Controller preflight:") + for check in checks: + status = "OK" if check["ok"] else "FAIL" + print(f" [{status}] {check['name']}: {check['detail']}") + + failed = [check for check in checks if not check["ok"]] + if failed: + print("Controller did not start because preflight checks failed.", file=sys.stderr) + raise SystemExit(2) + + handler = make_handler(config, db) + server = ThreadingHTTPServer((config.host, config.port), handler) + print(f"Controller started at {config.public_base_url}") + print(f"Health: {config.public_base_url}/health") + print(f"Panel: {config.public_base_url}/") + if _browser_autostart_enabled(): + print("Opening controller panel in browser...") + _open_panel_later(config.public_base_url) + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nController stopping...") + finally: + server.server_close() diff --git a/SystemTest-Controller/app/storage.py b/SystemTest-Controller/app/storage.py new file mode 100644 index 0000000..9031b88 --- /dev/null +++ b/SystemTest-Controller/app/storage.py @@ -0,0 +1,1205 @@ +from __future__ import annotations + +import hashlib +import json +import re +import secrets +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None: + if row is None: + return None + return {key: row[key] for key in row.keys()} + + +def json_loads(value: str | None, default: Any) -> Any: + if not value: + return default + try: + return json.loads(value) + except json.JSONDecodeError: + return default + + +def json_dumps(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True) + + +def as_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def as_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +class AlreadyRegisteredError(RuntimeError): + pass + + +class ManagedConnection(sqlite3.Connection): + def __exit__(self, exc_type, exc_value, exc_traceback): + try: + return super().__exit__(exc_type, exc_value, exc_traceback) + finally: + self.close() + + +DEFAULT_MODEL_CONFIG = { + "target_system_url": "", + "vllm_base_url": "", + "vllm_model": "", + "api_key": "", + "grounding_url": "", + "grounding_model": "", + "ground_coordinate_mode": "absolute", + "resolution": "1920x1080", + "screenshot_max_width": 1920, + "screenshot_max_height": 1200, + "max_steps": 40, + "request_timeout_seconds": 30, + "temperature": 0, +} + + +DEFAULT_TOOLS = [ + { + "id": "browser_control", + "name": "Browser Control", + "type": "interaction", + "version": "0.1", + "source": "controller", + "capability": "Open pages, click, type, navigate, and inspect Web UI state.", + "scenario": "Web / ERP / management system operation", + "available": True, + }, + { + "id": "screen_capture", + "name": "Screen Capture", + "type": "observation", + "version": "0.1", + "source": "agent", + "capability": "Capture controlled-device screenshots for model reasoning and evidence.", + "scenario": "Live monitoring and task evidence", + "available": True, + }, + { + "id": "keyboard_mouse", + "name": "Keyboard Mouse", + "type": "interaction", + "version": "0.1", + "source": "agent", + "capability": "Send keyboard and pointer actions to the controlled desktop.", + "scenario": "Desktop and browser automation", + "available": True, + }, + { + "id": "log_reader", + "name": "Log Reader", + "type": "evidence", + "version": "0.1", + "source": "agent", + "capability": "Read task logs and expose trace evidence for troubleshooting.", + "scenario": "Failure analysis and report evidence", + "available": True, + }, + { + "id": "report_generator", + "name": "Report Generator", + "type": "report", + "version": "0.1", + "source": "controller", + "capability": "Build run summaries from task status, duration, logs, and screenshots.", + "scenario": "Acceptance report and evidence package", + "available": True, + }, +] + + +DEFAULT_TOOL_CONFIG = { + "global_enabled": {tool["id"]: True for tool in DEFAULT_TOOLS}, + "agent_overrides": {}, + "plan_overrides": {}, +} + + +class Database: + def __init__(self, path: Path, agent_stale_after_seconds: int = 5): + self.path = path + self.agent_stale_after_seconds = agent_stale_after_seconds + + def connect(self) -> sqlite3.Connection: + self.path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(self.path, factory=ManagedConnection) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA journal_mode=WAL") + connection.execute("PRAGMA foreign_keys=ON") + return connection + + def init(self) -> None: + with self.connect() as db: + db.executescript( + """ + CREATE TABLE IF NOT EXISTS agents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_id TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + deployment_type TEXT NOT NULL, + machine_fingerprint TEXT NOT NULL, + token_hash TEXT NOT NULL, + agent_version TEXT NOT NULL, + registered_at TEXT NOT NULL, + last_seen_at TEXT, + status TEXT NOT NULL DEFAULT 'registered', + reported_host TEXT, + reported_ip TEXT, + reported_log_web_port INTEGER, + metadata_json TEXT NOT NULL DEFAULT '{}' + ); + + CREATE INDEX IF NOT EXISTS idx_agents_last_seen + ON agents(last_seen_at); + + CREATE TABLE IF NOT EXISTS heartbeats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id INTEGER NOT NULL, + received_at TEXT NOT NULL, + payload_json TEXT NOT NULL, + FOREIGN KEY(agent_id) REFERENCES agents(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + external_id TEXT UNIQUE, + title TEXT, + plan_name TEXT, + plan_version INTEGER NOT NULL DEFAULT 1, + module TEXT, + parent_external_id TEXT, + root_external_id TEXT, + depth INTEGER NOT NULL DEFAULT 0, + task_level TEXT, + risk_level TEXT, + status TEXT NOT NULL DEFAULT 'ready', + priority INTEGER NOT NULL DEFAULT 100, + assigned_agent_id INTEGER, + payload_json TEXT NOT NULL DEFAULT '{}', + failure_policy TEXT NOT NULL DEFAULT 'skip', + max_retries INTEGER NOT NULL DEFAULT 0, + timeout_seconds INTEGER, + dispatch_mode TEXT NOT NULL DEFAULT 'auto', + dependencies_json TEXT NOT NULL DEFAULT '[]', + tags_json TEXT NOT NULL DEFAULT '[]', + attempt INTEGER NOT NULL DEFAULT 0, + result_json TEXT, + last_error TEXT, + duration_seconds REAL, + model_duration_seconds REAL, + action_duration_seconds REAL, + operator_note TEXT, + created_at TEXT NOT NULL, + claimed_at TEXT, + started_at TEXT, + finished_at TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY(assigned_agent_id) REFERENCES agents(id) ON DELETE SET NULL + ); + + CREATE INDEX IF NOT EXISTS idx_tasks_status_priority + ON tasks(status, priority, created_at); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT, + actor TEXT NOT NULL DEFAULT 'system', + reason TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL + ); + """ + ) + self._migrate(db) + self._ensure_setting(db, "model_config", DEFAULT_MODEL_CONFIG) + self._ensure_setting( + db, + "tool_config", + { + "tools": DEFAULT_TOOLS, + "config": DEFAULT_TOOL_CONFIG, + }, + ) + + def _migrate(self, db: sqlite3.Connection) -> None: + self._ensure_column(db, "agents", "reported_log_web_port", "INTEGER") + self._ensure_column(db, "agents", "config_revision", "INTEGER NOT NULL DEFAULT 0") + self._ensure_column(db, "agents", "config_applied_at", "TEXT") + self._ensure_column(db, "agents", "config_restart_requested_at", "TEXT") + self._ensure_column(db, "agents", "config_json", "TEXT NOT NULL DEFAULT '{}'") + task_columns = { + "title": "TEXT", + "plan_name": "TEXT", + "plan_version": "INTEGER NOT NULL DEFAULT 1", + "module": "TEXT", + "parent_external_id": "TEXT", + "root_external_id": "TEXT", + "depth": "INTEGER NOT NULL DEFAULT 0", + "task_level": "TEXT", + "risk_level": "TEXT", + "failure_policy": "TEXT NOT NULL DEFAULT 'skip'", + "max_retries": "INTEGER NOT NULL DEFAULT 0", + "timeout_seconds": "INTEGER", + "dispatch_mode": "TEXT NOT NULL DEFAULT 'auto'", + "dependencies_json": "TEXT NOT NULL DEFAULT '[]'", + "tags_json": "TEXT NOT NULL DEFAULT '[]'", + "attempt": "INTEGER NOT NULL DEFAULT 0", + "result_json": "TEXT", + "last_error": "TEXT", + "duration_seconds": "REAL", + "model_duration_seconds": "REAL", + "action_duration_seconds": "REAL", + "operator_note": "TEXT", + "started_at": "TEXT", + "finished_at": "TEXT", + } + for name, definition in task_columns.items(): + self._ensure_column(db, "tasks", name, definition) + + def _ensure_column( + self, + db: sqlite3.Connection, + table_name: str, + column_name: str, + column_sql: str, + ) -> None: + columns = { + row["name"] + for row in db.execute(f"PRAGMA table_info({table_name})").fetchall() + } + if column_name not in columns: + db.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_sql}") + + def _ensure_setting(self, db: sqlite3.Connection, key: str, value: Any) -> None: + existing = db.execute("SELECT key FROM settings WHERE key = ?", (key,)).fetchone() + if existing is None: + db.execute( + """ + INSERT INTO settings (key, value_json, version, updated_at) + VALUES (?, ?, 1, ?) + """, + (key, json_dumps(value), utc_now()), + ) + + def _audit( + self, + db: sqlite3.Connection, + event_type: str, + entity_type: str, + entity_id: str | None, + payload: dict[str, Any] | None = None, + reason: str | None = None, + actor: str = "system", + ) -> None: + db.execute( + """ + INSERT INTO audit_events ( + event_type, entity_type, entity_id, actor, reason, payload_json, created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + event_type, + entity_type, + entity_id, + actor, + reason, + json_dumps(payload or {}), + utc_now(), + ), + ) + + def health(self) -> dict[str, Any]: + try: + with self.connect() as db: + db.execute("SELECT 1").fetchone() + agent_count = db.execute("SELECT COUNT(*) FROM agents").fetchone()[0] + task_count = db.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] + return { + "ok": True, + "agent_count": agent_count, + "task_count": task_count, + "path": str(self.path), + } + except sqlite3.Error as exc: + return {"ok": False, "error": str(exc), "path": str(self.path)} + + def register_agent(self, payload: dict[str, Any]) -> dict[str, Any]: + public_id = str(payload["public_id"]) + now = utc_now() + token = secrets.token_urlsafe(32) + metadata = payload.get("metadata") or {} + + with self.connect() as db: + existing = db.execute( + "SELECT id, machine_fingerprint, registered_at FROM agents WHERE public_id = ?", + (public_id,), + ).fetchone() + if existing is not None: + incoming_fingerprint = str(payload.get("machine_fingerprint") or "unknown") + existing_fingerprint = str(existing["machine_fingerprint"] or "unknown") + if existing_fingerprint != incoming_fingerprint: + raise AlreadyRegisteredError( + "Agent public_id is already registered by another machine fingerprint." + ) + + agent_id = int(existing["id"]) + db.execute( + """ + UPDATE agents + SET display_name = ?, + deployment_type = ?, + machine_fingerprint = ?, + token_hash = ?, + agent_version = ?, + status = 'registered', + reported_host = ?, + reported_ip = ?, + reported_log_web_port = ?, + metadata_json = ? + WHERE id = ? + """, + ( + str(payload.get("display_name") or public_id), + str(payload.get("deployment_type") or "unknown"), + incoming_fingerprint, + hash_token(token), + str(payload.get("agent_version") or "unknown"), + str(payload.get("reported_host") or ""), + str(payload.get("reported_ip") or ""), + payload.get("reported_log_web_port"), + json_dumps(metadata), + agent_id, + ), + ) + self._audit(db, "token_rotated", "agent", str(agent_id), {"public_id": public_id}) + return { + "agent_id": agent_id, + "public_id": public_id, + "token": token, + "registered": False, + "token_rotated": True, + "registered_at": str(existing["registered_at"]), + } + + cursor = db.execute( + """ + INSERT INTO agents ( + public_id, + display_name, + deployment_type, + machine_fingerprint, + token_hash, + agent_version, + registered_at, + status, + reported_host, + reported_ip, + reported_log_web_port, + metadata_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?, 'registered', ?, ?, ?, ?) + """, + ( + public_id, + str(payload.get("display_name") or public_id), + str(payload.get("deployment_type") or "unknown"), + str(payload.get("machine_fingerprint") or "unknown"), + hash_token(token), + str(payload.get("agent_version") or "unknown"), + now, + str(payload.get("reported_host") or ""), + str(payload.get("reported_ip") or ""), + payload.get("reported_log_web_port"), + json_dumps(metadata), + ), + ) + agent_id = cursor.lastrowid + self._audit(db, "registered", "agent", str(agent_id), {"public_id": public_id}) + + return { + "agent_id": agent_id, + "public_id": public_id, + "token": token, + "registered": True, + "registered_at": now, + } + + def validate_agent(self, agent_id: int, token: str) -> dict[str, Any] | None: + with self.connect() as db: + row = db.execute( + "SELECT * FROM agents WHERE id = ? AND token_hash = ?", + (agent_id, hash_token(token)), + ).fetchone() + return row_to_dict(row) + + def record_heartbeat(self, agent_id: int, payload: dict[str, Any]) -> dict[str, Any]: + now = utc_now() + deployment_type = str(payload.get("deployment_type") or "unknown") + display_name = str(payload.get("display_name") or "") + metadata = payload.get("metadata") or {} + status = str(payload.get("status") or "online") + reported_revision = as_int( + payload.get("config_revision", payload.get("model_config_version")), + 0, + ) + model_setting = self.get_model_config(public=False) + model_revision = as_int(model_setting.get("version"), 1) + update_available = reported_revision < model_revision + config_applied_at = now if reported_revision >= model_revision else None + config_restart_requested_at = now if update_available else None + + with self.connect() as db: + db.execute( + """ + UPDATE agents + SET last_seen_at = ?, + status = ?, + deployment_type = ?, + display_name = COALESCE(NULLIF(?, ''), display_name), + reported_host = COALESCE(NULLIF(?, ''), reported_host), + reported_ip = COALESCE(NULLIF(?, ''), reported_ip), + reported_log_web_port = ?, + metadata_json = ?, + config_json = ?, + config_revision = ?, + config_applied_at = COALESCE(?, config_applied_at), + config_restart_requested_at = COALESCE(?, config_restart_requested_at) + WHERE id = ? + """, + ( + now, + status, + deployment_type, + display_name, + str(payload.get("reported_host") or ""), + str(payload.get("reported_ip") or ""), + payload.get("reported_log_web_port"), + json_dumps(metadata), + json_dumps( + { + "model_config_version": reported_revision, + "runtime_generation": payload.get("runtime_generation"), + } + ), + reported_revision, + config_applied_at, + config_restart_requested_at, + agent_id, + ), + ) + db.execute( + """ + INSERT INTO heartbeats (agent_id, received_at, payload_json) + VALUES (?, ?, ?) + """, + (agent_id, now, json_dumps(payload)), + ) + + receipt: dict[str, Any] = { + "ok": True, + "agent_id": agent_id, + "received_at": now, + "model_config_revision": model_revision, + "agent_config_revision": reported_revision, + "config_update_available": update_available, + } + if update_available: + receipt["model_config"] = { + "version": model_revision, + "updated_at": model_setting.get("updated_at"), + "requires_agent_restart": True, + "value": self._public_model_config_value(model_setting.get("value") or DEFAULT_MODEL_CONFIG), + } + return receipt + + def _agent_status_with_stale_check(self, status: str, last_seen_at: str | None) -> str: + if not last_seen_at: + return status + try: + last_seen = datetime.fromisoformat(last_seen_at) + except ValueError: + return status + if last_seen.tzinfo is None: + last_seen = last_seen.replace(tzinfo=timezone.utc) + elapsed = (datetime.now(timezone.utc) - last_seen).total_seconds() + if elapsed > self.agent_stale_after_seconds: + return "offline" + return status + + def list_agents(self) -> list[dict[str, Any]]: + with self.connect() as db: + rows = db.execute( + """ + SELECT + id, + public_id, + display_name, + deployment_type, + machine_fingerprint, + agent_version, + registered_at, + last_seen_at, + status, + reported_host, + reported_ip, + reported_log_web_port, + config_revision, + config_applied_at, + config_restart_requested_at, + config_json, + metadata_json + FROM agents + ORDER BY last_seen_at DESC NULLS LAST, registered_at DESC + """ + ).fetchall() + agents: list[dict[str, Any]] = [] + for row in rows: + item = row_to_dict(row) or {} + item["config"] = json_loads(item.pop("config_json", "{}"), {}) + item["metadata"] = json_loads(item.pop("metadata_json", "{}"), {}) + item["raw_status"] = item.get("status") + item["status"] = self._agent_status_with_stale_check( + str(item.get("status") or "unknown"), + item.get("last_seen_at"), + ) + agents.append(item) + return agents + + def get_setting(self, key: str, default: Any) -> dict[str, Any]: + with self.connect() as db: + row = db.execute( + "SELECT value_json, version, updated_at FROM settings WHERE key = ?", + (key,), + ).fetchone() + if row is None: + return {"value": default, "version": 1, "updated_at": None} + return { + "value": json_loads(row["value_json"], default), + "version": row["version"], + "updated_at": row["updated_at"], + } + + def set_setting(self, key: str, value: Any, actor: str = "web") -> dict[str, Any]: + now = utc_now() + with self.connect() as db: + existing = db.execute( + "SELECT version FROM settings WHERE key = ?", + (key,), + ).fetchone() + version = int(existing["version"]) + 1 if existing else 1 + db.execute( + """ + INSERT INTO settings (key, value_json, version, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value_json = excluded.value_json, + version = excluded.version, + updated_at = excluded.updated_at + """, + (key, json_dumps(value), version, now), + ) + self._audit(db, "updated", "setting", key, {"version": version}, actor=actor) + return {"value": value, "version": version, "updated_at": now} + + def _public_model_config_value(self, value: dict[str, Any]) -> dict[str, Any]: + public_value = {**DEFAULT_MODEL_CONFIG, **value} + public_value["api_key_set"] = bool(public_value.get("api_key")) + public_value["api_key"] = "" + return public_value + + def _normalize_model_config( + self, + payload: dict[str, Any], + current: dict[str, Any] | None = None, + ) -> dict[str, Any]: + current_value = {**DEFAULT_MODEL_CONFIG, **(current or {})} + merged = {**current_value} + ignored = {"api_key_set", "version", "updated_at"} + for key, value in payload.items(): + if key in ignored or key == "api_key": + continue + if key in DEFAULT_MODEL_CONFIG: + merged[key] = value + + raw_api_key = str(payload.get("api_key") or "") + if payload.get("clear_api_key"): + merged["api_key"] = "" + elif raw_api_key: + merged["api_key"] = raw_api_key + else: + merged["api_key"] = str(current_value.get("api_key") or "") + + text_fields = [ + "target_system_url", + "vllm_base_url", + "vllm_model", + "grounding_url", + "grounding_model", + "ground_coordinate_mode", + "resolution", + "api_key", + ] + for field in text_fields: + merged[field] = str(merged.get(field) or "").strip() + + for field in ["target_system_url", "vllm_base_url", "grounding_url"]: + value = merged.get(field) + if value and not str(value).startswith(("http://", "https://")): + raise ValueError(f"{field} must start with http:// or https://") + + if not re.fullmatch(r"\d{3,5}x\d{3,5}", str(merged.get("resolution") or "")): + raise ValueError("resolution must use WIDTHxHEIGHT format, for example 1920x1080") + + numeric_ranges = { + "screenshot_max_width": (1, 7680), + "screenshot_max_height": (1, 7680), + "max_steps": (1, 500), + "request_timeout_seconds": (1, 300), + } + for field, (minimum, maximum) in numeric_ranges.items(): + value = as_int(merged.get(field), as_int(DEFAULT_MODEL_CONFIG[field])) + if not minimum <= value <= maximum: + raise ValueError(f"{field} must be between {minimum} and {maximum}") + merged[field] = value + + temperature = as_float(merged.get("temperature"), as_float(DEFAULT_MODEL_CONFIG["temperature"])) + if not 0 <= temperature <= 2: + raise ValueError("temperature must be between 0 and 2") + merged["temperature"] = temperature + + if merged["ground_coordinate_mode"] not in {"absolute", "normalized"}: + raise ValueError("ground_coordinate_mode must be absolute or normalized") + + return merged + + def get_model_config(self, public: bool = True) -> dict[str, Any]: + setting = self.get_setting("model_config", DEFAULT_MODEL_CONFIG) + value = {**DEFAULT_MODEL_CONFIG, **(setting.get("value") or {})} + if public: + value = self._public_model_config_value(value) + return { + "value": value, + "version": setting["version"], + "updated_at": setting["updated_at"], + } + + def save_model_config(self, payload: dict[str, Any]) -> dict[str, Any]: + current = self.get_model_config(public=False)["value"] + merged = self._normalize_model_config(payload, current) + saved = self.set_setting("model_config", merged, actor="web") + return { + "value": self._public_model_config_value(saved["value"]), + "version": saved["version"], + "updated_at": saved["updated_at"], + } + + def get_tool_config(self) -> dict[str, Any]: + value = self.get_setting( + "tool_config", + {"tools": DEFAULT_TOOLS, "config": DEFAULT_TOOL_CONFIG}, + ) + tool_payload = value["value"] + tools = tool_payload.get("tools") or DEFAULT_TOOLS + config = {**DEFAULT_TOOL_CONFIG, **(tool_payload.get("config") or {})} + return { + "tools": tools, + "config": config, + "version": value["version"], + "updated_at": value["updated_at"], + } + + def save_tool_config(self, payload: dict[str, Any]) -> dict[str, Any]: + current = self.get_tool_config() + tools = payload.get("tools") or current["tools"] + config = {**DEFAULT_TOOL_CONFIG, **current["config"], **(payload.get("config") or {})} + saved = self.set_setting( + "tool_config", + {"tools": tools, "config": config}, + actor="web", + ) + return { + "tools": tools, + "config": config, + "version": saved["version"], + "updated_at": saved["updated_at"], + } + + def _dedupe_external_id(self, db: sqlite3.Connection, external_id: str | None) -> str | None: + if not external_id: + return None + candidate = external_id + suffix = 2 + while db.execute("SELECT 1 FROM tasks WHERE external_id = ?", (candidate,)).fetchone(): + candidate = f"{external_id}-{suffix}" + suffix += 1 + return candidate + + def _normalize_task_payload( + self, + payload: dict[str, Any], + defaults: dict[str, Any] | None = None, + ) -> dict[str, Any]: + defaults = defaults or {} + task_payload = payload.get("payload") if isinstance(payload.get("payload"), dict) else payload + external_id = ( + payload.get("external_id") + or payload.get("task_id") + or payload.get("id") + or task_payload.get("task_id") + or task_payload.get("id") + ) + instruction = task_payload.get("instruction") or payload.get("instruction") or "" + title = ( + payload.get("title") + or task_payload.get("title") + or task_payload.get("name") + or (str(instruction)[:64] if instruction else external_id) + or "Untitled task" + ) + return { + "external_id": str(external_id) if external_id else None, + "title": str(title), + "plan_name": str(payload.get("plan_name") or task_payload.get("plan_name") or defaults.get("plan_name") or "Default Plan"), + "plan_version": as_int(payload.get("plan_version") or defaults.get("plan_version"), 1), + "module": str(payload.get("module") or task_payload.get("module") or task_payload.get("category") or ""), + "parent_external_id": ( + payload.get("parent_external_id") + or payload.get("parent_id") + or task_payload.get("parent_external_id") + or task_payload.get("parent_id") + ), + "root_external_id": ( + payload.get("root_external_id") + or payload.get("root_id") + or task_payload.get("root_external_id") + or task_payload.get("root_id") + ), + "depth": as_int(payload.get("depth") or task_payload.get("depth"), 0), + "task_level": str(payload.get("task_level") or payload.get("level") or task_payload.get("task_level") or task_payload.get("level") or ""), + "risk_level": str(payload.get("risk_level") or task_payload.get("risk_level") or "low"), + "status": str(payload.get("status") or defaults.get("status") or "ready"), + "priority": as_int( + payload.get("priority") + or task_payload.get("priority") + or task_payload.get("task_priority") + or defaults.get("priority"), + 100, + ), + "assigned_agent_id": payload.get("assigned_agent_id") or defaults.get("assigned_agent_id"), + "payload": task_payload, + "failure_policy": str( + payload.get("failure_policy") + or task_payload.get("failure_policy") + or task_payload.get("task_failure_policy") + or defaults.get("failure_policy") + or "skip" + ), + "max_retries": as_int(payload.get("max_retries") or task_payload.get("retries") or defaults.get("max_retries"), 0), + "timeout_seconds": payload.get("timeout_seconds") or task_payload.get("timeout") or defaults.get("timeout_seconds"), + "dispatch_mode": str(payload.get("dispatch_mode") or defaults.get("dispatch_mode") or "auto"), + "dependencies": payload.get("dependencies") or task_payload.get("depends_on") or [], + "tags": payload.get("tags") or task_payload.get("tags") or [], + } + + def create_task(self, payload: dict[str, Any]) -> dict[str, Any]: + task = self._normalize_task_payload(payload) + now = utc_now() + with self.connect() as db: + task["external_id"] = self._dedupe_external_id(db, task["external_id"]) + if not task["root_external_id"]: + task["root_external_id"] = task["external_id"] + cursor = db.execute( + """ + INSERT INTO tasks ( + external_id, title, plan_name, plan_version, module, + parent_external_id, root_external_id, depth, task_level, risk_level, + status, priority, + assigned_agent_id, payload_json, failure_policy, max_retries, + timeout_seconds, dispatch_mode, dependencies_json, tags_json, + created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + task["external_id"], + task["title"], + task["plan_name"], + task["plan_version"], + task["module"], + task["parent_external_id"], + task["root_external_id"], + task["depth"], + task["task_level"], + task["risk_level"], + task["status"], + task["priority"], + task["assigned_agent_id"], + json_dumps(task["payload"]), + task["failure_policy"], + task["max_retries"], + task["timeout_seconds"], + task["dispatch_mode"], + json_dumps(task["dependencies"]), + json_dumps(task["tags"]), + now, + now, + ), + ) + task_id = cursor.lastrowid + self._audit(db, "created", "task", str(task_id), {"external_id": task["external_id"]}, actor="web") + return self.get_task(int(task_id)) or {} + + def _row_to_task(self, row: sqlite3.Row) -> dict[str, Any]: + task = row_to_dict(row) or {} + task["payload"] = json_loads(task.pop("payload_json", "{}"), {}) + task["dependencies"] = json_loads(task.pop("dependencies_json", "[]"), []) + task["tags"] = json_loads(task.pop("tags_json", "[]"), []) + task["result"] = json_loads(task.pop("result_json", None), None) + return task + + def get_task(self, task_id: int) -> dict[str, Any] | None: + with self.connect() as db: + row = db.execute( + """ + SELECT t.*, a.display_name AS assigned_agent_name + FROM tasks t + LEFT JOIN agents a ON a.id = t.assigned_agent_id + WHERE t.id = ? + """, + (task_id,), + ).fetchone() + return self._row_to_task(row) if row else None + + def list_tasks(self, status: str | None = None, plan_name: str | None = None) -> list[dict[str, Any]]: + where: list[str] = [] + params: list[Any] = [] + if status: + where.append("t.status = ?") + params.append(status) + if plan_name: + where.append("t.plan_name = ?") + params.append(plan_name) + where_sql = "WHERE " + " AND ".join(where) if where else "" + with self.connect() as db: + rows = db.execute( + f""" + SELECT t.*, a.display_name AS assigned_agent_name + FROM tasks t + LEFT JOIN agents a ON a.id = t.assigned_agent_id + {where_sql} + ORDER BY t.priority ASC, t.created_at DESC + """, + params, + ).fetchall() + return [self._row_to_task(row) for row in rows] + + def import_tasks(self, payload: dict[str, Any]) -> dict[str, Any]: + raw = payload.get("content", payload.get("tasks", payload)) + if isinstance(raw, str): + raw = json.loads(raw) + + plan_name = payload.get("plan_name") + plan_version = as_int(payload.get("plan_version"), 1) + entries: list[dict[str, Any]] = [] + + if isinstance(raw, list): + for item in raw: + if isinstance(item, dict) and isinstance(item.get("tasks"), list): + name = item.get("plan_name") or item.get("name") or plan_name + for child in item["tasks"]: + if isinstance(child, dict): + entries.append({**child, "plan_name": child.get("plan_name") or name}) + elif isinstance(item, dict): + entries.append(item) + elif isinstance(raw, dict): + plan_name = raw.get("plan_name") or raw.get("name") or plan_name + if isinstance(raw.get("plans"), list): + for plan in raw["plans"]: + if not isinstance(plan, dict): + continue + name = plan.get("plan_name") or plan.get("name") or plan_name + for child in plan.get("tasks") or []: + if isinstance(child, dict): + entries.append({**child, "plan_name": child.get("plan_name") or name}) + elif isinstance(raw.get("tasks"), list): + for child in raw["tasks"]: + if isinstance(child, dict): + entries.append({**child, "plan_name": child.get("plan_name") or plan_name}) + elif isinstance(raw.get("tasks"), dict): + for key, child in raw["tasks"].items(): + if isinstance(child, dict): + entries.append({**child, "task_id": child.get("task_id") or key, "plan_name": child.get("plan_name") or plan_name}) + else: + entries.append(raw) + else: + raise ValueError("Unsupported task JSON shape") + + defaults = { + "plan_name": plan_name or "Imported Plan", + "plan_version": plan_version, + "priority": payload.get("priority") or 100, + } + created = [self.create_task({**entry, **{"plan_name": entry.get("plan_name") or defaults["plan_name"]}, "plan_version": plan_version}) for entry in entries] + return {"count": len(created), "tasks": created} + + def claim_task(self, agent_id: int) -> dict[str, Any] | None: + now = utc_now() + with self.connect() as db: + row = db.execute( + """ + SELECT * + FROM tasks + WHERE status IN ('ready', 'routed') + AND (assigned_agent_id IS NULL OR assigned_agent_id = ?) + ORDER BY priority ASC, created_at ASC + LIMIT 1 + """, + (agent_id,), + ).fetchone() + if row is None: + return None + + db.execute( + """ + UPDATE tasks + SET status = 'running', + assigned_agent_id = ?, + claimed_at = ?, + started_at = COALESCE(started_at, ?), + updated_at = ? + WHERE id = ? + """, + (agent_id, now, now, now, row["id"]), + ) + self._audit(db, "claimed", "task", str(row["id"]), {"agent_id": agent_id}) + + return self.get_task(int(row["id"])) + + def assign_task(self, task_id: int, agent_id: int | None, reason: str | None = None) -> dict[str, Any]: + now = utc_now() + with self.connect() as db: + db.execute( + """ + UPDATE tasks + SET assigned_agent_id = ?, + dispatch_mode = CASE WHEN ? IS NULL THEN 'auto' ELSE 'manual' END, + status = CASE WHEN ? IS NULL THEN 'ready' ELSE 'routed' END, + updated_at = ? + WHERE id = ? + """, + (agent_id, agent_id, agent_id, now, task_id), + ) + self._audit(db, "assigned", "task", str(task_id), {"agent_id": agent_id}, reason=reason, actor="web") + return self.get_task(task_id) or {} + + def update_task_status( + self, + task_id: int, + status: str, + reason: str | None = None, + payload: dict[str, Any] | None = None, + ) -> dict[str, Any]: + now = utc_now() + finish_statuses = {"succeeded", "failed", "cancelled"} + with self.connect() as db: + db.execute( + """ + UPDATE tasks + SET status = ?, + last_error = COALESCE(?, last_error), + result_json = COALESCE(?, result_json), + finished_at = CASE WHEN ? THEN COALESCE(finished_at, ?) ELSE finished_at END, + updated_at = ? + WHERE id = ? + """, + ( + status, + reason, + json_dumps(payload) if payload is not None else None, + 1 if status in finish_statuses else 0, + now, + now, + task_id, + ), + ) + self._audit(db, "status_changed", "task", str(task_id), {"status": status}, reason=reason, actor="web") + return self.get_task(task_id) or {} + + def retry_task(self, task_id: int, reason: str | None = None) -> dict[str, Any]: + now = utc_now() + with self.connect() as db: + db.execute( + """ + UPDATE tasks + SET status = 'ready', + attempt = attempt + 1, + assigned_agent_id = NULL, + claimed_at = NULL, + started_at = NULL, + finished_at = NULL, + updated_at = ? + WHERE id = ? + """, + (now, task_id), + ) + self._audit(db, "retried", "task", str(task_id), {}, reason=reason, actor="web") + return self.get_task(task_id) or {} + + def record_task_result( + self, + task_id: int, + result: dict[str, Any], + status: str | None = None, + ) -> dict[str, Any]: + now = utc_now() + final_status = status or ("succeeded" if result.get("success") else "failed") + with self.connect() as db: + db.execute( + """ + UPDATE tasks + SET status = ?, + result_json = ?, + last_error = ?, + duration_seconds = ?, + model_duration_seconds = ?, + action_duration_seconds = ?, + finished_at = ?, + updated_at = ? + WHERE id = ? + """, + ( + final_status, + json_dumps(result), + result.get("message") if not result.get("success") else None, + result.get("duration_seconds") or result.get("duration"), + result.get("model_duration_seconds"), + result.get("action_duration_seconds"), + now, + now, + task_id, + ), + ) + self._audit(db, "result_reported", "task", str(task_id), {"status": final_status}) + return self.get_task(task_id) or {} + + def dashboard(self) -> dict[str, Any]: + agents = self.list_agents() + tasks = self.list_tasks() + metrics = self.metrics() + return { + "agents": agents, + "tasks": tasks[:50], + "metrics": metrics, + "model_config": self.get_model_config(), + "tool_config": self.get_tool_config(), + } + + def metrics(self) -> dict[str, Any]: + agents = self.list_agents() + tasks = self.list_tasks() + total = len(tasks) + status_counts: dict[str, int] = {} + for task in tasks: + status_counts[task["status"]] = status_counts.get(task["status"], 0) + 1 + succeeded = status_counts.get("succeeded", 0) + failed = status_counts.get("failed", 0) + cancelled = status_counts.get("cancelled", 0) + completed = succeeded + failed + cancelled + running = status_counts.get("running", 0) + ready = status_counts.get("ready", 0) + status_counts.get("routed", 0) + active_agents = len([a for a in agents if a.get("status") in {"online", "idle", "running"}]) + durations = [as_float(t.get("duration_seconds")) for t in tasks if t.get("duration_seconds")] + avg_duration = round(sum(durations) / len(durations), 2) if durations else 0 + interventions = len([t for t in tasks if t.get("dispatch_mode") == "manual" or t.get("operator_note")]) + retried = len([t for t in tasks if as_int(t.get("attempt")) > 0]) + plan_names = sorted({t.get("plan_name") or "Default Plan" for t in tasks}) + + return { + "total_tasks": total, + "ready_tasks": ready, + "running_tasks": running, + "completed_tasks": completed, + "succeeded_tasks": succeeded, + "failed_tasks": failed, + "cancelled_tasks": cancelled, + "agent_total": len(agents), + "agent_active": active_agents, + "pass_rate": round((succeeded / (succeeded + failed)) * 100, 1) if succeeded + failed else 0, + "fail_rate": round((failed / (succeeded + failed)) * 100, 1) if succeeded + failed else 0, + "coverage_rate": round((completed / total) * 100, 1) if total else 0, + "avg_duration_seconds": avg_duration, + "concurrency_utilization": round((running / active_agents) * 100, 1) if active_agents else 0, + "manual_intervention_rate": round((interventions / total) * 100, 1) if total else 0, + "recovery_rate": round((retried / max(failed + retried, 1)) * 100, 1) if (failed or retried) else 0, + "tool_consistency_rate": 100.0 if self.get_tool_config()["tools"] else 0, + "status_counts": status_counts, + "plans": plan_names, + } + + def report_summary(self) -> dict[str, Any]: + tasks = self.list_tasks() + metrics = self.metrics() + failed = [t for t in tasks if t.get("status") == "failed"] + plans: dict[str, dict[str, Any]] = {} + for task in tasks: + name = task.get("plan_name") or "Default Plan" + item = plans.setdefault( + name, + {"plan_name": name, "total": 0, "succeeded": 0, "failed": 0, "cancelled": 0}, + ) + item["total"] += 1 + if task["status"] in item: + item[task["status"]] += 1 + return { + "generated_at": utc_now(), + "metrics": metrics, + "plans": list(plans.values()), + "failed_tasks": [ + { + "id": task["id"], + "external_id": task.get("external_id"), + "title": task.get("title"), + "plan_name": task.get("plan_name"), + "reason": task.get("last_error"), + "evidence": (task.get("result") or {}).get("screenshots") + or (task.get("result") or {}).get("logs"), + } + for task in failed[:50] + ], + } diff --git a/SystemTest-Controller/start_controller.bat b/SystemTest-Controller/start_controller.bat new file mode 100644 index 0000000..b7c1d3c --- /dev/null +++ b/SystemTest-Controller/start_controller.bat @@ -0,0 +1,21 @@ +@echo off +setlocal +cd /d "%~dp0" +if defined PYTHON_EXE ( + if exist "%PYTHON_EXE%" ( + "%PYTHON_EXE%" -m app + goto :end + ) +) + +set "CODEX_PYTHON=%USERPROFILE%\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" +if exist "%CODEX_PYTHON%" ( + "%CODEX_PYTHON%" -m app + goto :end +) + +python -m app +if errorlevel 1 py -3 -m app + +:end +endlocal diff --git a/SystemTest-Controller/start_controller.ps1 b/SystemTest-Controller/start_controller.ps1 new file mode 100644 index 0000000..76d8a81 --- /dev/null +++ b/SystemTest-Controller/start_controller.ps1 @@ -0,0 +1,53 @@ +param( + [string]$HostName, + [int]$Port = 0 +) + +$ErrorActionPreference = "Stop" +Set-Location -LiteralPath $PSScriptRoot + +if ($HostName) { + $env:CONTROLLER_HOST = $HostName +} +if ($Port -gt 0) { + $env:CONTROLLER_PORT = [string]$Port + if (-not $env:CONTROLLER_PUBLIC_BASE_URL) { + $env:CONTROLLER_PUBLIC_BASE_URL = "http://127.0.0.1:$Port" + } +} + +$pythonExe = $null +$pythonArgs = @() + +if ($env:PYTHON_EXE -and (Test-Path -LiteralPath $env:PYTHON_EXE)) { + $pythonExe = $env:PYTHON_EXE +} + +if (-not $pythonExe) { + $bundledPython = Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' + if (Test-Path -LiteralPath $bundledPython) { + $pythonExe = $bundledPython + } +} + +if (-not $pythonExe) { + $pythonCommand = Get-Command python -ErrorAction SilentlyContinue + if ($pythonCommand) { + $pythonExe = $pythonCommand.Source + } +} + +if (-not $pythonExe) { + $pythonCommand = Get-Command py -ErrorAction SilentlyContinue + if ($pythonCommand) { + $pythonExe = $pythonCommand.Source + $pythonArgs = @('-3') + } +} + +if (-not $pythonExe) { + throw "Python 3 was not found. Install Python 3, set PYTHON_EXE, or add Python to PATH." +} + +& $pythonExe @pythonArgs -m app +exit $LASTEXITCODE diff --git a/SystemTest-Controller/web/app.js b/SystemTest-Controller/web/app.js new file mode 100644 index 0000000..7422869 --- /dev/null +++ b/SystemTest-Controller/web/app.js @@ -0,0 +1,1471 @@ +const state = { + currentSection: "overview", + health: null, + agents: [], + tasks: [], + metrics: {}, + modelConfig: { value: {}, version: 1 }, + siteScan: { + status: "idle", + host: "", + ports: "80,443,3000,5173,8000-8010,8080,8081,9000", + sites: [], + message: "", + }, + systemTest: { + targetUrl: "", + assignedAgentId: "", + maxDepth: 2, + status: "idle", + message: "", + }, + discovery: { + status: "idle", + targetUrl: "", + planName: "智能探索 P0", + candidates: [], + summary: null, + message: "", + }, + toolConfig: { tools: [], config: {}, version: 1 }, + report: null, + selectedTaskId: "", + selectedToolAgent: "", + selectedToolPlan: "", +}; + +const titles = { + overview: "总览", + plans: "系统测试", + console: "运行控制台", + devices: "设备监控", + models: "模型配置", + tools: "Tool 配置", + metrics: "指标看板", + reports: "报告中心", +}; + +const $ = (selector, root = document) => root.querySelector(selector); +const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector)); + +function icon(name) { + return ``; +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function formatDate(value) { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return escapeHtml(value); + return date.toLocaleString(); +} + +function percent(value) { + const n = Number(value || 0); + return `${n.toFixed(n % 1 ? 1 : 0)}%`; +} + +function duration(value) { + if (value == null || value === "") return "-"; + const n = Number(value); + if (Number.isNaN(n)) return "-"; + return n >= 60 ? `${(n / 60).toFixed(1)} min` : `${n.toFixed(1)} s`; +} + +function statusBadge(status) { + const normalized = String(status || "-"); + const ok = ["online", "idle", "succeeded", "healthy"].includes(normalized); + const warn = ["ready", "routed", "running", "registered", "claimed"].includes(normalized); + const fail = ["failed", "cancelled", "offline"].includes(normalized); + const cls = ok ? "ok" : fail ? "fail" : warn ? "warn" : "info"; + return `${escapeHtml(normalized)}`; +} + +function showToast(message) { + const toast = $("#toast"); + toast.textContent = message; + toast.hidden = false; + clearTimeout(showToast.timer); + showToast.timer = setTimeout(() => { + toast.hidden = true; + }, 3200); +} + +async function fetchJson(url, options = {}) { + const response = await fetch(url, { + ...options, + headers: { + Accept: "application/json", + ...(options.body ? { "Content-Type": "application/json; charset=utf-8" } : {}), + ...(options.headers || {}), + }, + }); + const data = await response.json().catch(() => ({})); + if (!response.ok || data.ok === false) { + throw new Error(data.error || `${response.status} ${response.statusText}`); + } + return data; +} + +async function postJson(url, payload = {}) { + return fetchJson(url, { method: "POST", body: JSON.stringify(payload) }); +} + +async function refreshAll() { + $("#refreshButton").disabled = true; + try { + const [health, dashboard, report] = await Promise.all([ + fetchJson("/health"), + fetchJson("/api/dashboard"), + fetchJson("/api/reports/summary"), + ]); + state.health = health; + state.agents = dashboard.agents || []; + state.tasks = dashboard.tasks || []; + state.metrics = dashboard.metrics || {}; + state.modelConfig = dashboard.model_config || { value: {}, version: 1 }; + state.toolConfig = dashboard.tool_config || { tools: [], config: {}, version: 1 }; + state.report = report.report; + renderAll(); + } catch (error) { + renderHealthError(error); + showToast(error.message); + } finally { + $("#refreshButton").disabled = false; + } +} + +function renderHealthError(error) { + const pill = $("#healthPill"); + pill.classList.remove("ok"); + pill.classList.add("fail"); + pill.textContent = "offline"; + $("#serviceMeta").textContent = error.message; +} + +function renderChrome() { + const health = state.health; + const pill = $("#healthPill"); + pill.classList.toggle("ok", Boolean(health?.ok)); + pill.classList.toggle("fail", !health?.ok); + pill.textContent = health?.ok ? "healthy" : "attention"; + $("#sectionTitle").textContent = titles[state.currentSection]; + $("#serviceMeta").textContent = health + ? `${health.name} · ${health.public_base_url} · ${health.deployment_mode}` + : "Loading..."; +} + +function renderAll() { + renderChrome(); + renderCurrentView(); +} + +function renderCurrentView() { + if (state.currentSection === "overview") renderOverview(); + if (state.currentSection === "plans") renderPlans(); + if (state.currentSection === "console") renderConsole(); + if (state.currentSection === "devices") renderDevices(); + if (state.currentSection === "models") renderModels(); + if (state.currentSection === "tools") renderTools(); + if (state.currentSection === "metrics") renderMetrics(); + if (state.currentSection === "reports") renderReports(); +} + +function kpi(label, value, hint) { + return `
${escapeHtml(value)}${escapeHtml(hint)}
`; +} + +function renderOverview() { + const m = state.metrics; + const health = state.health; + $("#view-overview").innerHTML = ` +
+
+ ${kpi("在线被控端", `${m.agent_active || 0}/${m.agent_total || 0}`, "盒子物理机 / 虚拟机")} + ${kpi("待执行任务", m.ready_tasks || 0, "ready + routed")} + ${kpi("运行中", m.running_tasks || 0, "Agent 占用")} + ${kpi("通过率", percent(m.pass_rate), `${m.succeeded_tasks || 0} passed / ${m.failed_tasks || 0} failed`)} +
+ +
+
+
+

主控健康

+ ${statusBadge(health?.ok ? "healthy" : "attention")} +
+
+
+
数据库
${health?.checks?.database?.ok ? "OK" : "FAIL"}
+
任务数
${escapeHtml(health?.checks?.database?.task_count ?? 0)}
+
端口
${escapeHtml(`${health?.checks?.port?.host || "-"}:${health?.checks?.port?.port || "-"}`)}
+
静态面板
${health?.checks?.static_panel?.ok ? "OK" : "MISSING"}
+
启动时间
${formatDate(health?.started_at)}
+
+
+
+ +
+
+

最近被控端

+ ${state.agents.length} devices +
+
+ ${renderAgentMiniList()} +
+
+
+ +
+
+

最近任务

+ +
+
${renderTaskTable(state.tasks.slice(0, 8), { actions: false })}
+
+
+ `; +} + +function renderAgentMiniList() { + if (!state.agents.length) return `

暂无被控端接入

`; + return ` +
+ ${state.agents.slice(0, 4).map((agent) => ` +
+
+
+ ${escapeHtml(agent.display_name || agent.public_id)} + ${escapeHtml(agent.deployment_type || "-")} +
+ ${statusBadge(agent.status)} +
+
+
IP
${escapeHtml(agent.reported_ip || agent.reported_host || "-")}
+
日志端口
${escapeHtml(agent.reported_log_web_port || "-")}
+
心跳
${formatDate(agent.last_seen_at)}
+
+
+ `).join("")} +
+ `; +} + +function renderDiscoveryPanel() { + const discovery = state.discovery; + const cfg = state.modelConfig.value || {}; + const targetUrl = discovery.targetUrl || cfg.target_system_url || ""; + const selectedCount = (discovery.candidates || []).filter((candidate) => candidate.selected !== false).length; + return ` +
+
+

智能探索 P0

+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+
${renderDiscoveryResult()}
+
+
+ `; +} + +function renderDiscoveryResult() { + const discovery = state.discovery; + if (discovery.status === "idle") { + return `
输入待测系统地址后点击“发现任务”,Agent 会先基于首页结构拆出 P0 子任务。
`; + } + if (discovery.status === "running") { + return `
正在读取页面结构并生成候选任务...
`; + } + if (discovery.status === "error") { + return `
${escapeHtml(discovery.message || "发现任务失败")}
`; + } + + const candidates = discovery.candidates || []; + if (!candidates.length) { + return `
未发现可入队任务。
`; + } + + const summary = discovery.summary || {}; + const rows = candidates.map((candidate) => { + const depth = Math.max(0, Number(candidate.depth || 0)); + const risk = candidate.risk_level || "low"; + const selected = candidate.selected !== false; + const preconditions = Array.isArray(candidate.preconditions) && candidate.preconditions.length + ? `前置:${escapeHtml(candidate.preconditions.join("、"))}` + : ""; + return ` + + `; + }).join(""); + + return ` +
+ 页面:${escapeHtml(summary.page_title || summary.final_url || summary.target_url || "-")} + 候选 ${escapeHtml(summary.candidate_count || candidates.length)} 个 + 链接 ${escapeHtml(summary.link_count || 0)} / 表单 ${escapeHtml(summary.form_count || 0)} / 入口 ${escapeHtml(summary.button_count || 0)} + +
+
${rows}
+ `; +} + +function activeAgents() { + return state.agents.filter((agent) => ["online", "idle", "running", "registered"].includes(String(agent.status || ""))); +} + +function systemTestTargetUrl() { + const cfg = state.modelConfig.value || {}; + return state.systemTest.targetUrl || cfg.target_system_url || ""; +} + +function renderSystemTestAgentOptions() { + const selected = String(state.systemTest.assignedAgentId || ""); + return [``] + .concat(state.agents.map((agent) => { + const label = `${agent.display_name || agent.public_id || agent.id} ${agent.reported_ip || ""} ${agent.status || ""}`.trim(); + return ``; + })) + .join(""); +} + +function renderSystemTestStatus() { + const activeCount = activeAgents().length; + const status = state.systemTest.status; + const message = state.systemTest.message || (activeCount ? `${activeCount} 个被控端可接收任务` : "暂无在线被控端,任务会先进入队列"); + const cls = status === "error" ? "fail" : status === "starting" ? "warn" : activeCount ? "ok" : "info"; + return `
${escapeHtml(status)}${escapeHtml(message)}
`; +} + +function readSystemTestForm() { + const targetUrl = $("#systemTestTargetUrl")?.value.trim() || ""; + const assignedAgentId = $("#systemTestAgent")?.value || ""; + const maxDepth = Math.max(1, Math.min(5, Number($("#systemTestMaxDepth")?.value || 2))); + state.systemTest = { + ...state.systemTest, + targetUrl, + assignedAgentId, + maxDepth, + }; + return { target_url: targetUrl, assigned_agent_id: assignedAgentId || null, max_depth: maxDepth }; +} + +function renderPlans() { + const targetUrl = systemTestTargetUrl(); + $("#view-plans").innerHTML = ` +
+
+
+

系统测试

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
${renderSystemTestStatus()}
+
+
+ +
+
+

网站扫描

+ IP + Port +
+
${renderSiteScanner()}
+
+ +
+
+

被控端

+ ${state.agents.length} devices +
+
${renderAgentMiniList()}
+
+ +
+
+

任务队列

+ +
+
${renderTaskTable(state.tasks, { actions: false })}
+
+ + ${renderTaskDetail()} +
+ `; +} + +function renderConsole() { + $("#view-console").innerHTML = ` +
+
+ ${kpi("队列", state.metrics.ready_tasks || 0, "ready / routed")} + ${kpi("运行", state.metrics.running_tasks || 0, "running")} + ${kpi("完成", state.metrics.completed_tasks || 0, "succeeded / failed / cancelled")} + ${kpi("并发利用", percent(state.metrics.concurrency_utilization), "running / online agents")} +
+
+
+

任务队列

+
+ + +
+
+
${renderTaskTable(state.tasks, { actions: true })}
+
+ ${renderTaskDetail()} +
+ `; +} + +function renderTaskMeta(task) { + const level = task.task_level || task.payload?.level || ""; + const risk = task.risk_level || task.payload?.risk_level || ""; + const depth = Number(task.depth || task.payload?.depth || 0); + const parts = []; + if (level) parts.push(`层级 ${depth} · ${level}`); + if (risk) parts.push(`风险 ${risk}`); + if (task.parent_external_id) parts.push(`父任务 ${task.parent_external_id}`); + return parts.length ? `${escapeHtml(parts.join(" / "))}` : ""; +} + +function renderTaskTable(tasks, options = { actions: true }) { + if (!tasks.length) return `
暂无任务
`; + const showActions = options.actions !== false; + const showDetails = options.details !== false; + const agentOptions = [``] + .concat(state.agents.map((a) => ``)) + .join(""); + return ` + + + + ${showDetails || showActions ? "" : ""} + + + + ${tasks.map((task) => ` + + + + + + + + ${showDetails || showActions ? ` + ` : ""} + + `).join("")} + +
PriorityTaskPlanStatusAgentDurationActions
${escapeHtml(task.priority)} + ${escapeHtml(task.title || task.external_id || task.id)} +
${escapeHtml(task.external_id || `#${task.id}`)} + ${renderTaskMeta(task)} +
${escapeHtml(task.plan_name || "-")}${statusBadge(task.status)}${escapeHtml(task.assigned_agent_name || task.assigned_agent_id || "-")}${duration(task.duration_seconds)} +
+ ${showDetails ? `` : ""} + ${showActions ? ` + + + + + ` : ""} +
+
+ `; +} + +function selectedTask() { + const selectedId = String(state.selectedTaskId || ""); + if (selectedId) { + const task = state.tasks.find((item) => String(item.id) === selectedId); + if (task) return task; + } + return state.tasks.find((item) => item.result) || state.tasks[0] || null; +} + +function taskAgent(task) { + if (!task?.assigned_agent_id) return null; + return state.agents.find((agent) => String(agent.id) === String(task.assigned_agent_id)) || null; +} + +function screenshotHref(task, imagePath) { + const agent = taskAgent(task); + const host = agent?.reported_ip || agent?.reported_host || ""; + const port = agent?.reported_log_web_port || ""; + if (!host || !port || !imagePath) return ""; + return `http://${host}:${port}/agent/screenshot?path=${encodeURIComponent(imagePath)}`; +} + +function renderTaskDetail() { + const task = selectedTask(); + if (!task) { + return `

暂无任务详情

`; + } + if (!task.result) { + const payload = task.payload || {}; + const agent = taskAgent(task); + const agentStatus = String(agent?.status || "").toLowerCase(); + const likelyInterrupted = task.status === "running" && ["idle", "offline", "registered"].includes(agentStatus); + const progressText = task.status === "running" + ? likelyInterrupted + ? "被控端已恢复为空闲但任务没有结果回传,可能执行中断;请重试该任务。" + : "被控端已领取任务,正在等待执行结果回传。" + : task.status === "ready" + ? "任务已进入队列,等待空闲被控端领取。" + : task.status === "cancelled" + ? "任务已取消,未产生执行结果。" + : "任务尚未产生执行结果。"; + return ` +
+
+

任务详情

+ ${statusBadge(task.status)} +
+
+
+ ${escapeHtml(task.title || task.external_id || task.id)} + ${escapeHtml(task.external_id || `#${task.id}`)} +
+
+
目标 URL
${escapeHtml(payload.target_url || payload.url || "-")}
+
被控端
${escapeHtml(agent?.display_name || task.assigned_agent_name || "-")}
+
执行状态
${escapeHtml(progressText)}
+
创建时间
${formatDate(task.created_at)}
+
领取时间
${formatDate(task.claimed_at)}
+
更新时间
${formatDate(task.updated_at)}
+
执行结论
${escapeHtml(task.last_error || "-")}
+
+
+

模型分析

+

任务结果尚未回传,暂时没有模型调用记录。

+
+
+

日志

+

任务结果尚未回传,暂无执行日志。

+
+
+
+ `; + } + const result = task.result || {}; + const observation = result.observation || {}; + const browserOpen = result.browser_open || {}; + const modelAnalysis = result.model_analysis || {}; + const modelUsed = Boolean(modelAnalysis.model_used || result.host_decision?.model_used || result.app_decision?.model_used); + const modelErrors = [...new Set([ + ...(Array.isArray(result.model_errors) ? result.model_errors : []), + modelAnalysis.error, + result.host_decision?.model_error, + result.app_decision?.model_error, + ].filter(Boolean).map((item) => String(item)))]; + const modelCallAttempted = Boolean( + modelUsed + || modelErrors.length + || modelAnalysis.model_called + || modelAnalysis.model_duration_seconds + || result.host_decision?.model_duration_seconds + || result.app_decision?.model_duration_seconds + ); + const modelState = !result.model_available + ? "未配置" + : modelUsed + ? "已调用" + : modelErrors.length + ? "调用失败" + : modelCallAttempted + ? "已调用但无有效结果" + : "未调用"; + const discoverySource = result.discovery_source === "llm" ? "大模型生成" : result.discovery_source === "rule" ? "规则回退" : "-"; + const screenshots = Array.isArray(result.screenshots) ? result.screenshots : []; + const submitted = Array.isArray(result.submitted_child_tasks) ? result.submitted_child_tasks : []; + const discovered = Array.isArray(result.discovered_child_tasks) ? result.discovered_child_tasks : []; + const logs = Array.isArray(result.logs) ? result.logs : []; + const needsUserInput = result.needs_user_input || task.status === "failed"; + + return ` +
+
+

任务详情

+ ${statusBadge(task.status)} +
+
+
+ ${escapeHtml(task.title || task.external_id || task.id)} + ${escapeHtml(task.external_id || `#${task.id}`)} +
+
+
目标 URL
${escapeHtml(result.target_url || observation.url || (task.payload || {}).target_url || "-")}
+
最终 URL
${escapeHtml(result.final_url || observation.final_url || "-")}
+
页面标题
${escapeHtml(result.page_title || observation.page_title || "-")}
+
HTTP 状态
${escapeHtml(result.http_status || observation.http_status || "-")}
+
浏览器打开
${browserOpen.opened ? "已打开" : result.browser_open ? "未打开" : "-"}
+
大模型
${escapeHtml(modelState)}${result.model_duration_seconds ? ` · ${duration(result.model_duration_seconds)}` : ""}
+
任务来源
${escapeHtml(discoverySource)}
+
耗时
${duration(result.duration_seconds || task.duration_seconds)}
+
人工介入
${needsUserInput ? '需要确认' : '无需'}
+
执行结论
${escapeHtml(result.message || task.last_error || "-")}
+
+
+

模型分析

+ ${modelUsed ? ` +
    +
  • ${escapeHtml(modelAnalysis.summary || "模型已参与分析,但未返回摘要。")}
  • + ${(Array.isArray(modelAnalysis.coverage_notes) ? modelAnalysis.coverage_notes : []).slice(0, 5).map((note) => `
  • ${escapeHtml(note)}
  • `).join("")} + ${modelAnalysis.user_input_reason ? `
  • 需要用户补充:${escapeHtml(modelAnalysis.user_input_reason)}
  • ` : ""} +
+ ` : modelErrors.length ? ` +
    + ${modelErrors.map((error) => `
  • 调用失败:${escapeHtml(error)}
  • `).join("")} +
+

本轮已回退到规则探索。

+ ` : `

${result.model_available ? "本轮未使用模型生成有效分析,已回退到规则探索。" : "未配置可用模型,本轮使用规则探索。"}

`} +
+
+
+

页面发现

+
    +
  • 链接:${escapeHtml(observation.link_count ?? "-")}
  • +
  • 表单:${escapeHtml(observation.form_count ?? "-")}
  • +
  • 按钮:${escapeHtml(observation.button_count ?? "-")}
  • +
  • 登录表单:${observation.has_login_form ? "是" : "否"}
  • +
+
+
+

证据截图

+ ${screenshots.length ? ` +
    + ${screenshots.map((shot) => { + const imagePath = shot.image_path || shot.path || ""; + const href = screenshotHref(task, imagePath); + const label = imagePath || JSON.stringify(shot); + return `
  • ${href ? `${escapeHtml(label)}` : escapeHtml(label)}
  • `; + }).join("")} +
+ ` : `

暂无截图

`} +
+
+
+
+

已加入队列的子任务

+ ${submitted.length ? ` +
    + ${submitted.map((child) => `
  • ${escapeHtml(child.title || child.external_id)}层级 ${escapeHtml(child.depth ?? "-")} · ${escapeHtml(child.task_level || "-")} · ${escapeHtml(child.source || discoverySource || "-")}${child.llm_reason ? `${escapeHtml(child.llm_reason)}` : ""}
  • `).join("")} +
+ ` : `

暂无子任务

`} +
+
+

发现候选

+ ${discovered.length ? ` +
    + ${discovered.slice(0, 8).map((child) => `
  • ${escapeHtml(child.title || child.external_id)}${escapeHtml(child.target_url || "-")}
  • `).join("")} +
+ ` : `

暂无候选

`} +
+
+
+

日志

+ ${logs.length ? `
    ${logs.slice(-8).map((line) => `
  • ${escapeHtml(line)}
  • `).join("")}
` : `

暂无日志

`} +
+
+
+ `; +} + +function renderDevices() { + $("#view-devices").innerHTML = ` +
+
+
+

监控墙

+ ${state.agents.length} agents +
+
+ ${state.agents.length ? ` +
+ ${state.agents.map((agent) => ` +
+
+
+ ${escapeHtml(agent.display_name || agent.public_id)} + ${escapeHtml(agent.public_id)} +
+ ${statusBadge(agent.status)} +
+
+
部署形态
${escapeHtml(agent.deployment_type || "-")}
+
Agent 版本
${escapeHtml(agent.agent_version || "-")}
+
Host
${escapeHtml(agent.reported_host || "-")}
+
IP
${escapeHtml(agent.reported_ip || "-")}
+
配置版本
v${escapeHtml(agent.config_revision || 0)}${agent.config_applied_at ? ` · ${formatDate(agent.config_applied_at)}` : ""}
+
日志页
${agent.reported_ip && agent.reported_log_web_port ? `打开 ${escapeHtml(agent.reported_log_web_port)}` : "-"}
+
最后心跳
${formatDate(agent.last_seen_at)}
+
+
+ `).join("")} +
` : `

暂无被控端接入

`} +
+
+
+ `; +} + +function renderModels() { + const cfg = state.modelConfig.value || {}; + const targetVersion = Number(state.modelConfig.version || 1); + const syncedAgents = state.agents.filter((agent) => Number(agent.config_revision || 0) >= targetVersion).length; + const syncRows = state.agents.length + ? state.agents.map((agent) => { + const revision = Number(agent.config_revision || 0); + const isSynced = revision >= targetVersion; + return ` + + + ${escapeHtml(agent.display_name || agent.public_id)} +
${escapeHtml(agent.reported_ip || agent.public_id)}
+ + v${escapeHtml(revision)} + ${isSynced ? 'synced' : 'pending'} + ${formatDate(agent.config_applied_at)} + + `; + }).join("") + : `暂无被控端接入`; + $("#view-models").innerHTML = ` +
+
+
+

模型与执行参数

+ v${escapeHtml(state.modelConfig.version || 1)} +
+
+ ${modelInput("target_system_url", "待测系统 URL", cfg.target_system_url, "url", { placeholder: "http://host:port" })} + ${renderSiteScanner()} + ${modelInput("vllm_base_url", "vLLM / OpenAI Base URL", cfg.vllm_base_url, "url", { placeholder: "http://host:8000/v1" })} + ${modelInput("vllm_model", "模型名称", cfg.vllm_model, "text", { placeholder: "qwen2.5-vl / local-model" })} +
+ + +
+ ${modelInput("grounding_url", "Grounding URL", cfg.grounding_url, "url", { placeholder: "http://host:port" })} + ${modelInput("grounding_model", "Grounding 模型", cfg.grounding_model, "text", { placeholder: "grounding-model" })} + ${modelSelect("ground_coordinate_mode", "坐标模式", cfg.ground_coordinate_mode || "absolute", [["absolute", "absolute"], ["normalized", "normalized"]])} + ${modelInput("resolution", "分辨率", cfg.resolution, "text", { placeholder: "1920x1080" })} + ${modelInput("screenshot_max_width", "截图最大宽", cfg.screenshot_max_width, "number", { min: 1, max: 7680 })} + ${modelInput("screenshot_max_height", "截图最大高", cfg.screenshot_max_height, "number", { min: 1, max: 7680 })} + ${modelInput("max_steps", "最大步数", cfg.max_steps, "number", { min: 1, max: 500 })} + ${modelInput("request_timeout_seconds", "请求超时秒", cfg.request_timeout_seconds, "number", { min: 1, max: 300 })} + ${modelInput("temperature", "Temperature", cfg.temperature, "number", { min: 0, max: 2, step: "0.1" })} +
+ +
+
+
+ + +
+

保存后版本递增,并通过被控端心跳下发;被控端应用新配置后会重启运行时。

+
+
+
+
+
+
+

配置同步

+ ${syncedAgents}/${state.agents.length} agents +
+
+ + + ${syncRows} +
Agent版本状态应用时间
+
+
+
+

测试结果

+
+
${renderModelTestResult(null)}
+
+
+
+
+ `; +} + +function renderSiteScanner() { + const scan = state.siteScan; + return ` +
+
+
+ + +
+
+ + +
+
+ +
+
+
${renderSiteScanResult()}
+
+ `; +} + +function renderSiteScanResult() { + const scan = state.siteScan; + if (scan.status === "idle") { + return `
尚未扫描
`; + } + if (scan.status === "running") { + return `
正在扫描可访问网站...
`; + } + if (scan.status === "error") { + return `
${escapeHtml(scan.message || "扫描失败")}
`; + } + + const sites = scan.sites || []; + if (!sites.length) { + return `
未发现可访问网站
`; + } + + const rows = sites.map((site) => { + const address = site.address || `${site.ip || site.host}:${site.port}`; + const statusText = site.status ? `HTTP ${site.status}` : "HTTP"; + const elapsed = site.elapsed_seconds != null ? `${Number(site.elapsed_seconds).toFixed(2)}s` : "-"; + return ` +
+
+ ${escapeHtml(site.name || address)} + ${escapeHtml(site.url || "")} + ${escapeHtml(address)} · ${escapeHtml(statusText)} · ${escapeHtml(elapsed)} +
+ +
+ `; + }).join(""); + + return ` +
发现 ${sites.length} 个可访问网站
+
${rows}
+ `; +} + +function renderModelTestResult(test) { + if (!test) { + return ` +
+
${icon("activity")}
+
+

尚未测试

+

点击“连通性测试”后,这里会显示模型服务是否可访问、响应耗时和发现的模型。

+
+
+ `; + } + + if (test.status === "running") { + return ` +
+
${icon("activity")}
+
+

正在测试模型服务

+

正在访问模型服务,请稍候。

+
+
+ `; + } + + if (!test.ok) { + return ` +
+
${icon("x")}
+
+

连接失败

+

${escapeHtml(test.message || "模型服务暂时不可访问。")}

+ ${test.url ? `
测试地址
${escapeHtml(test.url)}
` : ""} +
    +
  • 确认模型服务已启动,并且主控主机可以访问该地址。
  • +
  • 检查 Base URL 是否包含正确端口,例如 http://host:8000/v1。
  • +
  • 如果服务需要鉴权,请确认 API Key 已保存且未过期。
  • +
+
+
+ `; + } + + const models = normalizeModelList(test.models); + const modelList = models.length + ? `
${models.map((name) => `${escapeHtml(name)}`).join("")}
` + : `

接口可访问,但没有返回可展示的模型名称。

`; + return ` +
+
${icon("check")}
+
+

连接成功

+

模型服务已响应,可以用于后续测试执行。

+
+
测试地址
${escapeHtml(test.url || "-")}
+
响应耗时
${duration(test.elapsed_seconds)}
+
+

发现的模型

+ ${modelList} +
+
+ `; +} + +function normalizeModelList(models) { + if (!Array.isArray(models)) return []; + return models + .map((item) => { + if (typeof item === "string") return item; + if (!item || typeof item !== "object") return ""; + return item.id || item.name || item.model || item.object || ""; + }) + .filter(Boolean) + .slice(0, 12); +} + +function modelInput(id, label, value, type = "text", attrs = {}) { + const attrText = Object.entries(attrs) + .filter(([key]) => key !== "full") + .map(([key, val]) => `${key}="${escapeHtml(val)}"`) + .join(" "); + return `
`; +} + +function modelSelect(id, label, value, options) { + return ` +
+ + +
+ `; +} + +function readModelForm() { + const fields = [ + "target_system_url", + "vllm_base_url", + "vllm_model", + "api_key", + "grounding_url", + "grounding_model", + "ground_coordinate_mode", + "resolution", + "screenshot_max_width", + "screenshot_max_height", + "max_steps", + "request_timeout_seconds", + "temperature", + ]; + const payload = {}; + fields.forEach((field) => { + const node = $(`#${field}`); + payload[field] = node.type === "number" ? Number(node.value || 0) : node.value.trim(); + }); + payload.clear_api_key = Boolean($("#clear_api_key")?.checked); + return payload; +} + +function readSiteScanForm() { + const host = $("#site_scan_host")?.value.trim() || ""; + const ports = $("#site_scan_ports")?.value.trim() || ""; + state.siteScan.host = host; + state.siteScan.ports = ports || state.siteScan.ports; + return { host, ports, timeout_seconds: 0.8 }; +} + +function readDiscoveryForm() { + const targetUrl = $("#discoveryTargetUrl")?.value.trim() || ""; + const planName = $("#discoveryPlanName")?.value.trim() || "智能探索 P0"; + state.discovery.targetUrl = targetUrl; + state.discovery.planName = planName; + return { target_url: targetUrl, plan_name: planName, timeout_seconds: 8 }; +} + +function selectedDiscoveryCandidates() { + const checked = new Set($$(".discovery-checkbox:checked").map((node) => node.dataset.taskId)); + state.discovery.candidates = (state.discovery.candidates || []).map((candidate) => ({ + ...candidate, + selected: checked.has(String(candidate.id)), + })); + return state.discovery.candidates.filter((candidate) => candidate.selected); +} + +function renderTools() { + const tools = state.toolConfig.tools || []; + const config = state.toolConfig.config || {}; + const globalEnabled = config.global_enabled || {}; + const agentOptions = [``] + .concat(state.agents.map((a) => ``)) + .join(""); + const agentOverride = (config.agent_overrides || {})[state.selectedToolAgent] || {}; + const planOverride = (config.plan_overrides || {})[state.selectedToolPlan] || {}; + $("#view-tools").innerHTML = ` +
+
+
+

全局默认

+ v${escapeHtml(state.toolConfig.version || 1)} +
+
+ + + + ${tools.map((tool) => ` + + + + + + + + `).join("")} + +
EnabledToolTypeSourceScenario
${escapeHtml(tool.name)}
${escapeHtml(tool.capability)}
${escapeHtml(tool.type)}${escapeHtml(tool.source)} · ${escapeHtml(tool.version)}${escapeHtml(tool.scenario)}
+
+
+ +
+
+

覆盖策略

+ +
+
+
+ + +
+
+ ${tools.map((tool) => ``).join("")} +
+
+ + +
+
+ ${tools.map((tool) => ``).join("")} +
+
+
+
+ `; +} + +function renderMetrics() { + const m = state.metrics; + const rows = [ + ["通过率", m.pass_rate], + ["失败率", m.fail_rate], + ["覆盖率", m.coverage_rate], + ["并发利用率", m.concurrency_utilization], + ["人工介入率", m.manual_intervention_rate], + ["异常可恢复率", m.recovery_rate], + ["Tool 配置一致率", m.tool_consistency_rate], + ]; + $("#view-metrics").innerHTML = ` +
+
+ ${kpi("任务总数", m.total_tasks || 0, "all plans")} + ${kpi("平均耗时", duration(m.avg_duration_seconds), "completed tasks")} + ${kpi("通过", m.succeeded_tasks || 0, "succeeded")} + ${kpi("失败", m.failed_tasks || 0, "failed")} +
+
+
+

核心指标

+ ${(m.plans || []).join(", ") || "No plans"} +
+
+ ${rows.map(([label, value]) => ` +
+ ${escapeHtml(label)} +
+ ${percent(value)} +
+ `).join("")} +
+
+
+ `; +} + +function renderReports() { + const report = state.report || { metrics: {}, plans: [], failed_tasks: [] }; + $("#view-reports").innerHTML = ` +
+
+
+

报告摘要

+ +
+
+
+
生成时间
${formatDate(report.generated_at)}
+
总任务
${escapeHtml(report.metrics?.total_tasks || 0)}
+
通过率
${percent(report.metrics?.pass_rate)}
+
覆盖率
${percent(report.metrics?.coverage_rate)}
+
+
+
+
+

失败清单

+
+ + + + ${(report.failed_tasks || []).length ? report.failed_tasks.map((task) => ` + + `).join("") : ``} + +
TaskPlanReason
${escapeHtml(task.title || task.external_id || task.id)}${escapeHtml(task.plan_name || "-")}${escapeHtml(task.reason || "-")}
暂无失败任务
+
+
+
+ `; +} + +function estimateTasks(raw) { + if (Array.isArray(raw)) return raw.reduce((sum, item) => sum + (item && Array.isArray(item.tasks) ? item.tasks.length : 1), 0); + if (raw && Array.isArray(raw.plans)) return raw.plans.reduce((sum, plan) => sum + (Array.isArray(plan.tasks) ? plan.tasks.length : 0), 0); + if (raw && Array.isArray(raw.tasks)) return raw.tasks.length; + if (raw && raw.tasks && typeof raw.tasks === "object") return Object.keys(raw.tasks).length; + return raw && typeof raw === "object" ? 1 : 0; +} + +function samplePlan() { + return JSON.stringify( + { + plan_name: "ERP Smoke Plan", + tasks: [ + { + task_id: "ERP-LOGIN-001", + title: "登录系统", + priority: 10, + tags: ["login", "smoke"], + instruction: "打开待测系统,使用测试账号登录,确认首页可访问。", + timeout: 300, + task_failure_policy: "terminate", + }, + { + task_id: "ERP-QUERY-001", + title: "查询采购单", + priority: 20, + tags: ["purchase"], + instruction: "进入采购模块,按单号查询并确认列表展示结果。", + timeout: 300, + task_failure_policy: "skip", + }, + ], + }, + null, + 2 + ); +} + +function downloadJson(filename, value) { + const blob = new Blob([JSON.stringify(value, null, 2)], { type: "application/json" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + URL.revokeObjectURL(link.href); +} + +document.addEventListener("click", async (event) => { + const nav = event.target.closest(".nav-item"); + if (nav) { + setSection(nav.dataset.section); + return; + } + + const jump = event.target.closest("[data-section-jump]"); + if (jump) { + setSection(jump.dataset.sectionJump); + return; + } + + const button = event.target.closest("[data-action]"); + if (!button) return; + const action = button.dataset.action; + try { + if (action === "start-system-test") { + const form = readSystemTestForm(); + if (!form.target_url) { + state.systemTest.status = "error"; + state.systemTest.message = "请先选择或填写待测系统 URL"; + renderPlans(); + return; + } + state.systemTest.status = "starting"; + state.systemTest.message = "正在创建根任务"; + renderPlans(); + const result = await postJson("/api/system-tests/start", form); + state.systemTest.status = "started"; + state.systemTest.message = `已创建根任务:${result.task?.external_id || result.task?.id || "-"}`; + showToast("系统测试已启动,等待被控端领取任务"); + setSection("console"); + await refreshAll(); + } else if (action === "sample-plan") { + $("#planJson").value = samplePlan(); + $("#planValidation").textContent = "已填入样例"; + } else if (action === "validate-plan") { + const raw = JSON.parse($("#planJson").value || "{}"); + $("#planValidation").textContent = `JSON 有效,可导入 ${estimateTasks(raw)} 条任务`; + } else if (action === "import-plan") { + const content = $("#planJson").value; + const result = await postJson("/api/tasks/import", { content }); + showToast(`已导入 ${result.count} 条任务`); + await refreshAll(); + } else if (action === "discover-site") { + const form = readDiscoveryForm(); + const resultContainer = $("#discoveryResult"); + state.discovery.status = "running"; + state.discovery.message = ""; + state.discovery.candidates = []; + state.discovery.summary = null; + if (resultContainer) resultContainer.innerHTML = renderDiscoveryResult(); + button.disabled = true; + try { + const result = await postJson("/api/discovery/preview", form); + state.discovery.status = "done"; + state.discovery.summary = result.summary || null; + state.discovery.candidates = result.candidates || []; + state.discovery.message = ""; + renderPlans(); + showToast(`发现 ${state.discovery.candidates.length} 个候选任务`); + } catch (error) { + state.discovery.status = "error"; + state.discovery.message = error.message; + if (resultContainer) resultContainer.innerHTML = renderDiscoveryResult(); + } finally { + button.disabled = false; + } + } else if (action === "toggle-discovery-all") { + const boxes = $$(".discovery-checkbox"); + const shouldCheck = boxes.some((box) => !box.checked); + boxes.forEach((box) => { + box.checked = shouldCheck; + }); + } else if (action === "import-discovery-tasks") { + const form = readDiscoveryForm(); + const tasks = selectedDiscoveryCandidates(); + if (!tasks.length) { + showToast("请先选择要入队的任务"); + return; + } + const result = await postJson("/api/discovery/import", { + target_url: form.target_url, + plan_name: form.plan_name, + tasks, + }); + showToast(`已入队 ${result.count} 个智能探索任务`); + await refreshAll(); + } else if (action === "export-tasks") { + downloadJson("system-test-tasks.json", state.tasks); + } else if (action === "create-task") { + await postJson("/api/tasks", { + task_id: $("#manualTaskId").value.trim(), + title: $("#manualTitle").value.trim(), + plan_name: $("#manualPlan").value.trim(), + priority: Number($("#manualPriority").value || 100), + failure_policy: $("#manualPolicy").value, + instruction: $("#manualInstruction").value.trim(), + }); + showToast("任务已保存"); + await refreshAll(); + } else if (action === "reload-tasks") { + const status = $("#taskStatusFilter")?.value; + const data = await fetchJson(status ? `/api/tasks?status=${encodeURIComponent(status)}` : "/api/tasks"); + state.tasks = data.tasks || []; + renderConsole(); + } else if (action === "show-task-detail") { + state.selectedTaskId = button.dataset.taskId || ""; + if (state.currentSection === "console") { + renderConsole(); + } else { + renderPlans(); + } + } else if (action === "assign-task") { + const taskId = button.dataset.taskId; + const agentId = $(`#assign-${taskId}`).value; + await postJson(`/api/tasks/${taskId}/assign`, { agent_id: agentId || null, reason: "manual dispatch from console" }); + showToast("分派已保存"); + await refreshAll(); + } else if (action === "retry-task") { + await postJson(`/api/tasks/${button.dataset.taskId}/retry`, { reason: "retry from console" }); + showToast("任务已回到队列"); + await refreshAll(); + } else if (action === "abort-task") { + await postJson(`/api/tasks/${button.dataset.taskId}/abort`, { reason: "aborted from console" }); + showToast("任务已中止"); + await refreshAll(); + } else if (action === "save-model") { + const saved = await postJson("/api/config/model", readModelForm()); + state.modelConfig = saved; + showToast(`模型配置已保存 v${saved.version}`); + await refreshAll(); + } else if (action === "test-model") { + const container = $("#modelTestResult"); + container.innerHTML = renderModelTestResult({ status: "running" }); + try { + const result = await postJson("/api/models/test", readModelForm()); + container.innerHTML = renderModelTestResult(result.test); + } catch (error) { + container.innerHTML = renderModelTestResult({ ok: false, message: error.message }); + } + } else if (action === "scan-sites") { + const resultContainer = $("#siteScanResult"); + button.disabled = true; + state.siteScan.status = "running"; + state.siteScan.message = ""; + state.siteScan.sites = []; + resultContainer.innerHTML = renderSiteScanResult(); + try { + const result = await postJson("/api/sites/scan", readSiteScanForm()); + state.siteScan.status = "done"; + state.siteScan.sites = result.sites || []; + state.siteScan.message = ""; + resultContainer.innerHTML = renderSiteScanResult(); + showToast(`发现 ${state.siteScan.sites.length} 个可访问网站`); + } catch (error) { + state.siteScan.status = "error"; + state.siteScan.message = error.message; + resultContainer.innerHTML = renderSiteScanResult(); + } finally { + button.disabled = false; + } + } else if (action === "select-site") { + const url = button.dataset.siteUrl || ""; + state.systemTest.targetUrl = url; + const systemTestInput = $("#systemTestTargetUrl"); + if (systemTestInput && url) { + systemTestInput.value = url; + } + const targetInput = $("#target_system_url"); + if (targetInput && url) { + targetInput.value = url; + } + if (url) showToast("已填入待测系统 URL"); + } else if (action === "save-tools") { + await saveTools(); + showToast("Tool 策略已保存"); + await refreshAll(); + } else if (action === "download-report") { + downloadJson("system-test-report-summary.json", state.report || {}); + } + } catch (error) { + showToast(error.message); + } +}); + +document.addEventListener("change", (event) => { + if (event.target.id === "toolAgentSelect") { + state.selectedToolAgent = event.target.value; + renderTools(); + } +}); + +document.addEventListener("input", (event) => { + if (event.target.id === "toolPlanName") { + state.selectedToolPlan = event.target.value.trim(); + } +}); + +$("#refreshButton").addEventListener("click", refreshAll); + +function setSection(section) { + state.currentSection = section; + $$(".nav-item").forEach((item) => item.classList.toggle("is-active", item.dataset.section === section)); + $$(".view").forEach((view) => view.classList.toggle("is-active", view.id === `view-${section}`)); + renderChrome(); + renderCurrentView(); +} + +async function saveTools() { + const current = state.toolConfig.config || {}; + const config = { + global_enabled: { ...(current.global_enabled || {}) }, + agent_overrides: { ...(current.agent_overrides || {}) }, + plan_overrides: { ...(current.plan_overrides || {}) }, + }; + $$("[data-tool-global]").forEach((node) => { + config.global_enabled[node.dataset.toolGlobal] = node.checked; + }); + if (state.selectedToolAgent) { + config.agent_overrides[state.selectedToolAgent] = {}; + $$("[data-tool-agent]").forEach((node) => { + config.agent_overrides[state.selectedToolAgent][node.dataset.toolAgent] = node.checked; + }); + } + const planName = $("#toolPlanName")?.value.trim(); + if (planName) { + config.plan_overrides[planName] = {}; + $$("[data-tool-plan]").forEach((node) => { + config.plan_overrides[planName][node.dataset.toolPlan] = node.checked; + }); + state.selectedToolPlan = planName; + } + const saved = await postJson("/api/tools", { tools: state.toolConfig.tools, config }); + state.toolConfig = saved; +} + +refreshAll(); +setInterval(() => { + if (!["plans", "models", "tools"].includes(state.currentSection)) { + refreshAll(); + } +}, 5000); diff --git a/SystemTest-Controller/web/index.html b/SystemTest-Controller/web/index.html new file mode 100644 index 0000000..4c18cbb --- /dev/null +++ b/SystemTest-Controller/web/index.html @@ -0,0 +1,79 @@ + + + + + + 系统测试 Agent 主控台 + + + + + +
+ + +
+
+
+

总览

+

Loading...

+
+
+ checking + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + + + + diff --git a/SystemTest-Controller/web/styles.css b/SystemTest-Controller/web/styles.css new file mode 100644 index 0000000..6ab5c3f --- /dev/null +++ b/SystemTest-Controller/web/styles.css @@ -0,0 +1,997 @@ +:root { + color-scheme: light; + font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif; + background: #f4f6f8; + color: #172033; + --surface: #ffffff; + --surface-alt: #f8fafc; + --line: #d9e2ec; + --line-soft: #e8eef5; + --text: #172033; + --muted: #607086; + --accent: #2563eb; + --accent-dark: #1d4ed8; + --green: #12805c; + --green-bg: #e9f7f1; + --amber: #a15c00; + --amber-bg: #fff4df; + --red: #b42318; + --red-bg: #fff1f0; + --violet: #6d4aff; + --shadow: 0 1px 2px rgba(15, 23, 42, 0.06); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + background: var(--surface-alt); +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +.icon-sprite { + display: none; +} + +svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 232px minmax(0, 1fr); +} + +.sidebar { + background: #111827; + color: #f9fafb; + padding: 18px 14px; + position: sticky; + top: 0; + height: 100vh; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + height: 48px; + margin-bottom: 16px; +} + +.brand-mark { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border: 1px solid #4b5563; + border-radius: 8px; + background: #1f2937; + font-size: 13px; + font-weight: 700; +} + +.brand strong, +.brand span { + display: block; +} + +.brand span { + color: #aab4c3; + font-size: 12px; + margin-top: 2px; +} + +.nav { + display: grid; + gap: 4px; +} + +.nav-item { + height: 38px; + width: 100%; + display: flex; + align-items: center; + gap: 10px; + border: 0; + border-radius: 6px; + color: #cbd5e1; + background: transparent; + padding: 0 10px; + text-align: left; +} + +.nav-item:hover { + background: #1f2937; + color: #ffffff; +} + +.nav-item.is-active { + background: #e5edff; + color: #17356f; +} + +.main-shell { + min-width: 0; +} + +.topbar { + min-height: 72px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 24px; + background: var(--surface); + border-bottom: 1px solid var(--line); + position: sticky; + top: 0; + z-index: 3; +} + +h1, +h2, +h3, +p { + margin: 0; +} + +h1 { + font-size: 22px; + line-height: 1.2; + font-weight: 650; +} + +h2 { + font-size: 17px; + line-height: 1.3; +} + +h3 { + font-size: 14px; + line-height: 1.3; +} + +#serviceMeta { + margin-top: 5px; + color: var(--muted); + font-size: 13px; +} + +.top-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.workspace { + padding: 18px 20px 28px; +} + +.view { + display: none; +} + +.view.is-active { + display: block; +} + +.view-grid { + display: grid; + gap: 14px; +} + +.view-grid > *, +.kpi, +.surface { + min-width: 0; +} + +.two-col { + grid-template-columns: minmax(360px, 0.95fr) minmax(420px, 1.3fr); +} + +.three-col { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.surface { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.surface-header { + min-height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--line-soft); +} + +.surface-body { + padding: 14px 16px 16px; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.button, +.icon-button { + height: 34px; + border: 1px solid #b8c5d6; + border-radius: 6px; + background: #ffffff; + color: #25344a; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 12px; +} + +.button.primary { + background: var(--accent); + border-color: var(--accent); + color: #ffffff; +} + +.button.primary:hover { + background: var(--accent-dark); +} + +.button.danger { + border-color: #f1b6b0; + color: var(--red); + background: #fffafa; +} + +.button:disabled, +.icon-button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.icon-button { + width: 34px; + padding: 0; +} + +.status-pill, +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 26px; + padding: 3px 9px; + border: 1px solid #c8d2df; + border-radius: 999px; + color: #42526a; + background: #f8fafc; + font-size: 12px; + font-weight: 650; + white-space: nowrap; +} + +.status-pill.ok, +.badge.ok { + border-color: #9bd7bd; + background: var(--green-bg); + color: var(--green); +} + +.status-pill.fail, +.badge.fail { + border-color: #f1b6b0; + background: var(--red-bg); + color: var(--red); +} + +.badge.warn { + border-color: #f2cf92; + background: var(--amber-bg); + color: var(--amber); +} + +.badge.info { + border-color: #bfd2ff; + background: #eef4ff; + color: #174a9c; +} + +.kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.kpi { + min-height: 94px; + padding: 14px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 8px; +} + +.kpi label { + display: block; + color: var(--muted); + font-size: 12px; +} + +.kpi strong { + display: block; + margin-top: 8px; + color: var(--text); + font-size: 26px; + line-height: 1; +} + +.kpi span { + display: block; + margin-top: 8px; + color: var(--muted); + font-size: 12px; +} + +.field-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.field-grid.full, +.field.full { + grid-column: 1 / -1; +} + +.field label { + display: block; + margin-bottom: 6px; + color: #40516a; + font-size: 12px; + font-weight: 650; +} + +.checkbox-field { + display: flex; + align-items: end; + min-height: 60px; +} + +.checkbox-field label { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; + color: #25344a; + font-size: 13px; +} + +.checkbox-field input { + width: 16px; + height: 16px; +} + +input, +select, +textarea { + width: 100%; + border: 1px solid #bfccdc; + border-radius: 6px; + background: #ffffff; + color: var(--text); +} + +input[type="checkbox"] { + width: 16px; + height: 16px; + margin: 0 4px 0 0; +} + +input, +select { + height: 34px; + padding: 0 10px; +} + +textarea { + min-height: 170px; + padding: 10px; + resize: vertical; + line-height: 1.45; + font-family: Consolas, "Cascadia Mono", monospace; + font-size: 13px; +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + min-width: 820px; + border-collapse: collapse; +} + +.compact-table table { + min-width: 560px; +} + +th, +td { + padding: 10px; + text-align: left; + border-bottom: 1px solid var(--line-soft); + font-size: 13px; + vertical-align: middle; +} + +th { + color: #5b6a7f; + background: #f8fafc; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0; +} + +td.actions { + min-width: 210px; +} + +.mono { + font-family: Consolas, "Cascadia Mono", monospace; +} + +.muted { + color: var(--muted); +} + +.empty { + color: var(--muted); + text-align: center; + padding: 26px 10px; +} + +.empty-cell { + color: var(--muted); + text-align: center; + padding: 22px 10px; +} + +.form-hint { + margin-top: 8px; + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +.device-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} + +.device-card { + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + padding: 12px; +} + +.device-card header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; + margin-bottom: 10px; +} + +.device-card strong { + display: block; + font-size: 14px; +} + +.kv { + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + gap: 6px 10px; + font-size: 13px; +} + +.kv dt { + color: var(--muted); +} + +.kv dd { + margin: 0; + overflow-wrap: anywhere; +} + +.progress-bar { + height: 8px; + border-radius: 999px; + background: #e6edf5; + overflow: hidden; +} + +.progress-bar span { + display: block; + height: 100%; + width: 0; + background: var(--accent); +} + +.metric-list { + display: grid; + gap: 12px; +} + +.metric-row { + display: grid; + grid-template-columns: 180px minmax(0, 1fr) 72px; + gap: 12px; + align-items: center; + font-size: 13px; +} + +.toast { + position: fixed; + right: 18px; + bottom: 18px; + max-width: min(420px, calc(100vw - 36px)); + background: #111827; + color: #ffffff; + border-radius: 8px; + padding: 12px 14px; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.22); + z-index: 20; +} + +.split-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.mini-select { + max-width: 150px; +} + +.button.small { + height: 30px; + padding: 0 10px; + font-size: 12px; +} + +.site-scan-panel { + padding: 12px 0 4px; + border-top: 1px solid var(--line-soft); +} + +.site-scan-fields { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + gap: 12px; + align-items: end; +} + +.site-scan-panel .field { + min-width: 0; +} + +.site-scan-action { + display: flex; + align-items: end; +} + +.site-scan-empty { + min-height: 36px; + margin-top: 10px; + padding: 9px 10px; + border: 1px dashed #c9d7ea; + border-radius: 6px; + color: var(--muted); + background: #ffffff; + font-size: 13px; +} + +.site-scan-empty.fail { + border-color: #f2bbb5; + color: var(--red); + background: var(--red-bg); +} + +.site-scan-summary { + margin-top: 10px; + color: #40516a; + font-size: 13px; +} + +.site-result-list { + display: grid; + gap: 8px; + margin-top: 8px; +} + +.site-result-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 10px; + border: 1px solid var(--line-soft); + border-radius: 6px; + background: #ffffff; +} + +.site-result-main { + display: grid; + min-width: 0; + gap: 4px; +} + +.site-result-main strong, +.site-url, +.site-meta { + overflow-wrap: anywhere; +} + +.site-url { + color: #174a9c; + font-size: 12px; +} + +.site-meta { + color: var(--muted); + font-size: 12px; +} + +.discovery-fields { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(220px, 0.7fr); + gap: 12px; +} + +.discovery-empty { + min-height: 42px; + margin-top: 12px; + padding: 10px 12px; + border: 1px dashed #c9d7ea; + border-radius: 6px; + color: var(--muted); + background: #ffffff; + font-size: 13px; +} + +.discovery-empty.fail { + border-color: #f2bbb5; + color: var(--red); + background: var(--red-bg); +} + +.discovery-summary { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin-top: 12px; + color: #40516a; + font-size: 13px; +} + +.discovery-tree { + display: grid; + gap: 8px; + margin-top: 10px; +} + +.discovery-row { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + gap: 10px; + align-items: start; + padding: 10px; + padding-left: calc(10px + (var(--depth, 0) * 22px)); + border: 1px solid var(--line-soft); + border-radius: 6px; + background: #ffffff; +} + +.discovery-row input { + margin-top: 3px; +} + +.discovery-main { + display: grid; + min-width: 0; + gap: 4px; +} + +.discovery-main strong, +.discovery-main span { + overflow-wrap: anywhere; +} + +.discovery-main > span:not(.site-url) { + color: #40516a; + font-size: 13px; +} + +.discovery-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + color: var(--muted); + font-size: 12px; +} + +.discovery-note { + color: var(--amber); +} + +.system-test-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + min-height: 34px; + padding: 8px 10px; + border: 1px solid var(--line-soft); + border-radius: 6px; + background: #ffffff; + color: #40516a; + font-size: 13px; +} + +.task-meta { + display: block; + margin-top: 4px; + color: var(--muted); + font-size: 12px; +} + +.task-detail-panel { + grid-column: 1 / -1; +} + +.task-detail { + display: grid; + gap: 14px; +} + +.task-detail-title { + display: grid; + gap: 4px; +} + +.task-detail h3 { + margin: 0 0 8px; + color: #1f2d42; + font-size: 14px; +} + +.task-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.detail-list { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; + color: #40516a; + font-size: 13px; +} + +.detail-list li { + display: grid; + gap: 3px; + min-width: 0; + padding: 7px 8px; + border: 1px solid var(--line-soft); + border-radius: 6px; + background: #ffffff; + overflow-wrap: anywhere; +} + +.detail-list.logs li { + font-family: Consolas, "Cascadia Mono", monospace; + font-size: 12px; +} + +.test-result { + display: grid; + grid-template-columns: 38px minmax(0, 1fr); + gap: 12px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #f8fafc; +} + +.test-result.ok { + border-color: #a9dec7; + background: var(--green-bg); +} + +.test-result.fail { + border-color: #f2bbb5; + background: var(--red-bg); +} + +.test-result.neutral { + border-color: #c9d7ea; + background: #f5f8fd; +} + +.test-result-icon { + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 50%; + background: #ffffff; + border: 1px solid currentColor; + color: var(--accent); +} + +.test-result.ok .test-result-icon { + color: var(--green); +} + +.test-result.fail .test-result-icon { + color: var(--red); +} + +.test-result p { + margin-top: 4px; + color: #40516a; + line-height: 1.5; +} + +.compact-kv { + margin: 12px 0; + grid-template-columns: 72px minmax(0, 1fr); +} + +.subheading { + margin-top: 10px; +} + +.model-chip-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.model-chip { + display: inline-flex; + align-items: center; + max-width: 100%; + min-height: 26px; + padding: 3px 8px; + border-radius: 999px; + border: 1px solid #bdd0ea; + background: #ffffff; + color: #243650; + font-size: 12px; + overflow-wrap: anywhere; +} + +.hint-list { + margin: 10px 0 0; + padding-left: 18px; + color: #40516a; + font-size: 13px; + line-height: 1.55; +} + +.json-preview { + max-height: 280px; + overflow: auto; + padding: 12px; + border: 1px solid var(--line); + border-radius: 6px; + background: #0f172a; + color: #d7e2f2; + font-family: Consolas, "Cascadia Mono", monospace; + font-size: 12px; + line-height: 1.45; +} + +@media (max-width: 1060px) { + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar { + position: static; + height: auto; + padding-bottom: 10px; + } + + .nav { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .nav-item { + justify-content: center; + } + + .two-col, + .three-col, + .kpi-grid, + .field-grid, + .discovery-fields, + .site-scan-fields, + .site-result-row, + .task-detail-grid { + grid-template-columns: 1fr; + } + + .site-scan-action .button, + .site-result-row .button { + width: 100%; + } +} + +@media (max-width: 700px) { + .topbar { + align-items: flex-start; + flex-direction: column; + padding: 14px; + } + + .workspace { + padding: 12px; + } + + .nav { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .surface-header, + .toolbar { + align-items: flex-start; + flex-direction: column; + } + + .metric-row { + grid-template-columns: 1fr; + gap: 6px; + } +}