feat(web-probe): add multi-sentinel registry

This commit is contained in:
Codex
2026-06-26 12:42:04 +00:00
parent 7b3df965cc
commit 4e0f1cba21
25 changed files with 1038 additions and 140 deletions
+77 -28
View File
@@ -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);
}