Merge remote-tracking branch 'origin/master'

This commit is contained in:
Codex
2026-07-11 20:30:27 +02:00
78 changed files with 4039 additions and 1548 deletions
+29 -20
View File
@@ -1,6 +1,6 @@
---
name: unidesk-cicd
description: UniDesk CI/CD 控制面 — `cicd branch-follower``hwlab g14``hwlab nodes control-plane``agentrun` 子命令,覆盖自动跟随 branch、PR 监控自动合并、Tekton/Argo 控制面、git-mirror、Secret、observability、CI tools image、PipelineRun 清理、AgentRun v0.1 部署和 AgentRun YAML-only lane 部署。用户提到 CI/CD、deploy、rollout、PipelineRun、trigger、git-mirror、control-plane、k8s/k3s 部署、branch follower、自动跟随、agentrun 部署、hwlab g14、monitor-prs、trigger-current 时使用。任何需要把代码变更推送部署到 G14 k3s 的操作都必须走本 skill。
description: UniDesk CI/CD 控制面 — `cicd branch-follower` 退役只读诊断`hwlab g14``hwlab nodes control-plane``agentrun` 子命令,覆盖 PR 监控自动合并、Tekton/Argo 控制面、git-mirror、Secret、observability、CI tools image、PipelineRun 清理、AgentRun v0.1 部署和 AgentRun YAML-only lane 部署。用户提到 CI/CD、deploy、rollout、PipelineRun、trigger、git-mirror、control-plane、k8s/k3s 部署、branch follower、自动跟随、agentrun 部署、hwlab g14、monitor-prs、trigger-current 时使用。任何需要把代码变更推送部署到 G14 k3s 的操作都必须走本 skill。
---
# UniDesk CI/CD
@@ -15,7 +15,6 @@ bun scripts/cli.ts cicd status --node <NODE>
bun scripts/cli.ts hwlab g14 monitor-prs --lane v02 --once --dry-run
bun scripts/cli.ts hwlab g14 control-plane status --lane v02
bun scripts/cli.ts hwlab nodes control-plane status --node NC01 --lane v03 --json
bun scripts/cli.ts hwlab g14 control-plane trigger-current --lane v02 --confirm --wait
bun scripts/cli.ts hwlab g14 git-mirror status --lane v02
bun scripts/cli.ts agentrun control-plane status
bun scripts/cli.ts platform-infra gitea mirror status --target JD01
@@ -27,8 +26,9 @@ bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id <
bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 --consumer <consumer>
bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 --consumer <consumer> --id <pipelinerun>
bun scripts/cli.ts platform-infra pipelines-as-code source-artifact check --target NC01 --consumer <consumer> --source-worktree <absolute-path>
bun scripts/cli.ts platform-infra pipelines-as-code source-artifact write --target NC01 --consumer <consumer> --source-worktree <absolute-path> --confirm
bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runtime --target NC01 --consumer <consumer> --source-worktree <absolute-path> --source-commit <full-sha>
bun scripts/cli.ts agentrun control-plane legacy-cicd --help
bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help
```
节点级只读状态必须优先用 `cicd status --node <NODE>`。它从 `config/platform-infra/pipelines-as-code.yaml` 找到该 node 的所有当前 PaC consumer,一次性汇总 PipelineRun、Argo/GitOps、runtime readiness 和诊断;不要再靠阅读源码或手动拼三条 consumer 命令来回答 “NC01 的 CI/CD 流水线情况”。`platform-infra pipelines-as-code status --target <NODE> --consumer <id>` 只作为单 consumer drill-down。
@@ -37,24 +37,26 @@ bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runti
- PR monitor 与自动合并: [references/pr-monitor.md](references/pr-monitor.md)。
- Tekton/Argo、node-scoped runtime lane、D601 infra bootstrap: [references/control-plane.md](references/control-plane.md)。
- HWLAB/AgentRun git-mirror source authority 与 flush: [references/git-mirror.md](references/git-mirror.md)。
- HWLAB/AgentRun legacy git-mirror source authority 与 flush: [references/git-mirror.md](references/git-mirror.md)。
- JD01/NC01 Gitea mirror + Pipelines-as-Code 正式架构、三 consumer 覆盖矩阵、target 级 webhook path 和旧入口处置: [references/gitea-pac.md](references/gitea-pac.md)。
- AgentRun/HWLAB env reuse 机制、证据解释和 PaC status/history 观察口径: [references/env-reuse.md](references/env-reuse.md)。
- Secret、observability、platform-infra、CI tools image、PipelineRun 清理和 rollout 补记: [references/platform-ops.md](references/platform-ops.md)。
- AgentRun YAML-only lane、v0.1 兼容入口和 AgentRun git-mirror: [references/agentrun.md](references/agentrun.md)。
- YAML-first K8s branch-follower 自动跟随、状态查询和 no-host-worktree 边界: [references/branch-follower.md](references/branch-follower.md)。
- 已退役 K8s branch-follower 的只读状态、事件、日志和历史边界: [references/branch-follower.md](references/branch-follower.md)。
## P0 边界
- PaC migrated consumer 的唯一正式 CI/CD 触发是目标 source branch 的 GitHub PR merge。合并后由 GitHub webhook -> Gitea controlled mirror + immutable snapshot ref -> Gitea webhook -> PaC -> Tekton -> GitOps/Argo -> runtime 自动滚动;不得要求主代理、子代理或操作者再执行 apply、closeout、trigger、sync、flush 或补跑。
- 自动链路不通时必须修复自动链自身,并通过修复 PR 合并产生的新正常事件验收。禁止人工 mirror sync、直接 Gitea push、人工 PipelineRun、`trigger-current`、运行面 patch 或其他手段补齐当前交付;只读 status/history/events/logs/closeout/debug-step 可用于定位,但不得改变交付状态。
- 自动链路不通时必须修复自动链自身,并通过修复 PR 合并产生的新正常事件验收。禁止人工 mirror sync、直接 Gitea push、人工 PipelineRun、`trigger-current`、运行面 patch 或其他手段补齐当前交付;只读 status/history/events/logs/debug-step 可用于定位,但不得改变交付状态。`closeout` 只属于显式 compatibility diagnostics,不得进入默认观察或恢复路径。
- CI/CD、GitOps、rollout、PipelineRun、Argo、git-mirror 和 AgentRun 的观察、诊断与 legacy 控制必须走受控 CLI;不要用裸 `kubectl``argo``tkn``curl` 当正式控制入口。PaC migrated consumer 仍只由 GitHub PR merge 触发,不得因“必须走 CLI”而额外调用写命令。
- CI/CD source authority 只能来自 YAML 声明的 Kubernetes 托管 source authority
- legacy lane 由受控命令在 k8s 内同步并创建不可变 `refs/unidesk/snapshots/.../<commit>` stage ref
- Gitea/PaC migrated lane 由 GitHub PR merge 自动驱动 GitHub webhook bridge、Gitea controlled mirror 与 immutable snapshot ref,禁止合并后人工同步或创建 snapshot;
- build/status/publish 只消费对应 snapshothost worktree、本地 `git fetch/pull`、可变 branch ref 或 Pipeline 内直连 GitHub 都不能作为 authoritative source。
- CLI 必须组合 `config/platform-infra/gitea.yaml``config/platform-infra/pipelines-as-code.yaml`,按 consumer、node、lane、upstream repository、branch 和 Gitea repository 精确解析 delivery authority。不得用 URL 片段或 repo 专属条件判断迁移;零匹配、多匹配和配置错误都返回 `unknown``mutation=false` 并 fail-closed。
- PaC 与 `unknown` authority 的 help、plan、status、失败态 `Next``REPAIR` 和实际执行 guard 都不得包含或执行 mutation command。`trigger-current|refresh|sync|flush` 只在 YAML 精确解析为 `legacy-manual` 后进入旧实现,且只从 `legacy-cicd` / `legacy-ops` scoped help 发现;平台 bootstrap、Secret 与配置维护使用独立 scoped help,不得充当 source delivery recovery。
- JD01/NC01 `agentrun-<node>-v02``sentinel-<node>-v03``hwlab-<node>-v03` 的交付收口由自动链自行完成并写入状态。仅在显式调查自动链故障时读取 `cicd status --node <NODE>``platform-infra pipelines-as-code status|history --target <NODE> --consumer <id>`;主代理与子代理不得把 `closeout` 当作 PR 合并后的人工步骤。`cicd branch-follower``cicd gitea-actions-poc` 对这些 consumer 只保留历史/迁移只读用途。
- PaC `.tekton` 文件必须用 Repository CR 的 target/node 参数隔离 JD01/NC01,避免同一个 Gitea push 在一个 target cluster 内额外创建另一个 target 的 PipelineRunhistory/detail 必须按实际 PipelineRun prefix/pipeline 归属 consumer
- PaC `.tekton` 文件必须用 Repository CR 的 target/node 参数隔离 JD01/NC01,避免同一个 Gitea push 在一个 target cluster 内额外创建另一个 target 的 PipelineRun`history --id` 必须按运行面 provenance 和实际 PipelineRun prefix 唯一归属 consumer;零匹配、多匹配或显式 consumer 不一致均 fail-closed,不得回退到默认 consumer 或默认 node
- PaC source artifact 必须遵循以下边界:
-`config/platform-infra/pipelines-as-code.yaml#consumers[].sourceArtifact` 显式声明;
- 通过 `source-artifact plan|check|write|status|verify-runtime` 管理;
@@ -77,9 +79,17 @@ bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runti
- 该入口只读且 `mutation=false`,不能创建 PipelineRun 或写 GitOps/runtime
- 未带 `--id` 时才只运行 evaluator fixture。
- GitHub/Git 相关 egress 必须走 YAML-first host proxy/sourceRefbranch-follower controller 读 `config/cicd-branch-followers.yaml#controller.source.githubSsh`runtime legacy git-mirror 读 owning lane/control-plane YAML 的 host proxy 和 `githubTransport`Gitea/PaC 迁移 lane 读 `config/platform-infra/gitea.yaml``config/platform-infra/pipelines-as-code.yaml`;禁止依赖未声明 host env、trans proxy、裸直连 GitHub 或 CLI 输出解析。
- Gitea/PaC lane 的 GitHub -> Gitea 自动同步由 owning YAML 渲染的 webhook bridge 承担,只更新 Gitea branch 和 immutable snapshot ref。`platform-infra gitea mirror webhook status|test` 只用于观察和单步诊断;`apply` 仅属于首次平台 bootstrap,不得用于 source PR 合并后的交付或恢复。不得引入 Gitea -> GitHub 回写、轮询 fallback、Gitea Actions/act_runner fallback 或第二套状态存储。
- Gitea webhook/FRPC/Caddy 自动链变更的 closeout 必须观察修复 PR 合并后的 rollout/status/test 证据;Secret 或 ConfigMap 声明已生成不等于 connector Pod 已加载新 proxy/path,也不得用人工 apply 补齐。
- `cicd branch-follower` 的自动跟随全过程不得读取或挂载 host worktree、target dev dir、`.worktree/*` 或 local git checkoutcontroller pod/一次性 reconcile Job 只能用 k8s git-mirror cache、Tekton PipelineRun、Argo Application、runtime workload 和 EmptyDir 执行,状态以 K8s ConfigMap/Lease 承载的 native observation 为准,不得解析下游 CLI 输出。
- Gitea/PaC lane 的 GitHub -> Gitea 自动同步由 owning YAML 渲染的 webhook bridge 与 durable inbox worker 承担:
- receiver 验签并校验 GitHub payload 后,只有在 delivery 通过 fsync 与 atomic rename 写入 YAML 声明的 PVC inbox 后才能返回 HTTP `202 Accepted`
- `202 Accepted` 只证明 delivery 已持久接收,不证明 Gitea refs 已提交;状态必须分为 `accepted``processing``committed``failed`
- 只有同一 deliveryId 的 exact-after immutable snapshot 与 authority branch 经 atomic push 后重新读取 refs 证明一致,才能进入 `committed`
- 同 deliveryId 与同 payload 必须幂等,异 payload 必须返回 `409`PVC 不可用或容量满必须返回 `503` 并使 readiness=false
- inbox worker 使用 YAML 声明的有界 backoff 自动重试,重启后恢复 `accepted` / `processing`;Caddy 只可在请求尚未持久化时做小于 10 秒的 body-safe 有界重试。
- Durable inbox 只是 webhook delivery 的持久接收与重试 journal,不是业务 source/ref 的第二份真相,也不得被 PaC、Tekton、GitOps、Argo 或 runtime 当作源码/read model。最终 source authority 仍只有 Gitea authority branch 与 immutable snapshot。默认只使用 `platform-infra gitea mirror webhook status` 观察 accepted -> committed 分层。会 POST hook test 的 mutation 测试入口已经删除;连通性只能通过真正只读的 status、GET 与 readiness 观察,不能制造伪 push。平台 bootstrap 必须先落入 Git-backed YAML/controller/source,并由正常 PR merge 的自动事件验收;禁止 Gitea -> GitHub 回写、轮询/read-model fallback、Gitea Actions/act_runner fallback 或第二 source authority。
- Gitea webhook/FRPC/Caddy 自动链变更的 closeout 必须观察修复 PR 合并后的真实 delivery、rollout、只读 status/GET/readiness 证据;Secret 或 ConfigMap 声明已生成不等于 connector Pod 已加载新 proxy/path,也不得用人工 apply 或 hook test 补齐。
- Sentinel 内部 publish capability 的唯一真相是 `config/platform-infra/pipelines-as-code.yaml#capabilities.sentinelInternalPublish`,当前默认关闭。Pod env、ownerReferences、ServiceAccount 与 metadata 不能证明 creator#1769 的 admission-owned provenance 落地前不得启用。`.tekton` 保持既有自动 `publish-current` 入口,禁止改成人工 publish。
- PaC PipelineRun 必须区分外层 `outer-pac-event`、内层 `inner-deterministic-publish``unknown`。只有唯一外层事件可驱动 latest/status/debug-step/closeout;内层只能作为未绑定执行观察,缺少 admission-owned 父子证据时不得按前缀推断。
- `cicd branch-follower` 对当前 PaC consumer 已退役,只保留 `status|events|logs` 与只读 `debug-step` 历史诊断。默认入口、任意 `--config` 和混合/未知 authority 都不能恢复 `apply|run-once|state-write` 等写能力,也不能猜测默认 node。
- k8s 运行面从拉取已构建镜像开始必须 0 Docker;CI 构建面可以使用 YAML 声明的原生构建工具,但不得把 Docker socket、Docker daemon 或 host Docker 带入运行面。
- 正式 CI/CD、publish、image build 和 rollout 必须走 Tekton Task/Pipeline/PipelineRun 承担 CI,并通过 GitOps/Argo 承担部署收敛;普通 Kubernetes Job 只允许用于 bounded helper、source sync、diagnostic、cleanup 或 bootstrap,不得作为正式发布、镜像构建或 rollout 入口。
- 未迁移的 legacy CI/CD 若仍保留人工入口,必须由单一受控命令完成 source sync、构建、发布、GitOps/Argo 收敛、runtime provenance 校验和 `/health` 端点验证。PaC migrated consumer 不得使用该人工入口,其唯一交付触发是 GitHub PR merge。
@@ -94,24 +104,23 @@ bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runti
- 显式 `--json` 的 stdout 只允许一个合法 JSON 文档,progress 写入 stderr。
- 触发或验收 rollout 时必须绑定 lane、source commit、PipelineRun/GitOps revision、runtime ready 和 `/health` 端点验证结果;web-probe/Playwright 结果只能作为单独的 post-deploy 证据。
- CI/CD 状态、日志和事件查询必须减少 trans/SSH 传输:能在目标 NODE/k8s 内解析、聚合、裁剪的内容,必须在目标侧计算成短 JSON/table 摘要后再回传;禁止为了本地解析而把完整 ConfigMap、大对象、长日志或原始 API payload 透传回来。
- branch-follower 排障必须优先使用 `debug-step` 做单步调试:`state-read``status-read``decide``state-write` 分别定位状态读取、K8s native status、决策和 ConfigMap patch,不要通过反复推小提交触发整条自动跟随大回环来定位同一问题
- branch-follower 的“单步”不是端到端流水线里的被动观察点;reuse plan、CI 并行/TaskRun plan、CD rollout plan、post-deploy health/monitoring 每个 gate 都必须有可独立触发、独立执行、独立复测的目标侧 CLI/debug-step/drill-down 入口。没有这个入口时先补入口,再做 `run-once`、自动 loop 或小 PR 联调
- branch-follower 单步测不通时应继续在该单步边界内定位和修复,直到目标侧复测通过;不要把普通 gate 失败包装成阻塞结论。只有权限缺失、外部依赖不可达、架构取舍或明确超出当前授权范围时,才写 issue 阻塞和下一步最小范围
- CI/CD 排障中再次踩到已经暴露过的运行面坑、工具误用、镜像假设或状态可见性缺口时,必须先把长期规则写入本 skill/reference,再继续单步调试;branch-follower 必须先让相关 `debug-step` 单步全部通过,再做 `run-once`、自动 loop 或小 PR/小提交联调
- CI/CD 验证、测试和性能度量必须在目标 NODE/k8s 内执行,尤其是 branch-follower、Tekton/Argo、runtime reuse/env reuse、git mirror 和 runtime-ready 相关改动;不要在 master/local host 跑 test 或用本地验证结果替代目标运行面证据。本机只用于源码阅读、编辑和必要静态语法检查,正式收敛结论必须来自目标 NODE 计算出的短摘要。
- 退役 branch-follower 排障只允许使用 `state-read|controller-source|status-read|decide``status|events|logs` 等只读边界;`state-write`、gate、`run-once` 和自动 loop 不再是当前 PaC consumer 的修复或验收入口
- 自动链单步测不通时,应在 Git-backed YAML、controller 或源码中修复根因,并由修复 PR 合并产生的新正常 webhook/PaC 事件复测;不得用 branch-follower 写状态或补跑当前交付
- CI/CD 排障中再次踩到已暴露的运行面坑、工具误用、镜像假设或状态可见性缺口时,必须先把长期规则写入本 skill/reference,再继续只读定位
- CI/CD 运行面验收必须在目标 NODE/k8s 内计算短摘要;本机只用于源码阅读、编辑和静态合同测试,不能替代正常 PR merge 自动事件产生的目标运行面证据
- in-cluster/controller/native helper 不能假设镜像内存在 `kubectl` binary。目标 Pod/Job 内读取或写入 Kubernetes 对象必须走 serviceaccount token + Kubernetes HTTPS API 或已封装的 native helper`kubectl` 只允许在 operator 侧受控 CLI/trans 边界作为 transport/debug 包装,不得进入正式 controller 状态读写链路。
- 一旦发现 CI/CD CLI 被误用且可能写入错误状态、产生伪证据或绕过目标运行面,必须立刻先把用法改成更符合直觉的公开入口并更新本 skill/reference,再继续验证或交付;不要只靠口头记忆、隐藏 flag、手动约定或后续小心来避免复发。内部 in-cluster 模式必须只由目标 k8s Job/Pod 调用,操作者从本机只能用公开入口提交目标侧 Job 或读取目标侧摘要。
- PaC migrated consumer 的旧手动 publish/debug 命令不得作为正式 closeout 引导;调查命令默认只能指向只读的 `platform-infra pipelines-as-code status|history``closeout` 仅保留只读兼容能力,不得作为主代理、子代理或操作者的 PR 合并后步骤。需要保留的旧命令只能做 debug/test 单步,不得标记或引导 manual recovery;单步确认的自动链缺陷必须回到 YAML/controller/源码修复。
- 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。
## 何时读取 reference
- PR 自动合并、v0.2/v0.3 lane 差异:读 [references/pr-monitor.md](references/pr-monitor.md)。
- 手动触发、定点 PipelineRun/source commit、RBAC/Pipeline/Argo、node-scoped runtime lane:读 [references/control-plane.md](references/control-plane.md)。
- git-mirror source authority 或 flush:读 [references/git-mirror.md](references/git-mirror.md)。
- Legacy 手动触发、定点 PipelineRun/source commit、RBAC/Pipeline/Argo、node-scoped runtime lane:读 [references/control-plane.md](references/control-plane.md)。
- Legacy git-mirror source authority 或 flush:读 [references/git-mirror.md](references/git-mirror.md)。
- AgentRun/HWLAB env reuse、`IMAGE_STATUS=reused``ENV_REUSE=hit``skipped,skip=<n>` 或 PaC history/status 解释:读 [references/env-reuse.md](references/env-reuse.md)。
- JD01 三运行面 Gitea+PaC 覆盖、旧 branch-follower/Gitea Actions POC 处置和 closeout 顺序:读 [references/gitea-pac.md](references/gitea-pac.md)。
- JD01/NC01 Gitea+PaC 覆盖、旧 branch-follower/Gitea Actions POC 处置和只读观察顺序:读 [references/gitea-pac.md](references/gitea-pac.md)。
- Secret、observability、CI tools image、PipelineRun/PV 清理:读 [references/platform-ops.md](references/platform-ops.md)。
- AgentRun v0.1 或 YAML-only lane 部署:读 [references/agentrun.md](references/agentrun.md)。
- 三运行面 branch follower 自动跟随、`apply/status/run-once/events/logs`:读 [references/branch-follower.md](references/branch-follower.md)。
- 退役 branch-follower `status|events|logs|debug-step` 历史只读诊断:读 [references/branch-follower.md](references/branch-follower.md)。
@@ -2,7 +2,9 @@
AgentRun YAML-only lane 以 `config/agentrun.yaml` 为部署真相;node/lane、source workspace/branch、image build、GitOps branch/path、runtime namespace、Secret、外置数据库、manager env、git-mirror 和 edge 暴露都从 YAML 进入 CLI。
## YAML-only lane
## 明确 legacy/manual authority 的 YAML-only lane
本节 mutation 命令只适用于 owning YAML 精确解析为 `legacy-manual` 的 lane。PaC 或 `unknown` authority 必须在远端调用和异步 job 创建前 fail-closed;当前 migrated consumer 的默认入口见下一节。
```bash
bun scripts/cli.ts agentrun control-plane plan --node D601 --lane v02
@@ -78,17 +80,17 @@ bun scripts/cli.ts agentrun provider-profile validate --node <NODE> --lane <lane
- 给出 HWLAB web-probe 原入口验收指引;
- 不替代真实 Workbench 用户路径验证。
YAML-only lane 的长步骤必须由 CLI 拆成短提交和状态轮询:k8s git-mirror snapshot sync、image build、GitOps publish、git-mirror flush 和 PipelineRun 创建不得塞进一个顶层 `trans` 长连接。GitOps publish 必须使用隔离临时 clone/worktree,不能切换或污染 YAML 声明的固定 source workspace。
Legacy YAML-only lane 的长步骤必须由 CLI 拆成短提交和状态轮询:k8s git-mirror snapshot sync、image build、GitOps publish、git-mirror flush 和 PipelineRun 创建不得塞进一个顶层 `trans` 长连接。GitOps publish 必须使用隔离临时 clone/worktree,不能切换或污染 YAML 声明的固定 source workspace。
AgentRun YAML-only lane closeout 必须同时看当前 k8s git-mirror source snapshot、目标 PipelineRun、GitOps revision、Argo revision 和 manager source commit。发布过程中如果 source branch 被并行 PR 推进,`status --pipeline-run <name>` 会通过 `summary.branchDrift` / `alignment.branchDrift` 标记目标 PipelineRun 是否已被当前 snapshot tip supersede。最终只用最新 PipelineRun 的 `status``aligned=true``blockers=[]``argoSyncedToGitops=true``managerSourceMatchesExpected=true` 收口。
## JD01 v0.2 Gitea / Pipelines-as-Code lane
## JD01/NC01 v0.2 Gitea / Pipelines-as-Code lane
JD01 `agentrun-jd01-v02` 的 CI 触发路径已经从 branch-follower、act_runner 和自维护 trigger-current 迁移为单一路径:Gitea webhook -> Pipelines-as-Code -> Tekton -> GitOps/Argo -> k8s runtime。不要保留 Gitea Actions、act_runner、legacy branch-follower 或第二套 trigger fallback。Gitea mirror 的 source authority 来自 `config/platform-infra/gitea.yaml`PaC controller、Repository、webhook 和 Tekton 参数来自 `config/platform-infra/pipelines-as-code.yaml`
JD01/NC01 `agentrun-<node>-v02` 已迁移为单一路径:GitHub PR merge -> GitHub webhook -> Gitea controlled mirror -> immutable snapshot -> Gitea webhook -> Pipelines-as-Code -> Tekton -> GitOps/Argo -> k8s runtime。不要保留 Gitea Actions、act_runner、legacy branch-follower 或第二套 trigger fallback。CLI 必须组合 `config/platform-infra/gitea.yaml` `config/platform-infra/pipelines-as-code.yaml` 精确解析 consumer;未知、歧义或 source identity drift 都必须 `mutation=false` 并 fail-closed
Gitea 只作为受控 mirror/source authority 和 PaC webhook 源。公开 Web UI 是 `https://gitea.pikapython.com`k8s 内部 clone/read 必须走 `http://gitea-http.devops-infra.svc.cluster.local:3000/...`,不要让内部 CI、Argo 或 runtime 通过公网域名回环。需要 CI 匿名内部 read 的 mirror repo 必须在 YAML 中声明 `publicRead: true`bootstrap/apply 必须修正既有 repo/org visibility,而不只依赖 create-time 默认值。
PaC closeout 的首选状态入口是:
PaC 默认只读状态入口是:
```bash
bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01
@@ -96,7 +98,7 @@ bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01
默认状态必须显示 controller/CRD/webhook ready、最新 PipelineRun 和 TaskRun duration、`IMAGE_STATUS`、env identity、image digest、GitOps commit、Argo revision/health 和 runtime source/env annotations。env reuse 机制和证据解释统一见 [env-reuse.md](env-reuse.md),本文件不重复维护第二套口径。
JD01 v0.2 的 runtime boot repoURL、Argo repoURL 和 CI git read URL 必须使用 Gitea internal read URL。若仍指向 legacy git-mirror read URLruntime 可能因 `git-mirror-exact-commit-unavailable` 崩溃,因为对应 commit 只存在于 Gitea snapshot/source。GitOps publish 必须用 PaC/Gitea token Secret 写回 Gitea mirror;如果 `platform-infra pipelines-as-code status` 中 GitOps commit 已推进但 Argo revision 未追上,可以用 `bun scripts/cli.ts agentrun control-plane refresh --node JD01 --lane jd01-v02 --confirm` 触发受控 hard refresh,但持久修复是 Gitea write pathArgo repoURL 对齐,不是 legacy git-mirror flush。
PaC lane 的 runtime boot repoURL、Argo repoURL 和 CI git read URL 必须使用 Gitea internal read URL。若仍指向 legacy git-mirror read URLruntime 可能因 `git-mirror-exact-commit-unavailable` 崩溃。GitOps commit 已推进但 Argo revision 未追上时,只用 status/history 定位并修 Gitea write pathArgo repoURL、controller 或 owning YAML;不得执行 AgentRun refresh 或 legacy git-mirror flush 补齐当前交付
Runner egress proxy、持久化、idle timeout 和 retention 只从 `config/agentrun.yaml``deployment.runner.*` 进入部署。验收不能只看 manager Deployment/Pod env;必须用 HWLAB/AgentRun 原入口创建新 turn 或 runner Job,并检查新 runner Job env、session PVC、`AGENTRUN_SOURCE_COMMIT` 和 trace/result 是否复用同一 run 且没有 `reuse-blocked`
@@ -104,7 +106,7 @@ Kafka event stream 验收也必须创建真实 runtime factsmanager event 用
Provider credential 的 `config.toml` 变更同样走 YAML `sourceRef``secret-sync``restart`lane config 只声明该 lane 需要的 Codex CLI runtime options。模型、provider 和 endpoint 需要由 lane YAML 拥有时,使用 `sourceMode: codex-config``codexConfig` 渲染 Secret 中的 `config.toml`;不要修改或复制指挥机全局 `~/.codex/config.toml` 作为长期事实。
## AgentRun v0.1 兼容入口
## AgentRun v0.1 legacy 兼容入口
```bash
bun scripts/cli.ts agentrun control-plane status [--dry-run|--confirm]
@@ -122,7 +124,7 @@ bun scripts/cli.ts agentrun control-plane cleanup-released-pvs [--limit N] [--dr
AgentRun compact JSON 关键字段在 `.data.summary.sourceCommit``.data.summary.expectedPipelineRun``.data.summary.runtimeAlignment``.data.summary.gitMirror``.data.summary.ci.pipelineRun``.data.summary.argo``.data.alignment`,不要假设存在 `.data.status`
## AgentRun Git mirror
## AgentRun legacy Git mirror
```bash
bun scripts/cli.ts agentrun git-mirror status [--full|--raw]
@@ -1,192 +1,72 @@
# CI/CD Branch Follower
# CI/CD Branch Follower 退役参考
SPEC: PJ2026-01060703 CI/CD branch follower draft-2026-07-03-p0-branch-follower
## Retired Scope
## 当前边界
For JD01 `agentrun-jd01-v02`, `sentinel-jd01-v03` and `hwlab-jd01-v03`, branch-follower is retired as a delivery authority. The current architecture is Gitea mirror + Pipelines-as-Code + Tekton + Argo; use [gitea-pac.md](gitea-pac.md) for closeout commands and coverage.
`config/cicd-branch-followers.yaml` 中现有 follower 已全部迁移到 GitHub PR merge 驱动的 Gitea + Pipelines-as-Code 自动链。`cicd branch-follower` 不再是 delivery authority,只保留退役状态和历史对象的只读诊断。
The commands below remain historical/migration diagnostics. They must not be used to decide current delivery success for the three migrated JD01 consumers.
## Entrypoints
默认入口只允许:
```bash
bun scripts/cli.ts cicd branch-follower plan
bun scripts/cli.ts cicd branch-follower status
bun scripts/cli.ts cicd branch-follower status --live
bun scripts/cli.ts cicd branch-follower run-once --all --dry-run
bun scripts/cli.ts cicd branch-follower run-once --follower <id> --confirm --wait
bun scripts/cli.ts cicd branch-follower status [--follower <id>]
bun scripts/cli.ts cicd branch-follower events --follower <id>
bun scripts/cli.ts cicd branch-follower logs --follower <id>
bun scripts/cli.ts cicd branch-follower taskrun --follower <id> --taskrun <name>
bun scripts/cli.ts cicd branch-follower runtime --follower <id> --workload <name>
bun scripts/cli.ts cicd branch-follower debug-step --follower <id> --step state-read
bun scripts/cli.ts cicd branch-follower debug-step --follower <id> --step controller-source
bun scripts/cli.ts cicd branch-follower debug-step --follower <id> --step status-read
bun scripts/cli.ts cicd branch-follower debug-step --follower <id> --step decide
bun scripts/cli.ts cicd branch-follower debug-step --follower <id> --step state-write --confirm
bun scripts/cli.ts cicd branch-follower events --follower <id>
bun scripts/cli.ts cicd branch-follower logs --follower <id>
bun scripts/cli.ts cicd branch-follower gate --follower hwlab-jd01-v03 --gate control-plane-refresh --source-commit <sha> --confirm --json
```
Historically, `apply --confirm --wait` deployed the K8s controller and `run-once --confirm --wait` was the manual trigger and closeout path. For the migrated JD01 consumers this is no longer the formal delivery path. `status`, `events`, `logs` and `debug-step` are retained only to inspect or retire old state.
默认入口不支持 `plan``apply``run-once``job``gate``state-write``cleanup-state`。这些动作不得用于推进、补跑或恢复已迁移 consumer。
`debug-step` is the required single-step troubleshooting entry before changing branch-follower code for repeated CI/CD convergence issues. It runs in a bounded target-side Job when called from the operator host, and uses the same controller modules as the real flow:
当前交付状态使用:
- `state-read`: read only the compact ConfigMap state, value bytes, resourceVersion and `_updatedAt`.
- `controller-source`: read only the current target-side one-shot checkout identity: HEAD, branch, registry sha and key file markers. Use this before attributing a failed/slow self-upgrade run to new controller code.
- `status-read`: read native source/Tekton/Argo/runtime status without triggering adapters.
- `decide`: run the decision function in dry-run mode without triggering adapters or writing state.
- `state-write --confirm`: patch the stored follower state back through the normal ConfigMap write helper and report before/after resourceVersion; this is for isolating state write failures, not for normal rollout.
```bash
bun scripts/cli.ts cicd status --node <NODE>
bun scripts/cli.ts platform-infra pipelines-as-code status --target <NODE> --consumer <id>
bun scripts/cli.ts platform-infra pipelines-as-code history --target <NODE> --consumer <id> --limit 10
```
Do not debug the same state/read/write problem by repeatedly pushing empty or tiny source commits to drive the full automatic follower loop.
## Authority 与配置防绕过
When a branch-follower issue remains ambiguous after a debug step or drill-down, split the CLI into a smaller single-step probe before any new end-to-end run. Add or use a focused `debug-step`, follower-scoped drill-down, or bounded target-side diagnostic for the exact missing edge, such as PipelineRun -> Pipeline spec, controller refresh apply object, state write, closeout re-read, or log/timing extraction. Do not use another source PR, merge, or full automatic follower loop as the next diagnostic action until the narrower step can show the needed evidence.
- 写操作只信任仓库内固定 `config/cicd-branch-followers.yaml`
- `--config` 只可用于只读诊断;任意绝对路径、临时 YAML 或自造 `deliveryAuthority: legacy-manual` 都不能扩大写权限。
- 只有固定 owning YAML 中存在精确 `legacy-manual` follower 时,旧写实现才可从 `legacy-cicd` scope 进入。当前 YAML 没有此类 follower,因此 scoped help 不展示 mutation 示例。
- 非唯一、混合或漂移的 authority 一律 `mutation=false` 并 fail-closed。
- 退役状态清理与交付完全分离;确需删除旧 ConfigMap 状态时,只能使用 `retired-maintenance cleanup-state`,并且仍须固定 owning YAML、显式确认和有界候选输出。
For HWLAB native `control-plane-refresh`, the bounded evidence chain must preserve both the rendered Pipeline summary and the applied cluster object summary for the same source commit: rendered Pipeline name, bounded `runtime-ready` task/when summary, source commit/stage ref, applied Pipeline name, resourceVersion, and a short annotation/label subset proving which object was patched. If the Job TTL has already removed the original Job, status/events/logs must show `-` or a bounded missing reason from stored state instead of inferring the missing edge.
## 只读单步诊断
CI/CD validation must be decomposable into ordered single-step gates before a full rollout observation is accepted: first validate the reuse plan, then CI parallelism/TaskRun plan, then CD rollout plan, then post-deploy monitoring/health evidence. "Single-step" means an independently triggerable and independently executable target-side CLI/debug-step/drill-down entry, not a passive observation extracted from one end-to-end follower run. Each gate must be runnable against a selected follower/source snapshot, must emit bounded evidence, and must be retryable/fixable without creating a new source PR or replaying the full follower loop. The owner should not stop at explaining historical timing/status after a gate is clear enough to exercise; within the assigned boundary, it should autonomously trigger the relevant target-side single-step gate, inspect the short native result, tune the smallest failing edge, and rerun that same gate until it passes or a real permission/external/architecture blocker is proven. For HWLAB Pipeline render changes, `gate --gate control-plane-refresh --source-commit <sha> --confirm` is the independently triggerable native refresh gate. Do not use issue comments, repeated PR merges, or end-to-end follower loops as substitutes for a missing single-step validator; add the missing bounded CLI step first.
- `state-read`:读取有界 ConfigMap 状态、resourceVersion 和更新时间。
- `controller-source`:读取退役控制器 checkout 身份与关键文件标记,不执行同步。
- `status-read`:读取 source、Tekton、Argo 和 runtime 状态,不触发 adapter。
- `decide`:只运行 dry-run 决策,不创建对象、不写状态。
- `events|logs|taskrun|runtime`:按单个 follower 或对象渐进披露历史证据。
PRs that change branch-follower convergence, reuse, Tekton/Argo closeout, runtime readiness or gate visibility must be submitted only after the author has run the affected independently triggerable single-step gates on the target NODE/k8s and captured bounded pass evidence. If a required gate cannot be triggered independently or does not pass, do not open the PR as a validation vehicle; leave a short issue comment with the missing gate, target object names and next minimal fix scope, then fix the gate first.
状态、事件和日志必须保持有界,并优先显示失败、活动和慢 TaskRun、Argo 非健康原因、runtime readiness 与短错误。缺失对象必须显示 `NotFound` 或结构化缺失原因,不得推断成功,也不得输出写入型 `Next`
An independently triggered single-step failure is an actionable defect inside that gate until proven otherwise. The owner must narrow the failing gate, patch the relevant CLI/helper/controller logic, rerun the same target-side step, and iterate until it passes before submitting a PR. Issue-only blockers are reserved for permission gaps, external service outages, architecture decisions, or work that is explicitly outside the assigned runtime/repo boundary; ordinary failing step evidence is not a blocker.
## 自动链规则
When a repeated runtime pitfall or visibility defect is found during branch-follower work, update this reference or the skill entry first, then continue with the narrow debug step. Do not proceed to `run-once`, controller loop observation, automatic follower validation, or source-commit-driven integration until the relevant `state-read`, `status-read`, `decide`, and `state-write` debug steps pass for the affected follower.
当前唯一正式链路是:
Stage and end-to-end timing budgets are observability and guidance signals, not hard failure gates. When a stage or total wall-clock exceeds its YAML budget, the CLI/controller should record `overBudget`, emit a warning/hint, keep exposing state and continue toward native completion when the underlying Tekton/Argo/runtime operation is still making progress. Do not fail, kill, or permanently block a follower solely because the timing budget elapsed; otherwise the timeout checker itself can become the source of hung or failed delivery. Real failures must come from native objects such as Job/TaskRun/PipelineRun/Argo/runtime conditions, explicit command failures, missing required source/config, or operator cancellation.
```text
GitHub PR merge
-> GitHub webhook durable inbox
-> Gitea authority branch + immutable snapshot
-> Gitea webhook
-> Pipelines-as-Code
-> Tekton
-> GitOps/Argo
-> runtime
```
`debug-step` wrappers must be failure-visible and non-crashing. If the target-side Job fails, returns an older schema, or omits optional summary fields, the operator-facing CLI must render `-`/null plus the target error and Job identity; it must not throw a local TypeError before showing the target evidence.
自动链不通时修 owning YAML、webhook bridge、controller 或源码,并用修复 PR 合并产生的新正常事件验收。禁止重新启用 branch-follower controller、Gitea Actions、`act_runner`、host worktree、人工 mirror sync、直接 Gitea push、人工 PipelineRun 或运行面 patch 作为 fallback。
`debug-step` output must stay bounded in both text and JSON modes. The default machine payload should include step result, compact state/status/decision/write summaries, target Job identity and short error/timing fields only. Full target Job logs, full target JSON and long stdout/stderr tails belong behind explicit drill-down, not in the default `--json` payload.
## 历史处置
Bounded JSON means the operator-facing `--json` payload must remain below the YAML-configured stdout limit in normal successful debug cases. Do not duplicate the same evidence as full top-level objects, compact `targetResult`, full `stateAfter` and target stdout tail at the same time; choose one compact representation by default and put full payload/log drill-down behind explicit commands.
Target-side state summaries used by `status`, `events`, `logs` and `debug-step state-read` must also remain below the transport stdout limit. When exposing stored native payloads, return gate summaries only: git-mirror, Tekton, Argo, runtime and short errors. Do not include full source objects, TaskRun item arrays, plan-artifact arrays, report payloads or full command payloads in the default state summary; a truncated state summary is a visibility defect because the operator can no longer parse the follower state.
Follower-scoped commands such as `status --follower`, `events --follower`, `logs --follower` and `debug-step --follower` must ask the target summary helper for only that follower's state. Do not fetch every follower and filter locally at the operator side; multi-follower summaries have different size budgets and should use lower per-follower stage limits.
Multi-follower status summaries should omit per-follower `command.payload`/native drill-down payloads entirely; those belong to follower-scoped `events`/`logs`/`debug-step` queries. Default all-follower status must remain parseable below the transport stdout limit.
`scripts/src/cicd.ts` must stay a thin top-level CI/CD route entry. Branch-follower implementation belongs in `scripts/src/cicd-branch-follower.ts` and responsibility-specific modules; rendering, debug steps, controller manifests, native K8s helpers, adapter-specific trigger/status logic and large data compactors must be split before any implementation file approaches the 3000-line hard split point.
`status-read`, `events`, `logs` and debug summaries must expose compact closeout gate details when a follower is not aligned: git-mirror readiness, Tekton PipelineRun condition, Argo sync/health, runtime target sha/readiness and short errors. Repeating only phase/observed/target/message is a visibility defect and must be fixed before further rollout tuning.
Argo closeout visibility must include the bounded reason for non-ready health, not only `Synced/Progressing`: health message, operation phase/message, short Application conditions and a small list of non-healthy resources when available.
Tekton failure visibility must include bounded TaskRun detail, not only PipelineRun `Failed`: failed TaskRuns, active TaskRuns and slow TaskRuns with task name, reason and duration. Without this, performance/failure work cannot move past the PipelineRun gate.
Default stage timing tables must prioritize failed, active and slow TaskRun rows before ordinary succeeded TaskRuns when the row budget is tight. Do not truncate TaskRuns purely by Kubernetes start time if that hides the first failing or slow task.
When Argo exposes operation start/finish timestamps, stage timing rows should report the Argo operation duration directly. Missing timestamps still render `-`; do not infer Argo duration from total elapsed time or from unrelated runtime polling.
The automatic controller loop is non-blocking, so closeout acceleration cannot live only in the user-facing `--wait` path. Once a triggered PipelineRun has succeeded and required runtime/GitOps gates are not aligned, the in-cluster controller path should perform the same bounded target-side Argo refresh used by wait closeout; otherwise convergence depends on Argo's background poll interval and can exceed the 120s budget even when Tekton finished quickly.
The same rule applies to git-mirror post-flush. If native status shows runtime/Argo are aligned but GitOps mirror is still pending flush, the automatic controller loop must run the bounded target-side git-mirror flush instead of leaving a follower in `ClosingOut` until a manual wait/closeout path is used.
After an automatic closeout accelerator runs, the same reconcile must do a bounded native status re-read/poll and write the resulting state when it is already aligned. Do not defer the final `Noop` write to the next controller loop; loop interval plus another status-read can add enough idle time to exceed the 120s end-to-end budget even when PipelineRun, Argo and runtime are already ready. The re-read timeout must come from YAML follower budgets, and the short poll interval must come from YAML controller budgets. A single immediate re-read is insufficient when Argo accepts refresh first and updates operation/runtime state a few seconds later.
Stage timing rows must not label optional gates as `not-ready` when they are not part of that follower's closeout contract. For sentinel-like followers without a GitOps branch flush gate, git-mirror source snapshot readiness should render as source-ready/ready, while missing GitOps `githubInSync` remains `-`/not-applicable instead of a failure-looking state.
## Source Authority
- Follower decisions must not read host source worktrees, target dev directories, `.worktree/*`, local git state, or direct GitHub branch refs.
- Controller pods use EmptyDir plus the YAML-declared k8s git-mirror cache PVC, sync GitHub refs from inside Kubernetes, clone UniDesk controller source from `/cache`, then run the CLI with the mounted registry.
- All GitHub/Git egress used by branch-follower source sync, adapter git-mirror sync/flush, PR/merge closeout helpers and controller bootstrap must resolve proxy settings from YAML/sourceRef. Controller GitHub SSH uses `config/cicd-branch-followers.yaml#controller.source.githubSsh`; runtime adapters use their owning lane/control-plane YAML host proxy refs such as `config/hwlab-node-control-plane.yaml#nodes.<NODE>.egressProxy`. Do not rely on undeclared pod env, host shell proxy variables, direct GitHub transport, or trans-side proxy defaults.
- Runtime source commits, build contexts, publish inputs and closeout status remain owned by each adapter's k8s git-mirror snapshot and runtime objects.
- Trigger adapters communicate through the Kubernetes API with the controller service account. Formal triggering, observation and closeout must not depend on downstream CLI stdout parsing, host worktrees, or operator shell state.
- Dirty, stale, or missing-dependency host worktrees are non-authoritative and must not change observed sha, trigger sha, PipelineRun, GitOps, or status output.
- `trans` or SSH may be used only by the operator CLI as a transport to create/read Kubernetes objects on the target cluster. It must not be part of branch-follower source sync, GitHub communication, status collection, decision making or closeout.
## YAML Ownership
`config/cicd-branch-followers.yaml` owns controller settings and the follower registry: id, adapter, source/target configRefs, command argv, native status object refs, closeout check labels and budgets.
It must not copy runtime/GitOps/Secret details from owning configs:
- HWLAB node lanes: `config/hwlab-node-lanes.yaml`
- AgentRun lanes: `config/agentrun.yaml`
- Web sentinel profiles/scenarios/reports/secrets: `config/hwlab-web-probe-sentinel/*.yaml`
Use configRef summaries in plan/status; do not create a `full.md` or super Markdown index.
Timeout, TTL, retry/backoff, reconcile interval and end-to-end budget values must be declared in YAML/source-of-truth fields. Do not introduce hidden numeric defaults in TypeScript, shell, native helper scripts, or controller manifests; helper code should read the configured values and fail structurally when required timing policy is missing.
## First Followers
- `hwlab-jd01-v03`: historical follower only. This lane has migrated to Gitea webhook -> Pipelines-as-Code consumer `hwlab-jd01-v03` -> Tekton -> GitOps/Argo -> runtime readiness; do not re-enable branch-follower, Gitea Actions, act_runner or custom trigger fallback for it.
- `agentrun-jd01-v02`: historical first follower only. This lane has migrated to Gitea webhook -> Pipelines-as-Code -> Tekton -> GitOps/Argo -> runtime readiness; do not re-enable branch-follower, act_runner or custom trigger fallback for it. Current operation lives in `config/platform-infra/gitea.yaml`, `config/platform-infra/pipelines-as-code.yaml`, [gitea-pac.md](gitea-pac.md) and [agentrun.md](agentrun.md).
- `web-probe-sentinel-master`: historical follower only. This lane has migrated to Gitea webhook -> Pipelines-as-Code consumer `sentinel-jd01-v03` -> Tekton publish -> GitOps/Argo -> runtime readiness; do not re-enable branch-follower, Gitea Actions, act_runner or custom fallback for it.
These entries describe the initial production set and migration history. Current JD01 closeout for all three consumers uses the PaC/Gitea path.
## Reuse And Mirror Contract
The controller must preserve the runtime reuse capabilities that already exist in the runtime lanes:
- runtime reuse: if both code identity and env identity are unchanged for a microservice, skip rebuild and rollout for that service;
- env reuse: if code changed but env identity is unchanged, reuse the previous environment image and publish only the changed service artifact;
- git mirror: source sync, immutable source snapshot creation and GitOps flush are generic branch-follower stages, not adapter-local afterthoughts.
Runtime/env reuse configuration for branch-followed source repositories must live in the followed repository at `./gitops/reuse.ymal`. The branch-follower reads that file from the k8s git-mirror source snapshot, parses it through the shared reuse-config parser, and passes only the bounded redacted summary to adapter status/trigger payloads. Do not keep separate adapter-local reuse config as the authoritative source for branch-follower runs.
The reuse-plan gate must emit an adapter-consumable per-service decision, not only a parsed config summary. At minimum the bounded plan must show the source/env identity comparison outcome, runtime reuse hit/miss, env reuse hit/miss, whether CI should build an image, the existing image/ref to reuse when known, and the short reason. When a service's Dockerfile/env identity is unchanged, the plan decision is `skipImageBuild` for that service: CI must consume that decision and must not rebuild the environment image by independently re-inferring changes from source files, pipeline defaults or TaskRun logs. If only code changed, rollout should move the new source through git-mirror/runtime source sync while reusing the prior env image.
Adapters should expose reuse evidence through compact native state. HWLAB uses the `plan-artifacts` task event summary (`affectedServices`, `buildServices`, `reusedServices`, `artifactProvenanceAudit`). AgentRun publishes deterministic image/GitOps/git-mirror stage names and source-commit labels so a later loop can resume closeout without rebuilding completed stages. Sentinel keeps the same source/CI/Argo/runtime contract but has no GitOps branch flush gate.
The normal convergence budget is 120 seconds per source change. A follower may report `ClosingOut` while waiting for Argo/runtime readiness, but it must not report `Noop` when the source sha matches and required native gates such as git-mirror flush are still incomplete.
## Status Contract
Default `status` output must show follower id, phase, adapter, source branch + observed sha, target sha, last triggered sha, last succeeded sha, in-flight job/PipelineRun, budget source, timing summary and next drill-down commands.
Stage timing must be queryable through normal CLI output, not only raw JSON. `status` and `run-once` print a bounded `STAGE TIMINGS` table with `total`, `status-read`, git-mirror, Kubernetes Job, PipelineRun, TaskRun, Argo, runtime and closeout rows when available. `followers[].timings` remains available in `--raw`/JSON for machine consumers.
`run-once` also prints a bounded `STATE WRITES` table whenever it writes follower state. The table must include follower id, write status, before/after ConfigMap resourceVersion, whether timing was preserved, exit code and a short message. Missing write evidence is a visibility defect; use `debug-step --step state-write` before any further full-loop validation.
`timings.totalSeconds` is the authoritative end-to-end wall-clock measurement for a triggered run: measure from `timings.startedAt` until `timings.finishedAt`, or until query time while closeout is still running. Do not compute total by summing stage rows, because stage rows can overlap, omit external waiting, or be reported by different native objects.
Do not backfill, infer, or migrate old branch-follower state when historical timing, stage timing, or other observability fields are missing or known to be unreliable. Compatibility starts with future state written by the current controller; old missing data must render as `-`/unknown in CLI output instead of being recovered from unrelated native objects.
State writes must preserve same-source total timing at the target side. When a later native observation for the same follower and same observed source sha lacks `timings.totalSeconds` or `timings.startedAt`, the ConfigMap patch helper must read the existing follower state on the target node, keep the already-recorded total timing, and only replace stage rows/current gate details. This merge must happen in the target-side patch operation, not by host-side parsing or by a prior local read that can be overwritten by the next controller loop.
Controller self-upgrade has a one-loop source boundary: the controller Deployment uses the stable tools image, syncs UniDesk source into the k8s git-mirror cache, then clones `/work/unidesk` each reconcile. A UniDesk source commit that changes branch-follower controller logic can still be triggered by the previous checkout if the loop observes that commit before cloning it for execution. Do not use that self-upgrade source change to validate new controller-state semantics, and do not backfill its missing total timing. First confirm the target Pod checkout contains the fix, then validate future timing/state behavior with a later source change or an explicit target-side `run-once` that starts from a stored state written by the fixed controller.
When self-upgrade timing is unclear, use `debug-step --step controller-source` before pushing another source change. If the checkout identity is not visible, add that single-step visibility first; do not infer controller code version from a slow automatic rollout alone.
If a deterministic Kubernetes Job or PipelineRun is reused and there is no already-stored `timings.startedAt`, the reused object's current wait/check duration is only a stage observation; it must not be promoted to `timings.totalSeconds`.
When `run-once --confirm --wait` resumes a source change that is already `ClosingOut`, the CLI may wait for native closeout and report a `closeout` stage duration. That closeout-only wait is not the end-to-end total unless the stored state already contains a valid `timings.startedAt`.
State machine phases are `Observed`, `Noop`, `PendingTrigger`, `Triggering`, `ClosingOut`, `Succeeded`, `Failed`, `Superseded`, `Blocked`, and `Skipped`.
Status and decision inputs are Kubernetes-native:
- source: k8s git-mirror cache ref and immutable snapshot ref;
- CI: Tekton `PipelineRun.status.conditions`;
- CI drill-down: compact TaskRun timings and plan-artifact reuse summary when available;
- git mirror: source snapshot readiness plus GitOps `pendingFlush`/`githubInSync` when the follower owns a GitOps branch;
- deployment: Argo `Application.status.sync` and `Application.status.health`;
- runtime: selected Deployment/StatefulSet readiness plus source commit labels, annotations or env.
The branch follower must not parse downstream CLI stdout/stderr, `kubectl` human tables, `argo` text, `tkn` text, or curl output to infer observed sha, target sha, readiness or closeout. `kubectl -o json` may be used inside the controller/Job as a structured Kubernetes API transport only.
In-cluster controller and native helper scripts must not require a `kubectl` binary in the image. Native helpers that read or write ConfigMaps, Jobs, PipelineRuns, Argo Applications, Pods or logs must use the serviceaccount token and Kubernetes HTTPS API directly, or a shared native helper that does the same. A missing `kubectl` binary is a product defect in the helper, not a node problem. Operator-side `kubectl` through the controlled CLI/trans boundary remains acceptable only as a transport/debug wrapper.
Native helper scripts that are reused in both execution planes must make the plane explicit. Inside a Pod/Job they use serviceaccount HTTPS API; from the operator/trans boundary they may use the controlled `kubectl` transport. A helper must not assume serviceaccount files exist on the target node, and must not assume `kubectl` exists inside the controller image.
The controller automatic loop submits trigger work without a blocking wait; later loops close out via the native state objects above. Failed state must not dedupe a source commit forever: retries may reuse deterministic native objects for the same source commit, and a new compact observation should be able to move the follower back into triggering or closeout.
State ConfigMaps must stay bounded and human-queryable. Store compact summaries, stage refs, conditions, short messages, and drill-down object names; do not store full API payloads or long log dumps. Cleanup is an explicit operator operation for stale/broken state and must not be required for normal convergence.
When retesting the same source sha after fixing controller/render inputs, `cleanup-state` only deletes the stored follower state. It does not delete deterministic native objects such as an existing PipelineRun, and decision logic may still treat that sha as already triggered. Do not loop on cleanup plus `run-once`; use an independently triggerable gate such as control-plane refresh or an explicit rerun/cleanup of the native object, then re-read status.
Status readers must compute near the data. When the operator CLI reaches a target node or k8s route through `trans`, the target NODE/k8s side must parse ConfigMap values, Kubernetes objects and log/event lists locally, then return only the bounded follower summary, timing rows, object names, counts and short tails needed by the CLI. Do not transmit complete ConfigMap entries, full API objects or long logs back to the host just so host-side TypeScript can parse and trim them.
Operator transport timing warnings such as `UNIDESK_SSH_TIMING` measure CLI/trans latency, not branch-follower CI/CD stage time or end-to-end convergence time. Do not mix those warnings into `timings.totalSeconds`, stage rows, or performance closeout evidence; when transport cost becomes noisy, reduce round trips by adding a target-side debug/status summary instead of pulling more raw output to the host.
Validation, test and performance evidence for branch-follower changes must also run on the target NODE/k8s runtime, not on the local/master host. For CI/CD changes, use the target node's Tekton/Argo/runtime objects, controlled CLI jobs, and target-side summary scripts as the evidence source; local tests may not be cited as convergence or performance proof.
Operator-facing commands must use intuitive target-side verbs instead of internal execution flags. From a local/master host, use `status --live`, `run-once ...`, `events`, or `logs`; these commands create a bounded target-side Job when live state is needed. The internal `--in-cluster` flag is reserved for the Kubernetes Job/Pod command line after the registry, serviceaccount, in-cluster API endpoint and EmptyDir source checkout are mounted. It must not appear in user-facing examples.
`--in-cluster` only selects the execution environment. It must not imply `--wait`, closeout blocking, longer budgets, or sequential waiting across followers. Only an explicit user-facing `--wait` may perform blocking closeout waits; the automatic controller loop must submit/observe one bounded step and let later loops advance state from native Kubernetes objects.
Legacy `--controller` is accepted only as a compatibility spelling: inside Kubernetes it maps to `--in-cluster`, while outside Kubernetes it behaves like the ordinary public target-side path rather than running in-cluster logic locally. If an internal flag, hidden mode, or operator shortcut is misused and can write partial state or misleading evidence, stop feature work and simplify the public command semantics plus this reference before continuing.
`run-once --dry-run` is read-only for deployment: it may refresh the state ConfigMap with current native observations, but it must not trigger adapters.
- `hwlab-jd01-v03``agentrun-jd01-v02``web-probe-sentinel-master` 及对应 NC01 历史项只用于迁移审计。
- 退役对象的历史 timing、reuse 和 closeout payload 不能作为当前状态权威。
- 需要保留的历史实现留在源码中并由 authority guard 屏蔽;文档不得继续把它描述为当前自动控制器。
@@ -2,7 +2,9 @@
控制面负责 PipelineRun、RBAC/Pipeline/Argo、node-scoped runtime lane 和 D601 节点本地 infra bootstrap。正式入口都必须使用 `bun scripts/cli.ts`,不要把裸 `kubectl``argo``tkn` 当长期操作面。
## G14 Control Plane
## G14 legacy Control Plane
本节 `trigger-current` 只适用于 YAML 明确解析为 legacy/manual authority 的 G14 lane。
```bash
# 最新 head
@@ -30,10 +32,10 @@ bun scripts/cli.ts hwlab g14 control-plane apply --lane v02 [--dry-run|--confirm
```bash
bun scripts/cli.ts hwlab nodes control-plane status --node <node> --lane v03 [--pipeline-run <name>|--source-commit <sha>] [--full|--raw]
bun scripts/cli.ts hwlab nodes control-plane apply --node <node> --lane v03 --confirm
bun scripts/cli.ts hwlab nodes control-plane trigger-current --node <node> --lane v03 --confirm --wait
bun scripts/cli.ts hwlab nodes control-plane sync --node <node> --lane v03 --confirm
bun scripts/cli.ts hwlab nodes control-plane refresh --node <node> --lane v03 --confirm
bun scripts/cli.ts platform-infra pipelines-as-code status --target <node> --consumer <consumer>
bun scripts/cli.ts platform-infra pipelines-as-code history --target <node> --consumer <consumer> --limit 10
bun scripts/cli.ts hwlab nodes control-plane platform-maintenance --help
bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help
```
`status` 默认返回 compact summary,只保留 source commit、PipelineRun、Argo、runtime readiness、public probe、git mirror 和 next action;完整 expected YAML/render target、kubectl result tail、Secret/sourceRef 详情和 probe 原始结果只在 `--full``--raw` 下展开。
@@ -50,17 +52,17 @@ bun scripts/cli.ts hwlab nodes control-plane refresh --node <node> --lane v03 --
- 总预算、heartbeat 周期、终止宽限期和探针开关只由 `config/hwlab-node-control-plane.yaml#status` 维护;
- `--timeout-seconds` 只作为本次调用的显式总预算覆盖,不能变成代码默认值或第二份长期配置。
`control-plane apply --confirm` node/lane 控制面更新入口:`config/hwlab-node-control-plane.yaml` 收敛 node-local infra、git-mirror ConfigMap、RBAC、Tekton/Argo 前置对象,再应用 source-render 的 runtime namespace、Pipeline 和 Argo Application。node-scoped git-mirror 脚本只有 infra manifest 是权威来源;不要把 HWLAB source render 生成的 `devops-infra/git-mirror.yaml` 当作正式 apply 来源
`control-plane apply --confirm`显式平台配置维护入口:从 `config/hwlab-node-control-plane.yaml` 收敛 node-local infra、RBAC、Tekton/Argo 前置对象,再应用 runtime namespace、Pipeline 和 Argo Application。它只从 `platform-maintenance` scoped help 发现,不得作为 PaC source PR 合并后的交付、恢复或补跑
`trigger-current --confirm --wait` 是 node/lane CI/CD 一键入口:按 YAML `sourceAuthority` 解析并同步 source snapshotlegacy lane 执行 k8s git-mirror source snapshot syncGitea/PaC 迁移 lane 执行受控 Gitea mirror sync 并读取 immutable snapshot ref;随后执行必要 pre-flush,刷新 control-plane,创建或复用 commit-pinned PipelineRun,等待 PipelineRun 终态,并在终态成功后继续执行 post-flush、GitOps/Argo、runtime readiness 和 public `/health` closeout
`trigger-current|refresh|sync` 只属于 owning YAML 明确解析为 `legacy-manual` 的 lane,并且只在 `legacy-cicd` scoped help 中可发现。PaC 与 `unknown` authority 必须在任何远端调用或异步 job 创建前结构化拒绝,默认/失败态 Next 只能指向 status/history 与自动链修复引用
120 秒是同一条 CLI 的端到端预算,不是每个子阶段各等一轮。嵌套 git-mirror sync/flush 必须按剩余预算裁剪并跳过多次 retry;状态探测也必须按剩余预算收窄。超预算时 CLI 返回 `pending` warning,输出阶段分解、PipelineRun 状态、GitOps flush revision、Argo observed/target revision、runtime/public readiness、pending TaskRun/Pod drill-down 和下一条受控 status 命令。PipelineRun 已成功但 Argo/runtime/public 仍在收敛时,状态原因应落在 Argo/runtime closeout,不再泛化成 CI TaskRun pending
Legacy 一键入口的端到端预算只由 owning YAML 声明,不是每个子阶段各等一轮。嵌套 git-mirror sync/flush 必须按剩余预算裁剪;超预算只输出阶段分解和下一条只读 status。PaC consumer 从 GitHub PR merge 事件计时,不适用操作者一键入口
CI/CD closeout 的 public gate 只验证选中 node/lane 的 formal public HTTPS `/health` 入口和必要 provenance。`web-probe`、Playwright、远程浏览器、DOM quick-verify 和用户路径 E2E 只能作为发布后的独立业务验证证据,不能作为 CI/CD gate、timeout blocker 或 closeout 必要条件。public gate 失败时先对齐 YAML publicExposure、GitOps 渲染出的 `frpc` ConfigMap/Deployment、Secret key presence、frpc 日志、PK01/Caddy loopback 和目标侧 HTTPS probe;不要把裸 FRP 端口、legacy 端口或 web-probe 超时当作 CI/CD 结论。
FRP/Caddy 类 closeout 要同时看 ConfigMap 与 Pod 实际加载状态。ConfigMap 或 Secret 对象存在不等于 frpc 已登录成功;必须检查运行中 Pod 日志是否有目标 proxy `start proxy success`,并区分 `port already used``port not allowed``token in login doesn't match token from configuration` 和 upstream service 502。frpc token 必须通过 YAML sourceRef 下发为 Kubernetes Secret,再由源码渲染以 SecretRef/env/template 消费;不要把 token 明文写入 GitOps ConfigMap、issue、日志或 reference。
`sync --confirm` 是 Argo runtime 收敛修复入口:先按 YAML 同步本地 postgres bootstrap Secret,再终止卡住的 running Argo operation、删除失败 hook Job,并在 StatefulSet template 已更新但旧 controller-revision pod 因 `ImagePullBackOff` / `ErrImagePull` / `CrashLoopBackOff` 卡住时受控删除该旧 pod。不要手工裸删 pod。
Legacy `sync --confirm` YAML 声明的 Argo runtime 收敛修复入口。PaC consumer 不得调用它补齐交付;平台 Secret 维护与 source delivery 必须分开。不要手工裸删 pod。
## D601 Infra Bootstrap
@@ -89,8 +91,8 @@ runtime base image 走 `config/hwlab-node-lanes.yaml``baseImageSource` 是公
## Failure Triage
PipelineRun 失败或长时间未完成时,先按定点 `control-plane status --pipeline-run <name>` 和 bounded 只读诊断定位失败 TaskRun/Pod/container。env-reuse service build 常见失败点是 `build-<service>``step-publish` 日志;先用 `platform-infra sub2api status|validate` 区分共享 proxy 整体故障和单个上游 transient。proxy 健康但单个依赖下载 transient 时,可以受控 `trigger-current --rerun`重复失败应修 `artifact-publish`/envRecipe 的有限 retry 后重新合并发布
PipelineRun 失败或长时间未完成时,先按定点 status/history 和 bounded 只读诊断定位 TaskRun/Pod/container。PaC consumer 即使是单个 transient 也不得用 `trigger-current --rerun`;应修 `artifact-publish`envRecipe、controller 或 owning YAML 的有限 retry,再通过新的正常 PR merge 验收。只有明确 legacy/manual authority 才能使用旧 rerun
小范围 PR 触发 120s 时必须看 plan artifacts 的 `affectedServices/buildServices/reusedServices`:如果 source diff 很小却出现所有 envreuse 服务都在 `buildServices``reusedServices=[]`,优先怀疑 current GitOps artifact catalog 没有 hydrate 到 source plan 阶段,而不是继续盲目重跑 PipelineRun。
JD01 migrated consumer 出现 registry half-state、GitOps 超预算或 runtime 未对齐时,先走通用 `platform-infra pipelines-as-code closeout --target JD01 --consumer <id> --source-commit <sha> --wait`,再用 `status`/`history` drill down。Web 哨兵 control-plane 的默认 `DRILLDOWN` 只作为 debug/status 辅助;PipelineRun/TaskRun 细节走 `platform-infra pipelines-as-code history --id`registry tag 走 `web-probe sentinel image status`GitOps flush 走受控 `hwlab nodes git-mirror status|flush` 或 async job status;不要回退到裸 `kubectl/tkn/argo`consumer 专用手动 publish 或解析原生命令输出
Migrated consumer 出现 registry half-state、GitOps 超预算或 runtime 未对齐时,只用 PaC `status|history`、PipelineRun id、只读 sentinel image status 和 bounded logs 下钻。不得把 PaC closeout、GitOps flush、async mutation job 或 consumer 专用 publish 放入 Next;定位后修 owning YAML、controller 或源码,并用新 PR merge 自动事件验收
@@ -1,40 +1,42 @@
# Git Mirror
# Git Mirror Authority
Git mirror 是 legacy lane 的 source authority 和迁移期 GitOps flush 入口;Gitea/PaC 迁移 lane 的 source authority 是 Gitea controlled mirror 和 immutable snapshot reflegacy git-mirror 只承担 GitOps flush,除非后续 issue 明确迁移 GitOps writeback。不要回退到 operator host git、目标 host fixed workspace 或第二套 source resolver。
## 默认只读入口
## HWLAB Git Mirror
Gitea/PaC migrated consumer 的 mirror、authority branch、immutable snapshot 与 GitOps publication 全由 GitHub PR merge 自动链拥有。默认只允许读取:
```bash
bun scripts/cli.ts hwlab g14 git-mirror status
bun scripts/cli.ts hwlab g14 git-mirror apply [--dry-run|--confirm]
bun scripts/cli.ts hwlab g14 git-mirror sync [--dry-run|--confirm]
bun scripts/cli.ts hwlab g14 git-mirror flush [--dry-run|--confirm]
bun scripts/cli.ts hwlab nodes git-mirror status --node <node> --lane v03
bun scripts/cli.ts hwlab nodes git-mirror sync --node <node> --lane v03 --confirm --wait
bun scripts/cli.ts hwlab nodes git-mirror flush --node <node> --lane v03 --confirm --wait
bun scripts/cli.ts platform-infra gitea mirror status --target <NODE>
bun scripts/cli.ts platform-infra gitea mirror webhook status --target <NODE>
bun scripts/cli.ts hwlab nodes git-mirror status --node <NODE> --lane <lane>
bun scripts/cli.ts agentrun git-mirror status --node <NODE> --lane <lane>
```
- `apply`: 渲染并 apply `devops-infra/git-mirror.yaml`
- `sync`: 把当前配置声明的 GitHub refs 拉入本地 mirror,并为 source branch tip 创建不可变 snapshot ref。
- `flush`: 把本地 lane GitOps ref 快进推回 GitHub。
已迁移或 `unknown` authority 必须在远端调用或异步 Job 创建前 fail-closed。状态和失败 `Next` 只能指向 status/history 与自动链修复引用,不得建议 mirror sync/flush、直接 Gitea push、host git、fixed workspace 或第二套 source resolver
Node-scoped `hwlab nodes control-plane apply --node <node> --lane v03 --confirm` 会先执行同一套 YAML-first infra apply 来收敛 git-mirrorgit-mirror ConfigMap、proxy、transport 和 Secret 绑定以 `config/hwlab-node-control-plane.yaml` 为权威。HWLAB source render 产出的 `devops-infra/git-mirror.yaml` 不是 node-scoped 正式 apply 来源,旧产物漂移时修 infra render 或显式 `hwlab nodes control-plane infra apply`,不要补 host worktree 或双路径兼容
自动链故障必须修 `config/platform-infra/gitea.yaml``config/platform-infra/pipelines-as-code.yaml`、webhook bridge、受控 mirror worker 或源码,并通过修复 PR 合并产生的新正常事件验收。不得使用 branch-follower、人工 PipelineRun 或 legacy mirror mutation 补齐当前交付
`cicd branch-follower` 已把 `sync``flush` 作为通用 Kubernetes-native stage 接入 HWLAB/AgentRun 自动跟随:trigger 前先 sync 并创建 immutable source snapshotGitOps publish 后必须 flushstatus/closeout 以 mirror cache ref 和 `pendingFlush=false`/`githubInSync=true` 为准。正常自动跟随不需要 operator 手动清理旧状态;旧 ConfigMap 或历史 PipelineRun 干扰时只用 `cicd branch-follower cleanup-state --follower <id> --confirm` 做显式 cleanup。
## Legacy scope
GitHub/GitHub SSH/HTTPS 相关 egress 必须从 YAML-first host proxy 配置进入 git-mirror Job/Podnode-scoped lanes 读取 `config/hwlab-node-control-plane.yaml#nodes.<NODE>.egressProxy``targets.<id>.gitMirror.egressProxy/githubTransport`branch-follower controller 读取 `config/cicd-branch-followers.yaml#controller.source.githubSsh`。状态和日志应披露 `source=yaml`、proxy mode、object/key/sourceRef 摘要和 redacted fingerprint,不得依赖未声明的 host env、trans proxy、裸直连 GitHub 或 CLI 输出解析。
Git mirror mutation 只属于 owning YAML 精确解析为 `legacy-manual` 的 lane,并且只能从显式 scoped help 发现:
PipelineRun `gitops-promote` 如果报 git mirror 控制面漂移、refs 不一致或 flush/publish 未完成,优先按当前 `devops-infra/git-mirror.yaml` 收敛:先 `git-mirror apply --confirm`,再 `git-mirror sync --confirm --wait`,然后用 `control-plane cleanup-runs --pipeline-run <failed-run> --confirm` 受控清理失败 PipelineRun 后重试。旧 branch/path allowlist gate 已删除,不要恢复旧 hook、直接 `kubectl delete`、手工 patch pod 内 hook 或绕过 `flush`
```bash
bun scripts/cli.ts hwlab nodes git-mirror legacy-ops --help
bun scripts/cli.ts agentrun git-mirror legacy-ops --help
```
手动 trigger closeout 不能只看 PipelineRun `Completed`。必须继续查 `control-plane status --pipeline-run <name>``git-mirror status`node-scoped `trigger-current --confirm --wait` 会自动做必要的 mirror pre/post flush,但 closeout 仍要确认最终 `pendingFlush=false``githubInSync=true`。如果 lower-level 手工路径或旧 job 留下 `pendingFlush=true`,执行 `git-mirror flush --confirm --wait``pendingFlush=false`
Legacy scope 仍须遵守:
`trigger-current --node D601|D518|JD01 --lane v03 --confirm --wait` source selection 必须走 YAML `sourceAuthority` 解析出的受控 source snapshotlegacy lane 先执行受控 `git-mirror sync`,在 mirror cache 中为本轮 branch tip 创建不可变 `refs/unidesk/snapshots/hwlab-node-runtime/<branch>/<commit>`Gitea/PaC 迁移 lane 先执行受控 Gitea mirror sync,并读取 Gitea immutable snapshot ref。随后 trigger/status/build 只读取该 snapshot ref 作为 authoritative source。旧 `source-render` / `local-git-clone-worktree` / 可变 branch ref 追 branch tip 的问题不得再用固定 worktree fetch/pull 修复。
- source selection 只使用 YAML 声明的 immutable snapshot,不读取 host worktree、`.worktree/*` 可变 branch ref
- GitHub transport、proxy、Secret sourceRef 与 snapshot prefix 全部来自 owning YAML
- `sync``flush` 与 apply 只服务明确 legacy lane,不能作为 PaC 恢复手段;
- 验收使用只读 status 证明 mirror、GitOps、Argo、runtime 与目标 source commit 对齐,不能只看 PipelineRun terminal。
node-scoped lane 可能在本次 PR 合并后又被后续 PR 推进。`control-plane status --pipeline-run <name>` 是定点观察某个 PipelineRun,但输出里的当前 `sourceHead` / `summary.sourceCommit` 可能已经是最新 branch tip。closeout 必须同时记录 PR merge commit、PipelineRun 名称/状态、Argo sync revision、当前 branch tip,并证明最新 tip 包含本次 PR。
## 退役状态清理
## D601 SSH Transport
Branch-follower 已退役。旧状态清理不属于 mirror 自动链,也不使用默认 `cleanup-state`。确需清理时使用 `cicd branch-follower retired-maintenance --help` 中的受控入口;固定 canonical owning YAML,先列候选,再显式确认。
D601/v03 `git-mirror` 的 GitHub upstream 标准传输固定为 YAML 声明的 SSH:`githubTransport.mode=ssh`,脚本通过 `GIT_SSH` wrapper 访问 `ssh://git@ssh.github.com:443/...`node-global HTTP proxy 只作为 SSH CONNECT tunnel,不是 GitHub HTTPS auth/token transport。若 CLI 输出 `transport=https``GITHUB_TOKEN``git-mirror-github-token` 或 HTTPS token sourceRef,按 control-plane drift/配置回归处理:先修 YAML 并执行 `hwlab nodes control-plane apply --node D601 --lane v03 --confirm`,不要改走 HTTPS、不要增加 fallback、不要用 host workspace repair。
## D601 legacy transport
D601/node-scoped mirror status 的 `githubGitops` 来自本地 mirror cache 的 `refs/mirror-stage/...``status` 输出应通过 `refSources.githubFieldsAreMirrorStageCache=true` 显示这一点。`flush --confirm --wait` 如果已经显示推送成功,但 post-push fetch/recheck 因 GitHub SSH transient 失败导致非零退出,会标记 `partialSuccess=push-succeeded-fetch-failed`。CLI 应自动做一次受控 sync/recheck;恢复后输出 `partialSuccessRecovered=true``postPushRecovery` 且整体 `ok=true`,未恢复时才指向 `sync --confirm --wait`
D601 legacy lane 的 GitHub upstream transport 由 YAML 固定为 SSH over `ssh.github.com:443`HTTP proxy 仅承担 SSH CONNECT。输出若漂移为 HTTPS token、host env 或未声明 transport,修 owning YAML 和平台配置,不增加 fallback
Legacy mirror status 中的 GitHub 字段可能来自 `refs/mirror-stage/...` cache,必须标记来源。push 已成功但 post-push fetch 因 transient 失败时,CLI 可以在同一个 legacy 受控实现内做一次有界 recheck;这项行为不得复制到 migrated PaC consumer 的 operator Next。
@@ -8,7 +8,7 @@ GitHub PR merge -> GitHub webhook bridge -> Gitea controlled mirror and immutabl
GitHub 是唯一上游写入权威。目标 source branch 的 GitHub PR merge 是唯一正式交付触发事件;合并后必须由 webhook、mirror、PaC、Tekton、GitOps/Argo 自动推进到运行面,无需主代理、子代理或操作者执行任何 apply、closeout、trigger、sync、flush 或补跑。状态与历史命令只能观察和诊断,不能推进交付。
自动链任一环节不通时,任务目标是修复该环节的 YAML、controller 或源码,并以修复 PR 合并后产生的新自动链事件作为验收证据。禁止用 Gitea Actions、`act_runner`、branch-follower、host worktree、Pipeline 内直连 GitHub、直接 Gitea push、人工 mirror sync、人工 PipelineRun、`trigger-current``webhook-test` 或自定义脚本补齐当前交付。`config/cicd-gitea-actions-poc.yaml` 只保留只读历史材料`webhook-test` 只允许诊断连通性。
自动链任一环节不通时,任务目标是修复该环节的 YAML、controller 或源码,并以修复 PR 合并后产生的新自动链事件作为验收证据。禁止用 Gitea Actions、`act_runner`、branch-follower、host worktree、Pipeline 内直连 GitHub、直接 Gitea push、人工 mirror sync、人工 PipelineRun、`trigger-current`伪 push、hook test 或自定义脚本补齐当前交付。`config/cicd-gitea-actions-poc.yaml` 只保留只读历史材料。会 POST Gitea hook `/tests``webhook-test` 已从 Gitea source-authority 与 PaC 公共 CLI、远端执行器移除,因为它会制造交付事件,不是只读连通性诊断
## Webhook bridge 自举保护
@@ -36,8 +36,10 @@ GitHub 是唯一上游写入权威。目标 source branch 的 GitHub PR merge
- Gitea source authority、GitHub webhook bridge、公网暴露与 source snapshot 归属 `config/platform-infra/gitea.yaml`
- PaC controller、Repository CR、consumer 参数、Tekton pipeline 名称与 Argo Application 归属 `config/platform-infra/pipelines-as-code.yaml`
- CLI 必须组合两份 YAML,并按 consumer、node、lane、upstream repository、branch 与 Gitea repository 精确解析 authority。不得用 URL heuristic 或 repo 专属特例;`unknown`、歧义和配置错误都必须 `mutation=false` 并 fail-closed。
- 旧人工 mutation 入口只允许在 owning YAML 精确解析为 `legacy-manual` 后从 scoped help 使用;Gitea/PaC migrated consumer 和 `unknown` authority 都不得回退到 legacy 实现。
- 包含 `gitea-actions` 的历史 snapshot prefix 只为既有 ref 保留,不代表 Gitea Actions 仍是有效触发架构。
- node 级状态入口是 `bun scripts/cli.ts cicd status --node <NODE>`consumer 级下钻入口是 `platform-infra pipelines-as-code status|history|closeout --target <NODE> --consumer <id>`
- node 级状态入口是 `bun scripts/cli.ts cicd status --node <NODE>`consumer 级默认下钻入口是 `platform-infra pipelines-as-code status|history --target <NODE> --consumer <id>`
- 共用 GitHub 上游的多 node PaC consumer 不得共用 webhook bridge URL。每个 target node 在 `config/platform-infra/gitea.yaml#targets[].webhookSync` 声明自己的 GitHub webhook 公网路径与 FRP remote port,并由平台自动交付渲染 node-local bridge、FRPC proxy 和 PK01 Caddy 路径;禁止在 source PR 合并后人工 apply 补齐。
- PaC Repository CR `spec.url` 必须与 Gitea webhook payload 中的 URL 一致。`config/platform-infra/pipelines-as-code.yaml#repositories[].url` 保持公网 Gitea repository URLk8s 内网 service URL 只能写入 `cloneUrl``params.git_read_url`;否则 PaC 会记录 `cannot find a repository match` 且不会创建 PipelineRun。
- JD01 与 NC01 共用的 repository 必须传入 target-specific `node` Repository param,每个 `.tekton` PipelineRun 必须用 `pipelinesascode.tekton.dev/on-cel-expression` 过滤该参数。一个 target 的 push 不得在同一 target cluster 创建另一 target 的 PipelineRun。
@@ -92,7 +94,7 @@ bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runti
使用 `bun scripts/cli.ts platform-infra pipelines-as-code history --target <NODE> --limit 10` 审计全部 consumer 的触发、耗时与 reuse。命令必须读取 target node 上的实时 Gitea Repository CR 与 Tekton PipelineRun/TaskRun,在 target 侧聚合并显式输出 `READ_ERRORS`
`history --id <pipelinerun>` 必须继续使用 consumer 实际 PipelineRun prefix 或 Tekton pipeline 归属匹配,避免共享 namespace 中的同一 PipelineRun 被同时归属到 JD01 和 NC01 consumer。
`history --id <pipelinerun>` 必须按 target 内唯一 PipelineRun prefix 或 Tekton pipeline 归属解析实际 consumer,并用该 consumer 生成 status/history `Next`。零匹配、多匹配或显式 `--consumer` 与实际归属冲突时必须在远端读取前 fail-closed;禁止回退默认 consumer。
## 按需只读调查
@@ -106,14 +108,28 @@ bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runti
`closeout` 只保留为可选的只读历史/诊断兼容入口,不得出现在 migrated consumer 的默认观察顺序、覆盖矩阵、正常下一步或 PR 合并后的主/子代理任务中。它不得创建、同步、刷新、重试或补跑任何交付对象。consumer 专属 debug 命令只允许复现单步故障,不得成为正式交付或恢复入口。
GitHub webhook delivery `202` 只代表 bridge 接收了事件。若状态显示 `STALE=true`,必须定位并修复 webhook bridge、受控 mirror 或 snapshot 自动同步逻辑;禁止输出或执行人工 `REPAIR`、mirror sync、直接 Gitea push 等补齐命令。修复后的端到端证据必须来自新的正常 GitHub PR merge。
GitHub webhook delivery 的持久接收与 refs 提交必须分层:
异步执行 `git fetch` 与 Gitea `push` 的 GitHub -> Gitea bridge worker 必须使用 YAML 声明的有界 retry。Retry 归属 `config/platform-infra/gitea.yaml#sourceAuthority.webhookSync.bridge.retry``webhook status` 应在默认摘要中显示当前 retry 形状。仅 GitHub delivery 返回 202 不足以收口 rollout 或 issue,还必须确认 Gitea branch、snapshot ref 与最新 bridge event 都对齐 GitHub head。
- receiver 验签并校验 payload 后,只有在 delivery 通过 fsync 与 atomic rename 写入 YAML 声明的 PVC durable inbox 后才能返回 HTTP `202 Accepted`
- `202 Accepted` 只证明 delivery 已持久接收,不证明 Gitea authority branch 或 immutable snapshot 已 committed
- journal 状态必须显式区分 `accepted``processing``committed``failed`,并保留 deliveryId、payload correlation、尝试次数与有界失败摘要;
- 同 deliveryId 与同 payload 必须幂等,异 payload 必须返回 `409`PVC 不可用或容量满必须返回 `503` 并使 readiness=false
- inbox worker 使用 YAML 声明的有界 backoff 自动重试,进程重启后恢复 `accepted` / `processing`,不要求人工 sync、repair、test 或 PipelineRun
- 只有同一 delivery 的 exact-after immutable snapshot 与 authority branch 经 atomic push 后重新读取 refs 证明一致,才能进入 `committed`
- Caddy 只可对尚未成功落盘的请求做小于 10 秒、body-safe 的有界重试,不能把代理成功当作 refs committed。
当前 bridge 的权威状态链是 GitHub delivery GUID -> durable inbox record -> post-push refs proof -> live refs。HTTP `202` 后记录仍是 `accepted``refsCommitted=false`;只有 worker 完成 exact refs 复核后才成为 committed。旧 durable journal 上线前的记录可以标记 `pre-durable-bootstrap`,但不得标记 committed。`ingressRetry` 在自动 PK01 host reconciler 完成前保持关闭,不能由操作者手工补配置。
Durable inbox 只是 webhook delivery 的持久接收与重试 journal,不是业务 source/ref 的第二份真相,也不得被 PaC、Tekton、GitOps、Argo 或 runtime 当作源码/read model。最终 source authority 仍只有 Gitea authority branch 与 immutable snapshot。`webhook status` 默认摘要必须显示 accepted -> processing -> committed/failed 分层,并将同一 delivery 的 accepted correlation 与 committed refs proof 关联。非 `202`、缺 accepted correlation、accepted 后未 committed 或进入 failed 都是失败/stale;只能修 webhook bridge、inbox worker、受控 mirror、snapshot controller 或 owning YAML,禁止输出或执行人工 `REPAIR`、mirror sync、直接 Gitea push 等补齐命令。
不得使用 `cicd branch-follower status` 判定三个已迁移 JD01 consumer 是否最新。该入口只保留历史/迁移用途,可能包含 PaC cutover 前的过期状态。
对已迁移 sentinel CI/CD 的 half-state 诊断,PaC `status` 命令负责 source/registry/GitOps/Argo/runtime 归因。它必须通过 target 侧短探针区分 registry-missing、GitOps-missing、Argo-pending 和 runtime-mismatch,并使用 `registry_probe_base` 等 YAML 声明的 probe endpoint;禁止重新把旧 control-plane status 当作主架构,也禁止把 registry manifest `HEAD` 当作 readiness 校验。
Sentinel 的内部 `pac-publish-current` capability 由 `config/platform-infra/pipelines-as-code.yaml#capabilities.sentinelInternalPublish` 单一拥有,当前必须保持 `enabled=false``admissionProvenance=unavailable``.tekton` 继续使用既有 `publish-current` 自动入口;不得因为 capability 关闭而改成人工 publish。Pod env、ownerReferences、ServiceAccount、label、annotation 与 controller metadata 都是普通 workload 可声明或可修改的运行对象,不能作为 creator proof。只有 #1769 定义的独立 admission-owned provenance、专用最小权限 SA/RBAC 与拒绝 workload 自声明标记全部落地后,才能通过 YAML 评审启用 capability。
PaC history/status 必须把候选 PipelineRun 分成 `outer-pac-event``inner-deterministic-publish``unknown`。只有唯一外层 PaC push event 可以驱动 latest、status、debug-step 或 closeout;内层 deterministic publish 只能作为未绑定的执行观察展示,父子关系缺证据时必须标记 `unproven`,不得按名称前缀推断。运行对象 metadata 只能支持“已观察到 PaC 形态”的分类,不能宣称 admission provenance 已验证。
## Reuse 证据解释
- AgentRun `IMAGE_STATUS=reused` 表示本次跳过 image build,或复用了同一 env identity 的 registry artifact。
@@ -128,6 +144,6 @@ GitHub webhook delivery `202` 只代表 bridge 接收了事件。若状态显示
- `skip`source commit 已推进,但没有声明的 runtime path 发生变更。流水线不得构建 image、修改 GitOps branch 或触发 Argo rollout,应保留既有 runtime source commit 与 digest 作为 provenance。
- `disabled``delivery.enabled=false`。删除生成的 manifest 与 release state;这代表 service retirement,不是 `skip` 的同义词。
CLI、文档、issue metadata 与 `runtimePaths` 之外的其他文件必须得到 `IMAGE_STATUS=skipped`。成功 skip 仍应产生一条短 PaC PipelineRun 作为 source-observation 证据,但应跳过 image build 与 GitOps publication。`status``closeout` 必须校验保留的 digest、GitOps revision、Argo state、runtime readiness 与 health,不得强制 runtime source commit 等于更新的非 runtime source commit。
CLI、文档、issue metadata 与 `runtimePaths` 之外的其他文件必须得到 `IMAGE_STATUS=skipped`。成功 skip 仍应产生一条短 PaC PipelineRun 作为 source-observation 证据,但应跳过 image build 与 GitOps publication。默认 `status` 必须校验保留的 digest、GitOps revision、Argo state、runtime readiness 与 health,不得强制 runtime source commit 等于更新的非 runtime source commit;兼容 `closeout` 只能复用同一只读证据
缺少 reuse 文本不能证明 reuse path 失败,应先核对 consumer type 与 PipelineRun 详情。
@@ -40,15 +40,19 @@ bun scripts/cli.ts hwlab g14 observability status|apply|query|targets|boundary|c
```bash
bun scripts/cli.ts platform-infra sub2api plan|apply|status|validate
bun scripts/cli.ts platform-infra sub2api codex-pool plan|sync|validate|expose|configure-local
bun scripts/cli.ts platform-infra gitea plan|apply|status|validate|mirror --target JD01
bun scripts/cli.ts platform-infra pipelines-as-code plan|apply|status|closeout|history|webhook-test --target JD01 [--consumer <id>]
bun scripts/cli.ts platform-infra gitea status|validate --target JD01
bun scripts/cli.ts platform-infra gitea mirror status --target JD01
bun scripts/cli.ts platform-infra gitea mirror webhook status --target JD01
bun scripts/cli.ts platform-infra pipelines-as-code status|history --target JD01 [--consumer <id>]
bun scripts/cli.ts platform-infra pipelines-as-code help platform-bootstrap
bun scripts/cli.ts platform-infra pipelines-as-code help compatibility-diagnostics
bun scripts/cli.ts platform-infra kafka groups --node <node> [--limit N|--full|--raw]
bun scripts/cli.ts platform-infra kafka groups cleanup --node <node> --policy <yaml-policy-id> [--confirm]
bun scripts/cli.ts platform-infra wechat-archive plan|apply|status|validate|pull
bun scripts/cli.ts platform-infra wechat-archive wcf-host-status|collector-plan|collector-apply|collector-status
```
`platform-infra` 是 UniDesk 运维的平台基础设施控制面;新增平台服务优先进入该命名空间或对应 YAML 声明目标,旧 `devops-infra` 只作为渐进迁移来源。Sub2API 日常部署、Codex pool、FRP 暴露、master `~/.codex` 配置、验收和排障统一使用 `$unidesk-sub2api`。Kafka groups cleanup 默认只生成 planpolicy 与单次上限来自 `config/platform-infra/kafka.yaml`;长期安全边界以 [Platform Infrastructure](../../../../docs/reference/platform-infra.md#kafka-event-bus-boundary) 为唯一权威。Gitea mirror 和 Pipelines-as-Code 是 JD01 migrated CI source/trigger 平台服务,source-of-truth 分别是 `config/platform-infra/gitea.yaml` `config/platform-infra/pipelines-as-code.yaml`;正式架构和三 consumer 矩阵见 [gitea-pac.md](gitea-pac.md)。PaC closeout 是 migrated lane 正式交付入口,PaC status 是当前状态入口,PaC history 是触发/耗时/env reuse 审计入口,默认输出必须包含 `READ_ERRORS` 并在目标 node 聚合,不能把读取失败或 namespace 过大误报为空表成功。不用 Gitea Actions、act_runner、branch-follower、自维护脚本或 consumer 专用手动 publish 兜底。`agentrun-jd01-v02` 是默认 consumer;哨兵使用 `--consumer sentinel-jd01-v03`HWLAB 使用 `--consumer hwlab-jd01-v03` 查看 PaC/Tekton/Argo/env reuse 证据。WeChat archive 是 platform-infra 的 YAML-first 工作流入口;只读 collector 的副本、镜像、WCF host、端口和版本 pin 都以 YAML 为准
`platform-infra` 是 UniDesk 运维的平台基础设施控制面;新增平台服务优先进入该命名空间或对应 YAML target,旧 `devops-infra` 只作为渐进迁移来源。Gitea mirror 和 Pipelines-as-Code 的 source-of-truth 分别是 `config/platform-infra/gitea.yaml` `config/platform-infra/pipelines-as-code.yaml`。Migrated consumer 的唯一交付触发是 GitHub PR merge,默认入口只有 Gitea/PaC status、history 与只读 debug。`closeout` 只在 PaC compatibility diagnostics scope 中保留为只读历史观察;会 POST hook test 的 `webhook-test` 已删除。`apply` 只属于首次平台 bootstrap,不能补齐 source PR 合并后的交付。任何读取失败都必须显式报告,不能误报为空表成功。其他平台服务继续遵循各自 owning YAML 与专项 skill
## CI Tools Image
@@ -26,6 +26,6 @@ bun scripts/cli.ts hwlab g14 monitor-prs --lane v02 [--once] [--dry-run]
bun scripts/cli.ts hwlab g14 monitor-prs --lane v03 [--once] [--dry-run]
```
只监控 base=`v0.3` 的 PR。ready PR 经 UniDesk `gh pr merge` 合并后触发 runtime lane CD,检查 PipelineRun、Argo、`hwlab-v03` runtime `/health` endpoint 和 Git mirror flush,并对失败 check、冲突、CD failure/timeout 创建或更新 failure issue
只监控 base=`v0.3` 的 PR。Ready PR 经 UniDesk `gh pr merge` 合并;合并本身是 PaC 唯一交付触发。Monitor 之后只能读取 PaC status/history、Argo、runtime `/health` 与 provenance,并在自动链失败或超时时创建/更新 failure issue;不得触发 runtime CD、Git mirror flush、人工 PipelineRun 或其他补跑
CI/CD validation 只允许使用部署对象的 `/health` 端点和必要 provenance;禁止在 CI/CD gate 中运行 web-probe、Playwright、远程浏览器截图或用户路径 E2E。public health probe 必须使用 `config/hwlab-node-lanes.yaml` 选中 node/lane 的 formal public URL;裸 IP、FRP 端口和 legacy 端口只作为边缘诊断证据,不能作为 CI/CD 验收口径。
@@ -96,6 +96,12 @@ proxy secret/config 文件只允许放在受控 Secret/state 路径,输出只
5. 提交并推送 YAML source truth 后,执行 `sub2api image-prepull --target <target> --confirm`,按返回 job 或 `--wait` 输出确认 YAML 声明的镜像已存在于目标 runtime。
6. 镜像准备好后执行 `sub2api apply --target <target> --confirm`,按返回的 `job status` 命令轮询完成。
7. 执行 `sub2api status --target <target>`,确认运行镜像等于 YAML 声明;滚动过程中短暂 `0/1` 先等待并复查,不要立即改账号池、Caddy 或 Secret。
8. 执行 `sub2api validate --target <target>`目标 public `/health` 和最小 public `/v1/responses` smoke;若需要确认未影响其它公开入口,再跑对应 target 的 `validate`
8. 执行 `sub2api validate --target <target>`目标 public `/health`;若需要确认未影响其它公开入口,再跑对应 target 的 `validate`
9. 镜像升级禁止配置对齐:
- 不得执行 `codex-pool plan``codex-pool sync``codex-pool validate`
- 不得对齐账号、group、统一 key、capacity、load factor、WebSocket、临时不可调度规则、代理绑定或手工账号。
10. 镜像升级的真实模型请求证据:
- 只能复用升级前已经存在的消费配置执行无写入 smoke;
- 不得为完成 smoke 创建、恢复、同步或覆盖任何配置。
不要把镜像版本写进脚本常量、JSON 或 manifest 模板。
@@ -2,11 +2,31 @@
部署 closeout 至少包含:
- 镜像升级验收:
- 只验证镜像、容器健康、依赖连通性和公开入口健康;
- 不执行 `codex-pool plan``codex-pool sync``codex-pool validate`
- 保持账号池和手工配置原样,防止升级覆盖手动配置。
- 镜像升级的真实模型请求证据:
- 只复用既有消费配置做无写入 smoke;
- 缺少既有消费配置时记录为未执行;
- 不得通过恢复 key、同步账号或对齐 group 补齐验收条件。
- `sub2api status`Deployment/StatefulSet/Service/Secret/NetworkPolicy 可见,运行镜像与 YAML 一致,`NetworkPolicy/allow-all` 符合 `podSelector: {}`、Ingress/Egress 全放行。
- `sub2api validate`app、PostgreSQL、Redis、service proxy、`NetworkPolicy/allow-all` 和临时跨 Pod PostgreSQL/Redis 连通性检查通过。
- `codex-pool validate`:统一 key 的 `GET /v1/models` 成功,并用 `localCodex.responsesSmokeModel` 跑一次小的 `POST /v1/responses` smokeowner balance / owner concurrency 已满足 YAML 最小值,capacity、WebSocket v2、Sub2API 内置 temporary-unschedulable 开关/规则和 sentinel runtime 状态与 YAML 对齐;`validation.gatewayResponsesRecent` 汇总最近 6 小时普通 `/responses``/v1/responses` 的 failover、forward failure、最终 4xx/5xx、慢 final error 与 `context canceled` 证据,`validation.gatewayCompactRecent` 单独汇总 `/responses/compact` 证据。若当前 Responses smoke `ok=true` 但 recent 字段 `degraded=true`,先区分是历史窗口残留还是新的 request id 正在失败;长期判定见 `docs/reference/platform-infra.md`
- 账号池验收:
- 仅在用户明确要求账号池运维或配置对齐时执行 `codex-pool validate`
- 统一 key 的 `GET /v1/models` 必须成功;
- 使用 `localCodex.responsesSmokeModel` 跑一次小的 `POST /v1/responses` smoke
- owner balance、owner concurrency、capacity、WebSocket v2、临时不可调度规则和 sentinel runtime 必须与 YAML 对齐;
- `validation.gatewayResponsesRecent` 汇总最近 6 小时普通 `/responses``/v1/responses` 的错误证据;
- `validation.gatewayCompactRecent` 单独汇总 `/responses/compact` 证据;
- 当前 Responses smoke 成功但 recent 字段 degraded 时,先区分历史窗口残留和新的 request id 失败;
- 长期判定见 `docs/reference/platform-infra.md`
-`publicExposure.enabled=true`,确认 YAML 声明的 public path 可用。FRP target 检查 FRP pathPK01 local target 检查 PK01 Caddy managed block 和 loopback upstream。未带 key 的 public `/v1/models` 401 只能证明网关可达,不能证明账号池可调度。
- 多 target 同时启用 public exposure 时,必须分别验证每个 target 的 root、`/health`、未带 key `/v1/models` 401,以及各自 `codex-pool validate --target <id>`;一个域名可用不能替代另一个域名的验收
- 多 target 公开入口验收
- 分别验证每个 target 的 root、`/health` 和未带 key `/v1/models` 401
- 只有用户明确要求账号池运维或配置对齐时,才执行各自的 `codex-pool validate --target <id>`
- 一个域名可用不能替代另一个域名的验收。
- 若目标声明了 `egressProxy.enabled=true`,确认 proxy Deployment/Service readySub2API 和 sentinel env 与 YAML 对齐,并通过 YAML 声明的 health URL 完成代理出站探针。
如果要证明真实模型请求可用,使用最小 `/v1/responses` 或等价 Codex smoke。不要把 group-level `/v1/models` 成功解释成每个上游 account 都健康。
+6
View File
@@ -10,6 +10,12 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子
## 高频规则
- 子代理并行只用于成功率高、耦合度低、能放进独立 worktree/branch/issue/PR 的任务;共享架构方向、公共契约和同文件高冲突修改先串行定锚。
- 管理性、决策性文档由主代理直接完成,不把决策权或治理写入外包给子代理:
- 主代理负责 `AGENTS.md`、skill 核心规则、长期 reference 规范、架构决策、主 issue anchor/closeout,以及 MDTODO FILE 的领域划分、ITEM 结构和状态治理;
- 子代理可以在已明确的 issue、MDTODO ITEM 和验收边界内独立调研,并编写对应任务报告、验证记录、实现说明和证据附件;
- 子代理报告只承载其任务事实与结论,不能自行改写上层目标、架构取舍、优先级、主线状态或跨任务治理规则;
- 主代理审阅子代理报告后,亲自把被采纳的结论写入管理性、决策性文档。
- 纯文档交付可以按 `$git-spec` 的稳定分支快路径由主代理直接 commit/push,不要求临时 branch、`.worktree` 或 PR;发现本地分叉、并行改动、分支保护或运行面影响时必须保留状态并回到隔离交付。
- 正式 GitHub issue/PR/comment/merge 仍走 `$unidesk-gh`;子代理可以提交 PR 和写调查结论,主代理负责 review/preflight/merge,除非用户明确授权某个子代理自上线自验证。
- 主代理和子代理必须通过 issue/PR/comment 链接传递可复用上下文、调查结论、证据链接和下一步边界;派发前先读既有评论,prompt 中只引用链接和增量任务,不复述长结论,避免不同子代理重复调查同一事实。
- 主代理派发执行型子代理前必须先创建子 issue,并把主要任务、接续上下文、目标分支/worktree、范围、禁止项和验收入口写入子 issue 正文;子代理 prompt 只给子 issue 链接和极短边界,不塞大段任务正文。每个执行型子代理维护自己的子 issue 和评论作为接续上下文;主 issue 评论区只由主代理写入主线 anchor、阶段汇总、调度决策和最终 closeout。
+3
View File
@@ -60,6 +60,7 @@ controller:
followers:
- id: hwlab-jd01-v03
enabled: false
deliveryAuthority: retired-readonly
adapter: hwlab-node-runtime
description: Retired for GH-1557; HWLAB v0.3 JD01 runtime CI is driven by Gitea mirror -> Pipelines-as-Code -> Tekton -> ArgoCD.
source:
@@ -137,6 +138,7 @@ followers:
- id: agentrun-jd01-v02
enabled: false
deliveryAuthority: retired-readonly
adapter: agentrun-yaml-lane
description: Retired for GH-1552; AgentRun v0.2 JD01 CI is driven by Gitea webhook -> Pipelines-as-Code -> Tekton.
source:
@@ -213,6 +215,7 @@ followers:
- id: web-probe-sentinel-master
enabled: false
deliveryAuthority: retired-readonly
adapter: web-probe-sentinel-cicd
description: Historical follower retired by GH-1555. Active path is Gitea mirror -> Pipelines-as-Code -> Tekton -> ArgoCD; do not re-enable branch-follower/Gitea Actions/act_runner fallback.
source:
@@ -23,6 +23,11 @@ release:
controllerServiceName: pipelines-as-code-controller
controllerServicePort: 8080
waitTimeoutSeconds: 55
capabilities:
sentinelInternalPublish:
enabled: false
admissionProvenance: unavailable
trackingIssue: https://github.com/pikasTech/unidesk/issues/1769
closeout:
gitOpsMirrorFlush:
enabled: true
@@ -222,6 +227,7 @@ templates:
gitops_manifest_path: "deploy/gitops/node/${nodeLower}/web-probe-sentinel/web-probe-sentinel.yaml"
pipeline_name: "hwlab-web-probe-sentinel-${nodeLower}-pac"
pipeline_run_prefix: "hwlab-web-probe-sentinel-${nodeLower}"
service_account: default
hwlabV03:
id: "hwlab-${nodeLower}-v03"
name: "hwlab-${nodeLower}-v03"
+1 -1
View File
@@ -82,7 +82,7 @@ targets:
redisReplicas: 1
image:
repository: docker.1panel.live/weishaw/sub2api
tag: 0.1.150
tag: 0.1.149
pullPolicy: IfNotPresent
dependencyImages:
redis: docker.m.daocloud.io/library/redis:8-alpine
@@ -0,0 +1,19 @@
# AgentRun 运行可靠性
来源:[AgentRun #294](https://github.com/pikasTech/agentrun/issues/294)。
范围:长期跟踪 AgentRun admission、dispatch、runner、resource bundle、provider 建立、终态失败可见性与自动交付可靠性。单次故障作为 MDTODO ITEM 纳入,不为每个 issue 新建文件。
## R1 [in_progress]
依据 [AgentRun #294](https://github.com/pikasTech/agentrun/issues/294) 恢复 Artificer resource-bundle Git 获取与失败可见性,完成任务后将详细报告写入[任务报告](./details/agentrun-resource-bundle-git-fetch/R1_Task_Report.md)。
### R1.1
用固定四个 run 单步披露 bundle source、fetch 阶段、脱敏 remote/credential sourceRef 与首个失败分类,完成任务后将详细报告写入[任务报告](./details/agentrun-resource-bundle-git-fetch/R1.1_Task_Report.md)。
### R1.2
在 YAML/sourceRef 或 AgentRun runner/resource-bundle 权威路径修复 code 128,并统一 task terminal failure 摘要,完成任务后将详细报告写入[任务报告](./details/agentrun-resource-bundle-git-fetch/R1.2_Task_Report.md)。
### R1.3
通过 PR 合并自动 CI/CD 后重派同类 Artificer 任务,验收 bundle materialized、provider established 和业务执行开始,完成任务后将详细报告写入[任务报告](./details/agentrun-resource-bundle-git-fetch/R1.3_Task_Report.md)。
@@ -0,0 +1,23 @@
# PaC 已迁移消费方人工修复提示清理
- 来源:[UniDesk #1764](https://github.com/pikasTech/unidesk/issues/1764)。
- 自动交付原则:[UniDesk #1755](https://github.com/pikasTech/unidesk/issues/1755)。
- webhook 与 mirror authority[UniDesk #1760](https://github.com/pikasTech/unidesk/issues/1760)。
- 范围:按 owning YAML source authority 区分已迁移 PaC consumer 与 legacy lane,清理会人工推进交付的错误提示并同步长期参考与 skill。
- 禁止:不得把 `apply``closeout``trigger-current``sync``flush``webhook-test`、人工 `PipelineRun` 或直接 push 作为已迁移 PaC consumer 的下一步、修复或验收动作。
## R1 [completed]
交付 [UniDesk #1764](https://github.com/pikasTech/unidesk/issues/1764) 的 PaC 已迁移消费方人工修复提示清理,并遵循 [#1755](https://github.com/pikasTech/unidesk/issues/1755) 与 [#1760](https://github.com/pikasTech/unidesk/issues/1760) 的自动链 authority,完成任务后将详细报告写入[任务报告](./details/pac-migrated-manual-repair-hint-cleanup/R1_Task_Report.md)。
### R1.1 [completed]
调查 [#1764](https://github.com/pikasTech/unidesk/issues/1764) 涉及的 CLI help/status/Next/REPAIR,并从 owning YAML 建立已迁移 PaC 与 legacy authority 判定,完成任务后将详细报告写入[任务报告](./details/pac-migrated-manual-repair-hint-cleanup/R1.1_Task_Report.md)。
### R1.2 [completed]
修复 [#1764](https://github.com/pikasTech/unidesk/issues/1764) 的结构化 CLI 提示合同,使已迁移 consumer 只给只读诊断与自动链 issue/fix 指引,未知 authority fail-closed,完成任务后将详细报告写入[任务报告](./details/pac-migrated-manual-repair-hint-cleanup/R1.2_Task_Report.md)。
### R1.3 [completed]
同步 [#1755](https://github.com/pikasTech/unidesk/issues/1755) 与 [#1760](https://github.com/pikasTech/unidesk/issues/1760) 到 platform-infra、agentrun、hwlab 长期参考和 unidesk-cicd skill,并保留显式 legacy 入口,完成任务后将详细报告写入[任务报告](./details/pac-migrated-manual-repair-hint-cleanup/R1.3_Task_Report.md)。
### R1.4 [completed]
补齐 [#1764](https://github.com/pikasTech/unidesk/issues/1764) 的 migrated consumer、legacy/未知 authority、外层/内层 PaC 分类回归,完成 35 项合同测试、CLI 只读样例与差异检查,完成任务后将详细报告写入[任务报告](./details/pac-migrated-manual-repair-hint-cleanup/R1.4_Task_Report.md)。
@@ -19,4 +19,19 @@
补充轻量文档合同检查,阻止人工交付入口重新成为正式路径,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R1.3_Task_Report.md)。
### R1.4 [completed]
完成静态验证、报告、提交、推送与非草稿 PR,禁止触发或部署 CI/CD,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R1.4_Task_Report.md)。
完成静态验证、报告、提交、推送与非草稿 PR,禁止触发或部署 CI/CD,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R1.4_Task_Report.md)。
## R2 [in_progress]
清理 migrated PaC status/help 中会诱导人工补跑的 NEXT、REPAIR 与长期指引漂移;来源:[UniDesk #1764](https://github.com/pikasTech/unidesk/issues/1764),保持 GitHub PR merge 为唯一交付触发,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R2_Task_Report.md)。
### R2.1 [in_progress]
盘点 migrated PaC 的 status/help/Next/REPAIR 与文档、skill 漂移,以 [UniDesk #1764](https://github.com/pikasTech/unidesk/issues/1764) 和 NC01 source 85abda3b 自动发布证据冻结删除边界,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R2.1_Task_Report.md)。
### R2.2
修复 CLI renderer 与定向合同测试:migrated consumer 只提示只读 status/history/debug-step,不输出 apply、closeout、trigger、sync、flush、webhook-test 或人工 PipelineRunlegacy 显式入口保持可发现,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R2.2_Task_Report.md)。
### R2.3
由主代理同步清理长期 reference/skill,合并后只观察新的 GitHub webhook→Gitea mirror/snapshot→PaC→Tekton→GitOps/Argo→runtime 自动事件,并完成 issue/报告收口,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R2.3_Task_Report.md)。
+11 -18
View File
@@ -42,7 +42,7 @@ NC01:/root/agentrun-v02/.worktree/{pr_branch}
## 文档落库规则
AgentRun 仓库内长期参考、`spec-v01-*` `architecture.md` 交叉引用 stub 变更不创建 PR。完成本地审查后,必须直接提交并推送到对应目标分支,例如 `origin/v0.2`。需求规格正文变更落到 UniDesk OA 的 `project-management/PJ2026-01/specs/`,不要在 AgentRun repo 另维护一份正文。过程计划、阶段证据、验收结果和阻塞点写入对应 GitHub issue 评论区,不能用文档 PR 代替直接落库
AgentRun 源码仓目标 branch 的任何变更都必须通过 GitHub PR 合并,包括长期参考、`spec-v01-*` `architecture.md` 交叉引用 stub。不得以“仅文档”为由直接 push 目标 branch,因为目标 branch 的 PR merge 是 migrated source delivery 的唯一正式触发。若确有不触发交付的文档专用 branch,必须由 owning YAML 与 delivery authority 显式声明,不能把它当作默认例外。需求规格正文变更落到 UniDesk OA 的 `project-management/PJ2026-01/specs/`,不要在 AgentRun repo 另维护一份正文。过程计划、阶段证据、验收结果和阻塞点写入对应 GitHub issue 评论区,不能用文档提交替代正式 issue 证据
## 部署目标
@@ -62,14 +62,12 @@ trans NC01:k3s kubectl get pods -n agentrun-v02
## 受控 CI/CD 入口
AgentRun 控制面写操作必须通过 UniDesk 高层 CLI 执行。无 `--node/--lane` 的控制面命令解析 `config/agentrun.yaml.controlPlane.default`,当前固定为 `NC01/nc01-v02`;显式 `--node <node> --lane <lane>` 也只应选择 NC01 lane。新增或迁移 lane 必须从 `config/agentrun.yaml` 解析目标,不得从 AgentRun service repo 的 `deploy.json` 读取部署真相
AgentRun 目标必须从 `config/agentrun.yaml` 解析,不得从 service repo 的 `deploy.json` 或 URL 片段推断。Delivery authority 还必须与 `config/platform-infra/gitea.yaml``config/platform-infra/pipelines-as-code.yaml` 的 consumer/repository 组合校验。当前默认 `NC01/nc01-v02` 是 PaC migrated consumer;目标 source branch 的 GitHub PR merge 是唯一正式交付触发
```bash
bun scripts/cli.ts agentrun control-plane status
bun scripts/cli.ts agentrun control-plane trigger-current --dry-run
bun scripts/cli.ts agentrun control-plane trigger-current --confirm
bun scripts/cli.ts agentrun control-plane refresh --dry-run
bun scripts/cli.ts agentrun control-plane refresh --confirm
bun scripts/cli.ts platform-infra pipelines-as-code status --target NC01 --consumer agentrun-nc01-v02
bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer agentrun-nc01-v02 --limit 10
bun scripts/cli.ts agentrun control-plane cleanup-runners --node NC01 --lane nc01-v02 --dry-run
bun scripts/cli.ts agentrun control-plane cleanup-runners --node NC01 --lane nc01-v02 --confirm
bun scripts/cli.ts agentrun control-plane cleanup-runs --min-age-minutes 30 --limit 200 --dry-run
@@ -78,7 +76,7 @@ bun scripts/cli.ts agentrun control-plane cleanup-released-pvs --limit 200 --dry
bun scripts/cli.ts agentrun control-plane cleanup-released-pvs --limit 200 --confirm
```
YAML-only lane 的标准入口是
平台配置维护与 source delivery 是独立职责。`apply``secret-sync``restart` 只用于 YAML 声明的控制面、Secret 或配置维护,不得作为 PR 合并后的交付恢复
```bash
bun scripts/cli.ts agentrun control-plane plan --node NC01 --lane nc01-v02
@@ -88,24 +86,19 @@ bun scripts/cli.ts agentrun control-plane secret-sync --node NC01 --lane nc01-v0
bun scripts/cli.ts agentrun control-plane secret-sync --node NC01 --lane nc01-v02 --confirm
bun scripts/cli.ts agentrun control-plane restart --node NC01 --lane nc01-v02 --dry-run
bun scripts/cli.ts agentrun control-plane restart --node NC01 --lane nc01-v02 --confirm
bun scripts/cli.ts agentrun control-plane trigger-current --node NC01 --lane nc01-v02 --dry-run
bun scripts/cli.ts agentrun control-plane trigger-current --node NC01 --lane nc01-v02 --confirm
bun scripts/cli.ts agentrun control-plane status --node NC01 --lane nc01-v02 --full
bun scripts/cli.ts platform-infra gitea mirror status --target NC01
bun scripts/cli.ts platform-infra pipelines-as-code status --target NC01
bun scripts/cli.ts agentrun control-plane platform-maintenance --help
```
`status` 只读观察 YAML 选中 lane 的 source authority、对应 PipelineRun、GitOps latest、Argo Application、runtime workload、manager source commit 和 git mirror/Gitea 摘要,并报告 Argo revision 是否对齐该 lane 的 GitOps latest。未迁移的 legacy `v0.2` lane source authority 只来自 k8s git-mirror snapshot:受控 sync 先为 branch tip 创建 `refs/unidesk/snapshots/agentrun-yaml-lane/<branch>/<commit>``status` / `trigger-current` / build 只读取该 snapshot 和 `sourceStageRef`,不得把 host source workspace、本地 fetch/pull、可变 branch ref 或 Pipeline 直连 GitHub 当 authoritative source。默认输出是 compact commander 视图:`target` 只保留 node/lane/source/runtime/CI/GitOps/git-mirror/database 摘要,关键结论在 `summary``alignment`,成功 probe 的 stdout/stderr tail、完整 YAML target原始 `source``runtime``gitMirror` payload 默认省略;需要完整展开时使用返回的 `disclosure.fullCommand` 或显式加 `--full`,需要原始调试视图时加 `--raw``status` 额外支持 `--pipeline-run <name>``--source-commit <sha>` 定点查询;`--pipeline-run` 会读取 PipelineRun `revision` 参数作为 pinned source commit,并在 `alignment.branchDrift` / `summary.branchDrift` 中同时披露当前 snapshot tip、目标 source commit、PipelineRun source commit、是否已被当前 snapshot supersede 以及 `triggerLatest` 下一步。`status` 会向 stderr 输出 `agentrun.control-plane.status.progress` 阶段事件,覆盖 `source``runtime``git-mirror`,避免长时间聚合时无可见进展。legacy `trigger-current` 会先执行 k8s git-mirror sync 并以 snapshot commit 创建 commit-pinned PipelineRun;同名 PipelineRun 正在运行或已经成功时必须拒绝重复触发,只允许在失败态或不存在时创建。该命令只提交 CI/CD 工作,不等待完整 PipelineRun 或 rollout 完成,后续用 `job status``status --pipeline-run <name>` 轮询。`refresh` 只对 YAML 声明的 Argo Application 执行 hard refresh,用于 GitOps promotion 已完成但 Argo 仍停留旧 revision 时的受控同步入口;它不直接 patch runtime workload。
`status` 只读观察 YAML 选中 lane 的 authority、PipelineRun、GitOps、Argo、runtime、manager source commit 与 Gitea 摘要。默认输出是 compact commander 视图,完整 target原始 probe payload 只在 `--full|--raw` 展开。PaC consumer 的正常、失败和超时状态都只能给 `status``history` 与只读下钻;不得返回 `trigger-current``refresh`、mirror `sync|flush` 或人工 PipelineRun。authority 零匹配、多匹配或配置错误必须返回 `unknown``mutation=false` 并 fail-closed。
NC01 `agentrun-nc01-v02` PaC lane 的正式触发链路是:GitHub PR 合并到 `pikasTech/agentrun@v0.2` -> GitHub webhook bridge 同步到 Gitea controlled mirror immutable snapshot refs -> Gitea repository push webhook 触发 Pipelines-as-Code -> Tekton PipelineRun -> GitOps/Argo -> k8s runtime。该 lane 不使用 branch-follower、Gitea Actions、act_runner 或自维护 `trigger-current` 作为正式 CI 触发器;对应 source authority、repo visibility、public exposure 和 mirror credentials 属于 `config/platform-infra/gitea.yaml`PaC controller/Repository/webhook/Tekton 参数属于 `config/platform-infra/pipelines-as-code.yaml`。Gitea 的公开 Web UI 是 `https://gitea.pikapython.com`,但 CI、Argo 和 runtime 内部读取必须使用 `gitea-http.devops-infra.svc.cluster.local:3000` 的 ClusterIP URL,避免公网回环和 legacy git-mirror commit 缺失
NC01 `agentrun-nc01-v02` PaC lane 的正式链路是:GitHub PR merge -> GitHub webhook -> Gitea controlled mirror -> immutable snapshot -> Gitea webhook -> Pipelines-as-Code -> Tekton -> GitOps/Argo -> k8s runtime。该 lane 不使用 branch-follower、Gitea Actions、act_runner 或自维护 trigger fallback。Gitea authority 属于 `config/platform-infra/gitea.yaml`PaC consumer 属于 `config/platform-infra/pipelines-as-code.yaml`CI、Argo 和 runtime 内部读取必须使用 YAML 声明的 ClusterIP URL
AgentRun PaC lane 的 closeout 以 `bun scripts/cli.ts cicd status --node <NODE>` `bun scripts/cli.ts platform-infra pipelines-as-code status --target <NODE> --consumer agentrun-<node>-v02` 为首选状态入口。默认输出必须能直接看到 webhook 是否存在、最新 PipelineRun/TaskRun duration、image status、env identity、digest、GitOps commit、Argo revision/health、manager Deployment readiness 和 runtime source/env annotation。env reuse 的通过证据是 `IMAGE_STATUS=reused`、同一 env identity 和稳定 digest;首次 cache miss 可以作为冷启动事实记录,但不能替代后续 env-reuse 秒级收口。若 GitHub PR 合并没有新 PipelineRun应先排查 `platform-infra gitea mirror webhook status|test``platform-infra pipelines-as-code history`,不得改用 `trigger-current`、直接创建 PipelineRun、手工 push Gitea 或裸 `kubectl` 作为正式补触发。若 webhook delivery 返回成功但 GitHub head、Gitea branch、snapshot 或 latest PipelineRun source 不一致,先用 `platform-infra gitea mirror webhook status --target <NODE>` 披露差异,再用受控 `platform-infra gitea mirror sync --target <NODE> --confirm` 修复 mirror/snapshot,并把自动跟进缺口归并到 pikasTech/unidesk#1622;不要重复触发旧 `trigger-current` 掩盖 source authority 分叉
AgentRun PaC lane 的默认观察入口是 `bun scripts/cli.ts cicd status --node <NODE>` `bun scripts/cli.ts platform-infra pipelines-as-code status|history --target <NODE> --consumer agentrun-<node>-v02`。输出应显示 webhook、PipelineRun/TaskRun duration、image、env identity、digest、GitOps、Argo 和 runtime provenance。GitHub PR 合并没有新 PipelineRun GitHub head、Gitea branch、snapshot PipelineRun source 不一致时,只用 webhook status 与 PaC history 定位,然后修 owning YAML、bridge/controller 或源码;禁止 mirror sync、直接 Gitea push、人工 PipelineRun 或旧 trigger fallback 补齐当前交付
YAML-only lane 的 `trigger-current --confirm` 是受控长流程入口;k8s git-mirror snapshot sync、image build、GitOps publish、git-mirror flush 和 PipelineRun 创建必须拆成短提交与状态轮询,不得把 clone、build、push 或长时间 polling 放一个顶层 `trans` 长连接。`trigger-current` 返回异步 job 时,先用 `bun scripts/cli.ts job status <jobId> --tail-bytes 12000` 观察 `agentrun-yaml-lane-trigger` progress,再用 `agentrun control-plane status --node <node> --lane <lane> --pipeline-run <name>` 观察 Tekton、GitOps 和 Argo 对齐。后台步骤的 `status``ok` 必须共同判定`status=succeeded``ok=false` 是终态失败,不能继续轮询到超时。GitOps publish 必须使用隔离临时 clone/worktree,不能切换或污染任何固定 source workspace`v0.2` 历史失败 publish 若留下 dirty/detached/GitOps branch 状态,不得通过 host workspace repair 恢复 source authority,只清理已知发布残留并从 git-mirror snapshot 重新触发
只有 owning YAML 明确解析为 `legacy-manual` 的 lane 才能进入旧 trigger/mirror 实现,且命令只在 `agentrun control-plane legacy-cicd --help``agentrun git-mirror legacy-ops --help` 中可发现。Legacy 长流程必须拆成短提交与状态轮询,不得把 clone、build、push 或长 polling 放一个顶层 `trans`。PaC 或 `unknown` authority 在任何远端调用或异步 job 创建前都必须结构化拒绝`mutation=false`
AgentRun YAML-only lane 发布收口必须以当前 k8s git-mirror snapshot tip 为准。`trigger-current` 期间若 lane source branch 被并行 PR 推进,`status --pipeline-run <name>` 会通过 `branchDrift.sourceBranchAdvanced=true` / `targetSupersededByCurrentBranch=true` 标记该 PipelineRun 已不是当前 snapshot tipcloseout 必须确认最新 snapshot commit 包含本次修复,再按最新 snapshot 重新 `trigger-current`,最后用最新 PipelineRun 的 `status` 证明 `aligned=true``blockers=[]``argoSyncedToGitops=true``managerSourceMatchesExpected=true`。不要用已经被更新 source supersede 的中间 PipelineRun 作为最终 closeout。
YAML-only lane 的 `trigger-current` 会先确保目标 branch 已同步到 k8s git-mirror snapshot,再从 UniDesk YAML 声明的 image build、GitOps branch/path、runtime namespace、Secret、数据库和 manager env 渲染 artifact catalog 与 GitOps desired state。该路径会删除新 lane source branch 中的 `deploy/deploy.json`,因为部署真相已经迁入 UniDesk YAML;旧 `v0.2` branch 中历史文件只作为迁移前遗留产物存在,不能作为新 lane 的事实来源。Secret export 格式或外部数据库连接参数变化时,先用 `platform-db postgres export-secrets --confirm` 物化本地 Secret source,再用 `agentrun control-plane secret-sync --node <node> --lane <lane> --confirm` 下发,最后用 `agentrun control-plane restart --node <node> --lane <lane> --confirm` 让 manager Deployment 通过 rollout 读取新 Secret;不要手工删除 Pod 或直接 patch Secret。
平台 Secret/config 维护继续使用 YAML `sourceRef``secret-sync` `restart`,并允许独立于 delivery authority 执行;它们不得创建 PipelineRun,也不得被描述成 source delivery recovery。不要手工删除 Pod 或直接 patch Secret。
Provider credential Secret 的 `auth.json``config.toml` 也必须按 NC01 lane 的 YAML `sourceRef` 下发,不能把指挥机全局 Codex 配置当成运行真相。lane 的模型、provider、endpoint 和 Codex runtime options 需要由 UniDesk YAML 拥有时,使用 `sourceMode: codex-config` 和同一 Secret spec 下的 `codexConfig` 渲染 `config.toml`;不要通过修改 `/root/.codex/config.toml``~/.codex/config.toml` 来改变 HWLAB/AgentRun 运行面模型。
+22 -267
View File
@@ -1,276 +1,31 @@
# G14 Provider Node
# G14 旧交付运行手册(已退役)
G14 is a configured HWLAB runtime-lane node and a UniDesk provider node for staging other infrastructure workloads. It is not the global HWLAB default; issue/CLI lane+node selection and `config/hwlab-node-lanes.yaml` decide whether a task targets G14, D601 or another node. Legacy HWLAB G14 DEV/PROD (`G14`/`G14-gitops`, `hwlab-dev`/`hwlab-prod`, ports 17666/17667 and 18666/18667, Argo Applications `hwlab-g14-dev`/`hwlab-g14-prod`) is retired and must not be used as current source, release, validation, rollback or support truth. G14's UniDesk provider id is `G14`; the local UniDesk worktree is `/root/unidesk`, and the native k3s kubeconfig is `/etc/rancher/k3s/k3s.yaml`.
这里只退役本文件承载的旧 delivery runbook;不表示 G14 节点、硬件或所有 legacy lane 已退役。本文件不再是 G14/HWLAB 交付或运行面的权威入口。旧内容混合了手工 `trigger-current`、mirror sync/flush、PipelineRun 清理、直接运行面恢复与历史 `v0.2` 路径,继续保留会与当前 YAML-first 和 PaC 自动交付规则形成第二真相。
G14's long-lived k3s control bridge is `k3sctl-adapter-g14`, a UniDesk direct service outside the k3s fault domain. It listens on the G14 host loopback port `127.0.0.1:4266` and is registered separately from the D601 `k3sctl-adapter`, so G14 infrastructure services can be built and tested without taking over user services that still run on D601.
当前工作必须按以下权威入口执行:
G14's node-level PostgreSQL platform database is host-native infrastructure, not a k3s workload or `devops-infra` namespace service. It is documented in `docs/reference/g14-platform-db.md`; HWLAB v0.3 uses it through the namespace-local `g14-platform-postgres` bridge and must not keep its old repo-owned PostgreSQL StatefulSet/PVC after migration.
- HWLAB 固定运行面、工作区、原入口验收与凭据边界:`docs/reference/hwlab.md`
- Gitea source authority、PaC/Tekton、GitOps/Argo 与自动交付边界:`docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界`
- 跨节点现场调查、持久化和原入口复测:`$dad-dev`
- CI/CD 只读观察、scoped help 与 legacy 判定:`$unidesk-cicd` 及其按需 reference。
- 固定开发机、Master 构建限制和任务 worktree`docs/reference/dev-environment.md`
For Code Queue and non-HWLAB CI/CD migration preparation, G14 uses native k3s labels `unidesk.ai/node-id=G14` and `unidesk.ai/provider-id=G14`. The G14 Code Queue manifests `src/components/microservices/k3sctl-adapter/k3s/code-queue.g14.k8s.yaml` and `src/components/microservices/k3sctl-adapter/k3s/code-queue.g14.k3s.json` are candidate staging artifacts only until an explicit production cutover is approved. Non-HWLAB production Code Queue, CI/CD and user-service execution must remain on D601 while D601 is carrying those services.
## 自动交付硬边界
## Legacy DEV/PROD Retirement
PaC migrated consumer 的唯一正式交付触发是目标 source branch 的正常 GitHub PR merge
Legacy G14 DEV/PROD is a retired runtime, not a stable baseline. The controlled UniDesk entry is:
```bash
bun scripts/cli.ts hwlab g14 retirement status
bun scripts/cli.ts hwlab g14 retirement plan
bun scripts/cli.ts hwlab g14 retirement execute --confirm
```text
GitHub PR merge
-> GitHub webhook
-> durable inbox
-> Gitea authority branch + immutable snapshot
-> Gitea webhook
-> PaC
-> Tekton
-> GitOps/Argo
-> runtime
```
`status` reports the legacy Argo Applications, legacy namespaces, bounded resource previews, protected v0.2/v0.3 Applications and namespaces, and the local marker at `.state/hwlab-g14/legacy-g14-retirement.json`. `plan` is dry-run only and lists the exact destructive targets. `execute --confirm` deletes only `argocd/hwlab-g14-dev`, `argocd/hwlab-g14-prod`, namespaces `hwlab-dev`, `hwlab-prod`, and active local `hwlab_g14_pr_monitor` jobs; it must not touch `hwlab-g14-v02`, `hwlab-node-v03`, `hwlab-v02`, or `hwlab-v03`. The legacy base=`G14` PR monitor is blocked by this retirement contract even in a fresh checkout; the marker is execution evidence, not the source of the policy.
合并后不得由主代理、子代理或操作者执行 `apply``trigger-current`、mirror sync/flush、人工 PipelineRun、直接 Gitea push、运行面 patch、hook test 或其他补跑。链路失败时必须修复 owning YAML、controller 或源码,并通过后续正常 PR merge 产生的新自动事件验收。
The old `/root/hwlab` workspace on branch `G14` is no longer a default source truth. Use it only for explicit legacy archaeology or a user-authorized retirement follow-up, after fast-forwarding and reading the HWLAB repo rules. Current source, render, CI/CD and validation work must use the workspace resolved from the selected node/lane, such as `G14:/root/hwlab-v02` for G14 v0.2 or `D601:/home/ubuntu/workspace/hwlab-v03` for D601 v0.3.
The standard entry forms are:
```bash
trans G14:/root/hwlab sh -- 'git fetch origin G14 && git pull --ff-only origin G14 && git status --short --branch && git remote -v'
trans G14:/root/hwlab apply-patch < patch.diff
trans G14:k3s kubectl get pods -n hwlab-v02
```
`G14:k3s` is the only supported k3s route form. Do not use `ssh G14 k3s ...`; the first token must locate the distributed target, and the following tokens must be the operation.
If `/root/hwlab` has unrelated local changes when this sync starts, first determine whether they can be quickly merged with the latest `origin/G14`. Merge them immediately when they are mergeable; do not default to stash, discard or a behind worktree. Only when the changes cannot be automatically merged should they be isolated and the operation stopped for human decision. A behind fixed workspace is not a valid basis for precheck, new worktree creation, render, polling, deployment or runtime validation.
The retired G14 DEV/PROD boundary is:
- Legacy public endpoints `http://74.48.78.17:17666/`, `http://74.48.78.17:17667/health/live`, `http://74.48.78.17:18666/`, and `http://74.48.78.17:18667/health/live` are not current validation targets.
- Keep HWLAB Services as `ClusterIP` unless a repo-owned G14 GitOps rule explicitly exposes them. Public exposure should stay in the approved G14 edge/proxy path, not ad hoc NodePort or local port-forward.
- Use runtime-lane local PostgreSQL and runtime-lane Secrets for cloud-api durable runtime tests. Do not copy D601 database credentials.
- Use only G14-local Codex auth material and k8s Secrets authorized for HWLAB on G14; do not copy D601 or production auth material by hand.
- Set `HWLAB_CLOUD_API_PORT=6667` explicitly in the G14 cloud-api Deployment. Kubernetes otherwise injects a `HWLAB_CLOUD_API_PORT=tcp://...` Service environment variable that breaks the Node port parser.
- `HWLAB_PUBLIC_ENDPOINT` and health/live evidence must describe the active runtime lane endpoint, not retired G14 DEV/PROD or old D601 production endpoints.
- Do not run HWLAB repository `check`, Playwright/browser smoke, image builds or other heavy validation on the master server. For G14 targets, run those through the target G14 runtime lane workspace, G14 k3s/Tekton, or another explicitly approved external execution plane; for non-G14 targets, use the selected node/lane execution plane from `config/hwlab-node-lanes.yaml`.
- Manual device-agent experiments for real hardware must be standalone resources in the active runtime lane namespace and must not patch existing HWLAB Deployments, Services, ArgoCD Applications, FRP, CD desired-state or public frontend routing unless a separate HWLAB change authorizes it.
- A D601 Windows `hwlab-gateway` may connect outbound to an active G14 runtime lane cloud-api as an external host bridge for Keil/serial/workspace access. In that G14 device-agent model, the Windows bridge is not the runtime target; D601 can still be a first-class HWLAB runtime node when issue/CLI explicitly selects a D601 node-scoped lane.
Healthy G14 HWLAB runtime means the active runtime lane's main Deployments and StatefulSets are Ready, `cloud-api` and `edge-proxy` return `/health/live` with `status=ok`, durable runtime checks pass, and the public lane endpoints report the expected revision. For a device-agent smoke, health also requires the standalone device-agent Service to answer in-cluster and the D601 Windows gateway session/resource/capability to be visible through the active G14 cloud-api.
## HWLAB v0.2 Expansion Line
HWLAB `v0.2` is the supported G14 runtime lane for the v0.2 branch. It must not recreate, depend on, or roll back to the retired legacy `G14` DEV/PROD runtime. Legacy `G14`/`G14-gitops`, `hwlab-dev`/`hwlab-prod`, DEV public ports `17666/17667`, and PROD public ports `18666/18667` are not the stability baseline.
The fixed `v0.2` source branch is `v0.2`, forked from the current `G14` branch after the G14 long-term reference docs record this decision. The fixed G14 development workspace for that branch is:
```bash
trans G14:/root/hwlab-v02 sh -- 'git status --short --branch && git remote -v'
```
`/root/hwlab-v02` is the long-lived `v0.2` development workspace, not a scratch clone or CI/CD source selector. It must track `origin/v0.2` with `origin git@github.com:pikasTech/HWLAB.git`; local dirty state, stale `HEAD`, and untracked `.worktree/` only affect human development. Do not reuse retired `/root/hwlab` or `/root/hwlab/.worktree/*` as the `v0.2` fixed workspace.
`v0.2` CI/CD source selection is isolated in the dedicated bare repo `G14:/root/hwlab-v02-cicd.git`. UniDesk control-plane commands must fetch `origin/v0.2` into that repo and render from a commit-pinned detached worktree; they must not read the source commit from `/root/hwlab-v02` checkout state.
The fixed `v0.2` runtime namespace is `hwlab-v02`. The intended public FRP allocation is:
- Cloud Web browser entry: `http://74.48.78.17:19666/`.
- API/edge entry and live health: `http://74.48.78.17:19667/health/live`.
Master-side FRP server maintenance for HWLAB public ports is documented in `docs/reference/hwlab.md#hwlab-frp-维护`; keep the detailed allowlist, restart boundary and verification sequence there instead of duplicating another runbook in this G14 node reference.
The `v0.2` CI/CD integration owns a dedicated CI/CD source repo, `devops-infra` git mirror/relay, GitOps desired-state lane, Argo CD Application, namespace resources, artifact catalog, and a `deploy/deploy.yaml` lane config only when they target `v0.2`/`hwlab-v02` explicitly. Do not add or revive a legacy `G14` branch poller, DEV/PROD Argo Applications, DEV/PROD runtime paths, or existing namespace resources to bootstrap `v0.2`.
For current G14/v0.2, `deploy/deploy.yaml` is the single human-authored deploy/runtime config source. `deploy/deploy.json` is not a v0.2 compatibility source and must not be recreated for this lane. YAML and JSON parsing are centralized in the HWLAB repo's format-agnostic config layer: `scripts/src/structured-config.mjs` handles file format parsing/writing, while `scripts/src/deploy-config.mjs` owns deploy-config defaults and shape. Renderers, planners, smoke scripts and CLIs should consume that layer through `readStructuredFile` / `writeStructuredFile` / `readDeployConfig` or equivalent helpers; do not scatter direct YAML parser imports or ad hoc `readFile` + `YAML.parse` calls. Legacy D601 HWLAB CD still has its own `deploy/deploy.json` desired-state documented in `docs/reference/hwlab.md`; that legacy path is not the G14/v0.2 authority.
On the UniDesk control-plane side, HWLAB runtime lane expansion is sourced from `/root/unidesk/config/hwlab-node-lanes.yaml`. That YAML owns `nodes`, `lanes`, `networkProfiles`, `downloadProfiles`, target overrides and public entrypoints for the UniDesk trigger/status/apply layer. The base `lanes.v03` entry can point at `nodes.G14`, while `lanes.v03.targets.D601` owns the D601 v0.3 workspace, CI/CD repo, runtime path and `https://hwlab.pikapython.com` public exposure. Proxy URLs, `NO_PROXY`, git/npm/pip/docker/curl retry/download defaults and Docker build proxy settings live under profile objects. G14 must remain a node config value, not a hardcoded code path such as `g14Proxy` or `g14GitOps`; future lanes and node targets should be added by adding YAML entries and then consuming the generated spec. `hyueapi.com` and `.hyueapi.com` are required `NO_PROXY` entries and must remain present in effective runtime and Docker build proxy environments.
Runtime lane base images are also node/lane YAML truth. `baseImage` is the node-local registry artifact consumed by Tekton/BuildKit, while `baseImageSource` is the public source image used to seed it. For G14 v0.3, inspect and seed this dependency through `bun scripts/cli.ts hwlab nodes control-plane runtime-image status --node G14 --lane v03` and `bun scripts/cli.ts hwlab nodes control-plane runtime-image preload --node G14 --lane v03 --confirm`; `runtime-image build` is only a preload alias until a separate image-build workflow is explicitly designed. Do not hand-run `docker tag`, `docker push` or raw registry shell as the long-term control path. If the base image status is ready but a later PipelineRun fails in a service-specific build task, track that as a separate CI/service issue instead of reopening the base-image preload path.
The `devops-infra` git mirror/relay remains manual and CLI-controlled, not CronJob-driven. The standard `v0.2` delivery trigger is `bun scripts/cli.ts hwlab g14 monitor-prs --lane v02`: it watches base=`v0.2` PRs, waits for GitHub preflight/CI readiness, auto-merges only ready and non-conflicting PRs, then drives the same controlled CD path and comments pending/blocked/succeeded/failed/timeout state back to the PR. The `v0.3` delivery trigger is `bun scripts/cli.ts hwlab g14 monitor-prs --lane v03`: it watches base=`v0.3` PRs, uses the runtime lane control-plane to trigger CD, verifies PipelineRun/Argo/`hwlab-v03` runtime public probes/Git mirror flush, comments PR progress, and creates or updates failure issues for failed checks, conflicts, merge blockers, CD failures and timeouts. The lower-level `bun scripts/cli.ts hwlab g14 control-plane trigger-current --lane v02|v03 --confirm` remains the manual recovery or diagnosis entry; it must fetch the lane CI/CD bare repo, resolve the current source branch commit, check the mirror source ref before creating the PipelineRun, run one bounded manual `git-mirror sync` Job when the mirror is stale, and only continue after the mirror ref matches the current source commit. Use `hwlab g14 git-mirror sync --confirm` directly only for explicit mirror maintenance or diagnosis.
After a `v0.2` PipelineRun completes, treat runtime rollout and remote GitOps persistence as two separate checks. `hwlab g14 control-plane status --lane v02` is the runtime check: it must show the expected source commit, PipelineRun completed, Argo `Synced/Healthy`, public 19666/19667 probes passing, and Cloud Web asset probes such as `/app.js` readable. `hwlab g14 git-mirror status` is the persistence check: `cache.summary.pendingFlush` must be false and `cache.summary.githubInSync` true before declaring GitOps fully flushed back to GitHub. The PR monitor performs this flush automatically for its own merged PRs and records the result in the PR comment. Manual operators should run `bun scripts/cli.ts hwlab g14 git-mirror flush --confirm` and poll the returned job with `bun scripts/cli.ts job status <jobId> --tail-bytes 12000` only when they used lower-level manual trigger/status paths or when the monitor reports a flush failure; do not replace this with raw `kubectl`, native `git push`, or a long SSH wait.
For D601/node-scoped runtime lanes, `hwlab nodes git-mirror status --node <node> --lane <lane>` reports GitHub refs from the mirror cache's `refs/mirror-stage/...`, not from a live GitHub API request. The status output must keep that source visible through `refSources.githubFieldsAreMirrorStageCache=true`. A `flush --wait` run can push the GitOps ref successfully and still hit a transient GitHub SSH error such as `kex_exchange_identification` during follow-up fetch/verification; this condition is reported as `partialSuccess=push-succeeded-fetch-failed`. Current CLI should first perform a controlled sync/recheck and mark `partialSuccessRecovered=true` when `pendingFlush=false` and `githubInSync=true`; only unrecovered cases should point operators at `hwlab nodes git-mirror sync --node <node> --lane <lane> --confirm --wait`. Do not keep repeating blind flush attempts; refresh `refs/mirror-stage/...` with `sync --wait`, then recheck status for `localGitops=githubGitops`, `pendingFlush=false`, and `githubInSync=true`.
If `gitops-promote` fails because the git mirror control plane drifted, refs are inconsistent, or publish/flush did not complete, recover through the controlled mirror path: `hwlab g14 git-mirror apply --confirm` to reinstall the current hook/ConfigMap, `hwlab g14 git-mirror sync --confirm --wait` to realign source and GitOps refs, then a targeted `control-plane cleanup-runs --pipeline-run <failed-run> --confirm` before retriggering the same lane. The old branch/path allowlist gate has been removed; do not restore it, patch the hook inside the pod, delete PipelineRuns with raw kubectl, or bypass `git-mirror flush`. Closeout still requires the target PipelineRun status, Argo health, public probes, and `git-mirror status` with `pendingFlush=false`.
When closing an issue against a specific completed `v0.2` PipelineRun, use targeted status instead of the latest-head status if `origin/v0.2` has already advanced through a parallel task:
```bash
bun scripts/cli.ts hwlab g14 control-plane status --lane v02 --pipeline-run hwlab-v02-ci-poll-<short-sha>
bun scripts/cli.ts hwlab g14 control-plane status --lane v02 --source-commit <full-sha>
```
Targeted status must expose `statusTarget.mode` and `targetValidation`. `targetValidation.state=passed` means the requested PipelineRun/source commit reached a succeeded PipelineRun, Argo `Synced/Healthy`, public web/API probes, flushed Git mirror, and matching runtime source commits for the services listed in that run's `planArtifacts.rolloutServices`; services listed in `planArtifacts.reusedServices` remain visible as runtime/provenance evidence but must not be forced to the target source commit. `targetValidation.state=superseded` means the requested PipelineRun succeeded but no longer owns runtime: either it was replaced by a newer succeeded `v0.2` PipelineRun, or latest-only promotion observed that `origin/v0.2` had advanced before GitOps/runtime writeback and closed the historical run as no-op. This is valid closure evidence for the requested run when the newer commit is on the same branch lineage. In both states, `commitAlignment.staleReasons` may still mention later `origin/v0.2` or CI/CD source head movement; that is parallel-head context, not a failure of the requested run. `falseGreenGuard` is a current-runtime guard and should report not-applicable/superseded for such historical targets instead of turning later runtime movement into a false failure. Default status without a target remains strict for the latest source head.
For HWLAB user-feedback, CLI, Cloud Web, AgentRun, device-pod, public API, or runtime workflow issues, source-level validation is not enough to close the issue. Source checks, `git diff --check`, targeted build checks, PR merge metadata, and source commit rollout evidence are supporting evidence only. The issue may be closed only after the affected user entry or original entry has been exercised against the target runtime. For CLI issues, that means running the relevant `hwlab-cli` or UniDesk-controlled CLI command from the G14 `v0.2` workspace or approved execution plane against the intended lane/URL/namespace and proving the observed behavior, not just proving the helper code compiles. For Cloud Web or public API issues, use the public endpoint or a bounded API/asset smoke that reaches the deployed runtime. For AgentRun or device-pod issues, capture the trace/session/thread/run/job/device evidence that proves the specific continuation or hardware workflow reached the live backend.
For Cloud Web Workbench and Code Agent issues, the closeout validation must use the same dispatch entry as the browser flow, or a CLI command that calls that same Cloud Web/Cloud API dispatcher path. A hand-written `dispatchHwlabAgentRun()` canary, direct AgentRun manager command, or runner job created outside the Web dispatcher is only infrastructure evidence; it cannot prove that the browser path requested the current AgentRun runtime assembly, tool credentials, transient env, conversation/session/thread binding, or runtime lane. The current HWLAB v0.2 resource assembly requirement is `ResourceBundleRef.kind="gitbundle"` with `bundles[]` materializing repo `tools/` and `skills/`; the authority for those fields is UniDesk OA [Runtime装配](../../project-management/PJ2026-01/specs/PJ2026-010202-runtime-assembly.md) and [HWLAB接入](../../project-management/PJ2026-01/specs/PJ2026-010205-hwlab-dispatch.md). If no CLI can exercise the Web-equivalent path, improve the CLI first and keep the issue open until the Web-equivalent CLI or browser trace proves the deployed behavior.
Provider profile configuration and credential write rules for Code Agent are owned by `docs/reference/hwlab.md#code-agent-provider-profile-配置与验收`. This G14 reference only defines runtime lane and closeout evidence; do not duplicate profile Secret, `config.toml`, `auth.json` or CLI credential semantics here.
For Cloud Web Workbench Code Agent response or trace-rendering bugs, the minimum Web-equivalent CLI proof is a fresh `hwlab-cli client agent send --wait` against the deployed public Web origin, followed by `hwlab-cli client agent trace <traceId> --render web` against the same origin. The submit proof must show the browser dispatcher family, normally `POST /v1/agent/chat`, result polling through `/v1/agent/chat/result/<traceId>`, `continuation.webEquivalent=true`, `shortConnection=true`, and explicit `sessionId` / `conversationId` / `threadId` binding when those values affect the bug. The result proof must show the final assistant text from `assistantText` or `reply.content`; placeholder status text, result summaries, terminal status messages, and AgentRun completion boilerplate are not acceptable substitutes for the assistant final response.
For persisted final-response display regressions, a fresh turn alone is not enough when the user report identifies an existing conversation, session, or trace. Re-read the original record on the deployed `v0.2` runtime with locked lane env and the correct `projectId`; the default session list project may differ from the affected Workbench project. The minimum proof is `client session list --project-id <projectId> --limit <N> --full`, `client session inspect <conversationId> --full`, and `client agent result <traceId> --full`. Passing evidence must show that list and inspect surface the same latest agent `traceId` as `lastTraceId`, the latest agent text matches the terminal result `reply.content` or equivalent final assistant text, and known fallback text such as `Code Agent 仍在处理,可以继续 steer 或等待 trace 完成。` is absent from list, inspect, and result output. When the repair is lazy-on-read, run the read path again or capture the exposed repair source/updated marker so the evidence proves persisted conversation state was repaired, not merely synthesized for one response. `client agent trace <traceId> --render web` remains required for trace-rendering bugs; for persisted conversation-display bugs it is supporting evidence unless it returns rendered assistant rows from the same original trace.
The `--render web` proof must inspect the rendered body, not only the raw event count. Passing evidence should include `body.render=web`, the shared renderer identity when exposed, `status=completed`, rendered/returned row counts, noise/omitted counts when available, at least one rendered assistant row containing the final assistant text, and an explicit absence check for known non-user boilerplate such as `AgentRun terminal status completed`, `AgentRun result is ready`, and `Code Agent 仍在处理`. If the trace API returns `status=missing`, `sourceEventCount=0`, or no rows for a historical issue trace, treat that trace as expired or unavailable; do not use it as closure evidence. Generate a fresh equivalent turn on the current v0.2 runtime and validate that trace instead.
CLI/Web-equivalent trace evidence does not replace browser UI evidence for visual, layout, copy-to-clipboard, collapsed-panel or removed-control bugs. G14/v0.2 browser or DOM evidence must still hit the deployed public endpoint for the target lane, but Playwright/web-probe/fake-server/screenshot operation details are owned by `$unidesk-webdev`; this G14 reference only records lane and rollout evidence.
The closing comment for these issues must be semantic natural language before it lists evidence: state what the user-visible problem was, what changed, where it rolled out, and what original entry was rechecked. It must include the actual command or entry path, target lane or endpoint, relevant trace/session/thread/PipelineRun/run/device ids, and the pass/fail result. If the original entry cannot be verified because rollout has not happened, credentials are unavailable, the target runtime is down, or the required CLI capability is missing, keep the issue open and record the blocker. Do not close the issue on the strength of PR merge, targeted tests, or "will be verified after rollout" wording. If an issue was closed before this real CLI/user-entry validation, reopen it and add a correction comment before continuing.
For HWLAB v0.2 Code Agent context-loss or multi-turn continuity issues, the minimum closeout is a real `hwlab-cli client agent` two-turn E2E from `G14:/root/hwlab-v02` or another approved G14 execution plane with locked runtime namespace/lane env. Submit the first turn, poll its result to completed, submit the second turn with the same explicit `conversationId`/`sessionId`/`threadId`, then capture `trace`/`inspect` evidence. Passing evidence must show the second turn used prior-turn context, and should include context attachment or run reuse labels such as `conversation-context:attached`, `agentrun:run:reused`, `agentrun:runner-job:reused`, plus the relevant run/command ids. Long verification evidence belongs in a separate `gh issue comment create --body-file` comment; lifecycle close comments stay short, as defined in `docs/reference/cli.md`.
`/health/live` revision is owned by `hwlab-cloud-api`; it can legitimately differ from the source commit for a Cloud Web-only change. Do not call that difference a failed Cloud Web rollout when `webAssets.checks.htmlOk`, `webAssets.checks.appJsOk`, CSS probes, Argo health, and `hwlab-cloud-web` Deployment readiness have passed. For Cloud Web behavior changes, the public JS asset probe or a bounded browser/DOM check is stronger evidence than cloud-api `apiRevision`.
Do not turn `v0.2` expansion governance into a stack of broad compatibility gates. The stable control points are branch, dedicated CI/CD source repo, git mirror/relay refs, GitOps branch, namespace, runtime path, Argo Application, FRP ports and generated-output ownership. Legacy DEV/D601/main preflights that block the `v0.2` lane should be removed from that lane, not patched with fallback or legacy modes. Naming, RBAC scope, cleanup policy, resource quota and rollback order are design decisions or runbook entries unless they protect a concrete high-value risk that cannot be enforced by the fixed boundaries above.
## v0.2 Source Workflow
The generic P2/P3/P4 flow is owned by `$dad-dev`; this section fixes the G14/v0.2 source route, branch and lane. `v0.2` now has two source workflows:
- `direct-lightweight`: CaseRun, case registry aggregation, trace rendering, short-connection CLI/helper, docs/reference, schema-free config reader/writer, and deploy-config cleanup that does not change cloud-api, web, gateway, GitOps, k3s runtime or other long-running services. Use the fixed workspace directly, run the relevant CLI/render/test validation on G14, commit to `v0.2`, and push `origin v0.2`. Do not open a PR, trigger CI/CD, rollout, or reintroduce legacy gates for this class.
- `pr-rollout` / service workflow: cloud-api, Cloud Web UI, gateway, AgentRun dispatch integration, device-pod runtime, GitOps/Tekton/Argo, k3s manifests, Secrets/RBAC, public endpoint behavior, or any change that must reach live runtime. Use a task-scoped worktree on a feature branch, merge through a PR, then use the controlled v0.2 CD/status path.
Direct-lightweight precheck:
```bash
trans G14:/root/hwlab-v02 sh -- 'git fetch origin v0.2 && git pull --ff-only origin v0.2 && git status --short --branch'
```
Service workflow setup:
```bash
trans G14:/root/hwlab-v02 sh -- 'git worktree add .worktree/<task> -b fix/issue<N>-<short-name> origin/v0.2'
```
The fixed repo at `/root/hwlab-v02` is not a scratch area for service/runtime work, but it is the direct-lightweight source workspace. When a direct-lightweight task sees parallel dirty state in the fixed repo, inspect and include or separate it according to the current user instruction and project Git rules; never discard it silently. Worktree branches for service workflow should follow the `fix/issue<N>-<short-name>` naming so PR titles and merge commits stay scannable. GitHub PR writes, merge, rollout trigger and final original-entry validation follow `$dad-dev` plus the UniDesk CLI control rules in `AGENTS.md`.
### Recovery From an Unapproved Direct Commit To v0.2
Direct-lightweight commits are allowed and do not need recovery. A direct commit on `v0.2` only needs recovery when it changed service/runtime/GitOps/CI/CD/public behavior that should have used the PR/rollout workflow. The recovery is bounded and audit-friendly, but it is also a `git push --force-with-lease` against the protected branch, so it is only acceptable when the unapproved direct commit is the only new content on `v0.2` since the last merged PR:
1. Confirm no parallel worktree was in flight and the commit is the only delta. `trans G14:/root/hwlab-v02 sh -- 'git log origin/v0.2..HEAD'` and `git log HEAD..origin/v0.2` must show the direct commit as a single fast-forward candidate.
2. Capture the commit identity and patch for the recovery record:
```bash
trans G14:/root/hwlab-v02 sh -- 'git show <direct-commit-sha> > /tmp/v0.2-recovery.patch'
```
3. Roll the fixed repo back to the previous merged PR head. Use `git reset --hard <previous-pr-sha>`; this preserves any autostash (e.g. from a parallel `git checkout` snapshot in another worktree) on the stash list and does not touch the other worktree's working tree.
4. In the pre-existing worktree (e.g. `.worktree/<task>` on `fix/issue<N>-<short-name>`) bring the branch up to the previous PR head with `trans G14:/root/hwlab-v02/.worktree/<task> sh -- 'git reset --hard <previous-pr-sha>'`, then `git cherry-pick <direct-commit-sha>` to replay the direct commit on the feature branch. If the worktree branch was already a clean clone of `origin/v0.2` at the previous PR head, the reset is a no-op.
5. Push the feature branch and force-push `v0.2` back to the rolled-back head with `--force-with-lease` (refuses to clobber a concurrent push):
```bash
trans G14:/root/hwlab-v02/.worktree/<task> sh -- 'git push -u origin fix/issue<N>-<short-name>'
trans G14:/root/hwlab-v02 sh -- 'git push --force-with-lease origin v0.2'
```
6. Open the PR through UniDesk CLI, squash-merge, then `git pull --ff-only origin v0.2` to bring the fixed repo back in sync. The previous PR's merge commit will not be in the new PR's history; the new PR's diff equals the original direct commit's diff, so the PR trail still contains the exact same bytes.
7. `bun scripts/cli.ts hwlab g14 control-plane status --lane v02` will read the new merge commit; the previously-staged PipelineRun for the direct commit was created on the v0.2 head and `trigger-current` will delete + recreate it for the post-merge head, so no manual PipelineRun cleanup is required.
The recovery is auditable: the original `git show` patch and the `cherry-pick` SHA both land in the PR diff, so the issue/PR trail still contains the exact same bytes that were first committed directly. Recurring unapproved service/runtime direct commits on `v0.2` are a workflow regression and must be called out in the relevant issue or PR; direct-lightweight commits are not a regression.
### v0.2 Cloud Web Runtime Layout Validation
Cloud Web layout, status-panel, collapsed-control, modal, Markdown renderer, scroll-owner and Workbench DOM issues on `v0.2` need deployed browser evidence after rollout; source checks and control-plane status are supporting evidence. Use `$unidesk-webdev` for the actual Playwright/web-probe shape, bounded screenshot artifact rules, layout metrics and no-skip dependency policy. G14/v0.2 closeout still records the lane endpoint, rollout provenance, `/health/live`, `/v1/live-builds` service row and the specific user-visible DOM assertion.
### v0.2 Cloud Web Button/JS Sync Rule (HWLAB #748)
When a `v0.2` Cloud Web fix removes a button from `index.html` or a field from the `el` literal in `web/hwlab-cloud-web/app.ts`, every `el.<removed-field>.addEventListener(...)` (or `.requestSubmit()` / `.showModal()` / etc.) binding must be removed from the matching `init*` function in the same commit. The static `web:check` does not catch this orphan listener class because the TypeScript build is `Bun.build` transpile-only (no `tsc --noEmit`), and the runtime crash only surfaces as `Cannot read properties of undefined (reading 'addEventListener')` on first init. The minimal closeout checks for the v0.2 lane are:
```bash
# 1. Web assets rebuild and the orphan is gone from the dist
trans G14:/root/hwlab-v02/.worktree/<task> sh -- 'cd web/hwlab-cloud-web && bun run build'
trans G14:/root/hwlab-v02/.worktree/<task> sh -- "grep -c '<removed-field>' web/hwlab-cloud-web/dist/app.js" # must be 0
trans G14:/root/hwlab-v02/.worktree/<task> sh -- "grep -c 'id=\"<removed-id>\"' web/hwlab-cloud-web/index.html" # must be 0
# 2. Live 19666/19667 confirms the deployed bundle is the new build
curl -fsS http://74.48.78.17:19666/ | grep -c '<removed-id>' # must be 0
curl -fsS http://74.48.78.17:19666/app.js | grep -c '<removed-field>' # must be 0
bun scripts/cli.ts hwlab g14 control-plane status --lane v02 # webAssets.checks.appJsOk = true, sourceCommit = merge commit
```
While the PR is open, the author can also run a one-liner to surface any orphan `el.<field>.addEventListener` whose field is not declared in the `el` literal of `app.ts`:
```bash
trans G14:/root/hwlab-v02/.worktree/<task> sh -- 'awk "/^const el = /,/^};/" web/hwlab-cloud-web/app.ts | tr -d "," | awk "{print \$1}" | grep -E "^[a-zA-Z]" | sort -u > /tmp/el-fields.txt; grep -nEo "el\\.([A-Za-z_$][A-Za-z0-9_$]*)\\.addEventListener" web/hwlab-cloud-web/*.ts | while read m; do field=$(echo "$m" | sed -E "s/.*el\\.([A-Za-z_$][A-Za-z0-9_$]*)\\.addEventListener.*/\\1/"); if ! grep -q "^$field$" /tmp/el-fields.txt; then echo "ORPHAN: el.$field.addEventListener"; fi; done'
```
Document the explicit `grep` / curl evidence in the issue closeout comment. Tightening the `el` literal with proper TypeScript types is tracked separately and must not be done as part of a runtime fix PR.
## Node-Local VPN Proxy
G14 has a node-local VPN/proxy stack for infrastructure bootstrap and recovery downloads:
- Primary mixed HTTP/SOCKS proxy: `127.0.0.1:10808`.
- Backup Hysteria2 HTTP proxy: `127.0.0.1:11809`.
- Backup Hysteria2 SOCKS5 proxy: `127.0.0.1:11808`.
- Operator-only local details remain on G14 under `/root/docs/vpn-proxy-ops.md`; subscription URLs, node credentials and GUI database contents must not be copied into the UniDesk repository.
The G14 host persists this proxy configuration in these local files:
- `/etc/profile.d/unidesk-g14-proxy.sh` exports `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, lowercase aliases and `NO_PROXY` for new login shells. Set `UNIDESK_G14_DISABLE_PROXY=1` before shell startup to opt out.
- `/root/.npmrc` pins npm `proxy`, `https-proxy`, `noproxy` and retry settings for root-side bootstrap commands.
- `/root/.gitconfig` pins root Git HTTP/HTTPS proxy settings.
- `/root/.docker/config.json` pins Docker client proxy settings for commands and build contexts that honor Docker client proxy configuration.
- `/etc/systemd/system/docker.service.d/proxy.conf` pins Docker daemon pull proxy settings. Updating this drop-in requires `systemctl daemon-reload` and a Docker restart before the active daemon sees the new `NO_PROXY`; do not restart Docker while G14 provider-gateway, k3s bootstrap or image builds are in flight unless that interruption is intentional.
- `/etc/systemd/system/k3s.service.env` pins k3s/containerd image-pull proxy settings. If Pod events show image pulls failing through a stale loopback proxy such as `127.0.0.1:10808` while Hysteria is the active listener, update this file through `trans G14:/etc/systemd/system apply-patch`, run `systemctl daemon-reload`, restart k3s, then verify node readiness and the target image pull. Do not hand-import images as the long-term fix for a stale k3s proxy env.
The `NO_PROXY` list must include localhost, the main server, private LAN ranges, k3s pod/service CIDRs, Kubernetes service domains and the loopback registry so that k3s, `127.0.0.1:5000`, Kubernetes API access and UniDesk control paths do not route through the VPN proxy.
The primary proxy can be used for G14 target-side image bootstrap when Docker Hub, npm, GitHub or Playwright downloads are unreliable through direct network or provider-gateway WS egress. For Docker build steps that use `127.0.0.1`, build with host networking so the build container reaches the host proxy:
```bash
docker build --network host \
--build-arg HTTP_PROXY=http://127.0.0.1:10808 \
--build-arg HTTPS_PROXY=http://127.0.0.1:10808 \
--build-arg ALL_PROXY=socks5h://127.0.0.1:10808 \
--build-arg http_proxy=http://127.0.0.1:10808 \
--build-arg https_proxy=http://127.0.0.1:10808 \
--build-arg all_proxy=socks5h://127.0.0.1:10808 \
...
```
`127.0.0.1:10808` is a G14 host loopback endpoint. Inside an ordinary k3s Pod, `127.0.0.1` is the Pod network namespace, not the node proxy. Do not set long-lived workload proxy env to `http://127.0.0.1:10808` unless that workload is intentionally `hostNetwork` and the port conflict/DEV-PROD blast radius has been reviewed. Temporary hostNetwork debug Pods may use the node-local proxy only for bounded bootstrap proof or cache prewarm; they must not become GitOps desired state just to make external downloads work.
The backup proxy uses `HTTP_PROXY=http://127.0.0.1:11809`, `HTTPS_PROXY=http://127.0.0.1:11809` and `ALL_PROXY=socks5h://127.0.0.1:11808`.
This proxy is not a replacement for UniDesk runtime egress. k3s workloads such as Code Queue must still use the cataloged `g14-provider-egress-proxy` Kubernetes Service and `g14-tcp-egress-gateway` for normal runtime access to PostgreSQL, OA Event Flow and external APIs. The node-local VPN proxy is allowed only for G14 host-side bootstrap, k3s image pulls, image build, cache prewarm or recovery steps, and those steps should record the proxy choice in issue or deployment evidence.
Runtime egress readiness is not proven by Service DNS alone. Before closing HWLAB, AgentRun, Code Queue, CI/CD or user-service issues that require GitHub/codeload/npm/pip/API access through `g14-provider-egress-proxy`, validate that the Deployment is Ready and the Service has at least one ready endpoint. If the Service resolves but `curl` through `http://g14-provider-egress-proxy.unidesk.svc.cluster.local:18789` fails immediately with connection refused, inspect `unidesk/g14-provider-egress-proxy` and `unidesk/g14-tcp-egress-gateway` pods for `ImagePullBackOff`, `ContainerStatusUnknown` or notReady endpoints. A pod trying to pull `unidesk-code-queue:g14` from Docker Hub as `docker.io/library/unidesk-code-queue:g14` is an invalid runtime egress deployment state; restore the controlled image source/import path instead of redirecting long-lived workload proxy env to the node-local bootstrap proxy.
## v0.2 device-pod cloud-api architecture
`v0.2` device-pod integration is the cloud-api → executor → D601 Windows `device-host-cli.mjs` chain under `internal/cloud/access-control.ts`, `cmd/hwlab-device-pod/main.ts` and the host-side `F:\Work\ConStart\tools\device-host-cli.mjs`. PR #765 (selector cheat sheet + fail-fast) and PR #778 (output.text propagation + evidence selector + read-only sub-action `--reason` exemption) are the two anchor PRs; PR #779 tracks the still-open host-side ops work. Earlier work used raw `MUTATING_INTENTS.has(intent) && !reason` and a single-pass `textOr(output.text, …)` extractor; both are obsolete and must not be re-introduced.
### Intent / sub-action / reason matrix
`DEVICE_JOB_INTENTS` (cloud-api) enumerates the full supported surface; `MUTATING_INTENTS` is the strict subset whose default sub-action is mutating. Only `workspace.build` and `debug.download` carry a structured sub-action (`start` / `status` / `output` / `wait` / `cancel` / `evidence`) and are listed in `DEVICE_JOB_ACTIONABLE_INTENTS`; for those two, `_deviceJobRequiresReason(intent, args, reason)` returns `false` when `reason` is provided OR when `args.action` is in `DEVICE_JOB_READ_ONLY_SUB_ACTIONS`. Any other mutating intent (`workspace.apply-patch`, `workspace.put`, `debug.reset`, `io.uart.write`, `io.uart.jsonrpc`, `io.uart.read-after-launch-flash`, etc.) still always requires a non-empty `reason`. Adding a new actionable mutating intent requires extending both `MUTATING_INTENTS` and `DEVICE_JOB_ACTIONABLE_INTENTS` together; adding a new read-only sub-action requires only the `DEVICE_JOB_READ_ONLY_SUB_ACTIONS` set.
The `evidence` sub-action on `workspace.evidence` / `debug.evidence` is a first-class intent, not a `workspace.build` sub-action. Code Agent sees `<pod>:workspace:/ build evidence [jobId]` and `<pod>:debug-probe download evidence [jobId]`; cloud-api maps to a new device-pod executor job, the executor maps to `deviceHostArgs = ["workspace", "evidence", kind, ...]`, and the host-side `device-host-cli.mjs` dispatches via `if (command === "evidence")` at the top level of `main()` (not nested under `if (command === "build")`). `workspace.evidence kind=build` → `keil-build` job; `debug.evidence kind=download` → `keil-download` job; the kind sub-arg must be `build` / `download` and the optional jobId selects a specific past job.
### Output text propagation chain
`body.output.text` flows through three layers in order; each layer tries more fields and only falls back when earlier sources are empty:
1. **host `device-host-cli.mjs`** returns a JSON envelope that already contains `stdout` / `stderr` / `summary` / `logTail` / `buildSummary` for build/download ops; `workspace.ls` / `workspace.cat` / `workspace.rg` are inline and include a JSON body.
2. **executor `cmd/hwlab-device-pod/main.ts` `gatewayDispatchText(result, dispatch)`** walks `result.stdout` → `result.stderr` → `dispatch.stdout` → `dispatch.stderr` → `result.evidence.{text,logTail,summary}` → `dispatch.message` (only when `dispatchStatus === "completed"`) → `dispatch.summary` → `result.summary` → `dispatch.buildSummary` → `result.text` → `JSON.stringify(result)`. The executor stores this as `job.output` and exposes it via `boundedOutput()` which clips at `DEVICE_JOB_OUTPUT_MAX_BYTES` (12000) and drops `executor` / nested `output` when truncated.
3. **cloud-api `executorOutputPayload(body, httpStatus)`** wraps what the executor sent and exposes `body.text` / `body.output` / `body.bytes` / `body.truncation` to the `/v1/device-pods/{id}/jobs/{jobId}/output` endpoint. `text` is `firstString(body?.text, output.text, nestedOutput.text, output.summary, nestedOutput.summary, evidence.text, evidence.logTail, evidence.summary)`; the matched key is recorded by caller convention. `executor` payload stays on the response so callers can read `dispatch.exitCode` / `dispatch.message` / `dispatch.stdout` even when text is empty.
The `evidence.*` and `*summary` lookups exist so a dispatcher that already includes host `logTail` / `buildSummary` becomes visible without a separate `bootsharp` re-run on the Code Agent side. The `summary` lookups also keep error messages (`dispatch.message`) in the response even when `dispatchStatus` is not `completed`; this is the reason `body.error.message` always has something to show for failed host dispatches.
### Cloud-api vs host-side boundary
`/root/hwlab-v02/skills/device-pod-cli/assets/device-host-cli.mjs` is the v0.2-shipped copy of the host-side CLI. The actual hardware host runs a separate `F:\Work\ConStart\tools\device-host-cli.mjs` that is **not** a deployment of the v0.2 repo; it is a D601 ops-side copy that must be synced manually when the v0.2 repo changes host-side behavior. The two-step contract is:
- v0.2 cloud-api / executor changes are valid once `PipelineRun Succeeded` + `git mirror flush` complete; runtime revision is `commit.id` from `/health/live` and source commit can be forced to match via the next `trigger-current`.
- v0.2 host-side `device-host-cli.mjs` changes are NOT visible until someone replaces `F:\Work\ConStart\tools\device-host-cli.mjs` on the D601 Windows host; cloud-api `body.text` will faithfully surface the "unsupported command" JSON error from the stale host binary, which proves the cloud-api propagation chain works but the host side is stale.
A live `workspace.evidence` / `debug.evidence` / `download evidence` selector that returns the host `logTail` end-to-end therefore requires both (a) the v0.2 PR merged and rolled, and (b) the D601 host binary replaced; missing either half is a known gap tracked in #779.
### v0.2 device-pod closeout checks
Device-pod fixes still follow `$dad-dev` and the service/runtime side of the `## v0.2 Source Workflow` route above. The device-pod-specific closeout is the three-layer runtime matrix below; keep these checks because they prove the cloud-api -> executor -> D601 host chain, while generic PR/CI/CD and worktree mechanics stay in `$dad-dev`.
```bash
trans G14:/root/hwlab-v02/.worktree/<task> sh -- 'cd tools && bun test device-pod-cli.test.ts'
trans G14:/root/hwlab-v02/.worktree/<task> sh -- 'cd cmd/hwlab-device-pod && bun test main.test.ts'
trans G14:/root/hwlab-v02/.worktree/<task> sh -- 'cd internal/cloud && bun test access-control.test.ts'
trans G14:/root/hwlab-v02/.worktree/<task> sh -- 'node --check skills/device-pod-cli/assets/device-host-cli.mjs'
```
Treat `access-control.test.ts` workbench failures as pre-existing on the v0.2 base unless the new test list explicitly covers them. After PR merge and `trigger-current --lane v02 --confirm`, the live `http://74.48.78.17:19667/` CLI 验收 must hit all three layers:
1. `body.output.text` non-empty for at least one happy-path intent (`workspace.ls` / `workspace.cat` are the cheapest ones to verify propagation without needing a real D601 build).
2. `workspace.evidence kind=build` / `kind=download` accepted by cloud-api, dispatched to executor, executor `blocker === null` and `job.reason === ""`.
3. `<mutating intent> action=status` accepted without `--reason` while the same intent with `action=start` is still rejected with `device_job_reason_required`.
There is no separate `device-pod` doc; this section is the single authoritative reference for the architecture, and the AGENTS.md index points to it.
旧命令只有在 owning YAML 精确声明 `legacy-manual` 时才可能存在,并且只能从 `$unidesk-cicd` 定义的 `legacy-cicd` / `legacy-ops` scoped help 发现。未知、混合或 PaC authority 必须 fail-closed;本退役文件不能作为启用 legacy 写入口的依据。
+4 -4
View File
@@ -7,11 +7,11 @@
- UniDesk 指挥侧 workspace`/root/unidesk`,固定使用 `master`,开始前执行 `git status``git pull --ff-only origin master`
- HWLAB 开发、部署和验证固定只使用 NC01。`config/hwlab-node-lanes.yaml` 是 node、lane、workspace、CI/CD repo、namespace、GitOps path、公网入口和 Secret sourceRef 的配置真相,当前默认目标必须是 `NC01/v03`
- HWLAB 固定主 workspace 是 `NC01:/root/hwlab-v03`,固定跟踪 `v0.3`。源码修改、PR 准备、repo 内验证、GitOps render 和 rollout 修复都必须在该固定 workspace 下创建任务级 `.worktree/<task>`master server 本地 `/root/HWLAB`、一次性 clone、runner clone、pod 内副本或非 NC01 workspace 只能做只读对照,不能承担开发 source truth。
- HWLAB v0.3 CI/CD source authority 由 `config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01.sourceAuthority` 决定。`trigger-current` / `status` / build 只消费受控 source snapshot,不得把 host worktree、本地 fetch/pull、可变 branch ref 或 Pipeline 直连 GitHub 当 authoritative source。固定 workspace 只服务源码修改、PR 准备和 repo 内验证;rollout closeout 以 source snapshot、PipelineRun、GitOps/Argo revision 和 runtime revision 收口。完整操作口径见 `$unidesk-cicd`
- HWLAB v0.3 delivery authority 由 `config/hwlab-node-lanes.yaml``config/platform-infra/gitea.yaml``config/platform-infra/pipelines-as-code.yaml` 组合确认。NC01/v03 精确解析为 PaC consumer 后,目标 branch 的 GitHub PR merge 是唯一交付触发;完整自动链为 GitHub webhook -> Gitea controlled mirror -> immutable snapshot -> Gitea webhook -> PaC -> Tekton -> GitOps/Argo -> runtime。默认 help、status 与 Next 只给只读 status/history;自动链故障必须修 owning YAML、controller 或源码,不得用 trigger、refresh、mirror sync/flush、人工 PipelineRun 或直接 Gitea push 补齐
- 进入任何 HWLAB 工作前,按 `NC01/v03` 解析 route/workspace/sourceBranch/kubeRoute/runtime namespace 做预检、快进和验证:工作面是 `NC01:/root/hwlab-v03`、source branch 是 `v0.3`、k3s route 是 `NC01:k3s`、runtime namespace 是 `hwlab-v03`、公网入口由 YAML 的 NC01 target 声明。
- NC01 的 node-local registry 是 k3s workload/PVC,不是 host Docker registry。`hwlab nodes control-plane infra status --node NC01 --lane v03` 应显示 registry workload ready、PVC bound 和 endpoint ready;必须通过受控 `runtime-image preload``infra tools-image build/status` 补齐 BuildKit、runtime/base 和 tools image,再把 HWLAB/AgentRun status 与 web-probe smoke 作为可用性证据。
- HWLAB 项目内长期规则入口仍以目标 repo 的 `AGENTS.md` 为准。进入已解析的目标 workspace 后,必须重新读取该 workspace 的规则文件;不能只凭主 server 的压缩上下文继续操作。
- 每次开始源码修改、PR 准备或 repo 内验证前必须通过 YAML-first 受控入口检查并同步 NC01 workspace`bun scripts/cli.ts hwlab nodes control-plane source-workspace sync --node NC01 --lane v03 --confirm`,再用 `source-workspace status` 读取 clean、branch、remote、HEAD 和依赖状态。`source-workspace status` 必须显示声明 remote/base 已对齐;如果返回 `source-workspace-not-ready``remoteUpToDate=false`、ahead/behind/diverged 或 HEAD 与声明 remote/base 不一致,先修复固定 workspace,再创建 `.worktree/<task>`、提交测试 PR 或给验收写结论。运行面 `status` healthy、PaC snapshot 对齐和 GitOps/Argo healthy 只能证明 runtime source authority,不代表 host 固定 workspace 已可用于源码工作。
- 每次开始源码修改、PR 准备或 repo 内验证前,先用 YAML-first 只读入口 `bun scripts/cli.ts hwlab nodes control-plane source-workspace status --node NC01 --lane v03` 读取 clean、branch、remote、HEAD 和依赖状态。NC01/v03 已解析为 PaC consumerconfirmed `source-workspace sync` 不得成为默认准备步骤或 source delivery recovery;它只允许在 owning YAML 精确解析为 `legacy-manual` 后从 `legacy-cicd` scoped help 使用。若 PaC workspace 返回 `source-workspace-not-ready``remoteUpToDate=false`、ahead/behind/diverged 或 HEAD 与声明 remote/base 不一致,先修 workspace 自动管理机制、owning YAML 或受控 worktree 基线,再从目标 branch 的 remote base 创建任务 `.worktree/<task>`,不得人工推进 source branch。运行面 `status` healthy、PaC snapshot 对齐和 GitOps/Argo healthy 只能证明 runtime source authority,不代表 host 固定 workspace 已可用于源码工作。
- k3s 操作必须使用 YAML 解析出的 route 语法,例如 `trans NC01:k3s ...`。第一个 route token 必须定位分布式目标,后续 token 才是 operation。
- 非 NC01 workspace、`/root/HWLAB``/workspace/hwlab``/tmp/hwlab-*`、无关 runner clone、master-server checkout 或未由 YAML 选中的 workspace 都不能作为当前 HWLAB 开发 source truthCI/CD source authority 只看 NC01 YAML `sourceAuthority` 的受控 source snapshot。
@@ -202,7 +202,7 @@ HWLAB 当前真实 runtime 固定为 NC01 `v0.3`。只读观察使用 YAML 解
trans NC01:k3s kubectl -n hwlab-v03 get deploy,svc,pod -o wide
```
HWLAB node/lane control-plane 的 PipelineRun 失败定位优先使用 UniDesk 受控状态入口,而不是先手工拼 TaskRun/Pod 查询:`bun scripts/cli.ts hwlab nodes control-plane status --node <node> --lane <lane> --pipeline-run <name>``trigger-current --wait` 返回对象,以及异步 `bun scripts/cli.ts job status <jobId> --tail-bytes 12000` 都应直接暴露失败 TaskRunPod、container、失败 step、termination reason 和 bounded `trans <node>:k3s logs --namespace hwlab-ci --pod <pod> --container <container> --tail 200` 查看命令。默认状态输出只给诊断命令和受控摘要,不打印完整 pod 日志、Secret、完整 DSN、API key 或其他可复制凭据。遇到 service build 的 `step-publish` 失败时,下一步先跑 `bun scripts/cli.ts platform-infra sub2api status --target <node>``bun scripts/cli.ts platform-infra sub2api validate --target <node>`,区分 Sub2API/proxy 整体故障和单上游 transient,再选择受控 rerun 或修复 artifact-publish/envRecipe 的有限 retry。
HWLAB node/lane 的 PipelineRun 失败定位优先使用 `bun scripts/cli.ts hwlab nodes control-plane status --node <node> --lane <lane> --pipeline-run <name>`PaC `status|history` 返回的 bounded TaskRun/Pod logs 下钻,而不是手工拼查询。PaC 或 `unknown` authority 的失败态不得给 trigger、refresh、mirror sync/flush 或 rerun;应由只读证据定位 owning YAML、controller 或源码缺陷,再用修复 PR 合并产生的新自动事件验收。默认状态不得打印完整 pod 日志、Secret、DSN、API key 或其他可复制凭据。
`hwlab-cloud-api``hwlab-edge-proxy` 在集群内可使用 `6667` Service 端口;对外入口以 NC01 target 的 publicExposure/public URL 为准。Node/Bun/undici 的 Fetch 实现会按 Fetch bad-port 规则拒绝 `6667`,因此任何需要代理、转发、探测或服务间访问该端口的 Node 实现都必须使用 `node:http`/`node:https`、已有 repo-owned HTTP helper,或改用不触发 bad-port 的受控代理端口;不要把 Fetch bad-port 造成的 `fetch failed` / 502 误判为 Service DNS、PVC、数据库或 trace 数据缺失。NC01 target 以 `config/hwlab-node-lanes.yaml``nodes.NC01``lanes.v03.targets.NC01` 为准。
@@ -236,7 +236,7 @@ HWLAB v0.3 订阅 AgentRun Kafka event 的权威输入是 `agentrun.event.v1`
Provider profile 有两条不同职责的正式路径,不能混用。HWLAB 产品侧动态 profile 通过已部署的 NC01 HWLAB Cloud API/CLI 管理,入口是 `hwlab-cli client provider-profiles`。AgentRun runtime lane 自身的 provider credential、`config.toml``auth.json`、runner egress/NO_PROXY 和 GitOps 物化由 UniDesk `config/agentrun.yaml` 拥有,入口是 `bun scripts/cli.ts agentrun provider-profile plan|status|apply|validate --node NC01 --lane nc01-v02 --profile <profile>`;该路径支持在线配置 profile,不要求 image rebuild 或 PipelineRun。不要把手工 patch AgentRun Secret、直接调用 AgentRun manager、临时 runner job、修改 `~/.codex/config.toml` 或修改 `/root/.codex/config.toml` 当作正式配置路径或关闭证据。
HWLAB Provider Profiles 的真实测试按钮只证明 Cloud Web -> Cloud API -> AgentRun validation 这条链路可用;它不是 AgentRun 发布状态的替代入口。若真实测试返回成功文本 `requestedModel``responseModel`、validation id、run id 或 command id 等诊断字段缺失,先用 `bun scripts/cli.ts agentrun control-plane status --node NC01 --lane nc01-v02``bun scripts/cli.ts cicd status --node NC01` 核对 AgentRun manager source/runtime 是否包含对应修复,再通过受控 `trigger-current``refresh`PaC closeout 对齐运行面。不要通过重建 HWLAB 容器、修改 operator 本机 `config.toml` 或重复下发 Secret 掩盖 AgentRun manager 版本漂移。
HWLAB Provider Profiles 的真实测试按钮只证明 Cloud Web -> Cloud API -> AgentRun validation 链路可用,不替代 AgentRun 发布状态。若成功文本缺少 `requestedModel``responseModel`、validation id、run id 或 command id,先用 AgentRun control-plane status`cicd status --node NC01` 和 PaC history 核对 source/runtime;若自动链未对齐,修 owning YAML、controller 或源码并等待新 PR merge 自动推进。不得用 trigger、refreshPaC closeout重建 HWLAB 容器、修改 operator `config.toml` 或重复下发 Secret 掩盖 manager 版本漂移。
当明确需要把当前 operator 的 Codex 凭据作为 AgentRun 动态 profile 提供给 HWLAB Code Agent 或 CaseRun 使用时,固定操作手册在 `$hwlab-code-agent``AgentRun 动态 sub2api profile` 小节。该场景是 AgentRun runtime profile 投影,不替代上面的 HWLAB provider-profiles 正式配置路径;默认 profile 名为 `sub2api`、模型为 `gpt-5.5`,优先使用 NC01 k3s 内受控 Sub2API ClusterIP 作为 `base_url`,并只记录 SecretRef、keyPresence、hash suffix 和原入口验收结果。
+1 -1
View File
@@ -89,7 +89,7 @@ OA Event Flow 的高频 trace 统计不得把每个 `trace-stats-updated` 投影
branch-follower 的 gate 与 TaskRun drill-down 默认输出必须能直接作为 target-side 性能证据使用,不能稳定触发 stdout dump。CI gate 应返回有界的 PipelineRun 总耗时、慢 TaskRun 对象名、阶段 timeline 和性能摘要;成功 TaskRun drill-down 默认只返回容器状态、日志字节数和 `node-cicd-timing` 摘要,不把成功日志正文塞进 JSON。失败、等待或显式 full/raw drill-down 才展开日志 tail。
`web-probe sentinel control-plane trigger-current --confirm --wait` 的成功路径必须默认输出 compact closeout summary至少包含 node/lane、source commit、PipelineRun 名称与状态、GitOps revision、Argo sync/health、runtime ready、public route readiness 和 PaC/status/history drill-down;成功时不得稳定触发全局 stdout guard。阻塞或失败路径仍应展开 status diagnosis、recovery next 和必要 drill-down,不得为了压缩输出隐藏失败原因
Web sentinel 的 PaC migrated lane 只能通过只读 status/history 展示自动交付证据。默认 compact 摘要至少包含 node/lane、source commit、外层 PaC PipelineRun 名称与状态、GitOps revision、Argo sync/health、runtime ready、public route readiness,以及 Gitea delivery 与 PaC history drill-down。内层 deterministic publish 只能标记为执行观察,不能代替外层 PaC event 驱动 latest、status 或 closeout。阻塞或失败时必须展示阶段、原因和只读 drill-down,并指向 owning YAML/controller/源码修复;禁止输出 `trigger-current`、sync、flush、人工 PipelineRun 或 recovery Next 来推进当前交付
全局 stdout guard 不能只返回 dump 元数据。对已知高频长输出命令,bounded wrapper 的 `summary` 必须保留可直接 closeout 的命令特定字段,同时把完整 payload 留在 dump/raw drill-down 中。例如 `debug dispatch ... provider.upgrade` 超阈值时应保留 dispatch task、wait task、plan host root、当前/目标 gateway 版本、scheduler 结果和最终 promoted container 的 `version``restartPolicy``pidMode``heartbeatTimestamp``provider triage --full` 超阈值时应保留 `decision``scope``retryable`、failed/degraded/healthy scopes、signal counts、recommended cross-checks 和问题信号预览。新增会稳定超阈值的诊断命令时,优先补命令特定 compact summary,而不是扩大全局 stdout 阈值。
+31 -27
View File
@@ -1,35 +1,39 @@
# Platform Infra
# Platform Infra 平台基础设施
`platform-infra` is the shared UniDesk-operated platform service area. Runtime placement is service-specific and YAML-selected: some services run in a k3s `platform-infra` namespace, while host-Docker services such as the current PK01 Sub2API target still belong to the same platform-infra control surface. For Sub2API, the active target is selected by `config/platform-infra/sub2api.yaml` and may be a host-Docker or k3s target; G14 remains a predeployed standby target scaled to zero. Other platform services may still declare G14 or another node as their active runtime in their own YAML. It is separate from HWLAB runtime lanes, AgentRun lanes, D601 user services, and legacy `devops-infra` control-plane helpers. New shared infra should land here first; old `devops-infra` resources migrate gradually only when a concrete owner and validation path exists.
`platform-infra` 是 UniDesk 统一运维的平台服务域。具体运行位置由各服务 owning YAML 选择,可以是 k3s `platform-infra` namespace,也可以是 YAML 明确声明的 host-Docker target。它与 HWLAB laneAgentRun laneD601 用户服务和旧 `devops-infra` 控制面 helper 分离。新增共享基础设施优先进入本域;旧资源只有在具备明确 owner 与原入口验收路径时才迁移。
## Source Of Truth
## 配置真相
- UniDesk-owned platform configuration must be YAML-first. `config/platform-infra/*.yaml` is the durable source for images, versions, endpoints, FRP exposure, account profile selection, and local consumer configuration.
- Runtime Secrets and local `~/.codex/config.toml*` / `auth.json*` files are inputs or generated local state, not committed truth. CLI output may show Secret object/key names, source paths, byte counts, presence and fingerprints. Short previews are allowed only for non-secret configuration; Secret and credential values are never previewed or printed.
- Code that reads platform YAML must validate object shape, field types, required fields, Kubernetes names, image strings, and ports before mutating G14 k3s or local consumer files.
- Do not hide image versions, namespace names, endpoint URLs, FRP ports, or profile lists in Python/TOML/JSON helper constants when they are UniDesk-owned choices. External tools may still require their own TOML/JSON/env file formats at the edge.
- Fresh node outbound bootstrap uses the zero-dependency host proxy boundary defined in `docs/reference/yaml-first-ops.md`. Platform-infra egress proxy settings may provide the benchmark-validated proxy source and workload consumer settings, but a new node must not depend on Docker, k3s, containerd or package-manager network access to install the initial host proxy client.
- UniDesk 平台配置必须 YAML-first`config/platform-infra/*.yaml` 持久拥有 imageversionendpointFRP 暴露、账号 profile 与 consumer 配置。
- Runtime Secret`~/.codex/config.toml*` `auth.json*` 只是输入或生成态,不是提交真相。CLI 只可显示对象/key、sourceRef、presence、长度和 fingerprint,不得预览或打印凭据值。
- 读取平台 YAML 的代码必须在写运行面或本地 consumer 文件前校验对象形状、字段类型、必填字段、Kubernetes 名称、image 和 port。
- UniDesk 拥有的 image versionnamespaceendpointFRP port profile 列表不得隐藏在 helper 常量里;外部工具要求的 TOMLJSONenv 仅是边缘渲染格式。
- 新节点出网 bootstrap 遵循 `docs/reference/yaml-first-ops.md` 的零依赖 host proxy 边界,不得依赖尚未安装的 Dockerk3scontainerd 或包管理器网络能力。
## Gitea And Pipelines-as-Code Boundary
## Gitea Pipelines-as-Code 边界
- Gitea mirror and Pipelines-as-Code are platform-infra CI source/trigger services operated by UniDesk. Their durable configuration lives in `config/platform-infra/gitea.yaml` and `config/platform-infra/pipelines-as-code.yaml`; do not hide repo URLs, mirror repo names, webhook settings, public exposure, FRP/Caddy ports, token sourceRefs or PaC Repository params in helper constants.
- The canonical Gitea entrypoints are `bun scripts/cli.ts platform-infra gitea plan|apply|status|validate|mirror --target <node>`, `bun scripts/cli.ts platform-infra gitea mirror plan|bootstrap|sync|status --target <node>`, and `bun scripts/cli.ts platform-infra gitea mirror webhook apply|status|test --target <node>`. Mirror bootstrap/sync must repair declared repo/org visibility such as `publicRead: true`; create-time defaults alone are not enough for long-lived repos.
- Gitea mirror webhook sync is GitHub -> Gitea only. GitHub remains the upstream write authority; the webhook bridge may update the YAML-declared Gitea branch and immutable snapshot refs, but it must not write Gitea changes back to GitHub or add a second polling path.
- The Gitea public Web UI and GitHub webhook endpoint may share the YAML-declared HTTPS hostname only through explicit Caddy path routing. Each target node that needs GitHub -> Gitea sync must have its own YAML-declared webhook path and FRP proxy/remote port; multiple nodes must not reuse one GitHub hook URL or one bridge. Ordinary Web UI traffic continues to route to the Gitea Web UI proxy. Internal k8s consumers still use the ClusterIP service URL, not the public hostname.
- Applying Gitea public exposure or webhook sync must roll the affected connector workloads after Secret or ConfigMap changes. A successful Secret apply is not sufficient evidence that `frpc` loaded a new proxy; closeout should use `platform-infra gitea apply` output plus `platform-infra gitea mirror webhook status|test` rather than assuming unchanged connector Pods picked up mounted config.
- Target webhook FRP remote ports must be checked against the live PK01 FRP listener set as well as YAML declarations. `frpc` log `port already used` means the GitHub delivery may hit an unrelated service and return 404; a port outside the PK01 published listener set can return 502 from Caddy. Fix the YAML port, re-apply with rollout wait, then use a fresh GitHub PR merge as the trigger evidence.
- `platform-infra gitea mirror webhook status --target <node>` is the default webhook closeout surface: it must show hook readiness, GitHub head, Gitea branch/snapshot, latest delivery status and bridge log event so a node-specific webhook problem can be diagnosed without reading source code or raw logs.
- The canonical PaC entrypoints are `bun scripts/cli.ts platform-infra pipelines-as-code plan|apply|status|history|webhook-test --target <node>`. PaC status is the operator-facing closeout surface for migrated CI lanes and must expose webhook count, latest PipelineRun/TaskRun duration, image status, env identity, digest, GitOps commit, Argo revision and runtime provenance without requiring raw `kubectl`, `tkn` or Gitea UI inspection.
- PaC Repository CR `spec.url` is the URL matcher for incoming Gitea webhook payloads, so `config/platform-infra/pipelines-as-code.yaml#repositories[].url` must use the public Gitea repository URL emitted by Gitea webhooks. Internal ClusterIP/service URLs belong in `cloneUrl` and `params.git_read_url`; putting an internal URL in `spec.url` makes PaC log `cannot find a repository match` and creates no PipelineRun even though the Gitea mirror is current.
- PaC Repository CR params must use the node-specific Gitea snapshot prefix declared for that consumer, such as `unidesk-master-nc01` for NC01 sentinel. A generic or other-node prefix can make the repository look healthy while fresh pushes cannot close out against the target node source.
- PaC history is the trigger/timing audit surface for Gitea/PaC-managed lanes. It must query Gitea Repository CR and Tekton PipelineRun/TaskRun live objects on the target node, aggregate there, return Beijing-time display by YAML timezone, expose a detail id for drill-down, and report read errors explicitly; a large namespace or unreadable target object must never be rendered as a successful empty table.
- PaC 迁移 consumer 的目标 source branch GitHub PR merge 是唯一正式交付触发事件。合并后必须由 GitHub webhook、Gitea controlled mirror 与 immutable snapshot ref、Gitea webhook、PaC、Tekton、GitOps/Argo 自动推进到运行面;主代理、子代理和操作者都不得执行 apply、closeout、trigger、sync、flush、人工 PipelineRun、直接 Gitea push、本地 Git mirror 修改或其他补跑来完成本次交付。`webhook-test` 只是连通性诊断,不得代替真实 PR merge 事件
- 自动链不通时必须修复 owning YAML、controller 或源码中的自动链缺陷,并用修复 PR 合并后产生的新自动事件验证;禁止用人工操作制造对齐状态或掩盖故障。`status``history``events``logs` 与单步 debug 入口只在显式调查时按需读取,不是交付前置;`closeout` 仅保留只读兼容能力,不得成为主/子代理的 PR 合并后步骤。任何诊断入口都不得隐式推进交付
- `config/platform-infra/pipelines-as-code.yaml` may declare multiple repositories and consumers. JD01 and NC01 both declare AgentRun, Web sentinel and HWLAB consumers; use `cicd status --node <NODE>` for node-level status, `history --target <NODE>` for all-consumer audit, and `status --target <NODE> --consumer <id>` for consumer-scoped closeout. Consumer-scoped status must not mix PipelineRuns or env reuse evidence across repositories.
- Shared upstream repositories must isolate target-specific `.tekton` files with Repository CR params, currently `node`, and `pipelinesascode.tekton.dev/on-cel-expression`. A NC01 webhook delivery must not create JD01 PipelineRuns in the NC01 cluster, and history drill-down must not attribute the same PipelineRun to multiple consumers.
- Public Gitea UI may use the YAML-declared HTTPS hostname, but k8s-internal consumers must use the ClusterIP service URL from YAML. Internal CI/Argo/runtime reads must not loop through public DNS/Caddy/FRP, and migrated lanes must not fall back to legacy git-mirror read URLs when the commit exists only in Gitea.
- A PaC-migrated lane must keep a single trigger path: Gitea webhook -> Pipelines-as-Code -> Tekton -> GitOps/Argo -> k8s runtime. Do not add Gitea Actions, `act_runner`, branch-follower or custom script fallback unless a later issue explicitly changes the architecture. `config/cicd-gitea-actions-poc.yaml` is archived POC context only; snapshot refs that still contain `gitea-actions` are historical names retained for compatibility, not evidence that Gitea Actions is active.
- k8s runtime remains Docker-free from the point it pulls already built images. CI build steps may use YAML-declared native build tooling, but Docker socket/daemon access must not become part of the runtime plane.
- Gitea mirror Pipelines-as-Code 是 UniDesk 运维的 CI source/trigger 服务。`config/platform-infra/gitea.yaml` `config/platform-infra/pipelines-as-code.yaml` 分别拥有 mirror/webhook 与 PaC Repository/consumer 配置;repo URL、snapshot、webhook、public exposureFRP/Caddy porttoken sourceRef PaC params 不得隐藏在代码特例中。
- Delivery authority resolver 必须组合上述两份 YAML,并按 consumer id、node、lane、upstream repository、branch 和 Gitea repository 精确关联。不得通过 URL 片段、repo 名称前缀或仓库专属 `if` 推断迁移状态。零匹配、多匹配和配置错误都必须返回 `unknown``mutation=false` 并停止生成或执行写操作。
- PaC consumer 的唯一正式触发是目标 source branch 的 GitHub PR merge。完整自动链固定为:GitHub PR merge -> GitHub webhook -> Gitea controlled mirror -> immutable snapshot -> Gitea webhook -> PaC/Pipelines-as-Code -> Tekton -> GitOps/Argo -> runtime 运行面。
- PR 合并后,主代理、子代理和操作者都不得用 `apply``closeout``trigger-current``refresh``sync``flush``webhook-test`、人工 PipelineRun、直接 Gitea push、本地 mirror 写入或其他补跑完成当前交付。自动链不通时必须修 owning YAML、controller 或源码,并只用修复 PR 合并产生的新正常自动事件验收。
- Migrated consumer 的默认 help、status、`Next``REPAIR` 只能给 `status``history``events``logs`、只读单步下钻以及本节稳定引用。CLI 必须省略 mutation command,而不是只给命令加警告文字。`unknown` authority 同样 fail-closed。
- `trigger-current``refresh` 和 mirror `sync|flush` 只允许在 owning YAML 明确解析为 `legacy-manual` 后执行,并且只在显式 `legacy-cicd``legacy-ops` scoped help 中可发现。平台 bootstrap、Secret 与配置维护属于独立职责,只在 `platform-bootstrap``platform-maintenance` scoped help 中展示,不能作为 source delivery recovery。
- `closeout` 仅保留只读历史/诊断兼容入口,并且只能从 `compatibility-diagnostics` scoped help 发现。会 POST hook test 的 `webhook-test` mutation 入口已经删除;连通性只能通过真正只读的 status、GET 与 readiness 观察,禁止制造伪 push。
- 默认 Gitea webhook 观察入口是 `bun scripts/cli.ts platform-infra gitea mirror webhook status --target <node>`。它应显示 hook readiness、GitHub head、Gitea branch/snapshot、最近 delivery 与 bridge event;状态陈旧时只能给只读下钻和“修复自动链”指引,不得输出人工 `REPAIR`
- GitHub webhook receiver 只有在验签 delivery 通过 fsync 与 atomic rename 写入 YAML 声明的 PVC durable inbox 后才能返回 HTTP `202 Accepted`。状态必须分为 `accepted``processing``committed``failed``202 Accepted` 只证明持久接收,不证明 refs 已提交。只有同一 deliveryId 的 exact-after immutable snapshot 与 authority branch 经 atomic push 后重新读取 refs 证明一致,才能进入 `committed`
- 同 deliveryId 与同 payload 必须幂等,异 payload 返回 `409`。PVC 不可用或容量满返回 `503` 且 readiness=false。inbox worker 按 YAML 有界 backoff 自动重试并在重启后恢复 `accepted` / `processing`;Caddy 只对尚未持久化的请求做小于 10 秒的 body-safe 有界重试。
- Durable inbox 只是 webhook delivery 的持久接收与重试 journal,不是业务 source/ref 的第二份真相,也不得被 PaC、Tekton、GitOps、Argo 或 runtime 当作源码/read model。最终 source authority 仍只有 Gitea authority branch 与 immutable snapshot;禁止新增 polling/read-model fallback 或第二 source authority。
- 默认 PaC 入口是 `bun scripts/cli.ts platform-infra pipelines-as-code status|history --target <node>``status` 应显示 webhook、PipelineRun/TaskRun duration、image status、env identity、digest、GitOps commit、Argo revision 和 runtime provenance`history` 必须读取 target node 上的实时对象、显式报告 read error,并支持 consumer 与 PipelineRun id 下钻。`history --id` 必须通过运行面 provenance 与 PipelineRun prefix 唯一解析实际 consumer;零匹配、多匹配或显式 consumer 不一致都 fail-closed,不能回退到默认 consumer 或默认 node
- Sentinel 内部 publish capability 由 `config/platform-infra/pipelines-as-code.yaml#capabilities.sentinelInternalPublish` 单一拥有,默认 `enabled=false`。Pod env、ownerReferences、ServiceAccount、label 与 annotation 不能证明 creator#1769 的 admission-owned provenance 与最小权限边界落地前不得启用。`.tekton` 继续走既有自动 `publish-current`,不能退化成人工 publish
- PaC 观察必须把 PipelineRun 分类为外层 `outer-pac-event`、内层 `inner-deterministic-publish``unknown`。只有唯一外层 PaC push event 能驱动 latest、status、debug-step 与兼容 closeout;内层只能展示为未绑定执行观察,缺少 admission-owned 父子证据时标记 `unproven`,禁止按名称前缀推断关系。
- `config/platform-infra/pipelines-as-code.yaml` 可以声明多个 repository 和 consumer。`cicd status --node <NODE>` 用于 node 汇总,`history --target <NODE>` 用于全 consumer 审计,`status --target <NODE> --consumer <id>` 用于 consumer 只读状态;不得跨 repository 混合 PipelineRun 或 env reuse 证据。
- PaC Repository CR `spec.url` 必须匹配 Gitea webhook payload 的 public repository URLClusterIP/service URL 只属于 `cloneUrl``params.git_read_url`。Repository params 必须使用该 consumer 的 node-specific snapshot prefix。
- GitHub 始终是 upstream write authority。Bridge 只允许 GitHub -> Gitea 更新 YAML 声明的 branch 与 immutable snapshot,不得反向写 GitHub,也不得增加 polling 或第二 trigger path。
- 每个 target 必须使用 YAML 独立声明的 webhook path、bridge 与 FRP port;多个节点不得复用 hook URL。Public Gitea Web UI 与 webhook 只能通过明确 Caddy path routing 共享 hostnamek8s 内部 consumer 必须使用 ClusterIP URL。
- Secret 或 ConfigMap 改变后,Gitea connector 的显式平台配置维护必须等待 workload rollout。成功写 Secret 不能证明 `frpc` 已加载新配置;端到端交付证据仍必须来自之后的新正常 PR merge。
- Shared upstream repository 必须通过 Repository CR params 与 `pipelinesascode.tekton.dev/on-cel-expression` 隔离 target-specific `.tekton`。NC01 delivery 不得在 JD01 cluster 创建 PipelineRunhistory 也不得把同一 PipelineRun 归属给多个 consumer。退役 branch-follower 的 unknown 或多 node scope 只返回 follower 自身的只读 status/events/logs 和稳定修复引用,禁止猜测 `JD01` 或其他默认 node 的 PaC status/history。
- PaC migrated lane 保持单 trigger path,禁止新增 Gitea Actions、`act_runner`、branch-follower 或脚本 fallback。`config/cicd-gitea-actions-poc.yaml` 只是归档 POC;历史 snapshot 名称不代表该路径仍在运行。
- k8s runtime 从拉取已构建 image 起保持 Docker-free。CI 可以使用 YAML 声明的 build tooling,但 runtime 不得依赖 Docker socket 或 daemon。
## Secret Distribution Boundary
+3 -3
View File
@@ -87,11 +87,11 @@ This split must be implemented explicitly in `deploy.json`, deploy planning, dep
During a v1 stabilization window, the future split-lane implementation is deliberately deferred. The current `environments.dev` lane is reserved for v1 validation until the v1 release criteria are met, then the explicit split-lane work can proceed as a separate infrastructure change.
## CI/CD Infrastructure Repair
## CI/CD 基础设施引导与修复
CI/CD infrastructure bootstrap, repair and upgrade must not depend on a new CI job that tests the CI/CD bootstrap path itself. When the CI/CD infrastructure is the broken component, use manual validation: runtime health, logs, source commit metadata, capability endpoints, bounded smoke commands and operator review.
CI/CD 基础设施的首次引导、修复和升级都必须先修改 Git-backed owning YAML、controller 或源码,并通过正常 PR 合并产生的自动 webhook、PaC、Tekton、GitOps/Argo 事件完成交付与验收。自动链自身故障时,可以用只读 health、status、logssource commit、capability 和有界 GET/smoke 收集证据,但这些观察不能替代正常自动事件,也不能被包装成人工交付。
Manual CI/CD infrastructure fixes may be promoted directly to production when production infrastructure is the intended target. The durable state still has to return to Git-backed desired state: any code, manifest, config or policy change that should survive must be committed and pushed after the repair.
禁止通过运行面 patch、人工 PipelineRun、直接 Gitea push、人工 mirror sync、伪 push、hook test 或隐藏脚本补齐当前 release。紧急情况下只允许经明确授权执行有界、可逆、可验证的临时缓解;缓解不能晋升为 desired state 或交付成功,随后仍必须在 Git-backed 来源修复根因,并用修复 PR 合并产生的新自动事件验收。
## Feature Flags
@@ -25,6 +25,84 @@ function exactSkipMarker(records, event) {
const pacContractTasks = new Set(["plan-artifacts", "collect-artifacts", "gitops-promote"]);
function firstString(...values) {
for (const value of values) {
if (typeof value === "string" && value.length > 0) return value;
}
return null;
}
function pipelineRunMatchesConsumer(consumerInput, itemInput) {
const consumer = record(consumerInput);
const item = record(itemInput);
const metadata = record(item.metadata);
const labels = record(metadata.labels);
const name = stringOrNull(metadata.name) || "";
const prefix = stringOrNull(consumer.pipelineRunPrefix) || "";
const pipeline = stringOrNull(consumer.pipeline) || "";
return (prefix.length > 0 && name.startsWith(prefix))
|| (pipeline.length > 0 && labels["tekton.dev/pipeline"] === pipeline)
|| (pipeline.length > 0 && labels["tekton.dev/pipelineName"] === pipeline);
}
function classifyPacPipelineRun(consumerInput, itemInput) {
const consumer = record(consumerInput);
const item = record(itemInput);
const metadata = record(item.metadata);
const labels = record(metadata.labels);
const annotations = record(metadata.annotations);
const candidate = pipelineRunMatchesConsumer(consumer, item);
const eventType = firstString(
labels["pipelinesascode.tekton.dev/event-type"],
annotations["pipelinesascode.tekton.dev/event-type"],
);
const repository = firstString(
labels["pipelinesascode.tekton.dev/repository"],
annotations["pipelinesascode.tekton.dev/repository"],
);
const managedBy = stringOrNull(labels["app.kubernetes.io/managed-by"]);
const provider = stringOrNull(annotations["pipelinesascode.tekton.dev/git-provider"]);
const expectedRepository = stringOrNull(consumer.repository);
const outer = candidate
&& eventType === "push"
&& expectedRepository !== null
&& repository === expectedRepository
&& managedBy === "pipelinesascode.tekton.dev"
&& provider === "gitea";
const inner = candidate
&& !outer
&& labels["app.kubernetes.io/part-of"] === "hwlab-web-probe-sentinel"
&& labels["unidesk.ai/ci-system"] === "tekton"
&& annotations["unidesk.ai/source-authority"] === "gitea-snapshot"
&& typeof annotations["unidesk.ai/publish-gitops"] === "string";
const deliveryClass = outer
? "outer-pac-event"
: inner
? "inner-deterministic-publish"
: "unknown";
const reason = outer
? "observed-pac-push-metadata-matches-consumer"
: inner
? "deterministic-sentinel-publish-without-proven-parent"
: candidate
? "consumer-candidate-lacks-classification-evidence"
: "not-a-consumer-candidate";
return {
deliveryClass,
reason,
candidate,
deliveryAuthorityEligible: outer,
deliveryOwner: outer ? "pac-controller-observed" : null,
executionOwner: inner ? "tekton-child-unattached" : null,
parentPipelineRun: null,
parentRelation: inner ? "unproven" : null,
metadataObservationOnly: outer,
admissionProvenanceVerified: false,
valuesPrinted: false,
};
}
function taskTerminalRecord(value) {
const taskRun = record(value);
const metadata = record(taskRun.metadata);
@@ -728,10 +806,12 @@ function runPacStatusFixtureChecks() {
}
module.exports = {
classifyPacPipelineRun,
evaluatePacStatus,
extractPacArtifactEvidence,
extractPacSourceObservation,
parsePacLogRecords,
runPacStatusFixtureChecks,
pipelineRunMatchesConsumer,
taskTerminalRecord,
};
+106 -31
View File
@@ -33,6 +33,12 @@ import {
type AgentRunArtifactService,
} from "../agentrun-manifests";
import { sha256Fingerprint } from "../platform-infra-ops-library";
import {
pacAutomaticDeliveryFix,
pacReadOnlyNext,
resolveCicdDeliveryAuthority,
type CicdDeliveryAuthority,
} from "../cicd-delivery-authority";
import type { CleanupReleasedPvOptions, CleanupRunnersOptions, CleanupRunsOptions, CleanupSessionPvcsOptions, ConfirmOptions, GitMirrorOptions, LaneConfirmOptions, RefreshOptions, SecretSyncOptions, StatusOptions } from "./options";
import { agentRunControlPlaneStatusCommand } from "./public-exposure";
@@ -258,11 +264,37 @@ export function positiveIntegerOption(args: string[], name: string, defaultValue
export async function controlPlanePlan(_config: UniDeskConfig, options: StatusOptions): Promise<Record<string, unknown>> {
const target = resolveAgentRunLaneTarget(options);
const spec = target.spec;
const deliveryAuthority = resolveCicdDeliveryAuthority({
node: spec.nodeId,
lane: spec.lane,
sourceRepository: spec.source.repository,
sourceBranch: spec.source.branch,
declaredSourceAuthorityMode: spec.source.sourceAuthority?.mode ?? null,
declaredConfigRef: target.configPath,
});
const statusCommand = `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`;
const next = deliveryAuthority.kind === "pac-pr-merge"
? pacReadOnlyNext(deliveryAuthority.consumer.node, deliveryAuthority.consumer.consumerId)
: deliveryAuthority.kind === "unknown"
? {
status: statusCommand,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${spec.nodeId} --limit 10`,
fixAutomaticDelivery: pacAutomaticDeliveryFix(deliveryAuthority),
valuesPrinted: false,
}
: {
status: statusCommand,
postgresStatus: spec.database.configRef ? `bun scripts/cli.ts platform-db postgres status --config ${spec.database.configRef}` : null,
platformMaintenanceHelp: "bun scripts/cli.ts agentrun control-plane platform-maintenance --help",
legacyCicdHelp: "bun scripts/cli.ts agentrun control-plane legacy-cicd --help",
valuesPrinted: false,
};
return {
ok: true,
command: "agentrun control-plane plan",
mode: "yaml-declared-node-lane",
configPath: target.configPath,
deliveryAuthority,
target: agentRunLaneSummary(spec),
plannedChecks: [
"source-branch-exists",
@@ -278,12 +310,7 @@ export async function controlPlanePlan(_config: UniDeskConfig, options: StatusOp
mutation: false,
note: "plan/status are read-only. Long writes must use controlled AgentRun/Platform DB commands and this YAML target.",
},
next: {
status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
postgresStatus: spec.database.configRef ? `bun scripts/cli.ts platform-db postgres status --config ${spec.database.configRef}` : null,
postgresApply: spec.database.configRef ? `bun scripts/cli.ts platform-db postgres apply --config ${spec.database.configRef} --confirm` : null,
controlPlaneApply: `bun scripts/cli.ts agentrun control-plane apply --node ${spec.nodeId} --lane ${spec.lane} --dry-run`,
},
next,
valuesPrinted: false,
};
}
@@ -345,29 +372,18 @@ export async function status(config: UniDeskConfig, options: StatusOptions): Pro
return await statusYamlLane(config, options, resolveAgentRunLaneTarget(options));
}
function isPipelinesAsCodeMigratedLane(spec: AgentRunLaneSpec): boolean {
return spec.source.remote.includes("gitea-http.")
|| spec.source.remote.includes("/mirrors/")
|| spec.gitMirror.readUrl.includes("gitea-http.")
|| spec.gitops.repoURL.includes("gitea-http.")
|| spec.gitMirror.readUrl.includes("/mirrors/");
}
function pacStatusCommand(spec: AgentRunLaneSpec, full = false): string {
return `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${spec.nodeId} --consumer ${pacConsumerId(spec)}${full ? " --full" : ""}`;
}
function pacConsumerId(spec: AgentRunLaneSpec): string {
return `agentrun-${spec.lane.toLowerCase()}`;
function pacStatusCommand(spec: AgentRunLaneSpec, consumerId: string, full = false): string {
return `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${spec.nodeId} --consumer ${consumerId}${full ? " --full" : ""}`;
}
function giteaMirrorStatusCommand(spec: AgentRunLaneSpec): string {
return `bun scripts/cli.ts platform-infra gitea mirror status --target ${spec.nodeId}`;
}
export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options: StatusOptions, target: { configPath: string; spec: AgentRunLaneSpec }): Promise<Record<string, unknown>> {
export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options: StatusOptions, target: { configPath: string; spec: AgentRunLaneSpec }, authority: Extract<CicdDeliveryAuthority, { kind: "pac-pr-merge" }>): Promise<Record<string, unknown>> {
const spec = target.spec;
const pacStatus = record(await runPlatformInfraPipelinesAsCodeCommand(config, ["status", "--target", spec.nodeId, "--consumer", pacConsumerId(spec), "--full"]));
const consumerId = authority.consumer.consumerId;
const pacStatus = record(await runPlatformInfraPipelinesAsCodeCommand(config, ["status", "--target", spec.nodeId, "--consumer", consumerId, "--full"]));
const pacSummary = record(pacStatus.summary);
const latestPipelineRun = record(pacSummary.latestPipelineRun);
const artifact = record(pacSummary.artifact);
@@ -412,13 +428,13 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
};
const nextAction = aligned
? { code: "none", summary: "PaC/Gitea migrated lane aligned; no action required", command: null }
: blockers.includes("argo-revision-stale")
? { code: "argo-refresh", summary: "GitOps commit is published but Argo has not observed it yet", command: `bun scripts/cli.ts agentrun control-plane refresh --node ${spec.nodeId} --lane ${spec.lane} --confirm` }
: { code: "inspect-pac-status", summary: `PaC/Gitea closeout blocked: ${blockers.join(", ")}`, command: pacStatusCommand(spec, true) };
: { code: "inspect-automatic-delivery", summary: `PaC/Gitea automatic delivery is blocked: ${blockers.join(", ")}`, command: pacStatusCommand(spec, consumerId, true) };
const migration = {
migrated: true,
sourceAuthority: "gitea-pipelines-as-code",
triggerPath: "Gitea webhook -> Pipelines-as-Code -> Tekton -> GitOps/Argo -> k8s runtime",
consumerId,
configRefs: authority.consumer.configRefs,
triggerPath: "GitHub PR merge -> GitHub webhook -> Gitea mirror/snapshot -> Gitea webhook -> Pipelines-as-Code -> Tekton -> GitOps/Argo -> k8s runtime",
legacyGitMirrorDisposition: "migration-readonly",
legacyTriggerCurrentDisabled: true,
valuesPrinted: false,
@@ -426,6 +442,7 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
const summary = {
aligned,
runtimeAligned: runtimeAlignment.runtimeAligned,
deliveryAuthority: authority,
blockers,
warnings: [],
sourceCommit,
@@ -486,6 +503,7 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
summary,
alignment: {
aligned,
deliveryAuthority: authority,
blockers,
warnings: [],
sourceCommit,
@@ -504,14 +522,15 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
raw: options.raw,
legacyGitMirror: "migration-readonly",
fullCommand: agentRunControlPlaneStatusCommand(spec, options, true),
pacStatusCommand: pacStatusCommand(spec, true),
pacStatusCommand: pacStatusCommand(spec, consumerId, true),
},
next: {
action: nextAction,
pacStatus: pacStatusCommand(spec),
statusFull: agentRunControlPlaneStatusCommand(spec, options, true),
pacStatus: pacStatusCommand(spec, consumerId),
pacHistory: record(pacReadOnlyNext(spec.nodeId, consumerId)).history,
giteaMirrorStatus: giteaMirrorStatusCommand(spec),
refresh: `bun scripts/cli.ts agentrun control-plane refresh --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
triggerCurrent: null,
fixAutomaticDelivery: pacAutomaticDeliveryFix(authority),
},
valuesPrinted: false,
};
@@ -534,9 +553,62 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
return result;
}
function unknownDeliveryAuthorityStatus(options: StatusOptions, target: { configPath: string; spec: AgentRunLaneSpec }, authority: Extract<CicdDeliveryAuthority, { kind: "unknown" }>): Record<string, unknown> {
const spec = target.spec;
const statusFull = agentRunControlPlaneStatusCommand(spec, options, true);
const nextAction = {
code: "delivery-authority-unknown",
summary: "无法从 owning YAML 唯一确认 PaC 或 legacy delivery authority;已停止生成任何人工推进提示。",
command: null,
};
return {
ok: false,
command: "agentrun control-plane status",
mode: "delivery-authority-unknown",
mutation: false,
configPath: target.configPath,
target: options.full || options.raw ? agentRunLaneSummary(spec) : compactAgentRunLaneStatusTarget(spec),
summary: {
aligned: false,
runtimeAligned: false,
blockers: ["delivery-authority-unknown"],
warnings: [],
deliveryAuthority: authority,
nextAction,
},
alignment: {
aligned: false,
runtimeAligned: false,
blockers: ["delivery-authority-unknown"],
warnings: [],
deliveryAuthority: authority,
},
disclosure: {
output: options.full || options.raw ? "full" : "compact-summary",
full: options.full,
raw: options.raw,
fullCommand: statusFull,
},
next: {
statusFull,
fixAutomaticDelivery: pacAutomaticDeliveryFix(authority),
},
valuesPrinted: false,
};
}
export async function statusYamlLane(config: UniDeskConfig, options: StatusOptions, target: { configPath: string; spec: AgentRunLaneSpec }): Promise<Record<string, unknown>> {
const spec = target.spec;
if (isPipelinesAsCodeMigratedLane(spec)) return await statusPipelinesAsCodeLane(config, options, target);
const deliveryAuthority = resolveCicdDeliveryAuthority({
node: spec.nodeId,
lane: spec.lane,
sourceRepository: spec.source.repository,
sourceBranch: spec.source.branch,
declaredSourceAuthorityMode: spec.source.sourceAuthority?.mode ?? null,
declaredConfigRef: target.configPath,
});
if (deliveryAuthority.kind === "pac-pr-merge") return await statusPipelinesAsCodeLane(config, options, target, deliveryAuthority);
if (deliveryAuthority.kind === "unknown") return unknownDeliveryAuthorityStatus(options, target, deliveryAuthority);
const sourceProbe = await timedStatusStage("source", () => spec.source.statusMode === "k3s-git-mirror"
? capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneK3sSourceStatusScript(spec)])
: capture(config, `${spec.nodeRoute}:${spec.source.workspace}`, ["sh", "--", yamlLaneSourceStatusScript(spec)]));
@@ -714,6 +786,7 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
const detailedSummary = {
aligned,
runtimeAligned,
deliveryAuthority,
ciEvidenceMissing,
mirrorAlreadySynced,
blockers,
@@ -777,6 +850,7 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
const commanderSummary = {
aligned,
runtimeAligned,
deliveryAuthority,
ciEvidenceMissing,
mirrorAlreadySynced,
blockers,
@@ -803,6 +877,7 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
alignment: {
aligned,
runtimeAligned,
deliveryAuthority,
ciEvidenceMissing,
mirrorAlreadySynced,
blockers,
+73 -25
View File
@@ -32,6 +32,7 @@ import {
type AgentRunArtifactService,
} from "../agentrun-manifests";
import { sha256Fingerprint } from "../platform-infra-ops-library";
import { decideCicdDeliveryMutation, resolveCicdDeliveryAuthority } from "../cicd-delivery-authority";
import type { AgentRunResourceVerb, AgentRunRestCompatGroup } from "./utils";
import { controlPlaneApply, controlPlanePlan, parseCleanupReleasedPvOptions, parseCleanupRunnersOptions, parseCleanupRunsOptions, parseCleanupSessionPvcsOptions, parseConfirmOptions, parseGitMirrorOptions, parseLaneConfirmOptions, parseRefreshOptions, parseSecretSyncOptions, status } from "./control-plane";
@@ -68,25 +69,14 @@ export function agentRunHelp(): unknown {
"bun scripts/cli.ts agentrun explain task",
"bun scripts/cli.ts agentrun explain session-policy",
"bun scripts/cli.ts agentrun control-plane plan --node NC01 --lane nc01-v02",
"bun scripts/cli.ts agentrun control-plane apply --node NC01 --lane nc01-v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane status --node NC01 --lane nc01-v02",
"bun scripts/cli.ts agentrun control-plane secret-sync --node NC01 --lane nc01-v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane secret-sync --node NC01 --lane nc01-v02 --confirm",
"bun scripts/cli.ts agentrun control-plane secret-sync --node NC01 --lane nc01-v02 --secret-id tool-github-pr-token --confirm",
"bun scripts/cli.ts agentrun control-plane restart --node NC01 --lane nc01-v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane restart --node NC01 --lane nc01-v02 --confirm",
"bun scripts/cli.ts agentrun control-plane trigger-current --node NC01 --lane nc01-v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane trigger-current --node NC01 --lane nc01-v02 --confirm",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer agentrun-nc01-v02 --limit 10",
"bun scripts/cli.ts agentrun control-plane platform-maintenance --help",
"bun scripts/cli.ts agentrun control-plane legacy-cicd --help",
"bun scripts/cli.ts agentrun control-plane status",
"bun scripts/cli.ts agentrun control-plane status --full",
"bun scripts/cli.ts agentrun control-plane status --pipeline-run <yaml-lane-pipelinerun>",
"bun scripts/cli.ts agentrun control-plane status --source-commit <full-sha>",
"bun scripts/cli.ts agentrun control-plane expose --dry-run",
"bun scripts/cli.ts agentrun control-plane expose --confirm",
"bun scripts/cli.ts agentrun control-plane trigger-current --dry-run",
"bun scripts/cli.ts agentrun control-plane trigger-current --confirm",
"bun scripts/cli.ts agentrun control-plane refresh --dry-run",
"bun scripts/cli.ts agentrun control-plane refresh --confirm",
"bun scripts/cli.ts agentrun control-plane cleanup-runners --node NC01 --lane nc01-v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane cleanup-runners --node NC01 --lane nc01-v02 --confirm",
"bun scripts/cli.ts agentrun control-plane cleanup-runs --min-age-minutes 30 --limit 200 --dry-run",
@@ -101,12 +91,12 @@ export function agentRunHelp(): unknown {
"bun scripts/cli.ts agentrun provider-profile validate --node NC01 --lane nc01-v02 --profile gpt.pika --wait",
"bun scripts/cli.ts agentrun git-mirror status",
"bun scripts/cli.ts agentrun git-mirror status --full",
"bun scripts/cli.ts agentrun git-mirror sync --confirm",
"bun scripts/cli.ts agentrun git-mirror flush --confirm",
"bun scripts/cli.ts agentrun git-mirror legacy-ops --help",
],
resources: ["task/qt", "run", "command/cmd", "runnerjob/rjob", "session/ses", "aipodspec/aps"],
description: "Operate AgentRun through Kubernetes-style resource verbs. Human output is compact by default; -o json|yaml returns the UniDesk render-only client schema, --raw exposes the REST envelope, and --node/--lane targets a YAML-declared runtime lane.",
legacyCompatibility: "queue/runs/commands/runner/sessions/aipod-specs remain as compatibility groups backed by direct HTTP; new commander work should use get/describe/events/logs/result/ack/cancel/dispatch/create/apply/send. sessions turn/steer are removed; use send only.",
cicdBoundary: "NC01 PaC consumer 仅由 GitHub PR merge 自动触发;默认帮助只给 status/history。legacy 与平台维护写入口只在显式 scoped help 中展示。",
};
}
@@ -141,11 +131,17 @@ export async function runAgentRunCommand(config: UniDeskConfig | null, args: str
if (action === "expose") return await exposeAgentRun(config, parseConfirmOptions(actionArgs));
if (action === "trigger-current") {
const options = parseTriggerOptions(actionArgs);
const target = resolveAgentRunLaneTarget(options);
const decision = agentRunDeliveryMutationDecision(target, "trigger-current");
if (!decision.allowed) return decision.result;
const result = await triggerCurrent(config, options);
return options.full || options.raw || !isRecord(result.target) ? result : renderAgentRunControlPlaneActionSummary(result, "AGENTRUN TRIGGER CURRENT");
}
if (action === "refresh") {
const options = parseRefreshOptions(actionArgs);
const target = resolveAgentRunLaneTarget(options);
const decision = agentRunDeliveryMutationDecision(target, "refresh");
if (!decision.allowed) return decision.result;
const result = await refresh(config, options);
return options.full || options.raw ? result : renderAgentRunControlPlaneActionSummary(result, "AGENTRUN CONTROL-PLANE REFRESH");
}
@@ -166,7 +162,10 @@ export async function runAgentRunCommand(config: UniDeskConfig | null, args: str
if (action === "status") return await gitMirrorStatus(config, parseGitMirrorStatusOptions(actionArgs));
if (action === "sync" || action === "flush") {
const options = parseGitMirrorOptions(actionArgs);
const { spec } = resolveAgentRunLaneTarget(options);
const target = resolveAgentRunLaneTarget(options);
const { spec } = target;
const decision = agentRunDeliveryMutationDecision(target, `git-mirror-${action}`);
if (!decision.allowed) return decision.result;
if (options.confirm && !options.wait) {
return startAsyncAgentRunJob(
`agentrun_${spec.lane}_git_mirror_${action}`,
@@ -180,6 +179,29 @@ export async function runAgentRunCommand(config: UniDeskConfig | null, args: str
return unsupported(route.canonicalArgs);
}
function agentRunDeliveryMutationDecision(
target: ReturnType<typeof resolveAgentRunLaneTarget>,
action: "trigger-current" | "refresh" | "git-mirror-sync" | "git-mirror-flush",
) {
const authority = resolveCicdDeliveryAuthority({
node: target.spec.nodeId,
lane: target.spec.lane,
sourceRepository: target.spec.source.repository,
sourceBranch: target.spec.source.branch,
declaredSourceAuthorityMode: target.spec.source.sourceAuthority?.mode ?? null,
declaredConfigRef: target.configPath,
});
return decideCicdDeliveryMutation(authority, {
command: action.startsWith("git-mirror-")
? `agentrun git-mirror ${action.slice("git-mirror-".length)}`
: `agentrun control-plane ${action}`,
statusCommand: `bun scripts/cli.ts agentrun control-plane status --node ${target.spec.nodeId} --lane ${target.spec.lane}`,
action,
node: target.spec.nodeId,
lane: target.spec.lane,
});
}
export function isAgentRunRestCompatGroup(group: string | undefined): group is AgentRunRestCompatGroup {
return group === "queue" || group === "sessions" || group === "aipod-specs" || group === "aipods" || group === "runs" || group === "commands" || group === "runner";
}
@@ -285,32 +307,58 @@ export function agentRunHelpText(args: string[]): string {
}
if (verb === "explain") return agentRunExplain(kind ?? "task");
if (verb === "control-plane") {
if (kind === "legacy-cicd") {
return [
"Usage: bun scripts/cli.ts agentrun control-plane <trigger-current|refresh> --node <node> --lane <lane> [--dry-run|--confirm]",
"",
"Scope: explicit legacy/manual CI/CD only.",
"The owning YAML delivery authority must resolve to legacy-manual. PaC migrated or unknown authority must fail closed and must not use these commands for delivery or recovery.",
].join("\n");
}
if (kind === "platform-maintenance") {
return [
"Usage: bun scripts/cli.ts agentrun control-plane <apply|secret-sync|restart|expose> --node <node> --lane <lane> [--dry-run|--confirm]",
"",
"Scope: explicit YAML-declared platform/configuration maintenance only.",
"These commands do not replace the PaC source delivery chain and must not be used after a source PR merge to complete or recover rollout.",
].join("\n");
}
return [
"Usage: bun scripts/cli.ts agentrun control-plane <action> [options]",
"",
"Actions: plan, apply, status, secret-sync, expose, trigger-current, refresh, cleanup-runners, cleanup-runs, cleanup-session-pvcs, cleanup-local-postgres, cleanup-released-pvs",
"Default actions: plan, status, cleanup-runners, cleanup-runs, cleanup-session-pvcs, cleanup-local-postgres, cleanup-released-pvs",
"Examples:",
" bun scripts/cli.ts agentrun control-plane plan --node NC01 --lane nc01-v02",
" bun scripts/cli.ts agentrun control-plane apply --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane status --node NC01 --lane nc01-v02",
" bun scripts/cli.ts agentrun control-plane secret-sync --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane trigger-current --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer agentrun-nc01-v02 --limit 10",
" bun scripts/cli.ts agentrun control-plane status",
" bun scripts/cli.ts agentrun control-plane status --pipeline-run <yaml-lane-pipelinerun>",
" bun scripts/cli.ts agentrun control-plane expose --dry-run",
" bun scripts/cli.ts agentrun control-plane trigger-current --dry-run",
" bun scripts/cli.ts agentrun control-plane cleanup-runners --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane cleanup-session-pvcs --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane cleanup-local-postgres --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane cleanup-runs --min-age-minutes 30 --limit 200 --dry-run",
"",
"Scoped maintenance help:",
" bun scripts/cli.ts agentrun control-plane platform-maintenance --help",
" bun scripts/cli.ts agentrun control-plane legacy-cicd --help",
"PaC migrated consumer 的唯一触发是 GitHub PR merge;自动链故障只用 status/history 调查并修 owning YAML/controller/source。",
].join("\n");
}
if (verb === "provider-profile") return providerProfileHelp();
if (verb === "git-mirror") {
if (kind === "legacy-ops") {
return [
"Usage: bun scripts/cli.ts agentrun git-mirror <sync|flush> --node <node> --lane <lane> [--dry-run|--confirm] [--wait]",
"",
"Scope: explicit legacy/manual mirror maintenance only.",
"The owning YAML delivery authority must resolve to legacy-manual. PaC migrated or unknown authority must fail closed.",
].join("\n");
}
return [
"Usage: bun scripts/cli.ts agentrun git-mirror <status|sync|flush> [--full|--raw|--confirm]",
"Usage: bun scripts/cli.ts agentrun git-mirror status [--full|--raw]",
"",
"Confirmed sync/flush returns an async job unless --wait is set.",
"PaC migrated consumer uses platform-infra Gitea/PaC status and never uses manual mirror sync/flush to complete delivery.",
"Explicit legacy operations: bun scripts/cli.ts agentrun git-mirror legacy-ops --help",
].join("\n");
}
if (verb !== undefined && isAgentRunRestCompatGroup(verb)) {
+22 -4
View File
@@ -32,6 +32,7 @@ import {
type AgentRunArtifactService,
} from "../agentrun-manifests";
import { sha256Fingerprint } from "../platform-infra-ops-library";
import { pacAutomaticDeliveryFix, pacReadOnlyNext, resolveCicdDeliveryAuthority } from "../cicd-delivery-authority";
import type { GitMirrorStatusOptions } from "./options";
import { readGitMirrorStatus } from "./rest-bridge";
@@ -473,6 +474,14 @@ export function refreshYamlLaneScript(spec: AgentRunLaneSpec): string {
export async function gitMirrorStatus(config: UniDeskConfig, options: GitMirrorStatusOptions = { full: false, raw: false, node: null, lane: null }): Promise<Record<string, unknown>> {
const target = resolveAgentRunLaneTarget(options);
const spec = target.spec;
const deliveryAuthority = resolveCicdDeliveryAuthority({
node: spec.nodeId,
lane: spec.lane,
sourceRepository: spec.source.repository,
sourceBranch: spec.source.branch,
declaredSourceAuthorityMode: spec.source.sourceAuthority?.mode ?? null,
declaredConfigRef: target.configPath,
});
const observation = await readGitMirrorStatus(config, target);
const summary = observation.summary;
return {
@@ -480,6 +489,7 @@ export async function gitMirrorStatus(config: UniDeskConfig, options: GitMirrorS
command: "agentrun git-mirror status",
mode: "yaml-declared-node-lane",
configPath: target.configPath,
deliveryAuthority,
target: agentRunLaneSummary(spec),
namespace: spec.gitMirror.namespace,
readUrl: spec.gitMirror.readUrl,
@@ -496,9 +506,17 @@ export async function gitMirrorStatus(config: UniDeskConfig, options: GitMirrorS
expandWith: `bun scripts/cli.ts agentrun git-mirror status --node ${spec.nodeId} --lane ${spec.lane} --full`,
rawWith: `bun scripts/cli.ts agentrun git-mirror status --node ${spec.nodeId} --lane ${spec.lane} --raw`,
},
next: {
sync: `bun scripts/cli.ts agentrun git-mirror sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
flush: summary.pendingFlush === true ? `bun scripts/cli.ts agentrun git-mirror flush --node ${spec.nodeId} --lane ${spec.lane} --confirm` : null,
},
next: deliveryAuthority.kind === "pac-pr-merge"
? pacReadOnlyNext(deliveryAuthority.consumer.node, deliveryAuthority.consumer.consumerId)
: deliveryAuthority.kind === "unknown"
? {
status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
fixAutomaticDelivery: pacAutomaticDeliveryFix(deliveryAuthority),
valuesPrinted: false,
}
: {
sync: `bun scripts/cli.ts agentrun git-mirror sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
flush: summary.pendingFlush === true ? `bun scripts/cli.ts agentrun git-mirror flush --node ${spec.nodeId} --lane ${spec.lane} --confirm` : null,
},
};
}
+5 -2
View File
@@ -359,7 +359,10 @@ export function unsupported(args: string[]): RenderedCliResult {
" agentrun aipod-specs|queue|runs|commands|runner|sessions",
"",
"Operations:",
" agentrun control-plane status|trigger-current|refresh",
" agentrun git-mirror status|sync|flush",
" agentrun control-plane status",
" agentrun git-mirror status",
" agentrun control-plane legacy-cicd --help",
" agentrun git-mirror legacy-ops --help",
" agentrun control-plane platform-maintenance --help",
].join("\n"));
}
@@ -0,0 +1,176 @@
// Responsibility: fail-closed delivery authority and safe navigation for the retired branch-follower surface.
import {
pacAutomaticDeliveryFix,
pacNodeReadOnlyNext,
resolveCicdDeliveryAuthority,
type CicdDeliveryAuthority,
} from "./cicd-delivery-authority";
import type { BranchFollowerRegistry, FollowerSpec, ParsedOptions } from "./cicd-types";
export type BranchFollowerDeliveryAuthority =
| {
readonly kind: "retired-readonly";
readonly mutationHintsAllowed: false;
readonly followers: readonly string[];
readonly pacAuthorities: readonly Extract<CicdDeliveryAuthority, { kind: "pac-pr-merge" }>[];
readonly reason: "all-selected-followers-retired-to-pac";
}
| {
readonly kind: "legacy-manual";
readonly mutationHintsAllowed: true;
readonly followers: readonly string[];
readonly reason: "all-selected-followers-explicit-legacy-manual";
}
| {
readonly kind: "unknown";
readonly mutationHintsAllowed: false;
readonly followers: readonly string[];
readonly reason: "authority-not-declared" | "authority-mixed" | "retired-pac-identity-invalid" | "legacy-conflicts-with-pac";
readonly details: readonly Record<string, unknown>[];
};
export function branchFollowerDeliveryAuthority(
registry: BranchFollowerRegistry,
options: ParsedOptions,
): BranchFollowerDeliveryAuthority {
const followers = selectedFollowers(registry, options);
const rows = followers.map(resolveFollowerAuthority);
if (rows.every((row) => row.kind === "retired-readonly")) {
return {
kind: "retired-readonly",
mutationHintsAllowed: false,
followers: followers.map((follower) => follower.id),
pacAuthorities: rows.map((row) => row.pacAuthority).filter((item): item is Extract<CicdDeliveryAuthority, { kind: "pac-pr-merge" }> => item !== null),
reason: "all-selected-followers-retired-to-pac",
};
}
if (rows.every((row) => row.kind === "legacy-manual")) {
return {
kind: "legacy-manual",
mutationHintsAllowed: true,
followers: followers.map((follower) => follower.id),
reason: "all-selected-followers-explicit-legacy-manual",
};
}
const reason = rows.some((row) => row.reason === "retired-pac-identity-invalid")
? "retired-pac-identity-invalid"
: rows.some((row) => row.reason === "legacy-conflicts-with-pac")
? "legacy-conflicts-with-pac"
: rows.some((row) => row.kind === "unknown")
? "authority-not-declared"
: "authority-mixed";
return {
kind: "unknown",
mutationHintsAllowed: false,
followers: followers.map((follower) => follower.id),
reason,
details: rows.map((row) => ({ follower: row.follower, kind: row.kind, reason: row.reason, authority: row.pacAuthority })),
};
}
export function branchFollowerMutationGuard(
registry: BranchFollowerRegistry,
options: ParsedOptions,
): Record<string, unknown> | null {
const deliveryMutation = options.action === "apply"
|| options.action === "run-once"
|| options.action === "job"
|| options.action === "gate"
|| (options.action === "debug-step" && options.debugStep === "state-write");
const retiredCleanup = options.action === "cleanup-state";
if (!deliveryMutation && !retiredCleanup) return null;
const authority = branchFollowerDeliveryAuthority(registry, options);
if (deliveryMutation && authority.kind === "legacy-manual" && options.scope === "legacy-cicd") return null;
if (retiredCleanup && authority.kind === "retired-readonly" && options.scope === "retired-maintenance") return null;
return {
ok: false,
action: options.action,
mutation: false,
mode: authority.kind === "retired-readonly"
? "retired-branch-follower-mutation-blocked"
: authority.kind === "legacy-manual"
? "legacy-branch-follower-scope-required"
: "branch-follower-delivery-authority-unknown",
reason: retiredCleanup
? "退役状态清理只允许通过 retired-maintenance scoped command;默认入口不修改控制器状态。"
: "该 branch-follower 已退役或 authority 无法唯一确认;CLI 不执行交付写操作。",
deliveryAuthority: authority,
next: branchFollowerReadOnlyNext(registry, options, authority),
valuesPrinted: false,
};
}
export function branchFollowerReadOnlyNext(
registry: BranchFollowerRegistry,
options: ParsedOptions,
authority = branchFollowerDeliveryAuthority(registry, options),
): Record<string, unknown> {
const suffix = options.followerId === null ? "" : ` --follower ${options.followerId}`;
const nodes = authority.kind === "retired-readonly"
? Array.from(new Set(authority.pacAuthorities.map((item) => item.consumer.node)))
: [];
const followerNext = {
status: `bun scripts/cli.ts cicd branch-follower status${suffix}`,
events: `bun scripts/cli.ts cicd branch-follower events${suffix}`,
logs: `bun scripts/cli.ts cicd branch-follower logs${suffix}`,
fixAutomaticDelivery: authority.kind === "unknown"
? pacAutomaticDeliveryFix(authorityFromUnknown(authority))
: { reference: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界", mutation: false },
valuesPrinted: false,
};
if (nodes.length !== 1) return followerNext;
const pacNext = pacNodeReadOnlyNext(nodes[0] as string);
return {
...followerNext,
pacStatus: pacNext.status,
pacHistory: pacNext.history,
};
}
function selectedFollowers(registry: BranchFollowerRegistry, options: ParsedOptions): FollowerSpec[] {
const selected = options.followerId === null
? registry.followers
: registry.followers.filter((follower) => follower.id === options.followerId);
if (selected.length === 0) throw new Error(`unknown follower ${options.followerId ?? "-"}`);
return selected;
}
function resolveFollowerAuthority(follower: FollowerSpec): {
follower: string;
kind: "retired-readonly" | "legacy-manual" | "unknown";
reason: string;
pacAuthority: Extract<CicdDeliveryAuthority, { kind: "pac-pr-merge" }> | null;
} {
const pacAuthority = resolveCicdDeliveryAuthority({
node: follower.target.node,
lane: follower.target.lane,
sourceRepository: follower.source.repository,
sourceBranch: follower.source.branch,
});
if (follower.deliveryAuthority === "retired-readonly") {
return pacAuthority.kind === "pac-pr-merge"
? { follower: follower.id, kind: "retired-readonly", reason: "exact-pac-consumer", pacAuthority }
: { follower: follower.id, kind: "unknown", reason: "retired-pac-identity-invalid", pacAuthority: null };
}
if (follower.deliveryAuthority === "legacy-manual") {
return pacAuthority.kind === "pac-pr-merge"
? { follower: follower.id, kind: "unknown", reason: "legacy-conflicts-with-pac", pacAuthority }
: { follower: follower.id, kind: "legacy-manual", reason: "explicit-legacy-manual", pacAuthority: null };
}
return { follower: follower.id, kind: "unknown", reason: "authority-not-declared", pacAuthority: null };
}
function authorityFromUnknown(authority: Extract<BranchFollowerDeliveryAuthority, { kind: "unknown" }>): Extract<CicdDeliveryAuthority, { kind: "unknown" }> {
return {
kind: "unknown",
migrated: false,
mutationHintsAllowed: false,
consumer: null,
reason: "authority-identity-mismatch",
detail: authority.reason,
configRefs: ["config/cicd-branch-followers.yaml", "config/platform-infra/pipelines-as-code.yaml"],
valuesPrinted: false,
};
}
+116 -65
View File
@@ -2,7 +2,7 @@
// Responsibility: YAML-first K8s branch-follower controller, status, and adapter orchestration.
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { isAbsolute } from "node:path";
import { isAbsolute, resolve } from "node:path";
import { repoRoot, rootPath, type UniDeskConfig } from "./config";
import { runCommand, type CommandResult } from "./command";
import { startJob } from "./jobs";
@@ -34,11 +34,16 @@ import { runBranchFollowerTaskRunDrillDown } from "./cicd-taskrun-drilldown";
import { runBranchFollowerJobDrillDown, runBranchFollowerRuntimeDrillDown } from "./cicd-job-runtime-drilldown";
import { runBranchFollowerGate } from "./cicd-gates";
import { buildCicdHelp } from "./cicd-help";
import {
branchFollowerDeliveryAuthority,
branchFollowerMutationGuard,
branchFollowerReadOnlyNext,
} from "./cicd-branch-follower-authority";
import { attachReconcileTimeline, compactReconcileTimeline, finishReconcileStep, finishReconcileTimeline, startReconcileStep, startReconcileTimeline } from "./cicd-reconcile-timeline";
import { orderFollowersForControllerCloseout, shouldYieldAfterAutomaticTrigger } from "./cicd-reconcile-scheduler";
import { buildFollowerTimings, compactListTimings, compactTimings, storedFollowerTimingsForStatus, timingPerformanceSummary } from "./cicd-timings";
import { timingAttributionSummary } from "./cicd-timing-attribution";
import type { AdapterSummary, BranchFollowerAction, BranchFollowerDebugStep, BranchFollowerGate, BranchFollowerPhase, BranchFollowerRegistry, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, TriggerResult } from "./cicd-types";
import type { AdapterSummary, BranchFollowerAction, BranchFollowerDebugStep, BranchFollowerGate, BranchFollowerPhase, BranchFollowerRegistry, BranchFollowerScope, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, TriggerResult } from "./cicd-types";
import {
arrayField,
asRecord,
@@ -56,20 +61,32 @@ const DEFAULT_CONFIG_PATH = "config/cicd-branch-followers.yaml";
const SPEC_REF = "PJ2026-01060703";
const SPEC_VERSION = "draft-2026-07-03-p0-branch-follower";
export function cicdHelp(): unknown {
return buildCicdHelp(DEFAULT_CONFIG_PATH, `${SPEC_REF} ${SPEC_VERSION}`);
export function cicdHelp(scope: BranchFollowerScope = "default", configPath = DEFAULT_CONFIG_PATH): unknown {
let legacyConfigured = false;
if (scope === "legacy-cicd" && isCanonicalBranchFollowerConfigPath(configPath)) {
try {
legacyConfigured = readRegistry(configPath).followers.some((follower) => follower.deliveryAuthority === "legacy-manual");
} catch {
legacyConfigured = false;
}
}
return buildCicdHelp(configPath, `${SPEC_REF} ${SPEC_VERSION}`, scope, legacyConfigured);
}
export async function runCicdCommand(_config: UniDeskConfig | null, args: string[]): Promise<RenderedCliResult> {
const top = args[0];
if (top === undefined || isHelpToken(top)) return renderMachine("cicd", cicdHelp(), "json");
if (top !== "branch-follower") {
throw new Error("cicd usage: cicd branch-follower plan|apply|status|run-once|debug-step|cleanup-state|events|logs|taskrun|job|runtime|gate");
throw new Error("cicd usage: cicd branch-follower status|events|logs|taskrun|runtime|debug-step");
}
const options = parseOptions(args.slice(1));
const command = commandLabel(options);
if (options.action === "help") return renderMachine(command, cicdHelp(), "json");
if (options.action === "help") return renderMachine(command, cicdHelp(options.scope, options.configPath), "json");
const configBlocked = branchFollowerConfigMutationGuard(options);
if (configBlocked !== null) return renderMachine(command, configBlocked, options.output === "yaml" ? "yaml" : "json", false);
const registry = readRegistry(options.configPath);
const blocked = branchFollowerMutationGuard(registry, options);
if (blocked !== null) return renderMachine(command, blocked, options.output === "yaml" ? "yaml" : "json", false);
switch (options.action) {
case "plan":
return renderResult(command, buildPlan(registry, options), options);
@@ -96,21 +113,60 @@ export async function runCicdCommand(_config: UniDeskConfig | null, args: string
case "gate":
return renderResult(command, await runGate(registry, options), options);
case "help":
return renderMachine(command, cicdHelp(), "json");
return renderMachine(command, cicdHelp(options.scope, options.configPath), "json");
}
}
function branchFollowerConfigMutationGuard(options: ParsedOptions): Record<string, unknown> | null {
const mutation = options.action === "apply"
|| options.action === "run-once"
|| options.action === "job"
|| options.action === "gate"
|| options.action === "cleanup-state"
|| options.action === "debug-step" && options.debugStep === "state-write";
if (!mutation || isCanonicalBranchFollowerConfigPath(options.configPath)) return null;
return {
ok: false,
action: options.action,
mutation: false,
mode: "noncanonical-branch-follower-config-mutation-blocked",
reason: "branch-follower 写操作只信任仓库内固定 owning YAML;--config 仅用于只读诊断,不能声明或扩大 delivery authority。",
configPath: options.configPath,
owningConfigPath: DEFAULT_CONFIG_PATH,
next: {
status: "bun scripts/cli.ts cicd branch-follower status",
fixAutomaticDelivery: { reference: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界", mutation: false },
valuesPrinted: false,
},
valuesPrinted: false,
};
}
function isCanonicalBranchFollowerConfigPath(configPath: string): boolean {
const absolute = isAbsolute(configPath) ? resolve(configPath) : resolve(rootPath(configPath));
return absolute === resolve(rootPath(DEFAULT_CONFIG_PATH));
}
function parseOptions(args: string[]): ParsedOptions {
const actionToken = args[0];
const firstToken = args[0];
const scope: BranchFollowerScope = firstToken === "legacy-cicd" || firstToken === "retired-maintenance" ? firstToken : "default";
const actionOffset = scope === "default" ? 0 : 1;
const actionToken = args[actionOffset];
if (actionToken === undefined || isHelpToken(actionToken)) {
return defaultOptions("help", args.slice(actionToken === undefined ? 0 : 1));
const options = defaultOptions("help", []);
options.scope = scope;
return options;
}
if (!["plan", "apply", "status", "run-once", "debug-step", "cleanup-state", "events", "logs", "taskrun", "job", "runtime", "gate"].includes(actionToken)) {
throw new Error(`cicd branch-follower unknown action: ${actionToken}`);
}
const action = actionToken as BranchFollowerAction;
if (scope === "default" && action === "plan") {
throw new Error("retired branch-follower default surface is read-only; use status/events/logs/taskrun/runtime/debug-step or PaC status/history");
}
const options = defaultOptions(action, []);
const rest = args.slice(1);
options.scope = scope;
const rest = args.slice(actionOffset + 1);
for (let index = 0; index < rest.length; index += 1) {
const arg = rest[index] ?? "";
if (isHelpToken(arg)) {
@@ -205,6 +261,9 @@ function parseOptions(args: string[]): ParsedOptions {
throw new Error(`${options.action} requires --follower <id>`);
}
if (options.action === "gate" && (options.followerId === null || options.gate === null)) throw new Error("gate requires --follower <id> --gate <name>");
if (options.scope === "retired-maintenance" && options.action !== "cleanup-state" && options.action !== "help") {
throw new Error("cicd branch-follower retired-maintenance only supports cleanup-state");
}
return options;
}
@@ -225,6 +284,7 @@ function isInClusterRuntime(): boolean {
function defaultOptions(action: BranchFollowerAction, _args: string[]): ParsedOptions {
return {
action,
scope: "default",
configPath: DEFAULT_CONFIG_PATH,
followerId: null,
all: false,
@@ -393,6 +453,7 @@ function parseFollower(root: Record<string, unknown>, index: number): FollowerSp
return {
id: simpleId(stringField(root, "id", label), `${label}.id`),
enabled: booleanField(root, "enabled", label),
deliveryAuthority: followerDeliveryAuthority(root.deliveryAuthority),
adapter: stringField(root, "adapter", label),
description: stringField(root, "description", label),
source: {
@@ -432,6 +493,10 @@ function parseFollower(root: Record<string, unknown>, index: number): FollowerSp
};
}
function followerDeliveryAuthority(value: unknown): FollowerSpec["deliveryAuthority"] {
return value === "retired-readonly" || value === "legacy-manual" ? value : "unknown";
}
function parseNativeStatus(root: Record<string, unknown>, label: string): NativeStatusSpec {
const source = recordField(root, "source", label);
const tekton = asOptionalRecord(root.tekton);
@@ -514,6 +579,7 @@ function stringMap(root: Record<string, unknown>, label: string): Record<string,
}
function buildPlan(registry: BranchFollowerRegistry, options: ParsedOptions): Record<string, unknown> {
const deliveryAuthority = branchFollowerDeliveryAuthority(registry, options);
const selected = selectFollowers(registry, options, { includeDisabled: true });
const followers = selected.map((follower) => {
const branchValue = safeResolveString(follower.source.branchRef);
@@ -528,6 +594,7 @@ function buildPlan(registry: BranchFollowerRegistry, options: ParsedOptions): Re
return {
id: follower.id,
enabled: follower.enabled,
deliveryAuthority: follower.deliveryAuthority,
adapter: follower.adapter,
description: follower.description,
source: {
@@ -570,14 +637,9 @@ function buildPlan(registry: BranchFollowerRegistry, options: ParsedOptions): Re
loop: registry.controller.loop,
budgets: registry.controller.budgets,
},
deliveryAuthority,
followers,
next: {
pacStatus: "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01",
pacHistory: "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10",
legacyApply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait",
legacyStatus: "bun scripts/cli.ts cicd branch-follower status",
legacyDryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run",
},
next: branchFollowerReadOnlyNext(registry, options, deliveryAuthority),
};
}
@@ -619,21 +681,21 @@ async function applyController(registry: BranchFollowerRegistry, options: Parsed
sourceMode: "k8s-git-mirror-to-emptyDir",
},
command: commandCompact(result, options),
next: {
status: "bun scripts/cli.ts cicd branch-follower status",
logs: `bun scripts/cli.ts cicd branch-follower logs --follower ${registry.followers[0]?.id ?? "<id>"}`,
dryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run",
},
next: branchFollowerReadOnlyNext(registry, options),
};
}
async function buildStatus(registry: BranchFollowerRegistry, options: ParsedOptions): Promise<Record<string, unknown>> {
const deliveryAuthority = branchFollowerDeliveryAuthority(registry, options);
const legacyScoped = deliveryAuthority.kind === "legacy-manual" && options.scope === "legacy-cicd";
let k8s = readK8sState(registry, options);
const beforeRefreshStateByFollower = k8s.stateByFollower;
const wantsLive = options.live || (!options.noLive && Object.keys(k8s.stateByFollower).length === 0);
const refresh = wantsLive && !options.inCluster ? runControllerReconcileJob(registry, options, { dryRun: true, wait: true, recordState: true }) : null;
const refresh = wantsLive && !options.inCluster && legacyScoped
? runControllerReconcileJob(registry, options, { dryRun: true, wait: true, recordState: true })
: null;
if (refresh !== null) k8s = readK8sState(registry, options);
const shouldLive = wantsLive && options.inCluster;
const shouldLive = wantsLive && options.inCluster && legacyScoped;
const selected = selectFollowers(registry, options, { includeDisabled: true });
const followers = [];
const detailedFollowers = options.full;
@@ -647,21 +709,16 @@ async function buildStatus(registry: BranchFollowerRegistry, options: ParsedOpti
ok: k8s.ok && followers.every((item) => item.ok !== false),
action: "status",
live: wantsLive,
liveMode: shouldLive ? "in-cluster-adapter-status" : refresh !== null ? "operator-status-refresh-job" : "stored-state",
liveMode: shouldLive ? "in-cluster-adapter-status" : refresh !== null ? "operator-status-refresh-job" : "stored-state-readonly",
liveRefresh: liveRefreshSummary(wantsLive, shouldLive, refresh),
registry: registrySummary(registry),
deliveryAuthority,
controller: controllerStatusSummary(registry, k8s),
followers,
refresh,
errors: k8s.errors,
warnings: k8s.warnings,
next: {
pacStatus: "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01",
pacHistory: "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10",
legacyApply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait",
liveStatus: "bun scripts/cli.ts cicd branch-follower status --live",
legacyDryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run",
},
next: branchFollowerReadOnlyNext(registry, options, deliveryAuthority),
};
}
@@ -683,10 +740,7 @@ async function runOnce(registry: BranchFollowerRegistry, options: ParsedOptions)
job: refresh,
followers: selected.map((follower) => mergeFollowerStatus(registry, follower, k8s.stateByFollower[follower.id] ?? {}, null, false, false, before.stateByFollower[follower.id] ?? {})),
warnings: refresh.ok ? [] : [`reconcile job failed: ${refresh.message}`],
next: {
status: "bun scripts/cli.ts cicd branch-follower status",
liveStatus: "bun scripts/cli.ts cicd branch-follower status --live",
},
next: branchFollowerReadOnlyNext(registry, options),
};
}
const selected = selectFollowers(registry, options, { includeDisabled: false });
@@ -740,10 +794,7 @@ async function runOnce(registry: BranchFollowerRegistry, options: ParsedOptions)
followers: results,
stateWrites,
warnings: stateWriteWarnings,
next: {
status: "bun scripts/cli.ts cicd branch-follower status",
liveStatus: "bun scripts/cli.ts cicd branch-follower status --live",
},
next: branchFollowerReadOnlyNext(registry, options),
};
}
@@ -775,12 +826,7 @@ function cleanupState(registry: BranchFollowerRegistry, options: ParsedOptions):
parsedDownstreamCliOutput: false,
command: command === null ? null : commandCompact(command, options),
errors: before.ok ? [] : [before.error],
next: {
status: "bun scripts/cli.ts cicd branch-follower status",
runOnce: options.followerId === null
? "bun scripts/cli.ts cicd branch-follower run-once --all --confirm --wait"
: `bun scripts/cli.ts cicd branch-follower run-once --follower ${options.followerId} --confirm --wait`,
},
next: branchFollowerReadOnlyNext(registry, options),
};
}
@@ -800,8 +846,12 @@ async function runFollowerDrillDown(registry: BranchFollowerRegistry, options: P
}
const follower = registry.followers.find((item) => item.id === options.followerId);
if (follower === undefined) throw new Error(`unknown follower ${options.followerId}`);
if (!options.inCluster) {
const refresh = runControllerReconcileJob(registry, options, { dryRun: true, wait: true, recordState: true });
const deliveryAuthority = branchFollowerDeliveryAuthority(registry, options);
const legacyScoped = deliveryAuthority.kind === "legacy-manual" && options.scope === "legacy-cicd";
if (!options.inCluster || !legacyScoped) {
const refresh = !options.inCluster && legacyScoped
? runControllerReconcileJob(registry, options, { dryRun: true, wait: true, recordState: true })
: null;
const k8s = readK8sState(registry, options);
const stored = k8s.stateByFollower[follower.id] ?? {};
const storedSource = asOptionalRecord(stored.source);
@@ -809,7 +859,7 @@ async function runFollowerDrillDown(registry: BranchFollowerRegistry, options: P
const command = asOptionalRecord(stored.command);
const native = asOptionalRecord(command?.payload);
return {
ok: refresh.ok && k8s.ok && Object.keys(stored).length > 0,
ok: (refresh === null || refresh.ok) && k8s.ok && Object.keys(stored).length > 0,
action: options.action,
follower: follower.id,
adapter: follower.adapter,
@@ -826,11 +876,7 @@ async function runFollowerDrillDown(registry: BranchFollowerRegistry, options: P
native,
refresh,
errors: k8s.errors,
next: {
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
liveStatus: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id} --live`,
runOnceDryRun: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --dry-run`,
},
next: branchFollowerReadOnlyNext(registry, options, deliveryAuthority),
};
}
const live = await readAdapterStatus(registry, follower, options);
@@ -850,10 +896,7 @@ async function runFollowerDrillDown(registry: BranchFollowerRegistry, options: P
message: live.message,
},
native: live.payload,
next: {
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
runOnceDryRun: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --dry-run`,
},
next: branchFollowerReadOnlyNext(registry, options, deliveryAuthority),
};
}
@@ -2015,7 +2058,7 @@ function mergeFollowerStatus(
evidence: detailed ? evidence : null,
reconcileTimeline: detailed ? reconcileTimeline : null,
rawStateDiagnostic: detailed ? asOptionalRecord(stored.rawStateDiagnostic) : null,
drilldown: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id} --live`,
drilldown: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
};
if (!detailed) return summary;
return {
@@ -2498,11 +2541,20 @@ function registrySummary(registry: BranchFollowerRegistry): Record<string, unkno
stateConfigMapName: registry.controller.stateConfigMapName,
leaseName: registry.controller.leaseName,
},
followers: registry.followers.map((item) => item.id),
followers: registry.followers.map((item) => ({ id: item.id, enabled: item.enabled, deliveryAuthority: item.deliveryAuthority })),
};
}
function redactCommands(follower: FollowerSpec): Record<string, string> {
if (follower.deliveryAuthority !== "legacy-manual") {
return {
plan: "retired-readonly",
status: "native:k8s-state+tekton+argocd+runtime",
trigger: "blocked-by-delivery-authority",
events: "native:k8s-readonly",
logs: "native:k8s-readonly",
};
}
return {
plan: follower.commands.plan.argv.join(" "),
status: "native:k8s-git-mirror+tekton+argocd+runtime",
@@ -2656,11 +2708,9 @@ function nativeGateTimingSummary(payload: Record<string, unknown> | null, timing
function followerNextCommands(follower: FollowerSpec): Record<string, string> {
const next: Record<string, string> = {
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
liveStatus: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id} --live`,
dryRun: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --dry-run`,
trigger: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --confirm --wait`,
events: `bun scripts/cli.ts cicd branch-follower events --follower ${follower.id}`,
logs: `bun scripts/cli.ts cicd branch-follower logs --follower ${follower.id}`,
fixAutomaticDelivery: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界",
};
if (follower.nativeStatus.tekton !== null) next.pipelineRuns = `bun scripts/cli.ts cicd branch-follower events --follower ${follower.id}`;
if (follower.nativeStatus.argo !== null) next.argoApplication = `bun scripts/cli.ts cicd branch-follower events --follower ${follower.id}`;
@@ -2804,8 +2854,9 @@ function tailText(text: string, maxChars: number): string {
}
function commandLabel(options: ParsedOptions): string {
if (options.taskRunName !== null) return "cicd branch-follower taskrun";
return `cicd branch-follower ${options.action}`;
const scope = options.scope === "default" ? "" : ` ${options.scope}`;
if (options.taskRunName !== null) return `cicd branch-follower${scope} taskrun`;
return `cicd branch-follower${scope} ${options.action}`;
}
function arrayRecords(value: unknown): Record<string, unknown>[] {
+3 -119
View File
@@ -5,9 +5,8 @@ import { existsSync, readFileSync } from "node:fs";
import { runCommand, type CommandResult } from "./command";
import { repoRoot, rootPath } from "./config";
import type { AdapterSummary, BranchFollowerDebugStep, BranchFollowerRegistry, FollowerSpec, FollowerState, K8sStateRead, ParsedOptions } from "./cicd-types";
import { renderControllerDebugJob, waitForJobShell } from "./cicd-controller-render";
import { taskRunItems } from "./cicd-taskruns";
import { redactText, shQuote } from "./platform-infra-ops-library";
import { redactText } from "./platform-infra-ops-library";
type KubeScriptRunner = (registry: BranchFollowerRegistry, options: ParsedOptions, script: string, input: string, timeoutMs: number) => CommandResult;
@@ -23,7 +22,6 @@ export interface CicdDebugDeps {
export async function buildDebugStep(registry: BranchFollowerRegistry, options: ParsedOptions, deps: CicdDebugDeps): Promise<Record<string, unknown>> {
const step = options.debugStep ?? "state-read";
if (options.followerId === null) throw new Error("debug-step requires --follower <id>");
if (!options.inCluster) return runTargetDebugStepJob(registry, options, step, deps);
const selected = deps.selectFollowers(registry, options, { includeDisabled: true });
if (selected.length !== 1) throw new Error("debug-step operates on exactly one follower");
@@ -53,7 +51,7 @@ export async function buildDebugStep(registry: BranchFollowerRegistry, options:
action: "debug-step",
step,
follower: follower.id,
execution: "k8s-native-in-cluster",
execution: options.inCluster ? "k8s-native-in-cluster" : "operator-readonly",
dryRun: !options.confirm,
stateBefore: stateSnapshot(before, follower.id),
stateWrite: { ok: false, skipped: true, reason: "stored-state-missing" },
@@ -74,7 +72,7 @@ export async function buildDebugStep(registry: BranchFollowerRegistry, options:
action: "debug-step",
step,
follower: follower.id,
execution: "k8s-native-in-cluster",
execution: options.inCluster ? "k8s-native-in-cluster" : "operator-readonly",
dryRun: !options.confirm,
stateBefore: stateSnapshot(before, follower.id),
controllerSource,
@@ -128,123 +126,10 @@ export function renderDebugStepHuman(payload: Record<string, unknown>): string {
`controller-source: ${next?.controllerSource ?? "-"}`,
`status-read: ${next?.statusRead ?? "-"}`,
`decide: ${next?.decide ?? "-"}`,
`state-write: ${next?.stateWrite ?? "-"}`,
"",
].filter((line) => line !== "").join("\n");
}
function runTargetDebugStepJob(registry: BranchFollowerRegistry, options: ParsedOptions, step: BranchFollowerDebugStep, deps: CicdDebugDeps): Record<string, unknown> {
const timeoutSeconds = options.timeoutSeconds ?? registry.controller.budgets.runOnceSeconds;
const jobName = `${registry.controller.deploymentName}-debug-${step}-${Date.now().toString(36)}`.replace(/[^a-z0-9-]+/gu, "-").slice(0, 63);
const manifest = renderControllerDebugJob(registry, options, jobName, step, timeoutSeconds);
const manifestYaml = `${Bun.YAML.stringify(manifest).trim()}\n`;
const script = [
"set -eu",
"tmp=$(mktemp)",
"base64 -d >\"$tmp\" <<'UNIDESK_CICD_DEBUG_JOB_B64'",
Buffer.from(manifestYaml, "utf8").toString("base64"),
"UNIDESK_CICD_DEBUG_JOB_B64",
`kubectl -n ${shQuote(registry.controller.namespace)} delete job ${shQuote(jobName)} --ignore-not-found=true >/dev/null 2>&1 || true`,
`kubectl apply --server-side --force-conflicts --field-manager=${shQuote(registry.controller.fieldManager)} -f "$tmp" >/dev/null`,
waitForJobShell(registry.controller.namespace, jobName, timeoutSeconds),
].join("\n");
const result = deps.runKubeScript(registry, options, script, "", (timeoutSeconds + registry.controller.budgets.reconcileTransportGraceSeconds) * 1000);
const parsed = parseLastJsonObject(result.stdout);
const state = deps.readK8sState(registry, options);
const followerId = options.followerId ?? "";
const compact = compactTargetDebugResult(parsed);
const ok = result.exitCode === 0 && parsed?.ok !== false;
const includeTargetTail = !ok || parsed === null;
const fallbackStateAfter = stateSnapshot(state, followerId);
return {
ok,
action: "debug-step",
step,
follower: followerId,
execution: "k8s-native-debug-job",
dryRun: !options.confirm,
stateBefore: compact?.stateBefore ?? compactStateLike(asOptionalRecord(parsed?.stateBefore)),
controllerSource: compact?.controllerSource ?? asOptionalRecord(parsed?.controllerSource),
status: compact?.status ?? null,
decision: compact?.decision ?? null,
stateWrite: compact?.stateWrite ?? null,
target: {
name: jobName,
namespace: registry.controller.namespace,
exitCode: result.exitCode,
timedOut: result.timedOut,
parsed: parsed !== null,
stdoutTail: includeTargetTail ? redactText(tailText(result.stdout, 1000)) : "",
stderrTail: includeTargetTail ? redactText(tailText(result.stderr, 800)) : "",
},
stateAfter: compact?.stateAfter ?? compactStateLike(asOptionalRecord(parsed?.stateAfter) ?? fallbackStateAfter),
parsedDownstreamCliOutput: false,
next: debugNext(followerId),
};
}
function compactTargetDebugResult(parsed: Record<string, unknown> | null): Record<string, unknown> | null {
if (parsed === null) return null;
const stateBefore = asOptionalRecord(parsed.stateBefore);
const controllerSource = asOptionalRecord(parsed.controllerSource);
const status = asOptionalRecord(parsed.status);
const decision = asOptionalRecord(parsed.decision);
const stateWrite = asOptionalRecord(parsed.stateWrite);
const stateAfter = asOptionalRecord(parsed.stateAfter);
return {
ok: parsed.ok === true,
action: stringOrNull(parsed.action),
step: stringOrNull(parsed.step),
follower: stringOrNull(parsed.follower),
stateBefore: compactStateLike(stateBefore),
controllerSource: controllerSource === null ? null : {
ok: controllerSource.ok === true,
repository: stringOrNull(controllerSource.repository),
branch: stringOrNull(controllerSource.branch),
head: stringOrNull(controllerSource.head),
registrySha256: stringOrNull(controllerSource.registrySha256),
closeoutRereadMarker: controllerSource.closeoutRereadMarker === true,
branchFollowerFile: asOptionalRecord(controllerSource.branchFollowerFile),
errors: Array.isArray(controllerSource.errors) ? controllerSource.errors.map(String).slice(0, 5) : [],
},
status: status === null ? null : {
ok: status.ok === true,
phase: stringOrNull(status.phase),
observedSha: stringOrNull(status.observedSha),
targetSha: stringOrNull(status.targetSha),
aligned: status.aligned === true ? true : status.aligned === false ? false : null,
pipelineRun: stringOrNull(status.pipelineRun),
message: stringOrNull(status.message),
gates: asOptionalRecord(status.gates),
},
decision: decision === null ? null : {
phase: stringOrNull(decision.phase),
observedSha: stringOrNull(decision.observedSha),
targetSha: stringOrNull(decision.targetSha),
decision: stringOrNull(decision.decision),
totalSeconds: numberOrNull(asOptionalRecord(decision.timings)?.totalSeconds),
totalStatus: stringOrNull(asOptionalRecord(decision.timings)?.totalStatus),
},
stateWrite,
stateAfter: compactStateLike(stateAfter),
};
}
function compactStateLike(value: Record<string, unknown> | null): Record<string, unknown> | null {
if (value === null) return null;
const metadata = asOptionalRecord(value.metadata);
return {
ok: value.ok === true,
phase: stringOrNull(value.phase),
observedSha: stringOrNull(value.observedSha),
targetSha: stringOrNull(value.targetSha),
totalSeconds: numberOrNull(value.totalSeconds),
timingStatus: stringOrNull(value.timingStatus),
resourceVersion: stringOrNull(metadata?.resourceVersion),
rawStateDiagnostic: asOptionalRecord(value.rawStateDiagnostic),
};
}
function controllerSourceSnapshot(registry: BranchFollowerRegistry): Record<string, unknown> {
const head = commandText(["git", "rev-parse", "HEAD"]);
const branch = commandText(["git", "symbolic-ref", "--short", "HEAD"]);
@@ -573,7 +458,6 @@ function debugNext(followerId: string): Record<string, string> {
controllerSource: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step controller-source`,
statusRead: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step status-read`,
decide: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step decide`,
stateWrite: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step state-write --confirm`,
};
}
+577
View File
@@ -0,0 +1,577 @@
import { describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { runAgentRunCommand, agentRunHelpText } from "./agentrun/entry";
import { cicdHelp as branchFollowerHelp, runCicdCommand as runBranchFollowerCommand } from "./cicd-branch-follower";
import { branchFollowerReadOnlyNext } from "./cicd-branch-follower-authority";
import { cicdGiteaActionsPocHelp, runGiteaActionsPocCommand } from "./cicd-gitea-actions-poc";
import { unsupported as unsupportedAgentRunCommand } from "./agentrun/utils";
import {
PAC_AUTOMATIC_DELIVERY_REFERENCE,
decideCicdDeliveryMutation,
gitRepositoryIdentity,
pacReadOnlyNext,
readCicdDeliveryAuthorityCatalog,
resolveCicdDeliveryAuthority,
resolveCicdDeliveryAuthorityFromCatalog,
type CicdDeliveryAuthorityCatalog,
} from "./cicd-delivery-authority";
import { hwlabNodeHelp } from "./hwlab-node-help";
import { runHwlabNodeCommand } from "./hwlab-node/entry";
import {
guardedSentinelDeliveryAction,
runWebProbeSentinelCommand,
webProbeSentinelDeliveryAuthorityFromCatalog,
} from "./hwlab-node-web-sentinel-cicd";
import { parseNodeScopedDelegatedOptions } from "./hwlab-node/plan";
import { parseNodeWebProbeSentinelOptions } from "./hwlab-node/web-probe-observe";
import { nodeRuntimeVisiblePipelineRunDiagnostics, summarizeNodeRuntimeControlPlaneStatus } from "./hwlab-node/render";
import { renderControlPlaneResult, renderPublishCurrentResult } from "./hwlab-node-web-sentinel-cicd-shared";
import { resolvePacHistoryConsumerSelection, runPlatformInfraPipelinesAsCodeCommand } from "./platform-infra-pipelines-as-code";
import { giteaHelp, runPlatformInfraGiteaCommand } from "./platform-infra-gitea";
import { platformInfraHelp } from "./platform-infra/entry";
import {
authorizeSentinelPacInternalPublish,
readSentinelPacInternalPublishCapability,
type SentinelPacInternalPublishCapability,
} from "./sentinel-pac-execution-context";
import type { BranchFollowerRegistry, FollowerSpec, ParsedOptions } from "./cicd-types";
const mutationCommand = /\b(?:apply|closeout|trigger-current|refresh|sync|flush|webhook-test)\b/u;
const pacEvaluator = createRequire(import.meta.url)("../native/cicd/pac-status-evaluator.cjs") as {
classifyPacPipelineRun: (consumer: Record<string, unknown>, item: Record<string, unknown>) => Record<string, unknown>;
};
describe("YAML 组合的 CI/CD delivery authority", () => {
test("组合 PaC 与 Gitea YAML,覆盖所有实际 consumer 且 id 唯一", () => {
const catalog = readCicdDeliveryAuthorityCatalog();
expect(catalog.consumers).toHaveLength(8);
expect(new Set(catalog.consumers.map((item) => item.consumerId)).size).toBe(8);
for (const consumerId of ["agentrun-nc01-v02", "hwlab-nc01-v03", "sentinel-nc01-v03", "unidesk-host", "platform-infra-gitea-nc01"]) {
const authority = resolveCicdDeliveryAuthority({ consumerId });
expect(authority.kind).toBe("pac-pr-merge");
expect(authority.mutationHintsAllowed).toBe(false);
}
});
test("repository identity 从通用 Git remote 解析,不依赖仓库特例", () => {
expect(gitRepositoryIdentity("git@github.com:acme/widgets.git")).toBe("acme/widgets");
expect(gitRepositoryIdentity("https://example.test/acme/widgets.git")).toBe("acme/widgets");
expect(gitRepositoryIdentity("not-a-repository")).toBeNull();
});
test("未知、冲突和歧义 authority 均 fail-closed", () => {
expect(resolveCicdDeliveryAuthority({ node: "NC01", lane: "v03" })).toMatchObject({
kind: "unknown",
reason: "authority-ambiguous",
mutationHintsAllowed: false,
});
expect(resolveCicdDeliveryAuthority({ consumerId: "hwlab-nc01-v03", node: "JD01" })).toMatchObject({
kind: "unknown",
reason: "authority-identity-mismatch",
mutationHintsAllowed: false,
});
expect(resolveCicdDeliveryAuthority({ declaredSourceAuthorityMode: "giteaSnapshot", declaredConfigRef: "fixture.yaml" })).toMatchObject({
kind: "unknown",
reason: "authority-not-declared",
mutationHintsAllowed: false,
});
expect(resolveCicdDeliveryAuthority({
node: "NC01",
lane: "nc01-v02",
sourceRepository: "acme/drifted",
sourceBranch: "v0.2",
declaredSourceAuthorityMode: "gitMirrorSnapshot",
declaredConfigRef: "config/agentrun.yaml",
})).toMatchObject({
kind: "unknown",
reason: "authority-identity-mismatch",
mutationHintsAllowed: false,
});
});
test("只有显式 legacy-manual authority 允许旧 mutation 实现", () => {
const catalog: CicdDeliveryAuthorityCatalog = {
consumers: [{
consumerId: "arbitrary-pac",
node: "NODE-A",
lane: "lane-a",
namespace: "ci-a",
pipelineRunPrefix: "widgets-a-",
repositoryRef: "repo-a",
sourceRepository: "acme/widgets",
sourceBranch: "main",
sourceSnapshotPrefix: "refs/unidesk/snapshots/widgets/main",
giteaOwner: "mirrors",
giteaRepository: "acme-widgets",
giteaRepositoryUrl: "https://gitea.example.test/mirrors/acme-widgets",
pacControllerVersion: "v0.48.0",
configRefs: ["pac.yaml", "gitea.yaml"],
}],
configRefs: ["pac.yaml", "gitea.yaml"],
};
const legacy = resolveCicdDeliveryAuthorityFromCatalog(catalog, {
node: "NODE-B",
lane: "lane-b",
sourceRepository: "acme/legacy",
sourceBranch: "release",
declaredSourceAuthorityMode: "gitMirrorSnapshot",
declaredConfigRef: "legacy-lanes.yaml",
});
expect(legacy).toMatchObject({ kind: "legacy-manual", mutationHintsAllowed: true });
expect(decideCicdDeliveryMutation(legacy, {
command: "fixture trigger-current",
statusCommand: "fixture status",
action: "trigger-current",
node: "NODE-B",
lane: "lane-b",
})).toMatchObject({ allowed: true });
});
test("只读 Next 只有 status/history 和稳定自动链修复引用", () => {
const next = pacReadOnlyNext("NC01", "hwlab-nc01-v03");
expect(Object.keys(next).sort()).toEqual(["fixAutomaticDelivery", "history", "status", "valuesPrinted"]);
expect(JSON.stringify(next)).not.toMatch(mutationCommand);
expect(PAC_AUTOMATIC_DELIVERY_REFERENCE).toBe("docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界");
expect(PAC_AUTOMATIC_DELIVERY_REFERENCE).not.toContain("issues/");
});
});
describe("migrated CLI 执行 guard 与提示清理", () => {
test("AgentRun migrated trigger/refresh/mirror mutation 在远端调用前结构化拒绝", async () => {
const commands = [
["control-plane", "trigger-current", "--node", "NC01", "--lane", "nc01-v02", "--confirm"],
["control-plane", "refresh", "--node", "NC01", "--lane", "nc01-v02", "--confirm"],
["git-mirror", "sync", "--node", "NC01", "--lane", "nc01-v02", "--confirm", "--wait"],
["git-mirror", "flush", "--node", "NC01", "--lane", "nc01-v02", "--confirm", "--wait"],
];
for (const args of commands) {
const result = await runAgentRunCommand({} as never, args) as Record<string, unknown>;
expect(result).toMatchObject({ ok: false, mode: "pac-pr-merge-mutation-blocked", mutation: false });
expect(JSON.stringify(result.next)).not.toMatch(mutationCommand);
}
});
test("HWLAB migrated delivery mutation 在异步 job 创建前结构化拒绝", async () => {
const commands = [
["control-plane", "trigger-current", "--node", "NC01", "--lane", "v03", "--confirm"],
["control-plane", "refresh", "--node", "NC01", "--lane", "v03", "--confirm"],
["control-plane", "sync", "--node", "NC01", "--lane", "v03", "--confirm"],
["control-plane", "source-workspace", "sync", "--node", "NC01", "--lane", "v03", "--confirm"],
["git-mirror", "sync", "--node", "NC01", "--lane", "v03", "--confirm", "--wait"],
["git-mirror", "flush", "--node", "NC01", "--lane", "v03", "--confirm", "--wait"],
];
for (const args of commands) {
const result = await runHwlabNodeCommand({} as never, args) as Record<string, unknown>;
expect(result).toMatchObject({ ok: false, mode: "pac-pr-merge-mutation-blocked", mutation: false });
expect(JSON.stringify(result.next)).not.toMatch(mutationCommand);
}
});
test("Web sentinel migrated delivery mutation 在 state load 和异步 job 前拒绝", () => {
const spec = parseNodeScopedDelegatedOptions("control-plane", ["status", "--node", "NC01", "--lane", "v03"]).spec;
const sourceOverride = { sourceCommit: null, sourceStageRef: null, sourceMirrorCommit: null, sourceAuthority: null };
const commands = [
{ kind: "image", action: "build", node: "NC01", lane: "v03", sentinelId: null, dryRun: true, confirm: false, wait: false, timeoutSeconds: 30, sourceOverride },
{ kind: "control-plane", action: "trigger-current", node: "NC01", lane: "v03", sentinelId: null, dryRun: true, confirm: false, wait: false, timeoutSeconds: 30, rerun: false, manualRecovery: false, reason: null, sourceOverride },
{ kind: "publish", action: "publish-current", node: "NC01", lane: "v03", sentinelId: null, dryRun: true, confirm: false, wait: false, timeoutSeconds: 30, rerun: false, manualRecovery: true, reason: "explicit recovery", sourceOverride },
];
for (const options of commands) {
const result = runWebProbeSentinelCommand(spec, options as never) as unknown as Record<string, unknown>;
expect(result).toMatchObject({ ok: false, mode: "pac-pr-merge-mutation-blocked", mutation: false });
expect(Object.keys(result.next as Record<string, unknown>).sort()).toEqual([
"controlPlaneStatus",
"fixAutomaticDelivery",
"history",
"pacHistory",
"pacStatus",
"status",
]);
expect(JSON.stringify(result.next)).not.toMatch(mutationCommand);
}
});
test("Web sentinel 稳定 PaC consumer 漂移或缺失时保持 unknown,不降级为 legacy", () => {
const spec = parseNodeScopedDelegatedOptions("control-plane", ["status", "--node", "NC01", "--lane", "v03"]).spec;
const catalog = readCicdDeliveryAuthorityCatalog();
const consumerId = "sentinel-nc01-v03";
const drifted: CicdDeliveryAuthorityCatalog = {
...catalog,
consumers: catalog.consumers.map((consumer) => consumer.consumerId === consumerId
? { ...consumer, node: "DRIFTED" }
: consumer),
};
const missing: CicdDeliveryAuthorityCatalog = {
...catalog,
consumers: catalog.consumers.filter((consumer) => consumer.consumerId !== consumerId),
};
for (const fixture of [drifted, missing]) {
const authority = webProbeSentinelDeliveryAuthorityFromCatalog(spec, fixture);
expect(authority).toMatchObject({ kind: "unknown", mutationHintsAllowed: false });
const decision = decideCicdDeliveryMutation(authority, {
command: "web-probe sentinel publish-current",
statusCommand: "web-probe sentinel control-plane status",
action: "sentinel-publish-current",
node: spec.nodeId,
lane: spec.lane,
});
expect(decision).toMatchObject({ allowed: false });
if (!decision.allowed) expect(JSON.stringify(decision.result.next)).not.toMatch(mutationCommand);
}
});
test("Web sentinel 内部 publish capability 默认关闭,启用后仍因 admission provenance 缺失而拒绝", () => {
const authority = resolveCicdDeliveryAuthority({ consumerId: "sentinel-nc01-v03" });
expect(authority.kind).toBe("pac-pr-merge");
const options = {
kind: "publish",
action: "publish-current",
node: "NC01",
lane: "v03",
sentinelId: "nc01-web-probe-sentinel",
dryRun: false,
confirm: true,
wait: true,
timeoutSeconds: 180,
rerun: false,
manualRecovery: false,
reason: null,
internalExecution: "pac-controller",
sourceOverride: { sourceCommit: "a".repeat(40), sourceStageRef: null, sourceMirrorCommit: null, sourceAuthority: "gitea-snapshot" },
} as const;
const capability = readSentinelPacInternalPublishCapability();
expect(capability).toMatchObject({ enabled: false, admissionProvenance: "unavailable" });
expect(authorizeSentinelPacInternalPublish(options, authority, capability)).toMatchObject({
allowed: false,
reason: "capability-disabled",
});
const enabled: SentinelPacInternalPublishCapability = { ...capability, enabled: true };
expect(authorizeSentinelPacInternalPublish(options, authority, enabled)).toMatchObject({
allowed: false,
reason: "admission-provenance-unavailable",
});
expect(authorizeSentinelPacInternalPublish({ ...options, internalExecution: null }, authority, enabled)).toMatchObject({
allowed: false,
reason: "operator-entrypoint",
});
});
test("Web sentinel 保持旧自动入口,不把 default SA、Pod env 或可写 metadata 当 creator proof", () => {
const pacYaml = readFileSync("config/platform-infra/pipelines-as-code.yaml", "utf8");
expect(pacYaml).toContain("sentinelInternalPublish:");
expect(pacYaml).toContain("enabled: false");
expect(pacYaml).toContain("issues/1769");
expect(pacYaml).toContain("service_account: default");
for (const node of ["jd01", "nc01"] as const) {
const source = readFileSync(`.tekton/web-probe-sentinel-${node}-pac.yaml`, "utf8");
expect(source).toContain("serviceAccountName: default");
expect(source).toContain("web-probe sentinel publish-current \\");
expect(source).not.toContain("pac-publish-current");
expect(source).not.toContain("UNIDESK_PAC_INTERNAL_ENTRYPOINT");
expect(source).not.toContain("UNIDESK_PAC_POD_UID");
}
const parsed = parseNodeWebProbeSentinelOptions([
"pac-publish-current",
"--node", "NC01",
"--lane", "v03",
"--sentinel", "nc01-web-probe-sentinel",
"--confirm",
"--wait",
"--source-commit", "a".repeat(40),
"--source-stage-ref", `refs/unidesk/snapshots/gitea-actions/unidesk-master-nc01/${"a".repeat(40)}`,
"--source-authority", "gitea-snapshot",
]);
expect(parsed.sentinel).toMatchObject({ kind: "publish", internalExecution: "pac-controller", manualRecovery: false });
});
test("PaC 外层事件与内层 deterministic publish 分开归类,只有外层可驱动 delivery", () => {
const consumer = { repository: "sentinel-nc01-v03", pipelineRunPrefix: "hwlab-web-probe-sentinel-nc01-", pipeline: "hwlab-web-probe-sentinel-nc01" };
const outer = {
metadata: {
name: "hwlab-web-probe-sentinel-nc01-9h49j",
labels: {
"app.kubernetes.io/managed-by": "pipelinesascode.tekton.dev",
"pipelinesascode.tekton.dev/event-type": "push",
"pipelinesascode.tekton.dev/repository": "sentinel-nc01-v03",
},
annotations: { "pipelinesascode.tekton.dev/git-provider": "gitea" },
},
};
const inner = {
metadata: {
name: "hwlab-web-probe-sentinel-nc01-web-probe-sentinel-b125fb358d46",
labels: {
"app.kubernetes.io/part-of": "hwlab-web-probe-sentinel",
"unidesk.ai/ci-system": "tekton",
},
annotations: {
"unidesk.ai/source-authority": "gitea-snapshot",
"unidesk.ai/publish-gitops": "true",
},
},
};
expect(pacEvaluator.classifyPacPipelineRun(consumer, outer)).toMatchObject({
deliveryClass: "outer-pac-event",
deliveryAuthorityEligible: true,
admissionProvenanceVerified: false,
metadataObservationOnly: true,
});
expect(pacEvaluator.classifyPacPipelineRun(consumer, inner)).toMatchObject({
deliveryClass: "inner-deterministic-publish",
deliveryAuthorityEligible: false,
parentPipelineRun: null,
parentRelation: "unproven",
});
const catalog = readCicdDeliveryAuthorityCatalog();
for (const consumerId of ["agentrun-nc01-v02", "hwlab-nc01-v03", "sentinel-nc01-v03", "unidesk-host"]) {
const configured = catalog.consumers.find((item) => item.consumerId === consumerId);
if (configured === undefined) throw new Error(`missing classification fixture ${consumerId}`);
const classified = pacEvaluator.classifyPacPipelineRun({
repository: configured.repositoryRef,
pipelineRunPrefix: configured.pipelineRunPrefix,
}, {
metadata: {
name: `${configured.pipelineRunPrefix}-outer-fixture`,
labels: {
"app.kubernetes.io/managed-by": "pipelinesascode.tekton.dev",
"pipelinesascode.tekton.dev/event-type": "push",
"pipelinesascode.tekton.dev/repository": configured.repositoryRef,
},
annotations: { "pipelinesascode.tekton.dev/git-provider": "gitea" },
},
});
expect(classified).toMatchObject({ deliveryClass: "outer-pac-event", deliveryAuthorityEligible: true });
}
});
test("Web sentinel 业务观测和仪表盘动作不被 delivery guard 误阻断", () => {
for (const options of [
{ kind: "dashboard", action: "trigger" },
{ kind: "validate", action: "validate" },
{ kind: "maintenance", action: "status" },
{ kind: "control-plane", action: "status" },
{ kind: "image", action: "status" },
]) {
expect(guardedSentinelDeliveryAction(options as never)).toBeNull();
}
});
test("HWLAB degraded summary 不再返回 flush 或 triggerCurrent", () => {
const scoped = parseNodeScopedDelegatedOptions("control-plane", ["status", "--node", "NC01", "--lane", "v03"]);
const summary = summarizeNodeRuntimeControlPlaneStatus({ ok: false, degradedReason: "git-mirror-pending-flush" }, scoped);
expect(summary.nextAction).toBe("bun scripts/cli.ts platform-infra pipelines-as-code status --target NC01 --consumer hwlab-nc01-v03");
expect(summary.next).not.toHaveProperty("triggerCurrent");
expect(summary.next).not.toHaveProperty("gitMirrorFlush");
expect(JSON.stringify(summary.next)).not.toMatch(mutationCommand);
});
test("HWLAB migrated PipelineRun diagnostics 不泄漏 rerun Next 或文本提示", () => {
const authority = resolveCicdDeliveryAuthority({ consumerId: "hwlab-nc01-v03" });
const diagnostics = nodeRuntimeVisiblePipelineRunDiagnostics({
failureSummary: { nextAction: "choose controlled rerun" },
next: { rerun: "bun scripts/cli.ts hwlab nodes control-plane trigger-current --confirm --rerun" },
}, authority);
expect(diagnostics?.next).toBeNull();
expect(JSON.stringify(diagnostics)).not.toContain("trigger-current");
expect(JSON.stringify(diagnostics)).not.toContain("controlled rerun");
expect(JSON.stringify(diagnostics)).toContain("不得人工 rerun");
});
test("默认 help 只暴露观察入口,legacy/platform 写入口只在 scoped help", async () => {
const agentRunDefault = agentRunHelpText(["control-plane", "--help"]);
const hwlabDefault = JSON.stringify(hwlabNodeHelp());
const pacDefault = JSON.stringify(await runPlatformInfraPipelinesAsCodeCommand({} as never, ["help"]));
const giteaDefault = JSON.stringify(giteaHelp());
expect(agentRunDefault).not.toMatch(mutationCommand);
expect(hwlabDefault).not.toMatch(mutationCommand);
expect(pacDefault).not.toMatch(mutationCommand);
expect(giteaDefault).not.toMatch(mutationCommand);
expect(agentRunHelpText(["control-plane", "legacy-cicd", "--help"])).toContain("trigger-current");
expect(JSON.stringify(hwlabNodeHelp("legacy-cicd"))).toContain("git-mirror flush");
expect(JSON.stringify(await runPlatformInfraPipelinesAsCodeCommand({} as never, ["help", "platform-bootstrap"]))).toContain("pipelines-as-code apply");
expect(JSON.stringify(giteaHelp("platform-bootstrap"))).toContain("platform-infra gitea apply");
});
test("所有 PaC consumer 与 AgentRun/HWLAB plan 均只返回 authority-aware 只读 Next", async () => {
const results = [
await runAgentRunCommand({} as never, ["control-plane", "plan", "--node", "NC01", "--lane", "nc01-v02", "--raw"]),
await runHwlabNodeCommand({} as never, ["control-plane", "plan", "--node", "NC01", "--lane", "v03", "--raw"]),
...await Promise.all(readCicdDeliveryAuthorityCatalog().consumers.map((consumer) => (
runPlatformInfraPipelinesAsCodeCommand({} as never, ["plan", "--target", consumer.node, "--consumer", consumer.consumerId, "--raw"])
))),
] as Record<string, unknown>[];
for (const result of results) {
const next = result.next as Record<string, unknown>;
expect(Object.keys(next).sort()).toEqual(["fixAutomaticDelivery", "history", "status", "valuesPrinted"]);
expect(JSON.stringify(next)).not.toMatch(mutationCommand);
}
});
test("Gitea 与 mirror 默认 plan 只返回只读 Next", async () => {
const plans = [
await runPlatformInfraGiteaCommand({} as never, ["plan", "--target", "NC01", "--raw"]),
await runPlatformInfraGiteaCommand({} as never, ["mirror", "plan", "--target", "NC01", "--raw"]),
] as Record<string, unknown>[];
for (const plan of plans) {
expect(plan.mutation).toBe(false);
expect(JSON.stringify(plan.next)).not.toMatch(mutationCommand);
expect(JSON.stringify(plan.next)).toContain("status");
expect(JSON.stringify(plan.next)).toContain(PAC_AUTOMATIC_DELIVERY_REFERENCE);
}
});
test("PaC history detail 只按唯一 consumer 生成 Next,四类前缀无默认 consumer 回退", () => {
const ids = ["sentinel-nc01-v03", "unidesk-host", "agentrun-nc01-v02", "hwlab-nc01-v03"];
const consumers = readCicdDeliveryAuthorityCatalog().consumers.filter((consumer) => ids.includes(consumer.consumerId)).map((consumer) => ({
id: consumer.consumerId,
node: consumer.node,
pipelineRunPrefix: consumer.pipelineRunPrefix,
}));
for (const consumer of consumers) {
const detailId = `${consumer.pipelineRunPrefix}-fixture`;
expect(resolvePacHistoryConsumerSelection(consumers, "NC01", null, detailId)).toEqual({
selectedConsumerIds: [consumer.id],
nextConsumerId: consumer.id,
});
}
expect(() => resolvePacHistoryConsumerSelection(consumers, "NC01", null, "no-matching-pipelinerun")).toThrow("matched 0");
expect(() => resolvePacHistoryConsumerSelection([
{ id: "a", node: "NC01", pipelineRunPrefix: "overlap-" },
{ id: "b", node: "NC01", pipelineRunPrefix: "overlap-run" },
], "NC01", null, "overlap-run-1")).toThrow("matched 2");
expect(() => resolvePacHistoryConsumerSelection(consumers, "NC01", "hwlab-nc01-v03", "hwlab-web-probe-sentinel-nc01-fixture")).toThrow("belongs to sentinel-nc01-v03");
});
test("PaC、HWLAB scoped help 的真实 CLI 路由可达且默认入口不泄漏 delivery mutation", () => {
const pacDefault = runCliJson(["platform-infra", "pipelines-as-code", "--help"]);
const pacBootstrap = runCliJson(["platform-infra", "pipelines-as-code", "help", "platform-bootstrap"]);
const pacCompatibility = runCliJson(["platform-infra", "pipelines-as-code", "help", "compatibility-diagnostics"]);
const hwlabPlatform = runCliJson(["hwlab", "nodes", "control-plane", "platform-maintenance", "--help"]);
const hwlabLegacy = runCliJson(["hwlab", "nodes", "control-plane", "legacy-cicd", "--help"]);
expect(JSON.stringify(pacDefault.data)).not.toMatch(mutationCommand);
expect(JSON.stringify(pacBootstrap.data)).toContain("pipelines-as-code apply");
expect(JSON.stringify(pacCompatibility.data)).not.toMatch(/webhook-test|trigger-current|\bsync\b|\bflush\b|apply --/u);
expect((hwlabPlatform.data as Record<string, unknown>).command).toBe("hwlab nodes control-plane platform-maintenance");
expect((hwlabLegacy.data as Record<string, unknown>).command).toBe("hwlab nodes control-plane legacy-cicd");
});
test("PaC webhook-test 已从公开路由与远端执行器移除", async () => {
const result = await runPlatformInfraPipelinesAsCodeCommand({} as never, ["webhook-test", "--target", "NC01", "--confirm"]) as Record<string, unknown>;
expect(result).toMatchObject({ ok: false, error: "unsupported-platform-infra-pipelines-as-code-command" });
expect(JSON.stringify(result.help)).not.toContain("webhook-test");
expect(readFileSync("scripts/src/platform-infra-pipelines-as-code-remote.sh", "utf8")).not.toContain("webhook-test");
});
test("Gitea webhook-test 已从公开路由与远端执行器移除", async () => {
const result = await runPlatformInfraGiteaCommand({} as never, ["mirror", "webhook", "test", "--target", "NC01", "--confirm"]) as Record<string, unknown>;
expect(result).toMatchObject({ ok: false, error: "unsupported-platform-infra-gitea-mirror-webhook-command" });
expect(readFileSync("scripts/src/platform-infra-gitea-remote.sh", "utf8")).not.toContain("mirror-webhook-test");
});
test("Sentinel migrated 文本输出只渲染实际只读 status/history/fix,不打印空 mutation 标签", () => {
const next = {
status: "bun scripts/cli.ts web-probe sentinel control-plane status --node NC01 --lane v03",
history: "bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer sentinel-nc01-v03",
fixAutomaticDelivery: PAC_AUTOMATIC_DELIVERY_REFERENCE,
};
for (const renderedText of [
renderPublishCurrentResult({ command: "fixture publish", ok: false, next }),
renderControlPlaneResult({ command: "fixture status", ok: false, next }),
]) {
expect(renderedText).toContain("status:");
expect(renderedText).toContain("history:");
expect(renderedText).toContain("fix-automatic-delivery:");
expect(renderedText).not.toMatch(/pac-closeout:|manual-recovery:|trigger-current:|apply:|sync:|flush:/u);
}
});
test("retired branch-follower 写操作和默认 cleanup 在任何远端执行前结构化拒绝", async () => {
const commands = [
["branch-follower", "apply", "--confirm", "--json"],
["branch-follower", "run-once", "--follower", "hwlab-jd01-v03", "--confirm", "--wait", "--json"],
["branch-follower", "job", "--follower", "agentrun-jd01-v02", "--job", "image-build", "--json"],
["branch-follower", "gate", "--follower", "hwlab-jd01-v03", "--gate", "runtime-closeout", "--confirm", "--json"],
["branch-follower", "debug-step", "--follower", "web-probe-sentinel-master", "--step", "state-write", "--confirm", "--json"],
["branch-follower", "cleanup-state", "--all", "--confirm", "--json"],
];
for (const args of commands) {
const rendered = await runBranchFollowerCommand(null, args);
const payload = JSON.parse(rendered.renderedText) as Record<string, unknown>;
expect(payload).toMatchObject({ ok: false, mutation: false });
expect(JSON.stringify(payload.next)).not.toMatch(mutationCommand);
}
const forged = await runBranchFollowerCommand(null, ["branch-follower", "legacy-cicd", "apply", "--config", "/tmp/forged-legacy.yaml", "--confirm", "--json"]);
expect(JSON.parse(forged.renderedText)).toMatchObject({
ok: false,
mutation: false,
mode: "noncanonical-branch-follower-config-mutation-blocked",
});
expect(Object.keys((JSON.parse(forged.renderedText) as { next: Record<string, unknown> }).next).sort()).toEqual([
"fixAutomaticDelivery",
"status",
"valuesPrinted",
]);
await expect(runBranchFollowerCommand(null, ["branch-follower", "plan", "--json"])).rejects.toThrow("default surface is read-only");
});
test("branch-follower unknown 与多 node scope 不猜测 JD01 PaC 入口", () => {
const options = { followerId: null } as ParsedOptions;
const unknownRegistry = {
followers: [{
id: "unknown-fixture",
deliveryAuthority: "unknown",
source: { repository: "acme/unknown", branch: "main" },
target: { node: "NODE-X", lane: "lane-x" },
} as FollowerSpec],
} as BranchFollowerRegistry;
const unknownNext = branchFollowerReadOnlyNext(unknownRegistry, options);
expect(Object.keys(unknownNext).sort()).toEqual(["events", "fixAutomaticDelivery", "logs", "status", "valuesPrinted"]);
expect(JSON.stringify(unknownNext)).not.toContain("JD01");
const catalog = readCicdDeliveryAuthorityCatalog();
const consumers = ["hwlab-jd01-v03", "hwlab-nc01-v03"].map((id) => {
const consumer = catalog.consumers.find((item) => item.consumerId === id);
if (consumer === undefined) throw new Error(`missing fixture consumer ${id}`);
return {
id: `${id}-retired-follower`,
deliveryAuthority: "retired-readonly",
source: { repository: consumer.sourceRepository, branch: consumer.sourceBranch },
target: { node: consumer.node, lane: consumer.lane },
} as FollowerSpec;
});
const multiNodeNext = branchFollowerReadOnlyNext({ followers: consumers } as BranchFollowerRegistry, options);
expect(Object.keys(multiNodeNext).sort()).toEqual(["events", "fixAutomaticDelivery", "logs", "status", "valuesPrinted"]);
expect(JSON.stringify(multiNodeNext)).not.toContain("--target JD01");
});
test("retired branch-follower 与 Gitea Actions POC 默认帮助只读", async () => {
expect(JSON.stringify(branchFollowerHelp())).not.toMatch(mutationCommand);
expect(JSON.stringify(cicdGiteaActionsPocHelp())).not.toMatch(mutationCommand);
await expect(runGiteaActionsPocCommand(null, ["apply"])).rejects.toThrow("intentionally unavailable");
});
test("platform-infra 默认 delivery 帮助和 AgentRun 失败帮助不暴露人工推进", () => {
const platformHelp = platformInfraHelp() as { usage: string[] };
const deliveryUsage = platformHelp.usage.filter((line) => line.includes("gitea") || line.includes("pipelines-as-code"));
expect(JSON.stringify(deliveryUsage)).not.toMatch(mutationCommand);
expect(deliveryUsage).toContain("bun scripts/cli.ts platform-infra gitea mirror webhook status --target NC01");
expect(deliveryUsage).toContain("bun scripts/cli.ts platform-infra pipelines-as-code help platform-bootstrap");
const agentRunOperations = unsupportedAgentRunCommand(["unexpected"]).renderedText.split("Operations:")[1] ?? "";
expect(agentRunOperations).not.toMatch(mutationCommand);
const root = runCliJson(["--help"]);
expect(JSON.stringify(root)).not.toMatch(/platform-infra gitea apply|platform-infra gitea mirror sync/u);
});
});
function runCliJson(args: string[]): { ok: boolean; command: string; data: unknown } {
const result = spawnSync("bun", ["scripts/cli.ts", ...args], {
cwd: process.cwd(),
encoding: "utf8",
timeout: 15_000,
});
if (result.status !== 0) throw new Error(`CLI ${args.join(" ")} failed: ${result.stderr}`);
return JSON.parse(result.stdout) as { ok: boolean; command: string; data: unknown };
}
+388
View File
@@ -0,0 +1,388 @@
import { rootPath } from "./config";
import { readYamlRecord } from "./platform-infra-ops-library";
import { materializeYamlComposition } from "./yaml-composition";
const pacConfigPath = "config/platform-infra/pipelines-as-code.yaml";
const giteaConfigPath = "config/platform-infra/gitea.yaml";
export const PAC_AUTOMATIC_DELIVERY_REFERENCE = "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界";
export interface PacDeliveryConsumerAuthority {
readonly consumerId: string;
readonly node: string;
readonly lane: string;
readonly namespace: string;
readonly pipelineRunPrefix: string;
readonly repositoryRef: string;
readonly sourceRepository: string;
readonly sourceBranch: string;
readonly sourceSnapshotPrefix: string;
readonly giteaOwner: string;
readonly giteaRepository: string;
readonly giteaRepositoryUrl: string;
readonly pacControllerVersion: string;
readonly configRefs: readonly [string, string];
}
export interface CicdDeliveryAuthorityCatalog {
readonly consumers: readonly PacDeliveryConsumerAuthority[];
readonly configRefs: readonly [string, string];
}
export interface CicdDeliveryAuthorityQuery {
readonly consumerId?: string | null;
readonly node?: string | null;
readonly lane?: string | null;
readonly sourceRepository?: string | null;
readonly sourceBranch?: string | null;
readonly declaredSourceAuthorityMode?: string | null;
readonly declaredConfigRef?: string | null;
}
export interface CicdDeliveryMutationContext {
readonly command: string;
readonly statusCommand: string;
readonly action: string;
readonly node: string;
readonly lane: string;
}
export type CicdDeliveryMutationDecision =
| {
readonly allowed: true;
readonly authority: Extract<CicdDeliveryAuthority, { kind: "legacy-manual" }>;
}
| {
readonly allowed: false;
readonly authority: Exclude<CicdDeliveryAuthority, { kind: "legacy-manual" }>;
readonly result: Record<string, unknown>;
};
export type CicdDeliveryAuthority =
| {
readonly kind: "pac-pr-merge";
readonly migrated: true;
readonly mutationHintsAllowed: false;
readonly consumer: PacDeliveryConsumerAuthority;
readonly reason: "exact-pac-consumer";
readonly valuesPrinted: false;
}
| {
readonly kind: "legacy-manual";
readonly migrated: false;
readonly mutationHintsAllowed: true;
readonly consumer: null;
readonly reason: "explicit-legacy-source-authority";
readonly declaredSourceAuthorityMode: string;
readonly declaredConfigRef: string;
readonly valuesPrinted: false;
}
| {
readonly kind: "unknown";
readonly migrated: false;
readonly mutationHintsAllowed: false;
readonly consumer: null;
readonly reason: "authority-config-invalid" | "authority-identity-mismatch" | "authority-not-declared" | "authority-ambiguous";
readonly detail: string;
readonly configRefs: readonly string[];
readonly valuesPrinted: false;
};
interface PacRepositoryRow {
readonly id: string;
readonly owner: string;
readonly repo: string;
readonly sourceBranch: string;
readonly sourceSnapshotPrefix: string;
readonly url: string;
}
interface GiteaRepositoryRow {
readonly targetId: string;
readonly owner: string;
readonly repo: string;
readonly sourceRepository: string;
readonly sourceBranch: string;
}
const legacyAuthorityModes = new Set(["gitMirrorSnapshot", "git-mirror-snapshot", "k8s-git-mirror-snapshot"]);
export function readCicdDeliveryAuthorityCatalog(): CicdDeliveryAuthorityCatalog {
const pacRoot = composedYaml(pacConfigPath, "platform-infra-pipelines-as-code");
const giteaRoot = composedYaml(giteaConfigPath, "platform-infra-gitea");
const release = record(pacRoot.release, `${pacConfigPath}#release`);
const pacControllerVersion = requiredString(release.version, `${pacConfigPath}#release.version`);
const pacRepositories = recordArray(pacRoot.repositories, `${pacConfigPath}#repositories`).map((item, index) => {
const path = `${pacConfigPath}#repositories[${index}]`;
const params = record(item.params, `${path}.params`);
return {
id: requiredString(item.id ?? item.name, `${path}.id`),
owner: requiredString(item.owner, `${path}.owner`),
repo: requiredString(item.repo, `${path}.repo`),
sourceBranch: requiredString(params.source_branch, `${path}.params.source_branch`),
sourceSnapshotPrefix: requiredString(params.source_snapshot_prefix, `${path}.params.source_snapshot_prefix`),
url: requiredString(item.url, `${path}.url`),
} satisfies PacRepositoryRow;
});
const sourceAuthority = record(giteaRoot.sourceAuthority, `${giteaConfigPath}#sourceAuthority`);
const giteaRepositories = recordArray(sourceAuthority.repositories, `${giteaConfigPath}#sourceAuthority.repositories`).map((item, index) => {
const path = `${giteaConfigPath}#sourceAuthority.repositories[${index}]`;
const upstream = record(item.upstream, `${path}.upstream`);
const gitea = record(item.gitea, `${path}.gitea`);
return {
targetId: requiredString(item.targetId, `${path}.targetId`),
owner: requiredString(gitea.owner, `${path}.gitea.owner`),
repo: requiredString(gitea.name, `${path}.gitea.name`),
sourceRepository: requiredString(upstream.repository, `${path}.upstream.repository`),
sourceBranch: requiredString(upstream.branch, `${path}.upstream.branch`),
} satisfies GiteaRepositoryRow;
});
const consumers = recordArray(pacRoot.consumers, `${pacConfigPath}#consumers`).map((item, index) => {
const path = `${pacConfigPath}#consumers[${index}]`;
const consumerId = requiredString(item.id, `${path}.id`);
const node = requiredString(item.node, `${path}.node`);
const lane = requiredString(item.lane, `${path}.lane`);
const namespace = requiredString(item.namespace, `${path}.namespace`);
const pipelineRunPrefix = requiredString(item.pipelineRunPrefix, `${path}.pipelineRunPrefix`);
const repositoryRef = requiredString(item.repositoryRef, `${path}.repositoryRef`);
const pacRepository = exactlyOne(
pacRepositories.filter((candidate) => same(candidate.id, repositoryRef)),
`${path}.repositoryRef=${repositoryRef}`,
);
const authorityRepository = exactlyOne(
giteaRepositories.filter((candidate) => same(candidate.targetId, node)
&& same(candidate.owner, pacRepository.owner)
&& same(candidate.repo, pacRepository.repo)
&& same(candidate.sourceBranch, pacRepository.sourceBranch)),
`${path} source authority ${node}/${pacRepository.owner}/${pacRepository.repo}@${pacRepository.sourceBranch}`,
);
return {
consumerId,
node,
lane,
namespace,
pipelineRunPrefix,
repositoryRef,
sourceRepository: authorityRepository.sourceRepository,
sourceBranch: authorityRepository.sourceBranch,
sourceSnapshotPrefix: pacRepository.sourceSnapshotPrefix,
giteaOwner: pacRepository.owner,
giteaRepository: pacRepository.repo,
giteaRepositoryUrl: pacRepository.url,
pacControllerVersion,
configRefs: [pacConfigPath, giteaConfigPath],
} satisfies PacDeliveryConsumerAuthority;
});
const duplicateIds = consumers.filter((item, index) => consumers.findIndex((candidate) => same(candidate.consumerId, item.consumerId)) !== index);
if (duplicateIds.length > 0) throw new Error(`${pacConfigPath} contains duplicate consumer ids: ${duplicateIds.map((item) => item.consumerId).join(", ")}`);
return { consumers, configRefs: [pacConfigPath, giteaConfigPath] };
}
export function resolveCicdDeliveryAuthority(query: CicdDeliveryAuthorityQuery): CicdDeliveryAuthority {
try {
return resolveCicdDeliveryAuthorityFromCatalog(readCicdDeliveryAuthorityCatalog(), query);
} catch (error) {
return {
kind: "unknown",
migrated: false,
mutationHintsAllowed: false,
consumer: null,
reason: "authority-config-invalid",
detail: error instanceof Error ? error.message : String(error),
configRefs: [pacConfigPath, giteaConfigPath],
valuesPrinted: false,
};
}
}
export function resolveCicdDeliveryAuthorityFromCatalog(catalog: CicdDeliveryAuthorityCatalog, query: CicdDeliveryAuthorityQuery): CicdDeliveryAuthority {
const suppliedIdentity = [query.consumerId, query.node, query.lane, query.sourceRepository, query.sourceBranch]
.some((value) => normalized(value) !== null);
const candidates = suppliedIdentity ? catalog.consumers.filter((consumer) => matchesQuery(consumer, query)) : [];
const stableCandidates = stableIdentityCandidates(catalog, query);
if (candidates.length === 1) {
return {
kind: "pac-pr-merge",
migrated: true,
mutationHintsAllowed: false,
consumer: candidates[0],
reason: "exact-pac-consumer",
valuesPrinted: false,
};
}
if (candidates.length > 1) {
return unknownAuthority("authority-ambiguous", `identity matches multiple PaC consumers: ${candidates.map((item) => item.consumerId).join(", ")}`, catalog.configRefs);
}
if (suppliedIdentity && query.consumerId !== undefined && query.consumerId !== null) {
return unknownAuthority("authority-identity-mismatch", `consumer identity does not match ${query.consumerId}`, catalog.configRefs);
}
if (stableCandidates.length > 0) {
return unknownAuthority(
stableCandidates.length > 1 ? "authority-ambiguous" : "authority-identity-mismatch",
`stable PaC identity conflicts with source details: ${stableCandidates.map((item) => item.consumerId).join(", ")}`,
catalog.configRefs,
);
}
const declaredMode = normalized(query.declaredSourceAuthorityMode);
const declaredConfigRef = normalized(query.declaredConfigRef);
if (declaredMode !== null && legacyAuthorityModes.has(declaredMode) && declaredConfigRef !== null) {
return {
kind: "legacy-manual",
migrated: false,
mutationHintsAllowed: true,
consumer: null,
reason: "explicit-legacy-source-authority",
declaredSourceAuthorityMode: declaredMode,
declaredConfigRef,
valuesPrinted: false,
};
}
return unknownAuthority(
suppliedIdentity ? "authority-identity-mismatch" : "authority-not-declared",
suppliedIdentity ? "no exact PaC consumer or explicit legacy authority matched" : "delivery authority identity is not declared",
catalog.configRefs,
);
}
function stableIdentityCandidates(catalog: CicdDeliveryAuthorityCatalog, query: CicdDeliveryAuthorityQuery): readonly PacDeliveryConsumerAuthority[] {
const consumerId = normalized(query.consumerId);
if (consumerId !== null) return catalog.consumers.filter((consumer) => same(consumer.consumerId, consumerId));
const node = normalized(query.node);
const lane = normalized(query.lane);
if (node === null || lane === null) return [];
return catalog.consumers.filter((consumer) => same(consumer.node, node) && same(consumer.lane, lane));
}
export function decideCicdDeliveryMutation(authority: CicdDeliveryAuthority, context: CicdDeliveryMutationContext): CicdDeliveryMutationDecision {
if (authority.kind === "legacy-manual") return { allowed: true, authority };
const next = authority.kind === "pac-pr-merge"
? pacReadOnlyNext(authority.consumer.node, authority.consumer.consumerId)
: {
status: context.statusCommand,
fixAutomaticDelivery: pacAutomaticDeliveryFix(authority),
valuesPrinted: false,
};
return {
allowed: false,
authority,
result: {
ok: false,
command: context.command,
mode: authority.kind === "pac-pr-merge" ? "pac-pr-merge-mutation-blocked" : "delivery-authority-unknown",
mutation: false,
requestedAction: context.action,
node: context.node,
lane: context.lane,
reason: authority.kind === "pac-pr-merge"
? "该 lane 已迁移到 PaCGitHub PR merge 是唯一交付触发,CLI 不执行人工推进。"
: "无法从 owning YAML 唯一确认 delivery authorityCLI 已 fail-closed。",
deliveryAuthority: authority,
next,
valuesPrinted: false,
},
};
}
export function gitRepositoryIdentity(remoteUrl: string): string | null {
const value = remoteUrl.trim();
if (value.length === 0) return null;
const scp = value.match(/^[^@\s]+@[^:\s]+:([^\s?#]+?)(?:\.git)?$/u);
if (scp !== null) return normalizedRepositoryPath(scp[1]);
try {
const url = new URL(value);
return normalizedRepositoryPath(url.pathname);
} catch {
return normalizedRepositoryPath(value);
}
}
export function pacAutomaticDeliveryFix(authority: CicdDeliveryAuthority): Record<string, unknown> {
const configRefs = authority.kind === "pac-pr-merge" ? authority.consumer.configRefs : authority.kind === "unknown" ? authority.configRefs : [authority.declaredConfigRef];
return automaticDeliveryFix(configRefs, authority.kind === "unknown" ? "fix-delivery-authority" : "fix-automatic-delivery");
}
export function pacReadOnlyNext(targetId: string, consumerId: string): Record<string, unknown> {
return {
status: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId} --consumer ${consumerId}`,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId} --consumer ${consumerId}`,
fixAutomaticDelivery: automaticDeliveryFix([pacConfigPath, giteaConfigPath], "fix-automatic-delivery"),
valuesPrinted: false,
};
}
export function pacNodeReadOnlyNext(targetId: string): Record<string, unknown> {
return {
status: `bun scripts/cli.ts cicd status --node ${targetId}`,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId} --limit 10`,
fixAutomaticDelivery: automaticDeliveryFix([pacConfigPath, giteaConfigPath], "fix-automatic-delivery"),
valuesPrinted: false,
};
}
function automaticDeliveryFix(configRefs: readonly string[], code: "fix-automatic-delivery" | "fix-delivery-authority"): Record<string, unknown> {
return {
code,
reference: PAC_AUTOMATIC_DELIVERY_REFERENCE,
configRefs,
instruction: "修复 owning YAML、controller 或源码中的自动链缺陷,并以新的正常 GitHub PR merge 验收;不得人工补齐当前交付。",
mutation: false,
valuesPrinted: false,
};
}
function normalizedRepositoryPath(value: string): string | null {
const result = value.replace(/^\/+|\/+$/gu, "").replace(/\.git$/iu, "").trim();
if (!/^[^/\s]+\/[^/\s]+$/u.test(result)) return null;
return result;
}
function matchesQuery(consumer: PacDeliveryConsumerAuthority, query: CicdDeliveryAuthorityQuery): boolean {
return matches(consumer.consumerId, query.consumerId)
&& matches(consumer.node, query.node)
&& matches(consumer.lane, query.lane)
&& matches(consumer.sourceRepository, query.sourceRepository)
&& matches(consumer.sourceBranch, query.sourceBranch);
}
function matches(actual: string, expected: string | null | undefined): boolean {
const value = normalized(expected);
return value === null || same(actual, value);
}
function same(left: string, right: string): boolean {
return left.toLowerCase() === right.toLowerCase();
}
function normalized(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
const result = value.trim();
return result.length === 0 ? null : result;
}
function unknownAuthority(reason: Extract<CicdDeliveryAuthority, { kind: "unknown" }>["reason"], detail: string, configRefs: readonly string[]): CicdDeliveryAuthority {
return { kind: "unknown", migrated: false, mutationHintsAllowed: false, consumer: null, reason, detail, configRefs, valuesPrinted: false };
}
function composedYaml(path: string, kind: string): Record<string, unknown> {
return materializeYamlComposition(readYamlRecord<Record<string, unknown>>(rootPath(...path.split("/")), kind), { label: path }).value;
}
function record(value: unknown, path: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value as Record<string, unknown>;
}
function recordArray(value: unknown, path: string): Record<string, unknown>[] {
if (!Array.isArray(value)) throw new Error(`${path} must be an array`);
return value.map((item, index) => record(item, `${path}[${index}]`));
}
function requiredString(value: unknown, path: string): string {
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path} must be a non-empty string`);
return value.trim();
}
function exactlyOne<T>(items: T[], path: string): T {
if (items.length !== 1) throw new Error(`${path} must resolve exactly one row; got ${items.length}`);
return items[0];
}
+59 -21
View File
@@ -1,36 +1,74 @@
// SPEC: PJ2026-01060703 CI/CD branch follower help text.
// Responsibility: bounded CLI help payloads for the branch-follower entrypoint.
// Responsibility: bounded, authority-scoped CLI help for the retired branch-follower entrypoint.
import type { BranchFollowerScope } from "./cicd-types";
export function buildCicdHelp(configPath: string, spec: string): unknown {
export function buildCicdHelp(
configPath: string,
spec: string,
scope: BranchFollowerScope = "default",
legacyConfigured = false,
): unknown {
if (scope === "retired-maintenance") {
return {
command: "cicd branch-follower retired-maintenance cleanup-state",
scope,
mutation: "explicit-confirm-required",
usage: [
"bun scripts/cli.ts cicd branch-follower retired-maintenance cleanup-state --all --dry-run",
"bun scripts/cli.ts cicd branch-follower retired-maintenance cleanup-state --all --confirm",
],
boundary: "仅清理 owning YAML 已标记 retired-readonly 的历史 follower state;不得触发、恢复或补齐交付。",
config: configPath,
spec,
};
}
if (scope === "legacy-cicd") {
return {
command: "cicd branch-follower legacy-cicd",
scope,
configured: legacyConfigured,
mutation: legacyConfigured ? "explicit-confirm-required" : false,
usage: legacyConfigured
? [
"bun scripts/cli.ts cicd branch-follower legacy-cicd plan --follower <id>",
"bun scripts/cli.ts cicd branch-follower legacy-cicd apply --confirm --wait",
"bun scripts/cli.ts cicd branch-follower legacy-cicd run-once --follower <id> --confirm --wait",
"bun scripts/cli.ts cicd branch-follower legacy-cicd debug-step --follower <id> --step state-write --confirm",
"bun scripts/cli.ts cicd branch-follower legacy-cicd job --follower <id> --job <name>",
"bun scripts/cli.ts cicd branch-follower legacy-cicd gate --follower <id> --gate <name> --confirm",
]
: [],
boundary: legacyConfigured
? "仅 owning YAML 精确声明 legacy-manual 的 follower 可执行;PaC、retired-readonly 与 unknown 一律 fail-closed。"
: "当前 owning YAML 未配置 legacy-manual follower;本 scope 不提供写入口。",
config: configPath,
spec,
};
}
return {
command: "cicd branch-follower plan|apply|status|run-once|debug-step|cleanup-state|events|logs|taskrun|job|runtime|gate",
command: "cicd branch-follower status|events|logs|taskrun|runtime|debug-step",
deprecated: true,
mode: "retired-readonly-for-jd01-consumers",
mode: "retired-readonly",
replacement: "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01",
issue: "https://github.com/pikasTech/unidesk/issues/1560",
output: "text by default; use --json, --raw, or -o json|yaml for machine output",
output: "默认输出文本;机器输出使用 --json、--raw 或 -o json|yaml",
usage: [
"bun scripts/cli.ts cicd branch-follower status",
"bun scripts/cli.ts cicd branch-follower status --live",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step controller-source",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step state-read",
"bun scripts/cli.ts cicd branch-follower events --follower agentrun-jd01-v02",
"bun scripts/cli.ts cicd branch-follower logs --follower web-probe-sentinel-master",
"bun scripts/cli.ts cicd branch-follower taskrun --follower hwlab-jd01-v03 --taskrun runtime-ready --logs-tail 120 --json",
"bun scripts/cli.ts cicd branch-follower job --follower agentrun-jd01-v02 --source-commit <sha> --job image-build --json",
"bun scripts/cli.ts cicd branch-follower gate --follower hwlab-jd01-v03 --gate control-plane-refresh --source-commit <sha> --confirm --json",
"bun scripts/cli.ts cicd branch-follower gate --follower hwlab-jd01-v03 --gate git-mirror-flush --source-commit <sha> --confirm --json",
"bun scripts/cli.ts cicd branch-follower gate --follower hwlab-jd01-v03 --gate runtime-closeout --source-commit <sha> --confirm --json",
"bun scripts/cli.ts cicd branch-follower gate --follower agentrun-jd01-v02 --gate reuse-plan --source-commit <sha> --json",
"bun scripts/cli.ts cicd branch-follower runtime --follower hwlab-jd01-v03 --json",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step controller-source",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step state-read",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step status-read",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step decide",
],
config: configPath,
spec,
description: "Retired branch-follower controller surface for the JD01 migrated consumers. Use PaC status/history for current closeout; branch-follower commands remain only for historical state and bounded migration diagnostics.",
currentCloseout: [
"bun scripts/cli.ts platform-infra gitea mirror status --target JD01",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target JD01",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10",
],
description: "退役 branch-follower 的只读历史状态与有界下钻入口;当前交付只观察 PaC 自动链。",
next: {
status: "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01",
history: "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10",
fixAutomaticDelivery: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界",
},
};
}
+2 -2
View File
@@ -159,15 +159,15 @@ function missingPolicyPayload(action: string, follower: FollowerSpec, registry:
follower: follower.id,
adapter: follower.adapter,
degradedReason: "drilldown-policy-missing",
message: `follower ${follower.id} registry is missing drillDown policy; apply the current config before drill-down`,
message: `follower ${follower.id} registry is missing drillDown policy; inspect the owning YAML before drill-down`,
query,
policy: null,
result: null,
statusAuthority: options.inCluster ? "kubernetes-api-serviceaccount" : "target-node-kubectl-raw",
parsedDownstreamCliOutput: false,
next: {
apply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait",
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
fixAutomaticDelivery: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界",
},
};
}
+3 -3
View File
@@ -14,7 +14,7 @@ interface NodeStatusOptions {
export function cicdNodeStatusHelp(): Record<string, unknown> {
return {
command: "cicd status --node <NODE>",
description: "Read a node-level CI/CD closeout summary for every Pipelines-as-Code consumer on the node.",
description: "只读汇总节点上每个 Pipelines-as-Code consumer 的自动交付状态。",
usage: [
"bun scripts/cli.ts cicd status --node NC01",
"bun scripts/cli.ts cicd status --node NC01 --full",
@@ -119,9 +119,9 @@ function renderNodeStatus(result: Record<string, unknown>, full: boolean): Rende
"",
] : []),
"NEXT",
` refresh: ${stringValue(record(result.next).status)}`,
` status: ${stringValue(record(result.next).status)}`,
` history: ${stringValue(record(result.next).history)}`,
` closeout: ${stringValue(record(result.next).closeout)}`,
` fix-automatic-delivery: ${stringValue(record(record(result.next).fixAutomaticDelivery).reference)}`,
];
return { ok: result.ok !== false, command: "cicd status", renderedText: lines.join("\n"), contentType: "text/plain" };
}
@@ -35,8 +35,9 @@ describe("PR 合并驱动的自动交付文档合同", () => {
test("状态与单步入口只读,自动链不通时不得人工恢复", () => {
expect(skill).toContain(
"status/history/events/logs/closeout/debug-step 可用于定位,但不得改变交付状态",
"status/history/events/logs/debug-step 可用于定位,但不得改变交付状态",
);
expect(skill).toContain("`closeout` 只属于显式 compatibility diagnostics");
expect(skill).toContain("PaC migrated consumer 禁止执行 `trigger-current`");
expect(skill).not.toContain("需要保留的旧命令只能标为 debug/test/recovery");
expect(reference).toContain("`closeout` 只保留为可选的只读历史/诊断兼容入口");
@@ -68,6 +69,31 @@ describe("PR 合并驱动的自动交付文档合同", () => {
expect(reference).toContain("不是合并后的 CI/CD 触发、同步、恢复或补跑入口");
});
test("delivery authority 由两份 YAML 组合,unknown 与 migrated 都禁止 mutation", () => {
for (const document of [skill, reference, platform]) {
expect(document).toContain("config/platform-infra/gitea.yaml");
expect(document).toContain("config/platform-infra/pipelines-as-code.yaml");
expect(document).toMatch(/unknown[\s\S]*mutation=false|unknown[\s\S]*`mutation=false`/);
expect(document).toMatch(/legacy-(?:manual|cicd)|legacy\/manual/);
}
});
test("durable inbox 只证明 accepted,同 delivery refs proof 才能 committed", () => {
for (const document of [skill, reference, platform]) {
expect(document).toContain("202 Accepted");
expect(document).toContain("accepted");
expect(document).toContain("processing");
expect(document).toContain("committed");
expect(document).toContain("failed");
expect(document).toContain("deliveryId");
expect(document).toMatch(/fsync[\s\S]*atomic (?:rename|write)/);
expect(document).toMatch(/immutable snapshot[\s\S]*authority branch|authority branch[\s\S]*immutable snapshot/);
expect(document).toMatch(/重新读取 refs|refs proof/);
expect(document).toMatch(/不是.*(?:source|源码).*第二份真相|不是业务 source\/ref 的第二份真相/);
expect(document).not.toMatch(/只有 HTTP 200|返回 HTTP 200 后.*committed/);
}
});
test("可调整预算继续归属 YAML,长期规则不固化当前数值", () => {
expect(skill).toContain("wall-clock 目标及预算只由 owning YAML 声明");
expect(skill).not.toContain("低于 2 分钟");
+14 -8
View File
@@ -64,9 +64,10 @@ function renderPlanHuman(payload: Record<string, unknown>): string {
"NEXT",
`pac-status: ${next?.pacStatus ?? "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01"}`,
`pac-history: ${next?.pacHistory ?? "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10"}`,
`legacy-apply: ${next?.legacyApply ?? "-"}`,
`legacy-status: ${next?.legacyStatus ?? next?.status ?? "-"}`,
`legacy-dry-run: ${next?.legacyDryRun ?? next?.dryRun ?? "-"}`,
`status: ${next?.status ?? "-"}`,
`events: ${next?.events ?? "-"}`,
`logs: ${next?.logs ?? "-"}`,
`fix-automatic-delivery: ${asOptionalRecord(next?.fixAutomaticDelivery)?.reference ?? next?.fixAutomaticDelivery ?? "-"}`,
"",
].join("\n");
}
@@ -88,7 +89,8 @@ function renderApplyHuman(payload: Record<string, unknown>): string {
"",
"NEXT",
`status: ${next?.status ?? "-"}`,
`dry-run: ${next?.dryRun ?? "-"}`,
`events: ${next?.events ?? "-"}`,
`logs: ${next?.logs ?? "-"}`,
"",
].filter((line) => line !== "").join("\n");
}
@@ -148,8 +150,10 @@ function renderStatusHuman(payload: Record<string, unknown>, _options: ParsedOpt
"NEXT",
`pac-status: bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01`,
`pac-history: bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10`,
`legacy-live-status: ${next?.liveStatus ?? "-"}`,
`legacy-dry-run: ${next?.legacyDryRun ?? next?.dryRun ?? "-"}`,
`status: ${next?.status ?? "-"}`,
`events: ${next?.events ?? "-"}`,
`logs: ${next?.logs ?? "-"}`,
`fix-automatic-delivery: ${asOptionalRecord(next?.fixAutomaticDelivery)?.reference ?? next?.fixAutomaticDelivery ?? "-"}`,
"",
].filter((line) => line !== "").join("\n");
}
@@ -205,7 +209,8 @@ function renderRunOnceHuman(payload: Record<string, unknown>): string {
"",
"NEXT",
`status: ${next?.status ?? "-"}`,
`live-status: ${next?.liveStatus ?? "-"}`,
`events: ${next?.events ?? "-"}`,
`logs: ${next?.logs ?? "-"}`,
"",
].join("\n");
}
@@ -233,7 +238,8 @@ function renderCleanupStateHuman(payload: Record<string, unknown>): string {
"",
"NEXT",
`status: ${next?.status ?? "-"}`,
`run-once: ${next?.runOnce ?? "-"}`,
`events: ${next?.events ?? "-"}`,
`logs: ${next?.logs ?? "-"}`,
"",
].filter((line) => line !== "").join("\n");
}
+2 -2
View File
@@ -23,7 +23,7 @@ export async function runBranchFollowerTaskRunDrillDown(
follower: follower.id,
adapter: follower.adapter,
degradedReason: "drilldown-policy-missing",
message: `follower ${follower.id} registry is missing drillDown policy; apply the current config before TaskRun drill-down`,
message: `follower ${follower.id} registry is missing drillDown policy; inspect the owning YAML before TaskRun drill-down`,
query: {
taskRun,
pipelineRun: options.pipelineRunName,
@@ -35,8 +35,8 @@ export async function runBranchFollowerTaskRunDrillDown(
statusAuthority: options.inCluster ? "kubernetes-api-serviceaccount" : "target-node-kubectl-raw",
parsedDownstreamCliOutput: false,
next: {
apply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait",
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
fixAutomaticDelivery: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界",
},
};
}
+4
View File
@@ -3,6 +3,8 @@
export type OutputMode = "human" | "json" | "yaml";
export type BranchFollowerAction = "help" | "plan" | "apply" | "status" | "run-once" | "debug-step" | "cleanup-state" | "events" | "logs" | "taskrun" | "job" | "runtime" | "gate";
export type BranchFollowerScope = "default" | "legacy-cicd" | "retired-maintenance";
export type BranchFollowerDeclaredDeliveryAuthority = "retired-readonly" | "legacy-manual" | "unknown";
export type BranchFollowerDebugStep = "state-read" | "controller-source" | "status-read" | "decide" | "state-write";
export type BranchFollowerGate = "reuse-plan" | "ci-taskrun-plan" | "cd-rollout-plan" | "post-deploy-health" | "control-plane-refresh" | "git-mirror-flush" | "runtime-closeout";
export type BranchFollowerPhase =
@@ -19,6 +21,7 @@ export type BranchFollowerPhase =
export interface ParsedOptions {
action: BranchFollowerAction;
scope: BranchFollowerScope;
configPath: string;
followerId: string | null;
all: boolean;
@@ -54,6 +57,7 @@ export interface CommandSpec {
export interface FollowerSpec {
id: string;
enabled: boolean;
deliveryAuthority: BranchFollowerDeclaredDeliveryAuthority;
adapter: string;
description: string;
source: {
+2 -2
View File
@@ -13,7 +13,7 @@ export function cicdHelp(): unknown {
output: "text by default for subcommands; top-level help is json",
nodeStatus: {
primary: "bun scripts/cli.ts cicd status --node NC01",
note: "Use this for node-level CI/CD status. It aggregates all current Pipelines-as-Code consumers for the node, including AgentRun, HWLAB and Web sentinel.",
note: "用于只读汇总节点上的 AgentRun、HWLAB、Web sentinel 与 unidesk-host PaC consumer。",
},
migration: {
issue: "https://github.com/pikasTech/unidesk/issues/1560",
@@ -22,7 +22,7 @@ export function cicdHelp(): unknown {
primaryHistory: "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10",
archived: "cicd gitea-actions-poc",
deprecated: "cicd branch-follower",
note: "JD01 CI/CD source/trigger authority is Gitea mirror -> Pipelines-as-Code -> Tekton -> Argo/k8s runtime. Gitea Actions POC and branch-follower are historical or migration-only surfaces.",
note: "当前 source/trigger authority 是 GitHub PR merge -> Gitea mirror -> Pipelines-as-Code -> Tekton -> Argo/k8s runtimeGitea Actions POC branch-follower 仅保留只读历史观察。",
},
subcommands: [
cicdNodeStatusHelp(),
+19 -2
View File
@@ -63,7 +63,7 @@ export function rootHelp(): unknown {
{ command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force]", description: "Reconcile services from origin/master:deploy.json environments; --commit overrides one reviewed artifact consumer such as frontend for release/v1 validation or rollback. code-queue artifact consumption is dev-only." },
{ command: "cicd status --node <NODE>", description: "Show one node's CI/CD closeout summary across current Pipelines-as-Code consumers, including PipelineRun, Argo and runtime readiness." },
{ command: "cicd gitea-actions-poc plan|status", description: "Archived read-only CI/CD migration evidence for GH-1548/GH-1549; never use as a delivery or closeout path." },
{ command: "cicd branch-follower status|events|logs", description: "Archived migration diagnostics only; PaC-migrated NC01 lane must use cicd status and platform-infra pipelines-as-code closeout/status/history." },
{ command: "cicd branch-follower status|events|logs|taskrun|runtime|debug-step", description: "退役 branch-follower 的只读历史诊断;当前 lane 只使用 cicd status platform-infra pipelines-as-code status/history 观察自动链。" },
{ command: "dev-env validate|prewarm-images|worktree add", description: "Validate D601 guardrails, prewarm dev images, or create UniDesk task worktrees with .worktreecopy local config copying." },
{ command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." },
{ command: "auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run", description: "Inspect the P0 Rust auth broker and CLI adapter contract without reading token values, writing GitHub, or starting services." },
@@ -777,6 +777,8 @@ function platformInfraHelpSummary(): unknown {
"bun scripts/cli.ts platform-infra gitea validate --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror status --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target NC01",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target NC01",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --limit 10",
],
description: "Operate platform-infra services such as Sub2API, shared egress-proxy benchmarks, LangBot, n8n, Web Terminal, WeChat archive workflows, the YAML-controlled Codex pool, and internal Gitea for the GH-1548/GH-1549 CI/CD migration.",
};
@@ -900,7 +902,18 @@ export async function staticNamespaceHelp(args: string[]): Promise<unknown | nul
if (top === "gh") return ghScopedHelp(args.slice(1)) ?? ghHelp();
if (top === "cicd") return loadHelp(async () => (await import("./cicd")).cicdHelp(), cicdHelpSummary());
if (top === "agentrun") return loadHelp(async () => (await import("./agentrun")).agentRunHelp(), agentRunHelpSummary());
if (top === "platform-infra" && (sub === "pipelines-as-code" || sub === "pac") && args[2] === "source-artifact") return null;
if (top === "platform-infra" && (sub === "pipelines-as-code" || sub === "pac")) {
return loadHelp(
async () => (await import("./platform-infra-pipelines-as-code")).runPlatformInfraPipelinesAsCodeCommand({} as never, args.slice(2)),
platformInfraHelpSummary(),
);
}
if (top === "platform-infra" && sub === "gitea") {
return loadHelp(
async () => (await import("./platform-infra-gitea")).runPlatformInfraGiteaCommand({} as never, args.slice(2)),
platformInfraHelpSummary(),
);
}
if (top === "platform-infra") return loadHelp(async () => (await import("./platform-infra")).platformInfraHelp(), platformInfraHelpSummary());
if (top === "platform-db") return platformDbHelp();
if (top === "secrets") return secretsHelp();
@@ -909,6 +922,10 @@ export async function staticNamespaceHelp(args: string[]): Promise<unknown | nul
if (top === "hwlab" && (sub === "node" || sub === "nodes") && args[2] === "control-plane" && args[3] === "infra") {
return loadHelp(async () => (await import("./hwlab-node-control-plane")).hwlabNodeControlPlaneInfraHelp(), hwlabNodeHelpSummary());
}
if (top === "hwlab" && (sub === "node" || sub === "nodes") && args[2] === "control-plane" && (args[3] === "platform-maintenance" || args[3] === "legacy-cicd")) {
const scope = args[3];
return loadHelp(async () => (await import("./hwlab-node-help")).hwlabNodeHelp(scope), hwlabNodeHelpSummary());
}
if (top === "hwlab" && (sub === "node" || sub === "nodes") && args[2] === "test-accounts") {
return loadHelp(async () => (await import("./hwlab-test-accounts")).hwlabTestAccountsHelp(), hwlabNodeHelpSummary());
}
+43 -11
View File
@@ -3,7 +3,38 @@
// Responsibility: Help payloads for HWLAB node/lane and web-probe CLI entries.
import { hwlabRuntimeLaneConfigPath } from "./hwlab-node-lanes";
export function hwlabNodeHelp(): Record<string, unknown> {
export function hwlabNodeHelp(scope: "legacy-cicd" | "platform-maintenance" | null = null): Record<string, unknown> {
if (scope === "legacy-cicd") {
return {
ok: true,
command: "hwlab nodes control-plane legacy-cicd",
description: "仅用于 owning YAML 明确解析为 legacy-manual 的旧交付入口。PaC 或 unknown authority 会 fail-closed。",
examples: [
"bun scripts/cli.ts hwlab nodes control-plane trigger-current --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes control-plane refresh --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes control-plane sync --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes control-plane source-workspace sync --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes git-mirror sync --node <node> --lane <lane> --confirm --wait",
"bun scripts/cli.ts hwlab nodes git-mirror flush --node <node> --lane <lane> --confirm --wait",
],
valuesPrinted: false,
};
}
if (scope === "platform-maintenance") {
return {
ok: true,
command: "hwlab nodes control-plane platform-maintenance",
description: "YAML 声明的平台配置、Secret 与容量维护;不得作为 PaC 源码交付的补偿或恢复入口。",
examples: [
"bun scripts/cli.ts hwlab nodes control-plane apply --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes control-plane public-exposure --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-runs --node <node> --lane <lane> --min-age-minutes 30 --limit 200 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-released-pvs --node <node> --lane <lane> --limit 200 --dry-run",
"bun scripts/cli.ts hwlab nodes secret status --node <node> --lane <lane> --name <secret>",
],
valuesPrinted: false,
};
}
return {
ok: true,
command: "hwlab nodes",
@@ -13,12 +44,8 @@ export function hwlabNodeHelp(): Record<string, unknown> {
"bun scripts/cli.ts hwlab nodes control-plane infra plan --node <node> --lane <lane>",
"bun scripts/cli.ts hwlab nodes control-plane status --node <node> --lane <lane>",
"bun scripts/cli.ts hwlab nodes control-plane status --node <node> --lane <lane> --json",
"bun scripts/cli.ts hwlab nodes control-plane source-workspace sync --node JD01 --lane v03 --confirm",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-runs --node JD01 --lane v03 --min-age-minutes 30 --limit 200 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-released-pvs --node JD01 --lane v03 --limit 200 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-legacy-docker-images --node JD01 --lane v03 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-legacy-docker-registry-volume --node JD01 --lane v03 --dry-run",
"bun scripts/cli.ts hwlab nodes git-mirror status --node <node> --lane <lane>",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target <node> --consumer <consumer>",
"bun scripts/cli.ts hwlab nodes hwpod-preinstall plan --node <node> --lane <lane> --dry-run",
"bun scripts/cli.ts hwlab nodes hwpod-node plan --node G14-WSL --lane v03",
"bun scripts/cli.ts hwlab nodes fake-model-provider plan --node D518 --lane v03 --provider fake-echo",
@@ -26,14 +53,16 @@ export function hwlabNodeHelp(): Record<string, unknown> {
"bun scripts/cli.ts hwlab nodes test-accounts status --node <node> --lane <lane>",
"bun scripts/cli.ts hwlab nodes observability performance-summary --node <node> --lane <lane>",
"bun scripts/cli.ts web-probe --help",
"bun scripts/cli.ts hwlab nodes control-plane platform-maintenance --help",
"bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help",
],
actions: {
"control-plane": "YAML-first node-local CI/CD, git-mirror, source-workspace sync, public exposure, runtime-image, Argo, PipelineRun and CI workspace retention operations.",
"control-plane": "YAML-first node-local CI/CD status, public exposure, runtime-image, Argo, PipelineRun and CI workspace retention domains; delivery writes are documented only by scoped help.",
"git-mirror": "Inspect or operate the selected node/lane source mirror.",
"hwpod-preinstall": "Render YAML-first HWPOD preinstall configRefs, runtime mount targets, PM MDTODO source, and gateway profile status.",
"hwpod-node": "通过 YAML 和 trans 部署、查询 Windows 原生 Python HWPOD 节点。",
"fake-model-provider": "Materialize and operate YAML-declared fake Responses model providers for HWLAB/AgentRun sentinel checks.",
secret: "Inspect and sync YAML-declared runtime Secrets without printing secret values.",
secret: "Inspect YAML-declared runtime Secrets without printing secret values; writes are documented only by platform-maintenance help.",
"test-accounts": "Prepare YAML-declared HWLAB admin/test account API keys with redacted sourceRef/fingerprint output.",
observability: "Read runtime metrics and authenticated Web Performance summaries.",
},
@@ -43,7 +72,8 @@ export function hwlabNodeHelp(): Record<string, unknown> {
"control-plane cleanup-runs deletes terminal PipelineRuns; cleanup-released-pvs deletes only orphaned hwlab-ci PipelineRun PVCs with no active pod mounts and Released local-path/Delete PVs.",
"cleanup-legacy-docker-images is a transitional legacy-cache GC for Docker images matching YAML allowlisted repositories; it protects container-referenced images, does not run prune, and does not touch Docker volumes.",
"cleanup-legacy-docker-registry-volume removes only an exited legacy Docker registry container and its unique /var/lib/registry volume after the YAML-declared k8s node-local-registry is ready.",
"`trigger-current --confirm --wait` is the one-command CICD path for current node/lane runtime publish.",
"PaC consumer 只由 GitHub PR merge 触发;默认帮助只给只读 status/history,自动链故障必须修 owning YAML、controller 或源码。",
"旧交付与平台维护写入口分别只在 legacy-cicd、platform-maintenance scoped help 中展示。",
"control-plane status reads its total budget, heartbeat cadence, termination grace period, and probe switches from config/hwlab-node-control-plane.yaml; --json keeps stdout as one compact JSON document while progress remains on stderr.",
],
};
@@ -65,6 +95,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"bun scripts/cli.ts web-probe observe start --node <node> --lane <lane> --origin public --target-path /projects/mdtodo --sample-interval-ms 5000",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type sendPrompt --text 'ping'",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateRealtimeFanout --profile pure-kafka-live --provider gpt.pika --text '<first-turn>' --second-text '<second-turn>'",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateExistingSessionRefresh --profile pure-kafka-live --session-id ses_existing",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateWorkbenchKafkaDebugReplay",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateWorkbenchTraceReadability",
"bun scripts/cli.ts web-probe observe status webobs-xxxx --command-id cmd-xxxx",
@@ -86,8 +117,8 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"bun scripts/cli.ts web-probe observe gc --node JD01 --lane v03 --keep-hours 24 --confirm",
"bun scripts/cli.ts web-probe sentinel plan --node <node> --lane <lane> --dry-run",
"bun scripts/cli.ts web-probe sentinel plan --node <node> --lane <lane> --sentinel workbench-auth-session-switch-2users",
"bun scripts/cli.ts platform-infra pipelines-as-code closeout --target JD01 --consumer sentinel-jd01-v03 --source-commit <sha> --wait",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 --consumer sentinel-jd01-v03",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --consumer sentinel-jd01-v03 --limit 10",
"bun scripts/cli.ts web-probe sentinel dashboard verify --node <node> --lane <lane> --sentinel workbench-dsflash-go-tool-call-10x",
"bun scripts/cli.ts web-probe sentinel dashboard screenshot --node <node> --lane <lane> --sentinel workbench-auth-session-switch-2users",
"bun scripts/cli.ts web-probe sentinel dashboard trigger --node JD01 --lane v03 --sentinel jd01-web-probe-sentinel",
@@ -103,7 +134,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
script: "Run caller-provided Playwright JS after CLI-managed /auth/login; scripts must not handle secrets themselves.",
screenshot: "Capture a page through the selected YAML semantic origin and node/lane remote browser, then download PNG artifacts to the caller /tmp by default.",
observe: "Start, inspect, control, stop, collect, analyze, and garbage-collect raw artifacts for long-running observers.",
sentinel: "Render and operate the YAML-first web-probe sentinel wrapper, image, GitOps, dashboard verification, maintenance and report views; migrated JD01 formal closeout uses platform-infra pipelines-as-code, while publish-current is debug/recovery only.",
sentinel: "Render and operate the YAML-first web-probe sentinel wrapper, image, GitOps, dashboard verification, maintenance and report views; migrated consumers are delivered only by the automatic PR-merge chain.",
},
notes: [
"Named internal/public origins, default origin, per-origin browser proxy mode, observe/analyze thresholds, and project-management command allowlist come from config/hwlab-node-lanes.yaml webProbe.",
@@ -117,6 +148,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.",
"Typed observe commands inherit the semantic origin selected by observe start and must not embed a replacement URL or IP.",
"validateRealtimeFanout is a YAML-profiled background command: it gates submit on two live product subscribers plus three Kafka debug consumers, validates two turns and disconnect/reconnect without replay, and is polled by exact command id.",
"validateExistingSessionRefresh is a YAML-profiled background command: it submits no prompt and proves an existing session keeps DOM identity while Kafka retention reaches the shared live SSE phase.",
"validateWorkbenchKafkaDebugReplay 复用 observer 当前 Workbench session,以 owning YAML 中的 topic、group prefix 和超时验证隔离 Kafka 重放,并通过精确 command id 查询结果。",
"validateWorkbenchTraceReadability 只检查当前 Workbench 会话中的产品 Trace 卡片。",
"该命令排除隔离调试面板,并分别验证运行中默认展开、source authority 与终态保留。",
@@ -176,6 +176,14 @@ function renderWebObserveStatusTable(value: Record<string, unknown>): string {
]]),
"",
] : []),
...(exactCommand.type === "validateExistingSessionRefresh" && exactReplayEvidence !== null ? [
...renderExistingSessionRefreshEvidence(
exactReplayEvidence,
exactResult?.reportSha256 ?? exactErrorDetails?.reportSha256,
record(exactResult?.screenshot ?? exactErrorDetails?.screenshot)?.sha256,
),
"",
] : []),
...(exactCommand.type === "validateWorkbenchKafkaDebugReplay" && exactReplayEvidence !== null ? [
...renderWorkbenchKafkaDebugReplayEvidence(
exactReplayEvidence,
@@ -236,6 +244,69 @@ function renderWebObserveStatusTable(value: Record<string, unknown>): string {
return lines.join("\n");
}
function renderExistingSessionRefreshEvidence(
evidence: Record<string, unknown>,
reportSha: unknown,
screenshotSha: unknown,
): string[] {
const before = record(evidence.before);
const after = record(evidence.after);
const replay = record(evidence.refreshReplay);
const counts = record(replay?.counts);
const product = record(evidence.productSse);
const failure = nullableRecord(evidence.failure);
const traceIds = Array.isArray(evidence.traceIds) ? evidence.traceIds.slice(0, 4).join(",") : "";
const partitions = Array.isArray(replay?.topicPartitions) ? replay.topicPartitions.slice(0, 8).join(",") : "";
const barrier = Array.isArray(replay?.barrier)
? replay.barrier.slice(0, 8).map((item) => {
const row = record(item);
return `${webObserveText(row.partition)}:${webObserveText(row.endOffset)}`;
}).join(",")
: "";
return [
"Workbench 既有 Session 刷新:",
webObserveTable(["SESSION", "TRACE_IDS", "PHASE", "CODE", "MSG_BEFORE", "MSG_AFTER", "SOURCES", "OPEN", "CONNECTED", "BUSINESS", "FAILURES", "FORBIDDEN", "REPORT_SHA", "SCREENSHOT_SHA"], [[
webObserveShort(webObserveText(evidence.sessionId), 32),
webObserveShort(webObserveText(traceIds), 40),
evidence.phase,
evidence.code,
before?.messageCount,
after?.messageCount,
product?.browserSourceCount,
product?.openCount,
product?.connectedEventCount,
product?.businessEventCount,
product?.typedFailureCount,
evidence.forbiddenRequestCount,
webObserveShort(webObserveText(reportSha), 32),
webObserveShort(webObserveText(screenshotSha), 32),
]]),
"Kafka refresh handoff:",
webObserveTable(["TOPIC", "PARTITIONS", "BARRIER", "MATCHED", "REPLAYED", "BUFFERED", "BUFFERED_DELIVERED", "LIVE_DELIVERED", "DEDUPLICATED", "LIVE"], [[
webObserveShort(webObserveText(replay?.topic), 32),
webObserveShort(webObserveText(partitions), 24),
webObserveShort(webObserveText(barrier), 56),
counts?.matched,
counts?.replayed,
counts?.buffered,
counts?.bufferedDelivered,
counts?.liveDelivered,
counts?.deduplicated,
product?.livePhase,
]]),
...(failure !== null ? [
"Typed failure:",
webObserveTable(["PHASE", "CODE", "STREAM_PHASE", "STREAM_CODE", "MESSAGE"], [[
failure.phase,
failure.code,
failure.streamPhase,
failure.streamCode,
webObserveShort(webObserveText(failure.message), 96),
]]),
] : []),
];
}
function renderWorkbenchKafkaDebugReplayEvidence(
evidence: Record<string, unknown>,
reportSha: unknown,
@@ -391,6 +462,14 @@ function renderWebObserveCommandTable(value: Record<string, unknown>): string {
replayScreenshot?.sha256 ?? resultScreenshot?.sha256,
),
] : []),
...(observerCommand?.type === "validateExistingSessionRefresh" && replayEvidence !== null ? [
"",
...renderExistingSessionRefreshEvidence(
replayEvidence,
result?.reportSha256 ?? details?.reportSha256,
replayScreenshot?.sha256 ?? resultScreenshot?.sha256,
),
] : []),
...(observerCommand?.type === "validateWorkbenchTraceReadability" && replayEvidence !== null ? [
"",
...renderWorkbenchTraceReadabilityEvidence(
@@ -102,6 +102,7 @@ async function processCommand(command) {
expectedActionWaitMs: command.expectedActionWaitMs,
}), "sendPrompt");
case "validateRealtimeFanout": return validateRealtimeFanout(command);
case "validateExistingSessionRefresh": return validateExistingSessionRefresh(command);
case "validateWorkbenchKafkaDebugReplay": return withObserverSync(await validateWorkbenchKafkaDebugReplay(command), "validateWorkbenchKafkaDebugReplay");
case "validateWorkbenchTraceReadability": return withObserverSync(await validateWorkbenchTraceReadability(command), "validateWorkbenchTraceReadability");
case "steer": return withObserverSync(await sendPrompt(String(command.text || ""), { expectedAction: "steer", responsePath: "/v1/agent/chat/steer", noActiveReason: "steer-no-active-turn", expectedActionWaitMs: command.expectedActionWaitMs }), "steer");
@@ -172,6 +172,88 @@ test("realtime fanout validates YAML-owned refresh and legacy live-only connecte
}, sessionId, "live", liveProfile));
});
test("existing session refresh rejects duplicate or reordered Workbench identities", () => {
const source = nodeWebObserveRunnerRealtimeSource();
const build = new Function(`${source}\nreturn realtimeAssertSameWorkbenchDom;`) as () => (
before: Record<string, unknown>, after: Record<string, unknown>,
) => void;
const assertSame = build();
const before = { messageCount: 2, identities: ["user:m1:", "agent:m2:t1"], missingIdentityRows: [], duplicateIdentities: [] };
assert.doesNotThrow(() => assertSame(before, { ...before }));
assert.throws(() => assertSame(before, { ...before, identities: [...before.identities].reverse() }), /identity or order changed/u);
assert.throws(() => assertSame(before, { ...before, duplicateIdentities: ["agent:m2:t1"] }), /duplicate message identities/u);
assert.throws(() => assertSame(before, { ...before, missingIdentityRows: [1] }), /without a stable message identity/u);
});
test("existing session refresh failures preserve typed phase and code", () => {
const source = nodeWebObserveRunnerRealtimeSource();
const build = new Function(`function errorSummary(error) { return { message: error.message }; }\n${source}\nreturn { realtimeExistingRefreshFailure, realtimeExistingRefreshStreamError };`) as () => {
realtimeExistingRefreshFailure: (phase: string, error: Error & { details?: Record<string, unknown> }) => Record<string, unknown>;
realtimeExistingRefreshStreamError: (value: Record<string, unknown>) => Error & { details?: Record<string, unknown> };
};
const helpers = build();
const error = helpers.realtimeExistingRefreshStreamError({
phase: "flushing",
error: { code: "workbench_kafka_refresh_barrier_gap", message: "Kafka retention barrier gap", retryable: true },
fallback: false,
});
const failure = helpers.realtimeExistingRefreshFailure("retention-barrier", error);
assert.equal(failure.phase, "retention-barrier");
assert.equal(failure.code, "retention_barrier_failed");
assert.equal(failure.streamPhase, "flushing");
assert.equal(failure.streamCode, "workbench_kafka_refresh_barrier_gap");
assert.equal((failure.streamFailure as Record<string, unknown>).fallback, false);
assert.deepEqual(failure.valuesRedacted, true);
});
test("existing session refresh preserves rejected connected evidence and rejects missing browser replay delivery", () => {
const source = nodeWebObserveRunnerRealtimeSource();
const build = new Function(
"sanitize",
`${source}\nreturn { realtimeExistingRefreshConnectedEvidence, realtimeAssertExistingRefreshDelivery };`,
) as (sanitize: <T>(value: T) => T) => {
realtimeExistingRefreshConnectedEvidence: (connected: Record<string, any>, sessionId: string, profile: Record<string, any>) => Record<string, unknown>;
realtimeAssertExistingRefreshDelivery: (browser: Record<string, unknown>, refreshReplay: Record<string, any>) => void;
};
const helpers = build((value) => value);
const sessionId = "ses_existing";
const refreshReplay = {
phase: "live",
topic: "hwlab.event.v1",
topicPartitions: [0],
barrier: [{ partition: 0, endOffset: "18" }],
counts: { matched: 8, replayed: 8, buffered: 1, bufferedDelivered: 1, liveDelivered: 0, deduplicated: 0 },
};
const profile = {
expectedKafka: { topics: { hwlab: "hwlab.event.v1" }, capabilities: { directPublish: true, liveKafkaSse: true, kafkaRefreshReplay: true } },
expectedProductSse: { deliverySemantics: "kafka-retention-then-live", liveOnly: false, replay: true, replaySupported: true, lossPossible: false, refreshHandoff: true },
};
const connected = {
realtimeSource: "hwlab.event.v1",
deliverySemantics: "invalid",
liveOnly: false,
replay: true,
replaySupported: true,
lossPossible: false,
filters: { sessionId, traceId: null },
capabilities: profile.expectedKafka.capabilities,
refreshReplay,
};
assert.throws(
() => helpers.realtimeExistingRefreshConnectedEvidence(connected, sessionId, profile),
(error: Error & { details?: Record<string, any> }) => {
assert.deepEqual(error.details?.refreshReplay?.barrier, refreshReplay.barrier);
assert.equal(error.details?.refreshReplay?.counts?.replayed, 8);
return true;
},
);
assert.doesNotThrow(() => helpers.realtimeAssertExistingRefreshDelivery({ businessEventCount: 9 }, refreshReplay));
assert.throws(
() => helpers.realtimeAssertExistingRefreshDelivery({ businessEventCount: 8 }, refreshReplay),
/fewer than replayed plus buffered delivery 9/u,
);
});
test("realtime fanout proves one formal user event through AgentRun, HWLAB Kafka, and product SSE", () => {
const source = nodeWebObserveRunnerRealtimeSource();
const build = new Function(`${source}\nreturn realtimeValidateTurnEvidence;`) as () => (input: Record<string, unknown>) => void;
@@ -327,6 +327,391 @@ async function validateRealtimeFanout(command) {
}
}
async function validateExistingSessionRefresh(command) {
const profileId = String(command.profile || "").trim();
const profile = realtimeFanoutProfiles[profileId];
if (!profile || typeof profile !== "object") throw new Error("validateExistingSessionRefresh profile is not declared by the owning YAML: " + profileId);
const effective = realtimeFanoutEffectiveProfile(profile, command.durationMs);
if (effective.expectedProductSse.refreshHandoff !== true) throw new Error("validateExistingSessionRefresh requires a YAML profile with refresh handoff enabled");
const reportDir = path.join(stateDir, "artifacts", "existing-session-refresh", safeId(command.id));
await mkdir(reportDir, { recursive: true, mode: 0o700 });
const network = [];
const requestListener = (request) => {
let url;
try { url = new URL(request.url()); } catch { return; }
if (url.origin !== new URL(baseUrl).origin) return;
network.push({
at: Date.now(),
method: request.method(),
path: url.pathname,
sessionId: url.searchParams.get("sessionId"),
traceId: url.searchParams.get("traceId"),
afterSeq: url.searchParams.get("afterSeq"),
lastEventId: request.headers()["last-event-id"] || null,
valuesRedacted: true,
});
if (network.length > 1000) network.splice(0, network.length - 1000);
};
const report = {
ok: false,
contract: "web-probe-existing-session-refresh-v1",
commandId: command.id,
profile: profileId,
origin,
startedAt: new Date().toISOString(),
valuesRedacted: true,
};
let phase = "session-selection";
let sessionId = null;
let refreshReplay = null;
let screenshot = null;
let requestPage = null;
try {
const controlRecovery = await ensureControlPageResponsiveForCommand("validateExistingSessionRefresh");
const initial = await workbenchSessionSnapshot();
sessionId = String(command.sessionId || initial?.activeSessionId || initial?.routeSessionId || "").trim();
if (!sessionId) throw new Error("validateExistingSessionRefresh requires --session-id or an active Workbench session");
if (!/^ses_[A-Za-z0-9_-]+$/u.test(sessionId)) throw new Error("validateExistingSessionRefresh session id is invalid");
Object.assign(report, { sessionId, controlRecovery });
if (initial?.activeSessionId !== sessionId || initial?.routeSessionId !== sessionId) {
await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId));
const selected = await waitForWorkbenchSessionHydrated(page, sessionId, { timeoutMs: effective.barrierTimeoutMs });
if (selected.ok !== true) throw new Error("existing session did not hydrate before refresh validation");
}
const before = await realtimeWorkbenchDomIdentity();
if (before.messageCount < 1) throw new Error("existing session has no rendered messages to validate across refresh");
report.before = before;
phase = "refresh-navigation";
requestPage = page;
requestPage.on("request", requestListener);
await realtimeArmExistingSessionRefreshProbe(requestPage, effective);
await requestPage.reload({ waitUntil: "domcontentloaded", timeout: effective.barrierTimeoutMs });
const settled = await waitForWorkbenchSessionHydrated(page, sessionId, { timeoutMs: effective.barrierTimeoutMs });
if (settled.ok !== true) throw new Error("existing session did not hydrate after refresh");
phase = "retention-barrier";
const refreshState = await realtimePoll(async () => page.evaluate(() => {
const state = window.__unideskExistingSessionRefresh;
const streamFailure = state?.failures?.at(-1) || null;
if (streamFailure) return { streamFailure };
const connected = state?.connected?.at(-1) || null;
return connected ? { connected } : null;
}), effective.barrierTimeoutMs, "existing session refresh connected evidence");
if (refreshState.streamFailure) throw realtimeExistingRefreshStreamError(refreshState.streamFailure);
const connected = refreshState.connected;
let connectedSummary = null;
try {
const connectedEvidence = realtimeExistingRefreshConnectedEvidence(connected, sessionId, effective);
connectedSummary = connectedEvidence.connected;
refreshReplay = connectedEvidence.refreshReplay;
} catch (error) {
connectedSummary = error?.details?.connected || null;
refreshReplay = error?.details?.refreshReplay || null;
if (connectedSummary) report.connected = connectedSummary;
throw error;
}
phase = "live-handoff";
await realtimeWaitForSameWorkbenchDom(before, effective.barrierTimeoutMs);
await sleep(effective.reconnectQuietMs);
const after = await realtimeWorkbenchDomIdentity();
realtimeAssertSameWorkbenchDom(before, after);
const browserEvidence = await page.evaluate(() => JSON.parse(JSON.stringify(window.__unideskExistingSessionRefresh || {})));
if (browserEvidence.sources.length !== 1) throw new Error("main Workbench product EventSource count is " + browserEvidence.sources.length + ", expected 1");
if (browserEvidence.sources[0]?.opened !== 1) throw new Error("main Workbench product EventSource open count is not 1");
if (browserEvidence.connected.length !== 1) throw new Error("main Workbench connected event count is " + browserEvidence.connected.length + ", expected 1");
if (browserEvidence.failures.length !== 0) throw realtimeExistingRefreshStreamError(browserEvidence.failures.at(-1));
if (browserEvidence.errors.length !== 0) throw new Error("main Workbench product EventSource observed an error");
realtimeAssertExistingRefreshDelivery(browserEvidence, refreshReplay);
const mainStreams = network.filter((item) => item.path === effective.productEventsPath && item.sessionId === sessionId);
const forbidden = network.filter((item) => realtimeForbiddenRequest(item, effective.forbiddenRequestPaths));
if (mainStreams.length !== 1) throw new Error("main Workbench session SSE request count is " + mainStreams.length + ", expected 1");
if (mainStreams[0].traceId !== null || mainStreams[0].afterSeq !== null || mainStreams[0].lastEventId !== null) throw new Error("main Workbench product SSE used a trace or cursor scope");
if (forbidden.length > 0) throw new Error("forbidden recovery or polling requests observed: " + forbidden.map((item) => item.path).join(","));
phase = "evidence-write";
const screenshotFile = path.join(reportDir, "existing-session-refresh.png");
await page.screenshot({ path: screenshotFile, fullPage: true, animations: "disabled", timeout: screenshotCaptureTimeoutMs });
const screenshotMeta = await fileMeta(screenshotFile);
screenshot = { kind: "existing-session-refresh-screenshot", path: screenshotFile, ...screenshotMeta, commandId: activeCommandId, valuesRedacted: true };
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), ...screenshot });
const productSse = {
requestCount: mainStreams.length,
browserSourceCount: browserEvidence.sources.length,
openCount: browserEvidence.sources[0]?.opened || 0,
connectedEventCount: browserEvidence.connected.length,
businessEventCount: browserEvidence.businessEventCount,
typedFailureCount: browserEvidence.failures.length,
livePhase: refreshReplay?.phase === "live",
valuesRedacted: true,
};
Object.assign(report, {
ok: true,
status: "passed",
phase: "live",
code: "refresh_live_handoff_validated",
completedAt: new Date().toISOString(),
after,
connected: connectedSummary,
refreshReplay,
productSse,
forbiddenRequests: { count: 0, rows: [], valuesRedacted: true },
screenshot,
valuesRedacted: true,
});
const artifacts = await realtimeWriteEvidenceArtifacts(reportDir, network, [], "existing-session-refresh");
report.artifacts = artifacts;
const reportArtifact = await realtimeWriteReport(reportDir, report, "existing-session-refresh-report");
const observer = await syncObserverPageToControlSession("validateExistingSessionRefresh", sessionId);
await appendJsonl(files.control, eventRecord("existing-session-refresh-validated", {
profile: profileId,
sessionId,
traceIds: after.traceIds,
phase: "live",
code: "refresh_live_handoff_validated",
reportPath: reportArtifact.path,
reportSha256: reportArtifact.sha256,
screenshotSha256: screenshot.sha256,
valuesRedacted: true,
}));
return {
ok: true,
status: "passed",
profile: profileId,
sessionId,
traceIds: after.traceIds,
phase: "live",
code: "refresh_live_handoff_validated",
before: realtimeWorkbenchDomSummary(before),
after: realtimeWorkbenchDomSummary(after),
refreshReplay,
productSse,
forbiddenRequestCount: 0,
reportPath: reportArtifact.path,
reportSha256: reportArtifact.sha256,
screenshot,
observer,
valuesRedacted: true,
};
} catch (error) {
const failure = realtimeExistingRefreshFailure(phase, error);
if (!screenshot) {
screenshot = await captureScreenshot("existing-session-refresh-failed-" + safeId(command.id))
.then((artifact) => ({ path: artifact.path, sha256: artifact.sha256, valuesRedacted: true }))
.catch((screenshotError) => ({ available: false, error: errorSummary(screenshotError), valuesRedacted: true }));
}
Object.assign(report, {
ok: false,
status: "failed",
completedAt: new Date().toISOString(),
phase: failure.phase,
code: failure.code,
refreshReplay,
failure,
screenshot,
valuesRedacted: true,
});
const artifacts = await realtimeWriteEvidenceArtifacts(reportDir, network, [], "existing-session-refresh").catch(() => null);
if (artifacts) report.artifacts = artifacts;
const reportArtifact = await realtimeWriteReport(reportDir, report, "existing-session-refresh-report").catch(() => null);
const wrapped = error instanceof Error ? error : new Error(String(error));
wrapped.details = {
...(wrapped.details || {}),
profile: profileId,
sessionId,
traceIds: report.after?.traceIds || report.before?.traceIds || [],
phase: failure.phase,
code: failure.code,
failure,
before: report.before ? realtimeWorkbenchDomSummary(report.before) : null,
after: report.after ? realtimeWorkbenchDomSummary(report.after) : null,
refreshReplay,
screenshot,
reportPath: reportArtifact?.path || null,
reportSha256: reportArtifact?.sha256 || null,
valuesRedacted: true,
};
throw wrapped;
} finally {
if (requestPage) requestPage.off("request", requestListener);
await page.evaluate(() => window.sessionStorage.removeItem("__unideskExistingSessionRefreshCapture")).catch(() => {});
}
}
function realtimeExistingRefreshFailure(phase, error) {
const codes = {
"session-selection": "session_selection_failed",
"refresh-navigation": "session_hydration_failed",
"retention-barrier": "retention_barrier_failed",
"live-handoff": "live_handoff_failed",
"evidence-write": "evidence_write_failed",
};
const streamFailure = realtimeExistingRefreshStreamFailure(error?.details?.streamFailure);
return {
phase,
code: codes[phase] || "existing_session_refresh_failed",
streamPhase: streamFailure?.phase || null,
streamCode: streamFailure?.code || null,
streamFailure,
error: errorSummary(error),
valuesRedacted: true,
};
}
function realtimeExistingRefreshConnectedEvidence(connected, sessionId, profile) {
const connectedSummary = realtimeConnectedSummary(connected);
const refreshReplay = connectedSummary.refreshReplay;
try {
realtimeAssertConnectedContract(connected, sessionId, "existing-session-refresh", profile, { requireRetainedEvents: true });
} catch (error) {
const wrapped = error instanceof Error ? error : new Error(String(error));
wrapped.details = { ...(wrapped.details || {}), connected: connectedSummary, refreshReplay, valuesRedacted: true };
throw wrapped;
}
return { connected: connectedSummary, refreshReplay, valuesRedacted: true };
}
function realtimeAssertExistingRefreshDelivery(browserEvidence, refreshReplay) {
const businessEventCount = Number(browserEvidence?.businessEventCount);
const replayed = Number(refreshReplay?.counts?.replayed);
const bufferedDelivered = Number(refreshReplay?.counts?.bufferedDelivered);
if (![businessEventCount, replayed, bufferedDelivered].every((value) => Number.isInteger(value) && value >= 0)) {
throw new Error("existing session refresh delivery counts are invalid");
}
const minimumDelivered = replayed + bufferedDelivered;
if (businessEventCount < minimumDelivered) {
throw new Error("existing session refresh browser observed " + businessEventCount + " business events, fewer than replayed plus buffered delivery " + minimumDelivered);
}
}
function realtimeExistingRefreshStreamError(value) {
const streamFailure = realtimeExistingRefreshStreamFailure(value);
const error = new Error(streamFailure?.message || "existing session refresh stream reported a typed failure");
error.details = { streamFailure, valuesRedacted: true };
return error;
}
function realtimeExistingRefreshStreamFailure(value) {
const nested = value?.error && typeof value.error === "object" ? value.error : value;
const phase = typeof value?.phase === "string" ? value.phase : typeof nested?.phase === "string" ? nested.phase : null;
const code = typeof nested?.code === "string" ? nested.code : typeof value?.code === "string" ? value.code : null;
const message = typeof nested?.message === "string" ? nested.message.slice(0, 240) : typeof value?.message === "string" ? value.message.slice(0, 240) : null;
if (!phase && !code && !message) return null;
return {
phase,
code,
message,
retryable: nested?.retryable === true,
fallback: value?.fallback === true,
valuesRedacted: true,
};
}
async function realtimeArmExistingSessionRefreshProbe(targetPage, profile) {
await targetPage.addInitScript(() => {
const markerKey = "__unideskExistingSessionRefreshCapture";
const raw = window.sessionStorage.getItem(markerKey);
if (!raw) return;
window.sessionStorage.removeItem(markerKey);
let config;
try { config = JSON.parse(raw); } catch { return; }
const NativeEventSource = window.EventSource;
const state = { sources: [], connected: [], businessEventCount: 0, failures: [], errors: [] };
window.__unideskExistingSessionRefresh = state;
if (typeof NativeEventSource !== "function") {
state.errors.push({ kind: "eventsource-unavailable" });
return;
}
class ObservedEventSource extends NativeEventSource {
constructor(url, options) {
super(url, options);
let pathname = "";
try { pathname = new URL(String(url), window.location.href).pathname; } catch {}
if (pathname !== config.productEventsPath) return;
state.sources.push({ url: String(url), opened: 0 });
const source = state.sources[state.sources.length - 1];
this.addEventListener("open", () => { source.opened += 1; });
this.addEventListener(config.productReadyEvent, (event) => {
try { state.connected.push(JSON.parse(event.data)); } catch { state.errors.push({ kind: "connected-parse" }); }
});
this.addEventListener(config.businessEvent, () => { state.businessEventCount += 1; });
this.addEventListener("workbench.error", (event) => {
try { state.failures.push(JSON.parse(event.data)); } catch { state.errors.push({ kind: "workbench-error-parse" }); }
});
this.addEventListener("error", () => { state.errors.push({ kind: "eventsource-error" }); });
}
}
Object.defineProperty(ObservedEventSource, "CONNECTING", { value: NativeEventSource.CONNECTING });
Object.defineProperty(ObservedEventSource, "OPEN", { value: NativeEventSource.OPEN });
Object.defineProperty(ObservedEventSource, "CLOSED", { value: NativeEventSource.CLOSED });
window.EventSource = ObservedEventSource;
});
await targetPage.evaluate((config) => {
window.sessionStorage.setItem("__unideskExistingSessionRefreshCapture", JSON.stringify(config));
}, {
productEventsPath: profile.productEventsPath,
productReadyEvent: profile.productReadyEvent,
businessEvent: profile.businessEvent,
});
}
async function realtimeWorkbenchDomIdentity() {
return page.evaluate(() => {
const cards = Array.from(document.querySelectorAll("#conversation-list > .message-card"));
const rows = cards.map((element, index) => ({
index,
role: element.getAttribute("data-role") || null,
messageId: element.getAttribute("data-message-id") || null,
traceId: element.getAttribute("data-trace-id") || null,
status: element.getAttribute("data-status") || null,
}));
const identity = (row) => [row.role || "", row.messageId || "", row.traceId || ""].join(":");
const identities = rows.map(identity);
return {
messageCount: rows.length,
identities,
missingIdentityRows: rows.filter((row) => !row.role || (!row.messageId && !row.traceId)).map((row) => row.index),
duplicateIdentities: [...new Set(identities.filter((value, index, all) => all.indexOf(value) !== index))],
traceIds: [...new Set(rows.map((row) => row.traceId).filter(Boolean))],
rows,
valuesRedacted: true,
};
});
}
function realtimeAssertSameWorkbenchDom(before, after) {
if (before.missingIdentityRows.length > 0 || after.missingIdentityRows.length > 0) throw new Error("Workbench DOM contains rows without a stable message identity");
if (before.duplicateIdentities.length > 0 || after.duplicateIdentities.length > 0) throw new Error("Workbench DOM contains duplicate message identities");
if (before.messageCount !== after.messageCount) throw new Error("Workbench message count changed across refresh: " + before.messageCount + " -> " + after.messageCount);
if (JSON.stringify(before.identities) !== JSON.stringify(after.identities)) throw new Error("Workbench message identity or order changed across refresh");
}
async function realtimeWaitForSameWorkbenchDom(before, timeoutMs) {
let latest = null;
return realtimePoll(async () => {
latest = await realtimeWorkbenchDomIdentity();
try {
realtimeAssertSameWorkbenchDom(before, latest);
return latest;
} catch {
return null;
}
}, timeoutMs, "existing session Workbench DOM identity restoration").catch((error) => {
const wrapped = error instanceof Error ? error : new Error(String(error));
wrapped.details = { ...(wrapped.details || {}), before: realtimeWorkbenchDomSummary(before), after: realtimeWorkbenchDomSummary(latest), valuesRedacted: true };
throw wrapped;
});
}
function realtimeWorkbenchDomSummary(value) {
return value ? {
messageCount: value.messageCount,
identities: Array.isArray(value.identities) ? value.identities.slice(0, 80) : [],
missingIdentityRows: Array.isArray(value.missingIdentityRows) ? value.missingIdentityRows.slice(0, 20) : [],
duplicateIdentities: Array.isArray(value.duplicateIdentities) ? value.duplicateIdentities.slice(0, 20) : [],
traceIds: Array.isArray(value.traceIds) ? value.traceIds.slice(0, 20) : [],
valuesRedacted: true,
} : null;
}
function realtimeFanoutEffectiveProfile(profile, durationMs) {
const debugStreams = Array.isArray(profile.debugStreams) ? profile.debugStreams.map(String) : [];
if (Number(profile.subscriberCount) !== 2) throw new Error("realtime fanout profile subscriberCount must be 2");
@@ -1041,7 +1426,7 @@ function realtimeDebugSnapshotSummary(snapshot) {
};
}
async function realtimeWriteEvidenceArtifacts(reportDir, network, debugSnapshots) {
async function realtimeWriteEvidenceArtifacts(reportDir, network, debugSnapshots, artifactPrefix = "realtime-fanout") {
const requestsFile = path.join(reportDir, "request-ledger.jsonl");
const eventsFile = path.join(reportDir, "events.jsonl");
await writeFile(requestsFile, network.map((item) => JSON.stringify(item)).join("\n") + "\n", { mode: 0o600 });
@@ -1059,16 +1444,16 @@ async function realtimeWriteEvidenceArtifacts(reportDir, network, debugSnapshots
events: { path: eventsFile, ...eventMeta, count: eventRows.length, valuesRedacted: true },
valuesRedacted: true,
};
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: "realtime-fanout-request-ledger", commandId: activeCommandId, ...artifacts.requestLedger });
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: "realtime-fanout-events", commandId: activeCommandId, ...artifacts.events });
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: artifactPrefix + "-request-ledger", commandId: activeCommandId, ...artifacts.requestLedger });
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: artifactPrefix + "-events", commandId: activeCommandId, ...artifacts.events });
return artifacts;
}
async function realtimeWriteReport(reportDir, report) {
async function realtimeWriteReport(reportDir, report, artifactKind = "realtime-fanout-report") {
const reportFile = path.join(reportDir, "report.json");
await writeFile(reportFile, JSON.stringify(sanitize(report), null, 2) + "\n", { mode: 0o600 });
const meta = await fileMeta(reportFile);
const artifact = { kind: "realtime-fanout-report", path: reportFile, byteCount: meta.byteCount, sha256: meta.sha256, commandId: activeCommandId, valuesRedacted: true };
const artifact = { kind: artifactKind, path: reportFile, byteCount: meta.byteCount, sha256: meta.sha256, commandId: activeCommandId, valuesRedacted: true };
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), ...artifact });
return artifact;
}
@@ -72,6 +72,9 @@ export type WebProbeSentinelOptions =
readonly wait: boolean;
readonly timeoutSeconds: number;
readonly rerun: boolean;
readonly manualRecovery: boolean;
readonly reason: string | null;
readonly internalExecution: "pac-controller" | null;
readonly sourceOverride: WebProbeSentinelSourceOverrideOptions;
}
| {
@@ -465,14 +468,16 @@ export function renderPublishResult(publish: Record<string, unknown>): string {
lines.push(
"",
"PUBLISH_DRILLDOWN",
` status: ${commands.cliStatus ?? "-"}`,
` logs: ${commands.logs ?? "-"}`,
` describe: ${commands.describe ?? "-"}`,
` manual-recovery: ${commands.publishCurrent ?? "-"}`,
` git-mirror: ${commands.gitMirrorStatus ?? "-"}`,
` sync: ${commands.gitMirrorSync ?? "-"}`,
` flush: ${commands.gitMirrorFlush ?? "-"}`,
` apply: ${commands.controlPlaneApply ?? "-"}`,
...commandRows([
["status", commands.cliStatus],
["logs", commands.logs],
["describe", commands.describe],
["manual-recovery", commands.publishCurrent],
["git-mirror", commands.gitMirrorStatus],
["sync", commands.gitMirrorSync],
["flush", commands.gitMirrorFlush],
["apply", commands.controlPlaneApply],
]),
);
}
return lines.join("\n");
@@ -592,43 +597,14 @@ export function renderPublishCurrentResult(result: Record<string, unknown>): str
"",
Object.keys(blocker).length === 0 ? "BLOCKER\n-" : ["BLOCKER", table(["CODE", "REASON"], [[blocker.code, blocker.reason]])].join("\n"),
"",
Object.keys(drillDown).length === 0 ? "DRILLDOWN\n-" : [
"DRILLDOWN",
table(["PIPELINERUN", "ASYNC_FLUSH_JOB"], [[drillDown.pipelineRun ?? "-", drillDown.asyncFlushJob ?? "-"]]),
` pipeline-run: ${drillDown.pipelineRunStatus ?? "-"}`,
` pipeline-run-full: ${drillDown.pipelineRunFull ?? "-"}`,
` pac-status: ${drillDown.consumerStatus ?? "-"}`,
` image-status: ${drillDown.imageStatus ?? "-"}`,
` git-mirror: ${drillDown.gitMirrorStatus ?? "-"}`,
` flush: ${drillDown.gitMirrorFlush ?? "-"}`,
` async-flush-status: ${drillDown.asyncFlushStatus ?? "-"}`,
].join("\n"),
renderSentinelDrillDown(drillDown),
"",
Object.keys(recoveryNext).length === 0 ? "RECOVERY_NEXT\n-" : [
"RECOVERY_NEXT",
table(["REASON", "PIPELINERUN", "DIGEST", "GITOPS"], [[recoveryNext.reason, recoveryNext.pipelineRun ?? "-", short(recoveryNext.digestRef), short(recoveryNext.gitopsCommit)]]),
` pac-closeout: ${recoveryNext.pacCloseout ?? "-"}`,
` manual-recovery: ${recoveryNext.publishCurrent ?? "-"}`,
` status: ${recoveryNext.nextStatus ?? "-"}`,
` git-mirror: ${recoveryNext.gitMirrorStatus ?? "-"}`,
` sync: ${recoveryNext.gitMirrorSync ?? "-"}`,
` flush: ${recoveryNext.gitMirrorFlush ?? "-"}`,
` apply: ${recoveryNext.controlPlaneApply ?? "-"}`,
].join("\n"),
renderSentinelRecoveryNext(recoveryNext),
"",
"NEXT",
` pac-closeout: ${next.pacCloseout ?? "-"}`,
` pac-status: ${next.pacStatus ?? "-"}`,
` pac-history: ${next.pacHistory ?? "-"}`,
` status: ${next.controlPlaneStatus ?? "-"}`,
` post-deploy-dashboard: ${next.dashboardVerify ?? "-"}`,
` git-mirror: ${next.gitMirrorStatus ?? "-"}`,
` sync: ${next.gitMirrorSync ?? "-"}`,
` flush: ${next.gitMirrorFlush ?? "-"}`,
renderSentinelNext(next),
"",
"DISCLOSURE",
" formal closeout for migrated JD01 sentinel CI/CD is platform-infra pipelines-as-code closeout/status/history.",
" publish-current is a debug/test/recovery step only; operator-side recovery must be explicit.",
" migrated PaC output only exposes status/history and the stable automatic-chain repair reference.",
` end-to-end and stage budgets are read from ${Object.keys(validationPlan).length > 0 ? "publishCurrent YAML and runtime.healthPath" : "YAML-required publishCurrent fields"}.`,
" CI/CD validation only checks the configured health endpoint; web-probe, Playwright and browser dashboard checks are post-deploy evidence, not this gate.",
" image build uses Tekton PipelineRun and BuildKit; this command does not require Docker daemon/socket/build.",
@@ -670,12 +646,7 @@ export function renderImageResult(result: Record<string, unknown>): string {
"",
Object.keys(blocker).length === 0 ? "BLOCKER\n-" : ["BLOCKER", table(["CODE", "REASON"], [[blocker.code, blocker.reason]])].join("\n"),
"",
"NEXT",
` status: ${next.status ?? "-"}`,
` dry-run: ${next.dryRun ?? "-"}`,
` confirm: ${next.confirm ?? "-"}`,
` trigger: ${next.controlPlaneTrigger ?? "-"}`,
` control-plane: ${next.controlPlanePlan ?? "-"}`,
renderSentinelNext(next),
"",
"DISCLOSURE",
" valuesRedacted=true; image status shows refs, hashes and object names only.",
@@ -758,13 +729,7 @@ export function renderControlPlaneResult(result: Record<string, unknown>): strin
"",
flush.mode === "async-job" ? `ASYNC_FLUSH_STATUS\n ${flushNext.status ?? "-"}` : "ASYNC_FLUSH_STATUS\n-",
"",
"NEXT",
` pac-closeout: ${next.pacCloseout ?? "-"}`,
` pac-status: ${next.pacStatus ?? "-"}`,
` pac-history: ${next.pacHistory ?? "-"}`,
` pipeline-run: ${drillDown.pipelineRunStatus ?? "-"}`,
` status: ${next.controlPlaneStatus ?? next.status ?? "-"}`,
` post-deploy-dashboard: ${next.dashboardVerify ?? "-"}`,
renderSentinelNext({ ...next, pipelineRunStatus: drillDown.pipelineRunStatus }),
"",
"DISCLOSURE",
" compact success view keeps CI/CD closeout fields below the global stdout guard.",
@@ -801,17 +766,7 @@ export function renderControlPlaneResult(result: Record<string, unknown>): strin
]]),
].join("\n"),
"",
Object.keys(drillDown).length === 0 ? "DRILLDOWN\n-" : [
"DRILLDOWN",
table(["PIPELINERUN", "ASYNC_FLUSH_JOB"], [[drillDown.pipelineRun ?? "-", drillDown.asyncFlushJob ?? "-"]]),
` pipeline-run: ${drillDown.pipelineRunStatus ?? "-"}`,
` pipeline-run-full: ${drillDown.pipelineRunFull ?? "-"}`,
` pac-status: ${drillDown.consumerStatus ?? "-"}`,
` image-status: ${drillDown.imageStatus ?? "-"}`,
` git-mirror: ${drillDown.gitMirrorStatus ?? "-"}`,
` flush: ${drillDown.gitMirrorFlush ?? "-"}`,
` async-flush-status: ${drillDown.asyncFlushStatus ?? "-"}`,
].join("\n"),
renderSentinelDrillDown(drillDown),
"",
Object.keys(sourceMirrorSync).length === 0 ? "SOURCE_MIRROR_SYNC\n-" : table(["OK", "PHASE", "JOB", "COMMIT", "STAGE_REF", "ELAPSED"], [[sourceMirrorSync.ok, sourceMirrorSync.phase, sourceMirrorSync.jobName, short(record(sourceMirrorSync.payload).mirrorCommit), short(record(sourceMirrorSync.payload).stageRef), sourceMirrorSync.elapsedMs ?? "-"]]),
"",
@@ -850,32 +805,9 @@ export function renderControlPlaneResult(result: Record<string, unknown>): strin
"",
Object.keys(blocker).length === 0 ? "BLOCKER\n-" : ["BLOCKER", table(["CODE", "REASON"], [[blocker.code, blocker.reason]])].join("\n"),
"",
Object.keys(recoveryNext).length === 0 ? "RECOVERY_NEXT\n-" : [
"RECOVERY_NEXT",
table(["REASON", "PIPELINERUN", "DIGEST", "GITOPS"], [[recoveryNext.reason, recoveryNext.pipelineRun ?? "-", short(recoveryNext.digestRef), short(recoveryNext.gitopsCommit)]]),
` pac-closeout: ${recoveryNext.pacCloseout ?? "-"}`,
` manual-recovery: ${recoveryNext.publishCurrent ?? "-"}`,
` status: ${recoveryNext.nextStatus ?? "-"}`,
` git-mirror: ${recoveryNext.gitMirrorStatus ?? "-"}`,
` sync: ${recoveryNext.gitMirrorSync ?? "-"}`,
` flush: ${recoveryNext.gitMirrorFlush ?? "-"}`,
` apply: ${recoveryNext.controlPlaneApply ?? "-"}`,
].join("\n"),
renderSentinelRecoveryNext(recoveryNext),
"",
"NEXT",
` pac-closeout: ${next.pacCloseout ?? "-"}`,
` pac-status: ${next.pacStatus ?? "-"}`,
` pac-history: ${next.pacHistory ?? "-"}`,
` plan: ${next.plan ?? "-"}`,
` status: ${next.status ?? "-"}`,
` image: ${next.image ?? "-"}`,
` trigger-current: ${next.triggerCurrent ?? "-"}`,
` apply: ${next.apply ?? "-"}`,
` validate: ${next.validate ?? "-"}`,
` quick-verify: ${next.quickVerify ?? "-"}`,
` git-mirror: ${next.gitMirrorStatus ?? "-"}`,
` sync: ${next.gitMirrorSync ?? "-"}`,
` flush: ${next.gitMirrorFlush ?? "-"}`,
renderSentinelNext(next),
"",
"DISCLOSURE",
" default view is a bounded CI/CD summary; full manifest content is represented by object counts and sha256.",
@@ -883,6 +815,89 @@ export function renderControlPlaneResult(result: Record<string, unknown>): strin
].join("\n");
}
function renderSentinelNext(next: Record<string, unknown>): string {
const status = commandText(next.pacStatus) ?? commandText(next.status) ?? commandText(next.controlPlaneStatus);
const controlPlaneStatus = commandText(next.controlPlaneStatus);
const history = commandText(next.pacHistory) ?? commandText(next.history);
const fix = commandText(next.fixAutomaticDelivery) ?? commandText(record(next.fixAutomaticDelivery).reference);
const rows: Array<[string, unknown]> = [
["status", status],
["control-plane-status", controlPlaneStatus === status ? null : controlPlaneStatus],
["history", history],
["fix-automatic-delivery", fix],
["pipeline-run", next.pipelineRunStatus],
["events", next.events],
["logs", next.logs],
["pac-closeout", next.pacCloseout],
["plan", next.plan ?? next.controlPlanePlan],
["image", next.image],
["image-status", next.imageStatus],
["dry-run", next.dryRun],
["confirm", next.confirm],
["trigger", next.controlPlaneTrigger ?? next.triggerCurrent],
["apply", next.apply],
["validate", next.validate],
["quick-verify", next.quickVerify],
["post-deploy-dashboard", next.dashboardVerify],
["git-mirror", next.gitMirrorStatus],
["sync", next.gitMirrorSync],
["flush", next.gitMirrorFlush],
];
const lines = commandRows(rows);
return ["NEXT", ...(lines.length === 0 ? ["-"] : lines)].join("\n");
}
function renderSentinelDrillDown(drillDown: Record<string, unknown>): string {
if (Object.keys(drillDown).length === 0) return "DRILLDOWN\n-";
const lines = commandRows([
["pipeline-run", drillDown.pipelineRunStatus],
["pipeline-run-full", drillDown.pipelineRunFull],
["pac-status", drillDown.consumerStatus],
["image-status", drillDown.imageStatus],
["git-mirror", drillDown.gitMirrorStatus],
["flush", drillDown.gitMirrorFlush],
["async-flush-status", drillDown.asyncFlushStatus],
]);
return [
"DRILLDOWN",
table(["PIPELINERUN", "ASYNC_FLUSH_JOB"], [[drillDown.pipelineRun ?? "-", drillDown.asyncFlushJob ?? "-"]]),
...lines,
].join("\n");
}
function renderSentinelRecoveryNext(recoveryNext: Record<string, unknown>): string {
if (Object.keys(recoveryNext).length === 0) return "RECOVERY_NEXT\n-";
const lines = commandRows([
["status", recoveryNext.nextStatus ?? recoveryNext.pacStatus ?? recoveryNext.status],
["history", recoveryNext.pacHistory ?? recoveryNext.history],
["fix-automatic-delivery", commandText(recoveryNext.fixAutomaticDelivery) ?? commandText(record(recoveryNext.fixAutomaticDelivery).reference)],
["git-mirror", recoveryNext.gitMirrorStatus],
["pac-closeout", recoveryNext.pacCloseout],
["manual-recovery", recoveryNext.publishCurrent],
["sync", recoveryNext.gitMirrorSync],
["flush", recoveryNext.gitMirrorFlush],
["apply", recoveryNext.controlPlaneApply],
]);
return [
"RECOVERY_NEXT",
table(["REASON", "PIPELINERUN", "DIGEST", "GITOPS"], [[recoveryNext.reason ?? "-", recoveryNext.pipelineRun ?? "-", short(recoveryNext.digestRef), short(recoveryNext.gitopsCommit)]]),
...lines,
].join("\n");
}
function commandRows(rows: Array<[string, unknown]>): string[] {
return rows.flatMap(([label, value]) => {
const command = commandText(value);
return command === null ? [] : [` ${label}: ${command}`];
});
}
function commandText(value: unknown): string | null {
if (typeof value !== "string") return null;
const command = value.trim();
return command.length === 0 || command === "-" ? null : command;
}
export function renderObservedStatus(observed: Record<string, unknown>): string {
const rows = [
observedStatusRow("source", observed.sourceMirror),
+88 -75
View File
@@ -16,11 +16,22 @@ import { join } from "node:path";
import { repoRoot, rootPath } from "./config";
import { runCommand, type CommandResult } from "./command";
import { runtimeReuseService, summarizeRuntimeReuseConfig, type RuntimeReuseConfig } from "./cicd-reuse-config";
import type { CicdDeliveryAuthority } from "./cicd-delivery-authority";
import { startJob } from "./jobs";
import { webProbeSentinelConfigPlan, withWebProbeSentinelConfigRendered } from "./hwlab-node-web-sentinel-config";
import { readWebProbeSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-config-ref";
import { effectiveWebProbeSentinelPublicExposure, requireSentinelIdForRegistry, resolveWebProbeSentinel } from "./hwlab-node-web-sentinel-resolver";
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
import {
guardedSentinelDeliveryAction,
publishCurrentNext,
renderBlockedSentinelDeliveryMutation,
sentinelReadOnlyNext,
sentinelSourceOverrideFromOptions,
sentinelSourceResolveMode,
webProbeSentinelDeliveryAuthority,
type SentinelSourceOverride,
} from "./hwlab-node/sentinel-delivery-authority";
import type { RenderedCliResult } from "./output";
import { probeSentinelRuntimeHealthEndpoint, runSentinelDashboard, runSentinelErrorDetail, runSentinelInspect, runSentinelMaintenance, runSentinelReport, runSentinelValidate } from "./hwlab-node-web-sentinel-p5";
import { runChildCli, sentinelP5Next } from "./hwlab-node-web-sentinel-p5-observe";
@@ -136,21 +147,26 @@ export {
table,
text,
};
export {
guardedSentinelDeliveryAction,
webProbeSentinelDeliveryAuthority,
webProbeSentinelDeliveryAuthorityFromCatalog,
webProbeSentinelDeliveryMutationDecision,
} from "./hwlab-node/sentinel-delivery-authority";
export type { SentinelSourceOverride } from "./hwlab-node/sentinel-delivery-authority";
const SPEC_REF = "PJ2026-01060508 Web哨兵 draft-2026-07-01-p16-cicd-source-snapshot";
export type SourceResolveMode = "cached" | "sync";
export interface SentinelSourceOverride {
readonly commit: string;
readonly stageRef?: string | null;
readonly mirrorCommit?: string | null;
readonly sourceAuthority: WebProbeSentinelSourceAuthority;
}
export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options: WebProbeSentinelOptions): RenderedCliResult {
if (options.kind === "config") return withWebProbeSentinelConfigRendered(webProbeSentinelConfigPlan(spec, options.action, options.sentinelId));
const guardedAction = guardedSentinelDeliveryAction(options);
if (guardedAction !== null) {
const blocked = renderBlockedSentinelDeliveryMutation(spec, options, guardedAction.command, guardedAction.action);
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));
if (options.kind === "image") return runSentinelImage(state, options);
@@ -164,28 +180,9 @@ export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options:
return runSentinelReport(state, options);
}
function sentinelSourceOverrideFromOptions(options: WebProbeSentinelOptions): SentinelSourceOverride | null {
if (options.kind !== "image" && options.kind !== "control-plane" && options.kind !== "publish") return null;
const override = options.sourceOverride;
if (override.sourceCommit === null && override.sourceStageRef === null && override.sourceMirrorCommit === null && override.sourceAuthority === null) return null;
if (override.sourceCommit === null) throw new Error("--source-commit is required when overriding web-probe sentinel source");
return {
commit: override.sourceCommit,
stageRef: override.sourceStageRef,
mirrorCommit: override.sourceMirrorCommit,
sourceAuthority: override.sourceAuthority ?? "git-mirror-snapshot",
};
}
function sentinelSourceResolveMode(options: WebProbeSentinelOptions): SourceResolveMode {
if (options.kind === "image" && options.action === "build" && options.confirm && options.wait) return "sync";
if (options.kind === "control-plane" && options.action === "trigger-current" && options.confirm && options.wait) return "sync";
if (options.kind === "publish" && options.confirm && options.wait) return "sync";
return "cached";
}
function runSentinelImage(state: SentinelCicdState, options: Extract<WebProbeSentinelOptions, { kind: "image" }>): RenderedCliResult {
const command = `web-probe sentinel image ${options.action}`;
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
if (options.action === "build" && options.confirm) {
if (!options.wait) return renderAsyncSentinelJob(state, "image", "build", options.timeoutSeconds);
return runSentinelImageBuildConfirmed(state, options);
@@ -210,12 +207,15 @@ function runSentinelImage(state: SentinelCicdState, options: Extract<WebProbeSen
blocker: sourceMirrorReady
? 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" },
next: {
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}${sentinelCliSuffix(state)} --dry-run`,
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}${sentinelCliSuffix(state)} --dry-run`,
},
deliveryAuthority,
next: deliveryAuthority.kind === "legacy-manual"
? {
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}${sentinelCliSuffix(state)} --dry-run`,
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}${sentinelCliSuffix(state)} --dry-run`,
}
: sentinelReadOnlyNext(state, deliveryAuthority),
valuesRedacted: true,
};
return rendered(result.ok, command, renderImageResult(result));
@@ -223,6 +223,7 @@ function runSentinelImage(state: SentinelCicdState, options: Extract<WebProbeSen
function runSentinelControlPlane(state: SentinelCicdState, options: Extract<WebProbeSentinelOptions, { kind: "control-plane" }>): RenderedCliResult {
const command = `web-probe sentinel control-plane ${options.action}`;
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
const mutationAction = options.action === "apply" || options.action === "trigger-current";
if (options.confirm && mutationAction) {
if (!options.wait) return renderAsyncSentinelJob(state, "control-plane", options.action, options.timeoutSeconds);
@@ -241,6 +242,7 @@ function runSentinelControlPlane(state: SentinelCicdState, options: Extract<WebP
sentinelId: state.sentinelId,
mode: options.action === "status" ? "status" : options.confirm ? "confirm" : "dry-run",
mutation: false,
deliveryAuthority,
specRef: SPEC_REF,
source: state.sourceHead,
image: state.image,
@@ -1094,29 +1096,6 @@ function publishCurrentBlocker(controlResult: Record<string, unknown>, health: R
return { code: "sentinel-publish-current-blocked", reason: "publish-current did not satisfy all checks", valuesRedacted: true };
}
function publishCurrentNext(state: SentinelCicdState): Record<string, string> {
const node = state.spec.nodeId;
const lane = state.spec.lane;
const suffix = sentinelCliSuffix(state);
const consumer = sentinelPacConsumerId(state);
const sourceCommit = state.sourceHead.commit === null ? "" : ` --source-commit ${state.sourceHead.commit}`;
return {
pacCloseout: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${node} --consumer ${consumer}${sourceCommit} --wait`,
pacStatus: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${node} --consumer ${consumer}`,
pacHistory: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${node} --consumer ${consumer} --limit 10`,
manualRecovery: `bun scripts/cli.ts web-probe sentinel publish-current --node ${node} --lane ${lane}${suffix} --confirm --wait --manual-recovery --reason <reason>`,
controlPlaneStatus: `bun scripts/cli.ts web-probe sentinel control-plane status --node ${node} --lane ${lane}${suffix}`,
dashboardVerify: `bun scripts/cli.ts web-probe sentinel dashboard verify --node ${node} --lane ${lane}${suffix}`,
gitMirrorStatus: `bun scripts/cli.ts hwlab nodes git-mirror status --node ${node} --lane ${lane}`,
gitMirrorSync: `bun scripts/cli.ts hwlab nodes git-mirror sync --node ${node} --lane ${lane} --confirm`,
gitMirrorFlush: `bun scripts/cli.ts hwlab nodes git-mirror flush --node ${node} --lane ${lane} --confirm --wait`,
};
}
function sentinelPacConsumerId(state: SentinelCicdState): string {
return `sentinel-${state.spec.nodeId.toLowerCase()}-${state.spec.lane}`;
}
function renderSentinelManifests(
spec: HwlabRuntimeLaneSpec,
sentinelId: string,
@@ -2009,6 +1988,8 @@ function sentinelObservedStatusDiagnosis(state: SentinelCicdState, value: unknow
const argoReady = argo.ok === true;
const runtimeReady = record(observed.runtime).ok === true;
const cadenceReady = cadence.ok === true;
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
const legacyDelivery = deliveryAuthority.kind === "legacy-manual";
const code = sourceReady && !registryPresent
? "sentinel-publish-half-state-registry-missing"
: registryPresent && !gitopsReady
@@ -2020,7 +2001,7 @@ function sentinelObservedStatusDiagnosis(state: SentinelCicdState, value: unknow
: !cadenceReady
? "sentinel-cadence-not-ready"
: "sentinel-control-plane-observed-not-ready";
const reason = code === "sentinel-publish-half-state-registry-missing"
const legacyReason = code === "sentinel-publish-half-state-registry-missing"
? "source mirror contains the selected commit, but the expected registry tag is missing; inspect PaC closeout/history before considering explicit manual recovery"
: code === "sentinel-gitops-manifest-not-updated"
? "registry contains the selected image, but GitOps manifest is not yet updated to a digest-pinned runtime image"
@@ -2031,6 +2012,9 @@ function sentinelObservedStatusDiagnosis(state: SentinelCicdState, value: unknow
: code === "sentinel-cadence-not-ready"
? "runtime is aligned, but cadence CronJob validation did not pass"
: "one or more source, registry, GitOps, Argo, runtime or cadence checks did not pass";
const reason = legacyDelivery
? legacyReason
: `Web sentinel automatic delivery observation is not aligned (${code}); inspect read-only status/history and fix the owning YAML, controller, or source.`;
const next = publishCurrentNext(state);
const controlNext = controlPlaneNext(state, "trigger-current");
const shouldRetryPublish = code === "sentinel-publish-half-state-registry-missing" || code === "sentinel-gitops-manifest-not-updated";
@@ -2055,26 +2039,38 @@ function sentinelObservedStatusDiagnosis(state: SentinelCicdState, value: unknow
argo: `${argo.syncStatus ?? "-"} ${argo.healthStatus ?? "-"} ${short(argo.revision)}/${short(argo.expectedRevision)}`,
runtime: `ready=${runtimeDeployment.readyReplicas ?? "-"}/${runtimeDeployment.desiredReplicas ?? "-"} image=${short(runtimeDeployment.image)} expected=${short(runtimeDeployment.expectedImage)}`,
pipelineRun,
warning: code === "sentinel-publish-half-state-registry-missing"
deliveryAuthority,
warning: !legacyDelivery
? `自动链未对齐;检查 ${next.pacStatus ?? next.status}${next.pacHistory ?? next.status},按 ${next.fixAutomaticDelivery} 修复后用新的正常 GitHub PR merge 验收。`
: code === "sentinel-publish-half-state-registry-missing"
? `source mirror already exposes ${short(state.sourceHead.commit)} but registry tag ${state.image.tag} is missing; run ${next.pacCloseout} and inspect ${next.pacHistory}.`
: code === "sentinel-git-mirror-not-in-sync"
? `runtime is aligned but git-mirror is not in sync; run ${next.gitMirrorSync} and then recheck ${next.controlPlaneStatus}.`
: null,
blocker: { code, reason, valuesRedacted: true },
recoveryNext: {
reason,
pipelineRun,
digestRef: registryPresent ? expectedRuntimeImageFromRegistry(state, record(observed.registry)) : null,
gitopsCommit: gitops.revision ?? null,
pacCloseout: next.pacCloseout,
publishCurrent: shouldRetryPublish ? next.manualRecovery : null,
nextStatus: next.pacStatus,
gitMirrorStatus: next.gitMirrorStatus,
gitMirrorSync: !gitMirrorReady ? next.gitMirrorSync : null,
gitMirrorFlush: null,
controlPlaneApply: shouldRetryPublish || code === "sentinel-runtime-not-aligned" ? controlNext.apply : null,
valuesRedacted: true,
},
recoveryNext: legacyDelivery
? {
reason,
pipelineRun,
digestRef: registryPresent ? expectedRuntimeImageFromRegistry(state, record(observed.registry)) : null,
gitopsCommit: gitops.revision ?? null,
pacCloseout: next.pacCloseout,
publishCurrent: shouldRetryPublish ? next.manualRecovery : null,
nextStatus: next.pacStatus,
gitMirrorStatus: next.gitMirrorStatus,
gitMirrorSync: !gitMirrorReady ? next.gitMirrorSync : null,
gitMirrorFlush: null,
controlPlaneApply: shouldRetryPublish || code === "sentinel-runtime-not-aligned" ? controlNext.apply : null,
valuesRedacted: true,
}
: {
reason,
status: next.status,
pacStatus: next.pacStatus,
pacHistory: next.pacHistory,
fixAutomaticDelivery: next.fixAutomaticDelivery,
valuesRedacted: true,
},
valuesRedacted: true,
};
}
@@ -2485,10 +2481,12 @@ function confirmBlocked(action: string, state: SentinelCicdState): Record<string
}
function controlPlaneNext(state: SentinelCicdState, action: WebProbeSentinelControlPlaneAction): Record<string, string> {
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
if (deliveryAuthority.kind !== "legacy-manual") return sentinelReadOnlyNext(state, deliveryAuthority);
const node = state.spec.nodeId;
const lane = state.spec.lane;
const suffix = sentinelCliSuffix(state);
const consumer = sentinelPacConsumerId(state);
const consumer = sentinelPacConsumerId(state.spec);
const sourceCommit = state.sourceHead.commit === null ? "" : ` --source-commit ${state.sourceHead.commit}`;
return {
pacCloseout: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${node} --consumer ${consumer}${sourceCommit} --wait`,
@@ -2512,6 +2510,14 @@ function controlPlaneNext(state: SentinelCicdState, action: WebProbeSentinelCont
function controlPlaneRecoveryNext(state: SentinelCicdState, ok: boolean, publish: unknown, flush: unknown, observed: unknown): Record<string, unknown> | null {
const payload = record(record(publish).payload);
if (ok || nonEmptyString(payload.digestRef) === null) return null;
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
if (deliveryAuthority.kind !== "legacy-manual") {
return {
reason: "平台维护后自动交付仍未对齐;只读检查 sentinel/PaC 状态并修复自动链,不得人工 publish、sync 或 flush。",
...sentinelReadOnlyNext(state, deliveryAuthority),
valuesRedacted: true,
};
}
const next = controlPlaneNext(state, "apply");
const flushRecord = record(flush);
const observedRecord = record(observed);
@@ -2535,12 +2541,13 @@ function controlPlaneRecoveryNext(state: SentinelCicdState, ok: boolean, publish
function controlPlaneDrillDown(state: SentinelCicdState, pipelineRun: unknown, flush: unknown): Record<string, unknown> {
const pipelineRunName = nonEmptyString(pipelineRun);
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
const next = controlPlaneNext(state, "status");
const pacBase = "bun scripts/cli.ts platform-infra pipelines-as-code";
const flushRecord = record(flush);
const flushNext = record(flushRecord.next);
const flushJob = record(flushRecord.job);
return {
const readOnly = {
pipelineRun: pipelineRunName,
pipelineRunStatus: pipelineRunName === null
? null
@@ -2549,12 +2556,18 @@ function controlPlaneDrillDown(state: SentinelCicdState, pipelineRun: unknown, f
? null
: `${pacBase} history --target ${state.spec.nodeId} --id ${pipelineRunName} --full`,
consumerStatus: `${pacBase} status --target ${state.spec.nodeId} --consumer sentinel-${state.spec.nodeId.toLowerCase()}-${state.spec.lane}`,
controlPlaneStatus: next.controlPlaneStatus ?? next.status,
fixAutomaticDelivery: next.fixAutomaticDelivery ?? PAC_AUTOMATIC_DELIVERY_REFERENCE,
valuesRedacted: true,
};
if (deliveryAuthority.kind !== "legacy-manual") return readOnly;
return {
...readOnly,
imageStatus: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`,
gitMirrorStatus: next.gitMirrorStatus,
gitMirrorFlush: next.gitMirrorFlush,
asyncFlushJob: nonEmptyString(flushJob.id),
asyncFlushStatus: nonEmptyString(flushNext.status),
valuesRedacted: true,
};
}
+2 -2
View File
@@ -202,7 +202,7 @@ export function nodeRuntimeBaseImageCommand(scoped: ReturnType<typeof parseNodeS
: "node-runtime-image-seed-failed",
next: scoped.dryRun
? { preload: `bun scripts/cli.ts hwlab nodes control-plane runtime-image preload --node ${scoped.node} --lane ${scoped.lane} --confirm` }
: { triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${scoped.node} --lane ${scoped.lane} --confirm --rerun` },
: { status: `bun scripts/cli.ts hwlab nodes control-plane runtime-image status --node ${scoped.node} --lane ${scoped.lane}` },
};
}
@@ -416,7 +416,7 @@ export function nodeRuntimeApply(scoped: ReturnType<typeof parseNodeScopedDelega
next: scoped.dryRun
? { apply: `bun scripts/cli.ts hwlab nodes control-plane apply --node ${scoped.node} --lane ${scoped.lane} --confirm` }
: {
triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${scoped.node} --lane ${scoped.lane} --confirm`,
status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane}`,
...(sshTcpPoolDiagnostics === null ? {} : { sshPoolStatus: `bun scripts/cli.ts debug ssh-pool ${scoped.node}` }),
},
};
@@ -0,0 +1,92 @@
import {
decideCicdDeliveryMutation,
gitRepositoryIdentity,
pacAutomaticDeliveryFix,
pacReadOnlyNext,
resolveCicdDeliveryAuthority,
type CicdDeliveryAuthority,
type CicdDeliveryMutationDecision,
} from "../cicd-delivery-authority";
import { hwlabRuntimeLaneConfigPath, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
export interface HwlabNodeDeliveryTarget {
readonly node: string;
readonly lane: string;
readonly spec: HwlabRuntimeLaneSpec;
}
export function hwlabNodeDeliveryAuthority(target: HwlabNodeDeliveryTarget): CicdDeliveryAuthority {
return resolveCicdDeliveryAuthority({
node: target.node,
lane: target.lane,
sourceRepository: gitRepositoryIdentity(target.spec.gitUrl),
sourceBranch: target.spec.sourceBranch,
declaredSourceAuthorityMode: target.spec.sourceAuthority?.mode ?? null,
declaredConfigRef: hwlabRuntimeLaneConfigPath(),
});
}
export function hwlabNodeDeliveryMutationDecision(
target: HwlabNodeDeliveryTarget,
command: string,
action: string,
): CicdDeliveryMutationDecision {
return decideCicdDeliveryMutation(hwlabNodeDeliveryAuthority(target), {
command,
statusCommand: `bun scripts/cli.ts hwlab nodes control-plane status --node ${target.node} --lane ${target.lane}`,
action,
node: target.node,
lane: target.lane,
});
}
export function hwlabNodeDeliveryNext(
target: HwlabNodeDeliveryTarget,
authority: CicdDeliveryAuthority,
runtimeStatusFull: string,
): Record<string, unknown> {
if (authority.kind === "pac-pr-merge") {
return {
runtimeStatusFull,
...pacReadOnlyNext(authority.consumer.node, authority.consumer.consumerId),
};
}
if (authority.kind === "unknown") {
return {
runtimeStatusFull,
fixAutomaticDelivery: pacAutomaticDeliveryFix(authority),
valuesPrinted: false,
};
}
return {
full: runtimeStatusFull,
plan: `bun scripts/cli.ts hwlab nodes control-plane plan --node ${target.node} --lane ${target.lane}`,
triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${target.node} --lane ${target.lane} --confirm`,
gitMirrorFlush: `bun scripts/cli.ts hwlab nodes git-mirror flush --node ${target.node} --lane ${target.lane} --confirm --wait`,
webProbe: `bun scripts/cli.ts web-probe run --node ${target.node} --lane ${target.lane}`,
};
}
export function hwlabNodePlanNext(
target: HwlabNodeDeliveryTarget,
authority: CicdDeliveryAuthority,
): Record<string, unknown> {
const status = `bun scripts/cli.ts hwlab nodes control-plane status --node ${target.node} --lane ${target.lane}`;
if (authority.kind === "pac-pr-merge") {
return pacReadOnlyNext(authority.consumer.node, authority.consumer.consumerId);
}
if (authority.kind === "unknown") {
return {
status,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${target.node} --limit 10`,
fixAutomaticDelivery: pacAutomaticDeliveryFix(authority),
valuesPrinted: false,
};
}
return {
status,
platformMaintenanceHelp: "bun scripts/cli.ts hwlab nodes control-plane platform-maintenance --help",
legacyCicdHelp: "bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help",
valuesPrinted: false,
};
}
+17 -1
View File
@@ -46,6 +46,7 @@ import { emitNodeRuntimeStatusWorkerEvent, isNodeRuntimeStatusWorker, readNodeRu
import { legacyHwlabNodeWebProbeUnsupported } from "../web-probe";
import { startNodeDelegatedJob } from "./web-probe";
import { assertKnownOptions } from "./web-probe-observe";
import { hwlabNodeDeliveryMutationDecision } from "./delivery-authority";
export { hwlabNodeHelp, hwlabNodeWebProbeHelp, hwlabNodeObservabilityHelp } from "../hwlab-node-help";
@@ -140,6 +141,7 @@ export type NodeWebProbeObserveCommandType =
| "newSession"
| "sendPrompt"
| "validateRealtimeFanout"
| "validateExistingSessionRefresh"
| "validateWorkbenchKafkaDebugReplay"
| "validateWorkbenchTraceReadability"
| "steer"
@@ -571,7 +573,10 @@ export async function runHwlabNodeCommand(_config: Config, args: string[]): Prom
if (args.length === 1 || args.includes("--help") || args.includes("-h") || args[1] === "help") return hwlabNodeObservabilityHelp();
return runNodeObservability(parseNodeObservabilityOptions(args.slice(1)));
}
if (args.includes("--help") || args.includes("-h")) return hwlabNodeHelp();
if (args.includes("--help") || args.includes("-h")) {
const scope = args.includes("legacy-cicd") ? "legacy-cicd" : args.includes("platform-maintenance") ? "platform-maintenance" : null;
return hwlabNodeHelp(scope);
}
if (domain === "control-plane" || domain === "git-mirror") {
return runNodeDelegatedDomain(_config, domain, args.slice(1));
}
@@ -614,6 +619,17 @@ export function parseNodeObservabilityOptions(args: string[]): NodeObservability
export async function runNodeDelegatedDomain(config: Config, domain: DelegatedNodeDomain, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
const scoped = parseNodeScopedDelegatedOptions(domain, args);
const guardedDeliveryAction = domain === "git-mirror" && (scoped.action === "sync" || scoped.action === "flush")
|| domain === "control-plane" && (scoped.action === "trigger-current" || scoped.action === "refresh" || scoped.action === "sync")
|| domain === "control-plane" && scoped.action === "source-workspace" && scoped.originalArgs[1] === "sync";
if (guardedDeliveryAction) {
const decision = hwlabNodeDeliveryMutationDecision(
scoped,
`hwlab nodes ${domain} ${scoped.action}${scoped.action === "source-workspace" ? " sync" : ""}`,
scoped.action === "source-workspace" ? "source-workspace-sync" : scoped.action,
);
if (!decision.allowed) return decision.result;
}
if (domain === "control-plane" && scoped.action === "public-exposure") {
return runNodePublicExposure({
action: "public-exposure",
+4 -8
View File
@@ -38,9 +38,11 @@ import { compactCommandResultRedacted, record, shellQuote } from "./utils";
import { readBootstrapAdminPasswordMaterial } from "./web-probe";
import { webProbeCredential } from "./web-probe-observe";
import { hwlabRuntimeActiveExternalPostgres } from "../hwlab-node-lanes";
import { hwlabNodeDeliveryAuthority, hwlabNodePlanNext } from "./delivery-authority";
export function nodeRuntimeControlPlanePlan(scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
const activeExternalPostgres = hwlabRuntimeActiveExternalPostgres(scoped.spec);
const deliveryAuthority = hwlabNodeDeliveryAuthority(scoped);
return {
ok: true,
command: `hwlab nodes control-plane plan --node ${scoped.node} --lane ${scoped.lane}`,
@@ -48,6 +50,7 @@ export function nodeRuntimeControlPlanePlan(scoped: ReturnType<typeof parseNodeS
mutation: false,
node: scoped.node,
lane: scoped.lane,
deliveryAuthority,
expected: nodeRuntimeExpected(scoped.spec),
checks: {
nodeScopedTargetConfigured: true,
@@ -58,14 +61,7 @@ export function nodeRuntimeControlPlanePlan(scoped: ReturnType<typeof parseNodeS
localPostgresExpectedAbsent: nodeRuntimeLocalPostgresExpectedAbsent(scoped.spec),
publicExposureDeclared: scoped.spec.publicExposure !== null,
},
next: {
infraStatus: `bun scripts/cli.ts hwlab nodes control-plane infra status --node ${scoped.node} --lane ${scoped.lane}`,
status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane}`,
platformDbStatus: activeExternalPostgres === undefined
? null
: `bun scripts/cli.ts platform-db postgres status --config ${activeExternalPostgres.configRef}`,
publicExposure: scoped.spec.publicExposure === null ? null : `bun scripts/cli.ts hwlab nodes control-plane public-exposure --node ${scoped.node} --lane ${scoped.lane} --confirm`,
},
next: hwlabNodePlanNext(scoped, deliveryAuthority),
};
}
@@ -525,7 +525,6 @@ export function runNodePublicExposure(options: NodePublicExposureOptions): Recor
valuesRedacted: true,
next: ok
? {
triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${options.node} --lane ${options.lane} --confirm`,
status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${options.node} --lane ${options.lane}`,
}
: { retry: `bun scripts/cli.ts hwlab nodes control-plane public-exposure --node ${options.node} --lane ${options.lane} --confirm` },
+47 -18
View File
@@ -42,6 +42,8 @@ import { readNodeRuntimeStatusPolicy, runNodeRuntimeStatusProbe, skippedNodeRunt
import { hwlabRuntimeActiveExternalPostgres } from "../hwlab-node-lanes";
import { nodeRuntimeDeployYamlOverlayShellScript } from "./deploy-overlay";
import { pipelineProvenanceAnnotationKeys } from "../pipeline-provenance";
import { hwlabNodeDeliveryAuthority, hwlabNodeDeliveryNext } from "./delivery-authority";
import type { CicdDeliveryAuthority } from "../cicd-delivery-authority";
const runtimeGitopsObservabilityNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-observability.mjs"), "utf8").trimEnd();
const runtimeGitopsPipelineGuardNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-pipeline-guard.mjs"), "utf8").trimEnd();
@@ -346,6 +348,9 @@ export function nodeRuntimeControlPlaneStatus(scoped: ReturnType<typeof parseNod
runtimeDegradedReason,
publicReady,
});
const deliveryAuthority = hwlabNodeDeliveryAuthority(scoped);
const visiblePipelineRunDiagnostics = nodeRuntimeVisiblePipelineRunDiagnostics(pipelineRunDiagnostics, deliveryAuthority);
const runtimeStatusFull = `${nodeRuntimeStatusCommand(scoped)} --full`;
const fullStatus = {
ok: controlPlaneReady && runtimeReady && argoReady && pipelineRunReady && publicReady && gitMirrorReady,
command: `hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane}`,
@@ -389,7 +394,7 @@ export function nodeRuntimeControlPlaneStatus(scoped: ReturnType<typeof parseNod
result: compactRuntimeCommand(argo),
},
pipelineRun: pipelineRunProbe,
pipelineRunDiagnostics,
pipelineRunDiagnostics: visiblePipelineRunDiagnostics,
runtime: {
ready: runtimeReady,
skipped: !statusPolicy.probes.runtime,
@@ -420,17 +425,32 @@ export function nodeRuntimeControlPlaneStatus(scoped: ReturnType<typeof parseNod
postgresObjects: postgresObjects === null ? null : compactRuntimeCommand(postgresObjects),
},
degradedReason,
next: {
plan: `bun scripts/cli.ts hwlab nodes control-plane plan --node ${scoped.node} --lane ${scoped.lane}`,
apply: `bun scripts/cli.ts hwlab nodes control-plane apply --node ${scoped.node} --lane ${scoped.lane} --confirm`,
triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${scoped.node} --lane ${scoped.lane} --confirm`,
},
deliveryAuthority,
next: hwlabNodeDeliveryNext(scoped, deliveryAuthority, runtimeStatusFull),
};
const summary = summarizeNodeRuntimeControlPlaneStatus(fullStatus, scoped);
const summary = summarizeNodeRuntimeControlPlaneStatus(fullStatus, scoped, deliveryAuthority);
if (scoped.originalArgs.includes("--full") || scoped.originalArgs.includes("--raw")) return { ...fullStatus, summary };
return summary;
}
export function nodeRuntimeVisiblePipelineRunDiagnostics(
diagnostics: Record<string, unknown> | null,
deliveryAuthority: CicdDeliveryAuthority,
): Record<string, unknown> | null {
if (deliveryAuthority.kind === "legacy-manual" || diagnostics === null) return diagnostics;
const failureSummary = record(diagnostics.failureSummary);
return {
...diagnostics,
failureSummary: Object.keys(failureSummary).length === 0
? diagnostics.failureSummary ?? null
: {
...failureSummary,
nextAction: "检查失败 TaskRun 与有界日志,修复 owning YAML、controller 或源码中的自动链缺陷,再以新的正常 GitHub PR merge 验收;不得人工 rerun。",
},
next: null,
};
}
export function parseNodeRuntimeWorkloadReadiness(text: string): Array<Record<string, unknown>> {
return text.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).map((line) => {
const [ref = "", counts = ""] = line.split(/\t/u);
@@ -724,7 +744,11 @@ export function nodeRuntimePipelinePendingTaskRunSummaries(
});
}
export function summarizeNodeRuntimeControlPlaneStatus(status: Record<string, unknown>, scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
export function summarizeNodeRuntimeControlPlaneStatus(
status: Record<string, unknown>,
scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>,
deliveryAuthority: CicdDeliveryAuthority = hwlabNodeDeliveryAuthority(scoped),
): Record<string, unknown> {
const pipelineRun = record(status.pipelineRun);
const pipelineRunDiagnostics = record(status.pipelineRunDiagnostics);
const argo = record(status.argo);
@@ -770,7 +794,7 @@ export function summarizeNodeRuntimeControlPlaneStatus(status: Record<string, un
pendingTaskRuns: pipelineRunDiagnostics.pendingTaskRuns ?? [],
unscheduledPods: pipelineRunDiagnostics.unscheduledPods ?? [],
schedulingMessages: pipelineRunDiagnostics.schedulingMessages ?? [],
next: pipelineRunDiagnostics.next ?? null,
next: deliveryAuthority.kind === "legacy-manual" ? pipelineRunDiagnostics.next ?? null : null,
},
},
argo: {
@@ -842,14 +866,9 @@ export function summarizeNodeRuntimeControlPlaneStatus(status: Record<string, un
flushNeeded: gitMirrorCompact.flushNeeded === true,
githubInSync: gitMirrorCompact.githubInSync === true,
},
nextAction: nodeRuntimeStatusNextAction(status, scoped),
next: {
full: `${nodeRuntimeStatusCommand(scoped)} --full`,
plan: `bun scripts/cli.ts hwlab nodes control-plane plan --node ${scoped.node} --lane ${scoped.lane}`,
triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${scoped.node} --lane ${scoped.lane} --confirm`,
gitMirrorFlush: `bun scripts/cli.ts hwlab nodes git-mirror flush --node ${scoped.node} --lane ${scoped.lane} --confirm --wait`,
webProbe: `bun scripts/cli.ts web-probe run --node ${scoped.node} --lane ${scoped.lane}`,
},
deliveryAuthority,
nextAction: nodeRuntimeStatusNextAction(status, scoped, deliveryAuthority),
next: hwlabNodeDeliveryNext(scoped, deliveryAuthority, `${nodeRuntimeStatusCommand(scoped)} --full`),
};
}
@@ -967,8 +986,18 @@ function argoEventRows(text: string): Record<string, unknown>[] {
}).filter((item): item is Record<string, unknown> => item !== null);
}
export function nodeRuntimeStatusNextAction(status: Record<string, unknown>, scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): string {
export function nodeRuntimeStatusNextAction(
status: Record<string, unknown>,
scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>,
deliveryAuthority: CicdDeliveryAuthority = hwlabNodeDeliveryAuthority(scoped),
): string {
const reason = typeof status.degradedReason === "string" ? status.degradedReason : null;
if (deliveryAuthority.kind === "pac-pr-merge") {
return reason === null
? `${nodeRuntimeStatusCommand(scoped)} --full`
: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${deliveryAuthority.consumer.node} --consumer ${deliveryAuthority.consumer.consumerId}`;
}
if (deliveryAuthority.kind === "unknown") return `${nodeRuntimeStatusCommand(scoped)} --full`;
if (reason === null) return `${nodeRuntimeStatusCommand(scoped)} --full`;
if (reason === "control-plane-not-ready" || reason === "runtime-namespace-missing") {
return `bun scripts/cli.ts hwlab nodes control-plane apply --node ${scoped.node} --lane ${scoped.lane} --confirm`;
+6 -2
View File
@@ -197,7 +197,6 @@ export function sshTcpPoolDiagnosticsFromCommand(spec: HwlabRuntimeLaneSpec, res
poolStatus: `bun scripts/cli.ts debug ssh-pool ${spec.nodeId}`,
retrySmoke: `trans ${spec.nodeId} argv true`,
retryApply: `bun scripts/cli.ts hwlab nodes control-plane apply --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
retryTriggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
},
};
}
@@ -210,7 +209,12 @@ export function nodeRuntimeUnsupportedAction(scoped: ReturnType<typeof parseNode
lane: scoped.lane,
mutation: false,
degradedReason: "unsupported-node-scoped-runtime-action",
message: "node-scoped runtime currently supports plan/status/apply/refresh/sync/trigger-current/cleanup-runs/cleanup-released-pvs/cleanup-legacy-docker-images/cleanup-legacy-docker-registry-volume/runtime-image/runtime-migration/source-workspace",
message: "node-scoped runtime default help exposes read-only plan/status; delivery mutations are available only from explicit legacy-cicd help, and platform maintenance from platform-maintenance help",
next: {
status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane}`,
legacyHelp: "bun scripts/cli.ts hwlab nodes help legacy-cicd",
maintenanceHelp: "bun scripts/cli.ts hwlab nodes help platform-maintenance",
},
expected: nodeRuntimeExpected(scoped.spec),
};
}
@@ -0,0 +1,207 @@
import {
PAC_AUTOMATIC_DELIVERY_REFERENCE,
decideCicdDeliveryMutation,
readCicdDeliveryAuthorityCatalog,
resolveCicdDeliveryAuthority,
resolveCicdDeliveryAuthorityFromCatalog,
type CicdDeliveryAuthority,
type CicdDeliveryAuthorityCatalog,
type CicdDeliveryMutationDecision,
} from "../cicd-delivery-authority";
import { authorizeSentinelPacInternalPublish } from "../sentinel-pac-execution-context";
import {
record,
rendered,
sentinelCliSuffix,
text,
type SentinelCicdState,
type WebProbeSentinelOptions,
type WebProbeSentinelSourceAuthority,
} from "../hwlab-node-web-sentinel-cicd-shared";
import { hwlabRuntimeLaneConfigPath, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { RenderedCliResult } from "../output";
export function webProbeSentinelDeliveryAuthority(spec: HwlabRuntimeLaneSpec): CicdDeliveryAuthority {
try {
return webProbeSentinelDeliveryAuthorityFromCatalog(spec, readCicdDeliveryAuthorityCatalog());
} catch {
return resolveCicdDeliveryAuthority({
consumerId: sentinelPacConsumerId(spec),
node: spec.nodeId,
lane: spec.lane,
});
}
}
export function webProbeSentinelDeliveryAuthorityFromCatalog(
spec: HwlabRuntimeLaneSpec,
catalog: CicdDeliveryAuthorityCatalog,
): CicdDeliveryAuthority {
const consumerId = sentinelPacConsumerId(spec);
const exactConsumer = resolveCicdDeliveryAuthorityFromCatalog(catalog, {
consumerId,
node: spec.nodeId,
lane: spec.lane,
});
if (exactConsumer.kind === "pac-pr-merge") return exactConsumer;
const stableSentinelIdentity = spec.sourceAuthority?.mode === "giteaSnapshot"
|| catalog.consumers.some((consumer) => consumer.consumerId.toLowerCase() === consumerId.toLowerCase())
|| catalog.consumers.some((consumer) => consumer.consumerId.toLowerCase().startsWith("sentinel-")
&& consumer.node.toLowerCase() === spec.nodeId.toLowerCase()
&& consumer.lane.toLowerCase() === spec.lane.toLowerCase());
if (stableSentinelIdentity) return exactConsumer;
return resolveCicdDeliveryAuthorityFromCatalog(catalog, {
declaredSourceAuthorityMode: spec.sourceAuthority?.mode ?? null,
declaredConfigRef: hwlabRuntimeLaneConfigPath(),
});
}
export function sentinelPacConsumerId(spec: HwlabRuntimeLaneSpec): string {
return `sentinel-${spec.nodeId.toLowerCase()}-${spec.lane}`;
}
export function webProbeSentinelDeliveryMutationDecision(
spec: HwlabRuntimeLaneSpec,
command: string,
action: string,
): CicdDeliveryMutationDecision {
return decideCicdDeliveryMutation(webProbeSentinelDeliveryAuthority(spec), {
command,
statusCommand: `bun scripts/cli.ts web-probe sentinel control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
action,
node: spec.nodeId,
lane: spec.lane,
});
}
export function guardedSentinelDeliveryAction(options: WebProbeSentinelOptions): { command: string; action: string } | null {
if (options.kind === "image" && options.action === "build") {
return { command: "web-probe sentinel image build", action: "sentinel-image-build" };
}
if (options.kind === "control-plane" && options.action === "trigger-current") {
return { command: "web-probe sentinel control-plane trigger-current", action: "sentinel-control-plane-trigger-current" };
}
if (options.kind === "publish" && (options.internalExecution === "pac-controller" || options.manualRecovery)) {
return { command: "web-probe sentinel publish-current", action: "sentinel-publish-current" };
}
return null;
}
export function renderBlockedSentinelDeliveryMutation(
spec: HwlabRuntimeLaneSpec,
options: WebProbeSentinelOptions,
command: string,
action: string,
): RenderedCliResult | null {
const decision = webProbeSentinelDeliveryMutationDecision(spec, command, action);
if (decision.allowed) return null;
const executionAuthorization = options.kind === "publish" && options.internalExecution === "pac-controller"
? authorizeSentinelPacInternalPublish(options, decision.authority)
: null;
const result = {
...decision.result,
...(executionAuthorization === null ? {} : {
mode: executionAuthorization.mode,
reason: executionAuthorization.reason,
capability: executionAuthorization.capability,
}),
executionAuthorization,
next: sentinelReadOnlyNextForSpec(spec, options.sentinelId, decision.authority),
sentinelId: options.sentinelId,
valuesRedacted: true,
};
const next = record(result.next);
const fixReference = typeof next.fixAutomaticDelivery === "string"
? next.fixAutomaticDelivery
: record(next.fixAutomaticDelivery).reference ?? PAC_AUTOMATIC_DELIVERY_REFERENCE;
return Object.assign(
rendered(false, command, [
"WEB SENTINEL DELIVERY BLOCKED",
`mode=${text(result.mode)} mutation=false authority=${text(record(result.deliveryAuthority).kind)}`,
`status=${text(next.status)}`,
`history=${text(next.history ?? next.pacHistory)}`,
`fix=${text(fixReference)}`,
"GitHub PR merge 是唯一正式交付触发;修复自动链后用新的正常合并事件验收。",
].join("\n")),
result,
);
}
export function sentinelReadOnlyNextForSpec(
spec: HwlabRuntimeLaneSpec,
sentinelId: string | null,
authority: CicdDeliveryAuthority,
): Record<string, string> {
const suffix = sentinelId === null ? "" : ` --sentinel ${sentinelId}`;
const controlPlaneStatus = `bun scripts/cli.ts web-probe sentinel control-plane status --node ${spec.nodeId} --lane ${spec.lane}${suffix}`;
const base = {
status: controlPlaneStatus,
controlPlaneStatus,
fixAutomaticDelivery: PAC_AUTOMATIC_DELIVERY_REFERENCE,
};
if (authority.kind !== "pac-pr-merge") return base;
const pacHistory = `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${authority.consumer.node} --consumer ${authority.consumer.consumerId} --limit 10`;
return {
...base,
pacStatus: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${authority.consumer.node} --consumer ${authority.consumer.consumerId}`,
pacHistory,
history: pacHistory,
};
}
export function sentinelReadOnlyNext(
state: SentinelCicdState,
authority: CicdDeliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec),
): Record<string, string> {
return sentinelReadOnlyNextForSpec(state.spec, state.sentinelId, authority);
}
export function publishCurrentNext(state: SentinelCicdState): Record<string, string> {
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
if (deliveryAuthority.kind !== "legacy-manual") return sentinelReadOnlyNext(state, deliveryAuthority);
const node = state.spec.nodeId;
const lane = state.spec.lane;
const suffix = sentinelCliSuffix(state);
const consumer = sentinelPacConsumerId(state.spec);
const sourceCommit = state.sourceHead.commit === null ? "" : ` --source-commit ${state.sourceHead.commit}`;
return {
pacCloseout: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${node} --consumer ${consumer}${sourceCommit} --wait`,
pacStatus: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${node} --consumer ${consumer}`,
pacHistory: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${node} --consumer ${consumer} --limit 10`,
manualRecovery: `bun scripts/cli.ts web-probe sentinel publish-current --node ${node} --lane ${lane}${suffix} --confirm --wait --manual-recovery --reason <reason>`,
controlPlaneStatus: `bun scripts/cli.ts web-probe sentinel control-plane status --node ${node} --lane ${lane}${suffix}`,
dashboardVerify: `bun scripts/cli.ts web-probe sentinel dashboard verify --node ${node} --lane ${lane}${suffix}`,
gitMirrorStatus: `bun scripts/cli.ts hwlab nodes git-mirror status --node ${node} --lane ${lane}`,
gitMirrorSync: `bun scripts/cli.ts hwlab nodes git-mirror sync --node ${node} --lane ${lane} --confirm`,
gitMirrorFlush: `bun scripts/cli.ts hwlab nodes git-mirror flush --node ${node} --lane ${lane} --confirm --wait`,
};
}
export function sentinelSourceResolveMode(options: WebProbeSentinelOptions): "cached" | "sync" {
if (options.kind === "image" && options.action === "build" && options.confirm && options.wait) return "sync";
if (options.kind === "control-plane" && options.action === "trigger-current" && options.confirm && options.wait) return "sync";
if (options.kind === "publish" && options.confirm && options.wait) return "sync";
return "cached";
}
export interface SentinelSourceOverride {
readonly commit: string;
readonly stageRef?: string | null;
readonly mirrorCommit?: string | null;
readonly sourceAuthority: WebProbeSentinelSourceAuthority;
}
export function sentinelSourceOverrideFromOptions(options: WebProbeSentinelOptions): SentinelSourceOverride | null {
if (options.kind !== "image" && options.kind !== "control-plane" && options.kind !== "publish") return null;
const override = options.sourceOverride;
if (override.sourceCommit === null && override.sourceStageRef === null && override.sourceMirrorCommit === null && override.sourceAuthority === null) return null;
if (override.sourceCommit === null) throw new Error("--source-commit is required when overriding web-probe sentinel source");
return {
commit: override.sourceCommit,
stageRef: override.sourceStageRef,
mirrorCommit: override.sourceMirrorCommit,
sourceAuthority: override.sourceAuthority ?? "git-mirror-snapshot",
};
}
+15 -2
View File
@@ -37,6 +37,7 @@ import { parseJsonObject, record, shellQuote, statusText } from "./utils";
import { webObserveTable } from "./web-observe-render";
import { nodeRuntimeGitMirrorTarget, printNodeRuntimeTriggerProgress, sleepSync } from "./web-probe";
import { webObserveShort, webObserveText } from "./web-probe-observe";
import { hwlabNodeDeliveryAuthority, hwlabNodeDeliveryNext } from "./delivery-authority";
type NodeScopedDelegatedOptions = ReturnType<typeof parseNodeScopedDelegatedOptions>;
export type NodeRuntimeGitMirrorRunOptions = {
@@ -61,8 +62,20 @@ export function nodeRuntimeExternalPostgresSecretRows(secrets: Record<string, un
}
export function nodeRuntimeGitMirrorStatus(scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
if (scoped.spec.sourceAuthority?.mode === "giteaSnapshot") return nodeRuntimeGiteaSourceStatus(scoped);
return nodeRuntimeLegacyGitMirrorStatus(scoped);
const result = scoped.spec.sourceAuthority?.mode === "giteaSnapshot"
? nodeRuntimeGiteaSourceStatus(scoped)
: nodeRuntimeLegacyGitMirrorStatus(scoped);
const deliveryAuthority = hwlabNodeDeliveryAuthority(scoped);
if (deliveryAuthority.kind === "legacy-manual") return { ...result, deliveryAuthority };
return {
...result,
deliveryAuthority,
next: hwlabNodeDeliveryNext(
scoped,
deliveryAuthority,
`bun scripts/cli.ts hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane} --full`,
),
};
}
function nodeRuntimeGiteaSourceStatus(scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
@@ -66,6 +66,98 @@ test("observe status renderer exposes the exact command phase and failed report
assert.match(text, /terminal event arrived before subscriber disconnect/u);
});
test("observe status renderer exposes existing-session refresh handoff and typed failure evidence", () => {
const base = {
ok: true,
status: "running",
command: "web-probe observe status",
id: "webobs-fixture",
node: "NC01",
lane: "v03",
next: {},
};
const rendered = withWebObserveStatusRendered({
...base,
observer: {
processAlive: true,
manifest: {},
heartbeat: {},
diagnostics: {},
commands: {},
exactCommand: {
commandId: "cmd-existing-refresh",
type: "validateExistingSessionRefresh",
phase: "done",
ok: true,
result: {
status: "passed",
sessionId: "ses_existing",
traceIds: ["trc_existing"],
phase: "live",
code: "refresh_live_handoff_validated",
before: { messageCount: 2 },
after: { messageCount: 2 },
refreshReplay: {
phase: "live",
topic: "hwlab.event.v1",
topicPartitions: [0],
barrier: [{ partition: 0, endOffset: "18" }],
counts: { matched: 8, replayed: 8, buffered: 1, bufferedDelivered: 1, liveDelivered: 0, deduplicated: 0 },
},
productSse: { browserSourceCount: 1, openCount: 1, connectedEventCount: 1, businessEventCount: 9, typedFailureCount: 0, livePhase: true },
forbiddenRequestCount: 0,
reportSha256: "sha256:existing-refresh-report",
screenshot: { sha256: "sha256:existing-refresh-image" },
},
},
tails: { samples: [], control: [], network: [] },
},
});
const text = String(rendered.renderedText);
assert.match(text, /Workbench Session /u);
assert.match(text, /ses_existing.*trc_existing.*live.*refresh_live_handoff_validated.*2.*2.*1.*1.*1.*9.*0.*0/u);
assert.match(text, /hwlab\.event\.v1.*0.*0:18.*8.*8.*1.*1.*0.*0.*true/u);
assert.match(text, /sha256:existing-refresh-report.*sha256:existing-refresh-image/u);
const failed = withWebObserveStatusRendered({
...base,
observer: {
processAlive: true,
manifest: {},
heartbeat: {},
diagnostics: {},
commands: {},
exactCommand: {
commandId: "cmd-existing-refresh-failed",
type: "validateExistingSessionRefresh",
phase: "failed",
ok: false,
error: {
message: "Kafka retention barrier gap",
details: {
sessionId: "ses_existing",
traceIds: ["trc_existing"],
phase: "retention-barrier",
code: "retention_barrier_failed",
failure: {
phase: "retention-barrier",
code: "retention_barrier_failed",
streamPhase: "flushing",
streamCode: "workbench_kafka_refresh_barrier_gap",
message: "Kafka retention barrier gap",
},
reportSha256: "sha256:failed-existing-refresh-report",
},
},
},
tails: { samples: [], control: [], network: [] },
},
});
const failedText = String(failed.renderedText);
assert.match(failedText, /Typed failure:/u);
assert.match(failedText, /retention-barrier.*retention_barrier_failed.*flushing.*workbench_kafka_refresh_barrier_gap.*Kafka retention barrier gap/u);
});
test("observe status renderer exposes bounded Workbench Kafka debug replay evidence", () => {
const rendered = withWebObserveStatusRendered({
ok: true,
@@ -58,6 +58,91 @@ test("observe status returns one exact completed command without prompt payloads
assert.equal(status.exactCommand.valuesRedacted, true);
});
test("observe status preserves bounded existing-session refresh evidence", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-existing-refresh-status-"));
const commandId = "cmd-existing-refresh";
await mkdir(join(stateDir, "commands", "done"), { recursive: true });
await writeFile(join(stateDir, "commands", "done", `${commandId}.json`), JSON.stringify({
ok: true,
commandId,
type: "validateExistingSessionRefresh",
completedAt: "2026-07-11T12:00:00.000Z",
result: {
ok: true,
status: "passed",
profile: "pure-kafka-live",
sessionId: "ses_existing",
traceIds: ["trc_existing"],
phase: "live",
code: "refresh_live_handoff_validated",
before: { messageCount: 2, identities: ["must-not-project"], missingIdentityRows: [], duplicateIdentities: [], traceIds: ["trc_existing"] },
after: { messageCount: 2, identities: ["must-not-project"], missingIdentityRows: [], duplicateIdentities: [], traceIds: ["trc_existing"] },
refreshReplay: {
phase: "live",
topic: "hwlab.event.v1",
topicPartitions: [0],
barrier: [{ partition: 0, endOffset: "18" }],
counts: { matched: 8, replayed: 8, buffered: 1, bufferedDelivered: 1, liveDelivered: 0, deduplicated: 0 },
},
productSse: { requestCount: 1, browserSourceCount: 1, openCount: 1, connectedEventCount: 1, businessEventCount: 9, typedFailureCount: 0, livePhase: true },
forbiddenRequestCount: 0,
reportPath: "/tmp/existing-refresh-report.json",
reportSha256: "sha256:existing-refresh-report",
screenshot: { path: "/tmp/existing-refresh.png", sha256: "sha256:existing-refresh-image" },
},
}) + "\n");
const status = await runStatusScript(stateDir, commandId);
const result = status.exactCommand.result;
assert.equal(result.phase, "live");
assert.equal(result.code, "refresh_live_handoff_validated");
assert.equal(result.before.messageCount, 2);
assert.equal(result.before.identities, undefined);
assert.deepEqual(result.refreshReplay.barrier, [{ partition: 0, endOffset: "18" }]);
assert.equal(result.refreshReplay.counts.replayed, 8);
assert.equal(result.productSse.typedFailureCount, 0);
assert.equal(result.reportSha256, "sha256:existing-refresh-report");
assert.equal(result.screenshot.sha256, "sha256:existing-refresh-image");
});
test("observe status preserves typed existing-session refresh stream failures", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-existing-refresh-failure-"));
const commandId = "cmd-existing-refresh-failed";
await mkdir(join(stateDir, "commands", "failed"), { recursive: true });
await writeFile(join(stateDir, "commands", "failed", `${commandId}.json`), JSON.stringify({
ok: false,
commandId,
type: "validateExistingSessionRefresh",
failedAt: "2026-07-11T12:00:00.000Z",
error: {
message: "Kafka retention barrier gap",
details: {
sessionId: "ses_existing",
traceIds: ["trc_existing"],
phase: "retention-barrier",
code: "retention_barrier_failed",
failure: {
phase: "retention-barrier",
code: "retention_barrier_failed",
streamPhase: "flushing",
streamCode: "workbench_kafka_refresh_barrier_gap",
streamFailure: { phase: "flushing", code: "workbench_kafka_refresh_barrier_gap", message: "Kafka retention barrier gap" },
},
reportSha256: "sha256:failed-existing-refresh-report",
},
},
}) + "\n");
const status = await runStatusScript(stateDir, commandId);
const details = status.exactCommand.error.details;
assert.equal(details.phase, "retention-barrier");
assert.equal(details.code, "retention_barrier_failed");
assert.equal(details.failure.streamPhase, "flushing");
assert.equal(details.failure.streamCode, "workbench_kafka_refresh_barrier_gap");
assert.equal(details.failure.message, "Kafka retention barrier gap");
assert.deepEqual(details.traceIds, ["trc_existing"]);
});
test("observe status keeps exact command not-found structured", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-exact-missing-"));
const status = await runStatusScript(stateDir, "cmd-missing");
+19 -2
View File
@@ -79,6 +79,23 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string,
rawHwlabEventWindow:raw?{available:raw.available??null,contract:raw.contract||null,reason:raw.reason||null,samePage:raw.samePage??null,productEventsPath:raw.productEventsPath||null,productEventSourceRequestCount:raw.productEventSourceRequestCount??null,sourceContractPresent:raw.sourceContractPresent??null,unfilteredContractPresent:raw.unfilteredContractPresent??null,textBytes:raw.textBytes??null,textHash:raw.textHash||null,textPresent:raw.textPresent??null,counts:rawCounts?{received:rawCounts.received??null,retained:rawCounts.retained??null,decoded:rawCounts.decoded??null,rejected:rawCounts.rejected??null,evicted:rawCounts.evicted??null,bytes:rawCounts.bytes??null}:null,valuesRedacted:true}:null
};
};
const compactExistingSessionRefreshEvidence=(value)=>{
const item=value&&typeof value==='object'?value:null;if(!item)return{};
const replay=item.refreshReplay&&typeof item.refreshReplay==='object'?item.refreshReplay:null;
const counts=replay&&replay.counts&&typeof replay.counts==='object'?replay.counts:null;
const product=item.productSse&&typeof item.productSse==='object'?item.productSse:null;
const before=item.before&&typeof item.before==='object'?item.before:null;
const after=item.after&&typeof item.after==='object'?item.after:null;
const failure=item.failure&&typeof item.failure==='object'?item.failure:null;
const streamFailure=failure&&failure.streamFailure&&typeof failure.streamFailure==='object'?failure.streamFailure:item.streamFailure&&typeof item.streamFailure==='object'?item.streamFailure:null;
const compactDom=(dom)=>dom?{messageCount:dom.messageCount??null,traceIds:Array.isArray(dom.traceIds)?dom.traceIds.slice(0,4):[],missingIdentityCount:Array.isArray(dom.missingIdentityRows)?dom.missingIdentityRows.length:null,duplicateIdentityCount:Array.isArray(dom.duplicateIdentities)?dom.duplicateIdentities.length:null,valuesRedacted:true}:null;
return{
before:compactDom(before),after:compactDom(after),
refreshReplay:replay?{phase:replay.phase||null,topic:replay.topic||null,topicPartitions:Array.isArray(replay.topicPartitions)?replay.topicPartitions.slice(0,8):[],barrier:Array.isArray(replay.barrier)?replay.barrier.slice(0,8).map((entry)=>({partition:entry?.partition??null,endOffset:entry?.endOffset||null})):[],counts:counts?{matched:counts.matched??null,replayed:counts.replayed??null,buffered:counts.buffered??null,bufferedDelivered:counts.bufferedDelivered??null,liveDelivered:counts.liveDelivered??null,deduplicated:counts.deduplicated??null}:null,valuesRedacted:true}:null,
productSse:product?{requestCount:product.requestCount??null,browserSourceCount:product.browserSourceCount??null,openCount:product.openCount??null,connectedEventCount:product.connectedEventCount??null,businessEventCount:product.businessEventCount??null,typedFailureCount:product.typedFailureCount??null,livePhase:product.livePhase??null,valuesRedacted:true}:null,
failure:failure||streamFailure?{phase:failure&&failure.phase||item.phase||null,code:failure&&failure.code||item.code||null,streamPhase:failure&&failure.streamPhase||streamFailure&&streamFailure.phase||null,streamCode:failure&&failure.streamCode||streamFailure&&streamFailure.code||null,message:short(streamFailure&&streamFailure.message||failure&&failure.error&&failure.error.message||'',200),valuesRedacted:true}:null
};
};
const compactTraceSnapshot=(value)=>{const item=value&&typeof value==='object'?value:null;if(!item)return null;const disclosure=item.disclosure&&typeof item.disclosure==='object'?item.disclosure:null;return{status:item.status||null,traceId:item.traceId||null,disclosure:disclosure?{available:disclosure.available??null,open:disclosure.open??null,eventCount:disclosure.eventCount??null,readableRowCount:disclosure.readableRowCount??null,renderedRowCount:disclosure.renderedRowCount??null,rowWindowed:disclosure.rowWindowed??null}:null,rowCount:item.rowCount??null,readableRowCount:item.readableRowCount??null,sourceAuthorityRowCount:item.sourceAuthorityRowCount??null,readableSourceRowCount:item.readableSourceRowCount??null,projectedAuthorityRowCount:item.projectedAuthorityRowCount??null,rowSetHash:item.rowSetHash||null,valuesRedacted:true};};
const compactTracePhase=(value)=>{const item=value&&typeof value==='object'?value:null;return item?{observed:item.observed??null,stable:item.stable??null,reason:item.reason||null,status:item.status||null,elapsedMs:item.elapsedMs??null,sampleCount:item.sampleCount??null,snapshot:compactTraceSnapshot(item.snapshot),valuesRedacted:true}:null;};
const compactTraceReadabilityEvidence=(value)=>{const item=value&&typeof value==='object'?value:null;if(!item)return{};const scope=item.scope&&typeof item.scope==='object'?item.scope:null;const disclosure=item.disclosure&&typeof item.disclosure==='object'?item.disclosure:null;const retention=item.runningRetention&&typeof item.runningRetention==='object'?item.runningRetention:null;return{startedDuringRunning:item.startedDuringRunning??null,forcedExpansion:item.forcedExpansion??null,scope:scope?{selector:short(scope.selector),conversationCount:scope.conversationCount??null,productCardCount:scope.productCardCount??null,debugPanelCountInsideConversation:scope.debugPanelCountInsideConversation??null,isolatedDebugExcluded:scope.isolatedDebugExcluded??null,valuesRedacted:true}:null,disclosure:disclosure?{openBefore:disclosure.openBefore??null,openAfterExpansion:disclosure.openAfterExpansion??null,openAfterTerminal:disclosure.openAfterTerminal??null,autoReadable:disclosure.autoReadable??null,valuesRedacted:true}:null,running:compactTracePhase(item.running),terminalArrival:compactTracePhase(item.terminalArrival),terminal:compactTracePhase(item.terminal),runningRetention:retention?{mode:retention.mode||null,passed:retention.passed??null,retainedRowCount:retention.retainedRowCount??null,runningEventCount:retention.runningEventCount??null,terminalEventCount:retention.terminalEventCount??null,runningSourceSeqMin:retention.runningSourceSeqMin??null,runningSourceSeqMax:retention.runningSourceSeqMax??null,terminalSourceSeqMin:retention.terminalSourceSeqMin??null,terminalSourceSeqMax:retention.terminalSourceSeqMax??null,valuesRedacted:true}:null,retainedRunningRowCount:item.retainedRunningRowCount??null,failures:Array.isArray(item.failures)?item.failures.slice(0,8).map((failure)=>({code:failure&&failure.code||null,message:short(failure&&failure.message),valuesRedacted:true})):null};};
@@ -97,7 +114,7 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string,
if(!parsed)continue;
const result=parsed.result&&typeof parsed.result==='object'?parsed.result:null;
const error=parsed.error&&typeof parsed.error==='object'?parsed.error:null;
return {commandId:exactCommandId,phase:bucket,ok:parsed.ok??null,type:parsed.type||null,createdAt:parsed.createdAt||null,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,profile:result.profile||null,sessionId:result.sessionId||null,traceId:result.traceId||null,traceIds:Array.isArray(result.traceIds)?result.traceIds.slice(0,4):[],runIds:Array.isArray(result.runIds)?result.runIds.slice(0,4):[],commandIds:Array.isArray(result.commandIds)?result.commandIds.slice(0,4):[],topic:result.topic||null,groupId:result.groupId||null,groupPrefix:result.groupPrefix||null,receivedCount:result.receivedCount??null,appliedCount:result.appliedCount??null,terminalStatus:result.terminalStatus||null,...compactReplayEvidence(result),...compactTraceReadabilityEvidence(result),warmRunnerReused:result.warmRunnerReused??null,forbiddenRequestCount:result.forbiddenRequestCount??null,replayedEventCount:result.replayedEventCount??null,reportPath:result.reportPath||null,reportSha256:result.reportSha256||null,screenshot:compactScreenshot(result.screenshot),valuesRedacted:true}:null,error:error?{name:error.name||null,message:short(error.message,200),details:error.details&&typeof error.details==='object'?{profile:error.details.profile||null,...compactReplayEvidence(error.details),...compactTraceReadabilityEvidence(error.details),sessionId:error.details.sessionId||null,traceId:error.details.traceId||null,topic:error.details.topic||null,groupId:error.details.groupId||null,receivedCount:error.details.receivedCount??null,appliedCount:error.details.appliedCount??null,terminalStatus:error.details.terminalStatus||null,reportPath:error.details.reportPath||null,reportSha256:error.details.reportSha256||null,screenshot:compactScreenshot(error.details.screenshot),valuesRedacted:true}:null}:null,valuesRedacted:true};
return {commandId:exactCommandId,phase:bucket,ok:parsed.ok??null,type:parsed.type||null,createdAt:parsed.createdAt||null,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,profile:result.profile||null,sessionId:result.sessionId||null,traceId:result.traceId||null,traceIds:Array.isArray(result.traceIds)?result.traceIds.slice(0,4):[],runIds:Array.isArray(result.runIds)?result.runIds.slice(0,4):[],commandIds:Array.isArray(result.commandIds)?result.commandIds.slice(0,4):[],topic:result.topic||null,groupId:result.groupId||null,groupPrefix:result.groupPrefix||null,receivedCount:result.receivedCount??null,appliedCount:result.appliedCount??null,terminalStatus:result.terminalStatus||null,...compactReplayEvidence(result),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(result):{}),...compactTraceReadabilityEvidence(result),warmRunnerReused:result.warmRunnerReused??null,forbiddenRequestCount:result.forbiddenRequestCount??null,replayedEventCount:result.replayedEventCount??null,reportPath:result.reportPath||null,reportSha256:result.reportSha256||null,screenshot:compactScreenshot(result.screenshot),valuesRedacted:true}:null,error:error?{name:error.name||null,message:short(error.message,200),details:error.details&&typeof error.details==='object'?{profile:error.details.profile||null,...compactReplayEvidence(error.details),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(error.details):{}),...compactTraceReadabilityEvidence(error.details),sessionId:error.details.sessionId||null,traceId:error.details.traceId||null,traceIds:Array.isArray(error.details.traceIds)?error.details.traceIds.slice(0,4):[],topic:error.details.topic||null,groupId:error.details.groupId||null,receivedCount:error.details.receivedCount??null,appliedCount:error.details.appliedCount??null,terminalStatus:error.details.terminalStatus||null,forbiddenRequestCount:error.details.forbiddenRequestCount??null,reportPath:error.details.reportPath||null,reportSha256:error.details.reportSha256||null,screenshot:compactScreenshot(error.details.screenshot),valuesRedacted:true}:null}:null,valuesRedacted:true};
}
return {commandId:exactCommandId,phase:'not-found',ok:false,valuesRedacted:true};
};
@@ -396,7 +413,7 @@ walk(dir);
const selectedFiles=out.slice(0,maxFiles);
const files=selectedFiles.slice(0,24).map(file=>{const st=fs.statSync(file); return {relative:path.relative(dir,file),byteCount:st.size,sha256:shaFile(file)}});
const totalBytes=selectedFiles.reduce((sum,file)=>sum+fs.statSync(file).size,0);
console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,fileCount:selectedFiles.length,listedFileCount:files.length,omittedFileCount:Math.max(0,selectedFiles.length-files.length),totalBytes,files,valuesRedacted:true}));
console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,mode:'list',view:'files',fileCount:selectedFiles.length,listedFileCount:files.length,omittedFileCount:Math.max(0,selectedFiles.length-files.length),totalBytes,files,valuesRedacted:true}));
`)} "$state_dir" ${shellQuote(collectFile ?? "")} ${shellQuote(collectFinding ?? "")} ${shellQuote(collectGrep ?? "")}`;
}
@@ -753,6 +753,12 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption
if (!options.commandText?.trim()) throw new Error("validateRealtimeFanout requires --text or --text-stdin for the first turn");
if (!options.commandSecondText?.trim()) throw new Error("validateRealtimeFanout requires --second-text for the follow-up turn");
}
if (type === "validateExistingSessionRefresh") {
if (!options.commandProfile?.trim()) throw new Error("validateExistingSessionRefresh requires --profile");
if (spec.webProbe?.realtimeFanoutProfiles?.[options.commandProfile.trim()] === undefined) {
throw new Error(`validateExistingSessionRefresh profile is not declared by the owning YAML: ${options.commandProfile.trim()}`);
}
}
if (type === "validateWorkbenchKafkaDebugReplay" && spec.webProbe?.workbenchKafkaDebugReplay?.enabled !== true) {
throw new Error("validateWorkbenchKafkaDebugReplay is not enabled by the owning YAML");
}
@@ -1,4 +1,4 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, test } from "bun:test";
@@ -6,6 +6,7 @@ import type { CommandResult } from "../command";
import type { NodeWebProbeObserveOptions } from "./entry";
import { buildNodeWebProbeObserveCollectPayload } from "./web-probe-observe-collect";
import { withWebObserveCollectRendered } from "../hwlab-node-web-observe-render";
import { nodeWebObserveCollectNodeScript } from "./web-observe-scripts";
describe("web-probe observe collect child JSON recovery", () => {
test("recovers collect JSON from trans stdout dump", () => {
@@ -171,6 +172,28 @@ test("accepts the standard file collect envelope without requiring a summary vie
expect(collect.file.byteCount).toBe(21885);
});
test("accepts the structured files listing envelope when --file is absent", () => {
const options = collectOptions();
options.collectView = "files";
const stateDir = mkdtempSync(join(tmpdir(), "unidesk-web-observe-files-list-"));
writeFileSync(join(stateDir, "manifest.json"), "{}\n");
const child = Bun.spawnSync(["bash", "-lc", nodeWebObserveCollectNodeScript(options.maxFiles, null, null, null)], {
env: { ...process.env, state_dir: stateDir },
stdout: "pipe",
stderr: "pipe",
});
expect(child.exitCode).toBe(0);
const payload = buildNodeWebProbeObserveCollectPayload(options, { workspace: "/workspace" }, {
command: ["trans", "NC01:/workspace", "sh"], cwd: "/repo", exitCode: 0,
stdout: Buffer.from(child.stdout).toString("utf8"),
stderr: Buffer.from(child.stderr).toString("utf8"), signal: null, timedOut: false,
} satisfies CommandResult);
expect(payload.ok).toBe(true);
expect(payload.status).toBe("collected");
expect((payload.collect as Record<string, unknown>).mode).toBe("list");
expect((payload.collect as Record<string, unknown>).view).toBe("files");
});
function collectOptions(): NodeWebProbeObserveOptions {
return {
action: "observe",
@@ -99,7 +99,7 @@ export function isWebObserveCollectJsonContract(value: Record<string, unknown>,
|| stringOrNull(value.error) !== null;
}
return command === "web-probe-observe collect"
&& (view !== null || mode === "file")
&& (view !== null || mode === "file" || mode === "list")
&& stateDir !== null;
}
@@ -69,6 +69,17 @@ test("validateRealtimeFanout rejects profiles absent from the owning YAML", () =
);
});
test("validateExistingSessionRefresh parses an existing session without prompts", () => {
const options = parseNodeWebProbeObserveOptions(
["command", "--type", "validateExistingSessionRefresh", "--profile", "pure-kafka-live", "--session-id", "ses_existing"],
"NC01", "v03", spec, "webobs-fixture", null,
);
assert.equal(options.commandType, "validateExistingSessionRefresh");
assert.equal(options.commandProfile, "pure-kafka-live");
assert.equal(options.commandSessionId, "ses_existing");
assert.equal(options.commandText, null);
});
test("validateWorkbenchKafkaDebugReplay is a YAML-enabled argument-free typed command", () => {
const options = parseNodeWebProbeObserveOptions(
["command", "--type", "validateWorkbenchKafkaDebugReplay"],
+32 -6
View File
@@ -52,6 +52,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
&& sentinelActionRaw !== "image"
&& sentinelActionRaw !== "control-plane"
&& sentinelActionRaw !== "publish-current"
&& sentinelActionRaw !== "pac-publish-current"
&& sentinelActionRaw !== "validate"
&& sentinelActionRaw !== "maintenance"
&& sentinelActionRaw !== "dashboard"
@@ -60,7 +61,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
&& sentinelActionRaw !== "inspect-url"
&& sentinelActionRaw !== "inspect-id"
) {
throw new Error("web-probe sentinel usage: sentinel plan|status|image|control-plane|publish-current|validate|maintenance|dashboard|error|report|inspect-url|inspect-id --node NODE --lane vNN [--dry-run|--confirm]");
throw new Error("web-probe sentinel usage: sentinel plan|status|image status|control-plane status|validate|maintenance|dashboard|error|report|inspect-url|inspect-id --node NODE --lane vNN; migrated delivery is triggered only by GitHub PR merge");
}
assertKnownOptions(args, new Set([
"--node",
@@ -131,19 +132,37 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
sentinel = { kind: "config", action: sentinelActionRaw, node, lane, sentinelId, dryRun };
} else if (sentinelActionRaw === "image") {
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 --node NODE --lane vNN; migrated image delivery is triggered only by GitHub PR merge");
sentinel = { kind: "image", action: imageAction, node, lane, sentinelId, dryRun: imageAction === "build" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds, sourceOverride };
} else if (sentinelActionRaw === "control-plane") {
const controlPlaneAction = args[1];
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|status --node NODE --lane vNN; delivery writes are unavailable for migrated PaC consumers");
}
sentinel = { kind: "control-plane", action: controlPlaneAction, node, lane, sentinelId, dryRun: controlPlaneAction === "apply" || controlPlaneAction === "trigger-current" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds, rerun: args.includes("--rerun"), sourceOverride };
} else if (sentinelActionRaw === "publish-current") {
} else if (sentinelActionRaw === "publish-current" || sentinelActionRaw === "pac-publish-current") {
const manualRecovery = args.includes("--manual-recovery") || args.includes("--recovery");
const reason = optionValue(args, "--reason") ?? null;
if (manualRecovery && (reason === null || reason.trim().length < 8)) throw new Error("web-probe sentinel publish-current manual recovery requires --reason with at least 8 characters");
sentinel = { kind: "publish", action: "publish-current", node, lane, sentinelId, dryRun: dryRun || !confirm, confirm, wait: args.includes("--wait"), timeoutSeconds, rerun: args.includes("--rerun"), manualRecovery, reason, sourceOverride };
if (sentinelActionRaw === "pac-publish-current" && (manualRecovery || args.includes("--rerun"))) {
throw new Error("web-probe sentinel pac-publish-current does not accept recovery or rerun options");
}
sentinel = {
kind: "publish",
action: "publish-current",
node,
lane,
sentinelId,
dryRun: dryRun || !confirm,
confirm,
wait: args.includes("--wait"),
timeoutSeconds,
rerun: args.includes("--rerun"),
manualRecovery,
reason,
internalExecution: sentinelActionRaw === "pac-publish-current" ? "pac-controller" : null,
sourceOverride,
};
} else if (sentinelActionRaw === "maintenance") {
const maintenanceAction = args[1];
if (maintenanceAction !== "status" && maintenanceAction !== "start" && maintenanceAction !== "stop") {
@@ -600,6 +619,12 @@ export function parseNodeWebProbeObserveOptions(
if (!commandText?.trim()) throw new Error("validateRealtimeFanout requires --text or --text-stdin for the first turn");
if (!commandSecondText?.trim()) throw new Error("validateRealtimeFanout requires --second-text for the follow-up turn");
}
if (observeActionRaw === "command" && commandType === "validateExistingSessionRefresh") {
if (!commandProfile?.trim()) throw new Error("validateExistingSessionRefresh requires --profile");
if (spec.webProbe?.realtimeFanoutProfiles?.[commandProfile.trim()] === undefined) {
throw new Error(`validateExistingSessionRefresh profile is not declared by the owning YAML: ${commandProfile.trim()}`);
}
}
if (observeActionRaw === "command" && commandType === "validateWorkbenchKafkaDebugReplay") {
if (spec.webProbe?.workbenchKafkaDebugReplay?.enabled !== true) {
throw new Error("validateWorkbenchKafkaDebugReplay is not enabled by the owning YAML");
@@ -705,6 +730,7 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe
|| value === "newSession"
|| value === "sendPrompt"
|| value === "validateRealtimeFanout"
|| value === "validateExistingSessionRefresh"
|| value === "validateWorkbenchKafkaDebugReplay"
|| value === "validateWorkbenchTraceReadability"
|| value === "steer"
@@ -742,7 +768,7 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe
|| value === "mark"
|| value === "stop"
) return value;
throw new Error(`web-probe observe command --type must be login, loginAccount, logout, listSessions, switchSessions, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, validateRealtimeFanout, validateWorkbenchKafkaDebugReplay, validateWorkbenchTraceReadability, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, closeMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, performanceCapture, 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, validateRealtimeFanout, validateExistingSessionRefresh, validateWorkbenchKafkaDebugReplay, validateWorkbenchTraceReadability, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, closeMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, performanceCapture, screenshot, mark, or stop; got ${value}`);
}
export function parseWebProbeBrowserProxyMode(value: string | undefined): WebProbeBrowserProxyMode {
@@ -0,0 +1,3 @@
import { startServer } from "./server.mjs";
startServer();
@@ -1,11 +1,10 @@
import { spawn } from "node:child_process";
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { mkdir, open, readFile, readdir, rename, stat, unlink } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
export function createGithubSyncHandler(config) {
const controller = config.controller ?? createDurableSyncController(config);
@@ -1358,14 +1357,3 @@ function positiveIntegerEnv(name) {
if (!Number.isFinite(value) || value < 1) throw new Error(`invalid positive integer env ${name}`);
return value;
}
export function isMainModule(argvPath = process.argv[1], moduleUrl = import.meta.url) {
if (!argvPath) return false;
try {
return pathToFileURL(realpathSync(argvPath)).href === pathToFileURL(realpathSync(new URL(moduleUrl))).href;
} catch {
return pathToFileURL(argvPath).href === moduleUrl;
}
}
if (isMainModule()) startServer();
@@ -5,14 +5,12 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import test from "node:test";
import {
createGithubSyncHandler,
createDurableInboxStore,
createDurableSyncController,
createSyncCoordinator,
isMainModule,
run,
runResult,
syncRepository,
@@ -20,17 +18,21 @@ import {
const zeroDelay = async () => {};
test("main module detection follows Kubernetes ConfigMap symlinks", () => {
test("explicit entrypoint starts through Kubernetes ConfigMap symlinks", () => {
const root = mkdtempSync(join(tmpdir(), "gitea-sync-main-"));
try {
const dataDirectory = join(root, "..data");
const source = join(dataDirectory, "server.mjs");
const mounted = join(root, "server.mjs");
const serverSource = join(dataDirectory, "server.mjs");
const entrypointSource = join(dataDirectory, "entrypoint.mjs");
const marker = join(root, "started");
mkdirSync(dataDirectory);
writeFileSync(source, "export {};\n", { flag: "wx" });
symlinkSync(join("..data", "server.mjs"), mounted);
writeFileSync(serverSource, `import { writeFileSync } from "node:fs";\nexport function startServer() { writeFileSync(${JSON.stringify(marker)}, "started\\n"); }\n`, { flag: "wx" });
writeFileSync(entrypointSource, 'import { startServer } from "./server.mjs";\nstartServer();\n', { flag: "wx" });
symlinkSync(join("..data", "server.mjs"), join(root, "server.mjs"));
symlinkSync(join("..data", "entrypoint.mjs"), join(root, "entrypoint.mjs"));
assert.equal(isMainModule(mounted, pathToFileURL(source).href), true);
execFileSync(process.execPath, [join(root, "entrypoint.mjs")], { stdio: "pipe" });
assert.equal(existsSync(marker), true);
} finally {
rmSync(root, { recursive: true, force: true });
}
@@ -60,7 +60,9 @@ test("owning YAML renders one child Application and the complete durable bridge
expect(manifest).toContain("argocd.argoproj.io/hook: PreSync");
expect(manifest).toContain('argocd.argoproj.io/sync-wave: "-2"');
expect(manifest).toContain('argocd.argoproj.io/sync-wave: "-1"');
expect(manifest).toContain("node /etc/gitea-github-sync-candidate/server.mjs &");
expect(manifest).toContain("node /etc/gitea-github-sync-candidate/entrypoint.mjs &");
expect(manifest).toContain('import { startServer } from "./server.mjs";');
expect(manifest).toContain("- /etc/gitea-github-sync/entrypoint.mjs");
expect(manifest).toContain("mountPath: /etc/gitea-github-sync-candidate");
expect(manifest).toContain("name: candidate-inbox\n emptyDir: {}");
expect(manifest).toContain('gate: "gitea-github-sync-candidate-startup"');
@@ -0,0 +1,68 @@
import { expect, test } from "bun:test";
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { renderGiteaRemotePayloadMaterializer, summarizeGiteaRemotePayloads } from "./platform-infra-gitea-payload";
import { renderGiteaWebhookSyncDesiredManifest } from "./platform-infra-gitea";
const remoteScriptPath = resolve(import.meta.dir, "platform-infra-gitea-remote.sh");
test("stdin file envelope materializes a payload larger than the current Gitea manifest without argv or env", () => {
const currentManifest = renderGiteaWebhookSyncDesiredManifest("NC01");
const oversizedManifest = `${currentManifest}\n${"payload-$HOME-'quote'-$(command)\n".repeat(8192)}`;
expect(Buffer.byteLength(oversizedManifest)).toBeGreaterThan(Buffer.byteLength(currentManifest));
expect(Buffer.byteLength(Buffer.from(oversizedManifest).toString("base64"))).toBeGreaterThan(128 * 1024);
const payloads = {
manifest: oversizedManifest,
repositories: JSON.stringify([{ key: "sentinel", branch: "master" }]),
statusEvaluator: "print('ok')\n",
frpcToml: "serverAddr = '127.0.0.1'\n",
};
const materializer = renderGiteaRemotePayloadMaterializer(payloads);
const result = spawnSync("sh", ["-s"], {
encoding: "utf8",
input: [
"set -eu",
'tmp="$(mktemp -d)"',
'trap \'rm -rf "$tmp"\' EXIT',
materializer,
'unidesk_gitea_materialize_payloads "$tmp"',
"python3 - \"$tmp\" <<'PY'",
"import hashlib, json, pathlib, sys",
"root = pathlib.Path(sys.argv[1])",
"print(json.dumps({p.name: {'bytes': p.stat().st_size, 'sha256': hashlib.sha256(p.read_bytes()).hexdigest()} for p in root.iterdir()}, sort_keys=True))",
"PY",
].join("\n"),
});
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
const observed = JSON.parse(result.stdout) as Record<string, { bytes: number; sha256: string }>;
expect(observed["gitea.k8s.yaml"]).toEqual({
bytes: Buffer.byteLength(oversizedManifest),
sha256: createHash("sha256").update(oversizedManifest).digest("hex"),
});
expect(materializer).not.toContain("UNIDESK_GITEA_MANIFEST_B64");
const summary = summarizeGiteaRemotePayloads(payloads) as any;
expect(summary.kind).toBe("trans-stdin-file-envelope");
expect(summary.valuesPrinted).toBe(false);
expect(JSON.stringify(summary)).not.toContain("payload-$HOME");
});
test("Gitea remote runner consumes materialized files instead of base64 environment variables", () => {
const source = readFileSync(remoteScriptPath, "utf8");
expect(source).toContain('unidesk_gitea_materialize_payloads "$tmp"');
expect(source).toContain('$tmp/gitea.k8s.yaml');
expect(source).toContain('$tmp/repos.json');
expect(source).not.toContain("UNIDESK_GITEA_MANIFEST_B64");
expect(source).not.toContain("UNIDESK_GITEA_STATUS_EVALUATOR_B64");
expect(source).not.toContain("UNIDESK_GITEA_REPOS_B64");
expect(source).not.toContain("UNIDESK_GITEA_FRPC_TOML_B64");
expect(source).toContain("kubectl apply --server-side --force-conflicts --dry-run=server");
expect(source).toContain("target server dry-run failed; manifest apply skipped");
expect(source.indexOf("kubectl apply --server-side --force-conflicts --dry-run=server")).toBeLessThan(
source.lastIndexOf('timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side --force-conflicts'),
);
});
@@ -0,0 +1,62 @@
import { createHash } from "node:crypto";
export interface GiteaRemotePayloads {
manifest: string;
repositories: string;
statusEvaluator: string;
frpcToml: string;
}
interface GiteaRemotePayloadFile {
role: keyof GiteaRemotePayloads;
name: string;
value: string;
}
const payloadFiles = (payloads: GiteaRemotePayloads): GiteaRemotePayloadFile[] => [
{ role: "manifest", name: "gitea.k8s.yaml", value: payloads.manifest },
{ role: "repositories", name: "repos.json", value: payloads.repositories },
{ role: "statusEvaluator", name: "platform_infra_gitea_status_evaluator.py", value: payloads.statusEvaluator },
{ role: "frpcToml", name: "frpc.toml", value: payloads.frpcToml },
];
function payloadMarker(file: GiteaRemotePayloadFile): string {
return `__UNIDESK_GITEA_${file.role.toUpperCase()}_STDIN_PAYLOAD__`;
}
export function renderGiteaRemotePayloadMaterializer(payloads: GiteaRemotePayloads): string {
const writes = payloadFiles(payloads).map((file) => {
const encoded = Buffer.from(file.value, "utf8").toString("base64");
const marker = payloadMarker(file);
return [
` if ! base64 -d >"$unidesk_gitea_payload_dir/${file.name}" <<'${marker}'`,
encoded,
marker,
" then",
" return 1",
" fi",
].join("\n");
});
return [
"unidesk_gitea_materialize_payloads() {",
" unidesk_gitea_payload_dir=$1",
" umask 077",
...writes,
"}",
].join("\n");
}
export function summarizeGiteaRemotePayloads(payloads: GiteaRemotePayloads): Record<string, unknown> {
const files = payloadFiles(payloads).map((file) => ({
role: file.role,
bytes: Buffer.byteLength(file.value, "utf8"),
sha256: `sha256:${createHash("sha256").update(file.value).digest("hex").slice(0, 16)}`,
}));
return {
kind: "trans-stdin-file-envelope",
materialization: "target-private-tmpdir",
totalBytes: files.reduce((total, file) => total + file.bytes, 0),
files,
valuesPrinted: false,
};
}
+32 -26
View File
@@ -5,7 +5,10 @@ tmp="$(mktemp -d)"
export tmp
trap 'rm -rf "$tmp"' EXIT
printf '%s' "$UNIDESK_GITEA_STATUS_EVALUATOR_B64" | base64 -d >"$tmp/platform_infra_gitea_status_evaluator.py"
if ! unidesk_gitea_materialize_payloads "$tmp"; then
printf '%s\n' '{"ok":false,"error":"gitea-stdin-payload-materialization-failed","valuesPrinted":false}'
exit 1
fi
json_tail() {
name="$1"
@@ -31,12 +34,10 @@ capture_json() {
run_apply() {
manifest="$tmp/gitea.k8s.yaml"
printf '%s' "$UNIDESK_GITEA_MANIFEST_B64" | base64 -d >"$manifest"
if [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
kubectl create namespace "$UNIDESK_GITEA_NAMESPACE" --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/namespace.out" 2>"$tmp/namespace.err"
fi
if [ "$UNIDESK_GITEA_FRPC_ENABLED" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
printf '%s' "$UNIDESK_GITEA_FRPC_TOML_B64" | base64 -d >"$tmp/frpc.toml"
kubectl -n "$UNIDESK_GITEA_NAMESPACE" create secret generic "$UNIDESK_GITEA_FRPC_SECRET_NAME" \
--from-file="$UNIDESK_GITEA_FRPC_SECRET_KEY=$tmp/frpc.toml" \
--dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/frpc-secret.out" 2>"$tmp/frpc-secret.err"
@@ -67,15 +68,23 @@ run_apply() {
printf '%s\n' "webhook sync disabled" >"$tmp/webhook-secret.err"
fi
fi
dry_arg=""
apply_args="--server-side --force-conflicts"
if [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then
dry_arg="--dry-run=client"
apply_args=""
fi
timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side --force-conflicts --dry-run=server \
--field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f "$manifest" >"$tmp/server-dry-run.out" 2>"$tmp/server-dry-run.err"
server_dry_run_rc=$?
if [ "$frpc_rc" -eq 0 ] && [ "$webhook_secret_rc" -eq 0 ]; then
timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply $apply_args $dry_arg --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err"
apply_rc=$?
if [ "$server_dry_run_rc" -ne 0 ]; then
apply_rc=1
: >"$tmp/apply.out"
printf '%s\n' "target server dry-run failed; manifest apply skipped" >"$tmp/apply.err"
elif [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then
apply_rc=0
cp "$tmp/server-dry-run.out" "$tmp/apply.out"
: >"$tmp/apply.err"
else
timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side --force-conflicts \
--field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err"
apply_rc=$?
fi
else
apply_rc=1
: >"$tmp/apply.out"
@@ -150,9 +159,9 @@ run_apply() {
printf '%s\n' "webhook sync disabled" >"$tmp/webhook-rollout.err"
fi
fi
python3 - "$frpc_rc" "$webhook_secret_rc" "$apply_rc" "$rollout_rc" "$frpc_rollout_rc" "$webhook_rollout_rc" "$frpc_cleanup_rc" "$tmp/frpc-secret.out" "$tmp/frpc-secret.err" "$tmp/frpc-cleanup.out" "$tmp/frpc-cleanup.err" "$tmp/webhook-secret.out" "$tmp/webhook-secret.err" "$tmp/apply.out" "$tmp/apply.err" "$tmp/rollout.out" "$tmp/rollout.err" "$tmp/frpc-rollout.out" "$tmp/frpc-rollout.err" "$tmp/webhook-rollout.out" "$tmp/webhook-rollout.err" <<'PY'
python3 - "$frpc_rc" "$webhook_secret_rc" "$server_dry_run_rc" "$apply_rc" "$rollout_rc" "$frpc_rollout_rc" "$webhook_rollout_rc" "$frpc_cleanup_rc" "$tmp/frpc-secret.out" "$tmp/frpc-secret.err" "$tmp/frpc-cleanup.out" "$tmp/frpc-cleanup.err" "$tmp/webhook-secret.out" "$tmp/webhook-secret.err" "$tmp/server-dry-run.out" "$tmp/server-dry-run.err" "$tmp/apply.out" "$tmp/apply.err" "$tmp/rollout.out" "$tmp/rollout.err" "$tmp/frpc-rollout.out" "$tmp/frpc-rollout.err" "$tmp/webhook-rollout.out" "$tmp/webhook-rollout.err" <<'PY'
import json, os, sys
frpc_rc, webhook_secret_rc, apply_rc, rollout_rc, frpc_rollout_rc, webhook_rollout_rc, frpc_cleanup_rc = [int(value) for value in sys.argv[1:8]]
frpc_rc, webhook_secret_rc, server_dry_run_rc, apply_rc, rollout_rc, frpc_rollout_rc, webhook_rollout_rc, frpc_cleanup_rc = [int(value) for value in sys.argv[1:9]]
def text(path, limit=3000):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
@@ -160,7 +169,7 @@ def text(path, limit=3000):
return ""
dry = os.environ.get("UNIDESK_GITEA_DRY_RUN") == "1"
payload = {
"ok": all(value == 0 for value in [frpc_rc, webhook_secret_rc, apply_rc, rollout_rc, frpc_rollout_rc, webhook_rollout_rc, frpc_cleanup_rc]),
"ok": all(value == 0 for value in [frpc_rc, webhook_secret_rc, server_dry_run_rc, apply_rc, rollout_rc, frpc_rollout_rc, webhook_rollout_rc, frpc_cleanup_rc]),
"target": os.environ.get("UNIDESK_GITEA_TARGET_ID"),
"route": os.environ.get("UNIDESK_GITEA_ROUTE"),
"namespace": os.environ.get("UNIDESK_GITEA_NAMESPACE"),
@@ -173,13 +182,14 @@ payload = {
"networkPolicy": "allow-all",
},
"steps": {
"frpcSecret": {"exitCode": frpc_rc, "stdoutTail": text(sys.argv[8]), "stderrTail": text(sys.argv[9])},
"frpcCleanup": {"exitCode": frpc_cleanup_rc, "stdoutTail": text(sys.argv[10]), "stderrTail": text(sys.argv[11])},
"webhookSyncSecret": {"exitCode": webhook_secret_rc, "stdoutTail": text(sys.argv[12]), "stderrTail": text(sys.argv[13])},
"apply": {"exitCode": apply_rc, "stdoutTail": text(sys.argv[14]), "stderrTail": text(sys.argv[15])},
"rollout": {"exitCode": rollout_rc, "stdoutTail": text(sys.argv[16]), "stderrTail": text(sys.argv[17])},
"frpcRollout": {"exitCode": frpc_rollout_rc, "stdoutTail": text(sys.argv[18]), "stderrTail": text(sys.argv[19])},
"webhookSyncRollout": {"exitCode": webhook_rollout_rc, "stdoutTail": text(sys.argv[20]), "stderrTail": text(sys.argv[21])},
"frpcSecret": {"exitCode": frpc_rc, "stdoutTail": text(sys.argv[9]), "stderrTail": text(sys.argv[10])},
"frpcCleanup": {"exitCode": frpc_cleanup_rc, "stdoutTail": text(sys.argv[11]), "stderrTail": text(sys.argv[12])},
"webhookSyncSecret": {"exitCode": webhook_secret_rc, "stdoutTail": text(sys.argv[13]), "stderrTail": text(sys.argv[14])},
"serverDryRun": {"exitCode": server_dry_run_rc, "stdoutTail": text(sys.argv[15]), "stderrTail": text(sys.argv[16])},
"apply": {"exitCode": apply_rc, "stdoutTail": text(sys.argv[17]), "stderrTail": text(sys.argv[18])},
"rollout": {"exitCode": rollout_rc, "stdoutTail": text(sys.argv[19]), "stderrTail": text(sys.argv[20])},
"frpcRollout": {"exitCode": frpc_rollout_rc, "stdoutTail": text(sys.argv[21]), "stderrTail": text(sys.argv[22])},
"webhookSyncRollout": {"exitCode": webhook_rollout_rc, "stdoutTail": text(sys.argv[23]), "stderrTail": text(sys.argv[24])},
},
"valuesPrinted": False,
}
@@ -356,7 +366,7 @@ PY
}
mirror_repos_json() {
printf '%s' "$UNIDESK_GITEA_REPOS_B64" | base64 -d >"$tmp/repos.json"
test -f "$tmp/repos.json"
}
admin_basic_b64() {
@@ -891,10 +901,6 @@ sys.exit(0 if payload["ok"] else 1)
PY
}
run_mirror_webhook_test() {
printf '{"ok":false,"mutation":false,"mode":"manual-source-delivery-test-disabled","error":"only real GitHub PR merge deliveries may enter the durable inbox","valuesPrinted":false}\n'
exit 2
}
case "$UNIDESK_GITEA_ACTION" in
apply) run_apply ;;
status) run_status ;;
+72 -83
View File
@@ -18,12 +18,15 @@ import type { PublicServiceExposure, PublicServiceTarget, FrpcSecretMaterial } f
import { applyPk01CaddySiteBlock, caddyManagedBlockMarkers } from "./pk01-caddy";
import { deriveGiteaWebhookAttemptBudgetMs, deriveGiteaWebhookResponseHeaderTimeoutMs, renderGiteaWebhookCaddyHandle, validateGiteaWebhookTiming } from "./platform-infra-gitea-caddy";
import type { GiteaWebhookIngressRetry } from "./platform-infra-gitea-caddy";
import { renderGiteaRemotePayloadMaterializer, summarizeGiteaRemotePayloads } from "./platform-infra-gitea-payload";
import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets";
import { PAC_AUTOMATIC_DELIVERY_REFERENCE } from "./cicd-delivery-authority";
const configFile = rootPath("config", "platform-infra", "gitea.yaml");
const configLabel = "config/platform-infra/gitea.yaml";
const remoteScriptFile = rootPath("scripts", "src", "platform-infra-gitea-remote.sh");
const githubSyncServerFile = rootPath("scripts", "src", "platform-infra-gitea-github-sync-server.mjs");
const githubSyncEntrypointFile = rootPath("scripts", "src", "platform-infra-gitea-github-sync-entrypoint.mjs");
const statusEvaluatorFile = rootPath("scripts", "src", "platform_infra_gitea_status_evaluator.py");
const fieldManager = "unidesk-platform-infra-gitea";
const y = createYamlFieldReader(configLabel);
@@ -319,7 +322,7 @@ interface MirrorRemoteParams {
export async function runPlatformInfraGiteaCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
const [action = "plan"] = args;
if (action === "help" || action === "--help") return giteaHelp();
if (action === "help" || action === "--help") return giteaHelp(args[1]);
if (action === "plan") {
const options = parseCommonOptions(args.slice(1));
const result = plan(options);
@@ -345,17 +348,31 @@ export async function runPlatformInfraGiteaCommand(config: UniDeskConfig, args:
};
}
export function giteaHelp(): Record<string, unknown> {
export function giteaHelp(scope?: string): Record<string, unknown> {
if (scope === "platform-bootstrap") {
return {
command: "platform-infra gitea help platform-bootstrap",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra gitea apply --target <NODE> --dry-run",
"bun scripts/cli.ts platform-infra gitea apply --target <NODE> --confirm",
"bun scripts/cli.ts platform-infra gitea mirror bootstrap --target <NODE> --confirm",
"bun scripts/cli.ts platform-infra gitea mirror webhook apply --target <NODE> --confirm",
],
boundary: "这些命令只用于平台首次引导或独立平台维护,不能补齐 PR merge 后的 source delivery。",
};
}
return {
command: "platform-infra gitea status|validate|mirror status|mirror webhook status",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra gitea status --target JD01 [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea validate --target JD01 [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea mirror status --target JD01 [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target JD01 [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea status --target <NODE> [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea validate --target <NODE> [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea mirror status --target <NODE> [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target <NODE> [--full|--raw]",
],
boundary: "PR merge is the only delivery trigger; these default entries are read-only observation and validation, never post-merge repair actions.",
scopedHelp: ["bun scripts/cli.ts platform-infra gitea help platform-bootstrap"],
boundary: "PR merge 是唯一 delivery 触发;默认入口只读观察和校验,不能充当合并后的恢复动作。",
};
}
@@ -830,7 +847,7 @@ function plan(options: CommonOptions): Record<string, unknown> {
objects: manifestObjectSummary(manifest),
},
policy,
next: nextCommands(target.id),
next: giteaReadOnlyNextCommands(target.id),
};
}
@@ -842,7 +859,8 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
if (!policy.every((check) => check.ok)) return { ok: false, action: "platform-infra-gitea-apply", mode: "policy-blocked", mutation: false, policy };
const frpcSecret = prepareGiteaFrpcSecret(gitea, target);
const secrets = gitea.sourceAuthority.webhookSync.enabled && !options.dryRun ? ensureMirrorSecrets(gitea, true, true) : undefined;
const result = await capture(config, target.route, ["sh"], remoteScript("apply", gitea, target, manifest, options, { frpcSecret, secrets }));
const remote = remoteScript("apply", gitea, target, manifest, options, { frpcSecret, secrets });
const result = await capture(config, target.route, ["sh"], remote.script);
const parsed = parseJsonOutput(result.stdout);
const caddy = !options.dryRun && result.exitCode === 0 && parsed?.ok === true && caddyExposureNeeded(gitea, target)
? await applyGiteaCaddyBlock(config, gitea)
@@ -856,17 +874,18 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
config: compactConfigSummary(gitea, target),
policy,
publicExposure: publicExposureSummary(gitea),
payloadTransport: remote.payloadSummary,
secrets: { frpc: frpcSecretSummary(frpcSecret), webhookSync: secrets === undefined ? { enabled: false } : webhookSecretSummary(gitea, target, secrets) },
pk01Caddy: caddy,
remote: parsed ?? compactCapture(result, { full: true }),
next: nextCommands(target.id),
next: giteaReadOnlyNextCommands(target.id),
};
}
async function status(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
const gitea = readGiteaConfig();
const target = resolveTarget(gitea, options.targetId);
const result = await capture(config, target.route, ["sh"], remoteScript("status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }));
const result = await capture(config, target.route, ["sh"], remoteScript("status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }).script);
const parsed = parseJsonOutput(result.stdout);
const summary = parsed === null ? null : statusSummary(parsed);
return {
@@ -877,17 +896,14 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise<Re
config: configSummary(gitea, target),
summary,
remote: options.raw ? parsed : options.full ? parsed : summary ?? compactCapture(result, { full: true }),
next: {
apply: `bun scripts/cli.ts platform-infra gitea apply --target ${target.id} --confirm`,
validate: `bun scripts/cli.ts platform-infra gitea validate --target ${target.id}`,
},
next: giteaReadOnlyNextCommands(target.id),
};
}
async function validate(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
const gitea = readGiteaConfig();
const target = resolveTarget(gitea, options.targetId);
const result = await capture(config, target.route, ["sh"], remoteScript("validate", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }));
const result = await capture(config, target.route, ["sh"], remoteScript("validate", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }).script);
const parsed = parseJsonOutput(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
@@ -950,11 +966,6 @@ async function mirrorWebhookCommand(config: UniDeskConfig, args: string[]): Prom
const result = await mirrorWebhookStatus(config, options);
return options.full || options.raw ? result : renderMirrorWebhookStatus(result);
}
if (action === "test") {
const options = parseMirrorOptions(args.slice(1));
const result = await mirrorWebhookTest(config, options);
return options.full || options.raw ? result : renderMirrorWebhookTest(result);
}
return {
ok: false,
error: "unsupported-platform-infra-gitea-mirror-webhook-command",
@@ -974,7 +985,7 @@ function mirrorPlan(options: CommonOptions): Record<string, unknown> {
responsibilities: gitea.sourceAuthority.responsibilities.map(responsibilitySummary),
repositories: repositoriesForTarget(gitea, target).map((repo) => repositorySummary(gitea, repo)),
credentials: credentialSummaries(gitea),
next: mirrorSetupNextCommands(target.id),
next: mirrorReadOnlyNextCommands(target.id),
};
}
@@ -986,7 +997,7 @@ async function mirrorStatus(config: UniDeskConfig, options: CommonOptions & { re
const credentials = credentialSummaries(gitea);
const secrets = ensureMirrorSecrets(gitea, false, false);
const selectedRepos = selectedRepositories(gitea, target, options.repoKey ?? null);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos: selectedRepos, secrets }));
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos: selectedRepos, secrets }).script);
const parsed = parseJsonOutput(result.stdout);
const repositories = arrayRecords(record(parsed).repositories);
return {
@@ -1020,7 +1031,7 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P
if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-bootstrap", mutation: false, mode: "missing-confirm", error: "mirror bootstrap requires --confirm" };
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, true, true);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-bootstrap", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }));
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-bootstrap", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }).script);
const parsed = parseJsonOutput(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
@@ -1051,7 +1062,7 @@ async function mirrorSync(config: UniDeskConfig, options: MirrorOptions): Promis
if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-sync", mutation: false, mode: "missing-confirm", error: "mirror sync requires --confirm" };
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, false, false);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-sync", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }));
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-sync", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }).script);
const parsed = parseJsonOutput(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
@@ -1072,7 +1083,7 @@ async function mirrorWebhookApply(config: UniDeskConfig, options: MirrorOptions)
if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-webhook-apply", mutation: false, mode: "missing-confirm", error: "mirror webhook apply requires --confirm" };
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, false, false);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-apply", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }));
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-apply", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }).script);
const parsed = parseJsonOutput(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
@@ -1091,7 +1102,7 @@ async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorOptions
const target = resolveTarget(gitea, options.targetId);
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, false, false);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos, secrets }));
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos, secrets }).script);
const parsed = parseJsonOutput(result.stdout);
const repositories = arrayRecords(record(parsed).repositories);
return {
@@ -1109,21 +1120,6 @@ async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorOptions
};
}
async function mirrorWebhookTest(config: UniDeskConfig, options: MirrorOptions): Promise<Record<string, unknown>> {
const gitea = readGiteaConfig();
const target = resolveTarget(gitea, options.targetId);
return {
ok: false,
action: "platform-infra-gitea-mirror-webhook-test",
mutation: false,
mode: "manual-source-delivery-test-disabled",
target: targetSummary(target),
webhook: webhookSyncSummary(gitea, target),
error: "manual webhook source-delivery tests are disabled; only real GitHub PR merge deliveries may enter the durable inbox",
next: mirrorReadOnlyNextCommands(target.id),
};
}
function renderManifest(gitea: GiteaConfig, target: GiteaTarget): string {
const app = gitea.app;
const image = `${app.image.repository}:${app.image.tag}`;
@@ -1296,7 +1292,8 @@ function renderGithubSyncManifest(gitea: GiteaConfig, target: GiteaTarget): stri
app.kubernetes.io/managed-by: unidesk`;
const reposJson = JSON.stringify(repositoriesForTarget(gitea, target).map(remoteRepoSpec), null, 2);
const serverSource = readFileSync(githubSyncServerFile, "utf8");
const configHash = createHash("sha256").update(reposJson).update("\0").update(serverSource).digest("hex").slice(0, 16);
const entrypointSource = readFileSync(githubSyncEntrypointFile, "utf8");
const configHash = createHash("sha256").update(reposJson).update("\0").update(serverSource).update("\0").update(entrypointSource).digest("hex").slice(0, 16);
const gate = sync.bridge.candidateGate;
const candidateManifest = gate.enabled ? `---
apiVersion: v1
@@ -1315,6 +1312,8 @@ data:
${indentBlock(reposJson, 4)}
server.mjs: |
${indentBlock(serverSource, 4)}
entrypoint.mjs: |
${indentBlock(entrypointSource, 4)}
---
apiVersion: batch/v1
kind: Job
@@ -1349,7 +1348,7 @@ spec:
- -eu
- -c
- |
node /etc/gitea-github-sync-candidate/server.mjs &
node /etc/gitea-github-sync-candidate/entrypoint.mjs &
server_pid=$!
trap 'kill "$server_pid" 2>/dev/null || true' EXIT INT TERM
node --input-type=module - ${gate.healthTimeoutMs} ${gate.pollIntervalMs} <<'NODE'
@@ -1463,6 +1462,8 @@ data:
${indentBlock(reposJson, 4)}
server.mjs: |
${indentBlock(serverSource, 4)}
entrypoint.mjs: |
${indentBlock(entrypointSource, 4)}
---
apiVersion: v1
kind: PersistentVolumeClaim
@@ -1533,7 +1534,7 @@ spec:
imagePullPolicy: IfNotPresent
command:
- node
- /etc/gitea-github-sync/server.mjs
- /etc/gitea-github-sync/entrypoint.mjs
ports:
- name: http
containerPort: ${sync.bridge.httpPort}
@@ -1660,7 +1661,7 @@ function envVars(gitea: GiteaConfig, target: GiteaTarget): string {
value: ${yamlQuote(value)}`).join("\n");
}
function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): string {
function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): { script: string; payloadSummary: Record<string, unknown> } {
const frpcExposure = targetFrpcExposure(gitea, target);
const sync = targetWebhookSync(gitea, target);
const env: Record<string, string> = {
@@ -1679,10 +1680,7 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
UNIDESK_GITEA_DRY_RUN: options.dryRun ? "1" : "0",
UNIDESK_GITEA_WAIT: options.wait ? "1" : "0",
UNIDESK_GITEA_FULL: options.full ? "1" : "0",
UNIDESK_GITEA_MANIFEST_B64: Buffer.from(manifest, "utf8").toString("base64"),
UNIDESK_GITEA_ROOT_URL: gitea.app.server.rootUrl,
UNIDESK_GITEA_REPOS_B64: Buffer.from(JSON.stringify((params.repos ?? []).map((repo) => remoteRepoSpec(repo))), "utf8").toString("base64"),
UNIDESK_GITEA_STATUS_EVALUATOR_B64: Buffer.from(readFileSync(statusEvaluatorFile, "utf8"), "utf8").toString("base64"),
UNIDESK_GITEA_GITHUB_PROXY_ENABLED: gitea.sourceAuthority.githubProxy.enabled ? "1" : "0",
UNIDESK_GITEA_GITHUB_PROXY_URL: gitea.sourceAuthority.githubProxy.url,
UNIDESK_GITEA_NO_PROXY: gitea.sourceAuthority.githubProxy.noProxy.join(","),
@@ -1702,7 +1700,6 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
if (params.frpcSecret !== undefined) {
env.UNIDESK_GITEA_FRPC_SECRET_NAME = params.frpcSecret.secretName;
env.UNIDESK_GITEA_FRPC_SECRET_KEY = params.frpcSecret.secretKey;
env.UNIDESK_GITEA_FRPC_TOML_B64 = Buffer.from(params.frpcSecret.frpcToml, "utf8").toString("base64");
}
if (params.secrets !== undefined) {
env.UNIDESK_GITEA_ADMIN_USERNAME = params.secrets.adminUsername;
@@ -1711,7 +1708,16 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
env.UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET = params.secrets.webhookSecret;
}
const exports = Object.entries(env).map(([key, value]) => `export ${key}=${shQuote(value)}`).join("\n");
return `${exports}\n${readFileSync(remoteScriptFile, "utf8")}`;
const payloads = {
manifest,
repositories: JSON.stringify((params.repos ?? []).map((repo) => remoteRepoSpec(repo))),
statusEvaluator: readFileSync(statusEvaluatorFile, "utf8"),
frpcToml: params.frpcSecret?.frpcToml ?? "",
};
return {
script: `${exports}\n${renderGiteaRemotePayloadMaterializer(payloads)}\n${readFileSync(remoteScriptFile, "utf8")}`,
payloadSummary: summarizeGiteaRemotePayloads(payloads),
};
}
function policyChecks(gitea: GiteaConfig, target: GiteaTarget, manifest: string): Array<Record<string, unknown>> {
@@ -2064,22 +2070,13 @@ function remoteRepoSpec(repo: GiteaMirrorRepository): Record<string, unknown> {
};
}
function mirrorSetupNextCommands(targetId: string): Record<string, string> {
return {
bootstrap: `bun scripts/cli.ts platform-infra gitea mirror bootstrap --target ${targetId} --confirm`,
status: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId}`,
webhookApply: `bun scripts/cli.ts platform-infra gitea mirror webhook apply --target ${targetId} --confirm`,
webhookStatus: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId}`,
full: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId} --full`,
};
}
function mirrorReadOnlyNextCommands(targetId: string): Record<string, string> {
return {
status: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId}`,
statusFull: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId} --full`,
webhookStatus: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId}`,
webhookStatusFull: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId} --full`,
fixAutomaticDelivery: PAC_AUTOMATIC_DELIVERY_REFERENCE,
};
}
@@ -2278,8 +2275,10 @@ function renderMirrorPlan(result: Record<string, unknown>): RenderedCliResult {
...(responsibilities.length === 0 ? ["-"] : table(["NAME", "CURRENT", "TARGET", "DISPOSITION"], responsibilities)),
"",
"NEXT",
` bootstrap: ${stringValue(next.bootstrap)}`,
` status: ${stringValue(next.status)}`,
` full: ${stringValue(next.statusFull)}`,
` webhook-status: ${stringValue(next.webhookStatus)}`,
` fix-automatic-delivery: ${stringValue(next.fixAutomaticDelivery)}`,
"",
"Disclosure: credentials are summarized by presence/fingerprint only.",
]);
@@ -2403,22 +2402,6 @@ function renderMirrorWebhookStatus(result: Record<string, unknown>): RenderedCli
]);
}
function renderMirrorWebhookTest(result: Record<string, unknown>): RenderedCliResult {
const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), boolText(repo.pingOk), boolText(repo.testOk), boolText(repo.sourceBundleReady), stringValue(repo.sourceCommit ?? repo.branchCommit).slice(0, 12), stringValue(repo.snapshotCommit).slice(0, 12), compactTail(stringValue(repo.error))]);
const next = record(result.next);
return rendered(result, "platform-infra gitea mirror webhook test", [
"PLATFORM-INFRA GITEA MIRROR WEBHOOK TEST",
...table(["OK", "MUTATION", "TARGET"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id)]]),
"",
"TEST",
...(repos.length === 0 ? ["-"] : table(["KEY", "GH_PING", "POST_OK", "SNAPSHOT_READY", "SOURCE", "SNAPSHOT", "ERROR"], repos)),
...remoteErrorLines(result),
"",
`NEXT ${stringValue(next.webhookStatus)}`,
]);
}
function renderPlan(result: Record<string, unknown>): RenderedCliResult {
const config = record(result.config);
const target = record(config.target);
@@ -2441,10 +2424,11 @@ function renderPlan(result: Record<string, unknown>): RenderedCliResult {
]),
"",
"NEXT",
` dry-run: ${stringValue(next.dryRun)}`,
` apply: ${stringValue(next.apply)}`,
` status: ${stringValue(next.status)}`,
` validate: ${stringValue(next.validate)}`,
` mirror-status: ${stringValue(next.mirrorStatus)}`,
` webhook-status: ${stringValue(next.webhookStatus)}`,
` fix-automatic-delivery: ${stringValue(next.fixAutomaticDelivery)}`,
"",
"Boundary: Gitea is internal ClusterIP source authority for GH-1560; CI trigger authority is Pipelines-as-Code, not Gitea Actions/act_runner.",
"Disclosure: Secret values are not printed; this stage does not create runner credentials.",
@@ -2453,8 +2437,10 @@ function renderPlan(result: Record<string, unknown>): RenderedCliResult {
function renderApply(result: Record<string, unknown>): RenderedCliResult {
const target = record(result.target);
const payload = record(result.payloadTransport);
const remote = record(result.remote);
const steps = record(remote.steps);
const serverDryRunStep = record(steps.serverDryRun);
const frpcStep = record(steps.frpcSecret);
const frpcCleanupStep = record(steps.frpcCleanup);
const webhookSecretStep = record(steps.webhookSyncSecret);
@@ -2466,9 +2452,11 @@ function renderApply(result: Record<string, unknown>): RenderedCliResult {
return rendered(result, "platform-infra gitea apply", [
"PLATFORM-INFRA GITEA APPLY",
...table(["TARGET", "NAMESPACE", "MODE", "OK"], [[stringValue(target.id), stringValue(target.namespace), stringValue(result.mode), boolText(result.ok)]]),
...table(["TRANSPORT", "BYTES", "MATERIALIZATION", "VALUES"], [[stringValue(payload.kind), stringValue(payload.totalBytes), stringValue(payload.materialization), stringValue(payload.valuesPrinted)]]),
"",
"STEPS",
...table(["STEP", "EXIT", "DETAIL"], [
["serverDryRun", stringValue(serverDryRunStep.exitCode), compactLine(stringValue(serverDryRunStep.stderrTail, stringValue(serverDryRunStep.stdoutTail)))],
["frpc-secret", stringValue(frpcStep.exitCode), compactLine(stringValue(frpcStep.stderrTail, stringValue(frpcStep.stdoutTail)))],
["frpc-cleanup", stringValue(frpcCleanupStep.exitCode), compactLine(stringValue(frpcCleanupStep.stderrTail, stringValue(frpcCleanupStep.stdoutTail)))],
["webhook-secret", stringValue(webhookSecretStep.exitCode), compactLine(stringValue(webhookSecretStep.stderrTail, stringValue(webhookSecretStep.stdoutTail)))],
@@ -2604,12 +2592,13 @@ function parseCommonOptions(args: string[]): CommonOptions {
return { targetId, full, raw };
}
function nextCommands(targetId: string): Record<string, string> {
function giteaReadOnlyNextCommands(targetId: string): Record<string, string> {
return {
dryRun: `bun scripts/cli.ts platform-infra gitea apply --target ${targetId} --dry-run`,
apply: `bun scripts/cli.ts platform-infra gitea apply --target ${targetId} --confirm`,
status: `bun scripts/cli.ts platform-infra gitea status --target ${targetId}`,
validate: `bun scripts/cli.ts platform-infra gitea validate --target ${targetId}`,
mirrorStatus: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId}`,
webhookStatus: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId}`,
fixAutomaticDelivery: PAC_AUTOMATIC_DELIVERY_REFERENCE,
};
}
@@ -261,6 +261,7 @@ pipeline_rows() {
kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get pipelinerun -o json >"$payload_file" 2>/dev/null || printf '{"items":[]}' >"$payload_file"
node - "$payload_file" <<'NODE'
const fs = require('node:fs');
const { classifyPacPipelineRun, pipelineRunMatchesConsumer } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
const input = fs.readFileSync(process.argv[2], 'utf8') || '{"items":[]}';
const data = input ? JSON.parse(input) : { items: [] };
function firstString(...values) {
@@ -311,17 +312,19 @@ function durationSeconds(item) {
}
const prefix = process.env.UNIDESK_PAC_PIPELINE_RUN_PREFIX;
const pipeline = process.env.UNIDESK_PAC_PIPELINE_NAME;
const consumer = {
id: process.env.UNIDESK_PAC_CONSUMER_ID,
repository: process.env.UNIDESK_PAC_REPOSITORY_NAME,
pipelineRunPrefix: prefix,
pipeline,
};
const rows = (data.items || [])
.filter((item) => {
const labels = item.metadata?.labels || {};
const name = item.metadata?.name || '';
return name.startsWith(prefix)
|| labels['tekton.dev/pipeline'] === pipeline
|| labels['tekton.dev/pipelineName'] === pipeline;
})
.sort((a, b) => Date.parse(b.metadata?.creationTimestamp || 0) - Date.parse(a.metadata?.creationTimestamp || 0))
.filter((item) => pipelineRunMatchesConsumer(consumer, item))
.map((item) => ({ item, classification: classifyPacPipelineRun(consumer, item) }))
.filter(({ classification }) => classification.deliveryClass === 'outer-pac-event' && classification.deliveryAuthorityEligible === true)
.sort((a, b) => Date.parse(b.item.metadata?.creationTimestamp || 0) - Date.parse(a.item.metadata?.creationTimestamp || 0))
.slice(0, 8)
.map((item) => {
.map(({ item, classification }) => {
const c = cond(item);
const params = mapParams(item.spec?.params);
return {
@@ -333,6 +336,9 @@ const rows = (data.items || [])
completionTime: item.status?.completionTime || null,
durationSeconds: durationSeconds(item),
sourceCommit: extractCommit(item, params),
deliveryClass: classification.deliveryClass,
deliveryAuthorityEligible: classification.deliveryAuthorityEligible,
classification,
};
});
process.stdout.write(JSON.stringify(rows));
@@ -344,7 +350,7 @@ history_rows() {
node <<'NODE'
const fs = require('node:fs');
const cp = require('node:child_process');
const { extractPacArtifactEvidence, parsePacLogRecords, taskTerminalRecord } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
const { classifyPacPipelineRun, extractPacArtifactEvidence, parsePacLogRecords, pipelineRunMatchesConsumer, taskTerminalRecord } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
const limit = Math.max(1, Math.min(50, Number.parseInt(process.env.UNIDESK_PAC_HISTORY_LIMIT || '5', 10) || 5));
const detailId = process.env.UNIDESK_PAC_HISTORY_ID || '';
const consumers = JSON.parse(Buffer.from(process.env.UNIDESK_PAC_HISTORY_CONSUMERS_B64 || 'W10=', 'base64').toString('utf8'));
@@ -612,6 +618,7 @@ function rowFor(consumer, item, taskRuns) {
...evidence.sourceObservation,
sourceMatched: sourceCommit !== null && evidence.sourceObservation.sourceCommit === sourceCommit,
};
const classification = classifyPacPipelineRun(consumer, item);
return {
id: item.metadata.name,
consumer: consumer.id,
@@ -634,6 +641,13 @@ function rowFor(consumer, item, taskRuns) {
branch: extractBranch(item, params),
eventType: firstString(labels['pipelinesascode.tekton.dev/event-type'], annotations['pipelinesascode.tekton.dev/event-type']),
sender: firstString(labels['pipelinesascode.tekton.dev/sender'], annotations['pipelinesascode.tekton.dev/sender']),
deliveryClass: classification.deliveryClass,
deliveryAuthorityEligible: classification.deliveryAuthorityEligible,
deliveryOwner: classification.deliveryOwner,
executionOwner: classification.executionOwner,
parentPipelineRun: classification.parentPipelineRun,
parentRelation: classification.parentRelation,
classification,
envReuse: {
status: evidence.status,
buildSkippedCount: evidence.buildSkippedCount,
@@ -645,19 +659,15 @@ function rowFor(consumer, item, taskRuns) {
artifact: evidence.artifact,
collector: evidence.collector,
taskRuns: tasks,
source: 'gitea-pac-tekton-live',
source: classification.deliveryClass === 'outer-pac-event'
? 'gitea-pac-event-observed'
: classification.deliveryClass === 'inner-deterministic-publish'
? 'tekton-deterministic-publish-observed'
: 'tekton-unclassified-observed',
valuesPrinted: false,
};
}
function pipelineRunMatchesConsumer(consumer, item) {
const labels = item.metadata?.labels || {};
const name = item.metadata?.name || '';
return name.startsWith(consumer.pipelineRunPrefix)
|| labels['tekton.dev/pipeline'] === consumer.pipeline
|| labels['tekton.dev/pipelineName'] === consumer.pipeline;
}
const rows = [];
const consumerSummaries = [];
for (const consumer of consumers) {
@@ -671,7 +681,18 @@ for (const consumer of consumers) {
matches = matches.sort((a, b) => Date.parse(b.metadata?.creationTimestamp || 0) - Date.parse(a.metadata?.creationTimestamp || 0));
if (!detailId) matches = matches.slice(0, limit);
for (const item of matches) rows.push(rowFor(consumer, item, taskRuns));
consumerSummaries.push({ id: consumer.id, namespace: consumer.namespace, repository: consumer.repository, pipelineRunPrefix: consumer.pipelineRunPrefix, totalPipelineRuns: (pipelineRuns.items || []).length, matched: matches.length });
const classified = matches.map((item) => classifyPacPipelineRun(consumer, item));
consumerSummaries.push({
id: consumer.id,
namespace: consumer.namespace,
repository: consumer.repository,
pipelineRunPrefix: consumer.pipelineRunPrefix,
totalPipelineRuns: (pipelineRuns.items || []).length,
matched: matches.length,
outerPacEvents: classified.filter((item) => item.deliveryClass === 'outer-pac-event').length,
innerDeterministicPublishes: classified.filter((item) => item.deliveryClass === 'inner-deterministic-publish').length,
unknown: classified.filter((item) => item.deliveryClass === 'unknown').length,
});
}
rows.sort((a, b) => Date.parse(b.triggeredAt || 0) - Date.parse(a.triggeredAt || 0));
process.stdout.write(JSON.stringify({ rows, consumers: consumerSummaries, errors: kubectlJsonErrors }));
@@ -1246,6 +1267,7 @@ function breakAt(code, stage, reason) {
}
if (historyErrors.length > 0) breakAt('target-read-failed', 'target-read', historyErrors[0]?.error || historyErrors[0]?.stderr || 'target-side history read failed');
if (Object.keys(row).length === 0) breakAt('pipeline-run-not-found', 'pipeline-run', `PipelineRun ${process.env.UNIDESK_PAC_HISTORY_ID || '-'} was not found for the selected consumer`);
if (Object.keys(row).length > 0 && row.deliveryAuthorityEligible !== true) breakAt('pipeline-run-not-delivery-authority', 'pipeline-run', `PipelineRun is classified as ${row.deliveryClass || 'unknown'}; only an outer PaC event can drive delivery closeout`);
if (Object.keys(row).length > 0 && row.status !== 'True') breakAt('pipeline-run-not-succeeded', 'pipeline-run', `PipelineRun terminal status is ${row.status || row.reason || 'unknown'}`);
if (Object.keys(row).length > 0 && Object.keys(sourceObservation).length === 0) breakAt('source-observation-missing', 'plan-artifacts', 'g14-ci-plan structured evidence is missing');
if (sourceObservation.sourceMatched !== true) breakAt('source-observation-mismatch', 'plan-artifacts', 'g14-ci-plan source commit does not match the selected PipelineRun');
@@ -1269,6 +1291,11 @@ const realRun = {
reason: row.reason || null,
sourceCommit: row.commit || null,
sourceMatched: sourceObservation.sourceMatched === true,
deliveryClass: row.deliveryClass || 'unknown',
deliveryAuthorityEligible: row.deliveryAuthorityEligible === true,
deliveryOwner: row.deliveryOwner || null,
executionOwner: row.executionOwner || null,
parentRelation: row.parentRelation || null,
valuesPrinted: false,
},
mode: sourceObservation.mode || null,
@@ -1316,19 +1343,10 @@ if (!out.ok) process.exitCode = 1;
NODE
}
webhook_test_action() {
hooks=$(hook_summary)
hook_id=$(printf '%s' "$hooks" | node -e 'const fs=require("fs"); const a=JSON.parse(fs.readFileSync(0,"utf8")||"[]"); process.stdout.write(String(a[0]?.id||""))')
test -n "$hook_id"
gitea_api POST "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks/$hook_id/tests" '{}' >/dev/null
printf '{"ok":true,"mutation":true,"webhookTestSent":true,"hookId":"%s","valuesPrinted":false}\n' "$(json_string "$hook_id")"
}
case "$UNIDESK_PAC_ACTION" in
apply) apply_action ;;
status) status_action ;;
history) history_action ;;
debug-step) debug_step_action ;;
webhook-test) webhook_test_action ;;
*) printf '{"ok":false,"error":"unsupported-action","valuesPrinted":false}\n'; exit 2 ;;
esac
+186 -306
View File
@@ -3,9 +3,6 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n
import { dirname, join } from "node:path";
import type { UniDeskConfig } from "./config";
import { rootPath } from "./config";
import { parseNodeScopedDelegatedOptions } from "./hwlab-node/plan";
import { nodeRuntimeEnsureGitMirrorFlushed } from "./hwlab-node/status";
import { runGitMirrorJob as runAgentRunGitMirrorJob } from "./agentrun/rest-bridge";
import type { RenderedCliResult } from "./output";
import {
capture,
@@ -17,6 +14,7 @@ import {
sha256Fingerprint,
} from "./platform-infra-ops-library";
import { materializeYamlComposition } from "./yaml-composition";
import { pacNodeReadOnlyNext, pacReadOnlyNext, resolveCicdDeliveryAuthority } from "./cicd-delivery-authority";
import {
canonicalizePacPipelineSpec,
pacSourceArtifactSafeError,
@@ -78,6 +76,13 @@ interface PacConfig {
controllerServicePort: number;
waitTimeoutSeconds: number;
};
capabilities: {
sentinelInternalPublish: {
enabled: boolean;
admissionProvenance: "unavailable";
trackingIssue: string;
};
};
closeout: {
gitOpsMirrorFlush: {
enabled: boolean;
@@ -167,6 +172,17 @@ interface HistoryOptions extends CommonOptions {
detailId: string | null;
}
export interface PacHistoryConsumerIdentity {
readonly id: string;
readonly node: string;
readonly pipelineRunPrefix: string;
}
export interface PacHistoryConsumerSelection {
readonly selectedConsumerIds: readonly string[];
readonly nextConsumerId: string | null;
}
interface CloseoutOptions extends CommonOptions {
sourceCommit: string | null;
wait: boolean;
@@ -178,10 +194,6 @@ interface ApplyOptions extends CommonOptions {
wait: boolean;
}
interface WebhookTestOptions extends CommonOptions {
confirm: boolean;
}
interface SecretMaterial {
adminUsername: string;
adminPassword: string;
@@ -193,7 +205,7 @@ interface SecretMaterial {
export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
const [action = "plan"] = args;
if (action === "help" || action === "--help") return help();
if (action === "help" || action === "--help") return help(args[1] ?? null);
if (action === "plan") {
const options = parseCommonOptions(args.slice(1));
const result = plan(options);
@@ -224,11 +236,6 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf
const result = await debugStep(config, options);
return options.full || options.raw ? result : options.json ? result : renderDebugStep(result);
}
if (action === "webhook-test") {
const options = parseWebhookTestOptions(args.slice(1));
const result = await webhookTest(config, options);
return options.json || options.full || options.raw ? result : renderWebhookTest(result);
}
if (action === "source-artifact") {
if (args.length === 1 || args.slice(1).includes("--help") || args.slice(1).includes("-h")) return sourceArtifactHelp();
const structuredError = args.includes("--json") || args.includes("--full");
@@ -275,7 +282,7 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf
return sourceArtifactValidationError(safeError);
}
}
return { ok: false, error: "unsupported-platform-infra-pipelines-as-code-command", args, help: help() };
return { ok: false, error: "unsupported-platform-infra-pipelines-as-code-command", args, help: help(null) };
}
function sourceArtifactHelp(): RenderedCliResult {
@@ -307,31 +314,57 @@ function sourceArtifactValidationError(error: ReturnType<typeof pacSourceArtifac
};
}
function help(): Record<string, unknown> {
function help(scope: string | null): Record<string, unknown> {
if (scope === "platform-bootstrap") {
return {
command: "platform-infra pipelines-as-code help platform-bootstrap",
scope: "explicit-initial-platform-bootstrap",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target <NODE> --dry-run",
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target <NODE> --confirm",
],
boundary: "仅用于首次安装或显式平台 bootstrap;不得在 source PR 合并后用于交付、恢复或补跑。",
mutation: "explicit-confirm-required",
};
}
if (scope === "compatibility-diagnostics") {
return {
command: "platform-infra pipelines-as-code help compatibility-diagnostics",
scope: "explicit-readonly-or-connectivity-diagnostics",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra pipelines-as-code closeout --target <NODE> --consumer <consumer> --source-commit <sha> --wait",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target <NODE> --consumer <consumer> --full",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target <NODE> --consumer <consumer> --limit 10 --full",
"bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target <NODE> --consumer <consumer> --json",
],
boundary: "本 scope 只允许读取现有 PaC/Tekton/Argo 证据;不得 POST hook test、伪造 push、交付、恢复或补跑。",
};
}
return {
command: "platform-infra pipelines-as-code plan|apply|status|closeout|history|debug-step|source-artifact",
command: "platform-infra pipelines-as-code plan|status|history|debug-step|source-artifact",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra pipelines-as-code plan --target JD01",
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target JD01 --dry-run",
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target JD01 --confirm",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 [--json|--full|--raw]",
"bun scripts/cli.ts platform-infra pipelines-as-code closeout --target JD01 --consumer <consumer> --source-commit <sha> --wait",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 [--consumer hwlab-jd01-v03] [--limit 10]",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id <pipelinerun>",
"bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 [--consumer <consumer>] [--id <pipelinerun>] [--json]",
"bun scripts/cli.ts platform-infra pipelines-as-code source-artifact check --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree",
"bun scripts/cli.ts platform-infra pipelines-as-code source-artifact write --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree --confirm",
"bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runtime --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree --source-commit <full-sha>",
],
diagnostics: "webhook-test exists only for bounded connectivity diagnosis and must not be used as delivery evidence.",
boundary: "Sole CI trigger path for GH-1552/GH-1607: GitHub PR merge -> GitHub webhook bridge -> Gitea mirror/snapshot -> Pipelines-as-Code -> Tekton -> GitOps/Argo/k8s runtime.",
maintenanceHelp: "bun scripts/cli.ts platform-infra pipelines-as-code help platform-bootstrap",
compatibilityHelp: "bun scripts/cli.ts platform-infra pipelines-as-code help compatibility-diagnostics",
boundary: "唯一正式触发链:GitHub PR merge -> GitHub webhook bridge -> Gitea mirror/snapshot -> Pipelines-as-Code -> Tekton -> GitOps/Argo/k8s runtime。",
};
}
function readPacConfig(): PacConfig {
const root = materializeYamlComposition(readYamlRecord<Record<string, unknown>>(configFile, "platform-infra-pipelines-as-code"), { label: configLabel }).value;
const release = y.objectField(root, "release", "");
const capabilities = y.objectField(root, "capabilities", "");
const sentinelInternalPublish = y.objectField(capabilities, "sentinelInternalPublish", "capabilities");
const closeout = y.objectField(root, "closeout", "");
const gitOpsMirrorFlush = y.objectField(closeout, "gitOpsMirrorFlush", "closeout");
const gitea = y.objectField(root, "gitea", "");
@@ -369,6 +402,13 @@ function readPacConfig(): PacConfig {
controllerServicePort: y.portField(release, "controllerServicePort", "release"),
waitTimeoutSeconds: positiveInteger(release, "waitTimeoutSeconds", "release"),
},
capabilities: {
sentinelInternalPublish: {
enabled: y.booleanField(sentinelInternalPublish, "enabled", "capabilities.sentinelInternalPublish"),
admissionProvenance: y.enumField(sentinelInternalPublish, "admissionProvenance", "capabilities.sentinelInternalPublish", ["unavailable"] as const),
trackingIssue: urlField(sentinelInternalPublish, "trackingIssue", "capabilities.sentinelInternalPublish"),
},
},
closeout: {
gitOpsMirrorFlush: {
enabled: y.booleanField(gitOpsMirrorFlush, "enabled", "closeout.gitOpsMirrorFlush"),
@@ -712,8 +752,7 @@ function plan(options: CommonOptions): Record<string, unknown> {
config: configSummary(pac, consumer, repository),
release: pac.release,
repository: repositorySummary(repository),
consumer,
closeout: closeoutDeclaration(pac, consumer),
consumer: consumerObservationSummary(consumer),
secrets,
policy: policyChecks(repository),
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
@@ -738,7 +777,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
config: compactConfigSummary(pac, consumer, repository),
release: { version: pac.release.version, manifestUrl: pac.release.manifestUrl },
repository: repositorySummary(repository),
consumer,
consumer: consumerObservationSummary(consumer),
secrets: secretSummaries(pac),
remote: parsed ?? compactCapture(result, { full: true }),
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
@@ -754,14 +793,15 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise<Re
const result = await capture(config, target.route, ["sh"], remoteScript("status", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, ""));
const parsed = parseJsonOutput(result.stdout);
const summary = parsed === null ? null : statusSummary(parsed);
const deliveryAuthority = resolveCicdDeliveryAuthority({ consumerId: consumer.id, node: consumer.node, lane: consumer.lane });
return {
ok: result.exitCode === 0 && summary?.ready === true,
ok: result.exitCode === 0 && summary?.ready === true && deliveryAuthority.kind === "pac-pr-merge",
action: "platform-infra-pipelines-as-code-status",
mutation: false,
target: targetSummary(target),
config: compactConfigSummary(pac, consumer, repository),
consumer,
closeout: closeoutDeclaration(pac, consumer),
consumer: consumerObservationSummary(consumer),
deliveryAuthority,
coverage: consumerCoverage(pac, target.id),
summary,
remote: parsed === null ? compactCapture(result, { full: true }) : options.raw ? parsed : undefined,
@@ -827,12 +867,7 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC
elapsedMs: Date.now() - startedAt,
valuesPrinted: false,
},
next: {
status: `bun scripts/cli.ts cicd status --node ${nodeId}`,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${target.id} --limit 10`,
closeout: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${target.id} --wait`,
valuesPrinted: false,
},
next: pacNodeReadOnlyNext(target.id),
details: options.full || options.raw ? statuses : undefined,
valuesPrinted: false,
};
@@ -859,20 +894,20 @@ async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promis
let observedSourceCommit = observedCloseoutSourceCommit(latest, diagnostics);
let sourceMatched = options.sourceCommit === null || observedSourceCommit === options.sourceCommit;
const ciReady = closeoutCiReady(current, options.sourceCommit);
printCloseoutProgress(target, consumer, "gitops-mirror-flush", "started", startedAt, {
required: pac.closeout.gitOpsMirrorFlush.enabled && consumer.closeoutGitOpsMirrorFlush,
ciReady,
sourceCommit: observedSourceCommit,
});
let gitOpsMirrorFlush = await runCloseoutGitOpsMirrorFlush(config, pac, consumer, summary, latest, ciReady, observedSourceCommit);
printCloseoutProgress(target, consumer, "gitops-mirror-flush", record(gitOpsMirrorFlush).ok === false ? "failed" : "completed", startedAt, {
mode: record(gitOpsMirrorFlush).mode,
executed: record(gitOpsMirrorFlush).executed === true,
required: record(gitOpsMirrorFlush).required === true,
});
const gitOpsMirrorFlushReady = record(gitOpsMirrorFlush).ok !== false;
let gitOpsMirrorFlush: Record<string, unknown> = {
ok: true,
enabled: false,
configured: consumer.closeoutGitOpsMirrorFlush,
required: false,
applicable: false,
mode: "automatic-chain-owned-observation",
executed: false,
reason: "PaC migrated delivery owns GitOps publication and mirror persistence; compatibility closeout is read-only.",
configSource: configLabel,
valuesPrinted: false,
};
const runtimeStartedAt = Date.now();
if (ciReady && gitOpsMirrorFlushReady) {
if (ciReady) {
current = await observeCloseoutStatus(config, options, target, consumer, "runtime", 1, startedAt);
runtimeAttempts += 1;
while (options.wait && !closeoutReady(current, options.sourceCommit) && Date.now() - runtimeStartedAt < waitMs) {
@@ -892,7 +927,7 @@ async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promis
afterSummary: summary,
valuesPrinted: false,
};
const ready = baseReady && gitOpsMirrorFlushReady;
const ready = baseReady;
printCloseoutProgress(target, consumer, "closeout", ready ? "completed" : "blocked", startedAt, {
ready,
sourceMatched,
@@ -903,7 +938,7 @@ async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promis
return {
ok: ready,
action: "platform-infra-pipelines-as-code-closeout",
mutation: record(gitOpsMirrorFlush).executed === true,
mutation: false,
target: targetSummary(target),
consumer,
sourceCommit: options.sourceCommit,
@@ -962,13 +997,8 @@ function printCloseoutProgress(target: PacTarget, consumer: PacConsumer, stage:
async function history(config: UniDeskConfig, options: HistoryOptions): Promise<Record<string, unknown>> {
const pac = readPacConfig();
const target = resolveTarget(pac, options.targetId);
const targetConsumers = pac.consumers.filter((consumer) => consumer.node.toLowerCase() === target.id.toLowerCase());
const inferredDetailConsumers = options.detailId === null
? []
: targetConsumers.filter((consumer) => options.detailId?.startsWith(consumer.pipelineRunPrefix));
const selectedConsumers = options.consumerId === null
? inferredDetailConsumers.length === 1 ? inferredDetailConsumers : targetConsumers
: [resolveConsumer(pac, options.consumerId)];
const selection = resolvePacHistoryConsumerSelection(pac.consumers, target.id, options.consumerId, options.detailId);
const selectedConsumers = selection.selectedConsumerIds.map((id) => resolveConsumer(pac, id));
const firstConsumer = selectedConsumers[0];
if (firstConsumer === undefined) throw new Error("no Pipelines-as-Code consumers are configured");
const firstRepository = resolveRepository(pac, firstConsumer.repositoryRef);
@@ -1003,7 +1033,46 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise<
valuesPrinted: false,
},
remote: parsed === null || options.raw ? remote : undefined,
next: nextCommands(target.id, options.consumerId ?? pac.defaults.consumerId, pac.defaults.consumerId),
next: selection.nextConsumerId === null
? pacNodeReadOnlyNext(target.id)
: pacReadOnlyNext(target.id, selection.nextConsumerId),
};
}
export function resolvePacHistoryConsumerSelection(
consumers: readonly PacHistoryConsumerIdentity[],
targetId: string,
requestedConsumerId: string | null,
detailId: string | null,
): PacHistoryConsumerSelection {
const targetConsumers = consumers.filter((consumer) => consumer.node.toLowerCase() === targetId.toLowerCase());
if (targetConsumers.length === 0) throw new Error(`no Pipelines-as-Code consumers are configured for target ${targetId}`);
const requestedConsumer = requestedConsumerId === null
? null
: consumers.find((consumer) => consumer.id.toLowerCase() === requestedConsumerId.toLowerCase()) ?? null;
if (requestedConsumerId !== null && requestedConsumer === null) {
throw new Error(`unknown Pipelines-as-Code consumer ${requestedConsumerId}`);
}
if (requestedConsumer !== null && requestedConsumer.node.toLowerCase() !== targetId.toLowerCase()) {
throw new Error(`Pipelines-as-Code consumer ${requestedConsumer.id} belongs to ${requestedConsumer.node}, not target ${targetId}`);
}
if (detailId !== null) {
const matches = targetConsumers.filter((consumer) => detailId.startsWith(consumer.pipelineRunPrefix));
if (matches.length !== 1) {
throw new Error(`PipelineRun ${detailId} must match exactly one consumer on target ${targetId}; matched ${matches.length}`);
}
const matched = matches[0] as PacHistoryConsumerIdentity;
if (requestedConsumer !== null && requestedConsumer.id.toLowerCase() !== matched.id.toLowerCase()) {
throw new Error(`PipelineRun ${detailId} belongs to ${matched.id}, not requested consumer ${requestedConsumer.id}`);
}
return { selectedConsumerIds: [matched.id], nextConsumerId: matched.id };
}
if (requestedConsumer !== null) {
return { selectedConsumerIds: [requestedConsumer.id], nextConsumerId: requestedConsumer.id };
}
return {
selectedConsumerIds: targetConsumers.map((consumer) => consumer.id),
nextConsumerId: targetConsumers.length === 1 ? (targetConsumers[0] as PacHistoryConsumerIdentity).id : null,
};
}
@@ -1030,26 +1099,6 @@ async function debugStep(config: UniDeskConfig, options: HistoryOptions): Promis
};
}
async function webhookTest(config: UniDeskConfig, options: WebhookTestOptions): Promise<Record<string, unknown>> {
const pac = readPacConfig();
const target = resolveTarget(pac, options.targetId);
const consumer = resolveConsumer(pac, options.consumerId);
const repository = resolveRepository(pac, consumer.repositoryRef);
if (!options.confirm) return { ok: false, action: "platform-infra-pipelines-as-code-webhook-test", mutation: false, mode: "missing-confirm", error: "webhook-test requires --confirm" };
const secrets = ensureSecrets(pac, false);
const result = await capture(config, target.route, ["sh"], remoteScript("webhook-test", pac, target, repository, consumer, { ...options, dryRun: false, wait: false }, secrets, ""));
const parsed = parseJsonOutput(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
action: "platform-infra-pipelines-as-code-webhook-test",
mutation: true,
target: targetSummary(target),
consumer,
remote: parsed ?? compactCapture(result, { full: true }),
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
};
}
async function fetchReleaseManifest(pac: PacConfig): Promise<string> {
const response = await fetch(pac.release.manifestUrl);
if (!response.ok) throw new Error(`failed to fetch ${pac.release.manifestUrl}: HTTP ${response.status}`);
@@ -1058,7 +1107,7 @@ async function fetchReleaseManifest(pac: PacConfig): Promise<string> {
return text;
}
function remoteScript(action: "apply" | "status" | "history" | "debug-step" | "webhook-test", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | WebhookTestOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer]): string {
function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer]): string {
const webhookUrl = `${pac.gitea.internalBaseUrl.replace(/\/+$/u, "").replace(/gitea-http\.[^.]+\.svc\.cluster\.local:3000/u, `${pac.release.controllerServiceName}.${pac.release.namespace}.svc.cluster.local:${pac.release.controllerServicePort}`)}`;
const env: Record<string, string> = {
UNIDESK_PAC_ACTION: action,
@@ -1385,11 +1434,11 @@ function configSummary(pac: PacConfig, consumer: PacConsumer, repository: PacRep
path: configLabel,
metadata: pac.metadata,
release: pac.release,
closeout: pac.closeout,
capabilities: capabilitySummary(pac),
gitea: { configRef: pac.gitea.configRef, internalBaseUrl: pac.gitea.internalBaseUrl, webhookBranch: pac.gitea.webhook.branch },
display: pac.display,
repositories: pac.repositories.map(repositorySummary),
consumers: pac.consumers,
consumers: pac.consumers.map(consumerObservationSummary),
repository: repositorySummary(repository),
consumer,
valuesPrinted: false,
@@ -1400,7 +1449,7 @@ function compactConfigSummary(pac: PacConfig, consumer: PacConsumer, repository:
return {
path: configLabel,
releaseVersion: pac.release.version,
closeout: pac.closeout,
capabilities: capabilitySummary(pac),
repository: repository.name,
providerType: repository.providerType,
sourceUrl: repository.cloneUrl,
@@ -1412,15 +1461,13 @@ function compactConfigSummary(pac: PacConfig, consumer: PacConsumer, repository:
};
}
function closeoutDeclaration(pac: PacConfig, consumer: PacConsumer): Record<string, unknown> {
const config = pac.closeout.gitOpsMirrorFlush;
function capabilitySummary(pac: PacConfig): Record<string, unknown> {
return {
enabled: config.enabled,
configured: consumer.closeoutGitOpsMirrorFlush,
required: config.enabled && consumer.closeoutGitOpsMirrorFlush,
lane: consumer.closeoutGitOpsMirrorLane ?? consumer.lane,
waitTimeoutSeconds: config.waitTimeoutSeconds,
maxAttempts: config.maxAttempts,
sentinelInternalPublish: {
...pac.capabilities.sentinelInternalPublish,
configRef: `${configLabel}#capabilities.sentinelInternalPublish`,
valuesPrinted: false,
},
valuesPrinted: false,
};
}
@@ -1441,34 +1488,38 @@ function repositorySummary(repository: PacRepository): Record<string, unknown> {
};
}
function consumerObservationSummary(consumer: PacConsumer): Record<string, unknown> {
return {
id: consumer.id,
node: consumer.node,
lane: consumer.lane,
namespace: consumer.namespace,
pipeline: consumer.pipeline,
pipelineRunPrefix: consumer.pipelineRunPrefix,
argoNamespace: consumer.argoNamespace,
argoApplication: consumer.argoApplication,
repositoryRef: consumer.repositoryRef,
valuesPrinted: false,
};
}
function consumerCoverage(pac: PacConfig, targetId: string): Record<string, unknown>[] {
return pac.consumers.filter((consumer) => consumer.node.toLowerCase() === targetId.toLowerCase()).map((consumer) => {
const repository = resolveRepository(pac, consumer.repositoryRef);
const suffix = consumerSuffix(consumer.id, pac.defaults.consumerId);
return {
consumer: consumer.id,
source: `${repository.owner}/${repository.repo}@${repository.params.source_branch ?? "-"}`,
namespace: consumer.namespace,
pipeline: consumer.pipeline,
argoApplication: consumer.argoApplication,
statusCommand: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId}${suffix}`,
historyCommand: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId}${suffix}`,
statusCommand: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId} --consumer ${consumer.id}`,
historyCommand: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId} --consumer ${consumer.id}`,
};
});
}
function consumerSuffix(consumerId: string, defaultConsumerId: string): string {
return consumerId === defaultConsumerId ? "" : ` --consumer ${consumerId}`;
}
function nextCommands(targetId: string, consumerId: string, defaultConsumerId: string): Record<string, string> {
const suffix = consumerSuffix(consumerId, defaultConsumerId);
return {
apply: `bun scripts/cli.ts platform-infra pipelines-as-code apply --target ${targetId}${suffix} --confirm`,
closeout: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${targetId}${suffix} --wait`,
status: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId}${suffix}`,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId}${suffix}`,
};
function nextCommands(targetId: string, consumerId: string, _defaultConsumerId: string): Record<string, unknown> {
return pacReadOnlyNext(targetId, consumerId);
}
function compactPlanJson(result: Record<string, unknown>): Record<string, unknown> {
@@ -1483,7 +1534,7 @@ function compactPlanJson(result: Record<string, unknown>): Record<string, unknow
path: config.path,
metadata: config.metadata,
release: config.release,
closeout: config.closeout,
capabilities: config.capabilities,
gitea: config.gitea,
display: config.display,
valuesPrinted: false,
@@ -1500,7 +1551,6 @@ function compactPlanJson(result: Record<string, unknown>): Record<string, unknow
argoApplication: consumer.argoApplication,
repositoryRef: consumer.repositoryRef,
},
closeout: result.closeout,
secrets: result.secrets,
policy: result.policy,
next: result.next,
@@ -1523,7 +1573,8 @@ function compactStatusJson(result: Record<string, unknown>): Record<string, unkn
pipeline: consumer.pipeline,
argoApplication: consumer.argoApplication,
},
closeout: result.closeout,
deliveryAuthority: result.deliveryAuthority,
capabilities: record(result.config).capabilities,
summary: compactStatusSummary(record(result.summary)),
next: result.next,
valuesPrinted: false,
@@ -1711,7 +1762,7 @@ function renderPlan(result: Record<string, unknown>): RenderedCliResult {
const config = record(result.config);
const repository = record(result.repository);
const consumer = record(result.consumer);
const closeout = record(result.closeout);
const internalPublish = record(record(config.capabilities).sentinelInternalPublish);
const secrets = arrayRecords(result.secrets);
const policy = arrayRecords(result.policy);
const lines = [
@@ -1726,25 +1777,23 @@ function renderPlan(result: Record<string, unknown>): RenderedCliResult {
["cd", "Argo", stringValue(consumer.argoApplication)],
]),
"",
"CLOSEOUT",
...table(["GLOBAL_ENABLED", "CONSUMER_CONFIGURED", "REQUIRED", "LANE", "WAIT_S", "MAX_ATTEMPTS"], [[
boolText(closeout.enabled),
boolText(closeout.configured),
boolText(closeout.required),
stringValue(closeout.lane),
stringValue(closeout.waitTimeoutSeconds),
stringValue(closeout.maxAttempts),
]]),
"",
"SECRETS",
...table(["ID", "PRESENT", "FINGERPRINT", "VALUES"], secrets.map((item) => [stringValue(item.id), boolText(item.present), stringValue(item.fingerprint), "false"])),
"",
"POLICY",
...table(["NAME", "OK", "DETAIL"], policy.map((item) => [stringValue(item.name), boolText(item.ok), stringValue(item.detail)])),
"",
"NEXT",
` apply: ${stringValue(record(result.next).apply)}`,
"CAPABILITIES",
...table(["NAME", "ENABLED", "PROVENANCE", "TRACKING"], [[
"sentinelInternalPublish",
boolText(internalPublish.enabled),
stringValue(internalPublish.admissionProvenance),
stringValue(internalPublish.trackingIssue),
]]),
"",
"READ-ONLY NEXT",
` status: ${stringValue(record(result.next).status)}`,
` history: ${stringValue(record(result.next).history)}`,
];
return rendered(result, "platform-infra pipelines-as-code plan", lines);
}
@@ -1770,7 +1819,8 @@ function renderApply(result: Record<string, unknown>): RenderedCliResult {
function renderStatus(result: Record<string, unknown>): RenderedCliResult {
const summary = record(result.summary);
const consumer = record(result.consumer);
const closeout = record(result.closeout);
const deliveryAuthority = record(result.deliveryAuthority);
const internalPublish = record(record(record(result.config).capabilities).sentinelInternalPublish);
const coverage = arrayRecords(result.coverage);
const latest = record(summary.latestPipelineRun);
const taskRuns = arrayRecords(summary.taskRuns);
@@ -1788,18 +1838,9 @@ function renderStatus(result: Record<string, unknown>): RenderedCliResult {
...table(["CONSUMER", "SOURCE", "NAMESPACE", "PIPELINE", "ARGO_APP"], coverage.map((item) => [stringValue(item.consumer), stringValue(item.source), stringValue(item.namespace), stringValue(item.pipeline), stringValue(item.argoApplication)])),
"",
]),
...table(["CONSUMER", "READY", "CRD", "CONTROLLER", "WEBHOOKS", "REPO_URL"], [[stringValue(consumer.id), boolText(summary.ready), boolText(summary.crdPresent), stringValue(summary.controllerReady), stringValue(summary.webhookCount), short(stringValue(repository.url), 56)]]),
...table(["CONSUMER", "READY", "AUTHORITY", "CRD", "CONTROLLER", "WEBHOOKS", "REPO_URL"], [[stringValue(consumer.id), boolText(summary.ready), stringValue(deliveryAuthority.kind), boolText(summary.crdPresent), stringValue(summary.controllerReady), stringValue(summary.webhookCount), short(stringValue(repository.url), 56)]]),
` repository-condition: ${compactLine(stringValue(summary.repositoryCondition))}`,
"",
"CLOSEOUT",
...table(["GLOBAL_ENABLED", "CONSUMER_CONFIGURED", "REQUIRED", "LANE", "WAIT_S", "MAX_ATTEMPTS"], [[
boolText(closeout.enabled),
boolText(closeout.configured),
boolText(closeout.required),
stringValue(closeout.lane),
stringValue(closeout.waitTimeoutSeconds),
stringValue(closeout.maxAttempts),
]]),
` sentinel-internal-publish: enabled=${boolText(internalPublish.enabled)} provenance=${stringValue(internalPublish.admissionProvenance)} tracking=${stringValue(internalPublish.trackingIssue)}`,
"",
"GITEA HOOKS",
...(webhooks.length === 0 ? ["-"] : table(["HOOK", "ACTIVE", "EVENTS", "URL"], webhooks.map((item) => [stringValue(item.id), boolText(item.active), Array.isArray(item.events) ? item.events.join(",") : stringValue(item.events), short(stringValue(item.url), 56)]))),
@@ -1852,9 +1893,9 @@ function renderStatus(result: Record<string, unknown>): RenderedCliResult {
` pipeline-run: ${latest.name === undefined ? "-" : `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${stringValue(record(result.target).id)} --id ${stringValue(latest.name)}`}`,
"",
"NEXT",
` closeout: ${stringValue(record(result.next).closeout)}`,
` full: ${stringValue(record(result.next).status)} --full`,
` all-history: ${stringValue(record(result.next).history).replace(/ --consumer [^ ]+/u, "")} --limit 10`,
` fix-automatic-delivery: ${stringValue(record(record(result.next).fixAutomaticDelivery).reference)}`,
];
return rendered(result, "platform-infra pipelines-as-code status", lines);
}
@@ -1905,7 +1946,7 @@ function renderCloseout(result: Record<string, unknown>): RenderedCliResult {
"CICD DIAGNOSIS",
...table(["OK", "CODE", "PHASE", "HINT"], [[boolText(diagnostics.ok !== false), stringValue(diagnostics.code), stringValue(diagnostics.phase), compactLine(stringValue(diagnostics.hint))]]),
"",
"GITOPS MIRROR FLUSH",
"AUTOMATIC DELIVERY OBSERVATION",
...table(["ENABLED", "REQUIRED", "OK", "MODE", "EXECUTED", "PENDING", "IN_SYNC"], [[
boolText(gitOpsMirrorFlush.enabled),
boolText(gitOpsMirrorFlush.required),
@@ -1945,10 +1986,11 @@ function renderHistory(result: Record<string, unknown>): RenderedCliResult {
]),
"",
"TRIGGERS",
...(rows.length === 0 ? ["-"] : table([timeHeader, "CONSUMER", "REPO", "STATUS", "DUR_S", "COMMIT", "ENV_REUSE", "ID"], rows.map((row) => [
...(rows.length === 0 ? ["-"] : table([timeHeader, "CONSUMER", "CLASS", "OWNER", "STATUS", "DUR_S", "COMMIT", "ENV_REUSE", "ID"], rows.map((row) => [
stringValue(row.displayTime) === "-" ? isoShort(stringValue(row.triggeredAt)) : stringValue(row.displayTime),
stringValue(row.consumer),
stringValue(row.repo),
stringValue(row.deliveryClass),
stringValue(row.deliveryOwner ?? row.executionOwner),
statusText(row),
stringValue(row.durationSeconds),
short(stringValue(row.commit)),
@@ -1964,18 +2006,6 @@ function renderHistory(result: Record<string, unknown>): RenderedCliResult {
return rendered(result, "platform-infra pipelines-as-code history", lines.filter((line): line is string => line !== null));
}
function renderWebhookTest(result: Record<string, unknown>): RenderedCliResult {
const remote = record(result.remote);
const lines = [
"PLATFORM-INFRA PIPELINES-AS-CODE WEBHOOK TEST",
...table(["OK", "MUTATION", "HOOK", "SENT"], [[boolText(result.ok), boolText(result.mutation), stringValue(remote.hookId), boolText(remote.webhookTestSent)]]),
"",
"NEXT",
` status: ${stringValue(record(result.next).status)}`,
];
return rendered(result, "platform-infra pipelines-as-code webhook-test", lines);
}
function renderDebugStep(result: Record<string, unknown>): RenderedCliResult {
const checks = arrayRecords(result.checks);
const realRun = record(result.realRun);
@@ -1998,8 +2028,10 @@ function renderDebugStep(result: Record<string, unknown>): RenderedCliResult {
...(Object.keys(realRun).length === 0 ? [] : [
"",
"REAL PIPELINERUN EVIDENCE",
...table(["FOUND", "STATUS", "SOURCE", "MODE", "VALID", "FIRST_BREAK"], [[
...table(["FOUND", "CLASS", "AUTHORITY", "STATUS", "SOURCE", "MODE", "VALID", "FIRST_BREAK"], [[
boolText(realRun.found),
stringValue(pipelineRun.deliveryClass),
boolText(pipelineRun.deliveryAuthorityEligible),
stringValue(pipelineRun.status ?? pipelineRun.reason),
short(stringValue(pipelineRun.sourceCommit), 16),
stringValue(realRun.mode),
@@ -2064,23 +2096,6 @@ function parseApplyOptions(args: string[]): ApplyOptions {
return { ...parseCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait };
}
function parseWebhookTestOptions(args: string[]): WebhookTestOptions {
const commonArgs: string[] = [];
let confirm = false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--confirm") confirm = true;
else {
commonArgs.push(arg);
if (arg === "--target" || arg === "--node" || arg === "--consumer") {
commonArgs.push(args[index + 1] ?? "");
index += 1;
}
}
}
return { ...parseCommonOptions(commonArgs), confirm };
}
function parseCloseoutOptions(args: string[]): CloseoutOptions {
const commonArgs: string[] = [];
let sourceCommit: string | null = null;
@@ -2250,146 +2265,6 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function runCloseoutGitOpsMirrorFlush(config: UniDeskConfig, pac: PacConfig, consumer: PacConsumer, summary: Record<string, unknown>, latest: Record<string, unknown>, baseReady: boolean, observedSourceCommit: string): Promise<Record<string, unknown>> {
const cfg = pac.closeout.gitOpsMirrorFlush;
const configSource = `${configLabel}.closeout.gitOpsMirrorFlush`;
const required = cfg.enabled && consumer.closeoutGitOpsMirrorFlush;
if (baseReady && stringValue(record(summary.diagnostics).code) === "pac-ready-no-runtime-change") {
return {
ok: true,
enabled: cfg.enabled,
required,
applicable: false,
mode: "retained-no-runtime-change",
executed: false,
reason: "the successful source observation produced no GitOps change, so there is no new revision to flush",
configSource,
valuesPrinted: false,
};
}
if (!required) {
return {
ok: true,
enabled: cfg.enabled,
required: false,
mode: cfg.enabled ? "consumer-not-enabled" : "disabled",
executed: false,
configSource,
valuesPrinted: false,
};
}
if (!baseReady) {
return {
ok: true,
enabled: true,
required: true,
mode: "waiting-for-pac-ready",
executed: false,
configSource,
valuesPrinted: false,
};
}
const pipelineRun = stringValue(latest.name);
if (observedSourceCommit === "-" || pipelineRun === "-") {
return {
ok: false,
enabled: true,
required: true,
mode: "missing-closeout-context",
executed: false,
configSource,
degradedReason: "pac-closeout-gitops-mirror-context-missing",
valuesPrinted: false,
};
}
if (consumer.id.startsWith("agentrun-")) {
const progressLines: string[] = [];
const flush = await captureStderrLines(progressLines, () => runAgentRunGitMirrorJob(config, "flush", {
node: consumer.node,
lane: consumer.lane,
confirm: true,
dryRun: false,
wait: true,
timeoutSeconds: cfg.waitTimeoutSeconds,
}));
return {
ok: flush.ok === true,
enabled: true,
required: true,
mode: stringValue(flush.mode, flush.ok === true ? "flushed" : "failed"),
executed: flush.ok === true,
sourceCommit: observedSourceCommit,
pipelineRun,
configSource,
waitTimeoutSeconds: cfg.waitTimeoutSeconds,
maxAttempts: cfg.maxAttempts,
beforeSummary: record(record(flush.status).summary),
afterSummary: record(record(flush.status).summary),
jobName: flush.jobName ?? null,
degradedReason: flush.ok === true ? undefined : "pac-closeout-agentrun-gitops-mirror-flush-failed",
next: record(flush.next),
flush,
progressLines,
valuesPrinted: false,
};
}
const deadlineMs = Date.now() + (cfg.waitTimeoutSeconds * 1000);
const gitOpsMirrorLane = consumer.closeoutGitOpsMirrorLane ?? consumer.lane;
const scoped = parseNodeScopedDelegatedOptions("git-mirror", [
"flush",
"--node",
consumer.node,
"--lane",
gitOpsMirrorLane,
"--confirm",
"--wait",
"--timeout-seconds",
String(cfg.waitTimeoutSeconds),
]);
const progressLines: string[] = [];
const flush = await captureStderrLines(progressLines, () => nodeRuntimeEnsureGitMirrorFlushed(scoped, "post", observedSourceCommit, pipelineRun, null, {
deadlineMs,
maxAttempts: cfg.maxAttempts,
}));
return {
ok: flush.ok === true,
enabled: true,
required: true,
mode: stringValue(flush.mode, flush.ok === true ? "flushed" : "failed"),
executed: flush.executed === true,
sourceCommit: observedSourceCommit,
pipelineRun,
configSource,
waitTimeoutSeconds: cfg.waitTimeoutSeconds,
maxAttempts: cfg.maxAttempts,
beforeSummary: flush.beforeSummary ?? null,
afterSummary: flush.afterSummary ?? null,
jobName: flush.jobName ?? null,
degradedReason: flush.degradedReason ?? undefined,
next: flush.next ?? undefined,
flush,
progressLines,
valuesPrinted: false,
};
}
async function captureStderrLines<T>(lines: string[], callback: () => T | Promise<T>): Promise<T> {
const stderr = process.stderr as typeof process.stderr & { write: (...args: unknown[]) => boolean };
const originalWrite = stderr.write.bind(process.stderr);
stderr.write = ((chunk: unknown, ...args: unknown[]): boolean => {
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
lines.push(text);
originalWrite(text);
const callbackArg = args.find((arg) => typeof arg === "function") as (() => void) | undefined;
callbackArg?.();
return true;
}) as typeof stderr.write;
try {
return await callback();
} finally {
stderr.write = originalWrite as typeof stderr.write;
}
}
function pipelineRunGate(latest: Record<string, unknown>): Record<string, unknown> {
const name = stringValue(latest.name);
@@ -2567,6 +2442,11 @@ function renderHistoryDetail(row: Record<string, unknown>): string[] {
"DETAIL",
...table(["FIELD", "VALUE"], [
["id", stringValue(row.id ?? row.pipelineRun)],
["deliveryClass", stringValue(row.deliveryClass)],
["deliveryAuthorityEligible", stringValue(row.deliveryAuthorityEligible)],
["deliveryOwner", stringValue(row.deliveryOwner)],
["executionOwner", stringValue(row.executionOwner)],
["parentRelation", stringValue(row.parentRelation)],
["pipeline", stringValue(row.pipeline)],
["branch", stringValue(row.branch)],
["commit", stringValue(row.commit)],
+7 -6
View File
@@ -462,13 +462,14 @@ export function platformInfraHelp(): unknown {
"bun scripts/cli.ts platform-infra gitea validate --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror status --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target NC01",
"bun scripts/cli.ts platform-infra pipelines-as-code closeout --target JD01 --consumer <consumer> --source-commit <sha> --wait",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 --consumer <consumer> [--json|--full|--raw]",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --consumer <consumer> --limit 10 [--json]",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id <pipelinerun> [--json|--full]",
"bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 --consumer <consumer> [--id <pipelinerun>] [--json]",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target NC01 --consumer <consumer> [--json|--full|--raw]",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer <consumer> --limit 10 [--json]",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --id <pipelinerun> [--json|--full]",
"bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target <NODE> --consumer <consumer> [--id <pipelinerun>] [--json]",
"bun scripts/cli.ts platform-infra pipelines-as-code help platform-bootstrap",
"bun scripts/cli.ts platform-infra pipelines-as-code help compatibility-diagnostics",
],
description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot, n8n, Web Terminal, WeChat archive workflows, OpenTelemetry tracing, the independent target-scoped secret plane, the target-scoped Kafka event bus, internal Gitea source authority, and Gitea + Pipelines-as-Code closeout for JD01 migrated consumers. Public services use PK01 Caddy/FRP or YAML-declared PK01 Caddy upstreams rather than Kubernetes Ingress, NodePort, or LoadBalancer.",
description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot, n8n, Web Terminal, WeChat archive workflows, OpenTelemetry tracing, the independent target-scoped secret plane, the target-scoped Kafka event bus, internal Gitea source authority, and read-only Gitea + Pipelines-as-Code observation for migrated consumers. Public services use PK01 Caddy/FRP or YAML-declared PK01 Caddy upstreams rather than Kubernetes Ingress, NodePort, or LoadBalancer.",
target,
codexPool: {
usage: [
@@ -0,0 +1,93 @@
// Responsibility: expose the YAML-owned sentinel internal publish capability without claiming provenance.
import { rootPath } from "./config";
import type { CicdDeliveryAuthority } from "./cicd-delivery-authority";
import type { WebProbeSentinelOptions } from "./hwlab-node-web-sentinel-cicd-shared";
import { readYamlRecord } from "./platform-infra-ops-library";
import { materializeYamlComposition } from "./yaml-composition";
const configPath = "config/platform-infra/pipelines-as-code.yaml";
const configRef = `${configPath}#capabilities.sentinelInternalPublish`;
export interface SentinelPacInternalPublishCapability {
readonly enabled: boolean;
readonly admissionProvenance: "unavailable";
readonly trackingIssue: string;
readonly configRef: string;
readonly valuesPrinted: false;
}
export type SentinelPacInternalPublishAuthorization = {
readonly allowed: false;
readonly mode: "sentinel-pac-internal-publish-blocked";
readonly reason:
| "operator-entrypoint"
| "delivery-authority-not-pac"
| "capability-disabled"
| "admission-provenance-unavailable";
readonly capability: SentinelPacInternalPublishCapability;
readonly valuesPrinted: false;
};
export function readSentinelPacInternalPublishCapability(): SentinelPacInternalPublishCapability {
const raw = readYamlRecord<Record<string, unknown>>(rootPath(...configPath.split("/")), "platform-infra-pipelines-as-code");
const composed = materializeYamlComposition(raw, { label: configPath }).value;
const capabilities = record(composed.capabilities, `${configPath}#capabilities`);
const capability = record(capabilities.sentinelInternalPublish, configRef);
const enabled = booleanField(capability.enabled, `${configRef}.enabled`);
const admissionProvenance = stringField(capability.admissionProvenance, `${configRef}.admissionProvenance`);
if (admissionProvenance !== "unavailable") {
throw new Error(`${configRef}.admissionProvenance must remain unavailable until the admission contract is implemented`);
}
const trackingIssue = stringField(capability.trackingIssue, `${configRef}.trackingIssue`);
if (!/^https:\/\/github\.com\/pikasTech\/unidesk\/issues\/\d+$/u.test(trackingIssue)) {
throw new Error(`${configRef}.trackingIssue must reference the owning GitHub issue`);
}
return {
enabled,
admissionProvenance,
trackingIssue,
configRef,
valuesPrinted: false,
};
}
export function authorizeSentinelPacInternalPublish(
options: WebProbeSentinelOptions,
authority: CicdDeliveryAuthority,
capability = readSentinelPacInternalPublishCapability(),
): SentinelPacInternalPublishAuthorization {
if (options.kind !== "publish" || options.internalExecution !== "pac-controller") {
return denied("operator-entrypoint", capability);
}
if (authority.kind !== "pac-pr-merge") return denied("delivery-authority-not-pac", capability);
if (!capability.enabled) return denied("capability-disabled", capability);
return denied("admission-provenance-unavailable", capability);
}
function denied(
reason: SentinelPacInternalPublishAuthorization["reason"],
capability: SentinelPacInternalPublishCapability,
): SentinelPacInternalPublishAuthorization {
return {
allowed: false,
mode: "sentinel-pac-internal-publish-blocked",
reason,
capability,
valuesPrinted: false,
};
}
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value as Record<string, unknown>;
}
function booleanField(value: unknown, path: string): boolean {
if (typeof value !== "boolean") throw new Error(`${path} must be a boolean`);
return value;
}
function stringField(value: unknown, path: string): string {
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path} must be a non-empty string`);
return value.trim();
}