feat: add l2 simulator runtime smoke
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user