Files
pikasTech-HWLAB/cmd/hwlab-gateway-simu/main.mjs
T
Lyon 7a0648088c fix: wire distinct M3 simulator identities
Merge PR #159 after Code Queue rebase and validation. Runner verified M3 cardinality, hardware-loop smoke, patch-panel runtime smoke, m3 readonly contract, and git diff check. This is source/config blocker removal only; it does not claim DEV-LIVE M3 pass.
2026-05-23 00:09:29 +08:00

383 lines
10 KiB
JavaScript

#!/usr/bin/env node
import { createAuditEvent, createEvidenceRecord, operationContext } from "../../internal/sim/l2-runtime.mjs";
import {
createGatewayState,
createHealth,
resourceIdForBox,
selectInstanceValue
} from "../../internal/sim/model.mjs";
import { fetchJson, createJsonServer, listen, parsePort, readJson } from "../../internal/sim/http.mjs";
import { createRpcErrorBody, ERROR_CODES } from "../../internal/protocol/index.mjs";
const gatewayId = selectInstanceValue(process.env.HWLAB_GATEWAY_ID, "gateway-a");
const port = parsePort(process.env.PORT, 7101);
const endpoint = `http://127.0.0.1:${port}`;
const boxUrls = parseBoxUrls(process.env.HWLAB_BOX_URLS);
const boxes = parseBoxes({
resources: process.env.HWLAB_BOX_RESOURCES,
ids: process.env.HWLAB_BOX_IDS,
urls: boxUrls,
instanceScoped: parseList(process.env.HWLAB_GATEWAY_ID).length > 1
});
const state = createGatewayState({
gatewayId,
endpoint,
boxes: boxes.map((box) => box.resourceId)
});
state.registry = {
gatewayId,
gatewaySessionId: state.session.gatewaySessionId,
endpoint,
boxes
};
state.lastHeartbeatAt = state.session.lastSeenAt;
const routes = new Map();
routes.set("GET /health/live", () =>
({
...createHealth({ serviceId: "hwlab-gateway-simu", name: gatewayId }),
gatewayId,
gatewaySessionId: state.session.gatewaySessionId
})
);
routes.set("GET /status", () => state);
routes.set("GET /boxes", () => ({
gatewayId,
gatewaySessionId: state.session.gatewaySessionId,
boxes: state.registry.boxes
}));
routes.set("POST /register", async ({ req }) => {
const body = await readJson(req);
const now = new Date().toISOString();
const registered = normalizeBoxRegistration(body);
upsertBox(registered);
state.boxes = state.registry.boxes.map((box) => box.resourceId);
state.updatedAt = now;
state.session.lastSeenAt = now;
return {
accepted: true,
serviceId: "hwlab-gateway-simu",
gatewayId,
gatewaySessionId: state.session.gatewaySessionId,
registered,
registry: state.registry
};
});
routes.set("POST /heartbeat", async ({ req }) => {
const body = await readJson(req);
const now = new Date().toISOString();
state.lastHeartbeatAt = now;
state.session.lastSeenAt = now;
state.updatedAt = now;
return {
accepted: true,
serviceId: "hwlab-gateway-simu",
gatewayId,
gatewaySessionId: state.session.gatewaySessionId,
heartbeat: {
observedAt: now,
boxCount: state.registry.boxes.length,
reportedBy: body.reportedBy ?? gatewayId
}
};
});
routes.set("GET /capabilities", async () => capabilityReport());
routes.set("POST /capabilities/report", async () => capabilityReport());
routes.set("POST /invoke", async ({ req }) => {
const body = await readJson(req);
return invokeBox(body);
});
function rpcHttpError(statusCode, { code, message, data = {} }) {
return Object.assign(new Error(message), {
statusCode,
body: createRpcErrorBody({
code,
message,
data
})
});
}
function parseBoxUrls(value) {
return String(value ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseList(value) {
return String(value ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseBoxes({ resources, ids, urls, instanceScoped = false }) {
const explicitResources = instanceScoped ? selectScopedList(resources) : parseList(resources);
const explicitIds = instanceScoped ? selectScopedList(ids) : parseList(ids);
const explicitUrls = instanceScoped ? selectScopedList(urls.join(",")) : urls;
const count = Math.max(explicitResources.length, explicitIds.length, explicitUrls.length, 1);
const parsed = [];
for (let index = 0; index < count; index += 1) {
const boxId =
explicitIds[index] ??
explicitResources[index]?.replace(/^res_/, "") ??
(count === 1 ? "boxsim_alpha" : `box-${index + 1}`);
const resourceId = explicitResources[index] ?? resourceIdForBox(boxId);
parsed.push({
boxId,
resourceId,
url: explicitUrls[index] ?? null,
state: "registered"
});
}
return parsed;
}
function selectScopedList(value) {
const items = parseList(value);
if (items.length <= 1) {
return items;
}
const selected = selectInstanceValue(items.join(","), "");
return selected ? [selected] : [];
}
function normalizeBoxRegistration(body) {
const boxId = body.boxId ?? body.identity?.boxId;
const resourceId = body.resourceId ?? body.resource?.resourceId ?? (boxId ? resourceIdForBox(boxId) : null);
if (!boxId || !resourceId) {
throw rpcHttpError(400, {
code: ERROR_CODES.invalidParams,
message: "register requires boxId or resourceId",
data: {
reason: "identity_required"
}
});
}
return {
boxId,
resourceId,
url: body.url ?? body.endpoint ?? null,
state: "registered",
capabilities: Array.isArray(body.capabilities) ? body.capabilities : undefined,
registeredAt: new Date().toISOString()
};
}
function upsertBox(box) {
const index = state.registry.boxes.findIndex((item) => item.resourceId === box.resourceId);
if (index >= 0) {
state.registry.boxes[index] = {
...state.registry.boxes[index],
...box
};
return;
}
state.registry.boxes.push(box);
}
async function capabilityReport() {
const reports = [];
for (const box of state.registry.boxes) {
if (box.url) {
try {
const response = await fetchJson(new URL("/capabilities", box.url));
if (response.ok) {
const body = response.body;
const capabilities = body?.capabilities ?? [];
upsertBox({
...box,
boxId: body?.boxId ?? box.boxId,
resourceId: body?.resourceId ?? box.resourceId,
capabilities
});
reports.push({
boxId: body?.boxId ?? box.boxId,
resourceId: body?.resourceId ?? box.resourceId,
url: box.url,
status: "available",
capabilities
});
continue;
}
} catch (error) {
reports.push({
boxId: box.boxId,
resourceId: box.resourceId,
url: box.url,
status: "unreachable",
capabilities: box.capabilities ?? [],
error: error instanceof Error ? error.message : String(error)
});
continue;
}
}
reports.push({
boxId: box.boxId,
resourceId: box.resourceId,
url: box.url,
status: box.capabilities ? "cached" : "unreachable",
capabilities: box.capabilities ?? []
});
}
state.capabilityReport = {
serviceId: "hwlab-gateway-simu",
gatewayId,
gatewaySessionId: state.session.gatewaySessionId,
reportedAt: new Date().toISOString(),
boxes: reports
};
return state.capabilityReport;
}
function findBoxTarget(body) {
const resourceId = body.resourceId;
const boxId = body.boxId;
return state.registry.boxes.find(
(box) => (resourceId && box.resourceId === resourceId) || (boxId && box.boxId === boxId)
);
}
async function invokeBox(body) {
const box = findBoxTarget(body);
if (!box) {
throw rpcHttpError(404, {
code: ERROR_CODES.sessionNotFound,
message: "Target box is not registered with this gateway",
data: {
gatewayId,
gatewaySessionId: state.session.gatewaySessionId,
boxId: body.boxId ?? null,
resourceId: body.resourceId ?? null
}
});
}
if (!box.url) {
throw rpcHttpError(409, {
code: ERROR_CODES.operationRejected,
message: "Target box has no invoke URL",
data: {
gatewayId,
resourceId: box.resourceId,
reason: "box_url_missing"
}
});
}
const context = operationContext(body, {
serviceId: "hwlab-gateway-simu",
actorId: "svc_hwlab-gateway-simu"
});
const forwarded = {
...body,
operationId: context.operationId,
traceId: context.traceId,
actorType: "service",
actorId: "svc_hwlab-gateway-simu",
source: body.source ?? "gateway-simu"
};
const response = await fetchJson(new URL("/invoke", box.url), {
method: "POST",
body: forwarded
});
if (!response.ok) {
throw Object.assign(new Error("box invoke failed"), {
statusCode: response.status,
body:
response.body ??
createRpcErrorBody({
code: ERROR_CODES.hwlabUnknown,
message: "Box invoke failed",
data: {
status: response.status
}
})
});
}
const now = new Date().toISOString();
const audit = createAuditEvent({
serviceId: "hwlab-gateway-simu",
action: "hardware.operation.dispatched",
targetType: "box_resource",
targetId: box.resourceId,
projectId: state.projectId,
gatewaySessionId: state.session.gatewaySessionId,
operationId: context.operationId,
traceId: context.traceId,
actorType: "service",
actorId: "svc_hwlab-gateway-simu",
outcome: "succeeded",
metadata: {
gatewayId,
boxId: box.boxId,
resourceId: box.resourceId,
capabilityId: body.capabilityId ?? null,
port: body.port ?? null,
forwardedTo: box.url
},
occurredAt: now
});
const evidence = createEvidenceRecord({
serviceId: "hwlab-gateway-simu",
projectId: state.projectId,
operationId: context.operationId,
traceId: context.traceId,
kind: "trace",
payload: {
gatewayId,
boxId: box.boxId,
resourceId: box.resourceId,
result: response.body
},
metadata: {
gatewaySessionId: state.session.gatewaySessionId,
forwardedTo: box.url,
producerServiceId: "hwlab-gateway-simu"
},
createdAt: now
});
state.lastDispatch = {
operationId: context.operationId,
traceId: context.traceId,
resourceId: box.resourceId,
observedAt: now,
accepted: true
};
state.updatedAt = now;
return {
accepted: true,
serviceId: "hwlab-gateway-simu",
gatewayId,
gatewaySessionId: state.session.gatewaySessionId,
operationId: context.operationId,
traceId: context.traceId,
target: {
boxId: box.boxId,
resourceId: box.resourceId,
url: box.url
},
result: response.body,
auditId: audit.auditId,
evidenceId: evidence.evidenceId,
audit,
evidence
};
}
listen(createJsonServer({ routes }), port);