From 78675f2de1d604bc37722b9af4110d33cf837ef8 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 04:34:23 +0000 Subject: [PATCH] fix: extend code agent heavy request timeout --- cmd/hwlab-edge-proxy/main.mjs | 15 +- internal/cloud/server.test.mjs | 159 +++++++++++++++ internal/dev-entrypoint/http.mjs | 16 +- internal/dev-entrypoint/http.test.mjs | 103 ++++++++++ package.json | 2 +- scripts/dev-artifact-publish.mjs | 19 +- scripts/dev-cloud-workbench-smoke.test.mjs | 25 +++ scripts/src/dev-cloud-workbench-smoke-lib.mjs | 182 +++++++++++++----- web/hwlab-cloud-web/app.mjs | 42 +++- web/hwlab-cloud-web/scripts/check.mjs | 11 ++ 10 files changed, 511 insertions(+), 63 deletions(-) create mode 100644 internal/dev-entrypoint/http.test.mjs diff --git a/cmd/hwlab-edge-proxy/main.mjs b/cmd/hwlab-edge-proxy/main.mjs index b0184274..95216da6 100644 --- a/cmd/hwlab-edge-proxy/main.mjs +++ b/cmd/hwlab-edge-proxy/main.mjs @@ -10,6 +10,10 @@ 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, 150000, { + min: 1000, + max: 300000 +}); const { host, port } = resolveHostPort({ listenEnv: "HWLAB_EDGE_LISTEN", hostEnv: "HWLAB_EDGE_HOST", @@ -20,15 +24,22 @@ const { host, port } = resolveHostPort({ function proxyDetails() { return { upstream, + timeoutMs: proxyTimeoutMs, mode: "dev-edge-proxy" }; } +function parseTimeout(value, fallback, { min, max }) { + const parsed = Number.parseInt(value || "", 10); + if (!Number.isInteger(parsed)) return fallback; + return Math.min(Math.max(parsed, min), max); +} + const server = createServer((request, response) => { const url = new URL(request.url || "/", "http://hwlab-edge-proxy.local"); if (request.method === "GET" && url.pathname === "/health/live") { - proxyHttpRequest({ request, response, upstream }); + proxyHttpRequest({ request, response, upstream, timeoutMs: proxyTimeoutMs }); return; } @@ -66,7 +77,7 @@ const server = createServer((request, response) => { return; } - proxyHttpRequest({ request, response, upstream }); + proxyHttpRequest({ request, response, upstream, timeoutMs: proxyTimeoutMs }); }); listen(server, { serviceId, host, port }); diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index b03ee4a4..8a2fa295 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -878,6 +878,161 @@ test("cloud api /v1/agent/chat uses streaming OpenAI Responses and parses SSE te } }); +test("cloud api /v1/agent/chat accepts provider success delayed beyond legacy 4500ms UI timeout", async () => { + const server = createCloudApiServer({ + callCodeAgentProvider: async ({ providerPlan, message, traceId }) => { + await delay(4700); + return { + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend, + content: `延迟真实 provider stub: ${message} / ${traceId}`, + usage: null, + providerTrace: { + source: "delayed-test-provider" + } + }; + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const startedAt = Date.now(); + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-trace-id": "trc_server-test-agent-chat-delayed" + }, + body: JSON.stringify({ + conversationId: "cnv_server-test-agent-chat-delayed", + message: "列出你能使用的所有skill" + }) + }); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(Date.now() - startedAt >= 4500, true); + assert.equal(payload.status, "completed"); + assert.equal(payload.conversationId, "cnv_server-test-agent-chat-delayed"); + assert.equal(payload.traceId, "trc_server-test-agent-chat-delayed"); + assert.match(payload.reply.content, /列出你能使用的所有skill/u); + assert.equal(Object.hasOwn(payload, "error"), false); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + +test("cloud api /v1/agent/chat reports provider timeout as failed without a reply", async () => { + const providerServer = createHttpServer((request, response) => { + request.resume(); + setTimeout(() => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ output_text: "too late" })); + }, 250); + }); + await new Promise((resolve) => providerServer.listen(0, "127.0.0.1", resolve)); + const providerPort = providerServer.address().port; + + const server = createCloudApiServer({ + env: { + OPENAI_API_KEY: "test-openai-key-material", + HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: `http://127.0.0.1:${providerPort}/v1/responses` + }, + codeAgentTimeoutMs: 50 + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-trace-id": "trc_server-test-agent-chat-provider-timeout" + }, + body: JSON.stringify({ + conversationId: "cnv_server-test-agent-chat-provider-timeout", + message: "请等待后回答" + }) + }); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.status, "failed"); + assert.equal(payload.traceId, "trc_server-test-agent-chat-provider-timeout"); + assert.equal(payload.error.code, "provider_unavailable"); + assert.match(payload.error.message, /timed out after 50ms/u); + assert.equal(Object.hasOwn(payload, "reply"), false); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await new Promise((resolve, reject) => { + providerServer.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + +test("cloud api /v1/agent/chat reports OpenAI provider 502 and 503 as failed blocked payloads", async () => { + for (const status of [502, 503]) { + const providerServer = createHttpServer((request, response) => { + request.resume(); + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify({ + error: { + message: `upstream ${status}` + } + })); + }); + await new Promise((resolve) => providerServer.listen(0, "127.0.0.1", resolve)); + const providerPort = providerServer.address().port; + + const server = createCloudApiServer({ + env: { + OPENAI_API_KEY: "test-openai-key-material", + HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: `http://127.0.0.1:${providerPort}/v1/responses` + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-trace-id": `trc_server-test-agent-chat-provider-${status}` + }, + body: JSON.stringify({ + conversationId: `cnv_server-test-agent-chat-provider-${status}`, + message: `provider ${status}` + }) + }); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.status, "failed"); + assert.equal(payload.traceId, `trc_server-test-agent-chat-provider-${status}`); + assert.equal(payload.error.code, "provider_unavailable"); + assert.equal(payload.error.providerStatus, status); + assert.match(payload.error.message, new RegExp(`HTTP ${status}`)); + assert.equal(Object.hasOwn(payload, "reply"), false); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await new Promise((resolve, reject) => { + providerServer.close((error) => (error ? reject(error) : resolve())); + }); + } + } +}); + test("cloud api /v1/agent/chat reports provider gaps without faking a reply", async () => { const server = createCloudApiServer({ env: { @@ -930,6 +1085,10 @@ test("cloud api /v1/agent/chat reports provider gaps without faking a reply", as } }); +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + test("cloud api /v1/agent/chat does not complete on empty provider text", async () => { const server = createCloudApiServer({ callCodeAgentProvider: async ({ providerPlan }) => ({ diff --git a/internal/dev-entrypoint/http.mjs b/internal/dev-entrypoint/http.mjs index 48b06614..611b58fa 100644 --- a/internal/dev-entrypoint/http.mjs +++ b/internal/dev-entrypoint/http.mjs @@ -2,6 +2,8 @@ import { createServer, request as httpRequest } from "node:http"; import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../protocol/index.mjs"; +const DEFAULT_PROXY_TIMEOUT_MS = 150000; + export function parsePort(value, fallback) { const parsed = Number.parseInt(value ?? "", 10); if (Number.isInteger(parsed) && parsed > 0 && parsed < 65536) { @@ -105,8 +107,11 @@ export function listen(server, { serviceId, host, port }) { } } -export function proxyHttpRequest({ request, response, upstream }) { +export function proxyHttpRequest({ request, response, upstream, timeoutMs = DEFAULT_PROXY_TIMEOUT_MS }) { const target = new URL(request.url || "/", upstream); + const timeout = setTimeout(() => { + proxy.destroy(new Error(`upstream timed out after ${timeoutMs}ms`)); + }, timeoutMs); const proxy = httpRequest( target, { @@ -118,11 +123,20 @@ export function proxyHttpRequest({ request, response, upstream }) { }, (upstreamResponse) => { response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.on("end", () => clearTimeout(timeout)); + upstreamResponse.on("close", () => clearTimeout(timeout)); upstreamResponse.pipe(response); } ); + let settled = false; proxy.on("error", (error) => { + clearTimeout(timeout); + if (settled || response.headersSent) { + response.destroy(error); + return; + } + settled = true; sendJson(response, 502, { error: "upstream_unavailable", upstream, diff --git a/internal/dev-entrypoint/http.test.mjs b/internal/dev-entrypoint/http.test.mjs new file mode 100644 index 00000000..b74ae89e --- /dev/null +++ b/internal/dev-entrypoint/http.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import test from "node:test"; + +import { proxyHttpRequest } from "./http.mjs"; + +test("dev entrypoint proxy allows slow first response beyond legacy 4500ms", async () => { + const upstream = createServer((request, response) => { + request.resume(); + setTimeout(() => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + status: "completed", + traceId: request.headers["x-trace-id"] ?? null + })); + }, 4700); + }); + await listen(upstream); + + const proxy = createServer((request, response) => { + proxyHttpRequest({ + request, + response, + upstream: serverUrl(upstream), + timeoutMs: 6000 + }); + }); + await listen(proxy); + + try { + const startedAt = Date.now(); + const response = await fetch(`${serverUrl(proxy)}/v1/agent/chat`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-trace-id": "trc_dev-entrypoint-proxy-slow-success" + }, + body: JSON.stringify({ message: "列出你能使用的所有skill" }) + }); + assert.equal(response.status, 200); + assert.equal(Date.now() - startedAt >= 4500, true); + const payload = await response.json(); + assert.equal(payload.status, "completed"); + assert.equal(payload.traceId, "trc_dev-entrypoint-proxy-slow-success"); + } finally { + await close(proxy); + await close(upstream); + } +}); + +test("dev entrypoint proxy returns 502 when hard timeout expires", async () => { + const upstream = createServer((request, response) => { + request.resume(); + setTimeout(() => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ status: "completed" })); + }, 250); + }); + await listen(upstream); + + const proxy = createServer((request, response) => { + proxyHttpRequest({ + request, + response, + upstream: serverUrl(upstream), + timeoutMs: 50 + }); + }); + await listen(proxy); + + try { + const response = await fetch(`${serverUrl(proxy)}/v1/agent/chat`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "slow" }) + }); + assert.equal(response.status, 502); + const payload = await response.json(); + assert.equal(payload.error, "upstream_unavailable"); + assert.match(payload.message, /timed out after 50ms/u); + } finally { + await close(proxy); + await close(upstream); + } +}); + +function listen(server) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); +} + +function close(server) { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +function serverUrl(server) { + const address = server.address(); + return `http://127.0.0.1:${address.port}`; +} diff --git a/package.json b/package.json index a35025c2..218344b1 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node --test scripts/artifact-runtime-readiness-guard.test.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/server.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index 5f8acaaa..827102d3 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -311,6 +311,10 @@ 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, 150000, { + min: 1000, + max: 300000 +}); const readOnlyRpcMethods = new Set([ "system.health", "cloud.adapter.describe", @@ -366,8 +370,17 @@ function copyHeaders(headers) { return result; } +function parseTimeout(value, fallback, { min, max }) { + const parsed = Number.parseInt(value || "", 10); + if (!Number.isInteger(parsed)) return fallback; + return Math.min(Math.max(parsed, min), max); +} + function requestUpstream(target, request, body) { return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + upstream.destroy(new Error("cloud api proxy timed out after " + cloudApiProxyTimeoutMs + "ms")); + }, cloudApiProxyTimeoutMs); const upstream = http.request( target, { @@ -382,6 +395,7 @@ function requestUpstream(target, request, body) { const chunks = []; upstreamResponse.on("data", (chunk) => chunks.push(chunk)); upstreamResponse.on("end", () => { + clearTimeout(timeout); resolve({ statusCode: upstreamResponse.statusCode || 502, headers: upstreamResponse.headers, @@ -390,7 +404,10 @@ function requestUpstream(target, request, body) { }); } ); - upstream.on("error", reject); + upstream.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); if (body) upstream.write(body); upstream.end(); }); diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 39d71c76..7e5e0507 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -5,6 +5,7 @@ import { classifyCodeAgentBrowserJourney, classifyLiveDeploymentIdentity, classifyLiveWebAssetIdentity, + runDevCloudWorkbenchTimeoutFixtureSmoke, sanitizeAgentChatBody } from "./src/dev-cloud-workbench-smoke-lib.mjs"; import { @@ -321,3 +322,27 @@ test("Code Agent browser classifier blocks completed payloads without backend ev assert.equal(classification.status, "blocked"); assert.equal(classification.blocker, "untrusted-completion"); }); + +test("local Code Agent timeout fixture keeps failed UI state, trace evidence, and retry input", async () => { + const report = await runDevCloudWorkbenchTimeoutFixtureSmoke({ + responseDelayMs: 120, + timeoutConfigMs: 50 + }); + + if (report.status === "skip") { + assert.match(report.summary, /Playwright is unavailable/u); + return; + } + + assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2)); + assert.equal(report.evidenceLevel, "SOURCE"); + assert.equal(report.devLive, false); + const timeoutCheck = report.checks.find((check) => check.id === "local-agent-timeout-fixture-failed-state"); + assert.equal(timeoutCheck?.status, "pass"); + assert.equal(timeoutCheck.observations.ui.agentChatStatus, "发送失败"); + assert.equal(timeoutCheck.observations.ui.failedMessageVisible, true); + assert.equal(timeoutCheck.observations.ui.failedMessageHasTrace, true); + assert.equal(timeoutCheck.observations.ui.failedMessageHasChineseTimeout, true); + assert.equal(timeoutCheck.observations.ui.retryInputPreserved, true); + assert.equal(timeoutCheck.observations.ui.completedMessageVisible, false); +}); diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 77d0fead..db1f83a1 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -176,9 +176,19 @@ export function runDevCloudWorkbenchStaticSmoke() { return runStaticSmoke(); } +export async function runDevCloudWorkbenchTimeoutFixtureSmoke(options = {}) { + return runLocalAgentFixtureSmoke({ + mode: "local-agent-timeout-fixture-browser", + responseDelayMs: options.responseDelayMs ?? 120, + timeoutConfigMs: options.timeoutConfigMs ?? 50, + expectTimeout: true + }); +} + export async function runDevCloudWorkbenchSmoke(argv = []) { const args = parseSmokeArgs(argv); if (args.mode === "live") return runLiveSmoke(args); + if (args.mode === "local-agent-timeout-fixture") return runDevCloudWorkbenchTimeoutFixtureSmoke(); if (args.mode === "local-agent-fixture") return runDevCloudWorkbenchLocalAgentFixtureSmoke(); if (args.mode === "mobile") return runDevCloudWorkbenchMobileSmoke(); return runStaticSmoke(); @@ -197,6 +207,8 @@ export function parseSmokeArgs(argv) { args.mobile = true; } else if (arg === "--local-agent-fixture") { args.mode = "local-agent-fixture"; + } else if (arg === "--local-agent-timeout-fixture") { + args.mode = "local-agent-timeout-fixture"; } else if (arg === "--url") { index += 1; if (!argv[index]) throw new Error("--url requires a value"); @@ -1269,6 +1281,12 @@ function hasCodeAgentConversationUxStates({ app, styles }) { /function\s+messageEvidencePanel\s*\(/u.test(app) && /function\s+messageEvidenceSummary\s*\(/u.test(app) && /function\s+boundedEvidenceField\s*\(/u.test(app) && + /DEFAULT_API_TIMEOUT_MS\s*=\s*4500/u.test(app) && + /DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*150000/u.test(app) && + /timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/u.test(app) && + /HWLAB_CLOUD_WEB_CONFIG/u.test(app) && + /失败时会保留输入供重试/u.test(app) && + /Code Agent 请求超过/u.test(app) && /Code Agent SOURCE 回复/u.test(app) && /SOURCE fixture 只可显示为 SOURCE 回复,不能冒充 DEV-LIVE/u.test(app) && /sourceKind:\s*completion\.sourceKind/u.test(submitBody) && @@ -1949,26 +1967,35 @@ async function inspectJourneyControls(page) { } async function inspectJourneyUi(page) { - return page.evaluate(() => ({ - title: document.title, - workspaceHidden: document.querySelector('[data-view="workspace"]')?.hidden ?? null, - gateHidden: document.querySelector('[data-view="gate"]')?.hidden ?? null, - helpHidden: document.querySelector('[data-view="help"]')?.hidden ?? null, - agentChatStatus: document.querySelector("#agent-chat-status")?.textContent?.trim() ?? null, - inputCleared: document.querySelector("#command-input")?.value === "", - completedMessageVisible: Boolean(document.querySelector(".message-card.status-completed")), - sourceMessageVisible: Boolean(document.querySelector(".message-card.status-source")), - failedMessageVisible: Boolean(document.querySelector(".message-card.status-failed")), - messageCount: document.querySelectorAll(".message-card").length, - completedMessageHasNonSensitiveMeta: Boolean( - [...document.querySelectorAll(".message-card.status-completed .message-meta")] - .some((element) => /openai-responses|gpt-5\.5|trc_/u.test(element.textContent ?? "")) - ), - sourceMessageHasNonSensitiveMeta: Boolean( - [...document.querySelectorAll(".message-card.status-source .message-meta")] - .some((element) => /SOURCE|source-fixture|trc_/u.test(element.textContent ?? "")) - ) - })); + return page.evaluate(() => { + const failedText = [...document.querySelectorAll(".message-card.status-failed")] + .map((element) => element.textContent ?? "") + .join("\n"); + const commandValue = document.querySelector("#command-input")?.value ?? ""; + return { + title: document.title, + workspaceHidden: document.querySelector('[data-view="workspace"]')?.hidden ?? null, + gateHidden: document.querySelector('[data-view="gate"]')?.hidden ?? null, + helpHidden: document.querySelector('[data-view="help"]')?.hidden ?? null, + agentChatStatus: document.querySelector("#agent-chat-status")?.textContent?.trim() ?? null, + inputCleared: commandValue === "", + retryInputPreserved: commandValue === "请用一句话说明当前 HWLAB 工作台可以做什么。", + completedMessageVisible: Boolean(document.querySelector(".message-card.status-completed")), + sourceMessageVisible: Boolean(document.querySelector(".message-card.status-source")), + failedMessageVisible: Boolean(document.querySelector(".message-card.status-failed")), + failedMessageHasTrace: /trace=trc_/u.test(failedText), + failedMessageHasChineseTimeout: /请求超过|请求超时|保留输入|重试/u.test(failedText), + messageCount: document.querySelectorAll(".message-card").length, + completedMessageHasNonSensitiveMeta: Boolean( + [...document.querySelectorAll(".message-card.status-completed .message-meta")] + .some((element) => /openai-responses|gpt-5\.5|trc_/u.test(element.textContent ?? "")) + ), + sourceMessageHasNonSensitiveMeta: Boolean( + [...document.querySelectorAll(".message-card.status-source .message-meta")] + .some((element) => /SOURCE|source-fixture|trc_/u.test(element.textContent ?? "")) + ) + }; + }); } async function inspectDefaultApiStatus(page) { @@ -2012,6 +2039,19 @@ function blockedAgentChatResponse(code) { } export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() { + return runLocalAgentFixtureSmoke({ + mode: "local-agent-fixture-browser", + responseDelayMs: 0, + expectTimeout: false + }); +} + +async function runLocalAgentFixtureSmoke({ + mode, + responseDelayMs = 0, + timeoutConfigMs = null, + expectTimeout = false +} = {}) { let chromium; try { ({ chromium } = await import("playwright")); @@ -2019,7 +2059,7 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() { return { status: "skip", task: "DC-DCSN-P0-2026-003", - mode: "local-agent-fixture-browser", + mode, evidenceLevel: "SOURCE", devLive: false, summary: `Local Code Agent browser fixture smoke skipped because Playwright is unavailable: ${error.message}`, @@ -2029,11 +2069,22 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() { }; } - const server = await startStaticWebServer({ agentFixture: true }); + const server = await startStaticWebServer({ agentFixture: true, agentDelayMs: responseDelayMs }); let browser; try { browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 1366, height: 768 } }); + if (timeoutConfigMs !== null) { + await page.addInitScript((timeoutMs) => { + globalThis.HWLAB_CLOUD_WEB_CONFIG = { + ...(globalThis.HWLAB_CLOUD_WEB_CONFIG ?? {}), + timeouts: { + ...(globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? {}), + codeAgentTimeoutMs: timeoutMs + } + }; + }, timeoutConfigMs); + } const agentResponses = []; page.on("response", async (response) => { if (!response.url().includes("/v1/agent/chat")) return; @@ -2061,30 +2112,50 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() { const controls = await inspectJourneyControls(page); const apiStatus = await inspectDefaultApiStatus(page); await page.locator("#command-input").fill("请用一句话说明当前 HWLAB 工作台可以做什么。"); - const responsePromise = page.waitForResponse( + const responsePromise = expectTimeout ? null : page.waitForResponse( (response) => response.url().includes("/v1/agent/chat") && response.request().method() === "POST", { timeout: 15000 } ); await page.locator("#command-send").click(); - const response = await responsePromise; - const responseBody = await response.json().catch(() => null); - await page.waitForFunction( - () => document.querySelector("#agent-chat-status")?.textContent?.trim() === "SOURCE 回复", - null, - { timeout: 8000 } - ); + let response = null; + let responseBody = null; + if (expectTimeout) { + await page.waitForFunction( + () => document.querySelector("#agent-chat-status")?.textContent?.trim() === "发送失败", + null, + { timeout: 8000 } + ); + } else { + response = await responsePromise; + responseBody = await response.json().catch(() => null); + await page.waitForFunction( + () => document.querySelector("#agent-chat-status")?.textContent?.trim() === "SOURCE 回复", + null, + { timeout: 8000 } + ); + } const ui = await inspectJourneyUi(page); - const responseSummary = sanitizeAgentChatBody(responseBody, { httpStatus: response.status() }); - const pass = - response.ok() && - responseSummary?.status === "completed" && - responseSummary?.sourceKind === "SOURCE" && - responseSummary?.hasReply === true && - ui.agentChatStatus === "SOURCE 回复" && - ui.sourceMessageVisible && - ui.sourceMessageHasNonSensitiveMeta && - !ui.completedMessageVisible && - !ui.failedMessageVisible; + const responseSummary = response ? sanitizeAgentChatBody(responseBody, { httpStatus: response.status() }) : null; + const pass = expectTimeout + ? ( + ui.agentChatStatus === "发送失败" && + ui.failedMessageVisible && + ui.retryInputPreserved && + ui.failedMessageHasTrace && + ui.failedMessageHasChineseTimeout && + !ui.completedMessageVisible + ) + : ( + response.ok() && + responseSummary?.status === "completed" && + responseSummary?.sourceKind === "SOURCE" && + responseSummary?.hasReply === true && + ui.agentChatStatus === "SOURCE 回复" && + ui.sourceMessageVisible && + ui.sourceMessageHasNonSensitiveMeta && + !ui.completedMessageVisible && + !ui.failedMessageVisible + ); const checks = [ { id: "local-agent-fixture-controls", @@ -2099,9 +2170,11 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() { observations: apiStatus }, { - id: "local-agent-fixture-replied-state", + id: expectTimeout ? "local-agent-timeout-fixture-failed-state" : "local-agent-fixture-replied-state", status: pass ? "pass" : "blocked", - summary: "Local SOURCE fixture sends input and reaches a user-visible replied state without marking the fixture as DEV-LIVE.", + summary: expectTimeout + ? "Local SOURCE fixture proves a bounded Code Agent timeout stays failed/BLOCKED in the UI, preserves trace evidence, and keeps the user's input for retry." + : "Local SOURCE fixture sends input and reaches a user-visible replied state without marking the fixture as DEV-LIVE.", observations: { response: responseSummary, ui, @@ -2120,12 +2193,14 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() { return { status: blockers.length === 0 ? "pass" : "blocked", task: "DC-DCSN-P0-2026-003", - mode: "local-agent-fixture-browser", + mode, url: server.url, generatedAt: new Date().toISOString(), evidenceLevel: "SOURCE", devLive: false, - summary: "Local browser smoke uses a sanitized SOURCE fixture for /v1/agent/chat; it proves the UI conversation path only and does not claim DEV-LIVE.", + summary: expectTimeout + ? "Local browser smoke uses a sanitized slow SOURCE fixture for /v1/agent/chat; it proves timeout UI classification only and does not claim DEV-LIVE." + : "Local browser smoke uses a sanitized SOURCE fixture for /v1/agent/chat; it proves the UI conversation path only and does not claim DEV-LIVE.", checks, blockers, safety: { @@ -2139,7 +2214,7 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() { return { status: "blocked", task: "DC-DCSN-P0-2026-003", - mode: "local-agent-fixture-browser", + mode, url: server.url, generatedAt: new Date().toISOString(), evidenceLevel: "SOURCE", @@ -2149,7 +2224,7 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() { blockers: [ { type: "observability_blocker", - scope: "local-agent-fixture-browser", + scope: mode, status: "open", summary: error.message } @@ -2336,7 +2411,7 @@ export async function runDevCloudWorkbenchMobileSmoke() { async function startStaticWebServer(options = {}) { const server = http.createServer(async (request, response) => { const url = new URL(request.url ?? "/", "http://127.0.0.1"); - if (options.agentFixture && await handleLocalAgentFixtureApi({ request, response, url })) { + if (options.agentFixture && await handleLocalAgentFixtureApi({ request, response, url, options })) { return; } let pathname = decodeURIComponent(url.pathname); @@ -2369,7 +2444,7 @@ async function startStaticWebServer(options = {}) { }; } -async function handleLocalAgentFixtureApi({ request, response, url }) { +async function handleLocalAgentFixtureApi({ request, response, url, options = {} }) { if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) { jsonResponse(response, 200, { serviceId: "hwlab-cloud-api", @@ -2418,6 +2493,7 @@ async function handleLocalAgentFixtureApi({ request, response, url }) { } if (request.method === "POST" && url.pathname === "/v1/agent/chat") { const body = await readJsonBody(request); + await delay(options.agentDelayMs ?? 0); const timestamp = "2026-05-22T00:00:00.000Z"; const conversationId = stringOrFallback(body?.conversationId, "cnv_source_fixture_browser"); jsonResponse(response, 200, { @@ -2453,6 +2529,11 @@ async function handleLocalAgentFixtureApi({ request, response, url }) { return false; } +function delay(ms) { + const normalized = Number.isFinite(Number(ms)) && Number(ms) > 0 ? Number(ms) : 0; + return new Promise((resolve) => setTimeout(resolve, normalized)); +} + function localSourceFixtureAvailability() { return { endpoint: "POST /v1/agent/chat", @@ -2781,11 +2862,12 @@ function escapeRegExp(value) { export function printSmokeHelp() { return { status: "usage", - command: "node scripts/dev-cloud-workbench-smoke.mjs --static | --mobile | --local-agent-fixture | --live --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-live.json]", + command: "node scripts/dev-cloud-workbench-smoke.mjs --static | --mobile | --local-agent-fixture | --local-agent-timeout-fixture | --live --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-live.json]", notes: [ "Static mode reads repository files and emits SOURCE-level evidence only.", "--mobile runs a local static 390x844 browser hit-test and still emits SOURCE-level evidence only.", "--local-agent-fixture runs a local browser send/reply path with a sanitized SOURCE fixture and does not claim DEV-LIVE.", + "--local-agent-timeout-fixture runs a local slow SOURCE fixture to verify bounded timeout UI classification and retry preservation without live acceptance.", "Live mode opens the deployed workbench in a browser, verifies core controls, sends one Code Agent message, and retains only non-sensitive response fields." ] }; diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index af27604a..31265b32 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -2,7 +2,10 @@ import { gateSummary } from "./gate-summary.mjs"; import { runtime } from "./runtime.mjs"; import { marked } from "./third_party/marked/marked.esm.js"; -const API_TIMEOUT_MS = 4500; +const DEFAULT_API_TIMEOUT_MS = 4500; +const DEFAULT_CODE_AGENT_TIMEOUT_MS = 150000; +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 }); const rpcReadMethods = Object.freeze([ "system.health", "cloud.adapter.describe", @@ -140,6 +143,13 @@ function query(selector) { return element; } +function resolveTimeoutMs(name, fallback, { min, max }) { + const config = globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? globalThis.HWLAB_CLOUD_WEB_TIMEOUTS ?? {}; + const configured = Number(config?.[name]); + if (!Number.isFinite(configured)) return fallback; + return Math.min(Math.max(Math.trunc(configured), min), max); +} + function initRoutes() { window.addEventListener("hashchange", () => showView(routeFromLocation())); for (const button of document.querySelectorAll("[data-route]")) { @@ -282,7 +292,7 @@ function initCommandBar() { id: nextProtocolId("msg"), role: "agent", title: "Code Agent 处理中", - text: "正在调用真实 Code Agent / Codex 后端,请稍候。", + text: `正在调用真实 Code Agent / Codex 后端,复杂问题可能需要 1-2 分钟;超时上限 ${Math.round(CODE_AGENT_TIMEOUT_MS / 1000)} 秒,失败时会保留输入供重试。`, status: "running", traceId, createdAt: new Date().toISOString() @@ -344,7 +354,9 @@ function initCommandBar() { traceId: error.traceId || traceId, updatedAt: new Date().toISOString(), error: { - message: error.message + code: error.code || "request_failed", + message: error.message, + timeoutMs: error.timeoutMs } }; el.commandInput.value = value; @@ -413,6 +425,8 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI "Content-Type": "application/json", "X-Trace-Id": traceId }, + timeoutMs: CODE_AGENT_TIMEOUT_MS, + timeoutName: "Code Agent", body: JSON.stringify({ message, conversationId, @@ -425,8 +439,12 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI if (isBlockedAgentResponse(response.data, response.status)) { return normalizeBlockedAgentResult(response.data, response.status, traceId, response.error); } - const error = new Error(response.error || "Code Agent 请求失败"); + const error = new Error(response.timeout + ? `Code Agent 请求超过 ${response.timeoutMs}ms 未完成;已保留输入,可稍后重试。` + : response.error || "Code Agent 请求失败"); error.traceId = traceId; + error.code = response.timeout ? "client_timeout" : "request_failed"; + error.timeoutMs = response.timeoutMs; throw error; } return { @@ -460,14 +478,19 @@ async function loadLiveSurface() { } async function fetchJson(path, options = {}) { + const { + timeoutMs = API_TIMEOUT_MS, + timeoutName = path, + ...fetchOptions + } = options; const controller = new AbortController(); - const timer = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); + const timer = window.setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(path, { - ...options, + ...fetchOptions, headers: { Accept: "application/json", - ...(options.headers ?? {}) + ...(fetchOptions.headers ?? {}) }, signal: controller.signal }); @@ -496,10 +519,13 @@ async function fetchJson(path, options = {}) { } return { ok: true, path, status: response.status, data }; } catch (error) { + const timedOut = error.name === "AbortError"; return { ok: false, path, - error: error.name === "AbortError" ? `${path} 请求超时 ${API_TIMEOUT_MS}ms` : `请求失败:${error.message}` + timeout: timedOut, + timeoutMs, + error: timedOut ? `${timeoutName} 请求超时 ${timeoutMs}ms` : `请求失败:${error.message}` }; } finally { window.clearTimeout(timer); diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 10bb0c7d..e7d12e0c 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -405,6 +405,13 @@ assert.match(app, /messageId/); 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*150000/); +assert.match(app, /CODE_AGENT_TIMEOUT_MS/); +assert.match(app, /timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/); +assert.match(app, /HWLAB_CLOUD_WEB_CONFIG/); +assert.match(app, /失败时会保留输入供重试/); +assert.match(app, /Code Agent 请求超过/); assert.doesNotMatch(html, /M3 Diagnostics Console/); assert.match(styles, /html,\s*\nbody\s*{[^}]*height:\s*100%;[^}]*overflow:\s*hidden;/s); assert.match(styles, /body\s*>\s*\[data-app-shell\]\s*{[^}]*min-height:\s*0;/s); @@ -429,6 +436,10 @@ assert.match(app, /callRpc\("cloud\.adapter\.describe"\)/); assert.match(app, /callRpc\("audit\.event\.query"/); assert.match(app, /callRpc\("evidence\.record\.query"/); assert.match(artifactPublisher, /HWLAB_API_BASE_URL/); +assert.match(artifactPublisher, /HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS/); +assert.match(artifactPublisher, /cloudApiProxyTimeoutMs/); +assert.match(artifactPublisher, /setTimeout\(\(\) => \{/); +assert.match(artifactPublisher, /cloud api proxy timed out after/); assert.match(artifactPublisher, /readOnlyRpcMethods/); assert.match(artifactPublisher, /requestUpstream/); assert.match(artifactPublisher, /from "node:http"/);