feat(web-probe): add multi-sentinel registry
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
// 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 { 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<HwlabRuntimeWebProbeSentinelConfigRefKey, string>;
|
||||
readonly target: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WebProbeSentinelRegistryRow {
|
||||
readonly id: string;
|
||||
readonly enabled: boolean;
|
||||
readonly configRef: string;
|
||||
}
|
||||
|
||||
export function webProbeSentinelRegistryRows(spec: HwlabRuntimeLaneSpec): readonly WebProbeSentinelRegistryRow[] {
|
||||
const registry = spec.observability.webProbe?.sentinels;
|
||||
if (registry !== undefined) return registry.map((item) => ({ id: item.id, enabled: item.enabled, configRef: item.configRef }));
|
||||
const legacy = spec.observability.webProbe?.sentinel;
|
||||
if (legacy === undefined) return [];
|
||||
return [{
|
||||
id: "workbench-dsflash-go-tool-call-10x",
|
||||
enabled: legacy.enabled,
|
||||
configRef: `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel`,
|
||||
}];
|
||||
}
|
||||
|
||||
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 <id> 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);
|
||||
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 normalizeSentinelConfigRefs(target: Record<string, unknown>, ref: string): Record<HwlabRuntimeWebProbeSentinelConfigRefKey, string> {
|
||||
const rawRefs = recordAt(target, "configRefs");
|
||||
const normalized: Record<string, string> = {};
|
||||
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<HwlabRuntimeWebProbeSentinelConfigRefKey, string>;
|
||||
}
|
||||
|
||||
function readConfigRefRecord(ref: string): Record<string, unknown> {
|
||||
const target = readConfigRefTarget(ref);
|
||||
if (!isRecord(target)) throw new Error(`${ref} must point to a YAML object`);
|
||||
return target;
|
||||
}
|
||||
|
||||
export function readConfigRefTarget(ref: string): unknown {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
Reference in New Issue
Block a user