feat: add M3 IO control surface path
This commit is contained in:
@@ -198,6 +198,10 @@
|
||||
"HWLAB_CLOUD_DB_CONTRACT": "dev-redacted-presence-only",
|
||||
"HWLAB_CLOUD_RUNTIME_ADAPTER": "postgres",
|
||||
"HWLAB_CLOUD_RUNTIME_DURABLE": "true",
|
||||
"HWLAB_M3_IO_CONTROL_ENABLED": "true",
|
||||
"HWLAB_M3_GATEWAY_SIMU_1_URL": "http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101",
|
||||
"HWLAB_M3_GATEWAY_SIMU_2_URL": "http://hwlab-gateway-simu-2.hwlab-dev.svc.cluster.local:7101",
|
||||
"HWLAB_M3_PATCH_PANEL_URL": "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301",
|
||||
"HWLAB_CODE_AGENT_PROVIDER": "openai",
|
||||
"HWLAB_CODE_AGENT_MODEL": "gpt-5.5",
|
||||
"HWLAB_CODE_AGENT_OPENAI_BASE_URL": "http://172.26.26.227:17680/v1/responses",
|
||||
|
||||
@@ -89,6 +89,22 @@
|
||||
"name": "HWLAB_CLOUD_RUNTIME_DURABLE",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "HWLAB_M3_IO_CONTROL_ENABLED",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "HWLAB_M3_GATEWAY_SIMU_1_URL",
|
||||
"value": "http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101"
|
||||
},
|
||||
{
|
||||
"name": "HWLAB_M3_GATEWAY_SIMU_2_URL",
|
||||
"value": "http://hwlab-gateway-simu-2.hwlab-dev.svc.cluster.local:7101"
|
||||
},
|
||||
{
|
||||
"name": "HWLAB_M3_PATCH_PANEL_URL",
|
||||
"value": "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301"
|
||||
},
|
||||
{
|
||||
"name": "HWLAB_CODE_AGENT_PROVIDER",
|
||||
"value": "openai"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,442 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||||
import { createBoxState, createGatewayState } from "../sim/model.mjs";
|
||||
import { writeBoxPort } from "../sim/l2-runtime.mjs";
|
||||
import {
|
||||
M3_IO_BLOCKER_CODES,
|
||||
M3_IO_CHAIN,
|
||||
describeM3IoControl,
|
||||
handleM3IoControl
|
||||
} from "./m3-io-control.mjs";
|
||||
|
||||
const fixedNow = "2026-05-23T00:00:00.000Z";
|
||||
|
||||
test("M3 IO control describes a cloud-api-only control surface contract", () => {
|
||||
const contract = describeM3IoControl({
|
||||
env: {
|
||||
HWLAB_M3_IO_CONTROL_ENABLED: "true"
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(contract.status, "available");
|
||||
assert.equal(contract.route, "/v1/m3/io");
|
||||
assert.equal(contract.boundaries.frontendCallsOnly, "/v1/m3/io");
|
||||
assert.equal(contract.boundaries.cloudApiDispatchesToGateway, true);
|
||||
assert.equal(contract.boundaries.patchPanelOwnsPropagation, true);
|
||||
assert.equal(contract.boundaries.directFrontendGatewayOrBoxAccess, false);
|
||||
assert.equal(contract.boundaries.genericHardwareRpcExposedToFrontend, false);
|
||||
assert.deepEqual(contract.chain, M3_IO_CHAIN);
|
||||
});
|
||||
|
||||
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({
|
||||
now: () => fixedNow
|
||||
});
|
||||
|
||||
const result = await handleM3IoControl(
|
||||
{
|
||||
action: "do.write",
|
||||
gatewayId: "gwsimu_1",
|
||||
resourceId: "res_boxsimu_1",
|
||||
boxId: "boxsimu_1",
|
||||
port: "DO1",
|
||||
value: true,
|
||||
traceId: "trc_m3_control_write_true",
|
||||
requestId: "req_m3_control_write_true",
|
||||
actorId: "usr_m3_operator"
|
||||
},
|
||||
{
|
||||
runtimeStore,
|
||||
now: () => fixedNow,
|
||||
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"
|
||||
},
|
||||
requestJson: fixture.requestJson
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.status, "succeeded");
|
||||
assert.equal(result.accepted, true);
|
||||
assert.equal(result.action, "do.write");
|
||||
assert.equal(result.command.port, "DO1");
|
||||
assert.equal(result.command.value, true);
|
||||
assert.match(result.operationId, /^op_m3_do_write_/u);
|
||||
assert.equal(result.traceId, "trc_m3_control_write_true");
|
||||
assert.match(result.auditId, /^aud_m3_do_write_/u);
|
||||
assert.match(result.evidenceId, /^evd_m3_do_write_/u);
|
||||
assert.equal(result.controlPath.cloudApi, true);
|
||||
assert.equal(result.controlPath.gatewaySimu, true);
|
||||
assert.equal(result.controlPath.boxSimu, true);
|
||||
assert.equal(result.controlPath.patchPanel, true);
|
||||
assert.equal(result.controlPath.frontendBypass, false);
|
||||
assert.equal(result.result.value, true);
|
||||
assert.equal(result.result.targetReadback.value, true);
|
||||
assert.equal(result.evidenceState.status, "blocked");
|
||||
assert.equal(result.evidenceState.sourceKind, "BLOCKED");
|
||||
assert.match(result.evidenceState.reason, /runtime durable 未 green/u);
|
||||
assert.equal(fixture.box1.ports.DO1.value, true);
|
||||
assert.equal(fixture.box2.ports.DI1.value, true);
|
||||
assert.deepEqual(fixture.calls.map((call) => call.path), [
|
||||
"/status",
|
||||
"/invoke",
|
||||
"/sync/tick",
|
||||
"/status",
|
||||
"/invoke"
|
||||
]);
|
||||
assert.equal(fixture.calls.every((call) => call.url.startsWith("http://gateway-") || call.url.startsWith("http://patch-panel")), true);
|
||||
|
||||
const audit = runtimeStore.queryAuditEvents({ projectId: M3_IO_CHAIN.projectId });
|
||||
assert.ok(audit.events.some((event) => event.action === "m3.io.do.write"));
|
||||
const evidence = runtimeStore.queryEvidenceRecords({ projectId: M3_IO_CHAIN.projectId });
|
||||
assert.equal(evidence.count, 1);
|
||||
assert.equal(evidence.records[0].operationId, result.operationId);
|
||||
});
|
||||
|
||||
test("M3 DI read returns structured value through gateway-simu without patch-panel mutation", async () => {
|
||||
const fixture = createM3ControlFixture();
|
||||
writeBoxPort({
|
||||
state: fixture.box2,
|
||||
port: "DI1",
|
||||
value: true,
|
||||
source: "patch-panel"
|
||||
});
|
||||
|
||||
const result = await handleM3IoControl(
|
||||
{
|
||||
action: "di.read",
|
||||
gatewayId: "gwsimu_2",
|
||||
resourceId: "res_boxsimu_2",
|
||||
boxId: "boxsimu_2",
|
||||
port: "DI1",
|
||||
traceId: "trc_m3_control_read_di",
|
||||
requestId: "req_m3_control_read_di",
|
||||
actorId: "usr_m3_operator"
|
||||
},
|
||||
{
|
||||
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
|
||||
now: () => fixedNow,
|
||||
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"
|
||||
},
|
||||
requestJson: fixture.requestJson
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.status, "succeeded");
|
||||
assert.equal(result.accepted, true);
|
||||
assert.equal(result.action, "di.read");
|
||||
assert.equal(result.command.port, "DI1");
|
||||
assert.equal(result.result.value, true);
|
||||
assert.equal(result.controlPath.patchPanel, false);
|
||||
assert.deepEqual(fixture.calls.map((call) => call.path), ["/status", "/invoke"]);
|
||||
});
|
||||
|
||||
test("M3 IO control blocks with Chinese reason when gateway session is unavailable", async () => {
|
||||
const result = await handleM3IoControl(
|
||||
{
|
||||
action: "do.write",
|
||||
value: true,
|
||||
traceId: "trc_m3_gateway_missing",
|
||||
requestId: "req_m3_gateway_missing",
|
||||
actorId: "usr_m3_operator"
|
||||
},
|
||||
{
|
||||
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
|
||||
now: () => fixedNow,
|
||||
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"
|
||||
},
|
||||
requestJson: async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
body: {
|
||||
error: {
|
||||
message: "unreachable"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.status, "blocked");
|
||||
assert.equal(result.accepted, false);
|
||||
assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.gatewayUnavailable);
|
||||
assert.match(result.blocker.zh, /gateway 未注册\/不可用/u);
|
||||
assert.equal(result.controlPath.cloudApi, true);
|
||||
assert.equal(result.controlPath.frontendBypass, false);
|
||||
});
|
||||
|
||||
test("M3 IO control blocks invalid port direction instead of allowing generic writes", async () => {
|
||||
const result = await handleM3IoControl(
|
||||
{
|
||||
action: "do.write",
|
||||
gatewayId: "gwsimu_1",
|
||||
resourceId: "res_boxsimu_1",
|
||||
port: "DI1",
|
||||
value: true,
|
||||
traceId: "trc_m3_invalid_port",
|
||||
requestId: "req_m3_invalid_port",
|
||||
actorId: "usr_m3_operator"
|
||||
},
|
||||
{
|
||||
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
|
||||
now: () => fixedNow,
|
||||
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"
|
||||
},
|
||||
requestJson: async () => {
|
||||
throw new Error("gateway must not be called for invalid port direction");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.status, "blocked");
|
||||
assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.portDirectionInvalid);
|
||||
assert.match(result.blocker.zh, /端口方向|资源越界/u);
|
||||
});
|
||||
|
||||
test("M3 IO control fails closed when indexed gateway identity drifts", async () => {
|
||||
const fixture = createM3ControlFixture({
|
||||
gateway1Id: "gwsimu_2"
|
||||
});
|
||||
|
||||
const result = await handleM3IoControl(
|
||||
{
|
||||
action: "do.write",
|
||||
gatewayId: "gwsimu_1",
|
||||
resourceId: "res_boxsimu_1",
|
||||
boxId: "boxsimu_1",
|
||||
port: "DO1",
|
||||
value: true,
|
||||
traceId: "trc_m3_gateway_identity_drift",
|
||||
requestId: "req_m3_gateway_identity_drift",
|
||||
actorId: "usr_m3_operator"
|
||||
},
|
||||
{
|
||||
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
|
||||
now: () => fixedNow,
|
||||
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"
|
||||
},
|
||||
requestJson: fixture.requestJson
|
||||
}
|
||||
);
|
||||
|
||||
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.deepEqual(fixture.calls.map((call) => call.path), ["/status"]);
|
||||
assert.equal(fixture.box1.ports.DO1.value, false);
|
||||
assert.equal(fixture.box2.ports.DI1.value, false);
|
||||
});
|
||||
|
||||
test("M3 IO control blocks when patch-panel wiring does not deliver DO1 to DI1", async () => {
|
||||
const fixture = createM3ControlFixture({
|
||||
patchPanelBody: {
|
||||
accepted: true,
|
||||
routes: [
|
||||
{
|
||||
accepted: true,
|
||||
deliveries: []
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const result = await handleM3IoControl(
|
||||
{
|
||||
action: "do.write",
|
||||
value: false,
|
||||
traceId: "trc_m3_wiring_missing",
|
||||
requestId: "req_m3_wiring_missing",
|
||||
actorId: "usr_m3_operator"
|
||||
},
|
||||
{
|
||||
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
|
||||
now: () => fixedNow,
|
||||
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"
|
||||
},
|
||||
requestJson: fixture.requestJson
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.status, "blocked");
|
||||
assert.equal(result.accepted, false);
|
||||
assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.wiringMissing);
|
||||
assert.match(result.blocker.zh, /wiring 不存在|未路由/u);
|
||||
});
|
||||
|
||||
function createM3ControlFixture({ patchPanelBody, gateway1Id = "gwsimu_1", gateway2Id = "gwsimu_2" } = {}) {
|
||||
const calls = [];
|
||||
const box1 = createBoxState({
|
||||
boxId: "boxsimu_1",
|
||||
gatewaySessionId: "gws_gwsimu_1",
|
||||
ports: ["DO1", "DI1"],
|
||||
now: fixedNow
|
||||
});
|
||||
box1.resource.resourceId = "res_boxsimu_1";
|
||||
box1.capabilities = box1.capabilities.map((capability) => ({
|
||||
...capability,
|
||||
resourceId: "res_boxsimu_1",
|
||||
projectId: M3_IO_CHAIN.projectId
|
||||
}));
|
||||
box1.projectId = M3_IO_CHAIN.projectId;
|
||||
box1.resource.projectId = M3_IO_CHAIN.projectId;
|
||||
|
||||
const box2 = createBoxState({
|
||||
boxId: "boxsimu_2",
|
||||
gatewaySessionId: "gws_gwsimu_2",
|
||||
ports: ["DO1", "DI1"],
|
||||
now: fixedNow
|
||||
});
|
||||
box2.resource.resourceId = "res_boxsimu_2";
|
||||
box2.capabilities = box2.capabilities.map((capability) => ({
|
||||
...capability,
|
||||
resourceId: "res_boxsimu_2",
|
||||
projectId: M3_IO_CHAIN.projectId
|
||||
}));
|
||||
box2.projectId = M3_IO_CHAIN.projectId;
|
||||
box2.resource.projectId = M3_IO_CHAIN.projectId;
|
||||
|
||||
const gateway1 = createGatewayStatus(gateway1Id, "http://gateway-1", [box1]);
|
||||
const gateway2 = createGatewayStatus(gateway2Id, "http://gateway-2", [box2]);
|
||||
|
||||
async function requestJson(url, { method = "GET", body } = {}) {
|
||||
const parsed = new URL(url);
|
||||
calls.push({
|
||||
url,
|
||||
method,
|
||||
path: parsed.pathname,
|
||||
body
|
||||
});
|
||||
|
||||
if (url.startsWith("http://gateway-1") && parsed.pathname === "/status") {
|
||||
return { ok: true, status: 200, body: gateway1 };
|
||||
}
|
||||
if (url.startsWith("http://gateway-2") && parsed.pathname === "/status") {
|
||||
return { ok: true, status: 200, body: gateway2 };
|
||||
}
|
||||
if (url.startsWith("http://gateway-1") && parsed.pathname === "/invoke") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: writeBoxPort({
|
||||
state: box1,
|
||||
port: body.port,
|
||||
capabilityId: body.capabilityId,
|
||||
value: body.value,
|
||||
source: "gateway-simu"
|
||||
})
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://gateway-2") && parsed.pathname === "/invoke") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
accepted: true,
|
||||
operationId: body.operationId,
|
||||
traceId: body.traceId,
|
||||
boxId: box2.boxId,
|
||||
resourceId: box2.resource.resourceId,
|
||||
capabilityId: body.capabilityId,
|
||||
port: body.port,
|
||||
value: box2.ports[body.port].value,
|
||||
state: box2.ports[body.port],
|
||||
auditId: "aud_box2_read",
|
||||
evidenceId: "evd_box2_read"
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://patch-panel") && parsed.pathname === "/sync/tick") {
|
||||
if (patchPanelBody) {
|
||||
return { ok: true, status: 200, body: patchPanelBody };
|
||||
}
|
||||
box2.ports.DI1 = {
|
||||
...box2.ports.DI1,
|
||||
value: body.signals[0].value,
|
||||
source: "patch-panel",
|
||||
sourceResourceId: "res_boxsimu_1",
|
||||
sourcePort: "DO1",
|
||||
propagatedBy: "hwlab-patch-panel",
|
||||
updatedAt: fixedNow
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
accepted: true,
|
||||
propagatedBy: "hwlab-patch-panel",
|
||||
routeCount: 1,
|
||||
deliveryCount: 1,
|
||||
routes: [
|
||||
{
|
||||
accepted: true,
|
||||
deliveryCount: 1,
|
||||
deliveries: [
|
||||
{
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1",
|
||||
value: body.signals[0].value,
|
||||
sourceResourceId: "res_boxsimu_1",
|
||||
sourcePort: "DO1",
|
||||
deliveryStatus: "applied"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`unexpected request ${method} ${url}`);
|
||||
}
|
||||
|
||||
return {
|
||||
calls,
|
||||
box1,
|
||||
box2,
|
||||
requestJson
|
||||
};
|
||||
}
|
||||
|
||||
function createGatewayStatus(gatewayId, endpoint, boxes) {
|
||||
const state = createGatewayState({
|
||||
gatewayId,
|
||||
endpoint,
|
||||
boxes: boxes.map((box) => box.resource.resourceId),
|
||||
now: fixedNow
|
||||
});
|
||||
state.projectId = M3_IO_CHAIN.projectId;
|
||||
state.session.projectId = M3_IO_CHAIN.projectId;
|
||||
state.registry = {
|
||||
gatewayId,
|
||||
gatewaySessionId: state.session.gatewaySessionId,
|
||||
endpoint,
|
||||
boxes: boxes.map((box) => ({
|
||||
boxId: box.boxId,
|
||||
resourceId: box.resource.resourceId,
|
||||
url: `http://${box.boxId}`,
|
||||
state: "registered",
|
||||
capabilities: box.capabilities
|
||||
}))
|
||||
};
|
||||
return state;
|
||||
}
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
import { describeCodeAgentAvailability, handleCodeAgentChat } from "./code-agent-chat.mjs";
|
||||
import { buildDbRuntimeReadiness } from "./db-contract.mjs";
|
||||
import { buildCloudApiReadiness } from "./health-contract.mjs";
|
||||
import {
|
||||
M3_IO_CONTROL_ROUTE,
|
||||
describeM3IoControl,
|
||||
handleM3IoControl
|
||||
} from "./m3-io-control.mjs";
|
||||
import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||||
|
||||
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
||||
@@ -172,11 +177,22 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
runtime,
|
||||
readiness,
|
||||
blockers: readiness.blockers,
|
||||
blockerCodes: readiness.blockerCodes
|
||||
blockerCodes: readiness.blockerCodes,
|
||||
m3IoControl: describeM3IoControl(options)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === M3_IO_CONTROL_ROUTE) {
|
||||
sendJson(response, 200, describeM3IoControl(options));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === M3_IO_CONTROL_ROUTE) {
|
||||
await handleM3IoControlHttp(request, response, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/v1/agent/chat") {
|
||||
await handleCodeAgentChatHttp(request, response, options);
|
||||
return;
|
||||
@@ -310,6 +326,78 @@ async function handleCodeAgentChatHttp(request, response, options) {
|
||||
sendJson(response, payload.status === "failed" && payload.error?.code === "invalid_params" ? 400 : 200, payload);
|
||||
}
|
||||
|
||||
async function handleM3IoControlHttp(request, response, options) {
|
||||
const body = await readBody(request, options.bodyLimitBytes);
|
||||
let params = {};
|
||||
|
||||
try {
|
||||
params = body ? JSON.parse(body) : {};
|
||||
} catch (error) {
|
||||
sendJson(response, 400, {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
contractVersion: "m3-io-control-v1",
|
||||
status: "blocked",
|
||||
accepted: false,
|
||||
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
||||
error: {
|
||||
code: "parse_error",
|
||||
message: "Invalid JSON body",
|
||||
reason: error.message
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
sendJson(response, 400, {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
contractVersion: "m3-io-control-v1",
|
||||
status: "blocked",
|
||||
accepted: false,
|
||||
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
||||
error: {
|
||||
code: "invalid_params",
|
||||
message: "M3 IO control body must be a JSON object"
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await handleM3IoControl(
|
||||
{
|
||||
...params,
|
||||
traceId: getHeader(request, "x-trace-id") || params.traceId,
|
||||
requestId: getHeader(request, "x-request-id") || params.requestId,
|
||||
actorId: getHeader(request, "x-actor-id") || params.actorId
|
||||
},
|
||||
{
|
||||
runtimeStore: options.runtimeStore,
|
||||
env: options.env,
|
||||
requestJson: options.m3IoRequestJson,
|
||||
traceId: getHeader(request, "x-trace-id"),
|
||||
requestId: getHeader(request, "x-request-id"),
|
||||
actorId: getHeader(request, "x-actor-id")
|
||||
}
|
||||
);
|
||||
sendJson(response, payload.httpStatus ?? 200, payload);
|
||||
} catch (error) {
|
||||
const protocolCode = Number.isInteger(error?.code) ? error.code : ERROR_CODES.internalError;
|
||||
sendJson(response, protocolCode === ERROR_CODES.invalidParams ? 400 : 200, {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
contractVersion: "m3-io-control-v1",
|
||||
status: "blocked",
|
||||
accepted: false,
|
||||
traceId: getHeader(request, "x-trace-id") || params.traceId || "trc_unassigned",
|
||||
error: {
|
||||
code: protocolCode,
|
||||
message: error.message,
|
||||
data: error.data ?? {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sendRestError(request, response, statusCode, code, message, context = {}) {
|
||||
const requestId = getHeader(request, "x-request-id") || "req_unassigned";
|
||||
sendJson(response, statusCode, {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createServer as createTcpServer } from "node:net";
|
||||
import test from "node:test";
|
||||
|
||||
import { createCloudApiServer } from "./server.mjs";
|
||||
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||||
import {
|
||||
RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED,
|
||||
@@ -969,3 +970,92 @@ 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"
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const index = await fetch(`http://127.0.0.1:${port}/v1`);
|
||||
assert.equal(index.status, 200);
|
||||
const indexPayload = await index.json();
|
||||
assert.equal(indexPayload.m3IoControl.route, "/v1/m3/io");
|
||||
assert.equal(indexPayload.m3IoControl.boundaries.directFrontendGatewayOrBoxAccess, false);
|
||||
|
||||
const contract = await fetch(`http://127.0.0.1:${port}/v1/m3/io`);
|
||||
assert.equal(contract.status, 200);
|
||||
const payload = await contract.json();
|
||||
assert.equal(payload.status, "available");
|
||||
assert.equal(payload.chain.sourceResourceId, "res_boxsimu_1");
|
||||
assert.equal(payload.chain.targetResourceId, "res_boxsimu_2");
|
||||
assert.equal(payload.boundaries.frontendCallsOnly, "/v1/m3/io");
|
||||
assert.equal(payload.boundaries.patchPanelOwnsPropagation, true);
|
||||
assert.equal(payload.boundaries.genericHardwareRpcExposedToFrontend, false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/m3/io returns structured blocked payload when gateway is unavailable", async () => {
|
||||
const runtimeStore = createCloudRuntimeStore({
|
||||
now: () => "2026-05-23T00:00:00.000Z"
|
||||
});
|
||||
const server = createCloudApiServer({
|
||||
runtimeStore,
|
||||
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"
|
||||
},
|
||||
m3IoRequestJson: async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
body: {
|
||||
error: {
|
||||
message: "gateway offline"
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/m3/io`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-trace-id": "trc_http_m3_gateway_missing",
|
||||
"x-actor-id": "usr_http_m3"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: "do.write",
|
||||
value: true
|
||||
})
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.status, "blocked");
|
||||
assert.equal(payload.accepted, false);
|
||||
assert.equal(payload.traceId, "trc_http_m3_gateway_missing");
|
||||
assert.equal(payload.operationId, null);
|
||||
assert.equal(payload.auditId, null);
|
||||
assert.equal(payload.evidenceId, null);
|
||||
assert.match(payload.blocker.zh, /gateway 未注册\/不可用/u);
|
||||
assert.equal(payload.controlPath.cloudApi, true);
|
||||
assert.equal(payload.controlPath.frontendBypass, false);
|
||||
assert.equal(payload.evidenceState.sourceKind, "BLOCKED");
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node --test scripts/artifact-runtime-readiness-guard.test.mjs",
|
||||
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
|
||||
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/server.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
|
||||
"dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs",
|
||||
"cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs",
|
||||
"m1:smoke": "node scripts/m1-contract-smoke.mjs",
|
||||
|
||||
@@ -459,10 +459,10 @@ async function serveCloudWeb() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(request.method === "GET" && (url.pathname === "/v1" || url.pathname.startsWith("/v1/"))) ||
|
||||
(request.method === "POST" && (url.pathname === "/json-rpc" || url.pathname === "/v1/agent/chat"))
|
||||
) {
|
||||
if (
|
||||
(request.method === "GET" && (url.pathname === "/v1" || url.pathname.startsWith("/v1/"))) ||
|
||||
(request.method === "POST" && (url.pathname === "/json-rpc" || url.pathname === "/v1/agent/chat" || url.pathname === "/v1/m3/io"))
|
||||
) {
|
||||
await proxyCloudApi(request, response, url);
|
||||
return;
|
||||
}
|
||||
|
||||
+258
-10
@@ -13,6 +13,7 @@ const rpcReadMethods = Object.freeze([
|
||||
const STATUS_LABELS = Object.freeze({
|
||||
active: "活动",
|
||||
available: "可用",
|
||||
blocked_after_cloud_api: "cloud-api 后阻塞",
|
||||
blocked: "阻塞 BLOCKED",
|
||||
completed: "完成",
|
||||
connected: "已连接",
|
||||
@@ -86,6 +87,14 @@ const el = {
|
||||
recordsList: byId("records-list"),
|
||||
diagnosticsList: byId("diagnostics-list"),
|
||||
methodList: byId("method-list"),
|
||||
m3ControlForm: byId("m3-control-form"),
|
||||
m3ControlStatus: byId("m3-control-status"),
|
||||
m3GatewaySelect: byId("m3-gateway-select"),
|
||||
m3BoxSelect: byId("m3-box-select"),
|
||||
m3PortSelect: byId("m3-port-select"),
|
||||
m3ValueSelect: byId("m3-value-select"),
|
||||
m3WriteDo: byId("m3-write-do"),
|
||||
m3ReadDi: byId("m3-read-di"),
|
||||
helpContent: byId("help-content"),
|
||||
helpStatus: byId("help-status")
|
||||
};
|
||||
@@ -95,7 +104,12 @@ const state = {
|
||||
codeAgentAvailability: null,
|
||||
chatMessages: [],
|
||||
chatPending: false,
|
||||
liveSurface: null
|
||||
liveSurface: null,
|
||||
m3Control: {
|
||||
contract: null,
|
||||
operation: null,
|
||||
pending: false
|
||||
}
|
||||
};
|
||||
|
||||
initRoutes();
|
||||
@@ -104,6 +118,7 @@ syncMobileExplorer();
|
||||
initSideTabs();
|
||||
initQuickActions();
|
||||
initCommandBar();
|
||||
initM3Control();
|
||||
renderStaticWorkbench();
|
||||
renderProbePending();
|
||||
loadHelpSurface();
|
||||
@@ -354,6 +369,16 @@ function initCommandBar() {
|
||||
});
|
||||
}
|
||||
|
||||
function initM3Control() {
|
||||
el.m3ControlForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await runM3IoAction("do.write");
|
||||
});
|
||||
el.m3ReadDi.addEventListener("click", async () => {
|
||||
await runM3IoAction("di.read");
|
||||
});
|
||||
}
|
||||
|
||||
function renderStaticWorkbench() {
|
||||
el.explorerStatus.textContent = "只读工作区";
|
||||
el.explorerStatus.className = "status-dot tone-source";
|
||||
@@ -367,6 +392,7 @@ function renderStaticWorkbench() {
|
||||
renderUnblockList();
|
||||
renderHardwareStatus(null);
|
||||
renderDrafts();
|
||||
renderM3ControlStatus();
|
||||
renderWiringList(null);
|
||||
renderRecords(null);
|
||||
renderDiagnostics(null);
|
||||
@@ -411,9 +437,10 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI
|
||||
|
||||
async function loadLiveSurface() {
|
||||
const projectId = gateSummary.topology.projectId;
|
||||
const [healthLive, restIndex, health, adapter, audit, evidence] = await Promise.all([
|
||||
const [healthLive, restIndex, m3Control, health, adapter, audit, evidence] = await Promise.all([
|
||||
fetchJson("/health/live"),
|
||||
fetchJson("/v1"),
|
||||
fetchJson("/v1/m3/io"),
|
||||
callRpc("system.health"),
|
||||
callRpc("cloud.adapter.describe"),
|
||||
callRpc("audit.event.query", { projectId, limit: 6 }),
|
||||
@@ -424,6 +451,7 @@ async function loadLiveSurface() {
|
||||
observedAt: new Date().toISOString(),
|
||||
healthLive,
|
||||
restIndex,
|
||||
m3Control,
|
||||
health,
|
||||
adapter,
|
||||
audit,
|
||||
@@ -522,6 +550,76 @@ async function callRpc(method, params = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function runM3IoAction(action) {
|
||||
if (state.m3Control.pending) return;
|
||||
const traceId = nextProtocolId("trc");
|
||||
state.m3Control.pending = true;
|
||||
state.m3Control.operation = {
|
||||
status: "running",
|
||||
action,
|
||||
traceId,
|
||||
observedAt: new Date().toISOString()
|
||||
};
|
||||
renderM3ControlStatus();
|
||||
renderDrafts();
|
||||
renderRecords(state.liveSurface);
|
||||
|
||||
const body = action === "do.write"
|
||||
? {
|
||||
action,
|
||||
gatewayId: el.m3GatewaySelect.value,
|
||||
resourceId: el.m3BoxSelect.value,
|
||||
boxId: "boxsimu_1",
|
||||
port: el.m3PortSelect.value,
|
||||
value: el.m3ValueSelect.value === "true",
|
||||
projectId: gateSummary.topology.projectId,
|
||||
traceId
|
||||
}
|
||||
: {
|
||||
action,
|
||||
gatewayId: "gwsimu_2",
|
||||
resourceId: M3_TRUSTED_ROUTE.toResourceId,
|
||||
boxId: "boxsimu_2",
|
||||
port: M3_TRUSTED_ROUTE.toPort,
|
||||
projectId: gateSummary.topology.projectId,
|
||||
traceId
|
||||
};
|
||||
|
||||
const response = await fetchJson("/v1/m3/io", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Trace-Id": traceId,
|
||||
"X-Actor-Id": "usr_hwlab_cloud_web"
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
state.m3Control.operation = {
|
||||
status: "blocked",
|
||||
action,
|
||||
traceId,
|
||||
blocker: {
|
||||
zh: response.error || "M3 IO 控制请求失败"
|
||||
},
|
||||
observedAt: new Date().toISOString(),
|
||||
sourceKind: "BLOCKED"
|
||||
};
|
||||
} else {
|
||||
state.m3Control.operation = {
|
||||
...response.data,
|
||||
sourceKind: response.data?.status === "succeeded" ? "DEV-LIVE" : "BLOCKED"
|
||||
};
|
||||
}
|
||||
state.m3Control.pending = false;
|
||||
renderM3ControlStatus();
|
||||
renderHardwareStatus(runtimeSummaryFrom(state.liveSurface));
|
||||
renderWiringList(runtimeSummaryFrom(state.liveSurface));
|
||||
renderRecords(state.liveSurface);
|
||||
renderDrafts();
|
||||
}
|
||||
|
||||
function nextProtocolId(prefix) {
|
||||
rpcSequence += 1;
|
||||
return `${prefix}_cloud-web-workbench-${Date.now()}-${rpcSequence}`;
|
||||
@@ -529,6 +627,7 @@ function nextProtocolId(prefix) {
|
||||
|
||||
function renderLiveSurface(live) {
|
||||
state.liveSurface = live;
|
||||
state.m3Control.contract = live.m3Control?.ok ? live.m3Control.data : null;
|
||||
const coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter];
|
||||
const surfaceStatus = workbenchApiSurfaceStatus(live, coreProbes);
|
||||
const runtimeSummary = runtimeSummaryFrom(live);
|
||||
@@ -545,6 +644,7 @@ function renderLiveSurface(live) {
|
||||
renderRecords(live);
|
||||
renderDiagnostics(live);
|
||||
renderMethodList(methods.length > 0 ? methods.filter((method) => rpcReadMethods.includes(method)) : rpcReadMethods, surfaceStatus.methodTone);
|
||||
renderM3ControlStatus();
|
||||
renderConversation();
|
||||
renderDrafts();
|
||||
}
|
||||
@@ -798,7 +898,7 @@ function renderHardwareStatus(summary) {
|
||||
const counts = summary?.counts ?? null;
|
||||
const patchPanel = gateSummary.topology.patchPanel;
|
||||
const sourceLink = trustedM3Link(patchPanel.activeConnections);
|
||||
const liveM3Evidence = trustedM3LiveEvidenceFrom(summary);
|
||||
const liveM3Evidence = trustedM3LiveEvidenceFrom(summary) ?? m3ControlLiveEvidence();
|
||||
const m3Live = hasTrustedM3LiveEvidence(liveM3Evidence);
|
||||
const runtimeCountsSourceKind = "SOURCE";
|
||||
const runtimeCountsTone = "source";
|
||||
@@ -885,10 +985,12 @@ function renderHardwareStatus(summary) {
|
||||
rows: [
|
||||
{
|
||||
label: "硬件控制",
|
||||
value: "已禁用",
|
||||
detail: "Web 不提供硬件写 RPC;不会新增伪硬件写操作。",
|
||||
sourceKind: "SOURCE",
|
||||
tone: "source"
|
||||
value: state.m3Control.contract?.status === "available" ? "cloud-api 受控" : "阻塞",
|
||||
detail: state.m3Control.contract?.status === "available"
|
||||
? "M3 控制只调用 same-origin /v1/m3/io,由 cloud-api 转发 gateway-simu/box-simu 并触发 patch-panel;Web 不直连 gateway/box-simu。Web 不提供硬件写 RPC;不会新增伪硬件写操作。"
|
||||
: "M3 控制路径未就绪;Web 不提供直连 gateway/box-simu 或伪硬件状态写入。",
|
||||
sourceKind: state.m3Control.contract?.status === "available" ? "SOURCE" : "BLOCKED",
|
||||
tone: state.m3Control.contract?.status === "available" ? "source" : "blocked"
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -973,6 +1075,35 @@ function trustedM3LiveEvidenceFrom(summary) {
|
||||
return candidates.find(hasTrustedM3LiveEvidence) ?? null;
|
||||
}
|
||||
|
||||
function m3ControlLiveEvidence() {
|
||||
const operation = state.m3Control.operation;
|
||||
if (!operation || operation.status !== "succeeded") return null;
|
||||
return {
|
||||
status: operation.evidenceState?.status === "green" ? "verified" : "blocked",
|
||||
sourceKind: operation.evidenceState?.status === "green" ? "DEV-LIVE" : "BLOCKED",
|
||||
live: operation.evidenceState?.status === "green",
|
||||
operationId: operation.operationId,
|
||||
traceId: operation.traceId,
|
||||
auditId: operation.auditId,
|
||||
evidenceId: operation.evidenceId,
|
||||
patchPanelLiveReport: {
|
||||
serviceId: M3_TRUSTED_ROUTE.patchPanelServiceId,
|
||||
sourceKind: operation.evidenceState?.status === "green" ? "DEV-LIVE" : "BLOCKED",
|
||||
live: operation.evidenceState?.status === "green",
|
||||
reportId: operation.evidenceId,
|
||||
activeConnections: [
|
||||
{
|
||||
fromResourceId: M3_TRUSTED_ROUTE.fromResourceId,
|
||||
fromPort: M3_TRUSTED_ROUTE.fromPort,
|
||||
toResourceId: M3_TRUSTED_ROUTE.toResourceId,
|
||||
toPort: M3_TRUSTED_ROUTE.toPort,
|
||||
patchPanelServiceId: M3_TRUSTED_ROUTE.patchPanelServiceId
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function liveRecordTone(record, live) {
|
||||
const runtimeSummary = runtimeSummaryFrom(live);
|
||||
return isDurableRuntimeReady(runtimeSummary) && hasTrustedM3LiveEvidence(record) ? "dev-live" : "blocked";
|
||||
@@ -1034,11 +1165,69 @@ function m3LiveDetail(evidence) {
|
||||
return `operation=${evidence.operationId} / trace=${evidence.traceId} / audit=${evidence.auditId} / evidence=${evidence.evidenceId}`;
|
||||
}
|
||||
|
||||
function renderM3ControlStatus() {
|
||||
const contract = state.m3Control.contract;
|
||||
const operation = state.m3Control.operation;
|
||||
const contractAvailable = contract?.status === "available";
|
||||
const pending = state.m3Control.pending;
|
||||
const operationSucceeded = operation?.status === "succeeded";
|
||||
const operationBlocked = operation?.status === "blocked";
|
||||
const label = pending
|
||||
? "执行中"
|
||||
: operationSucceeded
|
||||
? operation.evidenceState?.status === "green"
|
||||
? "控制可达 / 证据 green"
|
||||
: "控制可达 / 证据 blocked"
|
||||
: operationBlocked
|
||||
? "操作 BLOCKED"
|
||||
: contractAvailable
|
||||
? "cloud-api 受控"
|
||||
: "BLOCKED";
|
||||
const tone = pending
|
||||
? "dry-run"
|
||||
: operationSucceeded
|
||||
? operation.evidenceState?.status === "green" ? "dev-live" : "degraded"
|
||||
: operationBlocked || !contractAvailable
|
||||
? "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;
|
||||
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 controlRows() {
|
||||
const operation = state.m3Control.operation;
|
||||
const operationCards = operation
|
||||
? [
|
||||
{
|
||||
title: `M3 ${operationActionLabel(operation.action)} ${statusLabel(operation.status)}`,
|
||||
detail: m3OperationDetail(operation),
|
||||
tone: operation.status === "succeeded"
|
||||
? operation.evidenceState?.status === "green" ? "dev-live" : "degraded"
|
||||
: operation.status === "running"
|
||||
? "dry-run"
|
||||
: "blocked"
|
||||
}
|
||||
]
|
||||
: [];
|
||||
return [
|
||||
{
|
||||
title: "硬件操作",
|
||||
detail: "此 Web 工作台不开放硬件写入;需要硬件动作时交由受控后端流程处理。",
|
||||
title: "cloud-api 控制路径",
|
||||
detail: state.m3Control.contract?.status === "available"
|
||||
? "按钮只调用 /v1/m3/io;cloud-api 再通过 gateway-simu -> box-simu -> hwlab-patch-panel 执行 M3 DO/DI。"
|
||||
: state.m3Control.contract?.blockedReason ?? "等待 /v1/m3/io 合同探测;不可用时不会直连 gateway/box-simu。",
|
||||
tone: state.m3Control.contract?.status === "available" ? "source" : "blocked"
|
||||
},
|
||||
...operationCards,
|
||||
{
|
||||
title: "读取目标",
|
||||
detail: `${M3_TRUSTED_ROUTE.toResourceId}:${M3_TRUSTED_ROUTE.toPort} 只能通过 /v1/m3/io 的 di.read 读取;不能从浏览器直连 box-simu。`,
|
||||
tone: "source"
|
||||
},
|
||||
{
|
||||
@@ -1054,6 +1243,30 @@ function controlRows() {
|
||||
];
|
||||
}
|
||||
|
||||
function operationActionLabel(action) {
|
||||
return action === "do.write" ? "DO 写入" : action === "di.read" ? "DI 读取" : "操作";
|
||||
}
|
||||
|
||||
function m3OperationDetail(operation) {
|
||||
if (operation.status === "running") {
|
||||
return `trace=${operation.traceId} / 正在通过 cloud-api 提交。`;
|
||||
}
|
||||
const ids = [
|
||||
recordField("operation", operation.operationId),
|
||||
recordField("trace", operation.traceId),
|
||||
recordField("audit", operation.auditId),
|
||||
recordField("evidence", operation.evidenceId)
|
||||
].filter(Boolean).join(" / ");
|
||||
const value = operation.result?.value !== undefined && operation.result?.value !== null
|
||||
? ` / value=${String(operation.result.value)}`
|
||||
: "";
|
||||
const blocker = operation.blocker?.zh || operation.blocker?.message
|
||||
? ` / 阻塞原因=${operation.blocker.zh || operation.blocker.message}`
|
||||
: "";
|
||||
const evidence = operation.evidenceState?.reason ? ` / evidence=${operation.evidenceState.reason}` : "";
|
||||
return `${ids}${value}${blocker}${evidence}`;
|
||||
}
|
||||
|
||||
function renderDrafts() {
|
||||
const rows = [
|
||||
...state.chatMessages.slice(-3).reverse().map((message) => ({
|
||||
@@ -1153,6 +1366,27 @@ function codeAgentRecordCards() {
|
||||
|
||||
function operationRecordCards() {
|
||||
const wiring = gateSummary.topology.patchPanel;
|
||||
const controlOperation = state.m3Control.operation;
|
||||
const controlCards = controlOperation
|
||||
? [
|
||||
infoCard({
|
||||
title: controlOperation.operationId ?? controlOperation.traceId ?? "m3-control-operation",
|
||||
detail: [
|
||||
sourceKindLabel(controlOperation.evidenceState?.status === "green" ? "dev-live" : "blocked_after_cloud_api"),
|
||||
"via=/v1/m3/io",
|
||||
recordField("action", controlOperation.action),
|
||||
recordField("trace", controlOperation.traceId),
|
||||
recordField("audit", controlOperation.auditId),
|
||||
recordField("evidence", controlOperation.evidenceId),
|
||||
controlOperation.controlPath?.frontendBypass === false ? "frontendBypass=false" : null,
|
||||
controlOperation.evidenceState?.reason
|
||||
].filter(Boolean).join(" / "),
|
||||
tone: controlOperation.status === "succeeded"
|
||||
? controlOperation.evidenceState?.status === "green" ? "dev-live" : "degraded"
|
||||
: "blocked"
|
||||
})
|
||||
]
|
||||
: [];
|
||||
const wiringCards = wiring.activeConnections.map((link) =>
|
||||
infoCard({
|
||||
title: `${link.fromResourceId}:${link.fromPort} -> ${link.toResourceId}:${link.toPort}`,
|
||||
@@ -1178,11 +1412,18 @@ function operationRecordCards() {
|
||||
tone: operation.dryRun ? "dry-run" : "source"
|
||||
})
|
||||
);
|
||||
return [...wiringCards, ...operationCards];
|
||||
return [...controlCards, ...wiringCards, ...operationCards];
|
||||
}
|
||||
|
||||
function auditRecordCards(live) {
|
||||
const cards = [];
|
||||
if (state.m3Control.operation?.auditEvent) {
|
||||
cards.push(auditCard(
|
||||
state.m3Control.operation.auditEvent,
|
||||
state.m3Control.operation.evidenceState?.status === "green" ? "dev-live" : "blocked",
|
||||
state.m3Control.operation.evidenceState?.status === "green" ? null : state.m3Control.operation.evidenceState?.reason
|
||||
));
|
||||
}
|
||||
if (live) {
|
||||
if (live.audit.ok) {
|
||||
const liveEvents = live.audit.data?.events ?? [];
|
||||
@@ -1212,6 +1453,13 @@ function auditRecordCards(live) {
|
||||
|
||||
function evidenceRecordCards(live) {
|
||||
const cards = [];
|
||||
if (state.m3Control.operation?.evidenceRecord) {
|
||||
cards.push(evidenceCard(
|
||||
state.m3Control.operation.evidenceRecord,
|
||||
state.m3Control.operation.evidenceState?.status === "green" ? "dev-live" : "blocked",
|
||||
state.m3Control.operation.evidenceState?.status === "green" ? null : state.m3Control.operation.evidenceState?.reason
|
||||
));
|
||||
}
|
||||
if (live) {
|
||||
if (live.evidence.ok) {
|
||||
const liveRecords = live.evidence.data?.records ?? [];
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
|
||||
- Agent 对话区显示用户工作台范围、建议起步方式和可查看内容,不展示 Gate、诊断、验收、BLOCKED 或 M0-M5 执行轨迹。
|
||||
- 工作清单区提供描述目标、核对接线、留存记录三类用户动作。
|
||||
- 底部输入栏只把文字保存为浏览器本地草稿,不调用硬件写 API,不提交 patch-panel 变更,也不写入审计或证据记录。
|
||||
- 底部输入栏用于 Code Agent 对话;右侧 M3 基本 IO 控制只调用 same-origin `/v1/m3/io`,由 `hwlab-cloud-api` 通过 gateway-simu、box-simu、`hwlab-patch-panel` 执行,不从浏览器直连 gateway/box-simu,也不调用泛化硬件写 RPC。
|
||||
|
||||
## 右侧硬件与状态面板
|
||||
|
||||
- BOX-SIMU / Gateway-SIMU / Patch Panel 卡片展示只读计数、source fallback 与硬件写入边界。
|
||||
- 接线:`hwlab-patch-panel` 面板使用表格展示源设备、源端口、接线盘、目标设备、目标端口、状态、证据来源和 trace/evidence。
|
||||
- M3 虚拟硬件可信闭环的上位约束是:`res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1`。只有观察到这条完整链路并带有可信记录时,才可作为 M3 DEV-LIVE 依据。
|
||||
- Web 前端不会新增 `hardware.*` 写 RPC、patch-panel 写接口、audit 写接口或 evidence 写接口。
|
||||
- Web 前端不会新增 `hardware.*` 写 RPC、patch-panel 写接口、audit 写接口或 evidence 写接口;M3 控制的 operation/audit/evidence 字段由 `/v1/m3/io` 响应返回,runtime durable 未 green 时必须显示 BLOCKED。
|
||||
|
||||
## 右侧工作面板
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
- Gate / 诊断 / 验收:从活动栏 `内部复核` 进入,作为验收复核二级页面保留。
|
||||
- 解阻路径:先完成 DB live readiness,让 same-origin `/health/live` 能证明 db.ready=true、db.connected=true;再通过 `hwlab-patch-panel` 建立 `res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1`,并附带可信 trace/evidence。
|
||||
- 诊断探测只检查 same-origin `/health/live`、same-origin `/v1` 与只读 `/json-rpc` 方法。允许的方法包括 `system.health`、`cloud.adapter.describe`、`audit.event.query`、`evidence.record.query`。
|
||||
- 诊断探测检查 same-origin `/health/live`、same-origin `/v1`、`/v1/m3/io` 合同与只读 `/json-rpc` 方法。允许的 JSON-RPC 方法包括 `system.health`、`cloud.adapter.describe`、`audit.event.query`、`evidence.record.query`。
|
||||
|
||||
## 来源标签含义
|
||||
|
||||
|
||||
@@ -255,9 +255,42 @@
|
||||
|
||||
<section class="side-panel active" id="panel-control" role="tabpanel" aria-labelledby="tab-control" data-side-panel="control">
|
||||
<div class="panel-title-row">
|
||||
<h2>控制草稿</h2>
|
||||
<span class="state-tag tone-dry-run">演练 DRY-RUN 仅本地</span>
|
||||
<h2>M3 基本 IO 控制</h2>
|
||||
<span class="state-tag tone-blocked" id="m3-control-status">探测中</span>
|
||||
</div>
|
||||
<form class="m3-control-form" id="m3-control-form" aria-label="M3 DO1 写入">
|
||||
<div class="m3-control-grid">
|
||||
<label>
|
||||
<span>Gateway</span>
|
||||
<select id="m3-gateway-select">
|
||||
<option value="gwsimu_1">gateway-simu-1</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>BOX</span>
|
||||
<select id="m3-box-select">
|
||||
<option value="res_boxsimu_1">box-simu-1 / res_boxsimu_1</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>端口</span>
|
||||
<select id="m3-port-select">
|
||||
<option value="DO1">DO1</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>值</span>
|
||||
<select id="m3-value-select">
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="m3-control-actions">
|
||||
<button class="command-button" id="m3-write-do" type="submit">写入 DO1</button>
|
||||
<button class="command-button secondary" id="m3-read-di" type="button">读取 DI1</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="control-list" class="compact-list"></div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -319,6 +319,17 @@ assert.doesNotMatch(html, />添加草稿<\/button>/);
|
||||
assert.doesNotMatch(html, /主流程[\s\S]*添加草稿/u);
|
||||
assert.match(app, /sendAgentMessage/);
|
||||
assert.match(app, /fetchJson\("\/v1\/agent\/chat"/);
|
||||
assert.match(app, /fetchJson\("\/v1\/m3\/io"/);
|
||||
assert.match(html, /id="m3-control-form"/);
|
||||
assert.match(html, /id="m3-control-status"/);
|
||||
assert.match(html, />写入 DO1<\/button>/);
|
||||
assert.match(html, />读取 DI1<\/button>/);
|
||||
assert.match(app, /function runM3IoAction/);
|
||||
assert.match(app, /function renderM3ControlStatus/);
|
||||
assert.match(app, /frontendBypass=false/);
|
||||
assert.match(app, /按钮只调用 \/v1\/m3\/io/);
|
||||
assert.match(app, /Web 不直连 gateway\/box-simu/);
|
||||
assert.match(styles, /\.m3-control-form\s*{/);
|
||||
assert.match(app, /Code Agent 调用失败/);
|
||||
assert.match(app, /normalizeBlockedAgentResult/);
|
||||
assert.match(app, /isBlockedAgentResponse/);
|
||||
@@ -406,6 +417,7 @@ assert.match(artifactPublisher, /requestUpstream/);
|
||||
assert.match(artifactPublisher, /from "node:http"/);
|
||||
assert.doesNotMatch(artifactPublisher, /await fetch\(target/);
|
||||
assert.match(artifactPublisher, /url\.pathname === "\/v1\/agent\/chat"/);
|
||||
assert.match(artifactPublisher, /url\.pathname === "\/v1\/m3\/io"/);
|
||||
assert.match(artifactPublisher, /"system\.health"/);
|
||||
assert.match(artifactPublisher, /"cloud\.adapter\.describe"/);
|
||||
assert.match(artifactPublisher, /"audit\.event\.query"/);
|
||||
@@ -423,6 +435,7 @@ assert.doesNotMatch(app, /hardware\.operation\.request/);
|
||||
assert.doesNotMatch(app, /hardware\.invoke\.shell/);
|
||||
assert.doesNotMatch(app, /audit\.event\.write/);
|
||||
assert.doesNotMatch(app, /evidence\.record\.write/);
|
||||
assert.doesNotMatch(app, /https?:\/\/[^"']*(?:gateway-simu|box-simu|patch-panel)[^"']*/iu);
|
||||
assert.doesNotMatch(frontendSource, /--live --confirm-dev --confirmed-non-production/);
|
||||
assert.doesNotMatch(frontendSource, /74\.48\.78\.17:666[67]\b|:666[67]\b/);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const READ_ONLY_RPC_METHODS = Object.freeze([
|
||||
"audit.event.query",
|
||||
"evidence.record.query"
|
||||
]);
|
||||
const CONTROL_ROUTE = "/v1/m3/io";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = path.resolve(rootDir, "../..");
|
||||
@@ -56,8 +57,10 @@ export function runCloudWebM3ReadonlyContract() {
|
||||
"Cloud Web must call only read-only JSON-RPC diagnostics methods"
|
||||
);
|
||||
assert.match(app, /fetchJson\("\/v1"\)/u, "Cloud Web must read the public REST index");
|
||||
assert.match(app, /fetchJson\("\/v1\/m3\/io"/u, "Cloud Web M3 control must use only the same-origin cloud-api M3 IO route");
|
||||
assert.match(app, /fetchJson\("\/json-rpc"/u, "Cloud Web must use same-origin JSON-RPC for diagnostics");
|
||||
assert.match(app, /runtime\.serviceRoute\.join\(" -> "\)/u, "Cloud Web must render the observed route contract");
|
||||
assert.ok(app.includes("frontendBypass=false"), "M3 control evidence must explicitly record no frontend bypass");
|
||||
|
||||
for (const [pattern, label] of [
|
||||
[/DEV Control Console/iu, "generic control console label"],
|
||||
@@ -69,6 +72,7 @@ export function runCloudWebM3ReadonlyContract() {
|
||||
[/audit\.event\.write/iu, "audit write RPC"],
|
||||
[/evidence\.record\.write/iu, "evidence write RPC"],
|
||||
[/\/v1\/rpc\//iu, "direct REST RPC bridge"],
|
||||
[/https?:\/\/[^"']*(?:gateway-simu|box-simu|patch-panel)[^"']*/iu, "direct simulator endpoint"],
|
||||
[/\/wiring\/apply/iu, "patch-panel apply endpoint"],
|
||||
[/\/wiring\/reload/iu, "patch-panel reload endpoint"],
|
||||
[/method-pill[\s\S]{0,80}write/iu, "write method pill"]
|
||||
@@ -76,6 +80,11 @@ export function runCloudWebM3ReadonlyContract() {
|
||||
assert.doesNotMatch(frontendSource, pattern, `Cloud Web frontend must not expose ${label}`);
|
||||
}
|
||||
|
||||
const mutationFetches = [...app.matchAll(/fetchJson\(\s*["']([^"']+)["'][\s\S]{0,220}?method:\s*["']POST["']/gu)]
|
||||
.map((match) => match[1])
|
||||
.filter((path) => path !== "/v1/agent/chat" && path !== CONTROL_ROUTE && path !== "/json-rpc");
|
||||
assert.deepEqual(mutationFetches, [], "Cloud Web must not add POST mutation fetches outside Code Agent chat and M3 cloud-api control route");
|
||||
|
||||
const m3Milestone = gateSummary.milestones.find((item) => item.id === "M3");
|
||||
assert.equal(m3Milestone?.status, "blocked", "Cloud Web must not present edge-only M3 as DEV-LIVE");
|
||||
assert.match(m3Milestone.summary, /blocked by hardware loop topology/u, "M3 summary must explain the live blocker");
|
||||
|
||||
@@ -915,6 +915,54 @@ h3 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m3-control-form {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
padding: 9px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.m3-control-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.m3-control-grid label {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 760;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.m3-control-grid select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 30px;
|
||||
padding: 5px 7px;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: 11px/1.2 var(--mono);
|
||||
}
|
||||
|
||||
.m3-control-grid select:disabled,
|
||||
.m3-control-actions button:disabled {
|
||||
opacity: 0.62;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.m3-control-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
@@ -1283,6 +1331,8 @@ tbody tr:last-child td {
|
||||
.hardware-list,
|
||||
.milestone-grid,
|
||||
.gate-grid,
|
||||
.m3-control-grid,
|
||||
.m3-control-actions,
|
||||
.quick-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user