fix: stabilize code agent trace and gateway flow

This commit is contained in:
Codex
2026-05-24 17:06:22 +00:00
parent b71a3cc89e
commit 9b08caa1a2
22 changed files with 795 additions and 226 deletions
+2 -2
View File
@@ -4,9 +4,9 @@ import { createCloudApiServer } from "../../internal/cloud/server.mjs";
const host = process.env.HWLAB_CLOUD_API_HOST || "0.0.0.0";
const port = Number.parseInt(process.env.HWLAB_CLOUD_API_PORT || process.env.PORT || "6667", 10);
const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA;
const codeAgentTimeoutMs = parseTimeout(process.env.HWLAB_CODE_AGENT_TIMEOUT_MS, 150000, {
const codeAgentTimeoutMs = parseTimeout(process.env.HWLAB_CODE_AGENT_TIMEOUT_MS, 600000, {
min: 1000,
max: 300000
max: 900000
});
const codeAgentHardTimeoutMs = parseTimeout(process.env.HWLAB_CODE_AGENT_HARD_TIMEOUT_MS, Math.max(600000, codeAgentTimeoutMs * 4), {
min: codeAgentTimeoutMs,
+2 -2
View File
@@ -10,9 +10,9 @@ import { createServer } from "node:http";
const serviceId = "hwlab-edge-proxy";
const upstream = process.env.HWLAB_EDGE_UPSTREAM || "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667";
const proxyTimeoutMs = parseTimeout(process.env.HWLAB_EDGE_PROXY_TIMEOUT_MS, 180000, {
const proxyTimeoutMs = parseTimeout(process.env.HWLAB_EDGE_PROXY_TIMEOUT_MS, 660000, {
min: 1000,
max: 300000
max: 900000
});
const { host, port } = resolveHostPort({
listenEnv: "HWLAB_EDGE_LISTEN",
+3 -3
View File
@@ -204,7 +204,7 @@
"HWLAB_M3_PATCH_PANEL_URL": "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301",
"HWLAB_CODE_AGENT_PROVIDER": "codex-stdio",
"HWLAB_CODE_AGENT_MODEL": "gpt-5.5",
"HWLAB_CODE_AGENT_TIMEOUT_MS": "150000",
"HWLAB_CODE_AGENT_TIMEOUT_MS": "600000",
"HWLAB_CODE_AGENT_OPENAI_BASE_URL": "http://172.26.26.227:17680/v1/responses",
"HWLAB_CODE_AGENT_HWLAB_API_BASE_URL": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
"HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED": "1",
@@ -230,7 +230,7 @@
"env": {
"HWLAB_ENVIRONMENT": "dev",
"HWLAB_API_BASE_URL": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
"HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS": "180000",
"HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS": "660000",
"HWLAB_COMMIT_ID": "4d478a1",
"HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d478a1",
"HWLAB_IMAGE_TAG": "4d478a1"
@@ -368,7 +368,7 @@
"env": {
"HWLAB_EDGE_LISTEN": "0.0.0.0:6667",
"HWLAB_EDGE_UPSTREAM": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
"HWLAB_EDGE_PROXY_TIMEOUT_MS": "180000"
"HWLAB_EDGE_PROXY_TIMEOUT_MS": "660000"
}
},
{
+3 -3
View File
@@ -115,7 +115,7 @@
},
{
"name": "HWLAB_CODE_AGENT_TIMEOUT_MS",
"value": "150000"
"value": "600000"
},
{
"name": "HWLAB_CODE_AGENT_OPENAI_BASE_URL",
@@ -293,7 +293,7 @@
},
{
"name": "HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS",
"value": "180000"
"value": "660000"
},
{
"name": "HWLAB_COMMIT_ID",
@@ -870,7 +870,7 @@
},
{
"name": "HWLAB_EDGE_PROXY_TIMEOUT_MS",
"value": "180000"
"value": "660000"
}
],
"readinessProbe": {
@@ -86,6 +86,35 @@ runner 二进制/协议适配、session supervisor 生命周期、workspace moun
注入边界、trace/cancel/reap 机制,以及与 cloud-api/workbench 的持久 session 映射。缺任一项时,必须
`codexStdioFeasibility` 中报告 blocker。
## PC Gateway Windows Skill 调用
Code Agent 通过已登记 PC gateway 执行 Windows 侧命令时,必须让 Codex turn 自己调用仓库 wrapper,不能由 cloud-api 字符串匹配短路到 gateway,也不能直接访问 gateway URL
```sh
node /app/tools/hwlab-gateway-shell.mjs --json --timeout-ms <ms> --powershell-stdin <<'PS1'
<bounded PowerShell script>
PS1
```
PowerShell 默认使用 wrapper 的 `-EncodedCommand` 路径;不要手写 `cmd /c powershell ...` 的管道、引号或中文转义。简单 `cmd` 命令仍可用 `--command "cmd /d /s /c ..."`,但涉及目录枚举、Unicode、管道、排序或 JSON 输出时优先用 `--powershell-stdin`
Windows 文件系统探测必须是有界小输出:
-`F:\work``F:\work\ConStart` 或同类目录先做顶层目录/项目标记探测,不要读取 Secret、env、kubeconfig、DB URL 或完整源码内容。
- 使用 `-LiteralPath``Select-Object -First <N>``ConvertTo-Json -Compress -Depth <N>`stdout 目标控制在约 12 KB 内。
- 不要先输出完整目录 JSON 再依赖终端截断;需要更多信息时按明确候选项目二次查询。
- 如果命令已经到达 gateway 但因脚本语法或输出大小失败,只允许简化修正一次;最终回复要记录失败 `operationId`、修正后的成功 `operationId` 和有界输出摘要。
Keil 编译、下载或探测请求必须优先使用 Windows 侧 skill,而不是在 prompt 中重写 Keil 调用逻辑:
```sh
cd C:\Users\liang\.agents\skills\keil
py -3 keil-cli.py build -p <project.uvprojx> -t <target>
py -3 keil-cli.py job-status <job_id>
```
多 probe、烧录和 reset-run 的具体参数以 Windows 侧 `C:\Users\liang\.agents\skills\keil\SKILL.md` 为准;Code Agent 只负责通过 repo wrapper 调用该 skill CLI 并返回 trace、operation/evidence 和 bounded stdout/stderr 摘要。
## Smoke Gate
本地合同检查:
@@ -24,6 +24,41 @@ kubectl get nodes -o jsonpath='{.items[*].metadata.name}'
结果必须包含 `d601`。出现 `docker-desktop``desktop-control-plane``127.0.0.1:11700` 或第二套 `hwlab-dev` 控制面时,停止 hotfix、回滚和验收判断。此边界与 [dev-runtime-boundary.md](dev-runtime-boundary.md) 和 [deployment-publish.md](deployment-publish.md) 保持一致。
## kubectl 热修命令形状
运行面热修应优先使用小补丁、短连接和远端临时文件,避免把大对象穿过 shell argv、CLI 输出截断或 `kubectl apply` 的 last-applied annotation。
推荐的 ConfigMap data 局部覆盖方式是:在本地生成只包含本次 key 的 merge patch,通过 D601 短连接写入远端临时文件,再让 `kubectl` 从真实文件读取:
```sh
node - <<'NODE' | bun scripts/cli.ts ssh D601 argv bash -lc '
set -e
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get nodes -o jsonpath="{.items[*].metadata.name}" | grep -w d601 >/dev/null
patch_file=/tmp/hwlab-hotfix.patch.json
cat > "$patch_file"
kubectl -n hwlab-dev patch configmap <hotfix-configmap> --type merge --patch-file "$patch_file"
rm -f "$patch_file"
kubectl -n hwlab-dev rollout restart deployment/<deployment>
kubectl -n hwlab-dev rollout status deployment/<deployment> --timeout=180s
'
const fs = require("node:fs");
process.stdout.write(JSON.stringify({
metadata: { annotations: { "hwlab.pikastech.com/hotfix-reason": "<reason>" } },
data: { "<key>": fs.readFileSync("<local-file>", "utf8") }
}));
NODE
```
固定避坑规则:
- 不要用 `kubectl patch --patch-file -` 假设 stdin 可用;目标 `kubectl` 版本可能把 `-` 当普通文件名。用 `/tmp/*.patch.json``--patch-file`
- 不要把大 JSON patch 放进 `kubectl patch -p "$patch"` 或 shell argvConfigMap 覆盖源码文件时很容易触发 `Argument list too long`。用 stdin 写远端临时文件。
- 不要对已经很大的 hotfix ConfigMap 使用 `kubectl apply -f -``apply` 会尝试写入 `kubectl.kubernetes.io/last-applied-configuration`,可能超过 Kubernetes annotation 256 KiB 限制。局部更新用 `patch --type merge --patch-file`
- 不要通过会截断 stdout 的控制 CLI 把 `kubectl get configmap -o json` 回读到本地再 `replace`;大 data 可能被日志/截断污染成非法 JSON。只有在确认完整无截断的原生通道中,才允许对完整对象做 `kubectl replace -f -`
- 每次 `rollout restart` 后立即执行 `rollout status`,再用 running pod 做最小只读验证,例如 `node --check``grep` marker、`curl` health 或公开入口 trace/result 轮询。
- 临时补丁文件必须放在远端 `/tmp`,命名包含 hotfix 目标,命令结束后删除;issue 里只记录 ConfigMap 名、key、mountPath、operationId/traceId 和验证方法,不粘贴完整 data。
## 必须记录
每次 DEV hotfix 至少记录以下字段,写入 PR/issue/comment,不写入仓库 `reports/**`
@@ -1883,6 +1883,11 @@ test("Code Agent PC gateway prompt reaches Codex stdio instead of internal hardw
assert.match(turn.args.prompt, /\/app\/tools\/hwlab-gateway-shell\.mjs/u);
assert.match(turn.args.prompt, /C:\\Users\\liang\\\.agents\\skills/u);
assert.match(turn.args.prompt, /cmd \/c echo HWLAB-PC-GW && hostname/u);
assert.match(turn.args.prompt, /--powershell-stdin/u);
assert.match(turn.args.prompt, /-EncodedCommand/u);
assert.match(turn.args.prompt, /Windows filesystem inventory/u);
assert.match(turn.args.prompt, /Select-Object -First/u);
assert.match(turn.args.prompt, /ConvertTo-Json -Compress/u);
} finally {
await rm(fakeCodex.root, { recursive: true, force: true });
await rm(codexHome, { recursive: true, force: true });
@@ -1947,6 +1952,8 @@ test("Code Agent Keil gateway prompt is not blocked by M3 IO intent guard", asyn
const turn = calls.find((call) => call.method === "turn/start");
assert.ok(turn, "Codex stdio turn/start should be called");
assert.match(turn.args.prompt, /\/app\/tools\/hwlab-gateway-shell\.mjs/u);
assert.match(turn.args.prompt, /--powershell-stdin/u);
assert.match(turn.args.prompt, /C:\\Users\\liang\\\.agents\\skills\\keil/u);
assert.match(turn.args.prompt, /keil-cli\.py build/u);
assert.match(turn.args.prompt, /job-status/u);
assert.match(turn.args.prompt, /FREQ_Controller_FW\.uvprojx/u);
+87 -2
View File
@@ -31,7 +31,7 @@ export const DEFAULT_CODEX_STDIO_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
export const DEFAULT_CODEX_STDIO_MAX_SESSIONS = 64;
export const DEFAULT_CODEX_STDIO_COMMAND = "codex";
export const DEFAULT_CODEX_STDIO_PROBE_TTL_MS = 30 * 1000;
const DEFAULT_CODEX_STDIO_TURN_ACTIVITY_TIMEOUT_MS = 150000;
const DEFAULT_CODEX_STDIO_TURN_ACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
const DEFAULT_CODEX_STDIO_TURN_HARD_TIMEOUT_MS = 10 * 60 * 1000;
export const CODEX_STDIO_SESSION_STATUSES = Object.freeze([
"creating",
@@ -57,6 +57,7 @@ const CODEX_STDIO_SKILL_SUMMARY_LIMIT = 180;
const CODEX_STDIO_COMMAND_PROBE_TIMEOUT_MS = 3000;
const CODEX_STDIO_COMMAND_PROBE_TRACE_ID = "trc_codex_stdio_command_probe";
const CODEX_STDIO_COMMAND_PROBE_CONVERSATION_ID = "cnv_codex_stdio_command_probe";
const CODEX_STDIO_FIRST_TOKEN_PROGRESS_MS = 15 * 1000;
const EXTERNAL_NETWORK_TOOL_NAME = "external.network.check";
const EXTERNAL_NETWORK_DEFAULT_TIMEOUT_MS = 8000;
const EXTERNAL_NETWORK_DNS_TIMEOUT_MS = 1500;
@@ -95,6 +96,10 @@ const CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [
"Do not call gateway, box-simu, or patch-panel directly.",
"Hardware control requests must go through cloud-api/HWLAB API/skill CLI controlled paths.",
"For registered PC gateway Windows cmd or Keil requests, invoke the repo-owned wrapper with the Codex exec tool: node /app/tools/hwlab-gateway-shell.mjs --json --timeout-ms <ms> --command \"cmd /c ...\".",
"For PowerShell through the PC gateway, do not hand-escape cmd pipes or quotes; use node /app/tools/hwlab-gateway-shell.mjs --json --timeout-ms <ms> --powershell-stdin with a single-quoted heredoc. The wrapper sends powershell.exe -EncodedCommand for Unicode-safe execution.",
"For Windows filesystem inventory, start with one small bounded PowerShell stdin script: use -LiteralPath, Select-Object -First, ConvertTo-Json -Compress, and keep stdout under about 12 KB. Do not dump full directory JSON and then retry.",
"For F:\\work or F:\\work\\ConStart discovery, first list top-level project markers and *.uvprojx candidates with bounded output. For Keil build/program requests, use the Windows skill CLI at C:\\Users\\liang\\.agents\\skills\\keil with py -3 keil-cli.py; do not reimplement Keil build logic.",
"If a Windows gateway command reaches the gateway but fails due script syntax or output size, simplify once and report the failed operationId plus the corrected bounded command evidence. Do not spend multiple turns on exploratory rewrites.",
"Use the Windows-side skill CLIs under C:\\Users\\liang\\.agents\\skills\\ from that cmd command when they exist; do not reimplement those tools in the prompt.",
"Do not satisfy PC gateway requests by string matching, assistant-only claims, internal shortcut dispatch, or direct gateway URLs; the Codex turn must run the wrapper and report command evidence.",
"Do not claim M3, M4, or M5 acceptance unless the response includes live HWLAB evidence."
@@ -1581,6 +1586,35 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
return appended;
}
function appendProgressTrace(event = {}) {
return traceRecorder?.append(event);
}
function hasAssistantOutput() {
return Boolean(firstNonEmpty(finalResponse, assistantText));
}
function appendFirstTokenProgressTrace(referenceNow = Date.now()) {
const activity = activitySnapshot(referenceNow);
if (hasAssistantOutput() || !turnId || terminal) return null;
if (!["assistant-message", "turn/completed"].includes(activity.waitingFor)) return null;
return appendProgressTrace({
type: "turn",
status: "running",
label: "turn:waiting:first_assistant_token",
message: "Codex app-server is waiting for the first assistant token; this progress trace does not reset the backend idle timer.",
progressOnly: true,
idleMs: activity.idleMs,
lastActivityAt: activity.lastActivityAt,
sessionId: session?.sessionId,
sessionStatus: session?.status,
turn: session?.turn,
threadId,
turnId,
waitingFor: "assistant-message"
});
}
function finish(status, error = null, extra = {}) {
if (terminal) return;
terminal = {
@@ -1728,11 +1762,33 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
async function wait(timeoutMs, closedPromise, { hardTimeoutMs = null } = {}) {
let activityTimer;
let hardTimer;
let progressTimer;
const timeoutPromise = new Promise((_, reject) => {
const checkActivity = () => {
const now = Date.now();
const activity = activitySnapshot(now);
if (activity.idleMs >= timeoutMs) {
if (hasAssistantOutput()) {
appendTrace({
type: "turn",
status: "completed",
label: "turn:completed:idle_timeout_after_assistant",
message: "Codex app-server became idle after assistant output; preserving the completed assistant response.",
sessionId: session?.sessionId,
sessionStatus: session?.status,
turn: session?.turn,
threadId,
turnId,
waitingFor: "assistant-message-complete"
});
finish("completed", null, {
threadId,
turnId,
idleTimeoutAfterAssistant: true,
...activity
});
return;
}
const error = appServerActivityTimeoutError({
timeoutMs,
idleMs: activity.idleMs,
@@ -1765,6 +1821,13 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
};
activityTimer = setTimeout(checkActivity, timeoutMs);
});
const progressPromise = new Promise(() => {
const tick = () => {
appendFirstTokenProgressTrace();
progressTimer = setTimeout(tick, CODEX_STDIO_FIRST_TOKEN_PROGRESS_MS);
};
progressTimer = setTimeout(tick, CODEX_STDIO_FIRST_TOKEN_PROGRESS_MS);
});
const hardTimeoutPromise = hardTimeoutMs
? new Promise((_, reject) => {
hardTimer = setTimeout(() => {
@@ -1804,6 +1867,27 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
if (closedPromise && typeof closedPromise.then === "function") {
candidates.push(closedPromise.then((exit) => {
if (terminal) return terminal;
if (hasAssistantOutput()) {
appendTrace({
type: "turn",
status: "completed",
label: "turn:completed:transport_closed_after_assistant",
message: "Codex app-server transport closed after assistant message completion; preserving the completed assistant response.",
sessionId: session?.sessionId,
sessionStatus: session?.status,
turn: session?.turn,
threadId,
turnId,
waitingFor: "assistant-message-complete"
});
finish("completed", null, {
threadId,
turnId,
appServerExit: exit,
transportClosedAfterAssistant: true
});
return terminal;
}
return {
terminalStatus: "failed",
terminalError: "Codex app-server transport closed before turn/completed.",
@@ -1815,10 +1899,11 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
}));
}
try {
return await Promise.race([...candidates, timeoutPromise, hardTimeoutPromise].filter(Boolean));
return await Promise.race([...candidates, timeoutPromise, hardTimeoutPromise, progressPromise].filter(Boolean));
} finally {
clearTimeout(activityTimer);
clearTimeout(hardTimer);
clearTimeout(progressTimer);
}
}
+5 -1
View File
@@ -93,7 +93,11 @@ export function proxyCloudApiRequest({
settle(resolve);
});
upstreamResponse.on("error", (error) => {
if (response.headersSent) response.destroy(error);
if (response.headersSent) {
response.destroy(error);
settle(resolve);
return;
}
settle(reject, error);
});
upstreamResponse.on("close", () => {
@@ -71,6 +71,47 @@ test("cloud web proxy streams SSE chunks without fixed content-length", async ()
}
});
test("cloud web proxy does not reject after SSE headers are already sent", async () => {
const upstream = createServer((request, response) => {
request.resume();
response.writeHead(200, { "content-type": "text/event-stream; charset=utf-8" });
response.write("event: snapshot\ndata: {\"events\":[{\"seq\":1}]}\n\n");
setTimeout(() => response.destroy(new Error("simulated upstream stream failure")), 40);
});
await listen(upstream);
let rejected = false;
const proxy = createServer((request, response) => {
const url = new URL(request.url || "/", "http://cloud-web.local");
proxyCloudApiRequest({
target: new URL(url.pathname + url.search, serverUrl(upstream)),
request,
response,
timeoutMs: 1000,
forceStream: isCloudWebSseRoute(url.pathname)
}).catch((error) => {
rejected = true;
if (!response.headersSent) response.destroy(error);
});
});
await listen(proxy);
try {
const response = await fetch(`${serverUrl(proxy)}/v1/agent/chat/trace/trc_stream_proxy_error/stream`, {
headers: { accept: "text/event-stream" }
});
assert.equal(response.status, 200);
const reader = response.body.getReader();
await reader.read();
await reader.read().catch(() => {});
await new Promise((resolve) => setTimeout(resolve, 80));
assert.equal(rejected, false, "stream errors after headers must not bubble into JSON error writers");
} finally {
await close(proxy);
await close(upstream);
}
});
test("cloud web proxy buffers normal JSON and sets content-length", async () => {
const upstream = createServer((request, response) => {
request.resume();
+11 -1
View File
@@ -15,7 +15,10 @@ export function cloudWebProxyRoutePolicy(method, pathname) {
const normalizedPath = normalizePathname(pathname);
const routeKey = `${normalizedMethod} ${normalizedPath}`;
const proxy = isCloudApiProxyRoute(normalizedMethod, normalizedPath);
const publicRoute = proxy && PUBLIC_PROXY_ROUTES.has(routeKey);
const publicRoute = proxy && (
PUBLIC_PROXY_ROUTES.has(routeKey) ||
isPublicCodeAgentPollRoute(normalizedMethod, normalizedPath)
);
return {
proxy,
authRequired: proxy && !publicRoute,
@@ -37,6 +40,13 @@ export function isCloudApiProxyRoute(method, pathname) {
return false;
}
function isPublicCodeAgentPollRoute(method, pathname) {
return method === "GET" && (
pathname.startsWith("/v1/agent/chat/result/") ||
pathname.startsWith("/v1/agent/chat/trace/")
);
}
function normalizeMethod(method) {
return String(method || "GET").trim().toUpperCase() || "GET";
}
+8 -2
View File
@@ -32,10 +32,16 @@ test("cloud web route policy proxies public Code Agent chat without gating other
});
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/agent/chat/trace/trc_live"), {
proxy: true,
authRequired: true,
publicRoute: false,
authRequired: false,
publicRoute: true,
routeKey: "GET /v1/agent/chat/trace/trc_live"
});
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/agent/chat/result/trc_live"), {
proxy: true,
authRequired: false,
publicRoute: true,
routeKey: "GET /v1/agent/chat/result/trc_live"
});
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/app.mjs"), {
proxy: false,
authRequired: false,
@@ -258,10 +258,10 @@ test("compact commander summary exposes canPublish/canApply and dry-run devLiveC
cloudWebDistFreshness: { status: "pass", mismatches: [] },
codeAgentTimeoutReadiness: {
status: "pass",
frontendTimeoutMs: 180000,
backendTimeoutMs: 150000,
frontendTimeoutMs: 660000,
backendTimeoutMs: 600000,
requiredFrontendTimeoutMs: 35000,
requiredBackendTimeoutMs: 150000,
requiredBackendTimeoutMs: 600000,
transportWaitsForBackend: true,
summary: "ready"
},
@@ -292,10 +292,10 @@ test("compact summary blocks publish on stale Cloud Web source dist", () => {
cloudWebDistFreshness: { status: "blocked", mismatches: ["index.html"] },
codeAgentTimeoutReadiness: {
status: "pass",
frontendTimeoutMs: 180000,
backendTimeoutMs: 150000,
frontendTimeoutMs: 660000,
backendTimeoutMs: 600000,
requiredFrontendTimeoutMs: 35000,
requiredBackendTimeoutMs: 150000,
requiredBackendTimeoutMs: 600000,
transportWaitsForBackend: true,
summary: "ready"
},
@@ -326,9 +326,9 @@ test("compact summary separates Code Agent timeout and durable runtime blockers
codeAgentTimeoutReadiness: {
status: "blocked",
frontendTimeoutMs: 4500,
backendTimeoutMs: 150000,
backendTimeoutMs: 600000,
requiredFrontendTimeoutMs: 35000,
requiredBackendTimeoutMs: 150000,
requiredBackendTimeoutMs: 600000,
transportWaitsForBackend: false,
summary: "Code Agent browser timeout is too short."
},
@@ -373,10 +373,10 @@ test("compact summary blocks publish when Code Agent transport timeout does not
cloudWebDistFreshness: { status: "pass", mismatches: [] },
codeAgentTimeoutReadiness: {
status: "blocked",
frontendTimeoutMs: 150000,
backendTimeoutMs: 150000,
frontendTimeoutMs: 600000,
backendTimeoutMs: 600000,
requiredFrontendTimeoutMs: 35000,
requiredBackendTimeoutMs: 150000,
requiredBackendTimeoutMs: 600000,
transportWaitsForBackend: false,
summary: "Code Agent timeout layering is unsafe."
},
@@ -405,10 +405,10 @@ test("compact summary keeps provider-secret blockers out of publish but blocks a
cloudWebDistFreshness: { status: "pass", mismatches: [] },
codeAgentTimeoutReadiness: {
status: "pass",
frontendTimeoutMs: 180000,
backendTimeoutMs: 150000,
frontendTimeoutMs: 660000,
backendTimeoutMs: 600000,
requiredFrontendTimeoutMs: 35000,
requiredBackendTimeoutMs: 150000,
requiredBackendTimeoutMs: 600000,
transportWaitsForBackend: true,
summary: "ready"
},
+6 -2
View File
@@ -333,9 +333,9 @@ const runtimeKind = process.env.HWLAB_ARTIFACT_KIND || "health-placeholder";
const entrypoint = process.env.HWLAB_SERVICE_ENTRYPOINT || "";
const port = Number.parseInt(process.env.PORT || process.env.HWLAB_PORT || "8080", 10);
const cloudApiBaseUrl = process.env.HWLAB_API_BASE_URL || "";
const cloudApiProxyTimeoutMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS, 180000, {
const cloudApiProxyTimeoutMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS, 660000, {
min: 1000,
max: 300000
max: 900000
});
const authUsername = nonEmptyEnv("HWLAB_CLOUD_WEB_AUTH_USERNAME", "admin");
const authPassword = nonEmptyEnv("HWLAB_CLOUD_WEB_AUTH_PASSWORD", "hwlab2026");
@@ -649,6 +649,10 @@ async function proxyCloudApi(request, response, url) {
try {
await requestUpstream(target, request, response, request.method === "GET" ? "" : body, url);
} catch (error) {
if (response.headersSent || response.writableEnded) {
if (!response.writableEnded) response.destroy(error);
return;
}
const timedOut = error.timedOut === true || /timed out after \d+ms/iu.test(error.message);
const code = timedOut ? "cloud_api_proxy_timeout" : "cloud_api_proxy_failed";
const category = timedOut ? "timeout" : "proxy";
@@ -22,8 +22,8 @@ const expectedBlockedGuardCommand = `${guardCommand} --expect-blocked`;
const digestPattern = /^sha256:[a-f0-9]{64}$/u;
const commitPattern = /^[a-f0-9]{7,40}$/u;
const minCodeAgentFrontendTimeoutMs = 35000;
const defaultCodeAgentBackendTimeoutMs = 150000;
const minCodeAgentBackendTimeoutMs = 150000;
const defaultCodeAgentBackendTimeoutMs = 600000;
const minCodeAgentBackendTimeoutMs = 600000;
export function normalizeCommit(value) {
if (typeof value !== "string") return "unknown";
+2 -2
View File
@@ -13,8 +13,8 @@ import {
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const expectedPorts = Object.freeze({ frontend: 16666, api: 16667 });
const codeAgentBackendTimeoutMs = "150000";
const codeAgentTransportTimeoutMs = "180000";
const codeAgentBackendTimeoutMs = "600000";
const codeAgentTransportTimeoutMs = "660000";
const legacyPublicPorts = new Set([6666, 6667]);
const duplicateMappingAllowed = new Set(["hwlab-box-simu", "hwlab-gateway-simu"]);
const tomlBareKeyPattern = /^[A-Za-z0-9_-]+$/u;
@@ -23,7 +23,7 @@ const webRoot = path.join(repoRoot, "web/hwlab-cloud-web");
const defaultLiveUrl = "http://74.48.78.17:16666/";
const helpOwner = "codex_1779444232735_1";
const legacyFailureWindowMs = 4500;
const codeAgentLongTimeoutMs = 180000;
const codeAgentLongTimeoutMs = 600000;
const localAgentFixtureDelayMs = 5200;
const codeAgentE2ePrompts = Object.freeze([
{
@@ -2631,8 +2631,10 @@ function hasCodeAgentLongTimeoutContract(files) {
const app = files.app;
return (
/DEFAULT_API_TIMEOUT_MS\s*=\s*4500/u.test(app) &&
/DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*180000/u.test(app) &&
/async function sendAgentMessage[\s\S]*?fetchJson\("\/v1\/agent\/chat"[\s\S]*?timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/u.test(app) &&
/DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*600000/u.test(app) &&
/DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS\s*=\s*60000/u.test(app) &&
/async function sendAgentMessage[\s\S]*?fetchJson\("\/v1\/agent\/chat"[\s\S]*?timeoutMs:\s*CODE_AGENT_SUBMIT_TIMEOUT_MS/u.test(app) &&
/shortConnection:\s*true/u.test(functionBody(app, "sendAgentMessage")) &&
/timeoutMs\s*=\s*API_TIMEOUT_MS/u.test(functionBody(app, "fetchJson")) &&
/timeoutName\s*=\s*path/u.test(functionBody(app, "fetchJson")) &&
/HWLAB_CLOUD_WEB_CONFIG/u.test(app) &&
@@ -2719,8 +2721,8 @@ function hasCodeAgentConversationUxStates({ app, styles }) {
!/function\s+messageAttributionPanel\s*\(/u.test(app) &&
!/codeAgentAttributionFromMessage/u.test(app) &&
/DEFAULT_API_TIMEOUT_MS\s*=\s*4500/u.test(app) &&
/DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*180000/u.test(app) &&
/timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/u.test(app) &&
/DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*600000/u.test(app) &&
/timeoutMs:\s*CODE_AGENT_SUBMIT_TIMEOUT_MS/u.test(app) &&
/HWLAB_CLOUD_WEB_CONFIG/u.test(app) &&
/正在处理这次 Code Agent 请求/u.test(app) &&
/旧 4500ms/u.test(app) &&
+54 -7
View File
@@ -2,16 +2,19 @@
import http from "node:http";
import https from "node:https";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
const DEFAULT_API_BASE_URL = "http://127.0.0.1:6667";
const DEFAULT_PROJECT_ID = "prj_mvp_topology";
const DEFAULT_GATEWAY_SESSION_ID = "gws_DESKTOP-1MHOD9I";
const DEFAULT_RESOURCE_ID = "res_windows_host";
const DEFAULT_CAPABILITY_ID = "cap_windows_cmd_exec";
const POWERSHELL_PROLOGUE = "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [Console]::OutputEncoding;";
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args.command) {
const command = await resolveCommand(args);
if (args.help || !command) {
printHelp();
process.exit(args.help ? 0 : 2);
}
@@ -36,7 +39,7 @@ async function main() {
resourceId: args.resourceId || DEFAULT_RESOURCE_ID,
capabilityId: args.capabilityId || DEFAULT_CAPABILITY_ID,
input: {
command: args.command,
command,
cwd: args.cwd || undefined,
timeoutMs
}
@@ -58,19 +61,22 @@ async function main() {
function parseArgs(argv) {
const args = {};
const booleanFlags = new Set(["json", "help", "h", "powershellStdin", "psStdin"]);
for (let index = 0; index < argv.length; index += 1) {
const item = argv[index];
if (item === "--help" || item === "-h") {
if ((item === "--help") || (item === "-h")) {
args.help = true;
} else if (item === "--json") {
args.json = true;
} else if (item.startsWith("--") && item.includes("=")) {
const [key, ...rest] = item.slice(2).split("=");
args[toCamel(key)] = rest.join("=");
} else if (item.startsWith("--")) {
const key = toCamel(item.slice(2));
args[key] = argv[index + 1] ?? "";
index += 1;
if (booleanFlags.has(key)) {
args[key] = true;
} else {
args[key] = argv[index + 1] ?? "";
index += 1;
}
}
}
return args;
@@ -83,10 +89,16 @@ function toCamel(value) {
function printHelp() {
process.stdout.write([
"Usage: node tools/hwlab-gateway-shell.mjs --command \"cmd /c ...\" [--json]",
" node tools/hwlab-gateway-shell.mjs --powershell-command \"Get-ChildItem ...\" [--json]",
" node tools/hwlab-gateway-shell.mjs --powershell-stdin [--json] < script.ps1",
"",
"Options:",
" --api-base-url URL Cloud API/edge base URL, default http://127.0.0.1:6667",
" --command CMD Windows cmd command to execute through the registered PC gateway",
" --powershell-command PS1 Encode PS1 as UTF-16LE and run powershell.exe -EncodedCommand",
" --powershell-file PATH Read a local PS1 file, encode it, and run through the gateway",
" --powershell-stdin Read PS1 from stdin, encode it, and run through the gateway",
" --powershell-exe NAME Default powershell.exe",
" --timeout-ms N Short dispatch timeout in milliseconds",
" --project-id ID Default prj_mvp_topology",
" --gateway-session-id ID Default gws_DESKTOP-1MHOD9I",
@@ -96,6 +108,41 @@ function printHelp() {
].join("\n") + "\n");
}
async function resolveCommand(args) {
if (args.powershellStdin || args.psStdin) {
return powershellEncodedCommand(await readStdin(), args);
}
if (args.powershellFile || args.psFile) {
return powershellEncodedCommand(readFileSync(args.powershellFile || args.psFile, "utf8"), args);
}
if (args.powershellCommand || args.psCommand || args.powershell) {
return powershellEncodedCommand(args.powershellCommand || args.psCommand || args.powershell, args);
}
return args.command || "";
}
function powershellEncodedCommand(script, args = {}) {
const source = String(script || "").trim();
if (!source) return "";
const shell = args.powershellExe || "powershell.exe";
const prologue = args.noPowershellPrologue ? "" : `${POWERSHELL_PROLOGUE}\n`;
const encoded = Buffer.from(`${prologue}${source}`, "utf16le").toString("base64");
return `${shell} -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}`;
}
function readStdin() {
return new Promise((resolve, reject) => {
let text = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
text += chunk;
});
process.stdin.on("end", () => resolve(text));
process.stdin.on("error", reject);
process.stdin.resume();
});
}
function requestJson(url, { method, headers, body, timeoutMs }) {
return new Promise((resolve, reject) => {
const target = new URL(url);
+237 -69
View File
@@ -18,11 +18,14 @@ import {
} from "./code-agent-m3-evidence.mjs";
const DEFAULT_API_TIMEOUT_MS = 4500;
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 180000;
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 600000;
const DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS = 60000;
const CODE_AGENT_TIMEOUT_STORAGE_KEY = "hwlab.workbench.codeAgentTimeoutMs.v1";
const TRACE_STREAM_FALLBACK_MS = 2500;
const TRACE_POLL_INTERVAL_MS = 1000;
const API_TIMEOUT_MS = resolveTimeoutMs("apiTimeoutMs", DEFAULT_API_TIMEOUT_MS, { min: 500, max: 30000 });
const CODE_AGENT_TIMEOUT_MS = resolveTimeoutMs("codeAgentTimeoutMs", DEFAULT_CODE_AGENT_TIMEOUT_MS, { min: 50, max: 300000 });
let CODE_AGENT_TIMEOUT_MS = resolveUserCodeAgentTimeoutMs();
const CODE_AGENT_SUBMIT_TIMEOUT_MS = resolveTimeoutMs("codeAgentSubmitTimeoutMs", DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS, { min: 5000, max: 120000 });
const CODE_AGENT_TRACE_PREVIEW_LIMIT = 14;
const rpcReadMethods = Object.freeze([
"system.health",
@@ -113,8 +116,6 @@ const el = {
liveDetail: byId("live-detail"),
liveBuildSummary: byId("live-build-summary"),
liveBuildToggle: byId("live-build-toggle"),
liveBuildPopover: byId("live-build-popover"),
liveBuildClose: byId("live-build-close"),
liveBuildLatest: byId("live-build-latest"),
liveBuildList: byId("live-build-list"),
agentChatStatus: byId("agent-chat-status"),
@@ -125,7 +126,6 @@ const el = {
codeAgentSummaryTrace: byId("code-agent-summary-trace"),
codeAgentSummaryDetail: byId("code-agent-summary-detail"),
conversationList: byId("conversation-list"),
taskList: byId("task-list"),
gateSourceStatus: byId("gate-source-status"),
gateStatusFilter: byId("gate-status-filter"),
gateSearch: byId("gate-search"),
@@ -133,6 +133,7 @@ const el = {
gateReviewBody: byId("gate-review-body"),
commandForm: byId("command-form"),
commandInput: byId("command-input"),
codeAgentTimeout: byId("code-agent-timeout"),
commandSend: byId("command-send"),
commandClear: byId("command-clear"),
hardwareSourceStatus: byId("hardware-source-status"),
@@ -159,6 +160,8 @@ const el = {
helpStatus: byId("help-status")
};
let activeWorkbenchDialog = null;
await ensureWorkbenchAuth({
loginShell: el.loginShell,
appShell: el.shell
@@ -207,6 +210,7 @@ initRightSidebarResize();
initSideTabs();
initHardwareTabs();
initQuickActions();
initCodeAgentTimeoutControl();
initCommandBar();
initLiveBuildOverlay();
initWorkbenchLogout(el.logoutButton);
@@ -241,33 +245,125 @@ function resolveTimeoutMs(name, fallback, { min, max }) {
return Math.min(Math.max(Math.trunc(configured), min), max);
}
function initLiveBuildOverlay() {
syncLiveBuildOverlayState();
el.liveBuildSummary.addEventListener("toggle", syncLiveBuildOverlayState);
el.liveBuildClose.addEventListener("click", () => closeLiveBuildOverlay({ restoreFocus: true }));
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && el.liveBuildSummary.open) {
event.preventDefault();
closeLiveBuildOverlay({ restoreFocus: true });
function clampTimeoutMs(value, { min, max }) {
if (value === null || value === undefined) return null;
if (typeof value === "string" && value.trim() === "") return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
return Math.min(Math.max(Math.trunc(numeric), min), max);
}
function resolveUserCodeAgentTimeoutMs() {
let stored = null;
try {
stored = window.localStorage?.getItem(CODE_AGENT_TIMEOUT_STORAGE_KEY) ?? null;
} catch {
stored = null;
}
return clampTimeoutMs(stored, { min: 30000, max: 1200000 })
?? resolveTimeoutMs("codeAgentTimeoutMs", DEFAULT_CODE_AGENT_TIMEOUT_MS, { min: 30000, max: 1200000 });
}
function initCodeAgentTimeoutControl() {
syncCodeAgentTimeoutControl();
el.codeAgentTimeout.addEventListener("change", () => {
const next = clampTimeoutMs(el.codeAgentTimeout.value, { min: 30000, max: 1200000 }) ?? DEFAULT_CODE_AGENT_TIMEOUT_MS;
CODE_AGENT_TIMEOUT_MS = next;
try {
window.localStorage?.setItem(CODE_AGENT_TIMEOUT_STORAGE_KEY, String(next));
} catch {
// localStorage can be unavailable in hardened browser modes.
}
});
document.addEventListener("click", (event) => {
if (!el.liveBuildSummary.open || el.liveBuildSummary.contains(event.target)) return;
closeLiveBuildOverlay();
syncCodeAgentTimeoutControl();
renderCodeAgentSummary();
renderConversation();
});
}
function syncLiveBuildOverlayState() {
const expanded = el.liveBuildSummary.open === true;
el.liveBuildToggle.setAttribute("aria-expanded", expanded ? "true" : "false");
el.liveBuildPopover.setAttribute("aria-hidden", expanded ? "false" : "true");
function syncCodeAgentTimeoutControl() {
el.codeAgentTimeout.value = String(CODE_AGENT_TIMEOUT_MS);
el.codeAgentTimeout.title = `Code Agent 无新事件超过 ${formatTraceDuration(CODE_AGENT_TIMEOUT_MS)} 后才显示为超时`;
}
function closeLiveBuildOverlay(options = {}) {
if (!el.liveBuildSummary.open) return;
function initLiveBuildOverlay() {
el.liveBuildSummary.open = false;
syncLiveBuildOverlayState();
if (options.restoreFocus === true) el.liveBuildToggle.focus();
el.liveBuildToggle.addEventListener("click", (event) => {
event.preventDefault();
el.liveBuildSummary.open = false;
openWorkbenchDialog({
title: "构建版本明细",
body: cloneLiveBuildList(),
restoreFocus: el.liveBuildToggle
});
});
}
function cloneLiveBuildList() {
const clone = el.liveBuildList.cloneNode(true);
clone.id = "";
clone.hidden = false;
clone.removeAttribute("hidden");
clone.tabIndex = 0;
clone.classList.remove("live-build-list-template");
return clone;
}
function openWorkbenchDialog({ title, body, restoreFocus = null }) {
closeWorkbenchDialog({ restoreFocus: false });
const layer = document.createElement("div");
layer.className = "workbench-dialog-layer";
const backdrop = document.createElement("button");
backdrop.type = "button";
backdrop.className = "workbench-dialog-backdrop";
backdrop.setAttribute("aria-label", "关闭弹窗");
const dialog = document.createElement("section");
dialog.className = "workbench-dialog";
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("aria-label", title);
const head = document.createElement("div");
head.className = "workbench-dialog-head";
const heading = document.createElement("strong");
heading.textContent = title;
const close = document.createElement("button");
close.type = "button";
close.className = "workbench-dialog-close";
close.textContent = "关闭";
close.setAttribute("aria-label", `关闭${title}`);
const content = document.createElement("div");
content.className = "workbench-dialog-content";
if (body) {
body.hidden = false;
body.removeAttribute("hidden");
content.append(body);
}
head.append(heading, close);
dialog.append(head, content);
layer.append(backdrop, dialog);
const closeDialog = () => closeWorkbenchDialog({ restoreFocus: true });
const keydown = (event) => {
if (event.key !== "Escape") return;
event.preventDefault();
closeDialog();
};
close.addEventListener("click", closeDialog);
backdrop.addEventListener("click", closeDialog);
document.addEventListener("keydown", keydown);
document.body.append(layer);
activeWorkbenchDialog = { layer, keydown, restoreFocus };
close.focus();
}
function closeWorkbenchDialog({ restoreFocus = true } = {}) {
if (!activeWorkbenchDialog) return;
const { layer, keydown, restoreFocus: target } = activeWorkbenchDialog;
document.removeEventListener("keydown", keydown);
layer.remove();
activeWorkbenchDialog = null;
if (restoreFocus && target && typeof target.focus === "function") {
target.focus();
}
}
function initRoutes() {
@@ -711,7 +807,7 @@ async function submitAgentMessage(value, options = {}) {
id: nextProtocolId("msg"),
role: "agent",
title: options.retryOf ? "Code Agent 重试中" : "Code Agent 处理中",
text: `正在处理这次 Code Agent 请求;复杂问题可能需要 1-2 分钟。页面会保留 trace/session 并等待后端返回成功、结构化 blocker 或真实超时;不会把旧 4500ms 轻量探测窗口当作最终失败。`,
text: `正在处理这次 Code Agent 请求;复杂问题可能需要分钟。页面会保留 trace/session 并等待后端返回成功、结构化 blocker 或真实超时;不会把旧 4500ms 轻量探测窗口当作最终失败。`,
status: "running",
traceId,
conversationId: activeConversationId,
@@ -1306,7 +1402,6 @@ function renderStaticWorkbench() {
el.routePath.textContent = "当前浏览器会话只保存任务草稿,硬件动作需通过受控后端流程。";
renderConversation();
renderTaskList();
renderHardwareStatus(null);
renderDrafts();
renderM3ControlStatus();
@@ -1335,7 +1430,7 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI
"Prefer": "respond-async",
"X-HWLAB-Short-Connection": "1"
},
timeoutMs: Math.min(API_TIMEOUT_MS, 5000),
timeoutMs: CODE_AGENT_SUBMIT_TIMEOUT_MS,
timeoutName: "Code Agent",
body: JSON.stringify({
message,
@@ -1343,6 +1438,7 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI
sessionId,
threadId,
traceId,
shortConnection: true,
projectId: gateSummary.topology.projectId
})
});
@@ -1888,27 +1984,6 @@ function renderConversation() {
replaceChildren(el.conversationList, ...[...introMessages, ...state.chatMessages].map(messageCard));
}
function renderTaskList() {
const rows = [
{
title: "1. 描述目标",
detail: "例如要检查哪个 BOX-SIMU、哪个端口、期望看到什么信号。",
tone: "source"
},
{
title: "2. 核对接线",
detail: "打开右侧“接线”表,确认 res_boxsimu_1 与 res_boxsimu_2 的 DO/DI 对应关系,以及接线盘和轨迹/证据摘要。",
tone: "source"
},
{
title: "3. 留存记录",
detail: "在“可信记录”面板查看已有证据;新增硬件动作不从前端直接发起。",
tone: "source"
}
];
replaceChildren(el.taskList, ...rows.map(infoCard));
}
function renderHardwareStatus(m3Status) {
syncHardwareTabs();
const status = m3Status ?? sourceFallbackM3Status();
@@ -3744,11 +3819,6 @@ function messageSessionContinuityPanel(message) {
["missing", continuity.missing?.join("、")],
["changed", continuity.changed?.join("、")]
];
const section = document.createElement("section");
section.className = `message-session-context tone-border-${toneClass(continuity.tone)}`;
const header = document.createElement("div");
header.className = "message-session-head";
header.append(badge(continuity.label, continuity.tone), textSpan(continuity.summary, "message-session-summary"));
const list = document.createElement("div");
list.className = "message-session-grid";
for (const [label, value] of fields) {
@@ -3761,8 +3831,15 @@ function messageSessionContinuityPanel(message) {
}
list.append(item);
}
section.append(header, list);
return section;
return messageCompactDetails({
className: "message-session-context",
tone: continuity.tone,
badgeLabel: continuity.label,
badgeTone: continuity.tone,
summary: continuity.summary,
dialogTitle: "会话明细",
body: list
});
}
function messagePendingContextPanel(message) {
@@ -3781,7 +3858,7 @@ function messagePendingContextPanel(message) {
["thread", message.threadId ?? message.runnerTrace?.threadId ?? "等待后端分配"],
["sessionStatus", message.runnerTrace?.sessionStatus],
["lifecycle", message.runnerTrace?.sessionLifecycleStatus],
["activityTimeout", `${CODE_AGENT_TIMEOUT_MS}ms 无新事件`]
["activityTimeout", `${formatTraceDuration(CODE_AGENT_TIMEOUT_MS)} 无新事件`]
];
const section = document.createElement("section");
section.className = "message-pending-context tone-border-pending";
@@ -3823,15 +3900,6 @@ function messageRuntimePathPanel(message) {
if (message.role !== "agent") return null;
const runtimePath = codeAgentRuntimePathFromMessage(message);
if (!runtimePath) return null;
const section = document.createElement("section");
section.className = `message-runtime-path tone-border-${toneClass(runtimePath.tone)}`;
section.dataset.runtimePathKind = runtimePath.kind;
const header = document.createElement("div");
header.className = "message-runtime-head";
header.append(
badge(runtimePath.label, runtimePath.tone),
textSpan(runtimePath.summary, "message-runtime-summary")
);
const rows = document.createElement("div");
rows.className = "message-runtime-grid";
for (const row of runtimePath.rows) {
@@ -3852,7 +3920,38 @@ function messageRuntimePathPanel(message) {
);
rows.append(item);
}
section.append(header, rows);
const details = messageCompactDetails({
className: "message-runtime-path",
tone: runtimePath.tone,
badgeLabel: runtimePath.label,
badgeTone: runtimePath.tone,
summary: runtimePath.summary,
dialogTitle: "运行路径明细",
body: rows
});
details.dataset.runtimePathKind = runtimePath.kind;
return details;
}
function messageCompactDetails({ className, tone, badgeLabel, badgeTone, summary, dialogTitle, body }) {
const section = document.createElement("section");
section.className = `${className} message-compact-details tone-border-${toneClass(tone)}`;
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "message-compact-summary";
trigger.setAttribute("aria-haspopup", "dialog");
trigger.append(
badge(badgeLabel, badgeTone),
textSpan(summary, "message-compact-summary-text")
);
trigger.addEventListener("click", () => {
openWorkbenchDialog({
title: dialogTitle,
body,
restoreFocus: trigger
});
});
section.append(trigger);
return section;
}
@@ -3987,14 +4086,76 @@ function renderTraceEventList(list, rows) {
function traceDisplayRows(trace, events) {
const rows = [];
let noisyRun = [];
const flushNoisyRun = () => {
if (noisyRun.length === 0) return;
const row = traceNoiseSummaryRow(trace, noisyRun);
if (row) rows.push(row);
noisyRun = [];
};
for (const event of events) {
if (isNoisyTraceEvent(event)) {
noisyRun.push(event);
continue;
}
flushNoisyRun();
const row = traceDisplayRow(trace, event);
if (!row) continue;
rows.push(row);
}
flushNoisyRun();
return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event, { includeNoise: true })).filter(Boolean);
}
function traceNoiseSummaryRow(trace, events) {
const visibleEvents = events.filter(Boolean);
if (visibleEvents.length === 0) return null;
const last = visibleEvents.at(-1);
const clock = traceClock(last.createdAt);
const total = formatTraceDuration(traceRelativeMs(trace, last));
const assistantChunks = visibleEvents.filter((event) => isAssistantChunkTraceEvent(event));
const label = assistantChunks.length === visibleEvents.length
? `assistant stream x${visibleEvents.length}`
: `trace noise x${visibleEvents.length}`;
return {
seq: last.seq ?? null,
tone: "source",
header: `${clock} total=${total} stream ${label}`,
body: traceNoiseSummaryBody(visibleEvents, assistantChunks)
};
}
function traceNoiseSummaryBody(events, assistantChunks) {
if (assistantChunks.length > 0) {
const text = assistantChunks
.map((event) => cleanTraceText(event.chunk))
.join("")
.trim();
const waitingFor = events.at(-1)?.waitingFor ? `waiting=${events.at(-1).waitingFor}` : null;
const recent = text ? compactTraceTextTail(text, 900) : null;
return [
`compressed=${assistantChunks.length} assistant chunks`,
waitingFor,
recent ? `recent=${recent}` : null
].filter(Boolean).join("\n");
}
const labels = new Map();
for (const event of events) {
const label = String(event?.label ?? `${event?.type ?? "event"}:${event?.status ?? "observed"}`);
labels.set(label, (labels.get(label) ?? 0) + 1);
}
return [...labels.entries()]
.slice(0, 6)
.map(([label, count]) => `${readableTraceLabel({ label })} x${count}`)
.join("\n");
}
function compactTraceTextTail(text, maxLength) {
const clean = cleanTraceText(text);
if (clean.length <= maxLength) return clean;
return `...${clean.slice(-maxLength)}`;
}
function traceDisplayRow(trace, event, options = {}) {
if (!options.includeNoise && isNoisyTraceEvent(event)) return null;
const clock = traceClock(event.createdAt);
@@ -4019,13 +4180,19 @@ function traceDisplayRow(trace, event, options = {}) {
function isNoisyTraceEvent(event) {
const label = String(event?.label ?? "");
if (/assistant:chunk|token_count|outputDelta:chunk/iu.test(label)) return true;
if (event?.type === "assistant_message" && event?.status === "chunk") return true;
if (isAssistantChunkTraceEvent(event)) return true;
if (/token_count|outputDelta:chunk/iu.test(label)) return true;
if (event?.type === "event" && !event.outputSummary && !event.message && !event.errorCode) return true;
if (event?.label === "request:accepted" && !event.message) return true;
return false;
}
function isAssistantChunkTraceEvent(event) {
const label = String(event?.label ?? "");
return /assistant:chunk/iu.test(label) ||
(event?.type === "assistant_message" && event?.status === "chunk");
}
function readableTraceLabel(event) {
const label = String(event?.label ?? `${event?.type ?? "event"}:${event?.status ?? "observed"}`);
if (label === "request:accepted-short-connection") return "submit short-connection";
@@ -4033,6 +4200,7 @@ function readableTraceLabel(event) {
if (label.startsWith("tool:codex-app-server.thread/start+turn/start")) return "codex turn start";
if (label.startsWith("tool:codex-app-server.thread/resume+turn/start")) return "codex turn resume";
if (label.startsWith("item/commandExecution/outputDelta")) return "cmd output";
if (label === "turn:waiting:first_assistant_token") return "waiting first assistant token";
if (label.startsWith("turn:completed")) return "turn completed";
if (label.startsWith("result:completed")) return "result ready";
if (label.startsWith("result:failed")) return "result failed";
+12 -18
View File
@@ -72,17 +72,11 @@
<strong id="live-status" class="tone-pending">等待验证</strong>
<small id="live-detail">正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/m3/io;未完成前不标为通过。</small>
<details class="live-build-summary" id="live-build-summary">
<summary id="live-build-toggle" aria-controls="live-build-popover" aria-expanded="false">
<summary id="live-build-toggle" aria-haspopup="dialog" aria-expanded="false">
<span class="live-build-summary-label" id="live-build-latest">最新镜像构建时间:等待实时元数据</span>
</summary>
<div class="live-build-popover" id="live-build-popover" role="dialog" aria-modal="false" aria-label="live HWLAB 微服务构建时间明细">
<div class="live-build-popover-head">
<strong>构建版本明细</strong>
<button class="live-build-close" id="live-build-close" type="button" aria-label="关闭构建版本明细">关闭</button>
</div>
<div class="live-build-list" id="live-build-list" tabindex="0" aria-label="live HWLAB 微服务构建时间明细"></div>
</div>
</details>
<div class="live-build-list live-build-list-template" id="live-build-list" hidden aria-label="live HWLAB 微服务构建时间明细"></div>
</div>
<button class="logout-button" id="logout-button" type="button">退出</button>
</header>
@@ -109,16 +103,6 @@
<div class="conversation-list" id="conversation-list" aria-live="polite"></div>
</div>
<div class="workspace-panel task-panel">
<div class="panel-title-row">
<div>
<p class="eyebrow">工作清单</p>
<h2>下一步任务草稿</h2>
</div>
<span class="state-tag tone-source">浏览器本地</span>
</div>
<div id="task-list" class="task-list"></div>
</div>
</div>
</section>
@@ -217,6 +201,16 @@
data-prompt-text="通过 HWLAB API / Skill CLI 把 res_boxsimu_1 的 DO1 写成 false,并复核 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。"
>HWLAB APIDO1=false 复核 DI1</button>
</div>
<label class="agent-timeout-control" for="code-agent-timeout">
<span>无新事件超时</span>
<select id="code-agent-timeout" aria-label="Code Agent 无新事件超时">
<option value="180000">3 分钟</option>
<option value="300000">5 分钟</option>
<option value="600000" selected>10 分钟</option>
<option value="900000">15 分钟</option>
<option value="1200000">20 分钟</option>
</select>
</label>
<div class="input-shell">
<span class="prompt-mark">hwlab</span>
<input
+57 -13
View File
@@ -66,6 +66,8 @@ const markedLicense = fs.readFileSync(path.resolve(rootDir, "third_party/marked/
const artifactPublisher = fs.readFileSync(path.resolve(repoRoot, "scripts/dev-artifact-publish.mjs"), "utf8");
const cloudWebRoutes = fs.readFileSync(path.resolve(repoRoot, "internal/dev-entrypoint/cloud-web-routes.mjs"), "utf8");
const cloudWebProxy = fs.readFileSync(path.resolve(repoRoot, "internal/dev-entrypoint/cloud-web-proxy.mjs"), "utf8");
const codexStdioSession = fs.readFileSync(path.resolve(repoRoot, "internal/cloud/codex-stdio-session.mjs"), "utf8");
const gatewayShellTool = fs.readFileSync(path.resolve(repoRoot, "tools/hwlab-gateway-shell.mjs"), "utf8");
const frontendSource = `${html}\n${auth}\n${app}\n${artifactPublisher}`;
const requiredTrustedRecordTerms = Object.freeze([
"Code Agent 对话记录",
@@ -400,8 +402,16 @@ assert.match(app, /renderRecords\(state\.liveSurface\)/);
assert.match(app, /traceId: error\.traceId \|\| traceId/);
assert.match(app, /error\.traceId = (?:traceId|response\.data\?\.traceId \|\| traceId)/);
assert.match(app, /DEFAULT_API_TIMEOUT_MS\s*=\s*4500/);
assert.match(app, /DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*180000/);
assert.match(app, /async function sendAgentMessage[\s\S]*?timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/);
assert.match(app, /DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*600000/);
assert.match(app, /DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS\s*=\s*60000/);
assert.match(app, /CODE_AGENT_TIMEOUT_STORAGE_KEY/);
assert.match(functionBody(app, "clampTimeoutMs"), /value === null \|\| value === undefined/);
assert.match(functionBody(app, "clampTimeoutMs"), /typeof value === "string" && value\.trim\(\) === ""/);
assert.match(functionBody(app, "sendAgentMessage"), /timeoutMs:\s*CODE_AGENT_SUBMIT_TIMEOUT_MS/);
assert.match(functionBody(app, "sendAgentMessage"), /shortConnection:\s*true/);
assert.match(functionBody(app, "waitForAgentMessageResult"), /idleMs\s*>=\s*CODE_AGENT_TIMEOUT_MS/);
assert.match(html, /id="code-agent-timeout"/);
assert.match(styles, /\.agent-timeout-control\s*{/);
assert.match(app, /activityRef:\s*\(\)\s*=>\s*state\.currentRequest/);
assert.match(functionBody(app, "fetchJson"), /timeoutMs\s*=\s*API_TIMEOUT_MS/);
assert.match(functionBody(app, "fetchJson"), /无新事件/);
@@ -443,7 +453,6 @@ for (const workbenchElement of [
"code-agent-summary-label",
"code-agent-summary-capability",
"code-agent-summary-trace",
"task-list",
"right-sidebar",
"right-sidebar-resize",
"command-form",
@@ -794,6 +803,12 @@ assert.match(app, /下载 trace/);
assert.match(app, /function renderTraceEventList/);
assert.match(app, /function traceDisplayRows/);
assert.match(app, /function traceDisplayRow/);
assert.match(app, /function traceNoiseSummaryRow/);
assert.match(app, /assistant stream x\$\{visibleEvents\.length\}/);
assert.match(app, /compressed=\$\{assistantChunks\.length\} assistant chunks/);
assert.match(app, /waiting first assistant token/);
assert.match(app, /function compactTraceTextTail/);
assert.match(app, /function isAssistantChunkTraceEvent/);
assert.match(app, /function downloadTraceJson/);
assert.match(styles, /\.message-trace-toolbar\s*\{/);
assert.match(styles, /\.message-trace-count\s*\{/);
@@ -898,6 +913,11 @@ assert.match(styles, /\.message-user\s*{/);
assert.match(styles, /\.message-agent,\s*\n\.message-system\s*{/);
assert.match(styles, /\.message-pending-context\s*{/);
assert.match(styles, /\.message-session-context\s*{/);
assert.match(styles, /\.message-compact-details\s*{/);
assert.match(styles, /\.workbench-dialog-layer\s*{/);
assert.match(styles, /\.workbench-dialog\s*{/);
assert.match(app, /function openWorkbenchDialog/);
assert.match(app, /document\.body\.append\(layer\)/);
assert.match(styles, /\.message-m3-evidence\s*{/);
assert.match(styles, /\.message-m3-rows\s*{/);
assert.match(styles, /\.message-m3-row\s*{/);
@@ -949,9 +969,11 @@ assert.match(app, /createdAt/);
assert.match(app, /updatedAt/);
assert.match(app, /error\?\.message/);
assert.match(app, /DEFAULT_API_TIMEOUT_MS\s*=\s*4500/);
assert.match(app, /DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*180000/);
assert.match(app, /DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*600000/);
assert.match(app, /CODE_AGENT_SUBMIT_TIMEOUT_MS/);
assert.match(app, /CODE_AGENT_TIMEOUT_MS/);
assert.match(app, /timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/);
assert.match(functionBody(app, "sendAgentMessage"), /timeoutMs:\s*CODE_AGENT_SUBMIT_TIMEOUT_MS/);
assert.match(functionBody(app, "waitForAgentMessageResult"), /CODE_AGENT_TIMEOUT_MS/);
assert.match(app, /HWLAB_CLOUD_WEB_CONFIG/);
assert.match(app, /sourceKind:\s*"PENDING"/);
assert.match(app, /旧 4500ms/);
@@ -980,7 +1002,10 @@ assert.match(styles, /html,\s*\nbody\s*{[^}]*height:\s*100%;[^}]*overflow:\s*hid
assert.match(styles, /body\s*>\s*\[data-app-shell\]\s*{[^}]*min-height:\s*0;/s);
assert.match(styles, /\.workbench-shell\s*{[^}]*height:\s*100(?:d)?vh;[^}]*overflow:\s*hidden;/s);
assert.match(styles, /\.view\s*{[^}]*min-height:\s*0;[^}]*overflow:\s*auto;/s);
assert.match(styles, /\.(?:compact-list|conversation-list|task-list|hardware-list)[^{]*{[^}]*min-height:\s*0;[^}]*overflow:\s*auto;/s);
assert.match(styles, /\.(?:compact-list|conversation-list|hardware-list)[^{]*{[^}]*min-height:\s*0;[^}]*overflow:\s*auto;/s);
assert.doesNotMatch(html, /task-panel|task-list|下一步任务草稿/);
assert.doesNotMatch(app, /taskList|renderTaskList/);
assert.doesNotMatch(styles, /\.task-panel|\.task-list/);
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.activity-rail\s*{[\s\S]*?grid-row:\s*1 \/ 3;/);
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.center-workspace\s*{[\s\S]*?grid-row:\s*1;/);
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.right-sidebar\s*{[\s\S]*?grid-row:\s*2;/);
@@ -990,12 +1015,10 @@ assert.match(app, /fetchJson\("\/v1\/diagnostics\/gate"/);
assert.match(app, /fetchJson\("\/v1\/live-builds"\)/);
assert.match(html, /id="live-build-latest"/);
assert.match(html, /id="live-build-list"/);
assert.match(html, /id="live-build-toggle"[^>]*aria-controls="live-build-popover"[^>]*aria-expanded="false"/);
assert.match(html, /id="live-build-popover"[^>]*role="dialog"[^>]*aria-modal="false"/);
assert.match(html, /id="live-build-close"/);
assert.match(html, /id="live-build-toggle"[^>]*aria-haspopup="dialog"[^>]*aria-expanded="false"/);
assert.match(styles, /\.live-build-summary\s*{/);
assert.match(app, /initLiveBuildOverlay/);
assert.match(app, /closeLiveBuildOverlay/);
assert.match(app, /openWorkbenchDialog/);
assert.match(app, /aria-expanded/);
assert.match(app, /formatBeijingTime/);
assert.match(app, /Asia\/Shanghai/);
@@ -1009,11 +1032,8 @@ assert.match(app, /外部镜像或非 HWLAB 构建产物/);
assert.doesNotMatch(app, /artifact report|artifact-report/);
assert.match(app, /live health/);
assert.match(styles, /\.live-build-summary-label\s*{[^}]*min-width:\s*0;[^}]*max-width:\s*100%;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;/s);
assert.match(styles, /\.live-build-popover\s*{[^}]*position:\s*absolute;[^}]*max-height:\s*min\(420px,\s*58dvh\);[^}]*overflow:\s*hidden;/s);
assert.match(styles, /\.live-build-summary\[open\]\s+\.live-build-popover\s*{[^}]*display:\s*grid;/s);
assert.match(styles, /\.live-build-list\s*{[^}]*min-width:\s*0;[^}]*max-height:\s*min\(352px,\s*calc\(58dvh - 54px\)\);[^}]*overflow:\s*auto;/s);
assert.match(styles, /\.live-build-row-head\s*{[^}]*grid-template-columns:\s*minmax\(0,\s*0\.8fr\)\s*minmax\(0,\s*1\.2fr\);/s);
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.live-build-popover\s*{[\s\S]*?width:\s*100%;/);
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.live-build-row-head\s*{[\s\S]*?grid-template-columns:\s*1fr;/);
assert.match(styles, /\.live-build-meta span\s*{[^}]*overflow-wrap:\s*anywhere;/s);
assert.match(app, /fetchJson\("\/health\/live"\)/);
@@ -1034,15 +1054,39 @@ assert.match(cloudWebProxy, /text\\\/event-stream/);
assert.match(cloudWebProxy, /x-accel-buffering/);
assert.match(cloudWebProxy, /flushHeaders/);
assert.match(cloudWebProxy, /response\.write\(chunk\)/);
assert.match(cloudWebProxy, /response\.headersSent\)[\s\S]*?response\.destroy\(error\);[\s\S]*?settle\(resolve\);[\s\S]*?return;/);
assert.match(artifactPublisher, /response\.headersSent \|\| response\.writableEnded/);
assert.match(artifactPublisher, /if \(!response\.writableEnded\) response\.destroy\(error\);/);
assert.doesNotMatch(artifactPublisher, /await fetch\(target/);
assert.match(artifactPublisher, /cloudWebProxyRoutePolicy\(request\.method,\s*url\.pathname\)/);
assert.match(cloudWebRoutes, /"\/v1\/agent\/chat"/);
assert.match(cloudWebRoutes, /"\/v1\/agent\/chat\/cancel"/);
assert.match(cloudWebRoutes, /"\/v1\/m3\/io"/);
assert.match(cloudWebRoutes, /"POST \/v1\/agent\/chat"/);
assert.match(cloudWebRoutes, /isPublicCodeAgentPollRoute/);
assert.match(cloudWebRoutes, /\/v1\/agent\/chat\/result\//);
assert.match(cloudWebRoutes, /\/v1\/agent\/chat\/trace\//);
assert.match(cloudWebRoutes, /const GET_PROXY_PREFIXES = Object\.freeze\(\["\/v1\/"\]\)/);
assert.match(cloudWebRoutes, /const GET_PROXY_ROUTES = new Set\(\["\/v1"\]\)/);
assert.match(cloudWebRoutes, /authRequired: proxy && !publicRoute/);
assert.match(codexStdioSession, /turn:completed:transport_closed_after_assistant/);
assert.match(codexStdioSession, /turn:completed:idle_timeout_after_assistant/);
assert.match(codexStdioSession, /firstNonEmpty\(finalResponse,\s*assistantText\)/);
assert.match(codexStdioSession, /CODEX_STDIO_FIRST_TOKEN_PROGRESS_MS/);
assert.match(codexStdioSession, /turn:waiting:first_assistant_token/);
assert.match(codexStdioSession, /progressOnly:\s*true/);
assert.match(codexStdioSession, /\["assistant-message", "turn\/completed"\]\.includes\(activity\.waitingFor\)/);
assert.match(codexStdioSession, /does not reset the backend idle timer/);
assert.match(codexStdioSession, /--powershell-stdin/);
assert.match(codexStdioSession, /-EncodedCommand/);
assert.match(codexStdioSession, /Windows filesystem inventory/);
assert.match(codexStdioSession, /Select-Object -First/);
assert.match(codexStdioSession, /ConvertTo-Json -Compress/);
assert.match(codexStdioSession, /C:\\\\Users\\\\liang\\\\\.agents\\\\skills\\\\keil/);
assert.match(gatewayShellTool, /function powershellEncodedCommand/);
assert.match(gatewayShellTool, /Buffer\.from\(`\$\{prologue\}\$\{source\}`,\s*"utf16le"\)\.toString\("base64"\)/);
assert.match(gatewayShellTool, /--powershell-stdin/);
assert.match(gatewayShellTool, /powershell\.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand/);
assert.match(artifactPublisher, /"system\.health"/);
assert.match(artifactPublisher, /"cloud\.adapter\.describe"/);
assert.match(artifactPublisher, /"audit\.event\.query"/);
+171 -78
View File
@@ -461,7 +461,6 @@ h3 {
.compact-list,
.conversation-list,
.task-list,
.hardware-list {
min-height: 0;
overflow: auto;
@@ -564,66 +563,6 @@ h3 {
overflow-wrap: anywhere;
}
.live-build-popover {
position: absolute;
z-index: 30;
top: calc(100% + 8px);
right: 0;
width: min(100%, 560px);
max-width: 100%;
max-height: min(420px, 58dvh);
display: none;
grid-template-rows: auto minmax(0, 1fr);
gap: 8px;
padding: 10px;
background: rgba(20, 22, 20, 0.99);
border: 1px solid var(--line-strong);
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.42);
overflow: hidden;
}
.live-build-summary[open] .live-build-popover {
display: grid;
}
.live-build-popover-head {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.live-build-popover-head strong {
min-width: 0;
color: var(--text);
font-family: var(--mono);
font-size: 11px;
line-height: 1.2;
text-transform: none;
overflow-wrap: anywhere;
}
.live-build-close {
flex: 0 0 auto;
min-height: 26px;
padding: 3px 8px;
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--muted);
font-family: var(--mono);
font-size: 10px;
font-weight: 760;
cursor: pointer;
}
.live-build-close:hover,
.live-build-close:focus-visible {
border-color: var(--line-strong);
color: var(--text);
outline: 0;
}
.live-build-list {
min-width: 0;
max-height: min(352px, calc(58dvh - 54px));
@@ -633,6 +572,10 @@ h3 {
overscroll-behavior: contain;
}
.live-build-list-template {
display: none;
}
.live-build-row {
min-width: 0;
max-width: 100%;
@@ -692,6 +635,89 @@ h3 {
overflow-wrap: anywhere;
}
.workbench-dialog-layer {
position: fixed;
z-index: 1000;
inset: 0;
display: grid;
place-items: center;
padding: 18px;
}
.workbench-dialog-backdrop {
position: absolute;
inset: 0;
border: 0;
background: rgba(0, 0, 0, 0.58);
cursor: pointer;
}
.workbench-dialog {
position: relative;
z-index: 1;
width: min(820px, calc(100vw - 36px));
max-height: min(720px, calc(100dvh - 36px));
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 10px;
padding: 12px;
background: rgba(20, 22, 20, 0.99);
border: 1px solid var(--line-strong);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.54);
overflow: hidden;
}
.workbench-dialog-head {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.workbench-dialog-head strong {
min-width: 0;
color: var(--text);
font-family: var(--mono);
font-size: 11px;
line-height: 1.2;
overflow-wrap: anywhere;
}
.workbench-dialog-close {
flex: 0 0 auto;
min-height: 28px;
padding: 3px 9px;
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--muted);
font-family: var(--mono);
font-size: 10px;
font-weight: 760;
cursor: pointer;
}
.workbench-dialog-close:hover,
.workbench-dialog-close:focus-visible {
border-color: var(--line-strong);
color: var(--text);
outline: 0;
}
.workbench-dialog-content {
min-width: 0;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
}
.workbench-dialog-content .live-build-list,
.workbench-dialog-content .message-runtime-grid,
.workbench-dialog-content .message-session-grid {
max-height: none;
overflow: visible;
}
.view {
min-height: 0;
overflow: auto;
@@ -714,7 +740,7 @@ h3 {
.conversation-column {
min-height: 100%;
display: grid;
grid-template-rows: minmax(280px, 1fr) minmax(170px, 0.45fr);
grid-template-rows: minmax(0, 1fr);
gap: 10px;
}
@@ -730,10 +756,6 @@ h3 {
grid-template-rows: auto auto minmax(0, 1fr);
}
.task-panel {
grid-template-rows: auto minmax(0, 1fr);
}
.help-panel {
height: 100%;
grid-template-rows: auto minmax(0, 1fr);
@@ -741,8 +763,7 @@ h3 {
}
.conversation-list,
.compact-list,
.task-list {
.compact-list {
display: grid;
align-content: start;
gap: 8px;
@@ -993,6 +1014,44 @@ h3 {
border-left-width: 3px;
}
.message-compact-details {
display: block;
padding: 0;
}
.message-compact-summary {
width: 100%;
min-width: 0;
min-height: 30px;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 7px;
align-items: center;
padding: 6px 8px;
border: 0;
background: transparent;
color: var(--muted);
text-align: left;
cursor: pointer;
}
.message-compact-summary-text {
min-width: 0;
color: var(--muted);
font-size: 10px;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.message-compact-summary:hover,
.message-compact-summary:focus-visible {
background: rgba(255, 255, 255, 0.03);
color: var(--text);
outline: 0;
}
.message-trace {
background: rgba(20, 24, 23, 0.76);
border-left-width: 3px;
@@ -1514,6 +1573,34 @@ h3 {
outline: 0;
}
.agent-timeout-control {
grid-column: 1 / -1;
justify-self: start;
display: inline-grid;
grid-template-columns: auto auto;
align-items: center;
gap: 8px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid rgba(94, 142, 231, 0.38);
background: rgba(25, 31, 39, 0.78);
color: var(--muted);
font-family: var(--mono);
font-size: 10px;
font-weight: 760;
line-height: 1.2;
}
.agent-timeout-control select {
min-width: 88px;
min-height: 24px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
font: inherit;
cursor: pointer;
}
.input-shell {
min-width: 0;
display: grid;
@@ -2399,7 +2486,7 @@ tbody tr:last-child td {
}
.conversation-column {
grid-template-rows: minmax(210px, 1fr) minmax(150px, 0.45fr);
grid-template-rows: minmax(0, 1fr);
gap: 8px;
}
@@ -2418,6 +2505,11 @@ tbody tr:last-child td {
min-height: 30px;
}
.agent-timeout-control {
width: 100%;
justify-content: start;
}
.input-shell {
padding: 0 8px;
}
@@ -2441,16 +2533,8 @@ tbody tr:last-child td {
gap: 2px;
}
.live-build-popover {
right: 0;
left: auto;
width: 100%;
max-width: 100%;
max-height: min(420px, 56dvh);
}
.live-build-list {
max-height: min(352px, calc(56dvh - 54px));
max-height: min(352px, calc(70dvh - 54px));
}
.live-build-time {
@@ -2552,6 +2636,15 @@ tbody tr:last-child td {
flex-basis: 100%;
}
.agent-timeout-control {
grid-template-columns: minmax(0, 1fr);
justify-items: start;
}
.agent-timeout-control select {
width: 100%;
}
.prompt-mark {
display: none;
}