refactor(migration): share cached config runtime helper

This commit is contained in:
Peter Steinberger
2026-04-29 20:05:23 +01:00
parent 97e2f5b332
commit b0ae867034
7 changed files with 153 additions and 104 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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` |
<AccordionGroup>
<Accordion title="Channel subpaths">

View File

@@ -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<typeof configApi.current> => {
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<MigrationApplyResult> {
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) {

View File

@@ -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<typeof configApi.current> => {
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) {

View File

@@ -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<void> {
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<MigrationProviderContext["runtime"]>;
type RuntimeConfig = MigrationProviderContext["config"];
type MutateConfigFileParams = Parameters<Runtime["config"]["mutateConfigFile"]>[0];
type ReplaceConfigFileParams = Parameters<Runtime["config"]["replaceConfigFile"]>[0];
type MutateConfigFileResult = Awaited<ReturnType<Runtime["config"]["mutateConfigFile"]>>;
type ReplaceConfigFileResult = Awaited<ReturnType<Runtime["config"]["replaceConfigFile"]>>;
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<MutateConfigFileResult> => {
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<ReplaceConfigFileResult> => {
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();

View File

@@ -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<typeof configApi.current> => {
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<boolean> {
try {
await fs.access(filePath);