diff --git a/.agents/skills/unidesk-code-queue/SKILL.md b/.agents/skills/unidesk-code-queue/SKILL.md index dab34764..a344f422 100644 --- a/.agents/skills/unidesk-code-queue/SKILL.md +++ b/.agents/skills/unidesk-code-queue/SKILL.md @@ -21,9 +21,24 @@ bun scripts/cli.ts agentrun result run/ --command bun scripts/cli.ts agentrun get attempts --task bun scripts/cli.ts agentrun retry task/ --idempotency-key --reason --dry-run bun scripts/cli.ts agentrun send --prompt-stdin +bun scripts/cli.ts agentrun create task \ + --aipod Artificer \ + --target \ + --target-workspace \ + --repo \ + --ref \ + --mdtodo-id \ + --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 ...` 远端范围属于 `unidesk-ssh` tool target,不是旁路 source workspace; - - 访问远端 tool target 必须使用已投影的受控 `trans`,并继续遵守 route allowlist、SecretRef、脱敏和短连接边界; - - primary source 路径不可见时,先用 `agentrun events run/ --detail-seq ` 核对 durable workspace 物化事实,不让 Artificer 搜索或切换到旁路仓库。 +- Artificer 的 runner primary workspace 与任务 Target 必须分开判定: + - runner primary workspace 只装配默认 UniDesk resource bundle、`tools/trans`、skill 和轻量准备; + - 待修改源码只来自派单上下文中的 `targetWorkspace`,且该路径必须是任务专属 worktree; + - 现场探测、Git、编辑、验证和运行面观察必须使用 `trans :`; + - 不得把 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/ --input -o json`; diff --git a/.agents/skills/unidesk-code-queue/references/resources.md b/.agents/skills/unidesk-code-queue/references/resources.md index f4c842be..9cf15c59 100644 --- a/.agents/skills/unidesk-code-queue/references/resources.md +++ b/.agents/skills/unidesk-code-queue/references/resources.md @@ -11,6 +11,50 @@ - `agentrun retry task/ --idempotency-key --reason [--dry-run]`:对终态失败 task 创建下一次 attempt; - `agentrun dispatch|ack|cancel`:控制生命周期。 +## Artificer Target 单命令派单 + +标准入口: + +```bash +bun scripts/cli.ts agentrun create task \ + --aipod Artificer \ + --target \ + --target-workspace \ + --repo \ + --ref \ + --mdtodo-id \ + --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 :`; +- `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`; diff --git a/.agents/skills/unidesk-subagent/SKILL.md b/.agents/skills/unidesk-subagent/SKILL.md index 4b8aa8e2..c84e0bd9 100644 --- a/.agents/skills/unidesk-subagent/SKILL.md +++ b/.agents/skills/unidesk-subagent/SKILL.md @@ -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 :` 重入目标节点; + - 预检、创建、自动派发和观察都在重入后完成; + - Artificer 的任务 Target 操作必须继续使用 `trans :`: + - `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 也无推进时,才进入问询、关闭和重开流程。 diff --git a/config/agentrun.yaml b/config/agentrun.yaml index 9e4e3dfa..40714f1b 100644 --- a/config/agentrun.yaml +++ b/config/agentrun.yaml @@ -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: diff --git a/docs/reference/agentrun.md b/docs/reference/agentrun.md index 5294b28e..367dd159 100644 --- a/docs/reference/agentrun.md +++ b/docs/reference/agentrun.md @@ -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/` 派发;`--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-workspace \ + --repo \ + --ref \ + --mdtodo-id \ + --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 --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 :` 重入目标节点; + - marker、可执行文件和 CLI 相对路径来自 `client.targetExecution`; + - Target 内部按选中 lane 使用 `lane-k8s-service-proxy` 进入 manager `internalBaseUrl`; +- `controlPlane.nodes..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 ...` 远端范围属于 `unidesk-ssh` tool target,不属于旁路 source workspace; + - runner primary workspace 只承载默认 UniDesk resource bundle、工具、skill 和轻量准备; + - 待修改源码只来自派单上下文中的 Target task worktree; + - 现场探测、Git、编辑、验证和运行面观察都使用 `trans :`; + - 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。 - 失败归因: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 78d52372..0fb5c3dc 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -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 ` 读取阶段化 progress 和 bounded log tail;只有现场同步调试才显式加 `--wait`。`run` 手动创建每 commit 检查和 Code Queue 只读性能门禁;`publish-backend-core` 与 `publish-user-service` 从 pushed Git commit 构建并发布 `127.0.0.1:5000/unidesk/:` 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 --limit N` 是只读观察入口;`schedule run`、`schedule retry-run`、`schedule delete` 和 `schedule upsert-pgdata-backup` 会触发运行或写入配置,生产恢复时必须有明确授权。`schedule runs --limit N` 是全局历史视图,返回 `scope=global` 和 `scheduleId=null`;`schedule runs --limit N` 是指定 schedule 历史视图,返回 `scope=schedule` 和对应 `scheduleId`。CLI 必须拒绝 `schedule runs 50` 这类纯数字位置参数,并提示使用 `schedule runs --limit 50`,避免把空数组误判成“没有历史 run”。`schedule run --wait-ms N` 触发同一 schedule,并且即使 wait 超时也必须返回 `newRunId` 和 `observeCommand`;`schedule retry-run ` 只接受 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 ` 是旧 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/`、`events run/`、`logs session/`、`result run/ --command `;日常写入用 `create task --aipod Artificer --prompt-stdin`、`apply -f -`、`dispatch task/`、`send session/`、`ack/cancel task|session/`。用户级 CLI 取消 `turn` 和 `steer` 路径;`send session/` 是唯一 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/`; + - 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 --lane `、`agentrun send session/ --node --lane --dry-run` 和 `agentrun aipod-specs render --node --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 --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 --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; diff --git a/scripts/src/agentrun-lanes.ts b/scripts/src/agentrun-lanes.ts index 6953379d..8fa8d13b 100644 --- a/scripts/src/agentrun-lanes.ts +++ b/scripts/src/agentrun-lanes.ts @@ -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, 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 { 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).runId).toBe("run_target"); + expect((first.data as Record).sessionId).toBe("ses_target"); + expect(((first.autoDispatch as Record).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).decision)).toBe("idempotent-replay"); + expect(((replay.autoDispatch as Record).duplicateDispatchPrevented)).toBe(true); + + const plan = targetTaskDispatchDryRunPlan("artificer-target:test"); + expect(plan.sequence).toEqual(["queue-submit", "queue-dispatch"]); + expect((plan.request as Record).pathTemplate).toBe("/api/v1/queue/tasks//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", () => { diff --git a/scripts/src/agentrun/config.ts b/scripts/src/agentrun/config.ts index 161878b0..ea2fd6cc 100644 --- a/scripts/src/agentrun/config.ts +++ b/scripts/src/agentrun/config.ts @@ -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, 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): Record { return JSON.parse(JSON.stringify(value)) as Record; } @@ -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; diff --git a/scripts/src/agentrun/entry.ts b/scripts/src/agentrun/entry.ts index 373d6e1f..3955fcde 100644 --- a/scripts/src/agentrun/entry.ts +++ b/scripts/src/agentrun/entry.ts @@ -69,6 +69,7 @@ export function agentRunHelp(): unknown { "bun scripts/cli.ts agentrun get attempts --task --limit 20", "bun scripts/cli.ts agentrun dispatch task/", "bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin --idempotency-key ", + "bun scripts/cli.ts agentrun create task --aipod Artificer --target --target-workspace --repo --ref --mdtodo-id --prompt-stdin [--dry-run]", "bun scripts/cli.ts agentrun apply -f - --dry-run", "bun scripts/cli.ts agentrun send session/ --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/"; } if (verb === "create") { - return "Usage: bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin [--idempotency-key ] [--dry-run]"; + return "Usage: bun scripts/cli.ts agentrun create task --aipod Artificer --target --target-workspace --repo --ref --mdtodo-id --prompt-stdin [--idempotency-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: ."; @@ -411,11 +412,11 @@ export function agentRunHelpText(args: string[]): string { " bun scripts/cli.ts agentrun logs session/ --tail 100", " bun scripts/cli.ts agentrun retry task/ --idempotency-key --reason --dry-run", " bun scripts/cli.ts agentrun get attempts --task --limit 20", - " bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin", + " bun scripts/cli.ts agentrun create task --aipod Artificer --target --target-workspace --repo --ref --mdtodo-id --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"); } diff --git a/scripts/src/agentrun/render.ts b/scripts/src/agentrun/render.ts index 10a34ed4..4f5ca58c 100644 --- a/scripts/src/agentrun/render.ts +++ b/scripts/src/agentrun/render.ts @@ -496,12 +496,83 @@ export function renderMutationSummary(command: string, raw: Record 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[] { + 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[] { + 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[] { + 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[] { + 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[] { if (Object.keys(lifecycle).length === 0) return []; const authority = record(lifecycle.authority); diff --git a/scripts/src/agentrun/resource-actions.ts b/scripts/src/agentrun/resource-actions.ts index efd95c9c..93931491 100644 --- a/scripts/src/agentrun/resource-actions.ts +++ b/scripts/src/agentrun/resource-actions.ts @@ -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/"); + let ref: AgentRunResourceRef; + try { + ref = parseResourceRef(action, args, "task"); + } catch { + throw new AgentRunRestError("validation-failed", "retry requires task/"); + } + if (ref.kind !== "task") throw new AgentRunRestError("validation-failed", "retry supports task/"); + if (options.idempotencyKey === null) throw new AgentRunRestError("validation-failed", "retry requires --idempotency-key "); + if (options.reason === null) throw new AgentRunRestError("validation-failed", "retry requires --reason "); + } + if (verb === "get" && parseResourceKind(action) === "attempt" && options.taskId === null) { + throw new AgentRunRestError("validation-failed", "get attempts requires --task "); + } +} + 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 { diff --git a/scripts/src/agentrun/rest-bridge.ts b/scripts/src/agentrun/rest-bridge.ts index 523e34ab..23803caf 100644 --- a/scripts/src/agentrun/rest-bridge.ts +++ b/scripts/src/agentrun/rest-bridge.ts @@ -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 & { result: SshCaptureResult; raw: string; summary: Record }> { @@ -581,16 +590,45 @@ export async function runAgentRunAipodSpecsRest(action: string | undefined, id: export async function submitQueueTaskRest(args: string[]): Promise> { 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, 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 { + return { + enabled: true, + sequence: ["queue-submit", "queue-dispatch"], + request: { + method: "POST", + pathTemplate: "/api/v1/queue/tasks//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, + targetTask: Record, + aipodBinding: Record, +): Promise> { + 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, + dispatched: Record | null, + submittedTask: Record, + targetTask: Record, + aipodBinding: Record, + decision: "dispatched" | "idempotent-replay" | "concurrent-replay", +): Record { + 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, args: string[]): Promise> { 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, 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, 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): Record { const policyTarget = resolveAgentRunSessionPolicyTarget(); const executionPolicy = record(runBody.executionPolicy); diff --git a/scripts/src/agentrun/target-execution.ts b/scripts/src/agentrun/target-execution.ts new file mode 100644 index 00000000..276a2af4 --- /dev/null +++ b/scripts/src/agentrun/target-execution.ts @@ -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 { + 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): Record { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return { ...(value as Record), targetExecution: disclosure }; + } + return { data: value, targetExecution: disclosure }; +} + +function machineOutputOk(value: unknown): boolean { + return !(typeof value === "object" && value !== null && (value as Record).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); +} diff --git a/scripts/src/agentrun/target-task.ts b/scripts/src/agentrun/target-task.ts new file mode 100644 index 00000000..21c5b442 --- /dev/null +++ b/scripts/src/agentrun/target-task.ts @@ -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; + + constructor(stage: string, message: string, details: Record = {}) { + 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} \` 或对应 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 { + 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")}`; +} diff --git a/scripts/src/agentrun/utils.ts b/scripts/src/agentrun/utils.ts index 12989aef..06f45530 100644 --- a/scripts/src/agentrun/utils.ts +++ b/scripts/src/agentrun/utils.ts @@ -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[]; }