Merge remote-tracking branch 'origin/master' into fix/1846-artificer-gpt-pika

This commit is contained in:
Codex
2026-07-12 17:57:47 +02:00
35 changed files with 553 additions and 52 deletions
+1
View File
@@ -113,6 +113,7 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help
- PaC migrated consumer 的旧手动 publish/debug 命令不得作为正式 closeout 引导;调查命令默认只能指向只读的 `platform-infra pipelines-as-code status|history``closeout` 仅保留只读兼容能力,不得作为主代理、子代理或操作者的 PR 合并后步骤。需要保留的旧 mutation 命令只能属于 YAML 明确的 legacy authority 或独立平台维护,不得标记或引导 manual recovery;单步确认的自动链缺陷必须回到 YAML/controller/源码修复。
- Secret 只通过 YAML sourceRef/targetKey 和受控 CLI 下发;输出只披露 presence/fingerprint。
- 长命令用异步 job 或短轮询;不要长时间挂住 trans/ssh。
- Web 哨兵 YAML cadence 变更不新增发布 orchestrator:提交前用 `web-probe sentinel validate --local`;合并后用只读 `web-probe sentinel control-plane status --wait`。线上 desired schedule 必须来自 GitOps 清单,不能用操作者旧 worktree 的本地渲染值做阻塞门禁;收敛差异统一为非阻塞 warning。
## 何时读取 reference
+120
View File
@@ -0,0 +1,120 @@
---
name: unidesk-kafka
description: UniDesk Kafka 调试与事件链校对技能,覆盖 platform-infra Kafka 状态、topic/offset/group、codex stdio、AgentRun/HWLAB 事件映射、debug topic、离线 regenerate、隔离 replay、SSE/reducer 对账和事件丢失分层定位。用户提到 Kafka、topic、offset、consumer lag、Kafka replay/重放、离线重放、stdio event、agentrun.event、hwlab.event、事件丢失或要求用 CLI 校对事件链时使用。
---
# UniDesk Kafka 调试与重放
## 核心边界
- Kafka 运行事实由 `config/platform-infra/kafka.yaml` 与目标运行面共同拥有。
- 产品链路 topic 固定只读调查:
- `codex-stdio.raw.v1`
- `agentrun.event.v1`
- `hwlab.event.v1`
- 调试重建只能写隔离 topic
- `agentrun.event.debug.v1`
- `hwlab.event.debug.v1`
- replay/regenerate 不得向产品 topic 追加事件,不得重置产品 consumer group,不得把 debug group 冒充产品消费证据。
- 优先使用应用生产 reducer/mapper 的 regenerate CLI;平台 `kafka tail` 只返回 hash/bytes,不能证明 payload 语义。
- 调查结论必须区分:
- 源 stdio 未产生;
- stdio→AgentRun reducer 丢失或降维;
- AgentRun durable event→Kafka producer 丢失;
- AgentRun→HWLAB mapper 丢失;
- Kafka→SSE/reducer/read model 丢失或滞后。
- 版本、commit、schema/provenance 漂移只能作为 `warning`,不得阻塞用户业务或覆盖 durable terminal authority。
## 调查顺序
1. 固定 identity:记录 node/lane、business trace、OTel trace、session、run、command 和时间窗口。
2. 查 OTel stdio:统计 `codex_stdio.notification``tool_call.started|completed`、assistant delta 和 diff notification。
3. 查 AgentRun durable events:按 seq 分页,精确下钻 tool/diff/terminal event;不得只看默认前 20 条。
4. 查 Kafka control plane:确认 broker、topic ready、end offset 和相关 fixed group lag。
5. 先执行 `--no-publish`/`--dry-run` regenerate,对照源 frame 数、解析数、产出 event 数、拒绝原因和 lineage。
6. 需要跨 mapper 校对时,才把结果写入 debug topic,再由下一层从 debug topic regenerate;每层保存 count/hash/seq/type/tool identity。
7. 需要浏览器产品 reducer 校对时,使用 Workbench 隔离 debug replay;不得直接操作产品 SSE group。
8. 输出首个发生差异的层级和缺失 identity,不用后层缺失反推前层未产生。
## 受控 CLI
平台状态与只读元数据:
```bash
bun scripts/cli.ts platform-infra kafka status --node NC01
bun scripts/cli.ts platform-infra kafka offsets --node NC01 --topic codex-stdio.raw.v1
bun scripts/cli.ts platform-infra kafka offsets --node NC01 --topic agentrun.event.v1 --group <group>
bun scripts/cli.ts platform-infra kafka tail --node NC01 --topic hwlab.event.v1 --limit 20
```
AgentRun 生产 reducer 重建:
```bash
./scripts/agentrun kafka regenerate agentrun \
--session-id <ses_agentrun_...> \
--trace-id <trc_...> \
--from kafka \
--no-publish \
--format compact \
--json
```
HWLAB 生产 mapper 重建:
```bash
hwlab-cli kafka regenerate hwlab \
--from kafka \
--session-id <session> \
--trace-id <trc_...>
```
Workbench 隔离 replay
```bash
bun scripts/cli.ts web-probe observe command <observerId> \
--type validateWorkbenchKafkaDebugReplay
bun scripts/cli.ts web-probe observe status <observerId> \
--command-id <commandId>
```
## 网络平面
- Kafka cluster DNS 只在目标 k3s 网络内解析;host workspace 报 `getaddrinfo ENOTFOUND` 只说明网络平面错误,不是 topic 或事件缺失。
- 优先在包含正式应用 CLI 的目标 Pod/Job 内运行 regenerate。
- 运行镜像未携带应用 CLI 时,允许 operator 侧通过 `trans <node>:k3s kubectl ... port-forward` 建立有界临时诊断通道,再从同一 node 的 source workspace 使用显式 `--brokers 127.0.0.1:<port>` 调用正式 CLI
- 先确认端口只监听 loopback;
- 只用于当前只读或 debug-topic 调试;
- 完成或失败后立即关闭;
- 不把 port-forward 作为长期入口或验收 authority。
- 禁止因 host 无法解析 cluster DNS 而改写 `/etc/hosts`、暴露 broker 公网端口或添加第二套 Kafka endpoint。
## Replay 校对字段
- stdio 层:frame count、method/type、tool name、call id、started/completed、exit、diff notification、source seq。
- AgentRun 层:event id、run seq、type、phase、command/tool identity、output hash/bytes、terminal。
- HWLAB 层:source event id/seq、mapped envelope id/type、trace/session/command lineage、terminal/final response。
- 浏览器层:server scanned/matched/deliveredclient received/decoded/applieddeduplicatedtimeline 与 terminal seal。
- 每层同时给出:
- 输入数;
- 接受数;
- 拒绝数及 typed reason
- 输出数;
- 首个缺失/不一致 identity
- `mutation``topicAppended``valuesPrinted`
## 判定规则
- stdio 有完整 tool/diff,但 regenerate 未产出对应 AgentRun event:归因 reducer/mapping。
- regenerate 能产出,但 durable `agentrun.event.v1` 缺失:归因 producer/durable append。
- AgentRun 产品 event 存在,而 HWLAB debug mapper不产出:归因 HWLAB mapper。
- `hwlab.event.v1` 存在且 direct publish/fanout 数相等,但 UI/read model 缺失:归因 SSE/reducer/projection。
- `turn/diff/updated` 只保留 phase/correlation、不保留文件或变更摘要时,应报告为源映射降维;不能把它描述成“后续展示偶然漏了一条”。
- 直接答复没有工具调用时,不要求合成 tool event。
## 配合技能
- OTel trace 与跨服务 identity`$unidesk-otel`
- AgentRun durable resource/events`$unidesk-code-queue`
- Workbench 隔离 replay`$unidesk-webdev`
- 跨 host/k3s 临时诊断通道:`$unidesk-trans``$dad-dev`
- Kafka/YAML owner 或 Secret 变更:`$unidesk-ymalops`;本技能默认只读,不从运行面反解 Secret。
@@ -0,0 +1,4 @@
interface:
display_name: "UniDesk Kafka"
short_description: "调试 Kafka 事件链、离线重建与隔离重放校对流程"
default_prompt: "使用 UniDesk 受控 CLI 调查 Kafka topic、offset、stdio/AgentRun/HWLAB 事件映射,并执行不污染产品 topic 的离线或 debug replay。"
+2
View File
@@ -75,6 +75,8 @@ bun scripts/cli.ts web-probe observe analyze <observerId>
## Architecture Preference
Web 哨兵 YAML 小改动使用两条现有入口完成闭环:提交前运行 `web-probe sentinel validate --local`,提交合并后运行 `web-probe sentinel control-plane status --wait`。前者只验证当前 worktree 的 owning YAML 和渲染结果;后者只读观察 PaC/GitOps/Argo/runtime/cadence 收敛,不得触发 publish、sync、refresh 或 patch。收敛中的版本或 cadence 差异只能显示为 `warning`,不得阻塞用户业务。
Prefer Kubernetes-native discovery and isolation before inventing a custom control plane:
- Labels/selectors identify sentinel runners.
+4
View File
@@ -20,6 +20,10 @@ bun scripts/cli.ts platform-infra observability search --grep <service> --limit
## P0 边界
- 微服务契约版本、源码/镜像版本和 `commitId` 漂移只能记录为非阻塞 warning;不得建议或实现线上 admission、dispatch、readiness 或用户业务门禁。业务已成功时,漂移告警不能改写成功结论。
- `diagnose-code-agent` 同时展示 AgentRun durable authority 与 HWLAB projection/read model
- 权威终态决定 `effectiveStatus`
- 投影滞后或冲突固定输出 `warning=true``blocking=false`
- `mutation=false` 表示该聚合入口只读,不执行补链或运行面修补。
- 可见性问题优先修复;状态、耗时、失败原因、trace、命令结果或关键证据不可见时,先补 CLI/日志/状态输出。
- 离线调查中的 OTel 只能分析既有 trace 和已有业务记录;如果 trace/search/diagnose 摘要不好用、缺少业务映射或无法区分 observability gap 与业务失败,先改进 OTel CLI/analyze/instrumentation 可见性,再继续业务结论。
- OTel 查询默认低噪声摘要;完整 span/context 显式 `--full`/`--raw`
+5
View File
@@ -9,6 +9,11 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子
## 高频规则
- 主代理与子代理必须按用户明确目标控制实现范围:
- 未经用户明确要求,禁止新增或扩展通用合同、租约、安全机制、围栏及其配套门禁;
- 既有权限、Secret 脱敏、不可逆操作和损害预防边界继续生效,本规则不授权绕过既有安全底线;
- 发现衍生问题时,只能创建独立 issue 或记录待办,不得扩大当前 prompt、PR、合并范围或验收前置;
- 现有机制阻碍原始目标时,先寻找既有架构内的最小实现路径;确需架构扩展时,必须向用户说明与原始目标的关系并取得明确授权。
- 执行型和调研型委派默认优先使用 AgentRun `Artificer`
- 未显式选模时,不由主代理注入客户端模型默认值;使用 owning AipodSpec 的默认模型,当前为 `gpt-5.6-terra`
- Artificer 的 API credential
+4
View File
@@ -67,6 +67,10 @@ Host workspace、k3s、Windows、GitHub issue/PR route 见 [references/routes.md
- PowerShell prelude 将 `Get-Content` 默认编码固定为 UTF-8,读取 UTF-8 Markdown 时应与 `cat` helper 保持一致。
- Windows `ps '<PowerShell source>'` 的单个参数按 PowerShell 源码执行;`ps <command> <arg...>` 的多个参数按 argv 边界执行,Windows native application 通过显式命令行引用保留 `python -c` 代码、路径、encoding 和字典 key 中的嵌套引号。
- `trans` / `tran` 是 PATH wrapper,不是 `bun scripts/cli.ts` 的根子命令;误写 `bun scripts/cli.ts trans ...` 时应读取结构化 `trans-root-cli-entrypoint-moved` 错误,并改用 `trans <route> <operation> [args...]`
- cwd 语义按运行面分层:
- host/workspace 将绝对 cwd 写入 route,例如 `trans NC01:/root/unidesk exec git status --short`
- k3s workload 使用 route cwd 后缀或 `exec --cwd /workspace -- <command>`
- host/workspace 误用 `exec|argv|sh|bash --cwd` 会在连接前返回 `trans-host-cwd-belongs-in-route` 与精确替代命令,禁止继续试探远端参数。
- Direct argv 会保留调用端已经形成的每个 argv token,包括空格和中文;需要 shell 语法时仍显式使用 `sh``bash`、Windows `cmd``ps`
- Windows workspace 中依赖 Windows PATH、解释器或工具链的命令优先走 `<provider>:win/<drive>/<path> cmd|ps`;不得因同一 WSL provider 的 host/WSL plane 缺少 `python`、编译器或其他命令,就判断 Windows plane 也未安装。Windows Python 例如 `trans G14-WSL:win/d/Work/CONSTAR_workspace cmd python --version`
- `/mnt/<drive>` workspace 的命令返回 `command not found` 时,CLI 会输出对应 `:win/<drive>/... cmd``UNIDESK_SSH_HINT`;该提示只建议核对 Windows plane,不改变原命令退出码。
+3
View File
@@ -87,6 +87,9 @@ description: UniDesk YAML-first 运维正规化技能。用户提到 ymal-first/
## Validation
- Web 哨兵 cadence 例外采用现有 CLI 的轻量投影:`web-probe sentinel validate --local` 必须证明当前 worktree owning YAML 的 `configRef`、场景 cadence 和渲染 Cron,且 `mutation=false`
- 合并后只允许 `web-probe sentinel control-plane status --wait` 观察 GitOps 期望与 runtime 事实;旧 worktree 值不得参与线上阻塞判定。
- CLI 轻量变更:默认最多做 `bun --check`/TypeScript 语法、相关 `--help`、目标 `plan`/`validate`/`sync --dry-run` 或等价命令形态确认。
- 配置数值变更:只验证能被目标 CLI 读取和渲染,数值本身以 YAML 为准。
- 运行面变更:用原受控 CLI 入口验证实际对象、fingerprint、presence、health 或 public URL;不要绕到原生 `kubectl`/`curl` 作为正式控制面。
+4
View File
@@ -49,6 +49,10 @@
## P0: 最新要求、可见性与验证
- P0: 未经用户明确要求,禁止为当前任务新增或扩展合同、租约、安全机制或围栏:
- 既有权限、脱敏和损害预防底线继续遵守;
- 衍生问题只能登记支线,不得实现、合并或作为主线门禁;
- 细则见 `docs/reference/devops-hygiene.md``$unidesk-subagent`
- P0: 微服务线上契约、版本和 `commitId` 对齐只允许非阻塞 warning,禁止阻塞用户业务;细则见 `docs/reference/devops-hygiene.md`
- P0: 用户最新明确要求优先于旧测试、旧门禁、旧预检、旧断言和旧 guard:
- 阻碍最新目标的旧入口应删除;
@@ -27,7 +27,7 @@ sentinel:
- id: workbench-dsflash-go-hwpod-two-turn-freeze-repro
enabled: true
cadence: 1h
cadence: 2h
observeTargetPath: /workbench
sampleIntervalMs: 1000
screenshotIntervalMs: 60000
@@ -65,6 +65,9 @@
#### R4.1.1 [in_progress]
解决 [UniDesk #1820](https://github.com/pikasTech/unidesk/issues/1820):对齐 cleanup-runners 只读 observer 与 AgentRun manager retention 的终态 claim 分类,默认有界披露 terminal-run-stale-claim、repairKind 和一致的 candidate/protected 计数;manager 保持唯一回收 authority,禁止增加删除入口或第二事实源,完成任务后将详细报告写入[任务报告](./details/agentrun-runtime-reliability/R4.1.1_Task_Report.md)。
### R4.2
解决 [AgentRun #284](https://github.com/pikasTech/agentrun/issues/284):由 manager 以租约/fencing token 阻断目标 provider profile 的新 runner admission,统一观察既有 runner,并用 Kubernetes UID/resourceVersion CAS 原子提交 Provider Secret;幂等返回 no-op/blocked/applied,切主、租约过期、runner 状态变化或 CAS 冲突均 fail closed。待 #324 合入后由原生子代理基于最新 v0.2 实现,禁止 Artificer 递归自修复、UniDesk check-then-act、Pod-only 授权、手工 Secret patch 或人工 CI/CD,完成任务后将详细报告写入[任务报告](./details/agentrun-runtime-reliability/R4.2_Task_Report.md)。
## R5 [in_progress]
完善 Artificer AipodSpec、primary workspace、resource bundle 物化与业务 turn admission,使声明式源码快照、能力 bundle 和失败可见性形成单一合同,完成任务后将详细报告写入[任务报告](./details/agentrun-runtime-reliability/R5_Task_Report.md)。
@@ -9,7 +9,7 @@
### R1.1 [in_progress]
在 [UniDesk #1846](https://github.com/pikasTech/unidesk/issues/1846) 将 gpt.pika 的 auth.json/config.toml 唯一 sourceRef 改为 /root/.codex/*.pikaYAML plan/sync 只披露 presence/fingerprint,缺失 fail closed,完成任务后将详细报告写入[任务报告](./details/artificer-runtime-capabilities/R1.1_Task_Report.md)。
在 [UniDesk #1846](https://github.com/pikasTech/unidesk/issues/1846) 将 gpt.pika 的 auth.json/config.toml 唯一 sourceRef 改为 /root/.codex/*.pikaplan/sync 默认视图显示精确 sourceRef 与 source/effective profileconfig.toml 同步前验证显式 provider/base URL 且不输出值,activation fence blocked 时不提示 --confirm,缺失或不安全配置 fail closed,完成任务后将详细报告写入[任务报告](./details/artificer-runtime-capabilities/R1.1_Task_Report.md)。
### R1.2 [in_progress]
@@ -102,3 +102,7 @@
### R5.3
补 text/JSON/YAML 同构测试与真实 cancel dry-run 复验,确认 Next 可直接执行且 reason hash 相同;提交独立 PR,完成任务后将详细报告写入[任务报告](./details/cli-output-progressive-disclosure/R5.3_Task_Report.md)。
## R6
解决 [UniDesk #1850](https://github.com/pikasTech/unidesk/issues/1850):统一 trans 规范入口、host/workspace/k3s 的 cwd 与 scoped help,补齐验证成功非空摘要,并为 PR 自动交付及 Code Agent 跨服务诊断提供只读聚合视图;必须分层展示 durable authority 与 projection warning,禁止新增手工 trigger/sync/refresh、提高 stdout 阈值或用版本/commit 契约漂移阻塞业务。由独立子代理在 .worktree/tooling-usability、fix/tooling-usability 分支调查、实现、针对性验证并提交 PR,不自行合并,完成任务后将详细报告写入[任务报告](./details/cli-output-progressive-disclosure/R6_Task_Report.md)。
@@ -0,0 +1,72 @@
# R2 任务报告
## 结论
第二轮文件编辑展示缺失的首个丢失层位于 AgentRun `codex-stdio` notification reducer,而不是 HWLAB Kafka mapper、SSE 或最终 UI 偶发漏 event。
Codex app-server 产生了 `fileChange` item,但 AgentRun 的 `isVisibleCodexToolItemType` 只接受 `commandExecution``webSearch``mcpToolCall``dynamicToolCall``fileChange``item/started``item/completed` 被计入 suppressed summary;文件变更只剩无文件身份的 `turn/diff/updated` backend status。因此后续 Kafka/HWLAB 无法恢复 `apply_patch`、文件名或 patch 摘要。
## Identity
- business trace`trc_9352107b5e8a4125`
- OTel trace`e8fb030498a68cb2cbbc460c439fc3fd`
- HWLAB session`ses_d4473d6c-4d7a-4612-a102-254977698bc2`
- AgentRun session`ses_agentrun_d4473d6c_4d7a_4612_a102_254977698bc2`
- run`run_cc1bf3a430b14de1b1e147ea95f7773e`
- command`cmd_dffb545477d145f6bbb66d5500dff7ed`
## 分层证据
### stdio / OTel
- `codex_stdio.notification`985 spans。
- `codex_stdio.tool_call.started`29 spans。
- `codex_stdio.tool_call.completed`28 spans。
- 运行内存在真实文件编辑;AgentRun suppressed summary 的 `itemTypes` 明确包含 `fileChange: 8`
### AgentRun durable events
- 普通 shell/command 工具被正确投影为成对 `tool_call started/completed`
- 文件编辑对应多条 `turn/diff/updated`,例如 seq 56、57、59、67、70、71、78、81-88、92、100、105、108、112、115。
- raw durable payload 只有 `phase``runnerId``attemptId``commandId`,没有 file path、itemId、tool name、patch summary 或 diff hash。
- seq 117 suppressed summary
- suppressed total 94
- `item/started` 24、`item/completed` 24
- `fileChange` 8、`reasoning` 38、`userMessage` 2。
### 源码映射
`src/backend/codex-stdio.ts``isVisibleCodexToolItemType` 只允许四类工具,不包含 `fileChange`。未识别的 `turn/diff/updated` 进入 generic backend status fallback,只保存 phase。
### Kafka CLI replay
- `platform-infra kafka status --node NC01`broker 与五个相关产品/debug topic ready。
- `codex-stdio.raw.v1` end offset 为 26558`agentrun.event.v1` end offset 为 6406`hwlab.event.v1` end offset 为 4906。
- 使用正式 AgentRun CLI 从 Kafka 执行 `regenerate agentrun --no-publish`
- 按 AgentRun session + business trace`inputRecordCount=0`
- 按 HWLAB session + business trace`inputRecordCount=0`
- `kafka tail stdio` 按 session/trace 与 run/command 均 matched=0。
- 调试过程没有向产品或 debug topic 追加事件;`topicAppended=false`(由 `--no-publish` 保证)。
- host 直接使用 cluster DNS 首次返回 `getaddrinfo ENOTFOUND`,随后按 `unidesk-kafka` 使用 loopback 临时 port-forward;通道已关闭。
- KafkaJS 出现既有 `TimeoutNegativeWarning`,不作为数据丢失证据。
### HWLAB / Workbench
- OTel 显示 `hwlab.kafka.live.direct_publish``fanout_receive` 均为 124,产品 Kafka→fanout 没有计数差。
- HWLAB legacy trace store 只保留 7 条 admission diagnosticsprojection 仍停在 projectingdurable terminal authority 仍是 AgentRun completed。
- 即使 HWLAB mapper/SSE 完整转发,它收到的文件变更事实已经是降维后的 `turn/diff/updated`,无法生成 `apply_patch`/file edit 展示。
## 判定
- 源 stdio:产生了 fileChange。
- stdio raw Kafka:第二轮没有可重建输入。
- stdio→AgentRun reducer:主动抑制 `fileChange`,是首个确定丢失点。
- AgentRun durable→Kafka→HWLAB:传递的是已降维事件;不是首个根因。
- HWLAB projection:另有滞后,但不影响本次文件编辑缺失根因判定。
## 建议修复边界
- AgentRun 将 `fileChange item/started|completed` 映射为正式 `tool_call``diff` event,保留脱敏 file identity、itemId、status、changes/file count、bytes/hash 和 failure;不要保存完整 patch 正文。
- `turn/diff/updated` 应保留有界 diff summary/hash,并与 fileChange item lineage 关联。
- stdio raw topic 应提供可验证的 producer/retention 状态与按 run/session/trace 的 replay count,不能让 OTel 有 frame 而 regenerate 输入为 0 时只有 `schema-invalid`
- HWLAB mapper/UI 在上游事件补齐后再增加 file edit/apply_patch 卡片;不能在 UI 合成上游不存在的工具事件。
@@ -18,3 +18,7 @@
### R1.3
依赖 R1.2,用固定 warm-session trace 验证重复诊断不改变原业务 trace 的 terminal/instrumentation 判定,诊断 trace 仍完整可查;补跨服务合同和端到端验收,确认实时、刷新回放及纯 Kafka + SSE 事件顺序均无变化,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R1.3_Task_Report.md)。
## R2 [completed]
调查第二轮业务 trace `trc_9352107b5e8a4125` / session `ses_d4473d6c-4d7a-4612-a102-254977698bc2` 的文件编辑、`apply_patch` 与 tool event 可见性:先解析对应 OTel trace/run/command,再对照 codex stdio notification、AgentRun durable events、`agentrun.event.v1``hwlab.event.v1` 和 HWLAB trace/read model,并使用受控的集群内或应用侧 CLI 执行 Kafka replay 校对,判定事件是源头未产生、AgentRun 映射遗漏、Kafka 转换丢失还是 HWLAB 投影丢失;不得手写 Kafka API、不得用重放追加产品 topic event、不得把投影滞后改写成业务失败,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R2_Task_Report.md)。
+13 -1
View File
@@ -22,7 +22,19 @@ UniDesk 的统一 CLI 实现入口是根目录 `scripts/cli.ts`,运行方式
`trans` wrapper 是 SSH/WSL/k3s 透传的唯一默认入口:人工/Codex 远端操作、长期参考文档、AGENTS 索引、CLI help、非交互脚本和非交互 `exec` 都必须直接调主 server PATH 上的 `/root/.local/bin/trans`;禁止把 `bun scripts/cli.ts ssh ...``bun scripts/cli.ts trans ...` 或任何带 `bun scripts/cli.ts` 前缀的透传写法作为默认入口。`bun scripts/cli.ts help``config``server``provider``microservice` 等普通根 CLI 子命令不受这条限制,仍使用 `bun scripts/cli.ts <command>`,避免透传命令和根子命令在调用前缀上互相混淆。
误写 `bun scripts/cli.ts trans ...``bun scripts/cli.ts tran ...` 时,根 CLI 必须返回结构化 `trans-root-cli-entrypoint-moved` 错误、`replacementExamples` `Use: trans <route> <operation> [args...]` 迁移提示,不能退化成通用 `Unknown command`
误写 `bun scripts/cli.ts trans ...``bun scripts/cli.ts tran ...` 时,根 CLI 必须在本地返回紧凑的 `trans-root-cli-entrypoint-moved` 输入错误、精确 `usage` 替代命令`Use: trans <route> <operation> [args...]` 迁移提示,不能连接运行面、披露 stack 或退化成通用 `Unknown command`
- `trans` 的 cwd 与本地校验合同:
- host/workspace 的 cwd 只写入 route,例如 `trans NC01:/root/unidesk exec git status --short``trans NC01:/root/unidesk bash <<'BASH'`
- k3s workload 的容器 cwd 使用 route 后缀或 `exec --cwd /absolute/path -- <command>`
- host/workspace 上误用 `exec|argv|sh|bash --cwd` 时,必须在选择 transport 和连接运行面前返回 `trans-host-cwd-belongs-in-route` 与精确替代命令;
- `trans --help` 必须直接展示 host/workspace 与 k3s cwd 摘要,`trans --help exec|bash` 提供可执行示例。
- Code Agent 跨服务只读诊断合同:
- `platform-infra observability diagnose-code-agent` 是 AgentRun durable authority、HWLAB projection/read model 与 OTel 服务覆盖的单命令聚合入口;
- 默认 text 与显式 `--full|--raw` JSON 必须共同披露 `typed outcome``effectiveStatus``mutation=false`、durable authority 和 projection 状态;
- AgentRun 权威终态与 HWLAB 投影不一致时,以权威终态作为有效结果,并将投影滞后或冲突标记为 `warning=true``blocking=false`
- 聚合入口只读,不得触发 mirror sync、PipelineRun、Argo refresh/sync 或运行面修补。
Windows `ps` 的参数语义分为两种:单个参数是完整 PowerShell source;多个参数是边界明确的 argv。后者对 native application 使用 Windows 命令行引用规则,必须保留 `python -c` 代码中的路径、`encoding="utf-8"`、字典 key 和嵌套引号,并继承 route 对应的 Windows workspace。PowerShell prelude 将 `Get-Content` 默认编码固定为 UTF-8Windows fs helper 的 `head` / `tail` 同时支持 `-n N``-nN``--lines=N` 和 GNU `-N``wc -l` 必须把选项与路径分开解析。
+14
View File
@@ -166,6 +166,20 @@ CHANGELOG 的文档交叉引用必须同时满足以下要求:
- <用户可感知的功能或问题修复>。文档同步:`<API 文档>` <编号> <接口或字段变化>`<配置文档>` <编号> <默认值、范围或生效规则变化>;`<测试文档>` <编号> <验收场景或判定变化>。
```
## 用户目标与范围控制
实现范围只由用户明确目标及其在现有架构内不可缺少的直接前置组成。判断某项工作是否属于直接前置时,必须证明不完成该项就无法通过原始入口实现用户要求;仅能提高通用性、并发安全、可见性、兼容性或未来健壮性的工作属于衍生问题。
- 未经用户明确要求,禁止新增或扩展:
- 通用服务合同或版本合同;
- 租约、选主或 fencing token
- 新的安全机制、权限体系或凭据治理层;
- admission fence、activation fence、CAS 围栏或配套门禁。
- 既有权限校验、Secret 脱敏、不可逆操作保护和损害预防规则必须继续遵守;范围控制不授权删除仍保护明确风险的既有安全底线。
- 发现衍生问题时可以创建边界独立的 issue 或 MDTODO 记录,但在用户另行授权前不得实现、合并、部署,也不得把它加入原任务的验收条件或阻塞路径。
- 现有 guard 或工具限制阻碍原始目标时,先验证它是否仍保护当前任务中的明确风险,并优先使用现有架构内的最小配置或受控操作完成目标;若只能通过架构扩展解决,必须先向用户说明必要性、影响范围和替代方案并取得明确授权。
- 子代理不得自行扩大 issue、prompt 或 PR 范围。主代理负责在 review、preflight 和合并前删除非必要前置,并把获准之外的发现留在支线。
## Verification Priority
The user's latest explicit objective takes precedence over stale tests, guards, preflights and assertions. When an old gate blocks the current requirement without protecting a still-valid high-value risk, remove it from the active path instead of preserving a fallback, legacy mode or compatibility branch around it.
+8
View File
@@ -192,3 +192,11 @@ Avoid these patterns:
Long-term references should point to this architecture for common YAML-first ops rules, then document only domain-specific ownership and entrypoints. They should not repeat common Secret, exposure, target, redaction or no-hardcoding rules unless a domain adds a stricter constraint.
When a recurring operation becomes stable, update the owning reference document and the relevant skill with the domain entrypoint and decision boundary. Do not document one-off manual recovery as the standard path; manual repair remains recovery evidence until the YAML and CLI path exists.
## Web 哨兵最短变更路径
- 修改 cadence 前,使用 `web-probe sentinel validate --local` 直接读取当前 worktree 的 owning YAML。
- 本地验证必须输出 `configRef``scenarioId``cadence`、渲染后的 Cron 表达式和 `mutation=false`,不得读取远端 authority 来替代未提交 YAML。
- 合并后的观察使用 `web-probe sentinel control-plane status --wait`;该入口只读,等待和轮询预算来自 owning YAML。
- 状态必须同时区分 GitOps 期望和运行时事实,并输出 `desiredSourceCommit``runtimeSourceCommit``desiredSchedule``runtimeSchedule``converging``warning``blocking=false`
- 旧 worktree 的本地渲染值不得作为线上 cadence 对齐门禁;线上期望值来自 GitOps 清单。
+5 -13
View File
@@ -769,21 +769,13 @@ async function main(): Promise<void> {
}
if (top === "trans" || top === "tran") {
const error = new Error(`${top} is a PATH wrapper, not a bun scripts/cli.ts subcommand`);
Object.assign(error, {
name: "TransEntrypointMigrationError",
const replacement = `trans ${sub ?? "<route>"} ${args.slice(2).join(" ") || "<operation>"}`;
throw new CliInputError(`${top} is a PATH wrapper, not a bun scripts/cli.ts subcommand`, {
code: "trans-root-cli-entrypoint-moved",
level: "error",
entrypoint: top,
operation: "root-cli-dispatch",
replacementExamples: {
canonical: `trans ${sub ?? "<route>"} ${args.slice(2).join(" ") || "<operation>"}`,
legacyUnified: `bun scripts/cli.ts ssh ${sub ?? "<route>"} ${args.slice(2).join(" ") || "<operation>"}`,
},
migrationHint: "Use: trans <route> <operation> [args...]. The PATH wrapper delegates to the lightweight SSH CLI.",
note: "Ordinary root CLI commands continue to use bun scripts/cli.ts <command>.",
argument: top,
usage: replacement,
hint: "Use trans <route> <operation> [args...]; ordinary root CLI commands continue to use bun scripts/cli.ts <command>.",
});
throw error;
}
throw new CliInputError(`Unknown command: ${commandName}`, {
+15 -4
View File
@@ -190,8 +190,12 @@ const SSH_OPERATION_HELP: Record<SshHelpScope, SshOperationHelp> = {
},
exec: {
group: "process",
description: "在 k3s workload route 中运行进程,并可显式透传 stdin 或指定容器内 cwd。",
usage: ["trans <provider>:k3s:<namespace>:<workload>[:<container>] exec [--cwd /path] [--stdin] -- <command> [args...]"],
description: "运行单个远端进程;host/workspace 的 cwd 写入 routek3s workload 用 --cwd 指定容器内目录。",
usage: [
"trans <provider>:/absolute/workspace exec <command> [args...]",
"trans <provider>:k3s:<namespace>:<workload>[:<container>] exec [--cwd /path] [--stdin] -- <command> [args...]",
],
notes: ["host/workspace 不接受 exec --cwd;改写 route,例如 trans D601:/workspace exec git status。"],
},
git: {
group: "process",
@@ -221,8 +225,9 @@ const SSH_OPERATION_HELP: Record<SshHelpScope, SshOperationHelp> = {
},
bash: {
group: "shell",
description: "仅在需要 pipefail、数组、[[ ]] 等 Bash 语法时执行远端 Bash。",
usage: ["trans <route> bash <<'BASH'", "trans <route> bash -- '<bash command>'"],
description: "仅在需要 pipefail、数组、[[ ]] 等 Bash 语法时执行远端 Bashcwd 由 route 选择。",
usage: ["trans <provider>:/absolute/workspace bash <<'BASH'", "trans <provider>:k3s:<namespace>:<workload>[/cwd] bash <<'BASH'", "trans <route> bash -- '<bash command>'"],
notes: ["host/workspace 不接受 bash --cwd;将绝对目录写入 route。"],
},
py: {
group: "shell",
@@ -327,6 +332,11 @@ export function sshHelp(scope: SshHelpScope | undefined = undefined): unknown {
"远端文本读取优先 cat/rg,修改优先 apply-patchupload/download 仅用于必要的二进制或生成物。",
"普通 trans 短连接上限保持 60s;长流程使用受控 submit-and-poll 入口。",
],
cwdSemantics: {
hostWorkspace: `${entrypoint} <provider>:/absolute/workspace exec|sh|bash ...`,
k3sWorkload: `${entrypoint} <provider>:k3s:<namespace>:<workload>[:<container>] exec --cwd /absolute/container/path -- <command>`,
rule: "host/workspace cwd 属于 routek3s 容器 cwd 可由 route 后缀或 exec --cwd 指定。",
},
};
}
@@ -343,6 +353,7 @@ export function renderSshHelpText(scope: SshHelpScope | undefined = undefined):
`patch: ${SSH_OPERATION_GROUPS.patch.join(" | ")}`,
`transfer: ${SSH_OPERATION_GROUPS.transfer.join(" | ")}`,
`scoped help: ${entrypoint} --help <operation>`,
`cwd: host/workspace uses <provider>:/absolute/workspace; k3s exec uses --cwd /absolute/container/path`,
"boundary: route only selects the target; use sh/bash/ps/cmd explicitly for shell syntax.",
].join("\n");
}
@@ -3,6 +3,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, test } from "bun:test";
import { resolveSentinelChildJson } from "./hwlab-node-web-sentinel-cicd-shared";
import { sentinelDeliveryStatus, type SentinelCicdState } from "./hwlab-node-web-sentinel-cicd";
describe("sentinel CI/CD child JSON recovery", () => {
test("recovers remote probe JSON from trans truncation dump", () => {
@@ -37,3 +38,22 @@ describe("sentinel CI/CD child JSON recovery", () => {
expect(resolved.diagnostics.source).toBe("dump");
});
});
test("sentinel delivery status distinguishes cadence convergence without blocking", () => {
const commit = "f87cd3ac8fd2494f9dfed51e7d8ececd3fc42939";
const state = { sourceHead: { commit } } as unknown as SentinelCicdState;
const base = {
sourceMirror: { ok: true },
registry: { probe: { present: true } },
gitMirror: { ok: true },
gitops: { ok: true, sourceCommit: commit, schedule: "0 */2 * * *", revision: "gitops-revision" },
argo: { ok: true, syncStatus: "Synced", healthStatus: "Healthy" },
runtime: { ok: true, probe: { deployment: { sourceCommit: commit } } },
};
const converging = sentinelDeliveryStatus(state, { ...base, cadence: { ok: false, probe: { schedule: "0 * * * *" } } }, "pipeline-run");
expect(converging.outcome).toBe("converging");
expect(converging.blocking).toBe(false);
const converged = sentinelDeliveryStatus(state, { ...base, cadence: { ok: true, probe: { schedule: "0 */2 * * *" } } }, "pipeline-run");
expect(converged.outcome).toBe("converged");
expect(converged.desiredSourceCommit).toBe(commit);
});
@@ -102,6 +102,7 @@ export type WebProbeSentinelOptions =
readonly wait: boolean;
readonly timeoutSeconds: number;
readonly quickVerify: boolean;
readonly localOnly: boolean;
}
| {
readonly kind: "report";
@@ -659,6 +660,7 @@ export function renderControlPlaneResult(result: Record<string, unknown>): strin
const gitops = record(result.gitops);
const argo = record(result.argo);
const validation = record(result.validation);
const deliveryStatus = record(result.deliveryStatus);
const observability = record(result.observability);
const observed = record(result.observed);
const sourceMirrorSync = record(result.sourceMirrorSync);
@@ -751,6 +753,11 @@ export function renderControlPlaneResult(result: Record<string, unknown>): strin
"",
renderObservedStatus(observed),
"",
Object.keys(deliveryStatus).length === 0 ? "DELIVERY_STATUS\n-" : table(
["OUTCOME", "DESIRED_COMMIT", "RUNTIME_COMMIT", "DESIRED_SCHEDULE", "RUNTIME_SCHEDULE", "ARGO", "READY", "CONVERGING", "BLOCKING"],
[[deliveryStatus.outcome, short(deliveryStatus.desiredSourceCommit), short(deliveryStatus.runtimeSourceCommit), deliveryStatus.desiredSchedule ?? "-", deliveryStatus.runtimeSchedule ?? "-", `${deliveryStatus.argoSyncStatus ?? "-"}/${deliveryStatus.argoHealthStatus ?? "-"}`, deliveryStatus.runtimeReady, deliveryStatus.converging, deliveryStatus.blocking]],
),
"",
Object.keys(statusDiagnosis).length === 0 ? "STATUS_DIAGNOSIS\n-" : [
"STATUS_DIAGNOSIS",
table(["CODE", "PHASE", "PIPELINERUN", "SOURCE", "REGISTRY", "GIT_MIRROR", "GITOPS", "ARGO", "RUNTIME"], [[
@@ -965,8 +972,8 @@ export function renderAsyncJobResult(result: Record<string, unknown>): string {
].join("\n");
}
export function rendered(ok: boolean, command: string, text: string): RenderedCliResult {
return { ok, command, renderedText: `${text.trimEnd()}\n`, contentType: "text/plain" };
export function rendered(ok: boolean, command: string, text: string, projection?: Record<string, unknown>): RenderedCliResult {
return { ok, command, renderedText: `${text.trimEnd()}\n`, contentType: "text/plain", ...(projection === undefined ? {} : { projection }) };
}
export function sentinelProgressEvent(event: string, payload: Record<string, unknown>): void {
+59 -10
View File
@@ -168,7 +168,16 @@ export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options:
if (blocked !== null) return blocked;
}
requireSentinelIdForRegistry(spec, options.sentinelId, `web-probe sentinel ${options.kind}`);
const state = loadSentinelCicdState(spec, options.sentinelId, options.timeoutSeconds, sentinelSourceResolveMode(options), sentinelSourceOverrideFromOptions(options));
const localCommit = options.kind === "validate" && options.localOnly
? runCommand(["git", "rev-parse", "HEAD"], repoRoot, { timeoutMs: 5_000 }).stdout.trim()
: null;
const localSourceOverride = localCommit === null ? null : {
commit: localCommit,
stageRef: null,
mirrorCommit: localCommit,
sourceAuthority: "gitea-snapshot" as const,
};
const state = loadSentinelCicdState(spec, options.sentinelId, options.timeoutSeconds, sentinelSourceResolveMode(options), localSourceOverride ?? sentinelSourceOverrideFromOptions(options));
if (options.kind === "image") return runSentinelImage(state, options);
if (options.kind === "control-plane") return runSentinelControlPlane(state, options);
if (options.kind === "publish") return runSentinelPublishCurrent(state, options);
@@ -229,13 +238,17 @@ function runSentinelControlPlane(state: SentinelCicdState, options: Extract<WebP
if (!options.wait) return renderAsyncSentinelJob(state, "control-plane", options.action, options.timeoutSeconds);
return runSentinelControlPlaneConfirmed(state, options);
}
const observed = options.action === "status" ? collectSentinelObservedStatus(state, options.timeoutSeconds) : null;
const observed = options.action === "status"
? options.wait
? waitForSentinelObservedStatus(state, options.timeoutSeconds)
: collectSentinelObservedStatus(state, options.timeoutSeconds)
: null;
const observedReady = options.action !== "status" || sentinelObservedReady(record(observed));
const observedWarnings = options.action === "status" ? sentinelObservedWarnings(record(observed)) : [];
const pipelineRun = sentinelPipelineRunName(state, options.rerun);
const statusDiagnosis = options.action === "status" && !observedReady ? sentinelObservedStatusDiagnosis(state, observed, pipelineRun) : null;
const result = {
ok: state.configReady && state.sourceHead.ok && observedReady,
ok: state.configReady && state.sourceHead.ok && (options.action === "status" || observedReady),
command,
node: state.spec.nodeId,
lane: state.spec.lane,
@@ -283,17 +296,18 @@ function runSentinelControlPlane(state: SentinelCicdState, options: Extract<WebP
cicd: state.cicd,
}),
observed,
deliveryStatus: options.action === "status" ? sentinelDeliveryStatus(state, record(observed), pipelineRun) : null,
statusDiagnosis,
drillDown: controlPlaneDrillDown(state, pipelineRun, null),
warnings: mergeWarnings(observedWarnings, record(statusDiagnosis).warning),
blocker: observedReady
blocker: options.action === "status" || observedReady
? null
: record(statusDiagnosis).blocker ?? { code: "sentinel-control-plane-observed-not-ready", reason: "one or more source, registry, GitOps, Argo, runtime or cadence checks did not pass", valuesRedacted: true },
recoveryNext: record(statusDiagnosis).recoveryNext ?? null,
next: controlPlaneNext(state, options.action),
valuesRedacted: true,
};
return rendered(result.ok, command, renderControlPlaneResult(result));
return rendered(result.ok, command, renderControlPlaneResult(result), options.action === "status" ? record(result.deliveryStatus) : undefined);
}
function runSentinelPublishCurrent(state: SentinelCicdState, options: Extract<WebProbeSentinelOptions, { kind: "publish" }>): RenderedCliResult {
@@ -1924,7 +1938,7 @@ function collectSentinelObservedStatus(state: SentinelCicdState, timeoutSeconds:
gitops,
argo: probeArgoApplication(state, timeoutSeconds, effectiveExpectation.gitopsRevision),
runtime: probeRuntimeObjects(state, timeoutSeconds, effectiveExpectation.runtimeImage),
cadence: probeCadenceCronJob(state, timeoutSeconds),
cadence: probeCadenceCronJob(state, timeoutSeconds, nonEmptyString(gitops.schedule)),
};
}
@@ -1934,7 +1948,8 @@ function waitForSentinelObservedStatus(state: SentinelCicdState, timeoutSeconds:
let observed = collectSentinelObservedStatus(state, timeoutSeconds, expectation, includeGitMirror);
let polls = 1;
while (!sentinelObservedReady(observed) && Date.now() - startedAt < timeoutMs) {
runCommand(["sleep", "2"], repoRoot, { timeoutMs: 3_000 });
const pollSeconds = numberAtNullable(state.cicd, "confirmWait.pollSeconds") ?? 2;
runCommand(["sleep", String(pollSeconds)], repoRoot, { timeoutMs: (pollSeconds + 1) * 1_000 });
observed = collectSentinelObservedStatus(state, timeoutSeconds, expectation, includeGitMirror);
polls += 1;
}
@@ -1964,6 +1979,35 @@ function sentinelObservedReady(value: Record<string, unknown> | SentinelObserved
&& record(observed.cadence).ok === true;
}
export function sentinelDeliveryStatus(state: SentinelCicdState, observed: Record<string, unknown>, pipelineRun: string): Record<string, unknown> {
const gitops = record(observed.gitops);
const runtimeDeployment = record(record(observed.runtime).probe).deployment;
const cadenceProbe = record(record(observed.cadence).probe);
const argo = record(observed.argo);
const desiredSourceCommit = nonEmptyString(gitops.sourceCommit) ?? state.sourceHead.commit;
const runtimeSourceCommit = nonEmptyString(record(runtimeDeployment).sourceCommit);
const desiredSchedule = nonEmptyString(gitops.schedule);
const runtimeSchedule = nonEmptyString(cadenceProbe.schedule);
const converging = desiredSourceCommit !== runtimeSourceCommit || desiredSchedule !== runtimeSchedule || !sentinelObservedReady(observed);
return {
desiredSourceCommit,
runtimeSourceCommit,
pipelineRun,
gitopsRevision: gitops.revision ?? null,
argoSyncStatus: argo.syncStatus ?? null,
argoHealthStatus: argo.healthStatus ?? null,
runtimeReady: record(observed.runtime).ok === true,
desiredSchedule,
runtimeSchedule,
converging,
warning: converging ? "GitOps 期望状态尚未完全收敛;这是只读、非阻塞 warning。" : null,
blocking: false,
outcome: converging ? "converging" : "converged",
mutation: false,
valuesRedacted: true,
};
}
function sentinelObservedWarnings(value: Record<string, unknown> | SentinelObservedStatus | null): string[] {
const observed = record(value);
const argo = record(observed.argo);
@@ -2253,6 +2297,8 @@ function probeGitopsRuntimeManifest(state: SentinelCicdState, timeoutSeconds: nu
const revision = /^[0-9a-f]{40}$/iu.test(revisionLine?.trim() ?? "") ? revisionLine.trim() : null;
const manifest = manifestLines.join("\n");
const image = nonEmptyString(manifest.match(/image:\s*([^,\s}\]]+)/u)?.[1]);
const sourceCommit = nonEmptyString(manifest.match(/unidesk\.ai\/source-commit:\s*["']?([0-9a-f]{40})/iu)?.[1]);
const schedule = nonEmptyString(manifest.match(/\bschedule:\s*["']?([^,"'\r\n#]+)/u)?.[1]?.trim());
const imageMatchesRepository = image !== null && image.startsWith(`${state.image.repository}@sha256:`);
const compact = compactCommand(result);
return {
@@ -2261,6 +2307,8 @@ function probeGitopsRuntimeManifest(state: SentinelCicdState, timeoutSeconds: nu
branch,
manifestPath,
image,
sourceCommit,
schedule,
imageMatchesRepository,
result: { ...compact, stdoutPreview: `${revision ?? "-"} ${image ?? "-"}` },
valuesRedacted: true,
@@ -2301,9 +2349,10 @@ function probeRuntimeObjects(state: SentinelCicdState, timeoutSeconds: number, e
"const ready = Number(dep?.status?.readyReplicas ?? 0);",
"const updated = Number(dep?.status?.updatedReplicas ?? 0);",
"const image = dep?.spec?.template?.spec?.containers?.[0]?.image ?? null;",
"const sourceCommit = dep?.spec?.template?.metadata?.annotations?.['unidesk.ai/source-commit'] ?? dep?.metadata?.annotations?.['unidesk.ai/source-commit'] ?? null;",
"const imageMatches = expectedImage === null || image === expectedImage;",
"const payload = {",
" deployment: { present: deploymentPresent, desiredReplicas: desired, readyReplicas: ready, updatedReplicas: updated, image, expectedImage, imageMatches },",
" deployment: { present: deploymentPresent, desiredReplicas: desired, readyReplicas: ready, updatedReplicas: updated, image, expectedImage, imageMatches, sourceCommit },",
" service: { present: rc('svc') === 0 },",
" pvc: { present: rc('pvc') === 0, phase: json('pvc')?.status?.phase ?? null },",
" configMap: { present: rc('cm') === 0 },",
@@ -2320,7 +2369,7 @@ function probeRuntimeObjects(state: SentinelCicdState, timeoutSeconds: number, e
return { ok: result.exitCode === 0 && probe?.ok === true, probe, stdoutRecovery: probeResolution.diagnostics, result: compactCommand(result) };
}
function probeCadenceCronJob(state: SentinelCicdState, timeoutSeconds: number): Record<string, unknown> {
function probeCadenceCronJob(state: SentinelCicdState, timeoutSeconds: number, desiredSchedule: string | null = null): Record<string, unknown> {
const expected = state.manifests.find((item) => item.kind === "CronJob") ?? null;
if (expected === null) {
return { ok: true, skipped: true, reason: "targetValidation.cadenceScheduler.disabled", valuesRedacted: true };
@@ -2329,7 +2378,7 @@ function probeCadenceCronJob(state: SentinelCicdState, timeoutSeconds: number):
const spec = record(expected.spec);
const namespace = stringAt(metadata, "namespace");
const name = stringAt(metadata, "name");
const expectedSchedule = stringAt(spec, "schedule");
const expectedSchedule = desiredSchedule ?? stringAt(spec, "schedule");
const script = [
"set +e",
`namespace=${shellQuote(namespace)}`,
+42
View File
@@ -128,6 +128,48 @@ export function runSentinelMaintenance(state: SentinelCicdState, options: Extrac
export function runSentinelValidate(state: SentinelCicdState, options: Extract<WebProbeSentinelOptions, { kind: "validate" }>): RenderedCliResult {
const command = "web-probe sentinel validate";
if (options.localOnly) {
const cronJob = state.manifests.find((item) => item.kind === "CronJob") ?? null;
const scenarioId = stringAt(state.cicd, "targetValidation.scenarioId");
const scenarioRows = Array.isArray(state.scenarios)
? state.scenarios
: Array.isArray(record(state.scenarios).scenarios)
? record(state.scenarios).scenarios as unknown[]
: [];
const scenario = scenarioRows.map(record).find((item) => item.id === scenarioId) ?? null;
const cadence = scenario === null ? null : scenario.cadence ?? null;
const renderedCron = cronJob === null ? null : record(cronJob.spec).schedule ?? null;
const result = {
ok: state.configReady && state.sourceHead.ok && cadence !== null && renderedCron !== null,
command,
mode: "local",
mutation: false,
node: state.spec.nodeId,
lane: state.spec.lane,
sentinelId: state.sentinelId,
configRef: state.configRefs.scenarios,
scenarioId,
cadence,
renderedCron,
sourceCommit: state.sourceHead.commit,
manifestSha256: state.manifestSha256,
valuesRedacted: true,
};
return rendered(result.ok, command, [
command,
`status: ${result.ok ? "ok" : "blocked"}`,
`node: ${result.node}`,
`lane: ${result.lane}`,
`sentinelId: ${result.sentinelId}`,
`configRef: ${result.configRef}`,
`scenarioId: ${result.scenarioId}`,
`cadence: ${result.cadence ?? "-"}`,
`renderedCron: ${result.renderedCron ?? "-"}`,
`sourceCommit: ${result.sourceCommit ?? "-"}`,
`manifestSha256: ${result.manifestSha256}`,
"mutation: false",
].join("\n"), result);
}
const startedAt = Date.now();
const initialHealth = callSentinelService(state, "GET", "/api/health", null, options.timeoutSeconds);
let quickVerify: Record<string, unknown> | null = null;
+2 -2
View File
@@ -87,7 +87,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
"--source-stage-ref",
"--source-mirror-commit",
"--source-authority",
]), new Set(["--dry-run", "--confirm", "--wait", "--rerun", "--manual-recovery", "--recovery", "--quick-verify", "--raw", "--full", "--latest", "--full-page", "--no-full-page", "--diagnose-monitor-web"]));
]), new Set(["--dry-run", "--confirm", "--wait", "--local", "--rerun", "--manual-recovery", "--recovery", "--quick-verify", "--raw", "--full", "--latest", "--full-page", "--no-full-page", "--diagnose-monitor-web"]));
const nodeOption = optionValue(args, "--node") ?? null;
const laneOption = optionValue(args, "--lane") ?? null;
const inspectMode = sentinelActionRaw === "inspect-url" || sentinelActionRaw === "inspect-id";
@@ -183,7 +183,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
quickVerify: maintenanceAction === "stop" || args.includes("--quick-verify"),
};
} else if (sentinelActionRaw === "validate") {
sentinel = { kind: "validate", action: "validate", node, lane, sentinelId, 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"), localOnly: args.includes("--local") };
} else if (sentinelActionRaw === "dashboard") {
const dashboardAction = parseWebProbeSentinelDashboardAction(args[1]);
const timeoutMs = positiveIntegerOption(args, "--timeout-ms", 30000, 120000);
+1
View File
@@ -16,6 +16,7 @@ export interface RenderedCliResult {
command: string;
renderedText: string;
contentType: "text/plain" | "application/json" | "application/yaml";
projection?: Record<string, unknown>;
}
export interface CliInputErrorOptions {
@@ -97,6 +97,7 @@ export function compactDiagnoseCodeAgentResult(value: unknown): Record<string, u
hwlabReadModel: source.hwlabReadModel ?? null,
http: compactDiagnoseHttp(source.http),
projectionLag: source.projectionLag ?? null,
outcome: source.outcome ?? null,
summary: source.summary ?? null,
rootCauseCandidates: asArray(source.rootCauseCandidates).slice(0, 3).map(compactRootCauseCandidate),
spanNameCounts: compactNameCounts(source.spanNameCounts, 6),
@@ -123,16 +123,33 @@ describe("diagnose-code-agent warm-session terminal classification", () => {
expect(first.payload.diagnosticTrace.traceId).not.toBe(second.payload.diagnosticTrace.traceId);
expect(first.payload.diagnosticTrace.traceparent).not.toBe(second.payload.diagnosticTrace.traceparent);
});
test("keeps durable completion authoritative while projection lag stays a nonblocking warning", () => {
const diagnosis = runFixture({ authorityAvailable: true, terminalSpan: false, projectionStale: true });
expect(diagnosis.status).toBe(0);
expect(diagnosis.payload.outcome).toEqual({
typed: "completed-with-projection-warning",
effectiveStatus: "completed",
authority: { kind: "agentrun-durable-command", status: "completed", source: "agentrun-authority" },
projection: { kind: "hwlab-read-model", status: "running", lag: "confirmed", warning: true, blocking: false },
mutation: false,
});
const compact = compactDiagnoseCodeAgentResult(diagnosis.payload);
const rendered = renderDiagnoseCodeAgentTable({ ok: true, target, options, query: {}, result: compact });
expect(rendered.renderedText).toContain("outcome=completed-with-projection-warning effectiveStatus=completed mutation=false");
expect(rendered.renderedText).toContain("durableAuthority kind=agentrun-durable-command status=completed");
expect(rendered.renderedText).toContain("projection kind=hwlab-read-model status=running lag=confirmed warning=true blocking=false");
});
});
function runFixture(input: { authorityAvailable: boolean; terminalSpan: boolean }): {
function runFixture(input: { authorityAvailable: boolean; terminalSpan: boolean; projectionStale?: boolean }): {
status: number | null;
payload: Record<string, any>;
calls: string;
} {
const directory = mkdtempSync(join(tmpdir(), "unidesk-otel-diagnose-"));
temporaryDirectories.push(directory);
writeFileSync(join(directory, "trace.json"), JSON.stringify(traceFixture(input.terminalSpan)));
writeFileSync(join(directory, "trace.json"), JSON.stringify(traceFixture(input.terminalSpan, input.projectionStale === true)));
writeFileSync(join(directory, "command.json"), JSON.stringify({ id: "cmd_second", state: "completed" }));
writeFileSync(join(directory, "result.json"), JSON.stringify({ ok: true, commandId: "cmd_second", status: "completed", commandState: "completed", terminalStatus: "completed" }));
writeFileSync(join(directory, "run-result.json"), JSON.stringify({ ok: true, commandId: "cmd_second", commandState: "completed", terminalStatus: "completed" }));
@@ -235,7 +252,7 @@ function callLines(calls: string): string[] {
return calls.split("\n").map((line) => line.trim()).filter(Boolean);
}
function traceFixture(terminalSpan: boolean): Record<string, unknown> {
function traceFixture(terminalSpan: boolean, projectionStale = false): Record<string, unknown> {
const common = [
attribute("traceId", "trc_warm_second"),
attribute("runId", "run_warm"),
@@ -244,11 +261,17 @@ function traceFixture(terminalSpan: boolean): Record<string, unknown> {
];
return {
batches: [
batch("hwlab-cloud-api", [span("provider_decision", [
...common,
attribute("agent.chat.provider_profile", "gpt.pika"),
attribute("agentRunRunnerNamespace", "agentrun-v02"),
])]),
batch("hwlab-cloud-api", [
span("provider_decision", [
...common,
attribute("agent.chat.provider_profile", "gpt.pika"),
attribute("agentRunRunnerNamespace", "agentrun-v02"),
]),
...(projectionStale ? [
span("trace_events_read", [...common, attribute("sourceEventCount", "6"), attribute("sinceSeq", "7")]),
span("turn_status_read", [...common, attribute("turnStatus", "running")]),
] : []),
]),
batch("agentrun-manager", [
span("command_created", common),
...(terminalSpan ? [span("runner_terminal.completed", [...common, attribute("terminalStatus", "completed"), attribute("eventType", "terminal_status")])] : []),
@@ -2023,10 +2023,10 @@ def root_cause_candidates(http_summary, agentrun, read_model, lag_summary, error
status_conflict = terminal_status_conflicts(agentrun.get("terminalStatus"), read_model_turn_status)
if status_conflict:
candidates.append({
"code": "otel_business_trace_terminal_conflict",
"label": "OTel terminal conflict",
"code": "projection_authority_terminal_conflict",
"label": "projection/authority terminal conflict",
"confidence": 0.86,
"summary": "Scoped AgentRun terminal status conflicts with HWLAB read-model turn status for the requested business trace; prefer the HWLAB read-model status for Workbench RCA and inspect OTel correlation leakage.",
"summary": "HWLAB projection conflicts with the scoped AgentRun durable authority; preserve the AgentRun terminal outcome and inspect projection correlation or lag.",
"evidence": {
"agentrunTerminalStatus": agentrun.get("terminalStatus"),
"hwlabReadModelTurnStatus": read_model_turn_status,
@@ -2502,16 +2502,16 @@ if http_summary.get("actorForbidden"):
read_model_turn_status = read_model.get("turnStatus")
agentrun_read_model_status_conflict = terminal_status_conflicts(terminal_status, read_model_turn_status)
if agentrun_read_model_status_conflict:
facts.append("OTel AgentRun terminal status conflicts with HWLAB read model")
if terminal_status in ("failed", "error", "timeout", "blocked", "cancelled") and not agentrun_read_model_status_conflict:
facts.append("HWLAB projection conflicts with AgentRun durable authority")
if terminal_status in ("failed", "error", "timeout", "blocked", "cancelled"):
failure_kind = agentrun.get("failureKind")
if failure_kind:
facts.append(f"AgentRun terminal failed ({failure_kind})")
else:
facts.append("AgentRun terminal failed")
if terminal_status == "completed" and not agentrun_read_model_status_conflict:
if terminal_status == "completed":
facts.append("AgentRun completed")
if (terminal_status in (None, "") or agentrun_read_model_status_conflict) and read_model_turn_status in ("completed", "failed", "error", "timeout", "blocked", "cancelled", "canceled"):
if terminal_status in (None, "") and read_model_turn_status in ("completed", "failed", "error", "timeout", "blocked", "cancelled", "canceled"):
facts.append("HWLAB read model terminal " + str(read_model_turn_status))
if lag.get("status") in ("confirmed", "suspected"):
facts.append("projection/read-model stale")
@@ -2552,6 +2552,30 @@ evidence = {
"idleWarningSpanCount": len(idle_warning_spans),
"idleWarningSpanTail": [tiny_span(item) for item in idle_warning_spans[-3:]],
}
projection_warning = lag.get("status") in ("confirmed", "suspected") or agentrun_read_model_status_conflict
effective_status = terminal_status if terminal_status not in (None, "") else read_model_turn_status
typed_outcome = "unknown"
if effective_status not in (None, ""):
typed_outcome = str(effective_status)
if projection_warning:
typed_outcome = typed_outcome + "-with-projection-warning"
outcome = {
"typed": typed_outcome,
"effectiveStatus": effective_status,
"authority": {
"kind": "agentrun-durable-command",
"status": terminal_status,
"source": agentrun.get("terminalSource"),
},
"projection": {
"kind": "hwlab-read-model",
"status": read_model_turn_status,
"lag": lag.get("status"),
"warning": projection_warning,
"blocking": False,
},
"mutation": False,
}
payload = {
"ok": trace_rc == 0 and len(spans) > 0,
"mapping": mapping,
@@ -2594,6 +2618,7 @@ payload = {
"actorForbidden": http_summary.get("actorForbidden"),
},
"projectionLag": lag,
"outcome": outcome,
"summary": summary,
"rootCauseCandidates": candidates,
"spanNameCounts": flat["spanNameCounts"][:12],
@@ -761,6 +761,9 @@ export function renderDiagnoseCodeAgentTable(input: {
const observedCommandId = textValue(identity?.commandId);
const observedSessionId = textValue(identity?.sessionId);
const observedRunnerJobId = textValue(identity?.runnerJobId);
const outcome = asPlainRecord(input.result.outcome);
const outcomeAuthority = asPlainRecord(outcome?.authority);
const outcomeProjection = asPlainRecord(outcome?.projection);
const lines = [
`platform-infra observability diagnose-code-agent (${input.ok ? "ok" : "not-ok"})`,
"",
@@ -792,6 +795,9 @@ export function renderDiagnoseCodeAgentTable(input: {
formatTable(["METHOD", "ROUTE", "STATUS", "COUNT"], httpRows.length > 0 ? httpRows : [["-", "-", "-", "-"]]),
"",
"Summary:",
` outcome=${textValue(outcome?.typed)} effectiveStatus=${textValue(outcome?.effectiveStatus)} mutation=${textValue(outcome?.mutation)}`,
` durableAuthority kind=${textValue(outcomeAuthority?.kind)} status=${textValue(outcomeAuthority?.status)} source=${textValue(outcomeAuthority?.source)}`,
` projection kind=${textValue(outcomeProjection?.kind)} status=${textValue(outcomeProjection?.status)} lag=${textValue(outcomeProjection?.lag)} warning=${textValue(outcomeProjection?.warning)} blocking=${textValue(outcomeProjection?.blocking)}`,
` target=${input.target.id} spanCount=${textValue(input.result.spanCount)} servicePath=${joinValues(input.result.servicePath, 60)}`,
` agentrun=${textValue(agentrun?.terminalStatus)} failureKind=${textValue(agentrun?.failureKind)} source=${textValue(agentrun?.terminalSource)} authority=${shortenEnd(JSON.stringify((asPlainRecord(agentrun?.authority) ?? {})), 120)}`,
` authorityQuery namespace=${textValue(agentrunAuthority?.namespace)} source=${textValue(agentrunAuthority?.namespaceSource)} ok=${textValue(agentrunAuthority?.ok)} commandState=${textValue(agentrunAuthority?.commandState)} terminal=${textValue(agentrunAuthority?.terminalStatus)}`,
+19 -1
View File
@@ -86,10 +86,11 @@ describe("ssh bounded progressive help", () => {
test("renders compact top-level and scoped help without dump fallback", () => {
const top = runTransHelp("--help");
expect(Buffer.byteLength(top, "utf8")).toBeLessThan(2_048);
expect(top.trim().split("\n")).toHaveLength(10);
expect(top.trim().split("\n")).toHaveLength(11);
expect(top).toContain("usage: trans <route> <operation>");
expect(top).toContain("transfer: upload | download");
expect(top).toContain("scoped help: trans --help <operation>");
expect(top).toContain("cwd: host/workspace uses <provider>:/absolute/workspace; k3s exec uses --cwd");
expect(top).not.toContain("outputTruncated");
expect(top).not.toContain("dump");
@@ -102,6 +103,10 @@ describe("ssh bounded progressive help", () => {
expect(applyPatch).toContain("usage: trans <route> apply-patch");
expect(applyPatch).not.toContain("outputTruncated");
const exec = runTransHelp("--help", "exec");
expect(exec).toContain("trans <provider>:/absolute/workspace exec <command>");
expect(exec).toContain("k3s:<namespace>:<workload>");
const download = runTransHelp("--help", "download");
expect(download.trim().split("\n").length).toBeLessThanOrEqual(8);
expect(download).toContain("TRANS HELP download");
@@ -159,4 +164,17 @@ describe("ssh bounded progressive help", () => {
expect(result.stdout).not.toContain("stack");
expect(result.stdout).not.toContain(" at ");
});
test("rejects the moved root entrypoint locally with one exact replacement", () => {
const result = spawnSync("bun", ["scripts/cli.ts", "trans", "NC01:/root/unidesk", "git", "status", "--short"], {
cwd: repoRoot,
env: { ...process.env, UNIDESK_TRANS_REPO_ROOT: repoRoot },
encoding: "utf8",
});
expect(result.status).toBe(1);
expect(result.stderr).toBe("");
expect(result.stdout).toContain("ERROR cli-input/trans-root-cli-entrypoint-moved");
expect(result.stdout).toContain("usage: trans NC01:/root/unidesk git status --short");
expect(result.stdout).not.toContain("stack");
});
});
+2 -1
View File
@@ -1372,11 +1372,12 @@ function writeChunkedStdin(stdin: NodeJS.WritableStream, input: string): void {
export async function runSsh(config: UniDeskConfig, providerId: string, args: string[]): Promise<number> {
const normalizedArgs = normalizeSshOperationArgs(args);
process.stderr.write(sshRouteSeparatorCompatibilityHint(args, normalizedArgs));
// 在选择或连接 transport 前完成解析,保证可预测的 route/operation 错误始终在本地失败。
const invocation = parseSshInvocation(providerId, normalizedArgs);
const plan = sshCaptureBackendPlan(config, process.env);
if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) {
return await runRemoteSsh(config, plan.remoteHost, providerId, normalizedArgs);
}
const invocation = parseSshInvocation(providerId, normalizedArgs);
const parsed = invocation.parsed;
const operationName = normalizedArgs[0] ?? "";
if (isSshFileTransferOperation(normalizedArgs)) {
+14
View File
@@ -214,6 +214,20 @@ describe("ssh windows fs read-only operations", () => {
});
describe("ssh direct argv boundaries and plane diagnostics", () => {
test("rejects host/workspace cwd options locally with an exact route replacement", () => {
try {
parseSshInvocation("NC01:/root/unidesk", ["exec", "--cwd", "/root/hwlab-v03", "--", "git", "status", "--short"]);
throw new Error("expected host cwd input error");
} catch (error) {
expect(error).toMatchObject({
code: "trans-host-cwd-belongs-in-route",
usage: "trans NC01:/root/hwlab-v03 'exec' '--' 'git' 'status' '--short'",
});
}
expect(() => parseSshInvocation("NC01:/root/unidesk", ["bash", "--cwd=/root/hwlab-v03"])).toThrow("cwd belongs in the route");
expect(() => parseSshInvocation("NC01:k3s:hwlab-v03:hwlab-cloud-api", ["exec", "--cwd", "/workspace", "--", "pwd"])).not.toThrow();
});
test("preserves a shell-formed argv token containing spaces and Chinese", () => {
const invocation = parseSshInvocation("G14-WSL:/mnt/d/Work/CONSTAR_workspace", [
"python3",
+22
View File
@@ -3,6 +3,7 @@ import { isSshFileTransferOperation } from "./ssh-file-transfer";
import { isWindowsFsReadOnlyOperation, windowsFsReadOnlyScript } from "./ssh-windows-fs";
import { remoteApplyPatchSource, remoteGlobSource, remoteSkillDiscoverSource } from "./ssh-remote-tools";
import { readTransHostProxyEnvRule, type TransHostProxyEnvRule } from "./trans-host-proxy";
import { CliInputError } from "./output";
export interface ParsedSshArgs {
remoteCommand: string | null;
@@ -406,10 +407,31 @@ export function parseSshInvocation(target: string, args: string[]): ParsedSshInv
if ((operationArgs[0] ?? "") === "k3s") {
throw new Error(`ssh k3s shorthand is unsupported; use route syntax instead: trans ${route.providerId}:k3s ${operationArgs.slice(1).join(" ")}`.trim());
}
rejectHostWorkspaceCwdOption(route, operationArgs);
const parsed = parseSshArgs(operationArgs, route.raw);
return { providerId: route.providerId, route, parsed: withTransHostEnvironment(route, parsed) };
}
function rejectHostWorkspaceCwdOption(route: ParsedSshRoute, args: string[]): void {
const operation = args[0] ?? "";
if (!new Set(["exec", "argv", "sh", "bash"]).has(operation)) return;
const cwdIndex = args.findIndex((arg) => arg === "--cwd" || arg === "--workdir" || arg.startsWith("--cwd=") || arg.startsWith("--workdir="));
if (cwdIndex < 0) return;
const option = args[cwdIndex] ?? "--cwd";
const inlineValue = option.includes("=") ? option.slice(option.indexOf("=") + 1) : null;
const cwd = inlineValue ?? args[cwdIndex + 1] ?? "<absolute-workspace>";
const consumed = inlineValue === null ? 2 : 1;
const remaining = [...args.slice(0, cwdIndex), ...args.slice(cwdIndex + consumed)];
const replacementRoute = cwd.startsWith("/") ? `${route.providerId}:${cwd}` : `${route.providerId}:/absolute/workspace`;
const replacement = `trans ${replacementRoute} ${remaining.map(shellQuote).join(" ")}`;
throw new CliInputError(`host/workspace ${operation} does not accept ${option.split("=", 1)[0]}; cwd belongs in the route`, {
code: "trans-host-cwd-belongs-in-route",
argument: option,
usage: replacement,
hint: "Use <provider>:/absolute/workspace for host cwd. Use exec --cwd only with a k3s workload route.",
});
}
export function parseSshRoute(target: string): ParsedSshRoute {
if (!target) throw new Error("ssh requires provider id, for example: trans D601");
const firstColon = target.indexOf(":");
+6 -1
View File
@@ -12,7 +12,12 @@ export function webProbeHelp(): Record<string, unknown> {
export async function runWebProbeCommand(_config: Config, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
if (args.length === 0 || args.includes("--help") || args.includes("-h") || args[0] === "help") return webProbeHelp();
return runNodeWebProbe(parseNodeWebProbeOptions(args));
const format = args.includes("--json") ? "json" : args.includes("--yaml") ? "yaml" : "text";
const parsedArgs = args.filter((item) => item !== "--json" && item !== "--yaml");
const result = runNodeWebProbe(parseNodeWebProbeOptions(parsedArgs));
if (format === "text" || !("projection" in result) || result.projection === undefined) return result;
if (format === "json") return result.projection;
return { ...result, renderedText: Bun.YAML.stringify(result.projection), contentType: "application/yaml" };
}
export function legacyHwlabNodeWebProbeUnsupported(args: string[]): Record<string, unknown> {