2310 lines
78 KiB
JavaScript
2310 lines
78 KiB
JavaScript
import { createServer } from "node:http";
|
||
import { randomUUID } from "node:crypto";
|
||
import { existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
|
||
import { readFile } from "node:fs/promises";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
import {
|
||
buildMetadataFromEnv,
|
||
imageTagFromReference,
|
||
normalizeIsoTimestamp
|
||
} from "../build-metadata.mjs";
|
||
import { DEV_ENDPOINT, ENVIRONMENT_DEV, ERROR_CODES, SERVICE_IDS } from "../protocol/index.mjs";
|
||
import {
|
||
AUDIT_FIELD_NAMES,
|
||
CLOUD_API_SERVICE_ID,
|
||
createAuditRecord
|
||
} from "../audit/index.mjs";
|
||
import {
|
||
SUPPORTED_RPC_METHODS,
|
||
createErrorEnvelope,
|
||
handleJsonRpcRequest
|
||
} from "./json-rpc.mjs";
|
||
import {
|
||
createCodeAgentErrorPayload,
|
||
describeCodeAgentAvailability,
|
||
handleCodeAgentChat
|
||
} from "./code-agent-chat.mjs";
|
||
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.mjs";
|
||
import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs";
|
||
import { codeAgentSessionLifecycleSummary } from "./code-agent-session-lifecycle.mjs";
|
||
import { createCodexStdioSessionManager } from "./codex-stdio-session.mjs";
|
||
import { buildGateDiagnosticsRows } from "./gate-diagnostics.mjs";
|
||
import {
|
||
applyRuntimeDbReadinessLayers,
|
||
buildDbRuntimeReadiness
|
||
} from "./db-contract.mjs";
|
||
import { buildCloudApiReadiness } from "./health-contract.mjs";
|
||
import {
|
||
M3_IO_CONTROL_ROUTE,
|
||
M3_STATUS_ROUTE,
|
||
describeM3IoControl,
|
||
describeM3IoControlLive,
|
||
describeM3StatusLive,
|
||
handleM3IoControl
|
||
} from "./m3-io-control.mjs";
|
||
import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||
import {
|
||
buildDevicePodRestPayload,
|
||
buildDevicePodStatus
|
||
} from "../device-pod/fake-data.mjs";
|
||
import { createGatewayDemoRegistry } from "./gateway-demo-registry.mjs";
|
||
|
||
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
||
const LIVE_BUILD_TIMEOUT_MS = 900;
|
||
const DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT = 120;
|
||
const DEFAULT_CODE_AGENT_PROVIDER_PROFILE = "deepseek";
|
||
const CODE_AGENT_PROVIDER_PROFILE_IDS = Object.freeze(["deepseek", "codex-api", "runtime-default"]);
|
||
const CODE_AGENT_PROVIDER_PROFILE_LABELS = Object.freeze({
|
||
"deepseek": "DeepSeek",
|
||
"codex-api": "Codex API",
|
||
"runtime-default": "运行默认"
|
||
});
|
||
const DEFAULT_CODE_AGENT_CODEX_API_MODEL = "gpt-5.5";
|
||
const DEFAULT_CODE_AGENT_CODEX_API_BASE_URL = "http://127.0.0.1:49280/responses";
|
||
const DEFAULT_CODE_AGENT_DEEPSEEK_MODEL = "deepseek-chat";
|
||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||
const LIVE_BUILD_METADATA_PATHS = Object.freeze({
|
||
deployManifest: "deploy/deploy.json",
|
||
artifactCatalog: "deploy/artifact-catalog.dev.json"
|
||
});
|
||
const LIVE_BUILD_SERVICE_DEFAULTS = Object.freeze({
|
||
"hwlab-cloud-api": Object.freeze({ urlEnv: null, defaultUrl: "http://127.0.0.1:6667" }),
|
||
"hwlab-cloud-web": Object.freeze({ urlEnv: "HWLAB_CLOUD_WEB_SERVICE_URL", defaultUrl: "http://hwlab-cloud-web.hwlab-dev.svc.cluster.local:8080" }),
|
||
"hwlab-agent-mgr": Object.freeze({ urlEnv: "HWLAB_AGENT_MGR_URL", defaultUrl: "http://hwlab-agent-mgr.hwlab-dev.svc.cluster.local:7410" }),
|
||
"hwlab-agent-worker": Object.freeze({ urlEnv: "HWLAB_AGENT_WORKER_URL", defaultUrl: "http://hwlab-agent-worker.hwlab-dev.svc.cluster.local:7411" }),
|
||
"hwlab-device-pod": Object.freeze({ urlEnv: "HWLAB_DEVICE_POD_URL", defaultUrl: "http://hwlab-device-pod.hwlab-dev.svc.cluster.local:7601" }),
|
||
"hwlab-gateway": Object.freeze({ urlEnv: "HWLAB_GATEWAY_URL", defaultUrl: "http://hwlab-gateway.hwlab-dev.svc.cluster.local:7001" }),
|
||
"hwlab-gateway-simu": Object.freeze({ urlEnv: "HWLAB_GATEWAY_SIMU_URL", defaultUrl: "http://hwlab-gateway-simu.hwlab-dev.svc.cluster.local:7101" }),
|
||
"hwlab-box-simu": Object.freeze({ urlEnv: "HWLAB_BOX_SIMU_URL", defaultUrl: "http://hwlab-box-simu.hwlab-dev.svc.cluster.local:7201" }),
|
||
"hwlab-patch-panel": Object.freeze({ urlEnv: "HWLAB_PATCH_PANEL_URL", defaultUrl: "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301" }),
|
||
"hwlab-router": Object.freeze({ urlEnv: "HWLAB_ROUTER_URL", defaultUrl: "http://hwlab-router.hwlab-dev.svc.cluster.local:7401" }),
|
||
"hwlab-tunnel-client": Object.freeze({ urlEnv: "HWLAB_TUNNEL_CLIENT_URL", defaultUrl: "http://hwlab-tunnel-client.hwlab-dev.svc.cluster.local:7402" }),
|
||
"hwlab-edge-proxy": Object.freeze({ urlEnv: "HWLAB_EDGE_PROXY_URL", defaultUrl: "http://hwlab-edge-proxy.hwlab-dev.svc.cluster.local:6667", healthPath: "/health" }),
|
||
"hwlab-cli": Object.freeze({ urlEnv: "HWLAB_CLI_URL", defaultUrl: "http://hwlab-cli.hwlab-dev.svc.cluster.local:7501" }),
|
||
"hwlab-agent-skills": Object.freeze({ urlEnv: "HWLAB_AGENT_SKILLS_URL", defaultUrl: "http://hwlab-agent-skills.hwlab-dev.svc.cluster.local:7430" })
|
||
});
|
||
const LIVE_BUILD_EXTERNAL_COMPONENTS = Object.freeze([
|
||
Object.freeze({
|
||
serviceId: "hwlab-frpc",
|
||
serviceName: "hwlab-frpc",
|
||
kind: "external",
|
||
externalImage: true,
|
||
defaultImage: "127.0.0.1:5000/hwlab/frpc:v0.68.1",
|
||
externalReason: "外部镜像或非 HWLAB 构建产物"
|
||
})
|
||
]);
|
||
|
||
function runtimeEnvironment(env = process.env) {
|
||
const value = env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || ENVIRONMENT_DEV;
|
||
return typeof value === "string" && value.trim() ? value.trim() : ENVIRONMENT_DEV;
|
||
}
|
||
|
||
export function createCloudApiServer(options = {}) {
|
||
const env = options.env ?? process.env;
|
||
ensureCodeAgentRuntimeBase(env);
|
||
const sessionRegistry = options.sessionRegistry || createCodeAgentSessionRegistry();
|
||
const traceStore = options.traceStore || defaultCodeAgentTraceStore;
|
||
const codexStdioManager = options.codexStdioManager || createCodexStdioSessionManager({ traceStore });
|
||
const codeAgentChatResults = options.codeAgentChatResults || createCodeAgentChatResultStore({
|
||
maxResults: parsePositiveInteger(env.HWLAB_CODE_AGENT_CHAT_RESULT_CACHE_SIZE, 256)
|
||
});
|
||
const runtimeStore = options.runtimeStore || createConfiguredCloudRuntimeStore({ ...options, env });
|
||
const gatewayRegistry = options.gatewayRegistry || createGatewayDemoRegistry({
|
||
staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000),
|
||
dispatchTimeoutMs: parsePositiveInteger(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS, 120000)
|
||
});
|
||
return createServer(async (request, response) => {
|
||
try {
|
||
await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry, sessionRegistry, traceStore, codexStdioManager, codeAgentChatResults });
|
||
} catch (error) {
|
||
sendJson(response, 500, {
|
||
error: {
|
||
code: "internal_error",
|
||
message: "hwlab-cloud-api request handling failed",
|
||
reason: error.message
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
export function ensureCodeAgentRuntimeBase(env = process.env) {
|
||
const workspace = env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || env.HWLAB_CODE_AGENT_WORKSPACE || "/workspace/hwlab";
|
||
const codexHome = env.CODEX_HOME || env.HWLAB_CODE_AGENT_CODEX_HOME || "/codex-home";
|
||
const appCodexCommand = "/app/node_modules/.bin/codex";
|
||
const localCodexCommand = path.join(process.cwd(), "node_modules", ".bin", "codex");
|
||
const codexCommand = existsSync(appCodexCommand)
|
||
? appCodexCommand
|
||
: existsSync(localCodexCommand)
|
||
? localCodexCommand
|
||
: appCodexCommand;
|
||
env.HWLAB_CODE_AGENT_WORKSPACE ||= workspace;
|
||
env.HWLAB_CODE_AGENT_CODEX_WORKSPACE ||= workspace;
|
||
env.HWLAB_CODE_AGENT_CODEX_SANDBOX ||= "danger-full-access";
|
||
env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED ||= "1";
|
||
env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR ||= "repo-owned";
|
||
env.CODEX_HOME ||= codexHome;
|
||
env.HWLAB_CODE_AGENT_CODEX_COMMAND ||= codexCommand;
|
||
env.HWLAB_CODE_AGENT_SKILLS_DIRS ||= "/app/skills";
|
||
|
||
for (const target of [path.dirname(workspace), codexHome]) {
|
||
try {
|
||
mkdirSync(target, { recursive: true });
|
||
} catch {
|
||
// Runtime health reports the concrete missing/writable blocker.
|
||
}
|
||
}
|
||
try {
|
||
if (!existsSync(workspace)) {
|
||
if (workspace === "/workspace/hwlab" && existsSync("/app")) {
|
||
symlinkSync("/app", workspace, "dir");
|
||
} else {
|
||
mkdirSync(workspace, { recursive: true });
|
||
}
|
||
}
|
||
lstatSync(workspace);
|
||
} catch {
|
||
// Runtime health reports the concrete missing/writable blocker.
|
||
}
|
||
}
|
||
|
||
export async function buildHealthPayload(options = {}) {
|
||
const serviceId = CLOUD_API_SERVICE_ID;
|
||
const env = options.env ?? process.env;
|
||
ensureCodeAgentRuntimeBase(env);
|
||
const metadata = buildMetadataFromEnv(env, {
|
||
serviceId,
|
||
fallbackImageRepository: "ghcr.io/pikastech/hwlab-cloud-api"
|
||
});
|
||
const dbProbe = await buildDbRuntimeReadiness(env, options.dbProbe);
|
||
const codeAgent = await describeCodeAgentAvailability(env, options);
|
||
const runtime = await runtimeReadiness(options.runtimeStore ?? createConfiguredCloudRuntimeStore({ ...options, env }));
|
||
const db = applyRuntimeDbReadinessLayers(dbProbe, runtime);
|
||
const readiness = buildCloudApiReadiness({ db, codeAgent, runtime });
|
||
|
||
return {
|
||
serviceId,
|
||
environment: runtimeEnvironment(env),
|
||
status: readiness.status,
|
||
ready: readiness.ready,
|
||
revision: metadata.revision,
|
||
readiness,
|
||
blockers: readiness.blockers,
|
||
blockerCodes: readiness.blockerCodes,
|
||
service: {
|
||
id: serviceId,
|
||
role: "cloud-api",
|
||
healthPath: "/health",
|
||
livePath: "/health/live"
|
||
},
|
||
commit: metadata.commit,
|
||
image: metadata.image,
|
||
build: metadata.build,
|
||
endpoint: env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT,
|
||
observedAt: new Date().toISOString(),
|
||
db,
|
||
codeAgent,
|
||
runtime
|
||
};
|
||
}
|
||
|
||
async function routeRequest(request, response, options) {
|
||
const url = new URL(request.url || "/", "http://hwlab-cloud-api.local");
|
||
|
||
if (request.method === "GET" && (url.pathname === "/health" || url.pathname === "/health/live")) {
|
||
sendJson(response, 200, await buildHealthPayload(options));
|
||
return;
|
||
}
|
||
|
||
if (request.method === "GET" && url.pathname === "/live") {
|
||
sendJson(response, 200, {
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
status: "live"
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (request.method === "POST" && (url.pathname === "/rpc" || url.pathname === "/json-rpc")) {
|
||
await handleRpcHttpRequest(request, response, options);
|
||
return;
|
||
}
|
||
|
||
if (url.pathname === "/v1" || url.pathname.startsWith("/v1/")) {
|
||
await handleRestAdapter(request, response, url, options);
|
||
return;
|
||
}
|
||
|
||
sendRestError(request, response, 404, "not_found", "Route is not part of the L1 cloud-api runtime", {
|
||
operation: `${request.method || "GET"} ${url.pathname}`,
|
||
target: {
|
||
type: "route",
|
||
id: url.pathname
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleRpcHttpRequest(request, response, options) {
|
||
const body = await readBody(request, options.bodyLimitBytes);
|
||
let envelope;
|
||
|
||
try {
|
||
envelope = body ? JSON.parse(body) : {};
|
||
} catch (error) {
|
||
sendJson(
|
||
response,
|
||
400,
|
||
createErrorEnvelope({
|
||
id: "req_unassigned",
|
||
code: ERROR_CODES.parseError,
|
||
message: "Invalid JSON body",
|
||
data: {
|
||
reason: error.message
|
||
},
|
||
context: {
|
||
operation: "json_rpc.parse",
|
||
target: {
|
||
type: "http_route",
|
||
id: request.url || "/rpc"
|
||
},
|
||
result: "rejected"
|
||
}
|
||
})
|
||
);
|
||
return;
|
||
}
|
||
|
||
const rpcResponse = await handleJsonRpcRequest(envelope, {
|
||
adapter: "json-rpc",
|
||
runtimeStore: options.runtimeStore,
|
||
env: options.env,
|
||
dbProbe: options.dbProbe,
|
||
m3IoRequestJson: options.m3IoRequestJson,
|
||
gatewayRegistry: options.gatewayRegistry
|
||
});
|
||
sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse);
|
||
}
|
||
|
||
async function handleRestAdapter(request, response, url, options) {
|
||
if (request.method === "GET" && url.pathname === "/v1") {
|
||
const dbProbe = await buildDbRuntimeReadiness(options.env ?? process.env, options.dbProbe);
|
||
const codeAgent = await describeCodeAgentAvailability(options.env ?? process.env, options);
|
||
const runtime = await runtimeReadiness(options.runtimeStore);
|
||
const db = applyRuntimeDbReadinessLayers(dbProbe, runtime);
|
||
const readiness = buildCloudApiReadiness({ db, codeAgent, runtime });
|
||
sendJson(response, 200, {
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
adapter: "rest",
|
||
status: readiness.status,
|
||
ready: readiness.ready,
|
||
rpcBridge: "POST /v1/rpc/{method}",
|
||
codeAgent,
|
||
methods: SUPPORTED_RPC_METHODS,
|
||
auditFields: AUDIT_FIELD_NAMES,
|
||
db,
|
||
runtime,
|
||
readiness,
|
||
blockers: readiness.blockers,
|
||
blockerCodes: readiness.blockerCodes,
|
||
devicePod: {
|
||
route: "/v1/device-pods",
|
||
statusRoute: "/v1/device-pods/{devicePodId}/status",
|
||
eventsRoute: "/v1/device-pods/{devicePodId}/events",
|
||
contractVersion: "device-pod-fake-v1",
|
||
frontendCallsOnly: "/v1/device-pods",
|
||
legacyHardwareUi: "disabled"
|
||
},
|
||
m3IoControl: describeM3IoControl(options)
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (request.method === "GET" && (url.pathname === "/v1/device-pods" || url.pathname.startsWith("/v1/device-pods/"))) {
|
||
sendJson(response, 200, await buildDevicePodCloudApiPayload(url, options));
|
||
return;
|
||
}
|
||
|
||
if (request.method === "GET" && url.pathname === M3_IO_CONTROL_ROUTE) {
|
||
sendJson(response, 200, await describeM3IoControlLive(options));
|
||
return;
|
||
}
|
||
|
||
if (request.method === "GET" && url.pathname === M3_STATUS_ROUTE) {
|
||
sendJson(response, 200, await describeM3StatusLive(options));
|
||
return;
|
||
}
|
||
|
||
if (request.method === "GET" && url.pathname === "/v1/diagnostics/gate") {
|
||
sendJson(response, 200, await buildGateDiagnosticsRows(options));
|
||
return;
|
||
}
|
||
|
||
if (request.method === "GET" && url.pathname === "/v1/live-builds") {
|
||
sendJson(response, 200, await buildLiveBuildsPayload(options));
|
||
return;
|
||
}
|
||
|
||
if (request.method === "GET" && url.pathname === "/v1/gateway/sessions") {
|
||
sendJson(response, 200, options.gatewayRegistry.describe());
|
||
return;
|
||
}
|
||
|
||
if (request.method === "POST" && url.pathname === "/v1/gateway/poll") {
|
||
await handleGatewayPollHttp(request, response, options);
|
||
return;
|
||
}
|
||
|
||
if (request.method === "POST" && url.pathname === "/v1/gateway/result") {
|
||
await handleGatewayResultHttp(request, response, options);
|
||
return;
|
||
}
|
||
|
||
if (request.method === "POST" && url.pathname === M3_IO_CONTROL_ROUTE) {
|
||
await handleM3IoControlHttp(request, response, options);
|
||
return;
|
||
}
|
||
|
||
if (request.method === "POST" && url.pathname === "/v1/agent/chat") {
|
||
await handleCodeAgentChatHttp(request, response, options);
|
||
return;
|
||
}
|
||
|
||
if (request.method === "GET" && url.pathname === "/v1/agent/chat/inspect") {
|
||
await handleCodeAgentInspectHttp(request, response, url, options);
|
||
return;
|
||
}
|
||
|
||
if (request.method === "GET" && url.pathname.startsWith("/v1/agent/chat/result/")) {
|
||
await handleCodeAgentChatResultHttp(request, response, url, options);
|
||
return;
|
||
}
|
||
|
||
if (request.method === "POST" && url.pathname === "/v1/agent/chat/cancel") {
|
||
await handleCodeAgentCancelHttp(request, response, options);
|
||
return;
|
||
}
|
||
|
||
if (request.method === "GET" && url.pathname.startsWith("/v1/agent/chat/trace/")) {
|
||
await handleCodeAgentTraceHttp(request, response, url, options);
|
||
return;
|
||
}
|
||
|
||
if (request.method !== "POST" || !url.pathname.startsWith("/v1/rpc/")) {
|
||
sendRestError(request, response, 404, "not_found", "REST route is not implemented in the L1 cloud-api runtime", {
|
||
operation: `${request.method || "GET"} ${url.pathname}`,
|
||
target: {
|
||
type: "route",
|
||
id: url.pathname
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
const method = decodeURIComponent(url.pathname.slice("/v1/rpc/".length));
|
||
const body = await readBody(request, options.bodyLimitBytes);
|
||
let params = {};
|
||
|
||
try {
|
||
params = body ? JSON.parse(body) : {};
|
||
} catch (error) {
|
||
sendRestError(request, response, 400, "parse_error", "Invalid JSON body", {
|
||
operation: method,
|
||
target: {
|
||
type: "rpc_method",
|
||
id: method
|
||
},
|
||
result: "rejected",
|
||
reason: error.message
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||
sendRestError(request, response, 400, "invalid_params", "REST RPC bridge body must be a JSON object", {
|
||
operation: method,
|
||
target: {
|
||
type: "rpc_method",
|
||
id: method
|
||
},
|
||
result: "rejected"
|
||
});
|
||
return;
|
||
}
|
||
|
||
const rpcResponse = await handleJsonRpcRequest(
|
||
{
|
||
jsonrpc: "2.0",
|
||
id: getHeader(request, "x-request-id") || `req_${randomUUID()}`,
|
||
method,
|
||
params,
|
||
meta: {
|
||
traceId: getHeader(request, "x-trace-id") || `trc_${randomUUID()}`,
|
||
actorId: getHeader(request, "x-actor-id"),
|
||
serviceId: getHeader(request, "x-source-service-id") || CLOUD_API_SERVICE_ID,
|
||
environment: runtimeEnvironment(options.env ?? process.env)
|
||
}
|
||
},
|
||
{
|
||
adapter: "rest",
|
||
runtimeStore: options.runtimeStore,
|
||
env: options.env,
|
||
dbProbe: options.dbProbe,
|
||
m3IoRequestJson: options.m3IoRequestJson,
|
||
gatewayRegistry: options.gatewayRegistry
|
||
}
|
||
);
|
||
|
||
sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse);
|
||
}
|
||
|
||
async function buildLiveBuildsPayload(options = {}) {
|
||
const env = options.env ?? process.env;
|
||
const metadata = await loadLiveBuildMetadata(options);
|
||
const inventory = liveBuildServiceInventory(metadata);
|
||
const services = await Promise.all(
|
||
inventory.map((service) => observeLiveBuildService(service, { ...options, env }))
|
||
);
|
||
const hwlabServices = services.filter((service) => service.kind === "hwlab");
|
||
const latest = [...hwlabServices]
|
||
.filter((service) => service.build.createdAt)
|
||
.sort((left, right) => Date.parse(right.build.createdAt) - Date.parse(left.build.createdAt))[0] ?? null;
|
||
|
||
return {
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
environment: runtimeEnvironment(env),
|
||
status: "ok",
|
||
contractVersion: "live-builds-v1",
|
||
observedAt: new Date().toISOString(),
|
||
source: {
|
||
kind: "live-health+deploy-artifact-catalog",
|
||
route: "/v1/live-builds",
|
||
healthPath: "/health/live",
|
||
metadataPaths: LIVE_BUILD_METADATA_PATHS,
|
||
desiredStateSource: metadata.deployManifest.status === "ok" ? LIVE_BUILD_METADATA_PATHS.deployManifest : "unavailable",
|
||
artifactCatalogSource: metadata.artifactCatalog.status === "ok" ? LIVE_BUILD_METADATA_PATHS.artifactCatalog : "unavailable",
|
||
note: "Each row prefers current live service health build metadata and falls back only to deploy/artifact catalog metadata when the live tag/revision matches; observedAt is not used as build time."
|
||
},
|
||
latest,
|
||
counts: {
|
||
total: services.length,
|
||
hwlab: hwlabServices.length,
|
||
withBuildTime: hwlabServices.filter((service) => Boolean(service.build.createdAt)).length,
|
||
unavailable: hwlabServices.filter((service) => service.build.createdAt === null).length,
|
||
external: services.filter((service) => service.kind === "external").length
|
||
},
|
||
services
|
||
};
|
||
}
|
||
|
||
async function buildDevicePodCloudApiPayload(url, options = {}) {
|
||
const upstreamPayload = await fetchDevicePodUpstreamPayload(url, options);
|
||
if (upstreamPayload) {
|
||
return {
|
||
...upstreamPayload,
|
||
cloudApiProxy: {
|
||
route: url.pathname,
|
||
upstream: configuredDevicePodUrl(options.env ?? process.env),
|
||
status: "proxied"
|
||
}
|
||
};
|
||
}
|
||
const fallback = buildDevicePodRestPayload(url.pathname, url.searchParams, {
|
||
sourceKind: "FAKE-CLOUD-API"
|
||
}) ?? buildDevicePodStatus(undefined, { sourceKind: "FAKE-CLOUD-API" });
|
||
return {
|
||
...fallback,
|
||
cloudApiProxy: {
|
||
route: url.pathname,
|
||
upstream: configuredDevicePodUrl(options.env ?? process.env),
|
||
status: "fake-fallback",
|
||
reason: "hwlab-device-pod upstream is unavailable or not configured"
|
||
}
|
||
};
|
||
}
|
||
|
||
async function fetchDevicePodUpstreamPayload(url, options = {}) {
|
||
const baseUrl = configuredDevicePodUrl(options.env ?? process.env);
|
||
if (!baseUrl) return null;
|
||
const target = `${baseUrl}${url.pathname}${url.search}`;
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), 900);
|
||
try {
|
||
const response = await (options.fetchImpl ?? fetch)(target, {
|
||
headers: { Accept: "application/json" },
|
||
signal: controller.signal
|
||
});
|
||
if (!response.ok) return null;
|
||
return await response.json();
|
||
} catch {
|
||
return null;
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
}
|
||
}
|
||
|
||
function configuredDevicePodUrl(env = process.env) {
|
||
const value = String(env.HWLAB_DEVICE_POD_URL ?? "").trim();
|
||
return value ? value.replace(/\/+$/u, "") : "";
|
||
}
|
||
|
||
async function observeLiveBuildService(service, options) {
|
||
if (service.externalImage) {
|
||
return externalLiveBuildRecord(service, {
|
||
healthUrl: null,
|
||
httpStatus: null,
|
||
imageReference: service.defaultImage ?? "unknown",
|
||
reason: "外部镜像或非 HWLAB 构建产物"
|
||
});
|
||
}
|
||
|
||
const metadataRecords = liveBuildRecordsFromMetadata(service);
|
||
const metadataRecord = metadataRecords[0] ?? null;
|
||
if (service.serviceId === CLOUD_API_SERVICE_ID) {
|
||
const health = await buildHealthPayload(options);
|
||
return liveBuildRecordFromHealth(service, {
|
||
ok: true,
|
||
status: 200,
|
||
url: "/health/live",
|
||
payload: health
|
||
}, metadataRecords);
|
||
}
|
||
|
||
const baseUrl = String(options.env?.[service.urlEnv] || service.defaultUrl || "").replace(/\/+$/u, "");
|
||
if (!baseUrl) {
|
||
return metadataRecord
|
||
? metadataRecordWithHealthGap(metadataRecord, {
|
||
service,
|
||
healthUrl: null,
|
||
httpStatus: null,
|
||
reason: `${service.serviceId} 没有配置 live health URL`
|
||
})
|
||
: unavailableLiveBuildRecord(service, {
|
||
reasonCode: "service_url_unavailable",
|
||
reason: `${service.serviceId} 没有配置 live health URL`
|
||
});
|
||
}
|
||
|
||
const healthPath = service.healthPath ?? "/health/live";
|
||
return liveBuildRecordFromHealth(
|
||
service,
|
||
await fetchServiceHealth(`${baseUrl}${healthPath}`, options.fetchImpl ?? fetch),
|
||
metadataRecords
|
||
);
|
||
}
|
||
|
||
async function fetchServiceHealth(url, fetchImpl) {
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), LIVE_BUILD_TIMEOUT_MS);
|
||
try {
|
||
const response = await fetchImpl(url, {
|
||
headers: { Accept: "application/json" },
|
||
signal: controller.signal
|
||
});
|
||
const text = await response.text();
|
||
let payload = null;
|
||
try {
|
||
payload = text ? JSON.parse(text) : null;
|
||
} catch {
|
||
return {
|
||
ok: false,
|
||
status: response.status,
|
||
url,
|
||
reasonCode: "invalid_json",
|
||
reason: `${url} returned non-JSON health payload`
|
||
};
|
||
}
|
||
return {
|
||
ok: response.ok,
|
||
status: response.status,
|
||
url,
|
||
payload,
|
||
reasonCode: response.ok ? null : "http_error",
|
||
reason: response.ok ? null : `${url} returned HTTP ${response.status}`
|
||
};
|
||
} catch (error) {
|
||
const timedOut = error?.name === "AbortError";
|
||
return {
|
||
ok: false,
|
||
status: null,
|
||
url,
|
||
reasonCode: timedOut ? "timeout" : "request_failed",
|
||
reason: timedOut ? `${url} timed out after ${LIVE_BUILD_TIMEOUT_MS}ms` : error.message
|
||
};
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
}
|
||
}
|
||
|
||
function liveBuildRecordFromHealth(service, result, metadataRecords = []) {
|
||
const candidates = Array.isArray(metadataRecords) ? metadataRecords : [metadataRecords].filter(Boolean);
|
||
const metadataRecord = candidates[0] ?? null;
|
||
if (!result.ok) {
|
||
const reason = healthUnavailableReason(service, result.reason ?? `${service.serviceId} health unavailable`);
|
||
return metadataRecord
|
||
? metadataRecordWithHealthGap(metadataRecord, {
|
||
service,
|
||
healthUrl: result.url,
|
||
httpStatus: result.status,
|
||
reason
|
||
})
|
||
: unavailableLiveBuildRecord(service, {
|
||
healthUrl: result.url,
|
||
httpStatus: result.status,
|
||
reasonCode: result.reasonCode ?? "health_unavailable",
|
||
reason
|
||
});
|
||
}
|
||
|
||
const payload = result.payload ?? {};
|
||
const payloadServiceId = payload.serviceId ?? service.serviceId;
|
||
if (!String(payloadServiceId).startsWith("hwlab-")) {
|
||
return externalLiveBuildRecord(service, {
|
||
healthUrl: result.url,
|
||
httpStatus: result.status,
|
||
imageReference: payload.image?.reference ?? payload.image ?? "unknown",
|
||
imageTag: payload.image?.tag ?? null,
|
||
imageDigest: payload.image?.digest ?? "unknown",
|
||
commitId: payload.commit?.id ?? payload.revision ?? payload.version ?? "unknown",
|
||
commitSource: payload.commit?.source ?? "external-health",
|
||
revision: payload.revision ?? payload.commit?.id ?? payload.version ?? "unknown",
|
||
reason: "外部镜像或非 HWLAB 构建产物"
|
||
});
|
||
}
|
||
|
||
const imageReference = payload.image?.reference ?? payload.image ?? "unknown";
|
||
const commitId = payload.commit?.id ?? payload.revision ?? "unknown";
|
||
const liveIdentity = liveBuildIdentityFromHealth({ payload, imageReference, commitId });
|
||
const createdAt = normalizeHealthBuildTime(payload);
|
||
if (createdAt) {
|
||
return liveBuildHealthPayloadRecord(service, result, {
|
||
payload,
|
||
imageReference,
|
||
commitId,
|
||
createdAt,
|
||
metadataSource: payload.build?.metadataSource ?? "health:build.createdAt",
|
||
unavailableReason: null
|
||
});
|
||
}
|
||
|
||
const matched = selectMatchingMetadataRecord(liveIdentity, candidates);
|
||
if (matched.record?.build?.createdAt) {
|
||
return mergeLiveHealthOntoMetadataRecord(matched.record, {
|
||
match: matched.match,
|
||
liveIdentity,
|
||
service,
|
||
result,
|
||
payload,
|
||
imageReference,
|
||
commitId
|
||
});
|
||
}
|
||
|
||
const mismatch = matched.match ?? liveBuildMetadataMatch(liveIdentity, metadataRecord);
|
||
const unavailableReason = matched.record
|
||
? "构建时间不可用:当前 live 镜像未提供 buildCreatedAt"
|
||
: metadataRecord
|
||
? `构建时间不可用:live tag 与 ${controlledMetadataLabel(metadataRecord)} 不匹配`
|
||
: "构建时间不可用:当前 live 镜像未提供 buildCreatedAt";
|
||
return liveBuildHealthPayloadRecord(service, result, {
|
||
payload,
|
||
imageReference,
|
||
commitId,
|
||
createdAt: null,
|
||
metadataSource: matched.record ? "live-health:matched-controlled-metadata-without-build-time" : metadataRecord ? "live-health:controlled-metadata-mismatch" : "live-health:build-created-at-missing",
|
||
unavailableReason,
|
||
metadataMismatch: mismatch,
|
||
unavailableDetail: matched.record
|
||
? `live tag ${liveIdentity.imageTag} / revision ${liveIdentity.revision} 已匹配 ${controlledMetadataLabel(matched.record)},但该 metadata 没有 buildCreatedAt(metadata tag ${matched.record.image.tag},metadata revision ${matched.record.revision},来源 ${matched.record.build.metadataSource})`
|
||
: metadataRecord
|
||
? `未使用可能过期的构建时间:live tag ${liveIdentity.imageTag},live revision ${liveIdentity.revision},metadata tag ${metadataRecord.image.tag},metadata revision ${metadataRecord.revision},来源 ${metadataRecord.build.metadataSource}`
|
||
: "health payload 缺少 build.createdAt / image.createdAt / buildCreatedAt,deploy/artifact catalog 也没有可匹配的 buildCreatedAt"
|
||
});
|
||
}
|
||
|
||
function liveBuildHealthPayloadRecord(service, result, {
|
||
payload,
|
||
imageReference,
|
||
commitId,
|
||
createdAt,
|
||
metadataSource,
|
||
unavailableReason,
|
||
metadataMismatch = null,
|
||
unavailableDetail = null
|
||
}) {
|
||
return {
|
||
serviceId: service.serviceId,
|
||
name: service.serviceName,
|
||
kind: "hwlab",
|
||
status: createdAt ? "ok" : "build_time_unavailable",
|
||
healthUrl: result.url,
|
||
httpStatus: result.status,
|
||
desiredState: desiredStateSummary(service),
|
||
build: {
|
||
createdAt,
|
||
source: payload.build?.source ?? payload.commit?.source ?? "health-payload",
|
||
metadataSource,
|
||
unavailableReason,
|
||
unavailableDetail,
|
||
metadataMismatch
|
||
},
|
||
image: {
|
||
reference: imageReference,
|
||
tag: payload.image?.tag ?? imageTagFromReference(imageReference) ?? "unknown",
|
||
digest: payload.image?.digest ?? "unknown"
|
||
},
|
||
commit: {
|
||
id: commitId,
|
||
source: payload.commit?.source ?? "health-payload"
|
||
},
|
||
revision: payload.revision ?? commitId
|
||
};
|
||
}
|
||
|
||
function metadataRecordWithHealthGap(metadataRecord, { service, healthUrl, httpStatus, reason }) {
|
||
const metadataTag = metadataRecord.image?.tag ?? "unknown";
|
||
const metadataRevision = metadataRecord.revision ?? metadataRecord.commit?.id ?? "unknown";
|
||
return {
|
||
...metadataRecord,
|
||
healthUrl,
|
||
httpStatus,
|
||
status: "build_time_unavailable",
|
||
build: {
|
||
...metadataRecord.build,
|
||
createdAt: null,
|
||
metadataSource: "live-health-unavailable",
|
||
unavailableReason: `构建时间不可用:当前 live health 不可用,无法确认 ${service.serviceId} live tag/revision 是否匹配 deploy/artifact catalog metadata,未使用可能过期的构建时间(metadata tag ${metadataTag},metadata revision ${metadataRevision});${reason}`
|
||
}
|
||
};
|
||
}
|
||
|
||
function mergeLiveHealthOntoMetadataRecord(metadataRecord, { match, liveIdentity, service, result, payload, imageReference, commitId }) {
|
||
return {
|
||
...metadataRecord,
|
||
serviceId: service.serviceId,
|
||
name: service.serviceName,
|
||
kind: "hwlab",
|
||
status: "ok",
|
||
healthUrl: result.url,
|
||
httpStatus: result.status,
|
||
build: {
|
||
...metadataRecord.build,
|
||
metadataSource: metadataRecord.build.metadataSource,
|
||
unavailableReason: null,
|
||
liveMetadataMatch: match,
|
||
liveHealthMissingReason: `health payload 缺少 build.createdAt / image.createdAt / buildCreatedAt,已使用与 live tag/revision 匹配的 ${controlledMetadataLabel(metadataRecord)}(live tag ${liveIdentity.imageTag})`
|
||
},
|
||
image: {
|
||
reference: imageReference !== "unknown" ? imageReference : metadataRecord.image.reference,
|
||
tag: payload.image?.tag ?? imageTagFromReference(imageReference) ?? metadataRecord.image.tag,
|
||
digest: payload.image?.digest ?? metadataRecord.image.digest
|
||
},
|
||
commit: {
|
||
id: commitId !== "unknown" ? commitId : metadataRecord.commit.id,
|
||
source: payload.commit?.source ?? metadataRecord.commit.source
|
||
},
|
||
revision: payload.revision ?? (commitId !== "unknown" ? commitId : metadataRecord.revision)
|
||
};
|
||
}
|
||
|
||
function liveBuildIdentityFromHealth({ payload, imageReference, commitId }) {
|
||
return {
|
||
imageReference,
|
||
imageTag: payload.image?.tag ?? imageTagFromReference(imageReference) ?? "unknown",
|
||
commitId,
|
||
revision: payload.revision ?? commitId,
|
||
digest: payload.image?.digest ?? "unknown"
|
||
};
|
||
}
|
||
|
||
function selectMatchingMetadataRecord(liveIdentity, metadataRecords) {
|
||
let firstMatched = null;
|
||
for (const record of metadataRecords) {
|
||
const match = liveBuildMetadataMatch(liveIdentity, record);
|
||
if (!match.matched) continue;
|
||
if (record.build?.createdAt) return { record, match };
|
||
firstMatched ??= { record, match };
|
||
}
|
||
if (firstMatched) return firstMatched;
|
||
return {
|
||
record: null,
|
||
match: metadataRecords.length ? liveBuildMetadataMatch(liveIdentity, metadataRecords[0]) : null
|
||
};
|
||
}
|
||
|
||
function liveBuildMetadataMatch(liveIdentity, metadataRecord) {
|
||
if (!metadataRecord) {
|
||
return {
|
||
matched: false,
|
||
matchedValues: [],
|
||
live: liveBuildIdentitySummary(liveIdentity),
|
||
metadata: null
|
||
};
|
||
}
|
||
const liveValues = uniqueStrings([
|
||
liveIdentity.imageTag,
|
||
imageTagFromReference(liveIdentity.imageReference),
|
||
liveIdentity.revision,
|
||
liveIdentity.commitId,
|
||
liveIdentity.digest
|
||
]).filter((value) => value !== "unknown");
|
||
const metadataValues = uniqueStrings([
|
||
metadataRecord.image?.tag,
|
||
imageTagFromReference(metadataRecord.image?.reference),
|
||
metadataRecord.revision,
|
||
metadataRecord.commit?.id,
|
||
metadataRecord.image?.digest
|
||
]).filter((value) => value !== "unknown" && value !== "not_published");
|
||
const matchedValues = liveValues.filter((liveValue) =>
|
||
metadataValues.some((metadataValue) => liveBuildIdentityEquivalent(liveValue, metadataValue))
|
||
);
|
||
return {
|
||
matched: matchedValues.length > 0,
|
||
matchedValues,
|
||
live: liveBuildIdentitySummary(liveIdentity),
|
||
metadata: {
|
||
imageTag: metadataRecord.image?.tag ?? "unknown",
|
||
revision: metadataRecord.revision ?? "unknown",
|
||
commitId: metadataRecord.commit?.id ?? "unknown",
|
||
digest: metadataRecord.image?.digest ?? "unknown"
|
||
}
|
||
};
|
||
}
|
||
|
||
function liveBuildIdentitySummary(identity) {
|
||
return {
|
||
imageTag: identity?.imageTag ?? "unknown",
|
||
revision: identity?.revision ?? "unknown",
|
||
commitId: identity?.commitId ?? "unknown",
|
||
digest: identity?.digest ?? "unknown"
|
||
};
|
||
}
|
||
|
||
function liveBuildIdentityEquivalent(left, right) {
|
||
const a = String(left ?? "").trim();
|
||
const b = String(right ?? "").trim();
|
||
if (!a || !b || a === "unknown" || b === "unknown") return false;
|
||
if (a === b) return true;
|
||
if (a.startsWith("sha256:") || b.startsWith("sha256:")) return false;
|
||
return a.length >= 7 && b.length >= 7 && (a.startsWith(b) || b.startsWith(a));
|
||
}
|
||
|
||
function liveBuildRecordsFromMetadata(service) {
|
||
return [
|
||
liveBuildRecordFromMetadataSource(service, "artifact"),
|
||
liveBuildRecordFromMetadataSource(service, "deploy")
|
||
].filter(Boolean);
|
||
}
|
||
|
||
function liveBuildRecordFromMetadataSource(service, sourceKind) {
|
||
const source = sourceKind === "artifact"
|
||
? service.artifact
|
||
: service.deploy;
|
||
if (!source) return null;
|
||
const createdAt = normalizeIsoTimestamp(
|
||
sourceKind === "deploy" ? source.env?.HWLAB_BUILD_CREATED_AT : source.buildCreatedAt
|
||
);
|
||
|
||
const imageReference = sourceKind === "deploy"
|
||
? source.env?.HWLAB_IMAGE ?? source.image ?? "unknown"
|
||
: source.image ?? service.deploy?.image ?? "unknown";
|
||
const commitId = sourceKind === "artifact"
|
||
? source.commitId ?? imageTagFromReference(imageReference) ?? "unknown"
|
||
: source.env?.HWLAB_COMMIT_ID ?? imageTagFromReference(imageReference) ?? "unknown";
|
||
const digest = sourceKind === "deploy"
|
||
? source.env?.HWLAB_IMAGE_DIGEST ?? "unknown"
|
||
: source.digest ?? "unknown";
|
||
const buildSource = sourceKind === "deploy"
|
||
? source.env?.HWLAB_BUILD_SOURCE ?? "deploy-desired-state-env"
|
||
: source.buildSource ?? "artifact-catalog-metadata";
|
||
const imageTag = sourceKind === "deploy"
|
||
? source.env?.HWLAB_IMAGE_TAG ?? imageTagFromReference(imageReference) ?? "unknown"
|
||
: source.imageTag ?? imageTagFromReference(imageReference) ?? "unknown";
|
||
const revision = sourceKind === "deploy"
|
||
? source.env?.HWLAB_REVISION ?? commitId
|
||
: commitId;
|
||
return {
|
||
serviceId: service.serviceId,
|
||
name: service.serviceName,
|
||
kind: "hwlab",
|
||
status: createdAt ? "ok" : "build_time_unavailable",
|
||
healthUrl: null,
|
||
httpStatus: null,
|
||
desiredState: desiredStateSummary(service),
|
||
build: {
|
||
createdAt,
|
||
source: buildSource,
|
||
metadataSource: artifactMetadataSource(sourceKind),
|
||
unavailableReason: createdAt
|
||
? null
|
||
: `构建时间不可用:${controlledMetadataLabel({ build: { metadataSource: artifactMetadataSource(sourceKind) } })} 缺少 buildCreatedAt`
|
||
},
|
||
image: {
|
||
reference: imageReference,
|
||
tag: imageTag,
|
||
digest
|
||
},
|
||
commit: {
|
||
id: commitId,
|
||
source: sourceKind === "artifact" ? "artifact-catalog" : "deploy-desired-state"
|
||
},
|
||
revision
|
||
};
|
||
}
|
||
|
||
function desiredStateSummary(service) {
|
||
return {
|
||
namespace: service.deploy?.namespace ?? service.artifact?.namespace ?? "hwlab-dev",
|
||
profile: service.deploy?.profile ?? service.artifact?.profile ?? ENVIRONMENT_DEV,
|
||
replicas: Number.isFinite(Number(service.deploy?.replicas)) ? Number(service.deploy.replicas) : null,
|
||
healthPath: service.healthPath ?? service.deploy?.healthPath ?? service.artifact?.healthPath ?? "/health/live",
|
||
source: service.deploy ? LIVE_BUILD_METADATA_PATHS.deployManifest : "service-defaults"
|
||
};
|
||
}
|
||
|
||
function artifactMetadataSource(sourceKind) {
|
||
if (sourceKind === "artifact") return "artifact-catalog:buildCreatedAt";
|
||
return "deploy-env:HWLAB_BUILD_CREATED_AT";
|
||
}
|
||
|
||
function controlledMetadataLabel(record) {
|
||
const source = String(record?.build?.metadataSource ?? "");
|
||
if (source.includes("artifact-catalog")) return "catalog metadata";
|
||
if (source.includes("deploy-env")) return "deploy metadata";
|
||
return "controlled metadata";
|
||
}
|
||
|
||
function unavailableLiveBuildRecord(service, { healthUrl = null, httpStatus = null, reasonCode, reason }) {
|
||
return {
|
||
serviceId: service.serviceId,
|
||
name: service.serviceName,
|
||
kind: "hwlab",
|
||
status: "unavailable",
|
||
healthUrl,
|
||
httpStatus,
|
||
desiredState: desiredStateSummary(service),
|
||
build: {
|
||
createdAt: null,
|
||
metadataSource: "unavailable",
|
||
unavailableReason: `构建时间不可用:${reason}`
|
||
},
|
||
image: {
|
||
reference: "unknown",
|
||
tag: "unknown",
|
||
digest: "unknown"
|
||
},
|
||
commit: {
|
||
id: "unknown",
|
||
source: "unavailable"
|
||
},
|
||
revision: "unknown",
|
||
error: {
|
||
code: reasonCode,
|
||
message: reason
|
||
}
|
||
};
|
||
}
|
||
|
||
function externalLiveBuildRecord(service, {
|
||
healthUrl = null,
|
||
httpStatus = null,
|
||
imageReference = "unknown",
|
||
imageTag = null,
|
||
imageDigest = "unknown",
|
||
commitId = "unknown",
|
||
commitSource = "external-image",
|
||
revision = commitId,
|
||
reason
|
||
}) {
|
||
return {
|
||
serviceId: service.serviceId,
|
||
name: service.serviceName,
|
||
kind: "external",
|
||
status: "external",
|
||
healthUrl,
|
||
httpStatus,
|
||
build: {
|
||
createdAt: null,
|
||
metadataSource: "external-image",
|
||
unavailableReason: reason
|
||
},
|
||
image: {
|
||
reference: imageReference,
|
||
tag: imageTag ?? imageTagFromReference(imageReference) ?? "unknown",
|
||
digest: imageDigest
|
||
},
|
||
commit: {
|
||
id: commitId,
|
||
source: commitSource
|
||
},
|
||
revision
|
||
};
|
||
}
|
||
|
||
function healthUnavailableReason(service, reason) {
|
||
if (Number(service.deploy?.replicas) === 0) {
|
||
return `${service.serviceId} 当前 hwlab-dev desired replicas=0,无可读取的常驻 /health/live:${reason}`;
|
||
}
|
||
if (service.activation === "manual") {
|
||
return `${service.serviceId} 是手动激活服务,当前 live health 不可用:${reason}`;
|
||
}
|
||
return reason;
|
||
}
|
||
|
||
function normalizeHealthBuildTime(payload) {
|
||
return normalizeIsoTimestamp(
|
||
payload?.build?.createdAt ??
|
||
payload?.image?.createdAt ??
|
||
payload?.buildCreatedAt ??
|
||
payload?.metadata?.buildCreatedAt ??
|
||
null
|
||
);
|
||
}
|
||
|
||
async function loadLiveBuildMetadata(options = {}) {
|
||
if (options.liveBuildMetadata) return normalizeLiveBuildMetadata(options.liveBuildMetadata);
|
||
const root = options.repoRoot ?? process.env.HWLAB_REPO_ROOT ?? repoRoot;
|
||
const [deployManifest, artifactCatalog] = await Promise.all([
|
||
readJsonMetadata(root, LIVE_BUILD_METADATA_PATHS.deployManifest),
|
||
readJsonMetadata(root, LIVE_BUILD_METADATA_PATHS.artifactCatalog)
|
||
]);
|
||
return normalizeLiveBuildMetadata({ deployManifest, artifactCatalog });
|
||
}
|
||
|
||
function normalizeLiveBuildMetadata(metadata) {
|
||
return {
|
||
deployManifest: normalizeMetadataFile(metadata.deployManifest),
|
||
artifactCatalog: normalizeMetadataFile(metadata.artifactCatalog)
|
||
};
|
||
}
|
||
|
||
async function readJsonMetadata(root, relativePath) {
|
||
try {
|
||
const payload = JSON.parse(await readFile(path.join(root, relativePath), "utf8"));
|
||
return { status: "ok", path: relativePath, payload };
|
||
} catch (error) {
|
||
return {
|
||
status: "unavailable",
|
||
path: relativePath,
|
||
error: error.message,
|
||
payload: null
|
||
};
|
||
}
|
||
}
|
||
|
||
function normalizeMetadataFile(file) {
|
||
if (file?.status === "ok" || file?.status === "unavailable") {
|
||
return {
|
||
status: file.status,
|
||
path: file.path ?? null,
|
||
error: file.error ?? null,
|
||
payload: file.payload ?? null
|
||
};
|
||
}
|
||
return {
|
||
status: file ? "ok" : "unavailable",
|
||
path: null,
|
||
error: null,
|
||
payload: file ?? null
|
||
};
|
||
}
|
||
|
||
function liveBuildServiceInventory(metadata) {
|
||
const deployServices = Array.isArray(metadata.deployManifest.payload?.services)
|
||
? metadata.deployManifest.payload.services
|
||
: [];
|
||
const deployByServiceId = new Map(deployServices.map((service) => [service.serviceId, service]));
|
||
const artifactByServiceId = new Map(
|
||
(Array.isArray(metadata.artifactCatalog.payload?.services) ? metadata.artifactCatalog.payload.services : [])
|
||
.map((service) => [service.serviceId, service])
|
||
);
|
||
const serviceIds = uniqueStrings([
|
||
...SERVICE_IDS,
|
||
...deployServices.map((service) => service.serviceId),
|
||
...artifactByServiceId.keys()
|
||
]).filter((serviceId) => String(serviceId).startsWith("hwlab-"));
|
||
|
||
return [
|
||
...serviceIds.map((serviceId) => {
|
||
const deploy = deployByServiceId.get(serviceId) ?? null;
|
||
const defaults = LIVE_BUILD_SERVICE_DEFAULTS[serviceId] ?? {};
|
||
const healthPath = deploy?.healthPath ?? defaults.healthPath ?? "/health/live";
|
||
return Object.freeze({
|
||
serviceId,
|
||
serviceName: deploy?.name ?? serviceId,
|
||
urlEnv: defaults.urlEnv ?? serviceUrlEnvName(serviceId),
|
||
defaultUrl: defaults.defaultUrl ?? serviceDefaultUrl(serviceId),
|
||
healthPath,
|
||
activation: deploy?.replicas === 0 ? "zero-replica" : defaults.activation,
|
||
deploy,
|
||
artifact: artifactByServiceId.get(serviceId) ?? null
|
||
});
|
||
}),
|
||
...LIVE_BUILD_EXTERNAL_COMPONENTS
|
||
];
|
||
}
|
||
|
||
function uniqueStrings(values) {
|
||
return [...new Set(values.map((value) => String(value ?? "").trim()).filter(Boolean))];
|
||
}
|
||
|
||
function serviceUrlEnvName(serviceId) {
|
||
return `${serviceId.replace(/^hwlab-/u, "HWLAB_").replace(/-/gu, "_").toUpperCase()}_URL`;
|
||
}
|
||
|
||
function serviceDefaultUrl(serviceId) {
|
||
const port = {
|
||
"hwlab-cloud-api": 6667,
|
||
"hwlab-cloud-web": 8080,
|
||
"hwlab-agent-mgr": 7410,
|
||
"hwlab-agent-worker": 7411,
|
||
"hwlab-gateway": 7001,
|
||
"hwlab-gateway-simu": 7101,
|
||
"hwlab-box-simu": 7201,
|
||
"hwlab-patch-panel": 7301,
|
||
"hwlab-router": 7401,
|
||
"hwlab-tunnel-client": 7402,
|
||
"hwlab-edge-proxy": 6667,
|
||
"hwlab-cli": 7501,
|
||
"hwlab-agent-skills": 7430
|
||
}[serviceId] ?? 8080;
|
||
return `http://${serviceId}.hwlab-dev.svc.cluster.local:${port}`;
|
||
}
|
||
|
||
async function handleGatewayPollHttp(request, response, options) {
|
||
const body = await readBody(request, options.bodyLimitBytes);
|
||
let params = {};
|
||
|
||
try {
|
||
params = body ? JSON.parse(body) : {};
|
||
} catch (error) {
|
||
sendJson(response, 400, {
|
||
accepted: false,
|
||
error: {
|
||
code: "parse_error",
|
||
message: "Invalid JSON body",
|
||
reason: error.message
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||
sendJson(response, 400, {
|
||
accepted: false,
|
||
error: {
|
||
code: "invalid_params",
|
||
message: "gateway poll body must be a JSON object"
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
const session = options.gatewayRegistry.updateSession(params);
|
||
await persistGatewayDemoSession({
|
||
runtimeStore: options.runtimeStore,
|
||
session,
|
||
request,
|
||
env: options.env
|
||
});
|
||
const outbound = options.gatewayRegistry.nextRequest(session.gatewaySessionId);
|
||
sendJson(response, 200, {
|
||
accepted: true,
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
mode: "gateway-outbound-poll-demo",
|
||
gatewayId: session.gatewayId,
|
||
gatewaySessionId: session.gatewaySessionId,
|
||
queueDepth: session.queue.length,
|
||
request: outbound,
|
||
type: outbound ? "request" : "noop",
|
||
observedAt: new Date().toISOString()
|
||
});
|
||
}
|
||
|
||
async function handleGatewayResultHttp(request, response, options) {
|
||
const body = await readBody(request, options.bodyLimitBytes);
|
||
let params = {};
|
||
|
||
try {
|
||
params = body ? JSON.parse(body) : {};
|
||
} catch (error) {
|
||
sendJson(response, 400, {
|
||
accepted: false,
|
||
error: {
|
||
code: "parse_error",
|
||
message: "Invalid JSON body",
|
||
reason: error.message
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
const result = options.gatewayRegistry.complete(params);
|
||
sendJson(response, result.accepted ? 200 : 404, {
|
||
...result,
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
mode: "gateway-outbound-poll-demo",
|
||
observedAt: new Date().toISOString()
|
||
});
|
||
}
|
||
|
||
async function persistGatewayDemoSession({ runtimeStore, session, request, env = process.env }) {
|
||
if (!runtimeStore) return;
|
||
const refreshMs = parsePositiveInteger(env.HWLAB_GATEWAY_REGISTRATION_REFRESH_MS, 60000);
|
||
if (session.runtimeRegisteredEpochMs && Date.now() - session.runtimeRegisteredEpochMs < refreshMs) {
|
||
return;
|
||
}
|
||
const meta = {
|
||
traceId: getHeader(request, "x-trace-id") || `trc_gateway_poll_${randomUUID()}`,
|
||
actorId: `svc_${session.serviceId}`,
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
environment: runtimeEnvironment(env)
|
||
};
|
||
|
||
try {
|
||
await runtimeStore.registerGatewaySession?.({
|
||
projectId: session.projectId,
|
||
gatewaySessionId: session.gatewaySessionId,
|
||
serviceId: "hwlab-gateway",
|
||
gatewayId: session.gatewayId,
|
||
endpoint: session.endpoint,
|
||
status: "connected",
|
||
labels: {
|
||
demoOutboundPoll: "true",
|
||
os: String(session.system?.platform ?? "unknown")
|
||
}
|
||
}, meta);
|
||
await runtimeStore.registerBoxResource?.({
|
||
projectId: session.projectId,
|
||
gatewaySessionId: session.gatewaySessionId,
|
||
resourceId: session.resourceId,
|
||
boxId: session.boxId,
|
||
resourceType: "simulator_endpoint",
|
||
state: "available",
|
||
metadata: {
|
||
serviceId: "hwlab-gateway",
|
||
demoShellHost: true
|
||
}
|
||
}, meta);
|
||
if (session.capabilities.length > 0) {
|
||
await runtimeStore.reportBoxCapabilities?.({
|
||
capabilities: session.capabilities.map((capability) => ({
|
||
...capability,
|
||
resourceId: capability.resourceId ?? session.resourceId,
|
||
projectId: capability.projectId ?? session.projectId
|
||
}))
|
||
}, meta);
|
||
}
|
||
session.runtimeRegisteredAt = new Date().toISOString();
|
||
session.runtimeRegisteredEpochMs = Date.now();
|
||
} catch {
|
||
// Demo polling must keep the gateway online even when durable runtime writes are not ready.
|
||
}
|
||
}
|
||
|
||
async function handleCodeAgentChatHttp(request, response, options) {
|
||
const body = await readBody(request, options.bodyLimitBytes);
|
||
let params = {};
|
||
|
||
try {
|
||
params = body ? JSON.parse(body) : {};
|
||
} catch (error) {
|
||
sendJson(response, 400, {
|
||
...createCodeAgentErrorPayload({
|
||
code: "parse_error",
|
||
message: "Invalid JSON body",
|
||
reason: error.message,
|
||
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
||
model: options.env?.HWLAB_CODE_AGENT_MODEL || options.env?.OPENAI_MODEL || process.env.HWLAB_CODE_AGENT_MODEL || process.env.OPENAI_MODEL || "unknown",
|
||
layer: "api",
|
||
retryable: true
|
||
})
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||
sendJson(response, 400, {
|
||
...createCodeAgentErrorPayload({
|
||
code: "invalid_params",
|
||
message: "Code Agent chat body must be a JSON object",
|
||
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
||
model: options.env?.HWLAB_CODE_AGENT_MODEL || options.env?.OPENAI_MODEL || process.env.HWLAB_CODE_AGENT_MODEL || process.env.OPENAI_MODEL || "unknown",
|
||
layer: "api",
|
||
retryable: true
|
||
})
|
||
});
|
||
return;
|
||
}
|
||
|
||
const traceId = safeTraceId(getHeader(request, "x-trace-id") || params.traceId) || `trc_${randomUUID()}`;
|
||
const chatParams = {
|
||
...params,
|
||
traceId
|
||
};
|
||
|
||
if (codeAgentChatShortConnectionRequested(request, params, options)) {
|
||
submitCodeAgentChatTurn({
|
||
params: chatParams,
|
||
options,
|
||
traceId
|
||
});
|
||
const traceUrl = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`;
|
||
sendJson(response, 202, {
|
||
accepted: true,
|
||
status: "running",
|
||
shortConnection: true,
|
||
controlSemantics: "submit-and-poll",
|
||
traceId,
|
||
conversationId: safeConversationId(params.conversationId) || null,
|
||
sessionId: safeSessionId(params.sessionId) || null,
|
||
traceUrl,
|
||
resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`,
|
||
streamUrl: `${traceUrl}/stream`,
|
||
cancelUrl: "/v1/agent/chat/cancel",
|
||
polling: {
|
||
resultIntervalMs: parsePositiveInteger(options.env?.HWLAB_CODE_AGENT_RESULT_POLL_INTERVAL_MS, 1000),
|
||
traceIntervalMs: parsePositiveInteger(options.env?.HWLAB_CODE_AGENT_TRACE_POLL_INTERVAL_MS, 1000)
|
||
},
|
||
runnerTrace: (options.traceStore ?? defaultCodeAgentTraceStore).snapshot(traceId)
|
||
});
|
||
return;
|
||
}
|
||
|
||
const payload = await runCodeAgentChat(chatParams, options);
|
||
|
||
sendJson(response, payload.status === "failed" && payload.error?.code === "invalid_params" ? 400 : 200, payload);
|
||
}
|
||
|
||
function runCodeAgentChat(params, options) {
|
||
return handleCodeAgentChat(params, codeAgentChatExecutionOptions(options, params));
|
||
}
|
||
|
||
function codeAgentChatExecutionOptions(options = {}, params = {}) {
|
||
return {
|
||
callProvider: options.callCodeAgentProvider,
|
||
timeoutMs: options.codeAgentTimeoutMs,
|
||
hardTimeoutMs: options.codeAgentHardTimeoutMs,
|
||
env: codeAgentProviderProfileEnv(options.env ?? process.env, params),
|
||
workspace: options.workspace,
|
||
skillsDirs: options.skillsDirs,
|
||
skillsDirsExact: options.skillsDirsExact,
|
||
skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs,
|
||
sessionRegistry: options.sessionRegistry,
|
||
m3IoSkillRequestJson: options.m3IoSkillRequestJson ?? createCodeAgentM3HwlabApiRequestJson(options),
|
||
codexStdioManager: options.codexStdioManager,
|
||
traceStore: options.traceStore,
|
||
gatewayRegistry: options.gatewayRegistry
|
||
};
|
||
}
|
||
|
||
function codeAgentProviderProfileEnv(env = process.env, params = {}) {
|
||
const profile = normalizeCodeAgentProviderProfile(params.providerProfile ?? params.codeAgentProviderProfile ?? env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE);
|
||
if (profile === "runtime-default") return env;
|
||
const overlay = { ...env };
|
||
overlay.HWLAB_CODE_AGENT_SELECTED_PROVIDER_PROFILE = profile;
|
||
overlay.HWLAB_CODE_AGENT_PROVIDER_PROFILE_LABEL = CODE_AGENT_PROVIDER_PROFILE_LABELS[profile] ?? profile;
|
||
overlay.HWLAB_CODE_AGENT_PROVIDER = "codex-stdio";
|
||
if (profile === "codex-api") {
|
||
overlay.HWLAB_CODE_AGENT_MODEL = firstNonEmptyValue(env.HWLAB_CODE_AGENT_CODEX_API_MODEL, env.HWLAB_CODE_AGENT_LEGACY_CODEX_MODEL, DEFAULT_CODE_AGENT_CODEX_API_MODEL);
|
||
overlay.HWLAB_CODE_AGENT_OPENAI_BASE_URL = firstNonEmptyValue(env.HWLAB_CODE_AGENT_CODEX_API_BASE_URL, env.HWLAB_CODE_AGENT_LEGACY_OPENAI_BASE_URL, DEFAULT_CODE_AGENT_CODEX_API_BASE_URL);
|
||
} else {
|
||
overlay.HWLAB_CODE_AGENT_MODEL = firstNonEmptyValue(env.HWLAB_CODE_AGENT_DEEPSEEK_MODEL, DEFAULT_CODE_AGENT_DEEPSEEK_MODEL);
|
||
overlay.HWLAB_CODE_AGENT_OPENAI_BASE_URL = firstNonEmptyValue(env.HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL, defaultDeepSeekBaseUrlForEnv(env));
|
||
}
|
||
overlay.NO_PROXY = mergeNoProxyEntries(env.NO_PROXY, codeAgentClusterNoProxyEntries(env));
|
||
overlay.no_proxy = mergeNoProxyEntries(env.no_proxy ?? env.NO_PROXY, codeAgentClusterNoProxyEntries(env));
|
||
return overlay;
|
||
}
|
||
|
||
function normalizeCodeAgentProviderProfile(value) {
|
||
const text = String(value ?? DEFAULT_CODE_AGENT_PROVIDER_PROFILE).trim().toLowerCase();
|
||
return CODE_AGENT_PROVIDER_PROFILE_IDS.includes(text) ? text : DEFAULT_CODE_AGENT_PROVIDER_PROFILE;
|
||
}
|
||
|
||
function defaultDeepSeekBaseUrlForEnv(env = process.env) {
|
||
const namespace = env.HWLAB_GITOPS_PROFILE === "prod" || env.HWLAB_ENVIRONMENT === "prod" ? "hwlab-prod" : "hwlab-dev";
|
||
return `http://hwlab-deepseek-proxy.${namespace}.svc.cluster.local:4000/v1/responses`;
|
||
}
|
||
|
||
function codeAgentClusterNoProxyEntries(env = process.env) {
|
||
const namespace = env.HWLAB_GITOPS_PROFILE === "prod" || env.HWLAB_ENVIRONMENT === "prod" ? "hwlab-prod" : "hwlab-dev";
|
||
return [
|
||
`${namespace}.svc.cluster.local`,
|
||
".svc",
|
||
".cluster.local",
|
||
"127.0.0.1",
|
||
"localhost",
|
||
"::1",
|
||
"10.0.0.0/8",
|
||
"10.42.0.0/16",
|
||
"10.43.0.0/16",
|
||
"hyueapi.com",
|
||
".hyueapi.com",
|
||
"hyui.com",
|
||
".hyui.com",
|
||
"api.minimaxi.com",
|
||
".minimaxi.com"
|
||
];
|
||
}
|
||
|
||
function mergeNoProxyEntries(current, additions = []) {
|
||
return uniqueStrings([...String(current ?? "").split(","), ...additions]).join(",");
|
||
}
|
||
|
||
function firstNonEmptyValue(...values) {
|
||
for (const value of values) {
|
||
const text = String(value ?? "").trim();
|
||
if (text) return text;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function submitCodeAgentChatTurn({ params, options, traceId }) {
|
||
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
||
const results = options.codeAgentChatResults ?? createCodeAgentChatResultStore();
|
||
results.set(traceId, {
|
||
accepted: true,
|
||
status: "running",
|
||
traceId,
|
||
conversationId: safeConversationId(params.conversationId) || null,
|
||
sessionId: safeSessionId(params.sessionId) || null,
|
||
updatedAt: new Date().toISOString()
|
||
});
|
||
traceStore.ensure(traceId, {
|
||
runnerKind: "codex-app-server-stdio-runner",
|
||
workspace: options.workspace ?? options.env?.HWLAB_CODE_AGENT_CODEX_WORKSPACE ?? options.env?.HWLAB_CODE_AGENT_WORKSPACE ?? null,
|
||
sandbox: options.env?.HWLAB_CODE_AGENT_CODEX_SANDBOX ?? null,
|
||
sessionMode: "codex-app-server-stdio-long-lived",
|
||
implementationType: "repo-owned-codex-app-server-stdio-session"
|
||
});
|
||
traceStore.append(traceId, {
|
||
type: "request",
|
||
status: "accepted",
|
||
label: "request:accepted-short-connection",
|
||
message: "Code Agent request accepted; client must poll trace/result with short HTTP requests.",
|
||
waitingFor: "codex-stdio"
|
||
});
|
||
|
||
const run = async () => {
|
||
try {
|
||
const payload = await runCodeAgentChat(params, options);
|
||
if (isCodeAgentResultCanceled(results.get(traceId))) return;
|
||
results.set(traceId, payload);
|
||
traceStore.append(traceId, {
|
||
type: "result",
|
||
status: payload.status === "completed" ? "completed" : "failed",
|
||
label: `result:${payload.status}`,
|
||
message: payload.status === "completed"
|
||
? "Code Agent result is ready for short-connection polling."
|
||
: payload.error?.message ?? "Code Agent completed without a successful reply.",
|
||
sessionId: payload.sessionId ?? payload.session?.sessionId,
|
||
sessionStatus: payload.session?.status,
|
||
terminal: true
|
||
});
|
||
} catch (error) {
|
||
if (isCodeAgentResultCanceled(results.get(traceId))) return;
|
||
const payload = {
|
||
status: "failed",
|
||
traceId,
|
||
conversationId: safeConversationId(params.conversationId) || null,
|
||
sessionId: safeSessionId(params.sessionId) || null,
|
||
error: {
|
||
code: "code_agent_background_failed",
|
||
layer: "api",
|
||
retryable: true,
|
||
message: error?.message ?? "Code Agent background turn failed",
|
||
userMessage: "Code Agent 后台任务失败;trace/result 轮询已保留错误。"
|
||
},
|
||
updatedAt: new Date().toISOString()
|
||
};
|
||
results.set(traceId, payload);
|
||
traceStore.append(traceId, {
|
||
type: "result",
|
||
status: "failed",
|
||
label: "result:failed",
|
||
errorCode: payload.error.code,
|
||
message: payload.error.message,
|
||
terminal: true
|
||
});
|
||
}
|
||
};
|
||
setImmediate(() => {
|
||
run();
|
||
});
|
||
}
|
||
|
||
function codeAgentChatShortConnectionRequested(request, params, options = {}) {
|
||
const header = firstHeaderValue(request, "prefer");
|
||
const explicit = firstHeaderValue(request, "x-hwlab-short-connection");
|
||
const envValue = options.env?.HWLAB_CODE_AGENT_CHAT_SHORT_CONNECTION;
|
||
return /(?:^|,)\s*respond-async\s*(?:,|$)/iu.test(String(header ?? "")) ||
|
||
truthyFlag(explicit) ||
|
||
params?.shortConnection === true ||
|
||
truthyFlag(envValue);
|
||
}
|
||
|
||
async function handleCodeAgentChatResultHttp(request, response, url, options) {
|
||
const parts = url.pathname.split("/").filter(Boolean);
|
||
const traceId = decodeURIComponent(parts[4] ?? "");
|
||
if (!safeTraceId(traceId)) {
|
||
sendJson(response, 400, {
|
||
error: {
|
||
code: "invalid_trace_id",
|
||
message: "traceId must start with trc_ and contain only safe identifier characters"
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
const results = options.codeAgentChatResults;
|
||
const result = results?.get(traceId) ?? null;
|
||
if (result && result.status !== "running") {
|
||
sendJson(response, 200, compactCodeAgentChatResultPayload(result, options));
|
||
return;
|
||
}
|
||
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
||
const runnerTrace = traceStore.snapshot(traceId);
|
||
if (result || runnerTrace.status !== "missing") {
|
||
sendJson(response, 202, {
|
||
accepted: true,
|
||
status: "running",
|
||
shortConnection: true,
|
||
traceId,
|
||
runnerTrace: compactRunnerTraceForResult(runnerTrace, resultTraceEventLimit(options)),
|
||
waitingFor: runnerTrace.waitingFor ?? "codex-stdio"
|
||
});
|
||
return;
|
||
}
|
||
sendJson(response, 404, {
|
||
error: {
|
||
code: "code_agent_result_not_found",
|
||
message: `No Code Agent result is registered for ${traceId}`
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleCodeAgentInspectHttp(request, response, url, options) {
|
||
const query = {
|
||
conversationId: safeConversationId(url.searchParams.get("conversationId")),
|
||
sessionId: safeSessionId(url.searchParams.get("sessionId")),
|
||
threadId: safeOpaqueId(url.searchParams.get("threadId")),
|
||
traceId: safeTraceId(url.searchParams.get("traceId"))
|
||
};
|
||
const sessionRegistry = options.sessionRegistry;
|
||
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
||
const manager = options.codexStdioManager;
|
||
const managerDescription = manager && typeof manager.describe === "function" ? manager.describe() : null;
|
||
const managerRecentSessions = Array.isArray(managerDescription?.recentSessions) ? managerDescription.recentSessions : [];
|
||
const directManagerSession = query.sessionId && manager && typeof manager.get === "function"
|
||
? manager.get(query.sessionId, { conversationId: query.conversationId })
|
||
: null;
|
||
const matchedManagerSession = directManagerSession ?? managerRecentSessions.find((session) => sessionMatchesInspectQuery(session, query)) ?? null;
|
||
const registryInspect = sessionRegistry && typeof sessionRegistry.inspect === "function"
|
||
? sessionRegistry.inspect(query)
|
||
: { ok: false, status: "unavailable", traceIds: [], session: null, conversationFacts: null };
|
||
const session = matchedManagerSession ?? registryInspect.session ?? null;
|
||
const conversationFacts = registryInspect.conversationFacts ?? null;
|
||
const requestedRunnerTrace = query.traceId ? traceStore.snapshot(query.traceId) : null;
|
||
const requestedTraceFound = Boolean(requestedRunnerTrace && requestedRunnerTrace.status !== "missing");
|
||
const traceIds = uniqueStrings([
|
||
requestedTraceFound ? query.traceId : null,
|
||
session?.currentTraceId,
|
||
session?.lastTraceId,
|
||
conversationFacts?.latestTraceId,
|
||
...(Array.isArray(conversationFacts?.traceIds) ? conversationFacts.traceIds : []),
|
||
...(Array.isArray(registryInspect.traceIds) ? registryInspect.traceIds : [])
|
||
]);
|
||
const latestTraceId = traceIds[0] ?? null;
|
||
const runnerTrace = latestTraceId
|
||
? latestTraceId === query.traceId && requestedRunnerTrace
|
||
? requestedRunnerTrace
|
||
: traceStore.snapshot(latestTraceId)
|
||
: null;
|
||
const found = Boolean(registryInspect.ok || session || latestTraceId);
|
||
sendJson(response, found ? 200 : 404, {
|
||
ok: found,
|
||
action: "code-agent.chat.inspect",
|
||
status: found ? "found" : "not_found",
|
||
query,
|
||
session,
|
||
conversationFacts,
|
||
traceIds,
|
||
latestTraceId,
|
||
traceUrl: latestTraceId ? `/v1/agent/chat/trace/${encodeURIComponent(latestTraceId)}` : null,
|
||
resultUrl: latestTraceId ? `/v1/agent/chat/result/${encodeURIComponent(latestTraceId)}` : null,
|
||
runnerTrace,
|
||
valuesRedacted: true,
|
||
secretMaterialStored: false
|
||
});
|
||
}
|
||
|
||
function sessionMatchesInspectQuery(session, query) {
|
||
if (!session || typeof session !== "object") return false;
|
||
if (query.sessionId && session.sessionId === query.sessionId) return true;
|
||
if (query.threadId && session.threadId === query.threadId) return true;
|
||
if (query.conversationId && session.conversationId === query.conversationId) return true;
|
||
if (query.traceId && (session.currentTraceId === query.traceId || session.lastTraceId === query.traceId)) return true;
|
||
return false;
|
||
}
|
||
|
||
async function handleCodeAgentCancelHttp(request, response, options) {
|
||
const body = await readBody(request, options.bodyLimitBytes);
|
||
let params = {};
|
||
|
||
try {
|
||
params = body ? JSON.parse(body) : {};
|
||
} catch (error) {
|
||
sendJson(response, 400, {
|
||
...createCodeAgentErrorPayload({
|
||
code: "parse_error",
|
||
message: "Invalid JSON body",
|
||
reason: error.message,
|
||
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
||
layer: "api",
|
||
retryable: true
|
||
})
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||
sendJson(response, 400, {
|
||
...createCodeAgentErrorPayload({
|
||
code: "invalid_params",
|
||
message: "Code Agent cancel body must be a JSON object",
|
||
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
||
layer: "api",
|
||
retryable: true
|
||
})
|
||
});
|
||
return;
|
||
}
|
||
|
||
const traceId = safeTraceId(getHeader(request, "x-trace-id") || params.traceId);
|
||
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
||
const snapshot = traceId ? traceStore.snapshot(traceId) : null;
|
||
const sessionId = safeSessionId(params.sessionId) || safeSessionId(snapshot?.sessionId);
|
||
const conversationId = safeConversationId(params.conversationId);
|
||
const manager = options.codexStdioManager;
|
||
|
||
if (!traceId) {
|
||
sendJson(response, 400, cancelBlockedPayload({
|
||
code: "cancel_trace_missing",
|
||
message: "traceId is required to cancel the current Code Agent request.",
|
||
traceId: "trc_unassigned",
|
||
conversationId,
|
||
sessionId
|
||
}));
|
||
return;
|
||
}
|
||
|
||
if (!manager || typeof manager.get !== "function" || typeof manager.cancel !== "function") {
|
||
traceStore.append(traceId, {
|
||
type: "cancel",
|
||
status: "unsupported",
|
||
label: "cancel:unsupported",
|
||
errorCode: "cancel_unsupported",
|
||
message: "Codex stdio cancel/interrupt backend is not available on this runtime.",
|
||
waitingFor: "session-control"
|
||
});
|
||
const runnerTrace = traceStore.snapshot(traceId);
|
||
sendJson(response, 501, cancelBlockedPayload({
|
||
code: "cancel_unsupported",
|
||
message: "当前后端不支持 Code Agent interrupt/cancel;本次按 unsupported/degraded 返回,不会静默假装已中断。",
|
||
traceId,
|
||
conversationId,
|
||
sessionId,
|
||
runnerTrace,
|
||
status: "degraded",
|
||
unsupported: true,
|
||
degraded: true
|
||
}));
|
||
return;
|
||
}
|
||
|
||
if (!sessionId) {
|
||
traceStore.append(traceId, {
|
||
type: "cancel",
|
||
status: "blocked",
|
||
label: "cancel:not_cancelable",
|
||
errorCode: "cancel_session_missing",
|
||
message: "Cancel request did not include a bound Codex stdio sessionId.",
|
||
waitingFor: "session-binding"
|
||
});
|
||
sendJson(response, 409, cancelBlockedPayload({
|
||
code: "cancel_session_missing",
|
||
message: "当前请求尚未暴露可取消的 Codex stdio sessionId;页面不能只隐藏 UI,已保留输入和 trace。",
|
||
traceId,
|
||
conversationId,
|
||
sessionId: null,
|
||
runnerTrace: traceStore.snapshot(traceId)
|
||
}));
|
||
return;
|
||
}
|
||
|
||
const currentSession = manager.get(sessionId, { conversationId }) ?? null;
|
||
if (!currentSession || !["busy", "creating"].includes(currentSession.status)) {
|
||
traceStore.append(traceId, {
|
||
type: "cancel",
|
||
status: "blocked",
|
||
label: "cancel:not_in_flight",
|
||
errorCode: "cancel_not_in_flight",
|
||
message: `Session ${sessionId} is not an in-flight Codex stdio request.`,
|
||
sessionId,
|
||
sessionStatus: currentSession?.status ?? "missing"
|
||
});
|
||
sendJson(response, 409, cancelBlockedPayload({
|
||
code: currentSession ? "cancel_not_in_flight" : "cancel_session_not_found",
|
||
message: currentSession
|
||
? `当前 session 状态为 ${currentSession.status},没有可取消的 in-flight Codex stdio 请求。`
|
||
: `没有找到 sessionId=${sessionId} 的 Codex stdio session。`,
|
||
traceId,
|
||
conversationId,
|
||
sessionId,
|
||
session: currentSession,
|
||
runnerTrace: traceStore.snapshot(traceId)
|
||
}));
|
||
return;
|
||
}
|
||
|
||
const canceledSession = manager.cancel(sessionId, {
|
||
traceId,
|
||
conversationId,
|
||
reason: "user_cancel"
|
||
});
|
||
traceStore.append(traceId, {
|
||
type: "cancel",
|
||
status: "canceled",
|
||
label: "cancel:canceled",
|
||
message: "User canceled the current Codex stdio request.",
|
||
sessionId,
|
||
sessionStatus: canceledSession?.status ?? "canceled",
|
||
sessionLifecycleStatus: codeAgentSessionLifecycleSummary({ session: canceledSession, status: "canceled" }).status,
|
||
waitingFor: "user-retry",
|
||
terminal: true
|
||
});
|
||
const runnerTraceSnapshot = traceStore.snapshot(traceId);
|
||
const sessionSummary = codeAgentSessionLifecycleSummary({
|
||
session: canceledSession,
|
||
runnerTrace: runnerTraceSnapshot,
|
||
status: "canceled"
|
||
});
|
||
const runnerTrace = {
|
||
...runnerTraceSnapshot,
|
||
sessionLifecycleStatus: sessionSummary.status
|
||
};
|
||
const payload = {
|
||
accepted: true,
|
||
canceled: true,
|
||
status: "canceled",
|
||
conversationId: conversationId ?? canceledSession?.conversationId ?? null,
|
||
sessionId,
|
||
traceId,
|
||
session: canceledSession,
|
||
sessionLifecycleStatus: sessionSummary.status,
|
||
sessionLifecycle: sessionSummary,
|
||
sessionSummary,
|
||
runnerTrace,
|
||
lastTraceEvent: runnerTrace.lastEvent,
|
||
retryable: true,
|
||
error: {
|
||
code: "codex_stdio_canceled",
|
||
layer: "session",
|
||
category: "canceled",
|
||
retryable: true,
|
||
message: "user canceled current Code Agent request",
|
||
userMessage: "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。",
|
||
traceId,
|
||
route: "/v1/agent/chat/cancel",
|
||
toolName: "codex-stdio.cancel"
|
||
},
|
||
userMessage: "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。",
|
||
updatedAt: new Date().toISOString()
|
||
};
|
||
options.codeAgentChatResults?.set(traceId, payload);
|
||
sendJson(response, 200, payload);
|
||
}
|
||
|
||
function isCodeAgentResultCanceled(result) {
|
||
return result?.status === "canceled" || result?.canceled === true || result?.error?.code === "codex_stdio_canceled";
|
||
}
|
||
|
||
function cancelBlockedPayload({
|
||
code,
|
||
message,
|
||
traceId,
|
||
conversationId = null,
|
||
sessionId = null,
|
||
session = null,
|
||
runnerTrace = null,
|
||
status = "failed",
|
||
unsupported = false,
|
||
degraded = false
|
||
}) {
|
||
const sessionSummary = codeAgentSessionLifecycleSummary({
|
||
session,
|
||
runnerTrace,
|
||
status,
|
||
unsupported,
|
||
degraded,
|
||
error: { code, userMessage: message, message }
|
||
});
|
||
return {
|
||
accepted: false,
|
||
canceled: false,
|
||
status,
|
||
conversationId,
|
||
sessionId,
|
||
traceId,
|
||
session,
|
||
sessionLifecycleStatus: sessionSummary.status,
|
||
sessionLifecycle: sessionSummary,
|
||
sessionSummary,
|
||
unsupported,
|
||
degraded,
|
||
runnerTrace,
|
||
lastTraceEvent: runnerTrace?.lastEvent ?? null,
|
||
error: {
|
||
code,
|
||
layer: "session",
|
||
category: "cancel_blocked",
|
||
retryable: true,
|
||
userMessage: message,
|
||
message,
|
||
traceId,
|
||
route: "/v1/agent/chat/cancel",
|
||
toolName: "codex-stdio.cancel"
|
||
},
|
||
blocker: {
|
||
code,
|
||
layer: "session",
|
||
category: "cancel_blocked",
|
||
retryable: true,
|
||
summary: message,
|
||
userMessage: message,
|
||
traceId,
|
||
route: "/v1/agent/chat/cancel",
|
||
toolName: "codex-stdio.cancel"
|
||
}
|
||
};
|
||
}
|
||
|
||
async function handleCodeAgentTraceHttp(request, response, url, options) {
|
||
const parts = url.pathname.split("/").filter(Boolean);
|
||
const traceId = decodeURIComponent(parts[4] ?? "");
|
||
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
||
if (!/^trc_[A-Za-z0-9_.:-]+$/u.test(traceId)) {
|
||
sendJson(response, 400, {
|
||
error: {
|
||
code: "invalid_trace_id",
|
||
message: "traceId must start with trc_ and contain only safe identifier characters"
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (parts[5] === "stream") {
|
||
sendTraceSse(response, traceStore, traceId, options);
|
||
return;
|
||
}
|
||
|
||
sendJson(response, 200, traceStore.snapshot(traceId));
|
||
}
|
||
|
||
function sendTraceSse(response, traceStore, traceId, options = {}) {
|
||
response.writeHead(200, {
|
||
"content-type": "text/event-stream; charset=utf-8",
|
||
"cache-control": "no-store",
|
||
connection: "keep-alive",
|
||
"x-accel-buffering": "no"
|
||
});
|
||
const writeEvent = (eventName, payload) => {
|
||
response.write(`event: ${eventName}\n`);
|
||
response.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||
};
|
||
writeEvent("snapshot", traceStore.snapshot(traceId));
|
||
const unsubscribe = traceStore.subscribe(traceId, (event, snapshot) => {
|
||
writeEvent("runnerTrace", { event, snapshot });
|
||
});
|
||
const heartbeatMs = parsePositiveInteger(options.env?.HWLAB_CODE_AGENT_TRACE_HEARTBEAT_MS, 15000);
|
||
const heartbeat = setInterval(() => {
|
||
writeEvent("heartbeat", traceStore.snapshot(traceId));
|
||
}, heartbeatMs);
|
||
response.on("close", () => {
|
||
clearInterval(heartbeat);
|
||
unsubscribe();
|
||
});
|
||
}
|
||
|
||
function createCodeAgentChatResultStore({ maxResults = 256 } = {}) {
|
||
const limit = positiveInteger(maxResults, 256);
|
||
const results = new Map();
|
||
function set(traceId, payload) {
|
||
const id = safeTraceId(traceId);
|
||
if (!id) return;
|
||
results.set(id, {
|
||
...payload,
|
||
traceId: id,
|
||
cachedAt: new Date().toISOString()
|
||
});
|
||
while (results.size > limit) {
|
||
const oldest = results.keys().next().value;
|
||
if (!oldest) break;
|
||
results.delete(oldest);
|
||
}
|
||
}
|
||
return {
|
||
set,
|
||
get: (traceId) => results.get(safeTraceId(traceId)) ?? null,
|
||
clear: () => results.clear()
|
||
};
|
||
}
|
||
|
||
function compactCodeAgentChatResultPayload(payload, options = {}) {
|
||
if (!payload || typeof payload !== "object") return payload;
|
||
const limit = resultTraceEventLimit(options);
|
||
return {
|
||
...payload,
|
||
...(payload.runnerTrace && typeof payload.runnerTrace === "object"
|
||
? { runnerTrace: compactRunnerTraceForResult(payload.runnerTrace, limit) }
|
||
: {})
|
||
};
|
||
}
|
||
|
||
function compactRunnerTraceForResult(runnerTrace, limit = DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT) {
|
||
if (!runnerTrace || typeof runnerTrace !== "object") return runnerTrace;
|
||
const events = Array.isArray(runnerTrace.events) ? runnerTrace.events : [];
|
||
const total = Number.isInteger(runnerTrace.eventCount) ? runnerTrace.eventCount : events.length;
|
||
const effectiveLimit = Math.max(8, positiveInteger(limit, DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT));
|
||
if (events.length <= effectiveLimit) {
|
||
return {
|
||
...runnerTrace,
|
||
eventCount: total,
|
||
eventsCompacted: false
|
||
};
|
||
}
|
||
const headCount = Math.min(24, Math.max(1, Math.floor(effectiveLimit / 4)));
|
||
const tailCount = Math.max(1, effectiveLimit - headCount - 1);
|
||
const omitted = Math.max(0, events.length - headCount - tailCount);
|
||
const head = events.slice(0, headCount);
|
||
const tail = events.slice(events.length - tailCount);
|
||
const marker = {
|
||
traceId: runnerTrace.traceId ?? head[0]?.traceId ?? tail[0]?.traceId ?? null,
|
||
type: "trace",
|
||
stage: "trace",
|
||
status: "truncated",
|
||
label: "trace:compacted",
|
||
createdAt: tail[0]?.createdAt ?? runnerTrace.updatedAt ?? new Date().toISOString(),
|
||
message: `Result polling response compacted ${omitted} trace events; fetch /v1/agent/chat/trace/${encodeURIComponent(runnerTrace.traceId ?? "")} for the full trace.`,
|
||
omittedEventCount: omitted,
|
||
retainedEventCount: head.length + tail.length,
|
||
totalEventCount: total,
|
||
valuesPrinted: false
|
||
};
|
||
const compactEvents = [...head, marker, ...tail];
|
||
return {
|
||
...runnerTrace,
|
||
eventCount: total,
|
||
events: compactEvents,
|
||
eventLabels: compactEvents.map(traceEventLabel).filter(Boolean),
|
||
eventsCompacted: true,
|
||
eventWindow: {
|
||
mode: "head-tail",
|
||
retained: compactEvents.length,
|
||
omitted,
|
||
total
|
||
}
|
||
};
|
||
}
|
||
|
||
function traceEventLabel(event) {
|
||
return typeof event === "string" ? event : event?.label;
|
||
}
|
||
|
||
function resultTraceEventLimit(options = {}) {
|
||
return positiveInteger(
|
||
options.env?.HWLAB_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT,
|
||
DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT
|
||
);
|
||
}
|
||
|
||
function createCodeAgentM3HwlabApiRequestJson(options = {}) {
|
||
return async (targetUrl, request = {}) => {
|
||
const url = new URL(targetUrl);
|
||
const route = url.pathname;
|
||
if (route === M3_IO_CONTROL_ROUTE && request.method === "POST") {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
body: await handleM3IoControl(request.body ?? {}, {
|
||
runtimeStore: options.runtimeStore,
|
||
now: options.now,
|
||
env: options.env,
|
||
requestJson: options.m3IoRequestJson
|
||
})
|
||
};
|
||
}
|
||
if (route === M3_STATUS_ROUTE && request.method === "GET") {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
body: await describeM3StatusLive(options)
|
||
};
|
||
}
|
||
return {
|
||
ok: false,
|
||
status: 404,
|
||
body: {
|
||
error: {
|
||
code: "not_found",
|
||
message: `Unsupported Code Agent M3 HWLAB API route ${route}`
|
||
}
|
||
},
|
||
error: `Unsupported Code Agent M3 HWLAB API route ${route}`
|
||
};
|
||
};
|
||
}
|
||
|
||
async function handleM3IoControlHttp(request, response, options) {
|
||
const body = await readBody(request, options.bodyLimitBytes);
|
||
let params = {};
|
||
|
||
try {
|
||
params = body ? JSON.parse(body) : {};
|
||
} catch (error) {
|
||
sendJson(response, 400, {
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
contractVersion: "m3-io-control-v1",
|
||
route: M3_IO_CONTROL_ROUTE,
|
||
method: "POST",
|
||
status: "blocked",
|
||
accepted: false,
|
||
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
||
operationId: null,
|
||
auditId: null,
|
||
evidenceId: null,
|
||
audit: {
|
||
auditId: null,
|
||
status: "not_written"
|
||
},
|
||
evidence: {
|
||
evidenceId: null,
|
||
status: "blocked",
|
||
sourceKind: "BLOCKED"
|
||
},
|
||
readback: null,
|
||
error: {
|
||
code: "parse_error",
|
||
message: "Invalid JSON body",
|
||
reason: error.message
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||
sendJson(response, 400, {
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
contractVersion: "m3-io-control-v1",
|
||
route: M3_IO_CONTROL_ROUTE,
|
||
method: "POST",
|
||
status: "blocked",
|
||
accepted: false,
|
||
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
||
operationId: null,
|
||
auditId: null,
|
||
evidenceId: null,
|
||
audit: {
|
||
auditId: null,
|
||
status: "not_written"
|
||
},
|
||
evidence: {
|
||
evidenceId: null,
|
||
status: "blocked",
|
||
sourceKind: "BLOCKED"
|
||
},
|
||
readback: null,
|
||
error: {
|
||
code: "invalid_params",
|
||
message: "M3 IO control body must be a JSON object"
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const payload = await handleM3IoControl(
|
||
{
|
||
...params,
|
||
traceId: getHeader(request, "x-trace-id") || params.traceId,
|
||
requestId: getHeader(request, "x-request-id") || params.requestId,
|
||
actorId: getHeader(request, "x-actor-id") || params.actorId
|
||
},
|
||
{
|
||
runtimeStore: options.runtimeStore,
|
||
env: options.env,
|
||
requestJson: options.m3IoRequestJson,
|
||
traceId: getHeader(request, "x-trace-id"),
|
||
requestId: getHeader(request, "x-request-id"),
|
||
actorId: getHeader(request, "x-actor-id")
|
||
}
|
||
);
|
||
sendJson(response, payload.httpStatus ?? 200, payload);
|
||
} catch (error) {
|
||
const protocolCode = Number.isInteger(error?.code) ? error.code : ERROR_CODES.internalError;
|
||
sendJson(response, protocolCode === ERROR_CODES.invalidParams ? 400 : 200, {
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
contractVersion: "m3-io-control-v1",
|
||
route: M3_IO_CONTROL_ROUTE,
|
||
method: "POST",
|
||
status: "blocked",
|
||
accepted: false,
|
||
traceId: getHeader(request, "x-trace-id") || params.traceId || "trc_unassigned",
|
||
operationId: null,
|
||
auditId: null,
|
||
evidenceId: null,
|
||
audit: {
|
||
auditId: null,
|
||
status: "not_written"
|
||
},
|
||
evidence: {
|
||
evidenceId: null,
|
||
status: "blocked",
|
||
sourceKind: "BLOCKED"
|
||
},
|
||
readback: null,
|
||
error: {
|
||
code: protocolCode,
|
||
message: error.message,
|
||
data: error.data ?? {}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function sendRestError(request, response, statusCode, code, message, context = {}) {
|
||
const requestId = getHeader(request, "x-request-id") || "req_unassigned";
|
||
sendJson(response, statusCode, {
|
||
error: {
|
||
code,
|
||
message,
|
||
reason: context.reason,
|
||
audit: createAuditRecord({
|
||
requestId,
|
||
actor: {
|
||
type: "user",
|
||
id: getHeader(request, "x-actor-id") || `system_${CLOUD_API_SERVICE_ID}`
|
||
},
|
||
source: {
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
environment: runtimeEnvironment(options.env ?? process.env),
|
||
traceId: getHeader(request, "x-trace-id"),
|
||
adapter: "rest"
|
||
},
|
||
operation: context.operation || `${request.method || "GET"} ${request.url || "/"}`,
|
||
target: context.target || {
|
||
type: "route",
|
||
id: request.url || "/"
|
||
},
|
||
result: context.result || "failed"
|
||
})
|
||
}
|
||
});
|
||
}
|
||
|
||
function statusForRpcResponse(rpcResponse) {
|
||
if (!rpcResponse.error) {
|
||
return 200;
|
||
}
|
||
|
||
if (rpcResponse.error.code === ERROR_CODES.invalidRequest || rpcResponse.error.code === ERROR_CODES.parseError) {
|
||
return 400;
|
||
}
|
||
|
||
if (rpcResponse.error.code === ERROR_CODES.methodNotFound) {
|
||
return 404;
|
||
}
|
||
|
||
return 200;
|
||
}
|
||
|
||
function readBody(request, limitBytes = DEFAULT_BODY_LIMIT_BYTES) {
|
||
const limit = Number.isInteger(limitBytes) && limitBytes > 0 ? limitBytes : DEFAULT_BODY_LIMIT_BYTES;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
let body = "";
|
||
request.setEncoding("utf8");
|
||
|
||
request.on("data", (chunk) => {
|
||
body += chunk;
|
||
if (Buffer.byteLength(body, "utf8") > limit) {
|
||
request.destroy(new Error(`request body exceeds ${limit} bytes`));
|
||
}
|
||
});
|
||
|
||
request.on("end", () => resolve(body));
|
||
request.on("error", reject);
|
||
});
|
||
}
|
||
|
||
function sendJson(response, statusCode, body) {
|
||
response.writeHead(statusCode, {
|
||
"content-type": "application/json; charset=utf-8",
|
||
"cache-control": "no-store"
|
||
});
|
||
response.end(`${JSON.stringify(body)}\n`);
|
||
}
|
||
|
||
function getHeader(request, name) {
|
||
const value = request.headers[name.toLowerCase()];
|
||
if (Array.isArray(value)) {
|
||
return value[0];
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function firstHeaderValue(request, name) {
|
||
return getHeader(request, name);
|
||
}
|
||
|
||
function truthyFlag(value) {
|
||
return /^(?:1|true|yes|on|enabled)$/iu.test(String(value ?? "").trim());
|
||
}
|
||
|
||
function safeTraceId(value) {
|
||
const text = String(value ?? "").trim();
|
||
return /^trc_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null;
|
||
}
|
||
|
||
function safeSessionId(value) {
|
||
const text = String(value ?? "").trim();
|
||
return /^ses_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null;
|
||
}
|
||
|
||
function safeConversationId(value) {
|
||
const text = String(value ?? "").trim();
|
||
return /^cnv_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null;
|
||
}
|
||
|
||
function safeOpaqueId(value) {
|
||
const text = String(value ?? "").trim();
|
||
return /^[A-Za-z0-9_.:-]{3,180}$/u.test(text) ? text : null;
|
||
}
|
||
|
||
function parsePositiveInteger(value, fallback) {
|
||
const parsed = Number.parseInt(value ?? "", 10);
|
||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||
}
|
||
|
||
function positiveInteger(value, fallback) {
|
||
const parsed = Number.parseInt(value ?? "", 10);
|
||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||
}
|
||
|
||
async function runtimeReadiness(runtimeStore) {
|
||
if (typeof runtimeStore?.readiness === "function") {
|
||
return runtimeStore.readiness();
|
||
}
|
||
return runtimeStore.summary();
|
||
}
|