fix: stream cloud web trace SSE
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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"/);
|
||||
|
||||
Reference in New Issue
Block a user