// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel. // Responsibility: Resolve YAML-first web-probe sentinel registry entries into one selected sentinel config graph. import { existsSync, readFileSync } from "node:fs"; import { rootPath } from "./config"; import { readWebProbeSentinelConfigRefTarget, type WebProbeSentinelTemplateContext } from "./hwlab-node-web-sentinel-config-ref"; import { HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS, type HwlabRuntimeLaneSpec, type HwlabRuntimeWebProbeSentinelConfigRefKey, type HwlabRuntimeWebProbeSentinelRegistryItemSpec } from "./hwlab-node-lanes"; export interface ResolvedWebProbeSentinel { readonly id: string; readonly enabled: boolean; readonly mode: "registry" | "legacy"; readonly rootPath: string; readonly configRef: string | null; readonly configRefs: Record; readonly target: Record; } export interface WebProbeSentinelRegistryRow { readonly id: string; readonly enabled: boolean; readonly configRef: string; readonly monitorRoot: boolean; readonly publicBaseUrl?: string; readonly routePrefix?: string; } export function webProbeSentinelRegistryRows(spec: HwlabRuntimeLaneSpec): readonly WebProbeSentinelRegistryRow[] { const registry = spec.observability.webProbe?.sentinels; if (registry !== undefined) return registry.map((item) => sentinelRegistryRow(spec, item.id, item.enabled, item.configRef)); const legacy = spec.observability.webProbe?.sentinel; if (legacy === undefined) return []; return [sentinelRegistryRow( spec, "workbench-dsflash-go-tool-call-10x", legacy.enabled, `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel`, )]; } export function effectiveWebProbeSentinelPublicExposure(spec: HwlabRuntimeLaneSpec, sentinelId: string, publicExposure: Record): Record { const monitorRoot = spec.observability.webProbe?.monitorRoot; if (monitorRoot === undefined || monitorRoot.sentinelId !== sentinelId) return publicExposure; if (!monitorRoot.enabled) { return { ...publicExposure, monitorRoot: { enabled: false, sentinelId, caddyManagedBlockOwner: monitorRoot.caddyManagedBlockOwner, valuesRedacted: true, }, }; } const caddy = isRecord(publicExposure.caddy) ? publicExposure.caddy : {}; return { ...publicExposure, publicBaseUrl: monitorRoot.publicBaseUrl, routePrefix: monitorRoot.routePrefix, caddy: { ...caddy, managedBlockOwner: monitorRoot.caddyManagedBlockOwner, rootOrder: "active", }, monitorRoot: { enabled: true, sentinelId, publicBaseUrl: monitorRoot.publicBaseUrl, routePrefix: monitorRoot.routePrefix, caddyManagedBlockOwner: monitorRoot.caddyManagedBlockOwner, valuesRedacted: true, }, }; } export function resolveWebProbeSentinel(spec: HwlabRuntimeLaneSpec, sentinelId: string | null | undefined): ResolvedWebProbeSentinel { const registry = spec.observability.webProbe?.sentinels; if (registry !== undefined) return resolveRegistrySentinel(spec, registry, sentinelId ?? null); const legacy = spec.observability.webProbe?.sentinel; if (legacy === undefined) { throw new Error(`config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinels is missing`); } const id = sentinelId ?? "workbench-dsflash-go-tool-call-10x"; return { id, enabled: legacy.enabled, mode: "legacy", rootPath: `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel`, configRef: null, configRefs: legacy.configRefs, target: { id, configRefs: legacy.configRefs, valuesRedacted: true, }, }; } export function requireSentinelIdForRegistry(spec: HwlabRuntimeLaneSpec, sentinelId: string | null | undefined, command: string): void { const registry = spec.observability.webProbe?.sentinels; if (registry !== undefined && registry.length > 1 && (sentinelId === null || sentinelId === undefined || sentinelId.length === 0)) { const ids = registry.map((item) => item.id).join(", "); throw new Error(`${command} requires --sentinel when multiple sentinels are declared; available: ${ids}`); } } function resolveRegistrySentinel(spec: HwlabRuntimeLaneSpec, registry: readonly HwlabRuntimeWebProbeSentinelRegistryItemSpec[], sentinelId: string | null): ResolvedWebProbeSentinel { const selected = sentinelId === null && registry.length === 1 ? registry[0] : registry.find((item) => item.id === sentinelId); if (selected === undefined) { const ids = registry.map((item) => item.id).join(", "); throw new Error(`unknown web-probe sentinel ${sentinelId ?? "-"} for ${spec.nodeId}/${spec.lane}; available: ${ids}`); } const target = readConfigRefRecord(selected.configRef, spec); const targetId = optionalStringAt(target, "id") ?? selected.id; if (targetId !== selected.id) { throw new Error(`${selected.configRef}.id=${targetId} does not match registry id ${selected.id}`); } return { id: selected.id, enabled: selected.enabled, mode: "registry", rootPath: `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinels.${selected.id}`, configRef: selected.configRef, configRefs: normalizeSentinelConfigRefs(target, selected.configRef), target, }; } function sentinelRegistryRow(spec: HwlabRuntimeLaneSpec, id: string, enabled: boolean, configRef: string): WebProbeSentinelRegistryRow { const monitorRoot = spec.observability.webProbe?.monitorRoot; const isMonitorRoot = monitorRoot?.enabled === true && monitorRoot.sentinelId === id; return { id, enabled, configRef, monitorRoot: isMonitorRoot, ...(isMonitorRoot ? { publicBaseUrl: monitorRoot.publicBaseUrl, routePrefix: monitorRoot.routePrefix } : {}), }; } function normalizeSentinelConfigRefs(target: Record, ref: string): Record { const rawRefs = recordAt(target, "configRefs"); const normalized: Record = {}; for (const key of Object.keys(rawRefs)) { const value = rawRefs[key]; if (typeof value === "string" && value.length > 0) normalized[key] = value; } if (normalized.scenarios === undefined && normalized.workflow !== undefined) normalized.scenarios = normalized.workflow; const missing = HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.filter((key) => normalized[key] === undefined); if (missing.length > 0) throw new Error(`${ref}.configRefs is missing ${missing.join(",")}`); return Object.fromEntries(HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.map((key) => [key, normalized[key]])) as Record; } function readConfigRefRecord(ref: string, context: WebProbeSentinelTemplateContext): Record { const target = readConfigRefTarget(ref, context); if (!isRecord(target)) throw new Error(`${ref} must point to a YAML object`); return target; } export function readConfigRefTarget(ref: string, context?: WebProbeSentinelTemplateContext): unknown { if (context !== undefined) return readWebProbeSentinelConfigRefTarget(context, ref); const [file, path, extra] = ref.split("#"); if (extra !== undefined || file === undefined || path === undefined || file.length === 0 || path.length === 0) { throw new Error(`${ref} must use path/to/file.yaml#object.path syntax`); } if (file.startsWith("/") || file.includes("\0") || file.includes("..") || !file.startsWith("config/") || !file.endsWith(".yaml")) { throw new Error(`${ref} must reference a repo-relative config/*.yaml file without ..`); } const absPath = rootPath(file); if (!existsSync(absPath)) throw new Error(`${file} does not exist`); const doc = Bun.YAML.parse(readFileSync(absPath, "utf8")) as unknown; return valueAtPath(doc, path); } function recordAt(value: unknown, path: string): Record { const found = valueAtPath(value, path); if (!isRecord(found)) throw new Error(`${path} must be an object`); return found; } function optionalStringAt(value: unknown, path: string): string | null { const found = valueAtPath(value, path); return typeof found === "string" && found.length > 0 ? found : null; } function valueAtPath(value: unknown, path: string): unknown { let current: unknown = value; for (const segment of path.split(".")) { if (segment.length === 0) return undefined; const match = /^(?:([A-Za-z0-9_-]+))?(?:\[(\d+)\])?$/u.exec(segment); if (match === null) return undefined; if (match[1] !== undefined) { if (!isRecord(current)) return undefined; current = current[match[1]]; } if (match[2] !== undefined) { if (!Array.isArray(current)) return undefined; current = current[Number(match[2])]; } } return current; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }