From 5a510fe419709f5d15daf02e76d2b12dc484339d Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 28 Jun 2026 08:34:16 +0000 Subject: [PATCH] fix: isolate D518 web sentinel runners --- .../cicd.d518-v03.yaml | 4 +- .../cicd.fake-echo.d518-v03.yaml | 6 +- .../public-exposure.d518-v03.yaml | 12 +- .../public-exposure.fake-echo.d518-v03.yaml | 10 +- .../runtime.d518-v03.yaml | 16 +- .../runtime.fake-echo.d518-v03.yaml | 34 ++++ ...ench-fake-echo-session-invariance-10x.yaml | 2 +- .../PJ2026-01060508-web-probe-sentinel.md | 24 ++- scripts/src/hwlab-node-web-sentinel-cicd.ts | 30 ++- .../src/hwlab-node-web-sentinel-service.ts | 172 ++++++++++++------ 10 files changed, 225 insertions(+), 85 deletions(-) create mode 100644 config/hwlab-web-probe-sentinel/runtime.fake-echo.d518-v03.yaml diff --git a/config/hwlab-web-probe-sentinel/cicd.d518-v03.yaml b/config/hwlab-web-probe-sentinel/cicd.d518-v03.yaml index c5c24fae..345cebe1 100644 --- a/config/hwlab-web-probe-sentinel/cicd.d518-v03.yaml +++ b/config/hwlab-web-probe-sentinel/cicd.d518-v03.yaml @@ -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: diff --git a/config/hwlab-web-probe-sentinel/cicd.fake-echo.d518-v03.yaml b/config/hwlab-web-probe-sentinel/cicd.fake-echo.d518-v03.yaml index 4d5e1414..406e5849 100644 --- a/config/hwlab-web-probe-sentinel/cicd.fake-echo.d518-v03.yaml +++ b/config/hwlab-web-probe-sentinel/cicd.fake-echo.d518-v03.yaml @@ -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 diff --git a/config/hwlab-web-probe-sentinel/public-exposure.d518-v03.yaml b/config/hwlab-web-probe-sentinel/public-exposure.d518-v03.yaml index a4e42376..19715f7d 100644 --- a/config/hwlab-web-probe-sentinel/public-exposure.d518-v03.yaml +++ b/config/hwlab-web-probe-sentinel/public-exposure.d518-v03.yaml @@ -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 diff --git a/config/hwlab-web-probe-sentinel/public-exposure.fake-echo.d518-v03.yaml b/config/hwlab-web-probe-sentinel/public-exposure.fake-echo.d518-v03.yaml index 27f3803d..c4667df6 100644 --- a/config/hwlab-web-probe-sentinel/public-exposure.fake-echo.d518-v03.yaml +++ b/config/hwlab-web-probe-sentinel/public-exposure.fake-echo.d518-v03.yaml @@ -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 diff --git a/config/hwlab-web-probe-sentinel/runtime.d518-v03.yaml b/config/hwlab-web-probe-sentinel/runtime.d518-v03.yaml index bd63e312..96017718 100644 --- a/config/hwlab-web-probe-sentinel/runtime.d518-v03.yaml +++ b/config/hwlab-web-probe-sentinel/runtime.d518-v03.yaml @@ -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 diff --git a/config/hwlab-web-probe-sentinel/runtime.fake-echo.d518-v03.yaml b/config/hwlab-web-probe-sentinel/runtime.fake-echo.d518-v03.yaml new file mode 100644 index 00000000..31efffb3 --- /dev/null +++ b/config/hwlab-web-probe-sentinel/runtime.fake-echo.d518-v03.yaml @@ -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 diff --git a/config/hwlab-web-probe-sentinels/d518-v03/workbench-fake-echo-session-invariance-10x.yaml b/config/hwlab-web-probe-sentinels/d518-v03/workbench-fake-echo-session-invariance-10x.yaml index dcb68c59..b1d30bc4 100644 --- a/config/hwlab-web-probe-sentinels/d518-v03/workbench-fake-echo-session-invariance-10x.yaml +++ b/config/hwlab-web-probe-sentinels/d518-v03/workbench-fake-echo-session-invariance-10x.yaml @@ -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 diff --git a/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md b/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md index f7ddf42d..29b5cf02 100644 --- a/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md +++ b/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md @@ -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/` 详情页 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 heartbeat;OTel/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。 diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts index 3ae10dce..abd1df4f 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts @@ -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 diff --git a/scripts/src/hwlab-node-web-sentinel-service.ts b/scripts/src/hwlab-node-web-sentinel-service.ts index b659c896..111a2128 100644 --- a/scripts/src/hwlab-node-web-sentinel-service.ts +++ b/scripts/src/hwlab-node-web-sentinel-service.ts @@ -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 | 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[]; + 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[]; }, 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) { @@ -291,6 +292,8 @@ export function startWebProbeSentinelHttpService(service: WebProbeSentinelServic async function sentinelFetch(service: WebProbeSentinelService, request: Request): Promise { 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 | 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 { 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[]; + 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, 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 | 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 | 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 | null } } -function runCounts(db: Database): Record { - const rows = db.query("SELECT status, COUNT(*) AS count FROM runs GROUP BY status").all() as { status: string; count: number }[]; +function runCounts(config: WebProbeSentinelServiceConfig, db: Database): Record { + 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[]; 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 { - 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 { 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[]; 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 { - 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, 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 { }; } -function runWhereClause(filters: Record): { readonly sql: string; readonly params: readonly (string | number)[] } { - const clauses: string[] = []; - const params: (string | number)[] = []; +function runWhereClause(config: WebProbeSentinelServiceConfig, filters: Record): { 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): { readonly sql: strin return { sql: clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`, params }; } -function findingWhereClause(filters: Record): { readonly sql: string; readonly params: readonly (string | number)[] } { - const clauses: string[] = []; - const params: (string | number)[] = []; +function findingWhereClause(config: WebProbeSentinelServiceConfig, filters: Record): { 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 | null { - return db.query("SELECT * FROM runs WHERE id = ?").get(runId) as Record | null; +function readRunRow(config: WebProbeSentinelServiceConfig, db: Database, runId: string): Record | 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 | null; } function findingsForRun(config: WebProbeSentinelServiceConfig, db: Database, runId: string, limit: number): readonly Record[] { - 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[]; + 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[]; 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 { - const rows = db.query("SELECT finding_id, severity, count FROM findings").all() as Record[]; + const rows = db.query("SELECT finding_id, severity, count FROM findings WHERE sentinel_id = ?").all(config.sentinelId) as Record[]; return checkLevelCounts(config, rows); } function severityCountsForRun(config: WebProbeSentinelServiceConfig, db: Database, runId: string): Record { - const rows = db.query("SELECT finding_id, severity, count FROM findings WHERE run_id = ?").all(runId) as Record[]; + const rows = db.query("SELECT finding_id, severity, count FROM findings WHERE run_id = ? AND sentinel_id = ?").all(runId, config.sentinelId) as Record[]; return checkLevelCounts(config, rows); } -function latestRunForFinding(db: Database, row: Record): Record | null { +function latestRunForFinding(config: WebProbeSentinelServiceConfig, db: Database, row: Record): Record | 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 | null; + `).get(stringOrNull(row.finding_id), stringOrNull(row.scenario_id), config.sentinelId, config.node, config.lane, config.sentinelId) as Record | null; } function runTraceability(config: WebProbeSentinelServiceConfig, row: Record): Record { @@ -1284,6 +1337,7 @@ function runTraceability(config: WebProbeSentinelServiceConfig, row: Record | null - : db.query("SELECT * FROM runs WHERE id = ?").get(runId) as Record | 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 | 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 };