1278 lines
38 KiB
JavaScript
1278 lines
38 KiB
JavaScript
import { createHash, randomUUID } from "node:crypto";
|
||
|
||
import {
|
||
ENVIRONMENT_DEV,
|
||
ERROR_CODES,
|
||
HwlabProtocolError,
|
||
assertProtocolRecord
|
||
} from "../protocol/index.mjs";
|
||
import {
|
||
CLOUD_API_SERVICE_ID,
|
||
createProtocolAuditEvent
|
||
} from "../audit/index.mjs";
|
||
|
||
export const M3_IO_CONTROL_CONTRACT_VERSION = "m3-io-control-v1";
|
||
export const M3_IO_CONTROL_ROUTE = "/v1/m3/io";
|
||
|
||
export const M3_IO_CHAIN = Object.freeze({
|
||
projectId: "prj_m3_hardware_loop",
|
||
sourceGatewayId: "gwsimu_1",
|
||
sourceGatewaySessionId: "gws_gwsimu_1",
|
||
sourceResourceId: "res_boxsimu_1",
|
||
sourceBoxId: "boxsimu_1",
|
||
sourcePort: "DO1",
|
||
targetGatewayId: "gwsimu_2",
|
||
targetGatewaySessionId: "gws_gwsimu_2",
|
||
targetResourceId: "res_boxsimu_2",
|
||
targetBoxId: "boxsimu_2",
|
||
targetPort: "DI1",
|
||
patchPanelServiceId: "hwlab-patch-panel"
|
||
});
|
||
|
||
export const M3_IO_BLOCKER_CODES = Object.freeze({
|
||
gatewayUnavailable: "m3_gateway_session_unavailable",
|
||
gatewayIdentityMismatch: "m3_gateway_identity_mismatch",
|
||
boxUnavailable: "m3_box_resource_unavailable",
|
||
portDirectionInvalid: "m3_port_direction_invalid",
|
||
wiringMissing: "m3_wiring_missing",
|
||
patchPanelUnavailable: "m3_patch_panel_unavailable",
|
||
dispatchFailed: "m3_gateway_dispatch_failed",
|
||
runtimeDurableBlocked: "runtime_durable_not_green"
|
||
});
|
||
|
||
const DEFAULT_TIMEOUT_MS = 2200;
|
||
const DEV_SERVICE_ENDPOINTS = Object.freeze({
|
||
gateway1: "http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101",
|
||
gateway2: "http://hwlab-gateway-simu-2.hwlab-dev.svc.cluster.local:7101",
|
||
patchPanel: "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301"
|
||
});
|
||
const ACTIONS = new Set(["do.write", "di.read"]);
|
||
|
||
export function describeM3IoControl(options = {}) {
|
||
const env = options.env ?? process.env;
|
||
const config = buildM3IoConfig(env);
|
||
return {
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
contractVersion: M3_IO_CONTROL_CONTRACT_VERSION,
|
||
route: M3_IO_CONTROL_ROUTE,
|
||
status: config.enabled ? "available" : "blocked",
|
||
sourceKind: "SOURCE",
|
||
chain: M3_IO_CHAIN,
|
||
actions: [
|
||
{
|
||
action: "do.write",
|
||
label: "box-simu-1 DO1 write",
|
||
gatewayId: M3_IO_CHAIN.sourceGatewayId,
|
||
resourceId: M3_IO_CHAIN.sourceResourceId,
|
||
boxId: M3_IO_CHAIN.sourceBoxId,
|
||
port: M3_IO_CHAIN.sourcePort,
|
||
valueType: "boolean"
|
||
},
|
||
{
|
||
action: "di.read",
|
||
label: "box-simu-2 DI1 read",
|
||
gatewayId: M3_IO_CHAIN.targetGatewayId,
|
||
resourceId: M3_IO_CHAIN.targetResourceId,
|
||
boxId: M3_IO_CHAIN.targetBoxId,
|
||
port: M3_IO_CHAIN.targetPort,
|
||
valueType: "boolean"
|
||
}
|
||
],
|
||
boundaries: {
|
||
frontendCallsOnly: M3_IO_CONTROL_ROUTE,
|
||
cloudApiDispatchesToGateway: true,
|
||
patchPanelOwnsPropagation: true,
|
||
directFrontendGatewayOrBoxAccess: false,
|
||
genericHardwareRpcExposedToFrontend: false
|
||
},
|
||
endpointsConfigured: {
|
||
gateway1: Boolean(config.gateway1Url),
|
||
gateway2: Boolean(config.gateway2Url),
|
||
patchPanel: Boolean(config.patchPanelUrl)
|
||
},
|
||
blockedReason: config.enabled ? null : "M3 IO 控制未启用;前端保持 blocked,不直连 gateway/box-simu。"
|
||
};
|
||
}
|
||
|
||
export async function handleM3IoControl(params = {}, context = {}) {
|
||
const env = context.env ?? process.env;
|
||
const now = context.now?.() ?? new Date().toISOString();
|
||
const config = buildM3IoConfig(env);
|
||
const action = normalizeAction(params.action ?? params.operation);
|
||
const meta = buildRequestMeta(params, context);
|
||
const runtimeBefore = await safeRuntimeSummary(context.runtimeStore);
|
||
|
||
if (!config.enabled) {
|
||
return blockedResult({
|
||
action,
|
||
meta,
|
||
runtime: runtimeBefore,
|
||
code: "m3_control_disabled",
|
||
reason: "M3 IO 控制未启用;前端不能绕过 cloud-api 直连 gateway/box-simu。",
|
||
httpStatus: 503,
|
||
now
|
||
});
|
||
}
|
||
|
||
if (!ACTIONS.has(action)) {
|
||
throw new HwlabProtocolError("M3 IO action must be do.write or di.read", {
|
||
code: ERROR_CODES.invalidParams,
|
||
data: {
|
||
action,
|
||
allowedActions: [...ACTIONS]
|
||
}
|
||
});
|
||
}
|
||
|
||
const command = normalizeCommand(action, params);
|
||
const directionIssue = validateM3Command(command);
|
||
if (directionIssue) {
|
||
return blockedResult({
|
||
action,
|
||
meta,
|
||
runtime: runtimeBefore,
|
||
code: M3_IO_BLOCKER_CODES.portDirectionInvalid,
|
||
reason: directionIssue,
|
||
httpStatus: 400,
|
||
now
|
||
});
|
||
}
|
||
|
||
const gatewayUrl = action === "do.write" ? config.gateway1Url : config.gateway2Url;
|
||
const gatewayRole = action === "do.write" ? "source" : "target";
|
||
const gateway = await readGatewaySession(gatewayUrl, {
|
||
gatewayRole,
|
||
expectedGatewayId: command.gatewayId,
|
||
expectedGatewaySessionId: command.gatewaySessionId,
|
||
requestJson: context.requestJson,
|
||
timeoutMs: config.timeoutMs
|
||
});
|
||
if (!gateway.available) {
|
||
return blockedResult({
|
||
action,
|
||
command,
|
||
meta,
|
||
runtime: runtimeBefore,
|
||
code: gateway.code ?? M3_IO_BLOCKER_CODES.gatewayUnavailable,
|
||
reason: gateway.reason,
|
||
httpStatus: 200,
|
||
now,
|
||
details: {
|
||
gateway
|
||
}
|
||
});
|
||
}
|
||
|
||
const box = findGatewayBox(gateway.status, command);
|
||
if (!box) {
|
||
return blockedResult({
|
||
action,
|
||
command,
|
||
meta,
|
||
runtime: runtimeBefore,
|
||
code: M3_IO_BLOCKER_CODES.boxUnavailable,
|
||
reason: `${gatewayRole} gateway 已响应,但目标 box resource 未注册/不可用:${command.resourceId}`,
|
||
httpStatus: 200,
|
||
now,
|
||
details: {
|
||
gateway: publicGatewayStatus(gateway.status)
|
||
}
|
||
});
|
||
}
|
||
|
||
const capability = capabilityFor(command, box);
|
||
const operation = createOperationRecord({
|
||
action,
|
||
command,
|
||
capability,
|
||
gatewaySessionId: gateway.gatewaySessionId,
|
||
meta,
|
||
now
|
||
});
|
||
const registration = await persistRuntimeRegistrations({
|
||
runtimeStore: context.runtimeStore,
|
||
gatewayStatus: gateway.status,
|
||
box,
|
||
capability,
|
||
command,
|
||
meta
|
||
});
|
||
const operationWrite = await tryRuntimeWrite(
|
||
"hardware.operation.request",
|
||
() => context.runtimeStore?.requestHardwareOperation?.(operation, meta)
|
||
);
|
||
|
||
if (action === "di.read") {
|
||
return finalizeControlResult(await readDi({
|
||
config,
|
||
command,
|
||
capability,
|
||
gateway,
|
||
operation,
|
||
meta,
|
||
runtimeStore: context.runtimeStore,
|
||
runtimeBefore,
|
||
registration,
|
||
operationWrite,
|
||
requestJson: context.requestJson,
|
||
now
|
||
}));
|
||
}
|
||
|
||
return finalizeControlResult(await writeDo({
|
||
config,
|
||
command,
|
||
capability,
|
||
gateway,
|
||
operation,
|
||
meta,
|
||
runtimeStore: context.runtimeStore,
|
||
runtimeBefore,
|
||
registration,
|
||
operationWrite,
|
||
requestJson: context.requestJson,
|
||
now
|
||
}));
|
||
}
|
||
|
||
export function buildM3IoConfig(env = process.env) {
|
||
const enabledValue = String(env.HWLAB_M3_IO_CONTROL_ENABLED ?? env.HWLAB_M3_CONTROL_ENABLED ?? "true").toLowerCase();
|
||
return {
|
||
enabled: !["0", "false", "no", "disabled"].includes(enabledValue),
|
||
gateway1Url: trimUrl(env.HWLAB_M3_GATEWAY_SIMU_1_URL) ?? DEV_SERVICE_ENDPOINTS.gateway1,
|
||
gateway2Url: trimUrl(env.HWLAB_M3_GATEWAY_SIMU_2_URL) ?? DEV_SERVICE_ENDPOINTS.gateway2,
|
||
patchPanelUrl: trimUrl(env.HWLAB_M3_PATCH_PANEL_URL) ?? DEV_SERVICE_ENDPOINTS.patchPanel,
|
||
timeoutMs: parseTimeout(env.HWLAB_M3_CONTROL_TIMEOUT_MS)
|
||
};
|
||
}
|
||
|
||
async function writeDo({
|
||
config,
|
||
command,
|
||
capability,
|
||
gateway,
|
||
operation,
|
||
meta,
|
||
runtimeStore,
|
||
runtimeBefore,
|
||
registration,
|
||
operationWrite,
|
||
requestJson,
|
||
now
|
||
}) {
|
||
const dispatch = await invokeGateway(gateway.url, {
|
||
operation: "hardware.port.write",
|
||
projectId: M3_IO_CHAIN.projectId,
|
||
gatewaySessionId: gateway.gatewaySessionId,
|
||
resourceId: command.resourceId,
|
||
boxId: command.boxId,
|
||
capabilityId: capability.capabilityId,
|
||
port: command.port,
|
||
value: command.value,
|
||
operationId: operation.operationId,
|
||
traceId: meta.traceId,
|
||
requestId: meta.requestId,
|
||
actorType: "service",
|
||
actorId: `svc_${CLOUD_API_SERVICE_ID}`,
|
||
requestedBy: meta.actorId,
|
||
source: "cloud-api-m3-io-control"
|
||
}, { requestJson, timeoutMs: config.timeoutMs });
|
||
|
||
if (!dispatch.ok || dispatch.body?.accepted !== true) {
|
||
return blockedControlResult({
|
||
action: command.action,
|
||
command,
|
||
operation,
|
||
meta,
|
||
runtimeStore,
|
||
runtimeBefore,
|
||
registration,
|
||
operationWrite,
|
||
code: M3_IO_BLOCKER_CODES.dispatchFailed,
|
||
reason: `gateway-simu DO 写入失败:${dispatch.error ?? dispatch.body?.error?.message ?? "unknown"}`,
|
||
gateway,
|
||
dispatch,
|
||
now
|
||
});
|
||
}
|
||
|
||
const patchPanel = await tickPatchPanel(config.patchPanelUrl, {
|
||
signals: [
|
||
{
|
||
fromResourceId: M3_IO_CHAIN.sourceResourceId,
|
||
fromPort: M3_IO_CHAIN.sourcePort,
|
||
value: command.value,
|
||
operationId: operation.operationId,
|
||
traceId: meta.traceId
|
||
}
|
||
]
|
||
}, { requestJson, timeoutMs: config.timeoutMs });
|
||
const patchIssue = classifyPatchPanelResult(patchPanel);
|
||
if (patchIssue) {
|
||
return blockedControlResult({
|
||
action: command.action,
|
||
command,
|
||
operation,
|
||
meta,
|
||
runtimeStore,
|
||
runtimeBefore,
|
||
registration,
|
||
operationWrite,
|
||
code: patchIssue.code,
|
||
reason: patchIssue.reason,
|
||
gateway,
|
||
dispatch,
|
||
patchPanel,
|
||
now
|
||
});
|
||
}
|
||
|
||
const targetReadback = await readTargetDiAfterWrite({
|
||
config,
|
||
operation,
|
||
meta,
|
||
requestJson
|
||
});
|
||
if (targetReadback.status !== "succeeded" || targetReadback.value !== command.value) {
|
||
return blockedControlResult({
|
||
action: command.action,
|
||
command,
|
||
operation,
|
||
meta,
|
||
runtimeStore,
|
||
runtimeBefore,
|
||
registration,
|
||
operationWrite,
|
||
code: targetReadback.code ?? M3_IO_BLOCKER_CODES.wiringMissing,
|
||
reason: targetReadback.reason ?? `DI1 回读值 ${String(targetReadback.value)} 与 DO1 写入值 ${String(command.value)} 不一致;不能证明 M3 DO->DI 闭环。`,
|
||
gateway,
|
||
dispatch,
|
||
patchPanel,
|
||
targetReadback,
|
||
now
|
||
});
|
||
}
|
||
const tracePayload = {
|
||
action: command.action,
|
||
command,
|
||
gateway: gatewayResultSummary(gateway),
|
||
dispatch: dispatch.body,
|
||
patchPanel: patchPanel.body,
|
||
targetReadback
|
||
};
|
||
|
||
return successControlResult({
|
||
action: command.action,
|
||
command,
|
||
operation,
|
||
meta,
|
||
runtimeStore,
|
||
runtimeBefore,
|
||
registration,
|
||
operationWrite,
|
||
gateway,
|
||
dispatch,
|
||
patchPanel,
|
||
targetReadback,
|
||
tracePayload,
|
||
now
|
||
});
|
||
}
|
||
|
||
async function readDi({
|
||
config,
|
||
command,
|
||
capability,
|
||
gateway,
|
||
operation,
|
||
meta,
|
||
runtimeStore,
|
||
runtimeBefore,
|
||
registration,
|
||
operationWrite,
|
||
requestJson,
|
||
now
|
||
}) {
|
||
const dispatch = await invokeGateway(gateway.url, {
|
||
operation: "hardware.port.read",
|
||
projectId: M3_IO_CHAIN.projectId,
|
||
gatewaySessionId: gateway.gatewaySessionId,
|
||
resourceId: command.resourceId,
|
||
boxId: command.boxId,
|
||
capabilityId: capability.capabilityId,
|
||
port: command.port,
|
||
operationId: operation.operationId,
|
||
traceId: meta.traceId,
|
||
requestId: meta.requestId,
|
||
actorType: "service",
|
||
actorId: `svc_${CLOUD_API_SERVICE_ID}`,
|
||
requestedBy: meta.actorId,
|
||
source: "cloud-api-m3-io-control"
|
||
}, { requestJson, timeoutMs: config.timeoutMs });
|
||
|
||
if (!dispatch.ok || dispatch.body?.accepted !== true) {
|
||
return blockedControlResult({
|
||
action: command.action,
|
||
command,
|
||
operation,
|
||
meta,
|
||
runtimeStore,
|
||
runtimeBefore,
|
||
registration,
|
||
operationWrite,
|
||
code: M3_IO_BLOCKER_CODES.dispatchFailed,
|
||
reason: `gateway-simu DI 读取失败:${dispatch.error ?? dispatch.body?.error?.message ?? "unknown"}`,
|
||
gateway,
|
||
dispatch,
|
||
now
|
||
});
|
||
}
|
||
|
||
const tracePayload = {
|
||
action: command.action,
|
||
command,
|
||
gateway: gatewayResultSummary(gateway),
|
||
dispatch: dispatch.body,
|
||
value: readValueFromGatewayResult(dispatch.body)
|
||
};
|
||
|
||
return successControlResult({
|
||
action: command.action,
|
||
command,
|
||
operation,
|
||
meta,
|
||
runtimeStore,
|
||
runtimeBefore,
|
||
registration,
|
||
operationWrite,
|
||
gateway,
|
||
dispatch,
|
||
tracePayload,
|
||
now
|
||
});
|
||
}
|
||
|
||
async function readTargetDiAfterWrite({ config, operation, meta, requestJson }) {
|
||
const gateway = await readGatewaySession(config.gateway2Url, {
|
||
gatewayRole: "target-readback",
|
||
expectedGatewayId: M3_IO_CHAIN.targetGatewayId,
|
||
expectedGatewaySessionId: M3_IO_CHAIN.targetGatewaySessionId,
|
||
requestJson,
|
||
timeoutMs: config.timeoutMs
|
||
});
|
||
if (!gateway.available) {
|
||
return {
|
||
status: "blocked",
|
||
code: gateway.code ?? M3_IO_BLOCKER_CODES.gatewayUnavailable,
|
||
reason: gateway.reason
|
||
};
|
||
}
|
||
const command = normalizeCommand("di.read", {});
|
||
const box = findGatewayBox(gateway.status, command);
|
||
if (!box) {
|
||
return {
|
||
status: "blocked",
|
||
code: M3_IO_BLOCKER_CODES.boxUnavailable,
|
||
reason: `target gateway 已响应,但 ${M3_IO_CHAIN.targetResourceId} 未注册/不可用`
|
||
};
|
||
}
|
||
const capability = capabilityFor(command, box);
|
||
const dispatch = await invokeGateway(gateway.url, {
|
||
operation: "hardware.port.read",
|
||
projectId: M3_IO_CHAIN.projectId,
|
||
gatewaySessionId: gateway.gatewaySessionId,
|
||
resourceId: command.resourceId,
|
||
boxId: command.boxId,
|
||
capabilityId: capability.capabilityId,
|
||
port: command.port,
|
||
operationId: operation.operationId,
|
||
traceId: meta.traceId,
|
||
requestId: meta.requestId,
|
||
actorType: "service",
|
||
actorId: `svc_${CLOUD_API_SERVICE_ID}`,
|
||
requestedBy: meta.actorId,
|
||
source: "cloud-api-m3-io-control"
|
||
}, { requestJson, timeoutMs: config.timeoutMs });
|
||
|
||
return dispatch.ok && dispatch.body?.accepted === true
|
||
? {
|
||
status: "succeeded",
|
||
value: readValueFromGatewayResult(dispatch.body),
|
||
gatewaySessionId: gateway.gatewaySessionId,
|
||
resourceId: command.resourceId,
|
||
port: command.port,
|
||
result: dispatch.body
|
||
}
|
||
: {
|
||
status: "blocked",
|
||
code: M3_IO_BLOCKER_CODES.dispatchFailed,
|
||
reason: dispatch.error ?? dispatch.body?.error?.message ?? "target DI readback failed"
|
||
};
|
||
}
|
||
|
||
function normalizeAction(action) {
|
||
return String(action ?? "").trim().toLowerCase();
|
||
}
|
||
|
||
function normalizeCommand(action, params = {}) {
|
||
if (action === "do.write") {
|
||
return {
|
||
action,
|
||
gatewayId: params.gatewayId ?? params.sourceGatewayId ?? M3_IO_CHAIN.sourceGatewayId,
|
||
gatewaySessionId: params.gatewaySessionId ?? M3_IO_CHAIN.sourceGatewaySessionId,
|
||
resourceId: params.resourceId ?? M3_IO_CHAIN.sourceResourceId,
|
||
boxId: params.boxId ?? M3_IO_CHAIN.sourceBoxId,
|
||
port: normalizePort(params.port ?? M3_IO_CHAIN.sourcePort),
|
||
value: normalizeBoolean(params.value)
|
||
};
|
||
}
|
||
|
||
return {
|
||
action,
|
||
gatewayId: params.gatewayId ?? params.targetGatewayId ?? M3_IO_CHAIN.targetGatewayId,
|
||
gatewaySessionId: params.gatewaySessionId ?? M3_IO_CHAIN.targetGatewaySessionId,
|
||
resourceId: params.resourceId ?? M3_IO_CHAIN.targetResourceId,
|
||
boxId: params.boxId ?? M3_IO_CHAIN.targetBoxId,
|
||
port: normalizePort(params.port ?? M3_IO_CHAIN.targetPort)
|
||
};
|
||
}
|
||
|
||
function validateM3Command(command) {
|
||
if (command.action === "do.write") {
|
||
if (command.gatewayId !== M3_IO_CHAIN.sourceGatewayId || command.resourceId !== M3_IO_CHAIN.sourceResourceId || command.port !== M3_IO_CHAIN.sourcePort) {
|
||
return `M3 只允许 ${M3_IO_CHAIN.sourceGatewayId}/${M3_IO_CHAIN.sourceResourceId}/${M3_IO_CHAIN.sourcePort} 执行 DO write;当前请求会造成端口方向或资源越界。`;
|
||
}
|
||
if (typeof command.value !== "boolean") {
|
||
return "M3 DO1 写入值必须是 boolean。";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
if (command.action === "di.read") {
|
||
if (command.gatewayId !== M3_IO_CHAIN.targetGatewayId || command.resourceId !== M3_IO_CHAIN.targetResourceId || command.port !== M3_IO_CHAIN.targetPort) {
|
||
return `M3 只允许 ${M3_IO_CHAIN.targetGatewayId}/${M3_IO_CHAIN.targetResourceId}/${M3_IO_CHAIN.targetPort} 执行 DI read;当前请求会造成端口方向或资源越界。`;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function normalizePort(port) {
|
||
return String(port ?? "").trim().toUpperCase();
|
||
}
|
||
|
||
function normalizeBoolean(value) {
|
||
if (typeof value === "boolean") return value;
|
||
if (value === "true") return true;
|
||
if (value === "false") return false;
|
||
return value;
|
||
}
|
||
|
||
function buildRequestMeta(params = {}, context = {}) {
|
||
return {
|
||
traceId: params.traceId || context.traceId || `trc_${randomUUID()}`,
|
||
requestId: params.requestId || context.requestId || `req_${randomUUID()}`,
|
||
actorType: "user",
|
||
actorId: params.actorId || context.actorId || "usr_hwlab_cloud_web",
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
environment: ENVIRONMENT_DEV
|
||
};
|
||
}
|
||
|
||
function createOperationRecord({ action, command, capability, gatewaySessionId, meta, now }) {
|
||
const operationId = command.operationId || `op_m3_${action.replace(".", "_")}_${randomUUID()}`;
|
||
const operation = {
|
||
operationId,
|
||
projectId: M3_IO_CHAIN.projectId,
|
||
gatewaySessionId,
|
||
resourceId: command.resourceId,
|
||
capabilityId: capability.capabilityId,
|
||
requestedBy: meta.actorId,
|
||
input: {
|
||
action,
|
||
port: command.port,
|
||
...(Object.hasOwn(command, "value") ? { value: command.value } : {}),
|
||
route: {
|
||
from: `${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort}`,
|
||
via: M3_IO_CHAIN.patchPanelServiceId,
|
||
to: `${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`
|
||
},
|
||
frontendBypass: false
|
||
},
|
||
status: "accepted",
|
||
environment: ENVIRONMENT_DEV,
|
||
requestedAt: now,
|
||
updatedAt: now
|
||
};
|
||
assertProtocolRecord("hardwareOperation", operation);
|
||
return operation;
|
||
}
|
||
|
||
function capabilityFor(command, box = {}) {
|
||
const suffix = command.action === "do.write" ? "write" : "read";
|
||
const portPart = command.port.toLowerCase();
|
||
const boxId = box.boxId ?? command.boxId;
|
||
return {
|
||
capabilityId: `cap_${boxId}_${portPart}_${suffix}`,
|
||
resourceId: command.resourceId,
|
||
projectId: M3_IO_CHAIN.projectId,
|
||
name: command.action,
|
||
description: `M3 ${command.action} ${command.resourceId}:${command.port}`,
|
||
direction: command.action === "do.write" ? "output" : "input",
|
||
valueType: "boolean",
|
||
constraints: {
|
||
port: command.port,
|
||
portFamily: command.action === "do.write" ? "DO" : "DI",
|
||
patchPanelOnlyPropagation: true,
|
||
frontendBypassAllowed: false
|
||
},
|
||
mutatesState: command.action === "do.write",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString()
|
||
};
|
||
}
|
||
|
||
async function readGatewaySession(url, { gatewayRole, expectedGatewayId, expectedGatewaySessionId, requestJson, timeoutMs }) {
|
||
if (!url) {
|
||
return {
|
||
available: false,
|
||
role: gatewayRole,
|
||
code: M3_IO_BLOCKER_CODES.gatewayUnavailable,
|
||
reason: "gateway 未注册/不可用:cloud-api 未配置 gateway-simu endpoint"
|
||
};
|
||
}
|
||
const response = await requestRuntimeJson(new URL("/status", ensureTrailingSlash(url)).toString(), {
|
||
method: "GET",
|
||
requestJson,
|
||
timeoutMs
|
||
});
|
||
if (!response.ok) {
|
||
return {
|
||
available: false,
|
||
role: gatewayRole,
|
||
url: redactUrl(url),
|
||
code: M3_IO_BLOCKER_CODES.gatewayUnavailable,
|
||
reason: `gateway 未注册/不可用:${response.error ?? `HTTP ${response.status}`}`
|
||
};
|
||
}
|
||
const status = response.body ?? {};
|
||
const gatewaySessionId = status.session?.gatewaySessionId ?? status.gatewaySessionId ?? status.registry?.gatewaySessionId;
|
||
const gatewayId = status.gatewayId ?? status.session?.gatewayId ?? status.registry?.gatewayId;
|
||
if (!gatewaySessionId || !gatewayId) {
|
||
return {
|
||
available: false,
|
||
role: gatewayRole,
|
||
url: redactUrl(url),
|
||
status,
|
||
code: M3_IO_BLOCKER_CODES.gatewayUnavailable,
|
||
reason: "gateway 未注册/不可用:/status 未返回 gatewaySessionId/gatewayId"
|
||
};
|
||
}
|
||
if (expectedGatewayId && gatewayId !== expectedGatewayId) {
|
||
return {
|
||
available: false,
|
||
role: gatewayRole,
|
||
url: redactUrl(url),
|
||
status,
|
||
code: M3_IO_BLOCKER_CODES.gatewayIdentityMismatch,
|
||
reason: `gateway-simu identity mismatch:expected gatewayId=${expectedGatewayId},actual=${gatewayId};indexed simulator identity drift must fail closed.`
|
||
};
|
||
}
|
||
if (expectedGatewaySessionId && gatewaySessionId !== expectedGatewaySessionId) {
|
||
return {
|
||
available: false,
|
||
role: gatewayRole,
|
||
url: redactUrl(url),
|
||
status,
|
||
code: M3_IO_BLOCKER_CODES.gatewayIdentityMismatch,
|
||
reason: `gateway-simu identity mismatch:expected gatewaySessionId=${expectedGatewaySessionId},actual=${gatewaySessionId};indexed simulator identity drift must fail closed.`
|
||
};
|
||
}
|
||
return {
|
||
available: true,
|
||
role: gatewayRole,
|
||
url,
|
||
gatewayId,
|
||
gatewaySessionId,
|
||
status
|
||
};
|
||
}
|
||
|
||
function findGatewayBox(gatewayStatus, command) {
|
||
const boxes = [
|
||
...(Array.isArray(gatewayStatus?.registry?.boxes) ? gatewayStatus.registry.boxes : []),
|
||
...(Array.isArray(gatewayStatus?.boxes)
|
||
? gatewayStatus.boxes.map((item) => typeof item === "string" ? { resourceId: item } : item)
|
||
: [])
|
||
];
|
||
return boxes.find((box) => box?.resourceId === command.resourceId || box?.boxId === command.boxId) ?? null;
|
||
}
|
||
|
||
async function invokeGateway(gatewayUrl, body, options) {
|
||
return requestRuntimeJson(new URL("/invoke", ensureTrailingSlash(gatewayUrl)).toString(), {
|
||
method: "POST",
|
||
body,
|
||
requestJson: options.requestJson,
|
||
timeoutMs: options.timeoutMs
|
||
});
|
||
}
|
||
|
||
async function tickPatchPanel(patchPanelUrl, body, options) {
|
||
if (!patchPanelUrl) {
|
||
return {
|
||
ok: false,
|
||
status: 0,
|
||
error: "patch-panel endpoint is not configured"
|
||
};
|
||
}
|
||
return requestRuntimeJson(new URL("/sync/tick", ensureTrailingSlash(patchPanelUrl)).toString(), {
|
||
method: "POST",
|
||
body,
|
||
requestJson: options.requestJson,
|
||
timeoutMs: options.timeoutMs
|
||
});
|
||
}
|
||
|
||
function classifyPatchPanelResult(patchPanel) {
|
||
if (!patchPanel.ok) {
|
||
return {
|
||
code: M3_IO_BLOCKER_CODES.patchPanelUnavailable,
|
||
reason: `hwlab-patch-panel 不可用:${patchPanel.error ?? `HTTP ${patchPanel.status}`}`
|
||
};
|
||
}
|
||
if (patchPanel.body?.accepted !== true) {
|
||
const diagnostic = firstDiagnostic(patchPanel.body);
|
||
return {
|
||
code: diagnostic?.code === "target_endpoint_missing" ? M3_IO_BLOCKER_CODES.boxUnavailable : M3_IO_BLOCKER_CODES.patchPanelUnavailable,
|
||
reason: diagnostic?.message ?? "hwlab-patch-panel 拒绝同步;请检查 wiring 和 endpointMap。"
|
||
};
|
||
}
|
||
if (!hasExpectedPatchDelivery(patchPanel.body)) {
|
||
return {
|
||
code: M3_IO_BLOCKER_CODES.wiringMissing,
|
||
reason: `hwlab-patch-panel 未路由 ${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort} -> ${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort};wiring 不存在或不是 active。`
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function hasExpectedPatchDelivery(body) {
|
||
const routes = Array.isArray(body?.routes) ? body.routes : [];
|
||
return routes.some((route) =>
|
||
(route.deliveries ?? []).some((delivery) =>
|
||
delivery.sourceResourceId === M3_IO_CHAIN.sourceResourceId &&
|
||
delivery.sourcePort === M3_IO_CHAIN.sourcePort &&
|
||
delivery.resourceId === M3_IO_CHAIN.targetResourceId &&
|
||
delivery.port === M3_IO_CHAIN.targetPort &&
|
||
delivery.deliveryStatus !== "failed"
|
||
)
|
||
);
|
||
}
|
||
|
||
function firstDiagnostic(body) {
|
||
return [
|
||
...(Array.isArray(body?.diagnostics) ? body.diagnostics : []),
|
||
...(Array.isArray(body?.routes) ? body.routes.flatMap((route) => route.diagnostics ?? []) : [])
|
||
][0] ?? null;
|
||
}
|
||
|
||
async function requestRuntimeJson(url, { method = "GET", body, requestJson, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
||
if (requestJson) {
|
||
try {
|
||
const result = await requestJson(url, { method, body, timeoutMs });
|
||
return normalizeRuntimeJsonResponse(result);
|
||
} catch (error) {
|
||
return {
|
||
ok: false,
|
||
status: 0,
|
||
error: error instanceof Error ? error.message : String(error)
|
||
};
|
||
}
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||
try {
|
||
const response = await fetch(url, {
|
||
method,
|
||
headers: body === undefined
|
||
? { accept: "application/json" }
|
||
: {
|
||
accept: "application/json",
|
||
"content-type": "application/json"
|
||
},
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
signal: controller.signal
|
||
});
|
||
const text = await response.text();
|
||
let parsed = null;
|
||
try {
|
||
parsed = text ? JSON.parse(text) : null;
|
||
} catch {
|
||
parsed = null;
|
||
}
|
||
return {
|
||
ok: response.ok,
|
||
status: response.status,
|
||
body: parsed,
|
||
error: response.ok ? null : parsed?.error?.message ?? parsed?.message ?? response.statusText
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
ok: false,
|
||
status: 0,
|
||
error: error.name === "AbortError" ? `request timed out after ${timeoutMs}ms` : error.message
|
||
};
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
function normalizeRuntimeJsonResponse(result = {}) {
|
||
if (Object.hasOwn(result, "ok") || Object.hasOwn(result, "body")) {
|
||
return {
|
||
ok: result.ok ?? (Number(result.status ?? 200) >= 200 && Number(result.status ?? 200) < 300),
|
||
status: result.status ?? 200,
|
||
body: result.body ?? result.json ?? null,
|
||
error: result.error ?? result.body?.error?.message ?? null
|
||
};
|
||
}
|
||
if (Object.hasOwn(result, "status") || Object.hasOwn(result, "json")) {
|
||
const status = result.status ?? 200;
|
||
return {
|
||
ok: status >= 200 && status < 300,
|
||
status,
|
||
body: result.json ?? null,
|
||
error: result.error ?? null
|
||
};
|
||
}
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
body: result
|
||
};
|
||
}
|
||
|
||
async function persistRuntimeRegistrations({ runtimeStore, gatewayStatus, box, capability, command, meta }) {
|
||
const gatewaySessionId = gatewayStatus.session?.gatewaySessionId ?? gatewayStatus.gatewaySessionId ?? gatewayStatus.registry?.gatewaySessionId ?? command.gatewaySessionId;
|
||
const gatewayId = gatewayStatus.gatewayId ?? gatewayStatus.session?.gatewayId ?? gatewayStatus.registry?.gatewayId ?? command.gatewayId;
|
||
const gatewaySession = {
|
||
projectId: M3_IO_CHAIN.projectId,
|
||
gatewaySessionId,
|
||
gatewayId,
|
||
serviceId: "hwlab-gateway-simu",
|
||
endpoint: gatewayStatus.registry?.endpoint ?? gatewayStatus.session?.endpoint,
|
||
status: "connected",
|
||
labels: {
|
||
m3ControlSurface: "true"
|
||
}
|
||
};
|
||
const resource = {
|
||
projectId: M3_IO_CHAIN.projectId,
|
||
gatewaySessionId,
|
||
resourceId: command.resourceId,
|
||
boxId: box.boxId ?? command.boxId,
|
||
resourceType: "simulator_endpoint",
|
||
state: "available",
|
||
metadata: {
|
||
serviceId: "hwlab-box-simu",
|
||
patchPanelOnlyPropagation: true,
|
||
frontendBypassAllowed: false
|
||
}
|
||
};
|
||
const writes = [];
|
||
writes.push(await tryRuntimeWrite("gateway.session.register", () => runtimeStore?.registerGatewaySession?.(gatewaySession, meta)));
|
||
writes.push(await tryRuntimeWrite("box.resource.register", () => runtimeStore?.registerBoxResource?.(resource, meta)));
|
||
writes.push(await tryRuntimeWrite("box.capability.report", () => runtimeStore?.reportBoxCapabilities?.({ capability }, meta)));
|
||
return writes;
|
||
}
|
||
|
||
async function tryRuntimeWrite(label, write) {
|
||
if (typeof write !== "function") {
|
||
return {
|
||
label,
|
||
written: false,
|
||
skipped: true,
|
||
reason: "runtimeStore method unavailable"
|
||
};
|
||
}
|
||
try {
|
||
const result = await write();
|
||
return {
|
||
label,
|
||
written: true,
|
||
result
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
label,
|
||
written: false,
|
||
reason: error instanceof Error ? error.message : String(error),
|
||
code: error?.code ?? null,
|
||
data: error?.data ?? null
|
||
};
|
||
}
|
||
}
|
||
|
||
async function successControlResult(input) {
|
||
const records = await writeOutcomeRecords({
|
||
...input,
|
||
outcome: "succeeded",
|
||
reason: null
|
||
});
|
||
return {
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
contractVersion: M3_IO_CONTROL_CONTRACT_VERSION,
|
||
status: "succeeded",
|
||
accepted: true,
|
||
action: input.action,
|
||
chain: M3_IO_CHAIN,
|
||
command: input.command,
|
||
operationId: input.operation.operationId,
|
||
traceId: input.meta.traceId,
|
||
auditId: records.auditEvent.auditId,
|
||
evidenceId: records.evidenceRecord.evidenceId,
|
||
operation: {
|
||
...input.operation,
|
||
status: "succeeded",
|
||
completedAt: input.now,
|
||
updatedAt: input.now
|
||
},
|
||
result: {
|
||
value: input.command.action === "di.read"
|
||
? readValueFromGatewayResult(input.dispatch.body)
|
||
: input.command.value,
|
||
dispatch: gatewayResultSummary(input.gateway),
|
||
patchPanel: input.patchPanel?.body ?? null,
|
||
targetReadback: input.targetReadback ?? null
|
||
},
|
||
controlPath: {
|
||
status: "succeeded",
|
||
cloudApi: true,
|
||
gatewaySimu: true,
|
||
boxSimu: true,
|
||
patchPanel: input.action === "do.write",
|
||
frontendBypass: false
|
||
},
|
||
auditEvent: records.auditEvent,
|
||
evidenceRecord: records.evidenceRecord,
|
||
evidenceState: evidenceState(records.runtimeAfter, records),
|
||
persistence: persistenceSummary(records.runtimeAfter, input.registration, input.operationWrite, records),
|
||
observedAt: input.now
|
||
};
|
||
}
|
||
|
||
async function blockedControlResult(input) {
|
||
const records = await writeOutcomeRecords({
|
||
...input,
|
||
outcome: "failed",
|
||
reason: input.reason
|
||
});
|
||
return {
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
contractVersion: M3_IO_CONTROL_CONTRACT_VERSION,
|
||
status: "blocked",
|
||
accepted: false,
|
||
action: input.action,
|
||
chain: M3_IO_CHAIN,
|
||
command: input.command,
|
||
operationId: input.operation.operationId,
|
||
traceId: input.meta.traceId,
|
||
auditId: records.auditEvent.auditId,
|
||
evidenceId: records.evidenceRecord.evidenceId,
|
||
blocker: {
|
||
code: input.code,
|
||
message: input.reason,
|
||
zh: input.reason
|
||
},
|
||
operation: {
|
||
...input.operation,
|
||
status: "failed",
|
||
completedAt: input.now,
|
||
updatedAt: input.now,
|
||
error: {
|
||
code: input.code,
|
||
message: input.reason
|
||
}
|
||
},
|
||
result: {
|
||
dispatch: input.gateway ? gatewayResultSummary(input.gateway) : null,
|
||
patchPanel: input.patchPanel?.body ?? null,
|
||
targetReadback: input.targetReadback ?? null
|
||
},
|
||
controlPath: {
|
||
status: "blocked",
|
||
cloudApi: true,
|
||
gatewaySimu: Boolean(input.gateway),
|
||
boxSimu: input.code !== M3_IO_BLOCKER_CODES.gatewayUnavailable,
|
||
patchPanel: Boolean(input.patchPanel?.ok),
|
||
frontendBypass: false
|
||
},
|
||
auditEvent: records.auditEvent,
|
||
evidenceRecord: records.evidenceRecord,
|
||
evidenceState: evidenceState(records.runtimeAfter, records),
|
||
persistence: persistenceSummary(records.runtimeAfter, input.registration, input.operationWrite, records),
|
||
observedAt: input.now
|
||
};
|
||
}
|
||
|
||
function blockedResult({ action, command = null, meta, runtime, code, reason, httpStatus, now, details = {} }) {
|
||
return {
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
contractVersion: M3_IO_CONTROL_CONTRACT_VERSION,
|
||
status: "blocked",
|
||
accepted: false,
|
||
action: action || "unknown",
|
||
chain: M3_IO_CHAIN,
|
||
command,
|
||
operationId: null,
|
||
traceId: meta.traceId,
|
||
auditId: null,
|
||
evidenceId: null,
|
||
blocker: {
|
||
code,
|
||
message: reason,
|
||
zh: reason
|
||
},
|
||
controlPath: {
|
||
status: "blocked",
|
||
cloudApi: true,
|
||
gatewaySimu: false,
|
||
boxSimu: false,
|
||
patchPanel: false,
|
||
frontendBypass: false
|
||
},
|
||
evidenceState: evidenceState(runtime, {
|
||
auditWrite: { written: false, reason: "operation was blocked before audit write" },
|
||
evidenceWrite: { written: false, reason: "operation was blocked before evidence write" }
|
||
}),
|
||
persistence: {
|
||
runtime,
|
||
writes: []
|
||
},
|
||
observedAt: now,
|
||
httpStatus,
|
||
...details
|
||
};
|
||
}
|
||
|
||
async function writeOutcomeRecords({
|
||
action,
|
||
command,
|
||
operation,
|
||
meta,
|
||
runtimeStore,
|
||
runtimeBefore,
|
||
registration,
|
||
operationWrite,
|
||
tracePayload,
|
||
outcome,
|
||
reason,
|
||
now
|
||
}) {
|
||
const auditEvent = createProtocolAuditEvent({
|
||
auditId: `aud_${operation.operationId.slice(3)}_${outcome}`,
|
||
traceId: meta.traceId,
|
||
actorType: "user",
|
||
actorId: meta.actorId,
|
||
action: action === "do.write" ? "m3.io.do.write" : "m3.io.di.read",
|
||
targetType: "hardware_operation",
|
||
targetId: operation.operationId,
|
||
projectId: M3_IO_CHAIN.projectId,
|
||
gatewaySessionId: operation.gatewaySessionId,
|
||
operationId: operation.operationId,
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
outcome,
|
||
reason: reason ?? undefined,
|
||
metadata: {
|
||
route: `${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort}->${M3_IO_CHAIN.patchPanelServiceId}->${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`,
|
||
command,
|
||
frontendBypass: false,
|
||
runtimeDurableBefore: runtimeBefore?.status ?? "unknown"
|
||
},
|
||
occurredAt: now
|
||
});
|
||
const evidenceRecord = createEvidenceRecord({
|
||
evidenceId: `evd_${operation.operationId.slice(3)}_${outcome}`,
|
||
projectId: M3_IO_CHAIN.projectId,
|
||
operationId: operation.operationId,
|
||
traceId: meta.traceId,
|
||
createdAt: now,
|
||
payload: {
|
||
outcome,
|
||
reason,
|
||
tracePayload,
|
||
command,
|
||
frontendBypass: false,
|
||
patchPanelOnlyPropagation: true
|
||
}
|
||
});
|
||
const auditWrite = await tryRuntimeWrite("audit.event.write", () => runtimeStore?.writeAuditEvent?.({ event: auditEvent }, meta));
|
||
const evidenceWrite = await tryRuntimeWrite("evidence.record.write", () => runtimeStore?.writeEvidenceRecord?.({ record: evidenceRecord }, meta));
|
||
const runtimeAfter = await safeRuntimeSummary(runtimeStore);
|
||
return {
|
||
auditEvent,
|
||
evidenceRecord,
|
||
auditWrite,
|
||
evidenceWrite,
|
||
runtimeAfter,
|
||
registration,
|
||
operationWrite
|
||
};
|
||
}
|
||
|
||
function createEvidenceRecord({ evidenceId, projectId, operationId, traceId, payload, createdAt }) {
|
||
const metadata = {
|
||
traceId,
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
route: `${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort}->${M3_IO_CHAIN.patchPanelServiceId}->${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`,
|
||
frontendBypass: false,
|
||
patchPanelOnlyPropagation: true
|
||
};
|
||
const serialized = JSON.stringify({
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
projectId,
|
||
operationId,
|
||
traceId,
|
||
payload,
|
||
metadata,
|
||
createdAt
|
||
});
|
||
const record = {
|
||
evidenceId,
|
||
projectId,
|
||
operationId,
|
||
kind: "trace",
|
||
uri: `memory://hwlab-cloud-api/${operationId}/m3-io-control.json`,
|
||
mimeType: "application/json",
|
||
sha256: createHash("sha256").update(serialized).digest("hex"),
|
||
sizeBytes: Buffer.byteLength(serialized),
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
environment: ENVIRONMENT_DEV,
|
||
metadata,
|
||
createdAt
|
||
};
|
||
assertProtocolRecord("evidenceRecord", record);
|
||
return record;
|
||
}
|
||
|
||
function evidenceState(runtime, records) {
|
||
const durableReady = isDurableRuntimeReady(runtime);
|
||
const recordsWritten = records.auditWrite?.written === true && records.evidenceWrite?.written === true;
|
||
if (durableReady && recordsWritten) {
|
||
return {
|
||
status: "green",
|
||
sourceKind: "DEV-LIVE",
|
||
durable: true,
|
||
reason: "operation/audit/evidence 已通过 durable runtime 写入;仍需 DEV 浏览器 E2E 才能宣称验收通过。"
|
||
};
|
||
}
|
||
const blocker = runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked;
|
||
return {
|
||
status: "blocked",
|
||
sourceKind: "BLOCKED",
|
||
durable: runtime?.durable === true,
|
||
blocker,
|
||
reason: `操作链路可达性与可信持久化分离:runtime durable 未 green(status=${runtime?.status ?? "unknown"},blocker=${blocker}),不能宣称可信闭环完全 green。`
|
||
};
|
||
}
|
||
|
||
function persistenceSummary(runtime, registration, operationWrite, records) {
|
||
return {
|
||
runtime,
|
||
writes: [
|
||
...(registration ?? []),
|
||
operationWrite,
|
||
records.auditWrite,
|
||
records.evidenceWrite
|
||
].filter(Boolean)
|
||
};
|
||
}
|
||
|
||
async function safeRuntimeSummary(runtimeStore) {
|
||
try {
|
||
if (typeof runtimeStore?.readiness === "function") {
|
||
return await runtimeStore.readiness();
|
||
}
|
||
if (typeof runtimeStore?.summary === "function") {
|
||
return runtimeStore.summary();
|
||
}
|
||
} catch (error) {
|
||
return {
|
||
adapter: "unknown",
|
||
durable: false,
|
||
ready: false,
|
||
status: "blocked",
|
||
blocker: "runtime_summary_unavailable",
|
||
reason: error instanceof Error ? error.message : String(error),
|
||
liveRuntimeEvidence: false
|
||
};
|
||
}
|
||
return {
|
||
adapter: "none",
|
||
durable: false,
|
||
ready: false,
|
||
status: "blocked",
|
||
blocker: "runtime_store_missing",
|
||
reason: "runtime store is not available",
|
||
liveRuntimeEvidence: false
|
||
};
|
||
}
|
||
|
||
function isDurableRuntimeReady(runtime) {
|
||
if (!runtime || typeof runtime !== "object") return false;
|
||
if (runtime.durable !== true) return false;
|
||
if (runtime.ready === false) return false;
|
||
if (runtime.liveRuntimeEvidence !== true) return false;
|
||
return !["blocked", "degraded", "failed"].includes(String(runtime.status ?? "").toLowerCase());
|
||
}
|
||
|
||
function readValueFromGatewayResult(result) {
|
||
return result?.result?.value ?? result?.value ?? null;
|
||
}
|
||
|
||
function gatewayResultSummary(gateway) {
|
||
return {
|
||
gatewayId: gateway.gatewayId,
|
||
gatewaySessionId: gateway.gatewaySessionId,
|
||
url: redactUrl(gateway.url),
|
||
role: gateway.role
|
||
};
|
||
}
|
||
|
||
function publicGatewayStatus(status) {
|
||
return {
|
||
gatewayId: status?.gatewayId ?? status?.session?.gatewayId ?? null,
|
||
gatewaySessionId: status?.session?.gatewaySessionId ?? status?.gatewaySessionId ?? null,
|
||
boxCount: Array.isArray(status?.registry?.boxes) ? status.registry.boxes.length : null
|
||
};
|
||
}
|
||
|
||
function finalizeControlResult(result) {
|
||
return result;
|
||
}
|
||
|
||
function ensureTrailingSlash(url) {
|
||
return url.endsWith("/") ? url : `${url}/`;
|
||
}
|
||
|
||
function trimUrl(value) {
|
||
const text = String(value ?? "").trim();
|
||
return text ? text : null;
|
||
}
|
||
|
||
function parseTimeout(value) {
|
||
const parsed = Number.parseInt(value ?? "", 10);
|
||
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
|
||
}
|
||
|
||
function redactUrl(url) {
|
||
try {
|
||
const parsed = new URL(url);
|
||
parsed.username = parsed.username ? "***" : "";
|
||
parsed.password = parsed.password ? "***" : "";
|
||
return parsed.toString();
|
||
} catch {
|
||
return "invalid-url";
|
||
}
|
||
}
|