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 #!/usr/bin/env node
import { createMvpTopology } from "../../internal/patchpanel/fixture.mjs"; 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 { createHealth } from "../../internal/sim/model.mjs";
import { createJsonServer, listen, parsePort, readJson } from "../../internal/sim/http.mjs"; import { createJsonServer, listen, parsePort, readJson } from "../../internal/sim/http.mjs";
const port = parsePort(process.env.PORT, 7301); const port = parsePort(process.env.PORT, 7301);
const wiringConfigPath = process.env.HWLAB_WIRING_CONFIG_PATH;
const topology = createMvpTopology(); 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(); const routes = new Map();
routes.set("GET /health/live", () => routes.set("GET /health/live", () =>
createHealth({ serviceId: "hwlab-patch-panel", name: "hwlab-patch-panel" }) createHealth({ serviceId: "hwlab-patch-panel", name: "hwlab-patch-panel" })
); );
routes.set("GET /status", () => patchPanel.status()); routes.set("GET /status", () => runtime.status());
routes.set("GET /wiring", () => patchPanel.wiringConfig); routes.set("GET /diagnostics", () => runtime.diagnostics());
routes.set("POST /signals/route", async ({ req }) => patchPanel.routeSignal(await readJson(req))); 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); 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({ createBoxState({
boxId, boxId,
gatewaySessionId: gatewaySessionIds[index], gatewaySessionId: gatewaySessionIds[index],
ports: ["uart0", "eth0", "gpio0"],
includePortDefinitions: false,
now now
}) })
); );
+350 -15
View File
@@ -1,10 +1,28 @@
import { ENVIRONMENT_DEV } from "../protocol/index.mjs"; import { ENVIRONMENT_DEV } from "../protocol/index.mjs";
import { DEFAULT_GATEWAY_SESSION_ID, DEFAULT_PROJECT_ID } from "../sim/model.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({ export function createWiringConfig({
wiringConfigId = "wir_mvp_patch_panel", wiringConfigId = "wir_mvp_patch_panel",
projectId = DEFAULT_PROJECT_ID,
gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID, gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID,
name = "MVP simulated patch panel wiring",
status = "active",
connections, connections,
constraints,
now = new Date().toISOString() now = new Date().toISOString()
}) { }) {
if (!Array.isArray(connections) || connections.length === 0) { if (!Array.isArray(connections) || connections.length === 0) {
@@ -13,12 +31,12 @@ export function createWiringConfig({
return { return {
wiringConfigId, wiringConfigId,
projectId: DEFAULT_PROJECT_ID, projectId,
gatewaySessionId, gatewaySessionId,
name: "MVP simulated patch panel wiring", name,
status: "active", status,
connections, connections,
constraints: { constraints: constraints ?? {
propagation: "patch-panel-only", propagation: "patch-panel-only",
localLoopbackSubstituteAllowed: false localLoopbackSubstituteAllowed: false
}, },
@@ -29,17 +47,22 @@ export function createWiringConfig({
export function createPatchPanelState({ export function createPatchPanelState({
patchPanelStatusId = "pps_mvp_patch_panel", patchPanelStatusId = "pps_mvp_patch_panel",
gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID, gatewaySessionId = null,
wiringConfig, wiringConfig,
state = "active",
metadata = {},
now = new Date().toISOString() now = new Date().toISOString()
}) { }) {
const effectiveGatewaySessionId =
gatewaySessionId ?? wiringConfig.gatewaySessionId ?? DEFAULT_GATEWAY_SESSION_ID;
return { return {
patchPanelStatusId, patchPanelStatusId,
projectId: DEFAULT_PROJECT_ID, projectId: wiringConfig.projectId ?? DEFAULT_PROJECT_ID,
gatewaySessionId, gatewaySessionId: effectiveGatewaySessionId,
wiringConfigId: wiringConfig.wiringConfigId, wiringConfigId: wiringConfig.wiringConfigId,
serviceId: "hwlab-patch-panel", serviceId: PATCH_PANEL_SERVICE_ID,
state: "active", state,
environment: ENVIRONMENT_DEV, environment: ENVIRONMENT_DEV,
activeConnections: wiringConfig.connections.map((connection) => ({ activeConnections: wiringConfig.connections.map((connection) => ({
fromResourceId: connection.from.resourceId, fromResourceId: connection.from.resourceId,
@@ -50,30 +73,319 @@ export function createPatchPanelState({
observedAt: now, observedAt: now,
metadata: { metadata: {
propagation: "patch-panel-only", 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() }) { export function createPatchPanel({ wiringConfig, now = () => new Date().toISOString() }) {
const signals = new Map(); const signals = new Map();
const validation = validateWiringConfig(wiringConfig);
const summary = diagnosticSummary(validation.diagnostics);
function routeSignal({ fromResourceId, fromPort, value }) { function routeSignal({ fromResourceId, fromPort, value }) {
const observedAt = now(); const observedAt = now();
const deliveries = []; 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 = const forwardMatch =
connection.from.resourceId === fromResourceId && connection.from.port === fromPort; connection.from.resourceId === fromResourceId && connection.from.port === fromPort;
const reverseMatch = const reverseMatch =
connection.to.resourceId === fromResourceId && connection.to.port === fromPort; connection.bidirectionalRoute &&
connection.to.resourceId === fromResourceId &&
connection.to.port === fromPort;
if (!forwardMatch && !reverseMatch) { if (!forwardMatch && !reverseMatch) {
continue; continue;
} }
const valueIssue = validateValueForConnection(value, connection);
if (valueIssue) {
routeDiagnostics.push(valueIssue);
continue;
}
const target = forwardMatch ? connection.to : connection.from; const target = forwardMatch ? connection.to : connection.from;
const key = `${target.resourceId}:${target.port}`; const key = endpointKey(target.resourceId, target.port);
const delivery = { const delivery = {
resourceId: target.resourceId, resourceId: target.resourceId,
port: target.port, port: target.port,
@@ -86,24 +398,47 @@ export function createPatchPanel({ wiringConfig, now = () => new Date().toISOStr
deliveries.push(delivery); 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, accepted: true,
propagatedBy: "hwlab-patch-panel", propagatedBy: PATCH_PANEL_SERVICE_ID,
deliveryCount: deliveries.length, deliveryCount: deliveries.length,
deliveries, deliveries,
observedAt observedAt
}; };
if (routeDiagnostics.length > 0) {
response.diagnostics = routeDiagnostics;
}
return response;
} }
function status() { function status() {
return createPatchPanelState({ return createPatchPanelState({
wiringConfig, wiringConfig,
state: summary.errorCount > 0 ? "faulted" : "active",
metadata: {
diagnostics: summary,
syncableConnectionCount: validation.normalizedConnections.filter((connection) => connection.syncable).length
},
now: now() now: now()
}); });
} }
return { return {
wiringConfig, wiringConfig,
diagnostics: validation.diagnostics,
diagnosticSummary: summary,
normalizedConnections: validation.normalizedConnections,
routeSignal, routeSignal,
status, status,
signals signals
+77 -1
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import { createMvpTopology } from "./fixture.mjs"; 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", () => { test("MVP topology includes two box simulators, two gateway simulators, and one patch panel", () => {
const topology = createMvpTopology(); 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"); const raw = await readFile(new URL("../../fixtures/mvp-topology/topology.json", import.meta.url), "utf8");
assert.deepEqual(JSON.parse(raw), createMvpTopology()); 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", unit: "Hz",
initialValue: 0 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", port: "uart0",
family: "SERIAL", 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 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() { function isoNow() {
return new Date().toISOString(); return new Date().toISOString();
@@ -116,7 +132,13 @@ export function resourceIdForBox(boxId) {
return `res_${stableIdPart(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 { return {
resourceId: resourceIdForBox(boxId), resourceId: resourceIdForBox(boxId),
projectId: DEFAULT_PROJECT_ID, projectId: DEFAULT_PROJECT_ID,
@@ -128,8 +150,8 @@ export function createBoxResource({ boxId, gatewaySessionId = DEFAULT_GATEWAY_SE
environment: ENVIRONMENT_DEV, environment: ENVIRONMENT_DEV,
metadata: { metadata: {
serviceId: "hwlab-box-simu", serviceId: "hwlab-box-simu",
ports: SIM_PORTS, ports,
portFamilies: L2_PORT_FAMILIES, ...(portFamilies.length > 0 ? { portFamilies } : {}),
propagation: "patch-panel-only" propagation: "patch-panel-only"
}, },
createdAt: now, createdAt: now,
@@ -141,12 +163,13 @@ export function createBoxCapabilities({
boxId, boxId,
resourceId = resourceIdForBox(boxId), resourceId = resourceIdForBox(boxId),
projectId = DEFAULT_PROJECT_ID, projectId = DEFAULT_PROJECT_ID,
portDefinitions = SIM_PORT_DEFINITIONS,
now = isoNow() now = isoNow()
}) { }) {
const capabilities = []; const capabilities = [];
const boxPart = stableIdPart(boxId); const boxPart = stableIdPart(boxId);
for (const definition of SIM_PORT_DEFINITIONS) { for (const definition of portDefinitions) {
if (definition.legacy) { if (definition.legacy) {
continue; 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( const ports = Object.fromEntries(
SIM_PORT_DEFINITIONS.map((definition) => [ selectedDefinitions.map((definition) => [
definition.port, definition.port,
{ {
port: definition.port, port: definition.port,
family: definition.family, ...(includePortDefinitions
direction: definition.direction, ? {
valueType: definition.valueType, family: definition.family,
...(definition.unit ? { unit: definition.unit } : {}), direction: definition.direction,
valueType: definition.valueType,
...(definition.unit ? { unit: definition.unit } : {}),
internalPortApi: true
}
: {}),
value: definition.initialValue, value: definition.initialValue,
source: "local", source: "local",
internalPortApi: true,
updatedAt: now updatedAt: now
} }
]) ])
); );
for (const definition of SIM_PORT_DEFINITIONS) { const portFamilies = includePortDefinitions
ports[definition.port].value = definition.initialValue; ? [...new Set(selectedDefinitions.map((definition) => definition.family))]
} : [];
const resource = createBoxResource({ boxId, gatewaySessionId, now }); const resource = createBoxResource({
boxId,
gatewaySessionId,
ports: portNames,
portFamilies,
now
});
return { return {
serviceId: "hwlab-box-simu", serviceId: "hwlab-box-simu",
boxId, boxId,
projectId: DEFAULT_PROJECT_ID, projectId: DEFAULT_PROJECT_ID,
environment: ENVIRONMENT_DEV, ...(includePortDefinitions ? { environment: ENVIRONMENT_DEV } : {}),
gatewaySessionId, gatewaySessionId,
live: true, live: true,
resource, resource,
capabilities: createBoxCapabilities({ ...(includePortDefinitions
boxId, ? {
resourceId: resource.resourceId, capabilities: createBoxCapabilities({
projectId: DEFAULT_PROJECT_ID, boxId,
now resourceId: resource.resourceId,
}), projectId: DEFAULT_PROJECT_ID,
portDefinitions: selectedDefinitions,
now
})
}
: {}),
ports, ports,
constraints: { constraints: {
crossDevicePropagation: "patch-panel-only", crossDevicePropagation: "patch-panel-only",
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"validate": "node scripts/validate-contract.mjs", "validate": "node scripts/validate-contract.mjs",
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/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", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs",
"cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs",
"m1:smoke": "node scripts/m1-contract-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 field names, identities, relationships, timestamps, and state enums. Later
service work may tighten validation, but must not silently rename these L0 service work may tighten validation, but must not silently rename these L0
contract fields. 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;
}