@kanyun-ai-infra/agenthub 0.4.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,9 +24,9 @@ pnpm add @kanyun-ai-infra/agenthub
24
24
 
25
25
  需要 Node 18+,因为 SDK 使用全局 `fetch`。
26
26
 
27
- 如果调用方是 Java、Python、Go 或 Shell,请优先看 raw HTTP 指南和 REST 参考:
28
- [`docs/user-guide/agenthub-http-quickstart.md`](../../docs/user-guide/agenthub-http-quickstart.md),
29
- [`docs/user-guide/agenthub-rest-api.md`](../../docs/user-guide/agenthub-rest-api.md)。
27
+ 如果调用方是 Java、Python、Go 或 Shell,请优先看 REST 参考及其多语言快速开始:
28
+ [`docs/user-guide/agenthub-rest-api.md`](../../docs/user-guide/agenthub-rest-api.md)
29
+ (curl / Python / Java 示例见 [§9 多语言快速开始](../../docs/user-guide/agenthub-rest-api.md#9-多语言快速开始))。
30
30
 
31
31
  ## 快速开始
32
32
 
@@ -102,6 +102,11 @@ Project → Agent → Version → Session
102
102
  标记 project/agent 默认版本。
103
103
  - **Session**:基于某个 Version 发起的一次 startup/runtime allocation。
104
104
 
105
+ 上面这条链路是"定义"层面的(发布了什么);真正"跑在哪"是另一个独立维度——
106
+ **Environment**(k8s 配置底座)+ **Stage 槽位**(`test`/`production`,把某个
107
+ Version 部署到某个 Environment)。这两个概念、以及一次完整的"发布 → 部署 →
108
+ 诊断 → 回滚"闭环,见 [发布闭环](#发布闭环)。
109
+
105
110
  Agents 和 runtime templates 都是 **project-scoped**。Version 必须在同一个 Project
106
111
  内解析到 Agent 和 runtime source;唯一例外是下文的 platform-shared templates。
107
112
 
@@ -137,12 +142,16 @@ Agents 和 runtime templates 都是 **project-scoped**。Version 必须在同一
137
142
  | `list()` | 列出 token 有效 scope 内的 Project。 |
138
143
  | `get(projectId)` | 查询单个 Project。 |
139
144
  | `delete(projectId)` | 软删(停用)Project:清空 owner / members、关闭集成。**owner-only**(需 `project:settings`)。系统项目不可删。返回 `{ action: "deactivated", projectId }`。 |
145
+ | `getWorkbench(projectId)` | 工作台聚合(AgentHub lifecycle console 同款读模型):`workloadCount` / `sessionStats` / `stageDrift` / `recentChanges`。 |
146
+ | `getReleaseBoard(projectId)` | 发布板聚合:每个 Agent 一行,含 test/production 槽位摘要与 `drift`。 |
140
147
 
141
148
  ### `agenthub.agents`(别名 `agenthub.workloads`)
142
149
 
143
150
  | 方法 | 说明 |
144
151
  | --- | --- |
145
152
  | `create(req, { dryRun? })` | 创建 Agent。返回 `{ mode, status, agentId?/workloadId?, workload }`。 |
153
+ | `get({ projectId, agentSlug \| workloadSlug })` | 读回单个 Agent(含 `defaultRevisionId` 与内嵌 `defaultRevision`;发布过 `setDefault` Version 前为 `null`)。不存在返回 `404 WORKLOAD_NOT_FOUND`;`projectId` 超出 token scope 返回 `403 PROJECT_NOT_IN_TOKEN_SCOPE`。 |
154
+ | `getVersion({ projectId, agentSlug \| workloadSlug, version })` | 读回单个 Version(别名 `getRevision`)。返回 `{ id, projectId, workloadId, workloadSlug, version, runtimeSnapshotHash, createdAt }`(SDK 额外附 `agentId` 镜像)。不存在返回 `404 WORKLOAD_NOT_FOUND / REVISION_NOT_FOUND`。 |
146
155
  | `publishVersion(req, { dryRun? })` | 发布 Version(别名 `publishRevision`,用 `workloadSlug`)。返回 `{ mode, status, agentId/workloadId, versionId?/revisionId?, runtimeTemplateId, resolved }`。 |
147
156
  | `applyManifest({ projectId, manifest, dryRun? })` | 高级 / GitOps:apply raw agent manifest。 |
148
157
  | `applyRevisionManifest({ projectId, agentSlug \| workloadSlug, manifest, dryRun? })` | 高级 / GitOps:apply raw version manifest。两个 slug 拼写都接受(互斥)。 |
@@ -161,6 +170,55 @@ Agents 和 runtime templates 都是 **project-scoped**。Version 必须在同一
161
170
 
162
171
  `apply*` 方法里的 `manifest` 可以是 manifest 对象、`{ yaml }` 或 `{ json }`。
163
172
 
173
+ #### `agenthub.agents.stages` + `previewStage`
174
+
175
+ Stage 槽位(`test` / `production`)——把一个 Version 部署到某个槽位,槽位把
176
+ 「version 指针」和「environment 绑定」当成两个独立维度组合在一起。概念全貌与
177
+ 完整走一遍的例子见 [发布闭环](#发布闭环)。
178
+
179
+ | 方法 | 说明 |
180
+ | --- | --- |
181
+ | `stages.list(workloadId)` | 列出该 Agent 已部署的槽位;从未部署的 stage 直接不出现(无隐式创建)。 |
182
+ | `stages.set(workloadId, stage, { revisionId, environmentId?, runtimeOverrides?, configValues?, expectedUpdatedAt? })` | 创建/更新槽位(upsert,部署动作)。 |
183
+ | `stages.promote(workloadId, { environmentId?, expectedUpdatedAt?, sourceRevisionId?, expectedTestUpdatedAt? }?)` | 把 production 指针挪到 test 槽位当前指向的 revision(原子操作;只挪指针,production 自己的 overrides/config 不变)。 |
184
+ | `previewStage({ agentId \| workloadId, versionId \| revisionId, runtimeOverrides?, configValues?, environmentId? })` | UI-5 解析预览:只读计算部署后会解析成什么,不写任何行。晋级 / 部署前的确认步骤。 |
185
+
186
+ `set` / `promote` 的 `environmentId` 都是**三态**语义:
187
+
188
+ - **省略**(`undefined`)—— 保留槽位当前绑定;槽位第一次创建时没有"现值"可保留,
189
+ 于是落成 `environmentId: null`(未绑定)。
190
+ - **`null`** —— 清除绑定(槽位回退到 revision 快照,不叠加任何 environment 字段组)。
191
+ - **UUID** —— 绑定该 Environment(服务端校验:存在 / 同项目或 platform-shared /
192
+ 未 archived),并钉住它当前的 tip 版本。
193
+
194
+ ⚠️ **`environmentId` "必须显式传,没有继承 revision 环境这回事"这条规则,只适用于
195
+ `stages.set` / `stages.promote` 这两个显式调用——`publishVersion` / `publishRevision`
196
+ 本身没有 `environmentId` 这个入参,传不了。** `publishVersion({ setDefault: true })`
197
+ 首次发布时自动创建的那条 **test** 槽位(2026-07-13 起的新行为,`production` 不会
198
+ 被自动创建)同样落地成未绑定状态,但那是 upsert 时"省略"分支的结果,不是你对
199
+ publishVersion 传了什么参数。给这条自动创建的 test 槽位补环境绑定,要另外显式调用
200
+ `agenthub.agents.stages.set(workloadId, "test", { revisionId, environmentId })`。
201
+ 见下方 [发布闭环](#发布闭环) 的 ⚠️ 说明。
202
+
203
+ ### `agenthub.environments`
204
+
205
+ Environment = agent 运行的 **k8s 配置底座**(provider / 资源规格 / envVars /
206
+ TTL)——它本身不是"部署目标";`test`/`production` 才是部署目标(stage 槽位,
207
+ `agents.stages`),槽位钉住一对 `(environmentId, environmentVersion)`。
208
+
209
+ | 方法 | 说明 |
210
+ | --- | --- |
211
+ | `list(projectId, { includeArchived? })` | 列出项目可见的 Environment(含 platform-shared)。 |
212
+ | `get(id, projectId)` | 查询单个 Environment(含 config 全貌 + `referencedBy`——当前所有引用它的槽位)。 |
213
+ | `create(projectId, { name, base, description?, config? })` | 创建(从 platform-shared 底座拷贝 provider 字段)。定义域操作,日常建议在管理门户完成;这里是给 CI/IaC 场景开的口子。 |
214
+ | `update(id, projectId, { name?, description?, config?, repointReferences? })` | 编辑。`config` 面变更(`resources`/`envVars`/`defaultTtlSeconds`)冻结一个新版本,默认(`repointReferences` 省略或 `true`)批量把所有引用槽位拨到新版本;传 `false` 只建版本、槽位不动(配合 `repoint` 手动灰度)。返回 `{ environment, createdVersion, repointedSlotCount }`;改名/描述这类 metadata-only 写不产生新版本(`createdVersion: null`)。 |
215
+ | `archive(id, projectId)` | 归档(终态,无法撤销)。存量引用继续解析;任何新引用一律拒绝。 |
216
+ | `versions(id, projectId)` | 版本历史(每条自带完整 config 快照,不需要单独的详情调用)+ repoint 审计账(哪个槽位、何时、被谁从哪个版本拨到哪个版本)。 |
217
+ | `repoint(id, projectId, { toVersion, slots?, expectedSlots? })` | 拨动槽位的版本指针——回滚=拨回旧版本,灰度=只拨 `slots` 里点的槽位。省略 `slots` = 项目内所有引用该 Environment 的槽位。槽位选择器接受 `agentId`(同 `workloadId`)。 |
218
+
219
+ `repoint` 同步执行——单事务内的有界原子操作(行锁 + CAS + 批量 UPDATE),不是
220
+ 长任务,调用直接拿到结果,不需要轮询。
221
+
164
222
  ### `agenthub.sessions`
165
223
 
166
224
  | 方法 | 说明 |
@@ -169,6 +227,7 @@ Agents 和 runtime templates 都是 **project-scoped**。Version 必须在同一
169
227
  | `get(sessionId)` | 读取 Session 快照(`{ session: { id, status, ... } }`,字段为服务端原样返回)。 |
170
228
  | `wait(sessionId, { intervalMs?, timeoutMs?, signal? })` | 轮询到 `status === "ready"`;遇到 `failed` 或超时会抛错。 |
171
229
  | `releaseSandbox(sessionId)` | 释放 Session 当前 sandbox(保留 session,下一轮 turn 自动 revive 到新 sandbox)。需 `runs:create`。返回 `{ released, sandboxId, sandboxDestroyed, destroyError? }`;`sandboxDestroyed:false` 不代表失败(watcher 会兜底销毁)。 |
230
+ | `getDiagnosis(sessionId)` | 会话诊断聚合(AgentHub lifecycle console 同款读模型):`session` / `failure` / `events` / `sandbox`,任一区块读取失败时该块为 `null` 且 `blockErrors` 带错误说明,不影响其它区块。 |
172
231
 
173
232
  Session `status` 取值为 `created`、`provisioning`、`starting`、`ready`、`failed`。
174
233
 
@@ -212,6 +271,183 @@ const { sessionId } = await agenthub.sessions.create({
212
271
  约束:每 Session ≤ 20 个文件;`mountPath` 必须是 `.session/inputs/` 下的相对子路径
213
272
  (拒绝绝对路径与 `..`);只读。
214
273
 
274
+ ## 发布闭环
275
+
276
+ **正名**:Environment 对应 k8s 的「配置」——agent 跑在什么样的容器里(provider /
277
+ 资源规格 / envVars / TTL),是**底座**,不是部署目标本身。`test` 和
278
+ `production` 才是部署目标,叫 **stage**(部署槽位)。槽位
279
+ = `{ version 指针, environment 绑定 }` 两个互相独立的维度:一个槽位可以只钉了
280
+ version、没绑 environment(回退到 revision 快照),也可以两者都钉。**"分别创建
281
+ 测试环境和线上环境"**这个诉求,落到 API 上不是造两个 Environment,而是给
282
+ `test` 和 `production` 两个槽位各自 `stages.set` 一次——两个槽位可以指向
283
+ **同一个** Environment(同配置跑两份),也可以指向**不同**的 Environment
284
+ (比如 production 用更大的资源规格)。
285
+
286
+ 下面完整走一遍"发布 → 部署 → 预览 → 晋级 → canary 灰度出真实 drift → repoint
287
+ 回滚",全程用 agent 词汇;每段代码都是照实测过的 j8 SDK 旅程
288
+ (`tests/e2e/j8-agenthub-sdk-journey.spec.ts`)抄的真实调用序列,不是省略号带过
289
+ 的伪代码——复制到 strict TS 项目里应该能直接编译。
290
+
291
+ ### 第一幕:创建 Agent,发布 v1(setDefault:true 自动落地 test)
292
+
293
+ ```ts
294
+ const project = await agenthub.projects.create({ slug: "acme", displayName: "Acme" });
295
+ await agenthub.agents.create({
296
+ projectId: project.id,
297
+ slug: "support-bot",
298
+ displayName: "Support Bot",
299
+ kind: "managed_agent",
300
+ });
301
+
302
+ // setDefault:true —— 首发用它是安全的:2026-07-13 起的新行为只会自动创建
303
+ // **test** 槽位(environmentId 仍是 null,未绑定)+ 翻转 defaultRevisionId,
304
+ // 不会误建一个"看起来已经上线其实没配置"的 production(见文末 ⚠️)。存量
305
+ // 在此之前已经发布过、已经有 production 槽位的老 workload 走的是另一条
306
+ // 分支,不受这次改动影响(同样见文末 ⚠️ 的存量说明)。
307
+ const v1 = await agenthub.agents.publishVersion({
308
+ projectId: project.id,
309
+ agentSlug: "support-bot",
310
+ version: "v1",
311
+ workerContract: { kind: "managed_agent" },
312
+ setDefault: true,
313
+ });
314
+ // `revisionId` 在 RevisionApplyResult 里是 optional(dry-run / manifest 面共用
315
+ // 同一个类型);status:"created" 时必然有值,但 strict TS 下仍需要先断言/守卫
316
+ // 才能当必填参数传下去——不要在类型上偷懒直接 `v1.revisionId!` 到处传。
317
+ if (!v1.revisionId) throw new Error("publishVersion did not return a revisionId");
318
+ const revisionId = v1.revisionId;
319
+ ```
320
+
321
+ ### 第二幕:给已存在的 test 槽位补上环境绑定
322
+
323
+ ```ts
324
+ const env = await agenthub.environments.create(project.id, {
325
+ name: "acme-standard",
326
+ base: { platformTemplateName: "pilot-v2-pod-agent" },
327
+ });
328
+
329
+ // test 槽位在第一幕已经由 setDefault:true 自动创建(environmentId 是 null,
330
+ // 未绑定)——这里不是"首次部署",是确认/补上环境绑定。set 是 upsert,同一个
331
+ // revisionId 再传一次不会产生新状态,只补 environmentId 这一个字段。
332
+ // production 槽位此刻还不存在,留到第四幕用 promote 显式创建它。
333
+ await agenthub.agents.stages.set(v1.workloadId, "test", {
334
+ revisionId,
335
+ environmentId: env.id,
336
+ });
337
+ ```
338
+
339
+ ### 第三幕:晋级前先 preview
340
+
341
+ ```ts
342
+ const preview = await agenthub.agents.previewStage({
343
+ agentId: v1.workloadId,
344
+ versionId: revisionId,
345
+ environmentId: env.id,
346
+ });
347
+ // 把 preview.runtimeSnapshot / preview.environment 展示给操作者确认,
348
+ // 无误后再提交第四幕的晋级请求。
349
+ ```
350
+
351
+ ### 第四幕:晋级 production(第一次创建它)
352
+
353
+ ```ts
354
+ await agenthub.agents.stages.promote(v1.workloadId, {
355
+ // TOCTOU 防护:确保确认弹窗打开期间 test 槽位没有被别人改过。
356
+ sourceRevisionId: revisionId,
357
+ // production 此刻并不存在(新行为下首发只写 test)——promote 把它的指针
358
+ // 挪到 test 当前指向的 revision,但 environmentId 是独立三态、不会从
359
+ // test 继承绑定:首次创建 production 必须显式传 environmentId,否则会
360
+ // 带着 environmentId:null 落地(同一个坑,见文末 ⚠️)。
361
+ environmentId: env.id,
362
+ });
363
+ ```
364
+
365
+ 此刻 production 才第一次出现,且与 test 完全同步(同 revision、同
366
+ environment、同 environment 版本)——`getReleaseBoard` 这一行的 `drift` 是
367
+ `false`,这是正常稳态,不是 bug。
368
+
369
+ ### 第五幕:环境升级到 v2,canary 灰度到 test(这才是真实 drift 的由来)
370
+
371
+ ```ts
372
+ // defaultTtlSeconds 是"永不物化"字段(不需要 Kanyun template gateway)——
373
+ // config 面变更冻结出 v2;repointReferences:false 让两个槽位暂时都留在 v1,
374
+ // 不要一步就把所有槽位都拨过去。
375
+ const updated = await agenthub.environments.update(env.id, project.id, {
376
+ config: { defaultTtlSeconds: 7200 },
377
+ repointReferences: false,
378
+ });
379
+ // `createdVersion` is `number | null` — metadata-only writes create no
380
+ // version. A config-face write always does, but the type doesn't know that;
381
+ // guard before treating it as a required `toVersion`.
382
+ if (updated.createdVersion === null) throw new Error("update() did not create a new version");
383
+ const canaryVersion = updated.createdVersion;
384
+
385
+ // canary:只把 test 槽位拨到 v2,production 留在 v1。
386
+ const canary = await agenthub.environments.repoint(env.id, project.id, {
387
+ toVersion: canaryVersion,
388
+ slots: [{ agentId: v1.workloadId, stage: "test" }],
389
+ });
390
+
391
+ // 这才是 drift 真正出现的时刻——不是假设,是两次 get 读回来的真实状态。
392
+ const afterCanary = await agenthub.environments.get(env.id, project.id);
393
+ const testRef = afterCanary.referencedBy.find((ref) => ref.stage === "test");
394
+ const productionRef = afterCanary.referencedBy.find((ref) => ref.stage === "production");
395
+ // testRef.environmentVersion === 2, productionRef.environmentVersion === 1
396
+ ```
397
+
398
+ ### 第六幕:canary 效果不理想,回滚(真实 409 重试,不是省略号带过)
399
+
400
+ ```ts
401
+ import { PilotPlatformApiError } from "@kanyun-ai-infra/agenthub";
402
+
403
+ async function rollbackTestSlot(): Promise<void> {
404
+ // 重新拉一次,把 test 槽位的当前状态当成"我现在看到的影响面"传给
405
+ // expectedSlots——不要复用第五幕观察时的旧快照。
406
+ const environment = await agenthub.environments.get(env.id, project.id);
407
+ const testRef = environment.referencedBy.find((ref) => ref.stage === "test");
408
+ if (!testRef) return; // 槽位已经不再引用这个 environment,没什么可回滚的。
409
+
410
+ try {
411
+ await agenthub.environments.repoint(env.id, project.id, {
412
+ toVersion: 1, // 回到 canary 之前的版本
413
+ slots: [{ workloadId: testRef.workloadId, stage: "test" }],
414
+ expectedSlots: [{ workloadId: testRef.workloadId, stage: "test" }],
415
+ });
416
+ } catch (err) {
417
+ if (err instanceof PilotPlatformApiError && err.code === "ENVIRONMENT_REPOINT_IMPACT_SET_CHANGED") {
418
+ // 有人在你上面这次 get 之后又动过这个槽位——重新拉最新状态再试,
419
+ // 不能对着同一份 expectedSlots 硬重试。
420
+ return rollbackTestSlot();
421
+ }
422
+ throw err;
423
+ }
424
+ }
425
+
426
+ await rollbackTestSlot();
427
+ // production 全程没有被 canary 影响过——回滚只是撤销 test 侧的灰度尝试。
428
+ ```
429
+
430
+ ---
431
+
432
+ ⚠️ **`publishVersion({ setDefault: true })` 首次发布只会自动写 `test` 槽位
433
+ (`environmentId` 仍是 `null`,未绑定)+ 翻转 `defaultRevisionId`;`production`
434
+ 永远不会被自动创建,必须显式 `stages.set` / `stages.promote`。** 这是
435
+ 2026-07-13 拍板落地的行为(spec `pilot-v2-agent-stage-environment.md` §5.1
436
+ STG-M5~M8;读代码 + 实测确认,不是猜的):`applySetDefault`
437
+ (`packages/control-plane/src/workload/workload-manifest-apply.ts`)判定
438
+ "该 workload 此刻一个 stage 槽位都没有"(首发)时,只 upsert `test` 槽位
439
+ (`environmentId: undefined` 落在还不存在的槽位上,等价于插入 `null`——没有
440
+ "现值"可继承)+ 调用新写端点 `setWorkloadDefaultRevisionPointer` 挪
441
+ `default_revision_id`;`production` 完全不碰。**非首发**(该 workload 已经
442
+ 有 ≥1 个槽位,不管是 `test` 还是 `production`)的 `setDefault` 写只挪
443
+ `default_revision_id` 指针,同样不碰任何槽位——上面六幕如果给第二次发布也传
444
+ `setDefault: true`,也只是指针翻转,不会自动同步任何槽位。
445
+
446
+ > **存量兼容**:2026-07-13 之前已经首发过、因而已经有 `production` 槽位的老
447
+ > workload 判定为"非首发",行为不受这次改动影响、不做迁移——`production`
448
+ > 槽位继续存在,只是后续 `setDefault` 写不会再挪动它的 revision 指针(这本身
449
+ > 是一处行为变化:改动前"非首发"的 `setDefault` 也会挪 `production` 指针)。
450
+
215
451
  ## Client 配置
216
452
 
217
453
  `new PilotPlatformClient(config)`