feat(chv): 架构/可维护性/前端只读侧推进——生产实验分离、配置片段化、VM 只读展示

架构:WarmPool 从 cloud/chv/pool.py 移到 experimental_pool.py,
生产残留清理彻底分文件到 cloud/chv/orphans.py(facade 直接读
registry 文件清理,不构造池、不碰 prewarm/acquire/release);
cloud/chv 与 cloud/sandbox 不再导出 WarmPool,旧路径一律
ImportError(CHV 未合并、无生产用户,兼容保留已无保留理由);
worker 改走 orphans facade,不再持有 _warm_pool;#215 对齐从
接口形状推进到 REPLACEMENT_POINTS 替换清单(R1/R2 可机械替换、
R3 永不替换,#215 模块当前不存在由测试钉住)。

可维护性:build_chv_config 拆成 base/ssh/network/quota/disk/
timeout 六片段(组装只做 dict 合并,键冲突即报错;38 字段集
与取值不变,单项非法报错文案逐字一致);ADR-12 模块职责图
与禁 import 清单(AST 钉住);cloud/chv/ 公开 API 全量类型
标注、无 Any 逃逸,并修正三处旧标注错误;死代码扫一遍
(删实验池已无调用的 _quarantine 委托与未用 _LOGGER)。

前端(只读侧,不做控制按钮):chv.ts 新增纯逻辑谓词与文案
(执行器精确匹配、附件守卫、VM 状态文案、列表徽标);列表
行新增 VM 已报告死亡徽标;详情对全部 CHV 任务显示 VM 行
(已报告死亡/未报告死亡,不猜 live);WorkersSection 与成本
卡沿用已有数据,不新开池管理/成本页;服务端未新增第二个
生命周期字段(持久化事实只有三项,chv_vm_dead 已是完整派生),
只把派生抽成 _chv_vm_dead_from_row 并补 goal 回落形态测试。
This commit is contained in:
host committed 2026-11-08 09:44:34 +00:00
1 parent 56a6241bbe
commit 4fa94edd9b
30 files changed
+1252 -320

No files matched your search

+23 -8
View File
@@ -1,18 +1,33 @@
"""Cloud Hypervisor 执行器包(从 cloud/sandbox.py 巨类拆出)。
公开名与 #216 一致,cloud/sandbox.py 会 re-export 它们,
保证旧导入路径可用:
公开名与 #216 一致,cloud/sandbox.py 会 re-export 生产
接口,保证旧导入路径可用——但 WarmPool 除外(本轨破环
性清理,见下):
- CloudHypervisorExecutor:执行器本体(executor.py)
- WarmPool:预热池 + registry 持久化与孤儿清理(pool.py)
- ChvFailure:分类失败(errors.py)
- reconcile_orphans_for_executor:生产残留清理唯一入口
(orphans.py,只读 registry 文件做清理,不构造池)
子模块分工见 executor.py 头注释。本包顶层 import 不触
cloud.sandbox(执行器方法内按需 import 共享 helper),
所以任何导入顺序都不会循环。
破环性清理(feat-chv-arch-maint-fe,未合并前即生效,
合并时按 PR 描述的清单检查调用方):WarmPool 已从
cloud/chv/pool.py 移到 cloud/chv/experimental_pool.py,
本包与 cloud/sandbox.py 不再导出它。旧路径
`from cloud.chv import WarmPool`、
`from cloud.chv.pool import WarmPool`、
`from cloud.sandbox import WarmPool` 一律 ImportError;
实验用法只走
`from cloud.chv.experimental_pool import WarmPool`。
清理理由:CHV 尚未合并、尚无生产用户,兼容保留只是
“怕断 import”,留着反而让生产代码有路可走进实验接口。
子模块分工见 executor.py 头注释与 docs/chv-architecture.md
ADR-12。本包顶层 import 不触 cloud.sandbox(执行器方法
内按需 import 共享 helper),所以任何导入顺序都不会循环。
"""
from cloud.chv.errors import ChvFailure
from cloud.chv.executor import CloudHypervisorExecutor
from cloud.chv.pool import WarmPool
from cloud.chv.orphans import reconcile_orphans_for_executor
__all__ = ["ChvFailure", "CloudHypervisorExecutor", "WarmPool"]
__all__ = ["ChvFailure", "CloudHypervisorExecutor",
"reconcile_orphans_for_executor"]
+1 -1
View File
@@ -128,7 +128,7 @@ def _unix_socket_api(socket_path, method, path, payload=None):
return _parse_response(header, body)
def normalize_api_result(result) -> tuple:
def normalize_api_result(result: dict) -> tuple:
"""API 调用归一化:fake api_client 可返回 (status, body)
或 {"status":..., "body":...} 或带 .status 的对象,本
函数统一成 (status:int, body)。"""
+57 -2
View File
@@ -23,12 +23,67 @@ cloud/gates.py、cloud/quota.py、cloud/sandbox_policy.py。
不提供,测试钉住“CHV 不 import 它们”。
本模块只依赖标准库,是叶子模块,不 import cloud.sandbox。
合并后替换清单(REPLACEMENT_POINTS,本轨从“接口形状”
推进到可机械执行的清单;#215 未合并前一律不 import
cloud.gates / cloud.sandbox_policy / cloud.timeouts /
cloud.quota,测试钉住这些模块在本基线里不存在):
- R1 gates.resolve_inside:唯一生产调用方是
hardening._is_within(快照 URL 白名单)。合并后两步:
① hardening 改为 `from cloud.gates import resolve_inside`;
② 删本模块 resolve_inside。语义必须逐字一致,改完
重跑快照白名单测试(逃逸/符号链接/绝对路径三例)。
- R2 sandbox_policy.tmpfs_isolated:形状占位,当前无
生产调用方(只有测试)。合并后一步:删本模块
tmpfs_isolated;将来有调用方时直接 import #215 版,
不得再本地复刻。CHV 语义是“一律不隔离(fail-closed)”,
若 #215 版对 cloud-hypervisor 返回 True,替换即改行为,
必须先停下来核对,不得机械替换。
- R3 timeouts / quota:永不替换。#215 的 timeouts 是
审批/队列超时(None/0 = 不过期),quota 是按月美元
配额;CHV 的 api/ready 超时是秒数(0 无效)、配额是
VM 数/磁盘 GB/快照数,单位与语义都不同。清单里明写
“不替换”,防止合并时有人按名字硬套。
"""
import os
def resolve_inside(base, rel):
# 合并后替换清单:每项的 local/replacement/test 字段由测试
# 逐项核对(local 函数真实存在、#215 模块当前不存在、
# test 命名的测试类真实存在),清单本身不得漂移。
REPLACEMENT_POINTS = (
{
"id": "R1",
"upstream": "cloud.gates.resolve_inside",
"local": "cloud.chv.compat215.resolve_inside",
"caller": "cloud.chv.hardening._is_within",
"replacement": "hardening 改为 from cloud.gates import "
"resolve_inside,并删除本地 resolve_inside",
"test": "Compat215Test + 快照白名单测试",
},
{
"id": "R2",
"upstream": "cloud.sandbox_policy.tmpfs_isolated",
"local": "cloud.chv.compat215.tmpfs_isolated",
"caller": None,
"replacement": "删除本地 tmpfs_isolated;有调用方时"
"直接 import #215 版(先核对 CHV 一律 False 语义)",
"test": "Compat215Test.test_tmpfs_isolated_chv_and_unknown_"
"fail_closed",
},
{
"id": "R3",
"upstream": "cloud.timeouts / cloud.quota",
"local": None,
"caller": None,
"replacement": None,
"test": "Compat215Test.test_no_timeout_or_quota_map",
},
)
def resolve_inside(base: str, rel: str) -> str | None:
"""目录门:base 内的相对路径安全解析,越界回 None。
空串回 base 本身;绝对路径、.. 逃逸、符号链接逃逸、
@@ -48,7 +103,7 @@ def resolve_inside(base, rel):
return target
def tmpfs_isolated(executor_kind, path):
def tmpfs_isolated(executor_kind: str, path: str) -> tuple[bool, str]:
"""某执行器下某路径是否走 tmpfs 隔离:(isolated, reason)。
cloud-hypervisor:客机 /tmp 落在客机磁盘上,不走宿主
+102 -84
View File
@@ -15,20 +15,19 @@ cloud.sandbox,避免循环导入)。
直接 ValueError。
与 #215 的关系(只列清单,不复制模块):#215 未合并,
本轨不假装复用它。#215 合并后应再复用的点:超时类
(api_timeout/ready_timeout/ready_interval/task_timeout →
timeouts 模块)、门禁类(preflight fail-fast、容量门、磁盘
配额门、egress fail-closed → gates 模块)、配额类
(max_vms、disk_gb、任务成本走 worker quota → quota 模块)、
隔离类(单任务单 VM、磁盘 copy 模式、egress 代理、SSH 钉死
→ sandbox_policy 模块)。见 PR 描述的复用清单。
本轨不假装复用它。上一轨 ADR-7 的泛化映射已被证伪
(#215 的 timeouts 是审批/队列超时、quota 是按月美元
配额,与 CHV 的秒数超时、VM 数/磁盘 GB 单位语义不同,
硬套会改行为),真实映射只有两处,见
cloud/chv/compat215.py 的 REPLACEMENT_POINTS 清单
(合并时按清单机械替换,不在本轨硬 import 未合并模块)。
"""
import os
import re
def _parse_memory_bytes(value):
def _parse_memory_bytes(value: str) -> int:
"""内存字符串转字节:2G/512M/1024K/2048(无单位=字节)。
非法值 ValueError,绝不静默按默认算。"""
m = re.match(r"^\s*(\d+)\s*([KMGkmg]?)\s*[Bb]?\s*$",
@@ -45,15 +44,44 @@ class ChvConfig:
执行器 __init__ 直接把字段抄到 self 上,旧测试读属性
的方式全部不变。"""
def __init__(self, **fields):
def __init__(self, **fields: object) -> None:
for key, value in fields.items():
setattr(self, key, value)
def _base_fragment(params: dict, env: dict) -> dict:
"""身份/资源基底片段:二进制、内核、根盘、CPU/内存、
state_dir、KVM/cpuinfo 路径、ch-remote、模型。无校验,
取值口径与旧 build_chv_config 内联 base 逐字一致。"""
return {
"binary": params.get("binary") or env.get("CHV_BINARY")
or "cloud-hypervisor",
"kernel": params.get("kernel") or env.get("CHV_KERNEL"),
"rootfs": params.get("rootfs") or params.get("image")
or env.get("CHV_ROOTFS") or env.get("CHV_IMAGE"),
"cpus": str(params.get("cpus") or env.get("CHV_CPU") or "2"),
"memory": params.get("memory") or env.get("CHV_MEM") or "2G",
"state_dir": params.get("state_dir")
or env.get("CHV_STATE_DIR")
or os.path.join(os.path.expanduser("~"),
".local", "share", "codex-app-server", "chv-vms"),
"kvm_path": params.get("kvm_path")
if params.get("kvm_path") is not None else "/dev/kvm",
"cpuinfo_path": params.get("cpuinfo_path")
if params.get("cpuinfo_path") is not None else "/proc/cpuinfo",
"ch_remote": params.get("ch_remote") or env.get("CHV_REMOTE")
or "ch-remote",
"model": params.get("model"),
}
def _ssh_fragment(params: dict, env: dict) -> dict:
"""SSH/命令面配置片段:返回 dict,供 build_chv_config 组装。
错误文案与原内联代码逐字一致,校验时机不变(构造时)。"""
"""SSH/命令面片段:密钥、用户、端口、host key 模式、
客内 host.py 路径、内核命令行。错误文案与原内联代码
逐字一致,校验时机不变(构造时)。网络字段(net、
ssh_host、egress)已移到 _network_fragment,不在本
片段,避免“SSH”片段名不副实。"""
ssh_key = params.get("ssh_key") or env.get("CHV_SSH_KEY")
if params.get("ssh_user") is not None \
and params.get("ssh_user") == "":
@@ -82,19 +110,45 @@ def _ssh_fragment(params: dict, env: dict) -> dict:
"guest_host_py": params.get("guest_host_py") or "/app/host.py",
"cmdline": params.get("cmdline") or env.get("CHV_CMDLINE")
or "console=hvc0 root=/dev/vda1 rw",
}
def _network_fragment(params: dict, env: dict) -> dict:
"""网络片段:tap/桥接描述、SSH 目标、端口转发开关、
egress 代理与直连开关。无数值校验(校验在 preflight
的 net/egress 门里,不在配置解析层)。egress 从旧
quota 片段移到这里:它是网络出口策略,不是 VM 配额。"""
return {
"net": params.get("net") or env.get("CHV_NET"),
"net_json": params.get("net_json") or env.get("CHV_NET_JSON"),
"ssh_host": params.get("ssh_host") or env.get("CHV_SSH_HOST"),
"ssh_forward": env.get("CHV_SSH_FORWARD") == "1",
"egress_proxy": env.get("CHV_EGRESS_PROXY"),
"allow_direct_egress": env.get("CHV_ALLOW_DIRECT_EGRESS")
== "1",
}
def _quota_fragment(env: dict) -> dict:
"""配额/磁盘/快照配置片段:返回 dict,错误文案逐字一致。"""
"""配额片段:仅容量数——最大并发 VM 数与预热池大小。
磁盘/快照数上限不在这里,它们是磁盘行为,见
_disk_fragment。校验文案与旧代码逐字一致。"""
try:
max_vms = 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)
except ValueError:
raise ValueError("CHV_WARM_POOL_SIZE 必须是整数")
return {"max_vms": max_vms, "warm_pool_size": warm_pool_size}
def _disk_fragment(env: dict) -> dict:
"""磁盘片段:磁盘模式/大小、单租户 reuse 实验开关、
快照数/总量/保留期/目录/远端开关与快照 URL。校验
文案与旧 quota 片段逐字一致(disk_mode 白名单、
disk_gb > 0、快照数非负)。"""
disk_mode = env.get("CHV_DISK_MODE") or "copy"
if disk_mode not in ("copy", "reuse"):
raise ValueError(
@@ -119,8 +173,7 @@ def _quota_fragment(env: dict) -> dict:
or snapshot_retention_days < 0:
raise ValueError("CHV_SNAPSHOT_* 不能为负数")
return {
"max_vms": max_vms, "disk_mode": disk_mode,
"disk_gb": disk_gb,
"disk_mode": disk_mode, "disk_gb": disk_gb,
"reuse_experiment": env.get(
"CHV_REUSE_SINGLE_TENANT_EXPERIMENT") == "1",
"single_tenant_project": env.get("CHV_SINGLE_TENANT_PROJECT"),
@@ -130,21 +183,17 @@ def _quota_fragment(env: dict) -> dict:
"snapshot_dir": env.get("CHV_SNAPSHOT_DIR"),
"allow_remote_snapshot": env.get("CHV_ALLOW_REMOTE_SNAPSHOT")
== "1",
"egress_proxy": env.get("CHV_EGRESS_PROXY"),
"allow_direct_egress": env.get("CHV_ALLOW_DIRECT_EGRESS")
== "1",
"snapshot_url": env.get("CHV_SNAPSHOT_URL"),
}
def _timeout_fragment(params: dict, env: dict) -> dict:
"""超时/池/快照 URL 配置片段:返回 dict,错误文案逐字一致。"""
"""超时片段:任务超时(None = 不限,旧语义)、VMM API
超时、客机就绪超时/间隔。ready_interval <= 0 时回落
5 秒(旧语义,非校验失败)。校验文案逐字一致。"""
task_timeout_raw = env.get("CHV_TASK_TIMEOUT")
task_timeout = float(task_timeout_raw) \
if task_timeout_raw else None
try:
warm_pool_size = int(env.get("CHV_WARM_POOL_SIZE") or 0)
except ValueError:
raise ValueError("CHV_WARM_POOL_SIZE 必须是整数")
try:
api_timeout = float(
params.get("api_timeout")
@@ -163,73 +212,42 @@ def _timeout_fragment(params: dict, env: dict) -> dict:
if ready_interval <= 0:
ready_interval = 5
return {
"task_timeout": task_timeout, "warm_pool_size": warm_pool_size,
"api_timeout": api_timeout, "ready_timeout": ready_timeout,
"task_timeout": task_timeout, "api_timeout": api_timeout,
"ready_timeout": ready_timeout,
"ready_interval": ready_interval,
"snapshot_url": env.get("CHV_SNAPSHOT_URL"),
}
def _assemble_chv_config(base: dict, ssh: dict, quota: dict,
timeouts: dict):
"""把基础字段 + SSH/配额/超时三个片段组装成 ChvConfig。
def _assemble_chv_config(fragments: list) -> "ChvConfig":
"""把各片段 dict 合并成 ChvConfig(纯合并,不改值)。
纯移动:字段名与取值逐字不变,build_chv_config 只解析。"""
return ChvConfig(
binary=base["binary"], kernel=base["kernel"],
rootfs=base["rootfs"], cpus=base["cpus"],
memory=base["memory"], state_dir=base["state_dir"],
ssh_key=ssh["ssh_key"], ssh_user=ssh["ssh_user"],
ssh_port=ssh["ssh_port"],
guest_host_py=ssh["guest_host_py"], cmdline=ssh["cmdline"],
net=ssh["net"], net_json=ssh["net_json"],
kvm_path=base["kvm_path"], cpuinfo_path=base["cpuinfo_path"],
ch_remote=base["ch_remote"], model=base["model"],
max_vms=quota["max_vms"], disk_mode=quota["disk_mode"],
disk_gb=quota["disk_gb"], ssh_host=ssh["ssh_host"],
ssh_forward=ssh["ssh_forward"],
reuse_experiment=quota["reuse_experiment"],
egress_proxy=quota["egress_proxy"],
allow_direct_egress=quota["allow_direct_egress"],
host_key_mode=ssh["host_key_mode"],
task_timeout=timeouts["task_timeout"],
snapshot_url=timeouts["snapshot_url"],
warm_pool_size=timeouts["warm_pool_size"],
api_timeout=timeouts["api_timeout"],
ready_timeout=timeouts["ready_timeout"],
ready_interval=timeouts["ready_interval"],
single_tenant_project=quota["single_tenant_project"],
snapshot_max_count=quota["snapshot_max_count"],
snapshot_max_total_gb=quota["snapshot_max_total_gb"],
snapshot_retention_days=quota["snapshot_retention_days"],
snapshot_dir=quota["snapshot_dir"],
allow_remote_snapshot=quota["allow_remote_snapshot"])
片段键集必须互不相交——键冲突是编程错误,直接
ValueError,绝不让后一片段静默覆盖前一片段的值。"""
fields: dict = {}
for fragment in fragments:
for key, value in fragment.items():
if key in fields:
raise ValueError(
"CHV 配置片段键冲突:%s" % key)
fields[key] = value
return ChvConfig(**fields)
def build_chv_config(params: dict, env: dict):
"""把显式构造参数与环境变量解析成 ChvConfig(见
_assemble_chv_config 的组装段;片段化与校验顺序不变)。"""
def build_chv_config(params: dict, env: dict) -> "ChvConfig":
"""把显式构造参数与环境变量解析成 ChvConfig。
片段调用顺序即旧校验顺序(SSH → 配额 → 磁盘 →
超时),只有一处可观测的顺序变化:warm_pool_size
从旧超时片段移到配额片段,因此“CHV_WARM_POOL_SIZE
非法 + 磁盘非法同时出现”时先报前者;单项非法时
报错与旧代码逐字一致。字段集合与取值与旧代码一致,
由测试的字段集断言钉住。"""
env = env if env is not None else {}
base = {
"binary": params.get("binary") or env.get("CHV_BINARY")
or "cloud-hypervisor",
"kernel": params.get("kernel") or env.get("CHV_KERNEL"),
"rootfs": params.get("rootfs") or params.get("image")
or env.get("CHV_ROOTFS") or env.get("CHV_IMAGE"),
"cpus": str(params.get("cpus") or env.get("CHV_CPU") or "2"),
"memory": params.get("memory") or env.get("CHV_MEM") or "2G",
"state_dir": params.get("state_dir")
or env.get("CHV_STATE_DIR")
or os.path.join(os.path.expanduser("~"),
".local", "share", "codex-app-server", "chv-vms"),
"kvm_path": params.get("kvm_path")
if params.get("kvm_path") is not None else "/dev/kvm",
"cpuinfo_path": params.get("cpuinfo_path")
if params.get("cpuinfo_path") is not None else "/proc/cpuinfo",
"ch_remote": params.get("ch_remote") or env.get("CHV_REMOTE")
or "ch-remote",
"model": params.get("model"),
}
return _assemble_chv_config(
base, _ssh_fragment(params, env), _quota_fragment(env),
_timeout_fragment(params, env))
return _assemble_chv_config([
_base_fragment(params, env),
_ssh_fragment(params, env),
_quota_fragment(env),
_disk_fragment(env),
_network_fragment(params, env),
_timeout_fragment(params, env),
])
+8 -5
View File
@@ -33,7 +33,7 @@ def _dir_total_bytes(path):
return total
def check_disk_quota(rootfs: str, disk_gb: float, state_dir=None) -> None:
def check_disk_quota(rootfs: str, disk_gb: float, state_dir: str | None = None) -> None:
"""磁盘配额门(feat-chv-fix 口径):
- disk_gb 未设时不检查;
@@ -162,8 +162,10 @@ def _save_snapshots(state_dir, vm_name, entries):
os.replace(tmp_path, path)
def prune_snapshots(state_dir, vm_name, retention_days,
now=None, delete_files=True):
def prune_snapshots(state_dir: str, vm_name: str,
retention_days: float | None,
now: float | None = None,
delete_files: bool = True) -> tuple[list, list]:
"""按保留期剪枝:删过期条目;delete_files 时同时删
对应的 file:// 快照文件(只删登记里记的 path,不猜
文件名)。返回(保留条目,剪掉条目)。"""
@@ -189,7 +191,8 @@ def prune_snapshots(state_dir, vm_name, retention_days,
def check_snapshot_limits(state_dir: str, vm_name: str, max_count: int,
max_total_gb, retention_days):
max_total_gb: float,
retention_days: float | None) -> list:
"""快照上限预检(snapshot_vm 调 ch-remote 之前调):
先按保留期剪枝,再查数量(已有数 + 本次 > 上限即
snapshot_quota_exceeded)与总量(已有总量已达上限即
@@ -218,7 +221,7 @@ def check_snapshot_limits(state_dir: str, vm_name: str, max_count: int,
return kept
def record_snapshot(state_dir: str, vm_name: str, snapshot_url: str, max_total_gb: float) -> dict:
def record_snapshot(state_dir: str, vm_name: str, snapshot_url: str, max_total_gb: float) -> tuple[list, list]:
"""快照成功后登记;file:// 目标记实际大小。登记后若
总量超上限,按最旧优先删登记外的旧快照文件(总量硬
帽,删动作由调用方审计——本函数返回被删的旧条目)。
+1 -1
View File
@@ -7,6 +7,6 @@ class ChvFailure(RuntimeError):
审计与失败分类用,绝不笼统只写 failed。kind 取值见
CloudHypervisorExecutor.preflight / wake_vm 的注释。"""
def __init__(self, kind, message):
def __init__(self, kind: str, message: str) -> None:
super().__init__("chv_failure:%s: %s" % (kind, message))
self.kind = kind
+66 -50
View File
@@ -7,7 +7,8 @@
- ssh_transport.py:SSH 命令构造、每 VM known_hosts 钉死、
ssh-keyscan 首次捕获(TOFU 边界见该模块)
- disk.py:每任务磁盘供给与磁盘配额门
- pool.py:WarmPool 预热池 + registry 持久化与孤儿清理
- orphans.py:生产残留清理唯一入口(registry 文件直读清理)
- experimental_pool.py:WarmPool 预热池(纯实验,生产禁 import)
- executor.py(本模块):执行器类本体——生命周期方法
(wake/pause/resume/snapshot/restore/hibernate/stop/
cleanup)与任务执行方法(dispatch/run/goal/execute)。
@@ -154,11 +155,11 @@ class CloudHypervisorExecutor(Executor):
- 单任务单 VM:wake → 跑 → finally 关,同一任务不共享 VM;
每任务磁盘 copy 模式独立,租户无共享路径、无东西向互访;
VM 内 root 只能影响本 VM。
- 快速启动:WarmPool(cloud/chv/pool.py)预热干净快照 VM,
- 快速启动:WarmPool(cloud/chv/experimental_pool.py)预热干净快照 VM,
acquire 消耗、release 销毁,池空回落冷启动,绝不降级;
池状态另落 state_dir/warm-pool.json registry,worker
重启后载入的条目一律当孤儿、只能 reconcile_orphans()
清理,绝不重新发给任务(见 pool.py)。
清理,绝不重新发给任务(见 orphans.py)。
- 长任务中途死亡:_run_with_vm 在客内命令抛传输异常、
或非 goal 结果已 failed 且 VM 登记进程已死时,改判
interrupted(断点可续跑)而不是笼统 failed;VM 还活
@@ -245,17 +246,30 @@ class CloudHypervisorExecutor(Executor):
开启嵌套虚拟化的实例,那是用户侧开通决策,不在本轨内。
"""
def __init__(self, binary=None, kernel=None, rootfs=None,
image=None, cpus=None, memory=None, state_dir=None,
runner=None, launcher=None, ssh_key=None,
ssh_user=None, ssh_port=None,
guest_host_py="/app/host.py", cmdline=None,
net=None, net_json=None, ssh_host=None,
kvm_path="/dev/kvm",
cpuinfo_path="/proc/cpuinfo", env=None,
model=None, ch_remote=None, api_client=None,
sleeper=None, api_timeout=None, ready_timeout=None,
ready_interval=None, host_key_mode=None):
def __init__(self, binary: str | None = None,
kernel: str | None = None,
rootfs: str | None = None,
image: str | None = None,
cpus: str | int | None = None,
memory: str | None = None,
state_dir: str | None = None,
runner: object = None, launcher: object = None,
ssh_key: str | None = None,
ssh_user: str | None = None,
ssh_port: int | str | None = None,
guest_host_py: str = "/app/host.py",
cmdline: str | None = None,
net: str | None = None, net_json: str | None = None,
ssh_host: str | None = None,
kvm_path: str = "/dev/kvm",
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,
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
# 配置解析已拆到 cloud/chv/config.py(参数优先于环境
# 变量,默认值与 #216 一致;显式空用户/越界端口的收紧
@@ -326,7 +340,7 @@ class CloudHypervisorExecutor(Executor):
return re.sub(r"[^A-Za-z0-9_.-]", "-",
str(task.get("id")))
def vm_name(self, task_or_name):
def vm_name(self, task_or_name: dict | str) -> str:
"""VM 名:chv-<安全字符任务名>(与 Docker 的 codex- 同款
派生,同一任务每次算出同一名字,清理才能按名找)。
幂等:传入的已是 chv- 前缀名时不再二次加前缀(清理
@@ -339,7 +353,7 @@ class CloudHypervisorExecutor(Executor):
return safe
return "chv-" + safe
def api_socket(self, task_or_name):
def api_socket(self, task_or_name: dict | str) -> str:
"""该 VM 的 api-socket 路径:state_dir/<vm 名>.sock。"""
return os.path.join(self.state_dir,
self.vm_name(task_or_name) + ".sock")
@@ -559,7 +573,7 @@ class CloudHypervisorExecutor(Executor):
validate_net_entries(_entries, [])
except ChvFailure as exc:
missing.append((exc.kind, str(exc)))
def preflight(self):
def preflight(self) -> bool:
"""预检(缺一不可则 fail-fast):收集全部缺失项一次报出
具体缺哪一项,绝不笼统报错;失败为 ChvFailure(带 kind,
供审计与失败分类用),绝不回落 Docker/Local。
@@ -651,7 +665,7 @@ class CloudHypervisorExecutor(Executor):
cmd += ["--net", self.net]
return cmd
def known_hosts_path(self, task_or_name):
def known_hosts_path(self, task_or_name: dict | str) -> str:
"""该 VM 的 known_hosts 文件:state_dir/<vm>/known_hosts
(与每任务磁盘同目录,见 ssh_transport.py 的钉死模型)。"""
return _known_hosts_path(self.state_dir,
@@ -671,7 +685,7 @@ class CloudHypervisorExecutor(Executor):
return str(entry["ip"])
return None
def resolve_ssh_host(self):
def resolve_ssh_host(self) -> str:
"""SSH 目标解析(feat-chv-fix,P0-4 选定方案:客机 IP):
显式 CHV_SSH_HOST > net 静态客机 IP > 显式转发宣称下
@@ -690,7 +704,7 @@ class CloudHypervisorExecutor(Executor):
"里给 net 条目配 ip(静态 tap 客机 IP)、或显式设 "
"CHV_SSH_FORWARD=1 宣称宿主 NAT 已转发回环端口")
def provision_host_key(self, task, secret_env=None):
def provision_host_key(self, task: dict, secret_env: dict | None = None) -> dict:
"""按 VM 预置 host key(补缺口轨强方案,见 hostkeys.py):
启动前为该 VM 生成一次性 ed25519 host key 对,公钥
立即写进该 VM 的 known_hosts(钉死),私钥只写进
@@ -741,7 +755,7 @@ class CloudHypervisorExecutor(Executor):
return {"paths": paths, "seed_dir": seed_dir,
"known_hosts": paths["known_hosts"]}
def capture_host_key(self, task):
def capture_host_key(self, task: dict) -> bool:
"""首次捕获客机 host key(TOFU,弱回落,边界见
ssh_transport.py 与 hostkeys.py):ssh-keyscan 经本机
回环端口抓 key 行,写入该 VM 的 known_hosts 文件
@@ -775,7 +789,7 @@ class CloudHypervisorExecutor(Executor):
"vm=%s host=%s" % (self.vm_name(task), ssh_host))
return True
def build_ssh_command(self, task, inner_cmd, extra_env=None):
def build_ssh_command(self, task: dict, inner_cmd: list, extra_env: dict | None = None) -> list:
"""构造 SSH 命令:ssh -p <端口> -i <key 文件> + 钉死选项
+ user@<SSH 目标> + 客内命令。argv 里只有 key 文件路径,
绝无任何 key 值。egress_proxy 已设时客内命令前加 env
@@ -821,7 +835,7 @@ class CloudHypervisorExecutor(Executor):
guest_env_file=guest_env_file)
# -- API 模式:VmConfig / API 调用 / 就绪轮询 -----------------------
def build_vm_config(self, task_disk=None, seed_disk=None):
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: 字节}、
kernel{path}、cmdline{args}、disks[{path: 每任务磁盘}]、
@@ -968,7 +982,7 @@ class CloudHypervisorExecutor(Executor):
self.state_dir, self.vm_name(task), self.rootfs,
self.disk_mode)
def wait_api_ready(self, task):
def wait_api_ready(self, task: dict) -> bool:
"""等 vmm.ping 通:按次数轮询(api_timeout/1 秒取整,
至少 1 次),不用墙钟死循环——fake sleeper 不推进墙钟
时墙钟循环会空转,次数循环在单测里是确定性的。
@@ -1014,7 +1028,7 @@ class CloudHypervisorExecutor(Executor):
return True
return False
def wait_guest_ready(self, task):
def wait_guest_ready(self, task: dict) -> bool:
"""等 guest SSH 就绪:轮询 ssh "true",returncode 0 即就绪;
每轮先尽力捕获 host key(keyscan 模式),超时先关 VM
再 raise guest_not_ready,绝不在未就绪时派任务。"""
@@ -1298,7 +1312,7 @@ class CloudHypervisorExecutor(Executor):
return task_disk, socket_path
def wake_vm(self, task, extra_env=None):
def wake_vm(self, task: dict, extra_env: dict | None = None) -> dict:
"""主唤醒路径(API 模式,dispatch/run/goal 的前置步骤):
preflight → 容量门 → 磁盘配额门 → 每任务磁盘供给 →
launcher 只起 VMM(--api-socket)→ 等 vmm.ping 通 →
@@ -1335,14 +1349,14 @@ class CloudHypervisorExecutor(Executor):
return {"vm": name, "api_socket": socket_path,
"state": "running", "ready": True, "mode": "cold"}
def start_vm(self, task):
def start_vm(self, task: dict) -> dict:
"""旧名保留:即 wake_vm(API 模式)。直接启动命令
_build_vm_command() 只作手动等价写法(私有),不再是执行器
的启动路径——两条启动路径并存是上一版的半吊子,
本版只认 API 模式。"""
return self.wake_vm(task)
def pause_vm(self, task_or_name):
def pause_vm(self, task_or_name: dict | str) -> bool:
"""暂停 VM:ch-remote pause。成功审计 vm_pause。"""
self._ch_remote(task_or_name, "pause")
task = task_or_name if isinstance(task_or_name, dict) \
@@ -1351,7 +1365,7 @@ class CloudHypervisorExecutor(Executor):
"vm=%s" % self.vm_name(task_or_name))
return True
def resume_vm(self, task_or_name):
def resume_vm(self, task_or_name: dict | str) -> bool:
"""唤醒已暂停的 VM:ch-remote resume。成功审计 vm_resume。
注意:这是暂停唤醒的唯一路径,恢复后的 VM 也走它
(见 restore_vm),绝不能只 restore 就算醒。"""
@@ -1362,7 +1376,7 @@ class CloudHypervisorExecutor(Executor):
"vm=%s" % self.vm_name(task_or_name))
return True
def snapshot_vm(self, task_or_name, snapshot_url):
def snapshot_vm(self, task_or_name: dict | str, snapshot_url: str) -> str:
"""给(已暂停的)VM 做快照:ch-remote snapshot
<source_url>。snapshot_url 为空即 ValueError;成功
审计 vm_snapshot。快照本身不改变 VM 状态(仍 paused,
@@ -1454,7 +1468,7 @@ class CloudHypervisorExecutor(Executor):
self.resume_vm(task)
self.wait_guest_ready(task)
def restore_vm(self, task, snapshot_url):
def restore_vm(self, task: dict, snapshot_url: str) -> dict:
"""快照恢复唤醒:校验 → 只起 VMM → restore → 显式
resume → 等 SSH 就绪。恢复后默认 paused,不 resume
就不算醒。任一步失败都关 VM 再抛;成功返回
@@ -1474,7 +1488,7 @@ class CloudHypervisorExecutor(Executor):
return {"vm": name, "api_socket": self.api_socket(task),
"state": "running", "ready": True, "mode": "restored"}
def hibernate_vm(self, task_or_name, snapshot_url):
def hibernate_vm(self, task_or_name: dict | str, snapshot_url: str) -> dict:
"""休眠(CHV 无原生 hibernate 端点,等价实现):
pause + snapshot + shutdown。休眠后文件与 worktree
保留在快照里(快照是暂停时刻的完整状态),临时文件
@@ -1489,7 +1503,7 @@ class CloudHypervisorExecutor(Executor):
return {"vm": self.vm_name(task_or_name),
"state": "hibernated", "snapshot": snapshot_url}
def wake_from_hibernate(self, task, snapshot_url):
def wake_from_hibernate(self, task: dict, snapshot_url: str) -> dict:
"""从休眠唤醒:即 restore_vm(restore + 显式 resume
+ SSH 就绪),语义与 restore_vm 完全一致,只是名字
说明来源是休眠快照。"""
@@ -1560,7 +1574,7 @@ class CloudHypervisorExecutor(Executor):
except Exception:
pass
def stop_vm(self, task_or_name):
def stop_vm(self, task_or_name: dict | str) -> bool:
"""停止 VM(尽力而为,不抛异常):已死进程直接清登记,
已关 VM 仍尽力调一次 ch-remote(返回 False),socket
残留删 socket(返回 True)。ch-remote shutdown 失败
@@ -1597,7 +1611,7 @@ class CloudHypervisorExecutor(Executor):
_LOGGER.info("chv_vm_stop vm=%s had=%s", name, had)
return had
def gc_stale_disks(self):
def gc_stale_disks(self) -> dict:
"""残留盘 GC(H3):扫描 state_dir/*/disk.raw,VM
非 live(无 api-socket 文件且无登记活进程)即删盘
并审计;删失败审计 vm_disk_delete_failed 并计入
@@ -1675,7 +1689,7 @@ class CloudHypervisorExecutor(Executor):
pass
return removed_priv, removed_seed
def cleanup_stale_secrets(self):
def cleanup_stale_secrets(self) -> list:
"""崩溃残留扫描(H5):私钥一律删、非 live VM 的 seed
同理删并审计;返回已清理 VM 名。worker 启动与
cleanup_stale_vms 时调它,不依赖 socket 是否存在。"""
@@ -1700,18 +1714,18 @@ class CloudHypervisorExecutor(Executor):
% (entry, removed_priv, removed_seed))
return cleaned
def shutdown_vm(self, task_or_name):
def shutdown_vm(self, task_or_name: dict | str) -> bool:
"""关闭入口的正式名:即 stop_vm(ch-remote shutdown)。
任务结束/失败/取消时调它。"""
return self.stop_vm(task_or_name)
def cancel_vm(self, task_or_name):
def cancel_vm(self, task_or_name: dict | str) -> bool:
"""取消入口:即 shutdown_vm。worker 的取消路径对
Docker 调 kill_container,对 CHV 调本方法——取消绝不
只改数据库状态,必须真关 VM。"""
return self.shutdown_vm(task_or_name)
def recycle_vm(self, task):
def recycle_vm(self, task: dict) -> dict:
"""回收 VM(任务结束后的销毁语义):
先 shutdown,再返回保留/丢失清单。保留的只有宿主侧
的 task_checkpoints / task_attempts(数据库里的断点与
@@ -1767,7 +1781,7 @@ class CloudHypervisorExecutor(Executor):
if dead:
self._vms.pop(name, None)
def cleanup_stale_vms(self):
def cleanup_stale_vms(self) -> list:
"""清理残留 VM:先跑残留盘 GC 与残留密钥扫描,再走
socket 扫描与登记清理。只清 socket 残留与已死进程
对应的 VM;state_dir 不存在返回 [],绝不抛异常。"""
@@ -1907,7 +1921,7 @@ class CloudHypervisorExecutor(Executor):
"""每任务 VM 生命周期(冷启动正式路径,feat-chv-arch95
定案):wake_vm(API 模式 + SSH 就绪)→ 跑客内命令 →
finally shutdown_vm。本方法绝不查 WarmPool:预热池是
实验性的(见 pool.py 头定案与 ADR-5),dispatch 永远
实验性的(见 experimental_pool.py 头定案与 ADR-5),dispatch 永远
冷启动,warm_pool_wired_to_dispatch 恒 False。
wake 失败时不跑
任务、直接抛分类失败。本方法不做内部重试:重试是
@@ -1951,7 +1965,7 @@ class CloudHypervisorExecutor(Executor):
except Exception:
pass
def execute(self, task) -> dict:
def execute(self, task: dict) -> dict:
"""只读执行:客内 host.py status,不直接跑模型回合
(与 Local/Docker 的 execute 同款语义)。
@@ -1987,7 +2001,7 @@ class CloudHypervisorExecutor(Executor):
"SSH 失败归类为未运行:%s" % exc)
raise
def dispatch_task(self, task, extra_env=None, model_provider=None) -> dict:
def dispatch_task(self, task: dict, extra_env: dict | None = None, model_provider: str | None = None) -> dict:
"""客内 host.py dispatch:建 host 任务并跑一轮 turn。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
@@ -2004,7 +2018,7 @@ class CloudHypervisorExecutor(Executor):
model_provider=model_provider,
sandbox=("read-only" if task.get("plan") else None))
def run_task(self, task, extra_env=None, model_provider=None) -> dict:
def run_task(self, task: dict, extra_env: dict | None = None, model_provider: str | None = None) -> dict:
"""客内 host.py run:在已有 host 任务上再跑一轮 turn。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
@@ -2017,7 +2031,7 @@ class CloudHypervisorExecutor(Executor):
effort=task.get("effort"), approval=task.get("approval"),
model_provider=model_provider)
def goal_task(self, task, extra_env=None, model_provider=None) -> dict:
def goal_task(self, task: dict, extra_env: dict | None = None, model_provider: str | None = None) -> dict:
"""客内 host.py goal:目标循环,轮次规则与 Local 同款
(缺省 3 轮,>100 轮 loud fail,旧验收形态 0 轮禁用)。"""
if not isinstance(task, dict) or task.get("id") is None:
@@ -2048,8 +2062,10 @@ class CloudHypervisorExecutor(Executor):
effort=task.get("effort"), approval=task.get("approval"),
model_provider=model_provider)
def goal_resume_task(self, task, goal_text=None,
max_rounds=None, extra_env=None, model_provider=None) -> dict:
def goal_resume_task(self, task: dict, goal_text: str | None = None,
max_rounds: int | None = None,
extra_env: dict | None = None,
model_provider: str | None = None) -> dict:
"""客内 host.py goal-resume:goal_text=None 为断点续跑
(不带 --goal-text,沿用原 goal+verify),给 goal_text
为开新目标(带 --goal-text 与 --max-rounds,默认 0)。"""
@@ -2067,9 +2083,9 @@ class CloudHypervisorExecutor(Executor):
effort=task.get("effort"), approval=task.get("approval"),
model_provider=model_provider)
def goal_resume_breakpoint_task(self, task,
model_provider=None,
extra_env=None) -> dict:
def goal_resume_breakpoint_task(self, task: dict,
model_provider: str | None = None,
extra_env: dict | None = None) -> dict:
"""客内 host.py goal-resume 断点续跑:无 --goal-text、
无 --max-rounds,host 沿用原任务从断点轮次续跑。
worker 层已接线(补缺口轨):非网关的 CHV 断点任务
@@ -1,4 +1,13 @@
"""快照预热池 WarmPool(实验性,非生产路径,feat-chv-arch95 定案)。
"""快照预热池 WarmPool(纯实验模块,非生产路径)。
本轨(feat-chv-arch-maint-fe)破环性清理:WarmPool 从
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 的
reconcile_orphans_for_executor(),它不 import 本模块。
定案(架构决策,见 docs/chv-architecture.md ADR-5):WarmPool
不再宣称为生产快速启动特性。原因:预热 VM 的磁盘是干净快照
@@ -7,10 +16,11 @@
语义三者都未实现),把预热盘直接当任务盘用会击穿“任务盘
隔离”不变量。acquire 内 resume 只是实验性接口,worker
dispatch 永远走冷启动(wake_vm),_run_with_vm 绝不查池。
pool 保留的唯一生产价值是 reconcile_orphans()(上一世残留
清理),worker 启动/关闭仍调它。warm_pool_wired_to_dispatch
恒为 False,任何把它改成 True 的改动都必须先实现任务盘
重供给并补不变量测试,否则不得合入。
本模块的 reconcile_orphans() 只留给实验/旧测试直接调用;
生产启动/关闭走 orphans facade,不再经本类。
warm_pool_wired_to_dispatch 恒为 False,任何把它改成
True 的改动都必须先实现任务盘重供给并补不变量测试,
否则不得合入。
隔离红线(与 #216 一致,绝不妥协):预热条目是一次性的
@@ -43,13 +53,11 @@ Registry(本轨新增):
仍可用,重启后孤儿改由 socket 扫描兜底)。
"""
import json
import logging
import os
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
_REGISTRY_FILENAME = "warm-pool.json"
from cloud.chv.orphans import _REGISTRY_FILENAME
from cloud.chv.orphans import _read_registry
from cloud.chv.orphans import _write_registry
class WarmPool:
@@ -73,8 +81,9 @@ class WarmPool:
wired_to_dispatch = False
def __init__(self, executor, size=None, snapshot_url=None,
registry_path=None):
def __init__(self, executor: object, size: int | None = None,
snapshot_url: str | None = None,
registry_path: str | None = None) -> None:
self.executor = executor
self.size = int(size if size is not None
else getattr(executor, "warm_pool_size", 0))
@@ -93,24 +102,19 @@ class WarmPool:
self._load_registry()
# -- registry ------------------------------------------------------
def _load_registry(self):
def _load_registry(self) -> None:
"""载入 registry:读到的条目一律标 orphan(见模块
docstring,绝不直接当 warm 发给任务)。无文件时为空
池;损坏时 quarantine 并从空开始。"""
池;损坏时 quarantine 并从空开始。文件 IO 与损坏
语义唯一来源是 cloud.chv.orphans(生产模块),本
方法只做“条目 → 内存 orphan 态”的映射。"""
if not self.registry_path:
return
try:
with open(self.registry_path, "r") as fh:
data = json.load(fh)
except FileNotFoundError:
return
except (OSError, ValueError):
data, corrupt = _read_registry(self.registry_path)
if corrupt:
self.registry_corrupt = True
self._quarantine_registry()
return
if not isinstance(data, dict):
self.registry_corrupt = True
self._quarantine_registry()
if data is None:
return
for name, entry in data.items():
if not isinstance(entry, dict) \
@@ -125,22 +129,10 @@ class WarmPool:
"snapshot_url": entry.get("snapshot_url"),
}
def _quarantine_registry(self):
"""把损坏的 registry 文件挪到 <path>.corrupt 旁(尽力
而为),原路径不再留损坏数据给下一次载入。"""
if not self.registry_path:
return
try:
os.replace(self.registry_path,
self.registry_path + ".corrupt")
except OSError:
_LOGGER.warning("chv_pool_registry_quarantine_failed "
"path=%s", self.registry_path)
def _save_registry(self):
def _save_registry(self) -> None:
"""落盘当前条目(只写 warm/assigned 态;orphan 是
待清理的上一世条目,reconcile 后即删,不写回)。
写失败只记日志,不炸任务流。"""
写失败只记日志,不炸任务流。落盘 IO 委托 orphans。"""
if not self.registry_path:
return
data = {}
@@ -156,23 +148,14 @@ class WarmPool:
"task_id": task_id,
"snapshot_url": entry.get("snapshot_url"),
}
try:
directory = os.path.dirname(self.registry_path)
if directory:
os.makedirs(directory, exist_ok=True)
tmp_path = self.registry_path + ".tmp"
with open(tmp_path, "w") as fh:
json.dump(data, fh)
os.replace(tmp_path, self.registry_path)
except OSError:
_LOGGER.warning("chv_pool_registry_save_failed path=%s",
self.registry_path)
_write_registry(self.registry_path, data)
def reconcile_orphans(self) -> list:
"""清理上一世残留条目:对每个 orphan/assigned 条目
尽力 shutdown_vm(清 socket 与残留登记),条目清掉
并落盘,返回已清理的 VM 名列表。worker 启动/清理时
显式调它(见模块 docstring 的单活池假设)。"""
"""清理本池内存里的 orphan/assigned 条目(实验入口)。
对每个条目尽力 shutdown_vm,条目清掉并落盘,返回
已清理的 VM 名列表。生产启动/关闭不走本方法,走
cloud.chv.orphans.reconcile_orphans_for_executor()。"""
cleaned = []
for name, entry in list(self._entries.items()):
if entry["state"] not in ("orphan", "assigned"):
@@ -204,7 +187,7 @@ class WarmPool:
return sum(1 for e in self._entries.values()
if e["state"] in ("orphan", "assigned"))
def prewarm(self, count=None):
def prewarm(self, count: int | None = None) -> list:
"""预热到目标数:已有 warm 条目不重复建;每个新条目
用一个合成任务名 wake + pause(合成任务 id 为
warm-<序号>,绝不与真实任务 id 撞名)。返回新增的
@@ -237,7 +220,7 @@ class WarmPool:
self._save_registry()
return added
def acquire(self, task):
def acquire(self, task: dict) -> dict:
"""分配一个预热 VM 给任务(feat-chv-fix,P1-3 接线):
有 warm 条目时先调 executor.resume_vm() 把暂停的 VM
@@ -285,7 +268,7 @@ class WarmPool:
"pool_task": entry["task"]}
return {"mode": "cold", "vm": None, "pool_task": None}
def release(self, task, pool_vm=None):
def release(self, task: dict, pool_vm: str | None = None) -> bool:
"""释放:无论 warm 还是 cold 路径,VM 一律销毁
(executor.shutdown_vm),池条目清掉;绝不把用过
的 VM 放回 warm 池。返回 True 表示确实销毁了一个
@@ -304,13 +287,3 @@ class WarmPool:
"warm_pool_release", task,
"vm=%s destroyed=%s" % (pool_vm, destroyed))
return destroyed
def reconcile_orphans_for_executor(executor) -> list:
"""生产路径唯一的 WarmPool 入口:只做孤儿清理。
worker 启动/关闭调它,不构造 WarmPool 类、不调
prewarm/acquire/release——实验接口与生产路径彻底分离:
生产代码 import 本函数即可,acquire/prewarm 只存在于
实验用的 WarmPool 类与测试里。"""
return WarmPool(executor).reconcile_orphans()
+3 -2
View File
@@ -183,8 +183,9 @@ def _is_within(directory, path):
return resolve_inside(directory, rel) is not None
def validate_snapshot_url(snapshot_url: str, state_dir: str, snapshot_dir=None,
allow_remote=False):
def validate_snapshot_url(snapshot_url: str, state_dir: str,
snapshot_dir: str | None = None,
allow_remote: bool = False) -> str | None:
"""快照 URL 白名单(L2):
- file://:目标须在 state_dir 或 CHV_SNAPSHOT_DIR 内,
+11 -9
View File
@@ -40,7 +40,7 @@ import shlex
GUEST_ENV_PATH = "/etc/chv/guest-env"
def host_key_paths(state_dir: str, vm_name: str) -> tuple:
def host_key_paths(state_dir: str, vm_name: str) -> dict:
"""该 VM 的 host key 文件路径(私钥/公钥/known_hosts)。"""
base = os.path.join(state_dir, vm_name)
return {
@@ -59,7 +59,7 @@ def dirty_marker_path(state_dir: str, vm_name: str) -> str:
return os.path.join(state_dir, vm_name, ".cleanup-dirty")
def delete_host_private_key(state_dir: str, vm_name: str) -> None:
def delete_host_private_key(state_dir: str, vm_name: str) -> str:
"""删宿主侧 host 私钥文件(feat-chv-secfix,H5):seed
盘建好后宿主私钥已无存在必要(客机已有私钥、宿主只需
公钥 + known_hosts 做校验)。删失败即 OSError,由调用
@@ -70,7 +70,7 @@ def delete_host_private_key(state_dir: str, vm_name: str) -> None:
return path
def known_hosts_line(port, public_key_text, host="127.0.0.1"):
def known_hosts_line(port: int, public_key_text: str, host: str = "127.0.0.1") -> str:
"""公钥文件内容转 known_hosts 行:只取前两列(类型+数据)。
公钥文件形如 'ssh-ed25519 AAAA... [comment]',known_hosts
@@ -88,7 +88,7 @@ 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=None) -> None:
def build_seed_disk(seed_dir: str, seed_disk: str, runner: object = None) -> str:
"""把 seed 目录做成 CIDATA 卷标 ISO(feat-chv-fix,P0-1 的
注入载体):genisoimage -volid CIDATA -joliet -rock。
@@ -121,7 +121,7 @@ def build_seed_disk(seed_dir: str, seed_disk: str, runner=None) -> None:
return seed_disk
def build_seed_user_data(private_key_text, public_key_text):
def build_seed_user_data(private_key_text: str, public_key_text: str) -> str:
"""cloud-init user-data:把 host key 对写进客机 /etc/ssh。
注意:user-data 本身含私钥,只写进 per-VM seed 目录
@@ -156,8 +156,9 @@ def build_seed_user_data(private_key_text, public_key_text):
return "\n".join(lines) + "\n"
def write_seed(seed_dir, vm_name, private_key_text, public_key_text,
secret_env=None):
def write_seed(seed_dir: str, vm_name: str, private_key_text: str,
public_key_text: str,
secret_env: dict | None = None) -> str:
"""写 seed 目录:meta-data + user-data(均为 0600)。
返回 seed 目录路径。seed 目录本身不含已知主机之外的
@@ -204,8 +205,9 @@ def write_seed(seed_dir, vm_name, private_key_text, public_key_text,
return seed_dir
def generate_host_keypair(state_dir, vm_name, runner=None,
keygen="ssh-keygen"):
def generate_host_keypair(state_dir: str, vm_name: str,
runner: object = None,
keygen: str = "ssh-keygen") -> dict:
"""为该 VM 生成(或复用本 VM 已有的)host key 对。
同一 VM 重启/恢复时复用已有对(否则钉死的 known_hosts
+142
View File
@@ -0,0 +1,142 @@
"""CHV 上一世残留 VM 清理(生产路径唯一入口)。
本模块是叶子模块,只依赖标准库,绝不 import
cloud.chv.experimental_pool(实验接口)与 cloud.sandbox。
生产代码(worker 启动/关闭)只调
reconcile_orphans_for_executor(),它直接读 registry 文件、
对每个上一世条目调 executor.shutdown_vm(),不构造任何
池对象、不调 prewarm/acquire/release。
Registry 语义与旧 WarmPool._load_registry 完全一致
(feat-chv-arch95plus 的 fail-safe 口径,本轨只搬位置、
不改语义):
- 文件:state_dir/warm-pool.json,内容为
{<合成名>: {"state", "vm", "task_id", "snapshot_url"}}。
- 读到的 warm/assigned 条目一律当 orphan:旧 VMM 进程
已随旧 worker 消失,无法验证它还是当初那个干净快照
VM,绝不发给任务,只能清理。
- 损坏 fail-safe:文件不是合法 JSON / 顶层不是 dict 时,
整个文件 quarantine 成 <path>.corrupt,清理从空开始;
单条目结构非法(无 vm、或 state 不在 warm/assigned)
时只丢该条,不连坐其它条目。
- 清理后落盘为空 dict(原子 tmp + rename);写失败只记
日志警告,不炸 worker 启动/关闭。shutdown_vm 抛错时该
条目仍计入已清理(旧 reconcile 语义:尽力清理、不因
单条失败卡住整批),_emit 失败同样 best-effort。
"""
import json
import logging
import os
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
_REGISTRY_FILENAME = "warm-pool.json"
def registry_path_for_executor(executor: object) -> str | None:
"""执行器的 registry 路径:state_dir 下的 warm-pool.json。
无 state_dir 时返回 None(调用方按“无残留”处理)。"""
state_dir = getattr(executor, "state_dir", None)
if not state_dir:
return None
return os.path.join(state_dir, _REGISTRY_FILENAME)
def _quarantine_registry(path: str) -> None:
"""把损坏的 registry 文件挪到 <path>.corrupt 旁(尽力而为)。"""
try:
os.replace(path, path + ".corrupt")
except OSError:
_LOGGER.warning("chv_pool_registry_quarantine_failed "
"path=%s", path)
def _read_registry(path: str) -> tuple[dict | None, bool]:
"""读 registry 原始 dict:(data, corrupt)。
无文件 → (None, False);损坏 → quarantine 后 (None, True);
顶层非 dict 同样算损坏。调用方按 corrupt 决定是否记
registry_corrupt(实验池需要区分“无文件”与“损坏”)。"""
try:
with open(path, "r") as fh:
data = json.load(fh)
except FileNotFoundError:
return None, False
except (OSError, ValueError):
_quarantine_registry(path)
return None, True
if not isinstance(data, dict):
_quarantine_registry(path)
return None, True
return data, False
def _write_registry(path: str, data: dict) -> None:
"""原子落盘 registry(tmp + rename);写失败只记日志。"""
try:
directory = os.path.dirname(path)
if directory:
os.makedirs(directory, exist_ok=True)
tmp_path = path + ".tmp"
with open(tmp_path, "w") as fh:
json.dump(data, fh)
os.replace(tmp_path, path)
except OSError:
_LOGGER.warning("chv_pool_registry_save_failed path=%s", path)
def _orphan_entries(data: dict) -> list[dict]:
"""从原始 registry dict 里筛出可清理的上一世条目。
只留 state 为 warm/assigned 且有 vm 的条目;非法条目
静默丢弃(不连坐)。返回条目为 {"vm", "task_id",
"snapshot_url"} 的新 dict,不改原数据。"""
entries: list[dict] = []
for entry in data.values():
if not isinstance(entry, dict):
continue
if not entry.get("vm"):
continue
if entry.get("state") not in ("warm", "assigned"):
continue
entries.append({
"vm": entry["vm"],
"task_id": entry.get("task_id"),
"snapshot_url": entry.get("snapshot_url"),
})
return entries
def reconcile_orphans_for_executor(executor: object) -> list[str]:
"""生产路径唯一的残留清理入口:返回已清理的 VM 名列表。
只做清理:读 registry → 每个上一世条目尽力
shutdown_vm → 落盘为空 → best-effort 审计事件。
绝不构造 WarmPool、绝不调 prewarm/acquire/release,
绝不把任何条目发给任务。无 registry 路径时返回 []。"""
path = registry_path_for_executor(executor)
if not path:
return []
data, _corrupt = _read_registry(path)
if data is None:
return []
entries = _orphan_entries(data)
cleaned: list[str] = []
for entry in entries:
vm = entry["vm"]
try:
executor.shutdown_vm(vm) # type: ignore[attr-defined]
except Exception:
pass
cleaned.append(vm)
if cleaned:
_write_registry(path, {})
try:
executor._emit( # type: ignore[attr-defined]
"warm_pool_reconcile", {"id": None},
"cleaned=%s" % ",".join(cleaned))
except Exception:
pass
return cleaned
+10 -6
View File
@@ -63,8 +63,9 @@ def split_secret_env(extra_env: dict) -> tuple:
return secrets, plain
def remote_command(inner_cmd, egress_proxy=None, extra_env=None,
guest_env_file=None):
def remote_command(inner_cmd: list, egress_proxy: str | None = None,
extra_env: dict | None = None,
guest_env_file: str | None = None) -> list:
"""构造远程 shell 命令的 token 列表(不含 ssh 前缀)。
每个 token 都经 shlex.quote:ssh 客户端把 argv 用空格拼
@@ -108,9 +109,12 @@ def known_hosts_path(state_dir: str, vm_name: str) -> str:
return os.path.join(state_dir, vm_name, "known_hosts")
def build_ssh_command(ssh_port, ssh_key, ssh_user, egress_proxy,
known_hosts_file, inner_cmd, ssh_host="127.0.0.1",
extra_env=None, guest_env_file=None):
def build_ssh_command(ssh_port: int, ssh_key: str | None,
ssh_user: str, egress_proxy: str | None,
known_hosts_file: str, inner_cmd: list,
ssh_host: str = "127.0.0.1",
extra_env: dict | None = None,
guest_env_file: str | None = None) -> list:
"""构造 SSH 命令:ssh -p <端口> -i <key 文件> + 钉死选项
+ user@<ssh_host> + 客内命令(远程 token 均经 shlex.quote,
见 remote_command)。
@@ -138,7 +142,7 @@ def build_ssh_command(ssh_port, ssh_key, ssh_user, egress_proxy,
"%s@%s" % (ssh_user, ssh_host)] + remote
def parse_keyscan_output(text, host="127.0.0.1"):
def parse_keyscan_output(text: str, host: str = "127.0.0.1") -> list:
"""从 ssh-keyscan 的 stdout 里挑出目标主机的 host key
行:形如 "[<host>]:<port> ssh-ed25519 AAAA..."(-p 时
的标准写法)或 "<host> ssh-rsa AAAA..."。其它主机行、
+44 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { chvAvailability, chvQueueHint, countChvWorkers, isWorkerAlive } from "./chv";
import { chvAttachmentBlocked, chvAvailability, chvQueueHint, chvVmBadge, chvVmStatusText, countChvWorkers, isChvExecutor, isWorkerAlive } from "./chv";
// 纯逻辑测试:只测 chv.ts,不渲染组件、不发请求、不读 localStorage。
// alive 规则与 WorkersSection 现有口径一致:alive 字段优先,
@@ -50,6 +50,49 @@ describe("chvAvailability", () => {
});
});
describe("isChvExecutor", () => {
it("精确值才算 CHV,不做大小写/前缀宽松匹配", () => {
expect(isChvExecutor("cloud-hypervisor")).toBe(true);
expect(isChvExecutor("Cloud-Hypervisor")).toBe(false);
expect(isChvExecutor("cloud-hypervisor-x")).toBe(false);
expect(isChvExecutor("docker")).toBe(false);
expect(isChvExecutor(undefined)).toBe(false);
expect(isChvExecutor(null)).toBe(false);
});
});
describe("chvAttachmentBlocked", () => {
it("仅 CHV + 带附件时拦(后端 400 的前端提示口径)", () => {
expect(chvAttachmentBlocked("cloud-hypervisor", true)).toBe(true);
expect(chvAttachmentBlocked("cloud-hypervisor", false)).toBe(false);
expect(chvAttachmentBlocked("docker", true)).toBe(false);
expect(chvAttachmentBlocked("cloud", true)).toBe(false);
});
});
describe("chvVmStatusText", () => {
it("已报告死亡时给死亡文案", () => {
expect(chvVmStatusText({ executor: "cloud-hypervisor", chv_vm_dead: true })).toContain("VM 中途死亡");
});
it("CHV 无字段时给未报告文案,绝不写成活着", () => {
const text = chvVmStatusText({ executor: "cloud-hypervisor" });
expect(text).toContain("未报告 VM 死亡");
expect(text).toContain("不代表 VM 活着");
});
it("非 CHV 任务不显示 VM 行", () => {
expect(chvVmStatusText({ executor: "docker" })).toBe("");
expect(chvVmStatusText({ executor: "docker", chv_vm_dead: true })).toBe("");
});
});
describe("chvVmBadge", () => {
it("只有 CHV + 已报告死亡时才出徽标", () => {
expect(chvVmBadge({ executor: "cloud-hypervisor", chv_vm_dead: true })).toBe("VM 已报告死亡");
expect(chvVmBadge({ executor: "cloud-hypervisor" })).toBe("");
expect(chvVmBadge({ executor: "docker", chv_vm_dead: true })).toBe("");
});
});
describe("chvQueueHint", () => {
it("仅 CHV + 排队 + 已知零在线时给提示", () => {
const knownZero = { state: "known" as const, online: 0, total: 0 };
+54
View File
@@ -35,6 +35,60 @@ export function chvAvailability(
return { state: "unknown", reason: "not-loaded" };
}
// 执行器选择谓词:派发下拉与列表筛选都以字符串相等为准,
// 不做大小写/前缀宽松匹配(后端白名单是精确值,见 server
// POST /tasks 的 executor 白名单;宽松匹配会把非法值放进
// “CHV 任务”分支,造成前端提示与后端拒绝不一致)。
export function isChvExecutor(executor: string | undefined | null): boolean {
return executor === "cloud-hypervisor";
}
// 附件守卫谓词:CHV + 带附件 → 后端建任务即拒绝(server
// POST /tasks 的附件绑定分支,错误文案“附件不支持
// cloud-hypervisor 执行器”)。本函数只作前端提示口径,
// 绝不代替后端白名单:前端照常提交,后端拒绝为准。
export function chvAttachmentBlocked(
executor: string | undefined | null,
hasAttachments: boolean,
): boolean {
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: {
executor?: string | null;
chv_vm_dead?: boolean;
}): string {
// 死亡文案同样先过执行器门:后端只对 CHV 任务派生该字段,
// 万一某天非 CHV 行误带该字段,前端也不把 docker 任务
// 写成“VM 死亡”。
if (task.chv_vm_dead && isChvExecutor(task.executor)) {
return "VM 中途死亡,任务未完成、断点可续跑(后端只读字段 chv_vm_dead)";
}
if (isChvExecutor(task.executor)) {
return "未报告 VM 死亡(后端无 live 状态字段,不代表 VM 活着)";
}
return "";
}
// 列表行徽标:只有已报告死亡时才出现短徽标;未报告时
// 列表行不写 VM 状态(详情里才写“未报告”长文案,避免
// 列表行被未知状态刷屏)。
export function chvVmBadge(task: {
executor?: string | null;
chv_vm_dead?: boolean;
}): string {
return task.chv_vm_dead && isChvExecutor(task.executor)
? "VM 已报告死亡"
: "";
}
// 排队原因提示:只在“已知零 CHV worker 在线 + CHV 任务排队”时
// 给出,文案明确写“可能原因”,且不编造后端未返回的
// VM 状态/IP/快照。未知时返回空串(不提示、不猜测)。
@@ -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 { chvAvailability } from "../api/chv";
import { chvAttachmentBlocked, chvAvailability, isChvExecutor } from "../api/chv";
import { EFFORTS } from "../api/types";
export default function DispatchSection() {
@@ -31,7 +31,7 @@ export default function DispatchSection() {
const [hasAttachments, setHasAttachments] = useState(false);
const [msg, setMsg] = useState("");
const chvAvail = chvAvailability(ctx.workers, ctx.workersSummary, ctx.workersError);
const isChv = executor === "cloud-hypervisor";
const isChv = isChvExecutor(executor);
function refreshAttachments() {
setHasAttachments(
!!(filesRef.current?.files?.length || dirRef.current?.files?.length || imgRef.current?.files?.length),
@@ -80,7 +80,7 @@ export default function DispatchSection() {
if (goalMode || planMode) { setMsg("协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON"); return; }
}
const atts = isSide ? [] : gatherAttachments(filesRef.current?.files || null, dirRef.current?.files || null, imgRef.current?.files || null);
if (isChv && atts.length) { setMsg("cloud-hypervisor 任务不支持附件:请先清空附件再提交(后端对带附件的 CHV 任务会 400)"); return; }
if (chvAttachmentBlocked(executor, atts.length > 0)) { setMsg("cloud-hypervisor 任务不支持附件:请先清空附件再提交(后端对带附件的 CHV 任务会 400)"); return; }
try {
const result = await ctx.dispatch(body, atts);
if (isSide) {
@@ -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 } from "../api/chv";
import { chvAvailability, chvQueueHint, chvVmStatusText } 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() {
<div><dt>turn</dt><dd>{shortId(t.turn_id)}</dd></div>
<div><dt>root turn</dt><dd>{shortId(t.root_turn_id)}</dd></div>
<div><dt>executor</dt><dd>{t.executor || ""}{t.executor === "cloud-hypervisor" ? "(实验执行器,默认关闭;仅展示,无 VM 控制按钮)" : ""}</dd></div>
{t.chv_vm_dead ? <div><dt>VM 状态</dt><dd>VM 中途死亡,任务未完成、断点可续跑(后端只读字段 chv_vm_dead;无该字段时不代表 VM 活着)</dd></div> : null}
{t.executor === "cloud-hypervisor" ? <div><dt>VM 状态</dt><dd>{chvVmStatusText(t)}</dd></div> : null}
<div><dt>主机</dt><dd>{t.worktree_host || ""}</dd></div>
<div><dt>模型</dt><dd>{t.model || "默认"}</dd></div>
<div><dt>强度</dt><dd>{t.effort || "默认"}</dd></div>
@@ -1,7 +1,7 @@
import { useConsole } from "../context";
import { shortId } from "../api/client";
import { firstChar, durationOf, tokenOf } from "../api/taskMeta";
import { chvAvailability, chvQueueHint } from "../api/chv";
import { chvAvailability, chvQueueHint, chvVmBadge } from "../api/chv";
// 任务列表行 meta:首字 / 总耗时 / token,计算见 api/taskMeta.ts,
// 与任务详情共用同一口径。
@@ -90,6 +90,9 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
{chvQueueHint(t.executor, t.status, chvAvail) ? (
<div className="issue-meta" title={chvQueueHint(t.executor, t.status, chvAvail)}>{chvQueueHint(t.executor, t.status, chvAvail)}</div>
) : null}
{chvVmBadge(t) ? (
<div className="issue-meta" title="后端只读字段 chv_vm_dead(worker 终局写回的 VM 死亡原因派生);无徽标时不代表 VM 活着">{chvVmBadge(t)}</div>
) : null}
</span>
</li>
);
+16 -16
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Codex 命令台</title>
<script type="module" crossorigin>(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const g of document.querySelectorAll('link[rel="modulepreload"]'))f(g);new MutationObserver(g=>{for(const A of g)if(A.type==="childList")for(const O of A.addedNodes)O.tagName==="LINK"&&O.rel==="modulepreload"&&f(O)}).observe(document,{childList:!0,subtree:!0});function h(g){const A={};return g.integrity&&(A.integrity=g.integrity),g.referrerPolicy&&(A.referrerPolicy=g.referrerPolicy),g.crossOrigin==="use-credentials"?A.credentials="include":g.crossOrigin==="anonymous"?A.credentials="omit":A.credentials="same-origin",A}function f(g){if(g.ep)return;g.ep=!0;const A=h(g);fetch(g.href,A)}})();function Wh(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var oo={exports:{}},ci={};/**
<script type="module" crossorigin>(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const g of document.querySelectorAll('link[rel="modulepreload"]'))f(g);new MutationObserver(g=>{for(const A of g)if(A.type==="childList")for(const O of A.addedNodes)O.tagName==="LINK"&&O.rel==="modulepreload"&&f(O)}).observe(document,{childList:!0,subtree:!0});function h(g){const A={};return g.integrity&&(A.integrity=g.integrity),g.referrerPolicy&&(A.referrerPolicy=g.referrerPolicy),g.crossOrigin==="use-credentials"?A.credentials="include":g.crossOrigin==="anonymous"?A.credentials="omit":A.credentials="same-origin",A}function f(g){if(g.ep)return;g.ep=!0;const A=h(g);fetch(g.href,A)}})();function Ih(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var fo={exports:{}},ci={};/**
* @license React
* react-jsx-runtime.production.js
*
@@ -12,7 +12,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var wh;function qy(){if(wh)return ci;wh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function h(f,g,A){var O=null;if(A!==void 0&&(O=""+A),g.key!==void 0&&(O=""+g.key),"key"in g){A={};for(var b in g)b!=="key"&&(A[b]=g[b])}else A=g;return g=A.ref,{$$typeof:i,type:f,key:O,ref:g!==void 0?g:null,props:A}}return ci.Fragment=r,ci.jsx=h,ci.jsxs=h,ci}var Uh;function Yy(){return Uh||(Uh=1,oo.exports=qy()),oo.exports}var c=Yy(),fo={exports:{}},St={};/**
*/var Uh;function Gy(){if(Uh)return ci;Uh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function h(f,g,A){var O=null;if(A!==void 0&&(O=""+A),g.key!==void 0&&(O=""+g.key),"key"in g){A={};for(var b in g)b!=="key"&&(A[b]=g[b])}else A=g;return g=A.ref,{$$typeof:i,type:f,key:O,ref:g!==void 0?g:null,props:A}}return ci.Fragment=r,ci.jsx=h,ci.jsxs=h,ci}var Hh;function Vy(){return Hh||(Hh=1,fo.exports=Gy()),fo.exports}var c=Vy(),mo={exports:{}},St={};/**
* @license React
* react.production.js
*
@@ -20,7 +20,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Hh;function Gy(){if(Hh)return St;Hh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),h=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),g=Symbol.for("react.profiler"),A=Symbol.for("react.consumer"),O=Symbol.for("react.context"),b=Symbol.for("react.forward_ref"),E=Symbol.for("react.suspense"),M=Symbol.for("react.memo"),q=Symbol.for("react.lazy"),p=Symbol.for("react.activity"),H=Symbol.for("react.view_transition"),Z=Symbol.iterator;function F(y){return y===null||typeof y!="object"?null:(y=Z&&y[Z]||y["@@iterator"],typeof y=="function"?y:null)}var it={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},ft=Object.assign,Et={};function st(y,k,nt){this.props=y,this.context=k,this.refs=Et,this.updater=nt||it}st.prototype.isReactComponent={},st.prototype.setState=function(y,k){if(typeof y!="object"&&typeof y!="function"&&y!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,y,k,"setState")},st.prototype.forceUpdate=function(y){this.updater.enqueueForceUpdate(this,y,"forceUpdate")};function Ht(){}Ht.prototype=st.prototype;function Q(y,k,nt){this.props=y,this.context=k,this.refs=Et,this.updater=nt||it}var ct=Q.prototype=new Ht;ct.constructor=Q,ft(ct,st.prototype),ct.isPureReactComponent=!0;var z=Array.isArray;function L(){}var at={H:null,A:null,T:null,S:null},jt=Object.prototype.hasOwnProperty;function Mt(y,k,nt){var et=nt.ref;return{$$typeof:i,type:y,key:k,ref:et!==void 0?et:null,props:nt}}function yt(y,k){return Mt(y.type,k,y.props)}function bt(y){return typeof y=="object"&&y!==null&&y.$$typeof===i}function B(y){var k={"=":"=0",":":"=2"};return"$"+y.replace(/[=:]/g,function(nt){return k[nt]})}var mt=/\/+/g;function Ct(y,k){return typeof y=="object"&&y!==null&&y.key!=null?B(""+y.key):k.toString(36)}function V(y){switch(y.status){case"fulfilled":return y.value;case"rejected":throw y.reason;default:switch(typeof y.status=="string"?y.then(L,L):(y.status="pending",y.then(function(k){y.status==="pending"&&(y.status="fulfilled",y.value=k)},function(k){y.status==="pending"&&(y.status="rejected",y.reason=k)})),y.status){case"fulfilled":return y.value;case"rejected":throw y.reason}}throw y}function Y(y,k,nt,et,xt){var Nt=typeof y;(Nt==="undefined"||Nt==="boolean")&&(y=null);var Ot=!1;if(y===null)Ot=!0;else switch(Nt){case"bigint":case"string":case"number":Ot=!0;break;case"object":switch(y.$$typeof){case i:case r:Ot=!0;break;case q:return Ot=y._init,Y(Ot(y._payload),k,nt,et,xt)}}if(Ot)return xt=xt(y),Ot=et===""?"."+Ct(y,0):et,z(xt)?(nt="",Ot!=null&&(nt=Ot.replace(mt,"$&/")+"/"),Y(xt,k,nt,"",function(Ne){return Ne})):xt!=null&&(bt(xt)&&(xt=yt(xt,nt+(xt.key==null||y&&y.key===xt.key?"":(""+xt.key).replace(mt,"$&/")+"/")+Ot)),k.push(xt)),1;Ot=0;var W=et===""?".":et+":";if(z(y))for(var ht=0;ht<y.length;ht++)et=y[ht],Nt=W+Ct(et,ht),Ot+=Y(et,k,nt,Nt,xt);else if(ht=F(y),typeof ht=="function")for(y=ht.call(y),ht=0;!(et=y.next()).done;)et=et.value,Nt=W+Ct(et,ht++),Ot+=Y(et,k,nt,Nt,xt);else if(Nt==="object"){if(typeof y.then=="function")return Y(V(y),k,nt,et,xt);throw k=String(y),Error("Objects are not valid as a React child (found: "+(k==="[object Object]"?"object with keys {"+Object.keys(y).join(", ")+"}":k)+"). If you meant to render a collection of children, use an array instead.")}return Ot}function J(y,k,nt){if(y==null)return y;var et=[],xt=0;return Y(y,et,"","",function(Nt){return k.call(nt,Nt,xt++)}),et}function dt(y){if(y._status===-1){var k=y._result,nt=k();nt.then(function(et){(y._status===0||y._status===-1)&&(y._status=1,y._result=et,nt.status===void 0&&(nt.status="fulfilled",nt.value=et))},function(et){(y._status===0||y._status===-1)&&(y._status=2,y._result=et,nt.status===void 0&&(nt.status="rejected",nt.reason=et))}),y._status===-1&&(y._status=0,y._result=nt)}if(y._status===1)return y._result.default;throw y._result}var gt=typeof reportError=="function"?reportError:function(y){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var k=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof y=="object"&&y!==null&&typeof y.message=="string"?String(y.message):String(y),error:y});if(!window.dispatchEvent(k))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",y);return}console.error(y)};function ee(y){var k=at.T,nt={};nt.types=k!==null?k.types:null,at.T=nt;try{var et=y(),xt=at.S;xt!==null&&xt(nt,et),typeof et=="object"&&et!==null&&typeof et.then=="function"&&et.then(L,gt)}catch(Nt){gt(Nt)}finally{k!==null&&nt.types!==null&&(k.types=nt.types),at.T=k}}function fe(y){var k=at.T;if(k!==null){var nt=k.types;nt===null?k.types=[y]:nt.indexOf(y)===-1&&nt.push(y)}else ee(fe.bind(null,y))}var X={map:J,forEach:function(y,k,nt){J(y,function(){k.apply(this,arguments)},nt)},count:function(y){var k=0;return J(y,function(){k++}),k},toArLine truncated
*/var Bh;function Qy(){if(Bh)return St;Bh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),h=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),g=Symbol.for("react.profiler"),A=Symbol.for("react.consumer"),O=Symbol.for("react.context"),b=Symbol.for("react.forward_ref"),E=Symbol.for("react.suspense"),M=Symbol.for("react.memo"),q=Symbol.for("react.lazy"),p=Symbol.for("react.activity"),H=Symbol.for("react.view_transition"),Z=Symbol.iterator;function F(y){return y===null||typeof y!="object"?null:(y=Z&&y[Z]||y["@@iterator"],typeof y=="function"?y:null)}var it={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},ft=Object.assign,Et={};function st(y,k,nt){this.props=y,this.context=k,this.refs=Et,this.updater=nt||it}st.prototype.isReactComponent={},st.prototype.setState=function(y,k){if(typeof y!="object"&&typeof y!="function"&&y!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,y,k,"setState")},st.prototype.forceUpdate=function(y){this.updater.enqueueForceUpdate(this,y,"forceUpdate")};function Ht(){}Ht.prototype=st.prototype;function V(y,k,nt){this.props=y,this.context=k,this.refs=Et,this.updater=nt||it}var ct=V.prototype=new Ht;ct.constructor=V,ft(ct,st.prototype),ct.isPureReactComponent=!0;var z=Array.isArray;function L(){}var at={H:null,A:null,T:null,S:null},jt=Object.prototype.hasOwnProperty;function Mt(y,k,nt){var et=nt.ref;return{$$typeof:i,type:y,key:k,ref:et!==void 0?et:null,props:nt}}function yt(y,k){return Mt(y.type,k,y.props)}function bt(y){return typeof y=="object"&&y!==null&&y.$$typeof===i}function B(y){var k={"=":"=0",":":"=2"};return"$"+y.replace(/[=:]/g,function(nt){return k[nt]})}var mt=/\/+/g;function Ct(y,k){return typeof y=="object"&&y!==null&&y.key!=null?B(""+y.key):k.toString(36)}function Q(y){switch(y.status){case"fulfilled":return y.value;case"rejected":throw y.reason;default:switch(typeof y.status=="string"?y.then(L,L):(y.status="pending",y.then(function(k){y.status==="pending"&&(y.status="fulfilled",y.value=k)},function(k){y.status==="pending"&&(y.status="rejected",y.reason=k)})),y.status){case"fulfilled":return y.value;case"rejected":throw y.reason}}throw y}function Y(y,k,nt,et,xt){var Nt=typeof y;(Nt==="undefined"||Nt==="boolean")&&(y=null);var Ot=!1;if(y===null)Ot=!0;else switch(Nt){case"bigint":case"string":case"number":Ot=!0;break;case"object":switch(y.$$typeof){case i:case r:Ot=!0;break;case q:return Ot=y._init,Y(Ot(y._payload),k,nt,et,xt)}}if(Ot)return xt=xt(y),Ot=et===""?"."+Ct(y,0):et,z(xt)?(nt="",Ot!=null&&(nt=Ot.replace(mt,"$&/")+"/"),Y(xt,k,nt,"",function(Ne){return Ne})):xt!=null&&(bt(xt)&&(xt=yt(xt,nt+(xt.key==null||y&&y.key===xt.key?"":(""+xt.key).replace(mt,"$&/")+"/")+Ot)),k.push(xt)),1;Ot=0;var W=et===""?".":et+":";if(z(y))for(var ht=0;ht<y.length;ht++)et=y[ht],Nt=W+Ct(et,ht),Ot+=Y(et,k,nt,Nt,xt);else if(ht=F(y),typeof ht=="function")for(y=ht.call(y),ht=0;!(et=y.next()).done;)et=et.value,Nt=W+Ct(et,ht++),Ot+=Y(et,k,nt,Nt,xt);else if(Nt==="object"){if(typeof y.then=="function")return Y(Q(y),k,nt,et,xt);throw k=String(y),Error("Objects are not valid as a React child (found: "+(k==="[object Object]"?"object with keys {"+Object.keys(y).join(", ")+"}":k)+"). If you meant to render a collection of children, use an array instead.")}return Ot}function J(y,k,nt){if(y==null)return y;var et=[],xt=0;return Y(y,et,"","",function(Nt){return k.call(nt,Nt,xt++)}),et}function dt(y){if(y._status===-1){var k=y._result,nt=k();nt.then(function(et){(y._status===0||y._status===-1)&&(y._status=1,y._result=et,nt.status===void 0&&(nt.status="fulfilled",nt.value=et))},function(et){(y._status===0||y._status===-1)&&(y._status=2,y._result=et,nt.status===void 0&&(nt.status="rejected",nt.reason=et))}),y._status===-1&&(y._status=0,y._result=nt)}if(y._status===1)return y._result.default;throw y._result}var gt=typeof reportError=="function"?reportError:function(y){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var k=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof y=="object"&&y!==null&&typeof y.message=="string"?String(y.message):String(y),error:y});if(!window.dispatchEvent(k))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",y);return}console.error(y)};function ee(y){var k=at.T,nt={};nt.types=k!==null?k.types:null,at.T=nt;try{var et=y(),xt=at.S;xt!==null&&xt(nt,et),typeof et=="object"&&et!==null&&typeof et.then=="function"&&et.then(L,gt)}catch(Nt){gt(Nt)}finally{k!==null&&nt.types!==null&&(k.types=nt.types),at.T=k}}function fe(y){var k=at.T;if(k!==null){var nt=k.types;nt===null?k.types=[y]:nt.indexOf(y)===-1&&nt.push(y)}else ee(fe.bind(null,y))}var X={map:J,forEach:function(y,k,nt){J(y,function(){k.apply(this,arguments)},nt)},count:function(y){var k=0;return J(y,function(){k++}),k},toArLine truncated
* @license React
* scheduler.production.js
*
@@ -28,7 +28,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var qh;function Vy(){return qh||(qh=1,(function(i){function r(V,Y){var J=V.length;V.push(Y);t:for(;0<J;){var dt=J-1>>>1,gt=V[dt];if(0<g(gt,Y))V[dt]=Y,V[J]=gt,J=dt;else break t}}function h(V){return V.length===0?null:V[0]}function f(V){if(V.length===0)return null;var Y=V[0],J=V.pop();if(J!==Y){V[0]=J;t:for(var dt=0,gt=V.length,ee=gt>>>1;dt<ee;){var fe=2*(dt+1)-1,X=V[fe],y=fe+1,k=V[y];if(0>g(X,J))y<gt&&0>g(k,X)?(V[dt]=k,V[y]=J,dt=y):(V[dt]=X,V[fe]=J,dt=fe);else if(y<gt&&0>g(k,J))V[dt]=k,V[y]=J,dt=y;else break t}}return Y}function g(V,Y){var J=V.sortIndex-Y.sortIndex;return J!==0?J:V.id-Y.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var A=performance;i.unstable_now=function(){return A.now()}}else{var O=Date,b=O.now();i.unstable_now=function(){return O.now()-b}}var E=[],M=[],q=1,p=null,H=3,Z=!1,F=!1,it=!1,ft=!1,Et=typeof setTimeout=="function"?setTimeout:null,st=typeof clearTimeout=="function"?clearTimeout:null,Ht=typeof setImmediate<"u"?setImmediate:null;function Q(V){for(var Y=h(M);Y!==null;){if(Y.callback===null)f(M);else if(Y.startTime<=V)f(M),Y.sortIndex=Y.expirationTime,r(E,Y);else break;Y=h(M)}}function ct(V){if(it=!1,Q(V),!F)if(h(E)!==null)F=!0,z||(z=!0,bt());else{var Y=h(M);Y!==null&&Ct(ct,Y.startTime-V)}}var z=!1,L=-1,at=5,jt=-1;function Mt(){return ft?!0:!(i.unstable_now()-jt<at)}function yt(){if(ft=!1,z){var V=i.unstable_now();jt=V;var Y=!0;try{t:{F=!1,it&&(it=!1,st(L),L=-1),Z=!0;var J=H;try{e:{for(Q(V),p=h(E);p!==null&&!(p.expirationTime>V&&Mt());){var dt=p.callback;if(typeof dt=="function"){p.callback=null,H=p.priorityLevel;var gt=dt(p.expirationTime<=V);if(V=i.unstable_now(),typeof gt=="function"){p.callback=gt,Q(V),Y=!0;break e}p===h(E)&&f(E),Q(V)}else f(E);p=h(E)}if(p!==null)Y=!0;else{var ee=h(M);ee!==null&&Ct(ct,ee.startTime-V),Y=!1}}break t}finally{p=null,H=J,Z=!1}Y=void 0}}finally{Y?bt():z=!1}}}var bt;if(typeof Ht=="function")bt=function(){Ht(yt)};else if(typeof MessageChannel<"u"){var B=new MessageChannel,mt=B.port2;B.port1.onmessage=yt,bt=function(){mt.postMessage(null)}}else bt=function(){Et(yt,0)};function Ct(V,Y){L=Et(function(){V(i.unstable_now())},Y)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(V){V.callback=null},i.unstable_forceFrameRate=function(V){0>V||125<V?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):at=0<V?Math.floor(1e3/V):5},i.unstable_getCurrentPriorityLevel=function(){return H},i.unstable_next=function(V){switch(H){case 1:case 2:case 3:var Y=3;break;default:Y=H}var J=H;H=Y;try{return V()}finally{H=J}},i.unstable_requestPaint=function(){ft=!0},i.unstable_runWithPriority=function(V,Y){switch(V){case 1:case 2:case 3:case 4:case 5:break;default:V=3}var J=H;H=V;try{return Y()}finally{H=J}},i.unstable_scheduleCallback=function(V,Y,J){var dt=i.unstable_now();switch(typeof J=="object"&&J!==null?(J=J.delay,J=typeof J=="number"&&0<J?dt+J:dt):J=dt,V){case 1:var gt=-1;break;case 2:gt=250;break;case 5:gt=1073741823;break;case 4:gt=1e4;break;default:gt=5e3}return gt=J+gt,V={id:q++,callback:Y,priorityLevel:V,startTime:J,expirationTime:gt,sortIndex:-1},J>dt?(V.sortIndex=J,r(M,V),h(E)===null&&V===h(M)&&(it?(st(L),L=-1):it=!0,Ct(ct,J-dt))):(V.sortIndex=gt,r(E,V),F||Z||(F=!0,z||(z=!0,bt()))),V},i.unstable_shouldYield=Mt,i.unstable_wrapCallback=function(V){var Y=H;return function(){var J=H;H=Y;try{return V.apply(this,arguments)}finally{H=J}}}})(vo)),vo}var Yh;function Xy(){return Yh||(Yh=1,ho.exports=Vy()),ho.exports}var yo={exports:{}},ke={};/**
*/var Yh;function Ly(){return Yh||(Yh=1,(function(i){function r(Q,Y){var J=Q.length;Q.push(Y);t:for(;0<J;){var dt=J-1>>>1,gt=Q[dt];if(0<g(gt,Y))Q[dt]=Y,Q[J]=gt,J=dt;else break t}}function h(Q){return Q.length===0?null:Q[0]}function f(Q){if(Q.length===0)return null;var Y=Q[0],J=Q.pop();if(J!==Y){Q[0]=J;t:for(var dt=0,gt=Q.length,ee=gt>>>1;dt<ee;){var fe=2*(dt+1)-1,X=Q[fe],y=fe+1,k=Q[y];if(0>g(X,J))y<gt&&0>g(k,X)?(Q[dt]=k,Q[y]=J,dt=y):(Q[dt]=X,Q[fe]=J,dt=fe);else if(y<gt&&0>g(k,J))Q[dt]=k,Q[y]=J,dt=y;else break t}}return Y}function g(Q,Y){var J=Q.sortIndex-Y.sortIndex;return J!==0?J:Q.id-Y.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var A=performance;i.unstable_now=function(){return A.now()}}else{var O=Date,b=O.now();i.unstable_now=function(){return O.now()-b}}var E=[],M=[],q=1,p=null,H=3,Z=!1,F=!1,it=!1,ft=!1,Et=typeof setTimeout=="function"?setTimeout:null,st=typeof clearTimeout=="function"?clearTimeout:null,Ht=typeof setImmediate<"u"?setImmediate:null;function V(Q){for(var Y=h(M);Y!==null;){if(Y.callback===null)f(M);else if(Y.startTime<=Q)f(M),Y.sortIndex=Y.expirationTime,r(E,Y);else break;Y=h(M)}}function ct(Q){if(it=!1,V(Q),!F)if(h(E)!==null)F=!0,z||(z=!0,bt());else{var Y=h(M);Y!==null&&Ct(ct,Y.startTime-Q)}}var z=!1,L=-1,at=5,jt=-1;function Mt(){return ft?!0:!(i.unstable_now()-jt<at)}function yt(){if(ft=!1,z){var Q=i.unstable_now();jt=Q;var Y=!0;try{t:{F=!1,it&&(it=!1,st(L),L=-1),Z=!0;var J=H;try{e:{for(V(Q),p=h(E);p!==null&&!(p.expirationTime>Q&&Mt());){var dt=p.callback;if(typeof dt=="function"){p.callback=null,H=p.priorityLevel;var gt=dt(p.expirationTime<=Q);if(Q=i.unstable_now(),typeof gt=="function"){p.callback=gt,V(Q),Y=!0;break e}p===h(E)&&f(E),V(Q)}else f(E);p=h(E)}if(p!==null)Y=!0;else{var ee=h(M);ee!==null&&Ct(ct,ee.startTime-Q),Y=!1}}break t}finally{p=null,H=J,Z=!1}Y=void 0}}finally{Y?bt():z=!1}}}var bt;if(typeof Ht=="function")bt=function(){Ht(yt)};else if(typeof MessageChannel<"u"){var B=new MessageChannel,mt=B.port2;B.port1.onmessage=yt,bt=function(){mt.postMessage(null)}}else bt=function(){Et(yt,0)};function Ct(Q,Y){L=Et(function(){Q(i.unstable_now())},Y)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(Q){Q.callback=null},i.unstable_forceFrameRate=function(Q){0>Q||125<Q?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):at=0<Q?Math.floor(1e3/Q):5},i.unstable_getCurrentPriorityLevel=function(){return H},i.unstable_next=function(Q){switch(H){case 1:case 2:case 3:var Y=3;break;default:Y=H}var J=H;H=Y;try{return Q()}finally{H=J}},i.unstable_requestPaint=function(){ft=!0},i.unstable_runWithPriority=function(Q,Y){switch(Q){case 1:case 2:case 3:case 4:case 5:break;default:Q=3}var J=H;H=Q;try{return Y()}finally{H=J}},i.unstable_scheduleCallback=function(Q,Y,J){var dt=i.unstable_now();switch(typeof J=="object"&&J!==null?(J=J.delay,J=typeof J=="number"&&0<J?dt+J:dt):J=dt,Q){case 1:var gt=-1;break;case 2:gt=250;break;case 5:gt=1073741823;break;case 4:gt=1e4;break;default:gt=5e3}return gt=J+gt,Q={id:q++,callback:Y,priorityLevel:Q,startTime:J,expirationTime:gt,sortIndex:-1},J>dt?(Q.sortIndex=J,r(M,Q),h(E)===null&&Q===h(M)&&(it?(st(L),L=-1):it=!0,Ct(ct,J-dt))):(Q.sortIndex=gt,r(E,Q),F||Z||(F=!0,z||(z=!0,bt()))),Q},i.unstable_shouldYield=Mt,i.unstable_wrapCallback=function(Q){var Y=H;return function(){var J=H;H=Y;try{return Q.apply(this,arguments)}finally{H=J}}}})(yo)),yo}var Gh;function Zy(){return Gh||(Gh=1,vo.exports=Ly()),vo.exports}var go={exports:{}},ke={};/**
* @license React
* react-dom.production.js
*
@@ -36,7 +36,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Gh;function Ly(){if(Gh)return ke;Gh=1;var i=xo();function r(q){var p="https://react.dev/errors/"+q;if(1<arguments.length){p+="?args[]="+encodeURIComponent(arguments[1]);for(var H=2;H<arguments.length;H++)p+="&args[]="+encodeURIComponent(arguments[H])}return"Minified React error #"+q+"; visit "+p+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function h(){}var f={d:{f:h,r:function(){throw Error(r(522))},D:h,C:h,L:h,m:h,X:h,S:h,M:h},p:0,findDOMNode:null},g=Symbol.for("react.portal"),A=Symbol.for("react.recoverable"),O=Symbol.for("react.optimistic_key");function b(q,p,H){var Z=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:g,key:Z==null?null:Z===O?O:""+Z,children:q,containerInfo:p,implementation:H}}var E=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function M(q,p){if(q==="font")return"";if(typeof p=="string")return p==="use-credentials"?p:""}return ke.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,ke.browser=function(q){return{$$typeof:A,_reason:q}},ke.createPortal=function(q,p){var H=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!p||p.nodeType!==1&&p.nodeType!==9&&p.nodeType!==11)throw Error(r(299));return b(q,p,null,H)},ke.flushSync=function(q){var p=E.T,H=f.p;try{if(E.T=null,f.p=2,q)return q()}finally{E.T=p,f.p=H,f.d.f()}},ke.preconnect=function(q,p){typeof q=="string"&&(p?(p=p.crossOrigin,p=typeof p=="string"?p==="use-credentials"?p:"":void 0):p=null,f.d.C(q,p))},ke.prefetchDNS=function(q){typeof q=="string"&&f.d.D(q)},ke.preinit=function(q,p){if(typeof q=="string"&&p&&typeof p.as=="string"){var H=p.as,Z=M(H,p.crossOrigin),F=typeof p.integrity=="string"?p.integrity:void 0,it=typeof p.fetchPriority=="string"?p.fetchPriority:void 0;H==="style"?f.d.S(q,typeof p.precedence=="string"?p.precedence:void 0,{crossOrigin:Z,integrity:F,fetchPriority:it}):H==="script"&&f.d.X(q,{crossOrigin:Z,integrity:F,fetchPriority:it,nonce:typeof p.nonce=="string"?p.nonce:void 0})}},ke.preinitModule=function(q,p){if(typeof q=="string")if(typeof p=="object"&&p!==null){if(p.as==null||p.as==="script"){var H=M(p.as,p.crossOrigin);f.d.M(q,{crossOrigin:H,integrity:typeof p.integrity=="string"?p.integrity:void 0,nonce:typeof p.nonce=="string"?p.nonce:void 0,fetchPriority:typeof p.fetchPriority=="string"?p.fetchPriority:void 0})}}else p==null&&f.d.M(q)},ke.preload=function(q,p){if(typeof q=="string"&&typeof p=="object"&&p!==null&&typeof p.as=="string"){var H=p.as,Z=M(H,p.crossOrigin);f.d.L(q,H,{crossOrigin:Z,integrity:typeof p.integrity=="string"?p.integrity:void 0,nonce:typeof p.nonce=="string"?p.nonce:void 0,type:typeof p.type=="string"?p.type:void 0,fetchPriority:typeof p.fetchPriority=="string"?p.fetchPriority:void 0,referrerPolicy:typeof p.referrerPolicy=="string"?p.referrerPolicy:void 0,imageSrcSet:typeof p.imageSrcSet=="string"?p.imageSrcSet:void 0,imageSizes:typeof p.imageSizes=="string"?p.imageSizes:void 0,media:typeof p.media=="string"?p.media:void 0})}},ke.preloadModule=function(q,p){if(typeof q=="string")if(p){var H=M(p.as,p.crossOrigin);f.d.m(q,{as:typeof p.as=="string"&&p.as!=="script"?p.as:void 0,crossOrigin:H,integrity:typeof p.integrity=="string"?p.integrity:void 0,nonce:typeof p.nonce=="string"?p.nonce:void 0,fetchPriority:typeof p.fetchPriority=="string"?p.fetchPriority:void 0})}else f.d.m(q)},ke.requestFormReset=function(q){f.d.r(q)},ke.unstable_batchedUpdates=function(q,p){return q(p)},ke.useFormState=function(q,p,H){return E.H.useFormState(q,p,H)},ke.useFormStatus=function(){return E.H.useHostTransitionStatus()},ke.version="19.3.1",ke}var Qh;function Zy(){if(Qh)return yo.exports;Qh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(r){console.error(r)}}return i(),yo.exports=Ly(),yo.exports}/**
*/var Vh;function Ky(){if(Vh)return ke;Vh=1;var i=jo();function r(q){var p="https://react.dev/errors/"+q;if(1<arguments.length){p+="?args[]="+encodeURIComponent(arguments[1]);for(var H=2;H<arguments.length;H++)p+="&args[]="+encodeURIComponent(arguments[H])}return"Minified React error #"+q+"; visit "+p+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function h(){}var f={d:{f:h,r:function(){throw Error(r(522))},D:h,C:h,L:h,m:h,X:h,S:h,M:h},p:0,findDOMNode:null},g=Symbol.for("react.portal"),A=Symbol.for("react.recoverable"),O=Symbol.for("react.optimistic_key");function b(q,p,H){var Z=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:g,key:Z==null?null:Z===O?O:""+Z,children:q,containerInfo:p,implementation:H}}var E=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function M(q,p){if(q==="font")return"";if(typeof p=="string")return p==="use-credentials"?p:""}return ke.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,ke.browser=function(q){return{$$typeof:A,_reason:q}},ke.createPortal=function(q,p){var H=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!p||p.nodeType!==1&&p.nodeType!==9&&p.nodeType!==11)throw Error(r(299));return b(q,p,null,H)},ke.flushSync=function(q){var p=E.T,H=f.p;try{if(E.T=null,f.p=2,q)return q()}finally{E.T=p,f.p=H,f.d.f()}},ke.preconnect=function(q,p){typeof q=="string"&&(p?(p=p.crossOrigin,p=typeof p=="string"?p==="use-credentials"?p:"":void 0):p=null,f.d.C(q,p))},ke.prefetchDNS=function(q){typeof q=="string"&&f.d.D(q)},ke.preinit=function(q,p){if(typeof q=="string"&&p&&typeof p.as=="string"){var H=p.as,Z=M(H,p.crossOrigin),F=typeof p.integrity=="string"?p.integrity:void 0,it=typeof p.fetchPriority=="string"?p.fetchPriority:void 0;H==="style"?f.d.S(q,typeof p.precedence=="string"?p.precedence:void 0,{crossOrigin:Z,integrity:F,fetchPriority:it}):H==="script"&&f.d.X(q,{crossOrigin:Z,integrity:F,fetchPriority:it,nonce:typeof p.nonce=="string"?p.nonce:void 0})}},ke.preinitModule=function(q,p){if(typeof q=="string")if(typeof p=="object"&&p!==null){if(p.as==null||p.as==="script"){var H=M(p.as,p.crossOrigin);f.d.M(q,{crossOrigin:H,integrity:typeof p.integrity=="string"?p.integrity:void 0,nonce:typeof p.nonce=="string"?p.nonce:void 0,fetchPriority:typeof p.fetchPriority=="string"?p.fetchPriority:void 0})}}else p==null&&f.d.M(q)},ke.preload=function(q,p){if(typeof q=="string"&&typeof p=="object"&&p!==null&&typeof p.as=="string"){var H=p.as,Z=M(H,p.crossOrigin);f.d.L(q,H,{crossOrigin:Z,integrity:typeof p.integrity=="string"?p.integrity:void 0,nonce:typeof p.nonce=="string"?p.nonce:void 0,type:typeof p.type=="string"?p.type:void 0,fetchPriority:typeof p.fetchPriority=="string"?p.fetchPriority:void 0,referrerPolicy:typeof p.referrerPolicy=="string"?p.referrerPolicy:void 0,imageSrcSet:typeof p.imageSrcSet=="string"?p.imageSrcSet:void 0,imageSizes:typeof p.imageSizes=="string"?p.imageSizes:void 0,media:typeof p.media=="string"?p.media:void 0})}},ke.preloadModule=function(q,p){if(typeof q=="string")if(p){var H=M(p.as,p.crossOrigin);f.d.m(q,{as:typeof p.as=="string"&&p.as!=="script"?p.as:void 0,crossOrigin:H,integrity:typeof p.integrity=="string"?p.integrity:void 0,nonce:typeof p.nonce=="string"?p.nonce:void 0,fetchPriority:typeof p.fetchPriority=="string"?p.fetchPriority:void 0})}else f.d.m(q)},ke.requestFormReset=function(q){f.d.r(q)},ke.unstable_batchedUpdates=function(q,p){return q(p)},ke.useFormState=function(q,p,H){return E.H.useFormState(q,p,H)},ke.useFormStatus=function(){return E.H.useHostTransitionStatus()},ke.version="19.3.1",ke}var Qh;function Jy(){if(Qh)return go.exports;Qh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(r){console.error(r)}}return i(),go.exports=Ky(),go.exports}/**
* @license React
* react-dom-client.production.js
*
@@ -44,37 +44,37 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Vh;function Ky(){if(Vh)return si;Vh=1;var i=Xy(),r=xo(),h=Zy();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)e+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function g(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function A(t){for(var e=t,l=e;l&&!l.alternate;)e=l,(e.flags&4098)!==0&&(t=e.return),l=e.return;for(;e.return;)e=e.return;return e.tag===3?t:null}function O(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function b(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function E(t){if(A(t)!==t)throw Error(f(188))}function M(t){var e=t.alternate;if(!e){if(e=A(t),e===null)throw Error(f(188));return e!==t?null:t}for(var l=t,a=e;;){var n=l.return;if(n===null)break;var u=n.alternate;if(u===null){if(a=n.return,a!==null){l=a;continue}break}if(n.child===u.child){for(u=n.child;u;){if(u===l)return E(n),t;if(u===a)return E(n),e;u=u.sibling}throw Error(f(188))}if(l.return!==a.return)l=n,a=u;else{for(var s=!1,o=n.child;o;){if(o===l){s=!0,l=n,a=u;break}if(o===a){s=!0,a=n,l=u;break}o=o.sibling}if(!s){for(o=u.child;o;){if(o===l){s=!0,l=u,a=n;break}if(o===a){s=!0,a=u,l=n;break}o=o.sibling}if(!s)throw Error(f(189))}}if(l.alternate!==a)throw Error(f(190))}if(l.tag!==3)throw Error(f(188));return l.stateNode.current===l?t:e}function q(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=q(t),e!==null)return e;t=t.sibling}return null}function p(t,e,l,a,n,u){for(;t!==null;){if((t.tag===5||t.tag===27||t.tag===6)&&l(t,a,n,u)||(t.tag!==22||t.memoizedState===null)&&(e||t.tag!==5&&t.tag!==27)&&p(t.child,e,l,a,n,u))return!0;t=t.sibling}return!1}function H(t){for(t=t.return;t!==null;){if(t.tag===3||t.tag===5||t.tag===27)return t;t=t.return}return null}function Z(t){var e=!1;for(t=t.return;t!==null&&(t.tag===4&&(e=!0),!(t.tag===3||t.tag===5||t.tag===27));)t=t.return;return e}function F(t){var e=[null,null],l=H(t);return l===null||it(e,t,l.child,{foundSelf:!1}),e}function it(t,e,l,a){for(;l!==null;){if(l===e)a.foundSelf=!0;else if(l.tag===5||l.tag===27||l.tag===6){if(a.foundSelf)return t[1]=l,!0;t[0]=l}else if((l.tag!==22||l.memoizedState===null)&&it(t,e,l.child,a))return!0;l=l.sibling}return!1}function ft(t){switch(t.tag){case 5:case 27:case 6:return t.stateNode;case 3:return t.stateNode.containerInfo;default:throw Error(f(559))}}var Et=null,st=null;function Ht(t,e,l){return t===l?!0:t===e?(Et=t,!0):!1}function Q(t,e,l){return t===l?(st=t,!1):t===e?(st!==null&&(Et=t),!0):!1}function ct(t){if(t===null)return null;do t=t===null?null:t.return;while(t&&t.tag!==5&&t.tag!==27&&t.tag!==3);return t||null}function z(t,e,l){for(var a=0,n=t;n;n=l(n))a++;n=0;for(var u=e;u;u=l(u))n++;for(;0<a-n;)t=l(t),a--;for(;0<n-a;)e=l(e),n--;for(;a--;){if(t===e||e!==null&&t===e.alternate)return t;t=l(t),e=l(e)}return null}var L=Object.assign,at=Symbol.for("react.element"),jt=Symbol.for("react.transitional.element"),Mt=Symbol.for("react.portal"),yt=Symbol.for("react.fragment"),bt=Symbol.for("react.strict_mode"),B=Symbol.for("react.profiler"),mt=Symbol.for("react.consumer"),Ct=Symbol.for("react.context"),V=Symbol.for("react.forward_ref"),Y=Symbol.for("react.suspense"),J=Symbol.for("react.suspense_list"),dt=Symbol.for("react.memo"),gt=Symbol.for("react.lazy"),ee=Symbol.for("react.activity"),fe=Symbol.for("react.legacy_hidden"),X=Symbol.for("react.memo_cache_sentinel"),y=Symbol.for("react.view_transition"),k=Symbol.for("react.recoverable"),nt=Symbol.iterator;function et(t){return t===null||typeof t!="object"?null:(t=nt&&t[nt]||t["@@iterator"],typeof t=="function"?t:null)}var xt=Symbol.for("react.client.reference");function Nt(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===xt?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case yt:return"Fragment";case B:return"Profiler";case bt:return"StrictMode";case Y:return"Suspense";case J:return"SuspenseList";case ee:return"Activity";case y:return"ViewTransition"}if(typeof t=="object")switch(t.$$typeof){case Mt:return"Portal";case Ct:return t.displayName||"Context";case mt:return(t._context.displayName||"Context")+".Consumer";case V:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case dt:return e=t.displayName||null,e!==null?e:Nt(t.type)||"Memo";case gt:e=t._payload,t=t._init;try{return Nt(t(e))}catch{}}return null}var Ot=Array.isArray,W=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ht=h.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,Ne={pending:!1,data:null,method:null,action:Line truncated
*/var Xh;function Fy(){if(Xh)return si;Xh=1;var i=Zy(),r=jo(),h=Jy();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)e+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function g(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function A(t){for(var e=t,l=e;l&&!l.alternate;)e=l,(e.flags&4098)!==0&&(t=e.return),l=e.return;for(;e.return;)e=e.return;return e.tag===3?t:null}function O(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function b(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function E(t){if(A(t)!==t)throw Error(f(188))}function M(t){var e=t.alternate;if(!e){if(e=A(t),e===null)throw Error(f(188));return e!==t?null:t}for(var l=t,a=e;;){var n=l.return;if(n===null)break;var u=n.alternate;if(u===null){if(a=n.return,a!==null){l=a;continue}break}if(n.child===u.child){for(u=n.child;u;){if(u===l)return E(n),t;if(u===a)return E(n),e;u=u.sibling}throw Error(f(188))}if(l.return!==a.return)l=n,a=u;else{for(var s=!1,o=n.child;o;){if(o===l){s=!0,l=n,a=u;break}if(o===a){s=!0,a=n,l=u;break}o=o.sibling}if(!s){for(o=u.child;o;){if(o===l){s=!0,l=u,a=n;break}if(o===a){s=!0,a=u,l=n;break}o=o.sibling}if(!s)throw Error(f(189))}}if(l.alternate!==a)throw Error(f(190))}if(l.tag!==3)throw Error(f(188));return l.stateNode.current===l?t:e}function q(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=q(t),e!==null)return e;t=t.sibling}return null}function p(t,e,l,a,n,u){for(;t!==null;){if((t.tag===5||t.tag===27||t.tag===6)&&l(t,a,n,u)||(t.tag!==22||t.memoizedState===null)&&(e||t.tag!==5&&t.tag!==27)&&p(t.child,e,l,a,n,u))return!0;t=t.sibling}return!1}function H(t){for(t=t.return;t!==null;){if(t.tag===3||t.tag===5||t.tag===27)return t;t=t.return}return null}function Z(t){var e=!1;for(t=t.return;t!==null&&(t.tag===4&&(e=!0),!(t.tag===3||t.tag===5||t.tag===27));)t=t.return;return e}function F(t){var e=[null,null],l=H(t);return l===null||it(e,t,l.child,{foundSelf:!1}),e}function it(t,e,l,a){for(;l!==null;){if(l===e)a.foundSelf=!0;else if(l.tag===5||l.tag===27||l.tag===6){if(a.foundSelf)return t[1]=l,!0;t[0]=l}else if((l.tag!==22||l.memoizedState===null)&&it(t,e,l.child,a))return!0;l=l.sibling}return!1}function ft(t){switch(t.tag){case 5:case 27:case 6:return t.stateNode;case 3:return t.stateNode.containerInfo;default:throw Error(f(559))}}var Et=null,st=null;function Ht(t,e,l){return t===l?!0:t===e?(Et=t,!0):!1}function V(t,e,l){return t===l?(st=t,!1):t===e?(st!==null&&(Et=t),!0):!1}function ct(t){if(t===null)return null;do t=t===null?null:t.return;while(t&&t.tag!==5&&t.tag!==27&&t.tag!==3);return t||null}function z(t,e,l){for(var a=0,n=t;n;n=l(n))a++;n=0;for(var u=e;u;u=l(u))n++;for(;0<a-n;)t=l(t),a--;for(;0<n-a;)e=l(e),n--;for(;a--;){if(t===e||e!==null&&t===e.alternate)return t;t=l(t),e=l(e)}return null}var L=Object.assign,at=Symbol.for("react.element"),jt=Symbol.for("react.transitional.element"),Mt=Symbol.for("react.portal"),yt=Symbol.for("react.fragment"),bt=Symbol.for("react.strict_mode"),B=Symbol.for("react.profiler"),mt=Symbol.for("react.consumer"),Ct=Symbol.for("react.context"),Q=Symbol.for("react.forward_ref"),Y=Symbol.for("react.suspense"),J=Symbol.for("react.suspense_list"),dt=Symbol.for("react.memo"),gt=Symbol.for("react.lazy"),ee=Symbol.for("react.activity"),fe=Symbol.for("react.legacy_hidden"),X=Symbol.for("react.memo_cache_sentinel"),y=Symbol.for("react.view_transition"),k=Symbol.for("react.recoverable"),nt=Symbol.iterator;function et(t){return t===null||typeof t!="object"?null:(t=nt&&t[nt]||t["@@iterator"],typeof t=="function"?t:null)}var xt=Symbol.for("react.client.reference");function Nt(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===xt?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case yt:return"Fragment";case B:return"Profiler";case bt:return"StrictMode";case Y:return"Suspense";case J:return"SuspenseList";case ee:return"Activity";case y:return"ViewTransition"}if(typeof t=="object")switch(t.$$typeof){case Mt:return"Portal";case Ct:return t.displayName||"Context";case mt:return(t._context.displayName||"Context")+".Consumer";case Q:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case dt:return e=t.displayName||null,e!==null?e:Nt(t.type)||"Memo";case gt:e=t._payload,t=t._init;try{return Nt(t(e))}catch{}}return null}var Ot=Array.isArray,W=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ht=h.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,Ne={pending:!1,data:null,method:null,action:Line truncated
at`)?" (<anonymous>)":-1<l.stack.indexOf("@")?"@unknown:0:0":""}return`
`+xl+t+Dl}var me=!1;function il(t,e){if(!t||me)return"";me=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var D=function(){throw Error()};if(Object.defineProperty(D.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(D,[])}catch(K){var S=K}Reflect.construct(t,[],D)}else{try{D.call()}catch(K){S=K}D=!1;try{var _=Object.getOwnPropertyDescriptor(t.prototype,"props");Object.defineProperty(t.prototype,"props",{configurable:!0,set:function(){throw Error()}}),D=!0,new t}finally{D&&(_!==void 0?Object.defineProperty(t.prototype,"props",_):delete t.prototype.props)}}}else{try{throw Error()}catch(K){S=K}(D=t())&&typeof D.catch=="function"&&D.catch(function(){})}}catch(K){if(K&&S&&typeof K.stack=="string")return[K.stack,S.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var n=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");n&&n.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var u=a.DetermineComponentFrameRoot(),s=u[0],o=u[1];if(s&&o){var m=s.split(`
`),j=o.split(`
`);for(n=a=0;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;for(;n<j.length&&!j[n].includes("DetermineComponentFrameRoot");)n++;if(a===m.length||n===j.length)for(a=m.length-1,n=j.length-1;1<=a&&0<=n&&m[a]!==j[n];)n--;for(;1<=a&&0<=n;a--,n--)if(m[a]!==j[n]){if(a!==1||n!==1)do if(a--,n--,0>n||m[a]!==j[n]){var C=`
`+m[a].replace(" at new "," at ");return t.displayName&&C.includes("<anonymous>")&&(C=C.replace("<anonymous>",t.displayName)),C}while(1<=a&&0<=n);break}}}finally{me=!1,Error.prepareStackTrace=l}return(l=t?t.displayName||t.name:"")?de(l):""}function kl(t,e){switch(t.tag){case 26:case 27:case 5:return de(t.type);case 16:return de("Lazy");case 13:return t.child!==e&&e!==null?de("Suspense Fallback"):de("Suspense");case 19:return de("SuspenseList");case 0:case 15:return il(t.type,!1);case 11:return il(t.type.render,!1);case 1:return il(t.type,!0);case 31:return de("Activity");case 30:return de("ViewTransition");default:return""}}function Fl(t){try{var e="",l=null;do e+=kl(t,l),l=t,t=t.return;while(t);return e}catch(a){return`
Error generating stack: `+a.message+`
`+a.stack}}var ue=Object.prototype.hasOwnProperty,jl=i.unstable_scheduleCallback,wl=i.unstable_cancelCallback,Ul=i.unstable_shouldYield,cl=i.unstable_requestPaint,Xt=i.unstable_now,bl=i.unstable_getCurrentPriorityLevel,Tl=i.unstable_ImmediatePriority,_l=i.unstable_UserBlockingPriority,Se=i.unstable_NormalPriority,Wl=i.unstable_LowPriority,$l=i.unstable_IdlePriority,Il=i.log,du=i.unstable_setDisableYieldValue,Pl=null,Ce=null;function sl(t){if(typeof Il=="function"&&du(t),Ce&&typeof Ce.setStrictMode=="function")try{Ce.setStrictMode(Pl,t)}catch{}}var Ae=Math.clz32?Math.clz32:vu,mu=Math.log,hu=Math.LN2;function vu(t){return t>>>=0,t===0?32:31-(mu(t)/hu|0)|0}var pa=256,Sa=262144,xa=4194304;function U(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&-t;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function w(t,e,l){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,s=t.pingedLanes;t=t.warmLanes;var o=a&134217727;return o!==0?(a=o&~u,a!==0?n=U(a):(s&=o,s!==0?n=U(s):l||(l=o&~t,l!==0&&(n=U(l))))):(o=a&~u,o!==0?n=U(o):s!==0?n=U(s):l||(l=a&~t,l!==0&&(n=U(l)))),n===0?0:e!==0&&e!==n&&(e&u)===0&&(u=n&-n,l=e&-e,u>=l||u===32&&(l&4194048)!==0)?e:n}function P(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function N(t,e){(e&8)!==0&&(e|=e&32);var l=t.entangledLanes;if(l!==0)for(t=t.entanglements,l&=e;0<l;){var a=31-Ae(l),n=1<<a;e|=t[a],l&=~n}return e}function G(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function lt(){var t=xa;return xa<<=1,(xa&62914560)===0&&(xa=4194304),t}function Ut(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function pt(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function $t(t,e,l,a,n,u){var s=t.pendingLanes;t.pendingLanes=l,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=l,t.entangledLanes&=l,t.errorRecoveryDisabledLanes&=l,t.shellSuspendCounter=0;var o=t.entanglements,m=t.expirationTimes,j=t.hiddenUpdates;for(l=s&~l;0<l;){var C=31-Ae(l),D=1<<C;o[C]=0,m[C]=-1;var S=j[C];if(S!==null)for(j[C]=null,C=0;C<S.length;C++){var _=S[C];_!==null&&(_.lane&=-536870913)}l&=~D}a!==0&&rl(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(s&~e))}function rl(t,e,l){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-Ae(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|l&261930}function ja(t,e){var l=t.entangledLanes|=e;for(t=t.entanglements;l;){var a=31-Ae(l),n=1<<a;n&e|t[a]&e&&(t[a]|=e),l&=~n}}function fi(t,e){var l=e&-e;return l=(l&42)!==0?1:yu(l),(l&(t.suspendedLanes|e))!==0?0:l}function yu(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function Fa(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function ta(){var t=ht.p;return t!==0?t:(t=window.event,t===void 0?32:Ah(t.type))}function ea(t,e){var l=ht.p;try{return ht.p=t,e()}finally{ht.p=l}}var El=Math.random().toString(36).slice(2),he="__reactFiber$"+El,It="__reactProps$"+El,xe="__reactContainer$"+El,di="__reactEvents$"+El,ev="__reactListeners$"+El,lv="__reactHandles$"+El,To="__reactResources$"+El,gu="__reactMarker$"+El,mi="__reactLoad$"+El;function hi(t){delete t[he],delete t[It],delete t[ev],delete t[lv]}function Wa(t){var e;if(e=t[he])return e;for(var l=t.parentNode;l;){if(e=l[xe]||l[he]){if(l=e.alternate,e.child!==null||l!==null&&l.child!==null)for(t=sh(t);t!==null;){if(l=t[he])return l;t=sh(t)}return e}t=l,l=t.parentNode}return null}function xn(t){if(t=t[he]||t[xe]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function pu(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function jn(t){var e=t[To];return e||(e=t[To]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function be(t){t[gu]=!0}function _o(t){t[mi]=void 0}var Eo=new Set,No={};function $a(t,e){bn(t,e),bn(t+"Capture",e)}function bn(t,e)Line truncated
`).replace(V0,"")}function Qm(t,e){return e=Gm(e),Gm(t)===e}function Kt(t,e,l,a,n,u){switch(l){case"children":if(typeof a=="string")e==="body"||e==="textarea"&&a===""||_n(t,a);else if(typeof a=="number"||typeof a=="bigint")e!=="body"&&_n(t,""+a);else return;break;case"className":yi(t,"class",a);break;case"tabIndex":yi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":yi(t,l,a);break;case"style":Uo(t,a,u);return;case"data":if(e!=="object"){yi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||l!=="href")){t.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=gi(a),t.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(l==="formAction"?(e!=="input"&&Kt(t,e,"name",n.name,n,null),Kt(t,e,"formEncType",n.formEncType,n,null),Kt(t,e,"formMethod",n.formMethod,n,null),Kt(t,e,"formTarget",n.formTarget,n,null)):(Kt(t,e,"encType",n.encType,n,null),Kt(t,e,"method",n.method,n,null),Kt(t,e,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=gi(a),t.setAttribute(l,a);break;case"onClick":a!=null&&(t.onclick=Hl);return;case"onScroll":a!=null&&Dt("scroll",t);return;case"onScrollEnd":a!=null&&Dt("scrollend",t);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}l=gi(a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,""):t.removeAttribute(l);break;case"capture":case"download":a===!0?t.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(l,a):t.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(l):t.setAttribute(l,a);break;case"popover":Dt("beforetoggle",t),Dt("toggle",t),vi(t,"popover",a);break;case"xlinkActuate":la(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":la(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":la(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":la(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":la(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":la(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":la(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":la(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":la(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":vi(t,"is",a);break;case"innerText":case"textContent":return;default:if(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")l=sv.get(l)||l,vi(t,l,a);else return}qt=!0}function Gr(t,e,l,a,n,u){switch(l){case"style":Uo(t,a,u);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"children":if(typeof a=="string")_n(t,a);else if(typeof a=="number"||typeof a=="bigint")_n(t,""+a);else return;break;case"onScroll":a!=null&&Dt("scroll",t);return;case"onScrollEnd":a!=null&&Dt("scroLine truncated
`+a.stack}}var ue=Object.prototype.hasOwnProperty,jl=i.unstable_scheduleCallback,wl=i.unstable_cancelCallback,Ul=i.unstable_shouldYield,cl=i.unstable_requestPaint,Xt=i.unstable_now,bl=i.unstable_getCurrentPriorityLevel,Tl=i.unstable_ImmediatePriority,_l=i.unstable_UserBlockingPriority,Se=i.unstable_NormalPriority,Wl=i.unstable_LowPriority,$l=i.unstable_IdlePriority,Il=i.log,du=i.unstable_setDisableYieldValue,Pl=null,Ce=null;function sl(t){if(typeof Il=="function"&&du(t),Ce&&typeof Ce.setStrictMode=="function")try{Ce.setStrictMode(Pl,t)}catch{}}var Ae=Math.clz32?Math.clz32:vu,mu=Math.log,hu=Math.LN2;function vu(t){return t>>>=0,t===0?32:31-(mu(t)/hu|0)|0}var pa=256,Sa=262144,xa=4194304;function U(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&-t;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function w(t,e,l){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,s=t.pingedLanes;t=t.warmLanes;var o=a&134217727;return o!==0?(a=o&~u,a!==0?n=U(a):(s&=o,s!==0?n=U(s):l||(l=o&~t,l!==0&&(n=U(l))))):(o=a&~u,o!==0?n=U(o):s!==0?n=U(s):l||(l=a&~t,l!==0&&(n=U(l)))),n===0?0:e!==0&&e!==n&&(e&u)===0&&(u=n&-n,l=e&-e,u>=l||u===32&&(l&4194048)!==0)?e:n}function P(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function N(t,e){(e&8)!==0&&(e|=e&32);var l=t.entangledLanes;if(l!==0)for(t=t.entanglements,l&=e;0<l;){var a=31-Ae(l),n=1<<a;e|=t[a],l&=~n}return e}function G(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function lt(){var t=xa;return xa<<=1,(xa&62914560)===0&&(xa=4194304),t}function Ut(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function pt(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function $t(t,e,l,a,n,u){var s=t.pendingLanes;t.pendingLanes=l,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=l,t.entangledLanes&=l,t.errorRecoveryDisabledLanes&=l,t.shellSuspendCounter=0;var o=t.entanglements,m=t.expirationTimes,j=t.hiddenUpdates;for(l=s&~l;0<l;){var C=31-Ae(l),D=1<<C;o[C]=0,m[C]=-1;var S=j[C];if(S!==null)for(j[C]=null,C=0;C<S.length;C++){var _=S[C];_!==null&&(_.lane&=-536870913)}l&=~D}a!==0&&rl(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(s&~e))}function rl(t,e,l){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-Ae(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|l&261930}function ja(t,e){var l=t.entangledLanes|=e;for(t=t.entanglements;l;){var a=31-Ae(l),n=1<<a;n&e|t[a]&e&&(t[a]|=e),l&=~n}}function di(t,e){var l=e&-e;return l=(l&42)!==0?1:yu(l),(l&(t.suspendedLanes|e))!==0?0:l}function yu(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function Fa(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function ta(){var t=ht.p;return t!==0?t:(t=window.event,t===void 0?32:Mh(t.type))}function ea(t,e){var l=ht.p;try{return ht.p=t,e()}finally{ht.p=l}}var El=Math.random().toString(36).slice(2),he="__reactFiber$"+El,It="__reactProps$"+El,xe="__reactContainer$"+El,mi="__reactEvents$"+El,av="__reactListeners$"+El,nv="__reactHandles$"+El,_o="__reactResources$"+El,gu="__reactMarker$"+El,hi="__reactLoad$"+El;function vi(t){delete t[he],delete t[It],delete t[av],delete t[nv]}function Wa(t){var e;if(e=t[he])return e;for(var l=t.parentNode;l;){if(e=l[xe]||l[he]){if(l=e.alternate,e.child!==null||l!==null&&l.child!==null)for(t=rh(t);t!==null;){if(l=t[he])return l;t=rh(t)}return e}t=l,l=t.parentNode}return null}function xn(t){if(t=t[he]||t[xe]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function pu(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function jn(t){var e=t[_o];return e||(e=t[_o]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function be(t){t[gu]=!0}function Eo(t){t[hi]=void 0}var No=new Set,Co={};function $a(t,e){bn(t,e),bn(t+"Capture",e)}function bn(t,e)Line truncated
`).replace(L0,"")}function Qm(t,e){return e=Vm(e),Vm(t)===e}function Kt(t,e,l,a,n,u){switch(l){case"children":if(typeof a=="string")e==="body"||e==="textarea"&&a===""||_n(t,a);else if(typeof a=="number"||typeof a=="bigint")e!=="body"&&_n(t,""+a);else return;break;case"className":gi(t,"class",a);break;case"tabIndex":gi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":gi(t,l,a);break;case"style":Ho(t,a,u);return;case"data":if(e!=="object"){gi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||l!=="href")){t.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=pi(a),t.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(l==="formAction"?(e!=="input"&&Kt(t,e,"name",n.name,n,null),Kt(t,e,"formEncType",n.formEncType,n,null),Kt(t,e,"formMethod",n.formMethod,n,null),Kt(t,e,"formTarget",n.formTarget,n,null)):(Kt(t,e,"encType",n.encType,n,null),Kt(t,e,"method",n.method,n,null),Kt(t,e,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=pi(a),t.setAttribute(l,a);break;case"onClick":a!=null&&(t.onclick=Hl);return;case"onScroll":a!=null&&Dt("scroll",t);return;case"onScrollEnd":a!=null&&Dt("scrollend",t);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}l=pi(a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,""):t.removeAttribute(l);break;case"capture":case"download":a===!0?t.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(l,a):t.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(l):t.setAttribute(l,a);break;case"popover":Dt("beforetoggle",t),Dt("toggle",t),yi(t,"popover",a);break;case"xlinkActuate":la(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":la(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":la(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":la(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":la(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":la(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":la(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":la(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":la(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":yi(t,"is",a);break;case"innerText":case"textContent":return;default:if(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")l=ov.get(l)||l,yi(t,l,a);else return}qt=!0}function Vr(t,e,l,a,n,u){switch(l){case"style":Ho(t,a,u);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"children":if(typeof a=="string")_n(t,a);else if(typeof a=="number"||typeof a=="bigint")_n(t,""+a);else return;break;case"onScroll":a!=null&&Dt("scroll",t);return;case"onScrollEnd":a!=null&&Dt("scroLine truncated
`;Ut&&($t+=Ut+`
`),pt&&($t+=(Array.isArray(pt)?pt.join(`
`):String(pt))+`
`),$t+=JSON.stringify(lt,null,2).slice(0,4e3),Qe($t),Ne("已返回登录信息,见下方")}catch(lt){Ne("发起失败:"+lt.message)}},[]),xa=d.useCallback(async()=>{const N=F.trim();if(!N){Z("请填要查配额的用户 id");return}try{const G=await I($.adminQuotaUser(N));Z("用户 "+N.slice(0,8)+":本月用量 "+su(G.used_usd_micros)+" / 配额 "+su(G.quota_usd_micros)+(G.remaining_usd_micros!==null&&G.remaining_usd_micros!==void 0?"(剩余 "+su(G.remaining_usd_micros)+")":"")+" | 月份 "+(G.month||""))}catch(G){Z(wc(G))}},[F]),U=d.useCallback(async(N,G)=>{const lt=N.trim();if(!lt){Z("请填要设置配额的用户 id");return}let Ut;if(!G.trim())Ut=null;else{const pt=Number(G.trim());if(!Number.isFinite(pt)||pt<0){Z("配额金额无效:请输入 >= 0 的美元数,留空表示不限额");return}Ut=Math.round(pt*1e6)}try{const pt=await I($.adminQuotas,"POST",{user_id:lt,quota_usd_micros:Ut});Z("配额已设置:用户 "+lt.slice(0,8)+":本月用量 "+su(pt.used_usd_micros)+" / 配额 "+su(pt.quota_usd_micros)+(pt.remaining_usd_micros!==null&&pt.remaining_usd_micros!==void 0?"(剩余 "+su(pt.remaining_usd_micros)+")":"")+" | 月份 "+(pt.month||""))}catch(pt){Z(wc(pt))}},[]),w=d.useCallback(async()=>{if(window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。"))try{const G=(await I($.adminSweep,"POST",{})).swept||[];Z("sweep 完成,清理 "+G.length+" 个任务"+(G.length?":"+G.join("、"):""))}catch(N){Z(wc(N))}},[]),P=d.useCallback(async()=>{try{const N=await I($.adminBackup,"POST",{});Z("备份完成:backend="+N.backend+" | 路径 "+N.path+" | 大小 "+N.size_bytes+" 字节 | 任务 "+N.task_count+" 行")}catch(N){Z(wc(N))}},[]);return{keySaved:i,keyState:h,quotaText:g,healthText:O,adminGate:E,adminBody:q,adminResult:H,adminUserId:F,setAdminUserId:it,projects:ft,projectId:st,setProjectId:Ht,projectMsg:Q,githubRepos:z,workers:at,workersSummary:Mt,workersError:bt,members:mt,memberMsg:V,memberError:J,skills:gt,apps:fe,mcpServers:y,skillMsg:nt,skillError:xt,mcpMsg:Ot,mcpOauthMsg:ht,mcpOauthBox:Ja,envs:Ve,envCount:Qt,envMsg:Jl,envRepoLine:ya,envRepoBar:Rl,envRepoMsg:ga,envSelect:Dl,setEnvSelect:de,models:me,modelCustomVisible:kl,refreshHealth:wl,loadQuota:Ul,probeAdmin:cl,refreshProjects:Xt,loadGithubRepos:bl,loadRelayModels:Tl,refreshWorkers:_l,refreshEnvs:Se,refreshMembers:Wl,refreshSkills:$l,saveKeyInput:Il,clearKeyInput:du,createProject:Pl,createEnv:Ce,archiveEnv:sl,saveEnvRepo:Ae,inviteMember:mu,changeMemberRole:hu,removeMember:vu,reloadMcp:pa,mcpOauth:Sa,adminQuota:xa,adminSetQuota:U,adminSweep:w,adminBackup:P}}const go=20;function ug(){const[i,r]=d.useState([]),[h,f]=d.useState(""),[g,A]=d.useState(""),[O,b]=d.useState(""),[E,M]=d.useState(""),[q,p]=d.useState(""),[H,Z]=d.useState(0),[F,it]=d.useState(0),[ft,Et]=d.useState({}),st=d.useRef(0),Ht=d.useRef({status:"",executor:"",q:"",kind:""});Ht.current={status:g,executor:O,q,kind:E};const Q=d.useRef(""),ct=d.useCallback(async(Y,J,dt)=>{const gt=Y!==void 0?Y:J;if(!gt){r([]),it(0),Et({});return}Q.current=gt;const ee=dt!==void 0?dt:st.current,fe=Ht.current;try{const X=await I($.tasksPage(gt,go,ee*go,fe.status,fe.executor,fe.q,fe.kind));r(X.tasks||[]),it(X.total??(X.tasks||[]).length),Et(X.status_counts||{}),f("")}catch(X){f("加载失败:"+X.message)}},[]),z=d.useCallback(Y=>{const J=Math.max(0,Y);st.current=J,Z(J)},[]),L=d.useCallback(Y=>{z(Y),ct(Q.current||void 0,void 0,Y)},[ct,z]);d.useEffect(()=>{if(!Q.current)return;const Y=setTimeout(()=>{ct(Q.current,void 0,0)},300);return()=>clearTimeout(Y)},[g,O,q,E]);const at=d.useCallback(Y=>{A(Y),z(0)},[z]),jt=d.useCallback(Y=>{b(Y),z(0)},[z]),Mt=d.useCallback(Y=>{M(Y),z(0)},[z]),yt=d.useCallback(Y=>{p(Y),z(0)},[z]),bt=d.useCallback(async Y=>{z(0),await ct(Y,void 0,0)},[ct,z]),B=Math.max(1,Math.ceil(F/go)),mt=F===0?"共 0 个任务":"共 "+F+" 个任务,第 "+(H+1)+" / "+B+" 页",Ct=i,V=d.useCallback(async(Y,J)=>{const dt={...Y};J.length&&(dt.upload_id=await lg(J));const gt=await I($.tasksCreate,"POST",dt);return"任务已建:"+se(gt.id)},[]);return{tasks:i,taskError:h,statusFilter:g,setStatusFilter:at,executorFilter:O,setExecutorFilter:jt,kindFilter:E,setKindFilter:Mt,searchText:q,setSearchText:yt,filteredTasks:Ct,taskCountText:mt,refreshTasks:ct,refreshTasksFirstPage:bt,page:H,totalPages:B,taskTotal:F,taskStatusCounts:ft,goToPage:L,dispatch:V}}function ig(i,r){const[h,f]=d.useState(null),[g,A]=d.useState(""),[O,b]=d.useState(""),[E,M]=d.useState(""),[q,p]=d.useState(""),[H,Z]=d.useState(void 0),[F,it]=d.useState(!1),[ft,Et]=d.useState(""),[st,Ht]=d.useState([]),[Q,ct]=d.useState([]),[z,L]=d.useState([]),[at,jt]=d.useState(""),[Mt,yt]=d.useState([]),[bt,B]=d.useState(""),[mt,Ct]=d.useState(""),[V,Y]=d.useState([]),[J,dt]=d.useState(""),[gt,ee]=d.useState(""),[fe,X]=d.useState(""),[y,k]=d.useState(""),[nt,et]=d.useState([]),[xt,Nt]=d.useState([]),[Ot,W]=d.useState(""),[ht,Ne]=d.useState([]),[Ja,Qe]=d.useState(""),[Ve,Line truncated
`),$t+=JSON.stringify(lt,null,2).slice(0,4e3),Ve($t),Ne("已返回登录信息,见下方")}catch(lt){Ne("发起失败:"+lt.message)}},[]),xa=d.useCallback(async()=>{const N=F.trim();if(!N){Z("请填要查配额的用户 id");return}try{const G=await I($.adminQuotaUser(N));Z("用户 "+N.slice(0,8)+":本月用量 "+su(G.used_usd_micros)+" / 配额 "+su(G.quota_usd_micros)+(G.remaining_usd_micros!==null&&G.remaining_usd_micros!==void 0?"(剩余 "+su(G.remaining_usd_micros)+")":"")+" | 月份 "+(G.month||""))}catch(G){Z(Uc(G))}},[F]),U=d.useCallback(async(N,G)=>{const lt=N.trim();if(!lt){Z("请填要设置配额的用户 id");return}let Ut;if(!G.trim())Ut=null;else{const pt=Number(G.trim());if(!Number.isFinite(pt)||pt<0){Z("配额金额无效:请输入 >= 0 的美元数,留空表示不限额");return}Ut=Math.round(pt*1e6)}try{const pt=await I($.adminQuotas,"POST",{user_id:lt,quota_usd_micros:Ut});Z("配额已设置:用户 "+lt.slice(0,8)+":本月用量 "+su(pt.used_usd_micros)+" / 配额 "+su(pt.quota_usd_micros)+(pt.remaining_usd_micros!==null&&pt.remaining_usd_micros!==void 0?"(剩余 "+su(pt.remaining_usd_micros)+")":"")+" | 月份 "+(pt.month||""))}catch(pt){Z(Uc(pt))}},[]),w=d.useCallback(async()=>{if(window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。"))try{const G=(await I($.adminSweep,"POST",{})).swept||[];Z("sweep 完成,清理 "+G.length+" 个任务"+(G.length?":"+G.join("、"):""))}catch(N){Z(Uc(N))}},[]),P=d.useCallback(async()=>{try{const N=await I($.adminBackup,"POST",{});Z("备份完成:backend="+N.backend+" | 路径 "+N.path+" | 大小 "+N.size_bytes+" 字节 | 任务 "+N.task_count+" 行")}catch(N){Z(Uc(N))}},[]);return{keySaved:i,keyState:h,quotaText:g,healthText:O,adminGate:E,adminBody:q,adminResult:H,adminUserId:F,setAdminUserId:it,projects:ft,projectId:st,setProjectId:Ht,projectMsg:V,githubRepos:z,workers:at,workersSummary:Mt,workersError:bt,members:mt,memberMsg:Q,memberError:J,skills:gt,apps:fe,mcpServers:y,skillMsg:nt,skillError:xt,mcpMsg:Ot,mcpOauthMsg:ht,mcpOauthBox:Ja,envs:Qe,envCount:Vt,envMsg:Jl,envRepoLine:ya,envRepoBar:Rl,envRepoMsg:ga,envSelect:Dl,setEnvSelect:de,models:me,modelCustomVisible:kl,refreshHealth:wl,loadQuota:Ul,probeAdmin:cl,refreshProjects:Xt,loadGithubRepos:bl,loadRelayModels:Tl,refreshWorkers:_l,refreshEnvs:Se,refreshMembers:Wl,refreshSkills:$l,saveKeyInput:Il,clearKeyInput:du,createProject:Pl,createEnv:Ce,archiveEnv:sl,saveEnvRepo:Ae,inviteMember:mu,changeMemberRole:hu,removeMember:vu,reloadMcp:pa,mcpOauth:Sa,adminQuota:xa,adminSetQuota:U,adminSweep:w,adminBackup:P}}const po=20;function cg(){const[i,r]=d.useState([]),[h,f]=d.useState(""),[g,A]=d.useState(""),[O,b]=d.useState(""),[E,M]=d.useState(""),[q,p]=d.useState(""),[H,Z]=d.useState(0),[F,it]=d.useState(0),[ft,Et]=d.useState({}),st=d.useRef(0),Ht=d.useRef({status:"",executor:"",q:"",kind:""});Ht.current={status:g,executor:O,q,kind:E};const V=d.useRef(""),ct=d.useCallback(async(Y,J,dt)=>{const gt=Y!==void 0?Y:J;if(!gt){r([]),it(0),Et({});return}V.current=gt;const ee=dt!==void 0?dt:st.current,fe=Ht.current;try{const X=await I($.tasksPage(gt,po,ee*po,fe.status,fe.executor,fe.q,fe.kind));r(X.tasks||[]),it(X.total??(X.tasks||[]).length),Et(X.status_counts||{}),f("")}catch(X){f("加载失败:"+X.message)}},[]),z=d.useCallback(Y=>{const J=Math.max(0,Y);st.current=J,Z(J)},[]),L=d.useCallback(Y=>{z(Y),ct(V.current||void 0,void 0,Y)},[ct,z]);d.useEffect(()=>{if(!V.current)return;const Y=setTimeout(()=>{ct(V.current,void 0,0)},300);return()=>clearTimeout(Y)},[g,O,q,E]);const at=d.useCallback(Y=>{A(Y),z(0)},[z]),jt=d.useCallback(Y=>{b(Y),z(0)},[z]),Mt=d.useCallback(Y=>{M(Y),z(0)},[z]),yt=d.useCallback(Y=>{p(Y),z(0)},[z]),bt=d.useCallback(async Y=>{z(0),await ct(Y,void 0,0)},[ct,z]),B=Math.max(1,Math.ceil(F/po)),mt=F===0?"共 0 个任务":"共 "+F+" 个任务,第 "+(H+1)+" / "+B+" 页",Ct=i,Q=d.useCallback(async(Y,J)=>{const dt={...Y};J.length&&(dt.upload_id=await ng(J));const gt=await I($.tasksCreate,"POST",dt);return"任务已建:"+se(gt.id)},[]);return{tasks:i,taskError:h,statusFilter:g,setStatusFilter:at,executorFilter:O,setExecutorFilter:jt,kindFilter:E,setKindFilter:Mt,searchText:q,setSearchText:yt,filteredTasks:Ct,taskCountText:mt,refreshTasks:ct,refreshTasksFirstPage:bt,page:H,totalPages:B,taskTotal:F,taskStatusCounts:ft,goToPage:L,dispatch:Q}}function sg(i,r){const[h,f]=d.useState(null),[g,A]=d.useState(""),[O,b]=d.useState(""),[E,M]=d.useState(""),[q,p]=d.useState(""),[H,Z]=d.useState(void 0),[F,it]=d.useState(!1),[ft,Et]=d.useState(""),[st,Ht]=d.useState([]),[V,ct]=d.useState([]),[z,L]=d.useState([]),[at,jt]=d.useState(""),[Mt,yt]=d.useState([]),[bt,B]=d.useState(""),[mt,Ct]=d.useState(""),[Q,Y]=d.useState([]),[J,dt]=d.useState(""),[gt,ee]=d.useState(""),[fe,X]=d.useState(""),[y,k]=d.useState(""),[nt,et]=d.useState([]),[xt,Nt]=d.useState([]),[Ot,W]=d.useState(""),[ht,Ne]=d.useState([]),[Ja,Ve]=d.useState(""),[Qe,Line truncated
`);Fa=ea.pop()||"";for(const El of ea){const he=El.split(`
`).filter(It=>It.indexOf("data: ")===0).map(It=>It.slice(6)).join(`
`);if(he)try{const It=JSON.parse(he);if(It.type==="snapshot")N=It.entries||[],Object.keys(G).forEach(xe=>delete G[xe]),N.forEach((xe,di)=>{xe.id&&(G[xe.id]=di)}),lt=It.note||"",Y([...N]),dt(lt),Fl(U);else if(It.type==="entry"){const xe=It.entry;xe.id&&xe.id in G?N[G[xe.id]]=xe:(xe.id&&(G[xe.id]=N.length),N.push(xe)),Y([...N]),Fl(U)}else It.type==="done"&&(Ut=!0,pt=It.status,ee(pt))}catch{}}if(Ut)break}}catch($t){if($t&&$t.name==="AbortError")return;dt("链路流中断:"+$t.message);return}Ut&&r()},[il,Fl,r]),Il=d.useCallback(async U=>{il();try{const w=await I($.task(U));f(w);let P="";w.result_text&&(P+=`=== result ===
`);if(he)try{const It=JSON.parse(he);if(It.type==="snapshot")N=It.entries||[],Object.keys(G).forEach(xe=>delete G[xe]),N.forEach((xe,mi)=>{xe.id&&(G[xe.id]=mi)}),lt=It.note||"",Y([...N]),dt(lt),Fl(U);else if(It.type==="entry"){const xe=It.entry;xe.id&&xe.id in G?N[G[xe.id]]=xe:(xe.id&&(G[xe.id]=N.length),N.push(xe)),Y([...N]),Fl(U)}else It.type==="done"&&(Ut=!0,pt=It.status,ee(pt))}catch{}}if(Ut)break}}catch($t){if($t&&$t.name==="AbortError")return;dt("链路流中断:"+$t.message);return}Ut&&r()},[il,Fl,r]),Il=d.useCallback(async U=>{il();try{const w=await I($.task(U));f(w);let P="";w.result_text&&(P+=`=== result ===
`+w.result_text.slice(0,8e3)+`
`),w.error_text&&(P+=`=== error ===
`+w.error_text.slice(0,8e3)+`
`),P||(P="(暂无 result/error 输出)"),A(P),p(""),Z(void 0),wl(U),it(!1),Et(""),Qe(""),re(""),Wl(w.status);const N=i(w.project_id),G=N==="maintainer"||N==="owner",lt=N==="contributor"||G,Ut=ou.includes(w.status),pt=[];["failed","interrupted","merge-failed"].includes(w.status)&&G||pt.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(G?"":",且仅 maintainer 可重试")),!Ut&&lt||pt.push("取消:不可用——"+(Ut?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),w.status==="completed"&&G||pt.push("合并:不可用——仅 completed 可合并"+(G?"":",且仅 maintainer 可合并")),w.status==="merged"&&G||pt.push("提 PR:不可用——仅 merged 可提 PR"+(G?"":",且仅 maintainer 可提 PR")),M(pt.join(";")),b(""),$l(U,w.status),kl(U),ue(w),jl(w),cl(U),Ul(U),Tl(U),_l(U),Se(U),Xt(U),bl(U)}catch(w){f(null),A("加载失败:"+w.message)}},[il,$l,kl,ue,jl,cl,Ul,Tl,_l,Se,Xt,bl,wl,i]),du=d.useCallback(async()=>{const U=me.current;if(U){try{const w=await I($.task(U.id));f(w);let P="";w.result_text&&(P+=`=== result ===
`),P||(P="(暂无 result/error 输出)"),A(P),p(""),Z(void 0),wl(U),it(!1),Et(""),Ve(""),re(""),Wl(w.status);const N=i(w.project_id),G=N==="maintainer"||N==="owner",lt=N==="contributor"||G,Ut=ou.includes(w.status),pt=[];["failed","interrupted","merge-failed"].includes(w.status)&&G||pt.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(G?"":",且仅 maintainer 可重试")),!Ut&&lt||pt.push("取消:不可用——"+(Ut?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),w.status==="completed"&&G||pt.push("合并:不可用——仅 completed 可合并"+(G?"":",且仅 maintainer 可合并")),w.status==="merged"&&G||pt.push("提 PR:不可用——仅 merged 可提 PR"+(G?"":",且仅 maintainer 可提 PR")),M(pt.join(";")),b(""),$l(U,w.status),kl(U),ue(w),jl(w),cl(U),Ul(U),Tl(U),_l(U),Se(U),Xt(U),bl(U)}catch(w){f(null),A("加载失败:"+w.message)}},[il,$l,kl,ue,jl,cl,Ul,Tl,_l,Se,Xt,bl,wl,i]),du=d.useCallback(async()=>{const U=me.current;if(U){try{const w=await I($.task(U.id));f(w);let P="";w.result_text&&(P+=`=== result ===
`+w.result_text.slice(0,8e3)+`
`),w.error_text&&(P+=`=== error ===
`+w.error_text.slice(0,8e3)+`
`),A(P||"(暂无 result/error 输出)")}catch{}Ul(U.id),cl(U.id),Xt(U.id),bl(U.id),ue(U),jl(U),kl(U.id),Tl(U.id),_l(U.id),Se(U.id),wl(U.id)}},[Ul,cl,Xt,bl,ue,jl,kl,Tl,_l,Se,wl]),Pl=d.useCallback(async U=>{const w=me.current;if(!w)return;const P={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!(P[U]&&!window.confirm(P[U])))try{const N=await I($.taskAction(w.id,U),"POST",{});b(U+" 已提交:"+JSON.stringify(N).slice(0,300)),r(),await Il(w.id)}catch(N){b(U+" 失败:"+N.message)}},[Il,r]),Ce=d.useCallback(async()=>{const U=me.current;if(U)try{const w=await I($.taskCost(U.id));p(JSON.stringify(w,null,2).slice(0,4e3))}catch(w){p("查成本失败:"+w.message)}},[]),sl=d.useCallback(async U=>{const w=me.current;if(w){if(!U.trim()){Et("分支消息必填");return}if(!ou.includes(w.status)){Et("仅终态任务可分支");return}try{const P=await I($.taskFork(w.id),"POST",{message:U.trim()});Et("分支任务已建:"+P.id.slice(0,8)),r()}catch(P){Et("分支失败:"+P.message)}}},[r]),Ae=d.useCallback(async U=>{const w=me.current;if(w)try{await I($.taskReviews(w.id),"POST",U),B("评审已登记(requested)"),await Xt(w.id)}catch(P){B("登记评审需求失败:"+P.message)}},[Xt]),mu=d.useCallback(U=>{const w=me.current;if(!w)return;const P=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");P!==null&&I($.taskReviewFindings(w.id,U),"POST",{findings:P}).then(()=>void Xt(w.id)).catch(N=>B("写 findings 失败:"+N.message))},[Xt]),hu=d.useCallback((U,w)=>{const P=me.current;if(!P)return;const N=w==="approved"?"approve":"deny";I($.taskApprovalDecision(P.id,U,N),"POST",{}).then(()=>{W("已记录"+(w==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),bl(P.id)}).catch(G=>W("决定失败:"+G.message))},[bl]),vu=d.useCallback(async(U,w,P)=>{const N=me.current;if(N){if(!w.trim()){Qe("目标任务 id 不能为空");return}if(!P.trim()){Qe("消息正文不能为空");return}try{const G=await I($.taskMessagesTo(w.trim()),"POST",{from_task_id:N.id,body:P.trim(),mode:U});Qe("已发送("+G.delivery+")"),await Tl(N.id)}catch(G){Qe("发送失败:"+G.message)}}},[Tl]),pa=d.useCallback((U,w)=>{const P=me.current;if(!P)return;if(!["completed","failed","interrupted"].includes(P.status)){re("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const N={};if(U==="rename"){if(!w.trim()){re("新线程名不能为空");return}N.name=w.trim()}I($.taskThreadAction(P.id,U),"POST",N).then(G=>{re(U+" 已提交:"+JSON.stringify(G).slice(0,300)),_l(P.id),Se(P.id)}).catch(G=>re(U+" 失败:"+G.message))},[_l,Se]),Sa=d.useCallback(async U=>{const w=me.current;if(!w)return;const P=U.trim();if(!P){Fe("消息不能为空");return}try{ou.includes(w.status)&&w.status!=="merged"?(await I($.taskContinue(w.id),"POST",{message:P}),Fe("已重新排队,同一会话继续"),r(),await Il(w.id)):["queued","running"].includes(w.status)?(await I($.taskFollowups(w.id),"POST",{body:P,mode:"direct"}),Fe("已直接发送"),await Ul(w.id),await cl(w.id)):Fe("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(N){Fe("发送失败:"+N.message)}},[Il,r,Ul,cl]),xa=d.useCallback(async()=>{const U=me.current;if(!U){k("先选一个任务");return}try{const w=await I($.taskTrace(U.id)),P=new Blob([JSON.stringify({task_id:U.id,status:w.status,note:w.note||"",count:(w.entries||[]).length,entries:w.entries||[]},null,2)],{type:"application/json"}),N=document.createElement("a");N.href=URL.createObjectURL(P),N.download="trace-"+U.id.slice(0,8)+".json",document.body.appendChild(N),N.click(),N.remove(),setTimeout(()=>URL.revokeObjectURL(N.href),1e3),k("已下载完整链路 "+(w.entries||[]).length+" 条")}catch(w){k("下载失败:"+w.message)}},[]);return d.useEffect(()=>()=>il(),[il]),{currentTask:h,detailBody:g,actionMsg:O,actionReasons:E,costBox:q,costSummary:H,forkVisible:F,setForkVisible:it,forkMsg:ft,coordChildren:st,sideThreads:Q,agents:z,agentsNote:at,reviews:Mt,reviewMsg:bt,reviewError:mt,traceEntries:V,traceNote:J,traceDone:gt,traceKind:fe,setTraceKind:X,traceMsg:y,followups:nt,approvals:xt,approvalMsg:Ot,peerMessages:ht,peerMsg:Ja,threadText:Ve,threadGateNote:Qt,threadMsg:Jl,threadList:ya,chatMsg:Rl,chatHistory:ga,showDetail:Il,refreshDetailLists:du,doAction:Pl,taskCost:Ce,forkTask:sl,postReview:Ae,submitFindings:mu,decideApproval:hu,postPeer:vu,threadAction:pa,sendChatMessage:Sa,downloadTrace:xa,refreshReviews:()=>void Xt(),refreshApprovals:()=>void bl()}}function cg(i){const[r,h]=d.useState([]),[f,g]=d.useState(""),[A,O]=d.useState(null),[b,E]=d.useState(""),[M,q]=d.useState(null),[p,H]=d.useState(""),Z=d.useCallback(async Q=>{const ct=Q!==void 0?Q:i();if(!ct){h([]);return}try{const z=await I($.projectAutomations(ct));h(z.automations||[])}catch(z){g("自动化Line truncated
(链路摘要已截断至 300 字,完整回复见右侧任务结果)`:f,label:"助手"}}if(i.kind==="exec"||i.kind==="file"||i.kind==="tool"||i.kind==="call"){const f=i.kind==="exec"?"命令":i.kind==="file"?"文件":i.kind==="tool"?"工具":"调用";return{id:h,role:"tool",kind:i.kind,text:So(i),label:f}}return i.kind==="plan"?{id:h,role:"status",kind:i.kind,text:i.text||""}:i.kind==="reasoning"?{id:h,role:"status",kind:i.kind,text:"思考:"+(i.summary||"")}:i.kind==="agent-start"?{id:h,role:"status",kind:i.kind,text:"子代理开始:"+(i.name||"")+(i.prompt?"("+i.prompt+")":"")}:i.kind==="agent-done"?{id:h,role:"status",kind:i.kind,text:"子代理结束:"+(i.name||"")+" "+(i.status||"")}:{id:h,role:"status",kind:i.kind,text:So(i)}}function bg(){const i=Gt(),r=i.currentTask,h=d.useMemo(()=>{if(!r)return[];let E;if(i.chatHistory){E=i.chatHistory.messages.map(F=>({id:F.id,role:F.role,text:F.text,label:F.label,kind:F.kind}));const H=new Set(E.map(F=>F.id)),Z=i.traceEntries.filter(F=>F.id&&!H.has(F.id)&&!String(F.id).startsWith("fu-")).map((F,it)=>Zh(F,it));E=[...E,...Z]}else{const H=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],Z=i.traceEntries.filter(it=>!String(it.id||"").startsWith("fu-")).map((it,ft)=>Zh(it,ft)),F=i.followups.map((it,ft)=>({id:"followup-"+ft,role:"user",text:String(it.body||""),label:"后续消息"}));E=[...H,...Z,...F]}const M=i.traceKind,q=M?E.filter(H=>H.role==="user"||!H.kind||H.kind===M):E,p=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...q,...p]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const f=ou.includes(r.status),g=["queued","running"].includes(r.status),A=f||g,O=f?"终态任务:发送走继续接口(同一会话再跑一轮)":g?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",b=i.chatHistory?i.chatHistory.legacy_continue_count??null:null;return c.jsxs("div",{className:"chat-column",children:[c.jsx("div",{className:"muted",children:"实时对话"}),c.jsxs(zt,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:E=>i.setTraceKind(E.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(E=>c.jsx("option",{value:E,children:E==="exec"?"命令":E==="file"?"文件":E==="call"?"调用":E==="tool"?"工具":E==="message"?"消息":E==="reasoning"?"思考":E==="plan"?"计划":"子代理"},E))]}),c.jsx("button",{onClick:i.downloadTrace,children:"下载完整链路"}),c.jsx("span",{className:"muted",children:i.traceMsg})]}),i.traceNote?c.jsx("div",{children:c.jsx("span",{className:"err",children:i.traceNote})}):null,b!==null&&b>0?c.jsxs("div",{className:"muted",children:["此前有 ",b," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):null,i.chatHistory&&i.chatHistory.trace_note?c.jsx("div",{className:"muted",children:i.chatHistory.trace_note}):null,c.jsx(xg,{messages:h,empty:"(暂无会话消息)"}),c.jsx(jg,{disabled:!A,placeholder:"消息",onSend:E=>void i.sendChatMessage(E)}),c.jsxs("div",{className:"muted",children:[O,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function Tg(){return Gt().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(bg,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function _g(){const i=Gt(),r=i.currentTask,[h,f]=d.useState("");if(!r)return null;const g=ou.includes(r.status),A=["failed","interrupted","merge-failed"].includes(r.status),O=r.kind==="side";return c.jsxs(c.Fragment,{children:[c.jsxs(zt,{children:[c.jsx("button",{disabled:!A,title:A?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:g,title:g?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),O?null:c.jsxs(c.Fragment,{children:[c.jsx("button",{disabled:r.status!=="completed",title:r.status!=="completed"?"仅 completed 可合并":"",onClick:()=>i.doAction("merge"),children:"合并(completed → merged)"}),c.jsx("button",{disabled:r.status!=="merged",title:r.status!=="merged"?"仅 merged 可提 Line truncated
`),A(P||"(暂无 result/error 输出)")}catch{}Ul(U.id),cl(U.id),Xt(U.id),bl(U.id),ue(U),jl(U),kl(U.id),Tl(U.id),_l(U.id),Se(U.id),wl(U.id)}},[Ul,cl,Xt,bl,ue,jl,kl,Tl,_l,Se,wl]),Pl=d.useCallback(async U=>{const w=me.current;if(!w)return;const P={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!(P[U]&&!window.confirm(P[U])))try{const N=await I($.taskAction(w.id,U),"POST",{});b(U+" 已提交:"+JSON.stringify(N).slice(0,300)),r(),await Il(w.id)}catch(N){b(U+" 失败:"+N.message)}},[Il,r]),Ce=d.useCallback(async()=>{const U=me.current;if(U)try{const w=await I($.taskCost(U.id));p(JSON.stringify(w,null,2).slice(0,4e3))}catch(w){p("查成本失败:"+w.message)}},[]),sl=d.useCallback(async U=>{const w=me.current;if(w){if(!U.trim()){Et("分支消息必填");return}if(!ou.includes(w.status)){Et("仅终态任务可分支");return}try{const P=await I($.taskFork(w.id),"POST",{message:U.trim()});Et("分支任务已建:"+P.id.slice(0,8)),r()}catch(P){Et("分支失败:"+P.message)}}},[r]),Ae=d.useCallback(async U=>{const w=me.current;if(w)try{await I($.taskReviews(w.id),"POST",U),B("评审已登记(requested)"),await Xt(w.id)}catch(P){B("登记评审需求失败:"+P.message)}},[Xt]),mu=d.useCallback(U=>{const w=me.current;if(!w)return;const P=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");P!==null&&I($.taskReviewFindings(w.id,U),"POST",{findings:P}).then(()=>void Xt(w.id)).catch(N=>B("写 findings 失败:"+N.message))},[Xt]),hu=d.useCallback((U,w)=>{const P=me.current;if(!P)return;const N=w==="approved"?"approve":"deny";I($.taskApprovalDecision(P.id,U,N),"POST",{}).then(()=>{W("已记录"+(w==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),bl(P.id)}).catch(G=>W("决定失败:"+G.message))},[bl]),vu=d.useCallback(async(U,w,P)=>{const N=me.current;if(N){if(!w.trim()){Ve("目标任务 id 不能为空");return}if(!P.trim()){Ve("消息正文不能为空");return}try{const G=await I($.taskMessagesTo(w.trim()),"POST",{from_task_id:N.id,body:P.trim(),mode:U});Ve("已发送("+G.delivery+")"),await Tl(N.id)}catch(G){Ve("发送失败:"+G.message)}}},[Tl]),pa=d.useCallback((U,w)=>{const P=me.current;if(!P)return;if(!["completed","failed","interrupted"].includes(P.status)){re("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const N={};if(U==="rename"){if(!w.trim()){re("新线程名不能为空");return}N.name=w.trim()}I($.taskThreadAction(P.id,U),"POST",N).then(G=>{re(U+" 已提交:"+JSON.stringify(G).slice(0,300)),_l(P.id),Se(P.id)}).catch(G=>re(U+" 失败:"+G.message))},[_l,Se]),Sa=d.useCallback(async U=>{const w=me.current;if(!w)return;const P=U.trim();if(!P){Fe("消息不能为空");return}try{ou.includes(w.status)&&w.status!=="merged"?(await I($.taskContinue(w.id),"POST",{message:P}),Fe("已重新排队,同一会话继续"),r(),await Il(w.id)):["queued","running"].includes(w.status)?(await I($.taskFollowups(w.id),"POST",{body:P,mode:"direct"}),Fe("已直接发送"),await Ul(w.id),await cl(w.id)):Fe("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(N){Fe("发送失败:"+N.message)}},[Il,r,Ul,cl]),xa=d.useCallback(async()=>{const U=me.current;if(!U){k("先选一个任务");return}try{const w=await I($.taskTrace(U.id)),P=new Blob([JSON.stringify({task_id:U.id,status:w.status,note:w.note||"",count:(w.entries||[]).length,entries:w.entries||[]},null,2)],{type:"application/json"}),N=document.createElement("a");N.href=URL.createObjectURL(P),N.download="trace-"+U.id.slice(0,8)+".json",document.body.appendChild(N),N.click(),N.remove(),setTimeout(()=>URL.revokeObjectURL(N.href),1e3),k("已下载完整链路 "+(w.entries||[]).length+" 条")}catch(w){k("下载失败:"+w.message)}},[]);return d.useEffect(()=>()=>il(),[il]),{currentTask:h,detailBody:g,actionMsg:O,actionReasons:E,costBox:q,costSummary:H,forkVisible:F,setForkVisible:it,forkMsg:ft,coordChildren:st,sideThreads:V,agents:z,agentsNote:at,reviews:Mt,reviewMsg:bt,reviewError:mt,traceEntries:Q,traceNote:J,traceDone:gt,traceKind:fe,setTraceKind:X,traceMsg:y,followups:nt,approvals:xt,approvalMsg:Ot,peerMessages:ht,peerMsg:Ja,threadText:Qe,threadGateNote:Vt,threadMsg:Jl,threadList:ya,chatMsg:Rl,chatHistory:ga,showDetail:Il,refreshDetailLists:du,doAction:Pl,taskCost:Ce,forkTask:sl,postReview:Ae,submitFindings:mu,decideApproval:hu,postPeer:vu,threadAction:pa,sendChatMessage:Sa,downloadTrace:xa,refreshReviews:()=>void Xt(),refreshApprovals:()=>void bl()}}function rg(i){const[r,h]=d.useState([]),[f,g]=d.useState(""),[A,O]=d.useState(null),[b,E]=d.useState(""),[M,q]=d.useState(null),[p,H]=d.useState(""),Z=d.useCallback(async V=>{const ct=V!==void 0?V:i();if(!ct){h([]);return}try{const z=await I($.projectAutomations(ct));h(z.automations||[])}catch(z){g("自动化Line truncated
(链路摘要已截断至 300 字,完整回复见右侧任务结果)`:f,label:"助手"}}if(i.kind==="exec"||i.kind==="file"||i.kind==="tool"||i.kind==="call"){const f=i.kind==="exec"?"命令":i.kind==="file"?"文件":i.kind==="tool"?"工具":"调用";return{id:h,role:"tool",kind:i.kind,text:xo(i),label:f}}return i.kind==="plan"?{id:h,role:"status",kind:i.kind,text:i.text||""}:i.kind==="reasoning"?{id:h,role:"status",kind:i.kind,text:"思考:"+(i.summary||"")}:i.kind==="agent-start"?{id:h,role:"status",kind:i.kind,text:"子代理开始:"+(i.name||"")+(i.prompt?"("+i.prompt+")":"")}:i.kind==="agent-done"?{id:h,role:"status",kind:i.kind,text:"子代理结束:"+(i.name||"")+" "+(i.status||"")}:{id:h,role:"status",kind:i.kind,text:xo(i)}}function Ng(){const i=Gt(),r=i.currentTask,h=d.useMemo(()=>{if(!r)return[];let E;if(i.chatHistory){E=i.chatHistory.messages.map(F=>({id:F.id,role:F.role,text:F.text,label:F.label,kind:F.kind}));const H=new Set(E.map(F=>F.id)),Z=i.traceEntries.filter(F=>F.id&&!H.has(F.id)&&!String(F.id).startsWith("fu-")).map((F,it)=>Jh(F,it));E=[...E,...Z]}else{const H=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],Z=i.traceEntries.filter(it=>!String(it.id||"").startsWith("fu-")).map((it,ft)=>Jh(it,ft)),F=i.followups.map((it,ft)=>({id:"followup-"+ft,role:"user",text:String(it.body||""),label:"后续消息"}));E=[...H,...Z,...F]}const M=i.traceKind,q=M?E.filter(H=>H.role==="user"||!H.kind||H.kind===M):E,p=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...q,...p]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const f=ou.includes(r.status),g=["queued","running"].includes(r.status),A=f||g,O=f?"终态任务:发送走继续接口(同一会话再跑一轮)":g?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",b=i.chatHistory?i.chatHistory.legacy_continue_count??null:null;return c.jsxs("div",{className:"chat-column",children:[c.jsx("div",{className:"muted",children:"实时对话"}),c.jsxs(zt,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:E=>i.setTraceKind(E.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(E=>c.jsx("option",{value:E,children:E==="exec"?"命令":E==="file"?"文件":E==="call"?"调用":E==="tool"?"工具":E==="message"?"消息":E==="reasoning"?"思考":E==="plan"?"计划":"子代理"},E))]}),c.jsx("button",{onClick:i.downloadTrace,children:"下载完整链路"}),c.jsx("span",{className:"muted",children:i.traceMsg})]}),i.traceNote?c.jsx("div",{children:c.jsx("span",{className:"err",children:i.traceNote})}):null,b!==null&&b>0?c.jsxs("div",{className:"muted",children:["此前有 ",b," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):null,i.chatHistory&&i.chatHistory.trace_note?c.jsx("div",{className:"muted",children:i.chatHistory.trace_note}):null,c.jsx(_g,{messages:h,empty:"(暂无会话消息)"}),c.jsx(Eg,{disabled:!A,placeholder:"消息",onSend:E=>void i.sendChatMessage(E)}),c.jsxs("div",{className:"muted",children:[O,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function Cg(){return Gt().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(Ng,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function Ag(){const i=Gt(),r=i.currentTask,[h,f]=d.useState("");if(!r)return null;const g=ou.includes(r.status),A=["failed","interrupted","merge-failed"].includes(r.status),O=r.kind==="side";return c.jsxs(c.Fragment,{children:[c.jsxs(zt,{children:[c.jsx("button",{disabled:!A,title:A?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:g,title:g?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),O?null:c.jsxs(c.Fragment,{children:[c.jsx("button",{disabled:r.status!=="completed",title:r.status!=="completed"?"仅 completed 可合并":"",onClick:()=>i.doAction("merge"),children:"合并(completed → merged)"}),c.jsx("button",{disabled:r.status!=="merged",title:r.status!=="merged"?"仅 merged 可提 Line truncated
`);for(;mt>=0;){const Ct=yt.slice(0,mt);yt=yt.slice(mt+2);for(const V of Ct.split(`
`))if(V.startsWith("data: "))try{const Y=JSON.parse(V.slice(6));Y.waiting?b(Y.task_status==="interrupted"?"任务已中断,等待续跑…":Y.task_status==="paused"?"任务已暂停…":"等待会话…"):Y.image&&(f("data:image/jpeg;base64,"+Y.image),b("直播中"));const J={};Y.holder&&(J.holder=Y.holder),Y.url&&(J.url=Y.url),Y.title!==void 0&&(J.title=Y.title),Object.keys(J).length>0&&A(dt=>({...dt||{},...J}))}catch{}mt=yt.indexOf(`
`);for(;mt>=0;){const Ct=yt.slice(0,mt);yt=yt.slice(mt+2);for(const Q of Ct.split(`
`))if(Q.startsWith("data: "))try{const Y=JSON.parse(Q.slice(6));Y.waiting?b(Y.task_status==="interrupted"?"任务已中断,等待续跑…":Y.task_status==="paused"?"任务已暂停…":"等待会话…"):Y.image&&(f("data:image/jpeg;base64,"+Y.image),b("直播中"));const J={};Y.holder&&(J.holder=Y.holder),Y.url&&(J.url=Y.url),Y.title!==void 0&&(J.title=Y.title),Object.keys(J).length>0&&A(dt=>({...dt||{},...J}))}catch{}mt=yt.indexOf(`
`)}}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"})]}),E?c.jsx("div",{className:"muted",style:{marginTop:8},children:E}):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 Ng(){const i=Gt(),[r,h]=d.useState("uncommittedChanges"),[f,g]=d.useState(""),[A,O]=d.useState(""),[b,E]=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=>E(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.Line truncated
`)}}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 V=(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:["控制权 ",V]}),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:"刷新状态"}),V!=="user"?c.jsx("button",{type:"button",onClick:()=>void Et("user"),disabled:!ct,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void Et("agent"),children:"交还给 Agent"})]}),E?c.jsx("div",{className:"muted",style:{marginTop:8},children:E}):null,V==="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:V==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:ct?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function Og(){const i=Gt(),[r,h]=d.useState("uncommittedChanges"),[f,g]=d.useState(""),[A,O]=d.useState(""),[b,E]=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=>E(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.Line truncated
<style rel="stylesheet" crossorigin>:root{--text:#1f2328;--muted:#656d76;--subtle:#6e7781;--line:#d0d7de;--line-muted:#d8dee4;--paper:#ffffff;--card:#ffffff;--subtle-bg:#f6f8fa;--inset-bg:#f6f8fa;--accent:#0969da;--accent-emphasis:#0969da;--green:#2da44e;--green-hover:#2c974b;--success:#1a7f37;--danger:#d1242f;--danger-bg:#ffebe9;--attention:#9a6700;--done:#8250df;--selected-bg:#ddf4ff;--radius:6px;--shadow:0 1px 0 rgba(27,31,36,.04);--sans:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;--ctl:32px}*{box-sizing:border-box}html,body,#root{height:100%}body{font-family:var(--sans);margin:0;color:var(--text);background:#fff;line-height:1.5;font-size:14px}a{color:var(--accent);text-underline-offset:3px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}.muted{color:var(--muted);font-size:12px;line-height:1.5}.err{color:var(--danger)}.ok{color:var(--success)}button{cursor:pointer;font-weight:500;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text);box-shadow:var(--shadow)}button:hover{background:var(--subtle-bg)}button:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}button.primary,button.seal{background:var(--green);border-color:#1b1f2426;color:#fff}button.primary:hover,button.seal:hover{background:var(--green-hover)}button.danger{color:var(--danger);border-color:var(--line);background:#fff}button.danger:hover{background:var(--danger-bg);color:var(--danger)}.icon-btn{width:32px;height:32px;padding:0;display:grid;place-items:center;border:1px solid var(--line);border-radius:var(--radius);background:#fff;color:var(--text);font-size:16px;line-height:1}.icon-btn:hover{background:var(--subtle-bg)}.dash-topbar{position:sticky;top:0;z-index:10;background:#24292f;color:#fff;border-bottom:1px solid rgba(255,255,255,.15);padding:10px 20px 8px}.dash-topbar .muted{color:#ffffffbf}.dash-topbar-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap}.dash-brand{font-weight:600;font-size:16px;color:#fff}.dash-status{font-size:13px;font-weight:600;color:#ffffffbf}.dash-status.ok{color:#2da44e}.dash-stamp{margin-left:auto}.dash-nav{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}.dash-nav a{color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.dash-nav a:hover{background:#ffffff1f;text-decoration:none}.tab-nav button{background:transparent;border:0;box-shadow:none;color:#fff;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.tab-nav button:hover{background:#ffffff1f}.tab-nav button.active{background:#ffffff2e}.dash-page-tabs{display:flex}.dash-page-tabs>.tab-content{grid-column:1 / -1;min-width:0;display:flex;flex-direction:column;gap:20px}.tab-workbench{display:grid;grid-template-columns:320px minmax(0,1fr);gap:20px;align-items:start}.tab-workbench>*{min-width:0}@media(max-width:899px){.tab-workbench{grid-template-columns:1fr}.tab-workbench #sec-dispatch{order:1}.tab-workbench #sec-tasks{order:2}.tab-workbench #sec-conv{order:3}}.dash-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:20px}.dash-page>*{min-width:0}.dash-section>*{min-width:0}.dash-section{scroll-margin-top:130px;display:flex;flex-direction:column;gap:16px}.dash-section.card{padding:16px}.dash-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.dash-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px;min-width:0}.dash-card-label{color:var(--muted);font-size:12px;font-weight:600}.dash-card-value{font-size:24px;font-weight:600;line-height:1.3;overflow-wrap:anywhere}.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:none;padding:16px}.card h2{font-size:14px;margin:0 0 12px;font-weight:600}.card h3{font-size:14px;margin:24px 0 12px;padding-top:16px;border-top:1px solid var(--line);font-weight:600}.card h3:first-of-type{border-top:0;padding-top:0}.composer h2{font-size:16px}.composer textarea.prompt{min-height:148px;font-size:14px;padding:12px;border-radius:var(--radius)}.composer textarea.coord{font-family:var(--mono);font-size:13px}.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}.bar>button,.bar>label{display:inline-flex;align-items:center;height:var(--ctl);min-height:var(--ctl);padding:0 16px;box-sizing:border-box;white-space:nowrap}.bar>input,.bar>select{height:var(--ctl);min-height:var(--ctl)}input,select,textarea,button{font:inherit;font-size:14px;padding:5px 12px;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px #0969da4d}select{-moz-appearance:none;appearance:none;-webkit-appearance:none;cursLine truncated
</head>
<body>
+31 -9
View File
@@ -141,6 +141,30 @@ def _agent_tool_approvals_from_row(row):
# 演练注:两 helper 由 main 侧 server.py 原样移入(split 后
# _task_to_dict 的唯一定义在此,两 helper 须与它同模块,
# 否则 handler_support 无法引用;server.py 重导出旧路径)。
def _chv_vm_dead_from_row(row):
"""CHV 只读字段 chv_vm_dead 的派生规则(本轨命名抽出,
行为与 feat-chv-arch95plus 逐字一致,见 ADR-11/14)。
来源链(缺一环字段就不出现,绝不编造):
执行器 _vm_death_result 写 result["vm_dead"]=True →
worker 终局仅当 status='interrupted' 时把原因写进
tasks.interrupted_reason(非 goal 形态是完整原因文本,
goal 形态无 interrupted_reason 字段时回落为纯文本
“VM 中途死亡”)→ 本函数只在 executor 为
cloud-hypervisor 且原因文本含“VM 中途死亡”时返回
True,其它情形返回 None(调用方不写字段)。
本字段是“已报告死亡”,不是 live 状态:server 进程
没有 VM 登记表,VM 活着/死了的 live 状态编不出来;
前端无该字段时必须显示“未报告死亡”,不得写成活着。
"""
if _row_get(row, "executor") != "cloud-hypervisor":
return None
reason = _row_get(row, "interrupted_reason")
if reason and "VM 中途死亡" in str(reason):
return True
return None
def _task_to_dict(row, attachments=None, total_tokens=None):
depends_on = row["depends_on"]
try:
@@ -226,16 +250,14 @@ def _task_to_dict(row, attachments=None, total_tokens=None):
# 列表接口由 _task_tokens_map 一次聚合传入;其它调用方
# 不传时为 None(前端显示占位),绝不把“未算”写成 0。
"total_tokens": total_tokens,
# feat-chv-arch95plus 只读字段:仅当 CHV 任务且 worker
# 已把 VM 死亡写进 interrupted_reason 时才出现,值为
# True;其它情形一律不返回该字段(绝不把“未报告死亡”
# 写成“VM 活着”,live VM 状态不在 server 进程里,
# server 没有 VM 登记表,编不出来也不编)。
# feat-chv-arch-maint-fe:只读字段经
# _chv_vm_dead_from_row 派生(规则见该函数;本轨
# 未新增第二个生命周期字段——server 持久化的 CHV
# 生命周期事实只有 executor/status/interrupted_reason
# 三项,chv_vm_dead 已是其完整派生,再编字段就是
# 复制 interrupted_reason 或猜 live 状态,两者都不做)。
**({"chv_vm_dead": True}
if _row_get(row, "executor") == "cloud-hypervisor"
and _row_get(row, "interrupted_reason")
and "VM 中途死亡" in str(_row_get(row, "interrupted_reason"))
else {}),
if _chv_vm_dead_from_row(row) else {}),
}
+9 -5
View File
@@ -2354,13 +2354,17 @@ class CloudExecutor(Executor):
# -- Cloud Hypervisor 执行器(已拆到 cloud/chv/,这里只留兼容 re-export) --
# #216 的 CHV 类(原 2233-3342 行)与 WarmPool(原 3342-3437 行)、
# _parse_memory_bytes、_unix_socket_api 已分别移到 cloud/chv/
# 的 executor.py / pool.py / config.py / api_client.py;本模块
# 不再保留它们的副本,旧导入路径(from cloud.sandbox import
# CloudHypervisorExecutor / WarmPool / ChvFailure)仍可用,
# 新代码优先从 cloud.chv import。执行器方法内按需 import
# 的 executor.py / experimental_pool.py / config.py /
# api_client.py;本模块不再保留它们的副本。生产接口旧导入
# 路径(from cloud.sandbox import CloudHypervisorExecutor /
# ChvFailure)仍可用,新代码优先从 cloud.chv import。
# WarmPool 除外:本轨破环性清理(feat-chv-arch-maint-fe,
# 见 cloud/chv/__init__.py),`from cloud.sandbox import
# WarmPool` 一律 ImportError,实验用法只走
# cloud.chv.experimental_pool。执行器方法内按需 import
# 本模块共享 helper 的原因见 cloud/chv/executor.py 头注释。
from cloud.chv import ChvFailure # noqa: F401
from cloud.chv import CloudHypervisorExecutor # noqa: F401
from cloud.chv import WarmPool # noqa: F401
from cloud.chv import reconcile_orphans_for_executor # noqa: F401
from cloud.chv.api_client import _unix_socket_api # noqa: F401
from cloud.chv.config import _parse_memory_bytes # noqa: F401
+2 -2
View File
@@ -14,7 +14,7 @@ import unittest
from pathlib import Path
try:
from cloud.chv.pool import WarmPool
from cloud.chv.experimental_pool import WarmPool
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
@@ -23,7 +23,7 @@ try:
)
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv.pool import WarmPool
from cloud.chv.experimental_pool import WarmPool
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
+6 -5
View File
@@ -19,14 +19,14 @@ from pathlib import Path
try:
from cloud.chv.compat215 import resolve_inside, tmpfs_isolated
from cloud.chv.hardening import validate_snapshot_url
from cloud.chv.pool import reconcile_orphans_for_executor
from cloud.chv.orphans import reconcile_orphans_for_executor
from cloud.sandbox import CloudHypervisorExecutor
from cloud.server import _task_to_dict
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv.compat215 import resolve_inside, tmpfs_isolated
from cloud.chv.hardening import validate_snapshot_url
from cloud.chv.pool import reconcile_orphans_for_executor
from cloud.chv.orphans import reconcile_orphans_for_executor
from cloud.sandbox import CloudHypervisorExecutor
from cloud.server import _task_to_dict
@@ -92,10 +92,11 @@ class Compat215Test(unittest.TestCase):
class PoolFacadeTest(unittest.TestCase):
def test_facade_only_reconciles_never_acquires(self):
# facade 内部构造 WarmPool:用假执行器 + 空 registry,
# 只要它不调 acquire/prewarm 就返回 [] 且不炸。
# facade 直接读 registry 文件做清理(本轨不再构造
# WarmPool):用假执行器 + 空 registry,返回 [] 不炸,
# 且绝不调 acquire/prewarm(假执行器根本没有这两个方法)。
ex = FakeExecutor()
# WarmPool 构造需要 state_dir 属性;没有则 registry 为空
# facade 需要 state_dir 属性定 registry 路径;没有则返回 []
ex.state_dir = tempfile.mkdtemp()
ex.warm_pool_size = 0
ex.snapshot_url = None
+464
View File
@@ -0,0 +1,464 @@
"""CHV 架构/可维护性/前端(只读侧)推进轨测试。
本轨 feat-chv-arch-maint-fe,堆叠于 #254(feat-chv-
arch95plus)。只用标准库与 mock,绝不启动真实 VM;
测试里只用 FAKE 占位值,绝无真实密钥。覆盖:
- WarmPool 破环性清理:旧 import 路径一律 ImportError,
实验路径唯一,生产代码不 import 实验模块;
- orphans facade:registry 语义(有效/非法/损坏/缺失/
shutdown 抛错/_emit 抛错)与旧 WarmPool 逐项一致;
- 配置片段:五片段键集互不相交、并集等于旧字段集、
默认值与单项非法报错逐字一致、组装键冲突即报错;
- ADR-12 模块依赖图:叶子模块只依赖标准库、禁 import
清单、公开 API 全量类型标注且无 Any 逃逸;
- #215 替换清单:每项 local 真实存在、#215 模块当前
不存在、R3 永不替换;
- 服务端只读字段:goal 回落形态(纯文本“VM 中途死亡”)
同样派生 chv_vm_dead,非 VM 中断原因一律不派生。
"""
import ast
import importlib.util
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
try:
from cloud.chv import reconcile_orphans_for_executor
from cloud.chv.compat215 import REPLACEMENT_POINTS
from cloud.chv.compat215 import resolve_inside, tmpfs_isolated
from cloud.chv.config import _assemble_chv_config
from cloud.chv.config import _base_fragment
from cloud.chv.config import _disk_fragment
from cloud.chv.config import _network_fragment
from cloud.chv.config import _quota_fragment
from cloud.chv.config import _ssh_fragment
from cloud.chv.config import _timeout_fragment
from cloud.chv.config import build_chv_config
from cloud.chv.experimental_pool import WarmPool
from cloud.chv.orphans import _REGISTRY_FILENAME
from cloud.chv.orphans import reconcile_orphans_for_executor as _facade
from cloud.server import _chv_vm_dead_from_row, _task_to_dict
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv import reconcile_orphans_for_executor
from cloud.chv.compat215 import REPLACEMENT_POINTS
from cloud.chv.compat215 import resolve_inside, tmpfs_isolated
from cloud.chv.config import _assemble_chv_config
from cloud.chv.config import _base_fragment
from cloud.chv.config import _disk_fragment
from cloud.chv.config import _network_fragment
from cloud.chv.config import _quota_fragment
from cloud.chv.config import _ssh_fragment
from cloud.chv.config import _timeout_fragment
from cloud.chv.config import build_chv_config
from cloud.chv.experimental_pool import WarmPool
from cloud.chv.orphans import _REGISTRY_FILENAME
from cloud.chv.orphans import reconcile_orphans_for_executor as _facade
from cloud.server import _chv_vm_dead_from_row, _task_to_dict
ROOT = Path(__file__).resolve().parents[2]
CHV = ROOT / "cloud" / "chv"
EXPECTED_FIELDS = {
"binary", "kernel", "rootfs", "cpus", "memory", "state_dir",
"kvm_path", "cpuinfo_path", "ch_remote", "model",
"ssh_key", "ssh_user", "ssh_port", "host_key_mode",
"guest_host_py", "cmdline",
"net", "net_json", "ssh_host", "ssh_forward",
"egress_proxy", "allow_direct_egress",
"max_vms", "warm_pool_size",
"disk_mode", "disk_gb", "reuse_experiment",
"single_tenant_project", "snapshot_max_count",
"snapshot_max_total_gb", "snapshot_retention_days",
"snapshot_dir", "allow_remote_snapshot", "snapshot_url",
"task_timeout", "api_timeout", "ready_timeout", "ready_interval",
}
class FakeProdExecutor:
"""生产 facade 替身:只有 facade 允许碰的属性。
acquire/prewarm/release 一律不存在——facade 若敢碰,
__getattr__ 直接抛错,测试即 fail。"""
def __init__(self, state_dir):
self.state_dir = state_dir
self.shutdowns = []
self.emits = []
def __getattr__(self, name):
if name in ("acquire", "prewarm", "release"):
raise AssertionError("facade 不得碰实验接口:%s" % name)
raise AttributeError(name)
def shutdown_vm(self, vm):
self.shutdowns.append(vm)
return True
def _emit(self, event, task, detail):
self.emits.append(event)
def _write_registry(state_dir, data):
path = os.path.join(state_dir, _REGISTRY_FILENAME)
if isinstance(data, str):
with open(path, "w") as fh:
fh.write(data)
else:
with open(path, "w") as fh:
json.dump(data, fh)
return path
class BreakingCleanupTest(unittest.TestCase):
def test_old_paths_raise_import_error(self):
with self.assertRaises(ImportError):
from cloud.chv import WarmPool # noqa: F401
with self.assertRaises(ImportError):
from cloud.sandbox import WarmPool # noqa: F401
with self.assertRaises(ImportError):
import cloud.chv.pool # noqa: F401
def test_new_paths_and_identity(self):
self.assertEqual(WarmPool.__module__,
"cloud.chv.experimental_pool")
self.assertIs(reconcile_orphans_for_executor, _facade)
import cloud.sandbox as sb
self.assertIs(sb.reconcile_orphans_for_executor, _facade)
self.assertFalse(WarmPool.wired_to_dispatch)
def test_production_sources_never_import_experimental(self):
# 钉的是 import 语句,不是文档提及:executor/__init__
# 的 docstring 本来就要写清实验模块去向,文本匹配会
# 误杀,故用 AST 只看 Import/ImportFrom 节点。
for rel in ("cloud/worker.py", "cloud/chv/executor.py",
"cloud/chv/__init__.py", "cloud/sandbox.py",
"cloud/chv/orphans.py"):
tree = ast.parse((ROOT / rel).read_text())
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
self.assertNotIn(a.name, (
"cloud.chv.pool",
"cloud.chv.experimental_pool"), rel)
if isinstance(node, ast.ImportFrom):
self.assertNotIn(node.module, (
"cloud.chv.pool",
"cloud.chv.experimental_pool"), rel)
if node.module in ("cloud.chv", "cloud.sandbox"):
names = [a.name for a in node.names]
self.assertNotIn("WarmPool", names, rel)
class OrphansTest(unittest.TestCase):
def test_valid_warm_and_assigned_cleaned_invalid_dropped(self):
with tempfile.TemporaryDirectory() as d:
path = _write_registry(d, {
"a": {"state": "warm", "vm": "chv-a",
"task_id": "a", "snapshot_url": None},
"b": {"state": "assigned", "vm": "chv-b",
"task_id": "b", "snapshot_url": None},
"c": {"state": "warm", "task_id": "c"},
"e": {"state": "running", "vm": "chv-e"},
"f": "not-a-dict",
})
ex = FakeProdExecutor(d)
self.assertEqual(
reconcile_orphans_for_executor(ex),
["chv-a", "chv-b"])
self.assertEqual(ex.shutdowns, ["chv-a", "chv-b"])
self.assertEqual(ex.emits, ["warm_pool_reconcile"])
with open(path) as fh:
self.assertEqual(json.load(fh), {})
def test_invalid_only_returns_empty_and_leaves_file(self):
with tempfile.TemporaryDirectory() as d:
path = _write_registry(d, {
"c": {"state": "warm", "task_id": "c"}})
ex = FakeProdExecutor(d)
self.assertEqual(reconcile_orphans_for_executor(ex), [])
self.assertEqual(ex.shutdowns, [])
with open(path) as fh:
self.assertIn("task_id", fh.read())
def test_corrupt_json_quarantined(self):
with tempfile.TemporaryDirectory() as d:
path = _write_registry(d, "{not json")
ex = FakeProdExecutor(d)
self.assertEqual(reconcile_orphans_for_executor(ex), [])
self.assertFalse(os.path.exists(path))
self.assertTrue(os.path.exists(path + ".corrupt"))
def test_nondict_top_level_quarantined(self):
with tempfile.TemporaryDirectory() as d:
path = _write_registry(d, "[1, 2]")
ex = FakeProdExecutor(d)
self.assertEqual(reconcile_orphans_for_executor(ex), [])
self.assertTrue(os.path.exists(path + ".corrupt"))
def test_missing_file_and_no_state_dir(self):
with tempfile.TemporaryDirectory() as d:
ex = FakeProdExecutor(d)
self.assertEqual(reconcile_orphans_for_executor(ex), [])
class NoDir:
def shutdown_vm(self, vm):
raise AssertionError("must not be called")
self.assertEqual(
reconcile_orphans_for_executor(NoDir()), [])
def test_shutdown_and_emit_failures_do_not_break_cleanup(self):
with tempfile.TemporaryDirectory() as d:
_write_registry(d, {
"a": {"state": "warm", "vm": "chv-a"}})
ex = FakeProdExecutor(d)
def boom(vm):
raise RuntimeError("shutdown down")
ex.shutdown_vm = boom
ex._emit = lambda *a, **k: (_ for _ in ()).throw(
RuntimeError("audit down"))
self.assertEqual(
reconcile_orphans_for_executor(ex), ["chv-a"])
def test_experimental_pool_registry_roundtrip_unchanged(self):
# 实验池自身的 registry 语义(载入即 orphan、reconcile
# 清理)与上一轨一致,只是文件 IO 委托给 orphans。
with tempfile.TemporaryDirectory() as d:
ex = FakeProdExecutor(d)
ex.warm_pool_size = 0
ex.snapshot_url = None
pool1 = WarmPool(ex)
pool1._entries["chv-old"] = {
"state": "warm", "vm": "chv-old",
"task": {"id": "old"}, "snapshot_url": None}
pool1._save_registry()
pool2 = WarmPool(ex)
self.assertEqual(pool2.orphan_count(), 1)
self.assertEqual(pool2.warm_count(), 0)
self.assertEqual(pool2.reconcile_orphans(), ["chv-old"])
class ConfigFragmentsTest(unittest.TestCase):
def test_fragment_keys_disjoint_and_union_is_field_set(self):
frags = [_base_fragment({}, {}), _ssh_fragment({}, {}),
_network_fragment({}, {}), _quota_fragment({}),
_disk_fragment({}), _timeout_fragment({}, {})]
seen = set()
for frag in frags:
self.assertFalse(seen & set(frag), "片段键重叠")
seen |= set(frag)
self.assertEqual(seen, EXPECTED_FIELDS)
self.assertEqual(len(EXPECTED_FIELDS), 38)
def test_defaults_and_overrides(self):
cfg = build_chv_config({}, {})
self.assertEqual(cfg.binary, "cloud-hypervisor")
self.assertEqual(cfg.ssh_port, 2222)
self.assertEqual(cfg.ssh_user, "root")
self.assertEqual(cfg.host_key_mode, "provisioned")
self.assertEqual(cfg.max_vms, 4)
self.assertEqual(cfg.disk_mode, "copy")
self.assertEqual(cfg.disk_gb, 50.0)
self.assertEqual(cfg.api_timeout, 30.0)
self.assertEqual(cfg.ready_timeout, 120.0)
self.assertEqual(cfg.ready_interval, 5.0)
self.assertIsNone(cfg.task_timeout)
self.assertIsNone(cfg.snapshot_url)
cfg2 = build_chv_config(
{"ssh_user": "u", "ssh_port": 2223},
{"CHV_SSH_USER": "env-user", "CHV_MAX_VMS": "2",
"CHV_EGRESS_PROXY": "http://p:1",
"CHV_SNAPSHOT_URL": "file:///snap/base"})
self.assertEqual(cfg2.ssh_user, "u")
self.assertEqual(cfg2.ssh_port, 2223)
self.assertEqual(cfg2.max_vms, 2)
self.assertEqual(cfg2.egress_proxy, "http://p:1")
self.assertEqual(cfg2.snapshot_url, "file:///snap/base")
def test_single_error_messages_unchanged(self):
cases = [
({"ssh_user": ""}, {}, "CHV_SSH_USER 不能为空字符串"),
({}, {"CHV_SSH_PORT": "0"}, "CHV_SSH_PORT 必须在 1-65535 之间"),
({}, {"CHV_SSH_PORT": "99999"},
"CHV_SSH_PORT 必须在 1-65535 之间"),
({}, {"CHV_DISK_MODE": "bad"},
"CHV_DISK_MODE 只能是 copy 或 reuse"),
({}, {"CHV_DISK_GB": "0"}, "CHV_DISK_GB 必须大于 0"),
({}, {"CHV_SNAPSHOT_MAX_COUNT": "-1"},
"CHV_SNAPSHOT_* 不能为负数"),
({}, {"CHV_MAX_VMS": "x"}, "CHV_MAX_VMS 必须是整数"),
({}, {"CHV_WARM_POOL_SIZE": "x"},
"CHV_WARM_POOL_SIZE 必须是整数"),
({}, {"CHV_API_TIMEOUT": "x"},
"CHV_*_TIMEOUT/INTERVAL 必须是数字"),
]
for params, env, msg in cases:
with self.assertRaises(ValueError) as cm:
build_chv_config(params, env)
self.assertIn(msg, str(cm.exception))
def test_assemble_key_conflict_raises(self):
with self.assertRaises(ValueError):
_assemble_chv_config([{"a": 1}, {"a": 2}])
def _top_level_imports(path):
tree = ast.parse(path.read_text())
mods = set()
for node in tree.body:
if isinstance(node, ast.Import):
mods.update(a.name for a in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
mods.add(node.module)
return mods
class DependencyGraphTest(unittest.TestCase):
def test_leaf_modules_only_import_stdlib(self):
for name in ("orphans", "config", "compat215", "errors"):
mods = _top_level_imports(CHV / (name + ".py"))
for mod in mods:
top = mod.split(".")[0]
self.assertFalse(top.startswith("cloud"),
"%s 不得 import %s" % (name, mod))
def test_executor_never_imports_pool_or_orphans(self):
# 同上:只看 AST import 节点,模块头 docstring 的
# 分工说明不算 import。
tree = ast.parse((CHV / "executor.py").read_text())
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
self.assertNotIn(a.name, (
"cloud.chv.pool",
"cloud.chv.experimental_pool",
"cloud.chv.orphans"))
if isinstance(node, ast.ImportFrom):
self.assertNotIn(node.module, (
"cloud.chv.pool",
"cloud.chv.experimental_pool",
"cloud.chv.orphans"))
def test_no_chv_function_over_50_lines(self):
over = []
for f in CHV.glob("*.py"):
tree = ast.parse(f.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
n = node.end_lineno - node.lineno + 1
if n > 50:
over.append("%s:%s:%d" % (f.name, node.name, n))
self.assertEqual(over, [])
class PublicApiTypesTest(unittest.TestCase):
def test_public_api_fully_annotated_and_no_any(self):
import re
unannotated = []
for f in CHV.glob("*.py"):
text = f.read_text()
for line in text.splitlines():
self.assertIsNone(re.search(r"\bAny\b", line),
"%s 含 Any 逃逸:%s" % (f.name, line))
tree = ast.parse(text)
for node in tree.body:
if isinstance(node, ast.FunctionDef) \
and not node.name.startswith("_"):
bad = [a.arg for a in node.args.args
if a.annotation is None]
if bad or node.returns is None:
unannotated.append("%s:%s" % (f.name, node.name))
if isinstance(node, ast.ClassDef):
for m in node.body:
if isinstance(m, ast.FunctionDef) and (
not m.name.startswith("_")
or m.name == "__init__"):
bad = [a.arg for a in m.args.args
if a.arg != "self"
and a.annotation is None]
bad += [a.arg for a in m.args.kwonlyargs
if a.annotation is None]
if bad or m.returns is None:
unannotated.append(
"%s:%s.%s" % (f.name, node.name,
m.name))
self.assertEqual(unannotated, [])
def test_corrected_annotations_match_runtime(self):
from cloud.chv.disk import record_snapshot
from cloud.chv.hostkeys import host_key_paths
with tempfile.TemporaryDirectory() as d:
entries, removed = record_snapshot(
d, "vm1", "https://example.invalid/snap", 20.0)
self.assertIsInstance(entries, list)
self.assertIsInstance(removed, list)
paths = host_key_paths(d, "vm1")
self.assertIsInstance(paths, dict)
self.assertIn("private", paths)
class ReplacementChecklistTest(unittest.TestCase):
def test_points_resolve_and_215_modules_absent(self):
by_id = {p["id"]: p for p in REPLACEMENT_POINTS}
self.assertEqual(set(by_id), {"R1", "R2", "R3"})
self.assertIsNotNone(resolve_inside)
self.assertIsNotNone(tmpfs_isolated)
hardening = (ROOT / "cloud" / "chv" / "hardening.py").read_text()
self.assertIn("resolve_inside", hardening)
for mod in ("cloud.gates", "cloud.sandbox_policy",
"cloud.timeouts", "cloud.quota"):
self.assertIsNone(importlib.util.find_spec(mod), mod)
import cloud.chv.compat215 as m
self.assertFalse(hasattr(m, "resolve_module_timeout"))
self.assertFalse(hasattr(m, "decide"))
self.assertIsNone(by_id["R3"]["replacement"])
self.assertIsNone(by_id["R2"]["caller"])
def test_tmpfs_isolated_has_no_production_caller(self):
hits = []
for f in (ROOT / "cloud").rglob("*.py"):
if "tests" in f.parts:
continue
if "tmpfs_isolated" in f.read_text():
hits.append(str(f.relative_to(ROOT)))
self.assertEqual(hits, ["cloud/chv/compat215.py"])
def _row(**kw):
base = {
"id": "t1", "project_id": "p1", "prompt": "x",
"status": "interrupted", "idempotency_key": None,
"depends_on": "[]", "priority": 0, "created_by": "u",
"created_at": 0, "updated_at": 0, "last_heartbeat": 0,
}
base.update(kw)
return base
class ServerLifecycleTest(unittest.TestCase):
def test_goal_fallback_form_also_derives_dead(self):
row = _row(executor="cloud-hypervisor",
interrupted_reason="VM 中途死亡")
self.assertTrue(_chv_vm_dead_from_row(row))
self.assertTrue(_task_to_dict(row).get("chv_vm_dead"))
def test_non_vm_reasons_never_derive(self):
for reason in ("quota", "worker_shutdown", None, ""):
row = _row(executor="cloud-hypervisor",
interrupted_reason=reason)
self.assertIsNone(_chv_vm_dead_from_row(row))
self.assertNotIn("chv_vm_dead", _task_to_dict(row))
row = _row(executor="docker",
interrupted_reason="VM 中途死亡,任务未完成")
self.assertIsNone(_chv_vm_dead_from_row(row))
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -23,7 +23,7 @@ try:
from cloud.chv.api_client import _unix_socket_api
from cloud.chv.disk import check_disk_quota, provision_task_disk
from cloud.chv.hostkeys import build_seed_disk, seed_disk_path
from cloud.chv.pool import WarmPool
from cloud.chv.experimental_pool import WarmPool
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
@@ -34,7 +34,7 @@ except ImportError:
from cloud.chv.api_client import _unix_socket_api
from cloud.chv.disk import check_disk_quota, provision_task_disk
from cloud.chv.hostkeys import build_seed_disk, seed_disk_path
from cloud.chv.pool import WarmPool
from cloud.chv.experimental_pool import WarmPool
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
+16 -6
View File
@@ -23,7 +23,7 @@ try:
known_hosts_line,
write_seed,
)
from cloud.chv.pool import WarmPool
from cloud.chv.experimental_pool import WarmPool
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
@@ -39,7 +39,7 @@ except ImportError:
known_hosts_line,
write_seed,
)
from cloud.chv.pool import WarmPool
from cloud.chv.experimental_pool import WarmPool
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
@@ -232,28 +232,38 @@ class ReconcileWiringTest(ChvGapCase):
self.assertTrue(
os.path.exists(os.path.join(self.state_dir, "warm-pool.json")))
# Worker 构造即 startup reconcile:用假 shutdown 记录。
# 本轨 worker 不再持有 _warm_pool(生产走 orphans
# facade);实验池只用于本测试造 registry 种子。
shutdowns = []
orig_shutdown = ex.shutdown_vm
ex.shutdown_vm = lambda name: shutdowns.append(name) or True
worker = Worker(db_path, "/fake/host.py", executor=ex,
env={"XCM_API_KEY": "test-placeholder"})
self.assertIn("chv-old", shutdowns)
self.assertEqual(worker._warm_pool.orphan_count(), 0)
self.assertFalse(hasattr(worker, "_warm_pool"))
with open(os.path.join(self.state_dir, "warm-pool.json")) as fh:
self.assertEqual(json.load(fh), {})
worker.close()
# close 时再次 reconcile 不炸(池已空,返回 [] 即可)。
self.assertEqual(worker._reconcile_warm_pool("shutdown"), [])
def test_reconcile_failure_does_not_break_close(self):
from cloud.server import init_db
import cloud.chv.orphans as orphans
db_path = os.path.join(self.dir, "control.db")
init_db(db_path)
ex = self.make_exec()
worker = Worker(db_path, "/fake/host.py", executor=ex,
env={"XCM_API_KEY": "test-placeholder"})
# 池的 reconcile 抛错时 close 仍不炸。
worker._warm_pool.reconcile_orphans = lambda: (_ for _ in ()).throw(
RuntimeError("boom"))
# facade 抛错时 close 仍不炸(worker 方法内每次现取
# facade,猴子补丁模块属性即生效)。
orig = orphans.reconcile_orphans_for_executor
orphans.reconcile_orphans_for_executor = lambda ex: (
(_ for _ in ()).throw(RuntimeError("boom")))
try:
worker.close() # 不抛即通过
finally:
orphans.reconcile_orphans_for_executor = orig
# -- (3) SSH host key 预置 ---------------------------------------------
+18 -6
View File
@@ -23,14 +23,15 @@ from pathlib import Path
try:
from cloud.chv import ChvFailure
from cloud.chv import CloudHypervisorExecutor
from cloud.chv import WarmPool
from cloud.chv import reconcile_orphans_for_executor
from cloud.chv.experimental_pool import WarmPool
from cloud.chv.api_client import _unix_socket_api as _chv_api
from cloud.chv.config import _parse_memory_bytes as _chv_mem
from cloud.sandbox import ChvFailure as _sb_failure
from cloud.sandbox import CloudHypervisorExecutor as _sb_exec
from cloud.sandbox import DockerExecutor
from cloud.sandbox import reconcile_orphans_for_executor as _sb_reconcile
from cloud.sandbox import LocalExecutor
from cloud.sandbox import WarmPool as _sb_pool
from cloud.sandbox import _call_runner_with_timeout
from cloud.sandbox import _parse_memory_bytes as _sb_mem
from cloud.sandbox import _unix_socket_api as _sb_api
@@ -38,14 +39,15 @@ except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.p
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv import ChvFailure
from cloud.chv import CloudHypervisorExecutor
from cloud.chv import WarmPool
from cloud.chv import reconcile_orphans_for_executor
from cloud.chv.experimental_pool import WarmPool
from cloud.chv.api_client import _unix_socket_api as _chv_api
from cloud.chv.config import _parse_memory_bytes as _chv_mem
from cloud.sandbox import ChvFailure as _sb_failure
from cloud.sandbox import CloudHypervisorExecutor as _sb_exec
from cloud.sandbox import DockerExecutor
from cloud.sandbox import reconcile_orphans_for_executor as _sb_reconcile
from cloud.sandbox import LocalExecutor
from cloud.sandbox import WarmPool as _sb_pool
from cloud.sandbox import _call_runner_with_timeout
from cloud.sandbox import _parse_memory_bytes as _sb_mem
from cloud.sandbox import _unix_socket_api as _sb_api
@@ -168,17 +170,27 @@ class ChvOptCase(unittest.TestCase):
class SplitTest(unittest.TestCase):
def test_new_and_old_import_paths_are_same_objects(self):
self.assertIs(CloudHypervisorExecutor, _sb_exec)
self.assertIs(WarmPool, _sb_pool)
self.assertIs(ChvFailure, _sb_failure)
self.assertIs(_chv_mem, _sb_mem)
self.assertIs(_chv_api, _sb_api)
self.assertIs(reconcile_orphans_for_executor, _sb_reconcile)
def test_classes_live_in_chv_modules(self):
self.assertEqual(CloudHypervisorExecutor.__module__,
"cloud.chv.executor")
self.assertEqual(WarmPool.__module__, "cloud.chv.pool")
self.assertEqual(WarmPool.__module__,
"cloud.chv.experimental_pool")
self.assertEqual(ChvFailure.__module__, "cloud.chv.errors")
def test_old_warm_pool_import_paths_are_gone(self):
# 本轨破环性清理:旧路径一律 ImportError,实验路径可用。
with self.assertRaises(ImportError):
from cloud.chv import WarmPool # noqa: F401
with self.assertRaises(ImportError):
from cloud.sandbox import WarmPool # noqa: F401
with self.assertRaises(ImportError):
import cloud.chv.pool # noqa: F401
# -- 去重复:goal 解析与 host 命令 ------------------------------------------
class SharedGoalTest(unittest.TestCase):
+2 -2
View File
@@ -38,7 +38,7 @@ try:
dirty_marker_path,
write_seed,
)
from cloud.chv.pool import WarmPool
from cloud.chv.experimental_pool import WarmPool
from cloud.chv.ssh_transport import (
build_ssh_command,
split_secret_env,
@@ -67,7 +67,7 @@ except ImportError:
dirty_marker_path,
write_seed,
)
from cloud.chv.pool import WarmPool
from cloud.chv.experimental_pool import WarmPool
from cloud.chv.ssh_transport import (
build_ssh_command,
split_secret_env,
+2 -2
View File
@@ -20,18 +20,18 @@ from pathlib import Path
from unittest import mock
try:
from cloud.chv.experimental_pool import WarmPool
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
WarmPool,
_parse_memory_bytes,
)
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv.experimental_pool import WarmPool
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
WarmPool,
_parse_memory_bytes,
)
+17 -23
View File
@@ -802,17 +802,14 @@ class Worker:
self.executor.audit_callback = _chv_audit
except Exception:
pass
# CHV WarmPool:仅孤儿清理接线(feat-chv-arch95 定案)。
# 池构造本身不自动
# 清理(单活池假设,见 pool.py),worker 启动是显式
# 入口,在这里建池并调 reconcile_orphans();关闭时
# close() 再调一次。调用失败不炸 worker 构造/关闭,
# 但要进审计(_audit 本身 best-effort)。
self._warm_pool = None
# CHV 残留清理:仅孤儿清理接线(feat-chv-arch95 定案,
# 本轨分文件:生产入口 cloud.chv.orphans,绝不构造
# WarmPool、不 import 实验模块)。worker 启动是显式
# 入口,在这里调 facade;关闭时 close() 再调一次。
# 调用失败不炸 worker 构造/关闭,但要进审计(_audit
# 本身 best-effort)。本 worker 不再持有 _warm_pool。
if isinstance(self.executor, CloudHypervisorExecutor):
try:
from cloud.chv.pool import WarmPool as _WarmPool
self._warm_pool = _WarmPool(self.executor)
self._reconcile_warm_pool("startup")
except Exception:
pass
@@ -1076,25 +1073,22 @@ class Worker:
return self._cost_store
def _reconcile_warm_pool(self, when):
"""显式调 WarmPool.reconcile_orphans(),返回已清理 VM 名。
"""显式调生产残留清理 facade,返回已清理 VM 名。
仅 CHV worker 且池已建时有效;池未建时若执行器是 CHV
则现建现调(供测试与手动清理用)。调用失败不炸主流程,
但进审计(失败本身也审计,不静默丢弃)。orphan 条目
绝不发给任务,reconcile 只做清理。"""
pool = getattr(self, "_warm_pool", None)
if pool is None:
仅 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.pool import WarmPool as _WarmPool
pool = _WarmPool(executor)
self._warm_pool = pool
except Exception:
return []
try:
cleaned = pool.reconcile_orphans() or []
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",
+97 -1
View File
@@ -1,6 +1,6 @@
# Cloud Hypervisor 执行器架构决策记录(ADR)
范围:cloud/chv/ 执行器链(#216 → #221 → #230 → #232 → #234 → #239 → #246 → 本轨)。
范围:cloud/chv/ 执行器链(#216 → #221 → #230 → #232 → #234 → #239 → #246 → #252 → #254 → 本轨 feat-chv-arch-maint-fe)。
本文件只记录已落代码的决策,不记录未验证为已验证;真机、压测、动态安全
仍见 docs/chv-production-readiness.md §11。
@@ -97,6 +97,15 @@
import 路径),新代码不得经类入口进实验接口。
- 测试钉住:facade 测试断言 acquire/prewarm 绝不被调;
executor 不 import pool 由模块依赖检查覆盖。
- 本轨更新(feat-chv-arch-maint-fe,取代上文“保留兼容”
部分):CHV 尚未合并、尚无生产用户,“怕断 import”
不是保留理由,兼容保留已删除。WarmPool 从 pool.py
移到 experimental_pool.py,cloud/chv/__init__ 与
cloud/sandbox.py 不再导出它,生产残留清理彻底分文件
到 cloud/chv/orphans.py(facade 不再构造 WarmPool,
直接读 registry 文件做清理)。旧 import 路径一律
ImportError,见 ADR-12;合并前即生效的破环性清理,
合并时须按 PR 描述的清单检查调用方。
## ADR-9 审计失败取舍:记日志 + 计数,不中断任务
@@ -125,6 +134,93 @@
三个标记);_unix_socket_api 的读满语义仍由四个 helper
共同保证,改任一 helper 须重跑截断/超时测试。
## ADR-12 模块职责图与禁 import 清单(本轨)
依赖方向(箭头 = 可以 import,叶子在下):
- worker.py → orphans.py(生产残留清理,方法内 import)
→ 标准库;worker 不 import experimental_pool。
- executor.py → config/disk/hardening/hostkeys/
ssh_transport/api_client/errors(方法内按需 import
sandbox 共享 helper 除外,顶层绝不 import sandbox)。
- experimental_pool.py → orphans.py(只借 registry 文件
IO 与损坏语义,方向是实验 → 生产,绝不反向)。
- orphans/config/compat215/errors → 只依赖标准库
(叶子模块,测试用 AST 钉住)。
- __init__.py / sandbox.py → 只 re-export 生产接口
(CloudHypervisorExecutor、ChvFailure、
reconcile_orphans_for_executor),不导出 WarmPool。
- config.py → executor.py(executor 只经 build_chv_config
取配置,不直接读 env 做二次解析)。
禁 import 清单(测试用 AST 逐项钉住,文档提及不算):
- orphans/executor/worker/__init__/sandbox 一律不得
import experimental_pool 或已删除的 cloud.chv.pool;
- executor 一律不得 import orphans(执行器本体不做
残留清理,那是 worker 启动/关闭的职责);
- 任何生产模块不得 import cloud.gates /
cloud.sandbox_policy / cloud.timeouts / cloud.quota
(#215 未合并,见 compat215 REPLACEMENT_POINTS)。
## ADR-13 配置组装片段化(本轨,行为不变)
- build_chv_config 拆成六个片段:base(身份/资源基底)、
ssh(密钥/用户/端口/host key 模式/客内路径/命令行)、
network(net/net_json/ssh_host/ssh_forward/egress)、
quota(max_vms/warm_pool_size)、disk(磁盘模式/大小/
reuse 实验/快照上限与 URL)、timeout(任务/API/就绪
超时)。组装只做 dict 合并,键冲突即 ValueError,
绝不静默覆盖。
- 移动说明(字段集合与取值不变,测试以 38 字段集钉住):
egress 从旧 quota 片段移到 network(它是出口策略,
不是 VM 配额);snapshot_url 从旧超时片段移到 disk;
warm_pool_size 从旧超时片段移到 quota。唯一可观测
的校验顺序变化:warm_pool_size 非法与磁盘非法同时
出现时先报前者;单项非法时报错文案逐字一致。
- 类型:cloud/chv/ 公开 API(非下划线函数 + 公开类
__init__)全量标注,无 Any 逃逸;本轨顺手修正三处
旧标注错误(record_snapshot 应为 tuple、host_key_paths
应为 dict、build_seed_disk 应为 str)。私有 helper
不在“公开 API”口径内,不硬标。
## ADR-14 前端只读侧扩展与验证方式(本轨)
- 本轨前端只做只读侧,不做控制按钮:后端无 VM 控制
端点且安全审计 No-Go,绝不发明控制接口。
- 列表行:此前 chv_vm_dead 只在详情展示,本轨列表行
增加“VM 已报告死亡”徽标(字段已由同一 serializer
返回,列表此前只是没渲染);无徽标时不代表 VM 活着。
- 详情:此前只有 chv_vm_dead 时才出现 VM 行,本轨对
所有 CHV 任务都显示 VM 行——已报告死亡给死亡文案,
否则给“未报告 VM 死亡(后端无 live 状态字段,不代表
VM 活着)”。非 CHV 任务不显示 VM 行;死亡文案同样
先过执行器门,防止非 CHV 行误带字段时误显示。
- 池管理与成本页:不新开页面。已有数据继续在已有
位置展示:WorkersSection 的 CHV worker 在线数/共计、
详情成本卡(token/LLM 请求数/美元明细,来自
GET /tasks/{id}/cost)。没有数据的页面绝不新开。
- 后端字段:本轨未新增第二个生命周期字段,理由写清——
server 持久化的 CHV 生命周期事实只有 executor /
status / interrupted_reason 三项,chv_vm_dead 已是其
完整派生;再编一个字段要么是 interrupted_reason 的
复制,要么是猜 live 状态,两者都不做。本轨后端改动
是把派生抽成 _chv_vm_dead_from_row(行为逐字一致)
并补 goal 回落形态(纯文本“VM 中途死亡”)的测试。
- 前端测试:纯逻辑全覆盖——执行器选择谓词(精确值,
不宽松匹配)、可用性(上一轨)、附件守卫谓词(CHV +
带附件 → 后端 400,前端提示口径,后端是最终权威;
派发页提交拦截已接该谓词)、只读状态文案与列表徽标。
组件渲染不在覆盖内(无 testing-library 依赖,不临时
加依赖冒充覆盖)。
- 验证方式(仓库浏览器任务拒绝回环地址是已知限制,
本轨绝不声称已做真机点击):① tsc + vite 构建通过;
② npm run build:console 产物 console.html 经 grep 含
新文案;③ Vitest 全过;④ 用户侧 live browser 手动
检查清单(见 PR 描述):本地起 vite preview 或直接
打开部署后的控制台,建一个 CHV 任务(无附件)进详情
看 VM 行文案、带附件提交看拦截文案、列表行对已报告
死亡任务看徽标;真机点击结果由用户侧回填,不在本轨
回执里预填。
## ADR-11 只读 VM 字段边界:chv_vm_dead 不是 live 状态
- 决策:server 任务序列化只在 CHV 任务且 worker 已把