diff --git a/.changeset/bailian-native-deployments.md b/.changeset/bailian-native-deployments.md new file mode 100644 index 0000000..3a60a55 --- /dev/null +++ b/.changeset/bailian-native-deployments.md @@ -0,0 +1,5 @@ +--- +"@openagentpack/sdk": minor +--- + +Bailian: implement native Deployment support against the Agent Studio `/deployments` API (create, get, list, update, archive, run, pause/unpause), replacing the previous emulated session expansion. Deployment schedules now run server-side; `user.define_outcome` events and `github_repository` resources are dropped from the deployment payload and surface a warning on plan. diff --git a/README.md b/README.md index 89c0c46..ddd6dbb 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ Beta testers can install `@openagentpack/cli@beta`; see the [release guide](./do | MCP Server | native | native | native | native | | Memory Store | unsupported | native | native | native | | Multi-Agent | unsupported | unsupported | native | native | -| Deployment | emulated | native | native | emulated | +| Deployment | native | native | native | emulated | | Session | native | native | native | native | The full capability matrix and per-provider differences live in the [Provider reference](./docs/reference/providers.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index b316783..b941e1e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -147,7 +147,7 @@ Beta 用户可以安装 `@openagentpack/cli@beta`;固定版本及切回稳定 | MCP Server | native | native | native | native | | Memory Store | unsupported | native | native | native | | Multi-Agent | unsupported | unsupported | native | native | -| Deployment | emulated | native | native | emulated | +| Deployment | native | native | native | emulated | | Session | native | native | native | native | 完整能力矩阵与各 Provider 差异见 [Provider 参考](./docs/reference/providers.zh-CN.md)。 diff --git a/bun.lock b/bun.lock index 10aaa7a..f8b12e8 100644 --- a/bun.lock +++ b/bun.lock @@ -131,7 +131,7 @@ "esbuild": "0.28.1", "fast-equals": "5.3.3", "js-yaml": "4.3.1", - "nanoid": "3.3.17", + "nanoid": "3.3.18", "postcss": "8.5.23", }, "packages": { @@ -831,7 +831,7 @@ "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], - "nanoid": ["nanoid@3.3.17", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="], + "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], diff --git a/docs/architecture/how-it-works.md b/docs/architecture/how-it-works.md index 0c76937..006fa4c 100644 --- a/docs/architecture/how-it-works.md +++ b/docs/architecture/how-it-works.md @@ -63,4 +63,4 @@ agents state import
Resources (agents, environments, skills…) are **infrastructure** — long-lived, managed by `plan`/`apply`. A **session** is a **runtime** conversation started from an agent. Sessions are managed separately with `agents session` and are not part of the plan/apply lifecycle. -Deployments sit between the two: they are declared as resources but produce runs. On Qoder and Claude they schedule server-side; on Bailian and Volcengine Ark a `deployment run` expands into a session. +Deployments sit between the two: they are declared as resources but produce runs. On Bailian, Qoder, and Claude they schedule server-side; on Volcengine Ark a `deployment run` expands into a session. diff --git a/docs/concepts/agents-as-code.md b/docs/concepts/agents-as-code.md index ec64d9a..13f15c6 100644 --- a/docs/concepts/agents-as-code.md +++ b/docs/concepts/agents-as-code.md @@ -19,7 +19,7 @@ Because the declaration is a file, it gets everything a file gets: code review, - The **agent harness** is the provider-managed layer that wraps a model into an agent: knowledge base, skills, MCP wiring, prompt/instructions, vault, deployment, multi-agent orchestration. These are the customer's portable assets. - The **agent infra** is the interchangeable execution substrate beneath the harness — the specific provider (Bailian, Qoder, Claude, Volcengine Ark) that runs the agent. -OpenAgentPack's portability claim is that the same harness declaration can target different agent infra. Portability means the *core declaration* is portable and the per-provider **capability contract** is explicit — unsupported facets degrade gracefully (for example, an emulated `Deployment` on Bailian/Volcengine Ark) — not that every feature is identical on every provider. +OpenAgentPack's portability claim is that the same harness declaration can target different agent infra. Portability means the _core declaration_ is portable and the per-provider **capability contract** is explicit — unsupported facets degrade gracefully (for example, an emulated `Deployment` on Volcengine Ark) — not that every feature is identical on every provider. ## What this enables diff --git a/docs/concepts/sessions-and-deployments.md b/docs/concepts/sessions-and-deployments.md index d8597d7..4f662ea 100644 --- a/docs/concepts/sessions-and-deployments.md +++ b/docs/concepts/sessions-and-deployments.md @@ -24,9 +24,10 @@ How a deployment *runs* depends on the provider's capability tier: |----------|:--------------:|------------------------------------| | Claude | native | schedules server-side through the deployments API | | Qoder | native | creates a deployment run and associated session | -| Bailian, Ark | emulated | expands into a one-shot session at run time | +| Bailian | native | triggers a server-side run through the deployments API | +| Ark | emulated | expands into a one-shot session at run time | -On the emulated providers, scheduling and outcome rubrics are **not** enforced server-side — use external cron/CI for always-on or scheduled runs. +On Ark (the emulated provider), scheduling and outcome rubrics are **not** enforced server-side — use external cron/CI for always-on or scheduled runs. ## The lifecycle in one picture @@ -34,7 +35,7 @@ On the emulated providers, scheduling and outcome rubrics are **not** enforced s agents.yaml ──plan/apply──▶ managed resources (agent, environment, …) │ └─session create/run──▶ runtime session - └─deployment run──────▶ runtime session (emulated) or scheduled run (native) + └─deployment run──────▶ scheduled run (native) or runtime session (emulated on Ark) ``` Next: [Run sessions](../guides/run-sessions.md) and [Manage deployments](../guides/manage-deployments.md). diff --git a/docs/examples.md b/docs/examples.md index d1edbe2..5a5102c 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -53,7 +53,7 @@ agents destroy | MCP Server | native | native | native | native | | Memory Store | unsupported | native | native | native | | Multi-Agent | unsupported | unsupported | native | native | -| Deployment | emulated | native | native | emulated | +| Deployment | native | native | native | emulated | | Session | native | native | native | native | See [Provider reference](./reference/providers.md) for per-provider configuration and notes. diff --git a/docs/guides/configure-an-agent.zh-CN.md b/docs/guides/configure-an-agent.zh-CN.md index 3610281..344ac32 100644 --- a/docs/guides/configure-an-agent.zh-CN.md +++ b/docs/guides/configure-an-agent.zh-CN.md @@ -484,7 +484,7 @@ Deployment 是介于「定义」与「运行」之间的声明式中间层。它 - **部署层**(Deployment):声明「用哪个 Agent、带哪些绑定、以什么初始事件和调度运行」。 - **运行层**(Session):一次具体的执行实例。 -> Provider 差异:Qoder 和 Claude 原生支持 Deployment(对应平台的 deployments API,可服务端调度);百炼、火山方舟为**模拟**实现——`apply` 只写本地状态(`remote_id` 为 `null`),`agents deployment run` 时展开为一个 Session。详见 [Provider 参考](../reference/providers.zh-CN.md#模拟emulated资源的能力降级)。 +> Provider 差异:百炼、Qoder 和 Claude 原生支持 Deployment(对应平台的 deployments API,可服务端调度);火山方舟为**模拟**实现——`apply` 只写本地状态(`remote_id` 为 `null`),`agents deployment run` 时展开为一个 Session。百炼上 `user.define_outcome` 事件和 `github_repository` 资源不在部署 payload 内,plan 时会输出警告。详见 [Provider 参考](../reference/providers.zh-CN.md#原生-deployment-的-payload-裁剪)。 ### 定义 Deployment diff --git a/docs/guides/deploy-to-bailian.md b/docs/guides/deploy-to-bailian.md index 0a3352d..32ac5ee 100644 --- a/docs/guides/deploy-to-bailian.md +++ b/docs/guides/deploy-to-bailian.md @@ -26,11 +26,11 @@ providers: | Environment, Vault, Skill, Agent, MCP Server, Session | native | | Memory Store | unsupported | | Multi-Agent | unsupported | -| Deployment | emulated | +| Deployment | native | - Skills upload as a zip via the Files API (two-step). - MCP servers are **official managed servers** referenced by `name` (no vault needed for them). -- `deployment run` expands into a one-shot session; scheduling/outcome rubrics are not enforced server-side. +- Deployments are native: `apply` creates the remote deployment, `schedule` runs server-side (cron + timezone), and `deployment run` triggers a server-side run. `user.define_outcome` events and `github_repository` resources are not part of the deployment payload and surface a warning on plan. ## Minimal agent diff --git a/docs/guides/manage-deployments.md b/docs/guides/manage-deployments.md index 3b5486d..3b3ed16 100644 --- a/docs/guides/manage-deployments.md +++ b/docs/guides/manage-deployments.md @@ -73,12 +73,14 @@ Qoder deployments may also declare `environment_variables` as a semicolon- or ne |----------|:--------------:|----------------------------| | Claude | native | schedules server-side through the deployments API | | Qoder | native | creates a deployment run and associated session | -| Bailian, Ark | emulated | expands into a one-shot session at run time | +| Bailian | native | triggers a server-side run through the deployments API | +| Ark | emulated | expands into a one-shot session at run time | -On the emulated providers, scheduling and outcome rubrics are **not** enforced server-side — use external cron/CI for always-on or scheduled runs. +On Ark (the emulated provider), scheduling and outcome rubrics are **not** enforced server-side — use external cron/CI for always-on or scheduled runs. On Bailian, `user.define_outcome` events and `github_repository` resources are dropped from the deployment payload and surface a warning on plan. ## Examples - Native deployment + outcome rubric: [`examples/claude/deployment/`](../../examples/claude/deployment/) - Native deployment + memory store: [`examples/qoder/deployment/`](../../examples/qoder/deployment/) -- Emulated deployment + file resources: [`examples/bailian/deployment/`](../../examples/bailian/deployment/) and [`examples/ark/deployment/`](../../examples/ark/deployment/) +- Native deployment + file resources: [`examples/bailian/deployment/`](../../examples/bailian/deployment/) +- Emulated deployment + file resources: [`examples/ark/deployment/`](../../examples/ark/deployment/) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index cfee071..aa7e803 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -124,7 +124,7 @@ Manage scheduled / triggered deployments. | `deployment get ` | Show a deployment's status and resolved bindings. | | `deployment pause ` | Pause scheduled runs for a native deployment. | | `deployment unpause ` | Resume a paused native deployment. | -| `deployment run ` | Trigger a deployment run (native on Qoder/Claude, emulated as a session on Bailian/Volcengine Ark). | +| `deployment run ` | Trigger a deployment run (native on Bailian/Qoder/Claude, emulated as a session on Volcengine Ark). | ## `agents memory-store` diff --git a/docs/reference/providers.md b/docs/reference/providers.md index 45380d1..2cdd665 100644 --- a/docs/reference/providers.md +++ b/docs/reference/providers.md @@ -15,7 +15,7 @@ OpenAgentPack targets multiple agent platforms behind one declarative config. Ea | MCP Server | native | native | native | native | Bailian uses official managed servers referenced by name. | | Memory Store | unsupported | native | native | native | Qoder, Claude (beta), and Ark adapters implement the complete upstream lifecycle. | | Multi-Agent | unsupported | unsupported | native | native | Coordinator topology is available on Claude and Volcengine Ark. | -| Deployment | emulated | native | native | emulated | Qoder and Claude use native deployments; Bailian and Ark expand a deployment into a session at `run` time. | +| Deployment | native | native | native | emulated | Bailian, Qoder, and Claude use native deployments; Ark expands a deployment into a session at `run` time. | | Session | native | native | native | native | Runtime sessions are native on every provider. | - **native** — the provider supports the feature directly. @@ -32,7 +32,7 @@ The resource matrix above answers whether a declaration can be applied. The tabl |------------------|:-------:|:-----:|:------:|:--------------:|----------------------| | List agents, environments, and vaults | yes | yes | yes | yes | Powers resource discovery in the Web UI. | | Export resources to YAML (`sync`) | yes | yes | yes | limited | Ark cannot enumerate skills, so skill export is skipped. | -| Full drift comparison | Environment, Agent | Environment, Agent | no | no | Other supported resources degrade to existence checks; emulated deployments are local state. | +| Full drift comparison | Environment, Agent | Environment, Agent | no | no | Other supported resources degrade to existence checks; deployment content is never compared. | | List uploaded files | yes | yes | yes | yes | File upload, metadata lookup, and deletion are also implemented by all adapters. | | Resolve artifact download URL | no | yes | no | no | Qoder exposes a short-lived file content URL. | | List skills | yes | yes | yes | no | Ark supports lookup by ID, but its adapter cannot enumerate skills. | @@ -46,7 +46,7 @@ The resource matrix above answers whether a declaration can be applied. The tabl ### Notable provider-specific behavior -- **Bailian:** skill upload uses the Files API and supports scan-status polling; agent updates create provider-side versions. Official MCP servers are referenced by name. +- **Bailian:** skill upload uses the Files API and supports scan-status polling; agent updates create provider-side versions. Official MCP servers are referenced by name. Deployments are native, with server-side cron schedules, manual runs, and pause/unpause. - **Qoder:** tool names are translated from the lowercase config vocabulary to PascalCase. Session sends return a cursor, enabling resumable event consumption. Deployments are native and support manual or scheduled runs. - **Claude:** deployments are native, including their server-side lifecycle. It is currently the only adapter that downloads remote skill packages during `sync`. - **Volcengine Ark:** skills are create + get + attach only in the API behavior verified by this project. Updates re-upload a new skill; list and in-place update are unavailable; deletion is best-effort. Deployment is emulated as a session. diff --git a/docs/reference/providers.zh-CN.md b/docs/reference/providers.zh-CN.md index 0220771..0f43787 100644 --- a/docs/reference/providers.zh-CN.md +++ b/docs/reference/providers.zh-CN.md @@ -34,7 +34,7 @@ OpenAgentPack 通过 Provider 适配器与不同的 AI Agent 平台交互。每 | MCP Server | native | native | native | native | 通过 Agent 的 MCP 配置挂载 | | Memory Store | unsupported | native | native | native | Qoder、Claude(beta)、方舟均已接入 | | Multi-Agent | unsupported | unsupported | native | native | Claude 与 火山方舟 支持 coordinator | -| Deployment | emulated | native | native | emulated | Qoder 和 Claude 使用原生 Deployment;百炼和火山方舟在 `run` 时展开为 Session | +| Deployment | native | native | native | emulated | 百炼、Qoder 和 Claude 使用原生 Deployment;火山方舟在 `run` 时展开为 Session | | Session | native | native | native | native | 四者均原生支持 | ### Adapter 实现能力对照表 @@ -45,7 +45,7 @@ OpenAgentPack 通过 Provider 适配器与不同的 AI Agent 平台交互。每 |----------------|:----:|:-----:|:------:|:--------:|----------| | 枚举 Agent、Environment、Vault | yes | yes | yes | yes | 用于 Web UI 的云端资源发现 | | 导出资源到 YAML(`sync`) | yes | yes | yes | limited | 方舟无法枚举 Skill,因此会跳过 Skill 导出 | -| 完整 Drift 内容比较 | Environment、Agent | Environment、Agent | no | no | 其他已支持资源降级为存在性检查;模拟 Deployment 仅有本地状态 | +| 完整 Drift 内容比较 | Environment、Agent | Environment、Agent | no | no | 其他已支持资源降级为存在性检查;Deployment 不比较内容 | | 枚举已上传文件 | yes | yes | yes | yes | 四个 Adapter 也都实现上传、元数据查询和删除 | | 获取产物下载 URL | no | yes | no | no | Qoder 可返回短期有效的文件内容 URL | | 枚举 Skill | yes | yes | yes | no | 方舟可按 ID 查询,但当前无法枚举 | @@ -59,7 +59,7 @@ OpenAgentPack 通过 Provider 适配器与不同的 AI Agent 平台交互。每 #### Provider 特有实现与限制 -- **百炼**:Skill 通过 Files API 上传并支持扫描状态轮询;Agent 更新会生成平台侧版本;官方 MCP Server 按名称引用。 +- **百炼**:Skill 通过 Files API 上传并支持扫描状态轮询;Agent 更新会生成平台侧版本;官方 MCP Server 按名称引用;Deployment 为原生资源,支持服务端 cron 调度、手动触发和暂停/恢复。 - **Qoder**:配置中的小写工具名会转换为 PascalCase;Session 发送返回游标,可恢复事件消费;Deployment 为原生资源,支持手动或定时运行。 - **Claude**:Deployment 是原生资源,具有服务端生命周期;当前只有 Claude Adapter 会在 `sync` 时下载远端 Skill 包。 - **火山方舟**:经本项目验证的 Skill API 行为仅支持创建、按 ID 查询和挂载。更新会重新上传,无法枚举和原地更新,删除为 best-effort;Deployment 由 Session 模拟。 @@ -113,7 +113,7 @@ OpenAgentPack 通过 Provider 适配器与不同的 AI Agent 平台交互。每 | Skill | existence | existence | existence | existence | 可发现缺失/删除,不比较包内容 | | Vault | existence | existence | existence | existence | 凭证内容通常不可读回,不比较内容 | | Memory Store | unsupported | existence | existence | existence | 可发现资源缺失 | -| Deployment | unsupported | native | native 路径待验证 | unsupported | 百炼和火山方舟的 emulated Deployment 为本地记录 | +| Deployment | unsupported | native | native 路径待验证 | unsupported | 百炼 Deployment 为原生资源但不比较内容;火山方舟的 emulated Deployment 仅为本地记录 | Claude 的 drift detection 接口路径已预留;本仓库中的 live baseline 因 Anthropic API 账号余额不足未完成 Agent 创建验证。 @@ -139,7 +139,7 @@ qoder.multiagent.unsupported: ### 模拟(emulated)资源的能力降级 -`emulated` 等级表示 Provider 没有对应的原生原语,OpenAgentPack 通过其他原语间接实现。Deployment 在百炼和火山方舟上为模拟实现:`apply` 时**不调用**部署 API(状态记录的 `remote_id` 为 `null`),而是在 `agents deployment run` 时展开为一个 Session 并回放 `initial_events`。 +`emulated` 等级表示 Provider 没有对应的原生原语,OpenAgentPack 通过其他原语间接实现。Deployment 在火山方舟上为模拟实现:`apply` 时**不调用**部署 API(状态记录的 `remote_id` 为 `null`),而是在 `agents deployment run` 时展开为一个 Session 并回放 `initial_events`。 部分子特性在 emulated Provider 上无法在服务端执行。`plan`/`apply` 阶段会输出**警告**(不阻断部署),`run` 时尽力降级: @@ -153,13 +153,30 @@ qoder.multiagent.unsupported: 示例诊断输出: ``` -⚠ bailian.deployment.schedule_unsupported - Resource: deployment.daily-report (bailian) +⚠ ark.deployment.schedule_unsupported + Resource: deployment.daily-report (ark) Schedules are not enforced server-side on this provider; trigger runs via external cron/CI. +⚠ ark.deployment.define_outcome_unsupported + Resource: deployment.daily-report (ark) + Outcome rubrics (user.define_outcome) are not enforced server-side on this provider; the run executes without rubric grading. +``` + +### 原生 Deployment 的 payload 裁剪 + +百炼 Deployment 是原生资源,但其 payload 比 OpenAgentPack 的中立声明更窄:`initial_events` 只承载消息,`resources` 只接受文件。被丢弃的字段会在 `plan`/`apply` 阶段输出**警告**: + +| 子特性 | 百炼行为 | 替代建议 | +|--------|----------|---------| +| `initial_events` 中的 `user.define_outcome` | 从 payload 中丢弃,不做结果评分 | 将要求写入 `user.message` / `system.message` | +| `resources` 中的 `github_repository` | 从 payload 中丢弃 | 在 Session 内克隆仓库 | + +示例诊断输出: + +``` ⚠ bailian.deployment.define_outcome_unsupported Resource: deployment.daily-report (bailian) - Outcome rubrics (user.define_outcome) are not enforced server-side on this provider; the run executes without rubric grading. + Outcome rubrics (user.define_outcome) are dropped from the Bailian deployment payload; the run executes without rubric grading. ``` ## Provider 配置 diff --git a/examples/README.md b/examples/README.md index 847c24c..5691413 100644 --- a/examples/README.md +++ b/examples/README.md @@ -65,7 +65,7 @@ extensions, and live-test commands. | MCP Server | native | native | native | native | Bailian uses official managed servers referenced by name. | | Memory Store | unsupported | native | native | native | Qoder, Claude (beta), and Volcengine Ark. | | Multi-Agent | unsupported | unsupported | native | native | Claude and Volcengine Ark support coordinator. | -| Deployment | emulated | native | native | emulated | Qoder and Claude schedule server-side; Bailian and Ark expand into a session at `run` time. | +| Deployment | native | native | native | emulated | Bailian, Qoder, and Claude schedule server-side; Ark expands into a session at `run` time. | | Session | native | native | native | native | All four support runtime sessions. | | GitHub Session resource | unsupported | native | native | unsupported | Qoder and Claude clone and mount repositories at Session creation. | diff --git a/examples/bailian/deployment/agents.yaml b/examples/bailian/deployment/agents.yaml index 0d9b847..e2c9622 100644 --- a/examples/bailian/deployment/agents.yaml +++ b/examples/bailian/deployment/agents.yaml @@ -26,30 +26,33 @@ agents: tools: builtin: [read, glob, grep, web_search] -# Deployment is EMULATED on Bailian (no native deployment primitive): -# - `agents apply` records local state only (remote_id = null) -# - `agents deployment run daily-report` expands it into a Session, uploads the -# file resource, and replays the initial_events. -# `schedule` and `define_outcome` are NOT enforced server-side and will surface -# warning diagnostics on plan. Use external cron/CI for scheduled runs. +# Deployment is NATIVE on Bailian (real /deployments API): +# - `agents apply` creates the remote Deployment (state records its remote_id) +# - `schedule` runs server-side (cron + timezone) +# - `agents deployment run daily-report` triggers a server-side run +# - `agents deployment pause/unpause daily-report` toggles scheduled runs +# `define_outcome` events and `github_repository` resources are NOT part of the +# deployment payload and surface warning diagnostics on plan; only file resources +# are uploaded (at apply time) and mounted into each run's Session. deployments: daily-report: agent: reporter - description: "Daily report (emulated -> Session on run)" + description: "Daily report (native Bailian deployment)" schedule: expression: "0 9 * * *" timezone: UTC initial_events: - type: user.message content: "Summarize yesterday's commits and generate the daily report." - # define_outcome is accepted in config but filtered out on Bailian (no - # server-side outcome evaluation); kept here for cross-provider parity. + # define_outcome is accepted in config but dropped from the Bailian + # deployment payload (no server-side outcome evaluation); kept here for + # cross-provider parity. - type: user.define_outcome description: "Daily report quality gate" rubric: "Must include an executive summary and at least three key metrics." max_iterations: 3 resources: - # Local file uploaded at `deployment run` time and mounted into the Session. + # Local file uploaded at `apply` time and mounted into each run's Session. - type: file source: ./data/report-template.md - mount_path: /data/report-template.md + mount_path: /mnt/report-template.md diff --git a/examples/bailian/with-mcp/agents.yaml b/examples/bailian/with-mcp/agents.yaml index f9fcb45..2f40d15 100644 --- a/examples/bailian/with-mcp/agents.yaml +++ b/examples/bailian/with-mcp/agents.yaml @@ -43,7 +43,7 @@ agents: deployments: web-search-demo: agent: researcher - description: "WebSearch demo (emulated -> Session on run)" + description: "WebSearch demo (native Bailian deployment)" initial_events: - type: user.message content: | diff --git a/package.json b/package.json index ae70704..415e25c 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "esbuild": "0.28.1", "fast-equals": "5.3.3", "js-yaml": "4.3.1", - "nanoid": "3.3.17", + "nanoid": "3.3.18", "postcss": "8.5.23" }, "trustedDependencies": [ diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index c42abe9..551d7d3 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -328,7 +328,7 @@ deploymentCmd deploymentCmd .command("run ") - .description("Trigger a deployment run (native on Qoder/Claude, emulated on Bailian/Ark)") + .description("Trigger a deployment run (native on Bailian/Qoder/Claude, emulated on Ark)") .addOption(configFileOption()) .addOption(providerOption("Target provider")) .action(withResolvedConfigFile(deploymentRunCommand)); diff --git a/packages/sdk/src/internal/core/validate-config.ts b/packages/sdk/src/internal/core/validate-config.ts index dcac16f..3a6a862 100644 --- a/packages/sdk/src/internal/core/validate-config.ts +++ b/packages/sdk/src/internal/core/validate-config.ts @@ -9,7 +9,7 @@ import { getProvider } from "../providers/registry.ts"; import type { ProjectConfig } from "../types/config.ts"; import type { Diagnostic } from "../types/plan.ts"; import type { ResourceAddress } from "../types/state.ts"; -import { providerMountPrefix } from "../utils/sandbox-mount.ts"; +import { providerMountPrefix, resolveSandboxMountPath } from "../utils/sandbox-mount.ts"; import { findMissingBailianMcpToolConfigs } from "../validation/bailian.ts"; export interface ValidateProjectConfigOptions { @@ -364,6 +364,83 @@ export function collectProviderCapabilities( } } + if (providerName === "bailian") { + // Bailian deployments are native, but their payload is narrower than the + // provider-neutral declaration: initial_events carry messages only, and + // resources accept files only. Surface what gets dropped. + for (const [name, deployment] of Object.entries(config.deployments ?? {})) { + if (deployment.provider && deployment.provider !== providerName) continue; + const addr: ResourceAddress = { + type: "deployment", + name, + provider: providerName, + }; + + if (deployment.initial_events?.some((event) => event.type === "user.define_outcome")) { + diagnostics.warning( + `${providerName}.deployment.define_outcome_unsupported`, + "Outcome rubrics (user.define_outcome) are dropped from the Bailian deployment payload; the run executes without rubric grading.", + addr, + ); + } + if ( + !deployment.initial_events?.some((event) => event.type === "user.message" || event.type === "system.message") + ) { + diagnostics.error( + `${providerName}.deployment.initial_events.message_required`, + `deployment.${name}: Bailian requires at least one user.message or system.message initial event; user.define_outcome events are dropped.`, + addr, + ); + } + + if (deployment.resources?.some((resource) => resource.type === "github_repository")) { + diagnostics.warning( + `${providerName}.deployment.github_repository_unsupported`, + "Bailian deployment resources accept files only; github_repository resources are dropped. Clone the repository inside the session instead.", + addr, + ); + } + + const mountPrefix = providerMountPrefix(providerName); + const normalizedMountPaths = new Set(); + for (const resource of deployment.resources ?? []) { + if (resource.type !== "file") continue; + if (!resource.mount_path?.trim()) { + diagnostics.error( + `${providerName}.deployment.file.mount_path.required`, + `deployment.${name}: Bailian file resources require mount_path.`, + addr, + ); + continue; + } + if ( + mountPrefix && + resource.mount_path.startsWith("/") && + resource.mount_path !== mountPrefix && + !resource.mount_path.startsWith(`${mountPrefix}/`) + ) { + diagnostics.error( + `${providerName}.deployment.file.mount_path.invalid`, + `deployment.${name}: Bailian file mount_path must start with '${mountPrefix}/'.`, + addr, + ); + continue; + } + + const normalizedMountPath = resolveSandboxMountPath(providerName, resource.mount_path); + if (normalizedMountPaths.has(normalizedMountPath)) { + diagnostics.error( + `${providerName}.deployment.file.mount_path.duplicate`, + `deployment.${name}: Bailian file mount_path '${normalizedMountPath}' is duplicated after normalization.`, + addr, + ); + } else { + normalizedMountPaths.add(normalizedMountPath); + } + } + } + } + if (providerName !== "qoder") { for (const [name, env] of Object.entries(config.environments ?? {})) { // External references are never sent to the provider API, so a @@ -451,7 +528,11 @@ export function collectProviderCapabilities( if (config.deployments && caps.deployment.tier === "emulated") { for (const [name, dep] of Object.entries(config.deployments)) { if (dep.provider && dep.provider !== providerName) continue; - const addr: ResourceAddress = { type: "deployment", name, provider: providerName }; + const addr: ResourceAddress = { + type: "deployment", + name, + provider: providerName, + }; if (dep.schedule) { diagnostics.warning( diff --git a/packages/sdk/src/internal/executor/executor.ts b/packages/sdk/src/internal/executor/executor.ts index 208c76a..9794754 100644 --- a/packages/sdk/src/internal/executor/executor.ts +++ b/packages/sdk/src/internal/executor/executor.ts @@ -5,6 +5,7 @@ import { getResourceDeclaration } from "../planner/declaration.ts"; import { computeReplacementFingerprint, computeResourceHash } from "../planner/hasher.ts"; import { buildReadinessBaseline } from "../planner/plan-semantics.ts"; import { ApiError, ConflictError } from "../providers/base-client.ts"; +import { DeploymentCreateConflictError } from "../providers/deployment-conflict.ts"; import { readComparableIfSupported } from "../providers/drift-support.ts"; import type { RemoteResource } from "../providers/interface.ts"; import type { DriftReadAdapter, ResourceCrudAdapter } from "../providers/resource-workflow.ts"; @@ -597,16 +598,41 @@ async function executeActionInner( case "deployment": { const decl = ctx.config.deployments![name]!; const refs = resolveDeploymentRefs(name, ctx.config, address.provider, ctx.state); - if (isUpdate) { - result = await provider.updateDeployment(existingId!, name, decl, refs, ctx.configPath ?? ""); - } else { + const hasLocalFileSources = decl.resources?.some( + (resource) => resource.type === "file" && !resource.file_id && Boolean(resource.source), + ); + const materializeDeployment = async (): Promise => { try { - result = await provider.createDeployment(name, decl, refs, ctx.configPath ?? ""); + return await provider.createDeployment(name, decl, refs, ctx.configPath ?? ""); } catch (err) { - result = await adoptOnConflict(err, address, provider, ctx.onFeedback, { - onExisting: (existing) => provider.updateDeployment(existing.id!, name, decl, refs, ctx.configPath ?? ""), + const preparedFiles = err instanceof DeploymentCreateConflictError ? err.preparedFiles : undefined; + const existing = await adoptOnConflict(err, address, provider, ctx.onFeedback, { + onExisting: (existing) => + provider.updateDeployment(existing.id!, name, decl, refs, ctx.configPath ?? "", preparedFiles), + }); + adopted = true; + return existing; + } + }; + if (isUpdate && existingId) { + result = await provider.updateDeployment(existingId, name, decl, refs, ctx.configPath ?? ""); + } else { + // A deployment with local file sources uploads before its create request. If the + // remote deployment already exists, an optimistic create would upload once, + // conflict, then upload again during adoption. Preflight this side-effecting + // path so the existing deployment is updated with a single set of uploads. + const existing = hasLocalFileSources ? await findExistingByNames(provider, "deployment", [name]) : null; + if (existing) { + result = await provider.updateDeployment(existing.resource.id!, name, decl, refs, ctx.configPath ?? ""); + emitRuntimeFeedback(ctx.onFeedback, { + type: "resource_adopted", + level: "info", + resource: address, + message: `adopt deployment.${name} (${address.provider}) — already existed remotely as "${existing.name}"`, }); adopted = true; + } else { + result = await materializeDeployment(); } } break; diff --git a/packages/sdk/src/internal/planner/hasher.ts b/packages/sdk/src/internal/planner/hasher.ts index c9f107a..e7980aa 100644 --- a/packages/sdk/src/internal/planner/hasher.ts +++ b/packages/sdk/src/internal/planner/hasher.ts @@ -28,9 +28,16 @@ export async function computeResourceHash( } } + if (address.type === "file" && basePath) { + const fileDecl = decl as { source: string }; + const fileHash = computeLocalFileContentHash(fileDecl.source, basePath); + return contentHash({ decl, fileHash }); + } + if (address.type === "deployment") { const refs = resolveDeploymentReferenceIds(decl as DeploymentRefDecl, config, address.provider, state); - if (refs) return contentHash({ decl, refs }); + const sourceHashes = basePath ? computeDeploymentSourceHashes(decl as DeploymentRefDecl, basePath) : undefined; + if (refs || sourceHashes) return contentHash({ decl, refs, sourceHashes }); } if (address.type === "template") { @@ -77,6 +84,7 @@ function resolveChannelReferenceIds( interface DeploymentRefDecl { agent: string; environment?: string; + resources?: Array<{ type: string; file_id?: string; source?: string }>; } interface TemplateRefDecl { @@ -150,6 +158,26 @@ function getDeclaration(address: ResourceAddress, config: ProjectConfig): unknow return getResourceDeclaration(address, config); } +function computeDeploymentSourceHashes(decl: DeploymentRefDecl, basePath: string): Record | undefined { + const sources = [ + ...new Set( + (decl.resources ?? []).flatMap((resource) => + resource.type === "file" && !resource.file_id && resource.source ? [resource.source] : [], + ), + ), + ]; + if (sources.length === 0) return undefined; + + return Object.fromEntries(sources.map((source) => [source, computeLocalFileContentHash(source, basePath)])); +} + +export function computeLocalFileContentHash(source: string, basePath: string): string { + const fullPath = resolve(dirname(basePath), source); + const stat = statSync(fullPath, { throwIfNoEntry: false }); + if (!stat?.isFile()) return ""; + return contentHash(readFileSync(fullPath).toString("base64")); +} + export function computeSkillContentHash(source: string, basePath: string): string { const fullPath = resolve(dirname(basePath), source); const stat = statSync(fullPath, { throwIfNoEntry: false }); diff --git a/packages/sdk/src/internal/planner/planner.ts b/packages/sdk/src/internal/planner/planner.ts index 4fa4e3d..999b972 100644 --- a/packages/sdk/src/internal/planner/planner.ts +++ b/packages/sdk/src/internal/planner/planner.ts @@ -5,6 +5,7 @@ import { } from "../core/validate-config.ts"; import { DiagnosticCollector } from "../diagnostics/diagnostics.ts"; import { buildDependencyGraph, type DependencyGraph, topologicalSort } from "../graph/dependency.ts"; +import { getProvider } from "../providers/registry.ts"; import type { ProjectConfig } from "../types/config.ts"; import type { ExecutionPlan, PlannedAction } from "../types/plan.ts"; import type { ResourceAddress, StateFile } from "../types/state.ts"; @@ -53,6 +54,10 @@ export async function buildPlan( const desiredHash = await computeResourceHash(address, config, options.configPath, hashStateLookup); const existing = stateIndex.get(key); const deps = getDependencies(address, graph); + const needsNativeDeploymentMaterialization = + address.type === "deployment" && + existing?.remote_id === null && + getProvider(address.provider)?.capabilities.deployment.tier === "native"; if (address.type === "environment" && existing) { const envDecl = config.environments?.[address.name]; @@ -134,6 +139,17 @@ export async function buildPlan( after: { content_hash: desiredHash }, dependencies: deps, }); + } else if (needsNativeDeploymentMaterialization) { + actions.push({ + action: "update", + address, + driftKind: "none", + readinessImpact: "blocking", + reason: "Materialize legacy state as a native deployment", + before: { content_hash: existing.desired_hash ?? existing.content_hash }, + after: { content_hash: desiredHash }, + dependencies: deps, + }); } else if ( (existing.desired_hash ?? existing.content_hash) !== desiredHash && existing.drift_status === "drifted" diff --git a/packages/sdk/src/internal/providers/bailian/adapter.ts b/packages/sdk/src/internal/providers/bailian/adapter.ts index c0e17da..5d779dd 100644 --- a/packages/sdk/src/internal/providers/bailian/adapter.ts +++ b/packages/sdk/src/internal/providers/bailian/adapter.ts @@ -34,10 +34,13 @@ import type { ProviderSkillInfo } from "../../types/skill-info.ts"; import type { ResourceType } from "../../types/state.ts"; import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts"; import { toRemoteResource } from "../base-client.ts"; +import { preserveDeploymentFilesOnConflict } from "../deployment-conflict.ts"; import type { ComparableRemoteResource, DeploymentContext, DeploymentInfo, + DeploymentListFilter, + DeploymentListResult, DeploymentRunResult, DriftSupport, ExportedResource, @@ -63,9 +66,9 @@ import { fileToDecl, mapAgent, mapCredential, - mapDeploymentToSession, + mapDeployment, + mapDeploymentUpdate, mapEnvironment, - mapInitialEvents, mapSendMessage, mapSession, mapVault, @@ -95,6 +98,7 @@ export class BailianAdapter implements ProviderAdapter { skill: "/skills", vault: "/vaults", file: "/files", + deployment: "/deployments", }; async findResource(type: ResourceType, name: string, id?: string | null): Promise { @@ -549,76 +553,130 @@ export class BailianAdapter implements ProviderAdapter { await this.client.delete(`/vaults/${vaultId}/credentials/${credentialId}`); } - // --- Deployment (emulated) --- + // --- Deployment --- async createDeployment( - _name: string, - _decl: DeploymentDecl, - _refs: ResolvedDeploymentRefs, - _basePath: string, + name: string, + decl: DeploymentDecl, + refs: ResolvedDeploymentRefs, + basePath: string, ): Promise { - return { id: null, type: "deployment" }; + const uploaded = await this.uploadDeploymentFiles(decl, basePath); + const body = mapDeployment(name, decl, refs, this.projectName, uploaded); + try { + const res = (await this.client.post("/deployments", body)) as Record; + return toRemoteResource(res); + } catch (error) { + preserveDeploymentFilesOnConflict(error, uploaded); + } } async updateDeployment( - _id: string, - _name: string, - _decl: DeploymentDecl, - _refs: ResolvedDeploymentRefs, - _basePath: string, + id: string, + name: string, + decl: DeploymentDecl, + refs: ResolvedDeploymentRefs, + basePath: string, + preparedFiles?: ReadonlyMap, ): Promise { - return { id: null, type: "deployment" }; + // Deployments used to be emulated here, so state rows written before native + // support carry `remote_id: null`. An update against one has nothing to PATCH — + // materialize it remotely instead of failing on an empty path segment. + if (!id) return this.createDeployment(name, decl, refs, basePath); + + const current = (await this.client.get(`/deployments/${id}`)) as Record; + const uploaded = preparedFiles ? new Map(preparedFiles) : await this.uploadDeploymentFiles(decl, basePath); + const body = mapDeploymentUpdate( + name, + decl, + refs, + this.projectName, + uploaded, + current.metadata as Record | undefined, + ); + const res = (await this.client.post(`/deployments/${id}`, body)) as Record; + return toRemoteResource(res); } - async deleteDeployment(_id: string): Promise { - // Emulated: no remote object to delete. + async deleteDeployment(id: string): Promise { + await this.client.post(`/deployments/${id}/archive`, {}); } async runDeployment(ctx: DeploymentContext): Promise { - const fileIds: string[] = []; - for (const r of ctx.decl.resources ?? []) { - if (r.type === "file") { - if (r.file_id) { - fileIds.push(r.file_id); - } else if (r.source) { - fileIds.push(await this.uploadSessionFile(r.source, ctx.basePath)); - } - } + if (!ctx.id) { + throw new UserError(`Deployment '${ctx.name}' has no remote id; run \`agents apply\` first.`); } + const res = (await this.client.post(`/deployments/${ctx.id}/run`, {})) as Record; + return { + run_id: res.id as string | undefined, + session_id: (res.session_id as string | null) ?? null, + error: (res.error as { type: string; message: string } | null | undefined) ?? undefined, + }; + } - const body = mapDeploymentToSession(ctx.decl, ctx.refs, fileIds); - const sessionRes = (await this.client.post("/sessions", body)) as Record; - const sessionId = sessionRes.id as string; - - const eventsBody = mapInitialEvents(ctx.decl.initial_events); - const input = (eventsBody as { input: unknown[] }).input; - if (input.length) { - await this.client.post(`/sessions/${sessionId}/events`, eventsBody); + async getDeployment(ctx: DeploymentContext): Promise { + if (!ctx.id) { + throw new UserError(`Deployment '${ctx.name}' has no remote id; run \`agents apply\` first.`); } - - return { session_id: sessionId }; + const res = (await this.client.get(`/deployments/${ctx.id}`)) as Record; + return toDeploymentInfo(res); } - async getDeployment(ctx: DeploymentContext): Promise { - const plan = mapDeploymentToSession(ctx.decl, ctx.refs, []); + async listDeployments(filter?: DeploymentListFilter): Promise { + const params = new URLSearchParams(); + if (filter?.agent_id) params.set("agent_id", filter.agent_id); + if (filter?.status) params.set("status", filter.status); + if (filter?.include_archived) params.set("include_archived", "true"); + if (filter?.limit) params.set("limit", String(filter.limit)); + if (filter?.page) params.set("page", filter.page); + if (filter?.created_at_gte) params.set("created_at[gte]", filter.created_at_gte); + if (filter?.created_at_lte) params.set("created_at[lte]", filter.created_at_lte); + const query = params.toString(); + const res = (await this.client.get(`/deployments${query ? `?${query}` : ""}`)) as Record; + // The list response carries no `has_more`; a non-null `next_page` cursor is the signal. + const nextPage = (res.next_page as string | null | undefined) ?? undefined; return { - id: ctx.id, - status: "emulated (local)", - schedule: ctx.decl.schedule, - attributes: { materialization_plan: plan }, + deployments: ((res.data as Record[] | undefined) ?? []).map(toDeploymentInfo), + has_more: nextPage != null, + next_page: nextPage, }; } - private async uploadSessionFile(source: string, basePath: string): Promise { + async pauseDeployment(ctx: DeploymentContext): Promise { + return this.setDeploymentPaused(ctx, true); + } + + async unpauseDeployment(ctx: DeploymentContext): Promise { + return this.setDeploymentPaused(ctx, false); + } + + private async setDeploymentPaused(ctx: DeploymentContext, paused: boolean): Promise { + if (!ctx.id) throw new UserError(`Deployment '${ctx.name}' has no remote id; run \`agents apply\` first.`); + const action = paused ? "pause" : "unpause"; + const res = (await this.client.post(`/deployments/${ctx.id}/${action}`, {})) as Record; + return toDeploymentInfo(res); + } + + private async uploadDeploymentFiles(decl: DeploymentDecl, basePath: string): Promise> { + const uploaded = new Map(); + for (const resource of decl.resources ?? []) { + if (resource.type === "file" && !resource.file_id && resource.source && !uploaded.has(resource.source)) { + uploaded.set(resource.source, await this.uploadDeploymentFile(resource.source, basePath)); + } + } + return uploaded; + } + + private async uploadDeploymentFile(source: string, basePath: string): Promise { const fullPath = resolve(dirname(basePath), source); const content = readFileSync(fullPath); const formData = new FormData(); formData.append("file", new File([new Uint8Array(content)], basename(fullPath))); const res = (await this.client.postFormData("/files", formData)) as Record; const fileId = res.id as string; - // A fresh upload lands in `checking` while content audit runs; binding it to the - // session (bindSessionFiles) rejects an unavailable source with "源文件不可用". Wait - // for `available` before materializing the session, mirroring the skill path. + // A fresh upload lands in `checking` while content audit runs; attaching an + // unavailable source is rejected downstream. Wait for `available` before the + // deployment references it, mirroring the skill path. await this.waitForFileAvailable(fileId); return fileId; } @@ -751,6 +809,22 @@ function isArchivedAgent(raw: Record): boolean { return typeof raw.archived_at === "string" && raw.archived_at.trim().length > 0; } +function toDeploymentInfo(res: Record): DeploymentInfo { + const schedule = res.schedule as Record | null | undefined; + return { + id: (res.id as string | undefined) ?? null, + status: (res.status as string) ?? "unknown", + paused_reason: (res.paused_reason as DeploymentInfo["paused_reason"] | null | undefined) ?? undefined, + schedule: schedule + ? { + expression: schedule.expression as string, + timezone: schedule.timezone as string | undefined, + } + : undefined, + attributes: res, + }; +} + export function toSessionInfo(res: Record): ProviderSessionInfo { return buildSessionInfo(res, () => []); } diff --git a/packages/sdk/src/internal/providers/bailian/capabilities.ts b/packages/sdk/src/internal/providers/bailian/capabilities.ts index 713bf76..24f00fa 100644 --- a/packages/sdk/src/internal/providers/bailian/capabilities.ts +++ b/packages/sdk/src/internal/providers/bailian/capabilities.ts @@ -17,10 +17,8 @@ export const BAILIAN_CAPABILITIES: ProviderCapabilities = { remediation: "deploy agents independently and orchestrate via MCP", }, deployment: { - tier: "emulated", - reason: "no deployment primitive on Bailian; expanded into a session at run time", - remediation: - "scheduling and outcome rubrics are not enforced server-side — use external cron/CI for always-on or scheduled runs", + tier: "native", + reason: "deployments API with cron schedules, manual runs, pause/unpause and archive", }, session: { tier: "native", reason: "sessions API" }, identity: { tier: "unsupported", reason: "no mapped Identity primitive on Bailian" }, diff --git a/packages/sdk/src/internal/providers/bailian/mapper.ts b/packages/sdk/src/internal/providers/bailian/mapper.ts index e0c302f..6a3c0bd 100644 --- a/packages/sdk/src/internal/providers/bailian/mapper.ts +++ b/packages/sdk/src/internal/providers/bailian/mapper.ts @@ -162,7 +162,10 @@ export function agentToDecl(raw: Record): Record t.type === "builtin_toolkit"); if (toolset) { - const configs = (toolset.configs ?? []) as Array<{ name: string; enabled?: boolean }>; + const configs = (toolset.configs ?? []) as Array<{ + name: string; + enabled?: boolean; + }>; builtinTools = configs.filter((c) => c.enabled !== false).map((c) => c.name); } } @@ -236,7 +239,9 @@ export function mapAgent( // Tools: builtin_toolkit + mcp_toolkit blocks const BAILIAN_BUILTINS = new Set(["bash", "read", "write", "edit", "glob", "grep", "download_file"]); if (decl.tools) { - const toolConfigs = resolveBuiltinTools(decl.tools, { supportedWireNames: BAILIAN_BUILTINS }).map((tool) => ({ + const toolConfigs = resolveBuiltinTools(decl.tools, { + supportedWireNames: BAILIAN_BUILTINS, + }).map((tool) => ({ name: tool.wireName, enabled: true, })); @@ -323,36 +328,101 @@ export function mapSession(bindings: ManagedSessionBindings): unknown { return body; } -export function mapDeploymentToSession(decl: DeploymentDecl, refs: ResolvedDeploymentRefs, fileIds: string[]): unknown { +export function mapDeployment( + name: string, + decl: DeploymentDecl, + refs: ResolvedDeploymentRefs, + projectName?: string, + uploadedFiles?: Map, +): unknown { + // `agent` is always an object here: the API's required shape is `{ id, version? }`, + // unlike the sessions API which takes a bare agent id string. + const agent: Record = { id: refs.agent_id }; + if (refs.agent_version !== undefined) agent.version = refs.agent_version; + const body: Record = { - agent: refs.agent_id, + name, + agent, environment_id: refs.environment_id, + initial_events: mapMessageEvents(decl.initial_events), }; - if (decl.description) body.title = decl.description; + if (decl.description) body.description = decl.description; + if (refs.vault_ids.length) body.vault_ids = refs.vault_ids; - if (fileIds.length) { - const fileResources = (decl.resources ?? []).filter((r) => r.type === "file"); - body.resources = fileIds.map((id, index) => { - const entry: Record = { type: "file", file_id: id }; - const mountPath = fileResources[index]?.mount_path; - if (mountPath) entry.mount_path = mountPath; - return entry; - }); + const resources = mapDeploymentResources(decl, uploadedFiles); + if (resources.length) body.resources = resources; + + if (decl.schedule) { + body.schedule = { + type: "cron", + expression: decl.schedule.expression, + timezone: decl.schedule.timezone, + }; + } + + if (projectName) { + body.metadata = injectMetadata(decl.metadata, projectName, name); + } else if (decl.metadata) { + body.metadata = decl.metadata; } return body; } -export function mapInitialEvents(events: InitialEventDecl[]): unknown { - const input = events - .filter((e) => e.type === "user.message" || e.type === "system.message") - .map((e) => ({ +/** + * Update replaces only the fields present in the payload, so every optional field a + * deployment can drop locally must be sent explicitly to be cleared remotely. + * In particular, `schedule: null` switches a scheduled deployment back to manual. + */ +export function mapDeploymentUpdate( + name: string, + decl: DeploymentDecl, + refs: ResolvedDeploymentRefs, + projectName?: string, + uploadedFiles?: Map, + existingMetadata?: Record, +): unknown { + const body = mapDeployment(name, decl, refs, projectName, uploadedFiles) as Record; + body.description = decl.description ?? ""; + body.vault_ids = refs.vault_ids; + body.resources = mapDeploymentResources(decl, uploadedFiles); + if (!decl.schedule) body.schedule = null; + if (!projectName && !decl.metadata && existingMetadata) body.metadata = existingMetadata; + return body; +} + +function mapDeploymentResources(decl: DeploymentDecl, uploadedFiles?: Map): unknown[] { + // The deployment API accepts file resources only; memory_store and + // github_repository declarations are dropped (validate-config warns). + const resources: unknown[] = []; + for (const resource of decl.resources ?? []) { + if (resource.type !== "file") continue; + const fileId = resource.file_id ?? (resource.source ? uploadedFiles?.get(resource.source) : undefined); + if (!fileId) continue; + const entry: Record = { type: "file", file_id: fileId }; + if (resource.mount_path) entry.mount_path = resolveSandboxMountPath("bailian", resource.mount_path); + resources.push(entry); + } + return resources; +} + +/** + * Bailian carries an initial event as a session message. `user.define_outcome` has no + * documented deployment payload shape, so it is dropped (validate-config warns). + */ +function mapMessageEvents(events: InitialEventDecl[]): unknown[] { + return events + .filter((event) => event.type === "user.message" || event.type === "system.message") + .map((event) => ({ role: "user", type: "message", - content: [{ type: "text", text: (e as { content: string }).content }], + content: [{ type: "text", text: (event as { content: string }).content }], })); - return { input }; +} + +export function mapInitialEvents(events: InitialEventDecl[]): unknown { + return { input: mapMessageEvents(events) }; } export function mapSendMessage(text: string): unknown { diff --git a/packages/sdk/src/internal/providers/claude/adapter.ts b/packages/sdk/src/internal/providers/claude/adapter.ts index 9840195..e160891 100644 --- a/packages/sdk/src/internal/providers/claude/adapter.ts +++ b/packages/sdk/src/internal/providers/claude/adapter.ts @@ -32,6 +32,7 @@ import type { ResourceType } from "../../types/state.ts"; import { extractSkillZipFiles } from "../../utils/normalize-skill-zip.ts"; import { skillNameFromFiles } from "../../utils/skill-manifest.ts"; import { toRemoteResource } from "../base-client.ts"; +import { preserveDeploymentFilesOnConflict } from "../deployment-conflict.ts"; import type { DeploymentContext, DeploymentInfo, @@ -368,8 +369,12 @@ export class ClaudeAdapter implements ProviderAdapter { ): Promise { const uploaded = await this.uploadDeploymentFiles(decl, basePath); const body = mapDeployment(name, decl, refs, this.projectName, uploaded); - const res = (await this.client.post("/deployments", body)) as Record; - return toRemoteResource(res); + try { + const res = (await this.client.post("/deployments", body)) as Record; + return toRemoteResource(res); + } catch (error) { + preserveDeploymentFilesOnConflict(error, uploaded); + } } async updateDeployment( @@ -378,8 +383,9 @@ export class ClaudeAdapter implements ProviderAdapter { decl: DeploymentDecl, refs: ResolvedDeploymentRefs, basePath: string, + preparedFiles?: ReadonlyMap, ): Promise { - const uploaded = await this.uploadDeploymentFiles(decl, basePath); + const uploaded = preparedFiles ? new Map(preparedFiles) : await this.uploadDeploymentFiles(decl, basePath); const current = (await this.client.get(`/deployments/${id}`)) as Record; if (current.schedule && !decl.schedule) { throw new UserError( diff --git a/packages/sdk/src/internal/providers/deployment-conflict.ts b/packages/sdk/src/internal/providers/deployment-conflict.ts new file mode 100644 index 0000000..8b2454d --- /dev/null +++ b/packages/sdk/src/internal/providers/deployment-conflict.ts @@ -0,0 +1,23 @@ +import { ConflictError } from "./base-client.ts"; + +export type PreparedDeploymentFiles = ReadonlyMap; + +/** Carries files uploaded before a deployment create conflict into the adoption update. */ +export class DeploymentCreateConflictError extends ConflictError { + constructor( + conflict: ConflictError, + public readonly preparedFiles: PreparedDeploymentFiles, + ) { + super(conflict.statusCode, conflict.responseBody, "Deployment create"); + this.name = conflict.name; + this.message = conflict.message; + this.stack = conflict.stack; + } +} + +export function preserveDeploymentFilesOnConflict(error: unknown, preparedFiles: PreparedDeploymentFiles): never { + if (error instanceof ConflictError && preparedFiles.size > 0) { + throw new DeploymentCreateConflictError(error, preparedFiles); + } + throw error; +} diff --git a/packages/sdk/src/internal/providers/interface.ts b/packages/sdk/src/internal/providers/interface.ts index 39d9b6b..dad3020 100644 --- a/packages/sdk/src/internal/providers/interface.ts +++ b/packages/sdk/src/internal/providers/interface.ts @@ -238,6 +238,7 @@ export interface ProviderAdapter { decl: DeploymentDecl, refs: ResolvedDeploymentRefs, basePath: string, + preparedFiles?: ReadonlyMap, ): Promise; deleteDeployment(id: string): Promise; runDeployment(ctx: DeploymentContext): Promise; diff --git a/packages/sdk/src/internal/providers/qoder/adapter.ts b/packages/sdk/src/internal/providers/qoder/adapter.ts index ff35da9..2751204 100644 --- a/packages/sdk/src/internal/providers/qoder/adapter.ts +++ b/packages/sdk/src/internal/providers/qoder/adapter.ts @@ -40,6 +40,7 @@ import type { ProviderSkillInfo } from "../../types/skill-info.ts"; import type { ResourceType } from "../../types/state.ts"; import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts"; import { ApiError, toRemoteResource } from "../base-client.ts"; +import { preserveDeploymentFilesOnConflict } from "../deployment-conflict.ts"; import type { ComparableRemoteResource, DeploymentContext, @@ -669,8 +670,12 @@ export class QoderAdapter implements ProviderAdapter { ): Promise { const uploaded = await this.uploadDeploymentFiles(decl, basePath); const body = mapDeployment(name, decl, refs, this.projectName, uploaded); - const res = (await this.client.post("/deployments", body)) as Record; - return toRemoteResource(res); + try { + const res = (await this.client.post("/deployments", body)) as Record; + return toRemoteResource(res); + } catch (error) { + preserveDeploymentFilesOnConflict(error, uploaded); + } } async updateDeployment( @@ -679,8 +684,9 @@ export class QoderAdapter implements ProviderAdapter { decl: DeploymentDecl, refs: ResolvedDeploymentRefs, basePath: string, + preparedFiles?: ReadonlyMap, ): Promise { - const uploaded = await this.uploadDeploymentFiles(decl, basePath); + const uploaded = preparedFiles ? new Map(preparedFiles) : await this.uploadDeploymentFiles(decl, basePath); const current = (await this.client.get(`/deployments/${id}`)) as Record; if (current.schedule && !decl.schedule) { throw new UserError( diff --git a/packages/sdk/src/internal/providers/resource-workflow.ts b/packages/sdk/src/internal/providers/resource-workflow.ts index 8859152..9ca8eed 100644 --- a/packages/sdk/src/internal/providers/resource-workflow.ts +++ b/packages/sdk/src/internal/providers/resource-workflow.ts @@ -94,6 +94,7 @@ export interface ResourceCrudAdapter { decl: DeploymentDecl, refs: ResolvedDeploymentRefs, basePath: string, + preparedFiles?: ReadonlyMap, ): Promise; deleteDeployment(id: string): Promise; diff --git a/packages/sdk/tests/e2e/bailian-adapter.test.ts b/packages/sdk/tests/e2e/bailian-adapter.test.ts index 98f20d2..ee149ef 100644 --- a/packages/sdk/tests/e2e/bailian-adapter.test.ts +++ b/packages/sdk/tests/e2e/bailian-adapter.test.ts @@ -1,6 +1,14 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { BailianAdapter } from "../../src/internal/providers/bailian/adapter.ts"; -import type { ResolvedAgentRefs, ResolvedDeploymentRefs } from "../../src/internal/providers/interface.ts"; +import { DeploymentCreateConflictError } from "../../src/internal/providers/deployment-conflict.ts"; +import type { + DeploymentContext, + ResolvedAgentRefs, + ResolvedDeploymentRefs, +} from "../../src/internal/providers/interface.ts"; import type { AgentDecl, DeploymentDecl, EnvironmentDecl, SkillDecl } from "../../src/internal/types/config.ts"; import type { SessionBindings } from "../../src/internal/types/session.ts"; import type { SkillFile } from "../../src/internal/types/skill-file.ts"; @@ -183,7 +191,7 @@ describe("BailianAdapter e2e", () => { }); test("returns null for unmapped resource types", async () => { - const result = await makeAdapter().findResource("deployment", "any"); + const result = await makeAdapter().findResource("memory_store", "any"); expect(result).toBeNull(); }); @@ -747,158 +755,316 @@ describe("BailianAdapter e2e", () => { }); }); - // ---- Deployment (emulated) ---- + // ---- Deployment (native) ---- - describe("Deployment (emulated)", () => { + describe("Deployment (native)", () => { const deploymentDecl: DeploymentDecl = { agent: "helper", initial_events: [{ type: "user.message", content: "Please analyze the data." }], description: "Analysis task", + schedule: { expression: "0 9 * * *", timezone: "UTC" }, }; const deployRefs: ResolvedDeploymentRefs = { agent_id: "agent_xxx", environment_id: "env_yyy", - vault_ids: [], + vault_ids: ["vlt_1"], memory_store_ids: {}, }; + const DEPLOYMENT_RESPONSE = { + id: "depl_new", + type: "deployment", + name: "deploy-1", + status: "active", + }; + + test("createDeployment posts the deployment body", async () => { + const { calls, restore } = mockFetch([{ status: 200, body: DEPLOYMENT_RESPONSE }]); + cleanup = restore; - test("createDeployment returns null id (emulated)", async () => { const result = await makeAdapter().createDeployment("deploy-1", deploymentDecl, deployRefs, "/fake/path"); - expect(result.id).toBeNull(); + + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe(`${BASE}/deployments`); + expect(calls[0]!.method).toBe("POST"); + const body = calls[0]!.body as Record; + expect(body.name).toBe("deploy-1"); + expect(body.agent).toEqual({ id: "agent_xxx" }); + expect(body.environment_id).toBe("env_yyy"); + expect(body.description).toBe("Analysis task"); + expect(body.vault_ids).toEqual(["vlt_1"]); + expect(body.schedule).toEqual({ type: "cron", expression: "0 9 * * *", timezone: "UTC" }); + expect(body.initial_events).toEqual([ + { role: "user", type: "message", content: [{ type: "text", text: "Please analyze the data." }] }, + ]); + // Project name is stamped onto metadata for resource ownership tracking. + expect((body.metadata as Record)["agents.project"]).toBe("test-project"); + expect(result.id).toBe("depl_new"); expect(result.type).toBe("deployment"); }); - test("updateDeployment returns null id (emulated)", async () => { - const result = await makeAdapter().updateDeployment( - "deploy_xxx", + test("createDeployment pins agent version when resolved", async () => { + const { calls, restore } = mockFetch([{ status: 200, body: DEPLOYMENT_RESPONSE }]); + cleanup = restore; + + await makeAdapter().createDeployment( "deploy-1", deploymentDecl, - deployRefs, + { ...deployRefs, agent_version: 12 }, "/fake/path", ); - expect(result.id).toBeNull(); - expect(result.type).toBe("deployment"); + expect((calls[0]!.body as Record).agent).toEqual({ id: "agent_xxx", version: 12 }); }); - test("deleteDeployment is a no-op", async () => { - const { calls, restore } = mockFetch([]); + test("createDeployment uploads file resources and waits for availability first", async () => { + const dir = mkdtempSync(join(tmpdir(), "bailian-depl-")); + writeFileSync(join(dir, "report.md"), "# template"); + const declWithFile: DeploymentDecl = { + agent: "helper", + initial_events: [{ type: "user.message", content: "analyze" }], + resources: [{ type: "file", source: "./report.md", mount_path: "/mnt/report.md" }], + }; + const { calls, restore } = mockFetch([ + { status: 200, body: { id: "file_up", status: "checking" } }, + { status: 200, body: { id: "file_up", status: "available" } }, + { status: 200, body: { ...DEPLOYMENT_RESPONSE, id: "depl_files" } }, + ]); cleanup = restore; - await makeAdapter().deleteDeployment("deploy_xxx"); - expect(calls).toHaveLength(0); + const result = await makeAdapter().createDeployment( + "deploy-1", + declWithFile, + deployRefs, + join(dir, "agents.yaml"), + ); + expect(result.id).toBe("depl_files"); + + expect(calls[0]!.url).toBe(`${BASE}/files`); + expect(calls[0]!.method).toBe("POST"); + expect(calls[1]!.url).toBe(`${BASE}/files/file_up`); + expect(calls[2]!.url).toBe(`${BASE}/deployments`); + const body = calls[2]!.body as Record; + expect((body.resources as any[])[0]).toEqual({ + type: "file", + file_id: "file_up", + mount_path: "/mnt/report.md", + }); }); - test("runDeployment creates session and sends events", async () => { + test("createDeployment preserves uploaded files when the deployment conflicts", async () => { + const dir = mkdtempSync(join(tmpdir(), "bailian-depl-conflict-")); + writeFileSync(join(dir, "report.md"), "# template"); + const declWithFile: DeploymentDecl = { + agent: "helper", + initial_events: [{ type: "user.message", content: "analyze" }], + resources: [{ type: "file", source: "./report.md", mount_path: "/mnt/report.md" }], + }; const { calls, restore } = mockFetch([ - { status: 200, body: { ...SESSION_RESPONSE, id: "sesn_new" } }, - { - status: 200, - body: { - data: [ - { - object: "message", - status: "completed", - id: "msg_001", - type: "message", - role: "user", - }, - ], - }, - }, + { status: 200, body: { id: "file_up", status: "checking" } }, + { status: 200, body: { id: "file_up", status: "available" } }, + { status: 409, body: { message: "deployment already exists" } }, ]); cleanup = restore; - const result = await makeAdapter().runDeployment({ - id: null, - name: "analysis", - decl: deploymentDecl, - refs: deployRefs, - basePath: "/fake/project.yaml", - }); + const error = await makeAdapter() + .createDeployment("deploy-1", declWithFile, deployRefs, join(dir, "agents.yaml")) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(DeploymentCreateConflictError); + expect((error as DeploymentCreateConflictError).preparedFiles.get("./report.md")).toBe("file_up"); + expect(calls.filter((call) => call.url === `${BASE}/files`)).toHaveLength(1); + }); + + test("updateDeployment reuses files preserved by a conflicting create", async () => { + const declWithFile: DeploymentDecl = { + agent: "helper", + initial_events: [{ type: "user.message", content: "analyze" }], + resources: [{ type: "file", source: "./report.md", mount_path: "/mnt/report.md" }], + }; + const { calls, restore } = mockFetch([ + { status: 200, body: { id: "depl_xxx", metadata: {} } }, + { status: 200, body: { ...DEPLOYMENT_RESPONSE, id: "depl_xxx" } }, + ]); + cleanup = restore; + + await makeAdapter().updateDeployment( + "depl_xxx", + "deploy-1", + declWithFile, + deployRefs, + "/unused/agents.yaml", + new Map([["./report.md", "file_up"]]), + ); expect(calls).toHaveLength(2); + expect(calls.some((call) => call.url === `${BASE}/files`)).toBe(false); + expect((calls[1]!.body as Record).resources).toEqual([ + { type: "file", file_id: "file_up", mount_path: "/mnt/report.md" }, + ]); + }); - // Call 1: create session - expect(calls[0]!.url).toBe(`${BASE}/sessions`); - expect(calls[0]!.method).toBe("POST"); - const sessionBody = calls[0]!.body as Record; - expect(sessionBody.agent).toBe("agent_xxx"); - expect(sessionBody.environment_id).toBe("env_yyy"); - expect(sessionBody.title).toBe("Analysis task"); + test("updateDeployment reads current then posts to the id", async () => { + const { calls, restore } = mockFetch([ + { status: 200, body: { id: "depl_xxx", schedule: { type: "cron", expression: "0 9 * * *" } } }, + { status: 200, body: { ...DEPLOYMENT_RESPONSE, id: "depl_xxx" } }, + ]); + cleanup = restore; - // Call 2: send initial events - expect(calls[1]!.url).toBe(`${BASE}/sessions/sesn_new/events`); + const result = await makeAdapter().updateDeployment( + "depl_xxx", + "deploy-1", + deploymentDecl, + deployRefs, + "/fake/path", + ); + + expect(calls[0]!.url).toBe(`${BASE}/deployments/depl_xxx`); + expect(calls[0]!.method).toBe("GET"); + expect(calls[1]!.url).toBe(`${BASE}/deployments/depl_xxx`); expect(calls[1]!.method).toBe("POST"); - const eventsBody = calls[1]!.body as { input: any[] }; - expect(eventsBody.input).toHaveLength(1); - expect(eventsBody.input[0].role).toBe("user"); - expect(eventsBody.input[0].type).toBe("message"); - expect(eventsBody.input[0].content).toEqual([{ type: "text", text: "Please analyze the data." }]); + expect(result.id).toBe("depl_xxx"); + }); + + test("updateDeployment switches a scheduled deployment to manual", async () => { + const { calls, restore } = mockFetch([ + { status: 200, body: { id: "depl_xxx", schedule: { type: "cron", expression: "0 9 * * *" } } }, + { status: 200, body: { ...DEPLOYMENT_RESPONSE, id: "depl_xxx", schedule: null } }, + ]); + cleanup = restore; + + const manualDecl: DeploymentDecl = { agent: "helper", initial_events: [] }; + const result = await makeAdapter().updateDeployment("depl_xxx", "deploy-1", manualDecl, deployRefs, "/fake/path"); - expect(result.session_id).toBe("sesn_new"); + expect(calls).toHaveLength(2); + expect(calls[1]!.url).toBe(`${BASE}/deployments/depl_xxx`); + expect(calls[1]!.method).toBe("POST"); + expect((calls[1]!.body as Record).schedule).toBeNull(); + expect(result.id).toBe("depl_xxx"); }); - test("runDeployment skips events POST when no supported events", async () => { - const declNoEvents: DeploymentDecl = { - agent: "helper", - initial_events: [{ type: "user.define_outcome", description: "test" }], - }; - const { calls, restore } = mockFetch([{ status: 200, body: SESSION_RESPONSE }]); + test("updateDeployment materializes a never-applied (null-id) deployment via create", async () => { + const { calls, restore } = mockFetch([{ status: 200, body: DEPLOYMENT_RESPONSE }]); cleanup = restore; - await makeAdapter().runDeployment({ - id: null, + const result = await makeAdapter().updateDeployment("", "deploy-1", deploymentDecl, deployRefs, "/fake/path"); + + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe(`${BASE}/deployments`); + expect(calls[0]!.method).toBe("POST"); + expect(result.id).toBe("depl_new"); + }); + + test("deleteDeployment archives the deployment", async () => { + const { calls, restore } = mockFetch([ + { status: 200, body: { id: "depl_xxx", archived_at: "2026-07-28T03:00:00Z" } }, + ]); + cleanup = restore; + + await makeAdapter().deleteDeployment("depl_xxx"); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe(`${BASE}/deployments/depl_xxx/archive`); + expect(calls[0]!.method).toBe("POST"); + }); + + test("runDeployment triggers a server-side run", async () => { + const { calls, restore } = mockFetch([ + { status: 200, body: { id: "drun_1", type: "deployment_run", session_id: null, status: "running" } }, + ]); + cleanup = restore; + + const result = await makeAdapter().runDeployment({ + id: "depl_xxx", name: "analysis", - decl: declNoEvents, + decl: deploymentDecl, refs: deployRefs, basePath: "/fake/project.yaml", }); expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe(`${BASE}/deployments/depl_xxx/run`); + expect(calls[0]!.method).toBe("POST"); + expect(result.run_id).toBe("drun_1"); + expect(result.session_id).toBeNull(); }); - test("runDeployment uploads file resources before creating session", async () => { - const declWithFile: DeploymentDecl = { - agent: "helper", - initial_events: [{ type: "user.message", content: "analyze" }], - resources: [{ type: "file", file_id: "file_existing" }], - }; + test("runDeployment rejects a deployment with no remote id", async () => { + expect( + makeAdapter().runDeployment({ + id: null, + name: "analysis", + decl: deploymentDecl, + refs: deployRefs, + basePath: "/fake/project.yaml", + }), + ).rejects.toThrow(/no remote id/); + }); + + test("getDeployment reads the remote deployment", async () => { const { calls, restore } = mockFetch([ - { status: 200, body: { ...SESSION_RESPONSE, id: "sesn_with_files" } }, - { status: 200, body: { data: [] } }, + { + status: 200, + body: { + id: "depl_xxx", + status: "active", + schedule: { type: "cron", expression: "0 9 * * *", timezone: "UTC" }, + }, + }, ]); cleanup = restore; - const result = await makeAdapter().runDeployment({ - id: null, + const result = await makeAdapter().getDeployment({ + id: "depl_xxx", name: "analysis", - decl: declWithFile, + decl: deploymentDecl, refs: deployRefs, basePath: "/fake/project.yaml", }); - const sessionBody = calls[0]!.body as Record; - expect((sessionBody.resources as any[])[0]).toEqual({ - type: "file", - file_id: "file_existing", - }); - expect(result.session_id).toBe("sesn_with_files"); + expect(calls[0]!.url).toBe(`${BASE}/deployments/depl_xxx`); + expect(calls[0]!.method).toBe("GET"); + expect(result.id).toBe("depl_xxx"); + expect(result.status).toBe("active"); + expect(result.schedule).toEqual({ expression: "0 9 * * *", timezone: "UTC" }); }); - test("getDeployment returns emulated info", async () => { - const result = await makeAdapter().getDeployment({ - id: "deploy_local", + test("listDeployments forwards filters and derives has_more from next_page", async () => { + const { calls, restore } = mockFetch([ + { status: 200, body: { data: [{ id: "depl_1", status: "active" }], next_page: "cursor_2" } }, + ]); + cleanup = restore; + + const result = await makeAdapter().listDeployments({ agent_id: "agent_xxx", status: "active", limit: 50 }); + + const url = new URL(calls[0]!.url); + expect(url.pathname).toBe("/api/v1/agentstudio/deployments"); + expect(url.searchParams.get("agent_id")).toBe("agent_xxx"); + expect(url.searchParams.get("status")).toBe("active"); + expect(url.searchParams.get("limit")).toBe("50"); + expect(result.deployments).toHaveLength(1); + expect(result.has_more).toBe(true); + expect(result.next_page).toBe("cursor_2"); + }); + + test("pauseDeployment and unpauseDeployment hit their endpoints", async () => { + const { calls, restore } = mockFetch([ + { status: 200, body: { id: "depl_xxx", status: "paused" } }, + { status: 200, body: { id: "depl_xxx", status: "active" } }, + ]); + cleanup = restore; + + const ctx: DeploymentContext = { + id: "depl_xxx", name: "analysis", decl: deploymentDecl, refs: deployRefs, basePath: "/fake/project.yaml", - }); + }; + const paused = await makeAdapter().pauseDeployment(ctx); + const active = await makeAdapter().unpauseDeployment(ctx); - expect(result.id).toBe("deploy_local"); - expect(result.status).toBe("emulated (local)"); - const plan = result.attributes!.materialization_plan as Record; - expect(plan.agent).toBe("agent_xxx"); - expect(plan.environment_id).toBe("env_yyy"); + expect(calls[0]!.url).toBe(`${BASE}/deployments/depl_xxx/pause`); + expect(calls[1]!.url).toBe(`${BASE}/deployments/depl_xxx/unpause`); + expect(paused.status).toBe("paused"); + expect(active.status).toBe("active"); }); }); diff --git a/packages/sdk/tests/e2e/bailian-smoke.ts b/packages/sdk/tests/e2e/bailian-smoke.ts index da99ccb..fd08308 100644 --- a/packages/sdk/tests/e2e/bailian-smoke.ts +++ b/packages/sdk/tests/e2e/bailian-smoke.ts @@ -13,7 +13,10 @@ if (!API_KEY || !BASE_URL || !WORKSPACE_ID) { const adapter = new BailianAdapter(API_KEY, WORKSPACE_ID, BASE_URL, "agents-smoke-test"); -const created: { envId?: string; agentId?: string; sessionIds: string[] } = { sessionIds: [] }; +const created: { envId?: string; agentId?: string; sessionIds: string[]; deploymentIds: string[] } = { + sessionIds: [], + deploymentIds: [], +}; async function waitForIdle(id: string, timeoutMs = 30_000): Promise { const start = Date.now(); @@ -111,8 +114,8 @@ async function run() { const listed = await adapter.listSessions({ agent_id: agent.id!, limit: 5 }); console.log(` ✅ listSessions: found ${listed.sessions.length} session(s), has_more=${listed.has_more}\n`); - // 9. emulated deployment - console.log("9. Deployment (emulated)..."); + // 9. native deployment + console.log("9. Deployment (native)..."); const deployRefs: ResolvedDeploymentRefs = { agent_id: agent.id!, environment_id: env.id!, @@ -126,17 +129,28 @@ async function run() { }; const deployResult = await adapter.createDeployment("smoke-deploy", deployDecl, deployRefs, "/fake"); - console.log(` ✅ createDeployment: id=${deployResult.id} (emulated, expected null)`); + console.log(` ✅ createDeployment: id=${deployResult.id}`); + if (deployResult.id) created.deploymentIds.push(deployResult.id); + + const deployInfo = await adapter.getDeployment({ + id: deployResult.id, + name: "smoke-deploy", + decl: deployDecl, + refs: deployRefs, + basePath: "/fake/project.yaml", + }); + console.log(` ✅ getDeployment: status=${deployInfo.status}`); const runResult = await adapter.runDeployment({ - id: null, + id: deployResult.id, name: "smoke-deploy", decl: deployDecl, refs: deployRefs, basePath: "/fake/project.yaml", }); - console.log(` ✅ runDeployment: session_id=${runResult.session_id}\n`); + console.log(` ✅ runDeployment: run_id=${runResult.run_id}, session_id=${runResult.session_id}\n`); + // The run creates its Session asynchronously, so session_id may still be null here. if (runResult.session_id) { created.sessionIds.push(runResult.session_id); } @@ -144,6 +158,11 @@ async function run() { // 10. cleanup console.log("\n10. Cleanup..."); + for (const deploymentId of created.deploymentIds) { + await adapter.deleteDeployment(deploymentId); + console.log(` 🧹 archived deployment: ${deploymentId}`); + } + for (const sid of created.sessionIds) { await safeDeleteSession(sid); console.log(` 🧹 deleted session: ${sid}`); @@ -167,6 +186,11 @@ run().catch(async (err) => { // Best-effort cleanup console.log("\n🧹 Attempting cleanup after failure..."); + for (const deploymentId of created.deploymentIds) { + try { + await adapter.deleteDeployment(deploymentId); + } catch {} + } for (const sid of created.sessionIds) { try { await safeDeleteSession(sid); diff --git a/packages/sdk/tests/e2e/deployment-migration.test.ts b/packages/sdk/tests/e2e/deployment-migration.test.ts new file mode 100644 index 0000000..38f53b0 --- /dev/null +++ b/packages/sdk/tests/e2e/deployment-migration.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ProjectRuntimeContext } from "../../src/internal/core/project-runtime.ts"; +import { executePlannedProject, planProjectContext } from "../../src/internal/core/resource-runtime.ts"; +import { computeResourceHash } from "../../src/internal/planner/hasher.ts"; +import { BailianAdapter } from "../../src/internal/providers/bailian/adapter.ts"; +import "../../src/internal/providers/ark/index.ts"; +import "../../src/internal/providers/bailian/index.ts"; +import { StateManager } from "../../src/internal/state/state-manager.ts"; +import type { ResolvedProjectConfig } from "../../src/internal/types/config.ts"; +import type { ResourceAddress } from "../../src/internal/types/state.ts"; + +function statePath(provider: string): string { + return join(tmpdir(), `deployment-migration-${provider}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); +} + +function configFor(provider: "ark" | "bailian"): ResolvedProjectConfig { + return { + version: "1", + providers: + provider === "bailian" ? { bailian: { api_key: "test", workspace_id: "ws" } } : { ark: { api_key: "test" } }, + defaults: { provider }, + environments: { + dev: { config: { type: "cloud" } }, + }, + agents: { + helper: { + model: "qwen3", + instructions: "Help with the task.", + environment: "dev", + }, + }, + deployments: { + daily: { + agent: "helper", + initial_events: [{ type: "user.message", content: "Run the task." }], + }, + }, + _resolved: true, + }; +} + +async function legacyState(config: ResolvedProjectConfig, provider: "ark" | "bailian"): Promise { + const state = StateManager.initialize(statePath(provider)); + const environment: ResourceAddress = { type: "environment", name: "dev", provider }; + state.setResource({ + address: environment, + remote_id: "env_1", + content_hash: await computeResourceHash(environment, config, "/tmp/agents.yaml", state), + }); + + const agent: ResourceAddress = { type: "agent", name: "helper", provider }; + state.setResource({ + address: agent, + remote_id: "agent_1", + content_hash: await computeResourceHash(agent, config, "/tmp/agents.yaml", state), + }); + + const deployment: ResourceAddress = { type: "deployment", name: "daily", provider }; + state.setResource({ + address: deployment, + remote_id: null, + content_hash: await computeResourceHash(deployment, config, "/tmp/agents.yaml", state), + }); + return state; +} + +describe("legacy emulated deployment migration", () => { + test("materializes a native Bailian deployment and converges on the next plan", async () => { + const config = configFor("bailian"); + const state = await legacyState(config, "bailian"); + const calls: Array<{ path: string; body: unknown }> = []; + let updateCalls = 0; + const adapter = new BailianAdapter("test", "ws", undefined, "migration-test"); + (adapter as unknown as { client: { post: (path: string, body: unknown) => Promise } }).client = { + async post(path, body) { + calls.push({ path, body }); + return { id: "depl_native_1", type: "deployment" }; + }, + }; + adapter.updateDeployment = async () => { + updateCalls += 1; + throw new Error("A deployment without a remote id must be created, not updated"); + }; + const runtime: ProjectRuntimeContext = { + configPath: "/tmp/agents.yaml", + projectName: "migration-test", + config, + state, + providers: new Map([["bailian", adapter]]), + }; + + const firstPlan = await planProjectContext(runtime, { refresh: false }); + const migration = firstPlan.plan.actions.find((action) => action.address.type === "deployment"); + expect(migration?.action).toBe("update"); + expect(migration?.reason).toContain("native deployment"); + + const execution = await executePlannedProject(firstPlan); + expect(execution.partial).toBe(false); + expect(calls).toHaveLength(1); + expect(calls[0]?.path).toBe("/deployments"); + expect(updateCalls).toBe(0); + expect(state.getResource({ type: "deployment", name: "daily", provider: "bailian" })?.remote_id).toBe( + "depl_native_1", + ); + + const secondPlan = await planProjectContext(runtime, { refresh: false }); + const converged = secondPlan.plan.actions.find((action) => action.address.type === "deployment"); + expect(converged?.action).toBe("no-op"); + }); + + test("keeps an emulated Ark deployment with a null remote id as no-op", async () => { + const config = configFor("ark"); + const state = await legacyState(config, "ark"); + const runtime: ProjectRuntimeContext = { + configPath: "/tmp/agents.yaml", + projectName: "migration-test", + config, + state, + providers: new Map(), + }; + + const plan = await planProjectContext(runtime, { refresh: false }); + const deployment = plan.plan.actions.find((action) => action.address.type === "deployment"); + expect(deployment?.action).toBe("no-op"); + }); +}); diff --git a/packages/sdk/tests/unit/bailian-deployment-file-wait.test.ts b/packages/sdk/tests/unit/bailian-deployment-file-wait.test.ts index 79799b6..1966f9c 100644 --- a/packages/sdk/tests/unit/bailian-deployment-file-wait.test.ts +++ b/packages/sdk/tests/unit/bailian-deployment-file-wait.test.ts @@ -3,20 +3,77 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BailianAdapter } from "../../src/internal/providers/bailian/adapter.ts"; -import type { DeploymentContext } from "../../src/internal/providers/interface.ts"; +import type { ResolvedDeploymentRefs } from "../../src/internal/providers/interface.ts"; +import type { DeploymentDecl } from "../../src/internal/types/config.ts"; -// A freshly uploaded file lands in `checking`; binding it to a session (bindSessionFiles) -// rejects an unavailable source with "源文件不可用". The emulated deployment run must poll the -// Files API to `available` before POST /sessions, just like the skill-upload path. This locks -// in that ordering so the bind never races the content audit again. +// A freshly uploaded file lands in `checking`; attaching it to a deployment before the +// content audit finishes is rejected downstream. createDeployment must poll the Files API +// to `available` before POST /deployments, just like the skill-upload path. This locks in +// that ordering so the deployment never references a file the audit hasn't cleared. const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); -describe("BailianAdapter emulated deployment file upload", () => { - test("waits for the uploaded file to become available before creating the session", async () => { +describe("BailianAdapter deployment file upload", () => { + test("reads the current deployment before uploading replacement files", async () => { + const dir = mkdtempSync(join(tmpdir(), "agents-dep-update-")); + writeFileSync(join(dir, "report-template.md"), "# template"); + const calls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = new URL(String(input)).pathname; + const method = init?.method ?? "GET"; + calls.push(`${method} ${path}`); + if (method === "GET" && path.endsWith("/deployments/depl_1")) { + return Response.json({ + id: "depl_1", + schedule: { type: "cron", expression: "0 9 * * *" }, + }); + } + if (method === "POST" && path.endsWith("/files")) { + return Response.json({ id: "file_x", status: "checking" }); + } + if (method === "GET" && path.endsWith("/files/file_x")) { + return Response.json({ id: "file_x", status: "available" }); + } + if (method === "POST" && path.endsWith("/deployments/depl_1")) { + return Response.json({ id: "depl_1", type: "deployment", schedule: null }); + } + throw new Error(`unexpected ${method} ${path}`); + }) as typeof fetch; + + const adapter = new BailianAdapter("sk-test", "ws-test", "https://bailian.test/api/v1/agentstudio"); + const decl: DeploymentDecl = { + agent: "reporter", + initial_events: [{ type: "user.message", content: "go" }], + resources: [ + { + type: "file", + source: "report-template.md", + mount_path: "/mnt/report-template.md", + }, + ], + }; + const refs: ResolvedDeploymentRefs = { + agent_id: "agent_1", + environment_id: "env_1", + vault_ids: [], + memory_store_ids: {}, + }; + + const result = await adapter.updateDeployment("depl_1", "daily-report", decl, refs, join(dir, "agents.yaml")); + expect(result.id).toBe("depl_1"); + expect(calls).toEqual([ + "GET /api/v1/agentstudio/deployments/depl_1", + "POST /api/v1/agentstudio/files", + "GET /api/v1/agentstudio/files/file_x", + "POST /api/v1/agentstudio/deployments/depl_1", + ]); + }); + + test("waits for the uploaded file to become available before creating the deployment", async () => { const dir = mkdtempSync(join(tmpdir(), "agents-dep-")); writeFileSync(join(dir, "report-template.md"), "# template"); @@ -30,43 +87,54 @@ describe("BailianAdapter emulated deployment file upload", () => { calls.push(`${method} ${path}`); if (method === "POST" && path.endsWith("/files")) { - return Response.json({ id: "file_x", filename: "report-template.md", status: "checking" }); + return Response.json({ + id: "file_x", + filename: "report-template.md", + status: "checking", + }); } if (method === "GET" && path.endsWith("/files/file_x")) { // Flip to available on the first poll so the wait is short but real. const status = fileStatus; fileStatus = "available"; - return Response.json({ id: "file_x", filename: "report-template.md", status }); + return Response.json({ + id: "file_x", + filename: "report-template.md", + status, + }); } - if (method === "POST" && path.endsWith("/sessions")) { - return Response.json({ id: "sesn_1" }); - } - if (method === "POST" && path.endsWith("/sessions/sesn_1/events")) { - return Response.json({ id: "evt_1" }); + if (method === "POST" && path.endsWith("/deployments")) { + return Response.json({ id: "depl_1", type: "deployment" }); } throw new Error(`unexpected ${method} ${path}`); }) as typeof fetch; const adapter = new BailianAdapter("sk-test", "ws-test", "https://bailian.test/api/v1/agentstudio"); - const ctx: DeploymentContext = { - id: null, - name: "daily-report", - decl: { - agent: "reporter", - initial_events: [{ type: "user.message", content: "go" }], - resources: [{ type: "file", source: "report-template.md", mount_path: "/data/report-template.md" }], - }, - refs: { agent_id: "agent_1", environment_id: "env_1", vault_ids: [], memory_store_ids: {} }, - basePath: join(dir, "agents.yaml"), + const decl: DeploymentDecl = { + agent: "reporter", + initial_events: [{ type: "user.message", content: "go" }], + resources: [ + { + type: "file", + source: "report-template.md", + mount_path: "/mnt/report-template.md", + }, + ], + }; + const refs: ResolvedDeploymentRefs = { + agent_id: "agent_1", + environment_id: "env_1", + vault_ids: [], + memory_store_ids: {}, }; - const res = await adapter.runDeployment(ctx); - expect(res.session_id).toBe("sesn_1"); + const res = await adapter.createDeployment("daily-report", decl, refs, join(dir, "agents.yaml")); + expect(res.id).toBe("depl_1"); const pollIdx = calls.indexOf("GET /api/v1/agentstudio/files/file_x"); - const sessionIdx = calls.indexOf("POST /api/v1/agentstudio/sessions"); + const deployIdx = calls.indexOf("POST /api/v1/agentstudio/deployments"); expect(pollIdx).toBeGreaterThanOrEqual(0); - expect(sessionIdx).toBeGreaterThanOrEqual(0); - expect(pollIdx).toBeLessThan(sessionIdx); + expect(deployIdx).toBeGreaterThanOrEqual(0); + expect(pollIdx).toBeLessThan(deployIdx); }); }); diff --git a/packages/sdk/tests/unit/executor-conflict-adopt.test.ts b/packages/sdk/tests/unit/executor-conflict-adopt.test.ts index c72d7e0..dd730ec 100644 --- a/packages/sdk/tests/unit/executor-conflict-adopt.test.ts +++ b/packages/sdk/tests/unit/executor-conflict-adopt.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ExecContext } from "../../src/internal/executor/context.ts"; import { executePlan } from "../../src/internal/executor/executor.ts"; import { ApiError, ConflictError } from "../../src/internal/providers/base-client.ts"; +import { DeploymentCreateConflictError } from "../../src/internal/providers/deployment-conflict.ts"; import type { ProviderAdapter, RemoteResource } from "../../src/internal/providers/interface.ts"; import type { IStateManager } from "../../src/internal/state/state-manager.ts"; import { StateManager } from "../../src/internal/state/state-manager.ts"; @@ -219,6 +221,101 @@ describe("executor conflict-adopt", () => { expect(calls).toEqual([]); }); + test("deployment conflict adoption reuses files uploaded by create", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "exec-deployment-conflict-")); + writeFileSync(join(projectDir, "report-template.md"), "# template"); + const deploymentConfig: ProjectConfig = { + version: "1", + providers: { bailian: { api_key: "test", workspace_id: "ws" } }, + defaults: { provider: "bailian" }, + environments: { dev: { config: { type: "cloud" } } }, + agents: { + reporter: { + model: "qwen-plus", + instructions: "Generate reports", + environment: "dev", + }, + }, + deployments: { + "daily-report": { + agent: "reporter", + initial_events: [{ type: "user.message", content: "go" }], + resources: [{ type: "file", source: "report-template.md" }], + }, + }, + }; + const calls: string[] = []; + let findCalls = 0; + const provider = { + name: "bailian", + findResource: async (type: string, name: string) => { + calls.push(`find:${type}.${name}`); + findCalls += 1; + return findCalls === 1 ? null : { id: "deployment_existing", type: "deployment" }; + }, + createDeployment: async () => { + calls.push("create-and-upload:file_uploaded_once"); + throw new DeploymentCreateConflictError( + new ConflictError(409, "exists", "Bailian API"), + new Map([["report-template.md", "file_uploaded_once"]]), + ); + }, + updateDeployment: async ( + id: string, + _name: unknown, + _decl: unknown, + _refs: unknown, + _basePath: unknown, + preparedFiles?: ReadonlyMap, + ) => { + calls.push(`update-and-reuse:${id}:${preparedFiles?.get("report-template.md") ?? "missing"}`); + return { id, type: "deployment" }; + }, + } as unknown as ProviderAdapter; + const state = StateManager.initialize(tmpPath()); + state.setResource({ + address: { type: "environment", name: "dev", provider: "bailian" }, + remote_id: "environment_existing", + content_hash: "h", + }); + state.setResource({ + address: { type: "agent", name: "reporter", provider: "bailian" }, + remote_id: "agent_existing", + content_hash: "h", + }); + const plan: ExecutionPlan = { + actions: [ + { + action: "create", + address: { type: "deployment", name: "daily-report", provider: "bailian" }, + reason: "missing", + after: { content_hash: "h" }, + dependencies: [], + }, + ], + diagnostics: [], + }; + const ctx: ExecContext = { + config: deploymentConfig, + configPath: join(projectDir, "agents.yaml"), + providers: new Map([["bailian", provider]]), + state, + }; + + const result = await executePlan(plan, ctx); + + expect(result.partial).toBe(false); + expect(calls).toEqual([ + "find:deployment.daily-report", + "create-and-upload:file_uploaded_once", + "find:deployment.daily-report", + "update-and-reuse:deployment_existing:file_uploaded_once", + ]); + expect(state.getResource({ type: "deployment", name: "daily-report", provider: "bailian" })?.remote_id).toBe( + "deployment_existing", + ); + }); + test("skill ConflictError → multi searchNames → adopt as-is without rebuild", async () => { const skillConfig: ProjectConfig = { version: "1", diff --git a/packages/sdk/tests/unit/hasher-local-files.test.ts b/packages/sdk/tests/unit/hasher-local-files.test.ts new file mode 100644 index 0000000..9eee1c1 --- /dev/null +++ b/packages/sdk/tests/unit/hasher-local-files.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { computeResourceHash } from "../../src/internal/planner/hasher.ts"; +import { buildPlan } from "../../src/internal/planner/planner.ts"; +import type { ProjectConfig } from "../../src/internal/types/config.ts"; +import type { ResourceAddress, StateFile } from "../../src/internal/types/state.ts"; +import { addressKey } from "../../src/internal/types/state.ts"; +import "../../src/internal/providers/qoder/index.ts"; + +function stateLookup(state: StateFile) { + return { + getResource(address: ResourceAddress) { + return state.resources.find((resource) => addressKey(resource.address) === addressKey(address)); + }, + }; +} + +describe("local file content hashing", () => { + test("plans an update when a declared File's content changes at the same source path", async () => { + const directory = mkdtempSync(join(tmpdir(), "agents-file-hash-")); + try { + const configPath = join(directory, "agents.yaml"); + const sourcePath = join(directory, "payload.bin"); + writeFileSync(sourcePath, new Uint8Array([0, 1, 2, 3])); + const config: ProjectConfig = { + version: "1", + providers: { qoder: {} }, + defaults: { provider: "qoder" }, + files: { payload: { source: "payload.bin" } }, + }; + const address: ResourceAddress = { type: "file", name: "payload", provider: "qoder" }; + const originalHash = await computeResourceHash(address, config, configPath); + const state: StateFile = { + resources: [{ address, remote_id: "file_1", content_hash: originalHash }], + }; + + writeFileSync(sourcePath, new Uint8Array([0, 1, 2, 4])); + + const changedHash = await computeResourceHash(address, config, configPath); + expect(changedHash).not.toBe(originalHash); + const plan = await buildPlan(config, state, { configPath }); + expect(plan.actions.find((action) => addressKey(action.address) === addressKey(address))?.action).toBe("update"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + test("plans an update when a Deployment source file changes at the same path", async () => { + const directory = mkdtempSync(join(tmpdir(), "agents-deployment-hash-")); + try { + const configPath = join(directory, "agents.yaml"); + const sourcePath = join(directory, "report.md"); + writeFileSync(sourcePath, "first report\n"); + const config: ProjectConfig = { + version: "1", + providers: { qoder: {} }, + defaults: { provider: "qoder" }, + environments: { dev: { config: { type: "cloud" } } }, + agents: { + reporter: { + model: "ultimate", + instructions: "Create a report.", + environment: "dev", + }, + }, + deployments: { + daily: { + agent: "reporter", + initial_events: [{ type: "user.message", content: "Run the report." }], + resources: [{ type: "file", source: "report.md", mount_path: "/data/report.md" }], + }, + }, + }; + const environment: ResourceAddress = { type: "environment", name: "dev", provider: "qoder" }; + const agent: ResourceAddress = { type: "agent", name: "reporter", provider: "qoder" }; + const deployment: ResourceAddress = { type: "deployment", name: "daily", provider: "qoder" }; + const state: StateFile = { resources: [] }; + state.resources.push({ + address: environment, + remote_id: "env_1", + content_hash: await computeResourceHash(environment, config, configPath, stateLookup(state)), + }); + state.resources.push({ + address: agent, + remote_id: "agent_1", + content_hash: await computeResourceHash(agent, config, configPath, stateLookup(state)), + }); + const originalHash = await computeResourceHash(deployment, config, configPath, stateLookup(state)); + state.resources.push({ address: deployment, remote_id: "depl_1", content_hash: originalHash }); + + writeFileSync(sourcePath, "updated report\n"); + + const changedHash = await computeResourceHash(deployment, config, configPath, stateLookup(state)); + expect(changedHash).not.toBe(originalHash); + const plan = await buildPlan(config, state, { configPath }); + expect(plan.actions.find((action) => addressKey(action.address) === addressKey(deployment))?.action).toBe( + "update", + ); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/sdk/tests/unit/map-deployment.test.ts b/packages/sdk/tests/unit/map-deployment.test.ts index e1d14da..c525cda 100644 --- a/packages/sdk/tests/unit/map-deployment.test.ts +++ b/packages/sdk/tests/unit/map-deployment.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { mapDeploymentToSession as mapBailianDeploymentToSession } from "../../src/internal/providers/bailian/mapper.ts"; +import { + mapDeployment as mapBailianDeployment, + mapDeploymentUpdate as mapBailianDeploymentUpdate, +} from "../../src/internal/providers/bailian/mapper.ts"; import { mapDeployment, mapDeploymentUpdate } from "../../src/internal/providers/claude/mapper.ts"; import type { ResolvedDeploymentRefs } from "../../src/internal/providers/interface.ts"; import { @@ -70,8 +73,14 @@ describe("Claude mapDeployment", () => { expect(body.environment_id).toBe("env_456"); expect(body.vault_ids).toEqual(["vault_a"]); expect(body.initial_events).toEqual([ - { type: "user.message", content: [{ type: "text", text: "Run the daily report" }] }, - { type: "system.message", content: [{ type: "text", text: "You are punctual" }] }, + { + type: "user.message", + content: [{ type: "text", text: "Run the daily report" }], + }, + { + type: "system.message", + content: [{ type: "text", text: "You are punctual" }], + }, { type: "user.define_outcome", description: "Grade", @@ -86,12 +95,24 @@ describe("Claude mapDeployment", () => { checkout: { type: "branch", name: "main" }, mount_path: "/repo", }, - { type: "memory_store", memory_store_id: "ms_2", access: "read_only", instructions: "ref only" }, + { + type: "memory_store", + memory_store_id: "ms_2", + access: "read_only", + instructions: "ref only", + }, { type: "memory_store", memory_store_id: "ms_1" }, ]); - expect(body.schedule).toEqual({ type: "cron", expression: "0 9 * * *", timezone: "UTC" }); + expect(body.schedule).toEqual({ + type: "cron", + expression: "0 9 * * *", + timezone: "UTC", + }); expect(body.description).toBe("Daily report"); - expect(body.metadata).toEqual({ "agents.project": "myproj", "agents.resource": "daily-report" }); + expect(body.metadata).toEqual({ + "agents.project": "myproj", + "agents.resource": "daily-report", + }); }); test("minimal decl omits optional fields and uses bare agent id", () => { @@ -114,7 +135,10 @@ describe("Claude mapDeployment", () => { }; const body = mapDeployment("d", decl, minimalRefs()) as Record; expect(body.initial_events).toEqual([ - { type: "user.define_outcome", rubric: { type: "file", file_id: "file_abc" } }, + { + type: "user.define_outcome", + rubric: { type: "file", file_id: "file_abc" }, + }, ]); }); @@ -156,7 +180,11 @@ describe("Qoder mapDeploymentToSession", () => { description: "Daily", initial_events: [], resources: [ - { type: "file", source: "./report-template.md", mount_path: "/data/report-template.md" }, + { + type: "file", + source: "./report-template.md", + mount_path: "/data/report-template.md", + }, { type: "file", file_id: "file_2" }, ], }; @@ -171,7 +199,11 @@ describe("Qoder mapDeploymentToSession", () => { expect(body.resources).toEqual([ { type: "memory_store", memory_store_id: "ms_1" }, { type: "memory_store", memory_store_id: "ms_2" }, - { type: "file", file_id: "file_1", mount_path: "/data/report-template.md" }, + { + type: "file", + file_id: "file_1", + mount_path: "/data/report-template.md", + }, { type: "file", file_id: "file_2" }, ]); }); @@ -236,8 +268,14 @@ describe("Qoder mapDeployment", () => { // Qoder's /deployments API rejects tunnel_id (HTTP 400) — never sent. expect(body.tunnel_id).toBeUndefined(); expect(body.initial_events).toEqual([ - { type: "user.message", content: [{ type: "text", text: "Run the daily report" }] }, - { type: "system.message", content: [{ type: "text", text: "You are punctual" }] }, + { + type: "user.message", + content: [{ type: "text", text: "Run the daily report" }], + }, + { + type: "system.message", + content: [{ type: "text", text: "You are punctual" }], + }, { type: "user.define_outcome", description: "Grade", @@ -253,25 +291,44 @@ describe("Qoder mapDeployment", () => { checkout: { type: "branch", name: "main" }, mount_path: "/repo", }, - { type: "memory_store", memory_store_id: "ms_2", access: "read_only", instructions: "ref only" }, + { + type: "memory_store", + memory_store_id: "ms_2", + access: "read_only", + instructions: "ref only", + }, { type: "memory_store", memory_store_id: "ms_1" }, ]); - expect(body.schedule).toEqual({ type: "cron", expression: "0 9 * * *", timezone: "Asia/Shanghai" }); + expect(body.schedule).toEqual({ + type: "cron", + expression: "0 9 * * *", + timezone: "Asia/Shanghai", + }); expect(body.vault_ids).toEqual(["vault_a"]); - expect(body.metadata).toEqual({ "agents.project": "myproj", "agents.resource": "daily-report" }); + expect(body.metadata).toEqual({ + "agents.project": "myproj", + "agents.resource": "daily-report", + }); }); test("create carries environment variables and update explicitly clears removed fields", () => { const configured = mapQoderDeployment( "d", - { agent: "x", initial_events: [{ type: "user.message", content: "run" }], environment_variables: "B=2;A=1" }, + { + agent: "x", + initial_events: [{ type: "user.message", content: "run" }], + environment_variables: "B=2;A=1", + }, minimalRefs(), ) as Record; expect(configured.environment_variables).toBe("B=2;A=1"); const update = mapQoderDeploymentUpdate( "d", - { agent: "x", initial_events: [{ type: "user.message", content: "run" }] }, + { + agent: "x", + initial_events: [{ type: "user.message", content: "run" }], + }, minimalRefs(), undefined, undefined, @@ -290,33 +347,159 @@ describe("Qoder mapDeployment", () => { test("Claude update explicitly clears removed optional fields", () => { const update = mapDeploymentUpdate( "d", - { agent: "x", initial_events: [{ type: "user.message", content: "run" }] }, + { + agent: "x", + initial_events: [{ type: "user.message", content: "run" }], + }, minimalRefs(), ) as Record; - expect(update).toMatchObject({ vault_ids: [], resources: [], description: "" }); + expect(update).toMatchObject({ + vault_ids: [], + resources: [], + description: "", + }); expect(update.schedule).toBeUndefined(); }); }); -describe("Bailian mapDeploymentToSession", () => { - test("preserves mount_path for file resources", () => { +describe("Bailian mapDeployment", () => { + test("normalizes file mount paths under Bailian's /mnt sandbox root", () => { + const body = mapBailianDeployment( + "daily-report", + { + agent: "researcher", + initial_events: [], + resources: [ + { + type: "file", + file_id: "file_existing", + mount_path: "reports/template.md", + }, + ], + }, + minimalRefs(), + ) as Record; + + expect(body.resources).toEqual([ + { + type: "file", + file_id: "file_existing", + mount_path: "/mnt/reports/template.md", + }, + ]); + expect(() => + mapBailianDeployment( + "daily-report", + { + agent: "researcher", + initial_events: [], + resources: [ + { + type: "file", + file_id: "file_existing", + mount_path: "/data/template.md", + }, + ], + }, + minimalRefs(), + ), + ).toThrow("bailian mount_path must start with '/mnt/'"); + }); + + test("full decl produces a native deployment body with object agent", () => { const decl: DeploymentDecl = { agent: "researcher", + agent_version: 3, description: "Daily", - initial_events: [], + schedule: { expression: "0 9 * * *", timezone: "Asia/Shanghai" }, + initial_events: [ + { type: "user.message", content: "Run the daily report" }, + // define_outcome has no documented Bailian deployment shape and is dropped. + { + type: "user.define_outcome", + description: "Grade", + rubric: "Must include charts", + }, + ], resources: [ - { type: "file", source: "./report-template.md", mount_path: "/workspace/report-template.md" }, + { + type: "file", + source: "./report-template.md", + mount_path: "/mnt/report-template.md", + }, { type: "file", file_id: "file_existing" }, + // github_repository is not a Bailian deployment resource and is dropped. + { type: "github_repository", url: "https://github.com/acme/repo" }, ], }; - const body = mapBailianDeploymentToSession(decl, minimalRefs(), ["file_uploaded", "file_existing"]) as Record< - string, - unknown - >; + const uploaded = new Map([["./report-template.md", "file_uploaded"]]); + const body = mapBailianDeployment("daily-report", decl, fullRefs(), "myproj", uploaded) as Record; + expect(body.name).toBe("daily-report"); + expect(body.agent).toEqual({ id: "agent_123", version: 3 }); + expect(body.environment_id).toBe("env_456"); + expect(body.vault_ids).toEqual(["vault_a"]); + expect(body.description).toBe("Daily"); + expect(body.schedule).toEqual({ + type: "cron", + expression: "0 9 * * *", + timezone: "Asia/Shanghai", + }); + expect(body.initial_events).toEqual([ + { + role: "user", + type: "message", + content: [{ type: "text", text: "Run the daily report" }], + }, + ]); expect(body.resources).toEqual([ - { type: "file", file_id: "file_uploaded", mount_path: "/workspace/report-template.md" }, + { + type: "file", + file_id: "file_uploaded", + mount_path: "/mnt/report-template.md", + }, { type: "file", file_id: "file_existing" }, ]); + expect(body.metadata).toEqual({ + "agents.project": "myproj", + "agents.resource": "daily-report", + }); + }); + + test("minimal decl omits optional fields and always sends an object agent", () => { + const body = mapBailianDeployment("d", { agent: "x", initial_events: [] }, minimalRefs()) as Record< + string, + unknown + >; + + expect(body.agent).toEqual({ id: "agent_min" }); + expect(body.environment_id).toBe("env_min"); + expect(body.initial_events).toEqual([]); + expect(body.vault_ids).toBeUndefined(); + expect(body.resources).toBeUndefined(); + expect(body.schedule).toBeUndefined(); + expect(body.description).toBeUndefined(); + expect(body.metadata).toBeUndefined(); + }); + + test("update explicitly clears removed optional fields", () => { + const update = mapBailianDeploymentUpdate( + "d", + { + agent: "x", + initial_events: [{ type: "user.message", content: "run" }], + }, + minimalRefs(), + undefined, + undefined, + { stale: "value" }, + ) as Record; + expect(update).toMatchObject({ + vault_ids: [], + resources: [], + description: "", + metadata: { stale: "value" }, + schedule: null, + }); }); }); diff --git a/packages/sdk/tests/unit/validate-config.test.ts b/packages/sdk/tests/unit/validate-config.test.ts index 7589144..8d61044 100644 --- a/packages/sdk/tests/unit/validate-config.test.ts +++ b/packages/sdk/tests/unit/validate-config.test.ts @@ -156,6 +156,55 @@ test("rejects Claude GitHub Session mount paths outside /workspace", () => { expect(diagnostics.some((item) => item.code === "claude.agent.session_resource.mount_path.invalid")).toBe(true); }); +test("warns on Bailian deployment payload drops but not on a native schedule", () => { + const config: ProjectConfig = { + version: "1", + providers: { bailian: {} }, + defaults: { provider: "bailian" }, + environments: { dev: { config: { type: "cloud" } } }, + agents: { + reporter: { + model: "qwen3.7-max", + instructions: "report", + environment: "dev", + tools: { builtin: ["read"] }, + }, + }, + deployments: { + "daily-report": { + agent: "reporter", + schedule: { expression: "0 9 * * *", timezone: "UTC" }, + initial_events: [ + { type: "user.message", content: "go" }, + { + type: "user.define_outcome", + description: "quality gate", + rubric: "must have a summary", + }, + ], + resources: [ + { + type: "file", + file_id: "file_existing", + mount_path: "/data/report-template.md", + }, + { + type: "github_repository", + url: "https://github.com/acme/repo.git", + }, + ], + }, + }, + }; + + const diagnostics = validateProjectConfig(config); + expect(diagnostics.some((item) => item.code === "bailian.deployment.define_outcome_unsupported")).toBe(true); + expect(diagnostics.some((item) => item.code === "bailian.deployment.github_repository_unsupported")).toBe(true); + expect(diagnostics.some((item) => item.code === "bailian.deployment.file.mount_path.invalid")).toBe(true); + // Schedule is native on Bailian now — the emulated schedule warning must not fire. + expect(diagnostics.some((item) => item.code === "bailian.deployment.schedule_unsupported")).toBe(false); +}); + test("allows setup_script on Qoder and rejects unsupported writable package declarations", () => { const diagnostics = validateProjectConfig({ version: "1", @@ -219,3 +268,66 @@ test("rejects networking and packages on managed Qoder self_hosted environments" diagnostics.find((item) => item.code === "qoder.environment.self_hosted.config.unsupported")?.message, ).toContain("only config.type and config.setup_script"); }); + +test("rejects Bailian deployments without a supported initial message event", () => { + const diagnostics = validateProjectConfig({ + version: "1", + providers: { bailian: {} }, + defaults: { provider: "bailian" }, + agents: { + reporter: { model: "qwen3.7-max", instructions: "report" }, + }, + deployments: { + "daily-report": { + agent: "reporter", + initial_events: [ + { + type: "user.define_outcome", + description: "quality gate", + rubric: "must have a summary", + }, + ], + }, + }, + }); + + expect( + diagnostics.some( + (item) => item.code === "bailian.deployment.initial_events.message_required" && item.severity === "error", + ), + ).toBe(true); + expect(diagnostics.some((item) => item.code === "bailian.deployment.define_outcome_unsupported")).toBe(true); +}); + +test("requires unique normalized mount paths for Bailian deployment files", () => { + const diagnostics = validateProjectConfig({ + version: "1", + providers: { bailian: {} }, + defaults: { provider: "bailian" }, + agents: { + reporter: { model: "qwen3.7-max", instructions: "report" }, + }, + deployments: { + "daily-report": { + agent: "reporter", + initial_events: [{ type: "user.message", content: "go" }], + resources: [ + { type: "file", file_id: "file_missing_mount" }, + { + type: "file", + file_id: "file_relative_mount", + mount_path: "reports/template.md", + }, + { + type: "file", + file_id: "file_absolute_mount", + mount_path: "/mnt/reports/template.md", + }, + ], + }, + }, + }); + + expect(diagnostics.filter((item) => item.code === "bailian.deployment.file.mount_path.required")).toHaveLength(1); + expect(diagnostics.filter((item) => item.code === "bailian.deployment.file.mount_path.duplicate")).toHaveLength(1); +});