feat(web-probe): add multi-sentinel registry
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
|
||||
// Responsibility: Redacted YAML configRef graph for web-probe sentinel plan/status.
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { rootPath } from "./config";
|
||||
import { HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS, type HwlabRuntimeLaneSpec, type HwlabRuntimeWebProbeSentinelConfigRefKey } from "./hwlab-node-lanes";
|
||||
import { resolveWebProbeSentinel, webProbeSentinelRegistryRows, type WebProbeSentinelRegistryRow } from "./hwlab-node-web-sentinel-resolver";
|
||||
import type { RenderedCliResult } from "./output";
|
||||
|
||||
export type WebProbeSentinelConfigAction = "plan" | "status";
|
||||
@@ -15,7 +17,9 @@ export interface WebProbeSentinelConfigPlan {
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly rootPath: string;
|
||||
readonly sentinelId: string | null;
|
||||
readonly enabled: boolean;
|
||||
readonly sentinels: readonly WebProbeSentinelRegistryRow[];
|
||||
readonly refs: readonly WebProbeSentinelConfigRefStatus[];
|
||||
readonly conflicts: readonly string[];
|
||||
readonly next: Record<string, string>;
|
||||
@@ -165,41 +169,64 @@ const REQUIRED_TARGET_SHAPES: Record<HwlabRuntimeWebProbeSentinelConfigRefKey, R
|
||||
},
|
||||
};
|
||||
|
||||
export function webProbeSentinelConfigPlan(spec: HwlabRuntimeLaneSpec, action: WebProbeSentinelConfigAction): WebProbeSentinelConfigPlan {
|
||||
const sentinel = spec.observability.webProbe?.sentinel;
|
||||
const command = `web-probe sentinel ${action} --node ${spec.nodeId} --lane ${spec.lane}`;
|
||||
const rootConfigPath = `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel`;
|
||||
if (sentinel === undefined) {
|
||||
export function webProbeSentinelConfigPlan(spec: HwlabRuntimeLaneSpec, action: WebProbeSentinelConfigAction, sentinelId: string | null = null): WebProbeSentinelConfigPlan {
|
||||
const command = `web-probe sentinel ${action} --node ${spec.nodeId} --lane ${spec.lane}${sentinelId === null ? "" : ` --sentinel ${sentinelId}`}`;
|
||||
const registry = webProbeSentinelRegistryRows(spec);
|
||||
const registryPath = `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinels`;
|
||||
if (sentinelId === null && registry.length > 1) {
|
||||
const enabled = registry.some((item) => item.enabled);
|
||||
return {
|
||||
ok: enabled,
|
||||
command,
|
||||
status: enabled ? "ready" : "disabled",
|
||||
node: spec.nodeId,
|
||||
lane: spec.lane,
|
||||
rootPath: registryPath,
|
||||
sentinelId: null,
|
||||
enabled,
|
||||
sentinels: registry,
|
||||
refs: [],
|
||||
conflicts: [],
|
||||
next: sentinelNext(spec.nodeId, spec.lane, registry[0]?.id ?? null),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
if (registry.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
command,
|
||||
status: "blocked",
|
||||
node: spec.nodeId,
|
||||
lane: spec.lane,
|
||||
rootPath: rootConfigPath,
|
||||
rootPath: registryPath,
|
||||
sentinelId,
|
||||
enabled: false,
|
||||
sentinels: [],
|
||||
refs: [],
|
||||
conflicts: [`${rootConfigPath} is missing`],
|
||||
next: sentinelNext(spec.nodeId, spec.lane),
|
||||
conflicts: [`${registryPath} is missing`],
|
||||
next: sentinelNext(spec.nodeId, spec.lane, sentinelId),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
const refs = HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.map((key) => readSentinelConfigRef(key, sentinel.configRefs[key]));
|
||||
const conflicts = sentinel.enabled ? crossReferenceConflicts(spec, refs) : [];
|
||||
const selected = resolveWebProbeSentinel(spec, sentinelId);
|
||||
const refs = HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.map((key) => readSentinelConfigRef(key, selected.configRefs[key]));
|
||||
const conflicts = selected.enabled ? crossReferenceConflicts(spec, refs) : [];
|
||||
const refBlocked = refs.some((ref) => !ref.present || !ref.targetPresent || ref.missingFields.length > 0 || ref.conflicts.length > 0 || ref.error !== null);
|
||||
const ok = sentinel.enabled && !refBlocked && conflicts.length === 0;
|
||||
const ok = selected.enabled && !refBlocked && conflicts.length === 0;
|
||||
return {
|
||||
ok,
|
||||
command,
|
||||
status: sentinel.enabled ? ok ? "ready" : "blocked" : "disabled",
|
||||
status: selected.enabled ? ok ? "ready" : "blocked" : "disabled",
|
||||
node: spec.nodeId,
|
||||
lane: spec.lane,
|
||||
rootPath: rootConfigPath,
|
||||
enabled: sentinel.enabled,
|
||||
rootPath: selected.rootPath,
|
||||
sentinelId: selected.id,
|
||||
enabled: selected.enabled,
|
||||
sentinels: registry,
|
||||
refs: refs.map(stripInternalTarget),
|
||||
conflicts,
|
||||
next: sentinelNext(spec.nodeId, spec.lane),
|
||||
next: sentinelNext(spec.nodeId, spec.lane, selected.id),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
@@ -280,9 +307,10 @@ function emptyRefStatus(key: HwlabRuntimeWebProbeSentinelConfigRefKey, ref: stri
|
||||
function missingFieldsForTarget(key: HwlabRuntimeWebProbeSentinelConfigRefKey, target: unknown): string[] {
|
||||
const shape = REQUIRED_TARGET_SHAPES[key];
|
||||
if (shape.kind === "array") {
|
||||
if (!Array.isArray(target)) return [`expected ${shape.kind}`];
|
||||
if (target.length === 0) return ["[0]"];
|
||||
return target.flatMap((item, index) => shape.requiredPaths
|
||||
const items = key === "scenarios" && isRecord(target) ? [target] : Array.isArray(target) ? target : null;
|
||||
if (items === null) return [`expected ${shape.kind}`];
|
||||
if (items.length === 0) return ["[0]"];
|
||||
return items.flatMap((item, index) => shape.requiredPaths
|
||||
.filter((path) => valueAtPath(item, path) === undefined)
|
||||
.map((path) => `[${index}].${path}`));
|
||||
}
|
||||
@@ -294,7 +322,7 @@ function crossReferenceConflicts(spec: HwlabRuntimeLaneSpec, refs: readonly Inte
|
||||
const byKey = new Map(refs.map((ref) => [ref.key, ref]));
|
||||
const conflicts: string[] = [];
|
||||
const runtime = recordTarget(byKey.get("runtime"));
|
||||
const scenarios = arrayTarget(byKey.get("scenarios"));
|
||||
const scenarios = scenarioTargets(byKey.get("scenarios"));
|
||||
const promptSet = recordTarget(byKey.get("promptSet"));
|
||||
const cicd = recordTarget(byKey.get("cicd"));
|
||||
const secrets = recordTarget(byKey.get("secrets"));
|
||||
@@ -369,10 +397,11 @@ function stripInternalTarget(ref: InternalConfigRefStatus): WebProbeSentinelConf
|
||||
|
||||
function summarizeTarget(key: HwlabRuntimeWebProbeSentinelConfigRefKey, target: unknown): string {
|
||||
if (target === undefined) return "target=missing";
|
||||
if (key === "scenarios" && Array.isArray(target)) {
|
||||
const ids = target.map((item) => stringAt(item, "id")).filter((item): item is string => item !== null).slice(0, 4);
|
||||
const cadences = target.map((item) => stringAt(item, "cadence")).filter((item): item is string => item !== null).slice(0, 4);
|
||||
const checks = target.flatMap((item) => arrayAt(item, "sessionInvarianceChecks"));
|
||||
if (key === "scenarios") {
|
||||
const items = isRecord(target) ? [target] : Array.isArray(target) ? target : [];
|
||||
const ids = items.map((item) => stringAt(item, "id")).filter((item): item is string => item !== null).slice(0, 4);
|
||||
const cadences = items.map((item) => stringAt(item, "cadence")).filter((item): item is string => item !== null).slice(0, 4);
|
||||
const checks = items.flatMap((item) => arrayAt(item, "sessionInvarianceChecks"));
|
||||
const afterRounds = checks
|
||||
.map((item) => {
|
||||
const value = isRecord(item) ? item.afterRound : null;
|
||||
@@ -380,7 +409,7 @@ function summarizeTarget(key: HwlabRuntimeWebProbeSentinelConfigRefKey, target:
|
||||
})
|
||||
.filter((item): item is string => item !== null)
|
||||
.slice(0, 8);
|
||||
return `items=${target.length} ids=${ids.join(",") || "-"} cadence=${cadences.join(",") || "-"} sessionInvarianceChecks=${checks.length} afterRound=${afterRounds.join(",") || "-"}`;
|
||||
return `items=${items.length} ids=${ids.join(",") || "-"} cadence=${cadences.join(",") || "-"} sessionInvarianceChecks=${checks.length} afterRound=${afterRounds.join(",") || "-"}`;
|
||||
}
|
||||
if (!isRecord(target)) return `kind=${targetKindOf(target)}`;
|
||||
if (key === "runtime") return `namespace=${textAt(target, "namespace")} service=${textAt(target, "serviceName")} image=${short(textAt(target, "imageRef"), 48)}`;
|
||||
@@ -408,7 +437,19 @@ function renderWebProbeSentinelConfigPlan(value: WebProbeSentinelConfigPlan): st
|
||||
return [
|
||||
`web-probe sentinel ${commandAction(value.command)} (${value.status})`,
|
||||
"",
|
||||
sentinelTable(["NODE", "LANE", "ENABLED", "OK", "ROOT"], [[value.node, value.lane, value.enabled, value.ok, value.rootPath]]),
|
||||
sentinelTable(["NODE", "LANE", "SENTINEL", "ENABLED", "OK", "ROOT"], [[value.node, value.lane, value.sentinelId ?? "registry", value.enabled, value.ok, value.rootPath]]),
|
||||
...(value.sentinels.length === 0 ? [] : [
|
||||
"",
|
||||
sentinelTable(
|
||||
["SENTINEL", "ENABLED", "CONFIG_REF"],
|
||||
value.sentinels.map((item) => [item.id, item.enabled, short(item.configRef, 110)]),
|
||||
),
|
||||
]),
|
||||
...(value.refs.length === 0 ? [
|
||||
"",
|
||||
"DRILL_DOWN",
|
||||
...value.sentinels.map((item) => ` ${item.id}: bun scripts/cli.ts web-probe sentinel ${commandAction(value.command)} --node ${value.node} --lane ${value.lane} --sentinel ${item.id}`),
|
||||
] : [
|
||||
"",
|
||||
sentinelTable(
|
||||
["KEY", "PRESENT", "TARGET", "TYPE", "HASH", "MISSING", "SUMMARY"],
|
||||
@@ -427,6 +468,7 @@ function renderWebProbeSentinelConfigPlan(value: WebProbeSentinelConfigPlan): st
|
||||
["KEY", "FILE", "PATH", "BYTES"],
|
||||
value.refs.map((ref) => [ref.key, ref.file, ref.path, ref.byteCount ?? "-"]),
|
||||
),
|
||||
]),
|
||||
...blocked,
|
||||
"",
|
||||
"NEXT",
|
||||
@@ -437,10 +479,11 @@ function renderWebProbeSentinelConfigPlan(value: WebProbeSentinelConfigPlan): st
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function sentinelNext(node: string, lane: string): Record<string, string> {
|
||||
function sentinelNext(node: string, lane: string, sentinelId: string | null): Record<string, string> {
|
||||
const suffix = sentinelId === null ? "" : ` --sentinel ${sentinelId}`;
|
||||
return {
|
||||
plan: `bun scripts/cli.ts web-probe sentinel plan --node ${node} --lane ${lane} --dry-run`,
|
||||
status: `bun scripts/cli.ts web-probe sentinel status --node ${node} --lane ${lane}`,
|
||||
plan: `bun scripts/cli.ts web-probe sentinel plan --node ${node} --lane ${lane}${suffix} --dry-run`,
|
||||
status: `bun scripts/cli.ts web-probe sentinel status --node ${node} --lane ${lane}${suffix}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -487,6 +530,12 @@ function arrayTarget(ref: InternalConfigRefStatus | undefined): Record<string, u
|
||||
return ref !== undefined && Array.isArray(ref.target) ? ref.target.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function scenarioTargets(ref: InternalConfigRefStatus | undefined): Record<string, unknown>[] {
|
||||
if (ref === undefined) return [];
|
||||
if (Array.isArray(ref.target)) return ref.target.filter(isRecord);
|
||||
return isRecord(ref.target) ? [ref.target] : [];
|
||||
}
|
||||
|
||||
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