From 32318ea881fed5ec044580b7d6d56756f8985e47 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 8 Jun 2026 15:04:08 +0800 Subject: [PATCH] fix: resolve gitbundle source from repo ref --- docs/reference/spec-v01-agentrun-runner.md | 6 +- .../spec-v01-hwlab-manual-dispatch.md | 15 ++-- docs/reference/spec-v01-runtime-assembly.md | 27 +++--- docs/reference/spec-v01-validation.md | 6 +- src/common/types.ts | 4 +- src/common/validation.ts | 25 ++++-- src/mgr/result.ts | 5 +- src/mgr/store.ts | 5 +- src/runner/resource-bundle.ts | 83 +++++++++++++++---- src/runner/run-once.ts | 12 ++- .../cases/50-hwlab-manual-dispatch.ts | 39 +++++++-- 11 files changed, 161 insertions(+), 66 deletions(-) diff --git a/docs/reference/spec-v01-agentrun-runner.md b/docs/reference/spec-v01-agentrun-runner.md index 284c4aa..9e0af64 100644 --- a/docs/reference/spec-v01-agentrun-runner.md +++ b/docs/reference/spec-v01-agentrun-runner.md @@ -30,7 +30,7 @@ Runner Secret 只能通过 Kubernetes Secret projection、ServiceAccount/RBAC Kubernetes Job runner 必须把 credential source 与 runtime home 分开:Secret volume 只读挂在 `/var/run/agentrun/secrets/...`,`/home/agentrun` 由 `emptyDir` 提供可写空间,`CODEX_HOME` 指向当前 run/profile 的 writable runtime home,`AGENTRUN_CODEX_SECRET_HOME` 指向当前 `backendProfile` 对应的只读 projection。runner/backend 在启动 provider 前只复制授权文件,不打印内容。`codex`、`deepseek` 与 `minimax-m3` profile 不得共享同一个可写 runtime home,除非它们运行在不同的 per-run Kubernetes Job 且该目录由 Job 独占 emptyDir 提供。 -RuntimeAssembly P0 中 `SessionRef` 可以显式为 `null`,runner 不得把完整 `CODEX_HOME`、Secret projection 或节点 host path 当作 session store。`ResourceBundleRef` P0 收敛为 `kind="gitbundle"`;runner 已支持把 `repoUrl + full commitId + bundles[]` checkout 到 `AGENTRUN_WORKSPACE_ROOT` 下的隔离目录,按 `subpath -> target_path` 复制到 workspace,并记录 commit/tree/bundles 摘要。工具来自 workspace `tools/`,skill 来自 workspace `.agents/skills`,不能把用户上传文件、inline seed、旧字段或 env dump 混入 gitbundle。 +RuntimeAssembly P0 中 `SessionRef` 可以显式为 `null`,runner 不得把完整 `CODEX_HOME`、Secret projection 或节点 host path 当作 session store。`ResourceBundleRef` P0 收敛为 `kind="gitbundle"`;runner 已支持把 `repoUrl + ref/materialized commit + bundles[]` checkout 到 `AGENTRUN_WORKSPACE_ROOT` 下的隔离目录,按 `subpath -> target_path` 复制到 workspace,并记录 requested ref/commit、actual commit、tree 和 bundles 摘要。工具来自 workspace `tools/`,skill 来自 workspace `.agents/skills`,不能把用户上传文件、inline seed、旧字段或 env dump 混入 gitbundle。 ### v0.1.1 Session state 持久化(per-session RWO PVC 直接挂载) @@ -66,7 +66,7 @@ volumeMounts: ### ResourceBundle gitbundle 装配 -Runner materialize `ResourceBundleRef.kind="gitbundle"` 后必须按固定顺序处理资源:先 checkout repo/commit,再按 `bundles[]` 复制文件或目录,再准备 workspace `tools/`,再发现 `.agents/skills`,最后读取 `promptRefs` 并生成当前 command 的 assembled initial prompt 摘要。旧字段输入必须由 schema 校验直接拒绝,不得在 runner 里兼容。 +Runner materialize `ResourceBundleRef.kind="gitbundle"` 后必须按固定顺序处理资源:先从 repo/ref 解析并 checkout 实际 commit,再按 `bundles[]` 复制文件或目录,再准备 workspace `tools/`,再发现 `.agents/skills`,最后读取 `promptRefs` 并生成当前 command 的 assembled initial prompt 摘要。旧字段输入必须由 schema 校验直接拒绝,不得在 runner 里兼容。 gitbundle skill 聚合规则: @@ -202,7 +202,7 @@ HWLAB v0.2 原有 Code Agent 在 cloud-api 进程内执行,失败时依赖本 ### T7 Resource prompt/skill 装配 -阅读本文和 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md),然后创建一个带 `ResourceBundleRef.kind="gitbundle"`、`bundles[]` 和 `promptRefs` 的真实或自测试 run。确认 runner 从同一 full commit checkout 装配 prompt、workspace tools 和 `.agents/skills`;新 thread 首轮显示 `initialPromptInjected=true`,assistant 能看见 gitbundle skill 摘要;第二轮 resume 显示 `initialPromptInjected=false`,且没有拼接第一轮历史 prompt。旧字段请求必须 schema-invalid,required prompt 缺失必须 blocked 为 `prompt-unavailable`。 +阅读本文和 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md),然后创建一个带 `ResourceBundleRef.kind="gitbundle"`、`bundles[]` 和 `promptRefs` 的真实或自测试 run。确认 runner 从 repo/ref 解析到同一 materialized commit 后装配 prompt、workspace tools 和 `.agents/skills`;新 thread 首轮显示 `initialPromptInjected=true`,assistant 能看见 gitbundle skill 摘要;第二轮 resume 显示 `initialPromptInjected=false`,且没有拼接第一轮历史 prompt。旧字段请求必须 schema-invalid,required prompt 缺失必须 blocked 为 `prompt-unavailable`。 ## 规格的实现情况 diff --git a/docs/reference/spec-v01-hwlab-manual-dispatch.md b/docs/reference/spec-v01-hwlab-manual-dispatch.md index 438b6bb..642a424 100644 --- a/docs/reference/spec-v01-hwlab-manual-dispatch.md +++ b/docs/reference/spec-v01-hwlab-manual-dispatch.md @@ -48,7 +48,7 @@ AgentRun `v0.1` 承接 HWLAB v0.2 时,只吸收原有 Code Agent 的通用执 | runnerTrace 可见,能展示请求、工具、输出、错误和终态 | `internal/cloud/code-agent-trace-store.ts`、`web/hwlab-cloud-web/app-trace.ts` | 标准 event schema、单 run 内 seq 单调、bounded payload、Secret redaction | [spec-v01-backend-adapter.md](spec-v01-backend-adapter.md)、[spec-v01-agentrun-runner.md](spec-v01-agentrun-runner.md) | | 取消正在执行的 turn | `internal/cloud/server-code-agent-http.ts`、`internal/cloud/codex-stdio-session.ts` | durable run/command cancel、pending 阻止启动、running interrupt、terminal 幂等返回 | [spec-v01-agentrun-mgr.md](spec-v01-agentrun-mgr.md)、[spec-v01-agentrun-runner.md](spec-v01-agentrun-runner.md) | | conversation/session/thread 复用 | `internal/cloud/codex-stdio-session.ts`、`internal/cloud/code-agent-session-registry.ts` | `SessionRef` 保存 session/thread 摘要;同一 run/runner Job 处理后续 command,不重新 materialize bundle;runner 内每 turn 有 thread 则 resume,无 thread 则 start | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-agentrun-runner.md](spec-v01-agentrun-runner.md) | -| 固定 repo workspace 执行 | `internal/cloud/code-agent-contract.ts`、`docs/reference/code-agent-chat-readiness.md` | `ResourceBundleRef` 使用 Git-only `repoUrl + full commitId` checkout 到隔离 workspace | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-agentrun-runner.md](spec-v01-agentrun-runner.md) | +| 固定 repo workspace 执行 | `internal/cloud/code-agent-contract.ts`、`docs/reference/code-agent-chat-readiness.md` | `ResourceBundleRef` 使用 Git-only `repoUrl + workspaceRef.branch/ref` checkout 到隔离 workspace,并记录 materialized commit | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-agentrun-runner.md](spec-v01-agentrun-runner.md) | | 初始业务 prompt 注入 | `internal/cloud/codex-stdio-session.ts` 的 boundary instructions、`internal/cloud/codex-stdio-session-helpers.ts` 的 `buildCodexUserPrompt()` | HWLAB 把稳定业务 instruction 文件放入同一 Git bundle,并通过 `ResourceBundleRef.promptRefs` 指定;AgentRun 只在新 thread 首轮注入,resume 不重复注入 | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-backend-codex.md](spec-v01-backend-codex.md) | | skill discovery 与 skill facts | `internal/cloud/skills-store.ts`、`internal/cloud/codex-stdio-session-helpers.ts` 的 `discoverSkillsForStdio()` 和 `codexSidecarSkillsPrompt()` | HWLAB 把完整 `skills/` 子树通过 `kind="gitbundle"` 复制到 workspace `.agents/skills`;AgentRun 发现多文件 skill 并向 Codex 暴露有界 skill facts | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-agentrun-runner.md](spec-v01-agentrun-runner.md) | | provider profile 隔离和 Secret 不泄露 | `internal/cloud/code-agent-contract.ts`、`docs/reference/code-agent-chat-readiness.md` | `ProfileRef/SecretRef` profile-scoped 投影、缺失为 `secret-unavailable`、禁止 fallback 和泄露值 | [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)、[spec-v01-backend-adapter.md](spec-v01-backend-adapter.md) | @@ -87,10 +87,10 @@ HWLAB canary 创建 run 时应使用以下字段口径: | `projectId` | `pikasTech/HWLAB`。 | | `providerId` | `G14`,只表示目标 provider,不授予 HWLAB 业务权限。 | | `backendProfile` | `deepseek`、`codex` 或 `minimax-m3`,由 HWLAB 或调度方显式选择;缺少 matching SecretRef 必须失败,不 fallback。 | -| `workspaceRef` | 必须引用 ResourceBundleRef 中的 Git-only full commit;不得由 runner 猜 host path。 | +| `workspaceRef` | 必须提供 Git-only repo/branch 语义,HWLAB v0.2 使用 `branch=v0.2`;不得由 runner 猜 host path。 | | `resourceBundleRef.kind` | 必须是 `gitbundle`。 | | `resourceBundleRef.bundles[]` | 用于承接 HWLAB 固定工具和 skill 子树,默认 `tools -> tools`、`skills -> .agents/skills`;旧字段不得再发送。 | -| `resourceBundleRef.promptRefs[]` | 用于承接 HWLAB 稳定初始 prompt,例如 `hwlab-v02-runtime`;必须来自同一 full commit,`inject=thread-start`,新 thread 首轮注入,resume 不注入。 | +| `resourceBundleRef.promptRefs[]` | 用于承接 HWLAB 稳定初始 prompt,例如 `hwlab-v02-runtime`;必须来自同一 materialized gitbundle checkout,`inject=thread-start`,新 thread 首轮注入,resume 不注入。 | | `executionPolicy` | sandbox、network、timeout、secretScope 必须显式,不得由 HWLAB 扩大 AgentRun Secret 范围。 | | `executionPolicy.secretScope.toolCredentials[]` | 需要 UniDesk SSH passthrough 时必须声明 `tool=unidesk-ssh`、`purpose=ssh-passthrough`、SecretRef `agentrun-v01-tool-unidesk-ssh`、projection env `UNIDESK_SSH_CLIENT_TOKEN`;不得把 token 放入 command payload 或 runner-job transientEnv。 | | `traceSink` | 可指向 HWLAB trace adapter;为 `null` 时 HWLAB 仍可通过 AgentRun events 轮询。 | @@ -147,7 +147,7 @@ AgentRun 需要提供 durable cancel 能力,建议形态为 `POST /api/v1/runs ### P1 ResourceBundleRef / bundle materialization -`ResourceBundleRef` 必须按 `kind="gitbundle"` 模型落地:`repoUrl + full commitId + bundles[]` 是唯一内容身份。runner 只能 checkout 到允许 workspace 前缀,不能覆盖 `/app`、Secret projection、profile runtime home 或 session 目录。HWLAB canary 默认只复制 `tools -> tools` 和 `skills -> .agents/skills`;用户上传文件、inline seed、对象存储 artifact 和旧字段不进入 `v0.1`。 +`ResourceBundleRef` 必须按 `kind="gitbundle"` 模型落地:输入只依赖 `repoUrl`、ref/branch 和 `bundles[]`,runner 从 git mirror/repo 解析 actual commit 后形成内容身份。runner 只能 checkout 到允许 workspace 前缀,不能覆盖 `/app`、Secret projection、profile runtime home 或 session 目录。HWLAB canary 默认只复制 `tools -> tools` 和 `skills -> .agents/skills`;用户上传文件、inline seed、对象存储 artifact 和旧字段不进入 `v0.1`。 ### P1 Resource prompt/skill assembly @@ -158,7 +158,6 @@ HWLAB 旧 Code Agent 的业务 prompt 和 skill 注入必须收敛为 `gitbundle "resourceBundleRef": { "kind": "gitbundle", "repoUrl": "git@github.com:pikasTech/HWLAB.git", - "commitId": "", "bundles": [ { "name": "hwlab-tools", "subpath": "tools", "target_path": "tools" }, { "name": "hwlab-agent-skills", "subpath": "skills", "target_path": ".agents/skills" } @@ -181,7 +180,7 @@ HWLAB 旧 Code Agent 的业务 prompt 和 skill 注入必须收敛为 `gitbundle | 1 | 手动调度 API 固化 | `runner-jobs` request/response schema、idempotency、job identity、CLI 调同一 REST API | 重复 key 不重复创建;短返回;manager 重启后可查。 | | 2 | trace/result 元语 | 标准 event 子集、terminal result envelope、bounded output metadata | HWLAB 可由 events 稳定生成 result/trace;partial 不误报 completed。 | | 3 | cancel 闭环 | durable cancel API、runner cancel poll、backend interrupt/process group stop | pending/running/terminal 后 cancel 均幂等且可见。 | -| 4 | ResourceBundleRef materialization | Git-only checkout、workspace 前缀、commit/tree 摘要、failureKind | 使用 full commit;不接受 branch/tag/HEAD;不覆盖 Secret/session/runtime home。 | +| 4 | ResourceBundleRef materialization | Git-only checkout、workspace 前缀、requested ref/commit、actual commit/tree 摘要、failureKind | 从 repo/ref 解析 actual commit;不依赖 cloud-api 或 CI/CD rollout 的 artifact revision;不覆盖 Secret/session/runtime home。 | | 5 | Resource prompt/skill assembly | `promptRefs` thread-start 注入、gitbundle skillDirs 发现、hash/bytes 可见 | 简短 HWLAB prompt 能看到业务 instruction 和 gitbundle skills;resume 不重复注入;旧字段直接拒绝。 | | 6 | SessionRef 持久化与 runner 多 turn | session record/store、thread resume、runner command loop、TTL/GC、profile 隔离 | 同一 conversation 连续两轮复用同一 run/runner Job;第二轮不重新 materialize bundle、不重复注入 initial prompt;不同 profile 不污染。 | | 7 | HWLAB v0.2 canary | HWLAB dispatcher adapter、traceId 映射、result/trace 转换 | 普通自然语言最短 turn 真实 completed 且 reply 非空;HWPOD 仍由 HWLAB 授权。 | @@ -206,7 +205,7 @@ HWLAB 旧 Code Agent 的业务 prompt 和 skill 注入必须收敛为 `gitbundle ### T5 SessionRef 与 ResourceBundleRef -阅读本文和 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md),然后验证一次带 SessionRef 和 Git-only ResourceBundleRef 的 runner Job。确认 session 不含 credential 文件,bundle 使用 full commit checkout 到允许 workspace,event/result 能回答 session id、repo、commit 和 checkout 摘要。 +阅读本文和 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md),然后验证一次带 SessionRef 和 Git-only ResourceBundleRef 的 runner Job。确认 session 不含 credential 文件,bundle 从 repo/ref checkout 到允许 workspace,event/result 能回答 session id、repo、requested ref/commit、actual commit 和 checkout 摘要。 ### T6 同 run/runner 后续 turn @@ -235,6 +234,6 @@ HWLAB 旧 Code Agent 的业务 prompt 和 skill 注入必须收敛为 `gitbundle | cancel | 已实现最小闭环 | 已提供 run/command cancel API;pending cancel 会阻止新 runner Job,running runner 通过轮询触发 backend abort,终态写入 event、command state 和 run status。 | | SessionRef | 已实现最小持久化 | run 可携带 `sessionRef`,manager 保存 session/thread,runner 会按 threadId resume,result envelope 暴露脱敏 session 摘要;TTL/GC 仍按后续运维策略细化。 | | SessionRef | v0.1.1 已实现/已通过 HWLAB v0.2 原入口复测 | 在「metadata-only 最小持久化」基础上把 session 真实持久化:每个 session 绑 RWO PVC(`agentrun-v01-session-`),runner Job 把 PVC 直接挂到 `${CODEX_HOME}/`,codex app-server 自己落盘;HWLAB 原入口已验证 runner pod 删除后同 session/thread/PVC 可以恢复,仍禁止 fake 续接。 | -| ResourceBundleRef | 已实现 `kind="gitbundle"` materialization/promptRefs/tools/skillDirs 装配 | run 可携带 `repoUrl + full commitId + bundles[]`,runner checkout 到 `AGENTRUN_WORKSPACE_ROOT` 下的隔离目录并记录 commit/tree/workspace/bundles 摘要;`tools/` PATH、`promptRefs` thread-start 注入和 `.agents/skills` 目录发现已实现;上传文件、inline seed 和对象存储不进入 v0.1。 | +| ResourceBundleRef | 已实现 `kind="gitbundle"` materialization/promptRefs/tools/skillDirs 装配 | run 可携带 `repoUrl + ref/branch + bundles[]`,runner checkout 到 `AGENTRUN_WORKSPACE_ROOT` 下的隔离目录并记录 requested ref/commit、actual commit/tree/workspace/bundles 摘要;`tools/` PATH、`promptRefs` thread-start 注入和 `.agents/skills` 目录发现已实现;上传文件、inline seed 和对象存储不进入 v0.1。 | | 同 run/runner 多 turn | 已实现最小闭环 | runner Job 在 idle timeout 内持续 poll 同一 run 的后续 command;普通 turn completed 不终结 run,bundle 只 materialize 一次,command result 按 commandId 独立聚合。 | | HWLAB v0.2 canary | 已实现/已通过 HWLAB v0.2 原入口复测 | HWLAB dispatcher adapter 已调 AgentRun 手动调度 API,并能转换 result/trace;MiniMax-M3 显式 session、provider profile 继承和 runner pod 删除后的同 session resume 已通过原入口 CLI 复测。 | diff --git a/docs/reference/spec-v01-runtime-assembly.md b/docs/reference/spec-v01-runtime-assembly.md index e149ba0..3910491 100644 --- a/docs/reference/spec-v01-runtime-assembly.md +++ b/docs/reference/spec-v01-runtime-assembly.md @@ -13,7 +13,7 @@ | `BackendImageRef` | `image` | digest-pinned backend/runner 镜像。 | API KEY、profile config、用户代码、session 文件。 | | `ProfileRef` | `profile`、`secretRef` | provider profile 和 API KEY/配置 SecretRef。 | backend 镜像、session、repo 文件、GitHub/业务工具 credential。 | | `SessionRef` | `sessionId` 或 `null` | backend 会话文件持久化引用;P0 可以为 `null`。 | API KEY、完整 `CODEX_HOME`、Git workspace。 | -| `ResourceBundleRef` | `kind="gitbundle"`、`repoUrl`、`commitId`、`bundles[]`、可选 `promptRefs` | 初始代码/文件输入、工具目录、skill 目录和同一 commit 下的稳定初始 prompt;P0 固定 Git-only gitbundle。 | 上传文件、对象存储 artifact、inline env、Secret value、会话历史、旧 inline seed。 | +| `ResourceBundleRef` | `kind="gitbundle"`、`repoUrl`、`bundles[]`、可选 `ref` / `commitId` / `promptRefs` | 初始代码/文件输入、工具目录、skill 目录和稳定初始 prompt;P0 固定 Git-only gitbundle,默认从 repo/ref 解析实际 commit。 | 上传文件、对象存储 artifact、inline env、Secret value、会话历史、旧 inline seed。 | P0 最小 JSON 形态: @@ -30,7 +30,6 @@ P0 最小 JSON 形态: "resourceBundleRef": { "kind": "gitbundle", "repoUrl": "git@github.com:pikasTech/unidesk.git", - "commitId": "", "bundles": [ { "name": "tools", "subpath": "tools", "target_path": "tools" }, { "name": "skills", "subpath": "skills", "target_path": ".agents/skills" } @@ -40,7 +39,7 @@ P0 最小 JSON 形态: } ``` -`executionPolicy`、`observabilityPolicy`、tenant identity、network、GC、failureKind、provenance、resource limit、tool credential scope、初始 prompt 装配和 skill 装配都不是新的运行时要素;它们分别挂在四要素或 run policy 上,并且必须能追溯到 SecretRef、Git commit/path/hash、配置引用或显式 null/deferred 状态。 +`executionPolicy`、`observabilityPolicy`、tenant identity、network、GC、failureKind、provenance、resource limit、tool credential scope、初始 prompt 装配和 skill 装配都不是新的运行时要素;它们分别挂在四要素或 run policy 上,并且必须能追溯到 SecretRef、Git repo/ref、实际 materialized commit、path/hash、配置引用或显式 null/deferred 状态。 ## 装配对象与 credential 归属 @@ -100,7 +99,7 @@ HWLAB v0.2 原有 Code Agent 已经验证了 profile、session、workspace 和 S | --- | --- | --- | --- | | provider profile 可切换 | `internal/cloud/code-agent-contract.ts` | `ProfileRef.profile`、`ProfileRef.secretRef` | `deepseek`、`minimax-m3` 与 `codex` 只选择 profile/config/SecretRef,不复制 backend 协议;缺失 Secret 必须失败,不 fallback。 | | Codex app-server thread 复用 | `internal/cloud/codex-stdio-session.ts`、`internal/cloud/code-agent-session-registry.ts` | `SessionRef.sessionId`、`conversationId`、`threadId` | AgentRun 保存 backend thread/session 摘要;不保存 API KEY、`auth.json`、`config.toml` 或完整 `CODEX_HOME`。 | -| 固定 `/workspace/hwlab` 代码上下文 | `internal/cloud/code-agent-contract.ts` | `ResourceBundleRef.repoUrl`、`commitId` | 用 Git-only full commit 取代 HWLAB Pod 内固定路径;runner checkout 到隔离 workspace。 | +| 固定 `/workspace/hwlab` 代码上下文 | `internal/cloud/code-agent-contract.ts` | `ResourceBundleRef.repoUrl`、`ref` / `workspaceRef.branch`、materialized `commitId` | 用 Git-only repo/ref checkout 取代 HWLAB Pod 内固定路径;runner checkout 到隔离 workspace,并在 event/result 记录实际 commit。 | | writable `CODEX_HOME` 与 Secret 投影分离 | `docs/reference/code-agent-chat-readiness.md` | `ProfileRef` + runner runtime home | Secret 只读投影,复制到当前 run/profile writable runtime home;profile 间不共享。 | | runner/image 可追溯 | HWLAB live build/source metadata | `BackendImageRef.image` | runner/backend image 必须可追溯 digest/source commit,不能由调用方任意传未受控镜像。 | @@ -156,13 +155,13 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也 ### ResourceBundleRef -- P0 固定 `kind="gitbundle"`,由 `repoUrl + full commitId + bundles[]` 决定内容身份。 -- `commitId` 必须是不可变 full commit sha,不能是 branch、tag 或 `HEAD`。 -- `bundles[]` 每一项只允许 `{ name?, repoUrl?, commitId?, subpath, target_path }`;缺省 repo/commit 继承顶层。 +- P0 固定 `kind="gitbundle"`,输入只依赖 Git repo/ref 与 `bundles[]`;runner 从 `resourceBundleRef.ref`、run 的 `workspaceRef.branch` 或 `HEAD` 解析实际 commit,再用该 materialized commit 作为内容身份。 +- `commitId` 是可选 pin 或历史请求 hint;提供时必须是 full commit sha,但 HWLAB gitbundle 默认不得依赖 cloud-api/CI/CD rollout 注入的 commitId。 +- `bundles[]` 每一项只允许 `{ name?, repoUrl?, ref?, commitId?, subpath, target_path }`;缺省 repo/ref/commit 继承顶层解析结果。 - `subpath` 必须留在 checkout 内,`target_path` 必须留在 runner workspace 内;runner 按 `subpath -> target_path` 复制文件或目录。 - `credentialRef` 只用于拉取私有 Git repo,不等同于 backend API KEY。 - 不支持上传文件、对象存储 artifact、任意 ConfigMap 文件袋、inline seed 或旧字段;旧 `toolAliases`、`skillRefs`、`workspaceFiles`、`subdir`、`sparsePaths` 输入必须直接 schema-invalid。 -- 面向 HWLAB 手动调度 canary,默认 bundle 把 repo 的 `tools/` 复制到 workspace `tools/`,把 `skills/` 复制到 workspace `.agents/skills/`;event/result 必须记录 repo、full commit、checkout tree、bundle 列表、workspace 摘要、tools 和 skillDirs 摘要。 +- 面向 HWLAB 手动调度 canary,默认 bundle 把 repo 的 `tools/` 复制到 workspace `tools/`,把 `skills/` 复制到 workspace `.agents/skills/`;event/result 必须记录 repo、requested ref/commit、实际 materialized commit、checkout tree、bundle 列表、workspace 摘要、tools 和 skillDirs 摘要。 #### tools 目录 @@ -170,7 +169,7 @@ runner 对 workspace `tools/` 做统一装配:顶层带 shebang 的脚本会 #### promptRefs -`promptRefs` 用于按 `repoUrl + full commitId + path` 装配初始 prompt。它承载业务域稳定 runtime/developer instruction,例如某个项目的标准入口、禁止路径和工具使用纪律;它不承载用户本轮 message,也不承载历史会话。 +`promptRefs` 用于按同一个 materialized gitbundle checkout 的 `path` 装配初始 prompt。它承载业务域稳定 runtime/developer instruction,例如某个项目的标准入口、禁止路径和工具使用纪律;它不承载用户本轮 message,也不承载历史会话。 最小形态: @@ -209,7 +208,7 @@ skill 只来自 gitbundle 复制进 workspace 的 `.agents/skills//SKILL.m 5. Runner materialize tool credential 到该 run 允许的 env/file projection;未实现的 tool scope 必须显式 failed/blocked,不能静默跳过后让 agent 自己猜凭据。 6. Runner materialize `kind="gitbundle"` resource bundle 到 workspace;P0 未实现时必须显式 blocked,不能猜测 host path。 7. Runner 按 `bundles[]` 复制目录或文件,准备 workspace `tools/`、发现 `.agents/skills`,读取并校验 `promptRefs`,写入有界 assembly event。 -8. Runner 启动 backend,并在 event 中记录 image digest、profile、SecretRef 名称/key、tool credential scope、sessionRef、repoUrl/commitId、bundles、promptRefs、tools 和 skillDirs 摘要。 +8. Runner 启动 backend,并在 event 中记录 image digest、profile、SecretRef 名称/key、tool credential scope、sessionRef、repoUrl、requested ref/commit、materialized commit、bundles、promptRefs、tools 和 skillDirs 摘要。 任何一个要素缺失或不合法,都必须按该要素失败;不得静默 fallback。 @@ -245,8 +244,8 @@ skill 只来自 gitbundle 复制进 workspace 的 `.agents/skills//SKILL.m ### A4 ResourceBundleRef 验收 -- P0 ResourceBundle 只能是 `kind="gitbundle"`:`repoUrl + full commitId + bundles[]`。 -- `commitId` 不是 branch/tag/HEAD。 +- P0 ResourceBundle 只能是 `kind="gitbundle"`:`repoUrl + resolved ref/materialized commit + bundles[]`。 +- `commitId` 若作为可选 pin 出现必须是 full sha;默认 HWLAB 路径以 repo/ref 解析,不使用 cloud-api artifact revision 当内容来源。 - checkout 只能进入允许 workspace 前缀,不能覆盖 `/app`、Secret projection、profile runtime home 或 session 目录。 - run payload 不携带文件正文、env dump、Secret value 或大型 artifact。 - 若提供 `bundles[]`,必须能看到每个 `subpath -> target_path` 的复制摘要;旧字段输入必须 schema-invalid。 @@ -260,7 +259,7 @@ skill 只来自 gitbundle 复制进 workspace 的 `.agents/skills//SKILL.m 1. 用哪一个 image digest。 2. 用哪一个 profile 和 SecretRef。 3. 是否使用 session;若不用,必须明确为 `null`/deferred。 -4. 使用哪一个 Git repo 和 full commit;若 P0 尚未 materialize,必须明确为 deferred,不能隐式使用 host path。 +4. 使用哪一个 Git repo/ref,以及最终 materialized 到哪一个 full commit;若 P0 尚未 materialize,必须明确为 deferred,不能隐式使用 host path。 5. 是否装配 gitbundle bundles、workspace tools、初始 prompt 和 skillDirs;若提供,必须能回答 name/path/hash/inject/required 和是否注入,不能只依赖模型默认 prompt 或默认 skill registry。 6. 是否装配 tool credential;若需要 GitHub PR 能力,必须能回答 tool、purpose、SecretRef 和 projection kind,不能只在运行时 shell 中偶然存在 token。 @@ -272,5 +271,5 @@ skill 只来自 gitbundle 复制进 workspace 的 `.agents/skills//SKILL.m | `ProfileRef` | 已实现/已通过 HWLAB v0.2 原入口复测 | `codex`、`deepseek` 与 `minimax-m3` 已通过 SecretRef、writable runtime home 和真实 stdio turn 验证;MiniMax-M3 已通过 HWLAB 显式 session 原入口复测,后续只允许作为 profile/config/SecretRef 选择,不新增直连 backend。 | | `SessionRef` | 已实现最小持久化 | manager 持久化 `sessionId/conversationId/threadId`,run 创建会解析既有 session,runner 按 threadId resume;session 不保存 credential 文件,TTL/GC 后续细化。 | | `SessionRef` | v0.1.1 已实现/已通过 HWLAB v0.2 原入口复测 | manager 持久化 `sessionId/conversationId/threadId` + 每个 session 绑 RWO PVC(`agentrun-v01-session-`),runner Job 把 PVC 直接挂到 `${CODEX_HOME}/`,codex app-server 自己落盘;runner pod 删除后 replacement runner 仍复用同一 SessionRef/PVC/thread,禁止 copy/restore、replacement threadId 和 fake resume。 | -| `ResourceBundleRef` | 已实现 `kind="gitbundle"` materialization/promptRefs/tools/skillDirs 装配 | `repoUrl + full commitId + bundles[]` 已进入 run schema 和 runner checkout,workspace 受 `AGENTRUN_WORKSPACE_ROOT` 限制,event/result 记录 commit/tree/workspace/bundles 摘要;`tools/` PATH、`promptRefs` thread-start 注入和 `.agents/skills` 目录发现已实现。 | +| `ResourceBundleRef` | 已实现 `kind="gitbundle"` materialization/promptRefs/tools/skillDirs 装配 | `repoUrl + ref/materialized commit + bundles[]` 已进入 run schema 和 runner checkout,workspace 受 `AGENTRUN_WORKSPACE_ROOT` 限制,event/result 记录 requested ref/commit、actual commit、tree/workspace/bundles 摘要;`tools/` PATH、`promptRefs` thread-start 注入和 `.agents/skills` 目录发现已实现。 | | `toolCredentials` | 已实现最小 env projection | GitHub PR 和 UniDesk SSH passthrough 等 agent shell/tool 授权通过装配 SPEC 的 SecretRef 进入 runner;v0.1 支持 `tool=github` 与 `tool=unidesk-ssh`、`projection.kind=env`,runner Job 使用 `valueFrom.secretKeyRef` 注入,不用 `transientEnv` 绕过。 | diff --git a/docs/reference/spec-v01-validation.md b/docs/reference/spec-v01-validation.md index 02bed9d..a42b65b 100644 --- a/docs/reference/spec-v01-validation.md +++ b/docs/reference/spec-v01-validation.md @@ -104,7 +104,7 @@ CLI 与 RESTful API 可以复用同一个真实 run 做联调。若两者观察 | trace 可见性 | 分页读取 events | event seq 单调,包含 backend_status、assistant 或 error、terminal_status;payload 有界且 redacted。 | | cancel | 对 pending/running/terminal 分别调用 cancel | cancel 幂等,pending 不再启动 runner,running 收敛为 cancelled 或既有 terminal,events/result 可见。 | | SessionRef | 连续两轮使用同一 sessionRef 或 conversation/session/thread 摘要 | 第二轮可 resume backend thread;session 不包含 credential 文件或完整 CODEX_HOME。 | -| ResourceBundleRef | 使用 `repoUrl + full commitId` 启动 runner | runner checkout 到允许 workspace,event/result 能回答 repo、commit、workspace 摘要;不使用 branch/tag/HEAD 或 host path。 | +| ResourceBundleRef | 使用 `repoUrl + ref/branch + bundles[]` 启动 runner | runner checkout 到允许 workspace,event/result 能回答 repo、requested ref/commit、actual commit、workspace 摘要;不使用 host path、cloud-api artifact revision 或 CI/CD rollout 状态作为 bundle 内容来源。 | | Resource prompt/skill assembly | 使用同一 `ResourceBundleRef.kind="gitbundle"` 指定 `bundles[]` 和 `promptRefs` | 新 thread 首轮注入 initial prompt 和 gitbundle skill facts;resume 不重复注入;required prompt 缺失 blocked;不使用用户长 prompt、旧硬编码 prompt、镜像 `/app/skills` 或默认 Codex skill registry 替代。 | | DS 短 prompt 探测 | 通过正式 CLI/Web 等价入口向真实 `backendProfile=deepseek` 发送短 prompt | “可见 skill”回复包含 gitbundle `.agents/skills` 中的业务 skill 而不只是 Codex 默认系统 skill;“当前 HWLAB 初始规则”能回答 hwpod/HWPOD 四要素/禁止路径;“编译 D601-F103-V2”能触发 `hwpod -> hwpod-cli -> hwpod-compiler-cli -> /v1/hwpod-node-ops -> hwpod-node` 正向链路。 | | ProfileRef/SecretRef | 分别验证 `codex`、`deepseek` 与 `minimax-m3` profile | 只使用当前 profile SecretRef;缺失时 `secret-unavailable`,不 fallback,不泄露 Secret 值。 | @@ -183,7 +183,7 @@ T8 是涉及 backend profile 变更时的综合联调标准;不涉及 backend ### T9 RuntimeAssembly 四要素验收 -阅读本文和 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md),然后检查一次真实 runner Job 或 dry-run manifest,确认四个问题都有明确答案:用哪一个 digest-pinned image、用哪一个 profile/SecretRef、是否使用 session、使用哪一个 Git repo/full commit。P0 未启用 session 或 Git-only resource materialization 时,必须明确为 `null` 或 deferred,不能由 runner 隐式使用 host path。任何 Secret value、用户文件正文、env dump、branch/tag/HEAD 形式的 resource commit 都不能作为通过证据。 +阅读本文和 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md),然后检查一次真实 runner Job 或 dry-run manifest,确认四个问题都有明确答案:用哪一个 digest-pinned image、用哪一个 profile/SecretRef、是否使用 session、使用哪一个 Git repo/ref 和 materialized commit。P0 未启用 session 或 Git-only resource materialization 时,必须明确为 `null` 或 deferred,不能由 runner 隐式使用 host path。任何 Secret value、用户文件正文、env dump 或 cloud-api artifact revision 都不能作为通过证据。 ### T10 HWLAB 手动调度 canary 验收 @@ -206,7 +206,7 @@ T8 是涉及 backend profile 变更时的综合联调标准;不涉及 backend | 真实主闭环 | 已通过 | 当前 v0.1 已通过真实 Tekton/Argo、Postgres、SecretRef、Kubernetes runner Job、Codex stdio turn、RESTful API 和 CLI 主闭环;每次发布仍需按本文手动复验。 | | `deepseek` profile 切换验收 | 已通过主闭环 | 自测试和 CLI smoke 已覆盖 profile registry、Secret render、fake stdio turn、无 fallback 和结构化错误;真实 `agentrun-v01` 已按 T8 完成 `codex -> deepseek -> codex` 切换综合联调。后续涉及 backend profile 的发布仍必须按 T8 复验。 | | `minimax-m3` profile 切换验收 | 已定义/待真实主闭环 | 自测试和 CLI smoke 覆盖 profile registry、Secret render、fake stdio turn、无 fallback 和结构化错误;真实 `agentrun-v01` 需要按 T8 完成 `codex -> deepseek -> minimax-m3 -> codex` 切换综合联调。 | -| RuntimeAssembly 四要素验收 | 已定义 | T9 收敛为四个最简问题:image digest、profile/SecretRef、session null/deferred、Git-only repo/full commit;session/resource materialization 后续实现时必须补真实联调。 | +| RuntimeAssembly 四要素验收 | 已定义 | T9 收敛为四个最简问题:image digest、profile/SecretRef、session null/deferred、Git-only repo/ref 与 materialized commit;session/resource materialization 后续实现时必须补真实联调。 | | HWLAB 手动调度 canary 验收 | 已定义 | T10 规定 HWLAB dispatcher 通过手动 runner Job API 使用 AgentRun 的真实联调口径;自动 scheduler 不是前置条件。 | | Queue 吸收 Code Queue 验收 | 已定义 | T11 规定 Queue RESTful/CLI/Session 分层的真实手动交互验收;mock 只允许在自测试层。 | | Queue Q2 受控 dispatch/refresh 主闭环 | 已通过 | 已在真实 `agentrun-v01` Postgres 与 G14 k3s runner Job 上完成正式 CLI 手动验收;通过样本使用 `opaque` workspace 完成 Codex stdio turn,`queue refresh` 后 Queue task/latestAttempt 为 completed。 | diff --git a/src/common/types.ts b/src/common/types.ts index 66fda4b..f41f5d6 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -60,6 +60,7 @@ export interface GitBundleItemRef extends JsonRecord { name?: string; repoUrl?: string; commitId?: string; + ref?: string; subpath: string; targetPath: string; } @@ -67,7 +68,8 @@ export interface GitBundleItemRef extends JsonRecord { export interface ResourceBundleRef extends JsonRecord { kind: "gitbundle"; repoUrl: string; - commitId: string; + commitId?: string; + ref?: string; bundles: GitBundleItemRef[]; promptRefs?: Array<{ name: string; diff --git a/src/common/validation.ts b/src/common/validation.ts index 27d337f..bde6187 100644 --- a/src/common/validation.ts +++ b/src/common/validation.ts @@ -85,10 +85,11 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n const kind = requiredString(record, "kind"); if (kind !== "gitbundle") throw new AgentRunError("schema-invalid", "resourceBundleRef.kind must be gitbundle", { httpStatus: 400 }); const repoUrl = requiredString(record, "repoUrl"); - const commitId = requiredString(record, "commitId"); - validateCommitId(commitId, "resourceBundleRef.commitId"); + const commitId = optionalString(record.commitId); + if (commitId) validateCommitId(commitId, "resourceBundleRef.commitId"); + const ref = validateGitRef(record.ref, "resourceBundleRef.ref"); rejectLegacyResourceBundleFields(record); - const result: ResourceBundleRef = { kind: "gitbundle", repoUrl, commitId, bundles: validateResourceGitBundles(record.bundles, repoUrl, commitId) }; + const result: ResourceBundleRef = { kind: "gitbundle", repoUrl, ...(commitId ? { commitId } : {}), ...(ref ? { ref } : {}), bundles: validateResourceGitBundles(record.bundles, repoUrl, commitId, ref) }; if (record.promptRefs !== undefined) result.promptRefs = validateResourcePromptRefs(record.promptRefs); if (record.submodules !== undefined && record.submodules !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.submodules can only be false in v0.1", { httpStatus: 400 }); if (record.lfs !== undefined && record.lfs !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.lfs can only be false in v0.1", { httpStatus: 400 }); @@ -104,7 +105,7 @@ function rejectLegacyResourceBundleFields(record: JsonRecord): void { } } -function validateResourceGitBundles(value: unknown, defaultRepoUrl: string, defaultCommitId: string): ResourceBundleRef["bundles"] { +function validateResourceGitBundles(value: unknown, defaultRepoUrl: string, defaultCommitId?: string, defaultRef?: string): ResourceBundleRef["bundles"] { if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.bundles must be an array", { httpStatus: 400 }); if (value.length === 0) throw new AgentRunError("schema-invalid", "resourceBundleRef.bundles must contain at least one entry", { httpStatus: 400 }); if (value.length > 64) throw new AgentRunError("schema-invalid", "resourceBundleRef.bundles must contain at most 64 entries", { httpStatus: 400 }); @@ -115,15 +116,16 @@ function validateResourceGitBundles(value: unknown, defaultRepoUrl: string, defa if (name) validateResourceName(name, `resourceBundleRef.bundles[${index}].name`); const repoUrl = optionalString(record.repoUrl) ?? defaultRepoUrl; const commitId = optionalString(record.commitId) ?? defaultCommitId; - validateCommitId(commitId, `resourceBundleRef.bundles[${index}].commitId`); + if (commitId) validateCommitId(commitId, `resourceBundleRef.bundles[${index}].commitId`); + const ref = validateGitRef(record.ref, `resourceBundleRef.bundles[${index}].ref`) ?? (commitId ? undefined : defaultRef); const subpath = validateBundleSubpath(requiredString(record, "subpath"), `resourceBundleRef.bundles[${index}].subpath`); const rawTargetPath = typeof record.targetPath === "string" ? record.targetPath : record.target_path; if (typeof rawTargetPath !== "string" || rawTargetPath.trim().length === 0) throw new AgentRunError("schema-invalid", `resourceBundleRef.bundles[${index}].target_path is required`, { httpStatus: 400 }); const targetPath = validateWorkspaceRelativePath(rawTargetPath.trim(), `resourceBundleRef.bundles[${index}].target_path`); - const identity = `${repoUrl}\0${commitId}\0${subpath}\0${targetPath}`; + const identity = `${repoUrl}\0${commitId ?? ""}\0${ref ?? ""}\0${subpath}\0${targetPath}`; if (seen.has(identity)) throw new AgentRunError("schema-invalid", `resourceBundleRef.bundles[${index}] duplicates an earlier bundle target`, { httpStatus: 400 }); seen.add(identity); - return { ...(name ? { name } : {}), ...(repoUrl === defaultRepoUrl ? {} : { repoUrl }), ...(commitId === defaultCommitId ? {} : { commitId }), subpath, targetPath }; + return { ...(name ? { name } : {}), ...(repoUrl === defaultRepoUrl ? {} : { repoUrl }), ...(commitId && commitId !== defaultCommitId ? { commitId } : {}), ...(ref && ref !== defaultRef ? { ref } : {}), subpath, targetPath }; }); } @@ -131,6 +133,15 @@ function validateCommitId(commitId: string, fieldName: string): void { if (!/^[0-9a-f]{40}$/u.test(commitId)) throw new AgentRunError("schema-invalid", `${fieldName} must be a full 40-character git commit sha`, { httpStatus: 400 }); } +function validateGitRef(value: unknown, fieldName: string): string | undefined { + const ref = optionalString(value); + if (!ref) return undefined; + if (ref.length > 200 || ref.startsWith("-") || ref.includes("..") || ref.includes("@{") || ref.endsWith("/") || ref.endsWith(".") || /[\s~^:?*[\\\x00-\x1f\x7f]/u.test(ref)) { + throw new AgentRunError("schema-invalid", `${fieldName} must be a bounded git ref name`, { httpStatus: 400 }); + } + return ref; +} + function validateResourcePromptRefs(value: unknown): NonNullable { if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.promptRefs must be an array", { httpStatus: 400 }); if (value.length > 16) throw new AgentRunError("schema-invalid", "resourceBundleRef.promptRefs must contain at most 16 entries", { httpStatus: 400 }); diff --git a/src/mgr/result.ts b/src/mgr/result.ts index 92b43dc..4ac0eba 100644 --- a/src/mgr/result.ts +++ b/src/mgr/result.ts @@ -171,10 +171,11 @@ function resourceBundleSummary(run: RunRecord, events: RunEvent[]): JsonRecord | return { kind: run.resourceBundleRef.kind, repoUrl: run.resourceBundleRef.repoUrl, - commitId: run.resourceBundleRef.commitId, + commitId: run.resourceBundleRef.commitId ?? null, + ref: run.resourceBundleRef.ref ?? null, bundles: { count: run.resourceBundleRef.bundles.length, - items: run.resourceBundleRef.bundles.map((item) => ({ name: item.name ?? null, repoUrl: item.repoUrl ?? run.resourceBundleRef?.repoUrl ?? null, commitId: item.commitId ?? run.resourceBundleRef?.commitId ?? null, subpath: item.subpath, targetPath: item.targetPath, valuesPrinted: false })), + items: run.resourceBundleRef.bundles.map((item) => ({ name: item.name ?? null, repoUrl: item.repoUrl ?? run.resourceBundleRef?.repoUrl ?? null, commitId: item.commitId ?? run.resourceBundleRef?.commitId ?? null, ref: item.ref ?? run.resourceBundleRef?.ref ?? null, subpath: item.subpath, targetPath: item.targetPath, valuesPrinted: false })), valuesPrinted: false, }, promptRefs: run.resourceBundleRef.promptRefs ? { count: run.resourceBundleRef.promptRefs.length, names: run.resourceBundleRef.promptRefs.map((item) => item.name), required: run.resourceBundleRef.promptRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false }, diff --git a/src/mgr/store.ts b/src/mgr/store.ts index feab101..558bb45 100644 --- a/src/mgr/store.ts +++ b/src/mgr/store.ts @@ -731,10 +731,11 @@ export function summarizeResourceBundleRef(resourceBundleRef: RunRecord["resourc return { kind: resourceBundleRef.kind, repoUrl: resourceBundleRef.repoUrl, - commitId: resourceBundleRef.commitId, + commitId: resourceBundleRef.commitId ?? null, + ref: resourceBundleRef.ref ?? null, bundles: { count: resourceBundleRef.bundles.length, - items: resourceBundleRef.bundles.map((item) => ({ name: item.name ?? null, repoUrl: item.repoUrl ?? resourceBundleRef.repoUrl, commitId: item.commitId ?? resourceBundleRef.commitId, subpath: item.subpath, targetPath: item.targetPath, valuesPrinted: false })), + items: resourceBundleRef.bundles.map((item) => ({ name: item.name ?? null, repoUrl: item.repoUrl ?? resourceBundleRef.repoUrl, commitId: item.commitId ?? resourceBundleRef.commitId ?? null, ref: item.ref ?? resourceBundleRef.ref ?? null, subpath: item.subpath, targetPath: item.targetPath, valuesPrinted: false })), valuesPrinted: false, }, promptRefs: resourceBundleRef.promptRefs ? { count: resourceBundleRef.promptRefs.length, names: resourceBundleRef.promptRefs.map((item) => item.name), required: resourceBundleRef.promptRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false }, diff --git a/src/runner/resource-bundle.ts b/src/runner/resource-bundle.ts index f299682..1946b27 100644 --- a/src/runner/resource-bundle.ts +++ b/src/runner/resource-bundle.ts @@ -43,14 +43,24 @@ interface MaterializedSkillRef { interface GitCheckout { repoUrl: string; commitId: string; + requestedCommitId?: string; + requestedRef?: string; checkoutPath: string; treeId: string; } +interface GitBundleSource { + repoUrl: string; + commitId?: string; + ref?: string; +} + interface MaterializedGitBundle { name: string | null; repoUrl: string; commitId: string; + requestedCommitId: string | null; + requestedRef: string | null; subpath: string; targetPath: string; sourceKind: "file" | "directory"; @@ -67,18 +77,19 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl await rm(assemblyRoot, { recursive: true, force: true }); await mkdir(checkoutRoot, { recursive: true }); await mkdir(workspacePath, { recursive: true }); + const defaultSource = defaultGitBundleSource(resourceBundleRef, env); const checkoutCache = new Map>(); - const checkoutFor = (repoUrl: string, commitId: string) => { - const key = stableHash({ repoUrl, commitId }); + const checkoutFor = (source: GitBundleSource) => { + const key = stableHash(gitSourceIdentity(source)); let checkout = checkoutCache.get(key); if (!checkout) { - checkout = checkoutGitCommit(checkoutRoot, repoUrl, commitId); + checkout = checkoutGitSource(checkoutRoot, source); checkoutCache.set(key, checkout); } return checkout; }; - const materializedBundles = await materializeGitBundles(workspacePath, resourceBundleRef, checkoutFor); - const defaultCheckout = await checkoutFor(resourceBundleRef.repoUrl, resourceBundleRef.commitId); + const defaultCheckout = await checkoutFor(defaultSource); + const materializedBundles = await materializeGitBundles(workspacePath, resourceBundleRef, defaultSource, defaultCheckout, checkoutFor); const tools = await prepareGitBundleTools(workspacePath); const skills = await discoverGitBundleSkills(workspacePath); const prompts = await materializePromptRefs(defaultCheckout.checkoutPath, resourceBundleRef.promptRefs ?? []); @@ -92,7 +103,9 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl phase: "resource-bundle-materialized", kind: "gitbundle", repoUrl: resourceBundleRef.repoUrl, - commitId: resourceBundleRef.commitId, + commitId: defaultCheckout.commitId, + requestedCommitId: resourceBundleRef.commitId ?? null, + requestedRef: defaultCheckout.requestedRef ?? null, treeId: defaultCheckout.treeId, checkoutPath: pathSummary(defaultCheckout.checkoutPath), workspacePath: pathSummary(workspacePath), @@ -110,26 +123,56 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl }; } -async function checkoutGitCommit(checkoutRoot: string, repoUrl: string, commitId: string): Promise { - const checkoutPath = path.join(checkoutRoot, stableHash({ repoUrl, commitId }).slice(0, 16)); +function defaultGitBundleSource(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv): GitBundleSource { + const ref = optionalNonEmpty(resourceBundleRef.ref) ?? optionalNonEmpty(env.AGENTRUN_RESOURCE_BUNDLE_REF) ?? optionalNonEmpty(env.AGENTRUN_WORKSPACE_REF) ?? optionalNonEmpty(env.AGENTRUN_WORKSPACE_BRANCH); + if (ref) return { repoUrl: resourceBundleRef.repoUrl, ref }; + const commitId = optionalNonEmpty(resourceBundleRef.commitId); + if (commitId) return { repoUrl: resourceBundleRef.repoUrl, commitId }; + return { repoUrl: resourceBundleRef.repoUrl, ref: "HEAD" }; +} + +function bundleGitSource(bundle: ResourceBundleRef["bundles"][number], resourceBundleRef: ResourceBundleRef, defaultSource: GitBundleSource): GitBundleSource { + const repoUrl = bundle.repoUrl ?? resourceBundleRef.repoUrl; + const ref = optionalNonEmpty(bundle.ref); + if (ref) return { repoUrl, ref }; + const commitId = optionalNonEmpty(bundle.commitId); + if (commitId) return { repoUrl, commitId }; + if (repoUrl === defaultSource.repoUrl) return defaultSource; + if (defaultSource.ref) return { repoUrl, ref: defaultSource.ref }; + if (defaultSource.commitId) return { repoUrl, commitId: defaultSource.commitId }; + return { repoUrl, ref: "HEAD" }; +} + +async function checkoutGitSource(checkoutRoot: string, source: GitBundleSource): Promise { + const checkoutPath = path.join(checkoutRoot, stableHash(gitSourceIdentity(source)).slice(0, 16)); await mkdir(checkoutPath, { recursive: true }); await git(["init"], checkoutPath); await git(["remote", "remove", "origin"], checkoutPath, { allowFailure: true }); - await git(["remote", "add", "origin", repoUrl], checkoutPath); - await git(["fetch", "--depth", "1", "origin", commitId], checkoutPath); - await git(["checkout", "--detach", commitId], checkoutPath); + await git(["remote", "add", "origin", source.repoUrl], checkoutPath); + if (source.ref) { + await git(["fetch", "--depth", "1", "origin", source.ref], checkoutPath); + await git(["checkout", "--detach", "FETCH_HEAD"], checkoutPath); + } else if (source.commitId) { + await git(["fetch", "--depth", "1", "origin", source.commitId], checkoutPath); + await git(["checkout", "--detach", source.commitId], checkoutPath); + } else { + throw new AgentRunError("schema-invalid", "gitbundle source must include repo ref or commit", { httpStatus: 400 }); + } const actualCommit = (await git(["rev-parse", "HEAD"], checkoutPath)).stdout.trim(); - if (actualCommit !== commitId) throw new AgentRunError("infra-failed", "gitbundle checkout did not land on requested commit", { httpStatus: 500, details: { expectedCommit: commitId, actualCommit } }); + if (source.commitId && actualCommit !== source.commitId) throw new AgentRunError("infra-failed", "gitbundle checkout did not land on requested commit", { httpStatus: 500, details: { expectedCommit: source.commitId, actualCommit } }); const treeId = (await git(["rev-parse", "HEAD^{tree}"], checkoutPath)).stdout.trim(); - return { repoUrl, commitId, checkoutPath, treeId }; + return { repoUrl: source.repoUrl, commitId: actualCommit, ...(source.commitId ? { requestedCommitId: source.commitId } : {}), ...(source.ref ? { requestedRef: source.ref } : {}), checkoutPath, treeId }; } -async function materializeGitBundles(workspacePath: string, resourceBundleRef: ResourceBundleRef, checkoutFor: (repoUrl: string, commitId: string) => Promise): Promise { +function gitSourceIdentity(source: GitBundleSource): JsonRecord { + return { repoUrl: source.repoUrl, commitId: source.commitId ?? null, ref: source.ref ?? null }; +} + +async function materializeGitBundles(workspacePath: string, resourceBundleRef: ResourceBundleRef, defaultSource: GitBundleSource, defaultCheckout: GitCheckout, checkoutFor: (source: GitBundleSource) => Promise): Promise { const items: MaterializedGitBundle[] = []; for (const [index, bundle] of resourceBundleRef.bundles.entries()) { - const repoUrl = bundle.repoUrl ?? resourceBundleRef.repoUrl; - const commitId = bundle.commitId ?? resourceBundleRef.commitId; - const checkout = await checkoutFor(repoUrl, commitId); + const gitSource = bundleGitSource(bundle, resourceBundleRef, defaultSource); + const checkout = gitSource === defaultSource ? defaultCheckout : await checkoutFor(gitSource); const source = resolveBundlePath(checkout.checkoutPath, bundle.subpath, `bundles[${index}].subpath`); const target = resolveWorkspaceTargetPath(workspacePath, bundle.targetPath, `bundles[${index}].target_path`); let sourceStat; @@ -141,11 +184,15 @@ async function materializeGitBundles(workspacePath: string, resourceBundleRef: R await mkdir(path.dirname(target), { recursive: true }); await rm(target, { recursive: true, force: true }); await cp(source, target, { recursive: true, force: true, dereference: false }); - items.push({ name: bundle.name ?? null, repoUrl, commitId, subpath: bundle.subpath, targetPath: bundle.targetPath, sourceKind: sourceStat.isDirectory() ? "directory" : "file", sourceBytes: sourceStat.isFile() ? sourceStat.size : null }); + items.push({ name: bundle.name ?? null, repoUrl: checkout.repoUrl, commitId: checkout.commitId, requestedCommitId: bundle.commitId ?? resourceBundleRef.commitId ?? null, requestedRef: checkout.requestedRef ?? null, subpath: bundle.subpath, targetPath: bundle.targetPath, sourceKind: sourceStat.isDirectory() ? "directory" : "file", sourceBytes: sourceStat.isFile() ? sourceStat.size : null }); } return items; } +function optionalNonEmpty(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + async function prepareGitBundleTools(workspacePath: string): Promise<{ binPath?: string; event: JsonRecord }> { const binPath = path.join(workspacePath, "tools"); let entries; diff --git a/src/runner/run-once.ts b/src/runner/run-once.ts index 7b7cdd4..97ba845 100644 --- a/src/runner/run-once.ts +++ b/src/runner/run-once.ts @@ -95,7 +95,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise { if (!materializationAttempted) { materializationAttempted = true; try { - const materialized = await materializeResourceBundle(claimed.resourceBundleRef ?? null, resourceMaterializationEnv(options.env ?? process.env, options.runId, attemptId)); + const materialized = await materializeResourceBundle(claimed.resourceBundleRef ?? null, resourceMaterializationEnv(options.env ?? process.env, options.runId, attemptId, claimed.workspaceRef)); if (materialized) { workspacePath = materialized.workspacePath; resourceEnv = resourceEnvForMaterialized(options.env ?? process.env, materialized); @@ -145,8 +145,14 @@ function withResourceAssembly(options: RunnerOnceOptions, resourceEnv: NodeJS.Pr }; } -function resourceMaterializationEnv(env: NodeJS.ProcessEnv, runId: string, attemptId: string): NodeJS.ProcessEnv { - return { ...env, AGENTRUN_RUN_ID: env.AGENTRUN_RUN_ID ?? runId, AGENTRUN_ATTEMPT_ID: env.AGENTRUN_ATTEMPT_ID ?? attemptId }; +function resourceMaterializationEnv(env: NodeJS.ProcessEnv, runId: string, attemptId: string, workspaceRef: RunRecord["workspaceRef"]): NodeJS.ProcessEnv { + const workspaceBranch = typeof workspaceRef.branch === "string" && workspaceRef.branch.trim().length > 0 ? workspaceRef.branch.trim() : undefined; + return { + ...env, + AGENTRUN_RUN_ID: env.AGENTRUN_RUN_ID ?? runId, + AGENTRUN_ATTEMPT_ID: env.AGENTRUN_ATTEMPT_ID ?? attemptId, + ...(workspaceBranch ? { AGENTRUN_WORKSPACE_BRANCH: env.AGENTRUN_WORKSPACE_BRANCH ?? workspaceBranch, AGENTRUN_WORKSPACE_REF: env.AGENTRUN_WORKSPACE_REF ?? workspaceBranch } : {}), + }; } function resourceEnvForMaterialized(env: NodeJS.ProcessEnv, materialized: Awaited>): NodeJS.ProcessEnv | undefined { diff --git a/src/selftest/cases/50-hwlab-manual-dispatch.ts b/src/selftest/cases/50-hwlab-manual-dispatch.ts index f4d24e7..d32fa11 100644 --- a/src/selftest/cases/50-hwlab-manual-dispatch.ts +++ b/src/selftest/cases/50-hwlab-manual-dispatch.ts @@ -11,7 +11,8 @@ import type { JsonRecord, ResourceBundleRef } from "../../common/types.js"; import { assertNoSecretLeak, type SelfTestCase, type SelfTestContext } from "../harness.js"; const execFile = promisify(execFileCallback); -type LocalBundle = { repoUrl: string; commitId: string; bundles?: ResourceBundleRef["bundles"]; promptRefs?: ResourceBundleRef["promptRefs"] }; +type LocalBundle = { repoUrl: string; commitId: string; branch?: string; bundles?: ResourceBundleRef["bundles"]; promptRefs?: ResourceBundleRef["promptRefs"] }; +type RefResolvedBundle = LocalBundle & { branch: string; latestCommitId: string }; const selfTest: SelfTestCase = async (context) => { const containerfile = await readFile(path.join(context.root, "deploy/container/Containerfile"), "utf8"); @@ -95,6 +96,21 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin assert.deepEqual(((materialized.skillDirs as JsonRecord).names), ["hwpod-cli", "hwpod-ctl"]); assertNoSecretLeak(resultEnvelope); + const refBundle = await createRefResolvedLocalGitBundle(context); + const refRun = await createHwlabRun(client, context, refBundle, "hwlab-session-ref-resolution", "resolve bundle from branch ref", "hwlab-command-ref-resolution"); + const refRunResult = await runOnce({ managerUrl: server.baseUrl, runId: refRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-ref-resolution") }, oneShot: true }) as JsonRecord; + assert.equal(refRunResult.terminalStatus, "completed"); + const refEnvelope = await client.get(`/api/v1/runs/${refRun.runId}/commands/${refRun.commandId}/result`) as JsonRecord; + const refResource = refEnvelope.resourceBundleRef as JsonRecord; + assert.equal(refResource.commitId, refBundle.commitId, "result summary keeps the request commit as a non-authoritative hint"); + const refMaterialized = refResource.materialized as JsonRecord; + assert.equal(refMaterialized.commitId, refBundle.latestCommitId, "materialized bundle must resolve the current workspaceRef.branch commit"); + assert.equal(refMaterialized.requestedCommitId, refBundle.commitId); + assert.equal(refMaterialized.requestedRef, refBundle.branch); + const refBundleCommits = (((refMaterialized.bundles as JsonRecord).items as JsonRecord[]).map((item) => item.commitId)); + assert.deepEqual(refBundleCommits, [refBundle.latestCommitId, refBundle.latestCommitId]); + assertNoSecretLeak(refEnvelope); + const assemblyRun = await createHwlabRun(client, context, assemblyBundle, "hwlab-session-assembly", "list visible bundle skills without tools", "hwlab-command-assembly-1"); const assemblyInputFile = path.join(context.tmp, "fake-codex-turn-input-assembly.jsonl"); const assemblyRunner = runOnce({ managerUrl: server.baseUrl, runId: assemblyRun.runId, commandId: assemblyRun.commandId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-assembly"), AGENTRUN_FAKE_CODEX_TURN_INPUT_FILE: assemblyInputFile }, idleTimeoutMs: 500, pollIntervalMs: 50 }); @@ -195,14 +211,14 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin const runningResult = await running; assert.equal(runningResult.terminalStatus, "cancelled"); - return { name: "hwlab-manual-dispatch", tests: ["runner-job-idempotency", "pending-cancel", "result-envelope", "session-ref-resume", "resource-gitbundle-materialization", "gitbundle-tools-path", "gitbundle-skill-dir-assembly", "resource-prompt-required-blocker", "same-run-runner-multiturn", "running-steer", "running-cancel"] }; + return { name: "hwlab-manual-dispatch", tests: ["runner-job-idempotency", "pending-cancel", "result-envelope", "session-ref-resume", "resource-gitbundle-materialization", "gitbundle-ref-resolution", "gitbundle-tools-path", "gitbundle-skill-dir-assembly", "resource-prompt-required-blocker", "same-run-runner-multiturn", "running-steer", "running-cancel"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); } }; -async function createLocalGitBundle(context: SelfTestContext): Promise { - const repo = path.join(context.tmp, "bundle-repo"); +async function createLocalGitBundle(context: SelfTestContext, repoName = "bundle-repo"): Promise { + const repo = path.join(context.tmp, repoName); await mkdir(repo, { recursive: true }); await execFile("git", ["init"], { cwd: repo }); await writeFile(path.join(repo, "README.md"), "HWLAB bundle self-test\n", "utf8"); @@ -244,13 +260,26 @@ async function createLocalGitBundle(context: SelfTestContext): Promise { + const first = await createLocalGitBundle(context, "bundle-ref-repo"); + const { stdout: branchStdout } = await execFile("git", ["branch", "--show-current"], { cwd: first.repoUrl }); + const branch = branchStdout.trim() || "master"; + await writeFile(path.join(first.repoUrl, "README.md"), "HWLAB bundle self-test latest ref\n", "utf8"); + await execFile("git", ["add", "README.md"], { cwd: first.repoUrl }); + await execFile("git", ["-c", "user.email=selftest@example.invalid", "-c", "user.name=AgentRun SelfTest", "commit", "-m", "bundle selftest latest ref"], { cwd: first.repoUrl }); + const { stdout } = await execFile("git", ["rev-parse", "HEAD"], { cwd: first.repoUrl }); + const latestCommitId = stdout.trim(); + assert.notEqual(latestCommitId, first.commitId); + return { ...first, branch, latestCommitId }; +} + async function createHwlabRun(client: ManagerClient, context: SelfTestContext, bundle: LocalBundle, sessionId: string, prompt: string, idempotencyKey: string, timeoutMs = 15_000): Promise<{ runId: string; commandId: string }> { const resourceBundleRef: ResourceBundleRef = { kind: "gitbundle", repoUrl: bundle.repoUrl, commitId: bundle.commitId, bundles: bundle.bundles ?? defaultGitBundles(), submodules: false, lfs: false }; if (bundle.promptRefs) resourceBundleRef.promptRefs = bundle.promptRefs; const run = await client.post("/api/v1/runs", { tenantId: "hwlab", projectId: "pikasTech/HWLAB", - workspaceRef: { kind: "opaque", repo: "pikasTech/HWLAB" }, + workspaceRef: { kind: "opaque", repo: "pikasTech/HWLAB", ...(bundle.branch ? { branch: bundle.branch } : {}) }, sessionRef: { sessionId, conversationId: sessionId }, resourceBundleRef, providerId: "G14",