"""P8 browser_tasks:每浏览器任务一个实例——建/绑/毁生命周期。 对齐 Muse B4(官方安全文):每个浏览器任务由专门浏览器子代理 完成。P2 已把浏览器子代理独立成进程(chv-browser.py,常驻、 close 只关页不杀进程);本轨改为每任务一个实例:建实例、绑 定一个浏览器任务、用完即毁,状态绝不跨任务复用。 transport 接口:真实 transport 走 P2 的 broker 协议 (open/snapshot/screenshot/close/ping,经 /run/chv/browser.sock); 客机侧“每任务起一个全新 chv-browser.py、close 后杀进程” 是差距表 §3/§6 的跟进项(P2 的 chv-broker.py 里改,本轨不 复制 P2 的文件)。本层只管生命周期、单实例单任务与状态隔离, transport 注入(测试用 fake)。 存储:宿主级 AppStateStore(round-12 已迁移,见 docs/chv-muse-p8-gap.md §6):state_dir/app-state.sqlite 的 ``p8-browser-tasks`` 命名空间,一行存整份登记;旧 browser-tasks.json 首次加载时自动导入并改名 ``.migrated``; 行损坏/库打不开时回空结构(fail-safe,orphans 同款口径), 下次成功保存即覆盖自愈。 """ from __future__ import annotations import json import sqlite3 import logging import os import time import uuid from cloud.chv.app_state import AppStateStore, host_state_db_path from cloud.chv.compat215 import resolve_inside _LOGGER = logging.getLogger("cloud.cloud_hypervisor") _REGISTRY_FILENAME = "browser-tasks.json" _KV_NAMESPACE = "p8-browser-tasks" _KV_KEY = "registry" class BrowserTransport: """浏览器实例 transport 接口:真实实现走 P2 的 broker 协议。 spawn 建实例、close 毁实例、ping 探活。子类须实现三者; 基类方法一律 NotImplementedError(绝不静默假装成功)。""" def spawn(self, instance: dict) -> None: """建一个浏览器实例(instance 含 instance_id/task_id)。""" raise NotImplementedError def close(self, instance: dict) -> None: """毁一个浏览器实例(进程退出、状态清理)。""" raise NotImplementedError def ping(self, instance: dict) -> bool: """实例是否还活着。""" raise NotImplementedError def _open_store(state_dir: str) -> AppStateStore: """打开宿主级状态库(每次调用新建连接,用完即关)。""" store = AppStateStore(host_state_db_path(state_dir)) store.open() return store def _registry_path(state_dir: str) -> str: """旧版实例登记文件路径(只用于一次性迁移导入)。""" return os.path.join(state_dir, _REGISTRY_FILENAME) def _quarantine(path: str) -> None: """把损坏的登记文件挪到 .corrupt 旁(尽力而为)。""" try: os.replace(path, path + ".corrupt") except OSError: _LOGGER.warning("chv_browser_tasks_quarantine_failed " "path=%s", path) def _load_registry(state_dir: str) -> dict: """读登记:KV 行缺失时走一次性旧文件导入;行损坏/库打不 开时回空结构(fail-safe,orphans 同款口径;下次成功保存 即覆盖自愈)。""" try: with _open_store(state_dir) as store: raw = store.kv_get(_KV_NAMESPACE, _KV_KEY) except (sqlite3.Error, OSError): _LOGGER.warning("chv_browser_tasks_store_unreadable " "state_dir=%s", state_dir) return {"instances": {}} if raw is None: return _import_legacy(state_dir) try: data = json.loads(raw) except ValueError: _LOGGER.warning("chv_browser_tasks_corrupt state_dir=%s", state_dir) return {"instances": {}} if not isinstance(data, dict) \ or not isinstance(data.get("instances"), dict): return {"instances": {}} return data def _import_legacy(state_dir: str) -> dict: """一次性导入旧 browser-tasks.json:合法即进 KV 并把文件 改名 ``.migrated``;无文件回空;损坏则 quarantine 后回空 (旧口径)。导入失败时返回读到的数据、文件保留,下次加 载重试。""" path = _registry_path(state_dir) try: with open(path, "r") as fh: data = json.load(fh) except FileNotFoundError: return {"instances": {}} except (OSError, ValueError): _quarantine(path) return {"instances": {}} if not isinstance(data, dict) \ or not isinstance(data.get("instances"), dict): _quarantine(path) return {"instances": {}} try: with _open_store(state_dir) as store: store.kv_put(_KV_NAMESPACE, _KV_KEY, json.dumps(data)) except (sqlite3.Error, OSError): _LOGGER.warning("chv_browser_tasks_import_failed " "state_dir=%s", state_dir) return data try: os.replace(path, path + ".migrated") except OSError: pass return data def _save_registry(state_dir: str, data: dict) -> None: """落盘登记到 KV 行;写失败只记日志。""" try: with _open_store(state_dir) as store: store.kv_put(_KV_NAMESPACE, _KV_KEY, json.dumps(data)) except (sqlite3.Error, OSError): _LOGGER.warning("chv_browser_tasks_save_failed " "state_dir=%s", state_dir) def _get_or_raise(data: dict, task_id: str) -> dict: """取某任务的实例;不存在即 ValueError。""" instance = data.get("instances", {}).get(task_id) if not isinstance(instance, dict): raise ValueError("browser instance not found") return instance def create_instance(state_dir: str, task_id: str, transport: BrowserTransport, now: float | None = None) -> dict: """为一个浏览器任务建一个实例:单任务单实例(该任务已有 任何实例记录——active、destroyed 乃至 failed——即 ValueError;一个任务一生只有一个实例,毁后不复建,审计 轨迹留在登记里);先落盘 creating、再调 transport.spawn、 成功后置 active;spawn 抛错时记 failed 并原样抛出 (不留半个实例)。返回实例 dict。""" if now is None: now = time.time() if not task_id or not isinstance(task_id, str): raise ValueError("task_id required") data = _load_registry(state_dir) existing = data["instances"].get(task_id) if isinstance(existing, dict): raise ValueError("browser instance already exists") instance_id = uuid.uuid4().hex instance = { "instance_id": instance_id, "task_id": task_id, "status": "creating", "shots_dir": "browser-tasks/%s/shots" % instance_id, "created_at": now, } data["instances"][task_id] = instance _save_registry(state_dir, data) try: transport.spawn(instance) except Exception: instance["status"] = "failed" _save_registry(state_dir, data) raise instance["status"] = "active" _save_registry(state_dir, data) return dict(instance) def get_instance(state_dir: str, task_id: str) -> dict | None: """取某任务的实例;不存在回 None。""" data = _load_registry(state_dir) instance = data.get("instances", {}).get(task_id) if not isinstance(instance, dict): return None return dict(instance) def list_instances(state_dir: str) -> list[dict]: """列出全部实例(按创建时间排序)。""" data = _load_registry(state_dir) out = [dict(v) for v in data["instances"].values() if isinstance(v, dict)] out.sort(key=lambda i: (i.get("created_at") or 0, i.get("task_id") or "")) return out def destroy_instance(state_dir: str, task_id: str, transport: BrowserTransport, now: float | None = None) -> dict: """毁一个实例:须是 active(不存在/已毁即 ValueError); 调 transport.close,close 抛错也记下并照常置 destroyed (清理不能因一次 close 失败卡住),返回实例 dict。""" if now is None: now = time.time() data = _load_registry(state_dir) instance = _get_or_raise(data, task_id) if instance.get("status") != "active": raise ValueError("browser instance is not active") close_error = "" try: transport.close(instance) except Exception as exc: close_error = str(exc)[:300] instance["status"] = "destroyed" instance["destroyed_at"] = now instance["close_error"] = close_error _save_registry(state_dir, data) return dict(instance) def ping_instance(state_dir: str, task_id: str, transport: BrowserTransport) -> bool: """实例是否还活着:非 active 一律 False;active 时问 transport。""" data = _load_registry(state_dir) instance = data.get("instances", {}).get(task_id) if not isinstance(instance, dict): return False if instance.get("status") != "active": return False try: return bool(transport.ping(instance)) except Exception: return False def instance_shots_dir(state_dir: str, task_id: str) -> str | None: """取某任务实例的截图目录(state_dir 内、resolve_inside 校验,越界回 None);实例不存在回 None。""" data = _load_registry(state_dir) instance = data.get("instances", {}).get(task_id) if not isinstance(instance, dict): return None rel = instance.get("shots_dir") if not isinstance(rel, str) or not rel: return None return resolve_inside(state_dir, rel)