diff --git a/chv-guest/check_lockfile.py b/chv-guest/check_lockfile.py
new file mode 100644
index 0000000..8fa22a4
--- /dev/null
+++ b/chv-guest/check_lockfile.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+"""校验已构建镜像的 BUILDINFO 供应链清单(事后 lockfile 检查)。
+
+事前 apt/pip 精确 lockfile 无法在本轨生成:apt 版本在构建
+宿主上解析、pip 版本在客机里解析,没有构建宿主就编不出
+版本号——本脚本绝不编造。构建时 build.sh 已从客机读回
+dpkg + pip freeze 写进 BUILDINFO.versions(事后 lockfile),
+本脚本校验它:versions 非空、tag 与预期一致、可选的冻结
+清单(--frozen JSON,形如 {"dpkg": ["pkg version", ...]})
+逐项比对,漂移即退出非 0。
+
+用法:
+ python3 check_lockfile.py out/BUILDINFO.json [--tag TAG]
+ [--frozen frozen.json]
+"""
+import json
+import sys
+
+
+def main(argv):
+ if len(argv) < 2:
+ print("用法:check_lockfile.py BUILDINFO.json [--tag TAG] "
+ "[--frozen frozen.json]", file=sys.stderr)
+ return 2
+ path = argv[1]
+ tag = None
+ frozen_path = None
+ i = 2
+ while i < len(argv):
+ if argv[i] == "--tag" and i + 1 < len(argv):
+ tag = argv[i + 1]
+ i += 2
+ elif argv[i] == "--frozen" and i + 1 < len(argv):
+ frozen_path = argv[i + 1]
+ i += 2
+ else:
+ print("未知参数:%s" % argv[i], file=sys.stderr)
+ return 2
+ with open(path) as fh:
+ data = json.load(fh)
+ if tag is not None and data.get("tag") != tag:
+ print("tag 不一致:%s != %s" % (data.get("tag"), tag),
+ file=sys.stderr)
+ return 1
+ versions = data.get("versions") or {}
+ if not versions.get("dpkg") or not versions.get("pip"):
+ print("BUILDINFO.versions.dpkg/pip 为空,供应链清单缺失",
+ file=sys.stderr)
+ return 1
+ if frozen_path:
+ with open(frozen_path) as fh:
+ frozen = json.load(fh)
+ for kind in ("dpkg", "pip"):
+ want = set(frozen.get(kind) or [])
+ got = set(versions.get(kind) or [])
+ missing = want - got
+ if missing:
+ print("%s 漂移:冻结清单里有、构建产物里无:%s"
+ % (kind, sorted(missing)), file=sys.stderr)
+ return 1
+ print("lockfile 校验通过:tag=%s dpkg=%d pip=%d"
+ % (data.get("tag"), len(versions["dpkg"]),
+ len(versions["pip"])))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv))
diff --git a/cloud/chv/api_client.py b/cloud/chv/api_client.py
index 64b7c6e..6cae561 100644
--- a/cloud/chv/api_client.py
+++ b/cloud/chv/api_client.py
@@ -9,12 +9,100 @@ import os
import socket
+def _read_header(sock):
+ """读满响应头:返回 (header_text, 已收体部前缀)。
+
+ 超时或提前关流即 RuntimeError,绝不把半截头当结束
+ (feat-chv-fix P1-6 的语义,纯移动)。"""
+ buf = b""
+ header_end = None
+ timed_out = False
+ while header_end is None:
+ try:
+ data = sock.recv(65536)
+ except socket.timeout:
+ timed_out = True
+ break
+ if not data:
+ break
+ buf += data
+ header_end = buf.find(b"\r\n\r\n")
+ if timed_out or header_end is None:
+ raise RuntimeError("CHV API 响应头不完整(超时或提前关流)")
+ header = buf[:header_end].decode("utf-8", errors="replace")
+ return header, buf[header_end + 4:]
+
+
+def _parse_content_length(header):
+ """从响应头取 Content-Length:chunked 或无长度即拒绝。
+
+ chunked 明确拒绝(CHV REST 不应发 chunked);无长度则
+ 无法证完整性,同样拒绝。"""
+ content_length = None
+ is_chunked = False
+ for line in header.split("\r\n")[1:]:
+ if ":" not in line:
+ continue
+ name, value = line.split(":", 1)
+ name = name.strip().lower()
+ value = value.strip()
+ if name == "content-length":
+ try:
+ content_length = int(value)
+ except ValueError:
+ raise RuntimeError("CHV API Content-Length 非法")
+ elif name == "transfer-encoding" \
+ and "chunked" in value.lower():
+ is_chunked = True
+ if is_chunked:
+ raise RuntimeError("CHV API 响应为 chunked,拒绝解析")
+ if content_length is None:
+ raise RuntimeError("CHV API 响应无 Content-Length,拒绝解析")
+ return content_length
+
+
+def _read_body(sock, body, content_length):
+ """按 Content-Length 读满体部:超时或提前关流即截断错误。"""
+ while len(body) < content_length:
+ try:
+ data = sock.recv(65536)
+ except socket.timeout:
+ raise RuntimeError(
+ "CHV API 响应体截断:已收 %d / 需 %d 字节"
+ % (len(body), content_length))
+ if not data:
+ raise RuntimeError(
+ "CHV API 响应体截断:已收 %d / 需 %d 字节"
+ % (len(body), content_length))
+ body += data
+ return body[:content_length]
+
+
+def _parse_response(header, body):
+ """从已读满的头与体部解析 (status, body):能解析 JSON
+ 时为 dict,否则为原文本;状态行解析失败时 status 为 0。"""
+ text = header + "\r\n\r\n" + body.decode("utf-8", errors="replace")
+ status = 0
+ if text.startswith("HTTP/"):
+ try:
+ status = int(text.split()[1])
+ except (IndexError, ValueError):
+ status = 0
+ body_text = body.decode("utf-8", errors="replace")
+ try:
+ parsed = json.loads(body_text) if body_text.strip() else {}
+ except ValueError:
+ parsed = body_text
+ return status, parsed
+
+
def _unix_socket_api(socket_path, method, path, payload=None):
"""Cloud Hypervisor REST API 的默认传输:标准库 raw HTTP
over Unix socket(无第三方依赖)。返回 (status, body),
body 能解析 JSON 时为 dict,否则为原文本。socket 不存在
或连接失败时 raise RuntimeError(调用方据此判 api 超时
- /失败,不编造 200)。"""
+ /失败,不编造 200)。读满语义见 _read_header /
+ _parse_content_length / _read_body(P1-6,纯移动)。"""
if not os.path.exists(socket_path):
raise RuntimeError("api socket 不存在:%s" % socket_path)
body = b""
@@ -32,79 +120,12 @@ def _unix_socket_api(socket_path, method, path, payload=None):
try:
sock.connect(socket_path)
sock.sendall(raw)
- chunks = []
- # feat-chv-fix(P1-6):读超时不再当“响应结束”。
- # 先读满响应头,再按 Content-Length 读满体部;超时、
- # 提前关流、体部短于 Content-Length 一律 RuntimeError
- # (调用方判 api_timeout / 传输失败),绝不解析半截
- # 响应。chunked 与无 Content-Length 响应明确拒绝
- # (Cloud Hypervisor REST 不应发 chunked;无长度则
- # 无法证完整性)。
- buf = b""
- header_end = None
- timed_out = False
- while header_end is None:
- try:
- data = sock.recv(65536)
- except socket.timeout:
- timed_out = True
- break
- if not data:
- break
- buf += data
- header_end = buf.find(b"\r\n\r\n")
- if timed_out or header_end is None:
- raise RuntimeError("CHV API 响应头不完整(超时或提前关流)")
- header = buf[:header_end].decode("utf-8", errors="replace")
- body = buf[header_end + 4:]
- content_length = None
- is_chunked = False
- for line in header.split("\r\n")[1:]:
- if ":" not in line:
- continue
- name, value = line.split(":", 1)
- name = name.strip().lower()
- value = value.strip()
- if name == "content-length":
- try:
- content_length = int(value)
- except ValueError:
- raise RuntimeError("CHV API Content-Length 非法")
- elif name == "transfer-encoding" \
- and "chunked" in value.lower():
- is_chunked = True
- if is_chunked:
- raise RuntimeError("CHV API 响应为 chunked,拒绝解析")
- if content_length is None:
- raise RuntimeError("CHV API 响应无 Content-Length,拒绝解析")
- while len(body) < content_length:
- try:
- data = sock.recv(65536)
- except socket.timeout:
- raise RuntimeError(
- "CHV API 响应体截断:已收 %d / 需 %d 字节"
- % (len(body), content_length))
- if not data:
- raise RuntimeError(
- "CHV API 响应体截断:已收 %d / 需 %d 字节"
- % (len(body), content_length))
- body += data
- body = body[:content_length]
+ header, body = _read_header(sock)
+ content_length = _parse_content_length(header)
+ body = _read_body(sock, body, content_length)
finally:
sock.close()
- text = header + "\r\n\r\n" + body.decode("utf-8", errors="replace")
- status = 0
- if text.startswith("HTTP/"):
- try:
- status = int(text.split()[1])
- except (IndexError, ValueError):
- status = 0
- body_text = body.decode("utf-8", errors="replace")
- try:
- parsed = json.loads(body_text) if body_text.strip() else {}
- except ValueError:
- parsed = body_text
- return status, parsed
+ return _parse_response(header, body)
def normalize_api_result(result) -> tuple:
diff --git a/cloud/chv/compat215.py b/cloud/chv/compat215.py
new file mode 100644
index 0000000..35c4adb
--- /dev/null
+++ b/cloud/chv/compat215.py
@@ -0,0 +1,59 @@
+"""#215(feat-arch-converge)接口对齐层,CHV 本地实现。
+
+背景:#215 未合并,本基线里没有 cloud/timeouts.py、
+cloud/gates.py、cloud/quota.py、cloud/sandbox_policy.py。
+本模块绝不复制 #215 的文件,只把 CHV 真正能映射的
+接口写成与 #215 相同的签名,#215 合并后替换为 import
+即是机械的(见 docs/chv-architecture.md ADR-8)。
+
+映射表(逐个核对过 #215 远程分支的真实函数,不是凭空编的):
+- gates.resolve_inside(base, rel):目录门,CHV 快照/磁盘
+ 路径白名单正好需要它——本模块实现同语义并已在
+ hardening.validate_snapshot_url 里使用,#215 合并后
+ 改为 from cloud.gates import resolve_inside 即可。
+- sandbox_policy.tmpfs_isolated(executor_kind, path):CHV
+ 客机 /tmp 落在客机磁盘上,不是宿主 tmpfs,故对
+ cloud-hypervisor 一律返回 (False, ...)——fail-closed,
+ 绝不宣称隔离。未知执行器同样 fail-closed。
+- timeouts.resolve_module_timeout / quota.decide:无映射。
+ 前者是审批/队列超时(None=不过期、0=不过期),CHV
+ 的 api/ready 超时是秒数且 0 无效,语义不同,硬套会
+ 改行为;后者是按月美元配额,CHV 的配额是 VM 数/磁盘
+ GB/快照数,单位不同。两者在本模块里 deliberately
+ 不提供,测试钉住“CHV 不 import 它们”。
+
+本模块只依赖标准库,是叶子模块,不 import cloud.sandbox。
+"""
+
+import os
+
+
+def resolve_inside(base, rel):
+ """目录门:base 内的相对路径安全解析,越界回 None。
+
+ 空串回 base 本身;绝对路径、.. 逃逸、符号链接逃逸、
+ commonpath 跨盘符 ValueError 一律 None。签名与 #215
+ 的 gates.resolve_inside 一致,合并后可直接替换为 import。"""
+ if not rel:
+ return base
+ if os.path.isabs(rel):
+ return None
+ real_base = os.path.realpath(base)
+ target = os.path.realpath(os.path.join(real_base, rel))
+ try:
+ if os.path.commonpath([real_base, target]) != real_base:
+ return None
+ except ValueError:
+ return None
+ return target
+
+
+def tmpfs_isolated(executor_kind, path):
+ """某执行器下某路径是否走 tmpfs 隔离:(isolated, reason)。
+
+ cloud-hypervisor:客机 /tmp 落在客机磁盘上,不走宿主
+ tmpfs,一律 (False, ...);未知执行器 fail-closed 同样
+ False。签名与 #215 的 sandbox_policy.tmpfs_isolated 一致。"""
+ if executor_kind == "cloud-hypervisor":
+ return False, "CHV 客机 /tmp 落在客机磁盘上,不走宿主 tmpfs"
+ return False, "未知执行器,按不隔离处理(fail-closed 口径)"
diff --git a/cloud/chv/config.py b/cloud/chv/config.py
index 78c947b..48ee740 100644
--- a/cloud/chv/config.py
+++ b/cloud/chv/config.py
@@ -170,45 +170,21 @@ def _timeout_fragment(params: dict, env: dict) -> dict:
}
-def build_chv_config(params: dict, env: dict):
- """把显式构造参数(params,None=未给)与环境变量(env,
- 默认为 os.environ 的调用方传入 dict)解析成 ChvConfig。
+def _assemble_chv_config(base: dict, ssh: dict, quota: dict,
+ timeouts: dict):
+ """把基础字段 + SSH/配额/超时三个片段组装成 ChvConfig。
- 显式参数只有 __init__ 本来就有的那些;max_vms、磁盘模式、
- 磁盘配额、egress、快照、预热池目标数本来就没有显式参数,
- 仍只走环境变量(与 #216 一致,不悄悄加新参数面)。
- 片段化(feat-chv-arch95):SSH 面、配额面、超时面分别
- 由 _ssh_fragment/_quota_fragment/_timeout_fragment 解析,
- 错误文案与校验顺序逐字一致;build_chv_config 只组装。"""
- env = env if env is not None else {}
- binary = params.get("binary") or env.get("CHV_BINARY") \
- or "cloud-hypervisor"
- kernel = params.get("kernel") or env.get("CHV_KERNEL")
- rootfs = params.get("rootfs") or params.get("image") \
- or env.get("CHV_ROOTFS") or env.get("CHV_IMAGE")
- cpus = str(params.get("cpus") or env.get("CHV_CPU") or "2")
- memory = params.get("memory") or env.get("CHV_MEM") or "2G"
- state_dir = params.get("state_dir") or env.get("CHV_STATE_DIR") \
- or os.path.join(os.path.expanduser("~"),
- ".local", "share", "codex-app-server", "chv-vms")
- kvm_path = params.get("kvm_path") \
- if params.get("kvm_path") is not None else "/dev/kvm"
- cpuinfo_path = params.get("cpuinfo_path") \
- if params.get("cpuinfo_path") is not None else "/proc/cpuinfo"
- ch_remote = params.get("ch_remote") or env.get("CHV_REMOTE") \
- or "ch-remote"
- model = params.get("model")
- ssh = _ssh_fragment(params, env)
- quota = _quota_fragment(env)
- timeouts = _timeout_fragment(params, env)
+ 纯移动:字段名与取值逐字不变,build_chv_config 只解析。"""
return ChvConfig(
- binary=binary, kernel=kernel, rootfs=rootfs, cpus=cpus,
- memory=memory, state_dir=state_dir, ssh_key=ssh["ssh_key"],
- ssh_user=ssh["ssh_user"], ssh_port=ssh["ssh_port"],
+ binary=base["binary"], kernel=base["kernel"],
+ rootfs=base["rootfs"], cpus=base["cpus"],
+ memory=base["memory"], state_dir=base["state_dir"],
+ ssh_key=ssh["ssh_key"], ssh_user=ssh["ssh_user"],
+ ssh_port=ssh["ssh_port"],
guest_host_py=ssh["guest_host_py"], cmdline=ssh["cmdline"],
net=ssh["net"], net_json=ssh["net_json"],
- kvm_path=kvm_path, cpuinfo_path=cpuinfo_path,
- ch_remote=ch_remote, model=model,
+ kvm_path=base["kvm_path"], cpuinfo_path=base["cpuinfo_path"],
+ ch_remote=base["ch_remote"], model=base["model"],
max_vms=quota["max_vms"], disk_mode=quota["disk_mode"],
disk_gb=quota["disk_gb"], ssh_host=ssh["ssh_host"],
ssh_forward=ssh["ssh_forward"],
@@ -228,3 +204,32 @@ def build_chv_config(params: dict, env: dict):
snapshot_retention_days=quota["snapshot_retention_days"],
snapshot_dir=quota["snapshot_dir"],
allow_remote_snapshot=quota["allow_remote_snapshot"])
+
+
+def build_chv_config(params: dict, env: dict):
+ """把显式构造参数与环境变量解析成 ChvConfig(见
+ _assemble_chv_config 的组装段;片段化与校验顺序不变)。"""
+ env = env if env is not None else {}
+ base = {
+ "binary": params.get("binary") or env.get("CHV_BINARY")
+ or "cloud-hypervisor",
+ "kernel": params.get("kernel") or env.get("CHV_KERNEL"),
+ "rootfs": params.get("rootfs") or params.get("image")
+ or env.get("CHV_ROOTFS") or env.get("CHV_IMAGE"),
+ "cpus": str(params.get("cpus") or env.get("CHV_CPU") or "2"),
+ "memory": params.get("memory") or env.get("CHV_MEM") or "2G",
+ "state_dir": params.get("state_dir")
+ or env.get("CHV_STATE_DIR")
+ or os.path.join(os.path.expanduser("~"),
+ ".local", "share", "codex-app-server", "chv-vms"),
+ "kvm_path": params.get("kvm_path")
+ if params.get("kvm_path") is not None else "/dev/kvm",
+ "cpuinfo_path": params.get("cpuinfo_path")
+ if params.get("cpuinfo_path") is not None else "/proc/cpuinfo",
+ "ch_remote": params.get("ch_remote") or env.get("CHV_REMOTE")
+ or "ch-remote",
+ "model": params.get("model"),
+ }
+ return _assemble_chv_config(
+ base, _ssh_fragment(params, env), _quota_fragment(env),
+ _timeout_fragment(params, env))
diff --git a/cloud/chv/executor.py b/cloud/chv/executor.py
index 21f3198..5c06379 100644
--- a/cloud/chv/executor.py
+++ b/cloud/chv/executor.py
@@ -289,6 +289,7 @@ class CloudHypervisorExecutor(Executor):
else _unix_socket_api
self.sleeper = sleeper if sleeper is not None else time.sleep
self.audit_callback = None
+ self.audit_failure_count = 0
self._vms_lock = threading.Lock()
if runner is not None:
self.runner = runner
@@ -602,9 +603,15 @@ class CloudHypervisorExecutor(Executor):
feat-chv-secfix(M5):审计回调失败不再双重静默
pass——记宿主 error 日志(审计完整性本身没有保证,
但失败至少可见;任务仍不因此炸,审计失败不改变
- 任务状态是 worker 层的既定语义)。审计 detail 里
- 绝不含密钥值(调用方负责:本类所有 _emit 的 detail
- 只含 VM 名、路径、URL、kind,不含任何 key 值)。"""
+ 任务状态是 worker 层的既定语义)。feat-chv-arch95plus
+ 取舍(见 ADR-9):不改成“审计失败即中断任务”——
+ 审计是可观测性不是门禁,DB 短暂不可用时硬中断会把
+ 一次审计失败放大成任务失败,且与 worker 全局
+ best-effort 语义冲突;改为“失败记日志 + 计数 + 不
+ 中断”,计数见 self.audit_failure_count(宿主可观测,
+ 非任务状态)。审计 detail 里绝不含密钥值(调用方
+ 负责:本类所有 _emit 的 detail 只含 VM 名、路径、
+ URL、kind,不含任何 key 值)。"""
try:
if self.audit_callback is not None:
self.audit_callback(event, task, detail, kind)
@@ -612,6 +619,8 @@ class CloudHypervisorExecutor(Executor):
_LOGGER.info("chv_audit event=%s kind=%s %s",
event, kind, detail)
except Exception as exc:
+ self.audit_failure_count = getattr(
+ self, "audit_failure_count", 0) + 1
_LOGGER.error("chv_audit_failed event=%s kind=%s error=%s",
event, kind, exc)
@@ -981,21 +990,37 @@ class CloudHypervisorExecutor(Executor):
"vmm.ping 超时:%d 次内 API 未通,socket=%s"
% (attempts, socket_path))
- def wait_guest_ready(self, task):
- """等 guest SSH 就绪(只选 SSH,见类 docstring):
- 轮询 ssh … "true",returncode 0 即就绪;次数 =
- ready_timeout/ready_interval 取整,至少 1 次。
- 每轮先尽力捕获 host key(known_hosts 文件不存在时;
- sshd 未起时抓不到是常态,下轮再试),再发 ssh
- "true"——钉死式 SSH 在文件缺失时会直接拒绝未知主机,
- 不先捕获就永远等不到就绪。超时时先关 VM 再 raise
- ChvFailure(kind=guest_not_ready),绝不在未就绪时
- 派任务。"""
- # feat-chv-fix(P0-2):provisioned 模式下缺 known_hosts
- # 时直接 fail-fast,绝不调用 capture_host_key 回落 TOFU。
- # (keyscan 模式才走下面的捕获分支。)
+ def _provisioned_key_guard(self, task):
+ """provisioned 下缺 known_hosts 即 fail-fast,绝不 keyscan。
+
+ 返回 True 表示已拦截(调用方须先关 VM 再抛
+ host_key_not_pinned);False 表示可继续轮询。"""
if getattr(self, "host_key_mode", "provisioned") == "provisioned" \
and not os.path.exists(self.known_hosts_path(task)):
+ return True
+ return False
+
+ def _check_host_key_mismatch(self, task, result):
+ """SSH stderr 含钉死拒绝标记时审计并返回 True(须 fail-fast)。
+
+ MITM 尝试是独立安全事件,不轮询成普通超时;审计
+ detail 只含 VM 名与标记,不含 key 值。"""
+ stderr = getattr(result, "stderr", "") or ""
+ if "Host key verification failed" in stderr \
+ or "REMOTE HOST IDENTIFICATION" in stderr:
+ self._emit("host_key_mismatch", task,
+ "vm=%s ssh host key 被拒绝(钉死不匹配)"
+ % self.vm_name(task), "host_key_mismatch")
+ return True
+ return False
+
+ def wait_guest_ready(self, task):
+ """等 guest SSH 就绪:轮询 ssh "true",returncode 0 即就绪;
+ 每轮先尽力捕获 host key(keyscan 模式),超时先关 VM
+ 再 raise guest_not_ready,绝不在未就绪时派任务。"""
+ # P0-2:provisioned 下缺 known_hosts 即 fail-fast,绝不
+ # 调用 capture_host_key 回落 TOFU(keyscan 才走捕获分支)。
+ if self._provisioned_key_guard(task):
try:
self.stop_vm(task)
except Exception:
@@ -1013,17 +1038,9 @@ class CloudHypervisorExecutor(Executor):
self.build_ssh_command(task, ["true"]))
if getattr(result, "returncode", 1) == 0:
return True
- # feat-chv-secfix(M5):host key 不匹配是独立
- # 安全事件,不是“sshd 没起”。SSH stderr 含
- # 钉死拒绝标记时立即审计 host_key_mismatch 并
- # fail-fast(不把 MITM 尝试轮询成普通超时),
- # 审计 detail 只含 VM 名与标记,不含 key 值。
- stderr = getattr(result, "stderr", "") or ""
- if "Host key verification failed" in stderr \
- or "REMOTE HOST IDENTIFICATION" in stderr:
- self._emit("host_key_mismatch", task,
- "vm=%s ssh host key 被拒绝(钉死不匹配)"
- % self.vm_name(task), "host_key_mismatch")
+ # M5:host key 不匹配是独立安全事件,见
+ # _check_host_key_mismatch(审计 + fail-fast)。
+ if self._check_host_key_mismatch(task, result):
try:
self.stop_vm(task)
except Exception:
@@ -1394,58 +1411,60 @@ class CloudHypervisorExecutor(Executor):
% (self.vm_name(task_or_name), snapshot_url))
return snapshot_url
- def restore_vm(self, task, snapshot_url):
- """快照恢复唤醒:preflight → 只起 VMM(--api-socket)
- → 等 vmm.ping → ch-remote restore → 显式 resume →
- 等 guest SSH 就绪。恢复后的 VM 默认是 paused,不
- resume 就不算醒——本方法绝不省略 resume 那一步。
- 任一步失败都关 VM 再抛。成功返回 {vm, api_socket,
- state: "running", ready: True, mode: "restored"}。"""
+ def _validate_restore(self, task, snapshot_url):
+ """restore 前置校验:返回归一化后的 snapshot_url。
+
+ restore 不走 vm.create、没有 VmConfig 可挂 seed:
+ 快照内已固化原 VM host key,重预置反而破坏钉死,
+ 故 provisioned 下复用已钉死的 known_hosts、缺失
+ fail-fast、绝不 keyscan、绝不重生成。restore 时不
+ 重复验快照文件存在(远端/对象存储源合法,ch-remote
+ 本身会在源缺失时失败)。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
if not snapshot_url:
raise ValueError("snapshot_url 不能为空")
if "://" not in snapshot_url:
snapshot_url = "file://%s" % snapshot_url
- # feat-chv-secfix(L2):恢复源同样过 URL 白名单
- # (file:// 须在白名单目录内,远端须显式放行),
- # 在 preflight/起 VMM 之前就拒绝。
validate_snapshot_url(
snapshot_url, self.state_dir,
snapshot_dir=getattr(self, "snapshot_dir", None),
allow_remote=getattr(self, "allow_remote_snapshot", False))
self.preflight()
self.resolve_ssh_host()
- # feat-chv-fix(P0-2 修正版):restore 不走 vm.create,
- # 没有 VmConfig 可挂 seed——审计报告“restore 须把 seed
- # 接入 VmConfig”的说法在此不适用,纠正为:快照内已
- # 固化原 VM 的 host key,重新预置新 key 反而会破坏
- # 钉死。正确不变量是:provisioned 下复用已钉死的
- # known_hosts(stop 时公钥保留、私钥已删但快照内仍有
- # 原私钥),缺失时 fail-fast、绝不 keyscan、绝不重
- # 生成。file:// 快照须先验存在且非空,否则不启动。
- # 注意:restore 时不重复验快照文件存在——快照落盘
- # 校验是 snapshot_vm/hibernate_vm 创建时的门(P1-7),
- # restore 的 ch-remote 本身会在源缺失时失败;在这里
- # 再验一次会把“源在远端/对象存储”的合法路径也挡掉。
- if getattr(self, "host_key_mode", "provisioned") == "provisioned" \
- and not os.path.exists(self.known_hosts_path(task)):
+ if self._provisioned_key_guard(task):
raise ChvFailure(
"host_key_not_pinned",
"provisioned 模式下 restore 缺 known_hosts,拒绝 "
"keyscan 回落:VM %s" % self.vm_name(task))
+ return snapshot_url
+
+ def _restore_boot(self, task, snapshot_url):
+ """restore 的启动段:起 VMM → 等 ping → restore →
+ 显式 resume → 等 SSH 就绪(恢复后默认 paused,不
+ resume 就不算醒)。"""
+ name = self.vm_name(task)
+ self._launch_api_only(task)
+ self.wait_api_ready(task)
+ self._check_api_socket_permissions(self.api_socket(task))
+ self._ch_remote(task, "restore",
+ "source_url=%s" % snapshot_url)
+ self._emit("vm_restore", task,
+ "vm=%s snapshot=%s" % (name, snapshot_url))
+ self.resume_vm(task)
+ self.wait_guest_ready(task)
+
+ def restore_vm(self, task, snapshot_url):
+ """快照恢复唤醒:校验 → 只起 VMM → restore → 显式
+ resume → 等 SSH 就绪。恢复后默认 paused,不 resume
+ 就不算醒。任一步失败都关 VM 再抛;成功返回
+ {vm, api_socket, state: "running", ready: True,
+ mode: "restored"}。"""
+ snapshot_url = self._validate_restore(task, snapshot_url)
name = self.vm_name(task)
self._check_capacity(exclude=name)
try:
- self._launch_api_only(task)
- self.wait_api_ready(task)
- self._check_api_socket_permissions(self.api_socket(task))
- self._ch_remote(task, "restore",
- "source_url=%s" % snapshot_url)
- self._emit("vm_restore", task,
- "vm=%s snapshot=%s" % (name, snapshot_url))
- self.resume_vm(task)
- self.wait_guest_ready(task)
+ self._restore_boot(task, snapshot_url)
except Exception:
try:
self.stop_vm(task)
@@ -1476,58 +1495,26 @@ class CloudHypervisorExecutor(Executor):
说明来源是休眠快照。"""
return self.restore_vm(task, snapshot_url)
- def stop_vm(self, task_or_name):
- """停止 VM(尽力而为,不抛异常;三种情况):
- - 已死进程:登记进程 poll() 非 None,跳过 terminate
- 等待,直接删 socket、清登记,返回 True。
- - 已关 VM:无登记进程、无 socket,ch-remote 仍尽力
- 调过一次,返回 False(本来就没有什么可清)。
- - socket 残留:有 socket、无登记进程,删 socket 并
- 返回 True。
- ch-remote shutdown 失败不炸(VM 可能已死),但
- ch-remote 命令本身仍会发出——测试据此断言关闭路径
- 确实用了 ch-remote shutdown,而不是只杀进程。"""
- name = self.vm_name(task_or_name)
- socket_path = os.path.join(self.state_dir, name + ".sock")
+ def _stop_registered_proc(self, name, proc):
+ """清登记进程:已死直接清,活着先杀再清(纯移动)。"""
+ if proc is None:
+ return
with self._vms_lock:
- had_proc = name in self._vms
- proc = self._vms.get(name)
- had = had_proc or os.path.exists(socket_path)
+ self._vms.pop(name, None)
try:
- self.runner([self.ch_remote,
- "--api-socket=%s" % socket_path, "shutdown"])
+ alive = proc.poll() is None
except Exception:
- pass
- if proc is not None:
- with self._vms_lock:
- self._vms.pop(name, None)
- try:
- alive = proc.poll() is None
- except Exception:
- alive = False
- if alive:
- self._kill_proc(proc)
- try:
- if os.path.exists(socket_path):
- os.remove(socket_path)
- except Exception:
- pass
- # 补缺口轨:per-VM seed 目录与 host 私钥随 VM
- # 关闭而删,绝不留给下一个 VM 复用;known_hosts
- # 公钥文件保留供事后审计,删失败不炸关闭。下次
- # 同名 VM 预置时若私钥已删即重新生成并覆盖
- # known_hosts,不会用旧钉死去校验新 VM。
- # feat-chv-secfix(H3/M2):清理分三块独立记账——
- # seed/私钥块、任务磁盘块、net 登记块,任一块失败
- # 都记对应审计 + 写脏标记(清理失败即拒起),绝不
- # 再以“一块失败、整段吞掉”收场;任务磁盘块是 H3
- # 的主修复:stop 即删 disk.raw(保留期 0;需取证
- # 须在 shutdown 前经 SSH 拷出,见 recycle_vm 文档)。
- # net.json 是 live 预约(非审计 artifact),随 VM
- # 关闭而删,tap/mac/ip 预约随即释放。
- _task = task_or_name if isinstance(task_or_name, dict) \
- else {"id": task_or_name}
- _cleanup_failed = False
+ alive = False
+ if alive:
+ self._kill_proc(proc)
+
+ def _cleanup_vm_files(self, name, task):
+ """stop 的三块文件清理:seed/私钥、任务磁盘、net 登记。
+
+ 任一块失败记对应审计并返回 True(脏标记由调用方写);
+ seed/私钥与磁盘删除的审计名与文案逐字不变。"""
+ from cloud.chv.hostkeys import dirty_marker_path # noqa: F401
+ failed = False
try:
seed_dir = os.path.join(self.state_dir, name, "seed")
if os.path.isdir(seed_dir):
@@ -1539,45 +1526,69 @@ class CloudHypervisorExecutor(Executor):
if os.path.exists(_p):
os.remove(_p)
except Exception as _cleanup_exc:
- _cleanup_failed = True
- self._emit("vm_key_cleanup_failed", _task,
+ failed = True
+ self._emit("vm_key_cleanup_failed", task,
"vm=%s error=%s" % (name, _cleanup_exc))
try:
if delete_task_disk(self.state_dir, name):
- self._emit("vm_disk_deleted", _task,
+ self._emit("vm_disk_deleted", task,
"vm=%s disk deleted on stop" % name)
except Exception as _disk_exc:
- _cleanup_failed = True
- self._emit("vm_disk_delete_failed", _task,
+ failed = True
+ self._emit("vm_disk_delete_failed", task,
"vm=%s error=%s" % (name, _disk_exc))
try:
_net_path = os.path.join(self.state_dir, name, "net.json")
if os.path.exists(_net_path):
os.remove(_net_path)
except Exception:
- _cleanup_failed = True
+ failed = True
self._guest_env_delivered.pop(name, None)
- if _cleanup_failed:
- # M2 根治:脏标记。标记本身写失败也不炸关闭,
- # 但下次 provision_host_key 仍会因残留文件复用
- # 旧 key——该残留情形由 cleanup_stale_secrets 的
- # 扫描兜底(见 cleanup_stale_vms),文档明写。
- try:
- from cloud.chv.hostkeys import dirty_marker_path
- _marker = dirty_marker_path(self.state_dir, name)
+ return failed
+
+ def _write_dirty_marker(self, name, failed):
+ """M2 脏标记:清理失败写标记、成功清标记;写失败不炸关闭。"""
+ from cloud.chv.hostkeys import dirty_marker_path
+ try:
+ _marker = dirty_marker_path(self.state_dir, name)
+ if failed:
os.makedirs(os.path.dirname(_marker), exist_ok=True)
with open(_marker, "w") as fh:
fh.write("cleanup failed\n")
- except Exception:
- pass
- else:
- try:
- from cloud.chv.hostkeys import dirty_marker_path
- _marker = dirty_marker_path(self.state_dir, name)
- if os.path.exists(_marker):
- os.remove(_marker)
- except Exception:
- pass
+ elif os.path.exists(_marker):
+ os.remove(_marker)
+ except Exception:
+ pass
+
+ def stop_vm(self, task_or_name):
+ """停止 VM(尽力而为,不抛异常):已死进程直接清登记,
+ 已关 VM 仍尽力调一次 ch-remote(返回 False),socket
+ 残留删 socket(返回 True)。ch-remote shutdown 失败
+ 不炸,但命令本身仍会发出(测试据此断言关闭路径)。"""
+ name = self.vm_name(task_or_name)
+ socket_path = os.path.join(self.state_dir, name + ".sock")
+ with self._vms_lock:
+ had_proc = name in self._vms
+ proc = self._vms.get(name)
+ had = had_proc or os.path.exists(socket_path)
+ try:
+ self.runner([self.ch_remote,
+ "--api-socket=%s" % socket_path, "shutdown"])
+ except Exception:
+ pass
+ self._stop_registered_proc(name, proc)
+ try:
+ if os.path.exists(socket_path):
+ os.remove(socket_path)
+ except Exception:
+ pass
+ # per-VM seed 目录与 host 私钥随 VM 关闭而删,绝不留给
+ # 下一个 VM 复用;known_hosts 公钥保留供事后审计。
+ # 三块清理独立记账 + 脏标记(H3/M2,见 _cleanup_vm_files)。
+ _task = task_or_name if isinstance(task_or_name, dict) \
+ else {"id": task_or_name}
+ _cleanup_failed = self._cleanup_vm_files(name, _task)
+ self._write_dirty_marker(name, _cleanup_failed)
task = task_or_name if isinstance(task_or_name, dict) \
else {"id": task_or_name}
if had:
@@ -1624,12 +1635,49 @@ class CloudHypervisorExecutor(Executor):
"vm=%s gc error=%s" % (entry, exc))
return {"deleted": deleted, "failed": failed}
+ def _is_live_vm(self, entry):
+ """VM 是否活着:socket 存在或登记进程未死即活。"""
+ if os.path.exists(os.path.join(self.state_dir, entry + ".sock")):
+ return True
+ with self._vms_lock:
+ proc = self._vms.get(entry)
+ if proc is not None:
+ try:
+ return proc.poll() is None
+ except Exception:
+ pass
+ return False
+
+ def _clean_one_vm_secrets(self, entry, vm_dir):
+ """单 VM 残留清理:私钥一律删(live VM 私钥在 seed 盘
+ 建好后即删,残留只可能是崩溃遗留);非 live VM 的
+ seed.iso/seed 目录同理删。返回 (删私钥, 删 seed)。"""
+ removed_priv = False
+ priv = os.path.join(vm_dir, "ssh_host_ed25519_key")
+ if os.path.exists(priv):
+ try:
+ os.remove(priv)
+ removed_priv = True
+ except Exception:
+ pass
+ removed_seed = False
+ if not self._is_live_vm(entry):
+ for _suffix in ("seed.iso", "seed"):
+ _p = os.path.join(vm_dir, _suffix)
+ try:
+ if os.path.isdir(_p):
+ shutil.rmtree(_p)
+ removed_seed = True
+ elif os.path.exists(_p):
+ os.remove(_p)
+ removed_seed = True
+ except Exception:
+ pass
+ return removed_priv, removed_seed
+
def cleanup_stale_secrets(self):
- """崩溃残留扫描(H5):state_dir 下的 host 私钥文件
- 在本轨不变量下只可能是残留——live VM 的私钥在 seed
- 盘建好后即删(见 wake_vm),所以任何残留私钥文件
- 一律删除并审计;非 live VM 的 seed.iso/seed 目录同
- 理。返回已清理的 VM 名列表。worker 启动与
+ """崩溃残留扫描(H5):私钥一律删、非 live VM 的 seed
+ 同理删并审计;返回已清理 VM 名。worker 启动与
cleanup_stale_vms 时调它,不依赖 socket 是否存在。"""
cleaned = []
try:
@@ -1642,36 +1690,8 @@ class CloudHypervisorExecutor(Executor):
vm_dir = os.path.join(self.state_dir, entry)
if not os.path.isdir(vm_dir):
continue
- priv = os.path.join(vm_dir, "ssh_host_ed25519_key")
- removed_priv = False
- if os.path.exists(priv):
- try:
- os.remove(priv)
- removed_priv = True
- except Exception:
- pass
- live = os.path.exists(
- os.path.join(self.state_dir, entry + ".sock"))
- with self._vms_lock:
- proc = self._vms.get(entry)
- if proc is not None:
- try:
- live = live or proc.poll() is None
- except Exception:
- pass
- removed_seed = False
- if not live:
- for _suffix in ("seed.iso", "seed"):
- _p = os.path.join(vm_dir, _suffix)
- try:
- if os.path.isdir(_p):
- shutil.rmtree(_p)
- removed_seed = True
- elif os.path.exists(_p):
- os.remove(_p)
- removed_seed = True
- except Exception:
- pass
+ removed_priv, removed_seed = \
+ self._clean_one_vm_secrets(entry, vm_dir)
if removed_priv or removed_seed:
cleaned.append(entry)
self._emit("host_secret_residue_cleaned",
@@ -1712,32 +1732,11 @@ class CloudHypervisorExecutor(Executor):
"lost": ["guest /tmp", "guest worktree temp files"],
}
- def cleanup_stale_vms(self):
- """清理残留 VM:只清 socket 残留与已死进程对应的 VM。
+ def _stop_dead_socket_vms(self, entries):
+ """socket 扫描:登记进程仍活的跳过,其余走 stop_vm。
- feat-chv-secfix(H3/H5):先跑残留盘 GC 与残留
- 密钥扫描(不依赖 socket 是否存在,覆盖 worker 崩溃
- 后 socket 已删、文件仍在的情形),再走原 socket 扫描。
- 扫描 state_dir 下 *.sock:登记进程仍活(poll() 为 None)
- 的跳过;其余一律走 stop_vm。返回已清理的 VM 名列表;
- state_dir 不存在时返回 [],绝不抛异常。基础设施或
- 其它执行器的 socket 不在 state_dir 里,不会被误删;
- 同目录下非 chv- 前缀的 socket 也跳过(只认本执行器
- 的 chv- 命名)。
- """
+ 返回已清理 VM 名;非 chv- 前缀 socket 一律跳过。"""
cleaned = []
- try:
- self.cleanup_stale_secrets()
- except Exception:
- pass
- try:
- self.gc_stale_disks()
- except Exception:
- pass
- try:
- entries = os.listdir(self.state_dir)
- except Exception:
- return []
for entry in entries:
if not entry.endswith(".sock"):
continue
@@ -1754,9 +1753,11 @@ class CloudHypervisorExecutor(Executor):
continue
if self.stop_vm(name):
cleaned.append(name)
- # 登记进程已死但 socket 文件已被删的情况:登记项本身
- # 也是残留,顺手清掉登记,不计入 cleaned(没有 socket
- # 可删,不算一次清理)。
+ return cleaned
+
+ def _purge_dead_registry(self):
+ """登记进程已死但 socket 已被删的登记项顺手清掉
+ (没有 socket 可删,不计入清理数)。"""
for name in list(self._vms.keys()):
proc = self._vms.get(name)
try:
@@ -1765,6 +1766,25 @@ class CloudHypervisorExecutor(Executor):
dead = True
if dead:
self._vms.pop(name, None)
+
+ def cleanup_stale_vms(self):
+ """清理残留 VM:先跑残留盘 GC 与残留密钥扫描,再走
+ socket 扫描与登记清理。只清 socket 残留与已死进程
+ 对应的 VM;state_dir 不存在返回 [],绝不抛异常。"""
+ try:
+ self.cleanup_stale_secrets()
+ except Exception:
+ pass
+ try:
+ self.gc_stale_disks()
+ except Exception:
+ pass
+ try:
+ entries = os.listdir(self.state_dir)
+ except Exception:
+ return []
+ cleaned = self._stop_dead_socket_vms(entries)
+ self._purge_dead_registry()
return cleaned
# -- 客内执行 ------------------------------------------------------
diff --git a/cloud/chv/hardening.py b/cloud/chv/hardening.py
index 015fc10..21a60a8 100644
--- a/cloud/chv/hardening.py
+++ b/cloud/chv/hardening.py
@@ -99,33 +99,47 @@ def validate_net_entries(entries: list, existing_entries: list) -> list:
vm})的 tap/mac/ip 任一重复即 net_tap_conflict,
拒绝启动并由调用方审计。
返回归一化后的条目 list。"""
- seen_taps, seen_macs, seen_ips = set(), set(), set()
- normalized = []
- for entry in entries:
- unknown = set(entry) - _NET_FIELDS
- if unknown:
- raise ChvFailure(
- "net_config_invalid",
- "CHV_NET_JSON 条目含未知字段:%s(白名单:%s)"
- % (",".join(sorted(unknown)),
- ",".join(sorted(_NET_FIELDS))))
- tap = str(entry.get("tap") or "").strip()
- mac = str(entry.get("mac") or "").strip().lower()
- ip = str(entry.get("ip") or "").strip()
- for kind, value, seen in (("tap", tap, seen_taps),
- ("mac", mac, seen_macs),
- ("ip", ip, seen_ips)):
+ normalized = [_normalize_net_entry(e) for e in entries]
+ _check_net_duplicates(normalized)
+ _check_net_conflicts(normalized, existing_entries)
+ return normalized
+
+
+def _normalize_net_entry(entry: dict) -> dict:
+ """单条 net 条目归一化:字段白名单 + tap/mac/ip 归一化。
+
+ 空 tap 视为未指定,不参与 tap 冲突比较(见
+ validate_net_entries 的说明,纯移动)。"""
+ unknown = set(entry) - _NET_FIELDS
+ if unknown:
+ raise ChvFailure(
+ "net_config_invalid",
+ "CHV_NET_JSON 条目含未知字段:%s(白名单:%s)"
+ % (",".join(sorted(unknown)),
+ ",".join(sorted(_NET_FIELDS))))
+ return {"tap": str(entry.get("tap") or "").strip(),
+ "mac": str(entry.get("mac") or "").strip().lower(),
+ "ip": str(entry.get("ip") or "").strip(),
+ "mask": str(entry.get("mask") or "")}
+
+
+def _check_net_duplicates(normalized: list) -> None:
+ """本次条目内 tap/mac/ip 重复即 net_config_invalid。"""
+ seen = {"tap": set(), "mac": set(), "ip": set()}
+ for entry in normalized:
+ for kind in ("tap", "mac", "ip"):
+ value = entry.get(kind)
if not value:
continue
- if kind == "tap" and not tap:
- continue
- if value in seen:
+ if value in seen[kind]:
raise ChvFailure(
"net_config_invalid",
"CHV_NET_JSON 内 %s 重复:%s" % (kind, value))
- seen.add(value)
- normalized.append({"tap": tap, "mac": mac, "ip": ip,
- "mask": str(entry.get("mask") or "")})
+ seen[kind].add(value)
+
+
+def _check_net_conflicts(normalized: list, existing_entries: list) -> None:
+ """与已登记 VM 的 tap/mac/ip 任一重复即 net_tap_conflict。"""
for entry in normalized:
for other in existing_entries:
for kind in ("tap", "mac", "ip"):
@@ -136,7 +150,6 @@ def validate_net_entries(entries: list, existing_entries: list) -> list:
"net %s %s 已被 VM %s 使用,拒绝共享"
" tap/mac/ip:每 VM 必须独立"
% (kind, value, other.get("vm")))
- return normalized
def validate_egress_proxy(egress_proxy: str) -> None:
@@ -162,9 +175,12 @@ def validate_egress_proxy(egress_proxy: str) -> None:
def _is_within(directory, path):
- directory = os.path.realpath(directory)
- path = os.path.realpath(path)
- return path == directory or path.startswith(directory + os.sep)
+ """目录内判定:经 compat215.resolve_inside(#215 目录门
+ 同签名),越界回 None 即不在内。绝对路径先转相对路径
+ 再过门,.. 逃逸与符号链接逃逸一律挡。"""
+ from cloud.chv.compat215 import resolve_inside
+ rel = os.path.relpath(path, directory)
+ return resolve_inside(directory, rel) is not None
def validate_snapshot_url(snapshot_url: str, state_dir: str, snapshot_dir=None,
diff --git a/cloud/chv/pool.py b/cloud/chv/pool.py
index 6630e64..0c4963f 100644
--- a/cloud/chv/pool.py
+++ b/cloud/chv/pool.py
@@ -304,3 +304,13 @@ class WarmPool:
"warm_pool_release", task,
"vm=%s destroyed=%s" % (pool_vm, destroyed))
return destroyed
+
+
+def reconcile_orphans_for_executor(executor) -> list:
+ """生产路径唯一的 WarmPool 入口:只做孤儿清理。
+
+ worker 启动/关闭调它,不构造 WarmPool 类、不调
+ prewarm/acquire/release——实验接口与生产路径彻底分离:
+ 生产代码 import 本函数即可,acquire/prewarm 只存在于
+ 实验用的 WarmPool 类与测试里。"""
+ return WarmPool(executor).reconcile_orphans()
diff --git a/cloud/console-react/src/api/types.ts b/cloud/console-react/src/api/types.ts
index d75b399..ee7630a 100644
--- a/cloud/console-react/src/api/types.ts
+++ b/cloud/console-react/src/api/types.ts
@@ -24,6 +24,10 @@ export interface Task {
kind?: "main" | "side";
no_time_limit?: number | boolean;
environment_id?: string; attachments?: string[]; model?: string; effort?: string;
+ // feat-chv-arch95plus 只读字段:仅 CHV 任务且 worker 已把
+ // VM 死亡写进 interrupted_reason 时 server 才返回 true,
+ // 其它情形字段不存在(绝不把“未报告死亡”当成“VM 活着”)。
+ chv_vm_dead?: boolean;
child_model?: string; child_effort?: string; approval?: string;
result_text?: string; error_text?: string;
}
diff --git a/cloud/console-react/src/sections/TaskDetailPanel.tsx b/cloud/console-react/src/sections/TaskDetailPanel.tsx
index 7c02241..aaf57f6 100644
--- a/cloud/console-react/src/sections/TaskDetailPanel.tsx
+++ b/cloud/console-react/src/sections/TaskDetailPanel.tsx
@@ -131,6 +131,7 @@ export default function TaskDetailPanel() {
turn{shortId(t.turn_id)}
root turn{shortId(t.root_turn_id)}
executor{t.executor || ""}{t.executor === "cloud-hypervisor" ? "(实验执行器,默认关闭;仅展示,无 VM 控制按钮)" : ""}
+ {t.chv_vm_dead ? VM 状态VM 中途死亡,任务未完成、断点可续跑(后端只读字段 chv_vm_dead;无该字段时不代表 VM 活着) : null}
主机{t.worktree_host || ""}
模型{t.model || "默认"}
强度{t.effort || "默认"}
diff --git a/cloud/console.html b/cloud/console.html
index 20b055e..21159b8 100644
--- a/cloud/console.html
+++ b/cloud/console.html
@@ -74,7 +74,7 @@ Error generating stack: `+a.message+`
`);for(;mt>=0;){const Ct=yt.slice(0,mt);yt=yt.slice(mt+2);for(const V of Ct.split(`
`))if(V.startsWith("data: "))try{const Y=JSON.parse(V.slice(6));Y.waiting?b(Y.task_status==="interrupted"?"任务已中断,等待续跑…":Y.task_status==="paused"?"任务已暂停…":"等待会话…"):Y.image&&(f("data:image/jpeg;base64,"+Y.image),b("直播中"));const J={};Y.holder&&(J.holder=Y.holder),Y.url&&(J.url=Y.url),Y.title!==void 0&&(J.title=Y.title),Object.keys(J).length>0&&A(dt=>({...dt||{},...J}))}catch{}mt=yt.indexOf(`
-`)}}b("流已断开")}catch(at){if(at.name==="AbortError")return;M(at instanceof Error?at.message:String(at)),b("未开始")}finally{p(!1)}},[r]),ft=d.useCallback(()=>{var z;(z=H.current)==null||z.abort(),p(!1),b("已停止")},[]),Et=d.useCallback(async z=>{if(r)try{const L=await I($.taskBrowserControl(r.id),"POST",{holder:z});A(L),M("")}catch(L){M(L instanceof Error?L.message:String(L))}},[r]),st=d.useCallback(async z=>{if(!r||(g==null?void 0:g.holder)!=="user"||!Z.current)return;const L=Z.current.getBoundingClientRect(),at=Math.round((z.clientX-L.left)/L.width*1280),jt=Math.round((z.clientY-L.top)/L.height*800);try{await I($.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:at,y:jt})}catch(Mt){M(Mt instanceof Error?Mt.message:String(Mt))}},[r,g]),Ht=d.useCallback(async z=>{if(!(!r||(g==null?void 0:g.holder)!=="user")){z.preventDefault();try{z.key.length===1?await I($.taskBrowserInput(r.id),"POST",{type:"type",text:z.key}):await I($.taskBrowserInput(r.id),"POST",{type:"key",key:z.key})}catch(L){M(L instanceof Error?L.message:String(L))}}},[r,g]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const Q=(g==null?void 0:g.holder)||"—",ct=g!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:O})]}),c.jsxs("span",{className:"muted",children:["会话 ",ct?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",Q]}),g!=null&&g.url?c.jsx("span",{className:"muted",title:g.url,children:g.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[q?c.jsx("button",{type:"button",onClick:ft,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void it(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void F(),children:"刷新状态"}),Q!=="user"?c.jsx("button",{type:"button",onClick:()=>void Et("user"),disabled:!ct,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void Et("agent"),children:"交还给 Agent"})]}),E?c.jsx("div",{className:"muted",style:{marginTop:8},children:E}):null,Q==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:Z,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:z=>void st(z),onKeyDown:z=>void Ht(z),style:{width:"100%",maxWidth:1280,cursor:Q==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:ct?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function Ng(){const i=Gt(),[r,h]=d.useState("uncommittedChanges"),[f,g]=d.useState(""),[A,O]=d.useState(""),[b,E]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:M=>h(M.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:M=>g(M.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:A,onChange:M=>O(M.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:b,onChange:M=>E(M.target.value)}),c.jsxs(zt,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:A.trim()||void 0,instructions:b.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(Sn,{children:i.reviews.map(M=>{let q=M.target_type||"";return M.branch&&(q+=":"+M.branch),M.sha&&(q+=":"+String(M.sha).slice(0,12)),M.instructions&&(q+=":"+String(M.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",se(M.id)," | ",q," | ",c.jsx("span",{className:"st-"+(M.status||""),children:M.status}),M.created_at?" | "+Ge(M.created_at):"",c.jsx("br",{}),M.findings?String(M.findings).slice(0,1e3):c.jsx("span",{className:"muted",children:"(暂无 findings:评审尚未执行或尚未写回)"})]}),c.jsx("button",{onClick:()=>i.submitFindings(M.id),children:"写 findings"})]},M.id)})})]})}function Cg(){const i=Gt();return i.currentTask?i.currentTask.coordinator_parts?i.coordChildren.length===0?c.jsx("div",{className:"muted",children:"(子任务尚未创建)"}):c.jsx(Sn,{children:i.coordChildren.map(r=>c.jsx("li",{children:c.jsxs("span",{className:"row-main",children:["分工 ",c.jsx("b",{children:r.coordinator_part||""})," | 状态 ",c.jsx("span",{className:"st-"+r.status,children:r.status})," | ",se(r.id)," | ",String(r.prompt||"").slice(0,60)]})},r.id))}):c.jsx("div",{className:"muted",children:"(非协调员任务)"}):c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示协调员的子任务"})}function Ag(){const i=Gt();return i.currentTask?i.sideThreads.length===0?c.jsx("div",{className:"muted",children:"(暂无旁聊;列表“开旁聊”可在此任务下新建,不建 worktree)"}):c.jsx(Sn,{children:i.sideThreads.map(r=>c.jsx("li",{onClick:()=>i.showDetail(r.id),style:{cursor:"pointer"},children:c.jsxs("span",{className:"row-main",children:["旁聊 | 状态 ",c.jsx("span",{className:"st-"+r.status,children:r.status})," | ",se(r.id)," | ",String(r.prompt||"").slice(0,60)]})},r.id))}):c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示旁聊"})}function Mg(){const i=Gt();if(!i.currentTask)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示子代理链路"});if(i.agents.length===0)return c.jsxs("div",{className:"muted",children:["(暂无子代理)",i.agentsNote?c.jsx("span",{className:"err",children:i.agentsNote}):null]});const r={main:"主任务",delegate:"委派子任务",model:"模型子代理"},h={running:"进行中",completed:"已完成",failed:"失败",interrupted:"已中断",unknown:"未知"};return c.jsx(Sn,{children:i.agents.map((f,g)=>c.jsx("li",{children:c.jsxs("span",{className:"row-main",children:[c.jsxs("b",{children:[r[f.kind||""]||f.kind||"",":"]}),f.name||""," ",c.jsx("span",{className:"st-"+(f.status||""),children:h[f.status||""]||f.status||"?"})," | 过程调用 "+(f.calls?f.calls.length:0)+" 条",f.updated_ts?" | 最近活动 "+Ge(f.updated_ts/1e3):"",f.prompt?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsxs("span",{className:"muted",children:["任务:",f.prompt]})]}):null,f.calls&&f.calls.length?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),f.calls.slice(-50).map((A,O)=>c.jsx("div",{children:So(A)},O))]}):c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsx("span",{className:"muted",children:"(暂无过程调用)"})]})]})},g))})}function Og(){const i=Gt();return c.jsxs(c.Fragment,{children:[c.jsxs(zt,{children:[c.jsx("button",{onClick:i.refreshApprovals,children:"刷新审批队列"}),c.jsx("span",{className:"muted",children:i.approvalMsg})]}),c.jsx("div",{className:"muted",children:"待审批项需人工点记录允许或记录拒绝;超时默认拒绝。当前版本只留回执,不拦截实时行为(实时请求的事前拦截待 host 接入后生效,在此之前实时行为不变)。"}),c.jsx(ul,{empty:"(暂无审批项)",items:i.approvals.map(r=>c.jsxs("span",{className:"row-main",children:["[",r.kind,"|",r.status,"] ",r.summary,["approved","denied","expired"].includes(r.status||"")?"|回执:"+(r.decision||r.status)+" by "+String(r.decided_by||"").slice(0,8)+" "+Ge(r.decided_at):"",r.status==="pending"?c.jsxs(c.Fragment,{children:[" ",c.jsx("button",{onClick:()=>i.decideApproval(r.id,"approved"),children:"记录允许"})," ",c.jsx("button",{onClick:()=>i.decideApproval(r.id,"denied"),children:"记录拒绝"})]}):null]},r.id))})]})}function zg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"目标任务 id(与本任务同项目;对方运行中排队执行、已终态则续跑复活,绝不打断当前轮)",style:{width:"100%"},value:r,onChange:A=>h(A.target.value)}),c.jsx("textarea",{placeholder:"消息正文(以本任务名义发出;对方 agent 会看到发送方任务 id,可照此回消息)",value:f,onChange:A=>g(A.target.value)}),c.jsxs(zt,{children:[c.jsx("button",{onClick:async()=>{await i.postPeer("queued",r,f),g("")},children:"排队发给对方"}),c.jsx("button",{className:"primary",onClick:async()=>{await i.postPeer("direct",r,f),g("")},children:"直接发送"}),c.jsx("span",{className:"muted",children:i.peerMsg})]}),c.jsx(ul,{empty:"(本任务尚未发出任务间消息)",items:i.peerMessages.map((A,O)=>c.jsxs("span",{className:"row-main",children:["[发给 ",String(A.task_id).slice(0,8),"|",A.mode,"|",A.status,"] ",String(A.body||"").slice(0,120)]},O))})]})}function Rg(){const i=Gt(),[r,h]=d.useState(""),f=i.currentTask?["completed","failed","interrupted"].includes(i.currentTask.status):!1;return c.jsxs(c.Fragment,{children:[c.jsx("div",{className:"muted",children:i.threadText||"点任务行查看详情后,这里显示线程元数据"}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"新线程名",value:r,onChange:g=>h(g.target.value)}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("rename",r),children:"重命名"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("archive",""),children:"归档"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("unarchive",""),children:"取消归档"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("compact",""),children:"压缩"}),c.jsx("span",{className:"muted",children:i.threadGateNote}),c.jsx("span",{className:"muted",children:i.threadMsg})]}),c.jsx(ul,{empty:"暂无其他线程",items:i.threadList.map((g,A)=>c.jsxs("span",{className:"row-main",children:[g.name||g.thread_name||g.id||""," [",g.id||"","]",g.archived?" [已归档]":"",g.cwd?" "+g.cwd:""]},A))}),c.jsx("div",{className:"muted",children:"限制:Docker/异机任务线程操作一律 409;merged/merge-failed 不可改;turn/steer 与 review/start 不在本批。"})]})}function Dg(i){return/503|Service Unavailable/.test(i)?"上游模型服务暂时不可用(503):模型一轮都没跑出来,不是任务逻辑失败;等上游恢复或换模型重试":/429|rate limit/i.test(i)?"上游限流(429):模型拒绝了本次调用,可稍后重试":/timeout|timed out/i.test(i)?"上游调用超时:模型响应未在限时内返回":/401|403|Invalid API key|unauthorized/i.test(i)?"上游鉴权失败:网关 key 或权限有问题":/ECONNREFUSED|ENOTFOUND|network|fetch failed/i.test(i)?"连不上上游模型服务:网络或网关地址有问题":"任务失败(原因见原始错误文本)"}function kg(i){return i===null||i<0?"未知":i<60?Math.floor(i)+" 秒前":i<3600?Math.floor(i/60)+" 分钟前":(i/3600).toFixed(1)+" 小时前"}const wg=[["overview","概览"],["collab","协作"],["review","评审与审批"],["threads","线程"],["browser","浏览器"]];function Ug(){const i=Gt(),r=i.currentTask,[h,f]=d.useState("overview");if(!r)return c.jsx(Jt,{id:"nav-detail",title:"任务详情",children:c.jsx("div",{className:"muted",children:"点任务行查看详情"})});const[g,A]=Ph(r),[O,b]=tv(r),E=oi(i.workers,i.workersSummary,i.workersError),M=Bc(r.executor,r.status,E),q=Ih(r),p=i.costSummary,H=r.plan?"计划模式(只读)":r.goal_mode?r.no_time_limit?"goal 模式(不限轮次,模型宣告完成;任务不限时)":"goal 模式(不限轮次,模型宣告完成)":r.verify_command?"goal 模式(旧语义,验收命令)":null;return c.jsxs("div",{className:"detail-stack",id:"nav-detail",children:[c.jsx("nav",{className:"detail-tabs","aria-label":"任务详情页签",children:wg.map(([Z,F])=>c.jsx("button",{type:"button",className:h===Z?"active":"",onClick:()=>f(Z),children:F},Z))}),h==="overview"?c.jsxs(c.Fragment,{children:[c.jsxs(Jt,{title:"任务详情",children:[c.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"},children:[c.jsx("span",{className:"issue-first",title:"任务首字:"+q,"aria-hidden":"true",children:q}),c.jsxs("span",{children:["状态 ",c.jsx("b",{className:"st-"+r.status,children:r.status}),"(",Iy[r.status]||"未知状态",")"]}),c.jsxs("span",{className:"muted",children:["更新 ",Ge(r.updated_at)]})]}),c.jsxs("div",{className:"detail-cards",children:[c.jsxs("div",{className:"detail-card",title:A,children:[c.jsx("div",{className:"detail-card-label",children:"耗时"}),c.jsx("div",{className:"detail-card-value",children:g})]}),c.jsxs("div",{className:"detail-card",title:b,children:[c.jsx("div",{className:"detail-card-label",children:"token"}),c.jsx("div",{className:"detail-card-value",children:O})]}),c.jsxs("div",{className:"detail-card",title:"LLM 请求数 = GET /tasks/{id}/cost 的 llm_requests,即 cost_records 的 completions 之和:host.py 每收到一次 thread/tokenUsage/updated(一次上游补全)计 1,worker 每轮终局记账时累加;估算行(无真实用量)计 0;部署前已记账的老任务没有 completions 列,显示 0 而非回填数",children:[c.jsx("div",{className:"detail-card-label",children:"LLM 请求数"}),c.jsx("div",{className:"detail-card-value",children:p===void 0?"加载中…":p===null?"—":(p.llm_requests??0).toLocaleString()}),p===void 0?c.jsx("div",{className:"muted",children:"成本加载中…"}):p===null?c.jsx("div",{className:"muted",children:"成本明细加载失败,LLM 请求数不可用"}):c.jsxs("div",{className:"muted",children:["上游补全次数之和| 轮数 ",p.turns??0," | fallback ",p.fallback_count??0]})]}),c.jsxs("div",{className:"detail-card",title:"成本明细:GET /tasks/{id}/cost 聚合;美元只计有价行,无价 token 单列;缓存是输入 token 的子集,不另计",children:[c.jsx("div",{className:"detail-card-label",children:"成本"}),c.jsx("div",{className:"detail-card-value",children:p===void 0?"加载中…":p===null?"—":yg(p.usd_micros)}),p===void 0?c.jsx("div",{className:"muted",children:"成本加载中…"}):p===null?c.jsx("div",{className:"muted",children:"成本明细加载失败(“查成本”按钮仍可手动拉取原始 JSON)"}):c.jsxs("div",{className:"muted",children:["token ",(p.total_tokens??0).toLocaleString(),"(实际 ",(p.actual_tokens??0).toLocaleString()," / 估算 ",(p.estimated_tokens??0).toLocaleString(),",缓存 ",(p.cached_input_tokens??0).toLocaleString(),",无价 ",(p.unpriced_actual_tokens??0).toLocaleString(),")| fallback ",p.fallback_count??0]})]})]})]}),r.status==="running"||r.status==="queued"?(()=>{const Z=Date.now()/1e3,F=r.last_heartbeat||r.updated_at||null,it=F?Z-F:null;return c.jsxs(Jt,{title:"LLM 进度",children:[c.jsxs("div",{children:["状态 ",r.status==="running"?"进行中":"排队中"," · 已用 ",g,it!==null?" · 上次活动 "+kg(it):""]}),M?c.jsx("div",{className:"muted",children:M}):null,it!==null&&it>180?c.jsx("div",{className:"muted",children:"超过 3 分钟无活动:可能在等上游模型响应(上游 503 时 worker 会重试后失败,失败后这里会显示失败原因;无活动不等于已断连,服务端没有“LLM 已连接”字段,不作此断言)"}):c.jsx("div",{className:"muted",children:"首轮无输出时属正常:模型第一轮回复到达前 trace 里没有消息;回复到达后会在会话区出现,成本里的 LLM 请求数随之增加"})]})})():null,r.error_text?c.jsxs(Jt,{title:"失败原因",children:[c.jsx("div",{children:Dg(r.error_text)}),c.jsx("pre",{style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:r.error_text.slice(0,1e3)}),c.jsxs("div",{className:"muted",children:["完整错误文本仍在下方“操作与结果”里;模型 ",r.model||"默认"," · executor ",r.executor||""]})]}):null,c.jsx(Jt,{title:"基本信息",children:c.jsxs("dl",{className:"detail-kv",children:[c.jsxs("div",{children:[c.jsx("dt",{children:"任务"}),c.jsx("dd",{children:r.id})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"turn"}),c.jsx("dd",{children:se(r.turn_id)})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"root turn"}),c.jsx("dd",{children:se(r.root_turn_id)})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"executor"}),c.jsxs("dd",{children:[r.executor||"",r.executor==="cloud-hypervisor"?"(实验执行器,默认关闭;仅展示,无 VM 控制按钮)":""]})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"主机"}),c.jsx("dd",{children:r.worktree_host||""})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"模型"}),c.jsx("dd",{children:r.model||"默认"})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"强度"}),c.jsx("dd",{children:r.effort||"默认"})]}),r.child_model||r.child_effort?c.jsxs("div",{children:[c.jsx("dt",{children:"子任务默认"}),c.jsx("dd",{children:(r.child_model||"跟随本任务")+" / "+(r.child_effort||"跟随本任务")})]}):null,c.jsxs("div",{children:[c.jsx("dt",{children:"权限"}),c.jsx("dd",{children:r.approval||"默认"})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"项目"}),c.jsx("dd",{children:se(r.project_id)})]}),H?c.jsxs("div",{children:[c.jsx("dt",{children:"模式"}),c.jsx("dd",{children:H})]}):null,r.kind==="side"?c.jsxs("div",{children:[c.jsx("dt",{children:"类型"}),c.jsx("dd",{children:"旁聊(无 worktree/分支/容器)"})]}):null,r.forked_from?c.jsxs("div",{children:[c.jsx("dt",{children:"分支自"}),c.jsx("dd",{children:se(r.forked_from)})]}):null,r.coordinator_parts?c.jsxs("div",{children:[c.jsx("dt",{children:"协调员"}),c.jsxs("dd",{children:[r.coordinator_parts.length," 个子任务(",r.coordinator_parts.map(Z=>Z.name).join("、"),")"]})]}):null,r.parent_task_id&&r.kind==="side"?c.jsxs("div",{children:[c.jsx("dt",{children:"父会话"}),c.jsxs("dd",{children:["父任务 ",se(r.parent_task_id)," ",c.jsx("button",{type:"button",onClick:()=>i.showDetail(r.parent_task_id),children:"跳转到父任务"})]})]}):null,r.parent_task_id&&r.kind!=="side"?c.jsxs("div",{children:[c.jsx("dt",{children:"协调子任务"}),c.jsxs("dd",{children:["父任务 ",se(r.parent_task_id),r.coordinator_part?"("+r.coordinator_part+")":""]})]}):null,r.attachments&&r.attachments.length?c.jsxs("div",{children:[c.jsx("dt",{children:"附件"}),c.jsx("dd",{children:r.attachments.join("、")})]}):null,r.pr_url?c.jsxs("div",{children:[c.jsx("dt",{children:"PR"}),c.jsx("dd",{children:c.jsxs("a",{href:r.pr_url,target:"_blank",rel:"noopener noreferrer",children:["PR #",r.pr_number]})})]}):null]})}),c.jsx(Jt,{title:"prompt",children:c.jsx("div",{style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:r.prompt||"(无 prompt)"})}),c.jsxs(Jt,{title:"操作与结果",children:[c.jsx(_g,{}),c.jsx("pre",{children:i.detailBody||"(暂无 result/error 输出)"}),i.costBox?c.jsx("pre",{children:i.costBox}):null]})]}):h==="collab"?c.jsxs(c.Fragment,{children:[c.jsx(Jt,{title:"协调子任务",children:c.jsx(Cg,{})}),c.jsx(Jt,{title:"旁聊",children:c.jsx(Ag,{})}),c.jsx(Jt,{title:"子代理链路",children:c.jsx(Mg,{})}),c.jsx(Jt,{title:"给其他任务发消息",children:c.jsx(zg,{})})]}):h==="review"?c.jsxs(c.Fragment,{children:[c.jsx(Jt,{title:"评审队列",children:c.jsx(Ng,{})}),c.jsx(Jt,{title:"运行时审批队列",children:c.jsx(Og,{})})]}):h==="browser"?c.jsx(c.Fragment,{children:c.jsx(Jt,{title:"浏览器远控",children:c.jsx(Eg,{})})}):c.jsx(c.Fragment,{children:c.jsx(Jt,{title:"线程",children:c.jsx(Rg,{})})})]})}function Hg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState(""),[A,O]=d.useState([]),[b,E]=d.useState(null),[M,q]=d.useState(!1),[p,H]=d.useState(""),[Z,F]=d.useState(""),it=d.useCallback(async()=>{if(!i.keySaved){O([]);return}try{const Q=await I($.userKeys);O(Q.keys||[]),F("")}catch(Q){F(Q instanceof Error?Q.message:"加载 key 列表失败")}},[i.keySaved]);d.useEffect(()=>{it()},[it]);const ft=async()=>{H(""),F(""),q(!1);try{const Q=await I($.userKeys,"POST",{label:f.trim()||"api-key"});E(Q),g(""),H("新 key 已生成,只显示一次:复制后请妥善保存。"),await it()}catch(Q){F(Q instanceof Error?Q.message:"生成 key 失败")}},Et=async()=>{if(b)try{await navigator.clipboard.writeText(b.api_key),q(!0)}catch{F("复制失败:请手动选中上方 key 文本复制")}},st=()=>{b&&(i.saveKeyInput(b.api_key),E(null),H("已存为当前浏览器 key(原有 key 未被撤销,需要时可单独撤销)。"))},Ht=async Q=>{const ct=(()=>{try{return nl().slice(-4)}catch{return""}})(),z=Q.id==="legacy"?"(主 key:撤销后当前浏览器保存的 key 也可能随之失效,需重新登录拿新 key)":Q.hint&&Q.hint===ct?"(这就是本浏览器当前在用的 key,撤销后本页会立刻未连接)":"";if(window.confirm(`撤销 key「${Q.label}」(后 4 位 ${Q.hint||"无"})吗?${z}撤销不可恢复。`)){H(""),F("");try{await I($.userKey(Q.id),"DELETE"),H(`已撤销 key「${Q.label}」。`),await it()}catch(L){F(L instanceof Error?L.message:"撤销失败")}}};return c.jsxs(Jt,{id:"nav-key",title:"API Key",children:[c.jsxs(zt,{children:[c.jsx("input",{type:"password",placeholder:"粘贴你的 X-API-Key(与调 API 用的同一个)",autoComplete:"off",value:r,onChange:Q=>h(Q.target.value)}),c.jsx("button",{className:"primary",onClick:()=>{i.saveKeyInput(r),h("")},children:"保存到本浏览器"}),c.jsx("button",{onClick:i.clearKeyInput,children:"清除"}),c.jsx("span",{className:"muted",children:i.keyState})]}),c.jsx("div",{children:c.jsx("span",{className:"muted",children:i.quotaText})}),!i.keySaved&&c.jsx("div",{children:c.jsx("span",{className:"muted",children:"先粘贴并保存已有 key,再生成或管理 key(生成接口本身也要用已有 key 鉴权)。"})}),i.keySaved&&c.jsxs(c.Fragment,{children:[c.jsxs(zt,{children:[c.jsx("input",{placeholder:"新 key 备注(可选,默认 api-key,如 harness2-temp)",autoComplete:"off",value:f,onChange:Q=>g(Q.target.value)}),c.jsx("button",{className:"primary",onClick:()=>void ft(),children:"生成新 key"}),c.jsx("button",{onClick:()=>void it(),children:"刷新列表"})]}),b&&c.jsxs("div",{children:[c.jsx("div",{children:c.jsxs("span",{className:"muted",children:["新 key(",b.label,",后 4 位 ",b.hint,"):只显示一次,关闭后无法再查看。"]})}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",readOnly:!0,value:b.api_key,onFocus:Q=>Q.target.select()}),c.jsx("button",{className:"primary",onClick:()=>void Et(),children:M?"已复制":"复制"}),c.jsx("button",{onClick:st,children:"存为当前浏览器 key"}),c.jsx("button",{onClick:()=>E(null),children:"关闭(不再显示)"})]})]}),p&&c.jsx("div",{children:c.jsx("span",{className:"muted",children:p})}),Z&&c.jsx("div",{children:c.jsx("span",{children:Z})}),c.jsxs("div",{className:"api-key-list",children:[A.length===0&&c.jsx("span",{className:"muted",children:"暂无 key(或列表加载失败,见上方报错)。"}),A.map(Q=>c.jsxs("div",{children:[c.jsx("span",{children:Q.label})," ",c.jsxs("span",{className:"muted",children:["后 4 位 ",Q.hint||"无"," · 创建 ",Ge(Q.created_at)," · 最近使用 ",Q.last_used_at?Ge(Q.last_used_at):"未使用过"]})," ",Q.revoked_at?c.jsxs("span",{className:"muted",children:["已撤销(",Ge(Q.revoked_at),")"]}):c.jsx("button",{onClick:()=>void Ht(Q),children:"撤销"})]},Q.id))]})]})]})}function Bg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState("");return c.jsxs(Jt,{id:"adminCard",title:c.jsxs(c.Fragment,{children:["管理区 ",c.jsx("span",{className:"muted",children:"(仅全局管理员)"})]}),children:[c.jsx("div",{className:"muted",children:i.adminGate}),i.adminBody?c.jsxs(c.Fragment,{children:[c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"要查配额的用户 id",value:i.adminUserId,onChange:A=>i.setAdminUserId(A.target.value)}),c.jsx("button",{onClick:i.adminQuota,children:"查该用户配额"})]}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"要设置配额的用户 id",value:r,onChange:A=>h(A.target.value)}),c.jsx("input",{type:"text",placeholder:"美元配额(留空 = 不限额)",value:f,onChange:A=>g(A.target.value)}),c.jsx("button",{className:"primary",onClick:()=>i.adminSetQuota(r,f),children:"设置配额"})]}),c.jsxs(zt,{children:[c.jsx("button",{onClick:i.adminSweep,children:"手动触发 sweep(清理陈旧 running 任务)"}),c.jsx("button",{onClick:i.adminBackup,children:"手动触发备份"})]}),c.jsx("div",{className:"muted",children:i.adminResult})]}):null]})}function qg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState("contributor"),A=i.projects.find(O=>O.id===i.projectId);return c.jsxs(Jt,{title:"项目成员",children:[A?c.jsxs("div",{className:"muted",children:[A.name," | ",A.id," | 仓库 ",A.repo_url||"未绑定"," | 我的角色 ",A.my_role||"未知"]}):c.jsx("div",{className:"muted",children:"先选项目"}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"成员 user_id 或用户名",value:r,onChange:O=>h(O.target.value)}),c.jsxs("select",{"aria-label":"邀请角色",value:f,onChange:O=>g(O.target.value),children:[c.jsx("option",{value:"viewer",children:"viewer(只读)"}),c.jsx("option",{value:"contributor",children:"contributor(可建任务)"}),c.jsx("option",{value:"maintainer",children:"maintainer(可批合并)"}),c.jsx("option",{value:"owner",children:"owner(可管项目)"})]}),c.jsx("button",{onClick:async()=>{await i.inviteMember(r,f),h("")},children:"邀请成员"}),c.jsx("button",{onClick:i.refreshMembers,children:"刷新成员"})]}),i.memberError?c.jsx("div",{className:"muted",children:i.memberError}):i.members.length===0?c.jsx("div",{className:"muted",children:"先选项目"}):c.jsx(Sn,{children:i.members.map(O=>{const b=(O.username||"")+(O.username?"("+String(O.user_id).slice(0,8)+")":String(O.user_id).slice(0,8));return c.jsxs("li",{children:[c.jsx("span",{className:"row-main",children:b}),c.jsx("select",{"aria-label":"成员角色",value:O.role||"viewer",onChange:E=>void i.changeMemberRole(O.user_id,E.target.value),children:Py.map(E=>c.jsx("option",{value:E,children:E},E))}),c.jsx("button",{onClick:()=>void i.removeMember(O.user_id),children:"移除"})]},O.user_id)})}),c.jsx("span",{className:"muted",children:i.memberMsg})]})}function Yg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState(""),[A,O]=d.useState("");return c.jsxs("div",{className:"project-nav",children:[c.jsxs(Jt,{title:"项目",children:[c.jsx(zt,{children:c.jsx("button",{onClick:i.refreshProjects,children:"刷新项目"})}),i.projects.length===0?c.jsx("div",{className:"muted",children:"暂无项目"}):c.jsx("ul",{className:"project-nav-list",children:i.projects.map(b=>c.jsxs("li",{className:"project-nav-item"+(b.id===i.projectId?" selected":""),onClick:()=>i.selectProject(b.id),title:b.id,children:[c.jsx("span",{className:"project-nav-name",children:b.name}),c.jsxs("span",{className:"project-nav-meta",children:[se(b.id),b.my_role?" · "+b.my_role:""]})]},b.id))}),c.jsx("h3",{children:"创建项目"}),c.jsx(zt,{children:c.jsx("input",{type:"text",placeholder:"新项目名",value:r,onChange:b=>h(b.target.value)})}),c.jsx(zt,{children:c.jsxs("select",{"aria-label":"从 GitHub 仓库选",value:f,onChange:b=>{g(b.target.value),b.target.value&&O(b.target.value)},children:[c.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选,或手动输入)"}),i.githubRepos.map(b=>c.jsx("option",{value:b.clone_url,children:b.repo_full_name},b.clone_url))]})}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"repo_url(可选)",value:A,onChange:b=>O(b.target.value)}),c.jsx("button",{className:"primary",onClick:async()=>{await i.createProject(r,A),h(""),O(""),g("")},children:"创建项目"})]}),c.jsx("span",{className:"muted",children:i.projectMsg})]}),c.jsx(qg,{})]})}function Gg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState(""),[A,O]=d.useState(""),b=oi(i.workers,i.workersSummary,i.workersError);return c.jsxs(Jt,{id:"nav-env",title:c.jsxs(c.Fragment,{children:["环境 ",c.jsx("span",{className:"muted",children:"(选、建、管:当前项目的基准检出)"})]}),children:[c.jsxs(zt,{children:[c.jsx("button",{onClick:i.refreshEnvs,children:"刷新"}),c.jsx("span",{className:"muted",children:i.envCount})]}),i.envs.length===0?c.jsx("div",{className:"muted",children:"先选项目"}):c.jsx(Sn,{children:i.envs.map(E=>c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[E.name,"(",se(E.id),") / 分支 ",E.branch||"默认分支"," / 执行器 ",E.executor||"不限"," / 状态 ",E.status,E.status==="failed"&&E.error_text?" / 失败原因:"+E.error_text:""]}),E.status==="archived"?null:c.jsx("button",{onClick:()=>void i.archiveEnv(E.id),children:"归档"})]},E.id))}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"新环境名",value:r,onChange:E=>h(E.target.value)}),c.jsx("input",{type:"text",placeholder:"分支(可选,空=默认分支)",value:f,onChange:E=>g(E.target.value)}),c.jsxs("select",{"aria-label":"新环境执行器",value:A,onChange:E=>O(E.target.value),children:[c.jsx("option",{value:"",children:"执行器:不限"}),c.jsx("option",{value:"local",children:"local"}),c.jsx("option",{value:"docker",children:"docker"}),c.jsx("option",{value:"cloud-hypervisor",children:"cloud-hypervisor(实验,默认关闭)"})]}),c.jsx("button",{onClick:async()=>{await i.createEnv(r,f,A),h(""),g("")},children:"新建环境"})]}),A==="cloud-hypervisor"?c.jsxs("div",{className:"muted",children:["实验执行器,默认关闭:环境创建白名单同样允许 cloud-hypervisor(后端环境接口 allowlist:local / docker / cloud-hypervisor,不含 cloud);仍需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务排队。前端只传值,不绕过后端白名单。",b.state==="known"?c.jsxs(c.Fragment,{children:[" CHV worker 在线 ",b.online," / 共 ",b.total,"(来自 /workers)",b.online===0?":当前无 CHV worker 在线,任务将一直排队。":""]}):" CHV worker 在线数未知:完整 worker 清单仅全局管理员可见。"]}):null,c.jsx("span",{className:"muted",children:i.envMsg})]})}function Qg(){const i=Gt(),[r,h]=d.useState("");return c.jsxs(Jt,{title:"仓库与环境上下文",children:[c.jsx("div",{className:"muted",children:i.envRepoLine||"先选项目"}),c.jsx("div",{className:"muted",children:i.envCount}),i.envRepoBar?c.jsxs(zt,{children:[c.jsxs("select",{"aria-label":"补仓库",value:r,onChange:f=>h(f.target.value),children:[c.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选)"}),i.githubRepos.map(f=>c.jsx("option",{value:f.clone_url,children:f.repo_full_name},f.clone_url))]}),c.jsx("button",{onClick:()=>void i.saveEnvRepo(r),children:"保存仓库"})]}):null,c.jsx("span",{className:"muted",children:i.envRepoMsg})]})}function Vg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState("all"),A=M=>{const q=qc(M);return M.alive!==void 0&&M.alive!==null?!!M.alive:q.age!==null&&q.age<=120},O=r.trim().toLowerCase(),b=(i.workers||[]).filter(M=>!(O&&!((M.worker_id||M.id||"")+" "+(M.executor||"")).toLowerCase().includes(O)||f==="online"&&!A(M)||f==="offline"&&A(M))),E=oi(i.workers,i.workersSummary,i.workersError);return c.jsxs(Jt,{id:"nav-workers",title:"Workers",children:[c.jsx(zt,{children:c.jsx("button",{onClick:i.refreshWorkers,children:"刷新"})}),c.jsx("div",{className:"muted",children:E.state==="known"?c.jsxs(c.Fragment,{children:["cloud-hypervisor worker 在线 ",E.online," / 共 ",E.total,"(实验,默认关闭)",E.online===0?":当前无 CHV worker 在线,CHV 任务将一直排队。":""]}):"cloud-hypervisor worker 在线数未知:完整 worker 清单仅全局管理员可见,非管理员只能看到聚合数,无法按执行器分项。"}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"搜索 worker_id / 执行器","aria-label":"搜索 Worker",value:r,onChange:M=>h(M.target.value)}),c.jsxs("select",{"aria-label":"在线状态筛选",value:f,onChange:M=>g(M.target.value),children:[c.jsx("option",{value:"all",children:"在线状态:全部"}),c.jsx("option",{value:"online",children:"仅在线"}),c.jsx("option",{value:"offline",children:"仅离线"})]}),i.workers?c.jsxs("span",{className:"muted",children:["显示 ",b.length," / 共 ",i.workers.length," 个"]}):null]}),i.workersError?c.jsx("div",{className:"muted",children:i.workersError}):i.workers===null?c.jsxs("div",{className:"muted",children:[i.workersSummary||"未加载","(完整清单仅全局管理员可见,搜索/筛选仅对完整清单生效)"]}):i.workers.length===0?c.jsx("div",{className:"muted",children:"无 worker 注册"}):b.length===0?c.jsx("div",{className:"muted",children:"没有符合筛选条件的 worker"}):c.jsx(Sn,{children:b.map((M,q)=>{const p=M.running===void 0||M.running===null?"?":M.running,H=M.capacity===void 0||M.capacity===null?"?":M.capacity,Z=qc(M),F=A(M),it=F?" / 在线":" / 离线(占用为上次上报值)";return c.jsx("li",{className:F?"is-online":"is-offline",children:c.jsxs("span",{className:"row-main",children:[M.worker_id||M.id||"?"," / ",M.executor||""," / 占用 ",p,"/",H,it," / 最后心跳 ",Z.text]})},q)})})]})}function Xg(){const i=Gt();return c.jsxs(Jt,{title:"健康与配额",children:[c.jsxs(zt,{children:[c.jsx("span",{className:"muted",children:i.healthText}),c.jsx("button",{onClick:i.refreshHealth,children:"刷新"})]}),c.jsx("div",{children:c.jsx("span",{className:"muted",children:i.quotaText})}),c.jsx("div",{className:"muted",children:i.workersSummary})]})}function Lg(){const i=Gt();return c.jsxs(Jt,{id:"nav-skills",title:"技能与 MCP",children:[c.jsx("div",{className:"muted",children:"反映宿主租户 home,容器内 home 可能不同;重载只在 schema 枚举里,当前 pin 的二进制拒绝时原样 502;OAuth 只发起登录,不代表登录完成。"}),c.jsxs(zt,{children:[c.jsx("button",{onClick:i.refreshSkills,children:"刷新"}),c.jsx("span",{className:"muted",children:i.skillMsg})]}),c.jsx("h3",{children:"技能"}),i.skillError?c.jsx("div",{className:"muted",children:i.skillError}):c.jsx(ul,{empty:"暂无技能",items:i.skills.map((r,h)=>c.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:"",r.detail?" - "+r.detail:""]},h))}),c.jsx("h3",{children:"应用"}),c.jsx(ul,{empty:"暂无应用",items:i.apps.map((r,h)=>c.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:""]},h))}),c.jsx("h3",{children:"MCP 服务"}),c.jsx(ul,{empty:"暂无 MCP 服务",items:i.mcpServers.map((r,h)=>{const f=r.detail||r.details||r.state||"";return c.jsxs("span",{className:"row-main",children:[r.name||r.id||""," | 状态 ",r.status||"",f?" | "+f:""]},h)})})]})}function Zg(){const i=Gt(),[r,h]=d.useState("");return c.jsxs(Jt,{title:"MCP 操作",children:[c.jsxs(zt,{children:[c.jsx("button",{onClick:i.reloadMcp,children:"重载配置"}),c.jsx("span",{className:"muted",children:i.mcpMsg})]}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"MCP 服务名",value:r,onChange:f=>h(f.target.value)}),c.jsx("button",{onClick:()=>void i.mcpOauth(r),children:"发起 OAuth 登录"}),c.jsx("span",{className:"muted",children:i.mcpOauthMsg})]}),i.mcpOauthBox?c.jsx("pre",{children:i.mcpOauthBox}):null]})}const Kh=160;function Kg(i){return i?i%86400===0?"(每天)":i%3600===0?"(每小时)":i%60===0?"(每 "+i/60+" 分钟)":"":""}function Jg({detail:i}){const r=Gt(),h=f=>{if(f){r.showDetail(f);try{window.location.hash="#view-tasks"}catch{}}};return c.jsxs("div",{className:"card",children:[c.jsxs("h2",{children:["自动化详情:",i.name]}),c.jsxs("div",{className:"muted",children:["状态 ",i.status,i.due?"|到期":""," | 每 ",i.interval_seconds," 秒 | 下次 ",Ge(i.next_run_at),"| 上次执行 ",Ge(i.last_run_at)," | 创建人 ",i.created_by?se(i.created_by):""," | 创建于 ",Ge(i.created_at),i.cancelled_at?" | 已取消于 "+Ge(i.cancelled_at):"",i.heartbeat_binding?" | 心跳绑定:已绑定":""]}),c.jsx("div",{className:"muted",style:{whiteSpace:"pre-wrap",marginTop:8},children:i.prompt||""}),i.repo_url?c.jsxs("div",{className:"muted",children:["仓库 ",i.repo_url]}):null,c.jsxs(zt,{children:[c.jsx("button",{onClick:r.closeAutomationDetail,children:"关闭详情"}),c.jsxs("span",{className:"muted",children:["编辑请在下面列表里点“编辑”。",r.autoDetailMsg]})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["最近产出(",(i.runs||[]).length,")"]}),c.jsx(ul,{empty:"(无)",items:(i.runs||[]).map((f,g)=>c.jsxs("span",{className:"row-main",children:[f.status,f.created_at?"|"+Ge(f.created_at):"",f.task_id?c.jsxs(c.Fragment,{children:[" | 任务 ",c.jsx("button",{onClick:()=>h(f.task_id),children:se(f.task_id)})]}):"",f.detail?"|"+String(f.detail).slice(0,120):""]},g))})]}),i.runs_note?c.jsx("div",{className:"muted",children:i.runs_note}):null]})}function Fg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState(""),[A,O]=d.useState(""),[b,E]=d.useState(""),[M,q]=d.useState(""),[p,H]=d.useState(""),[Z,F]=d.useState(""),[it,ft]=d.useState(""),[Et,st]=d.useState(""),[Ht,Q]=d.useState({}),ct=z=>{q(z.id),H(z.name||""),F(z.prompt||""),ft(z.interval_seconds?String(z.interval_seconds):""),st(z.repo_url||"")};return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"自动化名称(同项目内启用中的名称唯一)",style:{width:"100%"},value:r,onChange:z=>h(z.target.value)}),c.jsx("textarea",{placeholder:"定时任务内容(创建只登记定义与到期状态;执行由后续执行器经 claim 抢单后建任务,不另起调度器)",value:f,onChange:z=>g(z.target.value)}),c.jsxs(zt,{children:[c.jsx("input",{placeholder:"间隔秒数(默认 86400,最小 60)",style:{width:"40%"},value:A,onChange:z=>O(z.target.value)}),c.jsx("input",{placeholder:"仓库(可选)",style:{width:"40%"},value:b,onChange:z=>E(z.target.value)}),c.jsx("button",{className:"primary",onClick:async()=>{await i.createAutomation(r,f,A,b),h(""),g(""),O(""),E("")},children:"创建自动化"}),c.jsx("button",{onClick:i.refreshAutomations,children:"刷新自动化"}),c.jsx("span",{className:"muted",children:i.autoMsg})]}),c.jsx(ul,{empty:"(暂无自动化)",items:i.automations.map(z=>{const L=z.prompt||"",at=!!Ht[z.id],jt=L.length>Kh,Mt=L?at||!jt?L:L.slice(0,Kh)+"…":"(无内容)";return c.jsxs("div",{children:[c.jsxs("div",{className:"row-main",children:["[",z.status,z.due?"|到期":"",z.run_once?"|一次性":"","] ",z.name,"|每 ",z.interval_seconds," 秒",Kg(z.interval_seconds),"|上次 ",Ge(z.last_run_at)||"未运行","|下次 ",Ge(z.next_run_at),c.jsx("button",{onClick:()=>void i.loadAutomationDetail(z.id),children:"详情"}),z.status==="active"?c.jsxs(c.Fragment,{children:[" ",c.jsx("button",{onClick:()=>ct(z),children:"编辑"})," ",c.jsx("button",{onClick:()=>i.cancelAutomation(z.id),children:"取消"})]}):null]}),z.repo_url?c.jsxs("div",{className:"muted",children:["仓库:",z.repo_url]}):null,z.status==="cancelled"&&z.cancelled_at?c.jsxs("div",{className:"muted",children:["已取消于 ",Ge(z.cancelled_at)]}):null,c.jsxs("div",{className:"muted",style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:[Mt,jt?c.jsx("button",{onClick:()=>Q(yt=>({...yt,[z.id]:!yt[z.id]})),children:at?"收起":"展开"}):null]}),M===z.id?c.jsxs("div",{children:[c.jsx("input",{placeholder:"自动化名称",style:{width:"100%"},value:p,onChange:yt=>H(yt.target.value)}),c.jsx("textarea",{placeholder:"定时任务内容",value:Z,onChange:yt=>F(yt.target.value)}),c.jsxs(zt,{children:[c.jsx("input",{placeholder:"间隔秒数(最小 60,留空保持原值)",style:{width:"40%"},value:it,onChange:yt=>ft(yt.target.value)}),c.jsx("input",{placeholder:"仓库(可选,清空则清除)",style:{width:"40%"},value:Et,onChange:yt=>st(yt.target.value)}),c.jsx("button",{className:"primary",onClick:async()=>{const yt=i.automations.find(B=>B.id===M)||z;await i.updateAutomation(yt,p,Z,it,Et)&&q("")},children:"保存"}),c.jsx("button",{onClick:()=>q(""),children:"取消"})]})]}):null]},z.id)})}),i.autoDetailMsg&&!i.autoDetail?c.jsx("div",{className:"muted",children:i.autoDetailMsg}):null,i.autoDetail?c.jsx(Jg,{detail:i.autoDetail},i.autoDetail.id):null]})}function Wg(){var g,A,O,b;const i=Gt(),r=i.triage,h=E=>{if(E){i.showDetail(E);try{window.location.hash="#view-tasks"}catch{}}},f=E=>{if(E){i.loadAutomationDetail(E);try{window.location.hash="#view-automation"}catch{}}};return i.triageError?c.jsx("div",{className:"muted",children:i.triageError}):r?c.jsxs(c.Fragment,{children:[c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["到期自动化(",((g=r.counts)==null?void 0:g.automations_due)||0,")"]}),c.jsx(ul,{empty:"(无)",items:(r.automations_due||[]).map((E,M)=>c.jsx("span",{className:"row-main",children:E.id?c.jsxs("button",{onClick:()=>f(E.id),children:[E.name,"|每 ",E.interval_seconds," 秒"]}):c.jsxs(c.Fragment,{children:[E.name,"|每 ",E.interval_seconds," 秒"]})},M))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["失败/中断任务(",((A=r.counts)==null?void 0:A.failed_tasks)||0,")"]}),c.jsx(ul,{empty:"(无)",items:(r.failed_tasks||[]).map((E,M)=>c.jsx("span",{className:"row-main",children:c.jsxs("button",{onClick:()=>h(E.id),children:[se(E.id),"|",E.status,"|",String(E.prompt||"").slice(0,80)]})},M))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["待处理后续消息(",((O=r.counts)==null?void 0:O.pending_followups)||0,")"]}),c.jsx(ul,{empty:"(无)",items:(r.pending_followups||[]).map((E,M)=>c.jsx("span",{className:"row-main",children:c.jsxs("button",{onClick:()=>h(E.task_id),children:["任务 ",se(E.task_id),"|",String(E.body||"").slice(0,80)]})},M))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["最近自动化产出(",((b=r.counts)==null?void 0:b.recent_runs)||0,")"]}),c.jsx(ul,{empty:"(无)",items:(r.recent_runs||[]).map((E,M)=>c.jsxs("span",{className:"row-main",children:[se(E.automation_id),"|",E.status,E.task_id?c.jsxs(c.Fragment,{children:["|任务 ",c.jsx("button",{onClick:()=>h(E.task_id),children:se(E.task_id)})]}):"",E.task_status?"|"+E.task_status:""]},M))})]})]}):c.jsx("div",{className:"muted",children:"(先选项目)"})}function $g(){return c.jsx(Jt,{id:"nav-automations",title:"自动化",children:c.jsx(Fg,{})})}function Ig(){const i=Gt();return c.jsxs(Jt,{id:"nav-triage",title:"分诊收件箱",children:[c.jsx(zt,{children:c.jsx("button",{onClick:i.refreshTriage,children:"刷新分诊"})}),c.jsx(Wg,{})]})}const po=["pending","in_progress","blocked","completed","cancelled"],Hc={pending:"待办",in_progress:"进行中",blocked:"受阻",completed:"已完成",cancelled:"已取消"},Pg=["low","medium","high"],Jh={low:"低",medium:"中",high:"高"};function tp(i){return i?new Date(i*1e3).toLocaleString():""}function ep(){const i=Gt(),[r,h]=d.useState([]),[f,g]=d.useState({}),[A,O]=d.useState(0),[b,E]=d.useState(""),[M,q]=d.useState(""),[p,H]=d.useState(""),[Z,F]=d.useState(""),[it,ft]=d.useState(""),[Et,st]=d.useState(""),[Ht,Q]=d.useState("medium"),[ct,z]=d.useState(""),[L,at]=d.useState(""),jt=d.useCallback(async()=>{if(!i.projectId){h([]),g({}),O(0);return}try{let B=$.projectTodos(i.projectId);const mt=new URLSearchParams;p&&mt.set("status",p),Z.trim()&&mt.set("q",Z.trim());const Ct=mt.toString();Ct&&(B+="?"+Ct);const V=await I(B);h(V.todos||[]),g(V.counts||{}),O(V.total||0),q("")}catch(B){q("待办加载失败:"+((B==null?void 0:B.message)||String(B)))}},[i.projectId,p,Z]);d.useEffect(()=>{jt()},[jt]);const Mt=async()=>{if(!i.projectId){E("先选项目");return}if(!it.trim()){E("标题必填");return}E("");try{const B={title:it.trim(),description:Et,priority:Ht};ct.trim()&&(B.task_id=ct.trim()),L&&(B.due_at=Math.floor(new Date(L).getTime()/1e3)),await I($.projectTodos(i.projectId),"POST",B),ft(""),st(""),z(""),at(""),E("已创建"),await jt()}catch(B){E("创建失败:"+((B==null?void 0:B.message)||String(B)))}},yt=async(B,mt)=>{if(i.projectId)try{await I($.projectTodo(i.projectId,B.id),"PATCH",mt),await jt()}catch(Ct){q("待办更新失败:"+((Ct==null?void 0:Ct.message)||String(Ct)))}},bt=async B=>{if(i.projectId&&window.confirm("删除待办“"+B.title+"”?删除后不可恢复(取消请改状态为已取消)。"))try{await I($.projectTodo(i.projectId,B.id),"DELETE"),await jt()}catch(mt){q("待办删除失败:"+((mt==null?void 0:mt.message)||String(mt)))}};return c.jsxs(Jt,{id:"nav-todos",title:"项目待办",children:[c.jsx("div",{className:"muted",children:i.projectId?"共 "+A+" 项 | "+po.map(B=>Hc[B]+" "+(f[B]||0)).join(" | "):"先选项目"}),M?c.jsx("div",{className:"err",children:M}):null,c.jsxs(zt,{children:[c.jsxs("select",{"aria-label":"状态筛选",value:p,onChange:B=>H(B.target.value),children:[c.jsx("option",{value:"",children:"状态:全部"}),po.map(B=>c.jsx("option",{value:B,children:Hc[B]},B))]}),c.jsx("input",{type:"text",placeholder:"搜索标题/说明",value:Z,onChange:B=>F(B.target.value)}),c.jsx("button",{onClick:()=>void jt(),children:"刷新"})]}),c.jsx("input",{type:"text",placeholder:"待办标题(必填)",style:{width:"100%"},value:it,onChange:B=>ft(B.target.value)}),c.jsx("textarea",{placeholder:"说明(可选)",value:Et,onChange:B=>st(B.target.value)}),c.jsxs(zt,{children:[c.jsx("select",{"aria-label":"优先级",value:Ht,onChange:B=>Q(B.target.value),children:Pg.map(B=>c.jsxs("option",{value:B,children:["优先级:",Jh[B]]},B))}),c.jsx("input",{type:"text",placeholder:"关联任务 id(可选)",value:ct,onChange:B=>z(B.target.value)}),c.jsx("input",{type:"datetime-local","aria-label":"截止时间",value:L,onChange:B=>at(B.target.value)}),c.jsx("button",{className:"primary",onClick:()=>void Mt(),children:"新建待办"}),c.jsx("span",{className:"muted",children:b})]}),c.jsx(ul,{empty:i.projectId?"(暂无待办)":"(先选项目)",items:r.map(B=>c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:B.title})," | ",c.jsx("span",{className:"st-"+B.status,children:Hc[B.status]||B.status})," | 优先级 "+(Jh[B.priority]||B.priority),B.task_id?" | 任务 "+se(B.task_id):"",B.due_at?" | 截止 "+tp(B.due_at):"",B.completed_at?" | 完成于 "+new Date(B.completed_at*1e3).toLocaleString():"",B.description?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsx("span",{className:"muted",children:B.description})]}):null,c.jsx("br",{}),c.jsx("select",{"aria-label":"改状态",value:B.status,onChange:mt=>void yt(B,{status:mt.target.value}),children:po.map(mt=>c.jsx("option",{value:mt,children:Hc[mt]},mt))})," ",c.jsx("button",{onClick:()=>void yt(B,{status:"completed"}),children:"完成"})," ",c.jsx("button",{onClick:()=>void bt(B),children:"删除"})]},B.id))}),c.jsx("div",{className:"muted",children:"状态与 Muse 待办同款五态;完成时服务端记完成时间,改回其它状态时清掉;已取消是软关闭,删除才是硬删除。"})]})}const bo=[["tasks","任务工作台","任务"],["todos","项目待办","任务"],["resources","环境与 Workers","管理"],["automation","技能与自动化","管理"],["admin","系统与 Key","管理"]],lp=Object.fromEntries(bo.map(([i,r])=>[i,r]));function Fh(){const i=window.location.hash.replace("#view-","");return bo.some(([r])=>r===i)?i:"tasks"}function ap({view:i,setView:r}){return c.jsxs("aside",{className:"shell-side",children:[c.jsx("div",{className:"shell-brand",children:"Codex 命令台"}),c.jsx("nav",{className:"shell-nav","aria-label":"导航",children:["任务","管理"].map(h=>c.jsxs("div",{className:"shell-nav-group",children:[c.jsx("div",{className:"shell-nav-group-label",children:h}),bo.filter(([,,f])=>f===h).map(([f,g])=>c.jsx("button",{type:"button",className:i===f?"active":"",onClick:()=>r(f),children:g},f))]},h))}),c.jsx("div",{id:"nav-projects",className:"shell-project-nav",children:c.jsx(Yg,{})}),c.jsxs("div",{className:"shell-stats",children:[c.jsx("div",{className:"shell-stats-label",children:"概览"}),c.jsx(dg,{})]})]})}function np(){const i=Gt();return c.jsx("span",{className:i.keySaved?"dash-status ok":"dash-status off",children:i.keySaved?"● 已连接":"○ 未连接"})}function up(){const i=fg();sg(b=>i.refreshAll(b),1e4),rg(()=>i.refreshSlow(),6e4);const[r,h]=d.useState(()=>Fh()),[f,g]=d.useState(!0),A=b=>{h(b);try{window.history.replaceState(null,"","#view-"+b)}catch{}};d.useEffect(()=>{const b=()=>h(Fh());return window.addEventListener("hashchange",b),()=>window.removeEventListener("hashchange",b)},[]);const O=()=>{A("tasks"),g(!0),setTimeout(()=>{var b;return(b=document.getElementById("sec-dispatch"))==null?void 0:b.scrollIntoView({behavior:"smooth"})},0)};return c.jsx($h.Provider,{value:i,children:c.jsxs("div",{className:"shell",children:[c.jsx(ap,{view:r,setView:A}),c.jsxs("div",{className:"shell-main",children:[c.jsxs("header",{className:"shell-header",children:[c.jsx("h1",{children:lp[r]}),c.jsx(np,{}),c.jsx("span",{className:"muted shell-hide-sm",children:i.healthText}),c.jsx("span",{className:"muted shell-hide-sm",children:i.quotaText}),c.jsxs("span",{className:"muted shell-stamp",children:["自动刷新 · 每 10 秒 · 已刷新 ",i.refreshStamp]})]}),r==="tasks"?c.jsxs("div",{className:"workspace",children:[c.jsxs("section",{className:"workspace-rail","aria-label":"任务列表",children:[c.jsx("h2",{children:"任务列表"}),c.jsx(gg,{onNew:O})]}),c.jsxs("section",{className:"workspace-center",children:[c.jsx("div",{className:"composer-toggle",children:c.jsx("button",{type:"button",onClick:()=>g(!f),children:f?"收起新建任务":"新建任务"})}),f?c.jsx("section",{id:"sec-dispatch",children:c.jsx(vg,{})}):null,c.jsxs("section",{className:"card workspace-conv",children:[c.jsx("h2",{children:"对话"}),c.jsx(Tg,{})]})]}),c.jsx("section",{className:"workspace-inspector","aria-label":"任务详情",children:c.jsx(Ug,{})})]}):c.jsx("main",{className:"page",children:r==="todos"?c.jsx(c.Fragment,{children:c.jsx(ep,{})}):r==="resources"?c.jsxs(c.Fragment,{children:[c.jsx(Gg,{}),c.jsx(Qg,{}),c.jsx(Vg,{}),c.jsx(Xg,{})]}):r==="automation"?c.jsxs(c.Fragment,{children:[c.jsx(Lg,{}),c.jsx(Zg,{}),c.jsx($g,{}),c.jsx(Ig,{})]}):c.jsxs(c.Fragment,{children:[c.jsx(Bg,{}),c.jsx(Hg,{})]})})]})]})})}Wy.createRoot(document.getElementById("root")).render(c.jsx(Qy.StrictMode,{children:c.jsx(up,{})}));
+`)}}b("流已断开")}catch(at){if(at.name==="AbortError")return;M(at instanceof Error?at.message:String(at)),b("未开始")}finally{p(!1)}},[r]),ft=d.useCallback(()=>{var z;(z=H.current)==null||z.abort(),p(!1),b("已停止")},[]),Et=d.useCallback(async z=>{if(r)try{const L=await I($.taskBrowserControl(r.id),"POST",{holder:z});A(L),M("")}catch(L){M(L instanceof Error?L.message:String(L))}},[r]),st=d.useCallback(async z=>{if(!r||(g==null?void 0:g.holder)!=="user"||!Z.current)return;const L=Z.current.getBoundingClientRect(),at=Math.round((z.clientX-L.left)/L.width*1280),jt=Math.round((z.clientY-L.top)/L.height*800);try{await I($.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:at,y:jt})}catch(Mt){M(Mt instanceof Error?Mt.message:String(Mt))}},[r,g]),Ht=d.useCallback(async z=>{if(!(!r||(g==null?void 0:g.holder)!=="user")){z.preventDefault();try{z.key.length===1?await I($.taskBrowserInput(r.id),"POST",{type:"type",text:z.key}):await I($.taskBrowserInput(r.id),"POST",{type:"key",key:z.key})}catch(L){M(L instanceof Error?L.message:String(L))}}},[r,g]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const Q=(g==null?void 0:g.holder)||"—",ct=g!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:O})]}),c.jsxs("span",{className:"muted",children:["会话 ",ct?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",Q]}),g!=null&&g.url?c.jsx("span",{className:"muted",title:g.url,children:g.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[q?c.jsx("button",{type:"button",onClick:ft,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void it(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void F(),children:"刷新状态"}),Q!=="user"?c.jsx("button",{type:"button",onClick:()=>void Et("user"),disabled:!ct,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void Et("agent"),children:"交还给 Agent"})]}),E?c.jsx("div",{className:"muted",style:{marginTop:8},children:E}):null,Q==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:Z,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:z=>void st(z),onKeyDown:z=>void Ht(z),style:{width:"100%",maxWidth:1280,cursor:Q==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:ct?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function Ng(){const i=Gt(),[r,h]=d.useState("uncommittedChanges"),[f,g]=d.useState(""),[A,O]=d.useState(""),[b,E]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:M=>h(M.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:M=>g(M.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:A,onChange:M=>O(M.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:b,onChange:M=>E(M.target.value)}),c.jsxs(zt,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:A.trim()||void 0,instructions:b.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(Sn,{children:i.reviews.map(M=>{let q=M.target_type||"";return M.branch&&(q+=":"+M.branch),M.sha&&(q+=":"+String(M.sha).slice(0,12)),M.instructions&&(q+=":"+String(M.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",se(M.id)," | ",q," | ",c.jsx("span",{className:"st-"+(M.status||""),children:M.status}),M.created_at?" | "+Ge(M.created_at):"",c.jsx("br",{}),M.findings?String(M.findings).slice(0,1e3):c.jsx("span",{className:"muted",children:"(暂无 findings:评审尚未执行或尚未写回)"})]}),c.jsx("button",{onClick:()=>i.submitFindings(M.id),children:"写 findings"})]},M.id)})})]})}function Cg(){const i=Gt();return i.currentTask?i.currentTask.coordinator_parts?i.coordChildren.length===0?c.jsx("div",{className:"muted",children:"(子任务尚未创建)"}):c.jsx(Sn,{children:i.coordChildren.map(r=>c.jsx("li",{children:c.jsxs("span",{className:"row-main",children:["分工 ",c.jsx("b",{children:r.coordinator_part||""})," | 状态 ",c.jsx("span",{className:"st-"+r.status,children:r.status})," | ",se(r.id)," | ",String(r.prompt||"").slice(0,60)]})},r.id))}):c.jsx("div",{className:"muted",children:"(非协调员任务)"}):c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示协调员的子任务"})}function Ag(){const i=Gt();return i.currentTask?i.sideThreads.length===0?c.jsx("div",{className:"muted",children:"(暂无旁聊;列表“开旁聊”可在此任务下新建,不建 worktree)"}):c.jsx(Sn,{children:i.sideThreads.map(r=>c.jsx("li",{onClick:()=>i.showDetail(r.id),style:{cursor:"pointer"},children:c.jsxs("span",{className:"row-main",children:["旁聊 | 状态 ",c.jsx("span",{className:"st-"+r.status,children:r.status})," | ",se(r.id)," | ",String(r.prompt||"").slice(0,60)]})},r.id))}):c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示旁聊"})}function Mg(){const i=Gt();if(!i.currentTask)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示子代理链路"});if(i.agents.length===0)return c.jsxs("div",{className:"muted",children:["(暂无子代理)",i.agentsNote?c.jsx("span",{className:"err",children:i.agentsNote}):null]});const r={main:"主任务",delegate:"委派子任务",model:"模型子代理"},h={running:"进行中",completed:"已完成",failed:"失败",interrupted:"已中断",unknown:"未知"};return c.jsx(Sn,{children:i.agents.map((f,g)=>c.jsx("li",{children:c.jsxs("span",{className:"row-main",children:[c.jsxs("b",{children:[r[f.kind||""]||f.kind||"",":"]}),f.name||""," ",c.jsx("span",{className:"st-"+(f.status||""),children:h[f.status||""]||f.status||"?"})," | 过程调用 "+(f.calls?f.calls.length:0)+" 条",f.updated_ts?" | 最近活动 "+Ge(f.updated_ts/1e3):"",f.prompt?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsxs("span",{className:"muted",children:["任务:",f.prompt]})]}):null,f.calls&&f.calls.length?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),f.calls.slice(-50).map((A,O)=>c.jsx("div",{children:So(A)},O))]}):c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsx("span",{className:"muted",children:"(暂无过程调用)"})]})]})},g))})}function Og(){const i=Gt();return c.jsxs(c.Fragment,{children:[c.jsxs(zt,{children:[c.jsx("button",{onClick:i.refreshApprovals,children:"刷新审批队列"}),c.jsx("span",{className:"muted",children:i.approvalMsg})]}),c.jsx("div",{className:"muted",children:"待审批项需人工点记录允许或记录拒绝;超时默认拒绝。当前版本只留回执,不拦截实时行为(实时请求的事前拦截待 host 接入后生效,在此之前实时行为不变)。"}),c.jsx(ul,{empty:"(暂无审批项)",items:i.approvals.map(r=>c.jsxs("span",{className:"row-main",children:["[",r.kind,"|",r.status,"] ",r.summary,["approved","denied","expired"].includes(r.status||"")?"|回执:"+(r.decision||r.status)+" by "+String(r.decided_by||"").slice(0,8)+" "+Ge(r.decided_at):"",r.status==="pending"?c.jsxs(c.Fragment,{children:[" ",c.jsx("button",{onClick:()=>i.decideApproval(r.id,"approved"),children:"记录允许"})," ",c.jsx("button",{onClick:()=>i.decideApproval(r.id,"denied"),children:"记录拒绝"})]}):null]},r.id))})]})}function zg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"目标任务 id(与本任务同项目;对方运行中排队执行、已终态则续跑复活,绝不打断当前轮)",style:{width:"100%"},value:r,onChange:A=>h(A.target.value)}),c.jsx("textarea",{placeholder:"消息正文(以本任务名义发出;对方 agent 会看到发送方任务 id,可照此回消息)",value:f,onChange:A=>g(A.target.value)}),c.jsxs(zt,{children:[c.jsx("button",{onClick:async()=>{await i.postPeer("queued",r,f),g("")},children:"排队发给对方"}),c.jsx("button",{className:"primary",onClick:async()=>{await i.postPeer("direct",r,f),g("")},children:"直接发送"}),c.jsx("span",{className:"muted",children:i.peerMsg})]}),c.jsx(ul,{empty:"(本任务尚未发出任务间消息)",items:i.peerMessages.map((A,O)=>c.jsxs("span",{className:"row-main",children:["[发给 ",String(A.task_id).slice(0,8),"|",A.mode,"|",A.status,"] ",String(A.body||"").slice(0,120)]},O))})]})}function Rg(){const i=Gt(),[r,h]=d.useState(""),f=i.currentTask?["completed","failed","interrupted"].includes(i.currentTask.status):!1;return c.jsxs(c.Fragment,{children:[c.jsx("div",{className:"muted",children:i.threadText||"点任务行查看详情后,这里显示线程元数据"}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"新线程名",value:r,onChange:g=>h(g.target.value)}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("rename",r),children:"重命名"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("archive",""),children:"归档"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("unarchive",""),children:"取消归档"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("compact",""),children:"压缩"}),c.jsx("span",{className:"muted",children:i.threadGateNote}),c.jsx("span",{className:"muted",children:i.threadMsg})]}),c.jsx(ul,{empty:"暂无其他线程",items:i.threadList.map((g,A)=>c.jsxs("span",{className:"row-main",children:[g.name||g.thread_name||g.id||""," [",g.id||"","]",g.archived?" [已归档]":"",g.cwd?" "+g.cwd:""]},A))}),c.jsx("div",{className:"muted",children:"限制:Docker/异机任务线程操作一律 409;merged/merge-failed 不可改;turn/steer 与 review/start 不在本批。"})]})}function Dg(i){return/503|Service Unavailable/.test(i)?"上游模型服务暂时不可用(503):模型一轮都没跑出来,不是任务逻辑失败;等上游恢复或换模型重试":/429|rate limit/i.test(i)?"上游限流(429):模型拒绝了本次调用,可稍后重试":/timeout|timed out/i.test(i)?"上游调用超时:模型响应未在限时内返回":/401|403|Invalid API key|unauthorized/i.test(i)?"上游鉴权失败:网关 key 或权限有问题":/ECONNREFUSED|ENOTFOUND|network|fetch failed/i.test(i)?"连不上上游模型服务:网络或网关地址有问题":"任务失败(原因见原始错误文本)"}function kg(i){return i===null||i<0?"未知":i<60?Math.floor(i)+" 秒前":i<3600?Math.floor(i/60)+" 分钟前":(i/3600).toFixed(1)+" 小时前"}const wg=[["overview","概览"],["collab","协作"],["review","评审与审批"],["threads","线程"],["browser","浏览器"]];function Ug(){const i=Gt(),r=i.currentTask,[h,f]=d.useState("overview");if(!r)return c.jsx(Jt,{id:"nav-detail",title:"任务详情",children:c.jsx("div",{className:"muted",children:"点任务行查看详情"})});const[g,A]=Ph(r),[O,b]=tv(r),E=oi(i.workers,i.workersSummary,i.workersError),M=Bc(r.executor,r.status,E),q=Ih(r),p=i.costSummary,H=r.plan?"计划模式(只读)":r.goal_mode?r.no_time_limit?"goal 模式(不限轮次,模型宣告完成;任务不限时)":"goal 模式(不限轮次,模型宣告完成)":r.verify_command?"goal 模式(旧语义,验收命令)":null;return c.jsxs("div",{className:"detail-stack",id:"nav-detail",children:[c.jsx("nav",{className:"detail-tabs","aria-label":"任务详情页签",children:wg.map(([Z,F])=>c.jsx("button",{type:"button",className:h===Z?"active":"",onClick:()=>f(Z),children:F},Z))}),h==="overview"?c.jsxs(c.Fragment,{children:[c.jsxs(Jt,{title:"任务详情",children:[c.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"},children:[c.jsx("span",{className:"issue-first",title:"任务首字:"+q,"aria-hidden":"true",children:q}),c.jsxs("span",{children:["状态 ",c.jsx("b",{className:"st-"+r.status,children:r.status}),"(",Iy[r.status]||"未知状态",")"]}),c.jsxs("span",{className:"muted",children:["更新 ",Ge(r.updated_at)]})]}),c.jsxs("div",{className:"detail-cards",children:[c.jsxs("div",{className:"detail-card",title:A,children:[c.jsx("div",{className:"detail-card-label",children:"耗时"}),c.jsx("div",{className:"detail-card-value",children:g})]}),c.jsxs("div",{className:"detail-card",title:b,children:[c.jsx("div",{className:"detail-card-label",children:"token"}),c.jsx("div",{className:"detail-card-value",children:O})]}),c.jsxs("div",{className:"detail-card",title:"LLM 请求数 = GET /tasks/{id}/cost 的 llm_requests,即 cost_records 的 completions 之和:host.py 每收到一次 thread/tokenUsage/updated(一次上游补全)计 1,worker 每轮终局记账时累加;估算行(无真实用量)计 0;部署前已记账的老任务没有 completions 列,显示 0 而非回填数",children:[c.jsx("div",{className:"detail-card-label",children:"LLM 请求数"}),c.jsx("div",{className:"detail-card-value",children:p===void 0?"加载中…":p===null?"—":(p.llm_requests??0).toLocaleString()}),p===void 0?c.jsx("div",{className:"muted",children:"成本加载中…"}):p===null?c.jsx("div",{className:"muted",children:"成本明细加载失败,LLM 请求数不可用"}):c.jsxs("div",{className:"muted",children:["上游补全次数之和| 轮数 ",p.turns??0," | fallback ",p.fallback_count??0]})]}),c.jsxs("div",{className:"detail-card",title:"成本明细:GET /tasks/{id}/cost 聚合;美元只计有价行,无价 token 单列;缓存是输入 token 的子集,不另计",children:[c.jsx("div",{className:"detail-card-label",children:"成本"}),c.jsx("div",{className:"detail-card-value",children:p===void 0?"加载中…":p===null?"—":yg(p.usd_micros)}),p===void 0?c.jsx("div",{className:"muted",children:"成本加载中…"}):p===null?c.jsx("div",{className:"muted",children:"成本明细加载失败(“查成本”按钮仍可手动拉取原始 JSON)"}):c.jsxs("div",{className:"muted",children:["token ",(p.total_tokens??0).toLocaleString(),"(实际 ",(p.actual_tokens??0).toLocaleString()," / 估算 ",(p.estimated_tokens??0).toLocaleString(),",缓存 ",(p.cached_input_tokens??0).toLocaleString(),",无价 ",(p.unpriced_actual_tokens??0).toLocaleString(),")| fallback ",p.fallback_count??0]})]})]})]}),r.status==="running"||r.status==="queued"?(()=>{const Z=Date.now()/1e3,F=r.last_heartbeat||r.updated_at||null,it=F?Z-F:null;return c.jsxs(Jt,{title:"LLM 进度",children:[c.jsxs("div",{children:["状态 ",r.status==="running"?"进行中":"排队中"," · 已用 ",g,it!==null?" · 上次活动 "+kg(it):""]}),M?c.jsx("div",{className:"muted",children:M}):null,it!==null&&it>180?c.jsx("div",{className:"muted",children:"超过 3 分钟无活动:可能在等上游模型响应(上游 503 时 worker 会重试后失败,失败后这里会显示失败原因;无活动不等于已断连,服务端没有“LLM 已连接”字段,不作此断言)"}):c.jsx("div",{className:"muted",children:"首轮无输出时属正常:模型第一轮回复到达前 trace 里没有消息;回复到达后会在会话区出现,成本里的 LLM 请求数随之增加"})]})})():null,r.error_text?c.jsxs(Jt,{title:"失败原因",children:[c.jsx("div",{children:Dg(r.error_text)}),c.jsx("pre",{style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:r.error_text.slice(0,1e3)}),c.jsxs("div",{className:"muted",children:["完整错误文本仍在下方“操作与结果”里;模型 ",r.model||"默认"," · executor ",r.executor||""]})]}):null,c.jsx(Jt,{title:"基本信息",children:c.jsxs("dl",{className:"detail-kv",children:[c.jsxs("div",{children:[c.jsx("dt",{children:"任务"}),c.jsx("dd",{children:r.id})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"turn"}),c.jsx("dd",{children:se(r.turn_id)})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"root turn"}),c.jsx("dd",{children:se(r.root_turn_id)})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"executor"}),c.jsxs("dd",{children:[r.executor||"",r.executor==="cloud-hypervisor"?"(实验执行器,默认关闭;仅展示,无 VM 控制按钮)":""]})]}),r.chv_vm_dead?c.jsxs("div",{children:[c.jsx("dt",{children:"VM 状态"}),c.jsx("dd",{children:"VM 中途死亡,任务未完成、断点可续跑(后端只读字段 chv_vm_dead;无该字段时不代表 VM 活着)"})]}):null,c.jsxs("div",{children:[c.jsx("dt",{children:"主机"}),c.jsx("dd",{children:r.worktree_host||""})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"模型"}),c.jsx("dd",{children:r.model||"默认"})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"强度"}),c.jsx("dd",{children:r.effort||"默认"})]}),r.child_model||r.child_effort?c.jsxs("div",{children:[c.jsx("dt",{children:"子任务默认"}),c.jsx("dd",{children:(r.child_model||"跟随本任务")+" / "+(r.child_effort||"跟随本任务")})]}):null,c.jsxs("div",{children:[c.jsx("dt",{children:"权限"}),c.jsx("dd",{children:r.approval||"默认"})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"项目"}),c.jsx("dd",{children:se(r.project_id)})]}),H?c.jsxs("div",{children:[c.jsx("dt",{children:"模式"}),c.jsx("dd",{children:H})]}):null,r.kind==="side"?c.jsxs("div",{children:[c.jsx("dt",{children:"类型"}),c.jsx("dd",{children:"旁聊(无 worktree/分支/容器)"})]}):null,r.forked_from?c.jsxs("div",{children:[c.jsx("dt",{children:"分支自"}),c.jsx("dd",{children:se(r.forked_from)})]}):null,r.coordinator_parts?c.jsxs("div",{children:[c.jsx("dt",{children:"协调员"}),c.jsxs("dd",{children:[r.coordinator_parts.length," 个子任务(",r.coordinator_parts.map(Z=>Z.name).join("、"),")"]})]}):null,r.parent_task_id&&r.kind==="side"?c.jsxs("div",{children:[c.jsx("dt",{children:"父会话"}),c.jsxs("dd",{children:["父任务 ",se(r.parent_task_id)," ",c.jsx("button",{type:"button",onClick:()=>i.showDetail(r.parent_task_id),children:"跳转到父任务"})]})]}):null,r.parent_task_id&&r.kind!=="side"?c.jsxs("div",{children:[c.jsx("dt",{children:"协调子任务"}),c.jsxs("dd",{children:["父任务 ",se(r.parent_task_id),r.coordinator_part?"("+r.coordinator_part+")":""]})]}):null,r.attachments&&r.attachments.length?c.jsxs("div",{children:[c.jsx("dt",{children:"附件"}),c.jsx("dd",{children:r.attachments.join("、")})]}):null,r.pr_url?c.jsxs("div",{children:[c.jsx("dt",{children:"PR"}),c.jsx("dd",{children:c.jsxs("a",{href:r.pr_url,target:"_blank",rel:"noopener noreferrer",children:["PR #",r.pr_number]})})]}):null]})}),c.jsx(Jt,{title:"prompt",children:c.jsx("div",{style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:r.prompt||"(无 prompt)"})}),c.jsxs(Jt,{title:"操作与结果",children:[c.jsx(_g,{}),c.jsx("pre",{children:i.detailBody||"(暂无 result/error 输出)"}),i.costBox?c.jsx("pre",{children:i.costBox}):null]})]}):h==="collab"?c.jsxs(c.Fragment,{children:[c.jsx(Jt,{title:"协调子任务",children:c.jsx(Cg,{})}),c.jsx(Jt,{title:"旁聊",children:c.jsx(Ag,{})}),c.jsx(Jt,{title:"子代理链路",children:c.jsx(Mg,{})}),c.jsx(Jt,{title:"给其他任务发消息",children:c.jsx(zg,{})})]}):h==="review"?c.jsxs(c.Fragment,{children:[c.jsx(Jt,{title:"评审队列",children:c.jsx(Ng,{})}),c.jsx(Jt,{title:"运行时审批队列",children:c.jsx(Og,{})})]}):h==="browser"?c.jsx(c.Fragment,{children:c.jsx(Jt,{title:"浏览器远控",children:c.jsx(Eg,{})})}):c.jsx(c.Fragment,{children:c.jsx(Jt,{title:"线程",children:c.jsx(Rg,{})})})]})}function Hg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState(""),[A,O]=d.useState([]),[b,E]=d.useState(null),[M,q]=d.useState(!1),[p,H]=d.useState(""),[Z,F]=d.useState(""),it=d.useCallback(async()=>{if(!i.keySaved){O([]);return}try{const Q=await I($.userKeys);O(Q.keys||[]),F("")}catch(Q){F(Q instanceof Error?Q.message:"加载 key 列表失败")}},[i.keySaved]);d.useEffect(()=>{it()},[it]);const ft=async()=>{H(""),F(""),q(!1);try{const Q=await I($.userKeys,"POST",{label:f.trim()||"api-key"});E(Q),g(""),H("新 key 已生成,只显示一次:复制后请妥善保存。"),await it()}catch(Q){F(Q instanceof Error?Q.message:"生成 key 失败")}},Et=async()=>{if(b)try{await navigator.clipboard.writeText(b.api_key),q(!0)}catch{F("复制失败:请手动选中上方 key 文本复制")}},st=()=>{b&&(i.saveKeyInput(b.api_key),E(null),H("已存为当前浏览器 key(原有 key 未被撤销,需要时可单独撤销)。"))},Ht=async Q=>{const ct=(()=>{try{return nl().slice(-4)}catch{return""}})(),z=Q.id==="legacy"?"(主 key:撤销后当前浏览器保存的 key 也可能随之失效,需重新登录拿新 key)":Q.hint&&Q.hint===ct?"(这就是本浏览器当前在用的 key,撤销后本页会立刻未连接)":"";if(window.confirm(`撤销 key「${Q.label}」(后 4 位 ${Q.hint||"无"})吗?${z}撤销不可恢复。`)){H(""),F("");try{await I($.userKey(Q.id),"DELETE"),H(`已撤销 key「${Q.label}」。`),await it()}catch(L){F(L instanceof Error?L.message:"撤销失败")}}};return c.jsxs(Jt,{id:"nav-key",title:"API Key",children:[c.jsxs(zt,{children:[c.jsx("input",{type:"password",placeholder:"粘贴你的 X-API-Key(与调 API 用的同一个)",autoComplete:"off",value:r,onChange:Q=>h(Q.target.value)}),c.jsx("button",{className:"primary",onClick:()=>{i.saveKeyInput(r),h("")},children:"保存到本浏览器"}),c.jsx("button",{onClick:i.clearKeyInput,children:"清除"}),c.jsx("span",{className:"muted",children:i.keyState})]}),c.jsx("div",{children:c.jsx("span",{className:"muted",children:i.quotaText})}),!i.keySaved&&c.jsx("div",{children:c.jsx("span",{className:"muted",children:"先粘贴并保存已有 key,再生成或管理 key(生成接口本身也要用已有 key 鉴权)。"})}),i.keySaved&&c.jsxs(c.Fragment,{children:[c.jsxs(zt,{children:[c.jsx("input",{placeholder:"新 key 备注(可选,默认 api-key,如 harness2-temp)",autoComplete:"off",value:f,onChange:Q=>g(Q.target.value)}),c.jsx("button",{className:"primary",onClick:()=>void ft(),children:"生成新 key"}),c.jsx("button",{onClick:()=>void it(),children:"刷新列表"})]}),b&&c.jsxs("div",{children:[c.jsx("div",{children:c.jsxs("span",{className:"muted",children:["新 key(",b.label,",后 4 位 ",b.hint,"):只显示一次,关闭后无法再查看。"]})}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",readOnly:!0,value:b.api_key,onFocus:Q=>Q.target.select()}),c.jsx("button",{className:"primary",onClick:()=>void Et(),children:M?"已复制":"复制"}),c.jsx("button",{onClick:st,children:"存为当前浏览器 key"}),c.jsx("button",{onClick:()=>E(null),children:"关闭(不再显示)"})]})]}),p&&c.jsx("div",{children:c.jsx("span",{className:"muted",children:p})}),Z&&c.jsx("div",{children:c.jsx("span",{children:Z})}),c.jsxs("div",{className:"api-key-list",children:[A.length===0&&c.jsx("span",{className:"muted",children:"暂无 key(或列表加载失败,见上方报错)。"}),A.map(Q=>c.jsxs("div",{children:[c.jsx("span",{children:Q.label})," ",c.jsxs("span",{className:"muted",children:["后 4 位 ",Q.hint||"无"," · 创建 ",Ge(Q.created_at)," · 最近使用 ",Q.last_used_at?Ge(Q.last_used_at):"未使用过"]})," ",Q.revoked_at?c.jsxs("span",{className:"muted",children:["已撤销(",Ge(Q.revoked_at),")"]}):c.jsx("button",{onClick:()=>void Ht(Q),children:"撤销"})]},Q.id))]})]})]})}function Bg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState("");return c.jsxs(Jt,{id:"adminCard",title:c.jsxs(c.Fragment,{children:["管理区 ",c.jsx("span",{className:"muted",children:"(仅全局管理员)"})]}),children:[c.jsx("div",{className:"muted",children:i.adminGate}),i.adminBody?c.jsxs(c.Fragment,{children:[c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"要查配额的用户 id",value:i.adminUserId,onChange:A=>i.setAdminUserId(A.target.value)}),c.jsx("button",{onClick:i.adminQuota,children:"查该用户配额"})]}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"要设置配额的用户 id",value:r,onChange:A=>h(A.target.value)}),c.jsx("input",{type:"text",placeholder:"美元配额(留空 = 不限额)",value:f,onChange:A=>g(A.target.value)}),c.jsx("button",{className:"primary",onClick:()=>i.adminSetQuota(r,f),children:"设置配额"})]}),c.jsxs(zt,{children:[c.jsx("button",{onClick:i.adminSweep,children:"手动触发 sweep(清理陈旧 running 任务)"}),c.jsx("button",{onClick:i.adminBackup,children:"手动触发备份"})]}),c.jsx("div",{className:"muted",children:i.adminResult})]}):null]})}function qg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState("contributor"),A=i.projects.find(O=>O.id===i.projectId);return c.jsxs(Jt,{title:"项目成员",children:[A?c.jsxs("div",{className:"muted",children:[A.name," | ",A.id," | 仓库 ",A.repo_url||"未绑定"," | 我的角色 ",A.my_role||"未知"]}):c.jsx("div",{className:"muted",children:"先选项目"}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"成员 user_id 或用户名",value:r,onChange:O=>h(O.target.value)}),c.jsxs("select",{"aria-label":"邀请角色",value:f,onChange:O=>g(O.target.value),children:[c.jsx("option",{value:"viewer",children:"viewer(只读)"}),c.jsx("option",{value:"contributor",children:"contributor(可建任务)"}),c.jsx("option",{value:"maintainer",children:"maintainer(可批合并)"}),c.jsx("option",{value:"owner",children:"owner(可管项目)"})]}),c.jsx("button",{onClick:async()=>{await i.inviteMember(r,f),h("")},children:"邀请成员"}),c.jsx("button",{onClick:i.refreshMembers,children:"刷新成员"})]}),i.memberError?c.jsx("div",{className:"muted",children:i.memberError}):i.members.length===0?c.jsx("div",{className:"muted",children:"先选项目"}):c.jsx(Sn,{children:i.members.map(O=>{const b=(O.username||"")+(O.username?"("+String(O.user_id).slice(0,8)+")":String(O.user_id).slice(0,8));return c.jsxs("li",{children:[c.jsx("span",{className:"row-main",children:b}),c.jsx("select",{"aria-label":"成员角色",value:O.role||"viewer",onChange:E=>void i.changeMemberRole(O.user_id,E.target.value),children:Py.map(E=>c.jsx("option",{value:E,children:E},E))}),c.jsx("button",{onClick:()=>void i.removeMember(O.user_id),children:"移除"})]},O.user_id)})}),c.jsx("span",{className:"muted",children:i.memberMsg})]})}function Yg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState(""),[A,O]=d.useState("");return c.jsxs("div",{className:"project-nav",children:[c.jsxs(Jt,{title:"项目",children:[c.jsx(zt,{children:c.jsx("button",{onClick:i.refreshProjects,children:"刷新项目"})}),i.projects.length===0?c.jsx("div",{className:"muted",children:"暂无项目"}):c.jsx("ul",{className:"project-nav-list",children:i.projects.map(b=>c.jsxs("li",{className:"project-nav-item"+(b.id===i.projectId?" selected":""),onClick:()=>i.selectProject(b.id),title:b.id,children:[c.jsx("span",{className:"project-nav-name",children:b.name}),c.jsxs("span",{className:"project-nav-meta",children:[se(b.id),b.my_role?" · "+b.my_role:""]})]},b.id))}),c.jsx("h3",{children:"创建项目"}),c.jsx(zt,{children:c.jsx("input",{type:"text",placeholder:"新项目名",value:r,onChange:b=>h(b.target.value)})}),c.jsx(zt,{children:c.jsxs("select",{"aria-label":"从 GitHub 仓库选",value:f,onChange:b=>{g(b.target.value),b.target.value&&O(b.target.value)},children:[c.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选,或手动输入)"}),i.githubRepos.map(b=>c.jsx("option",{value:b.clone_url,children:b.repo_full_name},b.clone_url))]})}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"repo_url(可选)",value:A,onChange:b=>O(b.target.value)}),c.jsx("button",{className:"primary",onClick:async()=>{await i.createProject(r,A),h(""),O(""),g("")},children:"创建项目"})]}),c.jsx("span",{className:"muted",children:i.projectMsg})]}),c.jsx(qg,{})]})}function Gg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState(""),[A,O]=d.useState(""),b=oi(i.workers,i.workersSummary,i.workersError);return c.jsxs(Jt,{id:"nav-env",title:c.jsxs(c.Fragment,{children:["环境 ",c.jsx("span",{className:"muted",children:"(选、建、管:当前项目的基准检出)"})]}),children:[c.jsxs(zt,{children:[c.jsx("button",{onClick:i.refreshEnvs,children:"刷新"}),c.jsx("span",{className:"muted",children:i.envCount})]}),i.envs.length===0?c.jsx("div",{className:"muted",children:"先选项目"}):c.jsx(Sn,{children:i.envs.map(E=>c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[E.name,"(",se(E.id),") / 分支 ",E.branch||"默认分支"," / 执行器 ",E.executor||"不限"," / 状态 ",E.status,E.status==="failed"&&E.error_text?" / 失败原因:"+E.error_text:""]}),E.status==="archived"?null:c.jsx("button",{onClick:()=>void i.archiveEnv(E.id),children:"归档"})]},E.id))}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"新环境名",value:r,onChange:E=>h(E.target.value)}),c.jsx("input",{type:"text",placeholder:"分支(可选,空=默认分支)",value:f,onChange:E=>g(E.target.value)}),c.jsxs("select",{"aria-label":"新环境执行器",value:A,onChange:E=>O(E.target.value),children:[c.jsx("option",{value:"",children:"执行器:不限"}),c.jsx("option",{value:"local",children:"local"}),c.jsx("option",{value:"docker",children:"docker"}),c.jsx("option",{value:"cloud-hypervisor",children:"cloud-hypervisor(实验,默认关闭)"})]}),c.jsx("button",{onClick:async()=>{await i.createEnv(r,f,A),h(""),g("")},children:"新建环境"})]}),A==="cloud-hypervisor"?c.jsxs("div",{className:"muted",children:["实验执行器,默认关闭:环境创建白名单同样允许 cloud-hypervisor(后端环境接口 allowlist:local / docker / cloud-hypervisor,不含 cloud);仍需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务排队。前端只传值,不绕过后端白名单。",b.state==="known"?c.jsxs(c.Fragment,{children:[" CHV worker 在线 ",b.online," / 共 ",b.total,"(来自 /workers)",b.online===0?":当前无 CHV worker 在线,任务将一直排队。":""]}):" CHV worker 在线数未知:完整 worker 清单仅全局管理员可见。"]}):null,c.jsx("span",{className:"muted",children:i.envMsg})]})}function Qg(){const i=Gt(),[r,h]=d.useState("");return c.jsxs(Jt,{title:"仓库与环境上下文",children:[c.jsx("div",{className:"muted",children:i.envRepoLine||"先选项目"}),c.jsx("div",{className:"muted",children:i.envCount}),i.envRepoBar?c.jsxs(zt,{children:[c.jsxs("select",{"aria-label":"补仓库",value:r,onChange:f=>h(f.target.value),children:[c.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选)"}),i.githubRepos.map(f=>c.jsx("option",{value:f.clone_url,children:f.repo_full_name},f.clone_url))]}),c.jsx("button",{onClick:()=>void i.saveEnvRepo(r),children:"保存仓库"})]}):null,c.jsx("span",{className:"muted",children:i.envRepoMsg})]})}function Vg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState("all"),A=M=>{const q=qc(M);return M.alive!==void 0&&M.alive!==null?!!M.alive:q.age!==null&&q.age<=120},O=r.trim().toLowerCase(),b=(i.workers||[]).filter(M=>!(O&&!((M.worker_id||M.id||"")+" "+(M.executor||"")).toLowerCase().includes(O)||f==="online"&&!A(M)||f==="offline"&&A(M))),E=oi(i.workers,i.workersSummary,i.workersError);return c.jsxs(Jt,{id:"nav-workers",title:"Workers",children:[c.jsx(zt,{children:c.jsx("button",{onClick:i.refreshWorkers,children:"刷新"})}),c.jsx("div",{className:"muted",children:E.state==="known"?c.jsxs(c.Fragment,{children:["cloud-hypervisor worker 在线 ",E.online," / 共 ",E.total,"(实验,默认关闭)",E.online===0?":当前无 CHV worker 在线,CHV 任务将一直排队。":""]}):"cloud-hypervisor worker 在线数未知:完整 worker 清单仅全局管理员可见,非管理员只能看到聚合数,无法按执行器分项。"}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"搜索 worker_id / 执行器","aria-label":"搜索 Worker",value:r,onChange:M=>h(M.target.value)}),c.jsxs("select",{"aria-label":"在线状态筛选",value:f,onChange:M=>g(M.target.value),children:[c.jsx("option",{value:"all",children:"在线状态:全部"}),c.jsx("option",{value:"online",children:"仅在线"}),c.jsx("option",{value:"offline",children:"仅离线"})]}),i.workers?c.jsxs("span",{className:"muted",children:["显示 ",b.length," / 共 ",i.workers.length," 个"]}):null]}),i.workersError?c.jsx("div",{className:"muted",children:i.workersError}):i.workers===null?c.jsxs("div",{className:"muted",children:[i.workersSummary||"未加载","(完整清单仅全局管理员可见,搜索/筛选仅对完整清单生效)"]}):i.workers.length===0?c.jsx("div",{className:"muted",children:"无 worker 注册"}):b.length===0?c.jsx("div",{className:"muted",children:"没有符合筛选条件的 worker"}):c.jsx(Sn,{children:b.map((M,q)=>{const p=M.running===void 0||M.running===null?"?":M.running,H=M.capacity===void 0||M.capacity===null?"?":M.capacity,Z=qc(M),F=A(M),it=F?" / 在线":" / 离线(占用为上次上报值)";return c.jsx("li",{className:F?"is-online":"is-offline",children:c.jsxs("span",{className:"row-main",children:[M.worker_id||M.id||"?"," / ",M.executor||""," / 占用 ",p,"/",H,it," / 最后心跳 ",Z.text]})},q)})})]})}function Xg(){const i=Gt();return c.jsxs(Jt,{title:"健康与配额",children:[c.jsxs(zt,{children:[c.jsx("span",{className:"muted",children:i.healthText}),c.jsx("button",{onClick:i.refreshHealth,children:"刷新"})]}),c.jsx("div",{children:c.jsx("span",{className:"muted",children:i.quotaText})}),c.jsx("div",{className:"muted",children:i.workersSummary})]})}function Lg(){const i=Gt();return c.jsxs(Jt,{id:"nav-skills",title:"技能与 MCP",children:[c.jsx("div",{className:"muted",children:"反映宿主租户 home,容器内 home 可能不同;重载只在 schema 枚举里,当前 pin 的二进制拒绝时原样 502;OAuth 只发起登录,不代表登录完成。"}),c.jsxs(zt,{children:[c.jsx("button",{onClick:i.refreshSkills,children:"刷新"}),c.jsx("span",{className:"muted",children:i.skillMsg})]}),c.jsx("h3",{children:"技能"}),i.skillError?c.jsx("div",{className:"muted",children:i.skillError}):c.jsx(ul,{empty:"暂无技能",items:i.skills.map((r,h)=>c.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:"",r.detail?" - "+r.detail:""]},h))}),c.jsx("h3",{children:"应用"}),c.jsx(ul,{empty:"暂无应用",items:i.apps.map((r,h)=>c.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:""]},h))}),c.jsx("h3",{children:"MCP 服务"}),c.jsx(ul,{empty:"暂无 MCP 服务",items:i.mcpServers.map((r,h)=>{const f=r.detail||r.details||r.state||"";return c.jsxs("span",{className:"row-main",children:[r.name||r.id||""," | 状态 ",r.status||"",f?" | "+f:""]},h)})})]})}function Zg(){const i=Gt(),[r,h]=d.useState("");return c.jsxs(Jt,{title:"MCP 操作",children:[c.jsxs(zt,{children:[c.jsx("button",{onClick:i.reloadMcp,children:"重载配置"}),c.jsx("span",{className:"muted",children:i.mcpMsg})]}),c.jsxs(zt,{children:[c.jsx("input",{type:"text",placeholder:"MCP 服务名",value:r,onChange:f=>h(f.target.value)}),c.jsx("button",{onClick:()=>void i.mcpOauth(r),children:"发起 OAuth 登录"}),c.jsx("span",{className:"muted",children:i.mcpOauthMsg})]}),i.mcpOauthBox?c.jsx("pre",{children:i.mcpOauthBox}):null]})}const Kh=160;function Kg(i){return i?i%86400===0?"(每天)":i%3600===0?"(每小时)":i%60===0?"(每 "+i/60+" 分钟)":"":""}function Jg({detail:i}){const r=Gt(),h=f=>{if(f){r.showDetail(f);try{window.location.hash="#view-tasks"}catch{}}};return c.jsxs("div",{className:"card",children:[c.jsxs("h2",{children:["自动化详情:",i.name]}),c.jsxs("div",{className:"muted",children:["状态 ",i.status,i.due?"|到期":""," | 每 ",i.interval_seconds," 秒 | 下次 ",Ge(i.next_run_at),"| 上次执行 ",Ge(i.last_run_at)," | 创建人 ",i.created_by?se(i.created_by):""," | 创建于 ",Ge(i.created_at),i.cancelled_at?" | 已取消于 "+Ge(i.cancelled_at):"",i.heartbeat_binding?" | 心跳绑定:已绑定":""]}),c.jsx("div",{className:"muted",style:{whiteSpace:"pre-wrap",marginTop:8},children:i.prompt||""}),i.repo_url?c.jsxs("div",{className:"muted",children:["仓库 ",i.repo_url]}):null,c.jsxs(zt,{children:[c.jsx("button",{onClick:r.closeAutomationDetail,children:"关闭详情"}),c.jsxs("span",{className:"muted",children:["编辑请在下面列表里点“编辑”。",r.autoDetailMsg]})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["最近产出(",(i.runs||[]).length,")"]}),c.jsx(ul,{empty:"(无)",items:(i.runs||[]).map((f,g)=>c.jsxs("span",{className:"row-main",children:[f.status,f.created_at?"|"+Ge(f.created_at):"",f.task_id?c.jsxs(c.Fragment,{children:[" | 任务 ",c.jsx("button",{onClick:()=>h(f.task_id),children:se(f.task_id)})]}):"",f.detail?"|"+String(f.detail).slice(0,120):""]},g))})]}),i.runs_note?c.jsx("div",{className:"muted",children:i.runs_note}):null]})}function Fg(){const i=Gt(),[r,h]=d.useState(""),[f,g]=d.useState(""),[A,O]=d.useState(""),[b,E]=d.useState(""),[M,q]=d.useState(""),[p,H]=d.useState(""),[Z,F]=d.useState(""),[it,ft]=d.useState(""),[Et,st]=d.useState(""),[Ht,Q]=d.useState({}),ct=z=>{q(z.id),H(z.name||""),F(z.prompt||""),ft(z.interval_seconds?String(z.interval_seconds):""),st(z.repo_url||"")};return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"自动化名称(同项目内启用中的名称唯一)",style:{width:"100%"},value:r,onChange:z=>h(z.target.value)}),c.jsx("textarea",{placeholder:"定时任务内容(创建只登记定义与到期状态;执行由后续执行器经 claim 抢单后建任务,不另起调度器)",value:f,onChange:z=>g(z.target.value)}),c.jsxs(zt,{children:[c.jsx("input",{placeholder:"间隔秒数(默认 86400,最小 60)",style:{width:"40%"},value:A,onChange:z=>O(z.target.value)}),c.jsx("input",{placeholder:"仓库(可选)",style:{width:"40%"},value:b,onChange:z=>E(z.target.value)}),c.jsx("button",{className:"primary",onClick:async()=>{await i.createAutomation(r,f,A,b),h(""),g(""),O(""),E("")},children:"创建自动化"}),c.jsx("button",{onClick:i.refreshAutomations,children:"刷新自动化"}),c.jsx("span",{className:"muted",children:i.autoMsg})]}),c.jsx(ul,{empty:"(暂无自动化)",items:i.automations.map(z=>{const L=z.prompt||"",at=!!Ht[z.id],jt=L.length>Kh,Mt=L?at||!jt?L:L.slice(0,Kh)+"…":"(无内容)";return c.jsxs("div",{children:[c.jsxs("div",{className:"row-main",children:["[",z.status,z.due?"|到期":"",z.run_once?"|一次性":"","] ",z.name,"|每 ",z.interval_seconds," 秒",Kg(z.interval_seconds),"|上次 ",Ge(z.last_run_at)||"未运行","|下次 ",Ge(z.next_run_at),c.jsx("button",{onClick:()=>void i.loadAutomationDetail(z.id),children:"详情"}),z.status==="active"?c.jsxs(c.Fragment,{children:[" ",c.jsx("button",{onClick:()=>ct(z),children:"编辑"})," ",c.jsx("button",{onClick:()=>i.cancelAutomation(z.id),children:"取消"})]}):null]}),z.repo_url?c.jsxs("div",{className:"muted",children:["仓库:",z.repo_url]}):null,z.status==="cancelled"&&z.cancelled_at?c.jsxs("div",{className:"muted",children:["已取消于 ",Ge(z.cancelled_at)]}):null,c.jsxs("div",{className:"muted",style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:[Mt,jt?c.jsx("button",{onClick:()=>Q(yt=>({...yt,[z.id]:!yt[z.id]})),children:at?"收起":"展开"}):null]}),M===z.id?c.jsxs("div",{children:[c.jsx("input",{placeholder:"自动化名称",style:{width:"100%"},value:p,onChange:yt=>H(yt.target.value)}),c.jsx("textarea",{placeholder:"定时任务内容",value:Z,onChange:yt=>F(yt.target.value)}),c.jsxs(zt,{children:[c.jsx("input",{placeholder:"间隔秒数(最小 60,留空保持原值)",style:{width:"40%"},value:it,onChange:yt=>ft(yt.target.value)}),c.jsx("input",{placeholder:"仓库(可选,清空则清除)",style:{width:"40%"},value:Et,onChange:yt=>st(yt.target.value)}),c.jsx("button",{className:"primary",onClick:async()=>{const yt=i.automations.find(B=>B.id===M)||z;await i.updateAutomation(yt,p,Z,it,Et)&&q("")},children:"保存"}),c.jsx("button",{onClick:()=>q(""),children:"取消"})]})]}):null]},z.id)})}),i.autoDetailMsg&&!i.autoDetail?c.jsx("div",{className:"muted",children:i.autoDetailMsg}):null,i.autoDetail?c.jsx(Jg,{detail:i.autoDetail},i.autoDetail.id):null]})}function Wg(){var g,A,O,b;const i=Gt(),r=i.triage,h=E=>{if(E){i.showDetail(E);try{window.location.hash="#view-tasks"}catch{}}},f=E=>{if(E){i.loadAutomationDetail(E);try{window.location.hash="#view-automation"}catch{}}};return i.triageError?c.jsx("div",{className:"muted",children:i.triageError}):r?c.jsxs(c.Fragment,{children:[c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["到期自动化(",((g=r.counts)==null?void 0:g.automations_due)||0,")"]}),c.jsx(ul,{empty:"(无)",items:(r.automations_due||[]).map((E,M)=>c.jsx("span",{className:"row-main",children:E.id?c.jsxs("button",{onClick:()=>f(E.id),children:[E.name,"|每 ",E.interval_seconds," 秒"]}):c.jsxs(c.Fragment,{children:[E.name,"|每 ",E.interval_seconds," 秒"]})},M))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["失败/中断任务(",((A=r.counts)==null?void 0:A.failed_tasks)||0,")"]}),c.jsx(ul,{empty:"(无)",items:(r.failed_tasks||[]).map((E,M)=>c.jsx("span",{className:"row-main",children:c.jsxs("button",{onClick:()=>h(E.id),children:[se(E.id),"|",E.status,"|",String(E.prompt||"").slice(0,80)]})},M))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["待处理后续消息(",((O=r.counts)==null?void 0:O.pending_followups)||0,")"]}),c.jsx(ul,{empty:"(无)",items:(r.pending_followups||[]).map((E,M)=>c.jsx("span",{className:"row-main",children:c.jsxs("button",{onClick:()=>h(E.task_id),children:["任务 ",se(E.task_id),"|",String(E.body||"").slice(0,80)]})},M))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["最近自动化产出(",((b=r.counts)==null?void 0:b.recent_runs)||0,")"]}),c.jsx(ul,{empty:"(无)",items:(r.recent_runs||[]).map((E,M)=>c.jsxs("span",{className:"row-main",children:[se(E.automation_id),"|",E.status,E.task_id?c.jsxs(c.Fragment,{children:["|任务 ",c.jsx("button",{onClick:()=>h(E.task_id),children:se(E.task_id)})]}):"",E.task_status?"|"+E.task_status:""]},M))})]})]}):c.jsx("div",{className:"muted",children:"(先选项目)"})}function $g(){return c.jsx(Jt,{id:"nav-automations",title:"自动化",children:c.jsx(Fg,{})})}function Ig(){const i=Gt();return c.jsxs(Jt,{id:"nav-triage",title:"分诊收件箱",children:[c.jsx(zt,{children:c.jsx("button",{onClick:i.refreshTriage,children:"刷新分诊"})}),c.jsx(Wg,{})]})}const po=["pending","in_progress","blocked","completed","cancelled"],Hc={pending:"待办",in_progress:"进行中",blocked:"受阻",completed:"已完成",cancelled:"已取消"},Pg=["low","medium","high"],Jh={low:"低",medium:"中",high:"高"};function tp(i){return i?new Date(i*1e3).toLocaleString():""}function ep(){const i=Gt(),[r,h]=d.useState([]),[f,g]=d.useState({}),[A,O]=d.useState(0),[b,E]=d.useState(""),[M,q]=d.useState(""),[p,H]=d.useState(""),[Z,F]=d.useState(""),[it,ft]=d.useState(""),[Et,st]=d.useState(""),[Ht,Q]=d.useState("medium"),[ct,z]=d.useState(""),[L,at]=d.useState(""),jt=d.useCallback(async()=>{if(!i.projectId){h([]),g({}),O(0);return}try{let B=$.projectTodos(i.projectId);const mt=new URLSearchParams;p&&mt.set("status",p),Z.trim()&&mt.set("q",Z.trim());const Ct=mt.toString();Ct&&(B+="?"+Ct);const V=await I(B);h(V.todos||[]),g(V.counts||{}),O(V.total||0),q("")}catch(B){q("待办加载失败:"+((B==null?void 0:B.message)||String(B)))}},[i.projectId,p,Z]);d.useEffect(()=>{jt()},[jt]);const Mt=async()=>{if(!i.projectId){E("先选项目");return}if(!it.trim()){E("标题必填");return}E("");try{const B={title:it.trim(),description:Et,priority:Ht};ct.trim()&&(B.task_id=ct.trim()),L&&(B.due_at=Math.floor(new Date(L).getTime()/1e3)),await I($.projectTodos(i.projectId),"POST",B),ft(""),st(""),z(""),at(""),E("已创建"),await jt()}catch(B){E("创建失败:"+((B==null?void 0:B.message)||String(B)))}},yt=async(B,mt)=>{if(i.projectId)try{await I($.projectTodo(i.projectId,B.id),"PATCH",mt),await jt()}catch(Ct){q("待办更新失败:"+((Ct==null?void 0:Ct.message)||String(Ct)))}},bt=async B=>{if(i.projectId&&window.confirm("删除待办“"+B.title+"”?删除后不可恢复(取消请改状态为已取消)。"))try{await I($.projectTodo(i.projectId,B.id),"DELETE"),await jt()}catch(mt){q("待办删除失败:"+((mt==null?void 0:mt.message)||String(mt)))}};return c.jsxs(Jt,{id:"nav-todos",title:"项目待办",children:[c.jsx("div",{className:"muted",children:i.projectId?"共 "+A+" 项 | "+po.map(B=>Hc[B]+" "+(f[B]||0)).join(" | "):"先选项目"}),M?c.jsx("div",{className:"err",children:M}):null,c.jsxs(zt,{children:[c.jsxs("select",{"aria-label":"状态筛选",value:p,onChange:B=>H(B.target.value),children:[c.jsx("option",{value:"",children:"状态:全部"}),po.map(B=>c.jsx("option",{value:B,children:Hc[B]},B))]}),c.jsx("input",{type:"text",placeholder:"搜索标题/说明",value:Z,onChange:B=>F(B.target.value)}),c.jsx("button",{onClick:()=>void jt(),children:"刷新"})]}),c.jsx("input",{type:"text",placeholder:"待办标题(必填)",style:{width:"100%"},value:it,onChange:B=>ft(B.target.value)}),c.jsx("textarea",{placeholder:"说明(可选)",value:Et,onChange:B=>st(B.target.value)}),c.jsxs(zt,{children:[c.jsx("select",{"aria-label":"优先级",value:Ht,onChange:B=>Q(B.target.value),children:Pg.map(B=>c.jsxs("option",{value:B,children:["优先级:",Jh[B]]},B))}),c.jsx("input",{type:"text",placeholder:"关联任务 id(可选)",value:ct,onChange:B=>z(B.target.value)}),c.jsx("input",{type:"datetime-local","aria-label":"截止时间",value:L,onChange:B=>at(B.target.value)}),c.jsx("button",{className:"primary",onClick:()=>void Mt(),children:"新建待办"}),c.jsx("span",{className:"muted",children:b})]}),c.jsx(ul,{empty:i.projectId?"(暂无待办)":"(先选项目)",items:r.map(B=>c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:B.title})," | ",c.jsx("span",{className:"st-"+B.status,children:Hc[B.status]||B.status})," | 优先级 "+(Jh[B.priority]||B.priority),B.task_id?" | 任务 "+se(B.task_id):"",B.due_at?" | 截止 "+tp(B.due_at):"",B.completed_at?" | 完成于 "+new Date(B.completed_at*1e3).toLocaleString():"",B.description?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsx("span",{className:"muted",children:B.description})]}):null,c.jsx("br",{}),c.jsx("select",{"aria-label":"改状态",value:B.status,onChange:mt=>void yt(B,{status:mt.target.value}),children:po.map(mt=>c.jsx("option",{value:mt,children:Hc[mt]},mt))})," ",c.jsx("button",{onClick:()=>void yt(B,{status:"completed"}),children:"完成"})," ",c.jsx("button",{onClick:()=>void bt(B),children:"删除"})]},B.id))}),c.jsx("div",{className:"muted",children:"状态与 Muse 待办同款五态;完成时服务端记完成时间,改回其它状态时清掉;已取消是软关闭,删除才是硬删除。"})]})}const bo=[["tasks","任务工作台","任务"],["todos","项目待办","任务"],["resources","环境与 Workers","管理"],["automation","技能与自动化","管理"],["admin","系统与 Key","管理"]],lp=Object.fromEntries(bo.map(([i,r])=>[i,r]));function Fh(){const i=window.location.hash.replace("#view-","");return bo.some(([r])=>r===i)?i:"tasks"}function ap({view:i,setView:r}){return c.jsxs("aside",{className:"shell-side",children:[c.jsx("div",{className:"shell-brand",children:"Codex 命令台"}),c.jsx("nav",{className:"shell-nav","aria-label":"导航",children:["任务","管理"].map(h=>c.jsxs("div",{className:"shell-nav-group",children:[c.jsx("div",{className:"shell-nav-group-label",children:h}),bo.filter(([,,f])=>f===h).map(([f,g])=>c.jsx("button",{type:"button",className:i===f?"active":"",onClick:()=>r(f),children:g},f))]},h))}),c.jsx("div",{id:"nav-projects",className:"shell-project-nav",children:c.jsx(Yg,{})}),c.jsxs("div",{className:"shell-stats",children:[c.jsx("div",{className:"shell-stats-label",children:"概览"}),c.jsx(dg,{})]})]})}function np(){const i=Gt();return c.jsx("span",{className:i.keySaved?"dash-status ok":"dash-status off",children:i.keySaved?"● 已连接":"○ 未连接"})}function up(){const i=fg();sg(b=>i.refreshAll(b),1e4),rg(()=>i.refreshSlow(),6e4);const[r,h]=d.useState(()=>Fh()),[f,g]=d.useState(!0),A=b=>{h(b);try{window.history.replaceState(null,"","#view-"+b)}catch{}};d.useEffect(()=>{const b=()=>h(Fh());return window.addEventListener("hashchange",b),()=>window.removeEventListener("hashchange",b)},[]);const O=()=>{A("tasks"),g(!0),setTimeout(()=>{var b;return(b=document.getElementById("sec-dispatch"))==null?void 0:b.scrollIntoView({behavior:"smooth"})},0)};return c.jsx($h.Provider,{value:i,children:c.jsxs("div",{className:"shell",children:[c.jsx(ap,{view:r,setView:A}),c.jsxs("div",{className:"shell-main",children:[c.jsxs("header",{className:"shell-header",children:[c.jsx("h1",{children:lp[r]}),c.jsx(np,{}),c.jsx("span",{className:"muted shell-hide-sm",children:i.healthText}),c.jsx("span",{className:"muted shell-hide-sm",children:i.quotaText}),c.jsxs("span",{className:"muted shell-stamp",children:["自动刷新 · 每 10 秒 · 已刷新 ",i.refreshStamp]})]}),r==="tasks"?c.jsxs("div",{className:"workspace",children:[c.jsxs("section",{className:"workspace-rail","aria-label":"任务列表",children:[c.jsx("h2",{children:"任务列表"}),c.jsx(gg,{onNew:O})]}),c.jsxs("section",{className:"workspace-center",children:[c.jsx("div",{className:"composer-toggle",children:c.jsx("button",{type:"button",onClick:()=>g(!f),children:f?"收起新建任务":"新建任务"})}),f?c.jsx("section",{id:"sec-dispatch",children:c.jsx(vg,{})}):null,c.jsxs("section",{className:"card workspace-conv",children:[c.jsx("h2",{children:"对话"}),c.jsx(Tg,{})]})]}),c.jsx("section",{className:"workspace-inspector","aria-label":"任务详情",children:c.jsx(Ug,{})})]}):c.jsx("main",{className:"page",children:r==="todos"?c.jsx(c.Fragment,{children:c.jsx(ep,{})}):r==="resources"?c.jsxs(c.Fragment,{children:[c.jsx(Gg,{}),c.jsx(Qg,{}),c.jsx(Vg,{}),c.jsx(Xg,{})]}):r==="automation"?c.jsxs(c.Fragment,{children:[c.jsx(Lg,{}),c.jsx(Zg,{}),c.jsx($g,{}),c.jsx(Ig,{})]}):c.jsxs(c.Fragment,{children:[c.jsx(Bg,{}),c.jsx(Hg,{})]})})]})]})})}Wy.createRoot(document.getElementById("root")).render(c.jsx(Qy.StrictMode,{children:c.jsx(up,{})}));
diff --git a/cloud/handler_support.py b/cloud/handler_support.py
index 83ad4f3..c0399e6 100644
--- a/cloud/handler_support.py
+++ b/cloud/handler_support.py
@@ -226,6 +226,16 @@ def _task_to_dict(row, attachments=None, total_tokens=None):
# 列表接口由 _task_tokens_map 一次聚合传入;其它调用方
# 不传时为 None(前端显示占位),绝不把“未算”写成 0。
"total_tokens": total_tokens,
+ # feat-chv-arch95plus 只读字段:仅当 CHV 任务且 worker
+ # 已把 VM 死亡写进 interrupted_reason 时才出现,值为
+ # True;其它情形一律不返回该字段(绝不把“未报告死亡”
+ # 写成“VM 活着”,live VM 状态不在 server 进程里,
+ # server 没有 VM 登记表,编不出来也不编)。
+ **({"chv_vm_dead": True}
+ if _row_get(row, "executor") == "cloud-hypervisor"
+ and _row_get(row, "interrupted_reason")
+ and "VM 中途死亡" in str(_row_get(row, "interrupted_reason"))
+ else {}),
}
diff --git a/cloud/tests/test_chv_arch95plus.py b/cloud/tests/test_chv_arch95plus.py
new file mode 100644
index 0000000..f47b59c
--- /dev/null
+++ b/cloud/tests/test_chv_arch95plus.py
@@ -0,0 +1,179 @@
+"""CHV 架构 9.5+ 提升轨测试(feat-chv-arch95plus,堆叠于 #252)。
+
+只用标准库与 mock,绝不启动真实 VM;测试里只用 FAKE
+占位密钥,绝无真实密钥。覆盖:长函数提取后的行为不变、
+WarmPool facade 不调实验接口、#215 对齐接口形状、审计
+失败计数不中断、只读字段 chv_vm_dead 的派生规则、
+lockfile 校验脚本的通过/漂移/空清单。
+"""
+
+import json
+import logging
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+try:
+ from cloud.chv.compat215 import resolve_inside, tmpfs_isolated
+ from cloud.chv.hardening import validate_snapshot_url
+ from cloud.chv.pool import reconcile_orphans_for_executor
+ from cloud.sandbox import CloudHypervisorExecutor
+ from cloud.server import _task_to_dict
+except ImportError:
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+ from cloud.chv.compat215 import resolve_inside, tmpfs_isolated
+ from cloud.chv.hardening import validate_snapshot_url
+ from cloud.chv.pool import reconcile_orphans_for_executor
+ from cloud.sandbox import CloudHypervisorExecutor
+ from cloud.server import _task_to_dict
+
+
+def _row(**kw):
+ base = {
+ "id": "t1", "project_id": "p1", "prompt": "x",
+ "status": "interrupted", "idempotency_key": None,
+ "depends_on": "[]", "priority": 0, "created_by": "u",
+ "created_at": 0, "updated_at": 0, "last_heartbeat": 0,
+ }
+ base.update(kw)
+ return base
+
+
+class FakeExecutor:
+ def __init__(self):
+ self.acquire_called = False
+ self.prewarm_called = False
+
+ def shutdown_vm(self, vm):
+ return True
+
+ def _emit(self, *args, **kwargs):
+ pass
+
+
+class Compat215Test(unittest.TestCase):
+ def test_resolve_inside_blocks_escape_and_symlink(self):
+ with tempfile.TemporaryDirectory() as d:
+ inside = os.path.join(d, "a.txt")
+ with open(inside, "w") as fh:
+ fh.write("x")
+ self.assertEqual(resolve_inside(d, "a.txt"), inside)
+ self.assertIsNone(resolve_inside(d, "../outside"))
+ self.assertIsNone(resolve_inside(d, "/etc/passwd"))
+ self.assertEqual(resolve_inside(d, ""), d)
+
+ def test_tmpfs_isolated_chv_and_unknown_fail_closed(self):
+ isolated, _ = tmpfs_isolated("cloud-hypervisor", "/tmp")
+ self.assertFalse(isolated)
+ isolated, _ = tmpfs_isolated("unknown-kind", "/tmp")
+ self.assertFalse(isolated)
+
+ def test_snapshot_url_uses_resolve_inside(self):
+ with tempfile.TemporaryDirectory() as d:
+ state = os.path.join(d, "state")
+ os.makedirs(state)
+ path = os.path.join(state, "s.raw")
+ with open(path, "w") as fh:
+ fh.write("x")
+ self.assertEqual(
+ validate_snapshot_url("file://" + path, state), path)
+ with self.assertRaises(Exception):
+ validate_snapshot_url(
+ "file:///etc/passwd", state)
+
+ def test_no_timeout_or_quota_map(self):
+ import cloud.chv.compat215 as m
+ self.assertFalse(hasattr(m, "resolve_module_timeout"))
+ self.assertFalse(hasattr(m, "decide"))
+
+
+class PoolFacadeTest(unittest.TestCase):
+ def test_facade_only_reconciles_never_acquires(self):
+ # facade 内部构造 WarmPool:用假执行器 + 空 registry,
+ # 只要它不调 acquire/prewarm 就返回 [] 且不炸。
+ ex = FakeExecutor()
+ # WarmPool 构造需要 state_dir 属性;没有则 registry 为空
+ ex.state_dir = tempfile.mkdtemp()
+ ex.warm_pool_size = 0
+ ex.snapshot_url = None
+ result = reconcile_orphans_for_executor(ex)
+ self.assertEqual(result, [])
+ self.assertFalse(ex.acquire_called)
+ self.assertFalse(ex.prewarm_called)
+
+
+class AuditCounterTest(unittest.TestCase):
+ def test_emit_failure_counts_and_does_not_raise(self):
+ ex = CloudHypervisorExecutor.__new__(CloudHypervisorExecutor)
+ ex.audit_callback = lambda *a, **k: (_ for _ in ()).throw(
+ RuntimeError("db down"))
+ ex.audit_failure_count = 0
+ # _emit 不抛异常(任务不因此中断),计数 +1
+ ex._emit("vm_boot", {"id": "t1"}, "vm=chv-t1")
+ self.assertEqual(ex.audit_failure_count, 1)
+ ex._emit("vm_boot", {"id": "t1"}, "vm=chv-t1")
+ self.assertEqual(ex.audit_failure_count, 2)
+
+
+class ChvVmDeadFieldTest(unittest.TestCase):
+ def test_only_chv_with_vm_death_reason_returns_true(self):
+ row = _row(executor="cloud-hypervisor",
+ interrupted_reason="VM 中途死亡,任务未完成")
+ d = _task_to_dict(row)
+ self.assertTrue(d.get("chv_vm_dead"))
+ row2 = _row(executor="cloud-hypervisor",
+ interrupted_reason="quota")
+ self.assertNotIn("chv_vm_dead", _task_to_dict(row2))
+ row3 = _row(executor="docker",
+ interrupted_reason="VM 中途死亡,任务未完成")
+ self.assertNotIn("chv_vm_dead", _task_to_dict(row3))
+ row4 = _row(executor="cloud-hypervisor",
+ interrupted_reason=None)
+ self.assertNotIn("chv_vm_dead", _task_to_dict(row4))
+
+
+class LockfileScriptTest(unittest.TestCase):
+ def _run(self, data, extra=None):
+ with tempfile.TemporaryDirectory() as d:
+ p = os.path.join(d, "BUILDINFO.json")
+ with open(p, "w") as fh:
+ json.dump(data, fh)
+ script = str(Path(__file__).resolve().parents[2]
+ / "chv-guest" / "check_lockfile.py")
+ cmd = [sys.executable, script, p] + (extra or [])
+ return subprocess.run(cmd, capture_output=True, text=True)
+
+ def test_pass_and_empty_and_drift(self):
+ good = {"tag": "t", "versions": {"dpkg": ["a 1"],
+ "pip": ["b==2"]}}
+ self.assertEqual(self._run(good, ["--tag", "t"]).returncode, 0)
+ bad = {"tag": "t", "versions": {"dpkg": [], "pip": []}}
+ self.assertNotEqual(self._run(bad).returncode, 0)
+ with tempfile.TemporaryDirectory() as d:
+ frozen = os.path.join(d, "frozen.json")
+ with open(frozen, "w") as fh:
+ json.dump({"dpkg": ["missing 9"], "pip": []}, fh)
+ self.assertNotEqual(
+ self._run(good, ["--frozen", frozen]).returncode, 0)
+
+
+class LongFunctionTest(unittest.TestCase):
+ def test_no_chv_function_over_50_lines(self):
+ import ast
+ base = Path(__file__).resolve().parents[2] / "cloud" / "chv"
+ over = []
+ for f in base.glob("*.py"):
+ tree = ast.parse(f.read_text())
+ for node in ast.walk(tree):
+ if isinstance(node, ast.FunctionDef):
+ n = node.end_lineno - node.lineno + 1
+ if n > 50:
+ over.append("%s:%s:%d" % (f.name, node.name, n))
+ self.assertEqual(over, [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/cloud/worker.py b/cloud/worker.py
index 534e0a7..9a59265 100644
--- a/cloud/worker.py
+++ b/cloud/worker.py
@@ -9348,6 +9348,17 @@ class Worker:
final_written = self._set_status_if_running(task_id, final)
if quota_stop and final_written and final == "interrupted":
self._mark_interrupted_reason(task_id, "quota")
+ # feat-chv-arch95plus:VM 死亡写回——执行器 _run_with_vm
+ # 已把 vm_dead/interrupted_reason 放进 result,本处把它
+ # 写进 tasks.interrupted_reason 列,server 的只读字段
+ # chv_vm_dead 才能据此触发;否则该字段在生产里永远
+ # 不出现,前端 VM 死亡横幅就是死代码。只写原因文本,
+ # 不改状态本身(_mark 只在 status='interrupted' 时写)。
+ if final_written and final == "interrupted" \
+ and (result or {}).get("vm_dead"):
+ self._mark_interrupted_reason(
+ task_id, (result or {}).get("interrupted_reason")
+ or "VM 中途死亡")
finished_at = time.time()
# P0 安全批后:CAS 没改到行(任务已被取消/回收)时
# 绝不写回结果,否则迟到的旧进程会覆盖新一轮的结果
diff --git a/docs/chv-architecture.md b/docs/chv-architecture.md
index a216f75..90930f2 100644
--- a/docs/chv-architecture.md
+++ b/docs/chv-architecture.md
@@ -72,9 +72,67 @@
- 现状:#215(timeouts/gates/quota/sandbox_policy)未合并,
本链 base 里无其模块,本轨不复制其代码。
-- 接口对齐点(#215 合并后机械替换,非重写):超时类
- api/ready/task 解析 → timeouts;门禁类 preflight/容量/
- 磁盘配额/egress fail-closed → gates;配额类 max_vms/disk_gb
- (任务成本仍走 worker quota)→ quota;隔离类单任务单 VM/
- 磁盘 copy/egress 代理/SSH 钉死/池一次性与孤儿清理 →
- sandbox_policy。替换时须重跑本链 163 项测试,不得改语义。
+- 纠正(feat-chv-arch95plus,核对过 #215 远程分支真实
+ 函数后):上一轨 ADR-7 的“超时→timeouts、配额→quota”
+ 泛化映射是错的。#215 的 timeouts 收敛的是审批/队列
+ 超时(None/0=不过期),quota 是按月美元配额,与 CHV
+ 的 api/ready 秒数(0 无效)和 VM 数/磁盘 GB/快照数
+ 单位与语义都不同,硬套会改行为,故这两项无映射。
+- 真实映射(已落代码,见 cloud/chv/compat215.py):
+ gates.resolve_inside(base, rel) 目录门 → CHV 快照/磁盘
+ 路径白名单已在 hardening 里使用,合并后改为 import;
+ sandbox_policy.tmpfs_isolated → CHV 客机 /tmp 落客机
+ 磁盘、一律不隔离(fail-closed),合并后改为 import。
+ 其余 #215 函数(审批、控制门、合并状态门、worker 寻址)
+ 与 CHV 无交集,不映射。替换时须重跑本链测试,不得改语义。
+
+## ADR-8 WarmPool 生产隔离:facade 而非类导入
+
+- 决策:生产路径(worker 启动/关闭)只调
+ cloud/chv/pool.py 的 reconcile_orphans_for_executor()
+ facade,该函数内部才构造 WarmPool 并只调
+ reconcile_orphans();生产 dispatch 绝不调 prewarm/
+ acquire/release,executor.py 也不 import pool。
+ WarmPool 类与 sandbox re-export 保留仅为兼容(旧测试
+ import 路径),新代码不得经类入口进实验接口。
+- 测试钉住:facade 测试断言 acquire/prewarm 绝不被调;
+ executor 不 import pool 由模块依赖检查覆盖。
+
+## ADR-9 审计失败取舍:记日志 + 计数,不中断任务
+
+- 选项 A(失败即中断任务):审计完整性最强,但 DB 短暂
+ 不可用会把一次审计失败放大成任务失败,且与 worker
+ 全局 best-effort 语义冲突,CHV 单独硬中断是不一致的。
+- 选项 B(失败记日志 + 计数 + 不中断任务):已选。
+ executor._emit 失败时记宿主 error 日志并累加
+ audit_failure_count(宿主可观测,非任务状态),任务
+ 继续;审计完整性本身没有保证,文档与就绪清单明写。
+- 边界:本决策只针对 VM 生命周期审计;VM 启动/关机等
+ 门禁失败仍按原有分类失败处理,不受本条影响。
+
+## ADR-10 长函数抽不动的耦合点(本轨后已无 >50 行函数)
+
+- 本轨后 cloud/chv/ 全部函数均 ≤50 行,上一轨的 7 个
+ 长函数全部拆完:stop_vm(进程清理/文件清理/脏标记三
+ helper)、wait_guest_ready(provisioned 守卫/不匹配检查)、
+ restore_vm(校验/启动段)、cleanup_stale_secrets(单 VM
+ 清理/活 VM 判定)、cleanup_stale_vms(死 socket 扫描/
+ 登记清理)、_unix_socket_api(读头/解析长度/读体/解析
+ 响应)、validate_net_entries(归一化/去重/冲突检查)、
+ build_chv_config(组装段纯移动)。
+- 残留耦合点(已拆但仍需注意):stop_vm 的三块清理共享
+ 同一脏标记,任一块失败即整 VM 标记脏(有意,不拆成
+ 三个标记);_unix_socket_api 的读满语义仍由四个 helper
+ 共同保证,改任一 helper 须重跑截断/超时测试。
+
+## ADR-11 只读 VM 字段边界:chv_vm_dead 不是 live 状态
+
+- 决策:server 任务序列化只在 CHV 任务且 worker 已把
+ VM 死亡写进 interrupted_reason 列时返回 chv_vm_dead=True,
+ 其它情形字段不存在;worker 终局时把执行器的 vm_dead
+ 原因写回该列(见 worker.py 终局块),否则该字段在
+ 生产里永远不出现。
+- 边界:该字段是“已报告死亡”,不是“VM 活着/死了”的
+ live 状态——server 进程没有 VM 登记表,live 状态编不
+ 出来;前端无该字段时不显示 VM 状态,不猜测。VM 控制
+ 按钮仍不做(后端无控制端点且安全审计 No-Go)。
diff --git a/docs/chv-production-readiness.md b/docs/chv-production-readiness.md
index b85fce5..122afa5 100644
--- a/docs/chv-production-readiness.md
+++ b/docs/chv-production-readiness.md
@@ -426,3 +426,36 @@
- 真机动态渗透、压测(无 KVM,做不了,只列清单);
- WarmPool dispatch 前查池接线(已定案不做,见 ADR-5;重启须先
实现任务盘重供给并补不变量测试)。
+
+## 12. 架构 9.5+ 提升轨 feat-chv-arch95plus(堆叠于 #252 之上)
+
+> 验证方式标注(本轨新增,逐条不混淆):静态 = 代码 + mock
+> 测试已覆盖;真机 = 须 KVM 宿主验证;压测 = 须并发负载验证。
+> 凡写“代码已做”而未标真机/压测的,一律只代表静态通过。
+
+- 长函数:cloud/chv/ 全部函数 ≤50 行(静态,mock 测试覆盖
+ 提取后的 stop/wait/restore/清理/接收/校验/组装段)。
+- WarmPool 生产隔离:生产路径只调 reconcile facade(静态);
+ 预热收益仍是实验性,真机收益未验证(真机)。
+- #215 对齐:目录门与 tmpfs 口径已落代码(静态);超时与
+ 美元配额无映射,#215 合并后替换为 import 仍须重跑测试
+ (合并后,静态)。
+- 审计失败:记日志 + 计数 + 不中断(静态);审计完整性在
+ DB 故障下的实际表现仍须真机/故障注入验证(真机)。
+- 供应链:BUILDINFO 事后 lockfile 校验脚本已落(静态);
+ 事前 apt/pip 精确 lockfile 仍缺(须构建宿主,外部)。
+- 只读 VM 字段:chv_vm_dead 派生自 worker 已写回的
+ interrupted_reason(静态,mock 测试覆盖);live VM 状态
+ 仍无字段,VM 控制按钮仍不做(后端无控制端点 + 安全 No-Go)。
+- 合并前 diff 复核清单(合并时逐项打勾,不在本轨内做):
+ server 白名单、worker 断点分支、sandbox re-export、
+ console.html 构建产物与 src 一致、#215 合并后的 import
+ 替换点。
+- 真机验证清单(沿用 §11.5,另加本轨项):VM 死亡横幅在
+ 真任务中断后是否出现、审计失败计数在 DB 故障时是否
+ 累加且任务不中断、快照白名单目录门在符号链接逃逸时
+ 是否拒绝。
+- 灰度启用清单(启用时逐项打勾):CI 变绿 → 按 #216→#252→
+ 本 PR 顺序合并 → KVM 宿主 + 防火墙清单 → 镜像构建发布
+ → §11.5 + 本节真机清单全过 → 默认关闭下单租户/受信项目
+ 灰度 → 全量多租户(须另行决策,不在本轨内默认开启)。