diff --git a/.changeset/calm-otters-list.md b/.changeset/calm-otters-list.md new file mode 100644 index 0000000..5879b72 --- /dev/null +++ b/.changeset/calm-otters-list.md @@ -0,0 +1,5 @@ +--- +"@openagentpack/sdk": minor +--- + +Expose Bailian Managed Agents operation-level reads, cursor pagination, session events, file downloads, deployment actions, provider capability metadata, generic scoped create-only planning/apply, local Skill source inspection, scoped Vault Credential creation, and display names independent from logical YAML keys. diff --git a/apps/server/openapi.json b/apps/server/openapi.json index 1bc1a56..7f8eacc 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -1520,6 +1520,12 @@ "archived_at": { "type": "string", "nullable": true + }, + "attributes": { + "type": "object", + "additionalProperties": { + "nullable": true + } } }, "required": ["id"] @@ -1793,6 +1799,12 @@ "archived_at": { "type": "string", "nullable": true + }, + "attributes": { + "type": "object", + "additionalProperties": { + "nullable": true + } } }, "required": ["id"] @@ -2062,6 +2074,12 @@ "archived_at": { "type": "string", "nullable": true + }, + "attributes": { + "type": "object", + "additionalProperties": { + "nullable": true + } } }, "required": ["id"] diff --git a/apps/webui/src/lib/api/generated/schema.d.ts b/apps/webui/src/lib/api/generated/schema.d.ts index 373a037..6eaddd7 100644 --- a/apps/webui/src/lib/api/generated/schema.d.ts +++ b/apps/webui/src/lib/api/generated/schema.d.ts @@ -870,6 +870,9 @@ export interface paths { created_at?: string; updated_at?: string; archived_at?: string | null; + attributes?: { + [key: string]: unknown; + }; }[]; }; }; @@ -1116,6 +1119,9 @@ export interface paths { created_at?: string; updated_at?: string; archived_at?: string | null; + attributes?: { + [key: string]: unknown; + }; }[]; }; }; @@ -1346,6 +1352,9 @@ export interface paths { created_at?: string | null; updated_at?: string | null; archived_at?: string | null; + attributes?: { + [key: string]: unknown; + }; }[]; }; }; diff --git a/docs/contributing/release.md b/docs/contributing/release.md index 0501a54..e17aded 100644 --- a/docs/contributing/release.md +++ b/docs/contributing/release.md @@ -35,12 +35,14 @@ Choose the SemVer impact and describe the change for the generated changelog. On ## Publish a beta 1. In GitHub, open **Actions → Publish npm → Run workflow**. -2. Keep the workflow branch set to `main`, choose `beta`, type `PUBLISH`, and run it. +2. Select the exact branch to publish, choose `beta`, type `PUBLISH`, and run it. 3. Approve the `npm-release` Environment deployment after reviewing the commit, generated version, and job summary. -The workflow derives an immutable version from the GitHub Actions run ID and current `main` commit without changing Git history, for example `0.0.0-beta.run-123456789.sha-a1b2c3d`. It publishes with the npm `beta` dist-tag and creates the matching immutable Git tag. It then installs that exact version from the public npm registry on Linux, Windows, and macOS under Node.js 22 and 24. The GitHub prerelease is created only after all six consumer jobs pass. +The workflow derives an immutable version from the selected commit without changing Git history, for example `0.4.0-beta-a1b2c3d-20260827`, and creates the matching immutable Git tag. A beta from `main` publishes to npm's shared `beta` dist-tag. A beta from another branch publishes to a branch-scoped tag such as `beta-feat-managed-agent-api-commands`, so it cannot replace the shared `beta` channel. The job summary prints the exact tag. It then installs that exact version from the public npm registry on Linux, Windows, and macOS under Node.js 22 and 24. The GitHub prerelease is created only after all six consumer jobs pass. -For another beta, merge fixes into `main` and run **Publish npm** again. Every run gets a new Actions-run-and-commit-derived version; no beta branch is created and changesets are not consumed. +For another beta, run **Publish npm** from the updated branch. Every new commit gets a new immutable snapshot version; changesets are not consumed. If the `npm-release` Environment has deployment-branch restrictions, its allowed branch patterns must include the selected beta branch while required reviewers remain enabled. + +Install a branch beta with its printed dist-tag, for example `npm install @openagentpack/sdk@beta-feat-managed-agent-api-commands`. Pinning the exact generated version is the most reproducible option. ## Publish a stable release @@ -91,7 +93,7 @@ npm install --global @openagentpack/cli npm install --global @openagentpack/cli@beta # Pin or test an exact version without a global install -npx @openagentpack/cli@0.0.0-beta.run-123456789.sha-a1b2c3d --version +npx @openagentpack/cli@0.4.0-beta-a1b2c3d-20260827 --version # SDK npm install @openagentpack/sdk @@ -106,4 +108,4 @@ After installing the CLI, run `agents --help`. A beta user returns to stable wit - If all packages published but a post-release consumer job fails, keep the immutable tag, do not unpublish or move the tag, and do not create the GitHub Release. Fix the compatibility issue and publish a new patch version; npm package versions cannot be overwritten. - Registry visibility is retried for five minutes before it is classified as a release failure. Retry the same workflow only when npm propagation, rather than package compatibility, was the cause. - If a version tag already points at another commit, stop. Tags are immutable; investigate the repository history instead of moving or deleting the tag. -- Beta publishing must be manually dispatched from `main`; the release identity check rejects other branches. +- Beta publishing may be manually dispatched from any branch; non-`main` branches receive an isolated npm dist-tag. Stable publishing and npm's `latest` tag remain restricted to `main`. diff --git a/docs/contributing/release.zh-CN.md b/docs/contributing/release.zh-CN.md index 7919859..210fee4 100644 --- a/docs/contributing/release.zh-CN.md +++ b/docs/contributing/release.zh-CN.md @@ -35,12 +35,14 @@ bun run changeset ## 发布 Beta 1. 打开 GitHub 的 **Actions → Publish npm → Run workflow**。 -2. workflow branch 保持 `main`,channel 选择 `beta`,输入 `PUBLISH`,然后运行。 +2. 选择要发布的准确分支,channel 选择 `beta`,输入 `PUBLISH`,然后运行。 3. 检查提交、自动生成的精确版本和 job 信息后,批准 `npm-release` Environment deployment。 -工作流根据 GitHub Actions run ID 和 `main` 当前提交生成不修改 Git 历史的版本,例如 `0.0.0-beta.run-123456789.sha-a1b2c3d`。通过后使用 npm 的 `beta` dist-tag 发布,并创建对应的不可变 Git tag。随后,它会在 Linux、Windows、macOS 的 Node.js 22 和 24 环境中,从公共 npm registry 安装该精确版本。只有六个消费者 job 全部通过,才创建 GitHub prerelease。 +工作流根据所选提交生成不修改 Git 历史的不可变版本,例如 `0.4.0-beta-a1b2c3d-20260827`,并创建对应的不可变 Git tag。从 `main` 发布时仍使用 npm 的共享 `beta` dist-tag;从其他分支发布时使用分支独立标签,例如 `beta-feat-managed-agent-api-commands`,因此不会覆盖共享 `beta` 渠道。job 信息会输出准确标签。随后,它会在 Linux、Windows、macOS 的 Node.js 22 和 24 环境中,从公共 npm registry 安装该精确版本。只有六个消费者 job 全部通过,才创建 GitHub prerelease。 -需要下一个 Beta 时,把修复合并到 `main` 后再次运行 **Publish npm**。每次运行都会根据 Actions run ID 和提交生成新的不可变版本,不需要 Beta 分支,也不会消费 changeset。 +需要下一个 Beta 时,从更新后的分支再次运行 **Publish npm**。每个新提交都会生成新的不可变快照版本,并且不会消费 changeset。如果 `npm-release` Environment 配置了 deployment branch 限制,需要在保留 Required reviewers 的同时,把目标 Beta 分支模式加入允许范围。 + +可以使用 job 输出的分支标签安装,例如 `npm install @openagentpack/sdk@beta-feat-managed-agent-api-commands`;固定自动生成的精确版本最可复现。 ## 发布稳定版 @@ -91,7 +93,7 @@ npm install --global @openagentpack/cli npm install --global @openagentpack/cli@beta # 固定或临时体验某个精确 Beta 版本 -npx @openagentpack/cli@0.0.0-beta.run-123456789.sha-a1b2c3d --version +npx @openagentpack/cli@0.4.0-beta-a1b2c3d-20260827 --version # SDK npm install @openagentpack/sdk @@ -106,4 +108,4 @@ npm install @openagentpack/sdk - 所有包都已发布,但发布后消费者 job 失败:保留不可变 tag,不执行 unpublish、不移动 tag,也不创建 GitHub Release。修复兼容性问题后发布新的 patch 版本;npm 上的版本不能被覆盖。 - registry 可见性会重试五分钟,之后才判定 release 失败。只有确认失败原因是 npm 同步延迟而不是包兼容性时,才从同一个提交重试。 - 版本 tag 已指向其他提交:立即停止。tag 必须保持不可变,应排查历史,不能移动或删除 tag。 -- Beta 发布必须从 `main` 手动触发;其他分支会被发布身份检查拒绝。 +- Beta 可以从任意分支手动触发;非 `main` 分支会获得隔离的 npm dist-tag。稳定版和 npm `latest` 标签仍只能从 `main` 发布。 diff --git a/packages/sdk/docs/public-api-layers.md b/packages/sdk/docs/public-api-layers.md index b0dea63..1aa1bdc 100644 --- a/packages/sdk/docs/public-api-layers.md +++ b/packages/sdk/docs/public-api-layers.md @@ -18,10 +18,17 @@ The public entry exposes domain workflows that return structured domain results. New CLI and WebUI behavior should start here. - Project/resource workflows: config resolution, planning, apply, and destroy. -- Agent workflows: agent listing, readiness, resource planning, and sync. -- Session workflows: creation, runs, follow-up messages, event listing, and - summaries. -- Deployment workflows: listing, details, and runs. +- Agent workflows: agent listing/detail/version reads, readiness, resource + planning, and sync. +- Managed-resource reads: environment, skill/version/download, vault, and file + metadata/content APIs with opaque cursor preservation. +- Session workflows: creation, list/get/update/archive/delete, runs, follow-up + messages, raw event send/list/stream, and summaries. Managed Agents child + threads remain event metadata (`session_thread_id`); there is no independent + public Thread resource API. +- Deployment workflows: list/get/search, run history, run, pause, and unpause. +- Operation capabilities: provider-scoped support/auth/reason metadata for + API-oriented command hosts. - State workflows: a file/in-memory `StateManager` plus state-address parsing. - Validation/model workflows: config validation and provider/model discovery. diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index de79e3d..2acb20c 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -23,7 +23,15 @@ export { writeProjectRuntime, } from "./internal/core/project-runtime.ts"; -export type { ResolvedProjectConfig } from "./internal/types/config.ts"; +export type { + CredentialDecl, + DeploymentDecl, + EnvironmentDecl, + ProjectConfig, + ResolvedProjectConfig, + SkillDecl, + VaultDecl, +} from "./internal/types/config.ts"; export type { LoadedProjectConfig } from "./internal/parser/index.ts"; export { resolveProjectConfig, @@ -34,6 +42,7 @@ export { decideDestructive, executePlannedProject, importResource, + planProjectWithStateBackend, planProjectContext, syncProjectResourcesWithStateBackend, } from "./internal/core/resource-runtime.ts"; @@ -42,8 +51,10 @@ export type { ResourceActionResult, ResourceExecutionResult, ResourcePlanResult, + ResourcePlanScope, ResourceRefreshResult, ResourceRuntimeOptions, + ResourceSyncMode, ResourceSyncRun, } from "./internal/core/resource-runtime.ts"; @@ -79,7 +90,81 @@ export { runDeploymentForContext, } from "./internal/core/deployment-runtime.ts"; -export type { DeploymentListFilter, DeploymentListResult } from "./internal/providers/interface.ts"; +export type { + DeploymentInfo, + DeploymentListFilter, + DeploymentListResult, + DeploymentRunResult, +} from "./internal/providers/interface.ts"; + +export { + archiveRemoteSession, + downloadRemoteFile, + downloadRemoteSkill, + getManagedAgentProviderCapabilities, + getRemoteAgent, + getRemoteDeployment, + getRemoteDeploymentRun, + getRemoteEnvironment, + getRemoteSkill, + getRemoteSkillDownloadInfo, + getRemoteSkillVersion, + getRemoteVault, + listRemoteAgents, + listRemoteAgentVersions, + listRemoteDeploymentRuns, + listRemoteDeployments, + listRemoteEnvironments, + listRemoteFiles, + listRemoteSkills, + listRemoteSkillVersions, + listRemoteVaults, + runRemoteDeployment, + sendRemoteSessionEvents, + setRemoteDeploymentPaused, + updateRemoteSession, +} from "./internal/core/managed-api-runtime.ts"; +export type { ManagedApiTarget } from "./internal/core/managed-api-runtime.ts"; +export type { + AgentListOptions, + AgentPage, + AgentVersionListOptions, + CursorListOptions, + CursorPage, + DeploymentRunInfo, + DeploymentRunPage, + EnvironmentListOptions, + EnvironmentPage, + FileListOptions, + FilePage, + ManagedAgentOperationAuth, + ManagedAgentOperationCapability, + ManagedAgentProviderCapabilities, + SessionEventInput, + SessionEventSendResult, + SessionUpdateInput, + SkillDownloadInfo, + SkillListOptions, + SkillPage, + SkillVersionInfo, + SkillVersionListOptions, + SkillVersionPage, + VaultListOptions, + VaultPage, + VaultCredentialInfo, +} from "./internal/types/managed-api.ts"; + +export { + createVaultCredential, + createVaultCredentialWithStateBackend, + planVaultCredentialCreate, + planVaultCredentialCreateWithStateBackend, +} from "./internal/core/vault-credential-runtime.ts"; +export type { + VaultCredentialCreateOptions, + VaultCredentialCreatePlan, + VaultCredentialCreateResult, +} from "./internal/core/vault-credential-runtime.ts"; export type { DestroyDefaultMemoryStoreResult, @@ -148,8 +233,16 @@ export { listCloudAgents, listCloudEnvironments, listCloudVaults, + planAgentResources, + planAgentResourcesWithStateBackend, syncAgentResourcesWithStateBackend, } from "./internal/core/agent-runtime.ts"; +export type { + AgentResourcePlan, + AgentResourcePlanOptions, + AgentResourceSyncMode, + AgentResourceSyncOptions, +} from "./internal/core/agent-runtime.ts"; export type { CollectedSessionEvents } from "./internal/core/session-runtime.ts"; export { @@ -198,8 +291,13 @@ export { type ProviderConfig, type ProviderConfigProvider, } from "./internal/provider-config.ts"; -export type { ProviderSessionInfo } from "./internal/types/session.ts"; -export type { ProviderSessionEvent } from "./internal/types/session-event.ts"; +export type { ProviderSessionInfo, SessionFilter, SessionListResult } from "./internal/types/session.ts"; +export type { + EventListOptions, + EventStreamOptions, + ProviderSessionEvent, + ProviderSessionEventList, +} from "./internal/types/session-event.ts"; export type { ProviderFileInfo } from "./internal/types/file.ts"; export type { ProviderSkillInfo } from "./internal/types/skill-info.ts"; @@ -212,6 +310,8 @@ export { LocalFileStateBackend } from "./internal/state/local-file-state-backend export type { StateScope } from "./internal/state/backend.ts"; export { extractSkillZipFiles } from "./internal/utils/normalize-skill-zip.ts"; +export { inspectSkillSource } from "./internal/core/skill-source.ts"; +export type { SkillSourceInspection } from "./internal/core/skill-source.ts"; export type { RuntimeFeedbackEvent, diff --git a/packages/sdk/src/internal/core/agent-builder.ts b/packages/sdk/src/internal/core/agent-builder.ts index c64ddac..ce4b710 100644 --- a/packages/sdk/src/internal/core/agent-builder.ts +++ b/packages/sdk/src/internal/core/agent-builder.ts @@ -20,6 +20,7 @@ export interface AgentMcpBuildInput { } export interface AgentBuildInput { + name?: string; description?: string; model?: AgentDecl["model"]; instructions?: string; @@ -79,6 +80,7 @@ export function buildAgentDecl(base: AgentDecl | undefined, input: AgentBuildInp const agent: AgentDecl = { ...(base ?? {}), + name: input.name ?? base?.name, description: input.description ?? base?.description, model, instructions: input.instructions ?? base?.instructions ?? "", diff --git a/packages/sdk/src/internal/core/agent-runtime.ts b/packages/sdk/src/internal/core/agent-runtime.ts index 994f3b1..f1e2eec 100644 --- a/packages/sdk/src/internal/core/agent-runtime.ts +++ b/packages/sdk/src/internal/core/agent-runtime.ts @@ -1,4 +1,5 @@ import { UserError } from "../errors.ts"; +import { buildDependencyGraph, collectDependencyClosure } from "../graph/dependency.ts"; import type { RemoteResource } from "../providers/interface.ts"; import type { ResourceCrudAdapter } from "../providers/resource-workflow.ts"; import { buildSessionBindings, resolveSessionProvider } from "../session/session-manager.ts"; @@ -21,7 +22,7 @@ import type { ResourceAddress } from "../types/state.ts"; import { addressKey } from "../types/state.ts"; import { resolveAgentMaterialization } from "./agent-materialization.ts"; import type { BackendRuntimeInput, ProjectRuntimeContext } from "./project-runtime.ts"; -import { getRuntimeProvider, writeProjectRuntime } from "./project-runtime.ts"; +import { getRuntimeProvider, readProjectRuntime, writeProjectRuntime } from "./project-runtime.ts"; import { type DestructivePolicy, decideDestructive, @@ -29,7 +30,7 @@ import { planProjectContext, type ResourceExecutionResult, type ResourcePlanResult, - replaceResourcePlan, + type ResourceSyncMode, selectDestructive, } from "./resource-runtime.ts"; @@ -57,8 +58,11 @@ export interface AgentResourcePlan { export interface AgentResourcePlanOptions { refresh?: boolean; quiet?: boolean; + mode?: AgentResourceSyncMode; } +export type AgentResourceSyncMode = ResourceSyncMode; + export interface AgentResourceSyncOptions extends AgentResourcePlanOptions { policy?: DestructivePolicy; confirm?: (actions: PlannedAction[]) => boolean | Promise; @@ -246,22 +250,34 @@ export async function planAgentResources( options: AgentResourcePlanOptions = {}, ): Promise { const agent = getAgent(ctx, agentId); + const rootAddress = collectAgentAddresses(ctx.config, agent.agentName, agent.provider)[0]!; const planned = await planProjectContext(ctx, { provider: agent.provider, + scope: { roots: [rootAddress] }, + mode: options.mode, refresh: options.refresh, quiet: options.quiet ?? true, }); - const actions = filterAgentActions(ctx, agent, planned.plan); + const actions = planned.plan.actions; + const diagnostics = planned.plan.diagnostics; return { agentId, provider: agent.provider, actions, - diagnostics: filterAgentDiagnostics(ctx, agent, planned.plan), + diagnostics, destructiveActions: selectDestructive(actions), planned, }; } +export async function planAgentResourcesWithStateBackend( + input: BackendRuntimeInput, + agentId: string, + options: AgentResourcePlanOptions = {}, +): Promise { + return readProjectRuntime(input, (ctx) => planAgentResources(ctx, agentId, options)); +} + export async function syncAgentResources( ctx: ProjectRuntimeContext, agentId: string, @@ -278,6 +294,7 @@ async function runAgentSync( const fullPlan = await planAgentResources(ctx, agentId, { refresh: options.refresh, quiet: options.quiet, + mode: options.mode, }); const actions = fullPlan.actions; const destructiveActions = fullPlan.destructiveActions; @@ -317,11 +334,7 @@ async function runAgentSync( } try { - const scopedPlan = { - diagnostics: fullPlan.diagnostics, - actions: scopePlanActions(fullPlan.planned.plan, actions), - }; - const execution = await executePlannedProject(replaceResourcePlan(fullPlan.planned, scopedPlan), { + const execution = await executePlannedProject(fullPlan.planned, { policy: "force", }); const results = toAgentSyncResults(execution); @@ -443,36 +456,6 @@ export function getAgentReadinessFromPlan( }; } -function filterAgentActions(ctx: ProjectRuntimeContext, agent: AgentDefinition, plan: ExecutionPlan): PlannedAction[] { - const relevantKeys = agentAddressKeys(ctx, agent); - return plan.actions.filter( - (action) => - relevantKeys.has(addressKey(action.address)) || - action.dependencies.some((dependency) => relevantKeys.has(addressKey(dependency))), - ); -} - -function filterAgentDiagnostics(ctx: ProjectRuntimeContext, agent: AgentDefinition, plan: ExecutionPlan): Diagnostic[] { - const relevantKeys = agentAddressKeys(ctx, agent); - return plan.diagnostics.filter( - (diagnostic) => !diagnostic.resource || relevantKeys.has(addressKey(diagnostic.resource)), - ); -} - -function agentAddressKeys(ctx: ProjectRuntimeContext, agent: AgentDefinition): Set { - return new Set(collectAgentAddresses(ctx.config, agent.agentName, agent.provider).map(addressKey)); -} - -function scopePlanActions(fullPlan: ExecutionPlan, agentActions: PlannedAction[]): PlannedAction[] { - const allowedKeys = new Set(agentActions.filter((action) => action.action !== "no-op").map(actionKey)); - return fullPlan.actions.filter((action) => action.action === "no-op" || allowedKeys.has(actionKey(action))); -} - -function actionKey(action: PlannedAction): string { - const address = action.address; - return `${action.action}:${address.provider}:${address.type}:${address.name}`; -} - function isNonBlockingAgentDrift(action: PlannedAction): boolean { if (action.action === "no-op") return true; return action.readinessImpact === "non_blocking"; @@ -485,35 +468,13 @@ export function collectAgentAddresses(config: ProjectConfig, agentName: string, } const resolvedProvider = provider ?? resolveSessionProvider(agentName, config, undefined); const materialization = resolveAgentMaterialization(resolvedProvider, agent); - const addresses: ResourceAddress[] = [ - { type: materialization.resourceType, name: agentName, provider: resolvedProvider }, - ]; - - if (agent.environment) { - addresses.push({ - type: "environment", - name: agent.environment, - provider: resolvedProvider, - }); - } - if (agent.vault) { - addresses.push({ type: "vault", name: agent.vault, provider: resolvedProvider }); - } - for (const name of agent.memory_stores ?? []) { - addresses.push({ type: "memory_store", name, provider: resolvedProvider }); - } - for (const skill of agent.skills ?? []) { - if (typeof skill === "string") { - addresses.push({ type: "skill", name: skill, provider: resolvedProvider }); - } - } - for (const subAgent of agent.multiagent?.agents ?? []) { - const subDecl = config.agents?.[subAgent]; - const subType = subDecl ? resolveAgentMaterialization(resolvedProvider, subDecl).resourceType : "agent"; - addresses.push({ type: subType, name: subAgent, provider: resolvedProvider }); - } - - return addresses; + const rootAddress: ResourceAddress = { + type: materialization.resourceType, + name: agentName, + provider: resolvedProvider, + }; + const graph = buildDependencyGraph(config, [resolvedProvider]); + return collectDependencyClosure(graph, [rootAddress]); } function toAgentDefinition(config: ProjectConfig, agentName: string, agent: AgentDecl): AgentDefinition { diff --git a/packages/sdk/src/internal/core/managed-api-runtime.ts b/packages/sdk/src/internal/core/managed-api-runtime.ts new file mode 100644 index 0000000..fc67154 --- /dev/null +++ b/packages/sdk/src/internal/core/managed-api-runtime.ts @@ -0,0 +1,386 @@ +import { UserError } from "../errors.ts"; +import { ApiError } from "../providers/base-client.ts"; +import type { + DeploymentInfo, + DeploymentListFilter, + DeploymentListResult, + DeploymentRunResult, + ProviderAdapter, +} from "../providers/interface.ts"; +import { resolveFetch } from "../transport.ts"; +import type { + AgentListOptions, + AgentPage, + AgentVersionListOptions, + DeploymentRunInfo, + DeploymentRunPage, + EnvironmentListOptions, + EnvironmentPage, + FileListOptions, + FilePage, + ManagedAgentOperationCapability, + ManagedAgentProviderCapabilities, + SessionEventInput, + SessionEventSendResult, + SessionUpdateInput, + SkillDownloadInfo, + SkillListOptions, + SkillPage, + SkillVersionInfo, + SkillVersionListOptions, + SkillVersionPage, + VaultListOptions, + VaultPage, +} from "../types/managed-api.ts"; +import type { ProviderSessionInfo } from "../types/session.ts"; +import type { ProviderSkillInfo } from "../types/skill-info.ts"; +import type { ProjectRuntimeContext } from "./project-runtime.ts"; +import { getRuntimeProvider } from "./project-runtime.ts"; + +export interface ManagedApiTarget { + provider?: string; +} + +function resolveAdapter(ctx: ProjectRuntimeContext, provider?: string): ProviderAdapter { + if (provider) return getRuntimeProvider(ctx, provider); + const configured = Array.from(ctx.providers.keys()); + if (configured.length === 1) return getRuntimeProvider(ctx, configured[0]!); + throw new UserError("Multiple providers configured. Use --provider to specify one."); +} + +function requiredMethod(value: T | undefined, provider: string, operation: string): T { + if (value) return value; + throw new UserError(`Provider '${provider}' does not support ${operation}.`); +} + +export async function listRemoteAgents( + ctx: ProjectRuntimeContext, + options: ManagedApiTarget & AgentListOptions = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.listAgentResources, adapter.name, "agent listing"); + return method.call(adapter, options); +} + +export async function getRemoteAgent( + ctx: ProjectRuntimeContext, + id: string, + options: ManagedApiTarget & { version?: number } = {}, +) { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.getRemoteAgent, adapter.name, "agent lookup"); + return method.call(adapter, id, options.version); +} + +export async function listRemoteAgentVersions( + ctx: ProjectRuntimeContext, + id: string, + options: ManagedApiTarget & AgentVersionListOptions = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.listAgentVersions, adapter.name, "agent version listing"); + return method.call(adapter, id, options); +} + +export async function listRemoteEnvironments( + ctx: ProjectRuntimeContext, + options: ManagedApiTarget & EnvironmentListOptions = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.listEnvironmentResources, adapter.name, "environment listing"); + return method.call(adapter, options); +} + +export async function getRemoteEnvironment(ctx: ProjectRuntimeContext, id: string, options: ManagedApiTarget = {}) { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.getRemoteEnvironment, adapter.name, "environment lookup"); + return method.call(adapter, id); +} + +export async function listRemoteVaults( + ctx: ProjectRuntimeContext, + options: ManagedApiTarget & VaultListOptions = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.listVaultResources, adapter.name, "vault listing"); + return method.call(adapter, options); +} + +export async function getRemoteVault(ctx: ProjectRuntimeContext, id: string, options: ManagedApiTarget = {}) { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.getRemoteVault, adapter.name, "vault lookup"); + return method.call(adapter, id); +} + +export async function listRemoteSkills( + ctx: ProjectRuntimeContext, + options: ManagedApiTarget & SkillListOptions = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.listSkillResources, adapter.name, "skill listing"); + return method.call(adapter, options); +} + +export async function getRemoteSkill( + ctx: ProjectRuntimeContext, + id: string, + options: ManagedApiTarget = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.getSkillInfo, adapter.name, "skill lookup"); + return method.call(adapter, id); +} + +export async function listRemoteSkillVersions( + ctx: ProjectRuntimeContext, + id: string, + options: ManagedApiTarget & SkillVersionListOptions = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.listSkillVersions, adapter.name, "skill version listing"); + return method.call(adapter, id, options); +} + +export async function getRemoteSkillVersion( + ctx: ProjectRuntimeContext, + id: string, + version: string, + options: ManagedApiTarget = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.getSkillVersion, adapter.name, "skill version lookup"); + return method.call(adapter, id, version); +} + +export async function getRemoteSkillDownloadInfo( + ctx: ProjectRuntimeContext, + id: string, + version: string, + options: ManagedApiTarget = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.getSkillDownloadInfo, adapter.name, "skill version download"); + return method.call(adapter, id, version); +} + +export async function downloadRemoteSkill( + ctx: ProjectRuntimeContext, + id: string, + version: string, + options: ManagedApiTarget = {}, +): Promise { + const info = await getRemoteSkillDownloadInfo(ctx, id, version, options); + const response = await resolveFetch()(info.file_url, { method: "GET" }); + if (!response.ok) { + throw new ApiError(response.status, await response.text(), "Skill download failed"); + } + return new Uint8Array(await response.arrayBuffer()); +} + +export async function listRemoteFiles( + ctx: ProjectRuntimeContext, + options: ManagedApiTarget & FileListOptions = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.listFileResources, adapter.name, "file listing"); + return method.call(adapter, options); +} + +export async function downloadRemoteFile( + ctx: ProjectRuntimeContext, + id: string, + options: ManagedApiTarget = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.downloadFileContent, adapter.name, "file content download"); + return method.call(adapter, id); +} + +export async function updateRemoteSession( + ctx: ProjectRuntimeContext, + id: string, + input: SessionUpdateInput, + options: ManagedApiTarget = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.updateSession, adapter.name, "session update"); + return method.call(adapter, id, input); +} + +export async function archiveRemoteSession( + ctx: ProjectRuntimeContext, + id: string, + options: ManagedApiTarget = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.archiveSession, adapter.name, "session archive"); + return method.call(adapter, id); +} + +export async function sendRemoteSessionEvents( + ctx: ProjectRuntimeContext, + id: string, + events: SessionEventInput[], + options: ManagedApiTarget = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.sendSessionEvents, adapter.name, "generic session event sending"); + return method.call(adapter, id, events); +} + +export async function listRemoteDeployments( + ctx: ProjectRuntimeContext, + options: ManagedApiTarget & DeploymentListFilter = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.listDeployments, adapter.name, "deployment listing"); + return method.call(adapter, options); +} + +export async function getRemoteDeployment( + ctx: ProjectRuntimeContext, + id: string, + options: ManagedApiTarget = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.getDeploymentById, adapter.name, "deployment lookup"); + return method.call(adapter, id); +} + +export async function runRemoteDeployment( + ctx: ProjectRuntimeContext, + id: string, + options: ManagedApiTarget = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.runDeploymentById, adapter.name, "deployment run"); + return method.call(adapter, id); +} + +export async function setRemoteDeploymentPaused( + ctx: ProjectRuntimeContext, + id: string, + paused: boolean, + options: ManagedApiTarget = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = paused ? adapter.pauseDeploymentById : adapter.unpauseDeploymentById; + return requiredMethod(method, adapter.name, `${paused ? "pausing" : "unpausing"} deployments`).call(adapter, id); +} + +export async function listRemoteDeploymentRuns( + ctx: ProjectRuntimeContext, + deploymentId: string, + options: ManagedApiTarget & { limit?: number; page?: string } = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.listDeploymentRuns, adapter.name, "deployment run listing"); + return method.call(adapter, deploymentId, options); +} + +export async function getRemoteDeploymentRun( + ctx: ProjectRuntimeContext, + runId: string, + options: ManagedApiTarget = {}, +): Promise { + const adapter = resolveAdapter(ctx, options.provider); + const method = requiredMethod(adapter.getDeploymentRun, adapter.name, "deployment run lookup"); + return method.call(adapter, runId); +} + +const API_KEY: ManagedAgentOperationCapability = { supported: true, auth: "api_key" }; +const CLIENT_SEARCH: ManagedAgentOperationCapability = { + supported: true, + auth: "api_key", + reason: "Client-side filtering over the corresponding list API.", +}; +const UNSUPPORTED_MODEL: ManagedAgentOperationCapability = { + supported: false, + auth: null, + reason: "The public Managed Agents API does not expose a model catalog endpoint.", +}; +const UNSUPPORTED_THREAD: ManagedAgentOperationCapability = { + supported: false, + auth: null, + reason: + "Managed Agents exposes child-thread ids and lifecycle events through Session Events, but no independent Thread resource API.", +}; +const UNSUPPORTED_OAUTH: ManagedAgentOperationCapability = { + supported: false, + auth: null, + reason: "The public Managed Agents data plane does not expose MCP OAuth login.", +}; + +const BAILIAN_OPERATIONS: Record = { + "agent.create": { ...API_KEY, reason: "YAML declaration plus scoped create-only apply." }, + "agent.list": API_KEY, + "agent.get": API_KEY, + "agent.search": CLIENT_SEARCH, + "agent.versions": API_KEY, + "environment.create": { ...API_KEY, reason: "YAML declaration plus scoped create-only apply." }, + "environment.list": API_KEY, + "environment.get": API_KEY, + "environment.search": CLIENT_SEARCH, + "skill.create": { ...API_KEY, reason: "YAML declaration plus scoped create-only apply." }, + "skill.list": API_KEY, + "skill.get": API_KEY, + "skill.search": CLIENT_SEARCH, + "skill.versions": API_KEY, + "skill.download": API_KEY, + "vault.create": { ...API_KEY, reason: "YAML declaration plus scoped create-only apply." }, + "vault.credential.create": { ...API_KEY, reason: "YAML declaration plus scoped Vault transaction." }, + "vault.list": API_KEY, + "vault.get": API_KEY, + "vault.search": CLIENT_SEARCH, + "deployment.create": { ...API_KEY, reason: "YAML declaration plus scoped create-only apply." }, + "deployment.list": API_KEY, + "deployment.get": API_KEY, + "deployment.search": { ...API_KEY, reason: "Maps to the server-side keyword parameter." }, + "deployment.runs.list": API_KEY, + "deployment.runs.get": API_KEY, + "deployment.run": API_KEY, + "deployment.pause": API_KEY, + "deployment.unpause": API_KEY, + "session.create": API_KEY, + "session.list": API_KEY, + "session.get": API_KEY, + "session.search": CLIENT_SEARCH, + "session.update": API_KEY, + "session.archive": API_KEY, + "session.delete": API_KEY, + "session.run": { ...API_KEY, reason: "Client composition of session create and event send/stream." }, + "session.event.send": API_KEY, + "session.event.list": API_KEY, + "session.event.stream": API_KEY, + "session.debug": { ...API_KEY, reason: "Client-side aggregation of supported read APIs." }, + "session.export": { ...API_KEY, reason: "Client-side export of supported read APIs." }, + "file.upload": API_KEY, + "file.list": API_KEY, + "file.get": API_KEY, + "file.search": CLIENT_SEARCH, + "file.download": API_KEY, + "file.delete": API_KEY, + "model.list": UNSUPPORTED_MODEL, + "model.search": UNSUPPORTED_MODEL, + "session_thread.list": UNSUPPORTED_THREAD, + "session_thread.get": UNSUPPORTED_THREAD, + "session_thread.archive": UNSUPPORTED_THREAD, + "session_thread.events": UNSUPPORTED_THREAD, + "mcp.oauth_login": UNSUPPORTED_OAUTH, +}; + +export function getManagedAgentProviderCapabilities(provider: string): ManagedAgentProviderCapabilities { + if (provider === "bailian") { + return { provider, operations: { ...BAILIAN_OPERATIONS } }; + } + return { + provider, + operations: { + "managed_agent.api": { + supported: false, + auth: null, + reason: "The operation-level API command surface is currently implemented for Bailian only.", + }, + }, + }; +} diff --git a/packages/sdk/src/internal/core/resource-runtime.ts b/packages/sdk/src/internal/core/resource-runtime.ts index 840a843..84ee4b4 100644 --- a/packages/sdk/src/internal/core/resource-runtime.ts +++ b/packages/sdk/src/internal/core/resource-runtime.ts @@ -1,5 +1,6 @@ import { UserError } from "../errors.ts"; import { type ExecutionResult, executePlan } from "../executor/executor.ts"; +import { buildDependencyGraph, collectDependencyClosure } from "../graph/dependency.ts"; import { getResourceDeclaration } from "../planner/declaration.ts"; import { buildReadinessBaseline } from "../planner/plan-semantics.ts"; import { buildPlan } from "../planner/planner.ts"; @@ -8,18 +9,28 @@ import { readComparableIfSupported } from "../providers/drift-support.ts"; import type { ExecutionPlan, PlannedAction } from "../types/plan.ts"; import type { RuntimeFeedbackSink } from "../types/runtime-feedback.ts"; import type { ResourceAddress, ResourceState, ResourceType } from "../types/state.ts"; +import { addressKey } from "../types/state.ts"; import { contentHash as stableContentHash } from "../utils/hash.ts"; import type { BackendRuntimeInput, ProjectRuntimeContext } from "./project-runtime.ts"; import { readProjectRuntime, writeProjectRuntime } from "./project-runtime.ts"; export interface ResourceRuntimeOptions extends DestructiveDecisionOptions { provider?: string; + scope?: ResourcePlanScope; + mode?: ResourceSyncMode; refresh?: boolean; refreshOnly?: boolean; quiet?: boolean; onFeedback?: RuntimeFeedbackSink; } +export type ResourceSyncMode = "reconcile" | "create-only"; + +export interface ResourcePlanScope { + roots: ResourceAddress[]; + includeDependencies?: boolean; +} + export interface ResourceRefreshResult { removed: ResourceState[]; errors: Array<{ resource: ResourceState; error: string }>; @@ -42,6 +53,8 @@ export interface ResourcePlanResult { refreshResult?: ResourceRefreshResult; targetProviders?: string[]; destructiveActions: PlannedAction[]; + selectedAddresses?: ResourceAddress[]; + mode?: ResourceSyncMode; } export type DestructivePolicy = "block" | "prompt" | "force"; @@ -72,6 +85,10 @@ export async function syncProjectResourcesWithStateBackend( if (options.refreshOnly) { return { planned }; } + if (options.mode === "create-only") { + const errorDiagnostic = planned.plan.diagnostics.find((diagnostic) => diagnostic.severity === "error"); + if (errorDiagnostic) throw new UserError(errorDiagnostic.message); + } return { planned, execution: await executePlannedProject(planned, { @@ -164,21 +181,34 @@ export async function planProjectContext( ctx: ProjectRuntimeContext, options: ResourceRuntimeOptions = {}, ): Promise { - const targetProviders = resolveTargetProviders(options.provider); + if (options.mode === "create-only" && !options.scope) { + throw new UserError("Resource create-only mode requires an explicit resource scope."); + } + let targetProviders = resolveTargetProviders(options.provider); + if (!targetProviders && options.scope) { + targetProviders = [...new Set(options.scope.roots.map((root) => root.provider))]; + } + const selectedAddresses = options.scope ? resolvePlanScope(ctx, targetProviders ?? [], options.scope) : undefined; + const resourceKeys = selectedAddresses ? new Set(selectedAddresses.map(addressKey)) : undefined; const refreshResult = options.refresh !== false && ctx.state.listResources().length > 0 ? await refreshState(ctx.state, ctx.providers, { targetProviders, + resourceKeys, config: ctx.config, quiet: options.quiet ?? true, onFeedback: options.onFeedback, }) : undefined; - const plan = await buildPlan(ctx.config, ctx.state.getStateFile(), { + let plan = await buildPlan(ctx.config, ctx.state.getStateFile(), { providers: targetProviders, configPath: ctx.configPath, + resourceAddresses: selectedAddresses, }); + if (options.mode === "create-only" && options.scope) { + plan = enforceCreateOnlyPlan(plan, options.scope, toResourceRefreshResult(refreshResult)); + } return { executionContext: ctx, @@ -186,9 +216,82 @@ export async function planProjectContext( refreshResult: toResourceRefreshResult(refreshResult), targetProviders, destructiveActions: selectDestructive(plan.actions), + selectedAddresses, + mode: options.mode, + }; +} + +function enforceCreateOnlyPlan( + plan: ExecutionPlan, + scope: ResourcePlanScope, + refreshResult: ResourceRefreshResult | undefined, +): ExecutionPlan { + const reasons: string[] = []; + const rootKeys = new Set(scope.roots.map(addressKey)); + const refreshError = refreshResult?.errors[0]; + if (refreshError) { + reasons.push( + `Cannot verify scoped dependencies because refresh failed for ${addressKey(refreshError.resource.address)}: ${refreshError.error}`, + ); + } + + for (const root of scope.roots) { + const rootKey = addressKey(root); + const rootAction = plan.actions.find((action) => addressKey(action.address) === rootKey); + if (!rootAction) { + reasons.push(`Scoped plan did not contain target resource ${rootKey}.`); + } else if (rootAction.action !== "create") { + reasons.push(`Target resource ${rootKey} must be new, but the scoped plan requires '${rootAction.action}'.`); + } + } + + const dependencyChanges = plan.actions.filter( + (action) => !rootKeys.has(addressKey(action.address)) && action.action !== "no-op", + ); + if (dependencyChanges.length > 0) { + const labels = dependencyChanges.map((action) => `${addressKey(action.address)} (${action.action})`).join(", "); + reasons.push(`Create-only requires every scoped dependency to be up-to-date. Reconcile first: ${labels}.`); + } + + if (reasons.length === 0) return plan; + return { + ...plan, + diagnostics: [ + ...plan.diagnostics, + { + severity: "error", + code: "resource.create_only.blocked", + message: reasons.join(" "), + resource: scope.roots[0], + }, + ], }; } +function resolvePlanScope( + ctx: ProjectRuntimeContext, + targetProviders: string[], + scope: ResourcePlanScope, +): ResourceAddress[] { + if (scope.roots.length === 0) { + throw new UserError("Resource plan scope requires at least one root address."); + } + for (const root of scope.roots) { + if (!targetProviders.includes(root.provider)) { + throw new UserError( + `Scoped resource ${addressKey(root)} is outside the selected provider set: ${targetProviders.join(", ")}.`, + ); + } + } + const graph = buildDependencyGraph(ctx.config, targetProviders); + for (const root of scope.roots) { + if (!graph.nodes.has(addressKey(root))) { + throw new UserError(`Scoped resource ${addressKey(root)} is not declared in the project config.`); + } + } + return scope.includeDependencies === false ? [...scope.roots] : collectDependencyClosure(graph, scope.roots); +} + export async function executePlannedProject( planned: ResourcePlanResult, options: DestructiveDecisionOptions & { diff --git a/packages/sdk/src/internal/core/session-event-sanitizer.ts b/packages/sdk/src/internal/core/session-event-sanitizer.ts index a7597ac..9599ccf 100644 --- a/packages/sdk/src/internal/core/session-event-sanitizer.ts +++ b/packages/sdk/src/internal/core/session-event-sanitizer.ts @@ -63,6 +63,7 @@ export function sanitizeSessionEvent( if (event.tool_input !== undefined) metadata.tool_input = event.tool_input; if (event.status !== undefined) metadata.status = event.status; if (event.stop_reason !== undefined) metadata.stop_reason = event.stop_reason; + if (event.session_thread_id !== undefined) metadata.session_thread_id = event.session_thread_id; // Always surface the engine's 7-class bucket so the renderer never has to re-derive it // for events the provider already classified. For unmapped raw types (type==="unknown") // we omit it on purpose, leaving the renderer to bucket by raw_type — its Agents-vocab table diff --git a/packages/sdk/src/internal/core/session-runtime.ts b/packages/sdk/src/internal/core/session-runtime.ts index c0fb281..a23bb4a 100644 --- a/packages/sdk/src/internal/core/session-runtime.ts +++ b/packages/sdk/src/internal/core/session-runtime.ts @@ -383,17 +383,25 @@ export async function deleteSession(ctx: ProjectRuntimeContext, sessionId: strin export async function listSessionEvents( ctx: ProjectRuntimeContext, sessionId: string, - options: SessionRuntimeTarget & { limit?: number; order?: string; page_token?: string; page?: string } = {}, + options: SessionRuntimeTarget & EventListOptions = {}, ): Promise { const listOptions: EventListOptions = {}; if (options.limit !== undefined) listOptions.limit = options.limit; if (options.order !== undefined) listOptions.order = options.order; + if (options.types !== undefined) listOptions.types = options.types; + if (options.created_at_gt !== undefined) listOptions.created_at_gt = options.created_at_gt; + if (options.created_at_gte !== undefined) listOptions.created_at_gte = options.created_at_gte; + if (options.created_at_lt !== undefined) listOptions.created_at_lt = options.created_at_lt; + if (options.created_at_lte !== undefined) listOptions.created_at_lte = options.created_at_lte; const pageToken = options.page_token ?? options.page; if (pageToken !== undefined) listOptions.page_token = pageToken; - return resolveDirectAdapter(ctx, options.provider).listSessionEvents( + const result = await resolveDirectAdapter(ctx, options.provider).listSessionEvents( sessionId, Object.keys(listOptions).length > 0 ? listOptions : undefined, ); + if (!options.types || options.types.length === 0) return result; + const allowedTypes = new Set(options.types); + return { ...result, events: result.events.filter((event) => allowedTypes.has(event.raw_type)) }; } /** @@ -503,21 +511,115 @@ export async function deleteSkill( } /** - * Subscribe to a session's live event stream by id (native SSE). The underlying provider - * stream pushes only post-connect events, so callers that need full history must replay it - * via listSessionEvents and de-dupe by ProviderSessionEvent.id. + * Subscribe to a session's event stream by id. Providers with native cursor support receive + * `after_id` directly; providers without it resume by replaying paginated history and polling + * for new events with event-id de-duplication. */ export function streamSessionEvents( ctx: ProjectRuntimeContext, sessionId: string, options: SessionRuntimeTarget & EventStreamOptions = {}, ): AsyncIterable { + const adapter = resolveDirectAdapter(ctx, options.provider); + if (options.after_id && !adapter.eventResume) { + return replaySessionEventsByPolling(adapter, sessionId, options.after_id); + } const streamOptions: EventStreamOptions = {}; if (options.after_id !== undefined) streamOptions.after_id = options.after_id; - return resolveDirectAdapter(ctx, options.provider).streamSessionEvents( - sessionId, - Object.keys(streamOptions).length > 0 ? streamOptions : undefined, - ); + return adapter.streamSessionEvents(sessionId, Object.keys(streamOptions).length > 0 ? streamOptions : undefined); +} + +async function* replaySessionEventsByPolling( + adapter: SessionWorkflowAdapter, + sessionId: string, + afterId: string, +): AsyncIterable { + const history = await listAllSessionEventHistory(adapter, sessionId); + const markerIndex = history.findIndex((event) => event.id === afterId); + if (markerIndex < 0) { + throw new UserError(`Session event '${afterId}' was not found; list session events and choose a valid event id.`); + } + + const knownEventKeys = new Set(history.slice(0, markerIndex + 1).map(sessionEventKey)); + for (const event of history.slice(markerIndex + 1)) { + const eventKey = sessionEventKey(event); + if (knownEventKeys.has(eventKey)) continue; + knownEventKeys.add(eventKey); + yield event; + if (event.type === "status" && isTerminalSessionStatus(event.status)) return; + } + + while (true) { + const newEvents = await listNewSessionEvents(adapter, sessionId, knownEventKeys); + for (const event of newEvents) { + const eventKey = sessionEventKey(event); + if (knownEventKeys.has(eventKey)) continue; + knownEventKeys.add(eventKey); + yield event; + if (event.type === "status" && isTerminalSessionStatus(event.status)) return; + } + const session = await adapter.getSession(sessionId); + if (isTerminalSessionStatus(session.status)) return; + await delay(DEFAULT_POLL_INTERVAL_MS); + } +} + +async function listAllSessionEventHistory( + adapter: SessionWorkflowAdapter, + sessionId: string, +): Promise { + const events: ProviderSessionEvent[] = []; + const seenPageTokens = new Set(); + let pageToken: string | undefined; + while (true) { + const result = await adapter.listSessionEvents(sessionId, { + limit: 100, + order: "asc", + ...(pageToken ? { page_token: pageToken } : {}), + }); + events.push(...result.events); + if (!result.next_page) return events; + if (seenPageTokens.has(result.next_page)) { + throw new UserError("Session event pagination returned a repeated cursor."); + } + seenPageTokens.add(result.next_page); + pageToken = result.next_page; + } +} + +async function listNewSessionEvents( + adapter: SessionWorkflowAdapter, + sessionId: string, + knownEventKeys: ReadonlySet, +): Promise { + const descendingEvents: ProviderSessionEvent[] = []; + const seenPageTokens = new Set(); + let pageToken: string | undefined; + while (true) { + const result = await adapter.listSessionEvents(sessionId, { + limit: 100, + order: "desc", + ...(pageToken ? { page_token: pageToken } : {}), + }); + let reachedKnownEvent = false; + for (const event of result.events) { + if (knownEventKeys.has(sessionEventKey(event))) { + reachedKnownEvent = true; + break; + } + descendingEvents.push(event); + } + if (reachedKnownEvent || !result.next_page) return descendingEvents.reverse(); + if (seenPageTokens.has(result.next_page)) { + throw new UserError("Session event pagination returned a repeated cursor."); + } + seenPageTokens.add(result.next_page); + pageToken = result.next_page; + } +} + +function sessionEventKey(event: ProviderSessionEvent): string { + return event.id ?? `raw:${event.raw_type}:${JSON.stringify(event.raw)}`; } function resolveDirectAdapter(ctx: ProjectRuntimeContext, overrideProvider?: string): SessionRuntimeAdapter { diff --git a/packages/sdk/src/internal/core/skill-source.ts b/packages/sdk/src/internal/core/skill-source.ts new file mode 100644 index 0000000..a1958eb --- /dev/null +++ b/packages/sdk/src/internal/core/skill-source.ts @@ -0,0 +1,71 @@ +import { readFileSync, statSync } from "node:fs"; +import { basename, resolve } from "node:path"; +import { parse } from "yaml"; +import { UserError } from "../errors.ts"; +import type { SkillFile } from "../types/skill-file.ts"; +import { collectFiles } from "../utils/collect-files.ts"; +import { extractSkillZipFiles } from "../utils/normalize-skill-zip.ts"; + +export interface SkillSourceInspection { + name: string; + sourcePath: string; + files: SkillFile[]; +} + +/** Inspect and normalize a local Skill directory, zip, or single SKILL.md. */ +export async function inspectSkillSource( + source: string, + options: { basePath?: string } = {}, +): Promise { + if (/^https?:\/\//i.test(source)) { + throw new UserError("Skill source inspection only accepts a local directory, zip, or SKILL.md file."); + } + const sourcePath = resolve(options.basePath ?? process.cwd(), source); + const sourceStat = statSync(sourcePath, { throwIfNoEntry: false }); + if (!sourceStat) throw new UserError(`Skill source not found: ${source}`); + + let files: SkillFile[]; + if (sourceStat.isDirectory()) { + files = collectFiles(sourcePath, ""); + } else if (sourceStat.isFile() && sourcePath.toLowerCase().endsWith(".zip")) { + files = await extractSkillZipFiles(readFileSync(sourcePath)); + } else if (sourceStat.isFile() && basename(sourcePath).toLowerCase() === "skill.md") { + files = [{ relativePath: "SKILL.md", content: readFileSync(sourcePath) }]; + } else { + throw new UserError("Skill source must be a directory, .zip archive, or SKILL.md file."); + } + + const normalizedFiles = normalizeSkillRoot(files); + const manifest = normalizedFiles.find((file) => file.relativePath === "SKILL.md")!; + const name = parseSkillManifestName(manifest.content); + return { name, sourcePath, files: normalizedFiles }; +} + +function normalizeSkillRoot(files: SkillFile[]): SkillFile[] { + if (files.some((file) => file.relativePath === "SKILL.md")) return files; + const manifests = files.filter((file) => file.relativePath.endsWith("/SKILL.md")); + if (manifests.length === 0) throw new UserError("Skill source does not contain SKILL.md."); + if (manifests.length > 1) { + throw new UserError("Skill source contains multiple SKILL.md files and has no unambiguous root."); + } + const prefix = manifests[0]!.relativePath.slice(0, -"SKILL.md".length); + return files + .filter((file) => file.relativePath.startsWith(prefix)) + .map((file) => ({ ...file, relativePath: file.relativePath.slice(prefix.length) })); +} + +function parseSkillManifestName(content: Buffer): string { + const text = content.toString("utf8"); + const frontmatter = text.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\s*\r?\n|$)/); + if (!frontmatter) throw new UserError("SKILL.md must start with YAML frontmatter containing a name."); + let manifest: unknown; + try { + manifest = parse(frontmatter[1]!); + } catch (error) { + throw new UserError(`Invalid SKILL.md frontmatter: ${error instanceof Error ? error.message : String(error)}`); + } + const rawName = manifest && typeof manifest === "object" ? (manifest as Record).name : undefined; + const name = typeof rawName === "string" ? rawName.trim() : ""; + if (!name) throw new UserError("SKILL.md frontmatter must contain a non-empty name."); + return name; +} diff --git a/packages/sdk/src/internal/core/vault-credential-runtime.ts b/packages/sdk/src/internal/core/vault-credential-runtime.ts new file mode 100644 index 0000000..790bb62 --- /dev/null +++ b/packages/sdk/src/internal/core/vault-credential-runtime.ts @@ -0,0 +1,207 @@ +import { UserError } from "../errors.ts"; +import { computeResourceHash } from "../planner/hasher.ts"; +import type { ProviderAdapter } from "../providers/interface.ts"; +import type { CredentialDecl, ResolvedProjectConfig } from "../types/config.ts"; +import type { VaultCredentialInfo } from "../types/managed-api.ts"; +import type { ResourceAddress, ResourceState } from "../types/state.ts"; +import type { BackendRuntimeInput, ProjectRuntimeContext } from "./project-runtime.ts"; +import { getRuntimeProvider, readProjectRuntime, writeProjectRuntime } from "./project-runtime.ts"; +import { planProjectContext } from "./resource-runtime.ts"; + +export interface VaultCredentialCreateOptions { + provider?: string; + refresh?: boolean; + quiet?: boolean; + /** Set false only for an offline preview; create always requires the remote duplicate check. */ + checkRemote?: boolean; +} + +export interface VaultCredentialCreatePlan { + provider: string; + vault: string; + vaultRemoteId: string; + credential: string; + remoteChecked: boolean; + reuseRemote: boolean; + remoteCredentialId?: string; +} + +export interface VaultCredentialCreateResult extends VaultCredentialCreatePlan { + credentialId: string; + adopted: boolean; +} + +type VaultCredentialAdapter = Pick; + +export async function planVaultCredentialCreate( + ctx: ProjectRuntimeContext, + vaultName: string, + credentialName: string, + options: VaultCredentialCreateOptions = {}, +): Promise { + const prepared = await prepareVaultCredentialCreate(ctx, vaultName, credentialName, options); + return { + provider: prepared.provider, + vault: vaultName, + vaultRemoteId: prepared.vaultState.remote_id!, + credential: credentialName, + remoteChecked: prepared.remoteChecked, + reuseRemote: Boolean(prepared.remoteCredential), + remoteCredentialId: prepared.remoteCredential?.id, + }; +} + +export async function planVaultCredentialCreateWithStateBackend( + input: BackendRuntimeInput, + vaultName: string, + credentialName: string, + options: VaultCredentialCreateOptions = {}, +): Promise { + return readProjectRuntime(input, (ctx) => planVaultCredentialCreate(ctx, vaultName, credentialName, options)); +} + +export async function createVaultCredential( + ctx: ProjectRuntimeContext, + vaultName: string, + credentialName: string, + options: VaultCredentialCreateOptions = {}, +): Promise { + if (options.checkRemote === false) { + throw new UserError("Vault Credential create requires the remote duplicate check."); + } + const prepared = await prepareVaultCredentialCreate(ctx, vaultName, credentialName, options); + const remote = + prepared.remoteCredential ?? + (await prepared.adapter.createCredential!(prepared.vaultState.remote_id!, prepared.credential)); + if (!remote.id) throw new UserError("Credential create returned no remote id."); + + const address: ResourceAddress = { type: "vault", name: vaultName, provider: prepared.provider }; + const desiredHash = await computeResourceHash(address, ctx.config, ctx.configPath, ctx.state); + ctx.state.setResource({ + ...prepared.vaultState, + content_hash: desiredHash, + desired_hash: desiredHash, + drift_paths: [], + drift_status: "unchecked", + }); + await ctx.state.save(); + + return { + provider: prepared.provider, + vault: vaultName, + vaultRemoteId: prepared.vaultState.remote_id!, + credential: credentialName, + remoteChecked: prepared.remoteChecked, + credentialId: remote.id, + reuseRemote: Boolean(prepared.remoteCredential), + remoteCredentialId: prepared.remoteCredential?.id, + adopted: Boolean(prepared.remoteCredential), + }; +} + +export async function createVaultCredentialWithStateBackend( + input: BackendRuntimeInput, + vaultName: string, + credentialName: string, + options: VaultCredentialCreateOptions = {}, +): Promise { + return writeProjectRuntime(input, (ctx) => createVaultCredential(ctx, vaultName, credentialName, options)); +} + +async function prepareVaultCredentialCreate( + ctx: ProjectRuntimeContext, + vaultName: string, + credentialName: string, + options: VaultCredentialCreateOptions, +): Promise<{ + provider: string; + credential: CredentialDecl; + vaultState: ResourceState; + adapter: VaultCredentialAdapter; + remoteChecked: boolean; + remoteCredential?: VaultCredentialInfo; +}> { + const vault = ctx.config.vaults?.[vaultName]; + if (!vault) throw new UserError(`Vault '${vaultName}' is not declared in the project config.`); + const provider = resolveVaultProvider(ctx, vault.provider, options.provider); + const credential = vault.credentials.at(-1); + if (!credential || credential.name !== credentialName) { + throw new UserError(`Credential '${credentialName}' must be the final newly appended entry in vault.${vaultName}.`); + } + if (vault.credentials.slice(0, -1).some((entry) => entry.name === credentialName)) { + throw new UserError(`Vault '${vaultName}' already declares a credential named '${credentialName}'.`); + } + + const address: ResourceAddress = { type: "vault", name: vaultName, provider }; + const priorConfig = structuredClone(ctx.config) as ResolvedProjectConfig; + priorConfig.vaults![vaultName] = { + ...priorConfig.vaults![vaultName]!, + credentials: priorConfig.vaults![vaultName]!.credentials.slice(0, -1), + }; + const priorPlan = await planProjectContext( + { ...ctx, config: priorConfig }, + { + provider, + scope: { roots: [address] }, + refresh: options.refresh, + quiet: options.quiet ?? true, + }, + ); + const refreshError = priorPlan.refreshResult?.errors[0]; + if (refreshError) { + throw new UserError(`Cannot verify vault.${vaultName} because refresh failed: ${refreshError.error}`); + } + const errorDiagnostic = priorPlan.plan.diagnostics.find((diagnostic) => diagnostic.severity === "error"); + if (errorDiagnostic) throw new UserError(errorDiagnostic.message); + const vaultAction = priorPlan.plan.actions.find( + (action) => action.address.type === "vault" && action.address.name === vaultName, + ); + if (vaultAction?.action !== "no-op") { + throw new UserError( + `vault.${vaultName} must be tracked and up-to-date before adding a credential; current action is '${vaultAction?.action ?? "missing"}'.`, + ); + } + const vaultState = ctx.state.getResource(address); + if (!vaultState?.remote_id) throw new UserError(`vault.${vaultName} has no tracked remote id.`); + + const adapter = getRuntimeProvider(ctx, provider); + if (!adapter.createCredential || !adapter.listCredentials) { + throw new UserError(`Provider '${provider}' does not support scoped Vault Credential create.`); + } + const remoteChecked = options.checkRemote !== false; + const sameName = remoteChecked + ? (await adapter.listCredentials(vaultState.remote_id)).filter( + (remoteCredential) => remoteCredential.display_name === credentialName, + ) + : []; + const exact = sameName.find((remoteCredential) => credentialMatches(remoteCredential, credential)); + if (sameName.length > 0 && !exact) { + throw new UserError(`Vault '${vaultName}' already has a different remote credential named '${credentialName}'.`); + } + return { provider, credential, vaultState, adapter, remoteChecked, remoteCredential: exact }; +} + +function resolveVaultProvider( + ctx: ProjectRuntimeContext, + declaredProvider: string | undefined, + requestedProvider: string | undefined, +): string { + if (requestedProvider && declaredProvider && requestedProvider !== declaredProvider) { + throw new UserError( + `Vault provider '${declaredProvider}' does not match requested provider '${requestedProvider}'.`, + ); + } + const provider = requestedProvider ?? declaredProvider ?? ctx.config.defaults?.provider; + if (provider && provider !== "all") return provider; + const configured = [...ctx.providers.keys()]; + if (configured.length === 1) return configured[0]!; + throw new UserError("Cannot infer one provider for Vault Credential create."); +} + +function credentialMatches(remote: VaultCredentialInfo, desired: CredentialDecl): boolean { + return ( + remote.auth_type === desired.type && + remote.secret_name === desired.secret_name && + (remote.networking_type ?? "unrestricted") === (desired.networking?.type ?? "unrestricted") + ); +} diff --git a/packages/sdk/src/internal/executor/skill-resolver.ts b/packages/sdk/src/internal/executor/skill-resolver.ts index f49c263..a231ac3 100644 --- a/packages/sdk/src/internal/executor/skill-resolver.ts +++ b/packages/sdk/src/internal/executor/skill-resolver.ts @@ -1,9 +1,8 @@ -import { readFileSync, statSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { dirname } from "node:path"; +import { inspectSkillSource } from "../core/skill-source.ts"; import { resolveFetch } from "../transport.ts"; import type { SkillDecl } from "../types/config.ts"; import type { SkillFile } from "../types/skill-file.ts"; -import { collectFiles } from "../utils/collect-files.ts"; import { extractSkillZipFiles } from "../utils/normalize-skill-zip.ts"; import type { ExecContext } from "./context.ts"; @@ -18,20 +17,5 @@ export async function resolveSkillFiles(decl: SkillDecl, ctx: ExecContext): Prom if (!ctx.configPath) return []; - const sourcePath = resolve(dirname(ctx.configPath), decl.source); - const stat = statSync(sourcePath, { throwIfNoEntry: false }); - - if (stat?.isDirectory()) { - return collectFiles(sourcePath, ""); - } - - if (stat?.isFile()) { - if (sourcePath.endsWith(".zip")) { - return extractSkillZipFiles(readFileSync(sourcePath)); - } - const content = readFileSync(sourcePath); - return [{ relativePath: "SKILL.md", content }]; - } - - return []; + return (await inspectSkillSource(decl.source, { basePath: dirname(ctx.configPath) })).files; } diff --git a/packages/sdk/src/internal/graph/dependency.ts b/packages/sdk/src/internal/graph/dependency.ts index e4e5226..69ce40b 100644 --- a/packages/sdk/src/internal/graph/dependency.ts +++ b/packages/sdk/src/internal/graph/dependency.ts @@ -11,6 +11,24 @@ export interface DependencyGraph { edges: Map>; } +/** Resolve explicit resource roots plus every declared transitive dependency. */ +export function collectDependencyClosure(graph: DependencyGraph, roots: readonly ResourceAddress[]): ResourceAddress[] { + const selected = new Map(); + + function visit(address: ResourceAddress): void { + const key = addressKey(address); + if (selected.has(key)) return; + selected.set(key, graph.nodes.get(key) ?? address); + for (const dependencyKey of graph.edges.get(key) ?? []) { + const dependency = graph.nodes.get(dependencyKey); + if (dependency) visit(dependency); + } + } + + for (const root of roots) visit(root); + return [...selected.values()]; +} + export function buildDependencyGraph(config: ProjectConfig, targetProviders: string[]): DependencyGraph { const nodes = new Map(); const edges = new Map>(); diff --git a/packages/sdk/src/internal/parser/schema.ts b/packages/sdk/src/internal/parser/schema.ts index 8275d2c..8c27138 100644 --- a/packages/sdk/src/internal/parser/schema.ts +++ b/packages/sdk/src/internal/parser/schema.ts @@ -49,6 +49,7 @@ const coerceString = z.union([z.string(), z.number()]).transform(String); const staticBearerCredentialSchema = z.object({ name: z.string(), type: z.literal("static_bearer"), + metadata: z.record(z.string(), z.string()).optional(), mcp_server_url: z.string(), access_token: coerceString, protocol: z.enum(["sse", "streamable_http"]).optional(), @@ -57,6 +58,7 @@ const staticBearerCredentialSchema = z.object({ const environmentVariableCredentialSchema = z.object({ name: z.string(), type: z.literal("environment_variable"), + metadata: z.record(z.string(), z.string()).optional(), secret_name: z.string(), secret_value: coerceString, networking: z.object({ type: z.enum(["unrestricted", "limited"]) }).optional(), @@ -337,6 +339,7 @@ const scheduleSchema = z.object({ }); const deploymentSchema = z.object({ + name: z.string().optional(), agent: z.string(), agent_version: z.number().int().optional(), environment: z.string().optional(), diff --git a/packages/sdk/src/internal/planner/planner.ts b/packages/sdk/src/internal/planner/planner.ts index 999b972..4e48336 100644 --- a/packages/sdk/src/internal/planner/planner.ts +++ b/packages/sdk/src/internal/planner/planner.ts @@ -17,6 +17,7 @@ import { buildReadinessBaseline, classifyReadinessImpact, diffReadinessBaseline export interface PlanOptions { providers?: string[]; configPath?: string; + resourceAddresses?: readonly ResourceAddress[]; } export async function buildPlan( @@ -24,18 +25,23 @@ export async function buildPlan( state: StateFile, options: PlanOptions = {}, ): Promise { + const scopedConfig = options.resourceAddresses ? selectProjectConfig(config, options.resourceAddresses) : config; + const resourceKeys = options.resourceAddresses ? new Set(options.resourceAddresses.map(addressKey)) : undefined; + const scopedState = resourceKeys + ? { ...state, resources: state.resources.filter((resource) => resourceKeys.has(addressKey(resource.address))) } + : state; const diagnostics = new DiagnosticCollector(); const actions: PlannedAction[] = []; - const targetProviders = options.providers ?? resolveTargetProviders(config); - collectReferenceDiagnostics(config, diagnostics); - collectProviderCapabilities(config, targetProviders, diagnostics); + const targetProviders = options.providers ?? resolveTargetProviders(scopedConfig); + collectReferenceDiagnostics(scopedConfig, diagnostics); + collectProviderCapabilities(scopedConfig, targetProviders, diagnostics); - const graph = buildDependencyGraph(config, targetProviders); + const graph = buildDependencyGraph(scopedConfig, targetProviders); const sorted = topologicalSort(graph); const stateIndex = new Map(); - for (const res of state.resources) { + for (const res of scopedState.resources) { stateIndex.set(addressKey(res.address), res); } @@ -43,7 +49,7 @@ export async function buildPlan( // deleted as they are consumed), so deployment hashing can always resolve the // remote ids of managed reference inputs. const remoteIdLookup = new Map(); - for (const res of state.resources) { + for (const res of scopedState.resources) { remoteIdLookup.set(addressKey(res.address), res); } const hashStateLookup = { getResource: (addr: ResourceAddress) => remoteIdLookup.get(addressKey(addr)) }; @@ -234,10 +240,61 @@ export async function buildPlan( }); } - coalesceChannelRenames(actions, config, state); + coalesceChannelRenames(actions, scopedConfig, scopedState); return { actions, diagnostics: diagnostics.getAll() }; } +function selectProjectConfig(config: ProjectConfig, addresses: readonly ResourceAddress[]): ProjectConfig { + const providers = new Set(addresses.map((address) => address.provider)); + const namesByType = new Map>(); + for (const address of addresses) { + const names = namesByType.get(address.type) ?? new Set(); + names.add(address.name); + namesByType.set(address.type, names); + } + + const pick = ( + record: Record | undefined, + names: ReadonlySet | undefined, + ): Record | undefined => { + if (!record || !names?.size) return undefined; + const selected = Object.fromEntries(Object.entries(record).filter(([name]) => names.has(name))); + return Object.keys(selected).length > 0 ? selected : undefined; + }; + const agentNames = new Set([...(namesByType.get("agent") ?? []), ...(namesByType.get("template") ?? [])]); + const selectedAgents = pick(config.agents, agentNames); + const tunnelNames = new Set(); + for (const agent of Object.values(selectedAgents ?? {})) { + if (agent.tunnel) tunnelNames.add(agent.tunnel); + } + for (const deployment of Object.values(pick(config.deployments, namesByType.get("deployment")) ?? {})) { + if (deployment.tunnel) tunnelNames.add(deployment.tunnel); + } + const identityNames = namesByType.get("identity"); + const selectedProviders = Object.fromEntries( + Object.entries(config.providers).filter(([providerName]) => providers.has(providerName)), + ); + const defaultProvider = providers.size === 1 ? [...providers][0] : config.defaults?.provider; + const defaultIdentity = + config.defaults?.identity && identityNames?.has(config.defaults.identity) ? config.defaults.identity : undefined; + + return { + ...config, + providers: selectedProviders, + defaults: defaultProvider || defaultIdentity ? { provider: defaultProvider, identity: defaultIdentity } : undefined, + environments: pick(config.environments, namesByType.get("environment")), + tunnels: pick(config.tunnels, tunnelNames), + vaults: pick(config.vaults, namesByType.get("vault")), + memory_stores: pick(config.memory_stores, namesByType.get("memory_store")), + skills: pick(config.skills, namesByType.get("skill")), + files: pick(config.files, namesByType.get("file")), + identities: pick(config.identities, identityNames), + agents: selectedAgents, + channels: pick(config.channels, namesByType.get("channel")), + deployments: pick(config.deployments, namesByType.get("deployment")), + }; +} + /** * A YAML key is a resource address, but changing that key should not force a * remote Channel replacement when the old and new declarations form one diff --git a/packages/sdk/src/internal/planner/refresh.ts b/packages/sdk/src/internal/planner/refresh.ts index 4f3939a..3cbb6e4 100644 --- a/packages/sdk/src/internal/planner/refresh.ts +++ b/packages/sdk/src/internal/planner/refresh.ts @@ -4,6 +4,7 @@ import type { IStateManager } from "../state/state-manager.ts"; import type { ProjectConfig } from "../types/config.ts"; import { emitRuntimeFeedback, type RuntimeFeedbackSink } from "../types/runtime-feedback.ts"; import type { ResourceState } from "../types/state.ts"; +import { addressKey } from "../types/state.ts"; import { contentHash } from "../utils/hash.ts"; import { getResourceDeclaration } from "./declaration.ts"; import { diffChangedPaths } from "./plan-semantics.ts"; @@ -28,6 +29,7 @@ export async function refreshState( providers: ReadonlyMap>, options: { targetProviders?: string[]; + resourceKeys?: ReadonlySet; config?: ProjectConfig; quiet?: boolean; onFeedback?: RuntimeFeedbackSink; @@ -39,6 +41,9 @@ export async function refreshState( let dirty = false; for (const res of resources) { + if (options.resourceKeys && !options.resourceKeys.has(addressKey(res.address))) { + continue; + } // Skip resources from providers not in scope if (options.targetProviders && !options.targetProviders.includes(res.address.provider)) { continue; diff --git a/packages/sdk/src/internal/providers/ark/mapper.ts b/packages/sdk/src/internal/providers/ark/mapper.ts index 265b3ef..15cb284 100644 --- a/packages/sdk/src/internal/providers/ark/mapper.ts +++ b/packages/sdk/src/internal/providers/ark/mapper.ts @@ -226,7 +226,7 @@ export function mapAgent( const modelValue = typeof arkModel === "string" ? { id: arkModel } : { id: arkModel.id }; const body: Record = { - name, + name: decl.name ?? name, model: modelValue, system: decl.instructions, }; diff --git a/packages/sdk/src/internal/providers/bailian/adapter.ts b/packages/sdk/src/internal/providers/bailian/adapter.ts index 5d779dd..89a300c 100644 --- a/packages/sdk/src/internal/providers/bailian/adapter.ts +++ b/packages/sdk/src/internal/providers/bailian/adapter.ts @@ -22,6 +22,30 @@ import type { } from "../../types/config.ts"; import type { CloudAgent, CloudEnvironment, CloudVault } from "../../types/dto.ts"; import type { ProviderFileInfo } from "../../types/file.ts"; +import type { + AgentListOptions, + AgentPage, + AgentVersionListOptions, + CursorListOptions, + DeploymentRunInfo, + DeploymentRunPage, + EnvironmentListOptions, + EnvironmentPage, + FileListOptions, + FilePage, + SessionEventInput, + SessionEventSendResult, + SessionUpdateInput, + SkillDownloadInfo, + SkillListOptions, + SkillPage, + SkillVersionInfo, + SkillVersionListOptions, + SkillVersionPage, + VaultCredentialInfo, + VaultListOptions, + VaultPage, +} from "../../types/managed-api.ts"; import type { ProviderSessionInfo, SessionBindings, SessionFilter, SessionListResult } from "../../types/session.ts"; import type { EventListOptions, @@ -165,6 +189,7 @@ export class BailianAdapter implements ProviderAdapter { } return compactDeep({ + name: raw.name, description: raw.description, model: normalizeBailianModel(raw.model), instructions: raw.system, @@ -202,6 +227,20 @@ export class BailianAdapter implements ProviderAdapter { await this.client.delete(`/environments/${id}`); } + async listEnvironmentResources(options?: EnvironmentListOptions): Promise { + const params = cursorParams(options); + if (options?.include_archived !== undefined) { + params.set("include_archived", String(options.include_archived)); + } + const res = (await this.client.get(withQuery("/environments", params))) as Record; + return cursorPage(res, toCloudEnvironment); + } + + async getRemoteEnvironment(id: string): Promise { + const res = (await this.client.get(`/environments/${id}`)) as Record; + return toCloudEnvironment(res); + } + // --- Agent --- async createAgent(name: string, decl: AgentDecl, refs: ResolvedAgentRefs): Promise { @@ -272,6 +311,28 @@ export class BailianAdapter implements ProviderAdapter { await this.client.post(`/agents/${id}/archive`, {}); } + async listAgentResources(options?: AgentListOptions): Promise { + const params = cursorParams(options); + if (options?.include_archived !== undefined) { + params.set("include_archived", String(options.include_archived)); + } + const res = (await this.client.get(withQuery("/agents", params))) as Record; + return cursorPage(res, toCloudAgent); + } + + async getRemoteAgent(id: string, version?: number): Promise { + const params = new URLSearchParams(); + if (version !== undefined) params.set("version", String(version)); + const res = (await this.client.get(withQuery(`/agents/${id}`, params))) as Record; + return toCloudAgent(res); + } + + async listAgentVersions(id: string, options?: AgentVersionListOptions): Promise { + const params = cursorParams(options); + const res = (await this.client.get(withQuery(`/agents/${id}/versions`, params))) as Record; + return cursorPage(res, toCloudAgent); + } + // --- Skill (2-step: Files API → Skills API) --- async createSkill(name: string, decl: SkillDecl, files: SkillFile[]): Promise { @@ -385,11 +446,39 @@ export class BailianAdapter implements ProviderAdapter { return all.map(toBailianSkillInfo); } + async listSkillResources(options?: SkillListOptions): Promise { + const params = cursorParams(options); + if (options?.source === "official") params.set("source", "official"); + if (options?.source === "custom") params.set("source", "customer"); + const res = (await this.client.get(withQuery("/skills", params))) as Record; + return cursorPage(res, toBailianSkillInfo); + } + async getSkillInfo(id: string): Promise { const res = (await this.client.get(`/skills/${id}`)) as Record; return toBailianSkillInfo(res); } + async listSkillVersions(id: string, options?: SkillVersionListOptions): Promise { + const params = cursorParams(options); + const res = (await this.client.get(withQuery(`/skills/${id}/versions`, params))) as Record; + return cursorPage(res, toSkillVersionInfo); + } + + async getSkillVersion(id: string, version: string): Promise { + const res = (await this.client.get(`/skills/${id}/versions/${encodeURIComponent(version)}`)) as Record< + string, + unknown + >; + return toSkillVersionInfo(res); + } + + async getSkillDownloadInfo(id: string, version: string): Promise { + return (await this.client.get( + `/skills/${id}/versions/${encodeURIComponent(version)}/content`, + )) as SkillDownloadInfo; + } + // Non-blocking create: file already uploaded → POST /skills with retry for residual OSS // replication lag (postSkillWithRetry covers SKILL_FILE_NOT_FOUND for a few seconds), and return // the initial status WITHOUT waiting for the security scan. Callers MUST ensure the file has @@ -489,6 +578,20 @@ export class BailianAdapter implements ProviderAdapter { return all.map(toCloudVault); } + async listVaultResources(options?: VaultListOptions): Promise { + const params = cursorParams(options); + if (options?.include_archived !== undefined) { + params.set("include_archived", String(options.include_archived)); + } + const res = (await this.client.get(withQuery("/vaults", params))) as Record; + return cursorPage(res, toCloudVault); + } + + async getRemoteVault(id: string): Promise { + const res = (await this.client.get(`/vaults/${id}`)) as Record; + return toCloudVault(res); + } + async getVault(id: string): Promise> { return (await this.client.get(`/vaults/${id}`)) as Record; } @@ -520,9 +623,25 @@ export class BailianAdapter implements ProviderAdapter { return toRemoteResource(res); } - async listCredentials(vaultId: string): Promise { + async listCredentials(vaultId: string): Promise { const all = await this.client.getAllPaged(`/vaults/${vaultId}/credentials`); - return all.map(toRemoteResource); + return all.map((raw) => { + const auth = (raw.auth ?? {}) as Record; + const networking = auth.networking as Record | undefined; + const metadata = raw.metadata as Record | undefined; + return { + id: raw.id as string, + display_name: (raw.display_name as string) ?? (raw.id as string), + auth_type: (auth.type as string) ?? "", + secret_name: auth.secret_name as string | undefined, + networking_type: networking?.type as string | undefined, + metadata: metadata + ? Object.fromEntries( + Object.entries(metadata).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ) + : undefined, + }; + }); } async getCredential(vaultId: string, credentialId: string): Promise> { @@ -607,11 +726,7 @@ export class BailianAdapter implements ProviderAdapter { 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, - }; + return toDeploymentRunResult(res); } async getDeployment(ctx: DeploymentContext): Promise { @@ -625,6 +740,7 @@ export class BailianAdapter implements ProviderAdapter { async listDeployments(filter?: DeploymentListFilter): Promise { const params = new URLSearchParams(); if (filter?.agent_id) params.set("agent_id", filter.agent_id); + if (filter?.keyword) params.set("keyword", filter.keyword); 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)); @@ -642,6 +758,38 @@ export class BailianAdapter implements ProviderAdapter { }; } + async getDeploymentById(id: string): Promise { + const res = (await this.client.get(`/deployments/${id}`)) as Record; + return toDeploymentInfo(res); + } + + async runDeploymentById(id: string): Promise { + const res = (await this.client.post(`/deployments/${id}/run`, {})) as Record; + return toDeploymentRunResult(res); + } + + async pauseDeploymentById(id: string): Promise { + const res = (await this.client.post(`/deployments/${id}/pause`, {})) as Record; + return toDeploymentInfo(res); + } + + async unpauseDeploymentById(id: string): Promise { + const res = (await this.client.post(`/deployments/${id}/unpause`, {})) as Record; + return toDeploymentInfo(res); + } + + async listDeploymentRuns(deploymentId: string, options?: CursorListOptions): Promise { + const res = (await this.client.get( + withQuery(`/deployments/${deploymentId}/runs`, cursorParams(options)), + )) as Record; + return cursorPage(res, toDeploymentRunInfo); + } + + async getDeploymentRun(runId: string): Promise { + const res = (await this.client.get(`/deployment_runs/${runId}`)) as Record; + return toDeploymentRunInfo(res); + } + async pauseDeployment(ctx: DeploymentContext): Promise { return this.setDeploymentPaused(ctx, true); } @@ -722,6 +870,9 @@ export class BailianAdapter implements ProviderAdapter { async listSessions(filter?: SessionFilter): Promise { const params = new URLSearchParams(); if (filter?.agent_id) params.set("agent_id", filter.agent_id); + for (const status of filter?.statuses ?? []) params.append("statuses[]", status); + 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); if (filter?.limit) params.set("limit", String(filter.limit)); if (filter?.page) params.set("page", filter.page); const qs = params.toString(); @@ -740,6 +891,20 @@ export class BailianAdapter implements ProviderAdapter { return toSessionInfo(res); } + async updateSession(id: string, input: SessionUpdateInput): Promise { + const res = (await this.client.post(`/sessions/${id}`, input)) as Record; + // The documented update response may be partial (id/status/title/metadata only). + // Normalize callers onto the full Session DTO by following with GET when the + // binding snapshot is absent. + if (!res.agent || !res.environment_id) return this.getSession(id); + return toSessionInfo(res); + } + + async archiveSession(id: string): Promise { + const res = (await this.client.post(`/sessions/${id}/archive`, {})) as Record; + return toSessionInfo(res); + } + async deleteSession(id: string): Promise { await this.client.delete(`/sessions/${id}`); } @@ -750,6 +915,14 @@ export class BailianAdapter implements ProviderAdapter { return extractCreatedEventId(res); } + async sendSessionEvents(sessionId: string, events: SessionEventInput[]): Promise { + const res = (await this.client.post(`/sessions/${sessionId}/events`, { input: events })) as Record; + return { + event_ids: extractCreatedEventIds(res), + attributes: res, + }; + } + async *streamSessionEvents(sessionId: string, _options?: EventStreamOptions): AsyncIterable { // AGENTSTUDIO's stream pushes only post-connect events and accepts no cursor // param; reconnection is done by replaying history via listSessionEvents and @@ -764,7 +937,7 @@ export class BailianAdapter implements ProviderAdapter { // http 200 with the full unfiltered list rather than rejecting), so forwarding it // buys nothing; it is never sent (mirrors claude). Shared page-cursor handling // lives in listSessionEventsPaged. - return listSessionEventsPaged(this.client, sessionId, options, toSessionEvent); + return listSessionEventsPaged(this.client, sessionId, options, toSessionEvent, { forwardTypes: false }); } // --- Files --- @@ -800,6 +973,17 @@ export class BailianAdapter implements ProviderAdapter { return all.map(toRestFileInfo); } + async listFileResources(options?: FileListOptions): Promise { + const params = cursorParams(options); + if (options?.scope_id) params.set("scope_id", options.scope_id); + const res = (await this.client.get(withQuery("/files", params))) as Record; + return cursorPage(res, toRestFileInfo); + } + + async downloadFileContent(id: string): Promise { + return new Uint8Array(await this.client.getBuffer(`/files/${id}/content`)); + } + async deleteFile(id: string): Promise { await this.client.delete(`/files/${id}`); } @@ -844,9 +1028,84 @@ export function toBailianSkillInfo(res: Record): ProviderSkillI latest_version: res.latest_version as string | undefined, created_at: res.created_at as string | undefined, updated_at: res.updated_at as string | undefined, + attributes: res, + }; +} + +function cursorParams(options?: CursorListOptions): URLSearchParams { + const params = new URLSearchParams(); + if (options?.limit !== undefined) params.set("limit", String(options.limit)); + if (options?.page) params.set("page", options.page); + return params; +} + +function withQuery(path: string, params: URLSearchParams): string { + const query = params.toString(); + return query ? `${path}?${query}` : path; +} + +function cursorPage(res: Record, mapper: (raw: Record) => T) { + const nextPage = (res.next_page as string | null | undefined) ?? undefined; + return { + data: ((res.data as Record[] | undefined) ?? []).map(mapper), + has_more: (res.has_more as boolean | undefined) ?? nextPage !== undefined, + next_page: nextPage, + }; +} + +function toSkillVersionInfo(res: Record): SkillVersionInfo { + return { + id: res.id as string | undefined, + skill_id: (res.skill_id as string) ?? "", + type: (res.type as string) ?? "skill_version", + name: res.name as string | undefined, + description: res.description as string | undefined, + version: (res.version as string) ?? "", + status: res.status as string | undefined, + created_at: res.created_at as string | undefined, + updated_at: res.updated_at as string | undefined, + additional_properties: res.additional_properties as Record | undefined, + attributes: res, + }; +} + +function toDeploymentRunResult(res: Record): DeploymentRunResult { + return { + run_id: res.id as string | undefined, + session_id: (res.session_id as string | null | undefined) ?? null, + error: (res.error as { type: string; message: string } | null | undefined) ?? undefined, + }; +} + +function toDeploymentRunInfo(res: Record): DeploymentRunInfo { + return { + id: (res.id as string) ?? "", + deployment_id: res.deployment_id as string | undefined, + session_id: (res.session_id as string | null | undefined) ?? undefined, + status: res.status as string | undefined, + error: (res.error as DeploymentRunInfo["error"] | null | undefined) ?? undefined, + created_at: res.created_at as string | undefined, + updated_at: res.updated_at as string | undefined, + attributes: res, }; } +function extractCreatedEventIds(res: Record): string[] { + const ids = new Set(); + const topLevel = extractCreatedEventId(res); + if (topLevel) ids.add(topLevel); + for (const key of ["data", "events"]) { + const items = res[key]; + if (!Array.isArray(items)) continue; + for (const item of items) { + if (!item || typeof item !== "object") continue; + const id = (item as Record).id; + if (typeof id === "string") ids.add(id); + } + } + return Array.from(ids); +} + function normalizeBailianModel(value: unknown): unknown { if (value && typeof value === "object" && "id" in value) { return (value as Record).id; diff --git a/packages/sdk/src/internal/providers/bailian/event-mapper.ts b/packages/sdk/src/internal/providers/bailian/event-mapper.ts index fcdad88..c3de68b 100644 --- a/packages/sdk/src/internal/providers/bailian/event-mapper.ts +++ b/packages/sdk/src/internal/providers/bailian/event-mapper.ts @@ -27,6 +27,8 @@ export function toSessionEvent(raw: Record): ProviderSessionEve const event: ProviderSessionEvent = { type, raw_type: rawType, raw }; if (typeof raw.id === "string") event.id = raw.id; if (typeof raw.role === "string") event.role = raw.role; + const threadId = extractSessionThreadId(raw); + if (threadId) event.session_thread_id = threadId; if (type === "message") { // Plain `message` events carry `role`, but the `thread_message_sent` / @@ -67,6 +69,18 @@ export function toSessionEvent(raw: Record): ProviderSessionEve return event; } +function extractSessionThreadId(raw: Record): string | undefined { + if (typeof raw.session_thread_id === "string") return raw.session_thread_id; + const metadata = raw.metadata; + if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) { + const value = (metadata as Record).session_thread_id; + if (typeof value === "string") return value; + } + const data = firstContentData(raw); + if (typeof data?.session_thread_id === "string") return data.session_thread_id; + return undefined; +} + function extractContentText(raw: Record): string { const content = raw.content; if (typeof content === "string") return content; diff --git a/packages/sdk/src/internal/providers/bailian/mapper.ts b/packages/sdk/src/internal/providers/bailian/mapper.ts index 6a3c0bd..e348a47 100644 --- a/packages/sdk/src/internal/providers/bailian/mapper.ts +++ b/packages/sdk/src/internal/providers/bailian/mapper.ts @@ -55,7 +55,7 @@ export function mapCredential(cred: CredentialDecl): unknown { // Bailian's credentials API currently only accepts `environment_variable` // authType; `static_bearer` is rejected with CREDENTIAL_AUTH_TYPE_ERROR. if (cred.type === "environment_variable") { - return { + const body: Record = { auth: { type: "environment_variable", secret_name: cred.secret_name, @@ -64,8 +64,10 @@ export function mapCredential(cred: CredentialDecl): unknown { }, display_name: cred.name, }; + if (cred.metadata) body.metadata = cred.metadata; + return body; } - return { + const body: Record = { auth: { type: cred.type, token: cred.access_token, @@ -73,6 +75,8 @@ export function mapCredential(cred: CredentialDecl): unknown { }, display_name: cred.name, }; + if (cred.metadata) body.metadata = cred.metadata; + return body; } // --- Reverse mapping (remote -> agents.yaml decl), used by `agents sync` --- @@ -91,6 +95,7 @@ export function credToDecl(raw: Record, vaultName: string): Cre return { name, type: "static_bearer", + metadata: stripAgentsMetadata(raw.metadata), mcp_server_url: (auth.mcp_server_url as string) ?? "", access_token: placeholder, }; @@ -101,6 +106,7 @@ export function credToDecl(raw: Record, vaultName: string): Cre return { name, type: "environment_variable", + metadata: stripAgentsMetadata(raw.metadata), secret_name: (auth.secret_name as string) ?? name, secret_value: placeholder, networking: networking ?? { type: "unrestricted" }, @@ -194,6 +200,7 @@ export function agentToDecl(raw: Record): Record = { - name, + name: decl.name ?? name, model: { id: modelId }, system: decl.instructions, }; @@ -341,7 +348,7 @@ export function mapDeployment( if (refs.agent_version !== undefined) agent.version = refs.agent_version; const body: Record = { - name, + name: decl.name ?? name, agent, environment_id: refs.environment_id, initial_events: mapMessageEvents(decl.initial_events), diff --git a/packages/sdk/src/internal/providers/claude/mapper.ts b/packages/sdk/src/internal/providers/claude/mapper.ts index 322320b..aa7e049 100644 --- a/packages/sdk/src/internal/providers/claude/mapper.ts +++ b/packages/sdk/src/internal/providers/claude/mapper.ts @@ -234,7 +234,7 @@ export function mapAgent( } const body: Record = { - name, + name: decl.name ?? name, model: modelValue, system: decl.instructions, }; diff --git a/packages/sdk/src/internal/providers/interface.ts b/packages/sdk/src/internal/providers/interface.ts index 108a688..bba493c 100644 --- a/packages/sdk/src/internal/providers/interface.ts +++ b/packages/sdk/src/internal/providers/interface.ts @@ -1,6 +1,7 @@ import type { AgentDecl, ChannelDecl, + CredentialDecl, DefaultMemoryStoreDecl, DeploymentDecl, EnvironmentDecl, @@ -11,6 +12,30 @@ import type { } from "../types/config.ts"; import type { CloudAgent, CloudEnvironment, CloudVault } from "../types/dto.ts"; import type { ProviderFileInfo } from "../types/file.ts"; +import type { + AgentListOptions, + AgentPage, + AgentVersionListOptions, + CursorListOptions, + DeploymentRunInfo, + DeploymentRunPage, + EnvironmentListOptions, + EnvironmentPage, + FileListOptions, + FilePage, + SessionEventInput, + SessionEventSendResult, + SessionUpdateInput, + SkillDownloadInfo, + SkillListOptions, + SkillPage, + SkillVersionInfo, + SkillVersionListOptions, + SkillVersionPage, + VaultCredentialInfo, + VaultListOptions, + VaultPage, +} from "../types/managed-api.ts"; import type { BatchCreateMemoryInput, BatchCreateMemoryResult, @@ -138,6 +163,8 @@ export interface DeploymentInfo { export interface DeploymentListFilter { agent_id?: string; + /** Bailian server-side fuzzy match against deployment id or name. */ + keyword?: string; status?: "active" | "paused"; include_archived?: boolean; limit?: number; @@ -178,13 +205,21 @@ export interface ProviderAdapter { // providers that can enumerate their remote agents implement it. `prefix` filters // by display-name prefix (e.g. "Agents/") server-side where supported. listAgents?(filter?: { prefix?: string; limit?: number }): Promise; + /** Cursor-preserving API listing used by command surfaces. */ + listAgentResources?(options?: AgentListOptions): Promise; + getRemoteAgent?(id: string, version?: number): Promise; + listAgentVersions?(id: string, options?: AgentVersionListOptions): Promise; // Raw cloud environment list (full remote objects). Optional: only providers that can // enumerate their remote environments implement it. Environments are a shared base // resource (sandbox), not tied to any playbook/agent. listEnvironments?(filter?: { limit?: number }): Promise; + listEnvironmentResources?(options?: EnvironmentListOptions): Promise; + getRemoteEnvironment?(id: string): Promise; // Raw cloud vault list (full remote objects). Optional: only providers that can enumerate // their remote vaults implement it. Vaults are a shared credential store, not tied to a playbook. listVaults?(filter?: { limit?: number }): Promise; + listVaultResources?(options?: VaultListOptions): Promise; + getRemoteVault?(id: string): Promise; getDriftSupport?(type: ResourceType): DriftSupport; readComparableResource?( type: ResourceType, @@ -211,6 +246,8 @@ export interface ProviderAdapter { createVault(name: string, decl: VaultDecl, mode?: ProviderResourceMode): Promise; deleteVault(id: string, mode?: ProviderResourceMode): Promise; + createCredential?(vaultId: string, credential: CredentialDecl): Promise; + listCredentials?(vaultId: string): Promise; createSkill(name: string, decl: SkillDecl, files: SkillFile[], mode?: ProviderResourceMode): Promise; updateSkill( @@ -289,6 +326,12 @@ export interface ProviderAdapter { runDeployment(ctx: DeploymentContext): Promise; getDeployment(ctx: DeploymentContext): Promise; listDeployments?(filter?: DeploymentListFilter): Promise; + getDeploymentById?(id: string): Promise; + runDeploymentById?(id: string): Promise; + pauseDeploymentById?(id: string): Promise; + unpauseDeploymentById?(id: string): Promise; + listDeploymentRuns?(deploymentId: string, options?: CursorListOptions): Promise; + getDeploymentRun?(runId: string): Promise; pauseDeployment?(ctx: DeploymentContext): Promise; unpauseDeployment?(ctx: DeploymentContext): Promise; @@ -314,6 +357,8 @@ export interface ProviderAdapter { getFileDownloadUrl?(id: string): Promise<{ url: string; expires_at?: string }>; /** List workspace user-uploaded files (newest first). Optional — only providers with a list API implement it. */ listFiles?(): Promise; + listFileResources?(options?: FileListOptions): Promise; + downloadFileContent?(id: string): Promise; /** * List skills (newest first). `source` selects the catalog: "custom" (workspace-uploaded, the @@ -321,8 +366,12 @@ export interface ProviderAdapter { * list API implement it. */ listSkills?(source?: "custom" | "official"): Promise; + listSkillResources?(options?: SkillListOptions): Promise; /** Fetch a single skill's metadata (incl. scan `status`); used to poll create → active. */ getSkillInfo?(id: string): Promise; + listSkillVersions?(id: string, options?: SkillVersionListOptions): Promise; + getSkillVersion?(id: string, version: string): Promise; + getSkillDownloadInfo?(id: string, version: string): Promise; /** * Create a skill from an already-uploaded zip's file_id and return immediately with the * initial (usually `checking`) status. NON-blocking — unlike `createSkill`, it does NOT wait @@ -333,9 +382,12 @@ export interface ProviderAdapter { createSession(bindings: SessionBindings): Promise; listSessions(filter?: SessionFilter): Promise; getSession(id: string): Promise; + updateSession?(id: string, input: SessionUpdateInput): Promise; + archiveSession?(id: string): Promise; deleteSession(id: string): Promise; sendSessionMessage(sessionId: string, message: string): Promise; + sendSessionEvents?(sessionId: string, events: SessionEventInput[]): Promise; streamSessionEvents(sessionId: string, options?: EventStreamOptions): AsyncIterable; listSessionEvents(sessionId: string, options?: EventListOptions): Promise; diff --git a/packages/sdk/src/internal/providers/qoder/adapter.ts b/packages/sdk/src/internal/providers/qoder/adapter.ts index 718a9ce..7e85083 100644 --- a/packages/sdk/src/internal/providers/qoder/adapter.ts +++ b/packages/sdk/src/internal/providers/qoder/adapter.ts @@ -409,6 +409,7 @@ export class QoderAdapter implements ProviderAdapter { } return compactDeep({ + name: raw.name, description: raw.description, model: normalizeModel(raw.model), instructions: raw.system, diff --git a/packages/sdk/src/internal/providers/qoder/mapper.ts b/packages/sdk/src/internal/providers/qoder/mapper.ts index 50f0101..abf707d 100644 --- a/packages/sdk/src/internal/providers/qoder/mapper.ts +++ b/packages/sdk/src/internal/providers/qoder/mapper.ts @@ -239,6 +239,7 @@ export function agentToDecl(raw: Record): Record = { - name, + name: decl.name ?? name, model, system: decl.instructions, }; diff --git a/packages/sdk/src/internal/providers/session-event-response.ts b/packages/sdk/src/internal/providers/session-event-response.ts index a7658cf..292be0c 100644 --- a/packages/sdk/src/internal/providers/session-event-response.ts +++ b/packages/sdk/src/internal/providers/session-event-response.ts @@ -4,21 +4,28 @@ import type { EventListOptions, ProviderSessionEvent, ProviderSessionEventList } * Shared `listSessionEvents` implementation for the managed-agents-style events * endpoint. All providers paginate with the same opaque forward cursor * (`page` in, `next_page` out) and shape the same `{events, has_more, next_page}` - * response; they differ only in whether the Agents-style `after_id` resume marker - * is forwarded (qoder accepts it; claude/bailian reject it). `has_more` reads the - * server field when present and otherwise derives from `next_page` — equivalent - * for endpoints that omit `has_more`. + * response. Providers differ in whether the Agents-style `after_id` resume marker and + * server-side `types` filtering are supported. `has_more` reads the server field when + * present and otherwise derives from `next_page` — equivalent for endpoints that omit + * `has_more`. */ export async function listSessionEventsPaged( client: { get(path: string): Promise }, sessionId: string, options: EventListOptions | undefined, toEvent: (raw: Record) => ProviderSessionEvent, - config?: { forwardAfterId?: boolean }, + config?: { forwardAfterId?: boolean; forwardTypes?: boolean }, ): Promise { const params = new URLSearchParams(); if (options?.limit) params.set("limit", String(options.limit)); if (options?.order) params.set("order", options.order); + if (config?.forwardTypes !== false) { + for (const type of options?.types ?? []) params.append("types", type); + } + if (options?.created_at_gt) params.set("created_at[gt]", options.created_at_gt); + if (options?.created_at_gte) params.set("created_at[gte]", options.created_at_gte); + if (options?.created_at_lt) params.set("created_at[lt]", options.created_at_lt); + if (options?.created_at_lte) params.set("created_at[lte]", options.created_at_lte); const pageCursor = options?.page_token ?? options?.page; if (pageCursor) params.set("page", pageCursor); if (config?.forwardAfterId && options?.after_id) params.set("after_id", options.after_id); diff --git a/packages/sdk/src/internal/providers/shared.ts b/packages/sdk/src/internal/providers/shared.ts index 148b178..a629838 100644 --- a/packages/sdk/src/internal/providers/shared.ts +++ b/packages/sdk/src/internal/providers/shared.ts @@ -184,6 +184,7 @@ export function toCloudAgent(raw: Record): CloudAgent { created_at: raw.created_at as string | undefined, updated_at: raw.updated_at as string | undefined, archived_at: (raw.archived_at as string | null | undefined) ?? null, + attributes: raw, }; } @@ -202,6 +203,7 @@ export function toCloudEnvironment(raw: Record): CloudEnvironme created_at: raw.created_at as string | undefined, updated_at: raw.updated_at as string | undefined, archived_at: (raw.archived_at as string | null | undefined) ?? null, + attributes: raw, }; } @@ -215,6 +217,7 @@ export function toCloudVault(raw: Record): CloudVault { created_at: (raw.created_at as string | null | undefined) ?? null, updated_at: (raw.updated_at as string | null | undefined) ?? null, archived_at: (raw.archived_at as string | null | undefined) ?? null, + attributes: raw, }; } @@ -233,6 +236,8 @@ export function toRestFileInfo(res: Record): ProviderFileInfo { downloadable: res.downloadable as boolean | undefined, status: res.status as string | undefined, purpose: res.purpose as string | undefined, + scope: res.scope as ProviderFileInfo["scope"], + attributes: res, }; } @@ -255,5 +260,6 @@ export function toRestSkillInfo(res: Record): ProviderSkillInfo latest_version: res.latest_version as string | undefined, created_at: res.created_at as string | undefined, updated_at: res.updated_at as string | undefined, + attributes: res, }; } diff --git a/packages/sdk/src/internal/types/config.ts b/packages/sdk/src/internal/types/config.ts index 1df46f4..6c46d44 100644 --- a/packages/sdk/src/internal/types/config.ts +++ b/packages/sdk/src/internal/types/config.ts @@ -103,6 +103,7 @@ export type CredentialType = "static_bearer" | "environment_variable"; export interface CredentialDecl { name: string; type: CredentialType; + metadata?: Record; // static_bearer mcp_server_url?: string; access_token?: string; @@ -257,6 +258,7 @@ export interface MultiagentDecl { // --- Deployment --- export interface DeploymentDecl { + name?: string; agent: string; agent_version?: number; environment?: string; diff --git a/packages/sdk/src/internal/types/dto.ts b/packages/sdk/src/internal/types/dto.ts index 58ef38d..1fe5c52 100644 --- a/packages/sdk/src/internal/types/dto.ts +++ b/packages/sdk/src/internal/types/dto.ts @@ -136,6 +136,7 @@ export const CloudAgentSchema = z.object({ created_at: z.string().optional(), updated_at: z.string().optional(), archived_at: z.string().nullish(), + attributes: z.record(z.string(), z.unknown()).optional(), }); export type CloudAgent = z.infer; @@ -161,6 +162,7 @@ export const CloudEnvironmentSchema = z.object({ created_at: z.string().optional(), updated_at: z.string().optional(), archived_at: z.string().nullish(), + attributes: z.record(z.string(), z.unknown()).optional(), }); export type CloudEnvironment = z.infer; @@ -180,6 +182,7 @@ export const CloudVaultSchema = z.object({ created_at: z.string().nullish(), updated_at: z.string().nullish(), archived_at: z.string().nullish(), + attributes: z.record(z.string(), z.unknown()).optional(), }); export type CloudVault = z.infer; diff --git a/packages/sdk/src/internal/types/file.ts b/packages/sdk/src/internal/types/file.ts index fa3e360..cf027f5 100644 --- a/packages/sdk/src/internal/types/file.ts +++ b/packages/sdk/src/internal/types/file.ts @@ -7,4 +7,6 @@ export interface ProviderFileInfo { downloadable?: boolean; status?: string; purpose?: string; + scope?: { type?: string; id?: string }; + attributes?: Record; } diff --git a/packages/sdk/src/internal/types/managed-api.ts b/packages/sdk/src/internal/types/managed-api.ts new file mode 100644 index 0000000..81a3e4c --- /dev/null +++ b/packages/sdk/src/internal/types/managed-api.ts @@ -0,0 +1,120 @@ +import type { CloudAgent, CloudEnvironment, CloudVault } from "./dto.ts"; +import type { ProviderFileInfo } from "./file.ts"; +import type { ProviderSkillInfo } from "./skill-info.ts"; + +export interface CursorPage { + data: T[]; + has_more: boolean; + next_page?: string; +} + +export interface CursorListOptions { + limit?: number; + page?: string; +} + +export interface AgentListOptions extends CursorListOptions { + include_archived?: boolean; +} + +export type AgentVersionListOptions = CursorListOptions; + +export type AgentPage = CursorPage; + +export interface EnvironmentListOptions extends CursorListOptions { + include_archived?: boolean; +} + +export type EnvironmentPage = CursorPage; + +export interface VaultListOptions extends CursorListOptions { + include_archived?: boolean; +} + +export type VaultPage = CursorPage; + +export interface VaultCredentialInfo { + id: string; + display_name: string; + auth_type: string; + secret_name?: string; + networking_type?: string; + metadata?: Record; +} + +export interface SkillListOptions extends CursorListOptions { + source?: "custom" | "official"; +} + +export interface SkillVersionInfo { + id?: string; + skill_id: string; + type: string; + name?: string; + description?: string; + version: string; + status?: string; + created_at?: string; + updated_at?: string; + additional_properties?: Record; + attributes: Record; +} + +export type SkillVersionListOptions = CursorListOptions; + +export type SkillVersionPage = CursorPage; + +export interface SkillDownloadInfo { + skill_id: string; + version: string; + file_url: string; +} + +export type SkillPage = CursorPage; + +export interface FileListOptions extends CursorListOptions { + scope_id?: string; +} + +export type FilePage = CursorPage; + +export interface SessionUpdateInput { + title?: string; + metadata?: Record; +} + +/** Raw AgentStudio message/event input. Provider adapters preserve the wire shape. */ +export type SessionEventInput = Record; + +export interface SessionEventSendResult { + event_ids: string[]; + attributes: Record; +} + +export interface DeploymentRunInfo { + id: string; + deployment_id?: string; + session_id?: string | null; + status?: string; + error?: { type?: string; message?: string }; + created_at?: string; + updated_at?: string; + attributes: Record; +} + +export type DeploymentRunPage = CursorPage; + +export type ManagedAgentOperationAuth = "api_key" | "none"; + +export interface ManagedAgentOperationCapability { + supported: boolean; + auth: ManagedAgentOperationAuth | null; + reason?: string; +} + +export interface ManagedAgentProviderCapabilities { + provider: string; + operations: Record; +} + +export type { CloudAgent, CloudEnvironment, CloudVault, ProviderFileInfo, ProviderSkillInfo }; diff --git a/packages/sdk/src/internal/types/session-event.ts b/packages/sdk/src/internal/types/session-event.ts index 631a062..9b94dc4 100644 --- a/packages/sdk/src/internal/types/session-event.ts +++ b/packages/sdk/src/internal/types/session-event.ts @@ -11,6 +11,8 @@ export interface ProviderSessionEvent { tool_input?: string; status?: string; stop_reason?: string; + /** Child-agent thread id when a Managed Agents multi-agent event carries one. */ + session_thread_id?: string; /** * A file the agent delivered to the provider's Files API (qoder DeliverArtifacts / * bailian download_file). Structured so the webui can show a download card without @@ -29,6 +31,11 @@ export interface EventListOptions { page_token?: string; /** Legacy alias for page_token used by some provider adapters. */ page?: string; + types?: string[]; + created_at_gt?: string; + created_at_gte?: string; + created_at_lt?: string; + created_at_lte?: string; } export interface EventStreamOptions { diff --git a/packages/sdk/src/internal/types/session.ts b/packages/sdk/src/internal/types/session.ts index b0c59e4..32cc36c 100644 --- a/packages/sdk/src/internal/types/session.ts +++ b/packages/sdk/src/internal/types/session.ts @@ -65,6 +65,9 @@ export interface ProviderSessionInfo { export interface SessionFilter { agent_id?: string; + statuses?: string[]; + created_at_gte?: string; + created_at_lte?: string; limit?: number; // Opaque forward cursor echoed verbatim from a prior response's `next_page`. page?: string; diff --git a/packages/sdk/src/internal/types/skill-info.ts b/packages/sdk/src/internal/types/skill-info.ts index b2c6d1d..93b87f4 100644 --- a/packages/sdk/src/internal/types/skill-info.ts +++ b/packages/sdk/src/internal/types/skill-info.ts @@ -7,4 +7,5 @@ export interface ProviderSkillInfo { latest_version?: string; created_at?: string; updated_at?: string; + attributes?: Record; } diff --git a/packages/sdk/tests/bailian-skill-info.test.ts b/packages/sdk/tests/bailian-skill-info.test.ts index 9cb1f48..b7113a7 100644 --- a/packages/sdk/tests/bailian-skill-info.test.ts +++ b/packages/sdk/tests/bailian-skill-info.test.ts @@ -3,7 +3,7 @@ import { toBailianSkillInfo } from "../src/internal/providers/bailian/adapter.ts describe("toBailianSkillInfo", () => { it("maps snake_case OpenAPI skill to ProviderSkillInfo", () => { - const info = toBailianSkillInfo({ + const attributes = { id: "skill_1", name: "Agents__demo", description: "d", @@ -12,7 +12,8 @@ describe("toBailianSkillInfo", () => { latest_version: "1", created_at: "2026-06-27T00:00:00Z", updated_at: "2026-06-27T00:00:00Z", - }); + }; + const info = toBailianSkillInfo(attributes); expect(info).toEqual({ id: "skill_1", name: "Agents__demo", @@ -22,6 +23,7 @@ describe("toBailianSkillInfo", () => { latest_version: "1", created_at: "2026-06-27T00:00:00Z", updated_at: "2026-06-27T00:00:00Z", + attributes, }); }); diff --git a/packages/sdk/tests/e2e/bailian-adapter.test.ts b/packages/sdk/tests/e2e/bailian-adapter.test.ts index ee149ef..7c427a6 100644 --- a/packages/sdk/tests/e2e/bailian-adapter.test.ts +++ b/packages/sdk/tests/e2e/bailian-adapter.test.ts @@ -1226,6 +1226,7 @@ describe("BailianAdapter e2e", () => { const staticCred = { name: "token", type: "static_bearer" as const, + metadata: { owner: "cli" }, mcp_server_url: "https://example.com/mcp", access_token: "tok-123", }; @@ -1245,6 +1246,7 @@ describe("BailianAdapter e2e", () => { mcp_server_url: "https://example.com/mcp", }, display_name: "token", + metadata: { owner: "cli" }, }); expect(result.id).toBe("vcrd_1"); }); diff --git a/packages/sdk/tests/unit/agent-runtime.test.ts b/packages/sdk/tests/unit/agent-runtime.test.ts index d049395..4674342 100644 --- a/packages/sdk/tests/unit/agent-runtime.test.ts +++ b/packages/sdk/tests/unit/agent-runtime.test.ts @@ -231,7 +231,103 @@ describe("agent runtime", () => { expect(plan.provider).toBe("bailian"); }); - test("agent sync blocks destructive actions by default", async () => { + test("scoped Agent planning excludes unrelated validation errors", async () => { + const runtime = ctx( + baseConfig({ + agents: { + "bailian-cli": baseConfig().agents!["bailian-cli"]!, + unrelated: { + model: "qwen3", + instructions: "ignore", + environment: "missing-unrelated-environment", + }, + }, + }), + state([]), + ); + + const plan = await planAgentResources(runtime, "bailian-cli", { refresh: false }); + + expect(plan.diagnostics.some((diagnostic) => diagnostic.message.includes("missing-unrelated"))).toBe(false); + }); + + test("scoped Agent refresh never reads unrelated state resources", async () => { + const config = baseConfig({ + environments: { + "bailian-cli": { config: { type: "cloud" } }, + unrelated: { config: { type: "cloud" } }, + }, + agents: { + "bailian-cli": baseConfig().agents!["bailian-cli"]!, + unrelated: { model: "qwen3", instructions: "ignore", environment: "unrelated" }, + }, + }); + const runtime = ctx( + config, + await matchingState(config, [ + { type: "agent", name: "bailian-cli", provider: "bailian" }, + { type: "environment", name: "bailian-cli", provider: "bailian" }, + { type: "agent", name: "unrelated", provider: "bailian" }, + { type: "environment", name: "unrelated", provider: "bailian" }, + ]), + ); + const reads: string[] = []; + runtime.providers.set("bailian", { + name: "bailian", + getDriftSupport: () => "existence", + findResource: async (type: string, name: string, remoteId: string) => { + reads.push(`${type}.${name}`); + return { id: remoteId, type }; + }, + } as never); + + await planAgentResources(runtime, "bailian-cli", { refresh: true }); + + expect(reads.sort()).toEqual(["agent.bailian-cli", "environment.bailian-cli"]); + }); + + test("create-only blocks when a dependency is not already reconciled", async () => { + const run = await syncAgentResources(ctx(baseConfig(), state([])), "bailian-cli", { + refresh: false, + mode: "create-only", + }); + + expect(run.status).toBe("blocked"); + expect(run.error).toMatch(/Reconcile first.*environment\.bailian-cli/); + expect(run.results).toEqual([]); + }); + + test("create-only creates only the target Agent when dependencies are no-op", async () => { + const config = baseConfig(); + const runtime = ctx( + config, + await matchingState(config, [{ type: "environment", name: "bailian-cli", provider: "bailian" }]), + ); + const created: string[] = []; + runtime.providers.set("bailian", { + name: "bailian", + createAgent: async (name: string) => { + created.push(name); + return { id: "agent_created", type: "agent" }; + }, + } as never); + + const run = await syncAgentResources(runtime, "bailian-cli", { + refresh: false, + mode: "create-only", + }); + + expect(run.status).toBe("completed"); + expect(created).toEqual(["bailian-cli"]); + expect(run.results).toHaveLength(1); + expect(run.results[0]?.action.address).toEqual({ + type: "agent", + name: "bailian-cli", + provider: "bailian", + }); + }); + + test("agent sync never deletes stale state outside the dependency closure", async () => { const c = baseConfig({ environments: {}, agents: { @@ -250,11 +346,11 @@ describe("agent runtime", () => { expect(planned.plan.actions.some((action) => action.action === "delete")).toBe(true); expect(run.status).toBe("blocked"); - expect(run.destructiveActions).toHaveLength(1); + expect(run.destructiveActions).toHaveLength(0); expect(run.results).toEqual([]); }); - test("agent sync with policy=prompt defers to the callback and makes no execution when declined", async () => { + test("agent sync does not prompt for stale state outside the dependency closure", async () => { const c = baseConfig({ environments: {}, agents: { @@ -276,7 +372,7 @@ describe("agent runtime", () => { }, }); - expect(confirmed).toBe(true); + expect(confirmed).toBe(false); expect(run.status).toBe("blocked"); expect(run.results).toEqual([]); }); diff --git a/packages/sdk/tests/unit/bailian-managed-api.test.ts b/packages/sdk/tests/unit/bailian-managed-api.test.ts new file mode 100644 index 0000000..31cbd87 --- /dev/null +++ b/packages/sdk/tests/unit/bailian-managed-api.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { + downloadRemoteSkill, + getManagedAgentProviderCapabilities, +} from "../../src/internal/core/managed-api-runtime.ts"; +import type { ProjectRuntimeContext } from "../../src/internal/core/project-runtime.ts"; +import { sanitizeSessionEvent } from "../../src/internal/core/session-event-sanitizer.ts"; +import { BailianAdapter } from "../../src/internal/providers/bailian/adapter.ts"; +import { toSessionEvent } from "../../src/internal/providers/bailian/event-mapper.ts"; + +const BASE = "https://bailian.test/api/v1/agentstudio"; +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function adapter(): BailianAdapter { + return new BailianAdapter("sk-test", "ws-test", BASE, "managed-api-test"); +} + +function installJsonFetch(responses: unknown[]) { + const calls: Array<{ url: string; method: string; body?: unknown }> = []; + let responseIndex = 0; + globalThis.fetch = mock(async (input: string | URL, init?: RequestInit) => { + const call: { url: string; method: string; body?: unknown } = { + url: String(input), + method: init?.method ?? "GET", + }; + if (typeof init?.body === "string") call.body = JSON.parse(init.body); + calls.push(call); + return new Response(JSON.stringify(responses[responseIndex++] ?? {}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as unknown as typeof fetch; + return calls; +} + +describe("Bailian managed API reads", () => { + test("preserves agent cursors and version parameters", async () => { + const calls = installJsonFetch([ + { data: [{ id: "agent_1", name: "one" }], next_page: "P2" }, + { id: "agent_1", name: "one", version: 2 }, + { data: [{ id: "agent_1", name: "one", version: 2 }], next_page: null }, + ]); + + const page = await adapter().listAgentResources({ limit: 10, page: "P1", include_archived: true }); + const detail = await adapter().getRemoteAgent("agent_1", 2); + const versions = await adapter().listAgentVersions("agent_1", { limit: 5 }); + + expect(new URL(calls[0]!.url).searchParams.get("page")).toBe("P1"); + expect(new URL(calls[0]!.url).searchParams.get("include_archived")).toBe("true"); + expect(calls[1]!.url).toBe(`${BASE}/agents/agent_1?version=2`); + expect(calls[2]!.url).toBe(`${BASE}/agents/agent_1/versions?limit=5`); + expect(page.next_page).toBe("P2"); + expect(detail.version).toBe(2); + expect(versions.has_more).toBe(false); + }); + + test("maps skill, vault, environment, and file list parameters", async () => { + const calls = installJsonFetch([ + { data: [], next_page: null }, + { data: [], next_page: "V2" }, + { data: [], next_page: null }, + { data: [], next_page: null }, + ]); + + await adapter().listSkillResources({ source: "custom", limit: 20, page: "S1" }); + await adapter().listVaultResources({ include_archived: true, limit: 3 }); + await adapter().listEnvironmentResources({ include_archived: false, page: "E1" }); + await adapter().listFileResources({ scope_id: "sesn_1", limit: 50, page: "F1" }); + + expect(new URL(calls[0]!.url).searchParams.get("source")).toBe("customer"); + expect(new URL(calls[1]!.url).searchParams.get("include_archived")).toBe("true"); + expect(new URL(calls[2]!.url).searchParams.get("include_archived")).toBe("false"); + expect(new URL(calls[3]!.url).searchParams.get("scope_id")).toBe("sesn_1"); + }); + + test("supports skill versions and download metadata", async () => { + const calls = installJsonFetch([ + { + data: [{ id: "skillver_1", skill_id: "skill_1", version: "1.0", type: "skill_version" }], + next_page: "SV2", + }, + { skill_id: "skill_1", version: "1.0", type: "skill_version" }, + { skill_id: "skill_1", version: "1.0", file_url: "https://download.test/skill.zip" }, + ]); + + const versions = await adapter().listSkillVersions("skill_1", { limit: 100, page: "SV1" }); + const version = await adapter().getSkillVersion("skill_1", "1.0"); + const download = await adapter().getSkillDownloadInfo("skill_1", "1.0"); + + expect(calls[0]!.url).toBe(`${BASE}/skills/skill_1/versions?limit=100&page=SV1`); + expect(versions.data[0]?.id).toBe("skillver_1"); + expect(versions.has_more).toBe(true); + expect(versions.next_page).toBe("SV2"); + expect(version.version).toBe("1.0"); + expect(download.file_url).toBe("https://download.test/skill.zip"); + }); + + test("forwards deployment keyword and exposes run history", async () => { + const calls = installJsonFetch([ + { data: [{ id: "depl_1", status: "active" }], next_page: "D2" }, + { data: [{ id: "run_1", deployment_id: "depl_1", status: "completed" }], next_page: null }, + { id: "run_1", deployment_id: "depl_1", status: "completed" }, + ]); + + const deployments = await adapter().listDeployments({ keyword: "report", limit: 20, page: "D1" }); + const runs = await adapter().listDeploymentRuns("depl_1", { limit: 10, page: "R1" }); + const run = await adapter().getDeploymentRun("run_1"); + + expect(new URL(calls[0]!.url).searchParams.get("keyword")).toBe("report"); + expect(calls[1]!.url).toBe(`${BASE}/deployments/depl_1/runs?limit=10&page=R1`); + expect(calls[2]!.url).toBe(`${BASE}/deployment_runs/run_1`); + expect(deployments.has_more).toBe(true); + expect(runs.data[0]?.id).toBe("run_1"); + expect(run.status).toBe("completed"); + }); + + test("uses direct deployment action and file-content endpoints", async () => { + const calls = installJsonFetch([ + { id: "run_1", session_id: "sesn_1" }, + { id: "depl_1", status: "paused" }, + { id: "depl_1", status: "active" }, + { content: "file bytes" }, + ]); + + const run = await adapter().runDeploymentById("depl_1"); + await adapter().pauseDeploymentById("depl_1"); + await adapter().unpauseDeploymentById("depl_1"); + const file = await adapter().downloadFileContent("file_1"); + + expect(calls.map((call) => [call.method, call.url])).toEqual([ + ["POST", `${BASE}/deployments/depl_1/run`], + ["POST", `${BASE}/deployments/depl_1/pause`], + ["POST", `${BASE}/deployments/depl_1/unpause`], + ["GET", `${BASE}/files/file_1/content`], + ]); + expect(run.session_id).toBe("sesn_1"); + expect(file.byteLength).toBeGreaterThan(0); + }); + + test("downloads a skill from provider-issued content metadata", async () => { + const runtime = { + providers: new Map([ + [ + "bailian", + { + name: "bailian", + getSkillDownloadInfo: async () => ({ + skill_id: "skill_1", + version: "1", + file_url: "https://download.test/skill.zip", + }), + }, + ], + ]), + } as unknown as ProjectRuntimeContext; + globalThis.fetch = mock(async (input: string | URL) => { + expect(String(input)).toBe("https://download.test/skill.zip"); + return new Response(new Uint8Array([1, 2, 3]), { status: 200 }); + }) as unknown as typeof fetch; + + const bytes = await downloadRemoteSkill(runtime, "skill_1", "1", { provider: "bailian" }); + expect(Array.from(bytes)).toEqual([1, 2, 3]); + }); +}); + +describe("Bailian session API additions", () => { + test("forwards filters, updates, archives, and sends raw events", async () => { + const session = { + id: "sesn_1", + status: "idle", + agent: { id: "agent_1" }, + environment_id: "env_1", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }; + const calls = installJsonFetch([ + { data: [session], next_page: null }, + { id: "sesn_1", status: "idle", title: "updated" }, + { ...session, title: "updated" }, + { ...session, status: "terminated" }, + { data: [{ id: "event_1" }] }, + { data: [{ id: "event_2", type: "message", content: [] }], next_page: null }, + ]); + + await adapter().listSessions({ + agent_id: "agent_1", + statuses: ["idle", "running"], + created_at_gte: "2026-01-01T00:00:00Z", + limit: 20, + }); + await adapter().updateSession("sesn_1", { title: "updated" }); + await adapter().archiveSession("sesn_1"); + const sent = await adapter().sendSessionEvents("sesn_1", [ + { role: "user", type: "interrupt", session_thread_id: "sthr_1" }, + ]); + const listed = await adapter().listSessionEvents("sesn_1", { + types: ["message"], + order: "asc", + limit: 20, + }); + + const listUrl = new URL(calls[0]!.url); + expect(listUrl.searchParams.getAll("statuses[]")).toEqual(["idle", "running"]); + expect(listUrl.searchParams.get("created_at[gte]")).toBe("2026-01-01T00:00:00Z"); + expect(calls[1]!.body).toEqual({ title: "updated" }); + expect(calls[2]!.url).toBe(`${BASE}/sessions/sesn_1`); + expect(calls[3]!.url).toBe(`${BASE}/sessions/sesn_1/archive`); + expect(calls[4]!.body).toEqual({ + input: [{ role: "user", type: "interrupt", session_thread_id: "sthr_1" }], + }); + const eventListUrl = new URL(calls[5]!.url); + expect(eventListUrl.searchParams.get("order")).toBe("asc"); + expect(eventListUrl.searchParams.has("types")).toBe(false); + expect(sent.event_ids).toEqual(["event_1"]); + expect(listed.events[0]?.id).toBe("event_2"); + }); + + test("preserves child-thread id through mapping and sanitization", () => { + const mapped = toSessionEvent({ + type: "thread_created", + content: [{ type: "data", data: { session_thread_id: "sthr_1", agent_name: "researcher" } }], + }); + const sanitized = sanitizeSessionEvent(mapped); + expect(mapped.session_thread_id).toBe("sthr_1"); + expect(sanitized.metadata?.session_thread_id).toBe("sthr_1"); + }); +}); + +test("operation capabilities distinguish Managed Agents Thread from deprecated Assistant Thread", () => { + const capabilities = getManagedAgentProviderCapabilities("bailian"); + expect(capabilities.operations["agent.create"]?.supported).toBe(true); + expect(capabilities.operations["environment.create"]?.supported).toBe(true); + expect(capabilities.operations["skill.create"]?.supported).toBe(true); + expect(capabilities.operations["vault.create"]?.supported).toBe(true); + expect(capabilities.operations["vault.credential.create"]?.supported).toBe(true); + expect(capabilities.operations["deployment.create"]?.supported).toBe(true); + expect(capabilities.operations["session.event.list"]?.supported).toBe(true); + expect(capabilities.operations["session_thread.list"]?.supported).toBe(false); + expect(capabilities.operations["model.list"]?.supported).toBe(false); +}); diff --git a/packages/sdk/tests/unit/bailian.test.ts b/packages/sdk/tests/unit/bailian.test.ts index 47055ad..b1312f0 100644 --- a/packages/sdk/tests/unit/bailian.test.ts +++ b/packages/sdk/tests/unit/bailian.test.ts @@ -117,6 +117,18 @@ describe("Bailian mapAgent", () => { expect(body.instructions).toBeUndefined(); }); + test("uses the declared display name while metadata keeps the logical YAML key", () => { + const body = mapAgent( + "assistant-2", + { ...minimalDecl, name: "Assistant" }, + emptyRefs, + undefined, + "my-project", + ) as Record; + expect(body.name).toBe("Assistant"); + expect((body.metadata as Record)["agents.resource"]).toBe("assistant-2"); + }); + test("wraps model string as { id } object", () => { const body = mapAgent("helper", minimalDecl, emptyRefs) as Record; expect(body.model).toEqual({ id: "qwen3.7-max" }); diff --git a/packages/sdk/tests/unit/core-session-runtime.test.ts b/packages/sdk/tests/unit/core-session-runtime.test.ts index f901642..b45b883 100644 --- a/packages/sdk/tests/unit/core-session-runtime.test.ts +++ b/packages/sdk/tests/unit/core-session-runtime.test.ts @@ -2,9 +2,11 @@ import { describe, expect, test } from "bun:test"; import type { ProjectRuntimeContext } from "../../src/internal/core/project-runtime.ts"; import { createSessionForAgent, + listSessionEvents, sendSessionMessageAndCollectEvents, startSessionRun, streamMessageEvents, + streamSessionEvents, } from "../../src/internal/core/session-runtime.ts"; import { BailianAdapter } from "../../src/internal/providers/bailian/adapter.ts"; import { ClaudeAdapter } from "../../src/internal/providers/claude/adapter.ts"; @@ -263,6 +265,61 @@ describe("core session runtime", () => { expect(result.terminalStatus).toBe("idle"); expect(calls).toEqual(["send:poll", "list:evt_user"]); }); + + test("filters raw event types client-side", async () => { + const provider = { + ...adapter("bailian", [], false), + listSessionEvents: async () => ({ + events: [ + { id: "evt_message", type: "message", raw_type: "message", raw: {} }, + { id: "evt_tool", type: "tool_call", raw_type: "tool_call", raw: {} }, + ], + has_more: false, + }), + }; + + const result = await listSessionEvents(ctx(provider), "sess_1", { types: ["tool_call"] }); + + expect(result.events.map((event) => event.id)).toEqual(["evt_tool"]); + }); + + test("replays after-id with history polling when native stream cursors are unsupported", async () => { + const calls: string[] = []; + const marker: ProviderSessionEvent = { + id: "evt_marker", + type: "message", + raw_type: "message", + raw: {}, + }; + const terminal: ProviderSessionEvent = { + id: "evt_terminal", + type: "status", + raw_type: "session_status", + status: "idle", + raw: {}, + }; + const provider = { + ...adapter("bailian", calls, false), + streamSessionEvents: async function* () { + calls.push("native-stream"); + yield terminal; + }, + listSessionEvents: async (_sessionId: string, options?: { order?: string }) => { + calls.push(`list:${options?.order ?? "none"}`); + return options?.order === "asc" + ? { events: [marker], has_more: false } + : { events: [terminal, marker], has_more: false }; + }, + }; + const seen: ProviderSessionEvent[] = []; + + for await (const event of streamSessionEvents(ctx(provider), "sess_1", { after_id: marker.id })) { + seen.push(event); + } + + expect(seen.map((event) => event.id)).toEqual(["evt_terminal"]); + expect(calls).toEqual(["list:asc", "list:desc"]); + }); }); describe("provider eventResume declarations", () => { diff --git a/packages/sdk/tests/unit/drift-detection.test.ts b/packages/sdk/tests/unit/drift-detection.test.ts index 9165577..5e3a361 100644 --- a/packages/sdk/tests/unit/drift-detection.test.ts +++ b/packages/sdk/tests/unit/drift-detection.test.ts @@ -100,6 +100,7 @@ describe("Qoder comparable fixtures", () => { const adapter = new QoderAdapter("pt-test", undefined, "tmp") as any; expect(adapter.normalizeRemote("agent", agentPayload)).toEqual({ + name: "assistant", description: "Agents live drift original agent", model: "ultimate", instructions: "You are a temporary Agents live drift validation agent. Reply with original.\n", diff --git a/packages/sdk/tests/unit/executor-conflict-adopt.test.ts b/packages/sdk/tests/unit/executor-conflict-adopt.test.ts index dd730ec..4a4642f 100644 --- a/packages/sdk/tests/unit/executor-conflict-adopt.test.ts +++ b/packages/sdk/tests/unit/executor-conflict-adopt.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ExecContext } from "../../src/internal/executor/context.ts"; @@ -317,25 +317,30 @@ describe("executor conflict-adopt", () => { }); test("skill ConflictError → multi searchNames → adopt as-is without rebuild", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "exec-conflict-skill-")); + writeFileSync( + join(projectDir, "SKILL.md"), + "---\nname: manifest-skill\ndescription: Conflict adoption fixture\n---\n", + ); const skillConfig: ProjectConfig = { version: "1", providers: { bailian: { api_key: "test", workspace_id: "ws" } }, defaults: { provider: "bailian" }, skills: { "my-skill": { - source: "skills/my-skill", + source: "SKILL.md", }, }, }; const existingSkill: RemoteResource = { id: "skill_remote", type: "skill" }; - let findCalls = 0; + const findNames: string[] = []; const provider = { name: "bailian", validate: async () => {}, - findResource: async () => { - findCalls += 1; - return findCalls === 1 ? null : existingSkill; + findResource: async (_type: string, name: string) => { + findNames.push(name); + return findNames.length === 4 ? existingSkill : null; }, createSkill: async () => { throw new ConflictError(409, "已存在自定义相同SkillName", "Bailian API"); @@ -364,17 +369,22 @@ describe("executor conflict-adopt", () => { const state = StateManager.initialize(tmpPath()); const ctx: ExecContext = { config: skillConfig, - configPath: "/tmp/agents.yaml", + configPath: join(projectDir, "agents.yaml"), providers: new Map([["bailian", provider]]), state, }; - const result = await executePlan(skillPlan, ctx); - - expect(result.partial).toBe(false); - expect(result.results[0].status).toBe("success"); - const saved = state.getResource({ type: "skill", name: "my-skill", provider: "bailian" })!; - expect(saved.remote_id).toBe("skill_remote"); + try { + const result = await executePlan(skillPlan, ctx); + + expect(result.partial).toBe(false); + expect(result.results[0].status).toBe("success"); + expect(findNames).toEqual(["my-skill", "manifest-skill", "my-skill", "manifest-skill"]); + const saved = state.getResource({ type: "skill", name: "my-skill", provider: "bailian" })!; + expect(saved.remote_id).toBe("skill_remote"); + } finally { + rmSync(projectDir, { 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 c525cda..a0c7c62 100644 --- a/packages/sdk/tests/unit/map-deployment.test.ts +++ b/packages/sdk/tests/unit/map-deployment.test.ts @@ -408,6 +408,7 @@ describe("Bailian mapDeployment", () => { test("full decl produces a native deployment body with object agent", () => { const decl: DeploymentDecl = { + name: "Daily Report", agent: "researcher", agent_version: 3, description: "Daily", @@ -435,7 +436,7 @@ describe("Bailian mapDeployment", () => { 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.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"]); diff --git a/packages/sdk/tests/unit/resource-create-only.test.ts b/packages/sdk/tests/unit/resource-create-only.test.ts new file mode 100644 index 0000000..7d46c4f --- /dev/null +++ b/packages/sdk/tests/unit/resource-create-only.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; +import type { ProjectRuntimeContext } from "../../src/internal/core/project-runtime.ts"; +import { planProjectContext } from "../../src/internal/core/resource-runtime.ts"; +import { computeResourceHash } from "../../src/internal/planner/hasher.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"; +import "../../src/internal/providers/all.ts"; + +const provider = "bailian"; + +function config(): ResolvedProjectConfig { + return { + version: "1", + providers: { bailian: { api_key: "test", workspace_id: "ws" } }, + defaults: { provider }, + environments: { + dev: { config: { type: "cloud" } }, + unrelated: { config: { type: "cloud" } }, + }, + vaults: { secrets: { display_name: "Secrets", credentials: [] } }, + skills: { review: { source: "./missing-skill", origin: "custom" } }, + agents: { + assistant: { model: "qwen3", instructions: "help", environment: "dev" }, + }, + deployments: { + daily: { + name: "Daily Report", + agent: "assistant", + environment: "dev", + initial_events: [{ type: "user.message", content: "run" }], + }, + }, + _resolved: true, + }; +} + +function runtime(resolvedConfig: ResolvedProjectConfig, state = StateManager.initialize("/tmp/create-only.json")) { + return { + projectName: "test", + configPath: "/tmp/agents.yaml", + config: resolvedConfig, + state, + providers: new Map(), + } as ProjectRuntimeContext; +} + +async function seedMatchingState( + resolvedConfig: ResolvedProjectConfig, + addresses: ResourceAddress[], +): Promise { + const state = StateManager.initialize("/tmp/create-only-state.json"); + for (const address of addresses) { + const hash = await computeResourceHash(address, resolvedConfig, "/tmp/agents.yaml", state); + state.setResource({ + address, + remote_id: `${address.type}_${address.name}`, + content_hash: hash, + desired_hash: hash, + drift_status: "in_sync", + }); + } + return state; +} + +describe("generic resource create-only planning", () => { + test.each([ + ["environment", "dev"], + ["vault", "secrets"], + ["skill", "review"], + ] as const)("accepts a new scoped %s root", async (type, name) => { + const address: ResourceAddress = { type, name, provider }; + const planned = await planProjectContext(runtime(config()), { + provider, + scope: { roots: [address] }, + mode: "create-only", + refresh: false, + }); + + expect(planned.plan.actions).toHaveLength(1); + expect(planned.plan.actions[0]).toMatchObject({ action: "create", address }); + expect(planned.plan.diagnostics.some((diagnostic) => diagnostic.severity === "error")).toBe(false); + }); + + test("allows Deployment create when its transitive dependencies are no-op", async () => { + const resolvedConfig = config(); + const state = await seedMatchingState(resolvedConfig, [ + { type: "environment", name: "dev", provider }, + { type: "agent", name: "assistant", provider }, + ]); + const root: ResourceAddress = { type: "deployment", name: "daily", provider }; + const planned = await planProjectContext(runtime(resolvedConfig, state), { + provider, + scope: { roots: [root] }, + mode: "create-only", + refresh: false, + }); + + expect(planned.plan.actions.find((action) => action.address.type === "deployment")?.action).toBe("create"); + expect( + planned.plan.actions + .filter((action) => action.address.type !== "deployment") + .every((action) => action.action === "no-op"), + ).toBe(true); + expect(planned.plan.diagnostics.some((diagnostic) => diagnostic.severity === "error")).toBe(false); + }); + + test("blocks when the target already exists or a dependency needs reconciliation", async () => { + const resolvedConfig = config(); + const existingEnvironment = await seedMatchingState(resolvedConfig, [ + { type: "environment", name: "dev", provider }, + ]); + const existingPlan = await planProjectContext(runtime(resolvedConfig, existingEnvironment), { + provider, + scope: { roots: [{ type: "environment", name: "dev", provider }] }, + mode: "create-only", + refresh: false, + }); + expect(existingPlan.plan.diagnostics).toContainEqual( + expect.objectContaining({ code: "resource.create_only.blocked", severity: "error" }), + ); + + const dependencyPlan = await planProjectContext(runtime(resolvedConfig), { + provider, + scope: { roots: [{ type: "agent", name: "assistant", provider }] }, + mode: "create-only", + refresh: false, + }); + expect(dependencyPlan.plan.diagnostics).toContainEqual( + expect.objectContaining({ + code: "resource.create_only.blocked", + message: expect.stringContaining("environment.dev (create)"), + }), + ); + }); + + test("requires scope and leaves full planning behavior unchanged", async () => { + await expect( + planProjectContext(runtime(config()), { provider, mode: "create-only", refresh: false }), + ).rejects.toThrow(/requires an explicit resource scope/); + + const fullPlan = await planProjectContext(runtime(config()), { provider, refresh: false }); + expect(fullPlan.plan.actions).toContainEqual( + expect.objectContaining({ + action: "create", + address: { type: "environment", name: "unrelated", provider }, + }), + ); + }); +}); diff --git a/packages/sdk/tests/unit/skill-source.test.ts b/packages/sdk/tests/unit/skill-source.test.ts new file mode 100644 index 0000000..e826230 --- /dev/null +++ b/packages/sdk/tests/unit/skill-source.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import JSZip from "jszip"; +import { inspectSkillSource } from "../../src/internal/core/skill-source.ts"; + +const manifest = (name: string) => `---\nname: ${name}\ndescription: Test\n---\n# Skill\n`; + +describe("local Skill source inspection", () => { + test("normalizes a wrapped directory root", async () => { + const root = await mkdtemp(join(tmpdir(), "skill-source-dir-")); + await mkdir(join(root, "wrapper", "assets"), { recursive: true }); + await writeFile(join(root, "wrapper", "SKILL.md"), manifest("wrapped-skill")); + await writeFile(join(root, "wrapper", "assets", "note.txt"), "ok"); + + const inspected = await inspectSkillSource(root); + + expect(inspected.name).toBe("wrapped-skill"); + expect(inspected.files.map((file) => file.relativePath).sort()).toEqual(["SKILL.md", "assets/note.txt"]); + }); + + test("reads zip and single SKILL.md sources", async () => { + const root = await mkdtemp(join(tmpdir(), "skill-source-files-")); + const single = join(root, "SKILL.md"); + await writeFile(single, manifest("single-skill")); + const zip = new JSZip(); + zip.file("bundle/SKILL.md", manifest("zip-skill")); + zip.file("bundle/reference.md", "reference"); + const zipPath = join(root, "skill.zip"); + await writeFile(zipPath, await zip.generateAsync({ type: "uint8array" })); + + expect((await inspectSkillSource(single)).name).toBe("single-skill"); + const inspectedZip = await inspectSkillSource(zipPath); + expect(inspectedZip.name).toBe("zip-skill"); + expect(inspectedZip.files.map((file) => file.relativePath).sort()).toEqual(["SKILL.md", "reference.md"]); + }); + + test("rejects missing or unnamed manifests", async () => { + const root = await mkdtemp(join(tmpdir(), "skill-source-invalid-")); + await writeFile(join(root, "README.md"), "missing"); + await expect(inspectSkillSource(root)).rejects.toThrow(/does not contain SKILL.md/); + await writeFile(join(root, "SKILL.md"), "---\ndescription: Missing name\n---\n"); + await expect(inspectSkillSource(root)).rejects.toThrow(/non-empty name/); + }); +}); diff --git a/packages/sdk/tests/unit/vault-credential-runtime.test.ts b/packages/sdk/tests/unit/vault-credential-runtime.test.ts new file mode 100644 index 0000000..39ea1ce --- /dev/null +++ b/packages/sdk/tests/unit/vault-credential-runtime.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test"; +import type { ProjectRuntimeContext } from "../../src/internal/core/project-runtime.ts"; +import { createVaultCredential, planVaultCredentialCreate } from "../../src/internal/core/vault-credential-runtime.ts"; +import { computeResourceHash } from "../../src/internal/planner/hasher.ts"; +import { StateManager } from "../../src/internal/state/state-manager.ts"; +import type { CredentialDecl, ResolvedProjectConfig } from "../../src/internal/types/config.ts"; + +const credential: CredentialDecl = { + name: "api-token", + type: "environment_variable", + secret_name: "API_TOKEN", + secret_value: "super-secret-value", + networking: { type: "unrestricted" }, + metadata: { owner: "cli" }, +}; + +function candidateConfig(): ResolvedProjectConfig { + return { + version: "1", + providers: { bailian: { api_key: "test", workspace_id: "ws" } }, + defaults: { provider: "bailian" }, + vaults: { + production: { display_name: "Production", credentials: [credential] }, + }, + _resolved: true, + }; +} + +async function makeRuntime( + options: { + remoteCredentials?: Array<{ + id: string; + display_name: string; + auth_type: string; + secret_name?: string; + networking_type?: string; + }>; + drifted?: boolean; + } = {}, +) { + const config = candidateConfig(); + const priorConfig = candidateConfig(); + priorConfig.vaults!.production!.credentials = []; + const address = { type: "vault" as const, name: "production", provider: "bailian" }; + const priorHash = await computeResourceHash(address, priorConfig); + const state = StateManager.initialize("/tmp/vault-credential-runtime.json"); + state.setResource({ + address, + remote_id: "vault_1", + content_hash: priorHash, + desired_hash: priorHash, + drift_status: options.drifted ? "drifted" : "in_sync", + }); + let createCalls = 0; + let listCalls = 0; + const provider = { + name: "bailian", + listCredentials: async () => { + listCalls += 1; + return options.remoteCredentials ?? []; + }, + createCredential: async () => { + createCalls += 1; + return { id: "credential_1", type: "credential" }; + }, + }; + const runtime = { + projectName: "test", + config, + state, + providers: new Map([["bailian", provider]]), + } as unknown as ProjectRuntimeContext; + return { + runtime, + state, + getCreateCalls: () => createCalls, + getListCalls: () => listCalls, + }; +} + +describe("scoped Vault Credential create", () => { + test("offline preview does not list remote credentials", async () => { + const { runtime, getListCalls } = await makeRuntime(); + const result = await planVaultCredentialCreate(runtime, "production", "api-token", { + refresh: false, + checkRemote: false, + }); + + expect(result).toMatchObject({ remoteChecked: false, reuseRemote: false }); + expect(getListCalls()).toBe(0); + }); + + test("creates one credential and advances only the parent Vault desired hash", async () => { + const { runtime, state, getCreateCalls } = await makeRuntime(); + const result = await createVaultCredential(runtime, "production", "api-token", { refresh: false }); + + expect(result).toMatchObject({ credentialId: "credential_1", adopted: false, vaultRemoteId: "vault_1" }); + expect(getCreateCalls()).toBe(1); + const saved = state.getResource({ type: "vault", name: "production", provider: "bailian" })!; + expect(saved.content_hash).toBe(await computeResourceHash(saved.address, runtime.config, undefined, state)); + expect(JSON.stringify(state.getStateFile())).not.toContain("super-secret-value"); + }); + + test("adopts an exact remote retry without creating a duplicate", async () => { + const { runtime, getCreateCalls } = await makeRuntime({ + remoteCredentials: [ + { + id: "credential_existing", + display_name: "api-token", + auth_type: "environment_variable", + secret_name: "API_TOKEN", + networking_type: "unrestricted", + }, + ], + }); + const result = await createVaultCredential(runtime, "production", "api-token", { refresh: false }); + + expect(result).toMatchObject({ credentialId: "credential_existing", adopted: true }); + expect(getCreateCalls()).toBe(0); + }); + + test("blocks Vault drift and conflicting same-name credentials", async () => { + const drifted = await makeRuntime({ drifted: true }); + await expect(createVaultCredential(drifted.runtime, "production", "api-token", { refresh: false })).rejects.toThrow( + /must be tracked and up-to-date/, + ); + + const conflicting = await makeRuntime({ + remoteCredentials: [ + { + id: "credential_other", + display_name: "api-token", + auth_type: "environment_variable", + secret_name: "OTHER_TOKEN", + }, + ], + }); + await expect( + createVaultCredential(conflicting.runtime, "production", "api-token", { refresh: false }), + ).rejects.toThrow(/different remote credential/); + }); +}); diff --git a/packages/sdk/tests/unit/vault-sync-export.test.ts b/packages/sdk/tests/unit/vault-sync-export.test.ts index 58bfa51..fdb9656 100644 --- a/packages/sdk/tests/unit/vault-sync-export.test.ts +++ b/packages/sdk/tests/unit/vault-sync-export.test.ts @@ -9,12 +9,14 @@ test("bailian credToDecl reverse-maps an environment_variable credential with a const raw = { id: "vcrd_1", display_name: "mcp-token", + metadata: { "agents.project": "p", team: "platform" }, auth: { type: "environment_variable", secret_name: "API_KEY", networking: { type: "unrestricted" } }, }; expect(bailianCred(raw, "secrets")).toEqual({ name: "mcp-token", type: "environment_variable", + metadata: { team: "platform" }, secret_name: "API_KEY", secret_value: "${VAULT_SECRETS_MCP_TOKEN}", networking: { type: "unrestricted" }, diff --git a/scripts/release/channel.test.ts b/scripts/release/channel.test.ts index e52b23b..a432d18 100644 --- a/scripts/release/channel.test.ts +++ b/scripts/release/channel.test.ts @@ -12,13 +12,18 @@ describe("release channel guard", () => { expect(() => validateReleaseIdentity("stable", "main", "1.2.3-beta.0")).toThrow("X.Y.Z"); }); - test("accepts deterministic beta snapshots only on main", () => { + test("accepts deterministic beta snapshots from main and feature branches", () => { expect(validateReleaseIdentity("beta", "main", "1.2.3-beta-a1b2c3d-20260720")).toEqual({ channel: "beta", version: "1.2.3-beta-a1b2c3d-20260720", distTag: "beta", }); - expect(() => validateReleaseIdentity("beta", "feature/test", "1.2.3-beta-a1b2c3d-20260720")).toThrow("main"); + expect(validateReleaseIdentity("beta", "feat/Managed_Agent.API", "1.2.3-beta-a1b2c3d-20260720")).toEqual({ + channel: "beta", + version: "1.2.3-beta-a1b2c3d-20260720", + distTag: "beta-feat-managed-agent-api", + }); + expect(() => validateReleaseIdentity("beta", "///", "1.2.3-beta-a1b2c3d-20260720")).toThrow("letter or number"); expect(() => validateReleaseIdentity("beta", "main", "1.2.3-beta.0")).toThrow("unexpected format"); }); diff --git a/scripts/release/channel.ts b/scripts/release/channel.ts index a41ac4c..d87ce51 100644 --- a/scripts/release/channel.ts +++ b/scripts/release/channel.ts @@ -6,7 +6,7 @@ export type ReleaseChannel = "beta" | "stable"; export interface ReleaseIdentity { channel: ReleaseChannel; version: string; - distTag: "beta" | "latest"; + distTag: "beta" | `beta-${string}` | "latest"; } const root = resolve(import.meta.dirname, "../.."); @@ -31,6 +31,19 @@ export function commonReleaseVersion(versions: readonly string[]): string { return unique[0]; } +/** Keep main's shared beta channel stable while isolating feature-branch snapshots. */ +export function betaDistTag(ref: string): "beta" | `beta-${string}` { + if (ref === "main") return "beta"; + const slug = ref + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 64) + .replace(/-+$/g, ""); + if (!slug) throw new Error(`beta release ref must contain at least one letter or number; found ${ref}`); + return `beta-${slug}`; +} + export function validateReleaseIdentity(channel: ReleaseChannel, ref: string, version: string): ReleaseIdentity { if (channel === "stable") { if (ref !== "main") throw new Error(`stable releases must run from main, not ${ref}`); @@ -38,11 +51,10 @@ export function validateReleaseIdentity(channel: ReleaseChannel, ref: string, ve return { channel, version, distTag: "latest" }; } - if (ref !== "main") throw new Error(`beta snapshots must run from main, not ${ref}`); if (!betaSnapshotVersion.test(version)) { throw new Error(`beta snapshot version has an unexpected format: ${version}`); } - return { channel, version, distTag: "beta" }; + return { channel, version, distTag: betaDistTag(ref) }; } function option(name: string): string | undefined {