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:
1 parent
42de20da68
commit
df8c64bd64
30 files changed
+1263
-331
No files matched your search
+23
-8
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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..."。其它主机行、
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 } from "../api/client";
|
||||
import { chvAvailability } from "../api/chv";
|
||||
import { chvAttachmentBlocked, chvAvailability, isChvExecutor } from "../api/chv";
|
||||
import { EFFORTS } from "../api/types";
|
||||
|
||||
export default function DispatchSection() {
|
||||
@@ -26,7 +26,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),
|
||||
@@ -69,7 +69,7 @@ export default function DispatchSection() {
|
||||
if (goalMode || goalInvincible || planMode) { setMsg("协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON"); return; }
|
||||
}
|
||||
const atts = 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);
|
||||
setMsg(result);
|
||||
|
||||
@@ -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,
|
||||
// 与任务详情共用同一口径。
|
||||
@@ -80,6 +80,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>
|
||||
);
|
||||
|
||||
+23
-23
@@ -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 p of document.querySelectorAll('link[rel="modulepreload"]'))f(p);new MutationObserver(p=>{for(const N of p)if(N.type==="childList")for(const C of N.addedNodes)C.tagName==="LINK"&&C.rel==="modulepreload"&&f(C)}).observe(document,{childList:!0,subtree:!0});function h(p){const N={};return p.integrity&&(N.integrity=p.integrity),p.referrerPolicy&&(N.referrerPolicy=p.referrerPolicy),p.crossOrigin==="use-credentials"?N.credentials="include":p.crossOrigin==="anonymous"?N.credentials="omit":N.credentials="same-origin",N}function f(p){if(p.ep)return;p.ep=!0;const N=h(p);fetch(p.href,N)}})();function Wh(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var io={exports:{}},ui={};/**
|
||||
<script type="module" crossorigin>(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const p of document.querySelectorAll('link[rel="modulepreload"]'))f(p);new MutationObserver(p=>{for(const N of p)if(N.type==="childList")for(const C of N.addedNodes)C.tagName==="LINK"&&C.rel==="modulepreload"&&f(C)}).observe(document,{childList:!0,subtree:!0});function h(p){const N={};return p.integrity&&(N.integrity=p.integrity),p.referrerPolicy&&(N.referrerPolicy=p.referrerPolicy),p.crossOrigin==="use-credentials"?N.credentials="include":p.crossOrigin==="anonymous"?N.credentials="omit":N.credentials="same-origin",N}function f(p){if(p.ep)return;p.ep=!0;const N=h(p);fetch(p.href,N)}})();function Ih(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var co={exports:{}},ui={};/**
|
||||
* @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 ui;wh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function h(f,p,N){var C=null;if(N!==void 0&&(C=""+N),p.key!==void 0&&(C=""+p.key),"key"in p){N={};for(var j in p)j!=="key"&&(N[j]=p[j])}else N=p;return p=N.ref,{$$typeof:i,type:f,key:C,ref:p!==void 0?p:null,props:N}}return ui.Fragment=r,ui.jsx=h,ui.jsxs=h,ui}var Uh;function Yy(){return Uh||(Uh=1,io.exports=qy()),io.exports}var c=Yy(),co={exports:{}},pt={};/**
|
||||
*/var Uh;function Gy(){if(Uh)return ui;Uh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function h(f,p,N){var C=null;if(N!==void 0&&(C=""+N),p.key!==void 0&&(C=""+p.key),"key"in p){N={};for(var j in p)j!=="key"&&(N[j]=p[j])}else N=p;return p=N.ref,{$$typeof:i,type:f,key:C,ref:p!==void 0?p:null,props:N}}return ui.Fragment=r,ui.jsx=h,ui.jsxs=h,ui}var Hh;function Vy(){return Hh||(Hh=1,co.exports=Gy()),co.exports}var c=Vy(),so={exports:{}},pt={};/**
|
||||
* @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 pt;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"),p=Symbol.for("react.profiler"),N=Symbol.for("react.consumer"),C=Symbol.for("react.context"),j=Symbol.for("react.forward_ref"),z=Symbol.for("react.suspense"),R=Symbol.for("react.memo"),q=Symbol.for("react.lazy"),g=Symbol.for("react.activity"),U=Symbol.for("react.view_transition"),F=Symbol.iterator;function X(y){return y===null||typeof y!="object"?null:(y=F&&y[F]||y["@@iterator"],typeof y=="function"?y:null)}var J={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Q=Object.assign,vt={};function St(y,D,ut){this.props=y,this.context=D,this.refs=vt,this.updater=ut||J}St.prototype.isReactComponent={},St.prototype.setState=function(y,D){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,D,"setState")},St.prototype.forceUpdate=function(y){this.updater.enqueueForceUpdate(this,y,"forceUpdate")};function jt(){}jt.prototype=St.prototype;function K(y,D,ut){this.props=y,this.context=D,this.refs=vt,this.updater=ut||J}var Nt=K.prototype=new jt;Nt.constructor=K,Q(Nt,St.prototype),Nt.isPureReactComponent=!0;var w=Array.isArray;function V(){}var nt={H:null,A:null,T:null,S:null},Dt=Object.prototype.hasOwnProperty;function qt(y,D,ut){var et=ut.ref;return{$$typeof:i,type:y,key:D,ref:et!==void 0?et:null,props:ut}}function dt(y,D){return qt(y.type,D,y.props)}function kt(y){return typeof y=="object"&&y!==null&&y.$$typeof===i}function H(y){var D={"=":"=0",":":"=2"};return"$"+y.replace(/[=:]/g,function(ut){return D[ut]})}var ot=/\/+/g;function yt(y,D){return typeof y=="object"&&y!==null&&y.key!=null?H(""+y.key):D.toString(36)}function G(y){switch(y.status){case"fulfilled":return y.value;case"rejected":throw y.reason;default:switch(typeof y.status=="string"?y.then(V,V):(y.status="pending",y.then(function(D){y.status==="pending"&&(y.status="fulfilled",y.value=D)},function(D){y.status==="pending"&&(y.status="rejected",y.reason=D)})),y.status){case"fulfilled":return y.value;case"rejected":throw y.reason}}throw y}function Y(y,D,ut,et,Ct){var bt=typeof y;(bt==="undefined"||bt==="boolean")&&(y=null);var Tt=!1;if(y===null)Tt=!0;else switch(bt){case"bigint":case"string":case"number":Tt=!0;break;case"object":switch(y.$$typeof){case i:case r:Tt=!0;break;case q:return Tt=y._init,Y(Tt(y._payload),D,ut,et,Ct)}}if(Tt)return Ct=Ct(y),Tt=et===""?"."+yt(y,0):et,w(Ct)?(ut="",Tt!=null&&(ut=Tt.replace(ot,"$&/")+"/"),Y(Ct,D,ut,"",function(re){return re})):Ct!=null&&(kt(Ct)&&(Ct=dt(Ct,ut+(Ct.key==null||y&&y.key===Ct.key?"":(""+Ct.key).replace(ot,"$&/")+"/")+Tt)),D.push(Ct)),1;Tt=0;var lt=et===""?".":et+":";if(w(y))for(var mt=0;mt<y.length;mt++)et=y[mt],bt=lt+yt(et,mt),Tt+=Y(et,D,ut,bt,Ct);else if(mt=X(y),typeof mt=="function")for(y=mt.call(y),mt=0;!(et=y.next()).done;)et=et.value,bt=lt+yt(et,mt++),Tt+=Y(et,D,ut,bt,Ct);else if(bt==="object"){if(typeof y.then=="function")return Y(G(y),D,ut,et,Ct);throw D=String(y),Error("Objects are not valid as a React child (found: "+(D==="[object Object]"?"object with keys {"+Object.keys(y).join(", ")+"}":D)+"). If you meant to render a collection of children, use an array instead.")}return Tt}function tt(y,D,ut){if(y==null)return y;var et=[],Ct=0;return Y(y,et,"","",function(bt){return D.call(ut,bt,Ct++)}),et}function gt(y){if(y._status===-1){var D=y._result,ut=D();ut.then(function(et){(y._status===0||y._status===-1)&&(y._status=1,y._result=et,ut.status===void 0&&(ut.status="fulfilled",ut.value=et))},function(et){(y._status===0||y._status===-1)&&(y._status=2,y._result=et,ut.status===void 0&&(ut.status="rejected",ut.reason=et))}),y._status===-1&&(y._status=0,y._result=ut)}if(y._status===1)return y._result.default;throw y._result}var xt=typeof reportError=="function"?reportError:function(y){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var D=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(D))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",y);return}console.error(y)};function Se(y){var D=nt.T,ut={};ut.types=D!==null?D.types:null,nt.T=ut;try{var et=y(),Ct=nt.S;Ct!==null&&Ct(ut,et),typeof et=="object"&&et!==null&&typeof et.then=="function"&&et.then(V,xt)}catch(bt){xt(bt)}finally{D!==null&&ut.types!==null&&(D.types=ut.types),nt.T=D}}function L(y){var D=nt.T;if(D!==null){var ut=D.types;ut===null?D.types=[y]:ut.indexOf(y)===-1&&ut.push(y)}else Se(L.bind(null,y))}var se={map:tt,forEach:function(y,D,ut){tt(y,function(){D.apply(this,arguments)},ut)},count:function(y){var D=0;return tt(y,function(){D++}),D},toALine truncated
|
||||
*/var Bh;function Qy(){if(Bh)return pt;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"),p=Symbol.for("react.profiler"),N=Symbol.for("react.consumer"),C=Symbol.for("react.context"),j=Symbol.for("react.forward_ref"),z=Symbol.for("react.suspense"),R=Symbol.for("react.memo"),q=Symbol.for("react.lazy"),g=Symbol.for("react.activity"),U=Symbol.for("react.view_transition"),F=Symbol.iterator;function X(y){return y===null||typeof y!="object"?null:(y=F&&y[F]||y["@@iterator"],typeof y=="function"?y:null)}var J={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Q=Object.assign,vt={};function St(y,D,ut){this.props=y,this.context=D,this.refs=vt,this.updater=ut||J}St.prototype.isReactComponent={},St.prototype.setState=function(y,D){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,D,"setState")},St.prototype.forceUpdate=function(y){this.updater.enqueueForceUpdate(this,y,"forceUpdate")};function jt(){}jt.prototype=St.prototype;function K(y,D,ut){this.props=y,this.context=D,this.refs=vt,this.updater=ut||J}var Nt=K.prototype=new jt;Nt.constructor=K,Q(Nt,St.prototype),Nt.isPureReactComponent=!0;var k=Array.isArray;function V(){}var nt={H:null,A:null,T:null,S:null},Dt=Object.prototype.hasOwnProperty;function qt(y,D,ut){var et=ut.ref;return{$$typeof:i,type:y,key:D,ref:et!==void 0?et:null,props:ut}}function dt(y,D){return qt(y.type,D,y.props)}function wt(y){return typeof y=="object"&&y!==null&&y.$$typeof===i}function H(y){var D={"=":"=0",":":"=2"};return"$"+y.replace(/[=:]/g,function(ut){return D[ut]})}var ot=/\/+/g;function yt(y,D){return typeof y=="object"&&y!==null&&y.key!=null?H(""+y.key):D.toString(36)}function G(y){switch(y.status){case"fulfilled":return y.value;case"rejected":throw y.reason;default:switch(typeof y.status=="string"?y.then(V,V):(y.status="pending",y.then(function(D){y.status==="pending"&&(y.status="fulfilled",y.value=D)},function(D){y.status==="pending"&&(y.status="rejected",y.reason=D)})),y.status){case"fulfilled":return y.value;case"rejected":throw y.reason}}throw y}function Y(y,D,ut,et,Ct){var bt=typeof y;(bt==="undefined"||bt==="boolean")&&(y=null);var Tt=!1;if(y===null)Tt=!0;else switch(bt){case"bigint":case"string":case"number":Tt=!0;break;case"object":switch(y.$$typeof){case i:case r:Tt=!0;break;case q:return Tt=y._init,Y(Tt(y._payload),D,ut,et,Ct)}}if(Tt)return Ct=Ct(y),Tt=et===""?"."+yt(y,0):et,k(Ct)?(ut="",Tt!=null&&(ut=Tt.replace(ot,"$&/")+"/"),Y(Ct,D,ut,"",function(re){return re})):Ct!=null&&(wt(Ct)&&(Ct=dt(Ct,ut+(Ct.key==null||y&&y.key===Ct.key?"":(""+Ct.key).replace(ot,"$&/")+"/")+Tt)),D.push(Ct)),1;Tt=0;var lt=et===""?".":et+":";if(k(y))for(var mt=0;mt<y.length;mt++)et=y[mt],bt=lt+yt(et,mt),Tt+=Y(et,D,ut,bt,Ct);else if(mt=X(y),typeof mt=="function")for(y=mt.call(y),mt=0;!(et=y.next()).done;)et=et.value,bt=lt+yt(et,mt++),Tt+=Y(et,D,ut,bt,Ct);else if(bt==="object"){if(typeof y.then=="function")return Y(G(y),D,ut,et,Ct);throw D=String(y),Error("Objects are not valid as a React child (found: "+(D==="[object Object]"?"object with keys {"+Object.keys(y).join(", ")+"}":D)+"). If you meant to render a collection of children, use an array instead.")}return Tt}function tt(y,D,ut){if(y==null)return y;var et=[],Ct=0;return Y(y,et,"","",function(bt){return D.call(ut,bt,Ct++)}),et}function gt(y){if(y._status===-1){var D=y._result,ut=D();ut.then(function(et){(y._status===0||y._status===-1)&&(y._status=1,y._result=et,ut.status===void 0&&(ut.status="fulfilled",ut.value=et))},function(et){(y._status===0||y._status===-1)&&(y._status=2,y._result=et,ut.status===void 0&&(ut.status="rejected",ut.reason=et))}),y._status===-1&&(y._status=0,y._result=ut)}if(y._status===1)return y._result.default;throw y._result}var xt=typeof reportError=="function"?reportError:function(y){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var D=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(D))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",y);return}console.error(y)};function Se(y){var D=nt.T,ut={};ut.types=D!==null?D.types:null,nt.T=ut;try{var et=y(),Ct=nt.S;Ct!==null&&Ct(ut,et),typeof et=="object"&&et!==null&&typeof et.then=="function"&&et.then(V,xt)}catch(bt){xt(bt)}finally{D!==null&&ut.types!==null&&(D.types=ut.types),nt.T=D}}function L(y){var D=nt.T;if(D!==null){var ut=D.types;ut===null?D.types=[y]:ut.indexOf(y)===-1&&ut.push(y)}else Se(L.bind(null,y))}var se={map:tt,forEach:function(y,D,ut){tt(y,function(){D.apply(this,arguments)},ut)},count:function(y){var D=0;return tt(y,function(){D++}),D},toALine 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 Qy(){return qh||(qh=1,(function(i){function r(G,Y){var tt=G.length;G.push(Y);t:for(;0<tt;){var gt=tt-1>>>1,xt=G[gt];if(0<p(xt,Y))G[gt]=Y,G[tt]=xt,tt=gt;else break t}}function h(G){return G.length===0?null:G[0]}function f(G){if(G.length===0)return null;var Y=G[0],tt=G.pop();if(tt!==Y){G[0]=tt;t:for(var gt=0,xt=G.length,Se=xt>>>1;gt<Se;){var L=2*(gt+1)-1,se=G[L],y=L+1,D=G[y];if(0>p(se,tt))y<xt&&0>p(D,se)?(G[gt]=D,G[y]=tt,gt=y):(G[gt]=se,G[L]=tt,gt=L);else if(y<xt&&0>p(D,tt))G[gt]=D,G[y]=tt,gt=y;else break t}}return Y}function p(G,Y){var tt=G.sortIndex-Y.sortIndex;return tt!==0?tt:G.id-Y.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var N=performance;i.unstable_now=function(){return N.now()}}else{var C=Date,j=C.now();i.unstable_now=function(){return C.now()-j}}var z=[],R=[],q=1,g=null,U=3,F=!1,X=!1,J=!1,Q=!1,vt=typeof setTimeout=="function"?setTimeout:null,St=typeof clearTimeout=="function"?clearTimeout:null,jt=typeof setImmediate<"u"?setImmediate:null;function K(G){for(var Y=h(R);Y!==null;){if(Y.callback===null)f(R);else if(Y.startTime<=G)f(R),Y.sortIndex=Y.expirationTime,r(z,Y);else break;Y=h(R)}}function Nt(G){if(J=!1,K(G),!X)if(h(z)!==null)X=!0,w||(w=!0,kt());else{var Y=h(R);Y!==null&&yt(Nt,Y.startTime-G)}}var w=!1,V=-1,nt=5,Dt=-1;function qt(){return Q?!0:!(i.unstable_now()-Dt<nt)}function dt(){if(Q=!1,w){var G=i.unstable_now();Dt=G;var Y=!0;try{t:{X=!1,J&&(J=!1,St(V),V=-1),F=!0;var tt=U;try{e:{for(K(G),g=h(z);g!==null&&!(g.expirationTime>G&&qt());){var gt=g.callback;if(typeof gt=="function"){g.callback=null,U=g.priorityLevel;var xt=gt(g.expirationTime<=G);if(G=i.unstable_now(),typeof xt=="function"){g.callback=xt,K(G),Y=!0;break e}g===h(z)&&f(z),K(G)}else f(z);g=h(z)}if(g!==null)Y=!0;else{var Se=h(R);Se!==null&&yt(Nt,Se.startTime-G),Y=!1}}break t}finally{g=null,U=tt,F=!1}Y=void 0}}finally{Y?kt():w=!1}}}var kt;if(typeof jt=="function")kt=function(){jt(dt)};else if(typeof MessageChannel<"u"){var H=new MessageChannel,ot=H.port2;H.port1.onmessage=dt,kt=function(){ot.postMessage(null)}}else kt=function(){vt(dt,0)};function yt(G,Y){V=vt(function(){G(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(G){G.callback=null},i.unstable_forceFrameRate=function(G){0>G||125<G?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):nt=0<G?Math.floor(1e3/G):5},i.unstable_getCurrentPriorityLevel=function(){return U},i.unstable_next=function(G){switch(U){case 1:case 2:case 3:var Y=3;break;default:Y=U}var tt=U;U=Y;try{return G()}finally{U=tt}},i.unstable_requestPaint=function(){Q=!0},i.unstable_runWithPriority=function(G,Y){switch(G){case 1:case 2:case 3:case 4:case 5:break;default:G=3}var tt=U;U=G;try{return Y()}finally{U=tt}},i.unstable_scheduleCallback=function(G,Y,tt){var gt=i.unstable_now();switch(typeof tt=="object"&&tt!==null?(tt=tt.delay,tt=typeof tt=="number"&&0<tt?gt+tt:gt):tt=gt,G){case 1:var xt=-1;break;case 2:xt=250;break;case 5:xt=1073741823;break;case 4:xt=1e4;break;default:xt=5e3}return xt=tt+xt,G={id:q++,callback:Y,priorityLevel:G,startTime:tt,expirationTime:xt,sortIndex:-1},tt>gt?(G.sortIndex=tt,r(R,G),h(z)===null&&G===h(R)&&(J?(St(V),V=-1):J=!0,yt(Nt,tt-gt))):(G.sortIndex=xt,r(z,G),X||F||(X=!0,w||(w=!0,kt()))),G},i.unstable_shouldYield=qt,i.unstable_wrapCallback=function(G){var Y=U;return function(){var tt=U;U=Y;try{return G.apply(this,arguments)}finally{U=tt}}}})(oo)),oo}var Yh;function Xy(){return Yh||(Yh=1,ro.exports=Qy()),ro.exports}var fo={exports:{}},Ue={};/**
|
||||
*/var Yh;function Ly(){return Yh||(Yh=1,(function(i){function r(G,Y){var tt=G.length;G.push(Y);t:for(;0<tt;){var gt=tt-1>>>1,xt=G[gt];if(0<p(xt,Y))G[gt]=Y,G[tt]=xt,tt=gt;else break t}}function h(G){return G.length===0?null:G[0]}function f(G){if(G.length===0)return null;var Y=G[0],tt=G.pop();if(tt!==Y){G[0]=tt;t:for(var gt=0,xt=G.length,Se=xt>>>1;gt<Se;){var L=2*(gt+1)-1,se=G[L],y=L+1,D=G[y];if(0>p(se,tt))y<xt&&0>p(D,se)?(G[gt]=D,G[y]=tt,gt=y):(G[gt]=se,G[L]=tt,gt=L);else if(y<xt&&0>p(D,tt))G[gt]=D,G[y]=tt,gt=y;else break t}}return Y}function p(G,Y){var tt=G.sortIndex-Y.sortIndex;return tt!==0?tt:G.id-Y.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var N=performance;i.unstable_now=function(){return N.now()}}else{var C=Date,j=C.now();i.unstable_now=function(){return C.now()-j}}var z=[],R=[],q=1,g=null,U=3,F=!1,X=!1,J=!1,Q=!1,vt=typeof setTimeout=="function"?setTimeout:null,St=typeof clearTimeout=="function"?clearTimeout:null,jt=typeof setImmediate<"u"?setImmediate:null;function K(G){for(var Y=h(R);Y!==null;){if(Y.callback===null)f(R);else if(Y.startTime<=G)f(R),Y.sortIndex=Y.expirationTime,r(z,Y);else break;Y=h(R)}}function Nt(G){if(J=!1,K(G),!X)if(h(z)!==null)X=!0,k||(k=!0,wt());else{var Y=h(R);Y!==null&&yt(Nt,Y.startTime-G)}}var k=!1,V=-1,nt=5,Dt=-1;function qt(){return Q?!0:!(i.unstable_now()-Dt<nt)}function dt(){if(Q=!1,k){var G=i.unstable_now();Dt=G;var Y=!0;try{t:{X=!1,J&&(J=!1,St(V),V=-1),F=!0;var tt=U;try{e:{for(K(G),g=h(z);g!==null&&!(g.expirationTime>G&&qt());){var gt=g.callback;if(typeof gt=="function"){g.callback=null,U=g.priorityLevel;var xt=gt(g.expirationTime<=G);if(G=i.unstable_now(),typeof xt=="function"){g.callback=xt,K(G),Y=!0;break e}g===h(z)&&f(z),K(G)}else f(z);g=h(z)}if(g!==null)Y=!0;else{var Se=h(R);Se!==null&&yt(Nt,Se.startTime-G),Y=!1}}break t}finally{g=null,U=tt,F=!1}Y=void 0}}finally{Y?wt():k=!1}}}var wt;if(typeof jt=="function")wt=function(){jt(dt)};else if(typeof MessageChannel<"u"){var H=new MessageChannel,ot=H.port2;H.port1.onmessage=dt,wt=function(){ot.postMessage(null)}}else wt=function(){vt(dt,0)};function yt(G,Y){V=vt(function(){G(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(G){G.callback=null},i.unstable_forceFrameRate=function(G){0>G||125<G?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):nt=0<G?Math.floor(1e3/G):5},i.unstable_getCurrentPriorityLevel=function(){return U},i.unstable_next=function(G){switch(U){case 1:case 2:case 3:var Y=3;break;default:Y=U}var tt=U;U=Y;try{return G()}finally{U=tt}},i.unstable_requestPaint=function(){Q=!0},i.unstable_runWithPriority=function(G,Y){switch(G){case 1:case 2:case 3:case 4:case 5:break;default:G=3}var tt=U;U=G;try{return Y()}finally{U=tt}},i.unstable_scheduleCallback=function(G,Y,tt){var gt=i.unstable_now();switch(typeof tt=="object"&&tt!==null?(tt=tt.delay,tt=typeof tt=="number"&&0<tt?gt+tt:gt):tt=gt,G){case 1:var xt=-1;break;case 2:xt=250;break;case 5:xt=1073741823;break;case 4:xt=1e4;break;default:xt=5e3}return xt=tt+xt,G={id:q++,callback:Y,priorityLevel:G,startTime:tt,expirationTime:xt,sortIndex:-1},tt>gt?(G.sortIndex=tt,r(R,G),h(z)===null&&G===h(R)&&(J?(St(V),V=-1):J=!0,yt(Nt,tt-gt))):(G.sortIndex=xt,r(z,G),X||F||(X=!0,k||(k=!0,wt()))),G},i.unstable_shouldYield=qt,i.unstable_wrapCallback=function(G){var Y=U;return function(){var tt=U;U=Y;try{return G.apply(this,arguments)}finally{U=tt}}}})(fo)),fo}var Gh;function Zy(){return Gh||(Gh=1,oo.exports=Ly()),oo.exports}var mo={exports:{}},Ue={};/**
|
||||
* @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 Ue;Gh=1;var i=po();function r(q){var g="https://react.dev/errors/"+q;if(1<arguments.length){g+="?args[]="+encodeURIComponent(arguments[1]);for(var U=2;U<arguments.length;U++)g+="&args[]="+encodeURIComponent(arguments[U])}return"Minified React error #"+q+"; visit "+g+" 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},p=Symbol.for("react.portal"),N=Symbol.for("react.recoverable"),C=Symbol.for("react.optimistic_key");function j(q,g,U){var F=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:p,key:F==null?null:F===C?C:""+F,children:q,containerInfo:g,implementation:U}}var z=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function R(q,g){if(q==="font")return"";if(typeof g=="string")return g==="use-credentials"?g:""}return Ue.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,Ue.browser=function(q){return{$$typeof:N,_reason:q}},Ue.createPortal=function(q,g){var U=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!g||g.nodeType!==1&&g.nodeType!==9&&g.nodeType!==11)throw Error(r(299));return j(q,g,null,U)},Ue.flushSync=function(q){var g=z.T,U=f.p;try{if(z.T=null,f.p=2,q)return q()}finally{z.T=g,f.p=U,f.d.f()}},Ue.preconnect=function(q,g){typeof q=="string"&&(g?(g=g.crossOrigin,g=typeof g=="string"?g==="use-credentials"?g:"":void 0):g=null,f.d.C(q,g))},Ue.prefetchDNS=function(q){typeof q=="string"&&f.d.D(q)},Ue.preinit=function(q,g){if(typeof q=="string"&&g&&typeof g.as=="string"){var U=g.as,F=R(U,g.crossOrigin),X=typeof g.integrity=="string"?g.integrity:void 0,J=typeof g.fetchPriority=="string"?g.fetchPriority:void 0;U==="style"?f.d.S(q,typeof g.precedence=="string"?g.precedence:void 0,{crossOrigin:F,integrity:X,fetchPriority:J}):U==="script"&&f.d.X(q,{crossOrigin:F,integrity:X,fetchPriority:J,nonce:typeof g.nonce=="string"?g.nonce:void 0})}},Ue.preinitModule=function(q,g){if(typeof q=="string")if(typeof g=="object"&&g!==null){if(g.as==null||g.as==="script"){var U=R(g.as,g.crossOrigin);f.d.M(q,{crossOrigin:U,integrity:typeof g.integrity=="string"?g.integrity:void 0,nonce:typeof g.nonce=="string"?g.nonce:void 0,fetchPriority:typeof g.fetchPriority=="string"?g.fetchPriority:void 0})}}else g==null&&f.d.M(q)},Ue.preload=function(q,g){if(typeof q=="string"&&typeof g=="object"&&g!==null&&typeof g.as=="string"){var U=g.as,F=R(U,g.crossOrigin);f.d.L(q,U,{crossOrigin:F,integrity:typeof g.integrity=="string"?g.integrity:void 0,nonce:typeof g.nonce=="string"?g.nonce:void 0,type:typeof g.type=="string"?g.type:void 0,fetchPriority:typeof g.fetchPriority=="string"?g.fetchPriority:void 0,referrerPolicy:typeof g.referrerPolicy=="string"?g.referrerPolicy:void 0,imageSrcSet:typeof g.imageSrcSet=="string"?g.imageSrcSet:void 0,imageSizes:typeof g.imageSizes=="string"?g.imageSizes:void 0,media:typeof g.media=="string"?g.media:void 0})}},Ue.preloadModule=function(q,g){if(typeof q=="string")if(g){var U=R(g.as,g.crossOrigin);f.d.m(q,{as:typeof g.as=="string"&&g.as!=="script"?g.as:void 0,crossOrigin:U,integrity:typeof g.integrity=="string"?g.integrity:void 0,nonce:typeof g.nonce=="string"?g.nonce:void 0,fetchPriority:typeof g.fetchPriority=="string"?g.fetchPriority:void 0})}else f.d.m(q)},Ue.requestFormReset=function(q){f.d.r(q)},Ue.unstable_batchedUpdates=function(q,g){return q(g)},Ue.useFormState=function(q,g,U){return z.H.useFormState(q,g,U)},Ue.useFormStatus=function(){return z.H.useHostTransitionStatus()},Ue.version="19.3.1",Ue}var Vh;function Zy(){if(Vh)return fo.exports;Vh=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(),fo.exports=Ly(),fo.exports}/**
|
||||
*/var Vh;function Ky(){if(Vh)return Ue;Vh=1;var i=So();function r(q){var g="https://react.dev/errors/"+q;if(1<arguments.length){g+="?args[]="+encodeURIComponent(arguments[1]);for(var U=2;U<arguments.length;U++)g+="&args[]="+encodeURIComponent(arguments[U])}return"Minified React error #"+q+"; visit "+g+" 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},p=Symbol.for("react.portal"),N=Symbol.for("react.recoverable"),C=Symbol.for("react.optimistic_key");function j(q,g,U){var F=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:p,key:F==null?null:F===C?C:""+F,children:q,containerInfo:g,implementation:U}}var z=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function R(q,g){if(q==="font")return"";if(typeof g=="string")return g==="use-credentials"?g:""}return Ue.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,Ue.browser=function(q){return{$$typeof:N,_reason:q}},Ue.createPortal=function(q,g){var U=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!g||g.nodeType!==1&&g.nodeType!==9&&g.nodeType!==11)throw Error(r(299));return j(q,g,null,U)},Ue.flushSync=function(q){var g=z.T,U=f.p;try{if(z.T=null,f.p=2,q)return q()}finally{z.T=g,f.p=U,f.d.f()}},Ue.preconnect=function(q,g){typeof q=="string"&&(g?(g=g.crossOrigin,g=typeof g=="string"?g==="use-credentials"?g:"":void 0):g=null,f.d.C(q,g))},Ue.prefetchDNS=function(q){typeof q=="string"&&f.d.D(q)},Ue.preinit=function(q,g){if(typeof q=="string"&&g&&typeof g.as=="string"){var U=g.as,F=R(U,g.crossOrigin),X=typeof g.integrity=="string"?g.integrity:void 0,J=typeof g.fetchPriority=="string"?g.fetchPriority:void 0;U==="style"?f.d.S(q,typeof g.precedence=="string"?g.precedence:void 0,{crossOrigin:F,integrity:X,fetchPriority:J}):U==="script"&&f.d.X(q,{crossOrigin:F,integrity:X,fetchPriority:J,nonce:typeof g.nonce=="string"?g.nonce:void 0})}},Ue.preinitModule=function(q,g){if(typeof q=="string")if(typeof g=="object"&&g!==null){if(g.as==null||g.as==="script"){var U=R(g.as,g.crossOrigin);f.d.M(q,{crossOrigin:U,integrity:typeof g.integrity=="string"?g.integrity:void 0,nonce:typeof g.nonce=="string"?g.nonce:void 0,fetchPriority:typeof g.fetchPriority=="string"?g.fetchPriority:void 0})}}else g==null&&f.d.M(q)},Ue.preload=function(q,g){if(typeof q=="string"&&typeof g=="object"&&g!==null&&typeof g.as=="string"){var U=g.as,F=R(U,g.crossOrigin);f.d.L(q,U,{crossOrigin:F,integrity:typeof g.integrity=="string"?g.integrity:void 0,nonce:typeof g.nonce=="string"?g.nonce:void 0,type:typeof g.type=="string"?g.type:void 0,fetchPriority:typeof g.fetchPriority=="string"?g.fetchPriority:void 0,referrerPolicy:typeof g.referrerPolicy=="string"?g.referrerPolicy:void 0,imageSrcSet:typeof g.imageSrcSet=="string"?g.imageSrcSet:void 0,imageSizes:typeof g.imageSizes=="string"?g.imageSizes:void 0,media:typeof g.media=="string"?g.media:void 0})}},Ue.preloadModule=function(q,g){if(typeof q=="string")if(g){var U=R(g.as,g.crossOrigin);f.d.m(q,{as:typeof g.as=="string"&&g.as!=="script"?g.as:void 0,crossOrigin:U,integrity:typeof g.integrity=="string"?g.integrity:void 0,nonce:typeof g.nonce=="string"?g.nonce:void 0,fetchPriority:typeof g.fetchPriority=="string"?g.fetchPriority:void 0})}else f.d.m(q)},Ue.requestFormReset=function(q){f.d.r(q)},Ue.unstable_batchedUpdates=function(q,g){return q(g)},Ue.useFormState=function(q,g,U){return z.H.useFormState(q,g,U)},Ue.useFormStatus=function(){return z.H.useHostTransitionStatus()},Ue.version="19.3.1",Ue}var Qh;function Jy(){if(Qh)return mo.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(),mo.exports=Ky(),mo.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 Qh;function Ky(){if(Qh)return ii;Qh=1;var i=Xy(),r=po(),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 p(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function N(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 C(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 j(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 z(t){if(N(t)!==t)throw Error(f(188))}function R(t){var e=t.alternate;if(!e){if(e=N(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 z(n),t;if(u===a)return z(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 g(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)&&g(t.child,e,l,a,n,u))return!0;t=t.sibling}return!1}function U(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 F(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 X(t){var e=[null,null],l=U(t);return l===null||J(e,t,l.child,{foundSelf:!1}),e}function J(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)&&J(t,e,l.child,a))return!0;l=l.sibling}return!1}function Q(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 vt=null,St=null;function jt(t,e,l){return t===l?!0:t===e?(vt=t,!0):!1}function K(t,e,l){return t===l?(St=t,!1):t===e?(St!==null&&(vt=t),!0):!1}function Nt(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 w(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 V=Object.assign,nt=Symbol.for("react.element"),Dt=Symbol.for("react.transitional.element"),qt=Symbol.for("react.portal"),dt=Symbol.for("react.fragment"),kt=Symbol.for("react.strict_mode"),H=Symbol.for("react.profiler"),ot=Symbol.for("react.consumer"),yt=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),Y=Symbol.for("react.suspense"),tt=Symbol.for("react.suspense_list"),gt=Symbol.for("react.memo"),xt=Symbol.for("react.lazy"),Se=Symbol.for("react.activity"),L=Symbol.for("react.legacy_hidden"),se=Symbol.for("react.memo_cache_sentinel"),y=Symbol.for("react.view_transition"),D=Symbol.for("react.recoverable"),ut=Symbol.iterator;function et(t){return t===null||typeof t!="object"?null:(t=ut&&t[ut]||t["@@iterator"],typeof t=="function"?t:null)}var Ct=Symbol.for("react.client.reference");function bt(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===Ct?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case dt:return"Fragment";case H:return"Profiler";case kt:return"StrictMode";case Y:return"Suspense";case tt:return"SuspenseList";case Se:return"Activity";case y:return"ViewTransition"}if(typeof t=="object")switch(t.$$typeof){case qt:return"Portal";case yt:return t.displayName||"Context";case ot:return(t._context.displayName||"Context")+".Consumer";case G:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case gt:return e=t.displayName||null,e!==null?e:bt(t.type)||"Memo";case xt:e=t._payload,t=t._init;try{return bt(t(e))}catch{}}return null}var Tt=Array.isArray,lt=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,mt=h.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,re={pending:!1,data:null,method:null,action:nLine truncated
|
||||
*/var Xh;function Fy(){if(Xh)return ii;Xh=1;var i=Zy(),r=So(),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 p(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function N(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 C(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 j(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 z(t){if(N(t)!==t)throw Error(f(188))}function R(t){var e=t.alternate;if(!e){if(e=N(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 z(n),t;if(u===a)return z(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 g(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)&&g(t.child,e,l,a,n,u))return!0;t=t.sibling}return!1}function U(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 F(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 X(t){var e=[null,null],l=U(t);return l===null||J(e,t,l.child,{foundSelf:!1}),e}function J(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)&&J(t,e,l.child,a))return!0;l=l.sibling}return!1}function Q(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 vt=null,St=null;function jt(t,e,l){return t===l?!0:t===e?(vt=t,!0):!1}function K(t,e,l){return t===l?(St=t,!1):t===e?(St!==null&&(vt=t),!0):!1}function Nt(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 k(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 V=Object.assign,nt=Symbol.for("react.element"),Dt=Symbol.for("react.transitional.element"),qt=Symbol.for("react.portal"),dt=Symbol.for("react.fragment"),wt=Symbol.for("react.strict_mode"),H=Symbol.for("react.profiler"),ot=Symbol.for("react.consumer"),yt=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),Y=Symbol.for("react.suspense"),tt=Symbol.for("react.suspense_list"),gt=Symbol.for("react.memo"),xt=Symbol.for("react.lazy"),Se=Symbol.for("react.activity"),L=Symbol.for("react.legacy_hidden"),se=Symbol.for("react.memo_cache_sentinel"),y=Symbol.for("react.view_transition"),D=Symbol.for("react.recoverable"),ut=Symbol.iterator;function et(t){return t===null||typeof t!="object"?null:(t=ut&&t[ut]||t["@@iterator"],typeof t=="function"?t:null)}var Ct=Symbol.for("react.client.reference");function bt(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===Ct?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case dt:return"Fragment";case H:return"Profiler";case wt:return"StrictMode";case Y:return"Suspense";case tt:return"SuspenseList";case Se:return"Activity";case y:return"ViewTransition"}if(typeof t=="object")switch(t.$$typeof){case qt:return"Portal";case yt:return t.displayName||"Context";case ot:return(t._context.displayName||"Context")+".Consumer";case G:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case gt:return e=t.displayName||null,e!==null?e:bt(t.type)||"Memo";case xt:e=t._payload,t=t._init;try{return bt(t(e))}catch{}}return null}var Tt=Array.isArray,lt=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,mt=h.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,re={pending:!1,data:null,method:null,action:nLine truncated
|
||||
at`)?" (<anonymous>)":-1<l.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||
`+Be+t+oe}var rl=!1;function Fl(t,e){if(!t||rl)return"";rl=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var O=function(){throw Error()};if(Object.defineProperty(O.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(O,[])}catch(Z){var S=Z}Reflect.construct(t,[],O)}else{try{O.call()}catch(Z){S=Z}O=!1;try{var _=Object.getOwnPropertyDescriptor(t.prototype,"props");Object.defineProperty(t.prototype,"props",{configurable:!0,set:function(){throw Error()}}),O=!0,new t}finally{O&&(_!==void 0?Object.defineProperty(t.prototype,"props",_):delete t.prototype.props)}}}else{try{throw Error()}catch(Z){S=Z}(O=t())&&typeof O.catch=="function"&&O.catch(function(){})}}catch(Z){if(Z&&S&&typeof Z.stack=="string")return[Z.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(`
|
||||
`),b=o.split(`
|
||||
`);for(n=a=0;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;for(;n<b.length&&!b[n].includes("DetermineComponentFrameRoot");)n++;if(a===m.length||n===b.length)for(a=m.length-1,n=b.length-1;1<=a&&0<=n&&m[a]!==b[n];)n--;for(;1<=a&&0<=n;a--,n--)if(m[a]!==b[n]){if(a!==1||n!==1)do if(a--,n--,0>n||m[a]!==b[n]){var E=`
|
||||
`+m[a].replace(" at new "," at ");return t.displayName&&E.includes("<anonymous>")&&(E=E.replace("<anonymous>",t.displayName)),E}while(1<=a&&0<=n);break}}}finally{rl=!1,Error.prepareStackTrace=l}return(l=t?t.displayName||t.name:"")?fe(l):""}function ma(t,e){switch(t.tag){case 26:case 27:case 5:return fe(t.type);case 16:return fe("Lazy");case 13:return t.child!==e&&e!==null?fe("Suspense Fallback"):fe("Suspense");case 19:return fe("SuspenseList");case 0:case 15:return Fl(t.type,!1);case 11:return Fl(t.type.render,!1);case 1:return Fl(t.type,!0);case 31:return fe("Activity");case 30:return fe("ViewTransition");default:return""}}function Dl(t){try{var e="",l=null;do e+=ma(t,l),l=t,t=t.return;while(t);return e}catch(a){return`
|
||||
Error generating stack: `+a.message+`
|
||||
`+a.stack}}var ee=Object.prototype.hasOwnProperty,Pe=i.unstable_scheduleCallback,qe=i.unstable_cancelCallback,_l=i.unstable_shouldYield,ol=i.unstable_requestPaint,$t=i.unstable_now,kl=i.unstable_getCurrentPriorityLevel,mn=i.unstable_ImmediatePriority,Va=i.unstable_UserBlockingPriority,be=i.unstable_NormalPriority,Qa=i.unstable_LowPriority,ha=i.unstable_IdlePriority,su=i.log,ru=i.unstable_setDisableYieldValue,Wl=null,Me=null;function fl(t){if(typeof su=="function"&&ru(t),Me&&typeof Me.setStrictMode=="function")try{Me.setStrictMode(Wl,t)}catch{}}var Oe=Math.clz32?Math.clz32:du,ou=Math.log,fu=Math.LN2;function du(t){return t>>>=0,t===0?32:31-(ou(t)/fu|0)|0}var B=256,k=262144,P=4194304;function ct(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 A(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=ct(a):(s&=o,s!==0?n=ct(s):l||(l=o&~t,l!==0&&(n=ct(l))))):(o=a&~u,o!==0?n=ct(o):s!==0?n=ct(s):l||(l=a&~t,l!==0&&(n=ct(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 $(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function st(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-Oe(l),n=1<<a;e|=t[a],l&=~n}return e}function Yt(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 wt(){var t=P;return P<<=1,(P&62914560)===0&&(P=4194304),t}function je(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function Te(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function wc(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,b=t.hiddenUpdates;for(l=s&~l;0<l;){var E=31-Oe(l),O=1<<E;o[E]=0,m[E]=-1;var S=b[E];if(S!==null)for(b[E]=null,E=0;E<S.length;E++){var _=S[E];_!==null&&(_.lane&=-536870913)}l&=~O}a!==0&&si(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(s&~e))}function si(t,e,l){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-Oe(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|l&261930}function hn(t,e){var l=t.entangledLanes|=e;for(t=t.entanglements;l;){var a=31-Oe(l),n=1<<a;n&e|t[a]&e&&(t[a]|=e),l&=~n}}function $l(t,e){var l=e&-e;return l=(l&42)!==0?1:wl(l),(l&(t.suspendedLanes|e))!==0?0:l}function wl(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 mu(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function hu(){var t=mt.p;return t!==0?t:(t=window.event,t===void 0?32:Ah(t.type))}function Ze(t,e){var l=mt.p;try{return mt.p=t,e()}finally{mt.p=l}}var ne=Math.random().toString(36).slice(2),ge="__reactFiber$"+ne,Ke="__reactProps$"+ne,vn="__reactContainer$"+ne,jo="__reactEvents$"+ne,ev="__reactListeners$"+ne,lv="__reactHandles$"+ne,To="__reactResources$"+ne,vu="__reactMarker$"+ne,ri="__reactLoad$"+ne;function oi(t){delete t[ge],delete t[Ke],delete t[ev],delete t[lv]}function Xa(t){var e;if(e=t[ge])return e;for(var l=t.parentNode;l;){if(e=l[vn]||l[ge]){if(l=e.alternate,e.child!==null||l!==null&&l.child!==null)for(t=sh(t);t!==null;){if(l=t[ge])return l;t=sh(t)}return e}t=l,l=t.parentNode}return null}function yn(t){if(t=t[ge]||t[vn]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function yu(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function gn(t){var e=t[To];return e||(e=t[To]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function _e(t){t[vu]=!0}function _o(t){t[ri]=void 0}var Eo=new Set,No={};function La(t,e){pn(t,e),pn(t+"Capture",e)}function pn(t,Line truncated
|
||||
`).replace(Q0,"")}function Vm(t,e){return e=Gm(e),Gm(t)===e}function Zt(t,e,l,a,n,u){switch(l){case"children":if(typeof a=="string")e==="body"||e==="textarea"&&a===""||xn(t,a);else if(typeof a=="number"||typeof a=="bigint")e!=="body"&&xn(t,""+a);else return;break;case"className":di(t,"class",a);break;case"tabIndex":di(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":di(t,l,a);break;case"style":Uo(t,a,u);return;case"data":if(e!=="object"){di(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=mi(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"&&Zt(t,e,"name",n.name,n,null),Zt(t,e,"formEncType",n.formEncType,n,null),Zt(t,e,"formMethod",n.formMethod,n,null),Zt(t,e,"formTarget",n.formTarget,n,null)):(Zt(t,e,"encType",n.encType,n,null),Zt(t,e,"method",n.method,n,null),Zt(t,e,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=mi(a),t.setAttribute(l,a);break;case"onClick":a!=null&&(t.onclick=Ul);return;case"onScroll":a!=null&&Ot("scroll",t);return;case"onScrollEnd":a!=null&&Ot("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=mi(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":Ot("beforetoggle",t),Ot("toggle",t),fi(t,"popover",a);break;case"xlinkActuate":Il(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":Il(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":Il(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":Il(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":Il(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":Il(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":Il(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":Il(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":Il(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":fi(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,fi(t,l,a);else return}Bt=!0}function Hr(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")xn(t,a);else if(typeof a=="number"||typeof a=="bigint")xn(t,""+a);else return;break;case"onScroll":a!=null&&Ot("scroll",t);return;case"onScrollEnd":a!=null&&Ot("scroLine truncated
|
||||
`+a.stack}}var ee=Object.prototype.hasOwnProperty,Pe=i.unstable_scheduleCallback,qe=i.unstable_cancelCallback,_l=i.unstable_shouldYield,ol=i.unstable_requestPaint,$t=i.unstable_now,wl=i.unstable_getCurrentPriorityLevel,mn=i.unstable_ImmediatePriority,Va=i.unstable_UserBlockingPriority,be=i.unstable_NormalPriority,Qa=i.unstable_LowPriority,ha=i.unstable_IdlePriority,su=i.log,ru=i.unstable_setDisableYieldValue,Wl=null,Me=null;function fl(t){if(typeof su=="function"&&ru(t),Me&&typeof Me.setStrictMode=="function")try{Me.setStrictMode(Wl,t)}catch{}}var Oe=Math.clz32?Math.clz32:du,ou=Math.log,fu=Math.LN2;function du(t){return t>>>=0,t===0?32:31-(ou(t)/fu|0)|0}var B=256,w=262144,P=4194304;function ct(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 A(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=ct(a):(s&=o,s!==0?n=ct(s):l||(l=o&~t,l!==0&&(n=ct(l))))):(o=a&~u,o!==0?n=ct(o):s!==0?n=ct(s):l||(l=a&~t,l!==0&&(n=ct(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 $(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function st(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-Oe(l),n=1<<a;e|=t[a],l&=~n}return e}function Yt(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 kt(){var t=P;return P<<=1,(P&62914560)===0&&(P=4194304),t}function je(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function Te(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Uc(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,b=t.hiddenUpdates;for(l=s&~l;0<l;){var E=31-Oe(l),O=1<<E;o[E]=0,m[E]=-1;var S=b[E];if(S!==null)for(b[E]=null,E=0;E<S.length;E++){var _=S[E];_!==null&&(_.lane&=-536870913)}l&=~O}a!==0&&ri(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(s&~e))}function ri(t,e,l){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-Oe(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|l&261930}function hn(t,e){var l=t.entangledLanes|=e;for(t=t.entanglements;l;){var a=31-Oe(l),n=1<<a;n&e|t[a]&e&&(t[a]|=e),l&=~n}}function $l(t,e){var l=e&-e;return l=(l&42)!==0?1:kl(l),(l&(t.suspendedLanes|e))!==0?0:l}function kl(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 mu(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function hu(){var t=mt.p;return t!==0?t:(t=window.event,t===void 0?32:Mh(t.type))}function Ze(t,e){var l=mt.p;try{return mt.p=t,e()}finally{mt.p=l}}var ne=Math.random().toString(36).slice(2),ge="__reactFiber$"+ne,Ke="__reactProps$"+ne,vn="__reactContainer$"+ne,To="__reactEvents$"+ne,av="__reactListeners$"+ne,nv="__reactHandles$"+ne,_o="__reactResources$"+ne,vu="__reactMarker$"+ne,oi="__reactLoad$"+ne;function fi(t){delete t[ge],delete t[Ke],delete t[av],delete t[nv]}function Xa(t){var e;if(e=t[ge])return e;for(var l=t.parentNode;l;){if(e=l[vn]||l[ge]){if(l=e.alternate,e.child!==null||l!==null&&l.child!==null)for(t=rh(t);t!==null;){if(l=t[ge])return l;t=rh(t)}return e}t=l,l=t.parentNode}return null}function yn(t){if(t=t[ge]||t[vn]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function yu(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function gn(t){var e=t[_o];return e||(e=t[_o]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function _e(t){t[vu]=!0}function Eo(t){t[oi]=void 0}var No=new Set,Co={};function La(t,e){pn(t,e),pn(t+"Capture",e)}function pn(t,Line truncated
|
||||
`).replace(L0,"")}function Qm(t,e){return e=Vm(e),Vm(t)===e}function Zt(t,e,l,a,n,u){switch(l){case"children":if(typeof a=="string")e==="body"||e==="textarea"&&a===""||xn(t,a);else if(typeof a=="number"||typeof a=="bigint")e!=="body"&&xn(t,""+a);else return;break;case"className":mi(t,"class",a);break;case"tabIndex":mi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":mi(t,l,a);break;case"style":Ho(t,a,u);return;case"data":if(e!=="object"){mi(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=hi(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"&&Zt(t,e,"name",n.name,n,null),Zt(t,e,"formEncType",n.formEncType,n,null),Zt(t,e,"formMethod",n.formMethod,n,null),Zt(t,e,"formTarget",n.formTarget,n,null)):(Zt(t,e,"encType",n.encType,n,null),Zt(t,e,"method",n.method,n,null),Zt(t,e,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=hi(a),t.setAttribute(l,a);break;case"onClick":a!=null&&(t.onclick=Ul);return;case"onScroll":a!=null&&Ot("scroll",t);return;case"onScrollEnd":a!=null&&Ot("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=hi(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":Ot("beforetoggle",t),Ot("toggle",t),di(t,"popover",a);break;case"xlinkActuate":Il(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":Il(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":Il(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":Il(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":Il(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":Il(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":Il(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":Il(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":Il(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":di(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,di(t,l,a);else return}Bt=!0}function Br(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")xn(t,a);else if(typeof a=="number"||typeof a=="bigint")xn(t,""+a);else return;break;case"onScroll":a!=null&&Ot("scroll",t);return;case"onScrollEnd":a!=null&&Ot("scroLine truncated
|
||||
`;Yt&&(je+=Yt+`
|
||||
`),wt&&(je+=(Array.isArray(wt)?wt.join(`
|
||||
`):String(wt))+`
|
||||
`),je+=JSON.stringify(st,null,2).slice(0,4e3),jl(je),re("已返回登录信息,见下方")}catch(st){re("发起失败:"+st.message)}},[]),k=d.useCallback(async()=>{const A=X.trim();if(!A){F("请填要查配额的用户 id");return}try{const $=await at(W.adminQuotaUser(A));F("用户 "+A.slice(0,8)+":本月用量 "+ho($.used_usd_micros)+" / 配额 "+ho($.quota_usd_micros)+($.remaining_usd_micros!==null&&$.remaining_usd_micros!==void 0?"(剩余 "+ho($.remaining_usd_micros)+")":"")+" | 月份 "+($.month||""))}catch($){F(mo($))}},[X]),P=d.useCallback(async()=>{if(window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。"))try{const $=(await at(W.adminSweep,"POST",{})).swept||[];F("sweep 完成,清理 "+$.length+" 个任务"+($.length?":"+$.join("、"):""))}catch(A){F(mo(A))}},[]),ct=d.useCallback(async()=>{try{const A=await at(W.adminBackup,"POST",{});F("备份完成:backend="+A.backend+" | 路径 "+A.path+" | 大小 "+A.size_bytes+" 字节 | 任务 "+A.task_count+" 行")}catch(A){F(mo(A))}},[]);return{keySaved:i,keyState:h,quotaText:p,healthText:C,adminGate:z,adminBody:q,adminResult:U,adminUserId:X,setAdminUserId:J,projects:Q,projectId:St,setProjectId:jt,projectMsg:K,githubRepos:w,workers:nt,workersSummary:qt,workersError:kt,members:ot,memberMsg:G,memberError:tt,skills:xt,apps:L,mcpServers:y,skillMsg:ut,skillError:Ct,mcpMsg:Tt,mcpOauthMsg:mt,mcpOauthBox:Ga,envs:Xe,envCount:Vt,envMsg:Jl,envRepoLine:da,envRepoBar:zl,envRepoMsg:Tl,envSelect:oe,setEnvSelect:fe,models:rl,modelCustomVisible:ma,refreshHealth:qe,loadQuota:_l,probeAdmin:ol,refreshProjects:$t,loadGithubRepos:kl,loadRelayModels:mn,refreshWorkers:Va,refreshEnvs:be,refreshMembers:Qa,refreshSkills:ha,saveKeyInput:su,clearKeyInput:ru,createProject:Wl,createEnv:Me,archiveEnv:fl,saveEnvRepo:Oe,inviteMember:ou,changeMemberRole:fu,reloadMcp:du,mcpOauth:B,adminQuota:k,adminSweep:P,adminBackup:ct}}const vo=20;function ug(){const[i,r]=d.useState([]),[h,f]=d.useState(""),[p,N]=d.useState(""),[C,j]=d.useState(""),[z,R]=d.useState(""),[q,g]=d.useState(0),[U,F]=d.useState(0),[X,J]=d.useState({}),Q=d.useRef(0),vt=d.useRef({status:"",executor:"",q:""});vt.current={status:p,executor:C,q:z};const St=d.useRef(""),jt=d.useCallback(async(ot,yt,G)=>{const Y=ot!==void 0?ot:yt;if(!Y){r([]),F(0),J({});return}St.current=Y;const tt=G!==void 0?G:Q.current,gt=vt.current;try{const xt=await at(W.tasksPage(Y,vo,tt*vo,gt.status,gt.executor,gt.q));r(xt.tasks||[]),F(xt.total??(xt.tasks||[]).length),J(xt.status_counts||{}),f("")}catch(xt){f("加载失败:"+xt.message)}},[]),K=d.useCallback(ot=>{const yt=Math.max(0,ot);Q.current=yt,g(yt)},[]),Nt=d.useCallback(ot=>{K(ot),jt(St.current||void 0,void 0,ot)},[jt,K]);d.useEffect(()=>{if(!St.current)return;const ot=setTimeout(()=>{jt(St.current,void 0,0)},300);return()=>clearTimeout(ot)},[p,C,z]);const w=d.useCallback(ot=>{N(ot),K(0)},[K]),V=d.useCallback(ot=>{j(ot),K(0)},[K]),nt=d.useCallback(ot=>{R(ot),K(0)},[K]),Dt=d.useCallback(async ot=>{K(0),await jt(ot,void 0,0)},[jt,K]),qt=Math.max(1,Math.ceil(U/vo)),dt=U===0?"共 0 个任务":"共 "+U+" 个任务,第 "+(q+1)+" / "+qt+" 页",kt=i,H=d.useCallback(async(ot,yt)=>{const G={...ot};yt.length&&(G.upload_id=await lg(yt));const Y=await at(W.tasksCreate,"POST",G);return"任务已建:"+Ae(Y.id)},[]);return{tasks:i,taskError:h,statusFilter:p,setStatusFilter:w,executorFilter:C,setExecutorFilter:V,searchText:z,setSearchText:nt,filteredTasks:kt,taskCountText:dt,refreshTasks:jt,refreshTasksFirstPage:Dt,page:q,totalPages:qt,taskTotal:U,taskStatusCounts:X,goToPage:Nt,dispatch:H}}function ig(i,r){const[h,f]=d.useState(null),[p,N]=d.useState(""),[C,j]=d.useState(""),[z,R]=d.useState(""),[q,g]=d.useState(""),[U,F]=d.useState(void 0),[X,J]=d.useState(!1),[Q,vt]=d.useState(""),[St,jt]=d.useState([]),[K,Nt]=d.useState([]),[w,V]=d.useState(""),[nt,Dt]=d.useState([]),[qt,dt]=d.useState(""),[kt,H]=d.useState(""),[ot,yt]=d.useState([]),[G,Y]=d.useState(""),[tt,gt]=d.useState(""),[xt,Se]=d.useState(""),[L,se]=d.useState(""),[y,D]=d.useState([]),[ut,et]=d.useState([]),[Ct,bt]=d.useState(""),[Tt,lt]=d.useState([]),[mt,re]=d.useState(""),[Ga,jl]=d.useState(""),[Xe,ae]=d.useState(""),[Vt,ye]=d.useState(""),[Jl,xe]=d.useState([]),[da,Le]=d.useState(""),[zl,Rl]=d.useState(null),Tl=d.useRef(null),Be=d.useRef(null),oe=d.useRef(null);oe.current=h;const fe=d.useCallback(()=>{if(Tl.current){try{Tl.current.abort()}catch{}Tl.current=null}Be.current&&(clearTimeout(Be.current),Be.current=null)},[]),rl=d.useCallback(async B=>{try{const k=await at(W.taskAgents(B));Nt(k.agents||[]),V(k.note||"")}catch(k){Nt([]),V("子代理链路加载失败:"+k.message)}},[]),Fl=d.useCallback(B=>{Be.current&&clearTimeout(Be.current),Be.current=setTimeout(()=>void rl(B),1e3)},[rl]),ma=d.useCallback(async B=>{if(!B.coordinator_parts){jt([]);return}try{const k=await at(W.tasksByParent(B.project_id||"",B.id));jt(k.tasks||[])}catch{jt([])}},[]),Dl=d.useCallback(async B=>{try{cLine truncated
|
||||
`),kt&&(je+=(Array.isArray(kt)?kt.join(`
|
||||
`):String(kt))+`
|
||||
`),je+=JSON.stringify(st,null,2).slice(0,4e3),jl(je),re("已返回登录信息,见下方")}catch(st){re("发起失败:"+st.message)}},[]),w=d.useCallback(async()=>{const A=X.trim();if(!A){F("请填要查配额的用户 id");return}try{const $=await at(W.adminQuotaUser(A));F("用户 "+A.slice(0,8)+":本月用量 "+vo($.used_usd_micros)+" / 配额 "+vo($.quota_usd_micros)+($.remaining_usd_micros!==null&&$.remaining_usd_micros!==void 0?"(剩余 "+vo($.remaining_usd_micros)+")":"")+" | 月份 "+($.month||""))}catch($){F(ho($))}},[X]),P=d.useCallback(async()=>{if(window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。"))try{const $=(await at(W.adminSweep,"POST",{})).swept||[];F("sweep 完成,清理 "+$.length+" 个任务"+($.length?":"+$.join("、"):""))}catch(A){F(ho(A))}},[]),ct=d.useCallback(async()=>{try{const A=await at(W.adminBackup,"POST",{});F("备份完成:backend="+A.backend+" | 路径 "+A.path+" | 大小 "+A.size_bytes+" 字节 | 任务 "+A.task_count+" 行")}catch(A){F(ho(A))}},[]);return{keySaved:i,keyState:h,quotaText:p,healthText:C,adminGate:z,adminBody:q,adminResult:U,adminUserId:X,setAdminUserId:J,projects:Q,projectId:St,setProjectId:jt,projectMsg:K,githubRepos:k,workers:nt,workersSummary:qt,workersError:wt,members:ot,memberMsg:G,memberError:tt,skills:xt,apps:L,mcpServers:y,skillMsg:ut,skillError:Ct,mcpMsg:Tt,mcpOauthMsg:mt,mcpOauthBox:Ga,envs:Xe,envCount:Vt,envMsg:Jl,envRepoLine:da,envRepoBar:zl,envRepoMsg:Tl,envSelect:oe,setEnvSelect:fe,models:rl,modelCustomVisible:ma,refreshHealth:qe,loadQuota:_l,probeAdmin:ol,refreshProjects:$t,loadGithubRepos:wl,loadRelayModels:mn,refreshWorkers:Va,refreshEnvs:be,refreshMembers:Qa,refreshSkills:ha,saveKeyInput:su,clearKeyInput:ru,createProject:Wl,createEnv:Me,archiveEnv:fl,saveEnvRepo:Oe,inviteMember:ou,changeMemberRole:fu,reloadMcp:du,mcpOauth:B,adminQuota:w,adminSweep:P,adminBackup:ct}}const yo=20;function cg(){const[i,r]=d.useState([]),[h,f]=d.useState(""),[p,N]=d.useState(""),[C,j]=d.useState(""),[z,R]=d.useState(""),[q,g]=d.useState(0),[U,F]=d.useState(0),[X,J]=d.useState({}),Q=d.useRef(0),vt=d.useRef({status:"",executor:"",q:""});vt.current={status:p,executor:C,q:z};const St=d.useRef(""),jt=d.useCallback(async(ot,yt,G)=>{const Y=ot!==void 0?ot:yt;if(!Y){r([]),F(0),J({});return}St.current=Y;const tt=G!==void 0?G:Q.current,gt=vt.current;try{const xt=await at(W.tasksPage(Y,yo,tt*yo,gt.status,gt.executor,gt.q));r(xt.tasks||[]),F(xt.total??(xt.tasks||[]).length),J(xt.status_counts||{}),f("")}catch(xt){f("加载失败:"+xt.message)}},[]),K=d.useCallback(ot=>{const yt=Math.max(0,ot);Q.current=yt,g(yt)},[]),Nt=d.useCallback(ot=>{K(ot),jt(St.current||void 0,void 0,ot)},[jt,K]);d.useEffect(()=>{if(!St.current)return;const ot=setTimeout(()=>{jt(St.current,void 0,0)},300);return()=>clearTimeout(ot)},[p,C,z]);const k=d.useCallback(ot=>{N(ot),K(0)},[K]),V=d.useCallback(ot=>{j(ot),K(0)},[K]),nt=d.useCallback(ot=>{R(ot),K(0)},[K]),Dt=d.useCallback(async ot=>{K(0),await jt(ot,void 0,0)},[jt,K]),qt=Math.max(1,Math.ceil(U/yo)),dt=U===0?"共 0 个任务":"共 "+U+" 个任务,第 "+(q+1)+" / "+qt+" 页",wt=i,H=d.useCallback(async(ot,yt)=>{const G={...ot};yt.length&&(G.upload_id=await ng(yt));const Y=await at(W.tasksCreate,"POST",G);return"任务已建:"+Ae(Y.id)},[]);return{tasks:i,taskError:h,statusFilter:p,setStatusFilter:k,executorFilter:C,setExecutorFilter:V,searchText:z,setSearchText:nt,filteredTasks:wt,taskCountText:dt,refreshTasks:jt,refreshTasksFirstPage:Dt,page:q,totalPages:qt,taskTotal:U,taskStatusCounts:X,goToPage:Nt,dispatch:H}}function sg(i,r){const[h,f]=d.useState(null),[p,N]=d.useState(""),[C,j]=d.useState(""),[z,R]=d.useState(""),[q,g]=d.useState(""),[U,F]=d.useState(void 0),[X,J]=d.useState(!1),[Q,vt]=d.useState(""),[St,jt]=d.useState([]),[K,Nt]=d.useState([]),[k,V]=d.useState(""),[nt,Dt]=d.useState([]),[qt,dt]=d.useState(""),[wt,H]=d.useState(""),[ot,yt]=d.useState([]),[G,Y]=d.useState(""),[tt,gt]=d.useState(""),[xt,Se]=d.useState(""),[L,se]=d.useState(""),[y,D]=d.useState([]),[ut,et]=d.useState([]),[Ct,bt]=d.useState(""),[Tt,lt]=d.useState([]),[mt,re]=d.useState(""),[Ga,jl]=d.useState(""),[Xe,ae]=d.useState(""),[Vt,ye]=d.useState(""),[Jl,xe]=d.useState([]),[da,Le]=d.useState(""),[zl,Rl]=d.useState(null),Tl=d.useRef(null),Be=d.useRef(null),oe=d.useRef(null);oe.current=h;const fe=d.useCallback(()=>{if(Tl.current){try{Tl.current.abort()}catch{}Tl.current=null}Be.current&&(clearTimeout(Be.current),Be.current=null)},[]),rl=d.useCallback(async B=>{try{const w=await at(W.taskAgents(B));Nt(w.agents||[]),V(w.note||"")}catch(w){Nt([]),V("子代理链路加载失败:"+w.message)}},[]),Fl=d.useCallback(B=>{Be.current&&clearTimeout(Be.current),Be.current=setTimeout(()=>void rl(B),1e3)},[rl]),ma=d.useCallback(async B=>{if(!B.coordinator_parts){jt([]);return}try{const w=await at(W.tasksByParent(B.project_id||"",B.id));jt(w.tasks||[])}catch{jt([])}},[]),Dl=d.useCallback(async B=>{try{cLine truncated
|
||||
|
||||
`);hn=wl.pop()||"";for(const mu of wl){const hu=mu.split(`
|
||||
`);hn=kl.pop()||"";for(const mu of kl){const hu=mu.split(`
|
||||
`).filter(Ze=>Ze.indexOf("data: ")===0).map(Ze=>Ze.slice(6)).join(`
|
||||
`);if(hu)try{const Ze=JSON.parse(hu);if(Ze.type==="snapshot")ct=Ze.entries||[],Object.keys(A).forEach(ne=>delete A[ne]),ct.forEach((ne,ge)=>{ne.id&&(A[ne.id]=ge)}),$=Ze.note||"",yt([...ct]),Y($),Fl(B);else if(Ze.type==="entry"){const ne=Ze.entry;ne.id&&ne.id in A?ct[A[ne.id]]=ne:(ne.id&&(A[ne.id]=ct.length),ct.push(ne)),yt([...ct]),Fl(B)}else Ze.type==="done"&&(st=!0,Yt=Ze.status,gt(Yt))}catch{}}if(st)break}}catch(wt){if(wt&&wt.name==="AbortError")return;Y("链路流中断:"+wt.message);return}st&&r()},[fe,Fl,r]),be=d.useCallback(async B=>{fe();try{const k=await at(W.task(B));f(k);let P="";k.result_text&&(P+=`=== result ===
|
||||
`+k.result_text.slice(0,8e3)+`
|
||||
`),k.error_text&&(P+=`=== error ===
|
||||
`+k.error_text.slice(0,8e3)+`
|
||||
`),P||(P="(暂无 result/error 输出)"),N(P),g(""),F(void 0),Dl(B),J(!1),vt(""),re(""),ye(""),mn(k.status);const ct=i(k.project_id),A=ct==="maintainer"||ct==="owner",$=ct==="contributor"||A,st=uu.includes(k.status),Yt=[];["failed","interrupted","merge-failed"].includes(k.status)&&A||Yt.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(A?"":",且仅 maintainer 可重试")),!st&&$||Yt.push("取消:不可用——"+(st?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),k.status==="completed"&&A||Yt.push("合并:不可用——仅 completed 可合并"+(A?"":",且仅 maintainer 可合并")),k.status==="merged"&&A||Yt.push("提 PR:不可用——仅 merged 可提 PR"+(A?"":",且仅 maintainer 可提 PR")),R(Yt.join(";")),j(""),Va(B,k.status),rl(B),ma(k),Pe(B),ee(B),ol(B),$t(B),kl(B),qe(B),_l(B)}catch(k){f(null),N("加载失败:"+k.message)}},[fe,Va,rl,ma,Pe,ee,ol,$t,kl,qe,_l,Dl,i]),Qa=d.useCallback(async()=>{const B=oe.current;if(B){try{const k=await at(W.task(B.id));f(k);let P="";k.result_text&&(P+=`=== result ===
|
||||
`+k.result_text.slice(0,8e3)+`
|
||||
`),k.error_text&&(P+=`=== error ===
|
||||
`+k.error_text.slice(0,8e3)+`
|
||||
`),N(P||"(暂无 result/error 输出)")}catch{}ee(B.id),Pe(B.id),qe(B.id),_l(B.id),ma(B),rl(B.id),ol(B.id),$t(B.id),kl(B.id),Dl(B.id)}},[ee,Pe,qe,_l,ma,rl,ol,$t,kl,Dl]),ha=d.useCallback(async B=>{const k=oe.current;if(!k)return;const P={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!(P[B]&&!window.confirm(P[B])))try{const ct=await at(W.taskAction(k.id,B),"POST",{});j(B+" 已提交:"+JSON.stringify(ct).slice(0,300)),r(),await be(k.id)}catch(ct){j(B+" 失败:"+ct.message)}},[be,r]),su=d.useCallback(async()=>{const B=oe.current;if(B)try{const k=await at(W.taskCost(B.id));g(JSON.stringify(k,null,2).slice(0,4e3))}catch(k){g("查成本失败:"+k.message)}},[]),ru=d.useCallback(async B=>{const k=oe.current;if(k){if(!B.trim()){vt("分支消息必填");return}if(!uu.includes(k.status)){vt("仅终态任务可分支");return}try{const P=await at(W.taskFork(k.id),"POST",{message:B.trim()});vt("分支任务已建:"+P.id.slice(0,8)),r()}catch(P){vt("分支失败:"+P.message)}}},[r]),Wl=d.useCallback(async B=>{const k=oe.current;if(k)try{await at(W.taskReviews(k.id),"POST",B),dt("评审已登记(requested)"),await qe(k.id)}catch(P){dt("登记评审需求失败:"+P.message)}},[qe]),Me=d.useCallback(B=>{const k=oe.current;if(!k)return;const P=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");P!==null&&at(W.taskReviewFindings(k.id,B),"POST",{findings:P}).then(()=>void qe(k.id)).catch(ct=>dt("写 findings 失败:"+ct.message))},[qe]),fl=d.useCallback((B,k)=>{const P=oe.current;if(!P)return;const ct=k==="approved"?"approve":"deny";at(W.taskApprovalDecision(P.id,B,ct),"POST",{}).then(()=>{bt("已记录"+(k==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),_l(P.id)}).catch(A=>bt("决定失败:"+A.message))},[_l]),Oe=d.useCallback(async(B,k,P)=>{const ct=oe.current;if(ct){if(!k.trim()){re("目标任务 id 不能为空");return}if(!P.trim()){re("消息正文不能为空");return}try{const A=await at(W.taskMessagesTo(k.trim()),"POST",{from_task_id:ct.id,body:P.trim(),mode:B});re("已发送("+A.delivery+")"),await ol(ct.id)}catch(A){re("发送失败:"+A.message)}}},[ol]),ou=d.useCallback((B,k)=>{const P=oe.current;if(!P)return;if(!["completed","failed","interrupted"].includes(P.status)){ye("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const ct={};if(B==="rename"){if(!k.trim()){ye("新线程名不能为空");return}ct.name=k.trim()}at(W.taskThreadAction(P.id,B),"POST",ct).then(A=>{ye(B+" 已提交:"+JSON.stringify(A).slice(0,300)),$t(P.id),kl(P.id)}).catch(A=>ye(B+" 失败:"+A.message))},[$t,kl]),fu=d.useCallback(async B=>{const k=oe.current;if(!k)return;const P=B.trim();if(!P){Le("消息不能为空");return}try{uu.includes(k.status)&&k.status!=="merged"?(await at(W.taskContinue(k.id),"POST",{message:P}),Le("已重新排队,同一会话继续"),r(),await be(k.id)):["queued","running"].includes(k.status)?(await at(W.taskFollowups(k.id),"POST",{body:P,mode:"direct"}),Le("已直接发送"),await ee(k.id),await Pe(k.id)):Le("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(ct){Le("发送失败:"+ct.message)}},[be,r,ee,Pe]),du=d.useCallback(async()=>{const B=oe.current;if(!B){se("先选一个任务");return}try{const k=await at(W.taskTrace(B.id)),P=new Blob([JSON.stringify({task_id:B.id,status:k.status,note:k.note||"",count:(k.entries||[]).length,entries:k.entries||[]},null,2)],{type:"application/json"}),ct=document.createElement("a");ct.href=URL.createObjectURL(P),ct.download="trace-"+B.id.slice(0,8)+".json",document.body.appendChild(ct),ct.click(),ct.remove(),setTimeout(()=>URL.revokeObjectURL(ct.href),1e3),se("已下载完整链路 "+(k.entries||[]).length+" 条")}catch(k){se("下载失败:"+k.message)}},[]);return d.useEffect(()=>()=>fe(),[fe]),{currentTask:h,detailBody:p,actionMsg:C,actionReasons:z,costBox:q,costSummary:U,forkVisible:X,setForkVisible:J,forkMsg:Q,coordChildren:St,agents:K,agentsNote:w,reviews:nt,reviewMsg:qt,reviewError:kt,traceEntries:ot,traceNote:G,traceDone:tt,traceKind:xt,setTraceKind:Se,traceMsg:L,followups:y,approvals:ut,approvalMsg:Ct,peerMessages:Tt,peerMsg:mt,threadText:Ga,threadGateNote:Xe,threadMsg:Vt,threadList:Jl,chatMsg:da,chatHistory:zl,showDetail:be,refreshDetailLists:Qa,doAction:ha,taskCost:su,forkTask:ru,postReview:Wl,submitFindings:Me,decideApproval:fl,postPeer:Oe,threadAction:ou,sendChatMessage:fu,downloadTrace:du,refreshReviews:()=>void qe(),refreshApprovals:()=>void _l()}}function cg(i){const[r,h]=d.useState([]),[f,p]=d.useState(""),[N,C]=d.useState(null),[j,z]=d.useState(""),R=d.useCallback(async X=>{const J=X!==void 0?X:i();if(!J){h([]);return}try{const Q=await at(W.projectAutomations(J));h(Q.automations||[])}catch(Q){p("自动化加载失败:"+Q.message)}},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:go(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:go(i)}}function jg(){const i=Kt(),r=i.currentTask,h=d.useMemo(()=>{if(!r)return[];let z;if(i.chatHistory){z=i.chatHistory.messages.map(X=>({id:X.id,role:X.role,text:X.text,label:X.label,kind:X.kind}));const U=new Set(z.map(X=>X.id)),F=i.traceEntries.filter(X=>X.id&&!U.has(X.id)&&!String(X.id).startsWith("fu-")).map((X,J)=>Zh(X,J));z=[...z,...F]}else{const U=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],F=i.traceEntries.filter(J=>!String(J.id||"").startsWith("fu-")).map((J,Q)=>Zh(J,Q)),X=i.followups.map((J,Q)=>({id:"followup-"+Q,role:"user",text:String(J.body||""),label:"后续消息"}));z=[...U,...F,...X]}const R=i.traceKind,q=R?z.filter(U=>U.role==="user"||!U.kind||U.kind===R):z,g=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...q,...g]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const f=uu.includes(r.status),p=["queued","running"].includes(r.status),N=f||p,C=f?"终态任务:发送走继续接口(同一会话再跑一轮)":p?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",j=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(Ht,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:z=>i.setTraceKind(z.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(z=>c.jsx("option",{value:z,children:z==="exec"?"命令":z==="file"?"文件":z==="call"?"调用":z==="tool"?"工具":z==="message"?"消息":z==="reasoning"?"思考":z==="plan"?"计划":"子代理"},z))]}),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,j!==null&&j>0?c.jsxs("div",{className:"muted",children:["此前有 ",j," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):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(bg,{disabled:!N,placeholder:"消息",onSend:z=>void i.sendChatMessage(z)}),c.jsxs("div",{className:"muted",children:[C,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function Tg(){return Kt().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(jg,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function _g(){const i=Kt(),r=i.currentTask,[h,f]=d.useState("");if(!r)return null;const p=uu.includes(r.status),N=["failed","interrupted","merge-failed"].includes(r.status);return c.jsxs(c.Fragment,{children:[c.jsxs(Ht,{children:[c.jsx("button",{disabled:!N,title:N?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:p,title:p?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),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 可提 PR":"",onClick:()=>i.doAction("pr"),children:"提 PR(须 mergedLine truncated
|
||||
`);if(hu)try{const Ze=JSON.parse(hu);if(Ze.type==="snapshot")ct=Ze.entries||[],Object.keys(A).forEach(ne=>delete A[ne]),ct.forEach((ne,ge)=>{ne.id&&(A[ne.id]=ge)}),$=Ze.note||"",yt([...ct]),Y($),Fl(B);else if(Ze.type==="entry"){const ne=Ze.entry;ne.id&&ne.id in A?ct[A[ne.id]]=ne:(ne.id&&(A[ne.id]=ct.length),ct.push(ne)),yt([...ct]),Fl(B)}else Ze.type==="done"&&(st=!0,Yt=Ze.status,gt(Yt))}catch{}}if(st)break}}catch(kt){if(kt&&kt.name==="AbortError")return;Y("链路流中断:"+kt.message);return}st&&r()},[fe,Fl,r]),be=d.useCallback(async B=>{fe();try{const w=await at(W.task(B));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 输出)"),N(P),g(""),F(void 0),Dl(B),J(!1),vt(""),re(""),ye(""),mn(w.status);const ct=i(w.project_id),A=ct==="maintainer"||ct==="owner",$=ct==="contributor"||A,st=uu.includes(w.status),Yt=[];["failed","interrupted","merge-failed"].includes(w.status)&&A||Yt.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(A?"":",且仅 maintainer 可重试")),!st&&$||Yt.push("取消:不可用——"+(st?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),w.status==="completed"&&A||Yt.push("合并:不可用——仅 completed 可合并"+(A?"":",且仅 maintainer 可合并")),w.status==="merged"&&A||Yt.push("提 PR:不可用——仅 merged 可提 PR"+(A?"":",且仅 maintainer 可提 PR")),R(Yt.join(";")),j(""),Va(B,w.status),rl(B),ma(w),Pe(B),ee(B),ol(B),$t(B),wl(B),qe(B),_l(B)}catch(w){f(null),N("加载失败:"+w.message)}},[fe,Va,rl,ma,Pe,ee,ol,$t,wl,qe,_l,Dl,i]),Qa=d.useCallback(async()=>{const B=oe.current;if(B){try{const w=await at(W.task(B.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)+`
|
||||
`),N(P||"(暂无 result/error 输出)")}catch{}ee(B.id),Pe(B.id),qe(B.id),_l(B.id),ma(B),rl(B.id),ol(B.id),$t(B.id),wl(B.id),Dl(B.id)}},[ee,Pe,qe,_l,ma,rl,ol,$t,wl,Dl]),ha=d.useCallback(async B=>{const w=oe.current;if(!w)return;const P={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!(P[B]&&!window.confirm(P[B])))try{const ct=await at(W.taskAction(w.id,B),"POST",{});j(B+" 已提交:"+JSON.stringify(ct).slice(0,300)),r(),await be(w.id)}catch(ct){j(B+" 失败:"+ct.message)}},[be,r]),su=d.useCallback(async()=>{const B=oe.current;if(B)try{const w=await at(W.taskCost(B.id));g(JSON.stringify(w,null,2).slice(0,4e3))}catch(w){g("查成本失败:"+w.message)}},[]),ru=d.useCallback(async B=>{const w=oe.current;if(w){if(!B.trim()){vt("分支消息必填");return}if(!uu.includes(w.status)){vt("仅终态任务可分支");return}try{const P=await at(W.taskFork(w.id),"POST",{message:B.trim()});vt("分支任务已建:"+P.id.slice(0,8)),r()}catch(P){vt("分支失败:"+P.message)}}},[r]),Wl=d.useCallback(async B=>{const w=oe.current;if(w)try{await at(W.taskReviews(w.id),"POST",B),dt("评审已登记(requested)"),await qe(w.id)}catch(P){dt("登记评审需求失败:"+P.message)}},[qe]),Me=d.useCallback(B=>{const w=oe.current;if(!w)return;const P=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");P!==null&&at(W.taskReviewFindings(w.id,B),"POST",{findings:P}).then(()=>void qe(w.id)).catch(ct=>dt("写 findings 失败:"+ct.message))},[qe]),fl=d.useCallback((B,w)=>{const P=oe.current;if(!P)return;const ct=w==="approved"?"approve":"deny";at(W.taskApprovalDecision(P.id,B,ct),"POST",{}).then(()=>{bt("已记录"+(w==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),_l(P.id)}).catch(A=>bt("决定失败:"+A.message))},[_l]),Oe=d.useCallback(async(B,w,P)=>{const ct=oe.current;if(ct){if(!w.trim()){re("目标任务 id 不能为空");return}if(!P.trim()){re("消息正文不能为空");return}try{const A=await at(W.taskMessagesTo(w.trim()),"POST",{from_task_id:ct.id,body:P.trim(),mode:B});re("已发送("+A.delivery+")"),await ol(ct.id)}catch(A){re("发送失败:"+A.message)}}},[ol]),ou=d.useCallback((B,w)=>{const P=oe.current;if(!P)return;if(!["completed","failed","interrupted"].includes(P.status)){ye("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const ct={};if(B==="rename"){if(!w.trim()){ye("新线程名不能为空");return}ct.name=w.trim()}at(W.taskThreadAction(P.id,B),"POST",ct).then(A=>{ye(B+" 已提交:"+JSON.stringify(A).slice(0,300)),$t(P.id),wl(P.id)}).catch(A=>ye(B+" 失败:"+A.message))},[$t,wl]),fu=d.useCallback(async B=>{const w=oe.current;if(!w)return;const P=B.trim();if(!P){Le("消息不能为空");return}try{uu.includes(w.status)&&w.status!=="merged"?(await at(W.taskContinue(w.id),"POST",{message:P}),Le("已重新排队,同一会话继续"),r(),await be(w.id)):["queued","running"].includes(w.status)?(await at(W.taskFollowups(w.id),"POST",{body:P,mode:"direct"}),Le("已直接发送"),await ee(w.id),await Pe(w.id)):Le("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(ct){Le("发送失败:"+ct.message)}},[be,r,ee,Pe]),du=d.useCallback(async()=>{const B=oe.current;if(!B){se("先选一个任务");return}try{const w=await at(W.taskTrace(B.id)),P=new Blob([JSON.stringify({task_id:B.id,status:w.status,note:w.note||"",count:(w.entries||[]).length,entries:w.entries||[]},null,2)],{type:"application/json"}),ct=document.createElement("a");ct.href=URL.createObjectURL(P),ct.download="trace-"+B.id.slice(0,8)+".json",document.body.appendChild(ct),ct.click(),ct.remove(),setTimeout(()=>URL.revokeObjectURL(ct.href),1e3),se("已下载完整链路 "+(w.entries||[]).length+" 条")}catch(w){se("下载失败:"+w.message)}},[]);return d.useEffect(()=>()=>fe(),[fe]),{currentTask:h,detailBody:p,actionMsg:C,actionReasons:z,costBox:q,costSummary:U,forkVisible:X,setForkVisible:J,forkMsg:Q,coordChildren:St,agents:K,agentsNote:k,reviews:nt,reviewMsg:qt,reviewError:wt,traceEntries:ot,traceNote:G,traceDone:tt,traceKind:xt,setTraceKind:Se,traceMsg:L,followups:y,approvals:ut,approvalMsg:Ct,peerMessages:Tt,peerMsg:mt,threadText:Ga,threadGateNote:Xe,threadMsg:Vt,threadList:Jl,chatMsg:da,chatHistory:zl,showDetail:be,refreshDetailLists:Qa,doAction:ha,taskCost:su,forkTask:ru,postReview:Wl,submitFindings:Me,decideApproval:fl,postPeer:Oe,threadAction:ou,sendChatMessage:fu,downloadTrace:du,refreshReviews:()=>void qe(),refreshApprovals:()=>void _l()}}function rg(i){const[r,h]=d.useState([]),[f,p]=d.useState(""),[N,C]=d.useState(null),[j,z]=d.useState(""),R=d.useCallback(async X=>{const J=X!==void 0?X:i();if(!J){h([]);return}try{const Q=await at(W.projectAutomations(J));h(Q.automations||[])}catch(Q){p("自动化加载失败:"+Q.message)}},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:po(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:po(i)}}function Ng(){const i=Kt(),r=i.currentTask,h=d.useMemo(()=>{if(!r)return[];let z;if(i.chatHistory){z=i.chatHistory.messages.map(X=>({id:X.id,role:X.role,text:X.text,label:X.label,kind:X.kind}));const U=new Set(z.map(X=>X.id)),F=i.traceEntries.filter(X=>X.id&&!U.has(X.id)&&!String(X.id).startsWith("fu-")).map((X,J)=>Jh(X,J));z=[...z,...F]}else{const U=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],F=i.traceEntries.filter(J=>!String(J.id||"").startsWith("fu-")).map((J,Q)=>Jh(J,Q)),X=i.followups.map((J,Q)=>({id:"followup-"+Q,role:"user",text:String(J.body||""),label:"后续消息"}));z=[...U,...F,...X]}const R=i.traceKind,q=R?z.filter(U=>U.role==="user"||!U.kind||U.kind===R):z,g=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...q,...g]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const f=uu.includes(r.status),p=["queued","running"].includes(r.status),N=f||p,C=f?"终态任务:发送走继续接口(同一会话再跑一轮)":p?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",j=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(Ht,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:z=>i.setTraceKind(z.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(z=>c.jsx("option",{value:z,children:z==="exec"?"命令":z==="file"?"文件":z==="call"?"调用":z==="tool"?"工具":z==="message"?"消息":z==="reasoning"?"思考":z==="plan"?"计划":"子代理"},z))]}),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,j!==null&&j>0?c.jsxs("div",{className:"muted",children:["此前有 ",j," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):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:!N,placeholder:"消息",onSend:z=>void i.sendChatMessage(z)}),c.jsxs("div",{className:"muted",children:[C,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function Cg(){return Kt().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=Kt(),r=i.currentTask,[h,f]=d.useState("");if(!r)return null;const p=uu.includes(r.status),N=["failed","interrupted","merge-failed"].includes(r.status);return c.jsxs(c.Fragment,{children:[c.jsxs(Ht,{children:[c.jsx("button",{disabled:!N,title:N?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:p,title:p?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),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 可提 PR":"",onClick:()=>i.doAction("pr"),children:"提 PR(须 mergedLine truncated
|
||||
|
||||
`);for(;ot>=0;){const yt=dt.slice(0,ot);dt=dt.slice(ot+2);for(const G of yt.split(`
|
||||
`))if(G.startsWith("data: "))try{const Y=JSON.parse(G.slice(6));Y.waiting?j(Y.task_status==="interrupted"?"任务已中断,等待续跑…":Y.task_status==="paused"?"任务已暂停…":"等待会话…"):Y.image&&(f("data:image/jpeg;base64,"+Y.image),j("直播中"));const tt={};Y.holder&&(tt.holder=Y.holder),Y.url&&(tt.url=Y.url),Y.title!==void 0&&(tt.title=Y.title),Object.keys(tt).length>0&&N(gt=>({...gt||{},...tt}))}catch{}ot=dt.indexOf(`
|
||||
|
||||
`)}}j("流已断开")}catch(nt){if(nt.name==="AbortError")return;R(nt instanceof Error?nt.message:String(nt)),j("未开始")}finally{g(!1)}},[r]),Q=d.useCallback(()=>{var w;(w=U.current)==null||w.abort(),g(!1),j("已停止")},[]),vt=d.useCallback(async w=>{if(r)try{const V=await at(W.taskBrowserControl(r.id),"POST",{holder:w});N(V),R("")}catch(V){R(V instanceof Error?V.message:String(V))}},[r]),St=d.useCallback(async w=>{if(!r||(p==null?void 0:p.holder)!=="user"||!F.current)return;const V=F.current.getBoundingClientRect(),nt=Math.round((w.clientX-V.left)/V.width*1280),Dt=Math.round((w.clientY-V.top)/V.height*800);try{await at(W.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:nt,y:Dt})}catch(qt){R(qt instanceof Error?qt.message:String(qt))}},[r,p]),jt=d.useCallback(async w=>{if(!(!r||(p==null?void 0:p.holder)!=="user")){w.preventDefault();try{w.key.length===1?await at(W.taskBrowserInput(r.id),"POST",{type:"type",text:w.key}):await at(W.taskBrowserInput(r.id),"POST",{type:"key",key:w.key})}catch(V){R(V instanceof Error?V.message:String(V))}}},[r,p]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const K=(p==null?void 0:p.holder)||"—",Nt=p!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:C})]}),c.jsxs("span",{className:"muted",children:["会话 ",Nt?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",K]}),p!=null&&p.url?c.jsx("span",{className:"muted",title:p.url,children:p.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[q?c.jsx("button",{type:"button",onClick:Q,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void J(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void X(),children:"刷新状态"}),K!=="user"?c.jsx("button",{type:"button",onClick:()=>void vt("user"),disabled:!Nt,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void vt("agent"),children:"交还给 Agent"})]}),z?c.jsx("div",{className:"muted",style:{marginTop:8},children:z}):null,K==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:F,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:w=>void St(w),onKeyDown:w=>void jt(w),style:{width:"100%",maxWidth:1280,cursor:K==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:Nt?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function Ng(){const i=Kt(),[r,h]=d.useState("uncommittedChanges"),[f,p]=d.useState(""),[N,C]=d.useState(""),[j,z]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:R=>h(R.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:R=>p(R.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:N,onChange:R=>C(R.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:j,onChange:R=>z(R.target.value)}),c.jsxs(Ht,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:N.trim()||void 0,instructions:j.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(cu,{children:i.reviews.map(R=>{let q=R.target_type||"";return R.branch&&(q+=":"+R.branch),R.sha&&(q+=":"+String(R.sha).slice(0,12)),R.instructions&&(q+=":"+String(R.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",Ae(R.id)," | ",q," | ",c.jsx("span",{className:"st-"+(R.status||""),children:RLine truncated
|
||||
`)}}j("流已断开")}catch(nt){if(nt.name==="AbortError")return;R(nt instanceof Error?nt.message:String(nt)),j("未开始")}finally{g(!1)}},[r]),Q=d.useCallback(()=>{var k;(k=U.current)==null||k.abort(),g(!1),j("已停止")},[]),vt=d.useCallback(async k=>{if(r)try{const V=await at(W.taskBrowserControl(r.id),"POST",{holder:k});N(V),R("")}catch(V){R(V instanceof Error?V.message:String(V))}},[r]),St=d.useCallback(async k=>{if(!r||(p==null?void 0:p.holder)!=="user"||!F.current)return;const V=F.current.getBoundingClientRect(),nt=Math.round((k.clientX-V.left)/V.width*1280),Dt=Math.round((k.clientY-V.top)/V.height*800);try{await at(W.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:nt,y:Dt})}catch(qt){R(qt instanceof Error?qt.message:String(qt))}},[r,p]),jt=d.useCallback(async k=>{if(!(!r||(p==null?void 0:p.holder)!=="user")){k.preventDefault();try{k.key.length===1?await at(W.taskBrowserInput(r.id),"POST",{type:"type",text:k.key}):await at(W.taskBrowserInput(r.id),"POST",{type:"key",key:k.key})}catch(V){R(V instanceof Error?V.message:String(V))}}},[r,p]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const K=(p==null?void 0:p.holder)||"—",Nt=p!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:C})]}),c.jsxs("span",{className:"muted",children:["会话 ",Nt?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",K]}),p!=null&&p.url?c.jsx("span",{className:"muted",title:p.url,children:p.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[q?c.jsx("button",{type:"button",onClick:Q,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void J(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void X(),children:"刷新状态"}),K!=="user"?c.jsx("button",{type:"button",onClick:()=>void vt("user"),disabled:!Nt,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void vt("agent"),children:"交还给 Agent"})]}),z?c.jsx("div",{className:"muted",style:{marginTop:8},children:z}):null,K==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:F,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:k=>void St(k),onKeyDown:k=>void jt(k),style:{width:"100%",maxWidth:1280,cursor:K==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:Nt?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function Og(){const i=Kt(),[r,h]=d.useState("uncommittedChanges"),[f,p]=d.useState(""),[N,C]=d.useState(""),[j,z]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:R=>h(R.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:R=>p(R.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:N,onChange:R=>C(R.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:j,onChange:R=>z(R.target.value)}),c.jsxs(Ht,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:N.trim()||void 0,instructions:j.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(cu,{children:i.reviews.map(R=>{let q=R.target_type||"";return R.branch&&(q+=":"+R.branch),R.sha&&(q+=":"+String(R.sha).slice(0,12)),R.instructions&&(q+=":"+String(R.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",Ae(R.id)," | ",q," | ",c.jsx("span",{className:"st-"+(R.status||""),children:RLine 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>
|
||||
|
||||
+9
-5
@@ -2208,13 +2208,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
|
||||
+31
-9
@@ -1766,6 +1766,30 @@ def _row_get(row, name):
|
||||
return None
|
||||
|
||||
|
||||
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:
|
||||
@@ -1826,16 +1850,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 {}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
worker.close() # 不抛即通过
|
||||
# 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 预置 ---------------------------------------------
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+20
-26
@@ -551,17 +551,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
|
||||
@@ -800,25 +797,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:
|
||||
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 []
|
||||
仅 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:
|
||||
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",
|
||||
|
||||
Reference in new issue
Block a user