feat: add l2 simulator runtime smoke

This commit is contained in:
HWLAB Code Queue
2026-05-21 18:21:28 +00:00
parent 38d1507e07
commit 1e9591a661
12 changed files with 1681 additions and 39 deletions
+6
View File
@@ -68,6 +68,12 @@ Run the lightweight validation suite:
npm run check
```
Run only the L2 gateway/box simulator runtime smoke:
```sh
npm run l2:smoke
```
## L6 Manual Acceptance Entry
The external MVP acceptance surface is DEV-only and report-driven:
+68 -21
View File
@@ -1,37 +1,84 @@
#!/usr/bin/env node
import { createHealth, createBoxState } from "../../internal/sim/model.mjs";
import {
handleBoxInvoke,
operationContext,
readBoxPort,
writeBoxPort
} from "../../internal/sim/l2-runtime.mjs";
import { createHealth, createBoxState, selectInstanceValue } from "../../internal/sim/model.mjs";
import { createJsonServer, listen, parsePort, readJson } from "../../internal/sim/http.mjs";
const boxId = process.env.HWLAB_BOX_ID ?? "boxsim_alpha";
const boxId = selectInstanceValue(process.env.HWLAB_BOX_ID, "boxsim_alpha");
const gatewaySessionId = selectInstanceValue(
process.env.HWLAB_GATEWAY_SESSION_ID,
"gws_mvp_simu"
);
const port = parsePort(process.env.PORT, 7201);
const state = createBoxState({ boxId });
const state = createBoxState({ boxId, gatewaySessionId });
const routes = new Map();
routes.set("GET /health/live", () => createHealth({ serviceId: "hwlab-box-simu", name: boxId }));
routes.set("GET /health/live", () => ({
...createHealth({ serviceId: "hwlab-box-simu", name: boxId }),
boxId,
resourceId: state.resource.resourceId
}));
routes.set("GET /status", () => state);
routes.set("GET /capabilities", () => ({
serviceId: "hwlab-box-simu",
boxId,
resourceId: state.resource.resourceId,
capabilities: state.capabilities
}));
routes.set("GET /internal/ports", () => ({
serviceId: "hwlab-box-simu",
boxId,
resourceId: state.resource.resourceId,
ports: state.ports,
internalPortApi: true
}));
routes.set("POST /ports/read", async ({ req }) => {
const body = await readJson(req);
return readBoxPort({
state,
port: body.port,
capabilityId: body.capabilityId,
context: operationContext(body, { serviceId: "hwlab-box-simu" })
});
});
routes.set("POST /ports/write", async ({ req }) => {
const body = await readJson(req);
if (!state.ports[body.port]) {
throw new Error(`unknown port ${body.port}`);
}
const now = new Date().toISOString();
state.ports[body.port] = {
return writeBoxPort({
state,
port: body.port,
value: body.value ?? null,
capabilityId: body.capabilityId,
value: body.value,
source: body.source ?? "local",
updatedAt: now
};
state.updatedAt = now;
return {
accepted: true,
boxId,
context: operationContext(body, { serviceId: "hwlab-box-simu" })
});
});
routes.set("POST /internal/ports/write", async ({ req }) => {
const body = await readJson(req);
return writeBoxPort({
state,
port: body.port,
crossDevicePropagation: "patch-panel-only",
localLoopbackEnabled: false
};
capabilityId: body.capabilityId,
value: body.value,
source: body.source ?? "internal",
sourceResourceId: body.sourceResourceId,
sourcePort: body.sourcePort,
propagatedBy: body.propagatedBy,
internal: true,
context: operationContext(body, { serviceId: "hwlab-box-simu" })
});
});
routes.set("POST /invoke", async ({ req }) => {
const body = await readJson(req);
return handleBoxInvoke({
state,
body,
context: operationContext(body, { serviceId: "hwlab-box-simu" })
});
});
listen(createJsonServer({ routes }), port);
+356 -10
View File
@@ -1,25 +1,371 @@
#!/usr/bin/env node
import { createGatewayState, createHealth } from "../../internal/sim/model.mjs";
import { createJsonServer, listen, parsePort } from "../../internal/sim/http.mjs";
import { createAuditEvent, createEvidenceRecord, operationContext } from "../../internal/sim/l2-runtime.mjs";
import {
createGatewayState,
createHealth,
resourceIdForBox,
selectInstanceValue
} from "../../internal/sim/model.mjs";
import { fetchJson, createJsonServer, listen, parsePort, readJson } from "../../internal/sim/http.mjs";
import { createRpcErrorBody, ERROR_CODES } from "../../internal/protocol/index.mjs";
const gatewayId = process.env.HWLAB_GATEWAY_ID ?? "gwsimu_east";
const gatewayId = selectInstanceValue(process.env.HWLAB_GATEWAY_ID, "gateway-a");
const port = parsePort(process.env.PORT, 7101);
const endpoint = `http://127.0.0.1:${port}`;
const boxes = (process.env.HWLAB_BOX_RESOURCES ?? "res_boxsim_alpha")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
const state = createGatewayState({ gatewayId, endpoint, boxes });
const boxUrls = parseBoxUrls(process.env.HWLAB_BOX_URLS);
const boxes = parseBoxes({
resources: process.env.HWLAB_BOX_RESOURCES,
ids: process.env.HWLAB_BOX_IDS,
urls: boxUrls
});
const state = createGatewayState({
gatewayId,
endpoint,
boxes: boxes.map((box) => box.resourceId)
});
state.registry = {
gatewayId,
gatewaySessionId: state.session.gatewaySessionId,
endpoint,
boxes
};
state.lastHeartbeatAt = state.session.lastSeenAt;
const routes = new Map();
routes.set("GET /health/live", () =>
createHealth({ serviceId: "hwlab-gateway-simu", name: gatewayId })
({
...createHealth({ serviceId: "hwlab-gateway-simu", name: gatewayId }),
gatewayId,
gatewaySessionId: state.session.gatewaySessionId
})
);
routes.set("GET /status", () => state);
routes.set("GET /boxes", () => ({
gatewayId,
boxes: state.boxes
gatewaySessionId: state.session.gatewaySessionId,
boxes: state.registry.boxes
}));
routes.set("POST /register", async ({ req }) => {
const body = await readJson(req);
const now = new Date().toISOString();
const registered = normalizeBoxRegistration(body);
upsertBox(registered);
state.boxes = state.registry.boxes.map((box) => box.resourceId);
state.updatedAt = now;
state.session.lastSeenAt = now;
return {
accepted: true,
serviceId: "hwlab-gateway-simu",
gatewayId,
gatewaySessionId: state.session.gatewaySessionId,
registered,
registry: state.registry
};
});
routes.set("POST /heartbeat", async ({ req }) => {
const body = await readJson(req);
const now = new Date().toISOString();
state.lastHeartbeatAt = now;
state.session.lastSeenAt = now;
state.updatedAt = now;
return {
accepted: true,
serviceId: "hwlab-gateway-simu",
gatewayId,
gatewaySessionId: state.session.gatewaySessionId,
heartbeat: {
observedAt: now,
boxCount: state.registry.boxes.length,
reportedBy: body.reportedBy ?? gatewayId
}
};
});
routes.set("GET /capabilities", async () => capabilityReport());
routes.set("POST /capabilities/report", async () => capabilityReport());
routes.set("POST /invoke", async ({ req }) => {
const body = await readJson(req);
return invokeBox(body);
});
function rpcHttpError(statusCode, { code, message, data = {} }) {
return Object.assign(new Error(message), {
statusCode,
body: createRpcErrorBody({
code,
message,
data
})
});
}
function parseBoxUrls(value) {
return String(value ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseList(value) {
return String(value ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseBoxes({ resources, ids, urls }) {
const explicitResources = parseList(resources);
const explicitIds = parseList(ids);
const count = Math.max(explicitResources.length, explicitIds.length, urls.length, 1);
const parsed = [];
for (let index = 0; index < count; index += 1) {
const boxId =
explicitIds[index] ??
explicitResources[index]?.replace(/^res_/, "") ??
(count === 1 ? "boxsim_alpha" : `box-${index + 1}`);
const resourceId = explicitResources[index] ?? resourceIdForBox(boxId);
parsed.push({
boxId,
resourceId,
url: urls[index] ?? null,
state: "registered"
});
}
return parsed;
}
function normalizeBoxRegistration(body) {
const boxId = body.boxId ?? body.identity?.boxId;
const resourceId = body.resourceId ?? body.resource?.resourceId ?? (boxId ? resourceIdForBox(boxId) : null);
if (!boxId || !resourceId) {
throw rpcHttpError(400, {
code: ERROR_CODES.invalidParams,
message: "register requires boxId or resourceId",
data: {
reason: "identity_required"
}
});
}
return {
boxId,
resourceId,
url: body.url ?? body.endpoint ?? null,
state: "registered",
capabilities: Array.isArray(body.capabilities) ? body.capabilities : undefined,
registeredAt: new Date().toISOString()
};
}
function upsertBox(box) {
const index = state.registry.boxes.findIndex((item) => item.resourceId === box.resourceId);
if (index >= 0) {
state.registry.boxes[index] = {
...state.registry.boxes[index],
...box
};
return;
}
state.registry.boxes.push(box);
}
async function capabilityReport() {
const reports = [];
for (const box of state.registry.boxes) {
if (box.url) {
try {
const response = await fetchJson(new URL("/capabilities", box.url));
if (response.ok) {
const body = response.body;
const capabilities = body?.capabilities ?? [];
upsertBox({
...box,
boxId: body?.boxId ?? box.boxId,
resourceId: body?.resourceId ?? box.resourceId,
capabilities
});
reports.push({
boxId: body?.boxId ?? box.boxId,
resourceId: body?.resourceId ?? box.resourceId,
url: box.url,
status: "available",
capabilities
});
continue;
}
} catch (error) {
reports.push({
boxId: box.boxId,
resourceId: box.resourceId,
url: box.url,
status: "unreachable",
capabilities: box.capabilities ?? [],
error: error instanceof Error ? error.message : String(error)
});
continue;
}
}
reports.push({
boxId: box.boxId,
resourceId: box.resourceId,
url: box.url,
status: box.capabilities ? "cached" : "unreachable",
capabilities: box.capabilities ?? []
});
}
state.capabilityReport = {
serviceId: "hwlab-gateway-simu",
gatewayId,
gatewaySessionId: state.session.gatewaySessionId,
reportedAt: new Date().toISOString(),
boxes: reports
};
return state.capabilityReport;
}
function findBoxTarget(body) {
const resourceId = body.resourceId;
const boxId = body.boxId;
return state.registry.boxes.find(
(box) => (resourceId && box.resourceId === resourceId) || (boxId && box.boxId === boxId)
);
}
async function invokeBox(body) {
const box = findBoxTarget(body);
if (!box) {
throw rpcHttpError(404, {
code: ERROR_CODES.sessionNotFound,
message: "Target box is not registered with this gateway",
data: {
gatewayId,
gatewaySessionId: state.session.gatewaySessionId,
boxId: body.boxId ?? null,
resourceId: body.resourceId ?? null
}
});
}
if (!box.url) {
throw rpcHttpError(409, {
code: ERROR_CODES.operationRejected,
message: "Target box has no invoke URL",
data: {
gatewayId,
resourceId: box.resourceId,
reason: "box_url_missing"
}
});
}
const context = operationContext(body, {
serviceId: "hwlab-gateway-simu",
actorId: "svc_hwlab-gateway-simu"
});
const forwarded = {
...body,
operationId: context.operationId,
traceId: context.traceId,
actorType: "service",
actorId: "svc_hwlab-gateway-simu",
source: body.source ?? "gateway-simu"
};
const response = await fetchJson(new URL("/invoke", box.url), {
method: "POST",
body: forwarded
});
if (!response.ok) {
throw Object.assign(new Error("box invoke failed"), {
statusCode: response.status,
body:
response.body ??
createRpcErrorBody({
code: ERROR_CODES.hwlabUnknown,
message: "Box invoke failed",
data: {
status: response.status
}
})
});
}
const now = new Date().toISOString();
const audit = createAuditEvent({
serviceId: "hwlab-gateway-simu",
action: "hardware.operation.dispatched",
targetType: "box_resource",
targetId: box.resourceId,
projectId: state.projectId,
gatewaySessionId: state.session.gatewaySessionId,
operationId: context.operationId,
traceId: context.traceId,
actorType: "service",
actorId: "svc_hwlab-gateway-simu",
outcome: "succeeded",
metadata: {
gatewayId,
boxId: box.boxId,
resourceId: box.resourceId,
capabilityId: body.capabilityId ?? null,
port: body.port ?? null,
forwardedTo: box.url
},
occurredAt: now
});
const evidence = createEvidenceRecord({
serviceId: "hwlab-gateway-simu",
projectId: state.projectId,
operationId: context.operationId,
traceId: context.traceId,
kind: "trace",
payload: {
gatewayId,
boxId: box.boxId,
resourceId: box.resourceId,
result: response.body
},
metadata: {
gatewaySessionId: state.session.gatewaySessionId,
forwardedTo: box.url,
producerServiceId: "hwlab-gateway-simu"
},
createdAt: now
});
state.lastDispatch = {
operationId: context.operationId,
traceId: context.traceId,
resourceId: box.resourceId,
observedAt: now,
accepted: true
};
state.updatedAt = now;
return {
accepted: true,
serviceId: "hwlab-gateway-simu",
gatewayId,
gatewaySessionId: state.session.gatewaySessionId,
operationId: context.operationId,
traceId: context.traceId,
target: {
boxId: box.boxId,
resourceId: box.resourceId,
url: box.url
},
result: response.body,
auditId: audit.auditId,
evidenceId: evidence.evidenceId,
audit,
evidence
};
}
listen(createJsonServer({ routes }), port);
+14
View File
@@ -56,3 +56,17 @@ actual cloud-web/cloud-api, gateway/router/tunnel path, hardware trusted closed
loop, agent automation closed loop, evidence persistence, and cleanup behavior.
That later test should produce deployment/evidence artifacts and must remain
separate from this local smoke harness.
## L2 Runtime Smoke
The L2 simulator runtime has a separate local check:
```sh
npm run l2:smoke
```
It starts two `hwlab-gateway-simu` and two `hwlab-box-simu` processes on
ephemeral localhost ports. It proves simulator identity, AI/AO/DI/DO/FREQ
state operations, gateway register/heartbeat/capability/invoke forwarding,
error codes, audit/evidence shapes, and the patch-panel-only propagation
boundary. It does not call DEV or PROD.
+26
View File
@@ -44,6 +44,32 @@ audit events, and evidence records. That future work must preserve the same
wiring contract and must keep routing decisions under `hwlab-patch-panel`
ownership instead of mutating another box directly.
## L2 Local Runtime Smoke
Issue 53 adds a runnable localhost-only L2 simulator contract:
```sh
npm run l2:smoke
```
The smoke starts two `hwlab-box-simu` instances (`box-a`, `box-b`) and two
`hwlab-gateway-simu` instances (`gateway-a`, `gateway-b`) on ephemeral
localhost ports. It verifies:
- box ports for `AI1`, `AO1`, `DI1`, `DO1`, and `FREQ1`;
- internal port API writes for patch-panel-owned delivery;
- gateway register, heartbeat, capability report, and invoke forwarding;
- multi-instance identity separation for `box-a`/`box-b` and
`gateway-a`/`gateway-b`;
- HWLAB JSON-RPC error codes for missing gateway registration and unavailable
capabilities;
- returned audit and evidence object shapes.
This smoke is still not a DEV deploy or PROD deploy. It proves the minimum
runtime surface that later M3/M5 live checks can call. Cross-device propagation
is not implemented as box-simu loopback; the only accepted cross-device owner
remains `hwlab-patch-panel`, which calls the box internal port API.
## DEV Runtime Smoke
Use this command only for the DEV simulator runtime:
+24
View File
@@ -125,3 +125,27 @@ export function validateRpcError(error) {
throw new Error("error.message is required");
}
}
export function errorNameForCode(code) {
for (const [name, value] of Object.entries(ERROR_CODES)) {
if (value === code) {
return name.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
}
return "hwlab_unknown";
}
export function createRpcErrorBody({ code = ERROR_CODES.hwlabUnknown, message = "HWLAB runtime error", data = {} }) {
const error = {
code,
name: errorNameForCode(code),
message,
data
};
validateRpcError(error);
return {
error
};
}
+52
View File
@@ -1,5 +1,13 @@
import { createServer } from "node:http";
export class HttpError extends Error {
constructor(statusCode, body) {
super(body?.message ?? body?.error ?? `HTTP ${statusCode}`);
this.statusCode = statusCode;
this.body = body;
}
}
export function jsonResponse(res, statusCode, body) {
const payload = JSON.stringify(body, null, 2);
res.writeHead(statusCode, {
@@ -40,6 +48,24 @@ export function createJsonServer({ routes }) {
const body = await handler({ req, url });
jsonResponse(res, 200, body);
} catch (error) {
if (error instanceof HttpError) {
jsonResponse(res, error.statusCode, error.body);
return;
}
if (Number.isInteger(error?.statusCode) && error?.body) {
jsonResponse(res, error.statusCode, error.body);
return;
}
if (error instanceof SyntaxError) {
jsonResponse(res, 400, {
error: "invalid_json",
message: error.message
});
return;
}
jsonResponse(res, 500, {
error: "internal_error",
message: error instanceof Error ? error.message : String(error)
@@ -48,6 +74,32 @@ export function createJsonServer({ routes }) {
});
}
export async function fetchJson(url, options = {}) {
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
const response = await fetch(url, {
method: options.method ?? (body === undefined ? "GET" : "POST"),
headers: {
...(body === undefined
? {}
: {
"content-type": "application/json"
}),
...(options.headers ?? {})
},
body,
signal: options.signal
});
const text = await response.text();
const json = text ? JSON.parse(text) : null;
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
body: json
};
}
export function parsePort(value, fallback) {
const parsed = Number.parseInt(value ?? "", 10);
if (Number.isInteger(parsed) && parsed > 0 && parsed < 65536) {
+472
View File
@@ -0,0 +1,472 @@
import { createHash, randomUUID } from "node:crypto";
import { createRpcErrorBody, ENVIRONMENT_DEV, ERROR_CODES } from "../protocol/index.mjs";
import { getPortDefinition } from "./model.mjs";
export const AUDIT_SHAPE_FIELDS = Object.freeze([
"auditId",
"traceId",
"actorType",
"actorId",
"action",
"targetType",
"targetId",
"serviceId",
"environment",
"occurredAt"
]);
export const EVIDENCE_SHAPE_FIELDS = Object.freeze([
"evidenceId",
"projectId",
"operationId",
"kind",
"uri",
"sha256",
"serviceId",
"environment",
"createdAt"
]);
export function isoNow() {
return new Date().toISOString();
}
export function makeId(prefix, value = randomUUID()) {
const normalized = String(value)
.trim()
.replace(/[^A-Za-z0-9._:-]+/g, "_")
.replace(/^_+|_+$/g, "");
return `${prefix}_${normalized || randomUUID()}`;
}
export function operationContext(input = {}, { serviceId, actorId = `svc_${serviceId}`, now = isoNow() } = {}) {
const traceId = input.traceId ?? makeId("trc", randomUUID());
const operationId = input.operationId ?? makeId("op", randomUUID());
const requestId = input.requestId ?? makeId("req", randomUUID());
return {
traceId,
operationId,
requestId,
actorType: input.actorType ?? "service",
actorId: normalizeActorId(input.actorId ?? actorId),
requestedBy: normalizeActorId(input.requestedBy ?? input.actorId ?? actorId),
observedAt: input.observedAt ?? now
};
}
function normalizeActorId(value) {
const text = String(value ?? "");
return /^[a-z][a-z0-9]*_/.test(text) ? text : makeId("svc", text || "hwlab-sim");
}
export function createAuditEvent({
serviceId,
action,
targetType,
targetId,
projectId,
gatewaySessionId,
operationId,
traceId,
actorType = "service",
actorId = `svc_${serviceId}`,
outcome,
reason,
metadata = {},
occurredAt = isoNow()
}) {
return {
auditId: makeId("aud", `${serviceId}_${operationId ?? traceId ?? randomUUID()}_${action}`),
traceId,
actorType,
actorId,
action,
targetType,
targetId,
...(projectId ? { projectId } : {}),
...(gatewaySessionId ? { gatewaySessionId } : {}),
...(operationId ? { operationId } : {}),
serviceId,
environment: ENVIRONMENT_DEV,
...(outcome ? { outcome } : {}),
...(reason ? { reason } : {}),
metadata,
occurredAt
};
}
export function createEvidenceRecord({
serviceId,
projectId,
operationId,
traceId,
kind = "measurement",
payload = {},
metadata = {},
createdAt = isoNow()
}) {
const serialized = JSON.stringify({
serviceId,
projectId,
operationId,
traceId,
kind,
payload,
metadata,
createdAt
});
const sha256 = createHash("sha256").update(serialized).digest("hex");
return {
evidenceId: makeId("evd", `${serviceId}_${operationId}_${kind}`),
projectId,
operationId,
kind,
uri: `memory://${serviceId}/${operationId}/${kind}.json`,
mimeType: "application/json",
sha256,
sizeBytes: Buffer.byteLength(serialized),
serviceId,
environment: ENVIRONMENT_DEV,
metadata: {
traceId,
...metadata
},
createdAt
};
}
export function normalizePortValue(value, definition) {
if (!definition) {
return value;
}
if (definition.valueType === "boolean") {
if (typeof value === "boolean") {
return value;
}
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
throw createRuntimeError({
code: ERROR_CODES.invalidParams,
message: `${definition.port} requires a boolean value`,
data: {
port: definition.port,
valueType: definition.valueType
}
});
}
if (definition.valueType === "number") {
const number = typeof value === "number" ? value : Number(value);
if (Number.isFinite(number)) {
return number;
}
throw createRuntimeError({
code: ERROR_CODES.invalidParams,
message: `${definition.port} requires a finite number value`,
data: {
port: definition.port,
valueType: definition.valueType
}
});
}
if (definition.valueType === "string" && value !== null && value !== undefined) {
return String(value);
}
if (definition.valueType === "object" && value !== null && value !== undefined) {
if (typeof value === "object" && !Array.isArray(value)) {
return value;
}
throw createRuntimeError({
code: ERROR_CODES.invalidParams,
message: `${definition.port} requires an object value`,
data: {
port: definition.port,
valueType: definition.valueType
}
});
}
return value ?? null;
}
export function findCapability(state, capabilityId) {
if (!capabilityId) {
return null;
}
return state.capabilities.find((capability) => capability.capabilityId === capabilityId) ?? null;
}
export function resolvePortForRead(state, { port, capabilityId }) {
if (capabilityId) {
const capability = findCapability(state, capabilityId);
if (!capability?.constraints?.port || !state.ports[capability.constraints.port]) {
throw createRuntimeError({
code: ERROR_CODES.capabilityUnavailable,
message: "Requested hardware capability is unavailable",
data: {
capabilityId,
port: port ?? null,
resourceId: state.resource.resourceId,
reason: "capability_unavailable"
}
});
}
if (port && port !== capability.constraints.port) {
throw createRuntimeError({
code: ERROR_CODES.invalidParams,
message: "Port does not match requested capability",
data: {
capabilityId,
capabilityPort: capability.constraints.port,
port,
resourceId: state.resource.resourceId,
reason: "capability_port_mismatch"
}
});
}
return capability.constraints.port;
}
if (port && state.ports[port]) {
return port;
}
throw createRuntimeError({
code: ERROR_CODES.capabilityUnavailable,
message: "Requested hardware capability is unavailable",
data: {
capabilityId: capabilityId ?? null,
port: port ?? null,
resourceId: state.resource.resourceId,
reason: "capability_unavailable"
}
});
}
export function resolvePortForWrite(state, { port, capabilityId }) {
const resolvedPort = resolvePortForRead(state, { port, capabilityId });
const capability = capabilityId ? findCapability(state, capabilityId) : null;
if (capabilityId && !capability?.mutatesState) {
throw createRuntimeError({
code: ERROR_CODES.operationRejected,
message: "Read-only capability cannot be used for a write operation",
data: {
capabilityId,
port: resolvedPort,
reason: "read_only_capability"
}
});
}
return resolvedPort;
}
export function readBoxPort({ state, port, capabilityId, context = operationContext({}) }) {
const resolvedPort = resolvePortForRead(state, { port, capabilityId });
const current = state.ports[resolvedPort];
const capability = capabilityId ? findCapability(state, capabilityId) : null;
const audit = createAuditEvent({
serviceId: "hwlab-box-simu",
action: "hardware.operation.succeeded",
targetType: "box_resource",
targetId: state.resource.resourceId,
projectId: state.projectId,
gatewaySessionId: state.gatewaySessionId,
operationId: context.operationId,
traceId: context.traceId,
actorType: context.actorType,
actorId: context.actorId,
outcome: "succeeded",
metadata: {
boxId: state.boxId,
port: resolvedPort,
direction: current.direction,
capabilityId: capability?.capabilityId ?? null,
operation: "port.read"
},
occurredAt: context.observedAt
});
const evidence = createEvidenceRecord({
serviceId: "hwlab-box-simu",
projectId: state.projectId,
operationId: context.operationId,
traceId: context.traceId,
payload: current,
metadata: {
boxId: state.boxId,
resourceId: state.resource.resourceId,
capabilityId: capability?.capabilityId ?? null,
port: resolvedPort
},
createdAt: context.observedAt
});
return {
accepted: true,
operationId: context.operationId,
traceId: context.traceId,
boxId: state.boxId,
resourceId: state.resource.resourceId,
capabilityId: capability?.capabilityId ?? null,
port: resolvedPort,
value: current.value,
state: current,
auditId: audit.auditId,
evidenceId: evidence.evidenceId,
audit,
evidence
};
}
export function writeBoxPort({
state,
port,
capabilityId,
value,
source = "local",
sourceResourceId,
sourcePort,
propagatedBy,
internal = false,
context = operationContext({})
}) {
const resolvedPort = resolvePortForWrite(state, { port, capabilityId });
const definition = getPortDefinition(resolvedPort);
const current = state.ports[resolvedPort];
const normalizedValue = normalizePortValue(value, definition);
const now = context.observedAt;
const capability = capabilityId ? findCapability(state, capabilityId) : null;
state.ports[resolvedPort] = {
...current,
value: normalizedValue,
source,
...(sourceResourceId ? { sourceResourceId } : {}),
...(sourcePort ? { sourcePort } : {}),
...(propagatedBy ? { propagatedBy } : {}),
updatedAt: now
};
state.updatedAt = now;
const audit = createAuditEvent({
serviceId: "hwlab-box-simu",
action: internal ? "hardware.internal_port.written" : "hardware.operation.succeeded",
targetType: "box_resource",
targetId: state.resource.resourceId,
projectId: state.projectId,
gatewaySessionId: state.gatewaySessionId,
operationId: context.operationId,
traceId: context.traceId,
actorType: context.actorType,
actorId: context.actorId,
outcome: "succeeded",
metadata: {
boxId: state.boxId,
port: resolvedPort,
direction: current.direction,
capabilityId: capability?.capabilityId ?? null,
internalPortApi: internal,
patchPanelOnlyPropagation: true,
localLoopbackEnabled: false
},
occurredAt: now
});
const evidence = createEvidenceRecord({
serviceId: "hwlab-box-simu",
projectId: state.projectId,
operationId: context.operationId,
traceId: context.traceId,
payload: state.ports[resolvedPort],
metadata: {
boxId: state.boxId,
resourceId: state.resource.resourceId,
capabilityId: capability?.capabilityId ?? null,
port: resolvedPort,
internalPortApi: internal,
producerServiceId: "hwlab-box-simu"
},
createdAt: now
});
return {
accepted: true,
operationId: context.operationId,
traceId: context.traceId,
boxId: state.boxId,
resourceId: state.resource.resourceId,
capabilityId: capability?.capabilityId ?? null,
port: resolvedPort,
value: normalizedValue,
state: state.ports[resolvedPort],
crossDevicePropagation: "patch-panel-only",
localLoopbackEnabled: false,
auditId: audit.auditId,
evidenceId: evidence.evidenceId,
audit,
evidence
};
}
export function handleBoxInvoke({ state, body, context = operationContext(body ?? {}) }) {
const operation = body.operation ?? body.method;
if (operation === "port.read" || operation === "hardware.port.read") {
return readBoxPort({
state,
port: body.port,
capabilityId: body.capabilityId,
context
});
}
if (operation === "port.write" || operation === "hardware.port.write") {
return writeBoxPort({
state,
port: body.port,
capabilityId: body.capabilityId,
value: body.value,
source: body.source ?? "gateway",
context
});
}
throw createRuntimeError({
code: ERROR_CODES.methodNotFound,
message: "Box simulator invoke method is not implemented",
data: {
method: operation ?? null,
resourceId: state.resource.resourceId
}
});
}
export function createRuntimeError({ code, message, data = {}, statusCode }) {
const error = new Error(message);
error.statusCode = statusCode ?? httpStatusForErrorCode(code);
error.body = createRpcErrorBody({ code, message, data });
return error;
}
export function httpStatusForErrorCode(code) {
if (code === ERROR_CODES.invalidRequest || code === ERROR_CODES.invalidParams) {
return 400;
}
if (code === ERROR_CODES.methodNotFound || code === ERROR_CODES.capabilityUnavailable) {
return 404;
}
if (code === ERROR_CODES.resourceLocked || code === ERROR_CODES.operationRejected) {
return 409;
}
return 500;
}
+197 -7
View File
@@ -3,15 +3,122 @@ import { ENVIRONMENT_DEV } from "../protocol/index.mjs";
export const DEFAULT_PROJECT_ID = "prj_mvp_topology";
export const DEFAULT_GATEWAY_SESSION_ID = "gws_mvp_simu";
export const SIM_PORTS = Object.freeze(["uart0", "eth0", "gpio0"]);
export const SIM_PORT_DEFINITIONS = Object.freeze([
{
port: "AI1",
family: "AI",
direction: "input",
valueType: "number",
unit: "V",
initialValue: 0
},
{
port: "AO1",
family: "AO",
direction: "output",
valueType: "number",
unit: "V",
initialValue: 0
},
{
port: "DI1",
family: "DI",
direction: "input",
valueType: "boolean",
initialValue: false
},
{
port: "DO1",
family: "DO",
direction: "output",
valueType: "boolean",
initialValue: false
},
{
port: "FREQ1",
family: "FREQ",
direction: "bidirectional",
valueType: "number",
unit: "Hz",
initialValue: 0
},
{
port: "uart0",
family: "SERIAL",
direction: "bidirectional",
valueType: "string",
initialValue: null,
legacy: true
},
{
port: "eth0",
family: "NETWORK",
direction: "bidirectional",
valueType: "object",
initialValue: null,
legacy: true
},
{
port: "gpio0",
family: "GPIO",
direction: "bidirectional",
valueType: "boolean",
initialValue: null,
legacy: true
}
]);
export const SIM_PORTS = Object.freeze(SIM_PORT_DEFINITIONS.map((definition) => definition.port));
export const L2_PORT_FAMILIES = Object.freeze(["AI", "AO", "DI", "DO", "FREQ"]);
function isoNow() {
return new Date().toISOString();
}
export function selectInstanceValue(value, fallback, { index = process.env.HWLAB_INSTANCE_INDEX } = {}) {
const items = String(value ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (items.length === 0) {
return fallback;
}
if (items.length === 1) {
return items[0];
}
const parsedIndex = Number.parseInt(index ?? "", 10);
if (Number.isInteger(parsedIndex) && parsedIndex >= 0 && parsedIndex < items.length) {
return items[parsedIndex];
}
const hostname = process.env.HOSTNAME ?? "";
const ordinal = Number.parseInt(hostname.match(/-(\d+)$/)?.[1] ?? "", 10);
if (Number.isInteger(ordinal) && ordinal >= 0 && ordinal < items.length) {
return items[ordinal];
}
return items[0];
}
export function stableIdPart(value) {
const normalized = String(value ?? "")
.trim()
.replace(/[^A-Za-z0-9._:-]+/g, "_")
.replace(/^_+|_+$/g, "");
return normalized || "sim";
}
export function resourceIdForBox(boxId) {
return `res_${stableIdPart(boxId)}`;
}
export function createBoxResource({ boxId, gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID, now = isoNow() }) {
return {
resourceId: `res_${boxId}`,
resourceId: resourceIdForBox(boxId),
projectId: DEFAULT_PROJECT_ID,
gatewaySessionId,
boxId,
@@ -22,6 +129,7 @@ export function createBoxResource({ boxId, gatewaySessionId = DEFAULT_GATEWAY_SE
metadata: {
serviceId: "hwlab-box-simu",
ports: SIM_PORTS,
portFamilies: L2_PORT_FAMILIES,
propagation: "patch-panel-only"
},
createdAt: now,
@@ -29,6 +137,72 @@ export function createBoxResource({ boxId, gatewaySessionId = DEFAULT_GATEWAY_SE
};
}
export function createBoxCapabilities({
boxId,
resourceId = resourceIdForBox(boxId),
projectId = DEFAULT_PROJECT_ID,
now = isoNow()
}) {
const capabilities = [];
const boxPart = stableIdPart(boxId);
for (const definition of SIM_PORT_DEFINITIONS) {
if (definition.legacy) {
continue;
}
const portPart = definition.port.toLowerCase();
const family = definition.family.toLowerCase();
capabilities.push({
capabilityId: `cap_${boxPart}_${portPart}_read`,
resourceId,
projectId,
name: `${family}.read`,
description: `Read ${definition.port} on ${boxId}.`,
direction: "input",
valueType: definition.valueType,
...(definition.unit ? { unit: definition.unit } : {}),
constraints: {
port: definition.port,
portFamily: definition.family,
internalPortApi: true,
patchPanelOnlyPropagation: true
},
mutatesState: false,
createdAt: now,
updatedAt: now
});
if (definition.direction === "output" || definition.direction === "bidirectional") {
capabilities.push({
capabilityId: `cap_${boxPart}_${portPart}_write`,
resourceId,
projectId,
name: `${family}.write`,
description: `Write ${definition.port} on ${boxId}.`,
direction: definition.direction === "bidirectional" ? "bidirectional" : "output",
valueType: definition.valueType,
...(definition.unit ? { unit: definition.unit } : {}),
constraints: {
port: definition.port,
portFamily: definition.family,
internalPortApi: false,
patchPanelOnlyPropagation: true
},
mutatesState: true,
createdAt: now,
updatedAt: now
});
}
}
return capabilities;
}
export function getPortDefinition(port) {
return SIM_PORT_DEFINITIONS.find((definition) => definition.port === port);
}
export function createGatewaySession({
gatewayId,
serviceId = "hwlab-gateway-simu",
@@ -53,24 +227,40 @@ export function createGatewaySession({
export function createBoxState({ boxId, gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID, now = isoNow() }) {
const ports = Object.fromEntries(
SIM_PORTS.map((port) => [
port,
SIM_PORT_DEFINITIONS.map((definition) => [
definition.port,
{
port,
value: null,
port: definition.port,
family: definition.family,
direction: definition.direction,
valueType: definition.valueType,
...(definition.unit ? { unit: definition.unit } : {}),
value: definition.initialValue,
source: "local",
internalPortApi: true,
updatedAt: now
}
])
);
for (const definition of SIM_PORT_DEFINITIONS) {
ports[definition.port].value = definition.initialValue;
}
const resource = createBoxResource({ boxId, gatewaySessionId, now });
return {
serviceId: "hwlab-box-simu",
boxId,
projectId: DEFAULT_PROJECT_ID,
environment: ENVIRONMENT_DEV,
gatewaySessionId,
live: true,
resource: createBoxResource({ boxId, gatewaySessionId, now }),
resource,
capabilities: createBoxCapabilities({
boxId,
resourceId: resource.resourceId,
projectId: DEFAULT_PROJECT_ID,
now
}),
ports,
constraints: {
crossDevicePropagation: "patch-panel-only",
+2 -1
View File
@@ -5,9 +5,10 @@
"type": "module",
"scripts": {
"validate": "node scripts/validate-contract.mjs",
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/server.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node --test internal/agent/index.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node --test internal/agent/index.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
"dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs",
"m1:smoke": "node scripts/m1-contract-smoke.mjs",
"l2:smoke": "node scripts/l2-runtime-contract-smoke.mjs",
"dev:evidence": "node scripts/dev-evidence-blocker-aggregator.mjs --pretty",
"dev:evidence:check": "node scripts/dev-evidence-blocker-aggregator.mjs --check",
"dev-edge:smoke": "node scripts/dev-edge-health-smoke.mjs",
+27
View File
@@ -42,3 +42,30 @@ Capabilities declare:
Hardware operations reference capabilities by `capabilityId` and resources by
`resourceId`. Implementations must not infer a capability from a free-form
operation name when a capability record exists.
## Box Simulator Minimum Runtime
`hwlab-box-simu` exposes the L2 MVP simulator families through stable port
names:
| Family | Port | Direction | Value type |
| --- | --- | --- | --- |
| AI | `AI1` | input | number |
| AO | `AO1` | output | number |
| DI | `DI1` | input | boolean |
| DO | `DO1` | output | boolean |
| FREQ | `FREQ1` | bidirectional | number |
The simulator reports these through `GET /capabilities` and supports direct
`POST /ports/read`, `POST /ports/write`, and `POST /invoke` operations. The
internal patch-panel-owned write surface is `POST /internal/ports/write`.
Box-simu must not use local loopback to copy an output to an input on another
box. Cross-device delivery is patch-panel-owned; box-simu only accepts a local
state update through the internal port API and records
`crossDevicePropagation: "patch-panel-only"` with `localLoopbackEnabled: false`.
`hwlab-gateway-simu` reports registered box capabilities through
`GET /capabilities` or `POST /capabilities/report`, accepts `POST /register`
and `POST /heartbeat`, and forwards `POST /invoke` to the selected box
simulator by `resourceId` or `boxId`.
+437
View File
@@ -0,0 +1,437 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import net from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
AUDIT_SHAPE_FIELDS,
EVIDENCE_SHAPE_FIELDS
} from "../internal/sim/l2-runtime.mjs";
import { ERROR_CODES } from "../internal/protocol/index.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const children = [];
function logOk(name) {
process.stdout.write(`[l2-runtime-smoke] ok ${name}\n`);
}
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : null;
server.close((error) => {
if (error) {
reject(error);
} else {
resolve(port);
}
});
});
});
}
function startNode(relativeScript, env = {}) {
const child = spawn(process.execPath, [relativeScript], {
cwd: repoRoot,
env: {
...process.env,
...env
},
stdio: ["ignore", "pipe", "pipe"]
});
const output = {
stdout: "",
stderr: ""
};
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
output.stdout += chunk;
});
child.stderr.on("data", (chunk) => {
output.stderr += chunk;
});
const record = { child, relativeScript, output };
children.push(record);
return record;
}
async function stopNode(record) {
const { child } = record;
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
await new Promise((resolve) => {
const killTimer = setTimeout(() => {
child.kill("SIGKILL");
}, 1500);
child.once("exit", () => {
clearTimeout(killTimer);
resolve();
});
child.kill("SIGTERM");
});
}
async function requestJson(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
"content-type": "application/json",
...(options.headers ?? {})
}
});
const text = await response.text();
const body = text ? JSON.parse(text) : null;
return { response, body };
}
async function waitForHttp(url, record) {
const startedAt = Date.now();
let lastError;
while (Date.now() - startedAt < 5000) {
if (record.child.exitCode !== null) {
throw new Error(
`${record.relativeScript} exited before readiness\nstdout:\n${record.output.stdout}\nstderr:\n${record.output.stderr}`
);
}
try {
const { response } = await requestJson(url);
if (response.ok) {
return;
}
lastError = new Error(`HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 75));
}
throw new Error(
`${record.relativeScript} did not become ready at ${url}: ${lastError?.message ?? "timeout"}\nstdout:\n${record.output.stdout}\nstderr:\n${record.output.stderr}`
);
}
function assertShape(record, fields, name) {
assert.ok(record && typeof record === "object", `${name} must be an object`);
for (const field of fields) {
assert.ok(Object.hasOwn(record, field), `${name}.${field} is required`);
}
}
function assertBoxStatus(status, { boxId, resourceId }) {
assert.equal(status.serviceId, "hwlab-box-simu");
assert.equal(status.boxId, boxId);
assert.equal(status.resource.resourceId, resourceId);
assert.equal(status.environment, "dev");
assert.equal(status.constraints.crossDevicePropagation, "patch-panel-only");
assert.equal(status.constraints.localLoopbackEnabled, false);
for (const port of ["AI1", "AO1", "DI1", "DO1", "FREQ1"]) {
assert.ok(status.ports[port], `${boxId} has ${port}`);
assert.equal(status.ports[port].internalPortApi, true, `${boxId} ${port} internal API`);
}
}
function assertGatewayStatus(status, { gatewayId, resourceIds }) {
assert.equal(status.serviceId, "hwlab-gateway-simu");
assert.equal(status.gatewayId, gatewayId);
assert.equal(status.session.gatewayId, gatewayId);
assert.equal(status.session.status, "connected");
assert.equal(status.session.environment, "dev");
assert.deepEqual(status.boxes, resourceIds);
}
async function main() {
const ports = {
boxA: await freePort(),
boxB: await freePort(),
gatewayA: await freePort(),
gatewayB: await freePort()
};
const base = {
boxA: `http://127.0.0.1:${ports.boxA}`,
boxB: `http://127.0.0.1:${ports.boxB}`,
gatewayA: `http://127.0.0.1:${ports.gatewayA}`,
gatewayB: `http://127.0.0.1:${ports.gatewayB}`
};
const boxA = startNode("cmd/hwlab-box-simu/main.mjs", {
PORT: String(ports.boxA),
HWLAB_BOX_ID: "box-a",
HWLAB_GATEWAY_SESSION_ID: "gws_gateway-a"
});
const boxB = startNode("cmd/hwlab-box-simu/main.mjs", {
PORT: String(ports.boxB),
HWLAB_BOX_ID: "box-b",
HWLAB_GATEWAY_SESSION_ID: "gws_gateway-b"
});
const gatewayA = startNode("cmd/hwlab-gateway-simu/main.mjs", {
PORT: String(ports.gatewayA),
HWLAB_GATEWAY_ID: "gateway-a",
HWLAB_BOX_RESOURCES: "res_box-a",
HWLAB_BOX_IDS: "box-a",
HWLAB_BOX_URLS: base.boxA
});
const gatewayB = startNode("cmd/hwlab-gateway-simu/main.mjs", {
PORT: String(ports.gatewayB),
HWLAB_GATEWAY_ID: "gateway-b",
HWLAB_BOX_RESOURCES: "res_box-b",
HWLAB_BOX_IDS: "box-b",
HWLAB_BOX_URLS: base.boxB
});
await Promise.all([
waitForHttp(`${base.boxA}/health/live`, boxA),
waitForHttp(`${base.boxB}/health/live`, boxB),
waitForHttp(`${base.gatewayA}/health/live`, gatewayA),
waitForHttp(`${base.gatewayB}/health/live`, gatewayB)
]);
const [boxAStatus, boxBStatus, gatewayAStatus, gatewayBStatus] = await Promise.all([
requestJson(`${base.boxA}/status`),
requestJson(`${base.boxB}/status`),
requestJson(`${base.gatewayA}/status`),
requestJson(`${base.gatewayB}/status`)
]);
assertBoxStatus(boxAStatus.body, { boxId: "box-a", resourceId: "res_box-a" });
assertBoxStatus(boxBStatus.body, { boxId: "box-b", resourceId: "res_box-b" });
assertGatewayStatus(gatewayAStatus.body, { gatewayId: "gateway-a", resourceIds: ["res_box-a"] });
assertGatewayStatus(gatewayBStatus.body, { gatewayId: "gateway-b", resourceIds: ["res_box-b"] });
assert.notEqual(gatewayAStatus.body.session.gatewaySessionId, gatewayBStatus.body.session.gatewaySessionId);
logOk("multi-instance identity");
const registerB = await requestJson(`${base.gatewayB}/register`, {
method: "POST",
body: JSON.stringify({
boxId: "box-b",
resourceId: "res_box-b",
url: base.boxB
})
});
assert.equal(registerB.response.status, 200);
assert.equal(registerB.body.accepted, true);
assert.equal(registerB.body.registered.boxId, "box-b");
const heartbeatA = await requestJson(`${base.gatewayA}/heartbeat`, {
method: "POST",
body: JSON.stringify({
reportedBy: "l2-smoke"
})
});
assert.equal(heartbeatA.response.status, 200);
assert.equal(heartbeatA.body.accepted, true);
assert.equal(heartbeatA.body.heartbeat.boxCount, 1);
logOk("register and heartbeat");
const [capA, capB] = await Promise.all([
requestJson(`${base.gatewayA}/capabilities`),
requestJson(`${base.gatewayB}/capabilities`)
]);
assert.equal(capA.response.status, 200);
assert.equal(capB.response.status, 200);
assert.equal(capA.body.boxes[0].resourceId, "res_box-a");
assert.equal(capB.body.boxes[0].resourceId, "res_box-b");
for (const report of [capA.body, capB.body]) {
const names = new Set(report.boxes.flatMap((box) => box.capabilities.map((capability) => capability.name)));
for (const name of ["ai.read", "ao.write", "di.read", "do.write", "freq.read", "freq.write"]) {
assert.equal(names.has(name), true, `capability report includes ${name}`);
}
}
logOk("capability report");
const aiWrite = await requestJson(`${base.boxA}/ports/write`, {
method: "POST",
body: JSON.stringify({
operationId: "op_l2_ai1_write",
traceId: "trc_l2_ai1_write",
port: "AI1",
value: 1.25,
source: "direct-smoke"
})
});
assert.equal(aiWrite.response.status, 200);
assert.equal(aiWrite.body.value, 1.25);
const aiRead = await requestJson(`${base.boxA}/ports/read`, {
method: "POST",
body: JSON.stringify({
operationId: "op_l2_ai1_read",
traceId: "trc_l2_ai1_read",
port: "AI1"
})
});
assert.equal(aiRead.response.status, 200);
assert.equal(aiRead.body.value, 1.25);
const aoWrite = await requestJson(`${base.gatewayA}/invoke`, {
method: "POST",
body: JSON.stringify({
operationId: "op_l2_ao1_write",
traceId: "trc_l2_ao1_write",
resourceId: "res_box-a",
operation: "port.write",
port: "AO1",
value: 3.3
})
});
assert.equal(aoWrite.response.status, 200);
assert.equal(aoWrite.body.result.value, 3.3);
const freqWrite = await requestJson(`${base.gatewayA}/invoke`, {
method: "POST",
body: JSON.stringify({
operationId: "op_l2_freq1_write",
traceId: "trc_l2_freq1_write",
resourceId: "res_box-a",
operation: "port.write",
port: "FREQ1",
value: 1000
})
});
assert.equal(freqWrite.response.status, 200);
assert.equal(freqWrite.body.result.value, 1000);
const freqRead = await requestJson(`${base.gatewayA}/invoke`, {
method: "POST",
body: JSON.stringify({
operationId: "op_l2_freq1_read",
traceId: "trc_l2_freq1_read",
resourceId: "res_box-a",
operation: "port.read",
port: "FREQ1"
})
});
assert.equal(freqRead.response.status, 200);
assert.equal(freqRead.body.result.value, 1000);
logOk("AI/AO/FREQ state read-write");
const directWrite = await requestJson(`${base.boxA}/ports/write`, {
method: "POST",
body: JSON.stringify({
operationId: "op_l2_direct_do1",
traceId: "trc_l2_direct_do1",
port: "DO1",
value: true,
source: "direct-smoke"
})
});
assert.equal(directWrite.response.status, 200);
assert.equal(directWrite.body.accepted, true);
assert.equal(directWrite.body.port, "DO1");
assert.equal(directWrite.body.value, true);
assert.equal(directWrite.body.crossDevicePropagation, "patch-panel-only");
assert.equal(directWrite.body.localLoopbackEnabled, false);
assertShape(directWrite.body.audit, AUDIT_SHAPE_FIELDS, "directWrite.audit");
assertShape(directWrite.body.evidence, EVIDENCE_SHAPE_FIELDS, "directWrite.evidence");
const internalWrite = await requestJson(`${base.boxB}/internal/ports/write`, {
method: "POST",
body: JSON.stringify({
operationId: "op_l2_internal_di1",
traceId: "trc_l2_internal_di1",
port: "DI1",
value: true,
sourceResourceId: "res_box-a",
sourcePort: "DO1",
propagatedBy: "hwlab-patch-panel",
source: "patch-panel"
})
});
assert.equal(internalWrite.response.status, 200);
assert.equal(internalWrite.body.accepted, true);
assert.equal(internalWrite.body.port, "DI1");
assert.equal(internalWrite.body.value, true);
assertShape(internalWrite.body.audit, AUDIT_SHAPE_FIELDS, "internalWrite.audit");
assertShape(internalWrite.body.evidence, EVIDENCE_SHAPE_FIELDS, "internalWrite.evidence");
logOk("direct call and internal port API");
const invoke = await requestJson(`${base.gatewayB}/invoke`, {
method: "POST",
body: JSON.stringify({
operationId: "op_l2_gateway_read_di1",
traceId: "trc_l2_gateway_read_di1",
resourceId: "res_box-b",
operation: "port.read",
port: "DI1"
})
});
assert.equal(invoke.response.status, 200);
assert.equal(invoke.body.accepted, true);
assert.equal(invoke.body.gatewayId, "gateway-b");
assert.equal(invoke.body.target.resourceId, "res_box-b");
assert.equal(invoke.body.result.value, true);
assertShape(invoke.body.audit, AUDIT_SHAPE_FIELDS, "invoke.audit");
assertShape(invoke.body.evidence, EVIDENCE_SHAPE_FIELDS, "invoke.evidence");
assertShape(invoke.body.result.audit, AUDIT_SHAPE_FIELDS, "invoke.result.audit");
assertShape(invoke.body.result.evidence, EVIDENCE_SHAPE_FIELDS, "invoke.result.evidence");
logOk("gateway invoke forwarding");
const notRegistered = await requestJson(`${base.gatewayA}/invoke`, {
method: "POST",
body: JSON.stringify({
operationId: "op_l2_wrong_gateway",
traceId: "trc_l2_wrong_gateway",
resourceId: "res_box-b",
operation: "port.read",
port: "DI1"
})
});
assert.equal(notRegistered.response.status, 404);
assert.equal(notRegistered.body.error.code, ERROR_CODES.sessionNotFound);
assert.equal(notRegistered.body.error.data.gatewayId, "gateway-a");
const badCapability = await requestJson(`${base.boxA}/invoke`, {
method: "POST",
body: JSON.stringify({
operationId: "op_l2_bad_capability",
traceId: "trc_l2_bad_capability",
operation: "port.read",
capabilityId: "cap_box-a_missing"
})
});
assert.equal(badCapability.response.status, 404);
assert.equal(badCapability.body.error.code, ERROR_CODES.capabilityUnavailable);
assert.equal(badCapability.body.error.data.reason, "capability_unavailable");
const capabilityPortMismatch = await requestJson(`${base.boxA}/invoke`, {
method: "POST",
body: JSON.stringify({
operationId: "op_l2_capability_mismatch",
traceId: "trc_l2_capability_mismatch",
operation: "port.read",
capabilityId: "cap_box-a_di1_read",
port: "DO1"
})
});
assert.equal(capabilityPortMismatch.response.status, 400);
assert.equal(capabilityPortMismatch.body.error.code, ERROR_CODES.invalidParams);
assert.equal(capabilityPortMismatch.body.error.data.reason, "capability_port_mismatch");
logOk("error codes");
const finalBoxA = await requestJson(`${base.boxA}/status`);
const finalBoxB = await requestJson(`${base.boxB}/status`);
assert.equal(finalBoxA.body.ports.DI1.value, false, "box-a DI1 must not loop back from its DO1 write");
assert.equal(finalBoxB.body.ports.DI1.value, true, "box-b DI1 changes only through internal port API");
assert.equal(finalBoxB.body.ports.DI1.propagatedBy, "hwlab-patch-panel");
logOk("patch-panel-only propagation boundary");
process.stdout.write("[l2-runtime-smoke] passed\n");
}
try {
await main();
} finally {
await Promise.allSettled(children.map(stopNode));
}