Merge pull request #1411 from pikasTech/issue-1396-workbench-backend-perf-p3

#1392/P3 后端性能指标与 Server-Timing
This commit is contained in:
Lyon
2026-06-17 18:23:25 +08:00
committed by GitHub
7 changed files with 577 additions and 34 deletions
+111
View File
@@ -0,0 +1,111 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
// Verifies Cloud API backend RED metrics, Server-Timing, and AgentRun upstream timing privacy.
import assert from "node:assert/strict";
import { createServer as createHttpServer } from "node:http";
import { test } from "bun:test";
import { createBackendPerformanceStore, backendPerformanceRouteTemplate, withBackendPerformanceContext } from "./backend-performance.ts";
import { submitAgentRunChatTurn } from "./code-agent-agentrun-adapter.ts";
import { createCodeAgentTraceStore } from "./code-agent-trace-store.ts";
import { createCloudApiServer } from "./server.ts";
test("backend performance store templates routes and appends Cloud API metrics to loopback metrics", async () => {
assert.equal(backendPerformanceRouteTemplate("/v1/agent/traces/trc_secret_0123456789abcdef?full=1"), "/v1/agent/traces/:id");
assert.equal(backendPerformanceRouteTemplate("/api/v1/runs/run_0123456789abcdef/commands/cmd_0123456789abcdef/result"), "/api/v1/runs/:id/commands/:id/result");
assert.equal(backendPerformanceRouteTemplate("/v1/admin/billing/users/12345/credits/adjust"), "/v1/admin/billing/users/:id/credits/adjust");
const backendPerformanceStore = createBackendPerformanceStore({ env: { POD_NAMESPACE: "hwlab-v03", HWLAB_GITOPS_TARGET: "v03" } });
const server = createCloudApiServer({ backendPerformanceStore, env: { POD_NAMESPACE: "hwlab-v03", HWLAB_GITOPS_TARGET: "v03" } });
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const address = server.address();
assert.ok(address && typeof address === "object");
const baseUrl = `http://127.0.0.1:${address.port}`;
const summary = await fetch(`${baseUrl}/v1/web-performance/summary`);
assert.equal(summary.status, 200);
assert.match(summary.headers.get("server-timing") ?? "", /total;dur=/u);
const metrics = await fetch(`${baseUrl}/v1/web-performance/metrics`);
const text = await metrics.text();
assert.equal(metrics.status, 200);
assert.match(text, /hwlab_cloud_api_requests_total\{[^}]*route="\/v1\/web-performance\/summary"[^}]*status_class="2xx"[^}]*\} 1/u);
assert.match(text, /hwlab_cloud_api_request_duration_seconds_count\{[^}]*route="\/v1\/web-performance\/summary"/u);
assert.doesNotMatch(text, /trc_secret|0123456789abcdef/u);
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
test("AgentRun adapter records upstream timing through backend performance context", async () => {
const calls: Array<{ method?: string; path: string; body?: any }> = [];
const agentRunServer = createHttpServer(async (request, response) => {
const url = new URL(request.url || "/", "http://127.0.0.1");
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null;
calls.push({ method: request.method, path: url.pathname, body });
const send = (data: Record<string, unknown>) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(`${JSON.stringify({ ok: true, data, traceId: "trc_backend_perf" })}\n`);
};
if (request.method === "POST" && url.pathname === "/api/v1/sessions") return send({ ok: true });
if (request.method === "POST" && url.pathname === "/api/v1/runs") return send({ id: "run_backend_perf", status: "pending", backendProfile: "codex", sessionRef: body.sessionRef, resourceBundleRef: body.resourceBundleRef });
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_backend_perf/commands") return send({ id: "cmd_backend_perf", runId: "run_backend_perf", state: "pending", type: "turn", seq: 1 });
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_backend_perf/runner-jobs") return send({ action: "create-kubernetes-job", runId: "run_backend_perf", commandId: body.commandId, attemptId: "attempt_backend_perf", runnerId: "runner_backend_perf", namespace: "agentrun-v01", jobName: "agentrun-v01-runner-backend-perf" });
response.writeHead(404, { "content-type": "application/json" });
response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`);
});
await new Promise<void>((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve));
try {
const address = agentRunServer.address();
assert.ok(address && typeof address === "object");
const store = createBackendPerformanceStore({ env: { POD_NAMESPACE: "hwlab-v03", HWLAB_GITOPS_TARGET: "v03" } });
const context = store.createManualContext({ route: "/v1/agent/chat", method: "POST" });
const traceStore = createCodeAgentTraceStore();
const result = await withBackendPerformanceContext(context, () => submitAgentRunChatTurn({
traceId: "trc_backend_perf",
traceStore,
params: {
message: "backend perf smoke",
ownerUserId: "usr_backend_perf",
projectId: "prj_hwpod_workbench",
conversationId: "cnv_backend_perf",
sessionId: "ses_backend_perf",
providerProfile: "codex-api"
},
options: {
env: {
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
AGENTRUN_MGR_URL: `http://127.0.0.1:${address.port}`,
HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1",
HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "D601",
HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567",
HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "codex-api",
HWLAB_RUNTIME_API_URL: "http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667",
HWLAB_RUNTIME_WEB_URL: "http://hwlab-cloud-web.hwlab-v03.svc.cluster.local:8080",
HWLAB_RUNTIME_NAMESPACE: "hwlab-v03",
HWLAB_RUNTIME_LANE: "v03",
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1",
HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1",
UNIDESK_MAIN_SERVER_IP: "http://74.48.78.17:18081"
},
accessController: {
async codeAgentToolCapabilitiesForOwner() { return { contractVersion: "admin-access-v1", tools: {} }; },
store: { async findActiveDefaultApiKeyForUser() { return { displaySecret: "hwl_live_owner_secret" }; } }
}
}
}));
assert.equal(result.status, "running");
assert.ok(calls.some((call) => call.path === "/api/v1/runs/run_backend_perf/runner-jobs"));
const text = store.metricsText();
assert.match(text, /hwlab_cloud_api_upstream_duration_seconds_count\{[^}]*component="agentrun"[^}]*route="\/api\/v1\/runs"[^}]*\} 1/u);
assert.match(text, /hwlab_cloud_api_upstream_duration_seconds_count\{[^}]*component="agentrun"[^}]*route="\/api\/v1\/runs\/:id\/commands"[^}]*\} 1/u);
assert.match(text, /hwlab_cloud_api_upstream_duration_seconds_count\{[^}]*component="agentrun"[^}]*route="\/api\/v1\/runs\/:id\/runner-jobs"[^}]*\} 1/u);
assert.doesNotMatch(text, /run_backend_perf|cmd_backend_perf|ses_backend_perf|cnv_backend_perf/u);
} finally {
await new Promise<void>((resolve, reject) => agentRunServer.close((error) => error ? reject(error) : resolve()));
}
});
+357
View File
@@ -0,0 +1,357 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
// Records low-cardinality Cloud API backend performance metrics and Server-Timing phases.
import { AsyncLocalStorage } from "node:async_hooks";
const REQUEST_BUCKETS_SECONDS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60];
const PHASE_BUCKETS_SECONDS = [0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30];
const UPSTREAM_BUCKETS_SECONDS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120];
const MAX_SERVER_TIMING_ENTRIES = 8;
const backendPerformanceStorage = new AsyncLocalStorage();
interface HistogramSeries {
labels: Record<string, string>;
buckets: number[];
sum: number;
count: number;
}
interface CounterSeries {
labels: Record<string, string>;
value: number;
}
interface BackendPerformanceStoreOptions {
env?: Record<string, unknown>;
maxSeries?: number;
}
interface BackendPerformanceContextInput {
route: string;
method: string;
}
interface BackendPhaseInput {
phase: string;
durationMs: number;
outcome?: string;
}
interface BackendUpstreamInput {
component: string;
operation?: string;
route: string;
method: string;
statusClass?: string;
outcome?: string;
durationMs: number;
}
export function createBackendPerformanceStore(options: BackendPerformanceStoreOptions = {}) {
const env = options.env ?? process.env;
const maxSeries = parsePositiveInteger(env.HWLAB_BACKEND_PERFORMANCE_MAX_SERIES, options.maxSeries ?? 320);
const requestDurationSeries = new Map<string, HistogramSeries>();
const requestTotalSeries = new Map<string, CounterSeries>();
const requestPhaseSeries = new Map<string, HistogramSeries>();
const upstreamSeries = new Map<string, HistogramSeries>();
const droppedSeries = new Map<string, CounterSeries>();
const baseLabels = {
service: "hwlab-cloud-api",
namespace: sanitizeLabelValue(env.POD_NAMESPACE ?? env.HWLAB_METRICS_NAMESPACE ?? "hwlab-v02"),
gitops_target: sanitizeLabelValue(env.HWLAB_GITOPS_TARGET ?? env.HWLAB_GITOPS_PROFILE ?? env.HWLAB_RUNTIME_LANE ?? "v02")
};
function beginHttpRequest(request: any, response: any) {
const url = safeUrl(request?.url || "/");
const context = createContext({ route: backendPerformanceRouteTemplate(url.pathname), method: normalizeMethod(request?.method) });
const originalWriteHead = typeof response?.writeHead === "function" ? response.writeHead : null;
if (originalWriteHead) {
response.writeHead = function writeHeadWithServerTiming(statusCode: number, ...args: unknown[]) {
context.setStatus(statusCode);
context.applyServerTimingHeader(response);
return originalWriteHead.call(this, statusCode, ...args);
};
}
if (typeof response?.once === "function") {
response.once("finish", () => context.finish(Number(response.statusCode ?? context.statusCode ?? 0)));
}
return context;
}
function createManualContext(input: BackendPerformanceContextInput) {
return createContext({ route: backendPerformanceRouteTemplate(input.route), method: normalizeMethod(input.method) });
}
function createContext(input: BackendPerformanceContextInput) {
const startedAt = nowMs();
const phases: Array<{ name: string; durationMs: number; outcome: string }> = [];
let statusCode = 200;
let finished = false;
const route = input.route;
const method = input.method;
function recordPhase(inputPhase: BackendPhaseInput) {
const durationMs = boundedDurationMs(inputPhase.durationMs, 30_000);
const phase = sanitizeMetricName(inputPhase.phase, "unknown");
const outcome = normalizeOutcome(inputPhase.outcome ?? "ok");
phases.push({ name: phase, durationMs, outcome });
const labels = { ...baseLabels, route, method, phase, outcome };
if (!recordHistogram(requestPhaseSeries, labels, durationMs / 1000, PHASE_BUCKETS_SECONDS, maxSeries)) incrementDropped("phase_series_limit");
}
async function measure<T>(phase: string, fn: () => Promise<T> | T): Promise<T> {
const phaseStartedAt = nowMs();
try {
const result = await fn();
recordPhase({ phase, durationMs: nowMs() - phaseStartedAt, outcome: "ok" });
return result;
} catch (error) {
recordPhase({ phase, durationMs: nowMs() - phaseStartedAt, outcome: "error" });
throw error;
}
}
function recordUpstream(inputUpstream: BackendUpstreamInput) {
const labels = {
...baseLabels,
component: sanitizeMetricName(inputUpstream.component, "unknown"),
operation: sanitizeMetricName(inputUpstream.operation ?? inputUpstream.route, "unknown"),
route: backendPerformanceRouteTemplate(inputUpstream.route),
method: normalizeMethod(inputUpstream.method),
status_class: normalizeStatusClass(inputUpstream.statusClass ?? "unknown"),
outcome: normalizeOutcome(inputUpstream.outcome ?? "ok")
};
if (!recordHistogram(upstreamSeries, labels, boundedDurationMs(inputUpstream.durationMs, 120_000) / 1000, UPSTREAM_BUCKETS_SECONDS, maxSeries)) incrementDropped("upstream_series_limit");
}
function setStatus(nextStatusCode: number) {
if (Number.isInteger(nextStatusCode) && nextStatusCode > 0) statusCode = nextStatusCode;
}
function applyServerTimingHeader(response: any) {
if (!response || typeof response.setHeader !== "function" || response.headersSent) return;
const header = serverTimingHeader(nowMs() - startedAt, phases);
if (header) response.setHeader("server-timing", header);
}
function finish(finalStatusCode = statusCode) {
if (finished) return;
finished = true;
const statusClassValue = statusClass(finalStatusCode);
const outcome = statusClassValue === "5xx" ? "error" : statusClassValue === "4xx" ? "denied" : "ok";
const labels = { ...baseLabels, route, method, status_class: statusClassValue, outcome };
if (!recordHistogram(requestDurationSeries, labels, boundedDurationMs(nowMs() - startedAt, 120_000) / 1000, REQUEST_BUCKETS_SECONDS, maxSeries)) incrementDropped("request_series_limit");
incrementCounter(requestTotalSeries, labels, maxSeries) || incrementDropped("request_counter_series_limit");
}
return { route, method, startedAt, recordPhase, measure, recordUpstream, setStatus, applyServerTimingHeader, finish, get statusCode() { return statusCode; } };
}
function incrementDropped(reason: string) {
incrementCounter(droppedSeries, { ...baseLabels, reason }, Math.max(maxSeries, 16));
}
function metricsText() {
return [
"# HELP hwlab_cloud_api_requests_total Cloud API HTTP requests by low-cardinality route template.",
"# TYPE hwlab_cloud_api_requests_total counter",
...renderCounters("hwlab_cloud_api_requests_total", requestTotalSeries),
"# HELP hwlab_cloud_api_request_duration_seconds Cloud API HTTP request duration in seconds.",
"# TYPE hwlab_cloud_api_request_duration_seconds histogram",
...renderHistogram("hwlab_cloud_api_request_duration_seconds", requestDurationSeries, REQUEST_BUCKETS_SECONDS),
"# HELP hwlab_cloud_api_request_phase_duration_seconds Cloud API handler phase duration in seconds.",
"# TYPE hwlab_cloud_api_request_phase_duration_seconds histogram",
...renderHistogram("hwlab_cloud_api_request_phase_duration_seconds", requestPhaseSeries, PHASE_BUCKETS_SECONDS),
"# HELP hwlab_cloud_api_upstream_duration_seconds Cloud API upstream dependency call duration in seconds.",
"# TYPE hwlab_cloud_api_upstream_duration_seconds histogram",
...renderHistogram("hwlab_cloud_api_upstream_duration_seconds", upstreamSeries, UPSTREAM_BUCKETS_SECONDS),
"# HELP hwlab_cloud_api_performance_dropped_total Backend performance samples dropped before Prometheus export.",
"# TYPE hwlab_cloud_api_performance_dropped_total counter",
...renderCounters("hwlab_cloud_api_performance_dropped_total", droppedSeries),
""
].join("\n");
}
function snapshot() {
return {
requestSeries: requestDurationSeries.size,
requestCount: [...requestTotalSeries.values()].reduce((sum, entry) => sum + entry.value, 0),
requestPhaseSeries: requestPhaseSeries.size,
requestPhaseCount: histogramSampleCount(requestPhaseSeries),
upstreamSeries: upstreamSeries.size,
upstreamCount: histogramSampleCount(upstreamSeries),
droppedSeries: droppedSeries.size
};
}
return { beginHttpRequest, createManualContext, metricsText, snapshot };
}
export function withBackendPerformanceContext<T>(context: unknown, callback: () => T): T {
return backendPerformanceStorage.run(context, callback);
}
export function currentBackendPerformanceContext(): any {
return backendPerformanceStorage.getStore() ?? null;
}
export function backendPerformanceRouteTemplate(input: unknown): string {
const raw = String(input ?? "").trim() || "/";
const pathOnly = raw.split(/[?#]/u, 1)[0] || "/";
let pathname = pathOnly;
try {
pathname = new URL(raw, "http://hwlab.local").pathname || pathOnly;
} catch {
// Keep pathOnly.
}
const parts = pathname.split("/").filter(Boolean).map((part) => routeSegmentTemplate(safeDecode(part)));
const path = `/${parts.join("/")}`;
return path.length > 140 ? `${path.slice(0, 137)}...` : path;
}
function serverTimingHeader(totalMs: number, phases: Array<{ name: string; durationMs: number }>) {
const entries = phases.slice(-MAX_SERVER_TIMING_ENTRIES).map((phase) => `${serverTimingToken(phase.name)};dur=${roundDurationMs(phase.durationMs)}`);
entries.push(`total;dur=${roundDurationMs(totalMs)}`);
return entries.join(", ");
}
function recordHistogram(series: Map<string, HistogramSeries>, labels: Record<string, string>, value: number, buckets: number[], maxSeries: number) {
const key = stableLabelKey(labels);
let entry = series.get(key);
if (!entry) {
if (series.size >= maxSeries) return false;
entry = { labels, buckets: buckets.map(() => 0), sum: 0, count: 0 };
series.set(key, entry);
}
for (let index = 0; index < buckets.length; index += 1) {
if (value <= buckets[index]) entry.buckets[index] += 1;
}
entry.sum += value;
entry.count += 1;
return true;
}
function incrementCounter(series: Map<string, CounterSeries>, labels: Record<string, string>, maxSeries: number) {
const key = stableLabelKey(labels);
let entry = series.get(key);
if (!entry) {
if (series.size >= maxSeries) return false;
entry = { labels, value: 0 };
series.set(key, entry);
}
entry.value += 1;
return true;
}
function renderCounters(metricName: string, series: Map<string, CounterSeries>) {
return [...series.values()].map((entry) => `${metricName}{${renderLabels(entry.labels)}} ${entry.value}`);
}
function renderHistogram(metricName: string, series: Map<string, HistogramSeries>, buckets: number[]) {
const lines: string[] = [];
for (const entry of series.values()) {
for (let index = 0; index < buckets.length; index += 1) {
lines.push(`${metricName}_bucket{${renderLabels({ ...entry.labels, le: String(buckets[index]) })}} ${entry.buckets[index]}`);
}
lines.push(`${metricName}_bucket{${renderLabels({ ...entry.labels, le: "+Inf" })}} ${entry.count}`);
lines.push(`${metricName}_sum{${renderLabels(entry.labels)}} ${entry.sum.toFixed(6)}`);
lines.push(`${metricName}_count{${renderLabels(entry.labels)}} ${entry.count}`);
}
return lines;
}
function histogramSampleCount(series: Map<string, HistogramSeries>) {
return [...series.values()].reduce((sum, entry) => sum + entry.count, 0);
}
function renderLabels(labels: Record<string, string>) {
return Object.entries(labels).map(([key, value]) => `${key}="${escapeLabelValue(value)}"`).join(",");
}
function stableLabelKey(labels: Record<string, string>) {
return Object.keys(labels).sort().map((key) => `${key}=${labels[key]}`).join("\u0000");
}
function routeSegmentTemplate(segment: string) {
if (/^\d+$/u.test(segment)) return ":id";
if (/^[a-z][a-z0-9]{1,12}_[A-Za-z0-9_.:-]+$/iu.test(segment)) return ":id";
if (/^[0-9a-f]{12,}$/iu.test(segment)) return ":id";
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(segment)) return ":id";
if (segment.length > 48) return ":id";
return sanitizePathSegment(segment);
}
function safeUrl(value: string) {
try { return new URL(value, "http://hwlab-cloud-api.local"); } catch { return new URL("/", "http://hwlab-cloud-api.local"); }
}
function safeDecode(value: string) {
try { return decodeURIComponent(value); } catch { return value; }
}
function sanitizePathSegment(value: string) {
const sanitized = value.replace(/[^A-Za-z0-9_.:-]/gu, "_");
return sanitized.slice(0, 64) || "_";
}
function normalizeMethod(value: unknown) {
const method = String(value ?? "GET").trim().toUpperCase();
return /^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)$/u.test(method) ? method : "OTHER";
}
function statusClass(value: unknown) {
const status = Number(value);
if (!Number.isFinite(status) || status <= 0) return "unknown";
return `${Math.floor(status / 100)}xx`;
}
function normalizeStatusClass(value: unknown) {
const text = String(value ?? "").trim().toLowerCase();
return /^(?:1xx|2xx|3xx|4xx|5xx|network|timeout|unknown)$/u.test(text) ? text : "unknown";
}
function normalizeOutcome(value: unknown) {
const text = sanitizeMetricName(value, "unknown");
return /^(?:ok|error|timeout|denied|network|cancelled|canceled|unknown)$/u.test(text) ? text : "unknown";
}
function sanitizeMetricName(value: unknown, fallback = "unknown") {
const text = String(value ?? "").trim().toLowerCase().replace(/[^a-z0-9_:-]+/gu, "_").replace(/[-:]+/gu, "_").replace(/^_+|_+$/gu, "");
return text || fallback;
}
function sanitizeLabelValue(value: unknown, fallback = "unknown") {
const text = String(value ?? "").trim();
if (!text) return fallback;
return text.replace(/[\n\r\t]/gu, " ").slice(0, 120);
}
function escapeLabelValue(value: string) {
return String(value).replace(/\\/gu, "\\\\").replace(/"/gu, "\\\"").replace(/\n/gu, "\\n");
}
function parsePositiveInteger(value: unknown, fallback: number) {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function boundedDurationMs(value: unknown, maxMs: number) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return 0;
return Math.min(parsed, maxMs);
}
function roundDurationMs(value: number) {
return Math.max(0, value).toFixed(1);
}
function serverTimingToken(value: string) {
const token = sanitizeMetricName(value, "phase").replace(/_/gu, "-");
return token || "phase";
}
function nowMs() {
if (typeof performance !== "undefined" && typeof performance.now === "function") return performance.now();
return Date.now();
}
@@ -1,3 +1,6 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// Responsibility: AgentRun v0.1 adapter and upstream timing projection for Cloud API observability.
import { createHash, randomUUID } from "node:crypto";
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts";
@@ -11,6 +14,7 @@ import {
text,
truthyFlag
} from "./server-http-utils.ts";
import { backendPerformanceRouteTemplate, currentBackendPerformanceContext } from "./backend-performance.ts";
const ADAPTER_ID = "agentrun-v01";
const DEFAULT_AGENTRUN_MGR_URL = "http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080";
@@ -1968,6 +1972,10 @@ function agentRunCommandExecutionEvent(base, payload = {}) {
async function agentRunJson(fetchImpl, managerUrl, path, { method = "GET", body, timeoutMs = 20_000, env = process.env } = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const performanceContext = currentBackendPerformanceContext();
const startedAt = nowMs();
let statusCode = 0;
let outcome = "ok";
try {
const headers: Record<string, string> = {};
const apiKey = resolveAgentRunApiKey(env);
@@ -1979,9 +1987,11 @@ async function agentRunJson(fetchImpl, managerUrl, path, { method = "GET", body,
body: body === undefined ? undefined : JSON.stringify(body),
signal: controller.signal
});
statusCode = Number(response.status ?? 0);
const text = await response.text();
const parsed = text ? JSON.parse(text) : {};
if (!response.ok || parsed?.ok === false) {
outcome = "error";
throw Object.assign(new Error(parsed?.message ?? `AgentRun ${method} ${path} failed with HTTP ${response.status}`), {
code: parsed?.failureKind ?? "agentrun_request_failed",
statusCode: response.status,
@@ -1991,14 +2001,42 @@ async function agentRunJson(fetchImpl, managerUrl, path, { method = "GET", body,
return parsed?.ok === true && Object.hasOwn(parsed, "data") ? parsed.data : parsed;
} catch (error) {
if (error?.name === "AbortError") {
outcome = "timeout";
statusCode = 504;
throw Object.assign(new Error(`AgentRun ${method} ${path} timed out after ${timeoutMs}ms`), agentRunHttpErrorContext({ managerUrl, path, method, timeoutMs, code: "agentrun_timeout", statusCode: 504, category: "upstream-timeout" }));
}
if (outcome === "ok") outcome = "error";
throw error;
} finally {
clearTimeout(timeout);
performanceContext?.recordUpstream?.({
component: "agentrun",
operation: agentRunOperationName(method, path),
route: backendPerformanceRouteTemplate(path),
method,
statusClass: statusClass(statusCode),
outcome,
durationMs: nowMs() - startedAt
});
}
}
function agentRunOperationName(method, path) {
const route = backendPerformanceRouteTemplate(path).replace(/^\/api\/v1\//u, "").replace(/\/:id/gu, "");
return `${String(method ?? "GET").toLowerCase()}_${route || "root"}`.replace(/[^a-z0-9]+/giu, "_").replace(/^_+|_+$/gu, "").toLowerCase();
}
function statusClass(value) {
const status = Number(value);
if (!Number.isFinite(status) || status <= 0) return "unknown";
return `${Math.floor(status / 100)}xx`;
}
function nowMs() {
if (typeof performance !== "undefined" && typeof performance.now === "function") return performance.now();
return Date.now();
}
function agentRunHttpErrorContext({ managerUrl, path, method, timeoutMs, code, statusCode, category }) {
return {
code,
+10 -8
View File
@@ -1,4 +1,4 @@
// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-0102 Agent编排 draft-2026-06-17-r0.
// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-0102 Agent编排 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// Responsibility: Code Agent HTTP turn admission, lifecycle compatibility wrappers, and session evidence projection.
import { createHash, randomUUID } from "node:crypto";
@@ -52,7 +52,8 @@ const DEFAULT_CODE_AGENT_DEEPSEEK_MODEL = "deepseek-chat";
const DEFAULT_CODE_AGENT_MINIMAX_M3_MODEL = "MiniMax-M3";
export async function handleCodeAgentChatHttp(request, response, options) {
const body = await readBody(request, options.bodyLimitBytes);
const perf = options.backendPerformance;
const body = perf ? await perf.measure("body_read", () => readBody(request, options.bodyLimitBytes)) : await readBody(request, options.bodyLimitBytes);
let params = {};
try {
@@ -95,15 +96,16 @@ export async function handleCodeAgentChatHttp(request, response, options) {
ownerUserId: options.actor?.id ?? params.ownerUserId,
ownerRole: options.actor?.role ?? params.ownerRole
};
const manualSession = await prepareManualCodeAgentSession({ params: chatParams, options, traceId, response });
const manualSession = perf ? await perf.measure("manual_session", () => prepareManualCodeAgentSession({ params: chatParams, options, traceId, response })) : await prepareManualCodeAgentSession({ params: chatParams, options, traceId, response });
if (manualSession?.blocked) return;
const workspaceClaim = await claimWorkbenchWorkspaceTurn({ params: chatParams, options, traceId, response });
const workspaceClaim = perf ? await perf.measure("workspace_claim", () => claimWorkbenchWorkspaceTurn({ params: chatParams, options, traceId, response })) : await claimWorkbenchWorkspaceTurn({ params: chatParams, options, traceId, response });
if (workspaceClaim?.blocked) return;
const billingPreflight = await preflightCodeAgentBilling({ params: chatParams, options, traceId, response });
const billingPreflight = perf ? await perf.measure("billing_preflight", () => preflightCodeAgentBilling({ params: chatParams, options, traceId, response })) : await preflightCodeAgentBilling({ params: chatParams, options, traceId, response });
if (billingPreflight?.blocked) return;
const nativeSessionChatParams = stripSyntheticConversationContext({ ...chatParams, userBillingReservation: billingPreflight?.reservation ?? null }, traceId, options);
if (codeAgentChatShortConnectionRequested(request, params, options)) {
if (perf) perf.recordPhase({ phase: "short_connection_accept", durationMs: 0, outcome: "ok" });
submitCodeAgentChatTurn({
params: nativeSessionChatParams,
options,
@@ -136,9 +138,9 @@ export async function handleCodeAgentChatHttp(request, response, options) {
return;
}
const payload = await runCodeAgentChat(nativeSessionChatParams, options);
await finalizeCodeAgentBillingUsage({ payload, params: nativeSessionChatParams, options });
await recordCodeAgentSessionOwner({ payload, params: nativeSessionChatParams, options, status: payload.status === "completed" ? "active" : payload.status });
const payload = perf ? await perf.measure("code_agent_run", () => runCodeAgentChat(nativeSessionChatParams, options)) : await runCodeAgentChat(nativeSessionChatParams, options);
await (perf ? perf.measure("billing_finalize", () => finalizeCodeAgentBillingUsage({ payload, params: nativeSessionChatParams, options })) : finalizeCodeAgentBillingUsage({ payload, params: nativeSessionChatParams, options }));
await (perf ? perf.measure("session_owner_record", () => recordCodeAgentSessionOwner({ payload, params: nativeSessionChatParams, options, status: payload.status === "completed" ? "active" : payload.status })) : recordCodeAgentSessionOwner({ payload, params: nativeSessionChatParams, options, status: payload.status === "completed" ? "active" : payload.status }));
const responsePayload = annotateOwner(payload, nativeSessionChatParams);
sendJson(response, responsePayload.status === "failed" && responsePayload.error?.code === "invalid_params" ? 400 : 200, responsePayload);
+36 -11
View File
@@ -1,5 +1,5 @@
/*
* SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0
* SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
* 职责: Workbench REST read model session/message/turn/trace facts AgentRun syncbilling finalize workspace repair
*/
import { createHash } from "node:crypto";
@@ -22,40 +22,41 @@ const TERMINAL_STATUSES = new Set(["completed", "failed", "blocked", "timeout",
const RUNNING_STATUSES = new Set(["running", "pending", "queued", "accepted", "dispatching", "streaming", "active"]);
export async function handleWorkbenchReadModelHttp(request, response, url, options = {}) {
const auth = await authenticateWorkbenchRead(request, response, options);
const perf = options.backendPerformance;
const auth = perf ? await perf.measure("workbench_auth", () => authenticateWorkbenchRead(request, response, options)) : await authenticateWorkbenchRead(request, response, options);
if (!auth) return;
if (url.pathname === "/v1/workbench/sessions") {
if (request.method !== "GET") return methodNotAllowed(response, "GET");
await handleWorkbenchSessionList(response, url, options, auth.actor);
await (perf ? perf.measure("workbench_session_list", () => handleWorkbenchSessionList(response, url, options, auth.actor)) : handleWorkbenchSessionList(response, url, options, auth.actor));
return;
}
const sessionMessagesMatch = url.pathname.match(/^\/v1\/workbench\/sessions\/([^/]+)\/messages$/u);
if (sessionMessagesMatch) {
if (request.method !== "GET") return methodNotAllowed(response, "GET");
await handleWorkbenchMessagePage(response, url, options, auth.actor, decodeURIComponent(sessionMessagesMatch[1]));
await (perf ? perf.measure("workbench_message_page", () => handleWorkbenchMessagePage(response, url, options, auth.actor, decodeURIComponent(sessionMessagesMatch[1]))) : handleWorkbenchMessagePage(response, url, options, auth.actor, decodeURIComponent(sessionMessagesMatch[1])));
return;
}
const sessionMatch = url.pathname.match(/^\/v1\/workbench\/sessions\/([^/]+)$/u);
if (sessionMatch) {
if (request.method !== "GET") return methodNotAllowed(response, "GET");
await handleWorkbenchSessionDetail(response, options, auth.actor, decodeURIComponent(sessionMatch[1]));
await (perf ? perf.measure("workbench_session_detail", () => handleWorkbenchSessionDetail(response, options, auth.actor, decodeURIComponent(sessionMatch[1]))) : handleWorkbenchSessionDetail(response, options, auth.actor, decodeURIComponent(sessionMatch[1])));
return;
}
const turnMatch = url.pathname.match(/^\/v1\/workbench\/turns\/([^/]+)$/u);
if (turnMatch) {
if (request.method !== "GET") return methodNotAllowed(response, "GET");
await handleWorkbenchTurnSnapshot(response, url, options, auth.actor, decodeURIComponent(turnMatch[1]));
await (perf ? perf.measure("workbench_turn_snapshot", () => handleWorkbenchTurnSnapshot(response, url, options, auth.actor, decodeURIComponent(turnMatch[1]))) : handleWorkbenchTurnSnapshot(response, url, options, auth.actor, decodeURIComponent(turnMatch[1])));
return;
}
const traceEventsMatch = url.pathname.match(/^\/v1\/workbench\/traces\/([^/]+)\/events$/u);
if (traceEventsMatch) {
if (request.method !== "GET") return methodNotAllowed(response, "GET");
await handleWorkbenchTraceEventPage(response, url, options, auth.actor, decodeURIComponent(traceEventsMatch[1]));
await (perf ? perf.measure("workbench_trace_events", () => handleWorkbenchTraceEventPage(response, url, options, auth.actor, decodeURIComponent(traceEventsMatch[1]))) : handleWorkbenchTraceEventPage(response, url, options, auth.actor, decodeURIComponent(traceEventsMatch[1])));
return;
}
@@ -64,7 +65,8 @@ export async function handleWorkbenchReadModelHttp(request, response, url, optio
export async function handleWorkbenchRealtimeHttp(request, response, url, options = {}) {
if (request.method !== "GET") return methodNotAllowed(response, "GET");
const auth = await authenticateWorkbenchRead(request, response, options);
const perf = options.backendPerformance;
const auth = perf ? await perf.measure("workbench_auth", () => authenticateWorkbenchRead(request, response, options)) : await authenticateWorkbenchRead(request, response, options);
if (!auth) return;
const projectId = textValue(url.searchParams.get("projectId")) || DEFAULT_PROJECT_ID;
@@ -86,8 +88,12 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option
const writeEvent = (name, payload = {}) => {
if (closed || response.destroyed) return;
const startedAt = nowMs();
const eventCreatedAt = realtimeEventCreatedAt(payload);
const traceSeq = realtimeTraceSeq(payload);
response.write(`event: ${name}\n`);
response.write(`data: ${JSON.stringify({ contractVersion: "workbench-events-v1", projectId, ...payload })}\n\n`);
response.write(`data: ${JSON.stringify({ contractVersion: "workbench-events-v1", projectId, serverSentAt: new Date().toISOString(), eventCreatedAt, traceSeq, ...payload })}\n\n`);
perf?.recordPhase({ phase: "sse_write", durationMs: nowMs() - startedAt, outcome: "ok" });
};
writeEvent("workbench.connected", {
@@ -100,7 +106,7 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option
}
});
const initialWorkspace = await visibleWorkbenchWorkspace(options, auth.actor, { workspaceId: requestedWorkspaceId, projectId });
const initialWorkspace = perf ? await perf.measure("workbench_initial_workspace", () => visibleWorkbenchWorkspace(options, auth.actor, { workspaceId: requestedWorkspaceId, projectId })) : await visibleWorkbenchWorkspace(options, auth.actor, { workspaceId: requestedWorkspaceId, projectId });
if (initialWorkspace) {
latestWorkspaceRevision = Number(initialWorkspace.revision ?? 0) || 0;
writeEvent("workbench.workspace.snapshot", {
@@ -113,7 +119,7 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option
const activeTraceId = requestedTraceId ?? safeTraceId(initialWorkspace?.activeTraceId ?? initialWorkspace?.workspace?.activeTraceId ?? initialWorkspace?.workspace?.lastTraceId);
if (activeTraceId) {
await writeTraceRealtimeSnapshot({ writeEvent, options, actor: auth.actor, traceId: activeTraceId, reason: "initial" });
await (perf ? perf.measure("workbench_initial_trace", () => writeTraceRealtimeSnapshot({ writeEvent, options, actor: auth.actor, traceId: activeTraceId, reason: "initial" })) : writeTraceRealtimeSnapshot({ writeEvent, options, actor: auth.actor, traceId: activeTraceId, reason: "initial" }));
const unsubscribe = traceStore.subscribe(activeTraceId, (event, snapshot) => {
if (event) {
writeEvent("workbench.trace.event", {
@@ -174,6 +180,25 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option
});
}
function realtimeEventCreatedAt(payload) {
const candidates = [payload?.event?.createdAt, payload?.snapshot?.lastEvent?.createdAt, payload?.turn?.updatedAt, payload?.workspace?.updatedAt];
for (const value of candidates) {
const text = textValue(value);
if (text) return text;
}
return null;
}
function realtimeTraceSeq(payload) {
const seq = Number(payload?.cursor?.traceSeq ?? payload?.event?.seq ?? payload?.snapshot?.lastEvent?.seq);
return Number.isFinite(seq) && seq >= 0 ? seq : null;
}
function nowMs() {
if (typeof performance !== "undefined" && typeof performance.now === "function") return performance.now();
return Date.now();
}
async function authenticateWorkbenchRead(request, response, options) {
const access = options.accessController;
if (!access?.authenticate) {
+20 -14
View File
@@ -1,5 +1,5 @@
/*
* SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0
* SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
* 职责: Cloud API REST/SSE route dispatcherWorkbench read model / compat wrapper dispatcher
*/
import { createServer } from "node:http";
@@ -55,6 +55,7 @@ import {
handleWebPerformanceMetricsHttp,
handleWebPerformanceSummaryHttp
} from "./web-performance.ts";
import { createBackendPerformanceStore, withBackendPerformanceContext } from "./backend-performance.ts";
import {
createCodeAgentChatResultStore,
handleCodeAgentCancelHttp,
@@ -104,6 +105,7 @@ export function createCloudApiServer(options = {}) {
maxResults: parsePositiveInteger(env.HWLAB_CODE_AGENT_CHAT_RESULT_CACHE_SIZE, 256)
});
const webPerformanceStore = options.webPerformanceStore || createWebPerformanceStore({ env });
const backendPerformanceStore = options.backendPerformanceStore || createBackendPerformanceStore({ env });
const gatewayRegistry = options.gatewayRegistry || createGatewayDemoRegistry({
staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000),
dispatchTimeoutMs: parsePositiveInteger(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS, 120000)
@@ -114,18 +116,22 @@ export function createCloudApiServer(options = {}) {
accessController.configureCodeAgentWorkspaceContext({ env, fetchImpl: options.fetchImpl, traceStore, codeAgentChatResults });
}
return createServer(async (request, response) => {
try {
await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry, hwpodNodeWsRegistry, accessController, sessionRegistry, traceStore, codexStdioManager, codeAgentChatResults, webPerformanceStore });
} catch (error) {
if (error?.alreadySent) return;
sendJson(response, 500, {
error: {
code: "internal_error",
message: "hwlab-cloud-api request handling failed",
reason: error.message
}
});
}
const backendPerformance = backendPerformanceStore.beginHttpRequest(request, response);
await withBackendPerformanceContext(backendPerformance, async () => {
try {
await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry, hwpodNodeWsRegistry, accessController, sessionRegistry, traceStore, codexStdioManager, codeAgentChatResults, webPerformanceStore, backendPerformanceStore, backendPerformance });
} catch (error) {
if (error?.alreadySent) return;
backendPerformance.recordPhase({ phase: "route_exception", durationMs: 0, outcome: "error" });
sendJson(response, 500, {
error: {
code: "internal_error",
message: "hwlab-cloud-api request handling failed",
reason: error.message
}
});
}
});
});
}
@@ -315,7 +321,7 @@ async function handleRestAdapter(request, response, url, options) {
}
if (url.pathname === "/v1/web-performance/metrics" && request.method === "GET") {
handleWebPerformanceMetricsHttp(request, response, { store: options.webPerformanceStore });
handleWebPerformanceMetricsHttp(request, response, { store: options.webPerformanceStore, extraMetricsText: () => options.backendPerformanceStore.metricsText() });
return;
}
+5 -1
View File
@@ -383,7 +383,11 @@ export function handleWebPerformanceMetricsHttp(request, response, options = {})
sendJson(response, 404, { error: { code: "not_found", message: "route is not public" } });
return;
}
const payload = options.store.metricsText();
const primary = String(options.store.metricsText() ?? "").trimEnd();
const extra = typeof options.extraMetricsText === "function"
? String(options.extraMetricsText() ?? "").trimEnd()
: String(options.extraMetricsText ?? "").trimEnd();
const payload = [primary, extra].filter(Boolean).join("\n") + "\n";
response.writeHead(200, {
"content-type": "text/plain; version=0.0.4; charset=utf-8",
"content-length": Buffer.byteLength(payload)