From 8354d3c91d372620c4a948362cea8dc52838ec63 Mon Sep 17 00:00:00 2001 From: heimanba <371510756@qq.com> Date: Thu, 20 Aug 2026 22:52:01 +0800 Subject: [PATCH 1/4] feat(qoder): manage default memory store lifecycle Change-Id: I35c34cc96127ecbd49678d04f8a554bbd709ffd7 --- docs/guides/configure-an-agent.zh-CN.md | 64 ++++ docs/reference/configuration.md | 52 ++++ packages/cli/src/commands/apply.ts | 15 +- packages/cli/src/commands/destroy.ts | 27 +- packages/sdk/src/index.ts | 5 +- .../sdk/src/internal/core/destroy-runtime.ts | 283 +++++++++++++++++- .../sdk/src/internal/core/validate-config.ts | 31 ++ .../sdk/src/internal/executor/executor.ts | 43 +++ packages/sdk/src/internal/graph/dependency.ts | 8 + packages/sdk/src/internal/parser/schema.ts | 7 + .../sdk/src/internal/providers/interface.ts | 13 + .../src/internal/providers/qoder/adapter.ts | 35 +++ .../sdk/src/internal/state/state-manager.ts | 8 +- packages/sdk/src/internal/types/config.ts | 9 + packages/sdk/src/internal/types/state.ts | 10 + .../sdk/tests/unit/destroy-runtime.test.ts | 214 +++++++++++++ packages/sdk/tests/unit/parser.test.ts | 26 ++ .../tests/unit/qoder-forward-template.test.ts | 239 +++++++++++++++ packages/sdk/tests/unit/slim-state.test.ts | 21 ++ 19 files changed, 1099 insertions(+), 11 deletions(-) diff --git a/docs/guides/configure-an-agent.zh-CN.md b/docs/guides/configure-an-agent.zh-CN.md index 344ac32..2a3d817 100644 --- a/docs/guides/configure-an-agent.zh-CN.md +++ b/docs/guides/configure-an-agent.zh-CN.md @@ -362,6 +362,70 @@ agents: `memory_stores` 字段接收存储名称数组。 +#### 配置 Qoder Forward 默认 Memory Store + +Qoder Forward 会在某个 `(Identity, Template)` 首次创建 Session 时自动建立唯一可写的系统默认 +Memory Store。`default_memory_store` 管理的是这个由 Qoder 创建的 Store,而不是在顶层 +`memory_stores` 中额外创建一个普通 Store。可以为它设置有业务含义的展示名称、描述以及销毁策略: + +```yaml +defaults: + provider: qoder + identity: support-user + +identities: + support-user: + external_id: support-user # 由 OpenCMA 管理;不是 identity_id + +agents: + support-agent: + # ...其他配置 + delivery: + qoder: + type: forward + default_memory_store: + name: "客服群长期记忆" + description: "群聊中确认过的业务知识和处理规则" + delete_on_destroy: false +``` + +##### apply 行为 + +- 仅支持 Qoder Forward,并且必须配置 `defaults.identity`。 +- `agents apply` 会通过 `defaults.identity` 和当前 Template 定位 `system_managed=true`、 + `access=read_write` 的 Store,然后幂等更新 `name` 和可选的 `description`。 +- `name` 会成为云端 Store 的展示名称,因此可以将系统生成的默认名称改为有业务含义的名字。 +- OpenCMA 不会为了生成 Store 而创建额外的初始化 Session。首次真实 Session 尚未创建时,apply 会提示 + pending;Session 创建后再次执行 apply 即可完成名称和描述的收敛。 + +##### destroy 行为 + +`delete_on_destroy` 控制执行 `agents destroy` 时是否永久删除这个系统默认 Store: + +| 配置 | destroy 结果 | +|---|---| +| 未配置或 `false` | 保留 Store、全部 Memory 和版本历史。这是默认行为。 | +| `true` | 系统挂载解除后,永久删除 Store、全部 Memory 和版本历史。 | + +永久删除的执行顺序如下: + +1. 删除任何项目资源前,先用 Identity ID 和 Template ID 查询并保存默认 Store ID。 +2. archive Template,并删除由 OpenCMA 管理的 Identity,以解除系统挂载。 +3. 使用之前保存的 Store ID 永久删除默认 Store。Qoder 可能异步解除系统挂载,因此遇到 + `still mounted` 时会进行有界退避重试。 +4. 如果挂载冲突仍未解除,尝试 `archive → delete`。真机 Qoder 已验证该路径可以完成永久删除。 + +如果预检阶段无法解析 Identity、Template,或无法完成 Store 查询,destroy 会在删除任何项目资源之前 +整体中止。如果最后的 Store 删除仍失败,命令会返回 `partial`,并把待清理 Store ID 保存在 state 中, +不会把结果显示成完整成功。即使其他资源已经全部删除,之后再次执行 `agents destroy` 也会继续清理。 +Store 已不存在时按 `already absent` 处理。 + +永久删除要求 `defaults.identity` 指向由 OpenCMA 管理的 Identity。外部 `identity_id` 永远不会被 +OpenCMA 删除,会继续挂载默认 Store,因此 `delete_on_destroy: true` 会直接校验失败。 + +> **警告:** `true` 会不可恢复地删除 Store 内容和全部版本历史。除非明确需要清除数据,否则应保持默认 +> `false`。`--cascade` 不会覆盖 `delete_on_destroy`。 + --- ## 多 Agent 协作 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index a9a9fa4..eb308fe 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -280,6 +280,10 @@ agents: skills: [ | { type, skill_id, version? } ] vault: memory_stores: [ ] + default_memory_store: # Qoder Forward only; requires defaults.identity + name: # 1-255 characters + description: # optional; up to 1024 characters + delete_on_destroy: # optional; defaults to false (retain) environment_variables: { : } # Qoder only managed_tool_config: { enabled_tools: [ ] } # Qoder Forward delivery only resources: [ SessionResource ] @@ -287,6 +291,51 @@ agents: metadata: { : } ``` +### Qoder Forward default Memory Store + +Qoder creates one writable, system-managed Memory Store for an `(Identity, Template)` pair when its first Forward Session is created. `default_memory_store` lets OpenCMA manage the display metadata and destroy policy of that provider-created Store; it does not declare a second, ordinary entry under the top-level `memory_stores` collection. + +```yaml +defaults: + provider: qoder + identity: support-user + +identities: + support-user: + external_id: support-user # managed by OpenCMA + +agents: + support: + # ... + delivery: + qoder: + type: forward + default_memory_store: + name: "Support group memory" + description: "Confirmed support knowledge and operating rules" + delete_on_destroy: false +``` + +Apply behavior: + +- Requires Qoder Forward delivery and `defaults.identity`. +- Locates the Store mounted as `system_managed: true` and `access: read_write`, then idempotently updates its `name` and optional `description`. +- Does not create an initialization Session. Before the first real Session has created the Store, apply reports the reconciliation as pending. Run apply again after a Session exists. +- `name` changes the provider Store's display name, so it can be meaningful instead of remaining the provider-generated default. + +Destroy behavior: + +| `delete_on_destroy` | Result | +|---|---| +| omitted or `false` | Retain the Store, its Memories, and all version history. This is the default. | +| `true` | Permanently delete the Store, its Memories, and all version history after its system mount has been removed. | + +For permanent deletion, OpenCMA captures and persists the Store ID before archiving the Template and deleting the Identity. Qoder may remove the system mount asynchronously, so OpenCMA uses bounded retries for a `still mounted` conflict. If the conflict remains, it tries `archive → delete`, matching the lifecycle verified against the live Qoder service. A cleanup that still cannot finish is retained in state and reported as a partial destroy; a later `agents destroy` resumes it even when all ordinary resources are already gone. + +If the preflight cannot resolve the Identity, Template, or Store lookup, destroy aborts before deleting any project resource. Authentication, permission, and non-retryable validation errors fail immediately. `--cascade` does not override this field. + +`delete_on_destroy: true` requires an OpenCMA-managed Identity. An external `identity_id` is never deleted by OpenCMA, so it keeps the system Store mounted and fails configuration validation. Permanent deletion is irreversible; keep the default `false` unless data removal is explicitly required. + | Field | Type | Required | Description | |-------|------|:--------:|-------------| | `model` | string \| map | yes | Single model or a per-provider map. | @@ -302,6 +351,9 @@ agents: | `skills[]` | string \| AgentSkillRef | no | Skill name or `{ type: "official"\|"custom", skill_id, version? }`. | | `vault` | string | no | Vault name. | | `memory_stores` | string[] | no | Bound memory stores. | +| `default_memory_store.name` | string | yes (with `default_memory_store`) | Display name for Qoder Forward's writable system-managed Store; 1–255 characters. | +| `default_memory_store.description` | string | no | Display description for the system-managed Store; up to 1024 characters. | +| `default_memory_store.delete_on_destroy` | boolean | no | Permanently delete the Store during destroy. Defaults to `false` (retain). | | `environment_variables` | map | no | Qoder runtime variables. Managed Sessions use Qoder's `KEY=VALUE;...` wire format; Forward Templates store the map as defaults and Forward Sessions send it under `config.environment_variables`. | | `managed_tool_config.enabled_tools` | string[] | no | Provider-operated tools the Agent Harness exposes, e.g. `create_forward_schedule`, `list_forward_schedules`, `delete_forward_schedule`. Qoder Forward delivery only; declaring it on managed delivery is a validation error. | | `resources` | SessionResource[] | no | Resources attached to every managed Session created for the Agent. | diff --git a/packages/cli/src/commands/apply.ts b/packages/cli/src/commands/apply.ts index 4a4fb40..f0410d7 100644 --- a/packages/cli/src/commands/apply.ts +++ b/packages/cli/src/commands/apply.ts @@ -107,7 +107,20 @@ export async function applyCommand(options: { const actionable = plan.actions.filter((a) => a.action !== "no-op"); if (actionable.length === 0) { - log.success("No changes. Infrastructure is up-to-date."); + if (options.refreshOnly) { + log.success("No changes. Infrastructure is up-to-date."); + return; + } + const s = p.spinner({ output: process.stderr }); + s.start("Reconciling provider-managed defaults..."); + const result = await executePlannedProject(planned, { + onFeedback: renderRuntimeFeedback, + policy: "force", + concurrency: options.concurrency, + }); + s.stop("Provider-managed defaults reconciled."); + if (result.partial) throw new UserError("Apply failed."); + log.success("No resource changes. Infrastructure is up-to-date."); return; } diff --git a/packages/cli/src/commands/destroy.ts b/packages/cli/src/commands/destroy.ts index 8af11c7..c5de7cf 100644 --- a/packages/cli/src/commands/destroy.ts +++ b/packages/cli/src/commands/destroy.ts @@ -1,5 +1,6 @@ import * as p from "@clack/prompts"; import { + type DestroyDefaultMemoryStoreResult, type DestroyResourceResult, destroyPlannedProjectResources, planDestroyProjectContext, @@ -14,7 +15,8 @@ export async function destroyCommand(options: { file: string; yes?: boolean; cas const planned = planDestroyProjectContext(ctx); const resources = planned.resources; - if (resources.length === 0) { + const pendingDefaultMemoryStores = planned.defaultMemoryStores.filter((store) => store.memoryStoreId); + if (resources.length === 0 && pendingDefaultMemoryStores.length === 0) { log.info("No resources in state. Nothing to destroy."); return; } @@ -23,6 +25,13 @@ export async function destroyCommand(options: { file: string; yes?: boolean; cas for (const r of resources) { console.log(chalk.red(` - ${formatResourceLabel(r.address)} [${r.remote_id}]`)); } + for (const store of planned.defaultMemoryStores) { + const policy = store.deleteOnDestroy ? chalk.red.bold("permanently delete") : chalk.green("retain"); + console.log(` - default_memory_store.${store.agentName} [${policy}]`); + } + if (planned.defaultMemoryStores.some((store) => store.deleteOnDestroy)) { + console.log(chalk.red.bold("\nDefault Memory Store content and all version history will be permanently deleted.")); + } if (!options.yes) { const shouldDestroy = await p.confirm({ @@ -69,14 +78,22 @@ export async function destroyCommand(options: { file: string; yes?: boolean; cas activeSpinner = undefined; }, }); + for (const store of result.defaultMemoryStoreResults) renderDefaultMemoryStoreResult(store); - const summary = - result.destroyed === result.resources.length - ? chalk.green(`Destroy complete. ${result.destroyed}/${result.resources.length} resources removed.`) - : chalk.yellow(`Destroy complete. ${result.destroyed}/${result.resources.length} resources removed.`); + const summary = !result.partial + ? chalk.green(`Destroy complete. ${result.destroyed}/${result.resources.length} resources removed.`) + : chalk.yellow(`Destroy partial. ${result.destroyed}/${result.resources.length} resources removed.`); p.outro(summary, { output: process.stderr }); } +function renderDefaultMemoryStoreResult(result: DestroyDefaultMemoryStoreResult): void { + const label = `default_memory_store.${result.agentName}`; + if (result.status === "retained") log.success(`${label} — retained`); + else if (result.status === "deleted") log.success(`${label} — permanently deleted`); + else if (result.status === "already_gone") log.warn(`${label} — already absent`); + else log.error(`${label} — delete failed: ${result.error ?? "unknown error"}`); +} + function stopResourceSpinner(spinner: ReturnType | undefined, result: DestroyResourceResult): void { const label = formatResourceLabel(result.resource.address); if (!spinner) { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index a6e7e54..de79e3d 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -81,7 +81,10 @@ export { export type { DeploymentListFilter, DeploymentListResult } from "./internal/providers/interface.ts"; -export type { DestroyResourceResult } from "./internal/core/destroy-runtime.ts"; +export type { + DestroyDefaultMemoryStoreResult, + DestroyResourceResult, +} from "./internal/core/destroy-runtime.ts"; export { destroyPlannedProjectResources, planDestroyProjectContext, diff --git a/packages/sdk/src/internal/core/destroy-runtime.ts b/packages/sdk/src/internal/core/destroy-runtime.ts index 6a988bc..5d487dd 100644 --- a/packages/sdk/src/internal/core/destroy-runtime.ts +++ b/packages/sdk/src/internal/core/destroy-runtime.ts @@ -28,19 +28,38 @@ export interface DestroyResourceResult { export interface DestroyPlanResult { resources: ResourceState[]; + defaultMemoryStores: DestroyDefaultMemoryStorePlan[]; executionContext: ProjectRuntimeContext; } +export interface DestroyDefaultMemoryStorePlan { + agentName: string; + provider: string; + identityId: string | null; + templateId: string | null; + deleteOnDestroy: boolean; + memoryStoreId?: string; +} + +export interface DestroyDefaultMemoryStoreResult extends DestroyDefaultMemoryStorePlan { + status: "retained" | "deleted" | "already_gone" | "failed"; + memoryStoreId?: string; + error?: string; +} + export interface DestroyProjectOptions { cascade?: boolean; onFeedback?: RuntimeFeedbackSink; onResourceStart?: (resource: ResourceState) => void; onResourceResult?: (result: DestroyResourceResult) => void; onCascadeRequired?: (result: DestroyResourceResult) => boolean | Promise; + /** Override bounded cleanup backoff, primarily for deterministic tests. */ + defaultMemoryStoreRetryDelaysMs?: number[]; } export interface DestroyProjectResult extends DestroyPlanResult { results: DestroyResourceResult[]; + defaultMemoryStoreResults: DestroyDefaultMemoryStoreResult[]; destroyed: number; partial: boolean; } @@ -62,7 +81,40 @@ export function planDestroyProjectContext(ctx: ProjectRuntimeContext): DestroyPl const resources = [...ctx.state.listResources()].sort( (a, b) => (destroyOrder[a.address.type] ?? 99) - (destroyOrder[b.address.type] ?? 99), ); - return { resources, executionContext: ctx }; + const identityName = ctx.config.defaults?.identity; + const defaultMemoryStores: DestroyDefaultMemoryStorePlan[] = []; + for (const [agentName, agent] of Object.entries(ctx.config.agents ?? {})) { + if (!agent.default_memory_store || agent.delivery?.qoder?.type !== "forward") continue; + if (agent.provider && agent.provider !== "qoder") continue; + defaultMemoryStores.push({ + agentName, + provider: "qoder", + identityId: identityName + ? (ctx.state.getResource({ type: "identity", name: identityName, provider: "qoder" })?.remote_id ?? null) + : null, + templateId: ctx.state.getResource({ type: "template", name: agentName, provider: "qoder" })?.remote_id ?? null, + deleteOnDestroy: agent.default_memory_store.delete_on_destroy ?? false, + }); + } + for (const pending of ctx.state.getStateFile().pending_default_memory_store_cleanups ?? []) { + const existing = defaultMemoryStores.find( + (item) => item.agentName === pending.agent_name && item.provider === pending.provider, + ); + if (existing) { + existing.memoryStoreId = pending.remote_id; + existing.deleteOnDestroy = true; + } else { + defaultMemoryStores.push({ + agentName: pending.agent_name, + provider: pending.provider, + identityId: pending.identity_id ?? null, + templateId: pending.template_id ?? null, + deleteOnDestroy: true, + memoryStoreId: pending.remote_id, + }); + } + } + return { resources, defaultMemoryStores, executionContext: ctx }; } export async function destroyPlannedProjectResources( @@ -70,8 +122,27 @@ export async function destroyPlannedProjectResources( options: DestroyProjectOptions = {}, ): Promise { const results: DestroyResourceResult[] = []; - let stateChanged = false; + const capturedDefaults = await captureDefaultMemoryStores(planned); const ctx = planned.executionContext; + if (capturedDefaults.some((item) => item.error)) { + const defaultMemoryStoreResults = capturedDefaults.map(preflightFailureResult); + for (const resource of planned.resources) { + const result: DestroyResourceResult = { + resource, + status: "blocked", + reason: "skipped", + error: "Destroy aborted because the default Memory Store preflight failed.", + }; + results.push(result); + options.onResourceResult?.(result); + } + return { ...planned, results, defaultMemoryStoreResults, destroyed: 0, partial: true }; + } + if (await persistCapturedDefaultMemoryStores(ctx, capturedDefaults)) { + await ctx.state.save(); + } + + let stateChanged = false; for (const resource of planned.resources) { options.onResourceStart?.(resource); @@ -84,16 +155,222 @@ export async function destroyPlannedProjectResources( if (stateChanged) { await ctx.state.save(); } + const defaultMemoryStoreResults = await finalizeDefaultMemoryStores(planned, capturedDefaults, options); + if (recordFailedDefaultMemoryStoreCleanups(ctx, defaultMemoryStoreResults)) { + await ctx.state.save(); + } + if (clearCompletedDefaultMemoryStoreCleanups(ctx, defaultMemoryStoreResults)) { + await ctx.state.save(); + } const destroyed = results.filter((result) => result.status === "success").length; return { ...planned, results, + defaultMemoryStoreResults, destroyed, - partial: destroyed !== planned.resources.length, + partial: + destroyed !== planned.resources.length || defaultMemoryStoreResults.some((result) => result.status === "failed"), }; } +interface CapturedDefaultMemoryStore { + plan: DestroyDefaultMemoryStorePlan; + memoryStoreId?: string; + error?: string; +} + +async function persistCapturedDefaultMemoryStores( + ctx: ProjectRuntimeContext, + captured: CapturedDefaultMemoryStore[], +): Promise { + const state = ctx.state.getStateFile(); + if (!state.pending_default_memory_store_cleanups) state.pending_default_memory_store_cleanups = []; + const pending = state.pending_default_memory_store_cleanups; + let changed = false; + for (const item of captured) { + if (!item.plan.deleteOnDestroy || !item.memoryStoreId) continue; + if (pending.some((entry) => entry.provider === item.plan.provider && entry.remote_id === item.memoryStoreId)) + continue; + pending.push({ + agent_name: item.plan.agentName, + provider: item.plan.provider, + remote_id: item.memoryStoreId, + ...(item.plan.identityId ? { identity_id: item.plan.identityId } : {}), + ...(item.plan.templateId ? { template_id: item.plan.templateId } : {}), + }); + changed = true; + } + return changed; +} + +function clearCompletedDefaultMemoryStoreCleanups( + ctx: ProjectRuntimeContext, + results: DestroyDefaultMemoryStoreResult[], +): boolean { + const state = ctx.state.getStateFile(); + const completedIds = new Set( + results + .filter((item) => item.status === "deleted" || item.status === "already_gone") + .map((item) => item.memoryStoreId) + .filter((id): id is string => Boolean(id)), + ); + if (completedIds.size === 0 || !state.pending_default_memory_store_cleanups?.length) return false; + const remaining = state.pending_default_memory_store_cleanups.filter((item) => !completedIds.has(item.remote_id)); + if (remaining.length === state.pending_default_memory_store_cleanups.length) return false; + state.pending_default_memory_store_cleanups = remaining; + return true; +} + +function recordFailedDefaultMemoryStoreCleanups( + ctx: ProjectRuntimeContext, + results: DestroyDefaultMemoryStoreResult[], +): boolean { + const pending = ctx.state.getStateFile().pending_default_memory_store_cleanups; + if (!pending?.length) return false; + let changed = false; + for (const result of results) { + if (result.status !== "failed" || !result.memoryStoreId) continue; + const entry = pending.find((item) => item.remote_id === result.memoryStoreId); + if (entry && entry.last_error !== result.error) { + entry.last_error = result.error; + changed = true; + } + } + return changed; +} + +function preflightFailureResult(item: CapturedDefaultMemoryStore): DestroyDefaultMemoryStoreResult { + if (!item.plan.deleteOnDestroy) return { ...item.plan, status: "retained" }; + return { + ...item.plan, + status: "failed", + ...(item.memoryStoreId ? { memoryStoreId: item.memoryStoreId } : {}), + error: item.error ?? "Destroy aborted because another default Memory Store preflight failed.", + }; +} + +async function captureDefaultMemoryStores(planned: DestroyPlanResult): Promise { + return Promise.all( + planned.defaultMemoryStores.map(async (item) => { + if (!item.deleteOnDestroy) return { plan: item }; + if (item.memoryStoreId) return { plan: item, memoryStoreId: item.memoryStoreId }; + if (!item.identityId || !item.templateId) { + return { plan: item, error: "Cannot resolve the Identity and Template before destroy." }; + } + try { + const provider = getRuntimeProvider(planned.executionContext, item.provider); + if (!provider.findDefaultMemoryStoreId || !provider.deleteDefaultMemoryStore) { + return { plan: item, error: `Provider '${item.provider}' cannot delete a default memory store.` }; + } + const memoryStoreId = await provider.findDefaultMemoryStoreId(item.identityId, item.templateId); + return { plan: item, ...(memoryStoreId ? { memoryStoreId } : {}) }; + } catch (error) { + return { plan: item, error: error instanceof Error ? error.message : String(error) }; + } + }), + ); +} + +async function finalizeDefaultMemoryStores( + planned: DestroyPlanResult, + captured: CapturedDefaultMemoryStore[], + options: DestroyProjectOptions, +): Promise { + const output: DestroyDefaultMemoryStoreResult[] = []; + for (const item of captured) { + if (!item.plan.deleteOnDestroy) { + output.push({ ...item.plan, status: "retained" }); + continue; + } + if (item.error) { + output.push({ ...item.plan, status: "failed", error: item.error }); + continue; + } + if (!item.memoryStoreId) { + output.push({ ...item.plan, status: "already_gone" }); + continue; + } + try { + const provider = getRuntimeProvider(planned.executionContext, item.plan.provider); + await deleteDefaultMemoryStoreWithArchiveFallback( + provider, + item.memoryStoreId, + options.defaultMemoryStoreRetryDelaysMs ?? [1000, 2000, 4000, 8000], + ); + output.push({ ...item.plan, status: "deleted", memoryStoreId: item.memoryStoreId }); + } catch (error) { + if (ApiError.isNotFound(error)) { + output.push({ ...item.plan, status: "already_gone", memoryStoreId: item.memoryStoreId }); + } else { + output.push({ + ...item.plan, + status: "failed", + memoryStoreId: item.memoryStoreId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + return output; +} + +async function deleteDefaultMemoryStoreWithArchiveFallback( + provider: ProviderAdapter, + memoryStoreId: string, + retryDelaysMs: number[], +): Promise { + try { + await retryDefaultMemoryStoreOperation(() => provider.deleteDefaultMemoryStore!(memoryStoreId), retryDelaysMs); + } catch (error) { + if (isAlreadyArchivedConflict(error) && provider.deleteMemoryStore) { + await retryDefaultMemoryStoreOperation(() => provider.deleteMemoryStore!(memoryStoreId), retryDelaysMs); + return; + } + if (!isStillMountedConflict(error) || !provider.archiveMemoryStore || !provider.deleteMemoryStore) throw error; + await retryDefaultMemoryStoreOperation(() => provider.archiveMemoryStore!(memoryStoreId), retryDelaysMs); + await retryDefaultMemoryStoreOperation(() => provider.deleteMemoryStore!(memoryStoreId), retryDelaysMs); + } +} + +async function retryDefaultMemoryStoreOperation(operation: () => Promise, delaysMs: number[]): Promise { + for (let attempt = 0; ; attempt++) { + try { + await operation(); + return; + } catch (error) { + if (!isRetryableDefaultMemoryStoreError(error) || attempt >= delaysMs.length) throw error; + await delay(delaysMs[attempt]!); + } + } +} + +function isRetryableDefaultMemoryStoreError(error: unknown): boolean { + return ( + error instanceof ApiError && + (isStillMountedConflict(error) || + isAlreadyArchivedConflict(error) || + error.statusCode === 429 || + [500, 502, 503].includes(error.statusCode)) + ); +} + +function isStillMountedConflict(error: unknown): boolean { + return error instanceof ApiError && error.statusCode === 409 && error.responseBody.includes("still mounted"); +} + +function isAlreadyArchivedConflict(error: unknown): boolean { + return ( + error instanceof ApiError && + error.statusCode === 409 && + error.responseBody.toLowerCase().includes("already archived") + ); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + async function destroyOneResource( ctx: ProjectRuntimeContext, resource: ResourceState, diff --git a/packages/sdk/src/internal/core/validate-config.ts b/packages/sdk/src/internal/core/validate-config.ts index 6d45f70..61e4b3c 100644 --- a/packages/sdk/src/internal/core/validate-config.ts +++ b/packages/sdk/src/internal/core/validate-config.ts @@ -318,6 +318,37 @@ export function collectProviderCapabilities( address, ); } + if (agent.default_memory_store) { + if (providerName !== "qoder" || delivery !== "forward") { + diagnostics.error( + `${providerName}.agent.default_memory_store.forward_required`, + `agent.${name}: default_memory_store is supported only by Qoder Forward delivery.`, + address, + ); + } else if (!config.defaults?.identity) { + diagnostics.error( + "qoder.template.default_memory_store.identity.required", + `agent.${name}: default_memory_store requires defaults.identity to select the owning Forward Identity.`, + { type: "template", name, provider: providerName }, + ); + } else { + const identity = config.identities?.[config.defaults.identity]; + if (identity?.provider && identity.provider !== providerName) { + diagnostics.error( + "qoder.template.default_memory_store.identity.provider_mismatch", + `agent.${name}: defaults.identity '${config.defaults.identity}' is pinned to provider '${identity.provider}'.`, + { type: "template", name, provider: providerName }, + ); + } + if (agent.default_memory_store.delete_on_destroy && identity?.identity_id) { + diagnostics.error( + "qoder.template.default_memory_store.delete.external_identity", + `agent.${name}: delete_on_destroy requires an OpenCMA-managed Identity because an external Identity keeps the default Memory Store mounted.`, + { type: "template", name, provider: providerName }, + ); + } + } + } if (delivery === "forward" && !isSupported(caps, "template")) { diagnostics.error( `${providerName}.agent.delivery.forward.unsupported`, diff --git a/packages/sdk/src/internal/executor/executor.ts b/packages/sdk/src/internal/executor/executor.ts index 9794754..16fa6a5 100644 --- a/packages/sdk/src/internal/executor/executor.ts +++ b/packages/sdk/src/internal/executor/executor.ts @@ -136,6 +136,11 @@ export async function executePlan( await ctx.state.save(); } + // Qoder creates the writable default Store lazily on the first Forward Session. + // Reconcile on every apply (including an otherwise no-op plan), so the first apply + // after that Session converges its display metadata without creating a throwaway Session. + await reconcileDefaultMemoryStores(ctx, new Set(plan.actions.map((action) => action.address.provider))); + // delete: run serially, preserving the planner's reverse dependency order. for (const action of deletions) { await runAction(action); @@ -149,6 +154,44 @@ export async function executePlan( }; } +async function reconcileDefaultMemoryStores(ctx: ExecContext, plannedProviders: ReadonlySet): Promise { + const identityName = ctx.config.defaults?.identity; + for (const [agentName, agent] of Object.entries(ctx.config.agents ?? {})) { + const desired = agent.default_memory_store; + if (!desired || agent.delivery?.qoder?.type !== "forward") continue; + const providerName = "qoder"; + if ((agent.provider && agent.provider !== providerName) || !plannedProviders.has(providerName)) continue; + const provider = ctx.providers.get(providerName); + if (!provider?.reconcileDefaultMemoryStore || !identityName) continue; + + const identityId = ctx.state.getResource({ + type: "identity", + name: identityName, + provider: providerName, + })?.remote_id; + const templateId = ctx.state.getResource({ type: "template", name: agentName, provider: providerName })?.remote_id; + if (!identityId || !templateId) continue; + + const result = await provider.reconcileDefaultMemoryStore(identityId, templateId, desired); + const resource = { type: "template" as const, name: agentName, provider: providerName }; + if (result.status === "pending") { + emitRuntimeFeedback(ctx.onFeedback, { + type: "provider_wait", + level: "warning", + resource, + message: `default memory store for template.${agentName} is pending — create the first Forward Session, then run apply again`, + }); + } else if (result.status === "updated") { + emitRuntimeFeedback(ctx.onFeedback, { + type: "resource_action_success", + level: "success", + resource, + message: `updated default memory store for template.${agentName} to "${desired.name}"`, + }); + } + } +} + // Run `worker` over `items` with at most `limit` concurrent executions. async function runWithConcurrency(items: T[], limit: number, worker: (item: T) => Promise): Promise { if (items.length === 0) return; diff --git a/packages/sdk/src/internal/graph/dependency.ts b/packages/sdk/src/internal/graph/dependency.ts index 74db72f..1f2109d 100644 --- a/packages/sdk/src/internal/graph/dependency.ts +++ b/packages/sdk/src/internal/graph/dependency.ts @@ -88,6 +88,14 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str const agentAddr: ResourceAddress = { type: materialization.resourceType, name, provider }; addNode(agentAddr); + if (decl.default_memory_store && materialization.resourceType === "template") { + const identityName = config.defaults?.identity; + if (identityName) { + const identityAddr: ResourceAddress = { type: "identity", name: identityName, provider }; + if (nodes.has(addressKey(identityAddr))) addEdge(agentAddr, identityAddr); + } + } + if (decl.environment && config.environments?.[decl.environment]) { const envAddr: ResourceAddress = { type: "environment", diff --git a/packages/sdk/src/internal/parser/schema.ts b/packages/sdk/src/internal/parser/schema.ts index cbe5f47..e9769b5 100644 --- a/packages/sdk/src/internal/parser/schema.ts +++ b/packages/sdk/src/internal/parser/schema.ts @@ -261,6 +261,13 @@ const agentSchema = z.object({ skills: z.array(z.union([z.string(), agentSkillRefSchema])).optional(), vault: z.string().optional(), memory_stores: z.array(z.string()).optional(), + default_memory_store: z + .object({ + name: z.string().trim().min(1).max(255), + description: z.string().max(1024).optional(), + delete_on_destroy: z.boolean().optional().default(false), + }) + .optional(), resources: z.array(sessionGithubRepoResourceSchema).optional(), multiagent: multiagentSchema.optional(), metadata: z.record(z.string(), z.string()).optional(), diff --git a/packages/sdk/src/internal/providers/interface.ts b/packages/sdk/src/internal/providers/interface.ts index b86b826..f7bdb52 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, + DefaultMemoryStoreDecl, DeploymentDecl, EnvironmentDecl, IdentityDecl, @@ -101,6 +102,11 @@ export interface ResolvedChannelRefs { agent_id?: string; } +export interface DefaultMemoryStoreReconcileResult { + status: "updated" | "unchanged" | "pending"; + memory_store_id?: string; +} + export interface DeploymentContext { id: string | null; name: string; @@ -201,6 +207,13 @@ export interface ProviderAdapter { updateTemplate?(id: string, name: string, decl: AgentDecl, refs: ResolvedTemplateRefs): Promise; /** Remove the template from desired state. Qoder implements this as a soft archive. */ archiveTemplate?(id: string): Promise; + reconcileDefaultMemoryStore?( + identityId: string, + templateId: string, + desired: DefaultMemoryStoreDecl, + ): Promise; + findDefaultMemoryStoreId?(identityId: string, templateId: string): Promise; + deleteDefaultMemoryStore?(id: string): Promise; createIdentity?(name: string, decl: IdentityDecl): Promise; updateIdentity?(id: string, name: string, decl: IdentityDecl): Promise; diff --git a/packages/sdk/src/internal/providers/qoder/adapter.ts b/packages/sdk/src/internal/providers/qoder/adapter.ts index 6461d60..e627381 100644 --- a/packages/sdk/src/internal/providers/qoder/adapter.ts +++ b/packages/sdk/src/internal/providers/qoder/adapter.ts @@ -526,6 +526,41 @@ export class QoderAdapter implements ProviderAdapter { await this.forwardClient.post(`/templates/${id}/archive`, {}); } + async reconcileDefaultMemoryStore( + identityId: string, + templateId: string, + desired: { name: string; description?: string }, + ): Promise<{ status: "updated" | "unchanged" | "pending"; memory_store_id?: string }> { + const storeId = await this.findDefaultMemoryStoreId(identityId, templateId); + if (!storeId) return { status: "pending" }; + + const current = (await this.forwardClient.get(`/memory_stores/${storeId}`)) as Record; + const descriptionChanged = desired.description !== undefined && current.description !== desired.description; + if (current.name === desired.name && !descriptionChanged) { + return { status: "unchanged", memory_store_id: storeId }; + } + + await this.forwardClient.post(`/memory_stores/${storeId}`, { + name: desired.name, + ...(desired.description !== undefined ? { description: desired.description } : {}), + }); + return { status: "updated", memory_store_id: storeId }; + } + + async findDefaultMemoryStoreId(identityId: string, templateId: string): Promise { + const mounts = (await this.forwardClient.get( + `/identities/${identityId}/templates/${templateId}/memory_stores`, + )) as { data?: Array> }; + const writableDefault = (mounts.data ?? []).find( + (mount) => mount.system_managed === true && mount.access === "read_write", + ); + return typeof writableDefault?.memory_store_id === "string" ? writableDefault.memory_store_id : null; + } + + async deleteDefaultMemoryStore(id: string): Promise { + await this.forwardClient.delete(`/memory_stores/${id}`); + } + async createIdentity(name: string, decl: IdentityDecl): Promise { if (decl.identity_id) return { id: decl.identity_id, type: "identity" }; const res = (await this.forwardClient.post("/identities", { diff --git a/packages/sdk/src/internal/state/state-manager.ts b/packages/sdk/src/internal/state/state-manager.ts index 5666549..bc6ac3d 100644 --- a/packages/sdk/src/internal/state/state-manager.ts +++ b/packages/sdk/src/internal/state/state-manager.ts @@ -51,7 +51,13 @@ export class StateManager implements IStateManager { drift_paths: r.drift_paths as string[] | undefined, drift_status: r.drift_status as ResourceState["drift_status"], })); - return new StateManager({ resources }, path); + const pending = Array.isArray(data.pending_default_memory_store_cleanups) + ? (data.pending_default_memory_store_cleanups as StateFile["pending_default_memory_store_cleanups"]) + : undefined; + return new StateManager( + { resources, ...(pending ? { pending_default_memory_store_cleanups: pending } : {}) }, + path, + ); } catch (err) { if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") { return StateManager.initialize(path); diff --git a/packages/sdk/src/internal/types/config.ts b/packages/sdk/src/internal/types/config.ts index 56353f3..f673bd5 100644 --- a/packages/sdk/src/internal/types/config.ts +++ b/packages/sdk/src/internal/types/config.ts @@ -171,6 +171,8 @@ export interface AgentDecl { skills?: AgentSkillDecl[]; vault?: string; memory_stores?: string[]; + /** Desired display metadata for Qoder Forward's system-managed writable Store. */ + default_memory_store?: DefaultMemoryStoreDecl; /** Resources mounted into every managed session created for this agent. */ resources?: SessionResourceDecl[]; multiagent?: MultiagentDecl; @@ -183,6 +185,13 @@ export interface AgentDecl { delivery?: Record; } +export interface DefaultMemoryStoreDecl { + name: string; + description?: string; + /** Permanently delete the system-managed Store during destroy. Defaults to false. */ + delete_on_destroy?: boolean; +} + export interface ManagedToolConfigDecl { /** Replaces the provider's enabled managed-tool set; an empty array disables all of them. */ enabled_tools: string[]; diff --git a/packages/sdk/src/internal/types/state.ts b/packages/sdk/src/internal/types/state.ts index 1395754..146fdac 100644 --- a/packages/sdk/src/internal/types/state.ts +++ b/packages/sdk/src/internal/types/state.ts @@ -35,6 +35,16 @@ export interface ResourceReadinessBaseline { export interface StateFile { resources: ResourceState[]; + pending_default_memory_store_cleanups?: PendingDefaultMemoryStoreCleanup[]; +} + +export interface PendingDefaultMemoryStoreCleanup { + agent_name: string; + provider: string; + remote_id: string; + identity_id?: string; + template_id?: string; + last_error?: string; } export function addressKey(addr: ResourceAddress): string { diff --git a/packages/sdk/tests/unit/destroy-runtime.test.ts b/packages/sdk/tests/unit/destroy-runtime.test.ts index d3b0c72..560b333 100644 --- a/packages/sdk/tests/unit/destroy-runtime.test.ts +++ b/packages/sdk/tests/unit/destroy-runtime.test.ts @@ -107,6 +107,220 @@ async function ctx(resources: ResourceState[], provider: ProviderAdapter): Promi } describe("destroy runtime", () => { + test("retains the default memory store unless deletion is explicitly enabled", async () => { + const calls: string[] = []; + const runtime = await ctx( + [resource("template", "assistant", "tmpl_1"), resource("identity", "user", "idn_1")], + adapter(calls, { + archiveTemplate: async (id) => calls.push(`template:${id}`), + deleteIdentity: async (id) => calls.push(`identity:${id}`), + findDefaultMemoryStoreId: async () => { + calls.push("find-default"); + return "memstore_1"; + }, + deleteDefaultMemoryStore: async (id) => calls.push(`default-memory:${id}`), + }), + ); + runtime.config.defaults = { provider: "qoder", identity: "user" }; + runtime.config.agents = { + assistant: { + model: { qoder: "auto" }, + instructions: "Help.", + delivery: { qoder: { type: "forward" } }, + default_memory_store: { name: "Support memory" }, + }, + }; + + const plan = planDestroyProjectContext(runtime); + expect(plan.defaultMemoryStores).toEqual([ + expect.objectContaining({ agentName: "assistant", deleteOnDestroy: false }), + ]); + const result = await destroyPlannedProjectResources(plan); + + expect(calls).toEqual(["template:tmpl_1", "identity:idn_1"]); + expect(result.defaultMemoryStoreResults).toEqual([ + expect.objectContaining({ agentName: "assistant", status: "retained" }), + ]); + expect(result.partial).toBe(false); + }); + + test("aborts destroy when the default Store preflight cannot capture its ID", async () => { + const calls: string[] = []; + const runtime = await ctx( + [resource("template", "assistant", "tmpl_1"), resource("identity", "user", "idn_1")], + adapter(calls, { + archiveTemplate: async (id) => calls.push(`template:${id}`), + deleteIdentity: async (id) => calls.push(`identity:${id}`), + findDefaultMemoryStoreId: async () => { + calls.push("find-default"); + throw new ApiError(503, "temporarily unavailable", "Forward API"); + }, + deleteDefaultMemoryStore: async (id) => calls.push(`default-memory:${id}`), + }), + ); + runtime.config.defaults = { provider: "qoder", identity: "user" }; + runtime.config.agents = { + assistant: { + model: { qoder: "auto" }, + instructions: "Help.", + delivery: { qoder: { type: "forward" } }, + default_memory_store: { name: "Support memory", delete_on_destroy: true }, + }, + }; + + const result = await destroyPlannedProjectResources(planDestroyProjectContext(runtime), { + defaultMemoryStoreRetryDelaysMs: [], + }); + + expect(calls).toEqual(["find-default"]); + expect(result.destroyed).toBe(0); + expect(result.results.every((item) => item.status === "blocked")).toBe(true); + expect(result.defaultMemoryStoreResults[0]).toMatchObject({ + status: "failed", + error: expect.stringContaining("temporarily unavailable"), + }); + }); + + test("captures the default Store before destroy and permanently deletes it afterwards", async () => { + const calls: string[] = []; + const runtime = await ctx( + [resource("template", "assistant", "tmpl_1"), resource("identity", "user", "idn_1")], + adapter(calls, { + archiveTemplate: async (id) => calls.push(`template:${id}`), + deleteIdentity: async (id) => calls.push(`identity:${id}`), + findDefaultMemoryStoreId: async (identityId, templateId) => { + calls.push(`find-default:${identityId}:${templateId}`); + return "memstore_1"; + }, + deleteDefaultMemoryStore: async (id) => calls.push(`default-memory:${id}`), + }), + ); + runtime.config.defaults = { provider: "qoder", identity: "user" }; + runtime.config.agents = { + assistant: { + model: { qoder: "auto" }, + instructions: "Help.", + delivery: { qoder: { type: "forward" } }, + default_memory_store: { name: "Support memory", delete_on_destroy: true }, + }, + }; + + const result = await destroyPlannedProjectResources(planDestroyProjectContext(runtime), { + defaultMemoryStoreRetryDelaysMs: [], + }); + + expect(calls).toEqual([ + "find-default:idn_1:tmpl_1", + "template:tmpl_1", + "identity:idn_1", + "default-memory:memstore_1", + ]); + expect(result.defaultMemoryStoreResults).toEqual([ + expect.objectContaining({ status: "deleted", memoryStoreId: "memstore_1" }), + ]); + expect(result.partial).toBe(false); + }); + + test("archives and retries a default Store when deletion is temporarily blocked by a stale mount", async () => { + const calls: string[] = []; + const runtime = await ctx( + [resource("template", "assistant", "tmpl_1"), resource("identity", "user", "idn_1")], + adapter(calls, { + archiveTemplate: async () => {}, + deleteIdentity: async () => {}, + findDefaultMemoryStoreId: async () => "memstore_1", + deleteDefaultMemoryStore: async (id) => { + calls.push(`delete-default:${id}`); + throw new ApiError(409, "still mounted", "Forward API"); + }, + archiveMemoryStore: async (id) => { + calls.push(`archive-default:${id}`); + return { id, type: "memory_store", name: "Support memory", status: "archived" }; + }, + deleteMemoryStore: async (id) => calls.push(`delete-archived:${id}`), + }), + ); + runtime.config.defaults = { provider: "qoder", identity: "user" }; + runtime.config.agents = { + assistant: { + model: { qoder: "auto" }, + instructions: "Help.", + delivery: { qoder: { type: "forward" } }, + default_memory_store: { name: "Support memory", delete_on_destroy: true }, + }, + }; + + const result = await destroyPlannedProjectResources(planDestroyProjectContext(runtime), { + defaultMemoryStoreRetryDelaysMs: [], + }); + + expect(calls).toEqual(["delete-default:memstore_1", "archive-default:memstore_1", "delete-archived:memstore_1"]); + expect(result.partial).toBe(false); + expect(result.defaultMemoryStoreResults[0]).toMatchObject({ + status: "deleted", + memoryStoreId: "memstore_1", + }); + }); + + test("persists a failed default Store cleanup and resumes it after ordinary resources are gone", async () => { + const runtime = await ctx( + [resource("template", "assistant", "tmpl_1"), resource("identity", "user", "idn_1")], + adapter([], { + archiveTemplate: async () => {}, + deleteIdentity: async () => {}, + findDefaultMemoryStoreId: async () => "memstore_1", + deleteDefaultMemoryStore: async () => { + throw new ApiError(409, "still mounted", "Forward API"); + }, + archiveMemoryStore: async () => { + throw new ApiError(409, "still mounted", "Forward API"); + }, + }), + ); + runtime.config.defaults = { provider: "qoder", identity: "user" }; + runtime.config.agents = { + assistant: { + model: { qoder: "auto" }, + instructions: "Help.", + delivery: { qoder: { type: "forward" } }, + default_memory_store: { name: "Support memory", delete_on_destroy: true }, + }, + }; + + const first = await destroyPlannedProjectResources(planDestroyProjectContext(runtime), { + defaultMemoryStoreRetryDelaysMs: [], + }); + expect(first.partial).toBe(true); + expect(runtime.state.getStateFile().pending_default_memory_store_cleanups).toEqual([ + expect.objectContaining({ agent_name: "assistant", remote_id: "memstore_1" }), + ]); + + let archivedDeleteAttempts = 0; + runtime.providers.set( + "qoder", + adapter([], { + deleteDefaultMemoryStore: async () => { + throw new ApiError(409, "Memory store is already archived.", "Forward API"); + }, + deleteMemoryStore: async () => { + if (archivedDeleteAttempts++ === 0) { + throw new ApiError(409, "Memory store is already archived.", "Qoder API"); + } + }, + }), + ); + const resumedPlan = planDestroyProjectContext(runtime); + expect(resumedPlan.resources).toHaveLength(0); + expect(resumedPlan.defaultMemoryStores[0]).toMatchObject({ memoryStoreId: "memstore_1" }); + const resumed = await destroyPlannedProjectResources(resumedPlan, { + defaultMemoryStoreRetryDelaysMs: [0], + }); + + expect(resumed.partial).toBe(false); + expect(resumed.defaultMemoryStoreResults[0]?.status).toBe("deleted"); + expect(runtime.state.getStateFile().pending_default_memory_store_cleanups).toEqual([]); + }); + test("plans resources in dependency-safe destroy order", async () => { const runtime: ProjectRuntimeContext = { projectName: "test", diff --git a/packages/sdk/tests/unit/parser.test.ts b/packages/sdk/tests/unit/parser.test.ts index dae10ca..b52e102 100644 --- a/packages/sdk/tests/unit/parser.test.ts +++ b/packages/sdk/tests/unit/parser.test.ts @@ -88,3 +88,29 @@ test("preserves Agent environment variables from YAML", async () => { RETRY_COUNT: "3", }); }); + +test("defaults default memory deletion to retain and accepts explicit deletion", () => { + const base = { + version: "1", + providers: { qoder: {} }, + agents: { + assistant: { + model: { qoder: "auto" }, + instructions: "Help.", + default_memory_store: { name: "Support memory" }, + }, + }, + }; + const retained = projectConfigSchema.parse(base); + expect(retained.agents?.assistant?.default_memory_store?.delete_on_destroy).toBe(false); + const deleted = projectConfigSchema.parse({ + ...base, + agents: { + assistant: { + ...base.agents.assistant, + default_memory_store: { name: "Support memory", delete_on_destroy: true }, + }, + }, + }); + expect(deleted.agents?.assistant?.default_memory_store?.delete_on_destroy).toBe(true); +}); diff --git a/packages/sdk/tests/unit/qoder-forward-template.test.ts b/packages/sdk/tests/unit/qoder-forward-template.test.ts index 80f52f0..a8317e3 100644 --- a/packages/sdk/tests/unit/qoder-forward-template.test.ts +++ b/packages/sdk/tests/unit/qoder-forward-template.test.ts @@ -59,6 +59,15 @@ function forwardConfig(): ProjectConfig { } describe("Qoder Forward Template declaration", () => { + test("makes the default Identity a Template dependency when default memory is configured", () => { + const config = forwardConfig(); + config.defaults = { provider: "qoder", identity: "zhang" }; + config.identities = { zhang: { external_id: "zhang" } }; + config.agents!.assistant!.default_memory_store = { name: "Support memory" }; + const graph = buildDependencyGraph(config, ["qoder"]); + expect([...graph.edges.get("qoder.template.assistant")!]).toContain("qoder.identity.zhang"); + }); + test("materializes a forward-delivered agent as a template graph resource", async () => { const config = forwardConfig(); const graph = buildDependencyGraph(config, ["qoder"]); @@ -386,6 +395,52 @@ describe("Qoder Forward Template mapping and lifecycle", () => { }); describe("Forward delivery validation and runtime isolation", () => { + test("requires a default Identity for default memory reconciliation", () => { + const config = forwardConfig(); + config.agents!.assistant!.default_memory_store = { name: "Support memory" }; + const diagnostics = validateProjectConfig(config); + expect(diagnostics.some((item) => item.code === "qoder.template.default_memory_store.identity.required")).toBe( + true, + ); + }); + + test("accepts default memory metadata for Qoder Forward delivery", () => { + const config = forwardConfig(); + config.defaults = { provider: "qoder", identity: "zhang" }; + config.identities = { zhang: { external_id: "zhang" } }; + config.agents!.assistant!.default_memory_store = { + name: "Support memory", + description: "Confirmed support knowledge", + }; + const diagnostics = validateProjectConfig(config); + expect(diagnostics.some((item) => item.code.includes("default_memory_store"))).toBe(false); + }); + + test("rejects a default memory Identity pinned to another provider", () => { + const config = forwardConfig(); + config.defaults = { provider: "qoder", identity: "zhang" }; + config.identities = { zhang: { external_id: "zhang", provider: "claude" } }; + config.agents!.assistant!.default_memory_store = { name: "Support memory" }; + const diagnostics = validateProjectConfig(config); + expect( + diagnostics.some((item) => item.code === "qoder.template.default_memory_store.identity.provider_mismatch"), + ).toBe(true); + }); + + test("rejects default memory deletion for an externally managed Identity", () => { + const config = forwardConfig(); + config.defaults = { provider: "qoder", identity: "zhang" }; + config.identities = { zhang: { identity_id: "idn_external" } }; + config.agents!.assistant!.default_memory_store = { + name: "Support memory", + delete_on_destroy: true, + }; + const diagnostics = validateProjectConfig(config); + expect( + diagnostics.some((item) => item.code === "qoder.template.default_memory_store.delete.external_identity"), + ).toBe(true); + }); + test("rejects Agent session resources because Forward sessions cannot attach them", () => { const config = forwardConfig(); config.agents!.assistant!.resources = [ @@ -500,3 +555,187 @@ describe("Forward delivery validation and runtime isolation", () => { expect(() => buildSessionBindings("assistant", config, "qoder", state)).toThrow(/defaults.identity/); }); }); + +describe("Qoder Forward default memory store", () => { + test("deletes a captured default Store through the Forward endpoint", async () => { + const calls: string[] = []; + const adapter = new QoderAdapter("pt-test") as any; + adapter.forwardClient = { + get: async (path: string) => { + calls.push(`GET ${path}`); + return { + data: [{ memory_store_id: "memstore_default", system_managed: true, access: "read_write" }], + }; + }, + delete: async (path: string) => calls.push(`DELETE ${path}`), + }; + + const id = await adapter.findDefaultMemoryStoreId("idn_1", "tmpl_1"); + await adapter.deleteDefaultMemoryStore(id!); + + expect(calls).toEqual([ + "GET /identities/idn_1/templates/tmpl_1/memory_stores", + "DELETE /memory_stores/memstore_default", + ]); + }); + + test("finds the writable system mount and updates changed metadata", async () => { + const calls: Array<{ path: string; body?: unknown }> = []; + const adapter = new QoderAdapter("pt-test") as any; + adapter.forwardClient = { + get: async (path: string) => { + calls.push({ path }); + if (path.includes("/identities/")) { + return { + data: [ + { memory_store_id: "memstore_default", system_managed: true, access: "read_write" }, + { memory_store_id: "memstore_explicit", system_managed: false, access: "read_only" }, + ], + }; + } + if (path === "/memory_stores/memstore_default") return { name: "System Default Memory", description: "" }; + throw new Error(`unexpected GET ${path}`); + }, + post: async (path: string, body: unknown) => { + calls.push({ path, body }); + return { id: "memstore_default" }; + }, + }; + + const result = await adapter.reconcileDefaultMemoryStore("idn_1", "tmpl_1", { + name: "Support memory", + description: "Confirmed support knowledge", + }); + expect(result).toEqual({ status: "updated", memory_store_id: "memstore_default" }); + expect(calls.at(-1)).toEqual({ + path: "/memory_stores/memstore_default", + body: { name: "Support memory", description: "Confirmed support knowledge" }, + }); + }); + + test("returns pending before the first Session creates a default Store", async () => { + const adapter = new QoderAdapter("pt-test") as any; + adapter.forwardClient = { get: async () => ({ data: [] }) }; + expect(await adapter.reconcileDefaultMemoryStore("idn_1", "tmpl_1", { name: "Support memory" })).toEqual({ + status: "pending", + }); + }); + + test("reconciles on an otherwise no-op apply", async () => { + const config = forwardConfig(); + config.defaults = { provider: "qoder", identity: "zhang" }; + config.identities = { zhang: { external_id: "zhang" } }; + config.agents!.assistant!.default_memory_store = { name: "Support memory" }; + const state = StateManager.initialize(tmpPath("default-memory-no-op")); + state.setResource({ + address: { type: "identity", name: "zhang", provider: "qoder" }, + remote_id: "idn_1", + content_hash: "identity-hash", + }); + state.setResource({ + address: { type: "template", name: "assistant", provider: "qoder" }, + remote_id: "tmpl_1", + content_hash: "template-hash", + }); + const calls: unknown[] = []; + const provider = { + reconcileDefaultMemoryStore: async (...args: unknown[]) => { + calls.push(args); + return { status: "unchanged", memory_store_id: "memstore_default" }; + }, + } as unknown as ProviderAdapter; + await executePlan( + { + actions: [ + { + action: "no-op", + address: { type: "template", name: "assistant", provider: "qoder" }, + dependencies: [], + }, + ], + diagnostics: [], + }, + { config, providers: new Map([["qoder", provider]]), state }, + ); + expect(calls).toEqual([["idn_1", "tmpl_1", { name: "Support memory" }]]); + }); + + test("does not reconcile Qoder defaults when the execution plan targets another provider", async () => { + const config = forwardConfig(); + config.defaults = { provider: "all", identity: "zhang" }; + config.identities = { zhang: { external_id: "zhang" } }; + config.agents!.assistant!.provider = "qoder"; + config.agents!.assistant!.default_memory_store = { name: "Support memory" }; + const state = StateManager.initialize(tmpPath("provider-scope")); + state.setResource({ + address: { type: "identity", name: "zhang", provider: "qoder" }, + remote_id: "idn_1", + content_hash: "identity-hash", + }); + state.setResource({ + address: { type: "template", name: "assistant", provider: "qoder" }, + remote_id: "tmpl_1", + content_hash: "template-hash", + }); + const calls: unknown[] = []; + const provider = { + reconcileDefaultMemoryStore: async (...args: unknown[]) => { + calls.push(args); + return { status: "unchanged" }; + }, + } as unknown as ProviderAdapter; + await executePlan( + { + actions: [ + { + action: "no-op", + address: { type: "agent", name: "other", provider: "claude" }, + dependencies: [], + }, + ], + diagnostics: [], + }, + { config, providers: new Map([["qoder", provider]]), state }, + ); + expect(calls).toEqual([]); + }); + + test("reconciles when Qoder is the sole provider and no provider default is declared", async () => { + const config = forwardConfig(); + config.defaults = { identity: "zhang" }; + config.identities = { zhang: { external_id: "zhang" } }; + config.agents!.assistant!.default_memory_store = { name: "Support memory" }; + const state = StateManager.initialize(tmpPath("implicit-qoder")); + state.setResource({ + address: { type: "identity", name: "zhang", provider: "qoder" }, + remote_id: "idn_1", + content_hash: "identity-hash", + }); + state.setResource({ + address: { type: "template", name: "assistant", provider: "qoder" }, + remote_id: "tmpl_1", + content_hash: "template-hash", + }); + const calls: unknown[] = []; + const provider = { + reconcileDefaultMemoryStore: async (...args: unknown[]) => { + calls.push(args); + return { status: "unchanged" }; + }, + } as unknown as ProviderAdapter; + await executePlan( + { + actions: [ + { + action: "no-op", + address: { type: "template", name: "assistant", provider: "qoder" }, + dependencies: [], + }, + ], + diagnostics: [], + }, + { config, providers: new Map([["qoder", provider]]), state }, + ); + expect(calls).toEqual([["idn_1", "tmpl_1", { name: "Support memory" }]]); + }); +}); diff --git a/packages/sdk/tests/unit/slim-state.test.ts b/packages/sdk/tests/unit/slim-state.test.ts index a5357ff..dac752f 100644 --- a/packages/sdk/tests/unit/slim-state.test.ts +++ b/packages/sdk/tests/unit/slim-state.test.ts @@ -78,6 +78,27 @@ describe("StateManager backward compat", () => { expect(r[0]!.remote_id).toBe("env_x"); expect(r[0]!.version).toBeUndefined(); }); + + test("round-trips pending default Memory Store cleanup state", async () => { + const path = tmpPath(); + const sm = StateManager.initialize(path); + sm.getStateFile().pending_default_memory_store_cleanups = [ + { + agent_name: "assistant", + provider: "qoder", + remote_id: "memstore_1", + identity_id: "idn_1", + template_id: "tmpl_1", + last_error: "still mounted", + }, + ]; + await sm.save(); + + const loaded = await StateManager.load(path); + expect(loaded.getStateFile().pending_default_memory_store_cleanups).toEqual( + sm.getStateFile().pending_default_memory_store_cleanups, + ); + }); }); describe("State only contains load-bearing fields", () => { From 51af416b767ad440487668f3d0cb82463c17c43c Mon Sep 17 00:00:00 2001 From: heimanba <371510756@qq.com> Date: Thu, 20 Aug 2026 22:56:03 +0800 Subject: [PATCH 2/4] fix(qoder): route forward-owned resources correctly Change-Id: If1d4eaa1c1713dd5510bec6ab84a0a8407ef63f1 --- .../sdk/src/internal/executor/executor.ts | 76 +++++++++++++++---- .../sdk/src/internal/providers/interface.ts | 18 +++-- .../src/internal/providers/qoder/adapter.ts | 51 +++++++------ .../internal/providers/resource-workflow.ts | 17 +++-- .../sdk/src/internal/state/state-manager.ts | 1 + packages/sdk/src/internal/types/state.ts | 2 + 6 files changed, 118 insertions(+), 47 deletions(-) diff --git a/packages/sdk/src/internal/executor/executor.ts b/packages/sdk/src/internal/executor/executor.ts index 16fa6a5..2378705 100644 --- a/packages/sdk/src/internal/executor/executor.ts +++ b/packages/sdk/src/internal/executor/executor.ts @@ -7,7 +7,7 @@ import { buildReadinessBaseline } from "../planner/plan-semantics.ts"; import { ApiError, ConflictError } from "../providers/base-client.ts"; import { DeploymentCreateConflictError } from "../providers/deployment-conflict.ts"; import { readComparableIfSupported } from "../providers/drift-support.ts"; -import type { RemoteResource } from "../providers/interface.ts"; +import type { ProviderResourceMode, RemoteResource } from "../providers/interface.ts"; import type { DriftReadAdapter, ResourceCrudAdapter } from "../providers/resource-workflow.ts"; import type { ExecutionPlan, PlannedAction } from "../types/plan.ts"; import type { RuntimeFeedbackSink } from "../types/runtime-feedback.ts"; @@ -291,6 +291,7 @@ async function executeActionInner( const existing = ctx.state.getResource(address); if (!existing) return false; const id = existing.remote_id; + const apiMode = existing.api_mode; // External-reference environments are owned outside OpenCMA; deleting them // here would only remove the local state entry. @@ -312,10 +313,10 @@ async function executeActionInner( await provider.deleteEnvironment(id); break; case "vault": - await provider.deleteVault(id); + await provider.deleteVault(id, apiMode); break; case "skill": - await provider.deleteSkill(id); + await provider.deleteSkill(id, apiMode); break; case "agent": await provider.deleteAgent(id); @@ -363,6 +364,10 @@ async function executeActionInner( const isUpdate = action.action === "update"; const priorAddress = action.previousAddress ?? address; const existingId = isUpdate ? ctx.state.getResource(priorAddress)?.remote_id : undefined; + const apiMode = resolveResourceApiMode(type, name, address.provider, ctx.config); + const priorApiMode = + ctx.state.getResource(priorAddress)?.api_mode ?? (address.provider === "qoder" ? "managed" : undefined); + const apiModeChanged = isUpdate && apiMode !== undefined && priorApiMode !== apiMode; let result: RemoteResource; @@ -409,22 +414,25 @@ async function executeActionInner( } case "vault": { const decl = ctx.config.vaults![name]!; - if (isUpdate) { + if (apiModeChanged) { + result = await provider.createVault(name, decl, apiMode); + if (existingId) await provider.deleteVault(existingId, priorApiMode).catch(() => undefined); + } else if (isUpdate) { try { - result = await provider.createVault(name, decl); - await provider.deleteVault(existingId!); + result = await provider.createVault(name, decl, apiMode); + await provider.deleteVault(existingId!, apiMode); } catch { - await provider.deleteVault(existingId!); - result = await provider.createVault(name, decl); + await provider.deleteVault(existingId!, apiMode); + result = await provider.createVault(name, decl, apiMode); } } else { try { - result = await provider.createVault(name, decl); + result = await provider.createVault(name, decl, apiMode); } catch (err) { result = await adoptOnConflict(err, address, provider, ctx.onFeedback, { onExisting: async (existing) => { - await provider.deleteVault(existing.id!); - return provider.createVault(name, decl); + await provider.deleteVault(existing.id!, apiMode); + return provider.createVault(name, decl, apiMode); }, }); adopted = true; @@ -449,8 +457,11 @@ async function executeActionInner( } const remoteName = decl.name ?? name; const files = await resolveSkillFiles(decl, ctx); - if (isUpdate) { - result = await provider.updateSkill(existingId!, remoteName, decl, files); + if (apiModeChanged) { + result = await provider.createSkill(remoteName, decl, files, apiMode); + if (existingId) await provider.deleteSkill(existingId, priorApiMode).catch(() => undefined); + } else if (isUpdate) { + result = await provider.updateSkill(existingId!, remoteName, decl, files, apiMode); } else { // A skill's remote name is not always the agents.yaml key: providers register // it under the SKILL.md frontmatter `name` (Bailian reads it server-side, @@ -472,7 +483,7 @@ async function executeActionInner( adopted = true; } else { try { - result = await provider.createSkill(remoteName, decl, files); + result = await provider.createSkill(remoteName, decl, files, apiMode); } catch (err) { result = await adoptOnConflict(err, address, provider, ctx.onFeedback, { searchNames, @@ -732,6 +743,7 @@ async function executeActionInner( (type === "identity" && ctx.config.identities?.[name]?.identity_id) ? true : undefined, + api_mode: apiMode, version: result.version, content_hash: hash, desired_hash: hash, @@ -747,6 +759,42 @@ async function executeActionInner( return adopted; } +function resolveResourceApiMode( + type: ResourceType, + name: string, + provider: string, + config: ExecContext["config"], +): ProviderResourceMode | undefined { + if (provider !== "qoder" || (type !== "skill" && type !== "vault" && type !== "memory_store" && type !== "file")) { + return undefined; + } + + let managed = false; + let forward = false; + for (const agent of Object.values(config.agents ?? {})) { + if (agent.provider && agent.provider !== provider) continue; + const referenced = + type === "skill" + ? agent.skills?.some((skill) => + typeof skill === "string" ? skill === name : skill.type === "custom" && skill.skill_id === name, + ) + : type === "vault" + ? agent.vault === name + : type === "memory_store" + ? agent.memory_stores?.includes(name) + : false; + if (!referenced) continue; + if (agent.delivery?.qoder?.type === "forward") forward = true; + else managed = true; + } + if (managed && forward) { + throw new UserError( + `Qoder ${type}.${name} is referenced by both Managed and Forward agents; declare separate resources for each API domain.`, + ); + } + return forward ? "forward" : "managed"; +} + async function findExistingByNames( provider: Pick, type: ResourceType, diff --git a/packages/sdk/src/internal/providers/interface.ts b/packages/sdk/src/internal/providers/interface.ts index f7bdb52..6040113 100644 --- a/packages/sdk/src/internal/providers/interface.ts +++ b/packages/sdk/src/internal/providers/interface.ts @@ -44,6 +44,8 @@ export interface RemoteResource { version?: number; } +export type ProviderResourceMode = "managed" | "forward"; + export interface ModelInfo { id: string; display_name: string; @@ -192,12 +194,18 @@ export interface ProviderAdapter { updateEnvironment(id: string, name: string, decl: EnvironmentDecl): Promise; deleteEnvironment(id: string, cascade?: boolean): Promise; - createVault(name: string, decl: VaultDecl): Promise; - deleteVault(id: string): Promise; + createVault(name: string, decl: VaultDecl, mode?: ProviderResourceMode): Promise; + deleteVault(id: string, mode?: ProviderResourceMode): Promise; - createSkill(name: string, decl: SkillDecl, files: SkillFile[]): Promise; - updateSkill(id: string, name: string, decl: SkillDecl, files: SkillFile[]): Promise; - deleteSkill(id: string): Promise; + createSkill(name: string, decl: SkillDecl, files: SkillFile[], mode?: ProviderResourceMode): Promise; + updateSkill( + id: string, + name: string, + decl: SkillDecl, + files: SkillFile[], + mode?: ProviderResourceMode, + ): Promise; + deleteSkill(id: string, mode?: ProviderResourceMode): Promise; createAgent(name: string, decl: AgentDecl, refs: ResolvedAgentRefs): Promise; updateAgent(id: string, name: string, decl: AgentDecl, refs: ResolvedAgentRefs): Promise; diff --git a/packages/sdk/src/internal/providers/qoder/adapter.ts b/packages/sdk/src/internal/providers/qoder/adapter.ts index e627381..fd151e3 100644 --- a/packages/sdk/src/internal/providers/qoder/adapter.ts +++ b/packages/sdk/src/internal/providers/qoder/adapter.ts @@ -52,6 +52,7 @@ import type { ExportedResource, ModelInfo, ProviderAdapter, + ProviderResourceMode, RemoteResource, ResolvedAgentRefs, ResolvedChannelRefs, @@ -446,20 +447,21 @@ export class QoderAdapter implements ProviderAdapter { } } - async createVault(name: string, decl: VaultDecl): Promise { + async createVault(name: string, decl: VaultDecl, mode: ProviderResourceMode = "managed"): Promise { + const client = mode === "forward" ? this.forwardClient : this.client; const body = mapVault(name, decl, this.projectName); - const res = (await this.client.post("/vaults", body)) as Record; + const res = (await client.post("/vaults", body)) as Record; const vaultId = res.id as string; // Credentials are not accepted inline at vault creation; add each via the // dedicated endpoint (mirrors the bailian adapter's two-step flow). for (const cred of decl.credentials ?? []) { - await this.client.post(`/vaults/${vaultId}/credentials`, mapCredential(cred)); + await client.post(`/vaults/${vaultId}/credentials`, mapCredential(cred)); } return toRemoteResource(res); } - async deleteVault(id: string): Promise { - await this.client.delete(`/vaults/${id}`); + async deleteVault(id: string, mode: ProviderResourceMode = "managed"): Promise { + await (mode === "forward" ? this.forwardClient : this.client).delete(`/vaults/${id}`); } async exportResources(type: ResourceType): Promise { @@ -472,19 +474,33 @@ export class QoderAdapter implements ProviderAdapter { }); } - async createSkill(name: string, decl: SkillDecl, files: SkillFile[]): Promise { + async createSkill( + name: string, + decl: SkillDecl, + files: SkillFile[], + mode: ProviderResourceMode = "managed", + ): Promise { const formData = await buildSkillFormData(name, decl, files); - const res = (await this.client.postFormData("/skills", formData)) as Record; + const res = (await (mode === "forward" ? this.forwardClient : this.client).postFormData( + "/skills", + formData, + )) as Record; return toRemoteResource(res); } - async updateSkill(id: string, name: string, decl: SkillDecl, files: SkillFile[]): Promise { - await this.client.delete(`/skills/${id}`); - return this.createSkill(name, decl, files); + async updateSkill( + id: string, + name: string, + decl: SkillDecl, + files: SkillFile[], + mode: ProviderResourceMode = "managed", + ): Promise { + await (mode === "forward" ? this.forwardClient : this.client).delete(`/skills/${id}`); + return this.createSkill(name, decl, files, mode); } - async deleteSkill(id: string): Promise { - await this.client.delete(`/skills/${id}`); + async deleteSkill(id: string, mode: ProviderResourceMode = "managed"): Promise { + await (mode === "forward" ? this.forwardClient : this.client).delete(`/skills/${id}`); } async createAgent(name: string, decl: AgentDecl, refs: ResolvedAgentRefs): Promise { @@ -507,14 +523,12 @@ export class QoderAdapter implements ProviderAdapter { } async createTemplate(name: string, decl: AgentDecl, refs: ResolvedTemplateRefs): Promise { - await this.registerForwardVaults(refs.vault_ids); const body = mapForwardTemplate(name, decl, refs, this.projectName); const res = (await this.forwardClient.post("/templates", body)) as Record; return toRemoteResource(res); } async updateTemplate(id: string, name: string, decl: AgentDecl, refs: ResolvedTemplateRefs): Promise { - await this.registerForwardVaults(refs.vault_ids); const body = mapForwardTemplate(name, decl, refs, this.projectName) as Record; // Forward updates are merge-style; null explicitly clears a previously inherited BYOC tunnel. if (!refs.tunnel_id) body.tunnel_id = null; @@ -644,15 +658,6 @@ export class QoderAdapter implements ProviderAdapter { return body; } - private async registerForwardVaults(vaultIds: string[]): Promise { - for (const id of vaultIds) { - await this.forwardClient.post("/resources/registry", { - type: "vault", - resource: { id }, - }); - } - } - async createMemoryStore(name: string, decl: MemoryStoreDecl): Promise { const body = mapMemoryStore(name, decl); const res = (await this.client.post("/memory_stores", body)) as Record; diff --git a/packages/sdk/src/internal/providers/resource-workflow.ts b/packages/sdk/src/internal/providers/resource-workflow.ts index 9ca8eed..edec9b4 100644 --- a/packages/sdk/src/internal/providers/resource-workflow.ts +++ b/packages/sdk/src/internal/providers/resource-workflow.ts @@ -27,6 +27,7 @@ import type { DeploymentInfo, DeploymentRunResult, DriftSupport, + ProviderResourceMode, RemoteResource, ResolvedAgentRefs, ResolvedChannelRefs, @@ -49,12 +50,18 @@ export interface ResourceCrudAdapter { updateEnvironment(id: string, name: string, decl: EnvironmentDecl): Promise; deleteEnvironment(id: string, cascade?: boolean): Promise; - createVault(name: string, decl: VaultDecl): Promise; - deleteVault(id: string): Promise; + createVault(name: string, decl: VaultDecl, mode?: ProviderResourceMode): Promise; + deleteVault(id: string, mode?: ProviderResourceMode): Promise; - createSkill(name: string, decl: SkillDecl, files: SkillFile[]): Promise; - updateSkill(id: string, name: string, decl: SkillDecl, files: SkillFile[]): Promise; - deleteSkill(id: string): Promise; + createSkill(name: string, decl: SkillDecl, files: SkillFile[], mode?: ProviderResourceMode): Promise; + updateSkill( + id: string, + name: string, + decl: SkillDecl, + files: SkillFile[], + mode?: ProviderResourceMode, + ): Promise; + deleteSkill(id: string, mode?: ProviderResourceMode): Promise; createAgent(name: string, decl: AgentDecl, refs: ResolvedAgentRefs): Promise; updateAgent(id: string, name: string, decl: AgentDecl, refs: ResolvedAgentRefs): Promise; diff --git a/packages/sdk/src/internal/state/state-manager.ts b/packages/sdk/src/internal/state/state-manager.ts index bc6ac3d..d53ebd6 100644 --- a/packages/sdk/src/internal/state/state-manager.ts +++ b/packages/sdk/src/internal/state/state-manager.ts @@ -40,6 +40,7 @@ export class StateManager implements IStateManager { address: r.address as ResourceState["address"], remote_id: r.remote_id as string | null, externally_managed: r.externally_managed === true ? true : undefined, + api_mode: r.api_mode === "forward" ? "forward" : r.api_mode === "managed" ? "managed" : undefined, version: r.version as number | undefined, content_hash: ((r.content_hash ?? r.desired_hash) as string) ?? "", desired_hash: ((r.desired_hash ?? r.content_hash) as string) ?? "", diff --git a/packages/sdk/src/internal/types/state.ts b/packages/sdk/src/internal/types/state.ts index 146fdac..55d7d92 100644 --- a/packages/sdk/src/internal/types/state.ts +++ b/packages/sdk/src/internal/types/state.ts @@ -10,6 +10,8 @@ export interface ResourceState { * referenced by this project. It must never be deleted remotely. */ externally_managed?: boolean; + /** Provider API domain that owns the remote resource (currently relevant to Qoder). */ + api_mode?: "managed" | "forward"; version?: number; /** * Backward-compatible alias for desired_hash. Kept while older state files From bd0fa044b8cc8fc4e2417b0c9c6e23a404868730 Mon Sep 17 00:00:00 2001 From: heimanba <371510756@qq.com> Date: Thu, 20 Aug 2026 22:59:47 +0800 Subject: [PATCH 3/4] fix(qoder): keep forward resource ownership consistent Change-Id: I611e80304831f8240784d56362f64bcf45a0e641 --- .../sdk/src/internal/core/validate-config.ts | 35 ++++++- .../sdk/src/internal/executor/executor.ts | 40 +++++--- .../sdk/src/internal/executor/resolver.ts | 6 ++ packages/sdk/src/internal/graph/dependency.ts | 2 +- packages/sdk/src/internal/planner/hasher.ts | 30 +++++- .../sdk/src/internal/providers/base-client.ts | 4 +- .../sdk/src/internal/providers/interface.ts | 23 +++-- .../src/internal/providers/qoder/adapter.ts | 78 ++++++++++++--- .../internal/providers/resource-workflow.ts | 21 ++-- .../tests/unit/qoder-forward-template.test.ts | 98 ++++++++++++++++++- 10 files changed, 285 insertions(+), 52 deletions(-) diff --git a/packages/sdk/src/internal/core/validate-config.ts b/packages/sdk/src/internal/core/validate-config.ts index 61e4b3c..d809470 100644 --- a/packages/sdk/src/internal/core/validate-config.ts +++ b/packages/sdk/src/internal/core/validate-config.ts @@ -264,6 +264,35 @@ export function collectProviderCapabilities( } } + if (providerName === "qoder") { + const domains = new Map>(); + for (const agent of Object.values(config.agents ?? {})) { + if (agent.provider && agent.provider !== providerName) continue; + const mode = agent.delivery?.qoder?.type === "forward" ? "forward" : "managed"; + const refs = [ + ...(agent.skills ?? []).flatMap((skill) => + typeof skill === "string" ? [`skill:${skill}`] : skill.type === "custom" ? [`skill:${skill.skill_id}`] : [], + ), + ...(agent.vault ? [`vault:${agent.vault}`] : []), + ...(agent.memory_stores ?? []).map((store) => `memory_store:${store}`), + ]; + for (const ref of refs) { + const modes = domains.get(ref) ?? new Set<"managed" | "forward">(); + modes.add(mode); + domains.set(ref, modes); + } + } + for (const [ref, modes] of domains) { + if (modes.size < 2) continue; + const [type, name] = ref.split(":") as ["skill" | "vault" | "memory_store", string]; + diagnostics.error( + `qoder.${type}.delivery_domain.conflict`, + `${type}.${name}: referenced by both Managed and Forward agents; declare separate resources because Qoder uses different API domains.`, + { type, name, provider: providerName }, + ); + } + } + for (const [name, agent] of Object.entries(config.agents ?? {})) { if (agent.provider && agent.provider !== providerName) continue; const delivery = agent.delivery?.[providerName]?.type ?? "managed"; @@ -364,10 +393,10 @@ export function collectProviderCapabilities( { type: "template", name, provider: providerName }, ); } - if (agent.memory_stores?.length) { + if (agent.memory_stores?.length && !config.defaults?.identity) { diagnostics.error( - "qoder.template.memory_store.unsupported", - `agent.${name}: memory_stores are not yet supported by Qoder Forward Template delivery.`, + "qoder.template.memory_store.identity.required", + `agent.${name}: Forward memory_stores require defaults.identity for the Identity/Template mount.`, { type: "template", name, provider: providerName }, ); } diff --git a/packages/sdk/src/internal/executor/executor.ts b/packages/sdk/src/internal/executor/executor.ts index 2378705..4a29ce4 100644 --- a/packages/sdk/src/internal/executor/executor.ts +++ b/packages/sdk/src/internal/executor/executor.ts @@ -328,7 +328,7 @@ async function executeActionInner( break; case "memory_store": if (!provider.deleteMemoryStore) throw memoryStoreUnsupported(address.provider); - await provider.deleteMemoryStore(id); + await provider.deleteMemoryStore(id, apiMode); break; case "deployment": await provider.deleteDeployment(id); @@ -504,18 +504,22 @@ async function executeActionInner( throw memoryStoreUnsupported(address.provider); } const reconcile = async (storeId: string): Promise => { - const store = await provider.updateMemoryStore!(storeId, { - name, - description: decl.description, - metadata: decl.metadata ?? {}, - }); + const store = await provider.updateMemoryStore!( + storeId, + { + name, + description: decl.description, + metadata: decl.metadata ?? {}, + }, + apiMode, + ); // Declarative entries are managed seeds. Update/create those paths in place, // while preserving memories learned by agents at runtime. const current = new Map(); let cursor: string | undefined; do { - const page = await provider.listMemories!(storeId, { limit: 100, cursor, view: "basic" }); + const page = await provider.listMemories!(storeId, { limit: 100, cursor, view: "basic" }, apiMode); for (const memory of page.data) { if (memory.type === "memory") current.set(memory.path, memory); } @@ -526,22 +530,30 @@ async function executeActionInner( const existing = current.get(entry.key.replace(/^\/+/, "")); if (existing) { if (existing.content_sha256 !== sha256(entry.content)) { - await provider.updateMemory!(storeId, existing.id, { - content: entry.content, - expected_content_sha256: existing.content_sha256, - }); + await provider.updateMemory!( + storeId, + existing.id, + { + content: entry.content, + expected_content_sha256: existing.content_sha256, + }, + apiMode, + ); } } else { - await provider.createMemory!(storeId, { path: entry.key, content: entry.content }); + await provider.createMemory!(storeId, { path: entry.key, content: entry.content }, apiMode); } } return store; }; - if (isUpdate) { + if (apiModeChanged) { + result = await createMemoryStore(name, decl, apiMode); + if (existingId) await deleteMemoryStore(existingId, priorApiMode).catch(() => undefined); + } else if (isUpdate) { result = await reconcile(existingId!); } else { try { - result = await createMemoryStore(name, decl); + result = await createMemoryStore(name, decl, apiMode); } catch (err) { result = await adoptOnConflict(err, address, provider, ctx.onFeedback, { onExisting: async (existing) => reconcile(existing.id!), diff --git a/packages/sdk/src/internal/executor/resolver.ts b/packages/sdk/src/internal/executor/resolver.ts index 8cf2081..fabb7b1 100644 --- a/packages/sdk/src/internal/executor/resolver.ts +++ b/packages/sdk/src/internal/executor/resolver.ts @@ -87,12 +87,18 @@ export function resolveTemplateRefs( if (!environment) throw new UserError(`Environment '${agent.environment}' is not defined in config.`); const agentRefs = resolveAgentRefs(agentName, config, provider, state); + const memoryStoreIds = agent.memory_stores?.map((memoryStore) => + requireRef(state, { type: "memory_store", name: memoryStore, provider }), + ); + const identityName = config.defaults?.identity; return { ...agentRefs, environment_id: environment.environment_id ?? requireRef(state, { type: "environment", name: agent.environment, provider }), ...(agent.tunnel ? { tunnel_id: resolveTunnelIdFromConfig(config, agent.tunnel, provider) } : {}), vault_ids: agent.vault ? [requireRef(state, { type: "vault", name: agent.vault, provider })] : [], + ...(memoryStoreIds ? { memory_store_ids: memoryStoreIds } : {}), + ...(identityName ? { identity_id: requireRef(state, { type: "identity", name: identityName, provider }) } : {}), }; } diff --git a/packages/sdk/src/internal/graph/dependency.ts b/packages/sdk/src/internal/graph/dependency.ts index 1f2109d..a4e7ed8 100644 --- a/packages/sdk/src/internal/graph/dependency.ts +++ b/packages/sdk/src/internal/graph/dependency.ts @@ -88,7 +88,7 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str const agentAddr: ResourceAddress = { type: materialization.resourceType, name, provider }; addNode(agentAddr); - if (decl.default_memory_store && materialization.resourceType === "template") { + if ((decl.default_memory_store || decl.memory_stores?.length) && materialization.resourceType === "template") { const identityName = config.defaults?.identity; if (identityName) { const identityAddr: ResourceAddress = { type: "identity", name: identityName, provider }; diff --git a/packages/sdk/src/internal/planner/hasher.ts b/packages/sdk/src/internal/planner/hasher.ts index ac13d48..7bbe404 100644 --- a/packages/sdk/src/internal/planner/hasher.ts +++ b/packages/sdk/src/internal/planner/hasher.ts @@ -22,10 +22,16 @@ export async function computeResourceHash( if (address.type === "skill") { const skillDecl = decl as { source: string }; + const apiMode = resolveQoderApiMode(address.type, address.name, address.provider, config); if (basePath) { const fileHash = computeSkillContentHash(skillDecl.source, basePath); - return contentHash({ decl, fileHash }); + return contentHash({ decl, fileHash, apiMode }); } + return contentHash({ decl, apiMode }); + } + + if (address.type === "vault" || address.type === "memory_store") { + return contentHash({ decl, apiMode: resolveQoderApiMode(address.type, address.name, address.provider, config) }); } if (address.type === "file" && basePath) { @@ -58,6 +64,28 @@ export async function computeResourceHash( return contentHash(decl); } +function resolveQoderApiMode( + type: "skill" | "vault" | "memory_store", + name: string, + provider: string, + config: ProjectConfig, +): "managed" | "forward" | undefined { + if (provider !== "qoder") return undefined; + for (const agent of Object.values(config.agents ?? {})) { + if (agent.provider && agent.provider !== provider) continue; + const referenced = + type === "skill" + ? agent.skills?.some((skill) => + typeof skill === "string" ? skill === name : skill.type === "custom" && skill.skill_id === name, + ) + : type === "vault" + ? agent.vault === name + : agent.memory_stores?.includes(name); + if (referenced && agent.delivery?.qoder?.type === "forward") return "forward"; + } + return "managed"; +} + /** Stable, non-reversible identity hint for resources whose YAML key may change. */ export function computeReplacementFingerprint(address: ResourceAddress, config: ProjectConfig): string | undefined { if (address.type !== "channel") return undefined; diff --git a/packages/sdk/src/internal/providers/base-client.ts b/packages/sdk/src/internal/providers/base-client.ts index f3b2fd3..42ee5c0 100644 --- a/packages/sdk/src/internal/providers/base-client.ts +++ b/packages/sdk/src/internal/providers/base-client.ts @@ -34,10 +34,10 @@ export abstract class BaseApiClient { throw new ApiError(res.status, body, this.errorPrefix); } - async post(path: string, body: unknown): Promise { + async post(path: string, body: unknown, options?: { headers?: Record }): Promise { const res = await resolveFetch()(`${this.baseUrl}${path}`, { method: "POST", - headers: this.headers(), + headers: { ...this.headers(), ...options?.headers }, body: JSON.stringify(body), }); await this.throwIfError(res); diff --git a/packages/sdk/src/internal/providers/interface.ts b/packages/sdk/src/internal/providers/interface.ts index 6040113..e178ceb 100644 --- a/packages/sdk/src/internal/providers/interface.ts +++ b/packages/sdk/src/internal/providers/interface.ts @@ -87,6 +87,8 @@ export interface ResolvedTemplateRefs extends ResolvedAgentRefs { /** Qoder BYOC private-network route used by Forward Templates. */ tunnel_id?: string; vault_ids: string[]; + identity_id?: string; + memory_store_ids?: string[]; } export interface ResolvedDeploymentRefs { @@ -231,17 +233,26 @@ export interface ProviderAdapter { updateChannel?(id: string, name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Promise; deleteChannel?(id: string): Promise; - createMemoryStore?(name: string, decl: MemoryStoreDecl): Promise; - deleteMemoryStore?(id: string): Promise; + createMemoryStore?(name: string, decl: MemoryStoreDecl, mode?: ProviderResourceMode): Promise; + deleteMemoryStore?(id: string, mode?: ProviderResourceMode): Promise; listMemoryStores?(options?: MemoryStoreListOptions): Promise>; getMemoryStore?(id: string): Promise; - updateMemoryStore?(id: string, input: UpdateMemoryStoreInput): Promise; + updateMemoryStore?(id: string, input: UpdateMemoryStoreInput, mode?: ProviderResourceMode): Promise; archiveMemoryStore?(id: string): Promise; - createMemory?(storeId: string, input: CreateMemoryInput): Promise; + createMemory?(storeId: string, input: CreateMemoryInput, mode?: ProviderResourceMode): Promise; batchCreateMemories?(storeId: string, input: BatchCreateMemoryInput): Promise; - listMemories?(storeId: string, options?: MemoryListOptions): Promise>; + listMemories?( + storeId: string, + options?: MemoryListOptions, + mode?: ProviderResourceMode, + ): Promise>; getMemory?(storeId: string, memoryId: string): Promise; - updateMemory?(storeId: string, memoryId: string, input: UpdateMemoryInput): Promise; + updateMemory?( + storeId: string, + memoryId: string, + input: UpdateMemoryInput, + mode?: ProviderResourceMode, + ): Promise; deleteMemory?(storeId: string, memoryId: string, expectedContentSha256?: string): Promise; listMemoryVersions?(storeId: string, options?: MemoryVersionListOptions): Promise>; getMemoryVersion?(storeId: string, versionId: string): Promise; diff --git a/packages/sdk/src/internal/providers/qoder/adapter.ts b/packages/sdk/src/internal/providers/qoder/adapter.ts index fd151e3..550687a 100644 --- a/packages/sdk/src/internal/providers/qoder/adapter.ts +++ b/packages/sdk/src/internal/providers/qoder/adapter.ts @@ -125,6 +125,7 @@ export class QoderAdapter implements ProviderAdapter { private client: QoderClient; private memoryApi: ProviderMemoryApi; private forwardClient: QoderClient; + private forwardMemoryApi: ProviderMemoryApi; private projectName: string; private forwardSessionIds = new Set(); @@ -147,6 +148,19 @@ export class QoderAdapter implements ProviderAdapter { apiKey, gateway: forwardGateway ?? deriveForwardGateway(gateway), }); + this.forwardMemoryApi = new ProviderMemoryApi(this.forwardClient, { + pathStyle: "relative", + cursorParam: "after_id", + updatePrecondition: "content_sha256", + prefixParam: "prefix", + versionsSegment: "versions", + storeMetadataMode: "merge_patch", + supportsView: false, + supportsMemoryMetadata: true, + supportsPathUpdate: false, + supportsDeletePrecondition: false, + supportsIncludeArchived: true, + }); this.projectName = projectName ?? ""; } @@ -525,6 +539,7 @@ export class QoderAdapter implements ProviderAdapter { async createTemplate(name: string, decl: AgentDecl, refs: ResolvedTemplateRefs): Promise { const body = mapForwardTemplate(name, decl, refs, this.projectName); const res = (await this.forwardClient.post("/templates", body)) as Record; + await this.reconcileForwardMemoryMounts(res.id as string, refs); return toRemoteResource(res); } @@ -533,6 +548,7 @@ export class QoderAdapter implements ProviderAdapter { // Forward updates are merge-style; null explicitly clears a previously inherited BYOC tunnel. if (!refs.tunnel_id) body.tunnel_id = null; const res = (await this.forwardClient.post(`/templates/${id}`, body)) as Record; + await this.reconcileForwardMemoryMounts(id, refs); return toRemoteResource(res); } @@ -540,6 +556,30 @@ export class QoderAdapter implements ProviderAdapter { await this.forwardClient.post(`/templates/${id}/archive`, {}); } + private async reconcileForwardMemoryMounts(templateId: string, refs: ResolvedTemplateRefs): Promise { + if (refs.memory_store_ids === undefined) return; + const desired = new Set(refs.memory_store_ids ?? []); + if (!refs.identity_id) { + if (desired.size > 0) throw new UserError("Qoder Forward Memory Store mounts require an Identity."); + return; + } + const path = `/identities/${refs.identity_id}/templates/${templateId}/memory_stores`; + const current = (await this.forwardClient.get(path)) as { data?: Array> }; + const explicit = (current.data ?? []).filter((mount) => mount.system_managed !== true); + for (const mount of explicit) { + const storeId = mount.memory_store_id; + if (typeof storeId === "string" && !desired.has(storeId)) { + await this.forwardClient.delete(`${path}/${storeId}`); + } + } + const mounted = new Set( + explicit.map((mount) => mount.memory_store_id).filter((id): id is string => typeof id === "string"), + ); + for (const memoryStoreId of desired) { + if (!mounted.has(memoryStoreId)) await this.forwardClient.post(path, { memory_store_id: memoryStoreId }); + } + } + async reconcileDefaultMemoryStore( identityId: string, templateId: string, @@ -658,24 +698,34 @@ export class QoderAdapter implements ProviderAdapter { return body; } - async createMemoryStore(name: string, decl: MemoryStoreDecl): Promise { + async createMemoryStore( + name: string, + decl: MemoryStoreDecl, + mode: ProviderResourceMode = "managed", + ): Promise { const body = mapMemoryStore(name, decl); - const res = (await this.client.post("/memory_stores", body)) as Record; + const client = mode === "forward" ? this.forwardClient : this.client; + const memoryApi = mode === "forward" ? this.forwardMemoryApi : this.memoryApi; + const res = (await client.post( + "/memory_stores", + body, + mode === "forward" ? { headers: { "Idempotency-Key": crypto.randomUUID() } } : undefined, + )) as Record; const storeId = res.id as string; try { for (const entry of decl.entries ?? []) { - await this.memoryApi.createMemory(storeId, { content: entry.content, path: entry.key }); + await memoryApi.createMemory(storeId, { content: entry.content, path: entry.key }); } } catch (error) { - await this.client.delete(`/memory_stores/${storeId}`).catch(() => undefined); + await client.delete(`/memory_stores/${storeId}`).catch(() => undefined); throw error; } return toRemoteResource(res); } - async deleteMemoryStore(id: string): Promise { - await this.client.delete(`/memory_stores/${id}`); + async deleteMemoryStore(id: string, mode: ProviderResourceMode = "managed"): Promise { + await (mode === "forward" ? this.forwardClient : this.client).delete(`/memory_stores/${id}`); } listMemoryStores(options?: MemoryStoreListOptions) { @@ -684,23 +734,23 @@ export class QoderAdapter implements ProviderAdapter { getMemoryStore(id: string) { return this.memoryApi.getStore(id); } - updateMemoryStore(id: string, input: UpdateMemoryStoreInput) { - return this.memoryApi.updateStore(id, input); + updateMemoryStore(id: string, input: UpdateMemoryStoreInput, mode: ProviderResourceMode = "managed") { + return (mode === "forward" ? this.forwardMemoryApi : this.memoryApi).updateStore(id, input); } archiveMemoryStore(id: string) { return this.memoryApi.archiveStore(id); } - createMemory(storeId: string, input: CreateMemoryInput) { - return this.memoryApi.createMemory(storeId, input); + createMemory(storeId: string, input: CreateMemoryInput, mode: ProviderResourceMode = "managed") { + return (mode === "forward" ? this.forwardMemoryApi : this.memoryApi).createMemory(storeId, input); } - listMemories(storeId: string, options?: MemoryListOptions) { - return this.memoryApi.listMemories(storeId, options); + listMemories(storeId: string, options?: MemoryListOptions, mode: ProviderResourceMode = "managed") { + return (mode === "forward" ? this.forwardMemoryApi : this.memoryApi).listMemories(storeId, options); } getMemory(storeId: string, memoryId: string) { return this.memoryApi.getMemory(storeId, memoryId); } - updateMemory(storeId: string, memoryId: string, input: UpdateMemoryInput) { - return this.memoryApi.updateMemory(storeId, memoryId, input); + updateMemory(storeId: string, memoryId: string, input: UpdateMemoryInput, mode: ProviderResourceMode = "managed") { + return (mode === "forward" ? this.forwardMemoryApi : this.memoryApi).updateMemory(storeId, memoryId, input); } deleteMemory(storeId: string, memoryId: string, expected?: string) { return this.memoryApi.deleteMemory(storeId, memoryId, expected); diff --git a/packages/sdk/src/internal/providers/resource-workflow.ts b/packages/sdk/src/internal/providers/resource-workflow.ts index edec9b4..824348a 100644 --- a/packages/sdk/src/internal/providers/resource-workflow.ts +++ b/packages/sdk/src/internal/providers/resource-workflow.ts @@ -82,12 +82,21 @@ export interface ResourceCrudAdapter { // Optional: only providers whose capability matrix marks `memory_store` supported // implement these. The registry validates the matrix↔method match; unsupported // providers omit them entirely (no throw-stubs). - createMemoryStore?(name: string, decl: MemoryStoreDecl): Promise; - deleteMemoryStore?(id: string): Promise; - updateMemoryStore?(id: string, input: UpdateMemoryStoreInput): Promise; - createMemory?(storeId: string, input: CreateMemoryInput): Promise; - listMemories?(storeId: string, options?: MemoryListOptions): Promise>; - updateMemory?(storeId: string, memoryId: string, input: UpdateMemoryInput): Promise; + createMemoryStore?(name: string, decl: MemoryStoreDecl, mode?: ProviderResourceMode): Promise; + deleteMemoryStore?(id: string, mode?: ProviderResourceMode): Promise; + updateMemoryStore?(id: string, input: UpdateMemoryStoreInput, mode?: ProviderResourceMode): Promise; + createMemory?(storeId: string, input: CreateMemoryInput, mode?: ProviderResourceMode): Promise; + listMemories?( + storeId: string, + options?: MemoryListOptions, + mode?: ProviderResourceMode, + ): Promise>; + updateMemory?( + storeId: string, + memoryId: string, + input: UpdateMemoryInput, + mode?: ProviderResourceMode, + ): Promise; createDeployment( name: string, diff --git a/packages/sdk/tests/unit/qoder-forward-template.test.ts b/packages/sdk/tests/unit/qoder-forward-template.test.ts index a8317e3..a211678 100644 --- a/packages/sdk/tests/unit/qoder-forward-template.test.ts +++ b/packages/sdk/tests/unit/qoder-forward-template.test.ts @@ -168,6 +168,15 @@ describe("Qoder Forward Template declaration", () => { const second = await computeResourceHash(address, config, undefined, lookup); expect(second).not.toBe(first); }); + + test("changes dependent resource hashes when Qoder switches API domains", async () => { + const config = forwardConfig(); + const address = { type: "vault", name: "mcp", provider: "qoder" } as const; + const forwardHash = await computeResourceHash(address, config); + delete config.agents!.assistant!.delivery; + const managedHash = await computeResourceHash(address, config); + expect(forwardHash).not.toBe(managedHash); + }); }); describe("Qoder Forward Template mapping and lifecycle", () => { @@ -264,16 +273,53 @@ describe("Qoder Forward Template mapping and lifecycle", () => { await adapter.findResource("template", "assistant", "tmpl_1"); expect(calls.map(({ method, path }) => `${method} ${path}`)).toEqual([ - "POST /resources/registry", "POST /templates", - "POST /resources/registry", "POST /templates/tmpl_1", "POST /templates/tmpl_1/archive", "GET /templates/tmpl_1", ]); - expect(calls.filter((call) => call.path === "/resources/registry").map((call) => call.body)).toEqual([ - { type: "vault", resource: { id: "vault_mcp" } }, - { type: "vault", resource: { id: "vault_mcp" } }, + }); + + test("uses Forward lifecycle endpoints for Forward-owned Skills and Vault credentials", async () => { + const managedCalls: string[] = []; + const forwardCalls: string[] = []; + const adapter = new QoderAdapter("pt-test") as any; + adapter.client = { + post: async (path: string) => managedCalls.push(`POST ${path}`), + postFormData: async (path: string) => managedCalls.push(`POST_FORM ${path}`), + delete: async (path: string) => managedCalls.push(`DELETE ${path}`), + }; + adapter.forwardClient = { + post: async (path: string) => { + forwardCalls.push(`POST ${path}`); + return path === "/vaults" ? { id: "vault_forward" } : {}; + }, + postFormData: async (path: string) => { + forwardCalls.push(`POST_FORM ${path}`); + return { id: "skill_forward" }; + }, + delete: async (path: string) => forwardCalls.push(`DELETE ${path}`), + }; + + await adapter.createSkill("forward-skill", { source: "." }, [], "forward"); + await adapter.deleteSkill("skill_forward", "forward"); + await adapter.createVault( + "forward-vault", + { + display_name: "Forward vault", + credentials: [{ name: "api", type: "static_bearer", access_token: "secret" }], + }, + "forward", + ); + await adapter.deleteVault("vault_forward", "forward"); + + expect(managedCalls).toEqual([]); + expect(forwardCalls).toEqual([ + "POST_FORM /skills", + "DELETE /skills/skill_forward", + "POST /vaults", + "POST /vaults/vault_forward/credentials", + "DELETE /vaults/vault_forward", ]); }); @@ -395,6 +441,48 @@ describe("Qoder Forward Template mapping and lifecycle", () => { }); describe("Forward delivery validation and runtime isolation", () => { + test("rejects sharing one Qoder Vault across Managed and Forward API domains", () => { + const config = forwardConfig(); + config.agents!.managed = { + description: "Managed assistant", + model: { qoder: "auto" }, + instructions: "Managed.", + vault: "mcp", + }; + const diagnostics = validateProjectConfig(config); + expect(diagnostics.some((item) => item.code === "qoder.vault.delivery_domain.conflict")).toBe(true); + }); + + test("passes the Forward API domain through apply and persists it in state", async () => { + const config = forwardConfig(); + const state = StateManager.initialize(tmpPath("forward-vault-domain")); + const modes: unknown[] = []; + const provider = { + name: "qoder", + findResource: async () => null, + createVault: async (_name: string, _decl: unknown, mode: unknown) => { + modes.push(mode); + return { id: "vault_forward", type: "vault" }; + }, + deleteVault: async () => {}, + } as unknown as ProviderAdapter; + await executePlan( + { + actions: [ + { + action: "create", + address: { type: "vault", name: "mcp", provider: "qoder" }, + dependencies: [], + }, + ], + diagnostics: [], + }, + { config, providers: new Map([["qoder", provider]]), state }, + ); + expect(modes).toEqual(["forward"]); + expect(state.getResource({ type: "vault", name: "mcp", provider: "qoder" })?.api_mode).toBe("forward"); + }); + test("requires a default Identity for default memory reconciliation", () => { const config = forwardConfig(); config.agents!.assistant!.default_memory_store = { name: "Support memory" }; From b98812415f30d86e2de8ba7560d3a56099052ae8 Mon Sep 17 00:00:00 2001 From: heimanba <371510756@qq.com> Date: Fri, 21 Aug 2026 01:22:21 +0800 Subject: [PATCH 4/4] fix(qoder): complete forward resource lifecycle Change-Id: Ifd23baa190a6b1f85c41357aedd6d3f644976da6 --- docs/guides/configure-an-agent.zh-CN.md | 7 + docs/reference/configuration.md | 9 + .../sdk/src/internal/core/destroy-runtime.ts | 37 +- .../sdk/src/internal/core/validate-config.ts | 24 +- .../sdk/src/internal/executor/executor.ts | 82 +++-- .../sdk/src/internal/executor/resolver.ts | 15 +- packages/sdk/src/internal/graph/dependency.ts | 7 + packages/sdk/src/internal/parser/schema.ts | 1 + packages/sdk/src/internal/planner/hasher.ts | 45 ++- packages/sdk/src/internal/planner/refresh.ts | 2 +- .../sdk/src/internal/providers/interface.ts | 33 +- .../src/internal/providers/qoder/adapter.ts | 181 +++++++-- .../src/internal/providers/qoder/mapper.ts | 10 + .../internal/providers/resource-workflow.ts | 28 +- packages/sdk/src/internal/types/config.ts | 2 + packages/sdk/src/internal/types/state.ts | 2 +- .../sdk/tests/unit/drift-detection.test.ts | 4 +- .../tests/unit/qoder-forward-template.test.ts | 343 +++++++++++++++++- 18 files changed, 719 insertions(+), 113 deletions(-) diff --git a/docs/guides/configure-an-agent.zh-CN.md b/docs/guides/configure-an-agent.zh-CN.md index 2a3d817..bcffa7a 100644 --- a/docs/guides/configure-an-agent.zh-CN.md +++ b/docs/guides/configure-an-agent.zh-CN.md @@ -364,6 +364,13 @@ agents: #### 配置 Qoder Forward 默认 Memory Store +Qoder Forward 本地声明的 Environment 只通过 Forward Environment API 创建。外部 `environment_id` +既可以引用 Managed API 的 Environment,也可以引用 Forward API 的 Environment;OpenCMA 不会创建或 +修改此类外部引用,并会在检查其是否存在时自动解析 API 域。Forward Agent 引用的自定义 Skill、Vault/Credential、File 和显式 Memory +Store 均通过 Forward API 管理;本地管理的 Environment、Skill、Vault、File 或 Memory Store 不能同时 +供 Managed 与 Forward Agent 共用,应分别声明。Forward File 通过 Agent 的 `files` 字段绑定到 Template。 +显式 Memory Store 依赖 `defaults.identity`,并以只读方式挂载到对应的 Identity + Template。 + Qoder Forward 会在某个 `(Identity, Template)` 首次创建 Session 时自动建立唯一可写的系统默认 Memory Store。`default_memory_store` 管理的是这个由 Qoder 创建的 Store,而不是在顶层 `memory_stores` 中额外创建一个普通 Store。可以为它设置有业务含义的展示名称、描述以及销毁策略: diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index eb308fe..a0a8939 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -350,6 +350,7 @@ If the preflight cannot resolve the Identity, Template, or Store lookup, destroy | `mcp_servers[]` | `{ name, type?, url? }` | no | URL (`url`/`http`) or `official` MCP server. | | `skills[]` | string \| AgentSkillRef | no | Skill name or `{ type: "official"\|"custom", skill_id, version? }`. | | `vault` | string | no | Vault name. | +| `files` | string[] | no | File declarations inherited by a Qoder Forward Template. These files are created through the Forward File API. | | `memory_stores` | string[] | no | Bound memory stores. | | `default_memory_store.name` | string | yes (with `default_memory_store`) | Display name for Qoder Forward's writable system-managed Store; 1–255 characters. | | `default_memory_store.description` | string | no | Display description for the system-managed Store; up to 1024 characters. | @@ -361,6 +362,14 @@ If the preflight cannot resolve the Identity, Template, or Store lookup, destroy | `multiagent.agents` | string[] | yes (with multiagent) | Agents it orchestrates. | | `metadata` | map | no | Free-form metadata. | +For Qoder Forward delivery, a locally declared Environment is created only through the Forward Environment API. An +external `environment_id` may reference an Environment from either the Managed API or the Forward API; OpenCMA does +not create or mutate such a reference and resolves its API domain when checking existence. Referenced custom Skills, Vaults and Credentials, Files, and explicit +Memory Stores are created through their Forward APIs. A locally managed Environment, Skill, Vault, File, or Memory +Store cannot be shared by Managed and Forward Agents under one logical declaration; declare separate resources for +the two API domains. Explicit Forward Memory Stores require `defaults.identity` and are mounted read-only to that +Identity and Template. + ### Session resources Qoder and Claude managed Sessions support a provider-neutral GitHub repository resource: diff --git a/packages/sdk/src/internal/core/destroy-runtime.ts b/packages/sdk/src/internal/core/destroy-runtime.ts index 5d487dd..ebaa6c1 100644 --- a/packages/sdk/src/internal/core/destroy-runtime.ts +++ b/packages/sdk/src/internal/core/destroy-runtime.ts @@ -1,6 +1,6 @@ import { UserError } from "../errors.ts"; import { ApiError } from "../providers/base-client.ts"; -import type { ProviderAdapter } from "../providers/interface.ts"; +import type { ProviderAdapter, ProviderResourceMode } from "../providers/interface.ts"; import type { RuntimeFeedbackSink } from "../types/runtime-feedback.ts"; import { emitRuntimeFeedback } from "../types/runtime-feedback.ts"; import type { ResourceState, ResourceType } from "../types/state.ts"; @@ -401,8 +401,25 @@ async function destroyOneResource( return successResult(resource, "destroyed"); } + const apiMode = resource.api_mode === "auto" ? undefined : resource.api_mode; try { - await deleteRemoteResource(provider, resource.address.type, resource.remote_id, options.cascade); + await deleteRemoteResource( + provider, + resource.address.type, + resource.remote_id, + options.cascade, + apiMode, + ctx.state + .listResources() + .filter( + (candidate) => + candidate.address.provider === resource.address.provider && + candidate.address.type === "memory_store" && + candidate.api_mode === "forward" && + typeof candidate.remote_id === "string", + ) + .map((candidate) => candidate.remote_id as string), + ); ctx.state.removeResource(resource.address); emitRuntimeFeedback(options.onFeedback, { type: "resource_action_success", @@ -432,7 +449,7 @@ async function destroyOneResource( } satisfies DestroyResourceResult; if (await options.onCascadeRequired?.(blocked)) { try { - await provider.deleteEnvironment(resource.remote_id, true); + await provider.deleteEnvironment(resource.remote_id, true, apiMode); ctx.state.removeResource(resource.address); return { ...successResult(resource, "destroyed"), @@ -481,6 +498,8 @@ async function deleteRemoteResource( type: ResourceType, id: string, cascade?: boolean, + mode?: ProviderResourceMode, + ownedMemoryStoreIds: string[] = [], ): Promise { switch (type) { case "agent": @@ -488,22 +507,22 @@ async function deleteRemoteResource( return; case "template": if (!provider.archiveTemplate) throw new UserError(`Provider does not support templates`); - await provider.archiveTemplate(id); + await provider.archiveTemplate(id, ownedMemoryStoreIds); return; case "skill": - await provider.deleteSkill(id); + await provider.deleteSkill(id, mode); return; case "memory_store": if (!provider.deleteMemoryStore) { throw new UserError(`Provider does not support memory stores`); } - await provider.deleteMemoryStore(id); + await provider.deleteMemoryStore(id, mode); return; case "vault": - await provider.deleteVault(id); + await provider.deleteVault(id, mode); return; case "environment": - await provider.deleteEnvironment(id, cascade); + await provider.deleteEnvironment(id, cascade, mode); return; case "deployment": await provider.deleteDeployment(id); @@ -517,7 +536,7 @@ async function deleteRemoteResource( await provider.deleteChannel(id); return; case "file": - await provider.deleteFile(id); + await provider.deleteFile(id, mode); return; } } diff --git a/packages/sdk/src/internal/core/validate-config.ts b/packages/sdk/src/internal/core/validate-config.ts index d809470..fcded0f 100644 --- a/packages/sdk/src/internal/core/validate-config.ts +++ b/packages/sdk/src/internal/core/validate-config.ts @@ -11,6 +11,7 @@ import type { Diagnostic } from "../types/plan.ts"; import type { ResourceAddress } from "../types/state.ts"; import { providerMountPrefix, resolveSandboxMountPath } from "../utils/sandbox-mount.ts"; import { findMissingBailianMcpToolConfigs } from "../validation/bailian.ts"; +import { resolveAgentMaterialization } from "./agent-materialization.ts"; export interface ValidateProjectConfigOptions { /** Providers to capability-check. Defaults to the config's target providers. */ @@ -51,6 +52,7 @@ export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics: const skillNames = new Set(Object.keys(config.skills ?? {})); const vaultNames = new Set(Object.keys(config.vaults ?? {})); const memoryNames = new Set(Object.keys(config.memory_stores ?? {})); + const fileNames = new Set(Object.keys(config.files ?? {})); const agentNames = new Set(Object.keys(config.agents ?? {})); const identityNames = new Set(Object.keys(config.identities ?? {})); @@ -80,6 +82,11 @@ export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics: if (agent.vault && !vaultNames.has(agent.vault)) { diagnostics.error("config.agent.vault.unknown", `agent.${name}: references unknown vault '${agent.vault}'`); } + for (const file of agent.files ?? []) { + if (!fileNames.has(file)) { + diagnostics.error("config.agent.file.unknown", `agent.${name}: references unknown file '${file}'`); + } + } for (const memory of agent.memory_stores ?? []) { if (!memoryNames.has(memory)) { diagnostics.error( @@ -270,11 +277,15 @@ export function collectProviderCapabilities( if (agent.provider && agent.provider !== providerName) continue; const mode = agent.delivery?.qoder?.type === "forward" ? "forward" : "managed"; const refs = [ + ...(agent.environment && !config.environments?.[agent.environment]?.environment_id + ? [`environment:${agent.environment}`] + : []), ...(agent.skills ?? []).flatMap((skill) => typeof skill === "string" ? [`skill:${skill}`] : skill.type === "custom" ? [`skill:${skill.skill_id}`] : [], ), ...(agent.vault ? [`vault:${agent.vault}`] : []), ...(agent.memory_stores ?? []).map((store) => `memory_store:${store}`), + ...(agent.files ?? []).map((file) => `file:${file}`), ]; for (const ref of refs) { const modes = domains.get(ref) ?? new Set<"managed" | "forward">(); @@ -284,7 +295,7 @@ export function collectProviderCapabilities( } for (const [ref, modes] of domains) { if (modes.size < 2) continue; - const [type, name] = ref.split(":") as ["skill" | "vault" | "memory_store", string]; + const [type, name] = ref.split(":") as ["environment" | "skill" | "vault" | "memory_store" | "file", string]; diagnostics.error( `qoder.${type}.delivery_domain.conflict`, `${type}.${name}: referenced by both Managed and Forward agents; declare separate resources because Qoder uses different API domains.`, @@ -293,6 +304,17 @@ export function collectProviderCapabilities( } } + for (const [name, agent] of Object.entries(config.agents ?? {})) { + if (agent.provider && agent.provider !== providerName) continue; + if (agent.files?.length && (providerName !== "qoder" || agent.delivery?.qoder?.type !== "forward")) { + diagnostics.error( + "config.agent.files.unsupported", + `agent.${name}: files are supported only by Qoder Forward Templates.`, + { type: resolveAgentMaterialization(providerName, agent).resourceType, name, provider: providerName }, + ); + } + } + for (const [name, agent] of Object.entries(config.agents ?? {})) { if (agent.provider && agent.provider !== providerName) continue; const delivery = agent.delivery?.[providerName]?.type ?? "managed"; diff --git a/packages/sdk/src/internal/executor/executor.ts b/packages/sdk/src/internal/executor/executor.ts index 4a29ce4..b844013 100644 --- a/packages/sdk/src/internal/executor/executor.ts +++ b/packages/sdk/src/internal/executor/executor.ts @@ -310,7 +310,7 @@ async function executeActionInner( try { switch (type) { case "environment": - await provider.deleteEnvironment(id); + await provider.deleteEnvironment(id, false, apiMode); break; case "vault": await provider.deleteVault(id, apiMode); @@ -324,7 +324,7 @@ async function executeActionInner( case "template": if (!provider.archiveTemplate) throw new UserError(`Provider '${address.provider}' does not support templates`); - await provider.archiveTemplate(id); + await provider.archiveTemplate(id, ownedForwardMemoryStoreIds(ctx, address.provider)); break; case "memory_store": if (!provider.deleteMemoryStore) throw memoryStoreUnsupported(address.provider); @@ -334,7 +334,7 @@ async function executeActionInner( await provider.deleteDeployment(id); break; case "file": - await provider.deleteFile(id); + await provider.deleteFile(id, apiMode); break; case "identity": if (!provider.deleteIdentity) @@ -386,6 +386,17 @@ async function executeActionInner( resource: action.address, message: `${action.action} ${action.address.type}.${action.address.name} (${action.address.provider}) — external reference, no remote mutation`, }); + } else if (apiModeChanged) { + try { + result = await provider.createEnvironment(remoteName, decl, apiMode); + } catch (err) { + result = await adoptOnConflict(err, address, provider, ctx.onFeedback, { + mode: apiMode, + onExisting: (existing) => provider.updateEnvironment(existing.id!, remoteName, decl, apiMode), + }); + adopted = true; + } + if (existingId) await provider.deleteEnvironment(existingId, false, priorApiMode); } else if (isUpdate) { // Defense in depth: ownership is a state-level fact. Never push the // local config onto an environment recorded as externally managed — @@ -399,13 +410,14 @@ async function executeActionInner( `with 'agents state rm environment.${name}' (then 'agents state import' to adopt it as a managed resource).`, ); } - result = await provider.updateEnvironment(existingId!, remoteName, decl); + result = await provider.updateEnvironment(existingId!, remoteName, decl, apiMode); } else { try { - result = await provider.createEnvironment(remoteName, decl); + result = await provider.createEnvironment(remoteName, decl, apiMode); } catch (err) { result = await adoptOnConflict(err, address, provider, ctx.onFeedback, { - onExisting: (existing) => provider.updateEnvironment(existing.id!, remoteName, decl), + mode: apiMode, + onExisting: (existing) => provider.updateEnvironment(existing.id!, remoteName, decl, apiMode), }); adopted = true; } @@ -430,6 +442,7 @@ async function executeActionInner( result = await provider.createVault(name, decl, apiMode); } catch (err) { result = await adoptOnConflict(err, address, provider, ctx.onFeedback, { + mode: apiMode, onExisting: async (existing) => { await provider.deleteVault(existing.id!, apiMode); return provider.createVault(name, decl, apiMode); @@ -471,7 +484,7 @@ async function executeActionInner( const searchNames = manifestName && manifestName !== remoteName ? [remoteName, manifestName] : [remoteName]; // Pre-check: if a matching skill already exists, adopt it directly without // uploading (avoids wasteful zip upload + OSS delay). - const existing = await findExistingByNames(provider, "skill", searchNames); + const existing = await findExistingByNames(provider, "skill", searchNames, apiMode); if (existing) { result = existing.resource; emitRuntimeFeedback(ctx.onFeedback, { @@ -486,6 +499,7 @@ async function executeActionInner( result = await provider.createSkill(remoteName, decl, files, apiMode); } catch (err) { result = await adoptOnConflict(err, address, provider, ctx.onFeedback, { + mode: apiMode, searchNames, onExisting: async (existing) => existing, }); @@ -556,6 +570,7 @@ async function executeActionInner( result = await createMemoryStore(name, decl, apiMode); } catch (err) { result = await adoptOnConflict(err, address, provider, ctx.onFeedback, { + mode: apiMode, onExisting: async (existing) => reconcile(existing.id!), }); adopted = true; @@ -711,16 +726,13 @@ async function executeActionInner( const oldId = ctx.state.getResource(address)?.remote_id; if (oldId) { try { - await provider.deleteFile(oldId); + await provider.deleteFile(oldId, priorApiMode); } catch { // best effort — old file may already be gone } } } - const info = await provider.uploadFile(filePath, { - name: decl.name, - purpose: decl.purpose, - }); + const info = await provider.uploadFile(filePath, { name: decl.name, purpose: decl.purpose }, apiMode); result = { id: info.id, type: "file" }; break; } @@ -771,30 +783,50 @@ async function executeActionInner( return adopted; } +function ownedForwardMemoryStoreIds(ctx: ExecContext, provider: string): string[] { + return ctx.state + .listResources() + .filter( + (resource) => + resource.address.provider === provider && + resource.address.type === "memory_store" && + resource.api_mode === "forward" && + typeof resource.remote_id === "string", + ) + .map((resource) => resource.remote_id as string); +} + function resolveResourceApiMode( type: ResourceType, name: string, provider: string, config: ExecContext["config"], ): ProviderResourceMode | undefined { - if (provider !== "qoder" || (type !== "skill" && type !== "vault" && type !== "memory_store" && type !== "file")) { + if ( + provider !== "qoder" || + (type !== "environment" && type !== "skill" && type !== "vault" && type !== "memory_store" && type !== "file") + ) { return undefined; } + // An external Environment reference can resolve in either Qoder API domain. + if (type === "environment" && config.environments?.[name]?.environment_id) return "auto"; let managed = false; let forward = false; for (const agent of Object.values(config.agents ?? {})) { if (agent.provider && agent.provider !== provider) continue; const referenced = - type === "skill" - ? agent.skills?.some((skill) => - typeof skill === "string" ? skill === name : skill.type === "custom" && skill.skill_id === name, - ) - : type === "vault" - ? agent.vault === name - : type === "memory_store" - ? agent.memory_stores?.includes(name) - : false; + type === "environment" + ? agent.environment === name + : type === "skill" + ? agent.skills?.some((skill) => + typeof skill === "string" ? skill === name : skill.type === "custom" && skill.skill_id === name, + ) + : type === "vault" + ? agent.vault === name + : type === "memory_store" + ? agent.memory_stores?.includes(name) + : agent.files?.includes(name); if (!referenced) continue; if (agent.delivery?.qoder?.type === "forward") forward = true; else managed = true; @@ -811,9 +843,10 @@ async function findExistingByNames( provider: Pick, type: ResourceType, names: string[], + mode?: ProviderResourceMode, ): Promise<{ resource: RemoteResource; name: string } | null> { for (const candidate of names) { - const found = await provider.findResource(type, candidate); + const found = await provider.findResource(type, candidate, undefined, mode); if (found && found.id !== null) return { resource: found, name: candidate }; } return null; @@ -826,13 +859,14 @@ async function adoptOnConflict( onFeedback: RuntimeFeedbackSink | undefined, opts: { searchNames?: string[]; + mode?: ProviderResourceMode; onExisting: (existing: RemoteResource) => Promise; }, ): Promise { if (!(err instanceof ConflictError)) throw err; const candidates = opts.searchNames?.length ? opts.searchNames : [address.name]; - const existing = await findExistingByNames(provider, address.type, candidates); + const existing = await findExistingByNames(provider, address.type, candidates, opts.mode); if (!existing) throw nameReservedError(err, address, candidates.join('" / "')); emitRuntimeFeedback(onFeedback, { diff --git a/packages/sdk/src/internal/executor/resolver.ts b/packages/sdk/src/internal/executor/resolver.ts index fabb7b1..dd9777c 100644 --- a/packages/sdk/src/internal/executor/resolver.ts +++ b/packages/sdk/src/internal/executor/resolver.ts @@ -87,7 +87,7 @@ export function resolveTemplateRefs( if (!environment) throw new UserError(`Environment '${agent.environment}' is not defined in config.`); const agentRefs = resolveAgentRefs(agentName, config, provider, state); - const memoryStoreIds = agent.memory_stores?.map((memoryStore) => + const memoryStoreIds = (agent.memory_stores ?? []).map((memoryStore) => requireRef(state, { type: "memory_store", name: memoryStore, provider }), ); const identityName = config.defaults?.identity; @@ -97,7 +97,18 @@ export function resolveTemplateRefs( environment.environment_id ?? requireRef(state, { type: "environment", name: agent.environment, provider }), ...(agent.tunnel ? { tunnel_id: resolveTunnelIdFromConfig(config, agent.tunnel, provider) } : {}), vault_ids: agent.vault ? [requireRef(state, { type: "vault", name: agent.vault, provider })] : [], - ...(memoryStoreIds ? { memory_store_ids: memoryStoreIds } : {}), + file_ids: (agent.files ?? []).map((file) => requireRef(state, { type: "file", name: file, provider })), + memory_store_ids: memoryStoreIds, + owned_memory_store_ids: state + .listResources() + .filter( + (resource) => + resource.address.provider === provider && + resource.address.type === "memory_store" && + resource.api_mode === "forward" && + typeof resource.remote_id === "string", + ) + .map((resource) => resource.remote_id as string), ...(identityName ? { identity_id: requireRef(state, { type: "identity", name: identityName, provider }) } : {}), }; } diff --git a/packages/sdk/src/internal/graph/dependency.ts b/packages/sdk/src/internal/graph/dependency.ts index a4e7ed8..e4e5226 100644 --- a/packages/sdk/src/internal/graph/dependency.ts +++ b/packages/sdk/src/internal/graph/dependency.ts @@ -125,6 +125,13 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str } } + if (decl.files) { + for (const fileName of decl.files) { + const fileAddr: ResourceAddress = { type: "file", name: fileName, provider }; + if (nodes.has(addressKey(fileAddr))) addEdge(agentAddr, fileAddr); + } + } + if (decl.memory_stores) { for (const msName of decl.memory_stores) { const msAddr: ResourceAddress = { diff --git a/packages/sdk/src/internal/parser/schema.ts b/packages/sdk/src/internal/parser/schema.ts index e9769b5..8275d2c 100644 --- a/packages/sdk/src/internal/parser/schema.ts +++ b/packages/sdk/src/internal/parser/schema.ts @@ -260,6 +260,7 @@ const agentSchema = z.object({ mcp_servers: z.array(mcpServerSchema).optional(), skills: z.array(z.union([z.string(), agentSkillRefSchema])).optional(), vault: z.string().optional(), + files: z.array(z.string()).optional(), memory_stores: z.array(z.string()).optional(), default_memory_store: z .object({ diff --git a/packages/sdk/src/internal/planner/hasher.ts b/packages/sdk/src/internal/planner/hasher.ts index 7bbe404..ade3c30 100644 --- a/packages/sdk/src/internal/planner/hasher.ts +++ b/packages/sdk/src/internal/planner/hasher.ts @@ -30,14 +30,18 @@ export async function computeResourceHash( return contentHash({ decl, apiMode }); } - if (address.type === "vault" || address.type === "memory_store") { + if (address.type === "environment" || address.type === "vault" || address.type === "memory_store") { return contentHash({ decl, apiMode: resolveQoderApiMode(address.type, address.name, address.provider, config) }); } if (address.type === "file" && basePath) { const fileDecl = decl as { source: string }; const fileHash = computeLocalFileContentHash(fileDecl.source, basePath); - return contentHash({ decl, fileHash }); + return contentHash({ + decl, + fileHash, + apiMode: resolveQoderApiMode("file", address.name, address.provider, config), + }); } if (address.type === "deployment") { @@ -65,22 +69,28 @@ export async function computeResourceHash( } function resolveQoderApiMode( - type: "skill" | "vault" | "memory_store", + type: "environment" | "skill" | "vault" | "memory_store" | "file", name: string, provider: string, config: ProjectConfig, -): "managed" | "forward" | undefined { +): "managed" | "forward" | "auto" | undefined { if (provider !== "qoder") return undefined; + // External Environment ids are valid in both the Managed and Forward domains. + if (type === "environment" && config.environments?.[name]?.environment_id) return "auto"; for (const agent of Object.values(config.agents ?? {})) { if (agent.provider && agent.provider !== provider) continue; const referenced = - type === "skill" - ? agent.skills?.some((skill) => - typeof skill === "string" ? skill === name : skill.type === "custom" && skill.skill_id === name, - ) - : type === "vault" - ? agent.vault === name - : agent.memory_stores?.includes(name); + type === "environment" + ? agent.environment === name + : type === "skill" + ? agent.skills?.some((skill) => + typeof skill === "string" ? skill === name : skill.type === "custom" && skill.skill_id === name, + ) + : type === "vault" + ? agent.vault === name + : type === "memory_store" + ? agent.memory_stores?.includes(name) + : agent.files?.includes(name); if (referenced && agent.delivery?.qoder?.type === "forward") return "forward"; } return "managed"; @@ -123,6 +133,8 @@ interface TemplateRefDecl { tunnel?: string; vault?: string; skills?: Array; + memory_stores?: string[]; + files?: string[]; } function resolveTemplateReferenceIds( @@ -154,6 +166,17 @@ function resolveTemplateReferenceIds( ? [state?.getResource({ type: "vault", name: decl.vault, provider })?.remote_id ?? decl.vault] : [], skill_ids: skillIds, + memory_store_ids: (decl.memory_stores ?? []).map( + (memoryStore) => + state?.getResource({ type: "memory_store", name: memoryStore, provider })?.remote_id ?? memoryStore, + ), + file_ids: (decl.files ?? []).map( + (file) => state?.getResource({ type: "file", name: file, provider })?.remote_id ?? file, + ), + identity_id: + decl.memory_stores?.length && config.defaults?.identity + ? state?.getResource({ type: "identity", name: config.defaults.identity, provider })?.remote_id + : undefined, }; } diff --git a/packages/sdk/src/internal/planner/refresh.ts b/packages/sdk/src/internal/planner/refresh.ts index 89c4928..4f3939a 100644 --- a/packages/sdk/src/internal/planner/refresh.ts +++ b/packages/sdk/src/internal/planner/refresh.ts @@ -121,7 +121,7 @@ export async function refreshState( // Prefer the recorded remote_id so existence checks hit the detail // endpoint (GET /{id}) instead of matching by name — names are not // guaranteed unique, so name matching can adopt the wrong resource. - const remote = await provider.findResource(res.address.type, res.address.name, res.remote_id); + const remote = await provider.findResource(res.address.type, res.address.name, res.remote_id, res.api_mode); if (!remote) { if (!options.quiet) { emitRuntimeFeedback(options.onFeedback, { diff --git a/packages/sdk/src/internal/providers/interface.ts b/packages/sdk/src/internal/providers/interface.ts index e178ceb..108a688 100644 --- a/packages/sdk/src/internal/providers/interface.ts +++ b/packages/sdk/src/internal/providers/interface.ts @@ -44,7 +44,7 @@ export interface RemoteResource { version?: number; } -export type ProviderResourceMode = "managed" | "forward"; +export type ProviderResourceMode = "managed" | "forward" | "auto"; export interface ModelInfo { id: string; @@ -87,8 +87,11 @@ export interface ResolvedTemplateRefs extends ResolvedAgentRefs { /** Qoder BYOC private-network route used by Forward Templates. */ tunnel_id?: string; vault_ids: string[]; + file_ids?: string[]; identity_id?: string; memory_store_ids?: string[]; + /** Forward Store ids owned by this project and therefore safe to detach during reconciliation. */ + owned_memory_store_ids?: string[]; } export interface ResolvedDeploymentRefs { @@ -165,7 +168,12 @@ export interface ProviderAdapter { * ambiguity of name matching when names are not unique. Without `id`, falls * back to listing and matching by `name` (used by conflict adoption). */ - findResource(type: ResourceType, name: string, id?: string | null): Promise; + findResource( + type: ResourceType, + name: string, + id?: string | null, + mode?: ProviderResourceMode, + ): Promise; // Raw cloud agent list (full remote objects, not local config). Optional: only // providers that can enumerate their remote agents implement it. `prefix` filters // by display-name prefix (e.g. "Agents/") server-side where supported. @@ -192,9 +200,14 @@ export interface ProviderAdapter { */ exportResources?(type: ResourceType): Promise; - createEnvironment(name: string, decl: EnvironmentDecl): Promise; - updateEnvironment(id: string, name: string, decl: EnvironmentDecl): Promise; - deleteEnvironment(id: string, cascade?: boolean): Promise; + createEnvironment(name: string, decl: EnvironmentDecl, mode?: ProviderResourceMode): Promise; + updateEnvironment( + id: string, + name: string, + decl: EnvironmentDecl, + mode?: ProviderResourceMode, + ): Promise; + deleteEnvironment(id: string, cascade?: boolean, mode?: ProviderResourceMode): Promise; createVault(name: string, decl: VaultDecl, mode?: ProviderResourceMode): Promise; deleteVault(id: string, mode?: ProviderResourceMode): Promise; @@ -216,7 +229,7 @@ export interface ProviderAdapter { createTemplate?(name: string, decl: AgentDecl, refs: ResolvedTemplateRefs): Promise; updateTemplate?(id: string, name: string, decl: AgentDecl, refs: ResolvedTemplateRefs): Promise; /** Remove the template from desired state. Qoder implements this as a soft archive. */ - archiveTemplate?(id: string): Promise; + archiveTemplate?(id: string, ownedMemoryStoreIds?: string[]): Promise; reconcileDefaultMemoryStore?( identityId: string, templateId: string, @@ -279,14 +292,18 @@ export interface ProviderAdapter { pauseDeployment?(ctx: DeploymentContext): Promise; unpauseDeployment?(ctx: DeploymentContext): Promise; - uploadFile(filePath: string, options?: { name?: string; purpose?: string }): Promise; + uploadFile( + filePath: string, + options?: { name?: string; purpose?: string }, + mode?: ProviderResourceMode, + ): Promise; /** Upload from in-memory content (no filesystem), for server contexts that receive bytes directly (e.g. webui browser uploads). */ uploadFileContent( content: Uint8Array, filename: string, options?: { mimeType?: string; purpose?: string }, ): Promise; - deleteFile(id: string): Promise; + deleteFile(id: string, mode?: ProviderResourceMode): Promise; /** Fetch a single file's metadata (incl. scan `status`); used to gate session binding on availability. */ getFileInfo?(id: string): Promise; /** diff --git a/packages/sdk/src/internal/providers/qoder/adapter.ts b/packages/sdk/src/internal/providers/qoder/adapter.ts index 550687a..89f52b1 100644 --- a/packages/sdk/src/internal/providers/qoder/adapter.ts +++ b/packages/sdk/src/internal/providers/qoder/adapter.ts @@ -39,6 +39,7 @@ import type { SkillFile } from "../../types/skill-file.ts"; import type { ProviderSkillInfo } from "../../types/skill-info.ts"; import type { ResourceType } from "../../types/state.ts"; import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts"; +import { skillNameFromFiles } from "../../utils/skill-manifest.ts"; import { ApiError, toRemoteResource } from "../base-client.ts"; import { preserveDeploymentFilesOnConflict } from "../deployment-conflict.ts"; import type { @@ -82,6 +83,7 @@ import { mapDeployment, mapDeploymentUpdate, mapEnvironment, + mapForwardEnvironment, mapForwardTemplate, mapMemoryStore, mapSendMessage, @@ -178,7 +180,12 @@ export class QoderAdapter implements ProviderAdapter { deployment: "/deployments", }; - async findResource(type: ResourceType, name: string, id?: string | null): Promise { + async findResource( + type: ResourceType, + name: string, + id?: string | null, + mode?: ProviderResourceMode, + ): Promise { if (type === "template") { const raw = await locateRemote(this.forwardClient, "/templates", name, id, (item) => item.status !== "archived"); return raw ? toRemoteResource(raw) : null; @@ -200,7 +207,22 @@ export class QoderAdapter implements ProviderAdapter { const raw = await locateRemote(this.forwardClient, "/channels", name, id, () => true); return raw ? toRemoteResource(raw) : null; } - const raw = await locateRemote(this.client, QoderAdapter.ENDPOINT_MAP[type], name, id, notArchived); + // An Environment id referenced by a Forward Template may belong to either + // the Cloud/Managed API or the Forward API. With no ownership domain + // recorded, resolve the external id across both read APIs. Locally owned + // environments always pass an explicit mode and never use this fallback. + if (type === "environment" && id && mode === "auto") { + const managed = await locateRemote(this.client, "/environments", name, id, notArchived); + if (managed) return toRemoteResource(managed); + const forward = await locateRemote(this.forwardClient, "/environments", name, id, notArchived); + return forward ? toRemoteResource(forward) : null; + } + const client = + mode === "forward" && + (type === "environment" || type === "skill" || type === "vault" || type === "memory_store" || type === "file") + ? this.forwardClient + : this.client; + const raw = await locateRemote(client, QoderAdapter.ENDPOINT_MAP[type], name, id, notArchived); return raw ? toRemoteResource(raw) : null; } @@ -396,26 +418,50 @@ export class QoderAdapter implements ProviderAdapter { }); } - async createEnvironment(name: string, decl: EnvironmentDecl): Promise { - const body = mapEnvironment(name, decl, this.projectName); - const res = (await this.client.post("/environments", body)) as Record; + async createEnvironment( + name: string, + decl: EnvironmentDecl, + mode: ProviderResourceMode = "managed", + ): Promise { + const body = + mode === "forward" + ? mapForwardEnvironment(name, decl, this.projectName) + : mapEnvironment(name, decl, this.projectName); + const res = (await (mode === "forward" ? this.forwardClient : this.client).post("/environments", body)) as Record< + string, + unknown + >; return toRemoteResource(res); } - async updateEnvironment(id: string, name: string, decl: EnvironmentDecl): Promise { - const body = mapEnvironment(name, decl, this.projectName) as Record; - const current = (await this.client.get(`/environments/${id}`)) as Record; + async updateEnvironment( + id: string, + name: string, + decl: EnvironmentDecl, + mode: ProviderResourceMode = "managed", + ): Promise { + const body = ( + mode === "forward" + ? mapForwardEnvironment(name, decl, this.projectName) + : mapEnvironment(name, decl, this.projectName) + ) as Record; + const client = mode === "forward" ? this.forwardClient : this.client; + const current = (await client.get(`/environments/${id}`)) as Record; const currentMetadata = (current.metadata ?? {}) as Record; const metadata = { ...((body.metadata ?? {}) as Record) }; for (const key of Object.keys(currentMetadata)) { if (!key.startsWith("agents.") && !(key in metadata)) metadata[key] = null; } body.metadata = metadata; - const res = (await this.client.post(`/environments/${id}`, body)) as Record; + const res = (await client.post(`/environments/${id}`, body)) as Record; return toRemoteResource(res); } - async deleteEnvironment(id: string, cascade = false): Promise { + async deleteEnvironment(id: string, cascade = false, mode: ProviderResourceMode = "managed"): Promise { + if (mode === "forward") { + await this.forwardClient.delete(`/environments/${id}`); + return; + } try { await this.client.delete(`/environments/${id}`); return; @@ -509,8 +555,12 @@ export class QoderAdapter implements ProviderAdapter { files: SkillFile[], mode: ProviderResourceMode = "managed", ): Promise { - await (mode === "forward" ? this.forwardClient : this.client).delete(`/skills/${id}`); - return this.createSkill(name, decl, files, mode); + const client = mode === "forward" ? this.forwardClient : this.client; + const packageName = skillNameFromFiles(files) ?? name; + const formData = await buildSkillFormData(packageName, decl, files, "files", false, true); + await client.postFormData(`/skills/${id}/versions`, formData); + const current = (await client.get(`/skills/${id}`)) as Record; + return toRemoteResource(current); } async deleteSkill(id: string, mode: ProviderResourceMode = "managed"): Promise { @@ -552,7 +602,25 @@ export class QoderAdapter implements ProviderAdapter { return toRemoteResource(res); } - async archiveTemplate(id: string): Promise { + async archiveTemplate(id: string, ownedMemoryStoreIds: string[] = []): Promise { + const owned = new Set(ownedMemoryStoreIds); + if (owned.size > 0) { + for (const identity of await this.forwardClient.getAllPaged("/identities")) { + if (typeof identity.id !== "string") continue; + const path = `/identities/${identity.id}/templates/${id}/memory_stores`; + try { + const mounts = (await this.forwardClient.get(path)) as { data?: Array> }; + for (const mount of mounts.data ?? []) { + const storeId = mount.memory_store_id; + if (mount.system_managed !== true && typeof storeId === "string" && owned.has(storeId)) { + await this.forwardClient.delete(`${path}/${storeId}`); + } + } + } catch (error) { + if (!ApiError.isNotFound(error)) throw error; + } + } + } await this.forwardClient.post(`/templates/${id}/archive`, {}); } @@ -566,9 +634,10 @@ export class QoderAdapter implements ProviderAdapter { const path = `/identities/${refs.identity_id}/templates/${templateId}/memory_stores`; const current = (await this.forwardClient.get(path)) as { data?: Array> }; const explicit = (current.data ?? []).filter((mount) => mount.system_managed !== true); + const owned = new Set(refs.owned_memory_store_ids ?? refs.memory_store_ids ?? []); for (const mount of explicit) { const storeId = mount.memory_store_id; - if (typeof storeId === "string" && !desired.has(storeId)) { + if (typeof storeId === "string" && owned.has(storeId) && !desired.has(storeId)) { await this.forwardClient.delete(`${path}/${storeId}`); } } @@ -612,7 +681,10 @@ export class QoderAdapter implements ProviderAdapter { } async deleteDefaultMemoryStore(id: string): Promise { - await this.forwardClient.delete(`/memory_stores/${id}`); + // Forward DELETE rejects the system-managed default Store while its + // non-detachable Identity/Template mount exists. The Cloud lifecycle API + // permanently deletes the same underlying Store and its stale binding. + await this.client.delete(`/memory_stores/${id}`); } async createIdentity(name: string, decl: IdentityDecl): Promise { @@ -717,15 +789,18 @@ export class QoderAdapter implements ProviderAdapter { await memoryApi.createMemory(storeId, { content: entry.content, path: entry.key }); } } catch (error) { - await client.delete(`/memory_stores/${storeId}`).catch(() => undefined); + await this.client.delete(`/memory_stores/${storeId}`).catch(() => undefined); throw error; } return toRemoteResource(res); } - async deleteMemoryStore(id: string, mode: ProviderResourceMode = "managed"): Promise { - await (mode === "forward" ? this.forwardClient : this.client).delete(`/memory_stores/${id}`); + async deleteMemoryStore(id: string, _mode: ProviderResourceMode = "managed"): Promise { + // Memory Store persistence is shared across Qoder API domains. Permanent + // deletion belongs to the Cloud lifecycle API, which also clears stale + // Forward mounts left by archived Templates or deleted Identities. + await this.client.delete(`/memory_stores/${id}`); } listMemoryStores(options?: MemoryStoreListOptions) { @@ -1096,13 +1171,25 @@ export class QoderAdapter implements ProviderAdapter { // --- Files --- - async uploadFile(filePath: string, options?: { name?: string; purpose?: string }): Promise { + async uploadFile( + filePath: string, + options?: { name?: string; purpose?: string }, + mode: ProviderResourceMode = "managed", + ): Promise { + if (mode === "forward" && options?.purpose && !["user_upload", "session_resource"].includes(options.purpose)) { + throw new UserError( + `Qoder Forward File purpose must be 'user_upload' or 'session_resource', got '${options.purpose}'.`, + ); + } const resolved = resolve(filePath); const content = readFileSync(resolved); const fileName = options?.name ?? basename(resolved); - return this.uploadFileContent(new Uint8Array(content), fileName, { - purpose: options?.purpose, - }); + const formData = buildFileFormData(new Uint8Array(content), fileName, { purpose: options?.purpose }); + const res = (await (mode === "forward" ? this.forwardClient : this.client).postFormData( + "/files", + formData, + )) as Record; + return toRestFileInfo(res); } async uploadFileContent( @@ -1110,23 +1197,32 @@ export class QoderAdapter implements ProviderAdapter { filename: string, options?: { mimeType?: string; purpose?: string }, ): Promise { - const formData = new FormData(); - const bytes = new Uint8Array(content); - formData.append( - "file", - options?.mimeType ? new File([bytes], filename, { type: options.mimeType }) : new File([bytes], filename), - ); - if (filename) formData.append("name", filename); - if (options?.purpose) formData.append("purpose", options.purpose); + const formData = buildFileFormData(content, filename, options); const res = (await this.client.postFormData("/files", formData)) as Record; return toRestFileInfo(res); } - async deleteFile(id: string): Promise { - await this.client.delete(`/files/${id}`); + async deleteFile(id: string, mode: ProviderResourceMode = "managed"): Promise { + await (mode === "forward" ? this.forwardClient : this.client).delete(`/files/${id}`); } } +function buildFileFormData( + content: Uint8Array, + filename: string, + options?: { mimeType?: string; purpose?: string }, +): FormData { + const formData = new FormData(); + const bytes = new Uint8Array(content); + formData.append( + "file", + options?.mimeType ? new File([bytes], filename, { type: options.mimeType }) : new File([bytes], filename), + ); + if (filename) formData.append("name", filename); + if (options?.purpose) formData.append("purpose", options.purpose); + return formData; +} + export function toSessionInfo(res: Record): ProviderSessionInfo { return buildSessionInfo(res, (r) => (r.memory_store_ids as string[]) ?? []); } @@ -1189,22 +1285,31 @@ function normalizeQoderMcpServers(value: unknown): unknown { }); } -async function buildSkillFormData(name: string, decl: SkillDecl, files: SkillFile[]): Promise { +async function buildSkillFormData( + name: string, + decl: SkillDecl, + files: SkillFile[], + fileField = "file", + includeCreateFields = true, + prefixTopLevelDirectory = false, +): Promise { const zip = new JSZip(); for (const f of files) { - zip.file(f.relativePath, f.content); + zip.file(prefixTopLevelDirectory ? `${name}/${f.relativePath}` : f.relativePath, f.content); } const zipContent = await zip.generateAsync({ type: "uint8array" }); const formData = new FormData(); formData.append( - "file", + fileField, new File([new Uint8Array(zipContent)], `${name}.zip`, { type: "application/zip", }), ); - formData.append("name", name); - formData.append("type", "custom"); - if (decl.description) formData.append("description", decl.description); + if (includeCreateFields) { + formData.append("name", name); + formData.append("type", "custom"); + if (decl.description) formData.append("description", decl.description); + } return formData; } diff --git a/packages/sdk/src/internal/providers/qoder/mapper.ts b/packages/sdk/src/internal/providers/qoder/mapper.ts index bd09a1e..50f0101 100644 --- a/packages/sdk/src/internal/providers/qoder/mapper.ts +++ b/packages/sdk/src/internal/providers/qoder/mapper.ts @@ -69,6 +69,15 @@ export function mapEnvironment(name: string, decl: EnvironmentDecl, projectName: }; } +/** Forward Environment config has its own schema and does not accept Managed networking fields. */ +export function mapForwardEnvironment(name: string, decl: EnvironmentDecl, projectName: string): unknown { + const body = mapEnvironment(name, decl, projectName) as Record; + const config = { ...(body.config as Record) }; + delete config.networking; + body.config = config; + return body; +} + // Qoder's create-vault endpoint accepts only display_name + metadata; credentials are // added one-by-one via POST /vaults/{id}/credentials (see adapter.createVault). export function mapVault(name: string, decl: VaultDecl, projectName: string): unknown { @@ -489,6 +498,7 @@ export function mapForwardTemplate( environment_id: refs.environment_id, vault_ids: refs.vault_ids, }; + body.files = Object.fromEntries((refs.file_ids ?? []).map((id) => [id, { enabled: true }])); if (refs.tunnel_id) body.tunnel_id = refs.tunnel_id; if (projectName) body.metadata = injectMetadata(decl.metadata, projectName, name); else body.metadata = decl.metadata ?? {}; diff --git a/packages/sdk/src/internal/providers/resource-workflow.ts b/packages/sdk/src/internal/providers/resource-workflow.ts index 824348a..a974fcf 100644 --- a/packages/sdk/src/internal/providers/resource-workflow.ts +++ b/packages/sdk/src/internal/providers/resource-workflow.ts @@ -44,11 +44,21 @@ import type { export interface ResourceCrudAdapter { readonly name: string; - findResource(type: ResourceType, name: string, id?: string | null): Promise; + findResource( + type: ResourceType, + name: string, + id?: string | null, + mode?: ProviderResourceMode, + ): Promise; - createEnvironment(name: string, decl: EnvironmentDecl): Promise; - updateEnvironment(id: string, name: string, decl: EnvironmentDecl): Promise; - deleteEnvironment(id: string, cascade?: boolean): Promise; + createEnvironment(name: string, decl: EnvironmentDecl, mode?: ProviderResourceMode): Promise; + updateEnvironment( + id: string, + name: string, + decl: EnvironmentDecl, + mode?: ProviderResourceMode, + ): Promise; + deleteEnvironment(id: string, cascade?: boolean, mode?: ProviderResourceMode): Promise; createVault(name: string, decl: VaultDecl, mode?: ProviderResourceMode): Promise; deleteVault(id: string, mode?: ProviderResourceMode): Promise; @@ -69,7 +79,7 @@ export interface ResourceCrudAdapter { createTemplate?(name: string, decl: AgentDecl, refs: ResolvedTemplateRefs): Promise; updateTemplate?(id: string, name: string, decl: AgentDecl, refs: ResolvedTemplateRefs): Promise; - archiveTemplate?(id: string): Promise; + archiveTemplate?(id: string, ownedMemoryStoreIds?: string[]): Promise; createIdentity?(name: string, decl: IdentityDecl): Promise; updateIdentity?(id: string, name: string, decl: IdentityDecl): Promise; @@ -114,8 +124,12 @@ export interface ResourceCrudAdapter { ): Promise; deleteDeployment(id: string): Promise; - uploadFile(filePath: string, options?: { name?: string; purpose?: string }): Promise; - deleteFile(id: string): Promise; + uploadFile( + filePath: string, + options?: { name?: string; purpose?: string }, + mode?: ProviderResourceMode, + ): Promise; + deleteFile(id: string, mode?: ProviderResourceMode): Promise; } /** diff --git a/packages/sdk/src/internal/types/config.ts b/packages/sdk/src/internal/types/config.ts index f673bd5..1df46f4 100644 --- a/packages/sdk/src/internal/types/config.ts +++ b/packages/sdk/src/internal/types/config.ts @@ -170,6 +170,8 @@ export interface AgentDecl { mcp_servers?: McpServerDecl[]; skills?: AgentSkillDecl[]; vault?: string; + /** File declarations inherited by a Qoder Forward Template. */ + files?: string[]; memory_stores?: string[]; /** Desired display metadata for Qoder Forward's system-managed writable Store. */ default_memory_store?: DefaultMemoryStoreDecl; diff --git a/packages/sdk/src/internal/types/state.ts b/packages/sdk/src/internal/types/state.ts index 55d7d92..d89b4df 100644 --- a/packages/sdk/src/internal/types/state.ts +++ b/packages/sdk/src/internal/types/state.ts @@ -11,7 +11,7 @@ export interface ResourceState { */ externally_managed?: boolean; /** Provider API domain that owns the remote resource (currently relevant to Qoder). */ - api_mode?: "managed" | "forward"; + api_mode?: "managed" | "forward" | "auto"; version?: number; /** * Backward-compatible alias for desired_hash. Kept while older state files diff --git a/packages/sdk/tests/unit/drift-detection.test.ts b/packages/sdk/tests/unit/drift-detection.test.ts index c8310f6..9165577 100644 --- a/packages/sdk/tests/unit/drift-detection.test.ts +++ b/packages/sdk/tests/unit/drift-detection.test.ts @@ -262,7 +262,9 @@ describe("planner drift classification", () => { describe("Qoder archived resources are treated as gone", () => { function adapterWith(getImpl: (path: string) => Promise, paged: Record[] = []) { const adapter = new QoderAdapter("pt-test", undefined, "tmp") as any; - adapter.client = { get: getImpl, getAllPaged: async () => paged }; + const client = { get: getImpl, getAllPaged: async () => paged }; + adapter.client = client; + adapter.forwardClient = client; return adapter; } diff --git a/packages/sdk/tests/unit/qoder-forward-template.test.ts b/packages/sdk/tests/unit/qoder-forward-template.test.ts index a211678..36f7a66 100644 --- a/packages/sdk/tests/unit/qoder-forward-template.test.ts +++ b/packages/sdk/tests/unit/qoder-forward-template.test.ts @@ -1,11 +1,14 @@ import { describe, expect, test } from "bun:test"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import JSZip from "jszip"; import { validateProjectConfig } from "../../src/internal/core/validate-config.ts"; import { executePlan } from "../../src/internal/executor/executor.ts"; +import { resolveTemplateRefs } from "../../src/internal/executor/resolver.ts"; import { buildDependencyGraph } from "../../src/internal/graph/dependency.ts"; import { computeResourceHash } from "../../src/internal/planner/hasher.ts"; import { buildPlan } from "../../src/internal/planner/planner.ts"; +import { ApiError } from "../../src/internal/providers/base-client.ts"; import type { ProviderAdapter } from "../../src/internal/providers/interface.ts"; import { QoderAdapter } from "../../src/internal/providers/qoder/adapter.ts"; import { mapForwardTemplate } from "../../src/internal/providers/qoder/mapper.ts"; @@ -177,9 +180,65 @@ describe("Qoder Forward Template declaration", () => { const managedHash = await computeResourceHash(address, config); expect(forwardHash).not.toBe(managedHash); }); + + test("keeps an unrelated Managed File in the Managed API domain", async () => { + const config = forwardConfig(); + config.files = { handbook: { source: "README.md" } }; + const address = { type: "file", name: "handbook", provider: "qoder" } as const; + const withForwardAgent = await computeResourceHash(address, config); + delete config.agents; + const managedOnly = await computeResourceHash(address, config); + expect(withForwardAgent).toBe(managedOnly); + }); }); describe("Qoder Forward Template mapping and lifecycle", () => { + test("routes self-created Forward Environments without changing Managed Environment routing", async () => { + const calls: string[] = []; + const adapter = new QoderAdapter("pt-test") as any; + adapter.client = { + post: async (path: string) => { + calls.push(`managed POST ${path}`); + return { id: "env_managed" }; + }, + }; + adapter.forwardClient = { + post: async (path: string, body: Record) => { + calls.push(`forward POST ${path}`); + expect(body.config.networking).toBeUndefined(); + return { id: "env_forward" }; + }, + }; + await adapter.createEnvironment("managed", { config: { type: "cloud" } }, "managed"); + await adapter.createEnvironment( + "forward", + { config: { type: "cloud", networking: { type: "unrestricted" } } }, + "forward", + ); + expect(calls).toEqual(["managed POST /environments", "forward POST /environments"]); + }); + + test("resolves an external Environment id from either API domain", async () => { + const calls: string[] = []; + const adapter = new QoderAdapter("pt-test") as any; + adapter.client = { + get: async (path: string) => { + calls.push(`managed GET ${path}`); + throw new ApiError(404, "not found", "test"); + }, + }; + adapter.forwardClient = { + get: async (path: string) => { + calls.push(`forward GET ${path}`); + return { id: "env_forward", type: "environment", name: "external" }; + }, + }; + + const found = await adapter.findResource("environment", "external", "env_forward", "auto"); + expect(found?.id).toBe("env_forward"); + expect(calls).toEqual(["managed GET /environments/env_forward", "forward GET /environments/env_forward"]); + }); + test("maps BYOC bindings and tool permissions", () => { const decl = forwardConfig().agents!.assistant!; decl.environment_variables = { BASE_MODE: "support" }; @@ -280,13 +339,17 @@ describe("Qoder Forward Template mapping and lifecycle", () => { ]); }); - test("uses Forward lifecycle endpoints for Forward-owned Skills and Vault credentials", async () => { + test("appends Skill versions in both API domains and keeps Forward Vault credentials isolated", async () => { const managedCalls: string[] = []; const forwardCalls: string[] = []; const adapter = new QoderAdapter("pt-test") as any; adapter.client = { post: async (path: string) => managedCalls.push(`POST ${path}`), postFormData: async (path: string) => managedCalls.push(`POST_FORM ${path}`), + get: async (path: string) => { + managedCalls.push(`GET ${path}`); + return { id: "skill_managed" }; + }, delete: async (path: string) => managedCalls.push(`DELETE ${path}`), }; adapter.forwardClient = { @@ -298,10 +361,16 @@ describe("Qoder Forward Template mapping and lifecycle", () => { forwardCalls.push(`POST_FORM ${path}`); return { id: "skill_forward" }; }, + get: async (path: string) => { + forwardCalls.push(`GET ${path}`); + return { id: "skill_forward" }; + }, delete: async (path: string) => forwardCalls.push(`DELETE ${path}`), }; await adapter.createSkill("forward-skill", { source: "." }, [], "forward"); + await adapter.updateSkill("skill_forward", "forward-skill", { source: "." }, [], "forward"); + await adapter.updateSkill("skill_managed", "managed-skill", { source: "." }, [], "managed"); await adapter.deleteSkill("skill_forward", "forward"); await adapter.createVault( "forward-vault", @@ -313,9 +382,11 @@ describe("Qoder Forward Template mapping and lifecycle", () => { ); await adapter.deleteVault("vault_forward", "forward"); - expect(managedCalls).toEqual([]); + expect(managedCalls).toEqual(["POST_FORM /skills/skill_managed/versions", "GET /skills/skill_managed"]); expect(forwardCalls).toEqual([ "POST_FORM /skills", + "POST_FORM /skills/skill_forward/versions", + "GET /skills/skill_forward", "DELETE /skills/skill_forward", "POST /vaults", "POST /vaults/vault_forward/credentials", @@ -323,6 +394,180 @@ describe("Qoder Forward Template mapping and lifecycle", () => { ]); }); + test("packages Skill versions under the SKILL.md manifest name", async () => { + let entries: string[] = []; + const adapter = new QoderAdapter("pt-test") as any; + adapter.client = { + postFormData: async (_path: string, form: FormData) => { + const archive = form.get("files") as File; + const zip = await JSZip.loadAsync(await archive.arrayBuffer()); + entries = Object.keys(zip.files); + }, + get: async () => ({ id: "skill_1" }), + }; + await adapter.updateSkill( + "skill_1", + "display-alias", + { source: "." }, + [ + { + relativePath: "SKILL.md", + content: "---\nname: manifest-name\ndescription: test\n---\n", + }, + ], + "managed", + ); + expect(entries).toContain("manifest-name/SKILL.md"); + expect(entries).not.toContain("display-alias/SKILL.md"); + }); + + test("creates Forward Memory Stores with idempotency and reconciles Identity mounts", async () => { + const calls: Array<{ method: string; path: string; body?: unknown; options?: unknown }> = []; + const adapter = new QoderAdapter("pt-test") as any; + adapter.forwardClient = { + post: async (path: string, body: unknown, options?: unknown) => { + calls.push({ method: "POST", path, body, options }); + if (path === "/memory_stores") return { id: "memstore_forward" }; + if (path === "/templates") return { id: "tmpl_forward" }; + return {}; + }, + get: async (path: string) => { + calls.push({ method: "GET", path }); + return { + data: [ + { memory_store_id: "memstore_old", system_managed: false }, + { memory_store_id: "memstore_external", system_managed: false }, + ], + }; + }, + delete: async (path: string) => calls.push({ method: "DELETE", path }), + }; + adapter.forwardMemoryApi = { createMemory: async () => ({}) }; + + await adapter.createMemoryStore("forward-memory", { description: "Forward memory" }, "forward"); + await adapter.createTemplate("assistant", forwardConfig().agents!.assistant!, { + environment_id: "env_byoc", + vault_ids: [], + skill_ids: [], + identity_id: "idn_1", + memory_store_ids: ["memstore_forward"], + owned_memory_store_ids: ["memstore_old", "memstore_forward"], + }); + + const createStore = calls.find((call) => call.path === "/memory_stores"); + expect(createStore?.options).toMatchObject({ headers: { "Idempotency-Key": expect.any(String) } }); + expect(calls.map(({ method, path }) => `${method} ${path}`)).toContain( + "DELETE /identities/idn_1/templates/tmpl_forward/memory_stores/memstore_old", + ); + expect(calls.map(({ method, path }) => `${method} ${path}`)).not.toContain( + "DELETE /identities/idn_1/templates/tmpl_forward/memory_stores/memstore_external", + ); + expect(calls.find((call) => call.path.startsWith("/identities/") && call.body)?.body).toEqual({ + memory_store_id: "memstore_forward", + }); + }); + + test("permanently deletes an explicit Forward Memory Store through the Cloud endpoint", async () => { + const managedCalls: string[] = []; + const forwardCalls: string[] = []; + const adapter = new QoderAdapter("pt-test") as any; + adapter.client = { + delete: async (path: string) => managedCalls.push(`DELETE ${path}`), + }; + adapter.forwardClient = { + delete: async (path: string) => forwardCalls.push(`DELETE ${path}`), + }; + + await adapter.deleteMemoryStore("memstore_forward", "forward"); + + expect(managedCalls).toEqual(["DELETE /memory_stores/memstore_forward"]); + expect(forwardCalls).toEqual([]); + }); + + test("rolls back a partially created Forward Memory Store through the Cloud endpoint", async () => { + const managedCalls: string[] = []; + const forwardCalls: string[] = []; + const adapter = new QoderAdapter("pt-test") as any; + adapter.client = { + delete: async (path: string) => managedCalls.push(`DELETE ${path}`), + }; + adapter.forwardClient = { + post: async (path: string) => { + forwardCalls.push(`POST ${path}`); + return { id: "memstore_partial" }; + }, + delete: async (path: string) => forwardCalls.push(`DELETE ${path}`), + }; + adapter.forwardMemoryApi = { + createMemory: async () => { + throw new Error("entry upload failed"); + }, + }; + + await expect( + adapter.createMemoryStore("partial", { entries: [{ key: "MEMORY.md", content: "partial" }] }, "forward"), + ).rejects.toThrow("entry upload failed"); + + expect(managedCalls).toEqual(["DELETE /memory_stores/memstore_partial"]); + expect(forwardCalls).toEqual(["POST /memory_stores"]); + }); + + test("detaches only project-owned Memory Store mounts while archiving a Template", async () => { + const calls: string[] = []; + const adapter = new QoderAdapter("pt-test") as any; + adapter.forwardClient = { + getAllPaged: async () => [{ id: "idn_1" }], + get: async () => ({ + data: [ + { memory_store_id: "memstore_owned", system_managed: false }, + { memory_store_id: "memstore_external", system_managed: false }, + { memory_store_id: "memstore_default", system_managed: true }, + ], + }), + delete: async (path: string) => calls.push(`DELETE ${path}`), + post: async (path: string) => calls.push(`POST ${path}`), + }; + await adapter.archiveTemplate("tmpl_1", ["memstore_owned"]); + expect(calls).toEqual([ + "DELETE /identities/idn_1/templates/tmpl_1/memory_stores/memstore_owned", + "POST /templates/tmpl_1/archive", + ]); + }); + + test("maps Forward Template file references using Forward file ids", () => { + const decl = forwardConfig().agents!.assistant!; + decl.files = ["handbook"]; + const body = mapForwardTemplate("assistant", decl, { + environment_id: "env_forward", + vault_ids: [], + skill_ids: [], + file_ids: ["file_forward"], + }) as Record; + expect(body.files).toEqual({ file_forward: { enabled: true } }); + }); + + test("sends an empty File map and Memory Store set when bindings are removed", async () => { + const config = forwardConfig(); + config.defaults = { provider: "qoder", identity: "user" }; + config.identities = { user: { external_id: "user" } }; + delete config.agents!.assistant!.vault; + const state = StateManager.initialize(tmpPath("forward-empty-bindings")); + state.setResource({ + address: { type: "identity", name: "user", provider: "qoder" }, + remote_id: "idn_1", + content_hash: "identity", + }); + const refs = resolveTemplateRefs("assistant", config, "qoder", state); + expect(refs).toMatchObject({ + file_ids: [], + memory_store_ids: [], + owned_memory_store_ids: [], + identity_id: "idn_1", + }); + const body = mapForwardTemplate("assistant", config.agents!.assistant!, refs) as Record; + expect(body.files).toEqual({}); + }); + test("reads a full Template drift snapshot including BYOC bindings", async () => { const adapter = new QoderAdapter("pt-test") as any; adapter.forwardClient = { @@ -441,6 +686,82 @@ describe("Qoder Forward Template mapping and lifecycle", () => { }); describe("Forward delivery validation and runtime isolation", () => { + test("rejects Agent files outside Qoder Forward Templates", () => { + const config: ProjectConfig = { + version: "1", + providers: { claude: { api_key: "test" } }, + defaults: { provider: "claude" }, + files: { handbook: { source: "README.md" } }, + agents: { + assistant: { + provider: "claude", + model: { claude: "claude-sonnet-4-5" }, + instructions: "test", + files: ["handbook"], + }, + }, + }; + expect(validateProjectConfig(config).some((item) => item.code === "config.agent.files.unsupported")).toBe(true); + }); + + test("migrates a locally owned Environment between API domains without orphaning the old resource", async () => { + const config = forwardConfig(); + config.environments!.byoc = { config: { type: "cloud" } }; + const state = StateManager.initialize(tmpPath("forward-environment-domain")); + state.setResource({ + address: { type: "environment", name: "byoc", provider: "qoder" }, + remote_id: "env_managed", + content_hash: "old", + api_mode: "managed", + }); + const calls: string[] = []; + const provider = { + name: "qoder", + createEnvironment: async (_name: string, _decl: unknown, mode: unknown) => { + calls.push(`create:${mode}`); + return { id: "env_forward", type: "environment" }; + }, + deleteEnvironment: async (id: string, _cascade: boolean, mode: unknown) => { + calls.push(`delete:${id}:${mode}`); + }, + } as unknown as ProviderAdapter; + await executePlan( + { + actions: [ + { + action: "update", + address: { type: "environment", name: "byoc", provider: "qoder" }, + dependencies: [], + }, + ], + diagnostics: [], + }, + { config, providers: new Map([["qoder", provider]]), state }, + ); + expect(calls).toEqual(["create:forward", "delete:env_managed:managed"]); + expect(state.getResource({ type: "environment", name: "byoc", provider: "qoder" })?.remote_id).toBe("env_forward"); + }); + + test("rejects sharing locally owned Environments and Files but allows an external Environment reference", () => { + const config = forwardConfig(); + config.environments!.byoc = { config: { type: "cloud" } }; + config.files = { handbook: { source: "README.md" } }; + config.agents!.assistant!.files = ["handbook"]; + config.agents!.managed = { + model: { qoder: "auto" }, + instructions: "Managed.", + environment: "byoc", + files: ["handbook"], + }; + let diagnostics = validateProjectConfig(config); + expect(diagnostics.some((item) => item.code === "qoder.environment.delivery_domain.conflict")).toBe(true); + expect(diagnostics.some((item) => item.code === "qoder.file.delivery_domain.conflict")).toBe(true); + + config.environments!.byoc!.environment_id = "env_existing"; + diagnostics = validateProjectConfig(config); + expect(diagnostics.some((item) => item.code === "qoder.environment.delivery_domain.conflict")).toBe(false); + }); + test("rejects sharing one Qoder Vault across Managed and Forward API domains", () => { const config = forwardConfig(); config.agents!.managed = { @@ -645,26 +966,28 @@ describe("Forward delivery validation and runtime isolation", () => { }); describe("Qoder Forward default memory store", () => { - test("deletes a captured default Store through the Forward endpoint", async () => { - const calls: string[] = []; + test("deletes a captured default Store through the Cloud permanent-delete endpoint", async () => { + const managedCalls: string[] = []; + const forwardCalls: string[] = []; const adapter = new QoderAdapter("pt-test") as any; + adapter.client = { + delete: async (path: string) => managedCalls.push(`DELETE ${path}`), + }; adapter.forwardClient = { get: async (path: string) => { - calls.push(`GET ${path}`); + forwardCalls.push(`GET ${path}`); return { data: [{ memory_store_id: "memstore_default", system_managed: true, access: "read_write" }], }; }, - delete: async (path: string) => calls.push(`DELETE ${path}`), + delete: async (path: string) => forwardCalls.push(`DELETE ${path}`), }; const id = await adapter.findDefaultMemoryStoreId("idn_1", "tmpl_1"); await adapter.deleteDefaultMemoryStore(id!); - expect(calls).toEqual([ - "GET /identities/idn_1/templates/tmpl_1/memory_stores", - "DELETE /memory_stores/memstore_default", - ]); + expect(forwardCalls).toEqual(["GET /identities/idn_1/templates/tmpl_1/memory_stores"]); + expect(managedCalls).toEqual(["DELETE /memory_stores/memstore_default"]); }); test("finds the writable system mount and updates changed metadata", async () => {