feat(frontend): add HWLAB external entry

This commit is contained in:
Codex
2026-05-21 12:19:38 +00:00
parent 92dff00028
commit 7b9aa4261c
7 changed files with 297 additions and 91 deletions
+1
View File
@@ -91,6 +91,7 @@ frontend shell 必须把左侧主模块与顶部子标签编译为统一的 URL
- `用户服务` 主模块用于展示挂载在计算节点或主 server Docker 中的业务后端。
- `服务目录` 必须显示 service id、Provider、仓库 URL、commit id、业务 Dockerfile/docker-compose 引用、节点后端私有映射、SSH 透传开发入口和运行态容器摘要。
- `HWLAB` 仅作为外部静态入口展示,固定跳转到 `http://74.48.78.17:6666`,可在目录页和独立子标签中打开;它不是 UniDesk 用户服务,不得纳入 microservice health、backend proxy、provider-gateway 透传或运行态容器摘要。
- `Todo Note` 子标签必须把主 server `todo-note-backend` 后端渲染为 UniDesk React 控件,包括迁移清单、树形任务、筛选、提醒、拖放/移动、撤销/重做、字号控制和显式原始 JSON 按钮。
- `FindJob` 子标签必须把 D601 findjob 后端渲染为 UniDesk React 控件,包括岗位指标、岗位预览、草稿报告和显式原始 JSON 按钮。
- `ClaudeQQ` 子标签必须把 D601 ClaudeQQ 后端渲染为 UniDesk React 控件,包括 NapCat 容器登录二维码、NapCat HTTP/WS 状态、事件缓存、QQ 事件订阅表、订阅创建表单、消息推送表单、主用户私聊账号 `645275593` 标记、最近 QQ 事件、已发送记录和显式原始 JSON 按钮。
+2 -1
View File
@@ -22,6 +22,7 @@ UniDesk 用户服务是挂载到 UniDesk 核心服务上的、面向用户使用
- `deployment.mode`,用于明确部署责任边界;`unidesk-direct` 表示 UniDesk 直接登记和探测目标 provider 上的容器,`internal-sidecar` 表示主 server Compose 内的轻量控制面/基础设施服务,`k3sctl-managed` 表示 UniDesk 只登记逻辑服务并经 `deployment.adapterServiceId` 指向的 `k3sctl-adapter` 访问,代管条目还必须写明 `k3sServiceId``namespace``expectedNodeIds` 和当前 `activeNodeId`
- `development.providerId``development.sshPassthrough=true``development.worktreePath`,用于说明开发调试入口必须在计算节点上通过 UniDesk SSH 透传完成。
- `frontend.route``frontend.integrated=true`,用于说明该业务前端已经整合到 UniDesk React 控制台,而不是继续公开业务自身前端。
- `HWLAB` 是前端内置的外部静态入口,固定目标为 `http://74.48.78.17:6666`;它不属于 `config.json` 的用户服务登记,不进入 health/proxy/runtime 采集,也不需要 provider-gateway、backend-core 或 microservice HTTP 代理参与。
## Runtime Configuration And Persistence Contract
@@ -225,7 +226,7 @@ D601 上必须显式使用原生 k3s kubeconfig`KUBECONFIG=/etc/rancher/k3s/k
- 队列语义:`POST /api/tasks``/api/tasks/batch` 入队,服务始终只运行一个 Codex turn;当前任务真正终止后才推进下一个任务。`GET /api/tasks``GET /api/tasks/{id}` 返回队列、attempt、judge 和输出;`GET /api/tasks/{id}/summary` 返回按任务 ID 查询的结构化摘要,包括初始 prompt、最后 assistant message、工具调用摘要、attempt、judge、错误和耗时;CLI 入口是 `bun scripts/cli.ts codex task <taskId>``GET|POST /api/tasks/{id}/judge?attempt=N` 与 CLI `bun scripts/cli.ts codex judge <taskId> --attempt N` 用于单步复现指定 attempt 的 judge,必须复用真实队列 worker 的上下文构建、prompt 压缩、MiniMax 调用、JSON 去噪/repair 和 fallback 路径;`dryRun=1`/`--dry-run` 只输出 prompt/payload 和重建诊断,不调用 MiniMax。`POST /api/tasks/{id}/steer` 向运行中 turn 推入 prompt`POST /api/tasks/{id}/interrupt``DELETE /api/tasks/{id}` 打断/取消;`POST /api/tasks/{id}/retry` 手动重试。队列 worker 必须隔离单个 task 的异常,不能因为某个 app-server、数据库 claim、judge 异常、judge 超时或 judge 判定 `fail` 让后续 queued 任务停止;`fail` 只把当前任务标为 failed,随后必须继续扫描并推进下一个 queued/retry_wait 任务。数据库 claim 必须有硬超时且失败时释放 active run slotjudge 必须有独立 watchdog,超时后走 fallback judge 并继续推进。当存在 queued/retry_wait 且 worker 空闲时,watchdog 必须自动重新调度。
- 稳定性与重启恢复:Code Queue 的第一目标是长期稳定可用;部署修复或运维排障时不得因为担心容器重启会打断任务而拒绝重启、重建或替换 active Pod。容器重启、服务进程重启和镜像替换后,队列、`promptHistory`、running/judging/retry_wait 任务和 active session 元数据必须从 PostgreSQL 恢复,并在已有 `codexThreadId` 可用时用 `thread/resume` 和 continuation prompt 无缝继续当前任务;如果原 app-server turn 已丢失,也必须把当前任务恢复到可 retry/continue 的状态,不能错误推进下一个任务或永久卡住。D601 侧重建必须走未来受控 target-side CD 路径;禁止先手工 `docker rm`、只手工 `docker compose up` 或用维护通道直连 D601 部署 Code Queue 再依赖后续命令补救,因为中断窗口会让 Pod/容器消失并触发 frontend/core 用户服务代理失败。重启后出现 active task 丢失、手动 steer/interrupt 记录丢失、running 任务卡死、误判完成、跳过当前任务、容器消失或阻塞队列,均属于 Code Queue 的 P0 核心缺陷,必须先修复并补充 restart-recovery 验收,不能把“避免重启”作为交付策略。
- 调度与 active run slotCode Queue 必须把“queue processor 正在等待/退避/轮询”和“实际占用 Codex/OpenCode 子进程运行槽”分开建模;`CODE_QUEUE_MAX_ACTIVE_QUEUES` 只限制真实 active run slot,不能把 retry backoff、等待内存下降或等待前序任务的 `processingQueues` 计入 active slot,否则设置全局 active slot 上限时,一个空等队列会把其他 runnable queue 永久饿死。多个 queue 同时等待 active slot 时必须显式维护 FIFO waiter 队列,避免某个长 retry/backoff 队列刚释放 slot 就立刻重抢,导致更早进入等待的 `retry_wait` 任务长期饥饿;`/health` 必须同时暴露真实 `activeQueueIds``activeRunSlotCount`、等待中的 `processingQueueIds` 和 active slot waiters,排障时以 active run slot 与 waiter 顺序判断是否真的有任务在跑、谁应下一个启动。restart-recovery 后的 `retry_wait` 任务若缺失 `codexThreadId`/OpenCode session id,不得无限拒绝 retry;必须用紧凑 recovery prompt 和原始任务摘要重新开一个 agent thread/session,让任务继续推进并在 Trace 中留下 recovery 证据。任何修改 scheduler、retry backoff、queue move、manual retry、shutdown recovery 或内存等待逻辑时,都必须保留“空等 processor 不占 active run slot”、“等待者 FIFO 不饥饿”和“缺失 thread/session 可恢复”的自测或 live 验证。
- 内存优化过程与防回归:Code Queue 已迁移到 D601,但内存治理仍必须按“PostgreSQL 权威源优先、进程热状态最小化、容器硬上限兜底”的顺序设计。长期可复用的优化路径是:先确认任务、queue、readAt、promptHistory、active session 和通知 outbox 均可从 PostgreSQL 恢复;再把历史任务列表、详情、统计、Trace/output 和 `/health` 的只读查询改为 PostgreSQL 直读或聚合查询;随后只把 `queued``running``judging``retry_wait` 等调度必需任务载入 Bun 堆,并在 PostgreSQL 查询侧裁剪 hot `output`/`events`;最后用 dirty-only flush、append-only 输出归档、Codex SQLite 小批量导出、`bun --smol``mem_limit=600m``memswap_limit=1536m``NODE_OPTIONS=--max-old-space-size=768` 和 cgroup memory watchdog 作为运行时防线。PostgreSQL 到进程的单次读取足够快,不能为了减少 SQL 查询把全部历史 `task_json`、Trace、output 或统计摘要常驻内存;任何新增缓存都必须有默认较小的环境变量上限、明确淘汰策略、可从 PostgreSQL 或 append-only 归档重建,且不得影响重启恢复。新增或修改 `/api/tasks`、overview、stats、summary、transcript、output、trace、health、flush、scheduler 和通知路径时,禁止在常规请求中调用会物化全量历史任务 JSON 的代码,禁止启动后无条件重写全量历史 task JSON,禁止用未设上限的 `Map`/数组保存历史 output/event/Trace`CODE_QUEUE_MAX_ACTIVE_QUEUES=0` 表示不按 queue 数量设置全局排队上限;如显式设置为正数,必须同时说明内存预算并补充内存压测验收。memory watchdog 必须以 cgroup working set 为主要判断,且在 swap 仍有余量时不得提前杀掉唯一 active run;否则 TypeScript/Playwright 这类短时高内存验证会被错误中断并让 retry 队列反复震荡。
- 内存优化过程与防回归:Code Queue 已迁移到 D601,但内存治理仍必须按“PostgreSQL 权威源优先、进程热状态最小化、容器硬上限兜底”的顺序设计。长期可复用的优化路径是:先确认任务、queue、readAt、promptHistory、active session 和通知 outbox 均可从 PostgreSQL 恢复;再把历史任务列表、详情、统计、Trace/output 和 `/health` 的只读查询改为 PostgreSQL 直读或聚合查询;随后只把 `queued``running``judging``retry_wait` 等调度必需任务载入 Bun 堆,并在 PostgreSQL 查询侧裁剪 hot `output`/`events`;最后用 dirty-only flush、append-only 输出归档、Codex SQLite 小批量导出、`bun --smol``NODE_OPTIONS=--max-old-space-size=1024`、production scheduler k3s `request/limit memory=15Gi`、legacy Compose `mem_limit=15g`/`memswap_limit=16g` 和 cgroup memory watchdog 作为运行时防线。PostgreSQL 到进程的单次读取足够快,不能为了减少 SQL 查询把全部历史 `task_json`、Trace、output 或统计摘要常驻内存;任何新增缓存都必须有默认较小的环境变量上限、明确淘汰策略、可从 PostgreSQL 或 append-only 归档重建,且不得影响重启恢复。新增或修改 `/api/tasks`、overview、stats、summary、transcript、output、trace、health、flush、scheduler 和通知路径时,禁止在常规请求中调用会物化全量历史任务 JSON 的代码,禁止启动后无条件重写全量历史 task JSON,禁止用未设上限的 `Map`/数组保存历史 output/event/Trace`CODE_QUEUE_MAX_ACTIVE_QUEUES=0` 表示不按 queue 数量设置全局排队上限,不等价于常规 5 / burst 10;如显式设置为正数,必须同时说明内存预算并补充内存压测验收。memory watchdog 必须以 cgroup working set 为主要判断,且在 swap 仍有余量时不得提前杀掉唯一 active run;否则 TypeScript/Playwright 这类短时高内存验证会被错误中断并让 retry 队列反复震荡。
- 列表/详情延迟优化原则:Code Queue 控制面交互的长期目标是常规历史规模下首屏、`GET /api/tasks/overview``POST /api/tasks/<id>/read` 和分页加载均在 1s 内完成;性能面板出现十几秒级 `core_proxy` 或 Code Queue 用户服务代理慢操作时,必须优先按后端查询形态和前后端通信策略定位,不能把问题归因于 React 渲染后只改 UI。后端优化顺序是:先为 queue、status、updated/created 时间、readAt/terminal unread 和常用筛选条件补齐 PostgreSQL 索引;再用 SQL `COUNT``GROUP BY`、条件聚合和分页 ID 查询生成 queue/status/stats/unread 摘要;随后按 ID 轻量加载当前页、selected、active 和 unread priority task,禁止为了列表或已读操作解析完整 Trace、output archive、Codex transcript 或物化全量历史 `task_json``read`/`read-all` 这类 mutation 必须是 SQL-only 更新并返回最小 patch/queue 计数,不能触发 overview 全量重算或重载所有任务;启动 warm 只能预热小体积聚合和索引路径,不得把历史任务作为常驻缓存。允许 frontend/backend 代理使用秒级、严格有界、mutation 自动失效的 overview micro-cache 来吸收重复刷新,但 cache 只能作为抖动保护,不能替代数据库索引、聚合查询和分页披露,也不能让 stale readAt/queue/status 状态跨设备可见。
- Trace/实时输出热路径防回归:Code Queue 的 `appendOutput`、output archive append、`publishTaskEvent`、SSE `/api/events`、任务列表、overview、task meta 和 `/health` 都属于热路径,必须保持 O(1) 或明确小常数上界;这些路径不得同步调用完整 transcript 构建器、`taskFullOutput`、output archive 全量读取、Codex session/log 文件解析、完整 `task_json` 物化或任何会随历史输出长度增长的统计。输出追加时必须增量维护轻量持久化指标,至少包括 `stepCount``llmStepCount``outputMaxSeq` 或等价字段;列表、overview、meta、SSE 事件和 `/health` 只能读取这些指标或小体积 SQL 聚合。完整 Trace、`trace-summary``trace-steps``trace-step`、transcript/output 详情允许在显式详情请求中解析归档,但必须分页或有界、使用短 TTL 或容量受限缓存,并在 archive append 后失效。若 frontend 性能面板出现 Code Queue 用户服务代理 502、`/api/tasks/overview`/trace 接口成批超时,或容器内 `/health` 在 active output 持续追加时也卡住,优先按 Bun event-loop starvation/backpressure 排查,而不是先改 React 渲染;修复必须证明热路径不再随 output/archive 历史线性增长。
- Trace STEP 权威来源:`GET /api/tasks/<id>/trace-steps``GET /api/tasks/<id>/trace-step` 必须直接从 `oa-event-flow` 读取 `trace-step-created` 事实事件,并在响应中暴露 `source=oa-event-flow`;不得在 OA 事件缺失、读取失败或本地 transcript 数量更多/更少时静默回退到 `task-transcript`、Codex session JSONL、output archive 或内存热状态。OA 事件不可用应显式失败或返回空事实集,避免 STEP 计数和执行过程摘要重新形成双路径分叉。
File diff suppressed because one or more lines are too long
+62
View File
@@ -1456,6 +1456,68 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
display: grid;
gap: 10px;
}
.hwlab-page {
grid-template-columns: 1fr;
}
.hwlab-page .panel-body {
display: grid;
gap: 10px;
}
.hwlab-hero {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(240px, 0.8fr);
gap: 10px;
align-items: stretch;
}
.hwlab-hero-copy {
display: grid;
gap: 4px;
min-width: 0;
}
.hwlab-hero-copy h2 {
font-size: 22px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.hwlab-hero-copy .paragraph {
max-width: 60ch;
}
.hwlab-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: start;
align-content: start;
}
.hwlab-actions .ghost-btn,
.hwlab-actions .primary-btn {
width: max-content;
}
.hwlab-entry-list {
grid-template-columns: 1fr;
}
.hwlab-entry-card {
display: grid;
gap: 4px;
min-width: 0;
padding: 8px;
border: 1px solid var(--line-soft);
background: var(--panel-3);
}
.hwlab-entry-card b {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hwlab-entry-card span {
color: var(--muted);
}
.hwlab-entry-actions {
margin-top: 6px;
}
.hwlab-url {
word-break: break-all;
}
.microservice-actions, .inline-actions {
display: flex;
flex-wrap: wrap;
+27
View File
@@ -7,6 +7,7 @@ import { CodeQueuePage } from "./code-queue";
import { DecisionCenterPage } from "./decision-center";
import { FileBrowserPage } from "./filebrowser";
import { FindJobPage } from "./findjob";
import { HWLAB_URL, HwlabPage } from "./hwlab";
import { MetNonlinearPage } from "./met-nonlinear";
import { MdtodoPage } from "./mdtodo";
import { canonicalizeKnownRoute, createRouteRegistry, DEFAULT_ACTIVE_TABS, MODULES, pathForTarget, resolveRouteTarget } from "./navigation";
@@ -1654,6 +1655,31 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord
h(MetricCard, { label: "集成前端", value: microservices.filter((service: any) => service.frontend?.integrated).length, hint: "UniDesk React 页面" }),
),
),
h(Panel, { title: "外部静态入口", eyebrow: "External Link" },
h("div", { className: "endpoint-list hwlab-entry-list" },
h("article", { className: "hwlab-entry-card", "data-testid": "hwlab-entry-card" },
h("b", null, "HWLAB"),
h("span", null, HWLAB_URL),
h("span", null, "external static link"),
h("div", { className: "microservice-actions hwlab-entry-actions" },
h("button", {
type: "button",
className: "ghost-btn",
"data-testid": "hwlab-open-current-from-catalog",
onClick: () => window.location.assign(HWLAB_URL),
}, "当前窗口"),
h("a", {
className: "ghost-btn",
href: HWLAB_URL,
target: "_blank",
rel: "noreferrer",
"data-testid": "hwlab-open-new-from-catalog",
}, "新窗口"),
),
),
),
h("p", { className: "muted paragraph" }, "该入口仅用于外部静态跳转,不会进入 microservice health、proxy 或运行态探测。"),
),
h(Panel, { title: "服务映射", eyebrow: "Repo Reference + Runtime" },
microservices.length === 0 ? h(EmptyState, { title: "暂无用户服务", text: "在 config.json 的 microservices 中登记用户服务的 provider、仓库引用和后端映射" }) :
h("div", { className: "table-wrap" }, h("table", { className: "microservice-table" },
@@ -2189,6 +2215,7 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
if (activeModule === "tasks" && activeTab === "history") return h(TaskHistoryPage, { tasks: data.tasks, onRaw });
if (activeModule === "tasks" && activeTab === "results") return h(TaskResultsPage, { tasks: data.tasks, onRaw });
if (activeModule === "apps" && activeTab === "catalog") return h(MicroserviceCatalogPage, { microservices: data.microservices, onRaw, onNavigate });
if (activeModule === "apps" && activeTab === "hwlab") return h(HwlabPage, {});
if (activeModule === "apps" && activeTab === "todo-note") return h(TodoNotePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "findjob") return h(FindJobPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "pipeline") return h(PipelinePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
+103
View File
@@ -0,0 +1,103 @@
import React from "react";
import { LoadingTitle } from "./loading-indicator";
type AnyRecord = Record<string, any>;
const h = React.createElement;
export const HWLAB_URL = "http://74.48.78.17:6666";
function StatusBadge({ status, children }: AnyRecord) {
const normalized = String(status || "unknown").toLowerCase();
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
}
function Panel({ title, eyebrow, actions, children, className }: AnyRecord) {
return h("section", { className: `panel ${className || ""}` },
h("div", { className: "panel-head" },
h("div", null,
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
h(LoadingTitle, { title, loading: false }),
),
actions ? h("div", { className: "panel-actions" }, actions) : null,
),
h("div", { className: "panel-body" }, children),
);
}
function EmptyState({ title, text }: AnyRecord) {
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
}
function ActionLink({ href, children, testId, primary = false }: AnyRecord) {
return h("a", {
className: primary ? "primary-btn compact" : "ghost-btn compact",
href,
target: "_blank",
rel: "noreferrer",
"data-testid": testId,
}, children);
}
function ActionButton({ onClick, children, testId, primary = false }: AnyRecord) {
return h("button", {
type: "button",
className: primary ? "primary-btn compact" : "ghost-btn compact",
onClick,
"data-testid": testId,
}, children);
}
export function HwlabPage(): any {
return h("div", { className: "microservice-page hwlab-page", "data-testid": "hwlab-page" },
h(Panel, {
title: "HWLAB",
eyebrow: "External Static Entry",
actions: h("span", { className: "panel-link-badge" },
h(StatusBadge, { status: "external" }, "external"),
),
},
h("div", { className: "hwlab-hero" },
h("div", { className: "hwlab-hero-copy" },
h("p", { className: "panel-eyebrow" }, "Static Link"),
h("h2", null, "HWLAB"),
h("p", { className: "muted paragraph" }, "这是一个外部静态入口,不经过 UniDesk 后端代理,不纳入 microservice health。"),
),
h("div", { className: "hwlab-actions" },
h(ActionButton, {
primary: true,
testId: "hwlab-open-current",
onClick: () => window.location.assign(HWLAB_URL),
}, "当前窗口打开"),
h(ActionLink, { href: HWLAB_URL, testId: "hwlab-open-new" }, "新窗口打开"),
),
),
h("div", { className: "metric-grid hwlab-metrics" },
h("article", { className: "metric-card" },
h("div", { className: "metric-label" }, "入口类型"),
h("div", { className: "metric-value" }, "external"),
h("div", { className: "metric-hint" }, "static link only"),
),
h("article", { className: "metric-card" },
h("div", { className: "metric-label" }, "目标 URL"),
h("div", { className: "metric-value hwlab-url" }, HWLAB_URL),
h("div", { className: "metric-hint" }, "fixed destination"),
),
h("article", { className: "metric-card" },
h("div", { className: "metric-label" }, "后端依赖"),
h("div", { className: "metric-value" }, "none"),
h("div", { className: "metric-hint" }, "no proxy / no health"),
),
h("article", { className: "metric-card" },
h("div", { className: "metric-label" }, "打开方式"),
h("div", { className: "metric-value" }, "current or new tab"),
h("div", { className: "metric-hint" }, "same style as catalog"),
),
),
h(EmptyState, {
title: "外部入口说明",
text: "HWLAB 只作为静态跳转目标展示在前端,不会登记到用户服务 health、proxy 或运行态数据中。",
}),
),
);
}
@@ -62,6 +62,7 @@ export const MODULES: UniDeskModuleDefinition[] = [
] },
{ id: "apps", label: "用户服务", code: "APP", routeSegment: "app", tabs: [
{ id: "catalog", label: "服务目录" },
{ id: "hwlab", label: "HWLAB" },
{ id: "todo-note", label: "Todo Note" },
{ id: "findjob", label: "FindJob" },
{ id: "pipeline", label: "Pipeline" },