diff --git a/cmd/hwlab-box-simu/main.mjs b/cmd/hwlab-box-simu/main.mjs new file mode 100644 index 00000000..ebd4ae15 --- /dev/null +++ b/cmd/hwlab-box-simu/main.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import { createHealth, createBoxState } from "../../internal/sim/model.mjs"; +import { createJsonServer, listen, parsePort, readJson } from "../../internal/sim/http.mjs"; + +const boxId = process.env.HWLAB_BOX_ID ?? "boxsim_alpha"; +const port = parsePort(process.env.PORT, 7201); +const state = createBoxState({ boxId }); + +const routes = new Map(); + +routes.set("GET /health/live", () => createHealth({ serviceId: "hwlab-box-simu", name: boxId })); +routes.set("GET /status", () => state); +routes.set("POST /ports/write", async ({ req }) => { + const body = await readJson(req); + if (!state.ports[body.port]) { + throw new Error(`unknown port ${body.port}`); + } + + const now = new Date().toISOString(); + state.ports[body.port] = { + port: body.port, + value: body.value ?? null, + source: body.source ?? "local", + updatedAt: now + }; + state.updatedAt = now; + + return { + accepted: true, + boxId, + port: body.port, + crossDevicePropagation: "patch-panel-only", + localLoopbackEnabled: false + }; +}); + +listen(createJsonServer({ routes }), port); diff --git a/cmd/hwlab-gateway-simu/main.mjs b/cmd/hwlab-gateway-simu/main.mjs new file mode 100644 index 00000000..8a0ad5b3 --- /dev/null +++ b/cmd/hwlab-gateway-simu/main.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import { createGatewayState, createHealth } from "../../internal/sim/model.mjs"; +import { createJsonServer, listen, parsePort } from "../../internal/sim/http.mjs"; + +const gatewayId = process.env.HWLAB_GATEWAY_ID ?? "gwsimu_east"; +const port = parsePort(process.env.PORT, 7101); +const endpoint = `http://127.0.0.1:${port}`; +const boxes = (process.env.HWLAB_BOX_RESOURCES ?? "res_boxsim_alpha") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +const state = createGatewayState({ gatewayId, endpoint, boxes }); + +const routes = new Map(); + +routes.set("GET /health/live", () => + createHealth({ serviceId: "hwlab-gateway-simu", name: gatewayId }) +); +routes.set("GET /status", () => state); +routes.set("GET /boxes", () => ({ + gatewayId, + boxes: state.boxes +})); + +listen(createJsonServer({ routes }), port); diff --git a/cmd/hwlab-gateway/main.mjs b/cmd/hwlab-gateway/main.mjs new file mode 100644 index 00000000..bdc0ed47 --- /dev/null +++ b/cmd/hwlab-gateway/main.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +import { createGatewayState, createHealth } from "../../internal/sim/model.mjs"; +import { createJsonServer, listen, parsePort } from "../../internal/sim/http.mjs"; + +const gatewayId = process.env.HWLAB_GATEWAY_ID ?? "gateway_dev_stub"; +const port = parsePort(process.env.PORT, 7001); +const state = createGatewayState({ + gatewayId, + serviceId: "hwlab-gateway", + endpoint: `http://127.0.0.1:${port}`, + boxes: [] +}); +state.session.status = "starting"; + +const routes = new Map(); + +routes.set("GET /health/live", () => createHealth({ serviceId: "hwlab-gateway", name: gatewayId })); +routes.set("GET /status", () => ({ + ...state, + note: "L2/L3 skeleton only; real hardware integration is intentionally absent for MVP simulator work." +})); + +listen(createJsonServer({ routes }), port); diff --git a/cmd/hwlab-patch-panel/main.mjs b/cmd/hwlab-patch-panel/main.mjs new file mode 100644 index 00000000..629be9b7 --- /dev/null +++ b/cmd/hwlab-patch-panel/main.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import { createMvpTopology } from "../../internal/patchpanel/fixture.mjs"; +import { createPatchPanel } from "../../internal/patchpanel/model.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 topology = createMvpTopology(); +const patchPanel = createPatchPanel({ wiringConfig: topology.wiringConfig }); + +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))); + +listen(createJsonServer({ routes }), port); diff --git a/fixtures/mvp-topology/README.md b/fixtures/mvp-topology/README.md new file mode 100644 index 00000000..5f1bec22 --- /dev/null +++ b/fixtures/mvp-topology/README.md @@ -0,0 +1,9 @@ +# MVP Simulated Topology + +`topology.json` is the L2/L3 dry-run fixture for the simulator skeleton. It +describes two `hwlab-box-simu` instances, two `hwlab-gateway-simu` instances, +and one `hwlab-patch-panel`. + +Cross-device propagation is represented only through the patch panel wiring +config. Box simulators keep local port state but do not provide loopback as a +cross-device synchronization substitute. diff --git a/fixtures/mvp-topology/topology.json b/fixtures/mvp-topology/topology.json new file mode 100644 index 00000000..3d43eb55 --- /dev/null +++ b/fixtures/mvp-topology/topology.json @@ -0,0 +1,226 @@ +{ + "topologyId": "top_mvp_l2_l3_sim_patch", + "projectId": "prj_mvp_topology", + "environment": "dev", + "services": { + "boxes": [ + { + "serviceId": "hwlab-box-simu", + "boxId": "boxsim_alpha", + "projectId": "prj_mvp_topology", + "gatewaySessionId": "gws_gwsimu_east", + "live": true, + "resource": { + "resourceId": "res_boxsim_alpha", + "projectId": "prj_mvp_topology", + "gatewaySessionId": "gws_gwsimu_east", + "boxId": "boxsim_alpha", + "resourceType": "simulator_endpoint", + "name": "boxsim_alpha simulator endpoint", + "state": "available", + "environment": "dev", + "metadata": { + "serviceId": "hwlab-box-simu", + "ports": ["uart0", "eth0", "gpio0"], + "propagation": "patch-panel-only" + }, + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-01T00:00:00.000Z" + }, + "ports": { + "uart0": { + "port": "uart0", + "value": null, + "source": "local", + "updatedAt": "2026-01-01T00:00:00.000Z" + }, + "eth0": { + "port": "eth0", + "value": null, + "source": "local", + "updatedAt": "2026-01-01T00:00:00.000Z" + }, + "gpio0": { + "port": "gpio0", + "value": null, + "source": "local", + "updatedAt": "2026-01-01T00:00:00.000Z" + } + }, + "constraints": { + "crossDevicePropagation": "patch-panel-only", + "localLoopbackEnabled": false + }, + "updatedAt": "2026-01-01T00:00:00.000Z" + }, + { + "serviceId": "hwlab-box-simu", + "boxId": "boxsim_beta", + "projectId": "prj_mvp_topology", + "gatewaySessionId": "gws_gwsimu_west", + "live": true, + "resource": { + "resourceId": "res_boxsim_beta", + "projectId": "prj_mvp_topology", + "gatewaySessionId": "gws_gwsimu_west", + "boxId": "boxsim_beta", + "resourceType": "simulator_endpoint", + "name": "boxsim_beta simulator endpoint", + "state": "available", + "environment": "dev", + "metadata": { + "serviceId": "hwlab-box-simu", + "ports": ["uart0", "eth0", "gpio0"], + "propagation": "patch-panel-only" + }, + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-01T00:00:00.000Z" + }, + "ports": { + "uart0": { + "port": "uart0", + "value": null, + "source": "local", + "updatedAt": "2026-01-01T00:00:00.000Z" + }, + "eth0": { + "port": "eth0", + "value": null, + "source": "local", + "updatedAt": "2026-01-01T00:00:00.000Z" + }, + "gpio0": { + "port": "gpio0", + "value": null, + "source": "local", + "updatedAt": "2026-01-01T00:00:00.000Z" + } + }, + "constraints": { + "crossDevicePropagation": "patch-panel-only", + "localLoopbackEnabled": false + }, + "updatedAt": "2026-01-01T00:00:00.000Z" + } + ], + "gateways": [ + { + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_east", + "projectId": "prj_mvp_topology", + "live": true, + "session": { + "gatewaySessionId": "gws_gwsimu_east", + "projectId": "prj_mvp_topology", + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_east", + "endpoint": "http://127.0.0.1:7101", + "status": "connected", + "environment": "dev", + "startedAt": "2026-01-01T00:00:00.000Z", + "lastSeenAt": "2026-01-01T00:00:00.000Z", + "labels": { + "simulator": "true" + } + }, + "boxes": ["res_boxsim_alpha"], + "updatedAt": "2026-01-01T00:00:00.000Z" + }, + { + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_west", + "projectId": "prj_mvp_topology", + "live": true, + "session": { + "gatewaySessionId": "gws_gwsimu_west", + "projectId": "prj_mvp_topology", + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_west", + "endpoint": "http://127.0.0.1:7102", + "status": "connected", + "environment": "dev", + "startedAt": "2026-01-01T00:00:00.000Z", + "lastSeenAt": "2026-01-01T00:00:00.000Z", + "labels": { + "simulator": "true" + } + }, + "boxes": ["res_boxsim_beta"], + "updatedAt": "2026-01-01T00:00:00.000Z" + } + ], + "patchPanel": { + "patchPanelStatusId": "pps_mvp_patch_panel", + "projectId": "prj_mvp_topology", + "gatewaySessionId": "gws_gwsimu_east", + "wiringConfigId": "wir_mvp_patch_panel", + "serviceId": "hwlab-patch-panel", + "state": "active", + "environment": "dev", + "activeConnections": [ + { + "fromResourceId": "res_boxsim_alpha", + "fromPort": "uart0", + "toResourceId": "res_boxsim_beta", + "toPort": "uart0" + }, + { + "fromResourceId": "res_boxsim_alpha", + "fromPort": "gpio0", + "toResourceId": "res_boxsim_beta", + "toPort": "gpio0" + } + ], + "observedAt": "2026-01-01T00:00:00.000Z", + "metadata": { + "propagation": "patch-panel-only", + "connectionCount": 2 + } + } + }, + "wiringConfig": { + "wiringConfigId": "wir_mvp_patch_panel", + "projectId": "prj_mvp_topology", + "gatewaySessionId": "gws_gwsimu_east", + "name": "MVP simulated patch panel wiring", + "status": "active", + "connections": [ + { + "from": { + "resourceId": "res_boxsim_alpha", + "port": "uart0" + }, + "to": { + "resourceId": "res_boxsim_beta", + "port": "uart0" + }, + "mode": "exclusive" + }, + { + "from": { + "resourceId": "res_boxsim_alpha", + "port": "gpio0" + }, + "to": { + "resourceId": "res_boxsim_beta", + "port": "gpio0" + }, + "mode": "monitor" + } + ], + "constraints": { + "propagation": "patch-panel-only", + "localLoopbackSubstituteAllowed": false + }, + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-01T00:00:00.000Z" + }, + "constraints": { + "patchPanelIsOnlyCrossDeviceChannel": true, + "boxSimuLocalLoopbackForCrossDeviceSync": false + }, + "dryRunHints": { + "cliTarget": "fixtures/mvp-topology/topology.json", + "expectedServices": ["hwlab-box-simu", "hwlab-gateway-simu", "hwlab-patch-panel"] + } +} diff --git a/internal/patchpanel/fixture.mjs b/internal/patchpanel/fixture.mjs new file mode 100644 index 00000000..c433d8b5 --- /dev/null +++ b/internal/patchpanel/fixture.mjs @@ -0,0 +1,80 @@ +import { createBoxState, createGatewayState } from "../sim/model.mjs"; +import { createPatchPanelState, createWiringConfig } from "./model.mjs"; + +export const MVP_BOX_IDS = Object.freeze(["boxsim_alpha", "boxsim_beta"]); +export const MVP_GATEWAY_IDS = Object.freeze(["gwsimu_east", "gwsimu_west"]); + +export function createMvpTopology({ now = "2026-01-01T00:00:00.000Z" } = {}) { + const gatewaySessionIds = MVP_GATEWAY_IDS.map((gatewayId) => `gws_${gatewayId}`); + const boxes = MVP_BOX_IDS.map((boxId, index) => + createBoxState({ + boxId, + gatewaySessionId: gatewaySessionIds[index], + now + }) + ); + + const gateways = MVP_GATEWAY_IDS.map((gatewayId, index) => + createGatewayState({ + gatewayId, + endpoint: `http://127.0.0.1:${7101 + index}`, + boxes: [boxes[index].resource.resourceId], + now + }) + ); + + const wiringConfig = createWiringConfig({ + gatewaySessionId: gatewaySessionIds[0], + connections: [ + { + from: { + resourceId: boxes[0].resource.resourceId, + port: "uart0" + }, + to: { + resourceId: boxes[1].resource.resourceId, + port: "uart0" + }, + mode: "exclusive" + }, + { + from: { + resourceId: boxes[0].resource.resourceId, + port: "gpio0" + }, + to: { + resourceId: boxes[1].resource.resourceId, + port: "gpio0" + }, + mode: "monitor" + } + ], + now + }); + + const patchPanel = createPatchPanelState({ + gatewaySessionId: gatewaySessionIds[0], + wiringConfig, + now + }); + + return { + topologyId: "top_mvp_l2_l3_sim_patch", + projectId: "prj_mvp_topology", + environment: "dev", + services: { + boxes, + gateways, + patchPanel + }, + wiringConfig, + constraints: { + patchPanelIsOnlyCrossDeviceChannel: true, + boxSimuLocalLoopbackForCrossDeviceSync: false + }, + dryRunHints: { + cliTarget: "fixtures/mvp-topology/topology.json", + expectedServices: ["hwlab-box-simu", "hwlab-gateway-simu", "hwlab-patch-panel"] + } + }; +} diff --git a/internal/patchpanel/model.mjs b/internal/patchpanel/model.mjs new file mode 100644 index 00000000..f3296255 --- /dev/null +++ b/internal/patchpanel/model.mjs @@ -0,0 +1,111 @@ +import { ENVIRONMENT_DEV } from "../protocol/index.mjs"; +import { DEFAULT_GATEWAY_SESSION_ID, DEFAULT_PROJECT_ID } from "../sim/model.mjs"; + +export function createWiringConfig({ + wiringConfigId = "wir_mvp_patch_panel", + gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID, + connections, + now = new Date().toISOString() +}) { + if (!Array.isArray(connections) || connections.length === 0) { + throw new Error("wiring config requires at least one connection"); + } + + return { + wiringConfigId, + projectId: DEFAULT_PROJECT_ID, + gatewaySessionId, + name: "MVP simulated patch panel wiring", + status: "active", + connections, + constraints: { + propagation: "patch-panel-only", + localLoopbackSubstituteAllowed: false + }, + createdAt: now, + updatedAt: now + }; +} + +export function createPatchPanelState({ + patchPanelStatusId = "pps_mvp_patch_panel", + gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID, + wiringConfig, + now = new Date().toISOString() +}) { + return { + patchPanelStatusId, + projectId: DEFAULT_PROJECT_ID, + gatewaySessionId, + wiringConfigId: wiringConfig.wiringConfigId, + serviceId: "hwlab-patch-panel", + state: "active", + environment: ENVIRONMENT_DEV, + activeConnections: wiringConfig.connections.map((connection) => ({ + fromResourceId: connection.from.resourceId, + fromPort: connection.from.port, + toResourceId: connection.to.resourceId, + toPort: connection.to.port + })), + observedAt: now, + metadata: { + propagation: "patch-panel-only", + connectionCount: wiringConfig.connections.length + } + }; +} + +export function createPatchPanel({ wiringConfig, now = () => new Date().toISOString() }) { + const signals = new Map(); + + function routeSignal({ fromResourceId, fromPort, value }) { + const observedAt = now(); + const deliveries = []; + + for (const connection of wiringConfig.connections) { + const forwardMatch = + connection.from.resourceId === fromResourceId && connection.from.port === fromPort; + const reverseMatch = + connection.to.resourceId === fromResourceId && connection.to.port === fromPort; + + if (!forwardMatch && !reverseMatch) { + continue; + } + + const target = forwardMatch ? connection.to : connection.from; + const key = `${target.resourceId}:${target.port}`; + const delivery = { + resourceId: target.resourceId, + port: target.port, + value, + sourceResourceId: fromResourceId, + sourcePort: fromPort, + observedAt + }; + signals.set(key, delivery); + deliveries.push(delivery); + } + + return { + accepted: true, + propagatedBy: "hwlab-patch-panel", + deliveryCount: deliveries.length, + deliveries, + observedAt + }; + } + + function status() { + return createPatchPanelState({ + wiringConfig, + now: now() + }); + } + + return { + wiringConfig, + routeSignal, + status, + signals + }; +} diff --git a/internal/patchpanel/model.test.mjs b/internal/patchpanel/model.test.mjs new file mode 100644 index 00000000..738c5f5b --- /dev/null +++ b/internal/patchpanel/model.test.mjs @@ -0,0 +1,52 @@ +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"; + +test("MVP topology includes two box simulators, two gateway simulators, and one patch panel", () => { + const topology = createMvpTopology(); + + assert.equal(topology.services.boxes.length, 2); + assert.equal(topology.services.gateways.length, 2); + assert.equal(topology.services.patchPanel.serviceId, "hwlab-patch-panel"); + assert.equal(topology.constraints.patchPanelIsOnlyCrossDeviceChannel, true); + + for (const box of topology.services.boxes) { + assert.equal(box.serviceId, "hwlab-box-simu"); + assert.equal(box.constraints.localLoopbackEnabled, false); + assert.equal(box.constraints.crossDevicePropagation, "patch-panel-only"); + } +}); + +test("patch panel routes cross-device signals and records deliveries", () => { + const topology = createMvpTopology(); + const patchPanel = createPatchPanel({ + wiringConfig: topology.wiringConfig, + now: () => "2026-01-01T00:00:01.000Z" + }); + + const result = patchPanel.routeSignal({ + fromResourceId: "res_boxsim_alpha", + fromPort: "uart0", + value: "boot>" + }); + + assert.equal(result.accepted, true); + assert.equal(result.propagatedBy, "hwlab-patch-panel"); + assert.deepEqual(result.deliveries, [ + { + resourceId: "res_boxsim_beta", + port: "uart0", + value: "boot>", + sourceResourceId: "res_boxsim_alpha", + sourcePort: "uart0", + observedAt: "2026-01-01T00:00:01.000Z" + } + ]); +}); + +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()); +}); diff --git a/internal/sim/http.mjs b/internal/sim/http.mjs new file mode 100644 index 00000000..19ea5c31 --- /dev/null +++ b/internal/sim/http.mjs @@ -0,0 +1,65 @@ +import { createServer } from "node:http"; + +export function jsonResponse(res, statusCode, body) { + const payload = JSON.stringify(body, null, 2); + res.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(payload) + }); + res.end(payload); +} + +export async function readJson(req) { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + + const raw = Buffer.concat(chunks).toString("utf8").trim(); + if (!raw) { + return {}; + } + return JSON.parse(raw); +} + +export function createJsonServer({ routes }) { + return createServer(async (req, res) => { + try { + const url = new URL(req.url ?? "/", "http://localhost"); + const key = `${req.method ?? "GET"} ${url.pathname}`; + const handler = routes.get(key); + + if (!handler) { + jsonResponse(res, 404, { + error: "not_found", + path: url.pathname + }); + return; + } + + const body = await handler({ req, url }); + jsonResponse(res, 200, body); + } catch (error) { + jsonResponse(res, 500, { + error: "internal_error", + message: error instanceof Error ? error.message : String(error) + }); + } + }); +} + +export function parsePort(value, fallback) { + const parsed = Number.parseInt(value ?? "", 10); + if (Number.isInteger(parsed) && parsed > 0 && parsed < 65536) { + return parsed; + } + return fallback; +} + +export function listen(server, port, host = "0.0.0.0") { + server.listen(port, host, () => { + const address = server.address(); + const bound = typeof address === "object" && address ? `${address.address}:${address.port}` : String(address); + console.log(`listening on ${bound}`); + }); +} diff --git a/internal/sim/model.mjs b/internal/sim/model.mjs new file mode 100644 index 00000000..d1f4fd0c --- /dev/null +++ b/internal/sim/model.mjs @@ -0,0 +1,109 @@ +import { ENVIRONMENT_DEV } from "../protocol/index.mjs"; + +export const DEFAULT_PROJECT_ID = "prj_mvp_topology"; +export const DEFAULT_GATEWAY_SESSION_ID = "gws_mvp_simu"; + +export const SIM_PORTS = Object.freeze(["uart0", "eth0", "gpio0"]); + +function isoNow() { + return new Date().toISOString(); +} + +export function createBoxResource({ boxId, gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID, now = isoNow() }) { + return { + resourceId: `res_${boxId}`, + projectId: DEFAULT_PROJECT_ID, + gatewaySessionId, + boxId, + resourceType: "simulator_endpoint", + name: `${boxId} simulator endpoint`, + state: "available", + environment: ENVIRONMENT_DEV, + metadata: { + serviceId: "hwlab-box-simu", + ports: SIM_PORTS, + propagation: "patch-panel-only" + }, + createdAt: now, + updatedAt: now + }; +} + +export function createGatewaySession({ + gatewayId, + serviceId = "hwlab-gateway-simu", + endpoint, + now = isoNow() +}) { + return { + gatewaySessionId: `gws_${gatewayId}`, + projectId: DEFAULT_PROJECT_ID, + serviceId, + gatewayId, + endpoint, + status: "connected", + environment: ENVIRONMENT_DEV, + startedAt: now, + lastSeenAt: now, + labels: { + simulator: serviceId === "hwlab-gateway-simu" ? "true" : "false" + } + }; +} + +export function createBoxState({ boxId, gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID, now = isoNow() }) { + const ports = Object.fromEntries( + SIM_PORTS.map((port) => [ + port, + { + port, + value: null, + source: "local", + updatedAt: now + } + ]) + ); + + return { + serviceId: "hwlab-box-simu", + boxId, + projectId: DEFAULT_PROJECT_ID, + gatewaySessionId, + live: true, + resource: createBoxResource({ boxId, gatewaySessionId, now }), + ports, + constraints: { + crossDevicePropagation: "patch-panel-only", + localLoopbackEnabled: false + }, + updatedAt: now + }; +} + +export function createGatewayState({ + gatewayId, + serviceId = "hwlab-gateway-simu", + endpoint, + boxes = [], + now = isoNow() +}) { + return { + serviceId, + gatewayId, + projectId: DEFAULT_PROJECT_ID, + live: true, + session: createGatewaySession({ gatewayId, serviceId, endpoint, now }), + boxes, + updatedAt: now + }; +} + +export function createHealth({ serviceId, name, now = isoNow() }) { + return { + serviceId, + name, + environment: ENVIRONMENT_DEV, + live: true, + observedAt: now + }; +}