diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 8d76c0044f9..f068317752a 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -4266568b93a03ce07e7811285ebc67ec90ebf7519ee892bbeb618b4a527f81e9 plugin-sdk-api-baseline.json -545c2ea8874ef1332c7710b74291f391231c861ac296afa46ddcc91fc1c2ed1c plugin-sdk-api-baseline.jsonl +cb1975fe65fcab0d50f4bf368118e61640d870a13bb8d9a44a9abb0f79f3c729 plugin-sdk-api-baseline.json +c8e2ebe7dc13d170b83b96109dd46fc33057e6f4200f981dc5ea9623e73affab plugin-sdk-api-baseline.jsonl diff --git a/docs/cli/migrate.md b/docs/cli/migrate.md index 24f44d0bea5..fd79b596ac3 100644 --- a/docs/cli/migrate.md +++ b/docs/cli/migrate.md @@ -151,7 +151,7 @@ Migration sources are plugins. A plugin declares its provider ids in `openclaw.p At runtime the plugin calls `api.registerMigrationProvider(...)`. The provider implements `detect`, `plan`, and `apply`. Core owns CLI orchestration, backup policy, prompts, JSON output, and conflict preflight. Core passes the reviewed plan into `apply(ctx, plan)`, and providers may rebuild the plan only when that argument is absent for compatibility. -Provider plugins can use `openclaw/plugin-sdk/migration` for item construction and summary counts, plus `openclaw/plugin-sdk/migration-runtime` for conflict-aware file copies, archive-only report copies, and migration reports. +Provider plugins can use `openclaw/plugin-sdk/migration` for item construction and summary counts, plus `openclaw/plugin-sdk/migration-runtime` for conflict-aware file copies, archive-only report copies, cached config-runtime wrappers, and migration reports. ## Onboarding integration diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index cb2180cde61..c498aa7fbd2 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -38,7 +38,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | `plugin-sdk/test-fixtures` | Generic CLI, sandbox, skill, agent-message, system-event, module reload, bundled plugin path, terminal, chunking, auth-token, and typed-case test fixtures | | `plugin-sdk/test-node-mocks` | Focused Node builtin mock helpers for use inside Vitest `vi.mock("node:*")` factories | | `plugin-sdk/migration` | Migration provider item helpers such as `createMigrationItem`, reason constants, item status markers, redaction helpers, and `summarizeMigrationItems` | -| `plugin-sdk/migration-runtime` | Runtime migration helpers such as `copyMigrationFileItem` and `writeMigrationReport` | +| `plugin-sdk/migration-runtime` | Runtime migration helpers such as `copyMigrationFileItem`, `withCachedMigrationConfigRuntime`, and `writeMigrationReport` | diff --git a/extensions/migrate-claude/apply.ts b/extensions/migrate-claude/apply.ts index 6c520b3cc91..1347c8d4b6d 100644 --- a/extensions/migrate-claude/apply.ts +++ b/extensions/migrate-claude/apply.ts @@ -3,6 +3,7 @@ import { summarizeMigrationItems } from "openclaw/plugin-sdk/migration"; import { archiveMigrationItem, copyMigrationFileItem, + withCachedMigrationConfigRuntime, writeMigrationReport, } from "openclaw/plugin-sdk/migration-runtime"; import type { @@ -16,54 +17,6 @@ import { appendItem } from "./helpers.js"; import { buildClaudePlan } from "./plan.js"; import { applyGeneratedSkillItem } from "./skills.js"; -function withCachedConfigRuntime( - runtime: MigrationProviderContext["runtime"] | undefined, - fallbackConfig: MigrationProviderContext["config"], -): MigrationProviderContext["runtime"] | undefined { - if (!runtime) { - return undefined; - } - const configApi = runtime.config; - if (!configApi?.current || !configApi.mutateConfigFile) { - return runtime; - } - let cachedConfig: MigrationProviderContext["config"] | undefined; - const current = (): ReturnType => { - cachedConfig ??= structuredClone( - (configApi.current() ?? fallbackConfig) as MigrationProviderContext["config"], - ); - return cachedConfig; - }; - return { - ...runtime, - config: { - ...runtime.config, - current, - mutateConfigFile: async (params) => { - const result = await configApi.mutateConfigFile({ - ...params, - mutate: async (draft, context) => { - const mutationResult = await params.mutate(draft, context); - cachedConfig = structuredClone(draft); - return mutationResult; - }, - }); - cachedConfig = structuredClone(result.nextConfig); - return result; - }, - ...(configApi.replaceConfigFile - ? { - replaceConfigFile: async (params) => { - const result = await configApi.replaceConfigFile(params); - cachedConfig = structuredClone(result.nextConfig); - return result; - }, - } - : {}), - }, - }; -} - export async function applyClaudePlan(params: { ctx: MigrationProviderContext; plan?: MigrationPlan; @@ -71,7 +24,10 @@ export async function applyClaudePlan(params: { }): Promise { const plan = params.plan ?? (await buildClaudePlan(params.ctx)); const reportDir = params.ctx.reportDir ?? path.join(params.ctx.stateDir, "migration", "claude"); - const runtime = withCachedConfigRuntime(params.ctx.runtime ?? params.runtime, params.ctx.config); + const runtime = withCachedMigrationConfigRuntime( + params.ctx.runtime ?? params.runtime, + params.ctx.config, + ); const applyCtx = { ...params.ctx, runtime }; const items: MigrationItem[] = []; for (const item of plan.items) { diff --git a/extensions/migrate-hermes/apply.ts b/extensions/migrate-hermes/apply.ts index 7421d8b3604..8b8ecf75494 100644 --- a/extensions/migrate-hermes/apply.ts +++ b/extensions/migrate-hermes/apply.ts @@ -3,6 +3,7 @@ import { markMigrationItemSkipped, summarizeMigrationItems } from "openclaw/plug import { archiveMigrationItem, copyMigrationFileItem, + withCachedMigrationConfigRuntime, writeMigrationReport, } from "openclaw/plugin-sdk/migration-runtime"; import type { @@ -20,54 +21,6 @@ import { resolveTargets } from "./targets.js"; const HERMES_REASON_BLOCKED_BY_APPLY_CONFLICT = "blocked by earlier apply conflict"; -function withCachedConfigRuntime( - runtime: MigrationProviderContext["runtime"] | undefined, - fallbackConfig: MigrationProviderContext["config"], -): MigrationProviderContext["runtime"] | undefined { - if (!runtime) { - return undefined; - } - const configApi = runtime.config; - if (!configApi?.current || !configApi.mutateConfigFile) { - return runtime; - } - let cachedConfig: MigrationProviderContext["config"] | undefined; - const current = (): ReturnType => { - cachedConfig ??= structuredClone( - (configApi.current() ?? fallbackConfig) as MigrationProviderContext["config"], - ); - return cachedConfig; - }; - return { - ...runtime, - config: { - ...runtime.config, - current, - mutateConfigFile: async (params) => { - const result = await configApi.mutateConfigFile({ - ...params, - mutate: async (draft, context) => { - const mutationResult = await params.mutate(draft, context); - cachedConfig = structuredClone(draft); - return mutationResult; - }, - }); - cachedConfig = structuredClone(result.nextConfig); - return result; - }, - ...(configApi.replaceConfigFile - ? { - replaceConfigFile: async (params) => { - const result = await configApi.replaceConfigFile(params); - cachedConfig = structuredClone(result.nextConfig); - return result; - }, - } - : {}), - }, - }; -} - export async function applyHermesPlan(params: { ctx: MigrationProviderContext; plan?: MigrationPlan; @@ -77,7 +30,10 @@ export async function applyHermesPlan(params: { const reportDir = params.ctx.reportDir ?? path.join(params.ctx.stateDir, "migration", "hermes"); const targets = resolveTargets(params.ctx); const items: MigrationItem[] = []; - const runtime = withCachedConfigRuntime(params.ctx.runtime ?? params.runtime, params.ctx.config); + const runtime = withCachedMigrationConfigRuntime( + params.ctx.runtime ?? params.runtime, + params.ctx.config, + ); const applyCtx = { ...params.ctx, runtime }; let blockedByApplyConflict = false; for (const item of plan.items) { diff --git a/src/plugin-sdk/migration-runtime.test.ts b/src/plugin-sdk/migration-runtime.test.ts index ed7d648b62e..f722cf64cbf 100644 --- a/src/plugin-sdk/migration-runtime.test.ts +++ b/src/plugin-sdk/migration-runtime.test.ts @@ -2,14 +2,99 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { copyMigrationFileItem, writeMigrationReport } from "./migration-runtime.js"; +import { + copyMigrationFileItem, + withCachedMigrationConfigRuntime, + writeMigrationReport, +} from "./migration-runtime.js"; import { createMigrationItem } from "./migration.js"; +import type { MigrationProviderContext } from "./plugin-entry.js"; async function writeFile(filePath: string, contents: string): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, contents, "utf8"); } +describe("withCachedMigrationConfigRuntime", () => { + it("serves later config mutations from the same cached runtime snapshot", async () => { + type Runtime = NonNullable; + type RuntimeConfig = MigrationProviderContext["config"]; + type MutateConfigFileParams = Parameters[0]; + type ReplaceConfigFileParams = Parameters[0]; + type MutateConfigFileResult = Awaited>; + type ReplaceConfigFileResult = Awaited>; + + const fallbackConfig = { agents: { defaults: { model: { primary: "openai/base" } } } }; + let runtimeConfig: RuntimeConfig = structuredClone(fallbackConfig); + const current = vi.fn(() => runtimeConfig); + const mutateConfigFile = vi.fn( + async (params: MutateConfigFileParams): Promise => { + const draft = structuredClone(runtimeConfig) as RuntimeConfig; + const result = await params.mutate(draft, { + snapshot: {} as never, + previousHash: null, + }); + runtimeConfig = structuredClone(draft); + return { + path: "/tmp/openclaw.json", + previousHash: null, + snapshot: {} as never, + nextConfig: runtimeConfig, + afterWrite: { mode: "auto" }, + followUp: { mode: "auto", requiresRestart: false }, + result, + }; + }, + ); + const replaceConfigFile = vi.fn( + async (params: ReplaceConfigFileParams): Promise => { + runtimeConfig = structuredClone(params.nextConfig); + return { + path: "/tmp/openclaw.json", + previousHash: null, + snapshot: {} as never, + nextConfig: runtimeConfig, + afterWrite: { mode: "auto" }, + followUp: { mode: "auto", requiresRestart: false }, + }; + }, + ); + const runtime = { + config: { + current, + mutateConfigFile, + replaceConfigFile, + }, + } as unknown as Runtime; + + const wrapped = withCachedMigrationConfigRuntime(runtime, fallbackConfig); + expect(wrapped?.config.current()).toEqual(fallbackConfig); + runtimeConfig = { agents: { defaults: { model: { primary: "openai/external" } } } }; + + await wrapped?.config.mutateConfigFile({ + base: "runtime", + afterWrite: { mode: "auto" }, + mutate(draft) { + draft.agents ??= {}; + draft.agents.defaults ??= {}; + draft.agents.defaults.model = { primary: "openai/mutated" }; + }, + }); + expect(wrapped?.config.current()).toEqual({ + agents: { defaults: { model: { primary: "openai/mutated" } } }, + }); + + await wrapped?.config.replaceConfigFile({ + nextConfig: { agents: { defaults: { model: { primary: "openai/replaced" } } } }, + afterWrite: { mode: "auto" }, + }); + expect(wrapped?.config.current()).toEqual({ + agents: { defaults: { model: { primary: "openai/replaced" } } }, + }); + expect(current).toHaveBeenCalledTimes(1); + }); +}); + describe("copyMigrationFileItem", () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/src/plugin-sdk/migration-runtime.ts b/src/plugin-sdk/migration-runtime.ts index e8f97988c72..2fa19df0596 100644 --- a/src/plugin-sdk/migration-runtime.ts +++ b/src/plugin-sdk/migration-runtime.ts @@ -3,7 +3,11 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import type { MigrationApplyResult, MigrationItem } from "../plugins/types.js"; +import type { + MigrationApplyResult, + MigrationItem, + MigrationProviderContext, +} from "../plugins/types.js"; import { MIGRATION_REASON_MISSING_SOURCE_OR_TARGET, MIGRATION_REASON_TARGET_EXISTS, @@ -14,6 +18,54 @@ import { export type { MigrationApplyResult, MigrationItem } from "../plugins/types.js"; +export function withCachedMigrationConfigRuntime( + runtime: MigrationProviderContext["runtime"] | undefined, + fallbackConfig: MigrationProviderContext["config"], +): MigrationProviderContext["runtime"] | undefined { + if (!runtime) { + return undefined; + } + const configApi = runtime.config; + if (!configApi?.current || !configApi.mutateConfigFile) { + return runtime; + } + let cachedConfig: MigrationProviderContext["config"] | undefined; + const current = (): ReturnType => { + cachedConfig ??= structuredClone( + (configApi.current() ?? fallbackConfig) as MigrationProviderContext["config"], + ); + return cachedConfig; + }; + return { + ...runtime, + config: { + ...runtime.config, + current, + mutateConfigFile: async (params) => { + const result = await configApi.mutateConfigFile({ + ...params, + mutate: async (draft, context) => { + const mutationResult = await params.mutate(draft, context); + cachedConfig = structuredClone(draft); + return mutationResult; + }, + }); + cachedConfig = structuredClone(result.nextConfig); + return result; + }, + ...(configApi.replaceConfigFile + ? { + replaceConfigFile: async (params) => { + const result = await configApi.replaceConfigFile(params); + cachedConfig = structuredClone(result.nextConfig); + return result; + }, + } + : {}), + }, + }; +} + async function exists(filePath: string): Promise { try { await fs.access(filePath);