diff --git a/internal/dev-entrypoint/cloud-web-proxy.mjs b/internal/dev-entrypoint/cloud-web-proxy.mjs new file mode 100644 index 00000000..0634e584 --- /dev/null +++ b/internal/dev-entrypoint/cloud-web-proxy.mjs @@ -0,0 +1,153 @@ +import { request as httpRequest } from "node:http"; + +const STREAMING_HEADER_DEFAULTS = Object.freeze({ + "cache-control": "no-store", + "x-accel-buffering": "no" +}); + +export function isCloudWebSseRoute(pathname) { + return /^\/v1\/agent\/chat\/trace\/[^/]+\/stream$/u.test(String(pathname || "")); +} + +export function isEventStreamResponse(headers = {}) { + const contentType = headerValue(headers, "content-type"); + return /^text\/event-stream(?:\s*;|$)/iu.test(contentType); +} + +export function copyProxyResponseHeaders(headers = {}, { streaming = false } = {}) { + const result = {}; + for (const [key, value] of Object.entries(headers)) { + const lowerKey = key.toLowerCase(); + if (value === undefined) continue; + if (lowerKey === "transfer-encoding") continue; + if (lowerKey === "content-length") continue; + result[key] = value; + } + if (streaming) { + for (const [key, value] of Object.entries(STREAMING_HEADER_DEFAULTS)) { + if (!hasHeader(result, key)) result[key] = value; + } + } + return result; +} + +export function upstreamRequestHeaders(request, body = "") { + const headers = { + accept: request.headers.accept || "application/json" + }; + const hasBody = body.length > 0; + if (request.headers["content-type"] || hasBody) { + headers["content-type"] = request.headers["content-type"] || "application/json"; + } + for (const name of ["x-trace-id", "x-request-id", "x-actor-id", "x-source-service-id"]) { + if (request.headers[name] !== undefined) headers[name] = request.headers[name]; + } + if (hasBody) headers["content-length"] = Buffer.byteLength(body); + return headers; +} + +export function proxyCloudApiRequest({ + target, + request, + response, + body = "", + timeoutMs, + forceStream = false +}) { + return new Promise((resolve, reject) => { + let timedOut = false; + let settled = false; + let timeout = null; + + const settle = (callback, value) => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + callback(value); + }; + const armTimeout = () => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => { + timedOut = true; + upstream.destroy(new Error("cloud api proxy timed out after " + timeoutMs + "ms")); + }, timeoutMs); + }; + + const upstream = httpRequest( + target, + { + method: request.method, + headers: upstreamRequestHeaders(request, body) + }, + (upstreamResponse) => { + const streaming = forceStream || isEventStreamResponse(upstreamResponse.headers); + if (streaming) { + response.writeHead(upstreamResponse.statusCode || 502, copyProxyResponseHeaders(upstreamResponse.headers, { streaming: true })); + response.flushHeaders?.(); + upstreamResponse.on("data", (chunk) => { + armTimeout(); + response.write(chunk); + }); + upstreamResponse.on("end", () => { + response.end(); + settle(resolve); + }); + upstreamResponse.on("error", (error) => { + if (response.headersSent) response.destroy(error); + settle(reject, error); + }); + upstreamResponse.on("close", () => { + if (!response.writableEnded) response.end(); + settle(resolve); + }); + response.on("close", () => upstream.destroy()); + return; + } + + const chunks = []; + upstreamResponse.on("data", (chunk) => { + armTimeout(); + chunks.push(chunk); + }); + upstreamResponse.on("end", () => { + const responseBody = Buffer.concat(chunks); + response.writeHead(upstreamResponse.statusCode || 502, { + ...copyProxyResponseHeaders(upstreamResponse.headers), + "content-length": responseBody.length + }); + response.end(responseBody); + settle(resolve); + }); + upstreamResponse.on("error", (error) => settle(reject, error)); + } + ); + + armTimeout(); + upstream.on("error", (error) => { + if (timedOut) error.timedOut = true; + if (response.headersSent) { + response.destroy(error); + settle(resolve); + return; + } + settle(reject, error); + }); + if (body) upstream.write(body); + upstream.end(); + }); +} + +function headerValue(headers, name) { + const lowerName = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() !== lowerName) continue; + if (Array.isArray(value)) return value[0] ?? ""; + return String(value ?? ""); + } + return ""; +} + +function hasHeader(headers, name) { + const lowerName = name.toLowerCase(); + return Object.keys(headers).some((key) => key.toLowerCase() === lowerName); +} diff --git a/internal/dev-entrypoint/cloud-web-proxy.test.mjs b/internal/dev-entrypoint/cloud-web-proxy.test.mjs new file mode 100644 index 00000000..80dad275 --- /dev/null +++ b/internal/dev-entrypoint/cloud-web-proxy.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import test from "node:test"; + +import { + isCloudWebSseRoute, + proxyCloudApiRequest +} from "./cloud-web-proxy.mjs"; + +test("cloud web proxy streams SSE chunks without fixed content-length", async () => { + const upstream = createServer((request, response) => { + assert.equal(request.headers["x-trace-id"], "trc_stream_proxy"); + response.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-store", + "x-accel-buffering": "no" + }); + setTimeout(() => response.write("event: snapshot\ndata: {\"events\":[{\"seq\":1}]}\n\n"), 40); + setTimeout(() => response.write("event: runnerTrace\ndata: {\"snapshot\":{\"events\":[{\"seq\":1},{\"seq\":2}]}}\n\n"), 140); + setTimeout(() => response.end(), 500); + }); + await listen(upstream); + + 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) => response.destroy(error)); + }); + await listen(proxy); + + try { + const startedAt = Date.now(); + const response = await fetch(`${serverUrl(proxy)}/v1/agent/chat/trace/trc_stream_proxy/stream`, { + headers: { + "x-trace-id": "trc_stream_proxy", + accept: "text/event-stream" + } + }); + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-length"), null); + assert.match(response.headers.get("content-type") || "", /^text\/event-stream/iu); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.equal(response.headers.get("x-accel-buffering"), "no"); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let text = ""; + let firstEventAt = null; + let secondEventAt = null; + while (!secondEventAt) { + const { value, done } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + const eventCount = (text.match(/^event:/gmu) || []).length; + if (eventCount >= 1 && firstEventAt === null) firstEventAt = Date.now() - startedAt; + if (eventCount >= 2) secondEventAt = Date.now() - startedAt; + } + await reader.cancel().catch(() => {}); + + assert.ok(firstEventAt !== null && firstEventAt < 250, `first SSE event was not flushed promptly: ${firstEventAt}ms`); + assert.ok(secondEventAt !== null && secondEventAt < 450, `second SSE event was not flushed promptly: ${secondEventAt}ms`); + assert.ok(secondEventAt > firstEventAt, "event count must grow at two distinct times"); + } 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(); + setTimeout(() => { + response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify({ ok: true })); + }, 20); + }); + await listen(upstream); + + const proxy = createServer((request, response) => { + proxyCloudApiRequest({ + target: new URL(request.url || "/", serverUrl(upstream)), + request, + response, + body: "", + timeoutMs: 1000 + }).catch((error) => response.destroy(error)); + }); + await listen(proxy); + + try { + const response = await fetch(`${serverUrl(proxy)}/v1`); + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-length"), String(Buffer.byteLength("{\"ok\":true}"))); + assert.deepEqual(await response.json(), { ok: true }); + } 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 3311eeae..a24828e0 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/repo-reports-guard.mjs && node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs", - "check": "node scripts/repo-reports-guard.mjs && node --check scripts/repo-reports-guard.mjs && node --check scripts/src/report-paths.mjs && node --check internal/protocol/index.mjs && node --check internal/build-metadata.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/cloud-web-routes.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/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/code-agent-trace-store.mjs && node --check internal/cloud/codex-stdio-session.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-cloud-api/provision.mjs && node --check cmd/hwlab-cloud-api/migrate.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/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-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.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/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.test.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/dev-runtime-base-image.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/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.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/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.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 skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check tools/hwlab-cli/lib/cicd-jobs.mjs && node --check tools/hwlab-cli/lib/cli.test.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/code-agent-facts.mjs && node --check web/hwlab-cloud-web/code-agent-facts.test.mjs && node --check web/hwlab-cloud-web/code-agent-status.mjs && node --check web/hwlab-cloud-web/code-agent-status.test.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.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/live-status-contract.test.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-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node cmd/hwlab-cloud-api/provision.mjs --check && node cmd/hwlab-cloud-api/migrate.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && 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 web/hwlab-cloud-web/code-agent-facts.test.mjs web/hwlab-cloud-web/code-agent-status.test.mjs web/hwlab-cloud-web/wiring-status.test.mjs web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs web/hwlab-cloud-web/scripts/live-status-contract.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/refresh-artifact-catalog.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs tools/hwlab-cli/lib/cli.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/code-agent-session-registry.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", + "check": "node scripts/repo-reports-guard.mjs && node --check scripts/repo-reports-guard.mjs && node --check scripts/src/report-paths.mjs && node --check internal/protocol/index.mjs && node --check internal/build-metadata.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/cloud-web-routes.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.test.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/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/code-agent-trace-store.mjs && node --check internal/cloud/codex-stdio-session.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-cloud-api/provision.mjs && node --check cmd/hwlab-cloud-api/migrate.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/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-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.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/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.test.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/dev-runtime-base-image.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/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.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/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.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 skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check tools/hwlab-cli/lib/cicd-jobs.mjs && node --check tools/hwlab-cli/lib/cli.test.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/code-agent-facts.mjs && node --check web/hwlab-cloud-web/code-agent-facts.test.mjs && node --check web/hwlab-cloud-web/code-agent-status.mjs && node --check web/hwlab-cloud-web/code-agent-status.test.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.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/live-status-contract.test.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-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node cmd/hwlab-cloud-api/provision.mjs --check && node cmd/hwlab-cloud-api/migrate.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && 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 web/hwlab-cloud-web/code-agent-facts.test.mjs web/hwlab-cloud-web/code-agent-status.test.mjs web/hwlab-cloud-web/wiring-status.test.mjs web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs web/hwlab-cloud-web/scripts/live-status-contract.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/refresh-artifact-catalog.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs tools/hwlab-cli/lib/cli.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/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/cloud-web-proxy.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", "dev-runtime-base:build": "node scripts/dev-runtime-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index 604ee551..3370721b 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -319,12 +319,12 @@ async function resolveServices(serviceIds, catalog) { function runtimeScriptBase64() { const source = String.raw` import { createServer } from "node:http"; -import http from "node:http"; import { spawn } from "node:child_process"; import { randomBytes, timingSafeEqual } from "node:crypto"; import { chmodSync, existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs"; import { readFile, stat } from "node:fs/promises"; import path from "node:path"; +import { isCloudWebSseRoute, proxyCloudApiRequest } from "/app/internal/dev-entrypoint/cloud-web-proxy.mjs"; import { cloudWebProxyRoutePolicy } from "/app/internal/dev-entrypoint/cloud-web-routes.mjs"; const serviceId = process.env.HWLAB_SERVICE_ID || "hwlab-unknown"; @@ -592,57 +592,20 @@ async function readRequestBody(request) { return body; } -function copyHeaders(headers) { - const result = {}; - for (const [key, value] of Object.entries(headers)) { - if (value === undefined) continue; - if (key.toLowerCase() === "transfer-encoding") continue; - if (key.toLowerCase() === "content-length") continue; - result[key] = value; - } - 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, - { - method: request.method, - headers: { - accept: request.headers.accept || "application/json", - "content-type": request.headers["content-type"] || "application/json", - "content-length": Buffer.byteLength(body) - } - }, - (upstreamResponse) => { - const chunks = []; - upstreamResponse.on("data", (chunk) => chunks.push(chunk)); - upstreamResponse.on("end", () => { - clearTimeout(timeout); - resolve({ - statusCode: upstreamResponse.statusCode || 502, - headers: upstreamResponse.headers, - body: Buffer.concat(chunks) - }); - }); - } - ); - upstream.on("error", (error) => { - clearTimeout(timeout); - reject(error); - }); - if (body) upstream.write(body); - upstream.end(); +function requestUpstream(target, request, response, body, url) { + return proxyCloudApiRequest({ + target, + request, + response, + body, + timeoutMs: cloudApiProxyTimeoutMs, + forceStream: isCloudWebSseRoute(url.pathname) }); } @@ -684,14 +647,9 @@ async function proxyCloudApi(request, response, url) { const target = new URL(url.pathname + url.search, cloudApiBaseUrl); try { - const upstream = await requestUpstream(target, request, request.method === "GET" ? "" : body); - response.writeHead(upstream.statusCode, { - ...copyHeaders(upstream.headers), - "content-length": upstream.body.length - }); - response.end(upstream.body); + await requestUpstream(target, request, response, request.method === "GET" ? "" : body, url); } catch (error) { - const timedOut = /timed out after \d+ms/iu.test(error.message); + 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"; const traceId = request.headers["x-trace-id"] || null; diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 7295f63f..769183eb 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -4383,6 +4383,9 @@ async function inspectLegacyFailureWindow(page, { beforeMessageCount }) { contained: panel.scrollWidth <= panel.clientWidth + 2 })) : []; + const pendingTraceEvents = latestAgent + ? [...latestAgent.querySelectorAll(".message-trace-events li")].map((item) => item.textContent?.replace(/\s+/gu, " ").trim() ?? "") + : []; const latestAgentBox = latestAgent?.getBoundingClientRect(); const pendingStillRunning = latestAgent?.classList.contains("status-running") === true || @@ -4398,6 +4401,7 @@ async function inspectLegacyFailureWindow(page, { beforeMessageCount }) { runningMessageVisible: Boolean(latestAgent?.classList.contains("status-running")), pendingChineseVisible: /正在处理|后端处理中|不会把旧 4500ms|结构化 blocker|真实超时/u.test(pendingText), pendingTraceVisible: /traceId\s*trc_|trace=trc_|trc_/u.test(pendingText), + pendingTraceShowsEvents: pendingTraceEvents.some((text) => /#\d+|session|prompt|tool|assistant/u.test(text)), pendingConversationVisible: /conversation\s*cnv_|conversation=cnv_|cnv_/u.test(pendingText), pendingSessionVisible: /session\s*(等待后端分配|cnv_|ses_)/u.test(pendingText), pendingTraceCopyVisible: pendingRows.some((row) => /traceId/u.test(row.text) && /复制/u.test(row.text)), @@ -4789,6 +4793,7 @@ async function runLocalAgentFixtureSmoke({ promptResults[0]?.classification?.blocker === "runner-blocked" && promptResults[0]?.legacyWindow?.permanentFailureAround4500ms === false && promptResults[0]?.legacyWindow?.pendingChineseVisible === true && + promptResults[0]?.legacyWindow?.pendingTraceShowsEvents === true && (agentFixtureMode === "external-network-blocker" ? ["能力未开放", "Runner 受阻", "服务受阻"].includes(ui.agentChatStatus) : ui.agentChatStatus === "Runner 受阻") && @@ -4811,6 +4816,7 @@ async function runLocalAgentFixtureSmoke({ result.legacyWindow?.runningMessageVisible === true && result.legacyWindow?.pendingChineseVisible === true && result.legacyWindow?.pendingTraceVisible === true && + result.legacyWindow?.pendingTraceShowsEvents === true && result.legacyWindow?.pendingConversationVisible === true && result.legacyWindow?.pendingSessionVisible === true && result.legacyWindow?.pendingTraceCopyVisible === true && @@ -5017,6 +5023,7 @@ async function inspectLocalAgentPendingViewport(browser, url, { width, height, r legacyWindow.runningMessageVisible === true && legacyWindow.pendingChineseVisible === true && legacyWindow.pendingTraceVisible === true && + legacyWindow.pendingTraceShowsEvents === true && legacyWindow.pendingConversationVisible === true && legacyWindow.pendingSessionVisible === true && legacyWindow.pendingTraceCopyVisible === true && @@ -5543,6 +5550,7 @@ async function inspectQuickPromptGeometry(page, viewport) { async function startStaticWebServer(options = {}) { const rootDir = path.resolve(options.rootDir ?? webRoot); const authFixtureSessions = new Set(); + const agentTraceStore = new Map(); const server = http.createServer(async (request, response) => { const url = new URL(request.url ?? "/", "http://127.0.0.1"); if (options.authFixture && await handleAuthFixtureApi({ request, response, url, authFixtureSessions })) { @@ -5554,7 +5562,7 @@ async function startStaticWebServer(options = {}) { if (options.quickPromptsFixture && await handleQuickPromptsFixtureApi({ request, response, url })) { return; } - if (options.agentFixture && await handleLocalAgentFixtureApi({ request, response, url, options })) { + if (options.agentFixture && await handleLocalAgentFixtureApi({ request, response, url, options, agentTraceStore })) { return; } if (options.liveBuildsFixture && request.method === "GET" && url.pathname === "/v1/live-builds") { @@ -5591,6 +5599,54 @@ async function startStaticWebServer(options = {}) { }; } +function startLocalAgentTrace(agentTraceStore, { traceId, conversationId, delayMs }) { + if (!agentTraceStore || agentTraceStore.has(traceId)) return; + const startedAt = Date.now(); + const events = []; + const append = (event) => { + const normalizedEvent = { + traceId, + ...event + }; + events.push(normalizedEvent); + agentTraceStore.set(traceId, localAgentTraceSnapshot({ + traceId, + conversationId, + startedAt, + events, + lastEvent: normalizedEvent + })); + }; + + append({ seq: 1, type: "session", stage: "created", status: "completed", label: "session:created" }); + const scheduledEvents = [ + [250, { seq: 2, type: "prompt", stage: "sent", status: "completed", label: "prompt:sent" }], + [900, { seq: 3, type: "runner", stage: "started", status: "running", label: "runner:started" }], + [1800, { seq: 4, type: "tool_call", stage: "tool_call", status: "running", label: "tool:fixture:running", toolName: "local.source.fixture" }], + [Math.min(Math.max(delayMs - 1200, 2400), 3400), { seq: 5, type: "assistant", stage: "waiting", status: "running", label: "assistant:waiting" }] + ]; + for (const [waitMs, event] of scheduledEvents) { + const timer = setTimeout(() => append(event), waitMs); + timer.unref?.(); + } +} + +function localAgentTraceSnapshot({ traceId, conversationId = null, startedAt = Date.now(), events = [], lastEvent = null }) { + return { + traceId, + runnerKind: "codex-mcp-stdio-runner", + sessionMode: "codex-mcp-stdio-long-lived", + sessionId: conversationId, + sessionStatus: events.length > 0 ? "running" : "pending", + status: events.length > 0 ? "running" : "pending", + elapsedMs: Math.max(0, Date.now() - startedAt), + waitingFor: events.length > 0 ? "fixture-response" : "fixture-trace", + events: [...events], + lastEvent, + updatedAt: new Date().toISOString() + }; +} + async function handleAuthFixtureApi({ request, response, url, authFixtureSessions }) { if (url.pathname === "/auth/session" && request.method === "GET") { const token = authFixtureCookie(request); @@ -6100,7 +6156,25 @@ function liveBuildsFixturePayload() { }; } -async function handleLocalAgentFixtureApi({ request, response, url, options = {} }) { +async function handleLocalAgentFixtureApi({ request, response, url, options = {}, agentTraceStore = null }) { + if (request.method === "GET" && url.pathname.startsWith("/v1/agent/chat/trace/")) { + const parts = url.pathname.split("/").filter(Boolean); + const traceId = decodeURIComponent(parts[4] ?? ""); + if (parts[5] === "stream") { + jsonResponse(response, 503, { + status: "failed", + error: { + code: "trace_stream_unavailable_for_fixture", + message: "fixture intentionally forces EventSource fallback to polling" + }, + traceId + }); + return true; + } + jsonResponse(response, 200, agentTraceStore?.get(traceId) ?? localAgentTraceSnapshot({ traceId })); + return true; + } + if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) { jsonResponse(response, 200, { serviceId: "hwlab-cloud-api", @@ -6234,10 +6308,15 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} } 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"); const traceId = stringOrFallback(body?.traceId, "trc_source_fixture_browser"); + startLocalAgentTrace(agentTraceStore, { + traceId, + conversationId, + delayMs: options.agentDelayMs ?? 0 + }); + await delay(options.agentDelayMs ?? 0); const normalizedMessage = String(body?.message ?? ""); const isSkillListPrompt = /列出你能使用的所有skill/u.test(normalizedMessage); const isExternalNetworkPrompt = /github|https?:\/\/|外网|公网|访问/u.test(normalizedMessage); diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 865962db..a299d089 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -18,6 +18,8 @@ import { const DEFAULT_API_TIMEOUT_MS = 4500; const DEFAULT_CODE_AGENT_TIMEOUT_MS = 180000; +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 }); const rpcReadMethods = Object.freeze([ @@ -866,24 +868,49 @@ function subscribeRunnerTrace(traceId, messageId) { if (typeof EventSource !== "function") return pollRunnerTrace(traceId, messageId); const existing = state.traceStreams.get(traceId); if (existing) existing(); + let stopped = false; + let fallbackStop = null; + let firstEventSeen = false; + let fallbackTimer = null; const source = new EventSource(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}/stream`); const close = () => { + stopped = true; + if (fallbackTimer) window.clearTimeout(fallbackTimer); source.close(); + if (fallbackStop) fallbackStop(); if (state.traceStreams.get(traceId) === close) state.traceStreams.delete(traceId); }; + const markEventSeen = () => { + firstEventSeen = true; + if (fallbackTimer) { + window.clearTimeout(fallbackTimer); + fallbackTimer = null; + } + }; + const startPolling = () => { + if (stopped || fallbackStop) return; + source.close(); + fallbackStop = pollRunnerTrace(traceId, messageId); + state.traceStreams.set(traceId, close); + }; + fallbackTimer = window.setTimeout(() => { + if (!firstEventSeen) startPolling(); + }, TRACE_STREAM_FALLBACK_MS); source.addEventListener("snapshot", (event) => { + markEventSeen(); updateMessageTrace(messageId, parseTraceEventData(event.data)); }); source.addEventListener("runnerTrace", (event) => { + markEventSeen(); const payload = parseTraceEventData(event.data); updateMessageTrace(messageId, payload?.snapshot ?? payload); }); source.addEventListener("heartbeat", (event) => { + markEventSeen(); updateMessageTrace(messageId, parseTraceEventData(event.data), { quiet: true }); }); source.onerror = () => { - close(); - pollRunnerTrace(traceId, messageId); + startPolling(); }; state.traceStreams.set(traceId, close); return close; @@ -894,12 +921,16 @@ function pollRunnerTrace(traceId, messageId) { let timer = null; const tick = async () => { if (stopped) return; - const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}`, { - timeoutMs: Math.min(API_TIMEOUT_MS, 3000), - timeoutName: "Code Agent trace" - }); - if (response.ok) updateMessageTrace(messageId, response.data); - if (!stopped) timer = window.setTimeout(tick, 1000); + try { + const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}`, { + timeoutMs: Math.min(API_TIMEOUT_MS, 3000), + timeoutName: "Code Agent trace" + }); + if (response.ok) updateMessageTrace(messageId, response.data); + } catch { + // Keep polling; the main /v1/agent/chat request owns terminal error handling. + } + if (!stopped) timer = window.setTimeout(tick, TRACE_POLL_INTERVAL_MS); }; tick(); const stop = () => { diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 08d5a565..d1018223 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -65,6 +65,7 @@ const distContractScript = fs.readFileSync(path.resolve(rootDir, "scripts/dist-c const markedLicense = fs.readFileSync(path.resolve(rootDir, "third_party/marked/LICENSE"), "utf8"); 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 frontendSource = `${html}\n${auth}\n${app}\n${artifactPublisher}`; const requiredTrustedRecordTerms = Object.freeze([ "Code Agent 对话记录", @@ -777,6 +778,9 @@ assert.match(app, /function runnerTraceHeadline/); assert.match(app, /function traceEventMeta/); assert.match(app, /function subscribeRunnerTrace/); assert.match(app, /function pollRunnerTrace/); +assert.match(app, /TRACE_STREAM_FALLBACK_MS\s*=\s*2500/); +assert.match(app, /fallbackStop\s*=\s*pollRunnerTrace\(traceId,\s*messageId\)/); +assert.match(app, /TRACE_POLL_INTERVAL_MS/); assert.match(app, /function updateMessageTrace/); assert.match(app, /function runnerTraceFromSnapshot/); assert.match(app, /new EventSource\(`\/v1\/agent\/chat\/trace\/\$\{encodeURIComponent\(traceId\)\}\/stream`\)/); @@ -977,11 +981,16 @@ 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(cloudWebProxy, /setTimeout\(\(\) => \{/); +assert.match(cloudWebProxy, /cloud api proxy timed out after/); assert.match(artifactPublisher, /readOnlyRpcMethods/); assert.match(artifactPublisher, /requestUpstream/); -assert.match(artifactPublisher, /from "node:http"/); +assert.match(artifactPublisher, /proxyCloudApiRequest/); +assert.match(artifactPublisher, /isCloudWebSseRoute\(url\.pathname\)/); +assert.match(cloudWebProxy, /text\\\/event-stream/); +assert.match(cloudWebProxy, /x-accel-buffering/); +assert.match(cloudWebProxy, /flushHeaders/); +assert.match(cloudWebProxy, /response\.write\(chunk\)/); assert.doesNotMatch(artifactPublisher, /await fetch\(target/); assert.match(artifactPublisher, /cloudWebProxyRoutePolicy\(request\.method,\s*url\.pathname\)/); assert.match(cloudWebRoutes, /"\/v1\/agent\/chat"/);