feat: add patch panel runtime sync

This commit is contained in:
HWLAB Code Queue
2026-05-21 18:36:39 +00:00
parent eba24b72c0
commit b1f4e6e52d
14 changed files with 1583 additions and 45 deletions
+25 -5
View File
@@ -1,20 +1,40 @@
#!/usr/bin/env node
import { createMvpTopology } from "../../internal/patchpanel/fixture.mjs";
import { createPatchPanel } from "../../internal/patchpanel/model.mjs";
import {
createPatchPanelRuntime,
createPatchPanelRuntimeFromFile
} from "../../internal/patchpanel/runtime.mjs";
import { createHealth } from "../../internal/sim/model.mjs";
import { createJsonServer, listen, parsePort, readJson } from "../../internal/sim/http.mjs";
const port = parsePort(process.env.PORT, 7301);
const wiringConfigPath = process.env.HWLAB_WIRING_CONFIG_PATH;
const topology = createMvpTopology();
const patchPanel = createPatchPanel({ wiringConfig: topology.wiringConfig });
const runtime = wiringConfigPath
? await createPatchPanelRuntimeFromFile({ path: wiringConfigPath })
: createPatchPanelRuntime({
wiringConfig: topology.wiringConfig,
configSource: "internal:mvp-topology"
});
const routes = new Map();
routes.set("GET /health/live", () =>
createHealth({ serviceId: "hwlab-patch-panel", name: "hwlab-patch-panel" })
);
routes.set("GET /status", () => patchPanel.status());
routes.set("GET /wiring", () => patchPanel.wiringConfig);
routes.set("POST /signals/route", async ({ req }) => patchPanel.routeSignal(await readJson(req)));
routes.set("GET /status", () => runtime.status());
routes.set("GET /diagnostics", () => runtime.diagnostics());
routes.set("GET /wiring", () => runtime.wiringConfig);
routes.set("POST /wiring/apply", async ({ req }) => runtime.applyConfig(await readJson(req)));
routes.set("POST /wiring/reload", async ({ req }) => {
const body = await readJson(req);
const path = body.path ?? wiringConfigPath;
if (!path) {
throw new Error("wiring reload requires body.path or HWLAB_WIRING_CONFIG_PATH");
}
return runtime.reloadFromFile(path);
});
routes.set("POST /signals/route", async ({ req }) => runtime.routeSignal(await readJson(req)));
routes.set("POST /sync/tick", async ({ req }) => runtime.tick(await readJson(req)));
listen(createJsonServer({ routes }), port);
@@ -0,0 +1,16 @@
# Patch Panel Runtime Fixture
`two-box-sync.json` is a local runtime smoke fixture for `hwlab-patch-panel`.
It starts two `hwlab-box-simu` services and applies one active wiring config to
the patch panel.
The smoke proves that patch-panel-owned propagation can synchronize:
```text
res_boxsimu_runtime_1:DO1 -> res_boxsimu_runtime_2:DI1
res_boxsimu_runtime_1:AO1 -> res_boxsimu_runtime_2:AI1
res_boxsimu_runtime_1:FREQ_OUT1 -> res_boxsimu_runtime_2:FREQ_IN1
```
The local smoke does not deploy DEV/PROD services, connect to real hardware, or
write audit rows.
@@ -0,0 +1,105 @@
{
"fixtureId": "fixture_patch_panel_runtime_two_box_sync",
"environment": "dev",
"description": "Local two-box patch-panel runtime smoke fixture.",
"boxes": [
{
"boxId": "boxsimu_runtime_1",
"resourceId": "res_boxsimu_runtime_1"
},
{
"boxId": "boxsimu_runtime_2",
"resourceId": "res_boxsimu_runtime_2"
}
],
"runtimeConfig": {
"wiringConfig": {
"wiringConfigId": "wir_patch_panel_runtime_two_box",
"projectId": "prj_patch_panel_runtime",
"gatewaySessionId": "gws_patch_panel_runtime",
"name": "Patch panel runtime two-box smoke wiring",
"status": "active",
"connections": [
{
"from": {
"resourceId": "res_boxsimu_runtime_1",
"port": "DO1"
},
"to": {
"resourceId": "res_boxsimu_runtime_2",
"port": "DI1"
},
"mode": "exclusive"
},
{
"from": {
"resourceId": "res_boxsimu_runtime_1",
"port": "AO1"
},
"to": {
"resourceId": "res_boxsimu_runtime_2",
"port": "AI1"
},
"mode": "exclusive"
},
{
"from": {
"resourceId": "res_boxsimu_runtime_1",
"port": "FREQ_OUT1"
},
"to": {
"resourceId": "res_boxsimu_runtime_2",
"port": "FREQ_IN1"
},
"mode": "exclusive"
}
],
"constraints": {
"propagation": "patch-panel-only",
"localLoopbackSubstituteAllowed": false
},
"createdAt": "2026-05-21T00:00:00.000Z",
"updatedAt": "2026-05-21T00:00:00.000Z"
},
"endpointMap": {
"res_boxsimu_runtime_1": "http://127.0.0.1:0",
"res_boxsimu_runtime_2": "http://127.0.0.1:0"
}
},
"signals": [
{
"fromResourceId": "res_boxsimu_runtime_1",
"fromPort": "DO1",
"value": true
},
{
"fromResourceId": "res_boxsimu_runtime_1",
"fromPort": "AO1",
"value": 1.25
},
{
"fromResourceId": "res_boxsimu_runtime_1",
"fromPort": "FREQ_OUT1",
"value": 1000
}
],
"expected": {
"targetResourceId": "res_boxsimu_runtime_2",
"ports": {
"DI1": true,
"AI1": 1.25,
"FREQ_IN1": 1000
}
},
"invalidWiringProbe": {
"from": {
"resourceId": "res_boxsimu_runtime_1",
"port": "DI1"
},
"to": {
"resourceId": "res_boxsimu_runtime_2",
"port": "DO1"
},
"mode": "exclusive"
}
}
+2
View File
@@ -10,6 +10,8 @@ export function createMvpTopology({ now = "2026-01-01T00:00:00.000Z" } = {}) {
createBoxState({
boxId,
gatewaySessionId: gatewaySessionIds[index],
ports: ["uart0", "eth0", "gpio0"],
includePortDefinitions: false,
now
})
);
+350 -15
View File
@@ -1,10 +1,28 @@
import { ENVIRONMENT_DEV } from "../protocol/index.mjs";
import { DEFAULT_GATEWAY_SESSION_ID, DEFAULT_PROJECT_ID } from "../sim/model.mjs";
export const PATCH_PANEL_SERVICE_ID = "hwlab-patch-panel";
const KNOWN_OUTPUT_FAMILIES = Object.freeze(["DO", "AO", "FREQ_OUT"]);
const SIGNAL_TARGET_FAMILY = Object.freeze({
DO: "DI",
AO: "AI",
FREQ_OUT: "FREQ_IN"
});
const SIGNAL_VALUE_TYPE = Object.freeze({
"DO->DI": "boolean",
"AO->AI": "number",
"FREQ_OUT->FREQ_IN": "number"
});
export function createWiringConfig({
wiringConfigId = "wir_mvp_patch_panel",
projectId = DEFAULT_PROJECT_ID,
gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID,
name = "MVP simulated patch panel wiring",
status = "active",
connections,
constraints,
now = new Date().toISOString()
}) {
if (!Array.isArray(connections) || connections.length === 0) {
@@ -13,12 +31,12 @@ export function createWiringConfig({
return {
wiringConfigId,
projectId: DEFAULT_PROJECT_ID,
projectId,
gatewaySessionId,
name: "MVP simulated patch panel wiring",
status: "active",
name,
status,
connections,
constraints: {
constraints: constraints ?? {
propagation: "patch-panel-only",
localLoopbackSubstituteAllowed: false
},
@@ -29,17 +47,22 @@ export function createWiringConfig({
export function createPatchPanelState({
patchPanelStatusId = "pps_mvp_patch_panel",
gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID,
gatewaySessionId = null,
wiringConfig,
state = "active",
metadata = {},
now = new Date().toISOString()
}) {
const effectiveGatewaySessionId =
gatewaySessionId ?? wiringConfig.gatewaySessionId ?? DEFAULT_GATEWAY_SESSION_ID;
return {
patchPanelStatusId,
projectId: DEFAULT_PROJECT_ID,
gatewaySessionId,
projectId: wiringConfig.projectId ?? DEFAULT_PROJECT_ID,
gatewaySessionId: effectiveGatewaySessionId,
wiringConfigId: wiringConfig.wiringConfigId,
serviceId: "hwlab-patch-panel",
state: "active",
serviceId: PATCH_PANEL_SERVICE_ID,
state,
environment: ENVIRONMENT_DEV,
activeConnections: wiringConfig.connections.map((connection) => ({
fromResourceId: connection.from.resourceId,
@@ -50,30 +73,319 @@ export function createPatchPanelState({
observedAt: now,
metadata: {
propagation: "patch-panel-only",
connectionCount: wiringConfig.connections.length
connectionCount: wiringConfig.connections.length,
...metadata
}
};
}
export function endpointKey(resourceId, port) {
return `${resourceId}:${port}`;
}
export function classifySignalPort(port) {
const normalized = String(port ?? "").trim().toUpperCase();
if (!normalized) {
return null;
}
const freqOut = normalized.match(/^FREQ_OUT(?<suffix>[A-Z0-9_-]*)$/);
if (freqOut) {
return {
family: "FREQ_OUT",
direction: "output",
valueType: "number",
suffix: freqOut.groups.suffix ?? ""
};
}
const freqIn = normalized.match(/^FREQ_IN(?<suffix>[A-Z0-9_-]*)$/);
if (freqIn) {
return {
family: "FREQ_IN",
direction: "input",
valueType: "number",
suffix: freqIn.groups.suffix ?? ""
};
}
const discrete = normalized.match(/^(?<family>DO|DI|AO|AI)(?<suffix>[A-Z0-9_-]*)$/);
if (!discrete) {
return null;
}
const family = discrete.groups.family;
return {
family,
direction: family === "DO" || family === "AO" ? "output" : "input",
valueType: family === "DO" || family === "DI" ? "boolean" : "number",
suffix: discrete.groups.suffix ?? ""
};
}
function issue(level, code, message, path, extra = {}) {
return {
level,
code,
message,
path,
...extra
};
}
function endpointDiagnostic(endpoint, path) {
if (!endpoint || typeof endpoint !== "object" || Array.isArray(endpoint)) {
return issue("error", "endpoint_invalid", "wiring endpoint must be an object", path);
}
if (!endpoint.resourceId || typeof endpoint.resourceId !== "string") {
return issue("error", "endpoint_resource_missing", "wiring endpoint requires resourceId", `${path}.resourceId`);
}
if (!endpoint.port || typeof endpoint.port !== "string") {
return issue("error", "endpoint_port_missing", "wiring endpoint requires port", `${path}.port`);
}
return null;
}
function describeExpectedTarget(fromFamily) {
const targetFamily = SIGNAL_TARGET_FAMILY[fromFamily];
if (!targetFamily) {
return `one of ${KNOWN_OUTPUT_FAMILIES.join(", ")}`;
}
return targetFamily;
}
function normalizeConnection(connection, index) {
const path = `$.connections[${index}]`;
const diagnostics = [];
if (!connection || typeof connection !== "object" || Array.isArray(connection)) {
return {
diagnostics: [issue("error", "connection_invalid", "wiring connection must be an object", path)],
normalized: null
};
}
for (const side of ["from", "to"]) {
const endpointIssue = endpointDiagnostic(connection[side], `${path}.${side}`);
if (endpointIssue) {
diagnostics.push(endpointIssue);
}
}
if (diagnostics.some((item) => item.level === "error")) {
return { diagnostics, normalized: null };
}
const fromKind = classifySignalPort(connection.from.port);
const toKind = classifySignalPort(connection.to.port);
let syncKind = "generic";
let valueType = "any";
let syncable = false;
if (fromKind || toKind) {
if (!fromKind || !toKind) {
diagnostics.push(
issue(
"error",
"typed_signal_endpoint_mismatch",
`typed signal wiring requires both endpoints to use signal ports; got ${connection.from.port} -> ${connection.to.port}`,
path
)
);
} else if (fromKind.direction !== "output") {
diagnostics.push(
issue(
"error",
"source_port_not_output",
`${connection.from.port} is an input port and cannot drive ${connection.to.port}; expected source family ${KNOWN_OUTPUT_FAMILIES.join(", ")}`,
`${path}.from.port`
)
);
} else if (toKind.direction !== "input") {
diagnostics.push(
issue(
"error",
"target_port_not_input",
`${connection.to.port} is an output port and cannot be driven by ${connection.from.port}; expected target family ${describeExpectedTarget(fromKind.family)}`,
`${path}.to.port`
)
);
} else if (SIGNAL_TARGET_FAMILY[fromKind.family] !== toKind.family) {
diagnostics.push(
issue(
"error",
"unsupported_signal_wiring",
`${fromKind.family} output port ${connection.from.port} can only drive ${describeExpectedTarget(fromKind.family)} input ports; got ${connection.to.port}`,
path
)
);
} else {
syncKind = `${fromKind.family}->${toKind.family}`;
valueType = SIGNAL_VALUE_TYPE[syncKind];
syncable = true;
}
}
return {
diagnostics,
normalized: {
index,
from: connection.from,
to: connection.to,
mode: connection.mode ?? "exclusive",
syncKind,
valueType,
syncable,
bidirectionalRoute: syncKind === "generic"
}
};
}
export function validateWiringConfig(wiringConfig) {
const diagnostics = [];
const normalizedConnections = [];
if (!wiringConfig || typeof wiringConfig !== "object" || Array.isArray(wiringConfig)) {
return {
diagnostics: [
issue("error", "wiring_config_invalid", "wiring config must be an object", "$")
],
normalizedConnections
};
}
for (const field of ["wiringConfigId", "projectId", "gatewaySessionId", "status"]) {
if (!wiringConfig[field] || typeof wiringConfig[field] !== "string") {
diagnostics.push(
issue("error", "wiring_config_field_missing", `wiring config requires string ${field}`, `$.${field}`)
);
}
}
if (wiringConfig.status !== "active") {
diagnostics.push(
issue(
"error",
"wiring_config_not_active",
`patch-panel runtime can only apply active wiring configs; got ${JSON.stringify(wiringConfig.status)}`,
"$.status"
)
);
}
if (wiringConfig.constraints?.propagation !== "patch-panel-only") {
diagnostics.push(
issue(
"error",
"wiring_propagation_invalid",
"wiring config constraints.propagation must be patch-panel-only",
"$.constraints.propagation"
)
);
}
if (wiringConfig.constraints?.localLoopbackSubstituteAllowed !== false) {
diagnostics.push(
issue(
"error",
"local_loopback_substitute_invalid",
"wiring config must set constraints.localLoopbackSubstituteAllowed=false",
"$.constraints.localLoopbackSubstituteAllowed"
)
);
}
if (!Array.isArray(wiringConfig.connections) || wiringConfig.connections.length === 0) {
diagnostics.push(
issue("error", "connections_missing", "wiring config requires at least one connection", "$.connections")
);
} else {
wiringConfig.connections.forEach((connection, index) => {
const result = normalizeConnection(connection, index);
diagnostics.push(...result.diagnostics);
if (result.normalized) {
normalizedConnections.push(result.normalized);
}
});
}
return {
diagnostics,
normalizedConnections
};
}
function diagnosticSummary(diagnostics) {
return {
errorCount: diagnostics.filter((item) => item.level === "error").length,
warningCount: diagnostics.filter((item) => item.level === "warning").length,
infoCount: diagnostics.filter((item) => item.level === "info").length
};
}
function validateValueForConnection(value, connection) {
if (connection.valueType === "boolean" && typeof value !== "boolean") {
return issue(
"error",
"signal_value_type_mismatch",
`${connection.syncKind} requires a boolean value; got ${typeof value}`,
`$.connections[${connection.index}]`
);
}
if (connection.valueType === "number" && typeof value !== "number") {
return issue(
"error",
"signal_value_type_mismatch",
`${connection.syncKind} requires a number value; got ${typeof value}`,
`$.connections[${connection.index}]`
);
}
return null;
}
export function createPatchPanel({ wiringConfig, now = () => new Date().toISOString() }) {
const signals = new Map();
const validation = validateWiringConfig(wiringConfig);
const summary = diagnosticSummary(validation.diagnostics);
function routeSignal({ fromResourceId, fromPort, value }) {
const observedAt = now();
const deliveries = [];
const routeDiagnostics = [];
for (const connection of wiringConfig.connections) {
if (summary.errorCount > 0) {
return {
accepted: false,
propagatedBy: PATCH_PANEL_SERVICE_ID,
deliveryCount: 0,
deliveries,
diagnostics: validation.diagnostics,
observedAt
};
}
for (const connection of validation.normalizedConnections) {
const forwardMatch =
connection.from.resourceId === fromResourceId && connection.from.port === fromPort;
const reverseMatch =
connection.to.resourceId === fromResourceId && connection.to.port === fromPort;
connection.bidirectionalRoute &&
connection.to.resourceId === fromResourceId &&
connection.to.port === fromPort;
if (!forwardMatch && !reverseMatch) {
continue;
}
const valueIssue = validateValueForConnection(value, connection);
if (valueIssue) {
routeDiagnostics.push(valueIssue);
continue;
}
const target = forwardMatch ? connection.to : connection.from;
const key = `${target.resourceId}:${target.port}`;
const key = endpointKey(target.resourceId, target.port);
const delivery = {
resourceId: target.resourceId,
port: target.port,
@@ -86,24 +398,47 @@ export function createPatchPanel({ wiringConfig, now = () => new Date().toISOStr
deliveries.push(delivery);
}
return {
if (routeDiagnostics.some((item) => item.level === "error")) {
return {
accepted: false,
propagatedBy: PATCH_PANEL_SERVICE_ID,
deliveryCount: deliveries.length,
deliveries,
diagnostics: routeDiagnostics,
observedAt
};
}
const response = {
accepted: true,
propagatedBy: "hwlab-patch-panel",
propagatedBy: PATCH_PANEL_SERVICE_ID,
deliveryCount: deliveries.length,
deliveries,
observedAt
};
if (routeDiagnostics.length > 0) {
response.diagnostics = routeDiagnostics;
}
return response;
}
function status() {
return createPatchPanelState({
wiringConfig,
state: summary.errorCount > 0 ? "faulted" : "active",
metadata: {
diagnostics: summary,
syncableConnectionCount: validation.normalizedConnections.filter((connection) => connection.syncable).length
},
now: now()
});
}
return {
wiringConfig,
diagnostics: validation.diagnostics,
diagnosticSummary: summary,
normalizedConnections: validation.normalizedConnections,
routeSignal,
status,
signals
+77 -1
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { createMvpTopology } from "./fixture.mjs";
import { createPatchPanel } from "./model.mjs";
import { createPatchPanel, createWiringConfig } from "./model.mjs";
test("MVP topology includes two box simulators, two gateway simulators, and one patch panel", () => {
const topology = createMvpTopology();
@@ -50,3 +50,79 @@ test("static dry-run fixture matches generated MVP topology", async () => {
const raw = await readFile(new URL("../../fixtures/mvp-topology/topology.json", import.meta.url), "utf8");
assert.deepEqual(JSON.parse(raw), createMvpTopology());
});
test("patch panel validates typed DO/AO/FREQ wiring and reports readable wrong-wire diagnostics", () => {
const wiringConfig = createWiringConfig({
wiringConfigId: "wir_typed_signals",
projectId: "prj_typed_signals",
gatewaySessionId: "gws_typed_signals",
connections: [
{
from: {
resourceId: "res_alpha",
port: "DO1"
},
to: {
resourceId: "res_beta",
port: "DI1"
},
mode: "exclusive"
},
{
from: {
resourceId: "res_alpha",
port: "AO1"
},
to: {
resourceId: "res_beta",
port: "AI1"
},
mode: "exclusive"
},
{
from: {
resourceId: "res_alpha",
port: "FREQ_OUT1"
},
to: {
resourceId: "res_beta",
port: "FREQ_IN1"
},
mode: "exclusive"
}
],
now: "2026-01-01T00:00:00.000Z"
});
const patchPanel = createPatchPanel({ wiringConfig });
assert.equal(patchPanel.diagnosticSummary.errorCount, 0);
assert.deepEqual(
patchPanel.normalizedConnections.map((connection) => connection.syncKind),
["DO->DI", "AO->AI", "FREQ_OUT->FREQ_IN"]
);
const invalid = createPatchPanel({
wiringConfig: createWiringConfig({
wiringConfigId: "wir_invalid_signal",
projectId: "prj_typed_signals",
gatewaySessionId: "gws_typed_signals",
connections: [
{
from: {
resourceId: "res_alpha",
port: "DI1"
},
to: {
resourceId: "res_beta",
port: "DO1"
},
mode: "exclusive"
}
],
now: "2026-01-01T00:00:00.000Z"
})
});
assert.equal(invalid.status().state, "faulted");
assert.ok(invalid.diagnostics.some((diagnostic) => diagnostic.code === "source_port_not_output"));
});
+470
View File
@@ -0,0 +1,470 @@
import { readFile } from "node:fs/promises";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { ENVIRONMENT_DEV } from "../protocol/index.mjs";
import { DEFAULT_GATEWAY_SESSION_ID, DEFAULT_PROJECT_ID } from "../sim/model.mjs";
import {
PATCH_PANEL_SERVICE_ID,
createPatchPanel,
createWiringConfig,
endpointKey
} from "./model.mjs";
const DEFAULT_APPLIED_AT = "not_applied";
function isoNow() {
return new Date().toISOString();
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function ensureObject(value, label) {
if (!isObject(value)) {
throw new Error(`${label} must be an object`);
}
}
function normalizeEndpointMap(raw = {}) {
ensureObject(raw, "endpoint map");
const byResourceId = new Map();
const diagnostics = [];
for (const [resourceId, endpoint] of Object.entries(raw)) {
if (typeof endpoint !== "string" || endpoint.trim().length === 0) {
diagnostics.push({
level: "error",
code: "endpoint_url_invalid",
message: `endpoint map entry ${resourceId} must be a non-empty URL string`,
path: `$.endpointMap.${resourceId}`
});
continue;
}
try {
const normalized = new URL(endpoint).toString();
byResourceId.set(resourceId, normalized);
} catch {
diagnostics.push({
level: "error",
code: "endpoint_url_invalid",
message: `endpoint map entry ${resourceId} is not a valid URL: ${endpoint}`,
path: `$.endpointMap.${resourceId}`
});
}
}
return { byResourceId, diagnostics };
}
function wiringFromRuntimeConfig(config, now) {
if (isObject(config.wiringConfig)) {
return clone(config.wiringConfig);
}
return createWiringConfig({
wiringConfigId: config.wiringConfigId ?? "wir_patch_panel_runtime",
projectId: config.projectId ?? DEFAULT_PROJECT_ID,
gatewaySessionId: config.gatewaySessionId ?? DEFAULT_GATEWAY_SESSION_ID,
name: config.name ?? "Patch panel runtime wiring",
status: config.status ?? "active",
connections: config.connections,
constraints: config.constraints,
now
});
}
export async function readPatchPanelRuntimeConfig(path) {
const raw = await readFile(path, "utf8");
return JSON.parse(raw);
}
export function createPatchPanelRuntime({
wiringConfig,
endpointMap = {},
configSource = "inline",
now = isoNow,
requestJson = postJson
}) {
let appliedAt = DEFAULT_APPLIED_AT;
let activeWiringConfig = null;
let patchPanel = null;
let endpoints = new Map();
let configDiagnostics = [];
let evidenceSummary = {
configApplied: false,
configSource,
appliedAt,
lastSyncAt: "not_run",
routeCount: 0,
tickCount: 0,
deliveryCount: 0,
failedDeliveryCount: 0
};
function applyConfig(config) {
const observedAt = now();
const source = config.configSource ?? configSource;
const runtimeConfig = isObject(config.wiringConfig) || Array.isArray(config.connections) ? config : {};
const nextWiringConfig = wiringFromRuntimeConfig(runtimeConfig, observedAt);
const endpointResult = normalizeEndpointMap(runtimeConfig.endpointMap ?? config.endpointMap ?? endpointMap);
const nextPatchPanel = createPatchPanel({
wiringConfig: nextWiringConfig,
now
});
const endpointDiagnostics = endpointResult.diagnostics;
const targetDiagnostics = [];
for (const connection of nextPatchPanel.normalizedConnections) {
const targetResourceId = connection.to.resourceId;
if (connection.syncable && !endpointResult.byResourceId.has(targetResourceId)) {
targetDiagnostics.push({
level: "error",
code: "target_endpoint_missing",
message: `sync target ${targetResourceId}:${connection.to.port} has no endpoint URL in endpointMap`,
path: `$.endpointMap.${targetResourceId}`
});
}
}
activeWiringConfig = nextWiringConfig;
patchPanel = nextPatchPanel;
endpoints = endpointResult.byResourceId;
configDiagnostics = [
...nextPatchPanel.diagnostics,
...endpointDiagnostics,
...targetDiagnostics
];
appliedAt = observedAt;
evidenceSummary = {
...evidenceSummary,
configApplied: true,
configSource: source,
appliedAt,
wiringConfigId: activeWiringConfig.wiringConfigId,
connectionCount: activeWiringConfig.connections.length,
syncableConnectionCount: nextPatchPanel.normalizedConnections.filter((connection) => connection.syncable).length,
diagnostics: diagnosticSummary(configDiagnostics)
};
return {
accepted: diagnosticSummary(configDiagnostics).errorCount === 0,
appliedAt,
wiringConfigId: activeWiringConfig.wiringConfigId,
diagnostics: configDiagnostics,
summary: evidenceSummary
};
}
async function reloadFromFile(path) {
const config = await readPatchPanelRuntimeConfig(path);
return applyConfig({
...config,
configSource: path
});
}
function diagnostics() {
return {
serviceId: PATCH_PANEL_SERVICE_ID,
environment: ENVIRONMENT_DEV,
state: diagnosticSummary(configDiagnostics).errorCount > 0 ? "faulted" : "active",
observedAt: now(),
configSource: evidenceSummary.configSource,
appliedAt,
wiringConfigId: activeWiringConfig?.wiringConfigId ?? null,
endpointMap: Object.fromEntries(endpoints.entries()),
diagnostics: configDiagnostics,
summary: evidenceSummary
};
}
function status() {
const state = patchPanel.status();
const summary = diagnosticSummary(configDiagnostics);
return {
...state,
state: summary.errorCount > 0 ? "faulted" : state.state,
metadata: {
...state.metadata,
configSource: evidenceSummary.configSource,
appliedAt,
diagnostics: summary,
evidenceSummary
}
};
}
async function routeSignal(signal) {
const result = patchPanel.routeSignal(signal);
const diagnostics = [...(result.diagnostics ?? [])];
if (!result.accepted) {
evidenceSummary = {
...evidenceSummary,
routeCount: evidenceSummary.routeCount + 1,
lastSyncAt: result.observedAt,
diagnostics: diagnosticSummary([...configDiagnostics, ...diagnostics])
};
return {
...result,
...(diagnostics.length > 0 ? { diagnostics } : {})
};
}
const hasEndpointDeliveries = result.deliveries.some((delivery) => {
const connection = findDeliveryConnection(delivery);
return connection?.syncable === true;
});
if (!hasEndpointDeliveries) {
evidenceSummary = {
...evidenceSummary,
routeCount: evidenceSummary.routeCount + 1,
lastSyncAt: result.observedAt,
deliveryCount: evidenceSummary.deliveryCount + result.deliveries.length,
diagnostics: diagnosticSummary([...configDiagnostics, ...diagnostics])
};
return {
...result,
...(diagnostics.length > 0 ? { diagnostics } : {})
};
}
const deliveries = [];
for (const delivery of result.deliveries) {
const connection = findDeliveryConnection(delivery);
const write = await deliverToEndpoint(delivery, { requireEndpoint: connection?.syncable === true });
deliveries.push({
...delivery,
deliveryStatus: write.deliveryStatus ?? (write.accepted ? "applied" : "failed"),
endpoint: write.endpoint ?? null,
diagnostic: write.diagnostic ?? null
});
if (write.diagnostic) {
diagnostics.push(write.diagnostic);
}
}
const failedDeliveryCount = deliveries.filter((delivery) => delivery.deliveryStatus === "failed").length;
evidenceSummary = {
...evidenceSummary,
routeCount: evidenceSummary.routeCount + 1,
lastSyncAt: result.observedAt,
deliveryCount: evidenceSummary.deliveryCount + deliveries.length,
failedDeliveryCount: evidenceSummary.failedDeliveryCount + failedDeliveryCount,
diagnostics: diagnosticSummary([...configDiagnostics, ...diagnostics])
};
return {
...result,
accepted: result.accepted && failedDeliveryCount === 0,
deliveryCount: deliveries.length,
deliveries,
...(diagnostics.length > 0 ? { diagnostics } : {})
};
}
async function tick({ signals = [] } = {}) {
if (!Array.isArray(signals)) {
throw new Error("tick signals must be an array");
}
const startedAt = now();
const routed = [];
for (const signal of signals) {
routed.push(await routeSignal(signal));
}
evidenceSummary = {
...evidenceSummary,
tickCount: evidenceSummary.tickCount + 1,
lastSyncAt: now()
};
return {
accepted: routed.every((result) => result.accepted),
propagatedBy: PATCH_PANEL_SERVICE_ID,
startedAt,
observedAt: evidenceSummary.lastSyncAt,
routeCount: routed.length,
deliveryCount: routed.reduce((sum, result) => sum + result.deliveryCount, 0),
routes: routed,
diagnostics: routed.flatMap((result) => result.diagnostics ?? []),
evidenceSummary
};
}
function findDeliveryConnection(delivery) {
return patchPanel.normalizedConnections.find(
(connection) =>
connection.from.resourceId === delivery.sourceResourceId &&
connection.from.port === delivery.sourcePort &&
connection.to.resourceId === delivery.resourceId &&
connection.to.port === delivery.port
);
}
async function deliverToEndpoint(delivery, { requireEndpoint }) {
const endpoint = endpoints.get(delivery.resourceId);
if (!endpoint) {
if (!requireEndpoint) {
return {
accepted: true,
deliveryStatus: "recorded"
};
}
return {
accepted: false,
diagnostic: {
level: "error",
code: "target_endpoint_missing",
message: `cannot deliver ${delivery.sourceResourceId}:${delivery.sourcePort} -> ${delivery.resourceId}:${delivery.port}; endpointMap is missing ${delivery.resourceId}`,
path: `$.endpointMap.${delivery.resourceId}`,
endpointKey: endpointKey(delivery.resourceId, delivery.port)
}
};
}
const url = new URL("/internal/ports/write", endpoint.endsWith("/") ? endpoint : `${endpoint}/`).toString();
try {
const response = await requestJson(url, {
port: delivery.port,
value: delivery.value,
source: "patch-panel",
sourceResourceId: delivery.sourceResourceId,
sourcePort: delivery.sourcePort,
propagatedBy: PATCH_PANEL_SERVICE_ID,
observedAt: delivery.observedAt
});
if (response.status < 200 || response.status >= 300 || response.json?.accepted !== true) {
return {
accepted: false,
endpoint: url,
diagnostic: {
level: "error",
code: "target_write_rejected",
message: `target endpoint rejected ${delivery.resourceId}:${delivery.port} write with status ${response.status}`,
path: `$.endpointMap.${delivery.resourceId}`,
response: response.json ?? response.bodyPreview
}
};
}
return {
accepted: true,
endpoint: url,
response: response.json
};
} catch (error) {
return {
accepted: false,
endpoint: url,
diagnostic: {
level: "error",
code: "target_write_failed",
message: `failed to deliver ${delivery.resourceId}:${delivery.port}: ${error instanceof Error ? error.message : String(error)}`,
path: `$.endpointMap.${delivery.resourceId}`
}
};
}
}
applyConfig({
wiringConfig,
endpointMap,
configSource
});
return {
applyConfig,
reloadFromFile,
diagnostics,
status,
routeSignal,
tick,
get wiringConfig() {
return activeWiringConfig;
},
get endpointMap() {
return Object.fromEntries(endpoints.entries());
},
get evidenceSummary() {
return evidenceSummary;
}
};
}
export async function createPatchPanelRuntimeFromFile({ path, now = isoNow, requestJson = postJson }) {
const config = await readPatchPanelRuntimeConfig(path);
return createPatchPanelRuntime({
...config,
configSource: path,
now,
requestJson
});
}
export function diagnosticSummary(diagnostics) {
return {
errorCount: diagnostics.filter((item) => item.level === "error").length,
warningCount: diagnostics.filter((item) => item.level === "warning").length,
infoCount: diagnostics.filter((item) => item.level === "info").length
};
}
export async function postJson(url, body, { timeoutMs = 5000 } = {}) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const payload = JSON.stringify(body);
const requestFn = parsed.protocol === "https:" ? httpsRequest : httpRequest;
const req = requestFn(
parsed,
{
method: "POST",
headers: {
"content-type": "application/json",
"content-length": Buffer.byteLength(payload)
},
timeout: timeoutMs
},
(response) => {
let text = "";
response.setEncoding("utf8");
response.on("data", (chunk) => {
text += chunk;
});
response.on("end", () => {
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
resolve({
status: response.statusCode ?? 0,
json,
bodyPreview: json ? undefined : text.slice(0, 500)
});
});
}
);
req.on("timeout", () => {
req.destroy(new Error(`request timed out after ${timeoutMs}ms`));
});
req.on("error", reject);
req.write(payload);
req.end();
});
}
+112
View File
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createWiringConfig } from "./model.mjs";
import { createPatchPanelRuntime } from "./runtime.mjs";
test("runtime applies config and delivers DO->DI through endpoint map", async () => {
const writes = [];
const runtime = createPatchPanelRuntime({
wiringConfig: createWiringConfig({
wiringConfigId: "wir_runtime_unit",
projectId: "prj_runtime_unit",
gatewaySessionId: "gws_runtime_unit",
connections: [
{
from: {
resourceId: "res_alpha",
port: "DO1"
},
to: {
resourceId: "res_beta",
port: "DI1"
},
mode: "exclusive"
}
],
now: "2026-01-01T00:00:00.000Z"
}),
endpointMap: {
res_beta: "http://127.0.0.1:7202"
},
now: () => "2026-01-01T00:00:01.000Z",
requestJson: async (url, body) => {
writes.push({ url, body });
return {
status: 200,
json: {
accepted: true
}
};
}
});
assert.equal(runtime.status().state, "active");
const result = await runtime.tick({
signals: [
{
fromResourceId: "res_alpha",
fromPort: "DO1",
value: true
}
]
});
assert.equal(result.accepted, true);
assert.equal(result.deliveryCount, 1);
assert.equal(writes.length, 1);
assert.equal(writes[0].url, "http://127.0.0.1:7202/internal/ports/write");
assert.deepEqual(writes[0].body, {
port: "DI1",
value: true,
source: "patch-panel",
sourceResourceId: "res_alpha",
sourcePort: "DO1",
propagatedBy: "hwlab-patch-panel",
observedAt: "2026-01-01T00:00:01.000Z"
});
assert.equal(runtime.evidenceSummary.configApplied, true);
assert.equal(runtime.evidenceSummary.tickCount, 1);
assert.equal(runtime.evidenceSummary.deliveryCount, 1);
});
test("runtime reports missing target endpoint as readable diagnostics", async () => {
const runtime = createPatchPanelRuntime({
wiringConfig: createWiringConfig({
wiringConfigId: "wir_runtime_missing_endpoint",
projectId: "prj_runtime_unit",
gatewaySessionId: "gws_runtime_unit",
connections: [
{
from: {
resourceId: "res_alpha",
port: "AO1"
},
to: {
resourceId: "res_beta",
port: "AI1"
},
mode: "exclusive"
}
],
now: "2026-01-01T00:00:00.000Z"
}),
endpointMap: {},
now: () => "2026-01-01T00:00:01.000Z",
requestJson: async () => {
throw new Error("requestJson should not be called without endpoint");
}
});
assert.equal(runtime.status().state, "faulted");
assert.ok(runtime.diagnostics().diagnostics.some((diagnostic) => diagnostic.code === "target_endpoint_missing"));
const result = await runtime.routeSignal({
fromResourceId: "res_alpha",
fromPort: "AO1",
value: 1.5
});
assert.equal(result.accepted, false);
assert.equal(result.deliveryCount, 1);
assert.ok(result.diagnostics.some((diagnostic) => diagnostic.code === "target_endpoint_missing"));
});
+76 -23
View File
@@ -42,6 +42,22 @@ export const SIM_PORT_DEFINITIONS = Object.freeze([
unit: "Hz",
initialValue: 0
},
{
port: "FREQ_IN1",
family: "FREQ_IN",
direction: "input",
valueType: "number",
unit: "Hz",
initialValue: 0
},
{
port: "FREQ_OUT1",
family: "FREQ_OUT",
direction: "output",
valueType: "number",
unit: "Hz",
initialValue: 0
},
{
port: "uart0",
family: "SERIAL",
@@ -70,7 +86,7 @@ export const SIM_PORT_DEFINITIONS = Object.freeze([
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"]);
export const L2_PORT_FAMILIES = Object.freeze(["AI", "AO", "DI", "DO", "FREQ", "FREQ_IN", "FREQ_OUT"]);
function isoNow() {
return new Date().toISOString();
@@ -116,7 +132,13 @@ export function resourceIdForBox(boxId) {
return `res_${stableIdPart(boxId)}`;
}
export function createBoxResource({ boxId, gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID, now = isoNow() }) {
export function createBoxResource({
boxId,
gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID,
ports = SIM_PORTS,
portFamilies = L2_PORT_FAMILIES,
now = isoNow()
}) {
return {
resourceId: resourceIdForBox(boxId),
projectId: DEFAULT_PROJECT_ID,
@@ -128,8 +150,8 @@ export function createBoxResource({ boxId, gatewaySessionId = DEFAULT_GATEWAY_SE
environment: ENVIRONMENT_DEV,
metadata: {
serviceId: "hwlab-box-simu",
ports: SIM_PORTS,
portFamilies: L2_PORT_FAMILIES,
ports,
...(portFamilies.length > 0 ? { portFamilies } : {}),
propagation: "patch-panel-only"
},
createdAt: now,
@@ -141,12 +163,13 @@ export function createBoxCapabilities({
boxId,
resourceId = resourceIdForBox(boxId),
projectId = DEFAULT_PROJECT_ID,
portDefinitions = SIM_PORT_DEFINITIONS,
now = isoNow()
}) {
const capabilities = [];
const boxPart = stableIdPart(boxId);
for (const definition of SIM_PORT_DEFINITIONS) {
for (const definition of portDefinitions) {
if (definition.legacy) {
continue;
}
@@ -225,42 +248,72 @@ export function createGatewaySession({
};
}
export function createBoxState({ boxId, gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID, now = isoNow() }) {
export function createBoxState({
boxId,
gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID,
ports: portNames = SIM_PORTS,
includePortDefinitions = true,
now = isoNow()
}) {
const selectedDefinitions = portNames
.map((port) => getPortDefinition(port) ?? {
port,
family: port,
direction: "bidirectional",
valueType: "object",
initialValue: null,
legacy: !includePortDefinitions
});
const ports = Object.fromEntries(
SIM_PORT_DEFINITIONS.map((definition) => [
selectedDefinitions.map((definition) => [
definition.port,
{
port: definition.port,
family: definition.family,
direction: definition.direction,
valueType: definition.valueType,
...(definition.unit ? { unit: definition.unit } : {}),
...(includePortDefinitions
? {
family: definition.family,
direction: definition.direction,
valueType: definition.valueType,
...(definition.unit ? { unit: definition.unit } : {}),
internalPortApi: true
}
: {}),
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 });
const portFamilies = includePortDefinitions
? [...new Set(selectedDefinitions.map((definition) => definition.family))]
: [];
const resource = createBoxResource({
boxId,
gatewaySessionId,
ports: portNames,
portFamilies,
now
});
return {
serviceId: "hwlab-box-simu",
boxId,
projectId: DEFAULT_PROJECT_ID,
environment: ENVIRONMENT_DEV,
...(includePortDefinitions ? { environment: ENVIRONMENT_DEV } : {}),
gatewaySessionId,
live: true,
resource,
capabilities: createBoxCapabilities({
boxId,
resourceId: resource.resourceId,
projectId: DEFAULT_PROJECT_ID,
now
}),
...(includePortDefinitions
? {
capabilities: createBoxCapabilities({
boxId,
resourceId: resource.resourceId,
projectId: DEFAULT_PROJECT_ID,
portDefinitions: selectedDefinitions,
now
})
}
: {}),
ports,
constraints: {
crossDevicePropagation: "patch-panel-only",
+1 -1
View File
@@ -5,7 +5,7 @@
"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/audit/index.mjs && node --check internal/db/runtime-store.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/cloud-api-runtime-smoke.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-gate-report.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/src/dev-artifact-services.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/src/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-gate-report.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/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs && node scripts/cloud-api-runtime-smoke.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/audit/index.mjs && node --check internal/db/runtime-store.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 internal/patchpanel/model.mjs && node --check internal/patchpanel/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/cloud-api-runtime-smoke.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-gate-report.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/src/dev-artifact-services.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/src/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/patch-panel-runtime-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-gate-report.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/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
"dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs",
"cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs",
"m1:smoke": "node scripts/m1-contract-smoke.mjs",
+7
View File
@@ -40,3 +40,10 @@ Schemas in `protocol/schemas` are draft-2020-12 JSON Schemas. They define L0
field names, identities, relationships, timestamps, and state enums. Later
service work may tighten validation, but must not silently rename these L0
contract fields.
## Runtime Notes
- `wiring.md` defines the L3 patch-panel runtime loading, reload, diagnostics,
and supported sync families.
- `box-simu-internal.md` defines the simulator ports and `/ports/write`
behavior used by local L2/L3 smoke tests.
+43
View File
@@ -0,0 +1,43 @@
# Box Simulator Internal Runtime Contract
`hwlab-box-simu` is the local simulator endpoint used by L2/L3 smoke tests. It
is not a substitute for real DEV hardware.
## Ports
The simulator exposes these ports through `GET /status`:
| Port | Direction | Value Type |
| --- | --- | --- |
| `uart0` | bidirectional | string |
| `eth0` | bidirectional | object |
| `gpio0` | bidirectional | boolean |
| `DO1` | output | boolean |
| `DI1` | input | boolean |
| `AO1` | output | number |
| `AI1` | input | number |
| `FREQ_OUT1` | output | number |
| `FREQ_IN1` | input | number |
The L3 patch-panel sync loop uses `DO1`, `DI1`, `AO1`, `AI1`, `FREQ_OUT1`, and
`FREQ_IN1`. Cross-device propagation must be `patch-panel-only`; local loopback
is not accepted as a substitute.
## Write Contract
`POST /ports/write` accepts:
```json
{
"port": "DI1",
"value": true,
"source": "res_boxsimu_runtime_1",
"sourcePort": "DO1",
"propagatedBy": "hwlab-patch-panel"
}
```
The response always includes whether the write was accepted, the box id, the
port, the stored value, and the patch-panel propagation constraints. When the
write came through `hwlab-patch-panel`, `GET /status` preserves
`source`, `sourcePort`, and `propagatedBy` on the target port.
+48
View File
@@ -0,0 +1,48 @@
# Wiring Runtime Contract
Wiring configs remain the L0 `wiring_configs` contract described by
`protocol/schemas/wiring-config.schema.json`. The L3 patch-panel runtime applies
one active config and owns cross-box signal propagation.
## Runtime Config
`hwlab-patch-panel` loads a runtime config from `HWLAB_WIRING_CONFIG_PATH` when
the variable is set. The file contains:
- `wiringConfig`: an L0 wiring config object.
- `endpointMap`: an object keyed by `resourceId` with HTTP base URLs for
writable `hwlab-box-simu` instances.
The service also supports:
- `GET /wiring` for the applied wiring config.
- `GET /status` for L0 patch-panel status plus config/evidence summary.
- `GET /diagnostics` for readable config, endpoint, and sync diagnostics.
- `POST /wiring/apply` to apply an inline runtime config.
- `POST /wiring/reload` to reload `body.path` or `HWLAB_WIRING_CONFIG_PATH`.
- `POST /signals/route` to route one source signal.
- `POST /sync/tick` to route a batch of source signals.
## Supported Sync Families
The minimal L3 runtime supports these directed signal families:
| Source | Target | Value |
| --- | --- | --- |
| `DO*` | `DI*` | boolean |
| `AO*` | `AI*` | number |
| `FREQ_OUT*` | `FREQ_IN*` | number |
Typed signal wiring is directional. For example, `DI1 -> DO1` is rejected with
`source_port_not_output`, and `AO1 -> DI1` is rejected with
`unsupported_signal_wiring`.
Untyped legacy MVP fixture ports such as `uart0` and `gpio0` may still be routed
as recorded deliveries for M1 compatibility, but they are not part of the L3
sync loop.
## Evidence Boundary
This runtime records only config application and evidence summary fields in
`/status` and `/diagnostics`. It does not write tick audit records. Live DEV or
PROD deployment is outside this local runtime contract.
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { request as httpRequest } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const fixturePath = path.join(repoRoot, "fixtures/mvp/patch-panel-runtime/two-box-sync.json");
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
async function freePort() {
const { createServer } = await import("node:net");
return new Promise((resolve, reject) => {
const server = 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(() => {
if (!port) {
reject(new Error("failed to allocate a free port"));
return;
}
resolve(port);
});
});
});
}
function startNode(scriptPath, env) {
const child = spawn(process.execPath, [scriptPath], {
cwd: repoRoot,
env: {
...process.env,
...env
},
stdio: ["ignore", "pipe", "pipe"]
});
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
return child;
}
function stopProcess(child) {
if (!child || child.exitCode !== null) {
return Promise.resolve();
}
return new Promise((resolve) => {
const timeout = setTimeout(() => {
child.kill("SIGKILL");
resolve();
}, 2000);
child.once("exit", () => {
clearTimeout(timeout);
resolve();
});
child.kill("SIGTERM");
});
}
async function requestJson(url, options = {}) {
const body = options.body ? JSON.stringify(options.body) : null;
return new Promise((resolve, reject) => {
const req = httpRequest(
new URL(url),
{
method: options.method ?? (body ? "POST" : "GET"),
headers: body
? {
"content-type": "application/json",
"content-length": Buffer.byteLength(body)
}
: {},
timeout: options.timeoutMs ?? 5000
},
(response) => {
let text = "";
response.setEncoding("utf8");
response.on("data", (chunk) => {
text += chunk;
});
response.on("end", () => {
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
resolve({
response,
status: response.statusCode ?? 0,
body: json,
text
});
});
}
);
req.on("timeout", () => {
req.destroy(new Error(`request timed out for ${url}`));
});
req.on("error", reject);
if (body) {
req.write(body);
}
req.end();
});
}
async function waitForHttp(url, child) {
const startedAt = Date.now();
let lastError = null;
while (Date.now() - startedAt < 5000) {
if (child.exitCode !== null) {
throw new Error(`process exited before ${url} became ready`);
}
try {
const result = await requestJson(url, { timeoutMs: 500 });
if (result.status >= 200 && result.status < 300) {
return result;
}
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`timed out waiting for ${url}: ${lastError?.message ?? "no response"}`);
}
function assertPatchPanelDiagnostics(diagnostics) {
assert.equal(diagnostics.serviceId, "hwlab-patch-panel");
assert.equal(diagnostics.environment, "dev");
assert.equal(diagnostics.state, "active");
assert.equal(diagnostics.summary.configApplied, true);
assert.equal(diagnostics.summary.connectionCount, 3);
assert.equal(diagnostics.summary.syncableConnectionCount, 3);
assert.equal(diagnostics.summary.diagnostics.errorCount, 0);
}
async function main() {
const fixture = JSON.parse(await readFile(fixturePath, "utf8"));
const box1Port = await freePort();
const box2Port = await freePort();
const patchPort = await freePort();
const tempDir = await mkdtemp(path.join(tmpdir(), "hwlab-patch-panel-runtime-"));
const configPath = path.join(tempDir, "runtime-config.json");
const runtimeConfig = clone(fixture.runtimeConfig);
runtimeConfig.endpointMap = {
res_boxsimu_runtime_1: `http://127.0.0.1:${box1Port}`,
res_boxsimu_runtime_2: `http://127.0.0.1:${box2Port}`
};
await writeFile(configPath, `${JSON.stringify(runtimeConfig, null, 2)}\n`, "utf8");
const box1 = startNode("cmd/hwlab-box-simu/main.mjs", {
PORT: String(box1Port),
HWLAB_BOX_ID: fixture.boxes[0].boxId
});
const box2 = startNode("cmd/hwlab-box-simu/main.mjs", {
PORT: String(box2Port),
HWLAB_BOX_ID: fixture.boxes[1].boxId
});
const patchPanel = startNode("cmd/hwlab-patch-panel/main.mjs", {
PORT: String(patchPort),
HWLAB_WIRING_CONFIG_PATH: configPath
});
try {
const box1Base = `http://127.0.0.1:${box1Port}`;
const box2Base = `http://127.0.0.1:${box2Port}`;
const patchBase = `http://127.0.0.1:${patchPort}`;
await Promise.all([
waitForHttp(`${box1Base}/health/live`, box1),
waitForHttp(`${box2Base}/health/live`, box2),
waitForHttp(`${patchBase}/health/live`, patchPanel)
]);
const diagnostics = await requestJson(`${patchBase}/diagnostics`);
assert.equal(diagnostics.status, 200);
assertPatchPanelDiagnostics(diagnostics.body);
const status = await requestJson(`${patchBase}/status`);
assert.equal(status.status, 200);
assert.equal(status.body.serviceId, "hwlab-patch-panel");
assert.equal(status.body.state, "active");
assert.equal(status.body.activeConnections.length, 3);
const tick = await requestJson(`${patchBase}/sync/tick`, {
method: "POST",
body: {
signals: fixture.signals
}
});
assert.equal(tick.status, 200);
assert.equal(tick.body.accepted, true);
assert.equal(tick.body.deliveryCount, 3);
const targetState = await requestJson(`${box2Base}/status`);
assert.equal(targetState.status, 200);
assert.equal(targetState.body.ports.DI1.value, fixture.expected.ports.DI1);
assert.equal(targetState.body.ports.DI1.propagatedBy, "hwlab-patch-panel");
assert.equal(targetState.body.ports.DI1.source, "patch-panel");
assert.equal(targetState.body.ports.DI1.sourceResourceId, fixture.signals[0].fromResourceId);
assert.equal(targetState.body.ports.AI1.value, fixture.expected.ports.AI1);
assert.equal(targetState.body.ports.AI1.propagatedBy, "hwlab-patch-panel");
assert.equal(targetState.body.ports.AI1.source, "patch-panel");
assert.equal(targetState.body.ports.AI1.sourceResourceId, fixture.signals[1].fromResourceId);
assert.equal(targetState.body.ports.FREQ_IN1.value, fixture.expected.ports.FREQ_IN1);
assert.equal(targetState.body.ports.FREQ_IN1.propagatedBy, "hwlab-patch-panel");
assert.equal(targetState.body.ports.FREQ_IN1.source, "patch-panel");
assert.equal(targetState.body.ports.FREQ_IN1.sourceResourceId, fixture.signals[2].fromResourceId);
const invalidConfig = clone(runtimeConfig);
invalidConfig.wiringConfig = {
...invalidConfig.wiringConfig,
wiringConfigId: "wir_patch_panel_runtime_invalid",
connections: [fixture.invalidWiringProbe]
};
const invalidApply = await requestJson(`${patchBase}/wiring/apply`, {
method: "POST",
body: invalidConfig
});
assert.equal(invalidApply.status, 200);
assert.equal(invalidApply.body.accepted, false);
assert.ok(
invalidApply.body.diagnostics.some((item) => item.code === "source_port_not_output"),
"invalid wiring must report source_port_not_output"
);
console.log("patch-panel runtime smoke passed");
console.log("wiring: DO1->DI1, AO1->AI1, and FREQ_OUT1->FREQ_IN1 synchronized through hwlab-patch-panel");
console.log("diagnostics: invalid DI1->DO1 wiring reported source_port_not_output");
} finally {
await Promise.all([stopProcess(patchPanel), stopProcess(box1), stopProcess(box2)]);
await rm(tempDir, { recursive: true, force: true });
}
}
try {
await main();
} catch (error) {
console.error(`[patch-panel-runtime-smoke] ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
}