2306 lines
73 KiB
JavaScript
2306 lines
73 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";
|
||
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",
|
||
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",
|
||
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) {
|
||
return {
|
||
...base,
|
||
status: "blocked",
|
||
sourceKind: "BLOCKED",
|
||
summary: "M3 状态聚合未启用;前端不能绕过 cloud-api 直连 gateway/box-simu。",
|
||
gateways: defaultM3Gateways({ sourceKind: "UNVERIFIED", observedAt }),
|
||
boxes: defaultM3Boxes({ sourceKind: "UNVERIFIED", observedAt }),
|
||
patchPanel: defaultM3PatchPanel({ observedAt }),
|
||
trust: {
|
||
...trust,
|
||
blocker: "m3_control_disabled"
|
||
},
|
||
blocker: {
|
||
code: "m3_control_disabled",
|
||
layer: "cloud-api",
|
||
zh: "M3 状态聚合未启用;前端不能绕过 cloud-api 直连 gateway/box-simu。"
|
||
}
|
||
};
|
||
}
|
||
|
||
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";
|
||
|
||
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,
|
||
trust,
|
||
blocker: blockers[0] ?? (trust.blocker ? {
|
||
code: trust.blocker,
|
||
layer: "runtime-durable",
|
||
zh: `runtime durable 仍 blocked:${trust.blocker}`
|
||
} : null)
|
||
};
|
||
}
|
||
|
||
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 ?? "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)
|
||
};
|
||
}
|
||
|
||
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
|
||
}
|
||
};
|
||
}
|
||
|
||
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 (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 身份不匹配:期望 gatewayId=${expectedGatewayId},实际=${gatewayId};控制面板保持阻塞。`
|
||
};
|
||
}
|
||
if (expectedGatewaySessionId && gatewaySessionId !== expectedGatewaySessionId) {
|
||
return {
|
||
available: false,
|
||
role: gatewayRole,
|
||
url: redactUrl(url),
|
||
status,
|
||
code: M3_IO_BLOCKER_CODES.gatewayIdentityMismatch,
|
||
reason: `gateway-simu 会话不匹配:期望 gatewaySessionId=${expectedGatewaySessionId},实际=${gatewaySessionId};控制面板保持阻塞。`
|
||
};
|
||
}
|
||
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;
|
||
}
|
||
|
||
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();
|
||
if (result === undefined) {
|
||
return {
|
||
label,
|
||
written: false,
|
||
skipped: true,
|
||
reason: "runtimeStore method unavailable"
|
||
};
|
||
}
|
||
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
|
||
});
|
||
const target = controlTarget(input.command, input.operation.gatewaySessionId);
|
||
return {
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
contractVersion: M3_IO_CONTROL_CONTRACT_VERSION,
|
||
status: "succeeded",
|
||
accepted: true,
|
||
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.ready,
|
||
controlReady: true,
|
||
action: input.action,
|
||
chain: M3_IO_CHAIN,
|
||
command: input.command,
|
||
target,
|
||
gatewayId: target.gatewayId,
|
||
gatewaySessionId: target.gatewaySessionId,
|
||
resourceId: target.resourceId,
|
||
boxId: target.boxId,
|
||
port: target.port,
|
||
value: target.value,
|
||
operationId: input.operation.operationId,
|
||
traceId: input.meta.traceId,
|
||
auditId: records.auditEvent.auditId,
|
||
evidenceId: records.evidenceRecord.evidenceId,
|
||
operationState: {
|
||
status: "succeeded",
|
||
reachable: true
|
||
},
|
||
auditState: auditState(records.auditWrite, records.runtimeAfter),
|
||
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),
|
||
durableStatus: durableStatus(records.runtimeAfter),
|
||
trustBlocker: trustBlocker(records.runtimeAfter),
|
||
blockerClassification: redactedBlockerClassification({
|
||
code: records.runtimeAfter?.blocker,
|
||
reason: records.runtimeAfter?.reason,
|
||
runtime: records.runtimeAfter
|
||
}),
|
||
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
|
||
});
|
||
const target = controlTarget(input.command, input.operation.gatewaySessionId);
|
||
return {
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
contractVersion: M3_IO_CONTROL_CONTRACT_VERSION,
|
||
status: "blocked",
|
||
accepted: false,
|
||
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
|
||
controlReady: false,
|
||
action: input.action,
|
||
chain: M3_IO_CHAIN,
|
||
command: input.command,
|
||
target,
|
||
gatewayId: target.gatewayId,
|
||
gatewaySessionId: target.gatewaySessionId,
|
||
resourceId: target.resourceId,
|
||
boxId: target.boxId,
|
||
port: target.port,
|
||
value: target.value,
|
||
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
|
||
},
|
||
blockerClassification: redactedBlockerClassification({
|
||
code: input.code,
|
||
reason: input.reason,
|
||
runtime: records.runtimeAfter
|
||
}),
|
||
operationState: {
|
||
status: "blocked",
|
||
reachable: false,
|
||
blocker: input.code
|
||
},
|
||
auditState: auditState(records.auditWrite, records.runtimeAfter),
|
||
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),
|
||
durableStatus: durableStatus(records.runtimeAfter),
|
||
trustBlocker: trustBlocker(records.runtimeAfter),
|
||
persistence: persistenceSummary(records.runtimeAfter, input.registration, input.operationWrite, records),
|
||
observedAt: input.now
|
||
};
|
||
}
|
||
|
||
function blockedResult({ action, command = null, meta, runtime, code, reason, httpStatus, now, details = {} }) {
|
||
const target = controlTarget(command, command?.gatewaySessionId);
|
||
return {
|
||
serviceId: CLOUD_API_SERVICE_ID,
|
||
contractVersion: M3_IO_CONTROL_CONTRACT_VERSION,
|
||
status: "blocked",
|
||
accepted: false,
|
||
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
|
||
controlReady: false,
|
||
action: action || "unknown",
|
||
chain: M3_IO_CHAIN,
|
||
command,
|
||
target,
|
||
gatewayId: target.gatewayId,
|
||
gatewaySessionId: target.gatewaySessionId,
|
||
resourceId: target.resourceId,
|
||
boxId: target.boxId,
|
||
port: target.port,
|
||
value: target.value,
|
||
operationId: null,
|
||
traceId: meta.traceId,
|
||
auditId: null,
|
||
evidenceId: null,
|
||
blocker: {
|
||
code,
|
||
message: reason,
|
||
zh: reason
|
||
},
|
||
blockerClassification: redactedBlockerClassification({
|
||
code,
|
||
reason,
|
||
runtime
|
||
}),
|
||
operationState: {
|
||
status: "blocked",
|
||
reachable: false,
|
||
blocker: code
|
||
},
|
||
auditState: {
|
||
status: "not_written",
|
||
durableStatus: durableStatus(runtime),
|
||
reason: "operation was blocked before audit write"
|
||
},
|
||
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" }
|
||
}),
|
||
durableStatus: durableStatus(runtime),
|
||
trustBlocker: trustBlocker(runtime),
|
||
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;
|
||
const evidenceWriteState = recordWriteState(records.evidenceWrite, runtime);
|
||
if (durableReady && recordsWritten) {
|
||
return {
|
||
status: "green",
|
||
sourceKind: "DEV-LIVE",
|
||
durable: true,
|
||
writeStatus: evidenceWriteState.status,
|
||
durableStatus: durableStatus(runtime),
|
||
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,
|
||
writeStatus: evidenceWriteState.status,
|
||
durableStatus: durableStatus(runtime),
|
||
reason: `操作链路可达性与可信持久化分离:runtime durable 未 green(status=${runtime?.status ?? "unknown"},blocker=${blocker}),不能宣称可信闭环完全 green。`
|
||
};
|
||
}
|
||
|
||
function auditState(auditWrite, runtime) {
|
||
return {
|
||
...recordWriteState(auditWrite, runtime),
|
||
durableStatus: durableStatus(runtime)
|
||
};
|
||
}
|
||
|
||
function recordWriteState(write, runtime) {
|
||
if (write?.written === true && isDurableRuntimeReady(runtime)) {
|
||
return {
|
||
status: "persisted",
|
||
durable: true,
|
||
sourceKind: "DEV-LIVE"
|
||
};
|
||
}
|
||
if (write?.written === true) {
|
||
return {
|
||
status: "written_non_durable",
|
||
durable: false,
|
||
sourceKind: "BLOCKED",
|
||
blocker: runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked
|
||
};
|
||
}
|
||
return {
|
||
status: "not_written",
|
||
durable: false,
|
||
sourceKind: "BLOCKED",
|
||
blocker: runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked,
|
||
reason: write?.reason ?? "runtime write did not complete"
|
||
};
|
||
}
|
||
|
||
function durableStatus(runtime) {
|
||
const ready = isDurableRuntimeReady(runtime);
|
||
return {
|
||
status: runtime?.status ?? "unknown",
|
||
durable: runtime?.durable === true,
|
||
ready: runtime?.ready === true,
|
||
liveRuntimeEvidence: runtime?.liveRuntimeEvidence === true,
|
||
blocker: ready ? null : runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked,
|
||
redacted: true
|
||
};
|
||
}
|
||
|
||
function trustBlocker(runtime) {
|
||
if (isDurableRuntimeReady(runtime)) return null;
|
||
const durable = durableStatus(runtime);
|
||
return {
|
||
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",
|
||
durableStatus: durable
|
||
};
|
||
}
|
||
|
||
function redactedBlockerClassification({ code, reason, runtime } = {}) {
|
||
const blockerCode = code ?? runtime?.blocker ?? null;
|
||
if (!blockerCode) {
|
||
return null;
|
||
}
|
||
return {
|
||
code: blockerCode,
|
||
category: blockerCategory(blockerCode),
|
||
runtimeStatus: runtime?.status ?? "unknown",
|
||
redacted: true,
|
||
message: reason ? redactBlockerMessage(reason) : null
|
||
};
|
||
}
|
||
|
||
function blockerCategory(code) {
|
||
if (String(code).startsWith("runtime_durable")) return "runtime_durable";
|
||
if (String(code).includes("gateway")) return "gateway_session";
|
||
if (String(code).includes("patch_panel")) return "patch_panel";
|
||
if (String(code).includes("wiring")) return "patch_panel_wiring";
|
||
if (String(code).includes("box")) return "box_resource";
|
||
if (String(code).includes("port")) return "port_direction";
|
||
return "m3_io_control";
|
||
}
|
||
|
||
function redactBlockerMessage(message) {
|
||
return String(message)
|
||
.replace(/https?:\/\/[^\s,,;]+/giu, "[redacted-url]")
|
||
.replace(/postgres(?:ql)?:\/\/[^\s,,;]+/giu, "[redacted-db-url]");
|
||
}
|
||
|
||
function controlTarget(command, gatewaySessionId) {
|
||
return {
|
||
gatewayId: command?.gatewayId ?? null,
|
||
gatewaySessionId: gatewaySessionId ?? command?.gatewaySessionId ?? null,
|
||
resourceId: command?.resourceId ?? null,
|
||
boxId: command?.boxId ?? null,
|
||
port: command?.port ?? null,
|
||
value: Object.hasOwn(command ?? {}, "value") ? command.value : null
|
||
};
|
||
}
|
||
|
||
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";
|
||
}
|
||
}
|