Merge remote-tracking branch 'origin/master' into fix/web-probe-memory-guard

This commit is contained in:
AgentRun Codex
2026-07-13 07:47:04 +00:00
24 changed files with 1581 additions and 37 deletions
+22 -1
View File
@@ -1,6 +1,13 @@
---
name: unidesk-cicd
description: UniDesk CI/CD 控制面 — `cicd branch-follower` 退役只读诊断、`hwlab g14``hwlab nodes control-plane``agentrun` 子命令,覆盖 PR 监控自动合并、Tekton/Argo 控制面、git-mirror、Secret、observability、CI tools image、PipelineRun 清理、AgentRun v0.1 部署和 AgentRun YAML-only lane 部署。用户提到 CI/CD、deploy、rollout、PipelineRun、trigger、git-mirror、control-plane、k8s/k3s 部署、branch follower、自动跟随、agentrun 部署、hwlab g14、monitor-prs、trigger-current 时使用。任何需要把代码变更推送部署到 G14 k3s 的操作都必须走本 skill。
description: >-
UniDesk CI/CD 控制面,覆盖 PaC consumer 首发 bootstrap、Tekton/Argo、GitOps、
git-mirror、PR 自动交付、Secret、observability、CI tools image、PipelineRun 清理、
AgentRun 与 HWLAB 部署,以及 branch-follower 退役只读诊断。
用户提到 CI/CD、deploy、rollout、PipelineRun、PaC、bootstrap、GitOps、Tekton、
git-mirror、control-plane、k8s/k3s 部署、branch follower、agentrun、hwlab g14、
monitor-prs 或 trigger-current 时使用。
任何需要把代码变更推送部署到 G14 k3s 的操作都必须走本 skill。
---
# UniDesk CI/CD
@@ -19,6 +26,8 @@ bun scripts/cli.ts hwlab g14 git-mirror status --lane v02
bun scripts/cli.ts agentrun control-plane status
bun scripts/cli.ts platform-infra gitea mirror status --target JD01
bun scripts/cli.ts platform-infra gitea mirror webhook status --target JD01
bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target NC01 --consumer <consumer> --dry-run
bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target NC01 --consumer <consumer> --confirm
bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01
bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 --consumer hwlab-jd01-v03
bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10
@@ -33,6 +42,14 @@ 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。
- 新增 PaC consumer 的首次引导:
- 先执行一次 `pipelines-as-code bootstrap --dry-run`
- 确认后把同一命令改为 `--confirm`
- 入口从 Gitea 与 PaC owning YAML 精确解析仓库;
- 入口只初始化空 Gitea 仓库和 PaC/Tekton/GitOps 控制面;
- 随后合并 source PR,作为唯一交付触发;
- 详细最短路径见 [references/gitea-pac.md](references/gitea-pac.md)。
按职责读取拆分后的 reference:
- PR monitor 与自动合并: [references/pr-monitor.md](references/pr-monitor.md)。
@@ -47,6 +64,10 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help
## P0 边界
- PaC migrated consumer 的唯一正式 CI/CD 触发是目标 source branch 的 GitHub PR merge。合并后由 GitHub webhook -> Gitea controlled mirror + immutable snapshot ref -> Gitea webhook -> PaC -> Tekton -> GitOps/Argo -> runtime 自动滚动;不得要求主代理、子代理或操作者再执行 apply、closeout、trigger、sync、flush 或补跑。
- PaC consumer 首次引导必须满足以下约束:
- 默认使用 `pipelines-as-code bootstrap --dry-run|--confirm`,不再人工串联 Gitea bootstrap 与 PaC apply
- `apply` 必须在任何 release、RBAC、Secret、Repository CR、Argo 或 webhook 写入前确认 YAML 匹配的 Gitea 仓库已存在;
- source PR 合并后不得再次调用 bootstrap 或 apply;正常验收不串联 mirror/status/history,只有失败归因才逐层下钻。
- 自动链路不通时必须修复自动链自身,并通过修复 PR 合并产生的新正常事件验收。禁止人工 mirror sync、直接 Gitea push、人工 PipelineRun、`trigger-current`、运行面 patch 或其他手段补齐当前交付;只读 status/history/events/logs/debug-step 可用于定位,但不得改变交付状态。`closeout` 只属于显式 compatibility diagnostics,不得进入默认观察或恢复路径。
- CI/CD、GitOps、rollout、PipelineRun、Argo、git-mirror 和 AgentRun 的观察、诊断与 legacy 控制必须走受控 CLI;不要用裸 `kubectl``argo``tkn``curl` 当正式控制入口。PaC migrated consumer 仍只由 GitHub PR merge 触发,不得因“必须走 CLI”而额外调用写命令。
- CI/CD source authority 只能来自 YAML 声明的 Kubernetes 托管 source authority
@@ -44,6 +44,41 @@ GitHub 是唯一上游写入权威。目标 source branch 的 GitHub PR merge
- PaC Repository CR `spec.url` 必须与 Gitea webhook payload 中的 URL 一致。`config/platform-infra/pipelines-as-code.yaml#repositories[].url` 保持公网 Gitea repository URLk8s 内网 service URL 只能写入 `cloneUrl``params.git_read_url`;否则 PaC 会记录 `cannot find a repository match` 且不会创建 PipelineRun。
- JD01 与 NC01 共用的 repository 必须传入 target-specific `node` Repository param,每个 `.tekton` PipelineRun 必须用 `pipelinesascode.tekton.dev/on-cel-expression` 过滤该参数。一个 target 的 push 不得在同一 target cluster 创建另一 target 的 PipelineRun。
## PaC consumer 首发最短路径
- 在 source PR 合并前完成源码侧准备:
- owning YAML 中声明 Gitea repository、PaC repository 与 consumer
- consumer 声明 `sourceArtifact` 时,先在 source worktree 执行 `source-artifact check`
- 不创建人工 PipelineRun,也不推送 Gitea branch 或 snapshot。
- 只使用同一个组合入口完成控制面首次引导:
```bash
bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target <NODE> --consumer <consumer> --dry-run
bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target <NODE> --consumer <consumer> --confirm
```
- 组合入口负责以下职责:
- 组合 `config/platform-infra/gitea.yaml``config/platform-infra/pipelines-as-code.yaml`
- 按 target、Gitea owner/name 与 read URL 精确选择唯一 repository
- 初始化选中的空 Gitea repository
- 安装或更新 PaC controller、consumer RBAC、Repository CR、Argo bootstrap 与 Gitea webhook
- 不同步 source branch,不创建 snapshot,不触发业务 PipelineRun。
- 预检与失败边界:
- PaC `apply` 在任何 Kubernetes 写入前读取 Gitea repository
- repository 缺失时返回 `gitea-repository-missing`,不留下半套 Tekton/RBAC/Argo 状态;
- YAML 零匹配或多匹配时在本地拒绝,不猜测 repository
- `gitea mirror bootstrap` 未带 `--confirm` 时输出可读 dry-run 与精确 confirm 命令,不再返回空白拒绝。
- 完成 `bootstrap --confirm` 后合并 source PR
- GitHub PR merge 是唯一 delivery 触发;
- 不再调用 bootstrap、apply、mirror sync、PipelineRun、Argo refresh 或 closeout
- 正常链路不串联 mirror status、webhook status、PaC status 与 history。
- 只有出现自动链故障或用户明确要求验收时才读取状态:
- 首选一次 `cicd status --node <NODE>` 获取 node 摘要;
- 只对失败 consumer 使用一次 `pipelines-as-code status --consumer <id>`
- 只有归属或 TaskRun 断点不清楚时再用 `history --id``debug-step --id`
- 默认不使用 `--full``--raw`,避免把大对象带回本机;
- consumer 声明 `sourceArtifact` 且需要精确提交验收时,最后执行一次 `source-artifact verify-runtime`
## PaC 源码侧制品同步
当 owning Pipeline 与消费仓 `.tekton` 内联 `pipelineSpec` 或远程 Pipeline 文件存在漂移时,统一使用以下入口:
+23 -7
View File
@@ -21,9 +21,24 @@ bun scripts/cli.ts agentrun result run/<runId> --command <commandId>
bun scripts/cli.ts agentrun get attempts --task <taskId>
bun scripts/cli.ts agentrun retry task/<taskId> --idempotency-key <key> --reason <text> --dry-run
bun scripts/cli.ts agentrun send <session> --prompt-stdin
bun scripts/cli.ts agentrun create task \
--aipod Artificer \
--target <Target> \
--target-workspace <absolute-task-worktree> \
--repo <owner/repo> \
--ref <branch> \
--mdtodo-id <R6.3> \
--prompt-stdin \
--dry-run
```
资源命令未传 `--node/--lane` 时固定使用 `config/agentrun.yaml#controlPlane.default`,当前为 `NC01/nc01-v02`;只有查询非默认 lane 时才显式传目标参数。主机 direct HTTP auth 只保留独立诊断,固定 Secret 文件路径为 `/root/.unidesk/.env/agentrun.env`,不得回退到 worktree 或旧 `/root/.config/hwlab-*` 路径。
资源命令的 Target 规则:
- 未传 `--target``--node``--lane` 时,使用 `config/agentrun.yaml#controlPlane.default`
- `client.targetExecution` 决定外层 `trans` 重入的 marker、CLI 路径和目标 UniDesk 工作区;
- 代码不得补 Target 路径默认值;
- 主机 direct HTTP auth 只保留独立诊断;
- Secret 固定读取 `/root/.unidesk/.env/agentrun.env`,不得回退到 worktree 或旧 `/root/.config/hwlab-*` 路径。
AipodSpec/Artificer、task manifest 和 queue 渐进披露见 [references/resources.md](references/resources.md)session 控制、HWLAB Code Agent 整合、trace/output 分页、judge、中断/取消见 [references/sessions.md](references/sessions.md);旧队列归档见 [references/legacy.md](references/legacy.md)。
@@ -31,12 +46,13 @@ AipodSpec/Artificer、task manifest 和 queue 渐进披露见 [references/resour
- AgentRun 与调用方之间的契约版本、源码/镜像版本和 `commitId` 对齐只能产生非阻塞 warning;可安全归一化的请求继续 admission/dispatch,不得因版本或契约漂移阻止用户任务、session、run、command 或 runner。
- 新写入口不要用 `codex submit/steer/resume`;这些 legacy mutation 已冻结。
- Artificer 的 primary source workspace 与远端 tool target 必须分开判定:
- task prompt 引用待修改源码时,默认写 primary workspace `.`,需要子目录时写相对路径
- 禁止把宿主 `/root/unidesk`、主代理 worktree 或其他 task 外部绝对路径当成 source workspace
- 用户明确授权的 `trans <route> ...` 远端范围属于 `unidesk-ssh` tool target,不是旁路 source workspace
- 访问远端 tool target 必须使用已投影的受控 `trans`,并继续遵守 route allowlist、SecretRef、脱敏和短连接边界
- primary source 路径不可见时,先用 `agentrun events run/<runId> --detail-seq <seq>` 核对 durable workspace 物化事实,不让 Artificer 搜索或切换到旁路仓库。
- Artificer 的 runner primary workspace 与任务 Target 必须分开判定:
- runner primary workspace 只装配默认 UniDesk resource bundle、`tools/trans`、skill 和轻量准备
- 待修改源码只来自派单上下文中的 `targetWorkspace`,且该路径必须是任务专属 worktree
- 现场探测、Git、编辑、验证和运行面观察必须使用 `trans <Target>:<targetWorkspace>`
- 不得把 runner 本地结果、默认 bundle 的 Git 状态或容器内同名路径当作 Target 验收
- `unidesk-trans` required skill、runner tools bundle、`unidesk-ssh` SecretRef 和 endpoint presence 缺一项即归为装配失败;
- Secret 输出只显示对象、key、presence/fingerprint 与 `valuesPrinted=false`
- 默认输出保持低噪声表格/摘要;机器消费显式 `-o json|yaml``--raw`
- 读取 task 的可重建输入:
- 使用 `agentrun describe task/<taskId> --input -o json`
@@ -11,6 +11,50 @@
- `agentrun retry task/<taskId> --idempotency-key <key> --reason <text> [--dry-run]`:对终态失败 task 创建下一次 attempt
- `agentrun dispatch|ack|cancel`:控制生命周期。
## Artificer Target 单命令派单
标准入口:
```bash
bun scripts/cli.ts agentrun create task \
--aipod Artificer \
--target <Target> \
--target-workspace <absolute-task-worktree> \
--repo <owner/repo> \
--ref <branch> \
--mdtodo-id <R6.3> \
--prompt-stdin \
--dry-run
```
- 先用同一命令保留 `--dry-run` 查看完整计划;
- dry-run 与 live 都必须先经 owning YAML 选中的 Target `trans` 重入;
- preflight 必须验证:
- task worktree 是 Git 根目录且 clean
- origin 与 `--repo` 一致;
- current branch 与 `--ref` 一致;
- HEAD 与 origin 远端 ref commit 一致;
- preflight 输出必须披露:
- Target route、repo/ref、projectId、HEAD/remote 关系;
- 合法的 `workspaceRef.kind/path`
- 从 AipodSpec 继承的默认 resource bundle
- session identity
- provider/tool SecretRef 的对象、key 与 presence
- preflight 失败固定 `mutation=false`,不得创建 task
- live 在 submit 成功后自动 dispatch,不要求用户再运行第二条命令;
- 未显式传 `--idempotency-key` 时,由 Target/repo/ref/MDTODO/verified commit 派生稳定键;
- 幂等重试遇到已有 running 或 terminal task 时,返回既有 attempt/run/session,不重复 dispatch
- 最终输出必须包含 task、attempt、run、command、runner 和 session identity。
Artificer 内部执行边界:
- primary workspace 只承载默认 UniDesk bundle、`tools/trans` 和 required skills
- 任务源码操作全部使用 `trans <Target>:<targetWorkspace>`
- `targetWorkspace` 必须是任务专属 worktree
- runner 本地结果不得替代 Target 原入口证据;
- `unidesk-trans`、runner tools bundle、`unidesk-ssh` SecretRef 和 endpoint presence 必须同时存在;
- Secret 值不得进入 prompt、task、日志或报告。
`agentrun events` 是高频渐进披露入口:
- 默认 human、`-o json``-o yaml` 最多返回 20 条同构事件摘要,并用一条 lookahead 披露 `hasMore``nextAfterSeq`
+10
View File
@@ -16,6 +16,15 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子
- 现有机制阻碍原始目标时,先寻找既有架构内的最小实现路径;确需架构扩展时,必须向用户说明与原始目标的关系并取得明确授权。
- 执行型和调研型委派默认优先使用 AgentRun `Artificer`
- Artificer 的 `create task``apply``dispatch` 必须以下文“子 issue + MDTODO”登记为 fail-closed 前置;禁止先创建 task 或派单再补登记;
- 原生子代理和 Artificer 每次派单前都必须已有对应 MDTODO ITEM 或 SUBITEM;派单 prompt 与最终报告必须写明同一个 MDTODO ID;
- Artificer 的 `create task` 外层入口:
- 必须按 owning YAML 选中 Target
- 必须通过 `trans <Target>:<unideskWorkspace>` 重入目标节点;
- 预检、创建、自动派发和观察都在重入后完成;
- Artificer 的任务 Target 操作必须继续使用 `trans <Target>:<targetWorkspace>`
- `targetWorkspace` 必须是任务专属 worktree
- 现场探测、Git、编辑、验证和运行面观察都不得在 runner primary workspace 冒充完成;
- runner primary workspace 只承载默认 UniDesk resource bundle、工具、skill 和轻量准备;
- 未显式选模时,不由主代理注入客户端模型默认值;使用 owning AipodSpec 的默认模型,当前为 `gpt-5.6-sol`,默认 reasoning effort 为 `medium`
- Artificer 的 API credential
- 只允许由 UniDesk owning YAML 将 `/root/.codex/auth.json.pika``/root/.codex/config.toml.pika` 投影为 `gpt-pika` SecretRef
@@ -42,6 +51,7 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子
- 创建子 issue,把主要任务、接续上下文、目标分支/worktree、范围、禁止项和验收入口写入正文;
- 使用 `$mdtodo-edit` 在对应泛化问题域的 MDTODO ITEM 或 SUBITEM 中登记子 issue 链接,并把任务标记为进行中;不得为单个 issue 创建窄范围 MDTODO FILE
- 任一登记缺失或写入失败时不得创建 AgentRun task;派单后补写 MDTODO 不计为合规登记。
- 派单 prompt 必须写入 MDTODO ITEM/SUBITEM ID;子代理的阶段报告、PR 说明和最终报告必须回链同一 ID。
- 子代理 prompt 只给子 issue 链接和极短边界,不塞大段任务正文。每个执行型子代理维护自己的子 issue 和评论作为接续上下文;主 issue 评论区只由主代理写入主线 anchor、阶段汇总、调度决策和最终 closeout。
- 子代理不得把过程日志、单步证据、长调查和 post-task 反馈直接堆到主 issue 评论区,只能在主 issue 需要可见时由主代理引用子 issue/PR/comment 链接。
- 主代理跟踪子代理进度时优先读取子 issue 评论区、关联 PR 更新和 bounded issue/PR 状态;不要为了普通进度查询主动 `send_input interrupt` 打断子代理主线。只有子 issue/PR 长时间无更新且只读 worktree 也无推进时,才进入问询、关闭和重开流程。
+6 -1
View File
@@ -28,7 +28,11 @@ auth:
scheme: Bearer
client:
role: render-only
transport: direct-http
transport: target-trans
targetExecution:
markerEnv: UNIDESK_AGENTRUN_TARGET_EXECUTED
executable: bun
cliPath: scripts/cli.ts
sessionPolicy:
tenantId: unidesk
projectId: default
@@ -52,6 +56,7 @@ controlPlane:
NC01:
route: NC01
kubeRoute: NC01:k3s
unideskWorkspace: /root/unidesk
lanes:
nc01-v02:
deployment:
+57 -4
View File
@@ -216,7 +216,45 @@ AgentRun 的产品边界、REST resource/API 语义、run/command/event/session/
AgentRun 指挥官任务面已经按 AgentRun issue #105 完成真实运行面验收,可作为新任务派发、commander queue 观察、events/logs/result、steer/send、ack 和 cancel 的 AgentRun 侧标准路径。长期能力规格以 UniDesk OA 的 [队列会话](../../project-management/PJ2026-01/specs/PJ2026-010203-queue-session.md) 和 [AgentRun核心](../../project-management/PJ2026-01/specs/PJ2026-010201-agentrun-core.md) 为准;UniDesk 只记录该路径以 NC01 `agentrun-v02` 运行面和 YAML profile 为准。
UniDesk 指挥官新任务入口固定使用 `bun scripts/cli.ts agentrun get|describe|events|logs|result|retry|ack|cancel|dispatch|create|apply|steer|send` 资源原语。该入口是 render-only clientUniDesk 客户端保留 k8s 风格命令解析、human 表格、生命周期摘要、下一步命令、分页、`-o json|yaml` 稳定客户端 schema 和错误展示;AgentRun 服务端只提供稳定 RESTful API、鉴权和业务事实,不承载 UniDesk CLI 渲染。日常派单优先用 `agentrun create task --aipod Artificer --prompt-stdin``agentrun apply -f -` 的 quoted YAML/JSON heredoc/stdin 形式;已创建未运行任务用 `agentrun dispatch task/<taskId>` 派发;`--json-file``--prompt-file``--runner-json-file` 只是客户端输入来源,用于已审阅且可复用的受控文件。UniDesk 不实现 AgentRun queue 协议,也不把任务 double-write 回旧 Code Queue。
UniDesk 指挥官新任务入口固定使用 `bun scripts/cli.ts agentrun get|describe|events|logs|result|retry|ack|cancel|dispatch|create|apply|send` 资源原语
- UniDesk 客户端负责:
- k8s 风格命令解析;
- human 表格、生命周期摘要和下一步命令;
- 分页、`-o json|yaml` 稳定 schema 和错误展示;
- AgentRun 服务端只提供 RESTful API、鉴权和业务事实;
- 通用 task manifest 继续使用 `agentrun apply -f -`
- Artificer Target 派单使用单命令:
```bash
bun scripts/cli.ts agentrun create task \
--aipod Artificer \
--target <Target> \
--target-workspace <absolute-task-worktree> \
--repo <owner/repo> \
--ref <branch> \
--mdtodo-id <R6.3> \
--prompt-stdin \
--dry-run
```
- dry-run 与 live 都先经 Target `trans` 重入并执行 source/worktree/Aipod/SecretRef 预检;
- live 的 submit 成功后自动 dispatch
- 幂等重试不得重复创建 attempt 或重复 dispatch
- UniDesk 不实现 AgentRun queue 协议,也不把 task double-write 回旧 Code Queue。
Artificer Target preflight 必须在 task 创建前完成:
- `targetWorkspace` 是任务专属 Git worktree 根目录;
- worktree 必须 clean
- origin 与 `--repo` 一致;
- current branch 与 `--ref` 一致;
- HEAD 与 origin 远端 ref commit 一致;
- AipodSpec 渲染出的 `workspaceRef` 只允许 `kind/path`
- 默认 UniDesk resource bundle 保持继承,不把私有目标 repo 改成 runner primary bundle
- 输出包含 projectId、session identity、SecretRef presence 和 HEAD/remote 关系;
- 失败固定 `mutation=false`,且不创建 task
- 成功返回 task、attempt、run、command、runner 和 session identity。
Queue 终态失败重试使用正式资源入口:
@@ -235,18 +273,33 @@ retry 保持 task identity 不变,服务端为每次执行保留不可变 atte
使用 lane-scoped AipodSpec 派单前,必须通过 `get/describe aipodspec`、render 输出或首个 runner job 摘要确认 `backendProfile`、provider credential SecretRef、tool credential SecretRef 和 bundle/workspaceRef 都存在于选中 lane 的 YAML 事实中。NC01/nc01-v02 的 Artificer 默认装配应从 lane YAML 绑定真实存在的 provider credential 和 tool credentialGitHub PR token 用 `tool=github``purpose=github-pr`、Secret key/projection env `GH_TOKEN`UniDesk 透传 token 用 `tool=unidesk-ssh``purpose=ssh-passthrough`、Secret key/projection env `UNIDESK_SSH_CLIENT_TOKEN``tool=github-ssh``sub2api` 或其他 legacy tool credential 只有在 YAML 明确声明完整 SecretRef、keys 和 projection 时才允许渲染。若 runner Pod 出现 `FailedMount`,且缺失对象是渲染出的 SecretRef,应归为 AipodSpec/YAML 绑定问题并修正受控配置;不得在 runtime namespace 手工创建 legacy Secret 或把其他 lane 的 Secret 复制过去。AipodSpec render 的默认输出也应是 bounded summary/table/drill-down;完整 render JSON 只在显式 `--full``--raw``-o json` 或机器消费路径展开,残余 dump 问题继续归 [#862](https://github.com/pikasTech/unidesk/issues/862) 跟踪。
资源原语和旧兼容 group 的默认 transport 是直连 AgentRun REST API,配置来源是 UniDesk 自有 YAML `config/agentrun.yaml`。不带 `--node`/`--lane` 时按 YAML 的默认 manager `baseUrl` 访问;显式 `--node <node> --lane <lane>` 时按同一 YAML 选中 runtime lane,经 `lane-k8s-service-proxy` 进入 manager `internalBaseUrl`,并用 manager pod env 中声明的 API key metadata 发起请求;输出只披露 node/lane/namespace/baseUrl/auth env metadata 和 `valuesPrinted=false`,不得打印 key value。该模式用于 NC01 `agentrun-v02` lane 的资源原语操作与证据采集,尤其是 `get/describe/events/logs/result`,不替代 `agentrun control-plane ...` 发布或运维控制。鉴权可以复用 `HWLAB_API_KEY` 的环境变量/固定文件发现风格,但不得依赖 HWLAB runtime、HWLAB backend-core、HWLAB frontend 代理或 SSH official CLI;多一层转发会增加故障面,不能作为正式路径。`--raw` 只披露直连 AgentRun REST envelope 和必要的 `transport=direct-http``clientRole=render-only``configPath``baseUrl`、auth source/redacted metadata,不打印 token value。`agentrun control-plane ...``git-mirror ...` 仍属于 NC01 source/runtime 运维控制路径,可以继续使用 UniDesk SSH capture bridge;这些控制面路径不得反向成为 queue/session 资源原语的默认 transport。
AgentRun 资源原语的默认执行路径由 UniDesk `config/agentrun.yaml` 唯一控制:
- `client.transport=target-trans` 时:
- 外层 CLI 从 `controlPlane.default` 或显式 `--target` / `--node` / `--lane` 解析 Target
- 外层通过 `trans <Target>:<unideskWorkspace>` 重入目标节点;
- marker、可执行文件和 CLI 相对路径来自 `client.targetExecution`
- Target 内部按选中 lane 使用 `lane-k8s-service-proxy` 进入 manager `internalBaseUrl`
- `controlPlane.nodes.<Target>.unideskWorkspace` 是 Target 上的 UniDesk 受控入口;
- 代码不得补主机名、绝对路径或第二条 transport 默认值;
- dry-run 也必须走同一 Target 重入路径,不能读取调用端同名目录冒充 Target 证据;
- 输出只披露 Target/lane/namespace、SecretRef、presence/fingerprint 和 `valuesPrinted=false`
- 旧兼容 group 的 direct HTTP 只保留独立诊断,不是 Artificer 派单路径;
- `agentrun control-plane ...``git-mirror ...` 仍属于 source/runtime 运维控制路径。
AgentRun 公网 HTTPS 入口、FRP/Caddy edge、direct REST base URL 和鉴权来源都由 UniDesk `config/agentrun.yaml` 声明。YAML-only lane 不允许把这些部署选择写回 AgentRun source branch 的 `deploy/deploy.json`AgentRun source repo 只保留应用代码、构建输入和 repo 内部实现文档。`bun scripts/cli.ts agentrun control-plane expose --confirm` 只负责按 UniDesk YAML 补 edge 侧 allow port 与 Caddy site,不在 AgentRun k3s 中创建 Ingress、NodePort、LoadBalancer、hostPort 或 HWLAB 转发层。
AgentRun Queue 任务调用 UniDesk 维护桥时,长期契约以 UniDesk OA 的 [Runtime装配](../../project-management/PJ2026-01/specs/PJ2026-010202-runtime-assembly.md) 和 [YAML运维](../../project-management/PJ2026-01/specs/PJ2026-010603-yaml-first-ops.md) 为准:
- workspace 与 tool target 边界:
- 待修改源码只来自 task 的 primary workspace
- 用户明确授权的 `trans <route> ...` 远端范围属于 `unidesk-ssh` tool target,不属于旁路 source workspace
- runner primary workspace 只承载默认 UniDesk resource bundle、工具、skill 和轻量准备
- 待修改源码只来自派单上下文中的 Target task worktree
- 现场探测、Git、编辑、验证和运行面观察都使用 `trans <Target>:<targetWorkspace>`
- runner 本地执行不得冒充 Target 原入口证据;
- runner 的 `tran` 必须委派 primary UniDesk workspace 中的共享 `scripts/tran`,不得在 AgentRun 维护第二套 Windows、host 或 k3s route parser。
- credential 与 endpoint
- 调用方通过 `executionPolicy.secretScope.toolCredentials[].tool=unidesk-ssh` 请求 `UNIDESK_SSH_CLIENT_TOKEN` SecretRef
- AipodSpec 必须同时 required `unidesk-trans`,并装配包含 `tools/trans` 的 runner tools bundle
- 非敏感 endpoint 由 runner-job `transientEnv` 显式提供,或由 manager 受控默认值自动补齐;
- UniDesk bridge 提交 Queue payload 时不得在 prompt、payload 或 `transientEnv` 中携带 token,也不得使用 HWLAB runtime Web 入口冒充 UniDesk frontend。
- 失败归因:
+16 -2
View File
@@ -220,9 +220,23 @@ PipelineRun 失败或长时间未完成时,先按定点 `control-plane status
- `ci install|install-status|status|run|publish-backend-core|publish-user-service|run-dev-e2e|logs` 管理 D601 原生 k3s 上的 Tekton CI。`install` 默认创建 `.state/jobs` 异步 job 并立即返回,`install-status <jobId|latest>` 读取阶段化 progress 和 bounded log tail;只有现场同步调试才显式加 `--wait``run` 手动创建每 commit 检查和 Code Queue 只读性能门禁;`publish-backend-core``publish-user-service` 从 pushed Git commit 构建并发布 `127.0.0.1:5000/unidesk/<service>:<commit>` commit-pinned artifacts,输出 `artifactSummary`(含 `serviceId``sourceCommit``sourceRepo``dockerfile``imageRef``tag``digest``digestRef`),但不部署生产;`run-dev-e2e` 的 Git 控制 runner、短 launcher、host fetch 边界、临时 smoke namespace 和 no-CD 规则只在 `docs/reference/dev-ci-runner.md` 定义;Tekton CI 通用规则见 `docs/reference/ci.md`
- `schedule list|get|runs|run|retry-run|delete|upsert-pgdata-backup` 管理 backend-core 定时任务和运行历史。`schedule list``schedule get``schedule runs --limit N``schedule runs <scheduleId> --limit N` 是只读观察入口;`schedule run``schedule retry-run``schedule delete``schedule upsert-pgdata-backup` 会触发运行或写入配置,生产恢复时必须有明确授权。`schedule runs --limit N` 是全局历史视图,返回 `scope=global``scheduleId=null``schedule runs <scheduleId> --limit N` 是指定 schedule 历史视图,返回 `scope=schedule` 和对应 `scheduleId`。CLI 必须拒绝 `schedule runs 50` 这类纯数字位置参数,并提示使用 `schedule runs --limit 50`,避免把空数组误判成“没有历史 run”。`schedule run <id> --wait-ms N` 触发同一 schedule,并且即使 wait 超时也必须返回 `newRunId``observeCommand``schedule retry-run <failedRunId>` 只接受 failed run,从原 run 反查 `scheduleId` 后重触发同一 schedule,并输出 `originalRunId``scheduleId``newRunId``observeCommand`。当 backend-core 目标容器缺失或只观察到 verify-only 容器时,schedule/microservice 命令必须以非零退出并返回 `failureKind=target-stack-not-running``runnerDisposition=infra-blocked``readOnlyCommands``authorizationRequiredForRecovery`,不得把 Docker 的 `No such container` 当成成功的空历史。
- `codex deploy <commitId>` 是旧 Code Queue 兼容部署入口,已禁用以防止维护通道直连 D601 部署 Code Queue;当前 dev 自动化只做 `ci run-dev-e2e` smoke,不提供 Code Queue CD,详细规则见 `docs/reference/codex-deploy.md`
- `agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|send` 是当前指挥官新任务和 AgentRun session 控制入口。UniDesk CLI 是 render-only client:客户端保留 k8s 风格命令解析、human 表格、生命周期摘要、下一步命令、分页、`-o json|yaml` 稳定客户端 schema 和错误展示;AgentRun 服务端只提供稳定 RESTful API、鉴权和业务事实,不承载 UniDesk CLI 渲染。日常查看用 `get tasks --queue commander``describe task/<taskId>``events run/<runId>``logs session/<sessionId>``result run/<runId> --command <commandId>`;日常写入用 `create task --aipod Artificer --prompt-stdin``apply -f -``dispatch task/<taskId>``send session/<sessionId>``ack/cancel task|session/<id>`。用户级 CLI 取消 `turn``steer` 路径;`send session/<sessionId>` 是唯一 session follow-up 写入口,AgentRun 服务端按 durable session/run/command 状态自动决定内部 `steer` 或新 `turn`,dry-run 必须真实返回这个 decision 且不写状态。兼容 group `queue|runs|commands|runner|sessions|aipod-specs` 也走同一 direct HTTP transport`--raw` 只披露直连 AgentRun REST envelope。
- `agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|send` 是当前 AgentRun 资源入口:
- 日常查看使用 `get``describe``events``logs``result`
- session follow-up 只使用 `send session/<sessionId>`
- Artificer Target 派单使用 `create task --aipod Artificer`
- 同一命令必须提供 `--target``--target-workspace``--repo``--ref``--mdtodo-id` 和 prompt
- Target 模式在 submit 后自动 dispatch,并返回 task/run/session/attempt identity
- 用户级 `turn``steer` 已取消;
- 兼容 group 只保留独立诊断。
- `agentrun explain session-policy --node <node> --lane <lane>``agentrun send session/<id> --node <node> --lane <lane> --dry-run``agentrun aipod-specs render <name> --node <node> --lane <lane>` 必须使用同一套目标 lane YAML 解析。非默认 lane 的短命令形态和 `--json-stdin -o json` 形态都应显示目标 `backendProfile``providerId``workspaceRef`、executionPolicy、provider credential SecretRef 和 tool credential SecretRef 摘要;Secret 输出只显示对象名、key 名、presence/fingerprint、projection 和 `valuesPrinted=false`。如果默认 human 输出触发 `/tmp/unidesk-cli-output` dump,应先把命令收敛为 summary/table/drill-down;机器完整输出留给显式 `--full``--raw``-o json` 或等价机器消费参数。`aipod-specs render` 的残余默认 dump 归 [#862](https://github.com/pikasTech/unidesk/issues/862) 跟踪。
- `agentrun` 资源原语的默认 transport 是直连 AgentRun REST API,配置来源是 UniDesk 自有 YAML `config/agentrun.yaml`。不带 `--node`/`--lane` 时按 YAML 的默认 manager `baseUrl` 访问;显式 `--node <node> --lane <lane>` 时按同一 YAML 选中 runtime lane,经 `lane-k8s-service-proxy` 进入 manager `internalBaseUrl`,并用 manager pod env 中声明的 API key metadata 发起请求;输出只披露 node/lane/namespace/baseUrl/auth env metadata 和 `valuesPrinted=false`,不得打印 key value。该模式用于 D601 `agentrun-v02` 等非默认 lane 的资源原语操作与证据采集,尤其是 `get/describe/events/logs/result`,不替代 `agentrun control-plane ...` 发布或运维控制。鉴权可以复用 `HWLAB_API_KEY` 的环境变量/固定文件发现风格,但不得依赖 HWLAB runtime、HWLAB backend-core、HWLAB frontend 代理或 SSH official CLI;多一层转发会增加故障面,不能作为正式路径。`agentrun control-plane ...``git-mirror ...` 仍属于 G14 source/runtime 运维控制路径,可以继续使用 UniDesk SSH capture bridge;这些控制面路径不得反向成为 queue/session 资源原语的默认 transport。
- `agentrun` 资源原语 transport 来自 `config/agentrun.yaml`
- `client.transport=target-trans` 时,外层先经选中 Target 的 `trans` 重入;
- marker、Target UniDesk 工作区和 CLI 路径全部来自 YAML;
- Target 内部再经 `lane-k8s-service-proxy` 访问 manager
- dry-run 也必须走相同 Target 路径;
- 代码不得补主机、绝对路径或第二条 transport 默认值;
- 输出只披露 route、SecretRef、presence/fingerprint 和 `valuesPrinted=false`
- `agentrun control-plane ...``git-mirror ...` 仍属于 source/runtime 运维控制路径。
- `agentrun control-plane secret-sync --node <node> --lane <lane> --dry-run` 是 YAML SecretRef 配置维护的有界预检入口:
- 默认文本、`-o json``-o yaml` 由同一个 compact payload 渲染;
- payload 展示 YAML Secret ID、目标 Secret/key、source presence/fingerprint、activation fence identity/status 和关联资源 identity
+4
View File
@@ -171,6 +171,7 @@ export interface AgentRunLaneSpec {
readonly nodeId: string;
readonly nodeRoute: string;
readonly nodeKubeRoute: string;
readonly nodeUnideskWorkspace: string;
readonly version: string;
readonly source: {
readonly statusMode: "host-worktree" | "k3s-git-mirror";
@@ -362,6 +363,7 @@ interface AgentRunNodeSpec {
readonly id: string;
readonly route: string;
readonly kubeRoute: string;
readonly unideskWorkspace: string;
}
interface AgentRunControlPlaneConfig {
@@ -652,6 +654,7 @@ function parseNodes(input: Record<string, unknown>, configPath: string): Record<
id,
route: stringField(node, "route", `${configPath}.controlPlane.nodes.${id}`),
kubeRoute: stringField(node, "kubeRoute", `${configPath}.controlPlane.nodes.${id}`),
unideskWorkspace: absolutePathField(node, "unideskWorkspace", `${configPath}.controlPlane.nodes.${id}`),
};
}
if (Object.keys(result).length === 0) throw new Error(`${configPath}.controlPlane.nodes must declare at least one node`);
@@ -689,6 +692,7 @@ function parseLane(lane: string, node: AgentRunNodeSpec, input: Record<string, u
nodeId: node.id,
nodeRoute: node.route,
nodeKubeRoute: node.kubeRoute,
nodeUnideskWorkspace: node.unideskWorkspace,
version,
source: {
statusMode,
+155
View File
@@ -3,6 +3,13 @@ import { describe, expect, test } from "bun:test";
import { parseAgentRunClientConfigYaml, resolveAgentRunAuth, runAgentRunCommand, stripAgentRunResourceWrapperArgs } from "./agentrun";
import { resolveAgentRunLaneTarget } from "./agentrun-lanes";
import { agentRunToolCredentialRefs } from "./agentrun/config";
import { autoDispatchTargetTask, targetTaskDispatchDryRunPlan } from "./agentrun/rest-bridge";
import {
preflightTargetTask,
targetTaskIdempotencyKey,
targetTaskRequestFromArgs,
type AgentRunTargetTaskPreflight,
} from "./agentrun/target-task";
import { placeholderAgentRunImage, renderAgentRunGitopsFiles } from "./agentrun-manifests";
import { collectLaneSecretSources, secretSyncScript } from "./agentrun/secrets";
@@ -163,6 +170,154 @@ describe("AgentRun render-only REST client config", () => {
expect(() => parseAgentRunClientConfigYaml(agentRunClientYaml.replace(" transport: direct-http", " transport: ssh-bridge"), "config/agentrun.yaml")).toThrow("client.transport must be direct-http");
expect(() => parseAgentRunClientConfigYaml(agentRunClientYaml.replace(" role: render-only", " role: proxy"), "config/agentrun.yaml")).toThrow("client.role must be render-only");
});
test("parses YAML-first target-trans execution without code-owned Target paths", () => {
const targetYaml = agentRunClientYaml.replace(
" transport: direct-http",
[
" transport: target-trans",
" targetExecution:",
" markerEnv: UNIDESK_AGENTRUN_TARGET_EXECUTED",
" executable: bun",
" cliPath: scripts/cli.ts",
].join("\n"),
);
const config = parseAgentRunClientConfigYaml(targetYaml, "config/agentrun.yaml");
expect(config.client.transport).toBe("target-trans");
expect(config.client.targetExecution).toEqual({
markerEnv: "UNIDESK_AGENTRUN_TARGET_EXECUTED",
executable: "bun",
cliPath: "scripts/cli.ts",
});
expect(() => parseAgentRunClientConfigYaml(
agentRunClientYaml.replace(" transport: direct-http", " transport: target-trans"),
"config/agentrun.yaml",
)).toThrow("client.targetExecution.markerEnv");
});
});
describe("Artificer Target one-command dispatch", () => {
test("requires trans re-entry before Target filesystem preflight and derives a stable idempotency key", () => {
const request = targetTaskRequestFromArgs([
"--target", "NC01",
"--target-workspace", "/root/.worktrees/selfmedia/r6-ocr-visual-planner",
"--repo", "pikainc/selfmedia",
"--ref", "feat/r6-ocr-visual-planner",
"--mdtodo-id", "R6.3",
]);
expect(request).not.toBeNull();
if (request === null) throw new Error("expected Target request");
const markerEnv = "UNIDESK_AGENTRUN_TARGET_EXECUTED_TEST";
const previous = process.env[markerEnv];
delete process.env[markerEnv];
try {
expect(() => preflightTargetTask(request, markerEnv)).toThrow("只能在外层 trans 重入 NC01 后执行");
} finally {
if (previous === undefined) delete process.env[markerEnv];
else process.env[markerEnv] = previous;
}
const preflight = {
...request,
targetRoute: `${request.target}:${request.targetWorkspace}`,
projectId: request.repository,
workspaceRoot: request.targetWorkspace,
currentBranch: request.ref,
headCommit: "a".repeat(40),
clean: true,
originRepository: request.repository,
verifiedCommit: "a".repeat(40),
matchedRemoteRef: `refs/heads/${request.ref}`,
refRelation: "branch-and-head-match-remote-ref",
sourceAuthority: {
kind: "target-workspace-origin",
credentialScope: "target-owned-git-credential",
valuesPrinted: false,
},
} satisfies AgentRunTargetTaskPreflight;
expect(targetTaskIdempotencyKey(preflight)).toMatch(/^artificer-target:[0-9a-f]{64}$/u);
expect(targetTaskIdempotencyKey(preflight)).toBe(targetTaskIdempotencyKey(preflight));
});
test("auto-dispatches pending submit exactly once and reuses existing attempt identity on retry", async () => {
let dispatchRequests = 0;
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url);
expect(request.headers.get("authorization")).toBe("Bearer secret-value");
if (request.method === "POST" && url.pathname === "/api/v1/queue/tasks/qt_target/dispatch") {
dispatchRequests += 1;
return Response.json({
ok: true,
data: {
task: { id: "qt_target", state: "running" },
run: { id: "run_target", sessionRef: { sessionId: "ses_target" } },
command: { id: "cmd_target" },
latestAttempt: {
attemptId: "qat_target",
runId: "run_target",
commandId: "cmd_target",
runnerJobId: "rjob_target",
sessionId: "ses_target",
},
},
});
}
return new Response("not found", { status: 404 });
},
});
const previousConfig = process.env.AGENTRUN_CLIENT_CONFIG;
const previousKey = process.env.HWLAB_API_KEY;
const tempConfigPath = `/tmp/unidesk-agentrun-target-dispatch-test-${process.pid}-${Date.now()}.yaml`;
try {
process.env.AGENTRUN_CLIENT_CONFIG = tempConfigPath;
process.env.HWLAB_API_KEY = "secret-value";
await Bun.write(tempConfigPath, agentRunMinimalClientYaml.replace("http://agentrun.example.local:8080", server.url.href.replace(/\/$/u, "")));
const first = await autoDispatchTargetTask(
{ ok: true, data: { id: "qt_target", state: "pending" } },
{ mdtodoId: "R6.3" },
{ aipod: "Artificer" },
);
expect(dispatchRequests).toBe(1);
expect((first.data as Record<string, unknown>).runId).toBe("run_target");
expect((first.data as Record<string, unknown>).sessionId).toBe("ses_target");
expect(((first.autoDispatch as Record<string, unknown>).decision)).toBe("dispatched");
const replay = await autoDispatchTargetTask(
{
ok: true,
data: {
id: "qt_target",
state: "running",
latestAttempt: {
attemptId: "qat_target",
runId: "run_target",
commandId: "cmd_target",
runnerJobId: "rjob_target",
sessionId: "ses_target",
},
},
},
{ mdtodoId: "R6.3" },
{ aipod: "Artificer" },
);
expect(dispatchRequests).toBe(1);
expect(((replay.autoDispatch as Record<string, unknown>).decision)).toBe("idempotent-replay");
expect(((replay.autoDispatch as Record<string, unknown>).duplicateDispatchPrevented)).toBe(true);
const plan = targetTaskDispatchDryRunPlan("artificer-target:test");
expect(plan.sequence).toEqual(["queue-submit", "queue-dispatch"]);
expect((plan.request as Record<string, unknown>).pathTemplate).toBe("/api/v1/queue/tasks/<submitted-task-id>/dispatch");
expect(plan.mutation).toBe(false);
} finally {
server.stop(true);
if (previousConfig === undefined) delete process.env.AGENTRUN_CLIENT_CONFIG;
else process.env.AGENTRUN_CLIENT_CONFIG = previousConfig;
if (previousKey === undefined) delete process.env.HWLAB_API_KEY;
else process.env.HWLAB_API_KEY = previousKey;
rmSync(tempConfigPath, { force: true });
}
});
});
describe("AgentRun default transport contract", () => {
+36 -2
View File
@@ -74,7 +74,23 @@ export function parseAgentRunClientConfigYaml(raw: string, sourcePath = "config/
const role = stringFieldFromRecord(client, "role", "client");
const transport = stringFieldFromRecord(client, "transport", "client");
if (role !== "render-only") throw new Error(`${sourcePath}: client.role must be render-only`);
if (transport !== "direct-http") throw new Error(`${sourcePath}: client.transport must be direct-http`);
if (transport !== "direct-http" && transport !== "target-trans") {
throw new Error(`${sourcePath}: client.transport must be direct-http or target-trans`);
}
const targetExecutionInput = record(client.targetExecution);
const targetExecution = transport === "target-trans"
? {
markerEnv: environmentVariableField(
stringFieldFromRecord(targetExecutionInput, "markerEnv", "client.targetExecution"),
`${sourcePath}: client.targetExecution.markerEnv`,
),
executable: stringFieldFromRecord(targetExecutionInput, "executable", "client.targetExecution"),
cliPath: relativePathField(
stringFieldFromRecord(targetExecutionInput, "cliPath", "client.targetExecution"),
`${sourcePath}: client.targetExecution.cliPath`,
),
}
: null;
const sessionPolicy = readAgentRunSessionPolicyConfig(client, sourcePath);
const publicExposure = readAgentRunPublicExposureConfig(input.publicExposure, baseUrl.replace(/\/+$/u, "/"), sourcePath);
return {
@@ -92,6 +108,7 @@ export function parseAgentRunClientConfigYaml(raw: string, sourcePath = "config/
client: {
role,
transport,
targetExecution,
sessionPolicy,
},
publicExposure,
@@ -754,6 +771,18 @@ export function booleanFieldFromRecord(obj: Record<string, unknown>, key: string
return value;
}
function environmentVariableField(value: string, pathValue: string): string {
if (!/^[A-Z_][A-Z0-9_]*$/u.test(value)) throw new Error(`${pathValue} must be an uppercase environment variable name`);
return value;
}
function relativePathField(value: string, pathValue: string): string {
if (value.startsWith("/") || value.includes("..") || value.includes("\0")) {
throw new Error(`${pathValue} must be a repository-relative path without ..`);
}
return value;
}
export function cloneJsonRecord(value: Record<string, unknown>): Record<string, unknown> {
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
}
@@ -772,7 +801,12 @@ export interface AgentRunClientConfig {
};
client: {
role: string;
transport: string;
transport: "direct-http" | "target-trans";
targetExecution: {
markerEnv: string;
executable: string;
cliPath: string;
} | null;
sessionPolicy: AgentRunSessionPolicyConfig;
};
publicExposure: AgentRunPublicExposure | null;
+6 -5
View File
@@ -69,6 +69,7 @@ export function agentRunHelp(): unknown {
"bun scripts/cli.ts agentrun get attempts --task <taskId> --limit 20",
"bun scripts/cli.ts agentrun dispatch task/<taskId>",
"bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin --idempotency-key <key>",
"bun scripts/cli.ts agentrun create task --aipod Artificer --target <Target> --target-workspace <absolute-task-worktree> --repo <owner/repo> --ref <branch> --mdtodo-id <R6.3> --prompt-stdin [--dry-run]",
"bun scripts/cli.ts agentrun apply -f - --dry-run",
"bun scripts/cli.ts agentrun send session/<sessionId> --aipod Artificer --prompt-stdin",
"bun scripts/cli.ts agentrun explain task",
@@ -99,8 +100,8 @@ export function agentRunHelp(): unknown {
"bun scripts/cli.ts agentrun git-mirror legacy-ops --help",
],
resources: ["task/qt", "attempt", "run", "command/cmd", "runnerjob/rjob", "session/ses", "aipodspec/aps"],
description: "Operate AgentRun through Kubernetes-style resource verbs. Human output is compact by default; -o json|yaml returns the UniDesk render-only client schema, --raw exposes the REST envelope, and --node/--lane targets a YAML-declared runtime lane.",
legacyCompatibility: "queue/runs/commands/runner/sessions/aipod-specs remain as compatibility groups backed by direct HTTP; new commander work should use get/describe/events/logs/result/ack/cancel/retry/dispatch/create/apply/send. sessions turn/steer are removed; use send only.",
description: "Operate AgentRun through Kubernetes-style resource verbs. Human output is compact by default; -o json|yaml returns the UniDesk render-only client schema, --raw exposes the REST envelope, and YAML client.targetExecution makes resource verbs re-enter the selected Target through trans.",
legacyCompatibility: "queue/runs/commands/runner/sessions/aipod-specs remain compatibility groups; new commander work should use get/describe/events/logs/result/ack/cancel/retry/dispatch/create/apply/send. sessions turn/steer are removed; use send only.",
cicdBoundary: "NC01 PaC consumer 仅由 GitHub PR merge 自动触发;默认帮助只给 status/history。legacy 与平台维护写入口只在显式 scoped help 中展示。",
};
}
@@ -316,7 +317,7 @@ export function agentRunHelpText(args: string[]): string {
return "Usage: bun scripts/cli.ts agentrun dispatch task/<taskId>";
}
if (verb === "create") {
return "Usage: bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin [--idempotency-key <key>] [--dry-run]";
return "Usage: bun scripts/cli.ts agentrun create task --aipod Artificer --target <Target> --target-workspace <task-worktree> --repo <owner/repo> --ref <branch> --mdtodo-id <R6.3> --prompt-stdin [--idempotency-key <key>] [--dry-run]";
}
if (verb === "apply") {
return "Usage: bun scripts/cli.ts agentrun apply -f task.yaml|json|- [--dry-run]\nTask manifests use kind: Task and spec: <AgentRun task payload>.";
@@ -411,11 +412,11 @@ export function agentRunHelpText(args: string[]): string {
" bun scripts/cli.ts agentrun logs session/<sessionId> --tail 100",
" bun scripts/cli.ts agentrun retry task/<taskId> --idempotency-key <key> --reason <text> --dry-run",
" bun scripts/cli.ts agentrun get attempts --task <taskId> --limit 20",
" bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin",
" bun scripts/cli.ts agentrun create task --aipod Artificer --target <Target> --target-workspace <task-worktree> --repo <owner/repo> --ref <branch> --mdtodo-id <R6.3> --prompt-stdin",
"",
"Machine/debug output:",
" -o json|yaml emits a stable UniDesk resource object without the global JSON envelope.",
" --raw emits the direct AgentRun REST envelope.",
" --node/--lane targets a YAML-declared AgentRun runtime lane; default is the configured NC01 manager.",
" --target/--node/--lane selects a YAML Target; resource commands re-enter it through target-trans.",
].join("\n");
}
+71
View File
@@ -496,12 +496,83 @@ export function renderMutationSummary(command: string, raw: Record<string, unkno
if (decision !== null) lines.push(`Decision: ${decision}`);
if (internalCommandType !== null) lines.push(`InternalCommandType: ${internalCommandType}`);
lines.push(...renderCancelLifecycleMutationLines(record(data.cancelLifecycle ?? raw.cancelLifecycle)));
lines.push(...renderTargetTaskMutationLines(record(data.targetTask ?? raw.targetTask)));
lines.push(...renderAipodBindingMutationLines(record(data.aipodBinding ?? raw.aipodBinding)));
lines.push(...renderAutoDispatchMutationLines(record(data.autoDispatch ?? raw.autoDispatch)));
lines.push(...renderDispatchPlanMutationLines(record(data.dispatchPlan ?? raw.dispatchPlan)));
const next = record(raw.next ?? data.next);
const nextLines = (overrideNextLines ?? Object.values(next).map(String)).filter((line) => line.length > 0).slice(0, 5);
if (nextLines.length > 0) lines.push("", "Next:", ...nextLines.map((line) => ` ${line}`));
return renderedCliResult(raw.ok !== false, command, lines.join("\n"));
}
export function renderTargetTaskMutationLines(targetTask: Record<string, unknown>): string[] {
if (Object.keys(targetTask).length === 0) return [];
const workspace = record(targetTask.workspace);
return [
"",
"TargetTaskPreflight:",
` MDTODO: ${displayValue(targetTask.mdtodoId)} project=${displayValue(targetTask.projectId)}`,
` Target: ${displayValue(targetTask.targetRoute)}`,
` Source: ${displayValue(targetTask.repository)} ref=${displayValue(targetTask.ref)}`,
` Commit: HEAD=${displayValue(workspace.headCommit)} remote=${displayValue(targetTask.verifiedCommit)}`,
` Worktree: branch=${displayValue(workspace.branch)} clean=${displayValue(workspace.clean)} relation=${displayValue(targetTask.refRelation)}`,
];
}
export function renderAipodBindingMutationLines(binding: Record<string, unknown>): string[] {
if (Object.keys(binding).length === 0) return [];
const workspaceRef = record(binding.workspaceRef);
const resourceBundleRef = record(binding.resourceBundleRef);
const session = record(binding.session);
const providerCredentials = arrayRecords(binding.providerCredentials);
const toolCredentials = arrayRecords(binding.toolCredentials);
const lines = [
"",
"AipodBinding:",
` Aipod: ${displayValue(binding.aipod)} target=${displayValue(binding.node)}/${displayValue(binding.lane)} namespace=${displayValue(binding.namespace)}`,
` WorkspaceRef: kind=${displayValue(workspaceRef.kind)} path=${displayValue(workspaceRef.path)} valid=${displayValue(workspaceRef.valid)}`,
` ResourceBundle: ${displayValue(resourceBundleRef.repoUrl)} ref=${displayValue(resourceBundleRef.ref)} inherited=${displayValue(resourceBundleRef.inheritedFromAipod)}`,
` Session: ${displayValue(session.sessionId)} source=${displayValue(session.identitySource)}`,
];
for (const credential of providerCredentials.slice(0, 4)) {
const secretRef = record(credential.secretRef);
lines.push(` ProviderSecretRef: ${displayValue(secretRef.namespace)}/${displayValue(secretRef.name)} keys=${displayValue(secretRef.keys)} present=${displayValue(credential.bindingPresent)}`);
}
for (const credential of toolCredentials.slice(0, 6)) {
const secretRef = record(credential.secretRef);
lines.push(` ToolSecretRef: ${displayValue(credential.tool)} ${displayValue(secretRef.namespace)}/${displayValue(secretRef.name)} keys=${displayValue(secretRef.keys)} present=${displayValue(credential.bindingPresent)}`);
}
return lines;
}
export function renderAutoDispatchMutationLines(autoDispatch: Record<string, unknown>): string[] {
if (Object.keys(autoDispatch).length === 0) return [];
const identity = record(autoDispatch.identity);
return [
"",
"AutoDispatch:",
` Decision: ${displayValue(autoDispatch.decision)} mutation=${displayValue(autoDispatch.mutation)} duplicatePrevented=${displayValue(autoDispatch.duplicateDispatchPrevented)}`,
` Task: ${displayValue(identity.taskId)} state=${displayValue(identity.taskState)} attempt=${displayValue(identity.attemptId)}`,
` Run: ${displayValue(identity.runId)} command=${displayValue(identity.commandId)} runner=${displayValue(identity.runnerJobId)}`,
` Session: ${displayValue(identity.sessionId)}`,
];
}
export function renderDispatchPlanMutationLines(plan: Record<string, unknown>): string[] {
if (Object.keys(plan).length === 0) return [];
const request = record(plan.request);
const sequence = Array.isArray(plan.sequence) ? plan.sequence.map(String).join(" -> ") : "-";
return [
"",
"AutoDispatchPlan:",
` Sequence: ${sequence}`,
` Request: ${displayValue(request.method)} ${displayValue(request.pathTemplate)}`,
` Condition: ${displayValue(plan.condition)}`,
` Replay: ${displayValue(plan.replay)}`,
];
}
export function renderCancelLifecycleMutationLines(lifecycle: Record<string, unknown>): string[] {
if (Object.keys(lifecycle).length === 0) return [];
const authority = record(lifecycle.authority);
+44 -1
View File
@@ -41,6 +41,7 @@ import { agentRunExplain, arrayRecords, shortId } from "./options";
import { agentRunCancelAuthorityDisclosure, agentRunCancelCascadeScope, agentRunCancelFencingDisclosure, agentRunCancelRunnerAbortDisclosure, compactAipodSpecDescriptionPayload, compactTaskDescriptionPayload, innerData, normalizeAipodSpecItems, normalizeAttemptResources, normalizeEventItems, normalizeLogItems, normalizeQueueAttemptItems, normalizeRunnerJobItems, normalizeSessionItems, normalizeSingleCommand, normalizeTaskItems, parseResourceKind, parseResourceRef, renderAipodSpecDescription, renderDescribe, renderEventLike, renderGenericDescription, renderMachine, renderMutationSummary, renderQueueAttemptList, renderQueueRetrySummary, renderResourceResult, renderResultSummary, renderTaskDescription, renderedCliResult, requiredContext, rerunWithoutDryRun, resolveAgentRunCancelPolicyTarget, resourceApply, resourceCreate, resourceDispatch, resourceSessionPromptCommand, taskInputDescriptionPayload, taskListState, unwrapTaskDetail } from "./render";
import { AgentRunRestError, renderAgentRunRestError, resolveAgentRunRestTarget, runAgentRunRestCommand, withAgentRunRestTarget } from "./rest-bridge";
import { renderSessionSendError } from "./session-send-render";
import { runAgentRunResourceThroughTarget } from "./target-execution";
import { runnerJobObservationScript, type RunnerJobObservationIdentity } from "./runner-observation";
import { capture, captureJsonPayload, compactCapture, nonNegativeIntegerOrNull, record, stringOrNull } from "./utils";
@@ -64,6 +65,7 @@ export async function runAgentRunResourceCommand(config: UniDeskConfig | null, v
try {
options = parseResourceOptions(resourceArgs);
validateResourceOptionsForVerb(verb, options);
validateResourceActionBeforeTarget(verb, action, actionArgs, options);
} catch (error) {
const validationError = error instanceof AgentRunRestError
? error
@@ -72,6 +74,8 @@ export async function runAgentRunResourceCommand(config: UniDeskConfig | null, v
}
const bridgeActionArgs = stripAgentRunResourceWrapperArgs(actionArgs);
try {
const targetResult = await runAgentRunResourceThroughTarget(config, command, canonicalArgs, options);
if (targetResult !== null) return targetResult;
return await withAgentRunRestTarget(resolveAgentRunRestTarget(config, options), async () => {
if (verb === "explain") return renderedCliResult(true, command, agentRunExplain(action ?? "task", resourceArgs, options));
if (verb === "get") return await resourceGet(config, command, action, bridgeActionArgs, options);
@@ -98,6 +102,29 @@ export async function runAgentRunResourceCommand(config: UniDeskConfig | null, v
return renderedCliResult(false, command, `Unsupported AgentRun resource command. Try: bun scripts/cli.ts agentrun --help`);
}
function validateResourceActionBeforeTarget(
verb: AgentRunResourceVerb,
action: string | undefined,
args: string[],
options: AgentRunResourceOptions,
): void {
if (verb === "retry") {
if (action === undefined || action.startsWith("-")) throw new AgentRunRestError("validation-failed", "retry requires task/<taskId>");
let ref: AgentRunResourceRef;
try {
ref = parseResourceRef(action, args, "task");
} catch {
throw new AgentRunRestError("validation-failed", "retry requires task/<taskId>");
}
if (ref.kind !== "task") throw new AgentRunRestError("validation-failed", "retry supports task/<taskId>");
if (options.idempotencyKey === null) throw new AgentRunRestError("validation-failed", "retry requires --idempotency-key <key>");
if (options.reason === null) throw new AgentRunRestError("validation-failed", "retry requires --reason <text>");
}
if (verb === "get" && parseResourceKind(action) === "attempt" && options.taskId === null) {
throw new AgentRunRestError("validation-failed", "get attempts requires --task <taskId>");
}
}
function resourceErrorOutputOptions(args: string[]): AgentRunResourceOptions {
const options = parseResourceOptions([]);
options.raw = args.includes("--raw");
@@ -162,9 +189,14 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions {
promptStdin: false,
node: null,
lane: null,
target: null,
targetWorkspace: null,
repository: null,
ref: null,
mdtodoId: null,
passthroughArgs: [],
};
const valueFlags = new Set(["-o", "--output", "--limit", "--cursor", "--queue", "--state", "--reader-id", "--task", "--task-id", "--run", "--run-id", "--command", "--command-id", "--session", "--session-id", "--after-seq", "--detail-seq", "--tail", "--reason", "-f", "--file", "--filename", "--aipod", "--idempotency-key", "--node", "--lane"]);
const valueFlags = new Set(["-o", "--output", "--limit", "--cursor", "--queue", "--state", "--reader-id", "--task", "--task-id", "--run", "--run-id", "--command", "--command-id", "--session", "--session-id", "--after-seq", "--detail-seq", "--tail", "--reason", "-f", "--file", "--filename", "--aipod", "--idempotency-key", "--node", "--lane", "--target", "--target-workspace", "--repo", "--ref", "--mdtodo-id"]);
const booleanFlags = new Set(["--full", "--raw", "--debug", "--input", "--unread", "--dry-run", "--full-text", "--prompt-stdin", "--stdin"]);
for (let index = 0; index < args.length; index += 1) {
const arg = args[index] ?? "";
@@ -186,6 +218,12 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions {
continue;
}
}
if (options.target !== null) {
if (options.node !== null && options.node !== options.target) {
throw new Error(`--target ${options.target} conflicts with --node ${options.node}`);
}
options.node = options.target;
}
if (options.taskInput && (options.full || options.raw)) {
const conflictingOptions = ["--input", options.full ? "--full" : null, options.raw ? "--raw" : null]
.filter((value): value is string => value !== null);
@@ -278,6 +316,11 @@ export function applyResourceOption(options: AgentRunResourceOptions, flag: stri
else if (flag === "--idempotency-key") options.idempotencyKey = requiredValue(value, flag);
else if (flag === "--node") options.node = requiredValue(value, flag);
else if (flag === "--lane") options.lane = requiredValue(value, flag);
else if (flag === "--target") options.target = requiredValue(value, flag);
else if (flag === "--target-workspace") options.targetWorkspace = requiredValue(value, flag);
else if (flag === "--repo") options.repository = requiredValue(value, flag);
else if (flag === "--ref") options.ref = requiredValue(value, flag);
else if (flag === "--mdtodo-id") options.mdtodoId = requiredValue(value, flag);
}
function parsePositiveInt(raw: string | null, flag: string): number {
+246 -4
View File
@@ -43,6 +43,15 @@ import { arrayRecords, displayValue, isRecord, pickCompact, renderTable, truncat
import { innerData, pathValue, renderMachine, renderedCliResult } from "./render";
import { parseResourceOptions, stripAgentRunResourceWrapperArgs } from "./resource-actions";
import { AGENTRUN_GIT_MIRROR_RETRY_MAX_ATTEMPTS, agentRunGitMirrorRetryDelayMs, agentRunGitMirrorRetrySummary, agentRunGitMirrorRetryableFailure, createYamlLaneJobScript, yamlLaneGitMirrorJobManifest, yamlLaneGitMirrorStatusScript, yamlLaneJobProbeScript } from "./secrets";
import {
AgentRunTargetTaskPreflightError,
preflightTargetTask,
targetTaskDisclosure,
targetTaskIdempotencyKey,
targetTaskPrompt,
targetTaskRequestFromArgs,
type AgentRunTargetTaskPreflight,
} from "./target-task";
import { capture, captureJsonPayload, compactCapture, nonNegativeIntegerOrNull, progressEvent, record, shQuote, sleep, stringOrNull } from "./utils";
export async function readGitMirrorStatus(config: UniDeskConfig, target: { configPath: string; spec: AgentRunLaneSpec }): Promise<Record<string, unknown> & { result: SshCaptureResult; raw: string; summary: Record<string, unknown> }> {
@@ -581,16 +590,45 @@ export async function runAgentRunAipodSpecsRest(action: string | undefined, id:
export async function submitQueueTaskRest(args: string[]): Promise<Record<string, unknown>> {
const aipod = agentRunOption(args, "aipod") ?? agentRunOption(args, "aipod-spec");
if (aipod) {
const rendered = await agentRunRestRequest("agentrun aipod-specs render", "POST", `/api/v1/aipod-specs/${encodeURIComponent(aipod)}/render`, await aipodRenderInputFromArgs(args, 2));
const targetTask = targetTaskPreflightFromArgs(args, aipod);
const renderInput = await aipodRenderInputFromArgs(args, targetTask === null ? 2 : args.length);
if (targetTask !== null) {
renderInput.prompt = targetTaskPrompt(targetTask, stringOrNull(renderInput.prompt));
if (agentRunOption(args, "project-id") === null) renderInput.projectId = targetTask.projectId;
if (agentRunOption(args, "provider-id") === null) renderInput.providerId = targetTask.target;
renderInput.metadata = {
...record(renderInput.metadata),
targetContext: targetTaskDisclosure(targetTask),
};
if (agentRunOption(args, "idempotency-key") === null) {
renderInput.idempotencyKey = targetTaskIdempotencyKey(targetTask);
}
}
const rendered = await agentRunRestRequest("agentrun aipod-specs render", "POST", `/api/v1/aipod-specs/${encodeURIComponent(aipod)}/render`, renderInput);
const body = normalizeAipodRenderedQueueTask(record(record(innerData(rendered)).queueTask), args, aipod);
if (Object.keys(body).length === 0) throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} render did not return queueTask`);
assertLegalAipodWorkspaceRef(body, aipod);
const targetTaskOutput = targetTask === null ? null : targetTaskDisclosure(targetTask);
const aipodBinding = agentRunAipodBindingDisclosure(body, aipod);
if (agentRunHasFlag(args, "dry-run")) {
return agentRunDryRunPlan("queue-submit", "/api/v1/queue/tasks", body, queueSubmitConfirmCommand(args, aipod), "POST", {
jsonInput: { source: "aipod-spec", aipod, valuesPrinted: false },
aipodBinding: agentRunAipodBindingDisclosure(body, aipod),
aipodBinding,
...(targetTaskOutput === null ? {} : { targetTask: targetTaskOutput }),
...(targetTask === null ? {} : {
dispatchPlan: targetTaskDispatchDryRunPlan(stringOrNull(body.idempotencyKey) ?? targetTaskIdempotencyKey(targetTask)),
}),
});
}
return await agentRunRestRequest("agentrun queue submit", "POST", "/api/v1/queue/tasks", body);
const submitted = await agentRunRestRequest("agentrun queue submit", "POST", "/api/v1/queue/tasks", body);
if (targetTask !== null) {
return await autoDispatchTargetTask(submitted, targetTaskOutput ?? {}, aipodBinding);
}
return {
...submitted,
aipodBinding,
...(targetTaskOutput === null ? {} : { targetTask: targetTaskOutput }),
};
}
const body = await requiredJsonBody(args, "queue submit");
const idempotencyKey = agentRunOption(args, "idempotency-key");
@@ -599,6 +637,176 @@ export async function submitQueueTaskRest(args: string[]): Promise<Record<string
return await agentRunRestRequest("agentrun queue submit", "POST", "/api/v1/queue/tasks", body);
}
function targetTaskPreflightFromArgs(args: string[], aipod: string): AgentRunTargetTaskPreflight | null {
try {
const request = targetTaskRequestFromArgs(args);
if (request === null) return null;
if (aipod !== "Artificer") {
throw new AgentRunTargetTaskPreflightError(
"aipod",
`Target 上下文派单当前只支持 --aipod Artificer;收到 ${aipod}`,
{ aipod },
);
}
const client = readAgentRunClientConfig();
const execution = client.client.targetExecution;
if (client.client.transport !== "target-trans" || execution === null) {
throw new AgentRunTargetTaskPreflightError(
"target-execution",
`${client.sourcePath} 必须声明 client.transport=target-trans 和 client.targetExecution。`,
{ configPath: client.sourcePath, transport: client.client.transport },
);
}
return preflightTargetTask(request, execution.markerEnv);
} catch (error) {
if (!(error instanceof AgentRunTargetTaskPreflightError)) throw error;
throw new AgentRunRestError("validation-failed", error.message, {
details: {
stage: error.stage,
...error.details,
mutation: false,
recoveryActions: [
"修复错误后用同一条 create task 命令重新执行;失败预检不会创建 task。",
"先保留 --dry-run 查看 Target、source、workspaceRef、resourceBundleRef、session 与 SecretRef 摘要。",
],
valuesPrinted: false,
},
});
}
}
function assertLegalAipodWorkspaceRef(task: Record<string, unknown>, aipod: string): void {
const workspaceRef = record(task.workspaceRef);
const keys = Object.keys(workspaceRef).sort();
const illegalKeys = keys.filter((key) => key !== "kind" && key !== "path");
if (typeof workspaceRef.kind !== "string" || typeof workspaceRef.path !== "string" || illegalKeys.length > 0) {
throw new AgentRunRestError(
"validation-failed",
`AipodSpec ${aipod} rendered an illegal workspaceRef; only kind/path are accepted.`,
{
details: {
stage: "aipod-workspace-ref",
keys,
illegalKeys,
mutation: false,
valuesPrinted: false,
recoveryActions: ["修复 AipodSpec 默认 workspaceRef 后重试;不要把 repo/ref 写入 workspaceRef。"],
},
},
);
}
}
export function targetTaskDispatchDryRunPlan(idempotencyKey: string): Record<string, unknown> {
return {
enabled: true,
sequence: ["queue-submit", "queue-dispatch"],
request: {
method: "POST",
pathTemplate: "/api/v1/queue/tasks/<submitted-task-id>/dispatch",
bodyKeys: [],
valuesPrinted: false,
},
condition: "dispatch only when the idempotent submit result is pending",
replay: "running or terminal task returns its existing attempt/run/session without another dispatch",
taskIdempotencyKey: idempotencyKey,
mutation: false,
valuesPrinted: false,
};
}
export async function autoDispatchTargetTask(
submitted: Record<string, unknown>,
targetTask: Record<string, unknown>,
aipodBinding: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const submittedTask = record(innerData(submitted));
const taskId = stringOrNull(submittedTask.id);
if (taskId === null) {
throw new AgentRunRestError("schema-mismatch", "queue submit did not return a task id before auto-dispatch", {
details: { stage: "queue-submit", mutation: true, dispatchMutation: false, valuesPrinted: false },
});
}
if (stringOrNull(submittedTask.state) !== "pending") {
return targetTaskDispatchResult(submitted, null, submittedTask, targetTask, aipodBinding, "idempotent-replay");
}
try {
const dispatched = await agentRunRestRequest(
"agentrun queue auto-dispatch",
"POST",
`/api/v1/queue/tasks/${encodeURIComponent(taskId)}/dispatch`,
{},
);
return targetTaskDispatchResult(submitted, dispatched, submittedTask, targetTask, aipodBinding, "dispatched");
} catch (error) {
const observed = await agentRunRestRequest(
"agentrun queue auto-dispatch recovery",
"GET",
`/api/v1/queue/tasks/${encodeURIComponent(taskId)}`,
);
const observedTask = record(innerData(observed));
if (stringOrNull(observedTask.state) === "pending") throw error;
return targetTaskDispatchResult(submitted, null, observedTask, targetTask, aipodBinding, "concurrent-replay");
}
}
function targetTaskDispatchResult(
submitted: Record<string, unknown>,
dispatched: Record<string, unknown> | null,
submittedTask: Record<string, unknown>,
targetTask: Record<string, unknown>,
aipodBinding: Record<string, unknown>,
decision: "dispatched" | "idempotent-replay" | "concurrent-replay",
): Record<string, unknown> {
const dispatchData = dispatched === null ? {} : record(innerData(dispatched));
const task = Object.keys(record(dispatchData.task)).length > 0 ? record(dispatchData.task) : submittedTask;
const run = record(dispatchData.run);
const command = record(dispatchData.command);
const latestAttempt = Object.keys(record(dispatchData.latestAttempt)).length > 0
? record(dispatchData.latestAttempt)
: record(task.latestAttempt);
const sessionRef = record(run.sessionRef);
const taskId = stringOrNull(task.id) ?? stringOrNull(submittedTask.id);
const identity = {
taskId,
taskState: task.state ?? submittedTask.state ?? null,
attemptId: latestAttempt.attemptId ?? null,
runId: run.id ?? latestAttempt.runId ?? null,
commandId: command.id ?? latestAttempt.commandId ?? null,
runnerJobId: latestAttempt.runnerJobId ?? null,
sessionId: latestAttempt.sessionId ?? sessionRef.sessionId ?? null,
valuesPrinted: false,
};
const autoDispatch = {
enabled: true,
decision,
mutation: decision === "dispatched",
duplicateDispatchPrevented: decision !== "dispatched",
identity,
valuesPrinted: false,
};
return {
ok: true,
action: "queue-submit-auto-dispatch",
data: {
...identity,
mutation: decision === "dispatched",
autoDispatch,
task,
...(Object.keys(run).length === 0 ? {} : { run }),
...(Object.keys(command).length === 0 ? {} : { command }),
latestAttempt,
valuesPrinted: false,
},
bridge: dispatched?.bridge ?? submitted.bridge,
targetTask,
aipodBinding,
autoDispatch,
valuesPrinted: false,
};
}
export async function mutateQueueTaskRest(action: string, taskId: string, suffix: string, body: Record<string, unknown>, args: string[]): Promise<Record<string, unknown>> {
const pathValue = `/api/v1/queue/tasks/${encodeURIComponent(taskId)}/${suffix}`;
if (agentRunHasFlag(args, "dry-run")) return agentRunDryRunPlan(action, pathValue, body, `bun scripts/cli.ts agentrun queue ${suffix} ${taskId}`);
@@ -727,30 +935,59 @@ export function agentRunAipodBindingDisclosure(task: Record<string, unknown>, ai
const providerCredentials = arrayRecords(secretScope.providerCredentials).map((credential) => ({
profile: credential.profile ?? null,
secretRef: {
namespace: policyTarget.spec.runtime.namespace,
name: record(credential.secretRef).name ?? null,
keys: Array.isArray(record(credential.secretRef).keys) ? record(credential.secretRef).keys : [],
},
bindingPresent: typeof record(credential.secretRef).name === "string",
valuesPrinted: false,
}));
const toolCredentials = arrayRecords(secretScope.toolCredentials).map((credential) => ({
tool: credential.tool ?? null,
purpose: credential.purpose ?? null,
secretRef: {
namespace: policyTarget.spec.runtime.namespace,
name: record(credential.secretRef).name ?? null,
keys: Array.isArray(record(credential.secretRef).keys) ? record(credential.secretRef).keys : [],
},
bindingPresent: typeof record(credential.secretRef).name === "string",
projection: record(credential.projection),
valuesPrinted: false,
}));
const workspaceRef = record(task.workspaceRef);
const resourceBundleRef = record(task.resourceBundleRef);
const sessionRef = record(task.sessionRef);
return {
aipod,
node: policyTarget.spec.nodeId,
lane: policyTarget.spec.lane,
namespace: policyTarget.spec.runtime.namespace,
policySource: policyTarget.source,
projectId: task.projectId ?? null,
providerId: task.providerId ?? null,
backendProfile: task.backendProfile ?? null,
workspaceRef: task.workspaceRef ?? null,
workspaceRef: {
kind: workspaceRef.kind ?? null,
path: workspaceRef.path ?? null,
legalKeys: Object.keys(workspaceRef).sort(),
schema: "kind/path",
valid: typeof workspaceRef.kind === "string"
&& typeof workspaceRef.path === "string"
&& Object.keys(workspaceRef).every((key) => key === "kind" || key === "path"),
},
resourceBundleRef: {
kind: resourceBundleRef.kind ?? null,
repoUrl: safeRepositoryUrl(stringOrNull(resourceBundleRef.repoUrl)),
ref: resourceBundleRef.ref ?? null,
inheritedFromAipod: true,
credentialRefPresent: Object.keys(record(resourceBundleRef.credentialRef)).length > 0,
valuesPrinted: false,
},
session: {
sessionId: sessionRef.sessionId ?? null,
identitySource: sessionRef.sessionId === undefined ? "aipod-render-default" : "aipod-rendered-sessionRef",
valuesPrinted: false,
},
executionPolicy: pickCompact(executionPolicy, ["sandbox", "approval", "timeoutMs", "network"]),
providerCredentials,
toolCredentials,
@@ -758,6 +995,11 @@ export function agentRunAipodBindingDisclosure(task: Record<string, unknown>, ai
};
}
function safeRepositoryUrl(value: string | null): string | null {
if (value === null) return null;
return value.replace(/^(https?:\/\/)[^@/]+@/u, "$1[redacted]@");
}
export function agentRunSessionRunPolicyDisclosure(runBody: Record<string, unknown>): Record<string, unknown> {
const policyTarget = resolveAgentRunSessionPolicyTarget();
const executionPolicy = record(runBody.executionPolicy);
+128
View File
@@ -0,0 +1,128 @@
import { readFileSync } from "node:fs";
import type { UniDeskConfig } from "../config";
import type { RenderedCliResult } from "../output";
import { resolveAgentRunLaneTarget } from "../agentrun-lanes";
import { runSshCommandCapture } from "../ssh";
import { readAgentRunClientConfig } from "./config";
import { renderMachine, renderedCliResult } from "./render";
import type { AgentRunResourceOptions } from "./utils";
export async function runAgentRunResourceThroughTarget(
config: UniDeskConfig | null,
command: string,
canonicalArgs: string[],
options: AgentRunResourceOptions,
): Promise<RenderedCliResult | null> {
const client = readAgentRunClientConfig();
if (client.client.transport !== "target-trans") return null;
if (config === null) return null;
const execution = client.client.targetExecution;
if (execution === null) throw new Error(`${client.sourcePath}: client.targetExecution is required for target-trans`);
const { configPath, spec } = resolveAgentRunLaneTarget({ node: options.node, lane: options.lane });
const activeTarget = process.env[execution.markerEnv] ?? null;
if (activeTarget === spec.nodeId) return null;
if (activeTarget !== null) {
throw new Error(`${execution.markerEnv}=${activeTarget} cannot re-enter a second Target ${spec.nodeId}`);
}
const route = `${spec.nodeRoute}:${spec.nodeUnideskWorkspace}`;
const targetArgs = [...canonicalArgs];
if (!hasOption(targetArgs, "target")) targetArgs.push("--target", spec.nodeId);
if (!hasOption(targetArgs, "lane")) targetArgs.push("--lane", spec.lane);
const stdin = targetExecutionStdin(targetArgs);
const capture = await runSshCommandCapture(config, route, [
"argv",
"env",
`${execution.markerEnv}=${spec.nodeId}`,
execution.executable,
execution.cliPath,
"agentrun",
...targetArgs,
], stdin);
const disclosure = {
transport: "target-trans",
target: spec.nodeId,
lane: spec.lane,
route,
configPath,
unideskWorkspace: spec.nodeUnideskWorkspace,
markerEnv: execution.markerEnv,
mutation: false,
valuesPrinted: false,
};
if (capture.exitCode !== 0) {
const detail = boundedRemoteFailure(capture.stderr || capture.stdout);
return renderedCliResult(
false,
command,
[
`ERROR target-trans/${spec.nodeId}`,
`route: ${route}`,
`exitCode: ${capture.exitCode}`,
`message: ${detail || "Target CLI did not return output"}`,
].join("\n"),
);
}
const output = capture.stdout.trimEnd();
if (options.raw || options.output === "json") {
const payload = parseTargetMachineOutput(output, "json");
return renderMachine(command, attachTargetExecution(payload, disclosure), "json", machineOutputOk(payload));
}
if (options.output === "yaml") {
const payload = parseTargetMachineOutput(output, "yaml");
return renderMachine(command, attachTargetExecution(payload, disclosure), "yaml", machineOutputOk(payload));
}
return renderedCliResult(
true,
command,
[`TargetExecution: trans ${route} lane=${spec.lane}`, output].filter(Boolean).join("\n"),
);
}
function hasOption(args: string[], name: string): boolean {
const flag = `--${name}`;
return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
}
function targetExecutionStdin(args: string[]): string | undefined {
const stdinFlag = args.some((arg) => arg === "--prompt-stdin" || arg === "--stdin"
|| arg === "--json-stdin" || arg === "--yaml-stdin");
const stdinFile = optionIsStdin(args, "prompt-file") || optionIsStdin(args, "json-file")
|| optionIsStdin(args, "yaml-file") || optionIsStdin(args, "file") || optionIsStdin(args, "filename")
|| args.some((arg, index) => arg === "-f" && args[index + 1] === "-");
return stdinFlag || stdinFile ? readFileSync(0, "utf8") : undefined;
}
function optionIsStdin(args: string[], name: string): boolean {
const flag = `--${name}`;
return args.some((arg, index) => (arg === flag && args[index + 1] === "-") || arg === `${flag}=-`);
}
function parseTargetMachineOutput(output: string, mode: "json" | "yaml"): unknown {
if (output.length === 0) throw new Error(`Target CLI returned empty ${mode} output`);
try {
return mode === "json" ? JSON.parse(output) : Bun.YAML.parse(output);
} catch (error) {
throw new Error(`Target CLI returned invalid ${mode} output: ${error instanceof Error ? error.message : String(error)}`);
}
}
function attachTargetExecution(value: unknown, disclosure: Record<string, unknown>): Record<string, unknown> {
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
return { ...(value as Record<string, unknown>), targetExecution: disclosure };
}
return { data: value, targetExecution: disclosure };
}
function machineOutputOk(value: unknown): boolean {
return !(typeof value === "object" && value !== null && (value as Record<string, unknown>).ok === false);
}
function boundedRemoteFailure(value: string): string {
return value
.replace(/https?:\/\/[^@\s]+@/gu, "https://[redacted]@")
.replace(/(token|password|authorization)[=:][^\s]+/giu, "$1=[redacted]")
.replace(/\s+/gu, " ")
.trim()
.slice(0, 600);
}
+339
View File
@@ -0,0 +1,339 @@
import { createHash } from "node:crypto";
import { realpathSync, statSync } from "node:fs";
import { spawnSync } from "node:child_process";
export interface AgentRunTargetTaskRequest {
readonly target: string;
readonly targetWorkspace: string;
readonly repository: string;
readonly ref: string;
readonly mdtodoId: string;
}
export interface AgentRunTargetTaskPreflight extends AgentRunTargetTaskRequest {
readonly targetRoute: string;
readonly projectId: string;
readonly workspaceRoot: string;
readonly currentBranch: string;
readonly headCommit: string;
readonly clean: true;
readonly originRepository: string;
readonly verifiedCommit: string;
readonly matchedRemoteRef: string;
readonly refRelation: "branch-and-head-match-remote-ref";
readonly sourceAuthority: {
readonly kind: "target-workspace-origin";
readonly credentialScope: "target-owned-git-credential";
readonly valuesPrinted: false;
};
}
export class AgentRunTargetTaskPreflightError extends Error {
readonly stage: string;
readonly details: Record<string, unknown>;
constructor(stage: string, message: string, details: Record<string, unknown> = {}) {
super(message);
this.name = "AgentRunTargetTaskPreflightError";
this.stage = stage;
this.details = { ...details, mutation: false, valuesPrinted: false };
}
}
export function targetTaskRequestFromArgs(args: string[]): AgentRunTargetTaskRequest | null {
const raw = {
target: option(args, "target"),
targetWorkspace: option(args, "target-workspace"),
repository: option(args, "repo"),
ref: option(args, "ref"),
mdtodoId: option(args, "mdtodo-id"),
};
const targetMode = raw.targetWorkspace !== null || raw.repository !== null || raw.ref !== null || raw.mdtodoId !== null;
if (!targetMode) return null;
const missing = Object.entries(raw).filter(([, value]) => value === null).map(([key]) => key);
if (missing.length > 0) {
throw new AgentRunTargetTaskPreflightError(
"target-context",
`Artificer Target 派单缺少 ${missing.join(", ")};请同时提供 --target、--target-workspace、--repo、--ref 和 --mdtodo-id。`,
{ missing },
);
}
return {
target: validateTarget(raw.target as string),
targetWorkspace: validateTargetWorkspace(raw.targetWorkspace as string),
repository: normalizeRepository(raw.repository as string),
ref: validateRef(raw.ref as string),
mdtodoId: validateMdtodoId(raw.mdtodoId as string),
};
}
export function preflightTargetTask(request: AgentRunTargetTaskRequest, markerEnv: string): AgentRunTargetTaskPreflight {
if (process.env[markerEnv] !== request.target) {
throw new AgentRunTargetTaskPreflightError(
"target-execution",
`Target 预检只能在外层 trans 重入 ${request.target} 后执行;当前缺少受控执行标记 ${markerEnv}`,
{ target: request.target, markerEnv, targetRoute: `${request.target}:${request.targetWorkspace}` },
);
}
let workspaceRoot: string;
try {
if (!statSync(request.targetWorkspace).isDirectory()) throw new Error("not a directory");
workspaceRoot = realpathSync(request.targetWorkspace);
} catch {
throw new AgentRunTargetTaskPreflightError(
"workspace",
`Target 工作区不存在或不是目录:${request.target}:${request.targetWorkspace}`,
{ target: request.target, targetWorkspace: request.targetWorkspace },
);
}
const gitRoot = git(request, ["rev-parse", "--show-toplevel"], "workspace-git-root").trim();
let observedRoot: string;
try {
observedRoot = realpathSync(gitRoot);
} catch {
throw new AgentRunTargetTaskPreflightError(
"workspace-git-root",
`Target Git 返回了无效工作区根目录:${gitRoot}`,
{ targetWorkspace: request.targetWorkspace },
);
}
if (observedRoot !== workspaceRoot) {
throw new AgentRunTargetTaskPreflightError(
"workspace-git-root",
`--target-workspace 必须指向 Git 工作区根目录;当前根目录是 ${gitRoot}`,
{ targetWorkspace: request.targetWorkspace, observedWorkspaceRoot: gitRoot },
);
}
const originUrl = git(request, ["remote", "get-url", "origin"], "origin").trim();
const originRepository = repositoryFromRemote(originUrl);
if (originRepository === null || originRepository.toLowerCase() !== request.repository.toLowerCase()) {
throw new AgentRunTargetTaskPreflightError(
"origin",
`Target 工作区 origin 与 --repo 不一致;期望 ${request.repository}`,
{
expectedRepository: request.repository,
observedRepository: originRepository,
observedRemoteFingerprint: sha256(originUrl),
},
);
}
const status = git(request, ["status", "--porcelain=v1", "--untracked-files=normal"], "workspace-status");
const dirtyEntryCount = status.split(/\r?\n/u).filter(Boolean).length;
if (dirtyEntryCount > 0) {
throw new AgentRunTargetTaskPreflightError(
"workspace-status",
"Target 固定工作区存在未提交修改;请先保存并按语义提交并行改动,再重新派单。",
{ targetWorkspace: request.targetWorkspace, dirtyEntryCount },
);
}
const currentBranch = git(request, ["branch", "--show-current"], "workspace-branch").trim() || "(detached)";
const headCommit = git(request, ["rev-parse", "HEAD"], "workspace-head").trim().toLowerCase();
const remote = git(request, ["ls-remote", "--exit-code", "origin", request.ref], "remote-ref");
const refs = remote.split(/\r?\n/u)
.map((line) => line.trim().split(/\s+/u))
.filter((parts) => /^[0-9a-f]{40}$/u.test(parts[0] ?? "") && (parts[1] ?? "").length > 0)
.map((parts) => ({ commit: parts[0] as string, ref: parts[1] as string }));
const selected = refs.find((item) => item.ref === `refs/heads/${request.ref}`) ?? refs[0];
if (selected === undefined) {
throw new AgentRunTargetTaskPreflightError(
"remote-ref",
`Target 工作区 origin 中找不到 --ref ${request.ref}`,
{ repository: request.repository, ref: request.ref },
);
}
if (currentBranch !== request.ref || headCommit !== selected.commit.toLowerCase()) {
throw new AgentRunTargetTaskPreflightError(
"workspace-ref-relation",
"Target 任务 worktree 必须检出 --ref,且 HEAD 必须等于 origin 远端提交;请先完成分支切换、提交和推送。",
{
targetWorkspace: request.targetWorkspace,
currentBranch,
expectedBranch: request.ref,
headCommit,
remoteCommit: selected.commit,
matchedRemoteRef: selected.ref,
},
);
}
return {
...request,
targetRoute: `${request.target}:${request.targetWorkspace}`,
projectId: request.repository,
workspaceRoot,
currentBranch,
headCommit,
clean: true,
originRepository,
verifiedCommit: selected.commit,
matchedRemoteRef: selected.ref,
refRelation: "branch-and-head-match-remote-ref",
sourceAuthority: {
kind: "target-workspace-origin",
credentialScope: "target-owned-git-credential",
valuesPrinted: false,
},
};
}
export function targetTaskPrompt(preflight: AgentRunTargetTaskPreflight, userPrompt: string | null): string {
const context = [
"任务目标上下文(由 UniDesk YAML-first CLI 在创建 task 前经 Target trans 只读核验):",
`- MDTODO ID: ${preflight.mdtodoId}`,
`- Target: ${preflight.target}`,
`- targetWorkspace: ${preflight.targetWorkspace}`,
`- targetRoute: ${preflight.targetRoute}`,
`- repository: ${preflight.repository}`,
`- ref: ${preflight.ref}`,
`- verifiedCommit: ${preflight.verifiedCommit}`,
`- refRelation: ${preflight.refRelation}`,
"- sourceAuthority: target-workspace-origin(凭据值不进入 task、prompt 或日志)",
"",
"执行边界:",
`- 目标源码的探测、Git、编辑、验证和运行面观察全部使用 \`trans ${preflight.target}:${preflight.targetWorkspace} <operation>\` 或对应 k3s route。`,
"- runner primary workspace 只承载 Artificer 默认受控 resource bundle、工具与 skill;不得把它当作目标源码,也不得用容器本地结果替代 Target 原入口证据。",
`- 最终报告必须回链 MDTODO ${preflight.mdtodoId}`,
].join("\n");
return userPrompt === null || userPrompt.trim().length === 0 ? context : `${context}\n\n${userPrompt}`;
}
export function targetTaskDisclosure(preflight: AgentRunTargetTaskPreflight): Record<string, unknown> {
return {
mdtodoId: preflight.mdtodoId,
target: preflight.target,
targetWorkspace: preflight.targetWorkspace,
targetRoute: preflight.targetRoute,
repository: preflight.repository,
ref: preflight.ref,
projectId: preflight.projectId,
verifiedCommit: preflight.verifiedCommit,
matchedRemoteRef: preflight.matchedRemoteRef,
refRelation: preflight.refRelation,
workspace: {
root: preflight.workspaceRoot,
branch: preflight.currentBranch,
headCommit: preflight.headCommit,
clean: preflight.clean,
},
sourceAuthority: preflight.sourceAuthority,
mutation: false,
valuesPrinted: false,
};
}
export function targetTaskIdempotencyKey(preflight: AgentRunTargetTaskPreflight): string {
const identity = [
preflight.target,
preflight.targetWorkspace,
preflight.repository,
preflight.ref,
preflight.mdtodoId,
preflight.verifiedCommit,
].join("\n");
return `artificer-target:${createHash("sha256").update(identity).digest("hex")}`;
}
function git(request: AgentRunTargetTaskRequest, args: string[], stage: string): string {
const result = spawnSync("git", args, {
cwd: request.targetWorkspace,
encoding: "utf8",
timeout: 15_000,
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
maxBuffer: 1024 * 1024,
});
if (result.error !== undefined || result.status !== 0) {
const stderr = String(result.stderr ?? "").trim().split(/\r?\n/u)[0] ?? "";
throw new AgentRunTargetTaskPreflightError(
stage,
`Target Git 只读预检失败(${stage});请先在 ${request.target}:${request.targetWorkspace} 修复 origin/ref/凭据可达性。`,
{
target: request.target,
targetWorkspace: request.targetWorkspace,
repository: request.repository,
ref: request.ref,
exitCode: result.status,
timedOut: result.error?.name === "ETIMEDOUT",
stderrSummary: redactGitError(stderr),
},
);
}
return String(result.stdout ?? "");
}
function option(args: string[], name: string): string | null {
const flag = `--${name}`;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index] ?? "";
if (arg === flag) {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) {
throw new AgentRunTargetTaskPreflightError("target-context", `${flag} requires a value`);
}
return value;
}
if (arg.startsWith(`${flag}=`)) return arg.slice(flag.length + 1);
}
return null;
}
function validateTarget(value: string): string {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(value)) {
throw new AgentRunTargetTaskPreflightError("target-context", "--target 必须是 YAML 声明的 Target ID");
}
return value;
}
function validateTargetWorkspace(value: string): string {
if (!value.startsWith("/") || value.includes("\0")) {
throw new AgentRunTargetTaskPreflightError("target-context", "--target-workspace 必须是 Target 上的绝对路径");
}
return value.replace(/\/+$/u, "") || "/";
}
function normalizeRepository(value: string): string {
const normalized = value
.replace(/^https:\/\/github\.com\//u, "")
.replace(/^git@github\.com:/u, "")
.replace(/\.git$/u, "");
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(normalized)) {
throw new AgentRunTargetTaskPreflightError("target-context", "--repo 必须使用 owner/repo 或等价 GitHub URL");
}
return normalized;
}
function validateRef(value: string): string {
const disallowed = /[\u0000-\u0020~^:?*\\\[]/u;
if (value.length === 0 || value.length > 255 || value.startsWith("-") || disallowed.test(value)
|| value.includes("..") || value.endsWith(".") || value.endsWith("/") || value.includes("//")) {
throw new AgentRunTargetTaskPreflightError("target-context", "--ref 不是安全的 Git ref");
}
return value;
}
function validateMdtodoId(value: string): string {
if (!/^R[1-9][0-9]*(?:\.[1-9][0-9]*)*$/u.test(value)) {
throw new AgentRunTargetTaskPreflightError("target-context", "--mdtodo-id 必须使用 R1 或 R1.1 这类 MDTODO ID");
}
return value;
}
function repositoryFromRemote(value: string): string | null {
const normalized = value.replace(/\.git$/u, "");
const match = normalized.match(/^(?:git@github\.com:|https:\/\/github\.com\/)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)$/u);
return match?.[1] ?? null;
}
function redactGitError(value: string): string {
return value
.replace(/https?:\/\/[^@\s]+@/gu, "https://[redacted]@")
.replace(/(token|password|authorization)[=:][^\s]+/giu, "$1=[redacted]")
.slice(0, 240);
}
function sha256(value: string): string {
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
}
+5
View File
@@ -87,6 +87,11 @@ export interface AgentRunResourceOptions {
promptStdin: boolean;
node: string | null;
lane: string | null;
target: string | null;
targetWorkspace: string | null;
repository: string | null;
ref: string | null;
mdtodoId: string | null;
passthroughArgs: string[];
}
+11 -4
View File
@@ -27,17 +27,24 @@ export function renderMirrorPlan(result: Record<string, unknown>): RenderedCliRe
}
export function renderMirrorBootstrap(result: Record<string, unknown>): RenderedCliResult {
const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.owner), stringValue(repo.name), boolText(record(repo.create).ok)]);
const repos = arrayRecords(result.repositories).map((repo) => [
stringValue(repo.key),
stringValue(repo.owner, stringValue(repo.giteaOwner)),
stringValue(repo.name, stringValue(repo.giteaRepo)),
result.mode === "dry-run" ? "planned" : boolText(record(repo.create).ok),
]);
const blockers = Array.isArray(result.blockers) ? result.blockers.map(String) : [];
const next = record(result.next);
return rendered(result, "platform-infra gitea mirror bootstrap", [
"PLATFORM-INFRA GITEA MIRROR BOOTSTRAP",
...table(["OK", "MUTATION", "TARGET"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id)]]),
...table(["OK", "MODE", "MUTATION", "TARGET"], [[boolText(result.ok), stringValue(result.mode), boolText(result.mutation), stringValue(record(result.target).id)]]),
"",
"REPOSITORIES",
...(repos.length === 0 ? ["-"] : table(["KEY", "OWNER", "REPO", "API_OK"], repos)),
...(repos.length === 0 ? ["-"] : table(["KEY", "OWNER", "REPO", "STATE"], repos)),
...(blockers.length === 0 ? [] : ["", `BLOCKERS ${blockers.join(",")}`]),
...remoteErrorLines(result),
"",
`NEXT ${stringValue(next.webhookStatus)}`,
`NEXT ${stringValue(result.mode === "dry-run" ? next.confirm : next.webhookStatus)}`,
]);
}
+37 -2
View File
@@ -71,6 +71,8 @@ interface ApplyOptions extends CommonOptions {
interface MirrorOptions extends CommonOptions {
confirm: boolean;
dryRun: boolean;
wait: boolean;
repoKey: string | null;
}
@@ -127,6 +129,7 @@ export function giteaHelp(scope?: string): Record<string, unknown> {
usage: [
"bun scripts/cli.ts platform-infra gitea apply --target <NODE> --dry-run",
"bun scripts/cli.ts platform-infra gitea apply --target <NODE> --confirm",
"bun scripts/cli.ts platform-infra gitea mirror bootstrap --target <NODE> --dry-run",
"bun scripts/cli.ts platform-infra gitea mirror bootstrap --target <NODE> --confirm",
"bun scripts/cli.ts platform-infra gitea mirror webhook apply --target <NODE> --confirm",
],
@@ -357,8 +360,28 @@ async function mirrorStatus(config: UniDeskConfig, options: CommonOptions & { re
async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): Promise<Record<string, unknown>> {
const gitea = readGiteaConfig();
const target = resolveCommandTarget(gitea, options.targetId);
if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-bootstrap", mutation: false, mode: "missing-confirm", error: "mirror bootstrap requires --confirm" };
const repos = selectedRepositories(gitea, target, options.repoKey);
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 repoFlag = options.repoKey === null ? "" : ` --repo ${options.repoKey}`;
return {
ok: blockers.length === 0,
action: "platform-infra-gitea-mirror-bootstrap",
mutation: false,
mode: "dry-run",
target: targetSummary(target),
repositories: repos.map((repo) => repositorySummary(gitea, repo)),
credentials,
blockers,
next: {
...mirrorReadOnlyNextCommands(target.id),
confirm: `bun scripts/cli.ts platform-infra gitea mirror bootstrap --target ${target.id}${repoFlag} --confirm`,
},
};
}
const secrets = ensureMirrorSecrets(gitea, true, true);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-bootstrap", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }).script);
const parsed = parseJsonOutput(result.stdout);
@@ -366,6 +389,7 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P
ok: result.exitCode === 0 && parsed?.ok === true,
action: "platform-infra-gitea-mirror-bootstrap",
mutation: true,
mode: "confirmed",
target: targetSummary(target),
repositories: arrayRecords(record(parsed).repositories),
credentials: credentialSummaries(gitea),
@@ -1759,11 +1783,14 @@ function parseMirrorWebhookStatusOptions(args: string[]): MirrorWebhookStatusOpt
function parseMirrorOptions(args: string[]): MirrorOptions {
const commonArgs: string[] = [];
let confirm = false;
let dryRun = false;
let repoKey: string | null = null;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--confirm") {
confirm = true;
} else if (arg === "--dry-run") {
dryRun = true;
} else if (arg === "--repo") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new CliInputError("--repo requires a value", {
@@ -1786,7 +1813,15 @@ function parseMirrorOptions(args: string[]): MirrorOptions {
}
}
}
return { ...parseCommonOptions(commonArgs), confirm, repoKey };
if (confirm && dryRun) throw new CliInputError("Gitea mirror bootstrap accepts only one of --confirm or --dry-run", {
code: "mutually-exclusive-options",
argument: "--confirm --dry-run",
usage: [
"bun scripts/cli.ts platform-infra gitea mirror bootstrap --target <node> --dry-run",
"bun scripts/cli.ts platform-infra gitea mirror bootstrap --target <node> --confirm",
],
});
return { ...parseCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait: false, repoKey };
}
function parseCommonOptions(args: string[]): CommonOptions {
@@ -0,0 +1,80 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import type { GiteaConfig, GiteaMirrorRepository } from "./platform-infra-gitea-config";
import { renderMirrorBootstrap } from "./platform-infra-gitea-render";
import { resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap";
import { runPlatformInfraPipelinesAsCodeCommand } from "./platform-infra-pipelines-as-code";
const mirror: GiteaMirrorRepository = {
key: "widgets-nc01",
targetId: "NC01",
upstream: { repository: "acme/widgets", cloneUrl: "https://github.com/acme/widgets.git", branch: "main", visibility: "public" },
gitea: { owner: "mirrors", name: "acme-widgets", mirrorMode: "controlled-push", publicRead: true, readUrl: "http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/acme-widgets.git" },
gitops: { branch: "main", flushDisposition: "not-a-gitops-branch" },
snapshot: { prefix: "refs/unidesk/snapshots/widgets-main-nc01" },
legacyGitMirror: null,
};
function fixtureConfig(repositories: GiteaMirrorRepository[]): GiteaConfig {
return { sourceAuthority: { repositories } } as unknown as GiteaConfig;
}
test("PaC bootstrap uses one exact YAML-owned Gitea repository", () => {
const selected = resolvePacBootstrapMirrorRepository(fixtureConfig([mirror]), "nc01", {
id: "widgets-nc01",
owner: "mirrors",
repo: "acme-widgets",
cloneUrl: `${mirror.gitea.readUrl}/`,
});
expect(selected.key).toBe("widgets-nc01");
expect(() => resolvePacBootstrapMirrorRepository(fixtureConfig([]), "NC01", {
id: "widgets-nc01",
owner: "mirrors",
repo: "acme-widgets",
cloneUrl: mirror.gitea.readUrl,
})).toThrow("no exact match");
expect(() => resolvePacBootstrapMirrorRepository(fixtureConfig([mirror, { ...mirror }]), "NC01", {
id: "widgets-nc01",
owner: "mirrors",
repo: "acme-widgets",
cloneUrl: mirror.gitea.readUrl,
})).toThrow("2 exact matches");
});
test("Gitea mirror bootstrap defaults to a visible dry-run instead of an empty refusal", () => {
const rendered = renderMirrorBootstrap({
ok: true,
action: "platform-infra-gitea-mirror-bootstrap",
mode: "dry-run",
mutation: false,
target: { id: "NC01" },
repositories: [{ key: "widgets-nc01", giteaOwner: "mirrors", giteaRepo: "acme-widgets" }],
blockers: [],
next: { confirm: "bun scripts/cli.ts platform-infra gitea mirror bootstrap --target NC01 --repo widgets-nc01 --confirm" },
}).renderedText;
expect(rendered).toContain("MODE MUTATION TARGET");
expect(rendered).toContain("widgets-nc01");
expect(rendered).toContain("--repo widgets-nc01 --confirm");
expect(rendered).not.toContain("missing-confirm");
});
test("PaC apply checks the Gitea repository before dry-run return and cluster mutation", () => {
const remote = readFileSync(resolve(import.meta.dir, "platform-infra-pipelines-as-code-remote.sh"), "utf8");
const action = remote.indexOf("apply_action() {");
const preflight = remote.indexOf("if ! repository_preflight", action);
const dryRun = remote.indexOf('if [ "$UNIDESK_PAC_DRY_RUN" = "1" ]', action);
const release = remote.indexOf(" apply_release", action);
expect(action).toBeGreaterThanOrEqual(0);
expect(preflight).toBeGreaterThan(action);
expect(dryRun).toBeGreaterThan(preflight);
expect(release).toBeGreaterThan(dryRun);
expect(remote).toContain('"error":"gitea-repository-missing"');
});
test("platform bootstrap help advertises the composite entry before low-level apply", async () => {
const help = await runPlatformInfraPipelinesAsCodeCommand({} as never, ["help", "platform-bootstrap"]);
const usage = (help as Record<string, unknown>).usage as string[];
expect(usage[0]).toContain("pipelines-as-code bootstrap");
expect(usage).toContain("bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target <NODE> --consumer <consumer> --confirm");
});
@@ -0,0 +1,35 @@
import type { GiteaConfig, GiteaMirrorRepository } from "./platform-infra-gitea-config";
export interface PacBootstrapRepositoryIdentity {
readonly id: string;
readonly owner: string;
readonly repo: string;
readonly cloneUrl: string;
}
export function resolvePacBootstrapMirrorRepository(
gitea: GiteaConfig,
targetId: string,
repository: PacBootstrapRepositoryIdentity,
): GiteaMirrorRepository {
const candidates = gitea.sourceAuthority.repositories.filter((item) => (
item.targetId.toLowerCase() === targetId.toLowerCase()
&& item.gitea.owner === repository.owner
&& item.gitea.name === repository.repo
&& repositoryUrlIdentity(item.gitea.readUrl) === repositoryUrlIdentity(repository.cloneUrl)
));
if (candidates.length !== 1) {
const disposition = candidates.length === 0 ? "no exact match" : `${candidates.length} exact matches`;
throw new Error(
`cannot resolve the YAML-owned Gitea bootstrap repository for PaC repository ${repository.id} on ${targetId}: ${disposition}; `
+ "match targetId, gitea owner/name, and read URL in config/platform-infra/gitea.yaml",
);
}
return candidates[0];
}
function repositoryUrlIdentity(value: string): string {
const parsed = new URL(value);
const path = parsed.pathname.replace(/\/+$/u, "");
return `${parsed.protocol}//${parsed.host}${path}`;
}
@@ -100,6 +100,37 @@ gitea_api() {
return 22
}
gitea_api_status() {
method="$1"
path="$2"
status=$(curl -sS -o /dev/null -w '%{http_code}' \
-u "$UNIDESK_PAC_GITEA_ADMIN_USERNAME:$UNIDESK_PAC_GITEA_ADMIN_PASSWORD" \
-X "$method" \
"$(api_url "$path")" 2>/dev/null) || status=000
printf '%s' "${status:-000}"
}
repository_preflight() {
repository_path="repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO"
repository_status=$(gitea_api_status GET "$repository_path")
case "$repository_status" in
2*) return 0 ;;
404)
printf '{"ok":false,"mode":"preflight-blocked","mutation":false,"error":"gitea-repository-missing","preflight":{"repositoryExists":false,"httpStatus":"404","repository":"%s/%s","valuesPrinted":false},"valuesPrinted":false}\n' \
"$(json_string "$UNIDESK_PAC_GITEA_OWNER")" \
"$(json_string "$UNIDESK_PAC_GITEA_REPO")"
return 22
;;
*)
printf '{"ok":false,"mode":"preflight-blocked","mutation":false,"error":"gitea-repository-preflight-failed","preflight":{"repositoryExists":false,"httpStatus":"%s","repository":"%s/%s","valuesPrinted":false},"valuesPrinted":false}\n' \
"$(json_string "$repository_status")" \
"$(json_string "$UNIDESK_PAC_GITEA_OWNER")" \
"$(json_string "$UNIDESK_PAC_GITEA_REPO")"
return 22
;;
esac
}
ensure_token() {
name="unidesk-pac-$(date +%Y%m%d%H%M%S)"
body=$(printf '{"name":"%s","scopes":["all"]}' "$name")
@@ -268,8 +299,17 @@ wait_ready() {
}
apply_action() {
preflight_tmp=$(mktemp)
if ! repository_preflight >"$preflight_tmp"; then
cat "$preflight_tmp"
rm -f "$preflight_tmp"
return 22
fi
rm -f "$preflight_tmp"
if [ "$UNIDESK_PAC_DRY_RUN" = "1" ]; then
printf '{"ok":true,"mode":"dry-run","mutation":false,"valuesPrinted":false}\n'
printf '{"ok":true,"mode":"dry-run","mutation":false,"preflight":{"repositoryExists":true,"httpStatus":"2xx","repository":"%s/%s","valuesPrinted":false},"valuesPrinted":false}\n' \
"$(json_string "$UNIDESK_PAC_GITEA_OWNER")" \
"$(json_string "$UNIDESK_PAC_GITEA_REPO")"
return
fi
apply_release
@@ -311,7 +351,7 @@ apply_action() {
hook_id=$(ensure_webhook)
ready=false
if wait_ready; then ready=true; fi
printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"argoBootstrap":%s,"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ready" "$ready" "$argo_bootstrap" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")"
printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"argoBootstrap":%s,"preflight":{"repositoryExists":true,"httpStatus":"2xx","repository":"%s/%s","valuesPrinted":false},"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ready" "$ready" "$argo_bootstrap" "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" "$(json_string "$UNIDESK_PAC_GITEA_REPO")" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")"
}
condition_status() {
+129 -2
View File
@@ -28,6 +28,9 @@ import {
} from "./platform-infra-pipelines-as-code-source-artifact";
import { pacAdmissionDesiredIdentity } from "./platform-infra-pac-provenance";
import { pacConsumerRbacDesiredIdentity, renderPacConsumerRbacDesiredFragment } from "./platform-infra-pac-consumer-rbac";
import { runPlatformInfraGiteaCommand } from "./platform-infra-gitea";
import { readGiteaConfig } from "./platform-infra-gitea-config";
import { resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap";
const configFile = rootPath("config", "platform-infra", "pipelines-as-code.yaml");
const configLabel = "config/platform-infra/pipelines-as-code.yaml";
@@ -240,6 +243,11 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf
const result = await apply(config, options);
return options.json || options.full || options.raw ? result : renderApply(result);
}
if (action === "bootstrap") {
const options = parseApplyOptions(args.slice(1));
const result = await bootstrap(config, options);
return options.json || options.full || options.raw ? result : renderBootstrap(result);
}
if (action === "status") {
const options = parseCommonOptions(args.slice(1));
const result = await status(config, options);
@@ -353,10 +361,12 @@ function help(scope: string | null): Record<string, unknown> {
scope: "explicit-initial-platform-bootstrap",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target <NODE> --consumer <consumer> --dry-run",
"bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target <NODE> --consumer <consumer> --confirm",
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target <NODE> --dry-run",
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target <NODE> --confirm",
],
boundary: "仅用于首次安装或显式平台 bootstrap;不得在 source PR 合并后用于交付、恢复或补跑。",
boundary: "bootstrap 是首次引导的最短入口;apply 保留为单层维护入口。两者都不得在 source PR 合并后用于交付、恢复或补跑。",
mutation: "explicit-confirm-required",
};
}
@@ -889,7 +899,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
return {
ok: result.exitCode === 0 && parsed?.ok === true,
action: "platform-infra-pipelines-as-code-apply",
mutation: !options.dryRun,
mutation: options.dryRun ? false : parsed?.mutation !== false,
mode: options.dryRun ? "dry-run" : "confirmed",
target: targetSummary(target),
config: compactConfigSummary(pac, consumer, repository),
@@ -902,6 +912,79 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
};
}
async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise<Record<string, unknown>> {
const pac = readPacConfig();
const target = resolveTarget(pac, options.targetId);
const consumer = resolveConsumer(pac, options.consumerId);
if (consumer.node.toLowerCase() !== target.id.toLowerCase()) {
throw new Error(`Pipelines-as-Code consumer ${consumer.id} belongs to ${consumer.node}, not target ${target.id}`);
}
const repository = resolveRepository(pac, consumer.repositoryRef);
const mirror = resolvePacBootstrapMirrorRepository(readGiteaConfig(), target.id, repository);
const giteaArgs = ["mirror", "bootstrap", "--target", target.id, "--repo", mirror.key, ...(options.confirm ? ["--confirm"] : []), "--full"];
const giteaBootstrap = record(await runPlatformInfraGiteaCommand(config, giteaArgs));
if (options.confirm && giteaBootstrap.ok !== true) {
return bootstrapResult(target, consumer, repository, mirror, options, giteaBootstrap, {
ok: false,
mutation: false,
mode: "skipped",
error: "gitea-bootstrap-failed",
valuesPrinted: false,
});
}
const pacApply = await apply(config, options);
return bootstrapResult(target, consumer, repository, mirror, options, giteaBootstrap, pacApply);
}
function bootstrapResult(
target: PacTarget,
consumer: PacConsumer,
repository: PacRepository,
mirror: ReturnType<typeof resolvePacBootstrapMirrorRepository>,
options: ApplyOptions,
giteaBootstrap: Record<string, unknown>,
pacApply: Record<string, unknown>,
): Record<string, unknown> {
const remote = record(pacApply.remote);
const repositoryMissing = remote.error === "gitea-repository-missing";
const pacReady = pacApply.ok === true || (!options.confirm && repositoryMissing);
const ok = giteaBootstrap.ok === true && pacReady;
const confirm = `bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target ${target.id} --consumer ${consumer.id} --confirm`;
return {
ok,
action: "platform-infra-pipelines-as-code-bootstrap",
mutation: giteaBootstrap.mutation === true || pacApply.mutation === true,
mode: options.confirm ? "confirmed" : "dry-run",
target: targetSummary(target),
consumer: consumerObservationSummary(consumer),
repository: repositorySummary(repository),
mirrorRepository: {
key: mirror.key,
upstreamRepository: mirror.upstream.repository,
upstreamBranch: mirror.upstream.branch,
owner: mirror.gitea.owner,
repo: mirror.gitea.name,
valuesPrinted: false,
},
steps: [
{ id: "gitea-repository", ok: giteaBootstrap.ok === true, mutation: giteaBootstrap.mutation === true, mode: giteaBootstrap.mode, valuesPrinted: false },
{ id: "pac-tekton-gitops", ok: pacReady, mutation: pacApply.mutation === true, mode: pacApply.mode, repositoryMissing, valuesPrinted: false },
],
giteaBootstrap,
pipelinesAsCodeApply: pacApply,
next: options.confirm
? {
deliveryTrigger: `Merge the prepared GitHub PR for ${mirror.upstream.repository}@${mirror.upstream.branch}; the automatic chain owns mirror, PaC, Tekton, GitOps, Argo, and runtime convergence.`,
investigate: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${target.id} --consumer ${consumer.id}`,
}
: {
confirm,
boundary: "Run confirm only before the first source PR merge; do not use bootstrap as post-merge recovery.",
},
valuesPrinted: false,
};
}
async function status(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
const pac = readPacConfig();
const target = resolveTarget(pac, options.targetId);
@@ -2023,6 +2106,7 @@ function renderPlan(result: Record<string, unknown>): RenderedCliResult {
function renderApply(result: Record<string, unknown>): RenderedCliResult {
const target = record(result.target);
const remote = record(result.remote);
const preflight = record(remote.preflight);
const errorText = compactLine(stringValue(remote.stderrTail, stringValue(remote.error, stringValue(result.error, ""))));
const lines = [
"PLATFORM-INFRA PIPELINES-AS-CODE APPLY",
@@ -2030,7 +2114,13 @@ function renderApply(result: Record<string, unknown>): RenderedCliResult {
"",
"REMOTE",
...table(["CONTROLLER", "WEBHOOK", "REPOSITORY", "EXIT", "VALUES"], [[stringValue(remote.controllerReady), stringValue(record(remote.webhook).present), stringValue(remote.repository), stringValue(remote.exitCode, "0"), "false"]]),
...(Object.keys(preflight).length === 0 ? [] : [
"",
"PREFLIGHT",
...table(["GITEA_REPOSITORY", "HTTP", "EXISTS"], [[stringValue(preflight.repository), stringValue(preflight.httpStatus), boolText(preflight.repositoryExists)]]),
]),
...(result.ok === false && errorText.length > 0 ? ["", `ERROR ${errorText}`] : []),
...(remote.error === "gitea-repository-missing" ? ["HELP bun scripts/cli.ts platform-infra pipelines-as-code help platform-bootstrap"] : []),
"",
"NEXT",
` status: ${stringValue(record(result.next).status)}`,
@@ -2038,6 +2128,43 @@ function renderApply(result: Record<string, unknown>): RenderedCliResult {
return rendered(result, "platform-infra pipelines-as-code apply", lines);
}
function renderBootstrap(result: Record<string, unknown>): RenderedCliResult {
const target = record(result.target);
const consumer = record(result.consumer);
const mirror = record(result.mirrorRepository);
const steps = arrayRecords(result.steps);
const pacApply = record(result.pipelinesAsCodeApply);
const pacRemote = record(pacApply.remote);
const giteaBootstrap = record(result.giteaBootstrap);
const next = record(result.next);
const errorValue = [pacRemote.error, giteaBootstrap.error, pacApply.error]
.find((value): value is string => typeof value === "string" && value.length > 0) ?? "";
const error = errorValue.length === 0 ? "" : compactLine(errorValue);
const lines = [
"PLATFORM-INFRA PAC / TEKTON / GITOPS BOOTSTRAP",
...table(["TARGET", "CONSUMER", "MODE", "MUTATION", "OK"], [[stringValue(target.id), stringValue(consumer.id), stringValue(result.mode), boolText(result.mutation), boolText(result.ok)]]),
"",
"SOURCE AUTHORITY",
...table(["GITEA_KEY", "GITEA_REPOSITORY", "UPSTREAM", "BRANCH"], [[stringValue(mirror.key), `${stringValue(mirror.owner)}/${stringValue(mirror.repo)}`, stringValue(mirror.upstreamRepository), stringValue(mirror.upstreamBranch)]]),
"",
"STEPS",
...table(["STEP", "OK", "MODE", "MUTATION", "DETAIL"], steps.map((step) => [
stringValue(step.id),
boolText(step.ok),
stringValue(step.mode),
boolText(step.mutation),
step.repositoryMissing === true ? "repository-will-be-created-on-confirm" : "ready",
])),
...(error.length === 0 ? [] : ["", `ERROR ${error}`]),
"",
"NEXT",
...(result.mode === "dry-run"
? [` confirm: ${stringValue(next.confirm)}`, ` boundary: ${stringValue(next.boundary)}`]
: [` delivery: ${stringValue(next.deliveryTrigger)}`, ` investigate: ${stringValue(next.investigate)}`]),
];
return rendered(result, "platform-infra pipelines-as-code bootstrap", lines);
}
function renderStatus(result: Record<string, unknown>): RenderedCliResult {
const summary = record(result.summary);
const consumer = record(result.consumer);