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 8c3a8a4..3f1efb7 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, shortId } 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() {
@@ -125,14 +125,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}
@@ -171,15 +165,15 @@ export default function DispatchSection() {
{isSide ? 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 aca839c..f826d75 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 06f05c4..94babae 100644
--- a/cloud/console-react/src/sections/WorkersSection.tsx
+++ b/cloud/console-react/src/sections/WorkersSection.tsx
@@ -2,7 +2,7 @@ import { useState } from "react";
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";
// Workers are read-only by design (GET /workers + worker-side
// heartbeat only — no start/stop/kill actions exist to surface), so
@@ -29,10 +29,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)}
setQuery(e.target.value)} />
diff --git a/cloud/console.html b/cloud/console.html
index a7203c5..502c18c 100644
--- a/cloud/console.html
+++ b/cloud/console.html
@@ -4,7 +4,7 @@
Codex 命令台
-
+`)}}b("流已断开")}catch(at){if(at.name==="AbortError")return;M(at instanceof Error?at.message:String(at)),b("未开始")}finally{p(!1)}},[r]),ft=d.useCallback(()=>{var z;(z=H.current)==null||z.abort(),p(!1),b("已停止")},[]),Et=d.useCallback(async z=>{if(r)try{const L=await I($.taskBrowserControl(r.id),"POST",{holder:z});A(L),M("")}catch(L){M(L instanceof Error?L.message:String(L))}},[r]),st=d.useCallback(async z=>{if(!r||(g==null?void 0:g.holder)!=="user"||!Z.current)return;const L=Z.current.getBoundingClientRect(),at=Math.round((z.clientX-L.left)/L.width*1280),jt=Math.round((z.clientY-L.top)/L.height*800);try{await I($.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:at,y:jt})}catch(Mt){M(Mt instanceof Error?Mt.message:String(Mt))}},[r,g]),Ht=d.useCallback(async z=>{if(!(!r||(g==null?void 0:g.holder)!=="user")){z.preventDefault();try{z.key.length===1?await I($.taskBrowserInput(r.id),"POST",{type:"type",text:z.key}):await I($.taskBrowserInput(r.id),"POST",{type:"key",key:z.key})}catch(L){M(L instanceof Error?L.message:String(L))}}},[r,g]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const Q=(g==null?void 0:g.holder)||"—",ct=g!==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:O})]}),c.jsxs("span",{className:"muted",children:["会话 ",ct?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",Q]}),g!=null&&g.url?c.jsx("span",{className:"muted",title:g.url,children:g.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:ft,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void it(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void F(),children:"刷新状态"}),Q!=="user"?c.jsx("button",{type:"button",onClick:()=>void Et("user"),disabled:!ct,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void Et("agent"),children:"交还给 Agent"})]}),N?c.jsx("div",{className:"muted",style:{marginTop:8},children:N}):null,Q==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:Z,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:z=>void st(z),onKeyDown:z=>void Ht(z),style:{width:"100%",maxWidth:1280,cursor:Q==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:ct?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function zg(){const i=Gt(),[r,h]=d.useState("uncommittedChanges"),[f,g]=d.useState(""),[A,O]=d.useState(""),[b,N]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:M=>h(M.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:M=>g(M.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:A,onChange:M=>O(M.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:b,onChange:M=>N(M.target.value)}),c.jsxs(zt,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:A.trim()||void 0,instructions:b.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(Sn,{children:i.reviews.map(M=>{let q=M.target_type||"";return M.branch&&(q+=":"+M.branch),M.sha&&(q+=":"+String(M.sha).slice(0,12)),M.instructions&&(q+=":"+String(M.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",se(M.id)," | ",q," | ",c.jsx("span",{className:"st-"+(M.status||""),children:M.status}),M.created_at?" | "+Ge(M.created_at):"",c.jsx("br",{}),M.findings?String(M.findings).slice(0,1e3):c.jsx("span",{className:"muted",children:"(暂无 findings:评审尚未执行或尚未写回)"})]}),c.jsx("button",{onClick:()=>i.submitFindings(M.id),children:"写 findings"})]},M.id)})})]})}function Dg(){const i=Gt();return i.currentTask?i.currentTask.coordinator_parts?i.coordChildren.length===0?c.jsx("div",{className:"muted",children:"(子任务尚未创建)"}):c.jsx(Sn,{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})," | ",se(r.id)," | ",String(r.prompt||"").slice(0,60)]})},r.id))}):c.jsx("div",{className:"muted",children:"(非协调员任务)"}):c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示协调员的子任务"})}function Rg(){const i=Gt();return i.currentTask?i.sideThreads.length===0?c.jsx("div",{className:"muted",children:"(暂无旁聊;列表“开旁聊”可在此任务下新建,不建 worktree)"}):c.jsx(Sn,{children:i.sideThreads.map(r=>c.jsx("li",{onClick:()=>i.showDetail(r.id),style:{cursor:"pointer"},children:c.jsxs("span",{className:"row-main",children:["旁聊 | 状态 ",c.jsx("span",{className:"st-"+r.status,children:r.status})," | ",se(r.id)," | ",String(r.prompt||"").slice(0,60)]})},r.id))}):c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示旁聊"})}function kg(){const i=Gt();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(Sn,{children:i.agents.map((f,g)=>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?" | 最近活动 "+Ge(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((A,O)=>c.jsx("div",{children:xo(A)},O))]}):c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsx("span",{className:"muted",children:"(暂无过程调用)"})]})]})},g))})}function wg(){const i=Gt();return c.jsxs(c.Fragment,{children:[c.jsxs(zt,{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(ul,{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)+" "+Ge(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 Ug(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"目标任务 id(与本任务同项目;对方运行中排队执行、已终态则续跑复活,绝不打断当前轮)",style:{width:"100%"},value:r,onChange:A=>h(A.target.value)}),c.jsx("textarea",{placeholder:"消息正文(以本任务名义发出;对方 agent 会看到发送方任务 id,可照此回消息)",value:f,onChange:A=>g(A.target.value)}),c.jsxs(zt,{children:[c.jsx("button",{onClick:async()=>{await i.postPeer("queued",r,f),g("")},children:"排队发给对方"}),c.jsx("button",{className:"primary",onClick:async()=>{await i.postPeer("direct",r,f),g("")},children:"直接发送"}),c.jsx("span",{className:"muted",children:i.peerMsg})]}),c.jsx(ul,{empty:"(本任务尚未发出任务间消息)",items:i.peerMessages.map((A,O)=>c.jsxs("span",{className:"row-main",children:["[发给 ",String(A.task_id).slice(0,8),"|",A.mode,"|",A.status,"] ",String(A.body||"").slice(0,120)]},O))})]})}function Hg(){const i=Gt(),[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(zt,{children:[c.jsx("input",{type:"text",placeholder:"新线程名",value:r,onChange:g=>h(g.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(ul,{empty:"暂无其他线程",items:i.threadList.map((g,A)=>c.jsxs("span",{className:"row-main",children:[g.name||g.thread_name||g.id||""," [",g.id||"","]",g.archived?" [已归档]":"",g.cwd?" "+g.cwd:""]},A))}),c.jsx("div",{className:"muted",children:"限制:Docker/异机任务线程操作一律 409;merged/merge-failed 不可改;turn/steer 与 review/start 不在本批。"})]})}function Bg(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 qg(i){return i===null||i<0?"未知":i<60?Math.floor(i)+" 秒前":i<3600?Math.floor(i/60)+" 分钟前":(i/3600).toFixed(1)+" 小时前"}const Yg=[["overview","概览"],["collab","协作"],["review","评审与审批"],["threads","线程"],["browser","浏览器"]];function Gg(){const i=Gt(),r=i.currentTask,[h,f]=d.useState("overview");if(!r)return c.jsx(Jt,{id:"nav-detail",title:"任务详情",children:c.jsx("div",{className:"muted",children:"点任务行查看详情"})});const[g,A]=lv(r),[O,b]=av(r),N=fi(i.workers,i.workersSummary,i.workersError),M=qc(r.executor,r.status,N),q=ev(r),p=i.costSummary,H=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:Yg.map(([Z,F])=>c.jsx("button",{type:"button",className:h===Z?"active":"",onClick:()=>f(Z),children:F},Z))}),h==="overview"?c.jsxs(c.Fragment,{children:[c.jsxs(Jt,{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:["更新 ",Ge(r.updated_at)]})]}),c.jsxs("div",{className:"detail-cards",children:[c.jsxs("div",{className:"detail-card",title:A,children:[c.jsx("div",{className:"detail-card-label",children:"耗时"}),c.jsx("div",{className:"detail-card-value",children:g})]}),c.jsxs("div",{className:"detail-card",title:b,children:[c.jsx("div",{className:"detail-card-label",children:"token"}),c.jsx("div",{className:"detail-card-value",children:O})]}),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:p===void 0?"加载中…":p===null?"—":(p.llm_requests??0).toLocaleString()}),p===void 0?c.jsx("div",{className:"muted",children:"成本加载中…"}):p===null?c.jsx("div",{className:"muted",children:"成本明细加载失败,LLM 请求数不可用"}):c.jsxs("div",{className:"muted",children:["上游补全次数之和| 轮数 ",p.turns??0," | fallback ",p.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:p===void 0?"加载中…":p===null?"—":jg(p.usd_micros)}),p===void 0?c.jsx("div",{className:"muted",children:"成本加载中…"}):p===null?c.jsx("div",{className:"muted",children:"成本明细加载失败(“查成本”按钮仍可手动拉取原始 JSON)"}):c.jsxs("div",{className:"muted",children:["token ",(p.total_tokens??0).toLocaleString(),"(实际 ",(p.actual_tokens??0).toLocaleString()," / 估算 ",(p.estimated_tokens??0).toLocaleString(),",缓存 ",(p.cached_input_tokens??0).toLocaleString(),",无价 ",(p.unpriced_actual_tokens??0).toLocaleString(),")| fallback ",p.fallback_count??0]})]})]})]}),r.status==="running"||r.status==="queued"?(()=>{const Z=Date.now()/1e3,F=r.last_heartbeat||r.updated_at||null,it=F?Z-F:null;return c.jsxs(Jt,{title:"LLM 进度",children:[c.jsxs("div",{children:["状态 ",r.status==="running"?"进行中":"排队中"," · 已用 ",g,it!==null?" · 上次活动 "+qg(it):""]}),M?c.jsx("div",{className:"muted",children:M}):null,it!==null&&it>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(Jt,{title:"失败原因",children:[c.jsx("div",{children:Bg(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(Jt,{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:se(r.turn_id)})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"root turn"}),c.jsx("dd",{children:se(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:se(r.project_id)})]}),H?c.jsxs("div",{children:[c.jsx("dt",{children:"模式"}),c.jsx("dd",{children:H})]}):null,r.kind==="side"?c.jsxs("div",{children:[c.jsx("dt",{children:"类型"}),c.jsx("dd",{children:"旁聊(无 worktree/分支/容器)"})]}):null,r.forked_from?c.jsxs("div",{children:[c.jsx("dt",{children:"分支自"}),c.jsx("dd",{children:se(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(Z=>Z.name).join("、"),")"]})]}):null,r.parent_task_id&&r.kind==="side"?c.jsxs("div",{children:[c.jsx("dt",{children:"父会话"}),c.jsxs("dd",{children:["父任务 ",se(r.parent_task_id)," ",c.jsx("button",{type:"button",onClick:()=>i.showDetail(r.parent_task_id),children:"跳转到父任务"})]})]}):null,r.parent_task_id&&r.kind!=="side"?c.jsxs("div",{children:[c.jsx("dt",{children:"协调子任务"}),c.jsxs("dd",{children:["父任务 ",se(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(Jt,{title:"prompt",children:c.jsx("div",{style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:r.prompt||"(无 prompt)"})}),c.jsxs(Jt,{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(Jt,{title:"协调子任务",children:c.jsx(Dg,{})}),c.jsx(Jt,{title:"旁聊",children:c.jsx(Rg,{})}),c.jsx(Jt,{title:"子代理链路",children:c.jsx(kg,{})}),c.jsx(Jt,{title:"给其他任务发消息",children:c.jsx(Ug,{})})]}):h==="review"?c.jsxs(c.Fragment,{children:[c.jsx(Jt,{title:"评审队列",children:c.jsx(zg,{})}),c.jsx(Jt,{title:"运行时审批队列",children:c.jsx(wg,{})})]}):h==="browser"?c.jsx(c.Fragment,{children:c.jsx(Jt,{title:"浏览器远控",children:c.jsx(Og,{})})}):c.jsx(c.Fragment,{children:c.jsx(Jt,{title:"线程",children:c.jsx(Hg,{})})})]})}function Qg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState(""),[A,O]=d.useState([]),[b,N]=d.useState(null),[M,q]=d.useState(!1),[p,H]=d.useState(""),[Z,F]=d.useState(""),it=d.useCallback(async()=>{if(!i.keySaved){O([]);return}try{const Q=await I($.userKeys);O(Q.keys||[]),F("")}catch(Q){F(Q instanceof Error?Q.message:"加载 key 列表失败")}},[i.keySaved]);d.useEffect(()=>{it()},[it]);const ft=async()=>{H(""),F(""),q(!1);try{const Q=await I($.userKeys,"POST",{label:f.trim()||"api-key"});N(Q),g(""),H("新 key 已生成,只显示一次:复制后请妥善保存。"),await it()}catch(Q){F(Q instanceof Error?Q.message:"生成 key 失败")}},Et=async()=>{if(b)try{await navigator.clipboard.writeText(b.api_key),q(!0)}catch{F("复制失败:请手动选中上方 key 文本复制")}},st=()=>{b&&(i.saveKeyInput(b.api_key),N(null),H("已存为当前浏览器 key(原有 key 未被撤销,需要时可单独撤销)。"))},Ht=async Q=>{const ct=(()=>{try{return nl().slice(-4)}catch{return""}})(),z=Q.id==="legacy"?"(主 key:撤销后当前浏览器保存的 key 也可能随之失效,需重新登录拿新 key)":Q.hint&&Q.hint===ct?"(这就是本浏览器当前在用的 key,撤销后本页会立刻未连接)":"";if(window.confirm(`撤销 key「${Q.label}」(后 4 位 ${Q.hint||"无"})吗?${z}撤销不可恢复。`)){H(""),F("");try{await I($.userKey(Q.id),"DELETE"),H(`已撤销 key「${Q.label}」。`),await it()}catch(L){F(L instanceof Error?L.message:"撤销失败")}}};return c.jsxs(Jt,{id:"nav-key",title:"API Key",children:[c.jsxs(zt,{children:[c.jsx("input",{type:"password",placeholder:"粘贴你的 X-API-Key(与调 API 用的同一个)",autoComplete:"off",value:r,onChange:Q=>h(Q.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(zt,{children:[c.jsx("input",{placeholder:"新 key 备注(可选,默认 api-key,如 harness2-temp)",autoComplete:"off",value:f,onChange:Q=>g(Q.target.value)}),c.jsx("button",{className:"primary",onClick:()=>void ft(),children:"生成新 key"}),c.jsx("button",{onClick:()=>void it(),children:"刷新列表"})]}),b&&c.jsxs("div",{children:[c.jsx("div",{children:c.jsxs("span",{className:"muted",children:["新 key(",b.label,",后 4 位 ",b.hint,"):只显示一次,关闭后无法再查看。"]})}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",readOnly:!0,value:b.api_key,onFocus:Q=>Q.target.select()}),c.jsx("button",{className:"primary",onClick:()=>void Et(),children:M?"已复制":"复制"}),c.jsx("button",{onClick:st,children:"存为当前浏览器 key"}),c.jsx("button",{onClick:()=>N(null),children:"关闭(不再显示)"})]})]}),p&&c.jsx("div",{children:c.jsx("span",{className:"muted",children:p})}),Z&&c.jsx("div",{children:c.jsx("span",{children:Z})}),c.jsxs("div",{className:"api-key-list",children:[A.length===0&&c.jsx("span",{className:"muted",children:"暂无 key(或列表加载失败,见上方报错)。"}),A.map(Q=>c.jsxs("div",{children:[c.jsx("span",{children:Q.label})," ",c.jsxs("span",{className:"muted",children:["后 4 位 ",Q.hint||"无"," · 创建 ",Ge(Q.created_at)," · 最近使用 ",Q.last_used_at?Ge(Q.last_used_at):"未使用过"]})," ",Q.revoked_at?c.jsxs("span",{className:"muted",children:["已撤销(",Ge(Q.revoked_at),")"]}):c.jsx("button",{onClick:()=>void Ht(Q),children:"撤销"})]},Q.id))]})]})]})}function Vg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState("");return c.jsxs(Jt,{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(zt,{children:[c.jsx("input",{type:"text",placeholder:"要查配额的用户 id",value:i.adminUserId,onChange:A=>i.setAdminUserId(A.target.value)}),c.jsx("button",{onClick:i.adminQuota,children:"查该用户配额"})]}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"要设置配额的用户 id",value:r,onChange:A=>h(A.target.value)}),c.jsx("input",{type:"text",placeholder:"美元配额(留空 = 不限额)",value:f,onChange:A=>g(A.target.value)}),c.jsx("button",{className:"primary",onClick:()=>i.adminSetQuota(r,f),children:"设置配额"})]}),c.jsxs(zt,{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 Xg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState("contributor"),A=i.projects.find(O=>O.id===i.projectId);return c.jsxs(Jt,{title:"项目成员",children:[A?c.jsxs("div",{className:"muted",children:[A.name," | ",A.id," | 仓库 ",A.repo_url||"未绑定"," | 我的角色 ",A.my_role||"未知"]}):c.jsx("div",{className:"muted",children:"先选项目"}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"成员 user_id 或用户名",value:r,onChange:O=>h(O.target.value)}),c.jsxs("select",{"aria-label":"邀请角色",value:f,onChange:O=>g(O.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(Sn,{children:i.members.map(O=>{const b=(O.username||"")+(O.username?"("+String(O.user_id).slice(0,8)+")":String(O.user_id).slice(0,8));return c.jsxs("li",{children:[c.jsx("span",{className:"row-main",children:b}),c.jsx("select",{"aria-label":"成员角色",value:O.role||"viewer",onChange:N=>void i.changeMemberRole(O.user_id,N.target.value),children:lg.map(N=>c.jsx("option",{value:N,children:N},N))}),c.jsx("button",{onClick:()=>void i.removeMember(O.user_id),children:"移除"})]},O.user_id)})}),c.jsx("span",{className:"muted",children:i.memberMsg})]})}function Lg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState(""),[A,O]=d.useState("");return c.jsxs("div",{className:"project-nav",children:[c.jsxs(Jt,{title:"项目",children:[c.jsx(zt,{children:c.jsx("button",{onClick:i.refreshProjects,children:"刷新项目"})}),i.projects.length===0?c.jsx("div",{className:"muted",children:"暂无项目"}):c.jsx("ul",{className:"project-nav-list",children:i.projects.map(b=>c.jsxs("li",{className:"project-nav-item"+(b.id===i.projectId?" selected":""),onClick:()=>i.selectProject(b.id),title:b.id,children:[c.jsx("span",{className:"project-nav-name",children:b.name}),c.jsxs("span",{className:"project-nav-meta",children:[se(b.id),b.my_role?" · "+b.my_role:""]})]},b.id))}),c.jsx("h3",{children:"创建项目"}),c.jsx(zt,{children:c.jsx("input",{type:"text",placeholder:"新项目名",value:r,onChange:b=>h(b.target.value)})}),c.jsx(zt,{children:c.jsxs("select",{"aria-label":"从 GitHub 仓库选",value:f,onChange:b=>{g(b.target.value),b.target.value&&O(b.target.value)},children:[c.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选,或手动输入)"}),i.githubRepos.map(b=>c.jsx("option",{value:b.clone_url,children:b.repo_full_name},b.clone_url))]})}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"repo_url(可选)",value:A,onChange:b=>O(b.target.value)}),c.jsx("button",{className:"primary",onClick:async()=>{await i.createProject(r,A),h(""),O(""),g("")},children:"创建项目"})]}),c.jsx("span",{className:"muted",children:i.projectMsg})]}),c.jsx(Xg,{})]})}function Zg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState(""),[A,O]=d.useState(""),b=fi(i.workers,i.workersSummary,i.workersError);return c.jsxs(Jt,{id:"nav-env",title:c.jsxs(c.Fragment,{children:["环境 ",c.jsx("span",{className:"muted",children:"(选、建、管:当前项目的基准检出)"})]}),children:[c.jsxs(zt,{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(Sn,{children:i.envs.map(N=>c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[N.name,"(",se(N.id),") / 分支 ",N.branch||"默认分支"," / 执行器 ",N.executor||"不限"," / 状态 ",N.status,N.status==="failed"&&N.error_text?" / 失败原因:"+N.error_text:""]}),N.status==="archived"?null:c.jsx("button",{onClick:()=>void i.archiveEnv(N.id),children:"归档"})]},N.id))}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"新环境名",value:r,onChange:N=>h(N.target.value)}),c.jsx("input",{type:"text",placeholder:"分支(可选,空=默认分支)",value:f,onChange:N=>g(N.target.value)}),c.jsxs("select",{"aria-label":"新环境执行器",value:A,onChange:N=>O(N.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,A),h(""),g("")},children:"新建环境"})]}),A==="cloud-hypervisor"?c.jsxs("div",{className:"muted","aria-live":"polite",children:["实验执行器,默认关闭:环境创建白名单同样允许 cloud-hypervisor(后端环境接口 allowlist:local / docker / cloud-hypervisor,不含 cloud);仍需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务排队。前端只传值,不绕过后端白名单。 ",To(b)]}):null,c.jsx("span",{className:"muted",children:i.envMsg})]})}function Kg(){const i=Gt(),[r,h]=d.useState("");return c.jsxs(Jt,{title:"仓库与环境上下文",children:[c.jsx("div",{className:"muted",children:i.envRepoLine||"先选项目"}),c.jsx("div",{className:"muted",children:i.envCount}),i.envRepoBar?c.jsxs(zt,{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 Jg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState("all"),A=M=>{const q=Yc(M);return M.alive!==void 0&&M.alive!==null?!!M.alive:q.age!==null&&q.age<=120},O=r.trim().toLowerCase(),b=(i.workers||[]).filter(M=>!(O&&!((M.worker_id||M.id||"")+" "+(M.executor||"")).toLowerCase().includes(O)||f==="online"&&!A(M)||f==="offline"&&A(M))),N=fi(i.workers,i.workersSummary,i.workersError);return c.jsxs(Jt,{id:"nav-workers",title:"Workers",children:[c.jsx(zt,{children:c.jsx("button",{onClick:i.refreshWorkers,children:"刷新"})}),c.jsx("div",{className:"muted","aria-live":"polite",children:To(N)}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"搜索 worker_id / 执行器","aria-label":"搜索 Worker",value:r,onChange:M=>h(M.target.value)}),c.jsxs("select",{"aria-label":"在线状态筛选",value:f,onChange:M=>g(M.target.value),children:[c.jsx("option",{value:"all",children:"在线状态:全部"}),c.jsx("option",{value:"online",children:"仅在线"}),c.jsx("option",{value:"offline",children:"仅离线"})]}),i.workers?c.jsxs("span",{className:"muted",children:["显示 ",b.length," / 共 ",i.workers.length," 个"]}):null]}),i.workersError?c.jsx("div",{className:"muted",children:i.workersError}):i.workers===null?c.jsxs("div",{className:"muted",children:[i.workersSummary||"未加载","(完整清单仅全局管理员可见,搜索/筛选仅对完整清单生效)"]}):i.workers.length===0?c.jsx("div",{className:"muted",children:"无 worker 注册"}):b.length===0?c.jsx("div",{className:"muted",children:"没有符合筛选条件的 worker"}):c.jsx(Sn,{children:b.map((M,q)=>{const p=M.running===void 0||M.running===null?"?":M.running,H=M.capacity===void 0||M.capacity===null?"?":M.capacity,Z=Yc(M),F=A(M),it=F?" / 在线":" / 离线(占用为上次上报值)";return c.jsx("li",{className:F?"is-online":"is-offline",children:c.jsxs("span",{className:"row-main",children:[M.worker_id||M.id||"?"," / ",M.executor||""," / 占用 ",p,"/",H,it," / 最后心跳 ",Z.text]})},q)})})]})}function Fg(){const i=Gt();return c.jsxs(Jt,{title:"健康与配额",children:[c.jsxs(zt,{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 Wg(){const i=Gt();return c.jsxs(Jt,{id:"nav-skills",title:"技能与 MCP",children:[c.jsx("div",{className:"muted",children:"反映宿主租户 home,容器内 home 可能不同;重载只在 schema 枚举里,当前 pin 的二进制拒绝时原样 502;OAuth 只发起登录,不代表登录完成。"}),c.jsxs(zt,{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(ul,{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(ul,{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(ul,{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 $g(){const i=Gt(),[r,h]=d.useState("");return c.jsxs(Jt,{title:"MCP 操作",children:[c.jsxs(zt,{children:[c.jsx("button",{onClick:i.reloadMcp,children:"重载配置"}),c.jsx("span",{className:"muted",children:i.mcpMsg})]}),c.jsxs(zt,{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 Ig(i){return i?i%86400===0?"(每天)":i%3600===0?"(每小时)":i%60===0?"(每 "+i/60+" 分钟)":"":""}function Pg({detail:i}){const r=Gt(),h=f=>{if(f){r.showDetail(f);try{window.location.hash="#view-tasks"}catch{}}};return c.jsxs("div",{className:"card",children:[c.jsxs("h2",{children:["自动化详情:",i.name]}),c.jsxs("div",{className:"muted",children:["状态 ",i.status,i.due?"|到期":""," | 每 ",i.interval_seconds," 秒 | 下次 ",Ge(i.next_run_at),"| 上次执行 ",Ge(i.last_run_at)," | 创建人 ",i.created_by?se(i.created_by):""," | 创建于 ",Ge(i.created_at),i.cancelled_at?" | 已取消于 "+Ge(i.cancelled_at):"",i.heartbeat_binding?" | 心跳绑定:已绑定":""]}),c.jsx("div",{className:"muted",style:{whiteSpace:"pre-wrap",marginTop:8},children:i.prompt||""}),i.repo_url?c.jsxs("div",{className:"muted",children:["仓库 ",i.repo_url]}):null,c.jsxs(zt,{children:[c.jsx("button",{onClick:r.closeAutomationDetail,children:"关闭详情"}),c.jsxs("span",{className:"muted",children:["编辑请在下面列表里点“编辑”。",r.autoDetailMsg]})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["最近产出(",(i.runs||[]).length,")"]}),c.jsx(ul,{empty:"(无)",items:(i.runs||[]).map((f,g)=>c.jsxs("span",{className:"row-main",children:[f.status,f.created_at?"|"+Ge(f.created_at):"",f.task_id?c.jsxs(c.Fragment,{children:[" | 任务 ",c.jsx("button",{onClick:()=>h(f.task_id),children:se(f.task_id)})]}):"",f.detail?"|"+String(f.detail).slice(0,120):""]},g))})]}),i.runs_note?c.jsx("div",{className:"muted",children:i.runs_note}):null]})}function tp(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState(""),[A,O]=d.useState(""),[b,N]=d.useState(""),[M,q]=d.useState(""),[p,H]=d.useState(""),[Z,F]=d.useState(""),[it,ft]=d.useState(""),[Et,st]=d.useState(""),[Ht,Q]=d.useState({}),ct=z=>{q(z.id),H(z.name||""),F(z.prompt||""),ft(z.interval_seconds?String(z.interval_seconds):""),st(z.repo_url||"")};return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"自动化名称(同项目内启用中的名称唯一)",style:{width:"100%"},value:r,onChange:z=>h(z.target.value)}),c.jsx("textarea",{placeholder:"定时任务内容(创建只登记定义与到期状态;执行由后续执行器经 claim 抢单后建任务,不另起调度器)",value:f,onChange:z=>g(z.target.value)}),c.jsxs(zt,{children:[c.jsx("input",{placeholder:"间隔秒数(默认 86400,最小 60)",style:{width:"40%"},value:A,onChange:z=>O(z.target.value)}),c.jsx("input",{placeholder:"仓库(可选)",style:{width:"40%"},value:b,onChange:z=>N(z.target.value)}),c.jsx("button",{className:"primary",onClick:async()=>{await i.createAutomation(r,f,A,b),h(""),g(""),O(""),N("")},children:"创建自动化"}),c.jsx("button",{onClick:i.refreshAutomations,children:"刷新自动化"}),c.jsx("span",{className:"muted",children:i.autoMsg})]}),c.jsx(ul,{empty:"(暂无自动化)",items:i.automations.map(z=>{const L=z.prompt||"",at=!!Ht[z.id],jt=L.length>Wh,Mt=L?at||!jt?L:L.slice(0,Wh)+"…":"(无内容)";return c.jsxs("div",{children:[c.jsxs("div",{className:"row-main",children:["[",z.status,z.due?"|到期":"",z.run_once?"|一次性":"","] ",z.name,"|每 ",z.interval_seconds," 秒",Ig(z.interval_seconds),"|上次 ",Ge(z.last_run_at)||"未运行","|下次 ",Ge(z.next_run_at),c.jsx("button",{onClick:()=>void i.loadAutomationDetail(z.id),children:"详情"}),z.status==="active"?c.jsxs(c.Fragment,{children:[" ",c.jsx("button",{onClick:()=>ct(z),children:"编辑"})," ",c.jsx("button",{onClick:()=>i.cancelAutomation(z.id),children:"取消"})]}):null]}),z.repo_url?c.jsxs("div",{className:"muted",children:["仓库:",z.repo_url]}):null,z.status==="cancelled"&&z.cancelled_at?c.jsxs("div",{className:"muted",children:["已取消于 ",Ge(z.cancelled_at)]}):null,c.jsxs("div",{className:"muted",style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:[Mt,jt?c.jsx("button",{onClick:()=>Q(vt=>({...vt,[z.id]:!vt[z.id]})),children:at?"收起":"展开"}):null]}),M===z.id?c.jsxs("div",{children:[c.jsx("input",{placeholder:"自动化名称",style:{width:"100%"},value:p,onChange:vt=>H(vt.target.value)}),c.jsx("textarea",{placeholder:"定时任务内容",value:Z,onChange:vt=>F(vt.target.value)}),c.jsxs(zt,{children:[c.jsx("input",{placeholder:"间隔秒数(最小 60,留空保持原值)",style:{width:"40%"},value:it,onChange:vt=>ft(vt.target.value)}),c.jsx("input",{placeholder:"仓库(可选,清空则清除)",style:{width:"40%"},value:Et,onChange:vt=>st(vt.target.value)}),c.jsx("button",{className:"primary",onClick:async()=>{const vt=i.automations.find(B=>B.id===M)||z;await i.updateAutomation(vt,p,Z,it,Et)&&q("")},children:"保存"}),c.jsx("button",{onClick:()=>q(""),children:"取消"})]})]}):null]},z.id)})}),i.autoDetailMsg&&!i.autoDetail?c.jsx("div",{className:"muted",children:i.autoDetailMsg}):null,i.autoDetail?c.jsx(Pg,{detail:i.autoDetail},i.autoDetail.id):null]})}function ep(){var g,A,O,b;const i=Gt(),r=i.triage,h=N=>{if(N){i.showDetail(N);try{window.location.hash="#view-tasks"}catch{}}},f=N=>{if(N){i.loadAutomationDetail(N);try{window.location.hash="#view-automation"}catch{}}};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:["到期自动化(",((g=r.counts)==null?void 0:g.automations_due)||0,")"]}),c.jsx(ul,{empty:"(无)",items:(r.automations_due||[]).map((N,M)=>c.jsx("span",{className:"row-main",children:N.id?c.jsxs("button",{onClick:()=>f(N.id),children:[N.name,"|每 ",N.interval_seconds," 秒"]}):c.jsxs(c.Fragment,{children:[N.name,"|每 ",N.interval_seconds," 秒"]})},M))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["失败/中断任务(",((A=r.counts)==null?void 0:A.failed_tasks)||0,")"]}),c.jsx(ul,{empty:"(无)",items:(r.failed_tasks||[]).map((N,M)=>c.jsx("span",{className:"row-main",children:c.jsxs("button",{onClick:()=>h(N.id),children:[se(N.id),"|",N.status,"|",String(N.prompt||"").slice(0,80)]})},M))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["待处理后续消息(",((O=r.counts)==null?void 0:O.pending_followups)||0,")"]}),c.jsx(ul,{empty:"(无)",items:(r.pending_followups||[]).map((N,M)=>c.jsx("span",{className:"row-main",children:c.jsxs("button",{onClick:()=>h(N.task_id),children:["任务 ",se(N.task_id),"|",String(N.body||"").slice(0,80)]})},M))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["最近自动化产出(",((b=r.counts)==null?void 0:b.recent_runs)||0,")"]}),c.jsx(ul,{empty:"(无)",items:(r.recent_runs||[]).map((N,M)=>c.jsxs("span",{className:"row-main",children:[se(N.automation_id),"|",N.status,N.task_id?c.jsxs(c.Fragment,{children:["|任务 ",c.jsx("button",{onClick:()=>h(N.task_id),children:se(N.task_id)})]}):"",N.task_status?"|"+N.task_status:""]},M))})]})]}):c.jsx("div",{className:"muted",children:"(先选项目)"})}function lp(){return c.jsx(Jt,{id:"nav-automations",title:"自动化",children:c.jsx(tp,{})})}function ap(){const i=Gt();return c.jsxs(Jt,{id:"nav-triage",title:"分诊收件箱",children:[c.jsx(zt,{children:c.jsx("button",{onClick:i.refreshTriage,children:"刷新分诊"})}),c.jsx(ep,{})]})}const So=["pending","in_progress","blocked","completed","cancelled"],Bc={pending:"待办",in_progress:"进行中",blocked:"受阻",completed:"已完成",cancelled:"已取消"},np=["low","medium","high"],$h={low:"低",medium:"中",high:"高"};function up(i){return i?new Date(i*1e3).toLocaleString():""}function ip(){const i=Gt(),[r,h]=d.useState([]),[f,g]=d.useState({}),[A,O]=d.useState(0),[b,N]=d.useState(""),[M,q]=d.useState(""),[p,H]=d.useState(""),[Z,F]=d.useState(""),[it,ft]=d.useState(""),[Et,st]=d.useState(""),[Ht,Q]=d.useState("medium"),[ct,z]=d.useState(""),[L,at]=d.useState(""),jt=d.useCallback(async()=>{if(!i.projectId){h([]),g({}),O(0);return}try{let B=$.projectTodos(i.projectId);const dt=new URLSearchParams;p&&dt.set("status",p),Z.trim()&&dt.set("q",Z.trim());const Ct=dt.toString();Ct&&(B+="?"+Ct);const V=await I(B);h(V.todos||[]),g(V.counts||{}),O(V.total||0),q("")}catch(B){q("待办加载失败:"+((B==null?void 0:B.message)||String(B)))}},[i.projectId,p,Z]);d.useEffect(()=>{jt()},[jt]);const Mt=async()=>{if(!i.projectId){N("先选项目");return}if(!it.trim()){N("标题必填");return}N("");try{const B={title:it.trim(),description:Et,priority:Ht};ct.trim()&&(B.task_id=ct.trim()),L&&(B.due_at=Math.floor(new Date(L).getTime()/1e3)),await I($.projectTodos(i.projectId),"POST",B),ft(""),st(""),z(""),at(""),N("已创建"),await jt()}catch(B){N("创建失败:"+((B==null?void 0:B.message)||String(B)))}},vt=async(B,dt)=>{if(i.projectId)try{await I($.projectTodo(i.projectId,B.id),"PATCH",dt),await jt()}catch(Ct){q("待办更新失败:"+((Ct==null?void 0:Ct.message)||String(Ct)))}},bt=async B=>{if(i.projectId&&window.confirm("删除待办“"+B.title+"”?删除后不可恢复(取消请改状态为已取消)。"))try{await I($.projectTodo(i.projectId,B.id),"DELETE"),await jt()}catch(dt){q("待办删除失败:"+((dt==null?void 0:dt.message)||String(dt)))}};return c.jsxs(Jt,{id:"nav-todos",title:"项目待办",children:[c.jsx("div",{className:"muted",children:i.projectId?"共 "+A+" 项 | "+So.map(B=>Bc[B]+" "+(f[B]||0)).join(" | "):"先选项目"}),M?c.jsx("div",{className:"err",children:M}):null,c.jsxs(zt,{children:[c.jsxs("select",{"aria-label":"状态筛选",value:p,onChange:B=>H(B.target.value),children:[c.jsx("option",{value:"",children:"状态:全部"}),So.map(B=>c.jsx("option",{value:B,children:Bc[B]},B))]}),c.jsx("input",{type:"text",placeholder:"搜索标题/说明",value:Z,onChange:B=>F(B.target.value)}),c.jsx("button",{onClick:()=>void jt(),children:"刷新"})]}),c.jsx("input",{type:"text",placeholder:"待办标题(必填)",style:{width:"100%"},value:it,onChange:B=>ft(B.target.value)}),c.jsx("textarea",{placeholder:"说明(可选)",value:Et,onChange:B=>st(B.target.value)}),c.jsxs(zt,{children:[c.jsx("select",{"aria-label":"优先级",value:Ht,onChange:B=>Q(B.target.value),children:np.map(B=>c.jsxs("option",{value:B,children:["优先级:",$h[B]]},B))}),c.jsx("input",{type:"text",placeholder:"关联任务 id(可选)",value:ct,onChange:B=>z(B.target.value)}),c.jsx("input",{type:"datetime-local","aria-label":"截止时间",value:L,onChange:B=>at(B.target.value)}),c.jsx("button",{className:"primary",onClick:()=>void Mt(),children:"新建待办"}),c.jsx("span",{className:"muted",children:b})]}),c.jsx(ul,{empty:i.projectId?"(暂无待办)":"(先选项目)",items:r.map(B=>c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:B.title})," | ",c.jsx("span",{className:"st-"+B.status,children:Bc[B.status]||B.status})," | 优先级 "+($h[B.priority]||B.priority),B.task_id?" | 任务 "+se(B.task_id):"",B.due_at?" | 截止 "+up(B.due_at):"",B.completed_at?" | 完成于 "+new Date(B.completed_at*1e3).toLocaleString():"",B.description?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsx("span",{className:"muted",children:B.description})]}):null,c.jsx("br",{}),c.jsx("select",{"aria-label":"改状态",value:B.status,onChange:dt=>void vt(B,{status:dt.target.value}),children:So.map(dt=>c.jsx("option",{value:dt,children:Bc[dt]},dt))})," ",c.jsx("button",{onClick:()=>void vt(B,{status:"completed"}),children:"完成"})," ",c.jsx("button",{onClick:()=>void bt(B),children:"删除"})]},B.id))}),c.jsx("div",{className:"muted",children:"状态与 Muse 待办同款五态;完成时服务端记完成时间,改回其它状态时清掉;已取消是软关闭,删除才是硬删除。"})]})}const _o=[["tasks","任务工作台","任务"],["todos","项目待办","任务"],["resources","环境与 Workers","管理"],["automation","技能与自动化","管理"],["admin","系统与 Key","管理"]],cp=Object.fromEntries(_o.map(([i,r])=>[i,r]));function Ih(){const i=window.location.hash.replace("#view-","");return _o.some(([r])=>r===i)?i:"tasks"}function sp({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}),_o.filter(([,,f])=>f===h).map(([f,g])=>c.jsx("button",{type:"button",className:i===f?"active":"",onClick:()=>r(f),children:g},f))]},h))}),c.jsx("div",{id:"nav-projects",className:"shell-project-nav",children:c.jsx(Lg,{})}),c.jsxs("div",{className:"shell-stats",children:[c.jsx("div",{className:"shell-stats-label",children:"概览"}),c.jsx(vg,{})]})]})}function rp(){const i=Gt();return c.jsx("span",{className:i.keySaved?"dash-status ok":"dash-status off",children:i.keySaved?"● 已连接":"○ 未连接"})}function op(){const i=hg();fg(b=>i.refreshAll(b),1e4),dg(()=>i.refreshSlow(),6e4);const[r,h]=d.useState(()=>Ih()),[f,g]=d.useState(!0),A=b=>{h(b);try{window.history.replaceState(null,"","#view-"+b)}catch{}};d.useEffect(()=>{const b=()=>h(Ih());return window.addEventListener("hashchange",b),()=>window.removeEventListener("hashchange",b)},[]);const O=()=>{A("tasks"),g(!0),setTimeout(()=>{var b;return(b=document.getElementById("sec-dispatch"))==null?void 0:b.scrollIntoView({behavior:"smooth"})},0)};return c.jsx(tv.Provider,{value:i,children:c.jsxs("div",{className:"shell",children:[c.jsx(sp,{view:r,setView:A}),c.jsxs("div",{className:"shell-main",children:[c.jsxs("header",{className:"shell-header",children:[c.jsx("h1",{children:cp[r]}),c.jsx(rp,{}),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(bg,{onNew:O})]}),c.jsxs("section",{className:"workspace-center",children:[c.jsx("div",{className:"composer-toggle",children:c.jsx("button",{type:"button",onClick:()=>g(!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(Gg,{})})]}):c.jsx("main",{className:"page",children:r==="todos"?c.jsx(c.Fragment,{children:c.jsx(ip,{})}):r==="resources"?c.jsxs(c.Fragment,{children:[c.jsx(Zg,{}),c.jsx(Kg,{}),c.jsx(Jg,{}),c.jsx(Fg,{})]}):r==="automation"?c.jsxs(c.Fragment,{children:[c.jsx(Wg,{}),c.jsx($g,{}),c.jsx(lp,{}),c.jsx(ap,{})]}):c.jsxs(c.Fragment,{children:[c.jsx(Vg,{}),c.jsx(Qg,{})]})})]})]})})}Py.createRoot(document.getElementById("root")).render(c.jsx(Ly.StrictMode,{children:c.jsx(op,{})}));
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 be3ff87..9ea598d 100644
--- a/cloud/worker.py
+++ b/cloud/worker.py
@@ -133,7 +133,6 @@ interrupted -> interrupted,is_defect 恒 False);同一派发内
import argparse
import hashlib
-import inspect
import json
import os
from datetime import datetime, timezone
@@ -250,6 +249,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
@@ -428,27 +430,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):
@@ -790,16 +778,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 定案,
@@ -1073,36 +1057,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 由调用方管)。
@@ -3617,61 +3576,22 @@ class Worker:
return cleaned
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- 容器。
@@ -5976,10 +5896,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 描述;回环地址限制下浏览器任务无法代劳)。