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 npm run check
``` ```
Run only the L2 gateway/box simulator runtime smoke:
```sh
npm run l2:smoke
```
## L6 Manual Acceptance Entry ## L6 Manual Acceptance Entry
The external MVP acceptance surface is DEV-only and report-driven: The external MVP acceptance surface is DEV-only and report-driven:
+68 -21
View File
@@ -1,37 +1,84 @@
#!/usr/bin/env node #!/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"; 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 port = parsePort(process.env.PORT, 7201);
const state = createBoxState({ boxId }); const state = createBoxState({ boxId, gatewaySessionId });
const routes = new Map(); 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 /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 }) => { routes.set("POST /ports/write", async ({ req }) => {
const body = await readJson(req); const body = await readJson(req);
if (!state.ports[body.port]) { return writeBoxPort({
throw new Error(`unknown port ${body.port}`); state,
}
const now = new Date().toISOString();
state.ports[body.port] = {
port: body.port, port: body.port,
value: body.value ?? null, capabilityId: body.capabilityId,
value: body.value,
source: body.source ?? "local", source: body.source ?? "local",
updatedAt: now context: operationContext(body, { serviceId: "hwlab-box-simu" })
}; });
state.updatedAt = now; });
routes.set("POST /internal/ports/write", async ({ req }) => {
return { const body = await readJson(req);
accepted: true, return writeBoxPort({
boxId, state,
port: body.port, port: body.port,
crossDevicePropagation: "patch-panel-only", capabilityId: body.capabilityId,
localLoopbackEnabled: false 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); listen(createJsonServer({ routes }), port);
+356 -10
View File
@@ -1,25 +1,371 @@
#!/usr/bin/env node #!/usr/bin/env node
import { createGatewayState, createHealth } from "../../internal/sim/model.mjs"; import { createAuditEvent, createEvidenceRecord, operationContext } from "../../internal/sim/l2-runtime.mjs";
import { createJsonServer, listen, parsePort } from "../../internal/sim/http.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 port = parsePort(process.env.PORT, 7101);
const endpoint = `http://127.0.0.1:${port}`; const endpoint = `http://127.0.0.1:${port}`;
const boxes = (process.env.HWLAB_BOX_RESOURCES ?? "res_boxsim_alpha") const boxUrls = parseBoxUrls(process.env.HWLAB_BOX_URLS);
.split(",") const boxes = parseBoxes({
.map((item) => item.trim()) resources: process.env.HWLAB_BOX_RESOURCES,
.filter(Boolean); ids: process.env.HWLAB_BOX_IDS,
const state = createGatewayState({ gatewayId, endpoint, boxes }); 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(); const routes = new Map();
routes.set("GET /health/live", () => 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 /status", () => state);
routes.set("GET /boxes", () => ({ routes.set("GET /boxes", () => ({
gatewayId, 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); 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. loop, agent automation closed loop, evidence persistence, and cleanup behavior.
That later test should produce deployment/evidence artifacts and must remain That later test should produce deployment/evidence artifacts and must remain
separate from this local smoke harness. 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` wiring contract and must keep routing decisions under `hwlab-patch-panel`
ownership instead of mutating another box directly. 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 ## DEV Runtime Smoke
Use this command only for the DEV simulator runtime: 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"); 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"; 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) { export function jsonResponse(res, statusCode, body) {
const payload = JSON.stringify(body, null, 2); const payload = JSON.stringify(body, null, 2);
res.writeHead(statusCode, { res.writeHead(statusCode, {
@@ -40,6 +48,24 @@ export function createJsonServer({ routes }) {
const body = await handler({ req, url }); const body = await handler({ req, url });
jsonResponse(res, 200, body); jsonResponse(res, 200, body);
} catch (error) { } 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, { jsonResponse(res, 500, {
error: "internal_error", error: "internal_error",
message: error instanceof Error ? error.message : String(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) { export function parsePort(value, fallback) {
const parsed = Number.parseInt(value ?? "", 10); const parsed = Number.parseInt(value ?? "", 10);
if (Number.isInteger(parsed) && parsed > 0 && parsed < 65536) { 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_PROJECT_ID = "prj_mvp_topology";
export const DEFAULT_GATEWAY_SESSION_ID = "gws_mvp_simu"; 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() { function isoNow() {
return new Date().toISOString(); 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() }) { export function createBoxResource({ boxId, gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID, now = isoNow() }) {
return { return {
resourceId: `res_${boxId}`, resourceId: resourceIdForBox(boxId),
projectId: DEFAULT_PROJECT_ID, projectId: DEFAULT_PROJECT_ID,
gatewaySessionId, gatewaySessionId,
boxId, boxId,
@@ -22,6 +129,7 @@ export function createBoxResource({ boxId, gatewaySessionId = DEFAULT_GATEWAY_SE
metadata: { metadata: {
serviceId: "hwlab-box-simu", serviceId: "hwlab-box-simu",
ports: SIM_PORTS, ports: SIM_PORTS,
portFamilies: L2_PORT_FAMILIES,
propagation: "patch-panel-only" propagation: "patch-panel-only"
}, },
createdAt: now, 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({ export function createGatewaySession({
gatewayId, gatewayId,
serviceId = "hwlab-gateway-simu", serviceId = "hwlab-gateway-simu",
@@ -53,24 +227,40 @@ export function createGatewaySession({
export function createBoxState({ boxId, gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID, now = isoNow() }) { export function createBoxState({ boxId, gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID, now = isoNow() }) {
const ports = Object.fromEntries( const ports = Object.fromEntries(
SIM_PORTS.map((port) => [ SIM_PORT_DEFINITIONS.map((definition) => [
port, definition.port,
{ {
port, port: definition.port,
value: null, family: definition.family,
direction: definition.direction,
valueType: definition.valueType,
...(definition.unit ? { unit: definition.unit } : {}),
value: definition.initialValue,
source: "local", source: "local",
internalPortApi: true,
updatedAt: now updatedAt: now
} }
]) ])
); );
for (const definition of SIM_PORT_DEFINITIONS) {
ports[definition.port].value = definition.initialValue;
}
const resource = createBoxResource({ boxId, gatewaySessionId, now });
return { return {
serviceId: "hwlab-box-simu", serviceId: "hwlab-box-simu",
boxId, boxId,
projectId: DEFAULT_PROJECT_ID, projectId: DEFAULT_PROJECT_ID,
environment: ENVIRONMENT_DEV,
gatewaySessionId, gatewaySessionId,
live: true, live: true,
resource: createBoxResource({ boxId, gatewaySessionId, now }), resource,
capabilities: createBoxCapabilities({
boxId,
resourceId: resource.resourceId,
projectId: DEFAULT_PROJECT_ID,
now
}),
ports, ports,
constraints: { constraints: {
crossDevicePropagation: "patch-panel-only", crossDevicePropagation: "patch-panel-only",
+2 -1
View File
@@ -5,9 +5,10 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"validate": "node scripts/validate-contract.mjs", "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", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs",
"m1:smoke": "node scripts/m1-contract-smoke.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": "node scripts/dev-evidence-blocker-aggregator.mjs --pretty",
"dev:evidence:check": "node scripts/dev-evidence-blocker-aggregator.mjs --check", "dev:evidence:check": "node scripts/dev-evidence-blocker-aggregator.mjs --check",
"dev-edge:smoke": "node scripts/dev-edge-health-smoke.mjs", "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 Hardware operations reference capabilities by `capabilityId` and resources by
`resourceId`. Implementations must not infer a capability from a free-form `resourceId`. Implementations must not infer a capability from a free-form
operation name when a capability record exists. 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));
}