987 lines
31 KiB
JavaScript
987 lines
31 KiB
JavaScript
import { createServer } from "node:http";
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
import {
|
|
buildMetadataFromEnv,
|
|
imageTagFromReference,
|
|
normalizeIsoTimestamp
|
|
} from "../build-metadata.mjs";
|
|
import { DEV_ENDPOINT, ENVIRONMENT_DEV, ERROR_CODES } 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 { 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 { createGatewayDemoRegistry } from "./gateway-demo-registry.mjs";
|
|
|
|
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
|
const LIVE_BUILD_TIMEOUT_MS = 900;
|
|
const LIVE_BUILD_SERVICE_ENDPOINTS = Object.freeze([
|
|
Object.freeze({ serviceId: "hwlab-cloud-api", serviceName: "hwlab-cloud-api", urlEnv: null, defaultUrl: "http://127.0.0.1:6667" }),
|
|
Object.freeze({ serviceId: "hwlab-cloud-web", serviceName: "hwlab-cloud-web", urlEnv: "HWLAB_CLOUD_WEB_SERVICE_URL", defaultUrl: "http://hwlab-cloud-web.hwlab-dev.svc.cluster.local:8080" }),
|
|
Object.freeze({ serviceId: "hwlab-agent-mgr", serviceName: "hwlab-agent-mgr", urlEnv: "HWLAB_AGENT_MGR_URL", defaultUrl: "http://hwlab-agent-mgr.hwlab-dev.svc.cluster.local:7410" }),
|
|
Object.freeze({ serviceId: "hwlab-agent-worker", serviceName: "hwlab-agent-worker", urlEnv: "HWLAB_AGENT_WORKER_URL", defaultUrl: "http://hwlab-agent-worker.hwlab-dev.svc.cluster.local:7411", activation: "suspended-job-template" }),
|
|
Object.freeze({ serviceId: "hwlab-gateway", serviceName: "hwlab-gateway", urlEnv: "HWLAB_GATEWAY_URL", defaultUrl: "http://hwlab-gateway.hwlab-dev.svc.cluster.local:7001", activation: "manual" }),
|
|
Object.freeze({ serviceId: "hwlab-gateway-simu", serviceName: "hwlab-gateway-simu", urlEnv: "HWLAB_GATEWAY_SIMU_URL", defaultUrl: "http://hwlab-gateway-simu.hwlab-dev.svc.cluster.local:7101" }),
|
|
Object.freeze({ serviceId: "hwlab-box-simu", serviceName: "hwlab-box-simu", urlEnv: "HWLAB_BOX_SIMU_URL", defaultUrl: "http://hwlab-box-simu.hwlab-dev.svc.cluster.local:7201" }),
|
|
Object.freeze({ serviceId: "hwlab-patch-panel", serviceName: "hwlab-patch-panel", urlEnv: "HWLAB_PATCH_PANEL_URL", defaultUrl: "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301" }),
|
|
Object.freeze({ serviceId: "hwlab-router", serviceName: "hwlab-router", urlEnv: "HWLAB_ROUTER_URL", defaultUrl: "http://hwlab-router.hwlab-dev.svc.cluster.local:7401" }),
|
|
Object.freeze({ serviceId: "hwlab-tunnel-client", serviceName: "hwlab-tunnel-client", urlEnv: "HWLAB_TUNNEL_CLIENT_URL", defaultUrl: "http://hwlab-tunnel-client.hwlab-dev.svc.cluster.local:7402" }),
|
|
Object.freeze({ serviceId: "hwlab-frpc", serviceName: "hwlab-frpc", externalImage: true, defaultImage: "127.0.0.1:5000/hwlab/frpc:v0.68.1" }),
|
|
Object.freeze({ serviceId: "hwlab-edge-proxy", serviceName: "hwlab-edge-proxy", urlEnv: "HWLAB_EDGE_PROXY_URL", defaultUrl: "http://hwlab-edge-proxy.hwlab-dev.svc.cluster.local:6667", healthPath: "/health" }),
|
|
Object.freeze({ serviceId: "hwlab-cli", serviceName: "hwlab-cli", urlEnv: "HWLAB_CLI_URL", defaultUrl: "http://hwlab-cli.hwlab-dev.svc.cluster.local:7501", activation: "suspended-job-template" }),
|
|
Object.freeze({ serviceId: "hwlab-agent-skills", serviceName: "hwlab-agent-skills", urlEnv: "HWLAB_AGENT_SKILLS_URL", defaultUrl: "http://hwlab-agent-skills.hwlab-dev.svc.cluster.local:7430" })
|
|
]);
|
|
|
|
export function createCloudApiServer(options = {}) {
|
|
const env = options.env ?? process.env;
|
|
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, 30000)
|
|
});
|
|
return createServer(async (request, response) => {
|
|
try {
|
|
await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry });
|
|
} catch (error) {
|
|
sendJson(response, 500, {
|
|
error: {
|
|
code: "internal_error",
|
|
message: "hwlab-cloud-api request handling failed",
|
|
reason: error.message
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function buildHealthPayload(options = {}) {
|
|
const serviceId = CLOUD_API_SERVICE_ID;
|
|
const env = options.env ?? process.env;
|
|
const metadata = buildMetadataFromEnv(env, {
|
|
serviceId,
|
|
fallbackImageRepository: "ghcr.io/pikastech/hwlab-cloud-api"
|
|
});
|
|
const dbProbe = await buildDbRuntimeReadiness(env, options.dbProbe);
|
|
const codeAgent = 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: ENVIRONMENT_DEV,
|
|
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 = 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,
|
|
m3IoControl: describeM3IoControl(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 !== "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: ENVIRONMENT_DEV
|
|
}
|
|
},
|
|
{
|
|
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 services = await Promise.all(
|
|
LIVE_BUILD_SERVICE_ENDPOINTS.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: ENVIRONMENT_DEV,
|
|
status: "ok",
|
|
contractVersion: "live-builds-v1",
|
|
observedAt: new Date().toISOString(),
|
|
source: {
|
|
kind: "live-health",
|
|
route: "/v1/live-builds",
|
|
healthPath: "/health/live",
|
|
note: "Each row is read from the current live service health payload or marked unavailable/external; 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 observeLiveBuildService(service, options) {
|
|
if (service.externalImage) {
|
|
return externalLiveBuildRecord(service, {
|
|
healthUrl: null,
|
|
httpStatus: null,
|
|
imageReference: service.defaultImage ?? "unknown",
|
|
reason: "外部镜像或非 HWLAB 构建产物"
|
|
});
|
|
}
|
|
|
|
if (service.serviceId === CLOUD_API_SERVICE_ID) {
|
|
const health = await buildHealthPayload(options);
|
|
return liveBuildRecordFromHealth(service, {
|
|
ok: true,
|
|
status: 200,
|
|
url: "/health/live",
|
|
payload: health
|
|
});
|
|
}
|
|
|
|
const baseUrl = String(options.env?.[service.urlEnv] || service.defaultUrl || "").replace(/\/+$/u, "");
|
|
if (!baseUrl) {
|
|
return 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)
|
|
);
|
|
}
|
|
|
|
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) {
|
|
if (!result.ok) {
|
|
return unavailableLiveBuildRecord(service, {
|
|
healthUrl: result.url,
|
|
httpStatus: result.status,
|
|
reasonCode: result.reasonCode ?? "health_unavailable",
|
|
reason: healthUnavailableReason(service, result.reason ?? `${service.serviceId} health unavailable`)
|
|
});
|
|
}
|
|
|
|
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 createdAt = normalizeHealthBuildTime(payload);
|
|
return {
|
|
serviceId: service.serviceId,
|
|
name: service.serviceName,
|
|
kind: "hwlab",
|
|
status: createdAt ? "ok" : "build_time_unavailable",
|
|
healthUrl: result.url,
|
|
httpStatus: result.status,
|
|
build: {
|
|
createdAt,
|
|
source: payload.build?.source ?? payload.commit?.source ?? "health-payload",
|
|
metadataSource: payload.build?.metadataSource ?? (createdAt ? "health:build.createdAt" : "unavailable"),
|
|
unavailableReason: createdAt ? null : "构建时间不可用:health payload 缺少 build.createdAt / image.createdAt / buildCreatedAt"
|
|
},
|
|
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 unavailableLiveBuildRecord(service, { healthUrl = null, httpStatus = null, reasonCode, reason }) {
|
|
return {
|
|
serviceId: service.serviceId,
|
|
name: service.serviceName,
|
|
kind: "hwlab",
|
|
status: "unavailable",
|
|
healthUrl,
|
|
httpStatus,
|
|
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 (service.activation === "suspended-job-template") {
|
|
return `${service.serviceId} 是 suspended Job template,当前 live desired replicas=0,无可读取的常驻 /health/live`;
|
|
}
|
|
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 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: ENVIRONMENT_DEV
|
|
};
|
|
|
|
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 payload = await handleCodeAgentChat(
|
|
{
|
|
...params,
|
|
traceId: getHeader(request, "x-trace-id") || params.traceId
|
|
},
|
|
{
|
|
callProvider: options.callCodeAgentProvider,
|
|
timeoutMs: options.codeAgentTimeoutMs,
|
|
env: options.env,
|
|
workspace: options.workspace,
|
|
skillsDirs: options.skillsDirs,
|
|
skillsDirsExact: options.skillsDirsExact,
|
|
skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs,
|
|
sessionRegistry: options.sessionRegistry,
|
|
m3IoSkillRequestJson: options.m3IoSkillRequestJson,
|
|
codexStdioManager: options.codexStdioManager
|
|
}
|
|
);
|
|
|
|
sendJson(response, payload.status === "failed" && payload.error?.code === "invalid_params" ? 400 : 200, payload);
|
|
}
|
|
|
|
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: ENVIRONMENT_DEV,
|
|
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 parsePositiveInteger(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();
|
|
}
|