Merge remote-tracking branch 'origin/master' into fix/web-probe-memory-guard
This commit is contained in:
@@ -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 URL,k8s 内网 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 文件存在漂移时,统一使用以下入口:
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 也无推进时,才进入问询、关闭和重开流程。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 client:UniDesk 客户端保留 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 credential:GitHub 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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}`,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user