From 1e9591a6611e52a89cd97aad4ac2f6f63a4148f9 Mon Sep 17 00:00:00 2001 From: HWLAB Code Queue Date: Thu, 21 May 2026 18:21:28 +0000 Subject: [PATCH] feat: add l2 simulator runtime smoke --- README.md | 6 + cmd/hwlab-box-simu/main.mjs | 89 +++-- cmd/hwlab-gateway-simu/main.mjs | 366 +++++++++++++++++++- docs/m1-local-smoke.md | 14 + docs/m3-hardware-loop.md | 26 ++ internal/protocol/index.mjs | 24 ++ internal/sim/http.mjs | 52 +++ internal/sim/l2-runtime.mjs | 472 ++++++++++++++++++++++++++ internal/sim/model.mjs | 204 ++++++++++- package.json | 3 +- protocol/capabilities.md | 27 ++ scripts/l2-runtime-contract-smoke.mjs | 437 ++++++++++++++++++++++++ 12 files changed, 1681 insertions(+), 39 deletions(-) create mode 100644 internal/sim/l2-runtime.mjs create mode 100644 scripts/l2-runtime-contract-smoke.mjs diff --git a/README.md b/README.md index e1f7fd79..a3cf8df0 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/cmd/hwlab-box-simu/main.mjs b/cmd/hwlab-box-simu/main.mjs index ebd4ae15..4d731b2a 100644 --- a/cmd/hwlab-box-simu/main.mjs +++ b/cmd/hwlab-box-simu/main.mjs @@ -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); diff --git a/cmd/hwlab-gateway-simu/main.mjs b/cmd/hwlab-gateway-simu/main.mjs index 8a0ad5b3..d1c6d6eb 100644 --- a/cmd/hwlab-gateway-simu/main.mjs +++ b/cmd/hwlab-gateway-simu/main.mjs @@ -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); diff --git a/docs/m1-local-smoke.md b/docs/m1-local-smoke.md index ca86317f..c12a82c3 100644 --- a/docs/m1-local-smoke.md +++ b/docs/m1-local-smoke.md @@ -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. diff --git a/docs/m3-hardware-loop.md b/docs/m3-hardware-loop.md index a62a2dec..18b6f944 100644 --- a/docs/m3-hardware-loop.md +++ b/docs/m3-hardware-loop.md @@ -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: diff --git a/internal/protocol/index.mjs b/internal/protocol/index.mjs index 49c857e2..77b9ec1d 100644 --- a/internal/protocol/index.mjs +++ b/internal/protocol/index.mjs @@ -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 + }; +} diff --git a/internal/sim/http.mjs b/internal/sim/http.mjs index 19ea5c31..f0c0bc81 100644 --- a/internal/sim/http.mjs +++ b/internal/sim/http.mjs @@ -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) { diff --git a/internal/sim/l2-runtime.mjs b/internal/sim/l2-runtime.mjs new file mode 100644 index 00000000..75956bea --- /dev/null +++ b/internal/sim/l2-runtime.mjs @@ -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; +} diff --git a/internal/sim/model.mjs b/internal/sim/model.mjs index d1f4fd0c..e55d690c 100644 --- a/internal/sim/model.mjs +++ b/internal/sim/model.mjs @@ -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", diff --git a/package.json b/package.json index 9db45861..c8e0dcb3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/protocol/capabilities.md b/protocol/capabilities.md index c40bb215..60f446c4 100644 --- a/protocol/capabilities.md +++ b/protocol/capabilities.md @@ -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`. diff --git a/scripts/l2-runtime-contract-smoke.mjs b/scripts/l2-runtime-contract-smoke.mjs new file mode 100644 index 00000000..163038b7 --- /dev/null +++ b/scripts/l2-runtime-contract-smoke.mjs @@ -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)); +}