feat: add patch panel runtime sync
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user