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

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

No files matched your search

+51 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { chvAttachmentBlocked, chvAvailability, chvQueueHint, chvVmBadge, chvVmStatusText, countChvWorkers, isChvExecutor, isWorkerAlive } from "./chv";
import { chvAttachmentBlocked, chvAvailability, chvAvailabilityText, chvQueueHint, chvVmBadge, chvVmDisplay, chvVmStatusText, countChvWorkers, isChvExecutor, isWorkerAlive } from "./chv";
// 纯逻辑测试:只测 chv.ts,不渲染组件、不发请求、不读 localStorage。
// alive 规则与 WorkersSection 现有口径一致:alive 字段优先,
@@ -103,3 +103,53 @@ describe("chvQueueHint", () => {
expect(chvQueueHint("cloud-hypervisor", "queued", { state: "known" as const, online: 1, total: 1 })).toBe("");
});
});
describe("chvVmDisplay(to10 四态)", () => {
it("字段 present=true → dead", () => {
const d = chvVmDisplay({ executor: "cloud-hypervisor", chv_vm_dead: true });
expect(d.state).toBe("dead");
expect(d.text).toContain("VM 中途死亡");
});
it("字段 absent → not-reported,不猜 live", () => {
const d = chvVmDisplay({ executor: "cloud-hypervisor" });
expect(d.state).toBe("not-reported");
expect(d.text).toContain("不代表 VM 活着");
});
it("任务未加载 → unknown", () => {
const d = chvVmDisplay(null);
expect(d.state).toBe("unknown");
expect(d.text).toContain("无法判断");
});
it("非 CHV → not-applicable,text 为空", () => {
const d = chvVmDisplay({ executor: "docker", chv_vm_dead: true });
expect(d.state).toBe("not-applicable");
expect(d.text).toBe("");
});
it("旧入口仍是 display 的 text(unknown/not-applicable 回空)", () => {
expect(chvVmStatusText({ executor: "cloud-hypervisor", chv_vm_dead: true })).toContain("VM 中途死亡");
expect(chvVmStatusText(null)).toBe("");
expect(chvVmStatusText({ executor: "docker" })).toBe("");
});
});
describe("chvAvailabilityText(to10 四处统一文案)", () => {
it("known 零在线:不拦提交、后端是最终权威", () => {
const text = chvAvailabilityText({ state: "known", online: 0, total: 2 });
expect(text).toContain("在线 0 / 共 2");
expect(text).toContain("不拦提交");
expect(text).toContain("最终权威");
});
it("known 非零:只有在线数,无排队警告", () => {
const text = chvAvailabilityText({ state: "known", online: 1, total: 1 });
expect(text).toContain("在线 1 / 共 1");
expect(text).not.toContain("排队");
});
it("未知三原因各有不同文案,绝不写成 0", () => {
expect(chvAvailabilityText({ state: "unknown", reason: "aggregate-only" })).toContain("仅全局管理员可见");
expect(chvAvailabilityText({ state: "unknown", reason: "load-failed" })).toContain("加载失败");
expect(chvAvailabilityText({ state: "unknown", reason: "not-loaded" })).toContain("尚未加载");
for (const reason of ["aggregate-only", "load-failed", "not-loaded"] as const) {
expect(chvAvailabilityText({ state: "unknown", reason })).not.toContain("在线 0");
}
});
});
+70 -12
View File
@@ -54,27 +54,85 @@ export function chvAttachmentBlocked(
return isChvExecutor(executor) && hasAttachments;
}
// VM 只读状态文案:输入只有后端已返回的两个字段,输出
// 三种情形之一,绝不编造第四种:
// - chv_vm_dead 为 true → 已报告死亡(来源:worker 终局
// 写回的 interrupted_reason,见 server _chv_vm_dead_from_row);
// - CHV 任务但无该字段 → 未报告死亡,且明写“后端无 live
// 状态,不代表 VM 活着”(server 没有 VM 登记表);
// - 非 CHV 任务 → 空串(不显示 VM 行)。
export function chvVmStatusText(task: {
// VM 只读状态:四种情形各有明确文案,绝不编造第五种,
// 绝不猜 live(to10 精确化,见 ADR-15):
// - dead:CHV 任务且 chv_vm_dead 为 true(字段已 present;
// 来源:worker 终局写回的 interrupted_reason,见 server
// _chv_vm_dead_from_row);
// - not-reported:CHV 任务但字段 absent(后端只在死亡时
// 才返回该字段;absent 只表示“未报告”,且明写“后端无
// live 状态字段,不代表 VM 活着”,server 没有 VM 登记表);
// - unknown:任务详情本身未加载(task 为 null),无法判断;
// - not-applicable:非 CHV 任务,不显示 VM 行(text 为空)。
export type ChvVmDisplay =
| { state: "dead"; text: string }
| { state: "not-reported"; text: string }
| { state: "unknown"; text: string }
| { state: "not-applicable"; text: string };
export function chvVmDisplay(task: {
executor?: string | null;
chv_vm_dead?: boolean;
}): string {
} | null | undefined): ChvVmDisplay {
if (task === null || task === undefined) {
return {
state: "unknown",
text: "任务详情未加载,无法判断 VM 状态(后端无 live 状态字段,详情加载后才显示)",
};
}
// 死亡文案同样先过执行器门:后端只对 CHV 任务派生该字段,
// 万一某天非 CHV 行误带该字段,前端也不把 docker 任务
// 写成“VM 死亡”。
if (task.chv_vm_dead && isChvExecutor(task.executor)) {
return "VM 中途死亡,任务未完成、断点可续跑(后端只读字段 chv_vm_dead)";
return {
state: "dead",
text: "VM 中途死亡,任务未完成、断点可续跑(后端只读字段 chv_vm_dead)",
};
}
if (isChvExecutor(task.executor)) {
return "未报告 VM 死亡(后端无 live 状态字段,不代表 VM 活着)";
return {
state: "not-reported",
text: "未报告 VM 死亡(后端无 live 状态字段,不代表 VM 活着)",
};
}
return "";
return { state: "not-applicable", text: "" };
}
// 旧入口(保留给已测调用点):即 chvVmDisplay 的 text,
// not-applicable 与 unknown 均回空串(调用点只在已加载的
// CHV 行上用它,unknown 分支由详情页的新入口处理)。
export function chvVmStatusText(task: {
executor?: string | null;
chv_vm_dead?: boolean;
} | null | undefined): string {
const display = chvVmDisplay(task);
return display.state === "not-applicable" ||
display.state === "unknown"
? ""
: display.text;
}
// CHV 可用性统一文案(to10):新建任务、Workers、环境
// 三处此前各写各的,Workers 的未知分支甚至恒写“仅全局
// 管理员可见”(加载失败/未加载时也是这句,是文案 bug)。
// 四处统一走本函数:known 零在线时不拦提交、后端是最终
// 权威;未知时按 reason 分三句,绝不把未知写成 0。
export function chvAvailabilityText(
availability: ChvAvailability,
): string {
if (availability.state === "known") {
const base = `CHV worker 在线 ${availability.online} / 共 ${availability.total}(来自 /workers)`;
return availability.online === 0
? `${base}:当前无 CHV worker 在线,任务将一直排队,请先确认宿主 worker 已起;本提示不拦提交,worker 随后上线可认领,后端是最终权威。`
: base;
}
if (availability.reason === "aggregate-only") {
return "CHV worker 在线数未知:完整 worker 清单仅全局管理员可见,非管理员只能看到聚合数,无法按执行器分项。";
}
if (availability.reason === "load-failed") {
return "CHV worker 在线数未知:/workers 加载失败,请到 Workers 区刷新后重试。";
}
return "CHV worker 在线数未知:/workers 尚未加载,请到 Workers 区刷新。";
}
// 列表行徽标:只有已报告死亡时才出现短徽标;未报告时
@@ -2,7 +2,7 @@ import { useRef, useState } from "react";
import { useConsole } from "../context";
import { Card, Bar } from "../components/ui";
import { gatherAttachments } from "../api/client";
import { chvAttachmentBlocked, chvAvailability, isChvExecutor } from "../api/chv";
import { chvAttachmentBlocked, chvAvailability, chvAvailabilityText, isChvExecutor } from "../api/chv";
import { EFFORTS } from "../api/types";
export default function DispatchSection() {
@@ -97,14 +97,8 @@ export default function DispatchSection() {
</Bar>
{isChv ? <div className="muted">实验执行器,默认关闭:需后端已开启 CHV worker 且宿主有 KVM,否则任务无人认领、一直排队;带附件时建任务即被后端拒绝(错误文案原样显示在下方)。前端只传 executor 值,不绕过后端白名单;默认仍是 docker。</div> : null}
{isChv ? (
<div className="muted">
{chvAvail.state === "known"
? <>CHV worker 在线 {chvAvail.online} / 共 {chvAvail.total}(来自 /workers){chvAvail.online === 0 ? <b>:当前无 CHV worker 在线,任务将一直排队,请先确认宿主 worker 已起;本提示不拦提交,worker 随后上线可认领,后端是最终权威。</b> : null}</>
: chvAvail.reason === "aggregate-only"
? "CHV worker 在线数未知:完整 worker 清单仅全局管理员可见,非管理员只能看到聚合数,无法按执行器分项。"
: chvAvail.reason === "load-failed"
? "CHV worker 在线数未知:/workers 加载失败,请到 Workers 区刷新后重试。"
: "CHV worker 在线数未知:/workers 尚未加载,请到 Workers 区刷新。"}
<div className="muted" aria-live="polite">
{chvAvailabilityText(chvAvail)}
{" "}<button type="button" onClick={ctx.refreshWorkers}>刷新 CHV 可用性</button>
</div>
) : null}
@@ -141,14 +135,14 @@ export default function DispatchSection() {
{goalInvincible ? <div className="muted">goal 模式无敌版:goal 模式 + 任务不限时(去掉 worker/docker 总超时;单轮 600 秒护栏还在,不限的是任务总时长与总轮次)。服务端默认的自动续跑与断点镜像同时生效。</div> : null}
<Bar>
<span className="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
<input ref={filesRef} type="file" multiple title="文件" disabled={isChv} onChange={refreshAttachments} />
<input ref={dirRef} type="file" multiple title="文件夹" disabled={isChv} onChange={refreshAttachments} {...({ webkitdirectory: "" } as any)} />
<input ref={imgRef} type="file" accept="image/*" multiple title="图片" disabled={isChv} onChange={refreshAttachments} />
<input ref={filesRef} type="file" multiple title="文件" aria-label="附件文件" disabled={isChv} onChange={refreshAttachments} />
<input ref={dirRef} type="file" multiple title="文件夹" aria-label="附件文件夹" disabled={isChv} onChange={refreshAttachments} {...({ webkitdirectory: "" } as any)} />
<input ref={imgRef} type="file" accept="image/*" multiple title="图片" aria-label="附件图片" disabled={isChv} onChange={refreshAttachments} />
</Bar>
{isChv ? <div className="muted">cloud-hypervisor 任务不支持附件:附件输入已禁用(后端对带附件的 CHV 任务会 400,错误文案原样显示在下方;切换回其他执行器可恢复附件输入)。{hasAttachments ? <>当前仍有已选附件,请先清空再提交。 <button type="button" onClick={() => { if (filesRef.current) filesRef.current.value = ""; if (dirRef.current) dirRef.current.value = ""; if (imgRef.current) imgRef.current.value = ""; setHasAttachments(false); }}>清空附件</button></> : ""}</div> : null}
<Bar>
<button className="seal" onClick={submit}>提交任务</button>
<span className="muted">{msg || ctx.dispatchMsg}</span>
<span className="muted" role="status" aria-live="polite">{msg || ctx.dispatchMsg}</span>
</Bar>
</Card>
);
@@ -2,7 +2,7 @@ import { useState } from "react";
import { useConsole } from "../context";
import { Card, Bar, DataListRaw } from "../components/ui";
import { shortId } from "../api/client";
import { chvAvailability } from "../api/chv";
import { chvAvailability, chvAvailabilityText } from "../api/chv";
export default function EnvSection() {
const ctx = useConsole();
@@ -37,7 +37,7 @@ export default function EnvSection() {
</select>
<button onClick={async () => { await ctx.createEnv(name, branch, executor); setName(""); setBranch(""); }}>新建环境</button>
</Bar>
{executor === "cloud-hypervisor" ? <div className="muted">实验执行器,默认关闭:环境创建白名单同样允许 cloud-hypervisor(后端环境接口 allowlist:local / docker / cloud-hypervisor,不含 cloud);仍需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务排队。前端只传值,不绕过后端白名单。{chvAvail.state === "known" ? <> CHV worker 在线 {chvAvail.online} / 共 {chvAvail.total}(来自 /workers){chvAvail.online === 0 ? ":当前无 CHV worker 在线,任务将一直排队。" : ""}</> : " CHV worker 在线数未知:完整 worker 清单仅全局管理员可见。"}</div> : null}
{executor === "cloud-hypervisor" ? <div className="muted" aria-live="polite">实验执行器,默认关闭:环境创建白名单同样允许 cloud-hypervisor(后端环境接口 allowlist:local / docker / cloud-hypervisor,不含 cloud);仍需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务排队。前端只传值,不绕过后端白名单。 {chvAvailabilityText(chvAvail)}</div> : null}
<span className="muted">{ctx.envMsg}</span>
</Card>
);
@@ -4,7 +4,7 @@ import { Card } from "../components/ui";
import { shortId, fmt } from "../api/client";
import { STATUS_EXPLAIN } from "../api/types";
import { firstChar, durationOf, tokenOf, fmtCostUsd } from "../api/taskMeta";
import { chvAvailability, chvQueueHint, chvVmStatusText } from "../api/chv";
import { chvAvailability, chvQueueHint, chvVmDisplay } from "../api/chv";
import DetailActions from "./detail/DetailActions";
import BrowserPanel from "./detail/BrowserPanel";
import ReviewPanel from "./detail/ReviewPanel";
@@ -131,7 +131,7 @@ export default function TaskDetailPanel() {
<div><dt>turn</dt><dd>{shortId(t.turn_id)}</dd></div>
<div><dt>root turn</dt><dd>{shortId(t.root_turn_id)}</dd></div>
<div><dt>executor</dt><dd>{t.executor || ""}{t.executor === "cloud-hypervisor" ? "(实验执行器,默认关闭;仅展示,无 VM 控制按钮)" : ""}</dd></div>
{t.executor === "cloud-hypervisor" ? <div><dt>VM 状态</dt><dd>{chvVmStatusText(t)}</dd></div> : null}
{t.executor === "cloud-hypervisor" ? <div><dt>VM 状态</dt><dd role="status" aria-label="VM 状态">{chvVmDisplay(t).text}</dd></div> : null}
<div><dt>主机</dt><dd>{t.worktree_host || ""}</dd></div>
<div><dt>模型</dt><dd>{t.model || "默认"}</dd></div>
<div><dt>强度</dt><dd>{t.effort || "默认"}</dd></div>
@@ -1,7 +1,7 @@
import { useConsole } from "../context";
import { Card, Bar, DataListRaw } from "../components/ui";
import { workerAgeText, shortId } from "../api/client";
import { chvAvailability } from "../api/chv";
import { chvAvailability, chvAvailabilityText } from "../api/chv";
export default function WorkersSection() {
const ctx = useConsole();
@@ -9,10 +9,8 @@ export default function WorkersSection() {
return (
<Card id="nav-workers" title="Workers">
<Bar><button onClick={ctx.refreshWorkers}>刷新</button></Bar>
<div className="muted">
{chvAvail.state === "known"
? <>cloud-hypervisor worker 在线 {chvAvail.online} / 共 {chvAvail.total}(实验,默认关闭){chvAvail.online === 0 ? ":当前无 CHV worker 在线,CHV 任务将一直排队。" : ""}</>
: "cloud-hypervisor worker 在线数未知:完整 worker 清单仅全局管理员可见,非管理员只能看到聚合数,无法按执行器分项。"}
<div className="muted" aria-live="polite">
{chvAvailabilityText(chvAvail)}
</div>
{ctx.workersError ? <div className="muted">{ctx.workersError}</div>
: ctx.workers === null ? <div className="muted">{ctx.workersSummary || "未加载"}</div>