feat(web-probe): add multi-sentinel registry

This commit is contained in:
Codex
2026-06-26 12:42:04 +00:00
parent 7b3df965cc
commit 4e0f1cba21
25 changed files with 1038 additions and 140 deletions
+7 -10
View File
@@ -259,16 +259,13 @@ lanes:
scrapeMode: pod-loopback scrapeMode: pod-loopback
publicRawMetrics: denied publicRawMetrics: denied
webProbe: webProbe:
sentinel: sentinels:
enabled: true - id: workbench-dsflash-go-tool-call-10x
configRefs: enabled: true
runtime: config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml#sentinel.runtime configRef: config/hwlab-web-probe-sentinels/d601-v03/workbench-dsflash-go-tool-call-10x.yaml#sentinel
scenarios: config/hwlab-web-probe-sentinel/scenarios.workbench.yaml#sentinel.scenarios - id: workbench-auth-session-switch-2users
promptSet: config/hwlab-web-probe-sentinel/prompt-set.dsflash-go.yaml#sentinel.promptSet enabled: true
reportViews: config/hwlab-web-probe-sentinel/report-views.yaml#sentinel.reportViews configRef: config/hwlab-web-probe-sentinels/d601-v03/workbench-auth-session-switch-2users.yaml#sentinel
publicExposure: config/hwlab-web-probe-sentinel/public-exposure.d601-v03.yaml#sentinel.publicExposure
cicd: config/hwlab-web-probe-sentinel/cicd.d601-v03.yaml#sentinel.cicd
secrets: config/hwlab-web-probe-sentinel/secrets.d601-v03.yaml#sentinel.secrets
workbench: workbench:
enabled: true enabled: true
summaryPath: /v1/web-performance/summary summaryPath: /v1/web-performance/summary
@@ -0,0 +1,49 @@
version: 1
kind: HwlabWebProbeSentinelCicd
metadata:
id: d601-v03-web-probe-sentinel-auth-session-switch-cicd
owner: UniDesk
specRef: PJ2026-01060508
sentinel:
cicd:
controlPlaneConfigRef: config/hwlab-node-control-plane.yaml#targets[0]
source:
repository: pikasTech/unidesk
branch: master
gitSshUrl: ssh://git@ssh.github.com:443/pikasTech/unidesk.git
gitMirrorReadUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git
buildContext: .
entrypoint: scripts/web-probe-sentinel-service.ts
checkoutPaths:
- scripts
- config
- package.json
- bun.lock
- bun.lockb
builder:
namespace: devops-infra
sourceMode: sparse-git-checkout
jobPrefix: web-probe-sentinel-auth-switch-publish
gitSshSecretName: git-mirror-github-ssh
dockerSocketPath: /var/run/docker.sock
activeDeadlineSeconds: 900
ttlSecondsAfterFinished: 3600
gitopsPath: deploy/gitops/node/d601/web-probe-sentinel-auth-switch
argo:
namespace: argocd
projectName: hwlab-d601
applicationName: hwlab-web-probe-sentinel-auth-switch
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-auth-switch
tagSource: source-commit
baseImageRef: config/hwlab-node-control-plane.yaml#targets[0].tekton.toolsImage.output
envRecipeRef: config/hwlab-web-probe-sentinel/runtime.auth-session-switch.d601-v03.yaml#sentinel.runtime
maintenance:
startCommand: sentinel maintenance start
stopCommand: sentinel maintenance stop
targetValidation:
scenarioId: workbench-auth-session-switch-2users
maxSeconds: 300
serviceUnavailablePolicy: structured-failure
@@ -0,0 +1,15 @@
version: 1
kind: HwlabWebProbeSentinelPromptSet
metadata:
id: d601-v03-web-probe-sentinel-auth-session-switch-prompt-set
owner: UniDesk
specRef: PJ2026-01060508
sentinel:
promptSet:
id: auth-session-switch-no-prompt
providerProfile: session-switch-sentinel
providerProfileMode: exact
promptSourceRef: hwlab/web-probe-sentinel-auth-switch.env
promptSourceKey: AUTH_SWITCH_UNUSED_PROMPTS_JSON
promptCount: 0
redaction: hash-and-byte-count
@@ -0,0 +1,37 @@
version: 1
kind: HwlabWebProbeSentinelPublicExposure
metadata:
id: d601-v03-web-probe-sentinel-auth-session-switch-public-exposure
owner: UniDesk
specRef: PJ2026-01060508
sentinel:
publicExposure:
enabled: true
mode: pk01-caddy-frp-path
publicBaseUrl: https://monitor.pikapython.com/sentinels/workbench-auth-session-switch-2users
hostname: monitor.pikapython.com
routePrefix: /sentinels/workbench-auth-session-switch-2users
expectedA: 82.156.23.220
frpc:
deploymentName: hwlab-web-probe-sentinel-auth-switch-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-auth-switch-frpc
secretKey: frpc.toml
tokenKey: token
httpProxy:
name: hwlab-d601-v03-web-probe-sentinel-auth-switch
remotePort: 22091
localIP: hwlab-web-probe-sentinel-auth-switch.hwlab-v03.svc.cluster.local
localPort: 8080
caddy:
route: PK01
configPath: /etc/caddy/Caddyfile
serviceName: caddy
email: ops@pikapython.com
tls: auto
responseHeaderTimeoutSeconds: 600
managedBlockOwner: hwlab-web-probe-sentinel-auth-switch-d601-v03
@@ -10,6 +10,7 @@ sentinel:
mode: pk01-caddy-frp mode: pk01-caddy-frp
publicBaseUrl: https://monitor.pikapython.com publicBaseUrl: https://monitor.pikapython.com
hostname: monitor.pikapython.com hostname: monitor.pikapython.com
routePrefix: /
expectedA: 82.156.23.220 expectedA: 82.156.23.220
frpc: frpc:
deploymentName: hwlab-web-probe-sentinel-frpc deploymentName: hwlab-web-probe-sentinel-frpc
@@ -0,0 +1,22 @@
version: 1
kind: HwlabWebProbeSentinelReportViews
metadata:
id: d601-v03-web-probe-sentinel-auth-session-switch-report-views
owner: UniDesk
specRef: PJ2026-01060508
sentinel:
reportViews:
defaultView: auth-session-switch-summary
views:
- summary
- auth-session-switch-summary
- findings
- trace-frame
pageSize: 20
maxPageSize: 100
rawAccess: explicit-only
redaction:
prompt: hash-and-byte-count
assistantFinal: summary-and-hash
providerPayload: denied
secrets: denied
@@ -0,0 +1,33 @@
version: 1
kind: HwlabWebProbeSentinelRuntime
metadata:
id: d601-v03-web-probe-sentinel-auth-session-switch-runtime
owner: UniDesk
specRef: PJ2026-01060508
sentinel:
runtime:
target:
node: D601
lane: v03
publicOriginRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D601.public.webUrl
observeWrapperRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D601.observability.webProbe.sentinels[1]
namespace: hwlab-v03
serviceAccountName: hwlab-web-probe-sentinel-auth-switch
deploymentName: hwlab-web-probe-sentinel-auth-switch
serviceName: hwlab-web-probe-sentinel-auth-switch
listenHost: 0.0.0.0
servicePort: 8080
pvcName: hwlab-web-probe-sentinel-auth-switch-state
pvcStorage: 10Gi
stateRoot: /var/lib/web-probe-sentinel-auth-switch
imageRef: 127.0.0.1:5000/hwlab/web-probe-sentinel-auth-switch:source-commit
replicas: 1
healthPath: /api/health
metricsPath: /metrics
scheduler:
intervalMs: 600000
heartbeatStaleSeconds: 900
maxConcurrentRuns: 1
sqlite:
path: /var/lib/web-probe-sentinel-auth-switch/index.sqlite
busyTimeoutMs: 2000
@@ -10,7 +10,7 @@ sentinel:
node: D601 node: D601
lane: v03 lane: v03
publicOriginRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D601.public.webUrl publicOriginRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D601.public.webUrl
observeWrapperRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D601.observability.webProbe.sentinel observeWrapperRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D601.observability.webProbe.sentinels[0]
namespace: hwlab-v03 namespace: hwlab-v03
serviceAccountName: hwlab-web-probe-sentinel serviceAccountName: hwlab-web-probe-sentinel
deploymentName: hwlab-web-probe-sentinel deploymentName: hwlab-web-probe-sentinel
@@ -25,8 +25,8 @@ sentinel:
healthPath: /api/health healthPath: /api/health
metricsPath: /metrics metricsPath: /metrics
scheduler: scheduler:
intervalMs: 30000 intervalMs: 600000
heartbeatStaleSeconds: 120 heartbeatStaleSeconds: 900
maxConcurrentRuns: 1 maxConcurrentRuns: 1
sqlite: sqlite:
path: /var/lib/web-probe-sentinel/index.sqlite path: /var/lib/web-probe-sentinel/index.sqlite
@@ -0,0 +1,47 @@
version: 1
kind: HwlabWebProbeSentinelSecrets
metadata:
id: d601-v03-web-probe-sentinel-auth-session-switch-secrets
owner: UniDesk
specRef: PJ2026-01060508
sentinel:
secrets:
sources:
- purpose: bootstrap-admin
sourceRef: hwlab/d601-v03-bootstrap-admin.env
sourceKey: HWLAB_BOOTSTRAP_ADMIN_PASSWORD
- purpose: account-a
sourceRef: hwlab/web-probe-sentinel-auth-switch-account-a.env
sourceKey: ACCOUNT_A_JSON
- purpose: account-b
sourceRef: hwlab/web-probe-sentinel-auth-switch-account-b.env
sourceKey: ACCOUNT_B_JSON
- purpose: prompt-set
sourceRef: hwlab/web-probe-sentinel-auth-switch.env
sourceKey: AUTH_SWITCH_UNUSED_PROMPTS_JSON
- purpose: frp-token
sourceRef: platform-infra/pk01-frp.env
sourceKey: FRP_TOKEN
runtimeSecrets:
- name: hwlab-web-probe-sentinel-auth-switch-bootstrap
namespace: hwlab-v03
data:
- sourcePurpose: bootstrap-admin
targetKey: bootstrap-admin-password
- name: hwlab-web-probe-sentinel-auth-switch-accounts
namespace: hwlab-v03
data:
- sourcePurpose: account-a
targetKey: account-a.json
- sourcePurpose: account-b
targetKey: account-b.json
- name: hwlab-web-probe-sentinel-auth-switch-prompt-set
namespace: hwlab-v03
data:
- sourcePurpose: prompt-set
targetKey: prompts.json
- name: hwlab-web-probe-sentinel-auth-switch-frpc
namespace: hwlab-v03
data:
- sourcePurpose: frp-token
targetKey: token
@@ -0,0 +1,41 @@
version: 1
kind: HwlabWebProbeSentinelWorkflow
metadata:
id: d601-v03-web-probe-sentinel-auth-session-switch-workflow
owner: UniDesk
specRef: PJ2026-01060508
sentinel:
workflow:
id: workbench-auth-session-switch-2users
enabled: true
cadence: 10m
observeTargetPath: /workbench
sampleIntervalMs: 1000
screenshotIntervalMs: 60000
maxRunSeconds: 900
providerProfile: session-switch-sentinel
providerProfileMode: exact
promptSetRef: config/hwlab-web-probe-sentinel/prompt-set.auth-session-switch.yaml#sentinel.promptSet
reportViewRef: config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml#sentinel.reportViews
accounts:
- id: account-a
sourcePurpose: account-a
usernameKey: username
passwordKey: password
- id: account-b
sourcePurpose: account-b
usernameKey: username
passwordKey: password
commandSequence:
- type: loginAccount
accountId: account-a
- type: listSessions
- type: logout
- type: loginAccount
accountId: account-b
- type: listSessions
- type: switchSessions
fromAccountId: account-b
toAccountId: account-a
- type: listSessions
- type: logout
@@ -0,0 +1,18 @@
version: 1
kind: HwlabWebProbeSentinel
metadata:
id: d601-v03-workbench-auth-session-switch-2users
owner: UniDesk
specRef: PJ2026-01060508
sentinel:
id: workbench-auth-session-switch-2users
enabled: true
mode: web-probe-observe-wrapper
configRefs:
runtime: config/hwlab-web-probe-sentinel/runtime.auth-session-switch.d601-v03.yaml#sentinel.runtime
workflow: config/hwlab-web-probe-sentinel/workflow.auth-session-switch.yaml#sentinel.workflow
promptSet: config/hwlab-web-probe-sentinel/prompt-set.auth-session-switch.yaml#sentinel.promptSet
reportViews: config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml#sentinel.reportViews
publicExposure: config/hwlab-web-probe-sentinel/public-exposure.auth-session-switch.d601-v03.yaml#sentinel.publicExposure
cicd: config/hwlab-web-probe-sentinel/cicd.auth-session-switch.d601-v03.yaml#sentinel.cicd
secrets: config/hwlab-web-probe-sentinel/secrets.auth-session-switch.d601-v03.yaml#sentinel.secrets
@@ -0,0 +1,18 @@
version: 1
kind: HwlabWebProbeSentinel
metadata:
id: d601-v03-workbench-dsflash-go-tool-call-10x
owner: UniDesk
specRef: PJ2026-01060508
sentinel:
id: workbench-dsflash-go-tool-call-10x
enabled: true
mode: web-probe-observe-wrapper
configRefs:
runtime: config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml#sentinel.runtime
scenarios: config/hwlab-web-probe-sentinel/scenarios.workbench.yaml#sentinel.scenarios
promptSet: config/hwlab-web-probe-sentinel/prompt-set.dsflash-go.yaml#sentinel.promptSet
reportViews: config/hwlab-web-probe-sentinel/report-views.yaml#sentinel.reportViews
publicExposure: config/hwlab-web-probe-sentinel/public-exposure.d601-v03.yaml#sentinel.publicExposure
cicd: config/hwlab-web-probe-sentinel/cicd.d601-v03.yaml#sentinel.cicd
secrets: config/hwlab-web-probe-sentinel/secrets.d601-v03.yaml#sentinel.secrets
@@ -21,6 +21,7 @@
| 状态 | 已生效 | | 状态 | 已生效 |
| 实现引用版本 | draft-2026-06-25-p0-web-probe-sentinel | | 实现引用版本 | draft-2026-06-25-p0-web-probe-sentinel |
| Dashboard 实现引用版本 | draft-2026-06-26-p8-web-probe-sentinel-recovery | | Dashboard 实现引用版本 | draft-2026-06-26-p8-web-probe-sentinel-recovery |
| 多实例实现引用版本 | draft-2026-06-26-p9-multi-web-probe-sentinel |
| 需求规格模板 | [ISO/IEC/IEEE 29148 需求规格模板](../../templates/iso-iec-ieee-29148-requirements-spec-template.md) | | 需求规格模板 | [ISO/IEC/IEEE 29148 需求规格模板](../../templates/iso-iec-ieee-29148-requirements-spec-template.md) |
| 上级规格 | [PJ2026-010605 运维监控](PJ2026-010605-observability-monitoring.md) | | 上级规格 | [PJ2026-010605 运维监控](PJ2026-010605-observability-monitoring.md) |
| 关联规格 | [PJ2026-010401 Web工作台](PJ2026-010401-web-workbench.md)、[PJ2026-0104010803 Workbench唯一投影](PJ2026-0104010803-workbench-unique-projection.md)、[PJ2026-010403 API契约](PJ2026-010403-api-contract.md)、[PJ2026-010601 发布流水](PJ2026-010601-controlled-release.md)、[PJ2026-010602 源码同步](PJ2026-010602-source-sync.md)、[PJ2026-010603 YAML运维](PJ2026-010603-yaml-first-ops.md)、[PJ2026-010604 公开入口](PJ2026-010604-public-entry.md)、[PJ2026-01060505 Workbench性能](PJ2026-01060505-workbench-performance.md) | | 关联规格 | [PJ2026-010401 Web工作台](PJ2026-010401-web-workbench.md)、[PJ2026-0104010803 Workbench唯一投影](PJ2026-0104010803-workbench-unique-projection.md)、[PJ2026-010403 API契约](PJ2026-010403-api-contract.md)、[PJ2026-010601 发布流水](PJ2026-010601-controlled-release.md)、[PJ2026-010602 源码同步](PJ2026-010602-source-sync.md)、[PJ2026-010603 YAML运维](PJ2026-010603-yaml-first-ops.md)、[PJ2026-010604 公开入口](PJ2026-010604-public-entry.md)、[PJ2026-01060505 Workbench性能](PJ2026-01060505-workbench-performance.md) |
@@ -41,12 +42,13 @@ Web哨兵必须遵循 UniDesk YAML-first ops。目标 node/lane、public origin
### 2.2 范围内 ### 2.2 范围内
- `web-probe observe` CLI 的 wrapper/adapter 边界,使常驻服务能够稳定调用 start/status/command/collect/analyze。 - `web-probe observe` CLI 的 wrapper/adapter 边界,使常驻服务能够稳定调用 start/status/command/collect/analyze。
- YAML `observability.webProbe.sentinel.enabled/configRefs` 与 runtime、scenario、promptSet、reportView、publicExposure、Secret、CI/CD owning YAML 的引用图。 - YAML `observability.webProbe.sentinel.enabled/configRefs` 的 legacy 单实例入口,以及 `observability.webProbe.sentinels[]` 多实例 registry 与每个 sentinel 管理 YAML 的引用图。
- 常驻 TypeScript 单 Pod wrapper 服务、scheduler、scenario runner、PVC/SQLite index、health、metrics、maintenance API 和 dashboard。 - 常驻 TypeScript 单 Pod wrapper 服务、scheduler、scenario runner、PVC/SQLite index、health、metrics、maintenance API 和 dashboard。
- `sentinel plan|apply|status|validate|report|maintenance``sentinel image|control-plane` 等受控 CLI 入口。 - `sentinel plan|apply|status|validate|report|maintenance``sentinel image|control-plane` 等受控 CLI 入口。
- 发布流水 maintenance start/stop、quick verify、targetValidation、GitOps/Argo/git-mirror closeout 和 public exposure 验证。 - 发布流水 maintenance start/stop、quick verify、targetValidation、GitOps/Argo/git-mirror closeout 和 public exposure 验证。
- Dashboard 信息架构、规范化 API、前端组件分层、自动刷新、筛选、深链和 trace/turn 两层阅读视图。 - Dashboard 信息架构、规范化 API、前端组件分层、自动刷新、筛选、深链和 trace/turn 两层阅读视图。
- `workbench-dsflash-go-tool-call-10x` 生产 canary 和 24 小时 dry-run 收口。 - `workbench-dsflash-go-tool-call-10x` 生产 canary 和 24 小时 dry-run 收口。
- `workbench-auth-session-switch-2users` 账号切换链路哨兵,覆盖账号 A/B 登录、登出、session 列表和 session 切换命令链。
- Secret、prompt、provider payload、artifact 和 dashboard 的脱敏边界。 - Secret、prompt、provider payload、artifact 和 dashboard 的脱敏边界。
### 2.3 范围外 ### 2.3 范围外
@@ -56,6 +58,7 @@ Web哨兵必须遵循 UniDesk YAML-first ops。目标 node/lane、public origin
- API path、错误 envelope、route policy 和用户身份语义归 [PJ2026-010403 API契约](PJ2026-010403-api-contract.md)。 - API path、错误 envelope、route policy 和用户身份语义归 [PJ2026-010403 API契约](PJ2026-010403-api-contract.md)。
- 第一阶段不交付分布式压测;loadtest 只保留同镜像、同 wrapper 的配置和命令扩展点。 - 第一阶段不交付分布式压测;loadtest 只保留同镜像、同 wrapper 的配置和命令扩展点。
- in-cluster 哨兵不是外部公网监控节点。若后续需要真正外网用户路径监控,应另行定义外部或边缘哨兵,不把当前服务伪装成外部观测点。 - in-cluster 哨兵不是外部公网监控节点。若后续需要真正外网用户路径监控,应另行定义外部或边缘哨兵,不把当前服务伪装成外部观测点。
- 多实例 Web 哨兵不得用一个 Pod、一个 PVC 或一个 SQLite index 伪装隔离。共享 dashboard domain 可以按 route prefix 暴露,但 runtime Deployment、Service、PVC、SQLite、GitOps path、Argo Application、metrics label 和 report index 必须按 sentinel id 独立。
- SQLite index、dashboard、metrics 和 maintenance 状态不得替代 `samples.jsonl``control.jsonl`、network/artifact JSONL 或 `analysis/report.json` 成为探针事实源。 - SQLite index、dashboard、metrics 和 maintenance 状态不得替代 `samples.jsonl``control.jsonl`、network/artifact JSONL 或 `analysis/report.json` 成为探针事实源。
- Dashboard 不负责修复 Workbench projection、trace timing、runner/envreuse 或 git mirror 慢路径;它只把 observe/analyze 已采集事实组织成可读的值守和分析入口。 - Dashboard 不负责修复 Workbench projection、trace timing、runner/envreuse 或 git mirror 慢路径;它只把 observe/analyze 已采集事实组织成可读的值守和分析入口。
@@ -64,6 +67,10 @@ Web哨兵必须遵循 UniDesk YAML-first ops。目标 node/lane、public origin
| 术语 | 定义 | | 术语 | 定义 |
| --- | --- | | --- | --- |
| Web哨兵 | 常驻 wrapper/orchestrator,按 YAML 调度现有 `web-probe observe` CLI,对 HWLAB Web public origin 做持续 canary、分析、展示和发布联动。 | | Web哨兵 | 常驻 wrapper/orchestrator,按 YAML 调度现有 `web-probe observe` CLI,对 HWLAB Web public origin 做持续 canary、分析、展示和发布联动。 |
| sentinel registry | node/lane root YAML 下的 `observability.webProbe.sentinels[]`,只声明 sentinel id、enabled 和管理 configRef。 |
| sentinel 管理 YAML | registry 项指向的 owning YAML,声明单个 sentinel 的 id、enabled、mode 和 runtime/workflow/promptSet/reportViews/publicExposure/cicd/secrets configRefs。 |
| sentinel id | 多实例哨兵的稳定小写标识,必须进入 CLI `--sentinel`、Kubernetes/GitOps label、metrics label、report index 和 dashboard route。 |
| 账号切换哨兵 | `workbench-auth-session-switch-2users`,使用两组 YAML Secret sourceRef 驱动登录、登出、session 列表和 session 切换链路。 |
| observe runner | 现有 `web-probe observe start` 启动的浏览器采样器;它写入 DOM、network、control、screenshot、artifact 等 JSONL。 | | observe runner | 现有 `web-probe observe start` 启动的浏览器采样器;它写入 DOM、network、control、screenshot、artifact 等 JSONL。 |
| observe artifact | `web-probe observe` 产生的 stateDir、JSONL、截图、analysis/report.md 和 analysis/report.json,是哨兵报告的事实来源。 | | observe artifact | `web-probe observe` 产生的 stateDir、JSONL、截图、analysis/report.md 和 analysis/report.json,是哨兵报告的事实来源。 |
| CLI wrapper adapter | 服务与 CLI 共享的命令适配层,把 start/status/command/collect/analyze 表达为稳定调用,不复制 runner/analyzer 实现。 | | CLI wrapper adapter | 服务与 CLI 共享的命令适配层,把 start/status/command/collect/analyze 表达为稳定调用,不复制 runner/analyzer 实现。 |
@@ -106,25 +113,28 @@ Web哨兵必须遵循 UniDesk YAML-first ops。目标 node/lane、public origin
| PJ2026-0106050807 | 安全隔离 | 本规格 6.7 | Secret/prompt/provider redaction、NetworkPolicy、public dashboard auth | 用户管理、平台运维 | 安全 closeout | | PJ2026-0106050807 | 安全隔离 | 本规格 6.7 | Secret/prompt/provider redaction、NetworkPolicy、public dashboard auth | 用户管理、平台运维 | 安全 closeout |
| PJ2026-0106050808 | 代码引用 | 本规格 6.8 | SPEC 头部标注和生成/配置追溯 | 规格治理 | 后续 PR 审计 | | PJ2026-0106050808 | 代码引用 | 本规格 6.8 | SPEC 头部标注和生成/配置追溯 | 规格治理 | 后续 PR 审计 |
| PJ2026-0106050809 | Dashboard工作台 | 本规格 6.9 | overview、runs、findings、run detail、trace-frame viewer、前端分层 | 报告视图、常驻服务、Workbench性能 | 平台值守和问题分析 | | PJ2026-0106050809 | Dashboard工作台 | 本规格 6.9 | overview、runs、findings、run detail、trace-frame viewer、前端分层 | 报告视图、常驻服务、Workbench性能 | 平台值守和问题分析 |
| PJ2026-0106050810 | 多实例与账号切换 | 本规格 6.10 | sentinel registry、实例隔离、账号切换 command、route prefix 和 report label | YAML配置、Wrapper边界、安全隔离 | 多哨兵巡检、账号链路值守 |
### 5.1 目标架构图 ### 5.1 目标架构图
```mermaid ```mermaid
flowchart LR flowchart LR
subgraph Config[UniDesk YAML source of truth] subgraph Config[UniDesk YAML source of truth]
Lane[hwlab-node-lanes.yaml<br/>sentinel enabled/configRefs] Lane[hwlab-node-lanes.yaml<br/>sentinels registry]
Mgmt[sentinel management YAML]
Runtime[runtime owning YAML] Runtime[runtime owning YAML]
Scenario[scenario owning YAML] Scenario[scenario owning YAML]
Prompts[synthetic prompt YAML] Prompts[synthetic prompt YAML]
Report[report view YAML] Report[report view YAML]
Exposure[publicExposure YAML] Exposure[publicExposure YAML]
Secrets[Secret sourceRef YAML] Secrets[Secret sourceRef YAML]
Lane --> Runtime Lane --> Mgmt
Lane --> Scenario Mgmt --> Runtime
Lane --> Prompts Mgmt --> Scenario
Lane --> Report Mgmt --> Prompts
Lane --> Exposure Mgmt --> Report
Lane --> Secrets Mgmt --> Exposure
Mgmt --> Secrets
end end
subgraph Sentinel[Web哨兵 Pod] subgraph Sentinel[Web哨兵 Pod]
@@ -171,8 +181,9 @@ flowchart LR
```mermaid ```mermaid
flowchart TD flowchart TD
Root[config/hwlab-node-lanes.yaml<br/>lanes.v03.targets.D601.observability.webProbe.sentinel] --> Enabled[enabled] Root[config/hwlab-node-lanes.yaml<br/>lanes.v03.targets.D601.observability.webProbe.sentinels[]] --> Entry[id/enabled/configRef]
Root --> Refs[configRefs] Entry --> Mgmt[sentinel management YAML#sentinel]
Mgmt --> Refs[configRefs]
Refs --> Runtime[runtime.d601-v03.yaml#sentinel.runtime] Refs --> Runtime[runtime.d601-v03.yaml#sentinel.runtime]
Refs --> Scenarios[scenarios.workbench.yaml#sentinel.scenarios] Refs --> Scenarios[scenarios.workbench.yaml#sentinel.scenarios]
Refs --> PromptSets[prompt-set.dsflash-go.yaml#sentinel.promptSet] Refs --> PromptSets[prompt-set.dsflash-go.yaml#sentinel.promptSet]
@@ -440,11 +451,11 @@ Web哨兵必须只编排现有顶层 `web-probe observe start/status/command/col
| --- | --- | --- | --- | | --- | --- | --- | --- |
| OPS-SENTINEL-REQ-002 | YAML配置 | PJ2026-0106050802 YAML配置 | [YAML运维](PJ2026-010603-yaml-first-ops.md)、[公开入口](PJ2026-010604-public-entry.md)、[源码同步](PJ2026-010602-source-sync.md) | | OPS-SENTINEL-REQ-002 | YAML配置 | PJ2026-0106050802 YAML配置 | [YAML运维](PJ2026-010603-yaml-first-ops.md)、[公开入口](PJ2026-010604-public-entry.md)、[源码同步](PJ2026-010602-source-sync.md) |
Web哨兵配置必须通过 node/lane root YAML 的 `observability.webProbe.sentinel.enabled/configRefs` 进入,再引用 runtime、scenario、promptSet、reportView、publicExposure、CI/CD 和 Secret owning YAML。root YAML 不得承载所有 runtime/scenario/report/Secret 数值,也不得生成合并后的超级配置作为第二 source of truth。 Web哨兵 legacy 单实例配置可通过 node/lane root YAML 的 `observability.webProbe.sentinel.enabled/configRefs` 进入;多实例配置必须通过 `observability.webProbe.sentinels[]` registry 进入。registry 项只能声明 `id``enabled``configRef`,再由 sentinel 管理 YAML 引用 runtime、workflow/scenario、promptSet、reportView、publicExposure、CI/CD 和 Secret owning YAML。root YAML 不得承载所有 runtime/scenario/report/Secret 数值,也不得生成合并后的超级配置作为第二 source of truth。
parser 只负责解析 `path/to/file.yaml#object.path` 或规格确认的等价引用,校验文件存在、路径存在、字段形状、类型、枚举键名、必填字段和重复事实冲突。缺失字段应报告 YAML path 和下一步 drill-down;不得用代码默认值补 namespace、image、cadence、timeout、threshold、profile、Secret、public URL、report view 或 retention。 parser 只负责解析 `path/to/file.yaml#object.path` 或规格确认的等价引用,校验文件存在、路径存在、字段形状、类型、枚举键名、必填字段和重复事实冲突。registry id 与 sentinel 管理 YAML 内的 id 必须一致;多实例 registry 下的非 config 操作必须显式选择 `--sentinel <id>`,避免误操作默认实例。缺失字段应报告 YAML path 和下一步 drill-down;不得用代码默认值补 namespace、image、cadence、timeout、threshold、profile、Secret、public URL、report view 或 retention。
`sentinel plan/status` 必须输出 redacted 配置引用图:每个 ref 的文件、path、presence、摘要 hash、缺失字段、冲突字段和下一步命令。默认输出不得 dump 完整展开 YAML、Secret 值、prompt 原文或 provider payload。 `sentinel plan/status` 必须输出 redacted 配置引用图:`--sentinel` 且存在多个实例时输出 registry 表和逐实例 drill-down;指定实例时输出该实例每个 ref 的文件、path、presence、摘要 hash、缺失字段、冲突字段和下一步命令。默认输出不得 dump 完整展开 YAML、Secret 值、prompt 原文或 provider payload。
### 6.3 OPS-SENTINEL-REQ-003 常驻服务和 artifact 索引 ### 6.3 OPS-SENTINEL-REQ-003 常驻服务和 artifact 索引
@@ -494,7 +505,7 @@ HWLAB runtime 发布 Pipeline 应在 Argo sync 前调用当前哨兵 `maintenanc
哨兵服务不可用、首次安装未完成或配置未就绪时,CI/CD 必须结构化失败并输出缺失项、恢复建议和可重试命令;不得自动回退到原纯客户端 CLI、裸 Playwright、私有 API、read-side repair、reload 循环或 session repair 形成第二执行路径。人工排障可以显式运行原 `web-probe observe start/status/command/collect/analyze`,但不能被 targetValidation 当作自动通过证据。 哨兵服务不可用、首次安装未完成或配置未就绪时,CI/CD 必须结构化失败并输出缺失项、恢复建议和可重试命令;不得自动回退到原纯客户端 CLI、裸 Playwright、私有 API、read-side repair、reload 循环或 session repair 形成第二执行路径。人工排障可以显式运行原 `web-probe observe start/status/command/collect/analyze`,但不能被 targetValidation 当作自动通过证据。
`sentinel validate --quick-verify --confirm --wait`、maintenance stop quick verify 和 control-plane targetValidation 的确认等待总耗时超过 120s 时,必须输出 warning,并在 quick verify run 摘要中记录可见警告。warning 文案必须指向严重超时和下一步调查方向:env-reuse、git mirror、source build path、运行路径和当前 wait 阶段;不得通过调大 timeout、减少业务轮次或 fallback 到第二执行路径来消除红灯。 `sentinel validate --quick-verify --confirm --wait`、maintenance stop quick verify 和 control-plane targetValidation 的确认等待总耗时超过 120s 时,必须输出 warning,并在 quick verify run 摘要中记录可见警告。计时超限本身只作为非阻塞告警;只有真正影响 Code Agent 多轮业务链路、submit/command 执行、trace/final 可见性或 session 连续性的失败才构成 targetValidation blocker。control-plane publish/build 等非业务等待可通过 YAML 将确认等待预算放宽到 300s;不得通过减少业务轮次、吞掉 submit 失败、fallback 到第二执行路径或读侧 repair 来消除红灯。
### 6.6 OPS-SENTINEL-REQ-006 dsflash-go 十轮 canary ### 6.6 OPS-SENTINEL-REQ-006 dsflash-go 十轮 canary
@@ -564,6 +575,24 @@ Dashboard 自动刷新只能读取 bounded API,不得发送 `observe command`
P8 中文运维页面必须以中文为默认用户可见语言:主标题、状态、筛选、运行历史、finding 分组、run detail、trace-frame、Final Response、空态、错误态、自动刷新和下一步动作均使用中文。原始英文 code、status 枚举、CLI 命令和 report SHA 可作为机器对照保留,但不得要求用户阅读英文 finding 才能判断 HWLAB 是否可用、卡在哪一层、下一步运行什么命令。 P8 中文运维页面必须以中文为默认用户可见语言:主标题、状态、筛选、运行历史、finding 分组、run detail、trace-frame、Final Response、空态、错误态、自动刷新和下一步动作均使用中文。原始英文 code、status 枚举、CLI 命令和 report SHA 可作为机器对照保留,但不得要求用户阅读英文 finding 才能判断 HWLAB 是否可用、卡在哪一层、下一步运行什么命令。
### 6.10 OPS-SENTINEL-REQ-010 多实例巡检与账号切换链路
| 编号 | 短名 | 主责模块 | 关联模块 |
| --- | --- | --- | --- |
| OPS-SENTINEL-REQ-010 | 多实例与账号切换 | PJ2026-0106050810 多实例与账号切换 | [YAML运维](PJ2026-010603-yaml-first-ops.md)、[用户管理](PJ2026-0105-user-management.md)、[公开入口](PJ2026-010604-public-entry.md) |
Web 哨兵多实例必须以 node/lane root YAML 的 `observability.webProbe.sentinels[]` 为唯一 registry。每个 registry 项必须通过 `configRef` 指向独立 sentinel 管理 YAML,管理 YAML 再声明 runtime、workflow/scenario、promptSet、reportViews、publicExposure、cicd 和 secrets configRefs。legacy `observability.webProbe.sentinel` 只能作为单实例兼容入口;当同一 node/lane 存在多个 sentinel 时,除 `plan/status` registry 总览外,image、control-plane、validate、maintenance、report 和服务启动都必须显式携带 `--sentinel <id>`
每个 sentinel 必须拥有独立的 Deployment、ServiceAccount、Service、PVC、SQLite path、GitOps path、Argo Application、FRP Secret/Deployment、metrics label、report index namespace 和 dashboard/report route label。不同 sentinel 可以共享同一 public hostname,但必须通过 YAML `routePrefix` 或等价 publicExposure 声明区分路径;不得共享同一个 PVC/SQLite 后再用 scenario id 伪装实例隔离。
`workbench-dsflash-go-tool-call-10x` 是保留的生产 canary,迁移到多实例 registry 后其 observe/analyze、report view、dashboard root 和 targetValidation 语义不得回退。迁移 closeout 必须证明旧实例 `sentinel plan --sentinel workbench-dsflash-go-tool-call-10x` 仍能解析原 runtime/scenario/prompt/report/publicExposure/cicd/secrets 引用。
`workbench-auth-session-switch-2users` 是账号切换链路哨兵。账号 A/B 的用户名、密码或 token 只能来自 Secret sourceRef 和 targetKeyCLI、服务日志、dashboard、report 和 issue evidence 只能展示 account id、sourcePurpose、presence、fingerprint 或 redacted 摘要。workflow 必须通过同一 `web-probe observe command` 控制队列支持 `loginAccount``logout``listSessions``switchSessions` 命令;submit 或命令失败必须作为结构化失败解决和上报,不能只写到备注里。
账号切换 report view 至少要给出账号 A/B login/logout 成败、session 列表可见性、切换前后 active session/account id、trace/final 可见性、blocked finding 和同一 observer/run/report SHA。`auth-session-switch-summary` 视图只读取已有 artifact/report/index,不重新访问 Workbench、不保存第二套截图、不打印账号凭据。
多实例 public exposure 复测必须通过受控 `web-probe screenshot` 或沉淀后的 command 远程截图能力完成,并把 PNG 保存到调用者 `/tmp` 下用于人工布局分析。修复 dashboard 布局问题后,复测截图必须覆盖 root dashboard 和至少一个 sentinel route prefix。
## 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)。
@@ -577,3 +606,5 @@ P7 P6 收口必须区分 public dashboard validation 与 targetValidation quick
P8 哨兵恢复执行 issue 为 [#971](https://github.com/pikasTech/unidesk/issues/971)。P8 closeout 必须回写:SPEC P8 引用、120s warning 证据、`quick-verify-no-business-turn` 或等价业务触达证据、`browser-timeout` 分类修正、中文运维页面验证、`monitor.pikapython.com` 公网入口验证、k3s 内部 Service DNS quick verify 路径、D601/v03 用户入口 smoke 结果,以及仍未解除的真实业务 blocker 是否已单独拆出。 P8 哨兵恢复执行 issue 为 [#971](https://github.com/pikasTech/unidesk/issues/971)。P8 closeout 必须回写:SPEC P8 引用、120s warning 证据、`quick-verify-no-business-turn` 或等价业务触达证据、`browser-timeout` 分类修正、中文运维页面验证、`monitor.pikapython.com` 公网入口验证、k3s 内部 Service DNS quick verify 路径、D601/v03 用户入口 smoke 结果,以及仍未解除的真实业务 blocker 是否已单独拆出。
P8-P8 起,targetValidation 的 availability blocker 与 Workbench timing 架构治理必须分层。若同一 trace 在 terminal 前最后一帧仍为 running,随后 terminal commit 的 sealed `durationMs` 把可见 `totalElapsed` 小幅校正到更短值,且 drop 不超过 YAML `turnTimingSampleSlackSeconds`、final response 与完成行均已可见,则该现象作为 terminal-boundary timing correction 证据保留,不生成 `turn-timing-total-elapsed-decrease` red blocker。归零、running/running 下降、terminal 后增长、完成耗时与卡片耗时超出 slack、trace 乱序和完成行非最后仍保持 red;根因治理继续归 [HWLAB #2055](https://github.com/pikasTech/HWLAB/issues/2055) 和 [HWLAB #2125](https://github.com/pikasTech/HWLAB/issues/2125),不得在 UI、CLI renderer 或 analyzer 中做读侧 repair。 P8-P8 起,targetValidation 的 availability blocker 与 Workbench timing 架构治理必须分层。若同一 trace 在 terminal 前最后一帧仍为 running,随后 terminal commit 的 sealed `durationMs` 把可见 `totalElapsed` 小幅校正到更短值,且 drop 不超过 YAML `turnTimingSampleSlackSeconds`、final response 与完成行均已可见,则该现象作为 terminal-boundary timing correction 证据保留,不生成 `turn-timing-total-elapsed-decrease` red blocker。归零、running/running 下降、terminal 后增长、完成耗时与卡片耗时超出 slack、trace 乱序和完成行非最后仍保持 red;根因治理继续归 [HWLAB #2055](https://github.com/pikasTech/HWLAB/issues/2055) 和 [HWLAB #2125](https://github.com/pikasTech/HWLAB/issues/2125),不得在 UI、CLI renderer 或 analyzer 中做读侧 repair。
P9 多实例巡检与账号切换链路执行 issue 为 [#1017](https://github.com/pikasTech/unidesk/issues/1017)。P9 closeout 必须回写:SPEC P9 引用、registry 和两条 sentinel drill-down、旧 dsflash canary 迁移验证、账号切换 workflow/Secret sourceRef 验证、独立 Deployment/PVC/Service/SQLite/GitOps/Argo/public route prefix 证据、submit/command 失败处理、非阻塞计时告警证据、远程 PNG 截图布局复测,以及未完成阶段是否已拆出后续 issue。
@@ -1,4 +1,5 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Browser-side API client, formatting, auto refresh, and base dashboard rendering. // Responsibility: Browser-side API client, formatting, auto refresh, and base dashboard rendering.
// Desktop view redesign (issue #1025): 三栏 master-detail、低噪声、渐进披露、排查直觉化。 // Desktop view redesign (issue #1025): 三栏 master-detail、低噪声、渐进披露、排查直觉化。
// 保持纯 vanilla JS + 原生 CSS,无框架、无构建步骤,所有渲染通过 innerHTML 拼接。 // 保持纯 vanilla JS + 原生 CSS,无框架、无构建步骤,所有渲染通过 innerHTML 拼接。
@@ -15,6 +16,7 @@
*/ */
const root = document.getElementById("sentinel-dashboard"); const root = document.getElementById("sentinel-dashboard");
const basePath = normalizeBasePath(root?.dataset?.basePath || "");
const refs = { const refs = {
statusPill: document.getElementById("status-pill"), statusPill: document.getElementById("status-pill"),
loadingBanner: document.getElementById("loading-banner"), loadingBanner: document.getElementById("loading-banner"),
@@ -178,21 +180,31 @@ loadDashboard({ silent: false }).catch((error) => renderError(error));
function createDashboardApi() { function createDashboardApi() {
return { return {
/** @returns {Promise<SentinelOverview>} */ /** @returns {Promise<SentinelOverview>} */
overview: () => getJson("/api/overview"), overview: () => getJson(apiPath("/api/overview")),
/** @returns {Promise<SentinelRunsResponse>} */ /** @returns {Promise<SentinelRunsResponse>} */
runs: (filters) => getJson(`/api/runs?${runsQuery(filters)}`), runs: (filters) => getJson(apiPath(`/api/runs?${runsQuery(filters)}`)),
/** @returns {Promise<SentinelFindingsResponse>} */ /** @returns {Promise<SentinelFindingsResponse>} */
findings: (filters) => getJson(`/api/findings?${findingsQuery(filters)}`), findings: (filters) => getJson(apiPath(`/api/findings?${findingsQuery(filters)}`)),
/** @returns {Promise<SentinelRunDetail>} */ /** @returns {Promise<SentinelRunDetail>} */
runDetail: (runId) => getJson(`/api/runs/${encodeURIComponent(runId)}`), runDetail: (runId) => getJson(apiPath(`/api/runs/${encodeURIComponent(runId)}`)),
runViews: (runId, view = null) => { runViews: (runId, view = null) => {
const query = new URLSearchParams({ maxBytes: "24000" }); const query = new URLSearchParams({ maxBytes: "24000" });
if (view) query.set("view", view); if (view) query.set("view", view);
return getJson(`/api/runs/${encodeURIComponent(runId)}/views?${query.toString()}`); return getJson(apiPath(`/api/runs/${encodeURIComponent(runId)}/views?${query.toString()}`));
}, },
}; };
} }
function apiPath(path) {
return `${basePath}${path}`;
}
function normalizeBasePath(value) {
const text = String(value || "").replace(/\/+$/u, "");
if (!text || text === "/") return "";
return text.startsWith("/") ? text : `/${text}`;
}
async function getJson(path) { async function getJson(path) {
const response = await fetch(path, { headers: { accept: "application/json" } }); const response = await fetch(path, { headers: { accept: "application/json" } });
const body = await response.json().catch(() => ({})); const body = await response.json().catch(() => ({}));
+4 -1
View File
@@ -1,4 +1,5 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. // SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Help payloads for HWLAB node/lane and web-probe CLI entries. // Responsibility: Help payloads for HWLAB node/lane and web-probe CLI entries.
import { hwlabRuntimeLaneConfigPath } from "./hwlab-node-lanes"; import { hwlabRuntimeLaneConfigPath } from "./hwlab-node-lanes";
@@ -59,7 +60,8 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"bun scripts/cli.ts web-probe observe collect webobs-xxxx --view project-mdtodo-summary", "bun scripts/cli.ts web-probe observe collect webobs-xxxx --view project-mdtodo-summary",
"bun scripts/cli.ts web-probe observe analyze webobs-xxxx", "bun scripts/cli.ts web-probe observe analyze webobs-xxxx",
"bun scripts/cli.ts web-probe sentinel plan --node D601 --lane v03 --dry-run", "bun scripts/cli.ts web-probe sentinel plan --node D601 --lane v03 --dry-run",
"bun scripts/cli.ts web-probe sentinel maintenance stop --node D601 --lane v03 --confirm --wait --release-id <id>", "bun scripts/cli.ts web-probe sentinel plan --node D601 --lane v03 --sentinel workbench-auth-session-switch-2users",
"bun scripts/cli.ts web-probe sentinel maintenance stop --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x --confirm --wait --release-id <id>",
], ],
actions: { actions: {
run: "Run the repo-owned scripts/web-live-dom-probe.mjs helper.", run: "Run the repo-owned scripts/web-live-dom-probe.mjs helper.",
@@ -75,6 +77,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"After observe start, prefer observe status|command|stop|collect|analyze <id> instead of repeating --node/--lane/--state-dir.", "After observe start, prefer observe status|command|stop|collect|analyze <id> instead of repeating --node/--lane/--state-dir.",
"collect views render bounded summaries from existing artifacts and do not create a second source of truth.", "collect views render bounded summaries from existing artifacts and do not create a second source of truth.",
"analyze is offline-only: it reads artifact JSONL and writes analysis/report.md plus analysis/report.json.", "analyze is offline-only: it reads artifact JSONL and writes analysis/report.md plus analysis/report.json.",
"When multiple web-probe sentinels are declared, sentinel image/control-plane/validate/maintenance/report require `--sentinel <id>`; plan/status without it show the registry drill-down.",
"Issue evidence should cite observer id, stateDir, report SHA, screenshot SHA, command ids and concise summaries, not prompt/provider/secret payloads.", "Issue evidence should cite observer id, stateDir, report SHA, screenshot SHA, command ids and concise summaries, not prompt/provider/secret payloads.",
], ],
}; };
+41 -2
View File
@@ -1,5 +1,6 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. // SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets. // SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets.
// Responsibility: YAML source-of-truth parsing for HWLAB node/lane Workbench observability. // Responsibility: YAML source-of-truth parsing for HWLAB node/lane Workbench observability.
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
@@ -149,6 +150,12 @@ export interface HwlabRuntimeWebProbeSentinelSpec {
readonly configRefs: Record<HwlabRuntimeWebProbeSentinelConfigRefKey, string>; readonly configRefs: Record<HwlabRuntimeWebProbeSentinelConfigRefKey, string>;
} }
export interface HwlabRuntimeWebProbeSentinelRegistryItemSpec {
readonly id: string;
readonly enabled: boolean;
readonly configRef: string;
}
export interface HwlabRuntimeWebProbeAlertThresholdsSpec { export interface HwlabRuntimeWebProbeAlertThresholdsSpec {
readonly sameOriginApiSlowMs: number; readonly sameOriginApiSlowMs: number;
readonly partialApiSlowMs: number; readonly partialApiSlowMs: number;
@@ -189,6 +196,7 @@ export interface HwlabRuntimeObservabilitySpec {
export interface HwlabRuntimeObservabilityWebProbeSpec { export interface HwlabRuntimeObservabilityWebProbeSpec {
readonly sentinel?: HwlabRuntimeWebProbeSentinelSpec; readonly sentinel?: HwlabRuntimeWebProbeSentinelSpec;
readonly sentinels?: readonly HwlabRuntimeWebProbeSentinelRegistryItemSpec[];
} }
export interface HwlabRuntimeObservabilityMetricsEndpointSpec { export interface HwlabRuntimeObservabilityMetricsEndpointSpec {
@@ -793,6 +801,35 @@ function webProbeSentinelConfig(value: unknown, path: string): HwlabRuntimeWebPr
}; };
} }
function webProbeSentinelRegistryItemConfig(value: unknown, path: string): HwlabRuntimeWebProbeSentinelRegistryItemSpec {
const raw = asRecord(value, path);
const allowed = new Set(["id", "enabled", "configRef"]);
for (const key of Object.keys(raw)) {
if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed; sentinel registry items may only contain id/enabled/configRef`);
}
const id = stringField(raw, "id", path);
if (!/^[a-z0-9][a-z0-9-]{1,80}$/u.test(id)) throw new Error(`${path}.id must be a stable lowercase sentinel id`);
const configRef = stringField(raw, "configRef", path);
validateConfigRef(configRef, `${path}.configRef`);
return {
id,
enabled: booleanField(raw, "enabled", path),
configRef,
};
}
function webProbeSentinelRegistryConfig(value: unknown, path: string): readonly HwlabRuntimeWebProbeSentinelRegistryItemSpec[] {
if (!Array.isArray(value)) throw new Error(`${path} must be an array`);
if (value.length === 0) throw new Error(`${path} must contain at least one sentinel`);
const items = value.map((item, index) => webProbeSentinelRegistryItemConfig(item, `${path}[${index}]`));
const ids = new Set<string>();
for (const item of items) {
if (ids.has(item.id)) throw new Error(`${path} contains duplicate sentinel id ${item.id}`);
ids.add(item.id);
}
return items;
}
function validateConfigRef(ref: string, path: string): void { function validateConfigRef(ref: string, path: string): void {
const [file, fragment, extra] = ref.split("#"); const [file, fragment, extra] = ref.split("#");
if (extra !== undefined || file === undefined || fragment === undefined || file.length === 0 || fragment.length === 0) { if (extra !== undefined || file === undefined || fragment === undefined || file.length === 0 || fragment.length === 0) {
@@ -937,12 +974,14 @@ function observabilityConfig(value: unknown, path: string): HwlabRuntimeObservab
function observabilityWebProbeConfig(value: unknown, path: string): HwlabRuntimeObservabilityWebProbeSpec | undefined { function observabilityWebProbeConfig(value: unknown, path: string): HwlabRuntimeObservabilityWebProbeSpec | undefined {
if (value === undefined) return undefined; if (value === undefined) return undefined;
const raw = asRecord(value, path); const raw = asRecord(value, path);
const allowed = new Set(["sentinel"]); const allowed = new Set(["sentinel", "sentinels"]);
for (const key of Object.keys(raw)) { for (const key of Object.keys(raw)) {
if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed; observability.webProbe currently only owns sentinel`); if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed; observability.webProbe currently only owns sentinel/sentinels`);
} }
if (raw.sentinel !== undefined && raw.sentinels !== undefined) throw new Error(`${path} may declare sentinel or sentinels, not both`);
return { return {
...(raw.sentinel === undefined ? {} : { sentinel: webProbeSentinelConfig(raw.sentinel, `${path}.sentinel`) }), ...(raw.sentinel === undefined ? {} : { sentinel: webProbeSentinelConfig(raw.sentinel, `${path}.sentinel`) }),
...(raw.sentinels === undefined ? {} : { sentinels: webProbeSentinelRegistryConfig(raw.sentinels, `${path}.sentinels`) }),
}; };
} }
@@ -1,4 +1,5 @@
// SPEC: PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer. // SPEC: PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Source string for the pure-client HWLAB web-probe observer runner. // Responsibility: Source string for the pure-client HWLAB web-probe observer runner.
import { nodeWebObserveRunnerCommandActionsSource } from "./hwlab-node-web-observe-runner-actions-source"; import { nodeWebObserveRunnerCommandActionsSource } from "./hwlab-node-web-observe-runner-actions-source";
@@ -340,6 +341,10 @@ async function processCommand(command) {
await appendJsonl(files.control, controlRecord(command, "started", commandInputSummary(command))); await appendJsonl(files.control, controlRecord(command, "started", commandInputSummary(command)));
switch (command.type) { switch (command.type) {
case "login": return authenticate(context, page); case "login": return authenticate(context, page);
case "loginAccount": return withObserverSync(await loginAccount(command), "loginAccount");
case "logout": return withObserverSync(await logoutAccount(command), "logout");
case "listSessions": return withObserverSync(await listSessions(command), "listSessions");
case "switchSessions": return withObserverSync(await switchSessions(command), "switchSessions");
case "preflight": return preflightSummary(); case "preflight": return preflightSummary();
case "goto": return withObserverSync(await gotoTarget(command.path || command.url || targetPath), "goto"); case "goto": return withObserverSync(await gotoTarget(command.path || command.url || targetPath), "goto");
case "newSession": return withObserverSync(await createSessionFromUi(), "newSession"); case "newSession": return withObserverSync(await createSessionFromUi(), "newSession");
@@ -592,7 +597,7 @@ async function authenticate(browserContext, authPage) {
throw error; throw error;
} }
async function pageAuthLogin(authPage, loginUrl) { async function pageAuthLogin(authPage, loginUrl, credential = { username, password }) {
if (!authPage) throw new Error("auth page is not ready"); if (!authPage) throw new Error("auth page is not ready");
await authPage.goto(new URL("/assets/favicon.svg", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 12000 }); await authPage.goto(new URL("/assets/favicon.svg", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 12000 });
return authPage.evaluate(async (input) => { return authPage.evaluate(async (input) => {
@@ -608,7 +613,156 @@ async function pageAuthLogin(authPage, loginUrl) {
status: response.status, status: response.status,
statusText: response.statusText || "", statusText: response.statusText || "",
}; };
}, { username, password, loginUrl }); }, { username: credential.username, password: credential.password, loginUrl });
}
async function loginAccount(command) {
const accountId = requiredAccountId(command, ["accountId", "account", "value", "text"]);
const credential = credentialForAccount(accountId);
const loginUrl = new URL("/auth/login", baseUrl).toString();
const before = await accountSessionSnapshot();
const response = await pageAuthLogin(page, loginUrl, credential);
const cookieState = await readAuthCookieState(context);
if (!response.ok || !cookieState.cookiePresent) {
const error = new Error("loginAccount failed for accountId=" + accountId + " status=" + response.status + " " + (response.statusText || ""));
error.details = { accountId, status: response.status, statusText: response.statusText, cookiePresent: cookieState.cookiePresent, credentialSource: credential.source, valuesRedacted: true };
throw error;
}
const target = isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "") ? safeUrlPath(currentPageUrl()) : targetPath;
const navigation = await gotoTarget(target || targetPath);
const after = await accountSessionSnapshot();
return { ok: true, type: "loginAccount", accountId, credentialSource: credential.source, before, after, navigation, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true };
}
async function logoutAccount(command = {}) {
const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null;
const before = await accountSessionSnapshot();
const logoutUrl = new URL("/logout", baseUrl).toString();
const response = await page.evaluate(async (input) => {
const res = await fetch(input.logoutUrl, { method: "POST", headers: { accept: "application/json" }, credentials: "include" });
await res.text().catch(() => "");
return { ok: res.ok, status: res.status, statusText: res.statusText || "" };
}, { logoutUrl });
await context.clearCookies().catch(() => {});
const cookieState = await readAuthCookieState(context);
const afterUrl = await page.goto(new URL("/auth/login", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 15000 }).then(() => currentPageUrl()).catch(() => currentPageUrl());
const result = { ok: response.ok || response.status === 401 || !cookieState.cookiePresent, type: "logout", accountId, status: response.status, statusText: response.statusText, before, after: { url: afterUrl, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true }, valuesRedacted: true };
if (!result.ok) {
const error = new Error("logout failed status=" + response.status + " " + (response.statusText || ""));
error.details = result;
throw error;
}
return result;
}
async function listSessions(command = {}) {
const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null;
if (!isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "")) await gotoTarget(targetPath);
const snapshot = await workbenchSessionSnapshot();
const sessions = await page.evaluate(() => {
const seen = new Set();
const rows = [];
for (const element of Array.from(document.querySelectorAll("[data-session-id], .session-tab, a[href*='/workbench/sessions/']"))) {
const sessionId = element.getAttribute("data-session-id") || (element.getAttribute("href") || "").match(/\/workbench\/sessions\/([^/?#]+)/)?.[1] || "";
if (!sessionId || seen.has(sessionId)) continue;
seen.add(sessionId);
rows.push({
sessionId,
active: element.getAttribute("data-active") === "true" || element.getAttribute("aria-selected") === "true",
status: element.getAttribute("data-status") || null,
conversationId: element.getAttribute("data-conversation-id") || null,
});
}
return rows.slice(0, 50);
}).catch(() => []);
return { ok: true, type: "listSessions", accountId, sessionCount: sessions.length, activeSessionId: snapshot?.activeSessionId || snapshot?.routeSessionId || null, sessions, snapshot, valuesRedacted: true };
}
async function switchSessions(command) {
const fromAccountId = commandValue(command, ["fromAccountId", "fromAccount", "accountId"]);
const toAccountId = requiredAccountId(command, ["toAccountId", "toAccount", "value", "text"]);
const before = await accountSessionSnapshot();
if (fromAccountId) {
const beforeAccount = before.accountId || null;
await appendJsonl(files.control, eventRecord("switchSessions-from-account", { fromAccountId, observedAccountId: beforeAccount, valuesRedacted: true }));
}
const logout = await logoutAccount({ ...command, accountId: fromAccountId || null });
const login = await loginAccount({ ...command, accountId: toAccountId });
const sessions = await listSessions({ ...command, accountId: toAccountId });
return { ok: login.ok === true && sessions.ok === true, type: "switchSessions", fromAccountId: fromAccountId || null, toAccountId, before, logout, login, sessions, valuesRedacted: true };
}
async function accountSessionSnapshot() {
const cookieState = await readAuthCookieState(context);
const workbench = await workbenchSessionSnapshot().catch(() => null);
return {
url: currentPageUrl(),
path: safeUrlPath(currentPageUrl()),
cookiePresent: cookieState.cookiePresent,
cookieNames: cookieState.cookieNames,
activeSessionId: workbench?.activeSessionId || null,
routeSessionId: workbench?.routeSessionId || null,
tabCount: workbench?.tabCount ?? null,
messageCount: workbench?.messageCount ?? null,
valuesRedacted: true,
};
}
function requiredAccountId(command, keys) {
const accountId = commandValue(command, keys);
if (!isSafeAccountId(accountId)) throw new Error(command.type + " requires --account-id using lowercase account id");
return accountId;
}
function credentialForAccount(accountId) {
if (accountId === "bootstrap-admin" || accountId === "admin") {
if (!password) throw new Error("loginAccount accountId=" + accountId + " missing HWLAB_WEB_PASS");
return { username, password, source: "HWLAB_WEB_USER/HWLAB_WEB_PASS", valuesRedacted: true };
}
const env = accountCredentialEnvCandidates(accountId);
for (const jsonKey of env.jsonKeys) {
const raw = process.env[jsonKey];
if (!raw) continue;
const parsed = parseCredentialJson(raw);
if (parsed !== null) return { ...parsed, source: jsonKey, valuesRedacted: true };
}
for (const pair of env.pairs) {
const user = process.env[pair.userKey];
const pass = process.env[pair.passKey];
if (user && pass) return { username: user, password: pass, source: pair.userKey + "/" + pair.passKey, valuesRedacted: true };
}
throw new Error("loginAccount missing credential material for accountId=" + accountId + "; expected one of " + [...env.jsonKeys, ...env.pairs.flatMap((item) => [item.userKey, item.passKey])].join(","));
}
function accountCredentialEnvCandidates(accountId) {
const segment = accountId.toUpperCase().replace(/[^A-Z0-9]+/gu, "_").replace(/^_+|_+$/gu, "");
return {
jsonKeys: [
"HWLAB_WEB_" + segment + "_JSON",
"HWLAB_WEB_ACCOUNT_" + segment + "_JSON",
],
pairs: [
{ userKey: "HWLAB_WEB_" + segment + "_USER", passKey: "HWLAB_WEB_" + segment + "_PASS" },
{ userKey: "HWLAB_WEB_ACCOUNT_" + segment + "_USER", passKey: "HWLAB_WEB_ACCOUNT_" + segment + "_PASS" },
],
};
}
function parseCredentialJson(raw) {
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const user = typeof parsed.username === "string" ? parsed.username : typeof parsed.user === "string" ? parsed.user : typeof parsed.email === "string" ? parsed.email : "";
const pass = typeof parsed.password === "string" ? parsed.password : typeof parsed.pass === "string" ? parsed.pass : "";
if (!user || !pass) return null;
return { username: user, password: pass, valuesRedacted: true };
} catch {
return null;
}
}
function isSafeAccountId(value) {
return /^[a-z0-9][a-z0-9-]{1,80}$/u.test(String(value || ""));
} }
function publicAuth(value) { function publicAuth(value) {
+138 -46
View File
@@ -1,5 +1,6 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p8-web-probe-sentinel-recovery. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p8-web-probe-sentinel-recovery.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: YAML-first CI/CD, image, GitOps and Argo command plan for the web-probe sentinel. // 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";
@@ -8,6 +9,7 @@ import { repoRoot, rootPath } from "./config";
import { runCommand, type CommandResult } from "./command"; import { runCommand, type CommandResult } from "./command";
import { startJob } from "./jobs"; import { startJob } from "./jobs";
import { webProbeSentinelConfigPlan, withWebProbeSentinelConfigRendered } from "./hwlab-node-web-sentinel-config"; import { webProbeSentinelConfigPlan, withWebProbeSentinelConfigRendered } from "./hwlab-node-web-sentinel-config";
import { requireSentinelIdForRegistry, resolveWebProbeSentinel } from "./hwlab-node-web-sentinel-resolver";
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes"; import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
import type { RenderedCliResult } from "./output"; import type { RenderedCliResult } from "./output";
@@ -15,7 +17,7 @@ export type WebProbeSentinelConfigAction = "plan" | "status";
export type WebProbeSentinelImageAction = "status" | "build"; export type WebProbeSentinelImageAction = "status" | "build";
export type WebProbeSentinelControlPlaneAction = "plan" | "apply" | "status" | "trigger-current"; export type WebProbeSentinelControlPlaneAction = "plan" | "apply" | "status" | "trigger-current";
export type WebProbeSentinelMaintenanceAction = "status" | "start" | "stop"; export type WebProbeSentinelMaintenanceAction = "status" | "start" | "stop";
export type WebProbeSentinelReportView = "summary" | "turn-summary" | "findings" | "trace-frame"; export type WebProbeSentinelReportView = "summary" | "turn-summary" | "findings" | "trace-frame" | "auth-session-switch-summary";
export type WebProbeSentinelOptions = export type WebProbeSentinelOptions =
| { | {
@@ -23,6 +25,7 @@ export type WebProbeSentinelOptions =
readonly action: WebProbeSentinelConfigAction; readonly action: WebProbeSentinelConfigAction;
readonly node: string; readonly node: string;
readonly lane: string; readonly lane: string;
readonly sentinelId: string | null;
readonly dryRun: boolean; readonly dryRun: boolean;
} }
| { | {
@@ -30,6 +33,7 @@ export type WebProbeSentinelOptions =
readonly action: WebProbeSentinelImageAction; readonly action: WebProbeSentinelImageAction;
readonly node: string; readonly node: string;
readonly lane: string; readonly lane: string;
readonly sentinelId: string | null;
readonly dryRun: boolean; readonly dryRun: boolean;
readonly confirm: boolean; readonly confirm: boolean;
readonly wait: boolean; readonly wait: boolean;
@@ -40,6 +44,7 @@ export type WebProbeSentinelOptions =
readonly action: WebProbeSentinelControlPlaneAction; readonly action: WebProbeSentinelControlPlaneAction;
readonly node: string; readonly node: string;
readonly lane: string; readonly lane: string;
readonly sentinelId: string | null;
readonly dryRun: boolean; readonly dryRun: boolean;
readonly confirm: boolean; readonly confirm: boolean;
readonly wait: boolean; readonly wait: boolean;
@@ -50,6 +55,7 @@ export type WebProbeSentinelOptions =
readonly action: WebProbeSentinelMaintenanceAction; readonly action: WebProbeSentinelMaintenanceAction;
readonly node: string; readonly node: string;
readonly lane: string; readonly lane: string;
readonly sentinelId: string | null;
readonly dryRun: boolean; readonly dryRun: boolean;
readonly confirm: boolean; readonly confirm: boolean;
readonly wait: boolean; readonly wait: boolean;
@@ -63,6 +69,7 @@ export type WebProbeSentinelOptions =
readonly action: "validate"; readonly action: "validate";
readonly node: string; readonly node: string;
readonly lane: string; readonly lane: string;
readonly sentinelId: string | null;
readonly dryRun: boolean; readonly dryRun: boolean;
readonly confirm: boolean; readonly confirm: boolean;
readonly wait: boolean; readonly wait: boolean;
@@ -74,6 +81,7 @@ export type WebProbeSentinelOptions =
readonly action: "report"; readonly action: "report";
readonly node: string; readonly node: string;
readonly lane: string; readonly lane: string;
readonly sentinelId: string | null;
readonly view: WebProbeSentinelReportView; readonly view: WebProbeSentinelReportView;
readonly runId: string | null; readonly runId: string | null;
readonly latest: boolean; readonly latest: boolean;
@@ -85,6 +93,8 @@ export type WebProbeSentinelOptions =
interface SentinelCicdState { interface SentinelCicdState {
readonly spec: HwlabRuntimeLaneSpec; readonly spec: HwlabRuntimeLaneSpec;
readonly sentinelId: string;
readonly configRefs: Record<string, string>;
readonly configReady: boolean; readonly configReady: boolean;
readonly runtime: Record<string, unknown>; readonly runtime: Record<string, unknown>;
readonly cicd: Record<string, unknown>; readonly cicd: Record<string, unknown>;
@@ -163,8 +173,9 @@ interface ChildCliResult {
const SPEC_REF = "PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel"; const SPEC_REF = "PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel";
export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options: WebProbeSentinelOptions): RenderedCliResult { export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options: WebProbeSentinelOptions): RenderedCliResult {
if (options.kind === "config") return withWebProbeSentinelConfigRendered(webProbeSentinelConfigPlan(spec, options.action)); if (options.kind === "config") return withWebProbeSentinelConfigRendered(webProbeSentinelConfigPlan(spec, options.action, options.sentinelId));
const state = loadSentinelCicdState(spec, options.timeoutSeconds); requireSentinelIdForRegistry(spec, options.sentinelId, `web-probe sentinel ${options.kind}`);
const state = loadSentinelCicdState(spec, options.sentinelId, options.timeoutSeconds);
if (options.kind === "image") return runSentinelImage(state, options); if (options.kind === "image") return runSentinelImage(state, options);
if (options.kind === "control-plane") return runSentinelControlPlane(state, options); if (options.kind === "control-plane") return runSentinelControlPlane(state, options);
if (options.kind === "maintenance") return runSentinelMaintenance(state, options); if (options.kind === "maintenance") return runSentinelMaintenance(state, options);
@@ -187,6 +198,7 @@ function runSentinelImage(state: SentinelCicdState, options: Extract<WebProbeSen
command, command,
node: state.spec.nodeId, node: state.spec.nodeId,
lane: state.spec.lane, lane: state.spec.lane,
sentinelId: state.sentinelId,
mode: options.action === "status" ? "status" : options.confirm ? "confirm" : "dry-run", mode: options.action === "status" ? "status" : options.confirm ? "confirm" : "dry-run",
mutation: false, mutation: false,
specRef: SPEC_REF, specRef: SPEC_REF,
@@ -198,10 +210,10 @@ function runSentinelImage(state: SentinelCicdState, options: Extract<WebProbeSen
? registryReady ? null : { code: "sentinel-image-missing", reason: "expected sentinel image tag is not present in the node-local registry" } ? registryReady ? null : { code: "sentinel-image-missing", reason: "expected sentinel image tag is not present in the node-local registry" }
: { code: "sentinel-source-mirror-not-ready", reason: "source.gitMirrorReadUrl does not expose the selected source commit yet" }, : { code: "sentinel-source-mirror-not-ready", reason: "source.gitMirrorReadUrl does not expose the selected source commit yet" },
next: { next: {
status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}`, status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`,
dryRun: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane} --dry-run`, dryRun: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --dry-run`,
confirm: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane} --confirm`, confirm: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --confirm`,
controlPlanePlan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${state.spec.nodeId} --lane ${state.spec.lane} --dry-run`, controlPlanePlan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --dry-run`,
}, },
valuesRedacted: true, valuesRedacted: true,
}; };
@@ -223,6 +235,7 @@ function runSentinelControlPlane(state: SentinelCicdState, options: Extract<WebP
command, command,
node: state.spec.nodeId, node: state.spec.nodeId,
lane: state.spec.lane, lane: state.spec.lane,
sentinelId: state.sentinelId,
mode: options.action === "status" ? "status" : options.confirm ? "confirm" : "dry-run", mode: options.action === "status" ? "status" : options.confirm ? "confirm" : "dry-run",
mutation: false, mutation: false,
specRef: SPEC_REF, specRef: SPEC_REF,
@@ -262,13 +275,13 @@ function runSentinelControlPlane(state: SentinelCicdState, options: Extract<WebP
return rendered(result.ok, command, renderControlPlaneResult(result)); return rendered(result.ok, command, renderControlPlaneResult(result));
} }
function loadSentinelCicdState(spec: HwlabRuntimeLaneSpec, timeoutSeconds: number): SentinelCicdState { function loadSentinelCicdState(spec: HwlabRuntimeLaneSpec, sentinelId: string | null, timeoutSeconds: number): SentinelCicdState {
const sentinel = spec.observability.webProbe?.sentinel; const sentinel = resolveWebProbeSentinel(spec, sentinelId);
if (sentinel === undefined) throw new Error(`config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel is missing`); const configPlan = webProbeSentinelConfigPlan(spec, "status", sentinel.id);
const configPlan = webProbeSentinelConfigPlan(spec, "status");
const runtime = recordTarget(readConfigRefTarget(sentinel.configRefs.runtime), sentinel.configRefs.runtime); const runtime = recordTarget(readConfigRefTarget(sentinel.configRefs.runtime), sentinel.configRefs.runtime);
const cicd = recordTarget(readConfigRefTarget(sentinel.configRefs.cicd), sentinel.configRefs.cicd); const cicd = recordTarget(readConfigRefTarget(sentinel.configRefs.cicd), sentinel.configRefs.cicd);
const publicExposure = recordTarget(readConfigRefTarget(sentinel.configRefs.publicExposure), sentinel.configRefs.publicExposure); const publicExposure = recordTarget(readConfigRefTarget(sentinel.configRefs.publicExposure), sentinel.configRefs.publicExposure);
const secrets = recordTarget(readConfigRefTarget(sentinel.configRefs.secrets), sentinel.configRefs.secrets);
const controlPlaneRef = stringField(cicd, "controlPlaneConfigRef"); const controlPlaneRef = stringField(cicd, "controlPlaneConfigRef");
const controlPlaneTarget = recordTarget(readConfigRefTarget(controlPlaneRef), controlPlaneRef); const controlPlaneTarget = recordTarget(readConfigRefTarget(controlPlaneRef), controlPlaneRef);
const controlPlaneConfig = recordTarget(readConfigFile(configRefFile(controlPlaneRef)), configRefFile(controlPlaneRef)); const controlPlaneConfig = recordTarget(readConfigFile(configRefFile(controlPlaneRef)), configRefFile(controlPlaneRef));
@@ -276,10 +289,12 @@ function loadSentinelCicdState(spec: HwlabRuntimeLaneSpec, timeoutSeconds: numbe
const controlPlaneNode = recordTarget(valueAtPath(controlPlaneConfig, `nodes.${nodeId}`), `${configRefFile(controlPlaneRef)}#nodes.${nodeId}`); const controlPlaneNode = recordTarget(valueAtPath(controlPlaneConfig, `nodes.${nodeId}`), `${configRefFile(controlPlaneRef)}#nodes.${nodeId}`);
const sourceHead = resolveSourceHead(cicd, timeoutSeconds); const sourceHead = resolveSourceHead(cicd, timeoutSeconds);
const image = sentinelImagePlan(cicd, sourceHead); const image = sentinelImagePlan(cicd, sourceHead);
const manifests = renderSentinelManifests(spec, runtime, cicd, publicExposure, image); const manifests = renderSentinelManifests(spec, sentinel.id, runtime, cicd, publicExposure, secrets, image);
const manifestYaml = `${manifests.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`; const manifestYaml = `${manifests.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`;
return { return {
spec, spec,
sentinelId: sentinel.id,
configRefs: sentinel.configRefs,
configReady: configPlan.ok, configReady: configPlan.ok,
runtime, runtime,
cicd, cicd,
@@ -345,9 +360,11 @@ function sentinelDockerfile(baseImage: string, entrypoint: string): string {
function renderSentinelManifests( function renderSentinelManifests(
spec: HwlabRuntimeLaneSpec, spec: HwlabRuntimeLaneSpec,
sentinelId: string,
runtime: Record<string, unknown>, runtime: Record<string, unknown>,
cicd: Record<string, unknown>, cicd: Record<string, unknown>,
publicExposure: Record<string, unknown>, publicExposure: Record<string, unknown>,
secrets: Record<string, unknown>,
image: SentinelImagePlan, image: SentinelImagePlan,
): readonly Record<string, unknown>[] { ): readonly Record<string, unknown>[] {
const namespace = stringAt(runtime, "namespace"); const namespace = stringAt(runtime, "namespace");
@@ -358,12 +375,14 @@ function renderSentinelManifests(
"unidesk.ai/spec-ref": "PJ2026-01060508", "unidesk.ai/spec-ref": "PJ2026-01060508",
"unidesk.ai/node": spec.nodeId, "unidesk.ai/node": spec.nodeId,
"unidesk.ai/lane": spec.lane, "unidesk.ai/lane": spec.lane,
"unidesk.ai/web-probe-sentinel-id": sentinelId,
}; };
const deploymentName = stringAt(runtime, "deploymentName"); const deploymentName = stringAt(runtime, "deploymentName");
const serviceName = stringAt(runtime, "serviceName"); const serviceName = stringAt(runtime, "serviceName");
const servicePort = numberAt(runtime, "servicePort"); const servicePort = numberAt(runtime, "servicePort");
const pvcStorage = stringAt(runtime, "pvcStorage"); const pvcStorage = stringAt(runtime, "pvcStorage");
const stateRoot = stringAt(runtime, "stateRoot"); const stateRoot = stringAt(runtime, "stateRoot");
const sentinelEnv = sentinelContainerEnv(sentinelId, secrets);
return [ return [
{ {
apiVersion: "v1", apiVersion: "v1",
@@ -385,7 +404,9 @@ function renderSentinelManifests(
specRef: SPEC_REF, specRef: SPEC_REF,
node: spec.nodeId, node: spec.nodeId,
lane: spec.lane, lane: spec.lane,
sentinelId,
publicBaseUrl: stringAt(publicExposure, "publicBaseUrl"), publicBaseUrl: stringAt(publicExposure, "publicBaseUrl"),
routePrefix: stringAtNullable(publicExposure, "routePrefix") ?? "/",
gitopsPath: stringAt(cicd, "gitopsPath"), gitopsPath: stringAt(cicd, "gitopsPath"),
valuesRedacted: true, valuesRedacted: true,
}, null, 2), }, null, 2),
@@ -411,6 +432,8 @@ function renderSentinelManifests(
spec.nodeId, spec.nodeId,
"--lane", "--lane",
spec.lane, spec.lane,
"--sentinel",
sentinelId,
"--state-root", "--state-root",
stateRoot, stateRoot,
"--host", "--host",
@@ -418,6 +441,7 @@ function renderSentinelManifests(
"--port", "--port",
String(servicePort), String(servicePort),
], ],
env: sentinelEnv,
ports: [{ name: "http", containerPort: servicePort }], ports: [{ name: "http", containerPort: servicePort }],
readinessProbe: { httpGet: { path: stringAt(runtime, "healthPath"), port: "http" } }, readinessProbe: { httpGet: { path: stringAt(runtime, "healthPath"), port: "http" } },
livenessProbe: { httpGet: { path: stringAt(runtime, "healthPath"), port: "http" } }, livenessProbe: { httpGet: { path: stringAt(runtime, "healthPath"), port: "http" } },
@@ -492,6 +516,34 @@ function renderSentinelManifests(
]; ];
} }
function sentinelContainerEnv(sentinelId: string, secrets: Record<string, unknown>): readonly Record<string, unknown>[] {
const env: Record<string, unknown>[] = [{ name: "UNIDESK_WEB_PROBE_SENTINEL_ID", value: sentinelId }];
for (const runtimeSecret of arrayAt(secrets, "runtimeSecrets").map(record)) {
const secretName = stringAtNullable(runtimeSecret, "name");
if (secretName === null) continue;
for (const item of arrayAt(runtimeSecret, "data").map(record)) {
const targetKey = stringAtNullable(item, "targetKey");
const sourcePurpose = stringAtNullable(item, "sourcePurpose");
const envName = sourcePurpose === null || targetKey === null ? null : accountSecretEnvName(sourcePurpose, targetKey);
if (envName === null) continue;
env.push({ name: envName, valueFrom: { secretKeyRef: { name: secretName, key: targetKey } } });
}
}
return env;
}
function accountSecretEnvName(sourcePurpose: string, targetKey: string): string | null {
if (!/^account-[a-z0-9-]+$/u.test(sourcePurpose) || !targetKey.endsWith(".json")) return null;
const segment = sourcePurpose.toUpperCase().replace(/[^A-Z0-9]+/gu, "_").replace(/^_+|_+$/gu, "");
return segment.length === 0 ? null : `HWLAB_WEB_${segment}_JSON`;
}
function normalizeRoutePrefix(value: string | null): string {
if (value === null || value.trim() === "" || value.trim() === "/") return "/";
const prefixed = value.trim().startsWith("/") ? value.trim() : `/${value.trim()}`;
return prefixed.replace(/\/+$/u, "") || "/";
}
function probeImageRegistry(state: SentinelCicdState, timeoutSeconds: number): Record<string, unknown> { function probeImageRegistry(state: SentinelCicdState, timeoutSeconds: number): Record<string, unknown> {
const endpoint = stringAt(state.controlPlaneNode, "registry.endpoint"); const endpoint = stringAt(state.controlPlaneNode, "registry.endpoint");
const repoTag = state.image.ref.replace(`${endpoint}/`, ""); const repoTag = state.image.ref.replace(`${endpoint}/`, "");
@@ -555,8 +607,8 @@ function runSentinelImageBuildConfirmed(state: SentinelCicdState, options: Extra
? { code: "sentinel-image-publish-failed", reason: "remote image publish job failed before registry validation" } ? { code: "sentinel-image-publish-failed", reason: "remote image publish job failed before registry validation" }
: { code: "sentinel-image-registry-missing", reason: "image publish completed but expected registry tag is not visible" }, : { code: "sentinel-image-registry-missing", reason: "image publish completed but expected registry tag is not visible" },
next: { next: {
status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}`, status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`,
controlPlaneTrigger: `bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node ${state.spec.nodeId} --lane ${state.spec.lane} --confirm`, controlPlaneTrigger: `bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --confirm`,
}, },
valuesRedacted: true, valuesRedacted: true,
}; };
@@ -663,9 +715,9 @@ function runSentinelControlPlaneConfirmed(state: SentinelCicdState, options: Ext
function renderAsyncSentinelJob(state: SentinelCicdState, domain: "image" | "control-plane", action: string, timeoutSeconds: number): RenderedCliResult { function renderAsyncSentinelJob(state: SentinelCicdState, domain: "image" | "control-plane", action: string, timeoutSeconds: number): RenderedCliResult {
const args = domain === "image" const args = domain === "image"
? ["web-probe", "sentinel", "image", action, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)] ? ["web-probe", "sentinel", "image", action, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--sentinel", state.sentinelId, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)]
: ["web-probe", "sentinel", "control-plane", action, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)]; : ["web-probe", "sentinel", "control-plane", action, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--sentinel", state.sentinelId, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)];
const job = startJob(`hwlab_nodes_${state.spec.lane}_web_probe_sentinel_${domain}_${action}`, ["bun", "scripts/cli.ts", ...args], `Run HWLAB ${state.spec.lane} web-probe sentinel ${domain} ${action} for node ${state.spec.nodeId}`); const job = startJob(`hwlab_nodes_${state.spec.lane}_web_probe_sentinel_${safeJobSegment(state.sentinelId)}_${domain}_${action}`, ["bun", "scripts/cli.ts", ...args], `Run HWLAB ${state.spec.lane} web-probe sentinel ${state.sentinelId} ${domain} ${action} for node ${state.spec.nodeId}`);
const command = `web-probe sentinel ${domain} ${action}`; const command = `web-probe sentinel ${domain} ${action}`;
const result = { const result = {
ok: true, ok: true,
@@ -1415,11 +1467,12 @@ function confirmBlocked(action: string, state: SentinelCicdState): Record<string
function controlPlaneNext(state: SentinelCicdState, action: WebProbeSentinelControlPlaneAction): Record<string, string> { function controlPlaneNext(state: SentinelCicdState, action: WebProbeSentinelControlPlaneAction): Record<string, string> {
const node = state.spec.nodeId; const node = state.spec.nodeId;
const lane = state.spec.lane; const lane = state.spec.lane;
const suffix = sentinelCliSuffix(state);
return { return {
plan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${node} --lane ${lane} --dry-run`, plan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${node} --lane ${lane}${suffix} --dry-run`,
status: `bun scripts/cli.ts web-probe sentinel control-plane status --node ${node} --lane ${lane}`, status: `bun scripts/cli.ts web-probe sentinel control-plane status --node ${node} --lane ${lane}${suffix}`,
image: `bun scripts/cli.ts web-probe sentinel image status --node ${node} --lane ${lane}`, image: `bun scripts/cli.ts web-probe sentinel image status --node ${node} --lane ${lane}${suffix}`,
triggerCurrent: `bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node ${node} --lane ${lane} --dry-run`, triggerCurrent: `bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node ${node} --lane ${lane}${suffix} --dry-run`,
issue: "https://github.com/pikasTech/unidesk/issues/889", issue: "https://github.com/pikasTech/unidesk/issues/889",
currentAction: action, currentAction: action,
}; };
@@ -1586,11 +1639,11 @@ function runSentinelReport(state: SentinelCicdState, options: Extract<WebProbeSe
} }
function renderAsyncP5Job(state: SentinelCicdState, subcommand: readonly string[], timeoutSeconds: number, releaseId: string | null, reason: string | null, quickVerify: boolean): RenderedCliResult { function renderAsyncP5Job(state: SentinelCicdState, subcommand: readonly string[], timeoutSeconds: number, releaseId: string | null, reason: string | null, quickVerify: boolean): RenderedCliResult {
const args = ["web-probe", "sentinel", ...subcommand, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)]; const args = ["web-probe", "sentinel", ...subcommand, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--sentinel", state.sentinelId, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)];
if (releaseId !== null) args.push("--release-id", releaseId); if (releaseId !== null) args.push("--release-id", releaseId);
if (reason !== null) args.push("--reason", reason); if (reason !== null) args.push("--reason", reason);
if (quickVerify) args.push("--quick-verify"); if (quickVerify) args.push("--quick-verify");
const job = startJob(`hwlab_nodes_${state.spec.lane}_web_probe_sentinel_${subcommand.join("_")}`, ["bun", "scripts/cli.ts", ...args], `Run HWLAB ${state.spec.lane} web-probe sentinel ${subcommand.join(" ")} for node ${state.spec.nodeId}`); const job = startJob(`hwlab_nodes_${state.spec.lane}_web_probe_sentinel_${safeJobSegment(state.sentinelId)}_${subcommand.join("_")}`, ["bun", "scripts/cli.ts", ...args], `Run HWLAB ${state.spec.lane} web-probe sentinel ${state.sentinelId} ${subcommand.join(" ")} for node ${state.spec.nodeId}`);
const command = `web-probe sentinel ${subcommand.join(" ")}`; const command = `web-probe sentinel ${subcommand.join(" ")}`;
return rendered(true, command, renderAsyncJobResult({ return rendered(true, command, renderAsyncJobResult({
ok: true, ok: true,
@@ -1615,7 +1668,11 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
const maxSeconds = numberAt(state.cicd, "targetValidation.maxSeconds"); const maxSeconds = numberAt(state.cicd, "targetValidation.maxSeconds");
const scenario = findScenario(state, scenarioId); const scenario = findScenario(state, scenarioId);
if (scenario === null) return { ok: false, status: "blocked", reason: "scenario-not-found", scenarioId, valuesRedacted: true }; if (scenario === null) return { ok: false, status: "blocked", reason: "scenario-not-found", scenarioId, valuesRedacted: true };
const prompts = readPromptSetForScenario(scenario); const commandSequence = arrayAt(scenario, "commandSequence").map(record);
const needsPromptSet = commandSequence.some((item) => stringAt(item, "type") === "sendPrompt");
const prompts = needsPromptSet
? readPromptSetForScenario(scenario)
: { ok: true as const, prompts: [], summary: { source: "not-required", promptCount: 0, valuesRedacted: true } };
if (!prompts.ok) return { ok: false, status: "blocked", reason: "prompt-source-unavailable", promptSource: prompts, valuesRedacted: true }; if (!prompts.ok) return { ok: false, status: "blocked", reason: "prompt-source-unavailable", promptSource: prompts, valuesRedacted: true };
const sampleIntervalMs = numberAt(scenario, "sampleIntervalMs"); const sampleIntervalMs = numberAt(scenario, "sampleIntervalMs");
const budgetSeconds = Math.min(timeoutSeconds, maxSeconds); const budgetSeconds = Math.min(timeoutSeconds, maxSeconds);
@@ -1655,7 +1712,7 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
} }
let promptIndex = 0; let promptIndex = 0;
const sessionInvarianceChecks = sessionInvarianceChecksByRound(scenario); const sessionInvarianceChecks = sessionInvarianceChecksByRound(scenario);
for (const item of arrayAt(scenario, "commandSequence").map(record)) { for (const item of commandSequence) {
const type = stringAt(item, "type"); const type = stringAt(item, "type");
const repeat = Math.max(1, typeof item.repeat === "number" && Number.isFinite(item.repeat) ? Math.trunc(item.repeat) : 1); const repeat = Math.max(1, typeof item.repeat === "number" && Number.isFinite(item.repeat) ? Math.trunc(item.repeat) : 1);
for (let index = 0; index < repeat; index += 1) { for (let index = 0; index < repeat; index += 1) {
@@ -1675,6 +1732,16 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
} }
const args = ["web-probe", "observe", "command", observerId, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--type", type, "--wait-ms", "55000", "--command-timeout-seconds", String(remainingSeconds(deadline, 55))]; const args = ["web-probe", "observe", "command", observerId, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--type", type, "--wait-ms", "55000", "--command-timeout-seconds", String(remainingSeconds(deadline, 55))];
if (type === "selectProvider") args.push("--provider", stringAt(item, "provider")); if (type === "selectProvider") args.push("--provider", stringAt(item, "provider"));
if (type === "loginAccount" || type === "listSessions" || type === "logout") {
const accountId = stringAtNullable(item, "accountId");
if (accountId !== null) args.push("--account-id", accountId);
}
if (type === "switchSessions") {
const fromAccountId = stringAtNullable(item, "fromAccountId");
const toAccountId = stringAtNullable(item, "toAccountId");
if (fromAccountId !== null) args.push("--from-account-id", fromAccountId);
if (toAccountId !== null) args.push("--to-account-id", toAccountId);
}
if (type === "sendPrompt") { if (type === "sendPrompt") {
args.push("--text", prompts.prompts[promptIndex % prompts.prompts.length] ?? ""); args.push("--text", prompts.prompts[promptIndex % prompts.prompts.length] ?? "");
promptIndex += 1; promptIndex += 1;
@@ -2152,16 +2219,29 @@ function applySentinelCaddyBlock(state: SentinelCicdState, timeoutSeconds: numbe
const serviceName = stringAt(state.publicExposure, "caddy.serviceName"); const serviceName = stringAt(state.publicExposure, "caddy.serviceName");
const responseHeaderTimeoutSeconds = numberAt(state.publicExposure, "caddy.responseHeaderTimeoutSeconds"); const responseHeaderTimeoutSeconds = numberAt(state.publicExposure, "caddy.responseHeaderTimeoutSeconds");
const remotePort = numberAt(state.publicExposure, "frpc.httpProxy.remotePort"); const remotePort = numberAt(state.publicExposure, "frpc.httpProxy.remotePort");
const block = [ const routePrefix = normalizeRoutePrefix(stringAtNullable(state.publicExposure, "routePrefix"));
`${hostname} {`, const proxyLines = [
` reverse_proxy 127.0.0.1:${remotePort} {`, `reverse_proxy 127.0.0.1:${remotePort} {`,
" transport http {", " transport http {",
` response_header_timeout ${responseHeaderTimeoutSeconds}s`, ` response_header_timeout ${responseHeaderTimeoutSeconds}s`,
" }",
" }", " }",
"}", "}",
"", ];
].join("\n"); const block = routePrefix === "/"
? [
`${hostname} {`,
...proxyLines.map((line) => ` ${line}`),
"}",
"",
].join("\n")
: [
`${hostname} {`,
` handle_path ${routePrefix}* {`,
...proxyLines.map((line) => ` ${line}`),
" }",
"}",
"",
].join("\n");
const blockB64 = Buffer.from(block, "utf8").toString("base64"); const blockB64 = Buffer.from(block, "utf8").toString("base64");
const script = [ const script = [
"set +e", "set +e",
@@ -2229,7 +2309,7 @@ function applySentinelCaddyBlock(state: SentinelCicdState, timeoutSeconds: numbe
].join("\n"); ].join("\n");
const result = runCommand(["trans", stringAt(state.publicExposure, "caddy.route"), "sh", "--", script], repoRoot, { timeoutMs: Math.min(timeoutSeconds, 60) * 1000 }); const result = runCommand(["trans", stringAt(state.publicExposure, "caddy.route"), "sh", "--", script], repoRoot, { timeoutMs: Math.min(timeoutSeconds, 60) * 1000 });
const parsed = parseJsonObject(result.stdout); const parsed = parseJsonObject(result.stdout);
return { ok: result.exitCode === 0 && parsed?.ok === true, ...record(parsed), result: compactCommand(result), valuesRedacted: true }; return { ok: result.exitCode === 0 && parsed?.ok === true, routePrefix, ...record(parsed), result: compactCommand(result), valuesRedacted: true };
} }
function readAnalysisSummaryFromWorkspace(state: SentinelCicdState, stateDir: string, timeoutSeconds: number): Record<string, unknown> { function readAnalysisSummaryFromWorkspace(state: SentinelCicdState, stateDir: string, timeoutSeconds: number): Record<string, unknown> {
@@ -2487,11 +2567,9 @@ function cliDataPayload(parsed: Record<string, unknown> | null): Record<string,
} }
function findScenario(state: SentinelCicdState, scenarioId: string): Record<string, unknown> | null { function findScenario(state: SentinelCicdState, scenarioId: string): Record<string, unknown> | null {
const sentinel = state.spec.observability.webProbe?.sentinel; const scenarios = readConfigRefTarget(state.configRefs.scenarios);
if (sentinel === undefined) return null; const items = Array.isArray(scenarios) ? scenarios : isRecord(scenarios) ? [scenarios] : [];
const scenarios = readConfigRefTarget(sentinel.configRefs.scenarios); return items.map(record).find((item) => item.id === scenarioId) ?? null;
if (!Array.isArray(scenarios)) return null;
return scenarios.map(record).find((item) => item.id === scenarioId) ?? null;
} }
function readPromptSetForScenario(scenario: Record<string, unknown>): { ok: true; prompts: string[]; summary: Record<string, unknown> } | { ok: false; error: string; summary: Record<string, unknown> } { function readPromptSetForScenario(scenario: Record<string, unknown>): { ok: true; prompts: string[]; summary: Record<string, unknown> } | { ok: false; error: string; summary: Record<string, unknown> } {
@@ -2595,7 +2673,7 @@ function serviceUnavailableBlocker(state: SentinelCicdState): Record<string, unk
code: "sentinel-service-unavailable", code: "sentinel-service-unavailable",
policy: stringAt(state.cicd, "targetValidation.serviceUnavailablePolicy"), policy: stringAt(state.cicd, "targetValidation.serviceUnavailablePolicy"),
reason: "sentinel service must be reachable through k3s internal Service DNS before quick verify can run; no public/fallback path is used.", reason: "sentinel service must be reachable through k3s internal Service DNS before quick verify can run; no public/fallback path is used.",
retry: `bun scripts/cli.ts web-probe sentinel validate --node ${state.spec.nodeId} --lane ${state.spec.lane}`, retry: `bun scripts/cli.ts web-probe sentinel validate --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`,
valuesRedacted: true, valuesRedacted: true,
}; };
} }
@@ -2603,12 +2681,13 @@ function serviceUnavailableBlocker(state: SentinelCicdState): Record<string, unk
function sentinelP5Next(state: SentinelCicdState): Record<string, string> { function sentinelP5Next(state: SentinelCicdState): Record<string, string> {
const node = state.spec.nodeId; const node = state.spec.nodeId;
const lane = state.spec.lane; const lane = state.spec.lane;
const suffix = sentinelCliSuffix(state);
return { return {
validate: `bun scripts/cli.ts web-probe sentinel validate --node ${node} --lane ${lane}`, validate: `bun scripts/cli.ts web-probe sentinel validate --node ${node} --lane ${lane}${suffix}`,
quickVerify: `bun scripts/cli.ts web-probe sentinel validate --node ${node} --lane ${lane} --quick-verify --confirm --wait`, quickVerify: `bun scripts/cli.ts web-probe sentinel validate --node ${node} --lane ${lane}${suffix} --quick-verify --confirm --wait`,
maintenanceStart: `bun scripts/cli.ts web-probe sentinel maintenance start --node ${node} --lane ${lane} --confirm --wait`, maintenanceStart: `bun scripts/cli.ts web-probe sentinel maintenance start --node ${node} --lane ${lane}${suffix} --confirm --wait`,
maintenanceStop: `bun scripts/cli.ts web-probe sentinel maintenance stop --node ${node} --lane ${lane} --confirm --wait`, maintenanceStop: `bun scripts/cli.ts web-probe sentinel maintenance stop --node ${node} --lane ${lane}${suffix} --confirm --wait`,
report: `bun scripts/cli.ts web-probe sentinel report --node ${node} --lane ${lane} --view summary`, report: `bun scripts/cli.ts web-probe sentinel report --node ${node} --lane ${lane}${suffix} --view summary`,
}; };
} }
@@ -2805,7 +2884,20 @@ function renderReportResult(result: Record<string, unknown>): string {
function sentinelPipelineRunName(state: SentinelCicdState): string { function sentinelPipelineRunName(state: SentinelCicdState): string {
const commit = state.sourceHead.commit ?? "source"; const commit = state.sourceHead.commit ?? "source";
return `hwlab-web-probe-sentinel-${commit.slice(0, 12)}`; return `hwlab-web-probe-sentinel-${safeKubernetesSegment(state.sentinelId, 24)}-${commit.slice(0, 12)}`;
}
function sentinelCliSuffix(state: SentinelCicdState): string {
return ` --sentinel ${state.sentinelId}`;
}
function safeJobSegment(value: string): string {
return value.replace(/[^A-Za-z0-9_]+/gu, "_").replace(/^_+|_+$/gu, "").slice(0, 48) || "sentinel";
}
function safeKubernetesSegment(value: string, maxLength: number): string {
const normalized = value.toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/^-+|-+$/gu, "");
return (normalized || "sentinel").slice(0, Math.max(1, maxLength)).replace(/-+$/u, "") || "sentinel";
} }
function renderImageResult(result: Record<string, unknown>): string { function renderImageResult(result: Record<string, unknown>): string {
+77 -28
View File
@@ -1,9 +1,11 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Redacted YAML configRef graph for web-probe sentinel plan/status. // Responsibility: Redacted YAML configRef graph for web-probe sentinel plan/status.
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { rootPath } from "./config"; import { rootPath } from "./config";
import { HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS, type HwlabRuntimeLaneSpec, type HwlabRuntimeWebProbeSentinelConfigRefKey } from "./hwlab-node-lanes"; import { HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS, type HwlabRuntimeLaneSpec, type HwlabRuntimeWebProbeSentinelConfigRefKey } from "./hwlab-node-lanes";
import { resolveWebProbeSentinel, webProbeSentinelRegistryRows, type WebProbeSentinelRegistryRow } from "./hwlab-node-web-sentinel-resolver";
import type { RenderedCliResult } from "./output"; import type { RenderedCliResult } from "./output";
export type WebProbeSentinelConfigAction = "plan" | "status"; export type WebProbeSentinelConfigAction = "plan" | "status";
@@ -15,7 +17,9 @@ export interface WebProbeSentinelConfigPlan {
readonly node: string; readonly node: string;
readonly lane: string; readonly lane: string;
readonly rootPath: string; readonly rootPath: string;
readonly sentinelId: string | null;
readonly enabled: boolean; readonly enabled: boolean;
readonly sentinels: readonly WebProbeSentinelRegistryRow[];
readonly refs: readonly WebProbeSentinelConfigRefStatus[]; readonly refs: readonly WebProbeSentinelConfigRefStatus[];
readonly conflicts: readonly string[]; readonly conflicts: readonly string[];
readonly next: Record<string, string>; readonly next: Record<string, string>;
@@ -165,41 +169,64 @@ const REQUIRED_TARGET_SHAPES: Record<HwlabRuntimeWebProbeSentinelConfigRefKey, R
}, },
}; };
export function webProbeSentinelConfigPlan(spec: HwlabRuntimeLaneSpec, action: WebProbeSentinelConfigAction): WebProbeSentinelConfigPlan { export function webProbeSentinelConfigPlan(spec: HwlabRuntimeLaneSpec, action: WebProbeSentinelConfigAction, sentinelId: string | null = null): WebProbeSentinelConfigPlan {
const sentinel = spec.observability.webProbe?.sentinel; const command = `web-probe sentinel ${action} --node ${spec.nodeId} --lane ${spec.lane}${sentinelId === null ? "" : ` --sentinel ${sentinelId}`}`;
const command = `web-probe sentinel ${action} --node ${spec.nodeId} --lane ${spec.lane}`; const registry = webProbeSentinelRegistryRows(spec);
const rootConfigPath = `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel`; const registryPath = `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinels`;
if (sentinel === undefined) { if (sentinelId === null && registry.length > 1) {
const enabled = registry.some((item) => item.enabled);
return {
ok: enabled,
command,
status: enabled ? "ready" : "disabled",
node: spec.nodeId,
lane: spec.lane,
rootPath: registryPath,
sentinelId: null,
enabled,
sentinels: registry,
refs: [],
conflicts: [],
next: sentinelNext(spec.nodeId, spec.lane, registry[0]?.id ?? null),
valuesRedacted: true,
};
}
if (registry.length === 0) {
return { return {
ok: false, ok: false,
command, command,
status: "blocked", status: "blocked",
node: spec.nodeId, node: spec.nodeId,
lane: spec.lane, lane: spec.lane,
rootPath: rootConfigPath, rootPath: registryPath,
sentinelId,
enabled: false, enabled: false,
sentinels: [],
refs: [], refs: [],
conflicts: [`${rootConfigPath} is missing`], conflicts: [`${registryPath} is missing`],
next: sentinelNext(spec.nodeId, spec.lane), next: sentinelNext(spec.nodeId, spec.lane, sentinelId),
valuesRedacted: true, valuesRedacted: true,
}; };
} }
const refs = HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.map((key) => readSentinelConfigRef(key, sentinel.configRefs[key])); const selected = resolveWebProbeSentinel(spec, sentinelId);
const conflicts = sentinel.enabled ? crossReferenceConflicts(spec, refs) : []; const refs = HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.map((key) => readSentinelConfigRef(key, selected.configRefs[key]));
const conflicts = selected.enabled ? crossReferenceConflicts(spec, refs) : [];
const refBlocked = refs.some((ref) => !ref.present || !ref.targetPresent || ref.missingFields.length > 0 || ref.conflicts.length > 0 || ref.error !== null); const refBlocked = refs.some((ref) => !ref.present || !ref.targetPresent || ref.missingFields.length > 0 || ref.conflicts.length > 0 || ref.error !== null);
const ok = sentinel.enabled && !refBlocked && conflicts.length === 0; const ok = selected.enabled && !refBlocked && conflicts.length === 0;
return { return {
ok, ok,
command, command,
status: sentinel.enabled ? ok ? "ready" : "blocked" : "disabled", status: selected.enabled ? ok ? "ready" : "blocked" : "disabled",
node: spec.nodeId, node: spec.nodeId,
lane: spec.lane, lane: spec.lane,
rootPath: rootConfigPath, rootPath: selected.rootPath,
enabled: sentinel.enabled, sentinelId: selected.id,
enabled: selected.enabled,
sentinels: registry,
refs: refs.map(stripInternalTarget), refs: refs.map(stripInternalTarget),
conflicts, conflicts,
next: sentinelNext(spec.nodeId, spec.lane), next: sentinelNext(spec.nodeId, spec.lane, selected.id),
valuesRedacted: true, valuesRedacted: true,
}; };
} }
@@ -280,9 +307,10 @@ function emptyRefStatus(key: HwlabRuntimeWebProbeSentinelConfigRefKey, ref: stri
function missingFieldsForTarget(key: HwlabRuntimeWebProbeSentinelConfigRefKey, target: unknown): string[] { function missingFieldsForTarget(key: HwlabRuntimeWebProbeSentinelConfigRefKey, target: unknown): string[] {
const shape = REQUIRED_TARGET_SHAPES[key]; const shape = REQUIRED_TARGET_SHAPES[key];
if (shape.kind === "array") { if (shape.kind === "array") {
if (!Array.isArray(target)) return [`expected ${shape.kind}`]; const items = key === "scenarios" && isRecord(target) ? [target] : Array.isArray(target) ? target : null;
if (target.length === 0) return ["[0]"]; if (items === null) return [`expected ${shape.kind}`];
return target.flatMap((item, index) => shape.requiredPaths if (items.length === 0) return ["[0]"];
return items.flatMap((item, index) => shape.requiredPaths
.filter((path) => valueAtPath(item, path) === undefined) .filter((path) => valueAtPath(item, path) === undefined)
.map((path) => `[${index}].${path}`)); .map((path) => `[${index}].${path}`));
} }
@@ -294,7 +322,7 @@ function crossReferenceConflicts(spec: HwlabRuntimeLaneSpec, refs: readonly Inte
const byKey = new Map(refs.map((ref) => [ref.key, ref])); const byKey = new Map(refs.map((ref) => [ref.key, ref]));
const conflicts: string[] = []; const conflicts: string[] = [];
const runtime = recordTarget(byKey.get("runtime")); const runtime = recordTarget(byKey.get("runtime"));
const scenarios = arrayTarget(byKey.get("scenarios")); const scenarios = scenarioTargets(byKey.get("scenarios"));
const promptSet = recordTarget(byKey.get("promptSet")); const promptSet = recordTarget(byKey.get("promptSet"));
const cicd = recordTarget(byKey.get("cicd")); const cicd = recordTarget(byKey.get("cicd"));
const secrets = recordTarget(byKey.get("secrets")); const secrets = recordTarget(byKey.get("secrets"));
@@ -369,10 +397,11 @@ function stripInternalTarget(ref: InternalConfigRefStatus): WebProbeSentinelConf
function summarizeTarget(key: HwlabRuntimeWebProbeSentinelConfigRefKey, target: unknown): string { function summarizeTarget(key: HwlabRuntimeWebProbeSentinelConfigRefKey, target: unknown): string {
if (target === undefined) return "target=missing"; if (target === undefined) return "target=missing";
if (key === "scenarios" && Array.isArray(target)) { if (key === "scenarios") {
const ids = target.map((item) => stringAt(item, "id")).filter((item): item is string => item !== null).slice(0, 4); const items = isRecord(target) ? [target] : Array.isArray(target) ? target : [];
const cadences = target.map((item) => stringAt(item, "cadence")).filter((item): item is string => item !== null).slice(0, 4); const ids = items.map((item) => stringAt(item, "id")).filter((item): item is string => item !== null).slice(0, 4);
const checks = target.flatMap((item) => arrayAt(item, "sessionInvarianceChecks")); const cadences = items.map((item) => stringAt(item, "cadence")).filter((item): item is string => item !== null).slice(0, 4);
const checks = items.flatMap((item) => arrayAt(item, "sessionInvarianceChecks"));
const afterRounds = checks const afterRounds = checks
.map((item) => { .map((item) => {
const value = isRecord(item) ? item.afterRound : null; const value = isRecord(item) ? item.afterRound : null;
@@ -380,7 +409,7 @@ function summarizeTarget(key: HwlabRuntimeWebProbeSentinelConfigRefKey, target:
}) })
.filter((item): item is string => item !== null) .filter((item): item is string => item !== null)
.slice(0, 8); .slice(0, 8);
return `items=${target.length} ids=${ids.join(",") || "-"} cadence=${cadences.join(",") || "-"} sessionInvarianceChecks=${checks.length} afterRound=${afterRounds.join(",") || "-"}`; return `items=${items.length} ids=${ids.join(",") || "-"} cadence=${cadences.join(",") || "-"} sessionInvarianceChecks=${checks.length} afterRound=${afterRounds.join(",") || "-"}`;
} }
if (!isRecord(target)) return `kind=${targetKindOf(target)}`; if (!isRecord(target)) return `kind=${targetKindOf(target)}`;
if (key === "runtime") return `namespace=${textAt(target, "namespace")} service=${textAt(target, "serviceName")} image=${short(textAt(target, "imageRef"), 48)}`; if (key === "runtime") return `namespace=${textAt(target, "namespace")} service=${textAt(target, "serviceName")} image=${short(textAt(target, "imageRef"), 48)}`;
@@ -408,7 +437,19 @@ function renderWebProbeSentinelConfigPlan(value: WebProbeSentinelConfigPlan): st
return [ return [
`web-probe sentinel ${commandAction(value.command)} (${value.status})`, `web-probe sentinel ${commandAction(value.command)} (${value.status})`,
"", "",
sentinelTable(["NODE", "LANE", "ENABLED", "OK", "ROOT"], [[value.node, value.lane, value.enabled, value.ok, value.rootPath]]), sentinelTable(["NODE", "LANE", "SENTINEL", "ENABLED", "OK", "ROOT"], [[value.node, value.lane, value.sentinelId ?? "registry", value.enabled, value.ok, value.rootPath]]),
...(value.sentinels.length === 0 ? [] : [
"",
sentinelTable(
["SENTINEL", "ENABLED", "CONFIG_REF"],
value.sentinels.map((item) => [item.id, item.enabled, short(item.configRef, 110)]),
),
]),
...(value.refs.length === 0 ? [
"",
"DRILL_DOWN",
...value.sentinels.map((item) => ` ${item.id}: bun scripts/cli.ts web-probe sentinel ${commandAction(value.command)} --node ${value.node} --lane ${value.lane} --sentinel ${item.id}`),
] : [
"", "",
sentinelTable( sentinelTable(
["KEY", "PRESENT", "TARGET", "TYPE", "HASH", "MISSING", "SUMMARY"], ["KEY", "PRESENT", "TARGET", "TYPE", "HASH", "MISSING", "SUMMARY"],
@@ -427,6 +468,7 @@ function renderWebProbeSentinelConfigPlan(value: WebProbeSentinelConfigPlan): st
["KEY", "FILE", "PATH", "BYTES"], ["KEY", "FILE", "PATH", "BYTES"],
value.refs.map((ref) => [ref.key, ref.file, ref.path, ref.byteCount ?? "-"]), value.refs.map((ref) => [ref.key, ref.file, ref.path, ref.byteCount ?? "-"]),
), ),
]),
...blocked, ...blocked,
"", "",
"NEXT", "NEXT",
@@ -437,10 +479,11 @@ function renderWebProbeSentinelConfigPlan(value: WebProbeSentinelConfigPlan): st
].join("\n"); ].join("\n");
} }
function sentinelNext(node: string, lane: string): Record<string, string> { function sentinelNext(node: string, lane: string, sentinelId: string | null): Record<string, string> {
const suffix = sentinelId === null ? "" : ` --sentinel ${sentinelId}`;
return { return {
plan: `bun scripts/cli.ts web-probe sentinel plan --node ${node} --lane ${lane} --dry-run`, plan: `bun scripts/cli.ts web-probe sentinel plan --node ${node} --lane ${lane}${suffix} --dry-run`,
status: `bun scripts/cli.ts web-probe sentinel status --node ${node} --lane ${lane}`, status: `bun scripts/cli.ts web-probe sentinel status --node ${node} --lane ${lane}${suffix}`,
}; };
} }
@@ -487,6 +530,12 @@ function arrayTarget(ref: InternalConfigRefStatus | undefined): Record<string, u
return ref !== undefined && Array.isArray(ref.target) ? ref.target.filter(isRecord) : []; return ref !== undefined && Array.isArray(ref.target) ? ref.target.filter(isRecord) : [];
} }
function scenarioTargets(ref: InternalConfigRefStatus | undefined): Record<string, unknown>[] {
if (ref === undefined) return [];
if (Array.isArray(ref.target)) return ref.target.filter(isRecord);
return isRecord(ref.target) ? [ref.target] : [];
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
} }
@@ -1,4 +1,5 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Static dashboard shell and asset serving for the web-probe sentinel frontend. // Responsibility: Static dashboard shell and asset serving for the web-probe sentinel frontend.
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { rootPath } from "./config"; import { rootPath } from "./config";
@@ -6,6 +7,7 @@ import { rootPath } from "./config";
interface DashboardShellConfig { interface DashboardShellConfig {
readonly node: string; readonly node: string;
readonly lane: string; readonly lane: string;
readonly sentinelId: string;
readonly plan: { readonly ok: boolean }; readonly plan: { readonly ok: boolean };
readonly publicExposure: Record<string, unknown>; readonly publicExposure: Record<string, unknown>;
} }
@@ -15,13 +17,14 @@ const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p9-desktop-view-density";
export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig): string { export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig): string {
const publicOrigin = stringOrNull(config.publicExposure.publicBaseUrl) ?? ""; const publicOrigin = stringOrNull(config.publicExposure.publicBaseUrl) ?? "";
const basePath = publicBasePath(publicOrigin);
return `<!doctype html> return `<!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>HWLAB Web哨兵</title> <title>HWLAB Web哨兵</title>
<link rel="stylesheet" href="/dashboard/assets/dashboard.css"> <link rel="stylesheet" href="${escapeAttr(basePath)}/dashboard/assets/dashboard.css">
</head> </head>
<body> <body>
<main <main
@@ -29,6 +32,8 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
class="sentinel-shell" class="sentinel-shell"
data-node="${escapeAttr(config.node)}" data-node="${escapeAttr(config.node)}"
data-lane="${escapeAttr(config.lane)}" data-lane="${escapeAttr(config.lane)}"
data-sentinel-id="${escapeAttr(config.sentinelId)}"
data-base-path="${escapeAttr(basePath)}"
data-public-origin="${escapeAttr(publicOrigin)}" data-public-origin="${escapeAttr(publicOrigin)}"
data-config-ready="${config.plan.ok ? "true" : "false"}" data-config-ready="${config.plan.ok ? "true" : "false"}"
data-contract-version="${DASHBOARD_CONTRACT_VERSION}" data-contract-version="${DASHBOARD_CONTRACT_VERSION}"
@@ -213,11 +218,20 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
<div id="copy-toast" class="copy-toast" hidden></div> <div id="copy-toast" class="copy-toast" hidden></div>
</main> </main>
<script type="module" src="/dashboard/assets/dashboard.js"></script> <script type="module" src="${escapeAttr(basePath)}/dashboard/assets/dashboard.js"></script>
</body> </body>
</html>`; </html>`;
} }
function publicBasePath(publicBaseUrl: string): string {
try {
const path = new URL(publicBaseUrl).pathname.replace(/\/+$/u, "");
return path === "/" ? "" : path;
} catch {
return "";
}
}
export function webProbeSentinelDashboardAssetResponse(pathname: string): Response | null { export function webProbeSentinelDashboardAssetResponse(pathname: string): Response | null {
if (pathname === "/dashboard/assets/dashboard.css") return textAsset(`${DASHBOARD_ASSET_ROOT}/dashboard.css`, "text/css; charset=utf-8"); if (pathname === "/dashboard/assets/dashboard.css") return textAsset(`${DASHBOARD_ASSET_ROOT}/dashboard.css`, "text/css; charset=utf-8");
if (pathname === "/dashboard/assets/dashboard.js") return textAsset(`${DASHBOARD_ASSET_ROOT}/dashboard.js`, "application/javascript; charset=utf-8"); if (pathname === "/dashboard/assets/dashboard.js") return textAsset(`${DASHBOARD_ASSET_ROOT}/dashboard.js`, "application/javascript; charset=utf-8");
@@ -0,0 +1,154 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Resolve YAML-first web-probe sentinel registry entries into one selected sentinel config graph.
import { existsSync, readFileSync } from "node:fs";
import { rootPath } from "./config";
import { HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS, type HwlabRuntimeLaneSpec, type HwlabRuntimeWebProbeSentinelConfigRefKey, type HwlabRuntimeWebProbeSentinelRegistryItemSpec } from "./hwlab-node-lanes";
export interface ResolvedWebProbeSentinel {
readonly id: string;
readonly enabled: boolean;
readonly mode: "registry" | "legacy";
readonly rootPath: string;
readonly configRef: string | null;
readonly configRefs: Record<HwlabRuntimeWebProbeSentinelConfigRefKey, string>;
readonly target: Record<string, unknown>;
}
export interface WebProbeSentinelRegistryRow {
readonly id: string;
readonly enabled: boolean;
readonly configRef: string;
}
export function webProbeSentinelRegistryRows(spec: HwlabRuntimeLaneSpec): readonly WebProbeSentinelRegistryRow[] {
const registry = spec.observability.webProbe?.sentinels;
if (registry !== undefined) return registry.map((item) => ({ id: item.id, enabled: item.enabled, configRef: item.configRef }));
const legacy = spec.observability.webProbe?.sentinel;
if (legacy === undefined) return [];
return [{
id: "workbench-dsflash-go-tool-call-10x",
enabled: legacy.enabled,
configRef: `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel`,
}];
}
export function resolveWebProbeSentinel(spec: HwlabRuntimeLaneSpec, sentinelId: string | null | undefined): ResolvedWebProbeSentinel {
const registry = spec.observability.webProbe?.sentinels;
if (registry !== undefined) return resolveRegistrySentinel(spec, registry, sentinelId ?? null);
const legacy = spec.observability.webProbe?.sentinel;
if (legacy === undefined) {
throw new Error(`config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinels is missing`);
}
const id = sentinelId ?? "workbench-dsflash-go-tool-call-10x";
return {
id,
enabled: legacy.enabled,
mode: "legacy",
rootPath: `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel`,
configRef: null,
configRefs: legacy.configRefs,
target: {
id,
configRefs: legacy.configRefs,
valuesRedacted: true,
},
};
}
export function requireSentinelIdForRegistry(spec: HwlabRuntimeLaneSpec, sentinelId: string | null | undefined, command: string): void {
const registry = spec.observability.webProbe?.sentinels;
if (registry !== undefined && registry.length > 1 && (sentinelId === null || sentinelId === undefined || sentinelId.length === 0)) {
const ids = registry.map((item) => item.id).join(", ");
throw new Error(`${command} requires --sentinel <id> when multiple sentinels are declared; available: ${ids}`);
}
}
function resolveRegistrySentinel(spec: HwlabRuntimeLaneSpec, registry: readonly HwlabRuntimeWebProbeSentinelRegistryItemSpec[], sentinelId: string | null): ResolvedWebProbeSentinel {
const selected = sentinelId === null && registry.length === 1
? registry[0]
: registry.find((item) => item.id === sentinelId);
if (selected === undefined) {
const ids = registry.map((item) => item.id).join(", ");
throw new Error(`unknown web-probe sentinel ${sentinelId ?? "-"} for ${spec.nodeId}/${spec.lane}; available: ${ids}`);
}
const target = readConfigRefRecord(selected.configRef);
const targetId = optionalStringAt(target, "id") ?? selected.id;
if (targetId !== selected.id) {
throw new Error(`${selected.configRef}.id=${targetId} does not match registry id ${selected.id}`);
}
return {
id: selected.id,
enabled: selected.enabled,
mode: "registry",
rootPath: `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinels.${selected.id}`,
configRef: selected.configRef,
configRefs: normalizeSentinelConfigRefs(target, selected.configRef),
target,
};
}
function normalizeSentinelConfigRefs(target: Record<string, unknown>, ref: string): Record<HwlabRuntimeWebProbeSentinelConfigRefKey, string> {
const rawRefs = recordAt(target, "configRefs");
const normalized: Record<string, string> = {};
for (const key of Object.keys(rawRefs)) {
const value = rawRefs[key];
if (typeof value === "string" && value.length > 0) normalized[key] = value;
}
if (normalized.scenarios === undefined && normalized.workflow !== undefined) normalized.scenarios = normalized.workflow;
const missing = HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.filter((key) => normalized[key] === undefined);
if (missing.length > 0) throw new Error(`${ref}.configRefs is missing ${missing.join(",")}`);
return Object.fromEntries(HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.map((key) => [key, normalized[key]])) as Record<HwlabRuntimeWebProbeSentinelConfigRefKey, string>;
}
function readConfigRefRecord(ref: string): Record<string, unknown> {
const target = readConfigRefTarget(ref);
if (!isRecord(target)) throw new Error(`${ref} must point to a YAML object`);
return target;
}
export function readConfigRefTarget(ref: string): unknown {
const [file, path, extra] = ref.split("#");
if (extra !== undefined || file === undefined || path === undefined || file.length === 0 || path.length === 0) {
throw new Error(`${ref} must use path/to/file.yaml#object.path syntax`);
}
if (file.startsWith("/") || file.includes("\0") || file.includes("..") || !file.startsWith("config/") || !file.endsWith(".yaml")) {
throw new Error(`${ref} must reference a repo-relative config/*.yaml file without ..`);
}
const absPath = rootPath(file);
if (!existsSync(absPath)) throw new Error(`${file} does not exist`);
const doc = Bun.YAML.parse(readFileSync(absPath, "utf8")) as unknown;
return valueAtPath(doc, path);
}
function recordAt(value: unknown, path: string): Record<string, unknown> {
const found = valueAtPath(value, path);
if (!isRecord(found)) throw new Error(`${path} must be an object`);
return found;
}
function optionalStringAt(value: unknown, path: string): string | null {
const found = valueAtPath(value, path);
return typeof found === "string" && found.length > 0 ? found : null;
}
function valueAtPath(value: unknown, path: string): unknown {
let current: unknown = value;
for (const segment of path.split(".")) {
if (segment.length === 0) return undefined;
const match = /^(?:([A-Za-z0-9_-]+))?(?:\[(\d+)\])?$/u.exec(segment);
if (match === null) return undefined;
if (match[1] !== undefined) {
if (!isRecord(current)) return undefined;
current = current[match[1]];
}
if (match[2] !== undefined) {
if (!Array.isArray(current)) return undefined;
current = current[Number(match[2])];
}
}
return current;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+46 -19
View File
@@ -1,6 +1,7 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p7-web-probe-sentinel-dashboard. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p7-web-probe-sentinel-dashboard.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Persistent HTTP wrapper service for web-probe observe scheduling, index, health, metrics, maintenance, and dashboard. // 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";
@@ -11,6 +12,7 @@ import { rootPath } from "./config";
import { renderWebProbeSentinelDashboardHtml, webProbeSentinelDashboardAssetResponse } from "./hwlab-node-web-sentinel-dashboard-assets"; import { renderWebProbeSentinelDashboardHtml, webProbeSentinelDashboardAssetResponse } from "./hwlab-node-web-sentinel-dashboard-assets";
import { webProbeSentinelConfigPlan, type WebProbeSentinelConfigPlan } from "./hwlab-node-web-sentinel-config"; import { webProbeSentinelConfigPlan, type WebProbeSentinelConfigPlan } from "./hwlab-node-web-sentinel-config";
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes"; import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
import { resolveWebProbeSentinel, readConfigRefTarget as readSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-resolver";
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p9-desktop-view-density"; const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p9-desktop-view-density";
const DASHBOARD_MAX_TEXT_BYTES = 16_000; const DASHBOARD_MAX_TEXT_BYTES = 16_000;
@@ -18,6 +20,7 @@ const DASHBOARD_MAX_TEXT_BYTES = 16_000;
export interface WebProbeSentinelServiceConfig { export interface WebProbeSentinelServiceConfig {
readonly node: string; readonly node: string;
readonly lane: string; readonly lane: string;
readonly sentinelId: string;
readonly plan: WebProbeSentinelConfigPlan; readonly plan: WebProbeSentinelConfigPlan;
readonly runtime: Record<string, unknown>; readonly runtime: Record<string, unknown>;
readonly scenarios: readonly Record<string, unknown>[]; readonly scenarios: readonly Record<string, unknown>[];
@@ -35,6 +38,7 @@ export interface WebProbeSentinelServiceConfig {
export interface WebProbeSentinelServiceOptions { export interface WebProbeSentinelServiceOptions {
readonly spec: HwlabRuntimeLaneSpec; readonly spec: HwlabRuntimeLaneSpec;
readonly sentinelId?: string | null;
readonly stateRootOverride?: string; readonly stateRootOverride?: string;
readonly portOverride?: number; readonly portOverride?: number;
readonly hostOverride?: string; readonly hostOverride?: string;
@@ -83,19 +87,19 @@ export interface WebProbeSentinelService {
} }
export function loadWebProbeSentinelServiceConfig(spec: HwlabRuntimeLaneSpec, options: Omit<WebProbeSentinelServiceOptions, "spec"> = {}): WebProbeSentinelServiceConfig { export function loadWebProbeSentinelServiceConfig(spec: HwlabRuntimeLaneSpec, options: Omit<WebProbeSentinelServiceOptions, "spec"> = {}): WebProbeSentinelServiceConfig {
const sentinel = spec.observability.webProbe?.sentinel; const sentinel = resolveWebProbeSentinel(spec, options.sentinelId ?? null);
if (sentinel === undefined) throw new Error(`config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel is missing`); const plan = webProbeSentinelConfigPlan(spec, "status", sentinel.id);
const plan = webProbeSentinelConfigPlan(spec, "status"); const runtime = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.runtime));
const runtime = recordTarget(readConfigRefTarget(sentinel.configRefs.runtime)); const scenarios = scenarioArrayTarget(readSentinelConfigRefTarget(sentinel.configRefs.scenarios));
const scenarios = arrayTarget(readConfigRefTarget(sentinel.configRefs.scenarios)); const reportViews = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.reportViews));
const reportViews = recordTarget(readConfigRefTarget(sentinel.configRefs.reportViews)); const publicExposure = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.publicExposure));
const publicExposure = recordTarget(readConfigRefTarget(sentinel.configRefs.publicExposure)); const cicd = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.cicd));
const cicd = recordTarget(readConfigRefTarget(sentinel.configRefs.cicd));
const stateRoot = options.stateRootOverride ?? stringAt(runtime, "stateRoot"); const stateRoot = options.stateRootOverride ?? stringAt(runtime, "stateRoot");
const yamlSqlitePath = stringAt(runtime, "sqlite.path"); const yamlSqlitePath = stringAt(runtime, "sqlite.path");
return { return {
node: spec.nodeId, node: spec.nodeId,
lane: spec.lane, lane: spec.lane,
sentinelId: sentinel.id,
plan, plan,
runtime, runtime,
scenarios, scenarios,
@@ -167,6 +171,7 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
ok: true, ok: true,
node: config.node, node: config.node,
lane: config.lane, lane: config.lane,
sentinelId: config.sentinelId,
status: "observed", status: "observed",
configReady: config.plan.ok, configReady: config.plan.ok,
scheduler: schedulerSummary(config, db), scheduler: schedulerSummary(config, db),
@@ -388,7 +393,7 @@ function serviceHealth(config: WebProbeSentinelServiceConfig, db: Database, sche
command: `bun scripts/cli.ts web-probe observe analyze --node ${config.node} --lane ${config.lane} --state-dir <stateDir>`, command: `bun scripts/cli.ts web-probe observe analyze --node ${config.node} --lane ${config.lane} --state-dir <stateDir>`,
}; };
const ok = Object.values(checks).every((check) => check.ok === true); const ok = Object.values(checks).every((check) => check.ok === true);
return { ok, status: ok ? "healthy" : "degraded", node: config.node, lane: config.lane, checks, valuesRedacted: true }; return { ok, status: ok ? "healthy" : "degraded", node: config.node, lane: config.lane, sentinelId: config.sentinelId, checks, valuesRedacted: true };
} }
function checkWritable(stateRoot: string): Record<string, unknown> { function checkWritable(stateRoot: string): Record<string, unknown> {
@@ -432,6 +437,16 @@ function buildObserveCommandPlan(config: WebProbeSentinelServiceConfig, scenario
const argv = ["bun", "scripts/cli.ts", "web-probe", "observe", "command", "<observerId>", "--type", type]; const argv = ["bun", "scripts/cli.ts", "web-probe", "observe", "command", "<observerId>", "--type", type];
if (type === "selectProvider") argv.push("--provider", stringAt(item, "provider")); if (type === "selectProvider") argv.push("--provider", stringAt(item, "provider"));
if (type === "sendPrompt") argv.push("--text-stdin"); if (type === "sendPrompt") argv.push("--text-stdin");
if (type === "loginAccount" || type === "listSessions" || type === "logout") {
const accountId = stringOrNull(item.accountId);
if (accountId !== null) argv.push("--account-id", accountId);
}
if (type === "switchSessions") {
const fromAccountId = stringOrNull(item.fromAccountId);
const toAccountId = stringOrNull(item.toAccountId);
if (fromAccountId !== null) argv.push("--from-account-id", fromAccountId);
if (toAccountId !== null) argv.push("--to-account-id", toAccountId);
}
return { phase: `observe-command-${type}`, argv, stdinSource: type === "sendPrompt" ? "prompt-source" : "none" } satisfies CommandPlanStep; return { phase: `observe-command-${type}`, argv, stdinSource: type === "sendPrompt" ? "prompt-source" : "none" } satisfies CommandPlanStep;
}); });
const analyze: CommandPlanStep = { const analyze: CommandPlanStep = {
@@ -459,28 +474,29 @@ function renderMetrics(config: WebProbeSentinelServiceConfig, db: Database, heal
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));
const labels = `node="${metricLabel(config.node)}",lane="${metricLabel(config.lane)}",sentinel="${metricLabel(config.sentinelId)}"`;
const lines = [ const lines = [
"# HELP web_probe_sentinel_config_ready Config reference graph is ready.", "# HELP web_probe_sentinel_config_ready Config reference graph is ready.",
"# TYPE web_probe_sentinel_config_ready gauge", "# TYPE web_probe_sentinel_config_ready gauge",
`web_probe_sentinel_config_ready{node="${metricLabel(config.node)}",lane="${metricLabel(config.lane)}"} ${config.plan.ok ? 1 : 0}`, `web_probe_sentinel_config_ready{${labels}} ${config.plan.ok ? 1 : 0}`,
"# HELP web_probe_sentinel_health Healthy status of the sentinel service.", "# HELP web_probe_sentinel_health Healthy status of the sentinel service.",
"# TYPE web_probe_sentinel_health gauge", "# TYPE web_probe_sentinel_health gauge",
`web_probe_sentinel_health{node="${metricLabel(config.node)}",lane="${metricLabel(config.lane)}"} ${health.ok === true ? 1 : 0}`, `web_probe_sentinel_health{${labels}} ${health.ok === true ? 1 : 0}`,
"# HELP web_probe_sentinel_runs_total Runs indexed by status.", "# HELP web_probe_sentinel_runs_total Runs indexed by status.",
"# TYPE web_probe_sentinel_runs_total gauge", "# TYPE web_probe_sentinel_runs_total gauge",
...Object.entries(counts).map(([status, count]) => `web_probe_sentinel_runs_total{status="${metricLabel(status)}"} ${count}`), ...Object.entries(counts).map(([status, count]) => `web_probe_sentinel_runs_total{${labels},status="${metricLabel(status)}"} ${count}`),
"# HELP web_probe_sentinel_active_runs Active observe runs known to the sentinel index.", "# 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 ${countWhere(db, "status IN ('queued', 'running', 'analyzing')")}`, `web_probe_sentinel_active_runs{${labels}} ${countWhere(db, "status IN ('queued', 'running', 'analyzing')")}`,
"# HELP web_probe_sentinel_recent_findings Findings indexed from recent reports.", "# 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 ${sumColumn(db, "runs", "finding_count")}`, `web_probe_sentinel_recent_findings{${labels}} ${sumColumn(db, "runs", "finding_count")}`,
"# HELP web_probe_sentinel_maintenance_active Maintenance window active flag.", "# 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 ${maintenance.active ? 1 : 0}`, `web_probe_sentinel_maintenance_active{${labels}} ${maintenance.active ? 1 : 0}`,
"# HELP web_probe_sentinel_scheduler_heartbeat_age_seconds Scheduler heartbeat age.", "# HELP web_probe_sentinel_scheduler_heartbeat_age_seconds Scheduler heartbeat age.",
"# TYPE web_probe_sentinel_scheduler_heartbeat_age_seconds gauge", "# TYPE web_probe_sentinel_scheduler_heartbeat_age_seconds gauge",
`web_probe_sentinel_scheduler_heartbeat_age_seconds ${heartbeatAge}`, `web_probe_sentinel_scheduler_heartbeat_age_seconds{${labels}} ${heartbeatAge}`,
]; ];
return `${lines.join("\n")}\n`; return `${lines.join("\n")}\n`;
} }
@@ -525,6 +541,7 @@ function dashboardOverview(config: WebProbeSentinelServiceConfig, db: Database,
status: dashboardOverallStatus(health, latestRun, severityCounts), status: dashboardOverallStatus(health, latestRun, severityCounts),
node: config.node, node: config.node,
lane: config.lane, lane: config.lane,
sentinelId: config.sentinelId,
publicOrigin: stringOrNull(config.publicExposure.publicBaseUrl), publicOrigin: stringOrNull(config.publicExposure.publicBaseUrl),
configReady: config.plan.ok, configReady: config.plan.ok,
health, health,
@@ -566,6 +583,7 @@ function dashboardRunList(config: WebProbeSentinelServiceConfig, db: Database, u
contractVersion: DASHBOARD_CONTRACT_VERSION, contractVersion: DASHBOARD_CONTRACT_VERSION,
node: config.node, node: config.node,
lane: config.lane, lane: config.lane,
sentinelId: config.sentinelId,
filters, filters,
page: { page: {
limit: page.limit, limit: page.limit,
@@ -594,6 +612,7 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database,
return { return {
ok: true, ok: true,
contractVersion: DASHBOARD_CONTRACT_VERSION, contractVersion: DASHBOARD_CONTRACT_VERSION,
sentinelId: config.sentinelId,
run: dashboardRunSummary(config, db, row), run: dashboardRunSummary(config, db, row),
summary: record(stored.summary), summary: record(stored.summary),
findings, findings,
@@ -605,9 +624,9 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database,
publicOrigin: stringOrNull(stored.publicOrigin), publicOrigin: stringOrNull(stored.publicOrigin),
}, },
commands: { commands: {
summary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --run ${runId} --view summary`, summary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --sentinel ${config.sentinelId} --run ${runId} --view summary`,
turnSummary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --run ${runId} --view turn-summary`, turnSummary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --sentinel ${config.sentinelId} --run ${runId} --view turn-summary`,
traceFrame: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --run ${runId} --view trace-frame`, traceFrame: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --sentinel ${config.sentinelId} --run ${runId} --view trace-frame`,
}, },
redaction: record(config.reportViews.redaction), redaction: record(config.reportViews.redaction),
traceability: runTraceability(config, row), traceability: runTraceability(config, row),
@@ -651,6 +670,7 @@ function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database,
contractVersion: DASHBOARD_CONTRACT_VERSION, contractVersion: DASHBOARD_CONTRACT_VERSION,
node: config.node, node: config.node,
lane: config.lane, lane: config.lane,
sentinelId: config.sentinelId,
filters, filters,
page: { page: {
limit, limit,
@@ -677,6 +697,7 @@ function dashboardRunViews(config: WebProbeSentinelServiceConfig, db: Database,
return { return {
ok: true, ok: true,
contractVersion: DASHBOARD_CONTRACT_VERSION, contractVersion: DASHBOARD_CONTRACT_VERSION,
sentinelId: config.sentinelId,
run: dashboardRunSummary(config, db, row), run: dashboardRunSummary(config, db, row),
views, views,
view: view ?? null, view: view ?? null,
@@ -717,6 +738,7 @@ function dashboardRunSummary(config: WebProbeSentinelServiceConfig, db: Database
status: stringOrNull(row.status), status: stringOrNull(row.status),
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: config.sentinelId,
observer_id: stringOrNull(row.observer_id), observer_id: stringOrNull(row.observer_id),
observerId: stringOrNull(row.observer_id), observerId: stringOrNull(row.observer_id),
state_dir: stringOrNull(row.state_dir), state_dir: stringOrNull(row.state_dir),
@@ -1233,6 +1255,11 @@ function arrayTarget(value: unknown): Record<string, unknown>[] {
return value.map(recordTarget); return value.map(recordTarget);
} }
function scenarioArrayTarget(value: unknown): Record<string, unknown>[] {
if (Array.isArray(value)) return value.map(recordTarget);
return [recordTarget(value)];
}
function valueAtPath(value: unknown, path: string): unknown { function valueAtPath(value: unknown, path: string): unknown {
let current: unknown = value; let current: unknown = value;
for (const segment of path.split(".")) { for (const segment of path.split(".")) {
+8
View File
@@ -4,6 +4,7 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. // SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: YAML-first node/lane operations, including Workbench observability control commands. // Responsibility: YAML-first node/lane operations, including Workbench observability control commands.
import { createHash, randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -113,6 +114,10 @@ export type NodeWebProbeObserveAction = "start" | "status" | "command" | "stop"
export type NodeWebProbeObserveCommandType = export type NodeWebProbeObserveCommandType =
| "login" | "login"
| "loginAccount"
| "logout"
| "listSessions"
| "switchSessions"
| "preflight" | "preflight"
| "goto" | "goto"
| "gotoProjectMdtodo" | "gotoProjectMdtodo"
@@ -199,6 +204,9 @@ export interface NodeWebProbeObserveOptions {
commandRequireComposerReady: boolean; commandRequireComposerReady: boolean;
commandFindingId: string | null; commandFindingId: string | null;
commandBlocking: boolean | null; commandBlocking: boolean | null;
commandAccountId: string | null;
commandFromAccountId: string | null;
commandToAccountId: string | null;
commandSourceId: string | null; commandSourceId: string | null;
commandFileRef: string | null; commandFileRef: string | null;
commandFilename: string | null; commandFilename: string | null;
+41 -7
View File
@@ -4,6 +4,7 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. // SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: YAML-first node/lane operations, including Workbench observability control commands. // Responsibility: YAML-first node/lane operations, including Workbench observability control commands.
import { createHash, randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -60,11 +61,15 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
"--run-id", "--run-id",
"--trace-id", "--trace-id",
"--sample-seq", "--sample-seq",
"--sentinel",
"--sentinel-id",
]), new Set(["--dry-run", "--confirm", "--wait", "--quick-verify", "--raw", "--latest"])); ]), new Set(["--dry-run", "--confirm", "--wait", "--quick-verify", "--raw", "--latest"]));
const node = requiredOption(args, "--node"); const node = requiredOption(args, "--node");
assertNodeId(node); assertNodeId(node);
const lane = requiredOption(args, "--lane"); const lane = requiredOption(args, "--lane");
assertLane(lane); assertLane(lane);
const sentinelId = optionValue(args, "--sentinel") ?? optionValue(args, "--sentinel-id") ?? null;
if (sentinelId !== null && !/^[a-z0-9][a-z0-9-]{1,80}$/u.test(sentinelId)) throw new Error(`--sentinel must be a stable lowercase sentinel id, got ${sentinelId}`);
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`); if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
const confirm = args.includes("--confirm"); const confirm = args.includes("--confirm");
const dryRun = args.includes("--dry-run"); const dryRun = args.includes("--dry-run");
@@ -72,17 +77,17 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
const timeoutSeconds = positiveIntegerOption(args, "--timeout-seconds", 900, 3600); const timeoutSeconds = positiveIntegerOption(args, "--timeout-seconds", 900, 3600);
let sentinel: WebProbeSentinelOptions; let sentinel: WebProbeSentinelOptions;
if (sentinelActionRaw === "plan" || sentinelActionRaw === "status") { if (sentinelActionRaw === "plan" || sentinelActionRaw === "status") {
sentinel = { kind: "config", action: sentinelActionRaw, node, lane, dryRun }; sentinel = { kind: "config", action: sentinelActionRaw, node, lane, sentinelId, dryRun };
} else if (sentinelActionRaw === "image") { } else if (sentinelActionRaw === "image") {
const imageAction = args[1]; const imageAction = args[1];
if (imageAction !== "status" && imageAction !== "build") throw new Error("web-probe sentinel image usage: image status|build --node NODE --lane vNN [--dry-run|--confirm]"); if (imageAction !== "status" && imageAction !== "build") throw new Error("web-probe sentinel image usage: image status|build --node NODE --lane vNN [--dry-run|--confirm]");
sentinel = { kind: "image", action: imageAction, node, lane, dryRun: imageAction === "build" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds }; sentinel = { kind: "image", action: imageAction, node, lane, sentinelId, dryRun: imageAction === "build" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds };
} else if (sentinelActionRaw === "control-plane") { } else if (sentinelActionRaw === "control-plane") {
const controlPlaneAction = args[1]; const controlPlaneAction = args[1];
if (controlPlaneAction !== "plan" && controlPlaneAction !== "apply" && controlPlaneAction !== "status" && controlPlaneAction !== "trigger-current") { if (controlPlaneAction !== "plan" && controlPlaneAction !== "apply" && controlPlaneAction !== "status" && controlPlaneAction !== "trigger-current") {
throw new Error("web-probe sentinel control-plane usage: control-plane plan|apply|status|trigger-current --node NODE --lane vNN [--dry-run|--confirm]"); throw new Error("web-probe sentinel control-plane usage: control-plane plan|apply|status|trigger-current --node NODE --lane vNN [--dry-run|--confirm]");
} }
sentinel = { kind: "control-plane", action: controlPlaneAction, node, lane, dryRun: controlPlaneAction === "apply" || controlPlaneAction === "trigger-current" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds }; sentinel = { kind: "control-plane", action: controlPlaneAction, node, lane, sentinelId, dryRun: controlPlaneAction === "apply" || controlPlaneAction === "trigger-current" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds };
} else if (sentinelActionRaw === "maintenance") { } else if (sentinelActionRaw === "maintenance") {
const maintenanceAction = args[1]; const maintenanceAction = args[1];
if (maintenanceAction !== "status" && maintenanceAction !== "start" && maintenanceAction !== "stop") { if (maintenanceAction !== "status" && maintenanceAction !== "start" && maintenanceAction !== "stop") {
@@ -93,6 +98,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
action: maintenanceAction, action: maintenanceAction,
node, node,
lane, lane,
sentinelId,
dryRun: maintenanceAction === "status" ? dryRun : dryRun || !confirm, dryRun: maintenanceAction === "status" ? dryRun : dryRun || !confirm,
confirm, confirm,
wait: args.includes("--wait"), wait: args.includes("--wait"),
@@ -102,7 +108,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
quickVerify: maintenanceAction === "stop" || args.includes("--quick-verify"), quickVerify: maintenanceAction === "stop" || args.includes("--quick-verify"),
}; };
} else if (sentinelActionRaw === "validate") { } else if (sentinelActionRaw === "validate") {
sentinel = { kind: "validate", action: "validate", node, lane, dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds, quickVerify: args.includes("--quick-verify") }; sentinel = { kind: "validate", action: "validate", node, lane, sentinelId, dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds, quickVerify: args.includes("--quick-verify") };
} else { } else {
const view = parseWebProbeSentinelReportView(optionValue(args, "--view") ?? "summary"); const view = parseWebProbeSentinelReportView(optionValue(args, "--view") ?? "summary");
const latest = args.includes("--latest"); const latest = args.includes("--latest");
@@ -116,6 +122,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
action: "report", action: "report",
node, node,
lane, lane,
sentinelId,
view, view,
runId, runId,
latest, latest,
@@ -134,8 +141,8 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
} }
function parseWebProbeSentinelReportView(value: string): WebProbeSentinelReportView { function parseWebProbeSentinelReportView(value: string): WebProbeSentinelReportView {
if (value === "summary" || value === "turn-summary" || value === "findings" || value === "trace-frame") return value; if (value === "summary" || value === "turn-summary" || value === "findings" || value === "trace-frame" || value === "auth-session-switch-summary") return value;
throw new Error(`web-probe sentinel report --view must be summary, turn-summary, findings, or trace-frame; got ${value}`); throw new Error(`web-probe sentinel report --view must be summary, turn-summary, findings, trace-frame, or auth-session-switch-summary; got ${value}`);
} }
export function normalizeNodeWebProbeObserveArgs(args: string[]): { args: string[]; id: string | null } { export function normalizeNodeWebProbeObserveArgs(args: string[]): { args: string[]; id: string | null } {
@@ -202,6 +209,10 @@ export function parseNodeWebProbeObserveOptions(
"--label", "--label",
"--session-id", "--session-id",
"--provider", "--provider",
"--account-id",
"--account",
"--from-account-id",
"--to-account-id",
"--after-round", "--after-round",
"--severity", "--severity",
"--alternate-session-strategy", "--alternate-session-strategy",
@@ -272,6 +283,9 @@ export function parseNodeWebProbeObserveOptions(
} }
const commandText = commandTextFromStdin ? readFileSync(0, "utf8") : commandTextOption; const commandText = commandTextFromStdin ? readFileSync(0, "utf8") : commandTextOption;
const commandSourceId = optionValue(args, "--source-id") ?? null; const commandSourceId = optionValue(args, "--source-id") ?? null;
const commandAccountId = optionValue(args, "--account-id") ?? optionValue(args, "--account") ?? null;
const commandFromAccountId = optionValue(args, "--from-account-id") ?? null;
const commandToAccountId = optionValue(args, "--to-account-id") ?? null;
const commandFileRef = optionValue(args, "--file-ref") ?? null; const commandFileRef = optionValue(args, "--file-ref") ?? null;
const commandFilename = optionValue(args, "--filename") ?? null; const commandFilename = optionValue(args, "--filename") ?? null;
const commandTaskRef = optionValue(args, "--task-ref") ?? null; const commandTaskRef = optionValue(args, "--task-ref") ?? null;
@@ -301,6 +315,9 @@ export function parseNodeWebProbeObserveOptions(
["--expected-sentinel-range", commandExpectedSentinelRange], ["--expected-sentinel-range", commandExpectedSentinelRange],
["--finding-id", commandFindingId], ["--finding-id", commandFindingId],
["--source-id", commandSourceId], ["--source-id", commandSourceId],
["--account-id/--account", commandAccountId],
["--from-account-id", commandFromAccountId],
["--to-account-id", commandToAccountId],
["--file-ref", commandFileRef], ["--file-ref", commandFileRef],
["--filename", commandFilename], ["--filename", commandFilename],
["--task-ref", commandTaskRef], ["--task-ref", commandTaskRef],
@@ -366,6 +383,9 @@ export function parseNodeWebProbeObserveOptions(
commandRequireComposerReady: args.includes("--require-composer-ready"), commandRequireComposerReady: args.includes("--require-composer-ready"),
commandFindingId, commandFindingId,
commandBlocking, commandBlocking,
commandAccountId,
commandFromAccountId,
commandToAccountId,
commandSourceId, commandSourceId,
commandFileRef, commandFileRef,
commandFilename, commandFilename,
@@ -386,6 +406,10 @@ export function parseNodeWebProbeObserveOptions(
export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbeObserveCommandType { export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbeObserveCommandType {
if ( if (
value === "login" value === "login"
|| value === "loginAccount"
|| value === "logout"
|| value === "listSessions"
|| value === "switchSessions"
|| value === "preflight" || value === "preflight"
|| value === "goto" || value === "goto"
|| value === "newSession" || value === "newSession"
@@ -423,7 +447,7 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe
|| value === "mark" || value === "mark"
|| value === "stop" || value === "stop"
) return value; ) return value;
throw new Error(`web-probe observe command --type must be login, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, screenshot, mark, or stop; got ${value}`); throw new Error(`web-probe observe command --type must be login, loginAccount, logout, listSessions, switchSessions, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, screenshot, mark, or stop; got ${value}`);
} }
export function parseWebProbeBrowserProxyMode(value: string | undefined): WebProbeBrowserProxyMode { export function parseWebProbeBrowserProxyMode(value: string | undefined): WebProbeBrowserProxyMode {
@@ -1186,6 +1210,12 @@ export function normalizedProxyUrl(parsed: URL): string {
return value; return value;
} }
function webProbeAccountEnvAssignments(): string[] {
return Object.entries(process.env)
.filter(([key, value]) => value !== undefined && /^HWLAB_WEB_(?:ACCOUNT_)?[A-Z0-9_]+_(?:JSON|USER|PASS)$/u.test(key))
.map(([key, value]) => `${key}=${shellQuote(value ?? "")}`);
}
export function runNodeWebProbeObserve( export function runNodeWebProbeObserve(
options: NodeWebProbeObserveOptions, options: NodeWebProbeObserveOptions,
spec: HwlabRuntimeLaneSpec, spec: HwlabRuntimeLaneSpec,
@@ -1223,6 +1253,7 @@ export function runNodeWebProbeObserveStart(
const projectManagement = nodeWebProbeProjectManagementConfig(spec); const projectManagement = nodeWebProbeProjectManagementConfig(spec);
const runnerEnvAssignments = [ const runnerEnvAssignments = [
...webProbeProxy.envAssignments, ...webProbeProxy.envAssignments,
...webProbeAccountEnvAssignments(),
`HWLAB_WEB_BASE_URL=${shellQuote(options.url)}`, `HWLAB_WEB_BASE_URL=${shellQuote(options.url)}`,
`HWLAB_WEB_USER=${shellQuote(secretSpec.bootstrapAdminUsername)}`, `HWLAB_WEB_USER=${shellQuote(secretSpec.bootstrapAdminUsername)}`,
`HWLAB_WEB_PASS=${shellQuote(material.password)}`, `HWLAB_WEB_PASS=${shellQuote(material.password)}`,
@@ -1382,6 +1413,9 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption
requireComposerReady: options.commandRequireComposerReady, requireComposerReady: options.commandRequireComposerReady,
findingId: options.commandFindingId, findingId: options.commandFindingId,
blocking: options.commandBlocking, blocking: options.commandBlocking,
accountId: options.commandAccountId,
fromAccountId: options.commandFromAccountId,
toAccountId: options.commandToAccountId,
sourceId: options.commandSourceId, sourceId: options.commandSourceId,
fileRef: options.commandFileRef, fileRef: options.commandFileRef,
filename: options.commandFilename, filename: options.commandFilename,
+4 -1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bun #!/usr/bin/env bun
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel. // SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Bun entrypoint for the web-probe sentinel HTTP wrapper service. // Responsibility: Bun entrypoint for the web-probe sentinel HTTP wrapper service.
import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane } from "./src/hwlab-node-lanes"; import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane } from "./src/hwlab-node-lanes";
import { createWebProbeSentinelService, startWebProbeSentinelHttpService } from "./src/hwlab-node-web-sentinel-service"; import { createWebProbeSentinelService, startWebProbeSentinelHttpService } from "./src/hwlab-node-web-sentinel-service";
@@ -7,6 +8,7 @@ import { createWebProbeSentinelService, startWebProbeSentinelHttpService } from
const args = process.argv.slice(2); const args = process.argv.slice(2);
const node = requiredOption("--node"); const node = requiredOption("--node");
const lane = requiredOption("--lane"); const lane = requiredOption("--lane");
const sentinelId = optionValue("--sentinel") ?? optionValue("--sentinel-id") ?? null;
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe sentinel service only supports HWLAB runtime lanes, got ${lane}`); if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe sentinel service only supports HWLAB runtime lanes, got ${lane}`);
const spec = hwlabRuntimeLaneSpecForNode(lane, node); const spec = hwlabRuntimeLaneSpecForNode(lane, node);
const stateRootOverride = optionValue("--state-root"); const stateRootOverride = optionValue("--state-root");
@@ -15,7 +17,7 @@ const portRaw = optionValue("--port");
const portOverride = portRaw === undefined ? undefined : Number(portRaw); const portOverride = portRaw === undefined ? undefined : Number(portRaw);
if (portOverride !== undefined && (!Number.isInteger(portOverride) || portOverride <= 0 || portOverride > 65535)) throw new Error("--port must be 1-65535"); if (portOverride !== undefined && (!Number.isInteger(portOverride) || portOverride <= 0 || portOverride > 65535)) throw new Error("--port must be 1-65535");
const schedulerEnabled = !args.includes("--scheduler-disabled"); const schedulerEnabled = !args.includes("--scheduler-disabled");
const service = createWebProbeSentinelService({ spec, stateRootOverride, hostOverride, portOverride, schedulerEnabled }); const service = createWebProbeSentinelService({ spec, sentinelId, stateRootOverride, hostOverride, portOverride, schedulerEnabled });
if (args.includes("--once")) { if (args.includes("--once")) {
const health = service.health(); const health = service.health();
@@ -30,6 +32,7 @@ console.log(JSON.stringify({
command: "web-probe-sentinel-service", command: "web-probe-sentinel-service",
node, node,
lane, lane,
sentinelId: service.config.sentinelId,
url: server.url, url: server.url,
schedulerEnabled, schedulerEnabled,
health: service.health(), health: service.health(),