From b1f4e6e52dce08a6bd3dcca11aeebe1081ecb8bd Mon Sep 17 00:00:00 2001 From: HWLAB Code Queue Date: Thu, 21 May 2026 18:36:39 +0000 Subject: [PATCH] feat: add patch panel runtime sync --- cmd/hwlab-patch-panel/main.mjs | 30 +- fixtures/mvp/patch-panel-runtime/README.md | 16 + .../mvp/patch-panel-runtime/two-box-sync.json | 105 ++++ internal/patchpanel/fixture.mjs | 2 + internal/patchpanel/model.mjs | 365 +++++++++++++- internal/patchpanel/model.test.mjs | 78 ++- internal/patchpanel/runtime.mjs | 470 ++++++++++++++++++ internal/patchpanel/runtime.test.mjs | 112 +++++ internal/sim/model.mjs | 99 +++- package.json | 2 +- protocol/README.md | 7 + protocol/box-simu-internal.md | 43 ++ protocol/wiring.md | 48 ++ scripts/patch-panel-runtime-smoke.mjs | 251 ++++++++++ 14 files changed, 1583 insertions(+), 45 deletions(-) create mode 100644 fixtures/mvp/patch-panel-runtime/README.md create mode 100644 fixtures/mvp/patch-panel-runtime/two-box-sync.json create mode 100644 internal/patchpanel/runtime.mjs create mode 100644 internal/patchpanel/runtime.test.mjs create mode 100644 protocol/box-simu-internal.md create mode 100644 protocol/wiring.md create mode 100644 scripts/patch-panel-runtime-smoke.mjs diff --git a/cmd/hwlab-patch-panel/main.mjs b/cmd/hwlab-patch-panel/main.mjs index 629be9b7..9fdde4e2 100644 --- a/cmd/hwlab-patch-panel/main.mjs +++ b/cmd/hwlab-patch-panel/main.mjs @@ -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); diff --git a/fixtures/mvp/patch-panel-runtime/README.md b/fixtures/mvp/patch-panel-runtime/README.md new file mode 100644 index 00000000..01ac142b --- /dev/null +++ b/fixtures/mvp/patch-panel-runtime/README.md @@ -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. diff --git a/fixtures/mvp/patch-panel-runtime/two-box-sync.json b/fixtures/mvp/patch-panel-runtime/two-box-sync.json new file mode 100644 index 00000000..e5b11825 --- /dev/null +++ b/fixtures/mvp/patch-panel-runtime/two-box-sync.json @@ -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" + } +} diff --git a/internal/patchpanel/fixture.mjs b/internal/patchpanel/fixture.mjs index c433d8b5..3fb4b471 100644 --- a/internal/patchpanel/fixture.mjs +++ b/internal/patchpanel/fixture.mjs @@ -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 }) ); diff --git a/internal/patchpanel/model.mjs b/internal/patchpanel/model.mjs index f3296255..42f1e6fe 100644 --- a/internal/patchpanel/model.mjs +++ b/internal/patchpanel/model.mjs @@ -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(?[A-Z0-9_-]*)$/); + if (freqOut) { + return { + family: "FREQ_OUT", + direction: "output", + valueType: "number", + suffix: freqOut.groups.suffix ?? "" + }; + } + + const freqIn = normalized.match(/^FREQ_IN(?[A-Z0-9_-]*)$/); + if (freqIn) { + return { + family: "FREQ_IN", + direction: "input", + valueType: "number", + suffix: freqIn.groups.suffix ?? "" + }; + } + + const discrete = normalized.match(/^(?DO|DI|AO|AI)(?[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 diff --git a/internal/patchpanel/model.test.mjs b/internal/patchpanel/model.test.mjs index 738c5f5b..19d16423 100644 --- a/internal/patchpanel/model.test.mjs +++ b/internal/patchpanel/model.test.mjs @@ -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")); +}); diff --git a/internal/patchpanel/runtime.mjs b/internal/patchpanel/runtime.mjs new file mode 100644 index 00000000..87663149 --- /dev/null +++ b/internal/patchpanel/runtime.mjs @@ -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(); + }); +} diff --git a/internal/patchpanel/runtime.test.mjs b/internal/patchpanel/runtime.test.mjs new file mode 100644 index 00000000..bb6282a3 --- /dev/null +++ b/internal/patchpanel/runtime.test.mjs @@ -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")); +}); diff --git a/internal/sim/model.mjs b/internal/sim/model.mjs index e55d690c..53c0e5de 100644 --- a/internal/sim/model.mjs +++ b/internal/sim/model.mjs @@ -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", diff --git a/package.json b/package.json index a72ffe15..97ec8f83 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/protocol/README.md b/protocol/README.md index 4b42732d..1a11a746 100644 --- a/protocol/README.md +++ b/protocol/README.md @@ -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. diff --git a/protocol/box-simu-internal.md b/protocol/box-simu-internal.md new file mode 100644 index 00000000..4f879ea8 --- /dev/null +++ b/protocol/box-simu-internal.md @@ -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. diff --git a/protocol/wiring.md b/protocol/wiring.md new file mode 100644 index 00000000..1b9f8a5a --- /dev/null +++ b/protocol/wiring.md @@ -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. diff --git a/scripts/patch-panel-runtime-smoke.mjs b/scripts/patch-panel-runtime-smoke.mjs new file mode 100644 index 00000000..d6178c3d --- /dev/null +++ b/scripts/patch-panel-runtime-smoke.mjs @@ -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; +}