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 dockerSocketPath: /var/run/docker.sock
activeDeadlineSeconds: 900 activeDeadlineSeconds: 900
ttlSecondsAfterFinished: 3600 ttlSecondsAfterFinished: 3600
gitopsPath: deploy/gitops/node/d518/web-probe-sentinel gitopsPath: deploy/gitops/node/d518/web-probe-sentinel-dsflash
argo: argo:
namespace: argocd namespace: argocd
projectName: hwlab-d518 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 repoURL: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git
targetRevision: v0.3-gitops targetRevision: v0.3-gitops
image: image:
@@ -31,18 +31,18 @@ sentinel:
dockerSocketPath: /var/run/docker.sock dockerSocketPath: /var/run/docker.sock
activeDeadlineSeconds: 900 activeDeadlineSeconds: 900
ttlSecondsAfterFinished: 3600 ttlSecondsAfterFinished: 3600
gitopsPath: deploy/gitops/node/d518/web-probe-sentinel gitopsPath: deploy/gitops/node/d518/web-probe-sentinel-fake-echo
argo: argo:
namespace: argocd namespace: argocd
projectName: hwlab-d518 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 repoURL: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git
targetRevision: v0.3-gitops targetRevision: v0.3-gitops
image: image:
repository: 127.0.0.1:5000/hwlab/web-probe-sentinel repository: 127.0.0.1:5000/hwlab/web-probe-sentinel
tagSource: source-commit tagSource: source-commit
baseImageRef: config/hwlab-node-control-plane.yaml#targets[1].tekton.toolsImage.output 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: maintenance:
startCommand: sentinel maintenance start startCommand: sentinel maintenance start
stopCommand: sentinel maintenance stop stopCommand: sentinel maintenance stop
@@ -13,19 +13,19 @@ sentinel:
routePrefix: /sentinels/d518-workbench-dsflash-go-tool-call-10x routePrefix: /sentinels/d518-workbench-dsflash-go-tool-call-10x
expectedA: 82.156.23.220 expectedA: 82.156.23.220
frpc: 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 image: 127.0.0.1:5000/hwlab/frpc:v0.68.1
serverAddr: 82.156.23.220 serverAddr: 82.156.23.220
serverPort: 22000 serverPort: 22000
tokenSourceRef: platform-infra/pk01-frp.env tokenSourceRef: platform-infra/pk01-frp.env
tokenSourceKey: FRP_TOKEN tokenSourceKey: FRP_TOKEN
secretName: hwlab-web-probe-sentinel-frpc secretName: hwlab-web-probe-sentinel-dsflash-frpc
secretKey: frpc.toml secretKey: frpc.toml
tokenKey: token tokenKey: token
httpProxy: httpProxy:
name: hwlab-d518-v03-web-probe-sentinel name: hwlab-d518-v03-web-probe-sentinel-dsflash
remotePort: 22093 remotePort: 22094
localIP: hwlab-web-probe-sentinel.hwlab-v03.svc.cluster.local localIP: hwlab-web-probe-sentinel-dsflash.hwlab-v03.svc.cluster.local
localPort: 8080 localPort: 8080
caddy: caddy:
route: PK01 route: PK01
@@ -34,4 +34,4 @@ sentinel:
email: ops@pikapython.com email: ops@pikapython.com
tls: auto tls: auto
responseHeaderTimeoutSeconds: 600 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 routePrefix: /sentinels/d518-workbench-fake-echo-session-invariance-10x
expectedA: 82.156.23.220 expectedA: 82.156.23.220
frpc: 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 image: 127.0.0.1:5000/hwlab/frpc:v0.68.1
serverAddr: 82.156.23.220 serverAddr: 82.156.23.220
serverPort: 22000 serverPort: 22000
tokenSourceRef: platform-infra/pk01-frp.env tokenSourceRef: platform-infra/pk01-frp.env
tokenSourceKey: FRP_TOKEN tokenSourceKey: FRP_TOKEN
secretName: hwlab-web-probe-sentinel-frpc secretName: hwlab-web-probe-sentinel-fake-echo-frpc
secretKey: frpc.toml secretKey: frpc.toml
tokenKey: token tokenKey: token
httpProxy: httpProxy:
name: hwlab-d518-v03-web-probe-sentinel name: hwlab-d518-v03-web-probe-sentinel-fake-echo
remotePort: 22093 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 localPort: 8080
caddy: caddy:
route: PK01 route: PK01
@@ -35,4 +35,4 @@ sentinel:
email: ops@pikapython.com email: ops@pikapython.com
tls: auto tls: auto
responseHeaderTimeoutSeconds: 600 responseHeaderTimeoutSeconds: 600
managedBlockOwner: hwlab-web-probe-sentinel-d518-v03 managedBlockOwner: hwlab-web-probe-sentinel-d518-v03-fake-echo
@@ -1,7 +1,7 @@
version: 1 version: 1
kind: HwlabWebProbeSentinelRuntime kind: HwlabWebProbeSentinelRuntime
metadata: metadata:
id: d518-v03-web-probe-sentinel-runtime id: d518-v03-web-probe-sentinel-dsflash-runtime
owner: UniDesk owner: UniDesk
specRef: PJ2026-01060508 specRef: PJ2026-01060508
sentinel: sentinel:
@@ -10,16 +10,16 @@ sentinel:
node: D518 node: D518
lane: v03 lane: v03
publicOriginRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D518.public.webUrl 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 namespace: hwlab-v03
serviceAccountName: hwlab-web-probe-sentinel serviceAccountName: hwlab-web-probe-sentinel-dsflash
deploymentName: hwlab-web-probe-sentinel deploymentName: hwlab-web-probe-sentinel-dsflash
serviceName: hwlab-web-probe-sentinel serviceName: hwlab-web-probe-sentinel-dsflash
listenHost: 0.0.0.0 listenHost: 0.0.0.0
servicePort: 8080 servicePort: 8080
pvcName: hwlab-web-probe-sentinel-state pvcName: hwlab-web-probe-sentinel-dsflash-state
pvcStorage: 10Gi 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 imageRef: 127.0.0.1:5000/hwlab/web-probe-sentinel:p3-service
replicas: 1 replicas: 1
healthPath: /api/health healthPath: /api/health
@@ -29,5 +29,5 @@ sentinel:
heartbeatStaleSeconds: 900 heartbeatStaleSeconds: 900
maxConcurrentRuns: 1 maxConcurrentRuns: 1
sqlite: sqlite:
path: /var/lib/web-probe-sentinel/index.sqlite path: /var/lib/web-probe-sentinel-dsflash/index.sqlite
busyTimeoutMs: 2000 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 enabled: true
mode: web-probe-observe-wrapper mode: web-probe-observe-wrapper
configRefs: 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 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 promptSet: config/hwlab-web-probe-sentinel/prompt-set.fake-echo.yaml#sentinel.promptSet
reportViews: config/hwlab-web-probe-sentinel/report-views.yaml#sentinel.reportViews 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 写入。 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 必须说明包体、构建耗时、交互能力和维护成本取舍。 `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。 发布成功判据不能只看 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. 过程控制 ## 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)。 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 方向优化。 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-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-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-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. // Responsibility: YAML-first CI/CD, image, GitOps and Argo command plan for the web-probe sentinel.
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs"; 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_HEIGHT=${shellQuote(heightRaw ?? "900")}`,
`export UNIDESK_SENTINEL_DASHBOARD_TIMEOUT_MS=${shellQuote(String(options.timeoutMs))}`, `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_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 UNIDESK_SENTINEL_DASHBOARD_PLAYWRIGHT_MODULE=${shellQuote(`${state.spec.workspace}/node_modules/playwright/index.mjs`)}`,
"export PLAYWRIGHT_BROWSERS_PATH=0", "export PLAYWRIGHT_BROWSERS_PATH=0",
"if command -v chromium >/dev/null 2>&1; then", "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 timeout = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_TIMEOUT_MS || 30000);
const fullPage = process.env.UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE !== "0"; const fullPage = process.env.UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE !== "0";
const executablePath = process.env.UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH || ""; 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"); if (!url) throw new Error("missing dashboard URL");
@@ -2353,6 +2358,23 @@ const dom = await page.evaluate(async () => {
&& chartCounts.error === latestRunCounts.error && chartCounts.error === latestRunCounts.error
&& chartCounts.warning === latestRunCounts.warning && chartCounts.warning === latestRunCounts.warning
&& chartCounts.total === latestRunCounts.total; && 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 statusText = text(".status-strip");
const doc = document.documentElement; const doc = document.documentElement;
const body = document.body; const body = document.body;
@@ -2389,10 +2411,11 @@ const dom = await page.evaluate(async () => {
dataset: root ? { dataset: root ? {
node: root.getAttribute("data-node"), node: root.getAttribute("data-node"),
lane: root.getAttribute("data-lane"), lane: root.getAttribute("data-lane"),
sentinelId: root.getAttribute("data-sentinel-id"), sentinelId: datasetSentinelId,
basePath: root.getAttribute("data-base-path"), basePath: root.getAttribute("data-base-path"),
contractVersion: root.getAttribute("data-contract-version"), contractVersion: root.getAttribute("data-contract-version"),
} : {}, } : {},
sentinelBoundary,
title: document.title, title: document.title,
finalUrl: window.location.href, finalUrl: window.location.href,
statusText: text(".topbar .pill"), statusText: text(".topbar .pill"),
@@ -2486,6 +2509,11 @@ const ok = !navigationError
&& httpStatus < 300 && httpStatus < 300
&& dom.shell === true && dom.shell === true
&& dom.ready === 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.errorVisible !== true
&& dom.trendCurve === true && dom.trendCurve === true
&& dom.chartCounts?.ok === 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-desktop-view-density.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel. // 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-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. // Responsibility: Persistent HTTP wrapper service for web-probe observe scheduling, index, health, metrics, maintenance, and dashboard.
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
@@ -122,8 +123,8 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
const config = loadWebProbeSentinelServiceConfig(options.spec, options); const config = loadWebProbeSentinelServiceConfig(options.spec, options);
mkdirSync(config.stateRoot, { recursive: true }); mkdirSync(config.stateRoot, { recursive: true });
const db = new Database(config.sqlitePath); const db = new Database(config.sqlitePath);
initializeIndex(db); initializeIndex(db, config);
const restored = markInterruptedRuns(db, nowIso()); const restored = markInterruptedRuns(config, db, nowIso());
const schedulerEnabled = options.schedulerEnabled ?? true; const schedulerEnabled = options.schedulerEnabled ?? true;
let schedulerTimer: ReturnType<typeof setInterval> | null = null; let schedulerTimer: ReturnType<typeof setInterval> | null = null;
let schedulerHeartbeatAt = nowIso(); let schedulerHeartbeatAt = nowIso();
@@ -178,14 +179,14 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
configReady: config.plan.ok, configReady: config.plan.ok,
scheduler: schedulerSummary(config, db), scheduler: schedulerSummary(config, db),
maintenance: this.maintenance(), maintenance: this.maintenance(),
runs: runCounts(db), runs: runCounts(config, db),
latestRuns: this.runs(8), latestRuns: this.runs(8),
valuesRedacted: true, valuesRedacted: true,
}; };
}, },
runs(limit = 20) { 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 ?") 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(limit) as Record<string, unknown>[]; .all(config.sentinelId, config.node, config.lane, limit) as Record<string, unknown>[];
}, },
overview() { overview() {
return dashboardOverview(config, db, this.health(), this.maintenance()); 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 runId = `sentinel-run-${Date.now()}-${randomUUID().slice(0, 8)}`;
const commandPlan = buildObserveCommandPlan(config, scenario); const commandPlan = buildObserveCommandPlan(config, scenario);
const createdAt = nowIso(); const createdAt = nowIso();
db.query("INSERT INTO runs (id, scenario_id, node, lane, status, maintenance, created_at, updated_at, command_plan_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") db.query("INSERT INTO runs (id, sentinel_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 })); .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 }; return { ok: true, runId, scenarioId, status: "planned", commandPlanSha256: sha256Json(commandPlan), valuesRedacted: true };
}, },
recordRun(input: Record<string, unknown>) { recordRun(input: Record<string, unknown>) {
@@ -291,6 +292,8 @@ export function startWebProbeSentinelHttpService(service: WebProbeSentinelServic
async function sentinelFetch(service: WebProbeSentinelService, request: Request): Promise<Response> { async function sentinelFetch(service: WebProbeSentinelService, request: Request): Promise<Response> {
const url = new URL(request.url); 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); const pathname = normalizedSentinelRequestPath(service, url.pathname);
if (request.method === "GET" && pathname === "/api/health") { if (request.method === "GET" && pathname === "/api/health") {
const health = service.health(); const health = service.health();
@@ -359,6 +362,28 @@ function normalizedSentinelRequestPath(service: WebProbeSentinelService, pathnam
return pathname; 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 { function publicExposurePath(publicExposure: Record<string, unknown>): string {
const publicBaseUrl = stringOrNull(publicExposure.publicBaseUrl); const publicBaseUrl = stringOrNull(publicExposure.publicBaseUrl);
if (publicBaseUrl === null) return ""; if (publicBaseUrl === null) return "";
@@ -378,7 +403,7 @@ function normalizedDashboardAssetPath(pathname: string): string {
return pathname; return pathname;
} }
function initializeIndex(db: Database): void { function initializeIndex(db: Database, config: WebProbeSentinelServiceConfig): void {
db.exec(` db.exec(`
PRAGMA journal_mode = WAL; PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS metadata ( CREATE TABLE IF NOT EXISTS metadata (
@@ -388,6 +413,7 @@ function initializeIndex(db: Database): void {
); );
CREATE TABLE IF NOT EXISTS runs ( CREATE TABLE IF NOT EXISTS runs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
sentinel_id TEXT NOT NULL DEFAULT '',
scenario_id TEXT NOT NULL, scenario_id TEXT NOT NULL,
node TEXT NOT NULL, node TEXT NOT NULL,
lane TEXT NOT NULL, lane TEXT NOT NULL,
@@ -405,6 +431,7 @@ function initializeIndex(db: Database): void {
); );
CREATE TABLE IF NOT EXISTS findings ( CREATE TABLE IF NOT EXISTS findings (
run_id TEXT NOT NULL, run_id TEXT NOT NULL,
sentinel_id TEXT NOT NULL DEFAULT '',
finding_id TEXT NOT NULL, finding_id TEXT NOT NULL,
severity TEXT NOT NULL, severity TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 1, count INTEGER NOT NULL DEFAULT 1,
@@ -412,11 +439,25 @@ function initializeIndex(db: Database): void {
report_json_sha256 TEXT, report_json_sha256 TEXT,
created_at TEXT NOT NULL 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 { function ensureColumn(db: Database, table: "runs" | "findings", column: string, definition: string): void {
const result = db.query("UPDATE runs SET status = 'interrupted', interrupted_at = ?, updated_at = ? WHERE status IN ('queued', 'running', 'analyzing')").run(at, at); 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); 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")), enabledScenarios: config.scenarios.filter((item) => boolAt(item, "enabled")).map((item) => stringAt(item, "id")),
intervalMs: config.schedulerIntervalMs, intervalMs: config.schedulerIntervalMs,
maxConcurrentRuns: config.maxConcurrentRuns, maxConcurrentRuns: config.maxConcurrentRuns,
activeRuns: countWhere(db, "status IN ('queued', 'running', 'analyzing')"), activeRuns: countWhere(config, db, "status IN ('queued', 'running', 'analyzing')"),
plannedRuns: planned.count, plannedRuns: planned.count,
oldestPlannedRunId: planned.oldestRunId, oldestPlannedRunId: planned.oldestRunId,
oldestPlannedRunScenarioId: planned.oldestScenarioId, 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 { 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 heartbeat = record(readMetadata(db, "scheduler.heartbeat"));
const heartbeatAt = stringOrNull(heartbeat.at); const heartbeatAt = stringOrNull(heartbeat.at);
const heartbeatAge = heartbeatAt === null ? -1 : Math.max(0, Math.round((Date.now() - Date.parse(heartbeatAt)) / 1000)); 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}`), ...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.", "# HELP web_probe_sentinel_active_runs Active observe runs known to the sentinel index.",
"# TYPE web_probe_sentinel_active_runs gauge", "# 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.", "# HELP web_probe_sentinel_recent_findings Findings indexed from recent reports.",
"# TYPE web_probe_sentinel_recent_findings gauge", "# 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.", "# HELP web_probe_sentinel_maintenance_active Maintenance window active flag.",
"# TYPE web_probe_sentinel_maintenance_active gauge", "# TYPE web_probe_sentinel_maintenance_active gauge",
`web_probe_sentinel_maintenance_active{${labels}} ${maintenance.active ? 1 : 0}`, `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}`, `web_probe_sentinel_scheduler_heartbeat_age_seconds{${labels}} ${heartbeatAge}`,
"# HELP web_probe_sentinel_planned_runs Planned runs waiting for host cadence execution.", "# HELP web_probe_sentinel_planned_runs Planned runs waiting for host cadence execution.",
"# TYPE web_probe_sentinel_planned_runs gauge", "# 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.", "# 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", "# TYPE web_probe_sentinel_oldest_planned_run_age_seconds gauge",
`web_probe_sentinel_oldest_planned_run_age_seconds{${labels}} ${plannedRunBacklog(config, db).oldestAgeSeconds ?? -1}`, `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 staleAfterSeconds: number;
readonly stale: boolean; 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 activeWhere = [
const count = countWhere(db, activeWhere); "status = 'planned'",
const oldest = db.query(`SELECT id, scenario_id, created_at FROM runs WHERE ${activeWhere} ORDER BY created_at ASC LIMIT 1`) "AND NOT EXISTS (",
.get() as Record<string, unknown> | null; "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 oldestCreatedAt = stringOrNull(oldest?.created_at);
const oldestAgeSeconds = oldestCreatedAt === null ? null : ageSeconds(oldestCreatedAt); const oldestAgeSeconds = oldestCreatedAt === null ? null : ageSeconds(oldestCreatedAt);
const staleAfterSeconds = Math.max(60, Math.round(config.schedulerIntervalMs / 1000)); 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> { function runCounts(config: WebProbeSentinelServiceConfig, 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 }[]; 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)])); 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), scheduler: schedulerSummary(config, db),
maintenance, maintenance,
latestRun, latestRun,
runCounts: runCounts(db), runCounts: runCounts(config, db),
severityCounts, severityCounts,
freshness: { freshness: {
latestRunUpdatedAt: latestUpdatedAt, latestRunUpdatedAt: latestUpdatedAt,
@@ -719,7 +772,7 @@ function dashboardRunList(config: WebProbeSentinelServiceConfig, db: Database, u
const filters = dashboardRunFilters(url); const filters = dashboardRunFilters(url);
const page = dashboardPage(url, config); const page = dashboardPage(url, config);
const sort = dashboardRunSort(url); 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 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 rows = db.query(sql).all(...where.params, page.limit + 1, page.offset) as Record<string, unknown>[];
const visibleRows = rows.slice(0, page.limit); 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> { 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 }; if (row === null) return { ok: false, error: "run-not-found", runId, valuesRedacted: true };
const stored = readMetadata(db, `run.report.${runId}`) ?? {}; const stored = readMetadata(db, `run.report.${runId}`) ?? {};
const findings = findingsForRun(config, db, runId, dashboardPageSize(config)); 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> { function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database, url: URL): Record<string, unknown> {
const limit = dashboardPage(url, config).limit; const limit = dashboardPage(url, config).limit;
const filters = dashboardFindingFilters(url); 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 queryLimit = Math.min(dashboardMaxPageSize(config), Math.max(limit + 1, limit * 4));
const rows = db.query(` const rows = db.query(`
SELECT f.finding_id, SELECT f.finding_id,
@@ -799,7 +852,7 @@ function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database,
`).all(...where.params, queryLimit) as Record<string, unknown>[]; `).all(...where.params, queryLimit) as Record<string, unknown>[];
const severityFilter = stringOrNull(filters.severity); const severityFilter = stringOrNull(filters.severity);
const items = rows.map((row) => { 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)); const latestDetail = latestRun === null ? null : storedFindingDetailForRow(db, row, stringOrNull(latestRun.id));
return enrichFindingWithCheck(config, { return enrichFindingWithCheck(config, {
code: stringOrNull(row.finding_id), 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> { 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 }; if (row === null) return { ok: false, error: "run-not-found", runId, valuesRedacted: true };
const requestedViews = view === null ? stringArrayAt(config.reportViews, "views") : [view]; const requestedViews = view === null ? stringArrayAt(config.reportViews, "views") : [view];
const maxBytes = Math.min(numberParam(url, "maxBytes", DASHBOARD_MAX_TEXT_BYTES), 64_000); 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"; if (health.ok !== true) return "degraded";
const latestStatus = latestRun === null ? null : stringOrNull(latestRun.status); const latestStatus = latestRun === null ? null : stringOrNull(latestRun.status);
if (latestStatus !== null && /blocked|failed|error|timeout/iu.test(latestStatus)) return "blocked"; 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"; if (Object.values(severityCounts).some((count) => count > 0)) return "warning";
return latestRun === null ? "idle" : "healthy"; 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)[] } { function runWhereClause(config: WebProbeSentinelServiceConfig, filters: Record<string, unknown>): { readonly sql: string; readonly params: readonly (string | number)[] } {
const clauses: string[] = []; const clauses: string[] = ["sentinel_id = ?", "node = ?", "lane = ?"];
const params: (string | number)[] = []; const params: (string | number)[] = [config.sentinelId, config.node, config.lane];
const status = stringOrNull(filters.status); const status = stringOrNull(filters.status);
if (status !== null) { if (status !== null) {
clauses.push("status = ?"); 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 }; return { sql: clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`, params };
} }
function findingWhereClause(filters: Record<string, unknown>): { readonly sql: string; readonly params: readonly (string | number)[] } { function findingWhereClause(config: WebProbeSentinelServiceConfig, filters: Record<string, unknown>): { readonly sql: string; readonly params: readonly (string | number)[] } {
const clauses: string[] = []; const clauses: string[] = ["r.sentinel_id = ?", "r.node = ?", "r.lane = ?", "f.sentinel_id = ?"];
const params: (string | number)[] = []; const params: (string | number)[] = [config.sentinelId, config.node, config.lane, config.sentinelId];
const code = stringOrNull(filters.code); const code = stringOrNull(filters.code);
if (code !== null) { if (code !== null) {
clauses.push("f.finding_id = ?"); 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))); return Math.max(1, Math.min(200, numberOr(config.reportViews.maxPageSize, 100)));
} }
function readRunRow(db: Database, runId: string): Record<string, unknown> | null { function readRunRow(config: WebProbeSentinelServiceConfig, db: Database, runId: string): Record<string, unknown> | null {
return db.query("SELECT * FROM runs WHERE id = ?").get(runId) as 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>[] { 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 ?") 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, limit) as Record<string, unknown>[]; .all(runId, config.sentinelId, limit) as Record<string, unknown>[];
return rows.map((row) => enrichFindingRowWithStoredDetail(config, db, runId, row)); 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> { 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); return checkLevelCounts(config, rows);
} }
function severityCountsForRun(config: WebProbeSentinelServiceConfig, db: Database, runId: string): Record<string, number> { 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); 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(` return db.query(`
SELECT r.* SELECT r.*
FROM findings f FROM findings f
JOIN runs r ON r.id = f.run_id 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 ORDER BY f.created_at DESC
LIMIT 1 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> { 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", source: "sqlite-index+run-report-metadata",
node: stringOrNull(row.node) ?? config.node, node: stringOrNull(row.node) ?? config.node,
lane: stringOrNull(row.lane) ?? config.lane, lane: stringOrNull(row.lane) ?? config.lane,
sentinelId: stringOrNull(row.sentinel_id) ?? config.sentinelId,
runId: stringOrNull(row.id), runId: stringOrNull(row.id),
observerId: stringOrNull(row.observer_id), observerId: stringOrNull(row.observer_id),
stateDir: stringOrNull(row.state_dir), 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`; 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 { function countWhere(config: WebProbeSentinelServiceConfig, db: Database, where: string): number {
const row = db.query(`SELECT COUNT(*) AS count FROM runs WHERE ${where}`).get() as { count?: number } | null; 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); return Number(row?.count ?? 0);
} }
function sumColumn(db: Database, table: string, column: string): number { 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}`).get() as { total?: number } | null; 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); return Number(row?.total ?? 0);
} }
@@ -1428,9 +1484,10 @@ function recordRunResult(config: WebProbeSentinelServiceConfig, db: Database, in
const artifactCount = numberOr(input.artifactCount, 0); const artifactCount = numberOr(input.artifactCount, 0);
const createdAt = stringOrNull(input.createdAt) ?? now; const createdAt = stringOrNull(input.createdAt) ?? now;
db.query(` 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) 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
sentinel_id = excluded.sentinel_id,
status = excluded.status, status = excluded.status,
observer_id = excluded.observer_id, observer_id = excluded.observer_id,
state_dir = excluded.state_dir, state_dir = excluded.state_dir,
@@ -1438,20 +1495,21 @@ function recordRunResult(config: WebProbeSentinelServiceConfig, db: Database, in
finding_count = excluded.finding_count, finding_count = excluded.finding_count,
artifact_count = excluded.artifact_count, artifact_count = excluded.artifact_count,
updated_at = excluded.updated_at 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 })); `).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 status = 'planned' AND scenario_id = ? AND created_at < ?") 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, scenarioId, createdAt); .run(now, config.sentinelId, config.node, config.lane, scenarioId, createdAt);
db.query("DELETE FROM findings WHERE run_id = ?").run(runId); db.query("DELETE FROM findings WHERE run_id = ? AND sentinel_id = ?").run(runId, config.sentinelId);
for (const item of findings) { for (const item of findings) {
const findingId = stringOrNull(item.id) ?? stringOrNull(item.kind) ?? stringOrNull(item.code) ?? "finding"; const findingId = stringOrNull(item.id) ?? stringOrNull(item.kind) ?? stringOrNull(item.code) ?? "finding";
const check = checkForFinding(config, { ...item, id: findingId }); const check = checkForFinding(config, { ...item, id: findingId });
const severity = stringOrNull(check.level) ?? normalizeCheckLevel(stringOrNull(item.severity) ?? stringOrNull(item.level)) ?? "unknown"; const severity = stringOrNull(check.level) ?? normalizeCheckLevel(stringOrNull(item.severity) ?? stringOrNull(item.level)) ?? "unknown";
const summary = stringOrNull(item.summary) ?? stringOrNull(item.message) ?? findingId; 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 (?, ?, ?, ?, ?, ?, ?)") db.query("INSERT INTO findings (run_id, sentinel_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); .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}`, { writeMetadata(db, `run.report.${runId}`, {
runId, runId,
sentinelId: config.sentinelId,
scenarioId, scenarioId,
observerId, observerId,
stateDir, stateDir,
@@ -1473,8 +1531,8 @@ function reportRunView(config: WebProbeSentinelServiceConfig, db: Database, view
return { ok: false, error: "unsupported-report-view", view, valuesRedacted: true }; return { ok: false, error: "unsupported-report-view", view, valuesRedacted: true };
} }
const row = runId === null 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 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
: db.query("SELECT * FROM runs WHERE id = ?").get(runId) as Record<string, unknown> | null; : readRunRow(config, db, runId);
if (row === null) return { ok: false, error: "report-run-missing", runId, view, valuesRedacted: true }; if (row === null) return { ok: false, error: "report-run-missing", runId, view, valuesRedacted: true };
const selectedRunId = stringOrNull(row.id); const selectedRunId = stringOrNull(row.id);
if (selectedRunId === null) return { ok: false, error: "report-run-id-missing", view, valuesRedacted: true }; if (selectedRunId === null) return { ok: false, error: "report-run-id-missing", view, valuesRedacted: true };