diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index e0d26cc1..3907d091 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -98,6 +98,7 @@ bun scripts/cli.ts hwlab nodes web-probe observe stop webobs-xxxx bun scripts/cli.ts hwlab nodes web-probe observe analyze webobs-xxxx bun scripts/cli.ts hwlab nodes web-probe sentinel plan --node D601 --lane v03 --dry-run bun scripts/cli.ts hwlab nodes web-probe sentinel status --node D601 --lane v03 +bun scripts/web-probe-sentinel-service.ts --node D601 --lane v03 --state-root .state/web-probe-sentinel-smoke --scheduler-disabled --once ``` `observe analyze` 的 duplicate final response 判定必须以 trace-frame 可见行事实为准。`observe collect --view trace-frame` 固定渲染的 `Final Response` 区块是 summary,不是第二条业务 assistant message;只有同一 trace-frame 中出现两个可见 assistant final rows 且内容重复时,才应报告 duplicate finding,并在证据中写明 `finalResponseSummaryBlockCounted=false`。 @@ -125,6 +126,7 @@ bun scripts/cli.ts hwlab nodes web-probe observe analyze webobs-xxxx - `observe collect --view turn-summary` 是第一层 CLI 阅读视图:只从 `samples.jsonl`、`control.jsonl` 和已有 `analysis/report.json` 按需渲染同一 session 的多 turn 摘要,包含用户消息 preview/hash、traceId、状态、耗时/最近更新时间、steer/cancel 标记和 Final Response 摘要。`observe collect --view trace-frame --trace-id --sample-seq ` 是第二层 CLI 阅读视图:从同一采样帧渲染单帧 trace 文字截图,并固定输出 `Final Response` 区块。`observe collect --view project-summary` 从同一 artifact 渲染项目管理 / mdtodo DOM 采样、Workbench launch command、捕获到的 `x-hwlab-otel-trace-id` 和 Tempo drill-down 命令。collect 视图不是采样器新增保存物,不构成第二事实源。 - `observe start/status/command/collect/analyze` 默认输出包含 `Wrapper contract` 区块;该区块证明 Web 哨兵只能 wrap 现有 observe CLI verb、现有 runner/analyzer 和既有 artifact contract,不新增第二套 Playwright runner、analyzer、状态机或私有 web-probe API。 - `web-probe sentinel plan|status` 只读取 `observability.webProbe.sentinel.enabled/configRefs` 和 owning YAML,渲染 redacted 配置引用图、文件 hash、缺失字段和跨 ref 冲突;它不启动浏览器、不读取 Secret 值、不保存采样结果,也不是第二套 runner/analyzer。真正的采样和判定仍以 `observe start|command|collect|analyze` artifacts 为准。 +- `scripts/web-probe-sentinel-service.ts` 是 Web 哨兵 Pod entrypoint;`--once` 只做 config/PVC/SQLite/scheduler/analyzer-command health 快照,`--scheduler-disabled` 仅用于本地服务健康冒烟,不能作为生产运行参数。HTTP 服务只提供 `/api/health`、`/api/status`、`/api/runs`、`/api/maintenance`、`/metrics` 和 redacted dashboard 外壳,底层采样仍只能经 observe CLI adapter。 - `trace-frame` 出现 `(无 trace rows;这是 blocker...)` 时,必须先看同一输出中的 `TRACE DIAGNOSTIC`:记录 pageRole/pageId、traceRows/turns/messages 数量、sampleTraceIds、尾部 traceRow/turn/message 归属。若目标 trace 的 turn/message/final 存在但 traceRows 全部属于旧 trace,应按 Workbench read model authority 分裂登记到架构/业务 issue(例:HWLAB #2124),不得把旧 traceRows 当作新 turn 通过证据,也不得让 analyzer 的聚合计数压过 CLI trace 视图。 - analyzer finding 不得压过 CLI `trace-frame` 人工视图。尤其 `trace-assistant-message-duplicates-final-response` 只有在 `trace-frame` 中同一 completed turn 可见多条相同 assistant final rows 时才按业务 bug 处理;如果 `trace-frame` 只有一条 assistant final row、后面固定 `Final Response` 区块正确且 API messages/turns 对齐,该 amber 归类为 analyzer 精度问题,应登记/修工具,不得阻止业务 closeout。 - 若 `observe status` 显示 PID still alive 但 heartbeat/sample 不推进、`commands/pending/*.json` 不被消费,或 `observe stop --force` 只是继续排队 stop command,应先按 web-probe runner 工具缺陷处理(例:UniDesk #874),用 route 只读确认 PID/heartbeat 后清理进程;不要把 pending command、未触发的 cancel 或 runner stale 混入 Workbench 业务结论。 diff --git a/config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml b/config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml index 22e5657b..eff51338 100644 --- a/config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml +++ b/config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml @@ -15,10 +15,18 @@ sentinel: serviceAccountName: hwlab-web-probe-sentinel deploymentName: hwlab-web-probe-sentinel serviceName: hwlab-web-probe-sentinel + listenHost: 0.0.0.0 servicePort: 8080 pvcName: hwlab-web-probe-sentinel-state stateRoot: /var/lib/web-probe-sentinel - imageRef: 127.0.0.1:5000/hwlab/web-probe-sentinel:yaml-p2 + imageRef: 127.0.0.1:5000/hwlab/web-probe-sentinel:p3-service replicas: 1 healthPath: /api/health metricsPath: /metrics + scheduler: + intervalMs: 30000 + heartbeatStaleSeconds: 120 + maxConcurrentRuns: 1 + sqlite: + path: /var/lib/web-probe-sentinel/index.sqlite + busyTimeoutMs: 2000 diff --git a/scripts/src/hwlab-node-web-sentinel-config.ts b/scripts/src/hwlab-node-web-sentinel-config.ts index a107963a..f1ae9f42 100644 --- a/scripts/src/hwlab-node-web-sentinel-config.ts +++ b/scripts/src/hwlab-node-web-sentinel-config.ts @@ -59,6 +59,7 @@ const REQUIRED_TARGET_SHAPES: Record; + readonly scenarios: readonly Record[]; + readonly reportViews: Record; + readonly stateRoot: string; + readonly sqlitePath: string; + readonly listenHost: string; + readonly servicePort: number; + readonly schedulerIntervalMs: number; + readonly schedulerHeartbeatStaleSeconds: number; + readonly maxConcurrentRuns: number; +} + +export interface WebProbeSentinelServiceOptions { + readonly spec: HwlabRuntimeLaneSpec; + readonly stateRootOverride?: string; + readonly portOverride?: number; + readonly hostOverride?: string; + readonly schedulerEnabled?: boolean; +} + +interface MaintenanceState { + readonly active: boolean; + readonly reason: string | null; + readonly releaseId: string | null; + readonly startedAt: string | null; + readonly stoppedAt: string | null; + readonly quickVerifyPlannedAt: string | null; +} + +interface CommandPlanStep { + readonly phase: string; + readonly argv: readonly string[]; + readonly stdinSource: "none" | "prompt-source"; +} + +export interface WebProbeSentinelService { + readonly config: WebProbeSentinelServiceConfig; + readonly db: Database; + readonly schedulerEnabled: boolean; + startScheduler(): void; + stopScheduler(): void; + close(): void; + health(): Record; + status(): Record; + runs(limit?: number): readonly Record[]; + maintenance(): MaintenanceState; + setMaintenance(active: boolean, input: Record): MaintenanceState; + planScenarioRun(scenarioId: string, reason: string): Record; + metrics(): string; + dashboardHtml(): string; + fetch(request: Request): Promise; +} + +export function loadWebProbeSentinelServiceConfig(spec: HwlabRuntimeLaneSpec, options: Omit = {}): 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 stateRoot = options.stateRootOverride ?? stringAt(runtime, "stateRoot"); + const yamlSqlitePath = stringAt(runtime, "sqlite.path"); + return { + node: spec.nodeId, + lane: spec.lane, + plan, + runtime, + scenarios, + reportViews, + stateRoot, + sqlitePath: options.stateRootOverride === undefined ? yamlSqlitePath : join(stateRoot, "index.sqlite"), + listenHost: options.hostOverride ?? stringAt(runtime, "listenHost"), + servicePort: options.portOverride ?? numberAt(runtime, "servicePort"), + schedulerIntervalMs: numberAt(runtime, "scheduler.intervalMs"), + schedulerHeartbeatStaleSeconds: numberAt(runtime, "scheduler.heartbeatStaleSeconds"), + maxConcurrentRuns: numberAt(runtime, "scheduler.maxConcurrentRuns"), + }; +} + +export function createWebProbeSentinelService(options: WebProbeSentinelServiceOptions): WebProbeSentinelService { + const config = loadWebProbeSentinelServiceConfig(options.spec, options); + mkdirSync(config.stateRoot, { recursive: true }); + const db = new Database(config.sqlitePath); + initializeIndex(db); + const restored = markInterruptedRuns(db, nowIso()); + const schedulerEnabled = options.schedulerEnabled ?? true; + let schedulerTimer: ReturnType | null = null; + let schedulerHeartbeatAt = nowIso(); + let schedulerLastError: string | null = null; + writeMetadata(db, "service.boot", { at: schedulerHeartbeatAt, restoredInterruptedRuns: restored, valuesRedacted: true }); + writeMetadata(db, "scheduler.heartbeat", { at: schedulerHeartbeatAt, loop: "boot" }); + + const service: WebProbeSentinelService = { + config, + db, + schedulerEnabled, + startScheduler() { + if (!schedulerEnabled || schedulerTimer !== null) return; + schedulerHeartbeatAt = nowIso(); + writeMetadata(db, "scheduler.heartbeat", { at: schedulerHeartbeatAt, loop: "started" }); + schedulerTimer = setInterval(() => { + try { + schedulerHeartbeatAt = nowIso(); + writeMetadata(db, "scheduler.heartbeat", { at: schedulerHeartbeatAt, loop: "tick" }); + writeMetadata(db, "scheduler.summary", schedulerSummary(config, db)); + schedulerLastError = null; + } catch (error) { + schedulerLastError = error instanceof Error ? error.message : String(error); + writeMetadata(db, "scheduler.error", { at: nowIso(), message: schedulerLastError }); + } + }, config.schedulerIntervalMs); + }, + stopScheduler() { + if (schedulerTimer !== null) clearInterval(schedulerTimer); + schedulerTimer = null; + writeMetadata(db, "scheduler.heartbeat", { at: nowIso(), loop: "stopped" }); + }, + close() { + this.stopScheduler(); + db.close(); + }, + health() { + return serviceHealth(config, db, { + schedulerEnabled, + schedulerHeartbeatAt, + schedulerTimerActive: schedulerTimer !== null, + schedulerLastError, + }); + }, + status() { + return { + ok: true, + node: config.node, + lane: config.lane, + status: "observed", + configReady: config.plan.ok, + scheduler: schedulerSummary(config, db), + maintenance: this.maintenance(), + runs: runCounts(db), + latestRuns: this.runs(8), + valuesRedacted: true, + }; + }, + runs(limit = 20) { + return db.query("SELECT id, scenario_id, status, node, lane, observer_id, state_dir, report_json_sha256, finding_count, artifact_count, maintenance, created_at, updated_at, interrupted_at FROM runs ORDER BY created_at DESC LIMIT ?") + .all(limit) as Record[]; + }, + maintenance() { + return readMetadata(db, "maintenance") as MaintenanceState | null ?? emptyMaintenance(); + }, + setMaintenance(active: boolean, input: Record) { + const current = this.maintenance(); + const next: MaintenanceState = active + ? { + active: true, + reason: stringOrNull(input.reason), + releaseId: stringOrNull(input.releaseId), + startedAt: nowIso(), + stoppedAt: current.stoppedAt, + quickVerifyPlannedAt: current.quickVerifyPlannedAt, + } + : { + active: false, + reason: current.reason, + releaseId: current.releaseId, + startedAt: current.startedAt, + stoppedAt: nowIso(), + quickVerifyPlannedAt: nowIso(), + }; + writeMetadata(db, "maintenance", next); + if (!active) { + const scenarioId = firstEnabledScenarioId(config); + if (scenarioId !== null) this.planScenarioRun(scenarioId, "maintenance-stop-quick-verify"); + } + return next; + }, + planScenarioRun(scenarioId: string, reason: string) { + const scenario = config.scenarios.find((item) => stringAt(item, "id") === scenarioId); + if (scenario === undefined) throw new Error(`scenario not found: ${scenarioId}`); + const runId = `sentinel-run-${Date.now()}-${randomUUID().slice(0, 8)}`; + const commandPlan = buildObserveCommandPlan(config, scenario); + const createdAt = nowIso(); + db.query("INSERT INTO runs (id, scenario_id, node, lane, status, maintenance, created_at, updated_at, command_plan_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") + .run(runId, scenarioId, config.node, config.lane, "planned", this.maintenance().active ? 1 : 0, createdAt, createdAt, JSON.stringify({ reason, commandPlan, valuesRedacted: true })); + return { ok: true, runId, scenarioId, status: "planned", commandPlanSha256: sha256Json(commandPlan), valuesRedacted: true }; + }, + metrics() { + return renderMetrics(config, db, this.health(), this.maintenance()); + }, + dashboardHtml() { + return renderDashboard(config, this.status()); + }, + async fetch(request: Request) { + return sentinelFetch(service, request); + }, + }; + service.startScheduler(); + return service; +} + +export function startWebProbeSentinelHttpService(service: WebProbeSentinelService): { readonly url: string; readonly stop: () => void } { + const server = Bun.serve({ + hostname: service.config.listenHost, + port: service.config.servicePort, + fetch: (request) => service.fetch(request), + }); + return { + url: `http://${service.config.listenHost}:${server.port}`, + stop: () => server.stop(true), + }; +} + +async function sentinelFetch(service: WebProbeSentinelService, request: Request): Promise { + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/api/health") return jsonResponse(service.health(), service.health().ok === true ? 200 : 503); + if (request.method === "GET" && url.pathname === "/api/status") return jsonResponse(service.status()); + if (request.method === "GET" && url.pathname === "/api/runs") return jsonResponse({ ok: true, runs: service.runs(numberParam(url, "limit", 20)), valuesRedacted: true }); + if (request.method === "GET" && url.pathname === "/api/maintenance") return jsonResponse({ ok: true, maintenance: service.maintenance(), valuesRedacted: true }); + if (request.method === "POST" && (url.pathname === "/api/maintenance/start" || url.pathname === "/api/maintenance/stop")) { + const body = await readJsonBody(request); + const active = url.pathname.endsWith("/start"); + return jsonResponse({ ok: true, maintenance: service.setMaintenance(active, body), valuesRedacted: true }); + } + if (request.method === "POST" && url.pathname === "/api/runs/plan") { + const body = await readJsonBody(request); + return jsonResponse(service.planScenarioRun(stringField(body, "scenarioId"), stringOrNull(body.reason) ?? "manual")); + } + if (request.method === "GET" && url.pathname === "/metrics") { + return new Response(service.metrics(), { headers: { "content-type": "text/plain; version=0.0.4; charset=utf-8" } }); + } + if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/dashboard")) { + return new Response(service.dashboardHtml(), { headers: { "content-type": "text/html; charset=utf-8" } }); + } + return jsonResponse({ ok: false, error: "not-found", path: url.pathname, valuesRedacted: true }, 404); +} + +function initializeIndex(db: Database): void { + db.exec(` + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS runs ( + id TEXT PRIMARY KEY, + scenario_id TEXT NOT NULL, + node TEXT NOT NULL, + lane TEXT NOT NULL, + status TEXT NOT NULL, + observer_id TEXT, + state_dir TEXT, + report_json_sha256 TEXT, + finding_count INTEGER NOT NULL DEFAULT 0, + artifact_count INTEGER NOT NULL DEFAULT 0, + maintenance INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + interrupted_at TEXT, + command_plan_json TEXT + ); + CREATE TABLE IF NOT EXISTS findings ( + run_id TEXT NOT NULL, + finding_id TEXT NOT NULL, + severity TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 1, + summary TEXT NOT NULL, + report_json_sha256 TEXT, + created_at TEXT NOT NULL + ); + `); +} + +function markInterruptedRuns(db: Database, at: string): number { + const result = db.query("UPDATE runs SET status = 'interrupted', interrupted_at = ?, updated_at = ? WHERE status IN ('queued', 'running', 'analyzing')").run(at, at); + return Number(result.changes ?? 0); +} + +function serviceHealth(config: WebProbeSentinelServiceConfig, db: Database, scheduler: Record): Record { + const checks: Record> = {}; + checks.config = { ok: config.plan.ok, status: config.plan.status, conflicts: config.plan.conflicts.length }; + checks.pvc = checkWritable(config.stateRoot); + checks.sqlite = checkSqlite(db); + const heartbeatAt = stringOrNull(scheduler.schedulerHeartbeatAt) ?? stringOrNull(readMetadata(db, "scheduler.heartbeat")?.at); + const heartbeatAgeSeconds = heartbeatAt === null ? null : Math.max(0, Math.round((Date.now() - Date.parse(heartbeatAt)) / 1000)); + checks.scheduler = { + ok: scheduler.schedulerLastError === null && heartbeatAgeSeconds !== null && heartbeatAgeSeconds <= config.schedulerHeartbeatStaleSeconds, + enabled: scheduler.schedulerEnabled === true, + active: scheduler.schedulerTimerActive === true, + heartbeatAt, + heartbeatAgeSeconds, + staleAfterSeconds: config.schedulerHeartbeatStaleSeconds, + lastError: scheduler.schedulerLastError, + }; + checks.analyzer = { + ok: true, + source: "existing observe analyze CLI command", + command: `bun scripts/cli.ts hwlab nodes web-probe observe analyze --node ${config.node} --lane ${config.lane} --state-dir `, + }; + 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 }; +} + +function checkWritable(stateRoot: string): Record { + try { + mkdirSync(stateRoot, { recursive: true }); + const path = join(stateRoot, ".health-write-probe"); + writeFileSync(path, `${nowIso()}\n`); + return { ok: true, stateRoot, probeFile: path }; + } catch (error) { + return { ok: false, stateRoot, error: error instanceof Error ? error.message : String(error) }; + } +} + +function checkSqlite(db: Database): Record { + try { + db.query("SELECT 1 AS ok").get(); + writeMetadata(db, "sqlite.health", { at: nowIso() }); + return { ok: true }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +function buildObserveCommandPlan(config: WebProbeSentinelServiceConfig, scenario: Record): readonly CommandPlanStep[] { + const targetPath = stringAt(scenario, "observeTargetPath"); + const start: CommandPlanStep = { + phase: "observe-start", + argv: [ + "bun", "scripts/cli.ts", "hwlab", "nodes", "web-probe", "observe", "start", + "--node", config.node, + "--lane", config.lane, + "--target-path", targetPath, + "--sample-interval-ms", String(numberAt(scenario, "sampleIntervalMs")), + "--screenshot-interval-ms", String(numberAt(scenario, "screenshotIntervalMs")), + "--command-timeout-seconds", "55", + ], + stdinSource: "none", + }; + const commands = arrayAt(scenario, "commandSequence").map((item) => { + const type = stringAt(item, "type"); + const argv = ["bun", "scripts/cli.ts", "hwlab", "nodes", "web-probe", "observe", "command", "", "--type", type]; + if (type === "selectProvider") argv.push("--provider", stringAt(item, "provider")); + if (type === "sendPrompt") argv.push("--text-stdin"); + return { phase: `observe-command-${type}`, argv, stdinSource: type === "sendPrompt" ? "prompt-source" : "none" } satisfies CommandPlanStep; + }); + const analyze: CommandPlanStep = { + phase: "observe-analyze", + argv: ["bun", "scripts/cli.ts", "hwlab", "nodes", "web-probe", "observe", "analyze", ""], + stdinSource: "none", + }; + return [start, ...commands, analyze]; +} + +function schedulerSummary(config: WebProbeSentinelServiceConfig, db: Database): Record { + return { + enabledScenarios: config.scenarios.filter((item) => boolAt(item, "enabled")).map((item) => stringAt(item, "id")), + intervalMs: config.schedulerIntervalMs, + maxConcurrentRuns: config.maxConcurrentRuns, + activeRuns: countWhere(db, "status IN ('queued', 'running', 'analyzing')"), + plannedRuns: countWhere(db, "status = 'planned'"), + heartbeat: readMetadata(db, "scheduler.heartbeat"), + valuesRedacted: true, + }; +} + +function renderMetrics(config: WebProbeSentinelServiceConfig, db: Database, health: Record, maintenance: MaintenanceState): string { + const counts = runCounts(db); + 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 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}`, + "# 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}`, + "# 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}`), + "# 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')")}`, + "# 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")}`, + "# 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}`, + "# 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}`, + ]; + return `${lines.join("\n")}\n`; +} + +function renderDashboard(config: WebProbeSentinelServiceConfig, status: Record): string { + const runs = Array.isArray(status.latestRuns) ? status.latestRuns.map(record) : []; + const rows = runs.map((run) => `${escapeHtml(stringOrNull(run.id) ?? "-")}${escapeHtml(stringOrNull(run.scenario_id) ?? "-")}${escapeHtml(stringOrNull(run.status) ?? "-")}${escapeHtml(stringOrNull(run.report_json_sha256) ?? "-")}${escapeHtml(String(run.finding_count ?? 0))}`).join(""); + return ` +HWLAB Web Probe Sentinel + + +

HWLAB Web Probe Sentinel

+

${escapeHtml(config.node)} / ${escapeHtml(config.lane)} configReady=${config.plan.ok ? "true" : "false"}

+

Dashboard is redacted: prompt text, assistant body, cookies, tokens, API keys, provider payload, and stdout/stderr are not displayed.

+

Latest Runs

+${rows}
RunScenarioStatusReport SHAFindings
+`; +} + +function readConfigRefTarget(ref: string): unknown { + const [file, path] = ref.split("#"); + if (file === undefined || path === undefined) throw new Error(`invalid configRef: ${ref}`); + const text = readFileSync(rootPath(file), "utf8"); + return valueAtPath(Bun.YAML.parse(text) as unknown, path); +} + +function writeMetadata(db: Database, key: string, value: unknown): void { + db.query("INSERT INTO metadata (key, value_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at") + .run(key, JSON.stringify(value), nowIso()); +} + +function readMetadata(db: Database, key: string): Record | null { + const row = db.query("SELECT value_json FROM metadata WHERE key = ?").get(key) as { value_json?: string } | null; + if (typeof row?.value_json !== "string") return null; + try { + const parsed = JSON.parse(row.value_json) as unknown; + return record(parsed); + } catch { + return null; + } +} + +function runCounts(db: Database): Record { + const rows = db.query("SELECT status, COUNT(*) AS count FROM runs GROUP BY status").all() as { status: string; count: number }[]; + return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)])); +} + +function countWhere(db: Database, where: string): number { + const row = db.query(`SELECT COUNT(*) AS count FROM runs WHERE ${where}`).get() as { count?: number } | null; + return Number(row?.count ?? 0); +} + +function sumColumn(db: Database, table: string, column: string): number { + const row = db.query(`SELECT COALESCE(SUM(${column}), 0) AS total FROM ${table}`).get() as { total?: number } | null; + return Number(row?.total ?? 0); +} + +function firstEnabledScenarioId(config: WebProbeSentinelServiceConfig): string | null { + const scenario = config.scenarios.find((item) => boolAt(item, "enabled")); + return scenario === undefined ? null : stringAt(scenario, "id"); +} + +async function readJsonBody(request: Request): Promise> { + if ((request.headers.get("content-length") ?? "0") === "0") return {}; + const value = await request.json().catch(() => ({})) as unknown; + return record(value); +} + +function jsonResponse(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value, null, 2), { status, headers: { "content-type": "application/json; charset=utf-8" } }); +} + +function emptyMaintenance(): MaintenanceState { + return { active: false, reason: null, releaseId: null, startedAt: null, stoppedAt: null, quickVerifyPlannedAt: null }; +} + +function checkPath(value: unknown, path: string): unknown { + const result = valueAtPath(value, path); + if (result === undefined) throw new Error(`required field missing: ${path}`); + return result; +} + +function stringAt(value: unknown, path: string): string { + const result = checkPath(value, path); + if (typeof result !== "string" || result.length === 0) throw new Error(`${path} must be a non-empty string`); + return result; +} + +function numberAt(value: unknown, path: string): number { + const result = checkPath(value, path); + if (typeof result !== "number" || !Number.isFinite(result)) throw new Error(`${path} must be a number`); + return result; +} + +function boolAt(value: unknown, path: string): boolean { + const result = checkPath(value, path); + if (typeof result !== "boolean") throw new Error(`${path} must be a boolean`); + return result; +} + +function arrayAt(value: unknown, path: string): Record[] { + const result = checkPath(value, path); + if (!Array.isArray(result)) throw new Error(`${path} must be an array`); + return result.filter(record); +} + +function stringField(value: Record, key: string): string { + const found = value[key]; + if (typeof found !== "string" || found.length === 0) throw new Error(`${key} must be a non-empty string`); + return found; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function numberParam(url: URL, key: string, defaultValue: number): number { + const raw = url.searchParams.get(key); + if (raw === null) return defaultValue; + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed > 0 && parsed <= 200 ? parsed : defaultValue; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function recordTarget(value: unknown): Record { + const result = record(value); + if (Object.keys(result).length === 0) throw new Error("configRef target must be an object"); + return result; +} + +function arrayTarget(value: unknown): Record[] { + if (!Array.isArray(value)) throw new Error("configRef target must be an array"); + return value.map(recordTarget); +} + +function valueAtPath(value: unknown, path: string): unknown { + let current: unknown = value; + for (const segment of path.split(".")) { + const match = /^(?:([A-Za-z0-9_-]+))?(?:\[(\d+)\])?$/u.exec(segment); + if (match === null) return undefined; + if (match[1] !== undefined) { + const obj = record(current); + current = obj[match[1]]; + } + if (match[2] !== undefined) { + if (!Array.isArray(current)) return undefined; + current = current[Number(match[2])]; + } + } + return current; +} + +function sha256Json(value: unknown): string { + return `sha256:${createHash("sha256").update(JSON.stringify(value)).digest("hex")}`; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function metricLabel(value: string): string { + return value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"').replace(/\n/gu, "\\n"); +} + +function escapeHtml(value: string): string { + return value.replace(/&/gu, "&").replace(//gu, ">").replace(/"/gu, """); +} diff --git a/scripts/web-probe-sentinel-service.ts b/scripts/web-probe-sentinel-service.ts new file mode 100644 index 00000000..27f0d739 --- /dev/null +++ b/scripts/web-probe-sentinel-service.ts @@ -0,0 +1,61 @@ +#!/usr/bin/env bun +// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel. +// Responsibility: Bun entrypoint for the web-probe sentinel HTTP wrapper service. +import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane } from "./src/hwlab-node-lanes"; +import { createWebProbeSentinelService, startWebProbeSentinelHttpService } from "./src/hwlab-node-web-sentinel-service"; + +const args = process.argv.slice(2); +const node = requiredOption("--node"); +const lane = requiredOption("--lane"); +if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe sentinel service only supports HWLAB runtime lanes, got ${lane}`); +const spec = hwlabRuntimeLaneSpecForNode(lane, node); +const stateRootOverride = optionValue("--state-root"); +const hostOverride = optionValue("--host"); +const portRaw = optionValue("--port"); +const portOverride = portRaw === undefined ? undefined : Number(portRaw); +if (portOverride !== undefined && (!Number.isInteger(portOverride) || portOverride <= 0 || portOverride > 65535)) throw new Error("--port must be 1-65535"); +const schedulerEnabled = !args.includes("--scheduler-disabled"); +const service = createWebProbeSentinelService({ spec, stateRootOverride, hostOverride, portOverride, schedulerEnabled }); + +if (args.includes("--once")) { + const health = service.health(); + console.log(JSON.stringify(health, null, 2)); + service.close(); + process.exit(health.ok === true ? 0 : 1); +} + +const server = startWebProbeSentinelHttpService(service); +console.log(JSON.stringify({ + ok: true, + command: "web-probe-sentinel-service", + node, + lane, + url: server.url, + schedulerEnabled, + health: service.health(), + valuesRedacted: true, +}, null, 2)); + +process.on("SIGTERM", () => { + server.stop(); + service.close(); + process.exit(0); +}); +process.on("SIGINT", () => { + server.stop(); + service.close(); + process.exit(0); +}); + +await new Promise(() => undefined); + +function optionValue(name: string): string | undefined { + const index = args.indexOf(name); + return index === -1 ? undefined : args[index + 1]; +} + +function requiredOption(name: string): string { + const value = optionValue(name); + if (value === undefined || value.length === 0) throw new Error(`${name} is required`); + return value; +}