Merge remote-tracking branch 'origin/master' into feat/1923-cicd-otel-observability

This commit is contained in:
Codex
2026-07-13 14:59:12 +02:00
47 changed files with 1686 additions and 161 deletions
+4 -1
View File
@@ -40,7 +40,10 @@ 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。
- 节点级只读状态
- 优先用 `cicd status --node <NODE>`
- 单 consumer 再用 `platform-infra pipelines-as-code status --target <NODE> --consumer <id>` 下钻;
- 状态输出与 registry applicability 的长期判定见 [references/gitea-pac.md](references/gitea-pac.md)。
- 新增 PaC consumer 的首次引导:
- 先执行一次 `pipelines-as-code bootstrap --dry-run`
@@ -78,6 +78,15 @@ bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target <NODE> --
- 只有归属或 TaskRun 断点不清楚时再用 `history --id``debug-step --id`
- 默认不使用 `--full``--raw`,避免把大对象带回本机;
- consumer 声明 `sourceArtifact` 且需要精确提交验收时,最后执行一次 `source-artifact verify-runtime`
- node 状态摘要必须保持可读且可下钻:
- text 输出在零 consumer、ready、warning 与失败时都返回非空 typed 摘要;
- 摘要只给出与当前状态匹配的精确下钻命令;
- 不要求操作者阅读源码或手工拼接多个 consumer 命令。
- registry applicability 只由 owning YAML 决定:
- consumer 对应 pipeline 声明 image repository 时,registry 状态适用;
- 适用的 registry 缺失是 blocker
- GitOps-only consumer 未声明 image repository 时,registry 显示 `N/A`
- 不得因 registry 不适用而把 GitOps-only consumer 判为失败。
## PaC 源码侧制品同步
+26
View File
@@ -356,6 +356,32 @@ sourceAuthority:
disposition: replaced-by-gitea
- key: selfmedia-nc01
targetId: NC01
credentialOverride:
github:
transport: https-token
sourceRef: pikainc-selfmedia-gh-token.txt
sourceKey: GH_TOKEN
format: raw-token
requiredFor:
- upstream-mirror
- mirror-sync
- managed-repository-fetch
- github-head-observe
- github-hooks-list
- github-hooks-reconcile
permissions:
contents: read
metadata: read
webhooks: read-write
gitFetchCredential:
apiVersion: unidesk.ai/v1
kind: GitFetchCredential
authMode: github-https-token
host: github.com
secretRef:
namespace: devops-infra
name: gitea-github-sync-secrets
key: github-token-pikainc-selfmedia
upstream:
repository: pikainc/selfmedia
cloneUrl: https://github.com/pikainc/selfmedia.git
@@ -139,6 +139,6 @@
按 [UniDesk #1905](https://github.com/pikasTech/unidesk/issues/1905) 清理冲突的 reference/skill/Next,并在 Artificer YAML prompt/resource bundle 内明确支持 stdin 时禁止一次性 /tmp 正文;补最小 render/行为测试后独立交付,当前只登记不执行,完成任务后将详细报告写入[任务报告](./details/cli-output-progressive-disclosure/R9.1_Task_Report.md)。
## R10 [in_progress]
## R10 [completed]
解决 [UniDesk #1656](https://github.com/pikasTech/unidesk/issues/1656):由 Artificer 在独立 `.worktree/cicd-status-visibility``fix/cicd-status-visibility` 分支修复 `cicd status --node` 成功零输出,并统一 PaC consumer 的 registry applicabilityGitOps-only/publisher-only 应显示 N/A 且由 PipelineRun、GitOps/Argo 与 runtime 证据闭环,只有 owning YAML 明确声明 image/registry 的 consumer 才把 registry missing 作为 blocker。保持默认输出有界、text/JSON/YAML 同构、缺失可选 YAML 事实降级为 warning,不新增严格合同或手工交付入口;补针对性测试和 `unidesk-cicd` 说明,提交独立 PR 且不自行合并,完成任务后将详细报告写入[任务报告](./details/cli-output-progressive-disclosure/R10_Task_Report.md)。
@@ -0,0 +1,22 @@
# R10 任务报告
## 交付
- Issue[pikasTech/unidesk#1656](https://github.com/pikasTech/unidesk/issues/1656)。
- PR[pikasTech/unidesk#1918](https://github.com/pikasTech/unidesk/pull/1918)。
- 实现提交:`f130598c`;文档治理提交:`40cdb946`;合并提交:`52b9c4bc`
- `cicd status --node` 在零 consumer、ready、warning 和 blocked 投影中稳定返回非空 typed 摘要。
- registry applicability 从 owning PaC YAML 的 repository/pipeline 关系派生,没有新增 consumer 特例或严格版本门禁。
## 验证
- 合并态测试:12 pass179 assertions;语法检查 11/11`git diff --check` 通过。
- NC01 节点状态返回 7 个 consumer 的完整表,Sub2Rank 为 ready。
- `platform-infra-gitea-nc01` 的 registry 为 `applicability=not-configured``status=not-configured`,不再产生 `pac-registry-missing`
- image-producing 的 `platform-infra-sub2rank-nc01` 仍要求真实 registry artifact,观测为 `present:sha256:c36633bb4ae`PipelineRun、GitOps、Argo 与 runtime ready。
- 节点整体仍有其他 consumer 的既有失败,这些失败未被本任务隐藏,也不影响 registry applicability 验收。
## 收尾
- PR 已合并,远端分支已删除,任务 worktree 与本地分支已清理。
- 长期说明位于 `unidesk-cicd``references/gitea-pac.md`skill 仅保留短索引。
@@ -0,0 +1,14 @@
# R1.1 任务报告
## 复现与合同
- 根因记录于 [agent_skills#7](https://github.com/pikasTech/agent_skills/issues/7):双引号 positional 标题在 CLI 启动前被 shell 展开,反引号和 `$()` 可执行 command substitution。
- positional 标题包含 CR、LF、NUL 或空标题时,必须在文件锁与写入前拒绝,并返回 `validation-failed``mutation=false`
- `add``add-sub``update` 的安全标题入口固定为 quoted heredoc `--stdin`
- batch 任一标题非法时整批零写入;错误不得回显原始敏感正文。
- update 必须保留原任务 ID、children、相邻任务、报告路径和文件锁语义。
## 证据
- 上游 PR #8 的固定测试覆盖 CR/LF/NUL、shell 敏感字符、stdin/positional 冲突和 update 恢复。
- 本轮临时 fixture 复测了反引号、`$()`、空格与引号,确认 shell substitution 未执行。
@@ -0,0 +1,15 @@
# R1.2 任务报告
## 实现
- 上游实现由 [agent_skills PR #8](https://github.com/pikasTech/agent_skills/pull/8) 合并,merge commit 为 `f928206`
- `add``add-sub``update` 支持 `--stdin`,并拒绝同时提供 positional title。
- 所有写入口共用单行校验、空标题校验和幂等标题正规化。
- update 恢复保持原 ID、children、相邻任务与报告链接,不复制任务。
- PR #9 继续收敛标点幂等与空标题验证,安装基线为 `2dfe24b`
## 验证
- 本机合并后的 `mdtodo-edit` 全量测试 207 pass。
- skill quick validation 与 `git diff --check` 均通过。
- 未创建第二套 MDTODO 工具,也未引入版本或 commit 门禁。
@@ -0,0 +1,20 @@
# R1.3 任务报告
## 合并与安装
- [agent_skills PR #8](https://github.com/pikasTech/agent_skills/pull/8) 已合并,issue #7 已关闭。
- `/root/.agents/skills` 原状态落后 5 个提交且包含并行脏改。
- 安装过程先 `stash -u`,再快进到 `origin/master@2dfe24b`,最后 `stash apply` 并对 3 个重叠文件做语义合并。
- 上游安全 stdin/校验实现作为本地标点改动的超集保留;其余 6 组并行修改、未跟踪 worktree 与保护 stash 未删除。
## 原入口验收
- `add --help` 显示 `--stdin``add-sub``update` capability 由合并态 UniDesk 测试覆盖。
- 临时 MDTODO 使用 quoted heredoc 写入包含反引号、`$()`、空格和引号的标题。
- 标题内容原样读取,预设 command-substitution 文件不存在。
- 全量测试 207 passskill 校验通过,CLI 文件 SHA-256 为 `16e9c7aacc97fc80...`
## 清理
- 临时 MDTODO fixture 已删除。
- 保护 stash 保留,避免对并行改动做破坏性 drop。
@@ -0,0 +1,15 @@
# R1 任务报告
## 结果
- MDTODO 标题污染根因、输入合同、实现、合并、本机安装与原入口复测全部闭环。
- 上游 owner 保持为 `pikasTech/agent_skills`,实现通过 issue #7 / PR #8 长期维护。
- UniDesk 通过 issue #1913 / PR #1920 提供 capability/freshness warning 和 quoted heredoc 默认示例,不复制实现。
- 缺失或过期只产生 warning,不作为业务 blocker。
## 验收摘要
- 上游 merge`f928206`;安装基线:`2dfe24b`
- MDTODO 测试:207 passUniDesk adoption 测试:2 pass / 9 assertions。
- 真实标题包含 Markdown、反引号、`$()`、空格与引号,内容原样写入且 shell substitution 未执行。
- 任务 ID、报告、children、相邻任务和文件锁合同均由固定测试保护。
@@ -0,0 +1,28 @@
# R2 任务报告
## 交付
- Issue[pikasTech/unidesk#1913](https://github.com/pikasTech/unidesk/issues/1913)。
- PR[pikasTech/unidesk#1920](https://github.com/pikasTech/unidesk/pull/1920)。
- 实现提交:`cfcade4e`;复审修正:`7dcc1395`;合并提交:`99fcfd34`
- UniDesk 只探测 `mdtodo-edit add/add-sub/update --help``--stdin` capability,不复制工具实现,也不绑定版本或 commit。
- source/target identity、freshness、warning 与精确 Next 已进入 compact/full 输出;漂移不改变 `ok``runnerUsable``contractOk`
- source stale、target ready 的边界现在返回非阻塞 `source-stale` warning,不再误报 ready freshness。
## 验证
- 合并态定向测试:2 pass9 assertions;语法检查 11/11`git diff --check` 通过。
- 本机 `/root/.agents/skills` 按 stash、快进、stash apply 做语义合并,原并行修改与保护 stash 均保留。
- 安装 HEAD 与 `origin/master` 同为 `2dfe24b`CLI SHA-256 为 `16e9c7aacc97fc80` 前缀。
- MDTODO 全量测试 207 passskill 校验通过。
- 临时真实 MDTODO 标题包含 Markdown、反引号、`$()`、空格和引号,经 quoted heredoc 原样写入;命令替换目标文件未生成。
## 非阻塞限制
- `codex skills-sync --dry-run --full` 在当前 NC01 主机仍报告旧 local Compose `backend-core-container-missing``mutation=false`
- 该诊断入口不可提供 live backend 投影,但不影响已安装安全 stdin、生成命令、fixture 测试或业务运行;没有为绕过它启动 Compose 或建立第二控制面。
## 收尾
- PR 已合并,远端分支已删除,任务 worktree 与本地分支已清理。
- R2 完成后由 R1.3 记录上游实现、受控安装和真实标题复测的完整闭环。
@@ -0,0 +1,77 @@
# R1.1 任务报告
## 任务回链
- MDTODOR1.1。
- TargetNC01。
- targetWorkspace`/root/.worktrees/unidesk/selfmedia-repo-github-authority`
- repository`pikasTech/unidesk`
- ref`feat/selfmedia-repo-github-authority`
## Primary Workspace 轻量准备
- 仅加载 `dad-dev``unidesk-trans``unidesk-ymalops``cli-spec``git-spec``unidesk-gh``docs-spec`
- 未在 runner primary workspace 读取、修改或验证目标源码。
- 未把 runner 本地结果作为 Target 原入口证据。
## Target 实现
-`config/platform-infra/gitea.yaml``selfmedia-nc01` 声明 repository credential override。
- 独立 `sourceRef``/root/.unidesk/.env` 根下的 `pikainc-selfmedia-gh-token.txt`
- 独立 Secret key 为 `github-token-pikainc-selfmedia`,未修改全局 `gh_token.txt` 权限或内容。
- YAML 声明最小权限:Contents Read、Metadata Read、Webhooks Read/Write。
- 配置 parser 与 validator 支持通用 repository override,并校验 Secret key 唯一性、环境变量归一化碰撞、目标 namespace、bridge Secret 归属和必需能力。
- Secret 渲染支持多个 GitHub token keytoken 仅通过环境变量进入受控执行,不进入 YAML、Git、日志或报告。
- bridge、candidate、受控 fetch、GitHub head、hooks list/status 和 hook reconciler 均按 repository 选择 credential。
- bridge/candidate 只注入所属 Target 仓库 tokenhook owner reconciler 额外注入所有声明 `github-hooks-*` 的 overrideJD01 不依赖 NC01 selfmedia token。
- CLI credential 摘要对 override 仅披露 `sourceRef``present``requiredKeysPresent``fingerprint``permissionResult`、有界错误与 `valuesPrinted=false`
## Target 原入口证据
- 已通过 `trans NC01:/root/.worktrees/unidesk/selfmedia-repo-github-authority` 执行全部源码、Git 和验证操作。
- `bun test scripts/src/platform-infra-gitea-config.test.ts scripts/src/platform-infra-gitea-disclosure.test.ts`11 项通过,0 项失败。
- `bun scripts/cli.ts platform-infra gitea plan --target NC01`policy 为 `ok`
- `git diff --check`、Node 语法、Python `py_compile`、Shell `sh -n`:通过。
- `mirror bootstrap --target NC01 --repo selfmedia-nc01 --dry-run --full``OK=true``blockers=[]`;全局与 repository override 均只显示脱敏 presence/fingerprint,不显示 token。
- `mirror webhook status --target NC01 --repo selfmedia-nc01 --full`:可读取私有仓库 HEAD `6e6a95297c3c153ab269410a079a997adc345774`;当前 `hookReady=false``driftTypes=[desired-url-missing]`、有界错误为 `github-hook-missing`
- 三个 missing-credential 回归改用 synthetic blocker 输入,不再 spawn 宿主 CLI、不读取 sourceRef、不接触或打印 tokencompact/full 合同仍覆盖。
- `mirror bootstrap --target JD01 --dry-run``OK=true`,证明 JD01 不依赖 NC01 override。
- 专用凭据只读状态:`sourceRef=pikainc-selfmedia-gh-token.txt``presence=true``mode=600``fingerprint=sha256:5eed1c04a44ac5e4``valuesPrinted=false`
- 受控 status 已验证私有 HEAD 与 hooks list 可读,但未创建 hook;不得把可读性推断为超出 YAML 声明的额外权限,也不得披露 token 值。
## 并行保护
- 已两次按 `stash -u``fetch``rebase origin/master``stash apply` 安全吸收主线。
- 最终吸收 `origin/master@3aaa2616`
- R1.2 保持 `[completed]``R1.2_Task_Report.md` 保留。
## 本轮 bootstrap Secret 物化修复
- PR #1926 合并后的只读运行面证据显示,candidate Pod 因 `devops-infra/gitea-github-sync-secrets` 缺少 `github-token-pikainc-selfmedia` 而失败;live Secret 脱敏 key 列表只有全局 `github-token`、Gitea 管理员与 webhook secret。
- 旧 Deployment/reconciler 仅引用全局 `github-token`candidate desired manifest 已引用 repository key,因此故障发生在 YAML credential 选择之后、Secret 物化之前。
- 根因确认:`run_mirror_bootstrap()` 读取 repository token 并创建 Gitea repo,但从未执行 `run_apply()` 内的 webhook Secret upsert。此前“bootstrap 已写入 key”的现场假设不成立。
- 修复将 Secret upsert 抽为 `upsert_webhook_sync_secret()`,由 `run_apply()``run_mirror_bootstrap()` 复用。bootstrap 在任何 Gitea admin/repository mutation 前物化完整 Target/owner YAML credential key 集合;失败时显式 `return 1``apiBootstrap.skipped=true``valuesPrinted=false`
- bootstrap 的 Secret key map 由本次实际加载的 `githubTokens` keys 生成,selfmedia override 不回落或合并到全局 token,也不会删除同 Target/owner 的其他 YAML repository keys。Secret 值未进入 GitOps、Git、日志或报告。
- 行为测试使用 fixture token 模拟全部 key 与 Secret apply 失败,不读取宿主 sourceRef;断言两个 GitHub key 均物化、失败时不发生后续 Gitea mutation。
- 主代理按 `bun.lock` 执行 `bun install --frozen-lockfile`,未修改 package/lock`sh -n``git diff --check` 与 payload/config/PaC bootstrap 三个测试文件共 14/14 通过。
- 受控 webhook status 已可读取 `pikainc/selfmedia` master HEAD `6e6a95297c3c153ab269410a079a997adc345774`,先前 `github-api-get-404` 已消失;生产 Hook 尚未创建,R1.1 继续保持 `in_progress`
## 未执行事项
- 未创建假 token,未复制 SSH 私钥,未修改仓库可见性。
- 未手工 apply、Secret sync、PipelineRun、Argo sync/refresh、rollout 或 cutover。
- 未触发 PaC 或任何部署,只保留只读运行面证据。
## Git 与 PR 交付
- 源码提交:`89b618de`
- bounded status 修复:`21aed20f`
- compact blocker renderer 修复:`a539a4ff`
- 中文 PR`pikasTech/unidesk#1926`
- 未自行合并 PR。
## 当前结论
- R1.1 配置、schema、renderer、Secret、bridge、observer 与受控 fetch 的源码能力已落地并通过轻量验证。
- repository-scoped credential material 已到位且 mode 为 `0600`;当前 GitHub hook 尚未创建,R1.1 保持 `in_progress`
- 下一步仅允许在授权的自动控制面中观察 hook reconciler 与自动交付收敛;本次未执行 hook 创建、Secret sync、apply、bootstrap 或任何部署。
@@ -0,0 +1,34 @@
# R1.2 任务报告
## 结论
NC01 / `nc01-v02` AgentRun manager 受控代理可见性已通过原入口自行恢复;本次按瞬态故障收口,没有修改 YAML、源码、Secret、Deployment、PipelineRun、Argo 或运行面状态。
`agentrun-proxy-exec-failed` 的现有实现语义是 lane `kubectl exec` 代理进程非零退出,发生在 manager HTTP 响应被解析前;它不同于 `auth-failed``agentrun-manager-fetch-failed``agentrun-timeout`。恢复后的同路径查询持续成功,未发现持久配置或服务故障。
## 配置与授权核对
- owning YAML`config/agentrun.yaml`
- 外层调用:`client.transport=target-trans`Target `NC01`,重入工作区 `/root/unidesk`
- lane`nc01-v02`,内部代理模式 `lane-k8s-service-proxy`manager Service URL `http://agentrun-mgr.agentrun-v02.svc.cluster.local:8080`
- 顶层 `manager.baseUrl=https://agentrun.74-48-78-17.nip.io/` 只属于 direct HTTP 诊断;本次带 Target 的资源查询未走该地址。
- host 授权文件 `/root/.unidesk/.env/agentrun.env``present=false``fingerprint=null``valuesPrinted=false`;lane 查询不依赖该文件,而是从 manager Pod 环境读取 API key。恢复后的鉴权查询成功,未输出凭据值。
## 原入口恢复证据
2026-07-13 10:12-10:16 UTC 期间重复执行以下只读入口,均返回退出码 0:
- `agentrun events run/run_9d2eb6e33cfb4656bb4a9ce450da28e2 --target NC01 --lane nc01-v02`:事件可读,并从 seq 21 持续推进到 seq 181 以后。
- `agentrun logs session/sess_artificer_c1f57337c99e7836cea26a5c --target NC01 --lane nc01-v02`:日志可读,并持续推进到 seq 213。
- `agentrun get tasks``agentrun describe task/qt_cdc6fa2b3f964b91bd12934f51b8c1e7`:目标任务为 `running`run、command、runner 与 session identity 完整。
- 机器输出明确显示 `TargetExecution.transport=target-trans``route=NC01:/root/unidesk``lane=nc01-v02``mutation=false``valuesPrinted=false`
- `agentrun control-plane status --node NC01 --lane nc01-v02`manager source `7585787e5e1f`Deployment `1/1 ready`Service 存在;最新 PaC PipelineRun 成功,Argo `Synced/Healthy`
控制面同时报告既有 `lane-secret-missing` 总体告警;它没有阻断本次 manager 资源查询,也不属于 R1.2 瞬态代理恢复范围,因此未扩大处理。
## 变更与回退
- 运行面变更:无。
- 配置/源码变更:无。
- 唯一交付:本报告与 R1.2 状态收口。
- 回退:不适用。
@@ -0,0 +1,69 @@
# R1 任务报告
## 任务回链
- MDTODO`docs/MDTODO/selfmedia-production-delivery.md` R1。
- Target`NC01`
- targetWorkspace`/root/.worktrees/unidesk/selfmedia-production-delivery`
- repository`pikasTech/unidesk`
- ref`feat/selfmedia-production-delivery`
- 状态:保持 `in_progress`,尚未取得修复合并后的真实自动链证据。
## 最小根因
- `platform-infra gitea mirror webhook status` 的 GitHub hooks 观察器在未校验 HTTP 结果和 JSON 形状时直接迭代响应。
- 私有仓库 `pikainc/selfmedia` 对 YAML 声明的 `gh_token.txt` authority 返回 HTTP `404`;错误对象被按 hook list 迭代,最终产生 `AttributeError: 'str' object has no attribute 'get'`,并把权限故障伪装成空 hook。
- owning YAML 已包含 `selfmedia-nc01`、GitHub→Gitea hook topology、Gitea repository、PaC consumer、GitOps/Argo、双 PVC、Secret、NetworkPolicy 和公网 `152.53.229.148:4317` 契约;不需要新增 Secret 或手工 apply。
- 自动链当前真正阻塞 authority:`config/platform-infra/gitea.yaml` 声明的 GitHub bridge credential `gh_token.txt` 无法读取或管理私有仓库 `pikainc/selfmedia` 的 hooks,也无法读取仓库 head。Git SSH push 可用不能替代 GitHub API hook authority。
## 源码修复
- `scripts/src/platform_infra_gitea_status_evaluator.py`
- 新增 GitHub hooks HTTP/JSON/list/item 的 fail-closed 解析。
- 错误仅输出 `github-hooks-http-<status>``github-hooks-response-not-list``github-hooks-item-not-object` 等有界 code,不回显 GitHub 错误正文或凭据。
- 统一支持 hook `config` 为对象 URL 或字符串 URL 的公开 API 形状。
- `scripts/src/platform-infra-gitea-remote.sh`
- embedded observer 复用 evaluator,不再直接迭代未校验响应。
- HTTP 失败、错误对象和畸形 list 不再伪装成空 hook。
- `scripts/src/platform-infra-gitea-status-evaluator.test.ts`
- 覆盖 HTTP 错误对象、非 list、含非对象元素、对象 config、字符串 config 和畸形 hook。
## Target 原入口验证
- `sh -n scripts/src/platform-infra-gitea-remote.sh`:通过。
- `python3 -m py_compile scripts/src/platform_infra_gitea_status_evaluator.py`:通过。
- `git diff --check`:通过。
- `bun test scripts/src/platform-infra-gitea-status-evaluator.test.ts``14 pass / 0 fail / 86 expect()`
- `platform-infra gitea mirror webhook status --target NC01 --repo selfmedia-nc01 --full`
- bridge `ready=true``1/1`
- durable inbox `ready=true`,无 pending/failed
- selfmedia `hookReady=false``authorityMatches=false`
- topology drift 为 `desired-url-missing`
- 有界错误为 `github-hooks-http-404`
- 不再出现 Python traceback,不打印 token 或 GitHub 错误正文。
- `platform-infra pipelines-as-code status --target NC01 --consumer selfmedia-nc01`
- consumer bootstrap readyrunner automount disabledArgo repository Secret keys ready
- Repository hook `0`Latest PipelineRun 为空;
- diagnosis 仍为 `pac-source-unknown`、registry missing、Argo sync Unknown。
- 公网补充证据:`152.53.229.148:4317` 健康但 WebTerm 显示“后端尚未声明内置终端能力”,`/api/v1/system` 无 terminal configRef;宿主 PID 自 01:44 运行。该入口是旧宿主进程证据,不是 K8s 自动发布证明,任务未执行重启或 cutover。
- Secret 补充证据:`secrets status --scope selfmedia --target selfmedia-nc01` 显示 runtime TOKEN 与 codex `auth.json` / `config.toml` 均存在且 keys exact、`valuesPrinted=false`;任务未新增或同步 Secret。
## Primary Workspace 边界
- primary workspace 仅用于读取 `$dad-dev``$unidesk-trans``$unidesk-cicd``$unidesk-ymalops``$cli-spec``$git-spec``$docs-spec``$unidesk-gh`
- 所有源码读取、编辑、测试、Git 和运行面只读观察均通过 `trans NC01:/root/.worktrees/unidesk/selfmedia-production-delivery ...` 完成。
- 未使用 runner 本地结果替代 Target 原入口证据,未递归派单。
## 保留与删除的特例
- `keep-domain-special`selfmedia 双 PVC、零 runtime RBAC、`automountServiceAccountToken=false`、非特权 runtime、YAML-first Secret、NetworkPolicy、公网 IP 与 GitOps branch 契约保持不变。
- `remove-code-default`:无新增代码默认值;修复只校验实际 GitHub API 形状。
- `legacy-retire`:不恢复 branch-follower、人工 mirror sync、人工 PipelineRun、Argo refresh/sync、rollout 或 cutover。
## 合并后自动验收
1. 由凭据 authority 管理者为 owning YAML 的 `gh_token.txt` 授予 `pikainc/selfmedia` 私有仓库读取与 webhook 管理权限;不得复制 root SSH 私钥或改变仓库可见性。
2. 合并本修复 PR 后,仅观察 `platform-infra-gitea-nc01` 自动 PipelineRun 与 Argo 收敛;不得 apply、sync、refresh 或手工 reconciler。
3. 待 hook reconciler 自动运行后,只读执行 `platform-infra gitea mirror webhook status --target NC01 --repo selfmedia-nc01`,应看到 hook ready、真实 delivery accepted→committed、GitHub head 与 Gitea branch/snapshot 对齐。
4. 合并一条 `pikainc/selfmedia/master` 正常 PR,观察自动产生唯一外层 selfmedia PipelineRun、镜像 digest、GitOps release state、Argo revision、runtime commitId 与 `/healthz` ready。
5.`platform-infra pipelines-as-code status|history --target NC01 --consumer selfmedia-nc01` 验证阶段耗时、失败归因和下一步;未取得上述真实自动事件前 R1 保持 `in_progress`
@@ -0,0 +1,20 @@
# R1.4.1 任务报告
## 结论
- [UniDesk #1919](https://github.com/pikasTech/unidesk/issues/1919) 的约 10 分钟业务 Session 已定位为 JD01 遗留 quick-verify CronJob,不是 NC01 小时级哨兵或 10 分钟 heartbeat。
- 用户最新确认退役可以是临时操作,不要求 YAML-first 永久化;因此不合并删除 JD01 source profile、PaC consumer 或 `.tekton` 的 PR。
## 临时退役结果
- Argo Application `hwlab-web-probe-sentinel-jd01``NotFound`
- PaC Repository `sentinel-jd01-v03``NotFound`
- `hwlab-v03` 中按 `unidesk.ai/web-probe-sentinel-id=jd01-web-probe-sentinel` 查询 CronJob、Job、Deployment、Pod、Service、ConfigMap、ServiceAccount、Role、RoleBinding、NetworkPolicy 和 PVC,结果为空。
-`*/10` 调度最后一次 Job 保持在 `2026-07-13T08:25:09Z`;紧急停止后跨过后续调度窗口未出现新 Job。
- NC01 小时级 Web 哨兵保持不变。
## 交付边界
- [PR #1922](https://github.com/pikasTech/unidesk/pull/1922) 是此前永久 YAML-first 退役方案;因用户最新范围改为临时退役,不合并该 PR。
- 源码中的 JD01 配置保留,未来如需恢复可重新走受控部署;本次完成标准仅为当前运行面全部 absent。
- D518 遗留和其他 Monitor 问题不在本任务范围。
+5 -5
View File
@@ -3,22 +3,22 @@
本文件跟踪 MDTODO CLI 的结构安全、并发写入、输入合同、渐进披露、任务恢复和跨平台可用性。单次工具故障及其修复作为 ITEM 进入本长期问题域。
## R1 [in_progress]
## R1 [completed]
依据 [agent_skills #7](https://github.com/pikasTech/agent_skills/issues/7) 修复 MDTODO 标题多行污染并提供安全 stdin 输入,在保持任务 ID、报告、文件锁和层级合同下支持原 ID 恢复,完成任务后将详细报告写入[任务报告](./details/mdtodo-tooling-reliability/R1_Task_Report.md)。
### R1.1 [in_progress]
### R1.1 [completed]
复现 positional title 含 CR/LF/NUL 与 shell command substitution stdout 的污染形态,定义 validation-failed、mutation=false、stdin 标题和原 ID 恢复合同,引用 [agent_skills #7](https://github.com/pikasTech/agent_skills/issues/7),完成任务后将详细报告写入[任务报告](./details/mdtodo-tooling-reliability/R1.1_Task_Report.md)。
### R1.2
### R1.2 [completed]
实现 add/add-sub/batch/update 的单行校验、安全 stdin 输入与结构化 update 正规化,精确保留 children、相邻任务、报告路径和文件锁;补单元与回归测试,完成任务后将详细报告写入[任务报告](./details/mdtodo-tooling-reliability/R1.2_Task_Report.md)。
### R1.3
### R1.3 [completed]
在独立 worktree 提交 PR,完成 Python/skill 校验和临时 fixture 前向验收;合并后更新本机受控 skill 安装并用本 MDTODO 的真实 Markdown 标题复测,完成任务后将详细报告写入[任务报告](./details/mdtodo-tooling-reliability/R1.3_Task_Report.md)。
## R2 [in_progress]
## R2 [completed]
解决 UniDesk #1913https://github.com/pikasTech/unidesk/issues/1913):复用 agent_skills #7 与 PR #8 的 MDTODO 安全 stdin 实现,让主代理、Artificer resource bundle 和受控 host skill 投影默认使用安全标题输入,并有界披露 source/target freshness、warning 与精确修复入口;不得复制第二套 MDTODO 工具、增加版本门禁或覆盖宿主并行脏改,由 Artificer 在独立 worktree 和分支实现、测试并提交 PR,不自行合并,完成任务后将详细报告写入[任务报告](./details/mdtodo-tooling-reliability/R2_Task_Report.md)。
@@ -0,0 +1,13 @@
## R1 [in_progress]
打通 selfmedia WebTerm 在 NC01 的纯自动生产交付:以 config/selfmedia.yaml 与 platform-infra owning YAML 为唯一真相,修复 GitHub→Gitea mirror→PaC/Tekton→GitOps/Argo 的自动触发与可见性,使合并 pikainc/selfmedia/master 后无需人工触发即可构建并发布;保留零 RBAC runtime、双 PVC、YAML-first Secret、NetworkPolicy、公网 IP 152.53.229.148:4317,禁止用手工 PipelineRun、Argo sync/refresh、rollout 或 cutover 代替自动链路,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R1_Task_Report.md)。
### R1.1 [in_progress]
以 YAML-first 为 selfmedia-nc01 增加仓库级 GitHub authority:使用独立 sourceRef 提供 pikainc/selfmedia 私有仓库 Contents Read 与 Webhooks Read/Write 权限,校验权限/Secret presence 与脱敏 fingerprint;不扩大全局 gh_token.txt、不向 Codex 下发、不手工触发 PaC,待凭据到位后仅观察 hook reconciler 与自动交付收敛,并将详细报告写入任务报告,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R1.1_Task_Report.md)。
### R1.2 [completed]
有界恢复 NC01 AgentRun manager 受控代理可见性:定位连续 agentrun-proxy-exec-failed,只允许检查并恢复既有 manager.baseUrl、lane service proxy 与授权 presence,不打印凭据,不修改 Artificer 主线源码,不新增安全围栏;恢复后用原始 agentrun events/logs 入口验证并写任务报告,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R1.2_Task_Report.md)。
@@ -24,9 +24,10 @@ PR 合并后只等待自动发布链,使用受控 Web sentinel 原入口产生
核对 [#1861](https://github.com/pikasTech/unidesk/issues/1861) 的小时级实际探测 cadence 与 10 分钟 scheduler heartbeat/扫描间隔;若只读报告语义误导则纠正字段和文档,若 owning YAML 未生效则修正渲染,验收 CronJob 与报告明确区分二者,完成任务后将详细报告写入[任务报告](./details/web-sentinel-runtime-reliability/R1.4_Task_Report.md)。
#### R1.4.1 [in_progress]
#### R1.4.1 [completed]
完成 [UniDesk #1919](https://github.com/pikasTech/unidesk/issues/1919) 的 JD01 Web 哨兵临时退役:确认约 10 分钟 Session 来自遗留 quick-verify CronJob,停止并移除 live Argo Application、PaC Repository 及带 JD01 sentinel 标签的运行资源;不要求 YAML-first 永久退役,源码配置保留以便恢复;NC01 小时级巡检保持不变,D518 不在本任务范围,完成任务后将详细报告写入[任务报告](./details/web-sentinel-runtime-reliability/R1.4.1_Task_Report.md)。
解决 [UniDesk #1919](https://github.com/pikasTech/unidesk/issues/1919):已确认约 10 分钟 Session 来自 JD01 遗留 `*/10` quick-verify CronJob,按用户最新要求整个停掉 JD01 哨兵;紧急停止后通过 Git-backed owning YAML 与既有自动 lifecycle 永久退役 JD01 sentinel consumer、Argo desired state、CronJob、sentinel/frpc runtime,不恢复 JD01 target、不把 10m 改成 1h;保留 NC01 唯一小时级巡检,自动交付后覆盖完整小时窗口验收,D518 若需处理另行登记,完成任务后将详细报告写入[任务报告](./details/web-sentinel-runtime-reliability/R1.4.1_Task_Report.md)。
## R2 [in_progress]
推进 [UniDesk #1868](https://github.com/pikasTech/unidesk/issues/1868):按当前多 runner、全局查询与 Dashboard 复杂度重构 Monitor 平台,评估并实施分散 SQLite 到 NC01 YAML-first Host PostgreSQL 的统一迁移;P0 先冻结架构、数据模型和切换边界,再实现、自动发布和原入口验收,不以迁移掩盖 [#1861](https://github.com/pikasTech/unidesk/issues/1861),不保留永久双写或第二权威,完成任务后将详细报告写入[任务报告](./details/web-sentinel-runtime-reliability/R2_Task_Report.md)。
+21 -2
View File
@@ -567,6 +567,11 @@ function extractPacArtifactEvidence(recordsInput, logText) {
function evaluatePacStatus(inputValue) {
const input = record(inputValue);
const registryApplicability = input.registryApplicability === "not-configured"
? "not-configured"
: input.registryApplicability === "configured" || stringOrNull(input.imageRepository) !== null
? "configured"
: "not-configured";
const artifact = record(input.artifact);
const argo = record(input.argo);
const runtime = record(input.runtime);
@@ -704,7 +709,7 @@ function evaluatePacStatus(inputValue) {
code = "pac-artifact-catalog-digest-missing";
phase = "artifact-catalog-digest-missing";
hint = "the HWLAB artifact catalog did not expose any sha256 digest";
} else if (stringOrNull(input.imageRepository) !== null && !registryEvidenceReady) {
} else if (registryApplicability === "configured" && stringOrNull(input.imageRepository) !== null && !registryEvidenceReady) {
ok = false;
code = "pac-registry-missing";
phase = "source-ready-registry-missing";
@@ -779,7 +784,9 @@ function evaluatePacStatus(inputValue) {
registryProbeBase: stringOrNull(input.registryProbeBase),
imageTag: stringOrNull(input.imageTag),
registry: {
present: registryEvidenceReady,
applicability: registryApplicability,
status: registryApplicability === "configured" ? (registryEvidenceReady ? "present" : "missing") : "not-configured",
present: registryApplicability === "configured" ? registryEvidenceReady : null,
probePresent: input.registryPresent === true,
catalogVerified: catalogDeliveryEvidence,
digest: registryDigest,
@@ -915,6 +922,18 @@ function runPacStatusFixtureChecks() {
expectedCode: "pac-ready-gitops-exact",
input: fixtureInput({ artifact: { imageStatus: "built", digest: digestA, digests: [digestA], gitopsCommit: revision, sourceObservation: delivery, catalog: publishedCatalog() }, argo: exactArgo, runtime: { image: `registry/service@${digestA}`, digest: digestA, replicas: 1, readyReplicas: 1 } }),
},
{
id: "gitops-only-registry-not-configured",
expectedOk: true,
expectedCode: "pac-ready-gitops-exact",
input: fixtureInput({ registryApplicability: "not-configured", imageRepository: "registry/shared-repository-image", artifact: { gitopsCommit: revision }, argo: exactArgo }),
},
{
id: "image-registry-missing-fails-closed",
expectedOk: false,
expectedCode: "pac-registry-missing",
input: fixtureInput({ registryApplicability: "configured", imageRepository: "registry/service", artifact: { imageStatus: "built", digest: digestA, gitopsCommit: revision }, argo: exactArgo }),
},
{
id: "delivery-ready-descendant",
expectedOk: true,
+66
View File
@@ -0,0 +1,66 @@
import { expect, test } from "bun:test";
import { renderNodeStatus } from "./cicd-node-status";
const next = {
status: "bun scripts/cli.ts cicd status --node NC01",
history: "bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01",
fixAutomaticDelivery: { reference: ".agents/skills/unidesk-cicd/SKILL.md" },
};
function result(status: "ready" | "warning" | "blocked", consumers: Array<Record<string, unknown>>): Record<string, unknown> {
return {
ok: status === "ready",
node: "NC01",
target: { id: "NC01" },
summary: {
ready: status === "ready",
status,
code: status === "ready" ? "cicd-node-ready" : status === "warning" ? "cicd-node-no-consumers" : "cicd-node-blocked",
total: consumers.length,
readyCount: consumers.filter((consumer) => consumer.ready === true).length,
blockedCount: consumers.filter((consumer) => consumer.ready !== true).length,
elapsedMs: 12,
},
consumers,
next,
};
}
const readyConsumer = {
consumer: "platform-infra-gitea-nc01",
ready: true,
pipelineStatus: "Succeeded",
durationSeconds: 9,
sourceCommit: "a".repeat(40),
gitopsCommit: "b".repeat(40),
argo: { sync: "Synced", health: "Healthy", revisionRelation: { relation: "exact" } },
runtime: { readyReplicas: 1, replicas: 1, digest: `sha256:${"c".repeat(64)}` },
reason: "ready",
latestPipelineRun: "platform-infra-gitea-nc01-fixture",
digest: `sha256:${"c".repeat(64)}`,
imageStatus: "-",
diagnostics: { hint: "GitOps-only consumer is ready", registry: { applicability: "not-configured", status: "not-configured", present: null } },
};
test("node status renderer is non-empty for zero consumers, ready, warning, and blocked projections", () => {
const fixtures = [
result("warning", []),
result("ready", [readyConsumer]),
result("warning", [{ ...readyConsumer, ready: false, reason: "diagnostics-warning" }]),
result("blocked", [{ ...readyConsumer, ready: false, reason: "pac-registry-missing" }]),
];
for (const fixture of fixtures) {
const rendered = renderNodeStatus(fixture, true).renderedText;
expect(rendered.trim().length).toBeGreaterThan(0);
expect(rendered).toContain("CI/CD NODE STATUS");
expect(rendered).toContain("NEXT");
}
});
test("node full renderer shows registry N/A without hiding digest evidence", () => {
const rendered = renderNodeStatus(result("ready", [readyConsumer]), true).renderedText;
expect(rendered).toContain("N/A");
expect(rendered).toContain("sha256:");
expect(rendered).toContain("platform-infra-gitea-nc01-fixture");
});
+11 -3
View File
@@ -76,7 +76,7 @@ function parseNodeStatusOptions(args: string[]): NodeStatusOptions {
return { nodeId, targetId, full, raw, output };
}
function renderNodeStatus(result: Record<string, unknown>, full: boolean): RenderedCliResult {
export function renderNodeStatus(result: Record<string, unknown>, full: boolean): RenderedCliResult {
const target = record(result.target);
const summary = record(result.summary);
const consumers = arrayRecords(result.consumers);
@@ -100,13 +100,16 @@ function renderNodeStatus(result: Record<string, unknown>, full: boolean): Rende
stringValue(row.latestPipelineRun),
short(stringValue(row.digest), 18),
stringValue(row.imageStatus),
registryText(record(record(row.diagnostics).registry)),
compactLine(stringValue(record(row.diagnostics).hint)),
]);
const lines = [
"CI/CD NODE STATUS",
...table(["NODE", "TARGET", "READY", "READY_COUNT", "BLOCKED", "ELAPSED_MS"], [[
...table(["NODE", "TARGET", "STATUS", "CODE", "READY", "READY_COUNT", "BLOCKED", "ELAPSED_MS"], [[
stringValue(result.node),
stringValue(target.id),
stringValue(summary.status),
stringValue(summary.code),
boolText(summary.ready),
`${stringValue(summary.readyCount)}/${stringValue(summary.total)}`,
stringValue(summary.blockedCount),
@@ -118,7 +121,7 @@ function renderNodeStatus(result: Record<string, unknown>, full: boolean): Rende
"",
...(full ? [
"DETAIL",
...(detailRows.length === 0 ? ["-"] : table(["CONSUMER", "PIPELINERUN", "DIGEST", "IMAGE_STATUS", "HINT"], detailRows)),
...(detailRows.length === 0 ? ["-"] : table(["CONSUMER", "PIPELINERUN", "DIGEST", "IMAGE_STATUS", "REGISTRY", "HINT"], detailRows)),
"",
] : []),
"NEXT",
@@ -129,6 +132,11 @@ function renderNodeStatus(result: Record<string, unknown>, full: boolean): Rende
return { ok: result.ok !== false, command: "cicd status", renderedText: lines.join("\n"), contentType: "text/plain" };
}
function registryText(registry: Record<string, unknown>): string {
const status = stringValue(registry.status);
return status === "not-configured" ? "N/A" : status;
}
function isHelpToken(value: string | undefined): boolean {
return value === "help" || value === "--help" || value === "-h";
}
+5
View File
@@ -6091,6 +6091,9 @@ function compactSkillsStatus(value: unknown): Record<string, unknown> | null {
readonly: record.readonly ?? false,
skillCount: record.skillCount ?? 0,
version: record.version ?? null,
capabilities: record.capabilities ?? null,
warnings: Array.isArray(record.warnings) ? record.warnings.map(String) : [],
next: record.next ?? null,
sourceSkillCount: record.sourceSkillCount ?? null,
targetSkillCount: record.targetSkillCount ?? null,
sourceMissingSkills: Array.isArray(record.sourceMissingSkills) ? record.sourceMissingSkills.map(String) : [],
@@ -6151,6 +6154,8 @@ function compactSkillsSyncStatus(value: unknown, full = false): Record<string, u
expected: record.expected ?? null,
counts: record.counts ?? null,
version: record.version ?? null,
capabilities: record.capabilities ?? null,
warnings: Array.isArray(record.warnings) ? record.warnings.map(String) : [],
missing: record.missing ?? null,
permissionFailures: Array.isArray(record.permissionFailures) ? record.permissionFailures.slice(0, full ? undefined : 4) : [],
permissionFailureCount: Array.isArray(record.permissionFailures) ? record.permissionFailures.length : 0,
@@ -410,8 +410,12 @@ function projectManagementPaneGapRows(projectSamples) {
const maxGapPx = maxNumber(severeGaps.map((item) => item.bottomGapPx));
const maxGapRatio = maxNumber(severeGaps.map((item) => item.bottomGapRatio));
const multiPane = severeGaps.length >= 2;
const singleExtreme = maxGapPx >= 240 && maxGapRatio >= 0.45;
if (!multiPane && !singleExtreme) continue;
const singlePrimaryPaneExtreme = severeGaps.some((item) =>
item.name !== "task-tree"
&& Number(item.bottomGapPx ?? 0) >= 240
&& Number(item.bottomGapRatio ?? 0) >= 0.45
);
if (!multiPane && !singlePrimaryPaneExtreme) continue;
const selectedTaskRefHash = sample?.projectManagement?.selectedTaskRef?.hash ?? null;
const isMdtodo = sample?.projectManagement?.pageKind === "project-management-mdtodo";
const isInitialEmptyDetail = isMdtodo && !selectedTaskRefHash;
+15 -2
View File
@@ -165,14 +165,15 @@ function renderWebObserveStatusTable(value: Record<string, unknown>): string {
] : []),
...(Object.keys(exactCommand).length > 0 ? [
"Exact command:",
webObserveTable(["COMMAND", "TYPE", "PHASE", "OK", "STATUS", "REPORT_SHA", "DETAIL"], [[
webObserveTable(["COMMAND", "TYPE", "PHASE", "OK", "STATUS", "PROFILE_SHA", "REPORT_SHA", "DETAIL"], [[
webObserveShort(webObserveText(exactCommand.commandId), 40),
exactCommand.type,
exactCommand.phase,
exactCommand.ok,
exactResult?.status,
webObserveShort(webObserveText(exactResult?.profileSha256 ?? exactErrorDetails?.profileSha256), 32),
webObserveShort(webObserveText(exactResult?.reportSha256 ?? exactErrorDetails?.reportSha256), 32),
webObserveShort(webObserveText(exactError.message ?? exactResult?.reportPath ?? exactErrorDetails?.reportPath), 96),
webObserveShort(webObserveText(exactError.message ?? exactResult?.reportPath ?? exactErrorDetails?.reportPath ?? exactResult?.profilePath ?? exactErrorDetails?.profilePath), 96),
]]),
"",
] : []),
@@ -436,6 +437,7 @@ function renderWebObserveCommandTable(value: Record<string, unknown>): string {
const details = nullableRecord(error?.details);
const replayEvidence = result ?? details;
const replayScreenshot = record(result?.screenshot ?? details?.screenshot);
const performanceCapture = observerCommand?.type === "performanceCapture" ? result ?? details : null;
const rawReadiness = record(error?.navigationReadiness) ?? record(details?.readiness) ?? record(details?.readinessAfterWait) ?? record(details?.readinessBeforeClick);
const readiness = record(rawReadiness?.snapshot) ?? rawReadiness;
const id = webObserveText(value.id);
@@ -454,6 +456,17 @@ function renderWebObserveCommandTable(value: Record<string, unknown>): string {
webObserveShort(webObserveText(asyncTurn?.traceId), 24),
webObserveShort(webObserveText(result?.mark ?? result?.currentUrl ?? observer?.error ?? observer?.queued), 80),
]]),
...(performanceCapture !== null ? [
"",
"Performance capture:",
webObserveTable(["CAPTURE", "DURATION_MS", "PROFILE_SHA", "REPORT_SHA", "COLLECT"], [[
performanceCapture.captureId,
performanceCapture.durationMs,
webObserveShort(webObserveText(performanceCapture.profileSha256), 32),
webObserveShort(webObserveText(performanceCapture.reportSha256), 32),
`observe collect ${id} --file ${webObserveText(performanceCapture.reportPath)}`,
]]),
] : []),
...(observerCommand?.type === "validateWorkbenchKafkaDebugReplay" && replayEvidence !== null ? [
"",
...renderWorkbenchKafkaDebugReplayEvidence(
@@ -1120,12 +1120,14 @@ async function waitForProjectManagementCommandReady(options = {}) {
while (Date.now() <= deadline) {
last = await projectManagementCommandSnapshot();
const path = String(last?.path || safeUrlPath(currentPageUrl()) || "");
const needsTask = /\/tasks\//u.test(path);
const selectedTaskReady = Boolean(last?.selectedTaskId || last?.selectedTaskRef?.hash || last?.taskBodyVisible === true || last?.launchButtonVisible === true);
const taskCollectionReady = Number(last?.taskCount || 0) > 0;
const baseReady = last?.pageKind === "project-management-mdtodo"
&& Number(last?.sourceCount || 0) > 0
&& Number(last?.fileCount || 0) > 0
&& Number(last?.taskCount || 0) > 0;
const needsTask = /\/tasks\//u.test(path);
const taskReady = !needsTask || Boolean(last?.selectedTaskId || last?.selectedTaskRef?.hash || last?.taskBodyVisible === true || last?.launchButtonVisible === true);
&& (needsTask ? selectedTaskReady : taskCollectionReady);
const taskReady = !needsTask || selectedTaskReady;
const needsReport = /\/reports\//u.test(path);
const reportReady = !needsReport || last?.reportPreviewVisible === true || last?.reportFullscreenVisible === true;
if (baseReady && taskReady && reportReady) return { ok: true, reason: "project-management-command-ready", durationMs: Date.now() - started, snapshot: last, valuesRedacted: true };
@@ -5,12 +5,36 @@ import {
MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR,
nodeWebObserveRunnerSamplingSource,
} from "./hwlab-node-web-observe-runner-sampling-source";
import { nodeWebObserveAnalyzerProjectSource } from "./hwlab-node-web-observe-analyzer-project-source";
import { nodeWebObserveRunnerControlSource } from "./hwlab-node-web-observe-runner-control-source";
test("MDTODO task tree pane gap treats the pinned facts footer as content", async () => {
assert.match(MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR, /\[data-testid="mdtodo-task-tree-facts"\]/u);
assert.match(
MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR,
/\[data-testid="mdtodo-task-tree-facts"\]/u,
);
assert.match(
MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR,
/\[data-testid="mdtodo-task-page-fact"\]/u,
);
const source = nodeWebObserveRunnerSamplingSource();
assert.ok(source.includes(`measurePaneGap("task-tree", '[data-testid="mdtodo-task-tree"]', ${JSON.stringify(MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR)})`));
assert.ok(
source.includes(
`measurePaneGap("task-tree", '[data-testid="mdtodo-task-tree"]', ${JSON.stringify(MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR)})`,
),
);
assert.match(
source,
/semanticTaskCandidates\.length > 0 \? semanticTaskCandidates : fallbackTaskCandidates/u,
);
assert.match(
source,
/taskCandidates\.filter\(\(candidate\) => !taskRefElement\(candidate\)\)\.length/u,
);
assert.doesNotMatch(source, /taskCandidates\.length - taskItems\.length/u);
assert.match(source, /mdtodo-body-read/u);
assert.match(source, /mdtodo-launch-workbench/u);
const child = Bun.spawn(["node", "--input-type=module", "--check", "-"], {
stdin: "pipe",
@@ -19,6 +43,91 @@ test("MDTODO task tree pane gap treats the pinned facts footer as content", asyn
});
child.stdin.write(source);
child.stdin.end();
const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited]);
const [stderr, exitCode] = await Promise.all([
new Response(child.stderr).text(),
child.exited,
]);
assert.equal(exitCode, 0, stderr);
});
test("MDTODO pane gap ignores a naturally short task tree but keeps primary pane regressions", () => {
const projectManagementPaneGapRows = new Function(
"maxNumber",
"projectManagementSampleRef",
`${nodeWebObserveAnalyzerProjectSource()}; return projectManagementPaneGapRows;`,
)(
(values: unknown[]) => Math.max(0, ...values.map((value) => Number(value ?? 0))),
(sample: Record<string, unknown>) => ({ seq: sample.seq ?? null }),
) as (samples: unknown[]) => { actionable: unknown[]; ignored: unknown[] };
const pane = (name: string, bottomGapPx: number, bottomGapRatio: number) => ({
name,
visible: true,
widthPx: 300,
heightPx: 600,
bottomGapPx,
bottomGapRatio,
contentNodeCount: 4,
});
const sample = (paneGaps: unknown[]) => ({
seq: 1,
projectManagement: {
pageKind: "project-management-mdtodo",
selectedTaskRef: { hash: "sha256:opaque" },
paneGaps,
},
});
assert.deepEqual(
projectManagementPaneGapRows([sample([pane("task-tree", 391, 0.618)])]),
{ actionable: [], ignored: [], valuesRedacted: true },
);
assert.equal(
projectManagementPaneGapRows([sample([pane("task-detail", 391, 0.618)])]).actionable.length,
1,
);
assert.equal(
projectManagementPaneGapRows([sample([
pane("task-tree", 240, 0.4),
pane("task-detail", 180, 0.3),
])]).actionable.length,
1,
);
});
test("MDTODO command readiness accepts a selected mobile task when the hidden tree has no visible rows", async () => {
const source = nodeWebObserveRunnerControlSource();
const makeReady = (snapshot: Record<string, unknown>) => new Function(
"projectManagementCommandSnapshot",
"safeUrlPath",
"currentPageUrl",
"page",
`${source}; return waitForProjectManagementCommandReady;`,
)(
async () => snapshot,
() => String(snapshot.path ?? ""),
() => `https://hwlab.pikapython.com${String(snapshot.path ?? "")}`,
{ waitForTimeout: async () => undefined },
) as (options: { timeoutMs: number }) => Promise<{ ok: boolean; reason: string }>;
const selectedMobileTask = {
path: "/projects/mdtodo/sources/source/files/file/tasks/R1",
pageKind: "project-management-mdtodo",
sourceCount: 3,
fileCount: 18,
taskCount: 0,
selectedTaskRef: { hash: "sha256:opaque" },
taskBodyVisible: true,
launchButtonVisible: true,
};
assert.equal((await makeReady(selectedMobileTask)({ timeoutMs: 1 })).ok, true);
const unselectedRoot = {
path: "/projects/mdtodo",
pageKind: "project-management-mdtodo",
sourceCount: 3,
fileCount: 18,
taskCount: 0,
};
assert.equal((await makeReady(unselectedRoot)({ timeoutMs: 1 })).ok, false);
});
@@ -3,6 +3,7 @@
export const MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR = [
'[data-testid="mdtodo-task-tree-facts"]',
'[data-testid="mdtodo-task-page-fact"]',
"[data-task-ref]",
'[role="treeitem"]',
'[role="listitem"]',
@@ -433,22 +434,32 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
selected: option.selected === true,
})) : [];
const selectedFileOption = fileOptions.find((option) => option.selected) || null;
const taskItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]')).filter(visible);
const taskCandidates = Array.from(document.querySelectorAll('[data-testid="mdtodo-task-tree"] li, [data-testid="mdtodo-task-tree"] [role="treeitem"], [data-testid="mdtodo-task-tree"] [role="listitem"]')).filter(visible);
const taskTree = document.querySelector('[data-testid="mdtodo-task-tree"]');
const semanticTaskCandidates = taskTree
? Array.from(taskTree.querySelectorAll('[role="treeitem"], [role="listitem"]')).filter(visible)
: [];
const fallbackTaskCandidates = taskTree
? Array.from(taskTree.querySelectorAll("li")).filter(visible)
: [];
const taskCandidates = semanticTaskCandidates.length > 0 ? semanticTaskCandidates : fallbackTaskCandidates;
const taskRefElement = (candidate) => candidate.matches("[data-task-ref]")
? candidate
: candidate.querySelector("[data-task-ref]");
const taskItems = Array.from(new Set(taskCandidates.map(taskRefElement).filter(visible)));
const selectedSource = document.querySelector('[data-source-id][data-selected="true"], [data-source-id][aria-selected="true"], [data-source-id].selected, [data-source-id].is-selected');
const selectedFile = document.querySelector('[data-file-ref][data-selected="true"], [data-file-ref][aria-selected="true"], [data-file-ref].selected, [data-file-ref].is-selected');
const selectedTask = document.querySelector('[data-task-ref][data-selected="true"], [data-task-ref][aria-selected="true"], [data-task-ref].selected, [data-task-ref].is-selected');
const statusCounts = {};
for (const task of taskItems) {
const status = task.getAttribute("data-task-status") || "unknown";
const status = task.getAttribute("data-task-status") || task.querySelector("[data-state]")?.getAttribute("data-state") || "unknown";
statusCounts[status] = (statusCounts[status] || 0) + 1;
}
const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]');
const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"]');
const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-launch-workbench"], [data-action="launch-workbench"]');
const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"], [data-testid="mdtodo-body-read"]');
const reportPreview = document.querySelector('[data-testid="mdtodo-report-preview"]');
const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]');
const reportLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-report-link"]')).filter(visible);
const blockers = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [role="alert"]')).filter(visible).slice(0, 12).map((element, index) => ({
const blockers = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [data-testid="mdtodo-create-blocker"], .launch-blocker, [role="alert"]')).filter(visible).slice(0, 12).map((element, index) => ({
index,
testId: element.getAttribute("data-testid"),
role: element.getAttribute("role"),
@@ -475,7 +486,7 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
};
const paneGaps = [
measurePaneGap("task-tree", '[data-testid="mdtodo-task-tree"]', ${JSON.stringify(MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR)}),
measurePaneGap("task-detail", '[data-testid="mdtodo-task-detail"]', '[data-testid="mdtodo-body-rendered"] > *, [data-testid="mdtodo-report-section"], [data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-delete-task"], [data-testid="mdtodo-task-detail-error"], .mdtodo-detail-header, .task-status-stack > *, .task-document-footer'),
measurePaneGap("task-detail", '[data-testid="mdtodo-task-detail"]', '[data-testid="mdtodo-body-rendered"] > *, [data-testid="mdtodo-body-read"], [data-testid="mdtodo-report-section"], [data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-launch-workbench"], [data-testid="mdtodo-delete-task"], [data-testid="mdtodo-task-detail-error"], .mdtodo-detail-header, .document-header, .task-status-stack > *, .task-document-footer, .document-footer'),
measurePaneGap("report-sidebar", '[data-testid="mdtodo-report-sidebar"]', '[data-testid="mdtodo-report-preview"] > *, [data-testid="mdtodo-report-error"], [data-testid="mdtodo-report-fullscreen"], [data-testid="mdtodo-report-close"], .report-sidebar-header, .report-preview .markdown-body > *'),
];
return {
@@ -486,17 +497,17 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
sourceCount: Math.max(sourceItems.length, sourceOptionCount),
fileCount: Math.max(fileItems.length, fileOptionCount),
taskCount: taskItems.length,
taskRefMissingCount: Math.max(0, taskCandidates.length - taskItems.length),
taskRefMissingCount: taskCandidates.filter((candidate) => !taskRefElement(candidate)).length,
selectedSourceId: opaqueDomId(selectedSource?.getAttribute("data-source-id") || sourceSelect?.value),
selectedFileRef: opaqueDomId(selectedFile?.getAttribute("data-file-ref") || fileSelect?.value),
selectedFileLabel: selectedFile ? trim(selectedFile.textContent || "", 180) : selectedFileOption?.label || null,
fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 24),
selectedTaskRef: opaqueDomId(selectedTask?.getAttribute("data-task-ref")),
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null,
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || selectedTask?.querySelector("[data-state]")?.getAttribute("data-state") || null,
sourceSelectVisible: visible(sourceSelect),
fileSelectVisible: visible(fileSelect),
sourceConfigVisible: visible(document.querySelector('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]')),
taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"]')),
taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"], [data-testid="mdtodo-body-editor"]')),
taskBodyVisible: visible(bodyRendered),
taskBodyText: visible(bodyRendered) ? trim(bodyRendered.textContent || "", 500) : "",
newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')),
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import { nodeWebObserveRunnerWorkbenchSource } from "./hwlab-node-web-observe-runner-workbench-source";
test("MDTODO observe commands recognize the rewritten workspace contracts", async () => {
const source = nodeWebObserveRunnerWorkbenchSource();
for (const testId of [
"mdtodo-body-read",
"mdtodo-body-editor",
"mdtodo-body-save",
"mdtodo-launch-workbench",
]) {
assert.match(source, new RegExp(testId, "u"));
}
for (const legacyTestId of [
"mdtodo-body-rendered",
"mdtodo-edit-body",
"mdtodo-edit-body-save",
"mdtodo-workbench-launch",
]) {
assert.match(source, new RegExp(legacyTestId, "u"));
}
const child = Bun.spawn(["node", "--input-type=module", "--check", "-"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
child.stdin.write(source);
child.stdin.end();
const [stderr, exitCode] = await Promise.all([
new Response(child.stderr).text(),
child.exited,
]);
assert.equal(exitCode, 0, stderr);
});
@@ -764,7 +764,7 @@ async function selectMdtodoTask(command) {
const clicked = await target.locator.evaluate((element) => ({
taskRef: element.getAttribute("data-task-ref") || null,
taskId: element.getAttribute("data-task-id") || element.getAttribute("data-rxx-id") || null,
status: element.getAttribute("data-task-status") || null,
status: element.getAttribute("data-task-status") || element.querySelector("[data-state]")?.getAttribute("data-state") || null,
selected: element.getAttribute("data-selected") === "true" || element.getAttribute("aria-selected") === "true"
})).catch(() => ({ taskRef: target.taskRef || null, taskId: target.taskId || null, status: null }));
if (!clicked.selected) {
@@ -895,12 +895,22 @@ async function closeMdtodoSourceConfigIfOpen(options = {}) {
return result;
}
function mdtodoTestIdCandidates(value) {
return (Array.isArray(value) ? value : [value]).filter((item) => typeof item === "string" && item.length > 0);
}
function mdtodoTestIdLocator(value) {
const candidates = mdtodoTestIdCandidates(value);
return page.locator(candidates.map((testId) => '[data-testid="' + cssEscape(testId) + '"]').join(", ")).first();
}
async function fillMdtodoField(testId, value) {
if (typeof value !== "string" || !value.trim()) return { testId, filled: false };
const locator = page.locator('[data-testid="' + cssEscape(testId) + '"]').first();
const locator = mdtodoTestIdLocator(testId);
await locator.waitFor({ state: "visible", timeout: 10000 });
await locator.fill(value);
return { testId, filled: true, value: opaqueIdSummary(value), valuesRedacted: true };
const resolvedTestId = await locator.getAttribute("data-testid").catch(() => null);
return { testId: resolvedTestId || mdtodoTestIdCandidates(testId)[0] || null, filled: true, value: opaqueIdSummary(value), valuesRedacted: true };
}
async function clickProjectButtonAndMaybeWait(testId, pathPattern) {
@@ -1003,7 +1013,7 @@ async function saveMdtodoTaskWithButton(command, type, testId, fields) {
if (field.openByDblClickTestId) inlineEditors.push(await ensureMdtodoInlineEditor(field.testId, field.openByDblClickTestId));
if (field.kind === "fill") await fillMdtodoField(field.testId, field.value);
if (field.kind === "select") {
const locator = page.locator('[data-testid="' + cssEscape(field.testId) + '"]').first();
const locator = mdtodoTestIdLocator(field.testId);
await locator.waitFor({ state: "visible", timeout: 10000 });
await selectHtmlOptionByValueOrLabel(locator, field.value);
}
@@ -1023,9 +1033,9 @@ async function clickProjectButtonAndMaybeWaitAny(testIds, pathPattern) {
}
async function ensureMdtodoInlineEditor(editorTestId, readTestId) {
const editor = page.locator('[data-testid="' + cssEscape(editorTestId) + '"]').first();
const editor = mdtodoTestIdLocator(editorTestId);
if (await visibleLocator(editor)) return { editorTestId, readTestId, opened: false };
const read = page.locator('[data-testid="' + cssEscape(readTestId) + '"]').first();
const read = mdtodoTestIdLocator(readTestId);
await read.waitFor({ state: "visible", timeout: 10000 });
await read.dblclick();
await editor.waitFor({ state: "visible", timeout: 10000 });
@@ -1041,7 +1051,7 @@ async function editMdtodoTaskTitle(command) {
async function editMdtodoTaskBody(command) {
const body = commandValue(command, ["text", "body", "value"]);
if (!body) throw new Error("editMdtodoTaskBody requires --text or --text-stdin");
return saveMdtodoTaskWithButton(command, "editMdtodoTaskBody", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]);
return saveMdtodoTaskWithButton(command, "editMdtodoTaskBody", ["mdtodo-body-save", "mdtodo-edit-body-save"], [{ kind: "fill", testId: ["mdtodo-body-editor", "mdtodo-edit-body"], value: body, openByDblClickTestId: ["mdtodo-body-read", "mdtodo-body-rendered"] }]);
}
async function toggleMdtodoTaskStatus(command) {
@@ -1060,7 +1070,7 @@ async function editMdtodoTaskInline(command) {
if (field === "body" || field === "content") {
const body = commandValue(command, ["body", "text"]);
if (!body) throw new Error("editMdtodoTaskInline --field body requires --body, --text, or --text-stdin");
return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]);
return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", ["mdtodo-body-save", "mdtodo-edit-body-save"], [{ kind: "fill", testId: ["mdtodo-body-editor", "mdtodo-edit-body"], value: body, openByDblClickTestId: ["mdtodo-body-read", "mdtodo-body-rendered"] }]);
}
if (field === "status") {
const status = commandValue(command, ["status", "text"]);
@@ -1219,7 +1229,9 @@ async function deleteMdtodoTask(command) {
const selection = await selectTaskIfCommandTargetsOne(command);
const firstClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", null);
let confirmClick = null;
const confirmVisible = await visibleLocator(page.locator('[data-testid="mdtodo-delete-cancel"]').first());
const deleteButton = page.locator('[data-testid="mdtodo-delete-task"]').first();
const confirmVisible = await visibleLocator(page.locator('[data-testid="mdtodo-delete-cancel"]').first())
|| await deleteButton.evaluate((element) => /|confirm/iu.test(String(element.textContent || ""))).catch(() => false);
if (confirmVisible) confirmClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", /^\/v1\/project-management\/mdtodo\/tasks/u);
return { beforeUrl, afterUrl: currentPageUrl(), type: "deleteMdtodoTask", selection, firstClick, confirmClick, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true };
}
@@ -1250,7 +1262,7 @@ async function launchWorkbenchFromTask(command) {
: null;
const providerSelection = await selectMdtodoProviderProfileForLaunch(command);
const projectBeforeClick = await projectManagementCommandSnapshot({ includeRaw: true });
const button = page.locator('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]').first();
const button = page.locator('[data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-launch-workbench"], [data-action="launch-workbench"]').first();
await button.waitFor({ state: "visible", timeout: 15000 });
const buttonState = await button.evaluate((element) => ({
disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true",
@@ -1392,8 +1404,8 @@ async function projectManagementCommandSnapshot(options = {}) {
selected: option.selected === true
})) : [];
const selectedFileOption = fileOptions.find((option) => option.selected) || null;
const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]');
const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"]');
const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-launch-workbench"], [data-action="launch-workbench"]');
const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"], [data-testid="mdtodo-body-read"]');
const reportPreview = document.querySelector('[data-testid="mdtodo-report-preview"]');
const reportError = document.querySelector('[data-testid="mdtodo-report-error"]');
const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]');
@@ -1410,11 +1422,11 @@ async function projectManagementCommandSnapshot(options = {}) {
fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 20),
selectedTaskRefRaw: selectedTask?.getAttribute("data-task-ref") || null,
selectedTaskId: selectedTask?.getAttribute("data-task-id") || selectedTask?.getAttribute("data-rxx-id") || null,
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null,
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || selectedTask?.querySelector("[data-state]")?.getAttribute("data-state") || null,
sourceSelectVisible: visible(sourceSelect),
fileSelectVisible: visible(fileSelect),
sourceConfigVisible: visible(document.querySelector('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]')),
taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"]')),
taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"], [data-testid="mdtodo-body-editor"]')),
taskBodyVisible: visible(bodyRendered),
taskBodyText: visible(bodyRendered) ? text(bodyRendered) : "",
newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')),
@@ -1427,7 +1439,7 @@ async function projectManagementCommandSnapshot(options = {}) {
launchButtonVisible: visible(launch),
launchButtonEnabled: visible(launch) && !launch.disabled && launch.getAttribute("aria-disabled") !== "true",
launchButtonText: text(launch),
blockerTexts: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [role="alert"]')).filter(visible).map(text).filter(Boolean).slice(0, 8),
blockerTexts: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [data-testid="mdtodo-create-blocker"], .launch-blocker, [role="alert"]')).filter(visible).map(text).filter(Boolean).slice(0, 8),
workbenchLinkCount: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-link-summary"] li, a[href*="/workbench/sessions/"]')).filter(visible).length,
valuesRedacted: true
};
@@ -25,6 +25,36 @@ test("observe command renderer separates control completion from async turn term
assert.match(text, /--view turn-summary --command-id cmd-fixture/u);
});
test("observe command renderer exposes bounded performance capture artifact drill-down", () => {
const rendered = withWebObserveCommandRendered({
ok: true,
status: "control-completed",
command: "web-probe observe command",
id: "webobs-fixture",
commandId: "cmd-performance",
observerCommand: { type: "performanceCapture" },
observer: {
ok: true,
completedAt: "2026-07-13T12:00:00.000Z",
result: {
captureId: "cpu-fixture",
durationMs: 1200,
profileSha256: "sha256:profile",
reportPath: ".state/web-observe/performance/cpu-fixture/summary.json",
reportSha256: "sha256:report",
},
},
control: { executionStatus: "completed", businessTurnTerminalImplied: false },
asyncTurn: { applicable: false, submissionStatus: "not-applicable", terminalStatus: null, terminalObserved: false },
});
const text = String(rendered.renderedText);
assert.match(text, /Performance capture:/u);
assert.match(text, /sha256:profile/u);
assert.match(text, /sha256:report/u);
assert.match(text, /observe collect webobs-fixture --file .*summary\.json/u);
assert.doesNotMatch(text, /nodes.*callFrame/u);
});
test("observe status renderer exposes the exact command phase and failed report evidence", () => {
const rendered = withWebObserveStatusRendered({
ok: true,
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "bun:test";
import { nodeWebObserveStatusNodeScript } from "./web-observe-scripts";
import { nodeWebObserveStatusNodeScript, nodeWebObserveWaitCommandShell } from "./web-observe-scripts";
async function runStatusScript(stateDir: string, commandId: string): Promise<Record<string, any>> {
const child = Bun.spawn(["bash", "-lc", nodeWebObserveStatusNodeScript(1, "NC01", "v03", commandId)], {
@@ -58,6 +58,77 @@ test("observe status returns one exact completed command without prompt payloads
assert.equal(status.exactCommand.valuesRedacted, true);
});
test("observe status summarizes performance capture artifact without embedding CPU profile", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-performance-status-"));
const commandId = "cmd-performance";
await mkdir(join(stateDir, "commands", "done"), { recursive: true });
await writeFile(join(stateDir, "commands", "done", `${commandId}.json`), JSON.stringify({
ok: true,
commandId,
type: "performanceCapture",
completedAt: "2026-07-13T12:00:00.000Z",
result: {
ok: true,
captureId: "cpu-fixture",
durationMs: 1200,
profile: { nodes: [{ id: 1, callFrame: { functionName: "large-profile-payload" } }] },
artifact: {
path: ".state/web-observe/performance/cpu-fixture/profile.cpuprofile",
sha256: "sha256:profile",
summaryPath: ".state/web-observe/performance/cpu-fixture/summary.json",
summarySha256: "sha256:report",
},
},
}));
const status = await runStatusScript(stateDir, commandId);
assert.equal(status.exactCommand.result.captureId, "cpu-fixture");
assert.equal(status.exactCommand.result.profileSha256, "sha256:profile");
assert.equal(status.exactCommand.result.reportSha256, "sha256:report");
assert.equal(JSON.stringify(status).includes("large-profile-payload"), false);
});
test("observe command wait emits bounded performance capture summary", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-performance-wait-"));
const commandId = "cmd-performance";
await mkdir(join(stateDir, "commands", "done"), { recursive: true });
await writeFile(join(stateDir, "commands", "done", `${commandId}.json`), JSON.stringify({
ok: true,
commandId,
type: "performanceCapture",
completedAt: "2026-07-13T12:00:00.000Z",
result: {
ok: true,
captureId: "cpu-fixture",
durationMs: 1200,
profile: { nodes: [{ id: 1, callFrame: { functionName: "large-profile-payload" } }] },
artifact: {
path: ".state/web-observe/performance/cpu-fixture/profile.cpuprofile",
sha256: "sha256:profile",
summaryPath: ".state/web-observe/performance/cpu-fixture/summary.json",
summarySha256: "sha256:report",
},
},
}));
const child = Bun.spawn(["bash", "-lc", nodeWebObserveWaitCommandShell(commandId, 1000)], {
env: { ...process.env, state_dir: stateDir },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
]);
assert.equal(exitCode, 0, stderr);
const summary = JSON.parse(stdout);
assert.equal(summary.status, "done");
assert.equal(summary.result.profileSha256, "sha256:profile");
assert.equal(summary.result.reportSha256, "sha256:report");
assert.equal(stdout.includes("large-profile-payload"), false);
assert.ok(Buffer.byteLength(stdout) < 2048);
});
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";
+14 -3
View File
@@ -101,6 +101,7 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string,
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};};
const compactScreenshot=(value)=>value&&typeof value==='object'?{path:value.path||null,sha256:value.sha256||null,available:value.available??null,valuesRedacted:true}:null;
const compactPerformanceCapture=(value)=>{const item=value&&typeof value==='object'?value:null;const artifact=item&&item.artifact&&typeof item.artifact==='object'?item.artifact:null;if(!item&&!artifact)return{};return{captureId:item&&item.captureId||artifact&&artifact.captureId||null,durationMs:item&&item.durationMs!==undefined?item.durationMs:artifact&&artifact.durationMs!==undefined?artifact.durationMs:null,profilePath:artifact&&artifact.path||null,profileSha256:artifact&&artifact.sha256||null,reportPath:artifact&&artifact.summaryPath||null,reportSha256:artifact&&artifact.summarySha256||null,valuesRedacted:true};};
const commandSummary=(name)=>{
const root=commandDir(name); let entries=[];
try{entries=fs.readdirSync(root).filter((item)=>item.endsWith('.json')).sort();}catch{}
@@ -115,7 +116,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),...(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: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,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),...(parsed.type==='performanceCapture'?compactPerformanceCapture(result):{profile:result.profile||null}),valuesRedacted:true}:null,error:error?{name:error.name||null,message:short(error.message,200),details:error.details&&typeof error.details==='object'?{...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),...(parsed.type==='performanceCapture'?compactPerformanceCapture(error.details):{profile:error.details.profile||null}),valuesRedacted:true}:null}:null,valuesRedacted:true};
}
return {commandId:exactCommandId,phase:'not-found',ok:false,valuesRedacted:true};
};
@@ -425,12 +426,22 @@ export function nodeWebObserveWaitCommandShell(commandId: string, waitMs: number
].join("\n");
}
const waitSeconds = Math.max(1, Math.ceil(waitMs / 1000));
const printCommandSummary = `node -e ${shellQuote(`
const fs=require('fs');
const file=process.argv[1];
const phase=process.argv[2];
const parsed=JSON.parse(fs.readFileSync(file,'utf8'));
const result=parsed&&parsed.result&&typeof parsed.result==='object'?parsed.result:null;
const artifact=result&&result.artifact&&typeof result.artifact==='object'?result.artifact:null;
const summary={ok:parsed.ok!==false,queued:false,commandId:parsed.commandId||parsed.id||null,type:parsed.type||null,status:phase,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,captureId:result.captureId||artifact&&artifact.captureId||null,durationMs:result.durationMs!==undefined?result.durationMs:artifact&&artifact.durationMs!==undefined?artifact.durationMs:null,profilePath:artifact&&artifact.path||null,profileSha256:artifact&&artifact.sha256||null,reportPath:artifact&&artifact.summaryPath||result.reportPath||null,reportSha256:artifact&&artifact.summarySha256||result.reportSha256||null,valuesRedacted:true}:null,error:parsed.error?{name:parsed.error.name||null,message:String(parsed.error.message||'').replace(/\\s+/g,' ').slice(0,200),valuesRedacted:true}:null,valuesRedacted:true};
console.log(JSON.stringify(summary));
`)}`;
return [
`command_id=${shellQuote(commandId)}`,
`deadline=$(( $(date +%s) + ${waitSeconds} ))`,
"while [ \"$(date +%s)\" -le \"$deadline\" ]; do",
" if [ -f \"$state_dir/commands/done/${command_id}.json\" ]; then cat \"$state_dir/commands/done/${command_id}.json\"; exit 0; fi",
" if [ -f \"$state_dir/commands/failed/${command_id}.json\" ]; then cat \"$state_dir/commands/failed/${command_id}.json\"; exit 2; fi",
` if [ -f "$state_dir/commands/done/\${command_id}.json" ]; then ${printCommandSummary} "$state_dir/commands/done/\${command_id}.json" done; exit 0; fi`,
` if [ -f "$state_dir/commands/failed/\${command_id}.json" ]; then ${printCommandSummary} "$state_dir/commands/failed/\${command_id}.json" failed; exit 2; fi`,
" sleep 1",
"done",
"printf '{\"ok\":true,\"queued\":true,\"waitTimedOut\":true,\"commandId\":\"%s\",\"stateDir\":\"%s\"}\\n' \"$command_id\" \"$state_dir\"",
@@ -110,6 +110,32 @@ test("web observe action JSON recovery accepts concrete start contract", () => {
assert.equal(resolved.diagnostics.stdoutContractAccepted, true);
});
test("web observe action JSON recovery accepts bounded performance capture command summary", () => {
const resolved = resolveWebObserveActionJson({
stdout: JSON.stringify({
ok: true,
queued: false,
commandId: "cmd-performance",
type: "performanceCapture",
status: "done",
result: {
captureId: "cpu-fixture",
profileSha256: "sha256:profile",
reportSha256: "sha256:report",
valuesRedacted: true,
},
valuesRedacted: true,
}),
stderr: "",
exitCode: 0,
timedOut: false,
}, "command");
assert.equal(resolved.source, "stdout");
assert.equal(resolved.parsed?.commandId, "cmd-performance");
assert.equal(resolved.diagnostics.stdoutContractAccepted, true);
});
test("web observe action JSON recovery reads dump wrapper for status contract", async () => {
const dir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-status-dump-"));
const dumpPath = join(dir, "status.json");
@@ -0,0 +1,87 @@
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "bun:test";
import {
collectMdtodoSafeTitleStdinCapability,
renderMdtodoSafeTitleCommand,
} from "../../src/components/microservices/code-queue/src/skill-availability";
function writeFixture(root: string, stdinReady: boolean): void {
const scripts = join(root, "mdtodo-edit", "scripts");
mkdirSync(scripts, { recursive: true });
const option = stdinReady ? " --stdin" : " title";
const script = `#!/usr/bin/env python3\nimport sys\nprint("usage: mdtodo " + sys.argv[1] + "\\n${option}")\n`;
const path = join(scripts, "mdtodo-edit-cli.py");
writeFileSync(path, script);
chmodSync(path, 0o755);
}
describe("MDTODO safe title stdin adoption", () => {
test("reports stale and ready source/target capability without blocking", () => {
const temporary = mkdtempSync("/tmp/unidesk-mdtodo-safe-input-");
try {
const source = join(temporary, "source");
const staleSource = join(temporary, "stale-source");
const staleTarget = join(temporary, "stale-target");
const readyTarget = join(temporary, "ready-target");
writeFixture(source, true);
writeFixture(staleSource, false);
writeFixture(staleTarget, false);
writeFixture(readyTarget, true);
expect(collectMdtodoSafeTitleStdinCapability(source, staleTarget)).toMatchObject({
nonBlocking: true,
ready: false,
freshness: "target-stale",
source: { ready: true },
target: { ready: false },
});
expect(collectMdtodoSafeTitleStdinCapability(source, readyTarget)).toMatchObject({
nonBlocking: true,
ready: true,
freshness: "ready",
warning: null,
});
expect(collectMdtodoSafeTitleStdinCapability(staleSource, readyTarget)).toMatchObject({
nonBlocking: true,
ready: true,
freshness: "source-stale",
source: { ready: false },
target: { ready: true },
warning: expect.stringContaining("approved source"),
});
} finally {
rmSync(temporary, { recursive: true, force: true });
}
});
test("keeps Markdown and shell syntax in quoted heredoc stdin", () => {
const temporary = mkdtempSync("/tmp/unidesk-mdtodo-command-");
try {
const home = join(temporary, "home");
const scripts = join(home, ".agents", "skills", "mdtodo-edit", "scripts");
const output = join(temporary, "title.txt");
const forbidden = join(temporary, "forbidden");
mkdirSync(scripts, { recursive: true });
writeFileSync(join(scripts, "mdtodo-edit-cli.py"), [
"import os, sys",
"open(os.environ['TITLE_OUTPUT'], 'w', encoding='utf-8').write(sys.stdin.read())",
].join("\n"));
const title = `修复 \`path\`$(touch ${forbidden}) 的 'quoted' 标题`;
const command = renderMdtodoSafeTitleCommand("docs/MDTODO/demo file.md", "add").replace("<title>", title);
expect(command).toContain("'docs/MDTODO/demo file.md'");
expect(command).toContain("--stdin <<'EOF'");
expect(command.split("--stdin", 1)[0]).not.toContain(title);
const result = Bun.spawnSync(["bash", "-c", command], {
env: { ...process.env, HOME: home, TITLE_OUTPUT: output },
});
expect(result.exitCode).toBe(0);
expect(readFileSync(output, "utf8")).toBe(`${title}\n`);
expect(existsSync(forbidden)).toBe(false);
} finally {
rmSync(temporary, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,121 @@
import { describe, expect, test } from "bun:test";
import {
githubTokenEnvNameForSecretKey,
readGiteaConfig,
resolveTarget,
} from "./platform-infra-gitea-config";
import { buildMirrorWebhookCredentialBlockedResult, renderManifest } from "./platform-infra-gitea";
import { renderMirrorWebhookCredentialBlocked } from "./platform-infra-gitea-render";
function syntheticMissingSelfmediaCredential(): Record<string, unknown> {
return {
id: "github-upstream:selfmedia-nc01",
repository: "pikainc/selfmedia",
sourceRef: "pikainc-selfmedia-gh-token.txt",
present: false,
requiredKeysPresent: false,
fingerprint: null,
permissionResult: {
status: "blocked-missing-credential",
permissions: { contents: "read", metadata: "read", webhooks: "read-write" },
error: "credential material is absent",
},
valuesPrinted: false,
};
}
function syntheticBlockedResult(allTargetRepositories = false): Record<string, unknown> {
const config = readGiteaConfig();
const target = resolveTarget(config, "NC01");
const repositories = config.sourceAuthority.repositories.filter((repo) => repo.targetId === "NC01"
&& (allTargetRepositories || repo.key === "selfmedia-nc01"));
const credential = syntheticMissingSelfmediaCredential();
return buildMirrorWebhookCredentialBlockedResult(config, target, repositories, [credential], [String(credential.id)]);
}
describe("platform-infra Gitea repository GitHub credentials", () => {
test("keeps the global credential and selfmedia override on distinct Secret keys", () => {
const config = readGiteaConfig();
const globalCredential = config.sourceAuthority.credentials.github;
const selfmedia = config.sourceAuthority.repositories.find((repo) => repo.key === "selfmedia-nc01");
const override = selfmedia?.credentialOverride?.github;
expect(override?.sourceRef).toBe("pikainc-selfmedia-gh-token.txt");
expect(override?.permissions).toEqual({ contents: "read", metadata: "read", webhooks: "read-write" });
expect(override?.gitFetchCredential.secretRef.key).not.toBe(globalCredential.gitFetchCredential.secretRef.key);
expect(githubTokenEnvNameForSecretKey(override?.gitFetchCredential.secretRef.key ?? ""))
.not.toBe(githubTokenEnvNameForSecretKey(globalCredential.gitFetchCredential.secretRef.key));
});
test("normalizes Secret keys to bounded environment variable names", () => {
expect(githubTokenEnvNameForSecretKey("github-token-pikainc-selfmedia"))
.toBe("UNIDESK_GITEA_GITHUB_TOKEN_GITHUB_TOKEN_PIKAINC_SELFMEDIA");
});
test("keeps repository credential overrides scoped to their configured target", () => {
const config = readGiteaConfig();
const jd01Overrides = config.sourceAuthority.repositories
.filter((repo) => repo.targetId === "JD01")
.flatMap((repo) => repo.credentialOverride?.github ?? []);
const nc01Overrides = config.sourceAuthority.repositories
.filter((repo) => repo.targetId === "NC01")
.flatMap((repo) => repo.credentialOverride?.github ?? []);
expect(jd01Overrides).toHaveLength(0);
expect(nc01Overrides.map((credential) => credential.sourceRef)).toContain("pikainc-selfmedia-gh-token.txt");
});
test("does not inject the NC01 selfmedia token into JD01 bridge containers", () => {
const config = readGiteaConfig();
const selfmediaTokenEnv = githubTokenEnvNameForSecretKey("github-token-pikainc-selfmedia");
const globalTokenEnv = githubTokenEnvNameForSecretKey(config.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key);
const jd01Manifest = renderManifest(config, resolveTarget(config, "JD01"));
const nc01Manifest = renderManifest(config, resolveTarget(config, "NC01"));
expect(jd01Manifest).toContain(globalTokenEnv);
expect(jd01Manifest).not.toContain(selfmediaTokenEnv);
expect(nc01Manifest).toContain(globalTokenEnv);
expect(nc01Manifest).toContain(selfmediaTokenEnv);
expect(nc01Manifest).toContain(`"tokenEnv": "${selfmediaTokenEnv}"`);
});
test("keeps missing selfmedia webhook status bounded and stack-free", () => {
const payload = syntheticBlockedResult();
const credentials = payload.credentials as Array<Record<string, unknown>>;
const selfmedia = credentials[0];
const serialized = JSON.stringify(payload);
expect((payload.error as Record<string, unknown>).code).toBe("github-credential-unavailable");
expect(selfmedia).toMatchObject({
sourceRef: "pikainc-selfmedia-gh-token.txt",
present: false,
fingerprint: null,
valuesPrinted: false,
});
expect(selfmedia).not.toHaveProperty("sourcePath");
expect(serialized).not.toContain('"stack"');
expect(serialized).not.toContain("/root/.worktrees/");
});
test("renders missing selfmedia webhook status as compact text by default", () => {
const stdout = renderMirrorWebhookCredentialBlocked(syntheticBlockedResult()).renderedText;
expect(stdout).toContain("PLATFORM-INFRA GITEA MIRROR WEBHOOK STATUS");
expect(stdout).toContain("SOURCE_REF");
expect(stdout).toContain("pikainc-selfmedia-gh-token.txt");
expect(stdout).toContain("PERMISSION_RESULT");
expect(stdout).toContain("github-upstream:selfmedia-nc01");
expect(stdout.trimStart()).not.toStartWith("{");
expect(stdout).not.toContain('"stack"');
});
test("matches compact blocker rows to their repository without --repo", () => {
const stdout = renderMirrorWebhookCredentialBlocked(syntheticBlockedResult(true)).renderedText;
expect(stdout).toContain("pikainc/selfmedia");
expect(stdout).toContain("pikainc-selfmedia-gh-token.txt");
expect(stdout).toContain("github-upstream:selfmedia-nc01");
expect(stdout).not.toContain("pikasTech/agentrun");
expect(stdout.trimStart()).not.toStartWith("{");
});
});
@@ -160,6 +160,16 @@ export interface GiteaGithubCredential {
};
}
export interface GiteaGithubPermissions {
contents: "read";
metadata: "read";
webhooks: "read-write";
}
export function githubTokenEnvNameForSecretKey(secretKey: string): string {
return `UNIDESK_GITEA_GITHUB_TOKEN_${secretKey.replace(/[^A-Za-z0-9_]/gu, "_").toUpperCase()}`;
}
export interface GiteaWebhookSync {
enabled: boolean;
direction: "github-to-gitea";
@@ -273,6 +283,9 @@ export interface GiteaSourceResponsibility {
export interface GiteaMirrorRepository {
key: string;
targetId: string;
credentialOverride: {
github: GiteaGithubCredential & { permissions: GiteaGithubPermissions };
} | null;
upstream: {
repository: string;
cloneUrl: string;
@@ -657,9 +670,28 @@ function parseMirrorRepository(record: Record<string, unknown>, index: number):
const legacyGitMirror = record.legacyGitMirror === null || record.legacyGitMirror === undefined
? null
: y.objectField(record, "legacyGitMirror", path);
const credentialOverride = record.credentialOverride === undefined
? null
: y.objectField(record, "credentialOverride", path);
const githubOverride = credentialOverride === null
? null
: y.objectField(credentialOverride, "github", `${path}.credentialOverride`);
const githubPermissions = githubOverride === null
? null
: y.objectField(githubOverride, "permissions", `${path}.credentialOverride.github`);
return {
key: y.stringField(record, "key", path),
targetId: y.stringField(record, "targetId", path),
credentialOverride: githubOverride === null || githubPermissions === null ? null : {
github: {
...parseGithubCredential(githubOverride, `${path}.credentialOverride.github`),
permissions: {
contents: y.enumField(githubPermissions, "contents", `${path}.credentialOverride.github.permissions`, ["read"] as const),
metadata: y.enumField(githubPermissions, "metadata", `${path}.credentialOverride.github.permissions`, ["read"] as const),
webhooks: y.enumField(githubPermissions, "webhooks", `${path}.credentialOverride.github.permissions`, ["read-write"] as const),
},
},
},
upstream: {
repository: repositoryField(upstream, "repository", `${path}.upstream`),
cloneUrl: gitCloneUrlField(upstream, "cloneUrl", `${path}.upstream`),
@@ -738,6 +770,35 @@ function validateConfig(gitea: GiteaConfig): void {
if (gitea.targets.some((target) => target.enabled && target.namespace !== gitFetchCredential.secretRef.namespace)) {
throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.namespace must match every enabled Gitea target namespace`);
}
const credentialKeys = new Set([gitFetchCredential.secretRef.key]);
const credentialEnvNames = new Set([githubTokenEnvNameForSecretKey(gitFetchCredential.secretRef.key)]);
for (const repo of gitea.sourceAuthority.repositories) {
const override = repo.credentialOverride?.github;
if (override === undefined) continue;
if (!override.requiredFor.includes("managed-repository-fetch")
|| !override.requiredFor.includes("github-head-observe")
|| !override.requiredFor.includes("github-hooks-list")
|| !override.requiredFor.includes("github-hooks-reconcile")) {
throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.requiredFor must cover fetch, head observation and hook reconciliation`);
}
if (override.gitFetchCredential.secretRef.name !== gitea.sourceAuthority.webhookSync.bridge.secretName) {
throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.gitFetchCredential.secretRef.name must match sourceAuthority.webhookSync.bridge.secretName`);
}
const target = resolveTarget(gitea, repo.targetId);
if (override.gitFetchCredential.secretRef.namespace !== target.namespace) {
throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.gitFetchCredential.secretRef.namespace must match repository target namespace`);
}
const key = override.gitFetchCredential.secretRef.key;
if (!/^[A-Za-z0-9._-]+$/u.test(key) || credentialKeys.has(key)) {
throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.gitFetchCredential.secretRef.key must be a unique Kubernetes Secret data key`);
}
credentialKeys.add(key);
const envName = githubTokenEnvNameForSecretKey(key);
if (credentialEnvNames.has(envName)) {
throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.gitFetchCredential.secretRef.key collides after environment variable normalization`);
}
credentialEnvNames.add(envName);
}
if (!gitea.sourceAuthority.enabled) throw new Error(`${configLabel}.sourceAuthority.enabled must be true for GH-1550`);
if (gitea.sourceAuthority.repositories.length < 1) throw new Error(`${configLabel}.sourceAuthority.repositories must not be empty`);
if (!/^docker\.gitea\.com\/gitea$/u.test(gitea.app.image.repository)) throw new Error(`${configLabel}.app.image.repository must use the official Gitea image registry`);
@@ -751,13 +751,13 @@ export async function syncRepository(repo, delivery, runtime) {
let requestedObject = await executeResult(["git", "-C", work, "cat-file", "-e", `${delivery.requestedCommit}^{commit}`]);
if (requestedObject.status !== 0) {
const branchFetch = await executeResult([
"git", "-C", work, "-c", `http.extraHeader=${runtime.githubAuthHeader}`,
"git", "-C", work, "-c", `http.extraHeader=${runtime.githubAuthHeaders[repo.key]}`,
"fetch", "--no-tags", repo.upstream.cloneUrl,
`+refs/heads/${repo.upstream.branch}:refs/remotes/github/${repo.upstream.branch}`,
]);
requestedObject = await executeResult(["git", "-C", work, "cat-file", "-e", `${delivery.requestedCommit}^{commit}`]);
if (requestedObject.status !== 0) {
const exactFetch = await executeResult(["git", "-C", work, "-c", `http.extraHeader=${runtime.githubAuthHeader}`, "fetch", "--no-tags", repo.upstream.cloneUrl, `${delivery.requestedCommit}:refs/remotes/github/delivery`]);
const exactFetch = await executeResult(["git", "-C", work, "-c", `http.extraHeader=${runtime.githubAuthHeaders[repo.key]}`, "fetch", "--no-tags", repo.upstream.cloneUrl, `${delivery.requestedCommit}:refs/remotes/github/delivery`]);
if (exactFetch.status !== 0) {
return {
ok: false,
@@ -931,14 +931,19 @@ export function retryDelayMs(syncAttempt, initialDelayMs, maxDelayMs) {
}
export function loadRuntimeConfig() {
const githubToken = requiredEnv("GITHUB_TOKEN");
const giteaUsername = requiredEnv("GITEA_USERNAME");
const giteaPassword = requiredEnv("GITEA_PASSWORD");
const repos = JSON.parse(readFileSync(process.env.UNIDESK_GITEA_REPOS_PATH || "/etc/gitea-github-sync/repos.json", "utf8"));
const defaultTokenEnv = requiredEnv("UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV");
const githubAuthHeaders = Object.fromEntries(repos.map((repo) => {
const token = requiredEnv(repo.githubCredential?.tokenEnv || defaultTokenEnv);
return [repo.key, `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`];
}));
return {
port: Number.parseInt(process.env.UNIDESK_GITEA_WEBHOOK_PORT || "8080", 10),
path: process.env.UNIDESK_GITEA_WEBHOOK_PATH || "/_unidesk/github-to-gitea",
responseBudgetMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RESPONSE_BUDGET_MS"),
repos: JSON.parse(readFileSync(process.env.UNIDESK_GITEA_REPOS_PATH || "/etc/gitea-github-sync/repos.json", "utf8")),
repos,
webhookSecret: requiredEnv("GITHUB_WEBHOOK_SECRET"),
giteaBaseUrl: requiredEnv("UNIDESK_GITEA_SERVICE_BASE_URL").replace(/\/+$/u, ""),
inbox: {
@@ -958,7 +963,7 @@ export function loadRuntimeConfig() {
},
shutdownGraceMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_SHUTDOWN_GRACE_MS"),
maxBodyBytes: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_MAX_BODY_BYTES"),
githubAuthHeader: `Authorization: Basic ${Buffer.from(`x-access-token:${githubToken}`).toString("base64")}`,
githubAuthHeaders,
giteaAuthHeader: `Authorization: Basic ${Buffer.from(`${giteaUsername}:${giteaPassword}`).toString("base64")}`,
log,
run,
@@ -7,6 +7,14 @@ import { renderGiteaRemotePayloadMaterializer, summarizeGiteaRemotePayloads } fr
import { renderGiteaWebhookSyncDesiredManifest } from "./platform-infra-gitea";
const remoteScriptPath = resolve(import.meta.dir, "platform-infra-gitea-remote.sh");
const orchestrationPath = resolve(import.meta.dir, "platform-infra-gitea.ts");
function functionSource(source: string, name: string, nextName: string): string {
const start = source.indexOf(`${name}() {`);
const end = source.indexOf(`\n${nextName}() {`, start);
if (start < 0 || end < 0) throw new Error(`missing shell function ${name}`);
return source.slice(start, end);
}
test("stdin file envelope materializes a payload larger than the current Gitea manifest without argv or env", () => {
const currentManifest = renderGiteaWebhookSyncDesiredManifest("NC01");
@@ -68,3 +76,68 @@ test("Gitea remote runner consumes materialized files instead of base64 environm
source.lastIndexOf('timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side --force-conflicts'),
);
});
test("mirror bootstrap materializes every selected GitHub credential key before repository mutation", () => {
const source = readFileSync(remoteScriptPath, "utf8");
const orchestration = readFileSync(orchestrationPath, "utf8");
const upsert = functionSource(source, "upsert_webhook_sync_secret", "run_apply");
const bootstrap = functionSource(source, "run_mirror_bootstrap", "run_mirror_sync");
expect(bootstrap.indexOf("upsert_webhook_sync_secret")).toBeLessThan(bootstrap.indexOf('pod="$(kubectl'));
expect(bootstrap).toContain('"apiBootstrap": {"exitCode": None, "skipped": True}');
expect(bootstrap).toContain("return 1");
expect(orchestration).toContain("ensureMirrorSecrets(gitea, target, repositoriesForTarget(gitea, target), true, true, true)");
expect(orchestration).toContain("Object.keys(params.secrets.githubTokens)");
expect(orchestration).toContain("env.UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP");
const result = spawnSync("sh", ["-s"], {
encoding: "utf8",
input: [
"set -u",
'tmp="$(mktemp -d)"',
'trap \'rm -rf "$tmp"\' EXIT',
'export tmp UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED=1 UNIDESK_GITEA_DRY_RUN=0 UNIDESK_GITEA_NAMESPACE=devops-infra UNIDESK_GITEA_WEBHOOK_SECRET_NAME=gitea-github-sync-secrets UNIDESK_GITEA_FIELD_MANAGER=test-manager',
'export UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP="github-token=GLOBAL_TOKEN,github-token-pikainc-selfmedia=SELFMEDIA_TOKEN"',
'export GLOBAL_TOKEN=fixture-global SELFMEDIA_TOKEN=fixture-selfmedia UNIDESK_GITEA_ADMIN_USERNAME=fixture-admin UNIDESK_GITEA_ADMIN_PASSWORD=fixture-password UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET=fixture-webhook',
'kubectl() { if [ "$1" = "-n" ] && [ "$3" = "create" ]; then printf "%s\\n" "apiVersion: v1" "kind: Secret"; printf "%s\\n" "$*" >>"$tmp/kubectl.log"; return 0; fi; if [ "$1" = "apply" ]; then cat >/dev/null; printf "%s\\n" "$*" >>"$tmp/kubectl.log"; return 0; fi; return 1; }',
upsert,
"upsert_webhook_sync_secret",
'cat "$tmp/kubectl.log"',
].join("\n"),
});
expect(result.status).toBe(0);
expect(result.stdout).toContain("--from-file=github-token=");
expect(result.stdout).toContain("--from-file=github-token-pikainc-selfmedia=");
expect(result.stdout).not.toContain("fixture-global");
expect(result.stdout).not.toContain("fixture-selfmedia");
const failed = spawnSync("sh", ["-s"], {
encoding: "utf8",
input: [
"set -u",
'tmp="$(mktemp -d)"',
'trap \'rm -rf "$tmp"\' EXIT',
'export tmp UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED=1 UNIDESK_GITEA_DRY_RUN=0 UNIDESK_GITEA_NAMESPACE=devops-infra UNIDESK_GITEA_WEBHOOK_SECRET_NAME=gitea-github-sync-secrets UNIDESK_GITEA_FIELD_MANAGER=test-manager UNIDESK_GITEA_TARGET_ID=NC01',
'export UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP="github-token=GLOBAL_TOKEN,github-token-pikainc-selfmedia=SELFMEDIA_TOKEN"',
'export GLOBAL_TOKEN=fixture-global SELFMEDIA_TOKEN=fixture-selfmedia UNIDESK_GITEA_ADMIN_USERNAME=fixture-admin UNIDESK_GITEA_ADMIN_PASSWORD=fixture-password UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET=fixture-webhook',
'mirror_repos_json() { printf "%s\\n" "[]" >"$tmp/repos.json"; }',
'kubectl() { if [ "$1" = "-n" ] && [ "$3" = "create" ]; then printf "%s\\n" "apiVersion: v1" "kind: Secret"; return 0; fi; if [ "$1" = "apply" ]; then cat >/dev/null; return 1; fi; touch "$tmp/unexpected-mutation"; return 1; }',
upsert,
bootstrap,
"run_mirror_bootstrap >\"$tmp/result.json\"",
'rc=$?',
'python3 - "$tmp/result.json" "$tmp/unexpected-mutation" "$rc" <<\'PY\'',
"import json, pathlib, sys",
"payload=json.load(open(sys.argv[1], encoding='utf-8'))",
"assert int(sys.argv[3]) == 1",
"assert payload['steps']['apiBootstrap']['skipped'] is True",
"assert payload['valuesPrinted'] is False",
"assert not pathlib.Path(sys.argv[2]).exists()",
"print('bootstrap-secret-fail-closed')",
"PY",
].join("\n"),
});
expect(failed.status).toBe(0);
expect(failed.stdout).toContain("bootstrap-secret-fail-closed");
expect(failed.stdout).not.toContain("fixture-global");
expect(failed.stdout).not.toContain("fixture-selfmedia");
});
+96 -45
View File
@@ -32,6 +32,49 @@ capture_json() {
printf '%s' "$rc" >"$tmp/$name.rc"
}
upsert_webhook_sync_secret() {
webhook_secret_rc=0
: >"$tmp/webhook-secret.out"
: >"$tmp/webhook-secret.err"
if [ "$UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED" != "1" ]; then
printf '%s\n' "webhook sync disabled" >"$tmp/webhook-secret.err"
return 0
fi
if [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then
printf '%s\n' "dry-run: webhook sync secret apply skipped" >"$tmp/webhook-secret.err"
return 0
fi
github_secret_args=""
old_ifs="$IFS"
IFS=','
for mapping in $UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP; do
secret_key=${mapping%%=*}
env_name=${mapping#*=}
eval "secret_value=\${$env_name-}"
if [ -z "$secret_value" ]; then
printf '%s\n' "missing GitHub credential environment for Secret key $secret_key" >"$tmp/webhook-secret.err"
webhook_secret_rc=1
break
fi
secret_file="$tmp/github-secret-$secret_key"
printf '%s' "$secret_value" >"$secret_file"
chmod 600 "$secret_file"
github_secret_args="$github_secret_args --from-file=$secret_key=$secret_file"
done
IFS="$old_ifs"
if [ "$webhook_secret_rc" -ne 0 ]; then
return "$webhook_secret_rc"
fi
kubectl -n "$UNIDESK_GITEA_NAMESPACE" create secret generic "$UNIDESK_GITEA_WEBHOOK_SECRET_NAME" \
$github_secret_args \
--from-literal=gitea-username="$UNIDESK_GITEA_ADMIN_USERNAME" \
--from-literal=gitea-password="$UNIDESK_GITEA_ADMIN_PASSWORD" \
--from-literal=github-webhook-secret="$UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET" \
--dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/webhook-secret.out" 2>"$tmp/webhook-secret.err"
webhook_secret_rc=$?
return "$webhook_secret_rc"
}
run_apply() {
manifest="$tmp/gitea.k8s.yaml"
if [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
@@ -51,23 +94,8 @@ run_apply() {
printf '%s\n' "frpc disabled" >"$tmp/frpc-secret.err"
fi
fi
if [ "$UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
kubectl -n "$UNIDESK_GITEA_NAMESPACE" create secret generic "$UNIDESK_GITEA_WEBHOOK_SECRET_NAME" \
--from-literal="$UNIDESK_GITEA_GITHUB_SECRET_KEY=$UNIDESK_GITEA_GITHUB_TOKEN" \
--from-literal=gitea-username="$UNIDESK_GITEA_ADMIN_USERNAME" \
--from-literal=gitea-password="$UNIDESK_GITEA_ADMIN_PASSWORD" \
--from-literal=github-webhook-secret="$UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET" \
--dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/webhook-secret.out" 2>"$tmp/webhook-secret.err"
webhook_secret_rc=$?
else
webhook_secret_rc=0
: >"$tmp/webhook-secret.out"
if [ "$UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED" = "1" ]; then
printf '%s\n' "dry-run: webhook sync secret apply skipped" >"$tmp/webhook-secret.err"
else
printf '%s\n' "webhook sync disabled" >"$tmp/webhook-secret.err"
fi
fi
upsert_webhook_sync_secret
webhook_secret_rc=$?
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=$?
@@ -389,6 +417,32 @@ PY
run_mirror_bootstrap() {
mirror_repos_json
upsert_webhook_sync_secret
webhook_secret_rc=$?
if [ "$webhook_secret_rc" -ne 0 ]; then
python3 - "$webhook_secret_rc" "$tmp/webhook-secret.out" "$tmp/webhook-secret.err" <<'PY'
import json, os, sys
def text(path, limit=1600):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
payload = {
"ok": False,
"target": os.environ.get("UNIDESK_GITEA_TARGET_ID"),
"namespace": os.environ.get("UNIDESK_GITEA_NAMESPACE"),
"steps": {
"webhookSyncSecret": {"exitCode": int(sys.argv[1]), "stdoutTail": text(sys.argv[2]), "stderrTail": text(sys.argv[3])},
"apiBootstrap": {"exitCode": None, "skipped": True},
},
"repositories": [],
"valuesPrinted": False,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(1)
PY
return 1
fi
pod="$(kubectl -n "$UNIDESK_GITEA_NAMESPACE" get pod -l "app.kubernetes.io/name=$UNIDESK_GITEA_APP_NAME,app.kubernetes.io/component=gitea" -o jsonpath='{.items[0].metadata.name}' 2>"$tmp/pod.err")"
create_rc=1
change_rc=1
@@ -470,26 +524,27 @@ json.dump(payload, open(out_path, "w", encoding="utf-8"), ensure_ascii=False, in
sys.exit(0 if ok else 1)
PY
api_rc=$?
python3 - "$create_rc" "$change_rc" "$unset_rc" "$api_rc" "$tmp/admin-create.out" "$tmp/admin-create.err" "$tmp/admin-pass.out" "$tmp/admin-pass.err" "$tmp/admin-unset.out" "$tmp/admin-unset.err" "$tmp/api.json" <<'PY'
python3 - "$webhook_secret_rc" "$create_rc" "$change_rc" "$unset_rc" "$api_rc" "$tmp/webhook-secret.out" "$tmp/webhook-secret.err" "$tmp/admin-create.out" "$tmp/admin-create.err" "$tmp/admin-pass.out" "$tmp/admin-pass.err" "$tmp/admin-unset.out" "$tmp/admin-unset.err" "$tmp/api.json" <<'PY'
import json, sys
def text(path, limit=1600):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
create_rc, change_rc, unset_rc, api_rc = map(int, sys.argv[1:5])
webhook_secret_rc, create_rc, change_rc, unset_rc, api_rc = map(int, sys.argv[1:6])
try:
api = json.load(open(sys.argv[11], encoding="utf-8"))
api = json.load(open(sys.argv[14], encoding="utf-8"))
except Exception:
api = {}
payload = {
"ok": create_rc == 0 and change_rc == 0 and unset_rc == 0 and api_rc == 0,
"ok": webhook_secret_rc == 0 and create_rc == 0 and change_rc == 0 and unset_rc == 0 and api_rc == 0,
"target": __import__("os").environ.get("UNIDESK_GITEA_TARGET_ID"),
"namespace": __import__("os").environ.get("UNIDESK_GITEA_NAMESPACE"),
"steps": {
"adminCreate": {"exitCode": create_rc, "stdoutTail": text(sys.argv[5]), "stderrTail": text(sys.argv[6])},
"adminPassword": {"exitCode": change_rc, "stdoutTail": text(sys.argv[7]), "stderrTail": text(sys.argv[8])},
"adminMustChangePasswordUnset": {"exitCode": unset_rc, "stdoutTail": text(sys.argv[9]), "stderrTail": text(sys.argv[10])},
"webhookSyncSecret": {"exitCode": webhook_secret_rc, "stdoutTail": text(sys.argv[6]), "stderrTail": text(sys.argv[7])},
"adminCreate": {"exitCode": create_rc, "stdoutTail": text(sys.argv[8]), "stderrTail": text(sys.argv[9])},
"adminPassword": {"exitCode": change_rc, "stdoutTail": text(sys.argv[10]), "stderrTail": text(sys.argv[11])},
"adminMustChangePasswordUnset": {"exitCode": unset_rc, "stdoutTail": text(sys.argv[12]), "stderrTail": text(sys.argv[13])},
"apiBootstrap": {"exitCode": api_rc},
},
"api": api,
@@ -518,11 +573,12 @@ for i, repo in enumerate(repos):
sync_gitops_from_upstream = repo["gitops"].get("flushDisposition") != "gitea-writeback"
branches = [source_branch] + ([] if not sync_gitops_from_upstream or gitops_branch == source_branch or gitops_branch == "not-a-gitops-branch" else [gitops_branch])
work = f"$tmp/repo-{i}.git"
token_env = (repo.get("githubCredential") or {}).get("tokenEnv") or os.environ.get("UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV", "")
gitea_write_url = base_url + urllib.parse.urlparse(repo["gitea"]["readUrl"]).path
script += [
f"mkdir -p {shlex.quote(work)}",
f"git init --bare {shlex.quote(work)} >$tmp/{key}.init.out 2>$tmp/{key}.init.err; printf '%s' \"$?\" >$tmp/{key}.init.rc",
f"git -C {shlex.quote(work)} -c http.extraHeader=\"$GITHUB_AUTH_HEADER\" fetch --prune {shlex.quote(repo['upstream']['cloneUrl'])} " + " ".join(shlex.quote(f"+refs/heads/{b}:refs/remotes/github/{b}") for b in branches) + f" >$tmp/{key}.fetch.out 2>$tmp/{key}.fetch.err; fetch_rc=$?; printf '%s' \"$fetch_rc\" >$tmp/{key}.fetch.rc",
f"repo_token=$(printenv {shlex.quote(token_env)}); repo_basic=$(printf 'x-access-token:%s' \"$repo_token\" | base64 | tr -d '\\n'); git -C {shlex.quote(work)} -c http.extraHeader=\"Authorization: Basic $repo_basic\" fetch --prune {shlex.quote(repo['upstream']['cloneUrl'])} " + " ".join(shlex.quote(f"+refs/heads/{b}:refs/remotes/github/{b}") for b in branches) + f" >$tmp/{key}.fetch.out 2>$tmp/{key}.fetch.err; fetch_rc=$?; unset repo_token repo_basic; printf '%s' \"$fetch_rc\" >$tmp/{key}.fetch.rc",
f"if [ \"$fetch_rc\" -eq 0 ]; then sha=$(git -C {shlex.quote(work)} rev-parse refs/remotes/github/{shlex.quote(source_branch)} 2>$tmp/{key}.revparse.err); revparse_rc=$?; printf '%s\\n' \"$sha\" >$tmp/{key}.revparse.out; else sha=''; revparse_rc=1; : >$tmp/{key}.revparse.out; printf '%s\\n' 'fetch failed; revparse skipped' >$tmp/{key}.revparse.err; fi; printf '%s' \"$revparse_rc\" >$tmp/{key}.revparse.rc",
f"if [ \"$revparse_rc\" -eq 0 ]; then snapshot_ref={shlex.quote(repo['snapshot']['prefix'])}/$sha; git -C {shlex.quote(work)} -c http.extraHeader=\"$GITEA_AUTH_HEADER\" push --force {shlex.quote(gitea_write_url)} $sha:refs/heads/{shlex.quote(source_branch)} $sha:$snapshot_ref >$tmp/{key}.push.out 2>$tmp/{key}.push.err; push_rc=$?; else snapshot_ref=''; push_rc=1; : >$tmp/{key}.push.out; printf '%s\\n' 'revparse failed; push skipped' >$tmp/{key}.push.err; fi; printf '%s' \"$push_rc\" >$tmp/{key}.push.rc",
]
@@ -537,15 +593,8 @@ for i, repo in enumerate(repos):
open(sys.argv[2], "w", encoding="utf-8").write("\n".join(script) + "\n")
json.dump(meta, open(sys.argv[3], "w", encoding="utf-8"), ensure_ascii=False, indent=2)
PY
github_basic="$(python3 - <<'PY'
import base64, os
raw = f"x-access-token:{os.environ['UNIDESK_GITEA_GITHUB_TOKEN']}".encode()
print(base64.b64encode(raw).decode())
PY
)"
GITHUB_AUTH_HEADER="Authorization: Basic $github_basic"
GITEA_AUTH_HEADER="Authorization: Basic $basic"
export GITHUB_AUTH_HEADER GITEA_AUTH_HEADER
export GITEA_AUTH_HEADER
unset GIT_SSH GIT_SSH_COMMAND SSH_AUTH_SOCK
export GIT_TERMINAL_PROMPT=0
export GIT_CONFIG_NOSYSTEM=1
@@ -685,7 +734,6 @@ run_mirror_webhook_apply() {
python3 - "$tmp/repos.json" <<'PY'
import json, os, sys, urllib.error, urllib.request
repos = json.load(open(sys.argv[1], encoding="utf-8"))
token = os.environ["UNIDESK_GITEA_GITHUB_TOKEN"]
url = os.environ["UNIDESK_GITEA_WEBHOOK_PUBLIC_URL"]
secret = os.environ["UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET"]
events = [item for item in os.environ.get("UNIDESK_GITEA_WEBHOOK_EVENTS", "push").split(",") if item]
@@ -707,6 +755,8 @@ def request(method, api_path, payload=None, expected=(200, 201, 204), tolerate=(
rows = []
for repo in repos:
repository = repo["upstream"]["repository"]
token_env = (repo.get("githubCredential") or {}).get("tokenEnv") or os.environ["UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV"]
token = os.environ[token_env]
list_result = request("GET", f"/repos/{repository}/hooks")
hooks = []
try:
@@ -757,7 +807,7 @@ NODE
python3 - "$tmp/repos.json" "$bridge_ready" "$service_exists" "$mirror_status_rc" "$bridge_logs_rc" "$inbox_status_rc" "$tmp/mirror-status.json" "$tmp/mirror-status.err" "$tmp/bridge.log" "$tmp/bridge.err" "$tmp/inbox-status.json" "$tmp/inbox-status.err" "$tmp/hook-topology.json" <<'PY'
import hashlib, json, os, sys, urllib.error, urllib.parse, urllib.request
sys.path.insert(0, os.environ["tmp"])
from platform_infra_gitea_status_evaluator import evaluate_repository, select_committed_head_record, select_target_delivery
from platform_infra_gitea_status_evaluator import evaluate_repository, github_hook_url, parse_github_hooks_response, select_committed_head_record, select_target_delivery
repos = json.load(open(sys.argv[1], encoding="utf-8"))
bridge_ready = sys.argv[2]
service_exists = bool(sys.argv[3])
@@ -771,7 +821,6 @@ bridge_log_err_path = sys.argv[10]
inbox_status_path = sys.argv[11]
inbox_status_err_path = sys.argv[12]
hook_topology_path = sys.argv[13]
token = os.environ["UNIDESK_GITEA_GITHUB_TOKEN"]
url = os.environ["UNIDESK_GITEA_WEBHOOK_PUBLIC_URL"]
try:
hook_topology = json.load(open(hook_topology_path, encoding="utf-8"))
@@ -783,12 +832,15 @@ topology_repositories = hook_topology.get("repositories") if isinstance(hook_top
def url_hash(value):
return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
def hook_url(item):
config = item.get("config") if isinstance(item.get("config"), dict) else {}
return config.get("url") if isinstance(config.get("url"), str) else ""
return github_hook_url(item)
def is_managed_url(value):
return value == managed_url_prefix or value.startswith(managed_url_prefix.rstrip("/") + "/")
def hook_config_matches(item):
config = item.get("config") if isinstance(item.get("config"), dict) else {}
config = item.get("config")
if isinstance(config, str):
return item.get("name") == "web" and item.get("active") is True and set(item.get("events") or []) == set(desired_events)
if not isinstance(config, dict):
return False
return item.get("name") == "web" and item.get("active") is True and set(item.get("events") or []) == set(desired_events) and config.get("content_type") == "json" and str(config.get("insecure_ssl", "0")) == "0"
def topology_observation(repository, hooks):
spec = next((item for item in topology_repositories if isinstance(item, dict) and item.get("repository") == repository), {})
@@ -987,13 +1039,12 @@ for line in text(bridge_log_path, 12000).splitlines():
rows = []
for repo in repos:
repository = repo["upstream"]["repository"]
topology_spec = next((item for item in topology_repositories if isinstance(item, dict) and item.get("repository") == repository), {})
token = os.environ[topology_spec.get("githubTokenEnv") or os.environ["UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV"]]
branch = repo["upstream"]["branch"]
result = request(f"/repos/{repository}/hooks")
hooks = []
try:
hooks = json.loads(result.get("body") or "[]")
except Exception:
hooks = []
hooks_result = parse_github_hooks_response(result.get("ok"), result.get("status"), result.get("body"))
hooks = hooks_result["hooks"]
matches = [item for item in hooks if hook_url(item) == url]
topology = topology_observation(repository, hooks)
head = github_head(repository, branch)
@@ -1044,7 +1095,7 @@ for repo in repos:
"latest": latest_delivery,
"latestPush": latest_push_delivery,
"selectedPush": selected_push_delivery,
"error": result.get("body", "")[:500] if not result.get("ok") else (delivery_errors[0] if delivery_errors else topology_reason),
"error": hooks_result["error"] or (delivery_errors[0] if delivery_errors else topology_reason),
}
rows.append(evaluate_repository(repo, hook, head, mirror, bridge_events, inbox_records, inbox_status.get("journal")))
bridge = {
@@ -107,6 +107,44 @@ export function renderMirrorWebhookApply(result: Record<string, unknown>): Rende
]);
}
export function renderMirrorWebhookCredentialBlocked(result: Record<string, unknown>): RenderedCliResult {
const target = record(result.target);
const error = record(result.error);
const blockerIds = Array.isArray(error.blockers) ? error.blockers.map(String) : [];
const repositories = arrayRecords(result.repositories);
const credentials = arrayRecords(result.credentials);
const rows = blockerIds.map((blocker) => {
const credential = credentials.find((item) => stringValue(item.id) === blocker) ?? {};
const permissionResult = record(credential.permissionResult);
const permissions = record(permissionResult.permissions);
const repoKey = blocker.includes(":") ? blocker.slice(blocker.indexOf(":") + 1) : "";
const repository = repositories.find((item) => stringValue(item.key) === repoKey) ?? {};
const permissionText = [
`status=${stringValue(permissionResult.status)}`,
`contents=${stringValue(permissions.contents)}`,
`metadata=${stringValue(permissions.metadata)}`,
`webhooks=${stringValue(permissions.webhooks)}`,
].join(",");
return [
stringValue(target.id),
stringValue(credential.repository, stringValue(repository.upstreamRepository)),
stringValue(credential.sourceRef),
boolText(credential.present),
stringValue(credential.fingerprint),
permissionText,
blocker,
boolText(credential.valuesPrinted),
];
});
const next = record(result.next);
return rendered(result, "platform-infra gitea mirror webhook status", [
"PLATFORM-INFRA GITEA MIRROR WEBHOOK STATUS",
...(rows.length === 0 ? ["-"] : table(["TARGET", "REPOSITORY", "SOURCE_REF", "PRESENT", "FINGERPRINT", "PERMISSION_RESULT", "BLOCKER", "VALUES_PRINTED"], rows)),
"",
`NEXT ${stringValue(next.webhookStatusFull)}`,
]);
}
export function renderMirrorWebhookStatus(result: Record<string, unknown>, disclosure: Record<string, unknown>): RenderedCliResult {
const repos = arrayRecords(result.repositories).map((repo) => {
const delivery = record(repo.selectedPushDelivery);
@@ -5,6 +5,33 @@ import { resolve } from "node:path";
const evaluator = resolve(import.meta.dir, "platform_infra_gitea_status_evaluator.py");
describe("Gitea source authority status evaluator", () => {
test("fails closed for GitHub hooks error objects and malformed lists", () => {
expect(evaluate({
action: "parse-github-hooks-response",
ok: false,
status: 403,
body: JSON.stringify({ message: "forbidden" }),
})).toEqual({ ok: false, status: 403, hooks: [], error: "github-hooks-http-403" });
expect(evaluate({
action: "parse-github-hooks-response",
ok: true,
status: 200,
body: JSON.stringify({ message: "not a list" }),
}).error).toBe("github-hooks-response-not-list");
expect(evaluate({
action: "parse-github-hooks-response",
ok: true,
status: 200,
body: JSON.stringify([{ id: 1 }, "malformed"]),
}).error).toBe("github-hooks-item-not-object");
});
test("reads GitHub hook URLs from object and string config shapes", () => {
expect(evaluate({ action: "github-hook-url", hook: { config: { url: "https://hooks.example/object" } } })).toBe("https://hooks.example/object");
expect(evaluate({ action: "github-hook-url", hook: { config: "https://hooks.example/string" } })).toBe("https://hooks.example/string");
expect(evaluate({ action: "github-hook-url", hook: "malformed" })).toBe("");
});
test("parses branch and exact snapshot from ls-remote output larger than the display tail budget", () => {
const head = "2".repeat(40);
const prefix = "refs/unidesk/snapshots/gitea-actions/unidesk-master-nc01";
+174 -47
View File
@@ -30,12 +30,14 @@ import {
renderMirrorStatus,
renderMirrorSync,
renderMirrorWebhookApply,
renderMirrorWebhookCredentialBlocked,
renderMirrorWebhookStatus,
renderPlan,
renderStatus,
} from "./platform-infra-gitea-render";
import {
GITEA_CONFIG_LABEL,
githubTokenEnvNameForSecretKey,
readGiteaConfig,
resolveTarget,
targetWebhookSync,
@@ -83,7 +85,7 @@ interface MirrorWebhookStatusOptions extends MirrorOptions {
interface MirrorSecrets {
adminUsername: string;
adminPassword: string;
githubToken: string;
githubTokens: Record<string, string>;
webhookSecret: string;
}
@@ -188,7 +190,10 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
const policy = policyChecks(gitea, target, manifest);
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 targetRepos = repositoriesForTarget(gitea, target);
const secrets = gitea.sourceAuthority.webhookSync.enabled && !options.dryRun
? ensureMirrorSecrets(gitea, target, targetRepos, true, true, true)
: undefined;
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);
@@ -294,6 +299,7 @@ async function mirrorWebhookCommand(config: UniDeskConfig, args: string[]): Prom
if (action === "status") {
const options = parseMirrorWebhookStatusOptions(args.slice(1));
const result = await mirrorWebhookStatus(config, options);
if (result.credentialBlocked === true) return options.full || options.raw ? result : renderMirrorWebhookCredentialBlocked(result);
if (options.raw) return rawMirrorWebhookStatus(result);
const disclosure = buildMirrorWebhookStatusDisclosure(result, options);
return options.full ? disclosure : renderMirrorWebhookStatus(result, disclosure);
@@ -316,7 +322,7 @@ function mirrorPlan(options: CommonOptions): Record<string, unknown> {
sourceAuthority: sourceAuthoritySummary(gitea, target),
responsibilities: gitea.sourceAuthority.responsibilities.map(responsibilitySummary),
repositories: repositoriesForTarget(gitea, target).map((repo) => repositorySummary(gitea, repo)),
credentials: credentialSummaries(gitea),
credentials: credentialSummaries(gitea, targetRepos),
next: mirrorReadOnlyNextCommands(target.id),
};
}
@@ -324,11 +330,26 @@ function mirrorPlan(options: CommonOptions): Record<string, unknown> {
async function mirrorStatus(config: UniDeskConfig, options: CommonOptions & { repoKey?: string | null }): Promise<Record<string, unknown>> {
const gitea = readGiteaConfig();
const target = resolveCommandTarget(gitea, options.targetId);
const selectedRepos = selectedRepositories(gitea, target, options.repoKey ?? null);
const credentials = credentialSummaries(gitea, selectedRepos);
const blockers = credentialBlockers(credentials);
if (blockers.length > 0) {
return {
ok: false,
action: "platform-infra-gitea-mirror-status",
mutation: false,
target: targetSummary(target),
serviceHealth: { ok: false },
sourceAuthority: { ...sourceAuthoritySummary(gitea, target), mirrorReady: false, stageReady: false },
repositories: selectedRepos.map((repo) => repositorySummary(gitea, repo)),
credentials,
error: { code: "github-credential-unavailable", blockers, valuesPrinted: false },
next: mirrorReadOnlyNextCommands(target.id),
};
}
const health = await validate(config, options);
const healthValidation = record(health.validation);
const credentials = credentialSummaries(gitea);
const secrets = ensureMirrorSecrets(gitea, false, false);
const selectedRepos = selectedRepositories(gitea, target, options.repoKey ?? null);
const secrets = ensureMirrorSecrets(gitea, target, selectedRepos, false, false, false);
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);
@@ -362,10 +383,8 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P
const target = resolveCommandTarget(gitea, options.targetId);
const repos = selectedRepositories(gitea, target, options.repoKey);
if (!options.confirm) {
const credentials = credentialSummaries(gitea);
const blockers = credentials
.filter((item) => item.id === "github-upstream" && (item.present !== true || item.requiredKeysPresent !== true))
.map((item) => String(item.id));
const credentials = credentialSummaries(gitea, repos);
const blockers = credentialBlockers(credentials);
const repoFlag = options.repoKey === null ? "" : ` --repo ${options.repoKey}`;
return {
ok: blockers.length === 0,
@@ -382,7 +401,7 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P
},
};
}
const secrets = ensureMirrorSecrets(gitea, true, true);
const secrets = ensureMirrorSecrets(gitea, target, repositoriesForTarget(gitea, target), true, true, true);
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 {
@@ -392,7 +411,7 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P
mode: "confirmed",
target: targetSummary(target),
repositories: arrayRecords(record(parsed).repositories),
credentials: credentialSummaries(gitea),
credentials: credentialSummaries(gitea, repos),
remote: parsed ?? compactCapture(result, { full: true }),
next: mirrorReadOnlyNextCommands(target.id),
};
@@ -414,7 +433,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 secrets = ensureMirrorSecrets(gitea, target, repos, false, false, false);
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 {
@@ -435,7 +454,7 @@ async function mirrorWebhookApply(config: UniDeskConfig, options: MirrorOptions)
if (!gitea.sourceAuthority.webhookSync.enabled) return { ok: false, action: "platform-infra-gitea-mirror-webhook-apply", mutation: false, error: "sourceAuthority.webhookSync.enabled is false" };
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 secrets = ensureMirrorSecrets(gitea, target, repos, false, false, false);
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 {
@@ -454,7 +473,10 @@ async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorWebhook
const gitea = readGiteaConfig();
const target = resolveCommandTarget(gitea, options.targetId);
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, false, false);
const credentials = credentialSummaries(gitea, repos);
const blockers = credentialBlockers(credentials);
if (blockers.length > 0) return buildMirrorWebhookCredentialBlockedResult(gitea, target, repos, credentials, blockers);
const secrets = ensureMirrorSecrets(gitea, target, repos, false, 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 }).script);
const parsed = parseJsonOutput(result.stdout);
const repositories = arrayRecords(record(parsed).repositories);
@@ -473,7 +495,28 @@ async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorWebhook
};
}
function renderManifest(gitea: GiteaConfig, target: GiteaTarget): string {
export function buildMirrorWebhookCredentialBlockedResult(
gitea: GiteaConfig,
target: GiteaTarget,
repos: GiteaMirrorRepository[],
credentials: Array<Record<string, unknown>>,
blockers: string[],
): Record<string, unknown> {
return {
ok: false,
action: "platform-infra-gitea-mirror-webhook-status",
mutation: false,
credentialBlocked: true,
target: targetSummary(target),
webhook: webhookSyncSummary(gitea, target),
repositories: repos.map((repo) => repositorySummary(gitea, repo)),
credentials,
error: { code: "github-credential-unavailable", blockers, valuesPrinted: false },
next: mirrorReadOnlyNextCommands(target.id),
};
}
export function renderManifest(gitea: GiteaConfig, target: GiteaTarget): string {
const app = gitea.app;
const image = `${app.image.repository}:${app.image.tag}`;
const labels = ` app.kubernetes.io/name: ${app.name}
@@ -649,6 +692,8 @@ function renderGithubSyncManifest(gitea: GiteaConfig, target: GiteaTarget): stri
const ownsHookReconcile = sync.hookReconcile.enabled && sync.hookReconcile.ownerTargetId.toLowerCase() === target.id.toLowerCase();
const hookTopologyJson = ownsHookReconcile ? JSON.stringify(githubHookTopology(gitea), null, 2) : "";
const hookReconcilerSource = ownsHookReconcile ? readFileSync(githubHookReconcilerFile, "utf8") : "";
const bridgeTokenEnv = githubTokenSecretEnv(gitea, target, false);
const hookTokenEnv = githubTokenSecretEnv(gitea, target, true);
const hookReconcilerConfig = ownsHookReconcile ? ` hook-topology.json: |
${indentBlock(hookTopologyJson, 4)}
platform_infra_gitea_hook_reconciler.py: |
@@ -670,11 +715,9 @@ ${indentBlock(hookReconcilerSource, 4)}
env:
- name: UNIDESK_GITEA_TARGET_ID
value: ${yamlQuote(target.id)}
- name: GITHUB_TOKEN
valueFrom:
secretKeyRef:
name: ${sync.bridge.secretName}
key: ${gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key}
- name: UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV
value: ${yamlQuote(githubTokenEnvName(gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key))}
${hookTokenEnv}
- name: GITHUB_WEBHOOK_SECRET
valueFrom:
secretKeyRef:
@@ -818,11 +861,9 @@ spec:
value: ${yamlQuote(String(sync.bridge.inbox.cleanupIntervalMs))}
- name: UNIDESK_GITEA_WEBHOOK_SHUTDOWN_GRACE_MS
value: ${yamlQuote(String(sync.bridge.shutdownGraceMs))}
- name: GITHUB_TOKEN
valueFrom:
secretKeyRef:
name: ${sync.bridge.secretName}
key: ${gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key}
- name: UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV
value: ${yamlQuote(githubTokenEnvName(gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key))}
${bridgeTokenEnv}
- name: GITEA_USERNAME
valueFrom:
secretKeyRef:
@@ -984,11 +1025,9 @@ ${hookRecoveryStateEnv} - name: UNIDESK_GITEA_WEBHOOK_INBOX_MAX_BYTES
value: ${yamlQuote(String(sync.bridge.inbox.cleanupIntervalMs))}
- name: UNIDESK_GITEA_WEBHOOK_SHUTDOWN_GRACE_MS
value: ${yamlQuote(String(sync.bridge.shutdownGraceMs))}
- name: GITHUB_TOKEN
valueFrom:
secretKeyRef:
name: ${sync.bridge.secretName}
key: ${gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key}
- name: UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV
value: ${yamlQuote(githubTokenEnvName(gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key))}
${bridgeTokenEnv}
- name: GITEA_USERNAME
valueFrom:
secretKeyRef:
@@ -1099,6 +1138,10 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
UNIDESK_GITEA_GITHUB_PROXY_ENABLED: gitea.sourceAuthority.githubProxy.enabled ? "1" : "0",
UNIDESK_GITEA_GITHUB_PROXY_URL: gitea.sourceAuthority.githubProxy.url,
UNIDESK_GITEA_GITHUB_SECRET_KEY: gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key,
UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV: githubTokenEnvName(gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key),
UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP: githubCredentialsForConsumers(gitea, target, repositoriesForTarget(gitea, target), true)
.map((credential) => `${credential.gitFetchCredential.secretRef.key}=${githubTokenEnvName(credential.gitFetchCredential.secretRef.key)}`)
.join(","),
UNIDESK_GITEA_NO_PROXY: gitea.sourceAuthority.githubProxy.noProxy.join(","),
UNIDESK_GITEA_FRPC_ENABLED: frpcExposure === null ? "0" : "1",
UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME: frpcExposure?.frpc.deploymentName ?? gitea.app.publicExposure.frpc.deploymentName,
@@ -1120,7 +1163,11 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
if (params.secrets !== undefined) {
env.UNIDESK_GITEA_ADMIN_USERNAME = params.secrets.adminUsername;
env.UNIDESK_GITEA_ADMIN_PASSWORD = params.secrets.adminPassword;
env.UNIDESK_GITEA_GITHUB_TOKEN = params.secrets.githubToken;
for (const [key, token] of Object.entries(params.secrets.githubTokens)) env[githubTokenEnvName(key)] = token;
env.UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP = Object.keys(params.secrets.githubTokens)
.sort()
.map((key) => `${key}=${githubTokenEnvName(key)}`)
.join(",");
env.UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET = params.secrets.webhookSecret;
}
const exports = Object.entries(env).map(([key, value]) => `export ${key}=${shQuote(value)}`).join("\n");
@@ -1445,7 +1492,14 @@ function githubHookTopology(gitea: GiteaConfig): Record<string, unknown> {
repoKeys: [...item.repoKeys].sort(),
branches: [...item.branches].sort(),
}));
return { repository, desiredUrls: desiredHooks.map((item) => item.url).sort(), desiredHooks };
const repo = gitea.sourceAuthority.repositories.find((item) => item.upstream.repository === repository);
const credential = repo === undefined ? gitea.sourceAuthority.credentials.github : githubCredentialForRepo(gitea, repo);
return {
repository,
githubTokenEnv: githubTokenEnvName(credential.gitFetchCredential.secretRef.key),
desiredUrls: desiredHooks.map((item) => item.url).sort(),
desiredHooks,
};
}),
};
}
@@ -1521,6 +1575,7 @@ function repositorySummary(gitea: GiteaConfig, repo: GiteaMirrorRepository): Rec
}
function remoteRepoSpec(repo: GiteaMirrorRepository): Record<string, unknown> {
const github = repo.credentialOverride?.github;
return {
key: repo.key,
upstream: repo.upstream,
@@ -1528,6 +1583,10 @@ function remoteRepoSpec(repo: GiteaMirrorRepository): Record<string, unknown> {
gitops: repo.gitops,
snapshot: repo.snapshot,
legacyGitMirror: repo.legacyGitMirror,
githubCredential: github === undefined ? null : {
secretKey: github.gitFetchCredential.secretRef.key,
tokenEnv: githubTokenEnvName(github.gitFetchCredential.secretRef.key),
},
};
}
@@ -1577,41 +1636,67 @@ function webhookSyncSummary(gitea: GiteaConfig, target: GiteaTarget): Record<str
};
}
function credentialSummaries(gitea: GiteaConfig): Array<Record<string, unknown>> {
function credentialSummaries(gitea: GiteaConfig, repositories = gitea.sourceAuthority.repositories): Array<Record<string, unknown>> {
const adminPath = credentialPath(gitea, gitea.sourceAuthority.credentials.admin.sourceRef);
const github = gitea.sourceAuthority.credentials.github;
const githubPath = credentialPath(gitea, github.sourceRef);
const adminLinePair = existsSync(adminPath) ? parseLinePairCredential(adminPath, gitea.sourceAuthority.credentials.admin) : { username: null, password: null };
const githubEnv = existsSync(githubPath) ? parseGithubCredentialFile(githubPath, github) : {};
return [
const summaries: Array<Record<string, unknown>> = [
{
id: "gitea-admin",
format: gitea.sourceAuthority.credentials.admin.format,
sourceRef: gitea.sourceAuthority.credentials.admin.sourceRef,
sourcePath: adminPath,
present: existsSync(adminPath),
requiredKeysPresent: Boolean(adminLinePair.username) && Boolean(adminLinePair.password),
fingerprint: fingerprintSecretParts([adminLinePair.username, adminLinePair.password]),
permissionResult: { status: "not-applicable", error: null },
valuesPrinted: false,
},
{
id: "github-upstream",
transport: github.transport,
sourceRef: github.sourceRef,
sourcePath: githubPath,
present: existsSync(githubPath),
requiredKeysPresent: Boolean(githubEnv[github.sourceKey]),
fingerprint: fingerprintKeys(githubEnv, [github.sourceKey]),
permissionResult: existsSync(githubPath) && Boolean(githubEnv[github.sourceKey])
? { status: "not-probed", error: null }
: { status: "blocked-missing-credential", error: "credential material is absent" },
valuesPrinted: false,
},
];
for (const repo of repositories) {
const githubOverride = repo.credentialOverride?.github;
if (githubOverride === undefined) continue;
const sourcePath = credentialPath(gitea, githubOverride.sourceRef);
const values = existsSync(sourcePath) ? parseGithubCredentialFile(sourcePath, githubOverride) : {};
summaries.push({
id: `github-upstream:${repo.key}`,
repository: repo.upstream.repository,
sourceRef: githubOverride.sourceRef,
present: existsSync(sourcePath),
requiredKeysPresent: Boolean(values[githubOverride.sourceKey]),
fingerprint: fingerprintKeys(values, [githubOverride.sourceKey]),
permissionResult: existsSync(sourcePath) && Boolean(values[githubOverride.sourceKey])
? { status: "not-probed", permissions: githubOverride.permissions, error: null }
: { status: "blocked-missing-credential", permissions: githubOverride.permissions, error: "credential material is absent" },
valuesPrinted: false,
});
}
return summaries;
}
function credentialBlockers(credentials: Array<Record<string, unknown>>): string[] {
return credentials.filter((item) => item.present !== true || item.requiredKeysPresent !== true).map((item) => String(item.id));
}
function ensureMirrorSecrets(gitea: GiteaConfig, createAdmin: boolean, createWebhookSecret: boolean): MirrorSecrets {
function ensureMirrorSecrets(
gitea: GiteaConfig,
target: GiteaTarget,
repositories: GiteaMirrorRepository[],
includeHookOwnerCredentials: boolean,
createAdmin: boolean,
createWebhookSecret: boolean,
): MirrorSecrets {
const admin = gitea.sourceAuthority.credentials.admin;
const adminPath = credentialPath(gitea, admin.sourceRef);
if (!existsSync(adminPath)) {
@@ -1623,17 +1708,59 @@ function ensureMirrorSecrets(gitea: GiteaConfig, createAdmin: boolean, createWeb
chmodSync(adminPath, 0o600);
}
const adminLinePair = parseLinePairCredential(adminPath, admin);
const github = gitea.sourceAuthority.credentials.github;
const githubPath = credentialPath(gitea, github.sourceRef);
if (!existsSync(githubPath)) throw new Error(`${github.sourceRef} is missing; cannot sync GitHub upstream`);
const githubEnv = parseGithubCredentialFile(githubPath, github);
const githubTokens: Record<string, string> = {};
for (const github of githubCredentialsForConsumers(gitea, target, repositories, includeHookOwnerCredentials)) {
const githubPath = credentialPath(gitea, github.sourceRef);
if (!existsSync(githubPath)) throw new Error(`${github.sourceRef} is missing; cannot sync GitHub upstream`);
const githubEnv = parseGithubCredentialFile(githubPath, github);
const githubToken = githubEnv[github.sourceKey];
if (!githubToken) throw new Error(`${github.sourceRef} must contain ${github.sourceKey}`);
githubTokens[github.gitFetchCredential.secretRef.key] = githubToken;
}
const webhookSecret = ensureWebhookSecret(gitea, createWebhookSecret);
const adminUsername = adminLinePair.username;
const adminPassword = adminLinePair.password;
const githubToken = githubEnv[github.sourceKey];
if (!adminUsername || !adminPassword) throw new Error(`${admin.sourceRef} must contain username on line ${admin.usernameLine} and password on line ${admin.passwordLine}`);
if (!githubToken) throw new Error(`${github.sourceRef} must contain ${github.sourceKey}`);
return { adminUsername, adminPassword, githubToken, webhookSecret };
return { adminUsername, adminPassword, githubTokens, webhookSecret };
}
function githubCredentialForRepo(gitea: GiteaConfig, repo: GiteaMirrorRepository): GiteaGithubCredential {
return repo.credentialOverride?.github ?? gitea.sourceAuthority.credentials.github;
}
function githubCredentialsForTarget(gitea: GiteaConfig, target: GiteaTarget): GiteaGithubCredential[] {
const credentials = [gitea.sourceAuthority.credentials.github, ...repositoriesForTarget(gitea, target).flatMap((repo) => repo.credentialOverride?.github ?? [])];
return [...new Map(credentials.map((credential) => [credential.gitFetchCredential.secretRef.key, credential])).values()];
}
function githubCredentialsForConsumers(
gitea: GiteaConfig,
target: GiteaTarget,
repositories: GiteaMirrorRepository[],
includeHookOwnerCredentials: boolean,
): GiteaGithubCredential[] {
const credentials = [gitea.sourceAuthority.credentials.github, ...repositories.flatMap((repo) => repo.credentialOverride?.github ?? [])];
if (includeHookOwnerCredentials
&& gitea.sourceAuthority.webhookSync.hookReconcile.ownerTargetId.toLowerCase() === target.id.toLowerCase()) {
credentials.push(...gitea.sourceAuthority.repositories.flatMap((repo) => {
const github = repo.credentialOverride?.github;
return github !== undefined && github.requiredFor.some((item) => item.startsWith("github-hooks-")) ? [github] : [];
}));
}
return [...new Map(credentials.map((credential) => [credential.gitFetchCredential.secretRef.key, credential])).values()];
}
function githubTokenEnvName(secretKey: string): string {
return githubTokenEnvNameForSecretKey(secretKey);
}
function githubTokenSecretEnv(gitea: GiteaConfig, target: GiteaTarget, includeHookOwnerCredentials: boolean): string {
const secretName = targetWebhookSync(gitea, target).bridge.secretName;
return githubCredentialsForConsumers(gitea, target, repositoriesForTarget(gitea, target), includeHookOwnerCredentials).map((credential) => ` - name: ${githubTokenEnvName(credential.gitFetchCredential.secretRef.key)}
valueFrom:
secretKeyRef:
name: ${secretName}
key: ${credential.gitFetchCredential.secretRef.key}`).join("\n");
}
function ensureWebhookSecret(gitea: GiteaConfig, createIfMissing: boolean): string {
@@ -347,7 +347,7 @@ test("AgentRun rendered delivery plan and real TaskRun terminals close the statu
test("provenance fixtures fail closed for pre-bootstrap, forged marker, wrong SA, and policy mismatch", () => {
const result = evaluator.runPacStatusFixtureChecks();
expect(result.ok).toBe(true);
for (const id of ["admission-provenance-verified", "pre-bootstrap-run-fails-closed", "forged-name-candidate-fails-closed", "forged-label-candidate-fails-closed", "forged-pipeline-ref-candidate-fails-closed", "wrong-service-account-fails-closed", "policy-identity-mismatch-fails-closed"]) {
for (const id of ["gitops-only-registry-not-configured", "image-registry-missing-fails-closed", "admission-provenance-verified", "pre-bootstrap-run-fails-closed", "forged-name-candidate-fails-closed", "forged-label-candidate-fails-closed", "forged-pipeline-ref-candidate-fails-closed", "wrong-service-account-fails-closed", "policy-identity-mismatch-fails-closed"]) {
expect(result.checks.find((item) => item.id === id)?.ok).toBe(true);
}
});
@@ -1297,8 +1297,9 @@ const runtimeSourceCommit = artifact.runtimeSourceCommit || (noRuntimeChange ? n
const tag = !noRuntimeChange && runtimeSourceCommit ? String(runtimeSourceCommit).slice(0, 12) : '';
const prefix = process.env.UNIDESK_PAC_CONSUMER_ID === 'unidesk-host' ? 'unidesk_host_' : '';
const param = (name) => params[`${prefix}${name}`] ?? params[name];
const imageRepository = typeof param('image_repository') === 'string' ? param('image_repository') : '';
const probeBase = typeof param('registry_probe_base') === 'string' ? param('registry_probe_base').replace(/\/+$/u, '') : '';
const registryConfigured = process.env.UNIDESK_PAC_REGISTRY_APPLICABILITY === 'configured';
const imageRepository = registryConfigured && typeof param('image_repository') === 'string' ? param('image_repository') : '';
const probeBase = registryConfigured && typeof param('registry_probe_base') === 'string' ? param('registry_probe_base').replace(/\/+$/u, '') : '';
let registryUrl = '';
if (imageRepository && tag) {
const firstSlash = imageRepository.indexOf('/');
@@ -1407,6 +1408,7 @@ const evaluated = evaluatePacStatus({
sourceCommit: process.env.UNIDESK_PAC_DIAG_SOURCE_COMMIT || latest.sourceCommit || null,
runtimeSourceCommit: process.env.UNIDESK_PAC_DIAG_RUNTIME_SOURCE_COMMIT || artifact.runtimeSourceCommit || null,
imageRepository: process.env.UNIDESK_PAC_DIAG_IMAGE_REPOSITORY || null,
registryApplicability: process.env.UNIDESK_PAC_REGISTRY_APPLICABILITY || 'not-configured',
registryProbeBase: process.env.UNIDESK_PAC_DIAG_REGISTRY_PROBE_BASE || null,
imageTag: process.env.UNIDESK_PAC_DIAG_IMAGE_TAG || null,
registryUrl: process.env.UNIDESK_PAC_DIAG_REGISTRY_URL || null,
@@ -1069,9 +1069,6 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC
const target = resolveTarget(pac, options.targetId ?? options.nodeId);
const nodeId = options.nodeId ?? target.id;
const consumers = pac.consumers.filter((consumer) => consumer.node.toLowerCase() === nodeId.toLowerCase());
if (consumers.length === 0) {
throw new Error(`no Pipelines-as-Code consumers are configured for node ${nodeId}; known nodes: ${Array.from(new Set(pac.consumers.map((item) => item.node))).sort().join(", ")}`);
}
const startedAt = Date.now();
const statuses = await Promise.all(consumers.map(async (consumer) => {
const repository = resolveRepository(pac, consumer.repositoryRef);
@@ -1101,8 +1098,9 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC
}
}));
const ready = statuses.every((row) => row.ready === true);
const configured = statuses.length > 0;
return {
ok: ready,
ok: configured && ready,
action: "cicd-node-status",
mutation: false,
node: nodeId,
@@ -1115,7 +1113,10 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC
},
consumers: statuses,
summary: {
ready,
ready: configured && ready,
status: configured ? (ready ? "ready" : "blocked") : "warning",
code: configured ? (ready ? "cicd-node-ready" : "cicd-node-blocked") : "cicd-node-no-consumers",
hint: configured ? null : `no Pipelines-as-Code consumers are configured for node ${nodeId}`,
total: statuses.length,
readyCount: statuses.filter((row) => row.ready === true).length,
blockedCount: statuses.filter((row) => row.ready !== true).length,
@@ -1420,6 +1421,7 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac
UNIDESK_PAC_WEBHOOK_SECRET_KEY: repository.webhookSecretKey,
UNIDESK_PAC_CONCURRENCY_LIMIT: String(repository.concurrencyLimit),
UNIDESK_PAC_PARAMS_JSON: JSON.stringify(repository.params),
UNIDESK_PAC_REGISTRY_APPLICABILITY: registryApplicability(consumer, repository),
UNIDESK_PAC_PIPELINE_NAME: consumer.pipeline,
UNIDESK_PAC_PIPELINE_RUN_PREFIX: consumer.pipelineRunPrefix,
UNIDESK_PAC_CONSUMER_CONFIG_B64: Buffer.from(JSON.stringify(consumerConfig), "utf8").toString("base64"),
@@ -1453,6 +1455,14 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac
return `${exports}\n${readFileSync(remoteScriptFile, "utf8")}`;
}
function registryApplicability(consumer: PacConsumer, repository: PacRepository): "configured" | "not-configured" {
const prefix = consumer.id === "unidesk-host" ? "unidesk_host_" : "";
const imageRepository = repository.params[`${prefix}image_repository`] ?? repository.params.image_repository;
if (typeof imageRepository !== "string" || imageRepository.length === 0) return "not-configured";
if (prefix.length > 0) return "configured";
return repository.params.pipeline_name === consumer.pipeline ? "configured" : "not-configured";
}
function historyConsumerConfig(pac: PacConfig, consumer: PacConsumer): Record<string, unknown> {
const repository = resolveRepository(pac, consumer.repositoryRef);
const admissionIdentity = pacAdmissionDesiredIdentity(consumer.node);
@@ -1738,6 +1748,7 @@ function nodeStatusRow(targetId: string, consumer: PacConsumer, repository: PacR
code: stringValue(diagnostics.code),
phase: stringValue(diagnostics.phase),
hint: compactLine(stringValue(diagnostics.hint)),
registry: record(diagnostics.registry),
},
traceId: stringValue(artifact.traceId),
firstBreak: record(artifact.firstBreak),
@@ -2840,6 +2851,7 @@ function envReuseText(envReuse: Record<string, unknown>): string {
function registryText(registry: Record<string, unknown>): string {
if (Object.keys(registry).length === 0) return "-";
if (registry.status === "not-configured" || registry.applicability === "not-configured") return "N/A";
const present = registry.present === true ? "present" : "missing";
const digest = short(stringValue(registry.digest), 18);
return digest === "-" ? present : `${present}:${digest}`;
@@ -602,7 +602,7 @@ def reconcile_repository(topology, repository_spec, token, secret, force_patch):
return observation, observed
def reconcile_once(topology, token, secret, force_patch):
def reconcile_once(topology, secret, force_patch):
started = time.monotonic()
topology_ready = True
recovery_ready = True
@@ -624,6 +624,7 @@ def reconcile_once(topology, token, secret, force_patch):
emit("github-hook-delivery-recovery-ledger-unavailable", ok=False, errorType=type(error).__name__, error=str(error)[:160])
for repository_spec in topology["repositories"]:
try:
token = os.environ[repository_spec["githubTokenEnv"]]
observation, observed_hooks = reconcile_repository(topology, repository_spec, token, secret, force_patch)
if not observation["ready"]:
topology_ready = False
@@ -675,13 +676,12 @@ def main():
topology = load_topology(sys.argv[1])
if topology.get("ownerTargetId") != os.environ.get("UNIDESK_GITEA_TARGET_ID"):
raise SystemExit("hook reconciler target is not the YAML owner")
token = os.environ["GITHUB_TOKEN"]
secret = os.environ["GITHUB_WEBHOOK_SECRET"]
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
force_patch = True
while not STOP.is_set():
if reconcile_once(topology, token, secret, force_patch):
if reconcile_once(topology, secret, force_patch):
force_patch = False
STOP.wait(topology["reconcile"]["intervalMs"] / 1000)
@@ -13,6 +13,31 @@ def parse_ls_remote_lines(lines):
return refs
def parse_github_hooks_response(ok, status, body):
if ok is not True:
return {"ok": False, "status": status, "hooks": [], "error": f"github-hooks-http-{status or 'unknown'}"}
try:
payload = json.loads(body or "[]")
except (TypeError, json.JSONDecodeError):
return {"ok": False, "status": status, "hooks": [], "error": "github-hooks-response-invalid-json"}
if not isinstance(payload, list):
return {"ok": False, "status": status, "hooks": [], "error": "github-hooks-response-not-list"}
if any(not isinstance(item, dict) for item in payload):
return {"ok": False, "status": status, "hooks": [], "error": "github-hooks-item-not-object"}
return {"ok": True, "status": status, "hooks": payload, "error": None}
def github_hook_url(item):
if not isinstance(item, dict):
return ""
config = item.get("config")
if isinstance(config, str):
return config
if isinstance(config, dict) and isinstance(config.get("url"), str):
return config["url"]
return ""
def select_committed_head_record(repo_key, github_head, records):
candidates = []
for record in records:
@@ -255,6 +280,10 @@ def main():
action = payload.get("action")
if action == "parse-ls-remote":
result = parse_ls_remote_lines(str(payload.get("output") or "").splitlines())
elif action == "parse-github-hooks-response":
result = parse_github_hooks_response(payload.get("ok"), payload.get("status"), payload.get("body"))
elif action == "github-hook-url":
result = github_hook_url(payload.get("hook"))
elif action == "select-committed-head-record":
result = select_committed_head_record(payload.get("repoKey"), payload.get("githubHead"), payload.get("records", []))
elif action == "select-target-delivery":
@@ -81,6 +81,14 @@ export interface SkillAvailabilityReport {
sourceSampledSkillNames: string[];
targetSampledSkillNames: string[];
};
capabilities: {
mdtodoSafeTitleStdin: MdtodoSafeTitleStdinCapability;
};
warnings: string[];
next: {
inspect: string;
safeTitleExample: string;
};
repairHint: string | null;
error: string | null;
valuesPrinted: false;
@@ -164,6 +172,10 @@ export interface SkillSyncPreflightReport {
sourceSampledSkillNames: string[];
targetSampledSkillNames: string[];
};
capabilities: {
mdtodoSafeTitleStdin: MdtodoSafeTitleStdinCapability;
};
warnings: string[];
missing: {
sourceSkills: string[];
targetSkills: string[];
@@ -185,10 +197,30 @@ export interface SkillSyncPreflightReport {
health: string;
runtimePreflight: string;
contractTest: string;
safeTitleExample: string;
};
valuesPrinted: false;
}
export interface MdtodoSafeTitleStdinProbe {
path: string;
exists: boolean;
commands: Record<"add" | "add-sub" | "update", boolean>;
ready: boolean;
identity: string | null;
}
export interface MdtodoSafeTitleStdinCapability {
name: "mdtodo-safe-title-stdin";
nonBlocking: true;
ready: boolean;
freshness: "ready" | "source-stale" | "target-stale" | "missing";
source: MdtodoSafeTitleStdinProbe;
target: MdtodoSafeTitleStdinProbe;
warning: string | null;
next: string;
}
export const defaultRequiredSkills = ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"];
export const defaultSource = "/home/ubuntu/.agents/skills";
export const expectedTarget = "/root/.agents/skills";
@@ -303,6 +335,64 @@ function skillSetVersion(path: string, readable: boolean): SkillSyncPathReport["
}
}
function mdtodoCliPath(skillsPath: string): string {
return resolve(skillsPath, "mdtodo-edit", "scripts", "mdtodo-edit-cli.py");
}
function probeMdtodoSafeTitleStdin(skillsPath: string): MdtodoSafeTitleStdinProbe {
const path = mdtodoCliPath(skillsPath);
const exists = existsSync(path);
const commands = { add: false, "add-sub": false, update: false };
if (exists) {
for (const command of Object.keys(commands) as Array<keyof typeof commands>) {
const result = spawnSync("python3", [path, command, "--help"], { encoding: "utf8", timeout: 2000 });
commands[command] = result.status === 0 && `${result.stdout ?? ""}\n${result.stderr ?? ""}`.includes("--stdin");
}
}
const ready = Object.values(commands).every(Boolean);
let identity: string | null = null;
if (exists) {
try {
identity = createHash("sha256").update(readFileSync(path)).digest("hex").slice(0, 16);
} catch {
identity = null;
}
}
return { path, exists, commands, ready, identity };
}
export function collectMdtodoSafeTitleStdinCapability(sourcePath: string, targetPath: string): MdtodoSafeTitleStdinCapability {
const source = probeMdtodoSafeTitleStdin(sourcePath);
const target = probeMdtodoSafeTitleStdin(targetPath);
const freshness = source.ready && target.ready
? "ready"
: source.ready
? "target-stale"
: source.exists || target.exists
? "source-stale"
: "missing";
const warning = freshness === "ready"
? null
: freshness === "target-stale"
? "MDTODO safe title stdin is available in the approved source but missing from the runner target projection."
: "MDTODO safe title stdin is missing from the approved source; update the managed agent_skills source before refreshing projections.";
return {
name: "mdtodo-safe-title-stdin",
nonBlocking: true,
ready: target.ready,
freshness,
source,
target,
warning,
next: "bun scripts/cli.ts codex skills-sync --dry-run --full",
};
}
export function renderMdtodoSafeTitleCommand(file: string, command: "add" | "add-sub" | "update", taskId?: string): string {
const positional = command === "add" ? shellQuote(file) : `${shellQuote(file)} ${shellQuote(taskId ?? "<taskId>")}`;
return `python3 "$HOME/.agents/skills/mdtodo-edit/scripts/mdtodo-edit-cli.py" ${command} ${positional} --stdin <<'EOF'\n<title>\nEOF`;
}
function accessProbe(path: string, mode: number, operation: string): { ok: boolean; failure: { path: string; operation: string; error: string } | null } {
try {
accessSync(path, mode);
@@ -450,6 +540,7 @@ export function collectSkillAvailability(options: SkillAvailabilityOptions): Ski
: targetSymlinkToSource ? "target-symlink" : "target";
const resolvedPath = target;
const selectedReport = targetProbe;
const mdtodoSafeTitleStdin = collectMdtodoSafeTitleStdinCapability(source, target);
const runnerUsable = !forbiddenPathConfigured && targetReady && !forbiddenPathExists;
const contractOk = runnerUsable;
const blocker = forbiddenPathConfigured
@@ -539,6 +630,12 @@ export function collectSkillAvailability(options: SkillAvailabilityOptions): Ski
sourceSampledSkillNames: sourceProbe.version.sampledSkillNames,
targetSampledSkillNames: targetProbe.version.sampledSkillNames,
},
capabilities: { mdtodoSafeTitleStdin },
warnings: mdtodoSafeTitleStdin.warning === null ? [] : [mdtodoSafeTitleStdin.warning],
next: {
inspect: mdtodoSafeTitleStdin.next,
safeTitleExample: renderMdtodoSafeTitleCommand("docs/MDTODO/example.md", "add"),
},
repairHint: contractOk
? null
: sourceHasRequiredSkills
@@ -561,6 +658,7 @@ export function collectSkillSyncPreflight(options: SkillSyncPreflightOptions = {
const forbiddenPathConfigured = forbiddenSourceConfigured || forbiddenTargetConfigured;
const forbiddenPathExists = forbiddenTargets.some((path) => existsSync(path));
const permissionFailures = [...source.permissionFailures, ...target.permissionFailures];
const mdtodoSafeTitleStdin = collectMdtodoSafeTitleStdinCapability(sourcePath, targetPath);
const blocker = forbiddenPathConfigured
? "forbidden-skills-path-configured"
: !source.report.approved
@@ -623,6 +721,8 @@ export function collectSkillSyncPreflight(options: SkillSyncPreflightOptions = {
sourceSampledSkillNames: source.report.version.sampledSkillNames,
targetSampledSkillNames: target.report.version.sampledSkillNames,
},
capabilities: { mdtodoSafeTitleStdin },
warnings: mdtodoSafeTitleStdin.warning === null ? [] : [mdtodoSafeTitleStdin.warning],
missing: {
sourceSkills: source.report.missingSkills,
targetSkills: target.report.missingSkills,
@@ -659,6 +759,7 @@ export function collectSkillSyncPreflight(options: SkillSyncPreflightOptions = {
health: "bun scripts/cli.ts microservice health code-queue",
runtimePreflight: "bun scripts/cli.ts codex pr-preflight --remote",
contractTest: "bun scripts/code-queue-runner-skills-contract-test.ts",
safeTitleExample: renderMdtodoSafeTitleCommand("docs/MDTODO/example.md", "add"),
},
valuesPrinted: false,
};