1738 lines
56 KiB
TypeScript
1738 lines
56 KiB
TypeScript
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";
|
||
import {
|
||
requestRuntimeJson,
|
||
normalizeRuntimeJsonResponse,
|
||
persistRuntimeRegistrations,
|
||
tryRuntimeWrite,
|
||
successControlResult,
|
||
blockedControlResult,
|
||
blockedResult,
|
||
writeOutcomeRecords,
|
||
createEvidenceRecord,
|
||
evidenceState,
|
||
auditState,
|
||
recordWriteState,
|
||
durableStatus,
|
||
trustBlocker,
|
||
redactedBlockerClassification,
|
||
blockerCategory,
|
||
redactBlockerMessage,
|
||
controlTarget,
|
||
persistenceSummary,
|
||
safeRuntimeSummary,
|
||
isDurableRuntimeReady,
|
||
readValueFromGatewayResult,
|
||
gatewayResultSummary,
|
||
publicGatewayStatus,
|
||
finalizeControlResult,
|
||
ensureTrailingSlash,
|
||
trimUrl,
|
||
parseTimeout,
|
||
redactUrl
|
||
} from "./m3-io-control-runtime.ts";
|
||
import { HWLAB_M3_IO_CAPABILITY_LEVELS } from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs";
|
||
|
||
export const M3_IO_CONTROL_CONTRACT_VERSION = "m3-io-control-v1";
|
||
export const M3_IO_CONTROL_ROUTE = "/v1/m3/io";
|
||
export const M3_STATUS_CONTRACT_VERSION = "m3-status-v1";
|
||
export const M3_STATUS_ROUTE = "/v1/m3/status";
|
||
export const M3_IO_RPC_METHODS = Object.freeze({
|
||
doWrite: "m3.io.do.write",
|
||
diRead: "m3.io.di.read"
|
||
});
|
||
|
||
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",
|
||
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"
|
||
});
|
||
|
||
export const DEFAULT_TIMEOUT_MS = 2200;
|
||
const DEV_SERVICE_ENDPOINTS = Object.freeze({
|
||
gateway1: "",
|
||
gateway2: "",
|
||
patchPanel: ""
|
||
});
|
||
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",
|
||
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
|
||
readinessStatus: "not_checked",
|
||
capabilityLevels: [
|
||
HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
|
||
HWLAB_M3_IO_CAPABILITY_LEVELS.ready
|
||
],
|
||
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 describeM3IoControlLive(options = {}) {
|
||
const contract = describeM3IoControl(options);
|
||
const env = options.env ?? process.env;
|
||
const config = buildM3IoConfig(env);
|
||
const runtime = await safeRuntimeSummary(options.runtimeStore);
|
||
const readiness = await buildM3IoReadiness({
|
||
config,
|
||
requestJson: options.m3IoRequestJson ?? options.requestJson,
|
||
now: options.now
|
||
});
|
||
const controlReady = readiness.status === "ready" && readiness.controlReady === true;
|
||
const capabilityLevel = controlReady
|
||
? HWLAB_M3_IO_CAPABILITY_LEVELS.ready
|
||
: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked;
|
||
const trustReadiness = buildM3IoTrustReadiness(runtime, {
|
||
observedAt: readiness.observedAt,
|
||
controlReady
|
||
});
|
||
|
||
return {
|
||
...contract,
|
||
status: controlReady ? "available" : "blocked",
|
||
sourceKind: controlReady ? "DEV-LIVE" : "BLOCKED",
|
||
capabilityLevel,
|
||
readinessStatus: readiness.status,
|
||
readiness: {
|
||
...readiness,
|
||
capabilityLevel,
|
||
trust: trustReadiness
|
||
},
|
||
trustReadiness,
|
||
controlReady,
|
||
blockedReason: controlReady
|
||
? null
|
||
: readiness.blocker?.zh ?? contract.blockedReason ?? "M3 IO 只读 readiness 未 green;控制面板保持阻塞。"
|
||
};
|
||
}
|
||
|
||
export async function describeM3StatusLive(options = {}) {
|
||
const env = options.env ?? process.env;
|
||
const config = buildM3IoConfig(env);
|
||
const requestJson = options.m3IoRequestJson ?? options.requestJson;
|
||
const observedAt = options.now?.() ?? new Date().toISOString();
|
||
const runtime = await safeRuntimeSummary(options.runtimeStore);
|
||
const trust = await buildM3StatusTrust({
|
||
runtimeStore: options.runtimeStore,
|
||
runtime
|
||
});
|
||
|
||
const base = {
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
contractVersion: M3_STATUS_CONTRACT_VERSION,
|
||
route: M3_STATUS_ROUTE,
|
||
observedAt,
|
||
chain: M3_IO_CHAIN,
|
||
boundaries: {
|
||
frontendCallsOnly: M3_STATUS_ROUTE,
|
||
cloudApiAggregatesRuntimeStatus: true,
|
||
patchPanelOwnsPropagation: true,
|
||
directFrontendGatewayOrBoxAccess: false,
|
||
sourceTopologyFallbackMustStayUnverified: true
|
||
}
|
||
};
|
||
|
||
if (!config.enabled) {
|
||
const blocker = {
|
||
code: "m3_control_disabled",
|
||
layer: "cloud-api",
|
||
zh: "M3 状态聚合未启用;前端不能绕过 cloud-api 直连 gateway/box-simu。"
|
||
};
|
||
const gateways = defaultM3Gateways({ sourceKind: "UNVERIFIED", observedAt });
|
||
const boxes = defaultM3Boxes({ sourceKind: "UNVERIFIED", observedAt });
|
||
const patchPanel = defaultM3PatchPanel({ observedAt });
|
||
const blockedTrust = {
|
||
...trust,
|
||
blocker: "m3_control_disabled"
|
||
};
|
||
return {
|
||
...base,
|
||
status: "blocked",
|
||
sourceKind: "BLOCKED",
|
||
summary: "M3 状态聚合未启用;前端不能绕过 cloud-api 直连 gateway/box-simu。",
|
||
gateways,
|
||
boxes,
|
||
patchPanel,
|
||
io: m3StatusIoSummary({ boxes, patchPanel, hardwareLive: false, observedAt }),
|
||
operation: m3StatusOperationSummary(blockedTrust),
|
||
trace: m3StatusTraceSummary(blockedTrust),
|
||
audit: m3StatusAuditSummary(blockedTrust),
|
||
runtimeDurable: m3StatusRuntimeDurableSummary(blockedTrust),
|
||
trust: blockedTrust,
|
||
blocker
|
||
};
|
||
}
|
||
|
||
const [sourceGateway, targetGateway, patchStatus, patchWiring] = await Promise.all([
|
||
readGatewaySession(config.gateway1Url, {
|
||
gatewayRole: "source",
|
||
expectedGatewayId: M3_IO_CHAIN.sourceGatewayId,
|
||
expectedGatewaySessionId: M3_IO_CHAIN.sourceGatewaySessionId,
|
||
requestJson,
|
||
timeoutMs: config.timeoutMs
|
||
}),
|
||
readGatewaySession(config.gateway2Url, {
|
||
gatewayRole: "target",
|
||
expectedGatewayId: M3_IO_CHAIN.targetGatewayId,
|
||
expectedGatewaySessionId: M3_IO_CHAIN.targetGatewaySessionId,
|
||
requestJson,
|
||
timeoutMs: config.timeoutMs
|
||
}),
|
||
requestRuntimeJson(new URL("/status", ensureTrailingSlash(config.patchPanelUrl)).toString(), {
|
||
method: "GET",
|
||
requestJson,
|
||
timeoutMs: config.timeoutMs
|
||
}),
|
||
requestRuntimeJson(new URL("/wiring", ensureTrailingSlash(config.patchPanelUrl)).toString(), {
|
||
method: "GET",
|
||
requestJson,
|
||
timeoutMs: config.timeoutMs
|
||
})
|
||
]);
|
||
|
||
const sourceCommand = normalizeCommand("do.write", { value: false });
|
||
const targetCommand = normalizeCommand("di.read", {});
|
||
const sourceBox = sourceGateway.available ? findGatewayBox(sourceGateway.status, sourceCommand) : null;
|
||
const targetBox = targetGateway.available ? findGatewayBox(targetGateway.status, targetCommand) : null;
|
||
const [sourceDoRead, targetDiRead] = await Promise.all([
|
||
sourceGateway.available && sourceBox
|
||
? readM3StatusPort({
|
||
gateway: sourceGateway,
|
||
command: sourceCommand,
|
||
requestJson,
|
||
timeoutMs: config.timeoutMs,
|
||
observedAt,
|
||
port: M3_IO_CHAIN.sourcePort,
|
||
direction: "output"
|
||
})
|
||
: Promise.resolve(unverifiedPortRead({
|
||
port: M3_IO_CHAIN.sourcePort,
|
||
direction: "output",
|
||
source: "gateway-simu",
|
||
reason: sourceGateway.reason ?? `${M3_IO_CHAIN.sourceResourceId} 未在 source gateway registry 中可见`,
|
||
observedAt
|
||
})),
|
||
targetGateway.available && targetBox
|
||
? readM3StatusPort({
|
||
gateway: targetGateway,
|
||
command: targetCommand,
|
||
requestJson,
|
||
timeoutMs: config.timeoutMs,
|
||
observedAt,
|
||
port: M3_IO_CHAIN.targetPort,
|
||
direction: "input"
|
||
})
|
||
: Promise.resolve(unverifiedPortRead({
|
||
port: M3_IO_CHAIN.targetPort,
|
||
direction: "input",
|
||
source: "gateway-simu",
|
||
reason: targetGateway.reason ?? `${M3_IO_CHAIN.targetResourceId} 未在 target gateway registry 中可见`,
|
||
observedAt
|
||
}))
|
||
]);
|
||
|
||
const gateways = [
|
||
gatewayStatusEntry(sourceGateway, {
|
||
id: M3_IO_CHAIN.sourceGatewayId,
|
||
sessionId: M3_IO_CHAIN.sourceGatewaySessionId,
|
||
role: "source",
|
||
observedAt
|
||
}),
|
||
gatewayStatusEntry(targetGateway, {
|
||
id: M3_IO_CHAIN.targetGatewayId,
|
||
sessionId: M3_IO_CHAIN.targetGatewaySessionId,
|
||
role: "target",
|
||
observedAt
|
||
})
|
||
];
|
||
const boxes = [
|
||
boxStatusEntry({
|
||
gateway: sourceGateway,
|
||
box: sourceBox,
|
||
boxId: M3_IO_CHAIN.sourceBoxId,
|
||
resourceId: M3_IO_CHAIN.sourceResourceId,
|
||
gatewayId: M3_IO_CHAIN.sourceGatewayId,
|
||
gatewaySessionId: M3_IO_CHAIN.sourceGatewaySessionId,
|
||
observedAt,
|
||
ports: {
|
||
[M3_IO_CHAIN.sourcePort]: sourceDoRead.port
|
||
}
|
||
}),
|
||
boxStatusEntry({
|
||
gateway: targetGateway,
|
||
box: targetBox,
|
||
boxId: M3_IO_CHAIN.targetBoxId,
|
||
resourceId: M3_IO_CHAIN.targetResourceId,
|
||
gatewayId: M3_IO_CHAIN.targetGatewayId,
|
||
gatewaySessionId: M3_IO_CHAIN.targetGatewaySessionId,
|
||
observedAt,
|
||
ports: {
|
||
[M3_IO_CHAIN.targetPort]: targetDiRead.port
|
||
}
|
||
})
|
||
];
|
||
const patchPanel = patchPanelStatusEntry({
|
||
statusResponse: patchStatus,
|
||
wiringResponse: patchWiring,
|
||
observedAt
|
||
});
|
||
const blockers = [
|
||
...gateways.filter((gateway) => !gateway.online).map((gateway) => ({
|
||
code: gateway.blocker,
|
||
layer: gateway.id,
|
||
zh: gateway.lastError
|
||
})),
|
||
...boxes.filter((box) => !box.online).map((box) => ({
|
||
code: M3_IO_BLOCKER_CODES.boxUnavailable,
|
||
layer: box.resourceId,
|
||
zh: `${box.resourceId} 未在 gateway-simu registry 中可见`
|
||
})),
|
||
patchPanel.connectionActive ? null : {
|
||
code: patchPanel.observable === false ? M3_IO_BLOCKER_CODES.patchPanelUnavailable : M3_IO_BLOCKER_CODES.wiringMissing,
|
||
layer: M3_IO_CHAIN.patchPanelServiceId,
|
||
zh: patchPanel.lastError ?? `hwlab-patch-panel 未确认 active ${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort} -> ${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort} 接线`
|
||
},
|
||
sourceDoRead.error ? {
|
||
code: M3_IO_BLOCKER_CODES.dispatchFailed,
|
||
layer: M3_IO_CHAIN.sourceResourceId,
|
||
zh: sourceDoRead.error
|
||
} : null,
|
||
targetDiRead.error ? {
|
||
code: M3_IO_BLOCKER_CODES.dispatchFailed,
|
||
layer: M3_IO_CHAIN.targetResourceId,
|
||
zh: targetDiRead.error
|
||
} : null
|
||
].filter(Boolean);
|
||
|
||
const hardwareLive =
|
||
blockers.length === 0 &&
|
||
sourceDoRead.port.sourceKind === "DEV-LIVE" &&
|
||
targetDiRead.port.sourceKind === "DEV-LIVE";
|
||
const trustGreen = trust.durableStatus === "green" && trust.evidenceId && trust.auditId && trust.operationId && trust.traceId;
|
||
const status = blockers.length > 0
|
||
? blockers.some((blocker) => blocker.code === M3_IO_BLOCKER_CODES.dispatchFailed || blocker.code === M3_IO_BLOCKER_CODES.patchPanelUnavailable) ? "error" : "blocked"
|
||
: trustGreen ? "live" : hardwareLive ? "blocked" : "unverified";
|
||
const sourceKind = hardwareLive ? "DEV-LIVE" : "UNVERIFIED";
|
||
const blocker = blockers[0] ?? (trust.blocker ? {
|
||
code: trust.blocker,
|
||
layer: "runtime-durable",
|
||
zh: `runtime durable 仍 blocked:${trust.blocker}`
|
||
} : null);
|
||
|
||
return {
|
||
...base,
|
||
status,
|
||
sourceKind,
|
||
summary: status === "live"
|
||
? "M3 硬件状态与可信持久化均为 DEV-LIVE。"
|
||
: hardwareLive
|
||
? "M3 DO1/DI1 实况读数来自 cloud-api 聚合入口;可信持久化仍保持 blocked,不得标为 trusted green。"
|
||
: "M3 硬件状态尚未完整验证;SOURCE 拓扑只能作为未验证 fallback。",
|
||
gateways,
|
||
boxes,
|
||
patchPanel,
|
||
io: m3StatusIoSummary({ boxes, patchPanel, hardwareLive, observedAt }),
|
||
operation: m3StatusOperationSummary(trust),
|
||
trace: m3StatusTraceSummary(trust),
|
||
audit: m3StatusAuditSummary(trust),
|
||
runtimeDurable: m3StatusRuntimeDurableSummary(trust),
|
||
trust,
|
||
blocker
|
||
};
|
||
}
|
||
|
||
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 async function buildM3IoReadiness({ config, requestJson, now } = {}) {
|
||
const observedAt = now?.() ?? new Date().toISOString();
|
||
const checks = [];
|
||
|
||
if (!config?.enabled) {
|
||
return blockedReadiness({
|
||
checks,
|
||
code: "m3_control_disabled",
|
||
layer: "cloud-api",
|
||
reason: "M3 IO 控制未启用;前端不能绕过 cloud-api 直连 gateway/box-simu。",
|
||
observedAt
|
||
});
|
||
}
|
||
|
||
const sourceGateway = await readGatewaySession(config.gateway1Url, {
|
||
gatewayRole: "source",
|
||
expectedGatewayId: M3_IO_CHAIN.sourceGatewayId,
|
||
expectedGatewaySessionId: M3_IO_CHAIN.sourceGatewaySessionId,
|
||
requestJson,
|
||
timeoutMs: config.timeoutMs
|
||
});
|
||
checks.push(readinessCheckFromGateway("source-gateway", sourceGateway));
|
||
if (!sourceGateway.available) {
|
||
return blockedReadiness({
|
||
checks,
|
||
code: sourceGateway.code ?? M3_IO_BLOCKER_CODES.gatewayUnavailable,
|
||
layer: "gateway-simu-1",
|
||
reason: sourceGateway.reason,
|
||
observedAt
|
||
});
|
||
}
|
||
|
||
const sourceCommand = normalizeCommand("do.write", {});
|
||
const sourceBox = findGatewayBox(sourceGateway.status, sourceCommand);
|
||
checks.push(readinessCheckFromBox("source-box", sourceBox, sourceCommand));
|
||
if (!sourceBox) {
|
||
return blockedReadiness({
|
||
checks,
|
||
code: M3_IO_BLOCKER_CODES.boxUnavailable,
|
||
layer: "box-simu-1",
|
||
reason: `gateway-simu-1 已响应,但 ${M3_IO_CHAIN.sourceResourceId} 未注册/不可用;控制面板保持阻塞。`,
|
||
observedAt
|
||
});
|
||
}
|
||
|
||
const targetGateway = await readGatewaySession(config.gateway2Url, {
|
||
gatewayRole: "target",
|
||
expectedGatewayId: M3_IO_CHAIN.targetGatewayId,
|
||
expectedGatewaySessionId: M3_IO_CHAIN.targetGatewaySessionId,
|
||
requestJson,
|
||
timeoutMs: config.timeoutMs
|
||
});
|
||
checks.push(readinessCheckFromGateway("target-gateway", targetGateway));
|
||
if (!targetGateway.available) {
|
||
return blockedReadiness({
|
||
checks,
|
||
code: targetGateway.code ?? M3_IO_BLOCKER_CODES.gatewayUnavailable,
|
||
layer: "gateway-simu-2",
|
||
reason: targetGateway.reason,
|
||
observedAt
|
||
});
|
||
}
|
||
|
||
const targetCommand = normalizeCommand("di.read", {});
|
||
const targetBox = findGatewayBox(targetGateway.status, targetCommand);
|
||
checks.push(readinessCheckFromBox("target-box", targetBox, targetCommand));
|
||
if (!targetBox) {
|
||
return blockedReadiness({
|
||
checks,
|
||
code: M3_IO_BLOCKER_CODES.boxUnavailable,
|
||
layer: "box-simu-2",
|
||
reason: `gateway-simu-2 已响应,但 ${M3_IO_CHAIN.targetResourceId} 未注册/不可用;控制面板保持阻塞。`,
|
||
observedAt
|
||
});
|
||
}
|
||
|
||
const patchStatus = await requestRuntimeJson(new URL("/status", ensureTrailingSlash(config.patchPanelUrl)).toString(), {
|
||
method: "GET",
|
||
requestJson,
|
||
timeoutMs: config.timeoutMs
|
||
});
|
||
checks.push(readinessCheckFromPatchPanelStatus(patchStatus));
|
||
if (!patchStatus.ok) {
|
||
return blockedReadiness({
|
||
checks,
|
||
code: M3_IO_BLOCKER_CODES.patchPanelUnavailable,
|
||
layer: "hwlab-patch-panel",
|
||
reason: `hwlab-patch-panel 不可用:${patchStatus.error ?? `HTTP ${patchStatus.status}`}`,
|
||
observedAt
|
||
});
|
||
}
|
||
|
||
const wiring = await requestRuntimeJson(new URL("/wiring", ensureTrailingSlash(config.patchPanelUrl)).toString(), {
|
||
method: "GET",
|
||
requestJson,
|
||
timeoutMs: config.timeoutMs
|
||
});
|
||
checks.push(readinessCheckFromPatchPanelWiring(wiring));
|
||
if (!wiring.ok) {
|
||
return blockedReadiness({
|
||
checks,
|
||
code: M3_IO_BLOCKER_CODES.wiringMissing,
|
||
layer: "hwlab-patch-panel",
|
||
reason: `hwlab-patch-panel 接线读取失败:${wiring.error ?? `HTTP ${wiring.status}`}`,
|
||
observedAt
|
||
});
|
||
}
|
||
|
||
if (!hasExpectedPatchPanelWiring(patchStatus.body) || !hasExpectedPatchPanelWiring(wiring.body)) {
|
||
return blockedReadiness({
|
||
checks,
|
||
code: M3_IO_BLOCKER_CODES.wiringMissing,
|
||
layer: "hwlab-patch-panel",
|
||
reason: `hwlab-patch-panel 未确认 active ${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort} -> ${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort} 接线;控制面板保持阻塞。`,
|
||
observedAt
|
||
});
|
||
}
|
||
|
||
return {
|
||
status: "ready",
|
||
controlReady: true,
|
||
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.ready,
|
||
sourceKind: "DEV-LIVE",
|
||
evidenceLevel: "DEV-LIVE",
|
||
observedAt,
|
||
summary: "cloud-api 只读 readiness 已确认 gateway-simu、box-simu 与 hwlab-patch-panel 目标接线;这只表示控制面板可执行受控操作,不等于 M3 验收通过。",
|
||
chain: M3_IO_CHAIN,
|
||
checks,
|
||
blocker: null
|
||
};
|
||
}
|
||
|
||
export async function handleM3IoRpc(action, params = {}, envelope = {}, context = {}) {
|
||
return handleM3IoControl(
|
||
{
|
||
...params,
|
||
action,
|
||
traceId: envelope.meta?.traceId ?? params.traceId,
|
||
requestId: envelope.id ?? params.requestId,
|
||
actorId: envelope.meta?.actorId ?? params.actorId
|
||
},
|
||
{
|
||
...context,
|
||
requestJson: context.requestJson ?? context.m3IoRequestJson,
|
||
traceId: envelope.meta?.traceId ?? context.traceId,
|
||
requestId: envelope.id ?? context.requestId,
|
||
actorId: envelope.meta?.actorId ?? context.actorId
|
||
}
|
||
);
|
||
}
|
||
|
||
export function buildM3IoConfig(env = process.env) {
|
||
const enabledValue = String(env.HWLAB_M3_IO_CONTROL_ENABLED ?? env.HWLAB_M3_CONTROL_ENABLED ?? "false").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)
|
||
};
|
||
}
|
||
|
||
function blockedReadiness({ checks, code, layer, reason, observedAt }) {
|
||
return {
|
||
status: "blocked",
|
||
controlReady: false,
|
||
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
|
||
sourceKind: "BLOCKED",
|
||
evidenceLevel: "BLOCKED",
|
||
observedAt,
|
||
summary: `M3 IO 只读 readiness 未 green:${reason}`,
|
||
chain: M3_IO_CHAIN,
|
||
blocker: {
|
||
code,
|
||
layer,
|
||
zh: reason
|
||
},
|
||
capabilityBlocker: {
|
||
code,
|
||
layer,
|
||
zh: reason,
|
||
category: blockerCategory(code)
|
||
},
|
||
checks
|
||
};
|
||
}
|
||
|
||
function buildM3IoTrustReadiness(runtime, { observedAt, controlReady } = {}) {
|
||
const durable = durableStatus(runtime);
|
||
const ready = isDurableRuntimeReady(runtime);
|
||
const blocker = ready
|
||
? null
|
||
: {
|
||
code: durable.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked,
|
||
layer: "runtime-durable",
|
||
zh: `runtime durable 未 green:${durable.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked}`,
|
||
category: "runtime_durable"
|
||
};
|
||
return {
|
||
status: ready ? "ready" : "blocked",
|
||
controlReady: Boolean(controlReady),
|
||
trustedEvidenceReady: ready,
|
||
capabilityLevel: controlReady
|
||
? HWLAB_M3_IO_CAPABILITY_LEVELS.ready
|
||
: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
|
||
evidenceLevel: ready ? "DEV-LIVE" : "BLOCKED",
|
||
sourceKind: ready ? "DEV-LIVE" : "BLOCKED",
|
||
observedAt,
|
||
durableStatus: durable,
|
||
blocker,
|
||
summary: ready
|
||
? "runtime durable 已 green;operation/audit/evidence 可作为可信记录写入。"
|
||
: "受控硬件路径 readiness 与可信持久化分离;runtime durable 未 green 时不得宣称完整硬件控制验收。"
|
||
};
|
||
}
|
||
|
||
async function buildM3StatusTrust({ runtimeStore, runtime }) {
|
||
const durable = durableStatus(runtime);
|
||
const base = {
|
||
operationId: null,
|
||
traceId: null,
|
||
auditId: null,
|
||
evidenceId: null,
|
||
durableStatus: isDurableRuntimeReady(runtime) ? "green" : "blocked",
|
||
runtime: durable,
|
||
blocker: isDurableRuntimeReady(runtime) ? null : durable.blocker
|
||
};
|
||
|
||
const [audit, evidence] = await Promise.all([
|
||
safeRuntimeRead(() => runtimeStore?.queryAuditEvents?.({ projectId: M3_IO_CHAIN.projectId, limit: 8 })),
|
||
safeRuntimeRead(() => runtimeStore?.queryEvidenceRecords?.({ projectId: M3_IO_CHAIN.projectId, limit: 8 }))
|
||
]);
|
||
const latestAudit = latestM3AuditEvent(audit.value?.events ?? []);
|
||
const latestEvidence = latestM3EvidenceRecord(evidence.value?.records ?? []);
|
||
const operationId = latestEvidence?.operationId ?? latestAudit?.operationId ?? null;
|
||
const traceId = latestEvidence?.traceId ?? latestEvidence?.metadata?.traceId ?? latestAudit?.traceId ?? null;
|
||
|
||
return {
|
||
...base,
|
||
operationId,
|
||
traceId,
|
||
auditId: latestAudit?.auditId ?? null,
|
||
evidenceId: latestEvidence?.evidenceId ?? null,
|
||
durableStatus: base.durableStatus,
|
||
blocker: base.blocker ?? audit.error?.code ?? evidence.error?.code ?? null,
|
||
readStatus: {
|
||
audit: audit.ok ? "read" : "blocked",
|
||
evidence: evidence.ok ? "read" : "blocked",
|
||
auditError: audit.error?.message ?? null,
|
||
evidenceError: evidence.error?.message ?? null
|
||
}
|
||
};
|
||
}
|
||
|
||
function m3StatusIoSummary({ boxes, patchPanel, hardwareLive, observedAt }) {
|
||
const sourceBox = boxes.find((box) => box.resourceId === M3_IO_CHAIN.sourceResourceId) ?? null;
|
||
const targetBox = boxes.find((box) => box.resourceId === M3_IO_CHAIN.targetResourceId) ?? null;
|
||
const do1 = sourceBox?.ports?.[M3_IO_CHAIN.sourcePort] ?? null;
|
||
const di1 = targetBox?.ports?.[M3_IO_CHAIN.targetPort] ?? null;
|
||
const blocker = !sourceBox?.online
|
||
? M3_IO_BLOCKER_CODES.boxUnavailable
|
||
: !targetBox?.online
|
||
? M3_IO_BLOCKER_CODES.boxUnavailable
|
||
: patchPanel?.connectionActive !== true
|
||
? M3_IO_BLOCKER_CODES.wiringMissing
|
||
: do1?.sourceKind !== "DEV-LIVE" || di1?.sourceKind !== "DEV-LIVE"
|
||
? "m3_io_unverified"
|
||
: null;
|
||
return {
|
||
status: hardwareLive ? "live" : blocker ? "blocked" : "unverified",
|
||
sourceKind: hardwareLive ? "DEV-LIVE" : "UNVERIFIED",
|
||
observedAt,
|
||
chain: {
|
||
from: `${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort}`,
|
||
via: M3_IO_CHAIN.patchPanelServiceId,
|
||
to: `${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`
|
||
},
|
||
do1: m3StatusPortSummary(sourceBox, M3_IO_CHAIN.sourcePort, do1),
|
||
di1: m3StatusPortSummary(targetBox, M3_IO_CHAIN.targetPort, di1),
|
||
patchPanel: {
|
||
serviceId: patchPanel?.serviceId ?? M3_IO_CHAIN.patchPanelServiceId,
|
||
connectionActive: patchPanel?.connectionActive === true,
|
||
sourceKind: patchPanel?.sourceKind ?? "UNVERIFIED",
|
||
observedAt: patchPanel?.lastSyncAt ?? observedAt,
|
||
blocker: patchPanel?.connectionActive === true ? null : patchPanel?.lastError ?? M3_IO_BLOCKER_CODES.wiringMissing
|
||
},
|
||
blocker
|
||
};
|
||
}
|
||
|
||
function m3StatusPortSummary(box, port, state) {
|
||
return {
|
||
resourceId: box?.resourceId ?? null,
|
||
boxId: box?.id ?? null,
|
||
gatewayId: box?.gatewayId ?? null,
|
||
gatewaySessionId: box?.gatewaySessionId ?? null,
|
||
port,
|
||
direction: state?.direction ?? null,
|
||
value: state?.value ?? null,
|
||
source: state?.source ?? "unverified",
|
||
sourceKind: state?.sourceKind ?? "UNVERIFIED",
|
||
observedAt: state?.observedAt ?? box?.observedAt ?? null,
|
||
blocker: state?.lastError ?? (box?.online ? null : M3_IO_BLOCKER_CODES.boxUnavailable)
|
||
};
|
||
}
|
||
|
||
function m3StatusOperationSummary(trust) {
|
||
return {
|
||
status: trust.operationId ? "read" : "blocked",
|
||
operationId: trust.operationId ?? null,
|
||
source: trust.operationId ? "runtime-store audit/evidence" : "runtime-store",
|
||
blocker: trust.operationId ? null : trust.blocker ?? "operation_not_persisted",
|
||
readStatus: {
|
||
audit: trust.readStatus?.audit ?? "blocked",
|
||
evidence: trust.readStatus?.evidence ?? "blocked"
|
||
}
|
||
};
|
||
}
|
||
|
||
function m3StatusTraceSummary(trust) {
|
||
return {
|
||
status: trust.traceId ? "read" : "blocked",
|
||
traceId: trust.traceId ?? null,
|
||
source: trust.traceId ? "runtime-store audit/evidence" : "runtime-store",
|
||
blocker: trust.traceId ? null : trust.blocker ?? "trace_not_persisted"
|
||
};
|
||
}
|
||
|
||
function m3StatusAuditSummary(trust) {
|
||
return {
|
||
status: trust.auditId ? "read" : "blocked",
|
||
auditId: trust.auditId ?? null,
|
||
readStatus: trust.readStatus?.audit ?? "blocked",
|
||
source: trust.auditId ? "runtime-store audit.event.query" : "runtime-store",
|
||
blocker: trust.auditId ? null : trust.readStatus?.auditError ?? trust.blocker ?? "audit_not_persisted"
|
||
};
|
||
}
|
||
|
||
function m3StatusRuntimeDurableSummary(trust) {
|
||
return {
|
||
status: trust.durableStatus === "green" ? "green" : "blocked",
|
||
green: trust.durableStatus === "green",
|
||
blocker: trust.blocker ?? null,
|
||
runtime: trust.runtime,
|
||
readStatus: trust.readStatus ?? {
|
||
audit: "blocked",
|
||
evidence: "blocked"
|
||
},
|
||
auditReady: trust.readStatus?.audit === "read",
|
||
evidenceReady: trust.readStatus?.evidence === "read"
|
||
};
|
||
}
|
||
|
||
async function safeRuntimeRead(read) {
|
||
if (typeof read !== "function") {
|
||
return {
|
||
ok: false,
|
||
value: null,
|
||
error: {
|
||
code: "runtime_read_unavailable",
|
||
message: "runtime read method is unavailable"
|
||
}
|
||
};
|
||
}
|
||
try {
|
||
const value = await read();
|
||
if (value === undefined || value === null) {
|
||
return {
|
||
ok: false,
|
||
value: null,
|
||
error: {
|
||
code: "runtime_read_unavailable",
|
||
message: "runtime read returned no value"
|
||
}
|
||
};
|
||
}
|
||
return {
|
||
ok: true,
|
||
value,
|
||
error: null
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
ok: false,
|
||
value: null,
|
||
error: {
|
||
code: error?.code ?? "runtime_read_blocked",
|
||
message: error instanceof Error ? redactBlockerMessage(error.message) : String(error)
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
function latestM3AuditEvent(events) {
|
||
return [...events].reverse().find((event) =>
|
||
event?.operationId &&
|
||
event?.traceId &&
|
||
(event.action === "m3.io.do.write" || event.action === "m3.io.di.read" || String(event.metadata?.route ?? "").includes("res_boxsimu_1:DO1"))
|
||
) ?? null;
|
||
}
|
||
|
||
function latestM3EvidenceRecord(records) {
|
||
return [...records].reverse().find((record) =>
|
||
record?.operationId &&
|
||
(record.serviceId === CLOUD_API_SERVICE_ID || String(record.metadata?.route ?? "").includes("res_boxsimu_1:DO1"))
|
||
) ?? null;
|
||
}
|
||
|
||
function defaultM3Gateways({ sourceKind, observedAt }) {
|
||
return [
|
||
{
|
||
id: M3_IO_CHAIN.sourceGatewayId,
|
||
role: "source",
|
||
online: false,
|
||
observable: false,
|
||
sessionId: M3_IO_CHAIN.sourceGatewaySessionId,
|
||
lastSeenAt: observedAt,
|
||
sourceKind,
|
||
lastError: "未验证"
|
||
},
|
||
{
|
||
id: M3_IO_CHAIN.targetGatewayId,
|
||
role: "target",
|
||
online: false,
|
||
observable: false,
|
||
sessionId: M3_IO_CHAIN.targetGatewaySessionId,
|
||
lastSeenAt: observedAt,
|
||
sourceKind,
|
||
lastError: "未验证"
|
||
}
|
||
];
|
||
}
|
||
|
||
function defaultM3Boxes({ sourceKind, observedAt }) {
|
||
return [
|
||
{
|
||
id: M3_IO_CHAIN.sourceBoxId,
|
||
resourceId: M3_IO_CHAIN.sourceResourceId,
|
||
gatewayId: M3_IO_CHAIN.sourceGatewayId,
|
||
gatewaySessionId: M3_IO_CHAIN.sourceGatewaySessionId,
|
||
online: false,
|
||
observable: false,
|
||
sourceKind,
|
||
observedAt,
|
||
ports: {
|
||
[M3_IO_CHAIN.sourcePort]: {
|
||
value: null,
|
||
direction: "output",
|
||
source: "未验证",
|
||
sourceKind,
|
||
observedAt
|
||
}
|
||
}
|
||
},
|
||
{
|
||
id: M3_IO_CHAIN.targetBoxId,
|
||
resourceId: M3_IO_CHAIN.targetResourceId,
|
||
gatewayId: M3_IO_CHAIN.targetGatewayId,
|
||
gatewaySessionId: M3_IO_CHAIN.targetGatewaySessionId,
|
||
online: false,
|
||
observable: false,
|
||
sourceKind,
|
||
observedAt,
|
||
ports: {
|
||
[M3_IO_CHAIN.targetPort]: {
|
||
value: null,
|
||
direction: "input",
|
||
source: "未验证",
|
||
sourceKind,
|
||
observedAt
|
||
}
|
||
}
|
||
}
|
||
];
|
||
}
|
||
|
||
function defaultM3PatchPanel({ observedAt }) {
|
||
return {
|
||
serviceId: M3_IO_CHAIN.patchPanelServiceId,
|
||
observable: false,
|
||
connectionActive: false,
|
||
connectionVersion: null,
|
||
wiringConfigId: null,
|
||
lastSyncAt: observedAt,
|
||
lastError: "未验证",
|
||
sourceKind: "UNVERIFIED"
|
||
};
|
||
}
|
||
|
||
async function readM3StatusPort({ gateway, command, requestJson, timeoutMs, observedAt, port, direction }) {
|
||
const capability = capabilityFor({ ...command, action: "di.read", port }, {
|
||
boxId: command.boxId,
|
||
resourceId: command.resourceId
|
||
});
|
||
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,
|
||
operationId: `op_m3_status_read_${port.toLowerCase()}_${randomUUID()}`,
|
||
traceId: `trc_m3_status_read_${port.toLowerCase()}_${randomUUID()}`,
|
||
actorType: "service",
|
||
actorId: `svc_${CLOUD_API_SERVICE_ID}`,
|
||
source: "cloud-api-m3-status"
|
||
}, { requestJson, timeoutMs });
|
||
|
||
if (!dispatch.ok || dispatch.body?.accepted !== true) {
|
||
return unverifiedPortRead({
|
||
port,
|
||
direction,
|
||
source: "gateway-simu",
|
||
reason: `gateway-simu ${port} 读取失败:${dispatch.error ?? dispatch.body?.error?.message ?? "unknown"}`,
|
||
observedAt
|
||
});
|
||
}
|
||
|
||
return {
|
||
port: {
|
||
value: readValueFromGatewayResult(dispatch.body),
|
||
direction,
|
||
source: port === M3_IO_CHAIN.targetPort ? "gateway-simu/patch-panel" : "gateway-simu",
|
||
sourceKind: "DEV-LIVE",
|
||
observedAt: dispatch.body?.result?.state?.updatedAt ?? dispatch.body?.state?.updatedAt ?? observedAt,
|
||
auditId: dispatch.body?.auditId ?? dispatch.body?.result?.auditId ?? null,
|
||
evidenceId: dispatch.body?.evidenceId ?? dispatch.body?.result?.evidenceId ?? null,
|
||
state: dispatch.body?.result?.state ?? dispatch.body?.state ?? null
|
||
},
|
||
error: null
|
||
};
|
||
}
|
||
|
||
function unverifiedPortRead({ port, direction, source, reason, observedAt }) {
|
||
return {
|
||
port: {
|
||
value: null,
|
||
direction,
|
||
source,
|
||
sourceKind: "UNVERIFIED",
|
||
observedAt,
|
||
lastError: reason
|
||
},
|
||
error: reason
|
||
};
|
||
}
|
||
|
||
function gatewayStatusEntry(gateway, { id, sessionId, role, observedAt }) {
|
||
return {
|
||
id: gateway.gatewayId ?? id,
|
||
role,
|
||
online: gateway.available === true,
|
||
observable: gateway.available === true,
|
||
sessionId: gateway.gatewaySessionId ?? sessionId,
|
||
lastSeenAt: gateway.status?.lastHeartbeatAt ?? gateway.status?.updatedAt ?? gateway.status?.session?.lastSeenAt ?? observedAt,
|
||
sourceKind: gateway.available === true ? "DEV-LIVE" : "UNVERIFIED",
|
||
lastError: gateway.available === true ? null : gateway.reason,
|
||
blocker: gateway.available === true ? null : gateway.code ?? M3_IO_BLOCKER_CODES.gatewayUnavailable
|
||
};
|
||
}
|
||
|
||
function boxStatusEntry({ gateway, box, boxId, resourceId, gatewayId, gatewaySessionId, observedAt, ports }) {
|
||
return {
|
||
id: box?.boxId ?? boxId,
|
||
resourceId: box?.resourceId ?? resourceId,
|
||
gatewayId: gateway.gatewayId ?? gatewayId,
|
||
gatewaySessionId: gateway.gatewaySessionId ?? gatewaySessionId,
|
||
online: gateway.available === true && Boolean(box),
|
||
observable: gateway.available === true && Boolean(box),
|
||
sourceKind: gateway.available === true && box ? "DEV-LIVE" : "UNVERIFIED",
|
||
observedAt,
|
||
ports
|
||
};
|
||
}
|
||
|
||
function patchPanelStatusEntry({ statusResponse, wiringResponse, observedAt }) {
|
||
const status = statusResponse.body ?? {};
|
||
const wiring = wiringResponse.body ?? {};
|
||
const connectionActive = statusResponse.ok && wiringResponse.ok && hasExpectedPatchPanelWiring(status) && hasExpectedPatchPanelWiring(wiring);
|
||
const lastError = !statusResponse.ok
|
||
? `hwlab-patch-panel status 不可读:${statusResponse.error ?? `HTTP ${statusResponse.status}`}`
|
||
: !wiringResponse.ok
|
||
? `hwlab-patch-panel wiring 不可读:${wiringResponse.error ?? `HTTP ${wiringResponse.status}`}`
|
||
: connectionActive
|
||
? status.metadata?.evidenceSummary?.diagnostics?.errorCount > 0
|
||
? "hwlab-patch-panel diagnostics reports errors"
|
||
: null
|
||
: `未确认 active ${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort} -> ${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`;
|
||
return {
|
||
serviceId: status.serviceId ?? M3_IO_CHAIN.patchPanelServiceId,
|
||
observable: statusResponse.ok,
|
||
connectionActive,
|
||
connectionVersion: status.metadata?.evidenceSummary?.tickCount ?? status.metadata?.evidenceSummary?.routeCount ?? null,
|
||
wiringConfigId: status.wiringConfigId ?? wiring.wiringConfigId ?? null,
|
||
lastSyncAt: status.metadata?.evidenceSummary?.lastSyncAt ?? status.observedAt ?? wiring.updatedAt ?? observedAt,
|
||
lastError,
|
||
sourceKind: connectionActive ? "DEV-LIVE" : statusResponse.ok ? "UNVERIFIED" : "BLOCKED",
|
||
activeConnections: status.activeConnections ?? [],
|
||
diagnostics: status.metadata?.diagnostics ?? status.metadata?.evidenceSummary?.diagnostics ?? null
|
||
};
|
||
}
|
||
|
||
function readinessCheckFromGateway(id, gateway) {
|
||
return {
|
||
id,
|
||
status: gateway.available ? "pass" : "blocked",
|
||
layer: gateway.role,
|
||
sourceKind: gateway.available ? "DEV-LIVE" : "BLOCKED",
|
||
summary: gateway.available
|
||
? `${gateway.role} gateway ${gateway.gatewayId}/${gateway.gatewaySessionId} 只读状态可用。`
|
||
: gateway.reason,
|
||
gatewayId: gateway.gatewayId ?? null,
|
||
gatewaySessionId: gateway.gatewaySessionId ?? null,
|
||
url: gateway.url ? redactUrl(gateway.url) : null,
|
||
blocker: gateway.available ? null : gateway.code ?? M3_IO_BLOCKER_CODES.gatewayUnavailable
|
||
};
|
||
}
|
||
|
||
function readinessCheckFromBox(id, box, command) {
|
||
return {
|
||
id,
|
||
status: box ? "pass" : "blocked",
|
||
sourceKind: box ? "DEV-LIVE" : "BLOCKED",
|
||
summary: box
|
||
? `${command.resourceId}/${command.port} 已在 gateway registry 中可见。`
|
||
: `${command.resourceId}/${command.port} 未在 gateway registry 中可见。`,
|
||
resourceId: box?.resourceId ?? command.resourceId,
|
||
boxId: box?.boxId ?? command.boxId,
|
||
port: command.port,
|
||
blocker: box ? null : M3_IO_BLOCKER_CODES.boxUnavailable
|
||
};
|
||
}
|
||
|
||
function readinessCheckFromPatchPanelStatus(response) {
|
||
const expected = response.ok && hasExpectedPatchPanelWiring(response.body);
|
||
return {
|
||
id: "patch-panel-status",
|
||
status: expected ? "pass" : "blocked",
|
||
sourceKind: expected ? "DEV-LIVE" : "BLOCKED",
|
||
summary: expected
|
||
? "hwlab-patch-panel status 包含目标 active 接线。"
|
||
: response.ok
|
||
? "hwlab-patch-panel status 未包含目标 active 接线。"
|
||
: `hwlab-patch-panel status 不可读:${response.error ?? `HTTP ${response.status}`}`,
|
||
serviceId: response.body?.serviceId ?? null,
|
||
state: response.body?.state ?? null,
|
||
blocker: expected ? null : M3_IO_BLOCKER_CODES.wiringMissing
|
||
};
|
||
}
|
||
|
||
function readinessCheckFromPatchPanelWiring(response) {
|
||
const expected = response.ok && hasExpectedPatchPanelWiring(response.body);
|
||
return {
|
||
id: "patch-panel-wiring",
|
||
status: expected ? "pass" : "blocked",
|
||
sourceKind: expected ? "DEV-LIVE" : "BLOCKED",
|
||
summary: expected
|
||
? "hwlab-patch-panel /wiring 包含目标 active 接线。"
|
||
: response.ok
|
||
? "hwlab-patch-panel /wiring 未包含目标 active 接线。"
|
||
: `hwlab-patch-panel /wiring 不可读:${response.error ?? `HTTP ${response.status}`}`,
|
||
wiringConfigId: response.body?.wiringConfigId ?? null,
|
||
wiringStatus: response.body?.status ?? null,
|
||
blocker: expected ? null : M3_IO_BLOCKER_CODES.wiringMissing
|
||
};
|
||
}
|
||
|
||
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 (typeof command.value !== "boolean") {
|
||
return "M3 DO 写入值必须是 boolean。";
|
||
}
|
||
}
|
||
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"
|
||
};
|
||
}
|
||
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 hasExpectedPatchPanelWiring(body) {
|
||
const connections = [
|
||
...(Array.isArray(body?.activeConnections) ? body.activeConnections : []),
|
||
...(Array.isArray(body?.connections) ? body.connections : []),
|
||
...(Array.isArray(body?.wiringConfig?.connections) ? body.wiringConfig.connections : [])
|
||
];
|
||
const active = body?.state === undefined || !["faulted", "blocked", "disabled"].includes(String(body.state).toLowerCase());
|
||
const wiringActive = body?.status === undefined || String(body.status).toLowerCase() === "active";
|
||
return active && wiringActive && connections.some((connection) => {
|
||
const from = connection.from ?? connection;
|
||
const to = connection.to ?? connection;
|
||
return String(from.resourceId ?? connection.fromResourceId ?? "") === M3_IO_CHAIN.sourceResourceId &&
|
||
normalizePort(from.port ?? connection.fromPort) === M3_IO_CHAIN.sourcePort &&
|
||
String(to.resourceId ?? connection.toResourceId ?? "") === M3_IO_CHAIN.targetResourceId &&
|
||
normalizePort(to.port ?? connection.toPort) === M3_IO_CHAIN.targetPort;
|
||
});
|
||
}
|
||
|
||
function firstDiagnostic(body) {
|
||
return [
|
||
...(Array.isArray(body?.diagnostics) ? body.diagnostics : []),
|
||
...(Array.isArray(body?.routes) ? body.routes.flatMap((route) => route.diagnostics ?? []) : [])
|
||
][0] ?? null;
|
||
}
|