Merge pull request #284 from pikasTech/fix/m3-control-readiness-227
fix: gate M3 controls on cloud-api readiness
This commit is contained in:
@@ -98,6 +98,29 @@ export function describeM3IoControl(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function describeM3IoControlLive(options = {}) {
|
||||
const contract = describeM3IoControl(options);
|
||||
const env = options.env ?? process.env;
|
||||
const config = buildM3IoConfig(env);
|
||||
const readiness = await buildM3IoReadiness({
|
||||
config,
|
||||
requestJson: options.m3IoRequestJson ?? options.requestJson,
|
||||
now: options.now
|
||||
});
|
||||
const controlReady = readiness.status === "ready" && readiness.controlReady === true;
|
||||
|
||||
return {
|
||||
...contract,
|
||||
status: controlReady ? "available" : "blocked",
|
||||
sourceKind: controlReady ? "DEV-LIVE" : "BLOCKED",
|
||||
readiness,
|
||||
controlReady,
|
||||
blockedReason: controlReady
|
||||
? null
|
||||
: readiness.blocker?.zh ?? contract.blockedReason ?? "M3 IO 只读 readiness 未 green;控制面板保持阻塞。"
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleM3IoControl(params = {}, context = {}) {
|
||||
const env = context.env ?? process.env;
|
||||
const now = context.now?.() ?? new Date().toISOString();
|
||||
@@ -239,6 +262,137 @@ export async function handleM3IoControl(params = {}, context = {}) {
|
||||
}));
|
||||
}
|
||||
|
||||
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,
|
||||
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(
|
||||
{
|
||||
@@ -269,6 +423,89 @@ export function buildM3IoConfig(env = process.env) {
|
||||
};
|
||||
}
|
||||
|
||||
function blockedReadiness({ checks, code, layer, reason, observedAt }) {
|
||||
return {
|
||||
status: "blocked",
|
||||
controlReady: false,
|
||||
sourceKind: "BLOCKED",
|
||||
evidenceLevel: "BLOCKED",
|
||||
observedAt,
|
||||
summary: `M3 IO 只读 readiness 未 green:${reason}`,
|
||||
chain: M3_IO_CHAIN,
|
||||
blocker: {
|
||||
code,
|
||||
layer,
|
||||
zh: reason
|
||||
},
|
||||
checks
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -697,7 +934,7 @@ async function readGatewaySession(url, { gatewayRole, expectedGatewayId, expecte
|
||||
url: redactUrl(url),
|
||||
status,
|
||||
code: M3_IO_BLOCKER_CODES.gatewayIdentityMismatch,
|
||||
reason: `gateway-simu identity mismatch:expected gatewayId=${expectedGatewayId},actual=${gatewayId};indexed simulator identity drift must fail closed.`
|
||||
reason: `gateway-simu 身份不匹配:期望 gatewayId=${expectedGatewayId},实际=${gatewayId};控制面板保持阻塞。`
|
||||
};
|
||||
}
|
||||
if (expectedGatewaySessionId && gatewaySessionId !== expectedGatewaySessionId) {
|
||||
@@ -707,7 +944,7 @@ async function readGatewaySession(url, { gatewayRole, expectedGatewayId, expecte
|
||||
url: redactUrl(url),
|
||||
status,
|
||||
code: M3_IO_BLOCKER_CODES.gatewayIdentityMismatch,
|
||||
reason: `gateway-simu identity mismatch:expected gatewaySessionId=${expectedGatewaySessionId},actual=${gatewaySessionId};indexed simulator identity drift must fail closed.`
|
||||
reason: `gateway-simu 会话不匹配:期望 gatewaySessionId=${expectedGatewaySessionId},实际=${gatewaySessionId};控制面板保持阻塞。`
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -791,6 +1028,24 @@ function hasExpectedPatchDelivery(body) {
|
||||
);
|
||||
}
|
||||
|
||||
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 : []),
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
M3_IO_CHAIN,
|
||||
M3_IO_RPC_METHODS,
|
||||
describeM3IoControl,
|
||||
describeM3IoControlLive,
|
||||
handleM3IoControl
|
||||
} from "./m3-io-control.mjs";
|
||||
|
||||
@@ -33,6 +34,62 @@ test("M3 IO control describes a cloud-api-only control surface contract", () =>
|
||||
assert.deepEqual(contract.chain, M3_IO_CHAIN);
|
||||
});
|
||||
|
||||
test("M3 IO live descriptor enables controls only after read-only backend readiness is green", async () => {
|
||||
const fixture = createM3ControlFixture();
|
||||
const contract = await describeM3IoControlLive({
|
||||
env: {
|
||||
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
||||
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
||||
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
||||
},
|
||||
now: () => fixedNow,
|
||||
m3IoRequestJson: fixture.requestJson
|
||||
});
|
||||
|
||||
assert.equal(contract.status, "available");
|
||||
assert.equal(contract.controlReady, true);
|
||||
assert.equal(contract.readiness.status, "ready");
|
||||
assert.equal(contract.readiness.sourceKind, "DEV-LIVE");
|
||||
assert.equal(contract.blockedReason, null);
|
||||
assert.deepEqual(fixture.calls.map((call) => call.path), [
|
||||
"/status",
|
||||
"/status",
|
||||
"/status",
|
||||
"/wiring"
|
||||
]);
|
||||
});
|
||||
|
||||
test("M3 IO live descriptor blocks controls with Chinese reason when readiness is not green", async () => {
|
||||
const fixture = createM3ControlFixture({
|
||||
patchPanelStatusBody: {
|
||||
serviceId: "hwlab-patch-panel",
|
||||
state: "active",
|
||||
activeConnections: []
|
||||
},
|
||||
patchPanelWiringBody: {
|
||||
wiringConfigId: "wir_wrong",
|
||||
status: "active",
|
||||
connections: []
|
||||
}
|
||||
});
|
||||
const contract = await describeM3IoControlLive({
|
||||
env: {
|
||||
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
||||
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
||||
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
||||
},
|
||||
now: () => fixedNow,
|
||||
m3IoRequestJson: fixture.requestJson
|
||||
});
|
||||
|
||||
assert.equal(contract.status, "blocked");
|
||||
assert.equal(contract.controlReady, false);
|
||||
assert.equal(contract.readiness.status, "blocked");
|
||||
assert.equal(contract.readiness.sourceKind, "BLOCKED");
|
||||
assert.match(contract.blockedReason, /未确认 active|控制面板保持阻塞/u);
|
||||
assert.equal(contract.readiness.blocker.code, M3_IO_BLOCKER_CODES.wiringMissing);
|
||||
});
|
||||
|
||||
test("M3 DO write dispatches through gateway-simu, ticks patch-panel, reads DI, and records audit/evidence fields", async () => {
|
||||
const fixture = createM3ControlFixture();
|
||||
const runtimeStore = createCloudRuntimeStore({
|
||||
@@ -361,7 +418,7 @@ test("M3 IO control fails closed when indexed gateway identity drifts", async ()
|
||||
assert.equal(result.status, "blocked");
|
||||
assert.equal(result.accepted, false);
|
||||
assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.gatewayIdentityMismatch);
|
||||
assert.match(result.blocker.zh, /identity mismatch/u);
|
||||
assert.match(result.blocker.zh, /身份不匹配|会话不匹配/u);
|
||||
assert.deepEqual(fixture.calls.map((call) => call.path), ["/status"]);
|
||||
assert.equal(fixture.box1.ports.DO1.value, false);
|
||||
assert.equal(fixture.box2.ports.DI1.value, false);
|
||||
@@ -589,7 +646,7 @@ function createBlockedDurableRuntimeStore() {
|
||||
};
|
||||
}
|
||||
|
||||
function createM3ControlFixture({ patchPanelBody, gateway1Id = "gwsimu_1", gateway2Id = "gwsimu_2" } = {}) {
|
||||
function createM3ControlFixture({ patchPanelBody, patchPanelStatusBody, patchPanelWiringBody, gateway1Id = "gwsimu_1", gateway2Id = "gwsimu_2" } = {}) {
|
||||
const calls = [];
|
||||
const box1 = createBoxState({
|
||||
boxId: "boxsimu_1",
|
||||
@@ -671,6 +728,47 @@ function createM3ControlFixture({ patchPanelBody, gateway1Id = "gwsimu_1", gatew
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://patch-panel") && parsed.pathname === "/status") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: patchPanelStatusBody ?? {
|
||||
serviceId: "hwlab-patch-panel",
|
||||
state: "active",
|
||||
activeConnections: [
|
||||
{
|
||||
fromResourceId: "res_boxsimu_1",
|
||||
fromPort: "DO1",
|
||||
toResourceId: "res_boxsimu_2",
|
||||
toPort: "DI1"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://patch-panel") && parsed.pathname === "/wiring") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: patchPanelWiringBody ?? {
|
||||
wiringConfigId: "wir_m3_do1_di1",
|
||||
status: "active",
|
||||
connections: [
|
||||
{
|
||||
from: {
|
||||
resourceId: "res_boxsimu_1",
|
||||
port: "DO1"
|
||||
},
|
||||
to: {
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1"
|
||||
},
|
||||
mode: "exclusive"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://patch-panel") && parsed.pathname === "/sync/tick") {
|
||||
if (patchPanelBody) {
|
||||
return { ok: true, status: 200, body: patchPanelBody };
|
||||
|
||||
@@ -21,6 +21,7 @@ import { buildCloudApiReadiness } from "./health-contract.mjs";
|
||||
import {
|
||||
M3_IO_CONTROL_ROUTE,
|
||||
describeM3IoControl,
|
||||
describeM3IoControlLive,
|
||||
handleM3IoControl
|
||||
} from "./m3-io-control.mjs";
|
||||
import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||||
@@ -190,7 +191,7 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === M3_IO_CONTROL_ROUTE) {
|
||||
sendJson(response, 200, describeM3IoControl(options));
|
||||
sendJson(response, 200, await describeM3IoControlLive(options));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -672,6 +672,99 @@ function layerForBlockedCase(blockedLayer) {
|
||||
return blockedLayer;
|
||||
}
|
||||
|
||||
async function m3ReadinessRequestJson(url) {
|
||||
const parsed = new URL(url);
|
||||
if (url.startsWith("http://gateway-1") && parsed.pathname === "/status") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: m3GatewayStatus({
|
||||
gatewayId: "gwsimu_1",
|
||||
gatewaySessionId: "gws_gwsimu_1",
|
||||
boxId: "boxsimu_1",
|
||||
resourceId: "res_boxsimu_1"
|
||||
})
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://gateway-2") && parsed.pathname === "/status") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: m3GatewayStatus({
|
||||
gatewayId: "gwsimu_2",
|
||||
gatewaySessionId: "gws_gwsimu_2",
|
||||
boxId: "boxsimu_2",
|
||||
resourceId: "res_boxsimu_2"
|
||||
})
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://patch-panel") && parsed.pathname === "/status") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
serviceId: "hwlab-patch-panel",
|
||||
state: "active",
|
||||
activeConnections: [
|
||||
{
|
||||
fromResourceId: "res_boxsimu_1",
|
||||
fromPort: "DO1",
|
||||
toResourceId: "res_boxsimu_2",
|
||||
toPort: "DI1"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://patch-panel") && parsed.pathname === "/wiring") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
wiringConfigId: "wir_m3_do1_di1",
|
||||
status: "active",
|
||||
connections: [
|
||||
{
|
||||
from: {
|
||||
resourceId: "res_boxsimu_1",
|
||||
port: "DO1"
|
||||
},
|
||||
to: {
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1"
|
||||
},
|
||||
mode: "exclusive"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected M3 readiness request ${url}`);
|
||||
}
|
||||
|
||||
function m3GatewayStatus({ gatewayId, gatewaySessionId, boxId, resourceId }) {
|
||||
return {
|
||||
serviceId: "hwlab-gateway-simu",
|
||||
gatewayId,
|
||||
gatewaySessionId,
|
||||
session: {
|
||||
gatewayId,
|
||||
gatewaySessionId
|
||||
},
|
||||
registry: {
|
||||
gatewayId,
|
||||
gatewaySessionId,
|
||||
boxes: [
|
||||
{
|
||||
boxId,
|
||||
resourceId,
|
||||
state: "registered"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test("cloud api /v1 describes Code Agent provider blocker without leaking secret values", async () => {
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
@@ -1153,8 +1246,12 @@ test("cloud api /v1/agent/chat does not complete on empty provider text", async
|
||||
test("cloud api /v1 exposes M3 IO control contract without generic frontend hardware RPC", async () => {
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
HWLAB_M3_IO_CONTROL_ENABLED: "true"
|
||||
}
|
||||
HWLAB_M3_IO_CONTROL_ENABLED: "true",
|
||||
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
||||
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
||||
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
||||
},
|
||||
m3IoRequestJson: m3ReadinessRequestJson
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
@@ -1172,6 +1269,8 @@ test("cloud api /v1 exposes M3 IO control contract without generic frontend hard
|
||||
assert.equal(contract.status, 200);
|
||||
const payload = await contract.json();
|
||||
assert.equal(payload.status, "available");
|
||||
assert.equal(payload.controlReady, true);
|
||||
assert.equal(payload.readiness.status, "ready");
|
||||
assert.equal(payload.chain.sourceResourceId, "res_boxsimu_1");
|
||||
assert.equal(payload.chain.targetResourceId, "res_boxsimu_2");
|
||||
assert.equal(payload.boundaries.frontendCallsOnly, "/v1/m3/io");
|
||||
|
||||
@@ -183,9 +183,78 @@ test("M3 IO E2E classifies trusted green only with durable DEV-LIVE evidence", (
|
||||
);
|
||||
});
|
||||
|
||||
test("M3 IO E2E does not claim trusted green for SOURCE, DRY-RUN, or BLOCKED backend evidence", () => {
|
||||
for (const sourceKind of ["SOURCE", "DRY-RUN", "BLOCKED"]) {
|
||||
const operations = expectedM3IoLiveSequence().map((step) => ({
|
||||
id: step.id,
|
||||
status: "succeeded",
|
||||
schema: { status: "pass", issues: [] },
|
||||
evidenceState: {
|
||||
status: "green",
|
||||
sourceKind,
|
||||
durable: true
|
||||
}
|
||||
}));
|
||||
assert.deepEqual(
|
||||
classifyM3IoControlReport({
|
||||
checks: [{ id: "source", status: "pass" }],
|
||||
liveOperations: operations
|
||||
}),
|
||||
{
|
||||
status: "blocked",
|
||||
classification: controlPathReachablePersistenceBlocked,
|
||||
trustedGreen: false
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("M3 IO E2E frontend guardrail requires readiness before controls unlock", () => {
|
||||
const appSource = `
|
||||
function m3ControlCanOperate(contract) {
|
||||
return contract?.status === "available" &&
|
||||
contract?.readiness?.status === "ready" &&
|
||||
contract?.readiness?.controlReady === true &&
|
||||
contract?.readiness?.sourceKind === "DEV-LIVE" &&
|
||||
contract?.readiness?.evidenceLevel === "DEV-LIVE";
|
||||
}
|
||||
async function runM3IoAction() {
|
||||
if (!m3ControlCanOperate()) return;
|
||||
return fetchJson("/v1/m3/io", { method: "POST" });
|
||||
}
|
||||
`;
|
||||
const pass = checkFrontendNoDirectRuntimeCalls({
|
||||
appSource,
|
||||
artifactPublisherSource: 'if (url.pathname === "/v1/m3/io") proxyCloudApi();'
|
||||
});
|
||||
assert.equal(pass.status, "pass");
|
||||
|
||||
const fail = checkFrontendNoDirectRuntimeCalls({
|
||||
appSource: `
|
||||
function m3ControlCanOperate(contract) {
|
||||
return contract?.status === "available";
|
||||
}
|
||||
async function runM3IoAction() {
|
||||
return fetchJson("/v1/m3/io", { method: "POST" });
|
||||
}
|
||||
`,
|
||||
artifactPublisherSource: 'if (url.pathname === "/v1/m3/io") proxyCloudApi();'
|
||||
});
|
||||
assert.equal(fail.status, "fail");
|
||||
assert.ok(fail.issues.some((issue) => /readiness/u.test(issue)));
|
||||
});
|
||||
|
||||
test("M3 IO E2E frontend guardrail catches direct gateway or patch-panel calls", () => {
|
||||
const appSource = `
|
||||
function m3ControlCanOperate(contract) {
|
||||
return contract?.status === "available" &&
|
||||
contract?.readiness?.status === "ready" &&
|
||||
contract?.readiness?.controlReady === true &&
|
||||
contract?.readiness?.sourceKind === "DEV-LIVE" &&
|
||||
contract?.readiness?.evidenceLevel === "DEV-LIVE";
|
||||
}
|
||||
async function runM3IoAction() {
|
||||
if (!m3ControlCanOperate()) return;
|
||||
return fetchJson("/v1/m3/io", { method: "POST" });
|
||||
}
|
||||
async function other() {
|
||||
|
||||
@@ -364,6 +364,20 @@ export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "",
|
||||
if (/fetchJson\(["'`](?!\/v1\/m3\/io)/u.test(actionBody) || /\bfetch\(/u.test(actionBody)) {
|
||||
issues.push("runM3IoAction must not call any path other than fetchJson(\"/v1/m3/io\")");
|
||||
}
|
||||
if (!/if \(!m3ControlCanOperate\(\)\)/u.test(actionBody)) {
|
||||
issues.push("runM3IoAction must fail closed before POST when M3 readiness is blocked");
|
||||
}
|
||||
const readinessBody = functionBody(appSource, "m3ControlCanOperate");
|
||||
for (const [pattern, label] of [
|
||||
[/readiness\?\.status === "ready"/u, "readiness.status=ready"],
|
||||
[/readiness\?\.controlReady === true/u, "readiness.controlReady=true"],
|
||||
[/readiness\?\.sourceKind === "DEV-LIVE"/u, "readiness.sourceKind=DEV-LIVE"],
|
||||
[/readiness\?\.evidenceLevel === "DEV-LIVE"/u, "readiness.evidenceLevel=DEV-LIVE"]
|
||||
]) {
|
||||
if (!pattern.test(readinessBody)) {
|
||||
issues.push(`m3ControlCanOperate must require ${label}`);
|
||||
}
|
||||
}
|
||||
if (!/url\.pathname === "\/v1\/m3\/io"/u.test(artifactPublisherSource)) {
|
||||
issues.push("cloud-web artifact server must proxy same-origin /v1/m3/io to cloud-api");
|
||||
}
|
||||
@@ -438,6 +452,9 @@ export function validateM3IoSourceResponseContract(m3Source) {
|
||||
/durableStatus:\s*durableStatus\(/u,
|
||||
/blockerClassification:\s*redactedBlockerClassification\(/u,
|
||||
/persistence:\s*persistenceSummary\(/u,
|
||||
/describeM3IoControlLive/u,
|
||||
/buildM3IoReadiness/u,
|
||||
/controlReady:\s*false/u,
|
||||
/frontendBypass:\s*false/u,
|
||||
/patchPanelOnlyPropagation:\s*true/u,
|
||||
/sourceKind:\s*"DEV-LIVE"/u,
|
||||
|
||||
+51
-14
@@ -579,6 +579,23 @@ async function callRpc(method, params = {}) {
|
||||
async function runM3IoAction(action) {
|
||||
if (state.m3Control.pending) return;
|
||||
const traceId = nextProtocolId("trc");
|
||||
if (!m3ControlCanOperate()) {
|
||||
state.m3Control.operation = {
|
||||
status: "blocked",
|
||||
action,
|
||||
traceId,
|
||||
blocker: {
|
||||
code: state.m3Control.contract?.readiness?.blocker?.code ?? "m3_control_readiness_blocked",
|
||||
zh: m3ControlBlockedReason()
|
||||
},
|
||||
observedAt: new Date().toISOString(),
|
||||
sourceKind: "BLOCKED"
|
||||
};
|
||||
renderM3ControlStatus();
|
||||
renderRecords(state.liveSurface);
|
||||
renderDrafts();
|
||||
return;
|
||||
}
|
||||
state.m3Control.pending = true;
|
||||
state.m3Control.operation = {
|
||||
status: "running",
|
||||
@@ -926,6 +943,7 @@ function renderHardwareStatus(summary) {
|
||||
const sourceLink = trustedM3Link(patchPanel.activeConnections);
|
||||
const liveM3Evidence = trustedM3LiveEvidenceFrom(summary) ?? m3ControlLiveEvidence();
|
||||
const m3Live = hasTrustedM3LiveEvidence(liveM3Evidence);
|
||||
const controlReady = m3ControlCanOperate();
|
||||
const runtimeCountsSourceKind = "SOURCE";
|
||||
const runtimeCountsTone = "source";
|
||||
const patchTone = m3Live ? "live" : patchPanel.state === "active" ? "source" : patchPanel.state;
|
||||
@@ -1011,12 +1029,12 @@ function renderHardwareStatus(summary) {
|
||||
rows: [
|
||||
{
|
||||
label: "硬件控制",
|
||||
value: state.m3Control.contract?.status === "available" ? "受控路径" : "等待探测",
|
||||
detail: state.m3Control.contract?.status === "available"
|
||||
value: controlReady ? "受控路径" : "受控路径受阻",
|
||||
detail: controlReady
|
||||
? "控制只通过受控后端执行,并经由 gateway-simu、box-simu 与 patch-panel;页面不直连模拟器,也不伪造硬件状态。"
|
||||
: "控制路径未就绪;页面不直连模拟器,也不伪造硬件状态。",
|
||||
sourceKind: state.m3Control.contract?.status === "available" ? "SOURCE" : "BLOCKED",
|
||||
tone: state.m3Control.contract?.status === "available" ? "source" : "blocked"
|
||||
: `${m3ControlBlockedReason()} 页面不直连模拟器,也不伪造硬件状态。`,
|
||||
sourceKind: controlReady ? "SOURCE" : "BLOCKED",
|
||||
tone: controlReady ? "source" : "blocked"
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -1206,7 +1224,8 @@ function m3LiveDetail(evidence) {
|
||||
function renderM3ControlStatus() {
|
||||
const contract = state.m3Control.contract;
|
||||
const operation = state.m3Control.operation;
|
||||
const contractAvailable = contract?.status === "available";
|
||||
const controlReady = m3ControlCanOperate(contract);
|
||||
const contractObserved = Boolean(contract);
|
||||
const pending = state.m3Control.pending;
|
||||
const operationSucceeded = operation?.status === "succeeded";
|
||||
const operationBlocked = operation?.status === "blocked";
|
||||
@@ -1218,29 +1237,47 @@ function renderM3ControlStatus() {
|
||||
: "控制可达 / 等待记录"
|
||||
: operationBlocked
|
||||
? "操作待处理"
|
||||
: contractAvailable
|
||||
? "受控路径可用"
|
||||
: controlReady
|
||||
? "受控路径可执行"
|
||||
: contractObserved
|
||||
? "受控路径受阻"
|
||||
: "等待探测";
|
||||
const tone = pending
|
||||
? "dry-run"
|
||||
: operationSucceeded
|
||||
? operation.evidenceState?.status === "green" ? "dev-live" : "degraded"
|
||||
: operationBlocked || !contractAvailable
|
||||
: operationBlocked || !controlReady
|
||||
? "blocked"
|
||||
: "source";
|
||||
|
||||
el.m3ControlStatus.textContent = label;
|
||||
el.m3ControlStatus.className = `state-tag tone-${toneClass(tone)}`;
|
||||
el.m3ControlStatus.title = operation?.evidenceState?.reason ?? contract?.blockedReason ?? "";
|
||||
const disabled = pending || !contractAvailable;
|
||||
el.m3ControlStatus.title = operation?.blocker?.zh ?? operation?.evidenceState?.reason ?? m3ControlBlockedReason(contract);
|
||||
const disabled = pending || !controlReady;
|
||||
for (const input of [el.m3GatewaySelect, el.m3BoxSelect, el.m3PortSelect, el.m3ValueSelect, el.m3WriteDo, el.m3ReadDi]) {
|
||||
input.disabled = disabled;
|
||||
}
|
||||
el.m3WriteDo.textContent = pending ? "执行中" : "写入 DO1";
|
||||
}
|
||||
|
||||
function m3ControlCanOperate(contract = state.m3Control.contract) {
|
||||
return contract?.status === "available" &&
|
||||
contract?.readiness?.status === "ready" &&
|
||||
contract?.readiness?.controlReady === true &&
|
||||
contract?.readiness?.sourceKind === "DEV-LIVE" &&
|
||||
contract?.readiness?.evidenceLevel === "DEV-LIVE";
|
||||
}
|
||||
|
||||
function m3ControlBlockedReason(contract = state.m3Control.contract) {
|
||||
if (!contract) return "等待 cloud-api 返回 M3 IO 只读 readiness;未确认前控制保持阻塞。";
|
||||
return contract.readiness?.blocker?.zh ??
|
||||
contract.blockedReason ??
|
||||
"M3 IO 只读 readiness 未 green;控制面板保持阻塞,不直连 gateway/box-simu。";
|
||||
}
|
||||
|
||||
function controlRows() {
|
||||
const operation = state.m3Control.operation;
|
||||
const controlReady = m3ControlCanOperate();
|
||||
const operationCards = operation
|
||||
? [
|
||||
{
|
||||
@@ -1257,10 +1294,10 @@ function controlRows() {
|
||||
return [
|
||||
{
|
||||
title: "控制路径",
|
||||
detail: state.m3Control.contract?.status === "available"
|
||||
detail: controlReady
|
||||
? "按钮只走受控后端,再通过 gateway-simu -> box-simu -> hwlab-patch-panel 执行 M3 DO/DI。"
|
||||
: "等待受控路径探测;不可用时不会直连 gateway/box-simu。",
|
||||
tone: state.m3Control.contract?.status === "available" ? "source" : "blocked"
|
||||
: `${m3ControlBlockedReason()} 不可用时不会直连 gateway/box-simu,也不会伪造硬件状态。`,
|
||||
tone: controlReady ? "source" : "blocked"
|
||||
},
|
||||
...operationCards,
|
||||
{
|
||||
|
||||
@@ -53,7 +53,10 @@ export function runM3ControlPanelGuard() {
|
||||
|
||||
for (const copy of [
|
||||
"M3 IO 控制请求失败",
|
||||
"等待受控路径探测;不可用时不会直连 gateway/box-simu。",
|
||||
"M3 IO 只读 readiness 未 green",
|
||||
"受控路径受阻",
|
||||
"等待 cloud-api 返回 M3 IO 只读 readiness",
|
||||
"不可用时不会直连 gateway/box-simu",
|
||||
"浏览器不直连 box-simu",
|
||||
"阻塞原因=",
|
||||
"仍在等待完整可信记录",
|
||||
@@ -109,6 +112,7 @@ function assertControlPanelSource(app) {
|
||||
assert.match(initBody, /el\.m3ReadDi\.addEventListener\("click"[\s\S]*runM3IoAction\("di\.read"\)/u, "DI button must run di.read");
|
||||
|
||||
const actionBody = functionBody(app, "runM3IoAction");
|
||||
assert.match(actionBody, /if \(!m3ControlCanOperate\(\)\)/u, "M3 action must fail closed before POST when readiness is blocked");
|
||||
assert.match(actionBody, /fetchJson\("\/v1\/m3\/io",\s*\{[\s\S]*method:\s*"POST"/u, "M3 action must POST same-origin /v1/m3/io");
|
||||
assert.doesNotMatch(actionBody, /\bfetch\(/u, "M3 action must not use raw fetch");
|
||||
assert.doesNotMatch(actionBody, /fetchJson\(\s*["'`](?!\/v1\/m3\/io)/u, "M3 action must not call any route except /v1/m3/io");
|
||||
@@ -125,9 +129,16 @@ function assertControlPanelSource(app) {
|
||||
assert.match(actionBody, /"X-Actor-Id":\s*"usr_hwlab_cloud_web"/u, "M3 action must attach web actor header");
|
||||
assert.match(actionBody, /evidenceState\?\.status === "green" \? "DEV-LIVE" : "BLOCKED"/u, "M3 operation sourceKind must not claim DEV-LIVE unless evidence is green");
|
||||
|
||||
const canOperateBody = functionBody(app, "m3ControlCanOperate");
|
||||
assert.match(canOperateBody, /readiness\?\.status === "ready"/u, "M3 controls must require readiness.status=ready");
|
||||
assert.match(canOperateBody, /readiness\?\.controlReady === true/u, "M3 controls must require readiness.controlReady=true");
|
||||
assert.match(canOperateBody, /readiness\?\.sourceKind === "DEV-LIVE"/u, "M3 controls must not unlock on SOURCE/DRY-RUN/BLOCKED readiness");
|
||||
assert.match(canOperateBody, /readiness\?\.evidenceLevel === "DEV-LIVE"/u, "M3 controls must require live readiness evidence level");
|
||||
|
||||
const controlRowsBody = functionBody(app, "controlRows");
|
||||
assert.match(controlRowsBody, /按钮只走受控后端/u, "control copy must describe the controlled backend route");
|
||||
assert.match(controlRowsBody, /gateway-simu -> box-simu -> hwlab-patch-panel/u, "control copy must show backend-owned path");
|
||||
assert.match(controlRowsBody, /m3ControlBlockedReason\(\)/u, "blocked control copy must surface the cloud-api Chinese readiness reason");
|
||||
assert.match(controlRowsBody, /受控路径读取/u, "control rows must describe DI read affordance");
|
||||
|
||||
const operationDetailBody = functionBody(app, "m3OperationDetail");
|
||||
@@ -191,8 +202,8 @@ function assertNoStaticDevLiveClaim({ html, app }) {
|
||||
assert.match(`${controlPanel}\n${wiringPanel}`, /tone-blocked|待可信记录/u, "static M3 panels must keep blocked/source posture");
|
||||
assert.doesNotMatch(
|
||||
functionBody(app, "renderM3ControlStatus"),
|
||||
/operationSucceeded[\s\S]{0,160}\?\s*"dev-live"\s*:\s*"degraded"[\s\S]{0,160}operation\.status === "succeeded"/u,
|
||||
"M3 status rendering must not treat any succeeded operation as DEV-LIVE without evidenceState green"
|
||||
/contract\?\.status === "available"[\s\S]{0,120}disabled/u,
|
||||
"M3 status rendering must not unlock controls from route availability alone"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user