Files
pikasTech-unidesk/scripts/src/hwlab-node-web-sentinel-resolver.ts
T
2026-06-26 12:48:32 +00:00

155 lines
7.0 KiB
TypeScript

// 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);
}