Merge pull request #1029 from pikasTech/feat/1017-multi-sentinel

feat(web-probe): add multi-sentinel registry
This commit is contained in:
Lyon
2026-06-26 20:51:23 +08:00
committed by GitHub
25 changed files with 1038 additions and 140 deletions
+4 -1
View File
@@ -1,4 +1,5 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Help payloads for HWLAB node/lane and web-probe CLI entries.
import { hwlabRuntimeLaneConfigPath } from "./hwlab-node-lanes";
@@ -59,7 +60,8 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"bun scripts/cli.ts web-probe observe collect webobs-xxxx --view project-mdtodo-summary",
"bun scripts/cli.ts web-probe observe analyze webobs-xxxx",
"bun scripts/cli.ts web-probe sentinel plan --node D601 --lane v03 --dry-run",
"bun scripts/cli.ts web-probe sentinel maintenance stop --node D601 --lane v03 --confirm --wait --release-id <id>",
"bun scripts/cli.ts web-probe sentinel plan --node D601 --lane v03 --sentinel workbench-auth-session-switch-2users",
"bun scripts/cli.ts web-probe sentinel maintenance stop --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x --confirm --wait --release-id <id>",
],
actions: {
run: "Run the repo-owned scripts/web-live-dom-probe.mjs helper.",
@@ -75,6 +77,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"After observe start, prefer observe status|command|stop|collect|analyze <id> instead of repeating --node/--lane/--state-dir.",
"collect views render bounded summaries from existing artifacts and do not create a second source of truth.",
"analyze is offline-only: it reads artifact JSONL and writes analysis/report.md plus analysis/report.json.",
"When multiple web-probe sentinels are declared, sentinel image/control-plane/validate/maintenance/report require `--sentinel <id>`; plan/status without it show the registry drill-down.",
"Issue evidence should cite observer id, stateDir, report SHA, screenshot SHA, command ids and concise summaries, not prompt/provider/secret payloads.",
],
};
+41 -2
View File
@@ -1,5 +1,6 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// 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.
// SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets.
// Responsibility: YAML source-of-truth parsing for HWLAB node/lane Workbench observability.
import { readFileSync } from "node:fs";
@@ -149,6 +150,12 @@ export interface HwlabRuntimeWebProbeSentinelSpec {
readonly configRefs: Record<HwlabRuntimeWebProbeSentinelConfigRefKey, string>;
}
export interface HwlabRuntimeWebProbeSentinelRegistryItemSpec {
readonly id: string;
readonly enabled: boolean;
readonly configRef: string;
}
export interface HwlabRuntimeWebProbeAlertThresholdsSpec {
readonly sameOriginApiSlowMs: number;
readonly partialApiSlowMs: number;
@@ -189,6 +196,7 @@ export interface HwlabRuntimeObservabilitySpec {
export interface HwlabRuntimeObservabilityWebProbeSpec {
readonly sentinel?: HwlabRuntimeWebProbeSentinelSpec;
readonly sentinels?: readonly HwlabRuntimeWebProbeSentinelRegistryItemSpec[];
}
export interface HwlabRuntimeObservabilityMetricsEndpointSpec {
@@ -793,6 +801,35 @@ function webProbeSentinelConfig(value: unknown, path: string): HwlabRuntimeWebPr
};
}
function webProbeSentinelRegistryItemConfig(value: unknown, path: string): HwlabRuntimeWebProbeSentinelRegistryItemSpec {
const raw = asRecord(value, path);
const allowed = new Set(["id", "enabled", "configRef"]);
for (const key of Object.keys(raw)) {
if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed; sentinel registry items may only contain id/enabled/configRef`);
}
const id = stringField(raw, "id", path);
if (!/^[a-z0-9][a-z0-9-]{1,80}$/u.test(id)) throw new Error(`${path}.id must be a stable lowercase sentinel id`);
const configRef = stringField(raw, "configRef", path);
validateConfigRef(configRef, `${path}.configRef`);
return {
id,
enabled: booleanField(raw, "enabled", path),
configRef,
};
}
function webProbeSentinelRegistryConfig(value: unknown, path: string): readonly HwlabRuntimeWebProbeSentinelRegistryItemSpec[] {
if (!Array.isArray(value)) throw new Error(`${path} must be an array`);
if (value.length === 0) throw new Error(`${path} must contain at least one sentinel`);
const items = value.map((item, index) => webProbeSentinelRegistryItemConfig(item, `${path}[${index}]`));
const ids = new Set<string>();
for (const item of items) {
if (ids.has(item.id)) throw new Error(`${path} contains duplicate sentinel id ${item.id}`);
ids.add(item.id);
}
return items;
}
function validateConfigRef(ref: string, path: string): void {
const [file, fragment, extra] = ref.split("#");
if (extra !== undefined || file === undefined || fragment === undefined || file.length === 0 || fragment.length === 0) {
@@ -937,12 +974,14 @@ function observabilityConfig(value: unknown, path: string): HwlabRuntimeObservab
function observabilityWebProbeConfig(value: unknown, path: string): HwlabRuntimeObservabilityWebProbeSpec | undefined {
if (value === undefined) return undefined;
const raw = asRecord(value, path);
const allowed = new Set(["sentinel"]);
const allowed = new Set(["sentinel", "sentinels"]);
for (const key of Object.keys(raw)) {
if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed; observability.webProbe currently only owns sentinel`);
if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed; observability.webProbe currently only owns sentinel/sentinels`);
}
if (raw.sentinel !== undefined && raw.sentinels !== undefined) throw new Error(`${path} may declare sentinel or sentinels, not both`);
return {
...(raw.sentinel === undefined ? {} : { sentinel: webProbeSentinelConfig(raw.sentinel, `${path}.sentinel`) }),
...(raw.sentinels === undefined ? {} : { sentinels: webProbeSentinelRegistryConfig(raw.sentinels, `${path}.sentinels`) }),
};
}
@@ -1,4 +1,5 @@
// SPEC: PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Source string for the pure-client HWLAB web-probe observer runner.
import { nodeWebObserveRunnerCommandActionsSource } from "./hwlab-node-web-observe-runner-actions-source";
@@ -340,6 +341,10 @@ async function processCommand(command) {
await appendJsonl(files.control, controlRecord(command, "started", commandInputSummary(command)));
switch (command.type) {
case "login": return authenticate(context, page);
case "loginAccount": return withObserverSync(await loginAccount(command), "loginAccount");
case "logout": return withObserverSync(await logoutAccount(command), "logout");
case "listSessions": return withObserverSync(await listSessions(command), "listSessions");
case "switchSessions": return withObserverSync(await switchSessions(command), "switchSessions");
case "preflight": return preflightSummary();
case "goto": return withObserverSync(await gotoTarget(command.path || command.url || targetPath), "goto");
case "newSession": return withObserverSync(await createSessionFromUi(), "newSession");
@@ -592,7 +597,7 @@ async function authenticate(browserContext, authPage) {
throw error;
}
async function pageAuthLogin(authPage, loginUrl) {
async function pageAuthLogin(authPage, loginUrl, credential = { username, password }) {
if (!authPage) throw new Error("auth page is not ready");
await authPage.goto(new URL("/assets/favicon.svg", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 12000 });
return authPage.evaluate(async (input) => {
@@ -608,7 +613,156 @@ async function pageAuthLogin(authPage, loginUrl) {
status: response.status,
statusText: response.statusText || "",
};
}, { username, password, loginUrl });
}, { username: credential.username, password: credential.password, loginUrl });
}
async function loginAccount(command) {
const accountId = requiredAccountId(command, ["accountId", "account", "value", "text"]);
const credential = credentialForAccount(accountId);
const loginUrl = new URL("/auth/login", baseUrl).toString();
const before = await accountSessionSnapshot();
const response = await pageAuthLogin(page, loginUrl, credential);
const cookieState = await readAuthCookieState(context);
if (!response.ok || !cookieState.cookiePresent) {
const error = new Error("loginAccount failed for accountId=" + accountId + " status=" + response.status + " " + (response.statusText || ""));
error.details = { accountId, status: response.status, statusText: response.statusText, cookiePresent: cookieState.cookiePresent, credentialSource: credential.source, valuesRedacted: true };
throw error;
}
const target = isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "") ? safeUrlPath(currentPageUrl()) : targetPath;
const navigation = await gotoTarget(target || targetPath);
const after = await accountSessionSnapshot();
return { ok: true, type: "loginAccount", accountId, credentialSource: credential.source, before, after, navigation, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true };
}
async function logoutAccount(command = {}) {
const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null;
const before = await accountSessionSnapshot();
const logoutUrl = new URL("/logout", baseUrl).toString();
const response = await page.evaluate(async (input) => {
const res = await fetch(input.logoutUrl, { method: "POST", headers: { accept: "application/json" }, credentials: "include" });
await res.text().catch(() => "");
return { ok: res.ok, status: res.status, statusText: res.statusText || "" };
}, { logoutUrl });
await context.clearCookies().catch(() => {});
const cookieState = await readAuthCookieState(context);
const afterUrl = await page.goto(new URL("/auth/login", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 15000 }).then(() => currentPageUrl()).catch(() => currentPageUrl());
const result = { ok: response.ok || response.status === 401 || !cookieState.cookiePresent, type: "logout", accountId, status: response.status, statusText: response.statusText, before, after: { url: afterUrl, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true }, valuesRedacted: true };
if (!result.ok) {
const error = new Error("logout failed status=" + response.status + " " + (response.statusText || ""));
error.details = result;
throw error;
}
return result;
}
async function listSessions(command = {}) {
const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null;
if (!isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "")) await gotoTarget(targetPath);
const snapshot = await workbenchSessionSnapshot();
const sessions = await page.evaluate(() => {
const seen = new Set();
const rows = [];
for (const element of Array.from(document.querySelectorAll("[data-session-id], .session-tab, a[href*='/workbench/sessions/']"))) {
const sessionId = element.getAttribute("data-session-id") || (element.getAttribute("href") || "").match(/\/workbench\/sessions\/([^/?#]+)/)?.[1] || "";
if (!sessionId || seen.has(sessionId)) continue;
seen.add(sessionId);
rows.push({
sessionId,
active: element.getAttribute("data-active") === "true" || element.getAttribute("aria-selected") === "true",
status: element.getAttribute("data-status") || null,
conversationId: element.getAttribute("data-conversation-id") || null,
});
}
return rows.slice(0, 50);
}).catch(() => []);
return { ok: true, type: "listSessions", accountId, sessionCount: sessions.length, activeSessionId: snapshot?.activeSessionId || snapshot?.routeSessionId || null, sessions, snapshot, valuesRedacted: true };
}
async function switchSessions(command) {
const fromAccountId = commandValue(command, ["fromAccountId", "fromAccount", "accountId"]);
const toAccountId = requiredAccountId(command, ["toAccountId", "toAccount", "value", "text"]);
const before = await accountSessionSnapshot();
if (fromAccountId) {
const beforeAccount = before.accountId || null;
await appendJsonl(files.control, eventRecord("switchSessions-from-account", { fromAccountId, observedAccountId: beforeAccount, valuesRedacted: true }));
}
const logout = await logoutAccount({ ...command, accountId: fromAccountId || null });
const login = await loginAccount({ ...command, accountId: toAccountId });
const sessions = await listSessions({ ...command, accountId: toAccountId });
return { ok: login.ok === true && sessions.ok === true, type: "switchSessions", fromAccountId: fromAccountId || null, toAccountId, before, logout, login, sessions, valuesRedacted: true };
}
async function accountSessionSnapshot() {
const cookieState = await readAuthCookieState(context);
const workbench = await workbenchSessionSnapshot().catch(() => null);
return {
url: currentPageUrl(),
path: safeUrlPath(currentPageUrl()),
cookiePresent: cookieState.cookiePresent,
cookieNames: cookieState.cookieNames,
activeSessionId: workbench?.activeSessionId || null,
routeSessionId: workbench?.routeSessionId || null,
tabCount: workbench?.tabCount ?? null,
messageCount: workbench?.messageCount ?? null,
valuesRedacted: true,
};
}
function requiredAccountId(command, keys) {
const accountId = commandValue(command, keys);
if (!isSafeAccountId(accountId)) throw new Error(command.type + " requires --account-id using lowercase account id");
return accountId;
}
function credentialForAccount(accountId) {
if (accountId === "bootstrap-admin" || accountId === "admin") {
if (!password) throw new Error("loginAccount accountId=" + accountId + " missing HWLAB_WEB_PASS");
return { username, password, source: "HWLAB_WEB_USER/HWLAB_WEB_PASS", valuesRedacted: true };
}
const env = accountCredentialEnvCandidates(accountId);
for (const jsonKey of env.jsonKeys) {
const raw = process.env[jsonKey];
if (!raw) continue;
const parsed = parseCredentialJson(raw);
if (parsed !== null) return { ...parsed, source: jsonKey, valuesRedacted: true };
}
for (const pair of env.pairs) {
const user = process.env[pair.userKey];
const pass = process.env[pair.passKey];
if (user && pass) return { username: user, password: pass, source: pair.userKey + "/" + pair.passKey, valuesRedacted: true };
}
throw new Error("loginAccount missing credential material for accountId=" + accountId + "; expected one of " + [...env.jsonKeys, ...env.pairs.flatMap((item) => [item.userKey, item.passKey])].join(","));
}
function accountCredentialEnvCandidates(accountId) {
const segment = accountId.toUpperCase().replace(/[^A-Z0-9]+/gu, "_").replace(/^_+|_+$/gu, "");
return {
jsonKeys: [
"HWLAB_WEB_" + segment + "_JSON",
"HWLAB_WEB_ACCOUNT_" + segment + "_JSON",
],
pairs: [
{ userKey: "HWLAB_WEB_" + segment + "_USER", passKey: "HWLAB_WEB_" + segment + "_PASS" },
{ userKey: "HWLAB_WEB_ACCOUNT_" + segment + "_USER", passKey: "HWLAB_WEB_ACCOUNT_" + segment + "_PASS" },
],
};
}
function parseCredentialJson(raw) {
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const user = typeof parsed.username === "string" ? parsed.username : typeof parsed.user === "string" ? parsed.user : typeof parsed.email === "string" ? parsed.email : "";
const pass = typeof parsed.password === "string" ? parsed.password : typeof parsed.pass === "string" ? parsed.pass : "";
if (!user || !pass) return null;
return { username: user, password: pass, valuesRedacted: true };
} catch {
return null;
}
}
function isSafeAccountId(value) {
return /^[a-z0-9][a-z0-9-]{1,80}$/u.test(String(value || ""));
}
function publicAuth(value) {
+138 -46
View File
@@ -1,5 +1,6 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p8-web-probe-sentinel-recovery.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: YAML-first CI/CD, image, GitOps and Argo command plan for the web-probe sentinel.
import { createHash, randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
@@ -8,6 +9,7 @@ import { repoRoot, rootPath } from "./config";
import { runCommand, type CommandResult } from "./command";
import { startJob } from "./jobs";
import { webProbeSentinelConfigPlan, withWebProbeSentinelConfigRendered } from "./hwlab-node-web-sentinel-config";
import { requireSentinelIdForRegistry, resolveWebProbeSentinel } from "./hwlab-node-web-sentinel-resolver";
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
import type { RenderedCliResult } from "./output";
@@ -15,7 +17,7 @@ export type WebProbeSentinelConfigAction = "plan" | "status";
export type WebProbeSentinelImageAction = "status" | "build";
export type WebProbeSentinelControlPlaneAction = "plan" | "apply" | "status" | "trigger-current";
export type WebProbeSentinelMaintenanceAction = "status" | "start" | "stop";
export type WebProbeSentinelReportView = "summary" | "turn-summary" | "findings" | "trace-frame";
export type WebProbeSentinelReportView = "summary" | "turn-summary" | "findings" | "trace-frame" | "auth-session-switch-summary";
export type WebProbeSentinelOptions =
| {
@@ -23,6 +25,7 @@ export type WebProbeSentinelOptions =
readonly action: WebProbeSentinelConfigAction;
readonly node: string;
readonly lane: string;
readonly sentinelId: string | null;
readonly dryRun: boolean;
}
| {
@@ -30,6 +33,7 @@ export type WebProbeSentinelOptions =
readonly action: WebProbeSentinelImageAction;
readonly node: string;
readonly lane: string;
readonly sentinelId: string | null;
readonly dryRun: boolean;
readonly confirm: boolean;
readonly wait: boolean;
@@ -40,6 +44,7 @@ export type WebProbeSentinelOptions =
readonly action: WebProbeSentinelControlPlaneAction;
readonly node: string;
readonly lane: string;
readonly sentinelId: string | null;
readonly dryRun: boolean;
readonly confirm: boolean;
readonly wait: boolean;
@@ -50,6 +55,7 @@ export type WebProbeSentinelOptions =
readonly action: WebProbeSentinelMaintenanceAction;
readonly node: string;
readonly lane: string;
readonly sentinelId: string | null;
readonly dryRun: boolean;
readonly confirm: boolean;
readonly wait: boolean;
@@ -63,6 +69,7 @@ export type WebProbeSentinelOptions =
readonly action: "validate";
readonly node: string;
readonly lane: string;
readonly sentinelId: string | null;
readonly dryRun: boolean;
readonly confirm: boolean;
readonly wait: boolean;
@@ -74,6 +81,7 @@ export type WebProbeSentinelOptions =
readonly action: "report";
readonly node: string;
readonly lane: string;
readonly sentinelId: string | null;
readonly view: WebProbeSentinelReportView;
readonly runId: string | null;
readonly latest: boolean;
@@ -85,6 +93,8 @@ export type WebProbeSentinelOptions =
interface SentinelCicdState {
readonly spec: HwlabRuntimeLaneSpec;
readonly sentinelId: string;
readonly configRefs: Record<string, string>;
readonly configReady: boolean;
readonly runtime: Record<string, unknown>;
readonly cicd: Record<string, unknown>;
@@ -163,8 +173,9 @@ interface ChildCliResult {
const SPEC_REF = "PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel";
export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options: WebProbeSentinelOptions): RenderedCliResult {
if (options.kind === "config") return withWebProbeSentinelConfigRendered(webProbeSentinelConfigPlan(spec, options.action));
const state = loadSentinelCicdState(spec, options.timeoutSeconds);
if (options.kind === "config") return withWebProbeSentinelConfigRendered(webProbeSentinelConfigPlan(spec, options.action, options.sentinelId));
requireSentinelIdForRegistry(spec, options.sentinelId, `web-probe sentinel ${options.kind}`);
const state = loadSentinelCicdState(spec, options.sentinelId, options.timeoutSeconds);
if (options.kind === "image") return runSentinelImage(state, options);
if (options.kind === "control-plane") return runSentinelControlPlane(state, options);
if (options.kind === "maintenance") return runSentinelMaintenance(state, options);
@@ -187,6 +198,7 @@ function runSentinelImage(state: SentinelCicdState, options: Extract<WebProbeSen
command,
node: state.spec.nodeId,
lane: state.spec.lane,
sentinelId: state.sentinelId,
mode: options.action === "status" ? "status" : options.confirm ? "confirm" : "dry-run",
mutation: false,
specRef: SPEC_REF,
@@ -198,10 +210,10 @@ function runSentinelImage(state: SentinelCicdState, options: Extract<WebProbeSen
? registryReady ? null : { code: "sentinel-image-missing", reason: "expected sentinel image tag is not present in the node-local registry" }
: { code: "sentinel-source-mirror-not-ready", reason: "source.gitMirrorReadUrl does not expose the selected source commit yet" },
next: {
status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}`,
dryRun: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane} --dry-run`,
confirm: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane} --confirm`,
controlPlanePlan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${state.spec.nodeId} --lane ${state.spec.lane} --dry-run`,
status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`,
dryRun: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --dry-run`,
confirm: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --confirm`,
controlPlanePlan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --dry-run`,
},
valuesRedacted: true,
};
@@ -223,6 +235,7 @@ function runSentinelControlPlane(state: SentinelCicdState, options: Extract<WebP
command,
node: state.spec.nodeId,
lane: state.spec.lane,
sentinelId: state.sentinelId,
mode: options.action === "status" ? "status" : options.confirm ? "confirm" : "dry-run",
mutation: false,
specRef: SPEC_REF,
@@ -262,13 +275,13 @@ function runSentinelControlPlane(state: SentinelCicdState, options: Extract<WebP
return rendered(result.ok, command, renderControlPlaneResult(result));
}
function loadSentinelCicdState(spec: HwlabRuntimeLaneSpec, timeoutSeconds: number): SentinelCicdState {
const sentinel = spec.observability.webProbe?.sentinel;
if (sentinel === undefined) throw new Error(`config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel is missing`);
const configPlan = webProbeSentinelConfigPlan(spec, "status");
function loadSentinelCicdState(spec: HwlabRuntimeLaneSpec, sentinelId: string | null, timeoutSeconds: number): SentinelCicdState {
const sentinel = resolveWebProbeSentinel(spec, sentinelId);
const configPlan = webProbeSentinelConfigPlan(spec, "status", sentinel.id);
const runtime = recordTarget(readConfigRefTarget(sentinel.configRefs.runtime), sentinel.configRefs.runtime);
const cicd = recordTarget(readConfigRefTarget(sentinel.configRefs.cicd), sentinel.configRefs.cicd);
const publicExposure = recordTarget(readConfigRefTarget(sentinel.configRefs.publicExposure), sentinel.configRefs.publicExposure);
const secrets = recordTarget(readConfigRefTarget(sentinel.configRefs.secrets), sentinel.configRefs.secrets);
const controlPlaneRef = stringField(cicd, "controlPlaneConfigRef");
const controlPlaneTarget = recordTarget(readConfigRefTarget(controlPlaneRef), controlPlaneRef);
const controlPlaneConfig = recordTarget(readConfigFile(configRefFile(controlPlaneRef)), configRefFile(controlPlaneRef));
@@ -276,10 +289,12 @@ function loadSentinelCicdState(spec: HwlabRuntimeLaneSpec, timeoutSeconds: numbe
const controlPlaneNode = recordTarget(valueAtPath(controlPlaneConfig, `nodes.${nodeId}`), `${configRefFile(controlPlaneRef)}#nodes.${nodeId}`);
const sourceHead = resolveSourceHead(cicd, timeoutSeconds);
const image = sentinelImagePlan(cicd, sourceHead);
const manifests = renderSentinelManifests(spec, runtime, cicd, publicExposure, image);
const manifests = renderSentinelManifests(spec, sentinel.id, runtime, cicd, publicExposure, secrets, image);
const manifestYaml = `${manifests.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`;
return {
spec,
sentinelId: sentinel.id,
configRefs: sentinel.configRefs,
configReady: configPlan.ok,
runtime,
cicd,
@@ -345,9 +360,11 @@ function sentinelDockerfile(baseImage: string, entrypoint: string): string {
function renderSentinelManifests(
spec: HwlabRuntimeLaneSpec,
sentinelId: string,
runtime: Record<string, unknown>,
cicd: Record<string, unknown>,
publicExposure: Record<string, unknown>,
secrets: Record<string, unknown>,
image: SentinelImagePlan,
): readonly Record<string, unknown>[] {
const namespace = stringAt(runtime, "namespace");
@@ -358,12 +375,14 @@ function renderSentinelManifests(
"unidesk.ai/spec-ref": "PJ2026-01060508",
"unidesk.ai/node": spec.nodeId,
"unidesk.ai/lane": spec.lane,
"unidesk.ai/web-probe-sentinel-id": sentinelId,
};
const deploymentName = stringAt(runtime, "deploymentName");
const serviceName = stringAt(runtime, "serviceName");
const servicePort = numberAt(runtime, "servicePort");
const pvcStorage = stringAt(runtime, "pvcStorage");
const stateRoot = stringAt(runtime, "stateRoot");
const sentinelEnv = sentinelContainerEnv(sentinelId, secrets);
return [
{
apiVersion: "v1",
@@ -385,7 +404,9 @@ function renderSentinelManifests(
specRef: SPEC_REF,
node: spec.nodeId,
lane: spec.lane,
sentinelId,
publicBaseUrl: stringAt(publicExposure, "publicBaseUrl"),
routePrefix: stringAtNullable(publicExposure, "routePrefix") ?? "/",
gitopsPath: stringAt(cicd, "gitopsPath"),
valuesRedacted: true,
}, null, 2),
@@ -411,6 +432,8 @@ function renderSentinelManifests(
spec.nodeId,
"--lane",
spec.lane,
"--sentinel",
sentinelId,
"--state-root",
stateRoot,
"--host",
@@ -418,6 +441,7 @@ function renderSentinelManifests(
"--port",
String(servicePort),
],
env: sentinelEnv,
ports: [{ name: "http", containerPort: servicePort }],
readinessProbe: { httpGet: { path: stringAt(runtime, "healthPath"), port: "http" } },
livenessProbe: { httpGet: { path: stringAt(runtime, "healthPath"), port: "http" } },
@@ -492,6 +516,34 @@ function renderSentinelManifests(
];
}
function sentinelContainerEnv(sentinelId: string, secrets: Record<string, unknown>): readonly Record<string, unknown>[] {
const env: Record<string, unknown>[] = [{ name: "UNIDESK_WEB_PROBE_SENTINEL_ID", value: sentinelId }];
for (const runtimeSecret of arrayAt(secrets, "runtimeSecrets").map(record)) {
const secretName = stringAtNullable(runtimeSecret, "name");
if (secretName === null) continue;
for (const item of arrayAt(runtimeSecret, "data").map(record)) {
const targetKey = stringAtNullable(item, "targetKey");
const sourcePurpose = stringAtNullable(item, "sourcePurpose");
const envName = sourcePurpose === null || targetKey === null ? null : accountSecretEnvName(sourcePurpose, targetKey);
if (envName === null) continue;
env.push({ name: envName, valueFrom: { secretKeyRef: { name: secretName, key: targetKey } } });
}
}
return env;
}
function accountSecretEnvName(sourcePurpose: string, targetKey: string): string | null {
if (!/^account-[a-z0-9-]+$/u.test(sourcePurpose) || !targetKey.endsWith(".json")) return null;
const segment = sourcePurpose.toUpperCase().replace(/[^A-Z0-9]+/gu, "_").replace(/^_+|_+$/gu, "");
return segment.length === 0 ? null : `HWLAB_WEB_${segment}_JSON`;
}
function normalizeRoutePrefix(value: string | null): string {
if (value === null || value.trim() === "" || value.trim() === "/") return "/";
const prefixed = value.trim().startsWith("/") ? value.trim() : `/${value.trim()}`;
return prefixed.replace(/\/+$/u, "") || "/";
}
function probeImageRegistry(state: SentinelCicdState, timeoutSeconds: number): Record<string, unknown> {
const endpoint = stringAt(state.controlPlaneNode, "registry.endpoint");
const repoTag = state.image.ref.replace(`${endpoint}/`, "");
@@ -555,8 +607,8 @@ function runSentinelImageBuildConfirmed(state: SentinelCicdState, options: Extra
? { code: "sentinel-image-publish-failed", reason: "remote image publish job failed before registry validation" }
: { code: "sentinel-image-registry-missing", reason: "image publish completed but expected registry tag is not visible" },
next: {
status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}`,
controlPlaneTrigger: `bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node ${state.spec.nodeId} --lane ${state.spec.lane} --confirm`,
status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`,
controlPlaneTrigger: `bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --confirm`,
},
valuesRedacted: true,
};
@@ -663,9 +715,9 @@ function runSentinelControlPlaneConfirmed(state: SentinelCicdState, options: Ext
function renderAsyncSentinelJob(state: SentinelCicdState, domain: "image" | "control-plane", action: string, timeoutSeconds: number): RenderedCliResult {
const args = domain === "image"
? ["web-probe", "sentinel", "image", action, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)]
: ["web-probe", "sentinel", "control-plane", action, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)];
const job = startJob(`hwlab_nodes_${state.spec.lane}_web_probe_sentinel_${domain}_${action}`, ["bun", "scripts/cli.ts", ...args], `Run HWLAB ${state.spec.lane} web-probe sentinel ${domain} ${action} for node ${state.spec.nodeId}`);
? ["web-probe", "sentinel", "image", action, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--sentinel", state.sentinelId, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)]
: ["web-probe", "sentinel", "control-plane", action, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--sentinel", state.sentinelId, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)];
const job = startJob(`hwlab_nodes_${state.spec.lane}_web_probe_sentinel_${safeJobSegment(state.sentinelId)}_${domain}_${action}`, ["bun", "scripts/cli.ts", ...args], `Run HWLAB ${state.spec.lane} web-probe sentinel ${state.sentinelId} ${domain} ${action} for node ${state.spec.nodeId}`);
const command = `web-probe sentinel ${domain} ${action}`;
const result = {
ok: true,
@@ -1415,11 +1467,12 @@ function confirmBlocked(action: string, state: SentinelCicdState): Record<string
function controlPlaneNext(state: SentinelCicdState, action: WebProbeSentinelControlPlaneAction): Record<string, string> {
const node = state.spec.nodeId;
const lane = state.spec.lane;
const suffix = sentinelCliSuffix(state);
return {
plan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${node} --lane ${lane} --dry-run`,
status: `bun scripts/cli.ts web-probe sentinel control-plane status --node ${node} --lane ${lane}`,
image: `bun scripts/cli.ts web-probe sentinel image status --node ${node} --lane ${lane}`,
triggerCurrent: `bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node ${node} --lane ${lane} --dry-run`,
plan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${node} --lane ${lane}${suffix} --dry-run`,
status: `bun scripts/cli.ts web-probe sentinel control-plane status --node ${node} --lane ${lane}${suffix}`,
image: `bun scripts/cli.ts web-probe sentinel image status --node ${node} --lane ${lane}${suffix}`,
triggerCurrent: `bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node ${node} --lane ${lane}${suffix} --dry-run`,
issue: "https://github.com/pikasTech/unidesk/issues/889",
currentAction: action,
};
@@ -1586,11 +1639,11 @@ function runSentinelReport(state: SentinelCicdState, options: Extract<WebProbeSe
}
function renderAsyncP5Job(state: SentinelCicdState, subcommand: readonly string[], timeoutSeconds: number, releaseId: string | null, reason: string | null, quickVerify: boolean): RenderedCliResult {
const args = ["web-probe", "sentinel", ...subcommand, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)];
const args = ["web-probe", "sentinel", ...subcommand, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--sentinel", state.sentinelId, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)];
if (releaseId !== null) args.push("--release-id", releaseId);
if (reason !== null) args.push("--reason", reason);
if (quickVerify) args.push("--quick-verify");
const job = startJob(`hwlab_nodes_${state.spec.lane}_web_probe_sentinel_${subcommand.join("_")}`, ["bun", "scripts/cli.ts", ...args], `Run HWLAB ${state.spec.lane} web-probe sentinel ${subcommand.join(" ")} for node ${state.spec.nodeId}`);
const job = startJob(`hwlab_nodes_${state.spec.lane}_web_probe_sentinel_${safeJobSegment(state.sentinelId)}_${subcommand.join("_")}`, ["bun", "scripts/cli.ts", ...args], `Run HWLAB ${state.spec.lane} web-probe sentinel ${state.sentinelId} ${subcommand.join(" ")} for node ${state.spec.nodeId}`);
const command = `web-probe sentinel ${subcommand.join(" ")}`;
return rendered(true, command, renderAsyncJobResult({
ok: true,
@@ -1615,7 +1668,11 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
const maxSeconds = numberAt(state.cicd, "targetValidation.maxSeconds");
const scenario = findScenario(state, scenarioId);
if (scenario === null) return { ok: false, status: "blocked", reason: "scenario-not-found", scenarioId, valuesRedacted: true };
const prompts = readPromptSetForScenario(scenario);
const commandSequence = arrayAt(scenario, "commandSequence").map(record);
const needsPromptSet = commandSequence.some((item) => stringAt(item, "type") === "sendPrompt");
const prompts = needsPromptSet
? readPromptSetForScenario(scenario)
: { ok: true as const, prompts: [], summary: { source: "not-required", promptCount: 0, valuesRedacted: true } };
if (!prompts.ok) return { ok: false, status: "blocked", reason: "prompt-source-unavailable", promptSource: prompts, valuesRedacted: true };
const sampleIntervalMs = numberAt(scenario, "sampleIntervalMs");
const budgetSeconds = Math.min(timeoutSeconds, maxSeconds);
@@ -1655,7 +1712,7 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
}
let promptIndex = 0;
const sessionInvarianceChecks = sessionInvarianceChecksByRound(scenario);
for (const item of arrayAt(scenario, "commandSequence").map(record)) {
for (const item of commandSequence) {
const type = stringAt(item, "type");
const repeat = Math.max(1, typeof item.repeat === "number" && Number.isFinite(item.repeat) ? Math.trunc(item.repeat) : 1);
for (let index = 0; index < repeat; index += 1) {
@@ -1675,6 +1732,16 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
}
const args = ["web-probe", "observe", "command", observerId, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--type", type, "--wait-ms", "55000", "--command-timeout-seconds", String(remainingSeconds(deadline, 55))];
if (type === "selectProvider") args.push("--provider", stringAt(item, "provider"));
if (type === "loginAccount" || type === "listSessions" || type === "logout") {
const accountId = stringAtNullable(item, "accountId");
if (accountId !== null) args.push("--account-id", accountId);
}
if (type === "switchSessions") {
const fromAccountId = stringAtNullable(item, "fromAccountId");
const toAccountId = stringAtNullable(item, "toAccountId");
if (fromAccountId !== null) args.push("--from-account-id", fromAccountId);
if (toAccountId !== null) args.push("--to-account-id", toAccountId);
}
if (type === "sendPrompt") {
args.push("--text", prompts.prompts[promptIndex % prompts.prompts.length] ?? "");
promptIndex += 1;
@@ -2152,16 +2219,29 @@ function applySentinelCaddyBlock(state: SentinelCicdState, timeoutSeconds: numbe
const serviceName = stringAt(state.publicExposure, "caddy.serviceName");
const responseHeaderTimeoutSeconds = numberAt(state.publicExposure, "caddy.responseHeaderTimeoutSeconds");
const remotePort = numberAt(state.publicExposure, "frpc.httpProxy.remotePort");
const block = [
`${hostname} {`,
` reverse_proxy 127.0.0.1:${remotePort} {`,
" transport http {",
` response_header_timeout ${responseHeaderTimeoutSeconds}s`,
" }",
const routePrefix = normalizeRoutePrefix(stringAtNullable(state.publicExposure, "routePrefix"));
const proxyLines = [
`reverse_proxy 127.0.0.1:${remotePort} {`,
" transport http {",
` response_header_timeout ${responseHeaderTimeoutSeconds}s`,
" }",
"}",
"",
].join("\n");
];
const block = routePrefix === "/"
? [
`${hostname} {`,
...proxyLines.map((line) => ` ${line}`),
"}",
"",
].join("\n")
: [
`${hostname} {`,
` handle_path ${routePrefix}* {`,
...proxyLines.map((line) => ` ${line}`),
" }",
"}",
"",
].join("\n");
const blockB64 = Buffer.from(block, "utf8").toString("base64");
const script = [
"set +e",
@@ -2229,7 +2309,7 @@ function applySentinelCaddyBlock(state: SentinelCicdState, timeoutSeconds: numbe
].join("\n");
const result = runCommand(["trans", stringAt(state.publicExposure, "caddy.route"), "sh", "--", script], repoRoot, { timeoutMs: Math.min(timeoutSeconds, 60) * 1000 });
const parsed = parseJsonObject(result.stdout);
return { ok: result.exitCode === 0 && parsed?.ok === true, ...record(parsed), result: compactCommand(result), valuesRedacted: true };
return { ok: result.exitCode === 0 && parsed?.ok === true, routePrefix, ...record(parsed), result: compactCommand(result), valuesRedacted: true };
}
function readAnalysisSummaryFromWorkspace(state: SentinelCicdState, stateDir: string, timeoutSeconds: number): Record<string, unknown> {
@@ -2487,11 +2567,9 @@ function cliDataPayload(parsed: Record<string, unknown> | null): Record<string,
}
function findScenario(state: SentinelCicdState, scenarioId: string): Record<string, unknown> | null {
const sentinel = state.spec.observability.webProbe?.sentinel;
if (sentinel === undefined) return null;
const scenarios = readConfigRefTarget(sentinel.configRefs.scenarios);
if (!Array.isArray(scenarios)) return null;
return scenarios.map(record).find((item) => item.id === scenarioId) ?? null;
const scenarios = readConfigRefTarget(state.configRefs.scenarios);
const items = Array.isArray(scenarios) ? scenarios : isRecord(scenarios) ? [scenarios] : [];
return items.map(record).find((item) => item.id === scenarioId) ?? null;
}
function readPromptSetForScenario(scenario: Record<string, unknown>): { ok: true; prompts: string[]; summary: Record<string, unknown> } | { ok: false; error: string; summary: Record<string, unknown> } {
@@ -2595,7 +2673,7 @@ function serviceUnavailableBlocker(state: SentinelCicdState): Record<string, unk
code: "sentinel-service-unavailable",
policy: stringAt(state.cicd, "targetValidation.serviceUnavailablePolicy"),
reason: "sentinel service must be reachable through k3s internal Service DNS before quick verify can run; no public/fallback path is used.",
retry: `bun scripts/cli.ts web-probe sentinel validate --node ${state.spec.nodeId} --lane ${state.spec.lane}`,
retry: `bun scripts/cli.ts web-probe sentinel validate --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`,
valuesRedacted: true,
};
}
@@ -2603,12 +2681,13 @@ function serviceUnavailableBlocker(state: SentinelCicdState): Record<string, unk
function sentinelP5Next(state: SentinelCicdState): Record<string, string> {
const node = state.spec.nodeId;
const lane = state.spec.lane;
const suffix = sentinelCliSuffix(state);
return {
validate: `bun scripts/cli.ts web-probe sentinel validate --node ${node} --lane ${lane}`,
quickVerify: `bun scripts/cli.ts web-probe sentinel validate --node ${node} --lane ${lane} --quick-verify --confirm --wait`,
maintenanceStart: `bun scripts/cli.ts web-probe sentinel maintenance start --node ${node} --lane ${lane} --confirm --wait`,
maintenanceStop: `bun scripts/cli.ts web-probe sentinel maintenance stop --node ${node} --lane ${lane} --confirm --wait`,
report: `bun scripts/cli.ts web-probe sentinel report --node ${node} --lane ${lane} --view summary`,
validate: `bun scripts/cli.ts web-probe sentinel validate --node ${node} --lane ${lane}${suffix}`,
quickVerify: `bun scripts/cli.ts web-probe sentinel validate --node ${node} --lane ${lane}${suffix} --quick-verify --confirm --wait`,
maintenanceStart: `bun scripts/cli.ts web-probe sentinel maintenance start --node ${node} --lane ${lane}${suffix} --confirm --wait`,
maintenanceStop: `bun scripts/cli.ts web-probe sentinel maintenance stop --node ${node} --lane ${lane}${suffix} --confirm --wait`,
report: `bun scripts/cli.ts web-probe sentinel report --node ${node} --lane ${lane}${suffix} --view summary`,
};
}
@@ -2805,7 +2884,20 @@ function renderReportResult(result: Record<string, unknown>): string {
function sentinelPipelineRunName(state: SentinelCicdState): string {
const commit = state.sourceHead.commit ?? "source";
return `hwlab-web-probe-sentinel-${commit.slice(0, 12)}`;
return `hwlab-web-probe-sentinel-${safeKubernetesSegment(state.sentinelId, 24)}-${commit.slice(0, 12)}`;
}
function sentinelCliSuffix(state: SentinelCicdState): string {
return ` --sentinel ${state.sentinelId}`;
}
function safeJobSegment(value: string): string {
return value.replace(/[^A-Za-z0-9_]+/gu, "_").replace(/^_+|_+$/gu, "").slice(0, 48) || "sentinel";
}
function safeKubernetesSegment(value: string, maxLength: number): string {
const normalized = value.toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/^-+|-+$/gu, "");
return (normalized || "sentinel").slice(0, Math.max(1, maxLength)).replace(/-+$/u, "") || "sentinel";
}
function renderImageResult(result: Record<string, unknown>): string {
+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);
}
@@ -1,4 +1,5 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Static dashboard shell and asset serving for the web-probe sentinel frontend.
import { readFileSync } from "node:fs";
import { rootPath } from "./config";
@@ -6,6 +7,7 @@ import { rootPath } from "./config";
interface DashboardShellConfig {
readonly node: string;
readonly lane: string;
readonly sentinelId: string;
readonly plan: { readonly ok: boolean };
readonly publicExposure: Record<string, unknown>;
}
@@ -15,13 +17,14 @@ const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p9-desktop-view-density";
export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig): string {
const publicOrigin = stringOrNull(config.publicExposure.publicBaseUrl) ?? "";
const basePath = publicBasePath(publicOrigin);
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HWLAB Web哨兵</title>
<link rel="stylesheet" href="/dashboard/assets/dashboard.css">
<link rel="stylesheet" href="${escapeAttr(basePath)}/dashboard/assets/dashboard.css">
</head>
<body>
<main
@@ -29,6 +32,8 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
class="sentinel-shell"
data-node="${escapeAttr(config.node)}"
data-lane="${escapeAttr(config.lane)}"
data-sentinel-id="${escapeAttr(config.sentinelId)}"
data-base-path="${escapeAttr(basePath)}"
data-public-origin="${escapeAttr(publicOrigin)}"
data-config-ready="${config.plan.ok ? "true" : "false"}"
data-contract-version="${DASHBOARD_CONTRACT_VERSION}"
@@ -213,11 +218,20 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
<div id="copy-toast" class="copy-toast" hidden>已复制</div>
</main>
<script type="module" src="/dashboard/assets/dashboard.js"></script>
<script type="module" src="${escapeAttr(basePath)}/dashboard/assets/dashboard.js"></script>
</body>
</html>`;
}
function publicBasePath(publicBaseUrl: string): string {
try {
const path = new URL(publicBaseUrl).pathname.replace(/\/+$/u, "");
return path === "/" ? "" : path;
} catch {
return "";
}
}
export function webProbeSentinelDashboardAssetResponse(pathname: string): Response | null {
if (pathname === "/dashboard/assets/dashboard.css") return textAsset(`${DASHBOARD_ASSET_ROOT}/dashboard.css`, "text/css; charset=utf-8");
if (pathname === "/dashboard/assets/dashboard.js") return textAsset(`${DASHBOARD_ASSET_ROOT}/dashboard.js`, "application/javascript; charset=utf-8");
@@ -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);
}
+46 -19
View File
@@ -1,6 +1,7 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p7-web-probe-sentinel-dashboard.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Persistent HTTP wrapper service for web-probe observe scheduling, index, health, metrics, maintenance, and dashboard.
import { Buffer } from "node:buffer";
import { createHash, randomUUID } from "node:crypto";
@@ -11,6 +12,7 @@ import { rootPath } from "./config";
import { renderWebProbeSentinelDashboardHtml, webProbeSentinelDashboardAssetResponse } from "./hwlab-node-web-sentinel-dashboard-assets";
import { webProbeSentinelConfigPlan, type WebProbeSentinelConfigPlan } from "./hwlab-node-web-sentinel-config";
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
import { resolveWebProbeSentinel, readConfigRefTarget as readSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-resolver";
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p9-desktop-view-density";
const DASHBOARD_MAX_TEXT_BYTES = 16_000;
@@ -18,6 +20,7 @@ const DASHBOARD_MAX_TEXT_BYTES = 16_000;
export interface WebProbeSentinelServiceConfig {
readonly node: string;
readonly lane: string;
readonly sentinelId: string;
readonly plan: WebProbeSentinelConfigPlan;
readonly runtime: Record<string, unknown>;
readonly scenarios: readonly Record<string, unknown>[];
@@ -35,6 +38,7 @@ export interface WebProbeSentinelServiceConfig {
export interface WebProbeSentinelServiceOptions {
readonly spec: HwlabRuntimeLaneSpec;
readonly sentinelId?: string | null;
readonly stateRootOverride?: string;
readonly portOverride?: number;
readonly hostOverride?: string;
@@ -83,19 +87,19 @@ export interface WebProbeSentinelService {
}
export function loadWebProbeSentinelServiceConfig(spec: HwlabRuntimeLaneSpec, options: Omit<WebProbeSentinelServiceOptions, "spec"> = {}): WebProbeSentinelServiceConfig {
const sentinel = spec.observability.webProbe?.sentinel;
if (sentinel === undefined) throw new Error(`config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel is missing`);
const plan = webProbeSentinelConfigPlan(spec, "status");
const runtime = recordTarget(readConfigRefTarget(sentinel.configRefs.runtime));
const scenarios = arrayTarget(readConfigRefTarget(sentinel.configRefs.scenarios));
const reportViews = recordTarget(readConfigRefTarget(sentinel.configRefs.reportViews));
const publicExposure = recordTarget(readConfigRefTarget(sentinel.configRefs.publicExposure));
const cicd = recordTarget(readConfigRefTarget(sentinel.configRefs.cicd));
const sentinel = resolveWebProbeSentinel(spec, options.sentinelId ?? null);
const plan = webProbeSentinelConfigPlan(spec, "status", sentinel.id);
const runtime = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.runtime));
const scenarios = scenarioArrayTarget(readSentinelConfigRefTarget(sentinel.configRefs.scenarios));
const reportViews = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.reportViews));
const publicExposure = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.publicExposure));
const cicd = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.cicd));
const stateRoot = options.stateRootOverride ?? stringAt(runtime, "stateRoot");
const yamlSqlitePath = stringAt(runtime, "sqlite.path");
return {
node: spec.nodeId,
lane: spec.lane,
sentinelId: sentinel.id,
plan,
runtime,
scenarios,
@@ -167,6 +171,7 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
ok: true,
node: config.node,
lane: config.lane,
sentinelId: config.sentinelId,
status: "observed",
configReady: config.plan.ok,
scheduler: schedulerSummary(config, db),
@@ -388,7 +393,7 @@ function serviceHealth(config: WebProbeSentinelServiceConfig, db: Database, sche
command: `bun scripts/cli.ts web-probe observe analyze --node ${config.node} --lane ${config.lane} --state-dir <stateDir>`,
};
const ok = Object.values(checks).every((check) => check.ok === true);
return { ok, status: ok ? "healthy" : "degraded", node: config.node, lane: config.lane, checks, valuesRedacted: true };
return { ok, status: ok ? "healthy" : "degraded", node: config.node, lane: config.lane, sentinelId: config.sentinelId, checks, valuesRedacted: true };
}
function checkWritable(stateRoot: string): Record<string, unknown> {
@@ -432,6 +437,16 @@ function buildObserveCommandPlan(config: WebProbeSentinelServiceConfig, scenario
const argv = ["bun", "scripts/cli.ts", "web-probe", "observe", "command", "<observerId>", "--type", type];
if (type === "selectProvider") argv.push("--provider", stringAt(item, "provider"));
if (type === "sendPrompt") argv.push("--text-stdin");
if (type === "loginAccount" || type === "listSessions" || type === "logout") {
const accountId = stringOrNull(item.accountId);
if (accountId !== null) argv.push("--account-id", accountId);
}
if (type === "switchSessions") {
const fromAccountId = stringOrNull(item.fromAccountId);
const toAccountId = stringOrNull(item.toAccountId);
if (fromAccountId !== null) argv.push("--from-account-id", fromAccountId);
if (toAccountId !== null) argv.push("--to-account-id", toAccountId);
}
return { phase: `observe-command-${type}`, argv, stdinSource: type === "sendPrompt" ? "prompt-source" : "none" } satisfies CommandPlanStep;
});
const analyze: CommandPlanStep = {
@@ -459,28 +474,29 @@ function renderMetrics(config: WebProbeSentinelServiceConfig, db: Database, heal
const heartbeat = record(readMetadata(db, "scheduler.heartbeat"));
const heartbeatAt = stringOrNull(heartbeat.at);
const heartbeatAge = heartbeatAt === null ? -1 : Math.max(0, Math.round((Date.now() - Date.parse(heartbeatAt)) / 1000));
const labels = `node="${metricLabel(config.node)}",lane="${metricLabel(config.lane)}",sentinel="${metricLabel(config.sentinelId)}"`;
const lines = [
"# HELP web_probe_sentinel_config_ready Config reference graph is ready.",
"# TYPE web_probe_sentinel_config_ready gauge",
`web_probe_sentinel_config_ready{node="${metricLabel(config.node)}",lane="${metricLabel(config.lane)}"} ${config.plan.ok ? 1 : 0}`,
`web_probe_sentinel_config_ready{${labels}} ${config.plan.ok ? 1 : 0}`,
"# HELP web_probe_sentinel_health Healthy status of the sentinel service.",
"# TYPE web_probe_sentinel_health gauge",
`web_probe_sentinel_health{node="${metricLabel(config.node)}",lane="${metricLabel(config.lane)}"} ${health.ok === true ? 1 : 0}`,
`web_probe_sentinel_health{${labels}} ${health.ok === true ? 1 : 0}`,
"# HELP web_probe_sentinel_runs_total Runs indexed by status.",
"# TYPE web_probe_sentinel_runs_total gauge",
...Object.entries(counts).map(([status, count]) => `web_probe_sentinel_runs_total{status="${metricLabel(status)}"} ${count}`),
...Object.entries(counts).map(([status, count]) => `web_probe_sentinel_runs_total{${labels},status="${metricLabel(status)}"} ${count}`),
"# HELP web_probe_sentinel_active_runs Active observe runs known to the sentinel index.",
"# TYPE web_probe_sentinel_active_runs gauge",
`web_probe_sentinel_active_runs ${countWhere(db, "status IN ('queued', 'running', 'analyzing')")}`,
`web_probe_sentinel_active_runs{${labels}} ${countWhere(db, "status IN ('queued', 'running', 'analyzing')")}`,
"# HELP web_probe_sentinel_recent_findings Findings indexed from recent reports.",
"# TYPE web_probe_sentinel_recent_findings gauge",
`web_probe_sentinel_recent_findings ${sumColumn(db, "runs", "finding_count")}`,
`web_probe_sentinel_recent_findings{${labels}} ${sumColumn(db, "runs", "finding_count")}`,
"# HELP web_probe_sentinel_maintenance_active Maintenance window active flag.",
"# TYPE web_probe_sentinel_maintenance_active gauge",
`web_probe_sentinel_maintenance_active ${maintenance.active ? 1 : 0}`,
`web_probe_sentinel_maintenance_active{${labels}} ${maintenance.active ? 1 : 0}`,
"# HELP web_probe_sentinel_scheduler_heartbeat_age_seconds Scheduler heartbeat age.",
"# TYPE web_probe_sentinel_scheduler_heartbeat_age_seconds gauge",
`web_probe_sentinel_scheduler_heartbeat_age_seconds ${heartbeatAge}`,
`web_probe_sentinel_scheduler_heartbeat_age_seconds{${labels}} ${heartbeatAge}`,
];
return `${lines.join("\n")}\n`;
}
@@ -525,6 +541,7 @@ function dashboardOverview(config: WebProbeSentinelServiceConfig, db: Database,
status: dashboardOverallStatus(health, latestRun, severityCounts),
node: config.node,
lane: config.lane,
sentinelId: config.sentinelId,
publicOrigin: stringOrNull(config.publicExposure.publicBaseUrl),
configReady: config.plan.ok,
health,
@@ -566,6 +583,7 @@ function dashboardRunList(config: WebProbeSentinelServiceConfig, db: Database, u
contractVersion: DASHBOARD_CONTRACT_VERSION,
node: config.node,
lane: config.lane,
sentinelId: config.sentinelId,
filters,
page: {
limit: page.limit,
@@ -594,6 +612,7 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database,
return {
ok: true,
contractVersion: DASHBOARD_CONTRACT_VERSION,
sentinelId: config.sentinelId,
run: dashboardRunSummary(config, db, row),
summary: record(stored.summary),
findings,
@@ -605,9 +624,9 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database,
publicOrigin: stringOrNull(stored.publicOrigin),
},
commands: {
summary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --run ${runId} --view summary`,
turnSummary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --run ${runId} --view turn-summary`,
traceFrame: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --run ${runId} --view trace-frame`,
summary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --sentinel ${config.sentinelId} --run ${runId} --view summary`,
turnSummary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --sentinel ${config.sentinelId} --run ${runId} --view turn-summary`,
traceFrame: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --sentinel ${config.sentinelId} --run ${runId} --view trace-frame`,
},
redaction: record(config.reportViews.redaction),
traceability: runTraceability(config, row),
@@ -651,6 +670,7 @@ function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database,
contractVersion: DASHBOARD_CONTRACT_VERSION,
node: config.node,
lane: config.lane,
sentinelId: config.sentinelId,
filters,
page: {
limit,
@@ -677,6 +697,7 @@ function dashboardRunViews(config: WebProbeSentinelServiceConfig, db: Database,
return {
ok: true,
contractVersion: DASHBOARD_CONTRACT_VERSION,
sentinelId: config.sentinelId,
run: dashboardRunSummary(config, db, row),
views,
view: view ?? null,
@@ -717,6 +738,7 @@ function dashboardRunSummary(config: WebProbeSentinelServiceConfig, db: Database
status: stringOrNull(row.status),
node: stringOrNull(row.node) ?? config.node,
lane: stringOrNull(row.lane) ?? config.lane,
sentinelId: config.sentinelId,
observer_id: stringOrNull(row.observer_id),
observerId: stringOrNull(row.observer_id),
state_dir: stringOrNull(row.state_dir),
@@ -1233,6 +1255,11 @@ function arrayTarget(value: unknown): Record<string, unknown>[] {
return value.map(recordTarget);
}
function scenarioArrayTarget(value: unknown): Record<string, unknown>[] {
if (Array.isArray(value)) return value.map(recordTarget);
return [recordTarget(value)];
}
function valueAtPath(value: unknown, path: string): unknown {
let current: unknown = value;
for (const segment of path.split(".")) {
+8
View File
@@ -4,6 +4,7 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// 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: YAML-first node/lane operations, including Workbench observability control commands.
import { createHash, randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -113,6 +114,10 @@ export type NodeWebProbeObserveAction = "start" | "status" | "command" | "stop"
export type NodeWebProbeObserveCommandType =
| "login"
| "loginAccount"
| "logout"
| "listSessions"
| "switchSessions"
| "preflight"
| "goto"
| "gotoProjectMdtodo"
@@ -199,6 +204,9 @@ export interface NodeWebProbeObserveOptions {
commandRequireComposerReady: boolean;
commandFindingId: string | null;
commandBlocking: boolean | null;
commandAccountId: string | null;
commandFromAccountId: string | null;
commandToAccountId: string | null;
commandSourceId: string | null;
commandFileRef: string | null;
commandFilename: string | null;
+41 -7
View File
@@ -4,6 +4,7 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// 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: YAML-first node/lane operations, including Workbench observability control commands.
import { createHash, randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -60,11 +61,15 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
"--run-id",
"--trace-id",
"--sample-seq",
"--sentinel",
"--sentinel-id",
]), new Set(["--dry-run", "--confirm", "--wait", "--quick-verify", "--raw", "--latest"]));
const node = requiredOption(args, "--node");
assertNodeId(node);
const lane = requiredOption(args, "--lane");
assertLane(lane);
const sentinelId = optionValue(args, "--sentinel") ?? optionValue(args, "--sentinel-id") ?? null;
if (sentinelId !== null && !/^[a-z0-9][a-z0-9-]{1,80}$/u.test(sentinelId)) throw new Error(`--sentinel must be a stable lowercase sentinel id, got ${sentinelId}`);
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
const confirm = args.includes("--confirm");
const dryRun = args.includes("--dry-run");
@@ -72,17 +77,17 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
const timeoutSeconds = positiveIntegerOption(args, "--timeout-seconds", 900, 3600);
let sentinel: WebProbeSentinelOptions;
if (sentinelActionRaw === "plan" || sentinelActionRaw === "status") {
sentinel = { kind: "config", action: sentinelActionRaw, node, lane, dryRun };
sentinel = { kind: "config", action: sentinelActionRaw, node, lane, sentinelId, dryRun };
} else if (sentinelActionRaw === "image") {
const imageAction = args[1];
if (imageAction !== "status" && imageAction !== "build") throw new Error("web-probe sentinel image usage: image status|build --node NODE --lane vNN [--dry-run|--confirm]");
sentinel = { kind: "image", action: imageAction, node, lane, dryRun: imageAction === "build" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds };
sentinel = { kind: "image", action: imageAction, node, lane, sentinelId, dryRun: imageAction === "build" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds };
} else if (sentinelActionRaw === "control-plane") {
const controlPlaneAction = args[1];
if (controlPlaneAction !== "plan" && controlPlaneAction !== "apply" && controlPlaneAction !== "status" && controlPlaneAction !== "trigger-current") {
throw new Error("web-probe sentinel control-plane usage: control-plane plan|apply|status|trigger-current --node NODE --lane vNN [--dry-run|--confirm]");
}
sentinel = { kind: "control-plane", action: controlPlaneAction, node, lane, dryRun: controlPlaneAction === "apply" || controlPlaneAction === "trigger-current" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds };
sentinel = { kind: "control-plane", action: controlPlaneAction, node, lane, sentinelId, dryRun: controlPlaneAction === "apply" || controlPlaneAction === "trigger-current" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds };
} else if (sentinelActionRaw === "maintenance") {
const maintenanceAction = args[1];
if (maintenanceAction !== "status" && maintenanceAction !== "start" && maintenanceAction !== "stop") {
@@ -93,6 +98,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
action: maintenanceAction,
node,
lane,
sentinelId,
dryRun: maintenanceAction === "status" ? dryRun : dryRun || !confirm,
confirm,
wait: args.includes("--wait"),
@@ -102,7 +108,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
quickVerify: maintenanceAction === "stop" || args.includes("--quick-verify"),
};
} else if (sentinelActionRaw === "validate") {
sentinel = { kind: "validate", action: "validate", node, lane, dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds, quickVerify: args.includes("--quick-verify") };
sentinel = { kind: "validate", action: "validate", node, lane, sentinelId, dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds, quickVerify: args.includes("--quick-verify") };
} else {
const view = parseWebProbeSentinelReportView(optionValue(args, "--view") ?? "summary");
const latest = args.includes("--latest");
@@ -116,6 +122,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
action: "report",
node,
lane,
sentinelId,
view,
runId,
latest,
@@ -134,8 +141,8 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
}
function parseWebProbeSentinelReportView(value: string): WebProbeSentinelReportView {
if (value === "summary" || value === "turn-summary" || value === "findings" || value === "trace-frame") return value;
throw new Error(`web-probe sentinel report --view must be summary, turn-summary, findings, or trace-frame; got ${value}`);
if (value === "summary" || value === "turn-summary" || value === "findings" || value === "trace-frame" || value === "auth-session-switch-summary") return value;
throw new Error(`web-probe sentinel report --view must be summary, turn-summary, findings, trace-frame, or auth-session-switch-summary; got ${value}`);
}
export function normalizeNodeWebProbeObserveArgs(args: string[]): { args: string[]; id: string | null } {
@@ -202,6 +209,10 @@ export function parseNodeWebProbeObserveOptions(
"--label",
"--session-id",
"--provider",
"--account-id",
"--account",
"--from-account-id",
"--to-account-id",
"--after-round",
"--severity",
"--alternate-session-strategy",
@@ -272,6 +283,9 @@ export function parseNodeWebProbeObserveOptions(
}
const commandText = commandTextFromStdin ? readFileSync(0, "utf8") : commandTextOption;
const commandSourceId = optionValue(args, "--source-id") ?? null;
const commandAccountId = optionValue(args, "--account-id") ?? optionValue(args, "--account") ?? null;
const commandFromAccountId = optionValue(args, "--from-account-id") ?? null;
const commandToAccountId = optionValue(args, "--to-account-id") ?? null;
const commandFileRef = optionValue(args, "--file-ref") ?? null;
const commandFilename = optionValue(args, "--filename") ?? null;
const commandTaskRef = optionValue(args, "--task-ref") ?? null;
@@ -301,6 +315,9 @@ export function parseNodeWebProbeObserveOptions(
["--expected-sentinel-range", commandExpectedSentinelRange],
["--finding-id", commandFindingId],
["--source-id", commandSourceId],
["--account-id/--account", commandAccountId],
["--from-account-id", commandFromAccountId],
["--to-account-id", commandToAccountId],
["--file-ref", commandFileRef],
["--filename", commandFilename],
["--task-ref", commandTaskRef],
@@ -366,6 +383,9 @@ export function parseNodeWebProbeObserveOptions(
commandRequireComposerReady: args.includes("--require-composer-ready"),
commandFindingId,
commandBlocking,
commandAccountId,
commandFromAccountId,
commandToAccountId,
commandSourceId,
commandFileRef,
commandFilename,
@@ -386,6 +406,10 @@ export function parseNodeWebProbeObserveOptions(
export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbeObserveCommandType {
if (
value === "login"
|| value === "loginAccount"
|| value === "logout"
|| value === "listSessions"
|| value === "switchSessions"
|| value === "preflight"
|| value === "goto"
|| value === "newSession"
@@ -423,7 +447,7 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe
|| value === "mark"
|| value === "stop"
) return value;
throw new Error(`web-probe observe command --type must be login, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, screenshot, mark, or stop; got ${value}`);
throw new Error(`web-probe observe command --type must be login, loginAccount, logout, listSessions, switchSessions, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, screenshot, mark, or stop; got ${value}`);
}
export function parseWebProbeBrowserProxyMode(value: string | undefined): WebProbeBrowserProxyMode {
@@ -1186,6 +1210,12 @@ export function normalizedProxyUrl(parsed: URL): string {
return value;
}
function webProbeAccountEnvAssignments(): string[] {
return Object.entries(process.env)
.filter(([key, value]) => value !== undefined && /^HWLAB_WEB_(?:ACCOUNT_)?[A-Z0-9_]+_(?:JSON|USER|PASS)$/u.test(key))
.map(([key, value]) => `${key}=${shellQuote(value ?? "")}`);
}
export function runNodeWebProbeObserve(
options: NodeWebProbeObserveOptions,
spec: HwlabRuntimeLaneSpec,
@@ -1223,6 +1253,7 @@ export function runNodeWebProbeObserveStart(
const projectManagement = nodeWebProbeProjectManagementConfig(spec);
const runnerEnvAssignments = [
...webProbeProxy.envAssignments,
...webProbeAccountEnvAssignments(),
`HWLAB_WEB_BASE_URL=${shellQuote(options.url)}`,
`HWLAB_WEB_USER=${shellQuote(secretSpec.bootstrapAdminUsername)}`,
`HWLAB_WEB_PASS=${shellQuote(material.password)}`,
@@ -1382,6 +1413,9 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption
requireComposerReady: options.commandRequireComposerReady,
findingId: options.commandFindingId,
blocking: options.commandBlocking,
accountId: options.commandAccountId,
fromAccountId: options.commandFromAccountId,
toAccountId: options.commandToAccountId,
sourceId: options.commandSourceId,
fileRef: options.commandFileRef,
filename: options.commandFilename,