diff --git a/chv-guest/generate_lockfile.py b/chv-guest/generate_lockfile.py new file mode 100644 index 0000000..2786e13 --- /dev/null +++ b/chv-guest/generate_lockfile.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""在构建宿主上生成 CHV 客机镜像的供应链 lockfile(to10 轨)。 + +事后校验见 check_lockfile.py(校验 BUILDINFO.versions); +本脚本是事前/事中生成端:在构建宿主上跑,读 BUILDINFO +的 tag 与 contents(仅名字清单,不含版本号),逐项从本 +机 dpkg / pip 查真实版本,写出 lockfile.json: + + {"tag": ..., "image_sha256": ... | null, + "generated_at": UTC ISO 时间, + "versions": {"dpkg": ["name version", ...], + "pip": ["name version", ...]}, + "unresolved": ["查不到版本的名", ...]} + +绝不编造版本号:查不到的进 unresolved,不猜、不填占位 +版本。--verify 模式校验一份 lockfile:tag 与 BUILDINFO +一致、dpkg/pip 非空、每条都有非空版本、unresolved 为空; +任一不满足即退出非 0(unresolved 非空 = 清单不完整, +不是“生成成功”)。 + +用法(均在构建宿主上跑): + python3 generate_lockfile.py BUILDINFO.json --output lockfile.json + python3 generate_lockfile.py --verify lockfile.json \ + --buildinfo BUILDINFO.json +本轨无构建宿主:脚本已落地且可跑(见回执里的 /tmp 合成 +BUILDINFO 实测),真实清单待构建宿主产出,绝不把合成 +数据写成生产清单。 +""" + +import json +import subprocess +import sys +from datetime import datetime, timezone + + +def _dpkg_version(name): + """dpkg 查版本,查不到回 None(不抛)。""" + try: + out = subprocess.run( + ["dpkg-query", "-W", "-f=${Version}", name], + capture_output=True, text=True, timeout=30) + except Exception: + return None + if out.returncode != 0: + return None + version = out.stdout.strip() + return version or None + + +def _pip_version(name): + """pip 查版本,查不到回 None(不抛)。""" + try: + out = subprocess.run( + [sys.executable, "-m", "pip", "show", name], + capture_output=True, text=True, timeout=30) + except Exception: + return None + if out.returncode != 0: + return None + for line in out.stdout.splitlines(): + if line.startswith("Version:"): + version = line.split(":", 1)[1].strip() + return version or None + return None + + +def generate(buildinfo_path, output_path): + with open(buildinfo_path) as fh: + buildinfo = json.load(fh) + names = buildinfo.get("contents") or [] + if not isinstance(names, list): + print("BUILDINFO.contents 不是列表", file=sys.stderr) + return 2 + dpkg, pip, unresolved = [], [], [] + for name in names: + version = _dpkg_version(name) + if version is not None: + dpkg.append("%s %s" % (name, version)) + continue + version = _pip_version(name) + if version is not None: + pip.append("%s %s" % (name, version)) + continue + unresolved.append(name) + lockfile = { + "tag": buildinfo.get("tag"), + "image_sha256": buildinfo.get("image_sha256") + or buildinfo.get("sha256"), + "generated_at": datetime.now(timezone.utc).isoformat(), + "versions": {"dpkg": sorted(dpkg), "pip": sorted(pip)}, + "unresolved": sorted(unresolved), + } + with open(output_path, "w") as fh: + json.dump(lockfile, fh, indent=2, sort_keys=True) + fh.write("\n") + if unresolved: + print("警告:%d 项查不到版本,已列入 unresolved:%s" + % (len(unresolved), ", ".join(sorted(unresolved))), + file=sys.stderr) + return 0 + + +def verify(lockfile_path, buildinfo_path): + with open(lockfile_path) as fh: + lockfile = json.load(fh) + if buildinfo_path: + with open(buildinfo_path) as fh: + buildinfo = json.load(fh) + if lockfile.get("tag") != buildinfo.get("tag"): + print("tag 不一致:lockfile %s != BUILDINFO %s" + % (lockfile.get("tag"), buildinfo.get("tag")), + file=sys.stderr) + return 1 + versions = lockfile.get("versions") or {} + for kind in ("dpkg", "pip"): + entries = versions.get(kind) + if not entries: + print("versions.%s 为空,供应链清单缺失" % kind, + file=sys.stderr) + return 1 + for entry in entries: + parts = str(entry).split() + if len(parts) < 2 or not parts[1]: + print("版本为空的条目:%s %r" % (kind, entry), + file=sys.stderr) + return 1 + unresolved = lockfile.get("unresolved") or [] + if unresolved: + print("清单不完整,unresolved 非空:%s" % sorted(unresolved), + file=sys.stderr) + return 1 + print("lockfile 校验通过:tag=%s dpkg=%d pip=%d" + % (lockfile.get("tag"), len(versions["dpkg"]), + len(versions["pip"]))) + return 0 + + +def main(argv): + if "--verify" in argv: + lockfile_path = argv[argv.index("--verify") + 1] + buildinfo_path = None + if "--buildinfo" in argv: + buildinfo_path = argv[argv.index("--buildinfo") + 1] + return verify(lockfile_path, buildinfo_path) + if len(argv) < 2: + print("用法:generate_lockfile.py BUILDINFO.json " + "--output lockfile.json | --verify lockfile.json " + "[--buildinfo BUILDINFO.json]", file=sys.stderr) + return 2 + output_path = None + if "--output" in argv: + output_path = argv[argv.index("--output") + 1] + if not output_path: + print("缺少 --output", file=sys.stderr) + return 2 + return generate(argv[1], output_path) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/cloud/chv/compat215.py b/cloud/chv/compat215.py index c89c777..5dd76f2 100644 --- a/cloud/chv/compat215.py +++ b/cloud/chv/compat215.py @@ -48,7 +48,6 @@ cloud.quota,测试钉住这些模块在本基线里不存在): import os - # 合并后替换清单:每项的 local/replacement/test 字段由测试 # 逐项核对(local 函数真实存在、#215 模块当前不存在、 # test 命名的测试类真实存在),清单本身不得漂移。 diff --git a/cloud/chv/config.py b/cloud/chv/config.py index 0265d93..693cfe1 100644 --- a/cloud/chv/config.py +++ b/cloud/chv/config.py @@ -27,6 +27,33 @@ import os import re +def _to_int(value: object) -> int: + """动态配置值转 int(env/参数均未定型):bool 拒绝, + int/float 按 int(),str 按 strip 后 int(),其它 ValueError。 + 旧行为是 int(value) 抛 TypeError/ValueError 后由调用方 + 转 ValueError;这里直接统一成 ValueError,文案由调用方定。""" + if isinstance(value, bool): + raise ValueError("布尔值不是整数") + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + return int(value.strip()) + raise ValueError("不是整数:%r" % (value,)) + + +def _to_float(value: object) -> float: + """动态配置值转 float,与 _to_int 同款口径。""" + if isinstance(value, bool): + raise ValueError("布尔值不是数字") + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + return float(value.strip()) + raise ValueError("不是数字:%r" % (value,)) + + def _parse_memory_bytes(value: str) -> int: """内存字符串转字节:2G/512M/1024K/2048(无单位=字节)。 非法值 ValueError,绝不静默按默认算。""" @@ -93,7 +120,7 @@ def _ssh_fragment(params: dict, env: dict) -> dict: else: raw_port = env.get("CHV_SSH_PORT") or 2222 try: - ssh_port = int(raw_port) + ssh_port = _to_int(raw_port) except (TypeError, ValueError): raise ValueError("CHV_SSH_PORT 必须是整数") if not 1 <= ssh_port <= 65535: @@ -134,11 +161,11 @@ def _quota_fragment(env: dict) -> dict: 磁盘/快照数上限不在这里,它们是磁盘行为,见 _disk_fragment。校验文案与旧代码逐字一致。""" try: - max_vms = int(env.get("CHV_MAX_VMS") or 4) + max_vms = _to_int(env.get("CHV_MAX_VMS") or 4) except ValueError: raise ValueError("CHV_MAX_VMS 必须是整数") try: - warm_pool_size = int(env.get("CHV_WARM_POOL_SIZE") or 0) + warm_pool_size = _to_int(env.get("CHV_WARM_POOL_SIZE") or 0) except ValueError: raise ValueError("CHV_WARM_POOL_SIZE 必须是整数") return {"max_vms": max_vms, "warm_pool_size": warm_pool_size} @@ -154,18 +181,18 @@ def _disk_fragment(env: dict) -> dict: raise ValueError( "CHV_DISK_MODE 只能是 copy 或 reuse:%s" % disk_mode) try: - disk_gb = float(env.get("CHV_DISK_GB")) \ + disk_gb = _to_float(env.get("CHV_DISK_GB")) \ if env.get("CHV_DISK_GB") else 50.0 except ValueError: raise ValueError("CHV_DISK_GB 必须是数字") if disk_gb <= 0: raise ValueError("CHV_DISK_GB 必须大于 0") try: - snapshot_max_count = int(env.get("CHV_SNAPSHOT_MAX_COUNT") - or 10) - snapshot_max_total_gb = float( + snapshot_max_count = _to_int(env.get("CHV_SNAPSHOT_MAX_COUNT") + or 10) + snapshot_max_total_gb = _to_float( env.get("CHV_SNAPSHOT_MAX_TOTAL_GB") or 20.0) - snapshot_retention_days = float( + snapshot_retention_days = _to_float( env.get("CHV_SNAPSHOT_RETENTION_DAYS") or 30.0) except ValueError: raise ValueError("CHV_SNAPSHOT_* 必须是数字") @@ -192,18 +219,18 @@ def _timeout_fragment(params: dict, env: dict) -> dict: 超时、客机就绪超时/间隔。ready_interval <= 0 时回落 5 秒(旧语义,非校验失败)。校验文案逐字一致。""" task_timeout_raw = env.get("CHV_TASK_TIMEOUT") - task_timeout = float(task_timeout_raw) \ + task_timeout = _to_float(task_timeout_raw) \ if task_timeout_raw else None try: - api_timeout = float( + api_timeout = _to_float( params.get("api_timeout") if params.get("api_timeout") is not None else env.get("CHV_API_TIMEOUT") or 30) - ready_timeout = float( + ready_timeout = _to_float( params.get("ready_timeout") if params.get("ready_timeout") is not None else env.get("CHV_READY_TIMEOUT") or 120) - ready_interval = float( + ready_interval = _to_float( params.get("ready_interval") if params.get("ready_interval") is not None else env.get("CHV_READY_INTERVAL") or 5) diff --git a/cloud/chv/disk.py b/cloud/chv/disk.py index f0404d2..68addf6 100644 --- a/cloud/chv/disk.py +++ b/cloud/chv/disk.py @@ -203,7 +203,7 @@ def check_snapshot_limits(state_dir: str, vm_name: str, max_count: int, raise ChvFailure( "snapshot_quota_exceeded", "快照数量已达上限 %d(VM %s)" % (max_count, vm_name)) - total = 0 + total: float = 0.0 for entry in kept: size = entry.get("size") if isinstance(size, (int, float)): diff --git a/cloud/chv/executor.py b/cloud/chv/executor.py index d19d6a9..3bdddc4 100644 --- a/cloud/chv/executor.py +++ b/cloud/chv/executor.py @@ -34,30 +34,35 @@ import shutil import subprocess import threading import time +from collections.abc import Callable -from cloud.chv.api_client import _unix_socket_api -from cloud.chv.api_client import normalize_api_result -from cloud.executor_base import Executor -from cloud.chv.config import _parse_memory_bytes -from cloud.chv.config import build_chv_config -from cloud.chv.disk import check_disk_quota -from cloud.chv.disk import check_snapshot_limits -from cloud.chv.disk import delete_task_disk -from cloud.chv.disk import provision_task_disk -from cloud.chv.disk import record_snapshot -from cloud.chv.disk import task_disk_path +from cloud.chv.api_client import _unix_socket_api, normalize_api_result +from cloud.chv.config import _parse_memory_bytes, build_chv_config +from cloud.chv.disk import ( + check_disk_quota, + check_snapshot_limits, + delete_task_disk, + provision_task_disk, + record_snapshot, + task_disk_path, +) from cloud.chv.errors import ChvFailure -from cloud.chv.hardening import ensure_secure_state_dir -from cloud.chv.hardening import parse_net_entries -from cloud.chv.hardening import socket_is_live -from cloud.chv.hardening import validate_egress_proxy -from cloud.chv.hardening import validate_net_entries -from cloud.chv.hardening import validate_snapshot_url +from cloud.chv.hardening import ( + ensure_secure_state_dir, + parse_net_entries, + socket_is_live, + validate_egress_proxy, + validate_net_entries, + validate_snapshot_url, +) from cloud.chv.ssh_transport import build_ssh_command as _build_ssh_cmd from cloud.chv.ssh_transport import known_hosts_path as _known_hosts_path -from cloud.chv.ssh_transport import parse_keyscan_output -from cloud.chv.ssh_transport import split_secret_env -from cloud.chv.ssh_transport import write_known_hosts +from cloud.chv.ssh_transport import ( + parse_keyscan_output, + split_secret_env, + write_known_hosts, +) +from cloud.executor_base import Executor _LOGGER = logging.getLogger("cloud.cloud_hypervisor") @@ -246,6 +251,57 @@ class CloudHypervisorExecutor(Executor): 开启嵌套虚拟化的实例,那是用户侧开通决策,不在本轨内。 """ + # to10:mypy 属性声明(仅注解、不赋值,运行时仍由 + # _copy_config_fields/_init_runtime 抄写)。没有这组声明, + # 动态 setattr 会让 mypy 认为全部配置属性不存在(基线 + # 115 个报错里近 90 个属此类),类型检查等于没跑。 + binary: str | None + kernel: str | None + rootfs: str | None + cpus: str + memory: str + state_dir: str + ssh_key: str | None + ssh_user: str + ssh_port: int + guest_host_py: str + cmdline: str + net: str | None + net_json: str | None + kvm_path: str + cpuinfo_path: str + ch_remote: str + model: str | None + max_vms: int + disk_mode: str + disk_gb: float + egress_proxy: str | None + allow_direct_egress: bool + task_timeout: float | None + snapshot_url: str | None + warm_pool_size: int + api_timeout: float + ready_timeout: float + ready_interval: float + host_key_mode: str + ssh_host: str | None + ssh_forward: bool + reuse_experiment: bool + single_tenant_project: str | None + snapshot_max_count: int + snapshot_max_total_gb: float + snapshot_retention_days: float + snapshot_dir: str | None + allow_remote_snapshot: bool + runner: "Callable[..., object]" + launcher: "Callable[..., object]" + api_client: "Callable[..., object]" + sleeper: "Callable[..., object]" + audit_callback: object + audit_failure_count: int + _vms: dict + _guest_env_delivered: dict + def __init__(self, binary: str | None = None, kernel: str | None = None, rootfs: str | None = None, @@ -253,7 +309,8 @@ class CloudHypervisorExecutor(Executor): cpus: str | int | None = None, memory: str | None = None, state_dir: str | None = None, - runner: object = None, launcher: object = None, + runner: Callable[..., object] | None = None, + launcher: Callable[..., object] | None = None, ssh_key: str | None = None, ssh_user: str | None = None, ssh_port: int | str | None = None, @@ -265,12 +322,13 @@ class CloudHypervisorExecutor(Executor): cpuinfo_path: str = "/proc/cpuinfo", env: dict | None = None, model: str | None = None, ch_remote: str | None = None, - api_client: object = None, - sleeper: object = None, api_timeout: float | None = None, + api_client: Callable[..., object] | None = None, + sleeper: Callable[..., object] | None = None, + api_timeout: float | None = None, ready_timeout: float | None = None, ready_interval: float | None = None, host_key_mode: str | None = None) -> None: - self._env = env if env is not None else os.environ + self._env: dict = env if env is not None else os.environ # type: ignore[assignment] # 配置解析已拆到 cloud/chv/config.py(参数优先于环境 # 变量,默认值与 #216 一致;显式空用户/越界端口的收紧 # 见 config.py 头注释)。字段名与原属性一一对应,旧 @@ -291,7 +349,15 @@ class CloudHypervisorExecutor(Executor): self._copy_config_fields(_cfg) self._init_runtime(runner, launcher, api_client, sleeper) - def _init_runtime(self, runner, launcher, api_client, sleeper) -> None: + def _init_runtime(self, runner: Callable[..., object] | None, + launcher: Callable[..., object] | None, + api_client: Callable[..., object] | None, + sleeper: Callable[..., object] | None) -> None: + # to10:runner/launcher/api_client/sleeper 均为可调用注入对象; + # 形参保持 object 以兼容旧测试的任意 lambda,调用点用 + # getattr 可调用检查后调用(见 _call_runner 等),mypy + # 的 object-not-callable 在 _call_runner 处以 Callable 局部 + # 变量处理。 """运行时注入:seed 投递集合、API 客户端、睡眠器、 runner/launcher 默认值(纯移动)。""" # feat-chv-secfix(H2):本实例已 seed 投递密钥的 VM @@ -346,7 +412,11 @@ class CloudHypervisorExecutor(Executor): 幂等:传入的已是 chv- 前缀名时不再二次加前缀(清理 扫描 socket 文件名时传的就是 chv- 名,二次加前缀会 把 chv-dead 算成 chv-chv-dead、永远清不掉)。""" - raw = task_or_name.get("id") if isinstance(task_or_name, dict) \ + # to10:id 按契约是 str;cast 只给 mypy 用,运行时原样 + # 传给 re.sub(id 缺失时仍按旧行为 TypeError,不静默 + # 编成 "None")。 + from typing import cast as _cast + raw = _cast(str, task_or_name.get("id")) if isinstance(task_or_name, dict) \ else str(task_or_name) safe = re.sub(r"[^A-Za-z0-9_.-]", "-", raw) if safe.startswith("chv-"): @@ -589,7 +659,7 @@ class CloudHypervisorExecutor(Executor): - egress_not_configured:CHV_EGRESS_PROXY 未设且未显式 允许直连(默认 fail-closed,VM 出口必须过白名单代理) """ - missing = [] # (kind, 文本) 列表,全部收集完再一次抛 + missing: list = [] # (kind, 文本) 列表,全部收集完再一次抛 self._preflight_host_checks(missing) self._preflight_file_checks(missing) self._preflight_net_checks(missing) @@ -637,6 +707,34 @@ class CloudHypervisorExecutor(Executor): self, "audit_failure_count", 0) + 1 _LOGGER.error("chv_audit_failed event=%s kind=%s error=%s", event, kind, exc) + # to10(ADR-14):回调失败的事件再落盘一份—— + # 宿主日志可能轮转、计数只剩数字,落盘文件是 + # 唯一能事后找回“哪次事件丢了”的取证。落盘 + # 本身 best-effort,失败只记日志,绝不炸任务。 + self._spill_audit(event, task, detail, kind) + + def _spill_audit(self, event, task, detail, kind) -> None: + """审计失败事件落盘(to10,见 ADR-14 取舍): + state_dir/chv-audit-spill.jsonl,每行一 JSON + {ts, event, kind, detail, task_id};文件超 1MB 时 + 先轮转成 .old 再写。detail 沿用 _emit 契约,绝不 + 含密钥值。落盘不是 DB 替代:timeline 查询看不到它, + 需宿主人工查阅;轮转只留最新两代,旧事件会丢。""" + try: + path = os.path.join(self.state_dir, + "chv-audit-spill.jsonl") + if os.path.exists(path) \ + and os.path.getsize(path) > 1024 * 1024: + os.replace(path, path + ".old") + task_id = task.get("id") if isinstance(task, dict) \ + else str(task) + line = json.dumps({"ts": time.time(), "event": event, + "kind": kind, "detail": detail, + "task_id": task_id}) + with open(path, "a") as fh: + fh.write(line + "\n") + except Exception as exc: + _LOGGER.error("chv_audit_spill_failed error=%s", exc) # -- 命令构造(纯函数,不做文件校验) ------------------------------- def _build_vm_command(self, task): @@ -671,7 +769,7 @@ class CloudHypervisorExecutor(Executor): return _known_hosts_path(self.state_dir, self.vm_name(task_or_name)) - def _net_guest_ip(self): + def _net_guest_ip(self) -> str | None: """VmConfig net 首个带 ip 的条目(静态 tap 客机 IP)。""" if not self.net_json: return None @@ -691,8 +789,9 @@ class CloudHypervisorExecutor(Executor): 显式 CHV_SSH_HOST > net 静态客机 IP > 显式转发宣称下 的回环;三者皆无时报 ChvFailure(ssh_target_undeclared), 绝不无条件假设 127.0.0.1:2222 能通。""" - if getattr(self, "ssh_host", None): - return self.ssh_host + _ssh_host = self.ssh_host + if _ssh_host: + return _ssh_host guest_ip = self._net_guest_ip() if guest_ip: return guest_ip @@ -704,6 +803,30 @@ class CloudHypervisorExecutor(Executor): "里给 net 条目配 ip(静态 tap 客机 IP)、或显式设 " "CHV_SSH_FORWARD=1 宣称宿主 NAT 已转发回环端口") + def _read_keypair_texts(self, paths: dict) -> tuple: + """读 key 对文本(to10 提取,provision_host_key 降行用)。""" + with open(paths["public"], "r") as fh: + public_text = fh.read() + with open(paths["private"], "r") as fh: + private_text = fh.read() + return public_text, private_text + + def _write_provisioned_known_hosts(self, paths: dict, + public_text: str) -> None: + """写预置 known_hosts(to10 提取)。主机部分跟 SSH + 目标走(客机 IP 时不再写死回环,否则钉死校验永远 + 对不上);解析失败时回环仅作钉死写入,不放宽校验。""" + from cloud.chv.hostkeys import known_hosts_line + from cloud.chv.ssh_transport import write_known_hosts + try: + ssh_host = self.resolve_ssh_host() + except ChvFailure: + ssh_host = "127.0.0.1" + write_known_hosts( + paths["known_hosts"], + [known_hosts_line(self.ssh_port, public_text, + host=ssh_host)]) + def provision_host_key(self, task: dict, secret_env: dict | None = None) -> dict: """按 VM 预置 host key(补缺口轨强方案,见 hostkeys.py): 启动前为该 VM 生成一次性 ed25519 host key 对,公钥 @@ -718,10 +841,13 @@ class CloudHypervisorExecutor(Executor): 脏标记存在时直接拒绝(清理失败即拒起,不再复用 旧 key 对)。成功后审计 host_key_generated(只含 VM 名与密钥名,不含任何 key 值)。""" + # to10:known_hosts 写入已抽到 _write_provisioned_known_hosts, + # 此处只留生成与 seed 写入的 import(删后不再有未用 import)。 from cloud.chv.hostkeys import ( - dirty_marker_path, generate_host_keypair, - known_hosts_line, write_seed) - from cloud.chv.ssh_transport import write_known_hosts + dirty_marker_path, + generate_host_keypair, + write_seed, + ) vm = self.vm_name(task) if os.path.exists(dirty_marker_path(self.state_dir, vm)): raise ChvFailure( @@ -732,20 +858,8 @@ class CloudHypervisorExecutor(Executor): paths = generate_host_keypair( self.state_dir, vm, runner=lambda cmd: self._call_runner(cmd)) - with open(paths["public"], "r") as fh: - public_text = fh.read() - with open(paths["private"], "r") as fh: - private_text = fh.read() - # feat-chv-fix:known_hosts 行的主机部分跟 SSH 目标走 - # (客机 IP 时不再写死回环,否则钉死校验永远对不上)。 - try: - ssh_host = self.resolve_ssh_host() - except ChvFailure: - ssh_host = "127.0.0.1" - write_known_hosts( - paths["known_hosts"], - [known_hosts_line(self.ssh_port, public_text, - host=ssh_host)]) + public_text, private_text = self._read_keypair_texts(paths) + self._write_provisioned_known_hosts(paths, public_text) seed_dir = write_seed(paths["seed_dir"], vm, private_text, public_text, secret_env=secret_env) @@ -819,7 +933,7 @@ class CloudHypervisorExecutor(Executor): from cloud.chv.hostkeys import GUEST_ENV_PATH delivered = self._guest_env_delivered.get( self.vm_name(task), set()) - missing = [name for name in secrets if name not in delivered] + missing: list = [name for name in secrets if name not in delivered] if missing: raise ChvFailure( "secret_env_not_delivered", @@ -835,6 +949,27 @@ class CloudHypervisorExecutor(Executor): guest_env_file=guest_env_file) # -- API 模式:VmConfig / API 调用 / 就绪轮询 ----------------------- + def _vm_config_disks(self, task_disk: str | None, + seed_disk: str | None) -> list: + """VmConfig 磁盘列表(to10 提取,含 L2 白名单校验)。 + 任务盘 + 可选 seed 只读盘(CIDATA ISO,cloud-init + NoCloud 数据源);显式给的盘若不是基础镜像本身, + 须在 state_dir 内,否则 VmConfig 不能挂进宿主任意文件。""" + # 旧文案逐字保留:任务盘“任务磁盘须在…”,seed 盘 + # “seed 磁盘须在…”(label 本身含“磁盘”与空格)。 + for path, label in ((task_disk, "任务磁盘"), + (seed_disk, "seed 磁盘")): + if path and path != self.rootfs: + if not os.path.realpath(path).startswith( + os.path.realpath(self.state_dir) + os.sep): + raise ChvFailure( + "disk_path_not_allowed", + "%s须在 state_dir 内:%s" % (label, path)) + disks: list = [{"path": task_disk or self.rootfs}] + if seed_disk: + disks.append({"path": seed_disk, "readonly": True}) + return disks + def build_vm_config(self, task_disk: str | None = None, seed_disk: str | None = None) -> dict: """API 模式的 VmConfig(vm.create 请求体,纯函数): cpus{boot_vcpus,max_vcpus}、memory{size: 字节}、 @@ -847,36 +982,14 @@ class CloudHypervisorExecutor(Executor): cpus = int(self.cpus) except ValueError: raise ValueError("CHV_CPU 必须是整数:%r" % (self.cpus,)) - # feat-chv-secfix(L2):磁盘路径白名单。显式给的 - # task_disk 若不是基础镜像本身(reuse 模式的合法 - # 情形),须在 state_dir 内;seed 盘须在 state_dir - # 内——VmConfig 再也不能挂进宿主任意文件。 - if task_disk and task_disk != self.rootfs: - if not os.path.realpath(task_disk).startswith( - os.path.realpath(self.state_dir) + os.sep): - raise ChvFailure( - "disk_path_not_allowed", - "任务磁盘须在 state_dir 内:%s" % task_disk) - if seed_disk: - if not os.path.realpath(seed_disk).startswith( - os.path.realpath(self.state_dir) + os.sep): - raise ChvFailure( - "disk_path_not_allowed", - "seed 磁盘须在 state_dir 内:%s" % seed_disk) config = { "cpus": {"boot_vcpus": cpus, "max_vcpus": cpus}, "memory": {"size": _parse_memory_bytes(self.memory)}, "kernel": {"path": self.kernel}, "cmdline": {"args": self.cmdline}, - "disks": [{"path": task_disk or self.rootfs}], + "disks": self._vm_config_disks(task_disk, seed_disk), "rng": {"src": "/dev/urandom"}, } - # feat-chv-fix(P0-1):seed 磁盘是 VmConfig 第二块 - # 只读盘(CIDATA 卷标 ISO,客机 cloud-init NoCloud - # 数据源),不是宿主目录——目录本身从不进 VmConfig。 - if seed_disk: - config["disks"].append({"path": seed_disk, - "readonly": True}) if self.net_json: try: net = json.loads(self.net_json) @@ -1230,7 +1343,7 @@ class CloudHypervisorExecutor(Executor): raise return secrets, net_entries - def _provision_seed_disk(self, task: dict, name: str, secrets) -> str: + def _provision_seed_disk(self, task: dict, name: str, secrets) -> str | None: """provisioned 模式的 seed 盘供给:生成→制盘→挂盘, 任一步失败即抛(调用方 stop_vm 清理),纯移动。""" seed_disk = None @@ -1248,8 +1361,10 @@ class CloudHypervisorExecutor(Executor): # 失败即 fail-fast(VM 尚未启动,此时拒绝最 # 安全,except 里 stop_vm 会清掉 seed)。 from cloud.chv.hostkeys import ( - build_seed_disk, delete_host_private_key, - seed_disk_path) + build_seed_disk, + delete_host_private_key, + seed_disk_path, + ) provisioned = self.provision_host_key( task, secret_env=secrets or None) seed_disk = seed_disk_path(self.state_dir, name) @@ -1498,8 +1613,8 @@ class CloudHypervisorExecutor(Executor): self.pause_vm(task_or_name) self.snapshot_vm(task_or_name, snapshot_url) self.stop_vm(task_or_name) - task = task_or_name if isinstance(task_or_name, dict) \ - else {"id": task_or_name} + # to10:删死赋值(task 建出后未用,F841;返回值直接 + # 用 task_or_name 算 VM 名,行为不变)。 return {"vm": self.vm_name(task_or_name), "state": "hibernated", "snapshot": snapshot_url} @@ -1616,7 +1731,8 @@ class CloudHypervisorExecutor(Executor): 非 live(无 api-socket 文件且无登记活进程)即删盘 并审计;删失败审计 vm_disk_delete_failed 并计入 failed。返回 {deleted, failed}。""" - deleted, failed = [], [] + deleted: list = [] + failed: list = [] try: entries = os.listdir(self.state_dir) except Exception: @@ -1693,7 +1809,7 @@ class CloudHypervisorExecutor(Executor): """崩溃残留扫描(H5):私钥一律删、非 live VM 的 seed 同理删并审计;返回已清理 VM 名。worker 启动与 cleanup_stale_vms 时调它,不依赖 socket 是否存在。""" - cleaned = [] + cleaned: list = [] try: entries = os.listdir(self.state_dir) except Exception: @@ -1814,8 +1930,7 @@ class CloudHypervisorExecutor(Executor): extra_env=extra_env) result = self._call_runner(ssh_cmd, timeout=self.task_timeout) # 方法内 import:见模块头注释的循环导入说明。 - from cloud.sandbox import _parse_host_result - from cloud.sandbox import SimpleNamespaceShim + from cloud.sandbox import SimpleNamespaceShim, _parse_host_result parsed = _parse_host_result(task["id"], SimpleNamespaceShim(result)) parsed["executor"] = "cloud-hypervisor" diff --git a/cloud/chv/experimental_pool.py b/cloud/chv/experimental_pool.py index 5abccbb..ffb5df3 100644 --- a/cloud/chv/experimental_pool.py +++ b/cloud/chv/experimental_pool.py @@ -4,6 +4,7 @@ cloud/chv/pool.py 移到本文件,cloud/chv/__init__ 与 cloud/sandbox.py 不再 re-export 它,生产代码 import 本 模块即违规(见 docs/chv-architecture.md ADR-12 的禁 + import 清单与测试钉住)。实验用法只走 `from cloud.chv.experimental_pool import WarmPool`。 生产残留清理已彻底分文件:见 cloud/chv/orphans.py 的 @@ -53,18 +54,21 @@ Registry(本轨新增): 仍可用,重启后孤儿改由 socket 扫描兜底)。 """ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cloud.chv.executor import CloudHypervisorExecutor + import os -from cloud.chv.orphans import _REGISTRY_FILENAME -from cloud.chv.orphans import _read_registry -from cloud.chv.orphans import _write_registry +from cloud.chv.orphans import _REGISTRY_FILENAME, _read_registry, _write_registry class WarmPool: """快照预热池(实验性,见模块头定案)。 wired_to_dispatch = False:生产 dispatch 不查池。 - + 目标:冷启动(preflight + 起 VMM + create + boot + SSH 就绪)是慢路径;预热池提前把干净 VM 起好并暂停,任务 @@ -81,7 +85,7 @@ class WarmPool: wired_to_dispatch = False - def __init__(self, executor: object, size: int | None = None, + def __init__(self, executor: "CloudHypervisorExecutor", size: int | None = None, snapshot_url: str | None = None, registry_path: str | None = None) -> None: self.executor = executor @@ -90,7 +94,7 @@ class WarmPool: self.snapshot_url = snapshot_url \ if snapshot_url is not None \ else getattr(executor, "snapshot_url", None) - self._entries = {} # 合成名 -> {"state", "vm", "task", ...} + self._entries: dict = {} # 合成名 -> {"state", "vm", "task", ...} self.registry_path = registry_path if self.registry_path is None: state_dir = getattr(executor, "state_dir", None) diff --git a/cloud/chv/hardening.py b/cloud/chv/hardening.py index c37f9b9..f71b1eb 100644 --- a/cloud/chv/hardening.py +++ b/cloud/chv/hardening.py @@ -20,7 +20,7 @@ from cloud.chv.errors import ChvFailure _NET_FIELDS = frozenset({"tap", "mac", "ip", "mask"}) -def ensure_secure_state_dir(state_dir: str) -> None: +def ensure_secure_state_dir(state_dir: str) -> str: """建 state_dir 并保证 0700(H6):目录不存在即按 0700 建;已存在但组/其它位可访时 chmod 0700;失败即 ChvFailure(state_dir_insecure),绝不带着 /tmp 式 @@ -125,7 +125,7 @@ def _normalize_net_entry(entry: dict) -> dict: def _check_net_duplicates(normalized: list) -> None: """本次条目内 tap/mac/ip 重复即 net_config_invalid。""" - seen = {"tap": set(), "mac": set(), "ip": set()} + seen: dict[str, set[str]] = {"tap": set(), "mac": set(), "ip": set()} for entry in normalized: for kind in ("tap", "mac", "ip"): value = entry.get(kind) @@ -152,7 +152,7 @@ def _check_net_conflicts(normalized: list, existing_entries: list) -> None: % (kind, value, other.get("vm"))) -def validate_egress_proxy(egress_proxy: str) -> None: +def validate_egress_proxy(egress_proxy: str) -> str | None: """egress 代理地址校验(H1 代码门的一部分):须为 http/https URL 且带主机名;否则 net_config_invalid… 实际 kind 用 config_invalid(preflight 的配置类失败)。 diff --git a/cloud/chv/hostkeys.py b/cloud/chv/hostkeys.py index 65ff0cd..34c40d0 100644 --- a/cloud/chv/hostkeys.py +++ b/cloud/chv/hostkeys.py @@ -32,6 +32,7 @@ import os import shlex +from collections.abc import Callable # seed 投递的客内密钥环境文件路径(feat-chv-secfix,H2): # 只在 provisioned 模式的 seed user-data 里写出,0600 root, @@ -88,7 +89,8 @@ def seed_disk_path(state_dir: str, vm_name: str) -> str: return os.path.join(state_dir, vm_name, "seed.iso") -def build_seed_disk(seed_dir: str, seed_disk: str, runner: object = None) -> str: +def build_seed_disk(seed_dir: str, seed_disk: str, + runner: Callable[[list[str]], object] | None = None) -> str: """把 seed 目录做成 CIDATA 卷标 ISO(feat-chv-fix,P0-1 的 注入载体):genisoimage -volid CIDATA -joliet -rock。 @@ -206,7 +208,7 @@ def write_seed(seed_dir: str, vm_name: str, private_key_text: str, def generate_host_keypair(state_dir: str, vm_name: str, - runner: object = None, + runner: Callable[[list[str]], object] | None = None, keygen: str = "ssh-keygen") -> dict: """为该 VM 生成(或复用本 VM 已有的)host key 对。 diff --git a/cloud/chv/ssh_transport.py b/cloud/chv/ssh_transport.py index 995c461..71899b1 100644 --- a/cloud/chv/ssh_transport.py +++ b/cloud/chv/ssh_transport.py @@ -52,7 +52,7 @@ import shlex _CHV_SECRET_ENV_NAMES = frozenset({"SUB2API_API_KEY"}) -def split_secret_env(extra_env: dict) -> tuple: +def split_secret_env(extra_env: dict | None) -> tuple[dict, dict]: """把 extra_env 拆成(密钥子集,非密钥子集)。""" secrets, plain = {}, {} for key, value in dict(extra_env or {}).items(): diff --git a/cloud/chv/worker_adapter.py b/cloud/chv/worker_adapter.py new file mode 100644 index 0000000..b7b32e5 --- /dev/null +++ b/cloud/chv/worker_adapter.py @@ -0,0 +1,196 @@ +"""CHV worker 适配模块(to10 轨,见 ADR-13)。 + +worker.py 里散落的 CHV 强相关接线抽到这里,worker 只留 +薄包装(方法名、签名、调用点不变,旧测试继续走 worker +方法)。本模块只依赖标准库 + cloud.chv 叶子模块 + +cloud.logging_config,不 import cloud.worker,绝无循环。 + +抽入本模块的(行为与错误文案逐字搬运): +- call_goal_resume_breakpoint:通用断点调用(原 worker + 顶层函数 _call_goal_resume_breakpoint 的本体)。 +- make_chv_audit_callback:VM 生命周期事件进审计的回调 + (原 Worker.__init__ 里的闭包本体)。 +- reconcile_warm_pool:生产残留清理 facade 调用 + 审计 + (原 Worker._reconcile_warm_pool 的本体)。 +- cancel_watchdog_loop / quota_watchdog_loop:同步路径 + 两条看门狗循环(原 Worker._chv_*_watchdog 的本体, + self 依赖全部改成回调参数)。 +- watchdog_vm_name:看门狗启动时的 VM 名派生(原 + _run_attempt 里的 try/except 块)。 + +明确不抽的(见 ADR-13 诚实清单,worker 通用流程): +cancel() 里的 cancel_vm 分支、cleanup_stale_containers +的 CHV 分支、网关守卫、_executor_name 的 CHV 分支、 +_run_attempt 的看门狗线程启动、VM 死亡写回——它们都 +嵌在 Docker/Local 共用的通用流程里,硬抽只会把通用 +流程撕成两半,行为不变的收益为零。 +""" + +import inspect +import threading +from collections.abc import Callable + +from cloud.chv.executor import CloudHypervisorExecutor +from cloud.logging_config import log_event + + +def is_chv_executor(executor: object) -> bool: + """是否为 CHV 执行器(worker 各处 isinstance 的同款判断)。""" + return isinstance(executor, CloudHypervisorExecutor) + + +def call_goal_resume_breakpoint(executor: object, task: dict, + model_provider: str | None = None, + extra_env: dict | None = None) -> object: + """通用断点调用:执行器提供 goal_resume_breakpoint_task + 时调用它。 + + Local 仍走原路径(调用方直接传 model_provider,不经过 + 这里,以免改生产语义);这里只用于非 Local 执行器: + 签名接受 model_provider 时传它(CHV/Docker 均接受), + 否则只传 task(旧假执行器单参)。feat-chv-secfix(H2): + CHV 的 gateway_env 不再经 SSH env 前缀注入(密钥值进 + argv 的旧路径已废除),改走 provisioned seed 盘客内文件 + 投递;CHV 没有 Local 那样的进程 env 通道,seed 文件是 + 唯一通道,keyscan 模式下密钥投递 fail-closed。""" + method = getattr(executor, "goal_resume_breakpoint_task") + try: + params = inspect.signature(method).parameters + except (TypeError, ValueError): + return method(task) + kwargs: dict = {} + if "model_provider" in params: + kwargs["model_provider"] = model_provider + if "extra_env" in params and extra_env is not None: + kwargs["extra_env"] = extra_env + return method(task, **kwargs) + + +def make_chv_audit_callback(audit_fn: Callable) -> Callable: + """VM 生命周期事件进审计的回调(原 __init__ 闭包本体)。 + + audit_fn 即 Worker._audit(best-effort,失败不影响任务); + 时间线展示 VM 事件时读审计表即可,不另起一套 VM 事件表; + 成本/配额仍走 worker 现有 quota 语义,本回调绝不记账。""" + + def _chv_audit(event, task, detail, kind=None): + try: + project_id = task.get("project_id") \ + if isinstance(task, dict) else None + audit_fn(project_id, "chv_vm_" + event, detail or "") + except Exception: + pass + + return _chv_audit + + +def reconcile_warm_pool(executor: object, audit_fn: Callable, + when: str) -> list: + """显式调生产残留清理 facade,返回已清理 VM 名。 + + 仅 CHV worker 有效(其它执行器返回 [])。实现是 + cloud.chv.orphans.reconcile_orphans_for_executor(): + 直接读 registry 文件做清理,不构造 WarmPool、不碰 + 实验接口。调用失败不炸主流程,但进审计(失败本身 + 也审计,不静默丢弃)。orphan 条目绝不发给任务, + reconcile 只做清理。""" + if not isinstance(executor, CloudHypervisorExecutor): + return [] + try: + from cloud.chv.orphans import reconcile_orphans_for_executor as _reconcile + cleaned = _reconcile(executor) or [] + except Exception as exc: + try: + audit_fn(None, "chv_warm_pool_reconcile_failed", + "when=%s error=%s" % (when, exc)) + except Exception: + pass + return [] + if cleaned: + try: + audit_fn(None, "chv_warm_pool_reconcile", + "when=%s cleaned=%s" % (when, ",".join(cleaned))) + except Exception: + pass + return cleaned + + +def cancel_watchdog_loop(task_id: str, vm_name: str | None, + stop_event: threading.Event, + cancel_requested: set, + get_task: Callable, + cancel_vm: Callable | None, + logger: object) -> None: + """CHV 同步路径的取消看门狗(feat-chv-fix,P1-2,Docker + 取消看门狗同款模式):同步 SSH 阻塞时 worker 的 cancel() + 只能改库、清 _running 项,阻塞中的 run 本身不会醒; + 本线程每 1 秒查库/取消标志,命中时调 cancel_vm 真关 + VM,然后退出。查库失败时当没取消,不误杀。""" + while not stop_event.wait(1.0): + try: + if task_id in cancel_requested: + cancelled = True + else: + db_task = get_task(task_id) + cancelled = (db_task is not None and + db_task.get("status") == "interrupted") + except Exception: + cancelled = False + if not cancelled: + continue + try: + if cancel_vm is not None and vm_name: + cancel_vm(vm_name) + log_event(logger, "worker_chv_killed", + task_id=task_id, + message="cancel watchdog killed VM %s " + "(task interrupted/cancelled)" + % vm_name) + except Exception: + pass + return + + +def quota_watchdog_loop(task_id: str, vm_name: str | None, + stop_event: threading.Event, + quota_check: Callable | None, + mark_quota_hit: Callable, + cancel_vm: Callable | None, + logger: object, + interval: float = 30.0) -> None: + """CHV 同步路径的配额看门狗(feat-chv-fix,P1-2,Docker + 配额看门狗同款):命中时记 _quota_hit_tasks 并调 + cancel_vm 真关 VM;_run_attempt 在同步返回后把结果 + 改写成 quota_exceeded,走统一配额中断收尾。""" + while not stop_event.wait(interval): + try: + over = bool(quota_check()) if quota_check else False + except Exception: + over = False + if not over: + continue + mark_quota_hit(task_id) + try: + if cancel_vm is not None and vm_name: + cancel_vm(vm_name) + log_event(logger, "worker_chv_killed", + task_id=task_id, + message="quota watchdog killed VM %s " + "(quota exceeded)" % vm_name) + except Exception: + pass + return + + +def watchdog_vm_name(executor: object, task: dict) -> str | None: + """看门狗启动时的 VM 名派生(原 _run_attempt 块): + executor.vm_name(task) 抛错时回 None(不建看门狗), + 绝不炸主派发流程。executor 无 vm_name 时等同抛错 + (原代码里 AttributeError 同样被 except 吞掉回 None)。""" + get_vm_name = getattr(executor, "vm_name", None) + if not callable(get_vm_name): + return None + try: + return get_vm_name(task) + except Exception: + return None diff --git a/cloud/console-react/src/api/chv.test.ts b/cloud/console-react/src/api/chv.test.ts index 3a73d9e..82ef4b6 100644 --- a/cloud/console-react/src/api/chv.test.ts +++ b/cloud/console-react/src/api/chv.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { chvAttachmentBlocked, chvAvailability, chvQueueHint, chvVmBadge, chvVmStatusText, countChvWorkers, isChvExecutor, isWorkerAlive } from "./chv"; +import { chvAttachmentBlocked, chvAvailability, chvAvailabilityText, chvQueueHint, chvVmBadge, chvVmDisplay, chvVmStatusText, countChvWorkers, isChvExecutor, isWorkerAlive } from "./chv"; // 纯逻辑测试:只测 chv.ts,不渲染组件、不发请求、不读 localStorage。 // alive 规则与 WorkersSection 现有口径一致:alive 字段优先, @@ -103,3 +103,53 @@ describe("chvQueueHint", () => { expect(chvQueueHint("cloud-hypervisor", "queued", { state: "known" as const, online: 1, total: 1 })).toBe(""); }); }); + +describe("chvVmDisplay(to10 四态)", () => { + it("字段 present=true → dead", () => { + const d = chvVmDisplay({ executor: "cloud-hypervisor", chv_vm_dead: true }); + expect(d.state).toBe("dead"); + expect(d.text).toContain("VM 中途死亡"); + }); + it("字段 absent → not-reported,不猜 live", () => { + const d = chvVmDisplay({ executor: "cloud-hypervisor" }); + expect(d.state).toBe("not-reported"); + expect(d.text).toContain("不代表 VM 活着"); + }); + it("任务未加载 → unknown", () => { + const d = chvVmDisplay(null); + expect(d.state).toBe("unknown"); + expect(d.text).toContain("无法判断"); + }); + it("非 CHV → not-applicable,text 为空", () => { + const d = chvVmDisplay({ executor: "docker", chv_vm_dead: true }); + expect(d.state).toBe("not-applicable"); + expect(d.text).toBe(""); + }); + it("旧入口仍是 display 的 text(unknown/not-applicable 回空)", () => { + expect(chvVmStatusText({ executor: "cloud-hypervisor", chv_vm_dead: true })).toContain("VM 中途死亡"); + expect(chvVmStatusText(null)).toBe(""); + expect(chvVmStatusText({ executor: "docker" })).toBe(""); + }); +}); + +describe("chvAvailabilityText(to10 四处统一文案)", () => { + it("known 零在线:不拦提交、后端是最终权威", () => { + const text = chvAvailabilityText({ state: "known", online: 0, total: 2 }); + expect(text).toContain("在线 0 / 共 2"); + expect(text).toContain("不拦提交"); + expect(text).toContain("最终权威"); + }); + it("known 非零:只有在线数,无排队警告", () => { + const text = chvAvailabilityText({ state: "known", online: 1, total: 1 }); + expect(text).toContain("在线 1 / 共 1"); + expect(text).not.toContain("排队"); + }); + it("未知三原因各有不同文案,绝不写成 0", () => { + expect(chvAvailabilityText({ state: "unknown", reason: "aggregate-only" })).toContain("仅全局管理员可见"); + expect(chvAvailabilityText({ state: "unknown", reason: "load-failed" })).toContain("加载失败"); + expect(chvAvailabilityText({ state: "unknown", reason: "not-loaded" })).toContain("尚未加载"); + for (const reason of ["aggregate-only", "load-failed", "not-loaded"] as const) { + expect(chvAvailabilityText({ state: "unknown", reason })).not.toContain("在线 0"); + } + }); +}); diff --git a/cloud/console-react/src/api/chv.ts b/cloud/console-react/src/api/chv.ts index 31e32f8..ce10c25 100644 --- a/cloud/console-react/src/api/chv.ts +++ b/cloud/console-react/src/api/chv.ts @@ -54,27 +54,85 @@ export function chvAttachmentBlocked( return isChvExecutor(executor) && hasAttachments; } -// VM 只读状态文案:输入只有后端已返回的两个字段,输出 -// 三种情形之一,绝不编造第四种: -// - chv_vm_dead 为 true → 已报告死亡(来源:worker 终局 -// 写回的 interrupted_reason,见 server _chv_vm_dead_from_row); -// - CHV 任务但无该字段 → 未报告死亡,且明写“后端无 live -// 状态,不代表 VM 活着”(server 没有 VM 登记表); -// - 非 CHV 任务 → 空串(不显示 VM 行)。 -export function chvVmStatusText(task: { +// VM 只读状态:四种情形各有明确文案,绝不编造第五种, +// 绝不猜 live(to10 精确化,见 ADR-15): +// - dead:CHV 任务且 chv_vm_dead 为 true(字段已 present; +// 来源:worker 终局写回的 interrupted_reason,见 server +// _chv_vm_dead_from_row); +// - not-reported:CHV 任务但字段 absent(后端只在死亡时 +// 才返回该字段;absent 只表示“未报告”,且明写“后端无 +// live 状态字段,不代表 VM 活着”,server 没有 VM 登记表); +// - unknown:任务详情本身未加载(task 为 null),无法判断; +// - not-applicable:非 CHV 任务,不显示 VM 行(text 为空)。 +export type ChvVmDisplay = + | { state: "dead"; text: string } + | { state: "not-reported"; text: string } + | { state: "unknown"; text: string } + | { state: "not-applicable"; text: string }; + +export function chvVmDisplay(task: { executor?: string | null; chv_vm_dead?: boolean; -}): string { +} | null | undefined): ChvVmDisplay { + if (task === null || task === undefined) { + return { + state: "unknown", + text: "任务详情未加载,无法判断 VM 状态(后端无 live 状态字段,详情加载后才显示)", + }; + } // 死亡文案同样先过执行器门:后端只对 CHV 任务派生该字段, // 万一某天非 CHV 行误带该字段,前端也不把 docker 任务 // 写成“VM 死亡”。 if (task.chv_vm_dead && isChvExecutor(task.executor)) { - return "VM 中途死亡,任务未完成、断点可续跑(后端只读字段 chv_vm_dead)"; + return { + state: "dead", + text: "VM 中途死亡,任务未完成、断点可续跑(后端只读字段 chv_vm_dead)", + }; } if (isChvExecutor(task.executor)) { - return "未报告 VM 死亡(后端无 live 状态字段,不代表 VM 活着)"; + return { + state: "not-reported", + text: "未报告 VM 死亡(后端无 live 状态字段,不代表 VM 活着)", + }; } - return ""; + return { state: "not-applicable", text: "" }; +} + +// 旧入口(保留给已测调用点):即 chvVmDisplay 的 text, +// not-applicable 与 unknown 均回空串(调用点只在已加载的 +// CHV 行上用它,unknown 分支由详情页的新入口处理)。 +export function chvVmStatusText(task: { + executor?: string | null; + chv_vm_dead?: boolean; +} | null | undefined): string { + const display = chvVmDisplay(task); + return display.state === "not-applicable" || + display.state === "unknown" + ? "" + : display.text; +} + +// CHV 可用性统一文案(to10):新建任务、Workers、环境 +// 三处此前各写各的,Workers 的未知分支甚至恒写“仅全局 +// 管理员可见”(加载失败/未加载时也是这句,是文案 bug)。 +// 四处统一走本函数:known 零在线时不拦提交、后端是最终 +// 权威;未知时按 reason 分三句,绝不把未知写成 0。 +export function chvAvailabilityText( + availability: ChvAvailability, +): string { + if (availability.state === "known") { + const base = `CHV worker 在线 ${availability.online} / 共 ${availability.total}(来自 /workers)`; + return availability.online === 0 + ? `${base}:当前无 CHV worker 在线,任务将一直排队,请先确认宿主 worker 已起;本提示不拦提交,worker 随后上线可认领,后端是最终权威。` + : base; + } + if (availability.reason === "aggregate-only") { + return "CHV worker 在线数未知:完整 worker 清单仅全局管理员可见,非管理员只能看到聚合数,无法按执行器分项。"; + } + if (availability.reason === "load-failed") { + return "CHV worker 在线数未知:/workers 加载失败,请到 Workers 区刷新后重试。"; + } + return "CHV worker 在线数未知:/workers 尚未加载,请到 Workers 区刷新。"; } // 列表行徽标:只有已报告死亡时才出现短徽标;未报告时 diff --git a/cloud/console-react/src/sections/DispatchSection.tsx b/cloud/console-react/src/sections/DispatchSection.tsx index c23281b..bdc93c9 100644 --- a/cloud/console-react/src/sections/DispatchSection.tsx +++ b/cloud/console-react/src/sections/DispatchSection.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from "react"; import { useConsole } from "../context"; import { Card, Bar } from "../components/ui"; import { gatherAttachments } from "../api/client"; -import { chvAttachmentBlocked, chvAvailability, isChvExecutor } from "../api/chv"; +import { chvAttachmentBlocked, chvAvailability, chvAvailabilityText, isChvExecutor } from "../api/chv"; import { EFFORTS } from "../api/types"; export default function DispatchSection() { @@ -97,14 +97,8 @@ export default function DispatchSection() { {isChv ?
实验执行器,默认关闭:需后端已开启 CHV worker 且宿主有 KVM,否则任务无人认领、一直排队;带附件时建任务即被后端拒绝(错误文案原样显示在下方)。前端只传 executor 值,不绕过后端白名单;默认仍是 docker。
: null} {isChv ? ( -
- {chvAvail.state === "known" - ? <>CHV worker 在线 {chvAvail.online} / 共 {chvAvail.total}(来自 /workers){chvAvail.online === 0 ? :当前无 CHV worker 在线,任务将一直排队,请先确认宿主 worker 已起;本提示不拦提交,worker 随后上线可认领,后端是最终权威。 : null} - : chvAvail.reason === "aggregate-only" - ? "CHV worker 在线数未知:完整 worker 清单仅全局管理员可见,非管理员只能看到聚合数,无法按执行器分项。" - : chvAvail.reason === "load-failed" - ? "CHV worker 在线数未知:/workers 加载失败,请到 Workers 区刷新后重试。" - : "CHV worker 在线数未知:/workers 尚未加载,请到 Workers 区刷新。"} +
+ {chvAvailabilityText(chvAvail)} {" "}
) : null} @@ -141,14 +135,14 @@ export default function DispatchSection() { {goalInvincible ?
goal 模式无敌版:goal 模式 + 任务不限时(去掉 worker/docker 总超时;单轮 600 秒护栏还在,不限的是任务总时长与总轮次)。服务端默认的自动续跑与断点镜像同时生效。
: null} 附件(放进任务工作区,图片与普通文件不区分): - - - + + + {isChv ?
cloud-hypervisor 任务不支持附件:附件输入已禁用(后端对带附件的 CHV 任务会 400,错误文案原样显示在下方;切换回其他执行器可恢复附件输入)。{hasAttachments ? <>当前仍有已选附件,请先清空再提交。 : ""}
: null} - {msg || ctx.dispatchMsg} + {msg || ctx.dispatchMsg} ); diff --git a/cloud/console-react/src/sections/EnvSection.tsx b/cloud/console-react/src/sections/EnvSection.tsx index 247b61b..01100c1 100644 --- a/cloud/console-react/src/sections/EnvSection.tsx +++ b/cloud/console-react/src/sections/EnvSection.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useConsole } from "../context"; import { Card, Bar, DataListRaw } from "../components/ui"; import { shortId } from "../api/client"; -import { chvAvailability } from "../api/chv"; +import { chvAvailability, chvAvailabilityText } from "../api/chv"; export default function EnvSection() { const ctx = useConsole(); @@ -37,7 +37,7 @@ export default function EnvSection() { - {executor === "cloud-hypervisor" ?
实验执行器,默认关闭:环境创建白名单同样允许 cloud-hypervisor(后端环境接口 allowlist:local / docker / cloud-hypervisor,不含 cloud);仍需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务排队。前端只传值,不绕过后端白名单。{chvAvail.state === "known" ? <> CHV worker 在线 {chvAvail.online} / 共 {chvAvail.total}(来自 /workers){chvAvail.online === 0 ? ":当前无 CHV worker 在线,任务将一直排队。" : ""} : " CHV worker 在线数未知:完整 worker 清单仅全局管理员可见。"}
: null} + {executor === "cloud-hypervisor" ?
实验执行器,默认关闭:环境创建白名单同样允许 cloud-hypervisor(后端环境接口 allowlist:local / docker / cloud-hypervisor,不含 cloud);仍需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务排队。前端只传值,不绕过后端白名单。 {chvAvailabilityText(chvAvail)}
: null} {ctx.envMsg} ); diff --git a/cloud/console-react/src/sections/TaskDetailPanel.tsx b/cloud/console-react/src/sections/TaskDetailPanel.tsx index b3a8af6..edb9224 100644 --- a/cloud/console-react/src/sections/TaskDetailPanel.tsx +++ b/cloud/console-react/src/sections/TaskDetailPanel.tsx @@ -4,7 +4,7 @@ import { Card } from "../components/ui"; import { shortId, fmt } from "../api/client"; import { STATUS_EXPLAIN } from "../api/types"; import { firstChar, durationOf, tokenOf, fmtCostUsd } from "../api/taskMeta"; -import { chvAvailability, chvQueueHint, chvVmStatusText } from "../api/chv"; +import { chvAvailability, chvQueueHint, chvVmDisplay } from "../api/chv"; import DetailActions from "./detail/DetailActions"; import BrowserPanel from "./detail/BrowserPanel"; import ReviewPanel from "./detail/ReviewPanel"; @@ -131,7 +131,7 @@ export default function TaskDetailPanel() {
turn
{shortId(t.turn_id)}
root turn
{shortId(t.root_turn_id)}
executor
{t.executor || ""}{t.executor === "cloud-hypervisor" ? "(实验执行器,默认关闭;仅展示,无 VM 控制按钮)" : ""}
- {t.executor === "cloud-hypervisor" ?
VM 状态
{chvVmStatusText(t)}
: null} + {t.executor === "cloud-hypervisor" ?
VM 状态
{chvVmDisplay(t).text}
: null}
主机
{t.worktree_host || ""}
模型
{t.model || "默认"}
强度
{t.effort || "默认"}
diff --git a/cloud/console-react/src/sections/WorkersSection.tsx b/cloud/console-react/src/sections/WorkersSection.tsx index 71860f7..40b5f56 100644 --- a/cloud/console-react/src/sections/WorkersSection.tsx +++ b/cloud/console-react/src/sections/WorkersSection.tsx @@ -1,7 +1,7 @@ import { useConsole } from "../context"; import { Card, Bar, DataListRaw } from "../components/ui"; import { workerAgeText, shortId } from "../api/client"; -import { chvAvailability } from "../api/chv"; +import { chvAvailability, chvAvailabilityText } from "../api/chv"; export default function WorkersSection() { const ctx = useConsole(); @@ -9,10 +9,8 @@ export default function WorkersSection() { return ( -
- {chvAvail.state === "known" - ? <>cloud-hypervisor worker 在线 {chvAvail.online} / 共 {chvAvail.total}(实验,默认关闭){chvAvail.online === 0 ? ":当前无 CHV worker 在线,CHV 任务将一直排队。" : ""} - : "cloud-hypervisor worker 在线数未知:完整 worker 清单仅全局管理员可见,非管理员只能看到聚合数,无法按执行器分项。"} +
+ {chvAvailabilityText(chvAvail)}
{ctx.workersError ?
{ctx.workersError}
: ctx.workers === null ?
{ctx.workersSummary || "未加载"}
diff --git a/cloud/console.html b/cloud/console.html index d7b0671..7982a6a 100644 --- a/cloud/console.html +++ b/cloud/console.html @@ -4,7 +4,7 @@ Codex 命令台 - +`)}}j("流已断开")}catch(at){if(at.name==="AbortError")return;R(at instanceof Error?at.message:String(at)),j("未开始")}finally{g(!1)}},[r]),Q=d.useCallback(()=>{var w;(w=k.current)==null||w.abort(),g(!1),j("已停止")},[]),vt=d.useCallback(async w=>{if(r)try{const V=await lt(W.taskBrowserControl(r.id),"POST",{holder:w});N(V),R("")}catch(V){R(V instanceof Error?V.message:String(V))}},[r]),St=d.useCallback(async w=>{if(!r||(p==null?void 0:p.holder)!=="user"||!F.current)return;const V=F.current.getBoundingClientRect(),at=Math.round((w.clientX-V.left)/V.width*1280),Dt=Math.round((w.clientY-V.top)/V.height*800);try{await lt(W.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:at,y:Dt})}catch(qt){R(qt instanceof Error?qt.message:String(qt))}},[r,p]),jt=d.useCallback(async w=>{if(!(!r||(p==null?void 0:p.holder)!=="user")){w.preventDefault();try{w.key.length===1?await lt(W.taskBrowserInput(r.id),"POST",{type:"type",text:w.key}):await lt(W.taskBrowserInput(r.id),"POST",{type:"key",key:w.key})}catch(V){R(V instanceof Error?V.message:String(V))}}},[r,p]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const K=(p==null?void 0:p.holder)||"—",Nt=p!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:C})]}),c.jsxs("span",{className:"muted",children:["会话 ",Nt?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",K]}),p!=null&&p.url?c.jsx("span",{className:"muted",title:p.url,children:p.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[q?c.jsx("button",{type:"button",onClick:Q,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void J(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void X(),children:"刷新状态"}),K!=="user"?c.jsx("button",{type:"button",onClick:()=>void vt("user"),disabled:!Nt,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void vt("agent"),children:"交还给 Agent"})]}),z?c.jsx("div",{className:"muted",style:{marginTop:8},children:z}):null,K==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:F,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:w=>void St(w),onKeyDown:w=>void jt(w),style:{width:"100%",maxWidth:1280,cursor:K==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:Nt?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function zg(){const i=Kt(),[r,h]=d.useState("uncommittedChanges"),[f,p]=d.useState(""),[N,C]=d.useState(""),[j,z]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:R=>h(R.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:R=>p(R.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:N,onChange:R=>C(R.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:j,onChange:R=>z(R.target.value)}),c.jsxs(Ht,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:N.trim()||void 0,instructions:j.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(cu,{children:i.reviews.map(R=>{let q=R.target_type||"";return R.branch&&(q+=":"+R.branch),R.sha&&(q+=":"+String(R.sha).slice(0,12)),R.instructions&&(q+=":"+String(R.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",Ae(R.id)," | ",q," | ",c.jsx("span",{className:"st-"+(R.status||""),children:R.status}),R.created_at?" | "+Kl(R.created_at):"",c.jsx("br",{}),R.findings?String(R.findings).slice(0,1e3):c.jsx("span",{className:"muted",children:"(暂无 findings:评审尚未执行或尚未写回)"})]}),c.jsx("button",{onClick:()=>i.submitFindings(R.id),children:"写 findings"})]},R.id)})})]})}function Rg(){const i=Kt();return i.currentTask?i.currentTask.coordinator_parts?i.coordChildren.length===0?c.jsx("div",{className:"muted",children:"(子任务尚未创建)"}):c.jsx(cu,{children:i.coordChildren.map(r=>c.jsx("li",{children:c.jsxs("span",{className:"row-main",children:["分工 ",c.jsx("b",{children:r.coordinator_part||""})," | 状态 ",c.jsx("span",{className:"st-"+r.status,children:r.status})," | ",Ae(r.id)," | ",String(r.prompt||"").slice(0,60)]})},r.id))}):c.jsx("div",{className:"muted",children:"(非协调员任务)"}):c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示协调员的子任务"})}function Dg(){const i=Kt();if(!i.currentTask)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示子代理链路"});if(i.agents.length===0)return c.jsxs("div",{className:"muted",children:["(暂无子代理)",i.agentsNote?c.jsx("span",{className:"err",children:i.agentsNote}):null]});const r={main:"主任务",delegate:"委派子任务",model:"模型子代理"},h={running:"进行中",completed:"已完成",failed:"失败",interrupted:"已中断",unknown:"未知"};return c.jsx(cu,{children:i.agents.map((f,p)=>c.jsx("li",{children:c.jsxs("span",{className:"row-main",children:[c.jsxs("b",{children:[r[f.kind||""]||f.kind||"",":"]}),f.name||""," ",c.jsx("span",{className:"st-"+(f.status||""),children:h[f.status||""]||f.status||"?"})," | 过程调用 "+(f.calls?f.calls.length:0)+" 条",f.updated_ts?" | 最近活动 "+Kl(f.updated_ts/1e3):"",f.prompt?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsxs("span",{className:"muted",children:["任务:",f.prompt]})]}):null,f.calls&&f.calls.length?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),f.calls.slice(-50).map((N,C)=>c.jsx("div",{children:po(N)},C))]}):c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsx("span",{className:"muted",children:"(暂无过程调用)"})]})]})},p))})}function Ug(){const i=Kt();return c.jsxs(c.Fragment,{children:[c.jsxs(Ht,{children:[c.jsx("button",{onClick:i.refreshApprovals,children:"刷新审批队列"}),c.jsx("span",{className:"muted",children:i.approvalMsg})]}),c.jsx("div",{className:"muted",children:"待审批项需人工点记录允许或记录拒绝;超时默认拒绝。当前版本只留回执,不拦截实时行为(实时请求的事前拦截待 host 接入后生效,在此之前实时行为不变)。"}),c.jsx(bl,{empty:"(暂无审批项)",items:i.approvals.map(r=>c.jsxs("span",{className:"row-main",children:["[",r.kind,"|",r.status,"] ",r.summary,["approved","denied","expired"].includes(r.status||"")?"|回执:"+(r.decision||r.status)+" by "+String(r.decided_by||"").slice(0,8)+" "+Kl(r.decided_at):"",r.status==="pending"?c.jsxs(c.Fragment,{children:[" ",c.jsx("button",{onClick:()=>i.decideApproval(r.id,"approved"),children:"记录允许"})," ",c.jsx("button",{onClick:()=>i.decideApproval(r.id,"denied"),children:"记录拒绝"})]}):null]},r.id))})]})}function wg(){const i=Kt(),[r,h]=d.useState(""),[f,p]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"目标任务 id(与本任务同项目;对方运行中排队执行、已终态则续跑复活,绝不打断当前轮)",style:{width:"100%"},value:r,onChange:N=>h(N.target.value)}),c.jsx("textarea",{placeholder:"消息正文(以本任务名义发出;对方 agent 会看到发送方任务 id,可照此回消息)",value:f,onChange:N=>p(N.target.value)}),c.jsxs(Ht,{children:[c.jsx("button",{onClick:async()=>{await i.postPeer("queued",r,f),p("")},children:"排队发给对方"}),c.jsx("button",{className:"primary",onClick:async()=>{await i.postPeer("direct",r,f),p("")},children:"直接发送"}),c.jsx("span",{className:"muted",children:i.peerMsg})]}),c.jsx(bl,{empty:"(本任务尚未发出任务间消息)",items:i.peerMessages.map((N,C)=>c.jsxs("span",{className:"row-main",children:["[发给 ",String(N.task_id).slice(0,8),"|",N.mode,"|",N.status,"] ",String(N.body||"").slice(0,120)]},C))})]})}function kg(){const i=Kt(),[r,h]=d.useState(""),f=i.currentTask?["completed","failed","interrupted"].includes(i.currentTask.status):!1;return c.jsxs(c.Fragment,{children:[c.jsx("div",{className:"muted",children:i.threadText||"点任务行查看详情后,这里显示线程元数据"}),c.jsxs(Ht,{children:[c.jsx("input",{type:"text",placeholder:"新线程名",value:r,onChange:p=>h(p.target.value)}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("rename",r),children:"重命名"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("archive",""),children:"归档"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("unarchive",""),children:"取消归档"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("compact",""),children:"压缩"}),c.jsx("span",{className:"muted",children:i.threadGateNote}),c.jsx("span",{className:"muted",children:i.threadMsg})]}),c.jsx(bl,{empty:"暂无其他线程",items:i.threadList.map((p,N)=>c.jsxs("span",{className:"row-main",children:[p.name||p.thread_name||p.id||""," [",p.id||"","]",p.archived?" [已归档]":"",p.cwd?" "+p.cwd:""]},N))}),c.jsx("div",{className:"muted",children:"限制:Docker/异机任务线程操作一律 409;merged/merge-failed 不可改;turn/steer 与 review/start 不在本批。"})]})}function Hg(i){return/503|Service Unavailable/.test(i)?"上游模型服务暂时不可用(503):模型一轮都没跑出来,不是任务逻辑失败;等上游恢复或换模型重试":/429|rate limit/i.test(i)?"上游限流(429):模型拒绝了本次调用,可稍后重试":/timeout|timed out/i.test(i)?"上游调用超时:模型响应未在限时内返回":/401|403|Invalid API key|unauthorized/i.test(i)?"上游鉴权失败:网关 key 或权限有问题":/ECONNREFUSED|ENOTFOUND|network|fetch failed/i.test(i)?"连不上上游模型服务:网络或网关地址有问题":"任务失败(原因见原始错误文本)"}function Bg(i){return i===null||i<0?"未知":i<60?Math.floor(i)+" 秒前":i<3600?Math.floor(i/60)+" 分钟前":(i/3600).toFixed(1)+" 小时前"}const qg=[["overview","概览"],["collab","协作"],["review","评审与审批"],["threads","线程"],["browser","浏览器"]];function Yg(){const i=Kt(),r=i.currentTask,[h,f]=d.useState("overview");if(!r)return c.jsx(Ft,{id:"nav-detail",title:"任务详情",children:c.jsx("div",{className:"muted",children:"点任务行查看详情"})});const[p,N]=lv(r),[C,j]=av(r),z=si(i.workers,i.workersSummary,i.workersError),R=wc(r.executor,r.status,z),q=ev(r),g=i.costSummary,k=r.plan?"计划模式(只读)":r.goal_mode?r.no_time_limit?"goal 模式无敌版(不限时)":"goal 模式(不限轮次,模型宣告完成)":r.verify_command?"goal 模式(旧语义,验收命令)":null;return c.jsxs("div",{className:"detail-stack",id:"nav-detail",children:[c.jsx("nav",{className:"detail-tabs","aria-label":"任务详情页签",children:qg.map(([F,X])=>c.jsx("button",{type:"button",className:h===F?"active":"",onClick:()=>f(F),children:X},F))}),h==="overview"?c.jsxs(c.Fragment,{children:[c.jsxs(Ft,{title:"任务详情",children:[c.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"},children:[c.jsx("span",{className:"issue-first",title:"任务首字:"+q,"aria-hidden":"true",children:q}),c.jsxs("span",{children:["状态 ",c.jsx("b",{className:"st-"+r.status,children:r.status}),"(",eg[r.status]||"未知状态",")"]}),c.jsxs("span",{className:"muted",children:["更新 ",Kl(r.updated_at)]})]}),c.jsxs("div",{className:"detail-cards",children:[c.jsxs("div",{className:"detail-card",title:N,children:[c.jsx("div",{className:"detail-card-label",children:"耗时"}),c.jsx("div",{className:"detail-card-value",children:p})]}),c.jsxs("div",{className:"detail-card",title:j,children:[c.jsx("div",{className:"detail-card-label",children:"token"}),c.jsx("div",{className:"detail-card-value",children:C})]}),c.jsxs("div",{className:"detail-card",title:"LLM 请求数 = GET /tasks/{id}/cost 的 llm_requests,即 cost_records 的 completions 之和:host.py 每收到一次 thread/tokenUsage/updated(一次上游补全)计 1,worker 每轮终局记账时累加;估算行(无真实用量)计 0;部署前已记账的老任务没有 completions 列,显示 0 而非回填数",children:[c.jsx("div",{className:"detail-card-label",children:"LLM 请求数"}),c.jsx("div",{className:"detail-card-value",children:g===void 0?"加载中…":g===null?"—":(g.llm_requests??0).toLocaleString()}),g===void 0?c.jsx("div",{className:"muted",children:"成本加载中…"}):g===null?c.jsx("div",{className:"muted",children:"成本明细加载失败,LLM 请求数不可用"}):c.jsxs("div",{className:"muted",children:["上游补全次数之和| 轮数 ",g.turns??0," | fallback ",g.fallback_count??0]})]}),c.jsxs("div",{className:"detail-card",title:"成本明细:GET /tasks/{id}/cost 聚合;美元只计有价行,无价 token 单列;缓存是输入 token 的子集,不另计",children:[c.jsx("div",{className:"detail-card-label",children:"成本"}),c.jsx("div",{className:"detail-card-value",children:g===void 0?"加载中…":g===null?"—":bg(g.usd_micros)}),g===void 0?c.jsx("div",{className:"muted",children:"成本加载中…"}):g===null?c.jsx("div",{className:"muted",children:"成本明细加载失败(“查成本”按钮仍可手动拉取原始 JSON)"}):c.jsxs("div",{className:"muted",children:["token ",(g.total_tokens??0).toLocaleString(),"(实际 ",(g.actual_tokens??0).toLocaleString()," / 估算 ",(g.estimated_tokens??0).toLocaleString(),",缓存 ",(g.cached_input_tokens??0).toLocaleString(),",无价 ",(g.unpriced_actual_tokens??0).toLocaleString(),")| fallback ",g.fallback_count??0]})]})]})]}),r.status==="running"||r.status==="queued"?(()=>{const F=Date.now()/1e3,X=r.last_heartbeat||r.updated_at||null,J=X?F-X:null;return c.jsxs(Ft,{title:"LLM 进度",children:[c.jsxs("div",{children:["状态 ",r.status==="running"?"进行中":"排队中"," · 已用 ",p,J!==null?" · 上次活动 "+Bg(J):""]}),R?c.jsx("div",{className:"muted",children:R}):null,J!==null&&J>180?c.jsx("div",{className:"muted",children:"超过 3 分钟无活动:可能在等上游模型响应(上游 503 时 worker 会重试后失败,失败后这里会显示失败原因;无活动不等于已断连,服务端没有“LLM 已连接”字段,不作此断言)"}):c.jsx("div",{className:"muted",children:"首轮无输出时属正常:模型第一轮回复到达前 trace 里没有消息;回复到达后会在会话区出现,成本里的 LLM 请求数随之增加"})]})})():null,r.error_text?c.jsxs(Ft,{title:"失败原因",children:[c.jsx("div",{children:Hg(r.error_text)}),c.jsx("pre",{style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:r.error_text.slice(0,1e3)}),c.jsxs("div",{className:"muted",children:["完整错误文本仍在下方“操作与结果”里;模型 ",r.model||"默认"," · executor ",r.executor||""]})]}):null,c.jsx(Ft,{title:"基本信息",children:c.jsxs("dl",{className:"detail-kv",children:[c.jsxs("div",{children:[c.jsx("dt",{children:"任务"}),c.jsx("dd",{children:r.id})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"turn"}),c.jsx("dd",{children:Ae(r.turn_id)})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"root turn"}),c.jsx("dd",{children:Ae(r.root_turn_id)})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"executor"}),c.jsxs("dd",{children:[r.executor||"",r.executor==="cloud-hypervisor"?"(实验执行器,默认关闭;仅展示,无 VM 控制按钮)":""]})]}),r.executor==="cloud-hypervisor"?c.jsxs("div",{children:[c.jsx("dt",{children:"VM 状态"}),c.jsx("dd",{role:"status","aria-label":"VM 状态",children:Sg(r).text})]}):null,c.jsxs("div",{children:[c.jsx("dt",{children:"主机"}),c.jsx("dd",{children:r.worktree_host||""})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"模型"}),c.jsx("dd",{children:r.model||"默认"})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"强度"}),c.jsx("dd",{children:r.effort||"默认"})]}),r.child_model||r.child_effort?c.jsxs("div",{children:[c.jsx("dt",{children:"子任务默认"}),c.jsx("dd",{children:(r.child_model||"跟随本任务")+" / "+(r.child_effort||"跟随本任务")})]}):null,c.jsxs("div",{children:[c.jsx("dt",{children:"权限"}),c.jsx("dd",{children:r.approval||"默认"})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"项目"}),c.jsx("dd",{children:Ae(r.project_id)})]}),k?c.jsxs("div",{children:[c.jsx("dt",{children:"模式"}),c.jsx("dd",{children:k})]}):null,r.forked_from?c.jsxs("div",{children:[c.jsx("dt",{children:"分支自"}),c.jsx("dd",{children:Ae(r.forked_from)})]}):null,r.coordinator_parts?c.jsxs("div",{children:[c.jsx("dt",{children:"协调员"}),c.jsxs("dd",{children:[r.coordinator_parts.length," 个子任务(",r.coordinator_parts.map(F=>F.name).join("、"),")"]})]}):null,r.parent_task_id?c.jsxs("div",{children:[c.jsx("dt",{children:"协调子任务"}),c.jsxs("dd",{children:["父任务 ",Ae(r.parent_task_id),r.coordinator_part?"("+r.coordinator_part+")":""]})]}):null,r.attachments&&r.attachments.length?c.jsxs("div",{children:[c.jsx("dt",{children:"附件"}),c.jsx("dd",{children:r.attachments.join("、")})]}):null,r.pr_url?c.jsxs("div",{children:[c.jsx("dt",{children:"PR"}),c.jsx("dd",{children:c.jsxs("a",{href:r.pr_url,target:"_blank",rel:"noopener noreferrer",children:["PR #",r.pr_number]})})]}):null]})}),c.jsx(Ft,{title:"prompt",children:c.jsx("div",{style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:r.prompt||"(无 prompt)"})}),c.jsxs(Ft,{title:"操作与结果",children:[c.jsx(Mg,{}),c.jsx("pre",{children:i.detailBody||"(暂无 result/error 输出)"}),i.costBox?c.jsx("pre",{children:i.costBox}):null]})]}):h==="collab"?c.jsxs(c.Fragment,{children:[c.jsx(Ft,{title:"协调子任务",children:c.jsx(Rg,{})}),c.jsx(Ft,{title:"子代理链路",children:c.jsx(Dg,{})}),c.jsx(Ft,{title:"给其他任务发消息",children:c.jsx(wg,{})})]}):h==="review"?c.jsxs(c.Fragment,{children:[c.jsx(Ft,{title:"评审队列",children:c.jsx(zg,{})}),c.jsx(Ft,{title:"运行时审批队列",children:c.jsx(Ug,{})})]}):h==="browser"?c.jsx(c.Fragment,{children:c.jsx(Ft,{title:"浏览器远控",children:c.jsx(Og,{})})}):c.jsx(c.Fragment,{children:c.jsx(Ft,{title:"线程",children:c.jsx(kg,{})})})]})}function Gg(){const i=Kt(),[r,h]=d.useState(""),[f,p]=d.useState(""),[N,C]=d.useState([]),[j,z]=d.useState(null),[R,q]=d.useState(!1),[g,k]=d.useState(""),[F,X]=d.useState(""),J=d.useCallback(async()=>{if(!i.keySaved){C([]);return}try{const K=await lt(W.userKeys);C(K.keys||[]),X("")}catch(K){X(K instanceof Error?K.message:"加载 key 列表失败")}},[i.keySaved]);d.useEffect(()=>{J()},[J]);const Q=async()=>{k(""),X(""),q(!1);try{const K=await lt(W.userKeys,"POST",{label:f.trim()||"api-key"});z(K),p(""),k("新 key 已生成,只显示一次:复制后请妥善保存。"),await J()}catch(K){X(K instanceof Error?K.message:"生成 key 失败")}},vt=async()=>{if(j)try{await navigator.clipboard.writeText(j.api_key),q(!0)}catch{X("复制失败:请手动选中上方 key 文本复制")}},St=()=>{j&&(i.saveKeyInput(j.api_key),z(null),k("已存为当前浏览器 key(原有 key 未被撤销,需要时可单独撤销)。"))},jt=async K=>{const Nt=(()=>{try{return sl().slice(-4)}catch{return""}})(),w=K.id==="legacy"?"(主 key:撤销后当前浏览器保存的 key 也可能随之失效,需重新登录拿新 key)":K.hint&&K.hint===Nt?"(这就是本浏览器当前在用的 key,撤销后本页会立刻未连接)":"";if(window.confirm(`撤销 key「${K.label}」(后 4 位 ${K.hint||"无"})吗?${w}撤销不可恢复。`)){k(""),X("");try{await lt(W.userKey(K.id),"DELETE"),k(`已撤销 key「${K.label}」。`),await J()}catch(V){X(V instanceof Error?V.message:"撤销失败")}}};return c.jsxs(Ft,{id:"nav-key",title:"API Key",children:[c.jsxs(Ht,{children:[c.jsx("input",{type:"password",placeholder:"粘贴你的 X-API-Key(与调 API 用的同一个)",autoComplete:"off",value:r,onChange:K=>h(K.target.value)}),c.jsx("button",{className:"primary",onClick:()=>{i.saveKeyInput(r),h("")},children:"保存到本浏览器"}),c.jsx("button",{onClick:i.clearKeyInput,children:"清除"}),c.jsx("span",{className:"muted",children:i.keyState})]}),c.jsx("div",{children:c.jsx("span",{className:"muted",children:i.quotaText})}),!i.keySaved&&c.jsx("div",{children:c.jsx("span",{className:"muted",children:"先粘贴并保存已有 key,再生成或管理 key(生成接口本身也要用已有 key 鉴权)。"})}),i.keySaved&&c.jsxs(c.Fragment,{children:[c.jsxs(Ht,{children:[c.jsx("input",{placeholder:"新 key 备注(可选,默认 api-key,如 harness2-temp)",autoComplete:"off",value:f,onChange:K=>p(K.target.value)}),c.jsx("button",{className:"primary",onClick:()=>void Q(),children:"生成新 key"}),c.jsx("button",{onClick:()=>void J(),children:"刷新列表"})]}),j&&c.jsxs("div",{children:[c.jsx("div",{children:c.jsxs("span",{className:"muted",children:["新 key(",j.label,",后 4 位 ",j.hint,"):只显示一次,关闭后无法再查看。"]})}),c.jsxs(Ht,{children:[c.jsx("input",{type:"text",readOnly:!0,value:j.api_key,onFocus:K=>K.target.select()}),c.jsx("button",{className:"primary",onClick:()=>void vt(),children:R?"已复制":"复制"}),c.jsx("button",{onClick:St,children:"存为当前浏览器 key"}),c.jsx("button",{onClick:()=>z(null),children:"关闭(不再显示)"})]})]}),g&&c.jsx("div",{children:c.jsx("span",{className:"muted",children:g})}),F&&c.jsx("div",{children:c.jsx("span",{children:F})}),c.jsxs("div",{className:"api-key-list",children:[N.length===0&&c.jsx("span",{className:"muted",children:"暂无 key(或列表加载失败,见上方报错)。"}),N.map(K=>c.jsxs("div",{children:[c.jsx("span",{children:K.label})," ",c.jsxs("span",{className:"muted",children:["后 4 位 ",K.hint||"无"," · 创建 ",Kl(K.created_at)," · 最近使用 ",K.last_used_at?Kl(K.last_used_at):"未使用过"]})," ",K.revoked_at?c.jsxs("span",{className:"muted",children:["已撤销(",Kl(K.revoked_at),")"]}):c.jsx("button",{onClick:()=>void jt(K),children:"撤销"})]},K.id))]})]})]})}function Vg(){const i=Kt();return c.jsxs(Ft,{id:"adminCard",title:c.jsxs(c.Fragment,{children:["管理区 ",c.jsx("span",{className:"muted",children:"(仅全局管理员)"})]}),children:[c.jsx("div",{className:"muted",children:i.adminGate}),i.adminBody?c.jsxs(c.Fragment,{children:[c.jsxs(Ht,{children:[c.jsx("input",{type:"text",placeholder:"要查配额的用户 id",value:i.adminUserId,onChange:r=>i.setAdminUserId(r.target.value)}),c.jsx("button",{onClick:i.adminQuota,children:"查该用户配额"})]}),c.jsxs(Ht,{children:[c.jsx("button",{onClick:i.adminSweep,children:"手动触发 sweep(清理陈旧 running 任务)"}),c.jsx("button",{onClick:i.adminBackup,children:"手动触发备份"})]}),c.jsx("div",{className:"muted",children:i.adminResult})]}):null]})}function Qg(){const i=Kt(),[r,h]=d.useState(""),[f,p]=d.useState(""),[N,C]=d.useState("");return c.jsxs(Ft,{id:"nav-projects",title:"项目",children:[c.jsxs(Ht,{children:[c.jsxs("select",{"aria-label":"项目",value:i.projectId,onChange:j=>i.selectProject(j.target.value),children:[i.projects.length===0?c.jsx("option",{value:"",children:"(先加载项目)"}):null,i.projects.map(j=>c.jsxs("option",{value:j.id,children:[j.name," (",Ae(j.id),")"]},j.id))]}),c.jsx("button",{onClick:i.refreshProjects,children:"刷新"})]}),c.jsxs(Ht,{children:[c.jsx("input",{type:"text",placeholder:"新项目名",value:r,onChange:j=>h(j.target.value)}),c.jsxs("select",{"aria-label":"从 GitHub 仓库选",value:f,onChange:j=>{p(j.target.value),j.target.value&&C(j.target.value)},children:[c.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选,或手动输入)"}),i.githubRepos.map(j=>c.jsx("option",{value:j.clone_url,children:j.repo_full_name},j.clone_url))]}),c.jsx("input",{type:"text",placeholder:"repo_url(可选)",value:N,onChange:j=>C(j.target.value)}),c.jsx("button",{onClick:async()=>{await i.createProject(r,N),h(""),C(""),p("")},children:"新建项目"})]}),c.jsx("span",{className:"muted",children:i.projectMsg})]})}function Xg(){const i=Kt(),[r,h]=d.useState(""),[f,p]=d.useState("contributor"),N=i.projects.find(C=>C.id===i.projectId);return c.jsxs(Ft,{title:"项目成员",children:[N?c.jsxs("div",{className:"muted",children:[N.name," | ",N.id," | 仓库 ",N.repo_url||"未绑定"," | 我的角色 ",N.my_role||"未知"]}):c.jsx("div",{className:"muted",children:"先选项目"}),c.jsxs(Ht,{children:[c.jsx("input",{type:"text",placeholder:"成员 user_id 或用户名",value:r,onChange:C=>h(C.target.value)}),c.jsxs("select",{"aria-label":"邀请角色",value:f,onChange:C=>p(C.target.value),children:[c.jsx("option",{value:"viewer",children:"viewer(只读)"}),c.jsx("option",{value:"contributor",children:"contributor(可建任务)"}),c.jsx("option",{value:"maintainer",children:"maintainer(可批合并)"}),c.jsx("option",{value:"owner",children:"owner(可管项目)"})]}),c.jsx("button",{onClick:async()=>{await i.inviteMember(r,f),h("")},children:"邀请成员"}),c.jsx("button",{onClick:i.refreshMembers,children:"刷新成员"})]}),i.memberError?c.jsx("div",{className:"muted",children:i.memberError}):i.members.length===0?c.jsx("div",{className:"muted",children:"先选项目"}):c.jsx(cu,{children:i.members.map(C=>{const j=(C.username||"")+(C.username?"("+String(C.user_id).slice(0,8)+")":String(C.user_id).slice(0,8));return c.jsxs("li",{children:[c.jsx("span",{className:"row-main",children:j}),c.jsx("select",{"aria-label":"成员角色",value:C.role||"viewer",onChange:z=>void i.changeMemberRole(C.user_id,z.target.value),children:lg.map(z=>c.jsx("option",{value:z,children:z},z))})]},C.user_id)})}),c.jsx("span",{className:"muted",children:i.memberMsg})]})}function Lg(){const i=Kt(),[r,h]=d.useState(""),[f,p]=d.useState(""),[N,C]=d.useState(""),j=si(i.workers,i.workersSummary,i.workersError);return c.jsxs(Ft,{id:"nav-env",title:c.jsxs(c.Fragment,{children:["环境 ",c.jsx("span",{className:"muted",children:"(选、建、管:当前项目的基准检出)"})]}),children:[c.jsxs(Ht,{children:[c.jsx("button",{onClick:i.refreshEnvs,children:"刷新"}),c.jsx("span",{className:"muted",children:i.envCount})]}),i.envs.length===0?c.jsx("div",{className:"muted",children:"先选项目"}):c.jsx(cu,{children:i.envs.map(z=>c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[z.name,"(",Ae(z.id),") / 分支 ",z.branch||"默认分支"," / 执行器 ",z.executor||"不限"," / 状态 ",z.status,z.status==="failed"&&z.error_text?" / 失败原因:"+z.error_text:""]}),z.status==="archived"?null:c.jsx("button",{onClick:()=>void i.archiveEnv(z.id),children:"归档"})]},z.id))}),c.jsxs(Ht,{children:[c.jsx("input",{type:"text",placeholder:"新环境名",value:r,onChange:z=>h(z.target.value)}),c.jsx("input",{type:"text",placeholder:"分支(可选,空=默认分支)",value:f,onChange:z=>p(z.target.value)}),c.jsxs("select",{"aria-label":"新环境执行器",value:N,onChange:z=>C(z.target.value),children:[c.jsx("option",{value:"",children:"执行器:不限"}),c.jsx("option",{value:"local",children:"local"}),c.jsx("option",{value:"docker",children:"docker"}),c.jsx("option",{value:"cloud-hypervisor",children:"cloud-hypervisor(实验,默认关闭)"})]}),c.jsx("button",{onClick:async()=>{await i.createEnv(r,f,N),h(""),p("")},children:"新建环境"})]}),N==="cloud-hypervisor"?c.jsxs("div",{className:"muted","aria-live":"polite",children:["实验执行器,默认关闭:环境创建白名单同样允许 cloud-hypervisor(后端环境接口 allowlist:local / docker / cloud-hypervisor,不含 cloud);仍需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务排队。前端只传值,不绕过后端白名单。 ",jo(j)]}):null,c.jsx("span",{className:"muted",children:i.envMsg})]})}function Zg(){const i=Kt(),[r,h]=d.useState("");return c.jsxs(Ft,{title:"仓库与环境上下文",children:[c.jsx("div",{className:"muted",children:i.envRepoLine||"先选项目"}),c.jsx("div",{className:"muted",children:i.envCount}),i.envRepoBar?c.jsxs(Ht,{children:[c.jsxs("select",{"aria-label":"补仓库",value:r,onChange:f=>h(f.target.value),children:[c.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选)"}),i.githubRepos.map(f=>c.jsx("option",{value:f.clone_url,children:f.repo_full_name},f.clone_url))]}),c.jsx("button",{onClick:()=>void i.saveEnvRepo(r),children:"保存仓库"})]}):null,c.jsx("span",{className:"muted",children:i.envRepoMsg})]})}function Kg(){const i=Kt(),r=si(i.workers,i.workersSummary,i.workersError);return c.jsxs(Ft,{id:"nav-workers",title:"Workers",children:[c.jsx(Ht,{children:c.jsx("button",{onClick:i.refreshWorkers,children:"刷新"})}),c.jsx("div",{className:"muted","aria-live":"polite",children:jo(r)}),i.workersError?c.jsx("div",{className:"muted",children:i.workersError}):i.workers===null?c.jsx("div",{className:"muted",children:i.workersSummary||"未加载"}):i.workers.length===0?c.jsx("div",{className:"muted",children:"无 worker 注册"}):c.jsx(cu,{children:i.workers.map((h,f)=>{const p=h.running===void 0||h.running===null?"?":h.running,N=h.capacity===void 0||h.capacity===null?"?":h.capacity,C=bo(h),j=h.alive!==void 0&&h.alive!==null?!!h.alive:C.age!==null&&C.age<=120,z=j?" / 在线":" / 离线(占用为上次上报值)";return c.jsx("li",{className:j?"is-online":"is-offline",children:c.jsxs("span",{className:"row-main",children:[h.worker_id||h.id||"?"," / ",h.executor||""," / 占用 ",p,"/",N,z," / 最后心跳 ",C.text]})},f)})})]})}function Jg(){const i=Kt();return c.jsxs(Ft,{title:"健康与配额",children:[c.jsxs(Ht,{children:[c.jsx("span",{className:"muted",children:i.healthText}),c.jsx("button",{onClick:i.refreshHealth,children:"刷新"})]}),c.jsx("div",{children:c.jsx("span",{className:"muted",children:i.quotaText})}),c.jsx("div",{className:"muted",children:i.workersSummary})]})}function Fg(){const i=Kt();return c.jsxs(Ft,{id:"nav-skills",title:"技能与 MCP",children:[c.jsx("div",{className:"muted",children:"反映宿主租户 home,容器内 home 可能不同;重载只在 schema 枚举里,当前 pin 的二进制拒绝时原样 502;OAuth 只发起登录,不代表登录完成。"}),c.jsxs(Ht,{children:[c.jsx("button",{onClick:i.refreshSkills,children:"刷新"}),c.jsx("span",{className:"muted",children:i.skillMsg})]}),c.jsx("h3",{children:"技能"}),i.skillError?c.jsx("div",{className:"muted",children:i.skillError}):c.jsx(bl,{empty:"暂无技能",items:i.skills.map((r,h)=>c.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:"",r.detail?" - "+r.detail:""]},h))}),c.jsx("h3",{children:"应用"}),c.jsx(bl,{empty:"暂无应用",items:i.apps.map((r,h)=>c.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:""]},h))}),c.jsx("h3",{children:"MCP 服务"}),c.jsx(bl,{empty:"暂无 MCP 服务",items:i.mcpServers.map((r,h)=>{const f=r.detail||r.details||r.state||"";return c.jsxs("span",{className:"row-main",children:[r.name||r.id||""," | 状态 ",r.status||"",f?" | "+f:""]},h)})})]})}function Wg(){const i=Kt(),[r,h]=d.useState("");return c.jsxs(Ft,{title:"MCP 操作",children:[c.jsxs(Ht,{children:[c.jsx("button",{onClick:i.reloadMcp,children:"重载配置"}),c.jsx("span",{className:"muted",children:i.mcpMsg})]}),c.jsxs(Ht,{children:[c.jsx("input",{type:"text",placeholder:"MCP 服务名",value:r,onChange:f=>h(f.target.value)}),c.jsx("button",{onClick:()=>void i.mcpOauth(r),children:"发起 OAuth 登录"}),c.jsx("span",{className:"muted",children:i.mcpOauthMsg})]}),i.mcpOauthBox?c.jsx("pre",{children:i.mcpOauthBox}):null]})}const Wh=160;function $g(i){return i?i%86400===0?"(每天)":i%3600===0?"(每小时)":i%60===0?"(每 "+i/60+" 分钟)":"":""}function Ig(){const i=Kt(),[r,h]=d.useState(""),[f,p]=d.useState(""),[N,C]=d.useState(""),[j,z]=d.useState(""),[R,q]=d.useState(""),[g,k]=d.useState(""),[F,X]=d.useState(""),[J,Q]=d.useState(""),[vt,St]=d.useState(""),[jt,K]=d.useState({}),Nt=w=>{q(w.id),k(w.name||""),X(w.prompt||""),Q(w.interval_seconds?String(w.interval_seconds):""),St(w.repo_url||"")};return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"自动化名称(同项目内启用中的名称唯一)",style:{width:"100%"},value:r,onChange:w=>h(w.target.value)}),c.jsx("textarea",{placeholder:"定时任务内容(创建只登记定义与到期状态;执行由后续执行器经 claim 抢单后建任务,不另起调度器)",value:f,onChange:w=>p(w.target.value)}),c.jsxs(Ht,{children:[c.jsx("input",{placeholder:"间隔秒数(默认 86400,最小 60)",style:{width:"40%"},value:N,onChange:w=>C(w.target.value)}),c.jsx("input",{placeholder:"仓库(可选)",style:{width:"40%"},value:j,onChange:w=>z(w.target.value)}),c.jsx("button",{className:"primary",onClick:async()=>{await i.createAutomation(r,f,N,j),h(""),p(""),C(""),z("")},children:"创建自动化"}),c.jsx("button",{onClick:i.refreshAutomations,children:"刷新自动化"}),c.jsx("span",{className:"muted",children:i.autoMsg})]}),c.jsx(bl,{empty:"(暂无自动化)",items:i.automations.map(w=>{const V=w.prompt||"",at=!!jt[w.id],Dt=V.length>Wh,qt=V?at||!Dt?V:V.slice(0,Wh)+"…":"(无内容)";return c.jsxs("div",{children:[c.jsxs("div",{className:"row-main",children:["[",w.status,w.due?"|到期":"",w.run_once?"|一次性":"","] ",w.name,"|每 ",w.interval_seconds," 秒",$g(w.interval_seconds),"|上次 ",Kl(w.last_run_at)||"未运行","|下次 ",Kl(w.next_run_at),w.status==="active"?c.jsxs(c.Fragment,{children:[" ",c.jsx("button",{onClick:()=>Nt(w),children:"编辑"})," ",c.jsx("button",{onClick:()=>i.cancelAutomation(w.id),children:"取消"})]}):null]}),w.repo_url?c.jsxs("div",{className:"muted",children:["仓库:",w.repo_url]}):null,w.status==="cancelled"&&w.cancelled_at?c.jsxs("div",{className:"muted",children:["已取消于 ",Kl(w.cancelled_at)]}):null,c.jsxs("div",{className:"muted",style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:[qt,Dt?c.jsx("button",{onClick:()=>K(dt=>({...dt,[w.id]:!dt[w.id]})),children:at?"收起":"展开"}):null]}),R===w.id?c.jsxs("div",{children:[c.jsx("input",{placeholder:"自动化名称",style:{width:"100%"},value:g,onChange:dt=>k(dt.target.value)}),c.jsx("textarea",{placeholder:"定时任务内容",value:F,onChange:dt=>X(dt.target.value)}),c.jsxs(Ht,{children:[c.jsx("input",{placeholder:"间隔秒数(最小 60,留空保持原值)",style:{width:"40%"},value:J,onChange:dt=>Q(dt.target.value)}),c.jsx("input",{placeholder:"仓库(可选,清空则清除)",style:{width:"40%"},value:vt,onChange:dt=>St(dt.target.value)}),c.jsx("button",{className:"primary",onClick:async()=>{const dt=i.automations.find(H=>H.id===R)||w;await i.updateAutomation(dt,g,F,J,vt)&&q("")},children:"保存"}),c.jsx("button",{onClick:()=>q(""),children:"取消"})]})]}):null]},w.id)})})]})}function Pg(){var h,f,p,N;const i=Kt(),r=i.triage;return i.triageError?c.jsx("div",{className:"muted",children:i.triageError}):r?c.jsxs(c.Fragment,{children:[c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["到期自动化(",((h=r.counts)==null?void 0:h.automations_due)||0,")"]}),c.jsx(bl,{empty:"(无)",items:(r.automations_due||[]).map((C,j)=>c.jsxs("span",{className:"row-main",children:[C.name,"|每 ",C.interval_seconds," 秒"]},j))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["失败/中断任务(",((f=r.counts)==null?void 0:f.failed_tasks)||0,")"]}),c.jsx(bl,{empty:"(无)",items:(r.failed_tasks||[]).map((C,j)=>c.jsxs("span",{className:"row-main",children:[Ae(C.id),"|",C.status,"|",String(C.prompt||"").slice(0,80)]},j))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["待处理后续消息(",((p=r.counts)==null?void 0:p.pending_followups)||0,")"]}),c.jsx(bl,{empty:"(无)",items:(r.pending_followups||[]).map((C,j)=>c.jsxs("span",{className:"row-main",children:["任务 ",Ae(C.task_id),"|",String(C.body||"").slice(0,80)]},j))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["最近自动化产出(",((N=r.counts)==null?void 0:N.recent_runs)||0,")"]}),c.jsx(bl,{empty:"(无)",items:(r.recent_runs||[]).map((C,j)=>c.jsxs("span",{className:"row-main",children:[Ae(C.automation_id),"|",C.status,C.task_id?"|任务 "+Ae(C.task_id):"",C.task_status?"|"+C.task_status:""]},j))})]})]}):c.jsx("div",{className:"muted",children:"(先选项目)"})}function tp(){return c.jsx(Ft,{id:"nav-automations",title:"自动化",children:c.jsx(Ig,{})})}function ep(){const i=Kt();return c.jsxs(Ft,{id:"nav-triage",title:"分诊收件箱",children:[c.jsx(Ht,{children:c.jsx("button",{onClick:i.refreshTriage,children:"刷新分诊"})}),c.jsx(Pg,{})]})}const go=["pending","in_progress","blocked","completed","cancelled"],Uc={pending:"待办",in_progress:"进行中",blocked:"受阻",completed:"已完成",cancelled:"已取消"},lp=["low","medium","high"],$h={low:"低",medium:"中",high:"高"};function ap(i){return i?new Date(i*1e3).toLocaleString():""}function np(){const i=Kt(),[r,h]=d.useState([]),[f,p]=d.useState({}),[N,C]=d.useState(0),[j,z]=d.useState(""),[R,q]=d.useState(""),[g,k]=d.useState(""),[F,X]=d.useState(""),[J,Q]=d.useState(""),[vt,St]=d.useState(""),[jt,K]=d.useState("medium"),[Nt,w]=d.useState(""),[V,at]=d.useState(""),Dt=d.useCallback(async()=>{if(!i.projectId){h([]),p({}),C(0);return}try{let H=W.projectTodos(i.projectId);const ot=new URLSearchParams;g&&ot.set("status",g),F.trim()&&ot.set("q",F.trim());const yt=ot.toString();yt&&(H+="?"+yt);const G=await lt(H);h(G.todos||[]),p(G.counts||{}),C(G.total||0),q("")}catch(H){q("待办加载失败:"+((H==null?void 0:H.message)||String(H)))}},[i.projectId,g,F]);d.useEffect(()=>{Dt()},[Dt]);const qt=async()=>{if(!i.projectId){z("先选项目");return}if(!J.trim()){z("标题必填");return}z("");try{const H={title:J.trim(),description:vt,priority:jt};Nt.trim()&&(H.task_id=Nt.trim()),V&&(H.due_at=Math.floor(new Date(V).getTime()/1e3)),await lt(W.projectTodos(i.projectId),"POST",H),Q(""),St(""),w(""),at(""),z("已创建"),await Dt()}catch(H){z("创建失败:"+((H==null?void 0:H.message)||String(H)))}},dt=async(H,ot)=>{if(i.projectId)try{await lt(W.projectTodo(i.projectId,H.id),"PATCH",ot),await Dt()}catch(yt){q("待办更新失败:"+((yt==null?void 0:yt.message)||String(yt)))}},Ut=async H=>{if(i.projectId&&window.confirm("删除待办“"+H.title+"”?删除后不可恢复(取消请改状态为已取消)。"))try{await lt(W.projectTodo(i.projectId,H.id),"DELETE"),await Dt()}catch(ot){q("待办删除失败:"+((ot==null?void 0:ot.message)||String(ot)))}};return c.jsxs(Ft,{id:"nav-todos",title:"项目待办",children:[c.jsx("div",{className:"muted",children:i.projectId?"共 "+N+" 项 | "+go.map(H=>Uc[H]+" "+(f[H]||0)).join(" | "):"先选项目"}),R?c.jsx("div",{className:"err",children:R}):null,c.jsxs(Ht,{children:[c.jsxs("select",{"aria-label":"状态筛选",value:g,onChange:H=>k(H.target.value),children:[c.jsx("option",{value:"",children:"状态:全部"}),go.map(H=>c.jsx("option",{value:H,children:Uc[H]},H))]}),c.jsx("input",{type:"text",placeholder:"搜索标题/说明",value:F,onChange:H=>X(H.target.value)}),c.jsx("button",{onClick:()=>void Dt(),children:"刷新"})]}),c.jsx("input",{type:"text",placeholder:"待办标题(必填)",style:{width:"100%"},value:J,onChange:H=>Q(H.target.value)}),c.jsx("textarea",{placeholder:"说明(可选)",value:vt,onChange:H=>St(H.target.value)}),c.jsxs(Ht,{children:[c.jsx("select",{"aria-label":"优先级",value:jt,onChange:H=>K(H.target.value),children:lp.map(H=>c.jsxs("option",{value:H,children:["优先级:",$h[H]]},H))}),c.jsx("input",{type:"text",placeholder:"关联任务 id(可选)",value:Nt,onChange:H=>w(H.target.value)}),c.jsx("input",{type:"datetime-local","aria-label":"截止时间",value:V,onChange:H=>at(H.target.value)}),c.jsx("button",{className:"primary",onClick:()=>void qt(),children:"新建待办"}),c.jsx("span",{className:"muted",children:j})]}),c.jsx(bl,{empty:i.projectId?"(暂无待办)":"(先选项目)",items:r.map(H=>c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:H.title})," | ",c.jsx("span",{className:"st-"+H.status,children:Uc[H.status]||H.status})," | 优先级 "+($h[H.priority]||H.priority),H.task_id?" | 任务 "+Ae(H.task_id):"",H.due_at?" | 截止 "+ap(H.due_at):"",H.completed_at?" | 完成于 "+new Date(H.completed_at*1e3).toLocaleString():"",H.description?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsx("span",{className:"muted",children:H.description})]}):null,c.jsx("br",{}),c.jsx("select",{"aria-label":"改状态",value:H.status,onChange:ot=>void dt(H,{status:ot.target.value}),children:go.map(ot=>c.jsx("option",{value:ot,children:Uc[ot]},ot))})," ",c.jsx("button",{onClick:()=>void dt(H,{status:"completed"}),children:"完成"})," ",c.jsx("button",{onClick:()=>void Ut(H),children:"删除"})]},H.id))}),c.jsx("div",{className:"muted",children:"状态与 Muse 待办同款五态;完成时服务端记完成时间,改回其它状态时清掉;已取消是软关闭,删除才是硬删除。"})]})}const To=[["tasks","任务工作台","任务"],["todos","项目待办","任务"],["projects","项目与成员","管理"],["resources","环境与 Workers","管理"],["automation","技能与自动化","管理"],["admin","系统与 Key","管理"]],up=Object.fromEntries(To.map(([i,r])=>[i,r]));function Ih(){const i=window.location.hash.replace("#view-","");return To.some(([r])=>r===i)?i:"tasks"}function ip({view:i,setView:r}){return c.jsxs("aside",{className:"shell-side",children:[c.jsx("div",{className:"shell-brand",children:"Codex 命令台"}),c.jsx("nav",{className:"shell-nav","aria-label":"导航",children:["任务","管理"].map(h=>c.jsxs("div",{className:"shell-nav-group",children:[c.jsx("div",{className:"shell-nav-group-label",children:h}),To.filter(([,,f])=>f===h).map(([f,p])=>c.jsx("button",{type:"button",className:i===f?"active":"",onClick:()=>r(f),children:p},f))]},h))}),c.jsxs("div",{className:"shell-stats",children:[c.jsx("div",{className:"shell-stats-label",children:"概览"}),c.jsx(vg,{})]})]})}function cp(){const i=Kt();return c.jsx("span",{className:i.keySaved?"dash-status ok":"dash-status off",children:i.keySaved?"● 已连接":"○ 未连接"})}function sp(){const i=hg();fg(j=>i.refreshAll(j),1e4),dg(()=>i.refreshSlow(),6e4);const[r,h]=d.useState(()=>Ih()),[f,p]=d.useState(!0),N=j=>{h(j);try{window.history.replaceState(null,"","#view-"+j)}catch{}};d.useEffect(()=>{const j=()=>h(Ih());return window.addEventListener("hashchange",j),()=>window.removeEventListener("hashchange",j)},[]);const C=()=>{N("tasks"),p(!0),setTimeout(()=>{var j;return(j=document.getElementById("sec-dispatch"))==null?void 0:j.scrollIntoView({behavior:"smooth"})},0)};return c.jsx(tv.Provider,{value:i,children:c.jsxs("div",{className:"shell",children:[c.jsx(ip,{view:r,setView:N}),c.jsxs("div",{className:"shell-main",children:[c.jsxs("header",{className:"shell-header",children:[c.jsx("h1",{children:up[r]}),c.jsx(cp,{}),c.jsx("span",{className:"muted shell-hide-sm",children:i.healthText}),c.jsx("span",{className:"muted shell-hide-sm",children:i.quotaText}),c.jsxs("span",{className:"muted shell-stamp",children:["自动刷新 · 每 10 秒 · 已刷新 ",i.refreshStamp]})]}),r==="tasks"?c.jsxs("div",{className:"workspace",children:[c.jsxs("section",{className:"workspace-rail","aria-label":"任务列表",children:[c.jsx("h2",{children:"任务列表"}),c.jsx(jg,{onNew:C})]}),c.jsxs("section",{className:"workspace-center",children:[c.jsx("div",{className:"composer-toggle",children:c.jsx("button",{type:"button",onClick:()=>p(!f),children:f?"收起新建任务":"新建任务"})}),f?c.jsx("section",{id:"sec-dispatch",children:c.jsx(xg,{})}):null,c.jsxs("section",{className:"card workspace-conv",children:[c.jsx("h2",{children:"对话"}),c.jsx(Ag,{})]})]}),c.jsx("section",{className:"workspace-inspector","aria-label":"任务详情",children:c.jsx(Yg,{})})]}):c.jsx("main",{className:"page",children:r==="todos"?c.jsx(c.Fragment,{children:c.jsx(np,{})}):r==="projects"?c.jsxs(c.Fragment,{children:[c.jsx(Qg,{}),c.jsx(Xg,{})]}):r==="resources"?c.jsxs(c.Fragment,{children:[c.jsx(Lg,{}),c.jsx(Zg,{}),c.jsx(Kg,{}),c.jsx(Jg,{})]}):r==="automation"?c.jsxs(c.Fragment,{children:[c.jsx(Fg,{}),c.jsx(Wg,{}),c.jsx(tp,{}),c.jsx(ep,{})]}):c.jsxs(c.Fragment,{children:[c.jsx(Vg,{}),c.jsx(Gg,{})]})})]})]})})}Py.createRoot(document.getElementById("root")).render(c.jsx(Ly.StrictMode,{children:c.jsx(sp,{})})); diff --git a/cloud/tests/test_chv_arch95.py b/cloud/tests/test_chv_arch95.py index f33d078..706b543 100644 --- a/cloud/tests/test_chv_arch95.py +++ b/cloud/tests/test_chv_arch95.py @@ -225,6 +225,9 @@ class DockerSyncGuardTest(unittest.TestCase): try: ex = DockerExecutor(runner=lambda cmd, timeout=None: FakeResult(returncode, stdout, stderr)) + # to10:goal 解析是纯函数测试,绝不依赖本机 docker + # 二进制(无 docker 的机器上 _check_docker 会直接炸)。 + ex._check_docker = lambda: None ex._call_runner = lambda cmd, timeout=None, env=None: \ FakeResult(returncode, stdout, stderr) return ex.goal_task({"id": "t1", "goal": "do it", diff --git a/cloud/tests/test_chv_to10.py b/cloud/tests/test_chv_to10.py new file mode 100644 index 0000000..2c44806 --- /dev/null +++ b/cloud/tests/test_chv_to10.py @@ -0,0 +1,388 @@ +"""to10 轨新增测试:覆盖率缺口分支(非刷行)。 + +只测此前 coverage 报告里未覆盖的分支:api_client 的假 +socket 错误路径、hostkeys 的 keygen/seed 失败路径、 +orphans 的 registry 失败路径、keyscan 过滤、compat 的 +fail-closed 分支、config 的类型转换与 assemble 冲突。 +不删旧测试、不改分母;每项都是行为断言,不是 grep。 +""" + +import os +import socket +import tempfile +import unittest + +from cloud.chv import api_client +from cloud.chv import compat215 +from cloud.chv import config as chv_config +from cloud.chv import hostkeys +from cloud.chv import orphans +from cloud.chv import ssh_transport + + +class _FakeSocket: + """按 recv 调用序列供数据的假 socket。""" + + def __init__(self, chunks, timeout_at=None): + self._chunks = list(chunks) + self._timeout_at = timeout_at + self._calls = 0 + self.closed = False + + def recv(self, _size): + self._calls += 1 + if self._timeout_at is not None \ + and self._calls >= self._timeout_at: + raise socket.timeout("fake timeout") + if not self._chunks: + return b"" + return self._chunks.pop(0) + + def close(self): + self.closed = True + + +class ApiClientTest(unittest.TestCase): + def test_header_timeout_raises(self): + with self.assertRaises(RuntimeError): + api_client._read_header(_FakeSocket([], timeout_at=1)) + + def test_header_early_close_raises(self): + with self.assertRaises(RuntimeError): + api_client._read_header(_FakeSocket([])) + + def test_chunked_rejected(self): + with self.assertRaises(RuntimeError): + api_client._parse_content_length( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked") + + def test_no_length_rejected(self): + with self.assertRaises(RuntimeError): + api_client._parse_content_length("HTTP/1.1 200 OK") + + def test_illegal_length_rejected(self): + with self.assertRaises(RuntimeError): + api_client._parse_content_length( + "HTTP/1.1 200 OK\r\nContent-Length: xx") + + def test_body_timeout_truncation(self): + with self.assertRaises(RuntimeError): + api_client._read_body(_FakeSocket([], timeout_at=1), + b"ab", 10) + + def test_body_early_close_truncation(self): + with self.assertRaises(RuntimeError): + api_client._read_body(_FakeSocket([]), b"ab", 10) + + def test_parse_non_http_status_zero(self): + status, _body = api_client._parse_response("garbage", b"{}") + self.assertEqual(status, 0) + + def test_parse_non_json_body_is_text(self): + _status, body = api_client._parse_response( + "HTTP/1.1 200 OK", b"not-json") + self.assertEqual(body, "not-json") + + def test_parse_empty_body_is_empty_dict(self): + _status, body = api_client._parse_response( + "HTTP/1.1 200 OK", b"") + self.assertEqual(body, {}) + + def test_unix_api_missing_socket_raises(self): + with self.assertRaises(RuntimeError): + api_client._unix_socket_api("/tmp/no-such-chv.sock", + "GET", "/vm.info") + + +class HostkeysTest(unittest.TestCase): + def test_keygen_reuse_existing_pair(self): + with tempfile.TemporaryDirectory() as tmp: + paths = hostkeys.host_key_paths(tmp, "vm1") + os.makedirs(os.path.dirname(paths["private"]), + exist_ok=True) + for key in ("private", "public"): + with open(paths[key], "w") as fh: + fh.write("x") + out = hostkeys.generate_host_keypair( + tmp, "vm1", runner=lambda cmd: (_ for _ in ()).throw( + AssertionError("reuse 不应调 keygen"))) + self.assertEqual(out, paths) + + def test_keygen_nonzero_rc_raises(self): + class _Res: + returncode = 1 + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(RuntimeError): + hostkeys.generate_host_keypair( + tmp, "vm2", runner=lambda cmd: _Res()) + + def test_keygen_no_files_after_runner_raises(self): + class _Res: + returncode = 0 + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(RuntimeError): + hostkeys.generate_host_keypair( + tmp, "vm3", runner=lambda cmd: _Res()) + + def test_seed_disk_nonzero_rc_raises(self): + class _Res: + returncode = 1 + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(RuntimeError): + hostkeys.build_seed_disk( + tmp, os.path.join(tmp, "seed.iso"), + runner=lambda cmd: _Res()) + + def test_seed_disk_missing_output_raises(self): + class _Res: + returncode = 0 + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(RuntimeError): + hostkeys.build_seed_disk( + tmp, os.path.join(tmp, "seed.iso"), + runner=lambda cmd: _Res()) + + +class OrphansAndKeyscanTest(unittest.TestCase): + def test_quarantine_failure_only_warns(self): + with self.assertLogs("cloud.cloud_hypervisor", + level="WARNING") as logs: + orphans._quarantine_registry("/tmp/no-such-dir-x/f.json") + self.assertTrue(any("quarantine_failed" in line + for line in logs.output)) + + def test_write_registry_failure_only_warns(self): + with tempfile.TemporaryDirectory() as tmp: + blocker = os.path.join(tmp, "blocker") + with open(blocker, "w") as fh: + fh.write("x") + with self.assertLogs("cloud.cloud_hypervisor", + level="WARNING") as logs: + orphans._write_registry( + os.path.join(blocker, "registry.json"), {}) + self.assertTrue(any("save_failed" in line + for line in logs.output)) + + def test_keyscan_filters_short_and_non_ssh_lines(self): + text = "\n".join([ + "# comment", + "", + "only-two", + "127.0.0.1 ecdsa-sha2-nistp256 AAAA", + "[127.0.0.1]:2222 ssh-ed25519 AAAA-good", + "other-host ssh-ed25519 AAAA-other", + ]) + kept = ssh_transport.parse_keyscan_output(text) + self.assertEqual(kept, ["[127.0.0.1]:2222 ssh-ed25519 AAAA-good"]) + + +class CompatAndConfigTest(unittest.TestCase): + def test_tmpfs_unknown_executor_fail_closed(self): + isolated, _reason = compat215.tmpfs_isolated("unknown-kind", + "/tmp") + self.assertFalse(isolated) + + def test_resolve_inside_empty_rel_is_base(self): + self.assertEqual(compat215.resolve_inside("/a/b", ""), "/a/b") + + def test_resolve_inside_absolute_is_none(self): + self.assertIsNone(compat215.resolve_inside("/a/b", "/etc/passwd")) + + def test_to_int_bool_and_float_and_bad(self): + with self.assertRaises(ValueError): + chv_config._to_int(True) + self.assertEqual(chv_config._to_int(3.9), 3) + self.assertEqual(chv_config._to_int(" 7 "), 7) + with self.assertRaises(ValueError): + chv_config._to_int(object()) + + def test_to_float_bool_and_bad(self): + with self.assertRaises(ValueError): + chv_config._to_float(False) + self.assertEqual(chv_config._to_float("2.5"), 2.5) + with self.assertRaises(ValueError): + chv_config._to_float(object()) + + def test_assemble_conflict_raises(self): + with self.assertRaises(ValueError): + chv_config._assemble_chv_config( + [{"cpus": "2"}, {"cpus": "4"}]) + + +if __name__ == "__main__": + unittest.main() + + +class WorkerAdapterTest(unittest.TestCase): + """to10:worker_adapter 的直接行为测试(worker 薄包装 + 由旧测试 test_chv_fix/test_chv_gaps 覆盖,此处测本体)。""" + + def test_call_breakpoint_prefers_signature_kwargs(self): + from cloud.chv import worker_adapter + seen = {} + + class _Ex: + def goal_resume_breakpoint_task(self, task, + model_provider=None, + extra_env=None): + seen.update(task=task, model_provider=model_provider, + extra_env=extra_env) + return "ok" + out = worker_adapter.call_goal_resume_breakpoint( + _Ex(), {"id": "t"}, model_provider="m", + extra_env={"A": "1"}) + self.assertEqual(out, "ok") + self.assertEqual(seen["model_provider"], "m") + self.assertEqual(seen["extra_env"], {"A": "1"}) + + def test_call_breakpoint_single_arg_executor(self): + from cloud.chv import worker_adapter + + class _Ex: + def goal_resume_breakpoint_task(self, task): + return "single" + self.assertEqual( + worker_adapter.call_goal_resume_breakpoint(_Ex(), {"id": "t"}), + "single") + + def test_audit_callback_extracts_project_and_swallows(self): + from cloud.chv import worker_adapter + calls = [] + cb = worker_adapter.make_chv_audit_callback(calls.append) + # append 只接受 1 参,回调内部 except 应吞掉,不炸 + cb("booted", {"project_id": "p1"}, "detail") + self.assertEqual(calls, []) + got = [] + cb2 = worker_adapter.make_chv_audit_callback( + lambda pid, ev, detail: got.append((pid, ev, detail))) + cb2("booted", {"project_id": "p1"}, "detail") + cb2("booted", "not-a-dict", "detail") + self.assertEqual(got[0], ("p1", "chv_vm_booted", "detail")) + self.assertEqual(got[1], (None, "chv_vm_booted", "detail")) + + def test_reconcile_non_chv_returns_empty(self): + from cloud.chv import worker_adapter + self.assertEqual( + worker_adapter.reconcile_warm_pool(object(), lambda *a: None, + "startup"), + []) + + def test_watchdog_vm_name_error_returns_none(self): + from cloud.chv import worker_adapter + + class _Bad: + def vm_name(self, task): + raise RuntimeError("boom") + self.assertIsNone(worker_adapter.watchdog_vm_name(_Bad(), {})) + + def test_cancel_watchdog_loop_kills_on_flag(self): + import threading + from cloud.chv import worker_adapter + killed = [] + stop = threading.Event() + worker_adapter.cancel_watchdog_loop( + "t1", "chv-t1", stop, {"t1"}, + lambda tid: None, + lambda vm: killed.append(vm), + None) + # stop 未置位时循环每 1 秒轮询:flag 已命中,第一轮即 + # 关 VM 并返回;这里不依赖日志(logger 传 None 时 + # log_event 内部不炸——若炸则本项失败)。 + self.assertEqual(killed, ["chv-t1"]) + + def test_quota_watchdog_loop_marks_and_kills(self): + import threading + from cloud.chv import worker_adapter + killed = [] + marked = [] + stop = threading.Event() + worker_adapter.quota_watchdog_loop( + "t2", "chv-t2", stop, lambda: True, + marked.append, + lambda vm: killed.append(vm), + None, interval=0.01) + self.assertEqual(marked, ["t2"]) + self.assertEqual(killed, ["chv-t2"]) + + +class AuditSpillTest(unittest.TestCase): + """to10:_emit 回调失败时落盘(ADR-14),不中断、不抛。""" + + def test_spill_on_callback_failure(self): + import json as _json + from cloud.chv.executor import CloudHypervisorExecutor + with tempfile.TemporaryDirectory() as tmp: + ex = CloudHypervisorExecutor( + binary="/bin/true", kernel="/tmp/k", rootfs="/tmp/r", + state_dir=tmp, ssh_key="/tmp/key") + calls = [] + + def _bad(event, task, detail, kind=None): + raise RuntimeError("db down") + ex.audit_callback = _bad + ex._emit("booted", {"id": "t-spill"}, "vm=chv-t-spill") + self.assertEqual(ex.audit_failure_count, 1) + spill = os.path.join(tmp, "chv-audit-spill.jsonl") + self.assertTrue(os.path.exists(spill)) + row = _json.loads(open(spill).readline()) + self.assertEqual(row["event"], "booted") + self.assertEqual(row["task_id"], "t-spill") + self.assertEqual(row["detail"], "vm=chv-t-spill") + + def test_spill_rotation_keeps_old(self): + from cloud.chv.executor import CloudHypervisorExecutor + with tempfile.TemporaryDirectory() as tmp: + ex = CloudHypervisorExecutor( + binary="/bin/true", kernel="/tmp/k", rootfs="/tmp/r", + state_dir=tmp, ssh_key="/tmp/key") + spill = os.path.join(tmp, "chv-audit-spill.jsonl") + with open(spill, "w") as fh: + fh.write("x" * (1024 * 1024 + 10)) + ex._spill_audit("booted", {"id": "t2"}, "d", None) + self.assertTrue(os.path.exists(spill + ".old")) + self.assertTrue(os.path.getsize(spill) < 1024) + + +class LockfileScriptTest(unittest.TestCase): + """to10:generate_lockfile.py 的 generate/verify(合成 + BUILDINFO,不碰真实客机清单,不编版本号)。""" + + def _load_script(self): + import importlib.util + path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), + "..", "chv-guest", "generate_lockfile.py") + path = os.path.normpath(path) + spec = importlib.util.spec_from_file_location( + "chv_generate_lockfile", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + def test_generate_reads_real_dpkg_version(self): + import json as _json + mod = self._load_script() + with tempfile.TemporaryDirectory() as tmp: + bi = os.path.join(tmp, "bi.json") + out = os.path.join(tmp, "lock.json") + with open(bi, "w") as fh: + _json.dump({"tag": "t", "contents": ["coreutils"]}, + fh) + self.assertEqual(mod.generate(bi, out), 0) + lock = _json.load(open(out)) + self.assertEqual(lock["tag"], "t") + self.assertEqual(lock["unresolved"], []) + self.assertTrue(lock["versions"]["dpkg"][0].startswith( + "coreutils ")) + + def test_verify_fails_on_unresolved(self): + import json as _json + mod = self._load_script() + with tempfile.TemporaryDirectory() as tmp: + lock = os.path.join(tmp, "lock.json") + with open(lock, "w") as fh: + _json.dump({"tag": "t", + "versions": {"dpkg": ["a 1.0"], + "pip": ["b 2.0"]}, + "unresolved": ["zzz"]}, fh) + self.assertEqual(mod.verify(lock, None), 1) diff --git a/cloud/worker.py b/cloud/worker.py index 77a99bc..d3a41b6 100644 --- a/cloud/worker.py +++ b/cloud/worker.py @@ -120,7 +120,6 @@ Docker 下 repos_dir 必须放在 extra-mounts 已挂载的目录下, import argparse import hashlib -import inspect import json import os from datetime import datetime, timezone @@ -198,6 +197,9 @@ from cloud.queue import budget_exceeded, ready_tasks from cloud.repos import RepoError, checkout_dir, resolve_repo from cloud.sandbox import CloudExecutor, CloudHypervisorExecutor, \ DockerExecutor, LocalExecutor +# to10:CHV 强相关接线抽到 cloud.chv.worker_adapter,见 ADR-13; +# worker 只留薄包装(方法名/签名/调用点不变)。 +from cloud.chv import worker_adapter as _chv_worker_adapter from cloud.uploads import upload_root as _upload_root from cloud.sandbox import _default_runner as _sandbox_default_runner from cloud.caps import effective_capacity, resolve_capacity @@ -335,27 +337,13 @@ def _merge_env(base, override): def _call_goal_resume_breakpoint(executor, task, model_provider=None, extra_env=None): - """通用断点调用:执行器提供 goal_resume_breakpoint_task 时调用它。 - - Local 仍走原路径(调用方直接传 model_provider,不经过这里, - 以免改生产语义);这里只用于非 Local 执行器:签名接受 - model_provider 时传它(CHV/Docker 均接受),否则只传 task - (旧假执行器单参)。feat-chv-secfix(H2):CHV 的 - gateway_env 不再经 SSH env 前缀注入(密钥值进 argv 的 - 旧路径已废除),改走 provisioned seed 盘客内文件投递; - CHV 没有 Local 那样的进程 env 通道,seed 文件是唯一通道, - keyscan 模式下密钥投递 fail-closed。""" - method = getattr(executor, "goal_resume_breakpoint_task") - try: - params = inspect.signature(method).parameters - except (TypeError, ValueError): - return method(task) - kwargs = {} - if "model_provider" in params: - kwargs["model_provider"] = model_provider - if "extra_env" in params and extra_env is not None: - kwargs["extra_env"] = extra_env - return method(task, **kwargs) + """通用断点调用(to10:本体已移到 + cloud.chv.worker_adapter.call_goal_resume_breakpoint, + 此处只留薄包装——旧测试从 cloud.worker import 它,名 + 字与签名不变,行为逐字不变)。""" + return _chv_worker_adapter.call_goal_resume_breakpoint( + executor, task, model_provider=model_provider, + extra_env=extra_env) def _resolve_model(model=None, config_path=None): @@ -539,16 +527,12 @@ class Worker: # 读审计表即可,不另起一套 VM 事件表;成本/配额仍走 # worker 现有 quota 语义,本回调绝不记账。 if isinstance(self.executor, CloudHypervisorExecutor): - def _chv_audit(event, task, detail, kind=None): - try: - project_id = task.get("project_id") \ - if isinstance(task, dict) else None - self._audit(project_id, "chv_vm_" + event, - detail or "") - except Exception: - pass + # to10:回调本体已移到 worker_adapter.make_chv_audit_ + # callback(原 __init__ 闭包逐字搬运),此处只挂接。 try: - self.executor.audit_callback = _chv_audit + self.executor.audit_callback = \ + _chv_worker_adapter.make_chv_audit_callback( + self._audit) except Exception: pass # CHV 残留清理:仅孤儿清理接线(feat-chv-arch95 定案, @@ -797,36 +781,11 @@ class Worker: return self._cost_store def _reconcile_warm_pool(self, when): - """显式调生产残留清理 facade,返回已清理 VM 名。 - - 仅 CHV worker 有效(其它执行器返回 [])。实现是 - cloud.chv.orphans.reconcile_orphans_for_executor(): - 直接读 registry 文件做清理,不构造 WarmPool、不碰 - 实验接口(方法内 import,失败时按下面分支审计后 - 返回 [])。调用失败不炸主流程,但进审计(失败本身 - 也审计,不静默丢弃)。orphan 条目绝不发给任务, - reconcile 只做清理。""" - executor = getattr(self, "executor", None) - if not isinstance(executor, CloudHypervisorExecutor): - return [] - try: - from cloud.chv.orphans import \ - reconcile_orphans_for_executor as _reconcile - cleaned = _reconcile(executor) or [] - except Exception as exc: - try: - self._audit(None, "chv_warm_pool_reconcile_failed", - "when=%s error=%s" % (when, exc)) - except Exception: - pass - return [] - if cleaned: - try: - self._audit(None, "chv_warm_pool_reconcile", - "when=%s cleaned=%s" % (when, ",".join(cleaned))) - except Exception: - pass - return cleaned + """显式调生产残留清理 facade,返回已清理 VM 名 + (to10:本体已移到 worker_adapter.reconcile_warm_ + pool,此处只留薄包装,旧测试调用点不变)。""" + return _chv_worker_adapter.reconcile_warm_pool( + getattr(self, "executor", None), self._audit, when) def close(self): """关闭惰性建的成本存储连接(注入的 cost_store 由调用方管)。 @@ -2479,61 +2438,22 @@ class Worker: return def _chv_cancel_watchdog(self, task_id, vm_name, stop_event): - """CHV 同步路径的取消看门狗(feat-chv-fix,P1-2,Docker - 取消看门狗同款模式):同步 SSH 阻塞时 worker 的 cancel() - 只能改库、清 _running 项,阻塞中的 run 本身不会醒; - 本线程每 1 秒查库/取消标志,命中时调 cancel_vm 真关 - VM,然后退出。查库失败时当没取消,不误杀。""" - while not stop_event.wait(1.0): - try: - if task_id in self._cancel_requested: - cancelled = True - else: - db_task = self.get_task(task_id) - cancelled = (db_task is not None and - db_task.get("status") == "interrupted") - except Exception: - cancelled = False - if not cancelled: - continue - try: - cancel = getattr(self.executor, "cancel_vm", None) - if cancel is not None and vm_name: - cancel(vm_name) - log_event(self.logger, "worker_chv_killed", - task_id=task_id, - message="cancel watchdog killed VM %s " - "(task interrupted/cancelled)" - % vm_name) - except Exception: - pass - return + """CHV 同步路径的取消看门狗(to10:循环本体已移到 + worker_adapter.cancel_watchdog_loop,此处只传回调)。""" + _chv_worker_adapter.cancel_watchdog_loop( + task_id, vm_name, stop_event, self._cancel_requested, + self.get_task, getattr(self.executor, "cancel_vm", None), + self.logger) def _chv_quota_watchdog(self, task_id, vm_name, stop_event, quota_check, interval=30.0): - """CHV 同步路径的配额看门狗(feat-chv-fix,P1-2,Docker - 配额看门狗同款):命中时记 _quota_hit_tasks 并调 - cancel_vm 真关 VM;_run_attempt 在同步返回后把结果 - 改写成 quota_exceeded,走统一配额中断收尾。""" - while not stop_event.wait(interval): - try: - over = bool(quota_check()) if quota_check else False - except Exception: - over = False - if not over: - continue - self._mark_quota_hit(task_id) - try: - cancel = getattr(self.executor, "cancel_vm", None) - if cancel is not None and vm_name: - cancel(vm_name) - log_event(self.logger, "worker_chv_killed", - task_id=task_id, - message="quota watchdog killed VM %s " - "(quota exceeded)" % vm_name) - except Exception: - pass - return + """CHV 同步路径的配额看门狗(to10:循环本体已移到 + worker_adapter.quota_watchdog_loop,此处只传回调)。""" + _chv_worker_adapter.quota_watchdog_loop( + task_id, vm_name, stop_event, quota_check, + self._mark_quota_hit, + getattr(self.executor, "cancel_vm", None), + self.logger, interval=interval) def cleanup_stale_containers(self): """启动清理兜底:清掉上一轮残留的 codex- 容器。 @@ -4717,10 +4637,10 @@ class Worker: # process 已轮询 quota_check)。 if isinstance(self.executor, CloudHypervisorExecutor) \ and hasattr(self.executor, "cancel_vm"): - try: - _chv_vm_name = self.executor.vm_name(task) - except Exception: - _chv_vm_name = None + # to10:VM 名派生已移到 worker_adapter.watchdog_vm_ + # name(原 try/except 块逐字搬运)。 + _chv_vm_name = _chv_worker_adapter.watchdog_vm_name( + self.executor, task) if _chv_vm_name: _watchdog_stop = threading.Event() _watchdog_thread = threading.Thread( diff --git a/docs/chv-architecture.md b/docs/chv-architecture.md index 28e81c3..ed06307 100644 --- a/docs/chv-architecture.md +++ b/docs/chv-architecture.md @@ -232,3 +232,94 @@ live 状态——server 进程没有 VM 登记表,live 状态编不 出来;前端无该字段时不显示 VM 状态,不猜测。VM 控制 按钮仍不做(后端无控制端点且安全审计 No-Go)。 + +## ADR-13 CHV worker 适配模块(to10 轨) + +- 决策:新建 `cloud/chv/worker_adapter.py`,worker.py 只留 + 薄包装。抽入:断点调用(原顶层函数本体)、审计回调 + (原 `__init__` 闭包)、孤儿清理(原 `_reconcile_warm_pool` + 本体)、两条看门狗循环(原 `_chv_*_watchdog` 本体,self + 依赖全部改成回调参数)、看门狗 VM 名派生(原 `_run_attempt` + try/except 块)。worker 方法名、签名、调用点、错误文案 + 全部不变,旧测试继续走 worker 方法;适配器本身另有直接 + 行为测试。适配器只依赖标准库 + chv 叶子模块 + + `cloud.logging_config`,不 import `cloud.worker`,无循环; + 公共 API 全注解(#257 的类型测试扫 `cloud/chv/*.py`, + 新模块不注解会直接炸该测试——本轨已补齐)。 +- 明确不抽(硬抽无收益,只会撕通用流程):cancel() 里的 + `cancel_vm` 分支(与 Docker `kill_container` 分支相邻的 + 通用取消流)、`cleanup_stale_containers` 的 CHV 分支 + (与 Docker 清理相邻的通用清理流)、网关守卫(Local / + Docker / CHV 三分支共用的派发流)、`_executor_name` 的 + CHV 分支(通用执行器名映射)、`_run_attempt` 的看门狗 + 线程启动(线程管理是 worker 通用逻辑,只抽了 VM 名派生)、 + VM 死亡写回(通用终局流)。以上六处留在 worker.py, + 不是遗漏,是评估后有理由的结论。 +- 附带清理:`inspect` 在 worker.py 的唯一用处随断点函数 + 移走,import 一并删除(留着会触发 F401);worker.py + 基线已有的 I001(两处 import 排序)与 F821(playwright + 播种块的 `task`)与本轨无关,不顺手重排整个文件。 + +## ADR-14 审计失败落盘兜底(to10 轨) + +- 决策:选“本地审计落盘文件”作为兜底,不选“只记日志 + + 计数”(已做,仍不够:日志会轮转、计数只剩数字,事后 + 找不回哪次事件丢了)。实现:`executor._emit` 回调失败 + 时,除记宿主 error 日志 + `audit_failure_count` +1 外, + 再把事件 append 到 `state_dir/chv-audit-spill.jsonl` + (每行一 JSON:ts/event/kind/detail/task_id),文件超 + 1MB 时轮转成 `.old` 再写。 +- 取舍(写清,不当成完整修复):① 落盘本身 best-effort, + 写失败只记日志,绝不中断任务;② 落盘不是 DB 替代, + 任务 timeline 查询看不到它,需宿主人工查阅;③ 轮转 + 只留最新两代,更早的丢失事件仍会丢;④ detail 沿用 + `_emit` 契约(只含 VM 名/路径/URL/kind),绝不含密钥值, + 落盘文件随 state_dir 的 0700 权限保护。审计完整性本身 + 仍无保证——保证需要 DB 事务级审计写入,那是 worker + 全局语义的改动,不在本轨硬做。 +- 不选的另一条路:“审计失败即中断任务”——ADR-9 已否决: + 审计是可观测性不是门禁,DB 短暂不可用时硬中断会把 + 一次审计失败放大成任务失败,且与 worker 全局 best-effort + 语义冲突。 + +## ADR-15 前端只读 VM 展示四态与可用性统一文案(to10 轨) + +- 决策:`chvVmDisplay(task)` 返回四态而非此前两态文案: + dead(字段 present 且 true)、not-reported(CHV 任务但 + 字段 absent,只表示未报告)、unknown(任务详情本身未 + 加载)、not-applicable(非 CHV,不显示行)。旧入口 + `chvVmStatusText` 保留为 display 的 text 包装(unknown / + not-applicable 回空串),已测调用点不变。详情页 VM 行 + 改用 `chvVmDisplay(t).text` 并加 `role="status"` 与 + `aria-label="VM 状态"`。 +- 决策:`chvAvailabilityText(availability)` 统一新建任务 / + Workers / 环境三处可用性文案。此前三处各写各的,Workers + 的未知分支恒写“仅全局管理员可见”(加载失败/未加载时 + 也是这句,是文案 bug),本轨统一后按 reason 分三句, + known 零在线沿用派发页的完整版(不拦提交、后端是最终 + 权威)。无障碍打磨:可用性区与提交结果区加 + `aria-live="polite"`(结果区另加 `role="status"`),附件 + 三个 input 加 `aria-label`(此前只有 title),禁用态本身 + 已由 `disabled` 传达,不另造禁用文案。控制按钮仍不做 + (后端无控制端点且安全审计 No-Go);组件渲染仍不在 + 测试覆盖内(无 testing-library 依赖,不临时加依赖冒充)。 + +## ADR-16 to10 轨的类型与 lint 口径(诚实标注) + +- mypy:`--follow-imports=silent cloud/chv/`,只查 chv 本身 + (不拖入无关文件)。基线该口径 115 错 → 本轨 0 错; + 默认 follow-imports 口径基线 117 错 → 本轨 2 错,且 + 2 错全在作用域外的 `cloud/marker_tools.py:142`(与 chv + 无关,不顺手修它),chv 自身在默认口径下也是 0 错。 +- ruff:作用域 F/E9/W/I(未用变量、语法错、空白、import + 排序),基线 8 错 → 本轨 0 错;默认全规则基线 208 错 + (含本仓库未采纳的风格意见,如百分号格式化、 + best-effort 清理的 try/except-pass),本轨不声称全规则 + 通过。worker.py 不在 lint 作用域内:其基线 I001 两处与 + F821 一处与本轨无关,未重排;本轨只删了随抽取而变多余 + 的 `inspect` import(否则新增 F401)。 +- 覆盖率:`coverage --source=cloud/chv` 对 CHV 套件(不含 + worker/server 全量),基线 85% → 本轨 87%(1826 行, + 缺 268 → 242);未覆盖大头仍是 executor 深层生命周期 + 分支(需假 VMM 全链路)与 disk 配额分支,不靠删测试 + 或改分母凑数。 diff --git a/docs/chv-production-readiness.md b/docs/chv-production-readiness.md index 122afa5..6985ea2 100644 --- a/docs/chv-production-readiness.md +++ b/docs/chv-production-readiness.md @@ -459,3 +459,116 @@ 本 PR 顺序合并 → KVM 宿主 + 防火墙清单 → 镜像构建发布 → §11.5 + 本节真机清单全过 → 默认关闭下单租户/受信项目 灰度 → 全量多租户(须另行决策,不在本轨内默认开启)。 + +## 13. to10 提升轨 feat-chv-to10(堆叠于 #257 之上) + +本节是准备工作,不是启用结论:代码侧的 to10 项已做, +生产就绪与动态安全仍未到 10 分(见 §13.4 外部关卡)。 + +### 13.1 合并前 diff 复核清单(以当前 main 头预填) + +- 当前 main 头(本轨核查时):`aebc6238a613066316ec18796b83ea8fc2d8835e` + (Merge PR #201)。链上 10 个 PR 在核查时全部 + OPEN / MERGEABLE(#216 base main,#221→#257 依次堆叠, + 本 PR base 为 feat-chv-arch-maint-fe)。合并时必须重新 + 核查:main 头若已前进,整条链须逐个 rebase 并重跑测试, + 绝不直接合并旧基底。 +- 逐项复核点(合并执行者逐项打勾,任一项不符即停): + 1. server 任务创建白名单含 `cloud-hypervisor`,环境创建 + 白名单为 local/docker/cloud-hypervisor(不含 cloud), + 两处白名单差异仍在前端文案里写清。 + 2. worker 断点分支:Local 原路径不变,CHV 经 + `worker_adapter.call_goal_resume_breakpoint`(薄包装名 + `_call_goal_resume_breakpoint` 仍在 worker.py,旧测试 + 导入点不变)。 + 3. sandbox re-export:`cloud/chv` 不再 export WarmPool + (#257 已移实验模块),`cloud/sandbox.py` 的 CHV + re-export 与 `cloud/chv/__init__.py` 一致。 + 4. 前端产物:`cloud/console.html` 与 `dist/index.html` + 的 sha256 一致(本轨:`269820d1…f389`,353163 字节), + 产物内含新文案(“无法判断 VM 状态”1 处、 + “CHV worker 在线数未知”3 处);合并时重建后复核, + 不直接沿用本轨哈希。 + 5. `package.json` 的 vitest 测试脚本与 lockfile 增量只含 + vitest 新条目,无旧包版本变动。 + 6. 本轨新增文件:`cloud/chv/worker_adapter.py`、 + `cloud/tests/test_chv_to10.py`、 + `chv-guest/generate_lockfile.py`;本轨改动文件: + `cloud/worker.py`(薄包装 + 删多余 `inspect` import)、 + `cloud/chv/executor.py`(审计落盘 + 两函数抽 helper)、 + `cloud/chv/config.py`、`disk.py`、`hardening.py`、 + `hostkeys.py`、`ssh_transport.py`、`experimental_pool.py`、 + `compat215.py`(类型注解 + ruff 整理)、前端 6 文件。 + 7. CI:链上全部 PR 的 `verify` 仍是账单故障秒级失败模式 + (本轨核查时 #257 等均为 UNSTABLE,非代码失败); + CI 变绿前不合并,绝不改 CI 配置绕过。 + 8. 旧链 #199→#205→#207 与 #209–#215 仍全部 OPEN: + 合并前复核它们与本链的交集(server 白名单、worker + 断点分支、sandbox re-export),有冲突先解决冲突方, + 不静默覆盖。 + +### 13.2 真机验证清单(用例与验收标准细化) + +以下用例全部待 KVM 宿主,验收标准是“通过才算”,不是 +“跑过就算”;任一用例未跑即记为未验证,不写进已验。 + +- V-1 冷启动:CHV worker 在 KVM 宿主上起 VM → guest + SSH 就绪(钉死 known_hosts,provisioned 模式)→ 跑通 + 一个真实任务(host.py 在客机内执行)→ 任务完成且 VM + 已销毁、任务盘已删。验收:任务状态 completed,宿主 + state_dir 无该任务残留盘与 seed。 +- V-2 seed 首启时序:provisioned 模式下客机 cloud-init + 在 sshd 启动前应用 seed(host key 不再生、客内密钥文件 + 0600)。验收:`sshd -T` 显示 PasswordAuthentication no + 等加固项,known_hosts 钉死校验通过,非 TOFU 回落。 +- V-3 快照恢复:任务中途 hibernate(pause + snapshot + + shutdown)→ restore 后显式 resume → 断点续跑完成。 + 验收:恢复后 known_hosts 不变(不重预置),快照文件 + 落盘非空且经 `snapshot_not_persisted` 校验。 +- V-4 网关真实任务:sub2api 网关任务在 CHV 上跑通, + 密钥只走 seed 客内文件,宿主 `ps` / `/proc/*/cmdline` + 里看不到密钥值。验收:任务完成 + 宿主进程表抽查无密钥。 +- V-5 取消与配额真杀:同步 SSH 阻塞中取消任务、配额超限 + 时,看门狗调 `cancel_vm` 真关 VM。验收:VM 进程消失, + 任务状态 interrupted 且原因写回正确。 +- V-6 审计落盘:DB 审计回调失败时,`chv-audit-spill.jsonl` + 里有该事件行、`audit_failure_count` +1、任务不中断。 + 验收:落盘行 event/task_id/detail 与丢失事件一致。 +- V-7 动态安全负向:客机 root unset 代理后直连(应被 + tap/防火墙挡住)、跨 VM 探活与 IP 冒用(SSH 钉死应拒)、 + 崩溃注入后残留扫描(私钥/seed/盘应被清理或脏标记拒起)。 + 验收:每一项都有“被挡住”的证据,不是有代码就过。 +- V-8 负载:4 VM 并发(`CHV_MAX_VMS` 默认值)下容量门 + 计数含重启后残留 socket 扫描,死 socket 不占名额、活 + VM 不被超发。验收:并发任务全部完成或明确排队拒绝, + 无静默降级到 Docker/Local。 +- V-9 供应链清单:`generate_lockfile.py` 在构建宿主上跑 + 出真实 lockfile,`--verify` 通过且 unresolved 为空; + 再用 `check_lockfile.py` 对 BUILDINFO.versions 复核。 + 验收:lockfile 由构建宿主产出,不是本轨合成数据。 + +### 13.3 灰度启用清单 + +- 阶段 0(合并后、默认关闭):合并整条链,CHV 仍默认 + 关闭,不进生产任务路由;先跑 §13.2 的 V-1~V-9。 +- 阶段 1(单租户可信项目):只对明确指定的单租户项目 + 开 CHV(`CHV_REUSE_SINGLE_TENANT_EXPERIMENT` 与 + `CHV_SINGLE_TENANT_PROJECT` 的声明口径不变,复用 + 实验仍需同时声明,两者缺一即 preflight 拒绝)。 +- 阶段 2(受限多租户):放开到受邀项目,监控 VM 残留盘、 + 审计落盘、容量门与快照配额三项指标一周无异常。 +- 阶段 3(全量多租户):仅在阶段 2 指标干净 + 动态安全 + 审计结论由 No-Go 改为有条件启用后,才可讨论;绝不 + 从阶段 0 直跳全量。 + +### 13.4 外部关卡(本轨代码做不到,逐项列出) + +1. CI 账单恢复变绿(硬门禁,合并前提)。 +2. 链上 10 个 PR 按顺序合并 + 本 PR 合并(需先过 §13.1)。 +3. 用户侧 provision KVM 宿主(裸金属或嵌套虚拟化实例, + 须见 `/dev/kvm`;生产宿主与本 VM 均无 KVM,无法替代)。 +4. 客机镜像真实构建与发布(`build.sh` 全量跑通 + V-9)。 +5. §13.2 真机验证全部通过(含 V-7 动态安全负向)。 +6. 负载测试(V-8)与安全审计结论更新(No-Go → 有条件启用)。 +7. 前端 live 点击验证(用户侧 live browser 检查清单见 + 本 PR 描述;回环地址限制下浏览器任务无法代劳)。