Merge pull request #306 from pikasTech/feat/m3-status-workspace-287
feat: add M3 status workspace
This commit is contained in:
@@ -13,6 +13,8 @@ import {
|
||||
|
||||
export const M3_IO_CONTROL_CONTRACT_VERSION = "m3-io-control-v1";
|
||||
export const M3_IO_CONTROL_ROUTE = "/v1/m3/io";
|
||||
export const M3_STATUS_CONTRACT_VERSION = "m3-status-v1";
|
||||
export const M3_STATUS_ROUTE = "/v1/m3/status";
|
||||
export const M3_IO_RPC_METHODS = Object.freeze({
|
||||
doWrite: "m3.io.do.write",
|
||||
diRead: "m3.io.di.read"
|
||||
@@ -121,6 +123,225 @@ export async function describeM3IoControlLive(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function describeM3StatusLive(options = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
const config = buildM3IoConfig(env);
|
||||
const requestJson = options.m3IoRequestJson ?? options.requestJson;
|
||||
const observedAt = options.now?.() ?? new Date().toISOString();
|
||||
const runtime = await safeRuntimeSummary(options.runtimeStore);
|
||||
const trust = await buildM3StatusTrust({
|
||||
runtimeStore: options.runtimeStore,
|
||||
runtime
|
||||
});
|
||||
|
||||
const base = {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
contractVersion: M3_STATUS_CONTRACT_VERSION,
|
||||
route: M3_STATUS_ROUTE,
|
||||
observedAt,
|
||||
chain: M3_IO_CHAIN,
|
||||
boundaries: {
|
||||
frontendCallsOnly: M3_STATUS_ROUTE,
|
||||
cloudApiAggregatesRuntimeStatus: true,
|
||||
patchPanelOwnsPropagation: true,
|
||||
directFrontendGatewayOrBoxAccess: false,
|
||||
sourceTopologyFallbackMustStayUnverified: true
|
||||
}
|
||||
};
|
||||
|
||||
if (!config.enabled) {
|
||||
return {
|
||||
...base,
|
||||
status: "blocked",
|
||||
sourceKind: "BLOCKED",
|
||||
summary: "M3 状态聚合未启用;前端不能绕过 cloud-api 直连 gateway/box-simu。",
|
||||
gateways: defaultM3Gateways({ sourceKind: "UNVERIFIED", observedAt }),
|
||||
boxes: defaultM3Boxes({ sourceKind: "UNVERIFIED", observedAt }),
|
||||
patchPanel: defaultM3PatchPanel({ observedAt }),
|
||||
trust: {
|
||||
...trust,
|
||||
blocker: "m3_control_disabled"
|
||||
},
|
||||
blocker: {
|
||||
code: "m3_control_disabled",
|
||||
layer: "cloud-api",
|
||||
zh: "M3 状态聚合未启用;前端不能绕过 cloud-api 直连 gateway/box-simu。"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const [sourceGateway, targetGateway, patchStatus, patchWiring] = await Promise.all([
|
||||
readGatewaySession(config.gateway1Url, {
|
||||
gatewayRole: "source",
|
||||
expectedGatewayId: M3_IO_CHAIN.sourceGatewayId,
|
||||
expectedGatewaySessionId: M3_IO_CHAIN.sourceGatewaySessionId,
|
||||
requestJson,
|
||||
timeoutMs: config.timeoutMs
|
||||
}),
|
||||
readGatewaySession(config.gateway2Url, {
|
||||
gatewayRole: "target",
|
||||
expectedGatewayId: M3_IO_CHAIN.targetGatewayId,
|
||||
expectedGatewaySessionId: M3_IO_CHAIN.targetGatewaySessionId,
|
||||
requestJson,
|
||||
timeoutMs: config.timeoutMs
|
||||
}),
|
||||
requestRuntimeJson(new URL("/status", ensureTrailingSlash(config.patchPanelUrl)).toString(), {
|
||||
method: "GET",
|
||||
requestJson,
|
||||
timeoutMs: config.timeoutMs
|
||||
}),
|
||||
requestRuntimeJson(new URL("/wiring", ensureTrailingSlash(config.patchPanelUrl)).toString(), {
|
||||
method: "GET",
|
||||
requestJson,
|
||||
timeoutMs: config.timeoutMs
|
||||
})
|
||||
]);
|
||||
|
||||
const sourceCommand = normalizeCommand("do.write", { value: false });
|
||||
const targetCommand = normalizeCommand("di.read", {});
|
||||
const sourceBox = sourceGateway.available ? findGatewayBox(sourceGateway.status, sourceCommand) : null;
|
||||
const targetBox = targetGateway.available ? findGatewayBox(targetGateway.status, targetCommand) : null;
|
||||
const [sourceDoRead, targetDiRead] = await Promise.all([
|
||||
sourceGateway.available && sourceBox
|
||||
? readM3StatusPort({
|
||||
gateway: sourceGateway,
|
||||
command: sourceCommand,
|
||||
requestJson,
|
||||
timeoutMs: config.timeoutMs,
|
||||
observedAt,
|
||||
port: M3_IO_CHAIN.sourcePort,
|
||||
direction: "output"
|
||||
})
|
||||
: Promise.resolve(unverifiedPortRead({
|
||||
port: M3_IO_CHAIN.sourcePort,
|
||||
direction: "output",
|
||||
source: "gateway-simu",
|
||||
reason: sourceGateway.reason ?? `${M3_IO_CHAIN.sourceResourceId} 未在 source gateway registry 中可见`,
|
||||
observedAt
|
||||
})),
|
||||
targetGateway.available && targetBox
|
||||
? readM3StatusPort({
|
||||
gateway: targetGateway,
|
||||
command: targetCommand,
|
||||
requestJson,
|
||||
timeoutMs: config.timeoutMs,
|
||||
observedAt,
|
||||
port: M3_IO_CHAIN.targetPort,
|
||||
direction: "input"
|
||||
})
|
||||
: Promise.resolve(unverifiedPortRead({
|
||||
port: M3_IO_CHAIN.targetPort,
|
||||
direction: "input",
|
||||
source: "gateway-simu",
|
||||
reason: targetGateway.reason ?? `${M3_IO_CHAIN.targetResourceId} 未在 target gateway registry 中可见`,
|
||||
observedAt
|
||||
}))
|
||||
]);
|
||||
|
||||
const gateways = [
|
||||
gatewayStatusEntry(sourceGateway, {
|
||||
id: M3_IO_CHAIN.sourceGatewayId,
|
||||
sessionId: M3_IO_CHAIN.sourceGatewaySessionId,
|
||||
role: "source",
|
||||
observedAt
|
||||
}),
|
||||
gatewayStatusEntry(targetGateway, {
|
||||
id: M3_IO_CHAIN.targetGatewayId,
|
||||
sessionId: M3_IO_CHAIN.targetGatewaySessionId,
|
||||
role: "target",
|
||||
observedAt
|
||||
})
|
||||
];
|
||||
const boxes = [
|
||||
boxStatusEntry({
|
||||
gateway: sourceGateway,
|
||||
box: sourceBox,
|
||||
boxId: M3_IO_CHAIN.sourceBoxId,
|
||||
resourceId: M3_IO_CHAIN.sourceResourceId,
|
||||
gatewayId: M3_IO_CHAIN.sourceGatewayId,
|
||||
gatewaySessionId: M3_IO_CHAIN.sourceGatewaySessionId,
|
||||
observedAt,
|
||||
ports: {
|
||||
[M3_IO_CHAIN.sourcePort]: sourceDoRead.port
|
||||
}
|
||||
}),
|
||||
boxStatusEntry({
|
||||
gateway: targetGateway,
|
||||
box: targetBox,
|
||||
boxId: M3_IO_CHAIN.targetBoxId,
|
||||
resourceId: M3_IO_CHAIN.targetResourceId,
|
||||
gatewayId: M3_IO_CHAIN.targetGatewayId,
|
||||
gatewaySessionId: M3_IO_CHAIN.targetGatewaySessionId,
|
||||
observedAt,
|
||||
ports: {
|
||||
[M3_IO_CHAIN.targetPort]: targetDiRead.port
|
||||
}
|
||||
})
|
||||
];
|
||||
const patchPanel = patchPanelStatusEntry({
|
||||
statusResponse: patchStatus,
|
||||
wiringResponse: patchWiring,
|
||||
observedAt
|
||||
});
|
||||
const blockers = [
|
||||
...gateways.filter((gateway) => !gateway.online).map((gateway) => ({
|
||||
code: gateway.blocker,
|
||||
layer: gateway.id,
|
||||
zh: gateway.lastError
|
||||
})),
|
||||
...boxes.filter((box) => !box.online).map((box) => ({
|
||||
code: M3_IO_BLOCKER_CODES.boxUnavailable,
|
||||
layer: box.resourceId,
|
||||
zh: `${box.resourceId} 未在 gateway-simu registry 中可见`
|
||||
})),
|
||||
patchPanel.connectionActive ? null : {
|
||||
code: patchPanel.observable === false ? M3_IO_BLOCKER_CODES.patchPanelUnavailable : M3_IO_BLOCKER_CODES.wiringMissing,
|
||||
layer: M3_IO_CHAIN.patchPanelServiceId,
|
||||
zh: patchPanel.lastError ?? `hwlab-patch-panel 未确认 active ${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort} -> ${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort} 接线`
|
||||
},
|
||||
sourceDoRead.error ? {
|
||||
code: M3_IO_BLOCKER_CODES.dispatchFailed,
|
||||
layer: M3_IO_CHAIN.sourceResourceId,
|
||||
zh: sourceDoRead.error
|
||||
} : null,
|
||||
targetDiRead.error ? {
|
||||
code: M3_IO_BLOCKER_CODES.dispatchFailed,
|
||||
layer: M3_IO_CHAIN.targetResourceId,
|
||||
zh: targetDiRead.error
|
||||
} : null
|
||||
].filter(Boolean);
|
||||
|
||||
const hardwareLive =
|
||||
blockers.length === 0 &&
|
||||
sourceDoRead.port.sourceKind === "DEV-LIVE" &&
|
||||
targetDiRead.port.sourceKind === "DEV-LIVE";
|
||||
const trustGreen = trust.durableStatus === "green" && trust.evidenceId && trust.auditId && trust.operationId && trust.traceId;
|
||||
const status = blockers.length > 0
|
||||
? blockers.some((blocker) => blocker.code === M3_IO_BLOCKER_CODES.dispatchFailed || blocker.code === M3_IO_BLOCKER_CODES.patchPanelUnavailable) ? "error" : "blocked"
|
||||
: trustGreen ? "live" : hardwareLive ? "blocked" : "unverified";
|
||||
const sourceKind = hardwareLive ? "DEV-LIVE" : "UNVERIFIED";
|
||||
|
||||
return {
|
||||
...base,
|
||||
status,
|
||||
sourceKind,
|
||||
summary: status === "live"
|
||||
? "M3 硬件状态与可信持久化均为 DEV-LIVE。"
|
||||
: hardwareLive
|
||||
? "M3 DO1/DI1 实况读数来自 cloud-api 聚合入口;可信持久化仍保持 blocked,不得标为 trusted green。"
|
||||
: "M3 硬件状态尚未完整验证;SOURCE 拓扑只能作为未验证 fallback。",
|
||||
gateways,
|
||||
boxes,
|
||||
patchPanel,
|
||||
trust,
|
||||
blocker: blockers[0] ?? (trust.blocker ? {
|
||||
code: trust.blocker,
|
||||
layer: "runtime-durable",
|
||||
zh: `runtime durable 仍 blocked:${trust.blocker}`
|
||||
} : null)
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleM3IoControl(params = {}, context = {}) {
|
||||
const env = context.env ?? process.env;
|
||||
const now = context.now?.() ?? new Date().toISOString();
|
||||
@@ -441,6 +662,294 @@ function blockedReadiness({ checks, code, layer, reason, observedAt }) {
|
||||
};
|
||||
}
|
||||
|
||||
async function buildM3StatusTrust({ runtimeStore, runtime }) {
|
||||
const durable = durableStatus(runtime);
|
||||
const base = {
|
||||
operationId: null,
|
||||
traceId: null,
|
||||
auditId: null,
|
||||
evidenceId: null,
|
||||
durableStatus: isDurableRuntimeReady(runtime) ? "green" : "blocked",
|
||||
runtime: durable,
|
||||
blocker: isDurableRuntimeReady(runtime) ? null : durable.blocker
|
||||
};
|
||||
|
||||
const [audit, evidence] = await Promise.all([
|
||||
safeRuntimeRead(() => runtimeStore?.queryAuditEvents?.({ projectId: M3_IO_CHAIN.projectId, limit: 8 })),
|
||||
safeRuntimeRead(() => runtimeStore?.queryEvidenceRecords?.({ projectId: M3_IO_CHAIN.projectId, limit: 8 }))
|
||||
]);
|
||||
const latestAudit = latestM3AuditEvent(audit.value?.events ?? []);
|
||||
const latestEvidence = latestM3EvidenceRecord(evidence.value?.records ?? []);
|
||||
const operationId = latestEvidence?.operationId ?? latestAudit?.operationId ?? null;
|
||||
const traceId = latestEvidence?.traceId ?? latestEvidence?.metadata?.traceId ?? latestAudit?.traceId ?? null;
|
||||
|
||||
return {
|
||||
...base,
|
||||
operationId,
|
||||
traceId,
|
||||
auditId: latestAudit?.auditId ?? null,
|
||||
evidenceId: latestEvidence?.evidenceId ?? null,
|
||||
durableStatus: base.durableStatus,
|
||||
blocker: base.blocker ?? audit.error?.code ?? evidence.error?.code ?? null,
|
||||
readStatus: {
|
||||
audit: audit.ok ? "read" : "blocked",
|
||||
evidence: evidence.ok ? "read" : "blocked",
|
||||
auditError: audit.error?.message ?? null,
|
||||
evidenceError: evidence.error?.message ?? null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function safeRuntimeRead(read) {
|
||||
if (typeof read !== "function") {
|
||||
return {
|
||||
ok: false,
|
||||
value: null,
|
||||
error: {
|
||||
code: "runtime_read_unavailable",
|
||||
message: "runtime read method is unavailable"
|
||||
}
|
||||
};
|
||||
}
|
||||
try {
|
||||
const value = await read();
|
||||
if (value === undefined || value === null) {
|
||||
return {
|
||||
ok: false,
|
||||
value: null,
|
||||
error: {
|
||||
code: "runtime_read_unavailable",
|
||||
message: "runtime read returned no value"
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
value: null,
|
||||
error: {
|
||||
code: error?.code ?? "runtime_read_blocked",
|
||||
message: error instanceof Error ? redactBlockerMessage(error.message) : String(error)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function latestM3AuditEvent(events) {
|
||||
return [...events].reverse().find((event) =>
|
||||
event?.operationId &&
|
||||
event?.traceId &&
|
||||
(event.action === "m3.io.do.write" || event.action === "m3.io.di.read" || String(event.metadata?.route ?? "").includes("res_boxsimu_1:DO1"))
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
function latestM3EvidenceRecord(records) {
|
||||
return [...records].reverse().find((record) =>
|
||||
record?.operationId &&
|
||||
(record.serviceId === CLOUD_API_SERVICE_ID || String(record.metadata?.route ?? "").includes("res_boxsimu_1:DO1"))
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
function defaultM3Gateways({ sourceKind, observedAt }) {
|
||||
return [
|
||||
{
|
||||
id: M3_IO_CHAIN.sourceGatewayId,
|
||||
role: "source",
|
||||
online: false,
|
||||
observable: false,
|
||||
sessionId: M3_IO_CHAIN.sourceGatewaySessionId,
|
||||
lastSeenAt: observedAt,
|
||||
sourceKind,
|
||||
lastError: "未验证"
|
||||
},
|
||||
{
|
||||
id: M3_IO_CHAIN.targetGatewayId,
|
||||
role: "target",
|
||||
online: false,
|
||||
observable: false,
|
||||
sessionId: M3_IO_CHAIN.targetGatewaySessionId,
|
||||
lastSeenAt: observedAt,
|
||||
sourceKind,
|
||||
lastError: "未验证"
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function defaultM3Boxes({ sourceKind, observedAt }) {
|
||||
return [
|
||||
{
|
||||
id: M3_IO_CHAIN.sourceBoxId,
|
||||
resourceId: M3_IO_CHAIN.sourceResourceId,
|
||||
gatewayId: M3_IO_CHAIN.sourceGatewayId,
|
||||
gatewaySessionId: M3_IO_CHAIN.sourceGatewaySessionId,
|
||||
online: false,
|
||||
observable: false,
|
||||
sourceKind,
|
||||
observedAt,
|
||||
ports: {
|
||||
[M3_IO_CHAIN.sourcePort]: {
|
||||
value: null,
|
||||
direction: "output",
|
||||
source: "未验证",
|
||||
sourceKind,
|
||||
observedAt
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: M3_IO_CHAIN.targetBoxId,
|
||||
resourceId: M3_IO_CHAIN.targetResourceId,
|
||||
gatewayId: M3_IO_CHAIN.targetGatewayId,
|
||||
gatewaySessionId: M3_IO_CHAIN.targetGatewaySessionId,
|
||||
online: false,
|
||||
observable: false,
|
||||
sourceKind,
|
||||
observedAt,
|
||||
ports: {
|
||||
[M3_IO_CHAIN.targetPort]: {
|
||||
value: null,
|
||||
direction: "input",
|
||||
source: "未验证",
|
||||
sourceKind,
|
||||
observedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function defaultM3PatchPanel({ observedAt }) {
|
||||
return {
|
||||
serviceId: M3_IO_CHAIN.patchPanelServiceId,
|
||||
observable: false,
|
||||
connectionActive: false,
|
||||
connectionVersion: null,
|
||||
wiringConfigId: null,
|
||||
lastSyncAt: observedAt,
|
||||
lastError: "未验证",
|
||||
sourceKind: "UNVERIFIED"
|
||||
};
|
||||
}
|
||||
|
||||
async function readM3StatusPort({ gateway, command, requestJson, timeoutMs, observedAt, port, direction }) {
|
||||
const capability = capabilityFor({ ...command, action: "di.read", port }, {
|
||||
boxId: command.boxId,
|
||||
resourceId: command.resourceId
|
||||
});
|
||||
const dispatch = await invokeGateway(gateway.url, {
|
||||
operation: "hardware.port.read",
|
||||
projectId: M3_IO_CHAIN.projectId,
|
||||
gatewaySessionId: gateway.gatewaySessionId,
|
||||
resourceId: command.resourceId,
|
||||
boxId: command.boxId,
|
||||
capabilityId: capability.capabilityId,
|
||||
port,
|
||||
operationId: `op_m3_status_read_${port.toLowerCase()}_${randomUUID()}`,
|
||||
traceId: `trc_m3_status_read_${port.toLowerCase()}_${randomUUID()}`,
|
||||
actorType: "service",
|
||||
actorId: `svc_${CLOUD_API_SERVICE_ID}`,
|
||||
source: "cloud-api-m3-status"
|
||||
}, { requestJson, timeoutMs });
|
||||
|
||||
if (!dispatch.ok || dispatch.body?.accepted !== true) {
|
||||
return unverifiedPortRead({
|
||||
port,
|
||||
direction,
|
||||
source: "gateway-simu",
|
||||
reason: `gateway-simu ${port} 读取失败:${dispatch.error ?? dispatch.body?.error?.message ?? "unknown"}`,
|
||||
observedAt
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
port: {
|
||||
value: readValueFromGatewayResult(dispatch.body),
|
||||
direction,
|
||||
source: port === M3_IO_CHAIN.targetPort ? "gateway-simu/patch-panel" : "gateway-simu",
|
||||
sourceKind: "DEV-LIVE",
|
||||
observedAt: dispatch.body?.result?.state?.updatedAt ?? dispatch.body?.state?.updatedAt ?? observedAt,
|
||||
auditId: dispatch.body?.auditId ?? dispatch.body?.result?.auditId ?? null,
|
||||
evidenceId: dispatch.body?.evidenceId ?? dispatch.body?.result?.evidenceId ?? null,
|
||||
state: dispatch.body?.result?.state ?? dispatch.body?.state ?? null
|
||||
},
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
function unverifiedPortRead({ port, direction, source, reason, observedAt }) {
|
||||
return {
|
||||
port: {
|
||||
value: null,
|
||||
direction,
|
||||
source,
|
||||
sourceKind: "UNVERIFIED",
|
||||
observedAt,
|
||||
lastError: reason
|
||||
},
|
||||
error: reason
|
||||
};
|
||||
}
|
||||
|
||||
function gatewayStatusEntry(gateway, { id, sessionId, role, observedAt }) {
|
||||
return {
|
||||
id: gateway.gatewayId ?? id,
|
||||
role,
|
||||
online: gateway.available === true,
|
||||
observable: gateway.available === true,
|
||||
sessionId: gateway.gatewaySessionId ?? sessionId,
|
||||
lastSeenAt: gateway.status?.lastHeartbeatAt ?? gateway.status?.updatedAt ?? gateway.status?.session?.lastSeenAt ?? observedAt,
|
||||
sourceKind: gateway.available === true ? "DEV-LIVE" : "UNVERIFIED",
|
||||
lastError: gateway.available === true ? null : gateway.reason,
|
||||
blocker: gateway.available === true ? null : gateway.code ?? M3_IO_BLOCKER_CODES.gatewayUnavailable
|
||||
};
|
||||
}
|
||||
|
||||
function boxStatusEntry({ gateway, box, boxId, resourceId, gatewayId, gatewaySessionId, observedAt, ports }) {
|
||||
return {
|
||||
id: box?.boxId ?? boxId,
|
||||
resourceId: box?.resourceId ?? resourceId,
|
||||
gatewayId: gateway.gatewayId ?? gatewayId,
|
||||
gatewaySessionId: gateway.gatewaySessionId ?? gatewaySessionId,
|
||||
online: gateway.available === true && Boolean(box),
|
||||
observable: gateway.available === true && Boolean(box),
|
||||
sourceKind: gateway.available === true && box ? "DEV-LIVE" : "UNVERIFIED",
|
||||
observedAt,
|
||||
ports
|
||||
};
|
||||
}
|
||||
|
||||
function patchPanelStatusEntry({ statusResponse, wiringResponse, observedAt }) {
|
||||
const status = statusResponse.body ?? {};
|
||||
const wiring = wiringResponse.body ?? {};
|
||||
const connectionActive = statusResponse.ok && wiringResponse.ok && hasExpectedPatchPanelWiring(status) && hasExpectedPatchPanelWiring(wiring);
|
||||
const lastError = !statusResponse.ok
|
||||
? `hwlab-patch-panel status 不可读:${statusResponse.error ?? `HTTP ${statusResponse.status}`}`
|
||||
: !wiringResponse.ok
|
||||
? `hwlab-patch-panel wiring 不可读:${wiringResponse.error ?? `HTTP ${wiringResponse.status}`}`
|
||||
: connectionActive
|
||||
? status.metadata?.evidenceSummary?.diagnostics?.errorCount > 0
|
||||
? "hwlab-patch-panel diagnostics reports errors"
|
||||
: null
|
||||
: `未确认 active ${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort} -> ${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`;
|
||||
return {
|
||||
serviceId: status.serviceId ?? M3_IO_CHAIN.patchPanelServiceId,
|
||||
observable: statusResponse.ok,
|
||||
connectionActive,
|
||||
connectionVersion: status.metadata?.evidenceSummary?.tickCount ?? status.metadata?.evidenceSummary?.routeCount ?? null,
|
||||
wiringConfigId: status.wiringConfigId ?? wiring.wiringConfigId ?? null,
|
||||
lastSyncAt: status.metadata?.evidenceSummary?.lastSyncAt ?? status.observedAt ?? wiring.updatedAt ?? observedAt,
|
||||
lastError,
|
||||
sourceKind: connectionActive ? "DEV-LIVE" : statusResponse.ok ? "UNVERIFIED" : "BLOCKED",
|
||||
activeConnections: status.activeConnections ?? [],
|
||||
diagnostics: status.metadata?.diagnostics ?? status.metadata?.evidenceSummary?.diagnostics ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function readinessCheckFromGateway(id, gateway) {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
M3_IO_BLOCKER_CODES,
|
||||
M3_IO_CHAIN,
|
||||
M3_IO_RPC_METHODS,
|
||||
M3_STATUS_ROUTE,
|
||||
describeM3IoControl,
|
||||
describeM3IoControlLive,
|
||||
describeM3StatusLive,
|
||||
handleM3IoControl
|
||||
} from "./m3-io-control.mjs";
|
||||
|
||||
@@ -90,6 +92,107 @@ test("M3 IO live descriptor blocks controls with Chinese reason when readiness i
|
||||
assert.equal(contract.readiness.blocker.code, M3_IO_BLOCKER_CODES.wiringMissing);
|
||||
});
|
||||
|
||||
test("M3 status live descriptor aggregates gateways, boxes, patch-panel, IO values, and blocked trust", async () => {
|
||||
const fixture = createM3ControlFixture();
|
||||
writeBoxPort({
|
||||
state: fixture.box1,
|
||||
port: "DO1",
|
||||
value: true,
|
||||
source: "gateway-simu"
|
||||
});
|
||||
writeBoxPort({
|
||||
state: fixture.box2,
|
||||
port: "DI1",
|
||||
value: true,
|
||||
source: "patch-panel",
|
||||
sourceResourceId: "res_boxsimu_1",
|
||||
sourcePort: "DO1",
|
||||
propagatedBy: "hwlab-patch-panel"
|
||||
});
|
||||
|
||||
const status = await describeM3StatusLive({
|
||||
runtimeStore: createBlockedDurableRuntimeStore({ blocker: "runtime_durable_adapter_auth_blocked" }),
|
||||
env: {
|
||||
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
||||
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
||||
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
||||
},
|
||||
now: () => fixedNow,
|
||||
m3IoRequestJson: fixture.requestJson
|
||||
});
|
||||
|
||||
assert.equal(status.route, M3_STATUS_ROUTE);
|
||||
assert.equal(status.status, "blocked");
|
||||
assert.equal(status.sourceKind, "DEV-LIVE");
|
||||
assert.equal(status.chain.sourceResourceId, "res_boxsimu_1");
|
||||
assert.equal(status.boundaries.frontendCallsOnly, "/v1/m3/status");
|
||||
assert.equal(status.boundaries.directFrontendGatewayOrBoxAccess, false);
|
||||
assert.deepEqual(status.gateways.map((gateway) => [gateway.id, gateway.online, gateway.sessionId, gateway.sourceKind]), [
|
||||
["gwsimu_1", true, "gws_gwsimu_1", "DEV-LIVE"],
|
||||
["gwsimu_2", true, "gws_gwsimu_2", "DEV-LIVE"]
|
||||
]);
|
||||
const box1 = status.boxes.find((box) => box.resourceId === "res_boxsimu_1");
|
||||
const box2 = status.boxes.find((box) => box.resourceId === "res_boxsimu_2");
|
||||
assert.equal(box1.online, true);
|
||||
assert.equal(box1.ports.DO1.value, true);
|
||||
assert.equal(box1.ports.DO1.sourceKind, "DEV-LIVE");
|
||||
assert.equal(box2.online, true);
|
||||
assert.equal(box2.ports.DI1.value, true);
|
||||
assert.equal(box2.ports.DI1.sourceKind, "DEV-LIVE");
|
||||
assert.equal(box2.ports.DI1.source, "gateway-simu/patch-panel");
|
||||
assert.equal(status.patchPanel.serviceId, "hwlab-patch-panel");
|
||||
assert.equal(status.patchPanel.connectionActive, true);
|
||||
assert.equal(status.patchPanel.wiringConfigId, "wir_m3_do1_di1");
|
||||
assert.equal(status.patchPanel.lastError, null);
|
||||
assert.equal(status.trust.durableStatus, "blocked");
|
||||
assert.equal(status.trust.blocker, "runtime_durable_adapter_auth_blocked");
|
||||
assert.equal(status.trust.evidenceId, null);
|
||||
assert.equal(status.blocker.code, "runtime_durable_adapter_auth_blocked");
|
||||
assert.deepEqual(fixture.calls.map((call) => call.path), [
|
||||
"/status",
|
||||
"/status",
|
||||
"/status",
|
||||
"/wiring",
|
||||
"/invoke",
|
||||
"/invoke"
|
||||
]);
|
||||
});
|
||||
|
||||
test("M3 status live descriptor keeps SOURCE fallback unverified when patch-panel route is missing", async () => {
|
||||
const fixture = createM3ControlFixture({
|
||||
patchPanelStatusBody: {
|
||||
serviceId: "hwlab-patch-panel",
|
||||
state: "active",
|
||||
activeConnections: []
|
||||
},
|
||||
patchPanelWiringBody: {
|
||||
wiringConfigId: "wir_wrong",
|
||||
status: "active",
|
||||
connections: []
|
||||
}
|
||||
});
|
||||
|
||||
const status = await describeM3StatusLive({
|
||||
runtimeStore: createBlockedDurableRuntimeStore(),
|
||||
env: {
|
||||
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
||||
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
||||
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
||||
},
|
||||
now: () => fixedNow,
|
||||
m3IoRequestJson: fixture.requestJson
|
||||
});
|
||||
|
||||
assert.equal(status.status, "blocked");
|
||||
assert.equal(status.patchPanel.connectionActive, false);
|
||||
assert.equal(status.patchPanel.sourceKind, "UNVERIFIED");
|
||||
assert.match(status.patchPanel.lastError, /未确认 active/u);
|
||||
assert.equal(status.blocker.code, M3_IO_BLOCKER_CODES.wiringMissing);
|
||||
assert.equal(status.boxes[0].ports.DO1.sourceKind, "DEV-LIVE");
|
||||
assert.equal(status.boxes[1].ports.DI1.sourceKind, "DEV-LIVE");
|
||||
assert.notEqual(status.sourceKind, "SOURCE");
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -627,7 +730,7 @@ test("M3 IO fixture success cannot be promoted to DEV-LIVE without durable evide
|
||||
assert.notEqual(result.auditState.sourceKind, "DEV-LIVE");
|
||||
});
|
||||
|
||||
function createBlockedDurableRuntimeStore() {
|
||||
function createBlockedDurableRuntimeStore({ blocker = "runtime_durable_adapter_query_blocked" } = {}) {
|
||||
return {
|
||||
async readiness() {
|
||||
return {
|
||||
@@ -637,7 +740,7 @@ function createBlockedDurableRuntimeStore() {
|
||||
durableCapable: false,
|
||||
ready: false,
|
||||
status: "blocked",
|
||||
blocker: "runtime_durable_adapter_query_blocked",
|
||||
blocker,
|
||||
reason: "Postgres runtime adapter could not complete a live runtime schema query",
|
||||
liveRuntimeEvidence: false,
|
||||
fixtureEvidence: false
|
||||
@@ -697,6 +800,26 @@ function createM3ControlFixture({ patchPanelBody, patchPanelStatusBody, patchPan
|
||||
return { ok: true, status: 200, body: gateway2 };
|
||||
}
|
||||
if (url.startsWith("http://gateway-1") && parsed.pathname === "/invoke") {
|
||||
if (body.operation === "hardware.port.read") {
|
||||
const state = box1.ports[body.port];
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
accepted: true,
|
||||
operationId: body.operationId,
|
||||
traceId: body.traceId,
|
||||
boxId: box1.boxId,
|
||||
resourceId: box1.resource.resourceId,
|
||||
capabilityId: body.capabilityId,
|
||||
port: body.port,
|
||||
value: state.value,
|
||||
state,
|
||||
auditId: "aud_box1_read",
|
||||
evidenceId: "evd_box1_read"
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
|
||||
@@ -20,8 +20,10 @@ import {
|
||||
import { buildCloudApiReadiness } from "./health-contract.mjs";
|
||||
import {
|
||||
M3_IO_CONTROL_ROUTE,
|
||||
M3_STATUS_ROUTE,
|
||||
describeM3IoControl,
|
||||
describeM3IoControlLive,
|
||||
describeM3StatusLive,
|
||||
handleM3IoControl
|
||||
} from "./m3-io-control.mjs";
|
||||
import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||||
@@ -195,6 +197,11 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === M3_STATUS_ROUTE) {
|
||||
sendJson(response, 200, await describeM3StatusLive(options));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === M3_IO_CONTROL_ROUTE) {
|
||||
await handleM3IoControlHttp(request, response, options);
|
||||
return;
|
||||
|
||||
@@ -693,6 +693,30 @@ async function m3ReadinessRequestJson(url) {
|
||||
})
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://gateway-1") && parsed.pathname === "/invoke") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
accepted: true,
|
||||
operationId: "op_m3_status_do1",
|
||||
traceId: "trc_m3_status_do1",
|
||||
boxId: "boxsimu_1",
|
||||
resourceId: "res_boxsimu_1",
|
||||
port: "DO1",
|
||||
value: false,
|
||||
state: {
|
||||
port: "DO1",
|
||||
direction: "output",
|
||||
value: false,
|
||||
source: "gateway-simu",
|
||||
updatedAt: "2026-05-23T00:00:00.000Z"
|
||||
},
|
||||
auditId: "aud_m3_status_do1",
|
||||
evidenceId: "evd_m3_status_do1"
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://gateway-2") && parsed.pathname === "/status") {
|
||||
return {
|
||||
ok: true,
|
||||
@@ -705,6 +729,33 @@ async function m3ReadinessRequestJson(url) {
|
||||
})
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://gateway-2") && parsed.pathname === "/invoke") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
accepted: true,
|
||||
operationId: "op_m3_status_di1",
|
||||
traceId: "trc_m3_status_di1",
|
||||
boxId: "boxsimu_2",
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1",
|
||||
value: false,
|
||||
state: {
|
||||
port: "DI1",
|
||||
direction: "input",
|
||||
value: false,
|
||||
source: "patch-panel",
|
||||
sourceResourceId: "res_boxsimu_1",
|
||||
sourcePort: "DO1",
|
||||
propagatedBy: "hwlab-patch-panel",
|
||||
updatedAt: "2026-05-23T00:00:00.000Z"
|
||||
},
|
||||
auditId: "aud_m3_status_di1",
|
||||
evidenceId: "evd_m3_status_di1"
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://patch-panel") && parsed.pathname === "/status") {
|
||||
return {
|
||||
ok: true,
|
||||
@@ -1475,6 +1526,22 @@ test("cloud api /v1 exposes M3 IO control contract without generic frontend hard
|
||||
assert.equal(payload.boundaries.frontendCallsOnly, "/v1/m3/io");
|
||||
assert.equal(payload.boundaries.patchPanelOwnsPropagation, true);
|
||||
assert.equal(payload.boundaries.genericHardwareRpcExposedToFrontend, false);
|
||||
|
||||
const statusResponse = await fetch(`http://127.0.0.1:${port}/v1/m3/status`);
|
||||
assert.equal(statusResponse.status, 200);
|
||||
const statusPayload = await statusResponse.json();
|
||||
assert.equal(statusPayload.route, "/v1/m3/status");
|
||||
assert.equal(statusPayload.chain.sourceResourceId, "res_boxsimu_1");
|
||||
assert.equal(statusPayload.chain.targetResourceId, "res_boxsimu_2");
|
||||
assert.equal(statusPayload.gateways.length, 2);
|
||||
assert.equal(statusPayload.gateways.every((gateway) => gateway.online === true), true);
|
||||
assert.equal(statusPayload.boxes.find((box) => box.resourceId === "res_boxsimu_1").ports.DO1.value, false);
|
||||
assert.equal(statusPayload.boxes.find((box) => box.resourceId === "res_boxsimu_2").ports.DI1.value, false);
|
||||
assert.equal(statusPayload.patchPanel.connectionActive, true);
|
||||
assert.equal(statusPayload.trust.durableStatus, "blocked");
|
||||
assert.notEqual(statusPayload.trust.durableStatus, "green");
|
||||
assert.equal(statusPayload.boundaries.frontendCallsOnly, "/v1/m3/status");
|
||||
assert.equal(statusPayload.boundaries.directFrontendGatewayOrBoxAccess, false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
|
||||
@@ -478,10 +478,10 @@ async function serveCloudWeb() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(request.method === "GET" && (url.pathname === "/v1" || url.pathname.startsWith("/v1/"))) ||
|
||||
if (
|
||||
(request.method === "GET" && (url.pathname === "/v1" || url.pathname === "/v1/m3/status" || 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;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
parseSmokeArgs,
|
||||
printSmokeHelp,
|
||||
runDevCloudWorkbenchMobileSmoke,
|
||||
runM3StatusFixtureSmoke,
|
||||
runDevCloudWorkbenchSmoke
|
||||
} from "./src/dev-cloud-workbench-smoke-lib.mjs";
|
||||
|
||||
@@ -52,6 +53,8 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
? printSmokeHelp()
|
||||
: args.mobile
|
||||
? await runDevCloudWorkbenchMobileSmoke()
|
||||
: args.mode === "m3-status-fixture"
|
||||
? await runM3StatusFixtureSmoke()
|
||||
: await runDevCloudWorkbenchSmoke(process.argv.slice(2));
|
||||
const reportWrite = decideSmokeReportWrite(args, report);
|
||||
if (reportWrite.write) {
|
||||
|
||||
@@ -505,7 +505,7 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async
|
||||
assert.equal(Array.isArray(report.failures), true);
|
||||
assert.equal(Array.isArray(report.skipped), true);
|
||||
assert.equal(report.safety.hitTestMethod.includes("elementsFromPoint"), true);
|
||||
assert.equal(report.checks.find((check) => check.id === "layout-issue-287-future-hardware-status-tabs")?.status, "skip");
|
||||
assert.equal(report.checks.find((check) => check.id === "layout-issue-287-future-hardware-status-tabs")?.status, "pass");
|
||||
assert.equal(report.checks.find((check) => check.id === "layout-issue-288-future-single-table-gate")?.status, "skip");
|
||||
|
||||
const desktopCollapsed = report.checks.find((check) => check.id === "layout-desktop-collapsed")?.observations;
|
||||
|
||||
@@ -225,7 +225,7 @@ test("M3 IO E2E frontend guardrail requires readiness before controls unlock", (
|
||||
`;
|
||||
const pass = checkFrontendNoDirectRuntimeCalls({
|
||||
appSource,
|
||||
artifactPublisherSource: 'if (url.pathname === "/v1/m3/io") proxyCloudApi();'
|
||||
artifactPublisherSource: 'if (url.pathname === "/v1/m3/io" || url.pathname === "/v1/m3/status") proxyCloudApi();'
|
||||
});
|
||||
assert.equal(pass.status, "pass");
|
||||
|
||||
@@ -238,7 +238,7 @@ test("M3 IO E2E frontend guardrail requires readiness before controls unlock", (
|
||||
return fetchJson("/v1/m3/io", { method: "POST" });
|
||||
}
|
||||
`,
|
||||
artifactPublisherSource: 'if (url.pathname === "/v1/m3/io") proxyCloudApi();'
|
||||
artifactPublisherSource: 'if (url.pathname === "/v1/m3/io" || url.pathname === "/v1/m3/status") proxyCloudApi();'
|
||||
});
|
||||
assert.equal(fail.status, "fail");
|
||||
assert.ok(fail.issues.some((issue) => /readiness/u.test(issue)));
|
||||
@@ -263,7 +263,7 @@ test("M3 IO E2E frontend guardrail catches direct gateway or patch-panel calls",
|
||||
`;
|
||||
const pass = checkFrontendNoDirectRuntimeCalls({
|
||||
appSource,
|
||||
artifactPublisherSource: 'if (url.pathname === "/v1/m3/io") proxyCloudApi();'
|
||||
artifactPublisherSource: 'if (url.pathname === "/v1/m3/io" || url.pathname === "/v1/m3/status") proxyCloudApi();'
|
||||
});
|
||||
assert.equal(pass.status, "pass");
|
||||
|
||||
|
||||
@@ -133,17 +133,21 @@ const requiredTrustedRecordTerms = Object.freeze([
|
||||
|
||||
const requiredHardwareStatusTerms = Object.freeze([
|
||||
"hardware-source-status",
|
||||
"hardwareGroup",
|
||||
"hardwareRow",
|
||||
"Gateway/Box 在线",
|
||||
"BOX-SIMU 端口",
|
||||
"Patch Panel 连线",
|
||||
"DO1 -> patch-panel -> DI1",
|
||||
"AI/AO/FREQ",
|
||||
"后续能力",
|
||||
"hardware-status-tabs",
|
||||
"hardwareKvGroup",
|
||||
"/v1/m3/status",
|
||||
"Gateway-SIMU",
|
||||
"BOX-SIMU-1",
|
||||
"BOX-SIMU-2",
|
||||
"Patch Panel",
|
||||
"Gateway 在线",
|
||||
"BOX 在线",
|
||||
"patch-panel",
|
||||
"runtime durable",
|
||||
"DISABLED 后续能力",
|
||||
"SOURCE",
|
||||
"DEV-LIVE",
|
||||
"BLOCKED"
|
||||
"unverified_cloud_api_m3_status"
|
||||
]);
|
||||
|
||||
const workbenchMarkers = Object.freeze([
|
||||
@@ -231,6 +235,7 @@ export async function runDevCloudWorkbenchSmoke(argv = []) {
|
||||
if (args.mode === "live") return runLiveSmoke(args);
|
||||
if (args.mode === "local-agent-timeout-fixture") return runDevCloudWorkbenchTimeoutFixtureSmoke();
|
||||
if (args.mode === "dom-only") return runLiveDomOnlySmoke(args);
|
||||
if (args.mode === "m3-status-fixture") return runM3StatusFixtureSmoke();
|
||||
if (args.mode === "layout") return runDevCloudWorkbenchLayoutSmoke(args);
|
||||
if (args.mode === "local-agent-fixture") return runDevCloudWorkbenchLocalAgentFixtureSmoke();
|
||||
if (args.mode === "mobile") return runDevCloudWorkbenchMobileSmoke();
|
||||
@@ -283,6 +288,8 @@ export function parseSmokeArgs(argv) {
|
||||
args.confirmDevLive = true;
|
||||
} else if (arg === "--dom-only") {
|
||||
args.mode = "dom-only";
|
||||
} else if (arg === "--m3-status-fixture") {
|
||||
args.mode = "m3-status-fixture";
|
||||
} else if (arg === "--layout") {
|
||||
args.mode = "layout";
|
||||
} else if (arg === "--build") {
|
||||
@@ -416,8 +423,8 @@ function runStaticSmoke() {
|
||||
"收起左侧资源树 / 展开左侧资源树 aria-label",
|
||||
"收起资源树 / 展开资源树 visible button text",
|
||||
"explorer-collapsed sets explorer column to 0",
|
||||
"collapsed desktop center column minmax(560px, 1fr)",
|
||||
"right side keeps a future width boundary through --right-width"
|
||||
"collapsed desktop center column minmax(420px, 1fr)",
|
||||
"right side keeps a 560-740px M3 status workspace boundary through --right-width"
|
||||
]
|
||||
});
|
||||
|
||||
@@ -426,9 +433,9 @@ function runStaticSmoke() {
|
||||
evidence: ["/health/live", "/v1", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods]
|
||||
});
|
||||
|
||||
addCheck(checks, blockers, "api-degraded-default-status", hasApiDegradedDefaultStatus(files), "Default workbench status distinguishes reachable degraded API from fully OK without exposing backend blocker details.", {
|
||||
addCheck(checks, blockers, "api-degraded-default-status", hasApiDegradedDefaultStatus(files), "Default workbench status distinguishes reachable blocked API from fully OK with concrete blocker copy.", {
|
||||
blocker: "observability_blocker",
|
||||
evidence: ["healthLive.data.status === degraded", "ready false", "runtime_durable_adapter_query_blocked", "API 降级 / 只读模式", "可信记录受阻 / 只读模式", "no Gate/diagnostics/blocker copy in default status classifier"]
|
||||
evidence: ["healthLive.data.status === degraded", "ready false", "runtime_durable_adapter_query_blocked", "runtime_durable_adapter_auth_blocked", "API 错误 / 只读模式", "可信记录受阻 / 只读模式", "no Gate/diagnostics copy in default status classifier"]
|
||||
});
|
||||
|
||||
addCheck(checks, blockers, "code-agent-chat-primary-flow", hasCodeAgentChatContract(files), "Center Agent conversation sends to the controlled Code Agent chat endpoint and no longer uses 添加草稿 as the primary flow.", {
|
||||
@@ -494,7 +501,7 @@ function runStaticSmoke() {
|
||||
addCheck(checks, blockers, "m3-rendered-workbench-not-m5-fixture", m3RenderedWorkbenchNotM5Fixture(files.app), "Right workbench hardware status and wiring table render the M3 chain, not M5 fixture rows or undefined DOM-node rows.", {
|
||||
blocker: "runtime_blocker",
|
||||
evidence: [
|
||||
"renderHardwareStatus passes data objects into hardwareGroup",
|
||||
"renderHardwareStatus renders tabbed Key/Value groups",
|
||||
"renderWiringList uses M3_TRUSTED_ROUTE",
|
||||
"M5 activeConnections are not mapped into the workbench wiring table"
|
||||
]
|
||||
@@ -1734,16 +1741,27 @@ function hardwareStatusStructureContract({ html, app, styles }) {
|
||||
return (
|
||||
requiredHardwareStatusTerms.every((term) => source.includes(term)) &&
|
||||
/id=["']hardware-source-status["']/u.test(html) &&
|
||||
/data-hardware-tab=["']overview["'][\s\S]*?>总览<\/button>/u.test(html) &&
|
||||
/data-hardware-tab=["']gateways["'][\s\S]*?>Gateway-SIMU<\/button>/u.test(html) &&
|
||||
/data-hardware-tab=["']box1["'][\s\S]*?>BOX-SIMU-1<\/button>/u.test(html) &&
|
||||
/data-hardware-tab=["']box2["'][\s\S]*?>BOX-SIMU-2<\/button>/u.test(html) &&
|
||||
/data-hardware-tab=["']patch["'][\s\S]*?>Patch Panel<\/button>/u.test(html) &&
|
||||
/function\s+renderHardwareStatus\s*\(/u.test(app) &&
|
||||
/function\s+hardwareGroup\s*\(/u.test(app) &&
|
||||
/function\s+hardwareRow\s*\(/u.test(app) &&
|
||||
/function\s+initHardwareTabs\s*\(/u.test(app) &&
|
||||
/function\s+hardwareKvGroup\s*\(/u.test(app) &&
|
||||
/function\s+sourceFallbackM3Status\s*\(/u.test(app) &&
|
||||
/function\s+trustedM3Link\s*\(/u.test(app) &&
|
||||
/function\s+hasTrustedM3LiveEvidence\s*\(/u.test(app) &&
|
||||
/function\s+linkStatusLabel\s*\(/u.test(app) &&
|
||||
/function\s+normalizePort\s*\(/u.test(app) &&
|
||||
/页面不直连模拟器,也不伪造硬件状态。/u.test(app) &&
|
||||
/fetchJson\("\/v1\/m3\/status"\)/u.test(app) &&
|
||||
/sourceFallbackM3Status\(\)/u.test(functionBody(app, "renderHardwareStatus")) &&
|
||||
/\.hardware-section\s*\{/u.test(styles) &&
|
||||
/\.hardware-row\s*\{/u.test(styles)
|
||||
/\.hardware-status-tabs\s*\{/u.test(styles) &&
|
||||
/\.hardware-tab\s*\{/u.test(styles) &&
|
||||
/\.kv-list\s*\{/u.test(styles) &&
|
||||
/\.kv-row\s*\{/u.test(styles)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1850,9 +1868,9 @@ function hasSidebarCollapseContract({ html, app, styles }) {
|
||||
/textContent = collapsed \? "展开资源树" : "收起资源树"/u.test(setCollapsedBody) &&
|
||||
/title = collapsed \? "展开左侧资源树,恢复导航和硬件资源" : "收起左侧资源树,给工作区更多宽度"/u.test(setCollapsedBody) &&
|
||||
/--explorer-width:\s*292px/u.test(styles) &&
|
||||
/--right-width:\s*clamp\(366px,\s*28vw,\s*440px\)/u.test(styles) &&
|
||||
/--right-width-expanded:\s*clamp\(366px,\s*26vw,\s*410px\)/u.test(styles) &&
|
||||
/\.explorer-collapsed\s*\{[^}]*--explorer-width:\s*0px;[^}]*grid-template-columns:\s*var\(--rail-width\)\s+0\s+minmax\(560px,\s*1fr\)\s+var\(--right-width\);/su.test(styles) &&
|
||||
/--right-width:\s*clamp\(560px,\s*38vw,\s*740px\)/u.test(styles) &&
|
||||
/--right-width-expanded:\s*clamp\(560px,\s*45vw,\s*740px\)/u.test(styles) &&
|
||||
/\.explorer-collapsed\s*\{[^}]*--explorer-width:\s*0px;[^}]*grid-template-columns:\s*var\(--rail-width\)\s+0\s+minmax\(420px,\s*1fr\)\s+var\(--right-width\);/su.test(styles) &&
|
||||
/\.side-panel\s*\{[^}]*overflow:\s*auto;[^}]*overscroll-behavior:\s*contain;/su.test(styles)
|
||||
);
|
||||
}
|
||||
@@ -1876,6 +1894,7 @@ function hasSameOriginReadOnlyBoundary(app) {
|
||||
return (
|
||||
/fetchJson\("\/health\/live"\)/u.test(app) &&
|
||||
/fetchJson\("\/v1"\)/u.test(app) &&
|
||||
/fetchJson\("\/v1\/m3\/status"\)/u.test(app) &&
|
||||
/fetchJson\("\/v1\/agent\/chat"/u.test(app) &&
|
||||
/fetchJson\("\/json-rpc"/u.test(app) &&
|
||||
JSON.stringify(rpcCalls) === JSON.stringify([...readOnlyRpcMethods].sort()) &&
|
||||
@@ -1898,13 +1917,16 @@ function hasApiDegradedDefaultStatus({ app }) {
|
||||
/payload\.readiness\?\.ready/u.test(functionBody(app, "readinessBooleanValues")) &&
|
||||
/readyValues\.includes\(false\)/u.test(readinessBody) &&
|
||||
/runtime_durable_adapter_query_blocked/u.test(durableBlockBody) &&
|
||||
/runtime_durable_adapter_/u.test(durableBlockBody) &&
|
||||
/payload\.runtime\?\.blocker/u.test(durableBlockBody) &&
|
||||
/payload\.readiness\?\.durability\?\.blocker/u.test(durableBlockBody) &&
|
||||
/blockedLayer === "durability_query"/u.test(durableBlockBody) &&
|
||||
/可信记录受阻 \/ 只读模式/u.test(statusBody) &&
|
||||
/API 降级 \/ 只读模式/u.test(statusBody) &&
|
||||
/同源 API 可响应但处于降级状态;当前保持只读查看和本地任务整理。/u.test(statusBody) &&
|
||||
/同源 API 可响应,但可信记录持久化尚未就绪;当前只保留只读查看和本地任务整理。/u.test(statusBody) &&
|
||||
/API 错误 \/ 只读模式/u.test(statusBody) &&
|
||||
/apiDurableBlockerDetail\(coreProbes\)/u.test(statusBody) &&
|
||||
/apiProbeErrorDetail\(coreProbes\)/u.test(statusBody) &&
|
||||
/\/health\/live 可响应,但可信记录持久化尚未就绪:/u.test(app) &&
|
||||
!/API 降级 \/ 只读模式/u.test(app) &&
|
||||
!/runtime_durable_adapter_query_blocked|DB live readiness|Gate|诊断|验收|M0-M5|执行轨迹/u.test(statusBody)
|
||||
);
|
||||
}
|
||||
@@ -2059,32 +2081,33 @@ function sourceEvidenceNotDevLive(source) {
|
||||
|
||||
function m3FixtureCountsNotDevLive(app) {
|
||||
const hardwareBody = functionBody(app, "renderHardwareStatus");
|
||||
const linkDetailBody = functionBody(app, "m3LinkDetail");
|
||||
const fallbackBody = functionBody(app, "sourceFallbackM3Status");
|
||||
const liveEvidenceBody = functionBody(app, "hasTrustedM3LiveEvidence");
|
||||
const patchPanelReportBody = functionBody(app, "hasTrustedPatchPanelLiveReport");
|
||||
const sourceOnlyRuntimeCounts =
|
||||
/runtimeCountsSourceKind\s*=\s*["']SOURCE["']/u.test(hardwareBody) &&
|
||||
!/sourceKind:\s*counts\s*\?\s*["']DEV-LIVE["']/u.test(hardwareBody) &&
|
||||
!/counts\s*&&\s*(?:liveLink|sourceLink|m3Link)[\s\S]{0,80}\?\s*["']DEV-LIVE["']/u.test(hardwareBody);
|
||||
const sourceFallbackStaysUnverified =
|
||||
/sourceKind:\s*"SOURCE"/u.test(fallbackBody) &&
|
||||
/unverified_cloud_api_m3_status/u.test(fallbackBody) &&
|
||||
/SOURCE 拓扑不能冒充实况硬件状态/u.test(fallbackBody) &&
|
||||
!/sourceKind:\s*"DEV-LIVE"/u.test(fallbackBody);
|
||||
return (
|
||||
sourceOnlyRuntimeCounts &&
|
||||
/hasTrustedM3LiveEvidence\(liveM3Evidence\)/u.test(hardwareBody) &&
|
||||
sourceFallbackStaysUnverified &&
|
||||
/sourceFallbackM3Status\(\)/u.test(hardwareBody) &&
|
||||
/patchPanelLiveReport/u.test(liveEvidenceBody) &&
|
||||
/LIVE_M3_ID_FIELDS\.every/u.test(liveEvidenceBody) &&
|
||||
/hasTrustedPatchPanelLiveReport/u.test(liveEvidenceBody) &&
|
||||
/serviceId\s*!==\s*M3_TRUSTED_ROUTE\.patchPanelServiceId/u.test(patchPanelReportBody) &&
|
||||
/trustedM3Link\(\[link\]\)/u.test(patchPanelReportBody) &&
|
||||
/只读运行计数已返回,但还不能证明接线盘闭环/u.test(linkDetailBody) &&
|
||||
/来源拓扑包含目标接线/u.test(linkDetailBody)
|
||||
/trustedM3Link\(\[link\]\)/u.test(patchPanelReportBody)
|
||||
);
|
||||
}
|
||||
|
||||
function m3RenderedWorkbenchNotM5Fixture(app) {
|
||||
const hardwareBody = functionBody(app, "renderHardwareStatus");
|
||||
const hardwareGroupBody = functionBody(app, "hardwareGroup");
|
||||
const overviewBody = functionBody(app, "hardwareOverviewGroups");
|
||||
const wiringBody = functionBody(app, "renderWiringList");
|
||||
return (
|
||||
/group\.rows\.map\(hardwareRow\)/u.test(hardwareGroupBody) &&
|
||||
/Key\/Value groups/u.test(hardwareBody) &&
|
||||
/M3_TRUSTED_ROUTE\.fromResourceId/u.test(overviewBody) &&
|
||||
/M3_TRUSTED_ROUTE\.toResourceId/u.test(overviewBody) &&
|
||||
!/hardwareRow\(\s*\{/u.test(hardwareBody) &&
|
||||
/M3_TRUSTED_ROUTE\.fromResourceId/u.test(wiringBody) &&
|
||||
/M3_TRUSTED_ROUTE\.patchPanelServiceId/u.test(wiringBody) &&
|
||||
@@ -3124,8 +3147,11 @@ async function inspectDefaultApiStatus(page) {
|
||||
methodText,
|
||||
firstScreenText,
|
||||
degradedStatusVisible:
|
||||
liveStatus === "可信记录受阻 / 只读模式" &&
|
||||
liveDetail === "同源 API 可响应,但可信记录持久化尚未就绪;当前只保留只读查看和本地任务整理。" &&
|
||||
(liveStatus === "可信记录受阻 / 只读模式" || liveStatus === "API 错误 / 只读模式") &&
|
||||
(
|
||||
liveDetail.startsWith("/health/live 可响应,但可信记录持久化尚未就绪:") ||
|
||||
liveDetail === "同源 API 返回 blocked/degraded/failed 或 ready=false;当前保持只读查看和本地任务整理。"
|
||||
) &&
|
||||
methodText.includes("system.health"),
|
||||
defaultCopyIsWorkbenchSafe: !forbidden.test(`${liveStatus} ${liveDetail} ${firstScreenText}`)
|
||||
};
|
||||
@@ -3302,7 +3328,7 @@ async function runLocalAgentFixtureSmoke({
|
||||
{
|
||||
id: "local-api-degraded-default-status",
|
||||
status: apiStatus.degradedStatusVisible && apiStatus.defaultCopyIsWorkbenchSafe ? "pass" : "blocked",
|
||||
summary: "Default workbench status renders reachable degraded /health/live as API degraded read-only without first-screen backend blocker details.",
|
||||
summary: "Default workbench status renders reachable blocked /health/live as concrete read-only status without first-screen backend route details.",
|
||||
observations: apiStatus
|
||||
},
|
||||
{
|
||||
@@ -3588,10 +3614,142 @@ export async function runDevCloudWorkbenchMobileSmoke() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runM3StatusFixtureSmoke() {
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = await importPlaywright());
|
||||
} catch (error) {
|
||||
const summary = `M3 status fixture smoke skipped because Playwright is unavailable: ${error.message}`;
|
||||
return {
|
||||
status: "skip",
|
||||
task: "DC-DCSN-P0-2026-003",
|
||||
mode: "m3-status-fixture",
|
||||
evidenceLevel: "SOURCE",
|
||||
devLive: false,
|
||||
summary,
|
||||
checks: [
|
||||
{
|
||||
id: "m3-status-browser-dependency",
|
||||
status: "skip",
|
||||
summary: playwrightDependencySummary,
|
||||
evidence: [playwrightDependencyCommand, "package.json dependencies.playwright"]
|
||||
}
|
||||
],
|
||||
blockers: [
|
||||
{
|
||||
type: "environment_blocker",
|
||||
scope: "m3-status-browser-dependency",
|
||||
status: "open",
|
||||
summary
|
||||
}
|
||||
],
|
||||
safety: staticSafety()
|
||||
};
|
||||
}
|
||||
|
||||
const m3State = {
|
||||
doValue: false,
|
||||
diValue: false,
|
||||
observedAt: "2026-05-23T00:00:00.000Z",
|
||||
sequence: 1
|
||||
};
|
||||
const server = await startStaticWebServer({ m3StatusFixture: true, m3State });
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const desktop = await inspectM3StatusFixtureViewport(browser, server.url, {
|
||||
width: 1366,
|
||||
height: 768,
|
||||
expectedRightWidthMin: 560
|
||||
});
|
||||
const mobile = await inspectM3StatusFixtureViewport(browser, server.url, {
|
||||
width: 390,
|
||||
height: 844,
|
||||
expectedRightWidthMin: 300
|
||||
});
|
||||
const refresh = await inspectM3StatusFixtureRefresh(browser, server.url);
|
||||
const checks = [
|
||||
{
|
||||
id: "m3-status-fixture-desktop",
|
||||
status: desktop.pass ? "pass" : "blocked",
|
||||
summary: "Desktop hardware workspace reads /v1/m3/status, exposes tabs/Key-Value state, and has no outer or horizontal scroll.",
|
||||
observations: desktop
|
||||
},
|
||||
{
|
||||
id: "m3-status-fixture-mobile",
|
||||
status: mobile.pass ? "pass" : "blocked",
|
||||
summary: "390x844 hardware workspace wraps tabs, preserves Key-Value content, and avoids horizontal overflow.",
|
||||
observations: mobile
|
||||
},
|
||||
{
|
||||
id: "m3-status-fixture-refresh",
|
||||
status: refresh.pass ? "pass" : "blocked",
|
||||
summary: "DO1 false/true writes refresh through same-origin cloud-api status and DI1 follows through patch-panel.",
|
||||
observations: refresh
|
||||
}
|
||||
];
|
||||
const blockers = checks
|
||||
.filter((check) => check.status !== "pass")
|
||||
.map((check) => ({
|
||||
type: "runtime_blocker",
|
||||
scope: check.id,
|
||||
status: "open",
|
||||
summary: check.summary
|
||||
}));
|
||||
return {
|
||||
status: blockers.length === 0 ? "pass" : "blocked",
|
||||
task: "DC-DCSN-P0-2026-003",
|
||||
mode: "m3-status-fixture",
|
||||
url: server.url,
|
||||
generatedAt: new Date().toISOString(),
|
||||
evidenceLevel: "SOURCE",
|
||||
devLive: false,
|
||||
summary: "Local SOURCE fixture browser smoke validates the M3 status UI contract; it is not DEV-LIVE hardware evidence.",
|
||||
refs: ["pikasTech/HWLAB#287", "pikasTech/HWLAB#273", "pikasTech/HWLAB#278"],
|
||||
checks,
|
||||
blockers,
|
||||
safety: {
|
||||
...staticSafety(),
|
||||
devLive: false,
|
||||
sourceIsDevLive: false,
|
||||
hardwareWriteApis: false,
|
||||
statement: "Fixture smoke intercepts only same-origin cloud-api routes on a local static server; it never contacts gateway-simu, box-simu, patch-panel, DEV, or PROD."
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "blocked",
|
||||
task: "DC-DCSN-P0-2026-003",
|
||||
mode: "m3-status-fixture",
|
||||
url: server.url,
|
||||
generatedAt: new Date().toISOString(),
|
||||
evidenceLevel: "SOURCE",
|
||||
devLive: false,
|
||||
summary: `M3 status fixture smoke failed: ${error.message}`,
|
||||
checks: [],
|
||||
blockers: [
|
||||
{
|
||||
type: "runtime_blocker",
|
||||
scope: "m3-status-fixture",
|
||||
status: "open",
|
||||
summary: error.message
|
||||
}
|
||||
],
|
||||
safety: staticSafety()
|
||||
};
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function startStaticWebServer(options = {}) {
|
||||
const rootDir = path.resolve(options.rootDir ?? webRoot);
|
||||
const server = http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
if (options.m3StatusFixture && await handleM3StatusFixtureApi({ request, response, url, options })) {
|
||||
return;
|
||||
}
|
||||
if (options.agentFixture && await handleLocalAgentFixtureApi({ request, response, url, options })) {
|
||||
return;
|
||||
}
|
||||
@@ -3625,6 +3783,219 @@ async function startStaticWebServer(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function handleM3StatusFixtureApi({ request, response, url, options = {} }) {
|
||||
if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) {
|
||||
jsonResponse(response, 200, m3StatusFixtureHealth(options.m3State));
|
||||
return true;
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/v1/m3/io") {
|
||||
jsonResponse(response, 200, {
|
||||
status: "available",
|
||||
route: "/v1/m3/io",
|
||||
readiness: {
|
||||
status: "ready",
|
||||
controlReady: true,
|
||||
sourceKind: "DEV-LIVE",
|
||||
evidenceLevel: "DEV-LIVE"
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/v1/m3/status") {
|
||||
jsonResponse(response, 200, m3StatusFixturePayload(options.m3State));
|
||||
return true;
|
||||
}
|
||||
if (request.method === "POST" && url.pathname === "/v1/m3/io") {
|
||||
const body = await readJsonBody(request);
|
||||
if (body?.action === "do.write") {
|
||||
options.m3State.doValue = body.value === true;
|
||||
options.m3State.diValue = body.value === true;
|
||||
options.m3State.sequence += 1;
|
||||
options.m3State.observedAt = `2026-05-23T00:00:0${Math.min(options.m3State.sequence, 9)}.000Z`;
|
||||
}
|
||||
jsonResponse(response, 200, {
|
||||
status: "succeeded",
|
||||
accepted: true,
|
||||
action: body?.action ?? "di.read",
|
||||
value: body?.action === "di.read" ? options.m3State.diValue : options.m3State.doValue,
|
||||
result: {
|
||||
value: body?.action === "di.read" ? options.m3State.diValue : options.m3State.doValue
|
||||
},
|
||||
operationId: `op_fixture_${options.m3State.sequence}`,
|
||||
traceId: `trc_fixture_${options.m3State.sequence}`,
|
||||
auditId: null,
|
||||
evidenceId: null,
|
||||
evidenceState: {
|
||||
status: "blocked",
|
||||
reason: "runtime_durable_adapter_auth_blocked"
|
||||
},
|
||||
controlPath: {
|
||||
frontendBypass: false
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (request.method === "POST" && url.pathname === "/json-rpc") {
|
||||
const body = await readJsonBody(request);
|
||||
jsonResponse(response, 200, {
|
||||
jsonrpc: "2.0",
|
||||
id: body?.id ?? "req_m3_status_fixture",
|
||||
result: localRpcFixtureResult(body?.method)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function m3StatusFixtureHealth(m3State = { sequence: 1 }) {
|
||||
return {
|
||||
serviceId: "hwlab-cloud-api",
|
||||
status: "degraded",
|
||||
ready: false,
|
||||
methods: [...readOnlyRpcMethods],
|
||||
runtime: {
|
||||
durable: true,
|
||||
ready: false,
|
||||
liveRuntimeEvidence: false,
|
||||
status: "blocked",
|
||||
blocker: "runtime_durable_adapter_auth_blocked",
|
||||
connection: {
|
||||
queryAttempted: true,
|
||||
queryResult: "auth_blocked"
|
||||
}
|
||||
},
|
||||
readiness: {
|
||||
status: "degraded",
|
||||
ready: false,
|
||||
durability: {
|
||||
status: "blocked",
|
||||
ready: false,
|
||||
blocker: "runtime_durable_adapter_auth_blocked",
|
||||
blockedLayer: "durability_auth",
|
||||
queryResult: "auth_blocked"
|
||||
}
|
||||
},
|
||||
blockerCodes: ["runtime_durable_adapter_auth_blocked"],
|
||||
m3FixtureSequence: m3State.sequence
|
||||
};
|
||||
}
|
||||
|
||||
function m3StatusFixturePayload(m3State = { doValue: false, diValue: false, observedAt: "2026-05-23T00:00:00.000Z", sequence: 1 }) {
|
||||
const observedAt = m3State.observedAt;
|
||||
return {
|
||||
serviceId: "hwlab-cloud-api",
|
||||
contractVersion: "m3-status-v1",
|
||||
route: "/v1/m3/status",
|
||||
status: "blocked",
|
||||
sourceKind: "DEV-LIVE",
|
||||
observedAt,
|
||||
chain: {
|
||||
projectId: gateSummary.topology.projectId,
|
||||
sourceGatewayId: "gwsimu_1",
|
||||
sourceGatewaySessionId: "gws_gwsimu_1",
|
||||
sourceResourceId: "res_boxsimu_1",
|
||||
sourceBoxId: "boxsimu_1",
|
||||
sourcePort: "DO1",
|
||||
targetGatewayId: "gwsimu_2",
|
||||
targetGatewaySessionId: "gws_gwsimu_2",
|
||||
targetResourceId: "res_boxsimu_2",
|
||||
targetBoxId: "boxsimu_2",
|
||||
targetPort: "DI1",
|
||||
patchPanelServiceId: "hwlab-patch-panel"
|
||||
},
|
||||
boundaries: {
|
||||
frontendCallsOnly: "/v1/m3/status",
|
||||
directFrontendGatewayOrBoxAccess: false,
|
||||
sourceTopologyFallbackMustStayUnverified: true
|
||||
},
|
||||
gateways: [
|
||||
{
|
||||
id: "gwsimu_1",
|
||||
role: "source",
|
||||
online: true,
|
||||
observable: true,
|
||||
sessionId: "gws_gwsimu_1",
|
||||
lastSeenAt: observedAt,
|
||||
sourceKind: "DEV-LIVE",
|
||||
lastError: null
|
||||
},
|
||||
{
|
||||
id: "gwsimu_2",
|
||||
role: "target",
|
||||
online: true,
|
||||
observable: true,
|
||||
sessionId: "gws_gwsimu_2",
|
||||
lastSeenAt: observedAt,
|
||||
sourceKind: "DEV-LIVE",
|
||||
lastError: null
|
||||
}
|
||||
],
|
||||
boxes: [
|
||||
{
|
||||
id: "boxsimu_1",
|
||||
resourceId: "res_boxsimu_1",
|
||||
gatewayId: "gwsimu_1",
|
||||
gatewaySessionId: "gws_gwsimu_1",
|
||||
online: true,
|
||||
observable: true,
|
||||
sourceKind: "DEV-LIVE",
|
||||
observedAt,
|
||||
ports: {
|
||||
DO1: {
|
||||
value: m3State.doValue,
|
||||
direction: "output",
|
||||
source: "gateway-simu",
|
||||
sourceKind: "DEV-LIVE",
|
||||
observedAt
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "boxsimu_2",
|
||||
resourceId: "res_boxsimu_2",
|
||||
gatewayId: "gwsimu_2",
|
||||
gatewaySessionId: "gws_gwsimu_2",
|
||||
online: true,
|
||||
observable: true,
|
||||
sourceKind: "DEV-LIVE",
|
||||
observedAt,
|
||||
ports: {
|
||||
DI1: {
|
||||
value: m3State.diValue,
|
||||
direction: "input",
|
||||
source: "gateway-simu/patch-panel",
|
||||
sourceKind: "DEV-LIVE",
|
||||
observedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
patchPanel: {
|
||||
serviceId: "hwlab-patch-panel",
|
||||
observable: true,
|
||||
connectionActive: true,
|
||||
connectionVersion: m3State.sequence,
|
||||
wiringConfigId: "wir_m3_do1_di1",
|
||||
lastSyncAt: observedAt,
|
||||
lastError: null,
|
||||
sourceKind: "DEV-LIVE"
|
||||
},
|
||||
trust: {
|
||||
operationId: `op_fixture_${m3State.sequence}`,
|
||||
traceId: `trc_fixture_${m3State.sequence}`,
|
||||
auditId: null,
|
||||
evidenceId: null,
|
||||
durableStatus: "blocked",
|
||||
blocker: "runtime_durable_adapter_auth_blocked"
|
||||
},
|
||||
blocker: {
|
||||
code: "runtime_durable_adapter_auth_blocked",
|
||||
layer: "runtime-durable",
|
||||
zh: "runtime durable 仍 blocked:runtime_durable_adapter_auth_blocked"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function handleLocalAgentFixtureApi({ request, response, url, options = {} }) {
|
||||
if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) {
|
||||
jsonResponse(response, 200, {
|
||||
@@ -3715,6 +4086,160 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function inspectM3StatusFixtureViewport(browser, url, viewport) {
|
||||
const page = await browser.newPage({ viewport: { width: viewport.width, height: viewport.height } });
|
||||
const requests = [];
|
||||
try {
|
||||
page.on("request", (request) => {
|
||||
const requestUrl = new URL(request.url());
|
||||
requests.push({
|
||||
method: request.method(),
|
||||
pathname: requestUrl.pathname,
|
||||
href: request.url()
|
||||
});
|
||||
});
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
await page.waitForFunction(() => document.querySelector("#hardware-source-status")?.textContent?.trim() === "实况受阻", null, { timeout: 8000 });
|
||||
for (const tab of ["gateways", "box1", "box2", "patch", "overview"]) {
|
||||
await page.locator(`[data-hardware-tab="${tab}"]`).click();
|
||||
}
|
||||
const dom = await page.evaluate(() => {
|
||||
const text = document.body.textContent ?? "";
|
||||
const hardwareText = document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, " ").trim() ?? "";
|
||||
const right = document.querySelector(".right-sidebar");
|
||||
const hardwareList = document.querySelector("#hardware-list");
|
||||
const tabs = [...document.querySelectorAll("[data-hardware-tab]")];
|
||||
const tabRows = new Set(tabs.map((tab) => Math.round(tab.getBoundingClientRect().top))).size;
|
||||
const tabLabels = tabs.map((tab) => tab.textContent?.trim() ?? "");
|
||||
const visible = (selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) return false;
|
||||
const box = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return box.width > 0 && box.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||||
};
|
||||
return {
|
||||
hardwareStatus: document.querySelector("#hardware-source-status")?.textContent?.trim() ?? "",
|
||||
liveDetail: document.querySelector("#live-detail")?.textContent?.trim() ?? "",
|
||||
hardwareText,
|
||||
tabLabels,
|
||||
tabRows,
|
||||
rightWidth: right?.getBoundingClientRect().width ?? 0,
|
||||
rightScrollWidth: right?.scrollWidth ?? 0,
|
||||
rightClientWidth: right?.clientWidth ?? 0,
|
||||
hardwareScrollWidth: hardwareList?.scrollWidth ?? 0,
|
||||
hardwareClientWidth: hardwareList?.clientWidth ?? 0,
|
||||
htmlScrollWidth: document.documentElement.scrollWidth,
|
||||
htmlClientWidth: document.documentElement.clientWidth,
|
||||
bodyScrollWidth: document.body.scrollWidth,
|
||||
bodyClientWidth: document.body.clientWidth,
|
||||
outerScrollLocked:
|
||||
getComputedStyle(document.documentElement).overflow === "hidden" &&
|
||||
getComputedStyle(document.body).overflow === "hidden" &&
|
||||
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
|
||||
document.body.scrollHeight <= document.body.clientHeight + 2,
|
||||
keysVisible: ["状态", "Gateway 在线", "BOX 在线", "DO1", "DI1", "runtime durable", "blocker"].every((term) => hardwareText.includes(term)),
|
||||
tabsVisible: ["总览", "Gateway-SIMU", "BOX-SIMU-1", "BOX-SIMU-2", "Patch Panel"].every((term) => tabLabels.includes(term)),
|
||||
blockedVisible: text.includes("runtime_durable_adapter_auth_blocked") && !text.includes("实况可信"),
|
||||
noHorizontalOverflow:
|
||||
document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 &&
|
||||
document.body.scrollWidth <= document.body.clientWidth + 2 &&
|
||||
(!right || right.scrollWidth <= right.clientWidth + 2) &&
|
||||
(!hardwareList || hardwareList.scrollWidth <= hardwareList.clientWidth + 2),
|
||||
boxTabClickable: visible('[data-hardware-tab="box1"]') && visible('[data-hardware-tab="box2"]')
|
||||
};
|
||||
});
|
||||
const directRuntimeRequests = requests.filter((request) => /gateway-simu|box-simu|patch-panel|:7101|:7201|:7301/iu.test(request.href));
|
||||
const pass =
|
||||
dom.hardwareStatus === "实况受阻" &&
|
||||
dom.tabsVisible &&
|
||||
dom.keysVisible &&
|
||||
dom.blockedVisible &&
|
||||
dom.outerScrollLocked &&
|
||||
dom.noHorizontalOverflow &&
|
||||
dom.boxTabClickable &&
|
||||
dom.rightWidth >= viewport.expectedRightWidthMin &&
|
||||
requests.some((request) => request.method === "GET" && request.pathname === "/v1/m3/status") &&
|
||||
directRuntimeRequests.length === 0;
|
||||
return {
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
pass,
|
||||
...dom,
|
||||
statusRequests: requests.filter((request) => request.pathname === "/v1/m3/status").length,
|
||||
directRuntimeRequests
|
||||
};
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectM3StatusFixtureRefresh(browser, url) {
|
||||
const page = await browser.newPage({ viewport: { width: 1366, height: 768 } });
|
||||
const requests = [];
|
||||
try {
|
||||
page.on("request", (request) => {
|
||||
const requestUrl = new URL(request.url());
|
||||
requests.push({
|
||||
method: request.method(),
|
||||
pathname: requestUrl.pathname,
|
||||
href: request.url()
|
||||
});
|
||||
});
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
await page.waitForFunction(() => document.querySelector("#hardware-source-status")?.textContent?.trim() === "实况受阻", null, { timeout: 8000 });
|
||||
await page.locator('[data-hardware-tab="box1"]').click();
|
||||
const initialBox1 = await hardwarePanelText(page);
|
||||
await page.locator("#m3-value-select").selectOption("true");
|
||||
await page.locator("#m3-write-do").click();
|
||||
await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DO1true"), null, { timeout: 8000 });
|
||||
const trueBox1 = await hardwarePanelText(page);
|
||||
await page.locator('[data-hardware-tab="box2"]').click();
|
||||
await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DI1true"), null, { timeout: 8000 });
|
||||
const trueBox2 = await hardwarePanelText(page);
|
||||
await page.locator("#m3-value-select").selectOption("false");
|
||||
await page.locator("#m3-write-do").click();
|
||||
await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DI1false"), null, { timeout: 8000 });
|
||||
await page.locator('[data-hardware-tab="box1"]').click();
|
||||
await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DO1false"), null, { timeout: 8000 });
|
||||
const falseBox1 = await hardwarePanelText(page);
|
||||
await page.locator('[data-hardware-tab="box2"]').click();
|
||||
await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DI1false"), null, { timeout: 8000 });
|
||||
const falseBox2 = await hardwarePanelText(page);
|
||||
const statusRequests = requests.filter((request) => request.method === "GET" && request.pathname === "/v1/m3/status").length;
|
||||
const ioPosts = requests.filter((request) => request.method === "POST" && request.pathname === "/v1/m3/io").length;
|
||||
const directRuntimeRequests = requests.filter((request) => /gateway-simu|box-simu|patch-panel|:7101|:7201|:7301/iu.test(request.href));
|
||||
const blockedStillVisible = await page.evaluate(() => document.body.textContent?.includes("runtime_durable_adapter_auth_blocked") === true && !document.body.textContent?.includes("实况可信"));
|
||||
const pass =
|
||||
initialBox1.includes("DO1false") &&
|
||||
trueBox1.includes("DO1true") &&
|
||||
trueBox2.includes("DI1true") &&
|
||||
falseBox1.includes("DO1false") &&
|
||||
falseBox2.includes("DI1false") &&
|
||||
statusRequests >= 3 &&
|
||||
ioPosts === 2 &&
|
||||
directRuntimeRequests.length === 0 &&
|
||||
blockedStillVisible;
|
||||
return {
|
||||
pass,
|
||||
initialBox1,
|
||||
trueBox1,
|
||||
trueBox2,
|
||||
falseBox1,
|
||||
falseBox2,
|
||||
statusRequests,
|
||||
ioPosts,
|
||||
directRuntimeRequests,
|
||||
blockedStillVisible
|
||||
};
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function hardwarePanelText(page) {
|
||||
return page.evaluate(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, " ").trim() ?? "");
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
const normalized = Number.isFinite(Number(ms)) && Number(ms) > 0 ? Number(ms) : 0;
|
||||
return new Promise((resolve) => setTimeout(resolve, normalized));
|
||||
@@ -4029,7 +4554,12 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) {
|
||||
["#tab-wiring", "M3 接线标签"],
|
||||
["#tab-records", "可信记录标签"],
|
||||
["#m3-write-do", "写入 DO1"],
|
||||
["#m3-read-di", "读取 DI1"]
|
||||
["#m3-read-di", "读取 DI1"],
|
||||
["[data-hardware-tab='overview']", "硬件总览标签"],
|
||||
["[data-hardware-tab='gateways']", "Gateway-SIMU 标签"],
|
||||
["[data-hardware-tab='box1']", "BOX-SIMU-1 标签"],
|
||||
["[data-hardware-tab='box2']", "BOX-SIMU-2 标签"],
|
||||
["[data-hardware-tab='patch']", "Patch Panel 标签"]
|
||||
];
|
||||
const overflowSelectors = [
|
||||
"body",
|
||||
@@ -4074,10 +4604,11 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) {
|
||||
visibility: style.visibility,
|
||||
overflow: style.overflow,
|
||||
overflowY: style.overflowY,
|
||||
overflowX: style.overflowX,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientWidth: element.clientWidth,
|
||||
clientHeight: element.clientHeight,
|
||||
scrollWidth: element.scrollWidth,
|
||||
scrollHeight: element.scrollHeight,
|
||||
text: element.textContent?.replace(/\s+/gu, " ").trim().slice(0, 120) ?? ""
|
||||
};
|
||||
};
|
||||
@@ -4211,6 +4742,8 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) {
|
||||
|
||||
const shell = document.querySelector("[data-app-shell]");
|
||||
const toggleElement = document.querySelector("#explorer-toggle");
|
||||
const isDesktop = viewport.width >= 861;
|
||||
const isMobile = !isDesktop;
|
||||
const boxes = {
|
||||
shell: boxFor("[data-app-shell]"),
|
||||
rail: boxFor(".activity-rail"),
|
||||
@@ -4228,6 +4761,26 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) {
|
||||
controlList: boxFor("#control-list"),
|
||||
topbar: boxFor(".topbar")
|
||||
};
|
||||
const noHorizontalOverflow = {
|
||||
html: document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2,
|
||||
body: document.body.scrollWidth <= document.body.clientWidth + 2,
|
||||
shell: boxes.shell ? boxes.shell.scrollWidth <= boxes.shell.clientWidth + 2 : false,
|
||||
right: boxes.right ? boxes.right.scrollWidth <= boxes.right.clientWidth + 2 : false,
|
||||
hardwareList: (() => {
|
||||
const element = document.querySelector("#hardware-list");
|
||||
return element ? element.scrollWidth <= element.clientWidth + 2 : false;
|
||||
})()
|
||||
};
|
||||
const hardwareTabRows = (() => {
|
||||
const tabs = [...document.querySelectorAll("[data-hardware-tab]")];
|
||||
return new Set(tabs.map((tab) => Math.round(tab.getBoundingClientRect().top))).size;
|
||||
})();
|
||||
const rightPanelStacked = Boolean(boxes.right && boxes.center && boxes.right.top >= boxes.center.bottom - 2);
|
||||
const rightWidthTarget = isDesktop
|
||||
? rightPanelStacked
|
||||
? boxes.right?.width >= 560 && boxes.right?.width <= viewport.width
|
||||
: boxes.right?.width >= (viewport.width >= 1500 ? 700 : 560) && boxes.right?.width <= 760
|
||||
: boxes.right?.width <= viewport.width && boxes.right?.width >= Math.max(0, viewport.width - 80);
|
||||
const rootAfterScrollAttempt = {
|
||||
windowScrollY: window.scrollY,
|
||||
htmlScrollTop: document.documentElement.scrollTop,
|
||||
@@ -4259,8 +4812,6 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) {
|
||||
boxes.explorer.width > 1 &&
|
||||
boxes.explorer.height > 1
|
||||
);
|
||||
const isDesktop = viewport.width >= 861;
|
||||
const isMobile = !isDesktop;
|
||||
const requireKeyTargets = !(isMobile && mode === "mobile-drawer");
|
||||
if (requireKeyTargets) {
|
||||
for (const target of blockedKeyTargets) {
|
||||
@@ -4564,6 +5115,16 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) {
|
||||
sidePanelScroll
|
||||
});
|
||||
}
|
||||
if (!rightWidthTarget) {
|
||||
failures.push({
|
||||
failureType: "overflow",
|
||||
selector: ".right-sidebar",
|
||||
viewport,
|
||||
summary: "M3 hardware status workspace width is outside the expected desktop, stacked desktop, or mobile boundary.",
|
||||
rightPanelStacked,
|
||||
rightBox: boxes.right
|
||||
});
|
||||
}
|
||||
const issue287HardwareTabsReady = ["总览", "Gateway-SIMU", "BOX-SIMU-1", "BOX-SIMU-2", "Patch Panel"].every((label) =>
|
||||
Boolean([...document.querySelectorAll(".hardware-status button, .hardware-status [role='tab'], .hardware-status .side-tab")].find((item) => item.textContent?.includes(label)))
|
||||
);
|
||||
@@ -4580,6 +5141,8 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) {
|
||||
noIncoherentOverlap &&
|
||||
semanticOverlapChecks.every((check) => !check.overlaps) &&
|
||||
overflowOk &&
|
||||
Object.values(noHorizontalOverflow).every(Boolean) &&
|
||||
rightWidthTarget &&
|
||||
keyTargetsRequirement &&
|
||||
collapsedStateMatches &&
|
||||
explorerStateMatches &&
|
||||
@@ -4606,7 +5169,12 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) {
|
||||
rightWidthDelta,
|
||||
collapsedWidthGain,
|
||||
restoredWidth,
|
||||
rightPanelStacked,
|
||||
overlapChecks,
|
||||
noHorizontalOverflow,
|
||||
horizontalOverflowFree: Object.values(noHorizontalOverflow).every(Boolean),
|
||||
hardwareTabRows,
|
||||
rightWidthTarget,
|
||||
noIncoherentOverlap,
|
||||
keyTargetsReachable,
|
||||
keyTargetsRequirement,
|
||||
|
||||
@@ -333,6 +333,7 @@ export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "",
|
||||
"/v1",
|
||||
"/v1/agent/chat",
|
||||
"/v1/m3/io",
|
||||
"/v1/m3/status",
|
||||
"/json-rpc"
|
||||
]);
|
||||
|
||||
@@ -361,8 +362,8 @@ export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "",
|
||||
if (!/fetchJson\("\/v1\/m3\/io"/u.test(actionBody)) {
|
||||
issues.push("runM3IoAction must POST through same-origin /v1/m3/io");
|
||||
}
|
||||
if (/fetchJson\(["'`](?!\/v1\/m3\/io)/u.test(actionBody) || /\bfetch\(/u.test(actionBody)) {
|
||||
issues.push("runM3IoAction must not call any path other than fetchJson(\"/v1/m3/io\")");
|
||||
if (/fetchJson\(["'`](?!\/v1\/m3\/(?:io|status))/u.test(actionBody) || /\bfetch\(/u.test(actionBody)) {
|
||||
issues.push("runM3IoAction must not call any path other than fetchJson(\"/v1/m3/io\") or fetchJson(\"/v1/m3/status\")");
|
||||
}
|
||||
if (!/if \(!m3ControlCanOperate\(\)\)/u.test(actionBody)) {
|
||||
issues.push("runM3IoAction must fail closed before POST when M3 readiness is blocked");
|
||||
@@ -381,6 +382,9 @@ export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "",
|
||||
if (!/url\.pathname === "\/v1\/m3\/io"/u.test(artifactPublisherSource)) {
|
||||
issues.push("cloud-web artifact server must proxy same-origin /v1/m3/io to cloud-api");
|
||||
}
|
||||
if (!/\/v1\/m3\/status/u.test(artifactPublisherSource)) {
|
||||
issues.push("cloud-web artifact server must proxy same-origin /v1/m3/status to cloud-api");
|
||||
}
|
||||
|
||||
return {
|
||||
status: issues.length === 0 ? "pass" : "fail",
|
||||
|
||||
+349
-118
@@ -37,6 +37,7 @@ const STATUS_LABELS = Object.freeze({
|
||||
sent: "已发送",
|
||||
requires: "依赖",
|
||||
source: "来源",
|
||||
unverified: "未验证",
|
||||
"dry-run": "演练",
|
||||
"dev-live": "实况已验证"
|
||||
});
|
||||
@@ -111,6 +112,8 @@ const state = {
|
||||
liveSurface: null,
|
||||
m3Control: {
|
||||
contract: null,
|
||||
status: null,
|
||||
hardwareTab: "overview",
|
||||
operation: null,
|
||||
pending: false
|
||||
}
|
||||
@@ -120,6 +123,7 @@ initRoutes();
|
||||
initExplorerToggle();
|
||||
syncMobileExplorer();
|
||||
initSideTabs();
|
||||
initHardwareTabs();
|
||||
initQuickActions();
|
||||
initCommandBar();
|
||||
initM3Control();
|
||||
@@ -277,6 +281,25 @@ function selectSideTab(tabId) {
|
||||
}
|
||||
}
|
||||
|
||||
function initHardwareTabs() {
|
||||
for (const tab of document.querySelectorAll("[data-hardware-tab]")) {
|
||||
tab.addEventListener("click", () => {
|
||||
state.m3Control.hardwareTab = tab.dataset.hardwareTab;
|
||||
syncHardwareTabs();
|
||||
renderHardwareStatus(state.m3Control.status);
|
||||
});
|
||||
}
|
||||
syncHardwareTabs();
|
||||
}
|
||||
|
||||
function syncHardwareTabs() {
|
||||
for (const tab of document.querySelectorAll("[data-hardware-tab]")) {
|
||||
const active = tab.dataset.hardwareTab === state.m3Control.hardwareTab;
|
||||
tab.classList.toggle("active", active);
|
||||
tab.setAttribute("aria-selected", active ? "true" : "false");
|
||||
}
|
||||
}
|
||||
|
||||
function initCommandBar() {
|
||||
el.commandForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -466,10 +489,11 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI
|
||||
|
||||
async function loadLiveSurface() {
|
||||
const projectId = gateSummary.topology.projectId;
|
||||
const [healthLive, restIndex, m3Control, health, adapter, audit, evidence] = await Promise.all([
|
||||
const [healthLive, restIndex, m3Control, m3Status, health, adapter, audit, evidence] = await Promise.all([
|
||||
fetchJson("/health/live"),
|
||||
fetchJson("/v1"),
|
||||
fetchJson("/v1/m3/io"),
|
||||
fetchJson("/v1/m3/status"),
|
||||
callRpc("system.health"),
|
||||
callRpc("cloud.adapter.describe"),
|
||||
callRpc("audit.event.query", { projectId, limit: 6 }),
|
||||
@@ -481,6 +505,7 @@ async function loadLiveSurface() {
|
||||
healthLive,
|
||||
restIndex,
|
||||
m3Control,
|
||||
m3Status,
|
||||
health,
|
||||
adapter,
|
||||
audit,
|
||||
@@ -666,10 +691,14 @@ async function runM3IoAction(action) {
|
||||
sourceKind: response.data?.evidenceState?.status === "green" ? "DEV-LIVE" : "BLOCKED"
|
||||
};
|
||||
}
|
||||
const refreshedStatus = await fetchJson("/v1/m3/status");
|
||||
if (refreshedStatus.ok) {
|
||||
state.m3Control.status = refreshedStatus.data;
|
||||
}
|
||||
state.m3Control.pending = false;
|
||||
renderM3ControlStatus();
|
||||
renderHardwareStatus(runtimeSummaryFrom(state.liveSurface));
|
||||
renderWiringList(runtimeSummaryFrom(state.liveSurface));
|
||||
renderHardwareStatus(state.m3Control.status);
|
||||
renderWiringList(state.m3Control.status);
|
||||
renderRecords(state.liveSurface);
|
||||
renderDrafts();
|
||||
}
|
||||
@@ -682,6 +711,7 @@ function nextProtocolId(prefix) {
|
||||
function renderLiveSurface(live) {
|
||||
state.liveSurface = live;
|
||||
state.m3Control.contract = live.m3Control?.ok ? live.m3Control.data : null;
|
||||
state.m3Control.status = live.m3Status?.ok ? live.m3Status.data : null;
|
||||
const coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter];
|
||||
const surfaceStatus = workbenchApiSurfaceStatus(live, coreProbes);
|
||||
const runtimeSummary = runtimeSummaryFrom(live);
|
||||
@@ -693,8 +723,8 @@ function renderLiveSurface(live) {
|
||||
el.liveDetail.textContent = surfaceStatus.detail ?? codeAgentAvailabilitySummary();
|
||||
|
||||
renderAgentChatStatus(deriveAgentChatStatus());
|
||||
renderHardwareStatus(runtimeSummary);
|
||||
renderWiringList(runtimeSummary);
|
||||
renderHardwareStatus(state.m3Control.status);
|
||||
renderWiringList(state.m3Control.status ?? runtimeSummary);
|
||||
renderRecords(live);
|
||||
renderDiagnostics(live);
|
||||
renderMethodList(methods.length > 0 ? methods.filter((method) => rpcReadMethods.includes(method)) : rpcReadMethods, surfaceStatus.methodTone);
|
||||
@@ -719,16 +749,16 @@ function workbenchApiSurfaceStatus(live, coreProbes = [live.healthLive, live.res
|
||||
label: "可信记录受阻 / 只读模式",
|
||||
tone: "blocked",
|
||||
methodTone: "degraded",
|
||||
detail: "同源 API 可响应,但可信记录持久化尚未就绪;当前只保留只读查看和本地任务整理。"
|
||||
detail: apiDurableBlockerDetail(coreProbes)
|
||||
};
|
||||
}
|
||||
|
||||
if (surface.degraded) {
|
||||
return {
|
||||
label: "API 降级 / 只读模式",
|
||||
tone: "degraded",
|
||||
label: "API 错误 / 只读模式",
|
||||
tone: "blocked",
|
||||
methodTone: "degraded",
|
||||
detail: "同源 API 可响应但处于降级状态;当前保持只读查看和本地任务整理。"
|
||||
detail: apiProbeErrorDetail(coreProbes)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -740,6 +770,33 @@ function workbenchApiSurfaceStatus(live, coreProbes = [live.healthLive, live.res
|
||||
};
|
||||
}
|
||||
|
||||
function apiDurableBlockerDetail(coreProbes = []) {
|
||||
const payloads = coreProbes
|
||||
.filter((probe) => probe?.ok)
|
||||
.map((probe) => probe.data)
|
||||
.filter((payload) => payload && typeof payload === "object");
|
||||
const blocker = safeDurableBlockerLabel(payloads.map(durableRuntimeBlockerCode).find(Boolean));
|
||||
return `/health/live 可响应,但可信记录持久化尚未就绪:${blocker};当前只保留只读查看和本地任务整理。`;
|
||||
}
|
||||
|
||||
function safeDurableBlockerLabel(blocker) {
|
||||
const normalized = normalizedStatusValue(blocker);
|
||||
if (normalized.includes("auth")) return "runtime durable adapter auth blocked";
|
||||
if (normalized.includes("schema")) return "runtime durable adapter schema blocked";
|
||||
if (normalized.includes("migration")) return "runtime durable adapter migration blocked";
|
||||
if (normalized.includes("ssl")) return "runtime durable adapter ssl blocked";
|
||||
if (normalized.includes("query")) return "runtime durable adapter query blocked";
|
||||
return "runtime durable adapter blocked";
|
||||
}
|
||||
|
||||
function apiProbeErrorDetail(coreProbes = []) {
|
||||
const failedProbe = coreProbes.find((probe) => probe && probe.ok !== true);
|
||||
if (failedProbe?.error?.message) {
|
||||
return `同源 API 探测失败:${failedProbe.error.message};当前保持只读查看和本地任务整理。`;
|
||||
}
|
||||
return "同源 API 返回 blocked/degraded/failed 或 ready=false;当前保持只读查看和本地任务整理。";
|
||||
}
|
||||
|
||||
function workbenchSurfaceReadiness(live, coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter]) {
|
||||
const payloads = coreProbes
|
||||
.filter((probe) => probe?.ok)
|
||||
@@ -775,6 +832,7 @@ function readinessBooleanValues(payload) {
|
||||
}
|
||||
|
||||
function hasDurableRuntimeQueryBlock(payload) {
|
||||
const legacyQueryBlockerCode = "runtime_durable_adapter_query_blocked";
|
||||
const codes = [
|
||||
...(Array.isArray(payload.blockerCodes) ? payload.blockerCodes : []),
|
||||
...(Array.isArray(payload.readiness?.blockerCodes) ? payload.readiness.blockerCodes : []),
|
||||
@@ -786,12 +844,34 @@ function hasDurableRuntimeQueryBlock(payload) {
|
||||
const queryResult = normalizedStatusValue(payload.runtime?.connection?.queryResult ?? payload.readiness?.durability?.queryResult);
|
||||
const blockedLayer = normalizedStatusValue(payload.runtime?.durabilityContract?.blockedLayer ?? payload.readiness?.durability?.blockedLayer);
|
||||
return (
|
||||
codes.includes("runtime_durable_adapter_query_blocked") ||
|
||||
queryResult === "query_blocked" ||
|
||||
blockedLayer === "durability_query"
|
||||
codes.includes(legacyQueryBlockerCode) ||
|
||||
codes.some((code) => code.startsWith("runtime_durable_adapter_") && code.endsWith("_blocked")) ||
|
||||
queryResult.endsWith("_blocked") ||
|
||||
blockedLayer === "durability_query" ||
|
||||
blockedLayer === "durability_auth" ||
|
||||
blockedLayer === "durability_schema" ||
|
||||
blockedLayer === "durability_migration" ||
|
||||
blockedLayer === "durability_ssl"
|
||||
);
|
||||
}
|
||||
|
||||
function durableRuntimeBlockerCode(payload) {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const candidates = [
|
||||
...(Array.isArray(payload.blockerCodes) ? payload.blockerCodes : []),
|
||||
...(Array.isArray(payload.readiness?.blockerCodes) ? payload.readiness.blockerCodes : []),
|
||||
...(Array.isArray(payload.blockers) ? payload.blockers.map((item) => item?.code) : []),
|
||||
...(Array.isArray(payload.readiness?.blockers) ? payload.readiness.blockers.map((item) => item?.code) : []),
|
||||
payload.runtime?.blocker,
|
||||
payload.readiness?.durability?.blocker,
|
||||
payload.runtime?.connection?.queryResult,
|
||||
payload.readiness?.durability?.queryResult,
|
||||
payload.runtime?.durabilityContract?.blockedLayer,
|
||||
payload.readiness?.durability?.blockedLayer
|
||||
];
|
||||
return candidates.find((value) => normalizedStatusValue(value).includes("durable") || normalizedStatusValue(value).endsWith("_blocked")) ?? null;
|
||||
}
|
||||
|
||||
function normalizedStatusValue(value) {
|
||||
return String(value ?? "").trim().toLowerCase();
|
||||
}
|
||||
@@ -948,108 +1028,31 @@ function renderUnblockList() {
|
||||
replaceChildren(el.unblockList, ...rows.map(infoCard));
|
||||
}
|
||||
|
||||
function renderHardwareStatus(summary) {
|
||||
const counts = summary?.counts ?? null;
|
||||
const patchPanel = gateSummary.topology.patchPanel;
|
||||
const sourceLink = trustedM3Link(patchPanel.activeConnections);
|
||||
const liveM3Evidence = trustedM3LiveEvidenceFrom(summary) ?? m3ControlLiveEvidence();
|
||||
const m3Live = hasTrustedM3LiveEvidence(liveM3Evidence);
|
||||
const controlReady = m3ControlCanOperate();
|
||||
const runtimeCountsSourceKind = "SOURCE";
|
||||
const runtimeCountsTone = "source";
|
||||
const patchTone = m3Live ? "live" : patchPanel.state === "active" ? "source" : patchPanel.state;
|
||||
const linkTone = m3Live ? "live" : sourceLink || patchPanel.activeConnectionCount > 0 ? "degraded" : "blocked";
|
||||
function renderHardwareStatus(m3Status) {
|
||||
syncHardwareTabs();
|
||||
const status = m3Status ?? sourceFallbackM3Status();
|
||||
const sourceKind = status.sourceKind ?? "UNVERIFIED";
|
||||
const trustedGreen = status.trust?.durableStatus === "green";
|
||||
const statusTone = status.status === "live" && trustedGreen
|
||||
? "dev-live"
|
||||
: status.status === "error" || status.status === "blocked"
|
||||
? "blocked"
|
||||
: sourceKind === "DEV-LIVE"
|
||||
? "dev-live"
|
||||
: "source";
|
||||
|
||||
el.hardwareSourceStatus.textContent = m3Live ? "实况已验证" : "只读核对";
|
||||
el.hardwareSourceStatus.className = `state-tag tone-${toneClass(m3Live ? "dev-live" : "source")}`;
|
||||
el.hardwareSourceStatus.textContent = hardwareStatusLabel(status);
|
||||
el.hardwareSourceStatus.className = `state-tag tone-${toneClass(statusTone)}`;
|
||||
|
||||
replaceChildren(
|
||||
el.hardwareList,
|
||||
hardwareGroup({
|
||||
title: "Gateway/Box 在线",
|
||||
rows: [
|
||||
{
|
||||
label: "Gateway-SIMU",
|
||||
value: counts ? `${counts.gatewaySessions ?? 0} 会话` : "离线查看",
|
||||
detail: counts
|
||||
? `${counts.gatewaySessions ?? 0} 个 Gateway 会话来自只读运行计数;仅用于资源核对。`
|
||||
: `${gateSummary.gatewaySessionCount} 个 Gateway 会话来自内置拓扑。`,
|
||||
sourceKind: runtimeCountsSourceKind,
|
||||
tone: runtimeCountsTone
|
||||
},
|
||||
{
|
||||
label: "BOX-SIMU",
|
||||
value: counts ? `${counts.boxResources ?? 0} 资源` : "离线查看",
|
||||
detail: counts
|
||||
? `${counts.boxResources ?? 0} 个 BOX 资源来自只读运行计数;仅用于资源核对。`
|
||||
: `${gateSummary.boxResourceCount} 个 BOX 资源来自内置拓扑。`,
|
||||
sourceKind: runtimeCountsSourceKind,
|
||||
tone: runtimeCountsTone
|
||||
}
|
||||
]
|
||||
}),
|
||||
hardwareGroup({
|
||||
title: "BOX-SIMU 端口",
|
||||
rows: [
|
||||
{
|
||||
label: "DO1",
|
||||
value: m3Live ? "已验证" : "待接线",
|
||||
detail: m3Live ? m3LiveDetail(liveM3Evidence) : "尚未看到 DO1 电平的完整可信记录。",
|
||||
sourceKind: m3Live ? "DEV-LIVE" : "BLOCKED",
|
||||
tone: m3Live ? "live" : "blocked"
|
||||
},
|
||||
{
|
||||
label: "DI1",
|
||||
value: m3Live ? "已验证" : "待回读",
|
||||
detail: m3Live ? m3LiveDetail(liveM3Evidence) : "尚未看到 DI1 回读的完整可信记录。",
|
||||
sourceKind: m3Live ? "DEV-LIVE" : "BLOCKED",
|
||||
tone: m3Live ? "live" : "blocked"
|
||||
},
|
||||
{
|
||||
label: "AI/AO/FREQ",
|
||||
value: "后续能力",
|
||||
detail: "当前未声明模拟量或频率闭环,按后续能力处理。",
|
||||
sourceKind: "SOURCE",
|
||||
tone: "disabled"
|
||||
}
|
||||
]
|
||||
}),
|
||||
hardwareGroup({
|
||||
title: "Patch Panel 连线",
|
||||
rows: [
|
||||
{
|
||||
label: "hwlab-patch-panel",
|
||||
value: statusLabel(patchPanel.state),
|
||||
detail: m3Live
|
||||
? `接线盘实况记录 ${liveM3Evidence.patchPanelLiveReport.reportId ?? liveM3Evidence.patchPanelLiveReport.patchPanelStatusId} 已绑定目标链路。`
|
||||
: `${patchPanel.activeConnectionCount} 条内置接线记录;工作台只展示目标链路,未升级为可信闭环证据。`,
|
||||
sourceKind: m3Live ? "DEV-LIVE" : "SOURCE",
|
||||
tone: patchTone
|
||||
},
|
||||
{
|
||||
label: "DO1 -> patch-panel -> DI1",
|
||||
value: linkStatusLabel(linkTone),
|
||||
detail: m3LinkDetail({ liveM3Evidence, sourceLink, patchPanel, counts }),
|
||||
sourceKind: m3Live ? "DEV-LIVE" : "BLOCKED",
|
||||
tone: linkTone
|
||||
}
|
||||
]
|
||||
}),
|
||||
hardwareGroup({
|
||||
title: "写入边界",
|
||||
rows: [
|
||||
{
|
||||
label: "硬件控制",
|
||||
value: controlReady ? "受控路径" : "受控路径受阻",
|
||||
detail: controlReady
|
||||
? "控制只通过受控后端执行,并经由 gateway-simu、box-simu 与 patch-panel;页面不直连模拟器,也不伪造硬件状态。"
|
||||
: `${m3ControlBlockedReason()} 页面不直连模拟器,也不伪造硬件状态。`,
|
||||
sourceKind: controlReady ? "SOURCE" : "BLOCKED",
|
||||
tone: controlReady ? "source" : "blocked"
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
const groups = {
|
||||
overview: hardwareOverviewGroups(status),
|
||||
gateways: hardwareGatewayGroups(status),
|
||||
box1: hardwareBoxGroups(status, M3_TRUSTED_ROUTE.fromResourceId),
|
||||
box2: hardwareBoxGroups(status, M3_TRUSTED_ROUTE.toResourceId),
|
||||
patch: hardwarePatchPanelGroups(status)
|
||||
};
|
||||
// Tab bodies are compact Key/Value groups; SOURCE fallback stays unverified.
|
||||
replaceChildren(el.hardwareList, ...(groups[state.m3Control.hardwareTab] ?? groups.overview));
|
||||
}
|
||||
|
||||
function hardwareGroup(group) {
|
||||
@@ -1076,6 +1079,230 @@ function hardwareRow(item) {
|
||||
return row;
|
||||
}
|
||||
|
||||
function hardwareStatusLabel(status) {
|
||||
if (!status) return "未验证";
|
||||
if (status.status === "live" && status.trust?.durableStatus === "green") return "实况可信";
|
||||
if (status.status === "error") return "错误";
|
||||
if (status.status === "blocked") return "实况受阻";
|
||||
if (status.sourceKind === "DEV-LIVE") return "DEV-LIVE 状态";
|
||||
return "未验证";
|
||||
}
|
||||
|
||||
function sourceFallbackM3Status() {
|
||||
const observedAt = new Date().toISOString();
|
||||
return {
|
||||
status: "unverified",
|
||||
sourceKind: "SOURCE",
|
||||
observedAt,
|
||||
chain: {
|
||||
sourceGatewayId: "gwsimu_1",
|
||||
sourceGatewaySessionId: "gws_gwsimu_1",
|
||||
sourceResourceId: M3_TRUSTED_ROUTE.fromResourceId,
|
||||
sourceBoxId: "boxsimu_1",
|
||||
sourcePort: M3_TRUSTED_ROUTE.fromPort,
|
||||
targetGatewayId: "gwsimu_2",
|
||||
targetGatewaySessionId: "gws_gwsimu_2",
|
||||
targetResourceId: M3_TRUSTED_ROUTE.toResourceId,
|
||||
targetBoxId: "boxsimu_2",
|
||||
targetPort: M3_TRUSTED_ROUTE.toPort,
|
||||
patchPanelServiceId: M3_TRUSTED_ROUTE.patchPanelServiceId
|
||||
},
|
||||
gateways: [
|
||||
{
|
||||
id: "gwsimu_1",
|
||||
role: "source",
|
||||
online: false,
|
||||
sessionId: "gws_gwsimu_1",
|
||||
sourceKind: "SOURCE",
|
||||
lastSeenAt: observedAt,
|
||||
lastError: "等待 cloud-api /v1/m3/status 聚合读取"
|
||||
},
|
||||
{
|
||||
id: "gwsimu_2",
|
||||
role: "target",
|
||||
online: false,
|
||||
sessionId: "gws_gwsimu_2",
|
||||
sourceKind: "SOURCE",
|
||||
lastSeenAt: observedAt,
|
||||
lastError: "等待 cloud-api /v1/m3/status 聚合读取"
|
||||
}
|
||||
],
|
||||
boxes: [
|
||||
sourceFallbackBox("boxsimu_1", M3_TRUSTED_ROUTE.fromResourceId, "gwsimu_1", "gws_gwsimu_1", "DO1", "output", observedAt),
|
||||
sourceFallbackBox("boxsimu_2", M3_TRUSTED_ROUTE.toResourceId, "gwsimu_2", "gws_gwsimu_2", "DI1", "input", observedAt)
|
||||
],
|
||||
patchPanel: {
|
||||
serviceId: M3_TRUSTED_ROUTE.patchPanelServiceId,
|
||||
connectionActive: false,
|
||||
connectionVersion: null,
|
||||
wiringConfigId: gateSummary.topology.patchPanel.wiringConfigId ?? null,
|
||||
lastSyncAt: observedAt,
|
||||
lastError: "SOURCE 拓扑 fallback,未验证 DEV-LIVE 接线盘状态",
|
||||
sourceKind: "SOURCE"
|
||||
},
|
||||
trust: {
|
||||
operationId: null,
|
||||
traceId: null,
|
||||
auditId: null,
|
||||
evidenceId: null,
|
||||
durableStatus: "blocked",
|
||||
blocker: "unverified_cloud_api_m3_status"
|
||||
},
|
||||
blocker: {
|
||||
code: "unverified_cloud_api_m3_status",
|
||||
layer: "cloud-api",
|
||||
zh: "等待 cloud-api /v1/m3/status 聚合读取;SOURCE 拓扑不能冒充实况硬件状态。"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sourceFallbackBox(id, resourceId, gatewayId, gatewaySessionId, port, direction, observedAt) {
|
||||
return {
|
||||
id,
|
||||
resourceId,
|
||||
gatewayId,
|
||||
gatewaySessionId,
|
||||
online: false,
|
||||
sourceKind: "SOURCE",
|
||||
observedAt,
|
||||
ports: {
|
||||
[port]: {
|
||||
value: null,
|
||||
direction,
|
||||
source: "SOURCE fallback",
|
||||
sourceKind: "SOURCE",
|
||||
observedAt,
|
||||
lastError: "未验证"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function hardwareOverviewGroups(status) {
|
||||
const box1 = findM3Box(status, M3_TRUSTED_ROUTE.fromResourceId);
|
||||
const box2 = findM3Box(status, M3_TRUSTED_ROUTE.toResourceId);
|
||||
const do1 = box1?.ports?.DO1;
|
||||
const di1 = box2?.ports?.DI1;
|
||||
const patch = status.patchPanel ?? {};
|
||||
const trust = status.trust ?? {};
|
||||
return [
|
||||
hardwareKvGroup("总览", [
|
||||
["状态", hardwareStatusLabel(status), statusTone(status)],
|
||||
["观测时间", status.observedAt ?? "未验证", "source"],
|
||||
["Gateway 在线", `${onlineCount(status.gateways)} / ${status.gateways?.length ?? 0}`, onlineCount(status.gateways) === 2 ? "live" : "blocked"],
|
||||
["BOX 在线", `${onlineCount(status.boxes)} / ${status.boxes?.length ?? 0}`, onlineCount(status.boxes) === 2 ? "live" : "blocked"],
|
||||
["链路", `${M3_TRUSTED_ROUTE.fromResourceId}:${M3_TRUSTED_ROUTE.fromPort} -> ${M3_TRUSTED_ROUTE.patchPanelServiceId} -> ${M3_TRUSTED_ROUTE.toResourceId}:${M3_TRUSTED_ROUTE.toPort}`, patch.connectionActive ? "live" : "blocked"],
|
||||
["DO1", valueLabel(do1), portTone(do1)],
|
||||
["DI1", valueLabel(di1), portTone(di1)],
|
||||
["patchPanel connectionActive", String(Boolean(patch.connectionActive)), patch.connectionActive ? "live" : "blocked"],
|
||||
["runtime durable", trust.durableStatus ?? "blocked", trust.durableStatus === "green" ? "dev-live" : "blocked"],
|
||||
["blocker", trust.blocker ?? status.blocker?.code ?? "none", trust.blocker || status.blocker ? "blocked" : "source"]
|
||||
]),
|
||||
hardwareKvGroup("可信证据", [
|
||||
["operationId", trust.operationId ?? "未持久化", trust.operationId ? "source" : "blocked"],
|
||||
["traceId", trust.traceId ?? "未持久化", trust.traceId ? "source" : "blocked"],
|
||||
["auditId", trust.auditId ?? "未持久化", trust.auditId ? "source" : "blocked"],
|
||||
["evidenceId", trust.evidenceId ?? "未持久化", trust.evidenceId ? "source" : "blocked"]
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
function hardwareGatewayGroups(status) {
|
||||
return (status.gateways ?? []).map((gateway) =>
|
||||
hardwareKvGroup(`${gateway.id} / ${gateway.role}`, [
|
||||
["online", String(Boolean(gateway.online)), gateway.online ? "live" : "blocked"],
|
||||
["sessionId", gateway.sessionId ?? "未验证", gateway.sessionId ? "source" : "blocked"],
|
||||
["lastSeenAt", gateway.lastSeenAt ?? status.observedAt ?? "未验证", gateway.online ? "live" : "source"],
|
||||
["source", gateway.sourceKind ?? "UNVERIFIED", sourceKindTone(gateway.sourceKind)],
|
||||
["lastError", gateway.lastError ?? "none", gateway.lastError ? "blocked" : "source"]
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function hardwareBoxGroups(status, resourceId) {
|
||||
const box = findM3Box(status, resourceId);
|
||||
const portEntries = Object.entries(box?.ports ?? {});
|
||||
return [
|
||||
hardwareKvGroup(box ? `${box.id} / ${box.resourceId}` : resourceId, [
|
||||
["online", String(Boolean(box?.online)), box?.online ? "live" : "blocked"],
|
||||
["gateway", `${box?.gatewayId ?? "未验证"} / ${box?.gatewaySessionId ?? "未验证"}`, box?.gatewayId ? "source" : "blocked"],
|
||||
["更新时间", box?.observedAt ?? status.observedAt ?? "未验证", sourceKindTone(box?.sourceKind)],
|
||||
["来源", box?.sourceKind ?? "UNVERIFIED", sourceKindTone(box?.sourceKind)],
|
||||
...portEntries.map(([port, portState]) => [port, valueLabel(portState), portTone(portState)]),
|
||||
["AI1", "DISABLED 后续能力", "disabled"],
|
||||
["AO1", "DISABLED 后续能力", "disabled"],
|
||||
["FREQ", "DISABLED 后续能力", "disabled"]
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
function hardwarePatchPanelGroups(status) {
|
||||
const patch = status.patchPanel ?? {};
|
||||
return [
|
||||
hardwareKvGroup("hwlab-patch-panel", [
|
||||
["serviceId", patch.serviceId ?? M3_TRUSTED_ROUTE.patchPanelServiceId, "source"],
|
||||
["connectionActive", String(Boolean(patch.connectionActive)), patch.connectionActive ? "live" : "blocked"],
|
||||
["connectionVersion", patch.connectionVersion ?? "未验证", patch.connectionVersion !== null && patch.connectionVersion !== undefined ? "source" : "blocked"],
|
||||
["wiringConfigId", patch.wiringConfigId ?? "未验证", patch.wiringConfigId ? "source" : "blocked"],
|
||||
["lastSyncAt", patch.lastSyncAt ?? status.observedAt ?? "未验证", patch.connectionActive ? "live" : "source"],
|
||||
["lastError", patch.lastError ?? "none", patch.lastError ? "blocked" : "source"],
|
||||
["来源", patch.sourceKind ?? "UNVERIFIED", sourceKindTone(patch.sourceKind)]
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
function hardwareKvGroup(title, rows) {
|
||||
const article = document.createElement("article");
|
||||
article.className = "hardware-section hardware-kv-section";
|
||||
article.append(textSpan(title, "hardware-section-title"));
|
||||
const list = document.createElement("dl");
|
||||
list.className = "kv-list";
|
||||
for (const [key, value, tone = "source"] of rows) {
|
||||
const row = document.createElement("div");
|
||||
row.className = `kv-row tone-border-${toneClass(tone)}`;
|
||||
row.append(textSpan(key, "kv-key"), textSpan(value, `kv-value tone-${toneClass(tone)}`));
|
||||
list.append(row);
|
||||
}
|
||||
article.append(list);
|
||||
return article;
|
||||
}
|
||||
|
||||
function findM3Box(status, resourceId) {
|
||||
return (status?.boxes ?? []).find((box) => box.resourceId === resourceId) ?? null;
|
||||
}
|
||||
|
||||
function onlineCount(items = []) {
|
||||
return items.filter((item) => item?.online === true).length;
|
||||
}
|
||||
|
||||
function valueLabel(portState) {
|
||||
if (!portState) return "未验证";
|
||||
if (portState.value === null || portState.value === undefined) return portState.lastError ? `未验证:${portState.lastError}` : "未验证";
|
||||
return String(portState.value);
|
||||
}
|
||||
|
||||
function portTone(portState) {
|
||||
if (!portState) return "blocked";
|
||||
if (portState.sourceKind === "DEV-LIVE") return "live";
|
||||
if (portState.sourceKind === "SOURCE") return "source";
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
function statusTone(status) {
|
||||
if (status.status === "live") return "dev-live";
|
||||
if (status.status === "error" || status.status === "blocked") return "blocked";
|
||||
if (status.sourceKind === "DEV-LIVE") return "live";
|
||||
return "source";
|
||||
}
|
||||
|
||||
function sourceKindTone(sourceKind) {
|
||||
const normalized = String(sourceKind ?? "").toUpperCase();
|
||||
if (normalized === "DEV-LIVE") return "live";
|
||||
if (normalized === "SOURCE") return "source";
|
||||
if (normalized === "BLOCKED") return "blocked";
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
function trustedM3Link(connections) {
|
||||
return (connections ?? []).find(
|
||||
(link) =>
|
||||
@@ -1316,7 +1543,7 @@ function controlRows() {
|
||||
title: "控制路径",
|
||||
detail: controlReady
|
||||
? "按钮只走受控后端,再通过 gateway-simu -> box-simu -> hwlab-patch-panel 执行 M3 DO/DI。"
|
||||
: `${m3ControlBlockedReason()} 不可用时不会直连 gateway/box-simu,也不会伪造硬件状态。`,
|
||||
: `${m3ControlBlockedReason()} 不可用时页面不直连模拟器,也不伪造硬件状态。`,
|
||||
tone: controlReady ? "source" : "blocked"
|
||||
},
|
||||
...operationCards,
|
||||
@@ -1384,14 +1611,18 @@ function renderDrafts() {
|
||||
}
|
||||
|
||||
function renderWiringList(summary = null) {
|
||||
const patchPanel = gateSummary.topology.patchPanel;
|
||||
const liveM3Evidence = trustedM3LiveEvidenceFrom(summary);
|
||||
const m3Status = summary?.contractVersion === "m3-status-v1" ? summary : null;
|
||||
const liveM3Evidence = m3Status ? null : trustedM3LiveEvidenceFrom(summary);
|
||||
const m3Live = hasTrustedM3LiveEvidence(liveM3Evidence);
|
||||
const connectionActive = m3Status?.patchPanel?.connectionActive === true;
|
||||
const blockedTrust = m3Status?.trust?.blocker;
|
||||
const traceEvidence = m3Live
|
||||
? [liveM3Evidence.operationId, liveM3Evidence.traceId, liveM3Evidence.auditId, liveM3Evidence.evidenceId].join(" / ")
|
||||
: "等待可信记录";
|
||||
const stateLabel = m3Live ? "实况已验证" : "待可信记录";
|
||||
const sourceLabel = m3Live ? "实况可信记录" : "来源拓扑";
|
||||
: m3Status
|
||||
? [m3Status.trust?.operationId, m3Status.trust?.traceId, m3Status.trust?.auditId, m3Status.trust?.evidenceId].filter(Boolean).join(" / ") || `durable blocked: ${blockedTrust ?? "unverified"}`
|
||||
: "等待可信记录";
|
||||
const stateLabel = m3Live ? "实况已验证" : connectionActive ? "接线实况 / 持久化受阻" : "待可信记录";
|
||||
const sourceLabel = m3Live ? "实况可信记录" : m3Status ? "/v1/m3/status 聚合" : "来源拓扑";
|
||||
|
||||
const sourceHeader = byId("wiring-source-device");
|
||||
const targetHeader = byId("wiring-target-device");
|
||||
|
||||
@@ -208,40 +208,26 @@
|
||||
<section class="hardware-status">
|
||||
<div class="panel-title-row">
|
||||
<div>
|
||||
<p class="eyebrow">运行界面</p>
|
||||
<h2>硬件状态:BOX-SIMU / Gateway-SIMU / hwlab-patch-panel</h2>
|
||||
<p class="eyebrow">M3 实况状态</p>
|
||||
<h2>硬件状态工作区</h2>
|
||||
</div>
|
||||
<span class="state-tag tone-source" id="hardware-source-status">只读核对</span>
|
||||
<span class="state-tag tone-source" id="hardware-source-status">未验证</span>
|
||||
</div>
|
||||
<div class="hardware-status-tabs" role="tablist" aria-label="M3 硬件状态">
|
||||
<button class="hardware-tab active" type="button" role="tab" aria-selected="true" data-hardware-tab="overview">总览</button>
|
||||
<button class="hardware-tab" type="button" role="tab" aria-selected="false" data-hardware-tab="gateways">Gateway-SIMU</button>
|
||||
<button class="hardware-tab" type="button" role="tab" aria-selected="false" data-hardware-tab="box1">BOX-SIMU-1</button>
|
||||
<button class="hardware-tab" type="button" role="tab" aria-selected="false" data-hardware-tab="box2">BOX-SIMU-2</button>
|
||||
<button class="hardware-tab" type="button" role="tab" aria-selected="false" data-hardware-tab="patch">Patch Panel</button>
|
||||
</div>
|
||||
<div id="hardware-list" class="hardware-list" data-static-source-fallback="SOURCE">
|
||||
<article class="hardware-section">
|
||||
<span class="hardware-section-title">只读资源概览</span>
|
||||
<div class="hardware-rows">
|
||||
<div class="hardware-row tone-border-source" data-source-kind="SOURCE">
|
||||
<div class="hardware-row-head">
|
||||
<span class="hardware-row-label">Gateway/Box 资源</span>
|
||||
<span class="state-tag tone-source">来源拓扑</span>
|
||||
</div>
|
||||
<span class="hardware-row-value">离线查看</span>
|
||||
<span class="hardware-row-detail">等待同源只读数据覆盖;当前展示内置拓扑摘要。</span>
|
||||
</div>
|
||||
<div class="hardware-row tone-border-source" data-source-kind="SOURCE">
|
||||
<div class="hardware-row-head">
|
||||
<span class="hardware-row-label">hwlab-patch-panel</span>
|
||||
<span class="state-tag tone-source">来源拓扑</span>
|
||||
</div>
|
||||
<span class="hardware-row-value">目标接线</span>
|
||||
<span class="hardware-row-detail">res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 仅为源码兜底显示。</span>
|
||||
</div>
|
||||
<div class="hardware-row tone-border-blocked" data-source-kind="BLOCKED">
|
||||
<div class="hardware-row-head">
|
||||
<span class="hardware-row-label">DO1 -> patch-panel -> DI1</span>
|
||||
<span class="state-tag tone-blocked">待可信记录</span>
|
||||
</div>
|
||||
<span class="hardware-row-value">等待可信记录</span>
|
||||
<span class="hardware-row-detail">尚未看到完整可信记录,不能作为实况闭环证据。</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hardware-section-title">Key / Value</span>
|
||||
<dl class="kv-list">
|
||||
<div><dt>数据入口</dt><dd>等待 cloud-api /v1/m3/status</dd></div>
|
||||
<div><dt>链路</dt><dd>res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1</dd></div>
|
||||
<div><dt>来源</dt><dd>未验证;拓扑资料只可作为 fallback</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -141,11 +141,13 @@ for (const legacyEnglishTitle of [
|
||||
]) {
|
||||
assert.doesNotMatch(`${html}\n${app}`, legacyEnglishTitle);
|
||||
}
|
||||
assert.match(html, /硬件状态:BOX-SIMU \/ Gateway-SIMU \/ hwlab-patch-panel/);
|
||||
assert.match(html, /M3 实况状态/);
|
||||
assert.match(html, /硬件状态工作区/);
|
||||
assert.match(html, /id="hardware-source-status"/);
|
||||
assert.match(app, /function renderHardwareStatus/);
|
||||
assert.match(app, /function hardwareGroup/);
|
||||
assert.match(app, /function hardwareRow/);
|
||||
assert.match(app, /function initHardwareTabs/);
|
||||
assert.match(app, /function hardwareKvGroup/);
|
||||
assert.match(app, /function sourceFallbackM3Status/);
|
||||
assert.match(app, /function trustedM3Link/);
|
||||
assert.match(app, /function hasTrustedM3LiveEvidence/);
|
||||
assert.match(app, /function hasTrustedPatchPanelLiveReport/);
|
||||
@@ -153,9 +155,10 @@ assert.match(app, /function linkStatusLabel/);
|
||||
assert.match(app, /function normalizePort/);
|
||||
assert.match(app, /LIVE_M3_ID_FIELDS/);
|
||||
assert.match(app, /patchPanelLiveReport/);
|
||||
assert.match(app, /runtimeCountsSourceKind = "SOURCE"/);
|
||||
assert.match(functionBody(app, "sourceFallbackM3Status"), /unverified_cloud_api_m3_status/);
|
||||
assert.match(functionBody(app, "sourceFallbackM3Status"), /SOURCE 拓扑不能冒充实况硬件状态/);
|
||||
assert.doesNotMatch(functionBody(app, "renderHardwareStatus"), /hardwareRow\(\s*\{/u);
|
||||
assert.match(functionBody(app, "hardwareGroup"), /group\.rows\.map\(hardwareRow\)/u);
|
||||
assert.match(functionBody(app, "renderHardwareStatus"), /Key\/Value groups/u);
|
||||
assert.doesNotMatch(app, /sourceKind:\s*counts\s*\?\s*"DEV-LIVE"/);
|
||||
assert.doesNotMatch(app, /counts\s*&&\s*(?:liveLink|sourceLink|m3Link)[\s\S]{0,80}\?\s*"DEV-LIVE"/);
|
||||
assert.match(app, /function renderRecords/);
|
||||
@@ -186,28 +189,29 @@ assert.match(app, /DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*150000/);
|
||||
assert.match(app, /async function sendAgentMessage[\s\S]*?timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/);
|
||||
assert.match(functionBody(app, "fetchJson"), /timeoutMs\s*=\s*API_TIMEOUT_MS/);
|
||||
for (const hardwareTerm of [
|
||||
"Gateway/Box 在线",
|
||||
"BOX-SIMU 端口",
|
||||
"Patch Panel 连线",
|
||||
"DO1 -> patch-panel -> DI1",
|
||||
"AI/AO/FREQ",
|
||||
"后续能力",
|
||||
"Gateway 在线",
|
||||
"BOX 在线",
|
||||
"patchPanel connectionActive",
|
||||
"runtime durable",
|
||||
"DISABLED 后续能力",
|
||||
"SOURCE",
|
||||
"DEV-LIVE",
|
||||
"BLOCKED",
|
||||
"只读运行计数;仅用于资源核对",
|
||||
"只读运行计数已返回,但还不能证明接线盘闭环",
|
||||
"unverified_cloud_api_m3_status",
|
||||
"/v1/m3/status",
|
||||
"仍在等待完整可信记录",
|
||||
"页面不直连模拟器,也不伪造硬件状态。"
|
||||
]) {
|
||||
assert.match(app, new RegExp(escapeRegExp(hardwareTerm)), `missing hardware term: ${hardwareTerm}`);
|
||||
}
|
||||
for (const hardwareHtmlTerm of ["Gateway-SIMU", "BOX-SIMU-1", "BOX-SIMU-2", "Patch Panel"]) {
|
||||
assert.match(html, new RegExp(escapeRegExp(hardwareHtmlTerm)), `missing hardware HTML term: ${hardwareHtmlTerm}`);
|
||||
}
|
||||
assert.match(app, /function hardwareEvidenceLabel/);
|
||||
assert.match(app, /dataset\.sourceKind/);
|
||||
assert.match(styles, /\.record-group\s*{/);
|
||||
assert.match(styles, /\.record-group-title\s*{/);
|
||||
assert.match(styles, /\.hardware-section\s*{/);
|
||||
assert.match(styles, /\.hardware-row\s*{/);
|
||||
assert.match(styles, /\.hardware-status-tabs\s*{/);
|
||||
assert.match(styles, /\.kv-row\s*{/);
|
||||
assert.match(html, /Gate \/ 诊断 \/ 验收/);
|
||||
assert.match(app, /new Set\(\["\/gate", "\/diagnostics\/gate"\]\)/);
|
||||
assert.match(html, /href="\/styles\.css"/);
|
||||
@@ -240,9 +244,9 @@ assert.match(html, />收起资源树<\/button>/);
|
||||
assert.match(app, /setAttribute\("aria-label", collapsed \? "展开左侧资源树" : "收起左侧资源树"\)/);
|
||||
assert.match(app, /textContent = collapsed \? "展开资源树" : "收起资源树"/);
|
||||
assert.match(styles, /--explorer-width:\s*292px/);
|
||||
assert.match(styles, /--right-width:\s*clamp\(366px,\s*28vw,\s*440px\)/);
|
||||
assert.match(styles, /--right-width-expanded:\s*clamp\(366px,\s*26vw,\s*410px\)/);
|
||||
assert.match(styles, /\.explorer-collapsed\s*\{[^}]*--explorer-width:\s*0px;[^}]*grid-template-columns:\s*var\(--rail-width\)\s+0\s+minmax\(560px,\s*1fr\)\s+var\(--right-width\);/s);
|
||||
assert.match(styles, /--right-width:\s*clamp\(560px,\s*38vw,\s*740px\)/);
|
||||
assert.match(styles, /--right-width-expanded:\s*clamp\(560px,\s*45vw,\s*740px\)/);
|
||||
assert.match(styles, /\.explorer-collapsed\s*\{[^}]*--explorer-width:\s*0px;[^}]*grid-template-columns:\s*var\(--rail-width\)\s+0\s+minmax\(420px,\s*1fr\)\s+var\(--right-width\);/s);
|
||||
for (const viewId of ["workspace", "gate"]) {
|
||||
assert.match(html, new RegExp(`data-view="${viewId}"`));
|
||||
}
|
||||
@@ -345,13 +349,15 @@ assert.match(functionBody(app, "readinessBooleanValues"), /payload\.ready/);
|
||||
assert.match(functionBody(app, "readinessBooleanValues"), /payload\.readiness\?\.ready/);
|
||||
assert.match(functionBody(app, "workbenchSurfaceReadiness"), /readyValues\.includes\(false\)/);
|
||||
assert.match(functionBody(app, "hasDurableRuntimeQueryBlock"), /runtime_durable_adapter_query_blocked/);
|
||||
assert.match(functionBody(app, "hasDurableRuntimeQueryBlock"), /runtime_durable_adapter_/);
|
||||
assert.match(functionBody(app, "hasDurableRuntimeQueryBlock"), /payload\.runtime\?\.blocker/);
|
||||
assert.match(functionBody(app, "hasDurableRuntimeQueryBlock"), /payload\.readiness\?\.durability\?\.blocker/);
|
||||
assert.match(functionBody(app, "hasDurableRuntimeQueryBlock"), /blockedLayer === "durability_query"/);
|
||||
assert.match(app, /可信记录受阻 \/ 只读模式/);
|
||||
assert.match(app, /API 降级 \/ 只读模式/);
|
||||
assert.match(app, /同源 API 可响应但处于降级状态;当前保持只读查看和本地任务整理。/);
|
||||
assert.match(app, /同源 API 可响应,但可信记录持久化尚未就绪;当前只保留只读查看和本地任务整理。/);
|
||||
assert.match(app, /API 错误 \/ 只读模式/);
|
||||
assert.match(app, /同源 API 返回 blocked\/degraded\/failed 或 ready=false;当前保持只读查看和本地任务整理。/);
|
||||
assert.match(app, /\/health\/live 可响应,但可信记录持久化尚未就绪:/);
|
||||
assert.doesNotMatch(app, /API 降级 \/ 只读模式/);
|
||||
assert.doesNotMatch(functionBody(app, "workbenchApiSurfaceStatus"), /runtime_durable_adapter_query_blocked|DB live readiness|Gate|诊断|验收|M0-M5|执行轨迹/u);
|
||||
assert.match(html, /method-list/);
|
||||
assert.match(html, /quick-actions/);
|
||||
@@ -365,6 +371,7 @@ 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(app, /fetchJson\("\/v1\/m3\/status"\)/);
|
||||
assert.match(html, /id="m3-control-form"/);
|
||||
assert.match(html, /id="m3-control-status"/);
|
||||
assert.match(html, />写入 DO1<\/button>/);
|
||||
@@ -375,6 +382,17 @@ assert.match(app, /frontendBypass=false/);
|
||||
assert.match(app, /按钮只走受控后端/);
|
||||
assert.match(app, /页面不直连模拟器,也不伪造硬件状态。/);
|
||||
assert.match(styles, /\.m3-control-form\s*{/);
|
||||
assert.match(html, /data-hardware-tab="overview"[\s\S]*>总览<\/button>/);
|
||||
assert.match(html, /data-hardware-tab="gateways"[\s\S]*>Gateway-SIMU<\/button>/);
|
||||
assert.match(html, /data-hardware-tab="box1"[\s\S]*>BOX-SIMU-1<\/button>/);
|
||||
assert.match(html, /data-hardware-tab="box2"[\s\S]*>BOX-SIMU-2<\/button>/);
|
||||
assert.match(html, /data-hardware-tab="patch"[\s\S]*>Patch Panel<\/button>/);
|
||||
assert.match(app, /function initHardwareTabs/);
|
||||
assert.match(app, /function hardwareKvGroup/);
|
||||
assert.match(app, /sourceFallbackM3Status/);
|
||||
assert.match(styles, /\.hardware-status-tabs\s*\{/);
|
||||
assert.match(styles, /\.kv-list\s*\{/);
|
||||
assert.match(styles, /\.right-sidebar \.table-wrap\s*\{[^}]*overflow-x:\s*hidden;/s);
|
||||
assert.match(app, /Code Agent 调用失败/);
|
||||
assert.match(app, /normalizeBlockedAgentResult/);
|
||||
assert.match(app, /isBlockedAgentResponse/);
|
||||
@@ -491,6 +509,7 @@ 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, /url\.pathname === "\/v1\/m3\/status"/);
|
||||
assert.match(artifactPublisher, /"system\.health"/);
|
||||
assert.match(artifactPublisher, /"cloud\.adapter\.describe"/);
|
||||
assert.match(artifactPublisher, /"audit\.event\.query"/);
|
||||
|
||||
@@ -5,7 +5,8 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
M3_IO_CHAIN,
|
||||
M3_IO_CONTROL_ROUTE
|
||||
M3_IO_CONTROL_ROUTE,
|
||||
M3_STATUS_ROUTE
|
||||
} from "../../../internal/cloud/m3-io-control.mjs";
|
||||
import { checkFrontendNoDirectRuntimeCalls } from "../../../scripts/src/m3-io-control-e2e.mjs";
|
||||
|
||||
@@ -31,6 +32,7 @@ export function runM3ControlPanelGuard() {
|
||||
const browserSource = `${html}\n${app}\n${styles}`;
|
||||
|
||||
assert.equal(M3_IO_CONTROL_ROUTE, "/v1/m3/io", "M3 IO control route must remain exact");
|
||||
assert.equal(M3_STATUS_ROUTE, "/v1/m3/status", "M3 status aggregate route must remain exact");
|
||||
assert.equal(
|
||||
exactTrustedRoute,
|
||||
"res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1",
|
||||
@@ -57,7 +59,7 @@ export function runM3ControlPanelGuard() {
|
||||
"M3 IO 只读 readiness 已确认",
|
||||
"受控路径受阻",
|
||||
"等待 cloud-api 返回 M3 IO 只读 readiness",
|
||||
"不可用时不会直连 gateway/box-simu",
|
||||
"不可用时页面不直连模拟器",
|
||||
"浏览器不直连 box-simu",
|
||||
"阻塞原因=",
|
||||
"仍在等待完整可信记录",
|
||||
@@ -118,8 +120,9 @@ function assertControlPanelSource(app) {
|
||||
const actionBody = functionBody(app, "runM3IoAction");
|
||||
assert.match(actionBody, /if \(!m3ControlCanOperate\(\)\)/u, "M3 action must fail closed before POST when readiness is blocked");
|
||||
assert.match(actionBody, /fetchJson\("\/v1\/m3\/io",\s*\{[\s\S]*method:\s*"POST"/u, "M3 action must POST same-origin /v1/m3/io");
|
||||
assert.match(actionBody, /fetchJson\("\/v1\/m3\/status"\)/u, "M3 action may refresh only through same-origin aggregate status after control");
|
||||
assert.doesNotMatch(actionBody, /\bfetch\(/u, "M3 action must not use raw fetch");
|
||||
assert.doesNotMatch(actionBody, /fetchJson\(\s*["'`](?!\/v1\/m3\/io)/u, "M3 action must not call any route except /v1/m3/io");
|
||||
assert.doesNotMatch(actionBody, /fetchJson\(\s*["'`](?!\/v1\/m3\/(?:io|status))/u, "M3 action must not call any route except /v1/m3/io and /v1/m3/status");
|
||||
assert.match(actionBody, /gatewayId:\s*el\.m3GatewaySelect\.value/u, "DO write request must use the selected source gateway");
|
||||
assert.match(actionBody, /resourceId:\s*el\.m3BoxSelect\.value/u, "DO write request must use the selected source resource");
|
||||
assert.match(actionBody, /boxId:\s*"boxsimu_1"/u, "DO write request must target source boxsimu_1");
|
||||
@@ -169,6 +172,7 @@ function assertControlPanelSource(app) {
|
||||
|
||||
function assertBrowserWriteBoundaries({ app, html, artifactPublisher }) {
|
||||
const browserSource = `${html}\n${app}`;
|
||||
const sanitizedBrowserSource = browserSource.replace(/data-static-source-fallback="SOURCE"/gu, "");
|
||||
const frontendGuard = checkFrontendNoDirectRuntimeCalls({
|
||||
appSource: app,
|
||||
htmlSource: html,
|
||||
@@ -191,6 +195,7 @@ function assertBrowserWriteBoundaries({ app, html, artifactPublisher }) {
|
||||
|
||||
const controlWriteTargets = postTargets.filter((target) => target !== "/json-rpc" && target !== "/v1/agent/chat");
|
||||
assert.deepEqual(controlWriteTargets, [M3_IO_CONTROL_ROUTE], "same-origin /v1/m3/io is the only browser hardware write control route");
|
||||
assert.match(app, /fetchJson\("\/v1\/m3\/status"\)/u, "browser hardware status reads must use same-origin /v1/m3/status");
|
||||
|
||||
for (const [pattern, label] of [
|
||||
[/\bcallRpc\(\s*["']hardware\./u, "generic hardware JSON-RPC"],
|
||||
@@ -203,7 +208,7 @@ function assertBrowserWriteBoundaries({ app, html, artifactPublisher }) {
|
||||
[/https?:\/\/[^"'`\s]*(?:gateway-simu|box-simu|patch-panel|:7101|:7201|:7301)/iu, "direct simulator URL"],
|
||||
[/\/wiring\/apply|\/wiring\/reload|\/signals\/route/u, "patch-panel write endpoint"]
|
||||
]) {
|
||||
assert.doesNotMatch(browserSource, pattern, `browser source must not expose ${label}`);
|
||||
assert.doesNotMatch(sanitizedBrowserSource, pattern, `browser source must not expose ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const READ_ONLY_RPC_METHODS = Object.freeze([
|
||||
"evidence.record.query"
|
||||
]);
|
||||
const CONTROL_ROUTE = "/v1/m3/io";
|
||||
const STATUS_ROUTE = "/v1/m3/status";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = path.resolve(rootDir, "../..");
|
||||
@@ -58,6 +59,7 @@ export function runCloudWebM3ReadonlyContract() {
|
||||
);
|
||||
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\("\/v1\/m3\/status"\)/u, "Cloud Web M3 status must use only the same-origin cloud-api aggregate status 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");
|
||||
@@ -82,7 +84,7 @@ export function runCloudWebM3ReadonlyContract() {
|
||||
|
||||
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");
|
||||
.filter((path) => path !== "/v1/agent/chat" && path !== CONTROL_ROUTE && path !== STATUS_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");
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
--info: #7ea6c9;
|
||||
--rail-width: 92px;
|
||||
--explorer-width: 292px;
|
||||
--right-width: clamp(366px, 28vw, 440px);
|
||||
--right-width-expanded: clamp(366px, 26vw, 410px);
|
||||
--right-width: clamp(560px, 38vw, 740px);
|
||||
--right-width-expanded: clamp(560px, 45vw, 740px);
|
||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
--body: "Aptos", "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ ul {
|
||||
max-height: 100dvh;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: var(--rail-width) var(--explorer-width) minmax(460px, 1fr) var(--right-width-expanded);
|
||||
grid-template-columns: var(--rail-width) var(--explorer-width) minmax(360px, 1fr) var(--right-width-expanded);
|
||||
grid-template-rows: 1fr;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
@@ -151,7 +151,7 @@ ul {
|
||||
|
||||
.explorer-collapsed {
|
||||
--explorer-width: 0px;
|
||||
grid-template-columns: var(--rail-width) 0 minmax(560px, 1fr) var(--right-width);
|
||||
grid-template-columns: var(--rail-width) 0 minmax(420px, 1fr) var(--right-width);
|
||||
}
|
||||
|
||||
.explorer-collapsed .explorer {
|
||||
@@ -779,7 +779,7 @@ h3 {
|
||||
|
||||
.right-sidebar {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 0.45fr) minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 0.9fr);
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: rgba(18, 20, 18, 0.98);
|
||||
@@ -790,16 +790,53 @@ h3 {
|
||||
.hardware-status {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hardware-status-tabs {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hardware-tab {
|
||||
flex: 1 1 92px;
|
||||
min-width: 0;
|
||||
min-height: 30px;
|
||||
padding: 5px 7px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
font-weight: 760;
|
||||
cursor: pointer;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.hardware-tab:hover,
|
||||
.hardware-tab:focus-visible,
|
||||
.hardware-tab.active {
|
||||
border-color: var(--line-strong);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.hardware-tab.active {
|
||||
box-shadow: inset 0 -3px 0 var(--accent);
|
||||
}
|
||||
|
||||
.hardware-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.hardware-section {
|
||||
@@ -824,6 +861,49 @@ h3 {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hardware-kv-section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kv-list {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.kv-row {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(96px, 0.42fr) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 7px 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-left-width: 3px;
|
||||
}
|
||||
|
||||
.kv-key,
|
||||
.kv-value {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.kv-key {
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.kv-value {
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.hardware-row {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
@@ -931,6 +1011,7 @@ h3 {
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
@@ -1071,6 +1152,10 @@ h3 {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.right-sidebar .table-wrap {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -1136,6 +1221,12 @@ table {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.right-sidebar table,
|
||||
.right-sidebar .wiring-table {
|
||||
min-width: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
@@ -1428,6 +1519,11 @@ tbody tr:last-child td {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.kv-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.message-card,
|
||||
.trace-item {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user