feat-chv-to10:往 10 分方向提升(类型/lint 清零、worker 适配抽取、审计落盘、lockfile 生成脚本、前端四态与统一文案、就绪清单 §13)

This commit is contained in:
host committed 2026-10-31 10:20:42 +00:00
1 parent df8c64bd64
commit 2f3ff8b3ec
22 files changed
+1400 -282

No files matched your search

-1
View File
@@ -48,7 +48,6 @@ cloud.quota,测试钉住这些模块在本基线里不存在):
import os
# 合并后替换清单:每项的 local/replacement/test 字段由测试
# 逐项核对(local 函数真实存在、#215 模块当前不存在、
# test 命名的测试类真实存在),清单本身不得漂移。
+39 -12
View File
@@ -27,6 +27,33 @@ import os
import re
def _to_int(value: object) -> int:
"""动态配置值转 int(env/参数均未定型):bool 拒绝,
int/float 按 int(),str 按 strip 后 int(),其它 ValueError。
旧行为是 int(value) 抛 TypeError/ValueError 后由调用方
转 ValueError;这里直接统一成 ValueError,文案由调用方定。"""
if isinstance(value, bool):
raise ValueError("布尔值不是整数")
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
return int(value.strip())
raise ValueError("不是整数:%r" % (value,))
def _to_float(value: object) -> float:
"""动态配置值转 float,与 _to_int 同款口径。"""
if isinstance(value, bool):
raise ValueError("布尔值不是数字")
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
return float(value.strip())
raise ValueError("不是数字:%r" % (value,))
def _parse_memory_bytes(value: str) -> int:
"""内存字符串转字节:2G/512M/1024K/2048(无单位=字节)。
非法值 ValueError,绝不静默按默认算。"""
@@ -93,7 +120,7 @@ def _ssh_fragment(params: dict, env: dict) -> dict:
else:
raw_port = env.get("CHV_SSH_PORT") or 2222
try:
ssh_port = int(raw_port)
ssh_port = _to_int(raw_port)
except (TypeError, ValueError):
raise ValueError("CHV_SSH_PORT 必须是整数")
if not 1 <= ssh_port <= 65535:
@@ -134,11 +161,11 @@ def _quota_fragment(env: dict) -> dict:
磁盘/快照数上限不在这里,它们是磁盘行为,见
_disk_fragment。校验文案与旧代码逐字一致。"""
try:
max_vms = int(env.get("CHV_MAX_VMS") or 4)
max_vms = _to_int(env.get("CHV_MAX_VMS") or 4)
except ValueError:
raise ValueError("CHV_MAX_VMS 必须是整数")
try:
warm_pool_size = int(env.get("CHV_WARM_POOL_SIZE") or 0)
warm_pool_size = _to_int(env.get("CHV_WARM_POOL_SIZE") or 0)
except ValueError:
raise ValueError("CHV_WARM_POOL_SIZE 必须是整数")
return {"max_vms": max_vms, "warm_pool_size": warm_pool_size}
@@ -154,18 +181,18 @@ def _disk_fragment(env: dict) -> dict:
raise ValueError(
"CHV_DISK_MODE 只能是 copy 或 reuse:%s" % disk_mode)
try:
disk_gb = float(env.get("CHV_DISK_GB")) \
disk_gb = _to_float(env.get("CHV_DISK_GB")) \
if env.get("CHV_DISK_GB") else 50.0
except ValueError:
raise ValueError("CHV_DISK_GB 必须是数字")
if disk_gb <= 0:
raise ValueError("CHV_DISK_GB 必须大于 0")
try:
snapshot_max_count = int(env.get("CHV_SNAPSHOT_MAX_COUNT")
or 10)
snapshot_max_total_gb = float(
snapshot_max_count = _to_int(env.get("CHV_SNAPSHOT_MAX_COUNT")
or 10)
snapshot_max_total_gb = _to_float(
env.get("CHV_SNAPSHOT_MAX_TOTAL_GB") or 20.0)
snapshot_retention_days = float(
snapshot_retention_days = _to_float(
env.get("CHV_SNAPSHOT_RETENTION_DAYS") or 30.0)
except ValueError:
raise ValueError("CHV_SNAPSHOT_* 必须是数字")
@@ -192,18 +219,18 @@ def _timeout_fragment(params: dict, env: dict) -> dict:
超时、客机就绪超时/间隔。ready_interval <= 0 时回落
5 秒(旧语义,非校验失败)。校验文案逐字一致。"""
task_timeout_raw = env.get("CHV_TASK_TIMEOUT")
task_timeout = float(task_timeout_raw) \
task_timeout = _to_float(task_timeout_raw) \
if task_timeout_raw else None
try:
api_timeout = float(
api_timeout = _to_float(
params.get("api_timeout")
if params.get("api_timeout") is not None
else env.get("CHV_API_TIMEOUT") or 30)
ready_timeout = float(
ready_timeout = _to_float(
params.get("ready_timeout")
if params.get("ready_timeout") is not None
else env.get("CHV_READY_TIMEOUT") or 120)
ready_interval = float(
ready_interval = _to_float(
params.get("ready_interval")
if params.get("ready_interval") is not None
else env.get("CHV_READY_INTERVAL") or 5)
+1 -1
View File
@@ -203,7 +203,7 @@ def check_snapshot_limits(state_dir: str, vm_name: str, max_count: int,
raise ChvFailure(
"snapshot_quota_exceeded",
"快照数量已达上限 %d(VM %s)" % (max_count, vm_name))
total = 0
total: float = 0.0
for entry in kept:
size = entry.get("size")
if isinstance(size, (int, float)):
+195 -80
View File
@@ -34,30 +34,35 @@ import shutil
import subprocess
import threading
import time
from collections.abc import Callable
from cloud.chv.api_client import _unix_socket_api
from cloud.chv.api_client import normalize_api_result
from cloud.executor_base import Executor
from cloud.chv.config import _parse_memory_bytes
from cloud.chv.config import build_chv_config
from cloud.chv.disk import check_disk_quota
from cloud.chv.disk import check_snapshot_limits
from cloud.chv.disk import delete_task_disk
from cloud.chv.disk import provision_task_disk
from cloud.chv.disk import record_snapshot
from cloud.chv.disk import task_disk_path
from cloud.chv.api_client import _unix_socket_api, normalize_api_result
from cloud.chv.config import _parse_memory_bytes, build_chv_config
from cloud.chv.disk import (
check_disk_quota,
check_snapshot_limits,
delete_task_disk,
provision_task_disk,
record_snapshot,
task_disk_path,
)
from cloud.chv.errors import ChvFailure
from cloud.chv.hardening import ensure_secure_state_dir
from cloud.chv.hardening import parse_net_entries
from cloud.chv.hardening import socket_is_live
from cloud.chv.hardening import validate_egress_proxy
from cloud.chv.hardening import validate_net_entries
from cloud.chv.hardening import validate_snapshot_url
from cloud.chv.hardening import (
ensure_secure_state_dir,
parse_net_entries,
socket_is_live,
validate_egress_proxy,
validate_net_entries,
validate_snapshot_url,
)
from cloud.chv.ssh_transport import build_ssh_command as _build_ssh_cmd
from cloud.chv.ssh_transport import known_hosts_path as _known_hosts_path
from cloud.chv.ssh_transport import parse_keyscan_output
from cloud.chv.ssh_transport import split_secret_env
from cloud.chv.ssh_transport import write_known_hosts
from cloud.chv.ssh_transport import (
parse_keyscan_output,
split_secret_env,
write_known_hosts,
)
from cloud.executor_base import Executor
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
@@ -246,6 +251,57 @@ class CloudHypervisorExecutor(Executor):
开启嵌套虚拟化的实例,那是用户侧开通决策,不在本轨内。
"""
# to10:mypy 属性声明(仅注解、不赋值,运行时仍由
# _copy_config_fields/_init_runtime 抄写)。没有这组声明,
# 动态 setattr 会让 mypy 认为全部配置属性不存在(基线
# 115 个报错里近 90 个属此类),类型检查等于没跑。
binary: str | None
kernel: str | None
rootfs: str | None
cpus: str
memory: str
state_dir: str
ssh_key: str | None
ssh_user: str
ssh_port: int
guest_host_py: str
cmdline: str
net: str | None
net_json: str | None
kvm_path: str
cpuinfo_path: str
ch_remote: str
model: str | None
max_vms: int
disk_mode: str
disk_gb: float
egress_proxy: str | None
allow_direct_egress: bool
task_timeout: float | None
snapshot_url: str | None
warm_pool_size: int
api_timeout: float
ready_timeout: float
ready_interval: float
host_key_mode: str
ssh_host: str | None
ssh_forward: bool
reuse_experiment: bool
single_tenant_project: str | None
snapshot_max_count: int
snapshot_max_total_gb: float
snapshot_retention_days: float
snapshot_dir: str | None
allow_remote_snapshot: bool
runner: "Callable[..., object]"
launcher: "Callable[..., object]"
api_client: "Callable[..., object]"
sleeper: "Callable[..., object]"
audit_callback: object
audit_failure_count: int
_vms: dict
_guest_env_delivered: dict
def __init__(self, binary: str | None = None,
kernel: str | None = None,
rootfs: str | None = None,
@@ -253,7 +309,8 @@ class CloudHypervisorExecutor(Executor):
cpus: str | int | None = None,
memory: str | None = None,
state_dir: str | None = None,
runner: object = None, launcher: object = None,
runner: Callable[..., object] | None = None,
launcher: Callable[..., object] | None = None,
ssh_key: str | None = None,
ssh_user: str | None = None,
ssh_port: int | str | None = None,
@@ -265,12 +322,13 @@ class CloudHypervisorExecutor(Executor):
cpuinfo_path: str = "/proc/cpuinfo",
env: dict | None = None,
model: str | None = None, ch_remote: str | None = None,
api_client: object = None,
sleeper: object = None, api_timeout: float | None = None,
api_client: Callable[..., object] | None = None,
sleeper: Callable[..., object] | None = None,
api_timeout: float | None = None,
ready_timeout: float | None = None,
ready_interval: float | None = None,
host_key_mode: str | None = None) -> None:
self._env = env if env is not None else os.environ
self._env: dict = env if env is not None else os.environ # type: ignore[assignment]
# 配置解析已拆到 cloud/chv/config.py(参数优先于环境
# 变量,默认值与 #216 一致;显式空用户/越界端口的收紧
# 见 config.py 头注释)。字段名与原属性一一对应,旧
@@ -291,7 +349,15 @@ class CloudHypervisorExecutor(Executor):
self._copy_config_fields(_cfg)
self._init_runtime(runner, launcher, api_client, sleeper)
def _init_runtime(self, runner, launcher, api_client, sleeper) -> None:
def _init_runtime(self, runner: Callable[..., object] | None,
launcher: Callable[..., object] | None,
api_client: Callable[..., object] | None,
sleeper: Callable[..., object] | None) -> None:
# to10:runner/launcher/api_client/sleeper 均为可调用注入对象;
# 形参保持 object 以兼容旧测试的任意 lambda,调用点用
# getattr 可调用检查后调用(见 _call_runner 等),mypy
# 的 object-not-callable 在 _call_runner 处以 Callable 局部
# 变量处理。
"""运行时注入:seed 投递集合、API 客户端、睡眠器、
runner/launcher 默认值(纯移动)。"""
# feat-chv-secfix(H2):本实例已 seed 投递密钥的 VM
@@ -346,7 +412,11 @@ class CloudHypervisorExecutor(Executor):
幂等:传入的已是 chv- 前缀名时不再二次加前缀(清理
扫描 socket 文件名时传的就是 chv- 名,二次加前缀会
把 chv-dead 算成 chv-chv-dead、永远清不掉)。"""
raw = task_or_name.get("id") if isinstance(task_or_name, dict) \
# to10:id 按契约是 str;cast 只给 mypy 用,运行时原样
# 传给 re.sub(id 缺失时仍按旧行为 TypeError,不静默
# 编成 "None")。
from typing import cast as _cast
raw = _cast(str, task_or_name.get("id")) if isinstance(task_or_name, dict) \
else str(task_or_name)
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", raw)
if safe.startswith("chv-"):
@@ -589,7 +659,7 @@ class CloudHypervisorExecutor(Executor):
- egress_not_configured:CHV_EGRESS_PROXY 未设且未显式
允许直连(默认 fail-closed,VM 出口必须过白名单代理)
"""
missing = [] # (kind, 文本) 列表,全部收集完再一次抛
missing: list = [] # (kind, 文本) 列表,全部收集完再一次抛
self._preflight_host_checks(missing)
self._preflight_file_checks(missing)
self._preflight_net_checks(missing)
@@ -637,6 +707,34 @@ class CloudHypervisorExecutor(Executor):
self, "audit_failure_count", 0) + 1
_LOGGER.error("chv_audit_failed event=%s kind=%s error=%s",
event, kind, exc)
# to10(ADR-14):回调失败的事件再落盘一份——
# 宿主日志可能轮转、计数只剩数字,落盘文件是
# 唯一能事后找回“哪次事件丢了”的取证。落盘
# 本身 best-effort,失败只记日志,绝不炸任务。
self._spill_audit(event, task, detail, kind)
def _spill_audit(self, event, task, detail, kind) -> None:
"""审计失败事件落盘(to10,见 ADR-14 取舍):
state_dir/chv-audit-spill.jsonl,每行一 JSON
{ts, event, kind, detail, task_id};文件超 1MB 时
先轮转成 .old 再写。detail 沿用 _emit 契约,绝不
含密钥值。落盘不是 DB 替代:timeline 查询看不到它,
需宿主人工查阅;轮转只留最新两代,旧事件会丢。"""
try:
path = os.path.join(self.state_dir,
"chv-audit-spill.jsonl")
if os.path.exists(path) \
and os.path.getsize(path) > 1024 * 1024:
os.replace(path, path + ".old")
task_id = task.get("id") if isinstance(task, dict) \
else str(task)
line = json.dumps({"ts": time.time(), "event": event,
"kind": kind, "detail": detail,
"task_id": task_id})
with open(path, "a") as fh:
fh.write(line + "\n")
except Exception as exc:
_LOGGER.error("chv_audit_spill_failed error=%s", exc)
# -- 命令构造(纯函数,不做文件校验) -------------------------------
def _build_vm_command(self, task):
@@ -671,7 +769,7 @@ class CloudHypervisorExecutor(Executor):
return _known_hosts_path(self.state_dir,
self.vm_name(task_or_name))
def _net_guest_ip(self):
def _net_guest_ip(self) -> str | None:
"""VmConfig net 首个带 ip 的条目(静态 tap 客机 IP)。"""
if not self.net_json:
return None
@@ -691,8 +789,9 @@ class CloudHypervisorExecutor(Executor):
显式 CHV_SSH_HOST > net 静态客机 IP > 显式转发宣称下
的回环;三者皆无时报 ChvFailure(ssh_target_undeclared),
绝不无条件假设 127.0.0.1:2222 能通。"""
if getattr(self, "ssh_host", None):
return self.ssh_host
_ssh_host = self.ssh_host
if _ssh_host:
return _ssh_host
guest_ip = self._net_guest_ip()
if guest_ip:
return guest_ip
@@ -704,6 +803,30 @@ class CloudHypervisorExecutor(Executor):
"里给 net 条目配 ip(静态 tap 客机 IP)、或显式设 "
"CHV_SSH_FORWARD=1 宣称宿主 NAT 已转发回环端口")
def _read_keypair_texts(self, paths: dict) -> tuple:
"""读 key 对文本(to10 提取,provision_host_key 降行用)。"""
with open(paths["public"], "r") as fh:
public_text = fh.read()
with open(paths["private"], "r") as fh:
private_text = fh.read()
return public_text, private_text
def _write_provisioned_known_hosts(self, paths: dict,
public_text: str) -> None:
"""写预置 known_hosts(to10 提取)。主机部分跟 SSH
目标走(客机 IP 时不再写死回环,否则钉死校验永远
对不上);解析失败时回环仅作钉死写入,不放宽校验。"""
from cloud.chv.hostkeys import known_hosts_line
from cloud.chv.ssh_transport import write_known_hosts
try:
ssh_host = self.resolve_ssh_host()
except ChvFailure:
ssh_host = "127.0.0.1"
write_known_hosts(
paths["known_hosts"],
[known_hosts_line(self.ssh_port, public_text,
host=ssh_host)])
def provision_host_key(self, task: dict, secret_env: dict | None = None) -> dict:
"""按 VM 预置 host key(补缺口轨强方案,见 hostkeys.py):
启动前为该 VM 生成一次性 ed25519 host key 对,公钥
@@ -718,10 +841,13 @@ class CloudHypervisorExecutor(Executor):
脏标记存在时直接拒绝(清理失败即拒起,不再复用
旧 key 对)。成功后审计 host_key_generated(只含
VM 名与密钥名,不含任何 key 值)。"""
# to10:known_hosts 写入已抽到 _write_provisioned_known_hosts,
# 此处只留生成与 seed 写入的 import(删后不再有未用 import)。
from cloud.chv.hostkeys import (
dirty_marker_path, generate_host_keypair,
known_hosts_line, write_seed)
from cloud.chv.ssh_transport import write_known_hosts
dirty_marker_path,
generate_host_keypair,
write_seed,
)
vm = self.vm_name(task)
if os.path.exists(dirty_marker_path(self.state_dir, vm)):
raise ChvFailure(
@@ -732,20 +858,8 @@ class CloudHypervisorExecutor(Executor):
paths = generate_host_keypair(
self.state_dir, vm,
runner=lambda cmd: self._call_runner(cmd))
with open(paths["public"], "r") as fh:
public_text = fh.read()
with open(paths["private"], "r") as fh:
private_text = fh.read()
# feat-chv-fix:known_hosts 行的主机部分跟 SSH 目标走
# (客机 IP 时不再写死回环,否则钉死校验永远对不上)。
try:
ssh_host = self.resolve_ssh_host()
except ChvFailure:
ssh_host = "127.0.0.1"
write_known_hosts(
paths["known_hosts"],
[known_hosts_line(self.ssh_port, public_text,
host=ssh_host)])
public_text, private_text = self._read_keypair_texts(paths)
self._write_provisioned_known_hosts(paths, public_text)
seed_dir = write_seed(paths["seed_dir"], vm,
private_text, public_text,
secret_env=secret_env)
@@ -819,7 +933,7 @@ class CloudHypervisorExecutor(Executor):
from cloud.chv.hostkeys import GUEST_ENV_PATH
delivered = self._guest_env_delivered.get(
self.vm_name(task), set())
missing = [name for name in secrets if name not in delivered]
missing: list = [name for name in secrets if name not in delivered]
if missing:
raise ChvFailure(
"secret_env_not_delivered",
@@ -835,6 +949,27 @@ class CloudHypervisorExecutor(Executor):
guest_env_file=guest_env_file)
# -- API 模式:VmConfig / API 调用 / 就绪轮询 -----------------------
def _vm_config_disks(self, task_disk: str | None,
seed_disk: str | None) -> list:
"""VmConfig 磁盘列表(to10 提取,含 L2 白名单校验)。
任务盘 + 可选 seed 只读盘(CIDATA ISO,cloud-init
NoCloud 数据源);显式给的盘若不是基础镜像本身,
须在 state_dir 内,否则 VmConfig 不能挂进宿主任意文件。"""
# 旧文案逐字保留:任务盘“任务磁盘须在…”,seed 盘
# “seed 磁盘须在…”(label 本身含“磁盘”与空格)。
for path, label in ((task_disk, "任务磁盘"),
(seed_disk, "seed 磁盘")):
if path and path != self.rootfs:
if not os.path.realpath(path).startswith(
os.path.realpath(self.state_dir) + os.sep):
raise ChvFailure(
"disk_path_not_allowed",
"%s须在 state_dir 内:%s" % (label, path))
disks: list = [{"path": task_disk or self.rootfs}]
if seed_disk:
disks.append({"path": seed_disk, "readonly": True})
return disks
def build_vm_config(self, task_disk: str | None = None, seed_disk: str | None = None) -> dict:
"""API 模式的 VmConfig(vm.create 请求体,纯函数):
cpus{boot_vcpus,max_vcpus}、memory{size: 字节}、
@@ -847,36 +982,14 @@ class CloudHypervisorExecutor(Executor):
cpus = int(self.cpus)
except ValueError:
raise ValueError("CHV_CPU 必须是整数:%r" % (self.cpus,))
# feat-chv-secfix(L2):磁盘路径白名单。显式给的
# task_disk 若不是基础镜像本身(reuse 模式的合法
# 情形),须在 state_dir 内;seed 盘须在 state_dir
# 内——VmConfig 再也不能挂进宿主任意文件。
if task_disk and task_disk != self.rootfs:
if not os.path.realpath(task_disk).startswith(
os.path.realpath(self.state_dir) + os.sep):
raise ChvFailure(
"disk_path_not_allowed",
"任务磁盘须在 state_dir 内:%s" % task_disk)
if seed_disk:
if not os.path.realpath(seed_disk).startswith(
os.path.realpath(self.state_dir) + os.sep):
raise ChvFailure(
"disk_path_not_allowed",
"seed 磁盘须在 state_dir 内:%s" % seed_disk)
config = {
"cpus": {"boot_vcpus": cpus, "max_vcpus": cpus},
"memory": {"size": _parse_memory_bytes(self.memory)},
"kernel": {"path": self.kernel},
"cmdline": {"args": self.cmdline},
"disks": [{"path": task_disk or self.rootfs}],
"disks": self._vm_config_disks(task_disk, seed_disk),
"rng": {"src": "/dev/urandom"},
}
# feat-chv-fix(P0-1):seed 磁盘是 VmConfig 第二块
# 只读盘(CIDATA 卷标 ISO,客机 cloud-init NoCloud
# 数据源),不是宿主目录——目录本身从不进 VmConfig。
if seed_disk:
config["disks"].append({"path": seed_disk,
"readonly": True})
if self.net_json:
try:
net = json.loads(self.net_json)
@@ -1230,7 +1343,7 @@ class CloudHypervisorExecutor(Executor):
raise
return secrets, net_entries
def _provision_seed_disk(self, task: dict, name: str, secrets) -> str:
def _provision_seed_disk(self, task: dict, name: str, secrets) -> str | None:
"""provisioned 模式的 seed 盘供给:生成→制盘→挂盘,
任一步失败即抛(调用方 stop_vm 清理),纯移动。"""
seed_disk = None
@@ -1248,8 +1361,10 @@ class CloudHypervisorExecutor(Executor):
# 失败即 fail-fast(VM 尚未启动,此时拒绝最
# 安全,except 里 stop_vm 会清掉 seed)。
from cloud.chv.hostkeys import (
build_seed_disk, delete_host_private_key,
seed_disk_path)
build_seed_disk,
delete_host_private_key,
seed_disk_path,
)
provisioned = self.provision_host_key(
task, secret_env=secrets or None)
seed_disk = seed_disk_path(self.state_dir, name)
@@ -1498,8 +1613,8 @@ class CloudHypervisorExecutor(Executor):
self.pause_vm(task_or_name)
self.snapshot_vm(task_or_name, snapshot_url)
self.stop_vm(task_or_name)
task = task_or_name if isinstance(task_or_name, dict) \
else {"id": task_or_name}
# to10:删死赋值(task 建出后未用,F841;返回值直接
# 用 task_or_name 算 VM 名,行为不变)。
return {"vm": self.vm_name(task_or_name),
"state": "hibernated", "snapshot": snapshot_url}
@@ -1616,7 +1731,8 @@ class CloudHypervisorExecutor(Executor):
非 live(无 api-socket 文件且无登记活进程)即删盘
并审计;删失败审计 vm_disk_delete_failed 并计入
failed。返回 {deleted, failed}。"""
deleted, failed = [], []
deleted: list = []
failed: list = []
try:
entries = os.listdir(self.state_dir)
except Exception:
@@ -1693,7 +1809,7 @@ class CloudHypervisorExecutor(Executor):
"""崩溃残留扫描(H5):私钥一律删、非 live VM 的 seed
同理删并审计;返回已清理 VM 名。worker 启动与
cleanup_stale_vms 时调它,不依赖 socket 是否存在。"""
cleaned = []
cleaned: list = []
try:
entries = os.listdir(self.state_dir)
except Exception:
@@ -1814,8 +1930,7 @@ class CloudHypervisorExecutor(Executor):
extra_env=extra_env)
result = self._call_runner(ssh_cmd, timeout=self.task_timeout)
# 方法内 import:见模块头注释的循环导入说明。
from cloud.sandbox import _parse_host_result
from cloud.sandbox import SimpleNamespaceShim
from cloud.sandbox import SimpleNamespaceShim, _parse_host_result
parsed = _parse_host_result(task["id"],
SimpleNamespaceShim(result))
parsed["executor"] = "cloud-hypervisor"
+10 -6
View File
@@ -4,6 +4,7 @@
cloud/chv/pool.py 移到本文件,cloud/chv/__init__ 与
cloud/sandbox.py 不再 re-export 它,生产代码 import 本
模块即违规(见 docs/chv-architecture.md ADR-12 的禁
import 清单与测试钉住)。实验用法只走
`from cloud.chv.experimental_pool import WarmPool`。
生产残留清理已彻底分文件:见 cloud/chv/orphans.py 的
@@ -53,18 +54,21 @@ Registry(本轨新增):
仍可用,重启后孤儿改由 socket 扫描兜底)。
"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from cloud.chv.executor import CloudHypervisorExecutor
import os
from cloud.chv.orphans import _REGISTRY_FILENAME
from cloud.chv.orphans import _read_registry
from cloud.chv.orphans import _write_registry
from cloud.chv.orphans import _REGISTRY_FILENAME, _read_registry, _write_registry
class WarmPool:
"""快照预热池(实验性,见模块头定案)。
wired_to_dispatch = False:生产 dispatch 不查池。
目标:冷启动(preflight + 起 VMM + create + boot + SSH
就绪)是慢路径;预热池提前把干净 VM 起好并暂停,任务
@@ -81,7 +85,7 @@ class WarmPool:
wired_to_dispatch = False
def __init__(self, executor: object, size: int | None = None,
def __init__(self, executor: "CloudHypervisorExecutor", size: int | None = None,
snapshot_url: str | None = None,
registry_path: str | None = None) -> None:
self.executor = executor
@@ -90,7 +94,7 @@ class WarmPool:
self.snapshot_url = snapshot_url \
if snapshot_url is not None \
else getattr(executor, "snapshot_url", None)
self._entries = {} # 合成名 -> {"state", "vm", "task", ...}
self._entries: dict = {} # 合成名 -> {"state", "vm", "task", ...}
self.registry_path = registry_path
if self.registry_path is None:
state_dir = getattr(executor, "state_dir", None)
+3 -3
View File
@@ -20,7 +20,7 @@ from cloud.chv.errors import ChvFailure
_NET_FIELDS = frozenset({"tap", "mac", "ip", "mask"})
def ensure_secure_state_dir(state_dir: str) -> None:
def ensure_secure_state_dir(state_dir: str) -> str:
"""建 state_dir 并保证 0700(H6):目录不存在即按 0700
建;已存在但组/其它位可访时 chmod 0700;失败即
ChvFailure(state_dir_insecure),绝不带着 /tmp 式
@@ -125,7 +125,7 @@ def _normalize_net_entry(entry: dict) -> dict:
def _check_net_duplicates(normalized: list) -> None:
"""本次条目内 tap/mac/ip 重复即 net_config_invalid。"""
seen = {"tap": set(), "mac": set(), "ip": set()}
seen: dict[str, set[str]] = {"tap": set(), "mac": set(), "ip": set()}
for entry in normalized:
for kind in ("tap", "mac", "ip"):
value = entry.get(kind)
@@ -152,7 +152,7 @@ def _check_net_conflicts(normalized: list, existing_entries: list) -> None:
% (kind, value, other.get("vm")))
def validate_egress_proxy(egress_proxy: str) -> None:
def validate_egress_proxy(egress_proxy: str) -> str | None:
"""egress 代理地址校验(H1 代码门的一部分):须为
http/https URL 且带主机名;否则 net_config_invalid…
实际 kind 用 config_invalid(preflight 的配置类失败)。
+4 -2
View File
@@ -32,6 +32,7 @@
import os
import shlex
from collections.abc import Callable
# seed 投递的客内密钥环境文件路径(feat-chv-secfix,H2):
# 只在 provisioned 模式的 seed user-data 里写出,0600 root,
@@ -88,7 +89,8 @@ def seed_disk_path(state_dir: str, vm_name: str) -> str:
return os.path.join(state_dir, vm_name, "seed.iso")
def build_seed_disk(seed_dir: str, seed_disk: str, runner: object = None) -> str:
def build_seed_disk(seed_dir: str, seed_disk: str,
runner: Callable[[list[str]], object] | None = None) -> str:
"""把 seed 目录做成 CIDATA 卷标 ISO(feat-chv-fix,P0-1 的
注入载体):genisoimage -volid CIDATA -joliet -rock。
@@ -206,7 +208,7 @@ def write_seed(seed_dir: str, vm_name: str, private_key_text: str,
def generate_host_keypair(state_dir: str, vm_name: str,
runner: object = None,
runner: Callable[[list[str]], object] | None = None,
keygen: str = "ssh-keygen") -> dict:
"""为该 VM 生成(或复用本 VM 已有的)host key 对。
+1 -1
View File
@@ -52,7 +52,7 @@ import shlex
_CHV_SECRET_ENV_NAMES = frozenset({"SUB2API_API_KEY"})
def split_secret_env(extra_env: dict) -> tuple:
def split_secret_env(extra_env: dict | None) -> tuple[dict, dict]:
"""把 extra_env 拆成(密钥子集,非密钥子集)。"""
secrets, plain = {}, {}
for key, value in dict(extra_env or {}).items():
+196
View File
@@ -0,0 +1,196 @@
"""CHV worker 适配模块(to10 轨,见 ADR-13)。
worker.py 里散落的 CHV 强相关接线抽到这里,worker 只留
薄包装(方法名、签名、调用点不变,旧测试继续走 worker
方法)。本模块只依赖标准库 + cloud.chv 叶子模块 +
cloud.logging_config,不 import cloud.worker,绝无循环。
抽入本模块的(行为与错误文案逐字搬运):
- call_goal_resume_breakpoint:通用断点调用(原 worker
顶层函数 _call_goal_resume_breakpoint 的本体)。
- make_chv_audit_callback:VM 生命周期事件进审计的回调
(原 Worker.__init__ 里的闭包本体)。
- reconcile_warm_pool:生产残留清理 facade 调用 + 审计
(原 Worker._reconcile_warm_pool 的本体)。
- cancel_watchdog_loop / quota_watchdog_loop:同步路径
两条看门狗循环(原 Worker._chv_*_watchdog 的本体,
self 依赖全部改成回调参数)。
- watchdog_vm_name:看门狗启动时的 VM 名派生(原
_run_attempt 里的 try/except 块)。
明确不抽的(见 ADR-13 诚实清单,worker 通用流程):
cancel() 里的 cancel_vm 分支、cleanup_stale_containers
的 CHV 分支、网关守卫、_executor_name 的 CHV 分支、
_run_attempt 的看门狗线程启动、VM 死亡写回——它们都
嵌在 Docker/Local 共用的通用流程里,硬抽只会把通用
流程撕成两半,行为不变的收益为零。
"""
import inspect
import threading
from collections.abc import Callable
from cloud.chv.executor import CloudHypervisorExecutor
from cloud.logging_config import log_event
def is_chv_executor(executor: object) -> bool:
"""是否为 CHV 执行器(worker 各处 isinstance 的同款判断)。"""
return isinstance(executor, CloudHypervisorExecutor)
def call_goal_resume_breakpoint(executor: object, task: dict,
model_provider: str | None = None,
extra_env: dict | None = None) -> object:
"""通用断点调用:执行器提供 goal_resume_breakpoint_task
时调用它。
Local 仍走原路径(调用方直接传 model_provider,不经过
这里,以免改生产语义);这里只用于非 Local 执行器:
签名接受 model_provider 时传它(CHV/Docker 均接受),
否则只传 task(旧假执行器单参)。feat-chv-secfix(H2):
CHV 的 gateway_env 不再经 SSH env 前缀注入(密钥值进
argv 的旧路径已废除),改走 provisioned seed 盘客内文件
投递;CHV 没有 Local 那样的进程 env 通道,seed 文件是
唯一通道,keyscan 模式下密钥投递 fail-closed。"""
method = getattr(executor, "goal_resume_breakpoint_task")
try:
params = inspect.signature(method).parameters
except (TypeError, ValueError):
return method(task)
kwargs: dict = {}
if "model_provider" in params:
kwargs["model_provider"] = model_provider
if "extra_env" in params and extra_env is not None:
kwargs["extra_env"] = extra_env
return method(task, **kwargs)
def make_chv_audit_callback(audit_fn: Callable) -> Callable:
"""VM 生命周期事件进审计的回调(原 __init__ 闭包本体)。
audit_fn 即 Worker._audit(best-effort,失败不影响任务);
时间线展示 VM 事件时读审计表即可,不另起一套 VM 事件表;
成本/配额仍走 worker 现有 quota 语义,本回调绝不记账。"""
def _chv_audit(event, task, detail, kind=None):
try:
project_id = task.get("project_id") \
if isinstance(task, dict) else None
audit_fn(project_id, "chv_vm_" + event, detail or "")
except Exception:
pass
return _chv_audit
def reconcile_warm_pool(executor: object, audit_fn: Callable,
when: str) -> list:
"""显式调生产残留清理 facade,返回已清理 VM 名。
仅 CHV worker 有效(其它执行器返回 [])。实现是
cloud.chv.orphans.reconcile_orphans_for_executor():
直接读 registry 文件做清理,不构造 WarmPool、不碰
实验接口。调用失败不炸主流程,但进审计(失败本身
也审计,不静默丢弃)。orphan 条目绝不发给任务,
reconcile 只做清理。"""
if not isinstance(executor, CloudHypervisorExecutor):
return []
try:
from cloud.chv.orphans import reconcile_orphans_for_executor as _reconcile
cleaned = _reconcile(executor) or []
except Exception as exc:
try:
audit_fn(None, "chv_warm_pool_reconcile_failed",
"when=%s error=%s" % (when, exc))
except Exception:
pass
return []
if cleaned:
try:
audit_fn(None, "chv_warm_pool_reconcile",
"when=%s cleaned=%s" % (when, ",".join(cleaned)))
except Exception:
pass
return cleaned
def cancel_watchdog_loop(task_id: str, vm_name: str | None,
stop_event: threading.Event,
cancel_requested: set,
get_task: Callable,
cancel_vm: Callable | None,
logger: object) -> None:
"""CHV 同步路径的取消看门狗(feat-chv-fix,P1-2,Docker
取消看门狗同款模式):同步 SSH 阻塞时 worker 的 cancel()
只能改库、清 _running 项,阻塞中的 run 本身不会醒;
本线程每 1 秒查库/取消标志,命中时调 cancel_vm 真关
VM,然后退出。查库失败时当没取消,不误杀。"""
while not stop_event.wait(1.0):
try:
if task_id in cancel_requested:
cancelled = True
else:
db_task = get_task(task_id)
cancelled = (db_task is not None and
db_task.get("status") == "interrupted")
except Exception:
cancelled = False
if not cancelled:
continue
try:
if cancel_vm is not None and vm_name:
cancel_vm(vm_name)
log_event(logger, "worker_chv_killed",
task_id=task_id,
message="cancel watchdog killed VM %s "
"(task interrupted/cancelled)"
% vm_name)
except Exception:
pass
return
def quota_watchdog_loop(task_id: str, vm_name: str | None,
stop_event: threading.Event,
quota_check: Callable | None,
mark_quota_hit: Callable,
cancel_vm: Callable | None,
logger: object,
interval: float = 30.0) -> None:
"""CHV 同步路径的配额看门狗(feat-chv-fix,P1-2,Docker
配额看门狗同款):命中时记 _quota_hit_tasks 并调
cancel_vm 真关 VM;_run_attempt 在同步返回后把结果
改写成 quota_exceeded,走统一配额中断收尾。"""
while not stop_event.wait(interval):
try:
over = bool(quota_check()) if quota_check else False
except Exception:
over = False
if not over:
continue
mark_quota_hit(task_id)
try:
if cancel_vm is not None and vm_name:
cancel_vm(vm_name)
log_event(logger, "worker_chv_killed",
task_id=task_id,
message="quota watchdog killed VM %s "
"(quota exceeded)" % vm_name)
except Exception:
pass
return
def watchdog_vm_name(executor: object, task: dict) -> str | None:
"""看门狗启动时的 VM 名派生(原 _run_attempt 块):
executor.vm_name(task) 抛错时回 None(不建看门狗),
绝不炸主派发流程。executor 无 vm_name 时等同抛错
(原代码里 AttributeError 同样被 except 吞掉回 None)。"""
get_vm_name = getattr(executor, "vm_name", None)
if not callable(get_vm_name):
return None
try:
return get_vm_name(task)
except Exception:
return None
+51 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { chvAttachmentBlocked, chvAvailability, chvQueueHint, chvVmBadge, chvVmStatusText, countChvWorkers, isChvExecutor, isWorkerAlive } from "./chv";
import { chvAttachmentBlocked, chvAvailability, chvAvailabilityText, chvQueueHint, chvVmBadge, chvVmDisplay, chvVmStatusText, countChvWorkers, isChvExecutor, isWorkerAlive } from "./chv";
// 纯逻辑测试:只测 chv.ts,不渲染组件、不发请求、不读 localStorage。
// alive 规则与 WorkersSection 现有口径一致:alive 字段优先,
@@ -103,3 +103,53 @@ describe("chvQueueHint", () => {
expect(chvQueueHint("cloud-hypervisor", "queued", { state: "known" as const, online: 1, total: 1 })).toBe("");
});
});
describe("chvVmDisplay(to10 四态)", () => {
it("字段 present=true → dead", () => {
const d = chvVmDisplay({ executor: "cloud-hypervisor", chv_vm_dead: true });
expect(d.state).toBe("dead");
expect(d.text).toContain("VM 中途死亡");
});
it("字段 absent → not-reported,不猜 live", () => {
const d = chvVmDisplay({ executor: "cloud-hypervisor" });
expect(d.state).toBe("not-reported");
expect(d.text).toContain("不代表 VM 活着");
});
it("任务未加载 → unknown", () => {
const d = chvVmDisplay(null);
expect(d.state).toBe("unknown");
expect(d.text).toContain("无法判断");
});
it("非 CHV → not-applicable,text 为空", () => {
const d = chvVmDisplay({ executor: "docker", chv_vm_dead: true });
expect(d.state).toBe("not-applicable");
expect(d.text).toBe("");
});
it("旧入口仍是 display 的 text(unknown/not-applicable 回空)", () => {
expect(chvVmStatusText({ executor: "cloud-hypervisor", chv_vm_dead: true })).toContain("VM 中途死亡");
expect(chvVmStatusText(null)).toBe("");
expect(chvVmStatusText({ executor: "docker" })).toBe("");
});
});
describe("chvAvailabilityText(to10 四处统一文案)", () => {
it("known 零在线:不拦提交、后端是最终权威", () => {
const text = chvAvailabilityText({ state: "known", online: 0, total: 2 });
expect(text).toContain("在线 0 / 共 2");
expect(text).toContain("不拦提交");
expect(text).toContain("最终权威");
});
it("known 非零:只有在线数,无排队警告", () => {
const text = chvAvailabilityText({ state: "known", online: 1, total: 1 });
expect(text).toContain("在线 1 / 共 1");
expect(text).not.toContain("排队");
});
it("未知三原因各有不同文案,绝不写成 0", () => {
expect(chvAvailabilityText({ state: "unknown", reason: "aggregate-only" })).toContain("仅全局管理员可见");
expect(chvAvailabilityText({ state: "unknown", reason: "load-failed" })).toContain("加载失败");
expect(chvAvailabilityText({ state: "unknown", reason: "not-loaded" })).toContain("尚未加载");
for (const reason of ["aggregate-only", "load-failed", "not-loaded"] as const) {
expect(chvAvailabilityText({ state: "unknown", reason })).not.toContain("在线 0");
}
});
});
+70 -12
View File
@@ -54,27 +54,85 @@ export function chvAttachmentBlocked(
return isChvExecutor(executor) && hasAttachments;
}
// VM 只读状态文案:输入只有后端已返回的两个字段,输出
// 三种情形之一,绝不编造第四种:
// - chv_vm_dead 为 true → 已报告死亡(来源:worker 终局
// 写回的 interrupted_reason,见 server _chv_vm_dead_from_row);
// - CHV 任务但无该字段 → 未报告死亡,且明写“后端无 live
// 状态,不代表 VM 活着”(server 没有 VM 登记表);
// - 非 CHV 任务 → 空串(不显示 VM 行)。
export function chvVmStatusText(task: {
// VM 只读状态:四种情形各有明确文案,绝不编造第五种,
// 绝不猜 live(to10 精确化,见 ADR-15):
// - dead:CHV 任务且 chv_vm_dead 为 true(字段已 present;
// 来源:worker 终局写回的 interrupted_reason,见 server
// _chv_vm_dead_from_row);
// - not-reported:CHV 任务但字段 absent(后端只在死亡时
// 才返回该字段;absent 只表示“未报告”,且明写“后端无
// live 状态字段,不代表 VM 活着”,server 没有 VM 登记表);
// - unknown:任务详情本身未加载(task 为 null),无法判断;
// - not-applicable:非 CHV 任务,不显示 VM 行(text 为空)。
export type ChvVmDisplay =
| { state: "dead"; text: string }
| { state: "not-reported"; text: string }
| { state: "unknown"; text: string }
| { state: "not-applicable"; text: string };
export function chvVmDisplay(task: {
executor?: string | null;
chv_vm_dead?: boolean;
}): string {
} | null | undefined): ChvVmDisplay {
if (task === null || task === undefined) {
return {
state: "unknown",
text: "任务详情未加载,无法判断 VM 状态(后端无 live 状态字段,详情加载后才显示)",
};
}
// 死亡文案同样先过执行器门:后端只对 CHV 任务派生该字段,
// 万一某天非 CHV 行误带该字段,前端也不把 docker 任务
// 写成“VM 死亡”。
if (task.chv_vm_dead && isChvExecutor(task.executor)) {
return "VM 中途死亡,任务未完成、断点可续跑(后端只读字段 chv_vm_dead)";
return {
state: "dead",
text: "VM 中途死亡,任务未完成、断点可续跑(后端只读字段 chv_vm_dead)",
};
}
if (isChvExecutor(task.executor)) {
return "未报告 VM 死亡(后端无 live 状态字段,不代表 VM 活着)";
return {
state: "not-reported",
text: "未报告 VM 死亡(后端无 live 状态字段,不代表 VM 活着)",
};
}
return "";
return { state: "not-applicable", text: "" };
}
// 旧入口(保留给已测调用点):即 chvVmDisplay 的 text,
// not-applicable 与 unknown 均回空串(调用点只在已加载的
// CHV 行上用它,unknown 分支由详情页的新入口处理)。
export function chvVmStatusText(task: {
executor?: string | null;
chv_vm_dead?: boolean;
} | null | undefined): string {
const display = chvVmDisplay(task);
return display.state === "not-applicable" ||
display.state === "unknown"
? ""
: display.text;
}
// CHV 可用性统一文案(to10):新建任务、Workers、环境
// 三处此前各写各的,Workers 的未知分支甚至恒写“仅全局
// 管理员可见”(加载失败/未加载时也是这句,是文案 bug)。
// 四处统一走本函数:known 零在线时不拦提交、后端是最终
// 权威;未知时按 reason 分三句,绝不把未知写成 0。
export function chvAvailabilityText(
availability: ChvAvailability,
): string {
if (availability.state === "known") {
const base = `CHV worker 在线 ${availability.online} / 共 ${availability.total}(来自 /workers)`;
return availability.online === 0
? `${base}:当前无 CHV worker 在线,任务将一直排队,请先确认宿主 worker 已起;本提示不拦提交,worker 随后上线可认领,后端是最终权威。`
: base;
}
if (availability.reason === "aggregate-only") {
return "CHV worker 在线数未知:完整 worker 清单仅全局管理员可见,非管理员只能看到聚合数,无法按执行器分项。";
}
if (availability.reason === "load-failed") {
return "CHV worker 在线数未知:/workers 加载失败,请到 Workers 区刷新后重试。";
}
return "CHV worker 在线数未知:/workers 尚未加载,请到 Workers 区刷新。";
}
// 列表行徽标:只有已报告死亡时才出现短徽标;未报告时
@@ -2,7 +2,7 @@ import { useRef, useState } from "react";
import { useConsole } from "../context";
import { Card, Bar } from "../components/ui";
import { gatherAttachments } from "../api/client";
import { chvAttachmentBlocked, chvAvailability, isChvExecutor } from "../api/chv";
import { chvAttachmentBlocked, chvAvailability, chvAvailabilityText, isChvExecutor } from "../api/chv";
import { EFFORTS } from "../api/types";
export default function DispatchSection() {
@@ -97,14 +97,8 @@ export default function DispatchSection() {
</Bar>
{isChv ? <div className="muted">实验执行器,默认关闭:需后端已开启 CHV worker 且宿主有 KVM,否则任务无人认领、一直排队;带附件时建任务即被后端拒绝(错误文案原样显示在下方)。前端只传 executor 值,不绕过后端白名单;默认仍是 docker。</div> : null}
{isChv ? (
<div className="muted">
{chvAvail.state === "known"
? <>CHV worker 在线 {chvAvail.online} / 共 {chvAvail.total}(来自 /workers){chvAvail.online === 0 ? <b>:当前无 CHV worker 在线,任务将一直排队,请先确认宿主 worker 已起;本提示不拦提交,worker 随后上线可认领,后端是最终权威。</b> : null}</>
: chvAvail.reason === "aggregate-only"
? "CHV worker 在线数未知:完整 worker 清单仅全局管理员可见,非管理员只能看到聚合数,无法按执行器分项。"
: chvAvail.reason === "load-failed"
? "CHV worker 在线数未知:/workers 加载失败,请到 Workers 区刷新后重试。"
: "CHV worker 在线数未知:/workers 尚未加载,请到 Workers 区刷新。"}
<div className="muted" aria-live="polite">
{chvAvailabilityText(chvAvail)}
{" "}<button type="button" onClick={ctx.refreshWorkers}>刷新 CHV 可用性</button>
</div>
) : null}
@@ -141,14 +135,14 @@ export default function DispatchSection() {
{goalInvincible ? <div className="muted">goal 模式无敌版:goal 模式 + 任务不限时(去掉 worker/docker 总超时;单轮 600 秒护栏还在,不限的是任务总时长与总轮次)。服务端默认的自动续跑与断点镜像同时生效。</div> : null}
<Bar>
<span className="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
<input ref={filesRef} type="file" multiple title="文件" disabled={isChv} onChange={refreshAttachments} />
<input ref={dirRef} type="file" multiple title="文件夹" disabled={isChv} onChange={refreshAttachments} {...({ webkitdirectory: "" } as any)} />
<input ref={imgRef} type="file" accept="image/*" multiple title="图片" disabled={isChv} onChange={refreshAttachments} />
<input ref={filesRef} type="file" multiple title="文件" aria-label="附件文件" disabled={isChv} onChange={refreshAttachments} />
<input ref={dirRef} type="file" multiple title="文件夹" aria-label="附件文件夹" disabled={isChv} onChange={refreshAttachments} {...({ webkitdirectory: "" } as any)} />
<input ref={imgRef} type="file" accept="image/*" multiple title="图片" aria-label="附件图片" disabled={isChv} onChange={refreshAttachments} />
</Bar>
{isChv ? <div className="muted">cloud-hypervisor 任务不支持附件:附件输入已禁用(后端对带附件的 CHV 任务会 400,错误文案原样显示在下方;切换回其他执行器可恢复附件输入)。{hasAttachments ? <>当前仍有已选附件,请先清空再提交。 <button type="button" onClick={() => { if (filesRef.current) filesRef.current.value = ""; if (dirRef.current) dirRef.current.value = ""; if (imgRef.current) imgRef.current.value = ""; setHasAttachments(false); }}>清空附件</button></> : ""}</div> : null}
<Bar>
<button className="seal" onClick={submit}>提交任务</button>
<span className="muted">{msg || ctx.dispatchMsg}</span>
<span className="muted" role="status" aria-live="polite">{msg || ctx.dispatchMsg}</span>
</Bar>
</Card>
);
@@ -2,7 +2,7 @@ import { useState } from "react";
import { useConsole } from "../context";
import { Card, Bar, DataListRaw } from "../components/ui";
import { shortId } from "../api/client";
import { chvAvailability } from "../api/chv";
import { chvAvailability, chvAvailabilityText } from "../api/chv";
export default function EnvSection() {
const ctx = useConsole();
@@ -37,7 +37,7 @@ export default function EnvSection() {
</select>
<button onClick={async () => { await ctx.createEnv(name, branch, executor); setName(""); setBranch(""); }}>新建环境</button>
</Bar>
{executor === "cloud-hypervisor" ? <div className="muted">实验执行器,默认关闭:环境创建白名单同样允许 cloud-hypervisor(后端环境接口 allowlist:local / docker / cloud-hypervisor,不含 cloud);仍需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务排队。前端只传值,不绕过后端白名单。{chvAvail.state === "known" ? <> CHV worker 在线 {chvAvail.online} / 共 {chvAvail.total}(来自 /workers){chvAvail.online === 0 ? ":当前无 CHV worker 在线,任务将一直排队。" : ""}</> : " CHV worker 在线数未知:完整 worker 清单仅全局管理员可见。"}</div> : null}
{executor === "cloud-hypervisor" ? <div className="muted" aria-live="polite">实验执行器,默认关闭:环境创建白名单同样允许 cloud-hypervisor(后端环境接口 allowlist:local / docker / cloud-hypervisor,不含 cloud);仍需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务排队。前端只传值,不绕过后端白名单。 {chvAvailabilityText(chvAvail)}</div> : null}
<span className="muted">{ctx.envMsg}</span>
</Card>
);
@@ -4,7 +4,7 @@ import { Card } from "../components/ui";
import { shortId, fmt } from "../api/client";
import { STATUS_EXPLAIN } from "../api/types";
import { firstChar, durationOf, tokenOf, fmtCostUsd } from "../api/taskMeta";
import { chvAvailability, chvQueueHint, chvVmStatusText } from "../api/chv";
import { chvAvailability, chvQueueHint, chvVmDisplay } from "../api/chv";
import DetailActions from "./detail/DetailActions";
import BrowserPanel from "./detail/BrowserPanel";
import ReviewPanel from "./detail/ReviewPanel";
@@ -131,7 +131,7 @@ export default function TaskDetailPanel() {
<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.executor === "cloud-hypervisor" ? <div><dt>VM 状态</dt><dd>{chvVmStatusText(t)}</dd></div> : null}
{t.executor === "cloud-hypervisor" ? <div><dt>VM 状态</dt><dd role="status" aria-label="VM 状态">{chvVmDisplay(t).text}</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 { Card, Bar, DataListRaw } from "../components/ui";
import { workerAgeText, shortId } from "../api/client";
import { chvAvailability } from "../api/chv";
import { chvAvailability, chvAvailabilityText } from "../api/chv";
export default function WorkersSection() {
const ctx = useConsole();
@@ -9,10 +9,8 @@ export default function WorkersSection() {
return (
<Card id="nav-workers" title="Workers">
<Bar><button onClick={ctx.refreshWorkers}>刷新</button></Bar>
<div className="muted">
{chvAvail.state === "known"
? <>cloud-hypervisor worker 在线 {chvAvail.online} / 共 {chvAvail.total}(实验,默认关闭){chvAvail.online === 0 ? ":当前无 CHV worker 在线,CHV 任务将一直排队。" : ""}</>
: "cloud-hypervisor worker 在线数未知:完整 worker 清单仅全局管理员可见,非管理员只能看到聚合数,无法按执行器分项。"}
<div className="muted" aria-live="polite">
{chvAvailabilityText(chvAvail)}
</div>
{ctx.workersError ? <div className="muted">{ctx.workersError}</div>
: ctx.workers === null ? <div className="muted">{ctx.workersSummary || "未加载"}</div>
+24 -24
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Codex 命令台</title>
<script type="module" crossorigin>(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const 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={};/**
<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 Ph(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 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={};/**
*/var Hh;function Vy(){if(Hh)return ui;Hh=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 Bh;function Qy(){return Bh||(Bh=1,co.exports=Vy()),co.exports}var c=Qy(),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 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
*/var qh;function Xy(){if(qh)return pt;qh=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"),k=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,nt){this.props=y,this.context=D,this.refs=vt,this.updater=nt||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,nt){this.props=y,this.context=D,this.refs=vt,this.updater=nt||J}var Nt=K.prototype=new jt;Nt.constructor=K,Q(Nt,St.prototype),Nt.isPureReactComponent=!0;var w=Array.isArray;function V(){}var at={H:null,A:null,T:null,S:null},Dt=Object.prototype.hasOwnProperty;function qt(y,D,nt){var tt=nt.ref;return{$$typeof:i,type:y,key:D,ref:tt!==void 0?tt:null,props:nt}}function dt(y,D){return qt(y.type,D,y.props)}function Ut(y){return typeof y=="object"&&y!==null&&y.$$typeof===i}function H(y){var D={"=":"=0",":":"=2"};return"$"+y.replace(/[=:]/g,function(nt){return D[nt]})}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,nt,tt,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,nt,tt,Ct)}}if(Tt)return Ct=Ct(y),Tt=tt===""?"."+yt(y,0):tt,w(Ct)?(nt="",Tt!=null&&(nt=Tt.replace(ot,"$&/")+"/"),Y(Ct,D,nt,"",function(re){return re})):Ct!=null&&(Ut(Ct)&&(Ct=dt(Ct,nt+(Ct.key==null||y&&y.key===Ct.key?"":(""+Ct.key).replace(ot,"$&/")+"/")+Tt)),D.push(Ct)),1;Tt=0;var et=tt===""?".":tt+":";if(w(y))for(var mt=0;mt<y.length;mt++)tt=y[mt],bt=et+yt(tt,mt),Tt+=Y(tt,D,nt,bt,Ct);else if(mt=X(y),typeof mt=="function")for(y=mt.call(y),mt=0;!(tt=y.next()).done;)tt=tt.value,bt=et+yt(tt,mt++),Tt+=Y(tt,D,nt,bt,Ct);else if(bt==="object"){if(typeof y.then=="function")return Y(G(y),D,nt,tt,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 st(y,D,nt){if(y==null)return y;var tt=[],Ct=0;return Y(y,tt,"","",function(bt){return D.call(nt,bt,Ct++)}),tt}function gt(y){if(y._status===-1){var D=y._result,nt=D();nt.then(function(tt){(y._status===0||y._status===-1)&&(y._status=1,y._result=tt,nt.status===void 0&&(nt.status="fulfilled",nt.value=tt))},function(tt){(y._status===0||y._status===-1)&&(y._status=2,y._result=tt,nt.status===void 0&&(nt.status="rejected",nt.reason=tt))}),y._status===-1&&(y._status=0,y._result=nt)}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=at.T,nt={};nt.types=D!==null?D.types:null,at.T=nt;try{var tt=y(),Ct=at.S;Ct!==null&&Ct(nt,tt),typeof tt=="object"&&tt!==null&&typeof tt.then=="function"&&tt.then(V,xt)}catch(bt){xt(bt)}finally{D!==null&&nt.types!==null&&(D.types=nt.types),at.T=D}}function L(y){var D=at.T;if(D!==null){var nt=D.types;nt===null?D.types=[y]:nt.indexOf(y)===-1&&nt.push(y)}else Se(L.bind(null,y))}var se={map:st,forEach:function(y,D,nt){st(y,function(){D.apply(this,arguments)},nt)},count:function(y){var D=0;return st(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 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={};/**
*/var Gh;function Zy(){return Gh||(Gh=1,(function(i){function r(G,Y){var st=G.length;G.push(Y);t:for(;0<st;){var gt=st-1>>>1,xt=G[gt];if(0<p(xt,Y))G[gt]=Y,G[st]=xt,st=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],st=G.pop();if(st!==Y){G[0]=st;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,st))y<xt&&0>p(D,se)?(G[gt]=D,G[y]=st,gt=y):(G[gt]=se,G[L]=st,gt=L);else if(y<xt&&0>p(D,st))G[gt]=D,G[y]=st,gt=y;else break t}}return Y}function p(G,Y){var st=G.sortIndex-Y.sortIndex;return st!==0?st: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,k=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,Ut());else{var Y=h(R);Y!==null&&yt(Nt,Y.startTime-G)}}var w=!1,V=-1,at=5,Dt=-1;function qt(){return Q?!0:!(i.unstable_now()-Dt<at)}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 st=k;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,k=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,k=st,F=!1}Y=void 0}}finally{Y?Ut():w=!1}}}var Ut;if(typeof jt=="function")Ut=function(){jt(dt)};else if(typeof MessageChannel<"u"){var H=new MessageChannel,ot=H.port2;H.port1.onmessage=dt,Ut=function(){ot.postMessage(null)}}else Ut=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"):at=0<G?Math.floor(1e3/G):5},i.unstable_getCurrentPriorityLevel=function(){return k},i.unstable_next=function(G){switch(k){case 1:case 2:case 3:var Y=3;break;default:Y=k}var st=k;k=Y;try{return G()}finally{k=st}},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 st=k;k=G;try{return Y()}finally{k=st}},i.unstable_scheduleCallback=function(G,Y,st){var gt=i.unstable_now();switch(typeof st=="object"&&st!==null?(st=st.delay,st=typeof st=="number"&&0<st?gt+st:gt):st=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=st+xt,G={id:q++,callback:Y,priorityLevel:G,startTime:st,expirationTime:xt,sortIndex:-1},st>gt?(G.sortIndex=st,r(R,G),h(z)===null&&G===h(R)&&(J?(St(V),V=-1):J=!0,yt(Nt,st-gt))):(G.sortIndex=xt,r(z,G),X||F||(X=!0,w||(w=!0,Ut()))),G},i.unstable_shouldYield=qt,i.unstable_wrapCallback=function(G){var Y=k;return function(){var st=k;k=Y;try{return G.apply(this,arguments)}finally{k=st}}}})(fo)),fo}var Vh;function Ky(){return Vh||(Vh=1,oo.exports=Zy()),oo.exports}var mo={exports:{}},ke={};/**
* @license React
* react-dom.production.js
*
@@ -36,7 +36,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var 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}/**
*/var Qh;function Jy(){if(Qh)return ke;Qh=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 k=2;k<arguments.length;k++)g+="&args[]="+encodeURIComponent(arguments[k])}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,k){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:k}}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 ke.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,ke.browser=function(q){return{$$typeof:N,_reason:q}},ke.createPortal=function(q,g){var k=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,k)},ke.flushSync=function(q){var g=z.T,k=f.p;try{if(z.T=null,f.p=2,q)return q()}finally{z.T=g,f.p=k,f.d.f()}},ke.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))},ke.prefetchDNS=function(q){typeof q=="string"&&f.d.D(q)},ke.preinit=function(q,g){if(typeof q=="string"&&g&&typeof g.as=="string"){var k=g.as,F=R(k,g.crossOrigin),X=typeof g.integrity=="string"?g.integrity:void 0,J=typeof g.fetchPriority=="string"?g.fetchPriority:void 0;k==="style"?f.d.S(q,typeof g.precedence=="string"?g.precedence:void 0,{crossOrigin:F,integrity:X,fetchPriority:J}):k==="script"&&f.d.X(q,{crossOrigin:F,integrity:X,fetchPriority:J,nonce:typeof g.nonce=="string"?g.nonce:void 0})}},ke.preinitModule=function(q,g){if(typeof q=="string")if(typeof g=="object"&&g!==null){if(g.as==null||g.as==="script"){var k=R(g.as,g.crossOrigin);f.d.M(q,{crossOrigin:k,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)},ke.preload=function(q,g){if(typeof q=="string"&&typeof g=="object"&&g!==null&&typeof g.as=="string"){var k=g.as,F=R(k,g.crossOrigin);f.d.L(q,k,{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})}},ke.preloadModule=function(q,g){if(typeof q=="string")if(g){var k=R(g.as,g.crossOrigin);f.d.m(q,{as:typeof g.as=="string"&&g.as!=="script"?g.as:void 0,crossOrigin:k,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)},ke.requestFormReset=function(q){f.d.r(q)},ke.unstable_batchedUpdates=function(q,g){return q(g)},ke.useFormState=function(q,g,k){return z.H.useFormState(q,g,k)},ke.useFormStatus=function(){return z.H.useHostTransitionStatus()},ke.version="19.3.1",ke}var Xh;function Fy(){if(Xh)return mo.exports;Xh=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=Jy(),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 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
*/var Lh;function Wy(){if(Lh)return ii;Lh=1;var i=Ky(),r=So(),h=Fy();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 k(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=k(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,at=Symbol.for("react.element"),Dt=Symbol.for("react.transitional.element"),qt=Symbol.for("react.portal"),dt=Symbol.for("react.fragment"),Ut=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"),st=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"),nt=Symbol.iterator;function tt(t){return t===null||typeof t!="object"?null:(t=nt&&t[nt]||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 Ut:return"StrictMode";case Y:return"Suspense";case st: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,et=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,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
`+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,Ul=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,U=262144,P=4194304;function it(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=it(a):(s&=o,s!==0?n=it(s):l||(l=o&~t,l!==0&&(n=it(l))))):(o=a&~u,o!==0?n=it(o):s!==0?n=it(s):l||(l=a&~t,l!==0&&(n=it(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 ct(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 kc(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: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:Oh(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,_o="__reactEvents$"+ne,nv="__reactListeners$"+ne,uv="__reactHandles$"+ne,Eo="__reactResources$"+ne,vu="__reactMarker$"+ne,oi="__reactLoad$"+ne;function fi(t){delete t[ge],delete t[Ke],delete t[nv],delete t[uv]}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=oh(t);t!==null;){if(l=t[ge])return l;t=oh(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[Eo];return e||(e=t[Eo]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function _e(t){t[vu]=!0}function No(t){t[oi]=void 0}var Co=new Set,Ao={};function La(t,e){pn(t,e),pn(t+"Capture",e)}function pn(t,Line truncated
`).replace(Z0,"")}function Xm(t,e){return e=Qm(e),Qm(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":Bo(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=kl);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=fv.get(l)||l,di(t,l,a);else return}Bt=!0}function Br(t,e,l,a,n,u){switch(l){case"style":Bo(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+`
`),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
`),wt&&(je+=(Array.isArray(wt)?wt.join(`
`):String(wt))+`
`),je+=JSON.stringify(ct,null,2).slice(0,4e3),jl(je),re("已返回登录信息,见下方")}catch(ct){re("发起失败:"+ct.message)}},[]),U=d.useCallback(async()=>{const A=X.trim();if(!A){F("请填要查配额的用户 id");return}try{const $=await lt(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 lt(W.adminSweep,"POST",{})).swept||[];F("sweep 完成,清理 "+$.length+" 个任务"+($.length?":"+$.join("、"):""))}catch(A){F(ho(A))}},[]),it=d.useCallback(async()=>{try{const A=await lt(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:k,adminUserId:X,setAdminUserId:J,projects:Q,projectId:St,setProjectId:jt,projectMsg:K,githubRepos:w,workers:at,workersSummary:qt,workersError:Ut,members:ot,memberMsg:G,memberError:st,skills:xt,apps:L,mcpServers:y,skillMsg:nt,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:Ul,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:U,adminSweep:P,adminBackup:it}}const yo=20;function sg(){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),[k,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 st=G!==void 0?G:Q.current,gt=vt.current;try{const xt=await lt(W.tasksPage(Y,yo,st*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 w=d.useCallback(ot=>{N(ot),K(0)},[K]),V=d.useCallback(ot=>{j(ot),K(0)},[K]),at=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(k/yo)),dt=k===0?"共 0 个任务":"共 "+k+" 个任务,第 "+(q+1)+" / "+qt+" 页",Ut=i,H=d.useCallback(async(ot,yt)=>{const G={...ot};yt.length&&(G.upload_id=await ug(yt));const Y=await lt(W.tasksCreate,"POST",G);return"任务已建:"+Ae(Y.id)},[]);return{tasks:i,taskError:h,statusFilter:p,setStatusFilter:w,executorFilter:C,setExecutorFilter:V,searchText:z,setSearchText:at,filteredTasks:Ut,taskCountText:dt,refreshTasks:jt,refreshTasksFirstPage:Dt,page:q,totalPages:qt,taskTotal:k,taskStatusCounts:X,goToPage:Nt,dispatch:H}}function rg(i,r){const[h,f]=d.useState(null),[p,N]=d.useState(""),[C,j]=d.useState(""),[z,R]=d.useState(""),[q,g]=d.useState(""),[k,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(""),[at,Dt]=d.useState([]),[qt,dt]=d.useState(""),[Ut,H]=d.useState(""),[ot,yt]=d.useState([]),[G,Y]=d.useState(""),[st,gt]=d.useState(""),[xt,Se]=d.useState(""),[L,se]=d.useState(""),[y,D]=d.useState([]),[nt,tt]=d.useState([]),[Ct,bt]=d.useState(""),[Tt,et]=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 U=await lt(W.taskAgents(B));Nt(U.agents||[]),V(U.note||"")}catch(U){Nt([]),V("子代理链路加载失败:"+U.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 U=await lt(W.tasksByParent(B.project_id||"",B.id));jt(U.tasks||[])}catch{jt([])}},[]),Dl=d.useCallback(async B=>{try{cLine truncated
`);hn=kl.pop()||"";for(const mu of kl){const hu=mu.split(`
`);hn=wl.pop()||"";for(const mu of wl){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(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
`);if(hu)try{const Ze=JSON.parse(hu);if(Ze.type==="snapshot")it=Ze.entries||[],Object.keys(A).forEach(ne=>delete A[ne]),it.forEach((ne,ge)=>{ne.id&&(A[ne.id]=ge)}),$=Ze.note||"",yt([...it]),Y($),Fl(B);else if(Ze.type==="entry"){const ne=Ze.entry;ne.id&&ne.id in A?it[A[ne.id]]=ne:(ne.id&&(A[ne.id]=it.length),it.push(ne)),yt([...it]),Fl(B)}else Ze.type==="done"&&(ct=!0,Yt=Ze.status,gt(Yt))}catch{}}if(ct)break}}catch(wt){if(wt&&wt.name==="AbortError")return;Y("链路流中断:"+wt.message);return}ct&&r()},[fe,Fl,r]),be=d.useCallback(async B=>{fe();try{const U=await lt(W.task(B));f(U);let P="";U.result_text&&(P+=`=== result ===
`+U.result_text.slice(0,8e3)+`
`),U.error_text&&(P+=`=== error ===
`+U.error_text.slice(0,8e3)+`
`),P||(P="(暂无 result/error 输出)"),N(P),g(""),F(void 0),Dl(B),J(!1),vt(""),re(""),ye(""),mn(U.status);const it=i(U.project_id),A=it==="maintainer"||it==="owner",$=it==="contributor"||A,ct=uu.includes(U.status),Yt=[];["failed","interrupted","merge-failed"].includes(U.status)&&A||Yt.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(A?"":",且仅 maintainer 可重试")),!ct&&$||Yt.push("取消:不可用——"+(ct?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),U.status==="completed"&&A||Yt.push("合并:不可用——仅 completed 可合并"+(A?"":",且仅 maintainer 可合并")),U.status==="merged"&&A||Yt.push("提 PR:不可用——仅 merged 可提 PR"+(A?"":",且仅 maintainer 可提 PR")),R(Yt.join(";")),j(""),Va(B,U.status),rl(B),ma(U),Pe(B),ee(B),ol(B),$t(B),Ul(B),qe(B),_l(B)}catch(U){f(null),N("加载失败:"+U.message)}},[fe,Va,rl,ma,Pe,ee,ol,$t,Ul,qe,_l,Dl,i]),Qa=d.useCallback(async()=>{const B=oe.current;if(B){try{const U=await lt(W.task(B.id));f(U);let P="";U.result_text&&(P+=`=== result ===
`+U.result_text.slice(0,8e3)+`
`),U.error_text&&(P+=`=== error ===
`+U.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),Ul(B.id),Dl(B.id)}},[ee,Pe,qe,_l,ma,rl,ol,$t,Ul,Dl]),ha=d.useCallback(async B=>{const U=oe.current;if(!U)return;const P={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!(P[B]&&!window.confirm(P[B])))try{const it=await lt(W.taskAction(U.id,B),"POST",{});j(B+" 已提交:"+JSON.stringify(it).slice(0,300)),r(),await be(U.id)}catch(it){j(B+" 失败:"+it.message)}},[be,r]),su=d.useCallback(async()=>{const B=oe.current;if(B)try{const U=await lt(W.taskCost(B.id));g(JSON.stringify(U,null,2).slice(0,4e3))}catch(U){g("查成本失败:"+U.message)}},[]),ru=d.useCallback(async B=>{const U=oe.current;if(U){if(!B.trim()){vt("分支消息必填");return}if(!uu.includes(U.status)){vt("仅终态任务可分支");return}try{const P=await lt(W.taskFork(U.id),"POST",{message:B.trim()});vt("分支任务已建:"+P.id.slice(0,8)),r()}catch(P){vt("分支失败:"+P.message)}}},[r]),Wl=d.useCallback(async B=>{const U=oe.current;if(U)try{await lt(W.taskReviews(U.id),"POST",B),dt("评审已登记(requested)"),await qe(U.id)}catch(P){dt("登记评审需求失败:"+P.message)}},[qe]),Me=d.useCallback(B=>{const U=oe.current;if(!U)return;const P=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");P!==null&&lt(W.taskReviewFindings(U.id,B),"POST",{findings:P}).then(()=>void qe(U.id)).catch(it=>dt("写 findings 失败:"+it.message))},[qe]),fl=d.useCallback((B,U)=>{const P=oe.current;if(!P)return;const it=U==="approved"?"approve":"deny";lt(W.taskApprovalDecision(P.id,B,it),"POST",{}).then(()=>{bt("已记录"+(U==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),_l(P.id)}).catch(A=>bt("决定失败:"+A.message))},[_l]),Oe=d.useCallback(async(B,U,P)=>{const it=oe.current;if(it){if(!U.trim()){re("目标任务 id 不能为空");return}if(!P.trim()){re("消息正文不能为空");return}try{const A=await lt(W.taskMessagesTo(U.trim()),"POST",{from_task_id:it.id,body:P.trim(),mode:B});re("已发送("+A.delivery+")"),await ol(it.id)}catch(A){re("发送失败:"+A.message)}}},[ol]),ou=d.useCallback((B,U)=>{const P=oe.current;if(!P)return;if(!["completed","failed","interrupted"].includes(P.status)){ye("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const it={};if(B==="rename"){if(!U.trim()){ye("新线程名不能为空");return}it.name=U.trim()}lt(W.taskThreadAction(P.id,B),"POST",it).then(A=>{ye(B+" 已提交:"+JSON.stringify(A).slice(0,300)),$t(P.id),Ul(P.id)}).catch(A=>ye(B+" 失败:"+A.message))},[$t,Ul]),fu=d.useCallback(async B=>{const U=oe.current;if(!U)return;const P=B.trim();if(!P){Le("消息不能为空");return}try{uu.includes(U.status)&&U.status!=="merged"?(await lt(W.taskContinue(U.id),"POST",{message:P}),Le("已重新排队,同一会话继续"),r(),await be(U.id)):["queued","running"].includes(U.status)?(await lt(W.taskFollowups(U.id),"POST",{body:P,mode:"direct"}),Le("已直接发送"),await ee(U.id),await Pe(U.id)):Le("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(it){Le("发送失败:"+it.message)}},[be,r,ee,Pe]),du=d.useCallback(async()=>{const B=oe.current;if(!B){se("先选一个任务");return}try{const U=await lt(W.taskTrace(B.id)),P=new Blob([JSON.stringify({task_id:B.id,status:U.status,note:U.note||"",count:(U.entries||[]).length,entries:U.entries||[]},null,2)],{type:"application/json"}),it=document.createElement("a");it.href=URL.createObjectURL(P),it.download="trace-"+B.id.slice(0,8)+".json",document.body.appendChild(it),it.click(),it.remove(),setTimeout(()=>URL.revokeObjectURL(it.href),1e3),se("已下载完整链路 "+(U.entries||[]).length+" 条")}catch(U){se("下载失败:"+U.message)}},[]);return d.useEffect(()=>()=>fe(),[fe]),{currentTask:h,detailBody:p,actionMsg:C,actionReasons:z,costBox:q,costSummary:k,forkVisible:X,setForkVisible:J,forkMsg:Q,coordChildren:St,agents:K,agentsNote:w,reviews:at,reviewMsg:qt,reviewError:Ut,traceEntries:ot,traceNote:G,traceDone:st,traceKind:xt,setTraceKind:Se,traceMsg:L,followups:y,approvals:nt,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 og(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 lt(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 Cg(){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 k=new Set(z.map(X=>X.id)),F=i.traceEntries.filter(X=>X.id&&!k.has(X.id)&&!String(X.id).startsWith("fu-")).map((X,J)=>Fh(X,J));z=[...z,...F]}else{const k=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],F=i.traceEntries.filter(J=>!String(J.id||"").startsWith("fu-")).map((J,Q)=>Fh(J,Q)),X=i.followups.map((J,Q)=>({id:"followup-"+Q,role:"user",text:String(J.body||""),label:"后续消息"}));z=[...k,...F,...X]}const R=i.traceKind,q=R?z.filter(k=>k.role==="user"||!k.kind||k.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(Eg,{messages:h,empty:"(暂无会话消息)"}),c.jsx(Ng,{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 Ag(){return Kt().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(Cg,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function Mg(){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(`
`))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 st={};Y.holder&&(st.holder=Y.holder),Y.url&&(st.url=Y.url),Y.title!==void 0&&(st.title=Y.title),Object.keys(st).length>0&&N(gt=>({...gt||{},...st}))}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 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
`)}}j("流已断开")}catch(at){if(at.name==="AbortError")return;R(at instanceof Error?at.message:String(at)),j("未开始")}finally{g(!1)}},[r]),Q=d.useCallback(()=>{var w;(w=k.current)==null||w.abort(),g(!1),j("已停止")},[]),vt=d.useCallback(async w=>{if(r)try{const V=await lt(W.taskBrowserControl(r.id),"POST",{holder:w});N(V),R("")}catch(V){R(V instanceof Error?V.message:String(V))}},[r]),St=d.useCallback(async w=>{if(!r||(p==null?void 0:p.holder)!=="user"||!F.current)return;const V=F.current.getBoundingClientRect(),at=Math.round((w.clientX-V.left)/V.width*1280),Dt=Math.round((w.clientY-V.top)/V.height*800);try{await lt(W.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:at,y:Dt})}catch(qt){R(qt instanceof Error?qt.message:String(qt))}},[r,p]),jt=d.useCallback(async w=>{if(!(!r||(p==null?void 0:p.holder)!=="user")){w.preventDefault();try{w.key.length===1?await lt(W.taskBrowserInput(r.id),"POST",{type:"type",text:w.key}):await lt(W.taskBrowserInput(r.id),"POST",{type:"key",key:w.key})}catch(V){R(V instanceof Error?V.message:String(V))}}},[r,p]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const K=(p==null?void 0:p.holder)||"—",Nt=p!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:C})]}),c.jsxs("span",{className:"muted",children:["会话 ",Nt?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",K]}),p!=null&&p.url?c.jsx("span",{className:"muted",title:p.url,children:p.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[q?c.jsx("button",{type:"button",onClick:Q,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void J(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void X(),children:"刷新状态"}),K!=="user"?c.jsx("button",{type:"button",onClick:()=>void vt("user"),disabled:!Nt,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void vt("agent"),children:"交还给 Agent"})]}),z?c.jsx("div",{className:"muted",style:{marginTop:8},children:z}):null,K==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:F,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:w=>void St(w),onKeyDown:w=>void jt(w),style:{width:"100%",maxWidth:1280,cursor:K==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:Nt?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function zg(){const i=Kt(),[r,h]=d.useState("uncommittedChanges"),[f,p]=d.useState(""),[N,C]=d.useState(""),[j,z]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:R=>h(R.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:R=>p(R.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:N,onChange:R=>C(R.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:j,onChange:R=>z(R.target.value)}),c.jsxs(Ht,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:N.trim()||void 0,instructions:j.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(cu,{children:i.reviews.map(R=>{let q=R.target_type||"";return R.branch&&(q+=":"+R.branch),R.sha&&(q+=":"+String(R.sha).slice(0,12)),R.instructions&&(q+=":"+String(R.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",Ae(R.id)," | ",q," | ",c.jsx("span",{className:"st-"+(R.status||""),children: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>
+3
View File
@@ -225,6 +225,9 @@ class DockerSyncGuardTest(unittest.TestCase):
try:
ex = DockerExecutor(runner=lambda cmd, timeout=None:
FakeResult(returncode, stdout, stderr))
# to10:goal 解析是纯函数测试,绝不依赖本机 docker
# 二进制(无 docker 的机器上 _check_docker 会直接炸)。
ex._check_docker = lambda: None
ex._call_runner = lambda cmd, timeout=None, env=None: \
FakeResult(returncode, stdout, stderr)
return ex.goal_task({"id": "t1", "goal": "do it",
+388
View File
@@ -0,0 +1,388 @@
"""to10 轨新增测试:覆盖率缺口分支(非刷行)。
只测此前 coverage 报告里未覆盖的分支:api_client 的假
socket 错误路径、hostkeys 的 keygen/seed 失败路径、
orphans 的 registry 失败路径、keyscan 过滤、compat 的
fail-closed 分支、config 的类型转换与 assemble 冲突。
不删旧测试、不改分母;每项都是行为断言,不是 grep。
"""
import os
import socket
import tempfile
import unittest
from cloud.chv import api_client
from cloud.chv import compat215
from cloud.chv import config as chv_config
from cloud.chv import hostkeys
from cloud.chv import orphans
from cloud.chv import ssh_transport
class _FakeSocket:
"""按 recv 调用序列供数据的假 socket。"""
def __init__(self, chunks, timeout_at=None):
self._chunks = list(chunks)
self._timeout_at = timeout_at
self._calls = 0
self.closed = False
def recv(self, _size):
self._calls += 1
if self._timeout_at is not None \
and self._calls >= self._timeout_at:
raise socket.timeout("fake timeout")
if not self._chunks:
return b""
return self._chunks.pop(0)
def close(self):
self.closed = True
class ApiClientTest(unittest.TestCase):
def test_header_timeout_raises(self):
with self.assertRaises(RuntimeError):
api_client._read_header(_FakeSocket([], timeout_at=1))
def test_header_early_close_raises(self):
with self.assertRaises(RuntimeError):
api_client._read_header(_FakeSocket([]))
def test_chunked_rejected(self):
with self.assertRaises(RuntimeError):
api_client._parse_content_length(
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked")
def test_no_length_rejected(self):
with self.assertRaises(RuntimeError):
api_client._parse_content_length("HTTP/1.1 200 OK")
def test_illegal_length_rejected(self):
with self.assertRaises(RuntimeError):
api_client._parse_content_length(
"HTTP/1.1 200 OK\r\nContent-Length: xx")
def test_body_timeout_truncation(self):
with self.assertRaises(RuntimeError):
api_client._read_body(_FakeSocket([], timeout_at=1),
b"ab", 10)
def test_body_early_close_truncation(self):
with self.assertRaises(RuntimeError):
api_client._read_body(_FakeSocket([]), b"ab", 10)
def test_parse_non_http_status_zero(self):
status, _body = api_client._parse_response("garbage", b"{}")
self.assertEqual(status, 0)
def test_parse_non_json_body_is_text(self):
_status, body = api_client._parse_response(
"HTTP/1.1 200 OK", b"not-json")
self.assertEqual(body, "not-json")
def test_parse_empty_body_is_empty_dict(self):
_status, body = api_client._parse_response(
"HTTP/1.1 200 OK", b"")
self.assertEqual(body, {})
def test_unix_api_missing_socket_raises(self):
with self.assertRaises(RuntimeError):
api_client._unix_socket_api("/tmp/no-such-chv.sock",
"GET", "/vm.info")
class HostkeysTest(unittest.TestCase):
def test_keygen_reuse_existing_pair(self):
with tempfile.TemporaryDirectory() as tmp:
paths = hostkeys.host_key_paths(tmp, "vm1")
os.makedirs(os.path.dirname(paths["private"]),
exist_ok=True)
for key in ("private", "public"):
with open(paths[key], "w") as fh:
fh.write("x")
out = hostkeys.generate_host_keypair(
tmp, "vm1", runner=lambda cmd: (_ for _ in ()).throw(
AssertionError("reuse 不应调 keygen")))
self.assertEqual(out, paths)
def test_keygen_nonzero_rc_raises(self):
class _Res:
returncode = 1
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(RuntimeError):
hostkeys.generate_host_keypair(
tmp, "vm2", runner=lambda cmd: _Res())
def test_keygen_no_files_after_runner_raises(self):
class _Res:
returncode = 0
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(RuntimeError):
hostkeys.generate_host_keypair(
tmp, "vm3", runner=lambda cmd: _Res())
def test_seed_disk_nonzero_rc_raises(self):
class _Res:
returncode = 1
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(RuntimeError):
hostkeys.build_seed_disk(
tmp, os.path.join(tmp, "seed.iso"),
runner=lambda cmd: _Res())
def test_seed_disk_missing_output_raises(self):
class _Res:
returncode = 0
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(RuntimeError):
hostkeys.build_seed_disk(
tmp, os.path.join(tmp, "seed.iso"),
runner=lambda cmd: _Res())
class OrphansAndKeyscanTest(unittest.TestCase):
def test_quarantine_failure_only_warns(self):
with self.assertLogs("cloud.cloud_hypervisor",
level="WARNING") as logs:
orphans._quarantine_registry("/tmp/no-such-dir-x/f.json")
self.assertTrue(any("quarantine_failed" in line
for line in logs.output))
def test_write_registry_failure_only_warns(self):
with tempfile.TemporaryDirectory() as tmp:
blocker = os.path.join(tmp, "blocker")
with open(blocker, "w") as fh:
fh.write("x")
with self.assertLogs("cloud.cloud_hypervisor",
level="WARNING") as logs:
orphans._write_registry(
os.path.join(blocker, "registry.json"), {})
self.assertTrue(any("save_failed" in line
for line in logs.output))
def test_keyscan_filters_short_and_non_ssh_lines(self):
text = "\n".join([
"# comment",
"",
"only-two",
"127.0.0.1 ecdsa-sha2-nistp256 AAAA",
"[127.0.0.1]:2222 ssh-ed25519 AAAA-good",
"other-host ssh-ed25519 AAAA-other",
])
kept = ssh_transport.parse_keyscan_output(text)
self.assertEqual(kept, ["[127.0.0.1]:2222 ssh-ed25519 AAAA-good"])
class CompatAndConfigTest(unittest.TestCase):
def test_tmpfs_unknown_executor_fail_closed(self):
isolated, _reason = compat215.tmpfs_isolated("unknown-kind",
"/tmp")
self.assertFalse(isolated)
def test_resolve_inside_empty_rel_is_base(self):
self.assertEqual(compat215.resolve_inside("/a/b", ""), "/a/b")
def test_resolve_inside_absolute_is_none(self):
self.assertIsNone(compat215.resolve_inside("/a/b", "/etc/passwd"))
def test_to_int_bool_and_float_and_bad(self):
with self.assertRaises(ValueError):
chv_config._to_int(True)
self.assertEqual(chv_config._to_int(3.9), 3)
self.assertEqual(chv_config._to_int(" 7 "), 7)
with self.assertRaises(ValueError):
chv_config._to_int(object())
def test_to_float_bool_and_bad(self):
with self.assertRaises(ValueError):
chv_config._to_float(False)
self.assertEqual(chv_config._to_float("2.5"), 2.5)
with self.assertRaises(ValueError):
chv_config._to_float(object())
def test_assemble_conflict_raises(self):
with self.assertRaises(ValueError):
chv_config._assemble_chv_config(
[{"cpus": "2"}, {"cpus": "4"}])
if __name__ == "__main__":
unittest.main()
class WorkerAdapterTest(unittest.TestCase):
"""to10:worker_adapter 的直接行为测试(worker 薄包装
由旧测试 test_chv_fix/test_chv_gaps 覆盖,此处测本体)。"""
def test_call_breakpoint_prefers_signature_kwargs(self):
from cloud.chv import worker_adapter
seen = {}
class _Ex:
def goal_resume_breakpoint_task(self, task,
model_provider=None,
extra_env=None):
seen.update(task=task, model_provider=model_provider,
extra_env=extra_env)
return "ok"
out = worker_adapter.call_goal_resume_breakpoint(
_Ex(), {"id": "t"}, model_provider="m",
extra_env={"A": "1"})
self.assertEqual(out, "ok")
self.assertEqual(seen["model_provider"], "m")
self.assertEqual(seen["extra_env"], {"A": "1"})
def test_call_breakpoint_single_arg_executor(self):
from cloud.chv import worker_adapter
class _Ex:
def goal_resume_breakpoint_task(self, task):
return "single"
self.assertEqual(
worker_adapter.call_goal_resume_breakpoint(_Ex(), {"id": "t"}),
"single")
def test_audit_callback_extracts_project_and_swallows(self):
from cloud.chv import worker_adapter
calls = []
cb = worker_adapter.make_chv_audit_callback(calls.append)
# append 只接受 1 参,回调内部 except 应吞掉,不炸
cb("booted", {"project_id": "p1"}, "detail")
self.assertEqual(calls, [])
got = []
cb2 = worker_adapter.make_chv_audit_callback(
lambda pid, ev, detail: got.append((pid, ev, detail)))
cb2("booted", {"project_id": "p1"}, "detail")
cb2("booted", "not-a-dict", "detail")
self.assertEqual(got[0], ("p1", "chv_vm_booted", "detail"))
self.assertEqual(got[1], (None, "chv_vm_booted", "detail"))
def test_reconcile_non_chv_returns_empty(self):
from cloud.chv import worker_adapter
self.assertEqual(
worker_adapter.reconcile_warm_pool(object(), lambda *a: None,
"startup"),
[])
def test_watchdog_vm_name_error_returns_none(self):
from cloud.chv import worker_adapter
class _Bad:
def vm_name(self, task):
raise RuntimeError("boom")
self.assertIsNone(worker_adapter.watchdog_vm_name(_Bad(), {}))
def test_cancel_watchdog_loop_kills_on_flag(self):
import threading
from cloud.chv import worker_adapter
killed = []
stop = threading.Event()
worker_adapter.cancel_watchdog_loop(
"t1", "chv-t1", stop, {"t1"},
lambda tid: None,
lambda vm: killed.append(vm),
None)
# stop 未置位时循环每 1 秒轮询:flag 已命中,第一轮即
# 关 VM 并返回;这里不依赖日志(logger 传 None 时
# log_event 内部不炸——若炸则本项失败)。
self.assertEqual(killed, ["chv-t1"])
def test_quota_watchdog_loop_marks_and_kills(self):
import threading
from cloud.chv import worker_adapter
killed = []
marked = []
stop = threading.Event()
worker_adapter.quota_watchdog_loop(
"t2", "chv-t2", stop, lambda: True,
marked.append,
lambda vm: killed.append(vm),
None, interval=0.01)
self.assertEqual(marked, ["t2"])
self.assertEqual(killed, ["chv-t2"])
class AuditSpillTest(unittest.TestCase):
"""to10:_emit 回调失败时落盘(ADR-14),不中断、不抛。"""
def test_spill_on_callback_failure(self):
import json as _json
from cloud.chv.executor import CloudHypervisorExecutor
with tempfile.TemporaryDirectory() as tmp:
ex = CloudHypervisorExecutor(
binary="/bin/true", kernel="/tmp/k", rootfs="/tmp/r",
state_dir=tmp, ssh_key="/tmp/key")
calls = []
def _bad(event, task, detail, kind=None):
raise RuntimeError("db down")
ex.audit_callback = _bad
ex._emit("booted", {"id": "t-spill"}, "vm=chv-t-spill")
self.assertEqual(ex.audit_failure_count, 1)
spill = os.path.join(tmp, "chv-audit-spill.jsonl")
self.assertTrue(os.path.exists(spill))
row = _json.loads(open(spill).readline())
self.assertEqual(row["event"], "booted")
self.assertEqual(row["task_id"], "t-spill")
self.assertEqual(row["detail"], "vm=chv-t-spill")
def test_spill_rotation_keeps_old(self):
from cloud.chv.executor import CloudHypervisorExecutor
with tempfile.TemporaryDirectory() as tmp:
ex = CloudHypervisorExecutor(
binary="/bin/true", kernel="/tmp/k", rootfs="/tmp/r",
state_dir=tmp, ssh_key="/tmp/key")
spill = os.path.join(tmp, "chv-audit-spill.jsonl")
with open(spill, "w") as fh:
fh.write("x" * (1024 * 1024 + 10))
ex._spill_audit("booted", {"id": "t2"}, "d", None)
self.assertTrue(os.path.exists(spill + ".old"))
self.assertTrue(os.path.getsize(spill) < 1024)
class LockfileScriptTest(unittest.TestCase):
"""to10:generate_lockfile.py 的 generate/verify(合成
BUILDINFO,不碰真实客机清单,不编版本号)。"""
def _load_script(self):
import importlib.util
path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
"..", "chv-guest", "generate_lockfile.py")
path = os.path.normpath(path)
spec = importlib.util.spec_from_file_location(
"chv_generate_lockfile", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def test_generate_reads_real_dpkg_version(self):
import json as _json
mod = self._load_script()
with tempfile.TemporaryDirectory() as tmp:
bi = os.path.join(tmp, "bi.json")
out = os.path.join(tmp, "lock.json")
with open(bi, "w") as fh:
_json.dump({"tag": "t", "contents": ["coreutils"]},
fh)
self.assertEqual(mod.generate(bi, out), 0)
lock = _json.load(open(out))
self.assertEqual(lock["tag"], "t")
self.assertEqual(lock["unresolved"], [])
self.assertTrue(lock["versions"]["dpkg"][0].startswith(
"coreutils "))
def test_verify_fails_on_unresolved(self):
import json as _json
mod = self._load_script()
with tempfile.TemporaryDirectory() as tmp:
lock = os.path.join(tmp, "lock.json")
with open(lock, "w") as fh:
_json.dump({"tag": "t",
"versions": {"dpkg": ["a 1.0"],
"pip": ["b 2.0"]},
"unresolved": ["zzz"]}, fh)
self.assertEqual(mod.verify(lock, None), 1)
+37 -117
View File
@@ -120,7 +120,6 @@ Docker 下 repos_dir 必须放在 extra-mounts 已挂载的目录下,
import argparse
import hashlib
import inspect
import json
import os
from datetime import datetime, timezone
@@ -198,6 +197,9 @@ from cloud.queue import budget_exceeded, ready_tasks
from cloud.repos import RepoError, checkout_dir, resolve_repo
from cloud.sandbox import CloudExecutor, CloudHypervisorExecutor, \
DockerExecutor, LocalExecutor
# to10:CHV 强相关接线抽到 cloud.chv.worker_adapter,见 ADR-13;
# worker 只留薄包装(方法名/签名/调用点不变)。
from cloud.chv import worker_adapter as _chv_worker_adapter
from cloud.uploads import upload_root as _upload_root
from cloud.sandbox import _default_runner as _sandbox_default_runner
from cloud.caps import effective_capacity, resolve_capacity
@@ -335,27 +337,13 @@ def _merge_env(base, override):
def _call_goal_resume_breakpoint(executor, task, model_provider=None,
extra_env=None):
"""通用断点调用:执行器提供 goal_resume_breakpoint_task 时调用它。
Local 仍走原路径(调用方直接传 model_provider,不经过这里,
以免改生产语义);这里只用于非 Local 执行器:签名接受
model_provider 时传它(CHV/Docker 均接受),否则只传 task
(旧假执行器单参)。feat-chv-secfix(H2):CHV 的
gateway_env 不再经 SSH env 前缀注入(密钥值进 argv 的
旧路径已废除),改走 provisioned seed 盘客内文件投递;
CHV 没有 Local 那样的进程 env 通道,seed 文件是唯一通道,
keyscan 模式下密钥投递 fail-closed。"""
method = getattr(executor, "goal_resume_breakpoint_task")
try:
params = inspect.signature(method).parameters
except (TypeError, ValueError):
return method(task)
kwargs = {}
if "model_provider" in params:
kwargs["model_provider"] = model_provider
if "extra_env" in params and extra_env is not None:
kwargs["extra_env"] = extra_env
return method(task, **kwargs)
"""通用断点调用(to10:本体已移到
cloud.chv.worker_adapter.call_goal_resume_breakpoint,
此处只留薄包装——旧测试从 cloud.worker import 它,名
字与签名不变,行为逐字不变)。"""
return _chv_worker_adapter.call_goal_resume_breakpoint(
executor, task, model_provider=model_provider,
extra_env=extra_env)
def _resolve_model(model=None, config_path=None):
@@ -539,16 +527,12 @@ class Worker:
# 读审计表即可,不另起一套 VM 事件表;成本/配额仍走
# worker 现有 quota 语义,本回调绝不记账。
if isinstance(self.executor, CloudHypervisorExecutor):
def _chv_audit(event, task, detail, kind=None):
try:
project_id = task.get("project_id") \
if isinstance(task, dict) else None
self._audit(project_id, "chv_vm_" + event,
detail or "")
except Exception:
pass
# to10:回调本体已移到 worker_adapter.make_chv_audit_
# callback(原 __init__ 闭包逐字搬运),此处只挂接。
try:
self.executor.audit_callback = _chv_audit
self.executor.audit_callback = \
_chv_worker_adapter.make_chv_audit_callback(
self._audit)
except Exception:
pass
# CHV 残留清理:仅孤儿清理接线(feat-chv-arch95 定案,
@@ -797,36 +781,11 @@ class Worker:
return self._cost_store
def _reconcile_warm_pool(self, when):
"""显式调生产残留清理 facade,返回已清理 VM 名。
仅 CHV worker 有效(其它执行器返回 [])。实现是
cloud.chv.orphans.reconcile_orphans_for_executor():
直接读 registry 文件做清理,不构造 WarmPool、不碰
实验接口(方法内 import,失败时按下面分支审计后
返回 [])。调用失败不炸主流程,但进审计(失败本身
也审计,不静默丢弃)。orphan 条目绝不发给任务,
reconcile 只做清理。"""
executor = getattr(self, "executor", None)
if not isinstance(executor, CloudHypervisorExecutor):
return []
try:
from cloud.chv.orphans import \
reconcile_orphans_for_executor as _reconcile
cleaned = _reconcile(executor) or []
except Exception as exc:
try:
self._audit(None, "chv_warm_pool_reconcile_failed",
"when=%s error=%s" % (when, exc))
except Exception:
pass
return []
if cleaned:
try:
self._audit(None, "chv_warm_pool_reconcile",
"when=%s cleaned=%s" % (when, ",".join(cleaned)))
except Exception:
pass
return cleaned
"""显式调生产残留清理 facade,返回已清理 VM 名
(to10:本体已移到 worker_adapter.reconcile_warm_
pool,此处只留薄包装,旧测试调用点不变)。"""
return _chv_worker_adapter.reconcile_warm_pool(
getattr(self, "executor", None), self._audit, when)
def close(self):
"""关闭惰性建的成本存储连接(注入的 cost_store 由调用方管)。
@@ -2479,61 +2438,22 @@ class Worker:
return
def _chv_cancel_watchdog(self, task_id, vm_name, stop_event):
"""CHV 同步路径的取消看门狗(feat-chv-fix,P1-2,Docker
取消看门狗同款模式):同步 SSH 阻塞时 worker 的 cancel()
只能改库、清 _running 项,阻塞中的 run 本身不会醒;
本线程每 1 秒查库/取消标志,命中时调 cancel_vm 真关
VM,然后退出。查库失败时当没取消,不误杀。"""
while not stop_event.wait(1.0):
try:
if task_id in self._cancel_requested:
cancelled = True
else:
db_task = self.get_task(task_id)
cancelled = (db_task is not None and
db_task.get("status") == "interrupted")
except Exception:
cancelled = False
if not cancelled:
continue
try:
cancel = getattr(self.executor, "cancel_vm", None)
if cancel is not None and vm_name:
cancel(vm_name)
log_event(self.logger, "worker_chv_killed",
task_id=task_id,
message="cancel watchdog killed VM %s "
"(task interrupted/cancelled)"
% vm_name)
except Exception:
pass
return
"""CHV 同步路径的取消看门狗(to10:循环本体已移到
worker_adapter.cancel_watchdog_loop,此处只传回调)。"""
_chv_worker_adapter.cancel_watchdog_loop(
task_id, vm_name, stop_event, self._cancel_requested,
self.get_task, getattr(self.executor, "cancel_vm", None),
self.logger)
def _chv_quota_watchdog(self, task_id, vm_name, stop_event,
quota_check, interval=30.0):
"""CHV 同步路径的配额看门狗(feat-chv-fix,P1-2,Docker
配额看门狗同款):命中时记 _quota_hit_tasks 并调
cancel_vm 真关 VM;_run_attempt 在同步返回后把结果
改写成 quota_exceeded,走统一配额中断收尾。"""
while not stop_event.wait(interval):
try:
over = bool(quota_check()) if quota_check else False
except Exception:
over = False
if not over:
continue
self._mark_quota_hit(task_id)
try:
cancel = getattr(self.executor, "cancel_vm", None)
if cancel is not None and vm_name:
cancel(vm_name)
log_event(self.logger, "worker_chv_killed",
task_id=task_id,
message="quota watchdog killed VM %s "
"(quota exceeded)" % vm_name)
except Exception:
pass
return
"""CHV 同步路径的配额看门狗(to10:循环本体已移到
worker_adapter.quota_watchdog_loop,此处只传回调)。"""
_chv_worker_adapter.quota_watchdog_loop(
task_id, vm_name, stop_event, quota_check,
self._mark_quota_hit,
getattr(self.executor, "cancel_vm", None),
self.logger, interval=interval)
def cleanup_stale_containers(self):
"""启动清理兜底:清掉上一轮残留的 codex- 容器。
@@ -4717,10 +4637,10 @@ class Worker:
# process 已轮询 quota_check)。
if isinstance(self.executor, CloudHypervisorExecutor) \
and hasattr(self.executor, "cancel_vm"):
try:
_chv_vm_name = self.executor.vm_name(task)
except Exception:
_chv_vm_name = None
# to10:VM 名派生已移到 worker_adapter.watchdog_vm_
# name(原 try/except 块逐字搬运)。
_chv_vm_name = _chv_worker_adapter.watchdog_vm_name(
self.executor, task)
if _chv_vm_name:
_watchdog_stop = threading.Event()
_watchdog_thread = threading.Thread(