feat: add M3 IO control surface path

This commit is contained in:
Code Queue Review
2026-05-23 04:30:54 +00:00
parent 36d8eead4e
commit 7f030e0c4e
14 changed files with 2291 additions and 21 deletions
+4
View File
@@ -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",
+16
View File
@@ -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
+442
View File
@@ -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;
}
+89 -1
View File
@@ -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, {
+90
View File
@@ -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
View File
@@ -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",
+4 -4
View File
@@ -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
View File
@@ -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-panelWeb 不直连 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/iocloud-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 ?? [];
+3 -3
View File
@@ -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`
## 来源标签含义
+35 -2
View File
@@ -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>
+13
View File
@@ -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");
+50
View File
@@ -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;
}