Merge pull request #284 from pikasTech/fix/m3-control-readiness-227

fix: gate M3 controls on cloud-api readiness
This commit is contained in:
Lyon
2026-05-23 15:24:40 +08:00
committed by GitHub
8 changed files with 611 additions and 24 deletions
+257 -2
View File
@@ -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 mismatchexpected 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 mismatchexpected 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 : []),
+100 -2
View File
@@ -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 };
+2 -1
View File
@@ -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;
}
+101 -2
View File
@@ -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");
+69
View File
@@ -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() {
+17
View File
@@ -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
View File
@@ -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"
);
}