From b3d298c3e5b1670bfa2097cad9c113eedef9b334 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 25 May 2026 01:14:17 +0000 Subject: [PATCH] fix code agent gateway trace timeouts --- cmd/hwlab-gateway/main.mjs | 4 +- docs/reference/code-agent-chat-readiness.md | 5 + docs/reference/dev-runtime-hotfix-runbook.md | 1 + docs/reference/gateway-outbound-demo.md | 2 +- internal/cloud/codex-stdio-session.mjs | 26 ++- internal/cloud/gateway-demo-registry.mjs | 2 +- internal/cloud/json-rpc.mjs | 17 +- internal/cloud/json-rpc.test.mjs | 47 +++++ internal/cloud/server.mjs | 2 +- tools/hwlab-gateway-shell.mjs | 37 +++- web/hwlab-cloud-web/app.mjs | 178 ++++++++++++++++++- web/hwlab-cloud-web/index.html | 10 ++ web/hwlab-cloud-web/scripts/check.mjs | 19 ++ web/hwlab-cloud-web/styles.css | 4 + 14 files changed, 329 insertions(+), 25 deletions(-) diff --git a/cmd/hwlab-gateway/main.mjs b/cmd/hwlab-gateway/main.mjs index c9bebb8d..88a39ade 100644 --- a/cmd/hwlab-gateway/main.mjs +++ b/cmd/hwlab-gateway/main.mjs @@ -12,7 +12,7 @@ const gatewaySessionId = process.env.HWLAB_GATEWAY_SESSION_ID ?? `gws_${gatewayI const port = parsePort(process.env.PORT, 7001); const cloudUrl = trimUrl(process.env.HWLAB_GATEWAY_CLOUD_URL); const pollIntervalMs = parsePositiveInteger(process.env.HWLAB_GATEWAY_POLL_INTERVAL_MS, 500); -const commandTimeoutMs = parsePositiveInteger(process.env.HWLAB_GATEWAY_CMD_TIMEOUT_MS, 10000); +const commandTimeoutMs = parsePositiveInteger(process.env.HWLAB_GATEWAY_CMD_TIMEOUT_MS, 120000); const outputLimitBytes = parsePositiveInteger(process.env.HWLAB_GATEWAY_CMD_OUTPUT_LIMIT_BYTES, 65536); const resourceId = process.env.HWLAB_GATEWAY_RESOURCE_ID ?? "res_windows_host"; const boxId = process.env.HWLAB_GATEWAY_BOX_ID ?? "box_windows_host"; @@ -250,6 +250,8 @@ function executeCommand(input, { timeoutMs, outputLimitBytes: limitBytes }) { windowsHide: true, env: { ...process.env, + PYTHONUTF8: process.env.PYTHONUTF8 ?? "1", + PYTHONIOENCODING: process.env.PYTHONIOENCODING ?? "utf-8", NO_PROXY: process.env.NO_PROXY, no_proxy: process.env.no_proxy }, diff --git a/docs/reference/code-agent-chat-readiness.md b/docs/reference/code-agent-chat-readiness.md index 66a6c392..94ea9ea0 100644 --- a/docs/reference/code-agent-chat-readiness.md +++ b/docs/reference/code-agent-chat-readiness.md @@ -98,6 +98,10 @@ PS1 PowerShell 默认使用 wrapper 的 `-EncodedCommand` 路径;不要手写 `cmd /c powershell ...` 的管道、引号或中文转义。简单 `cmd` 命令仍可用 `--command "cmd /d /s /c ..."`,但涉及目录枚举、Unicode、管道、排序或 JSON 输出时优先用 `--powershell-stdin`。 +Workbench 会把“Gateway 命令超时”控件的毫秒值随 `/v1/agent/chat` 传入 `gatewayShellTimeoutMs`。Codex prompt 必须把该值落实到 wrapper 的 `--timeout-ms`,cloud-api `hardware.invoke.shell` dispatch timeout 必须取环境配置、请求 `input.timeoutMs` 和 120s 默认值中的较大值并加 grace;不得让 20s/30s 的旧默认提前返回 `dispatchStatus=timed_out`。wrapper 自身 HTTP request timeout 要比 shell timeout 稍长,确保用户看到的是 gateway/cloud 返回的结构化 `status/operationId/dispatch`,不是 wrapper 先超时丢失结果。 + +Workbench trace 对已知 JSON-RPC gateway 响应应按普通 tool call 展示:首行展示 `tool hardware.invoke.shell status= op= exit= s=`,正文展示 request、gateway/resource/capability、dispatch、command、audit/evidence 以及有界 stdout/stderr。不要把整段 JSON 原样刷屏;复制/下载完整 trace 仍保留原始 JSON。 + Windows 侧 skill、编译器、脚本工具和多参数命令都应走同一个通用传输模式,不新增某个工具的专用 wrapper 子命令: - 工作目录优先用 wrapper 的 `--cwd ` 或脚本内 `Set-Location -LiteralPath ` 表达,不要在 prompt 里拼 `cmd /c "cd ... && ..."`。 @@ -109,6 +113,7 @@ Windows 文件系统探测必须是有界小输出: - 对 `F:\work`、`F:\work\ConStart` 或同类目录先做顶层目录/项目标记探测,不要读取 Secret、env、kubeconfig、DB URL 或完整源码内容。 - 使用 `-LiteralPath`、`Select-Object -First `、`ConvertTo-Json -Compress -Depth `,stdout 目标控制在约 12 KB 内。 +- wrapper 的 PowerShell prologue 已设置 UTF-8 console/output,并提供 `Read-HwlabText`、`Select-HwlabText`、`ConvertTo-HwlabJson`。读取中文 `SKILL.md`、Keil 日志或 manifest 时优先用这些 helper,避免 `Get-Content`/`Select-String` 的扩展对象字段和系统代码页造成乱码。 - 不要先输出完整目录 JSON 再依赖终端截断;需要更多信息时按明确候选项目二次查询。 - 如果命令已经到达 gateway 但因脚本语法或输出大小失败,只允许简化修正一次;最终回复要记录失败 `operationId`、修正后的成功 `operationId` 和有界输出摘要。 diff --git a/docs/reference/dev-runtime-hotfix-runbook.md b/docs/reference/dev-runtime-hotfix-runbook.md index c621e581..202aa6e7 100644 --- a/docs/reference/dev-runtime-hotfix-runbook.md +++ b/docs/reference/dev-runtime-hotfix-runbook.md @@ -55,6 +55,7 @@ NODE - 不要用 `kubectl patch --patch-file -` 假设 stdin 可用;目标 `kubectl` 版本可能把 `-` 当普通文件名。用 `/tmp/*.patch.json` 再 `--patch-file`。 - 不要把大 JSON patch 放进 `kubectl patch -p "$patch"` 或 shell argv;ConfigMap 覆盖源码文件时很容易触发 `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`。 +- 多文件运行面覆盖第一次创建 ConfigMap 时也不要默认用 `kubectl apply -f -`;如果 data 可能超过 annotation 限制,先 `kubectl delete configmap --ignore-not-found`,再 `kubectl create configmap --from-file=...`,随后用 Deployment annotation 记录 hotfix 版本并 rollout。正式 CD 收口时必须删除这些 unmanaged ConfigMap/volumeMount,不让热修覆盖继续漂在运行面。 - 不要通过会截断 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。 diff --git a/docs/reference/gateway-outbound-demo.md b/docs/reference/gateway-outbound-demo.md index 311f05c3..1bbb2acc 100644 --- a/docs/reference/gateway-outbound-demo.md +++ b/docs/reference/gateway-outbound-demo.md @@ -26,7 +26,7 @@ | `HWLAB_GATEWAY_CMD_EXEC_ENABLED=1` | 允许执行 shell 命令;未设置时 gateway 拒绝 `hardware.invoke.shell`。 | | `HWLAB_GATEWAY_DEMO_OPEN=1` | demo 明确打开标记;本地 smoke 会设置。 | | `HWLAB_GATEWAY_POLL_INTERVAL_MS` | poll 间隔,默认 500ms。 | -| `HWLAB_GATEWAY_CMD_TIMEOUT_MS` | 单条命令超时,默认 10000ms。 | +| `HWLAB_GATEWAY_CMD_TIMEOUT_MS` | 单条命令超时,默认 120000ms;Workbench 发起 Code Agent 请求时可通过“Gateway 命令超时”控件把本轮 wrapper `--timeout-ms` 调到 1/2/3/5/10 分钟。 | | `HWLAB_GATEWAY_CMD_OUTPUT_LIMIT_BYTES` | stdout/stderr 单路输出上限,默认 65536 bytes。 | 本地或内网存在代理时,必须显式设置: diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index db06fd5a..744de1d0 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -95,9 +95,10 @@ const CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [ "Do not read or print secrets, tokens, kubeconfig files, DB URLs, private keys, or raw environment values.", "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 command or skill requests, invoke the repo-owned wrapper with the Codex exec tool: node /app/tools/hwlab-gateway-shell.mjs --json --timeout-ms --powershell-stdin. Put scripts on stdin and set working directory with wrapper --cwd or PowerShell Set-Location -LiteralPath; do not hand-build compound cmd /c, cd &&, pipes, or nested quotes.", - "For PowerShell through the PC gateway, do not hand-escape cmd pipes or quotes; use a single-quoted heredoc with --powershell-stdin. 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 registered PC gateway Windows command or skill requests, invoke the repo-owned wrapper with the Codex exec tool: node /app/tools/hwlab-gateway-shell.mjs --json --timeout-ms --powershell-stdin. Put scripts on stdin and set working directory with wrapper --cwd or PowerShell Set-Location -LiteralPath; do not hand-build compound cmd /c, cd &&, pipes, or nested quotes.", + "For PowerShell through the PC gateway, do not hand-escape cmd pipes or quotes; use a single-quoted heredoc with --powershell-stdin. The wrapper sends powershell.exe -EncodedCommand and injects UTF-8 helper functions for Unicode-safe execution.", + "For Windows text files and skill manifests, avoid raw Get-Content or Select-String objects in JSON. Use Read-HwlabText, Select-HwlabText, and ConvertTo-HwlabJson from the wrapper prologue, or explicitly project plain scalar fields before ConvertTo-Json.", + "For Windows filesystem inventory, start with one small bounded PowerShell stdin script: use -LiteralPath, Select-Object -First, ConvertTo-HwlabJson, and keep stdout under about 12 KB. Do not dump full directory JSON and then retry.", "For Windows-side skills under C:\\Users\\liang\\.agents\\skills\\, read the skill manifest when needed and call its CLI from a PowerShell stdin script using explicit paths or argument arrays. Do not reimplement skill logic, and do not use shell working-directory tricks like cd &&.", "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 from the generic PowerShell stdin wrapper path; 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.", @@ -493,7 +494,13 @@ export function createCodexStdioSessionManager(options = {}) { runnerLimitations: ["skills-manifest-unavailable", "no-text-fallback-for-skills-discovery"] }); } - const prompt = buildCodexUserPrompt(params.message, { conversationId, traceId, conversationFacts: params.conversationFacts, sidecar }); + const prompt = buildCodexUserPrompt(params.message, { + conversationId, + traceId, + conversationFacts: params.conversationFacts, + sidecar, + gatewayShellTimeoutMs: normalizeGatewayShellTimeoutMs(params.gatewayShellTimeoutMs, env) + }); const toolName = session.threadId ? "codex-app-server.thread/resume+turn/start" : "codex-app-server.thread/start+turn/start"; traceRecorder.append({ type: "prompt", @@ -3490,12 +3497,13 @@ function safeDisplayPath(value) { return redactText(String(value ?? ".")).replace(/\s+/gu, " "); } -function buildCodexUserPrompt(message, { conversationId, traceId, conversationFacts, sidecar }) { +function buildCodexUserPrompt(message, { conversationId, traceId, conversationFacts, sidecar, gatewayShellTimeoutMs }) { return [ `conversationId: ${conversationId}`, `traceId: ${traceId}`, + `gatewayShellTimeoutMs: ${gatewayShellTimeoutMs}`, "", - CODEX_STDIO_BOUNDARY_INSTRUCTIONS, + CODEX_STDIO_BOUNDARY_INSTRUCTIONS.replace(//gu, String(gatewayShellTimeoutMs)), codexConversationFactsPrompt(conversationFacts), codexSidecarSkillsPrompt(sidecar), "", @@ -3504,6 +3512,12 @@ function buildCodexUserPrompt(message, { conversationId, traceId, conversationFa ].filter((line) => line !== null && line !== undefined).join("\n"); } +function normalizeGatewayShellTimeoutMs(value, env = process.env) { + const parsed = Number.parseInt(value ?? env.HWLAB_GATEWAY_SHELL_TIMEOUT_MS ?? "", 10); + if (!Number.isInteger(parsed) || parsed <= 0) return 120000; + return Math.min(Math.max(parsed, 30000), 600000); +} + function codexSidecarSkillsPrompt(sidecar) { if (!sidecar?.intent?.skills) return null; const skills = sidecar.skills; diff --git a/internal/cloud/gateway-demo-registry.mjs b/internal/cloud/gateway-demo-registry.mjs index 68030aa8..a10a6b55 100644 --- a/internal/cloud/gateway-demo-registry.mjs +++ b/internal/cloud/gateway-demo-registry.mjs @@ -4,7 +4,7 @@ import { ENVIRONMENT_DEV, JSON_RPC_VERSION } from "../protocol/index.mjs"; import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs"; const DEFAULT_STALE_MS = 30000; -const DEFAULT_DISPATCH_TIMEOUT_MS = 30000; +const DEFAULT_DISPATCH_TIMEOUT_MS = 120000; export function createGatewayDemoRegistry({ now = () => new Date().toISOString(), diff --git a/internal/cloud/json-rpc.mjs b/internal/cloud/json-rpc.mjs index 50603d51..06814194 100644 --- a/internal/cloud/json-rpc.mjs +++ b/internal/cloud/json-rpc.mjs @@ -286,7 +286,7 @@ async function handleHardwareInvokeShell(params, envelope, context) { params, meta: envelope.meta }), - timeoutMs: parseDispatchTimeout(context.env ?? process.env) + timeoutMs: parseDispatchTimeout(context.env ?? process.env, params) }); if (!dispatch.ok) { @@ -332,9 +332,18 @@ function statusFromShellResult(result = {}) { return "completed"; } -function parseDispatchTimeout(env) { - const parsed = Number.parseInt(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS ?? "", 10); - return Number.isInteger(parsed) && parsed > 0 ? parsed : 30000; +function parseDispatchTimeout(env, params = {}) { + const configured = parsePositiveInteger(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS, null); + const requested = parsePositiveInteger(params?.input?.timeoutMs ?? params?.timeoutMs, null); + const base = Math.max(configured ?? 0, requested ?? 0, 120000); + const graceMs = parsePositiveInteger(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_GRACE_MS, 10000); + const bounded = Math.min(Math.max(base + graceMs, 1000), 610000); + return bounded; +} + +function parsePositiveInteger(value, fallback) { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } async function runtimeReadiness(runtimeStore) { diff --git a/internal/cloud/json-rpc.test.mjs b/internal/cloud/json-rpc.test.mjs index a299cec0..b2a40bdc 100644 --- a/internal/cloud/json-rpc.test.mjs +++ b/internal/cloud/json-rpc.test.mjs @@ -126,6 +126,53 @@ test("JSON-RPC invalid meta reports unknown serviceId reason without leaking sec assert.equal(JSON.stringify(response).includes("sk-"), false); }); +test("hardware.invoke.shell dispatch timeout honors requested gateway shell timeout with grace", async () => { + let observedTimeoutMs = null; + const response = await handleJsonRpcRequest({ + jsonrpc: "2.0", + id: "req_01J00000000000000000000004", + method: "hardware.invoke.shell", + params: { + projectId: "prj_mvp_topology", + gatewaySessionId: "gws_DESKTOP-1MHOD9I", + resourceId: "res_windows_host", + capabilityId: "cap_windows_cmd_exec", + input: { + command: "powershell -NoProfile -Command Start-Sleep -Seconds 45", + timeoutMs: 180000 + } + }, + meta: { + traceId: "trc_01J00000000000000000000004", + actorId: "usr_01J00000000000000000000004", + serviceId: "hwlab-cloud-web", + environment: "dev" + } + }, { + env: { + HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS: "30000", + HWLAB_GATEWAY_DISPATCH_TIMEOUT_GRACE_MS: "5000" + }, + gatewayRegistry: { + isOnline: (gatewaySessionId) => gatewaySessionId === "gws_DESKTOP-1MHOD9I", + enqueue: async ({ timeoutMs }) => { + observedTimeoutMs = timeoutMs; + return { + ok: false, + status: "timed_out", + error: "forced timeout" + }; + } + } + }); + + validateResponse(response); + assert.equal(observedTimeoutMs, 185000); + assert.equal(response.result.status, "timed_out"); + assert.equal(response.result.dispatch.dispatchStatus, "timed_out"); + assert.equal(response.result.dispatch.shellExecuted, false); +}); + test("cloud-api runtime accepts register/report/invoke and exposes audit/evidence records", async () => { const runtimeStore = createCloudRuntimeStore({ now: () => "2026-05-21T00:00:00.000Z" diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index f0b79e13..21f71007 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -92,7 +92,7 @@ export function createCloudApiServer(options = {}) { const runtimeStore = options.runtimeStore || createConfiguredCloudRuntimeStore({ ...options, env }); const gatewayRegistry = options.gatewayRegistry || createGatewayDemoRegistry({ staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000), - dispatchTimeoutMs: parsePositiveInteger(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS, 30000) + dispatchTimeoutMs: parsePositiveInteger(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS, 120000) }); return createServer(async (request, response) => { try { diff --git a/tools/hwlab-gateway-shell.mjs b/tools/hwlab-gateway-shell.mjs index 1d390335..f1e6b07f 100644 --- a/tools/hwlab-gateway-shell.mjs +++ b/tools/hwlab-gateway-shell.mjs @@ -9,7 +9,21 @@ 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;"; +const DEFAULT_GATEWAY_SHELL_TIMEOUT_MS = 120000; +const MAX_GATEWAY_SHELL_TIMEOUT_MS = 600000; +const REQUEST_TIMEOUT_GRACE_MS = 10000; +const POWERSHELL_PROLOGUE = [ + "$ErrorActionPreference = 'Stop';", + "$utf8NoBom = [System.Text.UTF8Encoding]::new($false);", + "[Console]::InputEncoding = $utf8NoBom;", + "[Console]::OutputEncoding = $utf8NoBom;", + "$OutputEncoding = $utf8NoBom;", + "$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8';", + "$PSDefaultParameterValues['Set-Content:Encoding'] = 'utf8';", + "function Read-HwlabText { param([Parameter(Mandatory=$true)][string]$LiteralPath) [System.IO.File]::ReadAllText($LiteralPath, $utf8NoBom) }", + "function Select-HwlabText { param([Parameter(Mandatory=$true)][string]$LiteralPath,[Parameter(Mandatory=$true)][string]$Pattern,[int]$First=20) Select-String -LiteralPath $LiteralPath -Pattern $Pattern -Encoding UTF8 | Select-Object -First $First @{Name='path';Expression={$_.Path}}, @{Name='lineNumber';Expression={$_.LineNumber}}, @{Name='line';Expression={$_.Line}} }", + "function ConvertTo-HwlabJson { param([Parameter(ValueFromPipeline=$true)][object]$InputObject,[int]$Depth=8) begin { $items = @() } process { $items += $InputObject } end { if ($items.Count -eq 1) { $items[0] | ConvertTo-Json -Compress -Depth $Depth } else { $items | ConvertTo-Json -Compress -Depth $Depth } } }" +].join("\n"); async function main() { const args = parseArgs(process.argv.slice(2)); @@ -22,10 +36,17 @@ async function main() { const apiBaseUrl = stripTrailingSlash(args.apiBaseUrl || process.env.HWLAB_GATEWAY_SHELL_API_BASE_URL || DEFAULT_API_BASE_URL); const traceId = args.traceId || `trc_gateway_shell_cli_${Date.now()}`; const requestId = args.requestId || `req_gateway_shell_cli_${randomUUID()}`; - const timeoutMs = positiveInteger(args.timeoutMs, 30000); + const timeoutMs = boundedPositiveInteger(args.timeoutMs ?? process.env.HWLAB_GATEWAY_SHELL_TIMEOUT_MS, DEFAULT_GATEWAY_SHELL_TIMEOUT_MS, { + min: 1000, + max: MAX_GATEWAY_SHELL_TIMEOUT_MS + }); + const requestTimeoutMs = boundedPositiveInteger(args.requestTimeoutMs, timeoutMs + REQUEST_TIMEOUT_GRACE_MS, { + min: timeoutMs, + max: timeoutMs + Math.max(REQUEST_TIMEOUT_GRACE_MS, Math.floor(timeoutMs / 2)) + }); const response = await requestJson(`${apiBaseUrl}/v1/rpc/hardware.invoke.shell`, { method: "POST", - timeoutMs, + timeoutMs: requestTimeoutMs, headers: { "content-type": "application/json", "x-trace-id": traceId, @@ -100,7 +121,8 @@ function printHelp() { " --powershell-stdin Read PS1 from stdin, encode it, and run through the gateway", " --powershell-exe NAME Default powershell.exe", " --cwd WINDOWS_PATH Gateway-side working directory; prefer this over shell-level cd &&", - " --timeout-ms N Short dispatch timeout in milliseconds", + " --timeout-ms N Gateway command timeout, default 120000ms; can also use HWLAB_GATEWAY_SHELL_TIMEOUT_MS", + " --request-timeout-ms N HTTP wait timeout; defaults to command timeout plus a small response grace", " --project-id ID Default prj_mvp_topology", " --gateway-session-id ID Default gws_DESKTOP-1MHOD9I", " --resource-id ID Default res_windows_host", @@ -205,6 +227,13 @@ function positiveInteger(value, fallback) { return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } +function boundedPositiveInteger(value, fallback, { min, max } = {}) { + const parsed = positiveInteger(value, fallback); + const lower = Number.isInteger(min) ? min : 1; + const upper = Number.isInteger(max) ? max : parsed; + return Math.min(Math.max(parsed, lower), upper); +} + function stripTrailingSlash(value) { return String(value).replace(/\/+$/u, ""); } diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index d5000d79..6006ce26 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -21,11 +21,14 @@ const DEFAULT_API_TIMEOUT_MS = 4500; const DEFAULT_CODE_AGENT_TIMEOUT_MS = 600000; const DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS = 60000; const DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS = 30000; +const DEFAULT_GATEWAY_SHELL_TIMEOUT_MS = 120000; const CODE_AGENT_TIMEOUT_STORAGE_KEY = "hwlab.workbench.codeAgentTimeoutMs.v1"; +const GATEWAY_SHELL_TIMEOUT_STORAGE_KEY = "hwlab.workbench.gatewayShellTimeoutMs.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 }); let CODE_AGENT_TIMEOUT_MS = resolveUserCodeAgentTimeoutMs(); +let GATEWAY_SHELL_TIMEOUT_MS = resolveUserGatewayShellTimeoutMs(); const CODE_AGENT_SUBMIT_TIMEOUT_MS = resolveTimeoutMs("codeAgentSubmitTimeoutMs", DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS, { min: 5000, max: 120000 }); const CODE_AGENT_CANCEL_TIMEOUT_MS = resolveTimeoutMs("codeAgentCancelTimeoutMs", DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS, { min: 5000, max: 120000 }); const rpcReadMethods = Object.freeze([ @@ -135,6 +138,7 @@ const el = { commandForm: byId("command-form"), commandInput: byId("command-input"), codeAgentTimeout: byId("code-agent-timeout"), + gatewayShellTimeout: byId("gateway-shell-timeout"), commandSend: byId("command-send"), commandClear: byId("command-clear"), hardwareSourceStatus: byId("hardware-source-status"), @@ -266,6 +270,17 @@ function resolveUserCodeAgentTimeoutMs() { ?? resolveTimeoutMs("codeAgentTimeoutMs", DEFAULT_CODE_AGENT_TIMEOUT_MS, { min: 30000, max: 1200000 }); } +function resolveUserGatewayShellTimeoutMs() { + let stored = null; + try { + stored = window.localStorage?.getItem(GATEWAY_SHELL_TIMEOUT_STORAGE_KEY) ?? null; + } catch { + stored = null; + } + return clampTimeoutMs(stored, { min: 30000, max: 600000 }) + ?? resolveTimeoutMs("gatewayShellTimeoutMs", DEFAULT_GATEWAY_SHELL_TIMEOUT_MS, { min: 30000, max: 600000 }); +} + function initCodeAgentTimeoutControl() { syncCodeAgentTimeoutControl(); el.codeAgentTimeout.addEventListener("change", () => { @@ -280,11 +295,24 @@ function initCodeAgentTimeoutControl() { renderCodeAgentSummary(); renderConversation(); }); + el.gatewayShellTimeout.addEventListener("change", () => { + const next = clampTimeoutMs(el.gatewayShellTimeout.value, { min: 30000, max: 600000 }) ?? DEFAULT_GATEWAY_SHELL_TIMEOUT_MS; + GATEWAY_SHELL_TIMEOUT_MS = next; + try { + window.localStorage?.setItem(GATEWAY_SHELL_TIMEOUT_STORAGE_KEY, String(next)); + } catch { + // localStorage can be unavailable in hardened browser modes. + } + syncCodeAgentTimeoutControl(); + renderCodeAgentSummary(); + }); } function syncCodeAgentTimeoutControl() { el.codeAgentTimeout.value = String(CODE_AGENT_TIMEOUT_MS); el.codeAgentTimeout.title = `Code Agent 无新事件超过 ${formatTraceDuration(CODE_AGENT_TIMEOUT_MS)} 后才显示为超时`; + el.gatewayShellTimeout.value = String(GATEWAY_SHELL_TIMEOUT_MS); + el.gatewayShellTimeout.title = `Code Agent 调用 PC gateway wrapper 时默认使用 --timeout-ms ${GATEWAY_SHELL_TIMEOUT_MS}`; } function initLiveBuildOverlay() { @@ -1441,6 +1469,7 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI sessionId, threadId, traceId, + gatewayShellTimeoutMs: GATEWAY_SHELL_TIMEOUT_MS, shortConnection: true, projectId: gateSummary.topology.projectId }) @@ -4101,26 +4130,80 @@ function renderTraceEventList(list, rows) { function traceDisplayRows(trace, events) { const rows = []; let noisyRun = []; + let toolOutputRun = []; const flushNoisyRun = () => { if (noisyRun.length === 0) return; const row = traceNoiseSummaryRow(trace, noisyRun); if (row) rows.push(row); noisyRun = []; }; + const flushToolOutputRun = () => { + if (toolOutputRun.length === 0) return; + const row = traceToolOutputSummaryRow(trace, toolOutputRun); + if (row) rows.push(row); + toolOutputRun = []; + }; for (const event of events) { if (isNoisyTraceEvent(event)) { + flushToolOutputRun(); noisyRun.push(event); continue; } + if (isToolOutputChunkTraceEvent(event)) { + flushNoisyRun(); + toolOutputRun.push(event); + continue; + } flushNoisyRun(); + flushToolOutputRun(); const row = traceDisplayRow(trace, event); if (!row) continue; rows.push(row); } flushNoisyRun(); + flushToolOutputRun(); return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event, { includeNoise: true })).filter(Boolean); } +function traceToolOutputSummaryRow(trace, events) { + const visibleEvents = events.filter(Boolean); + if (visibleEvents.length === 0) return null; + const first = visibleEvents[0]; + const last = visibleEvents.at(-1); + const combined = visibleEvents.map((event) => rawTraceOutputText(event)).join(""); + const parsed = parseGatewayJsonRpcOutput(combined); + const clock = traceClock(first.createdAt); + const total = formatTraceDuration(traceRelativeMs(trace, first)); + if (parsed) { + const { payload, result, dispatch } = parsed; + const status = result.status ?? dispatch.dispatchStatus ?? "unknown"; + const exitCode = Number.isInteger(dispatch.exitCode) ? dispatch.exitCode : null; + const ok = status === "succeeded" || status === "completed" || exitCode === 0; + const header = [ + `${clock} total=${total}`, + ok ? "ok" : status === "timed_out" ? "timeout" : "fail", + "tool hardware.invoke.shell", + `status=${status}`, + result.operationId ? `op=${result.operationId}` : "op=null", + exitCode !== null ? `exit=${exitCode}` : null, + typeof dispatch.durationMs === "number" ? `s=${(dispatch.durationMs / 1000).toFixed(1)}` : null, + `chunks=${visibleEvents.length}` + ].filter(Boolean).join(" "); + return { + seq: first.seq ?? null, + tone: ok ? "ok" : status === "timed_out" ? "warn" : "blocked", + header, + body: gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw: combined }) + }; + } + return { + seq: first.seq ?? null, + tone: "source", + header: `${clock} total=${total} output cmd output chunks=${visibleEvents.length} out=${compactTraceSize(combined)}`, + body: compactTraceTextTail(cleanTraceOutputText(combined), 1600) + }; +} + function traceNoiseSummaryRow(trace, events) { const visibleEvents = events.filter(Boolean); if (visibleEvents.length === 0) return null; @@ -4194,6 +4277,7 @@ function traceDisplayRow(trace, event, options = {}) { function isNoisyTraceEvent(event) { const label = String(event?.label ?? ""); + if (isToolOutputChunkTraceEvent(event)) return false; 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; @@ -4207,6 +4291,12 @@ function isAssistantChunkTraceEvent(event) { (event?.type === "assistant_message" && event?.status === "chunk"); } +function isToolOutputChunkTraceEvent(event) { + const label = String(event?.label ?? ""); + return label === "item/commandExecution/outputDelta" || + (event?.type === "tool_call" && event?.status === "output_chunk" && !String(event?.toolName ?? "").startsWith("codex-app-server")); +} + function readableTraceLabel(event) { const label = String(event?.label ?? `${event?.type ?? "event"}:${event?.status ?? "observed"}`); if (label === "request:accepted-short-connection") return "submit short-connection"; @@ -4230,17 +4320,83 @@ function readableTraceLabel(event) { function traceDisplayBody(event) { const lines = [ - event.promptSummary, - event.outputSummary, - event.message, - event.chunk && !isNoisyTraceEvent(event) ? event.chunk : null, + event.promptSummary ? cleanTraceText(event.promptSummary) : null, + event.outputSummary ? cleanTraceOutputText(event.outputSummary) : null, + event.message ? cleanTraceText(event.message) : null, + event.chunk && !isNoisyTraceEvent(event) ? cleanTraceOutputText(event.chunk) : null, event.waitingFor && event.status !== "completed" ? `waiting=${event.waitingFor}` : null - ] - .map((value) => cleanTraceText(value)) - .filter(Boolean); + ].filter(Boolean); return lines.join("\n").slice(0, 1400); } +function rawTraceOutputText(event) { + return String(event?.outputSummary ?? event?.chunk ?? event?.message ?? ""); +} + +function parseGatewayJsonRpcOutput(text) { + const payload = parseFirstJsonObject(text); + if (!payload || payload.jsonrpc !== "2.0" || !payload.result || typeof payload.result !== "object") return null; + const result = payload.result; + const dispatch = result.dispatch && typeof result.dispatch === "object" ? result.dispatch : {}; + if (!("gatewaySessionId" in result) && !("capabilityId" in result) && !("shellExecuted" in dispatch)) return null; + return { payload, result, dispatch }; +} + +function parseFirstJsonObject(text) { + const source = String(text ?? ""); + const start = source.indexOf("{"); + if (start < 0) return null; + let depth = 0; + let inString = false; + let escaped = false; + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === "\"") { + inString = false; + } + continue; + } + if (char === "\"") { + inString = true; + continue; + } + if (char === "{") depth += 1; + if (char === "}") depth -= 1; + if (depth === 0) { + try { + return JSON.parse(source.slice(start, index + 1)); + } catch { + return null; + } + } + } + return null; +} + +function gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw }) { + const stdout = cleanTraceOutputText(dispatch.stdout); + const stderr = cleanTraceOutputText(dispatch.stderr); + const error = dispatch.error?.message ?? dispatch.error ?? dispatch.message ?? result.message ?? null; + return [ + `request=${payload.id ?? "unknown"}`, + `gateway=${result.gatewaySessionId ?? "unknown"} resource=${result.resourceId ?? "unknown"} capability=${result.capabilityId ?? "unknown"}`, + `dispatch=${dispatch.dispatchStatus ?? result.status ?? "unknown"} shellExecuted=${dispatch.shellExecuted === true ? "true" : "false"} timedOut=${dispatch.timedOut === true ? "true" : "false"}`, + error ? `error=${compactTraceTextTail(cleanTraceOutputText(error), 600)}` : null, + dispatch.command ? `command=${compactTraceTextTail(cleanTraceOutputText(dispatch.command), 420)}` : null, + result.auditId ? `audit=${result.auditId}` : null, + result.evidenceId ? `evidence=${result.evidenceId}` : null, + dispatch.stdoutTruncated === true ? "stdoutTruncated=true" : null, + stdout ? `stdout:\n${compactTraceTextTail(stdout, 1200)}` : null, + stderr ? `stderr:\n${compactTraceTextTail(stderr, 800)}` : null, + !stdout && !stderr && raw ? compactTraceTextTail(cleanTraceOutputText(raw), 900) : null + ].filter(Boolean).join("\n"); +} + function traceStatusToken(event) { const status = String(event?.status ?? ""); if (status === "completed" || status === "succeeded") return "ok"; @@ -4293,6 +4449,14 @@ function cleanTraceText(value) { .replace(/\n{3,}/gu, "\n\n"); } +function cleanTraceOutputText(value) { + const text = String(value ?? "").trim(); + if (!text) return ""; + return text + .replace(/[ \t]{2,}/gu, " ") + .replace(/\n{3,}/gu, "\n\n"); +} + function traceActionButton(label, onClick, title) { const button = document.createElement("button"); button.type = "button"; diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index c3db2311..f392babe 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -211,6 +211,16 @@ +
hwlab