Merge remote-tracking branch 'origin/master' into feat/1923-cicd-otel-observability
This commit is contained in:
@@ -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 源码侧制品同步
|
||||
|
||||
|
||||
@@ -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 applicability;GitOps-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 pass,179 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 pass,skill 校验通过,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 pass;UniDesk 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 pass,9 assertions;语法检查 11/11;`git diff --check` 通过。
|
||||
- 本机 `/root/.agents/skills` 按 stash、快进、stash apply 做语义合并,原并行修改与保护 stash 均保留。
|
||||
- 安装 HEAD 与 `origin/master` 同为 `2dfe24b`,CLI SHA-256 为 `16e9c7aacc97fc80` 前缀。
|
||||
- MDTODO 全量测试 207 pass,skill 校验通过。
|
||||
- 临时真实 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 任务报告
|
||||
|
||||
## 任务回链
|
||||
|
||||
- MDTODO:R1.1。
|
||||
- Target:NC01。
|
||||
- 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 key;token 仅通过环境变量进入受控执行,不进入 YAML、Git、日志或报告。
|
||||
- bridge、candidate、受控 fetch、GitHub head、hooks list/status 和 hook reconciler 均按 repository 选择 credential。
|
||||
- bridge/candidate 只注入所属 Target 仓库 token;hook owner reconciler 额外注入所有声明 `github-hooks-*` 的 override;JD01 不依赖 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、不接触或打印 token;compact/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 ready,runner automount disabled,Argo 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 问题不在本任务范围。
|
||||
@@ -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 #1913(https://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)。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user