From 8c216a02df482a256a660392f56687dde375d374 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 09:27:42 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=94=B6=E6=95=9B=20PaC=20?= =?UTF-8?q?=E9=A6=96=E5=8F=91=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/unidesk-cicd/SKILL.md | 23 ++- .../unidesk-cicd/references/gitea-pac.md | 35 +++++ .gitignore | 2 + scripts/src/platform-infra-gitea-render.ts | 15 +- scripts/src/platform-infra-gitea.ts | 39 +++++- ...-infra-pipelines-as-code-bootstrap.test.ts | 80 +++++++++++ ...tform-infra-pipelines-as-code-bootstrap.ts | 35 +++++ ...platform-infra-pipelines-as-code-remote.sh | 44 +++++- .../src/platform-infra-pipelines-as-code.ts | 131 +++++++++++++++++- 9 files changed, 393 insertions(+), 11 deletions(-) create mode 100644 scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts create mode 100644 scripts/src/platform-infra-pipelines-as-code-bootstrap.ts diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index de6b1dac..0d22ba22 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -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 --dry-run +bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target NC01 --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 `。它从 `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 --consumer ` 只作为单 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: diff --git a/.agents/skills/unidesk-cicd/references/gitea-pac.md b/.agents/skills/unidesk-cicd/references/gitea-pac.md index 4d129c17..5c9488f8 100644 --- a/.agents/skills/unidesk-cicd/references/gitea-pac.md +++ b/.agents/skills/unidesk-cicd/references/gitea-pac.md @@ -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 --consumer --dry-run +bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target --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 摘要; + - 只对失败 consumer 使用一次 `pipelines-as-code status --consumer `; + - 只有归属或 TaskRun 断点不清楚时再用 `history --id` 或 `debug-step --id`; + - 默认不使用 `--full` 或 `--raw`,避免把大对象带回本机; + - consumer 声明 `sourceArtifact` 且需要精确提交验收时,最后执行一次 `source-artifact verify-runtime`。 + ## PaC 源码侧制品同步 当 owning Pipeline 与消费仓 `.tekton` 内联 `pipelineSpec` 或远程 Pipeline 文件存在漂移时,统一使用以下入口: diff --git a/.gitignore b/.gitignore index ebc95e84..c2f2f3f4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ dist/ coverage/ target/ **/target/ +**/__pycache__/ +*.pyc # Local scratch/worktree artifacts /.worktree/ diff --git a/scripts/src/platform-infra-gitea-render.ts b/scripts/src/platform-infra-gitea-render.ts index b7bd9579..73271297 100644 --- a/scripts/src/platform-infra-gitea-render.ts +++ b/scripts/src/platform-infra-gitea-render.ts @@ -27,17 +27,24 @@ export function renderMirrorPlan(result: Record): RenderedCliRe } export function renderMirrorBootstrap(result: Record): 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)}`, ]); } diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index 20b7c759..9402413c 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -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 { usage: [ "bun scripts/cli.ts platform-infra gitea apply --target --dry-run", "bun scripts/cli.ts platform-infra gitea apply --target --confirm", + "bun scripts/cli.ts platform-infra gitea mirror bootstrap --target --dry-run", "bun scripts/cli.ts platform-infra gitea mirror bootstrap --target --confirm", "bun scripts/cli.ts platform-infra gitea mirror webhook apply --target --confirm", ], @@ -357,8 +360,28 @@ async function mirrorStatus(config: UniDeskConfig, options: CommonOptions & { re async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): Promise> { 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 --dry-run", + "bun scripts/cli.ts platform-infra gitea mirror bootstrap --target --confirm", + ], + }); + return { ...parseCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait: false, repoKey }; } function parseCommonOptions(args: string[]): CommonOptions { diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts new file mode 100644 index 00000000..bb8d7bd8 --- /dev/null +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -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).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 --consumer --confirm"); +}); diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts new file mode 100644 index 00000000..29451758 --- /dev/null +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts @@ -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}`; +} diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index d9818316..6c82d3af 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -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() { diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index b041a07b..be5e7a9d 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -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 { scope: "explicit-initial-platform-bootstrap", configTruth: configLabel, usage: [ + "bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target --consumer --dry-run", + "bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target --consumer --confirm", "bun scripts/cli.ts platform-infra pipelines-as-code apply --target --dry-run", "bun scripts/cli.ts platform-infra pipelines-as-code apply --target --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> { + 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, + options: ApplyOptions, + giteaBootstrap: Record, + pacApply: Record, +): Record { + 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> { const pac = readPacConfig(); const target = resolveTarget(pac, options.targetId); @@ -2023,6 +2106,7 @@ function renderPlan(result: Record): RenderedCliResult { function renderApply(result: Record): 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): 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): RenderedCliResult { return rendered(result, "platform-infra pipelines-as-code apply", lines); } +function renderBootstrap(result: Record): 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): RenderedCliResult { const summary = record(result.summary); const consumer = record(result.consumer); From 43124d01e8977f1b12878a852e223b5deb02a20b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 09:30:48 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(agentrun):=20=E7=AE=80=E5=8C=96=20Artif?= =?UTF-8?q?icer=20Target=20=E6=B4=BE=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/unidesk-code-queue/SKILL.md | 30 +- .../references/resources.md | 44 +++ .agents/skills/unidesk-subagent/SKILL.md | 10 + config/agentrun.yaml | 7 +- docs/reference/agentrun.md | 61 +++- docs/reference/cli.md | 18 +- scripts/src/agentrun-lanes.ts | 4 + scripts/src/agentrun.test.ts | 155 ++++++++ scripts/src/agentrun/config.ts | 38 +- scripts/src/agentrun/entry.ts | 11 +- scripts/src/agentrun/render.ts | 71 ++++ scripts/src/agentrun/resource-actions.ts | 45 ++- scripts/src/agentrun/rest-bridge.ts | 250 ++++++++++++- scripts/src/agentrun/target-execution.ts | 128 +++++++ scripts/src/agentrun/target-task.ts | 339 ++++++++++++++++++ scripts/src/agentrun/utils.ts | 5 + 16 files changed, 1190 insertions(+), 26 deletions(-) create mode 100644 scripts/src/agentrun/target-execution.ts create mode 100644 scripts/src/agentrun/target-task.ts 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[]; }