fix: isolate D518 web sentinel runners

This commit is contained in:
Codex
2026-06-28 08:34:16 +00:00
parent df71f5f70c
commit 5a510fe419
10 changed files with 225 additions and 85 deletions
@@ -30,11 +30,11 @@ sentinel:
dockerSocketPath: /var/run/docker.sock
activeDeadlineSeconds: 900
ttlSecondsAfterFinished: 3600
gitopsPath: deploy/gitops/node/d518/web-probe-sentinel
gitopsPath: deploy/gitops/node/d518/web-probe-sentinel-dsflash
argo:
namespace: argocd
projectName: hwlab-d518
applicationName: hwlab-web-probe-sentinel
applicationName: hwlab-web-probe-sentinel-dsflash
repoURL: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git
targetRevision: v0.3-gitops
image:
@@ -31,18 +31,18 @@ sentinel:
dockerSocketPath: /var/run/docker.sock
activeDeadlineSeconds: 900
ttlSecondsAfterFinished: 3600
gitopsPath: deploy/gitops/node/d518/web-probe-sentinel
gitopsPath: deploy/gitops/node/d518/web-probe-sentinel-fake-echo
argo:
namespace: argocd
projectName: hwlab-d518
applicationName: hwlab-web-probe-sentinel
applicationName: hwlab-web-probe-sentinel-fake-echo
repoURL: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git
targetRevision: v0.3-gitops
image:
repository: 127.0.0.1:5000/hwlab/web-probe-sentinel
tagSource: source-commit
baseImageRef: config/hwlab-node-control-plane.yaml#targets[1].tekton.toolsImage.output
envRecipeRef: config/hwlab-web-probe-sentinel/runtime.d518-v03.yaml#sentinel.runtime
envRecipeRef: config/hwlab-web-probe-sentinel/runtime.fake-echo.d518-v03.yaml#sentinel.runtime
maintenance:
startCommand: sentinel maintenance start
stopCommand: sentinel maintenance stop
@@ -13,19 +13,19 @@ sentinel:
routePrefix: /sentinels/d518-workbench-dsflash-go-tool-call-10x
expectedA: 82.156.23.220
frpc:
deploymentName: hwlab-web-probe-sentinel-frpc
deploymentName: hwlab-web-probe-sentinel-dsflash-frpc
image: 127.0.0.1:5000/hwlab/frpc:v0.68.1
serverAddr: 82.156.23.220
serverPort: 22000
tokenSourceRef: platform-infra/pk01-frp.env
tokenSourceKey: FRP_TOKEN
secretName: hwlab-web-probe-sentinel-frpc
secretName: hwlab-web-probe-sentinel-dsflash-frpc
secretKey: frpc.toml
tokenKey: token
httpProxy:
name: hwlab-d518-v03-web-probe-sentinel
remotePort: 22093
localIP: hwlab-web-probe-sentinel.hwlab-v03.svc.cluster.local
name: hwlab-d518-v03-web-probe-sentinel-dsflash
remotePort: 22094
localIP: hwlab-web-probe-sentinel-dsflash.hwlab-v03.svc.cluster.local
localPort: 8080
caddy:
route: PK01
@@ -34,4 +34,4 @@ sentinel:
email: ops@pikapython.com
tls: auto
responseHeaderTimeoutSeconds: 600
managedBlockOwner: hwlab-web-probe-sentinel-d518-v03
managedBlockOwner: hwlab-web-probe-sentinel-d518-v03-dsflash
@@ -14,19 +14,19 @@ sentinel:
routePrefix: /sentinels/d518-workbench-fake-echo-session-invariance-10x
expectedA: 82.156.23.220
frpc:
deploymentName: hwlab-web-probe-sentinel-frpc
deploymentName: hwlab-web-probe-sentinel-fake-echo-frpc
image: 127.0.0.1:5000/hwlab/frpc:v0.68.1
serverAddr: 82.156.23.220
serverPort: 22000
tokenSourceRef: platform-infra/pk01-frp.env
tokenSourceKey: FRP_TOKEN
secretName: hwlab-web-probe-sentinel-frpc
secretName: hwlab-web-probe-sentinel-fake-echo-frpc
secretKey: frpc.toml
tokenKey: token
httpProxy:
name: hwlab-d518-v03-web-probe-sentinel
name: hwlab-d518-v03-web-probe-sentinel-fake-echo
remotePort: 22093
localIP: hwlab-web-probe-sentinel.hwlab-v03.svc.cluster.local
localIP: hwlab-web-probe-sentinel-fake-echo.hwlab-v03.svc.cluster.local
localPort: 8080
caddy:
route: PK01
@@ -35,4 +35,4 @@ sentinel:
email: ops@pikapython.com
tls: auto
responseHeaderTimeoutSeconds: 600
managedBlockOwner: hwlab-web-probe-sentinel-d518-v03
managedBlockOwner: hwlab-web-probe-sentinel-d518-v03-fake-echo
@@ -1,7 +1,7 @@
version: 1
kind: HwlabWebProbeSentinelRuntime
metadata:
id: d518-v03-web-probe-sentinel-runtime
id: d518-v03-web-probe-sentinel-dsflash-runtime
owner: UniDesk
specRef: PJ2026-01060508
sentinel:
@@ -10,16 +10,16 @@ sentinel:
node: D518
lane: v03
publicOriginRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D518.public.webUrl
observeWrapperRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D518.observability.webProbe.sentinels[0]
observeWrapperRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D518.observability.webProbe.sentinels.workbench-dsflash-go-tool-call-10x
namespace: hwlab-v03
serviceAccountName: hwlab-web-probe-sentinel
deploymentName: hwlab-web-probe-sentinel
serviceName: hwlab-web-probe-sentinel
serviceAccountName: hwlab-web-probe-sentinel-dsflash
deploymentName: hwlab-web-probe-sentinel-dsflash
serviceName: hwlab-web-probe-sentinel-dsflash
listenHost: 0.0.0.0
servicePort: 8080
pvcName: hwlab-web-probe-sentinel-state
pvcName: hwlab-web-probe-sentinel-dsflash-state
pvcStorage: 10Gi
stateRoot: /var/lib/web-probe-sentinel
stateRoot: /var/lib/web-probe-sentinel-dsflash
imageRef: 127.0.0.1:5000/hwlab/web-probe-sentinel:p3-service
replicas: 1
healthPath: /api/health
@@ -29,5 +29,5 @@ sentinel:
heartbeatStaleSeconds: 900
maxConcurrentRuns: 1
sqlite:
path: /var/lib/web-probe-sentinel/index.sqlite
path: /var/lib/web-probe-sentinel-dsflash/index.sqlite
busyTimeoutMs: 2000
@@ -0,0 +1,34 @@
version: 1
kind: HwlabWebProbeSentinelRuntime
metadata:
id: d518-v03-web-probe-sentinel-fake-echo-runtime
owner: UniDesk
specRef: PJ2026-01060508
issue: 1206
sentinel:
runtime:
target:
node: D518
lane: v03
publicOriginRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D518.public.webUrl
observeWrapperRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D518.observability.webProbe.sentinels.workbench-fake-echo-session-invariance-10x
namespace: hwlab-v03
serviceAccountName: hwlab-web-probe-sentinel-fake-echo
deploymentName: hwlab-web-probe-sentinel-fake-echo
serviceName: hwlab-web-probe-sentinel-fake-echo
listenHost: 0.0.0.0
servicePort: 8080
pvcName: hwlab-web-probe-sentinel-fake-echo-state
pvcStorage: 10Gi
stateRoot: /var/lib/web-probe-sentinel-fake-echo
imageRef: 127.0.0.1:5000/hwlab/web-probe-sentinel:p3-service
replicas: 1
healthPath: /api/health
metricsPath: /metrics
scheduler:
intervalMs: 600000
heartbeatStaleSeconds: 900
maxConcurrentRuns: 1
sqlite:
path: /var/lib/web-probe-sentinel-fake-echo/index.sqlite
busyTimeoutMs: 2000
@@ -10,7 +10,7 @@ sentinel:
enabled: true
mode: web-probe-observe-wrapper
configRefs:
runtime: config/hwlab-web-probe-sentinel/runtime.d518-v03.yaml#sentinel.runtime
runtime: config/hwlab-web-probe-sentinel/runtime.fake-echo.d518-v03.yaml#sentinel.runtime
scenarios: config/hwlab-web-probe-sentinel/scenarios.fake-echo.workbench.yaml#sentinel.scenarios
promptSet: config/hwlab-web-probe-sentinel/prompt-set.fake-echo.yaml#sentinel.promptSet
reportViews: config/hwlab-web-probe-sentinel/report-views.yaml#sentinel.reportViews
@@ -641,7 +641,7 @@ cadence freshness 必须成为 `monitor-web` 的一等状态。每个 sentinel
P12 runner-served-bridge 形态下,sentinel runner Pod 可以只承载 API、PVC/SQLite index、health、metrics 和 dashboard 静态资源;若 Pod 内没有完整 repo 配置、`trans`、Chromium 或 observe 依赖,不得让 Pod 自行 SSH/回调宿主机触发巡检。周期巡检必须由受控宿主控制面调度器读取同一 YAML registry、scenario/workflow cadence、publicExposure 和 targetValidation timeout,按 stale 窗口触发现有 `web-probe sentinel validate --quick-verify --confirm --wait` 路径。该调度器只负责 due 判断、互斥锁、timeout、命令执行和 JSONL 事件日志,不实现第二套采样、analyze、finding 分类或 report 写入。
宿主控制面调度器必须能被 systemd timer 或等价受控入口周期调用,默认 tick 间隔不得替代 YAML cadence;每次 tick 必须输出 sentinel id、cadence、latest run age、due、trigger status、latest run id 和下一步 drill-down。触发失败要区分业务 finding、命令 submit/control 失败、overview/API 不可达、lock-held 和 timeout;业务 finding 已产生新 run 时不得把 scheduler 本身标为 infra blocker。`monitor-web` 应继续把 stale run 作为非阻塞告警展示,但 run/report 持续不更新或 submit/control 失败必须能在面板和 CLI 中直接看到根因。
宿主控制面调度器必须由目标 node/lane 的 k3s CronJob/GitOps 或等价受控入口周期调用,默认 tick 间隔不得替代 YAML cadence;每次 tick 必须输出 sentinel id、cadence、latest run age、due、trigger status、latest run id 和下一步 drill-down。触发失败要区分业务 finding、命令 submit/control 失败、overview/API 不可达、lock-held 和 timeout;业务 finding 已产生新 run 时不得把 scheduler 本身标为 infra blocker。`monitor-web` 应继续把 stale run 作为非阻塞告警展示,但 run/report 持续不更新或 submit/control 失败必须能在面板和 CLI 中直接看到根因。
`monitor-web` 前端必须使用 Vue 3 + TypeScript + Vite,并与 HWLAB Cloud Web/Sub2API 运维图表的组件化方式对齐:typed API client、format composable、auto refresh composable、chart component、timeline component、run table、detail tabs、finding groups、loading/empty/error 状态和深链路由。图表库不是前置结论;可选 Chart.js、ECharts 或原生 SVG/canvas,但 SPEC/PR 必须说明包体、构建耗时、交互能力和维护成本取舍。
@@ -651,6 +651,24 @@ Vue `monitor-web` 构建必须利用 env reuse、CI tools/env image、依赖缓
发布成功判据不能只看 legacy `dashboard.js` 静态资源 200。Vue `monitor-web` closeout 必须记录 Vite 产物内容 hash、asset 200/字节数/SHA、HTML 引用一致性、`/health`、root 聚合页 browser render、至少一个 `/sentinels/<id>` 详情页 render、远程 PNG localPath/SHA、DOM 摘要、overflow 结果、Argo/runtime/source/GitOps/git mirror alignment 和 runner degraded 降级行为。所有触发、rollout、status、git mirror sync/flush 和截图验收必须走 UniDesk 受控 CLI。
### 6.13 OPS-SENTINEL-REQ-013 D518 多 runner 强边界与 OTel 根因收敛
| 编号 | 短名 | 主责模块 | 关联模块 |
| --- | --- | --- | --- |
| OPS-SENTINEL-REQ-013 | D518 多 runner 强边界 | PJ2026-0106050813 D518 多 runner 强边界 | 多实例与账号切换、Monitor Web 聚合、OTel、AgentRun、YAML运维 |
本阶段执行 issue 为 [#1206](https://github.com/pikasTech/unidesk/issues/1206),阶段子 issue 为 P0 [#1208](https://github.com/pikasTech/unidesk/issues/1208)、P1 [#1209](https://github.com/pikasTech/unidesk/issues/1209)、P2 [#1210](https://github.com/pikasTech/unidesk/issues/1210)、P3 [#1211](https://github.com/pikasTech/unidesk/issues/1211)、P4 [#1212](https://github.com/pikasTech/unidesk/issues/1212)、P5 [#1213](https://github.com/pikasTech/unidesk/issues/1213)、P6 [#1214](https://github.com/pikasTech/unidesk/issues/1214)、P7 [#1215](https://github.com/pikasTech/unidesk/issues/1215) 和 P8 [#1216](https://github.com/pikasTech/unidesk/issues/1216)。
D518/v03 不允许再以“多个 sentinel 配置入口共享一个 runner Deployment/Service/PVC/SQLite/FRP/Caddy owner”的形式上线。每个 sentinel 必须有独立 runtime configRef,至少独立声明 Deployment、Service、ServiceAccount、PVC、stateRoot、SQLite path、CronJob、FRP Deployment、FRP Secret、httpProxy name、remotePort、GitOps path 和 Argo Application。`observeWrapperRef` 不得使用 `sentinels[0]` 这类位置索引;必须由 selected sentinel id 解引用或用等价稳定 id 绑定。
runner API 的边界必须是硬约束。`/api/overview``/api/runs``/api/findings``/api/runs/:id``/api/report` 只能返回当前 runner `sentinelId`、node、lane 下的数据;SQLite schema 至少包含 `sentinelId/scenarioId/observerId/runId`,并对 runs/findings 建立 sentinel 维度索引。route sentinelId 与服务实例 sentinelId 不一致时,API 必须返回结构化 `sentinel-route-mismatch` blocker,不允许 fallback 到其他 sentinel 的 latest run,也不允许前端过滤后继续展示。
状态语义必须区分 latest selected run 与 historical trend。latest run 的 `blocked/failed/error/timeout` 可以驱动当前状态为 blocker;历史趋势里的 red/error 样本只能作为趋势或历史风险展示,不能把一个已选 latest run 误标为当前阻塞。timing budget 超过 YAML `targetValidation.maxSeconds` 但已采集到 durable completed business turn 时,默认是非阻塞 timing warning;只有 submit/control 失败、样本缺失、报告未生成或 Code Agent 多轮业务链路不可继续时才升级为 blocker。
dashboard verify/screenshot 必须断言 selected sentinel 的 route 和 API 返回一致:远程浏览器脚本必须检查 DOM bootstrap `data-sentinel-id``/api/overview.sentinelId``/api/runs.sentinelId`、runs rows 的 `sentinelId` 和 public URL routePrefix。任何 dsflash route 返回 fake-echo 数据、fake-echo route 返回空 body 或 root route 绑定单 runner 的情况都必须失败并给出有界证据。
OTel 根因契约必须与应用内 report/index 分工清楚。sentinel report/index 负责 latest、history、finding、artifact、timeline、availability 和 runner heartbeatOTel/Tempo 负责跨服务根因链路。D518 `diagnose-code-agent` 的 AgentRun namespace/lane 必须从 `config/hwlab-node-lanes.yaml` 解析到实际 `agentrun-v02`,不得硬编码 `agentrun-v01`。HWLAB cloud-api、AgentRun manager 和 AgentRun runner 的 span 必须能通过 trace context 串联;缺失任一段时标为 instrumentation blocker 或 instrumentation gap,不得静默跳过。
## 7. 过程控制
Web哨兵架构执行 issue 为 [#883](https://github.com/pikasTech/unidesk/issues/883)。阶段跟踪 issue 为 P0 [#885](https://github.com/pikasTech/unidesk/issues/885)、P1 [#886](https://github.com/pikasTech/unidesk/issues/886)、P2 [#887](https://github.com/pikasTech/unidesk/issues/887)、P3 [#888](https://github.com/pikasTech/unidesk/issues/888)、P4 [#889](https://github.com/pikasTech/unidesk/issues/889)、P5 [#890](https://github.com/pikasTech/unidesk/issues/890) 和 P6 [#891](https://github.com/pikasTech/unidesk/issues/891)。
@@ -671,4 +689,6 @@ P10 monitor-web 聚合执行 issue 为 [#1056](https://github.com/pikasTech/unid
P11 monitor-web 观察面板治理执行 issue 为 [#1112](https://github.com/pikasTech/unidesk/issues/1112)。P11 的第一阶段必须先完成本 SPEC 收敛;SPEC 未合并前不得推进 Vue `monitor-web` 实现、CI/CD、GitOps、publicExposure 或部署代码。P11 实现 PR closeout 必须回写:SPEC P11 引用、Vue monitor-web 源码和 CI/CD 文件头部追溯、趋势曲线和运行时间线截图、固定视口三栏 overflow 摘要、cadence freshness 状态、env reuse/buildServices 证据、git mirror pre-sync/post-flush 证据、PipelineRun/Argo/GitOps/source alignment、root 与至少一个 sentinel detail 远程截图 localPath/SHA,以及超过两分钟 CI/CD 耗时是否已先从 env reuse/git mirror 方向优化。
P12 cadence 调度和 monitor-web 交互修复执行 issue 为 [#1123](https://github.com/pikasTech/unidesk/issues/1123)。P12 closeout 必须回写:SPEC P12 引用、两个 10m cadence sentinel 的 stale 证据、宿主控制面调度器 due 判断和触发记录、auth sentinel Argo/source alignment、趋势曲线 hover 数值和时间截图/DOM 证据、三栏 sticky header 遮盖复测、远程 PNG localPath/SHA、systemd timer 状态、以及两个目标 sentinel 最新 run 已刷新到当前窗口的证据。
P12 cadence 调度和 monitor-web 交互修复执行 issue 为 [#1123](https://github.com/pikasTech/unidesk/issues/1123)。P12 closeout 必须回写:SPEC P12 引用、两个 10m cadence sentinel 的 stale 证据、k3s CronJob/GitOps 调度器 due 判断和触发记录、auth sentinel Argo/source alignment、趋势曲线 hover 数值和时间截图/DOM 证据、三栏 sticky header 遮盖复测、远程 PNG localPath/SHA、k3s CronJob 状态、以及两个目标 sentinel 最新 run 已刷新到当前窗口的证据。
P13 D518 多 runner 强边界与 OTel 根因收敛执行 issue 为 [#1206](https://github.com/pikasTech/unidesk/issues/1206)。P13 closeout 必须回写:SPEC P13 引用、[#1208](https://github.com/pikasTech/unidesk/issues/1208)-[#1216](https://github.com/pikasTech/unidesk/issues/1216) 阶段状态、D518 双 sentinel 独立 Deployment/Service/PVC/CronJob/GitOps/Argo/public route 证据、route/API sentinelId 强断言、report/index 不串线证据、dashboard verify/screenshot localPath/SHA、k3s CronJob 调度证据、latest selected run 与 historical trend 状态分层证据、以及 OTel AgentRun namespace/trace gap 是否已解除或拆入后续 issue。
+29 -1
View File
@@ -4,6 +4,7 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p10-monitor-web-aggregation.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-27-p11-monitor-web-observability-dashboard.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-27-p12-cadence-scheduler-monitor-web.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-28-p13-1206-multi-runner-boundaries.
// 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";
@@ -2067,6 +2068,8 @@ function probeSentinelDashboardBrowser(state: SentinelCicdState, options: Extrac
`export UNIDESK_SENTINEL_DASHBOARD_HEIGHT=${shellQuote(heightRaw ?? "900")}`,
`export UNIDESK_SENTINEL_DASHBOARD_TIMEOUT_MS=${shellQuote(String(options.timeoutMs))}`,
`export UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE=${shellQuote(options.fullPage ? "1" : "0")}`,
`export UNIDESK_SENTINEL_DASHBOARD_EXPECTED_SENTINEL_ID=${shellQuote(state.sentinelId)}`,
`export UNIDESK_SENTINEL_DASHBOARD_EXPECTED_ROUTE_PREFIX=${shellQuote(stringAtNullable(state.publicExposure, "routePrefix") ?? "/")}`,
`export UNIDESK_SENTINEL_DASHBOARD_PLAYWRIGHT_MODULE=${shellQuote(`${state.spec.workspace}/node_modules/playwright/index.mjs`)}`,
"export PLAYWRIGHT_BROWSERS_PATH=0",
"if command -v chromium >/dev/null 2>&1; then",
@@ -2149,6 +2152,8 @@ const height = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_HEIGHT || 900);
const timeout = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_TIMEOUT_MS || 30000);
const fullPage = process.env.UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE !== "0";
const executablePath = process.env.UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH || "";
const expectedSentinelId = process.env.UNIDESK_SENTINEL_DASHBOARD_EXPECTED_SENTINEL_ID || "";
const expectedRoutePrefix = process.env.UNIDESK_SENTINEL_DASHBOARD_EXPECTED_ROUTE_PREFIX || "/";
if (!url) throw new Error("missing dashboard URL");
@@ -2353,6 +2358,23 @@ const dom = await page.evaluate(async () => {
&& chartCounts.error === latestRunCounts.error
&& chartCounts.warning === latestRunCounts.warning
&& chartCounts.total === latestRunCounts.total;
const datasetSentinelId = root?.getAttribute("data-sentinel-id") || "";
const finalPath = new URL(window.location.href).pathname.replace(/\/+$/u, "") || "/";
const expectedPath = expectedRoutePrefix.replace(/\/+$/u, "") || "/";
const routePrefixMatches = expectedPath === "/" ? finalPath === "/" : finalPath === expectedPath || finalPath.startsWith(expectedPath + "/");
const sentinelBoundary = {
expectedSentinelId,
expectedRoutePrefix,
datasetSentinelId,
overviewSentinelId: overviewPayload?.sentinelId || null,
runsSentinelId: runsPayload?.sentinelId || null,
finalPath,
routePrefixMatches,
datasetMatches: expectedSentinelId ? datasetSentinelId === expectedSentinelId : true,
overviewMatches: expectedSentinelId ? overviewPayload?.sentinelId === expectedSentinelId : true,
runsPayloadMatches: expectedSentinelId ? runsPayload?.sentinelId === expectedSentinelId : true,
runRowsMatch: expectedSentinelId ? runs.every((run) => (run?.sentinelId || expectedSentinelId) === expectedSentinelId) : true,
};
const statusText = text(".status-strip");
const doc = document.documentElement;
const body = document.body;
@@ -2389,10 +2411,11 @@ const dom = await page.evaluate(async () => {
dataset: root ? {
node: root.getAttribute("data-node"),
lane: root.getAttribute("data-lane"),
sentinelId: root.getAttribute("data-sentinel-id"),
sentinelId: datasetSentinelId,
basePath: root.getAttribute("data-base-path"),
contractVersion: root.getAttribute("data-contract-version"),
} : {},
sentinelBoundary,
title: document.title,
finalUrl: window.location.href,
statusText: text(".topbar .pill"),
@@ -2486,6 +2509,11 @@ const ok = !navigationError
&& httpStatus < 300
&& dom.shell === true
&& dom.ready === true
&& dom.sentinelBoundary?.datasetMatches === true
&& dom.sentinelBoundary?.overviewMatches === true
&& dom.sentinelBoundary?.runsPayloadMatches === true
&& dom.sentinelBoundary?.runRowsMatch === true
&& dom.sentinelBoundary?.routePrefixMatches === true
&& dom.errorVisible !== true
&& dom.trendCurve === true
&& dom.chartCounts?.ok === true
+115 -57
View File
@@ -3,6 +3,7 @@
// 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.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-27-p11-monitor-web-observability-dashboard.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-28-p13-1206-multi-runner-boundaries.
// 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";
@@ -122,8 +123,8 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
const config = loadWebProbeSentinelServiceConfig(options.spec, options);
mkdirSync(config.stateRoot, { recursive: true });
const db = new Database(config.sqlitePath);
initializeIndex(db);
const restored = markInterruptedRuns(db, nowIso());
initializeIndex(db, config);
const restored = markInterruptedRuns(config, db, nowIso());
const schedulerEnabled = options.schedulerEnabled ?? true;
let schedulerTimer: ReturnType<typeof setInterval> | null = null;
let schedulerHeartbeatAt = nowIso();
@@ -178,14 +179,14 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
configReady: config.plan.ok,
scheduler: schedulerSummary(config, db),
maintenance: this.maintenance(),
runs: runCounts(db),
runs: runCounts(config, 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<string, unknown>[];
return db.query("SELECT id, sentinel_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 WHERE sentinel_id = ? AND node = ? AND lane = ? ORDER BY created_at DESC LIMIT ?")
.all(config.sentinelId, config.node, config.lane, limit) as Record<string, unknown>[];
},
overview() {
return dashboardOverview(config, db, this.health(), this.maintenance());
@@ -244,8 +245,8 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
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 }));
db.query("INSERT INTO runs (id, sentinel_id, scenario_id, node, lane, status, maintenance, created_at, updated_at, command_plan_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run(runId, config.sentinelId, 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 };
},
recordRun(input: Record<string, unknown>) {
@@ -291,6 +292,8 @@ export function startWebProbeSentinelHttpService(service: WebProbeSentinelServic
async function sentinelFetch(service: WebProbeSentinelService, request: Request): Promise<Response> {
const url = new URL(request.url);
const routeMismatch = sentinelRouteMismatch(service.config, url.pathname);
if (routeMismatch !== null) return jsonResponse(routeMismatch, 409);
const pathname = normalizedSentinelRequestPath(service, url.pathname);
if (request.method === "GET" && pathname === "/api/health") {
const health = service.health();
@@ -359,6 +362,28 @@ function normalizedSentinelRequestPath(service: WebProbeSentinelService, pathnam
return pathname;
}
function sentinelRouteMismatch(config: WebProbeSentinelServiceConfig, pathname: string): Record<string, unknown> | null {
const match = /^\/sentinels\/([^/]+)/u.exec(pathname);
if (match === null) return null;
const routeSegment = decodeURIComponent(match[1]);
const nodePrefix = `${config.node.toLowerCase()}-`;
const routeSentinelId = routeSegment.startsWith(nodePrefix) ? routeSegment.slice(nodePrefix.length) : routeSegment;
if (routeSentinelId === config.sentinelId) return null;
return {
ok: false,
error: "sentinel-route-mismatch",
code: "sentinel-route-mismatch",
blocker: true,
routeSegment,
routeSentinelId,
serviceSentinelId: config.sentinelId,
node: config.node,
lane: config.lane,
message: "route sentinelId does not match the selected sentinel runner; refusing cross-sentinel fallback",
valuesRedacted: true,
};
}
function publicExposurePath(publicExposure: Record<string, unknown>): string {
const publicBaseUrl = stringOrNull(publicExposure.publicBaseUrl);
if (publicBaseUrl === null) return "";
@@ -378,7 +403,7 @@ function normalizedDashboardAssetPath(pathname: string): string {
return pathname;
}
function initializeIndex(db: Database): void {
function initializeIndex(db: Database, config: WebProbeSentinelServiceConfig): void {
db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS metadata (
@@ -388,6 +413,7 @@ function initializeIndex(db: Database): void {
);
CREATE TABLE IF NOT EXISTS runs (
id TEXT PRIMARY KEY,
sentinel_id TEXT NOT NULL DEFAULT '',
scenario_id TEXT NOT NULL,
node TEXT NOT NULL,
lane TEXT NOT NULL,
@@ -405,6 +431,7 @@ function initializeIndex(db: Database): void {
);
CREATE TABLE IF NOT EXISTS findings (
run_id TEXT NOT NULL,
sentinel_id TEXT NOT NULL DEFAULT '',
finding_id TEXT NOT NULL,
severity TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 1,
@@ -412,11 +439,25 @@ function initializeIndex(db: Database): void {
report_json_sha256 TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_runs_sentinel_updated ON runs(sentinel_id, node, lane, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_runs_sentinel_scenario ON runs(sentinel_id, scenario_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_findings_sentinel_run ON findings(sentinel_id, run_id);
CREATE INDEX IF NOT EXISTS idx_findings_sentinel_code ON findings(sentinel_id, finding_id, created_at DESC);
`);
ensureColumn(db, "runs", "sentinel_id", "TEXT");
ensureColumn(db, "findings", "sentinel_id", "TEXT");
db.query("UPDATE runs SET sentinel_id = ? WHERE sentinel_id IS NULL OR sentinel_id = ''").run(config.sentinelId);
db.query("UPDATE findings SET sentinel_id = ? WHERE sentinel_id IS NULL OR sentinel_id = ''").run(config.sentinelId);
}
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);
function ensureColumn(db: Database, table: "runs" | "findings", column: string, definition: string): void {
const rows = db.query(`PRAGMA table_info(${table})`).all() as Record<string, unknown>[];
if (rows.some((row) => stringOrNull(row.name) === column)) return;
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
function markInterruptedRuns(config: WebProbeSentinelServiceConfig, db: Database, at: string): number {
const result = db.query("UPDATE runs SET status = 'interrupted', interrupted_at = ?, updated_at = ? WHERE sentinel_id = ? AND node = ? AND lane = ? AND status IN ('queued', 'running', 'analyzing')").run(at, at, config.sentinelId, config.node, config.lane);
return Number(result.changes ?? 0);
}
@@ -563,7 +604,7 @@ function schedulerSummary(config: WebProbeSentinelServiceConfig, db: Database):
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')"),
activeRuns: countWhere(config, db, "status IN ('queued', 'running', 'analyzing')"),
plannedRuns: planned.count,
oldestPlannedRunId: planned.oldestRunId,
oldestPlannedRunScenarioId: planned.oldestScenarioId,
@@ -578,7 +619,7 @@ function schedulerSummary(config: WebProbeSentinelServiceConfig, db: Database):
}
function renderMetrics(config: WebProbeSentinelServiceConfig, db: Database, health: Record<string, unknown>, maintenance: MaintenanceState): string {
const counts = runCounts(db);
const counts = runCounts(config, 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));
@@ -595,10 +636,10 @@ function renderMetrics(config: WebProbeSentinelServiceConfig, db: Database, heal
...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{${labels}} ${countWhere(db, "status IN ('queued', 'running', 'analyzing')")}`,
`web_probe_sentinel_active_runs{${labels}} ${countWhere(config, 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{${labels}} ${sumColumn(db, "runs", "finding_count")}`,
`web_probe_sentinel_recent_findings{${labels}} ${sumColumn(config, 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{${labels}} ${maintenance.active ? 1 : 0}`,
@@ -607,7 +648,7 @@ function renderMetrics(config: WebProbeSentinelServiceConfig, db: Database, heal
`web_probe_sentinel_scheduler_heartbeat_age_seconds{${labels}} ${heartbeatAge}`,
"# HELP web_probe_sentinel_planned_runs Planned runs waiting for host cadence execution.",
"# TYPE web_probe_sentinel_planned_runs gauge",
`web_probe_sentinel_planned_runs{${labels}} ${countWhere(db, "status = 'planned'")}`,
`web_probe_sentinel_planned_runs{${labels}} ${countWhere(config, db, "status = 'planned'")}`,
"# HELP web_probe_sentinel_oldest_planned_run_age_seconds Oldest planned run age, or -1 when no planned run exists.",
"# TYPE web_probe_sentinel_oldest_planned_run_age_seconds gauge",
`web_probe_sentinel_oldest_planned_run_age_seconds{${labels}} ${plannedRunBacklog(config, db).oldestAgeSeconds ?? -1}`,
@@ -624,10 +665,21 @@ function plannedRunBacklog(config: WebProbeSentinelServiceConfig, db: Database):
readonly staleAfterSeconds: number;
readonly stale: boolean;
} {
const activeWhere = "status = 'planned' AND NOT EXISTS (SELECT 1 FROM runs newer WHERE newer.scenario_id = runs.scenario_id AND newer.status <> 'planned' AND newer.created_at > runs.created_at)";
const count = countWhere(db, activeWhere);
const oldest = db.query(`SELECT id, scenario_id, created_at FROM runs WHERE ${activeWhere} ORDER BY created_at ASC LIMIT 1`)
.get() as Record<string, unknown> | null;
const activeWhere = [
"status = 'planned'",
"AND NOT EXISTS (",
"SELECT 1 FROM runs newer",
"WHERE newer.sentinel_id = runs.sentinel_id",
"AND newer.node = runs.node",
"AND newer.lane = runs.lane",
"AND newer.scenario_id = runs.scenario_id",
"AND newer.status <> 'planned'",
"AND newer.created_at > runs.created_at",
")",
].join(" ");
const count = countWhere(config, db, activeWhere);
const oldest = db.query(`SELECT id, scenario_id, created_at FROM runs WHERE sentinel_id = ? AND node = ? AND lane = ? AND ${activeWhere} ORDER BY created_at ASC LIMIT 1`)
.get(config.sentinelId, config.node, config.lane) as Record<string, unknown> | null;
const oldestCreatedAt = stringOrNull(oldest?.created_at);
const oldestAgeSeconds = oldestCreatedAt === null ? null : ageSeconds(oldestCreatedAt);
const staleAfterSeconds = Math.max(60, Math.round(config.schedulerIntervalMs / 1000));
@@ -665,8 +717,9 @@ function readMetadata(db: Database, key: string): Record<string, unknown> | null
}
}
function runCounts(db: Database): Record<string, number> {
const rows = db.query("SELECT status, COUNT(*) AS count FROM runs GROUP BY status").all() as { status: string; count: number }[];
function runCounts(config: WebProbeSentinelServiceConfig, db: Database): Record<string, number> {
const rows = db.query("SELECT status, COUNT(*) AS count FROM runs WHERE sentinel_id = ? AND node = ? AND lane = ? GROUP BY status")
.all(config.sentinelId, config.node, config.lane) as { status: string; count: number }[];
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
}
@@ -689,7 +742,7 @@ function dashboardOverview(config: WebProbeSentinelServiceConfig, db: Database,
scheduler: schedulerSummary(config, db),
maintenance,
latestRun,
runCounts: runCounts(db),
runCounts: runCounts(config, db),
severityCounts,
freshness: {
latestRunUpdatedAt: latestUpdatedAt,
@@ -719,7 +772,7 @@ function dashboardRunList(config: WebProbeSentinelServiceConfig, db: Database, u
const filters = dashboardRunFilters(url);
const page = dashboardPage(url, config);
const sort = dashboardRunSort(url);
const where = runWhereClause(filters);
const where = runWhereClause(config, filters);
const sql = `SELECT * FROM runs ${where.sql} ORDER BY ${sort.sql} LIMIT ? OFFSET ?`;
const rows = db.query(sql).all(...where.params, page.limit + 1, page.offset) as Record<string, unknown>[];
const visibleRows = rows.slice(0, page.limit);
@@ -750,7 +803,7 @@ function dashboardRunList(config: WebProbeSentinelServiceConfig, db: Database, u
}
function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database, runId: string): Record<string, unknown> {
const row = readRunRow(db, runId);
const row = readRunRow(config, db, runId);
if (row === null) return { ok: false, error: "run-not-found", runId, valuesRedacted: true };
const stored = readMetadata(db, `run.report.${runId}`) ?? {};
const findings = findingsForRun(config, db, runId, dashboardPageSize(config));
@@ -783,7 +836,7 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database,
function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database, url: URL): Record<string, unknown> {
const limit = dashboardPage(url, config).limit;
const filters = dashboardFindingFilters(url);
const where = findingWhereClause(filters);
const where = findingWhereClause(config, filters);
const queryLimit = Math.min(dashboardMaxPageSize(config), Math.max(limit + 1, limit * 4));
const rows = db.query(`
SELECT f.finding_id,
@@ -799,7 +852,7 @@ function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database,
`).all(...where.params, queryLimit) as Record<string, unknown>[];
const severityFilter = stringOrNull(filters.severity);
const items = rows.map((row) => {
const latestRun = latestRunForFinding(db, row);
const latestRun = latestRunForFinding(config, db, row);
const latestDetail = latestRun === null ? null : storedFindingDetailForRow(db, row, stringOrNull(latestRun.id));
return enrichFindingWithCheck(config, {
code: stringOrNull(row.finding_id),
@@ -847,7 +900,7 @@ function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database,
}
function dashboardRunViews(config: WebProbeSentinelServiceConfig, db: Database, runId: string, view: string | null, url: URL): Record<string, unknown> {
const row = readRunRow(db, runId);
const row = readRunRow(config, db, runId);
if (row === null) return { ok: false, error: "run-not-found", runId, valuesRedacted: true };
const requestedViews = view === null ? stringArrayAt(config.reportViews, "views") : [view];
const maxBytes = Math.min(numberParam(url, "maxBytes", DASHBOARD_MAX_TEXT_BYTES), 64_000);
@@ -940,7 +993,6 @@ function dashboardOverallStatus(health: Record<string, unknown>, latestRun: Reco
if (health.ok !== true) return "degraded";
const latestStatus = latestRun === null ? null : stringOrNull(latestRun.status);
if (latestStatus !== null && /blocked|failed|error|timeout/iu.test(latestStatus)) return "blocked";
if (severityRank(maxSeverityFromCounts(severityCounts)) >= 3) return "blocked";
if (Object.values(severityCounts).some((count) => count > 0)) return "warning";
return latestRun === null ? "idle" : "healthy";
}
@@ -970,9 +1022,9 @@ function dashboardFindingFilters(url: URL): Record<string, unknown> {
};
}
function runWhereClause(filters: Record<string, unknown>): { readonly sql: string; readonly params: readonly (string | number)[] } {
const clauses: string[] = [];
const params: (string | number)[] = [];
function runWhereClause(config: WebProbeSentinelServiceConfig, filters: Record<string, unknown>): { readonly sql: string; readonly params: readonly (string | number)[] } {
const clauses: string[] = ["sentinel_id = ?", "node = ?", "lane = ?"];
const params: (string | number)[] = [config.sentinelId, config.node, config.lane];
const status = stringOrNull(filters.status);
if (status !== null) {
clauses.push("status = ?");
@@ -1016,9 +1068,9 @@ function runWhereClause(filters: Record<string, unknown>): { readonly sql: strin
return { sql: clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`, params };
}
function findingWhereClause(filters: Record<string, unknown>): { readonly sql: string; readonly params: readonly (string | number)[] } {
const clauses: string[] = [];
const params: (string | number)[] = [];
function findingWhereClause(config: WebProbeSentinelServiceConfig, filters: Record<string, unknown>): { readonly sql: string; readonly params: readonly (string | number)[] } {
const clauses: string[] = ["r.sentinel_id = ?", "r.node = ?", "r.lane = ?", "f.sentinel_id = ?"];
const params: (string | number)[] = [config.sentinelId, config.node, config.lane, config.sentinelId];
const code = stringOrNull(filters.code);
if (code !== null) {
clauses.push("f.finding_id = ?");
@@ -1073,13 +1125,14 @@ function dashboardMaxPageSize(config: WebProbeSentinelServiceConfig): number {
return Math.max(1, Math.min(200, numberOr(config.reportViews.maxPageSize, 100)));
}
function readRunRow(db: Database, runId: string): Record<string, unknown> | null {
return db.query("SELECT * FROM runs WHERE id = ?").get(runId) as Record<string, unknown> | null;
function readRunRow(config: WebProbeSentinelServiceConfig, db: Database, runId: string): Record<string, unknown> | null {
return db.query("SELECT * FROM runs WHERE id = ? AND sentinel_id = ? AND node = ? AND lane = ?")
.get(runId, config.sentinelId, config.node, config.lane) as Record<string, unknown> | null;
}
function findingsForRun(config: WebProbeSentinelServiceConfig, db: Database, runId: string, limit: number): readonly Record<string, unknown>[] {
const rows = db.query("SELECT finding_id, severity, count, summary, report_json_sha256, created_at FROM findings WHERE run_id = ? ORDER BY created_at DESC LIMIT ?")
.all(runId, limit) as Record<string, unknown>[];
const rows = db.query("SELECT finding_id, severity, count, summary, report_json_sha256, created_at FROM findings WHERE run_id = ? AND sentinel_id = ? ORDER BY created_at DESC LIMIT ?")
.all(runId, config.sentinelId, limit) as Record<string, unknown>[];
return rows.map((row) => enrichFindingRowWithStoredDetail(config, db, runId, row));
}
@@ -1259,24 +1312,24 @@ function normalizeCheckLevel(value: string | null): string | null {
}
function globalSeverityCounts(config: WebProbeSentinelServiceConfig, db: Database): Record<string, number> {
const rows = db.query("SELECT finding_id, severity, count FROM findings").all() as Record<string, unknown>[];
const rows = db.query("SELECT finding_id, severity, count FROM findings WHERE sentinel_id = ?").all(config.sentinelId) as Record<string, unknown>[];
return checkLevelCounts(config, rows);
}
function severityCountsForRun(config: WebProbeSentinelServiceConfig, db: Database, runId: string): Record<string, number> {
const rows = db.query("SELECT finding_id, severity, count FROM findings WHERE run_id = ?").all(runId) as Record<string, unknown>[];
const rows = db.query("SELECT finding_id, severity, count FROM findings WHERE run_id = ? AND sentinel_id = ?").all(runId, config.sentinelId) as Record<string, unknown>[];
return checkLevelCounts(config, rows);
}
function latestRunForFinding(db: Database, row: Record<string, unknown>): Record<string, unknown> | null {
function latestRunForFinding(config: WebProbeSentinelServiceConfig, db: Database, row: Record<string, unknown>): Record<string, unknown> | null {
return db.query(`
SELECT r.*
FROM findings f
JOIN runs r ON r.id = f.run_id
WHERE f.finding_id = ? AND r.scenario_id = ?
WHERE f.finding_id = ? AND r.scenario_id = ? AND r.sentinel_id = ? AND r.node = ? AND r.lane = ? AND f.sentinel_id = ?
ORDER BY f.created_at DESC
LIMIT 1
`).get(stringOrNull(row.finding_id), stringOrNull(row.scenario_id)) as Record<string, unknown> | null;
`).get(stringOrNull(row.finding_id), stringOrNull(row.scenario_id), config.sentinelId, config.node, config.lane, config.sentinelId) as Record<string, unknown> | null;
}
function runTraceability(config: WebProbeSentinelServiceConfig, row: Record<string, unknown>): Record<string, unknown> {
@@ -1284,6 +1337,7 @@ function runTraceability(config: WebProbeSentinelServiceConfig, row: Record<stri
source: "sqlite-index+run-report-metadata",
node: stringOrNull(row.node) ?? config.node,
lane: stringOrNull(row.lane) ?? config.lane,
sentinelId: stringOrNull(row.sentinel_id) ?? config.sentinelId,
runId: stringOrNull(row.id),
observerId: stringOrNull(row.observer_id),
stateDir: stringOrNull(row.state_dir),
@@ -1385,13 +1439,15 @@ function severityRankSql(expression: string): string {
return `CASE LOWER(COALESCE(${expression}, '')) WHEN 'critical' THEN 4 WHEN 'red' THEN 4 WHEN 'fatal' THEN 4 WHEN 'error' THEN 3 WHEN 'failed' THEN 3 WHEN 'blocked' THEN 3 WHEN 'warning' THEN 2 WHEN 'warn' THEN 2 WHEN 'amber' THEN 2 WHEN 'yellow' THEN 2 WHEN 'info' THEN 1 WHEN 'notice' THEN 1 ELSE 0 END`;
}
function countWhere(db: Database, where: string): number {
const row = db.query(`SELECT COUNT(*) AS count FROM runs WHERE ${where}`).get() as { count?: number } | null;
function countWhere(config: WebProbeSentinelServiceConfig, db: Database, where: string): number {
const row = db.query(`SELECT COUNT(*) AS count FROM runs WHERE sentinel_id = ? AND node = ? AND lane = ? AND ${where}`)
.get(config.sentinelId, config.node, config.lane) 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;
function sumColumn(config: WebProbeSentinelServiceConfig, db: Database, table: "runs", column: "finding_count"): number {
const row = db.query(`SELECT COALESCE(SUM(${column}), 0) AS total FROM ${table} WHERE sentinel_id = ? AND node = ? AND lane = ?`)
.get(config.sentinelId, config.node, config.lane) as { total?: number } | null;
return Number(row?.total ?? 0);
}
@@ -1428,9 +1484,10 @@ function recordRunResult(config: WebProbeSentinelServiceConfig, db: Database, in
const artifactCount = numberOr(input.artifactCount, 0);
const createdAt = stringOrNull(input.createdAt) ?? now;
db.query(`
INSERT INTO runs (id, scenario_id, node, lane, status, observer_id, state_dir, report_json_sha256, finding_count, artifact_count, maintenance, created_at, updated_at, command_plan_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO runs (id, sentinel_id, scenario_id, node, lane, status, observer_id, state_dir, report_json_sha256, finding_count, artifact_count, maintenance, created_at, updated_at, command_plan_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
sentinel_id = excluded.sentinel_id,
status = excluded.status,
observer_id = excluded.observer_id,
state_dir = excluded.state_dir,
@@ -1438,20 +1495,21 @@ function recordRunResult(config: WebProbeSentinelServiceConfig, db: Database, in
finding_count = excluded.finding_count,
artifact_count = excluded.artifact_count,
updated_at = excluded.updated_at
`).run(runId, scenarioId, config.node, config.lane, status, observerId, stateDir, reportJsonSha256, findingCount, artifactCount, thisMaintenanceFlag(input), createdAt, now, JSON.stringify({ source: "recorded-analyze-summary", valuesRedacted: true }));
const supersededPlannedRuns = db.query("UPDATE runs SET status = 'superseded', updated_at = ? WHERE status = 'planned' AND scenario_id = ? AND created_at < ?")
.run(now, scenarioId, createdAt);
db.query("DELETE FROM findings WHERE run_id = ?").run(runId);
`).run(runId, config.sentinelId, scenarioId, config.node, config.lane, status, observerId, stateDir, reportJsonSha256, findingCount, artifactCount, thisMaintenanceFlag(input), createdAt, now, JSON.stringify({ source: "recorded-analyze-summary", valuesRedacted: true }));
const supersededPlannedRuns = db.query("UPDATE runs SET status = 'superseded', updated_at = ? WHERE sentinel_id = ? AND node = ? AND lane = ? AND status = 'planned' AND scenario_id = ? AND created_at < ?")
.run(now, config.sentinelId, config.node, config.lane, scenarioId, createdAt);
db.query("DELETE FROM findings WHERE run_id = ? AND sentinel_id = ?").run(runId, config.sentinelId);
for (const item of findings) {
const findingId = stringOrNull(item.id) ?? stringOrNull(item.kind) ?? stringOrNull(item.code) ?? "finding";
const check = checkForFinding(config, { ...item, id: findingId });
const severity = stringOrNull(check.level) ?? normalizeCheckLevel(stringOrNull(item.severity) ?? stringOrNull(item.level)) ?? "unknown";
const summary = stringOrNull(item.summary) ?? stringOrNull(item.message) ?? findingId;
db.query("INSERT INTO findings (run_id, finding_id, severity, count, summary, report_json_sha256, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
.run(runId, findingId.slice(0, 160), severity.slice(0, 40), numberOr(item.count, 1), summary.slice(0, 500), reportJsonSha256, now);
db.query("INSERT INTO findings (run_id, sentinel_id, finding_id, severity, count, summary, report_json_sha256, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
.run(runId, config.sentinelId, findingId.slice(0, 160), severity.slice(0, 40), numberOr(item.count, 1), summary.slice(0, 500), reportJsonSha256, now);
}
writeMetadata(db, `run.report.${runId}`, {
runId,
sentinelId: config.sentinelId,
scenarioId,
observerId,
stateDir,
@@ -1473,8 +1531,8 @@ function reportRunView(config: WebProbeSentinelServiceConfig, db: Database, view
return { ok: false, error: "unsupported-report-view", view, valuesRedacted: true };
}
const row = runId === null
? db.query("SELECT * FROM runs WHERE report_json_sha256 IS NOT NULL ORDER BY updated_at DESC LIMIT 1").get() as Record<string, unknown> | null
: db.query("SELECT * FROM runs WHERE id = ?").get(runId) as Record<string, unknown> | null;
? db.query("SELECT * FROM runs WHERE report_json_sha256 IS NOT NULL AND sentinel_id = ? AND node = ? AND lane = ? ORDER BY updated_at DESC LIMIT 1").get(config.sentinelId, config.node, config.lane) as Record<string, unknown> | null
: readRunRow(config, db, runId);
if (row === null) return { ok: false, error: "report-run-missing", runId, view, valuesRedacted: true };
const selectedRunId = stringOrNull(row.id);
if (selectedRunId === null) return { ok: false, error: "report-run-id-missing", view, valuesRedacted: true };